mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-17 13:05:32 +09:00
287 lines
9.4 KiB
JavaScript
287 lines
9.4 KiB
JavaScript
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
|
|
import assert from 'node:assert/strict';
|
|
import {createHash} from 'node:crypto';
|
|
import {mkdirSync, mkdtempSync, readdirSync, readFileSync, writeFileSync} from 'node:fs';
|
|
import {createServer} from 'node:http';
|
|
import {createRequire} from 'node:module';
|
|
import {tmpdir} from 'node:os';
|
|
import {join} from 'node:path';
|
|
import {after, before, describe, test} from 'node:test';
|
|
import {fileURLToPath} from 'node:url';
|
|
import vm from 'node:vm';
|
|
|
|
const require = createRequire(import.meta.url);
|
|
const esbuild = require('esbuild');
|
|
|
|
function transform(name) {
|
|
const url = new URL(`./${name}`, import.meta.url);
|
|
const path = fileURLToPath(url);
|
|
return {
|
|
path,
|
|
code: esbuild.transformSync(readFileSync(path, 'utf8'), {
|
|
loader: 'ts',
|
|
format: 'cjs',
|
|
platform: 'node',
|
|
target: 'node20',
|
|
define: {'import.meta.url': JSON.stringify(url.href)},
|
|
}).code,
|
|
};
|
|
}
|
|
|
|
const appImageUpdateSource = transform('AppImageUpdate.ts');
|
|
const shellDownloadFormatsSource = transform('ShellDownloadFormats.ts');
|
|
const shellUpdateCapabilitySource = transform('ShellUpdateCapability.ts');
|
|
const updaterDownloadsSource = transform('UpdaterDownloads.ts');
|
|
const updaterSource = transform('Updater.ts');
|
|
|
|
const INSTALLED_NAME = 'My Fluxer.AppImage';
|
|
const CURRENT_VERSION = '2026.903.231208';
|
|
const PUBLISHED_VERSION = '2026.904.135113';
|
|
const OLD_BYTES = Buffer.from('installed-appimage');
|
|
const NEW_BYTES = Buffer.alloc(300_000, 3);
|
|
const NEW_SHA256 = createHash('sha256').update(NEW_BYTES).digest('hex');
|
|
|
|
let server;
|
|
let baseUrl;
|
|
let appImageRequests = 0;
|
|
|
|
before(async () => {
|
|
server = createServer((request, response) => {
|
|
if (request.url === '/latest') {
|
|
response.writeHead(200, {'content-type': 'application/json'});
|
|
response.end(
|
|
JSON.stringify({
|
|
version: PUBLISHED_VERSION,
|
|
pub_date: '2026-09-04T13:51:13Z',
|
|
files: {
|
|
appimage: {url: `${baseUrl}/appimage`, sha256: NEW_SHA256},
|
|
deb: {url: `${baseUrl}/deb`, sha256: 'deadbeef'},
|
|
setup: {url: `${baseUrl}/setup`, sha256: 'cafebabe'},
|
|
},
|
|
}),
|
|
);
|
|
return;
|
|
}
|
|
if (request.url === `/${PUBLISHED_VERSION}/appimage`) {
|
|
appImageRequests += 1;
|
|
response.writeHead(200, {'content-type': 'application/octet-stream', 'content-length': NEW_BYTES.length});
|
|
response.end(NEW_BYTES);
|
|
return;
|
|
}
|
|
response.writeHead(404);
|
|
response.end('missing');
|
|
});
|
|
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
|
|
baseUrl = `http://127.0.0.1:${server.address().port}`;
|
|
});
|
|
|
|
after(async () => {
|
|
await new Promise((resolve) => server.close(resolve));
|
|
});
|
|
|
|
function loadUpdater({
|
|
appImagePath,
|
|
version = CURRENT_VERSION,
|
|
appDir,
|
|
platform = 'linux',
|
|
arch = 'arm64',
|
|
packaged = true,
|
|
flatpak = false,
|
|
gateCheck = async () => ({available: true}),
|
|
}) {
|
|
const events = [];
|
|
const handlers = new Map();
|
|
const gate = {checks: 0};
|
|
const module = {exports: {}};
|
|
const stubs = {
|
|
'@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/DesktopUpdateGate': {
|
|
checkDesktopUpdateNow: async () => {
|
|
gate.checks += 1;
|
|
return await gateCheck();
|
|
},
|
|
},
|
|
'@electron/main/LinuxSandbox': {isFlatpakRuntime: () => flatpak},
|
|
'electron-log': {info() {}, warn() {}, error() {}, debug() {}},
|
|
electron: {
|
|
app: {
|
|
isPackaged: packaged,
|
|
getVersion: () => version,
|
|
},
|
|
net: {fetch: (input, init) => sandbox.fetch(input, init)},
|
|
ipcMain: {
|
|
handle(channel, handler) {
|
|
handlers.set(channel, handler);
|
|
},
|
|
},
|
|
},
|
|
};
|
|
const sandbox = {
|
|
console,
|
|
Buffer,
|
|
URL,
|
|
process: {
|
|
...process,
|
|
platform,
|
|
arch,
|
|
execPath: appDir ? join(appDir, 'fluxer-canary') : process.execPath,
|
|
env: {
|
|
...(appImagePath ? {APPIMAGE: appImagePath} : {}),
|
|
...(appDir ? {APPDIR: appDir} : {}),
|
|
},
|
|
},
|
|
setTimeout,
|
|
clearTimeout,
|
|
setImmediate,
|
|
fetch: (input, init) => {
|
|
const url = String(input).replace(/https:\/\/pkgs\.fluxer\.com\/desktop\/canary\/[^/]+\/[^/]+/, baseUrl);
|
|
return fetch(url, init);
|
|
},
|
|
require: (specifier) => stubs[specifier] ?? require(specifier),
|
|
};
|
|
const context = vm.createContext(sandbox);
|
|
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;
|
|
vm.runInContext(updaterSource.code, context, {filename: updaterSource.path});
|
|
|
|
module.exports.registerUpdater(() => ({webContents: {send: (_channel, event) => events.push(event)}}));
|
|
return {
|
|
events,
|
|
gate,
|
|
channels: () => [...handlers.keys()],
|
|
check: (context = 'user') => handlers.get('updater-check')({}, context),
|
|
};
|
|
}
|
|
|
|
function types(events) {
|
|
return events.map((event) => event.type);
|
|
}
|
|
|
|
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 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});
|
|
|
|
assert.deepEqual(updater.channels(), ['updater-check']);
|
|
});
|
|
|
|
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});
|
|
appImageRequests = 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);
|
|
});
|
|
|
|
test('a user check asks the manifest and leaves the update itself to the click', async () => {
|
|
const install = createInstall();
|
|
const updater = loadUpdater({appImagePath: install.installedPath, appDir: install.mount});
|
|
appImageRequests = 0;
|
|
|
|
await updater.check('user');
|
|
|
|
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('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('user');
|
|
|
|
assert.equal(updater.gate.checks, 1, 'the renderer module can update even when the shell cannot');
|
|
assert.deepEqual(types(updater.events), ['checking', 'available']);
|
|
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('a Linux package install that is current reports no update', async () => {
|
|
const updater = loadUpdater({appImagePath: null, version: PUBLISHED_VERSION});
|
|
|
|
await updater.check('user');
|
|
|
|
assert.deepEqual(types(updater.events), ['checking', 'not-available']);
|
|
});
|
|
|
|
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'},
|
|
]);
|
|
|
|
const unpackaged = loadUpdater({appImagePath: null, packaged: false});
|
|
await unpackaged.check('user');
|
|
assert.equal(unpackaged.events[0].reason, 'unpackaged');
|
|
});
|
|
});
|