diff --git a/apps/desktop/src/electron/ElectronShell.test.ts b/apps/desktop/src/electron/ElectronShell.test.ts index caaa39d88c0d..f676c71a1ad6 100644 --- a/apps/desktop/src/electron/ElectronShell.test.ts +++ b/apps/desktop/src/electron/ElectronShell.test.ts @@ -2,12 +2,16 @@ import { assert, describe, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import { beforeEach, vi } from "vite-plus/test"; -const { openExternalMock, writeTextMock } = vi.hoisted(() => ({ +const { getApplicationNameForProtocolMock, openExternalMock, writeTextMock } = vi.hoisted(() => ({ + getApplicationNameForProtocolMock: vi.fn(), openExternalMock: vi.fn(), writeTextMock: vi.fn(), })); vi.mock("electron", () => ({ + app: { + getApplicationNameForProtocol: getApplicationNameForProtocolMock, + }, shell: { openExternal: openExternalMock, }, @@ -20,10 +24,23 @@ import * as ElectronShell from "./ElectronShell.ts"; describe("ElectronShell", () => { beforeEach(() => { + getApplicationNameForProtocolMock.mockReset(); openExternalMock.mockReset(); writeTextMock.mockReset(); }); + it.effect("detects registered editor protocol handlers", () => + Effect.gen(function* () { + getApplicationNameForProtocolMock.mockImplementation((url: string) => + url === "zed://" ? "Zed" : "", + ); + + const electronShell = yield* ElectronShell.ElectronShell; + assert.equal(yield* electronShell.hasProtocolHandler("zed"), true); + assert.equal(yield* electronShell.hasProtocolHandler("vscode"), false); + }).pipe(Effect.provide(ElectronShell.layer)), + ); + it.effect("opens safe external URLs", () => Effect.gen(function* () { openExternalMock.mockResolvedValue(undefined); @@ -88,6 +105,22 @@ describe("ElectronShell", () => { }).pipe(Effect.provide(ElectronShell.layer)), ); + it.effect("opens Zed remote SSH editor URLs", () => + Effect.gen(function* () { + openExternalMock.mockResolvedValue(undefined); + + const electronShell = yield* ElectronShell.ElectronShell; + const result = yield* electronShell.openExternal( + "zed://ssh/example.com/home/user/my%20project", + ); + + assert.equal(result, true); + assert.deepEqual(openExternalMock.mock.calls, [ + ["zed://ssh/example.com/home/user/my%20project"], + ]); + }).pipe(Effect.provide(ElectronShell.layer)), + ); + it.effect("does not open remote editor URLs with userinfo", () => Effect.gen(function* () { openExternalMock.mockResolvedValue(undefined); @@ -131,6 +164,22 @@ describe("ElectronShell", () => { }).pipe(Effect.provide(ElectronShell.layer)), ); + it.effect("does not open non-SSH or malformed Zed URLs", () => + Effect.gen(function* () { + openExternalMock.mockResolvedValue(undefined); + + const electronShell = yield* ElectronShell.ElectronShell; + const results = yield* Effect.all([ + electronShell.openExternal("zed://file/example.com/home/user/project"), + electronShell.openExternal("zed://user@ssh/example.com/home/user/project"), + electronShell.openExternal("zed://ssh/example.com"), + ]); + + assert.deepEqual(results, [false, false, false]); + assert.equal(openExternalMock.mock.calls.length, 0); + }).pipe(Effect.provide(ElectronShell.layer)), + ); + it.effect("returns false when Electron rejects openExternal", () => Effect.gen(function* () { openExternalMock.mockRejectedValue(new Error("open failed")); diff --git a/apps/desktop/src/electron/ElectronShell.ts b/apps/desktop/src/electron/ElectronShell.ts index cda9c2567b37..97c0eba7bdb8 100644 --- a/apps/desktop/src/electron/ElectronShell.ts +++ b/apps/desktop/src/electron/ElectronShell.ts @@ -1,6 +1,6 @@ import { REMOTE_CAPABLE_EDITOR_IDS, - remoteSchemeForEditor, + remoteOpenDefinitionForEditor, type SystemSettingsPane, } from "@t3tools/contracts"; import * as Context from "effect/Context"; @@ -24,23 +24,31 @@ const SYSTEM_SETTINGS_URLS: Record = { "x-apple.systempreferences:com.apple.settings.PrivacySecurity.extension?Privacy_AllFiles", }; -// Remote open-in-editor deep links (`vscode://vscode-remote/ssh-remote+…`) -// must reach the OS handler; every other non-web scheme stays blocked. +// Remote open-in-editor deep links must reach the OS handler; every other +// non-web scheme stays blocked. const SAFE_WEB_PROTOCOLS = new Set(["http:", "https:"]); -const REMOTE_EDITOR_PROTOCOLS = new Set( - REMOTE_CAPABLE_EDITOR_IDS.flatMap((id) => { - const scheme = remoteSchemeForEditor(id); - return scheme === undefined ? [] : [`${scheme}:`]; - }), -); +const REMOTE_EDITOR_OPEN_DEFINITIONS = REMOTE_CAPABLE_EDITOR_IDS.flatMap((id) => { + const definition = remoteOpenDefinitionForEditor(id); + return definition === undefined ? [] : [definition]; +}); + +const hasNoUrlCredentials = (url: URL) => url.username.length === 0 && url.password.length === 0; const isRemoteEditorUrl = (url: URL) => - REMOTE_EDITOR_PROTOCOLS.has(url.protocol) && - url.username.length === 0 && - url.password.length === 0 && - url.host === "vscode-remote" && - url.pathname.startsWith("/ssh-remote+") && - url.pathname.length > "/ssh-remote+".length; + hasNoUrlCredentials(url) && + REMOTE_EDITOR_OPEN_DEFINITIONS.some((definition) => { + if (url.protocol !== `${definition.scheme}:` || url.host !== definition.urlHost) { + return false; + } + + const pathPrefix = `/${definition.sshPathPrefix}`; + if (!url.pathname.startsWith(pathPrefix)) { + return false; + } + + const sshTargetAndPath = url.pathname.slice(pathPrefix.length); + return sshTargetAndPath.indexOf("/") > 0; + }); export function parseSafeExternalUrl(rawUrl: unknown): Option.Option { if (typeof rawUrl !== "string") { @@ -61,6 +69,7 @@ export class ElectronShell extends Context.Service< ElectronShell, { readonly openExternal: (rawUrl: unknown) => Effect.Effect; + readonly hasProtocolHandler: (scheme: string) => Effect.Effect; /** Opens a known System Settings pane by identifier, not by URL. */ readonly openSystemSettings: (pane: SystemSettingsPane) => Effect.Effect; readonly copyText: (text: string) => Effect.Effect; @@ -80,6 +89,14 @@ export const make = ElectronShell.of({ ), ), }), + hasProtocolHandler: (scheme) => + Effect.sync(() => { + try { + return Electron.app.getApplicationNameForProtocol(`${scheme}://`) !== ""; + } catch { + return false; + } + }), openSystemSettings: (pane) => Effect.promise(() => Electron.shell.openExternal(SYSTEM_SETTINGS_URLS[pane]).then( diff --git a/apps/desktop/src/ipc/methods/window.ts b/apps/desktop/src/ipc/methods/window.ts index 61de1361a311..b0695e01ce6a 100644 --- a/apps/desktop/src/ipc/methods/window.ts +++ b/apps/desktop/src/ipc/methods/window.ts @@ -9,6 +9,7 @@ import { PickFolderOptionsSchema, PRIMARY_LOCAL_ENVIRONMENT_ID, REMOTE_CAPABLE_EDITOR_IDS, + remoteOpenDefinitionForEditor, SystemSettingsPaneSchema, type DesktopEnvironmentBootstrap, type PickedThemeFile, @@ -313,13 +314,19 @@ export const probeRemoteEditors = DesktopIpc.makeIpcMethod({ channel: IpcChannels.PROBE_REMOTE_EDITORS_CHANNEL, payload: Schema.Undefined, result: Schema.Array(EditorId), - // Probes THIS machine (where the renderer runs) for remote-capable editor - // CLIs, unlike the server's probe which walks the environment host's PATH. - // A Finder-launched app can miss PATH entries; an empty result makes the - // renderer fall back to VS Code only, so that fails soft. + // Probes THIS machine (where the renderer runs), unlike the server's probe + // which walks the environment host's PATH. Protocol handlers cover packaged + // editors whose optional CLI is absent from a Finder-launched app's PATH. handler: Effect.fn("desktop.ipc.window.probeRemoteEditors")(function* () { + const shell = yield* ElectronShell.ElectronShell; const available: Array = []; for (const editorId of REMOTE_CAPABLE_EDITOR_IDS) { + const remoteOpen = remoteOpenDefinitionForEditor(editorId); + if (remoteOpen !== undefined && (yield* shell.hasProtocolHandler(remoteOpen.scheme))) { + available.push(editorId); + continue; + } + const commands = EDITORS.find((editor) => editor.id === editorId)?.commands; if (!commands) continue; for (const command of commands) { diff --git a/apps/desktop/src/window/DesktopWindow.test.ts b/apps/desktop/src/window/DesktopWindow.test.ts index cdf2a0a27dbe..e8d849e1d359 100644 --- a/apps/desktop/src/window/DesktopWindow.test.ts +++ b/apps/desktop/src/window/DesktopWindow.test.ts @@ -288,6 +288,7 @@ function makeTestLayer(input: { input.openedExternalUrls?.push(url); return true; }), + hasProtocolHandler: () => Effect.succeed(false), openSystemSettings: () => Effect.succeed(true), copyText: () => Effect.void, } satisfies ElectronShell.ElectronShell["Service"]), @@ -390,6 +391,7 @@ const makeSplashScenario = (createOutcomes: readonly (Electron.BrowserWindow | n electronMenuLayer, Layer.succeed(ElectronShell.ElectronShell, { openExternal: () => Effect.succeed(true), + hasProtocolHandler: () => Effect.succeed(false), openSystemSettings: () => Effect.succeed(true), copyText: () => Effect.void, } satisfies ElectronShell.ElectronShell["Service"]), diff --git a/apps/web/src/remoteOpen.test.ts b/apps/web/src/remoteOpen.test.ts index ff78967aa3dc..0f095b31313a 100644 --- a/apps/web/src/remoteOpen.test.ts +++ b/apps/web/src/remoteOpen.test.ts @@ -141,8 +141,18 @@ describe("buildRemoteOpenUrl", () => { ).toBe("vscode://vscode-remote/ssh-remote+sol/C%3A/Users/theo"); }); + it("builds a Zed SSH deep link", () => { + expect( + buildRemoteOpenUrl({ + editor: "zed", + host: "sol.tail1234.ts.net", + absolutePath: "/home/theo/code/my repo", + }), + ).toBe("zed://ssh/sol.tail1234.ts.net/home/theo/code/my%20repo"); + }); + it("returns undefined for editors without remote support", () => { - expect(buildRemoteOpenUrl({ editor: "zed", host: "sol", absolutePath: "/tmp/x" })).toBe( + expect(buildRemoteOpenUrl({ editor: "idea", host: "sol", absolutePath: "/tmp/x" })).toBe( undefined, ); }); diff --git a/apps/web/src/remoteOpen.ts b/apps/web/src/remoteOpen.ts index 7f23d408844e..6b8c721d1505 100644 --- a/apps/web/src/remoteOpen.ts +++ b/apps/web/src/remoteOpen.ts @@ -1,8 +1,8 @@ /** * Remote open-in-editor: when this client is not on the environment's - * machine, "Open" must hand the OS a `vscode://vscode-remote/ssh-remote+…` - * deep link (local editor connects over SSH) instead of exec'ing an editor - * on the environment host. + * machine, "Open" must hand the OS an editor-specific deep link (the local + * editor connects over SSH) instead of exec'ing an editor on the environment + * host. * * Host precedence: a desktop-SSH environment's real `~/.ssh/config` alias * beats server-advertised names; among advertised names the tailnet MagicDNS @@ -124,9 +124,10 @@ export function useRemoteOpenState(environmentId: EnvironmentId | null): RemoteO /** * Editors offered in remote-link mode. The desktop app probes the machine the - * renderer runs on; a browser cannot, so it offers VS Code only. + * renderer runs on; a browser cannot, so it offers the primary supported + * editors and lets the OS protocol handler decide whether they are installed. */ -const REMOTE_FALLBACK_EDITORS: ReadonlyArray = ["vscode"]; +const REMOTE_FALLBACK_EDITORS: ReadonlyArray = ["vscode", "zed"]; let cachedProbedEditors: ReadonlyArray | null = null; diff --git a/docs/user/remote-access.md b/docs/user/remote-access.md index b10123ab6223..d3ef1e4fec36 100644 --- a/docs/user/remote-access.md +++ b/docs/user/remote-access.md @@ -123,6 +123,13 @@ In the desktop app, open **Settings → Connections → Add environment**, choos or reuses a server there and opens the port forward for you. Projects, provider credentials, and agent work stay on the remote machine. +For an environment with an SSH route, **Open in VS Code** or **Open in Zed** +launches the editor on your current device and connects it to the remote project. +VS Code requires the Remote - SSH extension; Zed supports SSH projects directly. +Zed and Zed Preview share the `zed://` handler, so the release channel that last +registered it opens the link. To choose one, run **cli: register zed scheme** from +its command palette, then restart T3 Code Desktop. + The remote host needs a compatible [Node.js installation](./install.md#requirements) and [provider setup](./install.md#providers). If launch cannot find Node or reports an incompatible version, check it through a non-interactive SSH session: diff --git a/packages/contracts/src/editor.ts b/packages/contracts/src/editor.ts index 4115b07d7aad..29ab032ac511 100644 --- a/packages/contracts/src/editor.ts +++ b/packages/contracts/src/editor.ts @@ -10,12 +10,13 @@ type EditorDefinition = { readonly commands: readonly [string, ...string[]] | null; readonly baseArgs?: readonly string[]; readonly launchStyle: EditorLaunchStyle; - /** - * URL scheme for editors that support VS Code's remote deep links - * (`://vscode-remote/ssh-remote+`). Only set for VS Code - * and forks that ship the Remote-SSH machinery. - */ - readonly remoteScheme?: string; + readonly remoteOpen?: RemoteEditorOpenDefinition; +}; + +export type RemoteEditorOpenDefinition = { + readonly scheme: string; + readonly urlHost: string; + readonly sshPathPrefix: string; }; export const EDITORS = [ @@ -24,7 +25,7 @@ export const EDITORS = [ label: "Cursor", commands: ["cursor"], launchStyle: "goto", - remoteScheme: "cursor", + remoteOpen: { scheme: "cursor", urlHost: "vscode-remote", sshPathPrefix: "ssh-remote+" }, }, { id: "trae", label: "Trae", commands: ["trae"], launchStyle: "goto" }, { id: "kiro", label: "Kiro", commands: ["kiro"], baseArgs: ["ide"], launchStyle: "goto" }, @@ -33,23 +34,33 @@ export const EDITORS = [ label: "VS Code", commands: ["code"], launchStyle: "goto", - remoteScheme: "vscode", + remoteOpen: { scheme: "vscode", urlHost: "vscode-remote", sshPathPrefix: "ssh-remote+" }, }, { id: "vscode-insiders", label: "VS Code Insiders", commands: ["code-insiders"], launchStyle: "goto", - remoteScheme: "vscode-insiders", + remoteOpen: { + scheme: "vscode-insiders", + urlHost: "vscode-remote", + sshPathPrefix: "ssh-remote+", + }, }, { id: "vscodium", label: "VSCodium", commands: ["codium"], launchStyle: "goto", - remoteScheme: "vscodium", + remoteOpen: { scheme: "vscodium", urlHost: "vscode-remote", sshPathPrefix: "ssh-remote+" }, + }, + { + id: "zed", + label: "Zed", + commands: ["zed", "zeditor"], + launchStyle: "direct-path", + remoteOpen: { scheme: "zed", urlHost: "ssh", sshPathPrefix: "" }, }, - { id: "zed", label: "Zed", commands: ["zed", "zeditor"], launchStyle: "direct-path" }, { id: "antigravity", label: "Antigravity", commands: ["agy"], launchStyle: "goto" }, { id: "idea", label: "IntelliJ IDEA", commands: ["idea"], launchStyle: "line-column" }, { id: "aqua", label: "Aqua", commands: ["aqua"], launchStyle: "line-column" }, @@ -82,37 +93,42 @@ export const LaunchEditorInput = Schema.Struct({ }); export type LaunchEditorInput = typeof LaunchEditorInput.Type; -const remoteSchemeOf = (editor: EditorDefinition): string | undefined => editor.remoteScheme; +const remoteOpenOf = (editor: EditorDefinition): RemoteEditorOpenDefinition | undefined => + editor.remoteOpen; -/** Editors that can open a remote workspace via `vscode-remote` deep links. */ +/** Editors that can open a remote workspace over SSH from the viewing machine. */ export const REMOTE_CAPABLE_EDITOR_IDS: ReadonlyArray = EDITORS.flatMap((editor) => - remoteSchemeOf(editor) !== undefined ? [editor.id] : [], + remoteOpenOf(editor) !== undefined ? [editor.id] : [], ); -export const remoteSchemeForEditor = (id: EditorId): string | undefined => { +export const remoteOpenDefinitionForEditor = ( + id: EditorId, +): RemoteEditorOpenDefinition | undefined => { const editor = EDITORS.find((candidate) => candidate.id === id); - return editor === undefined ? undefined : remoteSchemeOf(editor); + return editor === undefined ? undefined : remoteOpenOf(editor); }; /** - * Builds a `://vscode-remote/ssh-remote+` deep link that - * opens `absolutePath` on `host` in the local editor over SSH. Returns - * undefined for editors without remote deep-link support. + * Builds the editor-specific deep link that opens `absolutePath` on `host` + * from the viewing machine over SSH. Returns undefined for editors without + * remote deep-link support. */ export const buildRemoteOpenUrl = (input: { readonly editor: EditorId; readonly host: string; readonly absolutePath: string; }): string | undefined => { - const scheme = remoteSchemeForEditor(input.editor); - if (scheme === undefined) { + const remoteOpen = remoteOpenDefinitionForEditor(input.editor); + if (remoteOpen === undefined) { return undefined; } - // Windows server paths (`C:\...`) appear as `/C:/...` in vscode-remote URIs. + // Windows server paths (`C:\...`) appear as `/C:/...` in remote editor URIs. const posixPath = input.absolutePath.replaceAll("\\", "/"); const rootedPath = posixPath.startsWith("/") ? posixPath : `/${posixPath}`; const encodedPath = rootedPath.split("/").map(encodeURIComponent).join("/"); - return `${scheme}://vscode-remote/ssh-remote+${encodeURIComponent(input.host)}${encodedPath}`; + const encodedHost = encodeURIComponent(input.host); + + return `${remoteOpen.scheme}://${remoteOpen.urlHost}/${remoteOpen.sshPathPrefix}${encodedHost}${encodedPath}`; }; /** diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index cd906aecdfce..083e9e036994 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -1302,9 +1302,10 @@ export interface DesktopBridge { */ openSystemSettings?: (pane: SystemSettingsPane) => Promise; /** - * Probe this desktop machine for installed remote-capable editor CLIs - * (used for remote open-in-editor deep links). Optional: older desktop - * builds lack it; callers fall back to VS Code only. + * Probe this desktop machine for installed remote editor CLIs or registered + * protocol handlers. A handler can make an editor available without its CLI + * on PATH. Optional: older desktop builds lack it; callers fall back to the + * primary supported editors. */ probeRemoteEditors?: () => Promise; onMenuAction: (listener: (action: string) => void) => () => void;