fix(tools): recover staged first installs asynchronously

Promote a verified staged first installation after interruption and clear stale first-install journals. Stream archive and managed executable hashes, make status recovery non-blocking, and await the propagated tool and IPC status contracts.
This commit is contained in:
Sucukdeluxe
2026-08-12 01:23:15 +02:00
parent 60b5aa8ca9
commit aa06d486be
4 changed files with 158 additions and 76 deletions
+4 -4
View File
@@ -7977,9 +7977,9 @@ registerTrustedIpcHandler(ipcMain, 'run-preflight', isTrustedRendererEvent, () =
return await runPreflight(autoFix); return await runPreflight(autoFix);
}); });
ipcMain.handle('get-managed-tool-status', (event) => { ipcMain.handle('get-managed-tool-status', async (event) => {
if (!isTrustedRendererEvent(event)) return null; if (!isTrustedRendererEvent(event)) return null;
return getManagedToolStatuses(); return await getManagedToolStatuses();
}); });
ipcMain.handle('repair-managed-tools', async (event) => { ipcMain.handle('repair-managed-tools', async (event) => {
@@ -7987,9 +7987,9 @@ ipcMain.handle('repair-managed-tools', async (event) => {
return await repairManagedTools(); return await repairManagedTools();
}); });
ipcMain.handle('reset-managed-tools', (event) => { ipcMain.handle('reset-managed-tools', async (event) => {
if (!isTrustedRendererEvent(event)) return null; if (!isTrustedRendererEvent(event)) return null;
return resetManagedTools(); return await resetManagedTools();
}); });
registerTrustedIpcHandler(ipcMain, 'get-debug-log', isTrustedRendererEvent, () => Promise.resolve(''), async (_, lines: number = 200) => { registerTrustedIpcHandler(ipcMain, 'get-debug-log', isTrustedRendererEvent, () => Promise.resolve(''), async (_, lines: number = 200) => {
+69 -9
View File
@@ -1,4 +1,15 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
const fileSystemSpies = vi.hoisted(() => ({
readFileSync: vi.fn()
}));
vi.mock('node:fs', async (importOriginal) => {
const actual = await importOriginal<typeof import('node:fs')>();
fileSystemSpies.readFileSync.mockImplementation(actual.readFileSync);
return { ...actual, readFileSync: fileSystemSpies.readFileSync };
});
import * as crypto from 'node:crypto'; import * as crypto from 'node:crypto';
import * as fs from 'node:fs'; import * as fs from 'node:fs';
import * as os from 'node:os'; import * as os from 'node:os';
@@ -187,7 +198,7 @@ describe('managed tool installer', () => {
}); });
}); });
it('marks a mismatched installed version as unverified', () => { it('marks a mismatched installed version as unverified', async () => {
const { installer, installPath } = createInstaller(); const { installer, installPath } = createInstaller();
writeExistingInstallation(installPath); writeExistingInstallation(installPath);
fs.writeFileSync(path.join(installPath, '.tool-manifest.json'), JSON.stringify({ fs.writeFileSync(path.join(installPath, '.tool-manifest.json'), JSON.stringify({
@@ -198,7 +209,7 @@ describe('managed tool installer', () => {
sha256: sha256('old archive') sha256: sha256('old archive')
})); }));
expect(installer.status(manifest())).toMatchObject({ await expect(installer.status(manifest())).resolves.toMatchObject({
version: '8.3.0', version: '8.3.0',
state: 'unverified', state: 'unverified',
verified: false verified: false
@@ -222,8 +233,7 @@ describe('managed tool installer', () => {
const first = installer.repair(manifest()); const first = installer.repair(manifest());
const second = installer.repair(manifest()); const second = installer.repair(manifest());
await Promise.resolve(); await vi.waitFor(() => expect(download).toHaveBeenCalledTimes(1));
expect(download).toHaveBeenCalledTimes(1);
releaseDownload?.(); releaseDownload?.();
const [firstResult, secondResult] = await Promise.all([first, second]); const [firstResult, secondResult] = await Promise.all([first, second]);
@@ -233,7 +243,7 @@ describe('managed tool installer', () => {
expect(download).toHaveBeenCalledTimes(1); expect(download).toHaveBeenCalledTimes(1);
}); });
it('restores a valid previous tool at the active path after interruption between backup and promotion', () => { it('restores a valid previous tool at the active path after interruption between backup and promotion', async () => {
const { installer, installPath } = createInstaller(); const { installer, installPath } = createInstaller();
const backupPath = `${installPath}.backup-interrupted`; const backupPath = `${installPath}.backup-interrupted`;
const stagingPath = `${installPath}.stage-interrupted`; const stagingPath = `${installPath}.stage-interrupted`;
@@ -241,14 +251,14 @@ describe('managed tool installer', () => {
writeVerifiedInstallation(stagingPath, 'new executable'); writeVerifiedInstallation(stagingPath, 'new executable');
writeInterruptedPromotionJournal(installPath, backupPath, stagingPath, 'backup-created'); writeInterruptedPromotionJournal(installPath, backupPath, stagingPath, 'backup-created');
expect(installer.status(manifest())).toMatchObject({ state: 'verified', verified: true }); await expect(installer.status(manifest())).resolves.toMatchObject({ state: 'verified', verified: true });
expect(fs.readFileSync(path.join(installPath, 'bin', 'streamlink.exe'), 'utf8')).toBe('old executable'); expect(fs.readFileSync(path.join(installPath, 'bin', 'streamlink.exe'), 'utf8')).toBe('old executable');
expect(fs.existsSync(backupPath)).toBe(false); expect(fs.existsSync(backupPath)).toBe(false);
expect(fs.existsSync(stagingPath)).toBe(false); expect(fs.existsSync(stagingPath)).toBe(false);
expect(fs.existsSync(`${installPath}.transaction.json`)).toBe(false); expect(fs.existsSync(`${installPath}.transaction.json`)).toBe(false);
}); });
it('keeps a valid promoted tool after interruption between promotion and journal cleanup', () => { it('keeps a valid promoted tool after interruption between promotion and journal cleanup', async () => {
const { installer, installPath } = createInstaller(); const { installer, installPath } = createInstaller();
const backupPath = `${installPath}.backup-interrupted`; const backupPath = `${installPath}.backup-interrupted`;
const stagingPath = `${installPath}.stage-interrupted`; const stagingPath = `${installPath}.stage-interrupted`;
@@ -256,7 +266,7 @@ describe('managed tool installer', () => {
writeVerifiedInstallation(backupPath, 'old executable'); writeVerifiedInstallation(backupPath, 'old executable');
writeInterruptedPromotionJournal(installPath, backupPath, stagingPath, 'backup-created'); writeInterruptedPromotionJournal(installPath, backupPath, stagingPath, 'backup-created');
expect(installer.status(manifest())).toMatchObject({ state: 'verified', verified: true }); await expect(installer.status(manifest())).resolves.toMatchObject({ state: 'verified', verified: true });
expect(fs.readFileSync(path.join(installPath, 'bin', 'streamlink.exe'), 'utf8')).toBe('new executable'); expect(fs.readFileSync(path.join(installPath, 'bin', 'streamlink.exe'), 'utf8')).toBe('new executable');
expect(fs.existsSync(backupPath)).toBe(false); expect(fs.existsSync(backupPath)).toBe(false);
expect(fs.existsSync(`${installPath}.transaction.json`)).toBe(false); expect(fs.existsSync(`${installPath}.transaction.json`)).toBe(false);
@@ -313,11 +323,61 @@ describe('managed tool installer', () => {
expect(installed.success).toBe(true); expect(installed.success).toBe(true);
fs.writeFileSync(path.join(installPath, 'bin', 'streamlink.exe'), 'changed executable'); fs.writeFileSync(path.join(installPath, 'bin', 'streamlink.exe'), 'changed executable');
expect(installer.status(manifest())).toMatchObject({ state: 'corrupt', verified: false }); await expect(installer.status(manifest())).resolves.toMatchObject({ state: 'corrupt', verified: false });
const repaired = await installer.repair(manifest()); const repaired = await installer.repair(manifest());
expect(repaired.success).toBe(true); expect(repaired.success).toBe(true);
expect(repaired.status).toMatchObject({ state: 'verified', verified: true }); expect(repaired.status).toMatchObject({ state: 'verified', verified: true });
expect(fs.readFileSync(path.join(installPath, 'bin', 'streamlink.exe'), 'utf8')).toBe('new executable'); expect(fs.readFileSync(path.join(installPath, 'bin', 'streamlink.exe'), 'utf8')).toBe('new executable');
}); });
it('promotes a verified staged first install after interruption before promotion', async () => {
const { installer, installPath, download } = createInstaller();
const stagingPath = `${installPath}.stage-interrupted`;
writeVerifiedInstallation(stagingPath, 'first executable');
writeInterruptedPromotionJournal(installPath, `${installPath}.backup-interrupted`, stagingPath, 'staged');
await expect(installer.status(manifest())).resolves.toMatchObject({ state: 'verified', verified: true });
expect(fs.readFileSync(path.join(installPath, 'bin', 'streamlink.exe'), 'utf8')).toBe('first executable');
expect(fs.existsSync(stagingPath)).toBe(false);
expect(fs.existsSync(`${installPath}.transaction.json`)).toBe(false);
const result = await installer.install(manifest());
expect(result.success).toBe(true);
expect(download).not.toHaveBeenCalled();
});
it('clears a staged first-install journal after promotion before its phase update', async () => {
const { installer, installPath, download } = createInstaller();
writeVerifiedInstallation(installPath, 'first executable');
writeInterruptedPromotionJournal(installPath, `${installPath}.backup-interrupted`, `${installPath}.stage-interrupted`, 'staged');
await expect(installer.status(manifest())).resolves.toMatchObject({ state: 'verified', verified: true });
expect(fs.readFileSync(path.join(installPath, 'bin', 'streamlink.exe'), 'utf8')).toBe('first executable');
expect(fs.existsSync(`${installPath}.transaction.json`)).toBe(false);
const result = await installer.install(manifest());
expect(result.success).toBe(true);
expect(download).not.toHaveBeenCalled();
});
it('streams multi-megabyte executable verification without readFileSync and detects corruption', async () => {
const multiMegabyteExecutable = Buffer.alloc(3 * 1024 * 1024, 0x5a);
const { installer, installPath } = createInstaller({
extract: async (_archivePath, destinationPath) => {
fs.mkdirSync(path.join(destinationPath, 'bin'), { recursive: true });
fs.writeFileSync(path.join(destinationPath, 'bin', 'streamlink.exe'), multiMegabyteExecutable);
}
});
expect((await installer.repair(manifest())).success).toBe(true);
fileSystemSpies.readFileSync.mockClear();
await expect(installer.status(manifest())).resolves.toMatchObject({ state: 'verified', verified: true });
expect(fileSystemSpies.readFileSync).not.toHaveBeenCalled();
fs.writeFileSync(path.join(installPath, 'bin', 'streamlink.exe'), Buffer.alloc(3 * 1024 * 1024, 0x2a));
await expect(installer.status(manifest())).resolves.toMatchObject({ state: 'corrupt', verified: false });
expect(fileSystemSpies.readFileSync).not.toHaveBeenCalled();
});
}); });
+66 -46
View File
@@ -92,8 +92,16 @@ function findFileRecursive(rootDir: string, fileName: string): string | null {
return null; return null;
} }
function sha256File(filePath: string): string { function sha256File(filePath: string): Promise<string> {
return crypto.createHash('sha256').update(fs.readFileSync(filePath)).digest('hex'); return new Promise((resolve, reject) => {
const hash = crypto.createHash('sha256');
const stream = fs.createReadStream(filePath);
stream.on('data', (chunk) => {
hash.update(chunk);
});
stream.once('error', reject);
stream.once('end', () => resolve(hash.digest('hex')));
});
} }
function isRecord(value: unknown): value is ManagedToolRecord { function isRecord(value: unknown): value is ManagedToolRecord {
@@ -146,20 +154,20 @@ export class ManagedToolInstaller {
constructor(private readonly options: ManagedToolInstallerOptions) {} constructor(private readonly options: ManagedToolInstallerOptions) {}
status(manifest: ExternalToolManifest, includeInstallState = true): ManagedToolStatus { async status(manifest: ExternalToolManifest, includeInstallState = true): Promise<ManagedToolStatus> {
if (includeInstallState && this.inFlight.has(manifest.id)) { if (includeInstallState && this.inFlight.has(manifest.id)) {
return this.createStatus(manifest, 'installing', false); return this.createStatus(manifest, 'installing', false);
} }
const recovery = this.recover(manifest, []); const recovery = await this.recover(manifest, []);
if (!recovery.success) { if (!recovery.success) {
return this.createStatus(manifest, 'corrupt', false); return this.createStatus(manifest, 'corrupt', false);
} }
return this.statusFromInspection(manifest); return await this.statusFromInspection(manifest);
} }
install(manifest: ExternalToolManifest): Promise<ManagedToolInstallResult> { async install(manifest: ExternalToolManifest): Promise<ManagedToolInstallResult> {
const currentStatus = this.status(manifest); const currentStatus = await this.status(manifest);
if (currentStatus.verified) { if (currentStatus.verified) {
return Promise.resolve({ success: true, status: currentStatus, diagnostics: [] }); return Promise.resolve({ success: true, status: currentStatus, diagnostics: [] });
} }
@@ -170,19 +178,19 @@ export class ManagedToolInstaller {
return this.start(manifest); return this.start(manifest);
} }
reset(manifest: ExternalToolManifest): ManagedToolStatus { async reset(manifest: ExternalToolManifest): Promise<ManagedToolStatus> {
if (this.inFlight.has(manifest.id)) { if (this.inFlight.has(manifest.id)) {
return this.status(manifest); return await this.status(manifest);
} }
const diagnostics: string[] = []; const diagnostics: string[] = [];
const journal = this.readJournal(); const journal = await this.readJournal();
if (isJournal(journal, this.options.installationDirectory)) { if (isJournal(journal, this.options.installationDirectory)) {
this.cleanup(journal.backupDirectory, true, diagnostics); this.cleanup(journal.backupDirectory, true, diagnostics);
this.cleanup(journal.stagingDirectory, true, diagnostics); this.cleanup(journal.stagingDirectory, true, diagnostics);
} }
this.cleanup(this.options.installationDirectory, true, diagnostics); this.cleanup(this.options.installationDirectory, true, diagnostics);
this.cleanup(this.journalPath(), false, diagnostics); this.cleanup(this.journalPath(), false, diagnostics);
return this.status(manifest); return await this.status(manifest);
} }
private start(manifest: ExternalToolManifest): Promise<ManagedToolInstallResult> { private start(manifest: ExternalToolManifest): Promise<ManagedToolInstallResult> {
@@ -192,7 +200,7 @@ export class ManagedToolInstaller {
let operation!: Promise<ManagedToolInstallResult>; let operation!: Promise<ManagedToolInstallResult>;
operation = Promise.resolve() operation = Promise.resolve()
.then(() => this.installOnce(manifest)) .then(() => this.installOnce(manifest))
.catch((error: unknown) => this.failure(manifest, 'download-failed', this.errorText(error), [])) .catch(async (error: unknown) => await this.failure(manifest, 'download-failed', this.errorText(error), []))
.finally(() => { .finally(() => {
if (this.inFlight.get(manifest.id) === operation) { if (this.inFlight.get(manifest.id) === operation) {
this.inFlight.delete(manifest.id); this.inFlight.delete(manifest.id);
@@ -209,29 +217,29 @@ export class ManagedToolInstaller {
const diagnostics: string[] = []; const diagnostics: string[] = [];
try { try {
const recovery = this.recover(manifest, diagnostics); const recovery = await this.recover(manifest, diagnostics);
if (!recovery.success) { if (!recovery.success) {
return this.failure(manifest, recovery.error!, recovery.detail, diagnostics); return await this.failure(manifest, recovery.error!, recovery.detail, diagnostics);
} }
fs.mkdirSync(this.options.temporaryDirectory, { recursive: true }); fs.mkdirSync(this.options.temporaryDirectory, { recursive: true });
await this.options.download(manifest.sourceUrl, archivePath); await this.options.download(manifest.sourceUrl, archivePath);
if (sha256File(archivePath).toLowerCase() !== manifest.sha256.toLowerCase()) { if ((await sha256File(archivePath)).toLowerCase() !== manifest.sha256.toLowerCase()) {
return this.failure(manifest, 'archive-hash-mismatch', undefined, diagnostics); return await this.failure(manifest, 'archive-hash-mismatch', undefined, diagnostics);
} }
try { try {
await this.options.extract(archivePath, stagingDirectory); await this.options.extract(archivePath, stagingDirectory);
} catch (error) { } catch (error) {
return this.failure(manifest, 'extract-failed', this.errorText(error), diagnostics); return await this.failure(manifest, 'extract-failed', this.errorText(error), diagnostics);
} }
const executablePaths = this.executablePaths(stagingDirectory, manifest.executables); const executablePaths = this.executablePaths(stagingDirectory, manifest.executables);
if (!executablePaths) { if (!executablePaths) {
return this.failure(manifest, 'required-executable-missing', undefined, diagnostics); return await this.failure(manifest, 'required-executable-missing', undefined, diagnostics);
} }
const executableHashes = Object.fromEntries(executablePaths.map(([name, executablePath]) => [name, sha256File(executablePath)])); const executableHashes = Object.fromEntries(await Promise.all(executablePaths.map(async ([name, executablePath]) => [name, await sha256File(executablePath)] as const)));
const record: ManagedToolRecord = { const record: ManagedToolRecord = {
id: manifest.id, id: manifest.id,
version: manifest.version, version: manifest.version,
@@ -242,21 +250,21 @@ export class ManagedToolInstaller {
}; };
fs.writeFileSync(path.join(stagingDirectory, RECORD_FILE_NAME), JSON.stringify(record)); fs.writeFileSync(path.join(stagingDirectory, RECORD_FILE_NAME), JSON.stringify(record));
const promotion = this.promote(manifest, stagingDirectory, diagnostics); const promotion = await this.promote(manifest, stagingDirectory, diagnostics);
if (!promotion.success) { if (!promotion.success) {
return this.failure(manifest, promotion.error!, promotion.detail, diagnostics); return await this.failure(manifest, promotion.error!, promotion.detail, diagnostics);
} }
return { success: true, status: this.statusFromInspection(manifest), diagnostics }; return { success: true, status: await this.statusFromInspection(manifest), diagnostics };
} catch (error) { } catch (error) {
return this.failure(manifest, 'download-failed', this.errorText(error), diagnostics); return await this.failure(manifest, 'download-failed', this.errorText(error), diagnostics);
} finally { } finally {
this.cleanup(archivePath, false, diagnostics); this.cleanup(archivePath, false, diagnostics);
this.cleanup(stagingDirectory, true, diagnostics); this.cleanup(stagingDirectory, true, diagnostics);
} }
} }
private promote(manifest: ExternalToolManifest, stagingDirectory: string, diagnostics: string[]): RecoveryResult { private async promote(manifest: ExternalToolManifest, stagingDirectory: string, diagnostics: string[]): Promise<RecoveryResult> {
const backupDirectory = `${this.options.installationDirectory}.backup-${crypto.randomUUID()}`; const backupDirectory = `${this.options.installationDirectory}.backup-${crypto.randomUUID()}`;
const journal: InstallationJournal = { const journal: InstallationJournal = {
installationDirectory: this.options.installationDirectory, installationDirectory: this.options.installationDirectory,
@@ -279,26 +287,26 @@ export class ManagedToolInstaller {
this.cleanup(this.journalPath(), false, diagnostics); this.cleanup(this.journalPath(), false, diagnostics);
return { success: true }; return { success: true };
} catch (error) { } catch (error) {
const recovery = this.recover(manifest, diagnostics); const recovery = await this.recover(manifest, diagnostics);
return recovery.success return recovery.success
? { success: false, error: 'promotion-failed', detail: this.errorText(error) } ? { success: false, error: 'promotion-failed', detail: this.errorText(error) }
: recovery; : recovery;
} }
} }
private recover(manifest: ExternalToolManifest, diagnostics: string[]): RecoveryResult { private async recover(manifest: ExternalToolManifest, diagnostics: string[]): Promise<RecoveryResult> {
const journal = this.readJournal(); const journal = await this.readJournal();
if (journal === undefined) { if (journal === undefined) {
return { success: true }; return { success: true };
} }
if (!isJournal(journal, this.options.installationDirectory)) { if (!isJournal(journal, this.options.installationDirectory)) {
return { success: false, error: 'recovery-journal-invalid', detail: 'Ungültiges Installationsjournal.' }; return { success: false, error: 'recovery-journal-invalid', detail: 'Ungültiges Installationsjournal.' };
} }
return this.recoverJournal(manifest, journal, diagnostics); return await this.recoverJournal(manifest, journal, diagnostics);
} }
private recoverJournal(manifest: ExternalToolManifest, journal: InstallationJournal, diagnostics: string[]): RecoveryResult { private async recoverJournal(manifest: ExternalToolManifest, journal: InstallationJournal, diagnostics: string[]): Promise<RecoveryResult> {
const active = this.inspectInstallation(this.options.installationDirectory, manifest); const active = await this.inspectInstallation(this.options.installationDirectory, manifest);
if (active.strictValid || (journal.phase === 'staged' && active.layoutUsable)) { if (active.strictValid || (journal.phase === 'staged' && active.layoutUsable)) {
this.cleanup(journal.backupDirectory, true, diagnostics); this.cleanup(journal.backupDirectory, true, diagnostics);
this.cleanup(journal.stagingDirectory, true, diagnostics); this.cleanup(journal.stagingDirectory, true, diagnostics);
@@ -306,11 +314,8 @@ export class ManagedToolInstaller {
return { success: true }; return { success: true };
} }
const backup = this.inspectInstallation(journal.backupDirectory, manifest); const backup = await this.inspectInstallation(journal.backupDirectory, manifest);
if (!backup.layoutUsable) { if (backup.layoutUsable) {
return { success: false, error: 'recovery-restore-failed', detail: 'Kein nutzbares Tool-Backup für die Wiederherstellung vorhanden.' };
}
let displacedDirectory: string | undefined; let displacedDirectory: string | undefined;
try { try {
if (fs.existsSync(this.options.installationDirectory)) { if (fs.existsSync(this.options.installationDirectory)) {
@@ -330,12 +335,27 @@ export class ManagedToolInstaller {
return { success: true }; return { success: true };
} }
private statusFromInspection(manifest: ExternalToolManifest): ManagedToolStatus { const staging = await this.inspectInstallation(journal.stagingDirectory, manifest);
const inspection = this.inspectInstallation(this.options.installationDirectory, manifest); if (journal.phase !== 'staged' || !staging.strictValid || fs.existsSync(this.options.installationDirectory)) {
return { success: false, error: 'recovery-restore-failed', detail: 'Kein nutzbares Tool-Backup für die Wiederherstellung vorhanden.' };
}
try {
this.rename(journal.stagingDirectory, this.options.installationDirectory);
} catch (error) {
return { success: false, error: 'recovery-restore-failed', detail: this.errorText(error) };
}
this.cleanup(this.journalPath(), false, diagnostics);
return { success: true };
}
private async statusFromInspection(manifest: ExternalToolManifest): Promise<ManagedToolStatus> {
const inspection = await this.inspectInstallation(this.options.installationDirectory, manifest);
return this.createStatus(manifest, inspection.state, inspection.strictValid, inspection.version); return this.createStatus(manifest, inspection.state, inspection.strictValid, inspection.version);
} }
private inspectInstallation(directory: string, manifest: ExternalToolManifest): InstallationInspection { private async inspectInstallation(directory: string, manifest: ExternalToolManifest): Promise<InstallationInspection> {
if (!fs.existsSync(directory)) { if (!fs.existsSync(directory)) {
return { state: 'missing', version: manifest.version, layoutUsable: false, strictValid: false }; return { state: 'missing', version: manifest.version, layoutUsable: false, strictValid: false };
} }
@@ -345,7 +365,7 @@ export class ManagedToolInstaller {
return { state: 'corrupt', version: manifest.version, layoutUsable: false, strictValid: false }; return { state: 'corrupt', version: manifest.version, layoutUsable: false, strictValid: false };
} }
const record = this.readRecord(directory); const record = await this.readRecord(directory);
if (!record || !isRecord(record)) { if (!record || !isRecord(record)) {
return { state: 'unverified', version: manifest.version, layoutUsable: true, strictValid: false }; return { state: 'unverified', version: manifest.version, layoutUsable: true, strictValid: false };
} }
@@ -357,7 +377,7 @@ export class ManagedToolInstaller {
} }
try { try {
const hashesMatch = executablePaths.every(([name, executablePath]) => sha256File(executablePath).toLowerCase() === record.executableHashes[name].toLowerCase()); const hashesMatch = (await Promise.all(executablePaths.map(async ([name, executablePath]) => (await sha256File(executablePath)).toLowerCase() === record.executableHashes[name].toLowerCase()))).every(Boolean);
return hashesMatch return hashesMatch
? { state: 'verified', version: record.version, layoutUsable: true, strictValid: true } ? { state: 'verified', version: record.version, layoutUsable: true, strictValid: true }
: { state: 'corrupt', version: record.version, layoutUsable: true, strictValid: false }; : { state: 'corrupt', version: record.version, layoutUsable: true, strictValid: false };
@@ -374,20 +394,20 @@ export class ManagedToolInstaller {
return paths as Array<[string, string]>; return paths as Array<[string, string]>;
} }
private readRecord(directory: string): unknown { private async readRecord(directory: string): Promise<unknown> {
try { try {
return JSON.parse(fs.readFileSync(path.join(directory, RECORD_FILE_NAME), 'utf8')); return JSON.parse(await fs.promises.readFile(path.join(directory, RECORD_FILE_NAME), 'utf8'));
} catch { } catch {
return null; return null;
} }
} }
private readJournal(): unknown | typeof INVALID_JOURNAL | undefined { private async readJournal(): Promise<unknown | typeof INVALID_JOURNAL | undefined> {
try { try {
if (!fs.existsSync(this.journalPath())) { if (!fs.existsSync(this.journalPath())) {
return undefined; return undefined;
} }
return JSON.parse(fs.readFileSync(this.journalPath(), 'utf8')); return JSON.parse(await fs.promises.readFile(this.journalPath(), 'utf8'));
} catch { } catch {
return INVALID_JOURNAL; return INVALID_JOURNAL;
} }
@@ -433,12 +453,12 @@ export class ManagedToolInstaller {
}; };
} }
private failure(manifest: ExternalToolManifest, error: NonNullable<ManagedToolInstallResult['error']>, detail: string | undefined, diagnostics: string[]): ManagedToolInstallResult { private async failure(manifest: ExternalToolManifest, error: NonNullable<ManagedToolInstallResult['error']>, detail: string | undefined, diagnostics: string[]): Promise<ManagedToolInstallResult> {
return { return {
success: false, success: false,
error, error,
detail, detail,
status: this.statusFromInspection(manifest), status: await this.statusFromInspection(manifest),
diagnostics diagnostics
}; };
} }
+12 -10
View File
@@ -409,10 +409,10 @@ export interface ManagedToolStatuses {
ffmpeg: ManagedToolStatus; ffmpeg: ManagedToolStatus;
} }
export function getManagedToolStatuses(): ManagedToolStatuses { export async function getManagedToolStatuses(): Promise<ManagedToolStatuses> {
return { return {
streamlink: getManagedToolInstaller('streamlink').status(APPLICATION_TOOL_MANIFEST.streamlink), streamlink: await getManagedToolInstaller('streamlink').status(APPLICATION_TOOL_MANIFEST.streamlink),
ffmpeg: getManagedToolInstaller('ffmpeg').status(APPLICATION_TOOL_MANIFEST.ffmpeg) ffmpeg: await getManagedToolInstaller('ffmpeg').status(APPLICATION_TOOL_MANIFEST.ffmpeg)
}; };
} }
@@ -424,17 +424,19 @@ export async function repairManagedTools(): Promise<{ success: boolean; statuses
refreshBundledToolPaths(true); refreshBundledToolPaths(true);
return { return {
success: streamlink.success && ffmpeg.success, success: streamlink.success && ffmpeg.success,
statuses: getManagedToolStatuses() statuses: await getManagedToolStatuses()
}; };
} }
export function resetManagedTools(): { success: boolean; statuses: ManagedToolStatuses } { export async function resetManagedTools(): Promise<{ success: boolean; statuses: ManagedToolStatuses }> {
const streamlink = getManagedToolInstaller('streamlink').reset(APPLICATION_TOOL_MANIFEST.streamlink); const [streamlink, ffmpeg] = await Promise.all([
const ffmpeg = getManagedToolInstaller('ffmpeg').reset(APPLICATION_TOOL_MANIFEST.ffmpeg); getManagedToolInstaller('streamlink').reset(APPLICATION_TOOL_MANIFEST.streamlink),
getManagedToolInstaller('ffmpeg').reset(APPLICATION_TOOL_MANIFEST.ffmpeg)
]);
refreshBundledToolPaths(true); refreshBundledToolPaths(true);
return { return {
success: streamlink.state !== 'installing' && ffmpeg.state !== 'installing', success: streamlink.state !== 'installing' && ffmpeg.state !== 'installing',
statuses: getManagedToolStatuses() statuses: await getManagedToolStatuses()
}; };
} }
@@ -445,7 +447,7 @@ export async function ensureStreamlinkInstalled(): Promise<boolean> {
refreshBundledToolPaths(); refreshBundledToolPaths();
const manifest = APPLICATION_TOOL_MANIFEST.streamlink; const manifest = APPLICATION_TOOL_MANIFEST.streamlink;
const managedStatus = getManagedToolInstaller('streamlink').status(manifest); const managedStatus = await getManagedToolInstaller('streamlink').status(manifest);
const requiresManagedRepair = Boolean(bundledStreamlinkPath) && !managedStatus.verified; const requiresManagedRepair = Boolean(bundledStreamlinkPath) && !managedStatus.verified;
const current = getStreamlinkCommand(); const current = getStreamlinkCommand();
const versionArgs = [...current.prefixArgs, '--version']; const versionArgs = [...current.prefixArgs, '--version'];
@@ -491,7 +493,7 @@ export async function ensureFfmpegInstalled(): Promise<boolean> {
refreshBundledToolPaths(); refreshBundledToolPaths();
const manifest = APPLICATION_TOOL_MANIFEST.ffmpeg; const manifest = APPLICATION_TOOL_MANIFEST.ffmpeg;
const managedStatus = getManagedToolInstaller('ffmpeg').status(manifest); const managedStatus = await getManagedToolInstaller('ffmpeg').status(manifest);
const requiresManagedRepair = Boolean(bundledFFmpegPath || bundledFFprobePath) && !managedStatus.verified; const requiresManagedRepair = Boolean(bundledFFmpegPath || bundledFFprobePath) && !managedStatus.verified;
const ffmpegPath = getFFmpegPath(); const ffmpegPath = getFFmpegPath();
const ffprobePath = getFFprobePath(); const ffprobePath = getFFprobePath();