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:
+4
-4
@@ -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) => {
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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,36 +314,48 @@ 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) {
|
||||||
|
let displacedDirectory: string | undefined;
|
||||||
|
try {
|
||||||
|
if (fs.existsSync(this.options.installationDirectory)) {
|
||||||
|
displacedDirectory = `${this.options.installationDirectory}.recovery-${crypto.randomUUID()}`;
|
||||||
|
this.rename(this.options.installationDirectory, displacedDirectory);
|
||||||
|
}
|
||||||
|
this.rename(journal.backupDirectory, this.options.installationDirectory);
|
||||||
|
} catch (error) {
|
||||||
|
return { success: false, error: 'recovery-restore-failed', detail: this.errorText(error) };
|
||||||
|
}
|
||||||
|
|
||||||
|
this.cleanup(journal.stagingDirectory, true, diagnostics);
|
||||||
|
if (displacedDirectory) {
|
||||||
|
this.cleanup(displacedDirectory, true, diagnostics);
|
||||||
|
}
|
||||||
|
this.cleanup(this.journalPath(), false, diagnostics);
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
const staging = await this.inspectInstallation(journal.stagingDirectory, 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.' };
|
return { success: false, error: 'recovery-restore-failed', detail: 'Kein nutzbares Tool-Backup für die Wiederherstellung vorhanden.' };
|
||||||
}
|
}
|
||||||
|
|
||||||
let displacedDirectory: string | undefined;
|
|
||||||
try {
|
try {
|
||||||
if (fs.existsSync(this.options.installationDirectory)) {
|
this.rename(journal.stagingDirectory, this.options.installationDirectory);
|
||||||
displacedDirectory = `${this.options.installationDirectory}.recovery-${crypto.randomUUID()}`;
|
|
||||||
this.rename(this.options.installationDirectory, displacedDirectory);
|
|
||||||
}
|
|
||||||
this.rename(journal.backupDirectory, this.options.installationDirectory);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return { success: false, error: 'recovery-restore-failed', detail: this.errorText(error) };
|
return { success: false, error: 'recovery-restore-failed', detail: this.errorText(error) };
|
||||||
}
|
}
|
||||||
|
|
||||||
this.cleanup(journal.stagingDirectory, true, diagnostics);
|
|
||||||
if (displacedDirectory) {
|
|
||||||
this.cleanup(displacedDirectory, true, diagnostics);
|
|
||||||
}
|
|
||||||
this.cleanup(this.journalPath(), false, diagnostics);
|
this.cleanup(this.journalPath(), false, diagnostics);
|
||||||
return { success: true };
|
return { success: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
private statusFromInspection(manifest: ExternalToolManifest): ManagedToolStatus {
|
private async statusFromInspection(manifest: ExternalToolManifest): Promise<ManagedToolStatus> {
|
||||||
const inspection = this.inspectInstallation(this.options.installationDirectory, manifest);
|
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
@@ -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();
|
||||||
|
|||||||
Reference in New Issue
Block a user