Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 50 additions & 1 deletion apps/desktop/src/electron/ElectronShell.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
Expand All @@ -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);
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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"));
Expand Down
47 changes: 32 additions & 15 deletions apps/desktop/src/electron/ElectronShell.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import {
REMOTE_CAPABLE_EDITOR_IDS,
remoteSchemeForEditor,
remoteOpenDefinitionForEditor,
type SystemSettingsPane,
} from "@t3tools/contracts";
import * as Context from "effect/Context";
Expand All @@ -24,23 +24,31 @@ const SYSTEM_SETTINGS_URLS: Record<SystemSettingsPane, string> = {
"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<string> {
if (typeof rawUrl !== "string") {
Expand All @@ -61,6 +69,7 @@ export class ElectronShell extends Context.Service<
ElectronShell,
{
readonly openExternal: (rawUrl: unknown) => Effect.Effect<boolean>;
readonly hasProtocolHandler: (scheme: string) => Effect.Effect<boolean>;
/** Opens a known System Settings pane by identifier, not by URL. */
readonly openSystemSettings: (pane: SystemSettingsPane) => Effect.Effect<boolean>;
readonly copyText: (text: string) => Effect.Effect<void>;
Expand All @@ -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(
Expand Down
15 changes: 11 additions & 4 deletions apps/desktop/src/ipc/methods/window.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
PickFolderOptionsSchema,
PRIMARY_LOCAL_ENVIRONMENT_ID,
REMOTE_CAPABLE_EDITOR_IDS,
remoteOpenDefinitionForEditor,
SystemSettingsPaneSchema,
type DesktopEnvironmentBootstrap,
type PickedThemeFile,
Expand Down Expand Up @@ -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<EditorId> = [];
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) {
Expand Down
2 changes: 2 additions & 0 deletions apps/desktop/src/window/DesktopWindow.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"]),
Expand Down Expand Up @@ -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"]),
Expand Down
12 changes: 11 additions & 1 deletion apps/web/src/remoteOpen.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
);
});
Expand Down
11 changes: 6 additions & 5 deletions apps/web/src/remoteOpen.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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<EditorId> = ["vscode"];
const REMOTE_FALLBACK_EDITORS: ReadonlyArray<EditorId> = ["vscode", "zed"];

let cachedProbedEditors: ReadonlyArray<EditorId> | null = null;

Expand Down
7 changes: 7 additions & 0 deletions docs/user/remote-access.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
62 changes: 39 additions & 23 deletions packages/contracts/src/editor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
* (`<scheme>://vscode-remote/ssh-remote+<host><path>`). 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 = [
Expand All @@ -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" },
Expand All @@ -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" },
Expand Down Expand Up @@ -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<EditorId> = 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 `<scheme>://vscode-remote/ssh-remote+<host><path>` 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}`;
};

/**
Expand Down
Loading
Loading