diff --git a/apps/desktop/src/ipc/DesktopIpcHandlers.ts b/apps/desktop/src/ipc/DesktopIpcHandlers.ts index db26c525c0015..91eb9675c2452 100644 --- a/apps/desktop/src/ipc/DesktopIpcHandlers.ts +++ b/apps/desktop/src/ipc/DesktopIpcHandlers.ts @@ -1,4 +1,5 @@ import * as Effect from "effect/Effect"; +import * as NodeChildProcessSpawner from "@effect/platform-node/NodeChildProcessSpawner"; import { receiveProviderAuthCallback, cancelProviderAuthCallback } from "./methods/providerAuth.ts"; import * as DesktopIpc from "./DesktopIpc.ts"; @@ -46,7 +47,10 @@ import { openSystemSettings, checkSystemPermission, pasteAsText, + probeLocalEditors, probeRemoteEditors, + openLocalEditor, + getEditorClientId, revealWindow, pickFolder, pickProjectFavicon, @@ -94,6 +98,7 @@ export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers" yield* ipc.handleSync(takeLegacyLocalStorage); yield* ipc.handle(completeLegacyLocalStorage); yield* ipc.handleSync(getAppBranding); + yield* ipc.handleSync(getEditorClientId); yield* installRelayTelemetryEnabled(); yield* ipc.handleSync(getSystemLocale); yield* ipc.handleSync(getPreferredSystemLanguages); @@ -154,6 +159,8 @@ export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers" yield* ipc.handle(pasteAsText); yield* ipc.handle(revealWindow); yield* ipc.handle(probeRemoteEditors); + yield* ipc.handle(probeLocalEditors); + yield* ipc.handle(openLocalEditor).pipe(Effect.provide(NodeChildProcessSpawner.layer)); yield* ipc.handle(getUpdateState); yield* ipc.handle(setUpdateChannel); yield* ipc.handle(downloadUpdate); diff --git a/apps/desktop/src/ipc/channels.ts b/apps/desktop/src/ipc/channels.ts index 6a149e24ae695..910ab08824d91 100644 --- a/apps/desktop/src/ipc/channels.ts +++ b/apps/desktop/src/ipc/channels.ts @@ -8,6 +8,9 @@ export const OPEN_EXTERNAL_CHANNEL = "desktop:open-external"; export const READ_MEDIA_CHANNEL = "desktop:read-media"; export const OPEN_SYSTEM_SETTINGS_CHANNEL = "desktop:open-system-settings"; export const PROBE_REMOTE_EDITORS_CHANNEL = "desktop:probe-remote-editors"; +export const PROBE_LOCAL_EDITORS_CHANNEL = "desktop:probe-local-editors"; +export const GET_EDITOR_CLIENT_ID_CHANNEL = "desktop:get-editor-client-id"; +export const OPEN_LOCAL_EDITOR_CHANNEL = "desktop:open-local-editor"; export const MENU_ACTION_CHANNEL = "desktop:menu-action"; export const PASTE_AS_TEXT_CHANNEL = "desktop:paste-as-text"; export const REVEAL_WINDOW_CHANNEL = "desktop:reveal-window"; diff --git a/apps/desktop/src/ipc/methods/editorClientId.test.ts b/apps/desktop/src/ipc/methods/editorClientId.test.ts new file mode 100644 index 0000000000000..cce91deea644f --- /dev/null +++ b/apps/desktop/src/ipc/methods/editorClientId.test.ts @@ -0,0 +1,25 @@ +// @effect-diagnostics nodeBuiltinImport:off -- The id is a plain file in a temporary directory. +import { assert, it } from "@effect/vitest"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import { readOrCreateEditorClientId } from "./editorClientId.ts"; + +it.effect("keeps one id per installation and never shares it between installations", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const first = yield* fs.makeTempDirectoryScoped({ prefix: "t3-editor-id-a-" }); + const second = yield* fs.makeTempDirectoryScoped({ prefix: "t3-editor-id-b-" }); + + const id = readOrCreateEditorClientId(first); + assert.match(id ?? "", /^[a-f0-9-]{36}$/); + assert.strictEqual(readOrCreateEditorClientId(first), id); + assert.notStrictEqual(readOrCreateEditorClientId(second), id); + + yield* fs.writeFileString(path.join(first, "editor-client-id"), "not-an-id"); + assert.strictEqual(readOrCreateEditorClientId(first), null); + assert.strictEqual(readOrCreateEditorClientId(path.join(first, "missing", "dir")), null); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); diff --git a/apps/desktop/src/ipc/methods/editorClientId.ts b/apps/desktop/src/ipc/methods/editorClientId.ts new file mode 100644 index 0000000000000..39567aef03aca --- /dev/null +++ b/apps/desktop/src/ipc/methods/editorClientId.ts @@ -0,0 +1,20 @@ +// @effect-diagnostics nodeBuiltinImport:off -- The renderer reads this id through a synchronous IPC reply, which Effect's async FileSystem cannot serve. +import * as NodeCrypto from "node:crypto"; +import * as NodeFS from "node:fs"; +import * as NodePath from "node:path"; + +const CLIENT_ID_PATTERN = /^[a-f0-9-]{36}$/; + +/** Stable per-install id stored beside, not inside, Chromium storage; null when unreadable. */ +export function readOrCreateEditorClientId(userDataPath: string): string | null { + try { + const file = NodePath.join(userDataPath, "editor-client-id"); + if (!NodeFS.existsSync(file)) { + NodeFS.writeFileSync(file, NodeCrypto.randomUUID(), { flag: "wx", mode: 0o600 }); + } + const id = NodeFS.readFileSync(file, "utf8").trim(); + return CLIENT_ID_PATTERN.test(id) ? id : null; + } catch { + return null; + } +} diff --git a/apps/desktop/src/ipc/methods/localEditorLaunch.test.ts b/apps/desktop/src/ipc/methods/localEditorLaunch.test.ts new file mode 100644 index 0000000000000..1e96fb1309212 --- /dev/null +++ b/apps/desktop/src/ipc/methods/localEditorLaunch.test.ts @@ -0,0 +1,202 @@ +import { assert, describe, it } from "@effect/vitest"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import * as HostProcess from "@t3tools/shared/HostProcess"; +import { SpawnExecutableResolution } from "@t3tools/shared/shell"; +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/process/ChildProcess"; +import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; +import { launchLocalEditor } from "./localEditorLaunch.ts"; + +interface SpawnRecord { + readonly command: string; + readonly args: ReadonlyArray; + readonly shell: unknown; + readonly detached: unknown; +} + +const makeSpawner = (records: SpawnRecord[], failure?: unknown) => + Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make((command) => { + if (!ChildProcess.isStandardCommand(command)) return Effect.die("unexpected command"); + records.push({ + command: command.command, + args: command.args.map(String), + shell: command.options.shell, + detached: command.options.detached, + }); + if (failure !== undefined) return Effect.fail(failure as never); + return Effect.succeed( + ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(1), + exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(0)), + isRunning: Effect.succeed(false), + kill: () => Effect.void, + unref: Effect.succeed(Effect.void), + stdin: Sink.drain, + stdout: Stream.empty, + stderr: Stream.empty, + all: Stream.empty, + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.empty, + }), + ); + }), + ); + +const withEditorOnPath = ( + body: (input: { readonly directory: string; readonly editor: string }) => Effect.Effect, +) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const directory = yield* fs.makeTempDirectoryScoped({ prefix: "t3-local-editor-" }); + const bin = path.join(directory, "bin"); + yield* fs.makeDirectory(bin, { recursive: true }); + const editor = path.join(bin, "code"); + yield* fs.writeFileString(editor, "#!/bin/sh\n"); + yield* fs.chmod(editor, 0o755); + return yield* body({ directory, editor }).pipe( + Effect.provideService(HostProcess.Environment, { PATH: bin, HOME: directory }), + Effect.provideService(HostProcess.Platform, "linux"), + ); + }); + +describe("launchLocalEditor", () => { + it.effect("rejects missing and relative client paths before launching an editor", () => { + const records: SpawnRecord[] = []; + return Effect.gen(function* () { + assert.deepEqual(yield* launchLocalEditor({ editor: "vscode", path: "relative/project" }), { + ok: false, + reason: "invalid-path", + }); + assert.deepEqual( + yield* launchLocalEditor({ + editor: "vscode", + path: "/nonexistent-t3-editor-regression/project", + }), + { ok: false, reason: "path-missing" }, + ); + assert.deepEqual(yield* launchLocalEditor({ editor: "file-manager", path: "/" }), { + ok: false, + reason: "unsupported-editor", + }); + assert.deepEqual(records, []); + }).pipe(Effect.provide(Layer.mergeAll(NodeServices.layer, makeSpawner(records)))); + }); + + it.effect("spawns the editor argv without a shell for an existing absolute path", () => { + const records: SpawnRecord[] = []; + return withEditorOnPath(({ directory }) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const project = path.join(directory, "--weird; $(touch pwned) project"); + yield* fs.makeDirectory(project); + assert.deepEqual(yield* launchLocalEditor({ editor: "vscode", path: project }), { + ok: true, + }); + assert.deepEqual(records, [ + { command: "code", args: [project], shell: false, detached: true }, + ]); + }), + ).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, makeSpawner(records)))); + }); + + it.effect("passes line and column through the editor's launch style", () => { + const records: SpawnRecord[] = []; + return withEditorOnPath(({ directory }) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const file = path.join(directory, "main.ts"); + yield* fs.writeFileString(file, ""); + yield* launchLocalEditor({ editor: "vscode", path: `${file}:12:3` }); + assert.deepEqual( + records.map(({ args }) => args), + [["--goto", `${file}:12:3`]], + ); + // Zed's `direct-path` style keeps the position on the path argument. + yield* fs.writeFileString(path.join(directory, "bin", "zed"), "#!/bin/sh\n"); + yield* fs.chmod(path.join(directory, "bin", "zed"), 0o755); + records.length = 0; + yield* launchLocalEditor({ editor: "zed", path: `${file}:12:3` }); + assert.deepEqual(records[0]?.args, [`${file}:12:3`]); + // A real file whose name ends in :12 is opened as-is. + const literal = path.join(directory, "notes:12"); + yield* fs.writeFileString(literal, ""); + records.length = 0; + yield* launchLocalEditor({ editor: "vscode", path: literal }); + assert.deepEqual(records[0]?.args, [literal]); + // A position on a path that does not exist is still a missing path. + assert.deepEqual( + yield* launchLocalEditor({ editor: "vscode", path: `${directory}/gone.ts:4` }), + { ok: false, reason: "path-missing" }, + ); + }), + ).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, makeSpawner(records)))); + }); + + it.effect("reports an unavailable editor and a failed spawn without any fallback", () => { + const records: SpawnRecord[] = []; + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const empty = yield* fs.makeTempDirectoryScoped({ prefix: "t3-local-editor-empty-" }); + const missing = yield* launchLocalEditor({ editor: "vscode", path: empty }).pipe( + Effect.provideService(HostProcess.Environment, { PATH: empty, HOME: empty }), + Effect.provideService(HostProcess.Platform, "linux"), + ); + assert.deepEqual(missing, { ok: false, reason: "editor-unavailable" }); + assert.deepEqual(records, []); + }).pipe( + Effect.scoped, + Effect.provide(Layer.mergeAll(NodeServices.layer, makeSpawner(records))), + ); + }); + + it.effect("fails closed when the spawn itself fails", () => { + const records: SpawnRecord[] = []; + return withEditorOnPath(({ directory }) => + Effect.gen(function* () { + assert.deepEqual(yield* launchLocalEditor({ editor: "vscode", path: directory }), { + ok: false, + reason: "spawn-failed", + }); + assert.lengthOf(records, 1); + }), + ).pipe( + Effect.scoped, + Effect.provide(Layer.mergeAll(NodeServices.layer, makeSpawner(records, new Error("boom")))), + ); + }); + + it.effect("reports a shell-only Windows launcher as unsupported, not unsafe", () => { + const records: SpawnRecord[] = []; + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const directory = yield* fs.makeTempDirectoryScoped({ prefix: "t3-local-editor-win-" }); + yield* fs.writeFileString(path.join(directory, "code.cmd"), "@echo off\r\n"); + const result = yield* launchLocalEditor({ editor: "vscode", path: directory }).pipe( + Effect.provideService(HostProcess.Environment, { + PATH: directory, + PATHEXT: ".CMD;.EXE", + }), + Effect.provideService(HostProcess.Platform, "win32"), + Effect.provideService(SpawnExecutableResolution, (command) => + command === "code" ? path.join(directory, "code.cmd") : undefined, + ), + ); + assert.deepEqual(result, { ok: false, reason: "unsupported-launcher" }); + assert.deepEqual(records, []); + }).pipe( + Effect.scoped, + Effect.provide(Layer.mergeAll(NodeServices.layer, makeSpawner(records))), + ); + }); +}); diff --git a/apps/desktop/src/ipc/methods/localEditorLaunch.ts b/apps/desktop/src/ipc/methods/localEditorLaunch.ts new file mode 100644 index 0000000000000..622a595e9d5b5 --- /dev/null +++ b/apps/desktop/src/ipc/methods/localEditorLaunch.ts @@ -0,0 +1,107 @@ +import { + EDITORS, + type DesktopOpenLocalEditorInput, + type DesktopOpenLocalEditorResult, +} from "@t3tools/contracts"; +import { resolveEditorCommand } from "@t3tools/shared/editor"; +import * as HostProcess from "@t3tools/shared/HostProcess"; +import { resolveSpawnCommand } from "@t3tools/shared/shell"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as ChildProcess from "effect/process/ChildProcess"; +import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; + +const CONTROL_CHARACTERS = /[\u0000-\u001f\u007f]/; +const TARGET_WITH_POSITION = /^(.*?):(\d+)(?::(\d+))?$/; + +const refuse = ( + reason: Extract["reason"], +): DesktopOpenLocalEditorResult => ({ ok: false, reason }); + +// Mirrors the server launcher's argv per launch style; the path stays the last, absolute argument. +function editorArguments( + launchStyle: (typeof EDITORS)[number]["launchStyle"], + path: string, + position: { readonly line: string; readonly column: string | undefined } | null, +): ReadonlyArray { + if (position === null) return [path]; + const target = `${path}:${position.line}${position.column === undefined ? "" : `:${position.column}`}`; + // Zed's CLI reads `path:line:column` as a plain argument. + if (launchStyle === "direct-path") return [target]; + if (launchStyle === "goto") return ["--goto", target]; + return [ + "--line", + position.line, + // JetBrains IDEs count `--column` from 0; targets count columns from 1. + ...(position.column === undefined + ? [] + : ["--column", String(Math.max(0, Number(position.column) - 1))]), + path, + ]; +} + +// Absolute, existing paths cannot become options or silently create files on the wrong device. +export const launchLocalEditor = Effect.fn("desktop.localEditor.launch")(function* ( + input: DesktopOpenLocalEditorInput, +): Effect.fn.Return< + DesktopOpenLocalEditorResult, + never, + FileSystem.FileSystem | Path.Path | ChildProcessSpawner.ChildProcessSpawner +> { + const editor = EDITORS.find((candidate) => candidate.id === input.editor); + if (editor === undefined || editor.commands === null) return refuse("unsupported-editor"); + + const path = yield* Path.Path; + if (!path.isAbsolute(input.path) || CONTROL_CHARACTERS.test(input.path)) { + return refuse("invalid-path"); + } + const fileSystem = yield* FileSystem.FileSystem; + const exists = (candidate: string) => + fileSystem.exists(candidate).pipe(Effect.orElseSucceed(() => false)); + + // An existing file literally named `a:12` wins over reading `:12` as a line. + let targetPath = input.path; + let position: { readonly line: string; readonly column: string | undefined } | null = null; + if (!(yield* exists(input.path))) { + const match = TARGET_WITH_POSITION.exec(input.path); + if (match?.[1] === undefined || match[2] === undefined || !(yield* exists(match[1]))) { + return refuse("path-missing"); + } + targetPath = match[1]; + position = { line: match[2], column: match[3] }; + } + + const { ELECTRON_RUN_AS_NODE: _runAsNode, ...env } = yield* HostProcess.Environment; + const resolved = yield* resolveEditorCommand(editor, env); + if (Option.isNone(resolved)) return refuse("editor-unavailable"); + + const spawnCommand = yield* resolveSpawnCommand( + resolved.value.command, + [...resolved.value.baseArgs, ...editorArguments(editor.launchStyle, targetPath, position)], + { env }, + ); + // Windows `.cmd`/`.bat` launchers need a shell, which this path never uses. + if (spawnCommand.shell) return refuse("unsupported-launcher"); + + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + return yield* spawner + .spawn( + ChildProcess.make(spawnCommand.command, spawnCommand.args, { + detached: true, + shell: spawnCommand.shell, + stdin: "ignore", + stdout: "ignore", + stderr: "ignore", + env, + extendEnv: false, + }), + ) + .pipe( + Effect.flatMap((handle) => handle.unref), + Effect.scoped, + Effect.as({ ok: true }), + Effect.orElseSucceed(() => refuse("spawn-failed")), + ); +}); diff --git a/apps/desktop/src/ipc/methods/window.test.ts b/apps/desktop/src/ipc/methods/window.test.ts index 2ddc672891755..06115ef04cf04 100644 --- a/apps/desktop/src/ipc/methods/window.test.ts +++ b/apps/desktop/src/ipc/methods/window.test.ts @@ -32,7 +32,9 @@ import * as DesktopIpc from "../DesktopIpc.ts"; import { getLocalEnvironmentBootstraps, installRelayTelemetryEnabled, + openLocalEditor, pickProjectFavicon, + probeLocalEditors, probeRemoteEditors, readMedia, } from "./window.ts"; @@ -284,6 +286,26 @@ describe("readMedia", () => { ); }); +describe("openLocalEditor", () => { + it.effect("refuses callers other than the main window before touching the filesystem", () => + Effect.gen(function* () { + const main = { webContents: { id: 1 } } as unknown as Electron.BrowserWindow; + const open = (event?: { sender: { id: number } }) => + openLocalEditor + .handler({ editor: "vscode", path: "/" }, event) + .pipe( + Effect.provide( + Layer.mock(ElectronWindow.ElectronWindow)({ main: Effect.succeedSome(main) }), + ), + ); + + const refused = { ok: false, reason: "not-allowed" }; + assert.deepEqual(yield* open({ sender: { id: 2 } }), refused); + assert.deepEqual(yield* open(undefined), refused); + }).pipe(Effect.provide(NodeServices.layer)), + ); +}); + describe("pickProjectFavicon", () => { const pickerLayer = ( pickFiles: () => Effect.Effect>, @@ -386,3 +408,28 @@ it.effect.skipIf(HostProcess.Platform.defaultValue() === "win32")( assert.include(editors, "webstorm"); }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); + +it.effect.skipIf(HostProcess.Platform.defaultValue() === "win32")( + "probes local editors that have no remote scheme", + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const home = yield* fs.makeTempDirectoryScoped({ prefix: "t3-local-editors-" }); + const bin = path.join(home, "bin"); + yield* fs.makeDirectory(bin, { recursive: true }); + for (const command of ["kiro", "trae"]) { + yield* fs.writeFileString(path.join(bin, command), "#!/bin/sh\n"); + yield* fs.chmod(path.join(bin, command), 0o755); + } + const editors = yield* probeLocalEditors + .handler(undefined) + .pipe( + Effect.provideService(HostProcess.Environment, { HOME: home, PATH: bin }), + Effect.provideService(HostProcess.Platform, "linux"), + ); + assert.include(editors, "kiro"); + assert.include(editors, "trae"); + assert.notInclude(editors, "file-manager"); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); diff --git a/apps/desktop/src/ipc/methods/window.ts b/apps/desktop/src/ipc/methods/window.ts index 9580b29b94bba..9ef633b679079 100644 --- a/apps/desktop/src/ipc/methods/window.ts +++ b/apps/desktop/src/ipc/methods/window.ts @@ -3,6 +3,8 @@ import { DesktopAppBrandingSchema, DesktopEnvironmentBootstrapSchema, DesktopMediaReadSchema, + DesktopOpenLocalEditorInputSchema, + DesktopOpenLocalEditorResultSchema, DesktopThemeSchema, EDITORS, EditorId, @@ -43,6 +45,8 @@ import * as MacPermissions from "../../permissions/MacPermissions.ts"; import { safariPermissionCheck } from "../../preview/BrowserImport/SafariPermission.ts"; import * as IpcChannels from "../channels.ts"; import * as DesktopIpc from "../DesktopIpc.ts"; +import { readOrCreateEditorClientId } from "./editorClientId.ts"; +import { launchLocalEditor } from "./localEditorLaunch.ts"; import { extractDistroFromUncPath, resolveWslPickFolderDefaultPath, @@ -407,6 +411,45 @@ export const probeRemoteEditors = DesktopIpc.makeIpcMethod({ }), }); +export const probeLocalEditors = DesktopIpc.makeIpcMethod({ + channel: IpcChannels.PROBE_LOCAL_EDITORS_CHANNEL, + payload: Schema.Undefined, + result: Schema.Array(EditorId), + handler: Effect.fn("desktop.ipc.window.probeLocalEditors")(function* () { + const available: Array = []; + const env = yield* HostProcess.Environment; + for (const editor of EDITORS) { + if (editor.commands === null) continue; + if (Option.isSome(yield* resolveEditorCommand(editor, env))) available.push(editor.id); + } + return available; + }), +}); + +export const getEditorClientId = DesktopIpc.makeSyncIpcMethod({ + channel: IpcChannels.GET_EDITOR_CLIENT_ID_CHANNEL, + result: Schema.NullOr(Schema.String), + handler: () => Effect.sync(() => readOrCreateEditorClientId(Electron.app.getPath("userData"))), +}); + +export const openLocalEditor = DesktopIpc.makeIpcMethod({ + channel: IpcChannels.OPEN_LOCAL_EDITOR_CHANNEL, + payload: DesktopOpenLocalEditorInputSchema, + result: DesktopOpenLocalEditorResultSchema, + handler: Effect.fn("desktop.ipc.window.openLocalEditor")(function* (input, event) { + const window = yield* (yield* ElectronWindow.ElectronWindow).main; + // Only the app's own renderer may start processes on this machine. + if ( + event === undefined || + Option.isNone(window) || + window.value.webContents.id !== event.sender.id + ) { + return { ok: false as const, reason: "not-allowed" as const }; + } + return yield* launchLocalEditor(input); + }), +}); + export const pasteAsText = DesktopIpc.makeIpcMethod({ channel: IpcChannels.PASTE_AS_TEXT_CHANNEL, payload: Schema.Undefined, diff --git a/apps/desktop/src/legacyLocalStorageMerge.test.ts b/apps/desktop/src/legacyLocalStorageMerge.test.ts index fd10c1d02019c..d1d7d572e841d 100644 --- a/apps/desktop/src/legacyLocalStorageMerge.test.ts +++ b/apps/desktop/src/legacyLocalStorageMerge.test.ts @@ -28,16 +28,18 @@ const drafts = (version: number, threadKey: string, prompt: string) => }); describe("mergeLegacyLocalStorage", () => { - it("copies V1 keys into an empty profile, except the per-install client id", () => { + it("copies V1 keys into an empty profile, except per-install identity and opt-ins", () => { const storage = memoryStorage(); mergeLegacyLocalStorage(storage, { "t3code:theme": "dark", "t3code:prompt-stash:v2": stash("a"), "t3.backgroundActivity.clientId": "v1-client", + "t3code:device-local-editor-routes:v1": '["opt-in"]', }); assert.equal(storage.getItem("t3code:theme"), "dark"); assert.equal(storage.getItem("t3code:prompt-stash:v2"), stash("a")); assert.equal(storage.getItem("t3.backgroundActivity.clientId"), null); + assert.equal(storage.getItem("t3code:device-local-editor-routes:v1"), null); }); it("keeps V2 values for plain keys", () => { diff --git a/apps/desktop/src/legacyLocalStorageMerge.ts b/apps/desktop/src/legacyLocalStorageMerge.ts index c63bf7e4668c2..1a7451a45bea0 100644 --- a/apps/desktop/src/legacyLocalStorageMerge.ts +++ b/apps/desktop/src/legacyLocalStorageMerge.ts @@ -9,6 +9,8 @@ const NOT_IMPORTED_KEYS = new Set([ // Identifies one running install to the server; two apps must not share it. "t3.backgroundActivity.clientId", "t3code:snap-shot-setup-resume:v1", + // An explicit "open on this device" opt-in belongs to the install that granted it. + "t3code:device-local-editor-routes:v1", ]); /** Matches MAX_STASH_ENTRIES in apps/web/src/promptStashStore.ts. */ const MAX_STASH_ENTRIES = 20; diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index 7c44ec5133785..0b1eedf423090 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -216,6 +216,9 @@ contextBridge.exposeInMainWorld("desktopBridge", { openSystemSettings: (pane: string) => ipcRenderer.invoke(IpcChannels.OPEN_SYSTEM_SETTINGS_CHANNEL, pane), probeRemoteEditors: () => ipcRenderer.invoke(IpcChannels.PROBE_REMOTE_EDITORS_CHANNEL, undefined), + probeLocalEditors: () => ipcRenderer.invoke(IpcChannels.PROBE_LOCAL_EDITORS_CHANNEL, undefined), + getEditorClientId: () => ipcRenderer.sendSync(IpcChannels.GET_EDITOR_CLIENT_ID_CHANNEL), + openLocalEditor: (input) => ipcRenderer.invoke(IpcChannels.OPEN_LOCAL_EDITOR_CHANNEL, input), pasteAsText: () => ipcRenderer.invoke(IpcChannels.PASTE_AS_TEXT_CHANNEL, undefined), onMenuAction: (listener) => { const wrappedListener = (_event: Electron.IpcRendererEvent, action: unknown) => { diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index 5e3c7ca96fd3b..9cae27d53ae32 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -150,7 +150,7 @@ import { isMarkdownFileLinkLabel } from "@t3tools/shared/markdownLinks"; import { isElectron } from "../env"; import { readLocalApi } from "../localApi"; import { cn } from "../lib/utils"; -import { resolveRemoteOpenState, useRemoteOpenResolution } from "../remoteOpen"; +import { resolveRemoteOpenState, useLocalEditors, useRemoteOpenResolution } from "../remoteOpen"; import { useRightPanelStore } from "../rightPanelStore"; import { readThreadShell, useProjects } from "../state/entities"; import { serverEnvironment } from "../state/server"; @@ -552,8 +552,9 @@ function useChatMarkdownState({ const canOperatePreview = useEnvironmentScope(environmentId, AuthPreviewOperateScope); const remoteOpen = useRemoteOpenResolution(environmentId); const canUseShellActions = - canOperateHost && - canUseMarkdownFileShellActions(environmentId, remoteOpen.state.mode, remoteOpen.isResolved); + (remoteOpen.state.mode === "device-local" && remoteOpen.isResolved) || + (canOperateHost && + canUseMarkdownFileShellActions(environmentId, remoteOpen.state.mode, remoteOpen.isResolved)); const openMarkdownMedia = ( source: string, resolvedFilePath?: string, @@ -623,7 +624,10 @@ function useChatMarkdownState({ markdownServerConfigFieldsAtom(environmentId), ); const projects = useProjects(); - const [preferredEditor] = usePreferredEditor(availableEditors); + const localEditors = useLocalEditors(); + const [preferredEditor] = usePreferredEditor( + remoteOpen.state.mode === "device-local" ? localEditors : availableEditors, + ); const preferredEditorMenuLabel = openInEditorMenuLabel(preferredEditor); const openInPreferredEditor = useOpenInPreferredEditor(environmentId, availableEditors); const openInEditor = useAtomCommand(shellEnvironment.openInEditor, { @@ -868,7 +872,9 @@ function useChatMarkdownState({ } openInEditorMenuLabel={preferredEditorMenuLabel} onReveal={ - canUseShellActions && revealInFileManagerLabel !== undefined + canUseShellActions && + remoteOpen.state.mode !== "device-local" && + revealInFileManagerLabel !== undefined ? async () => revealFileInFileManager((await resolveHostPath()).absolutePath) : undefined } diff --git a/apps/web/src/components/chat/OpenInPicker.browser.test.tsx b/apps/web/src/components/chat/OpenInPicker.browser.test.tsx index 720ce4d6c443d..771023911f65d 100644 --- a/apps/web/src/components/chat/OpenInPicker.browser.test.tsx +++ b/apps/web/src/components/chat/OpenInPicker.browser.test.tsx @@ -60,7 +60,10 @@ vi.mock(import("../../editorPreferences"), async (importOriginal) => { vi.mock(import("../../remoteOpen"), async (importOriginal) => ({ ...(await importOriginal()), useRemoteOpenState: () => state.remote, + useDeviceLocalEditorPreference: () => + ({ available: false, enabled: false, setEnabled: () => undefined }) as const, useRemoteCapableEditors: () => state.clientEditors, + useLocalEditors: () => state.clientEditors, useRemoteOpenHint: () => [false, state.markHintSeen] as const, openRemoteEditorUrl: state.openUrl, })); diff --git a/apps/web/src/components/chat/OpenInPicker.deviceLocal.browser.test.tsx b/apps/web/src/components/chat/OpenInPicker.deviceLocal.browser.test.tsx new file mode 100644 index 0000000000000..218e4392c6068 --- /dev/null +++ b/apps/web/src/components/chat/OpenInPicker.deviceLocal.browser.test.tsx @@ -0,0 +1,386 @@ +import { AuthOrchestrationOperateScope, EnvironmentId, type EditorId } from "@t3tools/contracts"; +import { + BearerConnectionTarget, + PrimaryConnectionTarget, + RelayConnectionTarget, + type ConnectionTarget, +} from "@t3tools/client-runtime/connection"; +import { DEFAULT_RESOLVED_KEYBINDINGS } from "@t3tools/shared/keybindings"; +import * as Option from "effect/Option"; +import { AsyncResult, Atom } from "effect/reactivity"; +import { page, userEvent } from "vite-plus/test/browser"; +import { describe, expect, it, vi } from "vite-plus/test"; + +import { act, render as renderUi, stubGlobal } from "../../../test/render"; +import { toastManager } from "../ui/toast"; + +const CLIENT_ID = "11111111-2222-3333-4444-555555555555"; +const STORAGE_KEY = "t3code:device-local-editor-routes:v1"; +const WORKSPACE = "/work/project"; + +const state = vi.hoisted(() => ({ + run: vi.fn(), + route: null as unknown as Atom.Writable, +})); + +vi.mock(import("../../state/session"), async (importOriginal) => { + const actual = await importOriginal(); + const { Atom: AtomApi } = await import("effect/reactivity"); + const route = AtomApi.make(null).pipe(AtomApi.keepAlive); + state.route = route; + const prepared = AtomApi.make((get) => { + const current = get(route) as { readonly target: ConnectionTarget } | null; + return current === null ? Option.none() : Option.some({ target: current.target }); + }); + return { + ...actual, + // A granted host scope proves the server route stays available when the opt-in is off. + readEnvironmentScope: ((_environmentId: string, scope: string) => + scope === AuthOrchestrationOperateScope) as never, + useEnvironmentScope: (() => true) as never, + environmentSession: { + ...actual.environmentSession, + preparedConnectionValueAtom: () => prepared, + } as never, + }; +}); +vi.mock(import("../../state/presentation"), async (importOriginal) => { + const actual = await importOriginal(); + const { Atom: AtomApi } = await import("effect/reactivity"); + const presentation = AtomApi.make((get) => { + const current = get(state.route as Atom.Atom) as { + readonly target: ConnectionTarget; + readonly remoteOpenTargets?: ReadonlyArray<{ kind: string; host: string }>; + } | null; + return current === null + ? null + : { + entry: { target: current.target, profile: Option.none() }, + serverConfig: { remoteOpenTargets: current.remoteOpenTargets }, + }; + }); + return { + ...actual, + environmentPresentations: { + ...actual.environmentPresentations, + presentationAtom: () => presentation, + } as never, + }; +}); +vi.mock(import("../../state/use-atom-command"), async (importOriginal) => ({ + ...(await importOriginal()), + useAtomCommand: (() => state.run) as never, +})); +vi.mock(import("../../state/environments"), async (importOriginal) => ({ + ...(await importOriginal()), + useEnvironment: (() => ({ label: "Test host" })) as never, +})); + +import { AppAtomRegistryProvider, appAtomRegistry } from "../../rpc/atomRegistry"; +import { useOpenInPreferredEditor } from "../../editorPreferences"; +import { OpenInPicker } from "./OpenInPicker"; +import { + registerOpenFavoriteEditorCommand, + useOpenFavoriteEditorShortcut, +} from "./OpenInPickerShortcut"; + +const selected = EnvironmentId.make("selected"); + +const tunnelRoute = { + target: new BearerConnectionTarget({ + environmentId: selected, + label: "VM", + connectionId: "saved-vm-tunnel", + }), +}; +const otherRoute = { + target: new BearerConnectionTarget({ + environmentId: selected, + label: "VM", + connectionId: "saved-vm-tailscale", + }), + remoteOpenTargets: [{ kind: "tailscale", host: "sol.tail1234.ts.net" }], +}; +const relayRoute = { + target: new RelayConnectionTarget({ + environmentId: selected, + label: "VM", + }), +}; +const loopbackPrimary = { + target: new PrimaryConnectionTarget({ + environmentId: selected, + label: "This machine", + httpBaseUrl: "http://127.0.0.1:45999", + wsBaseUrl: "ws://127.0.0.1:45999", + }), +}; + +type OpenResult = { ok: true } | { ok: false; reason: string }; + +async function setup({ clientEditors = ["zed", "vscode"] as readonly EditorId[] } = {}) { + state.run.mockReset().mockResolvedValue(AsyncResult.success(undefined)); + window.localStorage.clear(); + const openLocalEditor = vi.fn<(input: unknown) => Promise>(async () => ({ + ok: true, + })); + const openExternal = vi.fn(async (_url: string) => true); + + await using stack = new AsyncDisposableStack(); + stack.use( + stubGlobal("desktopBridge", { + getEditorClientId: () => CLIENT_ID, + probeRemoteEditors: async () => clientEditors, + probeLocalEditors: async () => clientEditors, + openLocalEditor, + openExternal, + }), + ); + stack.use(vi.spyOn(navigator, "platform", "get").mockReturnValue("Linux")); + stack.use({ [Symbol.dispose]: () => window.localStorage.clear() }); + stack.use({ [Symbol.dispose]: registerOpenFavoriteEditorCommand() }); + const toast = vi.spyOn(toastManager, "add").mockReturnValue("toast" as never); + stack.use(toast); + const screen = stack.use(await renderUi(null)); + + const setRoute = (route: unknown) => act(async () => appAtomRegistry.set(state.route, route)); + const mountPicker = (openInCwd: string | null = WORKSPACE) => + act(async () => + screen.rerender( + + + , + ), + ); + const unmountPicker = () => act(async () => screen.rerender(null)); + const openMenu = async () => { + await userEvent.click(page.getByRole("button", { name: "Choose editor" })); + }; + const closeMenu = async () => { + await userEvent.keyboard("{Escape}"); + }; + const primary = () => page.getByRole("button", { name: "Open file in preferred editor" }); + const storedRoutes = () => JSON.parse(window.localStorage.getItem(STORAGE_KEY) ?? "[]"); + + const environment = stack.move(); + return { + screen, + toast, + openLocalEditor, + openExternal, + setRoute, + mountPicker, + unmountPicker, + openMenu, + closeMenu, + primary, + storedRoutes, + [Symbol.asyncDispose]: () => environment.disposeAsync(), + }; +} + +describe("device-local editor opt-in", () => { + it("offers the opt-in on a non-local route and persists it per client and route", async () => { + await using fixture = await setup(); + const { setRoute, mountPicker, unmountPicker, openMenu, closeMenu, storedRoutes } = fixture; + await setRoute(tunnelRoute); + await mountPicker(); + await openMenu(); + + await expect.element(page.getByText("Editor location: Automatic ✓")).toBeVisible(); + // Without an SSH route there is nothing to open, which is why the opt-in is offered here. + await expect.element(page.getByText("No SSH route to Test host")).toBeVisible(); + + await userEvent.click(page.getByText("Editor location: This device")); + expect(storedRoutes()).toEqual([ + JSON.stringify([ + CLIENT_ID, + JSON.stringify([selected, "BearerConnectionTarget", "saved-vm-tunnel"]), + ]), + ]); + await openMenu(); + await expect.element(page.getByText("Editor location: This device ✓")).toBeVisible(); + // Editors found on this device by the desktop probe, Zed included, replace the dead end. + await expect.element(page.getByRole("menuitem", { name: "Zed" })).toBeVisible(); + await closeMenu(); + + // A fresh mount (a reload) reads the stored choice back. + await unmountPicker(); + await mountPicker(); + await openMenu(); + await expect.element(page.getByText("Editor location: This device ✓")).toBeVisible(); + + await userEvent.click(page.getByText("Editor location: Automatic")); + expect(storedRoutes()).toEqual([]); + }); + + it("opens through the desktop bridge and never calls the server or an SSH link", async () => { + await using fixture = await setup(); + const { setRoute, mountPicker, openMenu, primary, openLocalEditor, openExternal } = fixture; + window.localStorage.setItem( + STORAGE_KEY, + JSON.stringify([ + JSON.stringify([ + CLIENT_ID, + JSON.stringify([selected, "BearerConnectionTarget", "saved-vm-tunnel"]), + ]), + ]), + ); + await setRoute(tunnelRoute); + await mountPicker(); + + await openMenu(); + await userEvent.click(page.getByRole("menuitem", { name: "Zed" })); + await userEvent.click(primary()); + await vi.waitFor(() => + expect(openLocalEditor).toHaveBeenCalledWith({ editor: "zed", path: WORKSPACE }), + ); + expect(state.run).not.toHaveBeenCalled(); + expect(openExternal).not.toHaveBeenCalled(); + }); + + it("follows the active route: another route, loopback primary and relay stay unaffected", async () => { + await using fixture = await setup(); + const { setRoute, mountPicker, openMenu, closeMenu, primary, openLocalEditor, openExternal } = + fixture; + await setRoute(tunnelRoute); + await mountPicker(); + await openMenu(); + await userEvent.click(page.getByText("Editor location: This device")); + await openMenu(); + await expect.element(page.getByText("Editor location: This device ✓")).toBeVisible(); + await closeMenu(); + + // Same environment over a different route: the opt-in does not carry over. + await setRoute(otherRoute); + await openMenu(); + await expect.element(page.getByText("Editor location: Automatic ✓")).toBeVisible(); + await closeMenu(); + await userEvent.click(primary()); + await vi.waitFor(() => expect(openExternal).toHaveBeenCalledOnce()); + expect(openExternal.mock.calls[0]![0]).toContain("sol.tail1234.ts.net"); + expect(openLocalEditor).not.toHaveBeenCalled(); + + // Relay is its own route and starts on Automatic. + await setRoute(relayRoute); + await openMenu(); + await expect.element(page.getByText("Editor location: Automatic ✓")).toBeVisible(); + await closeMenu(); + + // Switching back restores the stored choice. + await setRoute(tunnelRoute); + await openMenu(); + await expect.element(page.getByText("Editor location: This device ✓")).toBeVisible(); + }); + + it("keeps the default loopback behavior: no opt-in menu, server RPC, no bridge launch", async () => { + await using fixture = await setup(); + const { setRoute, mountPicker, openMenu, primary, openLocalEditor, openExternal } = fixture; + await setRoute(loopbackPrimary); + await mountPicker(); + await userEvent.click(primary()); + await vi.waitFor(() => + expect(state.run).toHaveBeenCalledWith({ + environmentId: selected, + input: { cwd: WORKSPACE, editor: "vscode" }, + }), + ); + expect(openLocalEditor).not.toHaveBeenCalled(); + expect(openExternal).not.toHaveBeenCalled(); + + await openMenu(); + await expect.element(page.getByRole("menuitem", { name: /^VS Code/ })).toBeVisible(); + expect(document.body.textContent).not.toContain("Editor location"); + }); + + it("reports a refused launch and does not fall back to the server", async () => { + await using fixture = await setup(); + const { setRoute, mountPicker, primary, openLocalEditor, openExternal, toast } = fixture; + window.localStorage.setItem( + STORAGE_KEY, + JSON.stringify([ + JSON.stringify([ + CLIENT_ID, + JSON.stringify([selected, "BearerConnectionTarget", "saved-vm-tunnel"]), + ]), + ]), + ); + await setRoute(tunnelRoute); + await mountPicker(); + + openLocalEditor.mockResolvedValueOnce({ ok: false, reason: "path-missing" }); + await userEvent.click(primary()); + await vi.waitFor(() => + expect(toast).toHaveBeenCalledWith( + expect.objectContaining({ + type: "error", + description: expect.stringContaining("Path does not exist on this device"), + }), + ), + ); + openLocalEditor.mockRejectedValueOnce(new Error("ipc closed")); + await userEvent.click(primary()); + await vi.waitFor(() => expect(toast).toHaveBeenCalledTimes(2)); + expect(state.run).not.toHaveBeenCalled(); + expect(openExternal).not.toHaveBeenCalled(); + }); + + it("routes the favorite-editor shortcut and the preferred-editor hook through the bridge", async () => { + await using fixture = await setup(); + const { screen, setRoute, openLocalEditor, openExternal } = fixture; + window.localStorage.setItem( + STORAGE_KEY, + JSON.stringify([ + JSON.stringify([ + CLIENT_ID, + JSON.stringify([selected, "BearerConnectionTarget", "saved-vm-tunnel"]), + ]), + ]), + ); + await setRoute(tunnelRoute); + function Harness() { + useOpenFavoriteEditorShortcut({ + enabled: true, + environmentId: selected, + keybindings: DEFAULT_RESOLVED_KEYBINDINGS, + availableEditors: ["vscode"], + openInCwd: WORKSPACE, + }); + const openPreferred = useOpenInPreferredEditor(selected, ["vscode"]); + return ( + + ); + } + await act(async () => + screen.rerender( + + + , + ), + ); + + await act(async () => { + document.body.dispatchEvent( + new KeyboardEvent("keydown", { key: "o", ctrlKey: true, bubbles: true, cancelable: true }), + ); + }); + await vi.waitFor(() => expect(openLocalEditor).toHaveBeenCalledTimes(1)); + + await userEvent.click(page.getByRole("button", { name: "Open preferred" })); + await vi.waitFor(() => expect(openLocalEditor).toHaveBeenCalledTimes(2)); + expect(openLocalEditor).toHaveBeenLastCalledWith({ + editor: expect.any(String), + path: "/work/project/src/main.ts", + }); + expect(state.run).not.toHaveBeenCalled(); + expect(openExternal).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/src/components/chat/OpenInPicker.tsx b/apps/web/src/components/chat/OpenInPicker.tsx index a19491adcd15e..bdbab18e7dc56 100644 --- a/apps/web/src/components/chat/OpenInPicker.tsx +++ b/apps/web/src/components/chat/OpenInPicker.tsx @@ -14,6 +14,9 @@ import { usePreferredEditor } from "../../editorPreferences"; import { editorLabelForPlatform } from "../../editorLabels"; import { openRemoteEditorUrl, + openDeviceLocalEditor, + useDeviceLocalEditorPreference, + useLocalEditors, useRemoteCapableEditors, useRemoteOpenHint, useRemoteOpenState, @@ -231,15 +234,22 @@ export const OpenInPicker = memo(function OpenInPicker({ const panelAnchorRef = useRef(null); const openInEditorMutation = useAtomCommand(shellEnvironment.openInEditor, "open in editor"); const remote = useRemoteOpenState(environmentId); + const devicePreference = useDeviceLocalEditorPreference(environmentId); const canOperateHost = useEnvironmentScope(environmentId, AuthOrchestrationOperateScope); const isHostEditorDenied = remote.mode === "local-exec" && !canOperateHost; const canOpenEditor = remote.mode !== "remote-unavailable" && !isHostEditorDenied; const [menuOpen, setMenuOpen] = useComposerMenuState(isHostEditorDenied); const remoteCapableEditors = useRemoteCapableEditors(); + const localEditors = useLocalEditors(); const [remoteHintSeen, markRemoteHintSeen] = useRemoteOpenHint(); const environmentLabel = useEnvironment(environmentId)?.label ?? "this machine"; // Remote editors run on the viewing machine; only desktop can probe that PATH. - const effectiveEditors = remote.mode === "local-exec" ? availableEditors : remoteCapableEditors; + const effectiveEditors = + remote.mode === "local-exec" + ? availableEditors + : remote.mode === "device-local" + ? localEditors + : remoteCapableEditors; const [preferredEditor, setPreferredEditor] = usePreferredEditor(effectiveEditors); const openInEditor = async (editorId: EditorId | null): Promise => { @@ -247,6 +257,11 @@ export const OpenInPicker = memo(function OpenInPicker({ const editor = editorId ?? preferredEditor; if (!editor) return false; if (remote.mode === "remote-unavailable") return false; + if (remote.mode === "device-local") { + const opened = await openDeviceLocalEditor(editor, openInCwd); + if (opened) setPreferredEditor(editor); + return opened; + } if (remote.mode === "remote-links") { const url = buildRemoteOpenUrl({ editor, @@ -288,6 +303,17 @@ export const OpenInPicker = memo(function OpenInPicker({ const editorItems = ( <> + {devicePreference.available && ( + <> + devicePreference.setEnabled(false)}> + Editor location: Automatic{!devicePreference.enabled ? " ✓" : ""} + + devicePreference.setEnabled(true)}> + Editor location: This device{devicePreference.enabled ? " ✓" : ""} + + Only this client and route. Local paths must match. + + )} {remote.mode === "remote-unavailable" ? ( No SSH route to {environmentLabel} diff --git a/apps/web/src/components/chat/OpenInPickerShortcut.ts b/apps/web/src/components/chat/OpenInPickerShortcut.ts index 833c3f3784987..a63919e763eec 100644 --- a/apps/web/src/components/chat/OpenInPickerShortcut.ts +++ b/apps/web/src/components/chat/OpenInPickerShortcut.ts @@ -10,7 +10,13 @@ import { usePreferredEditor } from "../../editorPreferences"; import { editorLabelForPlatform } from "../../editorLabels"; import { isOpenFavoriteEditorShortcut } from "../../keybindings"; import { announce } from "../../lib/announce"; -import { openRemoteEditorUrl, useRemoteCapableEditors, useRemoteOpenState } from "../../remoteOpen"; +import { + openDeviceLocalEditor, + openRemoteEditorUrl, + useLocalEditors, + useRemoteCapableEditors, + useRemoteOpenState, +} from "../../remoteOpen"; import { registerCommands } from "../../shortcuts/dispatcher"; import { shellEnvironment } from "../../state/shell"; import { useAtomCommand } from "../../state/use-atom-command"; @@ -56,9 +62,14 @@ export function useOpenFavoriteEditorShortcut({ const openInEditorMutation = useAtomCommand(shellEnvironment.openInEditor, "open in editor"); const remote = useRemoteOpenState(environmentId); const remoteCapableEditors = useRemoteCapableEditors(); + const localEditors = useLocalEditors(); // Matches the picker: remote editors run on the viewing machine. const [preferredEditor] = usePreferredEditor( - remote.mode === "local-exec" ? availableEditors : remoteCapableEditors, + remote.mode === "local-exec" + ? availableEditors + : remote.mode === "device-local" + ? localEditors + : remoteCapableEditors, ); // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- subscribes a window keydown listener for the open-in-editor shortcut while enabled @@ -75,6 +86,12 @@ export function useOpenFavoriteEditorShortcut({ event.preventDefault(); const label = editorLabelForPlatform(preferredEditor, navigator.platform); + if (remote.mode === "device-local") { + void openDeviceLocalEditor(preferredEditor, openInCwd).then((opened) => { + if (opened) announce(`Opened in ${label}`); + }); + return; + } if (remote.mode === "remote-links") { const url = buildRemoteOpenUrl({ editor: preferredEditor, diff --git a/apps/web/src/editorPreferences.test.ts b/apps/web/src/editorPreferences.test.ts index 212eda3304221..e8d256b8c3258 100644 --- a/apps/web/src/editorPreferences.test.ts +++ b/apps/web/src/editorPreferences.test.ts @@ -15,6 +15,12 @@ const state = vi.hoisted(() => ({ })); vi.mock("react", () => ({ useCallback: (callback: unknown) => callback })); +vi.mock("./remoteOpen", () => ({ + useRemoteOpenState: () => ({ mode: "local-exec" }), + useRemoteCapableEditors: () => [], + useLocalEditors: () => [], + openDeviceLocalEditor: vi.fn(), +})); vi.mock("./state/shell", () => ({ shellEnvironment: { openInEditor: "openInEditor" } })); vi.mock("./state/use-atom-command", () => ({ useAtomCommand: () => state.run })); vi.mock("./state/session", () => ({ diff --git a/apps/web/src/editorPreferences.ts b/apps/web/src/editorPreferences.ts index e6aacd32b02de..1c0bdde8a5a56 100644 --- a/apps/web/src/editorPreferences.ts +++ b/apps/web/src/editorPreferences.ts @@ -17,6 +17,7 @@ import { getLocalStorageItem, setLocalStorageItem, useLocalStorage } from "./hoo import { shellEnvironment } from "./state/shell"; import { useAtomCommand } from "./state/use-atom-command"; import { readEnvironmentScope } from "./state/session"; +import { openDeviceLocalEditor, useLocalEditors, useRemoteOpenState } from "./remoteOpen"; const LAST_EDITOR_KEY = "t3code:last-editor"; @@ -68,6 +69,8 @@ export function useOpenInPreferredEditor( environmentId: EnvironmentId | null, availableEditors: readonly EditorId[], ) { + const remote = useRemoteOpenState(environmentId); + const clientEditors = useLocalEditors(); const openInEditor = useAtomCommand(shellEnvironment.openInEditor, { reportFailure: false, }); @@ -93,6 +96,21 @@ export function useOpenInPreferredEditor( ), ); } + if (remote.mode === "device-local") { + const editor = resolveAndPersistPreferredEditor(clientEditors); + if (editor && (await openDeviceLocalEditor(editor, targetPath))) { + return AsyncResult.success(editor); + } + return AsyncResult.failure( + Cause.fail( + new PreferredEditorUnavailableError({ + environmentId, + targetPath, + availableEditorIds: clientEditors, + }), + ), + ); + } if (!readEnvironmentScope(environmentId, AuthOrchestrationOperateScope)) { return AsyncResult.failure( Cause.fail( diff --git a/apps/web/src/fileContextMenu.ts b/apps/web/src/fileContextMenu.ts index 1ea95c96de518..4cae3050c5e08 100644 --- a/apps/web/src/fileContextMenu.ts +++ b/apps/web/src/fileContextMenu.ts @@ -17,6 +17,7 @@ import { useAtomCommand } from "./state/use-atom-command"; import { resolvePathLinkTarget } from "@t3tools/shared/fileLinks"; import { toastManager } from "./components/ui/toast"; import { useAtomValue } from "@effect/atom-react"; +import { openDeviceLocalEditor, useLocalEditors, useRemoteOpenState } from "./remoteOpen"; export type FileContextMenuAction = | "reveal-in-folder" @@ -87,13 +88,17 @@ function buildFileContextMenuItems(input: { } export function useFileContextMenu(environmentId: EnvironmentId | null) { + const remote = useRemoteOpenState(environmentId); + const clientEditors = useLocalEditors(); const openInEditor = useAtomCommand(shellEnvironment.openInEditor, { reportFailure: false }); const serverConfig = useAtomValue(serverEnvironment.configValueAtom(environmentId)); - const availableEditors = serverConfig?.availableEditors ?? []; + const availableEditors = + remote.mode === "device-local" ? clientEditors : (serverConfig?.availableEditors ?? []); const capabilities: FileContextMenuCapabilities = { // WSL can reveal in Windows Explorer despite reporting Linux; use server wording. revealLabel: + remote.mode !== "device-local" && environmentId !== null && serverConfig?.shellRevealInFileManager === true && serverConfig.availableEditors.includes("file-manager") @@ -119,6 +124,10 @@ export function useFileContextMenu(environmentId: EnvironmentId | null) { : (action.slice("editor:".length) as EditorId); if (action !== "open" && !reveal && !capabilities.editorIds.includes(editor)) return; + if (remote.mode === "device-local") { + if (editor !== "file-manager") await openDeviceLocalEditor(editor, absolutePath); + return; + } const result = await openInEditor({ environmentId, input: { cwd: absolutePath, editor, ...(reveal ? { reveal: true } : {}) }, diff --git a/apps/web/src/remoteOpen.clientIdFailure.browser.test.tsx b/apps/web/src/remoteOpen.clientIdFailure.browser.test.tsx new file mode 100644 index 0000000000000..f74cfd7f591dd --- /dev/null +++ b/apps/web/src/remoteOpen.clientIdFailure.browser.test.tsx @@ -0,0 +1,60 @@ +import { EnvironmentId } from "@t3tools/contracts"; +import { BearerConnectionTarget } from "@t3tools/client-runtime/connection"; +import * as Option from "effect/Option"; +import { Atom } from "effect/reactivity"; +import { page } from "vite-plus/test/browser"; +import { expect, it, vi } from "vite-plus/test"; + +import { act, render, stubGlobal } from "../test/render"; + +const state = vi.hoisted(() => ({ route: null as unknown as Atom.Writable })); + +vi.mock(import("./state/session"), async (importOriginal) => { + const actual = await importOriginal(); + const { Atom: AtomApi } = await import("effect/reactivity"); + const route = AtomApi.make(Option.none()).pipe(AtomApi.keepAlive); + state.route = route; + const prepared = AtomApi.make((get) => get(route)); + return { + ...actual, + environmentSession: { + ...actual.environmentSession, + preparedConnectionValueAtom: () => prepared, + } as never, + }; +}); + +import { appAtomRegistry } from "./rpc/atomRegistry"; +import { useDeviceLocalEditorPreference } from "./remoteOpen"; + +const environmentId = EnvironmentId.make("selected"); + +function Probe() { + const preference = useDeviceLocalEditorPreference(environmentId); + return {preference.available ? "available" : "unavailable"}; +} + +// The client id is cached per page, so each failure mode gets its own file. +it("hides the opt-in when the desktop cannot provide a client id", async () => { + using _bridge = stubGlobal("desktopBridge", { + getEditorClientId: () => { + throw new Error("sendSync failed"); + }, + openLocalEditor: async () => ({ ok: true }), + }); + await using screen = await render(); + await act(async () => + appAtomRegistry.set( + state.route, + Option.some({ + target: new BearerConnectionTarget({ + environmentId, + label: "VM", + connectionId: "saved-vm-tunnel", + }), + }), + ), + ); + await expect.element(page.getByText("unavailable")).toBeVisible(); + expect(screen.container.textContent).toBe("unavailable"); +}); diff --git a/apps/web/src/remoteOpen.localProbeEmpty.browser.test.tsx b/apps/web/src/remoteOpen.localProbeEmpty.browser.test.tsx new file mode 100644 index 0000000000000..19cd6f815913f --- /dev/null +++ b/apps/web/src/remoteOpen.localProbeEmpty.browser.test.tsx @@ -0,0 +1,20 @@ +import { expect, it, vi } from "vite-plus/test"; + +import { AppAtomRegistryProvider } from "./rpc/atomRegistry"; +import { render, stubGlobal } from "../test/render"; +import { useLocalEditors } from "./remoteOpen"; + +// Own file: the probe atom is kept alive for the page, so another probe result would leak in. +it("keeps an empty local probe instead of offering the VS Code fallback", async () => { + const probe = vi.fn(async () => [] as const); + using _bridge = stubGlobal("desktopBridge", { probeLocalEditors: probe }); + function Probe() { + return {`[${useLocalEditors().join(",")}]`}; + } + await using screen = await render( + + + , + ); + await vi.waitFor(() => expect(screen.container.textContent).toBe("[]")); +}); diff --git a/apps/web/src/remoteOpen.probe.browser.test.tsx b/apps/web/src/remoteOpen.probe.browser.test.tsx new file mode 100644 index 0000000000000..4281786ef1d07 --- /dev/null +++ b/apps/web/src/remoteOpen.probe.browser.test.tsx @@ -0,0 +1,35 @@ +import { expect, it, vi } from "vite-plus/test"; + +import { AppAtomRegistryProvider } from "./rpc/atomRegistry"; +import { act, render, stubGlobal } from "../test/render"; +import { useLocalEditors, useRemoteCapableEditors } from "./remoteOpen"; + +it("lists a probed Zed install instead of the VS Code fallback", async () => { + const probe = vi.fn(async () => ["zed", "vscode"] as const); + using _bridge = stubGlobal("desktopBridge", { probeRemoteEditors: probe }); + function Probe() { + return {useRemoteCapableEditors().join(",")}; + } + await using screen = await render( + + + , + ); + await vi.waitFor(() => expect(screen.container.textContent).toBe("zed,vscode")); + await act(async () => undefined); + expect(probe).toHaveBeenCalledOnce(); +}); + +it("lists probed local editors that have no remote scheme", async () => { + const probe = vi.fn(async () => ["kiro", "zed"] as const); + using _bridge = stubGlobal("desktopBridge", { probeLocalEditors: probe }); + function Probe() { + return {useLocalEditors().join(",")}; + } + await using screen = await render( + + + , + ); + await vi.waitFor(() => expect(screen.container.textContent).toBe("kiro,zed")); +}); diff --git a/apps/web/src/remoteOpen.test.ts b/apps/web/src/remoteOpen.test.ts index 365ec4a3d50af..213c123fd2805 100644 --- a/apps/web/src/remoteOpen.test.ts +++ b/apps/web/src/remoteOpen.test.ts @@ -1,4 +1,5 @@ import { + BearerConnectionTarget, PrimaryConnectionTarget, RelayConnectionTarget, SshConnectionTarget, @@ -6,7 +7,12 @@ import { import { buildRemoteOpenUrl, EnvironmentId } from "@t3tools/contracts"; import { describe, expect, it } from "vite-plus/test"; -import { resolveRemoteOpenState } from "./remoteOpen"; +import { + deviceLocalPreferenceKey, + deviceLocalRouteKey, + resolveRemoteOpenState, + withDeviceLocalRoute, +} from "./remoteOpen"; const environmentId = EnvironmentId.make("environment-1"); @@ -143,6 +149,123 @@ describe("resolveRemoteOpenState", () => { }); }); +const savedLoopback = (connectionId: string) => + new BearerConnectionTarget({ environmentId, label: "sol", connectionId }); + +describe("opening editors on this device", () => { + const loopback = savedLoopback("bearer:environment-1:http://127.0.0.1:3773/"); + + it("keeps a saved loopback route remote under Automatic", () => { + // A loopback port can tunnel to a VM or container, so it never implies local files. + expect( + resolveRemoteOpenState({ + target: loopback, + sshTarget: null, + isDesktopRenderer: true, + remoteOpenTargets: undefined, + openOnThisDevice: false, + }), + ).toEqual({ mode: "remote-unavailable" }); + }); + + it("opens on this device only after an explicit opt-in", () => { + expect( + resolveRemoteOpenState({ + target: loopback, + sshTarget: null, + isDesktopRenderer: true, + remoteOpenTargets: TAILSCALE_TARGETS, + openOnThisDevice: true, + }), + ).toEqual({ mode: "device-local" }); + expect( + resolveRemoteOpenState({ + target: new RelayConnectionTarget({ environmentId, label: "sol" }), + sshTarget: null, + isDesktopRenderer: true, + remoteOpenTargets: undefined, + openOnThisDevice: true, + }), + ).toEqual({ mode: "device-local" }); + }); + + it("leaves SSH, primary, and desktop-managed targets on their existing path", () => { + const sshTarget = { alias: "sol", username: null }; + expect( + resolveRemoteOpenState({ + target: new SshConnectionTarget({ environmentId, label: "sol", connectionId: "ssh-1" }), + sshTarget, + isDesktopRenderer: true, + remoteOpenTargets: undefined, + openOnThisDevice: true, + }), + ).toEqual({ mode: "remote-links", host: { kind: "ssh-alias", host: "sol" } }); + expect( + resolveRemoteOpenState({ + target: primaryTarget("https://sol.tail1234.ts.net"), + sshTarget: null, + isDesktopRenderer: false, + remoteOpenTargets: TAILSCALE_TARGETS, + openOnThisDevice: true, + }), + ).toEqual({ mode: "remote-links", host: { kind: "tailscale", host: "sol.tail1234.ts.net" } }); + expect( + resolveRemoteOpenState({ + target: savedLoopback("local:wsl:default"), + sshTarget: null, + isDesktopRenderer: true, + remoteOpenTargets: undefined, + openOnThisDevice: true, + }), + ).toEqual({ mode: "local-exec" }); + }); + + it("keys the preference by environment and route, never by SSH, primary, or managed routes", () => { + const sameEnvironmentOtherPort = savedLoopback("bearer:environment-1:http://127.0.0.1:4000/"); + const otherEnvironment = new BearerConnectionTarget({ + environmentId: EnvironmentId.make("environment-2"), + label: "vm", + connectionId: loopback.connectionId, + }); + const keys = [loopback, sameEnvironmentOtherPort, otherEnvironment].map(deviceLocalRouteKey); + expect(new Set(keys).size).toBe(3); + expect( + deviceLocalRouteKey(new RelayConnectionTarget({ environmentId, label: "sol" })), + ).not.toBe(null); + for (const ineligible of [ + new SshConnectionTarget({ environmentId, label: "sol", connectionId: "ssh-1" }), + primaryTarget("http://127.0.0.1:8000"), + savedLoopback("local:wsl:default"), + ]) { + expect(deviceLocalRouteKey(ineligible)).toBe(null); + } + }); + + it("scopes the opt-in to the install and the active route", () => { + const other = savedLoopback("bearer:environment-1:http://127.0.0.1:4000/"); + const stored = [deviceLocalPreferenceKey("client-a", loopback)!]; + // Another installation (different client id) never inherits the stored opt-in. + expect(stored.includes(deviceLocalPreferenceKey("client-b", loopback)!)).toBe(false); + // Switching the active route of the same environment drops the opt-in. + expect(stored.includes(deviceLocalPreferenceKey("client-a", other)!)).toBe(false); + expect(stored.includes(deviceLocalPreferenceKey("client-a", loopback)!)).toBe(true); + // No install identity or no eligible route means no preference at all. + expect(deviceLocalPreferenceKey(null, loopback)).toBe(null); + expect(deviceLocalPreferenceKey("client-a", undefined)).toBe(null); + expect(deviceLocalPreferenceKey("client-a", savedLoopback("local:wsl:default"))).toBe(null); + }); + + it("changes only the toggled route in the stored preference", () => { + const a = deviceLocalRouteKey(loopback)!; + const b = deviceLocalRouteKey(savedLoopback("bearer:environment-1:http://127.0.0.1:4000/"))!; + const both = withDeviceLocalRoute(withDeviceLocalRoute([], a, true), b, true); + expect(both).toEqual([a, b]); + expect(withDeviceLocalRoute(both, a, true)).toEqual([a, b]); + expect(withDeviceLocalRoute(both, a, false)).toEqual([b]); + expect(withDeviceLocalRoute([b], a, false)).toEqual([b]); + }); +}); + describe("buildRemoteOpenUrl", () => { it("roots Windows paths", () => { expect( diff --git a/apps/web/src/remoteOpen.ts b/apps/web/src/remoteOpen.ts index 741f6f3c60cde..bf15efb93dd1e 100644 --- a/apps/web/src/remoteOpen.ts +++ b/apps/web/src/remoteOpen.ts @@ -14,6 +14,8 @@ import { isDesktopLocalConnectionTarget } from "~/connection/desktopLocal"; import { isLoopbackHostname } from "~/environments/primary/target"; import { useLocalStorage } from "~/hooks/useLocalStorage"; import { environmentPresentations } from "~/state/presentation"; +import { environmentSession } from "~/state/session"; +import { toastManager } from "~/components/ui/toast"; export interface RemoteOpenHost { readonly kind: "ssh-alias" | RemoteOpenTarget["kind"]; @@ -23,6 +25,7 @@ export interface RemoteOpenHost { export type RemoteOpenState = | { readonly mode: "local-exec" } + | { readonly mode: "device-local" } | { readonly mode: "remote-links"; readonly host: RemoteOpenHost } | { readonly mode: "remote-unavailable" }; @@ -53,6 +56,7 @@ export function resolveRemoteOpenState(input: { readonly sshTarget: { readonly alias: string; readonly username: string | null } | null; readonly remoteOpenTargets: ReadonlyArray | undefined; readonly isDesktopRenderer: boolean; + readonly openOnThisDevice?: boolean; }): RemoteOpenState { const { target } = input; if (target === null) { @@ -87,6 +91,9 @@ export function resolveRemoteOpenState(input: { }, }; } + if (input.isDesktopRenderer && input.openOnThisDevice && deviceLocalRouteKey(target) !== null) { + return { mode: "device-local" }; + } const advertised = input.remoteOpenTargets?.[0]; if (advertised !== undefined) { return { mode: "remote-links", host: advertised }; @@ -134,10 +141,84 @@ const remoteOpenResolutionAtom = Atom.family((environmentId: EnvironmentId) => const UNRESOLVED_REMOTE_OPEN_ATOM = Atom.make(UNRESOLVED_REMOTE_OPEN); +const DEVICE_LOCAL_ROUTES_KEY = "t3code:device-local-editor-routes:v1"; +const DeviceLocalRoutes = Schema.Array(Schema.String); + +export function deviceLocalRouteKey(target: ConnectionTarget): string | null { + if (target._tag === "RelayConnectionTarget") { + return JSON.stringify([target.environmentId, target._tag]); + } + if (target._tag !== "BearerConnectionTarget" || target.connectionId.startsWith("local:")) + return null; + return JSON.stringify([target.environmentId, target._tag, target.connectionId]); +} + +export function deviceLocalPreferenceKey( + clientId: string | null, + target: ConnectionTarget | undefined, +): string | null { + const routeKey = target === undefined ? null : deviceLocalRouteKey(target); + return clientId === null || routeKey === null ? null : JSON.stringify([clientId, routeKey]); +} + +export function withDeviceLocalRoute(routes: readonly string[], key: string, enabled: boolean) { + return enabled + ? routes.includes(key) + ? routes + : [...routes, key] + : routes.filter((r) => r !== key); +} + +const NO_PREPARED_CONNECTION = Atom.make( + Option.none(), +); + +let editorClientId: string | null | undefined; + +// Identity lives outside Chromium storage, so importing storage cannot authorize another install. +// The lookup is a synchronous IPC round trip, so it runs once per page. +function readEditorClientId(): string | null { + if (editorClientId === undefined) { + try { + editorClientId = window.desktopBridge?.getEditorClientId?.() ?? null; + } catch { + editorClientId = null; + } + } + return editorClientId; +} + +export function useDeviceLocalEditorPreference(environmentId: EnvironmentId | null) { + const prepared = useAtomValue( + environmentId === null + ? NO_PREPARED_CONNECTION + : environmentSession.preparedConnectionValueAtom(environmentId), + ); + const target = Option.getOrNull(prepared)?.target; + const key = deviceLocalPreferenceKey(readEditorClientId(), target); + const [routes, setRoutes] = useLocalStorage( + DEVICE_LOCAL_ROUTES_KEY, + [], + DeviceLocalRoutes, + ); + return { + available: key !== null && window.desktopBridge?.openLocalEditor !== undefined, + enabled: + key !== null && window.desktopBridge?.openLocalEditor !== undefined && routes.includes(key), + setEnabled: (enabled: boolean) => { + if (key !== null) setRoutes((previous) => withDeviceLocalRoute(previous, key, enabled)); + }, + }; +} + export function useRemoteOpenResolution(environmentId: EnvironmentId | null): RemoteOpenResolution { - return useAtomValue( + const resolution = useAtomValue( environmentId === null ? UNRESOLVED_REMOTE_OPEN_ATOM : remoteOpenResolutionAtom(environmentId), ); + const preference = useDeviceLocalEditorPreference(environmentId); + return preference.enabled + ? { state: { mode: "device-local" }, isResolved: resolution.isResolved } + : resolution; } export function useRemoteOpenState(environmentId: EnvironmentId | null): RemoteOpenState { @@ -163,6 +244,21 @@ export function useRemoteCapableEditors(): ReadonlyArray { return useAtomValue(remoteCapableEditorsAtom); } +// Device-local launches need no SSH scheme, so this covers every editor with a CLI on this device. +const localEditorsAtom = Atom.make((get): ReadonlyArray => { + const probe = window.desktopBridge?.probeLocalEditors; + probe?.().then( + // An empty result is real: no launchable editor, so the picker must not offer the fallback. + (ids) => get.setSelf(ids), + () => undefined, + ); + return REMOTE_FALLBACK_EDITORS; +}).pipe(Atom.keepAlive, Atom.withLabel("local-editors")); + +export function useLocalEditors(): ReadonlyArray { + return useAtomValue(localEditorsAtom); +} + /** Resolves false when the desktop refuses the URL, including unsupported editor schemes. */ export async function openRemoteEditorUrl(url: string): Promise { const bridge = window.desktopBridge; @@ -177,6 +273,26 @@ export async function openRemoteEditorUrl(url: string): Promise { return true; } +export async function openDeviceLocalEditor(editor: EditorId, path: string): Promise { + try { + const result = await window.desktopBridge?.openLocalEditor?.({ editor, path }); + if (result?.ok) return true; + toastManager.add({ + type: "error", + title: "Could not open editor on this device", + description: + result?.reason === "path-missing" + ? `Path does not exist on this device: ${path}. Local paths must match the environment.` + : result?.reason === "unsupported-launcher" + ? "This editor's launcher cannot be started without a shell on this system." + : `Local editor opening failed (${result?.reason ?? "desktop bridge unavailable"}). No server-side fallback was attempted.`, + }); + } catch { + toastManager.add({ type: "error", title: "Could not open editor on this device" }); + } + return false; +} + const REMOTE_OPEN_HINT_KEY = "t3code:remote-open-hint-seen"; export function useRemoteOpenHint(): readonly [seen: boolean, markSeen: () => void] { diff --git a/docs/user/remote-access.md b/docs/user/remote-access.md index 649dfbca63780..01ab247c55e83 100644 --- a/docs/user/remote-access.md +++ b/docs/user/remote-access.md @@ -149,6 +149,20 @@ shown there apply only to the route marked **In use**; other routes are not checked. Direct pairing and T3 Connect have separate sessions and may grant different permissions. +### Open files in an editor on this device + +By default, T3 Code never assumes a remote machine's files exist on your +computer, even when its address is `localhost` (a tunnel or VM can use it too). +If the paths really are the same on this computer, for example a shared mount or +a forwarded VM folder, open the **Open in** menu in the desktop app and choose +**Editor location: This device**. Choose **Automatic** to undo it. + +The choice applies only to this desktop installation, that machine, and the +route currently in use. Another route, another installation, or a restored +backup starts on **Automatic**. The editor opens the same absolute path on this +computer; if it does not exist here, T3 Code shows an error and does not ask the +machine to open it. It is not available on web or mobile. + ### Balance new threads across machines Auto balance is off by default. On web and desktop, enable it in diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index afb87b14bc91f..f617af4b7eb72 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -13,7 +13,7 @@ import { AuthAccessTokenResult, AuthSessionState } from "./auth.ts"; import { AdvertisedEndpoint } from "./remoteAccess.ts"; import { ExecutionEnvironmentDescriptor } from "./environment.ts"; import { type ClientSettings, type QuitConfirmationMode, SnapShotShortcut } from "./settings.ts"; -import type { EditorId } from "./editor.ts"; +import { EditorId } from "./editor.ts"; import { KeybindingShortcut, type PreviewForwardedShortcut } from "./keybindings.ts"; import type { @@ -1024,6 +1024,30 @@ export const DesktopMediaReadSchema = Schema.Struct({ }); export type DesktopMediaRead = typeof DesktopMediaReadSchema.Type; +export const DesktopOpenLocalEditorInputSchema = Schema.Struct({ + editor: EditorId, + path: TrimmedNonEmptyString, +}); +export type DesktopOpenLocalEditorInput = typeof DesktopOpenLocalEditorInputSchema.Type; + +export const DesktopOpenLocalEditorFailureReason = Schema.Literals([ + "not-allowed", + "unsupported-editor", + "invalid-path", + "path-missing", + "editor-unavailable", + "unsafe-path", + "unsupported-launcher", + "spawn-failed", +]); +export type DesktopOpenLocalEditorFailureReason = typeof DesktopOpenLocalEditorFailureReason.Type; + +export const DesktopOpenLocalEditorResultSchema = Schema.Union([ + Schema.Struct({ ok: Schema.Literal(true) }), + Schema.Struct({ ok: Schema.Literal(false), reason: DesktopOpenLocalEditorFailureReason }), +]); +export type DesktopOpenLocalEditorResult = typeof DesktopOpenLocalEditorResultSchema.Type; + export const SystemSettingsPaneSchema = Schema.Literals(["full-disk-access"]); export type SystemSettingsPane = typeof SystemSettingsPaneSchema.Type; @@ -1120,6 +1144,12 @@ export interface DesktopBridge { openSystemSettings: (pane: SystemSettingsPane) => Promise; checkSystemPermission: (pane: SystemSettingsPane) => Promise; probeRemoteEditors: () => Promise; + /** Editors this device can launch directly, including ones without a remote-SSH scheme. */ + probeLocalEditors: () => Promise; + /** Stable per-install id kept outside web storage; null when it cannot be read. */ + getEditorClientId: () => string | null; + /** Starts an editor on this device for an existing absolute path; never contacts an environment. */ + openLocalEditor: (input: DesktopOpenLocalEditorInput) => Promise; pasteAsText: () => Promise; onMenuAction: (listener: (action: string) => void) => () => void; onSnapShotEvent: (listener: (event: DesktopSnapShotEvent) => void) => () => void;