mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-13 05:58:29 +09:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2ffe8f3387 | ||
|
|
4194525933 |
No files matched your search
Generated
+3
-3
@@ -49,9 +49,9 @@ checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
|
||||
|
||||
[[package]]
|
||||
name = "ammonia"
|
||||
version = "4.2.0"
|
||||
version = "4.2.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3f7ed3ac4252be4d3e36fb589ed5659356842587b21a31596a9dc89d3b2f5e2a"
|
||||
checksum = "061e83b03c2681c18a6787d956e355c74e0b98ba7ba3d69b0822ade1e6f1d716"
|
||||
dependencies = [
|
||||
"cssparser 0.38.0",
|
||||
"html5ever 0.40.1",
|
||||
@@ -1266,7 +1266,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d045de693cb712d0b22c6a64be5b953f67b3ce00ab5ad3dd5d8b441886ab8e1a"
|
||||
dependencies = [
|
||||
"quote",
|
||||
"syn 3.0.6",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -19,9 +19,12 @@ describe('how often a client re-checks the module manifest', () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('a shipped build polls every ten minutes, not every two seconds', () => {
|
||||
test('a shipped build polls every 75 to 105 seconds', () => {
|
||||
assert.equal(resolveModulePollPlan(null).intervalMs, MODULE_POLL_INTERVAL_MS);
|
||||
assert.equal(MODULE_POLL_INTERVAL_MS, 10 * 60 * 1000);
|
||||
assert.equal(MODULE_POLL_INTERVAL_MS, 90 * 1000);
|
||||
const plan = resolveModulePollPlan(null);
|
||||
assert.ok(nextModulePollDelay(plan, () => 0) >= 75 * 1000);
|
||||
assert.ok(nextModulePollDelay(plan, () => 1) <= 105 * 1000);
|
||||
});
|
||||
|
||||
test('a shipped build spreads the poll so clients do not stampede the manifest', () => {
|
||||
@@ -34,8 +37,8 @@ describe('how often a client re-checks the module manifest', () => {
|
||||
const mid = nextModulePollDelay(plan, () => 0.5);
|
||||
const high = nextModulePollDelay(plan, () => 1);
|
||||
assert.equal(mid, plan.intervalMs);
|
||||
assert.equal(low, plan.intervalMs * (1 - plan.jitterRatio));
|
||||
assert.equal(high, plan.intervalMs * (1 + plan.jitterRatio));
|
||||
assert.equal(low, Math.round(plan.intervalMs * (1 - plan.jitterRatio)));
|
||||
assert.equal(high, Math.round(plan.intervalMs * (1 + plan.jitterRatio)));
|
||||
assert.ok(low < mid && mid < high);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
export const MODULE_POLL_INTERVAL_MS = 10 * 60 * 1000;
|
||||
const MODULE_POLL_JITTER_RATIO = 0.2;
|
||||
export const MODULE_POLL_INTERVAL_MS = 90 * 1000;
|
||||
const MODULE_POLL_JITTER_RATIO = 0.15;
|
||||
|
||||
export interface ModulePollPlan {
|
||||
readonly intervalMs: number;
|
||||
|
||||
@@ -5,7 +5,7 @@ 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 {resolveModulePollPlan} from '@electron/common/ModulePollPlan';
|
||||
import {
|
||||
hasOfflineRenderer,
|
||||
ModuleSystemLaunchDecision,
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
resolveModuleSystemLaunch,
|
||||
} from '@electron/common/ModuleSystem';
|
||||
import {checkDesktopUpdateNow} from '@electron/main/DesktopUpdateGate';
|
||||
import {armDesktopUpdatePoll, wakeDesktopUpdatePoll} from '@electron/main/DesktopUpdatePoll';
|
||||
import {DesktopUpdateRun, type DesktopUpdateTakeover} from '@electron/main/DesktopUpdateRun';
|
||||
import {armOpenUrlForwarding, observeRendererLaunchConfirmed} from '@electron/main/ModuleBootHandoff';
|
||||
import type {ModuleLaunchAttempt, ModuleStore as ModuleStoreInstance} from '@electron/main/ModuleStore';
|
||||
@@ -134,51 +135,6 @@ async function launchMainApp(permit: ModuleLaunchPermit, logger: BootstrapLogger
|
||||
await import(MAIN_PROCESS_ENTRY_URL);
|
||||
}
|
||||
|
||||
function armModulePoll(plan: ModulePollPlan, logger: BootstrapLogger): void {
|
||||
let inFlight = false;
|
||||
let failing = false;
|
||||
logger.info('Polling the module manifest', {
|
||||
intervalMs: plan.intervalMs,
|
||||
jitterRatio: plan.jitterRatio,
|
||||
});
|
||||
const schedule = (): void => {
|
||||
const timer = setTimeout(tick, nextModulePollDelay(plan));
|
||||
timer.unref();
|
||||
};
|
||||
const tick = (): void => {
|
||||
if (inFlight) {
|
||||
schedule();
|
||||
return;
|
||||
}
|
||||
inFlight = true;
|
||||
void checkDesktopUpdateNow()
|
||||
.then((state) => {
|
||||
if (failing) {
|
||||
failing = false;
|
||||
logger.info('The module poll recovered');
|
||||
}
|
||||
if (state.available) {
|
||||
logger.info('A desktop update is available, waiting for the user to start it');
|
||||
}
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (!failing) {
|
||||
failing = true;
|
||||
logger.warn('The module poll failed, staying quiet until it recovers', error);
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
inFlight = false;
|
||||
schedule();
|
||||
});
|
||||
};
|
||||
if (process.platform === 'linux') {
|
||||
tick();
|
||||
} else {
|
||||
schedule();
|
||||
}
|
||||
}
|
||||
|
||||
async function refuseUnsupportedBuild(reason: string): Promise<never> {
|
||||
const {app} = await import('electron');
|
||||
const {createChildLogger} = await import('@electron/common/Logger');
|
||||
@@ -811,8 +767,8 @@ async function runModuleBootstrap(): Promise<void> {
|
||||
);
|
||||
markSplashLaunching();
|
||||
armDesktopUpdate({
|
||||
check: async () => {
|
||||
const check = await desktopUpdate.check();
|
||||
check: async (manual) => {
|
||||
const check = await desktopUpdate.check({manual});
|
||||
recordPendingUpdate(check);
|
||||
return check;
|
||||
},
|
||||
@@ -821,7 +777,15 @@ async function runModuleBootstrap(): Promise<void> {
|
||||
armMainWindowHandoff(updater, permit.launchAttempt, () => {
|
||||
desktopUpdate.markLaunchSettled();
|
||||
preloadUpdateSplash();
|
||||
armModulePoll(resolveModulePollPlan(readDevModulePollInterval()), logger);
|
||||
armDesktopUpdatePoll(
|
||||
{
|
||||
plan: resolveModulePollPlan(readDevModulePollInterval()),
|
||||
check: () => checkDesktopUpdateNow(),
|
||||
logger,
|
||||
},
|
||||
process.platform === 'linux',
|
||||
);
|
||||
observeRendererLaunchConfirmed(wakeDesktopUpdatePoll);
|
||||
});
|
||||
app.once('before-quit', () => {
|
||||
closeSplashWindow();
|
||||
|
||||
@@ -195,12 +195,9 @@ describe('Bootstrap entry point', () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('the module poll only checks, it never downloads or reloads underneath the user', () => {
|
||||
const poll = source.slice(
|
||||
source.indexOf('function armModulePoll('),
|
||||
source.indexOf('async function refuseUnsupportedBuild('),
|
||||
);
|
||||
assert.match(poll, /void checkDesktopUpdateNow\(\)/);
|
||||
test('the update poll only checks, it never downloads or reloads underneath the user', () => {
|
||||
const poll = readFileSync(path.join(path.dirname(fileURLToPath(import.meta.url)), 'DesktopUpdatePoll.ts'), 'utf8');
|
||||
assert.match(source, /check: \(\) => checkDesktopUpdateNow\(\),/);
|
||||
assert.doesNotMatch(
|
||||
poll,
|
||||
/installPending|reloadIgnoringCache|refreshModuleRoots|activateMergedForRendererReload/,
|
||||
@@ -208,12 +205,9 @@ describe('Bootstrap entry point', () => {
|
||||
);
|
||||
assert.doesNotMatch(source, /reloadIgnoringCache/);
|
||||
});
|
||||
test('a module poll that went quiet says so when it comes back', () => {
|
||||
assert.match(
|
||||
source,
|
||||
/if \(failing\) \{\n\t+failing = false;\n\t+logger\.info\('The module poll recovered'\);\n\t+\}/,
|
||||
'The failure is logged once and then suppressed until recovery. Without a line on the way back, a reader of the log cannot tell a poll that recovered from one that is still failing, which is the whole question that warning raises.',
|
||||
);
|
||||
|
||||
test('a reload of the renderer checks for an update right away', () => {
|
||||
assert.match(source, /observeRendererLaunchConfirmed\(wakeDesktopUpdatePoll\);/);
|
||||
});
|
||||
|
||||
test('a minimized login launch converges the modules without flashing the splash', () => {
|
||||
|
||||
@@ -71,6 +71,50 @@ describe('DesktopUpdateGate', () => {
|
||||
stop();
|
||||
});
|
||||
|
||||
test('a background poll joins a check already running instead of starting another', async () => {
|
||||
const pending = deferred();
|
||||
const calls = [];
|
||||
armDesktopUpdate({
|
||||
check: async (manual) => {
|
||||
calls.push(manual);
|
||||
await pending.promise;
|
||||
return {shellNewer: false, modulesChanged: false};
|
||||
},
|
||||
start: async () => {},
|
||||
});
|
||||
|
||||
const first = checkDesktopUpdateNow();
|
||||
const second = checkDesktopUpdateNow();
|
||||
pending.resolve();
|
||||
await Promise.all([first, second]);
|
||||
|
||||
assert.deepEqual(calls, [false]);
|
||||
});
|
||||
|
||||
test('a manual check never settles for the answer of a background poll that was already running', async () => {
|
||||
const pending = deferred();
|
||||
const calls = [];
|
||||
armDesktopUpdate({
|
||||
check: async (manual) => {
|
||||
calls.push(manual);
|
||||
if (!manual) {
|
||||
await pending.promise;
|
||||
return {shellNewer: false, modulesChanged: false};
|
||||
}
|
||||
return {shellNewer: true, modulesChanged: false};
|
||||
},
|
||||
start: async () => {},
|
||||
});
|
||||
|
||||
const background = checkDesktopUpdateNow();
|
||||
const manual = checkDesktopUpdateNow({manual: true});
|
||||
pending.resolve();
|
||||
await background;
|
||||
|
||||
assert.deepEqual(await manual, {available: true, updating: false});
|
||||
assert.deepEqual(calls, [false, true]);
|
||||
});
|
||||
|
||||
test('a shell update that could not finish stays available for the next click', async () => {
|
||||
armDesktopUpdate({
|
||||
check: async () => ({shellNewer: true, modulesChanged: false}),
|
||||
|
||||
@@ -11,7 +11,7 @@ export interface DesktopUpdateCheck {
|
||||
}
|
||||
|
||||
interface DesktopUpdateController {
|
||||
readonly check: () => Promise<DesktopUpdateCheck>;
|
||||
readonly check: (manual: boolean) => Promise<DesktopUpdateCheck>;
|
||||
readonly start: () => Promise<void>;
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ interface DesktopUpdateGateState {
|
||||
controller: DesktopUpdateController | null;
|
||||
lastCheck: DesktopUpdateCheck | null;
|
||||
running: Promise<void> | null;
|
||||
checking: {readonly manual: boolean; readonly settled: Promise<void>} | null;
|
||||
notifiedAvailable: boolean;
|
||||
notifiedUpdating: boolean;
|
||||
readonly listeners: Set<DesktopUpdateStateListener>;
|
||||
@@ -30,6 +31,7 @@ const gateState: DesktopUpdateGateState = {
|
||||
controller: null,
|
||||
lastCheck: null,
|
||||
running: null,
|
||||
checking: null,
|
||||
notifiedAvailable: false,
|
||||
notifiedUpdating: false,
|
||||
listeners: new Set(),
|
||||
@@ -76,14 +78,37 @@ export function publishDesktopUpdateCheck(check: DesktopUpdateCheck): void {
|
||||
notify();
|
||||
}
|
||||
|
||||
export async function checkDesktopUpdateNow(): Promise<DesktopUpdateState> {
|
||||
export async function checkDesktopUpdateNow({
|
||||
manual = false,
|
||||
}: {
|
||||
readonly manual?: boolean;
|
||||
} = {}): Promise<DesktopUpdateState> {
|
||||
const pending = gateState.checking;
|
||||
if (pending != null) {
|
||||
if (pending.manual || !manual) {
|
||||
await pending.settled;
|
||||
return getDesktopUpdateState();
|
||||
}
|
||||
await pending.settled.catch(() => {});
|
||||
return await checkDesktopUpdateNow({manual});
|
||||
}
|
||||
const controller = gateState.controller;
|
||||
if (controller == null || gateState.running != null) {
|
||||
return getDesktopUpdateState();
|
||||
}
|
||||
const check = await controller.check();
|
||||
if (gateState.running == null) {
|
||||
publishDesktopUpdateCheck(check);
|
||||
const settled = controller.check(manual).then((check) => {
|
||||
if (gateState.running == null) {
|
||||
publishDesktopUpdateCheck(check);
|
||||
}
|
||||
});
|
||||
const checking = {manual, settled};
|
||||
gateState.checking = checking;
|
||||
try {
|
||||
await settled;
|
||||
} finally {
|
||||
if (gateState.checking === checking) {
|
||||
gateState.checking = null;
|
||||
}
|
||||
}
|
||||
return getDesktopUpdateState();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import assert from 'node:assert/strict';
|
||||
import {describe, test} from 'node:test';
|
||||
import './LocalAppTestSupport.test.mjs';
|
||||
|
||||
const {DesktopUpdatePoll} = await import('@electron/main/DesktopUpdatePoll');
|
||||
|
||||
const PLAN = Object.freeze({intervalMs: 90_000, jitterRatio: 0.15});
|
||||
|
||||
function settle() {
|
||||
return new Promise((resolve) => setImmediate(resolve));
|
||||
}
|
||||
|
||||
function harness(results) {
|
||||
const timers = [];
|
||||
const logs = [];
|
||||
let checks = 0;
|
||||
let release = null;
|
||||
const poll = new DesktopUpdatePoll({
|
||||
plan: PLAN,
|
||||
random: () => 0.5,
|
||||
check: () => {
|
||||
const result = results[Math.min(checks, results.length - 1)];
|
||||
checks += 1;
|
||||
if (result === 'hold') {
|
||||
return new Promise((resolve) => {
|
||||
release = () => resolve({available: false, updating: false});
|
||||
});
|
||||
}
|
||||
return result instanceof Error ? Promise.reject(result) : Promise.resolve(result);
|
||||
},
|
||||
logger: {
|
||||
info: (message) => logs.push(['info', message]),
|
||||
warn: (message) => logs.push(['warn', message]),
|
||||
},
|
||||
setTimer: (callback, delayMs) => {
|
||||
const timer = {callback, delayMs, cleared: false};
|
||||
timers.push(timer);
|
||||
return timer;
|
||||
},
|
||||
clearTimer: (timer) => {
|
||||
timer.cleared = true;
|
||||
},
|
||||
});
|
||||
const fire = async () => {
|
||||
const timer = timers.findLast((candidate) => !candidate.cleared);
|
||||
timer.cleared = true;
|
||||
timer.callback();
|
||||
await settle();
|
||||
};
|
||||
return {
|
||||
poll,
|
||||
timers,
|
||||
logs,
|
||||
fire,
|
||||
checks: () => checks,
|
||||
release: async () => {
|
||||
release();
|
||||
await settle();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const NOTHING = Object.freeze({available: false, updating: false});
|
||||
const AVAILABLE = Object.freeze({available: true, updating: false});
|
||||
|
||||
describe('DesktopUpdatePoll', () => {
|
||||
test('a healthy poll checks about every ninety seconds', async () => {
|
||||
const {poll, timers, fire, checks} = harness([NOTHING]);
|
||||
|
||||
poll.start(false);
|
||||
assert.equal(checks(), 0);
|
||||
assert.equal(timers.at(-1).delayMs, 90_000);
|
||||
|
||||
await fire();
|
||||
await fire();
|
||||
|
||||
assert.equal(checks(), 2);
|
||||
assert.equal(timers.at(-1).delayMs, 90_000);
|
||||
});
|
||||
|
||||
test('a failing poll backs off, warns once, and says so when it recovers', async () => {
|
||||
const offline = new Error('offline');
|
||||
const {poll, timers, logs, fire} = harness([offline, offline, offline, NOTHING, NOTHING]);
|
||||
|
||||
poll.start(true);
|
||||
await settle();
|
||||
const delays = [timers.at(-1).delayMs];
|
||||
await fire();
|
||||
delays.push(timers.at(-1).delayMs);
|
||||
await fire();
|
||||
delays.push(timers.at(-1).delayMs);
|
||||
await fire();
|
||||
delays.push(timers.at(-1).delayMs);
|
||||
await fire();
|
||||
|
||||
assert.deepEqual(delays, [135_000, 270_000, 540_000, 90_000]);
|
||||
assert.deepEqual(
|
||||
logs.filter(([level]) => level === 'warn').map(([, message]) => message),
|
||||
['The desktop update poll failed, backing off until it recovers'],
|
||||
);
|
||||
assert.equal(logs.filter(([, message]) => message === 'The desktop update poll recovered').length, 1);
|
||||
});
|
||||
|
||||
test('the back off never waits longer than fifteen minutes', async () => {
|
||||
const {poll, timers, fire} = harness([new Error('offline')]);
|
||||
|
||||
poll.start(true);
|
||||
await settle();
|
||||
for (let attempt = 0; attempt < 12; attempt += 1) {
|
||||
await fire();
|
||||
}
|
||||
|
||||
assert.ok(timers.at(-1).delayMs <= 15 * 60 * 1000);
|
||||
});
|
||||
|
||||
test('a reopened window or a reload checks at once and restarts the interval', async () => {
|
||||
const {poll, timers, checks} = harness([NOTHING]);
|
||||
poll.start(false);
|
||||
const pending = timers.at(-1);
|
||||
|
||||
poll.wake();
|
||||
await settle();
|
||||
|
||||
assert.equal(checks(), 1);
|
||||
assert.equal(pending.cleared, true);
|
||||
assert.equal(timers.filter((timer) => !timer.cleared).length, 1);
|
||||
});
|
||||
|
||||
test('a wake while a check runs never starts a second one', async () => {
|
||||
const {poll, checks, release, timers} = harness(['hold', NOTHING]);
|
||||
poll.start(true);
|
||||
|
||||
poll.wake();
|
||||
poll.wake();
|
||||
assert.equal(checks(), 1);
|
||||
|
||||
await release();
|
||||
assert.equal(timers.filter((timer) => !timer.cleared).length, 1);
|
||||
});
|
||||
|
||||
test('an update that stays available is logged once, not on every tick', async () => {
|
||||
const {poll, logs, fire} = harness([AVAILABLE]);
|
||||
poll.start(true);
|
||||
await settle();
|
||||
await fire();
|
||||
await fire();
|
||||
|
||||
assert.equal(
|
||||
logs.filter(([, message]) => message === 'A desktop update is available, waiting for the user to start it')
|
||||
.length,
|
||||
1,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,132 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {jitteredExponentialDelay} from '@electron/common/ExponentialBackoff';
|
||||
import {type ModulePollPlan, nextModulePollDelay} from '@electron/common/ModulePollPlan';
|
||||
import type {DesktopUpdateState} from '@fluxer/desktop_ipc/src/ModuleContract';
|
||||
|
||||
const DESKTOP_UPDATE_POLL_BACKOFF_CAP_MS = 15 * 60 * 1000;
|
||||
|
||||
interface DesktopUpdatePollLogger {
|
||||
readonly info: (message: string, details?: unknown) => void;
|
||||
readonly warn: (message: string, details?: unknown) => void;
|
||||
}
|
||||
|
||||
interface DesktopUpdatePollOptions {
|
||||
readonly plan: ModulePollPlan;
|
||||
readonly check: () => Promise<DesktopUpdateState>;
|
||||
readonly logger: DesktopUpdatePollLogger;
|
||||
readonly random?: () => number;
|
||||
readonly setTimer?: (callback: () => void, delayMs: number) => unknown;
|
||||
readonly clearTimer?: (timer: unknown) => void;
|
||||
}
|
||||
|
||||
function setUnrefTimer(callback: () => void, delayMs: number): NodeJS.Timeout {
|
||||
const timer = setTimeout(callback, delayMs);
|
||||
timer.unref();
|
||||
return timer;
|
||||
}
|
||||
|
||||
export class DesktopUpdatePoll {
|
||||
private readonly options: DesktopUpdatePollOptions;
|
||||
private readonly random: () => number;
|
||||
private readonly setTimer: (callback: () => void, delayMs: number) => unknown;
|
||||
private readonly clearTimer: (timer: unknown) => void;
|
||||
private timer: unknown = null;
|
||||
private inFlight = false;
|
||||
private failures = 0;
|
||||
private available = false;
|
||||
|
||||
public constructor(options: DesktopUpdatePollOptions) {
|
||||
this.options = options;
|
||||
this.random = options.random ?? Math.random;
|
||||
this.setTimer = options.setTimer ?? setUnrefTimer;
|
||||
this.clearTimer = options.clearTimer ?? ((timer) => clearTimeout(timer as NodeJS.Timeout));
|
||||
}
|
||||
|
||||
public start(immediately: boolean): void {
|
||||
this.options.logger.info('Polling the desktop update feeds', {
|
||||
intervalMs: this.options.plan.intervalMs,
|
||||
jitterRatio: this.options.plan.jitterRatio,
|
||||
});
|
||||
if (immediately) {
|
||||
this.tick();
|
||||
} else {
|
||||
this.schedule();
|
||||
}
|
||||
}
|
||||
|
||||
public wake(): void {
|
||||
this.tick();
|
||||
}
|
||||
|
||||
private nextDelay(): number {
|
||||
const delay = nextModulePollDelay(this.options.plan, this.random);
|
||||
if (this.failures === 0) {
|
||||
return delay;
|
||||
}
|
||||
return Math.max(
|
||||
delay,
|
||||
jitteredExponentialDelay(
|
||||
this.options.plan.intervalMs,
|
||||
DESKTOP_UPDATE_POLL_BACKOFF_CAP_MS,
|
||||
this.failures,
|
||||
this.random,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
private schedule(): void {
|
||||
this.timer = this.setTimer(() => {
|
||||
this.timer = null;
|
||||
this.tick();
|
||||
}, this.nextDelay());
|
||||
}
|
||||
|
||||
private tick(): void {
|
||||
if (this.inFlight) {
|
||||
return;
|
||||
}
|
||||
if (this.timer != null) {
|
||||
this.clearTimer(this.timer);
|
||||
this.timer = null;
|
||||
}
|
||||
this.inFlight = true;
|
||||
const {logger} = this.options;
|
||||
void this.options
|
||||
.check()
|
||||
.then((state) => {
|
||||
if (this.failures > 0) {
|
||||
logger.info('The desktop update poll recovered', {failures: this.failures});
|
||||
this.failures = 0;
|
||||
}
|
||||
if (state.available && !this.available) {
|
||||
logger.info('A desktop update is available, waiting for the user to start it');
|
||||
}
|
||||
this.available = state.available;
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
this.failures += 1;
|
||||
if (this.failures === 1) {
|
||||
logger.warn('The desktop update poll failed, backing off until it recovers', error);
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
this.inFlight = false;
|
||||
this.schedule();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let armedPoll: DesktopUpdatePoll | null = null;
|
||||
|
||||
export function armDesktopUpdatePoll(options: DesktopUpdatePollOptions, immediately: boolean): void {
|
||||
if (armedPoll != null) {
|
||||
return;
|
||||
}
|
||||
armedPoll = new DesktopUpdatePoll(options);
|
||||
armedPoll.start(immediately);
|
||||
}
|
||||
|
||||
export function wakeDesktopUpdatePoll(): void {
|
||||
armedPoll?.wake();
|
||||
}
|
||||
@@ -41,7 +41,7 @@ async function runUpdateCheck(): Promise<void> {
|
||||
for (;;) {
|
||||
let manualVersion: string | null = null;
|
||||
try {
|
||||
const state = await checkDesktopUpdateNow();
|
||||
const state = await checkDesktopUpdateNow({manual: true});
|
||||
if (state.updating === true) {
|
||||
logger.info('A desktop update is already running, leaving it to finish');
|
||||
return;
|
||||
@@ -129,7 +129,7 @@ export function startDesktopUpdateFromShell(): void {
|
||||
|
||||
export async function updateFromCommandLine(): Promise<void> {
|
||||
try {
|
||||
const state = await checkDesktopUpdateNow();
|
||||
const state = await checkDesktopUpdateNow({manual: true});
|
||||
if (!state.available) {
|
||||
logger.info('The command line asked for an update and none is waiting');
|
||||
return;
|
||||
|
||||
@@ -16,6 +16,7 @@ function harness({
|
||||
shellResults = [],
|
||||
installFails = false,
|
||||
settled = true,
|
||||
now = () => 0,
|
||||
} = {}) {
|
||||
const steps = [];
|
||||
const published = [];
|
||||
@@ -70,6 +71,7 @@ function harness({
|
||||
failures.push(failure);
|
||||
},
|
||||
logger: {info: quiet, warn: quiet, error: quiet},
|
||||
now,
|
||||
});
|
||||
if (settled) run.markLaunchSettled();
|
||||
return {run, steps, published, failures};
|
||||
@@ -144,6 +146,36 @@ describe('DesktopUpdateRun', () => {
|
||||
assert.deepEqual(steps, ['probe', 'takeover', 'close', 'shell-update', 'reopen', 'probe', 'probe']);
|
||||
});
|
||||
|
||||
test('a manual check offers the shell again even after the platform feed said it had nothing', async () => {
|
||||
const lagging = {shellLatestVersion: '2026.1012.154032', shellNewer: true, modulesChanged: false};
|
||||
const {run} = harness({probes: [lagging], shellResults: [{reason: 'no-update', detail: null}]});
|
||||
await run.start();
|
||||
assert.deepEqual(await run.check(), {shellNewer: false, modulesChanged: false});
|
||||
|
||||
assert.deepEqual(
|
||||
await run.check({manual: true}),
|
||||
{shellNewer: true, modulesChanged: false},
|
||||
'one stale answer from the platform feed must not make Check for updates say up to date for the rest of the session',
|
||||
);
|
||||
assert.deepEqual(await run.check(), {shellNewer: true, modulesChanged: false});
|
||||
});
|
||||
|
||||
test('the background check offers a lagging shell again once the platform feed had time to catch up', async () => {
|
||||
let clock = 0;
|
||||
const lagging = {shellLatestVersion: '2026.1012.154032', shellNewer: true, modulesChanged: false};
|
||||
const {run} = harness({
|
||||
probes: [lagging],
|
||||
shellResults: [{reason: 'no-update', detail: null}],
|
||||
now: () => clock,
|
||||
});
|
||||
await run.start();
|
||||
|
||||
clock = 9 * 60_000;
|
||||
assert.deepEqual(await run.check(), {shellNewer: false, modulesChanged: false});
|
||||
clock = 10 * 60_000;
|
||||
assert.deepEqual(await run.check(), {shellNewer: true, modulesChanged: false});
|
||||
});
|
||||
|
||||
test('a shell update that failed on the network is retried by the next click', async () => {
|
||||
const shell = {shellLatestVersion: '2026.1008.2', shellNewer: true, modulesChanged: false};
|
||||
const {run, steps, published} = harness({
|
||||
|
||||
@@ -43,6 +43,8 @@ interface DesktopUpdateRunOptions {
|
||||
readonly now?: () => number;
|
||||
}
|
||||
|
||||
const SHELL_FEED_BEHIND_RETRY_MS = 10 * 60_000;
|
||||
|
||||
interface FilteredProbe {
|
||||
readonly check: DesktopUpdateCheck;
|
||||
readonly shellLatestVersion: string;
|
||||
@@ -52,7 +54,7 @@ export class DesktopUpdateRun {
|
||||
private readonly options: DesktopUpdateRunOptions;
|
||||
private readonly now: () => number;
|
||||
private launchSettled = false;
|
||||
private shellFeedBehindVersion: string | null = null;
|
||||
private shellFeedBehind: {readonly version: string; readonly at: number} | null = null;
|
||||
private shellInstallFailed = false;
|
||||
private lastShellFailure: ShellSelfUpdateResult | null = null;
|
||||
private lastCheck: DesktopUpdateCheck | null = null;
|
||||
@@ -66,7 +68,10 @@ export class DesktopUpdateRun {
|
||||
this.launchSettled = true;
|
||||
}
|
||||
|
||||
public async check(): Promise<DesktopUpdateCheck> {
|
||||
public async check({manual = false}: {readonly manual?: boolean} = {}): Promise<DesktopUpdateCheck> {
|
||||
if (manual) {
|
||||
this.shellFeedBehind = null;
|
||||
}
|
||||
return (await this.probe()).check;
|
||||
}
|
||||
|
||||
@@ -188,7 +193,7 @@ export class DesktopUpdateRun {
|
||||
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;
|
||||
this.shellFeedBehind = {version: probe.shellLatestVersion, at: this.now()};
|
||||
return {shellNewer: false, modulesChanged: probe.check.modulesChanged};
|
||||
}
|
||||
if (result.reason === 'install-failed') {
|
||||
@@ -201,13 +206,18 @@ export class DesktopUpdateRun {
|
||||
const probe = await this.options.probe();
|
||||
const check = {
|
||||
shellNewer:
|
||||
this.options.canSelfUpdateShell && probe.shellNewer && probe.shellLatestVersion !== this.shellFeedBehindVersion,
|
||||
this.options.canSelfUpdateShell && probe.shellNewer && !this.isShellFeedBehind(probe.shellLatestVersion),
|
||||
modulesChanged: probe.modulesChanged,
|
||||
};
|
||||
this.lastCheck = check;
|
||||
return {check, shellLatestVersion: probe.shellLatestVersion};
|
||||
}
|
||||
|
||||
private isShellFeedBehind(version: string): boolean {
|
||||
const behind = this.shellFeedBehind;
|
||||
return behind != null && behind.version === version && this.now() - behind.at < SHELL_FEED_BEHIND_RETRY_MS;
|
||||
}
|
||||
|
||||
private publish(check: DesktopUpdateCheck): void {
|
||||
this.lastCheck = check;
|
||||
this.options.publish(check);
|
||||
|
||||
@@ -10,11 +10,13 @@ import {
|
||||
parseModuleUpdateManifest,
|
||||
} from '@electron/main/ModuleManifest';
|
||||
import type {ModuleStore} from '@electron/main/ModuleStore';
|
||||
import {type ModuleVersion, parseModuleVersion} from '@electron/main/ModuleVersion';
|
||||
|
||||
const CHANNEL_MANIFEST_CACHE_FILE_NAME = 'manifest.json';
|
||||
const MODULE_MANIFEST_TIMEOUT_MS = 15000;
|
||||
const MODULE_MANIFEST_MEMO_TTL_MS = 60000;
|
||||
const MODULE_MANIFEST_MAX_BYTES = 1024 * 1024;
|
||||
const SHELL_FEED_MAX_BYTES = 64 * 1024;
|
||||
const MODULE_CACHE_FILE_MODE = 0o644;
|
||||
|
||||
export interface ModuleManifestDocument {
|
||||
@@ -93,6 +95,8 @@ export class ModuleManifestRepository {
|
||||
private memo: {readonly expiresAt: number; readonly document: ModuleManifestDocument} | null = null;
|
||||
private inFlight: Promise<ModuleManifestDocument> | null = null;
|
||||
private memoEpoch = 0;
|
||||
private validated: ModuleManifestDocument | null = null;
|
||||
private shellFeed: {readonly etag: string; readonly version: ModuleVersion} | null = null;
|
||||
|
||||
public constructor(options: ModuleManifestRepositoryOptions) {
|
||||
this.store = options.store;
|
||||
@@ -158,9 +162,45 @@ export class ModuleManifestRepository {
|
||||
await writeFileAtomically(this.manifestCachePath, document.bytes, MODULE_CACHE_FILE_MODE);
|
||||
}
|
||||
|
||||
public async fetchShellLatestVersion(): Promise<ModuleVersion> {
|
||||
const url = this.shellFeedUrl();
|
||||
const known = this.shellFeed;
|
||||
const headers: Record<string, string> = {accept: 'application/json'};
|
||||
if (known != null) {
|
||||
headers['if-none-match'] = known.etag;
|
||||
}
|
||||
let response: Response;
|
||||
try {
|
||||
response = await this.fetchImplementation(url, {
|
||||
headers,
|
||||
signal: AbortSignal.timeout(MODULE_MANIFEST_TIMEOUT_MS),
|
||||
});
|
||||
} catch (error) {
|
||||
throw new ModuleManifestFetchError(`failed to reach ${url}`, null, {cause: error});
|
||||
}
|
||||
if (response.status === 304 && known != null) {
|
||||
return known.version;
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw new ModuleManifestFetchError(`shell feed request returned ${response.status}`, response.status);
|
||||
}
|
||||
const bytes = await this.readBoundedBody(response, SHELL_FEED_MAX_BYTES, 'shell feed');
|
||||
const payload = JSON.parse(bytes.toString('utf8')) as {version?: unknown};
|
||||
if (typeof payload.version !== 'string') {
|
||||
throw new ModuleManifestFetchError('shell feed is missing its version', response.status);
|
||||
}
|
||||
const version = parseModuleVersion(payload.version, 'shell feed version');
|
||||
const etag = response.headers.get('etag');
|
||||
this.shellFeed = etag == null ? null : {etag, version};
|
||||
return version;
|
||||
}
|
||||
|
||||
private async readLatestDocument(): Promise<ModuleManifestDocument> {
|
||||
const etag = this.store.getState().last_manifest_etag;
|
||||
const first = await this.requestManifest(etag);
|
||||
const validated = this.validated;
|
||||
if (validated != null) {
|
||||
return (await this.requestManifest(validated.etag)) ?? validated;
|
||||
}
|
||||
const first = await this.requestManifest(this.store.getState().last_manifest_etag);
|
||||
if (first != null) {
|
||||
return first;
|
||||
}
|
||||
@@ -175,12 +215,20 @@ export class ModuleManifestRepository {
|
||||
return refetched;
|
||||
}
|
||||
|
||||
private manifestUrl(): string {
|
||||
private feedUrl(fileName: string): string {
|
||||
return new URL(
|
||||
`${this.packageOrigin}/desktop/${this.target.releaseChannel}/${this.target.platform}/${this.target.arch}/modules.json`,
|
||||
`${this.packageOrigin}/desktop/${this.target.releaseChannel}/${this.target.platform}/${this.target.arch}/${fileName}`,
|
||||
).toString();
|
||||
}
|
||||
|
||||
private manifestUrl(): string {
|
||||
return this.feedUrl('modules.json');
|
||||
}
|
||||
|
||||
private shellFeedUrl(): string {
|
||||
return this.feedUrl('latest.json');
|
||||
}
|
||||
|
||||
private async requestManifest(ifNoneMatch: string | null): Promise<ModuleManifestDocument | null> {
|
||||
const url = this.manifestUrl();
|
||||
const headers: Record<string, string> = {accept: 'application/json'};
|
||||
@@ -204,7 +252,9 @@ export class ModuleManifestRepository {
|
||||
}
|
||||
const bytes = await this.readBoundedBody(response, MODULE_MANIFEST_MAX_BYTES, 'manifest');
|
||||
parseModuleUpdateManifest(bytes, this.target);
|
||||
return {bytes, etag: response.headers.get('etag'), fromCache: false};
|
||||
const document = {bytes, etag: response.headers.get('etag'), fromCache: false};
|
||||
this.validated = document.etag == null ? null : document;
|
||||
return document;
|
||||
}
|
||||
|
||||
private async readBoundedBody(response: Response, limit: number, description: string): Promise<Buffer> {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import {Buffer} from 'node:buffer';
|
||||
import {createHash} from 'node:crypto';
|
||||
import {mkdirSync, mkdtempSync, writeFileSync} from 'node:fs';
|
||||
import {mkdirSync, mkdtempSync, rmSync, writeFileSync} from 'node:fs';
|
||||
import {rm} from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
@@ -129,7 +129,14 @@ function createUpdater(store, modules, options = {}) {
|
||||
fetch: async (url, init) => {
|
||||
requests.push(url);
|
||||
const response = options.respond == null ? null : await options.respond(url, init);
|
||||
return response ?? new Response(bytes, {status: 200, headers: {etag: '"manifest-1"'}});
|
||||
if (response != null) {
|
||||
return response;
|
||||
}
|
||||
if (String(url).endsWith('/latest.json')) {
|
||||
const version = options.shellFeed ?? options.shellLatest ?? SHELL_VERSION;
|
||||
return new Response(JSON.stringify({version}), {status: 200, headers: {etag: `"feed-${version}"`}});
|
||||
}
|
||||
return new Response(bytes, {status: 200, headers: {etag: '"manifest-1"'}});
|
||||
},
|
||||
sleep: async (ms) => {
|
||||
sleeps.push(ms);
|
||||
@@ -412,7 +419,7 @@ describe('ModuleUpdater.run', () => {
|
||||
assert.equal(store.getState().boot_attempt, 2);
|
||||
});
|
||||
|
||||
test('a manifest older than the one already seen launches the verified set without retrying', async () => {
|
||||
test('a manifest older than the one already seen launches on the last accepted manifest without retrying', async () => {
|
||||
const userDataPath = createUserData();
|
||||
const store = await openStore(userDataPath);
|
||||
installModuleOnDisk(getModuleStoreRoot(userDataPath), 'fluxer_renderer', RENDERER_SHA, [['index.js', 'renderer']]);
|
||||
@@ -423,13 +430,30 @@ describe('ModuleUpdater.run', () => {
|
||||
|
||||
const outcome = await updater.run();
|
||||
|
||||
assert.equal(outcome.status, 'unreachable-launch');
|
||||
assert.equal(outcome.status, 'launching');
|
||||
assert.deepEqual(outcome.committed, {fluxer_renderer: RENDERER_SHA});
|
||||
assert.deepEqual(sleeps, []);
|
||||
assert.deepEqual(
|
||||
reports.map((report) => report.type),
|
||||
['manifest-rollback'],
|
||||
);
|
||||
assert.deepEqual(reports, []);
|
||||
assert.equal(store.getState().manifest_high_water[0].metadataVersion, 5);
|
||||
});
|
||||
|
||||
test('an older manifest with no accepted copy to fall back on retries like an outage instead of blocking', async () => {
|
||||
const userDataPath = createUserData();
|
||||
const storeRoot = getModuleStoreRoot(userDataPath);
|
||||
const store = await openStore(userDataPath);
|
||||
installModuleOnDisk(storeRoot, 'fluxer_renderer', RENDERER_SHA, [['index.js', 'renderer']]);
|
||||
await store.commit({fluxer_renderer: RENDERER_SHA});
|
||||
const seen = createUpdater(store, {fluxer_renderer: RENDERER_SHA}, {metadataVersion: 5});
|
||||
assert.equal((await seen.updater.run()).status, 'launching');
|
||||
rmSync(path.join(storeRoot, 'manifest.json'));
|
||||
const {updater, sleeps, reports} = createUpdater(store, {fluxer_renderer: NEXT_RENDERER_SHA}, {metadataVersion: 4});
|
||||
|
||||
const outcome = await updater.run();
|
||||
|
||||
assert.equal(outcome.status, 'unreachable-launch');
|
||||
assert.deepEqual(outcome.committed, {fluxer_renderer: RENDERER_SHA});
|
||||
assert.ok(sleeps.length > 0, 'a stale edge copy clears within a minute, so the launch retries before giving up');
|
||||
assert.ok(reports.every((report) => report.type === 'manifest-rollback'));
|
||||
});
|
||||
|
||||
test('a package the server no longer has demotes an optional module to its committed digest', async () => {
|
||||
@@ -501,7 +525,7 @@ describe('ModuleUpdater checks without downloading and installs only when asked'
|
||||
assert.deepEqual(check, {shellLatestVersion: SHELL_VERSION, shellNewer: false, modulesChanged: true});
|
||||
assert.deepEqual(
|
||||
requests.map((url) => String(url).split('/').pop()),
|
||||
['modules.json'],
|
||||
['modules.json', 'latest.json'],
|
||||
);
|
||||
assert.deepEqual(installs, []);
|
||||
assert.deepEqual(store.getCommitted(), {fluxer_renderer: RENDERER_SHA});
|
||||
@@ -521,6 +545,96 @@ describe('ModuleUpdater checks without downloading and installs only when asked'
|
||||
});
|
||||
});
|
||||
|
||||
test('a shell feed ahead of the manifest offers the newer shell', async () => {
|
||||
const {store} = await storeOnRenderer();
|
||||
const {updater} = createUpdater(store, {fluxer_renderer: RENDERER_SHA}, {shellFeed: '2026.1012.154032'});
|
||||
|
||||
assert.deepEqual(await updater.checkForUpdate(), {
|
||||
shellLatestVersion: '2026.1012.154032',
|
||||
shellNewer: true,
|
||||
modulesChanged: false,
|
||||
});
|
||||
});
|
||||
|
||||
test('a shell feed behind the manifest never hides the shell the manifest names', async () => {
|
||||
const {store} = await storeOnRenderer();
|
||||
const {updater} = createUpdater(
|
||||
store,
|
||||
{fluxer_renderer: RENDERER_SHA},
|
||||
{shellLatest: '2026.824.1', shellFeed: SHELL_VERSION},
|
||||
);
|
||||
|
||||
assert.deepEqual(await updater.checkForUpdate(), {
|
||||
shellLatestVersion: '2026.824.1',
|
||||
shellNewer: true,
|
||||
modulesChanged: false,
|
||||
});
|
||||
});
|
||||
|
||||
test('an unreachable shell feed still answers from the manifest', async () => {
|
||||
const {store} = await storeOnRenderer();
|
||||
const {updater} = createUpdater(
|
||||
store,
|
||||
{fluxer_renderer: NEXT_RENDERER_SHA},
|
||||
{respond: async (url) => (String(url).endsWith('/latest.json') ? new Response('', {status: 503}) : null)},
|
||||
);
|
||||
|
||||
assert.deepEqual(await updater.checkForUpdate(), {
|
||||
shellLatestVersion: SHELL_VERSION,
|
||||
shellNewer: false,
|
||||
modulesChanged: true,
|
||||
});
|
||||
});
|
||||
|
||||
test('repeat checks revalidate both feeds and reuse them when nothing changed', async () => {
|
||||
const {store} = await storeOnRenderer();
|
||||
const conditions = [];
|
||||
const {updater} = createUpdater(
|
||||
store,
|
||||
{fluxer_renderer: NEXT_RENDERER_SHA},
|
||||
{
|
||||
shellFeed: '2026.824.1',
|
||||
respond: async (url, init) => {
|
||||
const etag = init?.headers?.['if-none-match'] ?? null;
|
||||
conditions.push([String(url).split('/').pop(), etag]);
|
||||
return etag == null ? null : new Response(null, {status: 304});
|
||||
},
|
||||
},
|
||||
);
|
||||
const first = await updater.checkForUpdate();
|
||||
|
||||
const second = await updater.checkForUpdate();
|
||||
|
||||
assert.deepEqual(second, first);
|
||||
assert.deepEqual(second, {shellLatestVersion: '2026.824.1', shellNewer: true, modulesChanged: true});
|
||||
assert.deepEqual(conditions.slice(-2), [
|
||||
['modules.json', '"manifest-1"'],
|
||||
['latest.json', '"feed-2026.824.1"'],
|
||||
]);
|
||||
});
|
||||
|
||||
test('an older manifest during a background check reads as nothing new instead of failing', async () => {
|
||||
const {store} = await storeOnRenderer();
|
||||
let metadataVersion = 6;
|
||||
const {updater} = createUpdater(
|
||||
store,
|
||||
{fluxer_renderer: NEXT_RENDERER_SHA},
|
||||
{
|
||||
respond: async (url) =>
|
||||
String(url).endsWith('/modules.json')
|
||||
? new Response(manifestBytes({fluxer_renderer: NEXT_RENDERER_SHA}, {metadataVersion}), {status: 200})
|
||||
: null,
|
||||
},
|
||||
);
|
||||
const fresh = await updater.checkForUpdate();
|
||||
metadataVersion = 5;
|
||||
|
||||
const stale = await updater.checkForUpdate();
|
||||
|
||||
assert.deepEqual(stale, fresh);
|
||||
assert.deepEqual(stale, {shellLatestVersion: SHELL_VERSION, shellNewer: false, modulesChanged: true});
|
||||
});
|
||||
|
||||
test('a module that needs the next shell reads as a shell update, not as a module update', async () => {
|
||||
const {store} = await storeOnRenderer();
|
||||
const {updater} = createUpdater(
|
||||
|
||||
@@ -324,6 +324,7 @@ export class ModuleUpdater {
|
||||
private startupUpdatePolicy: ModuleStartupUpdatePolicy;
|
||||
private manifestObservationTail: Promise<void> = Promise.resolve();
|
||||
private recordedManifestSha256: string | null = null;
|
||||
private lastAccepted: {readonly document: ModuleManifestDocument; readonly metadataVersion: number} | null = null;
|
||||
|
||||
public constructor(options: ModuleUpdaterOptions) {
|
||||
this.store = options.store;
|
||||
@@ -448,7 +449,7 @@ export class ModuleUpdater {
|
||||
try {
|
||||
const document = await this.manifestRepository.fetchLatest();
|
||||
fetched = true;
|
||||
const manifest = await this.observeManifest(document);
|
||||
const manifest = await this.acceptManifest(document);
|
||||
const startupUpdateRequired = await this.startupUpdateRequired();
|
||||
if (!startupUpdateRequired) {
|
||||
return await this.launchIfAtFloor();
|
||||
@@ -502,7 +503,9 @@ export class ModuleUpdater {
|
||||
}
|
||||
const delayMs = backoff.fail();
|
||||
const exhausted =
|
||||
backoff.reachedCeiling || isRejectedManifestError(error) || (await this.onlyForcedByShellChange());
|
||||
backoff.reachedCeiling ||
|
||||
error instanceof ModuleManifestEquivocationError ||
|
||||
(await this.onlyForcedByShellChange());
|
||||
if (exhausted || !fetched) {
|
||||
const decision = await this.planner.evaluateUnreachableLaunch(
|
||||
this.isStartupUpdateEnforced(),
|
||||
@@ -623,7 +626,7 @@ export class ModuleUpdater {
|
||||
}
|
||||
|
||||
private async resolveManifestForEnsure(): Promise<DesktopModuleUpdateManifest> {
|
||||
return this.observeManifest(await this.manifestRepository.resolveMemoizedDocument());
|
||||
return this.acceptManifest(await this.manifestRepository.resolveMemoizedDocument());
|
||||
}
|
||||
|
||||
private async updateShellFirst(manifest: DesktopModuleUpdateManifest): Promise<boolean> {
|
||||
@@ -642,9 +645,19 @@ export class ModuleUpdater {
|
||||
}
|
||||
|
||||
public async checkForUpdate(): Promise<DesktopUpdateProbe> {
|
||||
const manifest = await this.fetchFreshManifest(ModuleManifestObservationMode.READ_ONLY);
|
||||
const shellLatestVersion = manifest.shell.latestVersion.source;
|
||||
const shellNewer = compareModuleVersions(manifest.shell.latestVersion, this.shellVersion) > 0;
|
||||
const [manifest, feedLatest] = await Promise.all([
|
||||
this.fetchFreshManifest(ModuleManifestObservationMode.READ_ONLY),
|
||||
this.manifestRepository.fetchShellLatestVersion().catch((error: unknown) => {
|
||||
this.reportFailure(error);
|
||||
return null;
|
||||
}),
|
||||
]);
|
||||
const shellLatest =
|
||||
feedLatest != null && compareModuleVersions(feedLatest, manifest.shell.latestVersion) > 0
|
||||
? feedLatest
|
||||
: manifest.shell.latestVersion;
|
||||
const shellLatestVersion = shellLatest.source;
|
||||
const shellNewer = compareModuleVersions(shellLatest, this.shellVersion) > 0;
|
||||
if (this.planner.shellUpdateRequired(manifest)) {
|
||||
return {shellLatestVersion, shellNewer, modulesChanged: false};
|
||||
}
|
||||
@@ -672,7 +685,25 @@ export class ModuleUpdater {
|
||||
|
||||
private async fetchFreshManifest(mode: ModuleManifestObservationMode): Promise<DesktopModuleUpdateManifest> {
|
||||
this.manifestRepository.clearMemo();
|
||||
return await this.observeManifest(await this.manifestRepository.fetchLatest(), mode);
|
||||
return await this.acceptManifest(await this.manifestRepository.fetchLatest(), mode);
|
||||
}
|
||||
|
||||
private async acceptManifest(
|
||||
document: ModuleManifestDocument,
|
||||
mode: ModuleManifestObservationMode = ModuleManifestObservationMode.RECORD_FETCH,
|
||||
): Promise<DesktopModuleUpdateManifest> {
|
||||
try {
|
||||
return await this.observeManifest(document, mode);
|
||||
} catch (error) {
|
||||
if (!(error instanceof ModuleManifestRollbackError)) {
|
||||
throw error;
|
||||
}
|
||||
const fallback = this.lastAccepted?.document ?? (await this.manifestRepository.readCached());
|
||||
if (fallback == null || fallback === document) {
|
||||
throw error;
|
||||
}
|
||||
return await this.observeManifest(fallback, mode);
|
||||
}
|
||||
}
|
||||
|
||||
private async collectPendingItems(
|
||||
@@ -923,6 +954,10 @@ export class ModuleUpdater {
|
||||
): Promise<DesktopModuleUpdateManifest> {
|
||||
const observe = async (): Promise<DesktopModuleUpdateManifest> => {
|
||||
const manifest = this.manifestRepository.parse(document);
|
||||
const accepted = this.lastAccepted;
|
||||
if (accepted != null && manifest.metadataVersion < accepted.metadataVersion) {
|
||||
throw new ModuleManifestRollbackError(this.manifestFeed, manifest.metadataVersion, accepted.metadataVersion);
|
||||
}
|
||||
const observation: ModuleManifestFeedObservation = {
|
||||
feed: this.manifestFeed,
|
||||
metadataVersion: manifest.metadataVersion,
|
||||
@@ -937,6 +972,7 @@ export class ModuleUpdater {
|
||||
await this.persistManifest(document, manifest, observation);
|
||||
this.recordedManifestSha256 = observation.manifestSha256;
|
||||
}
|
||||
this.lastAccepted = {document, metadataVersion: manifest.metadataVersion};
|
||||
return manifest;
|
||||
};
|
||||
const observed = this.manifestObservationTail.then(observe, observe);
|
||||
|
||||
@@ -148,7 +148,7 @@ function registerSelfUpdater(getMainWindow: () => BrowserWindow | null): void {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await checkDesktopUpdateNow();
|
||||
await checkDesktopUpdateNow({manual: true});
|
||||
send(getMainWindow(), {type: 'not-available', context});
|
||||
} catch (error) {
|
||||
log.warn('Desktop update check failed', error);
|
||||
@@ -164,7 +164,7 @@ function registerManualUpdater(
|
||||
ipcMain.handle('updater-check', async (_e, context: UpdaterContext) => {
|
||||
if (context === 'user') {
|
||||
try {
|
||||
await checkDesktopUpdateNow();
|
||||
await checkDesktopUpdateNow({manual: true});
|
||||
} catch (error) {
|
||||
log.warn('Desktop module update check failed', error);
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ import {getDesktopDistributionPath} from '@electron/main/DesktopDistributionPath
|
||||
import {resolveDesktopLandingUrl} from '@electron/main/DesktopLastRoute';
|
||||
import {DesktopRuntimeSecurity} from '@electron/main/DesktopRuntimeSecurity';
|
||||
import {hasActiveDesktopTray, refreshDesktopTrayMenu} from '@electron/main/DesktopTray';
|
||||
import {wakeDesktopUpdatePoll} from '@electron/main/DesktopUpdatePoll';
|
||||
import {drainPendingDisplayMediaRequests, registerDisplayMediaRequestHandler} from '@electron/main/DisplayMedia';
|
||||
import {isNativeGatewayAvailable} from '@electron/main/GatewaySocketNativeBoundary';
|
||||
import {shouldDisableV8CodeCache} from '@electron/main/LaunchOptions';
|
||||
@@ -856,6 +857,7 @@ export function createWindow(options: CreateWindowOptions = {}): BrowserWindow {
|
||||
});
|
||||
mainWindow.on('restore', refreshDesktopTrayMenu);
|
||||
mainWindow.on('show', refreshDesktopTrayMenu);
|
||||
mainWindow.on('show', wakeDesktopUpdatePoll);
|
||||
mainWindow.on('hide', refreshDesktopTrayMenu);
|
||||
mainWindow.on('close', (event) => {
|
||||
if (saveTimeout) clearTimeout(saveTimeout);
|
||||
|
||||
@@ -7,7 +7,7 @@ publish = false
|
||||
|
||||
[dependencies]
|
||||
fluxer_common = { path = "../fluxer_common" }
|
||||
ammonia = "4.2.0"
|
||||
ammonia = "4.2.3"
|
||||
anyhow = "1.0.104"
|
||||
chrono = { version = "0.4.45", default-features = false, features = ["std"] }
|
||||
encoding_rs = "0.8.41"
|
||||
|
||||
Reference in new issue
Block a user