mirror of
https://github.com/got-feedBack/feedBack-desktop.git
synced 2026-07-21 12:22:34 +00:00
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:
parent
dee918e705
commit
cd37efdce7
@ -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 ""
|
||||
}
|
||||
|
||||
@ -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'
|
||||
|
||||
@ -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
|
||||
|
||||
30
src/main/linux-update-decision.ts
Normal file
30
src/main/linux-update-decision.ts
Normal 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';
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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">
|
||||
|
||||
34
tests/linux-update-decision.test.js
Normal file
34
tests/linux-update-decision.test.js
Normal 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');
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user