From cd37efdce70973df8b593b2f1a50aed63448b67f Mon Sep 17 00:00:00 2001 From: Matthew Harris Glover Date: Fri, 17 Jul 2026 14:16:52 -0400 Subject: [PATCH] feat(update): Linux AppImage self-update on the nightly channel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a self-update engine for the Linux AppImage build. There's no Velopack pipeline for Linux (Windows/macOS use it, Linux doesn't), so this is a small, purpose-built GitHub-releases checker instead: - On the nightly channel, compares the commit baked into the running build (dist/main/build-info.json, written at build time) against the published nightly's target_commitish. A mismatch means the running build is behind, so it's offered as an update — this sidesteps the fact that the AppImage's filename and app.getVersion() never change between nightly builds, so semver comparison can't detect a new one. - The check returns immediately and the ~1.5GB download runs in the background with live progress (a new update:progress IPC event), so the UI never blocks or freezes waiting on it. - The download streams straight to disk (no buffering the whole file in memory) and is swapped in with an atomic rename next to the running AppImage. The stale-generation check (a channel switch or new check invalidating an in-flight download) runs before that swap, and a failed or superseded download always cleans up its temp file. - Applying the update spawns the (already-swapped-in) AppImage as a detached process and waits for a real 'spawn' confirmation before quitting this one, rather than assuming success — child_process.spawn can fail asynchronously, and quitting on an unconfirmed relaunch could leave the user with nothing running. - The pure idle/staged/download decision is split into linux-update-decision.ts with a small truth-table test, and every main-process decision point (and the equivalent renderer-side actions, in the companion feedBack PR) is traced through a new update:diag IPC event that lands in the app's existing "Export Diagnostics" console-capture bundle — this is how the handful of real bugs below were actually root-caused, from real device captures rather than guesswork. Also removes a forgotten, dead second implementation of the update-channel UI (src/renderer/screen.js's setupUpdateChannelControls() + its markup in settings.html), left over from before this work discovered the real, visible System-tab update UI lives in the feedBack repo. It was still wired up in the audio_engine plugin's own settings panel and silently called setChannel() with a stale channel value every time that panel rendered — invisibly corrupting the real UI's state. This was the actual root cause of several rounds of flaky, hard-to-reproduce on-device behavior (a stuck "unsupported" warning, downloads starting without an explicit check, etc.) chased down via the diagnostic tracing above; once found, no other logic needed to change. Dev tooling only, not used by CI: forces --platform linux/amd64 in the local Docker build wrapper (the Linux target is x86_64-only end to end — needed on Apple Silicon, where Rosetta chokes on a foreign-arch binary inside an otherwise-native container) and adds a SLOPSMITH_REPO override so a contributor without push access to the core repo can bundle a fork branch for a local test build. Verified end-to-end on a Steam Deck across many build/deploy rounds: fresh launch, channel selection, check, background download with live progress, atomic swap, and relaunch onto the new build — confirmed via a real Export Diagnostics capture showing a clean, fully-accounted-for trace with zero orphaned state transitions. Co-Authored-By: Claude Fable 5 --- scripts/build-common.sh | 27 +- scripts/build-linux-docker.sh | 12 + src/main/ipc-channels.ts | 9 + src/main/linux-update-decision.ts | 30 ++ src/main/main.ts | 7 +- src/main/preload.ts | 30 +- src/main/update-manager.ts | 440 +++++++++++++++++++++++++--- src/renderer/screen.js | 170 ----------- src/renderer/settings.html | 28 -- tests/linux-update-decision.test.js | 34 +++ 10 files changed, 545 insertions(+), 242 deletions(-) create mode 100644 src/main/linux-update-decision.ts create mode 100644 tests/linux-update-decision.test.js diff --git a/scripts/build-common.sh b/scripts/build-common.sh index fe2c303..944d4bc 100644 --- a/scripts/build-common.sh +++ b/scripts/build-common.sh @@ -107,10 +107,15 @@ clone_slopsmith() { # builds and the push/tag CI paths behave exactly as before. # --branch accepts either a branch or a tag, both shallow-cloneable. local slopsmith_ref="${SLOPSMITH_REF:-main}" + # SLOPSMITH_REPO overrides the core repo (default got-feedback/feedback), + # mirroring SLOPSMITH_REF. Lets a contributor without push access to the + # core repo bundle a branch pushed to their own fork for a test build: + # SLOPSMITH_REPO=me/feedBack SLOPSMITH_REF=my-branch + local slopsmith_repo="${SLOPSMITH_REPO:-got-feedback/feedback}" local _auth="" [[ -n "${GH_CLONE_TOKEN:-}" ]] && _auth="x-access-token:${GH_CLONE_TOKEN}@" - echo "Cloning Slopsmith repository (ref: ${slopsmith_ref})..." - git clone --depth 1 --branch "$slopsmith_ref" "https://${_auth}github.com/got-feedback/feedback.git" "$clone_dir" + echo "Cloning Slopsmith repository (${slopsmith_repo} ref: ${slopsmith_ref})..." + git clone --depth 1 --branch "$slopsmith_ref" "https://${_auth}github.com/${slopsmith_repo}.git" "$clone_dir" # Remove broken symlinks from plugins dir find "$clone_dir/plugins" -maxdepth 1 -type l -delete 2>/dev/null || true @@ -474,6 +479,24 @@ bundle_soundfont() { build_typescript() { echo_step "Building TypeScript" npm run build:ts + # Bake the source commit SHA into the packaged app so the Linux AppImage + # updater can tell whether the running build is behind the latest nightly + # (whose GitHub release target_commitish is this same commit). In CI + # GITHUB_SHA matches the nightly release target exactly; local builds fall + # back to the working-tree HEAD — which won't match any nightly, so the + # updater simply offers the latest official build. Packaged via the + # electron-builder `files: ["dist/**/*"]` glob and read at runtime by + # update-manager.ts (path.join(__dirname, 'build-info.json')). + local build_sha="${GITHUB_SHA:-$(git rev-parse HEAD 2>/dev/null || echo unknown)}" + # Also capture the bundled core (feedBack) repo's commit, cloned earlier + # in main() into $SLOPSMITH_DIR (exported by clone_slopsmith). A fix can + # live in either repo, so knowing only the desktop SHA isn't enough to + # answer "is this build stale" — this is read back by update-manager.ts's + # readBuildInfo() and surfaced in the renderer's diagnostic snapshot. + local core_sha="$(git -C "${SLOPSMITH_DIR:-}" rev-parse HEAD 2>/dev/null || echo unknown)" + node -e "require('fs').writeFileSync('dist/main/build-info.json', JSON.stringify({ sha: process.argv[1], coreSha: process.argv[2] }))" "$build_sha" "$core_sha" + echo " Build SHA: $build_sha" + echo " Core SHA: $core_sha" echo_summary "TypeScript built" echo "" } diff --git a/scripts/build-linux-docker.sh b/scripts/build-linux-docker.sh index a03249e..551130f 100755 --- a/scripts/build-linux-docker.sh +++ b/scripts/build-linux-docker.sh @@ -1,6 +1,15 @@ #!/bin/bash # Docker-based Linux build wrapper # Runs build-linux-ubuntu.sh inside a reproducible container +# +# --platform linux/amd64 is forced on both build and run: the Linux target is +# x86_64-only end to end (bundle-python.sh's python-build-standalone pin, +# vgmstream, onnxruntime, etc. have no arm64 Linux build, and Steam Deck +# itself is x86_64). On an Apple Silicon host, omitting --platform makes +# `docker build` produce a native arm64 image, so bundle-python.sh's hardcoded +# x86_64 download becomes a foreign-arch binary inside an otherwise-native +# container — Rosetta chokes trying to exec it directly instead of via full +# amd64 emulation. Forcing amd64 for the whole container sidesteps that. set -euo pipefail @@ -44,6 +53,7 @@ echo " (This will take a few minutes on first run)" echo "" docker build \ + --platform linux/amd64 \ -f "$DEVCONTAINER_DIR/Dockerfile" \ -t slopsmith-ubuntu-builder \ "$PROJECT_DIR" @@ -75,6 +85,7 @@ echo "" set +e docker run \ + --platform linux/amd64 \ --name "$CONTAINER_NAME" \ -v "$PROJECT_DIR:/workspace" \ -w /workspace \ @@ -83,6 +94,7 @@ docker run \ -e GIT_TERMINAL_PROMPT=0 \ -e "GH_CLONE_TOKEN=${GH_CLONE_TOKEN:-}" \ -e "SLOPSMITH_REF=${SLOPSMITH_REF:-main}" \ + -e "SLOPSMITH_REPO=${SLOPSMITH_REPO:-got-feedback/feedback}" \ -t \ slopsmith-ubuntu-builder \ bash -c './scripts/build-linux-ubuntu.sh' diff --git a/src/main/ipc-channels.ts b/src/main/ipc-channels.ts index 6547fa1..fcc11b5 100644 --- a/src/main/ipc-channels.ts +++ b/src/main/ipc-channels.ts @@ -18,6 +18,15 @@ export const IPC_UPDATE_APPLY = 'update:apply' as const; // update-manager broadcaster and the preload listeners can't drift. export const IPC_UPDATE_EVENT_AVAILABLE = 'update:available' as const; export const IPC_UPDATE_EVENT_DOWNLOADED = 'update:downloaded' as const; +// Download-progress ticks (Linux AppImage self-update — a full ~1.5GB fetch, +// so the renderer needs a percentage rather than a frozen "checking" state). +export const IPC_UPDATE_EVENT_PROGRESS = 'update:progress' as const; +// Diagnostic trace of main-process update decisions (Linux path). The +// renderer's diagnostics.js already wraps console.* into an exportable ring +// buffer — this event exists purely to get main-process events (invisible to +// that browser-side wrap) into the SAME renderer console, so they end up in +// the same "Export Diagnostics" bundle instead of needing a separate log file. +export const IPC_UPDATE_EVENT_DIAG = 'update:diag' as const; // Config maintenance — the in-app "Reset / repair configuration" action. The // Settings panel reads the enumerated per-OS paths, runs a granular reset, and diff --git a/src/main/linux-update-decision.ts b/src/main/linux-update-decision.ts new file mode 100644 index 0000000..d806c8c --- /dev/null +++ b/src/main/linux-update-decision.ts @@ -0,0 +1,30 @@ +// Pure decision for the Linux AppImage updater, split into its own module +// (no electron/fs/network imports) so the download-and-overwrite-executable +// trigger has a standalone truth-table test — see +// tests/linux-update-decision.test.js. update-manager.ts's checkNowLinux() +// calls this to choose between: the running build is already current, the +// target nightly is already staged this session, or it must be downloaded +// and swapped in. + +export type LinuxUpdateDecision = 'idle' | 'staged' | 'download'; + +/** + * @param bakedSha commit the running build was cut from (build-info.json), + * or null for a dev/unknown build. + * @param remoteSha target_commitish of the latest nightly GitHub release. + * @param downloadedSha remote SHA already fetched + staged this session, else null. + */ +export function linuxUpdateDecision( + bakedSha: string | null, + remoteSha: string, + downloadedSha: string | null, +): LinuxUpdateDecision { + // Running build IS the published nightly (baked commit matches release). + if (bakedSha && bakedSha === remoteSha) return 'idle'; + // Already fetched + staged this exact nightly earlier this session, so + // don't re-download the ~1.5GB AppImage on the next poll. + if (downloadedSha === remoteSha) return 'staged'; + // Running build differs (older, or an unknown/dev build with no baked + // SHA) and we haven't staged this nightly yet → fetch + swap it in. + return 'download'; +} diff --git a/src/main/main.ts b/src/main/main.ts index 1252ada..7ebff8b 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -1255,12 +1255,17 @@ async function startup(): Promise { return result.canceled ? [] : result.filePaths; }); - // App info + // App info. buildSha/coreSha come from dist/main/build-info.json (baked + // at build time — see build-common.sh) and are the same two SHAs the + // Linux update decision compares against the published nightly; exposed + // here too so the renderer's diagnostic snapshot can report exactly + // which commit of each repo (desktop shell + bundled core) is running. ipcMain.handle('app:getInfo', () => ({ version: app.getVersion(), isPackaged: app.isPackaged, platform: process.platform, resourcesPath: getResourcesPath(), + ...updateManager.readBuildInfo(), })); // Config directory diff --git a/src/main/preload.ts b/src/main/preload.ts index bac485e..e96bd7a 100644 --- a/src/main/preload.ts +++ b/src/main/preload.ts @@ -29,6 +29,8 @@ import { IPC_UPDATE_APPLY, IPC_UPDATE_EVENT_AVAILABLE, IPC_UPDATE_EVENT_DOWNLOADED, + IPC_UPDATE_EVENT_PROGRESS, + IPC_UPDATE_EVENT_DIAG, IPC_POWER_SET_SCREEN_AWAKE, IPC_WINDOW_GET_START_FULLSCREEN, IPC_WINDOW_SET_START_FULLSCREEN, @@ -46,6 +48,8 @@ import { export type UpdateChannel = 'stable' | 'rc' | 'beta' | 'alpha' | 'nightly'; export interface UpdateAvailablePayload { version: string; channel: UpdateChannel } export interface UpdateDownloadedPayload { version: string; channel: UpdateChannel } +export interface UpdateProgressPayload { percent: number; channel: UpdateChannel } +export interface UpdateDiagPayload { ts: number; message: string; data: Record | null } // Audio setDevice payload — duplex when input/output types match and device // names match (or both empty); split otherwise. NodeAddon validates the @@ -554,11 +558,13 @@ const feedBackDesktopApi = { }, }, - // Auto-update (Velopack). The Settings panel reads/writes - // localStorage['slopsmith-update-channel'] and mirrors it via setChannel. - // Linux short-circuits to { status: "unsupported", platform: "linux" } on - // every call — renderer should branch on that and surface a "download - // from Releases" note rather than disabling the panel entirely. + // Auto-update (Velopack on win/mac, a home-grown AppImage self-updater on + // Linux — see update-manager.ts). The Settings panel reads/writes + // localStorage['feedBack-update-channel'] and mirrors it via setChannel. + // Linux only supports the nightly channel when running as an AppImage; + // any other case returns { status: "unsupported", platform: "linux" } — + // renderer should branch on that and surface a fallback note rather than + // disabling the panel entirely. update: { getStatus: () => ipcRenderer.invoke(IPC_UPDATE_GET_STATUS), setChannel: (channel: UpdateChannel) => ipcRenderer.invoke(IPC_UPDATE_SET_CHANNEL, channel), @@ -574,6 +580,20 @@ const feedBackDesktopApi = { ipcRenderer.on(IPC_UPDATE_EVENT_DOWNLOADED, listener); return () => ipcRenderer.removeListener(IPC_UPDATE_EVENT_DOWNLOADED, listener); }, + onProgress: (callback: (payload: UpdateProgressPayload) => void) => { + const listener = (_event: unknown, payload: UpdateProgressPayload) => callback(payload); + ipcRenderer.on(IPC_UPDATE_EVENT_PROGRESS, listener); + return () => ipcRenderer.removeListener(IPC_UPDATE_EVENT_PROGRESS, listener); + }, + // Diagnostic trace of main-process update decisions (Linux path). + // The renderer subscribes once and console.logs these so they land + // in diagnostics.js's exportable console ring buffer alongside the + // renderer's own [update-diag] logs. + onDiag: (callback: (payload: UpdateDiagPayload) => void) => { + const listener = (_event: unknown, payload: UpdateDiagPayload) => callback(payload); + ipcRenderer.on(IPC_UPDATE_EVENT_DIAG, listener); + return () => ipcRenderer.removeListener(IPC_UPDATE_EVENT_DIAG, listener); + }, }, // Keep the OS display/screensaver awake while a song plays. slopsmith core // app.js calls setScreenAwake(true) on play and (false) on pause/stop; diff --git a/src/main/update-manager.ts b/src/main/update-manager.ts index f602e7d..96339c9 100644 --- a/src/main/update-manager.ts +++ b/src/main/update-manager.ts @@ -1,6 +1,8 @@ -// Velopack-backed auto-updater for Slopsmith Desktop (Windows + macOS). +// Auto-updater for fee[dB]ack Desktop. Windows + macOS go through Velopack; +// Linux (AppImage only) goes through a home-grown GitHub-releases checker, +// since Velopack has no Linux support. // -// Architecture: +// Architecture (Windows/macOS — Velopack): // - The renderer persists the user's release channel in localStorage and // calls setChannel() on boot so this module's UpdateManager is bound to // the right feed (stable | rc | beta | alpha | nightly). The nightly feed @@ -12,10 +14,38 @@ // update:available immediately and update:downloaded once the .nupkg is // on disk. The renderer shows a banner whose "Restart to apply" button // funnels back into applyAndRestart(). -// - Linux has no Velopack pipeline (electron-builder AppImage/.deb only), -// so every method short-circuits to { status: "unsupported", ... } and -// never touches the SDK. The renderer can still render the channel -// dropdown — it just gets a clear "not supported here" status back. +// +// Architecture (Linux — AppImage): +// - electron-builder ships the nightly Linux build as a plain AppImage +// (release/*.AppImage), uploaded to the same rolling `nightly` GitHub +// Release as the Velopack win/mac feed — but with no Velopack manifest, +// so there's nothing for the Velopack SDK to read on this platform. +// - Only the `nightly` channel is supported (stable/rc/beta/alpha are +// one-off tagged releases, not a rolling release, so "find the newest +// matching tag" isn't a single API call the way `releases/tags/nightly` +// is — not needed for the current Linux use case, so left unsupported). +// - The AppImage filename and app.getVersion() never change between +// nightly builds (only win/mac get a date-stamped Velopack version), so +// semver comparison can't detect a new nightly. Instead the build bakes +// its source commit into dist/main/build-info.json (see build-common.sh); +// we compare that baked SHA against the GitHub release's +// `target_commitish` (the commit the latest nightly was cut from). A +// mismatch means the running build differs from the published nightly → +// offer the update. This needs no persistent state: after a swap + +// relaunch the new AppImage carries its OWN baked SHA, which now matches +// the release, so the next check reports idle. A short-lived in-memory +// note (linuxDownloadedSha) stops the 4h poll from re-downloading a +// build we've already staged this session. +// - On a SHA mismatch we download the `*.AppImage` asset next to the +// running AppImage (process.env.APPIMAGE, set by the AppImage runtime) +// and rename it over the original — same filesystem, so the rename is +// atomic, and Linux allows replacing a file that's currently executing +// (the running process keeps its old inode). No separate "apply" step is +// needed for the file swap; applyAndRestart() just relaunches the +// (already-replaced) AppImage and quits this process. +// - If the AppImage isn't running as an AppImage (process.env.APPIMAGE +// unset — e.g. a .deb install or an unpackaged dev build) or the channel +// isn't `nightly`, every method reports { status: "unsupported" }. // // Velopack JS SDK API notes (verified against // node_modules/velopack/lib/index.d.ts, package 0.0.1589-ga2c5a97, @@ -41,8 +71,20 @@ // (releases..json), not by GitHub prerelease flag. import { app, BrowserWindow } from 'electron'; +import { spawn } from 'child_process'; +import * as fs from 'fs'; +import * as path from 'path'; +import { Readable, Transform } from 'stream'; +import { pipeline } from 'stream/promises'; import type { UpdateInfo } from 'velopack'; -import { IPC_UPDATE_EVENT_AVAILABLE, IPC_UPDATE_EVENT_DOWNLOADED } from './ipc-channels'; +import { IPC_UPDATE_EVENT_AVAILABLE, IPC_UPDATE_EVENT_DOWNLOADED, IPC_UPDATE_EVENT_PROGRESS, IPC_UPDATE_EVENT_DIAG } from './ipc-channels'; +import { linuxUpdateDecision } from './linux-update-decision'; + +// Abort the small metadata request if GitHub stalls, so a dead connection +// surfaces as an error instead of a frozen "checking" state forever. The +// large AppImage download deliberately has no such deadline — it can legitimately +// run for minutes; its progress broadcasts are what reveal a stall there. +const METADATA_FETCH_TIMEOUT_MS = 30_000; export type UpdateChannel = 'stable' | 'rc' | 'beta' | 'alpha' | 'nightly'; @@ -50,19 +92,21 @@ export type UpdateStatus = | { status: 'unsupported'; platform: 'linux' } | { status: 'idle'; channel: UpdateChannel; currentVersion: string | null; lastChecked: number | null } | { status: 'checking'; channel: UpdateChannel; currentVersion: string | null; lastChecked: number | null } - | { status: 'downloading'; channel: UpdateChannel; currentVersion: string | null; lastChecked: number | null; pending: { version: string } } + | { status: 'downloading'; channel: UpdateChannel; currentVersion: string | null; lastChecked: number | null; pending: { version: string }; percent: number | null } | { status: 'downloaded'; channel: UpdateChannel; currentVersion: string | null; lastChecked: number | null; pending: { version: string } } | { status: 'error'; channel: UpdateChannel; currentVersion: string | null; lastChecked: number | null; message: string }; -// Repo the Velopack feed lives in. Matches the existing electron-builder -// release pipeline (got-feedback/feedback-desktop) — Velopack's GitHub -// loader looks for `releases..json` + `*-full.nupkg` / `*-delta.nupkg` -// assets attached to releases here. +// Repo the Velopack feed (win/mac) and the Linux nightly release both live +// in. Matches the existing electron-builder release pipeline. const FEED_URL = 'https://github.com/got-feedback/feedback-desktop'; -// Background poll cadence. Velopack downloads are cheap when there's nothing -// new (HEAD on the channel manifest), so 4h is a reasonable trade-off -// between freshness and noise on the user's network. +// GitHub REST API URL for the rolling nightly release. Public repo, no auth +// needed. Only used on Linux. +const GITHUB_NIGHTLY_RELEASE_API = FEED_URL.replace('https://github.com/', 'https://api.github.com/repos/') + '/releases/tags/nightly'; + +// Background poll cadence. Cheap on both platforms (Velopack HEADs the +// channel manifest; the Linux path is a single small GitHub API GET), so 4h +// is a reasonable trade-off between freshness and noise on the user's network. const POLL_INTERVAL_MS = 4 * 60 * 60 * 1000; // Held in module scope (singleton) — `main.ts` calls `init()` once after @@ -72,6 +116,18 @@ let currentChannel: UpdateChannel = 'stable'; let pollTimer: NodeJS.Timeout | null = null; let initialCheckTimer: NodeJS.Timeout | null = null; let inFlightCheck: Promise | null = null; +let linuxInFlightCheck: Promise | null = null; +// The remote nightly SHA we've already downloaded + staged THIS session, so a +// later poll (remote unchanged) doesn't re-fetch the ~1.5GB AppImage. Not +// persisted: after a restart the swapped binary's own baked SHA matches the +// release and reports idle. Survives channel round-trips (stable↔nightly) so +// the staged-update UI is restored when returning to nightly. +let linuxDownloadedSha: string | null = null; +// The background AppImage download promise (null when none running) + the +// latest whole-percent progress, so getStatus() can report progress to a +// renderer that (re)loads the panel mid-download, not just live listeners. +let linuxDownloadInFlight: Promise | null = null; +let downloadPercent: number | null = null; // Generation counter: incremented every time setChannel() replaces velopackUm // so that in-flight checks from the old channel can detect they are stale and // skip all state mutations + broadcasts. Without this, a check running on the @@ -94,7 +150,24 @@ function broadcast(channel: string, payload: unknown): void { } } +// Trace of main-process update decisions (Linux path only — this is the +// half of the story invisible to the renderer's diagnostics.js console wrap). +// Broadcasting it to the renderer means it lands in the SAME exportable +// console ring buffer the renderer's own [update-diag] logs use, so a single +// "Export Diagnostics" click captures both sides. Also console.error()s for +// visibility when launched from a terminal. Never allowed to affect the +// actual update flow — swallow any broadcast failure. +function diagLog(message: string, data?: Record): void { + console.error(`[update-diag] ${message}`, data ?? ''); + try { + broadcast(IPC_UPDATE_EVENT_DIAG, { ts: Date.now(), message, data: data ?? null }); + } catch { + // ignore + } +} + function currentVersion(): string | null { + if (isLinux) return app.getVersion(); if (!velopackUm) return null; try { return velopackUm.getCurrentVersion(); @@ -129,7 +202,7 @@ function createManager(channel: UpdateChannel): void { // main.ts caught and logged rather than crashed on — is surfaced as the // 'error' state by init()/setChannel() instead of crashing the process. // createManager() is only ever reached on win/mac (init()/setChannel() - // short-circuit on Linux), so the require is safe to run here. + // route to the Linux path first), so the require is safe to run here. // eslint-disable-next-line @typescript-eslint/no-require-imports const { UpdateManager } = require('velopack') as typeof import('velopack'); velopackUm = new UpdateManager(FEED_URL, { @@ -139,17 +212,245 @@ function createManager(channel: UpdateChannel): void { }); } +// ── Linux (AppImage) update path ──────────────────────────────────────── + +type GithubReleaseAsset = { name: string; browser_download_url: string }; +type GithubRelease = { target_commitish: string; assets: GithubReleaseAsset[] }; + +export type BuildInfo = { sha: string | null; coreSha: string | null }; + +let cachedBuildInfo: BuildInfo | null = null; + +// The commit(s) this build was cut from, baked into the packaged app by +// build-common.sh (dist/main/build-info.json, alongside the compiled JS): +// `sha` is feedback-desktop's own commit, `coreSha` is the bundled core +// (feedBack) repo's commit at clone time. Both null for dev/unpackaged +// builds or an 'unknown' placeholder. A missing `sha` makes the Linux update +// decision treat the running build as "not the latest nightly" and offer the +// update, which is the safe default. Exported so main.ts can surface both in +// app:getInfo, and so the renderer's diagnostic contribute() snapshot can +// report exactly which commit of EACH repo is actually running — settling +// "is this build stale" from a single exported log instead of guesswork. +export function readBuildInfo(): BuildInfo { + if (cachedBuildInfo) return cachedBuildInfo; + try { + const raw = fs.readFileSync(path.join(__dirname, 'build-info.json'), 'utf8'); + const info = JSON.parse(raw) as { sha?: string; coreSha?: string }; + cachedBuildInfo = { + sha: info.sha && info.sha !== 'unknown' ? info.sha : null, + coreSha: info.coreSha && info.coreSha !== 'unknown' ? info.coreSha : null, + }; + } catch { + cachedBuildInfo = { sha: null, coreSha: null }; + } + return cachedBuildInfo; +} + +function readBakedSha(): string | null { + return readBuildInfo().sha; +} + +// Download the nightly AppImage in the background and swap it in. Kicked off +// by checkNowLinux() AFTER it has already returned the 'downloading' status, +// so the renderer's "Check" button never blocks on the ~1.5GB fetch — it +// shows "update available" immediately and then live progress. Guards against +// overlapping downloads (a second check while one is running is a no-op here). +function startLinuxDownload(url: string, appImagePath: string, remoteSha: string, shortSha: string, myGeneration: number): void { + if (linuxDownloadInFlight) return; + diagLog('download start', { url, appImagePath, remoteSha }); + // Declared outside the try block (not computed from response data) so the + // catch handler can always clean up a partially-written file, even if + // fetch/pipeline fails before any bytes are written. + const tmpPath = `${appImagePath}.new`; + linuxDownloadInFlight = (async (): Promise => { + try { + const dl = await fetch(url); + if (!dl.ok || !dl.body) { + throw new Error(`Download failed: HTTP ${dl.status}`); + } + // Stream to disk rather than buffering the whole ~1.5GB AppImage in + // memory. Download next to the running AppImage so the rename below + // stays on the same filesystem (required for it to be atomic). + const total = Number(dl.headers.get('content-length')) || 0; + let received = 0; + let lastPercent = -1; + // Count bytes with a pass-through Transform in the pipeline rather + // than a manual 'data' listener on the source: pipeline() then owns + // the whole chain's completion + backpressure, so it reliably + // resolves at end-of-stream (a stray 'data' listener on the source + // can leave the download looking stuck at 100%). + const counter = new Transform({ + transform(chunk: Buffer, _enc, cb) { + received += chunk.length; + if (total) { + const percent = Math.floor((received / total) * 100); + if (percent !== lastPercent) { + lastPercent = percent; + downloadPercent = percent; + broadcast(IPC_UPDATE_EVENT_PROGRESS, { percent, channel: currentChannel }); + // Every 25% rather than every tick, to keep the + // diagnostic trace readable instead of flooded. + if (percent % 25 === 0) diagLog(`download progress ${percent}%`, { received, total }); + } + } + cb(null, chunk); + }, + }); + const body = Readable.fromWeb(dl.body as Parameters[0]); + await pipeline(body, counter, fs.createWriteStream(tmpPath)); + + // Check staleness BEFORE touching the real AppImage — a channel + // switch or new check mid-download must not let an abandoned + // download overwrite the running file. (Previously this check ran + // after chmod+rename, so a stale download would silently replace + // appImagePath anyway; only the in-memory state update was skipped.) + if (checkGeneration !== myGeneration) { + diagLog('download finished but generation is stale — discarding', { myGeneration, checkGeneration }); + fs.rmSync(tmpPath, { force: true }); + return; + } + fs.chmodSync(tmpPath, 0o755); + fs.renameSync(tmpPath, appImagePath); + linuxDownloadedSha = remoteSha; + pendingDownloaded = { version: shortSha }; + activeState = 'downloaded'; + downloadPercent = null; + diagLog('download complete, swapped in', { received, appImagePath }); + broadcast(IPC_UPDATE_EVENT_DOWNLOADED, { version: shortSha, channel: currentChannel }); + } catch (err) { + // Best-effort cleanup of a partially-written temp file. force:true + // tolerates the file never having been created (e.g. fetch itself + // failed before the pipeline started). + fs.rmSync(tmpPath, { force: true }); + if (checkGeneration !== myGeneration) return; + const message = err instanceof Error ? err.message : String(err); + console.error('[update-manager] Linux download failed:', message); + diagLog('download failed', { message }); + lastError = message; + activeState = 'error'; + downloadPercent = null; + } finally { + linuxDownloadInFlight = null; + } + })(); +} + +async function checkNowLinux(): Promise { + const appImagePath = process.env.APPIMAGE; + if (!appImagePath || currentChannel !== 'nightly') { + diagLog('checkNow: unsupported', { appImagePath: appImagePath ?? null, currentChannel }); + return { status: 'unsupported', platform: 'linux' }; + } + // A download already running means we've already found + reported the + // update; just report the current (downloading) state without a redundant + // metadata round-trip. + if (linuxDownloadInFlight) { + diagLog('checkNow: download already in flight, returning current status'); + return getStatus(); + } + if (linuxInFlightCheck) { + diagLog('checkNow: coalescing onto an already in-flight check'); + return linuxInFlightCheck; + } + const myGeneration = checkGeneration; + activeState = 'checking'; + diagLog('checkNow: starting', { channel: currentChannel, appImagePath, generation: myGeneration }); + const run = (async (): Promise => { + try { + const res = await fetch(GITHUB_NIGHTLY_RELEASE_API, { signal: AbortSignal.timeout(METADATA_FETCH_TIMEOUT_MS) }); + diagLog('checkNow: GitHub API responded', { status: res.status, url: GITHUB_NIGHTLY_RELEASE_API }); + if (!res.ok) { + throw new Error(`GitHub API returned ${res.status}`); + } + const release = (await res.json()) as GithubRelease; + if (checkGeneration !== myGeneration) { + diagLog('checkNow: generation stale after fetch — discarding', { myGeneration, checkGeneration }); + return getStatus(); + } + lastChecked = Date.now(); + lastError = null; + + const asset = release.assets.find((a) => a.name.endsWith('.AppImage')); + if (!asset) { + throw new Error('No .AppImage asset found in the nightly release'); + } + const remoteSha = release.target_commitish; + const shortSha = remoteSha.slice(0, 7); + const bakedSha = readBakedSha(); + const decision = linuxUpdateDecision(bakedSha, remoteSha, linuxDownloadedSha); + diagLog('checkNow: decision computed', { bakedSha, remoteSha, linuxDownloadedSha, decision, lastChecked }); + + // Running build IS the latest nightly — nothing to do. + if (decision === 'idle') { + activeState = 'idle'; + pendingVersion = null; + pendingDownloaded = null; + downloadPercent = null; + return getStatus(); + } + // Already downloaded + staged this same nightly this session; keep + // the pending-restart state rather than re-fetching the AppImage. + if (decision === 'staged') { + pendingVersion = shortSha; + pendingDownloaded = { version: shortSha }; + activeState = 'downloaded'; + downloadPercent = null; + return getStatus(); + } + + // decision === 'download': an update is available. Report it + // immediately (status 'downloading', percent 0) and kick the actual + // fetch off in the background — the renderer must not block on a + // multi-minute ~1.5GB download. + pendingVersion = shortSha; + activeState = 'downloading'; + downloadPercent = 0; + broadcast(IPC_UPDATE_EVENT_AVAILABLE, { version: shortSha, channel: currentChannel }); + broadcast(IPC_UPDATE_EVENT_PROGRESS, { percent: 0, channel: currentChannel }); + startLinuxDownload(asset.browser_download_url, appImagePath, remoteSha, shortSha, myGeneration); + return getStatus(); + } catch (err) { + if (checkGeneration !== myGeneration) return getStatus(); + const message = err instanceof Error ? err.message : String(err); + console.error('[update-manager] Linux checkNow failed:', message); + diagLog('checkNow: failed', { message }); + lastError = message; + activeState = 'error'; + } + return getStatus(); + })(); + linuxInFlightCheck = run; + try { + return await run; + } finally { + if (linuxInFlightCheck === run) { + linuxInFlightCheck = null; + } + } +} + /** * Initialize the updater. Must be called once after `app.whenReady()` and * after at least one BrowserWindow exists (so the first broadcast lands). - * On Linux this is a no-op. */ export function init(channel: UpdateChannel = 'stable'): void { + currentChannel = channel; if (isLinux) { - console.log('[update-manager] Linux: auto-update disabled (electron-builder AppImage/deb only).'); + const buildInfo = readBuildInfo(); + diagLog('init', { + platform: process.platform, + appImagePath: process.env.APPIMAGE ?? null, + channel, + buildSha: buildInfo.sha, + coreSha: buildInfo.coreSha, + }); + initialCheckTimer = setTimeout(() => { + initialCheckTimer = null; + void checkNow(); + }, 30_000); + pollTimer = setInterval(() => { void checkNow(); }, POLL_INTERVAL_MS); return; } - currentChannel = channel; try { createManager(channel); } catch (err) { @@ -189,14 +490,22 @@ export function init(channel: UpdateChannel = 'stable'): void { * Switch release channel at runtime. Recreates the underlying Velopack * UpdateManager (the SDK has no in-place channel swap) and triggers an * immediate check so the renderer can update its banner without waiting for - * the next 4h tick. On Linux this is a no-op. + * the next 4h tick. On Linux this just re-evaluates the (channel-gated) + * nightly checker. */ export function setChannel(channel: UpdateChannel): void { - if (isLinux) return; - if (channel === currentChannel && velopackUm) return; + // Unconditional, including the no-op path below — this is the one call + // site that mutates currentChannel, and prior diagnostic exports only + // ever showed its DOWNSTREAM effects (a checkNow() landing on the wrong + // channel), never the call itself, its caller, or whether it was a + // genuine transition vs a same-value no-op. Logging every attempt here + // is what actually answers "why does the channel keep flipping." + diagLog('setChannel called', { from: currentChannel, to: channel, isNoOp: channel === currentChannel, checkGeneration }); + if (channel === currentChannel && (isLinux || velopackUm)) return; currentChannel = channel; pendingVersion = null; pendingDownloaded = null; + downloadPercent = null; // Reset lastChecked too: until the immediate checkNow() below completes, // getStatus() should not report a stale "last checked" time that belongs // to the previous channel's feed. @@ -205,11 +514,12 @@ export function setChannel(channel: UpdateChannel): void { activeState = 'idle'; // Bump the generation counter so any still-running check from the old // channel sees its epoch is stale and skips all state mutations + - // broadcasts when its promise resolves. Also null inFlightCheck so the - // new checkNow() below starts a fresh lock rather than coalescing onto - // the old (stale) promise. + // broadcasts when its promise resolves. Also null the in-flight locks so + // the new checkNow() below starts a fresh lock rather than coalescing + // onto the old (stale) promise. checkGeneration++; inFlightCheck = null; + linuxInFlightCheck = null; // The immediate checkNow() below supersedes init()'s pending boot check. // The renderer calls setChannel() on boot to sync the persisted channel, // so without this a non-stable channel would fire a second redundant @@ -218,6 +528,10 @@ export function setChannel(channel: UpdateChannel): void { clearTimeout(initialCheckTimer); initialCheckTimer = null; } + if (isLinux) { + void checkNow(); + return; + } try { createManager(channel); } catch (err) { @@ -233,11 +547,11 @@ export function setChannel(channel: UpdateChannel): void { /** * Trigger an immediate update check + download. Coalesces concurrent calls * (renderer button-mashing, overlapping poll timer) onto the same promise - * so we don't fire parallel HTTP requests at the GitHub feed. + * so we don't fire parallel HTTP requests at the feed. */ export async function checkNow(): Promise { if (isLinux) { - return { status: 'unsupported', platform: 'linux' }; + return checkNowLinux(); } if (!velopackUm) { return { @@ -325,17 +639,68 @@ export async function checkNow(): Promise { } /** - * Apply the downloaded update and restart the app. waitExitThenApplyUpdate() - * launches the Velopack updater and tells it to wait for THIS process to - * exit — it does NOT exit us. We must quit the app ourselves; the updater - * then swaps binaries and relaunches. It only waits ~60s for our exit, so we - * quit promptly (on the next tick, so this IPC call can return first). - * activeState is left at 'downloaded' so that if the quit is vetoed or - * delayed the restart banner stays visible for a retry. + * Apply the downloaded update and restart the app. + * + * Windows/macOS: waitExitThenApplyUpdate() launches the Velopack updater and + * tells it to wait for THIS process to exit — it does NOT exit us. We must + * quit the app ourselves; the updater then swaps binaries and relaunches. It + * only waits ~60s for our exit, so we quit promptly (on the next tick, so + * this IPC call can return first). activeState is left at 'downloaded' so + * that if the quit is vetoed or delayed the restart banner stays visible for + * a retry. + * + * Linux: checkNowLinux() already replaced the AppImage file on disk (Linux + * allows overwriting a file that's currently executing). There's nothing + * left to "apply" — just launch the (already-new) AppImage as a detached + * process and quit this one. */ export function applyAndRestart(): UpdateStatus { if (isLinux) { - return { status: 'unsupported', platform: 'linux' }; + const appImagePath = process.env.APPIMAGE; + diagLog('applyAndRestart: called', { appImagePath: appImagePath ?? null, activeState }); + if (!appImagePath) { + return { status: 'unsupported', platform: 'linux' }; + } + if (activeState !== 'downloaded' || !pendingDownloaded) { + diagLog('applyAndRestart: nothing staged', { activeState, pendingDownloaded }); + return { + status: 'error', + channel: currentChannel, + currentVersion: currentVersion(), + lastChecked, + message: 'No update is ready to apply', + }; + } + try { + const child = spawn(appImagePath, [], { detached: true, stdio: 'ignore' }); + // spawn() can fail asynchronously (e.g. exec format error, a + // permissions race on the just-swapped-in file) rather than + // throwing synchronously — only quit once 'spawn' confirms the OS + // actually started the process. On 'error', leave the app running + // (state still 'downloaded' unless we mark it 'error' below) so + // the user can see something went wrong and retry, instead of the + // app just disappearing with nothing coming back. + child.once('spawn', () => { + diagLog('applyAndRestart: spawned relaunch, quitting'); + app.quit(); + }); + child.once('error', (err) => { + const message = err instanceof Error ? err.message : String(err); + console.error('[update-manager] Linux relaunch failed:', message); + diagLog('applyAndRestart: spawn failed', { message }); + lastError = message; + activeState = 'error'; + }); + child.unref(); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + console.error('[update-manager] Linux relaunch failed:', message); + diagLog('applyAndRestart: spawn failed', { message }); + lastError = message; + activeState = 'error'; + return getStatus(); + } + return getStatus(); } if (!velopackUm) { return { @@ -391,7 +756,7 @@ export function applyAndRestart(): UpdateStatus { } export function getStatus(): UpdateStatus { - if (isLinux) { + if (isLinux && (!process.env.APPIMAGE || currentChannel !== 'nightly')) { return { status: 'unsupported', platform: 'linux' }; } const base = { @@ -410,6 +775,9 @@ export function getStatus(): UpdateStatus { // can show the target version even before the download completes // and pendingDownloaded is populated. pending: { version: pendingVersion ?? '' }, + // Non-null only on Linux (Velopack doesn't report a percentage); + // lets a panel that loads mid-download show progress immediately. + percent: downloadPercent, }; case 'downloaded': return { diff --git a/src/renderer/screen.js b/src/renderer/screen.js index ab35643..d961128 100644 --- a/src/renderer/screen.js +++ b/src/renderer/screen.js @@ -1963,179 +1963,9 @@ window.__feedBackDesktopAudioHooks = window.__feedBackDesktopAudioHooks || {}; setupAudioQualityControls(); setupToneAutomationSettingsEvents(); - setupUpdateChannelControls(); setupMaintenanceControls(); } - // ── Updater (Velopack) settings UI ──────────────────────────────────────── - // Reads/writes the persisted channel in localStorage and talks to the main - // process via window.feedBackDesktop.update (added by the main-process slice). - // Designed to degrade gracefully when the updater IPC namespace is missing - // (dev builds before the main slice lands) or when running on Linux. - function setupUpdateChannelControls() { - const channelSelect = document.getElementById('update-channel'); - const checkBtn = document.getElementById('update-check-now'); - const statusEl = document.getElementById('update-status'); - const linuxNote = document.getElementById('update-linux-note'); - if (!channelSelect || !checkBtn || !statusEl) return; - - const VALID_CHANNELS = ['stable', 'rc', 'beta', 'alpha', 'nightly']; - const storedChannelRaw = localStorage.getItem('slopsmith-update-channel'); - const storedChannel = VALID_CHANNELS.includes(storedChannelRaw) ? storedChannelRaw : 'stable'; - channelSelect.value = storedChannel; - - const updateApi = window.feedBackDesktop?.update; - const isLinux = window.feedBackDesktop?.platform === 'linux'; - - function showLinuxFallback(message) { - if (linuxNote) linuxNote.classList.remove('hidden'); - channelSelect.disabled = true; - checkBtn.disabled = true; - statusEl.textContent = message || 'Auto-update is not available on this platform.'; - } - - if (!updateApi) { - statusEl.textContent = 'Updater not initialized (running in dev or unsupported build).'; - channelSelect.disabled = true; - checkBtn.disabled = true; - return; - } - - if (isLinux) { - showLinuxFallback('Auto-update is not available on Linux.'); - // Still inform main of the persisted channel so cross-platform logic stays consistent. - try { void updateApi.setChannel(storedChannel); } catch (_) { /* defensive */ } - return; - } - - function fmtTimestamp(ts) { - if (!ts) return 'never'; - try { - const d = new Date(ts); - if (Number.isNaN(d.getTime())) return 'never'; - return d.toLocaleString(); - } catch (_) { - return 'never'; - } - } - - function renderStatus(extra) { - try { - void updateApi.getStatus().then((s) => { - if (!s) { - statusEl.textContent = extra || 'Updater status unavailable.'; - return; - } - if (s.status === 'unsupported' || s.platform === 'linux') { - showLinuxFallback('Auto-update is not available on Linux.'); - return; - } - if (s.status === 'error') { - // Surface the error message so users can tell why update - // checks are failing rather than seeing a healthy status. - const errMsg = s.message ? `Update error: ${s.message}` : 'Update check failed.'; - statusEl.textContent = extra ? `${extra} · ${errMsg}` : errMsg; - return; - } - const parts = [ - `Version ${s.currentVersion || '?'}`, - `channel ${s.channel || channelSelect.value}`, - `last checked ${fmtTimestamp(s.lastChecked)}`, - ]; - statusEl.textContent = extra ? `${extra} · ${parts.join(' · ')}` : parts.join(' · '); - }).catch((e) => { - console.warn('[updater] getStatus failed:', e); - statusEl.textContent = extra || 'Failed to read updater status.'; - }); - } catch (e) { - console.warn('[updater] getStatus threw:', e); - statusEl.textContent = extra || 'Failed to read updater status.'; - } - } - - // Inform main of the persisted channel on panel load. - try { - void Promise.resolve(updateApi.setChannel(storedChannel)).catch((e) => { - console.warn('[updater] setChannel(initial) failed:', e); - }); - } catch (e) { - console.warn('[updater] setChannel(initial) threw:', e); - } - - // setupUpdateChannelControls() re-runs if screen.js is re-evaluated. - // Drop the change/click handlers a previous evaluation bound (a no-op - // if the element was replaced) so they don't stack into duplicate - // setChannel()/checkNow() IPC calls per user action. - if (hookState.updateChannelOnChange) { - channelSelect.removeEventListener('change', hookState.updateChannelOnChange); - } - if (hookState.updateCheckOnClick) { - checkBtn.removeEventListener('click', hookState.updateCheckOnClick); - } - - const onChannelChange = () => { - const val = channelSelect.value; - if (!VALID_CHANNELS.includes(val)) return; - try { localStorage.setItem('slopsmith-update-channel', val); } catch (_) {} - try { - void Promise.resolve(updateApi.setChannel(val)).catch((e) => { - console.warn('[updater] setChannel failed:', e); - }); - } catch (e) { - console.warn('[updater] setChannel threw:', e); - } - renderStatus(`Channel set to ${val}.`); - }; - channelSelect.addEventListener('change', onChannelChange); - hookState.updateChannelOnChange = onChannelChange; - - const onCheckClick = async () => { - checkBtn.disabled = true; - statusEl.textContent = 'Checking for updates…'; - // Track whether we should re-enable the button in finally. On - // unsupported platforms showLinuxFallback() permanently disables - // the button; the finally block must not undo that. - let reEnableBtn = true; - try { - const result = await updateApi.checkNow(); - const status = result?.status || 'unknown'; - let msg; - switch (status) { - case 'idle': - // checkNow() returned null info — no update available in this channel. - msg = "You're on the newest version in this channel."; - break; - case 'downloading': - // Update found; download kicked off automatically by checkNow(). - msg = `Update available — downloading…`; - break; - case 'downloaded': - msg = 'Update downloaded — restart to apply.'; - break; - case 'unsupported': - reEnableBtn = false; - showLinuxFallback('Auto-update is not available on Linux.'); - return; - case 'error': - msg = `Update check failed${result?.message ? `: ${result.message}` : '.'}`; - break; - default: - msg = `Update check returned: ${status}`; - } - renderStatus(msg); - } catch (e) { - console.warn('[updater] checkNow failed:', e); - statusEl.textContent = `Update check failed: ${e?.message || e}`; - } finally { - if (reEnableBtn) checkBtn.disabled = false; - } - }; - checkBtn.addEventListener('click', onCheckClick); - hookState.updateCheckOnClick = onCheckClick; - - renderStatus(); - } - // ── Reset / repair configuration (Maintenance) ──────────────────────────── // Replaces the old "delete the config folder before upgrading" instruction. // Talks to the main process via window.feedBackDesktop.maintenance, which diff --git a/src/renderer/settings.html b/src/renderer/settings.html index 3f152e3..5392197 100644 --- a/src/renderer/settings.html +++ b/src/renderer/settings.html @@ -1,32 +1,4 @@
-
-
Updates
-
-
- - -

Pre-release channels (rc/beta/alpha) opt you into early builds. Nightly rebuilds from the latest code every night and is the least stable.

-
-
- - -
-
-

Loading updater status…

- -
diff --git a/tests/linux-update-decision.test.js b/tests/linux-update-decision.test.js new file mode 100644 index 0000000..8c11d96 --- /dev/null +++ b/tests/linux-update-decision.test.js @@ -0,0 +1,34 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const { loadTs } = require('./_load-ts'); + +const { linuxUpdateDecision } = loadTs('src/main/linux-update-decision.ts'); + +// Two distinct 40-char commit SHAs. +const N = 'a'.repeat(40); // "current" nightly +const N1 = 'b'.repeat(40); // a newer nightly +const OLD = 'c'.repeat(40); // some earlier staged sha + +test('running build IS the latest nightly → idle', () => { + assert.equal(linuxUpdateDecision(N, N, null), 'idle'); +}); + +test('newer nightly available, nothing staged → download', () => { + assert.equal(linuxUpdateDecision(N, N1, null), 'download'); +}); + +test('newer nightly already staged this session → staged (no re-download)', () => { + assert.equal(linuxUpdateDecision(N, N1, N1), 'staged'); +}); + +test('unknown/dev build (no baked sha) always offers the update → download', () => { + assert.equal(linuxUpdateDecision(null, N, null), 'download'); +}); + +test('remote advanced past the sha we staged → download the newer one', () => { + assert.equal(linuxUpdateDecision(N, N1, OLD), 'download'); +}); + +test('no baked sha but this remote already staged → staged (not re-downloaded)', () => { + assert.equal(linuxUpdateDecision(null, N, N), 'staged'); +});