From d32f42746db845c497f6e6ead1d36dac55d906f5 Mon Sep 17 00:00:00 2001
From: Sucukdeluxe <259325684+Sucukdeluxe@users.noreply.github.com>
Date: Wed, 12 Aug 2026 00:59:45 +0200
Subject: [PATCH] fix(tools): verify pinned archives before atomic install
Pin Streamlink and FFmpeg archives to verified manifests, stage replacements before promotion, and expose managed tool status with repair and reset controls. Keep updater checks exclusive until their underlying operation settles and restore default electron-builder certificate environment support.
---
package.json | 1 -
src/index.html | 7 +
src/main.ts | 101 +++----
src/main/domain/managed-tools.test.ts | 211 +++++++++++++++
src/main/domain/managed-tools.ts | 249 ++++++++++++++++++
src/main/domain/tool-manifest.ts | 20 ++
.../domain/update-check-operation.test.ts | 51 ++++
src/main/domain/update-check-operation.ts | 51 ++++
src/preload.ts | 3 +
src/renderer-globals.d.ts | 19 +-
src/renderer-locale-de.ts | 12 +
src/renderer-locale-en.ts | 12 +
src/renderer-settings.ts | 46 ++++
src/renderer-texts.ts | 5 +
src/renderer-updates.ts | 2 +-
src/renderer.ts | 1 +
src/tools.ts | 120 ++++++---
17 files changed, 822 insertions(+), 89 deletions(-)
create mode 100644 src/main/domain/managed-tools.test.ts
create mode 100644 src/main/domain/managed-tools.ts
create mode 100644 src/main/domain/tool-manifest.ts
create mode 100644 src/main/domain/update-check-operation.test.ts
create mode 100644 src/main/domain/update-check-operation.ts
diff --git a/package.json b/package.json
index 6360767..6a01442 100644
--- a/package.json
+++ b/package.json
@@ -72,7 +72,6 @@
"win": {
"target": "nsis",
"icon": "build/icon.ico",
- "signExecutable": false,
"artifactName": "Twitch-VOD-Manager-Setup-${version}.${ext}"
},
"nsis": {
diff --git a/src/index.html b/src/index.html
index 7a6f61f..8864676 100644
--- a/src/index.html
+++ b/src/index.html
@@ -914,6 +914,13 @@
diff --git a/src/main.ts b/src/main.ts
index 285ee00..6349e75 100644
--- a/src/main.ts
+++ b/src/main.ts
@@ -7,6 +7,7 @@ import { pathToFileURL } from 'node:url';
import axios from 'axios';
import { autoUpdater } from 'electron-updater';
import { compareUpdateVersions, isNewerUpdateVersion, normalizeUpdateVersion } from './main/domain/update-version-utils';
+import { createUpdateCheckCoordinator } from './main/domain/update-check-operation';
import { writeFileAtomicSync } from './main/infra/fs-atomic';
import { parseDuration, formatDuration, formatDurationDashed } from './main/infra/duration';
import {
@@ -60,6 +61,7 @@ import {
setDebugLogFn, initToolDirs,
getStreamlinkPath, getStreamlinkCommand, getFFmpegPath, getFFprobePath,
refreshBundledToolPaths, ensureStreamlinkInstalled, ensureFfmpegInstalled,
+ getManagedToolStatuses, repairManagedTools, resetManagedTools,
canExecute, canExecuteCommand,
cacheVerifiedStreamlinkCommand, isVerifiedStreamlinkCommand,
cacheVerifiedFfmpegCommands, isVerifiedFfmpegCommands,
@@ -827,6 +829,7 @@ let autoUpdaterInitialized = false;
let autoUpdateCheckTimer: NodeJS.Timeout | null = null;
let autoUpdateStartupTimer: NodeJS.Timeout | null = null;
let autoUpdateCheckInProgress = false;
+const autoUpdateCheckCoordinator = createUpdateCheckCoordinator();
let autoUpdateReadyToInstall = false;
let autoUpdateDownloadInProgress = false;
let lastAutoUpdateCheckAt = 0;
@@ -6951,7 +6954,7 @@ function buildUpdateInfoPayload(version: string, releaseDate?: string): {
}
async function requestUpdateCheck(source: UpdateCheckSource, force = false): Promise<{ started: boolean; reason?: string }> {
- if (autoUpdateCheckInProgress) {
+ if (autoUpdateCheckCoordinator.inProgress) {
return { started: false, reason: 'in-progress' };
}
@@ -6960,57 +6963,51 @@ async function requestUpdateCheck(source: UpdateCheckSource, force = false): Pro
return { started: false, reason: 'throttled' };
}
- autoUpdateCheckInProgress = true;
- lastAutoUpdateCheckAt = now;
- appendDebugLog('update-check-start', { source });
-
- try {
+ const result = await autoUpdateCheckCoordinator.run(async () => {
+ autoUpdateCheckInProgress = true;
+ lastAutoUpdateCheckAt = now;
+ appendDebugLog('update-check-start', { source });
try {
- const githubReleaseResponse = await axios.get(GITHUB_RELEASES_API_LATEST_URL, {
- timeout: 5000,
- headers: {
- 'Accept': 'application/json',
- 'User-Agent': 'Twitch-VOD-Manager'
- }
- });
- cacheLatestReleaseUpdateInfo(githubReleaseResponse.data);
- const tagName = latestReleaseUpdateInfo?.tagName || githubReleaseResponse.data?.tag_name;
- if (tagName) {
- autoUpdater.setFeedURL({
- provider: 'generic',
- url: `${GITHUB_RELEASES_DOWNLOAD_BASE_URL}/${tagName}`
+ try {
+ const githubReleaseResponse = await axios.get(GITHUB_RELEASES_API_LATEST_URL, {
+ timeout: 5000,
+ headers: {
+ 'Accept': 'application/json',
+ 'User-Agent': 'Twitch-VOD-Manager'
+ }
});
- appendDebugLog('github-feed-url-set', { tagName, owner: GITHUB_REPO_OWNER, repo: GITHUB_REPO_NAME });
+ cacheLatestReleaseUpdateInfo(githubReleaseResponse.data);
+ const tagName = latestReleaseUpdateInfo?.tagName || githubReleaseResponse.data?.tag_name;
+ if (tagName) {
+ autoUpdater.setFeedURL({
+ provider: 'generic',
+ url: `${GITHUB_RELEASES_DOWNLOAD_BASE_URL}/${tagName}`
+ });
+ appendDebugLog('github-feed-url-set', { tagName, owner: GITHUB_REPO_OWNER, repo: GITHUB_REPO_NAME });
+ }
+ } catch (apiErr) {
+ appendDebugLog('github-api-failed', String(apiErr));
}
- } catch (apiErr) {
- appendDebugLog('github-api-failed', String(apiErr));
- }
-
- let timeoutHandle: NodeJS.Timeout | null = null;
- try {
- await Promise.race([
- autoUpdater.checkForUpdates(),
- new Promise
((_, reject) => {
- timeoutHandle = setTimeout(() => {
- reject(new Error(`Update check timed out after ${AUTO_UPDATE_CHECK_TIMEOUT_MS}ms`));
- }, AUTO_UPDATE_CHECK_TIMEOUT_MS);
- })
- ]);
+ await autoUpdater.checkForUpdates();
} finally {
- if (timeoutHandle) {
- clearTimeout(timeoutHandle);
- timeoutHandle = null;
- }
+ autoUpdateCheckInProgress = false;
}
+ }, AUTO_UPDATE_CHECK_TIMEOUT_MS);
+ if (result.state === 'completed') {
return { started: true };
- } catch (err) {
- appendDebugLog('update-check-failed', { source, error: String(err) });
- console.error('Update check failed:', err);
- return { started: false, reason: 'error' };
- } finally {
- autoUpdateCheckInProgress = false;
}
+ if (result.state === 'timed-out') {
+ appendDebugLog('update-check-ui-timeout', { source, timeoutMs: AUTO_UPDATE_CHECK_TIMEOUT_MS });
+ return { started: false, reason: 'timed-out' };
+ }
+ if (result.state === 'in-progress') {
+ return { started: false, reason: 'in-progress' };
+ }
+
+ appendDebugLog('update-check-failed', { source, error: String(result.error) });
+ console.error('Update check failed:', result.error);
+ return { started: false, reason: 'error' };
}
async function requestUpdateDownload(source: UpdateDownloadSource): Promise<{ started: boolean; reason?: string }> {
@@ -7162,7 +7159,6 @@ function setupAutoUpdater() {
});
autoUpdater.on('error', (err) => {
- autoUpdateCheckInProgress = false;
autoUpdateDownloadInProgress = false;
const message = String(err);
appendDebugLog('auto-updater-error', message);
@@ -7981,6 +7977,21 @@ registerTrustedIpcHandler(ipcMain, 'run-preflight', isTrustedRendererEvent, () =
return await runPreflight(autoFix);
});
+ipcMain.handle('get-managed-tool-status', (event) => {
+ if (!isTrustedRendererEvent(event)) return null;
+ return getManagedToolStatuses();
+});
+
+ipcMain.handle('repair-managed-tools', async (event) => {
+ if (!isTrustedRendererEvent(event)) return null;
+ return await repairManagedTools();
+});
+
+ipcMain.handle('reset-managed-tools', (event) => {
+ if (!isTrustedRendererEvent(event)) return null;
+ return resetManagedTools();
+});
+
registerTrustedIpcHandler(ipcMain, 'get-debug-log', isTrustedRendererEvent, () => Promise.resolve(''), async (_, lines: number = 200) => {
// Cap so a misbehaving renderer (or future feature) cannot ask the
// main process to slice millions of lines from a multi-MB log.
diff --git a/src/main/domain/managed-tools.test.ts b/src/main/domain/managed-tools.test.ts
new file mode 100644
index 0000000..313d12c
--- /dev/null
+++ b/src/main/domain/managed-tools.test.ts
@@ -0,0 +1,211 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import * as crypto from 'node:crypto';
+import * as fs from 'node:fs';
+import * as os from 'node:os';
+import * as path from 'node:path';
+import { createManagedToolInstaller, type ExternalToolManifest } from './managed-tools';
+
+let directory: string;
+
+function sha256(value: string): string {
+ return crypto.createHash('sha256').update(value).digest('hex');
+}
+
+function manifest(): ExternalToolManifest {
+ return {
+ id: 'streamlink',
+ version: '8.4.0',
+ sourceUrl: 'https://example.invalid/streamlink-8.4.0.zip',
+ archiveName: 'streamlink-8.4.0.zip',
+ sha256: sha256('verified archive'),
+ executables: ['streamlink.exe']
+ };
+}
+
+function writeExistingInstallation(installPath: string, contents = 'old executable'): void {
+ fs.mkdirSync(path.join(installPath, 'bin'), { recursive: true });
+ fs.writeFileSync(path.join(installPath, 'bin', 'streamlink.exe'), contents);
+}
+
+function createInstaller(options: {
+ archiveContents?: string;
+ extract?: (archivePath: string, destinationPath: string) => Promise;
+ rename?: (sourcePath: string, destinationPath: string) => void;
+} = {}) {
+ const installPath = path.join(directory, 'installed');
+ const tempPath = path.join(directory, 'temporary');
+ const download = vi.fn(async (_sourceUrl: string, archivePath: string) => {
+ fs.mkdirSync(path.dirname(archivePath), { recursive: true });
+ fs.writeFileSync(archivePath, options.archiveContents ?? 'verified archive');
+ });
+ const extract = options.extract ?? (async (_archivePath: string, destinationPath: string) => {
+ fs.mkdirSync(path.join(destinationPath, 'bin'), { recursive: true });
+ fs.writeFileSync(path.join(destinationPath, 'bin', 'streamlink.exe'), 'new executable');
+ });
+
+ return {
+ installPath,
+ installer: createManagedToolInstaller({
+ installationDirectory: installPath,
+ temporaryDirectory: tempPath,
+ download,
+ extract,
+ rename: options.rename
+ }),
+ download
+ };
+}
+
+beforeEach(() => {
+ directory = fs.mkdtempSync(path.join(os.tmpdir(), 'twitch-vod-manager-managed-tools-'));
+});
+
+afterEach(() => {
+ fs.rmSync(directory, { recursive: true, force: true });
+});
+
+describe('managed tool installer', () => {
+ it('retains the working installation when the downloaded archive hash is corrupted', async () => {
+ const { installer, installPath, download } = createInstaller({ archiveContents: 'corrupted archive' });
+ writeExistingInstallation(installPath);
+
+ const result = await installer.repair(manifest());
+
+ expect(result.success).toBe(false);
+ expect(result.error).toBe('archive-hash-mismatch');
+ expect(download).toHaveBeenCalledTimes(1);
+ expect(fs.readFileSync(path.join(installPath, 'bin', 'streamlink.exe'), 'utf8')).toBe('old executable');
+ });
+
+ it('retains the working installation when extraction is interrupted', async () => {
+ const { installer, installPath } = createInstaller({
+ extract: async () => { throw new Error('interrupted extraction'); }
+ });
+ writeExistingInstallation(installPath);
+
+ const result = await installer.repair(manifest());
+
+ expect(result.success).toBe(false);
+ expect(result.error).toBe('extract-failed');
+ expect(fs.readFileSync(path.join(installPath, 'bin', 'streamlink.exe'), 'utf8')).toBe('old executable');
+ });
+
+ it('retains the working installation when a staged archive lacks the required executable', async () => {
+ const { installer, installPath } = createInstaller({
+ extract: async (_archivePath, destinationPath) => {
+ fs.mkdirSync(destinationPath, { recursive: true });
+ fs.writeFileSync(path.join(destinationPath, 'readme.txt'), 'missing executable');
+ }
+ });
+ writeExistingInstallation(installPath);
+
+ const result = await installer.repair(manifest());
+
+ expect(result.success).toBe(false);
+ expect(result.error).toBe('required-executable-missing');
+ expect(fs.readFileSync(path.join(installPath, 'bin', 'streamlink.exe'), 'utf8')).toBe('old executable');
+ });
+
+ it('restores the previous tool when promotion of the staged installation fails', async () => {
+ const installPath = path.join(directory, 'installed');
+ let failedPromotion = false;
+ const { installer } = createInstaller({
+ rename: (sourcePath, destinationPath) => {
+ if (!failedPromotion && sourcePath.includes('.stage-') && destinationPath === installPath) {
+ failedPromotion = true;
+ throw new Error('promotion interrupted');
+ }
+ fs.renameSync(sourcePath, destinationPath);
+ }
+ });
+ writeExistingInstallation(installPath);
+
+ const result = await installer.repair(manifest());
+
+ expect(result.success).toBe(false);
+ expect(result.error).toBe('promotion-failed');
+ expect(fs.readFileSync(path.join(installPath, 'bin', 'streamlink.exe'), 'utf8')).toBe('old executable');
+ });
+
+ it('reports promotion failure when the previous installation cannot be restored', async () => {
+ const installPath = path.join(directory, 'installed');
+ const { installer } = createInstaller({
+ rename: (sourcePath, destinationPath) => {
+ if (sourcePath.includes('.stage-') && destinationPath === installPath) {
+ throw new Error('promotion interrupted');
+ }
+ if (sourcePath.includes('.backup-') && destinationPath === installPath) {
+ throw new Error('rollback interrupted');
+ }
+ fs.renameSync(sourcePath, destinationPath);
+ }
+ });
+ writeExistingInstallation(installPath);
+
+ const result = await installer.repair(manifest());
+
+ expect(result.success).toBe(false);
+ expect(result.error).toBe('promotion-failed');
+ });
+
+ it('reports a verified manifest version only after a staged install is promoted', async () => {
+ const { installer, installPath } = createInstaller();
+
+ const result = await installer.repair(manifest());
+
+ expect(result.success).toBe(true);
+ expect(fs.readFileSync(path.join(installPath, 'bin', 'streamlink.exe'), 'utf8')).toBe('new executable');
+ expect(result.status).toMatchObject({
+ id: 'streamlink',
+ version: '8.4.0',
+ state: 'verified',
+ verified: true
+ });
+ });
+
+ it('marks a mismatched installed version as unverified', () => {
+ const { installer, installPath } = createInstaller();
+ writeExistingInstallation(installPath);
+ fs.writeFileSync(path.join(installPath, '.tool-manifest.json'), JSON.stringify({
+ id: 'streamlink',
+ version: '8.3.0',
+ sourceUrl: 'https://example.invalid/streamlink-8.3.0.zip',
+ archiveName: 'streamlink-8.3.0.zip',
+ sha256: sha256('old archive')
+ }));
+
+ expect(installer.status(manifest())).toMatchObject({
+ version: '8.3.0',
+ state: 'unverified',
+ verified: false
+ });
+ });
+
+ it('shares one repair operation for concurrent requests', async () => {
+ let releaseDownload: (() => void) | undefined;
+ const downloadGate = new Promise((resolve) => { releaseDownload = resolve; });
+ const { installer, download } = createInstaller({
+ extract: async (_archivePath, destinationPath) => {
+ fs.mkdirSync(path.join(destinationPath, 'bin'), { recursive: true });
+ fs.writeFileSync(path.join(destinationPath, 'bin', 'streamlink.exe'), 'new executable');
+ }
+ });
+ download.mockImplementationOnce(async (_sourceUrl: string, archivePath: string) => {
+ await downloadGate;
+ fs.mkdirSync(path.dirname(archivePath), { recursive: true });
+ fs.writeFileSync(archivePath, 'verified archive');
+ });
+
+ const first = installer.repair(manifest());
+ const second = installer.repair(manifest());
+ await Promise.resolve();
+ expect(download).toHaveBeenCalledTimes(1);
+
+ releaseDownload?.();
+ const [firstResult, secondResult] = await Promise.all([first, second]);
+
+ expect(firstResult.success).toBe(true);
+ expect(secondResult.success).toBe(true);
+ expect(download).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/src/main/domain/managed-tools.ts b/src/main/domain/managed-tools.ts
new file mode 100644
index 0000000..6e4b348
--- /dev/null
+++ b/src/main/domain/managed-tools.ts
@@ -0,0 +1,249 @@
+import * as crypto from 'node:crypto';
+import * as fs from 'node:fs';
+import * as path from 'node:path';
+
+export type ManagedToolId = 'streamlink' | 'ffmpeg';
+
+export interface ExternalToolManifest {
+ id: ManagedToolId;
+ version: string;
+ sourceUrl: string;
+ archiveName: string;
+ sha256: string;
+ executables: string[];
+}
+
+export interface ManagedToolStatus {
+ id: ManagedToolId;
+ version: string;
+ sourceUrl: string;
+ archiveName: string;
+ state: 'missing' | 'installing' | 'verified' | 'unverified' | 'corrupt';
+ verified: boolean;
+}
+
+export interface ManagedToolInstallResult {
+ success: boolean;
+ status: ManagedToolStatus;
+ error?: 'download-failed' | 'archive-hash-mismatch' | 'extract-failed' | 'required-executable-missing' | 'promotion-failed';
+}
+
+interface ManagedToolRecord {
+ id: ManagedToolId;
+ version: string;
+ sourceUrl: string;
+ archiveName: string;
+ sha256: string;
+}
+
+export interface ManagedToolInstallerOptions {
+ installationDirectory: string;
+ temporaryDirectory: string;
+ download(sourceUrl: string, archivePath: string): Promise;
+ extract(archivePath: string, destinationPath: string): Promise;
+ rename?(sourcePath: string, destinationPath: string): void;
+}
+
+const RECORD_FILE_NAME = '.tool-manifest.json';
+
+function findFileRecursive(rootDir: string, fileName: string): string | null {
+ try {
+ const entries = fs.readdirSync(rootDir, { withFileTypes: true });
+ for (const entry of entries) {
+ const entryPath = path.join(rootDir, entry.name);
+ if (entry.isFile() && entry.name.toLowerCase() === fileName.toLowerCase()) {
+ return entryPath;
+ }
+ if (entry.isDirectory()) {
+ const nested = findFileRecursive(entryPath, fileName);
+ if (nested) return nested;
+ }
+ }
+ } catch {
+ return null;
+ }
+ return null;
+}
+
+function sha256File(filePath: string): string {
+ return crypto.createHash('sha256').update(fs.readFileSync(filePath)).digest('hex');
+}
+
+function isRecord(value: unknown): value is ManagedToolRecord {
+ if (!value || typeof value !== 'object') return false;
+ const record = value as Record;
+ return typeof record.id === 'string'
+ && typeof record.version === 'string'
+ && typeof record.sourceUrl === 'string'
+ && typeof record.archiveName === 'string'
+ && typeof record.sha256 === 'string';
+}
+
+function matchesManifest(record: ManagedToolRecord, manifest: ExternalToolManifest): boolean {
+ return record.id === manifest.id
+ && record.version === manifest.version
+ && record.sourceUrl === manifest.sourceUrl
+ && record.archiveName === manifest.archiveName
+ && record.sha256 === manifest.sha256;
+}
+
+export class ManagedToolInstaller {
+ private readonly inFlight = new Map>();
+
+ constructor(private readonly options: ManagedToolInstallerOptions) {}
+
+ status(manifest: ExternalToolManifest, includeInstallState = true): ManagedToolStatus {
+ const base: Omit = {
+ id: manifest.id,
+ version: manifest.version,
+ sourceUrl: manifest.sourceUrl,
+ archiveName: manifest.archiveName
+ };
+
+ if (includeInstallState && this.inFlight.has(manifest.id)) {
+ return { ...base, state: 'installing', verified: false };
+ }
+
+ if (!fs.existsSync(this.options.installationDirectory)) {
+ return { ...base, state: 'missing', verified: false };
+ }
+
+ let record: ManagedToolRecord | null = null;
+ try {
+ const parsed = JSON.parse(fs.readFileSync(path.join(this.options.installationDirectory, RECORD_FILE_NAME), 'utf8'));
+ if (!isRecord(parsed)) {
+ return { ...base, state: 'corrupt', verified: false };
+ }
+ record = parsed;
+ } catch {
+ const hasAnyExecutable = manifest.executables.some((name) => findFileRecursive(this.options.installationDirectory, name));
+ return { ...base, state: hasAnyExecutable ? 'unverified' : 'corrupt', verified: false };
+ }
+
+ const recordBase = { ...base, version: record.version };
+ if (!matchesManifest(record, manifest)) {
+ return { ...recordBase, state: 'unverified', verified: false };
+ }
+
+ const allExecutablesPresent = manifest.executables.every((name) => findFileRecursive(this.options.installationDirectory, name));
+ return allExecutablesPresent
+ ? { ...recordBase, state: 'verified', verified: true }
+ : { ...recordBase, state: 'corrupt', verified: false };
+ }
+
+ install(manifest: ExternalToolManifest): Promise {
+ const currentStatus = this.status(manifest);
+ if (currentStatus.verified) {
+ return Promise.resolve({ success: true, status: currentStatus });
+ }
+ return this.start(manifest);
+ }
+
+ repair(manifest: ExternalToolManifest): Promise {
+ return this.start(manifest);
+ }
+
+ reset(manifest: ExternalToolManifest): ManagedToolStatus {
+ if (this.inFlight.has(manifest.id)) {
+ return this.status(manifest);
+ }
+ fs.rmSync(this.options.installationDirectory, { recursive: true, force: true });
+ return this.status(manifest);
+ }
+
+ private start(manifest: ExternalToolManifest): Promise {
+ const existing = this.inFlight.get(manifest.id);
+ if (existing) return existing;
+
+ const operation = this.installOnce(manifest);
+ this.inFlight.set(manifest.id, operation);
+ void operation.then(() => {
+ if (this.inFlight.get(manifest.id) === operation) {
+ this.inFlight.delete(manifest.id);
+ }
+ });
+ return operation;
+ }
+
+ private async installOnce(manifest: ExternalToolManifest): Promise {
+ const uniqueSuffix = crypto.randomUUID();
+ const archivePath = path.join(this.options.temporaryDirectory, `${manifest.archiveName}.${uniqueSuffix}`);
+ const stagingDirectory = `${this.options.installationDirectory}.stage-${uniqueSuffix}`;
+ let error: ManagedToolInstallResult['error'] | undefined;
+
+ try {
+ fs.mkdirSync(this.options.temporaryDirectory, { recursive: true });
+ await this.options.download(manifest.sourceUrl, archivePath);
+ if (sha256File(archivePath).toLowerCase() !== manifest.sha256.toLowerCase()) {
+ error = 'archive-hash-mismatch';
+ return { success: false, error, status: this.status(manifest, false) };
+ }
+
+ try {
+ await this.options.extract(archivePath, stagingDirectory);
+ } catch {
+ error = 'extract-failed';
+ return { success: false, error, status: this.status(manifest, false) };
+ }
+
+ if (!manifest.executables.every((name) => findFileRecursive(stagingDirectory, name))) {
+ error = 'required-executable-missing';
+ return { success: false, error, status: this.status(manifest, false) };
+ }
+
+ const record: ManagedToolRecord = {
+ id: manifest.id,
+ version: manifest.version,
+ sourceUrl: manifest.sourceUrl,
+ archiveName: manifest.archiveName,
+ sha256: manifest.sha256
+ };
+ fs.writeFileSync(path.join(stagingDirectory, RECORD_FILE_NAME), JSON.stringify(record));
+
+ try {
+ this.promote(stagingDirectory);
+ } catch {
+ error = 'promotion-failed';
+ return { success: false, error, status: this.status(manifest, false) };
+ }
+
+ return { success: true, status: this.status(manifest, false) };
+ } catch {
+ error = 'download-failed';
+ return { success: false, error, status: this.status(manifest, false) };
+ } finally {
+ fs.rmSync(archivePath, { force: true });
+ fs.rmSync(stagingDirectory, { recursive: true, force: true });
+ }
+ }
+
+ private promote(stagingDirectory: string): void {
+ const backupDirectory = `${this.options.installationDirectory}.backup-${crypto.randomUUID()}`;
+ const rename = this.options.rename ?? fs.renameSync;
+ let previousMoved = false;
+
+ try {
+ if (fs.existsSync(this.options.installationDirectory)) {
+ rename(this.options.installationDirectory, backupDirectory);
+ previousMoved = true;
+ }
+ rename(stagingDirectory, this.options.installationDirectory);
+ if (previousMoved) {
+ try {
+ fs.rmSync(backupDirectory, { recursive: true, force: true });
+ } catch {}
+ }
+ } catch (error) {
+ if (previousMoved && fs.existsSync(backupDirectory) && !fs.existsSync(this.options.installationDirectory)) {
+ try {
+ rename(backupDirectory, this.options.installationDirectory);
+ } catch {}
+ }
+ throw error;
+ }
+ }
+}
+
+export function createManagedToolInstaller(options: ManagedToolInstallerOptions): ManagedToolInstaller {
+ return new ManagedToolInstaller(options);
+}
diff --git a/src/main/domain/tool-manifest.ts b/src/main/domain/tool-manifest.ts
new file mode 100644
index 0000000..536fe1a
--- /dev/null
+++ b/src/main/domain/tool-manifest.ts
@@ -0,0 +1,20 @@
+import type { ExternalToolManifest } from './managed-tools';
+
+export const APPLICATION_TOOL_MANIFEST: Record<'streamlink' | 'ffmpeg', ExternalToolManifest> = {
+ streamlink: {
+ id: 'streamlink',
+ version: '8.4.0',
+ sourceUrl: 'https://github.com/streamlink/windows-builds/releases/download/8.4.0-1/streamlink-8.4.0-1-py314-x86_64.zip',
+ archiveName: 'streamlink-8.4.0-1-py314-x86_64.zip',
+ sha256: 'a8d3bd2b409e6d1b1f7a0e2a5c0cbfba619775e475da3f31285af08d680fb71c',
+ executables: ['streamlink.exe']
+ },
+ ffmpeg: {
+ id: 'ffmpeg',
+ version: '8.1.2',
+ sourceUrl: 'https://www.gyan.dev/ffmpeg/builds/packages/ffmpeg-8.1.2-essentials_build.zip',
+ archiveName: 'ffmpeg-8.1.2-essentials_build.zip',
+ sha256: 'db580001caa24ac104c8cb856cd113a87b0a443f7bdf47d8c12b1d740584a2ec',
+ executables: ['ffmpeg.exe', 'ffprobe.exe']
+ }
+};
diff --git a/src/main/domain/update-check-operation.test.ts b/src/main/domain/update-check-operation.test.ts
new file mode 100644
index 0000000..653993f
--- /dev/null
+++ b/src/main/domain/update-check-operation.test.ts
@@ -0,0 +1,51 @@
+import { describe, expect, it, vi } from 'vitest';
+import { createUpdateCheckCoordinator } from './update-check-operation';
+
+function deferred() {
+ let resolve: (value: T) => void = () => {};
+ let reject: (reason?: unknown) => void = () => {};
+ const promise = new Promise((resolvePromise, rejectPromise) => {
+ resolve = resolvePromise;
+ reject = rejectPromise;
+ });
+ return { promise, resolve, reject };
+}
+
+describe('update check coordinator', () => {
+ it('keeps a timed-out check exclusive until the underlying operation settles', async () => {
+ vi.useFakeTimers();
+ const coordinator = createUpdateCheckCoordinator();
+ const firstCheck = deferred();
+ const firstFactory = vi.fn(() => firstCheck.promise);
+ const secondFactory = vi.fn(() => Promise.resolve());
+
+ const first = coordinator.run(firstFactory, 100);
+ await vi.advanceTimersByTimeAsync(100);
+
+ expect(await first).toEqual({ state: 'timed-out' });
+ expect(coordinator.inProgress).toBe(true);
+ expect(await coordinator.run(secondFactory, 100)).toEqual({ state: 'in-progress' });
+ expect(secondFactory).not.toHaveBeenCalled();
+
+ firstCheck.resolve();
+ await firstCheck.promise;
+ await Promise.resolve();
+ await Promise.resolve();
+ expect(coordinator.inProgress).toBe(false);
+
+ expect(await coordinator.run(secondFactory, 100)).toEqual({ state: 'completed' });
+ expect(secondFactory).toHaveBeenCalledTimes(1);
+ vi.useRealTimers();
+ });
+
+ it('releases the operation only after its rejection settles', async () => {
+ const coordinator = createUpdateCheckCoordinator();
+ const failedCheck = deferred();
+
+ const attempt = coordinator.run(() => failedCheck.promise, 1000);
+ failedCheck.reject(new Error('network failed'));
+
+ await expect(attempt).resolves.toMatchObject({ state: 'failed' });
+ expect(coordinator.inProgress).toBe(false);
+ });
+});
diff --git a/src/main/domain/update-check-operation.ts b/src/main/domain/update-check-operation.ts
new file mode 100644
index 0000000..848d2e2
--- /dev/null
+++ b/src/main/domain/update-check-operation.ts
@@ -0,0 +1,51 @@
+export type UpdateCheckOperationResult =
+ | { state: 'completed' }
+ | { state: 'failed'; error: unknown }
+ | { state: 'timed-out' }
+ | { state: 'in-progress' };
+
+export class UpdateCheckCoordinator {
+ private activeOperation: Promise<{ state: 'completed' } | { state: 'failed'; error: unknown }> | null = null;
+
+ get inProgress(): boolean {
+ return this.activeOperation !== null;
+ }
+
+ run(operation: () => Promise, timeoutMs: number): Promise {
+ if (this.activeOperation) {
+ return Promise.resolve({ state: 'in-progress' });
+ }
+
+ const tracked = Promise.resolve()
+ .then(operation)
+ .then(
+ () => ({ state: 'completed' as const }),
+ (error) => ({ state: 'failed' as const, error })
+ );
+ this.activeOperation = tracked;
+ void tracked.then(() => {
+ if (this.activeOperation === tracked) {
+ this.activeOperation = null;
+ }
+ });
+
+ return new Promise((resolve) => {
+ let pending = true;
+ const timeout = setTimeout(() => {
+ if (!pending) return;
+ pending = false;
+ resolve({ state: 'timed-out' });
+ }, timeoutMs);
+ void tracked.then((result) => {
+ if (!pending) return;
+ pending = false;
+ clearTimeout(timeout);
+ resolve(result);
+ });
+ });
+ }
+}
+
+export function createUpdateCheckCoordinator(): UpdateCheckCoordinator {
+ return new UpdateCheckCoordinator();
+}
diff --git a/src/preload.ts b/src/preload.ts
index c28ae41..a019eae 100644
--- a/src/preload.ts
+++ b/src/preload.ts
@@ -200,6 +200,9 @@ contextBridge.exposeInMainWorld('api', {
installUpdate: () => ipcRenderer.invoke('install-update'),
openExternal: (url: string) => ipcRenderer.invoke('open-external', url),
runPreflight: (autoFix: boolean) => ipcRenderer.invoke('run-preflight', autoFix),
+ getManagedToolStatus: () => ipcRenderer.invoke('get-managed-tool-status'),
+ repairManagedTools: () => ipcRenderer.invoke('repair-managed-tools'),
+ resetManagedTools: () => ipcRenderer.invoke('reset-managed-tools'),
getDebugLog: (lines: number) => ipcRenderer.invoke('get-debug-log', lines),
getRuntimeMetrics: (): Promise => ipcRenderer.invoke('get-runtime-metrics'),
exportRuntimeMetrics: (): Promise<{ success: boolean; cancelled?: boolean; error?: string; filePath?: string }> =>
diff --git a/src/renderer-globals.d.ts b/src/renderer-globals.d.ts
index d1f364f..887cce0 100644
--- a/src/renderer-globals.d.ts
+++ b/src/renderer-globals.d.ts
@@ -258,6 +258,20 @@ interface PreflightResult {
timestamp: string;
}
+interface ManagedToolStatus {
+ id: 'streamlink' | 'ffmpeg';
+ version: string;
+ sourceUrl: string;
+ archiveName: string;
+ state: 'missing' | 'installing' | 'verified' | 'unverified' | 'corrupt';
+ verified: boolean;
+}
+
+interface ManagedToolStatuses {
+ streamlink: ManagedToolStatus;
+ ffmpeg: ManagedToolStatus;
+}
+
interface StreamerStorageEntry {
name: string;
fileCount: number;
@@ -442,11 +456,14 @@ interface ApiBridge {
cutVideo(inputCapability: string, startTime: number, endTime: number): Promise<{ success: boolean; outputName: string | null }>;
mergeVideos(inputCapabilities: string[], outputCapability: string): Promise<{ success: boolean; outputName: string | null }>;
getVersion(): Promise;
- checkUpdate(): Promise<{ checking?: boolean; error?: boolean; skipped?: 'ready-to-install' | 'in-progress' | 'throttled' | 'error' | string }>;
+ checkUpdate(): Promise<{ checking?: boolean; error?: boolean; skipped?: 'ready-to-install' | 'in-progress' | 'throttled' | 'timed-out' | 'error' | string }>;
downloadUpdate(): Promise<{ downloading?: boolean; error?: boolean; skipped?: 'ready-to-install' | 'in-progress' | 'error' | string }>;
installUpdate(): Promise;
openExternal(url: string): Promise;
runPreflight(autoFix: boolean): Promise;
+ getManagedToolStatus(): Promise;
+ repairManagedTools(): Promise<{ success: boolean; statuses: ManagedToolStatuses } | null>;
+ resetManagedTools(): Promise<{ success: boolean; statuses: ManagedToolStatuses } | null>;
getDebugLog(lines: number): Promise;
getRuntimeMetrics(): Promise;
exportRuntimeMetrics(): Promise<{ success: boolean; cancelled?: boolean; error?: string; filePath?: string }>;
diff --git a/src/renderer-locale-de.ts b/src/renderer-locale-de.ts
index e613036..e770f12 100644
--- a/src/renderer-locale-de.ts
+++ b/src/renderer-locale-de.ts
@@ -287,6 +287,18 @@ const UI_TEXT_DE = {
preflightFfmpeg: 'FFmpeg',
preflightFfprobe: 'FFprobe',
preflightPath: 'Download-Pfad',
+ managedToolsTitle: 'Verwaltete Tools',
+ managedToolsRefresh: 'Tool-Status aktualisieren',
+ managedToolsRepair: 'Tools reparieren',
+ managedToolsReset: 'Tools zurücksetzen',
+ managedToolsEmpty: 'Noch kein Tool-Status geladen.',
+ managedToolsRepairing: 'Repariere...',
+ managedToolsResetConfirm: 'Verwaltete Tool-Installationen zurücksetzen?',
+ managedToolsMissing: 'Fehlt',
+ managedToolsInstalling: 'Wird installiert',
+ managedToolsVerified: 'Verifiziert',
+ managedToolsUnverified: 'Nicht verifiziert',
+ managedToolsCorrupt: 'Beschädigt',
debugLogTitle: 'Live Debug-Log',
refreshLog: 'Aktualisieren',
autoRefresh: 'Auto-Refresh',
diff --git a/src/renderer-locale-en.ts b/src/renderer-locale-en.ts
index ef746a2..6f78cc3 100644
--- a/src/renderer-locale-en.ts
+++ b/src/renderer-locale-en.ts
@@ -287,6 +287,18 @@ const UI_TEXT_EN = {
preflightFfmpeg: 'FFmpeg',
preflightFfprobe: 'FFprobe',
preflightPath: 'Download path',
+ managedToolsTitle: 'Managed tools',
+ managedToolsRefresh: 'Refresh tool status',
+ managedToolsRepair: 'Repair tools',
+ managedToolsReset: 'Reset tools',
+ managedToolsEmpty: 'No tool status loaded yet.',
+ managedToolsRepairing: 'Repairing...',
+ managedToolsResetConfirm: 'Reset managed tool installations?',
+ managedToolsMissing: 'Missing',
+ managedToolsInstalling: 'Installing',
+ managedToolsVerified: 'Verified',
+ managedToolsUnverified: 'Unverified',
+ managedToolsCorrupt: 'Corrupt',
debugLogTitle: 'Live Debug Log',
refreshLog: 'Refresh',
autoRefresh: 'Auto refresh',
diff --git a/src/renderer-settings.ts b/src/renderer-settings.ts
index 788cc9e..e866419 100644
--- a/src/renderer-settings.ts
+++ b/src/renderer-settings.ts
@@ -344,6 +344,52 @@ async function runPreflight(autoFix = false): Promise {
}
}
+function getManagedToolStateLabel(state: ManagedToolStatus['state']): string {
+ const labels: Record = {
+ missing: UI_TEXT.static.managedToolsMissing,
+ installing: UI_TEXT.static.managedToolsInstalling,
+ verified: UI_TEXT.static.managedToolsVerified,
+ unverified: UI_TEXT.static.managedToolsUnverified,
+ corrupt: UI_TEXT.static.managedToolsCorrupt
+ };
+ return labels[state];
+}
+
+function renderManagedToolStatus(statuses: ManagedToolStatuses): void {
+ const lines = [statuses.streamlink, statuses.ffmpeg].map((status) => {
+ const verification = status.verified ? UI_TEXT.static.managedToolsVerified : UI_TEXT.static.managedToolsUnverified;
+ return `${status.id} ${status.version}: ${getManagedToolStateLabel(status.state)} · ${verification}`;
+ });
+ byId('managedToolStatus').textContent = lines.join('\n');
+}
+
+async function refreshManagedToolStatus(): Promise {
+ const statuses = await window.api.getManagedToolStatus();
+ if (statuses) renderManagedToolStatus(statuses);
+}
+
+async function repairManagedTools(): Promise {
+ const buttons = [
+ byId('btnRefreshManagedTools'),
+ byId('btnRepairManagedTools'),
+ byId('btnResetManagedTools')
+ ];
+ for (const button of buttons) button.disabled = true;
+ byId('managedToolStatus').textContent = UI_TEXT.static.managedToolsRepairing;
+ try {
+ const result = await window.api.repairManagedTools();
+ if (result) renderManagedToolStatus(result.statuses);
+ } finally {
+ for (const button of buttons) button.disabled = false;
+ }
+}
+
+async function resetManagedTools(): Promise {
+ if (!confirm(UI_TEXT.static.managedToolsResetConfirm)) return;
+ const result = await window.api.resetManagedTools();
+ if (result) renderManagedToolStatus(result.statuses);
+}
+
async function runCleanupDryRun(): Promise {
await runCleanupOnce(true);
}
diff --git a/src/renderer-texts.ts b/src/renderer-texts.ts
index de01ee7..d2aa50d 100644
--- a/src/renderer-texts.ts
+++ b/src/renderer-texts.ts
@@ -318,6 +318,11 @@ function applyLanguageToStaticUI(): void {
setText('btnPreflightRun', UI_TEXT.static.preflightRun);
setText('btnPreflightFix', UI_TEXT.static.preflightFix);
setText('preflightResult', UI_TEXT.static.preflightEmpty);
+ setText('managedToolsTitle', UI_TEXT.static.managedToolsTitle);
+ setText('btnRefreshManagedTools', UI_TEXT.static.managedToolsRefresh);
+ setText('btnRepairManagedTools', UI_TEXT.static.managedToolsRepair);
+ setText('btnResetManagedTools', UI_TEXT.static.managedToolsReset);
+ setText('managedToolStatus', UI_TEXT.static.managedToolsEmpty);
setText('debugLogTitle', UI_TEXT.static.debugLogTitle);
setText('btnRefreshLog', UI_TEXT.static.refreshLog);
setText('btnOpenDebugLogFile', UI_TEXT.static.openDebugLogFile);
diff --git a/src/renderer-updates.ts b/src/renderer-updates.ts
index aeb207d..5b80c4a 100644
--- a/src/renderer-updates.ts
+++ b/src/renderer-updates.ts
@@ -548,7 +548,7 @@ async function checkUpdate(): Promise {
return;
}
- if (skippedReason === 'in-progress' || skippedReason === 'throttled') {
+ if (skippedReason === 'in-progress' || skippedReason === 'throttled' || skippedReason === 'timed-out') {
shouldOpenUpdateModalOnAvailable = false;
manualUpdateOutcomeHandled = true;
manualUpdateCheckPending = false;
diff --git a/src/renderer.ts b/src/renderer.ts
index be38b55..9a85677 100644
--- a/src/renderer.ts
+++ b/src/renderer.ts
@@ -204,6 +204,7 @@ async function init(): Promise {
}, 3000);
void runPreflight(false);
+ void refreshManagedToolStatus();
void refreshDebugLog();
validateFilenameTemplates();
void refreshRuntimeMetrics();
diff --git a/src/tools.ts b/src/tools.ts
index 7012162..9753891 100644
--- a/src/tools.ts
+++ b/src/tools.ts
@@ -2,6 +2,8 @@ import * as path from 'path';
import * as fs from 'fs';
import { spawn, execSync, spawnSync } from 'child_process';
import axios from 'axios';
+import { createManagedToolInstaller, type ManagedToolInstaller, type ManagedToolStatus } from './main/domain/managed-tools';
+import { APPLICATION_TOOL_MANIFEST } from './main/domain/tool-manifest';
// ==========================================
// CONSTANTS
@@ -28,6 +30,7 @@ export function initToolDirs(streamlinkDir: string, ffmpegDir: string, getTempPa
TOOLS_STREAMLINK_DIR = streamlinkDir;
TOOLS_FFMPEG_DIR = ffmpegDir;
_getTempPath = getTempPath;
+ managedToolInstallers.clear();
}
// ==========================================
@@ -44,6 +47,7 @@ let verifiedStreamlinkCommandKey: string | null = null;
let verifiedFfmpegCommandKey: string | null = null;
let bundledToolPathSignature = '';
let bundledToolPathRefreshedAt = 0;
+const managedToolInstallers = new Map<'streamlink' | 'ffmpeg', ManagedToolInstaller>();
// ==========================================
// INTERNAL HELPERS
@@ -376,19 +380,79 @@ async function extractZip(zipPath: string, destinationDir: string): Promise {
+ if (!await downloadFile(sourceUrl, archivePath)) {
+ throw new Error('tool archive download failed');
+ }
+ },
+ extract: async (archivePath, destinationPath) => {
+ if (!await extractZip(archivePath, destinationPath)) {
+ throw new Error('tool archive extraction failed');
+ }
+ }
+ });
+ managedToolInstallers.set(toolId, installer);
+ return installer;
+}
+
+export interface ManagedToolStatuses {
+ streamlink: ManagedToolStatus;
+ ffmpeg: ManagedToolStatus;
+}
+
+export function getManagedToolStatuses(): ManagedToolStatuses {
+ return {
+ streamlink: getManagedToolInstaller('streamlink').status(APPLICATION_TOOL_MANIFEST.streamlink),
+ ffmpeg: getManagedToolInstaller('ffmpeg').status(APPLICATION_TOOL_MANIFEST.ffmpeg)
+ };
+}
+
+export async function repairManagedTools(): Promise<{ success: boolean; statuses: ManagedToolStatuses }> {
+ const [streamlink, ffmpeg] = await Promise.all([
+ getManagedToolInstaller('streamlink').repair(APPLICATION_TOOL_MANIFEST.streamlink),
+ getManagedToolInstaller('ffmpeg').repair(APPLICATION_TOOL_MANIFEST.ffmpeg)
+ ]);
+ refreshBundledToolPaths(true);
+ return {
+ success: streamlink.success && ffmpeg.success,
+ statuses: getManagedToolStatuses()
+ };
+}
+
+export function resetManagedTools(): { success: boolean; statuses: ManagedToolStatuses } {
+ const streamlink = getManagedToolInstaller('streamlink').reset(APPLICATION_TOOL_MANIFEST.streamlink);
+ const ffmpeg = getManagedToolInstaller('ffmpeg').reset(APPLICATION_TOOL_MANIFEST.ffmpeg);
+ refreshBundledToolPaths(true);
+ return {
+ success: streamlink.state !== 'installing' && ffmpeg.state !== 'installing',
+ statuses: getManagedToolStatuses()
+ };
+}
+
// ==========================================
// AUTO-INSTALL TOOLS
// ==========================================
export async function ensureStreamlinkInstalled(): Promise {
refreshBundledToolPaths();
+ const manifest = APPLICATION_TOOL_MANIFEST.streamlink;
+ const managedStatus = getManagedToolInstaller('streamlink').status(manifest);
+ const requiresManagedRepair = Boolean(bundledStreamlinkPath) && !managedStatus.verified;
const current = getStreamlinkCommand();
const versionArgs = [...current.prefixArgs, '--version'];
- if (isVerifiedStreamlinkCommand(current.command, versionArgs)) {
+ if (!requiresManagedRepair && isVerifiedStreamlinkCommand(current.command, versionArgs)) {
return true;
}
- if (canExecuteCommand(current.command, versionArgs)) {
+ if (!requiresManagedRepair && canExecuteCommand(current.command, versionArgs)) {
cacheVerifiedStreamlinkCommand(current.command, versionArgs);
return true;
}
@@ -399,34 +463,12 @@ export async function ensureStreamlinkInstalled(): Promise {
_appendDebugLog('streamlink-install-start');
try {
- fs.mkdirSync(TOOLS_STREAMLINK_DIR, { recursive: true });
-
- const release = await axios.get('https://api.github.com/repos/streamlink/windows-builds/releases/latest', {
- timeout: 120000,
- headers: {
- 'Accept': 'application/vnd.github+json',
- 'User-Agent': 'Twitch-VOD-Manager'
- }
- });
-
- const assets = release.data?.assets || [];
- const zipAsset = assets.find((a: any) => typeof a?.name === 'string' && /x86_64\.zip$/i.test(a.name));
- if (!zipAsset?.browser_download_url) {
- _appendDebugLog('streamlink-install-no-asset-found');
+ const result = await getManagedToolInstaller('streamlink').install(manifest);
+ if (!result.success) {
+ _appendDebugLog('streamlink-install-failed', { error: result.error, status: result.status });
return false;
}
- const zipPath = path.join(_getTempPath(), `streamlink_portable_${Date.now()}.zip`);
- const downloadOk = await downloadFile(zipAsset.browser_download_url, zipPath);
- if (!downloadOk) return false;
-
- fs.rmSync(TOOLS_STREAMLINK_DIR, { recursive: true, force: true });
- fs.mkdirSync(TOOLS_STREAMLINK_DIR, { recursive: true });
-
- const extractOk = await extractZip(zipPath, TOOLS_STREAMLINK_DIR);
- try { fs.unlinkSync(zipPath); } catch { }
- if (!extractOk) return false;
-
refreshBundledToolPaths(true);
streamlinkCommandCache = null;
@@ -447,13 +489,16 @@ export async function ensureStreamlinkInstalled(): Promise {
export async function ensureFfmpegInstalled(): Promise {
refreshBundledToolPaths();
+ const manifest = APPLICATION_TOOL_MANIFEST.ffmpeg;
+ const managedStatus = getManagedToolInstaller('ffmpeg').status(manifest);
+ const requiresManagedRepair = Boolean(bundledFFmpegPath || bundledFFprobePath) && !managedStatus.verified;
const ffmpegPath = getFFmpegPath();
const ffprobePath = getFFprobePath();
- if (isVerifiedFfmpegCommands(ffmpegPath, ffprobePath)) {
+ if (!requiresManagedRepair && isVerifiedFfmpegCommands(ffmpegPath, ffprobePath)) {
return true;
}
- if (canExecuteCommand(ffmpegPath, ['-version']) && canExecuteCommand(ffprobePath, ['-version'])) {
+ if (!requiresManagedRepair && canExecuteCommand(ffmpegPath, ['-version']) && canExecuteCommand(ffprobePath, ['-version'])) {
cacheVerifiedFfmpegCommands(ffmpegPath, ffprobePath);
return true;
}
@@ -464,18 +509,11 @@ export async function ensureFfmpegInstalled(): Promise {
_appendDebugLog('ffmpeg-install-start');
try {
- fs.mkdirSync(TOOLS_FFMPEG_DIR, { recursive: true });
-
- const zipPath = path.join(_getTempPath(), `ffmpeg_essentials_${Date.now()}.zip`);
- const downloadOk = await downloadFile('https://www.gyan.dev/ffmpeg/builds/ffmpeg-release-essentials.zip', zipPath);
- if (!downloadOk) return false;
-
- fs.rmSync(TOOLS_FFMPEG_DIR, { recursive: true, force: true });
- fs.mkdirSync(TOOLS_FFMPEG_DIR, { recursive: true });
-
- const extractOk = await extractZip(zipPath, TOOLS_FFMPEG_DIR);
- try { fs.unlinkSync(zipPath); } catch { }
- if (!extractOk) return false;
+ const result = await getManagedToolInstaller('ffmpeg').install(manifest);
+ if (!result.success) {
+ _appendDebugLog('ffmpeg-install-failed', { error: result.error, status: result.status });
+ return false;
+ }
refreshBundledToolPaths(true);