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
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 @@ -66,7 +66,14 @@ function layerPool(labelRef: Ref.Ref<string>): Layer.Layer<DesktopBackendPool.De
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
156 changes: 153 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,142 @@ 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),
Effect.provideService(HostProcess.Platform, "darwin"),
),
);

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
39 changes: 38 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 @@ -250,4 +252,39 @@ export const make = ElectronApp.of({
on: addScopedAppListener,
});

export const layer = Layer.succeed(ElectronApp, make);
const makeWithLinuxRelaunch = Effect.gen(function* () {
const platform = yield* HostProcess.Platform;
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
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",
options.execPath ?? process.execPath,
...(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);
50 changes: 50 additions & 0 deletions apps/desktop/src/electron/ElectronUpdater.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
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", () => ({
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)),
);
});
16 changes: 15 additions & 1 deletion apps/desktop/src/electron/ElectronUpdater.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import * as Context from "effect/Context";
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
import * as Option from "effect/Option";
import * as Schema from "effect/Schema";
import * as Scope from "effect/Scope";

Expand Down Expand Up @@ -154,7 +155,20 @@ export const make = ElectronUpdater.of({
return Effect.suspend(() => {
const channel = autoUpdater.channel;
return Effect.try({
try: () => autoUpdater.quitAndInstall(isSilent, isForceRunAfter),
try: () => {
// BaseUpdater reports synchronous install failures via events and returns normally.
let installError = Option.none<unknown>();
const onError = (cause: unknown) => {
installError = Option.some(cause);
};
autoUpdater.on("error", onError);
try {
autoUpdater.quitAndInstall(isSilent, isForceRunAfter);
if (Option.isSome(installError)) throw installError.value;
} finally {
autoUpdater.removeListener("error", onError);
}
},
catch: (cause) =>
new ElectronUpdaterQuitAndInstallError({
channel,
Expand Down
4 changes: 3 additions & 1 deletion apps/desktop/src/ipc/methods/notificationBadge.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import * as NodeServices from "@effect/platform-node/NodeServices";
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
import { beforeEach, expect, vi } from "vite-plus/test";
import { it } from "@effect/vitest";
import * as HostProcess from "@t3tools/shared/HostProcess";
Expand Down Expand Up @@ -79,7 +81,7 @@ it.effect("validates IPC and clears on native focus, quit, and disposal", () =>
).pipe(
Effect.provideService(HostProcess.Platform, "linux"),
Effect.provide([
ElectronApp.layer,
ElectronApp.layer.pipe(Layer.provide(NodeServices.layer)),
DesktopIpc.layer({
handle: (channel, handler) => {
handlers.set(channel, handler);
Expand Down
2 changes: 1 addition & 1 deletion apps/desktop/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -230,8 +230,8 @@ const layerDesktopClerk = DesktopClerk.layer.pipe(
Layer.provide(DesktopPreReadyFileSystem.layer),
Layer.provideMerge(ElectronShell.layer),
Layer.provideMerge(layerDesktopEnvironment),
Layer.provideMerge(NodeServices.layer),
Layer.provideMerge(ElectronApp.layer),
Layer.provideMerge(NodeServices.layer),
);

const layerDesktopApplicationRuntime = layerDesktopApplication.pipe(
Expand Down
Loading
Loading