feat(update): Linux AppImage self-update on the nightly channel

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 <noreply@anthropic.com>
This commit is contained in:
Matthew Harris Glover 2026-07-17 14:16:52 -04:00
parent dee918e705
commit cd37efdce7
10 changed files with 545 additions and 242 deletions

View File

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

View File

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

View File

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

View File

@ -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';
}

View File

@ -1255,12 +1255,17 @@ async function startup(): Promise<void> {
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

View File

@ -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<string, unknown> | 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;

View File

@ -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.<channel>.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.<channel>.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<UpdateInfo | null> | null = null;
let linuxInFlightCheck: Promise<UpdateStatus> | 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<void> | 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<string, unknown>): 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<void> => {
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<typeof Readable.fromWeb>[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<UpdateStatus> {
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<UpdateStatus> => {
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<UpdateStatus> {
if (isLinux) {
return { status: 'unsupported', platform: 'linux' };
return checkNowLinux();
}
if (!velopackUm) {
return {
@ -325,17 +639,68 @@ export async function checkNow(): Promise<UpdateStatus> {
}
/**
* 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 {

View File

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

View File

@ -1,32 +1,4 @@
<div class="space-y-3">
<div id="ae-updates-section" class="border border-slate-700 rounded-lg p-3 bg-slate-800/30">
<div class="text-xs font-semibold text-slate-300 mb-2">Updates</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
<div>
<label class="block text-xs text-slate-400 mb-1" for="update-channel">Update channel</label>
<select id="update-channel" class="w-full bg-slate-700 border border-slate-600 rounded px-3 py-1.5 text-sm">
<option value="stable">Stable</option>
<option value="rc">Release candidate</option>
<option value="beta">Beta</option>
<option value="alpha">Alpha</option>
<option value="nightly">Nightly</option>
</select>
<p class="text-xs text-slate-500 mt-1">Pre-release channels (rc/beta/alpha) opt you into early builds. Nightly rebuilds from the latest code every night and is the least stable.</p>
</div>
<div class="flex flex-col">
<label class="block text-xs text-slate-400 mb-1">Manual check</label>
<button id="update-check-now" class="px-3 py-1.5 rounded bg-blue-600 hover:bg-blue-500 text-sm font-medium disabled:opacity-50">
Check for updates
</button>
</div>
</div>
<p id="update-status" class="text-xs text-slate-400 mt-3">Loading updater status…</p>
<p id="update-linux-note" class="hidden text-xs text-amber-300 mt-2">
Auto-update is not available on Linux —
<a id="update-releases-link" href="https://github.com/got-feedback/feedback-desktop/releases"
target="_blank" rel="noopener" class="underline hover:text-amber-200">download new versions from GitHub Releases</a>.
</p>
</div>
<div>
<label class="block text-xs text-slate-400 mb-1">Audio Sync Offset</label>
<div class="flex items-center gap-3">

View File

@ -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');
});