mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-17 21:15:32 +09:00
fix(desktop): update from the splash only after the user clicks (#3280)
This commit is contained in:
1 parent
f1373a63c3
commit
7937d5f802
90 files changed
+6888
-11255
No files matched your search
@@ -2,7 +2,7 @@
|
||||
|
||||
import assert from 'node:assert/strict';
|
||||
import {createHash} from 'node:crypto';
|
||||
import {chmodSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, writeFileSync} from 'node:fs';
|
||||
import {mkdirSync, mkdtempSync, readdirSync, readFileSync, writeFileSync} from 'node:fs';
|
||||
import {createServer} from 'node:http';
|
||||
import {createRequire} from 'node:module';
|
||||
import {tmpdir} from 'node:os';
|
||||
@@ -34,14 +34,6 @@ const shellDownloadFormatsSource = transform('ShellDownloadFormats.ts');
|
||||
const shellUpdateCapabilitySource = transform('ShellUpdateCapability.ts');
|
||||
const updaterDownloadsSource = transform('UpdaterDownloads.ts');
|
||||
const updaterSource = transform('Updater.ts');
|
||||
const updaterPlatformUtilsSource = transform('../../../fluxer_app/src/features/app/utils/UpdaterPlatformUtils.ts');
|
||||
|
||||
function loadUpdaterPlatformUtils() {
|
||||
const module = {exports: {}};
|
||||
const context = vm.createContext({module, exports: module.exports, require});
|
||||
vm.runInContext(updaterPlatformUtilsSource.code, context, {filename: updaterPlatformUtilsSource.path});
|
||||
return module.exports;
|
||||
}
|
||||
|
||||
const INSTALLED_NAME = 'My Fluxer.AppImage';
|
||||
const CURRENT_VERSION = '2026.903.231208';
|
||||
@@ -52,9 +44,7 @@ const NEW_SHA256 = createHash('sha256').update(NEW_BYTES).digest('hex');
|
||||
|
||||
let server;
|
||||
let baseUrl;
|
||||
let serveCorruptBytes = false;
|
||||
let appImageRequests = 0;
|
||||
let failAppImageRequests = 0;
|
||||
|
||||
before(async () => {
|
||||
server = createServer((request, response) => {
|
||||
@@ -75,15 +65,8 @@ before(async () => {
|
||||
}
|
||||
if (request.url === `/${PUBLISHED_VERSION}/appimage`) {
|
||||
appImageRequests += 1;
|
||||
if (failAppImageRequests > 0) {
|
||||
failAppImageRequests -= 1;
|
||||
response.writeHead(503);
|
||||
response.end('try again');
|
||||
return;
|
||||
}
|
||||
const body = serveCorruptBytes ? Buffer.concat([Buffer.from([0]), NEW_BYTES.subarray(1)]) : NEW_BYTES;
|
||||
response.writeHead(200, {'content-type': 'application/octet-stream', 'content-length': body.length});
|
||||
response.end(body);
|
||||
response.writeHead(200, {'content-type': 'application/octet-stream', 'content-length': NEW_BYTES.length});
|
||||
response.end(NEW_BYTES);
|
||||
return;
|
||||
}
|
||||
response.writeHead(404);
|
||||
@@ -97,74 +80,38 @@ after(async () => {
|
||||
await new Promise((resolve) => server.close(resolve));
|
||||
});
|
||||
|
||||
function createInstall() {
|
||||
const root = mkdtempSync(join(tmpdir(), 'fluxer-updater-test-'));
|
||||
const applications = join(root, 'Applications');
|
||||
mkdirSync(applications);
|
||||
const mount = join(root, '.mount_Fluxer');
|
||||
mkdirSync(mount);
|
||||
writeFileSync(join(mount, 'AppRun'), '#!/usr/bin/env bash\n', {mode: 0o755});
|
||||
const installedPath = join(applications, INSTALLED_NAME);
|
||||
writeFileSync(installedPath, OLD_BYTES, {mode: 0o755});
|
||||
return {applications, installedPath, mount};
|
||||
}
|
||||
|
||||
function loadUpdater({
|
||||
appImagePath,
|
||||
version = CURRENT_VERSION,
|
||||
appDir,
|
||||
execPath,
|
||||
stagingErrno,
|
||||
platform = 'linux',
|
||||
arch = 'arm64',
|
||||
velopack,
|
||||
applyAttempt = null,
|
||||
packaged = true,
|
||||
flatpak = false,
|
||||
gateCheck = async () => ({available: true}),
|
||||
}) {
|
||||
const events = [];
|
||||
const handlers = new Map();
|
||||
const appEvents = new Map();
|
||||
const state = {relaunched: false, electronRelaunched: false};
|
||||
const applyState = {attempt: applyAttempt, recorded: [], cleared: 0};
|
||||
const gate = {checks: 0};
|
||||
const module = {exports: {}};
|
||||
const stubs = {
|
||||
'@electron/main/UpdaterApplyState': {
|
||||
readVelopackApplyAttempt: () => applyState.attempt,
|
||||
recordVelopackApplyAttempt: (recordedVersion) => {
|
||||
applyState.recorded.push(recordedVersion);
|
||||
applyState.attempt = {version: recordedVersion, attemptedAt: 0};
|
||||
},
|
||||
clearVelopackApplyAttempt: () => {
|
||||
applyState.cleared += 1;
|
||||
applyState.attempt = null;
|
||||
},
|
||||
},
|
||||
'@electron/common/BuildChannel': {BUILD_CHANNEL: 'canary'},
|
||||
'@electron/common/Constants': {DOWNLOAD_PAGE_URLS: {canary: 'https://canary.fluxer.app/download'}},
|
||||
'@electron/common/DesktopIdentity': {DESKTOP_ARTIFACT_PRODUCT_NAME: 'Fluxer-Canary'},
|
||||
'@electron/common/UserDataPath': {isPortableMode: () => false},
|
||||
'@electron/main/DesktopTray': {destroyDesktopTray() {}},
|
||||
'@electron/main/ModuleNetworkFetch': {moduleNetworkFetch: (input, init) => sandbox.fetch(input, init)},
|
||||
'@electron/main/LinuxSandbox': {isFlatpakRuntime: () => false},
|
||||
'@electron/main/Troubleshooting': {
|
||||
relaunchAndExit() {
|
||||
state.relaunched = true;
|
||||
'@electron/main/DesktopUpdateGate': {
|
||||
checkDesktopUpdateNow: async () => {
|
||||
gate.checks += 1;
|
||||
return await gateCheck();
|
||||
},
|
||||
},
|
||||
'@electron/main/Window': {setQuitting() {}},
|
||||
'@electron/main/LinuxSandbox': {isFlatpakRuntime: () => flatpak},
|
||||
'electron-log': {info() {}, warn() {}, error() {}, debug() {}},
|
||||
electron: {
|
||||
app: {
|
||||
isPackaged: true,
|
||||
isPackaged: packaged,
|
||||
getVersion: () => version,
|
||||
on(event, handler) {
|
||||
appEvents.set(event, handler);
|
||||
},
|
||||
relaunch() {
|
||||
state.electronRelaunched = true;
|
||||
},
|
||||
exit() {},
|
||||
},
|
||||
autoUpdater: {on() {}},
|
||||
net: {fetch: (input, init) => sandbox.fetch(input, init)},
|
||||
ipcMain: {
|
||||
handle(channel, handler) {
|
||||
@@ -176,11 +123,12 @@ function loadUpdater({
|
||||
const sandbox = {
|
||||
console,
|
||||
Buffer,
|
||||
URL,
|
||||
process: {
|
||||
...process,
|
||||
platform,
|
||||
arch,
|
||||
execPath: execPath ?? (appDir ? join(appDir, 'fluxer-canary') : process.execPath),
|
||||
execPath: appDir ? join(appDir, 'fluxer-canary') : process.execPath,
|
||||
env: {
|
||||
...(appImagePath ? {APPIMAGE: appImagePath} : {}),
|
||||
...(appDir ? {APPDIR: appDir} : {}),
|
||||
@@ -193,56 +141,21 @@ function loadUpdater({
|
||||
const url = String(input).replace(/https:\/\/pkgs\.fluxer\.com\/desktop\/canary\/[^/]+\/[^/]+/, baseUrl);
|
||||
return fetch(url, init);
|
||||
},
|
||||
require: (specifier) => {
|
||||
if (specifier === 'node:module' && velopack) {
|
||||
return {
|
||||
...require('node:module'),
|
||||
createRequire: () => (name) => (name === 'velopack' ? velopack : require(name)),
|
||||
};
|
||||
}
|
||||
if (specifier === 'node:fs/promises' && stagingErrno) {
|
||||
return {
|
||||
...require(specifier),
|
||||
open: async () => {
|
||||
const error = new Error(`${stagingErrno}: staging refused`);
|
||||
error.code = stagingErrno;
|
||||
throw error;
|
||||
},
|
||||
};
|
||||
}
|
||||
return stubs[specifier] ?? require(specifier);
|
||||
},
|
||||
require: (specifier) => stubs[specifier] ?? require(specifier),
|
||||
};
|
||||
const context = vm.createContext(sandbox);
|
||||
|
||||
const appImageModule = {exports: {}};
|
||||
sandbox.module = appImageModule;
|
||||
sandbox.exports = appImageModule.exports;
|
||||
sandbox.__filename = appImageUpdateSource.path;
|
||||
vm.runInContext(appImageUpdateSource.code, context, {filename: appImageUpdateSource.path});
|
||||
stubs['@electron/main/AppImageUpdate'] = appImageModule.exports;
|
||||
|
||||
const shellUpdateCapabilityModule = {exports: {}};
|
||||
sandbox.module = shellUpdateCapabilityModule;
|
||||
sandbox.exports = shellUpdateCapabilityModule.exports;
|
||||
sandbox.__filename = shellUpdateCapabilitySource.path;
|
||||
vm.runInContext(shellUpdateCapabilitySource.code, context, {filename: shellUpdateCapabilitySource.path});
|
||||
stubs['@electron/main/ShellUpdateCapability'] = shellUpdateCapabilityModule.exports;
|
||||
|
||||
const shellDownloadFormatsModule = {exports: {}};
|
||||
sandbox.module = shellDownloadFormatsModule;
|
||||
sandbox.exports = shellDownloadFormatsModule.exports;
|
||||
sandbox.__filename = shellDownloadFormatsSource.path;
|
||||
vm.runInContext(shellDownloadFormatsSource.code, context, {filename: shellDownloadFormatsSource.path});
|
||||
stubs['@electron/main/ShellDownloadFormats'] = shellDownloadFormatsModule.exports;
|
||||
|
||||
const updaterDownloadsModule = {exports: {}};
|
||||
sandbox.module = updaterDownloadsModule;
|
||||
sandbox.exports = updaterDownloadsModule.exports;
|
||||
sandbox.__filename = updaterDownloadsSource.path;
|
||||
vm.runInContext(updaterDownloadsSource.code, context, {filename: updaterDownloadsSource.path});
|
||||
stubs['@electron/main/UpdaterDownloads'] = updaterDownloadsModule.exports;
|
||||
|
||||
const load = (source, specifier) => {
|
||||
const loaded = {exports: {}};
|
||||
sandbox.module = loaded;
|
||||
sandbox.exports = loaded.exports;
|
||||
sandbox.__filename = source.path;
|
||||
vm.runInContext(source.code, context, {filename: source.path});
|
||||
stubs[specifier] = loaded.exports;
|
||||
};
|
||||
load(appImageUpdateSource, '@electron/main/AppImageUpdate');
|
||||
load(shellUpdateCapabilitySource, '@electron/main/ShellUpdateCapability');
|
||||
load(shellDownloadFormatsSource, '@electron/main/ShellDownloadFormats');
|
||||
load(updaterDownloadsSource, '@electron/main/UpdaterDownloads');
|
||||
sandbox.module = module;
|
||||
sandbox.exports = module.exports;
|
||||
sandbox.__filename = updaterSource.path;
|
||||
@@ -250,12 +163,10 @@ function loadUpdater({
|
||||
|
||||
module.exports.registerUpdater(() => ({webContents: {send: (_channel, event) => events.push(event)}}));
|
||||
return {
|
||||
applyState,
|
||||
events,
|
||||
state,
|
||||
check: () => handlers.get('updater-check')({}, 'user'),
|
||||
install: () => handlers.get('updater-install')({}),
|
||||
quit: () => appEvents.get('will-quit')?.(),
|
||||
gate,
|
||||
channels: () => [...handlers.keys()],
|
||||
check: (context = 'user') => handlers.get('updater-check')({}, context),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -263,359 +174,113 @@ function types(events) {
|
||||
return events.map((event) => event.type);
|
||||
}
|
||||
|
||||
function stagingLeftovers(directory) {
|
||||
return readdirSync(directory).filter((entry) => entry.startsWith('.fluxer-update-'));
|
||||
function createInstall() {
|
||||
const root = mkdtempSync(join(tmpdir(), 'fluxer-updater-test-'));
|
||||
const applications = join(root, 'Applications');
|
||||
mkdirSync(applications);
|
||||
const mount = join(root, '.mount_Fluxer');
|
||||
mkdirSync(mount);
|
||||
writeFileSync(join(mount, 'AppRun'), '#!/usr/bin/env bash\n', {mode: 0o755});
|
||||
const installedPath = join(applications, INSTALLED_NAME);
|
||||
writeFileSync(installedPath, OLD_BYTES, {mode: 0o755});
|
||||
return {applications, installedPath, mount};
|
||||
}
|
||||
|
||||
describe('Updater AppImage lifecycle', () => {
|
||||
test('downloads on check, reports the macOS lifecycle, and swaps in place', async () => {
|
||||
describe('Updater on a shell that updates itself', () => {
|
||||
test('registers only the check, never a download or an install channel', () => {
|
||||
const install = createInstall();
|
||||
const updater = loadUpdater({appImagePath: install.installedPath, appDir: install.mount});
|
||||
|
||||
await updater.check();
|
||||
|
||||
const available = updater.events.find((event) => event.type === 'available');
|
||||
assert.equal(available.downloadStarted, true);
|
||||
assert.equal(available.version, PUBLISHED_VERSION);
|
||||
assert.equal(available.downloadUrl, undefined);
|
||||
assert.equal(available.downloadOptions, undefined);
|
||||
const progress = updater.events.filter((event) => event.type === 'progress');
|
||||
assert.ok(progress.length > 0);
|
||||
assert.equal(progress.at(-1).total, NEW_BYTES.length);
|
||||
assert.equal(progress.at(-1).transferred, NEW_BYTES.length);
|
||||
assert.equal(progress.at(-1).percent, 100);
|
||||
const downloaded = updater.events.find((event) => event.type === 'downloaded');
|
||||
assert.equal(downloaded.version, PUBLISHED_VERSION);
|
||||
assert.deepEqual(
|
||||
types(updater.events).filter((type) => type !== 'progress'),
|
||||
['checking', 'available', 'downloaded'],
|
||||
);
|
||||
assert.equal(readFileSync(install.installedPath).equals(OLD_BYTES), true);
|
||||
|
||||
await updater.install();
|
||||
|
||||
assert.equal(updater.state.relaunched, true);
|
||||
assert.equal(
|
||||
updater.state.electronRelaunched,
|
||||
false,
|
||||
"app.relaunch cannot start an AppImage: Electron's relauncher runs under PR_SET_NO_NEW_PRIVS and the setuid fusermount the AppImage runtime needs fails with EPERM",
|
||||
);
|
||||
assert.deepEqual(readdirSync(install.applications), [INSTALLED_NAME]);
|
||||
assert.equal(readFileSync(install.installedPath).equals(NEW_BYTES), true);
|
||||
assert.deepEqual(updater.channels(), ['updater-check']);
|
||||
});
|
||||
|
||||
test('refuses to swap when the download does not match the published sha256', async () => {
|
||||
test('a background check downloads nothing and leaves the poll to the bootstrap', async () => {
|
||||
const install = createInstall();
|
||||
const updater = loadUpdater({appImagePath: install.installedPath, appDir: install.mount});
|
||||
serveCorruptBytes = true;
|
||||
try {
|
||||
await updater.check();
|
||||
} finally {
|
||||
serveCorruptBytes = false;
|
||||
}
|
||||
appImageRequests = 0;
|
||||
|
||||
assert.deepEqual(
|
||||
types(updater.events).filter((type) => type !== 'progress'),
|
||||
['checking', 'available', 'error', 'available'],
|
||||
);
|
||||
const failure = updater.events.find((event) => event.type === 'error');
|
||||
assert.equal(failure.phase, 'download');
|
||||
assert.match(failure.message, /checksum mismatch/);
|
||||
const fallback = updater.events.at(-1);
|
||||
assert.equal(fallback.downloadStarted, false);
|
||||
assert.ok(fallback.downloadOptions.length > 0);
|
||||
await updater.check('background');
|
||||
await updater.check('focus');
|
||||
|
||||
assert.equal(updater.gate.checks, 0);
|
||||
assert.deepEqual(updater.events, []);
|
||||
assert.equal(appImageRequests, 0);
|
||||
assert.deepEqual(readdirSync(install.applications), [INSTALLED_NAME]);
|
||||
assert.equal(readFileSync(install.installedPath).equals(OLD_BYTES), true);
|
||||
assert.deepEqual(stagingLeftovers(install.applications), []);
|
||||
await assert.rejects(updater.install(), /No AppImage update is ready to install/);
|
||||
});
|
||||
|
||||
test('offers the manual download when the install directory is not writable', {
|
||||
skip: process.getuid?.() === 0,
|
||||
}, async () => {
|
||||
test('a user check asks the manifest and leaves the update itself to the click', async () => {
|
||||
const install = createInstall();
|
||||
chmodSync(install.applications, 0o500);
|
||||
try {
|
||||
const updater = loadUpdater({appImagePath: install.installedPath, appDir: install.mount});
|
||||
const updater = loadUpdater({appImagePath: install.installedPath, appDir: install.mount});
|
||||
appImageRequests = 0;
|
||||
|
||||
await updater.check();
|
||||
await updater.check('user');
|
||||
|
||||
assert.deepEqual(types(updater.events), ['checking', 'available']);
|
||||
const available = updater.events.at(-1);
|
||||
assert.equal(available.downloadStarted, false);
|
||||
assert.equal(available.version, PUBLISHED_VERSION);
|
||||
assert.ok(available.downloadOptions.length > 0);
|
||||
assert.equal(available.downloadOptions[0].format, 'appimage');
|
||||
assert.equal(
|
||||
available.downloadOptions[0].suggestedName,
|
||||
`Fluxer-Canary-${PUBLISHED_VERSION}-linux-arm64.AppImage`,
|
||||
);
|
||||
} finally {
|
||||
chmodSync(install.applications, 0o755);
|
||||
}
|
||||
assert.equal(readFileSync(install.installedPath).equals(OLD_BYTES), true);
|
||||
assert.equal(updater.gate.checks, 1);
|
||||
assert.deepEqual(types(updater.events), ['not-available']);
|
||||
assert.equal(appImageRequests, 0);
|
||||
assert.deepEqual(readdirSync(install.applications), [INSTALLED_NAME]);
|
||||
});
|
||||
|
||||
test('falls back to the manual updater when APPIMAGE is unset', async () => {
|
||||
test('a user check that cannot reach the manifest says so', async () => {
|
||||
const install = createInstall();
|
||||
const updater = loadUpdater({
|
||||
appImagePath: install.installedPath,
|
||||
appDir: install.mount,
|
||||
gateCheck: async () => {
|
||||
throw new Error('manifest unreachable');
|
||||
},
|
||||
});
|
||||
|
||||
await updater.check('user');
|
||||
|
||||
assert.deepEqual(types(updater.events), ['error']);
|
||||
assert.match(updater.events[0].message, /manifest unreachable/);
|
||||
});
|
||||
|
||||
test('macOS and Windows go through the same check', async () => {
|
||||
for (const platform of ['darwin', 'win32']) {
|
||||
const updater = loadUpdater({platform, arch: 'x64'});
|
||||
|
||||
await updater.check('user');
|
||||
|
||||
assert.equal(updater.gate.checks, 1, platform);
|
||||
assert.deepEqual(updater.channels(), ['updater-check'], platform);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Updater on a shell that cannot update itself', () => {
|
||||
test('a Linux package install offers the newer package for manual download', async () => {
|
||||
const updater = loadUpdater({appImagePath: null});
|
||||
|
||||
await updater.check();
|
||||
await updater.check('user');
|
||||
|
||||
assert.equal(updater.gate.checks, 1, 'the renderer module can update even when the shell cannot');
|
||||
assert.deepEqual(types(updater.events), ['checking', 'available']);
|
||||
assert.equal(updater.events.at(-1).downloadStarted, false);
|
||||
await assert.rejects(updater.install(), /In-app updates are not supported on this platform/);
|
||||
const available = updater.events.at(-1);
|
||||
assert.equal(available.version, PUBLISHED_VERSION);
|
||||
assert.equal(Object.hasOwn(available, 'downloadStarted'), false);
|
||||
assert.ok(available.downloadOptions.some((option) => option.format === 'deb'));
|
||||
});
|
||||
|
||||
test('never rewrites an inherited APPIMAGE that this process did not come from', async () => {
|
||||
const install = createInstall();
|
||||
const victim = join(install.applications, 'Kdenlive-24.12.AppImage');
|
||||
writeFileSync(victim, OLD_BYTES, {mode: 0o755});
|
||||
const optDir = mkdtempSync(join(tmpdir(), 'fluxer-opt-test-'));
|
||||
const updater = loadUpdater({appImagePath: victim, execPath: join(optDir, 'fluxer')});
|
||||
test('a Linux package install that is current reports no update', async () => {
|
||||
const updater = loadUpdater({appImagePath: null, version: PUBLISHED_VERSION});
|
||||
|
||||
await updater.check();
|
||||
await updater.check('user');
|
||||
|
||||
assert.deepEqual(types(updater.events), ['checking', 'available']);
|
||||
assert.equal(updater.events.at(-1).downloadStarted, false);
|
||||
assert.equal(readFileSync(victim).equals(OLD_BYTES), true);
|
||||
assert.deepEqual(stagingLeftovers(install.applications), []);
|
||||
await assert.rejects(updater.install(), /In-app updates are not supported on this platform/);
|
||||
});
|
||||
|
||||
test('never rewrites the AppImage that only the shell around this process came from', async () => {
|
||||
const install = createInstall();
|
||||
const victim = join(install.applications, 'Kdenlive-24.12.AppImage');
|
||||
writeFileSync(victim, OLD_BYTES, {mode: 0o755});
|
||||
const extracted = join(mkdtempSync(join(tmpdir(), 'fluxer-extracted-test-')), 'squashfs-root');
|
||||
mkdirSync(extracted);
|
||||
writeFileSync(join(extracted, 'AppRun'), '#!/usr/bin/env bash\n', {mode: 0o755});
|
||||
const updater = loadUpdater({
|
||||
appImagePath: victim,
|
||||
appDir: install.mount,
|
||||
execPath: join(extracted, 'fluxer-canary'),
|
||||
});
|
||||
|
||||
await updater.check();
|
||||
|
||||
assert.deepEqual(types(updater.events), ['checking', 'available']);
|
||||
assert.equal(updater.events.at(-1).downloadStarted, false);
|
||||
assert.equal(readFileSync(victim).equals(OLD_BYTES), true);
|
||||
assert.deepEqual(stagingLeftovers(install.applications), []);
|
||||
await assert.rejects(updater.install(), /In-app updates are not supported on this platform/);
|
||||
});
|
||||
|
||||
test('gives up on a local write failure instead of downloading the AppImage again', async () => {
|
||||
const install = createInstall();
|
||||
const updater = loadUpdater({
|
||||
appImagePath: install.installedPath,
|
||||
appDir: install.mount,
|
||||
stagingErrno: 'ENOSPC',
|
||||
});
|
||||
const before = appImageRequests;
|
||||
|
||||
await updater.check();
|
||||
|
||||
assert.equal(appImageRequests - before, 1, 'a full disk must not pull the whole AppImage down five more times');
|
||||
const failure = updater.events.find((event) => event.type === 'error');
|
||||
assert.equal(failure.phase, 'download');
|
||||
assert.match(failure.message, /ENOSPC/);
|
||||
assert.equal(updater.events.at(-1).type, 'available');
|
||||
assert.equal(updater.events.at(-1).downloadStarted, false);
|
||||
assert.deepEqual(readdirSync(install.applications), [INSTALLED_NAME]);
|
||||
});
|
||||
|
||||
test('downloads the AppImage again after a transient server failure', async () => {
|
||||
const install = createInstall();
|
||||
const updater = loadUpdater({appImagePath: install.installedPath, appDir: install.mount});
|
||||
const before = appImageRequests;
|
||||
failAppImageRequests = 1;
|
||||
|
||||
try {
|
||||
await updater.check();
|
||||
} finally {
|
||||
failAppImageRequests = 0;
|
||||
}
|
||||
|
||||
assert.equal(appImageRequests - before, 2);
|
||||
assert.equal(updater.events.at(-1).type, 'downloaded');
|
||||
|
||||
await updater.install();
|
||||
|
||||
assert.equal(readFileSync(install.installedPath).equals(NEW_BYTES), true);
|
||||
});
|
||||
|
||||
test('reports no update when the published version is not newer', async () => {
|
||||
const install = createInstall();
|
||||
const updater = loadUpdater({
|
||||
appImagePath: install.installedPath,
|
||||
appDir: install.mount,
|
||||
version: PUBLISHED_VERSION,
|
||||
});
|
||||
|
||||
await updater.check();
|
||||
|
||||
assert.deepEqual(types(updater.events), ['checking', 'not-available']);
|
||||
assert.deepEqual(readdirSync(install.applications), [INSTALLED_NAME]);
|
||||
});
|
||||
|
||||
test('reclaims a staged update abandoned by a process that never reached will-quit', async () => {
|
||||
const install = createInstall();
|
||||
const abandoned = loadUpdater({appImagePath: install.installedPath, appDir: install.mount});
|
||||
|
||||
await abandoned.check();
|
||||
assert.equal(stagingLeftovers(install.applications).length, 1);
|
||||
|
||||
loadUpdater({appImagePath: install.installedPath, appDir: install.mount});
|
||||
|
||||
assert.deepEqual(stagingLeftovers(install.applications), []);
|
||||
assert.deepEqual(readdirSync(install.applications), [INSTALLED_NAME]);
|
||||
assert.equal(readFileSync(install.installedPath).equals(OLD_BYTES), true);
|
||||
});
|
||||
|
||||
test('shows the renderer the download progress the main process reports', async () => {
|
||||
const install = createInstall();
|
||||
const updater = loadUpdater({appImagePath: install.installedPath, appDir: install.mount});
|
||||
const {shouldShowNativeDesktopUpdateDownloadProgress} = loadUpdaterPlatformUtils();
|
||||
|
||||
await updater.check();
|
||||
|
||||
const reportsProgress = updater.events.some((event) => event.type === 'progress');
|
||||
assert.equal(reportsProgress, true);
|
||||
assert.equal(shouldShowNativeDesktopUpdateDownloadProgress('linux'), reportsProgress);
|
||||
});
|
||||
|
||||
test('discards a staged update that the user never installed', async () => {
|
||||
const install = createInstall();
|
||||
const updater = loadUpdater({appImagePath: install.installedPath, appDir: install.mount});
|
||||
|
||||
await updater.check();
|
||||
assert.equal(stagingLeftovers(install.applications).length, 1);
|
||||
|
||||
updater.quit();
|
||||
|
||||
assert.deepEqual(stagingLeftovers(install.applications), []);
|
||||
assert.equal(readFileSync(install.installedPath).equals(OLD_BYTES), true);
|
||||
});
|
||||
});
|
||||
|
||||
function createVelopackStub({installedVersion, pendingRestart = null, remoteUpdate = null}) {
|
||||
const applied = [];
|
||||
class UpdateManager {
|
||||
getCurrentVersion() {
|
||||
return installedVersion;
|
||||
}
|
||||
getUpdatePendingRestart() {
|
||||
return pendingRestart;
|
||||
}
|
||||
checkForUpdatesAsync() {
|
||||
return Promise.resolve(remoteUpdate);
|
||||
}
|
||||
downloadUpdateAsync() {
|
||||
return Promise.resolve();
|
||||
}
|
||||
waitExitThenApplyUpdate(update) {
|
||||
applied.push(update);
|
||||
}
|
||||
}
|
||||
return {applied, module: {UpdateManager}};
|
||||
}
|
||||
|
||||
function loadWindowsUpdater({installedVersion, pendingRestart, remoteUpdate, applyAttempt = null}) {
|
||||
const velopack = createVelopackStub({installedVersion, pendingRestart, remoteUpdate});
|
||||
const updater = loadUpdater({
|
||||
platform: 'win32',
|
||||
arch: 'x64',
|
||||
velopack: velopack.module,
|
||||
applyAttempt,
|
||||
});
|
||||
return {...updater, applied: velopack.applied};
|
||||
}
|
||||
|
||||
describe('Updater Windows apply failures', () => {
|
||||
test('offers the installer when a downloaded update never applied', async () => {
|
||||
const updater = loadWindowsUpdater({
|
||||
installedVersion: CURRENT_VERSION,
|
||||
pendingRestart: {Version: PUBLISHED_VERSION, Size: 100},
|
||||
applyAttempt: {version: PUBLISHED_VERSION, attemptedAt: 0},
|
||||
});
|
||||
|
||||
await updater.check();
|
||||
|
||||
assert.deepEqual(types(updater.events), ['checking', 'error', 'available']);
|
||||
assert.equal(updater.events[1].phase, 'install');
|
||||
assert.ok(updater.events[1].message.includes(PUBLISHED_VERSION));
|
||||
assert.equal(updater.events[2].downloadStarted, false);
|
||||
assert.equal(updater.events[2].downloadUrl, `${baseUrl}/setup`);
|
||||
assert.equal(updater.applyState.attempt.version, PUBLISHED_VERSION);
|
||||
});
|
||||
|
||||
test('keeps offering the installer when no newer release than the failed one is out', async () => {
|
||||
const updater = loadWindowsUpdater({
|
||||
installedVersion: CURRENT_VERSION,
|
||||
remoteUpdate: {Version: PUBLISHED_VERSION, Size: 100},
|
||||
applyAttempt: {version: PUBLISHED_VERSION, attemptedAt: 0},
|
||||
});
|
||||
|
||||
await updater.check();
|
||||
|
||||
assert.deepEqual(types(updater.events), ['checking', 'error', 'available']);
|
||||
assert.equal(updater.events[2].downloadStarted, false);
|
||||
assert.equal(updater.applyState.cleared, 0);
|
||||
});
|
||||
|
||||
test('retries the in-app update when a newer release than the failed one is out', async () => {
|
||||
const newerVersion = '2026.905.101010';
|
||||
const updater = loadWindowsUpdater({
|
||||
installedVersion: CURRENT_VERSION,
|
||||
pendingRestart: {Version: PUBLISHED_VERSION, Size: 100},
|
||||
remoteUpdate: {Version: newerVersion, Size: 200},
|
||||
applyAttempt: {version: PUBLISHED_VERSION, attemptedAt: 0},
|
||||
});
|
||||
|
||||
await updater.check();
|
||||
|
||||
assert.equal(updater.applyState.cleared, 1);
|
||||
assert.equal(updater.applyState.attempt, null);
|
||||
assert.deepEqual(types(updater.events), ['checking', 'available']);
|
||||
assert.equal(updater.events[1].version, newerVersion);
|
||||
assert.equal(updater.events[1].downloadUrl, undefined);
|
||||
});
|
||||
|
||||
test('resumes normal updates once the installed version catches up', async () => {
|
||||
const updater = loadWindowsUpdater({
|
||||
installedVersion: PUBLISHED_VERSION,
|
||||
applyAttempt: {version: PUBLISHED_VERSION, attemptedAt: 0},
|
||||
});
|
||||
|
||||
await updater.check();
|
||||
|
||||
assert.equal(updater.applyState.cleared, 1);
|
||||
assert.equal(updater.applyState.attempt, null);
|
||||
assert.deepEqual(types(updater.events), ['checking', 'not-available']);
|
||||
});
|
||||
|
||||
test('records the version it hands to the updater before quitting', async () => {
|
||||
const updater = loadWindowsUpdater({
|
||||
installedVersion: CURRENT_VERSION,
|
||||
pendingRestart: {Version: PUBLISHED_VERSION, Size: 100},
|
||||
});
|
||||
test('Flatpak and development builds point at their own update path', async () => {
|
||||
const flatpak = loadUpdater({appImagePath: null, flatpak: true});
|
||||
await flatpak.check('user');
|
||||
assert.deepEqual(JSON.parse(JSON.stringify(flatpak.events)), [
|
||||
{type: 'unsupported', context: 'user', reason: 'managed-package'},
|
||||
]);
|
||||
|
||||
await updater.install();
|
||||
|
||||
assert.deepEqual(updater.applyState.recorded, [PUBLISHED_VERSION]);
|
||||
assert.deepEqual(updater.applied, [{Version: PUBLISHED_VERSION, Size: 100}]);
|
||||
});
|
||||
|
||||
test('refuses to re-apply a version that already failed to install', async () => {
|
||||
const updater = loadWindowsUpdater({
|
||||
installedVersion: CURRENT_VERSION,
|
||||
pendingRestart: {Version: PUBLISHED_VERSION, Size: 100},
|
||||
applyAttempt: {version: PUBLISHED_VERSION, attemptedAt: 0},
|
||||
});
|
||||
|
||||
await assert.rejects(() => updater.install(), /Download the installer/);
|
||||
|
||||
assert.deepEqual(updater.applied, []);
|
||||
assert.deepEqual(updater.applyState.recorded, []);
|
||||
const unpackaged = loadUpdater({appImagePath: null, packaged: false});
|
||||
await unpackaged.check('user');
|
||||
assert.equal(unpackaged.events[0].reason, 'unpackaged');
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user