diff --git a/apps/desktop/src/ipc/DesktopIpcHandlers.ts b/apps/desktop/src/ipc/DesktopIpcHandlers.ts index e478d0c6eff..474d2c55681 100644 --- a/apps/desktop/src/ipc/DesktopIpcHandlers.ts +++ b/apps/desktop/src/ipc/DesktopIpcHandlers.ts @@ -37,6 +37,7 @@ import { getLocalEnvironmentBearerToken, getWindowFullscreenState, openExternal, + openRemoteZed, pickFolder, setTheme, showContextMenu, @@ -82,6 +83,7 @@ export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers" yield* ipc.handle(confirm); yield* ipc.handle(setTheme); yield* ipc.handle(showContextMenu); + yield* ipc.handle(openRemoteZed); yield* ipc.handle(openExternal); yield* ipc.handle(getUpdateState); yield* ipc.handle(setUpdateChannel); diff --git a/apps/desktop/src/ipc/channels.ts b/apps/desktop/src/ipc/channels.ts index 5988b1e42f9..48d8de71f66 100644 --- a/apps/desktop/src/ipc/channels.ts +++ b/apps/desktop/src/ipc/channels.ts @@ -2,6 +2,7 @@ export const PICK_FOLDER_CHANNEL = "desktop:pick-folder"; export const CONFIRM_CHANNEL = "desktop:confirm"; export const SET_THEME_CHANNEL = "desktop:set-theme"; export const CONTEXT_MENU_CHANNEL = "desktop:context-menu"; +export const OPEN_REMOTE_ZED_CHANNEL = "desktop:open-remote-zed"; export const OPEN_EXTERNAL_CHANNEL = "desktop:open-external"; export const MENU_ACTION_CHANNEL = "desktop:menu-action"; export const GET_WINDOW_FULLSCREEN_STATE_CHANNEL = "desktop:get-window-fullscreen-state"; diff --git a/apps/desktop/src/ipc/methods/window.ts b/apps/desktop/src/ipc/methods/window.ts index a4e98aaabad..feca70f142a 100644 --- a/apps/desktop/src/ipc/methods/window.ts +++ b/apps/desktop/src/ipc/methods/window.ts @@ -2,6 +2,7 @@ import { ContextMenuItemSchema, DesktopAppBrandingSchema, DesktopEnvironmentBootstrapSchema, + DesktopOpenRemoteZedInputSchema, DesktopThemeSchema, PickFolderOptionsSchema, PRIMARY_LOCAL_ENVIRONMENT_ID, @@ -17,6 +18,7 @@ import * as DesktopEnvironment from "../../app/DesktopEnvironment.ts"; import * as DesktopAppSettings from "../../settings/DesktopAppSettings.ts"; import * as DesktopWslBackend from "../../wsl/DesktopWslBackend.ts"; import * as DesktopWslEnvironment from "../../wsl/DesktopWslEnvironment.ts"; +import * as DesktopZedLauncher from "../../shell/DesktopZedLauncher.ts"; import * as ElectronDialog from "../../electron/ElectronDialog.ts"; import * as ElectronMenu from "../../electron/ElectronMenu.ts"; import * as ElectronShell from "../../electron/ElectronShell.ts"; @@ -268,3 +270,13 @@ export const openExternal = DesktopIpc.makeIpcMethod({ return yield* shell.openExternal(url); }), }); + +export const openRemoteZed = DesktopIpc.makeIpcMethod({ + channel: IpcChannels.OPEN_REMOTE_ZED_CHANNEL, + payload: DesktopOpenRemoteZedInputSchema, + result: Schema.Void, + handler: Effect.fn("desktop.ipc.window.openRemoteZed")(function* (input) { + const launcher = yield* DesktopZedLauncher.DesktopZedLauncher; + yield* launcher.openRemoteWorkspace(input); + }), +}); diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 933a7e4d831..e3da8517835 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -51,6 +51,7 @@ import * as DesktopClientSettings from "./settings/DesktopClientSettings.ts"; import * as DesktopSavedEnvironments from "./settings/DesktopSavedEnvironments.ts"; import * as DesktopAppSettings from "./settings/DesktopAppSettings.ts"; import * as DesktopShellEnvironment from "./shell/DesktopShellEnvironment.ts"; +import * as DesktopZedLauncher from "./shell/DesktopZedLauncher.ts"; import * as DesktopSshEnvironment from "./ssh/DesktopSshEnvironment.ts"; import * as DesktopSshPasswordPrompts from "./ssh/DesktopSshPasswordPrompts.ts"; import * as DesktopState from "./app/DesktopState.ts"; @@ -184,6 +185,7 @@ const desktopApplicationLayer = Layer.mergeAll( DesktopApplicationMenu.layer, DesktopLinuxUrlHandler.layer, DesktopShellEnvironment.layer, + DesktopZedLauncher.layer, desktopSshLayer, ).pipe( Layer.provideMerge(DesktopUpdates.layer), diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index 9f01baeed90..e471e913e1e 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -104,6 +104,7 @@ contextBridge.exposeInMainWorld("desktopBridge", { items, ...(position === undefined ? {} : { position }), }), + openRemoteZed: (input) => ipcRenderer.invoke(IpcChannels.OPEN_REMOTE_ZED_CHANNEL, input), openExternal: (url: string) => ipcRenderer.invoke(IpcChannels.OPEN_EXTERNAL_CHANNEL, url), onMenuAction: (listener) => { const wrappedListener = (_event: Electron.IpcRendererEvent, action: unknown) => { diff --git a/apps/desktop/src/shell/DesktopZedLauncher.test.ts b/apps/desktop/src/shell/DesktopZedLauncher.test.ts new file mode 100644 index 00000000000..7e90e74f75e --- /dev/null +++ b/apps/desktop/src/shell/DesktopZedLauncher.test.ts @@ -0,0 +1,186 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, describe, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Path from "effect/Path"; +import * as Sink from "effect/Sink"; +import * as Stream from "effect/Stream"; +import * as ChildProcess from "effect/unstable/process/ChildProcess"; +import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; + +import * as DesktopZedLauncher from "./DesktopZedLauncher.ts"; + +function makeDetachedHandle(onUnref: () => void): ChildProcessSpawner.ChildProcessHandle { + return ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(1), + exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(0)), + isRunning: Effect.succeed(true), + kill: () => Effect.void, + unref: Effect.sync(() => { + onUnref(); + return Effect.void; + }), + stdin: Sink.drain, + stdout: Stream.empty, + stderr: Stream.empty, + all: Stream.empty, + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.empty, + }); +} + +describe("DesktopZedLauncher", () => { + it("builds Zed's encoded SSH URI from the connection target", () => { + assert.equal( + DesktopZedLauncher.remoteZedSshUri({ + target: { + alias: "devbox", + hostname: "devbox.example.com", + username: "declan", + port: 2222, + }, + path: "~/code/project alpha", + }), + "ssh://declan@devbox:2222/~/code/project%20alpha", + ); + assert.equal( + DesktopZedLauncher.remoteZedSshUri({ + target: { + alias: "", + hostname: "devbox.example.com", + username: null, + port: null, + }, + path: "/srv/project", + }), + "ssh://devbox.example.com/srv/project", + ); + assert.equal( + DesktopZedLauncher.remoteZedSshUri({ + target: { + alias: "", + hostname: "2001:db8::1", + username: "declan", + port: 2222, + }, + path: "/srv/project", + }), + "ssh://declan@[2001:db8::1]:2222/srv/project", + ); + }); + + it.effect.each<{ + availableCommands: ReadonlyArray<"zed" | "zeditor">; + expectedCommand: "zed" | "zeditor"; + }>([ + { availableCommands: ["zed", "zeditor"], expectedCommand: "zed" }, + { availableCommands: ["zeditor"], expectedCommand: "zeditor" }, + ])( + "launches and detaches $expectedCommand when available", + ({ availableCommands, expectedCommand }) => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const binDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-zed-" }); + for (const command of availableCommands) { + const commandPath = path.join(binDir, command); + yield* fileSystem.writeFileString(commandPath, "#!/bin/sh\n"); + yield* fileSystem.chmod(commandPath, 0o755); + } + + let spawned: ChildProcess.StandardCommand | undefined; + let didUnref = false; + const spawnerLayer = Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make((command) => + Effect.sync(() => { + assert.equal(ChildProcess.isStandardCommand(command), true); + if (!ChildProcess.isStandardCommand(command)) { + throw new Error("Expected a standard command"); + } + spawned = command; + return makeDetachedHandle(() => { + didUnref = true; + }); + }), + ), + ); + const previousPath = process.env.PATH; + process.env.PATH = binDir; + + yield* Effect.gen(function* () { + const launcher = yield* DesktopZedLauncher.DesktopZedLauncher; + yield* launcher.openRemoteWorkspace({ + target: { + alias: "devbox", + hostname: "devbox.example.com", + username: null, + port: null, + }, + path: "/srv/project", + }); + }).pipe( + Effect.ensuring( + Effect.sync(() => { + process.env.PATH = previousPath; + }), + ), + Effect.provide( + DesktopZedLauncher.layer.pipe( + Layer.provide(Layer.merge(NodeServices.layer, spawnerLayer)), + ), + ), + ); + + assert.ok(spawned); + assert.equal(spawned.command, expectedCommand); + assert.deepEqual(spawned.args, ["-r", "ssh://devbox/srv/project"]); + assert.equal(spawned.options.detached, true); + assert.equal(didUnref, true); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); + + it("keeps launch errors free of SSH connection details", () => { + const error = new DesktopZedLauncher.DesktopZedLaunchError({ + argumentCount: 2, + cause: new Error("spawn failed"), + }); + + assert.equal(error.message, "Failed to open remote workspace in Zed."); + assert.equal(error.argumentCount, 2); + }); + + it.effect("fails clearly when no local Zed CLI is available", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const binDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-no-zed-" }); + const previousPath = process.env.PATH; + process.env.PATH = binDir; + + const error = yield* Effect.gen(function* () { + const launcher = yield* DesktopZedLauncher.DesktopZedLauncher; + return yield* launcher.openRemoteWorkspace({ + target: { + alias: "devbox", + hostname: "devbox.example.com", + username: null, + port: null, + }, + path: "/srv/project", + }); + }).pipe( + Effect.flip, + Effect.ensuring( + Effect.sync(() => { + process.env.PATH = previousPath; + }), + ), + Effect.provide(DesktopZedLauncher.layer.pipe(Layer.provide(NodeServices.layer))), + ); + + assert.equal(error._tag, "DesktopZedCommandNotFoundError"); + assert.equal(error.message, "Zed CLI not found."); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); +}); diff --git a/apps/desktop/src/shell/DesktopZedLauncher.ts b/apps/desktop/src/shell/DesktopZedLauncher.ts new file mode 100644 index 00000000000..361beed5144 --- /dev/null +++ b/apps/desktop/src/shell/DesktopZedLauncher.ts @@ -0,0 +1,122 @@ +import { + type DesktopOpenRemoteZedInput, + type DesktopSshEnvironmentTarget, +} from "@t3tools/contracts"; +import { isCommandAvailable, resolveSpawnCommand } from "@t3tools/shared/shell"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; +import * as ChildProcess from "effect/unstable/process/ChildProcess"; +import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; + +const ZED_COMMANDS = ["zed", "zeditor"] as const; + +export class DesktopZedCommandNotFoundError extends Schema.TaggedErrorClass()( + "DesktopZedCommandNotFoundError", + {}, +) { + override get message(): string { + return "Zed CLI not found."; + } +} + +export class DesktopZedLaunchError extends Schema.TaggedErrorClass()( + "DesktopZedLaunchError", + { + argumentCount: Schema.Number, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return "Failed to open remote workspace in Zed."; + } +} + +function remoteAuthority(target: DesktopSshEnvironmentTarget): string { + const rawHost = target.alias.trim() || target.hostname.trim(); + const host = rawHost.includes(":") && !rawHost.startsWith("[") ? `[${rawHost}]` : rawHost; + const username = target.username?.trim(); + const port = target.port === null ? "" : `:${target.port}`; + return `${username ? `${username}@` : ""}${host}${port}`; +} + +function remoteUriPath(path: string): string { + const normalized = path === "~" ? "/~" : path.startsWith("~/") ? `/~/${path.slice(2)}` : path; + const absolute = normalized.startsWith("/") ? normalized : `/${normalized}`; + return absolute + .split("/") + .map((segment) => encodeURIComponent(segment)) + .join("/"); +} + +export function remoteZedSshUri(input: DesktopOpenRemoteZedInput): string { + return `ssh://${remoteAuthority(input.target)}${remoteUriPath(input.path)}`; +} + +export class DesktopZedLauncher extends Context.Service< + DesktopZedLauncher, + { + readonly openRemoteWorkspace: ( + input: DesktopOpenRemoteZedInput, + ) => Effect.Effect; + } +>()("@t3tools/desktop/shell/DesktopZedLauncher") {} + +export const make = Effect.gen(function* () { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + + const openRemoteWorkspace = Effect.fn("desktop.zed.openRemoteWorkspace")(function* ( + input: DesktopOpenRemoteZedInput, + ) { + let command = Option.none(); + for (const candidate of ZED_COMMANDS) { + if ( + yield* isCommandAvailable(candidate).pipe( + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideService(Path.Path, path), + ) + ) { + command = Option.some(candidate); + break; + } + } + if (Option.isNone(command)) { + return yield* new DesktopZedCommandNotFoundError(); + } + + const args = ["-r", remoteZedSshUri(input)]; + const resolved = yield* resolveSpawnCommand(command.value, args).pipe( + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideService(Path.Path, path), + ); + const process = ChildProcess.make(resolved.command, resolved.args, { + detached: true, + shell: resolved.shell, + stdin: "ignore", + stdout: "ignore", + stderr: "ignore", + }); + yield* spawner.spawn(process).pipe( + Effect.flatMap((handle) => handle.unref), + Effect.asVoid, + Effect.scoped, + Effect.mapError( + (cause) => + new DesktopZedLaunchError({ + argumentCount: resolved.args.length, + cause, + }), + ), + ); + }); + + return DesktopZedLauncher.of({ openRemoteWorkspace }); +}); + +export const layer = Layer.effect(DesktopZedLauncher, make); diff --git a/apps/web/src/components/chat/ChatHeader.test.ts b/apps/web/src/components/chat/ChatHeader.test.ts index d716092fc3e..aa06086168a 100644 --- a/apps/web/src/components/chat/ChatHeader.test.ts +++ b/apps/web/src/components/chat/ChatHeader.test.ts @@ -36,6 +36,28 @@ describe("shouldShowOpenInPicker", () => { ).toBe(false); }); + it("shows the picker for SSH environments with local Zed launch support", () => { + expect( + shouldShowOpenInPicker({ + activeProjectName: "codething-mvp", + activeThreadEnvironmentId: EnvironmentId.make("environment-remote"), + primaryEnvironmentId, + remoteZedAvailable: true, + }), + ).toBe(true); + }); + + it("hides the picker for SSH environments without local Zed launch support", () => { + expect( + shouldShowOpenInPicker({ + activeProjectName: "codething-mvp", + activeThreadEnvironmentId: EnvironmentId.make("environment-remote"), + primaryEnvironmentId, + remoteZedAvailable: false, + }), + ).toBe(false); + }); + it("hides the picker when there is no active project", () => { expect( shouldShowOpenInPicker({ diff --git a/apps/web/src/components/chat/ChatHeader.tsx b/apps/web/src/components/chat/ChatHeader.tsx index 0adeed6ffa6..773c9850fe7 100644 --- a/apps/web/src/components/chat/ChatHeader.tsx +++ b/apps/web/src/components/chat/ChatHeader.tsx @@ -6,6 +6,7 @@ import { type ThreadId, } from "@t3tools/contracts"; import { scopeThreadRef } from "@t3tools/client-runtime/environment"; +import * as Option from "effect/Option"; import { memo } from "react"; import GitActionsControl from "../GitActionsControl"; import { type DraftId } from "~/composerDraftStore"; @@ -15,7 +16,7 @@ import ProjectScriptsControl, { type ProjectScriptActionResult, } from "../ProjectScriptsControl"; import { OpenInPicker } from "./OpenInPicker"; -import { usePrimaryEnvironmentId } from "../../state/environments"; +import { useEnvironment, usePrimaryEnvironmentId } from "../../state/environments"; import { useT3ProjectFileScripts } from "~/hooks/useT3ProjectFileScripts"; import { ProjectFavicon } from "../ProjectFavicon"; import { cn } from "~/lib/utils"; @@ -48,11 +49,13 @@ export function shouldShowOpenInPicker(input: { readonly activeProjectName: string | undefined; readonly activeThreadEnvironmentId: EnvironmentId; readonly primaryEnvironmentId: EnvironmentId | null; + readonly remoteZedAvailable?: boolean; }): boolean { return ( Boolean(input.activeProjectName) && - input.primaryEnvironmentId !== null && - input.activeThreadEnvironmentId === input.primaryEnvironmentId + ((input.primaryEnvironmentId !== null && + input.activeThreadEnvironmentId === input.primaryEnvironmentId) || + input.remoteZedAvailable === true) ); } @@ -81,11 +84,21 @@ export const ChatHeader = memo(function ChatHeader({ activeThreadEnvironmentId, activeProjectScripts ? activeProjectCwd : null, ); + const activeEnvironment = useEnvironment(activeThreadEnvironmentId); + const sshProfile = + activeEnvironment && + Option.isSome(activeEnvironment.entry.profile) && + activeEnvironment.entry.profile.value._tag === "SshConnectionProfile" + ? activeEnvironment.entry.profile.value + : null; + const remoteZedAvailable = sshProfile !== null && window.desktopBridge !== undefined; const showOpenInPicker = shouldShowOpenInPicker({ activeProjectName, activeThreadEnvironmentId, primaryEnvironmentId, + remoteZedAvailable, }); + const openInEditors = sshProfile ? (["zed"] as const) : availableEditors; return (
@@ -156,8 +169,9 @@ export const ChatHeader = memo(function ChatHeader({ )} {activeProjectName && ( diff --git a/apps/web/src/components/chat/OpenInPicker.tsx b/apps/web/src/components/chat/OpenInPicker.tsx index 8b7a96880b8..c4d0c50ee26 100644 --- a/apps/web/src/components/chat/OpenInPicker.tsx +++ b/apps/web/src/components/chat/OpenInPicker.tsx @@ -1,4 +1,9 @@ -import { EditorId, type EnvironmentId, type ResolvedKeybindingsConfig } from "@t3tools/contracts"; +import { + EditorId, + type DesktopSshEnvironmentTarget, + type EnvironmentId, + type ResolvedKeybindingsConfig, +} from "@t3tools/contracts"; import { memo, useCallback, useEffect, useMemo } from "react"; import { isOpenFavoriteEditorShortcut, shortcutLabelForCommand } from "../../keybindings"; import { usePreferredEditor } from "../../editorPreferences"; @@ -6,6 +11,7 @@ import { ChevronDownIcon, FolderClosedIcon } from "lucide-react"; import { Button } from "../ui/button"; import { Group, GroupSeparator } from "../ui/group"; import { Menu, MenuItem, MenuPopup, MenuShortcut, MenuTrigger } from "../ui/menu"; +import { toastManager } from "../ui/toast"; import { AntigravityIcon, CursorIcon, @@ -188,6 +194,7 @@ export const OpenInPicker = memo(function OpenInPicker({ keybindings, availableEditors, openInCwd, + remoteZedTarget, compact = false, enableShortcut = true, }: { @@ -195,6 +202,7 @@ export const OpenInPicker = memo(function OpenInPicker({ keybindings: ResolvedKeybindingsConfig; availableEditors: ReadonlyArray; openInCwd: string | null; + remoteZedTarget?: DesktopSshEnvironmentTarget; compact?: boolean; enableShortcut?: boolean; }) { @@ -211,6 +219,23 @@ export const OpenInPicker = memo(function OpenInPicker({ if (!openInCwd) return; const editor = editorId ?? preferredEditor; if (!editor) return; + if (remoteZedTarget) { + if (editor !== "zed" || !window.desktopBridge) return; + const result = window.desktopBridge + .openRemoteZed({ + target: remoteZedTarget, + path: openInCwd, + }) + .catch((error: unknown) => { + toastManager.add({ + type: "error", + title: "Unable to open remote workspace in Zed", + description: error instanceof Error ? error.message : "Zed launch failed.", + }); + }); + setPreferredEditor(editor); + return result; + } const result = openInEditorMutation({ environmentId, input: { @@ -221,7 +246,14 @@ export const OpenInPicker = memo(function OpenInPicker({ setPreferredEditor(editor); return result; }, - [environmentId, openInCwd, openInEditorMutation, preferredEditor, setPreferredEditor], + [ + environmentId, + openInCwd, + openInEditorMutation, + preferredEditor, + remoteZedTarget, + setPreferredEditor, + ], ); const openFavoriteEditorShortcutLabel = useMemo( @@ -237,24 +269,11 @@ export const OpenInPicker = memo(function OpenInPicker({ if (!preferredEditor) return; e.preventDefault(); - void openInEditorMutation({ - environmentId, - input: { - cwd: openInCwd, - editor: preferredEditor, - }, - }); + void openInEditor(preferredEditor); }; window.addEventListener("keydown", handler); return () => window.removeEventListener("keydown", handler); - }, [ - enableShortcut, - environmentId, - keybindings, - openInCwd, - openInEditorMutation, - preferredEditor, - ]); + }, [enableShortcut, keybindings, openInCwd, openInEditor, preferredEditor]); return ( diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index a92eb972d67..91f19631e70 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -292,6 +292,12 @@ export const DesktopSshEnvironmentTargetSchema = Schema.Struct({ }); export type DesktopSshEnvironmentTarget = typeof DesktopSshEnvironmentTargetSchema.Type; +export const DesktopOpenRemoteZedInputSchema = Schema.Struct({ + target: DesktopSshEnvironmentTargetSchema, + path: Schema.String.check(Schema.isTrimmed()).check(Schema.isNonEmpty()), +}); +export type DesktopOpenRemoteZedInput = typeof DesktopOpenRemoteZedInputSchema.Type; + export type DesktopSshHostSource = "ssh-config" | "known-hosts"; export const DesktopSshHostSourceSchema = Schema.Literals(["ssh-config", "known-hosts"]); @@ -1006,6 +1012,7 @@ export interface DesktopBridge { items: readonly ContextMenuItem[], position?: { x: number; y: number }, ) => Promise; + openRemoteZed: (input: DesktopOpenRemoteZedInput) => Promise; openExternal: (url: string) => Promise; onMenuAction: (listener: (action: string) => void) => () => void; getWindowFullscreenState: () => boolean;