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);