Skip to content
Merged
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
82 changes: 81 additions & 1 deletion apps/desktop/src/electron/ElectronShell.test.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,18 @@
import { assert, describe, it } from "@effect/vitest";
import { buildRemoteOpenUrl } from "@t3tools/contracts";
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 +25,34 @@ 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("treats failed protocol lookups as unavailable", () =>
Effect.gen(function* () {
getApplicationNameForProtocolMock.mockImplementation(() => {
throw new Error("protocol lookup unavailable");
});

const electronShell = yield* ElectronShell.ElectronShell;
assert.equal(yield* electronShell.hasProtocolHandler("zed"), false);
}).pipe(Effect.provide(ElectronShell.layer)),
);

it.effect("opens safe external URLs", () =>
Effect.gen(function* () {
openExternalMock.mockResolvedValue(undefined);
Expand Down Expand Up @@ -66,6 +95,39 @@ 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("hands a generated Zed worktree link to the OS without losing path characters", () =>
Effect.gen(function* () {
openExternalMock.mockResolvedValue(undefined);
const url = buildRemoteOpenUrl({
editor: "zed",
host: "zeus",
absolutePath: "/home/agent/code/my repo/feature#1%done",
});

const electronShell = yield* ElectronShell.ElectronShell;
assert.equal(yield* electronShell.openExternal(url), true);
assert.deepEqual(openExternalMock.mock.calls, [
["zed://ssh/zeus/home/agent/code/my%20repo/feature%231%25done"],
]);
}).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 @@ -109,6 +171,24 @@ 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"),
electronShell.openExternal("zed://ssh//home/user/project"),
electronShell.openExternal("zed://:secret@ssh/example.com/home/user/project"),
]);

assert.deepEqual(results, [false, false, 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 @@ -79,6 +88,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
55 changes: 54 additions & 1 deletion apps/desktop/src/ipc/methods/window.test.ts
Original file line number Diff line number Diff line change
@@ -1,24 +1,77 @@
import { assert, describe, it } from "@effect/vitest";
import { isCommandAvailable } from "@t3tools/shared/shell";
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 { vi } from "vite-plus/test";
import * as Path from "effect/Path";
import { beforeEach, vi } from "vite-plus/test";

import type * as Electron from "electron";

import * as DesktopBackendManager from "../../backend/DesktopBackendManager.ts";
import * as DesktopBackendPool from "../../backend/DesktopBackendPool.ts";
import * as ElectronDialog from "../../electron/ElectronDialog.ts";
import * as ElectronNotification from "../../electron/ElectronNotification.ts";
import * as ElectronShell from "../../electron/ElectronShell.ts";
import * as ElectronWindow from "../../electron/ElectronWindow.ts";
import { THREAD_COMPLETION_NOTIFICATION_CLICK_CHANNEL } from "../channels.ts";
import {
getLocalEnvironmentBootstraps,
getWindowFullscreenState,
pickProjectFavicon,
probeRemoteEditors,
showThreadCompletionNotification,
} from "./window.ts";

vi.mock("@t3tools/shared/shell", async (importOriginal) => ({
...(await importOriginal<typeof import("@t3tools/shared/shell")>()),
isCommandAvailable: vi.fn(),
}));

describe("probeRemoteEditors", () => {
beforeEach(() => {
vi.mocked(isCommandAvailable).mockReset();
vi.mocked(isCommandAvailable).mockReturnValue(Effect.succeed(false));
});

const shellLayer = (registeredSchemes: ReadonlyArray<string>) =>
Layer.mergeAll(
Layer.succeed(ElectronShell.ElectronShell, {
hasProtocolHandler: (scheme) => Effect.succeed(registeredSchemes.includes(scheme)),
openExternal: () => Effect.succeed(true),
openSystemSettings: () => Effect.succeed(true),
copyText: () => Effect.void,
}),
FileSystem.layerNoop({}),
Path.layer,
);

it.effect("finds Zed through its registered handler when no editor CLI is on PATH", () =>
Effect.gen(function* () {
assert.deepEqual(yield* probeRemoteEditors.handler(undefined), ["zed"]);
}).pipe(Effect.provide(shellLayer(["zed"]))),
);

it.effect("keeps CLI discovery and does not duplicate editors with a handler", () =>
Effect.gen(function* () {
vi.mocked(isCommandAvailable).mockImplementation((command) =>
Effect.succeed(command === "cursor" || command === "zed"),
);
assert.deepEqual(yield* probeRemoteEditors.handler(undefined), ["cursor", "zed"]);
}).pipe(Effect.provide(shellLayer(["zed"]))),
);

it.effect("supports the zeditor CLI alias when protocol detection is unavailable", () =>
Effect.gen(function* () {
vi.mocked(isCommandAvailable).mockImplementation((command) =>
Effect.succeed(command === "zeditor"),
);
assert.deepEqual(yield* probeRemoteEditors.handler(undefined), ["zed"]);
}).pipe(Effect.provide(shellLayer([]))),
);
});

const readyWslConfig: DesktopBackendManager.DesktopBackendStartConfig = {
executablePath: "wsl.exe",
args: ["-d", "Ubuntu", "--", "node", "/app/bin.mjs"],
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 @@ -10,6 +10,7 @@ import {
PickFolderOptionsSchema,
PRIMARY_LOCAL_ENVIRONMENT_ID,
REMOTE_CAPABLE_EDITOR_IDS,
remoteOpenDefinitionForEditor,
SystemSettingsPaneSchema,
type DesktopEnvironmentBootstrap,
type PickedThemeFile,
Expand Down Expand Up @@ -346,13 +347,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 @@ -280,6 +280,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 @@ -381,6 +382,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
15 changes: 15 additions & 0 deletions docs/personal-fork-changes.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,21 @@ worktree-grouped web/desktop threads, and cross-environment chat transfer. Every

This file is both the current inventory and the retirement record used during upstream syncs.

## Pending upstream integration

### Open remote projects in Zed

- The web/desktop Open menu offers Zed for environments with an SSH route and opens the current
project or worktree through the local `zed://ssh/<host>/<path>` handler. Desktop detection checks
registered protocol handlers as well as editor commands, so a Finder-launched app can find Zed
without its CLI on PATH.
- Adapted from [upstream PR #8866](https://github.com/pingdotgg/t3code/pull/8866), tracked by
[upstream issue #8938](https://github.com/pingdotgg/t3code/issues/8938). Replace this temporary
integration with upstream behavior when that support lands.
- Changes stay in the clients and shared editor URL helpers. Server behavior and wire schemas
remain compatible with unmodified upstream environments. Zed and Zed Preview share the OS URL
handler; the registered application determines which one opens.

## Maintained differences

### Desktop fork identity
Expand Down
Loading
Loading