fix: extract managed media tools reliably in long Windows paths
Windows CI / verify (push) Canceled after 0s

This commit is contained in:
Sucukdeluxe
2026-09-06 12:56:20 +02:00
parent 0c01668e55
commit 6b41767a71
6 changed files with 76 additions and 27 deletions
+1
View File
@@ -2,6 +2,7 @@
## 1.0.20 - 2026-09-06
- Fix automatic media-tool setup in long Windows paths by using the built-in archive extractor. Report extraction failures instead of accepting incomplete installations.
- Make queue cards easier to read with two-line titles, larger dates below the progress bar on the right, and 32-pixel remove and retry buttons.
- Show clearer download states, preserve the progress percentage while paused, and wrap long error messages. Keep the waiting status simple with a yellow dot and no badge background or outline.
- Open and close download details by double-clicking the card surface or using the dedicated keyboard-accessible arrow.
+1 -1
View File
@@ -81,7 +81,7 @@ npm run dev
- Tatsächlicher Start über `scripts/dev.mjs` mit der umbenannten Windows-EXE erfolgreich: Anwendungsfenster geöffnet, TypeScript-Watcher aktiv mit null Fehlern. Renderer-Dateien werden automatisch neu geladen; Änderungen am Main-Prozess lösen einen Neustart aus.
- Hot-Dev läuft mit den isolierten Entwicklungsdatenverzeichnissen. Der Launcher kann im Hintergrund über Node gestartet werden; Konsolenprotokolle liegen bei diesem Start außerhalb des Repositories im Windows-Temp-Verzeichnis.
- Streamlink 8.4.0 im Entwicklungsverzeichnis eingerichtet und per `--version` geprüft. Der vorhandene Managed-Tool-Installer hat Archiv- und EXE-Prüfsummen verifiziert; zum Entpacken wurde lokal PowerShell 7 mit `Expand-Archive -LiteralPath` und über Umgebungsvariablen übergebenen Pfaden verwendet.
- Der automatische Installationsversuch mit Windows PowerShell meldete zuvor `required-executable-missing`. Die lokale Einrichtung ist behoben; die Ursache im allgemeinen Entpackablauf ist noch offen.
- Der automatische Installationsversuch mit Windows PowerShell meldete zuvor `required-executable-missing`. Bei der Release-Prüfung wurde die Ursache reproduziert: lange Entpackpfade führen zu Dateifehlern und Rollback in `Expand-Archive`, obwohl PowerShell mit Exitcode 0 endet. Seit der 1.0.20-Vorbereitung verwendet die Anwendung primär das integrierte Windows-`tar.exe` mit strukturierten Argumenten und Zeitlimit; nur wenn es fehlt, folgt Windows PowerShell mit LiteralPath, eigenen Pfadvariablen und `-ErrorAction Stop`.
- Anschließender Hot-Reload-Neustart tatsächlich ausgelöst und geprüft: Das neue Anwendungsfenster reagiert, und der reale Preflight meldet Internet, Streamlink, FFmpeg, FFprobe und beschreibbares Download-Verzeichnis als erfolgreich. Keine Live-Downloads gestartet.
6. September 2026, Queue-Card-Rework (unveröffentlicht, Version weiterhin 1.0.19):
+2
View File
@@ -112,6 +112,8 @@
"src/main/infra/format-helpers.ts",
"src/main/infra/fs-atomic.test.ts",
"src/main/infra/fs-atomic.ts",
"src/main/infra/extract-zip.ts",
"src/main/infra/extract-zip.test.ts",
"src/main/infra/loopback-server.test.ts",
"src/main/infra/loopback-server.ts",
"src/main/infra/schema-v5.ts",
+43
View File
@@ -0,0 +1,43 @@
import { beforeEach, expect, it, vi } from 'vitest';
import { execFile } from 'node:child_process';
import { extractZipArchive } from './extract-zip';
vi.mock('node:child_process', () => ({ execFile: vi.fn() }));
const execute = vi.mocked(execFile);
const archive = "C:\\Downloads\\Sascha's [tools] & media.zip";
const destination = "C:\\Downloads\\Sascha's [tools] & media";
beforeEach(() => { execute.mockReset(); });
it('passes archive paths literally to the built-in Windows extractor without a shell', async () => {
execute.mockImplementation((...args: unknown[]) => {
(args[3] as (error: Error | null) => void)(null);
return {} as ReturnType<typeof execFile>;
});
await extractZipArchive(archive, destination);
expect(execute).toHaveBeenCalledWith(expect.stringMatching(/System32[\\/]tar\.exe$/), ['-xf', archive, '-C', destination], expect.objectContaining({ windowsHide: true, timeout: 120000 }), expect.any(Function));
});
it('falls back only when tar is unavailable and keeps paths out of PowerShell source', async () => {
execute.mockImplementation((...args: unknown[]) => {
(args[3] as (error: Error | null) => void)(execute.mock.calls.length === 1 ? Object.assign(new Error('missing'), { code: 'ENOENT' }) : null);
return {} as ReturnType<typeof execFile>;
});
await extractZipArchive(archive, destination);
expect(execute).toHaveBeenCalledTimes(2);
const fallback = execute.mock.calls[1];
expect(fallback[1]).toEqual(expect.arrayContaining([expect.stringContaining('-ErrorAction Stop')]));
expect(JSON.stringify(fallback[1])).not.toContain('Sascha');
expect(fallback[2]).toMatchObject({ env: { TVM_ARCHIVE_PATH: archive, TVM_EXTRACT_PATH: destination } });
});
it('rejects extraction errors instead of reporting an incomplete archive as successful', async () => {
const failure = Object.assign(new Error('invalid archive'), { code: 1 });
execute.mockImplementation((...args: unknown[]) => {
(args[3] as (error: Error | null) => void)(failure);
return {} as ReturnType<typeof execFile>;
});
await expect(extractZipArchive(archive, destination)).rejects.toBe(failure);
expect(execute).toHaveBeenCalledTimes(1);
});
+26
View File
@@ -0,0 +1,26 @@
import { execFile } from 'node:child_process';
import * as path from 'node:path';
function runExtractor(executable: string, args: string[], env = process.env): Promise<void> {
return new Promise((resolve, reject) => {
execFile(executable, args, { windowsHide: true, timeout: 120000, maxBuffer: 1024 * 1024, env }, (error) => {
if (error) reject(error);
else resolve();
});
});
}
export async function extractZipArchive(archivePath: string, destinationPath: string): Promise<void> {
const systemDirectory = path.join(process.env.SystemRoot || 'C:\\Windows', 'System32');
try {
await runExtractor(path.join(systemDirectory, 'tar.exe'), ['-xf', archivePath, '-C', destinationPath]);
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;
await runExtractor(path.join(systemDirectory, 'WindowsPowerShell', 'v1.0', 'powershell.exe'), [
'-NoProfile',
'-NonInteractive',
'-Command',
"$env:PSModulePath = Join-Path $PSHOME 'Modules'; Expand-Archive -LiteralPath $env:TVM_ARCHIVE_PATH -DestinationPath $env:TVM_EXTRACT_PATH -Force -ErrorAction Stop",
], { ...process.env, TVM_ARCHIVE_PATH: archivePath, TVM_EXTRACT_PATH: destinationPath });
}
}
+3 -26
View File
@@ -1,6 +1,7 @@
import * as path from 'path';
import * as fs from 'fs';
import { spawn, execSync, spawnSync } from 'child_process';
import { extractZipArchive } from './main/infra/extract-zip';
import { 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';
@@ -358,31 +359,7 @@ async function extractZip(zipPath: string, destinationDir: string): Promise<bool
try {
fs.mkdirSync(destinationDir, { recursive: true });
const command = `Expand-Archive -Path '${zipPath.replace(/'/g, "''")}' -DestinationPath '${destinationDir.replace(/'/g, "''")}' -Force`;
await new Promise<void>((resolve, reject) => {
const proc = spawn('powershell', [
'-NoProfile',
'-ExecutionPolicy', 'Bypass',
'-Command',
command
], { windowsHide: true });
let stderr = '';
proc.stderr?.on('data', (data) => {
stderr += data.toString();
});
proc.on('close', (code) => {
if (code === 0) {
resolve();
} else {
reject(new Error(`Expand-Archive exit code ${code}: ${stderr.trim()}`));
}
});
proc.on('error', (err) => reject(err));
});
await extractZipArchive(zipPath, destinationDir);
return true;
} catch (e) {