mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-11-10 04:46:07 -08:00
feat(launcher): bundle Bun and use it across all launchers (#243)
This commit is contained in:
1 parent
fb2d1a512b
commit
016a43b804
61 files changed
+6814
-294
No files matched your search
@@ -58,7 +58,7 @@ test('updateAppImageFromRelease verifies hash and atomically replaces writable A
|
||||
]);
|
||||
});
|
||||
|
||||
test('updateAppImageFromRelease reports protected command without replacing non-writable AppImage', async () => {
|
||||
test('updateAppImageFromRelease reports protected command for a direct non-writable AppImage', async () => {
|
||||
const result = await updateAppImageFromRelease({
|
||||
release: {
|
||||
tag_name: 'v0.15.0',
|
||||
@@ -67,7 +67,7 @@ test('updateAppImageFromRelease reports protected command without replacing non-
|
||||
assets: [{ name: 'SubMiner.AppImage', browser_download_url: 'https://example.test/app' }],
|
||||
},
|
||||
sha256Sums: new Map([['SubMiner.AppImage', appImageHash]]),
|
||||
appImagePath: '/opt/SubMiner/SubMiner.AppImage',
|
||||
appImagePath: '/usr/local/lib/SubMiner.AppImage',
|
||||
downloadAsset: async () => appImageBytes,
|
||||
fs: {
|
||||
stat: async () => ({
|
||||
@@ -87,10 +87,50 @@ test('updateAppImageFromRelease reports protected command without replacing non-
|
||||
});
|
||||
|
||||
assert.equal(result.status, 'protected');
|
||||
assert.equal(result.path, '/opt/SubMiner/SubMiner.AppImage');
|
||||
assert.equal(result.path, '/usr/local/lib/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'/);
|
||||
assert.match(result.command ?? '', /sudo mv "\$tmp" '\/usr\/local\/lib\/SubMiner\.AppImage'/);
|
||||
});
|
||||
|
||||
test('updateAppImageFromRelease leaves canonical and symlinked AUR AppImages to pacman', async () => {
|
||||
for (const appImagePath of ['/opt/SubMiner/SubMiner.AppImage', '/usr/bin/SubMiner.AppImage']) {
|
||||
let accessed = false;
|
||||
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,
|
||||
downloadAsset: async () => {
|
||||
throw new Error('must not download package-managed AppImage');
|
||||
},
|
||||
fs: {
|
||||
realpath: async () => '/opt/SubMiner/SubMiner.AppImage',
|
||||
stat: async () => {
|
||||
throw new Error('must not stat package-managed AppImage');
|
||||
},
|
||||
access: async () => {
|
||||
accessed = true;
|
||||
},
|
||||
writeFile: async () => {},
|
||||
chmod: async () => {},
|
||||
rename: async () => {},
|
||||
unlink: async () => {},
|
||||
},
|
||||
});
|
||||
|
||||
assert.deepEqual(result, {
|
||||
status: 'skipped',
|
||||
path: appImagePath,
|
||||
message: 'This AppImage is managed by the subminer-bin system package.',
|
||||
});
|
||||
assert.equal(accessed, false);
|
||||
assert.equal(result.command, undefined);
|
||||
}
|
||||
});
|
||||
|
||||
test('buildProtectedAppImageUpdateCommand quotes inputs and verifies checksum before sudo move', () => {
|
||||
|
||||
@@ -25,6 +25,7 @@ export interface AppImageUpdateResult {
|
||||
}
|
||||
|
||||
export interface AppImageUpdateFileSystem {
|
||||
realpath?: (targetPath: string) => Promise<string>;
|
||||
stat: (targetPath: string) => Promise<StatLike>;
|
||||
access: (targetPath: string) => Promise<void>;
|
||||
writeFile: (targetPath: string, data: Buffer) => Promise<void>;
|
||||
@@ -39,6 +40,7 @@ function sha256(data: Buffer): string {
|
||||
|
||||
function defaultFs(): AppImageUpdateFileSystem {
|
||||
return {
|
||||
realpath: (targetPath) => fs.promises.realpath(targetPath),
|
||||
stat: (targetPath) => fs.promises.stat(targetPath),
|
||||
access: async (targetPath) => {
|
||||
await fs.promises.access(targetPath, fs.constants.W_OK);
|
||||
@@ -105,6 +107,19 @@ export async function updateAppImageFromRelease(options: {
|
||||
}
|
||||
|
||||
const fsDeps = options.fs ?? defaultFs();
|
||||
let resolvedAppImagePath = options.appImagePath;
|
||||
try {
|
||||
resolvedAppImagePath = (await fsDeps.realpath?.(options.appImagePath)) ?? options.appImagePath;
|
||||
} catch {
|
||||
// stat below reports a missing or inaccessible path with the existing result shape.
|
||||
}
|
||||
if (resolvedAppImagePath === '/opt/SubMiner/SubMiner.AppImage') {
|
||||
return {
|
||||
status: 'skipped',
|
||||
path: options.appImagePath,
|
||||
message: 'This AppImage is managed by the subminer-bin system package.',
|
||||
};
|
||||
}
|
||||
let stat: StatLike;
|
||||
try {
|
||||
stat = await fsDeps.stat(options.appImagePath);
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
buildProtectedLauncherUpdateCommand,
|
||||
looksLikeSubminerLauncher,
|
||||
updateLauncherAtPath,
|
||||
updateLauncherFromRelease,
|
||||
} from './launcher-updater';
|
||||
|
||||
const launcherBytes = Buffer.from('#!/usr/bin/env bash\n# SubMiner launcher\nexec SubMiner "$@"\n');
|
||||
@@ -124,3 +125,137 @@ test('updateLauncherAtPath aborts on hash mismatch and suspicious launcher conte
|
||||
assert.equal(suspicious.status, 'skipped');
|
||||
assert.equal(mismatch.status, 'hash-mismatch');
|
||||
});
|
||||
|
||||
test('app-managed wrappers are never overwritten by the standalone release script', async () => {
|
||||
const { managedLauncherContent } = await import('../managed-launcher');
|
||||
let downloaded = false;
|
||||
const result = await updateLauncherAtPath({
|
||||
launcherPath: '/home/tester/.local/bin/subminer',
|
||||
assetUrl: 'https://example.test/subminer',
|
||||
expectedSha256: launcherHash,
|
||||
download: async () => {
|
||||
downloaded = true;
|
||||
return launcherBytes;
|
||||
},
|
||||
fs: {
|
||||
stat: async () => ({ isFile: () => true }),
|
||||
readFile: async () =>
|
||||
managedLauncherContent({
|
||||
platform: 'linux',
|
||||
appPath: '/apps/SubMiner.AppImage',
|
||||
}),
|
||||
access: async () => {
|
||||
throw new Error('must not modify wrapper');
|
||||
},
|
||||
writeFile: async () => {
|
||||
throw new Error('must not modify wrapper');
|
||||
},
|
||||
chmod: async () => {},
|
||||
rename: async () => {},
|
||||
unlink: async () => {},
|
||||
},
|
||||
});
|
||||
assert.equal(result.status, 'skipped');
|
||||
assert.equal(downloaded, false);
|
||||
});
|
||||
|
||||
test('GUI updates defer recognized standalone launcher migration to app startup', async () => {
|
||||
const accessed: string[] = [];
|
||||
let downloaded = false;
|
||||
const result = await updateLauncherAtPath({
|
||||
launcherPath: '/home/tester/.local/bin/subminer',
|
||||
assetUrl: 'https://example.test/subminer',
|
||||
expectedSha256: launcherHash,
|
||||
deferRecognizedLauncherUpdate: true,
|
||||
download: async () => {
|
||||
downloaded = true;
|
||||
return launcherBytes;
|
||||
},
|
||||
fs: {
|
||||
stat: async () => ({ isFile: () => true }),
|
||||
readFile: async () => Buffer.from('#!/bin/sh\n# SubMiner launcher\n'),
|
||||
access: async (targetPath) => {
|
||||
accessed.push(targetPath);
|
||||
},
|
||||
writeFile: async () => {},
|
||||
chmod: async () => {},
|
||||
rename: async () => {},
|
||||
unlink: async () => {},
|
||||
},
|
||||
});
|
||||
|
||||
assert.deepEqual(result, {
|
||||
status: 'skipped',
|
||||
path: '/home/tester/.local/bin/subminer',
|
||||
message: 'Launcher migration is deferred until the updated SubMiner app starts.',
|
||||
deferred: true,
|
||||
});
|
||||
assert.deepEqual(accessed, ['/home/tester/.local/bin/subminer', '/home/tester/.local/bin']);
|
||||
assert.equal(downloaded, false);
|
||||
});
|
||||
|
||||
test('release launcher updater propagates GUI migration deferral', async () => {
|
||||
let downloaded = false;
|
||||
const result = await updateLauncherFromRelease({
|
||||
release: {
|
||||
tag_name: 'v0.15.0',
|
||||
prerelease: false,
|
||||
draft: false,
|
||||
assets: [{ name: 'subminer', browser_download_url: 'https://example.test/subminer' }],
|
||||
},
|
||||
sha256Sums: new Map([['subminer', launcherHash]]),
|
||||
launcherPath: '/home/tester/.local/bin/subminer',
|
||||
deferRecognizedLauncherUpdate: true,
|
||||
exists: () => true,
|
||||
downloadAsset: async () => {
|
||||
downloaded = true;
|
||||
return launcherBytes;
|
||||
},
|
||||
fs: {
|
||||
stat: async () => ({ isFile: () => true }),
|
||||
readFile: async () => Buffer.from('#!/bin/sh\n# SubMiner launcher\n'),
|
||||
access: async () => {},
|
||||
writeFile: async () => {},
|
||||
chmod: async () => {},
|
||||
rename: async () => {},
|
||||
unlink: async () => {},
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(result.status, 'skipped');
|
||||
assert.match(result.message ?? '', /deferred until the updated SubMiner app starts/);
|
||||
assert.equal(downloaded, false);
|
||||
});
|
||||
|
||||
test('GUI migration reports a protected launcher when its file or parent is not writable', async () => {
|
||||
const launcherPath = '/usr/local/bin/subminer';
|
||||
for (const protectedPath of [launcherPath, '/usr/local/bin']) {
|
||||
const result = await updateLauncherAtPath({
|
||||
launcherPath,
|
||||
assetUrl: 'https://example.test/subminer',
|
||||
expectedSha256: launcherHash,
|
||||
deferRecognizedLauncherUpdate: true,
|
||||
download: async () => {
|
||||
throw new Error('Protected launchers must not download a replacement.');
|
||||
},
|
||||
fs: {
|
||||
stat: async () => ({ isFile: () => true }),
|
||||
readFile: async () => Buffer.from('#!/usr/bin/env bun\n// SubMiner launcher\n'),
|
||||
access: async (targetPath) => {
|
||||
if (targetPath === protectedPath) throw new Error('EACCES');
|
||||
},
|
||||
writeFile: async () => {
|
||||
throw new Error('Protected launchers must not be written.');
|
||||
},
|
||||
chmod: async () => {},
|
||||
rename: async () => {},
|
||||
unlink: async () => {},
|
||||
},
|
||||
});
|
||||
assert.equal(result.status, 'protected', protectedPath);
|
||||
assert.equal(
|
||||
result.command,
|
||||
buildProtectedLauncherUpdateCommand('https://example.test/subminer', launcherPath),
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -4,6 +4,7 @@ import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import type { GitHubRelease } from './release-assets';
|
||||
import { findReleaseAsset } from './release-assets';
|
||||
import { isManagedLauncher } from '../managed-launcher';
|
||||
|
||||
type StatLike = {
|
||||
isFile: () => boolean;
|
||||
@@ -23,6 +24,8 @@ export interface LauncherUpdateResult {
|
||||
path?: string;
|
||||
command?: string;
|
||||
message?: string;
|
||||
// Set when a writable legacy launcher was left for app startup to migrate.
|
||||
deferred?: boolean;
|
||||
}
|
||||
|
||||
export interface LauncherUpdateFileSystem {
|
||||
@@ -82,6 +85,7 @@ export async function updateLauncherAtPath(options: {
|
||||
assetUrl: string;
|
||||
expectedSha256: string;
|
||||
download: () => Promise<Buffer>;
|
||||
deferRecognizedLauncherUpdate?: boolean;
|
||||
fs?: LauncherUpdateFileSystem;
|
||||
}): Promise<LauncherUpdateResult> {
|
||||
const fsDeps = options.fs ?? defaultFs();
|
||||
@@ -96,6 +100,13 @@ export async function updateLauncherAtPath(options: {
|
||||
}
|
||||
|
||||
const existing = await fsDeps.readFile(options.launcherPath);
|
||||
if (isManagedLauncher(existing.toString())) {
|
||||
return {
|
||||
status: 'skipped',
|
||||
path: options.launcherPath,
|
||||
message: 'This launcher is updated with the SubMiner app.',
|
||||
};
|
||||
}
|
||||
if (!looksLikeSubminerLauncher(existing)) {
|
||||
return {
|
||||
status: 'skipped',
|
||||
@@ -103,9 +114,9 @@ export async function updateLauncherAtPath(options: {
|
||||
message: 'Existing executable does not look like a SubMiner launcher.',
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
await fsDeps.access(options.launcherPath);
|
||||
await fsDeps.access(path.dirname(options.launcherPath));
|
||||
} catch {
|
||||
return {
|
||||
status: 'protected',
|
||||
@@ -114,6 +125,15 @@ export async function updateLauncherAtPath(options: {
|
||||
};
|
||||
}
|
||||
|
||||
if (options.deferRecognizedLauncherUpdate) {
|
||||
return {
|
||||
status: 'skipped',
|
||||
path: options.launcherPath,
|
||||
message: 'Launcher migration is deferred until the updated SubMiner app starts.',
|
||||
deferred: true,
|
||||
};
|
||||
}
|
||||
|
||||
const data = await options.download();
|
||||
const actualSha256 = sha256(data);
|
||||
if (actualSha256 !== options.expectedSha256.toLowerCase()) {
|
||||
@@ -160,7 +180,9 @@ export async function updateLauncherFromRelease(options: {
|
||||
platform?: NodeJS.Platform;
|
||||
homeDir?: string;
|
||||
downloadAsset: (url: string) => Promise<Buffer>;
|
||||
deferRecognizedLauncherUpdate?: boolean;
|
||||
exists?: (targetPath: string) => boolean;
|
||||
fs?: LauncherUpdateFileSystem;
|
||||
}): Promise<LauncherUpdateResult> {
|
||||
if (!options.release) return { status: 'missing-asset', message: 'No release found.' };
|
||||
const asset = findReleaseAsset(options.release, 'subminer');
|
||||
@@ -184,5 +206,7 @@ export async function updateLauncherFromRelease(options: {
|
||||
assetUrl: asset.browser_download_url,
|
||||
expectedSha256,
|
||||
download: () => options.downloadAsset(asset.browser_download_url),
|
||||
deferRecognizedLauncherUpdate: options.deferRecognizedLauncherUpdate,
|
||||
fs: options.fs,
|
||||
});
|
||||
}
|
||||
@@ -19,7 +19,11 @@ import { shouldFetchReleaseMetadataForPlatform } from './release-metadata-policy
|
||||
import { updateLauncherFromRelease } from './launcher-updater';
|
||||
import { notifyUpdateAvailable } from './update-notifications';
|
||||
import { createUpdateDialogPresenter } from './update-dialogs';
|
||||
import { createFileUpdateStateStore, createUpdateService } from './update-service';
|
||||
import {
|
||||
createFileUpdateStateStore,
|
||||
createUpdateService,
|
||||
takePendingLauncherMigrationPath,
|
||||
} from './update-service';
|
||||
import { updateSupportAssetsFromRelease } from './support-assets';
|
||||
import { runSupportAssetUpdatesForLauncherResult } from './update-support-assets-runtime';
|
||||
|
||||
@@ -38,6 +42,9 @@ export interface UpdateServiceRuntimeDeps {
|
||||
|
||||
export function createUpdateServiceRuntime(deps: UpdateServiceRuntimeDeps): {
|
||||
getUpdateService: () => ReturnType<typeof createUpdateService>;
|
||||
takePendingLauncherMigrationPath: (
|
||||
refresh: Parameters<typeof takePendingLauncherMigrationPath>[1],
|
||||
) => Promise<string | undefined>;
|
||||
} {
|
||||
const updateStateStore = createFileUpdateStateStore(
|
||||
path.join(deps.userDataPath, 'update-state.json'),
|
||||
@@ -79,6 +86,7 @@ export function createUpdateServiceRuntime(deps: UpdateServiceRuntimeDeps): {
|
||||
sha256Sums: sums,
|
||||
launcherPath,
|
||||
downloadAsset: (url) => fetchReleaseAssetBuffer(fetchForUpdater, url),
|
||||
deferRecognizedLauncherUpdate: true,
|
||||
});
|
||||
return runSupportAssetUpdatesForLauncherResult({
|
||||
launcherResult,
|
||||
@@ -152,8 +160,7 @@ export function createUpdateServiceRuntime(deps: UpdateServiceRuntimeDeps): {
|
||||
getConfig: () => deps.getUpdatesConfig(),
|
||||
getCurrentVersion: () => app.getVersion(),
|
||||
now: () => Date.now(),
|
||||
readState: () => updateStateStore.readState(),
|
||||
writeState: (state) => updateStateStore.writeState(state),
|
||||
stateStore: updateStateStore,
|
||||
checkAppUpdate: (channel) => appUpdater.checkForUpdates(channel),
|
||||
shouldFetchReleaseMetadata: ({ request, appUpdate }) =>
|
||||
shouldFetchReleaseMetadataForPlatform(process.platform, appUpdate, request),
|
||||
@@ -187,5 +194,9 @@ export function createUpdateServiceRuntime(deps: UpdateServiceRuntimeDeps): {
|
||||
return updateService;
|
||||
}
|
||||
|
||||
return { getUpdateService };
|
||||
return {
|
||||
getUpdateService,
|
||||
takePendingLauncherMigrationPath: (refresh) =>
|
||||
takePendingLauncherMigrationPath(updateStateStore, refresh),
|
||||
};
|
||||
}
|
||||
@@ -1,7 +1,13 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { shouldFetchReleaseMetadataForPlatform } from './release-metadata-policy';
|
||||
import { createUpdateService, type UpdateServiceDeps, type UpdateState } from './update-service';
|
||||
import {
|
||||
createUpdateService,
|
||||
createUpdateStateStore,
|
||||
takePendingLauncherMigrationPath,
|
||||
type UpdateServiceDeps,
|
||||
type UpdateState,
|
||||
} from './update-service';
|
||||
|
||||
function createDeps(overrides: Partial<UpdateServiceDeps> = {}) {
|
||||
let state: UpdateState = {};
|
||||
@@ -15,11 +21,13 @@ function createDeps(overrides: Partial<UpdateServiceDeps> = {}) {
|
||||
}),
|
||||
getCurrentVersion: () => '0.14.0',
|
||||
now: () => 1_000_000,
|
||||
readState: async () => state,
|
||||
writeState: async (nextState) => {
|
||||
state = nextState;
|
||||
calls.push(`state:${JSON.stringify(nextState)}`);
|
||||
},
|
||||
stateStore: createUpdateStateStore({
|
||||
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',
|
||||
@@ -286,7 +294,7 @@ test('concurrent update checks share one in-flight check', async () => {
|
||||
const first = service.checkForUpdates({ source: 'manual' });
|
||||
const second = service.checkForUpdates({ source: 'manual' });
|
||||
|
||||
await Promise.resolve();
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
resolveCheck({ available: false, version: '0.14.0' });
|
||||
await Promise.all([first, second]);
|
||||
|
||||
@@ -307,7 +315,7 @@ test('manual install request does not reuse in-flight manual check', async () =>
|
||||
const manualCheck = service.checkForUpdates({ source: 'manual' });
|
||||
const manualInstall = service.checkForUpdates({ source: 'manual', installWhenAvailable: true });
|
||||
|
||||
await Promise.resolve();
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
assert.equal(checkCount, 2);
|
||||
for (const resolve of resolveChecks) {
|
||||
resolve({ available: false, version: '0.14.0' });
|
||||
@@ -315,26 +323,32 @@ test('manual install request does not reuse in-flight manual check', async () =>
|
||||
await Promise.all([manualCheck, manualInstall]);
|
||||
});
|
||||
|
||||
test('manual update check does not reuse in-flight automatic check', async () => {
|
||||
test('manual update check does not reuse in-flight automatic check and preserves all state', async () => {
|
||||
let checkCount = 0;
|
||||
const resolveChecks: Array<(value: { available: boolean; version: string }) => void> = [];
|
||||
const { deps } = createDeps({
|
||||
const { deps, getState } = createDeps({
|
||||
checkAppUpdate: () =>
|
||||
new Promise((resolve) => {
|
||||
checkCount += 1;
|
||||
resolveChecks.push(resolve);
|
||||
}),
|
||||
updateLauncher: async () => ({ status: 'skipped', path: '/x/subminer', deferred: true }),
|
||||
});
|
||||
const service = createUpdateService(deps);
|
||||
const automatic = service.checkForUpdates({ source: 'automatic', force: true });
|
||||
const manual = service.checkForUpdates({ source: 'manual' });
|
||||
const manual = service.checkForUpdates({ source: 'manual', installWhenAvailable: true });
|
||||
|
||||
await Promise.resolve();
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
assert.equal(checkCount, 2);
|
||||
for (const resolve of resolveChecks) {
|
||||
resolve({ available: false, version: '0.14.0' });
|
||||
resolve({ available: true, version: '0.15.0' });
|
||||
}
|
||||
await Promise.all([automatic, manual]);
|
||||
assert.deepEqual(getState(), {
|
||||
pendingLauncherMigrationPath: '/x/subminer',
|
||||
lastAutomaticCheckAt: 1_000_000,
|
||||
lastNotifiedVersion: '0.15.0',
|
||||
});
|
||||
});
|
||||
|
||||
test('manual update check passes selected GitHub release to launcher update', async () => {
|
||||
@@ -488,3 +502,85 @@ test('manual update check keeps current prerelease builds on configured stable c
|
||||
assert.equal(result.status, 'up-to-date');
|
||||
assert.deepEqual(calls, ['app:stable', 'fetch:stable', 'no-update:0.15.0-beta.3']);
|
||||
});
|
||||
|
||||
test('deferred launcher migration is persisted for the next app start', async () => {
|
||||
const launcherPath = '/home/tester/.local/bin/subminer';
|
||||
const { deps, calls } = createDeps({
|
||||
checkAppUpdate: async () => ({ available: true, version: '0.15.0' }),
|
||||
fetchLatestStableRelease: async () => ({
|
||||
tag_name: 'v0.15.0',
|
||||
prerelease: false,
|
||||
draft: false,
|
||||
assets: [],
|
||||
}),
|
||||
showUpdateAvailableDialog: async () => 'update',
|
||||
updateLauncher: async () => ({ status: 'skipped', path: launcherPath, deferred: true }),
|
||||
});
|
||||
const service = createUpdateService(deps);
|
||||
|
||||
const result = await service.checkForUpdates({ source: 'manual', launcherPath });
|
||||
|
||||
assert.equal(result.status, 'updated');
|
||||
assert.ok(
|
||||
calls.includes(`state:${JSON.stringify({ pendingLauncherMigrationPath: launcherPath })}`),
|
||||
);
|
||||
});
|
||||
|
||||
test('takePendingLauncherMigrationPath hands the path over exactly once', async () => {
|
||||
let state: UpdateState = {
|
||||
lastNotifiedVersion: '0.15.0',
|
||||
pendingLauncherMigrationPath: '/x/subminer',
|
||||
};
|
||||
const store = createUpdateStateStore({
|
||||
readState: async () => state,
|
||||
writeState: async (nextState: UpdateState) => {
|
||||
state = nextState;
|
||||
},
|
||||
});
|
||||
|
||||
const refresh = async () => true;
|
||||
assert.deepEqual(
|
||||
await Promise.all([
|
||||
takePendingLauncherMigrationPath(store, refresh),
|
||||
takePendingLauncherMigrationPath(store, refresh),
|
||||
]),
|
||||
['/x/subminer', undefined],
|
||||
);
|
||||
assert.deepEqual(state, { lastNotifiedVersion: '0.15.0' });
|
||||
assert.equal(await takePendingLauncherMigrationPath(store, refresh), undefined);
|
||||
});
|
||||
|
||||
test('failed migration remains pending and acknowledgement preserves concurrent check state', async () => {
|
||||
const { deps, getState, setState } = createDeps({
|
||||
checkAppUpdate: async () => ({ available: true, version: '0.15.0' }),
|
||||
});
|
||||
setState({ pendingLauncherMigrationPath: '/x/subminer' });
|
||||
await assert.rejects(
|
||||
takePendingLauncherMigrationPath(deps.stateStore, async () => {
|
||||
throw new Error('refresh failed');
|
||||
}),
|
||||
/refresh failed/,
|
||||
);
|
||||
assert.equal(getState().pendingLauncherMigrationPath, '/x/subminer');
|
||||
|
||||
const service = createUpdateService(deps);
|
||||
const check = service.checkForUpdates({ source: 'automatic' });
|
||||
await takePendingLauncherMigrationPath(deps.stateStore, async () => false);
|
||||
await check;
|
||||
assert.deepEqual(getState(), {
|
||||
pendingLauncherMigrationPath: '/x/subminer',
|
||||
lastAutomaticCheckAt: 1_000_000,
|
||||
lastNotifiedVersion: '0.15.0',
|
||||
});
|
||||
|
||||
const nextCheck = service.checkForUpdates({ source: 'automatic', force: true });
|
||||
assert.equal(
|
||||
await takePendingLauncherMigrationPath(deps.stateStore, async () => true),
|
||||
'/x/subminer',
|
||||
);
|
||||
await nextCheck;
|
||||
assert.deepEqual(getState(), {
|
||||
lastAutomaticCheckAt: 1_000_000,
|
||||
lastNotifiedVersion: '0.15.0',
|
||||
});
|
||||
});
|
||||
@@ -7,6 +7,8 @@ import { compareSemverLike, parseReleaseVersion } from './release-assets';
|
||||
export interface UpdateState {
|
||||
lastAutomaticCheckAt?: number;
|
||||
lastNotifiedVersion?: string;
|
||||
// Legacy launcher the last update left for the next app start to migrate.
|
||||
pendingLauncherMigrationPath?: string;
|
||||
}
|
||||
|
||||
export type UpdateCheckSource = 'manual' | 'automatic' | 'launcher';
|
||||
@@ -41,8 +43,7 @@ export interface UpdateServiceDeps {
|
||||
getConfig: () => Required<UpdatesConfig>;
|
||||
getCurrentVersion: () => string;
|
||||
now: () => number;
|
||||
readState: () => Promise<UpdateState>;
|
||||
writeState: (state: UpdateState) => Promise<void>;
|
||||
stateStore: ReturnType<typeof createUpdateStateStore>;
|
||||
checkAppUpdate: (channel: UpdateChannel) => Promise<AppUpdateMetadata>;
|
||||
shouldFetchReleaseMetadata?: (input: {
|
||||
request: UpdateCheckRequest;
|
||||
@@ -54,7 +55,7 @@ export interface UpdateServiceDeps {
|
||||
launcherPath?: string,
|
||||
channel?: UpdateChannel,
|
||||
release?: GitHubRelease | null,
|
||||
) => Promise<{ status: string; command?: string }>;
|
||||
) => Promise<{ status: string; command?: string; path?: string; deferred?: boolean }>;
|
||||
showNoUpdateDialog: (version: string) => Promise<void>;
|
||||
showUpdateAvailableDialog: (version: string) => Promise<'update' | 'close'>;
|
||||
showUpdateFailedDialog: (message: string) => Promise<void>;
|
||||
@@ -121,7 +122,7 @@ export function createUpdateService(deps: UpdateServiceDeps) {
|
||||
const now = deps.now();
|
||||
const config = deps.getConfig();
|
||||
const channel = config.channel;
|
||||
const state = await deps.readState();
|
||||
const state = await deps.stateStore.transaction((store) => store.readState());
|
||||
const isAutomatic = request.source === 'automatic';
|
||||
|
||||
if (isAutomatic && !request.force && shouldSkipAutomaticCheck(config, state, now)) {
|
||||
@@ -150,15 +151,18 @@ export function createUpdateService(deps: UpdateServiceDeps) {
|
||||
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);
|
||||
await deps.stateStore.transaction(async (store) => {
|
||||
const currentState = await store.readState();
|
||||
const nextState: UpdateState = {
|
||||
...currentState,
|
||||
lastAutomaticCheckAt: now,
|
||||
};
|
||||
if (latest.available && currentState.lastNotifiedVersion !== latest.version) {
|
||||
await deps.notifyUpdateAvailable(latest.version);
|
||||
nextState.lastNotifiedVersion = latest.version;
|
||||
}
|
||||
await store.writeState(nextState);
|
||||
});
|
||||
}
|
||||
|
||||
if (!latest.available) {
|
||||
@@ -189,6 +193,12 @@ export function createUpdateService(deps: UpdateServiceDeps) {
|
||||
if (launcherResult.status === 'protected' && launcherResult.command) {
|
||||
deps.log(`Launcher update requires manual command: ${launcherResult.command}`);
|
||||
}
|
||||
if (launcherResult.deferred && launcherResult.path) {
|
||||
const pendingLauncherMigrationPath = launcherResult.path;
|
||||
await deps.stateStore.transaction(async (store) => {
|
||||
await store.writeState({ ...(await store.readState()), pendingLauncherMigrationPath });
|
||||
});
|
||||
}
|
||||
|
||||
if (!appUpdateApplied) {
|
||||
await deps.showManualUpdateRequiredDialog(latest.version);
|
||||
@@ -237,11 +247,41 @@ export function createUpdateService(deps: UpdateServiceDeps) {
|
||||
};
|
||||
}
|
||||
|
||||
export function createFileUpdateStateStore(statePath: string): {
|
||||
// Keep the path until refresh acknowledges migration or an ineligible candidate.
|
||||
export async function takePendingLauncherMigrationPath(
|
||||
stateStore: ReturnType<typeof createUpdateStateStore>,
|
||||
refresh: (launcherPath: string | undefined) => Promise<boolean>,
|
||||
): Promise<string | undefined> {
|
||||
return stateStore.transaction(async (store) => {
|
||||
const { pendingLauncherMigrationPath, ...rest } = await store.readState();
|
||||
const acknowledged = await refresh(pendingLauncherMigrationPath);
|
||||
if (!pendingLauncherMigrationPath || !acknowledged) {
|
||||
return undefined;
|
||||
}
|
||||
await store.writeState(rest);
|
||||
return pendingLauncherMigrationPath;
|
||||
});
|
||||
}
|
||||
|
||||
export function createUpdateStateStore(store: {
|
||||
readState: () => Promise<UpdateState>;
|
||||
writeState: (state: UpdateState) => Promise<void>;
|
||||
} {
|
||||
}) {
|
||||
let pending = Promise.resolve();
|
||||
return {
|
||||
transaction<T>(operation: (stateStore: typeof store) => Promise<T>): Promise<T> {
|
||||
const result = pending.then(() => operation(store));
|
||||
pending = result.then(
|
||||
() => {},
|
||||
() => {},
|
||||
);
|
||||
return result;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function createFileUpdateStateStore(statePath: string) {
|
||||
return createUpdateStateStore({
|
||||
async readState(): Promise<UpdateState> {
|
||||
try {
|
||||
return JSON.parse(await fs.promises.readFile(statePath, 'utf8')) as UpdateState;
|
||||
@@ -253,5 +293,5 @@ export function createFileUpdateStateStore(statePath: string): {
|
||||
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