Skip to content
Closed
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
9 changes: 8 additions & 1 deletion apps/desktop/src/backend/DesktopBackendPool.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -178,7 +178,14 @@ function layerPool(
DesktopState.layer,
DesktopWslEnvironment.layerTest(),
ElectronDialog.layer,
ElectronApp.layer,
ElectronApp.layer.pipe(
Layer.provide(
Layer.succeed(
ChildProcessSpawner.ChildProcessSpawner,
ChildProcessSpawner.make(() => Effect.die("unexpected child process spawn")),
),
),
),
Layer.succeed(DesktopWindow.DesktopWindow, {
createMain: Effect.die("unexpected window create"),
ensureMain: Effect.die("unexpected window ensure"),
Expand Down
188 changes: 185 additions & 3 deletions apps/desktop/src/electron/ElectronApp.test.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,28 @@
// @effect-diagnostics nodeBuiltinImport:off -- The relaunch regression needs a separate parent process that really exits.
import * as NodeChildProcess from "node:child_process";
import * as NodeEvents from "node:events";
import * as NodeFSP from "node:fs/promises";
import * as NodeNet from "node:net";
import * as NodeOS from "node:os";
import * as NodePath from "node:path";
import * as NodeURL from "node:url";
import * as NodeServices from "@effect/platform-node/NodeServices";
import { assert, describe, it } from "@effect/vitest";
import * as HostProcess from "@t3tools/shared/HostProcess";
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
import { beforeEach, vi } from "vite-plus/test";

const { getSystemLocaleMock, getUserDefaultMock } = vi.hoisted(() => ({
const { getSystemLocaleMock, getUserDefaultMock, relaunchMock } = vi.hoisted(() => ({
relaunchMock: vi.fn(),
getSystemLocaleMock: vi.fn(() => "en-GB"),
getUserDefaultMock: vi.fn((_key: string) => false),
}));

vi.mock("electron", () => ({
app: {
getSystemLocale: getSystemLocaleMock,
relaunch: relaunchMock,
},
systemPreferences: {
getUserDefault: getUserDefaultMock,
Expand All @@ -19,10 +31,13 @@ vi.mock("electron", () => ({

import * as ElectronApp from "./ElectronApp.ts";

const electronAppLayer = ElectronApp.layer.pipe(Layer.provide(NodeServices.layer));

describe("ElectronApp", () => {
beforeEach(() => {
getSystemLocaleMock.mockReset();
getUserDefaultMock.mockReset();
relaunchMock.mockClear();
});

it("keeps the macOS region override and hour cycle in the locale tag", () => {
Expand All @@ -48,7 +63,7 @@ describe("ElectronApp", () => {
);

assert.strictEqual(systemLocale, "de-DE");
}).pipe(Effect.provide(ElectronApp.layer)),
}).pipe(Effect.provide(electronAppLayer)),
);

it.effect("falls back to Chromium's locale on Linux when the time locale is C", () =>
Expand All @@ -61,7 +76,174 @@ describe("ElectronApp", () => {
);

assert.strictEqual(systemLocale, "fr-FR");
}).pipe(Effect.provide(ElectronApp.layer)),
}).pipe(Effect.provide(electronAppLayer)),
);

it.effect("relaunches through app.relaunch() off Linux", () =>
Effect.gen(function* () {
const electronApp = yield* ElectronApp.ElectronApp;
yield* electronApp.relaunch({ args: ["--flag"] });

assert.deepEqual(relaunchMock.mock.calls, [[{ args: ["--flag"] }]]);
}).pipe(
Effect.provide(electronAppLayer),
Comment on lines +84 to +89

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This only mirrors a mock call for the unchanged non-Linux path. Could you delete this test?

Posted via Macroscope — Test Signal

Effect.provideService(HostProcess.Platform, "darwin"),
),
);

it("relaunches the AppImage only when its mount contains the current executable", () => {
const currentExecPath = "/tmp/t3-mount/t3code";
for (const [execPath, env, expected] of [
[currentExecPath, {}, currentExecPath],
[currentExecPath, { APPIMAGE: "/Apps/T3.AppImage" }, currentExecPath],
[currentExecPath, { APPDIR: "/tmp/t3-mount" }, currentExecPath],
[
currentExecPath,
{ APPIMAGE: "/Apps/T3.AppImage", APPDIR: "/tmp/t3-mount/" },
"/Apps/T3.AppImage",
],
[currentExecPath, { APPIMAGE: "/Apps/T3.AppImage", APPDIR: "/tmp/t3" }, currentExecPath],
[currentExecPath, { APPIMAGE: "/Apps/T3.AppImage", APPDIR: "/unrelated" }, currentExecPath],
[currentExecPath, { APPIMAGE: "/Apps/T3.AppImage", APPDIR: "tmp/t3-mount" }, currentExecPath],
[
currentExecPath,
{ APPIMAGE: "/Apps/T3.AppImage", APPDIR: "/tmp/t3-mount/../other" },
currentExecPath,
],
[
"/tmp/t3-mount/custom",
{ APPIMAGE: "/Apps/T3.AppImage", APPDIR: "/tmp/t3-mount" },
"/tmp/t3-mount/custom",
],
] as const) {
assert.strictEqual(
ElectronApp.resolveLinuxRelaunchExecPath({ execPath, currentExecPath, env }),
expected,
);
}
});

it.skipIf(HostProcess.Platform.defaultValue() !== "linux")(
"keeps the detached Linux relaunch alive until parent exit without setting NoNewPrivs",
async () => {
const directory = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3 relaunch "));
const executable = NodePath.join(directory, "node $HOME (Nightly)");
await NodeFSP.symlink(process.execPath, executable);
const server = NodeNet.createServer();
const listening = NodeEvents.EventEmitter.once(server, "listening");
server.listen(0, "127.0.0.1");
await listening;
const address = server.address();
if (address === null || typeof address === "string") throw new Error("Missing TCP port");
let restarted = false;
const result = new Promise<string>((resolve, reject) => {
server.once("connection", (socket) => {
restarted = true;
let output = "";
socket.setEncoding("utf8");
socket.on("data", (chunk) => {
output += chunk;
});
socket.once("end", () => resolve(output));
socket.once("error", reject);
});
});
const args = ["two words", "$HOME", "$(printf expanded)", "'quoted'", "", "line\nbreak"];
// Node stands in for the relaunched executable; Electron itself is never launched.
const restartedScript = `
const fs = require("node:fs");
const net = require("node:net");
const socket = net.connect(Number(process.argv[1]), "127.0.0.1", () => {
socket.end(JSON.stringify({
args: process.argv.slice(2),
status: fs.readFileSync("/proc/self/status", "utf8"),
}));
});
`;
const parentScript = `
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
import * as NodeServices from "@effect/platform-node/NodeServices";
import * as Spawner from "effect/process/ChildProcessSpawner";
import * as App from "./src/electron/ElectronApp.ts";
import { readFileSync } from "node:fs";
let handle;
const capture = Layer.effect(Spawner.ChildProcessSpawner, Effect.gen(function* () {
const actual = yield* Spawner.ChildProcessSpawner;
return Spawner.make(command => actual.spawn(command).pipe(Effect.tap(value => {
handle = value;
return Effect.void;
})));
})).pipe(Layer.provide(NodeServices.layer));
await Effect.runPromise(Effect.gen(function* () {
const app = yield* App.ElectronApp;
yield* app.relaunch(${JSON.stringify({ execPath: executable, args: ["-e", restartedScript, String(address.port), ...args] })});
}).pipe(Effect.provide(App.layer.pipe(Layer.provide(capture)))));
process.stdout.write(JSON.stringify({
pid: handle.pid,
running: await Effect.runPromise(handle.isRunning),
status: readFileSync("/proc/self/status", "utf8"),
}) + "\\n");
process.stdin.resume();
process.stdin.once("end", () => process.exit(0));
`;
const parent = NodeChildProcess.spawn(
process.execPath,
["--input-type=module", "-e", parentScript],
{
cwd: NodeURL.fileURLToPath(new URL("../..", import.meta.url)),
stdio: ["pipe", "pipe", "pipe"],
},
);
const exited = NodeEvents.EventEmitter.once(parent, "exit");
let stderr = "";
parent.stderr.setEncoding("utf8").on("data", (chunk) => {
stderr += chunk;
});
let shellPid: number | undefined;
try {
const ready = await Promise.race([
NodeEvents.EventEmitter.once(parent.stdout, "data").then(([data]) =>
JSON.parse(String(data)),
),
exited.then(([code]) => {
throw new Error(`Relaunch parent exited: ${code} ${stderr}`);
}),
]);
shellPid = ready.pid;
assert.isNumber(shellPid);
assert.isTrue(ready.running, "relauncher survives its Effect scope closing");
assert.match(ready.status, /^NoNewPrivs:\s+0$/m);
const stat = await NodeFSP.readFile(`/proc/${shellPid}/stat`, "utf8");
const [, ppid, group, session] = stat.slice(stat.lastIndexOf(")") + 2).split(" ");
assert.equal(Number(ppid), parent.pid);
assert.equal(Number(group), shellPid, "detached process group");
assert.equal(Number(session), shellPid, "detached session");
assert.equal(
await NodeFSP.readlink(`/proc/${shellPid}/exe`),
await NodeFSP.realpath("/bin/sh"),
"the shell must still be waiting, even if the replacement executable starts slowly",
);
assert.isFalse(restarted, "stdin remains open after the relaunch scope closes");

parent.stdin.end();
assert.deepEqual(await exited, [0, null]);
const launched = JSON.parse(await result);
assert.deepEqual(launched.args, args);
assert.match(launched.status, /^NoNewPrivs:\s+0$/m);
} finally {
if (parent.exitCode === null) parent.kill();
if (shellPid !== undefined) {
try {
process.kill(shellPid, "SIGKILL");
} catch {
// The stand-in normally exits as soon as it sends its result.
}
}
server.close();
await NodeFSP.rm(directory, { recursive: true, force: true });
}
},
);
});

Expand Down
64 changes: 63 additions & 1 deletion apps/desktop/src/electron/ElectronApp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
import * as Schema from "effect/Schema";
import * as Scope from "effect/Scope";
import * as ChildProcess from "effect/process/ChildProcess";
import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner";

import * as Electron from "electron";

Expand Down Expand Up @@ -252,4 +254,64 @@ export const make = ElectronApp.of({
on: addScopedAppListener,
});

export const layer = Layer.succeed(ElectronApp, make);
export function resolveLinuxRelaunchExecPath(input: {
readonly execPath: string;
readonly currentExecPath: string;
readonly env: NodeJS.ProcessEnv;
}): string {
const appImage = input.env.APPIMAGE?.trim();
const appDir = input.env.APPDIR?.trim().replace(/\/+$/, "");
if (!appImage || !appDir?.startsWith("/") || input.execPath !== input.currentExecPath) {
return input.execPath;
}
const hasParentSegment = (path: string) => path.split("/").includes("..");
// The AppImage mount disappears with this process, so start the AppImage itself.
return input.execPath.startsWith(`${appDir}/`) &&
!hasParentSegment(appDir) &&
!hasParentSegment(input.execPath)
? appImage
: input.execPath;
}

const makeWithLinuxRelaunch = Effect.gen(function* () {
const platform = yield* HostProcess.Platform;
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
const env = yield* HostProcess.Environment;
const currentExecPath = yield* HostProcess.ExecutablePath;
if (platform !== "linux") return make;

return ElectronApp.of({
...make,
// Electron's Linux relauncher sets irreversible no_new_privs, breaking pkexec updates.
// Keep stdin open until this process exits so the replacement can take its instance lock.
relaunch: Effect.fn("ElectronApp.relaunch")(function* (options) {
yield* spawner
.spawn(
ChildProcess.make(
"/bin/sh",
[
"-c",
'while read -r _; do :; done; exec "$@" </dev/null',
"t3code-relaunch",
resolveLinuxRelaunchExecPath({
execPath: options.execPath ?? currentExecPath,
currentExecPath,
env,
}),
...(options.args ?? process.argv.slice(1)),
],
{ detached: true, stdin: "pipe", stdout: "ignore", stderr: "ignore" },
),
)
// Keeps the waiting shell alive when this scope closes.
.pipe(
Effect.flatMap((handle) => handle.unref),
Effect.asVoid,
Effect.scoped,
Effect.catch(() => make.relaunch(options)),
);
}),
});
});

export const layer = Layer.effect(ElectronApp, makeWithLinuxRelaunch);
51 changes: 51 additions & 0 deletions apps/desktop/src/electron/ElectronUpdater.installError.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { assert, describe, it } from "@effect/vitest";
import * as Effect from "effect/Effect";
import * as Cause from "effect/Cause";
import { vi } from "vite-plus/test";

const { installed } = vi.hoisted(() => ({
installed: { updater: undefined as typeof import("electron-updater").autoUpdater | undefined },
}));

vi.mock("electron-updater", () => ({
AppImageUpdater: vi.fn(),
get autoUpdater() {
return installed.updater;
},
}));

import * as ElectronUpdater from "./ElectronUpdater.ts";
import { makeInstalledDebUpdater } from "../updates/updatesTestHarness.ts";

describe("ElectronUpdater", () => {
it.effect(
"rejects an install error emitted by the installed DebUpdater and removes its listener",
() =>
Effect.gen(function* () {
const cause = new Error("pkexec exited with code 126: authentication cancelled");
const native = yield* Effect.promise(() => makeInstalledDebUpdater(cause));
installed.updater = native.updater;
try {
const updater = yield* ElectronUpdater.ElectronUpdater;
const listenersBefore = native.updater.listenerCount("error");
const exit = yield* updater
.quitAndInstall({ isSilent: true, isForceRunAfter: false })
.pipe(Effect.exit);
assert.equal(
exit._tag,
"Failure",
"the emitted installer error must fail the service call",
);
if (exit._tag === "Success") return;
const error = Cause.squash(exit.cause);
assert.instanceOf(error, ElectronUpdater.ElectronUpdaterQuitAndInstallError);
assert.strictEqual(error.cause, cause);
assert.deepEqual(native.steps, ["install"]);
assert.equal(native.updater.listenerCount("error"), listenersBefore);
} finally {
installed.updater = undefined;
native.restore();
}
}).pipe(Effect.provide(ElectronUpdater.layer)),
);
});
Loading
Loading