diff --git a/CHANGELOG.md b/CHANGELOG.md index 1c3ad0c..d49b2a4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/PROJECT_MEMORY.md b/PROJECT_MEMORY.md index d64dd84..813e459 100644 --- a/PROJECT_MEMORY.md +++ b/PROJECT_MEMORY.md @@ -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): diff --git a/scripts/public-release-files.json b/scripts/public-release-files.json index 1a34abc..55d8516 100644 --- a/scripts/public-release-files.json +++ b/scripts/public-release-files.json @@ -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", diff --git a/src/main/infra/extract-zip.test.ts b/src/main/infra/extract-zip.test.ts new file mode 100644 index 0000000..90a965f --- /dev/null +++ b/src/main/infra/extract-zip.test.ts @@ -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; + }); + 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; + }); + 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; + }); + await expect(extractZipArchive(archive, destination)).rejects.toBe(failure); + expect(execute).toHaveBeenCalledTimes(1); +}); diff --git a/src/main/infra/extract-zip.ts b/src/main/infra/extract-zip.ts new file mode 100644 index 0000000..d5bc081 --- /dev/null +++ b/src/main/infra/extract-zip.ts @@ -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 { + 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 { + 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 }); + } +} diff --git a/src/tools.ts b/src/tools.ts index 642c80c..021cc35 100644 --- a/src/tools.ts +++ b/src/tools.ts @@ -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((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) {