mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-05-26 00:55:16 -07:00
feat: add auto update support
This commit is contained in:
@@ -0,0 +1,55 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { configureAutoUpdater, type ElectronAutoUpdaterLike } from './app-updater';
|
||||
|
||||
type UpdaterLogger = {
|
||||
info: (message: string) => void;
|
||||
debug: (message: string) => void;
|
||||
warn: (message: string) => void;
|
||||
error: (message: string) => void;
|
||||
};
|
||||
|
||||
test('configureAutoUpdater disables eager update behavior and suppresses info logging', () => {
|
||||
const logged: string[] = [];
|
||||
const updater: ElectronAutoUpdaterLike & { logger?: UpdaterLogger | null } = {
|
||||
autoDownload: true,
|
||||
allowPrerelease: true,
|
||||
allowDowngrade: true,
|
||||
logger: null,
|
||||
checkForUpdates: async () => null,
|
||||
downloadUpdate: async () => [],
|
||||
quitAndInstall: () => {},
|
||||
};
|
||||
|
||||
configureAutoUpdater(updater, (message) => logged.push(message));
|
||||
|
||||
assert.equal(updater.autoDownload, false);
|
||||
assert.equal(updater.allowPrerelease, false);
|
||||
assert.equal(updater.allowDowngrade, false);
|
||||
assert.ok(updater.logger);
|
||||
|
||||
updater.logger.info('Checking for update');
|
||||
updater.logger.debug('Generated new staging user ID');
|
||||
updater.logger.warn('metadata missing');
|
||||
updater.logger.error('download failed');
|
||||
|
||||
assert.deepEqual(logged, ['metadata missing', 'download failed']);
|
||||
});
|
||||
|
||||
test('configureAutoUpdater allows prereleases only for the prerelease channel', () => {
|
||||
const updater: ElectronAutoUpdaterLike = {
|
||||
autoDownload: true,
|
||||
allowPrerelease: false,
|
||||
allowDowngrade: true,
|
||||
logger: null,
|
||||
checkForUpdates: async () => null,
|
||||
downloadUpdate: async () => [],
|
||||
quitAndInstall: () => {},
|
||||
};
|
||||
|
||||
configureAutoUpdater(updater, () => {}, 'prerelease');
|
||||
assert.equal(updater.allowPrerelease, true);
|
||||
|
||||
configureAutoUpdater(updater, () => {}, 'stable');
|
||||
assert.equal(updater.allowPrerelease, false);
|
||||
});
|
||||
@@ -0,0 +1,92 @@
|
||||
import { autoUpdater as electronAutoUpdater } from 'electron-updater';
|
||||
import type { UpdateChannel } from '../../../types/config';
|
||||
import { compareSemverLike } from './release-assets';
|
||||
|
||||
export interface AppUpdateCheckResult {
|
||||
available: boolean;
|
||||
version: string;
|
||||
canUpdate: boolean;
|
||||
}
|
||||
|
||||
export interface ElectronUpdaterLoggerLike {
|
||||
info?: (message: string, ...args: unknown[]) => void;
|
||||
debug?: (message: string, ...args: unknown[]) => void;
|
||||
warn?: (message: string, ...args: unknown[]) => void;
|
||||
error?: (message: string, ...args: unknown[]) => void;
|
||||
}
|
||||
|
||||
export interface ElectronAutoUpdaterLike {
|
||||
autoDownload: boolean;
|
||||
allowPrerelease: boolean;
|
||||
allowDowngrade: boolean;
|
||||
logger?: ElectronUpdaterLoggerLike | null;
|
||||
checkForUpdates: () => Promise<{
|
||||
updateInfo?: {
|
||||
version?: string;
|
||||
};
|
||||
} | null>;
|
||||
downloadUpdate: () => Promise<unknown>;
|
||||
quitAndInstall: (isSilent?: boolean, isForceRunAfter?: boolean) => void;
|
||||
}
|
||||
|
||||
export function configureAutoUpdater(
|
||||
updater: ElectronAutoUpdaterLike,
|
||||
log: (message: string) => void = () => {},
|
||||
channel: UpdateChannel = 'stable',
|
||||
): ElectronAutoUpdaterLike {
|
||||
updater.autoDownload = false;
|
||||
updater.allowPrerelease = channel === 'prerelease';
|
||||
updater.allowDowngrade = false;
|
||||
updater.logger = {
|
||||
info: () => {},
|
||||
debug: () => {},
|
||||
warn: (message) => log(message),
|
||||
error: (message) => log(message),
|
||||
};
|
||||
return updater;
|
||||
}
|
||||
|
||||
export function createElectronAppUpdater(options: {
|
||||
currentVersion: string;
|
||||
isPackaged: boolean;
|
||||
updater?: ElectronAutoUpdaterLike;
|
||||
log: (message: string) => void;
|
||||
getChannel?: () => UpdateChannel;
|
||||
}) {
|
||||
const getChannel = options.getChannel ?? (() => 'stable' as const);
|
||||
const updater = configureAutoUpdater(
|
||||
options.updater ?? electronAutoUpdater,
|
||||
options.log,
|
||||
getChannel(),
|
||||
);
|
||||
|
||||
return {
|
||||
async checkForUpdates(channel?: UpdateChannel): Promise<AppUpdateCheckResult> {
|
||||
if (!options.isPackaged) {
|
||||
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;
|
||||
return {
|
||||
available: compareSemverLike(version, options.currentVersion) > 0,
|
||||
version,
|
||||
canUpdate: true,
|
||||
};
|
||||
},
|
||||
async downloadUpdate(): Promise<void> {
|
||||
if (!options.isPackaged) {
|
||||
options.log('Skipping app update download because this build is not packaged.');
|
||||
return;
|
||||
}
|
||||
await updater.downloadUpdate();
|
||||
},
|
||||
quitAndInstall(): void {
|
||||
updater.quitAndInstall(false, true);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createHash } from 'node:crypto';
|
||||
import {
|
||||
buildProtectedLauncherUpdateCommand,
|
||||
looksLikeSubminerLauncher,
|
||||
updateLauncherAtPath,
|
||||
} from './launcher-updater';
|
||||
|
||||
const launcherBytes = Buffer.from('#!/usr/bin/env bash\n# SubMiner launcher\nexec SubMiner "$@"\n');
|
||||
const launcherHash = createHash('sha256').update(launcherBytes).digest('hex');
|
||||
|
||||
test('looksLikeSubminerLauncher rejects unrelated executable content', () => {
|
||||
assert.equal(looksLikeSubminerLauncher(Buffer.from('#!/bin/sh\necho nope\n')), false);
|
||||
assert.equal(looksLikeSubminerLauncher(Buffer.from('SubMiner launcher binary payload')), true);
|
||||
});
|
||||
|
||||
test('buildProtectedLauncherUpdateCommand uses sudo curl and chmod for protected paths', () => {
|
||||
assert.equal(
|
||||
buildProtectedLauncherUpdateCommand(
|
||||
'https://github.com/ksyasuda/SubMiner/releases/latest/download/subminer',
|
||||
'/usr/local/bin/subminer',
|
||||
),
|
||||
'sudo curl -fSL https://github.com/ksyasuda/SubMiner/releases/latest/download/subminer -o /usr/local/bin/subminer && sudo chmod +x /usr/local/bin/subminer',
|
||||
);
|
||||
});
|
||||
|
||||
test('updateLauncherAtPath verifies hash and atomically replaces writable launcher', async () => {
|
||||
const writes: Array<{ path: string; data: Buffer }> = [];
|
||||
const renames: Array<{ from: string; to: string }> = [];
|
||||
const chmods: Array<{ path: string; mode: number }> = [];
|
||||
|
||||
const result = await updateLauncherAtPath({
|
||||
launcherPath: '/home/kyle/.local/bin/subminer',
|
||||
assetUrl: 'https://example.test/subminer',
|
||||
expectedSha256: launcherHash,
|
||||
download: async () => launcherBytes,
|
||||
fs: {
|
||||
readFile: async () => Buffer.from('#!/bin/sh\n# SubMiner launcher\n'),
|
||||
stat: async () => ({ isFile: () => true, mode: 0o755 }),
|
||||
access: async () => undefined,
|
||||
writeFile: async (filePath, data) => {
|
||||
writes.push({ path: filePath, data: Buffer.from(data) });
|
||||
},
|
||||
chmod: async (filePath, mode) => {
|
||||
chmods.push({ path: filePath, mode });
|
||||
},
|
||||
rename: async (from, to) => {
|
||||
renames.push({ from, to });
|
||||
},
|
||||
unlink: async () => undefined,
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(result.status, 'updated');
|
||||
assert.equal(writes.length, 1);
|
||||
assert.equal(writes[0]!.path, '/home/kyle/.local/bin/.subminer.update');
|
||||
assert.equal(writes[0]!.data.equals(launcherBytes), true);
|
||||
assert.deepEqual(chmods, [{ path: '/home/kyle/.local/bin/.subminer.update', mode: 0o755 }]);
|
||||
assert.deepEqual(renames, [
|
||||
{ from: '/home/kyle/.local/bin/.subminer.update', to: '/home/kyle/.local/bin/subminer' },
|
||||
]);
|
||||
});
|
||||
|
||||
test('updateLauncherAtPath reports protected command without replacing non-writable launcher', async () => {
|
||||
const result = await updateLauncherAtPath({
|
||||
launcherPath: '/usr/local/bin/subminer',
|
||||
assetUrl: 'https://example.test/subminer',
|
||||
expectedSha256: launcherHash,
|
||||
download: async () => launcherBytes,
|
||||
fs: {
|
||||
readFile: async () => Buffer.from('#!/bin/sh\n# SubMiner launcher\n'),
|
||||
stat: async () => ({ isFile: () => true, mode: 0o755 }),
|
||||
access: async () => {
|
||||
throw Object.assign(new Error('EACCES'), { code: 'EACCES' });
|
||||
},
|
||||
writeFile: async () => {
|
||||
throw new Error('unexpected write');
|
||||
},
|
||||
chmod: async () => undefined,
|
||||
rename: async () => undefined,
|
||||
unlink: async () => undefined,
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(result.status, 'protected');
|
||||
assert.match(result.command ?? '', /^sudo curl -fSL https:\/\/example\.test\/subminer/);
|
||||
});
|
||||
|
||||
test('updateLauncherAtPath aborts on hash mismatch and suspicious launcher content', async () => {
|
||||
const suspicious = await updateLauncherAtPath({
|
||||
launcherPath: '/home/kyle/bin/subminer',
|
||||
assetUrl: 'https://example.test/subminer',
|
||||
expectedSha256: launcherHash,
|
||||
download: async () => launcherBytes,
|
||||
fs: {
|
||||
readFile: async () => Buffer.from('#!/bin/sh\necho not-subminer\n'),
|
||||
stat: async () => ({ isFile: () => true, mode: 0o755 }),
|
||||
access: async () => undefined,
|
||||
writeFile: async () => undefined,
|
||||
chmod: async () => undefined,
|
||||
rename: async () => undefined,
|
||||
unlink: async () => undefined,
|
||||
},
|
||||
});
|
||||
const mismatch = await updateLauncherAtPath({
|
||||
launcherPath: '/home/kyle/.local/bin/subminer',
|
||||
assetUrl: 'https://example.test/subminer',
|
||||
expectedSha256: '0'.repeat(64),
|
||||
download: async () => launcherBytes,
|
||||
fs: {
|
||||
readFile: async () => Buffer.from('#!/bin/sh\n# SubMiner launcher\n'),
|
||||
stat: async () => ({ isFile: () => true, mode: 0o755 }),
|
||||
access: async () => undefined,
|
||||
writeFile: async () => {
|
||||
throw new Error('unexpected write');
|
||||
},
|
||||
chmod: async () => undefined,
|
||||
rename: async () => undefined,
|
||||
unlink: async () => undefined,
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(suspicious.status, 'skipped');
|
||||
assert.equal(mismatch.status, 'hash-mismatch');
|
||||
});
|
||||
@@ -0,0 +1,184 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import type { GitHubRelease } from './release-assets';
|
||||
import { findReleaseAsset } from './release-assets';
|
||||
|
||||
type StatLike = {
|
||||
isFile: () => boolean;
|
||||
mode?: number;
|
||||
};
|
||||
|
||||
export type LauncherUpdateStatus =
|
||||
| 'updated'
|
||||
| 'skipped'
|
||||
| 'protected'
|
||||
| 'hash-mismatch'
|
||||
| 'not-found'
|
||||
| 'missing-asset';
|
||||
|
||||
export interface LauncherUpdateResult {
|
||||
status: LauncherUpdateStatus;
|
||||
path?: string;
|
||||
command?: string;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export interface LauncherUpdateFileSystem {
|
||||
readFile: (targetPath: string) => Promise<Buffer | string>;
|
||||
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>;
|
||||
}
|
||||
|
||||
export function looksLikeSubminerLauncher(content: Buffer | string): boolean {
|
||||
const text = Buffer.isBuffer(content) ? content.toString('utf8') : content;
|
||||
return (
|
||||
text.includes('SubMiner launcher') ||
|
||||
text.includes('Launch MPV with SubMiner') ||
|
||||
text.includes('SUBMINER_APPIMAGE_PATH') ||
|
||||
text.includes('SubMiner.app') ||
|
||||
text.includes('SubMiner.AppImage')
|
||||
);
|
||||
}
|
||||
|
||||
export function buildProtectedLauncherUpdateCommand(
|
||||
assetUrl: string,
|
||||
launcherPath: string,
|
||||
): string {
|
||||
return `sudo curl -fSL ${assetUrl} -o ${launcherPath} && sudo chmod +x ${launcherPath}`;
|
||||
}
|
||||
|
||||
function sha256(data: Buffer): string {
|
||||
return createHash('sha256').update(data).digest('hex');
|
||||
}
|
||||
|
||||
function defaultFs(): LauncherUpdateFileSystem {
|
||||
return {
|
||||
readFile: (targetPath) => fs.promises.readFile(targetPath),
|
||||
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);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function updateLauncherAtPath(options: {
|
||||
launcherPath: string;
|
||||
assetUrl: string;
|
||||
expectedSha256: string;
|
||||
download: () => Promise<Buffer>;
|
||||
fs?: LauncherUpdateFileSystem;
|
||||
}): Promise<LauncherUpdateResult> {
|
||||
const fsDeps = options.fs ?? defaultFs();
|
||||
let stat: StatLike;
|
||||
try {
|
||||
stat = await fsDeps.stat(options.launcherPath);
|
||||
} catch {
|
||||
return { status: 'not-found', path: options.launcherPath };
|
||||
}
|
||||
if (!stat.isFile()) {
|
||||
return { status: 'skipped', path: options.launcherPath, message: 'Launcher is not a file.' };
|
||||
}
|
||||
|
||||
const existing = await fsDeps.readFile(options.launcherPath);
|
||||
if (!looksLikeSubminerLauncher(existing)) {
|
||||
return {
|
||||
status: 'skipped',
|
||||
path: options.launcherPath,
|
||||
message: 'Existing executable does not look like a SubMiner launcher.',
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
await fsDeps.access(options.launcherPath);
|
||||
} catch {
|
||||
return {
|
||||
status: 'protected',
|
||||
path: options.launcherPath,
|
||||
command: buildProtectedLauncherUpdateCommand(options.assetUrl, options.launcherPath),
|
||||
};
|
||||
}
|
||||
|
||||
const data = await options.download();
|
||||
const actualSha256 = sha256(data);
|
||||
if (actualSha256 !== options.expectedSha256.toLowerCase()) {
|
||||
return {
|
||||
status: 'hash-mismatch',
|
||||
path: options.launcherPath,
|
||||
message: `Expected ${options.expectedSha256}, got ${actualSha256}.`,
|
||||
};
|
||||
}
|
||||
|
||||
const tempPath = path.join(path.dirname(options.launcherPath), '.subminer.update');
|
||||
try {
|
||||
await fsDeps.writeFile(tempPath, data);
|
||||
await fsDeps.chmod(tempPath, stat.mode ? stat.mode & 0o777 : 0o755);
|
||||
await fsDeps.rename(tempPath, options.launcherPath);
|
||||
return { status: 'updated', path: options.launcherPath };
|
||||
} catch (error) {
|
||||
await fsDeps.unlink(tempPath);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export function detectLauncherCandidates(options: {
|
||||
platform: NodeJS.Platform;
|
||||
homeDir: string;
|
||||
explicitPath?: string;
|
||||
}): string[] {
|
||||
const candidates: string[] = [];
|
||||
if (options.explicitPath) candidates.push(options.explicitPath);
|
||||
if (options.platform === 'darwin') {
|
||||
candidates.push('/usr/local/bin/subminer', '/opt/homebrew/bin/subminer');
|
||||
candidates.push(path.join(options.homeDir, '.local/bin/subminer'));
|
||||
} else if (options.platform === 'linux') {
|
||||
candidates.push(path.join(options.homeDir, '.local/bin/subminer'));
|
||||
candidates.push('/usr/local/bin/subminer', '/usr/bin/subminer');
|
||||
}
|
||||
return [...new Set(candidates)];
|
||||
}
|
||||
|
||||
export async function updateLauncherFromRelease(options: {
|
||||
release: GitHubRelease | null;
|
||||
sha256Sums: Map<string, string>;
|
||||
launcherPath?: string;
|
||||
platform?: NodeJS.Platform;
|
||||
homeDir?: string;
|
||||
downloadAsset: (url: string) => Promise<Buffer>;
|
||||
exists?: (targetPath: string) => boolean;
|
||||
}): Promise<LauncherUpdateResult> {
|
||||
if (!options.release) return { status: 'missing-asset', message: 'No release found.' };
|
||||
const asset = findReleaseAsset(options.release, 'subminer');
|
||||
if (!asset) return { status: 'missing-asset', message: 'Release has no subminer asset.' };
|
||||
const expectedSha256 = options.sha256Sums.get('subminer');
|
||||
if (!expectedSha256) {
|
||||
return { status: 'missing-asset', message: 'SHA256SUMS.txt has no subminer entry.' };
|
||||
}
|
||||
|
||||
const exists = options.exists ?? fs.existsSync;
|
||||
const candidates = detectLauncherCandidates({
|
||||
platform: options.platform ?? process.platform,
|
||||
homeDir: options.homeDir ?? os.homedir(),
|
||||
explicitPath: options.launcherPath,
|
||||
});
|
||||
const targetPath = candidates.find((candidate) => exists(candidate));
|
||||
if (!targetPath) return { status: 'not-found', message: 'No installed launcher detected.' };
|
||||
|
||||
return await updateLauncherAtPath({
|
||||
launcherPath: targetPath,
|
||||
assetUrl: asset.browser_download_url,
|
||||
expectedSha256,
|
||||
download: () => options.downloadAsset(asset.browser_download_url),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
compareSemverLike,
|
||||
findReleaseAsset,
|
||||
parseSha256Sums,
|
||||
selectLatestStableRelease,
|
||||
} from './release-assets';
|
||||
|
||||
test('parseSha256Sums maps release asset basenames to hashes', () => {
|
||||
const sums = parseSha256Sums(`
|
||||
1111111111111111111111111111111111111111111111111111111111111111 SubMiner.AppImage
|
||||
2222222222222222222222222222222222222222222222222222222222222222 *subminer
|
||||
`);
|
||||
|
||||
assert.equal(
|
||||
sums.get('SubMiner.AppImage'),
|
||||
'1111111111111111111111111111111111111111111111111111111111111111',
|
||||
);
|
||||
assert.equal(
|
||||
sums.get('subminer'),
|
||||
'2222222222222222222222222222222222222222222222222222222222222222',
|
||||
);
|
||||
});
|
||||
|
||||
test('selectLatestStableRelease ignores drafts and prereleases', () => {
|
||||
const release = selectLatestStableRelease([
|
||||
{ tag_name: 'v0.16.0-beta.1', draft: false, prerelease: true, assets: [] },
|
||||
{ tag_name: 'v0.15.0', draft: true, prerelease: false, assets: [] },
|
||||
{ tag_name: 'v0.14.1', draft: false, prerelease: false, assets: [] },
|
||||
]);
|
||||
|
||||
assert.equal(release?.tag_name, 'v0.14.1');
|
||||
});
|
||||
|
||||
test('selectLatestStableRelease can opt into prerelease releases', () => {
|
||||
const release = selectLatestStableRelease(
|
||||
[
|
||||
{ tag_name: 'v0.16.0-beta.1', draft: false, prerelease: true, assets: [] },
|
||||
{ tag_name: 'v0.15.0', draft: false, prerelease: false, assets: [] },
|
||||
],
|
||||
'prerelease',
|
||||
);
|
||||
|
||||
assert.equal(release?.tag_name, 'v0.16.0-beta.1');
|
||||
});
|
||||
|
||||
test('compareSemverLike orders prerelease identifiers within the same base version', () => {
|
||||
assert.equal(compareSemverLike('0.15.0-beta.2', '0.15.0-beta.1') > 0, true);
|
||||
assert.equal(compareSemverLike('0.15.0-rc.1', '0.15.0-beta.2') > 0, true);
|
||||
assert.equal(compareSemverLike('0.15.0', '0.15.0-rc.1') > 0, true);
|
||||
});
|
||||
|
||||
test('findReleaseAsset finds exact asset names only', () => {
|
||||
const release = {
|
||||
tag_name: 'v0.14.1',
|
||||
draft: false,
|
||||
prerelease: false,
|
||||
assets: [
|
||||
{ name: 'subminer', browser_download_url: 'https://example.test/subminer' },
|
||||
{ name: 'subminer-assets.tar.gz', browser_download_url: 'https://example.test/assets' },
|
||||
],
|
||||
};
|
||||
|
||||
assert.equal(
|
||||
findReleaseAsset(release, 'subminer')?.browser_download_url,
|
||||
'https://example.test/subminer',
|
||||
);
|
||||
assert.equal(findReleaseAsset(release, 'latest.yml'), null);
|
||||
});
|
||||
@@ -0,0 +1,186 @@
|
||||
import type { UpdateChannel } from '../../../types/config';
|
||||
|
||||
export interface GitHubReleaseAsset {
|
||||
name: string;
|
||||
browser_download_url: string;
|
||||
size?: number;
|
||||
}
|
||||
|
||||
export interface GitHubRelease {
|
||||
tag_name: string;
|
||||
name?: string;
|
||||
draft?: boolean;
|
||||
prerelease?: boolean;
|
||||
html_url?: string;
|
||||
assets: GitHubReleaseAsset[];
|
||||
}
|
||||
|
||||
export interface FetchResponseLike {
|
||||
ok: boolean;
|
||||
status: number;
|
||||
statusText?: string;
|
||||
json: () => Promise<unknown>;
|
||||
text: () => Promise<string>;
|
||||
arrayBuffer: () => Promise<ArrayBuffer>;
|
||||
}
|
||||
|
||||
export type FetchLike = (url: string, init?: Record<string, unknown>) => Promise<FetchResponseLike>;
|
||||
|
||||
export function parseSha256Sums(text: string): Map<string, string> {
|
||||
const sums = new Map<string, string>();
|
||||
for (const line of text.split(/\r?\n/)) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith('#')) continue;
|
||||
const match = trimmed.match(/^([a-fA-F0-9]{64})\s+\*?(.+)$/);
|
||||
if (!match) continue;
|
||||
const [, hash, name] = match;
|
||||
if (!hash || !name) continue;
|
||||
sums.set(name.trim().split(/[\\/]/).pop() ?? name.trim(), hash.toLowerCase());
|
||||
}
|
||||
return sums;
|
||||
}
|
||||
|
||||
export function selectLatestStableRelease(
|
||||
releases: GitHubRelease[],
|
||||
channel: UpdateChannel = 'stable',
|
||||
): GitHubRelease | null {
|
||||
return (
|
||||
releases.find(
|
||||
(release) => !release.draft && (channel === 'prerelease' || !release.prerelease),
|
||||
) ?? null
|
||||
);
|
||||
}
|
||||
|
||||
export function findReleaseAsset(
|
||||
release: Pick<GitHubRelease, 'assets'>,
|
||||
assetName: string,
|
||||
): GitHubReleaseAsset | null {
|
||||
return release.assets.find((asset) => asset.name === assetName) ?? null;
|
||||
}
|
||||
|
||||
function assertRelease(value: unknown): GitHubRelease | null {
|
||||
if (!value || typeof value !== 'object') return null;
|
||||
const release = value as Partial<GitHubRelease>;
|
||||
if (typeof release.tag_name !== 'string' || !Array.isArray(release.assets)) return null;
|
||||
return {
|
||||
tag_name: release.tag_name,
|
||||
name: typeof release.name === 'string' ? release.name : undefined,
|
||||
draft: release.draft === true,
|
||||
prerelease: release.prerelease === true,
|
||||
html_url: typeof release.html_url === 'string' ? release.html_url : undefined,
|
||||
assets: release.assets
|
||||
.filter((asset): asset is GitHubReleaseAsset => {
|
||||
const candidate = asset as Partial<GitHubReleaseAsset>;
|
||||
return (
|
||||
typeof candidate.name === 'string' && typeof candidate.browser_download_url === 'string'
|
||||
);
|
||||
})
|
||||
.map((asset) => ({
|
||||
name: asset.name,
|
||||
browser_download_url: asset.browser_download_url,
|
||||
size: typeof asset.size === 'number' ? asset.size : undefined,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchLatestStableRelease(options: {
|
||||
fetch: FetchLike;
|
||||
owner?: string;
|
||||
repo?: string;
|
||||
channel?: UpdateChannel;
|
||||
}): Promise<GitHubRelease | null> {
|
||||
const owner = options.owner ?? 'ksyasuda';
|
||||
const repo = options.repo ?? 'SubMiner';
|
||||
const response = await options.fetch(`https://api.github.com/repos/${owner}/${repo}/releases`, {
|
||||
headers: {
|
||||
Accept: 'application/vnd.github+json',
|
||||
'User-Agent': 'SubMiner updater',
|
||||
},
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`GitHub releases request failed with ${response.status}`);
|
||||
}
|
||||
const body = await response.json();
|
||||
if (!Array.isArray(body)) return null;
|
||||
return selectLatestStableRelease(
|
||||
body.map(assertRelease).filter((item): item is GitHubRelease => item !== null),
|
||||
options.channel,
|
||||
);
|
||||
}
|
||||
|
||||
export async function fetchReleaseAssetText(fetch: FetchLike, assetUrl: string): Promise<string> {
|
||||
const response = await fetch(assetUrl);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Release asset request failed with ${response.status}`);
|
||||
}
|
||||
return await response.text();
|
||||
}
|
||||
|
||||
export async function fetchReleaseAssetBuffer(fetch: FetchLike, assetUrl: string): Promise<Buffer> {
|
||||
const response = await fetch(assetUrl);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Release asset request failed with ${response.status}`);
|
||||
}
|
||||
return Buffer.from(await response.arrayBuffer());
|
||||
}
|
||||
|
||||
export function parseReleaseVersion(
|
||||
release: Pick<GitHubRelease, 'tag_name'> | null,
|
||||
): string | null {
|
||||
if (!release) return null;
|
||||
return release.tag_name.replace(/^v/i, '');
|
||||
}
|
||||
|
||||
export function compareSemverLike(a: string, b: string): number {
|
||||
const parse = (
|
||||
value: string,
|
||||
): {
|
||||
core: number[];
|
||||
prerelease: Array<number | string>;
|
||||
} => {
|
||||
const normalized = value.replace(/^v/i, '');
|
||||
const [coreText = '', ...prereleaseParts] = normalized.split('-');
|
||||
const core = coreText
|
||||
.split('.')
|
||||
.slice(0, 3)
|
||||
.map((part) => Number.parseInt(part, 10) || 0);
|
||||
while (core.length < 3) core.push(0);
|
||||
const prereleaseText = prereleaseParts.join('-');
|
||||
return {
|
||||
core,
|
||||
prerelease: prereleaseText
|
||||
? prereleaseText.split('.').map((part) => {
|
||||
const numeric = Number.parseInt(part, 10);
|
||||
return /^\d+$/.test(part) ? numeric : part;
|
||||
})
|
||||
: [],
|
||||
};
|
||||
};
|
||||
const left = parse(a);
|
||||
const right = parse(b);
|
||||
for (let i = 0; i < 3; i += 1) {
|
||||
const diff = (left.core[i] ?? 0) - (right.core[i] ?? 0);
|
||||
if (diff !== 0) return diff;
|
||||
}
|
||||
|
||||
if (left.prerelease.length === 0 && right.prerelease.length === 0) return 0;
|
||||
if (left.prerelease.length === 0) return 1;
|
||||
if (right.prerelease.length === 0) return -1;
|
||||
|
||||
const length = Math.max(left.prerelease.length, right.prerelease.length);
|
||||
for (let i = 0; i < length; i += 1) {
|
||||
const leftPart = left.prerelease[i];
|
||||
const rightPart = right.prerelease[i];
|
||||
if (leftPart === undefined && rightPart === undefined) return 0;
|
||||
if (leftPart === undefined) return -1;
|
||||
if (rightPart === undefined) return 1;
|
||||
if (leftPart === rightPart) continue;
|
||||
if (typeof leftPart === 'number' && typeof rightPart === 'number') {
|
||||
return leftPart - rightPart;
|
||||
}
|
||||
if (typeof leftPart === 'number') return -1;
|
||||
if (typeof rightPart === 'number') return 1;
|
||||
return leftPart > rightPart ? 1 : -1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import { execFile } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { promisify } from 'node:util';
|
||||
import type { GitHubRelease } from './release-assets';
|
||||
import { findReleaseAsset } from './release-assets';
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
export interface SupportAssetsUpdateResult {
|
||||
status: 'updated' | 'skipped' | 'protected' | 'hash-mismatch' | 'missing-asset';
|
||||
path?: string;
|
||||
command?: string;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
function sha256(data: Buffer): string {
|
||||
return createHash('sha256').update(data).digest('hex');
|
||||
}
|
||||
|
||||
function shellQuote(value: string): string {
|
||||
return `'${value.replace(/'/g, `'\\''`)}'`;
|
||||
}
|
||||
|
||||
export function detectSupportAssetDataDirs(options: {
|
||||
platform: NodeJS.Platform;
|
||||
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'];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
export function buildProtectedSupportAssetsCommand(assetUrl: string, dataDir: string): string {
|
||||
const quotedDir = shellQuote(dataDir);
|
||||
return [
|
||||
'tmp=$(mktemp -d)',
|
||||
`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 cp "$tmp/assets/themes/subminer.rasi" ${quotedDir}/themes/subminer.rasi`,
|
||||
].join(' && ');
|
||||
}
|
||||
|
||||
async function pathExists(targetPath: string): Promise<boolean> {
|
||||
return await fs.promises
|
||||
.access(targetPath)
|
||||
.then(() => true)
|
||||
.catch(() => false);
|
||||
}
|
||||
|
||||
async function canWrite(targetPath: string): Promise<boolean> {
|
||||
return await fs.promises
|
||||
.access(targetPath, fs.constants.W_OK)
|
||||
.then(() => true)
|
||||
.catch(() => false);
|
||||
}
|
||||
|
||||
export async function updateSupportAssetsFromRelease(options: {
|
||||
release: GitHubRelease | null;
|
||||
sha256Sums: Map<string, string>;
|
||||
downloadAsset: (url: string) => Promise<Buffer>;
|
||||
platform?: NodeJS.Platform;
|
||||
homeDir?: string;
|
||||
xdgDataHome?: string;
|
||||
}): Promise<SupportAssetsUpdateResult[]> {
|
||||
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.' }];
|
||||
const expectedSha256 = options.sha256Sums.get('subminer-assets.tar.gz');
|
||||
if (!expectedSha256) {
|
||||
return [{ status: 'missing-asset', message: 'SHA256SUMS.txt has no support assets entry.' }];
|
||||
}
|
||||
|
||||
const dataDirs = detectSupportAssetDataDirs({
|
||||
platform: options.platform ?? process.platform,
|
||||
homeDir: options.homeDir ?? os.homedir(),
|
||||
xdgDataHome: options.xdgDataHome ?? process.env.XDG_DATA_HOME,
|
||||
});
|
||||
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 (existingDataDirs.length === 0) {
|
||||
return [{ status: 'skipped', message: 'No existing support asset install detected.' }];
|
||||
}
|
||||
|
||||
const protectedResults: SupportAssetsUpdateResult[] = existingDataDirs
|
||||
.filter((dataDir) => !fs.existsSync(dataDir) || !fs.statSync(dataDir).isDirectory())
|
||||
.map((dataDir) => ({
|
||||
status: 'skipped' as const,
|
||||
path: dataDir,
|
||||
message: 'Support asset path is not a directory.',
|
||||
}));
|
||||
const writableDataDirs: string[] = [];
|
||||
for (const dataDir of existingDataDirs) {
|
||||
if (await canWrite(dataDir)) {
|
||||
writableDataDirs.push(dataDir);
|
||||
} else {
|
||||
protectedResults.push({
|
||||
status: 'protected',
|
||||
path: dataDir,
|
||||
command: buildProtectedSupportAssetsCommand(asset.browser_download_url, dataDir),
|
||||
});
|
||||
}
|
||||
}
|
||||
if (writableDataDirs.length === 0) return protectedResults;
|
||||
|
||||
const archive = await options.downloadAsset(asset.browser_download_url);
|
||||
const actualSha256 = sha256(archive);
|
||||
if (actualSha256 !== expectedSha256.toLowerCase()) {
|
||||
return [
|
||||
...protectedResults,
|
||||
{
|
||||
status: 'hash-mismatch',
|
||||
message: `Expected ${expectedSha256}, got ${actualSha256}.`,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'subminer-assets-'));
|
||||
try {
|
||||
const archivePath = path.join(tempDir, 'subminer-assets.tar.gz');
|
||||
await fs.promises.writeFile(archivePath, archive);
|
||||
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,
|
||||
);
|
||||
}
|
||||
results.push({ status: 'updated', path: dataDir });
|
||||
}
|
||||
return results;
|
||||
} finally {
|
||||
await fs.promises.rm(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import type { CliArgs } from '../../../cli/args';
|
||||
import { runUpdateCliCommand } from './update-cli-command';
|
||||
|
||||
test('runUpdateCliCommand writes launcher response for second-instance update handoff', async () => {
|
||||
const writes: Array<{ path: string; payload: unknown }> = [];
|
||||
|
||||
await runUpdateCliCommand(
|
||||
{
|
||||
update: true,
|
||||
updateLauncherPath: '/home/kyle/.local/bin/subminer',
|
||||
updateResponsePath: '/tmp/subminer-update-response.json',
|
||||
} as CliArgs,
|
||||
'second-instance',
|
||||
{
|
||||
checkForUpdates: async (request) => {
|
||||
assert.deepEqual(request, {
|
||||
source: 'launcher',
|
||||
launcherPath: '/home/kyle/.local/bin/subminer',
|
||||
});
|
||||
return { status: 'up-to-date', version: '0.15.0' };
|
||||
},
|
||||
writeResponse: (responsePath, payload) => {
|
||||
writes.push({ path: responsePath, payload });
|
||||
},
|
||||
logWarn: () => {},
|
||||
},
|
||||
);
|
||||
|
||||
assert.deepEqual(writes, [
|
||||
{
|
||||
path: '/tmp/subminer-update-response.json',
|
||||
payload: { ok: true, status: 'up-to-date', version: '0.15.0' },
|
||||
},
|
||||
]);
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import type { CliArgs, CliCommandSource } from '../../../cli/args';
|
||||
import type { UpdateCheckRequest, UpdateCheckResult } from './update-service';
|
||||
|
||||
export interface UpdateCliCommandResponse {
|
||||
ok: boolean;
|
||||
status?: UpdateCheckResult['status'];
|
||||
version?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface UpdateCliCommandDeps {
|
||||
checkForUpdates: (request: UpdateCheckRequest) => Promise<UpdateCheckResult>;
|
||||
writeResponse: (responsePath: string, payload: UpdateCliCommandResponse) => void;
|
||||
logWarn: (message: string, error?: unknown) => void;
|
||||
}
|
||||
|
||||
export function writeUpdateCliCommandResponse(
|
||||
responsePath: string,
|
||||
payload: UpdateCliCommandResponse,
|
||||
): void {
|
||||
fs.mkdirSync(path.dirname(responsePath), { recursive: true });
|
||||
fs.writeFileSync(responsePath, `${JSON.stringify(payload, null, 2)}\n`, 'utf8');
|
||||
}
|
||||
|
||||
function responseFromResult(result: UpdateCheckResult): UpdateCliCommandResponse {
|
||||
const response: UpdateCliCommandResponse = {
|
||||
ok: result.status !== 'failed',
|
||||
status: result.status,
|
||||
};
|
||||
if (result.version !== undefined) response.version = result.version;
|
||||
if (result.error !== undefined) response.error = result.error;
|
||||
return response;
|
||||
}
|
||||
|
||||
function writeResponseSafe(
|
||||
responsePath: string | undefined,
|
||||
payload: UpdateCliCommandResponse,
|
||||
deps: Pick<UpdateCliCommandDeps, 'writeResponse' | 'logWarn'>,
|
||||
): void {
|
||||
if (!responsePath) return;
|
||||
try {
|
||||
deps.writeResponse(responsePath, payload);
|
||||
} catch (error) {
|
||||
deps.logWarn(`Failed to write update response: ${responsePath}`, error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function runUpdateCliCommand(
|
||||
args: Pick<CliArgs, 'updateLauncherPath' | 'updateResponsePath'>,
|
||||
_source: CliCommandSource,
|
||||
deps: UpdateCliCommandDeps,
|
||||
): Promise<UpdateCheckResult> {
|
||||
try {
|
||||
const result = await deps.checkForUpdates({
|
||||
source: args.updateLauncherPath ? 'launcher' : 'manual',
|
||||
launcherPath: args.updateLauncherPath,
|
||||
});
|
||||
writeResponseSafe(args.updateResponsePath, responseFromResult(result), deps);
|
||||
return result;
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
writeResponseSafe(
|
||||
args.updateResponsePath,
|
||||
{ ok: false, status: 'failed', error: message },
|
||||
deps,
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
export type UpdateAvailableChoice = 'update' | 'close';
|
||||
export type RestartChoice = 'restart' | 'later';
|
||||
|
||||
export interface MessageBoxResultLike {
|
||||
response: number;
|
||||
}
|
||||
|
||||
export type ShowMessageBox = (options: {
|
||||
type?: 'info' | 'warning' | 'error' | 'question';
|
||||
title?: string;
|
||||
message: string;
|
||||
detail?: string;
|
||||
buttons?: string[];
|
||||
defaultId?: number;
|
||||
cancelId?: number;
|
||||
}) => Promise<MessageBoxResultLike>;
|
||||
|
||||
export async function showNoUpdateDialog(
|
||||
showMessageBox: ShowMessageBox,
|
||||
version: string,
|
||||
): Promise<void> {
|
||||
await showMessageBox({
|
||||
type: 'info',
|
||||
title: 'SubMiner Updates',
|
||||
message: `SubMiner is up to date (v${version})`,
|
||||
buttons: ['Close'],
|
||||
});
|
||||
}
|
||||
|
||||
export async function showUpdateAvailableDialog(
|
||||
showMessageBox: ShowMessageBox,
|
||||
version: string,
|
||||
): Promise<UpdateAvailableChoice> {
|
||||
const result = await showMessageBox({
|
||||
type: 'question',
|
||||
title: 'SubMiner Updates',
|
||||
message: `SubMiner v${version} is available`,
|
||||
buttons: ['Update', 'Close'],
|
||||
defaultId: 0,
|
||||
cancelId: 1,
|
||||
});
|
||||
return result.response === 0 ? 'update' : 'close';
|
||||
}
|
||||
|
||||
export async function showRestartDialog(showMessageBox: ShowMessageBox): Promise<RestartChoice> {
|
||||
const result = await showMessageBox({
|
||||
type: 'question',
|
||||
title: 'SubMiner Updates',
|
||||
message: 'Restart to update',
|
||||
buttons: ['Restart', 'Later'],
|
||||
defaultId: 0,
|
||||
cancelId: 1,
|
||||
});
|
||||
return result.response === 0 ? 'restart' : 'later';
|
||||
}
|
||||
|
||||
export async function showUpdateFailedDialog(
|
||||
showMessageBox: ShowMessageBox,
|
||||
message: string,
|
||||
): Promise<void> {
|
||||
await showMessageBox({
|
||||
type: 'error',
|
||||
title: 'SubMiner Updates',
|
||||
message: 'Update check failed',
|
||||
detail: message,
|
||||
buttons: ['Close'],
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { notifyUpdateAvailable } from './update-notifications';
|
||||
|
||||
test('notifyUpdateAvailable routes system and osd notifications from config', async () => {
|
||||
const calls: string[] = [];
|
||||
const deps = {
|
||||
showSystemNotification: (title: string, body: string) => {
|
||||
calls.push(`system:${title}:${body}`);
|
||||
},
|
||||
showOsdNotification: async (message: string) => {
|
||||
calls.push(`osd:${message}`);
|
||||
},
|
||||
log: (message: string) => {
|
||||
calls.push(`log:${message}`);
|
||||
},
|
||||
};
|
||||
|
||||
await notifyUpdateAvailable({ notificationType: 'system', version: '0.15.0' }, deps);
|
||||
await notifyUpdateAvailable({ notificationType: 'both', version: '0.15.0' }, deps);
|
||||
await notifyUpdateAvailable({ notificationType: 'none', version: '0.15.0' }, deps);
|
||||
|
||||
assert.deepEqual(calls, [
|
||||
'system:SubMiner update available:SubMiner v0.15.0 is available',
|
||||
'system:SubMiner update available:SubMiner v0.15.0 is available',
|
||||
'osd:SubMiner v0.15.0 is available',
|
||||
]);
|
||||
});
|
||||
|
||||
test('notifyUpdateAvailable logs osd fallback when overlay notification fails', async () => {
|
||||
const calls: string[] = [];
|
||||
|
||||
await notifyUpdateAvailable(
|
||||
{ notificationType: 'osd', version: '0.15.0' },
|
||||
{
|
||||
showSystemNotification: () => {
|
||||
calls.push('system');
|
||||
},
|
||||
showOsdNotification: async () => {
|
||||
throw new Error('mpv disconnected');
|
||||
},
|
||||
log: (message) => {
|
||||
calls.push(message);
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
assert.deepEqual(calls, ['Update OSD notification failed: mpv disconnected']);
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { UpdateNotificationType } from '../../../types/config';
|
||||
|
||||
export interface UpdateNotificationDeps {
|
||||
showSystemNotification: (title: string, body: string) => void;
|
||||
showOsdNotification: (message: string) => void | Promise<void>;
|
||||
log: (message: string) => void;
|
||||
}
|
||||
|
||||
export async function notifyUpdateAvailable(
|
||||
options: { notificationType: UpdateNotificationType; version: string },
|
||||
deps: UpdateNotificationDeps,
|
||||
): Promise<void> {
|
||||
if (options.notificationType === 'none') return;
|
||||
|
||||
const message = `SubMiner v${options.version} is available`;
|
||||
if (options.notificationType === 'system' || options.notificationType === 'both') {
|
||||
deps.showSystemNotification('SubMiner update available', message);
|
||||
}
|
||||
if (options.notificationType === 'osd' || options.notificationType === 'both') {
|
||||
try {
|
||||
await deps.showOsdNotification(message);
|
||||
} catch (error) {
|
||||
deps.log(`Update OSD notification failed: ${(error as Error).message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createUpdateService, type UpdateServiceDeps, type UpdateState } from './update-service';
|
||||
|
||||
function createDeps(overrides: Partial<UpdateServiceDeps> = {}) {
|
||||
let state: UpdateState = {};
|
||||
const calls: string[] = [];
|
||||
const deps: UpdateServiceDeps = {
|
||||
getConfig: () => ({
|
||||
enabled: true,
|
||||
checkIntervalHours: 24,
|
||||
notificationType: 'system',
|
||||
channel: 'stable',
|
||||
}),
|
||||
getCurrentVersion: () => '0.14.0',
|
||||
now: () => 1_000_000,
|
||||
readState: async () => state,
|
||||
writeState: async (nextState) => {
|
||||
state = nextState;
|
||||
calls.push(`state:${JSON.stringify(nextState)}`);
|
||||
},
|
||||
checkAppUpdate: async () => ({ available: false, version: '0.14.0' }),
|
||||
fetchLatestStableRelease: async () => ({
|
||||
tag_name: 'v0.14.0',
|
||||
prerelease: false,
|
||||
draft: false,
|
||||
assets: [],
|
||||
}),
|
||||
updateLauncher: async () => ({ status: 'skipped' }),
|
||||
showNoUpdateDialog: async (version) => {
|
||||
calls.push(`no-update:${version}`);
|
||||
},
|
||||
showUpdateAvailableDialog: async (version) => {
|
||||
calls.push(`available-dialog:${version}`);
|
||||
return 'close';
|
||||
},
|
||||
showUpdateFailedDialog: async (message) => {
|
||||
calls.push(`failed:${message}`);
|
||||
},
|
||||
downloadAppUpdate: async () => {
|
||||
calls.push('download');
|
||||
},
|
||||
showRestartDialog: async () => {
|
||||
calls.push('restart-dialog');
|
||||
return 'later';
|
||||
},
|
||||
quitAndInstall: () => calls.push('quit-install'),
|
||||
notifyUpdateAvailable: async (version) => {
|
||||
calls.push(`notify:${version}`);
|
||||
},
|
||||
log: (message) => calls.push(`log:${message}`),
|
||||
...overrides,
|
||||
};
|
||||
|
||||
return {
|
||||
deps,
|
||||
calls,
|
||||
getState: () => state,
|
||||
setState: (nextState: UpdateState) => (state = nextState),
|
||||
};
|
||||
}
|
||||
|
||||
test('manual update check shows latest-version dialog when already current', async () => {
|
||||
const { deps, calls } = createDeps();
|
||||
const service = createUpdateService(deps);
|
||||
|
||||
const result = await service.checkForUpdates({ source: 'manual' });
|
||||
|
||||
assert.equal(result.status, 'up-to-date');
|
||||
assert.deepEqual(calls, ['no-update:0.14.0']);
|
||||
});
|
||||
|
||||
test('manual update check falls back to GitHub release when app metadata is unavailable', async () => {
|
||||
const { deps, calls } = createDeps({
|
||||
checkAppUpdate: async () => {
|
||||
throw new Error('latest-linux.yml missing');
|
||||
},
|
||||
fetchLatestStableRelease: async () => ({
|
||||
tag_name: 'v0.15.0',
|
||||
prerelease: false,
|
||||
draft: false,
|
||||
assets: [],
|
||||
}),
|
||||
});
|
||||
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']);
|
||||
});
|
||||
|
||||
test('automatic update check skips inside configured interval', async () => {
|
||||
const { deps, calls, setState } = createDeps();
|
||||
setState({ lastAutomaticCheckAt: 1_000_000 - 60 * 60 * 1000 });
|
||||
const service = createUpdateService(deps);
|
||||
|
||||
const result = await service.checkForUpdates({ source: 'automatic' });
|
||||
|
||||
assert.equal(result.status, 'skipped');
|
||||
assert.deepEqual(calls, []);
|
||||
});
|
||||
|
||||
test('automatic update check notifies once per version and records check time', async () => {
|
||||
const { deps, calls, getState } = createDeps({
|
||||
checkAppUpdate: async () => ({ available: true, version: '0.15.0' }),
|
||||
});
|
||||
const service = createUpdateService(deps);
|
||||
|
||||
const first = await service.checkForUpdates({ source: 'automatic' });
|
||||
const second = await service.checkForUpdates({ source: 'automatic', force: true });
|
||||
|
||||
assert.equal(first.status, 'update-available');
|
||||
assert.equal(second.status, 'update-available');
|
||||
assert.deepEqual(
|
||||
calls.filter((call) => call === 'notify:0.15.0'),
|
||||
['notify:0.15.0'],
|
||||
);
|
||||
assert.equal(getState().lastNotifiedVersion, '0.15.0');
|
||||
assert.equal(getState().lastAutomaticCheckAt, 1_000_000);
|
||||
});
|
||||
|
||||
test('concurrent update checks share one in-flight check', async () => {
|
||||
let checkCount = 0;
|
||||
let resolveCheck: (value: { available: boolean; version: string }) => void = () => {};
|
||||
const { deps } = createDeps({
|
||||
checkAppUpdate: () =>
|
||||
new Promise((resolve) => {
|
||||
checkCount += 1;
|
||||
resolveCheck = resolve;
|
||||
}),
|
||||
});
|
||||
const service = createUpdateService(deps);
|
||||
const first = service.checkForUpdates({ source: 'manual' });
|
||||
const second = service.checkForUpdates({ source: 'manual' });
|
||||
|
||||
await Promise.resolve();
|
||||
resolveCheck({ available: false, version: '0.14.0' });
|
||||
await Promise.all([first, second]);
|
||||
|
||||
assert.equal(checkCount, 1);
|
||||
});
|
||||
|
||||
test('manual prerelease update check uses prerelease release and launcher channel', async () => {
|
||||
const { deps, calls } = createDeps({
|
||||
getConfig: () => ({
|
||||
enabled: true,
|
||||
checkIntervalHours: 24,
|
||||
notificationType: 'system',
|
||||
channel: 'prerelease',
|
||||
}),
|
||||
checkAppUpdate: async () => ({ available: true, version: '0.15.0-beta.1' }),
|
||||
fetchLatestStableRelease: async (channel) => {
|
||||
calls.push(`fetch:${channel}`);
|
||||
return {
|
||||
tag_name: 'v0.15.0-beta.1',
|
||||
prerelease: true,
|
||||
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, 'updated');
|
||||
assert.deepEqual(calls, [
|
||||
'fetch:prerelease',
|
||||
'available-dialog:0.15.0-beta.1',
|
||||
'download',
|
||||
'launcher:prerelease',
|
||||
'restart-dialog',
|
||||
]);
|
||||
});
|
||||
@@ -0,0 +1,224 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import type { UpdateChannel, UpdatesConfig } from '../../../types/config';
|
||||
import type { GitHubRelease } from './release-assets';
|
||||
import { compareSemverLike, parseReleaseVersion } from './release-assets';
|
||||
|
||||
export interface UpdateState {
|
||||
lastAutomaticCheckAt?: number;
|
||||
lastNotifiedVersion?: string;
|
||||
}
|
||||
|
||||
export type UpdateCheckSource = 'manual' | 'automatic' | 'launcher';
|
||||
|
||||
export interface UpdateCheckRequest {
|
||||
source: UpdateCheckSource;
|
||||
force?: boolean;
|
||||
launcherPath?: string;
|
||||
}
|
||||
|
||||
export type UpdateCheckStatus =
|
||||
| 'up-to-date'
|
||||
| 'update-available'
|
||||
| 'updated'
|
||||
| 'skipped'
|
||||
| 'failed';
|
||||
|
||||
export interface UpdateCheckResult {
|
||||
status: UpdateCheckStatus;
|
||||
version?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface UpdateServiceDeps {
|
||||
getConfig: () => Required<UpdatesConfig>;
|
||||
getCurrentVersion: () => string;
|
||||
now: () => number;
|
||||
readState: () => Promise<UpdateState>;
|
||||
writeState: (state: UpdateState) => Promise<void>;
|
||||
checkAppUpdate: (
|
||||
channel: UpdateChannel,
|
||||
) => Promise<{ available: boolean; version: string; canUpdate?: boolean }>;
|
||||
fetchLatestStableRelease: (channel: UpdateChannel) => Promise<GitHubRelease | null>;
|
||||
updateLauncher: (
|
||||
launcherPath?: string,
|
||||
channel?: UpdateChannel,
|
||||
) => 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;
|
||||
notifyUpdateAvailable: (version: string) => Promise<void>;
|
||||
log: (message: string) => void;
|
||||
setTimeout?: (callback: () => void, delayMs: number) => unknown;
|
||||
setInterval?: (callback: () => void, delayMs: number) => unknown;
|
||||
}
|
||||
|
||||
function getBestLatestVersion(
|
||||
currentVersion: string,
|
||||
appUpdate: { available: boolean; version: string },
|
||||
release: GitHubRelease | null,
|
||||
): { available: boolean; version: string } {
|
||||
const releaseVersion = parseReleaseVersion(release);
|
||||
const candidates = [appUpdate.version, releaseVersion].filter(
|
||||
(value): value is string => typeof value === 'string' && value.length > 0,
|
||||
);
|
||||
const latest = candidates.reduce(
|
||||
(best, candidate) => (compareSemverLike(candidate, best) > 0 ? candidate : best),
|
||||
currentVersion,
|
||||
);
|
||||
return {
|
||||
available: appUpdate.available || compareSemverLike(latest, currentVersion) > 0,
|
||||
version: latest,
|
||||
};
|
||||
}
|
||||
|
||||
function shouldSkipAutomaticCheck(
|
||||
config: Required<UpdatesConfig>,
|
||||
state: UpdateState,
|
||||
now: number,
|
||||
) {
|
||||
if (!config.enabled) return true;
|
||||
if (!state.lastAutomaticCheckAt) return false;
|
||||
const intervalMs = Math.max(1, config.checkIntervalHours) * 60 * 60 * 1000;
|
||||
return now - state.lastAutomaticCheckAt < intervalMs;
|
||||
}
|
||||
|
||||
function summarizeError(error: unknown): string {
|
||||
const raw = error instanceof Error ? error.message : String(error);
|
||||
const firstLine = raw
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.find((line) => line.length > 0);
|
||||
return firstLine ?? 'unknown error';
|
||||
}
|
||||
|
||||
export function createUpdateService(deps: UpdateServiceDeps) {
|
||||
let inFlight: Promise<UpdateCheckResult> | null = null;
|
||||
|
||||
async function runCheck(request: UpdateCheckRequest): Promise<UpdateCheckResult> {
|
||||
const now = deps.now();
|
||||
const config = deps.getConfig();
|
||||
const channel = config.channel;
|
||||
const state = await deps.readState();
|
||||
const isAutomatic = request.source === 'automatic';
|
||||
|
||||
if (isAutomatic && !request.force && shouldSkipAutomaticCheck(config, state, now)) {
|
||||
return { status: 'skipped' };
|
||||
}
|
||||
|
||||
try {
|
||||
const [appUpdate, release] = await Promise.all([
|
||||
deps.checkAppUpdate(channel).catch((error) => {
|
||||
if (isAutomatic) {
|
||||
deps.log(`App update metadata check failed: ${summarizeError(error)}`);
|
||||
}
|
||||
return {
|
||||
available: false,
|
||||
version: deps.getCurrentVersion(),
|
||||
canUpdate: false,
|
||||
};
|
||||
}),
|
||||
deps.fetchLatestStableRelease(channel).catch((error) => {
|
||||
deps.log(`GitHub release update check failed: ${(error as Error).message}`);
|
||||
return null;
|
||||
}),
|
||||
]);
|
||||
const currentVersion = deps.getCurrentVersion();
|
||||
const latest = getBestLatestVersion(currentVersion, appUpdate, release);
|
||||
|
||||
if (isAutomatic) {
|
||||
const nextState: UpdateState = {
|
||||
...state,
|
||||
lastAutomaticCheckAt: now,
|
||||
};
|
||||
if (latest.available && state.lastNotifiedVersion !== latest.version) {
|
||||
await deps.notifyUpdateAvailable(latest.version);
|
||||
nextState.lastNotifiedVersion = latest.version;
|
||||
}
|
||||
await deps.writeState(nextState);
|
||||
}
|
||||
|
||||
if (!latest.available) {
|
||||
if (!isAutomatic) {
|
||||
await deps.showNoUpdateDialog(currentVersion);
|
||||
}
|
||||
return { status: 'up-to-date', version: currentVersion };
|
||||
}
|
||||
|
||||
if (isAutomatic) {
|
||||
return { status: 'update-available', version: latest.version };
|
||||
}
|
||||
|
||||
const choice = await deps.showUpdateAvailableDialog(latest.version);
|
||||
if (choice === 'close') {
|
||||
return { status: 'update-available', version: latest.version };
|
||||
}
|
||||
|
||||
if (appUpdate.available && appUpdate.canUpdate !== false) {
|
||||
await deps.downloadAppUpdate();
|
||||
}
|
||||
const launcherResult = await deps.updateLauncher(request.launcherPath, channel);
|
||||
if (launcherResult.status === 'protected' && launcherResult.command) {
|
||||
deps.log(`Launcher update requires manual command: ${launcherResult.command}`);
|
||||
}
|
||||
|
||||
const restartChoice = await deps.showRestartDialog();
|
||||
if (restartChoice === 'restart') {
|
||||
deps.quitAndInstall();
|
||||
}
|
||||
return { status: 'updated', version: latest.version };
|
||||
} catch (error) {
|
||||
const message = (error as Error).message;
|
||||
if (isAutomatic) {
|
||||
deps.log(`Automatic update check failed: ${message}`);
|
||||
} else {
|
||||
await deps.showUpdateFailedDialog(message);
|
||||
}
|
||||
return { status: 'failed', error: message };
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
checkForUpdates(request: UpdateCheckRequest): Promise<UpdateCheckResult> {
|
||||
if (inFlight) return inFlight;
|
||||
inFlight = runCheck(request).finally(() => {
|
||||
inFlight = null;
|
||||
});
|
||||
return inFlight;
|
||||
},
|
||||
startAutomaticChecks(options: { startupDelayMs?: number; pollIntervalMs?: number } = {}): void {
|
||||
const setTimeoutFn = deps.setTimeout ?? setTimeout;
|
||||
const setIntervalFn = deps.setInterval ?? setInterval;
|
||||
const startupDelayMs = options.startupDelayMs ?? 15_000;
|
||||
const pollIntervalMs = options.pollIntervalMs ?? 60 * 60 * 1000;
|
||||
setTimeoutFn(() => {
|
||||
void this.checkForUpdates({ source: 'automatic' });
|
||||
}, startupDelayMs);
|
||||
setIntervalFn(() => {
|
||||
void this.checkForUpdates({ source: 'automatic' });
|
||||
}, pollIntervalMs);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function createFileUpdateStateStore(statePath: string): {
|
||||
readState: () => Promise<UpdateState>;
|
||||
writeState: (state: UpdateState) => Promise<void>;
|
||||
} {
|
||||
return {
|
||||
async readState(): Promise<UpdateState> {
|
||||
try {
|
||||
return JSON.parse(await fs.promises.readFile(statePath, 'utf8')) as UpdateState;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
},
|
||||
async writeState(state: UpdateState): Promise<void> {
|
||||
await fs.promises.mkdir(path.dirname(statePath), { recursive: true });
|
||||
await fs.promises.writeFile(statePath, `${JSON.stringify(state, null, 2)}\n`, 'utf8');
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user