mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-05-26 00:55:16 -07:00
feat: add auto update support (#65)
This commit is contained in:
@@ -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 () => {
|
||||
|
||||
@@ -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',
|
||||
]);
|
||||
});
|
||||
@@ -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');
|
||||
|
||||
|
||||
Reference in New Issue
Block a user