From fcba6b197cc80b8ca2ca08f201b757af9720bd8c Mon Sep 17 00:00:00 2001 From: Gaurav Chaudhary Date: Tue, 22 Sep 2026 19:26:35 +0000 Subject: [PATCH] Add desktop auto-updates and Linux icon integration (#621) --- README.md | 39 +++++++++++++- e2e/desktop/smoke.spec.ts | 63 +++++++++++++++++----- electron/electron-builder.config.mjs | 10 +++- electron/main.ts | 78 ++++++++++++++++++++++++---- electron/package-lock.json | 61 ++++++++++++++++++---- electron/package.json | 3 ++ 6 files changed, 216 insertions(+), 38 deletions(-) diff --git a/README.md b/README.md index 3126d467..238380c2 100644 --- a/README.md +++ b/README.md @@ -78,7 +78,8 @@ See [CONTRIBUTING.md](CONTRIBUTING.md) for conventions and workflow. ### Desktop development (Linux) The Electron target and its unsigned packages are for development and testing. Release publishing -and auto-updates are not configured. Desktop secrets are encrypted using the OS keyring or keychain. +and production signing remain prerequisites for public distribution. Desktop secrets are encrypted +using the OS keyring or keychain. If protected storage is unavailable or unreadable, the app warns and keeps secrets only in memory until it closes, leaving the saved file untouched. Unlock or configure the OS keyring and restart to restore persistence. Linux’s insecure `basic_text` backend is never used. @@ -133,12 +134,19 @@ pnpm run package:desktop:macos Each command rebuilds production assets, copies and updates Capacitor, compiles Electron, vendors its runtime and plugins, and invokes electron-builder without publishing or signing. Outputs land in `electron/dist/`: a Linux x64 AppImage, a Windows x64 NSIS installer, and separate macOS x64 and -arm64 DMGs. The root package version is authoritative, the Capacitor app ID stays stable, and +arm64 DMGs and ZIPs. The root package version is authoritative, the Capacitor app ID stays stable, and `VITE_PLATFORM_NAME` supplies the product name. Vite's `.env.local` overrides apply, and explicit `VITE_*` environment values take precedence. Use production branding values when building artifacts for others. `VITE_PLATFORM_LOGO` can be a local or HTTPS image, which packaging resizes to 1024×1024 and stages in ignored output. +Window and Dock icons use the bundled branding image, including during local runs. The default +`VITE_PLATFORM_LOGO=static/logo.png` is the official logo; custom branding remains supported. +On Linux, packaged startup registers a hidden desktop entry and a persistent icon under `XDG_DATA_HOME` +(normally `~/.local/share`) so Wayland docks can identify the app. Existing user or system launchers +are preserved, and development runs never write an entry. The generated entry follows the AppImage +path, including immediately after an update renames it. Windows uses the executable icon resources. + Linux packaging requires Linux. Windows packaging from Linux uses the pinned official `electronuserland/builder` Wine image through Docker, mounting only a temporary copy of the prepared Electron project. Native addons need a target-OS ABI rebuild and cannot use this cross-build path. @@ -156,6 +164,33 @@ Use the AppImage or installed executable rather than the installer. This checks local assets, navigation, workers, and CSP using a disposable profile. Installation, reboot, and uninstall still require target-OS testing. +### Desktop updates + +Packaged apps check once at startup, download available updates, and install them on normal quit. +There is no update notification or custom updater UI. Unpackaged development runs do not check. +Update errors are logged and leave the app usable. The generic feed is configured in +`electron/electron-builder.config.mjs`; electron-builder generates `app-update.yml` and the +`latest*.yml` files alongside the installers, including when packaging with `--publish never`. + +Before public desktop auto-updates ship, the release pipeline must guarantee that **every stable +release selected by Gitea's latest route includes all desktop updater metadata and matching +artifacts**. A newer mobile-only release would break the desktop feed. Production acceptance also +requires platform signing and native Windows/macOS testing. +The existing release task does not yet upload updater metadata or macOS ZIPs; that is follow-up work. +Until it does, the public feed returns 404 for channel metadata and packaged startup logs an update +check failure. This PR provides packaging and runtime support, not an operational public update feed. +Do not publish desktop builds to users until the publishing follow-up is complete. + +For local updater QA, use disposable copies with temporary A/B versions and an isolated user-data +directory. In those copies only, point the builder's generic feed at a local HTTP server. Package +both versions and serve B's generated metadata and artifacts. Run AppImage A, wait for B to +download, quit normally, and relaunch the installed AppImage to verify its version and saved state. +Check that preferences, protected secrets, tray controls, and notification activation survive. +Also exercise unavailable/missing files, interrupted downloads, invalid checksums/versions, and +an already-current version; failed updates must not install. Never bypass integrity checks. +On macOS, build both architectures together and verify the single generated manifest references +both ZIPs with matching hashes. Do not hand-create or merge updater manifests. + ## Releasing `pnpm release` takes a tagged commit and ships it everywhere: the web bundle and native projects, diff --git a/e2e/desktop/smoke.spec.ts b/e2e/desktop/smoke.spec.ts index a610310a..facf0ea5 100644 --- a/e2e/desktop/smoke.spec.ts +++ b/e2e/desktop/smoke.spec.ts @@ -1,4 +1,4 @@ -import {mkdtemp, readFile, rm} from "node:fs/promises" +import {mkdir, mkdtemp, readFile, rm, writeFile} from "node:fs/promises" import {createRequire} from "node:module" import {tmpdir} from "node:os" import {join, resolve} from "node:path" @@ -19,13 +19,23 @@ declare global { test("the desktop app renders, navigates, and keeps external pages outside", async () => { const profile = await mkdtemp(join(tmpdir(), "flotilla-desktop-")) + const env = {...process.env, XDG_DATA_HOME: join(profile, "data")} try { const packaged = process.env.FLOTILLA_DESKTOP_EXECUTABLE + const {appId} = JSON.parse(await readFile("electron/generated/capacitor.config.json", "utf8")) + const entryPath = join(env.XDG_DATA_HOME, "applications", `${appId}.desktop`) + const existingEntry = + "[Desktop Entry]\nExec=/installed/app.AppImage\nX-Electron-Generated=true\n" + if (!packaged && process.platform === "linux") { + await mkdir(join(env.XDG_DATA_HOME, "applications"), {recursive: true}) + await writeFile(entryPath, existingEntry) + } const executablePath: string = packaged || createRequire(import.meta.url)(resolve("electron/node_modules/electron")) let app = await _electron.launch({ executablePath, + env, // Chromium refuses to start as root with its sandbox on, which is what a CI container is. chromiumSandbox: packaged ? true : process.getuid?.() !== 0, args: [...(packaged ? [] : [resolve("electron")]), `--user-data-dir=${profile}`], @@ -33,6 +43,13 @@ test("the desktop app renders, navigates, and keeps external pages outside", asy try { expect(await app.evaluate(({app}) => app.getPath("userData"))).toBe(profile) + if (packaged && process.platform === "linux") { + const entry = await readFile(entryPath, "utf8") + expect(entry).toContain(`Icon=${join(env.XDG_DATA_HOME, "icons", `${appId}.png`)}`) + expect(entry).not.toContain("/tmp/.mount_") + } else if (process.platform === "linux") { + expect(await readFile(entryPath, "utf8")).toBe(existingEntry) + } const mainWindows = () => app.windows().filter(page => page.url().startsWith("capacitor-electron://")) await expect.poll(() => mainWindows().length).toBe(1) @@ -45,7 +62,9 @@ test("the desktop app renders, navigates, and keeps external pages outside", asy const heading = page.getByRole("heading") await expect(heading).toBeVisible() - await expect(page).toHaveTitle((await heading.textContent())!.replace(/^Welcome to |!$/g, "")) + await expect(page).toHaveTitle( + (await heading.textContent())!.replace(/^Welcome to\s*|!$/g, ""), + ) expect( await page.evaluate(() => getComputedStyle(document.documentElement).colorScheme), ).toBe(await page.locator("body").getAttribute("data-theme")) @@ -59,6 +78,23 @@ test("the desktop app renders, navigates, and keeps external pages outside", asy expect(await app.evaluate(({app}) => app.getName())).toBe(await page.title()) expect(await app.evaluate(({app}) => app.getVersion())).toBe(version) expect(await app.evaluate(({app}) => app.getAppPath())).toMatch(/app\.asar$/) + const updater = await app.evaluate(async ({app}) => { + const {createRequire} = process.getBuiltinModule("node:module") + const {readFile} = process.getBuiltinModule("node:fs/promises") + const {join} = process.getBuiltinModule("node:path") + const require = createRequire(join(app.getAppPath(), "package.json")) + return { + version: require("electron-updater").autoUpdater.currentVersion.version, + config: require("js-yaml").load( + await readFile(join(app.getAppPath(), "..", "app-update.yml"), "utf8"), + ), + } + }) + expect(updater.version).toBe(version) + expect(updater.config).toMatchObject({ + provider: "generic", + url: "https://gitea.coracle.social/coracle/flotilla/releases/download/latest/", + }) expect(await app.evaluate(({app}) => app.commandLine.hasSwitch("no-sandbox"))).toBe(false) const preferences = await app.browserWindow(page).then(window => window.evaluate(window => { @@ -144,26 +180,27 @@ test("the desktop app renders, navigates, and keeps external pages outside", asy expect(typeof contract.decrypted.shouldReEncrypt).toBe("boolean") const fixture = "packaged-desktop-secret" await page.evaluate(async value => { - await window.Capacitor.Plugins.DesktopSecureStorage.set({key: "session", value}) + await window.Capacitor.Plugins.DesktopSecureStorage.set({key: "desktop-smoke", value}) }, fixture) expect((await readFile(join(profile, "secure-storage.bin"))).includes(fixture)).toBe(false) await app.close() app = await _electron.launch({ executablePath, + env, chromiumSandbox: true, args: [`--user-data-dir=${profile}`], }) - await expect - .poll(async () => { - const relaunched = app - .windows() - .find(candidate => candidate.url().startsWith("capacitor-electron://")) - return relaunched?.evaluate(async () => - window.Capacitor.Plugins.DesktopSecureStorage.get({key: "session"}), - ) - }) - .toEqual({value: fixture}) + await expect.poll(() => mainWindows().length).toBe(1) + const [relaunched] = mainWindows() + await relaunched.waitForFunction(() => + Boolean(window.Capacitor?.Plugins?.DesktopSecureStorage), + ) + expect( + await relaunched.evaluate(() => + window.Capacitor.Plugins.DesktopSecureStorage.get({key: "desktop-smoke"}), + ), + ).toEqual({value: fixture}) } } finally { await app.close() diff --git a/electron/electron-builder.config.mjs b/electron/electron-builder.config.mjs index 904bbfa1..01228bc1 100644 --- a/electron/electron-builder.config.mjs +++ b/electron/electron-builder.config.mjs @@ -32,7 +32,10 @@ export default { {from: "vendor/node_modules", to: "node_modules"}, ], allowMissingDependencies: false, - publish: [], + publish: { + provider: "generic", + url: "https://gitea.coracle.social/coracle/flotilla/releases/download/latest/", + }, toolsets: {appimage: "1.0.3"}, linux: { target: [{target: "AppImage", arch: ["x64"]}], @@ -41,7 +44,10 @@ export default { }, win: {target: [{target: "nsis", arch: ["x64"]}], signExecutable: false}, mac: { - target: [{target: "dmg", arch: ["x64", "arm64"]}], + target: [ + {target: "dmg", arch: ["x64", "arm64"]}, + {target: "zip", arch: ["x64", "arm64"]}, + ], category: "public.app-category.social-networking", notarize: false, }, diff --git a/electron/main.ts b/electron/main.ts index 9abb5c83..01f5d80b 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -1,14 +1,28 @@ -import {readFileSync} from "node:fs" +import {copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync} from "node:fs" import {join} from "node:path" import {app, Menu, Tray} from "electron" +import {autoUpdater} from "electron-updater" import {createCapacitorElectronApp} from "@capawesome/capacitor-electron" const {appId, appName} = JSON.parse( readFileSync(join(app.getAppPath(), "generated/capacitor.config.json"), "utf8"), ) +const iconPath = join( + app.getAppPath(), + app.isPackaged ? "generated/icon.png" : "app/pwa-192x192.png", +) let tray: Tray | undefined let quitting = false +app.whenReady().then(() => { + if (app.isPackaged) { + void autoUpdater + .checkForUpdates() + .then(result => result?.downloadPromise) + .catch(error => console.error("Update check failed", error)) + } +}) + const destroyTray = () => { tray?.destroy() tray = undefined @@ -25,6 +39,54 @@ createCapacitorElectronApp({ beforeReady: () => { if (process.platform === "win32") { app.setAppUserModelId(appId) + } else if (process.platform === "linux" && app.isPackaged) { + app.setDesktopName(`${appId}.desktop`) + try { + const data = process.env.XDG_DATA_HOME || join(app.getPath("home"), ".local/share") + const applications = join(data, "applications") + const entry = join(applications, `${appId}.desktop`) + const marker = "X-Electron-Generated=true" + const installed = (process.env.XDG_DATA_DIRS || "/usr/local/share:/usr/share") + .split(":") + .some(directory => existsSync(join(directory, "applications", `${appId}.desktop`))) + if (!installed && (!existsSync(entry) || readFileSync(entry, "utf8").includes(marker))) { + const icons = join(data, "icons") + const icon = join(icons, `${appId}.png`) + mkdirSync(applications, {recursive: true}) + mkdirSync(icons, {recursive: true}) + copyFileSync(iconPath, icon) + const escape = (value: string) => + value.replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/\r/g, "\\r") + const writeEntry = (executable: string) => { + const command = `"${executable.replace(/[\\"`$]/g, "\\$&").replace(/%/g, "%%")}"` + // Wayland shells resolve icons through desktop entries, not BrowserWindow.setIcon. + writeFileSync( + entry, + [ + "[Desktop Entry]", + "Type=Application", + `Name=${escape(appName)}`, + `Exec=${escape(command)}`, + `Icon=${escape(icon)}`, + `StartupWMClass=${appId}`, + "NoDisplay=true", + marker, + "", + ].join("\n"), + ) + } + writeEntry(process.env.APPIMAGE || process.execPath) + autoUpdater.on("appimage-filename-updated", (destination: string) => { + try { + writeEntry(destination) + } catch (error) { + console.error("Could not update the desktop launcher", error) + } + }) + } + } catch (error) { + console.error("Could not register the desktop icon", error) + } } }, onWindowCreated: window => { @@ -51,13 +113,7 @@ createCapacitorElectronApp({ : process.platform === "win32" ? "icon.ico" : "icon.png" - tray = new Tray( - join( - app.getAppPath(), - app.isPackaged ? "generated" : "app", - app.isPackaged ? icon : "pwa-192x192.png", - ), - ) + tray = new Tray(app.isPackaged ? join(app.getAppPath(), "generated", icon) : iconPath) tray.setToolTip(appName) tray.setContextMenu( Menu.buildFromTemplate([ @@ -67,8 +123,10 @@ createCapacitorElectronApp({ ) tray.on("click", show) window.on("closed", destroyTray) - if (process.platform === "linux" && app.isPackaged) { - window.setIcon(join(app.getAppPath(), "generated/icon.png")) + if (process.platform === "darwin") { + app.dock?.setIcon(iconPath) + } else if (process.platform === "linux") { + window.setIcon(iconPath) } }, }, diff --git a/electron/package-lock.json b/electron/package-lock.json index f6e8472d..9a2c09f4 100644 --- a/electron/package-lock.json +++ b/electron/package-lock.json @@ -6,6 +6,9 @@ "": { "name": "flotilla-electron", "hasInstallScript": true, + "dependencies": { + "electron-updater": "6.8.9" + }, "devDependencies": { "electron": "^43.0.0", "electron-builder": "26.16.1", @@ -795,7 +798,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true, "license": "Python-2.0" }, "node_modules/asn1js": { @@ -951,7 +953,6 @@ "version": "9.7.0", "resolved": "https://registry.npmjs.org/builder-util-runtime/-/builder-util-runtime-9.7.0.tgz", "integrity": "sha512-g/kR520giAFYkSXTzcmF3kqQq7wi8F6N6SzeDgZrqTBN+VHdmgWOyTdD1yD7AATDId/yXLvuP34CxW46/BwCdw==", - "dev": true, "license": "MIT", "dependencies": { "debug": "^4.3.4", @@ -1210,7 +1211,6 @@ "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -1520,6 +1520,34 @@ "mime": "^2.5.2" } }, + "node_modules/electron-updater": { + "version": "6.8.9", + "resolved": "https://registry.npmjs.org/electron-updater/-/electron-updater-6.8.9.tgz", + "integrity": "sha512-ZhVxM9iGONUpZGI1FxdMRgJjUFXi7AYGVa5PwKlO1tV1/4zDxQmfKpXOHVztKrd6L9rLcFjERvi1Mf2vxyTkig==", + "license": "MIT", + "dependencies": { + "builder-util-runtime": "9.7.0", + "fs-extra": "^10.1.0", + "js-yaml": "^4.1.0", + "lazy-val": "^1.0.5", + "lodash.escaperegexp": "^4.1.2", + "lodash.isequal": "^4.5.0", + "semver": "~7.7.3", + "tiny-typed-emitter": "^2.1.0" + } + }, + "node_modules/electron-updater/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/electron-winstaller": { "version": "5.4.0", "resolved": "https://registry.npmjs.org/electron-winstaller/-/electron-winstaller-5.4.0.tgz", @@ -1808,7 +1836,6 @@ "version": "10.1.0", "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", - "dev": true, "license": "MIT", "dependencies": { "graceful-fs": "^4.2.0", @@ -2034,7 +2061,6 @@ "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", - "dev": true, "license": "ISC" }, "node_modules/has-flag": { @@ -2256,7 +2282,6 @@ "version": "4.3.2", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz", "integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==", - "dev": true, "funding": [ { "type": "github", @@ -2314,7 +2339,6 @@ "version": "6.2.1", "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", - "dev": true, "license": "MIT", "dependencies": { "universalify": "^2.0.0" @@ -2337,7 +2361,6 @@ "version": "1.0.5", "resolved": "https://registry.npmjs.org/lazy-val/-/lazy-val-1.0.5.tgz", "integrity": "sha512-0/BnGCCfyUMkBpeDgWihanIAF9JmZhHBgUhEqzvf+adhNGLoP6TaiI5oF8oyb3I45P+PcnrqihSf01M0l0G5+Q==", - "dev": true, "license": "MIT" }, "node_modules/lodash": { @@ -2347,6 +2370,19 @@ "dev": true, "license": "MIT" }, + "node_modules/lodash.escaperegexp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/lodash.escaperegexp/-/lodash.escaperegexp-4.1.2.tgz", + "integrity": "sha512-TM9YBvyC84ZxE3rgfefxUWiQKLilstD6k7PTGt6wfbtXF8ixIJLOL3VYyV/z+ZiPLsVxAsKAFVwWlWeb2Y8Yyw==", + "license": "MIT" + }, + "node_modules/lodash.isequal": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz", + "integrity": "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==", + "deprecated": "This package is deprecated. Use require('node:util').isDeepStrictEqual instead.", + "license": "MIT" + }, "node_modules/lowercase-keys": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-2.0.0.tgz", @@ -2507,7 +2543,6 @@ "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, "license": "MIT" }, "node_modules/node-abi": { @@ -3067,7 +3102,6 @@ "version": "1.6.1", "resolved": "https://registry.npmjs.org/sax/-/sax-1.6.1.tgz", "integrity": "sha512-42tBVwLWnaQvW5zc4HbZrTuWccECCZfBi92FDuwtqxasH+JbPB3/FOKb1m222K42R4WxuxzzMsTswfzgtSu64Q==", - "dev": true, "license": "BlueOak-1.0.0", "engines": { "node": ">=11.0.0" @@ -3330,6 +3364,12 @@ "semver": "bin/semver" } }, + "node_modules/tiny-typed-emitter": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/tiny-typed-emitter/-/tiny-typed-emitter-2.1.0.tgz", + "integrity": "sha512-qVtvMxeXbVej0cQWKqVSSAHmKZEHAvxdF8HEUBFWts8h+xEo5m/lEiPakuyZ3BnCBjOD8i24kzNOiOLLgsSxhA==", + "license": "MIT" + }, "node_modules/tinyglobby": { "version": "0.2.17", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", @@ -3434,7 +3474,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", - "dev": true, "license": "MIT", "engines": { "node": ">= 10.0.0" diff --git a/electron/package.json b/electron/package.json index 05322504..ceb6eff2 100644 --- a/electron/package.json +++ b/electron/package.json @@ -13,5 +13,8 @@ "electron": "^43.0.0", "electron-builder": "26.16.1", "typescript": "^5.5.0" + }, + "dependencies": { + "electron-updater": "6.8.9" } }