Skip to content
7 changes: 7 additions & 0 deletions apps/desktop/src/ipc/DesktopIpcHandlers.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -47,7 +48,10 @@ import {
openSystemSettings,
checkSystemPermission,
pasteAsText,
probeLocalEditors,
probeRemoteEditors,
openLocalEditor,
getEditorClientId,
revealWindow,
pickFolder,
pickProjectFavicon,
Expand Down Expand Up @@ -97,6 +101,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);
Expand Down Expand Up @@ -159,6 +164,8 @@ export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers"
yield* ipc.handle(revealWindow);
yield* ipc.handle(syncHtmlFullscreen);
yield* ipc.handle(probeRemoteEditors);
yield* ipc.handle(probeLocalEditors);
yield* ipc.handle(openLocalEditor).pipe(Effect.provide(NodeChildProcessSpawner.layer));
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
yield* ipc.handle(getUpdateState);
yield* ipc.handle(setUpdateChannel);
yield* ipc.handle(downloadUpdate);
Expand Down
3 changes: 3 additions & 0 deletions apps/desktop/src/ipc/channels.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
25 changes: 25 additions & 0 deletions apps/desktop/src/ipc/methods/editorClientId.test.ts
Original file line number Diff line number Diff line change
@@ -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)),
);
20 changes: 20 additions & 0 deletions apps/desktop/src/ipc/methods/editorClientId.ts
Original file line number Diff line number Diff line change
@@ -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;
}
}
202 changes: 202 additions & 0 deletions apps/desktop/src/ipc/methods/localEditorLaunch.test.ts
Original file line number Diff line number Diff line change
@@ -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<string>;
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 = <A, E, R>(
body: (input: { readonly directory: string; readonly editor: string }) => Effect.Effect<A, E, R>,
) =>
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", "zeditor"), "#!/bin/sh\n");
yield* fs.chmod(path.join(directory, "bin", "zeditor"), 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))),
);
});
});
Loading
Loading