Skip to content
Open
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
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 @@ -46,7 +47,10 @@ import {
openSystemSettings,
checkSystemPermission,
pasteAsText,
probeLocalEditors,
probeRemoteEditors,
openLocalEditor,
getEditorClientId,
revealWindow,
pickFolder,
pickProjectFavicon,
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
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", "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))),
);
});
});
Loading
Loading