Add desktop auto-updates and Linux icon integration (#621)

This commit is contained in:
Gaurav Chaudhary 2026-09-22 19:26:35 +00:00 committed by Jon Staab
parent 05c7d963c2
commit fcba6b197c
6 changed files with 216 additions and 38 deletions

View file

@ -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,

View file

@ -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()

View file

@ -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,
},

View file

@ -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)
}
},
},

View file

@ -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"

View file

@ -13,5 +13,8 @@
"electron": "^43.0.0",
"electron-builder": "26.16.1",
"typescript": "^5.5.0"
},
"dependencies": {
"electron-updater": "6.8.9"
}
}