From 477dfd57504ce8d18c5688fd454409521e640f29 Mon Sep 17 00:00:00 2001 From: Sucukdeluxe <259325684+Sucukdeluxe@users.noreply.github.com> Date: Fri, 14 Aug 2026 12:46:02 +0200 Subject: [PATCH] fix(tools): never block downloads while a runnable tool exists ensureStreamlinkInstalled and ensureFfmpegInstalled forced a managed reinstall whenever a bundled tool directory existed without passing manifest verification. When that reinstall failed (offline, blocked download, locked executable), both functions returned false without ever probing the tool that was already present and runnable, so every download path aborted with an auto-install error while the System Check kept reporting the same tool as available. Both functions now fall back to the same executability probe the System Check uses after a failed managed repair, cache the verified command on success, and only report failure when nothing runnable remains. The repair attempt itself is preserved and retried on the next call. The tool archive download also registered no error handler on the HTTP response stream. A stream that failed mid-transfer settled neither the writer finish nor the writer error path, leaving the install promise pending forever and the download start hanging indefinitely. The response stream now rejects the download on error and destroys the writer. Merge group downloads reported the bare "Streamlink is missing." text while the VOD and live paths already explained the failed auto-install. All three gates now share streamlinkAutoInstallFailed, ffmpeg gained the matching ffmpegAutoInstallFailed message, and the now unused streamlinkMissing and ffmpegMissing keys were removed. The Managed tools panel showed "Missing / Unverified" with no hint that downloads still work through a system-provided installation, which reads like a hard failure on machines that never installed the managed copies. getManagedToolStatuses now reports per tool whether a runnable installation answers the version probe, and the panel appends a localized "Downloads still available" note whenever an unverified state is covered by a working tool. --- src/main-runtime.production-path.test.ts | 9 + src/main.ts | 4 +- src/main/domain/i18n-backend.ts | 6 +- src/renderer-globals.d.ts | 1 + src/renderer-locale-de.ts | 1 + src/renderer-locale-en.ts | 1 + src/renderer-settings.production-path.test.ts | 37 ++++ src/renderer-settings.ts | 6 +- src/tools.test.ts | 178 ++++++++++++++++++ src/tools.ts | 57 ++++-- 10 files changed, 281 insertions(+), 19 deletions(-) create mode 100644 src/tools.test.ts diff --git a/src/main-runtime.production-path.test.ts b/src/main-runtime.production-path.test.ts index 6a679e4..67eac41 100644 --- a/src/main-runtime.production-path.test.ts +++ b/src/main-runtime.production-path.test.ts @@ -85,6 +85,15 @@ describe('main runtime safety production paths', () => { expect(closeHandler.indexOf('finish({ success: true, filename })')).toBeGreaterThan(closeHandler.indexOf('partialDownloadRegistry.commit')); }); + it('reports the auto-install failure guidance on every download tool gate', () => { + const source = mainSource(); + const mergeGroup = source.slice(source.indexOf('async function processDownloadMergeGroup'), source.indexOf('// ---- PHASE 2: MERGING ----')); + expect(mergeGroup).toContain("tBackend('streamlinkAutoInstallFailed')"); + expect(mergeGroup).toContain("tBackend('ffmpegAutoInstallFailed')"); + expect(source).not.toContain("tBackend('streamlinkMissing')"); + expect(source).not.toContain("tBackend('ffmpegMissing')"); + }); + it('guards and tracks every standalone cut and merge process across shutdown', () => { const source = mainSource(); const cut = source.slice(source.indexOf('async function cutVideo('), source.indexOf('async function mergeVideos(')); diff --git a/src/main.ts b/src/main.ts index f05c512..6723515 100644 --- a/src/main.ts +++ b/src/main.ts @@ -6471,12 +6471,12 @@ async function processDownloadMergeGroup( if (mg.mergePhase === 'downloading') { const streamlinkReady = await ensureStreamlinkInstalled(); if (!streamlinkReady) { - return { success: false, error: tBackend('streamlinkMissing') }; + return { success: false, error: tBackend('streamlinkAutoInstallFailed') }; } const ffmpegReady = await ensureFfmpegInstalled(); if (!ffmpegReady) { - return { success: false, error: tBackend('ffmpegMissing') }; + return { success: false, error: tBackend('ffmpegAutoInstallFailed') }; } const streamer = mg.items[0].streamer.replace(/[^a-zA-Z0-9_-]/g, ''); diff --git a/src/main/domain/i18n-backend.ts b/src/main/domain/i18n-backend.ts index f5fc85d..1922c69 100644 --- a/src/main/domain/i18n-backend.ts +++ b/src/main/domain/i18n-backend.ts @@ -7,10 +7,9 @@ export const BACKEND_MESSAGES = { invalidClipUrl: 'Ungültige Clip-URL', clipNotFound: 'Clip nicht gefunden', streamlinkAutoInstallFailed: 'Streamlink fehlt und konnte nicht automatisch installiert werden. Siehe debug.log.', - streamlinkMissing: 'Streamlink fehlt.', streamlinkNotFound: 'Streamlink nicht gefunden. Installiere Streamlink oder Python+streamlink (py -3 -m pip install streamlink).', streamlinkExitCode: 'Streamlink Fehlercode {code}', - ffmpegMissing: 'FFmpeg fehlt.', + ffmpegAutoInstallFailed: 'FFmpeg fehlt und konnte nicht automatisch installiert werden. Siehe debug.log.', ffmpegMergeFailed: 'FFmpeg Merge fehlgeschlagen.', ffmpegSplitFailed: 'FFmpeg Split fehlgeschlagen.', fileTooSmall: 'Datei zu klein ({bytes} Bytes)', @@ -49,10 +48,9 @@ export const BACKEND_MESSAGES = { invalidClipUrl: 'Invalid clip URL', clipNotFound: 'Clip not found', streamlinkAutoInstallFailed: 'Streamlink is missing and could not be auto-installed. See debug.log.', - streamlinkMissing: 'Streamlink is missing.', streamlinkNotFound: 'Streamlink not found. Install streamlink or Python+streamlink (py -3 -m pip install streamlink).', streamlinkExitCode: 'Streamlink exit code {code}', - ffmpegMissing: 'FFmpeg is missing.', + ffmpegAutoInstallFailed: 'FFmpeg is missing and could not be auto-installed. See debug.log.', ffmpegMergeFailed: 'FFmpeg merge failed.', ffmpegSplitFailed: 'FFmpeg split failed.', fileTooSmall: 'File too small ({bytes} bytes)', diff --git a/src/renderer-globals.d.ts b/src/renderer-globals.d.ts index 7944879..33d9dae 100644 --- a/src/renderer-globals.d.ts +++ b/src/renderer-globals.d.ts @@ -305,6 +305,7 @@ interface ManagedToolStatus { archiveName: string; state: 'missing' | 'installing' | 'verified' | 'unverified' | 'corrupt'; verified: boolean; + fallbackRunnable: boolean; } interface ManagedToolStatuses { diff --git a/src/renderer-locale-de.ts b/src/renderer-locale-de.ts index b7b799f..2de98de 100644 --- a/src/renderer-locale-de.ts +++ b/src/renderer-locale-de.ts @@ -342,6 +342,7 @@ const UI_TEXT_DE = { managedToolsVerified: 'Verifiziert', managedToolsUnverified: 'Nicht verifiziert', managedToolsCorrupt: 'Beschädigt', + managedToolsFallbackActive: 'Downloads weiterhin möglich', debugLogTitle: 'Live Debug-Log', refreshLog: 'Aktualisieren', autoRefresh: 'Auto-Refresh', diff --git a/src/renderer-locale-en.ts b/src/renderer-locale-en.ts index a550ee7..4ead8e8 100644 --- a/src/renderer-locale-en.ts +++ b/src/renderer-locale-en.ts @@ -342,6 +342,7 @@ const UI_TEXT_EN = { managedToolsVerified: 'Verified', managedToolsUnverified: 'Unverified', managedToolsCorrupt: 'Corrupt', + managedToolsFallbackActive: 'Downloads still available', debugLogTitle: 'Live Debug Log', refreshLog: 'Refresh', autoRefresh: 'Auto refresh', diff --git a/src/renderer-settings.production-path.test.ts b/src/renderer-settings.production-path.test.ts index f8e7144..7f82d4c 100644 --- a/src/renderer-settings.production-path.test.ts +++ b/src/renderer-settings.production-path.test.ts @@ -77,6 +77,43 @@ function createElements(...ids: string[]): Map { } describe('renderer settings production diagnostics paths', () => { + it('marks unverified managed tools as still downloadable when a runnable installation exists', () => { + const elements = createElements('managedToolStatus'); + const context = { + UI_TEXT: { + static: { + managedToolsMissing: 'Missing', + managedToolsInstalling: 'Installing', + managedToolsVerified: 'Verified', + managedToolsUnverified: 'Unverified', + managedToolsCorrupt: 'Corrupt', + managedToolsFallbackActive: 'Downloads still available', + }, + }, + byId: (id: string) => elements.get(id), + }; + const api = evaluate( + sourceFragment('function getManagedToolStateLabel', 'async function refreshManagedToolStatus'), + context, + 'renderManagedToolStatus' + ); + + api.renderManagedToolStatus({ + streamlink: { id: 'streamlink', version: '8.4.0', state: 'missing', verified: false, fallbackRunnable: true }, + ffmpeg: { id: 'ffmpeg', version: '8.1.2', state: 'missing', verified: false, fallbackRunnable: false }, + }); + + const lines = (elements.get('managedToolStatus')?.textContent ?? '').split('\n'); + expect(lines[0]).toBe('streamlink 8.4.0: Missing · Unverified · Downloads still available'); + expect(lines[1]).toBe('ffmpeg 8.1.2: Missing · Unverified'); + + api.renderManagedToolStatus({ + streamlink: { id: 'streamlink', version: '8.4.0', state: 'verified', verified: true, fallbackRunnable: true }, + ffmpeg: { id: 'ffmpeg', version: '8.1.2', state: 'verified', verified: true, fallbackRunnable: true }, + }); + expect(elements.get('managedToolStatus')?.textContent).not.toContain('Downloads still available'); + }); + it('ends every consecutive runtime metrics rejection in the localized error state', async () => { const elements = createElements('runtimeMetricsOutput'); const context = { diff --git a/src/renderer-settings.ts b/src/renderer-settings.ts index 9bf3906..b499102 100644 --- a/src/renderer-settings.ts +++ b/src/renderer-settings.ts @@ -471,7 +471,11 @@ function getManagedToolStateLabel(state: ManagedToolStatus['state']): string { 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}`; + const parts = [`${status.id} ${status.version}: ${getManagedToolStateLabel(status.state)} · ${verification}`]; + if (!status.verified && status.fallbackRunnable) { + parts.push(UI_TEXT.static.managedToolsFallbackActive); + } + return parts.join(' · '); }); byId('managedToolStatus').textContent = lines.join('\n'); } diff --git a/src/tools.test.ts b/src/tools.test.ts new file mode 100644 index 0000000..9321cd1 --- /dev/null +++ b/src/tools.test.ts @@ -0,0 +1,178 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { PassThrough } from 'node:stream'; + +vi.mock('axios', () => ({ + default: { + get: vi.fn() + } +})); + +vi.mock('child_process', () => ({ + spawn: vi.fn(), + execSync: vi.fn(() => { + throw new Error('not on PATH'); + }), + spawnSync: vi.fn(() => ({ status: 1 })) +})); + +interface ToolsModule { + initToolDirs(streamlinkDir: string, ffmpegDir: string, getTempPath: () => string): void; + setDebugLogFn(fn: (message: string, details?: unknown) => void): void; + ensureStreamlinkInstalled(): Promise; + ensureFfmpegInstalled(): Promise; + getManagedToolStatuses(): Promise<{ + streamlink: { state: string; verified: boolean; fallbackRunnable: boolean }; + ffmpeg: { state: string; verified: boolean; fallbackRunnable: boolean }; + }>; +} + +const originalPlatform = process.platform; +let tempRoot: string; +let streamlinkDir: string; +let ffmpegDir: string; +let debugMessages: string[]; + +function forcePlatform(platform: NodeJS.Platform): void { + Object.defineProperty(process, 'platform', { value: platform, configurable: true }); +} + +function writeUnverifiedStreamlinkInstall(): string { + const binDir = path.join(streamlinkDir, 'bin'); + fs.mkdirSync(binDir, { recursive: true }); + const exePath = path.join(binDir, 'streamlink.exe'); + fs.writeFileSync(exePath, 'dummy-streamlink-binary'); + return exePath; +} + +function writeUnverifiedFfmpegInstall(): { ffmpegPath: string; ffprobePath: string } { + const binDir = path.join(ffmpegDir, 'bin'); + fs.mkdirSync(binDir, { recursive: true }); + const ffmpegPath = path.join(binDir, 'ffmpeg.exe'); + const ffprobePath = path.join(binDir, 'ffprobe.exe'); + fs.writeFileSync(ffmpegPath, 'dummy-ffmpeg-binary'); + fs.writeFileSync(ffprobePath, 'dummy-ffprobe-binary'); + return { ffmpegPath, ffprobePath }; +} + +async function loadTools(): Promise<{ tools: ToolsModule; axiosGet: ReturnType; spawnSync: ReturnType }> { + vi.resetModules(); + const axios = (await import('axios')).default as unknown as { get: ReturnType }; + const childProcess = await import('child_process') as unknown as { spawnSync: ReturnType }; + const tools = await import('./tools') as unknown as ToolsModule; + tools.initToolDirs(streamlinkDir, ffmpegDir, () => path.join(tempRoot, 'tmp')); + tools.setDebugLogFn((message) => { + debugMessages.push(message); + }); + return { tools, axiosGet: axios.get, spawnSync: childProcess.spawnSync }; +} + +function allowExecutionOf(spawnSync: ReturnType, runnablePaths: string[]): void { + spawnSync.mockImplementation((command: string) => ( + runnablePaths.includes(command) ? { status: 0 } : { status: 1 } + )); +} + +beforeEach(() => { + forcePlatform('win32'); + tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'tvm-tools-test-')); + streamlinkDir = path.join(tempRoot, 'tools', 'streamlink'); + ffmpegDir = path.join(tempRoot, 'tools', 'ffmpeg'); + fs.mkdirSync(streamlinkDir, { recursive: true }); + fs.mkdirSync(ffmpegDir, { recursive: true }); + debugMessages = []; +}); + +afterEach(() => { + Object.defineProperty(process, 'platform', { value: originalPlatform, configurable: true }); + fs.rmSync(tempRoot, { recursive: true, force: true }); +}); + +describe('ensureStreamlinkInstalled with an unverified managed install', () => { + it('returns true when the forced repair download fails but the existing streamlink runs', async () => { + const exePath = writeUnverifiedStreamlinkInstall(); + const { tools, axiosGet, spawnSync } = await loadTools(); + allowExecutionOf(spawnSync, [exePath]); + axiosGet.mockRejectedValue(new Error('offline')); + + await expect(tools.ensureStreamlinkInstalled()).resolves.toBe(true); + expect(debugMessages).toContain('streamlink-install-start'); + expect(debugMessages).toContain('streamlink-install-failed'); + }); + + it('returns false when the forced repair fails and the existing streamlink does not run', async () => { + writeUnverifiedStreamlinkInstall(); + const { tools, axiosGet, spawnSync } = await loadTools(); + allowExecutionOf(spawnSync, []); + axiosGet.mockRejectedValue(new Error('offline')); + + await expect(tools.ensureStreamlinkInstalled()).resolves.toBe(false); + expect(debugMessages).toContain('streamlink-install-failed'); + }); + + it('resolves instead of hanging when the download response stream errors mid-transfer', async () => { + const exePath = writeUnverifiedStreamlinkInstall(); + const { tools, axiosGet, spawnSync } = await loadTools(); + allowExecutionOf(spawnSync, [exePath]); + const brokenStream = new PassThrough(); + let errorScheduled = false; + brokenStream.on('newListener', (event) => { + if (event === 'error' && !errorScheduled) { + errorScheduled = true; + setImmediate(() => brokenStream.emit('error', new Error('connection reset'))); + } + }); + axiosGet.mockResolvedValue({ data: brokenStream }); + + await expect(tools.ensureStreamlinkInstalled()).resolves.toBe(true); + expect(debugMessages).toContain('streamlink-install-failed'); + }, 8000); +}); + +describe('getManagedToolStatuses fallback availability', () => { + it('reports a runnable unverified install as still downloadable', async () => { + const exePath = writeUnverifiedStreamlinkInstall(); + const { tools, spawnSync } = await loadTools(); + allowExecutionOf(spawnSync, [exePath]); + + const statuses = await tools.getManagedToolStatuses(); + expect(statuses.streamlink.verified).toBe(false); + expect(statuses.streamlink.fallbackRunnable).toBe(true); + expect(statuses.ffmpeg.fallbackRunnable).toBe(false); + }); + + it('reports nothing runnable when no installation responds', async () => { + writeUnverifiedStreamlinkInstall(); + const { tools, spawnSync } = await loadTools(); + allowExecutionOf(spawnSync, []); + + const statuses = await tools.getManagedToolStatuses(); + expect(statuses.streamlink.fallbackRunnable).toBe(false); + expect(statuses.ffmpeg.fallbackRunnable).toBe(false); + }); +}); + +describe('ensureFfmpegInstalled with an unverified managed install', () => { + it('returns true when the forced repair download fails but the existing ffmpeg and ffprobe run', async () => { + const { ffmpegPath, ffprobePath } = writeUnverifiedFfmpegInstall(); + const { tools, axiosGet, spawnSync } = await loadTools(); + allowExecutionOf(spawnSync, [ffmpegPath, ffprobePath]); + axiosGet.mockRejectedValue(new Error('offline')); + + await expect(tools.ensureFfmpegInstalled()).resolves.toBe(true); + expect(debugMessages).toContain('ffmpeg-install-start'); + expect(debugMessages).toContain('ffmpeg-install-failed'); + }); + + it('returns false when the forced repair fails and the existing ffmpeg does not run', async () => { + writeUnverifiedFfmpegInstall(); + const { tools, axiosGet, spawnSync } = await loadTools(); + allowExecutionOf(spawnSync, []); + axiosGet.mockRejectedValue(new Error('offline')); + + await expect(tools.ensureFfmpegInstalled()).resolves.toBe(false); + expect(debugMessages).toContain('ffmpeg-install-failed'); + }); +}); diff --git a/src/tools.ts b/src/tools.ts index df64a51..642c80c 100644 --- a/src/tools.ts +++ b/src/tools.ts @@ -338,6 +338,10 @@ async function downloadFile(url: string, destinationPath: string): Promise((resolve, reject) => { const writer = fs.createWriteStream(destinationPath); + response.data.on('error', (err: Error) => { + writer.destroy(); + reject(err); + }); response.data.pipe(writer); writer.on('finish', () => resolve()); writer.on('error', (err) => reject(err)); @@ -412,14 +416,27 @@ function getManagedToolInstaller(toolId: 'streamlink' | 'ffmpeg'): ManagedToolIn } export interface ManagedToolStatuses { - streamlink: ManagedToolStatus; - ffmpeg: ManagedToolStatus; + streamlink: ManagedToolStatus & { fallbackRunnable: boolean }; + ffmpeg: ManagedToolStatus & { fallbackRunnable: boolean }; } export async function getManagedToolStatuses(): Promise { + refreshBundledToolPaths(); + const streamlinkCommand = getStreamlinkCommand(); + const streamlinkVersionArgs = [...streamlinkCommand.prefixArgs, '--version']; + const ffmpegPath = getFFmpegPath(); + const ffprobePath = getFFprobePath(); return { - streamlink: await getManagedToolInstaller('streamlink').status(APPLICATION_TOOL_MANIFEST.streamlink), - ffmpeg: await getManagedToolInstaller('ffmpeg').status(APPLICATION_TOOL_MANIFEST.ffmpeg) + streamlink: { + ...(await getManagedToolInstaller('streamlink').status(APPLICATION_TOOL_MANIFEST.streamlink)), + fallbackRunnable: isVerifiedStreamlinkCommand(streamlinkCommand.command, streamlinkVersionArgs) + || fallbackToRunnableStreamlink(streamlinkCommand.command, streamlinkVersionArgs) + }, + ffmpeg: { + ...(await getManagedToolInstaller('ffmpeg').status(APPLICATION_TOOL_MANIFEST.ffmpeg)), + fallbackRunnable: isVerifiedFfmpegCommands(ffmpegPath, ffprobePath) + || fallbackToRunnableFfmpeg(ffmpegPath, ffprobePath) + } }; } @@ -450,6 +467,22 @@ export async function resetManagedTools(): Promise<{ success: boolean; statuses: // ========================================== // AUTO-INSTALL TOOLS // ========================================== +function fallbackToRunnableStreamlink(command: string, versionArgs: string[]): boolean { + if (!canExecuteCommand(command, versionArgs)) { + return false; + } + cacheVerifiedStreamlinkCommand(command, versionArgs); + return true; +} + +function fallbackToRunnableFfmpeg(ffmpegPath: string, ffprobePath: string): boolean { + if (!canExecuteCommand(ffmpegPath, ['-version']) || !canExecuteCommand(ffprobePath, ['-version'])) { + return false; + } + cacheVerifiedFfmpegCommands(ffmpegPath, ffprobePath); + return true; +} + export async function ensureStreamlinkInstalled(): Promise { refreshBundledToolPaths(); @@ -468,7 +501,7 @@ export async function ensureStreamlinkInstalled(): Promise { } if (process.platform !== 'win32') { - return false; + return fallbackToRunnableStreamlink(current.command, versionArgs); } _appendDebugLog('streamlink-install-start'); @@ -476,7 +509,7 @@ export async function ensureStreamlinkInstalled(): Promise { const result = await getManagedToolInstaller('streamlink').install(manifest); if (!result.success) { _appendDebugLog('streamlink-install-failed', { error: result.error, status: result.status }); - return false; + return fallbackToRunnableStreamlink(current.command, versionArgs); } refreshBundledToolPaths(true); @@ -489,10 +522,10 @@ export async function ensureStreamlinkInstalled(): Promise { cacheVerifiedStreamlinkCommand(cmd.command, installedVersionArgs); } _appendDebugLog('streamlink-install-finished', { works, command: cmd.command, prefixArgs: cmd.prefixArgs }); - return works; + return works || fallbackToRunnableStreamlink(current.command, versionArgs); } catch (e) { _appendDebugLog('streamlink-install-failed', String(e)); - return false; + return fallbackToRunnableStreamlink(current.command, versionArgs); } } @@ -514,7 +547,7 @@ export async function ensureFfmpegInstalled(): Promise { } if (process.platform !== 'win32') { - return false; + return fallbackToRunnableFfmpeg(ffmpegPath, ffprobePath); } _appendDebugLog('ffmpeg-install-start'); @@ -522,7 +555,7 @@ export async function ensureFfmpegInstalled(): Promise { const result = await getManagedToolInstaller('ffmpeg').install(manifest); if (!result.success) { _appendDebugLog('ffmpeg-install-failed', { error: result.error, status: result.status }); - return false; + return fallbackToRunnableFfmpeg(ffmpegPath, ffprobePath); } refreshBundledToolPaths(true); @@ -534,9 +567,9 @@ export async function ensureFfmpegInstalled(): Promise { cacheVerifiedFfmpegCommands(newFfmpegPath, newFfprobePath); } _appendDebugLog('ffmpeg-install-finished', { works, ffmpeg: newFfmpegPath, ffprobe: newFfprobePath }); - return works; + return works || fallbackToRunnableFfmpeg(ffmpegPath, ffprobePath); } catch (e) { _appendDebugLog('ffmpeg-install-failed', String(e)); - return false; + return fallbackToRunnableFfmpeg(ffmpegPath, ffprobePath); } }