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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 73 additions & 0 deletions apps/server/src/process/externalLauncher.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,79 @@ it.effect.skipIf(windowsHost)(
}).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
);

// Windows drive letters cannot resolve the POSIX editor stub.
it.effect.skipIf(windowsHost)(
"a Linux editor launch without a display takes the desktop session's display from the systemd user manager",
() =>
Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const binDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-editors-" });
const codiumPath = path.join(binDir, "codium");
yield* fileSystem.writeFileString(codiumPath, "#!/bin/sh\n");
yield* fileSystem.chmod(codiumPath, 0o755);

const launchEnv = (sessionManager: MockSpawnResult) =>
Effect.gen(function* () {
const spawned: ChildProcess.StandardCommand[] = [];
yield* Effect.gen(function* () {
const launcher = yield* ExternalLauncher.ExternalLauncher;
yield* launcher.launchEditor({ editor: "vscodium", cwd: "/workspace" });
yield* launcher.launchBrowser("https://example.com");
}).pipe(
Effect.provide(
layerTest({
platform: "linux",
env: { PATH: binDir },
onSpawn: (command) => spawned.push(command),
spawnResult: (command) =>
command.command === "busctl" ? sessionManager : undefined,
}),
),
Effect.provideService(HostProcess.Environment, { PATH: binDir }),
);
// Only editors borrow the session display; a browser must not open on that screen.
assert.deepEqual(spawned.find((command) => command.command === "xdg-open")?.options.env, {
PATH: binDir,
});
return spawned.find((command) => command.command === "codium")?.options.env;
});

assert.deepEqual(
yield* launchEnv({
stdout: JSON.stringify({
type: "as",
data: [
"DISPLAY=:0",
"HOME=/home/user",
"WAYLAND_DISPLAY=wayland-1",
"XAUTHORITY=/home/user/auth files/cookie",
],
}),
}),
{
PATH: binDir,
DISPLAY: ":0",
WAYLAND_DISPLAY: "wayland-1",
// Raw, not the $'...' quoting `systemctl show-environment` would print.
XAUTHORITY: "/home/user/auth files/cookie",
},
);
assert.deepEqual(yield* launchEnv({ stdout: "invalid JSON" }), { PATH: binDir });
assert.deepEqual(yield* launchEnv({ stdout: '{"data":[42]}' }), { PATH: binDir });
assert.deepEqual(
yield* launchEnv({
spawnError: PlatformError.systemError({
_tag: "NotFound",
module: "ChildProcess",
method: "spawn",
}),
}),
{ PATH: binDir },
);
}).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
);

it.effect("launches an installed editor with platform-safe arguments", () =>
Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
Expand Down
54 changes: 53 additions & 1 deletion apps/server/src/process/externalLauncher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import * as Layer from "effect/Layer";
import * as Option from "effect/Option";
import * as Path from "effect/Path";
import * as Ref from "effect/Ref";
import * as Schema from "effect/Schema";
import * as Scope from "effect/Scope";
import * as Stream from "effect/Stream";
import * as ChildProcess from "effect/process/ChildProcess";
Expand Down Expand Up @@ -673,6 +674,56 @@ const launchEditor = Effect.fn("externalLauncher.launchEditor")(function* (
yield* launchEditorProcess(launch);
});

const GRAPHICAL_SESSION_ENV_KEYS: ReadonlySet<string> = new Set([
"DISPLAY",
"WAYLAND_DISPLAY",
"XAUTHORITY",
]);

// The user manager's environment over D-Bus. `systemctl show-environment` would
// shell-quote values with spaces as $'...', while this JSON carries them raw.
const decodeManagerEnvironment = Schema.decodeUnknownEffect(
Schema.fromJsonString(Schema.Struct({ data: Schema.Array(Schema.String) })),
);

// Boot services miss the desktop environment imported later into the systemd user manager.
const readGraphicalSessionEnv = Effect.fn("externalLauncher.readGraphicalSessionEnv")(
function* () {
const hostEnv = yield* HostProcess.Environment;
if ((yield* HostProcess.Platform) !== "linux" || hasGraphicalLinuxSession(hostEnv)) {
return {};
}
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
const output = yield* spawner.string(
ChildProcess.make(
"busctl",
[
"--user",
"--json=short",
"get-property",
"org.freedesktop.systemd1",
"/org/freedesktop/systemd1",
"org.freedesktop.systemd1.Manager",
"Environment",
],
{ stdin: "ignore", stderr: "ignore" },
),
);
const { data } = yield* decodeManagerEnvironment(output);
return Object.fromEntries(
data.flatMap((line) => {
const separator = line.indexOf("=");
const key = line.slice(0, separator);
return separator > 0 && GRAPHICAL_SESSION_ENV_KEYS.has(key)
? [[key, line.slice(separator + 1)]]
: [];
}),
);
},
Effect.timeout("2 seconds"),
Effect.orElseSucceed((): NodeJS.ProcessEnv => ({})),
);

const launchAndUnref = Effect.fn("externalLauncher.launchAndUnref")(function* (
launch: ProcessLaunch,
onError: (cause: unknown) => ExternalLauncherError,
Expand All @@ -682,7 +733,7 @@ const launchAndUnref = Effect.fn("externalLauncher.launchAndUnref")(function* (
const { ELECTRON_RUN_AS_NODE: _runAsNode, ...env } = yield* HostProcess.Environment;
const command = ChildProcess.make(launch.command, launch.args, {
...launch.options,
env,
env: { ...env, ...launch.options.env },
extendEnv: false,
});

Expand Down Expand Up @@ -736,6 +787,7 @@ const launchEditorProcess = Effect.fn("externalLauncher.launchEditorProcess")(fu
options: {
detached: launch.detached ?? true,
shell: spawnCommand.shell,
env: yield* readGraphicalSessionEnv(),
stdin: "ignore",
stdout: "ignore",
stderr: "ignore",
Expand Down
Loading