diff --git a/fluxer_desktop/scripts/build.mjs b/fluxer_desktop/scripts/build.mjs index 4654b5e59..fe5718064 100644 --- a/fluxer_desktop/scripts/build.mjs +++ b/fluxer_desktop/scripts/build.mjs @@ -23,6 +23,7 @@ const SPLASH_DIST_DIR = path.join(DIST_DIR, 'splash'); const SPLASH_PRELOAD_FILE_NAME = 'splash.cjs'; const REQUIRED_RENDERER_ENTRIES = Object.freeze(['index.html', 'assets']); const BUNDLED_RENDERER_ENTRIES = Object.freeze([...REQUIRED_RENDERER_ENTRIES, 'version.json']); +const BUNDLED_RENDERER_VERSION_PATTERN = /^\d+\.\d+\.\d+$/u; const FORBIDDEN_RENDERER_ENTRIES = Object.freeze(['sw.js', 'sw.js.map']); const SUPPORTED_BUILD_ARGUMENTS = Object.freeze(['--shared-assets', '--use-shared-renderer']); const MAIN_BOOTSTRAP_ENTRY_NAME = 'index'; @@ -97,6 +98,24 @@ class BundledRendererMissingError extends Error { } } +class BundledRendererVersionError extends Error { + constructor(bundledVersion, buildVersion) { + super( + `The bundled renderer reports version ${JSON.stringify(bundledVersion)} and this shell is ${JSON.stringify(buildVersion)}. A shell that loads modules must bundle the renderer built for the same numeric version, because it serves whichever of the two is newer.`, + ); + this.name = 'BundledRendererVersionError'; + } +} + +function readBundledRendererVersion() { + try { + const parsed = JSON.parse(fs.readFileSync(path.join(RENDERER_DIST_DIR, 'version.json'), 'utf8')); + return typeof parsed?.version === 'string' ? parsed.version : ''; + } catch { + return ''; + } +} + class RendererServiceWorkerPresentError extends Error { constructor(present) { super( @@ -847,6 +866,10 @@ function prunePackedRendererModules() { if (missing.length > 0) { throw new BundledRendererMissingError(RENDERER_DIST_DIR, missing); } + const bundledVersion = readBundledRendererVersion(); + if (!BUNDLED_RENDERER_VERSION_PATTERN.test(bundledVersion) || bundledVersion !== embeddedBuildVersion) { + throw new BundledRendererVersionError(bundledVersion, embeddedBuildVersion); + } console.log(` Bundled renderer pruned in ${Date.now() - startedAt}ms`); } diff --git a/fluxer_desktop/scripts/release-check.mjs b/fluxer_desktop/scripts/release-check.mjs new file mode 100644 index 000000000..fd1038921 --- /dev/null +++ b/fluxer_desktop/scripts/release-check.mjs @@ -0,0 +1,384 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import {execFileSync, spawn} from 'node:child_process'; +import * as fs from 'node:fs'; +import * as net from 'node:net'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import {parseArgs} from 'node:util'; + +const CHANNEL_DATA_DIRECTORIES = Object.freeze({ + stable: 'fluxer', + canary: 'fluxercanary', + development: 'fluxerdevelopment', +}); +const SERVED_RENDERER_PATTERN = /Serving the renderer: source=(\S+) version=(\S+) bundled=(\S+)/gu; +const REPORTED_BUILD_PATTERN = /The renderer reported its build: (.+)$/gmu; +const REPORTED_WEB_VERSION_PATTERN = /\bWeb (\d+\.\d+\.\d+)\b/u; +const POLL_INTERVAL_MS = 500; +const QUIT_GRACE_MS = 8000; +const USAGE = `Usage: node scripts/release-check.mjs --app --channel --expect-renderer + +Runs a packaged desktop shell in a throwaway portable profile and fails unless the renderer +it serves, and the renderer that reports back from the window, are both the expected version. + + --app A macOS .app bundle, an unpacked app directory or its executable + --channel The channel the shell was built for + --expect-renderer The renderer build version the user must end up on + --expect-source bundled or module, when the source also has to match + --seed-profile A portable profile to start from, for example the previous release's + --package-origin The package feed to use instead of the one the shell was built for + --update Ask the running shell to update through its own command line path + --update-from-renderer Start the update the way the in app update button does instead + --expect-initial With --update, the renderer version before the update + --before-update With --update, a shell command to run once the app is up, for example a publish + --app-arg Extra argument for the shell, repeatable + --timeout-seconds How long each stage may take, default 180 + --workdir Where to create the sandbox, default the system temp directory + --keep Leave the sandbox in place for inspection +`; + +class ReleaseCheckError extends Error { + constructor(message) { + super(message); + this.name = 'ReleaseCheckError'; + } +} + +function fail(message) { + throw new ReleaseCheckError(message); +} + +function compareVersions(left, right) { + const a = left.split('.').map((part) => Number.parseInt(part, 10)); + const b = right.split('.').map((part) => Number.parseInt(part, 10)); + for (let index = 0; index < Math.max(a.length, b.length); index += 1) { + const difference = (a[index] ?? 0) - (b[index] ?? 0); + if (difference !== 0) return difference; + } + return 0; +} + +function readOptions() { + const {values} = parseArgs({ + options: { + app: {type: 'string'}, + channel: {type: 'string'}, + 'expect-renderer': {type: 'string'}, + 'expect-source': {type: 'string'}, + 'expect-initial': {type: 'string'}, + 'before-update': {type: 'string'}, + 'seed-profile': {type: 'string'}, + 'package-origin': {type: 'string'}, + update: {type: 'boolean', default: false}, + 'update-from-renderer': {type: 'boolean', default: false}, + 'app-arg': {type: 'string', multiple: true, default: []}, + 'timeout-seconds': {type: 'string', default: '180'}, + workdir: {type: 'string'}, + keep: {type: 'boolean', default: false}, + help: {type: 'boolean', default: false}, + }, + }); + if (values.help) { + process.stdout.write(USAGE); + process.exit(0); + } + for (const required of ['app', 'channel', 'expect-renderer']) { + if (!values[required]) fail(`--${required} is required\n\n${USAGE}`); + } + if (!Object.hasOwn(CHANNEL_DATA_DIRECTORIES, values.channel)) { + fail(`--channel must be one of ${Object.keys(CHANNEL_DATA_DIRECTORIES).join(', ')}`); + } + if (values['expect-source'] != null && !['bundled', 'module'].includes(values['expect-source'])) { + fail('--expect-source must be bundled or module'); + } + const timeoutSeconds = Number.parseInt(values['timeout-seconds'], 10); + if (!Number.isSafeInteger(timeoutSeconds) || timeoutSeconds <= 0) { + fail('--timeout-seconds must be a positive integer'); + } + return { + app: path.resolve(values.app), + channel: values.channel, + expectRenderer: values['expect-renderer'], + expectSource: values['expect-source'] ?? null, + expectInitial: values['expect-initial'] ?? null, + beforeUpdate: values['before-update'] ?? null, + seedProfile: values['seed-profile'] == null ? null : path.resolve(values['seed-profile']), + packageOrigin: values['package-origin'] ?? null, + update: values.update || values['update-from-renderer'], + updateFromRenderer: values['update-from-renderer'], + appArgs: values['app-arg'], + timeoutMs: timeoutSeconds * 1000, + workdir: values.workdir == null ? os.tmpdir() : path.resolve(values.workdir), + keep: values.keep, + }; +} + +function copyTree(source, target) { + if (process.platform === 'darwin') { + execFileSync('cp', ['-cR', source, target]); + return; + } + fs.cpSync(source, target, {recursive: true, verbatimSymlinks: true}); +} + +function resolveMacExecutable(appBundle) { + const macOSDirectory = path.join(appBundle, 'Contents', 'MacOS'); + const candidates = fs.readdirSync(macOSDirectory).filter((name) => !name.startsWith('.')); + const named = candidates.find((name) => name === path.basename(appBundle, '.app')); + const chosen = named ?? candidates[0]; + if (chosen == null) fail(`${macOSDirectory} holds no executable`); + return path.join(macOSDirectory, chosen); +} + +function stageApp(options, sandbox) { + const source = fs.statSync(options.app); + if (options.app.endsWith('.app') && source.isDirectory()) { + const staged = path.join(sandbox, path.basename(options.app)); + copyTree(options.app, staged); + return {executable: resolveMacExecutable(staged), portableRoot: path.join(sandbox, 'data')}; + } + if (source.isDirectory()) { + const staged = path.join(sandbox, 'app'); + copyTree(options.app, staged); + const executable = fs + .readdirSync(staged) + .map((name) => path.join(staged, name)) + .find((candidate) => { + const stats = fs.statSync(candidate); + return stats.isFile() && (stats.mode & 0o111) !== 0 && /^fluxer/iu.test(path.basename(candidate)); + }); + if (executable == null) fail(`${options.app} holds no fluxer executable`); + return {executable, portableRoot: path.join(staged, 'data')}; + } + const staged = path.join(sandbox, path.basename(options.app)); + fs.copyFileSync(options.app, staged); + fs.chmodSync(staged, 0o755); + return {executable: staged, portableRoot: path.join(sandbox, 'data')}; +} + +function freePort() { + return new Promise((resolve, reject) => { + const server = net.createServer(); + server.once('error', reject); + server.listen(0, '127.0.0.1', () => { + const {port} = server.address(); + server.close(() => resolve(port)); + }); + }); +} + +function sleep(ms) { + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); +} + +function readLog(logPath) { + try { + return fs.readFileSync(logPath, 'utf8'); + } catch { + return ''; + } +} + +function lastMatch(text, pattern) { + let last = null; + for (const match of text.matchAll(pattern)) { + last = match; + } + return last; +} + +function observeLog(logPath) { + const text = readLog(logPath); + const served = lastMatch(text, SERVED_RENDERER_PATTERN); + const reported = lastMatch(text, REPORTED_BUILD_PATTERN); + const reportedVersion = reported == null ? null : (REPORTED_WEB_VERSION_PATTERN.exec(reported[1])?.[1] ?? null); + return { + served: served == null ? null : {source: served[1], version: served[2], bundled: served[3]}, + reportedBuild: reported?.[1] ?? null, + reportedVersion, + }; +} + +async function evaluateInPage(debugPort, expression) { + let targets; + try { + targets = await (await fetch(`http://127.0.0.1:${debugPort}/json/list`)).json(); + } catch { + return null; + } + const page = targets.find((target) => target.type === 'page' && String(target.url).startsWith('fluxer-app://app')); + if (page == null) return null; + return await new Promise((resolve) => { + const socket = new WebSocket(page.webSocketDebuggerUrl); + const timer = setTimeout(() => { + socket.close(); + resolve(null); + }, 5000); + socket.addEventListener('open', () => { + socket.send( + JSON.stringify({ + id: 1, + method: 'Runtime.evaluate', + params: {expression, awaitPromise: true, returnByValue: true}, + }), + ); + }); + socket.addEventListener('message', (event) => { + const message = JSON.parse(String(event.data)); + if (message.id !== 1) return; + clearTimeout(timer); + socket.close(); + resolve(message.result?.result?.value ?? null); + }); + socket.addEventListener('error', () => { + clearTimeout(timer); + resolve(null); + }); + }); +} + +async function servedVersionInPage(debugPort) { + const value = await evaluateInPage( + debugPort, + "fetch('/version.json', {cache: 'no-store'}).then((response) => response.json()).then((body) => body.version).catch(() => null)", + ); + return typeof value === 'string' ? value : null; +} + +async function startUpdateFromRenderer(debugPort, timeoutMs) { + const deadline = Date.now() + timeoutMs; + await evaluateInPage(debugPort, "window.electron.updaterCheck?.('user').then(() => true, () => false)"); + while (Date.now() < deadline) { + const available = await evaluateInPage( + debugPort, + 'window.electron.desktopUpdate.state().then((state) => state.available === true, () => false)', + ); + if (available === true) { + await evaluateInPage(debugPort, 'window.electron.desktopUpdate.start().then(() => true, () => false)'); + return; + } + await sleep(POLL_INTERVAL_MS); + } + fail('update: the shell never told the renderer that an update is available'); +} + +async function waitForRenderer({logPath, debugPort, version, timeoutMs, label}) { + const deadline = Date.now() + timeoutMs; + let observed = {log: observeLog(logPath), page: null}; + while (Date.now() < deadline) { + observed = {log: observeLog(logPath), page: await servedVersionInPage(debugPort)}; + if ( + observed.page === version && + observed.log.reportedVersion === version && + observed.log.served?.version === version + ) { + return observed; + } + await sleep(POLL_INTERVAL_MS); + } + fail( + `${label}: expected renderer ${version}, the shell serves ${observed.log.served?.version ?? 'nothing'} from ${ + observed.log.served?.source ?? 'nowhere' + }, the window loads ${observed.page ?? 'nothing'} and reports ${observed.log.reportedBuild ?? 'nothing'}`, + ); +} + +async function stop(child) { + if (child.exitCode != null || child.signalCode != null) return; + const exited = new Promise((resolve) => { + child.once('exit', resolve); + }); + child.kill('SIGTERM'); + const graceful = await Promise.race([exited.then(() => true), sleep(QUIT_GRACE_MS).then(() => false)]); + if (!graceful) { + child.kill('SIGKILL'); + await exited; + } +} + +async function run() { + const options = readOptions(); + fs.mkdirSync(options.workdir, {recursive: true}); + const sandbox = fs.mkdtempSync(path.join(options.workdir, 'fluxer-release-check-')); + const {executable, portableRoot} = stageApp(options, sandbox); + const profile = path.join(portableRoot, CHANNEL_DATA_DIRECTORIES[options.channel]); + if (options.seedProfile != null) { + fs.mkdirSync(portableRoot, {recursive: true}); + copyTree(options.seedProfile, profile); + for (const stale of ['SingletonLock', 'SingletonCookie', 'SingletonSocket', 'DevToolsActivePort']) { + fs.rmSync(path.join(profile, stale), {force: true}); + } + fs.rmSync(path.join(profile, 'logs'), {force: true, recursive: true}); + } + fs.mkdirSync(profile, {recursive: true}); + const logPath = path.join(profile, 'logs', 'main.log'); + const debugPort = await freePort(); + const launchArguments = ['--fluxer-portable', `--remote-debugging-port=${debugPort}`, ...options.appArgs]; + const environment = {...process.env}; + if (options.packageOrigin != null) { + environment.FLUXER_DESKTOP_PACKAGE_ORIGIN = options.packageOrigin; + } + const child = spawn(executable, launchArguments, {env: environment, stdio: 'ignore'}); + const result = { + sandbox, + executable, + profile, + expectRenderer: options.expectRenderer, + stages: [], + }; + try { + const firstVersion = + options.update && options.expectInitial != null ? options.expectInitial : options.expectRenderer; + const first = await waitForRenderer({ + logPath, + debugPort, + version: firstVersion, + timeoutMs: options.timeoutMs, + label: 'launch', + }); + result.stages.push({stage: 'launch', ...first}); + let final = first; + if (options.update) { + if (options.beforeUpdate != null) { + execFileSync(options.beforeUpdate, {shell: true, stdio: 'inherit'}); + } + if (options.updateFromRenderer) { + await startUpdateFromRenderer(debugPort, options.timeoutMs); + } else { + execFileSync(executable, ['--fluxer-portable', '--fluxer-update'], {env: environment, stdio: 'ignore'}); + } + final = await waitForRenderer({ + logPath, + debugPort, + version: options.expectRenderer, + timeoutMs: options.timeoutMs, + label: 'update', + }); + result.stages.push({stage: 'update', ...final}); + } + const served = final.log.served; + if (options.expectSource != null && served.source !== options.expectSource) { + fail(`the shell serves the ${served.source} renderer, expected the ${options.expectSource} one`); + } + if (served.bundled !== 'none' && compareVersions(served.version, served.bundled) < 0) { + fail(`the shell serves renderer ${served.version}, older than the ${served.bundled} it bundles`); + } + result.shellPid = child.pid; + result.shellStillRunning = child.exitCode == null && child.signalCode == null; + result.ok = true; + } finally { + await stop(child); + if (!options.keep) { + fs.rmSync(sandbox, {recursive: true, force: true}); + } + } + process.stdout.write(`${JSON.stringify(result, null, '\t')}\n`); +} + +run().catch((error) => { + process.stderr.write(`${error instanceof ReleaseCheckError ? error.message : (error?.stack ?? String(error))}\n`); + process.exit(1); +}); diff --git a/fluxer_desktop/src/common/Logger.ts b/fluxer_desktop/src/common/Logger.ts index f3c358b10..492424098 100644 --- a/fluxer_desktop/src/common/Logger.ts +++ b/fluxer_desktop/src/common/Logger.ts @@ -1,8 +1,11 @@ // SPDX-License-Identifier: AGPL-3.0-or-later +import path from 'node:path'; import {BUILD_CHANNEL, type BuildChannel} from '@electron/common/BuildChannel'; import log from 'electron-log'; +const DEFAULT_LOG_FILE_NAME = 'main.log'; + const CHANNEL_LOG_LEVELS: Record = { stable: 'info', canary: 'debug', @@ -29,3 +32,7 @@ export function createChildLogger(componentName: string): typeof Logger { error: (...args: Array) => log.error(prefix, ...args), }; } + +export function writeLogFilesUnder(logsPath: string): void { + log.transports.file.resolvePathFn = (variables) => path.join(logsPath, variables.fileName ?? DEFAULT_LOG_FILE_NAME); +} diff --git a/fluxer_desktop/src/common/ModuleSystem.ts b/fluxer_desktop/src/common/ModuleSystem.ts index 656d0db9c..c81b2670b 100644 --- a/fluxer_desktop/src/common/ModuleSystem.ts +++ b/fluxer_desktop/src/common/ModuleSystem.ts @@ -8,6 +8,8 @@ export const MODULE_SYSTEM_BUILD_ENABLED = process.env.FLUXER_MODULES === '1'; const OFFLINE_RENDERER_DIRECTORY_SEGMENTS: ReadonlyArray = Object.freeze(['..', 'renderer']); const OFFLINE_RENDERER_INDEX_NAME = 'index.html'; +const OFFLINE_RENDERER_VERSION_NAME = 'version.json'; +const OFFLINE_RENDERER_VERSION_MAX_BYTES = 4096; const MODULE_SYSTEM_ENV = 'FLUXER_MODULE_SYSTEM'; const MODULE_SYSTEM_DISABLE_ARGUMENT = '--fluxer-no-module-system'; @@ -84,8 +86,22 @@ export function resolveModuleSystemLaunch({ } } +const ASAR_ARCHIVE_SEGMENT = `.asar${path.sep}`; +const ASAR_UNPACKED_SEGMENT = `.asar.unpacked${path.sep}`; + +export function resolveAsarUnpackedPath(target: string): string { + const index = target.lastIndexOf(ASAR_ARCHIVE_SEGMENT); + if (index < 0) { + return target; + } + const unpacked = `${target.slice(0, index)}${ASAR_UNPACKED_SEGMENT}${target.slice(index + ASAR_ARCHIVE_SEGMENT.length)}`; + return fs.existsSync(unpacked) ? unpacked : target; +} + export function getOfflineRendererRoot(mainModuleUrl: string): string { - return path.join(path.dirname(fileURLToPath(mainModuleUrl)), ...OFFLINE_RENDERER_DIRECTORY_SEGMENTS); + return resolveAsarUnpackedPath( + path.join(path.dirname(fileURLToPath(mainModuleUrl)), ...OFFLINE_RENDERER_DIRECTORY_SEGMENTS), + ); } export function hasOfflineRenderer(mainModuleUrl: string): boolean { @@ -98,3 +114,27 @@ export function hasOfflineRenderer(mainModuleUrl: string): boolean { throw error; } } + +export function readBundledRendererVersion(mainModuleUrl: string): string | null { + let raw: string; + try { + const target = path.join(getOfflineRendererRoot(mainModuleUrl), OFFLINE_RENDERER_VERSION_NAME); + if (fs.statSync(target).size > OFFLINE_RENDERER_VERSION_MAX_BYTES) { + return null; + } + raw = fs.readFileSync(target, 'utf8'); + } catch { + return null; + } + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + return null; + } + if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { + return null; + } + const version = (parsed as Record)['version']; + return typeof version === 'string' && version.length > 0 ? version : null; +} diff --git a/fluxer_desktop/src/main/AppImageUpdate.test.mjs b/fluxer_desktop/src/main/AppImageUpdate.test.mjs index cfcc51112..7f7b4bdb5 100644 --- a/fluxer_desktop/src/main/AppImageUpdate.test.mjs +++ b/fluxer_desktop/src/main/AppImageUpdate.test.mjs @@ -41,6 +41,9 @@ function loadAppImageUpdate(env = {}, overrides = {}, requireImpl = require) { require: requireImpl, console, Buffer, + AbortController, + setTimeout, + clearTimeout, fetch, Error, Number, @@ -261,6 +264,29 @@ describe('AppImageUpdate in-place replacement', () => { assert.deepEqual(stagingLeftovers(install.applications), []); }); + test('a download that stops sending data fails instead of hanging and leaves nothing staged', async () => { + const install = createInstall(); + const appImageUpdate = loadAppImageUpdate({APPIMAGE: install.installedPath}); + const resolved = appImageUpdate.resolveAppImageTarget(); + const silent = new ReadableStream({ + start(controller) { + controller.enqueue(NEW_BYTES.subarray(0, 4)); + }, + }); + + await assert.rejects( + appImageUpdate.stageAppImageUpdate({ + fetchImpl: async () => new Response(silent, {headers: {'content-length': String(NEW_BYTES.length)}}), + target: resolved.target, + url: 'https://pkgs.invalid/appimage', + expectedSha256: NEW_SHA256, + stallMs: 30, + }), + /sent no data for 30 ms/, + ); + assert.deepEqual(stagingLeftovers(install.applications), []); + }); + test('refuses to swap when the download does not match the published checksum', async () => { const install = createInstall(); const appImageUpdate = loadAppImageUpdate({APPIMAGE: install.installedPath}); diff --git a/fluxer_desktop/src/main/AppImageUpdate.ts b/fluxer_desktop/src/main/AppImageUpdate.ts index 2a51ce3a3..b3fe4da29 100644 --- a/fluxer_desktop/src/main/AppImageUpdate.ts +++ b/fluxer_desktop/src/main/AppImageUpdate.ts @@ -45,6 +45,7 @@ const APPDIR_ENTRYPOINT = 'AppRun'; const APPIMAGE_MODE = 0o755; const SHA256_PATTERN = /^[0-9a-f]{64}$/; const STAGING_FREE_SPACE_MARGIN_BYTES = 16 * 1024 * 1024; +export const APPIMAGE_DOWNLOAD_STALL_MS = 30_000; const NON_RETRYABLE_STAGING_ERRNOS = new Set([ 'ENOSPC', 'EDQUOT', @@ -167,12 +168,45 @@ export async function stageAppImageUpdate(options: { expectedSha256: string; onProgress?: (progress: AppImageDownloadProgress) => void; fetchImpl: typeof fetch; + stallMs?: number; }): Promise { const expected = options.expectedSha256.trim().toLowerCase(); if (!SHA256_PATTERN.test(expected)) { throw new Error('Update checksum is missing or malformed.'); } - const response = await options.fetchImpl(options.url, {cache: 'no-store', redirect: 'follow'}); + const stallMs = options.stallMs ?? APPIMAGE_DOWNLOAD_STALL_MS; + const stalled = new AbortController(); + let stallTimer: NodeJS.Timeout | null = null; + const stopStallTimer = (): void => { + if (stallTimer != null) clearTimeout(stallTimer); + stallTimer = null; + }; + const restartStallTimer = (): void => { + stopStallTimer(); + stallTimer = setTimeout(() => { + stalled.abort(new Error(`Update download sent no data for ${stallMs} ms.`)); + }, stallMs); + }; + restartStallTimer(); + try { + return await downloadAndStageAppImage(options, expected, stalled.signal, restartStallTimer); + } finally { + stopStallTimer(); + } +} + +async function downloadAndStageAppImage( + options: { + target: AppImageTarget; + url: string; + onProgress?: (progress: AppImageDownloadProgress) => void; + fetchImpl: typeof fetch; + }, + expected: string, + signal: AbortSignal, + onData: () => void, +): Promise { + const response = await options.fetchImpl(options.url, {cache: 'no-store', redirect: 'follow', signal}); if (!response.ok || response.body == null) { throw new Error(`Update download failed: ${response.status}`); } @@ -200,13 +234,21 @@ export async function stageAppImageUpdate(options: { try { const handle = await open(staged.stagedPath, 'wx', APPIMAGE_MODE); const reader = response.body.getReader(); + const cancelOnStall = (): void => { + void reader.cancel(signal.reason).catch(() => {}); + }; + signal.addEventListener('abort', cancelOnStall, {once: true}); let transferred = 0; try { for (;;) { const {done, value} = await reader.read(); + if (signal.aborted) { + throw signal.reason; + } if (done) { break; } + onData(); let written = 0; while (written < value.byteLength) { const result = await handle.write(value, written, value.byteLength - written); @@ -226,6 +268,7 @@ export async function stageAppImageUpdate(options: { await reader.cancel().catch(() => {}); throw error; } finally { + signal.removeEventListener('abort', cancelOnStall); await handle.close(); } const digest = await digestFile(staged.stagedPath); diff --git a/fluxer_desktop/src/main/Bootstrap.ts b/fluxer_desktop/src/main/Bootstrap.ts index 21e6d0534..0378c0605 100644 --- a/fluxer_desktop/src/main/Bootstrap.ts +++ b/fluxer_desktop/src/main/Bootstrap.ts @@ -1,6 +1,9 @@ // SPDX-License-Identifier: AGPL-3.0-or-later +import fs from 'node:fs'; import {createRequire} from 'node:module'; +import path from 'node:path'; +import {BUILD_CHANNEL} from '@electron/common/BuildChannel'; import {WINDOWS_APP_USER_MODEL_ID, WINDOWS_TOAST_ACTIVATOR_CLSID} from '@electron/common/DesktopIdentity'; import {type ModulePollPlan, nextModulePollDelay, resolveModulePollPlan} from '@electron/common/ModulePollPlan'; import { @@ -8,6 +11,7 @@ import { hasOfflineRenderer, ModuleSystemDisableDecision, ModuleSystemLaunchDecision, + readBundledRendererVersion, resolveModuleSystemLaunch, } from '@electron/common/ModuleSystem'; import {checkDesktopUpdateNow} from '@electron/main/DesktopUpdateGate'; @@ -60,11 +64,16 @@ function readDevModulePollInterval(): number | null { } const HAS_OFFLINE_RENDERER = hasOfflineRenderer(import.meta.url); +const BUNDLED_RENDERER_VERSION = HAS_OFFLINE_RENDERER ? readBundledRendererVersion(import.meta.url) : null; -const MODULE_SYSTEM_LAUNCH = resolveModuleSystemLaunch({hasOfflineRenderer: HAS_OFFLINE_RENDERER}); +const CAN_TURN_MODULE_SYSTEM_OFF = HAS_OFFLINE_RENDERER && BUILD_CHANNEL === 'development'; + +const MODULE_SYSTEM_LAUNCH = resolveModuleSystemLaunch({hasOfflineRenderer: CAN_TURN_MODULE_SYSTEM_OFF}); const MODULE_LAUNCH_PERMIT = Symbol('fluxer.desktop.moduleLaunchPermit'); +const PENDING_UPDATE_MARKER_NAME = 'update-pending'; +const IN_PLACE_RELOAD_CONFIRM_TIMEOUT_MS = 45_000; const UPDATE_TAKEOVER_SPLASH_WAIT_MS = 250; const UPDATE_SPLASH_PRELOAD_DELAY_MS = 5000; @@ -190,14 +199,14 @@ async function refuseUnsupportedBuild(reason: string): Promise { async function runModuleBootstrap(): Promise { const {app, clipboard, session, shell} = await import('electron'); - const {BUILD_CHANNEL} = await import('@electron/common/BuildChannel'); - const {createChildLogger} = await import('@electron/common/Logger'); + const {createChildLogger, writeLogFilesUnder} = await import('@electron/common/Logger'); const {loadDesktopConfig} = await import('@electron/common/DesktopConfig'); const {appendWindowsGpuDriverWorkaroundSwitches} = await import('@electron/main/ChromiumRuntime'); const {applyPreReadyChromiumConfiguration} = await import('@electron/main/PreReadyChromium'); const {configureUserDataPath} = await import('@electron/common/UserDataPath'); const {armNativeProbeCache} = await import('@electron/main/NativeProbeCache'); const {isStartMinimizedLaunch} = await import('@electron/main/AutostartLaunch'); + const {isDesktopUpdateRequested} = await import('@electron/main/LaunchOptions'); const {relaunchStableLaunchPath} = await import('@electron/main/LinuxLaunchPath'); const {getDesktopLocalAppProtocol} = await import('@electron/main/LocalAppProtocol'); const { @@ -242,10 +251,13 @@ async function runModuleBootstrap(): Promise { const logger = createChildLogger('Bootstrap'); if (MODULE_SYSTEM_LAUNCH.kind === ModuleSystemLaunchDecision.ENABLED_WITH_IGNORED_DISABLE_REQUEST) { logger.warn( - 'Ignoring the --fluxer-no-module-system override because this build carries no offline renderer, so turning the module system off would leave nothing to render', + 'Ignoring the --fluxer-no-module-system override, only a development build with a bundled renderer may turn the module system off', ); } const userDataConfig = configureUserDataPath(); + if (userDataConfig.portable) { + writeLogFilesUnder(app.getPath('logs')); + } armNativeProbeCache(userDataConfig.base); if (!app.requestSingleInstanceLock()) { @@ -257,14 +269,14 @@ async function runModuleBootstrap(): Promise { const instancePreference = decideModuleSystemDisableRequest( instanceTurnedModulesOff(userDataConfig.base), - HAS_OFFLINE_RENDERER, + CAN_TURN_MODULE_SYSTEM_OFF, ); switch (instancePreference.kind) { case ModuleSystemDisableDecision.NOT_REQUESTED: break; case ModuleSystemDisableDecision.IGNORED_WITHOUT_OFFLINE_RENDERER: logger.warn( - 'Ignoring the instance desktop module preference because this build carries no offline renderer, so turning the module system off would leave nothing to render', + 'Ignoring the instance desktop module preference, only a development build with a bundled renderer may turn the module system off', ); break; case ModuleSystemDisableDecision.HONOURED: @@ -443,14 +455,37 @@ async function runModuleBootstrap(): Promise { }, }); + if (store.recoveredUnreadableState != null) { + logger.warn('The module state was unreadable, set it aside and started from a clean state', { + setAside: store.recoveredUnreadableState, + }); + } if (store.shellVersionChanged) { logger.info('The shell version changed since the last boot, converging the modules before launch'); } + const pendingUpdateMarker = path.join(store.root, PENDING_UPDATE_MARKER_NAME); + const updateWasPending = fs.existsSync(pendingUpdateMarker); + if (updateWasPending) { + logger.info('An update was waiting when the app last quit, installing it before launch'); + } + const recordPendingUpdate = (check: {readonly shellNewer: boolean; readonly modulesChanged: boolean}): void => { + try { + if (check.modulesChanged) { + fs.writeFileSync(pendingUpdateMarker, ''); + } else { + fs.rmSync(pendingUpdateMarker, {force: true}); + } + } catch (error) { + logger.warn('Failed to record whether an update is waiting', error); + } + }; const updater = new ModuleUpdater({ store, shellVersion: app.getVersion(), hasOfflineRenderer: HAS_OFFLINE_RENDERER, - forceStartupUpdate: store.shellVersionChanged, + bundledRendererVersion: BUNDLED_RENDERER_VERSION, + preferUnversionedBundle: app.isPackaged, + forceStartupUpdate: store.shellVersionChanged || updateWasPending || isDesktopUpdateRequested(), selfUpdateShellFirst: shellUpdatePlan.capability === ShellUpdateCapability.SELF_UPDATE ? async (latestVersion, requiredSecurityUpdate) => { @@ -567,9 +602,12 @@ async function runModuleBootstrap(): Promise { let servedModulesUpdate: Promise = Promise.resolve(); const serveModules = (resolveModules: () => Readonly>): Promise => { const update = async (): Promise => { - const modules = resolveModules(); - setCommittedModuleFiles(store.storeRoot, await store.buildModuleIndex(modules)); - servedModules = modules; + const selection = await updater.selectServedModules(resolveModules()); + setCommittedModuleFiles(store.storeRoot, await store.buildModuleIndex(selection.modules)); + servedModules = selection.modules; + logger.info( + `Serving the renderer: source=${selection.renderer.source} version=${selection.renderer.version ?? 'unknown'} bundled=${selection.renderer.bundledVersion ?? 'none'}`, + ); }; const started = servedModulesUpdate.then(update, update); servedModulesUpdate = started.catch(() => undefined); @@ -708,6 +746,44 @@ async function runModuleBootstrap(): Promise { reopenMainWindow(launchAttempt); preloadUpdateSplash(); }, + reloadInPlace: async (launchAttempt) => { + const windows = takeoverWindows ?? (await import('@electron/main/Window')); + takeoverWindows = windows; + let settled = false; + const stopObservingLaunch = observeRendererLaunchConfirmed(() => { + if (settled) return; + settled = true; + stopObservingLaunch(); + clearTimeout(watchdog); + void updater.markLaunchSucceeded(launchAttempt).catch((error: unknown) => { + logger.error('Failed to record a successful in place module update', error); + }); + }); + const watchdog = setTimeout(() => { + if (settled) return; + settled = true; + stopObservingLaunch(); + void (async () => { + const reverted = await updater.revertLaunch(launchAttempt); + if (reverted == null) return; + logger.error('The updated renderer never confirmed it started, going back to the one before it', { + timeoutMs: IN_PLACE_RELOAD_CONFIRM_TIMEOUT_MS, + }); + await refreshModuleRoots(reverted); + if (!(await windows.reloadMainWindowForUpdate())) { + reopenMainWindow(null); + } + })().catch((error: unknown) => { + logger.error('Failed to go back to the renderer before the update', error); + }); + }, IN_PLACE_RELOAD_CONFIRM_TIMEOUT_MS); + watchdog.unref(); + const reloaded = await windows.reloadMainWindowForUpdate(); + logger.info('Reloaded the main window onto the updated renderer', {reloaded}); + if (!reloaded) { + reopenMainWindow(null); + } + }, }; const desktopUpdate = new DesktopUpdateRun({ @@ -722,7 +798,17 @@ async function runModuleBootstrap(): Promise { return launchAttempt; }, takeover: updateTakeover, - publish: publishDesktopUpdateCheck, + publish: (check) => { + recordPendingUpdate(check); + publishDesktopUpdateCheck(check); + }, + reportFailure: (failure) => { + void import('@electron/main/DesktopUpdatePrompt') + .then(({reportDesktopUpdateFailure}) => reportDesktopUpdateFailure(failure)) + .catch((error: unknown) => { + logger.error('Failed to report the desktop update failure', error); + }); + }, openDownloadsPage: async () => { const {DOWNLOAD_PAGE_URL} = await import('@electron/main/UpdaterDownloads'); await shell.openExternal(DOWNLOAD_PAGE_URL); @@ -732,6 +818,9 @@ async function runModuleBootstrap(): Promise { const permit = await runModuleUpdateLoop(); localNetworkHint.disarm(); + if (!permit.unreachable) { + recordPendingUpdate({shellNewer: false, modulesChanged: false}); + } await refreshModuleRoots(permit.launchAttempt.committed); setOnDemandModuleInstaller( createOnDemandModuleInstaller({ @@ -743,7 +832,14 @@ async function runModuleBootstrap(): Promise { }), ); markSplashLaunching(); - armDesktopUpdate({check: () => desktopUpdate.check(), start: () => desktopUpdate.start()}); + armDesktopUpdate({ + check: async () => { + const check = await desktopUpdate.check(); + recordPendingUpdate(check); + return check; + }, + start: () => desktopUpdate.start(), + }); armMainWindowHandoff(updater, permit.launchAttempt, () => { desktopUpdate.markLaunchSettled(); preloadUpdateSplash(); diff --git a/fluxer_desktop/src/main/BootstrapEntry.test.mjs b/fluxer_desktop/src/main/BootstrapEntry.test.mjs index def905c04..495cc347c 100644 --- a/fluxer_desktop/src/main/BootstrapEntry.test.mjs +++ b/fluxer_desktop/src/main/BootstrapEntry.test.mjs @@ -48,10 +48,15 @@ describe('Bootstrap entry point', () => { test('the instance module preference can only be honoured through the offline renderer guard', () => { assert.match( source, - /const instancePreference = decideModuleSystemDisableRequest\(\n\t\tinstanceTurnedModulesOff\(userDataConfig\.base\),\n\t\tHAS_OFFLINE_RENDERER,\n\t\);/, + /const instancePreference = decideModuleSystemDisableRequest\(\n\t\tinstanceTurnedModulesOff\(userDataConfig\.base\),\n\t\tCAN_TURN_MODULE_SYSTEM_OFF,\n\t\);/, 'A server side switch that could turn the module system off on a build with no renderer would brick the install, so it goes through the same guard the launch flag does.', ); assert.match(source, /case ModuleSystemDisableDecision\.HONOURED:\n\t\t\tlogger\.warn\(/); + assert.match( + source, + /const CAN_TURN_MODULE_SYSTEM_OFF = HAS_OFFLINE_RENDERER && BUILD_CHANNEL === 'development';/, + 'Every release shell bundles a renderer now, so the switch and the launch flag would otherwise skip updates, floors and on-demand assets on stable and canary.', + ); }); test('the instance module preference is read from the module store root, never from the app store database', () => { @@ -269,17 +274,6 @@ describe('Bootstrap entry point', () => { ); }); - test('a user check works from the first window, a click only after the renderer confirmed its launch', () => { - const bootstrap = source.slice(source.indexOf('async function runModuleBootstrap()')); - const arm = bootstrap.indexOf( - 'armDesktopUpdate({check: () => desktopUpdate.check(), start: () => desktopUpdate.start()});', - ); - assert.notEqual(arm, -1); - assert.ok(arm < bootstrap.indexOf('await launchMainApp(permit, logger);')); - assert.match(bootstrap, /\.finally\(onLaunchSettled\)/); - assert.match(bootstrap, /desktopUpdate\.markLaunchSettled\(\);/); - }); - test('the update splash hides the app only once it is on screen, in its own theme', () => { const bootstrap = source.slice(source.indexOf('async function runModuleBootstrap()')); assert.match(bootstrap, /openSplashWindow\(\{darkThemeOnShow: true\}\)/); diff --git a/fluxer_desktop/src/main/DeepLinks.ts b/fluxer_desktop/src/main/DeepLinks.ts index 25b53a281..f3a9906f6 100644 --- a/fluxer_desktop/src/main/DeepLinks.ts +++ b/fluxer_desktop/src/main/DeepLinks.ts @@ -1,7 +1,9 @@ // SPDX-License-Identifier: AGPL-3.0-or-later import {APP_PROTOCOL} from '@electron/common/Constants'; +import {updateFromCommandLine} from '@electron/main/DesktopUpdatePrompt'; import {parseJumpListTaskFromArgv} from '@electron/main/JumpList'; +import {isDesktopUpdateRequested} from '@electron/main/LaunchOptions'; import {recordRecentDeepLink} from '@electron/main/RecentDocuments'; import {getMainWindow, isMainWindowTakenOver, onMainWindowTakeoverEnded, showWindow} from '@electron/main/Window'; import {app, ipcMain} from 'electron'; @@ -215,6 +217,10 @@ function isSquirrelOrSyntheticArg(arg: string): boolean { } export function handleSecondInstance(argv: Array): void { + if (isDesktopUpdateRequested(argv)) { + void updateFromCommandLine(); + return; + } const task = parseJumpListTaskFromArgv(argv); if (task) { dispatchJumpListTask(task); diff --git a/fluxer_desktop/src/main/DesktopTray.ts b/fluxer_desktop/src/main/DesktopTray.ts index 12849e122..8a4af75d8 100644 --- a/fluxer_desktop/src/main/DesktopTray.ts +++ b/fluxer_desktop/src/main/DesktopTray.ts @@ -11,6 +11,8 @@ import type { TrayActionPayload, TrayRuntimeStatePayload, } from '@electron/common/Types'; +import {getDesktopUpdateState, observeDesktopUpdateState} from '@electron/main/DesktopUpdateGate'; +import {checkForUpdatesFromShell, startDesktopUpdateFromShell} from '@electron/main/DesktopUpdatePrompt'; import {relaunchStableLaunchPath} from '@electron/main/LinuxLaunchPath'; import {onLocaleChange, t} from '@electron/main/MainI18n'; import {app, type BrowserWindow, clipboard, Menu, nativeImage, Tray} from 'electron'; @@ -72,6 +74,9 @@ export function updateTrayRuntimeState(update: Partial, if (webContentsId !== undefined) { trayActionBridgeWebContentsId = webContentsId; } + if (typeof update.buildInfo === 'string' && update.buildInfo !== trayState.buildInfo) { + logger.info(`The renderer reported its build: ${update.buildInfo}`); + } Object.assign(trayState, update); refreshDesktopTrayMenu(); schedulePendingTrayActionFlush(); @@ -388,9 +393,18 @@ function buildTrayMenu(): Menu { }); } menuTemplate.push({type: 'separator'}); + if (getDesktopUpdateState().available) { + menuTemplate.push({ + label: t('desktop.tray.updateNow', {appName: APP_NAME}), + click: () => runTrayMenuAction(startDesktopUpdateFromShell), + }); + } menuTemplate.push({ label: t('desktop.tray.checkForUpdates'), - click: () => runTrayMenuAction(() => dispatchTrayAction({action: 'check-for-updates'})), + click: () => + runTrayMenuAction(() => { + void checkForUpdatesFromShell(); + }), }); if (trayState.buildInfo) { menuTemplate.push({ @@ -533,6 +547,7 @@ export function initializeDesktopTray(nextController: DesktopTrayController): vo } refreshDesktopTrayMenu(); }); + observeDesktopUpdateState(refreshDesktopTrayMenu); } } diff --git a/fluxer_desktop/src/main/DesktopUpdateGate.test.mjs b/fluxer_desktop/src/main/DesktopUpdateGate.test.mjs index efbf6ea68..527f5a67f 100644 --- a/fluxer_desktop/src/main/DesktopUpdateGate.test.mjs +++ b/fluxer_desktop/src/main/DesktopUpdateGate.test.mjs @@ -25,14 +25,14 @@ function deferred() { describe('DesktopUpdateGate', () => { test('nothing is available and nothing starts before the bootstrap arms the gate', async () => { - assert.deepEqual(getDesktopUpdateState(), {available: false}); - assert.deepEqual(await checkDesktopUpdateNow(), {available: false}); + assert.deepEqual(getDesktopUpdateState(), {available: false, updating: false}); + assert.deepEqual(await checkDesktopUpdateNow(), {available: false, updating: false}); assert.equal(startDesktopUpdate(), false); }); test('a check publishes the update and one click runs it exactly once', async () => { const states = []; - const stop = observeDesktopUpdateState((state) => states.push(state.available)); + const stop = observeDesktopUpdateState((state) => states.push(state)); const running = deferred(); let starts = 0; armDesktopUpdate({ @@ -44,18 +44,30 @@ describe('DesktopUpdateGate', () => { }, }); - assert.deepEqual(await checkDesktopUpdateNow(), {available: true}); + assert.deepEqual(await checkDesktopUpdateNow(), {available: true, updating: false}); assert.equal(startDesktopUpdate(), true); assert.equal(startDesktopUpdate(), false, 'a second click while the update runs is a no-op'); - assert.deepEqual(getDesktopUpdateState(), {available: false}, 'the affordance hides the moment it is clicked'); - assert.deepEqual(await checkDesktopUpdateNow(), {available: false}, 'a poll during the update changes nothing'); + assert.deepEqual( + getDesktopUpdateState(), + {available: false, updating: true}, + 'the affordance hides the moment it is clicked', + ); + assert.deepEqual( + await checkDesktopUpdateNow(), + {available: false, updating: true}, + 'a poll during the update changes nothing', + ); running.resolve(); await new Promise((resolve) => setImmediate(resolve)); assert.equal(starts, 1); - assert.deepEqual(getDesktopUpdateState(), {available: false}); - assert.deepEqual(states, [true, false]); + assert.deepEqual(getDesktopUpdateState(), {available: false, updating: false}); + assert.deepEqual(states, [ + {available: true, updating: false}, + {available: false, updating: true}, + {available: false, updating: false}, + ]); stop(); }); @@ -71,7 +83,7 @@ describe('DesktopUpdateGate', () => { assert.equal(startDesktopUpdate(), true); await new Promise((resolve) => setImmediate(resolve)); - assert.deepEqual(getDesktopUpdateState(), {available: true}); + assert.deepEqual(getDesktopUpdateState(), {available: true, updating: false}); assert.equal(startDesktopUpdate(), true); }); }); diff --git a/fluxer_desktop/src/main/DesktopUpdateGate.ts b/fluxer_desktop/src/main/DesktopUpdateGate.ts index beceefc2c..0f7c3ab79 100644 --- a/fluxer_desktop/src/main/DesktopUpdateGate.ts +++ b/fluxer_desktop/src/main/DesktopUpdateGate.ts @@ -22,6 +22,7 @@ interface DesktopUpdateGateState { lastCheck: DesktopUpdateCheck | null; running: Promise | null; notifiedAvailable: boolean; + notifiedUpdating: boolean; readonly listeners: Set; } @@ -30,6 +31,7 @@ const gateState: DesktopUpdateGateState = { lastCheck: null, running: null, notifiedAvailable: false, + notifiedUpdating: false, listeners: new Set(), }; @@ -38,15 +40,24 @@ function isAvailable(check: DesktopUpdateCheck | null): boolean { } export function getDesktopUpdateState(): DesktopUpdateState { - return {available: gateState.running == null && isAvailable(gateState.lastCheck)}; + return { + available: gateState.running == null && isAvailable(gateState.lastCheck), + updating: gateState.running != null, + }; +} + +export function desktopUpdateReplacesShell(): boolean { + return gateState.lastCheck?.shellNewer === true; } function notify(): void { const state = getDesktopUpdateState(); - if (state.available === gateState.notifiedAvailable) { + const updating = state.updating === true; + if (state.available === gateState.notifiedAvailable && updating === gateState.notifiedUpdating) { return; } gateState.notifiedAvailable = state.available; + gateState.notifiedUpdating = updating; for (const listener of Array.from(gateState.listeners)) { try { listener(state); diff --git a/fluxer_desktop/src/main/DesktopUpdatePrompt.ts b/fluxer_desktop/src/main/DesktopUpdatePrompt.ts new file mode 100644 index 000000000..dd0f3db6b --- /dev/null +++ b/fluxer_desktop/src/main/DesktopUpdatePrompt.ts @@ -0,0 +1,172 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import {DESKTOP_APP_NAME} from '@electron/common/DesktopIdentity'; +import {createChildLogger} from '@electron/common/Logger'; +import { + checkDesktopUpdateNow, + desktopUpdateReplacesShell, + getDesktopUpdateState, + startDesktopUpdate, +} from '@electron/main/DesktopUpdateGate'; +import {t} from '@electron/main/MainI18n'; +import {openExternalDeduped} from '@electron/main/OpenExternal'; +import {findNewerManualShell} from '@electron/main/Updater'; +import {DOWNLOAD_PAGE_URL} from '@electron/main/UpdaterDownloads'; +import {app, BrowserWindow, dialog, type MessageBoxOptions} from 'electron'; + +const logger = createChildLogger('DesktopUpdatePrompt'); + +export interface DesktopUpdateFailure { + readonly reason: string; + readonly detail: string | null; +} + +const UpdatePromptChoice = Object.freeze({ + PRIMARY: 0, + SECONDARY: 1, +} as const); + +let checking: Promise | null = null; +let failurePrompt: Promise | null = null; + +async function ask(options: MessageBoxOptions): Promise { + const parent = BrowserWindow.getFocusedWindow(); + const usable = parent != null && !parent.isDestroyed() && parent.isVisible() ? parent : null; + const result = usable == null ? await dialog.showMessageBox(options) : await dialog.showMessageBox(usable, options); + return result.response; +} + +function describeError(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +async function runUpdateCheck(): Promise { + for (;;) { + let manualVersion: string | null = null; + try { + const state = await checkDesktopUpdateNow(); + if (state.updating === true) { + logger.info('A desktop update is already running, leaving it to finish'); + return; + } + if (state.available) { + await offerDesktopUpdate(); + return; + } + manualVersion = (await findNewerManualShell())?.version ?? null; + } catch (error) { + logger.warn('The update check from a native menu failed', error); + const retry = await ask({ + type: 'warning', + message: t('desktop.update.checkFailedMessage'), + detail: `${t('desktop.update.checkFailedDetail')}\n\n${describeError(error)}`, + buttons: [t('desktop.appLoad.retry'), t('desktop.update.later')], + defaultId: UpdatePromptChoice.PRIMARY, + cancelId: UpdatePromptChoice.SECONDARY, + }); + if (retry === UpdatePromptChoice.PRIMARY) { + continue; + } + return; + } + if (manualVersion != null) { + const download = await ask({ + type: 'info', + message: t('desktop.update.manualMessage', {version: manualVersion}), + detail: t('desktop.update.manualDetail'), + buttons: [t('desktop.update.download'), t('desktop.update.later')], + defaultId: UpdatePromptChoice.PRIMARY, + cancelId: UpdatePromptChoice.SECONDARY, + }); + if (download === UpdatePromptChoice.PRIMARY) { + await openExternalDeduped(DOWNLOAD_PAGE_URL); + } + return; + } + await ask({ + type: 'info', + message: t('desktop.update.upToDateMessage'), + detail: t('desktop.update.upToDateDetail', {version: app.getVersion()}), + buttons: [t('desktop.update.ok')], + defaultId: UpdatePromptChoice.PRIMARY, + }); + return; + } +} + +async function offerDesktopUpdate(): Promise { + const install = await ask({ + type: 'info', + message: t('desktop.update.availableMessage'), + detail: t(desktopUpdateReplacesShell() ? 'desktop.update.availableDetail' : 'desktop.update.reloadDetail'), + buttons: [t('desktop.update.install'), t('desktop.update.later')], + defaultId: UpdatePromptChoice.PRIMARY, + cancelId: UpdatePromptChoice.SECONDARY, + }); + if (install === UpdatePromptChoice.PRIMARY) { + startDesktopUpdateFromShell(); + } +} + +export function checkForUpdatesFromShell(): Promise { + checking ??= runUpdateCheck() + .catch((error: unknown) => { + logger.error('The native update prompt failed', error); + }) + .finally(() => { + checking = null; + }); + return checking; +} + +export function startDesktopUpdateFromShell(): void { + if (startDesktopUpdate()) { + logger.info('Started the desktop update from a native menu'); + return; + } + if (getDesktopUpdateState().available) { + return; + } + void checkForUpdatesFromShell(); +} + +export async function updateFromCommandLine(): Promise { + try { + const state = await checkDesktopUpdateNow(); + if (!state.available) { + logger.info('The command line asked for an update and none is waiting'); + return; + } + logger.info('The command line asked for an update, starting it', {started: startDesktopUpdate()}); + } catch (error) { + logger.warn('The command line asked for an update and the check failed', error); + } +} + +export function reportDesktopUpdateFailure(failure: DesktopUpdateFailure): Promise { + failurePrompt ??= (async () => { + logger.warn('Telling the user the desktop update did not finish', failure); + const stillDownloading = failure.reason === 'timed-out'; + const choice = await ask({ + type: 'warning', + title: DESKTOP_APP_NAME, + message: t('desktop.update.failedMessage'), + detail: stillDownloading ? t('desktop.update.stillDownloadingDetail') : t('desktop.update.failedDetail'), + buttons: [t('desktop.appLoad.retry'), t('desktop.update.download'), t('desktop.update.later')], + defaultId: 0, + cancelId: 2, + }); + if (choice === 0) { + startDesktopUpdateFromShell(); + } else if (choice === 1) { + await openExternalDeduped(DOWNLOAD_PAGE_URL); + } + })() + .catch((error: unknown) => { + logger.error('The update failure prompt failed', error); + }) + .finally(() => { + failurePrompt = null; + }); + return failurePrompt; +} diff --git a/fluxer_desktop/src/main/DesktopUpdateRun.test.mjs b/fluxer_desktop/src/main/DesktopUpdateRun.test.mjs index c23776558..5924b1cc2 100644 --- a/fluxer_desktop/src/main/DesktopUpdateRun.test.mjs +++ b/fluxer_desktop/src/main/DesktopUpdateRun.test.mjs @@ -19,6 +19,7 @@ function harness({ } = {}) { const steps = []; const published = []; + const failures = []; let probeIndex = 0; let shellIndex = 0; const quiet = () => undefined; @@ -55,6 +56,9 @@ function harness({ reopen: (launchAttempt) => { steps.push(launchAttempt == null ? 'reopen' : 'reopen-updated'); }, + reloadInPlace: async () => { + steps.push('reload-in-place'); + }, }, publish: (check) => { published.push(check); @@ -62,10 +66,13 @@ function harness({ openDownloadsPage: async () => { steps.push('downloads-page'); }, + reportFailure: (failure) => { + failures.push(failure); + }, logger: {info: quiet, warn: quiet, error: quiet}, }); if (settled) run.markLaunchSettled(); - return {run, steps, published}; + return {run, steps, published, failures}; } describe('DesktopUpdateRun', () => { @@ -195,3 +202,119 @@ describe('DesktopUpdateRun', () => { assert.deepEqual(steps, []); }); }); + +describe('DesktopUpdateRun telling the user when a click did not update', () => { + const SHELL_AND_MODULES = {shellLatestVersion: '2026.1008.2', shellNewer: true, modulesChanged: true}; + + test('an update that installs reports nothing', async () => { + const {run, failures} = harness(); + + await run.start(); + + assert.deepEqual(failures, []); + }); + + test('a check that fails gives the windows back and says so', async () => { + const {run, steps, failures} = harness({probes: [new Error('offline')]}); + + await run.start(); + + assert.deepEqual(steps, ['probe', 'takeover', 'restore']); + assert.deepEqual(failures, [{reason: 'check-failed', detail: 'offline'}]); + }); + + test('a shell update that does not finish is reported once the app is back', async () => { + const {run, steps, failures} = harness({ + probes: [SHELL_AND_MODULES], + shellResults: [{reason: 'timed-out', detail: 'the update did not finish'}], + }); + + await run.start(); + + assert.equal(steps.at(-1), 'reopen-updated'); + assert.deepEqual(failures, [{reason: 'timed-out', detail: 'the update did not finish'}]); + }); + + test('a feed that has no newer shell after all is not a failure', async () => { + const {run, failures} = harness({ + probes: [SHELL_AND_MODULES], + shellResults: [{reason: 'no-update', detail: null}], + }); + + await run.start(); + + assert.deepEqual(failures, []); + }); + + test('a module install that throws is reported with its cause', async () => { + const {run, steps, failures} = harness({installFails: true}); + + await run.start(); + + assert.equal(steps.at(-1), 'reopen'); + assert.deepEqual(failures, [{reason: 'download-failed', detail: 'disk full'}]); + }); +}); + +describe('DesktopUpdateRun installing a renderer only update in place', () => { + const MODULES_ONLY = {shellLatestVersion: '2026.1008.2', shellNewer: false, modulesChanged: true}; + const SHELL_TOO = {shellLatestVersion: '2026.1008.3', shellNewer: true, modulesChanged: true}; + + test('a click after the background check found only modules never shows the splash or closes a window', async () => { + const {run, steps, published, failures} = harness({probes: [MODULES_ONLY]}); + await run.check(); + + await run.start(); + + assert.deepEqual(steps, ['probe', 'probe', 'install', 'reload-in-place']); + assert.deepEqual(published, [{shellNewer: false, modulesChanged: false}]); + assert.deepEqual(failures, []); + }); + + test('a shell that turned newer since the background check still updates behind the splash', async () => { + const {run, steps} = harness({ + probes: [MODULES_ONLY, SHELL_TOO], + shellResults: [{reason: 'no-update', detail: null}], + }); + await run.check(); + + await run.start(); + + assert.deepEqual(steps, ['probe', 'probe', 'takeover', 'close', 'shell-update', 'install', 'reopen-updated']); + }); + + test('a download that fails leaves the running app alone and says so', async () => { + const {run, steps, published, failures} = harness({probes: [MODULES_ONLY], installFails: true}); + await run.check(); + + await run.start(); + + assert.deepEqual(steps, ['probe', 'probe', 'install']); + assert.deepEqual(published, [{shellNewer: false, modulesChanged: true}]); + assert.deepEqual(failures, [{reason: 'download-failed', detail: 'disk full'}]); + }); + + test('a click while offline changes nothing on screen', async () => { + const {run, steps, failures} = harness({probes: [MODULES_ONLY, new Error('offline')]}); + await run.check(); + + await run.start(); + + assert.deepEqual(steps, ['probe', 'probe']); + assert.deepEqual(failures, [{reason: 'check-failed', detail: 'offline'}]); + }); + + test('a check works from the first window, a click only after the renderer confirmed its launch', async () => { + const {run, steps} = harness({probes: [MODULES_ONLY], settled: false}); + + assert.deepEqual(await run.check(), {shellNewer: false, modulesChanged: true}); + await run.start(); + + assert.deepEqual(steps, ['probe']); + + run.markLaunchSettled(); + await run.start(); + + assert.deepEqual(steps, ['probe', 'probe', 'install', 'reload-in-place']); + }); +}); diff --git a/fluxer_desktop/src/main/DesktopUpdateRun.ts b/fluxer_desktop/src/main/DesktopUpdateRun.ts index 394ff5ef3..75fccffd5 100644 --- a/fluxer_desktop/src/main/DesktopUpdateRun.ts +++ b/fluxer_desktop/src/main/DesktopUpdateRun.ts @@ -19,6 +19,7 @@ export interface DesktopUpdateTakeover { readonly restore: () => void; readonly closeApp: () => Promise; readonly reopen: (launchAttempt: ModuleLaunchAttempt | null) => void; + readonly reloadInPlace: (launchAttempt: ModuleLaunchAttempt) => Promise; } interface DesktopUpdateLogger { @@ -35,6 +36,7 @@ interface DesktopUpdateRunOptions { readonly takeover: DesktopUpdateTakeover; readonly publish: (check: DesktopUpdateCheck) => void; readonly openDownloadsPage: () => Promise; + readonly reportFailure?: (failure: ShellSelfUpdateResult) => void; readonly logger: DesktopUpdateLogger; readonly now?: () => number; } @@ -44,12 +46,17 @@ interface FilteredProbe { readonly shellLatestVersion: string; } +function describeFailure(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + export class DesktopUpdateRun { private readonly options: DesktopUpdateRunOptions; private readonly now: () => number; private launchSettled = false; private shellFeedBehindVersion: string | null = null; private shellInstallFailed = false; + private lastShellFailure: ShellSelfUpdateResult | null = null; private lastCheck: DesktopUpdateCheck | null = null; public constructor(options: DesktopUpdateRunOptions) { @@ -66,7 +73,7 @@ export class DesktopUpdateRun { } public async start(): Promise { - const {logger, takeover} = this.options; + const {logger} = this.options; const startedAt = this.now(); if (!this.launchSettled) { logger.warn('Ignoring a desktop update start before the renderer confirmed its launch'); @@ -77,9 +84,58 @@ export class DesktopUpdateRun { await this.options.openDownloadsPage(); return; } + if (this.lastCheck != null && !this.lastCheck.shellNewer && this.lastCheck.modulesChanged) { + await this.startInPlace(startedAt); + return; + } logger.info('Starting the desktop update'); const probing = this.probe(); probing.catch(() => {}); + await this.startWithTakeover(startedAt, probing); + } + + private async startInPlace(startedAt: number): Promise { + const {logger, takeover} = this.options; + logger.info('Starting the module update in place, the shell stays as it is'); + let probe: FilteredProbe; + try { + probe = await this.probe(); + } catch (error) { + logger.warn('The desktop update check failed, leaving the app as it is', error); + this.options.reportFailure?.({reason: 'check-failed', detail: describeFailure(error)}); + return; + } + const {check} = probe; + if (check.shellNewer) { + logger.info('A newer shell appeared since the last check, updating it behind the splash'); + await this.startWithTakeover(startedAt, Promise.resolve(probe)); + return; + } + if (!check.modulesChanged) { + this.publish(check); + return; + } + let launchAttempt: ModuleLaunchAttempt | null; + try { + launchAttempt = await this.options.installModules(); + } catch (error) { + logger.error('The module update failed, the app keeps running on the installed modules', error); + this.publish(check); + this.options.reportFailure?.({reason: 'download-failed', detail: describeFailure(error)}); + return; + } + this.publish({shellNewer: false, modulesChanged: false}); + logger.info('Installed the module update in place', { + activated: launchAttempt != null, + elapsedMs: this.now() - startedAt, + }); + if (launchAttempt != null) { + await takeover.reloadInPlace(launchAttempt); + } + } + + private async startWithTakeover(startedAt: number, probing: Promise): Promise { + const {logger, takeover} = this.options; await takeover.begin(); logger.info('The update splash took the app windows over', {elapsedMs: this.now() - startedAt}); let probe: FilteredProbe; @@ -88,6 +144,7 @@ export class DesktopUpdateRun { } catch (error) { logger.warn('The desktop update check failed, giving the app windows back', error); takeover.restore(); + this.options.reportFailure?.({reason: 'check-failed', detail: describeFailure(error)}); return; } const {check} = probe; @@ -101,9 +158,11 @@ export class DesktopUpdateRun { logger.info('Closed the app windows for the update', {elapsedMs: this.now() - startedAt}); let launchAttempt: ModuleLaunchAttempt | null = null; let remaining = check; + let failure: ShellSelfUpdateResult | null = null; try { if (check.shellNewer) { remaining = await this.updateShell(probe); + failure = this.lastShellFailure; } if (check.modulesChanged) { launchAttempt = await this.options.installModules(); @@ -115,16 +174,21 @@ export class DesktopUpdateRun { } } catch (error) { logger.error('The desktop update failed, reopening the app on the installed modules', error); + failure = {reason: 'download-failed', detail: describeFailure(error)}; } finally { this.publish(remaining); takeover.reopen(launchAttempt); } + if (failure != null) { + this.options.reportFailure?.(failure); + } } private async updateShell(probe: FilteredProbe): Promise { const {logger} = this.options; const result = await this.options.runShellSelfUpdate(); logger.warn('The shell self update did not restart the app', result); + this.lastShellFailure = result.reason === 'no-update' ? null : result; if (result.reason === 'no-update') { this.shellFeedBehindVersion = probe.shellLatestVersion; return {shellNewer: false, modulesChanged: probe.check.modulesChanged}; diff --git a/fluxer_desktop/src/main/LaunchOptions.ts b/fluxer_desktop/src/main/LaunchOptions.ts index 4876bd737..db65dc6d1 100644 --- a/fluxer_desktop/src/main/LaunchOptions.ts +++ b/fluxer_desktop/src/main/LaunchOptions.ts @@ -23,6 +23,7 @@ const DISABLE_SPELLCHECK_ARGS = new Set(['--fluxer-disable-spellcheck', '--fluxe const SPELLCHECK_MODE_ARGS = new Set(['--fluxer-spellcheck']); const DISABLE_V8_CODE_CACHE_ARGS = new Set(['--fluxer-disable-v8-code-cache']); const DISABLE_NATIVE_GATEWAY_ARGS = new Set(['--fluxer-disable-native-gateway']); +const DESKTOP_UPDATE_ARGS = new Set(['--fluxer-update']); function hasFlag(argv: ReadonlyArray, flags: ReadonlySet): boolean { return argv.some((arg) => flags.has(arg) || [...flags].some((flag) => arg.startsWith(`${flag}=`))); @@ -160,3 +161,7 @@ export function describeLaunchDiagnosticOptions(argv: ReadonlyArray = pr nativeGatewayDisableReason: getNativeGatewayDisableReason(argv), }; } + +export function isDesktopUpdateRequested(argv: ReadonlyArray = process.argv): boolean { + return hasFlag(argv, DESKTOP_UPDATE_ARGS); +} diff --git a/fluxer_desktop/src/main/LocalAppFileResolver.ts b/fluxer_desktop/src/main/LocalAppFileResolver.ts index 33b3b73f8..e64986458 100644 --- a/fluxer_desktop/src/main/LocalAppFileResolver.ts +++ b/fluxer_desktop/src/main/LocalAppFileResolver.ts @@ -4,6 +4,7 @@ import {Buffer} from 'node:buffer'; import fs from 'node:fs'; import path from 'node:path'; import {Readable} from 'node:stream'; +import {resolveAsarUnpackedPath} from '@electron/common/ModuleSystem'; import {isServedLocalAppExtension, localAppCacheControl, localAppContentType} from '@electron/main/LocalAppMime'; import {isLocalAppURL} from '@electron/main/LocalAppURL'; import {app} from 'electron'; @@ -429,7 +430,10 @@ export class DesktopLocalAppFiles { } private rendererRootPath(): string { - return this.configuredRendererRoot ?? path.join(app.getAppPath(), ...RENDERER_DIRECTORY_SEGMENTS); + return ( + this.configuredRendererRoot ?? + resolveAsarUnpackedPath(path.join(app.getAppPath(), ...RENDERER_DIRECTORY_SEGMENTS)) + ); } private resolveRendererRoot(): Promise { diff --git a/fluxer_desktop/src/main/MainI18n.ts b/fluxer_desktop/src/main/MainI18n.ts index bf8a23756..717239cd6 100644 --- a/fluxer_desktop/src/main/MainI18n.ts +++ b/fluxer_desktop/src/main/MainI18n.ts @@ -69,6 +69,24 @@ const FALLBACK_STRINGS: Readonly> = Object.freeze({ 'desktop.tray.disconnectFrom': 'Disconnect from {channel}', 'desktop.tray.disconnectVoice': 'Disconnect from voice', 'desktop.tray.checkForUpdates': 'Check for updates', + 'desktop.tray.updateNow': 'Update {appName} now', + 'desktop.appMenu.checkForUpdates': 'Check for updates...', + 'desktop.update.availableMessage': 'An update for {appName} is available.', + 'desktop.update.availableDetail': '{appName} will close, install the update and open again.', + 'desktop.update.reloadDetail': '{appName} will reload to finish updating.', + 'desktop.update.install': 'Update now', + 'desktop.update.later': 'Later', + 'desktop.update.ok': 'OK', + 'desktop.update.download': 'Download', + 'desktop.update.manualMessage': '{appName} {version} is available.', + 'desktop.update.manualDetail': 'Download the new version to update {appName}.', + 'desktop.update.upToDateMessage': '{appName} is up to date.', + 'desktop.update.upToDateDetail': 'You have version {version}.', + 'desktop.update.checkFailedMessage': "{appName} couldn't check for updates.", + 'desktop.update.checkFailedDetail': 'Check your internet connection and try again.', + 'desktop.update.failedMessage': "{appName} couldn't finish updating.", + 'desktop.update.failedDetail': 'The update did not download or install. Try again or download it yourself.', + 'desktop.update.stillDownloadingDetail': 'The update is still downloading. Try again in a few minutes.', 'desktop.tray.copyBuildInfo': 'Copy build info', 'desktop.tray.restart': 'Restart {appName}', 'desktop.tray.quit': 'Quit {appName}', diff --git a/fluxer_desktop/src/main/Menu.ts b/fluxer_desktop/src/main/Menu.ts index ad65e3dae..8f03307f6 100644 --- a/fluxer_desktop/src/main/Menu.ts +++ b/fluxer_desktop/src/main/Menu.ts @@ -1,6 +1,7 @@ // SPDX-License-Identifier: AGPL-3.0-or-later import {DESKTOP_APP_NAME} from '@electron/common/DesktopIdentity'; +import {checkForUpdatesFromShell} from '@electron/main/DesktopUpdatePrompt'; import {onLocaleChange, t} from '@electron/main/MainI18n'; import {openExternalDeduped} from '@electron/main/OpenExternal'; import {buildTroubleshootingMenuItems} from '@electron/main/Troubleshooting'; @@ -9,6 +10,15 @@ import {type BaseWindow, BrowserWindow, Menu, type MenuItem, type MenuItemConstr const MACOS_HELP_MENU_TITLE_AUTODETECT_OPT_OUT = '\u200C'; +function checkForUpdatesItem(): MenuItemConstructorOptions { + return { + label: t('desktop.appMenu.checkForUpdates'), + click: () => { + void checkForUpdatesFromShell(); + }, + }; +} + function buildTemplate(): Array { const appName = DESKTOP_APP_NAME; const isMac = process.platform === 'darwin'; @@ -21,6 +31,7 @@ function buildTemplate(): Array { role: 'about', label: t('desktop.appMenu.about', {appName}), }, + checkForUpdatesItem(), {type: 'separator'}, { label: t('desktop.appMenu.preferences'), @@ -198,6 +209,7 @@ function buildTemplate(): Array { ? `${t('desktop.appMenu.help')}${MACOS_HELP_MENU_TITLE_AUTODETECT_OPT_OUT}` : t('desktop.appMenu.help'), submenu: [ + ...(isMac ? [] : [checkForUpdatesItem(), {type: 'separator' as const}]), { label: t('desktop.appMenu.website'), click: async () => { diff --git a/fluxer_desktop/src/main/ModuleStore.test.mjs b/fluxer_desktop/src/main/ModuleStore.test.mjs index f358a71c1..456a217ec 100644 --- a/fluxer_desktop/src/main/ModuleStore.test.mjs +++ b/fluxer_desktop/src/main/ModuleStore.test.mjs @@ -1227,3 +1227,103 @@ describe('ModuleStore boot checks without rehashing', () => { assert.equal(await store.isInstalled('fluxer_renderer', RENDERER_V1.sha256), false); }); }); + +describe('ModuleStore beside a renderer bundled in the shell', () => { + test('an unreadable state file is set aside and the store starts clean', async () => { + const userDataPath = createUserData(); + const store = await openStore(userDataPath); + const statePath = path.join(getModuleStoreRoot(userDataPath), MODULE_STATE_FILE_NAME); + for (const contents of ['{not json', JSON.stringify({state_version: 1})]) { + writeFileSync(statePath, contents); + + const recovered = await openStore(userDataPath); + + assert.equal(recovered.recoveredUnreadableState, `${statePath}.unreadable`); + assert.equal(readFileSync(`${statePath}.unreadable`, 'utf8'), contents); + assert.deepEqual(recovered.getCommitted(), {}); + assert.equal(readState(userDataPath).state_version, MODULE_STATE_VERSION); + } + assert.equal(store.recoveredUnreadableState, null); + }); + + test('retiring a module drops it from the committed and previous sets so it can be collected', async () => { + const userDataPath = createUserData(); + const store = await openStore(userDataPath); + const first = await install(store, RENDERER_V1); + const second = await install(store, RENDERER_V2); + await install(store, GRAMMARS_V1); + await store.commit({fluxer_renderer: RENDERER_V1.sha256, fluxer_grammars: GRAMMARS_V1.sha256}); + await store.commit({fluxer_renderer: RENDERER_V2.sha256, fluxer_grammars: GRAMMARS_V1.sha256}); + + assert.equal(await store.retireModule('fluxer_renderer'), true); + assert.equal(await store.retireModule('fluxer_renderer'), false); + + assert.deepEqual(readState(userDataPath).committed, {fluxer_grammars: GRAMMARS_V1.sha256}); + assert.deepEqual(readState(userDataPath).previous, {fluxer_grammars: GRAMMARS_V1.sha256}); + await markLaunched(store); + await store.collectGarbage(); + assert.equal(existsSync(first.directory), false); + assert.equal(existsSync(second.directory), false); + assert.equal(await store.isInstalled('fluxer_grammars', GRAMMARS_V1.sha256), true); + }); + + test('two failed boots of a first renderer module fall back to the bundle instead of looping', async () => { + const userDataPath = createUserData(); + const store = await openStore(userDataPath); + await install(store, RENDERER_V1); + await install(store, GRAMMARS_V1); + await store.commit({fluxer_grammars: GRAMMARS_V1.sha256}); + await store.activateMergedForRendererReload({fluxer_renderer: RENDERER_V1.sha256}); + await store.commit({fluxer_renderer: RENDERER_V1.sha256, fluxer_grammars: GRAMMARS_V1.sha256}); + const state = readState(userDataPath); + writeFileSync( + path.join(getModuleStoreRoot(userDataPath), MODULE_STATE_FILE_NAME), + JSON.stringify({...state, previous: {}, boot_attempt: 2}), + ); + const reopened = await openStore(userDataPath); + + const withoutBundle = await reopened.beginBootAttempt(); + assert.equal(withoutBundle.rolledBack, false); + writeFileSync( + path.join(getModuleStoreRoot(userDataPath), MODULE_STATE_FILE_NAME), + JSON.stringify({...readState(userDataPath), boot_attempt: 2}), + ); + const bundled = await openStore(userDataPath); + + const attempt = await bundled.beginBootAttempt({bundled: ['fluxer_renderer']}); + + assert.equal(attempt.rolledBack, true); + assert.deepEqual(attempt.committed, {fluxer_grammars: GRAMMARS_V1.sha256}); + assert.equal(bundled.isRejected('fluxer_renderer', RENDERER_V1.sha256), true); + assert.equal(readState(userDataPath).boot_attempt, 0); + }); + + test('a renderer update that never confirms is reverted and rejected', async () => { + const userDataPath = createUserData(); + const store = await openStore(userDataPath); + await install(store, RENDERER_V1); + await install(store, RENDERER_V2); + await install(store, GRAMMARS_V1); + await store.commit({fluxer_renderer: RENDERER_V1.sha256}); + const attempt = await store.activateMergedForRendererReload({ + fluxer_renderer: RENDERER_V2.sha256, + fluxer_grammars: GRAMMARS_V1.sha256, + }); + + assert.deepEqual(await store.revertLaunchAttempt(attempt, ['fluxer_renderer']), { + fluxer_renderer: RENDERER_V1.sha256, + fluxer_grammars: GRAMMARS_V1.sha256, + }); + assert.equal(store.isRejected('fluxer_renderer', RENDERER_V2.sha256), true); + assert.equal(store.isRejected('fluxer_grammars', GRAMMARS_V1.sha256), false); + assert.equal(await store.revertLaunchAttempt(attempt, ['fluxer_renderer']), null); + + const first = await store.activateMergedForRendererReload({fluxer_renderer: RENDERER_V2.sha256}); + await store.revertLaunchAttempt(first, ['fluxer_renderer']); + await store.retireModule('fluxer_renderer'); + const onBundle = await store.activateMergedForRendererReload({fluxer_renderer: RENDERER_V1.sha256}); + assert.deepEqual(await store.revertLaunchAttempt(onBundle, ['fluxer_renderer']), { + fluxer_grammars: GRAMMARS_V1.sha256, + }); + }); +}); diff --git a/fluxer_desktop/src/main/ModuleStore.ts b/fluxer_desktop/src/main/ModuleStore.ts index 36309591b..b07868cb5 100644 --- a/fluxer_desktop/src/main/ModuleStore.ts +++ b/fluxer_desktop/src/main/ModuleStore.ts @@ -21,6 +21,7 @@ import {isDesktopModuleName} from '@fluxer/desktop_ipc/src/ModuleContract'; const MODULE_STORE_DIRECTORY_NAME = 'modules'; export const MODULE_STATE_FILE_NAME = 'state.json'; export const MODULE_STATE_VERSION = 3; +const MODULE_UNREADABLE_STATE_SUFFIX = '.unreadable'; const MODULE_DOWNLOAD_DIRECTORY_NAME = 'download'; const MODULE_DOWNLOAD_INCOMING_DIRECTORY_NAME = 'incoming'; const MODULE_STORE_TREE_DIRECTORY_NAME = 'store'; @@ -1179,6 +1180,7 @@ export class ModuleStore { public readonly storeRoot: string; private state: ModuleStoreState; private convergingShellVersion: string | null = null; + private unreadableStatePath: string | null = null; private launchSucceeded = false; private activeLaunchAttempt: ModuleLaunchAttempt | null = null; private readonly installCoordinator = new ModuleInstallCoordinator(); @@ -1211,9 +1213,26 @@ export class ModuleStore { await createDirectory(resolved); await createDirectory(path.join(resolved, MODULE_DOWNLOAD_DIRECTORY_NAME, MODULE_DOWNLOAD_INCOMING_DIRECTORY_NAME)); await createDirectory(path.join(resolved, MODULE_STORE_TREE_DIRECTORY_NAME)); - const stateFile = await ModuleStore.readState(path.join(resolved, MODULE_STATE_FILE_NAME)); + const statePath = path.join(resolved, MODULE_STATE_FILE_NAME); + let stateFile: ModuleStoreStateFile; + let unreadableState: string | null = null; + try { + stateFile = await ModuleStore.readState(statePath); + } catch (error) { + if ( + !(error instanceof ModuleStoreStateCorruptError) && + !(error instanceof ModuleStoreStateUnsupportedVersionError) + ) { + throw error; + } + unreadableState = `${statePath}${MODULE_UNREADABLE_STATE_SUFFIX}`; + await removeTree(unreadableState); + await renameWithRetry(statePath, unreadableState); + stateFile = {status: ModuleStoreStateFileStatus.MISSING}; + } if (stateFile.status === ModuleStoreStateFileStatus.MISSING) { const store = new ModuleStore(resolved, createInitialModuleStoreState(shellVersion, releaseChannel)); + store.unreadableStatePath = unreadableState; await store.writeState(store.state); return store; } @@ -1269,6 +1288,10 @@ export class ModuleStore { } } + public get recoveredUnreadableState(): string | null { + return this.unreadableStatePath; + } + public get shellVersionChanged(): boolean { return this.convergingShellVersion != null; } @@ -1500,6 +1523,21 @@ export class ModuleStore { }); } + public async retireModule(moduleName: string): Promise { + assertModuleName(moduleName); + return await this.withStateLock(async () => { + if (this.state.committed[moduleName] === undefined && this.state.previous[moduleName] === undefined) { + return false; + } + const committed = {...this.state.committed}; + const previous = {...this.state.previous}; + delete committed[moduleName]; + delete previous[moduleName]; + await this.writeState({...this.state, committed, previous}); + return true; + }); + } + public async mergeCommitted(entries: Readonly>): Promise { return await this.withStateLock(async () => { const added = await this.resolveInstalled(entries); @@ -1588,13 +1626,20 @@ export class ModuleStore { }); } - public async beginBootAttempt(): Promise { + public async beginBootAttempt({ + bundled = [], + }: { + readonly bundled?: ReadonlyArray; + } = {}): Promise { return await this.withStateLock(async () => { if (this.state.boot_attempt < MODULE_BOOT_ATTEMPT_ROLLBACK_THRESHOLD) { return {rolledBack: false, bootAttempt: this.state.boot_attempt, committed: this.state.committed}; } - const revertedCommitted = + const previousCommitted = Object.keys(this.state.previous).length === 0 ? this.state.committed : sortModuleMap(this.state.previous); + const revertedCommitted = sameModuleMap(this.state.committed, previousCommitted) + ? Object.fromEntries(Object.entries(previousCommitted).filter(([moduleName]) => !bundled.includes(moduleName))) + : previousCommitted; const rolledBack = !sameModuleMap(this.state.committed, revertedCommitted); const rejected: Record = {...this.state.rejected}; if (rolledBack) { @@ -1607,6 +1652,7 @@ export class ModuleStore { const reverted = await this.writeState({ ...this.state, committed: revertedCommitted, + previous: rolledBack ? revertedCommitted : this.state.previous, rejected, boot_attempt: 0, }); @@ -1614,6 +1660,41 @@ export class ModuleStore { }); } + public async revertLaunchAttempt( + attempt: ModuleLaunchAttempt, + bundled: ReadonlyArray = [], + ): Promise> | null> { + return await this.withStateLock(async () => { + if (this.activeLaunchAttempt !== attempt) { + return null; + } + const rejected: Record = {...this.state.rejected}; + const committed: Record = {}; + for (const [moduleName, sha256] of Object.entries(this.state.previous)) { + if (await this.isInstalled(moduleName, sha256)) { + committed[moduleName] = sha256; + } + } + for (const [moduleName, sha256] of Object.entries(this.state.committed)) { + if (committed[moduleName] === sha256) continue; + rejected[moduleName] = sha256; + if (committed[moduleName] === undefined && !bundled.includes(moduleName)) { + committed[moduleName] = sha256; + delete rejected[moduleName]; + } + } + this.activeLaunchAttempt = null; + const reverted = await this.writeState({ + ...this.state, + committed, + previous: committed, + rejected, + boot_attempt: 0, + }); + return reverted.committed; + }); + } + public async recordLaunchAttempt(): Promise { return await this.withStateLock(async () => { const next = await this.writeState({...this.state, boot_attempt: this.state.boot_attempt + 1}); diff --git a/fluxer_desktop/src/main/ModuleUpdatePlanner.test.mjs b/fluxer_desktop/src/main/ModuleUpdatePlanner.test.mjs index 2d8766c2b..b3b23cda9 100644 --- a/fluxer_desktop/src/main/ModuleUpdatePlanner.test.mjs +++ b/fluxer_desktop/src/main/ModuleUpdatePlanner.test.mjs @@ -80,8 +80,24 @@ function createStore({ }; } -function createPlanner(store, {shellVersion = SHELL_VERSION, hasOfflineRenderer = false} = {}) { - return new ModuleUpdatePlanner(store, parseModuleVersion(shellVersion, 'desktop shell version'), hasOfflineRenderer); +function createPlanner( + store, + {shellVersion = SHELL_VERSION, hasOfflineRenderer = false, bundledRendererVersion = null} = {}, +) { + return new ModuleUpdatePlanner( + store, + parseModuleVersion(shellVersion, 'desktop shell version'), + hasOfflineRenderer, + bundledRendererVersion == null ? null : parseModuleVersion(bundledRendererVersion, 'bundled renderer version'), + ); +} + +function bundledPlanner(store, bundledRendererVersion = SHELL_VERSION) { + return createPlanner(store, {hasOfflineRenderer: true, bundledRendererVersion}); +} + +function installedRenderer(sha256, buildVersion) { + return {[`fluxer_renderer:${sha256}`]: {build_version: buildVersion}}; } describe('ModuleUpdatePlanner.isShellCompatible', () => { @@ -329,3 +345,114 @@ describe('ModuleUpdatePlanner.evaluateUnreachableLaunch', () => { }); }); }); + +describe('ModuleUpdatePlanner with a renderer bundled in the shell', () => { + const OLDER = '2026.822.9'; + const NEWER = '2026.824.1'; + + test('a feed renderer no newer than the bundle is never planned or pending', async () => { + const planner = bundledPlanner(createStore()); + const feed = manifest({modules: {fluxer_renderer: {sha256: RENDERER_SHA}}}); + + assert.equal(planner.bundleCoversModule('fluxer_renderer', feed), true); + assert.deepEqual(await planner.plan(feed), {items: [], base: {}}); + }); + + test('a feed renderer newer than the bundle is downloaded but never required to launch', async () => { + const planner = bundledPlanner(createStore(), OLDER); + const feed = manifest({modules: {fluxer_renderer: {sha256: RENDERER_SHA}}}); + + assert.equal(planner.bundleCoversModule('fluxer_renderer', feed), false); + const plan = await planner.plan(feed); + assert.deepEqual( + plan.items.map((item) => [item.module, item.requirement]), + [['fluxer_renderer', 'optional']], + ); + }); + + test('the bundle only ever stands in for the renderer module', async () => { + const planner = bundledPlanner(createStore()); + const feed = manifest({modules: {fluxer_overlay: {sha256: OVERLAY_SHA}}}); + + assert.equal(planner.bundleCoversModule('fluxer_overlay', feed), false); + assert.equal((await planner.plan(feed)).items.length, 1); + }); + + test('a committed renderer older than or equal to the bundle is stale, a newer one is not', async () => { + for (const [installedVersion, stale] of [ + [OLDER, true], + [SHELL_VERSION, true], + [NEWER, false], + ]) { + const planner = bundledPlanner( + createStore({ + committed: {fluxer_renderer: RENDERER_SHA}, + installedManifests: installedRenderer(RENDERER_SHA, installedVersion), + }), + ); + assert.equal(await planner.staleRendererModule(), stale ? RENDERER_SHA : null, installedVersion); + } + }); + + test('a committed renderer whose installation is unreadable is stale', async () => { + const planner = bundledPlanner(createStore({committed: {fluxer_renderer: RENDERER_SHA}})); + + assert.equal(await planner.staleRendererModule(), RENDERER_SHA); + }); + + test('without a bundle no committed renderer is ever stale', async () => { + const planner = createPlanner( + createStore({ + committed: {fluxer_renderer: RENDERER_SHA}, + installedManifests: installedRenderer(RENDERER_SHA, OLDER), + }), + ); + + assert.equal(await planner.staleRendererModule(), null); + }); + + test('serving drops an older renderer module and keeps every other module', async () => { + const planner = bundledPlanner(createStore({installedManifests: installedRenderer(RENDERER_SHA, OLDER)})); + + assert.deepEqual(await planner.selectServedModules({fluxer_renderer: RENDERER_SHA, fluxer_overlay: OVERLAY_SHA}), { + modules: {fluxer_overlay: OVERLAY_SHA}, + renderer: {source: 'bundled', version: SHELL_VERSION, bundledVersion: SHELL_VERSION}, + }); + }); + + test('serving keeps a renderer module newer than the bundle', async () => { + const planner = bundledPlanner(createStore({installedManifests: installedRenderer(RENDERER_SHA, NEWER)})); + const modules = {fluxer_renderer: RENDERER_SHA}; + + assert.deepEqual(await planner.selectServedModules(modules), { + modules, + renderer: {source: 'module', version: NEWER, bundledVersion: SHELL_VERSION}, + }); + }); + + test('a renderer floor never holds the launch of a shell that bundles its renderer', async () => { + const planner = bundledPlanner(createStore({floor: {fluxer_renderer: RENDERER_SHA, fluxer_overlay: OVERLAY_SHA}})); + + assert.deepEqual(await planner.modulesBelowFloor(true), ['fluxer_overlay']); + }); + + test('a fresh install that never reached the feed launches the bundled renderer', async () => { + const planner = bundledPlanner(createStore({lastManifestFetch: null})); + + assert.deepEqual(await planner.evaluateUnreachableLaunch(true, false), { + kind: ModuleUnreachableLaunchDecision.LAUNCH, + }); + }); + + test('the bundle meets a Linux security minimum it is at least as new as', async () => { + const planner = bundledPlanner(createStore()); + + assert.equal( + await planner.securityUpdateRequired({ + version: parseModuleVersion(SHELL_VERSION, 'minimum'), + requiredModules: ['fluxer_renderer'], + }), + false, + ); + }); +}); diff --git a/fluxer_desktop/src/main/ModuleUpdatePlanner.ts b/fluxer_desktop/src/main/ModuleUpdatePlanner.ts index 3da12198c..a5d5e1fe5 100644 --- a/fluxer_desktop/src/main/ModuleUpdatePlanner.ts +++ b/fluxer_desktop/src/main/ModuleUpdatePlanner.ts @@ -8,6 +8,7 @@ import { } from '@electron/main/ModuleManifest'; import {MODULE_BOOT_ATTEMPT_ROLLBACK_THRESHOLD, type ModuleStore} from '@electron/main/ModuleStore'; import {compareModuleVersions, type ModuleVersion, parseModuleVersion} from '@electron/main/ModuleVersion'; +import {DESKTOP_RENDERER_MODULE_NAME} from '@fluxer/desktop_ipc/src/ModuleContract'; export const ModuleUpdaterBlockReason = Object.freeze({ REQUIRED_MODULE_UNAVAILABLE: 'required-module-unavailable', @@ -46,21 +47,136 @@ export type ModuleUnreachableLaunchDecision = | {readonly kind: typeof ModuleUnreachableLaunchDecision.LAUNCH} | {readonly kind: typeof ModuleUnreachableLaunchDecision.BLOCK; readonly reason: ModuleUpdaterBlockReason}; +export const ServedRendererSource = Object.freeze({ + BUNDLED: 'bundled', + MODULE: 'module', + NONE: 'none', +} as const); + +export type ServedRendererSource = (typeof ServedRendererSource)[keyof typeof ServedRendererSource]; + +export interface ServedModuleSelection { + readonly modules: Readonly>; + readonly renderer: { + readonly source: ServedRendererSource; + readonly version: string | null; + readonly bundledVersion: string | null; + }; +} + export class ModuleUpdatePlanner { private readonly store: ModuleStore; private readonly shellVersion: ModuleVersion; private readonly hasOfflineRenderer: boolean; + private readonly bundledRendererVersion: ModuleVersion | null; + private readonly bundleOutranksEveryModule: boolean; - public constructor(store: ModuleStore, shellVersion: ModuleVersion, hasOfflineRenderer: boolean) { + public constructor( + store: ModuleStore, + shellVersion: ModuleVersion, + hasOfflineRenderer: boolean, + bundledRendererVersion: ModuleVersion | null = null, + preferUnversionedBundle = false, + ) { this.store = store; this.shellVersion = shellVersion; this.hasOfflineRenderer = hasOfflineRenderer; + this.bundledRendererVersion = hasOfflineRenderer ? bundledRendererVersion : null; + this.bundleOutranksEveryModule = hasOfflineRenderer && bundledRendererVersion == null && preferUnversionedBundle; } public hasSomethingToRender(committed: Readonly>): boolean { return this.hasOfflineRenderer || Object.keys(committed).length > 0; } + public hasBundledRenderer(): boolean { + return this.bundledRendererVersion != null || this.bundleOutranksEveryModule; + } + + public bundledModules(): ReadonlyArray { + return this.hasBundledRenderer() ? [DESKTOP_RENDERER_MODULE_NAME] : []; + } + + public bundleCoversRendererAt(version: ModuleVersion): boolean { + if (this.bundleOutranksEveryModule) { + return true; + } + return this.bundledRendererVersion != null && compareModuleVersions(version, this.bundledRendererVersion) <= 0; + } + + public async uninstalledCommittedModules(): Promise> { + if (!this.hasBundledRenderer()) { + return []; + } + const missing: Array = []; + for (const [moduleName, sha256] of Object.entries(this.store.getCommitted())) { + if (!(await this.isInstalled(moduleName, sha256))) { + missing.push(moduleName); + } + } + return missing; + } + + public bundleCoversModule(moduleName: string, manifest: DesktopModuleUpdateManifest): boolean { + return moduleName === DESKTOP_RENDERER_MODULE_NAME && this.bundleCoversRendererAt(manifest.buildVersion); + } + + private async installedRendererVersion(sha256: string): Promise { + const installed = await this.store.getInstalledManifest(DESKTOP_RENDERER_MODULE_NAME, sha256); + if (installed == null) { + return null; + } + try { + return parseModuleVersion(installed.build_version, 'installed renderer build version'); + } catch { + return null; + } + } + + public async staleRendererModule(): Promise { + if (!this.hasBundledRenderer()) { + return null; + } + const sha256 = this.store.getCommitted()[DESKTOP_RENDERER_MODULE_NAME]; + if (sha256 == null) { + return null; + } + const installedVersion = await this.installedRendererVersion(sha256); + return installedVersion == null || this.bundleCoversRendererAt(installedVersion) ? sha256 : null; + } + + public async selectServedModules(modules: Readonly>): Promise { + const bundledVersion = this.bundledRendererVersion?.source ?? null; + const sha256 = modules[DESKTOP_RENDERER_MODULE_NAME]; + if (sha256 == null) { + return { + modules, + renderer: { + source: this.hasOfflineRenderer ? ServedRendererSource.BUNDLED : ServedRendererSource.NONE, + version: bundledVersion, + bundledVersion, + }, + }; + } + const installedVersion = await this.installedRendererVersion(sha256); + if (this.hasBundledRenderer() && (installedVersion == null || this.bundleCoversRendererAt(installedVersion))) { + const served = {...modules}; + delete served[DESKTOP_RENDERER_MODULE_NAME]; + return { + modules: served, + renderer: {source: ServedRendererSource.BUNDLED, version: bundledVersion, bundledVersion}, + }; + } + return { + modules, + renderer: { + source: ServedRendererSource.MODULE, + version: installedVersion?.source ?? null, + bundledVersion, + }, + }; + } + public isShellCompatible(entry: DesktopModuleManifestEntry): boolean { if (entry.minimumShellVersion != null && compareModuleVersions(this.shellVersion, entry.minimumShellVersion) < 0) { return false; @@ -90,6 +206,9 @@ export class ModuleUpdatePlanner { } const committed = this.store.getCommitted(); for (const moduleName of minimum.requiredModules) { + if (moduleName === DESKTOP_RENDERER_MODULE_NAME && this.bundleCoversRendererAt(minimum.version)) { + continue; + } const sha256 = committed[moduleName]; if (sha256 == null) { return true; @@ -124,7 +243,7 @@ export class ModuleUpdatePlanner { if (entry == null) { continue; } - if (!this.isShellCompatible(entry)) { + if (!this.isShellCompatible(entry) || this.bundleCoversModule(moduleName, manifest)) { await this.retainCommitted(base, moduleName); continue; } @@ -139,7 +258,10 @@ export class ModuleUpdatePlanner { items.push({ module: moduleName, entry, - requirement: blocking.has(moduleName) ? ModulePlanItemRequirement.REQUIRED : ModulePlanItemRequirement.OPTIONAL, + requirement: + blocking.has(moduleName) && !this.bundledModules().includes(moduleName) + ? ModulePlanItemRequirement.REQUIRED + : ModulePlanItemRequirement.OPTIONAL, }); } return {items, base}; @@ -162,6 +284,9 @@ export class ModuleUpdatePlanner { if (state.rejected[moduleName] === sha256) { continue; } + if (this.bundledModules().includes(moduleName)) { + continue; + } if (state.committed[moduleName] !== sha256 || !(await this.isInstalled(moduleName, sha256))) { below.push(moduleName); } @@ -187,7 +312,10 @@ export class ModuleUpdatePlanner { securityUpdateRequired: boolean, ): Promise { const state = this.store.getState(); - if (state.last_manifest_fetch == null || !this.hasSomethingToRender(state.committed)) { + if ( + (state.last_manifest_fetch == null && !this.hasBundledRenderer()) || + !this.hasSomethingToRender(state.committed) + ) { return {kind: ModuleUnreachableLaunchDecision.BLOCK, reason: ModuleUpdaterBlockReason.NOTHING_INSTALLED}; } if (state.boot_attempt >= MODULE_BOOT_ATTEMPT_ROLLBACK_THRESHOLD) { diff --git a/fluxer_desktop/src/main/ModuleUpdater.test.mjs b/fluxer_desktop/src/main/ModuleUpdater.test.mjs index 933dd5b67..e2690fe0a 100644 --- a/fluxer_desktop/src/main/ModuleUpdater.test.mjs +++ b/fluxer_desktop/src/main/ModuleUpdater.test.mjs @@ -39,7 +39,7 @@ function createUserData() { return root; } -function installModuleOnDisk(storeRoot, moduleName, digest, entries) { +function installModuleOnDisk(storeRoot, moduleName, digest, entries, buildVersion = SHELL_VERSION) { const directory = path.join(storeRoot, 'store', moduleName, digest); mkdirSync(directory, {recursive: true}); const files = entries.map(([relative, body]) => { @@ -51,7 +51,7 @@ function installModuleOnDisk(storeRoot, moduleName, digest, entries) { `${JSON.stringify( { module: moduleName, - build_version: SHELL_VERSION, + build_version: buildVersion, release_channel: RELEASE_CHANNEL, source_sha: SOURCE_SHA, files, @@ -121,7 +121,8 @@ function createUpdater(store, modules, options = {}) { platform: options.platform ?? PLATFORM, arch: ARCH, packageOrigin: PACKAGE_ORIGIN, - hasOfflineRenderer: false, + hasOfflineRenderer: options.bundledRendererVersion != null, + bundledRendererVersion: options.bundledRendererVersion ?? null, forceStartupUpdate: options.forceStartupUpdate ?? false, selfUpdateShellFirst: options.selfUpdateShellFirst, onState: options.onState, @@ -862,3 +863,209 @@ describe('ModuleUpdater after a crash-loop rollback', () => { assert.equal(store.isRejected('fluxer_renderer', NEXT_RENDERER_SHA), false); }); }); + +describe('ModuleUpdater with a renderer bundled in the shell', () => { + const OLDER_VERSION = '2026.822.9'; + + async function storeWithOlderRenderer() { + const store = await openStore(createUserData()); + installModuleOnDisk(store.root, 'fluxer_renderer', RENDERER_SHA, [['index.html', 'renderer one']], OLDER_VERSION); + await store.commit({fluxer_renderer: RENDERER_SHA}); + await store.recordManifestFetch({ + etag: null, + fetchedAt: new Date(NOW).toISOString(), + manifest: { + feed: {releaseChannel: RELEASE_CHANNEL, platform: PLATFORM, arch: ARCH}, + metadataVersion: 1, + manifestSha256: 'c'.repeat(64), + }, + floor: {fluxer_renderer: NEXT_RENDERER_SHA}, + }); + return store; + } + + test('a new shell over an older installed renderer launches the bundle with the feed down', async () => { + const store = await storeWithOlderRenderer(); + const {updater, reports} = createUpdater( + store, + {}, + {bundledRendererVersion: SHELL_VERSION, forceStartupUpdate: true, respond: unreachableFeed()}, + ); + + const outcome = await updater.run(); + + assert.equal(outcome.status, 'unreachable-launch'); + assert.deepEqual(outcome.committed, {}); + assert.deepEqual(store.getCommitted(), {}); + assert.deepEqual(store.getState().previous, {}); + assert.equal( + reports.some((report) => report.type === 'bundled-renderer-preferred'), + true, + ); + assert.deepEqual((await updater.selectServedModules(outcome.committed)).renderer, { + source: 'bundled', + version: SHELL_VERSION, + bundledVersion: SHELL_VERSION, + }); + }); + + test('a feed renderer the bundle already covers is never downloaded', async () => { + const store = await storeWithOlderRenderer(); + const {updater, requests} = createUpdater( + store, + {fluxer_renderer: NEXT_RENDERER_SHA}, + {bundledRendererVersion: SHELL_VERSION, forceStartupUpdate: true, metadataVersion: 2}, + ); + + const outcome = await updater.run(); + + assert.equal(outcome.status, 'launching'); + assert.deepEqual(outcome.committed, {}); + assert.equal( + requests.some((url) => String(url).endsWith('package.br')), + false, + ); + assert.deepEqual(store.getState().floor, {}); + assert.deepEqual(await updater.checkForUpdate(), { + shellLatestVersion: SHELL_VERSION, + shellNewer: false, + modulesChanged: false, + }); + }); + + test('a feed renderer newer than the bundle is still fetched', async () => { + const store = await openStore(createUserData()); + const {updater, requests} = createUpdater( + store, + {fluxer_renderer: NEXT_RENDERER_SHA}, + {bundledRendererVersion: OLDER_VERSION, respond: missingPackages()}, + ); + + await updater.run(); + + assert.equal( + requests.some((url) => String(url).endsWith(`${NEXT_RENDERER_SHA}/package.br`)), + true, + ); + }); + + test('a renderer module that keeps failing to boot is dropped for the bundle, never relaunched forever', async () => { + const store = await openStore(createUserData()); + installModuleOnDisk(store.root, 'fluxer_renderer', NEXT_RENDERER_SHA, [['index.html', 'broken']], '2026.900.1'); + await store.commit({fluxer_renderer: NEXT_RENDERER_SHA}); + const served = []; + for (let launch = 0; launch < 4; launch += 1) { + const {updater} = createUpdater( + store, + {}, + {bundledRendererVersion: SHELL_VERSION, platform: 'linux', respond: unreachableFeed()}, + ); + const outcome = await updater.run(); + served.push((await updater.selectServedModules(outcome.committed)).renderer.source); + } + + assert.deepEqual(served, ['module', 'module', 'bundled', 'bundled']); + assert.equal(store.isRejected('fluxer_renderer', NEXT_RENDERER_SHA), true); + }); + + test('a committed on-demand module whose files are gone never blocks an offline launch', async () => { + for (const platform of ['darwin', 'linux']) { + const store = await openStore(createUserData()); + const directory = installModuleOnDisk(store.root, 'fluxer_fonts_jp', OVERLAY_SHA, [['a.woff2', 'font']], '0.0.0'); + await store.commit({fluxer_fonts_jp: OVERLAY_SHA}); + await rm(directory, {recursive: true, force: true}); + const {updater} = createUpdater( + store, + {}, + {bundledRendererVersion: SHELL_VERSION, platform, respond: unreachableFeed()}, + ); + + const outcome = await updater.run(); + + assert.equal(outcome.status, platform === 'linux' ? 'launching' : 'unreachable-launch', platform); + assert.deepEqual(outcome.committed, {}, platform); + } + }); + + test('a newer feed renderer whose package is missing launches the bundle instead of blocking', async () => { + const store = await openStore(createUserData()); + const {updater} = createUpdater( + store, + {fluxer_renderer: NEXT_RENDERER_SHA}, + {bundledRendererVersion: OLDER_VERSION, respond: missingPackages()}, + ); + + const outcome = await updater.run(); + + assert.equal(outcome.status, 'launching'); + assert.deepEqual(outcome.committed, {}); + assert.deepEqual(await updater.checkForUpdate(), { + shellLatestVersion: SHELL_VERSION, + shellNewer: false, + modulesChanged: true, + }); + }); + + test('a packaged bundle with no readable version still outranks an installed module', async () => { + const store = await openStore(createUserData()); + installModuleOnDisk(store.root, 'fluxer_renderer', RENDERER_SHA, [['index.html', 'old']], OLDER_VERSION); + await store.commit({fluxer_renderer: RENDERER_SHA}); + const updater = new ModuleUpdater({ + store, + shellVersion: SHELL_VERSION, + releaseChannel: RELEASE_CHANNEL, + platform: 'linux', + arch: ARCH, + packageOrigin: PACKAGE_ORIGIN, + hasOfflineRenderer: true, + bundledRendererVersion: 'dev', + preferUnversionedBundle: true, + fetch: unreachableFeed(), + sleep: async () => {}, + random: () => 0, + now: () => NOW, + }); + + const outcome = await updater.run(); + + assert.equal((await updater.selectServedModules(outcome.committed)).renderer.source, 'bundled'); + assert.deepEqual(store.getCommitted(), {}); + }); + + test('a Linux launch never waits on a shell download, a required security update still does', async () => { + const attempts = []; + const selfUpdateShellFirst = async (latestVersion) => { + attempts.push(latestVersion); + }; + const store = await openStore(createUserData()); + const {updater} = createUpdater( + store, + {}, + { + bundledRendererVersion: SHELL_VERSION, + platform: 'linux', + forceStartupUpdate: true, + shellLatest: '2026.900.1', + shellMinimum: '0.0.0', + selfUpdateShellFirst, + }, + ); + + assert.equal((await updater.run()).status, 'launching'); + assert.deepEqual(attempts, []); + + const mac = createUpdater( + await openStore(createUserData()), + {}, + { + bundledRendererVersion: SHELL_VERSION, + forceStartupUpdate: true, + shellLatest: '2026.900.1', + shellMinimum: '0.0.0', + selfUpdateShellFirst, + }, + ); + await mac.updater.run(); + assert.deepEqual(attempts, ['2026.900.1']); + }); +}); diff --git a/fluxer_desktop/src/main/ModuleUpdater.ts b/fluxer_desktop/src/main/ModuleUpdater.ts index 2380b8b41..e7d5960f9 100644 --- a/fluxer_desktop/src/main/ModuleUpdater.ts +++ b/fluxer_desktop/src/main/ModuleUpdater.ts @@ -44,11 +44,12 @@ import { ModuleUnreachableLaunchDecision, ModuleUpdatePlanner, ModuleUpdaterBlockReason, + type ServedModuleSelection, } from '@electron/main/ModuleUpdatePlanner'; import {compareModuleVersions, type ModuleVersion, parseModuleVersion} from '@electron/main/ModuleVersion'; import {resolveDesktopPackageOrigin} from '@electron/main/ShellDownloadFormats'; import type {DesktopModuleEnsureResult} from '@fluxer/desktop_ipc/src/ModuleContract'; -import {DesktopModuleEnsureStatus} from '@fluxer/desktop_ipc/src/ModuleContract'; +import {DESKTOP_RENDERER_MODULE_NAME, DesktopModuleEnsureStatus} from '@fluxer/desktop_ipc/src/ModuleContract'; const MODULE_UPDATER_BACKOFF_BASE_MS = 1000; const MODULE_UPDATER_BACKOFF_CAP_MS = 30000; @@ -82,6 +83,7 @@ const ModuleUpdaterReportType = Object.freeze({ STORAGE_ERROR: 'storage-error', ROLLBACK: 'rollback', ROLLBACK_FLOOR_BYPASS: 'rollback-floor-bypass', + BUNDLED_RENDERER_PREFERRED: 'bundled-renderer-preferred', BLOCKED: 'blocked', } as const); @@ -97,6 +99,7 @@ const MODULE_UPDATER_REPORT_SAMPLE_RATES: Readonly { return new Promise((resolve) => { setTimeout(resolve, ms); @@ -263,6 +277,8 @@ interface ModuleUpdaterOptions { readonly arch?: NodeJS.Architecture; readonly packageOrigin?: string; readonly hasOfflineRenderer?: boolean; + readonly bundledRendererVersion?: string | null; + readonly preferUnversionedBundle?: boolean; readonly forceStartupUpdate?: boolean; readonly selfUpdateShellFirst?: (latestVersion: string, requiredSecurityUpdate: boolean) => Promise; readonly onState?: (state: ModuleUpdaterSplashState) => void; @@ -319,7 +335,13 @@ export class ModuleUpdater { this.manifestFeed = {releaseChannel, platform: this.platform, arch}; const packageOrigin = (options.packageOrigin ?? resolveDesktopPackageOrigin()).replace(/\/+$/u, ''); this.packageOrigin = packageOrigin; - this.planner = new ModuleUpdatePlanner(this.store, this.shellVersion, this.hasOfflineRenderer); + this.planner = new ModuleUpdatePlanner( + this.store, + this.shellVersion, + this.hasOfflineRenderer, + parseBundledRendererVersion(options.bundledRendererVersion ?? null), + options.preferUnversionedBundle ?? false, + ); this.onState = options.onState ?? (() => {}); this.reporter = options.report ?? (() => {}); this.sleep = options.sleep ?? defaultSleep; @@ -395,7 +417,12 @@ export class ModuleUpdater { } public async run(): Promise { - const attempt = await this.store.beginBootAttempt(); + let attempt = await this.store.beginBootAttempt({bundled: this.planner.bundledModules()}); + const retiredRenderer = await this.retireRendererCoveredByBundle(); + const retiredMissing = await this.retireUninstalledModules(); + if (retiredRenderer || retiredMissing) { + attempt = {...attempt, committed: this.store.getCommitted()}; + } await this.restoreCachedUpdatePolicy(); if (attempt.rolledBack) { this.report({ @@ -562,6 +589,37 @@ export class ModuleUpdater { return {module: moduleName, status: DesktopModuleEnsureStatus.INSTALLED}; } + private async retireRendererCoveredByBundle(): Promise { + const stale = await this.planner.staleRendererModule(); + if (stale == null) { + return false; + } + if (!(await this.store.retireModule(DESKTOP_RENDERER_MODULE_NAME))) { + return false; + } + this.report({ + type: ModuleUpdaterReportType.BUNDLED_RENDERER_PREFERRED, + module: DESKTOP_RENDERER_MODULE_NAME, + message: `retired installed renderer ${stale} because the renderer bundled with this shell is at least as new`, + }); + return true; + } + + private async retireUninstalledModules(): Promise { + let retired = false; + for (const moduleName of await this.planner.uninstalledCommittedModules()) { + if (await this.store.retireModule(moduleName)) { + retired = true; + this.report({ + type: ModuleUpdaterReportType.PACKAGE_MISSING, + module: moduleName, + message: 'dropped a committed module whose files are gone, the bundled renderer does not need it to launch', + }); + } + } + return retired; + } + private async resolveManifestForEnsure(): Promise { return this.observeManifest(await this.manifestRepository.resolveMemoizedDocument()); } @@ -571,7 +629,8 @@ export class ModuleUpdater { if ( this.selfUpdateShellFirst == null || compareModuleVersions(manifest.shell.latestVersion, this.shellVersion) <= 0 || - this.shellFirstAttempted.has(latestVersion) + this.shellFirstAttempted.has(latestVersion) || + (this.platform === 'linux' && !this.isSecurityUpdateRequired()) ) { return false; } @@ -623,7 +682,7 @@ export class ModuleUpdater { const wanted: Array = []; for (const moduleName of Array.from(names).sort()) { const entry = manifest.modules[moduleName]; - if (entry == null || !planner.isShellCompatible(entry)) { + if (entry == null || !planner.isShellCompatible(entry) || planner.bundleCoversModule(moduleName, manifest)) { continue; } if (committed[moduleName] === entry.sha256 && (await planner.isInstalled(moduleName, entry.sha256))) { @@ -668,6 +727,21 @@ export class ModuleUpdater { return {installed, unresolved}; } + public async selectServedModules(modules: Readonly>): Promise { + return await this.planner.selectServedModules(modules); + } + + public async revertLaunch(attempt: ModuleLaunchAttempt): Promise> | null> { + const reverted = await this.store.revertLaunchAttempt(attempt, this.planner.bundledModules()); + if (reverted != null) { + this.report({ + type: ModuleUpdaterReportType.ROLLBACK, + message: 'reverted an in place module update whose renderer never confirmed it started', + }); + } + return reverted; + } + public async markLaunchSucceeded(attempt: ModuleLaunchAttempt): Promise { if (!(await this.store.markLaunchAttemptSucceeded(attempt))) { return; @@ -946,6 +1020,9 @@ export class ModuleUpdater { } } else { for (const moduleName of blockingModuleNames(manifest)) { + if (this.planner.bundleCoversModule(moduleName, manifest)) { + continue; + } floor[moduleName] = manifest.modules[moduleName].sha256; } } diff --git a/fluxer_desktop/src/main/ShellSelfUpdate.test.mjs b/fluxer_desktop/src/main/ShellSelfUpdate.test.mjs index 1c1d660dc..cf6260e85 100644 --- a/fluxer_desktop/src/main/ShellSelfUpdate.test.mjs +++ b/fluxer_desktop/src/main/ShellSelfUpdate.test.mjs @@ -149,6 +149,7 @@ installElectronStub({ const { getElectronUpdateFeedUrl, + resetElectronUpdateSessionForTests, runShellSelfUpdate, SHELL_SELF_UPDATE_CHECK_TIMEOUT_MS, SHELL_SELF_UPDATE_TOTAL_TIMEOUT_MS, @@ -242,6 +243,7 @@ beforeEach(() => { rmSync(applyStatePath, {force: true}); velopack.constructedWith.length = 0; autoUpdater.handlers.clear(); + resetElectronUpdateSessionForTests(); autoUpdater.feedUrls.length = 0; autoUpdater.checks = 0; autoUpdater.installs = 0; @@ -443,6 +445,34 @@ describe('velopack self update', () => { ); }); +describe('velopack self update across repeated clicks', () => { + selfUpdateTest( + 'a second run for the same version joins the download already in flight', + async ({start, fireTimer}) => { + velopack.checkResult = {id: 'v3', TargetFullRelease: {Version: '2026.900.0'}}; + let finishDownload = () => {}; + velopack.downloadPromise = new Promise((resolve) => { + finishDownload = resolve; + }); + const first = start('velopack', createHooks().hooks); + await settle(); + fireTimer(SHELL_SELF_UPDATE_TOTAL_TIMEOUT_MS); + assert.deepEqual(await first, {reason: 'timed-out', detail: 'the update did not finish'}); + + const {calls, hooks} = createHooks(); + void start('velopack', hooks); + await settle(); + finishDownload(); + await settle(); + await settle(); + + assert.equal(velopack.calls.filter((call) => call === 'downloadUpdateAsync').length, 1); + assert.deepEqual(calls, [{hook: 'downloading', progress: 0}, {hook: 'restarting'}]); + assert.deepEqual(quits, ['quit']); + }, + ); +}); + describe('electron self update', () => { selfUpdateTest('points squirrel at the json feed with an identifying user agent', async ({start}) => { const {hooks} = createHooks(); @@ -461,7 +491,7 @@ describe('electron self update', () => { assert.equal(autoUpdater.checks, 1); }); - selfUpdateTest('nothing newer resolves and takes every listener with it', async ({start}) => { + selfUpdateTest('nothing newer resolves and later runs reuse the same four listeners', async ({start}) => { const {calls, hooks} = createHooks(); const promise = start('electron', hooks); @@ -471,7 +501,13 @@ describe('electron self update', () => { assert.deepEqual(await promise, {reason: 'no-update', detail: null}); assert.deepEqual(calls, []); - assert.equal(autoUpdater.totalListenerCount(), 0); + + const again = start('electron', createHooks().hooks); + autoUpdater.emit('update-not-available'); + await again; + + assert.equal(autoUpdater.totalListenerCount(), 4); + assert.equal(autoUpdater.checks, 2); }); selfUpdateTest( @@ -481,7 +517,12 @@ describe('electron self update', () => { const {hooks} = createHooks(); assert.deepEqual(await start('electron', hooks), {reason: 'check-failed', detail: 'Update URL is not set'}); - assert.equal(autoUpdater.totalListenerCount(), 0); + + autoUpdater.checkError = null; + const retry = start('electron', createHooks().hooks); + autoUpdater.emit('update-not-available'); + assert.deepEqual(await retry, {reason: 'no-update', detail: null}); + assert.equal(autoUpdater.checks, 2); }, ); @@ -535,23 +576,68 @@ describe('electron self update', () => { autoUpdater.emit('update-downloaded'); assert.deepEqual(await promise, {reason: 'install-failed', detail: 'the app is on a read only mount'}); - assert.equal(autoUpdater.totalListenerCount(), 0); }, ); - selfUpdateTest('a silent squirrel hits the check deadline and drops every listener', async ({start, fireTimer}) => { - const {hooks} = createHooks(); - const promise = start('electron', hooks); + selfUpdateTest( + 'a silent squirrel hits the check deadline and a late download installs on the next run', + async ({start, fireTimer, stillPending}) => { + const {hooks} = createHooks(); + const promise = start('electron', hooks); - fireTimer(SHELL_SELF_UPDATE_CHECK_TIMEOUT_MS); + fireTimer(SHELL_SELF_UPDATE_CHECK_TIMEOUT_MS); - assert.deepEqual(await promise, {reason: 'timed-out', detail: 'the update check produced no answer'}); - assert.equal(autoUpdater.totalListenerCount(), 0); + assert.deepEqual(await promise, {reason: 'timed-out', detail: 'the update check produced no answer'}); - autoUpdater.emit('update-downloaded'); + autoUpdater.emit('update-downloaded'); - assert.equal(autoUpdater.installs, 0); - }); + assert.equal(autoUpdater.installs, 0); + + const {calls, hooks: nextHooks} = createHooks(); + const next = start('electron', nextHooks); + + assert.deepEqual(calls, [{hook: 'restarting'}]); + assert.equal(autoUpdater.installs, 1); + assert.equal(autoUpdater.checks, 1); + assert.equal(await stillPending(next), true); + }, + ); + + selfUpdateTest( + 'a second run while squirrel is still downloading joins it instead of asking squirrel again', + async ({start, fireTimer, stillPending}) => { + const first = start('electron', createHooks().hooks); + autoUpdater.emit('update-available'); + fireTimer(SHELL_SELF_UPDATE_TOTAL_TIMEOUT_MS); + assert.deepEqual(await first, {reason: 'timed-out', detail: 'the update did not finish'}); + + const {calls, hooks} = createHooks(); + const second = start('electron', hooks); + + assert.equal(autoUpdater.checks, 1); + assert.deepEqual(calls, [{hook: 'downloading', progress: null}]); + assert.equal(await stillPending(second), true); + + autoUpdater.emit('update-downloaded'); + + assert.deepEqual(calls, [{hook: 'downloading', progress: null}, {hook: 'restarting'}]); + assert.equal(autoUpdater.installs, 1); + }, + ); + + selfUpdateTest( + 'two runs in flight both settle on one squirrel answer and squirrel is asked once', + async ({start}) => { + const first = start('electron', createHooks().hooks); + const second = start('electron', createHooks().hooks); + + autoUpdater.emit('error', new Error('dns')); + + assert.deepEqual(await first, {reason: 'check-failed', detail: 'dns'}); + assert.deepEqual(await second, {reason: 'check-failed', detail: 'dns'}); + assert.equal(autoUpdater.checks, 1); + }, + ); selfUpdateTest('a download that never finishes hits the total deadline', async ({start, fireTimer}) => { const {hooks} = createHooks(); @@ -561,6 +647,5 @@ describe('electron self update', () => { fireTimer(SHELL_SELF_UPDATE_TOTAL_TIMEOUT_MS); assert.deepEqual(await promise, {reason: 'timed-out', detail: 'the update did not finish'}); - assert.equal(autoUpdater.totalListenerCount(), 0); }); }); diff --git a/fluxer_desktop/src/main/ShellSelfUpdate.ts b/fluxer_desktop/src/main/ShellSelfUpdate.ts index 277e847f0..19679b13b 100644 --- a/fluxer_desktop/src/main/ShellSelfUpdate.ts +++ b/fluxer_desktop/src/main/ShellSelfUpdate.ts @@ -5,7 +5,6 @@ import {createChildLogger} from '@electron/common/Logger'; import { type AppImageTarget, applyStagedAppImageUpdate, - discardStagedAppImageUpdate, type StagedAppImageUpdate, stageAppImageUpdate, } from '@electron/main/AppImageUpdate'; @@ -89,6 +88,37 @@ function getStagedVelopackVersion(staged: UpdateInfo | VelopackAsset): string | return asset.Version ?? null; } +let velopackDownload: { + readonly version: string; + readonly settled: Promise; + readonly observers: Set<(percent: number) => void>; +} | null = null; + +function downloadVelopackUpdateOnce( + manager: VelopackUpdateManager, + available: UpdateInfo, + onProgress: (percent: number) => void, +): Promise { + const version = getStagedVelopackVersion(available); + if (version == null) { + return manager.downloadUpdateAsync(available, onProgress); + } + if (velopackDownload != null && velopackDownload.version === version) { + velopackDownload.observers.add(onProgress); + return velopackDownload.settled; + } + const observers = new Set([onProgress]); + const settled = manager + .downloadUpdateAsync(available, (percent) => { + for (const observer of Array.from(observers)) observer(percent); + }) + .finally(() => { + if (velopackDownload?.settled === settled) velopackDownload = null; + }); + velopackDownload = {version, settled, observers}; + return settled; +} + async function runVelopackSelfUpdate(control: SelfUpdateControl, hooks: ShellSelfUpdateHooks): Promise { const {UpdateManager} = requireModule('velopack') as typeof import('velopack'); const manager = new UpdateManager(getUpdateBaseUrl()); @@ -120,7 +150,7 @@ async function runVelopackSelfUpdate(control: SelfUpdateControl, hooks: ShellSel control.clearCheckDeadline(); hooks.onDownloading(0); try { - await manager.downloadUpdateAsync(available, (percent) => { + await downloadVelopackUpdateOnce(manager, available, (percent) => { if (control.isSettled()) return; hooks.onDownloading(percent); }); @@ -162,6 +192,46 @@ async function fetchPublishedAppImage(): Promise<{version: string; sha256: strin return {version: payload.version, sha256: typeof sha256 === 'string' ? sha256 : null}; } +let appImageStaging: { + readonly version: string; + readonly staged: Promise; + readonly observers: Set<(percent: number) => void>; +} | null = null; + +async function stageAppImageUpdateOnce( + target: AppImageTarget, + version: string, + expectedSha256: string, + onProgress: (percent: number) => void, +): Promise { + if (appImageStaging == null || appImageStaging.version !== version) { + const observers = new Set<(percent: number) => void>(); + const staged = stageAppImageUpdate({ + target, + url: `${getUpdateBaseUrl()}/${version}/appimage`, + expectedSha256, + fetchImpl: moduleNetworkFetch, + onProgress: ({transferred, total}) => { + if (total <= 0) return; + const percent = Math.min(100, (transferred / total) * 100); + for (const observer of Array.from(observers)) observer(percent); + }, + }); + const staging = {version, staged, observers}; + appImageStaging = staging; + staged.catch(() => { + if (appImageStaging === staging) appImageStaging = null; + }); + } + const staging = appImageStaging; + staging.observers.add(onProgress); + try { + return await staging.staged; + } finally { + staging.observers.delete(onProgress); + } +} + async function runAppImageSelfUpdate( control: SelfUpdateControl, hooks: ShellSelfUpdateHooks, @@ -194,25 +264,19 @@ async function runAppImageSelfUpdate( hooks.onDownloading(0); let staged: StagedAppImageUpdate; try { - staged = await stageAppImageUpdate({ - target, - url: `${getUpdateBaseUrl()}/${published.version}/appimage`, - expectedSha256: published.sha256, - fetchImpl: moduleNetworkFetch, - onProgress: ({transferred, total}) => { - if (control.isSettled() || total <= 0) return; - hooks.onDownloading(Math.min(100, (transferred / total) * 100)); - }, + staged = await stageAppImageUpdateOnce(target, published.version, published.sha256, (percent) => { + if (control.isSettled()) return; + hooks.onDownloading(percent); }); } catch (error) { control.settle({reason: 'download-failed', detail: errorDetail(error)}); return; } if (control.isSettled()) { - discardStagedAppImageUpdate(staged); return; } hooks.onRestarting(); + appImageStaging = null; try { applyStagedAppImageUpdate(target, staged); } catch (error) { @@ -223,17 +287,75 @@ async function runAppImageSelfUpdate( app.quit(); } +const ElectronUpdatePhase = Object.freeze({ + IDLE: 'idle', + CHECKING: 'checking', + DOWNLOADING: 'downloading', + DOWNLOADED: 'downloaded', +} as const); + +type ElectronUpdatePhase = (typeof ElectronUpdatePhase)[keyof typeof ElectronUpdatePhase]; + +type ElectronUpdateEvent = + | {readonly type: 'available'} + | {readonly type: 'not-available'} + | {readonly type: 'downloaded'} + | {readonly type: 'error'; readonly error: Error; readonly phase: ElectronUpdatePhase}; + +const electronUpdateSession: { + phase: ElectronUpdatePhase; + armed: boolean; + readonly observers: Set<(event: ElectronUpdateEvent) => void>; +} = { + phase: ElectronUpdatePhase.IDLE, + armed: false, + observers: new Set(), +}; + +function publishElectronUpdateEvent(event: ElectronUpdateEvent): void { + for (const observer of Array.from(electronUpdateSession.observers)) { + try { + observer(event); + } catch (error) { + logger.error('A shell self update observer threw', error); + } + } +} + +function armElectronUpdateSession(): void { + if (electronUpdateSession.armed) return; + electronUpdateSession.armed = true; + autoUpdater.on('update-available', () => { + electronUpdateSession.phase = ElectronUpdatePhase.DOWNLOADING; + publishElectronUpdateEvent({type: 'available'}); + }); + autoUpdater.on('update-not-available', () => { + electronUpdateSession.phase = ElectronUpdatePhase.IDLE; + publishElectronUpdateEvent({type: 'not-available'}); + }); + autoUpdater.on('update-downloaded', () => { + electronUpdateSession.phase = ElectronUpdatePhase.DOWNLOADED; + logger.info('The shell update finished downloading'); + publishElectronUpdateEvent({type: 'downloaded'}); + }); + autoUpdater.on('error', (error: Error) => { + const phase = electronUpdateSession.phase; + if (phase !== ElectronUpdatePhase.DOWNLOADED) { + electronUpdateSession.phase = ElectronUpdatePhase.IDLE; + } + publishElectronUpdateEvent({type: 'error', error, phase}); + }); +} + +export function resetElectronUpdateSessionForTests(): void { + electronUpdateSession.phase = ElectronUpdatePhase.IDLE; + electronUpdateSession.armed = false; + electronUpdateSession.observers.clear(); +} + function runElectronSelfUpdate(control: SelfUpdateControl, hooks: ShellSelfUpdateHooks): void { - let downloading = false; - const onUpdateAvailable = (): void => { - downloading = true; - control.clearCheckDeadline(); - hooks.onDownloading(null); - }; - const onUpdateNotAvailable = (): void => { - control.settle({reason: 'no-update', detail: null}); - }; - const onUpdateDownloaded = (): void => { + armElectronUpdateSession(); + const install = (): void => { control.clearCheckDeadline(); hooks.onRestarting(); try { @@ -242,20 +364,48 @@ function runElectronSelfUpdate(control: SelfUpdateControl, hooks: ShellSelfUpdat control.settle({reason: 'install-failed', detail: errorDetail(error)}); } }; - const onError = (error: Error): void => { - control.settle({reason: downloading ? 'download-failed' : 'check-failed', detail: errorDetail(error)}); + const observer = (event: ElectronUpdateEvent): void => { + switch (event.type) { + case 'available': + control.clearCheckDeadline(); + hooks.onDownloading(null); + return; + case 'not-available': + control.settle({reason: 'no-update', detail: null}); + return; + case 'downloaded': + install(); + return; + case 'error': + control.settle({ + reason: event.phase === ElectronUpdatePhase.DOWNLOADING ? 'download-failed' : 'check-failed', + detail: errorDetail(event.error), + }); + return; + } }; - autoUpdater.on('update-available', onUpdateAvailable); - autoUpdater.on('update-not-available', onUpdateNotAvailable); - autoUpdater.on('update-downloaded', onUpdateDownloaded); - autoUpdater.on('error', onError); + electronUpdateSession.observers.add(observer); control.addCleanup(() => { - autoUpdater.removeListener('update-available', onUpdateAvailable); - autoUpdater.removeListener('update-not-available', onUpdateNotAvailable); - autoUpdater.removeListener('update-downloaded', onUpdateDownloaded); - autoUpdater.removeListener('error', onError); + electronUpdateSession.observers.delete(observer); }); + switch (electronUpdateSession.phase) { + case ElectronUpdatePhase.DOWNLOADED: + logger.info('The shell update was already downloaded this session, installing it'); + install(); + return; + case ElectronUpdatePhase.DOWNLOADING: + logger.info('The shell update is already downloading this session, waiting for it'); + control.clearCheckDeadline(); + hooks.onDownloading(null); + return; + case ElectronUpdatePhase.CHECKING: + logger.info('The shell update check is already running this session, waiting for it'); + return; + case ElectronUpdatePhase.IDLE: + break; + } try { + electronUpdateSession.phase = ElectronUpdatePhase.CHECKING; autoUpdater.setFeedURL({ url: getElectronUpdateFeedUrl(), serverType: 'json', @@ -263,6 +413,7 @@ function runElectronSelfUpdate(control: SelfUpdateControl, hooks: ShellSelfUpdat }); autoUpdater.checkForUpdates(); } catch (error) { + electronUpdateSession.phase = ElectronUpdatePhase.IDLE; control.settle({reason: 'check-failed', detail: errorDetail(error)}); } } diff --git a/fluxer_desktop/src/main/Updater.ts b/fluxer_desktop/src/main/Updater.ts index aed8a13eb..4403ed777 100644 --- a/fluxer_desktop/src/main/Updater.ts +++ b/fluxer_desktop/src/main/Updater.ts @@ -136,6 +136,15 @@ async function fetchManualLatest(options: {forceRefresh?: boolean} = {}): Promis return info; } +export async function findNewerManualShell(): Promise { + const plan = resolveShellUpdatePlan(); + if (plan.capability !== ShellUpdateCapability.MANUAL_DOWNLOAD || plan.reason !== 'platform') { + return null; + } + const latest = await fetchManualLatest({forceRefresh: true}); + return compareVersions(latest.version, app.getVersion()) > 0 ? latest : null; +} + function sendManualUpdateAvailable( getMainWindow: () => BrowserWindow | null, context: UpdaterContext, diff --git a/fluxer_desktop/src/main/Window.ts b/fluxer_desktop/src/main/Window.ts index 2c67b32d4..f537d8929 100644 --- a/fluxer_desktop/src/main/Window.ts +++ b/fluxer_desktop/src/main/Window.ts @@ -49,6 +49,7 @@ const LIVE_RESIZE_IDLE_MS = 400; const VISIBILITY_MARGIN = 32; const RENDERER_GONE_REPEAT_WINDOW_MS = 30000; const CLOSE_FOR_UPDATE_TIMEOUT_MS = 5000; +const UPDATE_RELOAD_COMMIT_TIMEOUT_MS = 8000; const THEME_WINDOW_BACKGROUND_COLORS: Readonly> = Object.freeze({ dark: '#1a181e', light: '#ebecef', @@ -156,6 +157,7 @@ let themeStudioPopoutWindow: BrowserWindow | null = null; let lastRestorableMainWindowMaximized = false; let mainWindowRendererGone = false; let closingMainWindowForUpdate = false; +const SHELL_PAGE_URL_PREFIX = 'file:'; let mainWindowTakeover: (() => void) | null = null; const takeoverEndedListeners = new Set<() => void>(); let pendingMainWindowReveal: {readonly window: BrowserWindow; readonly requestShow: () => void} | null = null; @@ -1217,6 +1219,45 @@ export function isMainWindowTakenOver(): boolean { return mainWindowTakeover != null; } +export async function reloadMainWindowForUpdate(): Promise { + const window = mainWindow; + if (!isAliveWindow(window) || window.webContents.isDestroyed()) { + return false; + } + await closeAppWindowsForUpdate(window, {keepShellPages: true}); + if (window.isDestroyed() || window.webContents.isDestroyed()) { + return false; + } + const committed = waitForReloadCommit(window.webContents); + window.webContents.reloadIgnoringCache(); + if (await committed) { + return true; + } + logger.warn('The main window did not start reloading for the update, replacing it', { + timeoutMs: UPDATE_RELOAD_COMMIT_TIMEOUT_MS, + }); + if (!window.isDestroyed()) { + window.destroy(); + } + return false; +} + +function waitForReloadCommit(contents: Electron.WebContents): Promise { + return new Promise((resolve) => { + const finish = (committed: boolean): void => { + clearTimeout(timer); + contents.removeListener('did-navigate', onCommit); + contents.removeListener('destroyed', onDestroyed); + resolve(committed); + }; + const onCommit = (): void => finish(true); + const onDestroyed = (): void => finish(false); + const timer = setTimeout(() => finish(false), UPDATE_RELOAD_COMMIT_TIMEOUT_MS); + contents.once('did-navigate', onCommit); + contents.once('destroyed', onDestroyed); + }); +} + export function hideAppWindowsForUpdate(keep: BrowserWindow): ReadonlyArray { const hidden: Array = []; for (const window of BrowserWindow.getAllWindows()) { @@ -1234,8 +1275,17 @@ export function restoreAppWindowsAfterUpdate(hidden: ReadonlyArray { - const closing = BrowserWindow.getAllWindows().filter((window) => window !== keep && !window.isDestroyed()); +function showsShellPage(window: BrowserWindow): boolean { + return !window.webContents.isDestroyed() && window.webContents.getURL().startsWith(SHELL_PAGE_URL_PREFIX); +} + +export async function closeAppWindowsForUpdate( + keep: BrowserWindow, + {keepShellPages = false}: {readonly keepShellPages?: boolean} = {}, +): Promise { + const closing = BrowserWindow.getAllWindows().filter( + (window) => window !== keep && !window.isDestroyed() && !(keepShellPages && showsShellPage(window)), + ); closingMainWindowForUpdate = true; try { await Promise.all( diff --git a/fluxer_desktop/src/main/index.ts b/fluxer_desktop/src/main/index.ts index 913958d93..9cd243909 100644 --- a/fluxer_desktop/src/main/index.ts +++ b/fluxer_desktop/src/main/index.ts @@ -9,6 +9,7 @@ import { WINDOWS_APP_USER_MODEL_ID, WINDOWS_TOAST_ACTIVATOR_CLSID, } from '@electron/common/DesktopIdentity'; +import {writeLogFilesUnder} from '@electron/common/Logger'; import {configureUserDataPath} from '@electron/common/UserDataPath'; import { APP_STORE_ADDON_PACKAGE, @@ -151,8 +152,7 @@ process.on('unhandledRejection', (reason, promise) => { const userDataConfig = configureUserDataPath(); if (userDataConfig.portable) { - const portableLogsPath = app.getPath('logs'); - log.transports.file.resolvePathFn = (variables) => path.join(portableLogsPath, variables.fileName ?? 'main.log'); + writeLogFilesUnder(app.getPath('logs')); } armNativeProbeCache(userDataConfig.base); const processConfiguredAt = Date.now(); diff --git a/knip.json b/knip.json index 2989c90bd..ab5640884 100644 --- a/knip.json +++ b/knip.json @@ -65,6 +65,7 @@ "src/preload/index.ts", "src/preload/LegacyHarvestPreload.ts", "src/main/**/*.test.{ts,mjs}", + "scripts/release-check.mjs", "native/*/index.{js,d.ts}", "native/*/loader-diagnostics.cjs", "native/*/*.test.mjs", diff --git a/packages/desktop_ipc/src/ModuleContract.ts b/packages/desktop_ipc/src/ModuleContract.ts index 00b72679e..6f03a89a6 100644 --- a/packages/desktop_ipc/src/ModuleContract.ts +++ b/packages/desktop_ipc/src/ModuleContract.ts @@ -15,15 +15,15 @@ export const DESKTOP_UPDATE_EVENTS = Object.freeze({ } as const); const DESKTOP_MODULE_NAME_PATTERN = /^[a-z][a-z0-9_]{0,63}$/u; -export const DESKTOP_GRAMMAR_MODULE_NAME = 'fluxer_grammars'; +export const DESKTOP_RENDERER_MODULE_NAME = 'fluxer_renderer'; export const DESKTOP_TWEMOJI_MODULE_NAME = 'fluxer_twemoji'; export const DESKTOP_DEEP_FILTER_MODULE_NAME = 'fluxer_deepfilter'; +export const DESKTOP_CAMERA_EFFECTS_MODULE_NAME = 'fluxer_camera_effects'; export const DESKTOP_EMOJI_SPRITES_MODULE_NAME = 'fluxer_emoji_sprites'; export const DESKTOP_EXTRAS_MODULE_NAME = 'fluxer_extras'; export const DESKTOP_SOURCEMAP_MODULE_NAME = 'fluxer_sourcemaps'; export const DESKTOP_FONT_MODULE_NAMES = Object.freeze({ - 'non-latin': 'fluxer_fonts_nonlatin', sc: 'fluxer_fonts_sc', tc: 'fluxer_fonts_tc', jp: 'fluxer_fonts_jp', @@ -60,6 +60,7 @@ export interface DesktopModuleAPI { export interface DesktopUpdateState { readonly available: boolean; + readonly updating?: boolean; } export interface DesktopUpdateAPI { diff --git a/tools/dev/src/desktop.rs b/tools/dev/src/desktop.rs index de9d17496..75231c851 100644 --- a/tools/dev/src/desktop.rs +++ b/tools/dev/src/desktop.rs @@ -456,6 +456,14 @@ pub fn build_desktop( let dist_renderer = DESKTOP_DIR.join("dist/renderer"); remove_path(&dist_renderer)?; copy_tree(&renderer, &dist_renderer, &|_| false)?; + if delivery == RendererDelivery::Modules { + let version_file = dist_renderer.join("version.json"); + fs::write( + &version_file, + serde_json::to_vec(&serde_json::json!({ "version": version }))?, + ) + .with_context(|| format!("failed to write {}", version_file.display()))?; + } { let _guard = BuildChannelFileGuard::capture(); run_command( @@ -575,7 +583,7 @@ pub fn electron_builder_args( "-c.mac.notarize=false".to_owned(), ]; if *signing == MacSigning::AdHoc { - args.push("-c.mac.identity=-".to_owned()); + args.push("-c.mac.sign.identity=-".to_owned()); } if !extra_args .iter() @@ -1217,9 +1225,13 @@ mod tests { assert!(args.contains(&"-c.mac.notarize=false".to_owned())); assert!(args.contains(&"-c.directories.output=/tmp/out".to_owned())); assert!(args.contains(&"--arm64".to_owned())); - assert!(!args.iter().any(|arg| arg.starts_with("-c.mac.identity"))); + assert!( + !args + .iter() + .any(|arg| arg.starts_with("-c.mac.sign.identity")) + ); let ad_hoc = electron_builder_args(Path::new("/tmp/out"), "x64", &MacSigning::AdHoc, &[]); - assert!(ad_hoc.contains(&"-c.mac.identity=-".to_owned())); + assert!(ad_hoc.contains(&"-c.mac.sign.identity=-".to_owned())); assert!(ad_hoc.contains(&"--x64".to_owned())); let linux = electron_builder_args( Path::new("/tmp/out"),