From a9d777a6ce14663d86a193fb44a20e310ec86c3e Mon Sep 17 00:00:00 2001 From: Tony <97451137+TonybynMp4@users.noreply.github.com> Date: Sat, 3 Oct 2026 22:20:39 +0200 Subject: [PATCH 1/5] fix(desktop): relaunch on Linux without setting no_new_privs app.relaunch() starts the new instance with PR_SET_NO_NEW_PRIVS set (electron/electron#41463). The flag can't be cleared, so pkexec fails and every later .deb update from that instance fails too. On Linux, ElectronApp.relaunch now spawns a detached /bin/sh that waits for this process to exit and then execs the app, which leaves the flag unset. The .deb update path passes isForceRunAfter: false to electron-updater and relaunches through ElectronApp instead. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/backend/DesktopBackendPool.test.ts | 9 +- apps/desktop/src/electron/ElectronApp.test.ts | 156 +++++++++++++++++- apps/desktop/src/electron/ElectronApp.ts | 39 ++++- .../src/ipc/methods/notificationBadge.test.ts | 4 +- apps/desktop/src/main.ts | 2 +- .../src/updates/DesktopUpdates.test.ts | 18 ++ apps/desktop/src/updates/DesktopUpdates.ts | 10 +- .../desktop/src/updates/updatesTestHarness.ts | 32 +++- 8 files changed, 260 insertions(+), 10 deletions(-) diff --git a/apps/desktop/src/backend/DesktopBackendPool.test.ts b/apps/desktop/src/backend/DesktopBackendPool.test.ts index 2b2dd841ab53f..f698f44083715 100644 --- a/apps/desktop/src/backend/DesktopBackendPool.test.ts +++ b/apps/desktop/src/backend/DesktopBackendPool.test.ts @@ -66,7 +66,14 @@ function layerPool(labelRef: Ref.Ref): Layer.Layer Effect.die("unexpected child process spawn")), + ), + ), + ), Layer.succeed(DesktopWindow.DesktopWindow, { createMain: Effect.die("unexpected window create"), ensureMain: Effect.die("unexpected window ensure"), diff --git a/apps/desktop/src/electron/ElectronApp.test.ts b/apps/desktop/src/electron/ElectronApp.test.ts index 30b7f2d7387e3..2622740ee006a 100644 --- a/apps/desktop/src/electron/ElectronApp.test.ts +++ b/apps/desktop/src/electron/ElectronApp.test.ts @@ -1,9 +1,20 @@ +// @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), })); @@ -11,6 +22,7 @@ const { getSystemLocaleMock, getUserDefaultMock } = vi.hoisted(() => ({ vi.mock("electron", () => ({ app: { getSystemLocale: getSystemLocaleMock, + relaunch: relaunchMock, }, systemPreferences: { getUserDefault: getUserDefaultMock, @@ -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", () => { @@ -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", () => @@ -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((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 }); + } + }, ); }); diff --git a/apps/desktop/src/electron/ElectronApp.ts b/apps/desktop/src/electron/ElectronApp.ts index d677baa56dce3..55e40ceedf299 100644 --- a/apps/desktop/src/electron/ElectronApp.ts +++ b/apps/desktop/src/electron/ElectronApp.ts @@ -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"; @@ -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 "$@" handle.unref), + Effect.asVoid, + Effect.scoped, + Effect.catch(() => make.relaunch(options)), + ); + }), + }); +}); + +export const layer = Layer.effect(ElectronApp, makeWithLinuxRelaunch); diff --git a/apps/desktop/src/ipc/methods/notificationBadge.test.ts b/apps/desktop/src/ipc/methods/notificationBadge.test.ts index 7126b1cd31945..882b44dfefe7b 100644 --- a/apps/desktop/src/ipc/methods/notificationBadge.test.ts +++ b/apps/desktop/src/ipc/methods/notificationBadge.test.ts @@ -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"; @@ -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); diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 2fcb8d968c5a4..730814a4a0db7 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -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( diff --git a/apps/desktop/src/updates/DesktopUpdates.test.ts b/apps/desktop/src/updates/DesktopUpdates.test.ts index 3b45d80bbc6e2..36ab7d5c76de9 100644 --- a/apps/desktop/src/updates/DesktopUpdates.test.ts +++ b/apps/desktop/src/updates/DesktopUpdates.test.ts @@ -475,6 +475,24 @@ describe("DesktopUpdates", () => { ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer))); }); + it.effect("relaunches a .deb install itself instead of through electron-updater", () => { + const harness = makeHarness({ platform: "linux", packageType: "deb" }); + + return Effect.scoped( + Effect.gen(function* () { + const updates = yield* DesktopUpdates.DesktopUpdates; + yield* updates.configure; + harness.emit("update-downloaded", { version: "1.2.4" }); + yield* flushCallbacks; + + assert.isTrue((yield* updates.install).accepted); + assert.include(harness.installSteps, "quitAndInstall(no-run)"); + assert.include(harness.installSteps, "relaunch"); + assert.notInclude(harness.installSteps, "quitAndInstall"); + }), + ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer))); + }); + it.effect("drops the update restart marker when an install is interrupted", () => Effect.gen(function* () { const stopping = yield* Deferred.make(); diff --git a/apps/desktop/src/updates/DesktopUpdates.ts b/apps/desktop/src/updates/DesktopUpdates.ts index 6279e8b6141a8..5b6bcd7fda2ab 100644 --- a/apps/desktop/src/updates/DesktopUpdates.ts +++ b/apps/desktop/src/updates/DesktopUpdates.ts @@ -30,6 +30,7 @@ import * as DesktopConfig from "../app/DesktopConfig.ts"; import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; import * as DesktopObservability from "../app/DesktopObservability.ts"; import * as DesktopState from "../app/DesktopState.ts"; +import * as ElectronApp from "../electron/ElectronApp.ts"; import * as ElectronUpdater from "../electron/ElectronUpdater.ts"; import * as ElectronWindow from "../electron/ElectronWindow.ts"; import * as IpcChannels from "../ipc/channels.ts"; @@ -365,6 +366,7 @@ export const make = Effect.gen(function* () { const config = yield* DesktopConfig.DesktopConfig; const pool = yield* DesktopBackendPool.DesktopBackendPool; const desktopState = yield* DesktopState.DesktopState; + const electronApp = yield* ElectronApp.ElectronApp; const electronUpdater = yield* ElectronUpdater.ElectronUpdater; const electronWindow = yield* ElectronWindow.ElectronWindow; const environment = yield* DesktopEnvironment.DesktopEnvironment; @@ -822,10 +824,16 @@ export const make = Effect.gen(function* () { (instance) => instance.stop({ timeout: Duration.seconds(5) }), { concurrency: "unbounded" }, ); + // electron-updater relaunches a .deb install with app.relaunch(), + // which leaves the new version unable to install the next update. + // ElectronApp.relaunch avoids that. yield* electronUpdater.quitAndInstall({ isSilent: true, - isForceRunAfter: true, + isForceRunAfter: !isDebPackage, }); + if (isDebPackage) { + yield* electronApp.relaunch({}); + } return { accepted: true, completed: false, failed: false }; }).pipe( Effect.catchTags({ diff --git a/apps/desktop/src/updates/updatesTestHarness.ts b/apps/desktop/src/updates/updatesTestHarness.ts index ec3b1ce9a1d59..6b5115c1825ab 100644 --- a/apps/desktop/src/updates/updatesTestHarness.ts +++ b/apps/desktop/src/updates/updatesTestHarness.ts @@ -9,6 +9,7 @@ import * as Option from "effect/Option"; import * as DesktopBackendPool from "../backend/DesktopBackendPool.ts"; import * as DesktopConfig from "../app/DesktopConfig.ts"; import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; +import * as ElectronApp from "../electron/ElectronApp.ts"; import * as ElectronUpdater from "../electron/ElectronUpdater.ts"; import * as ElectronWindow from "../electron/ElectronWindow.ts"; import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts"; @@ -99,10 +100,10 @@ export function makeHarness(options: UpdatesHarnessOptions = {}) { downloadUpdate: Effect.sync(() => { downloadCount += 1; }).pipe(Effect.andThen(options.downloadUpdate ?? Effect.void)), - quitAndInstall: () => + quitAndInstall: ({ isForceRunAfter }) => Effect.sync(() => { quitAndInstallCount += 1; - installSteps.push("quitAndInstall"); + installSteps.push(isForceRunAfter ? "quitAndInstall" : "quitAndInstall(no-run)"); }).pipe(Effect.andThen(options.quitAndInstall ?? Effect.void)), on: (eventName, listener) => Effect.acquireRelease( @@ -116,6 +117,32 @@ export function makeHarness(options: UpdatesHarnessOptions = {}) { ).pipe(Effect.asVoid), } satisfies ElectronUpdater.ElectronUpdater["Service"]); + const layerElectronApp = Layer.succeed(ElectronApp.ElectronApp, { + metadata: Effect.die("unexpected metadata read"), + name: Effect.succeed("T3 Code"), + systemLocale: Effect.succeed("en-US"), + whenReady: Effect.void, + requestSingleInstanceLock: Effect.succeed(true), + quit: Effect.void, + exit: () => Effect.void, + relaunch: () => + Effect.sync(() => { + installSteps.push("relaunch"); + }), + setPath: () => Effect.void, + setName: () => Effect.void, + setAboutPanelOptions: () => Effect.void, + setAppUserModelId: () => Effect.void, + getAppMetrics: Effect.succeed([]), + setAsDefaultProtocolClient: () => Effect.succeed(true), + setDesktopName: () => Effect.void, + setDockIcon: () => Effect.void, + appendCommandLineSwitch: () => Effect.void, + onBeforeQuitForUpdate: () => Effect.void, + removeCommandLineSwitch: () => Effect.void, + on: () => Effect.void, + } satisfies ElectronApp.ElectronApp["Service"]); + const layerWindow = Layer.succeed(ElectronWindow.ElectronWindow, { create: () => Effect.die("unexpected BrowserWindow creation"), main: Effect.succeedNone, @@ -275,6 +302,7 @@ export function makeHarness(options: UpdatesHarnessOptions = {}) { Layer.provide(layerFileSystem), Layer.provide(DesktopUpdatePolicy.layerTest(options.policy)), Layer.provideMerge(layerUpdater), + Layer.provideMerge(layerElectronApp), Layer.provideMerge(layerWindow), Layer.provideMerge(layerBackend), Layer.provideMerge(DesktopState.layer), From 798d94c04fa523fb85d879eaa76a46a99f4a7171 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 19 Oct 2026 10:28:13 +0000 Subject: [PATCH 2/5] fix(desktop): avoid relaunch after a failed deb update --- .../src/electron/ElectronUpdater.test.ts | 50 +++++++++ apps/desktop/src/electron/ElectronUpdater.ts | 16 ++- .../src/updates/DesktopUpdates.test.ts | 106 +++++++++++++++--- .../desktop/src/updates/updatesTestHarness.ts | 85 +++++++++++++- 4 files changed, 237 insertions(+), 20 deletions(-) create mode 100644 apps/desktop/src/electron/ElectronUpdater.test.ts diff --git a/apps/desktop/src/electron/ElectronUpdater.test.ts b/apps/desktop/src/electron/ElectronUpdater.test.ts new file mode 100644 index 0000000000000..9aba1f9e041fc --- /dev/null +++ b/apps/desktop/src/electron/ElectronUpdater.test.ts @@ -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)), + ); +}); diff --git a/apps/desktop/src/electron/ElectronUpdater.ts b/apps/desktop/src/electron/ElectronUpdater.ts index c71b42040db29..071e139d56d00 100644 --- a/apps/desktop/src/electron/ElectronUpdater.ts +++ b/apps/desktop/src/electron/ElectronUpdater.ts @@ -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"; @@ -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(); + 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, diff --git a/apps/desktop/src/updates/DesktopUpdates.test.ts b/apps/desktop/src/updates/DesktopUpdates.test.ts index 36ab7d5c76de9..e3bead9ba4743 100644 --- a/apps/desktop/src/updates/DesktopUpdates.test.ts +++ b/apps/desktop/src/updates/DesktopUpdates.test.ts @@ -1,3 +1,5 @@ +import * as NodeEvents from "node:events"; +import { vi } from "vite-plus/test"; import { assert, describe, it } from "@effect/vitest"; import { DESKTOP_UPDATE_RESTART_MARKER_FILE } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; @@ -20,7 +22,17 @@ import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; import * as DesktopState from "../app/DesktopState.ts"; import * as DesktopUpdatePolicy from "./DesktopUpdatePolicy.ts"; import * as DesktopUpdates from "./DesktopUpdates.ts"; -import { flushCallbacks, makeHarness } from "./updatesTestHarness.ts"; +import { flushCallbacks, makeHarness, makeInstalledDebUpdater } from "./updatesTestHarness.ts"; + +const installed = vi.hoisted(() => ({ + updater: undefined as typeof import("electron-updater").autoUpdater | undefined, +})); +vi.mock("electron-updater", () => ({ + get autoUpdater() { + if (!installed.updater) throw new Error("Unexpected native updater access"); + return installed.updater; + }, +})); describe("DesktopUpdates", () => { it.effect("updates Linux .deb installs and leaves other non-AppImage installs off", () => @@ -475,23 +487,81 @@ describe("DesktopUpdates", () => { ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer))); }); - it.effect("relaunches a .deb install itself instead of through electron-updater", () => { - const harness = makeHarness({ platform: "linux", packageType: "deb" }); - - return Effect.scoped( - Effect.gen(function* () { - const updates = yield* DesktopUpdates.DesktopUpdates; - yield* updates.configure; - harness.emit("update-downloaded", { version: "1.2.4" }); - yield* flushCallbacks; - - assert.isTrue((yield* updates.install).accepted); - assert.include(harness.installSteps, "quitAndInstall(no-run)"); - assert.include(harness.installSteps, "relaunch"); - assert.notInclude(harness.installSteps, "quitAndInstall"); - }), - ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer))); - }); + it.effect.each(["success", "failed", "cancelled"] as const)( + "handles a %s .deb install through the installed updater", + (outcome) => + Effect.scoped( + Effect.gen(function* () { + const native = yield* Effect.acquireRelease( + Effect.promise(() => + makeInstalledDebUpdater( + outcome === "success" + ? undefined + : new Error(`pkexec exited with code ${outcome === "cancelled" ? 126 : 1}`), + ), + ), + (native) => + Effect.sync(() => { + installed.updater = undefined; + native.restore(); + }), + ); + installed.updater = native.updater; + const install = vi.spyOn(native.updater, "quitAndInstall"); + const harness = makeHarness({ + platform: "linux", + packageType: "deb", + updater: ElectronUpdater.make, + }); + yield* Effect.scoped( + Effect.gen(function* () { + const updates = yield* DesktopUpdates.DesktopUpdates; + const desktopState = yield* DesktopState.DesktopState; + const pool = yield* DesktopBackendPool.DesktopBackendPool; + yield* updates.configure; + const { changes } = yield* updates.subscribe; + native.updater.emit("update-downloaded", { + version: "1.2.4", + downloadedFile: "/unused/test.deb", + releaseDate: "2026-10-01", + files: [], + path: "test.deb", + sha512: "test", + }); + yield* changes.pipe( + Stream.filter((state) => state.status === "downloaded"), + Stream.runHead, + ); + const quit = + outcome === "success" + ? NodeEvents.EventEmitter.once(native.events, "quit") + : undefined; + + assert.isTrue((yield* updates.install).accepted); + if (quit) yield* Effect.promise(() => quit); + assert.deepEqual(install.mock.calls, [[true, false]]); + if (quit) { + assert.deepEqual(native.steps, ["install", "quit"]); + assert.deepEqual(harness.installSteps, ["relaunch"]); + assert.isFalse((yield* (yield* pool.primary).snapshot).desiredRunning); + } else { + yield* changes.pipe( + Stream.filter((state) => state.errorContext === "install"), + Stream.runHead, + ); + assert.deepEqual(native.steps, ["install"]); + assert.deepEqual(harness.installSteps, ["startBackend"]); + assert.isFalse(yield* Ref.get(desktopState.quitting)); + assert.isTrue((yield* (yield* pool.primary).snapshot).desiredRunning); + assert.equal(harness.updateRestartMarkers.size, 0); + assert.equal(harness.installRecords.size, 0); + assert.equal((yield* updates.getState).errorContext, "install"); + } + }), + ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer))); + }), + ), + ); it.effect("drops the update restart marker when an install is interrupted", () => Effect.gen(function* () { diff --git a/apps/desktop/src/updates/updatesTestHarness.ts b/apps/desktop/src/updates/updatesTestHarness.ts index 6b5115c1825ab..61dbbea8b4cea 100644 --- a/apps/desktop/src/updates/updatesTestHarness.ts +++ b/apps/desktop/src/updates/updatesTestHarness.ts @@ -1,3 +1,7 @@ +import * as NodeEvents from "node:events"; +import * as NodeModule from "node:module"; +import { DebUpdater } from "electron-updater/out/DebUpdater.js"; +import { DownloadedUpdateHelper } from "electron-updater/out/DownloadedUpdateHelper.js"; import * as NodeServices from "@effect/platform-node/NodeServices"; import type { DesktopUpdateState } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; @@ -28,6 +32,7 @@ export interface UpdatesHarnessOptions { readonly setUpdateChannelError?: DesktopAppSettings.DesktopSettingsWriteError; readonly setDisableDifferentialDownload?: Effect.Effect; readonly downloadUpdate?: Effect.Effect; + readonly updater?: ElectronUpdater.ElectronUpdater["Service"]; readonly quitAndInstall?: Effect.Effect; readonly stopBackend?: Effect.Effect; readonly startBackend?: Effect.Effect; @@ -301,7 +306,11 @@ export function makeHarness(options: UpdatesHarnessOptions = {}) { const layer = DesktopUpdates.layer.pipe( Layer.provide(layerFileSystem), Layer.provide(DesktopUpdatePolicy.layerTest(options.policy)), - Layer.provideMerge(layerUpdater), + Layer.provideMerge( + options.updater + ? Layer.succeed(ElectronUpdater.ElectronUpdater, options.updater) + : layerUpdater, + ), Layer.provideMerge(layerElectronApp), Layer.provideMerge(layerWindow), Layer.provideMerge(layerBackend), @@ -346,3 +355,77 @@ export function makeHarness(options: UpdatesHarnessOptions = {}) { }, }; } + +// Keep BaseUpdater.quitAndInstall and DebUpdater.doInstall real; stub only OS commands. +export async function makeInstalledDebUpdater(installError?: Error) { + const events = new NodeEvents.EventEmitter(); + const steps: string[] = []; + class TestDebUpdater extends DebUpdater { + override hasCommand() { + return true; + } + override detectPackageManager() { + return "apt"; + } + override runCommandWithSudoIfNeeded() { + steps.push("install"); + if (installError) throw installError; + return ""; + } + async stageDownload() { + const helper = new DownloadedUpdateHelper("/unused/cache"); + const info = { url: "test.deb", sha512: "test" }; + await helper.setDownloadedFile( + "/unused/test.deb", + null, + { + version: "1.2.4", + releaseDate: "2026-10-01", + files: [info], + path: info.url, + sha512: info.sha512, + }, + { url: new URL("https://example.invalid/test.deb"), info }, + "test.deb", + false, + ); + this.downloadedUpdateHelper = helper; + } + } + const updater = new TestDebUpdater(null, { + version: "1.2.3", + name: "Test", + isPackaged: true, + appUpdateConfigPath: "/unused/app-update.yml", + userDataPath: "/unused/data", + baseCachePath: "/unused/cache", + whenReady: async () => {}, + relaunch: () => { + steps.push("nativeRelaunch"); + }, + quit: () => { + steps.push("quit"); + events.emit("quit"); + }, + onQuit: () => {}, + }); + updater.logger = null; + updater.channel = "latest"; + await updater.stageDownload(); + + // electron-updater uses CJS require, which bypasses Vitest's ESM mocks. + const require = NodeModule.createRequire(import.meta.url); + const electronPath = require.resolve("electron"); + require("electron"); + const electronModule = require.cache[electronPath]!; + const previousExports: unknown = electronModule.exports; + electronModule.exports = { autoUpdater: events }; + return { + updater, + steps, + events, + restore: () => { + electronModule.exports = previousExports; + }, + }; +} From 68256adcfaca62e1a14e31c2c17fd3f61fc41c6b Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 19 Oct 2026 10:35:33 +0000 Subject: [PATCH 3/5] test(desktop): simplify Linux relaunch coverage --- apps/desktop/src/updates/DesktopUpdates.test.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/apps/desktop/src/updates/DesktopUpdates.test.ts b/apps/desktop/src/updates/DesktopUpdates.test.ts index e3bead9ba4743..286040501a151 100644 --- a/apps/desktop/src/updates/DesktopUpdates.test.ts +++ b/apps/desktop/src/updates/DesktopUpdates.test.ts @@ -487,7 +487,7 @@ describe("DesktopUpdates", () => { ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer))); }); - it.effect.each(["success", "failed", "cancelled"] as const)( + it.effect.each(["success", "failed"] as const)( "handles a %s .deb install through the installed updater", (outcome) => Effect.scoped( @@ -495,9 +495,7 @@ describe("DesktopUpdates", () => { const native = yield* Effect.acquireRelease( Effect.promise(() => makeInstalledDebUpdater( - outcome === "success" - ? undefined - : new Error(`pkexec exited with code ${outcome === "cancelled" ? 126 : 1}`), + outcome === "success" ? undefined : new Error("pkexec exited with code 1"), ), ), (native) => @@ -537,7 +535,9 @@ describe("DesktopUpdates", () => { ? NodeEvents.EventEmitter.once(native.events, "quit") : undefined; - assert.isTrue((yield* updates.install).accepted); + const result = yield* updates.install; + assert.isTrue(result.accepted); + assert.isFalse(result.completed); if (quit) yield* Effect.promise(() => quit); assert.deepEqual(install.mock.calls, [[true, false]]); if (quit) { From 9e70d228ae271f373cc85d423908f8745971bcfc Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 19 Oct 2026 10:43:32 +0000 Subject: [PATCH 4/5] test(desktop): remove unused updater stub branch --- apps/desktop/src/updates/updatesTestHarness.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/updates/updatesTestHarness.ts b/apps/desktop/src/updates/updatesTestHarness.ts index 61dbbea8b4cea..2a3d273d47e28 100644 --- a/apps/desktop/src/updates/updatesTestHarness.ts +++ b/apps/desktop/src/updates/updatesTestHarness.ts @@ -105,10 +105,10 @@ export function makeHarness(options: UpdatesHarnessOptions = {}) { downloadUpdate: Effect.sync(() => { downloadCount += 1; }).pipe(Effect.andThen(options.downloadUpdate ?? Effect.void)), - quitAndInstall: ({ isForceRunAfter }) => + quitAndInstall: () => Effect.sync(() => { quitAndInstallCount += 1; - installSteps.push(isForceRunAfter ? "quitAndInstall" : "quitAndInstall(no-run)"); + installSteps.push("quitAndInstall"); }).pipe(Effect.andThen(options.quitAndInstall ?? Effect.void)), on: (eventName, listener) => Effect.acquireRelease( From 8adf741a9ed9572824d06209ea55b59c2cde3567 Mon Sep 17 00:00:00 2001 From: Tony <97451137+TonybynMp4@users.noreply.github.com> Date: Sun, 1 Nov 2026 18:15:17 +0100 Subject: [PATCH 5/5] test(desktop): type the installed DebUpdater harness explicitly Co-Authored-By: Claude Sonnet 5.5 --- apps/desktop/src/updates/updatesTestHarness.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src/updates/updatesTestHarness.ts b/apps/desktop/src/updates/updatesTestHarness.ts index 2a3d273d47e28..54ee2ac2e86f4 100644 --- a/apps/desktop/src/updates/updatesTestHarness.ts +++ b/apps/desktop/src/updates/updatesTestHarness.ts @@ -357,7 +357,12 @@ export function makeHarness(options: UpdatesHarnessOptions = {}) { } // Keep BaseUpdater.quitAndInstall and DebUpdater.doInstall real; stub only OS commands. -export async function makeInstalledDebUpdater(installError?: Error) { +export async function makeInstalledDebUpdater(installError?: Error): Promise<{ + readonly updater: DebUpdater; + readonly steps: string[]; + readonly events: NodeEvents.EventEmitter; + readonly restore: () => void; +}> { const events = new NodeEvents.EventEmitter(); const steps: string[] = []; class TestDebUpdater extends DebUpdater {