feat: add auto update support (#65)

This commit is contained in:
2026-05-16 00:09:14 -07:00
committed by GitHub
parent 105713361e
commit 91a01b86a9
71 changed files with 2368 additions and 188 deletions
+227 -1
View File
@@ -1,6 +1,13 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { configureAutoUpdater, type ElectronAutoUpdaterLike } from './app-updater';
import {
configureAutoUpdater,
createElectronAppUpdater,
isKnownLinuxPackageManagedAppImage,
isNativeUpdaterSupported,
resolveMacAppBundlePath,
type ElectronAutoUpdaterLike,
} from './app-updater';
type UpdaterLogger = {
info: (message: string) => void;
@@ -53,3 +60,222 @@ test('configureAutoUpdater allows prereleases only for the prerelease channel',
configureAutoUpdater(updater, () => {}, 'stable');
assert.equal(updater.allowPrerelease, false);
});
test('configureAutoUpdater handles late updater error events', () => {
const logged: string[] = [];
const errorListeners: Array<(error: unknown) => void> = [];
const updater: ElectronAutoUpdaterLike & {
on: (event: string, listener: (error: unknown) => void) => typeof updater;
} = {
autoDownload: true,
allowPrerelease: false,
allowDowngrade: true,
logger: null,
checkForUpdates: async () => null,
downloadUpdate: async () => [],
quitAndInstall: () => {},
on: (event, listener) => {
if (event === 'error') errorListeners.push(listener);
return updater;
},
};
configureAutoUpdater(updater, (message) => logged.push(message));
const [errorListener] = errorListeners;
assert.ok(errorListener);
errorListener(new Error('APPIMAGE env is not defined'));
assert.deepEqual(logged, ['Updater error event: APPIMAGE env is not defined']);
});
test('app updater skips native update checks when native updater is unsupported', async () => {
let checked = false;
const updater: ElectronAutoUpdaterLike = {
autoDownload: true,
allowPrerelease: false,
allowDowngrade: true,
logger: null,
checkForUpdates: async () => {
checked = true;
return {
updateInfo: {
version: '0.15.0',
},
};
},
downloadUpdate: async () => [],
quitAndInstall: () => {},
};
const logged: string[] = [];
const appUpdater = createElectronAppUpdater({
currentVersion: '0.14.0',
isPackaged: true,
updater,
log: (message) => logged.push(message),
isNativeUpdaterSupported: () => false,
});
const result = await appUpdater.checkForUpdates('stable');
assert.equal(checked, false);
assert.deepEqual(result, {
available: false,
version: '0.14.0',
canUpdate: false,
});
assert.deepEqual(logged, [
'Skipping native app update check because native updater is unsupported.',
]);
});
test('app updater skips native downloads when native updater is unsupported', async () => {
let downloaded = false;
const updater: ElectronAutoUpdaterLike = {
autoDownload: true,
allowPrerelease: false,
allowDowngrade: true,
logger: null,
checkForUpdates: async () => null,
downloadUpdate: async () => {
downloaded = true;
return [];
},
quitAndInstall: () => {},
};
const logged: string[] = [];
const appUpdater = createElectronAppUpdater({
currentVersion: '0.14.0',
isPackaged: true,
updater,
log: (message) => logged.push(message),
isNativeUpdaterSupported: () => false,
});
await appUpdater.downloadUpdate();
assert.equal(downloaded, false);
assert.deepEqual(logged, ['Skipping app update download because native updater is unsupported.']);
});
test('resolveMacAppBundlePath resolves packaged macOS executable path', () => {
assert.equal(
resolveMacAppBundlePath('/Applications/SubMiner.app/Contents/MacOS/SubMiner'),
'/Applications/SubMiner.app',
);
assert.equal(resolveMacAppBundlePath('/usr/local/bin/SubMiner'), null);
});
test('mac native updater is unsupported for ad-hoc signed app bundles', async () => {
const logged: string[] = [];
const supported = await isNativeUpdaterSupported({
platform: 'darwin',
isPackaged: true,
execPath: '/Applications/SubMiner.app/Contents/MacOS/SubMiner',
readCodeSignature: () =>
['Signature=adhoc', 'TeamIdentifier=not set', 'Runtime Version=26.0.0'].join('\n'),
log: (message) => logged.push(message),
});
assert.equal(supported, false);
assert.deepEqual(logged, ['Skipping native macOS updater because this build is ad-hoc signed.']);
});
test('mac native updater is supported for Developer ID signed app bundles', async () => {
const supported = await isNativeUpdaterSupported({
platform: 'darwin',
isPackaged: true,
execPath: '/Applications/SubMiner.app/Contents/MacOS/SubMiner',
readCodeSignature: () =>
['Authority=Developer ID Application: Example', 'TeamIdentifier=ABCDE12345'].join('\n'),
});
assert.equal(supported, true);
});
test('linux native updater is unsupported even for writable direct AppImage installs', async () => {
const logged: string[] = [];
const supported = await isNativeUpdaterSupported({
platform: 'linux',
isPackaged: true,
execPath: '/tmp/.mount_SubMiner/SubMiner',
env: {
APPIMAGE: '/home/tester/.local/bin/SubMiner.AppImage',
},
log: (message) => logged.push(message),
});
assert.equal(supported, false);
assert.deepEqual(logged, [
'Skipping native Linux updater because Linux tray checks use GitHub release assets.',
]);
});
test('linux native updater is unsupported when APPIMAGE is missing', async () => {
const logged: string[] = [];
const supported = await isNativeUpdaterSupported({
platform: 'linux',
isPackaged: true,
execPath: '/tmp/.mount_SubMiner/SubMiner',
env: {},
log: (message) => logged.push(message),
});
assert.equal(supported, false);
assert.deepEqual(logged, [
'Skipping native Linux updater because Linux tray checks use GitHub release assets.',
]);
});
test('linux native updater is unsupported for non-writable AppImage installs', async () => {
const logged: string[] = [];
const supported = await isNativeUpdaterSupported({
platform: 'linux',
isPackaged: true,
execPath: '/tmp/.mount_SubMiner/SubMiner',
env: {
APPIMAGE: '/home/tester/.local/bin/SubMiner.AppImage',
},
log: (message) => logged.push(message),
});
assert.equal(supported, false);
assert.deepEqual(logged, [
'Skipping native Linux updater because Linux tray checks use GitHub release assets.',
]);
});
test('linux native updater is unsupported for package-managed AppImage installs', async () => {
const logged: string[] = [];
const supported = await isNativeUpdaterSupported({
platform: 'linux',
isPackaged: true,
execPath: '/tmp/.mount_SubMiner/SubMiner',
env: {
APPIMAGE: '/opt/SubMiner/SubMiner.AppImage',
},
log: (message) => logged.push(message),
});
assert.equal(supported, false);
assert.deepEqual(logged, [
'Skipping native Linux updater because Linux tray checks use GitHub release assets.',
]);
});
test('known Linux package-managed AppImage detection follows the canonical AUR path', () => {
assert.equal(isKnownLinuxPackageManagedAppImage('/opt/SubMiner/SubMiner.AppImage'), true);
assert.equal(
isKnownLinuxPackageManagedAppImage('/home/tester/.local/bin/SubMiner.AppImage'),
false,
);
});
test('native updater is unsupported on Windows by default', async () => {
const supported = await isNativeUpdaterSupported({
platform: 'win32',
isPackaged: true,
execPath: 'C:\\Program Files\\SubMiner\\SubMiner.exe',
});
assert.equal(supported, false);
});
+132 -1
View File
@@ -1,3 +1,6 @@
import { realpathSync } from 'node:fs';
import { execFile } from 'node:child_process';
import { promisify } from 'node:util';
import { autoUpdater as electronAutoUpdater } from 'electron-updater';
import type { UpdateChannel } from '../../../types/config';
import { compareSemverLike } from './release-assets';
@@ -20,6 +23,9 @@ export interface ElectronAutoUpdaterLike {
allowPrerelease: boolean;
allowDowngrade: boolean;
logger?: ElectronUpdaterLoggerLike | null;
on?: (event: 'error', listener: (error: unknown) => void) => unknown;
off?: (event: 'error', listener: (error: unknown) => void) => unknown;
removeListener?: (event: 'error', listener: (error: unknown) => void) => unknown;
checkForUpdates: () => Promise<{
updateInfo?: {
version?: string;
@@ -29,6 +35,85 @@ export interface ElectronAutoUpdaterLike {
quitAndInstall: (isSilent?: boolean, isForceRunAfter?: boolean) => void;
}
const updaterErrorListeners = new WeakMap<object, (error: unknown) => void>();
const execFileAsync = promisify(execFile);
export function resolveMacAppBundlePath(execPath: string): string | null {
const marker = '.app/Contents/MacOS/';
const markerIndex = execPath.indexOf(marker);
if (markerIndex < 0) return null;
return execPath.slice(0, markerIndex + '.app'.length);
}
async function readMacCodeSignature(appBundlePath: string): Promise<string | null> {
try {
const result = await execFileAsync('/usr/bin/codesign', ['-dv', '--verbose=4', appBundlePath], {
encoding: 'utf8',
});
return `${result.stdout ?? ''}\n${result.stderr ?? ''}`;
} catch {
return null;
}
}
function realpathOrOriginal(filePath: string): string {
try {
return realpathSync(filePath);
} catch {
return filePath;
}
}
export function isKnownLinuxPackageManagedAppImage(appImagePath: string): boolean {
return realpathOrOriginal(appImagePath) === '/opt/SubMiner/SubMiner.AppImage';
}
export async function isNativeUpdaterSupported(options: {
platform: NodeJS.Platform;
isPackaged: boolean;
execPath: string;
env?: NodeJS.ProcessEnv;
readCodeSignature?: (appBundlePath: string) => string | null | Promise<string | null>;
log?: (message: string) => void;
}): Promise<boolean> {
if (!options.isPackaged) {
options.log?.('Skipping native updater because this build is not packaged.');
return false;
}
if (options.platform === 'linux') {
options.log?.(
'Skipping native Linux updater because Linux tray checks use GitHub release assets.',
);
return false;
}
if (options.platform !== 'darwin') {
options.log?.('Skipping native updater because this platform uses GitHub metadata checks.');
return false;
}
const appBundlePath = resolveMacAppBundlePath(options.execPath);
if (!appBundlePath) {
options.log?.(
'Skipping native macOS updater because the app bundle path could not be resolved.',
);
return false;
}
const signature = await (options.readCodeSignature ?? readMacCodeSignature)(appBundlePath);
if (!signature) {
options.log?.(
'Skipping native macOS updater because the app code signature could not be read.',
);
return false;
}
if (/Signature=adhoc\b/.test(signature) || /TeamIdentifier=not set\b/.test(signature)) {
options.log?.('Skipping native macOS updater because this build is ad-hoc signed.');
return false;
}
return true;
}
export function configureAutoUpdater(
updater: ElectronAutoUpdaterLike,
log: (message: string) => void = () => {},
@@ -43,6 +128,22 @@ export function configureAutoUpdater(
warn: (message) => log(message),
error: (message) => log(message),
};
const previousErrorListener = updaterErrorListeners.get(updater);
if (previousErrorListener) {
if (updater.off) {
updater.off('error', previousErrorListener);
} else {
updater.removeListener?.('error', previousErrorListener);
}
}
if (updater.on) {
const errorListener = (error: unknown) => {
const message = error instanceof Error ? error.message : String(error);
log(`Updater error event: ${message}`);
};
updater.on('error', errorListener);
updaterErrorListeners.set(updater, errorListener);
}
return updater;
}
@@ -52,6 +153,7 @@ export function createElectronAppUpdater(options: {
updater?: ElectronAutoUpdaterLike;
log: (message: string) => void;
getChannel?: () => UpdateChannel;
isNativeUpdaterSupported?: () => boolean | Promise<boolean>;
}) {
const getChannel = options.getChannel ?? (() => 'stable' as const);
const updater = configureAutoUpdater(
@@ -59,6 +161,15 @@ export function createElectronAppUpdater(options: {
options.log,
getChannel(),
);
let nativeUpdaterSupported: Promise<boolean> | null = null;
async function getNativeUpdaterSupported(): Promise<boolean> {
if (!options.isNativeUpdaterSupported) return true;
if (nativeUpdaterSupported === null) {
nativeUpdaterSupported = Promise.resolve(options.isNativeUpdaterSupported());
}
return nativeUpdaterSupported;
}
return {
async checkForUpdates(channel?: UpdateChannel): Promise<AppUpdateCheckResult> {
@@ -69,6 +180,14 @@ export function createElectronAppUpdater(options: {
canUpdate: false,
};
}
if (!(await getNativeUpdaterSupported())) {
options.log('Skipping native app update check because native updater is unsupported.');
return {
available: false,
version: options.currentVersion,
canUpdate: false,
};
}
configureAutoUpdater(updater, options.log, channel ?? getChannel());
const result = await updater.checkForUpdates();
const version = result?.updateInfo?.version ?? options.currentVersion;
@@ -83,9 +202,21 @@ export function createElectronAppUpdater(options: {
options.log('Skipping app update download because this build is not packaged.');
return;
}
if (!(await getNativeUpdaterSupported())) {
options.log('Skipping app update download because native updater is unsupported.');
return;
}
await updater.downloadUpdate();
},
quitAndInstall(): void {
async quitAndInstall(): Promise<void> {
if (!options.isPackaged) {
options.log('Skipping app update install because this build is not packaged.');
return;
}
if (!(await getNativeUpdaterSupported())) {
options.log('Skipping app update install because native updater is unsupported.');
return;
}
updater.quitAndInstall(false, true);
},
};
@@ -0,0 +1,140 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createHash } from 'node:crypto';
import { buildProtectedAppImageUpdateCommand, updateAppImageFromRelease } from './appimage-updater';
const appImageBytes = Buffer.from('appimage');
const appImageHash = createHash('sha256').update(appImageBytes).digest('hex');
test('updateAppImageFromRelease verifies hash and atomically replaces writable AppImage', async () => {
const writes: Array<{ path: string; data: Buffer }> = [];
const chmods: Array<{ path: string; mode: number }> = [];
const renames: Array<{ from: string; to: string }> = [];
const result = await updateAppImageFromRelease({
release: {
tag_name: 'v0.15.0',
prerelease: false,
draft: false,
assets: [{ name: 'SubMiner.AppImage', browser_download_url: 'https://example.test/app' }],
},
sha256Sums: new Map([['SubMiner.AppImage', appImageHash]]),
appImagePath: '/home/kyle/.local/bin/SubMiner.AppImage',
downloadAsset: async () => appImageBytes,
fs: {
stat: async () => ({
isFile: () => true,
mode: 0o755,
}),
access: async () => {},
writeFile: async (targetPath, data) => {
writes.push({ path: targetPath, data });
},
chmod: async (targetPath, mode) => {
chmods.push({ path: targetPath, mode });
},
rename: async (from, to) => {
renames.push({ from, to });
},
unlink: async () => {},
},
});
assert.deepEqual(result, {
status: 'updated',
path: '/home/kyle/.local/bin/SubMiner.AppImage',
});
assert.deepEqual(writes, [
{ path: '/home/kyle/.local/bin/.SubMiner.AppImage.update', data: appImageBytes },
]);
assert.deepEqual(chmods, [
{ path: '/home/kyle/.local/bin/.SubMiner.AppImage.update', mode: 0o755 },
]);
assert.deepEqual(renames, [
{
from: '/home/kyle/.local/bin/.SubMiner.AppImage.update',
to: '/home/kyle/.local/bin/SubMiner.AppImage',
},
]);
});
test('updateAppImageFromRelease reports protected command without replacing non-writable AppImage', async () => {
const result = await updateAppImageFromRelease({
release: {
tag_name: 'v0.15.0',
prerelease: false,
draft: false,
assets: [{ name: 'SubMiner.AppImage', browser_download_url: 'https://example.test/app' }],
},
sha256Sums: new Map([['SubMiner.AppImage', appImageHash]]),
appImagePath: '/opt/SubMiner/SubMiner.AppImage',
downloadAsset: async () => appImageBytes,
fs: {
stat: async () => ({
isFile: () => true,
mode: 0o755,
}),
access: async () => {
throw new Error('EACCES');
},
writeFile: async () => {
throw new Error('unexpected write');
},
chmod: async () => {},
rename: async () => {},
unlink: async () => {},
},
});
assert.equal(result.status, 'protected');
assert.equal(result.path, '/opt/SubMiner/SubMiner.AppImage');
assert.match(result.command ?? '', /curl -fSL 'https:\/\/example\.test\/app' -o "\$tmp"/);
assert.match(result.command ?? '', /sha256sum -c -/);
assert.match(result.command ?? '', /sudo mv "\$tmp" '\/opt\/SubMiner\/SubMiner\.AppImage'/);
});
test('buildProtectedAppImageUpdateCommand quotes inputs and verifies checksum before sudo move', () => {
const command = buildProtectedAppImageUpdateCommand(
"https://example.test/Sub Miner.AppImage?sig='abc'",
"/opt/Sub Miner/SubMiner's.AppImage",
'ABCDEF',
);
assert.match(command, /trap 'rm -f "\$tmp"' EXIT/);
assert.match(
command,
/curl -fSL 'https:\/\/example\.test\/Sub Miner\.AppImage\?sig='\\''abc'\\''' -o "\$tmp"/,
);
assert.match(command, /printf '%s %s\\n' 'abcdef' "\$tmp" \| sha256sum -c -/);
assert.match(command, /sudo mv "\$tmp" '\/opt\/Sub Miner\/SubMiner'\\''s\.AppImage'/);
assert.match(command, /sudo chmod \+x '\/opt\/Sub Miner\/SubMiner'\\''s\.AppImage'/);
});
test('updateAppImageFromRelease aborts on hash mismatch', async () => {
const result = await updateAppImageFromRelease({
release: {
tag_name: 'v0.15.0',
prerelease: false,
draft: false,
assets: [{ name: 'SubMiner.AppImage', browser_download_url: 'https://example.test/app' }],
},
sha256Sums: new Map([['SubMiner.AppImage', '0'.repeat(64)]]),
appImagePath: '/home/kyle/.local/bin/SubMiner.AppImage',
downloadAsset: async () => appImageBytes,
fs: {
stat: async () => ({
isFile: () => true,
mode: 0o755,
}),
access: async () => {},
writeFile: async () => {
throw new Error('unexpected write');
},
chmod: async () => {},
rename: async () => {},
unlink: async () => {},
},
});
assert.equal(result.status, 'hash-mismatch');
});
+155
View File
@@ -0,0 +1,155 @@
import { createHash } from 'node:crypto';
import fs from 'node:fs';
import path from 'node:path';
import type { GitHubRelease } from './release-assets';
import { findReleaseAsset } from './release-assets';
type StatLike = {
isFile: () => boolean;
mode?: number;
};
export type AppImageUpdateStatus =
| 'updated'
| 'skipped'
| 'protected'
| 'hash-mismatch'
| 'not-found'
| 'missing-asset';
export interface AppImageUpdateResult {
status: AppImageUpdateStatus;
path?: string;
command?: string;
message?: string;
}
export interface AppImageUpdateFileSystem {
stat: (targetPath: string) => Promise<StatLike>;
access: (targetPath: string) => Promise<void>;
writeFile: (targetPath: string, data: Buffer) => Promise<void>;
chmod: (targetPath: string, mode: number) => Promise<void>;
rename: (fromPath: string, toPath: string) => Promise<void>;
unlink: (targetPath: string) => Promise<void>;
}
function sha256(data: Buffer): string {
return createHash('sha256').update(data).digest('hex');
}
function defaultFs(): AppImageUpdateFileSystem {
return {
stat: (targetPath) => fs.promises.stat(targetPath),
access: async (targetPath) => {
await fs.promises.access(targetPath, fs.constants.W_OK);
},
writeFile: (targetPath, data) => fs.promises.writeFile(targetPath, data),
chmod: (targetPath, mode) => fs.promises.chmod(targetPath, mode),
rename: (fromPath, toPath) => fs.promises.rename(fromPath, toPath),
unlink: async (targetPath) => {
await fs.promises.unlink(targetPath).catch(() => undefined);
},
};
}
function shellQuote(value: string): string {
return `'${value.replace(/'/g, `'\\''`)}'`;
}
export function buildProtectedAppImageUpdateCommand(
assetUrl: string,
appImagePath: string,
expectedSha256: string,
): string {
const quotedUrl = shellQuote(assetUrl);
const quotedPath = shellQuote(appImagePath);
const quotedSha256 = shellQuote(expectedSha256.toLowerCase());
return [
'tmp=$(mktemp)',
'trap \'rm -f "$tmp"\' EXIT',
`curl -fSL ${quotedUrl} -o "$tmp"`,
`printf '%s %s\\n' ${quotedSha256} "$tmp" | sha256sum -c -`,
`sudo mv "$tmp" ${quotedPath}`,
`sudo chmod +x ${quotedPath}`,
].join(' && ');
}
function selectAppImageAsset(release: GitHubRelease, appImagePath: string) {
const basename = path.basename(appImagePath);
return (
findReleaseAsset(release, basename) ??
findReleaseAsset(release, 'SubMiner.AppImage') ??
release.assets.find((asset) => asset.name.endsWith('.AppImage')) ??
null
);
}
export async function updateAppImageFromRelease(options: {
release: GitHubRelease | null;
sha256Sums: Map<string, string>;
appImagePath?: string;
downloadAsset: (url: string) => Promise<Buffer>;
fs?: AppImageUpdateFileSystem;
}): Promise<AppImageUpdateResult> {
if (!options.appImagePath) {
return { status: 'not-found', message: 'No AppImage path detected.' };
}
if (!options.release) return { status: 'missing-asset', message: 'No release found.' };
const asset = selectAppImageAsset(options.release, options.appImagePath);
if (!asset) return { status: 'missing-asset', message: 'Release has no AppImage asset.' };
const expectedSha256 = options.sha256Sums.get(asset.name);
if (!expectedSha256) {
return { status: 'missing-asset', message: `SHA256SUMS.txt has no ${asset.name} entry.` };
}
const fsDeps = options.fs ?? defaultFs();
let stat: StatLike;
try {
stat = await fsDeps.stat(options.appImagePath);
} catch {
return { status: 'not-found', path: options.appImagePath };
}
if (!stat.isFile()) {
return { status: 'skipped', path: options.appImagePath, message: 'AppImage is not a file.' };
}
try {
await fsDeps.access(options.appImagePath);
} catch {
return {
status: 'protected',
path: options.appImagePath,
command: buildProtectedAppImageUpdateCommand(
asset.browser_download_url,
options.appImagePath,
expectedSha256,
),
};
}
const data = await options.downloadAsset(asset.browser_download_url);
const actualSha256 = sha256(data);
if (actualSha256 !== expectedSha256.toLowerCase()) {
return {
status: 'hash-mismatch',
path: options.appImagePath,
message: `Expected ${expectedSha256}, got ${actualSha256}.`,
};
}
const tempPath = path.join(
path.dirname(options.appImagePath),
`.${path.basename(options.appImagePath)}.update`,
);
try {
await fsDeps.writeFile(tempPath, data);
await fsDeps.chmod(tempPath, stat.mode ? stat.mode & 0o777 : 0o755);
await fsDeps.rename(tempPath, options.appImagePath);
return { status: 'updated', path: options.appImagePath };
} catch (error) {
await fsDeps.unlink(tempPath);
throw error;
}
}
@@ -15,13 +15,13 @@ test('looksLikeSubminerLauncher rejects unrelated executable content', () => {
assert.equal(looksLikeSubminerLauncher(Buffer.from('SubMiner launcher binary payload')), true);
});
test('buildProtectedLauncherUpdateCommand uses sudo curl and chmod for protected paths', () => {
test('buildProtectedLauncherUpdateCommand quotes sudo curl and chmod paths', () => {
assert.equal(
buildProtectedLauncherUpdateCommand(
'https://github.com/ksyasuda/SubMiner/releases/latest/download/subminer',
'/usr/local/bin/subminer',
"https://github.com/ksyasuda/SubMiner/releases/latest/download/sub miner?sig='abc'",
"/usr/local/bin/subminer's launcher",
),
'sudo curl -fSL https://github.com/ksyasuda/SubMiner/releases/latest/download/subminer -o /usr/local/bin/subminer && sudo chmod +x /usr/local/bin/subminer',
"sudo curl -fSL 'https://github.com/ksyasuda/SubMiner/releases/latest/download/sub miner?sig='\\''abc'\\''' -o '/usr/local/bin/subminer'\\''s launcher' && sudo chmod +x '/usr/local/bin/subminer'\\''s launcher'",
);
});
@@ -84,7 +84,7 @@ test('updateLauncherAtPath reports protected command without replacing non-writa
});
assert.equal(result.status, 'protected');
assert.match(result.command ?? '', /^sudo curl -fSL https:\/\/example\.test\/subminer/);
assert.match(result.command ?? '', /^sudo curl -fSL 'https:\/\/example\.test\/subminer'/);
});
test('updateLauncherAtPath aborts on hash mismatch and suspicious launcher content', async () => {
+5 -1
View File
@@ -50,13 +50,17 @@ export function buildProtectedLauncherUpdateCommand(
assetUrl: string,
launcherPath: string,
): string {
return `sudo curl -fSL ${assetUrl} -o ${launcherPath} && sudo chmod +x ${launcherPath}`;
return `sudo curl -fSL ${shellQuote(assetUrl)} -o ${shellQuote(launcherPath)} && sudo chmod +x ${shellQuote(launcherPath)}`;
}
function sha256(data: Buffer): string {
return createHash('sha256').update(data).digest('hex');
}
function shellQuote(value: string): string {
return `'${value.replace(/'/g, `'\\''`)}'`;
}
function defaultFs(): LauncherUpdateFileSystem {
return {
readFile: (targetPath) => fs.promises.readFile(targetPath),
@@ -0,0 +1,103 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createHash } from 'node:crypto';
import { execFileSync } from 'node:child_process';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import {
buildProtectedSupportAssetsCommand,
detectSupportAssetDataDirs,
updateSupportAssetsFromRelease,
} from './support-assets';
function sha256(data: Buffer): string {
return createHash('sha256').update(data).digest('hex');
}
function makeSupportAssetsArchive(): { archive: Buffer; tempDir: string } {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-support-assets-test-'));
fs.mkdirSync(path.join(tempDir, 'assets/themes'), { recursive: true });
fs.mkdirSync(path.join(tempDir, 'plugin/subminer'), { recursive: true });
fs.writeFileSync(path.join(tempDir, 'assets/themes/subminer.rasi'), 'new theme\n');
fs.writeFileSync(path.join(tempDir, 'plugin/subminer/main.lua'), 'new plugin\n');
execFileSync('tar', ['-czf', 'subminer-assets.tar.gz', 'assets', 'plugin'], { cwd: tempDir });
return {
archive: fs.readFileSync(path.join(tempDir, 'subminer-assets.tar.gz')),
tempDir,
};
}
test('detectSupportAssetDataDirs only returns Linux rofi theme locations', () => {
assert.deepEqual(
detectSupportAssetDataDirs({
platform: 'darwin',
homeDir: '/Users/kyle',
}),
[],
);
assert.deepEqual(
detectSupportAssetDataDirs({
platform: 'linux',
homeDir: '/home/kyle',
xdgDataHome: '/tmp/xdg-data',
}),
['/tmp/xdg-data/SubMiner', '/usr/local/share/SubMiner', '/usr/share/SubMiner'],
);
});
test('buildProtectedSupportAssetsCommand cleans up temporary extraction directory', () => {
const command = buildProtectedSupportAssetsCommand(
"https://example.test/subminer assets.tar.gz?sig='abc'",
"/usr/local/share/SubMiner's data",
);
assert.match(command, /tmp=\$\(mktemp -d\)/);
assert.match(command, /trap 'rm -rf "\$tmp"' EXIT/);
assert.match(
command,
/curl -fSL 'https:\/\/example\.test\/subminer assets\.tar\.gz\?sig='\\''abc'\\''' -o "\$tmp\/subminer-assets\.tar\.gz"/,
);
assert.match(command, /sudo mkdir -p '\/usr\/local\/share\/SubMiner'\\''s data'\/themes/);
});
test('updateSupportAssetsFromRelease updates only the Linux rofi theme', async () => {
const xdgDataHome = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-xdg-data-'));
const dataDir = path.join(xdgDataHome, 'SubMiner');
fs.mkdirSync(path.join(dataDir, 'themes'), { recursive: true });
fs.mkdirSync(path.join(dataDir, 'plugin/subminer'), { recursive: true });
fs.writeFileSync(path.join(dataDir, 'themes/subminer.rasi'), 'old theme\n');
fs.writeFileSync(path.join(dataDir, 'plugin/subminer/main.lua'), 'old plugin\n');
const { archive, tempDir } = makeSupportAssetsArchive();
try {
const results = await updateSupportAssetsFromRelease({
release: {
tag_name: 'v0.15.0',
assets: [
{
name: 'subminer-assets.tar.gz',
browser_download_url: 'https://example.test/subminer-assets.tar.gz',
},
],
},
sha256Sums: new Map([['subminer-assets.tar.gz', sha256(archive)]]),
downloadAsset: async () => archive,
platform: 'linux',
xdgDataHome,
});
assert.deepEqual(results, [{ status: 'updated', path: dataDir }]);
assert.equal(
fs.readFileSync(path.join(dataDir, 'themes/subminer.rasi'), 'utf8'),
'new theme\n',
);
assert.equal(
fs.readFileSync(path.join(dataDir, 'plugin/subminer/main.lua'), 'utf8'),
'old plugin\n',
);
} finally {
fs.rmSync(xdgDataHome, { recursive: true, force: true });
fs.rmSync(tempDir, { recursive: true, force: true });
}
});
+9 -22
View File
@@ -29,12 +29,6 @@ export function detectSupportAssetDataDirs(options: {
homeDir: string;
xdgDataHome?: string;
}): string[] {
if (options.platform === 'darwin') {
return [
path.join(options.homeDir, 'Library/Application Support/SubMiner'),
'/usr/local/share/SubMiner',
];
}
if (options.platform === 'linux') {
const xdgDataHome = options.xdgDataHome || path.join(options.homeDir, '.local/share');
return [path.join(xdgDataHome, 'SubMiner'), '/usr/local/share/SubMiner', '/usr/share/SubMiner'];
@@ -46,10 +40,10 @@ export function buildProtectedSupportAssetsCommand(assetUrl: string, dataDir: st
const quotedDir = shellQuote(dataDir);
return [
'tmp=$(mktemp -d)',
'trap \'rm -rf "$tmp"\' EXIT',
`curl -fSL ${shellQuote(assetUrl)} -o "$tmp/subminer-assets.tar.gz"`,
'tar -xzf "$tmp/subminer-assets.tar.gz" -C "$tmp"',
`sudo mkdir -p ${quotedDir}/plugin/subminer ${quotedDir}/themes`,
`sudo cp -R "$tmp/plugin/subminer/." ${quotedDir}/plugin/subminer/`,
`sudo mkdir -p ${quotedDir}/themes`,
`sudo cp "$tmp/assets/themes/subminer.rasi" ${quotedDir}/themes/subminer.rasi`,
].join(' && ');
}
@@ -76,12 +70,15 @@ export async function updateSupportAssetsFromRelease(options: {
homeDir?: string;
xdgDataHome?: string;
}): Promise<SupportAssetsUpdateResult[]> {
if ((options.platform ?? process.platform) !== 'linux') {
return [{ status: 'skipped', message: 'Support assets are only installed on Linux.' }];
}
if (!options.release) return [{ status: 'missing-asset', message: 'No release found.' }];
const asset = findReleaseAsset(options.release, 'subminer-assets.tar.gz');
if (!asset) return [{ status: 'missing-asset', message: 'Release has no support assets.' }];
if (!asset) return [{ status: 'missing-asset', message: 'Release has no rofi theme asset.' }];
const expectedSha256 = options.sha256Sums.get('subminer-assets.tar.gz');
if (!expectedSha256) {
return [{ status: 'missing-asset', message: 'SHA256SUMS.txt has no support assets entry.' }];
return [{ status: 'missing-asset', message: 'SHA256SUMS.txt has no rofi theme entry.' }];
}
const dataDirs = detectSupportAssetDataDirs({
@@ -91,12 +88,11 @@ export async function updateSupportAssetsFromRelease(options: {
});
const existingDataDirs: string[] = [];
for (const dataDir of dataDirs) {
const hasPlugin = await pathExists(path.join(dataDir, 'plugin/subminer'));
const hasTheme = await pathExists(path.join(dataDir, 'themes/subminer.rasi'));
if (hasPlugin || hasTheme) existingDataDirs.push(dataDir);
if (hasTheme) existingDataDirs.push(dataDir);
}
if (existingDataDirs.length === 0) {
return [{ status: 'skipped', message: 'No existing support asset install detected.' }];
return [{ status: 'skipped', message: 'No existing rofi theme install detected.' }];
}
const protectedResults: SupportAssetsUpdateResult[] = existingDataDirs
@@ -139,17 +135,8 @@ export async function updateSupportAssetsFromRelease(options: {
await execFileAsync('tar', ['-xzf', archivePath, '-C', tempDir]);
const results: SupportAssetsUpdateResult[] = [...protectedResults];
for (const dataDir of writableDataDirs) {
const targetPluginDir = path.join(dataDir, 'plugin/subminer');
const targetThemePath = path.join(dataDir, 'themes/subminer.rasi');
if (await pathExists(targetPluginDir)) {
await fs.promises.mkdir(targetPluginDir, { recursive: true });
await fs.promises.cp(path.join(tempDir, 'plugin/subminer'), targetPluginDir, {
recursive: true,
force: true,
});
}
if (await pathExists(targetThemePath)) {
await fs.promises.mkdir(path.dirname(targetThemePath), { recursive: true });
await fs.promises.copyFile(
path.join(tempDir, 'assets/themes/subminer.rasi'),
targetThemePath,
@@ -0,0 +1,37 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createUpdateDialogPresenter, type ShowMessageBox } from './update-dialogs';
test('update dialog presenter focuses app before showing macOS dialogs', async () => {
const calls: string[] = [];
const showMessageBox: ShowMessageBox = async (options) => {
calls.push(`dialog:${options.message}`);
return { response: 0 };
};
const presenter = createUpdateDialogPresenter({
platform: 'darwin',
focusApp: () => calls.push('focus'),
showMessageBox,
});
await presenter.showNoUpdateDialog('0.14.0');
assert.deepEqual(calls, ['focus', 'dialog:SubMiner is up to date (v0.14.0)']);
});
test('update dialog presenter does not focus app before showing non-macOS dialogs', async () => {
const calls: string[] = [];
const showMessageBox: ShowMessageBox = async (options) => {
calls.push(`dialog:${options.message}`);
return { response: 0 };
};
const presenter = createUpdateDialogPresenter({
platform: 'linux',
focusApp: () => calls.push('focus'),
showMessageBox,
});
await presenter.showNoUpdateDialog('0.14.0');
assert.deepEqual(calls, ['dialog:SubMiner is up to date (v0.14.0)']);
});
+27
View File
@@ -15,6 +15,12 @@ export type ShowMessageBox = (options: {
cancelId?: number;
}) => Promise<MessageBoxResultLike>;
export interface UpdateDialogPresenterDeps {
showMessageBox: ShowMessageBox;
focusApp?: () => void;
platform?: NodeJS.Platform;
}
export async function showNoUpdateDialog(
showMessageBox: ShowMessageBox,
version: string,
@@ -27,6 +33,27 @@ export async function showNoUpdateDialog(
});
}
function maybeFocusAppForDialog(deps: UpdateDialogPresenterDeps): void {
if ((deps.platform ?? process.platform) !== 'darwin') return;
deps.focusApp?.();
}
export function createUpdateDialogPresenter(deps: UpdateDialogPresenterDeps) {
const showFocusedMessageBox: ShowMessageBox = async (options) => {
maybeFocusAppForDialog(deps);
return deps.showMessageBox(options);
};
return {
showNoUpdateDialog: (version: string) => showNoUpdateDialog(showFocusedMessageBox, version),
showUpdateAvailableDialog: (version: string) =>
showUpdateAvailableDialog(showFocusedMessageBox, version),
showUpdateFailedDialog: (message: string) =>
showUpdateFailedDialog(showFocusedMessageBox, message),
showRestartDialog: () => showRestartDialog(showFocusedMessageBox),
};
}
export async function showUpdateAvailableDialog(
showMessageBox: ShowMessageBox,
version: string,
@@ -47,3 +47,24 @@ test('notifyUpdateAvailable logs osd fallback when overlay notification fails',
assert.deepEqual(calls, ['Update OSD notification failed: mpv disconnected']);
});
test('notifyUpdateAvailable logs non-error osd failures with thrown value', async () => {
const calls: string[] = [];
await notifyUpdateAvailable(
{ notificationType: 'osd', version: '0.15.0' },
{
showSystemNotification: () => {
calls.push('system');
},
showOsdNotification: async () => {
throw 'mpv disconnected';
},
log: (message) => {
calls.push(message);
},
},
);
assert.deepEqual(calls, ['Update OSD notification failed: mpv disconnected']);
});
@@ -20,7 +20,8 @@ export async function notifyUpdateAvailable(
try {
await deps.showOsdNotification(message);
} catch (error) {
deps.log(`Update OSD notification failed: ${(error as Error).message}`);
const reason = error instanceof Error ? error.message : String(error);
deps.log(`Update OSD notification failed: ${reason}`);
}
}
}
+80 -1
View File
@@ -44,7 +44,9 @@ function createDeps(overrides: Partial<UpdateServiceDeps> = {}) {
calls.push('restart-dialog');
return 'later';
},
quitAndInstall: () => calls.push('quit-install'),
quitAndInstall: () => {
calls.push('quit-install');
},
notifyUpdateAvailable: async (version) => {
calls.push(`notify:${version}`);
},
@@ -90,6 +92,32 @@ test('manual update check falls back to GitHub release when app metadata is unav
assert.deepEqual(calls, ['available-dialog:0.15.0']);
});
test('manual update check reports available when no update asset was applied', async () => {
const { deps, calls } = createDeps({
checkAppUpdate: async () => ({ available: false, version: '0.14.0', canUpdate: false }),
fetchLatestStableRelease: async () => ({
tag_name: 'v0.15.0',
prerelease: false,
draft: false,
assets: [],
}),
showUpdateAvailableDialog: async (version) => {
calls.push(`available-dialog:${version}`);
return 'update';
},
updateLauncher: async (_launcherPath, channel) => {
calls.push(`launcher:${channel}`);
return { status: 'skipped' };
},
});
const service = createUpdateService(deps);
const result = await service.checkForUpdates({ source: 'manual' });
assert.equal(result.status, 'update-available');
assert.deepEqual(calls, ['available-dialog:0.15.0', 'launcher:stable']);
});
test('automatic update check skips inside configured interval', async () => {
const { deps, calls, setState } = createDeps();
setState({ lastAutomaticCheckAt: 1_000_000 - 60 * 60 * 1000 });
@@ -141,6 +169,57 @@ test('concurrent update checks share one in-flight check', async () => {
assert.equal(checkCount, 1);
});
test('manual update check does not reuse in-flight automatic check', async () => {
let checkCount = 0;
const resolveChecks: Array<(value: { available: boolean; version: string }) => void> = [];
const { deps } = createDeps({
checkAppUpdate: () =>
new Promise((resolve) => {
checkCount += 1;
resolveChecks.push(resolve);
}),
});
const service = createUpdateService(deps);
const automatic = service.checkForUpdates({ source: 'automatic', force: true });
const manual = service.checkForUpdates({ source: 'manual' });
await Promise.resolve();
assert.equal(checkCount, 2);
for (const resolve of resolveChecks) {
resolve({ available: false, version: '0.14.0' });
}
await Promise.all([automatic, manual]);
});
test('manual update check passes selected GitHub release to launcher update', async () => {
const selectedRelease = {
tag_name: 'v0.15.0',
prerelease: false,
draft: false,
assets: [],
};
let forwardedRelease: unknown;
const { deps, calls } = createDeps({
checkAppUpdate: async () => ({ available: true, version: '0.15.0' }),
fetchLatestStableRelease: async () => selectedRelease,
showUpdateAvailableDialog: async (version) => {
calls.push(`available-dialog:${version}`);
return 'update';
},
updateLauncher: (async (...args: unknown[]) => {
calls.push(`launcher:${args[1]}`);
forwardedRelease = args[2];
return { status: 'updated' };
}) as UpdateServiceDeps['updateLauncher'],
});
const service = createUpdateService(deps);
const result = await service.checkForUpdates({ source: 'manual' });
assert.equal(result.status, 'updated');
assert.equal(forwardedRelease, selectedRelease);
});
test('manual prerelease update check uses prerelease release and launcher channel', async () => {
const { deps, calls } = createDeps({
getConfig: () => ({
+17 -7
View File
@@ -43,13 +43,14 @@ export interface UpdateServiceDeps {
updateLauncher: (
launcherPath?: string,
channel?: UpdateChannel,
release?: GitHubRelease | null,
) => Promise<{ status: string; command?: string }>;
showNoUpdateDialog: (version: string) => Promise<void>;
showUpdateAvailableDialog: (version: string) => Promise<'update' | 'close'>;
showUpdateFailedDialog: (message: string) => Promise<void>;
downloadAppUpdate: () => Promise<void>;
showRestartDialog: () => Promise<'restart' | 'later'>;
quitAndInstall: () => void;
quitAndInstall: () => void | Promise<void>;
notifyUpdateAvailable: (version: string) => Promise<void>;
log: (message: string) => void;
setTimeout?: (callback: () => void, delayMs: number) => unknown;
@@ -96,7 +97,7 @@ function summarizeError(error: unknown): string {
}
export function createUpdateService(deps: UpdateServiceDeps) {
let inFlight: Promise<UpdateCheckResult> | null = null;
const inFlightBySource = new Map<UpdateCheckSource, Promise<UpdateCheckResult>>();
async function runCheck(request: UpdateCheckRequest): Promise<UpdateCheckResult> {
const now = deps.now();
@@ -157,17 +158,24 @@ export function createUpdateService(deps: UpdateServiceDeps) {
return { status: 'update-available', version: latest.version };
}
let appUpdateApplied = false;
if (appUpdate.available && appUpdate.canUpdate !== false) {
await deps.downloadAppUpdate();
appUpdateApplied = true;
}
const launcherResult = await deps.updateLauncher(request.launcherPath, channel);
const launcherResult = await deps.updateLauncher(request.launcherPath, channel, release);
if (launcherResult.status === 'protected' && launcherResult.command) {
deps.log(`Launcher update requires manual command: ${launcherResult.command}`);
}
const launcherUpdateApplied = launcherResult.status === 'updated';
if (!appUpdateApplied && !launcherUpdateApplied) {
return { status: 'update-available', version: latest.version };
}
const restartChoice = await deps.showRestartDialog();
if (restartChoice === 'restart') {
deps.quitAndInstall();
await deps.quitAndInstall();
}
return { status: 'updated', version: latest.version };
} catch (error) {
@@ -183,11 +191,13 @@ export function createUpdateService(deps: UpdateServiceDeps) {
return {
checkForUpdates(request: UpdateCheckRequest): Promise<UpdateCheckResult> {
const inFlight = inFlightBySource.get(request.source);
if (inFlight) return inFlight;
inFlight = runCheck(request).finally(() => {
inFlight = null;
const nextInFlight = runCheck(request).finally(() => {
inFlightBySource.delete(request.source);
});
return inFlight;
inFlightBySource.set(request.source, nextInFlight);
return nextInFlight;
},
startAutomaticChecks(options: { startupDelayMs?: number; pollIntervalMs?: number } = {}): void {
const setTimeoutFn = deps.setTimeout ?? setTimeout;