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
+20 -20
View File
@@ -241,36 +241,36 @@ test('dictionary command returns after app handoff starts', () => {
assert.equal(handled, true);
});
test('update command forwards launcher path and waits for response', async () => {
test('update command runs direct Linux release update without launching Electron', async () => {
const context = createContext();
context.args.update = true;
const forwarded: string[][] = [];
const responses: string[] = [];
const calls: string[] = [];
const handled = await runUpdateCommand(context, {
createTempDir: () => '/tmp/subminer-update-test',
joinPath: (...parts) => parts.join('/'),
runAppCommandCaptureOutput: (_appPath, appArgs) => {
forwarded.push(appArgs);
return { status: 0, stdout: '', stderr: '' };
runAppCommandCaptureOutput: () => {
throw new Error('unexpected Electron launch');
},
waitForUpdateResponse: async (responsePath) => {
responses.push(responsePath);
return { ok: true, status: 'up-to-date', version: '0.15.0' };
runDirectReleaseUpdate: async (request) => {
calls.push(`direct:${request.appPath}:${request.launcherPath}:${request.channel}`);
return {
appImage: { status: 'not-found' },
launcher: { status: 'updated' },
supportAssets: [{ status: 'skipped' }],
};
},
readMainConfig: () => null,
log: (level, _configured, message) => {
calls.push(`${level}:${message}`);
},
});
assert.equal(handled, true);
assert.deepEqual(forwarded, [
[
'--update',
'--update-launcher-path',
'/tmp/subminer',
'--update-response-path',
'/tmp/subminer-update-test/response.json',
],
assert.deepEqual(calls, [
'direct:/tmp/subminer.app:/tmp/subminer:stable',
'info:AppImage update: not-found',
'info:Launcher update: updated',
'info:Rofi theme update: skipped',
]);
assert.deepEqual(responses, ['/tmp/subminer-update-test/response.json']);
});
test('stats command launches attached app command with response path', async () => {
+140
View File
@@ -0,0 +1,140 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { runUpdateCommand } from './update-command';
import type { LauncherCommandContext } from './context';
function makeContext(overrides: Partial<LauncherCommandContext> = {}): LauncherCommandContext {
return {
args: {
update: true,
logLevel: 'warn',
} as LauncherCommandContext['args'],
scriptPath: '/home/kyle/.local/bin/subminer',
scriptName: 'subminer',
mpvSocketPath: '/tmp/subminer.sock',
pluginRuntimeConfig: {} as LauncherCommandContext['pluginRuntimeConfig'],
appPath: '/home/kyle/.local/bin/SubMiner.AppImage',
launcherJellyfinConfig: {} as LauncherCommandContext['launcherJellyfinConfig'],
processAdapter: {
platform: () => 'linux',
} as LauncherCommandContext['processAdapter'],
...overrides,
};
}
test('runUpdateCommand updates directly on Linux without launching Electron', async () => {
const calls: string[] = [];
const handled = await runUpdateCommand(makeContext(), {
runAppCommandCaptureOutput: () => {
throw new Error('unexpected Electron launch');
},
runDirectReleaseUpdate: async (request) => {
calls.push(`direct:${request.appPath}:${request.launcherPath}:${request.channel}`);
return {
appImage: { status: 'updated' },
launcher: { status: 'updated' },
supportAssets: [{ status: 'skipped' }],
};
},
readMainConfig: () => ({ updates: { channel: 'prerelease' } }),
log: (level, _configured, message) => {
calls.push(`${level}:${message}`);
},
});
assert.equal(handled, true);
assert.deepEqual(calls, [
'direct:/home/kyle/.local/bin/SubMiner.AppImage:/home/kyle/.local/bin/subminer:prerelease',
'info:AppImage update: updated',
'info:Launcher update: updated',
'info:Rofi theme update: skipped',
]);
});
test('runUpdateCommand skips Linux asset replacement when release is not newer', async () => {
const calls: string[] = [];
const originalFetch = globalThis.fetch;
globalThis.fetch = (async (url: string) => {
calls.push(`fetch:${url}`);
if (!url.endsWith('/releases')) {
throw new Error(`unexpected asset fetch: ${url}`);
}
return {
ok: true,
status: 200,
json: async () => [
{
tag_name: 'v0.14.0',
prerelease: false,
draft: false,
assets: [
{
name: 'SHA256SUMS.txt',
browser_download_url: 'https://example.test/SHA256SUMS.txt',
},
{
name: 'SubMiner.AppImage',
browser_download_url: 'https://example.test/SubMiner.AppImage',
},
],
},
],
text: async () => '',
arrayBuffer: async () => new ArrayBuffer(0),
};
}) as typeof fetch;
try {
const handled = await runUpdateCommand(makeContext(), {
runAppCommandCaptureOutput: () => {
throw new Error('unexpected Electron launch');
},
readMainConfig: () => null,
log: (level, _configured, message) => {
calls.push(`${level}:${message}`);
},
});
assert.equal(handled, true);
assert.deepEqual(calls, [
'fetch:https://api.github.com/repos/ksyasuda/SubMiner/releases',
'info:AppImage update: up to date',
'info:Launcher update: up to date',
'info:Rofi theme update: up to date',
]);
} finally {
globalThis.fetch = originalFetch;
}
});
test('runUpdateCommand keeps app-mediated update path on non-Linux', async () => {
const calls: string[] = [];
const handled = await runUpdateCommand(
makeContext({
processAdapter: {
platform: () => 'darwin',
} as LauncherCommandContext['processAdapter'],
appPath: '/Applications/SubMiner.app/Contents/MacOS/SubMiner',
}),
{
createTempDir: () => '/tmp/subminer-update-test',
joinPath: (...parts) => parts.join('/'),
runAppCommandCaptureOutput: (appPath, appArgs) => {
calls.push(`app:${appPath}:${appArgs.join(' ')}`);
return { status: 0, stdout: '', stderr: '' };
},
waitForUpdateResponse: async () => ({ ok: true, status: 'up-to-date' }),
removeDir: (targetPath) => {
calls.push(`remove:${targetPath}`);
},
},
);
assert.equal(handled, true);
assert.deepEqual(calls, [
'app:/Applications/SubMiner.app/Contents/MacOS/SubMiner:--update --update-launcher-path /home/kyle/.local/bin/subminer --update-response-path /tmp/subminer-update-test/response.json',
'remove:/tmp/subminer-update-test',
]);
});
+133
View File
@@ -1,10 +1,27 @@
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import packageJson from '../../package.json';
import { runAppCommandCaptureOutput } from '../mpv.js';
import { log as launcherLog } from '../log.js';
import { nowMs } from '../time.js';
import { sleep } from '../util.js';
import type { LauncherCommandContext } from './context.js';
import { readLauncherMainConfigObject } from '../config/shared-config-reader.js';
import type { UpdateChannel } from '../../src/types/config.js';
import { updateAppImageFromRelease } from '../../src/main/runtime/update/appimage-updater.js';
import { updateLauncherFromRelease } from '../../src/main/runtime/update/launcher-updater.js';
import {
compareSemverLike,
fetchLatestStableRelease,
fetchReleaseAssetBuffer,
fetchReleaseAssetText,
findReleaseAsset,
parseReleaseVersion,
parseSha256Sums,
type FetchLike,
} from '../../src/main/runtime/update/release-assets.js';
import { updateSupportAssetsFromRelease } from '../../src/main/runtime/update/support-assets.js';
type UpdateCommandResponse = {
ok: boolean;
@@ -13,6 +30,18 @@ type UpdateCommandResponse = {
error?: string;
};
type DirectReleaseUpdateRequest = {
appPath: string;
launcherPath: string;
channel: UpdateChannel;
};
type DirectReleaseUpdateResult = {
appImage: { status: string; command?: string; message?: string };
launcher: { status: string; command?: string; message?: string };
supportAssets: Array<{ status: string; command?: string; message?: string }>;
};
type UpdateCommandDeps = {
createTempDir: (prefix: string) => string;
joinPath: (...parts: string[]) => string;
@@ -22,9 +51,95 @@ type UpdateCommandDeps = {
) => { status: number; stdout: string; stderr: string; error?: Error };
waitForUpdateResponse: (responsePath: string) => Promise<UpdateCommandResponse>;
removeDir: (targetPath: string) => void;
runDirectReleaseUpdate: (
request: DirectReleaseUpdateRequest,
) => Promise<DirectReleaseUpdateResult>;
readMainConfig: () => Record<string, unknown> | null;
log: typeof launcherLog;
};
const UPDATE_RESPONSE_TIMEOUT_MS = 10 * 60 * 1000;
const CURRENT_VERSION = packageJson.version;
function getFetchForLauncherUpdater(): FetchLike {
return globalThis.fetch.bind(globalThis) as FetchLike;
}
async function runDirectReleaseUpdate(
request: DirectReleaseUpdateRequest,
): Promise<DirectReleaseUpdateResult> {
const fetchForUpdater = getFetchForLauncherUpdater();
const release = await fetchLatestStableRelease({
fetch: fetchForUpdater,
channel: request.channel,
});
const releaseVersion = parseReleaseVersion(release);
if (releaseVersion && compareSemverLike(releaseVersion, CURRENT_VERSION) <= 0) {
return {
appImage: { status: 'up-to-date' },
launcher: { status: 'up-to-date' },
supportAssets: [{ status: 'up-to-date' }],
};
}
const sumsAsset = release ? findReleaseAsset(release, 'SHA256SUMS.txt') : null;
const sha256Sums =
sumsAsset && release
? parseSha256Sums(
await fetchReleaseAssetText(fetchForUpdater, sumsAsset.browser_download_url),
)
: new Map<string, string>();
const downloadAsset = (url: string) => fetchReleaseAssetBuffer(fetchForUpdater, url);
const [appImage, launcher, supportAssets] = await Promise.all([
updateAppImageFromRelease({
release,
sha256Sums,
appImagePath: request.appPath,
downloadAsset,
}),
updateLauncherFromRelease({
release,
sha256Sums,
launcherPath: request.launcherPath,
downloadAsset,
}),
updateSupportAssetsFromRelease({
release,
sha256Sums,
downloadAsset,
}),
]);
return { appImage, launcher, supportAssets };
}
function readUpdateChannel(root: Record<string, unknown> | null): UpdateChannel {
const updates =
root?.updates && typeof root.updates === 'object' && !Array.isArray(root.updates)
? (root.updates as Record<string, unknown>)
: null;
return updates?.channel === 'prerelease' ? 'prerelease' : 'stable';
}
function logUpdateResult(
label: string,
result: { status: string; command?: string; message?: string },
configuredLogLevel: NonNullable<LauncherCommandContext['args']['logLevel']>,
deps: Pick<UpdateCommandDeps, 'log'>,
): void {
const displayStatus = result.status === 'up-to-date' ? 'up to date' : result.status;
deps.log('info', configuredLogLevel, `${label} update: ${displayStatus}`);
if (result.command) {
deps.log(
'warn',
configuredLogLevel,
`${label} update requires manual command: ${result.command}`,
);
} else if (result.message) {
deps.log('warn', configuredLogLevel, `${label} update note: ${result.message}`);
}
}
const defaultDeps: UpdateCommandDeps = {
createTempDir: (prefix) => fs.mkdtempSync(path.join(os.tmpdir(), prefix)),
@@ -47,6 +162,9 @@ const defaultDeps: UpdateCommandDeps = {
removeDir: (targetPath) => {
fs.rmSync(targetPath, { recursive: true, force: true });
},
runDirectReleaseUpdate,
readMainConfig: readLauncherMainConfigObject,
log: launcherLog,
};
export async function runUpdateCommand(
@@ -59,6 +177,21 @@ export async function runUpdateCommand(
return false;
}
if (context.processAdapter.platform() === 'linux') {
const result = await resolvedDeps.runDirectReleaseUpdate({
appPath,
launcherPath: scriptPath,
channel: readUpdateChannel(resolvedDeps.readMainConfig()),
});
const logLevel = args.logLevel ?? 'warn';
logUpdateResult('AppImage', result.appImage, logLevel, resolvedDeps);
logUpdateResult('Launcher', result.launcher, logLevel, resolvedDeps);
for (const supportResult of result.supportAssets) {
logUpdateResult('Rofi theme', supportResult, logLevel, resolvedDeps);
}
return true;
}
const tempDir = resolvedDeps.createTempDir('subminer-update-');
const responsePath = resolvedDeps.joinPath(tempDir, 'response.json');