feat(launcher): bundle Bun and use it across all launchers (#243)

This commit is contained in:
sudacode authored and GitHub committed 2026-09-11 00:21:10 -07:00
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),
);
}
});
+25 -1
View File
@@ -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),
};
}
+109 -13
View File
@@ -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',
});
});
+56 -16
View File
@@ -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');
},
};
});
}