diff --git a/apps/desktop/package.json b/apps/desktop/package.json index ab5c82bec329..e53f32de9991 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -26,7 +26,7 @@ "effect": "catalog:", "electron": "44.1.0", "electron-store": "^8.2.0", - "electron-updater": "^6.6.2", + "electron-updater": "6.8.3", "ffi-rs": "1.3.2", "playwright-core": "1.60.0", "react-grab": "^0.1.32" diff --git a/apps/desktop/src/electron/AppImageUpdater.test.ts b/apps/desktop/src/electron/AppImageUpdater.test.ts new file mode 100644 index 000000000000..6d4751117ccf --- /dev/null +++ b/apps/desktop/src/electron/AppImageUpdater.test.ts @@ -0,0 +1,188 @@ +// @effect-diagnostics nodeBuiltinImport:off -- Real files exercise the CommonJS updater installation boundary. +import * as NodeCrypto from "node:crypto"; +import * as NodeModule from "node:module"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; + +import { AppImageUpdater } from "electron-updater/out/AppImageUpdater.js"; +import { DownloadedUpdateHelper } from "electron-updater/out/DownloadedUpdateHelper.js"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +// The dependency uses CommonJS; spy on the same Node modules it loads. +const require = NodeModule.createRequire(import.meta.url); +const fs = require("node:fs") as typeof import("node:fs"); +const childProcess = require("node:child_process") as typeof import("node:child_process"); +const roots: string[] = []; +const oldBinary = "working AppImage"; +const newBinary = "verified replacement AppImage"; + +class TestUpdater extends AppImageUpdater { + override spawnLog = vi.fn(async () => true); + + async prepare(installer: string, sha512: string) { + this.downloadedUpdateHelper = new DownloadedUpdateHelper(NodePath.dirname(installer)); + await this.downloadedUpdateHelper.setDownloadedFile( + installer, + null, + { version: "1.1.0", files: [], path: "", sha512, releaseDate: "2026-09-08" }, + { + url: new URL("https://example.com/update.AppImage"), + info: { url: "update.AppImage", sha512 }, + }, + NodePath.basename(installer), + false, + ); + } +} + +async function fixture(name = "t3code.AppImage") { + const root = fs.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-appimage-")); + roots.push(root); + const installed = NodePath.join(root, name); + const cache = NodePath.join(root, "cache"); + fs.mkdirSync(cache); + const installer = NodePath.join(cache, "T3-Code-1.1.0.AppImage"); + fs.writeFileSync(installed, oldBinary, { mode: 0o755 }); + fs.writeFileSync(installer, newBinary, { mode: 0o755 }); + vi.stubEnv("APPIMAGE", installed); + const updater = new TestUpdater(null, { version: "1.0.0" }); + updater.logger = { info: vi.fn(), warn: vi.fn(), error: vi.fn() }; + const errors = vi.fn(); + updater.on("error", errors); + await updater.prepare( + installer, + NodeCrypto.createHash("sha512").update(newBinary).digest("base64"), + ); + return { root, installed, installer, updater, errors }; +} + +beforeEach(() => vi.useFakeTimers({ toFake: ["setImmediate"] })); + +afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + vi.unstubAllEnvs(); + for (const root of roots.splice(0)) fs.rmSync(root, { recursive: true, force: true }); +}); + +describe("AppImage installation", () => { + // eslint-disable-next-line t3code/no-global-process-runtime -- Directory fsync is a Linux installation boundary. + it.skipIf(NodeOS.platform() !== "linux").each([false, true])( + "syncs the renamed directory before cleanup and relaunch (sync failure: %s)", + async (failSync) => { + const { installed, installer, updater, errors, root } = + await fixture("T3-Code-1.0.0.AppImage"); + const destination = NodePath.join(root, NodePath.basename(installer)); + const sync = fs.fsyncSync; + let directoryFd: number | undefined; + vi.spyOn(fs, "fsyncSync").mockImplementation((fd) => { + if (fs.fstatSync(fd).isDirectory()) { + directoryFd = fd; + expect(fs.fstatSync(fd).ino).toBe(fs.statSync(root).ino); + expect(fs.readFileSync(destination, "utf8")).toBe(newBinary); + expect(fs.readFileSync(installed, "utf8")).toBe(oldBinary); + expect(fs.readFileSync(installer, "utf8")).toBe(newBinary); + expect(updater.spawnLog).not.toHaveBeenCalled(); + expect(updater.logger?.info).not.toHaveBeenCalledWith( + expect.stringContaining("Installed verified"), + ); + if (failSync) throw new Error("directory sync failed"); + } + sync(fd); + }); + + updater.quitAndInstall(true, true); + + const closedFd = directoryFd; + expect(closedFd).toBeDefined(); + if (closedFd !== undefined) expect(() => fs.fstatSync(closedFd)).toThrow(); + expect(errors).toHaveBeenCalledTimes(failSync ? 1 : 0); + expect(updater.spawnLog).toHaveBeenCalledTimes(failSync ? 0 : 1); + expect(vi.getTimerCount()).toBe(failSync ? 0 : 1); + expect(fs.existsSync(installed)).toBe(failSync); + expect(fs.existsSync(installer)).toBe(failSync); + }, + ); + + it.each(["empty", "corrupt", "ENOSPC"])( + "preserves the old executable after a %s copy", + async (fault) => { + const { installed, installer, updater, errors, root } = await fixture(); + const badCopy = (destination: import("node:fs").PathLike) => { + fs.writeFileSync(destination, fault === "corrupt" ? "x".repeat(newBinary.length) : ""); + if (fault === "ENOSPC") + throw Object.assign(new Error("No space left on device"), { code: "ENOSPC" }); + }; + vi.spyOn(fs, "copyFileSync").mockImplementation((_source, destination) => + badCopy(destination), + ); + // Reproduce the reported successful-but-empty cross-filesystem mv on the unpatched updater. + vi.spyOn(childProcess, "execFileSync").mockImplementation((command, args) => { + if (command !== "mv" || !Array.isArray(args)) throw new Error("Unexpected process launch"); + badCopy(String(args[2])); + fs.unlinkSync(installer); + return Buffer.alloc(0); + }); + + updater.quitAndInstall(true, true); + + expect(fs.readFileSync(installed, "utf8")).toBe(oldBinary); + expect(fs.readFileSync(installer, "utf8")).toBe(newBinary); + expect(updater.spawnLog).not.toHaveBeenCalled(); + expect(vi.getTimerCount()).toBe(0); + expect(errors).toHaveBeenCalledOnce(); + expect(fs.readdirSync(root).sort()).toEqual(["cache", "t3code.AppImage"]); + }, + ); + + it.each(["t3code.AppImage", "T3-Code-1.0.0.AppImage"])( + "atomically installs and relaunches %s", + async (name) => { + const { installed, installer, updater } = await fixture(name); + const destination = + name === "t3code.AppImage" + ? installed + : NodePath.join(NodePath.dirname(installed), NodePath.basename(installer)); + const rename = fs.renameSync; + const swapped = vi.spyOn(fs, "renameSync").mockImplementation((source, target) => { + expect(fs.readFileSync(installed, "utf8")).toBe(oldBinary); + expect(fs.readFileSync(source, "utf8")).toBe(newBinary); + expect(fs.statSync(source).dev).toBe(fs.statSync(NodePath.dirname(String(target))).dev); + rename(source, target); + }); + const renamed = vi.fn(); + updater.on("appimage-filename-updated", renamed); + + expect(updater.install(true, true)).toBe(true); + + expect(swapped).toHaveBeenCalledOnce(); + expect(fs.readFileSync(destination, "utf8")).toBe(newBinary); + // eslint-disable-next-line t3code/no-global-process-runtime -- Check permissions on the real fixture filesystem. + if (NodeOS.platform() !== "win32") expect(fs.statSync(destination).mode & 0o777).toBe(0o755); + expect(updater.spawnLog).toHaveBeenCalledWith( + destination, + [], + expect.objectContaining({ APPIMAGE_SILENT_INSTALL: "true" }), + ); + expect(renamed.mock.calls).toEqual(destination === installed ? [] : [[destination]]); + }, + ); + + it("keeps the executable and download retryable when the atomic rename fails", async () => { + const { installed, installer, updater, errors } = await fixture(); + const rename = vi.spyOn(fs, "renameSync").mockImplementationOnce(() => { + throw Object.assign(new Error("Permission denied"), { code: "EACCES" }); + }); + + updater.quitAndInstall(true, true); + + expect(fs.readFileSync(installed, "utf8")).toBe(oldBinary); + expect(fs.readFileSync(installer, "utf8")).toBe(newBinary); + expect(errors).toHaveBeenCalledOnce(); + expect(updater.spawnLog).not.toHaveBeenCalled(); + expect(vi.getTimerCount()).toBe(0); + rename.mockRestore(); + expect(updater.install(true, true)).toBe(true); + expect(fs.readFileSync(installed, "utf8")).toBe(newBinary); + }); +}); diff --git a/apps/desktop/src/electron/ElectronUpdater.test.ts b/apps/desktop/src/electron/ElectronUpdater.test.ts index 1e005d26fdf5..5fc397de53bb 100644 --- a/apps/desktop/src/electron/ElectronUpdater.test.ts +++ b/apps/desktop/src/electron/ElectronUpdater.test.ts @@ -1,5 +1,8 @@ import { assert, describe, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Logger from "effect/Logger"; +import * as References from "effect/References"; import { beforeEach, vi } from "vite-plus/test"; const { autoUpdaterMock } = vi.hoisted(() => ({ @@ -11,6 +14,7 @@ const { autoUpdaterMock } = vi.hoisted(() => ({ channel: "latest", disableDifferentialDownload: false, fullChangelog: false, + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, checkForUpdates: vi.fn(() => Promise.resolve(null)), downloadUpdate: vi.fn(() => Promise.resolve([])), on: vi.fn(), @@ -27,6 +31,34 @@ vi.mock("electron-updater", () => ({ import * as ElectronUpdater from "./ElectronUpdater.ts"; describe("ElectronUpdater", () => { + it.effect( + "routes updater install logs through desktop observability and restores the logger", + () => { + const previousLogger = autoUpdaterMock.logger; + const records: unknown[] = []; + const logger = Logger.make(({ message, fiber }) => { + records.push({ + message, + component: fiber.getRef(References.CurrentLogAnnotations).component, + span: fiber.currentSpan?._tag === "Span" ? fiber.currentSpan.name : undefined, + }); + }); + return Effect.gen(function* () { + yield* Effect.sync(() => autoUpdaterMock.logger.info("Installed verified AppImage")).pipe( + Effect.provide(ElectronUpdater.layer.pipe(Layer.provide(Logger.layer([logger])))), + ); + assert.deepEqual(records, [ + { + message: ["Installed verified AppImage"], + component: "desktop-updater", + span: "desktop.updater.log", + }, + ]); + assert.strictEqual(autoUpdaterMock.logger, previousLogger); + }); + }, + ); + beforeEach(() => { autoUpdaterMock.allowDowngrade = false; autoUpdaterMock.allowPrerelease = false; diff --git a/apps/desktop/src/electron/ElectronUpdater.ts b/apps/desktop/src/electron/ElectronUpdater.ts index e42ebc1003ab..a52e5145a42a 100644 --- a/apps/desktop/src/electron/ElectronUpdater.ts +++ b/apps/desktop/src/electron/ElectronUpdater.ts @@ -6,6 +6,8 @@ import * as Scope from "effect/Scope"; import { autoUpdater } from "electron-updater"; +import * as DesktopObservability from "../app/DesktopObservability.ts"; + type AutoUpdater = typeof autoUpdater; export type ElectronUpdaterFeedUrl = Parameters[0]; @@ -169,4 +171,28 @@ export const make = ElectronUpdater.of({ }, }); -export const layer = Layer.succeed(ElectronUpdater, make); +export const layer = Layer.effect( + ElectronUpdater, + Effect.gen(function* () { + const runSync = Effect.runSyncWith(yield* Effect.context()); + const logger = DesktopObservability.makeComponentLogger("desktop-updater"); + const log = (effect: Effect.Effect) => + runSync(effect.pipe(Effect.withSpan("desktop.updater.log"))); + const previousLogger = autoUpdater.logger; + yield* Effect.acquireRelease( + Effect.sync(() => { + autoUpdater.logger = { + info: (message) => log(logger.logInfo(String(message))), + warn: (message) => log(logger.logWarning(String(message))), + error: (message) => log(logger.logError(String(message))), + debug: (message) => log(logger.logDebug(String(message))), + }; + }), + () => + Effect.sync(() => { + autoUpdater.logger = previousLogger; + }), + ); + return make; + }), +); diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index ed920abcdc8f..c2e964444f3b 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -127,7 +127,6 @@ const electronLayer = Layer.mergeAll( ElectronSafeStorage.layer, ElectronShell.layer, ElectronTheme.layer, - ElectronUpdater.layer, ElectronWindow.layer, DesktopIpc.layer(Electron.ipcMain), ); @@ -140,6 +139,7 @@ const desktopFoundationLayer = Layer.mergeAll( DesktopConnectionCatalogStore.layer.pipe(Layer.provideMerge(DesktopSavedEnvironments.layer)), DesktopAssets.layer, DesktopObservability.layer, + ElectronUpdater.layer.pipe(Layer.provide(DesktopObservability.layer)), ).pipe(Layer.provideMerge(desktopEnvironmentLayer)); const desktopSshLayer = desktopSshEnvironmentLayer.pipe( diff --git a/patches/electron-updater@6.8.3.patch b/patches/electron-updater@6.8.3.patch new file mode 100644 index 000000000000..5d325e8bfe2d --- /dev/null +++ b/patches/electron-updater@6.8.3.patch @@ -0,0 +1,85 @@ +diff --git a/out/AppImageUpdater.js b/out/AppImageUpdater.js +index 25cef3598cd278cc2a7f1432377c7a50048824b9..a6132439a056671777e10fcfdd96820e331e489b 100644 +--- a/out/AppImageUpdater.js ++++ b/out/AppImageUpdater.js +@@ -6,6 +6,7 @@ const child_process_1 = require("child_process"); + const fs_extra_1 = require("fs-extra"); + const fs_1 = require("fs"); + const path = require("path"); ++const crypto = require("crypto"); + const BaseUpdater_1 = require("./BaseUpdater"); + const FileWithEmbeddedBlockMapDifferentialDownloader_1 = require("./differentialDownloader/FileWithEmbeddedBlockMapDifferentialDownloader"); + const Provider_1 = require("./providers/Provider"); +@@ -74,8 +75,6 @@ class AppImageUpdater extends BaseUpdater_1.BaseUpdater { + if (appImageFile == null) { + throw (0, builder_util_runtime_1.newError)("APPIMAGE env is not defined", "ERR_UPDATER_OLD_FILE_NOT_FOUND"); + } +- // https://stackoverflow.com/a/1712051/1910191 +- (0, fs_1.unlinkSync)(appImageFile); + let destination; + const existingBaseName = path.basename(appImageFile); + const installerPath = this.installerPath; +@@ -92,7 +91,62 @@ class AppImageUpdater extends BaseUpdater_1.BaseUpdater { + else { + destination = path.join(path.dirname(appImageFile), path.basename(installerPath)); + } +- (0, child_process_1.execFileSync)("mv", ["-f", installerPath, destination]); ++ // Stage beside the destination: cache and AppImage may be on different filesystems. ++ const stageDir = fs_1.mkdtempSync(path.join(path.dirname(destination), ".t3-update-")); ++ const staged = path.join(stageDir, "update.AppImage"); ++ this._logger.info(`Staging AppImage update for ${destination}`); ++ try { ++ const size = fs_1.statSync(installerPath).size; ++ fs_1.copyFileSync(installerPath, staged); ++ if (size === 0 || fs_1.statSync(staged).size !== size) { ++ throw new Error("Staged AppImage size mismatch"); ++ } ++ const fd = fs_1.openSync(staged, "r+"); ++ try { ++ const hash = crypto.createHash("sha512"); ++ const buffer = Buffer.allocUnsafe(1024 * 1024); ++ let bytes; ++ while ((bytes = fs_1.readSync(fd, buffer, 0, buffer.length, null)) > 0) { ++ hash.update(buffer.subarray(0, bytes)); ++ } ++ if (hash.digest("base64") !== this.downloadedUpdateHelper.downloadedFileInfo.sha512) { ++ throw new Error("Staged AppImage sha512 mismatch"); ++ } ++ fs_1.fchmodSync(fd, 0o755); ++ fs_1.fsyncSync(fd); ++ } ++ finally { ++ fs_1.closeSync(fd); ++ } ++ fs_1.renameSync(staged, destination); ++ if (process.platform === "linux") { ++ const directoryFd = fs_1.openSync(path.dirname(destination), "r"); ++ try { ++ fs_1.fsyncSync(directoryFd); ++ } ++ finally { ++ fs_1.closeSync(directoryFd); ++ } ++ } ++ this._logger.info(`Installed verified AppImage at ${destination}`); ++ } ++ finally { ++ try { ++ fs_1.rmSync(stageDir, { recursive: true, force: true }); ++ } ++ catch (error) { ++ this._logger.warn(`Could not remove AppImage staging directory: ${error}`); ++ } ++ } ++ // Cleanup must not turn a committed replacement into a failed install. ++ for (const obsolete of destination === appImageFile ? [installerPath] : [installerPath, appImageFile]) { ++ try { ++ fs_1.unlinkSync(obsolete); ++ } ++ catch (error) { ++ this._logger.warn(`Could not remove obsolete AppImage ${obsolete}: ${error}`); ++ } ++ } + if (destination !== appImageFile) { + this.emit("appimage-filename-updated", destination); + } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b449b48cc4ff..59fbc22890c2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -98,6 +98,7 @@ patchedDependencies: '@react-navigation/native-stack@7.17.6': e667c3cef8c78bb9ff4882ee5bd23a432247b843060a9499eb5f07e9e2295552 dbus-next@0.10.2: cfff57561b0ee59b5addb3b2e6c6f20906e967507a530ab67e8db8108e520ba4 effect@4.0.0-rc.112: 200b70758a8f02f5a4e6a4abea5f0079ad36fe9c2006486ebaf6ab1002a38320 + electron-updater@6.8.3: a879afb18801b8ab14fa9dcedd2d3ef644d0b77508cbd0e4231178a0f8223edd expo-audio@57.0.4: fa9a3e0442ed395d4071bb406e08c3a471c9a84700bdfa0b9ad7ff144c96041a expo-sharing@57.0.17: 8d2e3b10eb3f52036a9a086800180ec6cebf3b75bccc5b1775117a7244d4ac45 react-native-gesture-handler@2.32.0: 96573c000f7fe56b5abfa13e2e5f0d065907e674cb8e2300155226d7c9874398 @@ -171,8 +172,8 @@ importers: specifier: ^8.2.0 version: 8.2.0 electron-updater: - specifier: ^6.6.2 - version: 6.8.3 + specifier: 6.8.3 + version: 6.8.3(patch_hash=a879afb18801b8ab14fa9dcedd2d3ef644d0b77508cbd0e4231178a0f8223edd) ffi-rs: specifier: 1.3.2 version: 1.3.2 @@ -17551,7 +17552,7 @@ snapshots: electron-to-chromium@1.5.364: {} - electron-updater@6.8.3: + electron-updater@6.8.3(patch_hash=a879afb18801b8ab14fa9dcedd2d3ef644d0b77508cbd0e4231178a0f8223edd): dependencies: builder-util-runtime: 9.5.1 fs-extra: 10.1.0 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index ae279419d428..14e6274f0750 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -174,6 +174,7 @@ patchedDependencies: "@react-navigation/native-stack@7.17.6": patches/@react-navigation%2Fnative-stack@7.17.6.patch dbus-next@0.10.2: patches/dbus-next@0.10.2.patch effect@4.0.0-rc.112: patches/effect@4.0.0-rc.112.patch + electron-updater@6.8.3: patches/electron-updater@6.8.3.patch expo-audio@57.0.4: patches/expo-audio@57.0.4.patch expo-sharing@57.0.17: patches/expo-sharing@57.0.17.patch react-native-gesture-handler@2.32.0: patches/react-native-gesture-handler@2.32.0.patch