diff --git a/src/main/download-manager.ts b/src/main/download-manager.ts index 3a27909..1e3f392 100644 --- a/src/main/download-manager.ts +++ b/src/main/download-manager.ts @@ -6396,6 +6396,7 @@ export class DownloadManager extends EventEmitter { const hybridResolvedItems = new Map(); const hybridStartTimes = new Map(); let hybridLastEmitAt = 0; + let hybridLastProgressCurrent: number | null = null; // Mark items based on whether their archive is actually ready for extraction. // Only items whose archive is in readyArchives get "Ausstehend"; others keep @@ -6443,9 +6444,15 @@ export class DownloadManager extends EventEmitter { if (progress.phase === "done") { hybridResolvedItems.clear(); hybridStartTimes.clear(); + hybridLastProgressCurrent = null; return; } + const currentCount = Math.max(0, Number(progress.current ?? 0)); + const archiveFinished = progress.archiveDone === true + || (hybridLastProgressCurrent !== null && currentCount > hybridLastProgressCurrent); + hybridLastProgressCurrent = currentCount; + if (progress.archiveName) { // Resolve items for this archive if not yet tracked if (!hybridResolvedItems.has(progress.archiveName)) { @@ -6470,11 +6477,14 @@ export class DownloadManager extends EventEmitter { } const archItems = hybridResolvedItems.get(progress.archiveName) || []; - // If archive is at 100%, mark its items as done and remove from active - if (Number(progress.archivePercent ?? 0) >= 100) { + // Only mark as finished on explicit archive-done signal (or real current increment), + // never on raw 100% archivePercent, because password retries can report 100% mid-run. + if (archiveFinished) { const doneAt = nowMs(); const startedAt = hybridStartTimes.get(progress.archiveName) || doneAt; - const doneLabel = formatExtractDone(doneAt - startedAt); + const doneLabel = progress.archiveSuccess === false + ? "Entpacken - Error" + : formatExtractDone(doneAt - startedAt); for (const entry of archItems) { if (!isExtractedLabel(entry.fullStatus)) { entry.fullStatus = doneLabel; @@ -6484,7 +6494,7 @@ export class DownloadManager extends EventEmitter { hybridResolvedItems.delete(progress.archiveName); hybridStartTimes.delete(progress.archiveName); // Show transitional label while next archive initializes - const done = progress.current + 1; + const done = currentCount; if (done < progress.total) { pkg.postProcessLabel = `Entpacken (${done}/${progress.total}) - Naechstes Archiv...`; this.emitState(); @@ -6516,7 +6526,7 @@ export class DownloadManager extends EventEmitter { } // Update package-level label with overall extraction progress - const activeArchive = Number(progress.archivePercent ?? 0) > 0 ? 1 : 0; + const activeArchive = !archiveFinished && Number(progress.archivePercent ?? 0) > 0 ? 1 : 0; const currentDisplay = Math.max(0, Math.min(progress.total, progress.current + activeArchive)); if (progress.passwordFound) { pkg.postProcessLabel = `Passwort gefunden · ${progress.archiveName || ""}`; @@ -6777,6 +6787,7 @@ export class DownloadManager extends EventEmitter { // Track archives for parallel extraction progress const fullResolvedItems = new Map(); const fullStartTimes = new Map(); + let fullLastProgressCurrent: number | null = null; const result = await extractPackageArchives({ packageDir: pkg.outputDir, @@ -6802,10 +6813,16 @@ export class DownloadManager extends EventEmitter { if (progress.phase === "done") { fullResolvedItems.clear(); fullStartTimes.clear(); + fullLastProgressCurrent = null; emitExtractStatus("Entpacken 100%", true); return; } + const currentCount = Math.max(0, Number(progress.current ?? 0)); + const archiveFinished = progress.archiveDone === true + || (fullLastProgressCurrent !== null && currentCount > fullLastProgressCurrent); + fullLastProgressCurrent = currentCount; + if (progress.archiveName) { // Resolve items for this archive if not yet tracked if (!fullResolvedItems.has(progress.archiveName)) { @@ -6829,11 +6846,14 @@ export class DownloadManager extends EventEmitter { } const archiveItems = fullResolvedItems.get(progress.archiveName) || []; - // If archive is at 100%, mark its items as done and remove from active - if (Number(progress.archivePercent ?? 0) >= 100) { + // Only finalize on explicit archive completion (or real current increment), + // not on plain 100% archivePercent. + if (archiveFinished) { const doneAt = nowMs(); const startedAt = fullStartTimes.get(progress.archiveName) || doneAt; - const doneLabel = formatExtractDone(doneAt - startedAt); + const doneLabel = progress.archiveSuccess === false + ? "Entpacken - Error" + : formatExtractDone(doneAt - startedAt); for (const entry of archiveItems) { if (!isExtractedLabel(entry.fullStatus)) { entry.fullStatus = doneLabel; @@ -6843,7 +6863,7 @@ export class DownloadManager extends EventEmitter { fullResolvedItems.delete(progress.archiveName); fullStartTimes.delete(progress.archiveName); // Show transitional label while next archive initializes - const done = progress.current + 1; + const done = currentCount; if (done < progress.total) { emitExtractStatus(`Entpacken (${done}/${progress.total}) - Naechstes Archiv...`, true); } @@ -6878,7 +6898,7 @@ export class DownloadManager extends EventEmitter { const elapsed = progress.elapsedMs && progress.elapsedMs >= 1000 ? ` · ${Math.floor(progress.elapsedMs / 1000)}s` : ""; - const activeArchive = Number(progress.archivePercent ?? 0) > 0 ? 1 : 0; + const activeArchive = !archiveFinished && Number(progress.archivePercent ?? 0) > 0 ? 1 : 0; const currentDisplay = Math.max(0, Math.min(progress.total, progress.current + activeArchive)); let overallLabel: string; if (progress.passwordFound) { diff --git a/src/main/extractor.ts b/src/main/extractor.ts index f6c6448..88853eb 100644 --- a/src/main/extractor.ts +++ b/src/main/extractor.ts @@ -123,6 +123,8 @@ export interface ExtractProgressUpdate { passwordAttempt?: number; passwordTotal?: number; passwordFound?: boolean; + archiveDone?: boolean; + archiveSuccess?: boolean; } const MAX_EXTRACT_OUTPUT_BUFFER = 48 * 1024; @@ -378,6 +380,19 @@ function parseProgressPercent(chunk: string): number | null { return latest; } +function nextArchivePercent(previous: number, incoming: number): number { + const prev = Math.max(0, Math.min(100, Math.floor(Number(previous) || 0))); + const next = Math.max(0, Math.min(100, Math.floor(Number(incoming) || 0))); + if (next >= prev) { + return next; + } + // Wrong-password retries can emit a fresh 0..100 run for the same archive. + if (prev >= 95 && next <= 5) { + return next; + } + return prev; +} + async function shouldPreferExternalZip(archivePath: string): Promise { if (extractorBackendMode() !== "legacy") { return true; @@ -529,9 +544,12 @@ function prioritizePassword(passwords: string[], successful: string): string[] { return passwords; } const index = passwords.findIndex((candidate) => candidate === target); - if (index <= 0) { + if (index === 0) { return passwords; } + if (index < 0) { + return [target, ...passwords.filter((candidate) => candidate !== target)]; + } const next = [...passwords]; const [value] = next.splice(index, 1); next.unshift(value); @@ -961,9 +979,12 @@ function parseJvmLine( if (trimmed.startsWith("RD_PROGRESS ")) { const parsed = parseProgressPercent(trimmed); - if (parsed !== null && parsed > state.bestPercent) { - state.bestPercent = parsed; - onArchiveProgress?.(parsed); + if (parsed !== null) { + const next = nextArchivePercent(state.bestPercent, parsed); + if (next !== state.bestPercent) { + state.bestPercent = next; + onArchiveProgress?.(next); + } } return; } @@ -1742,6 +1763,10 @@ async function runExternalExtractInner( onArchiveProgress?.(0); } passwordAttempt += 1; + if (passwordAttempt > 1 && bestPercent > 0) { + bestPercent = 0; + onArchiveProgress?.(0); + } const quotedPw = password === "" ? '""' : `"${password}"`; logger.info(`Legacy-Passwort-Versuch ${passwordAttempt}/${passwords.length} für ${path.basename(archivePath)}: ${quotedPw}`); if (passwords.length > 1) { @@ -1750,11 +1775,14 @@ async function runExternalExtractInner( let args = buildExternalExtractArgs(command, archivePath, targetDir, conflictMode, password, usePerformanceFlags, hybridMode); let result = await runExtractCommand(command, args, (chunk) => { const parsed = parseProgressPercent(chunk); - if (parsed === null || parsed <= bestPercent) { + if (parsed === null) { return; } - bestPercent = parsed; - onArchiveProgress?.(bestPercent); + const next = nextArchivePercent(bestPercent, parsed); + if (next !== bestPercent) { + bestPercent = next; + onArchiveProgress?.(bestPercent); + } }, signal, timeoutMs); if (!result.ok && usePerformanceFlags && isUnsupportedExtractorSwitchError(result.errorText)) { @@ -1764,11 +1792,14 @@ async function runExternalExtractInner( args = buildExternalExtractArgs(command, archivePath, targetDir, conflictMode, password, false, hybridMode); result = await runExtractCommand(command, args, (chunk) => { const parsed = parseProgressPercent(chunk); - if (parsed === null || parsed <= bestPercent) { + if (parsed === null) { return; } - bestPercent = parsed; - onArchiveProgress?.(bestPercent); + const next = nextArchivePercent(bestPercent, parsed); + if (next !== bestPercent) { + bestPercent = next; + onArchiveProgress?.(bestPercent); + } }, signal, timeoutMs); } @@ -2258,6 +2289,7 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{ let extracted = candidates.length - pendingCandidates.length; let failed = 0; let lastError = ""; + let learnedPassword = ""; const extractedArchives = new Set(); for (const archivePath of candidates) { if (resumeCompleted.has(archiveNameKey(path.basename(archivePath)))) { @@ -2271,7 +2303,8 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{ phase: "extracting" | "done", archivePercent?: number, elapsedMs?: number, - pwInfo?: { passwordAttempt?: number; passwordTotal?: number; passwordFound?: boolean } + pwInfo?: { passwordAttempt?: number; passwordTotal?: number; passwordFound?: boolean }, + archiveInfo?: { archiveDone?: boolean; archiveSuccess?: boolean } ): void => { if (!options.onProgress) { return; @@ -2292,6 +2325,7 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{ archivePercent, elapsedMs, phase, + ...(archiveInfo || {}), ...(pwInfo || {}) }); } catch (error) { @@ -2306,7 +2340,7 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{ // rather than leaving them as "Entpacken - Ausstehend" until all extraction finishes. for (const archivePath of candidates) { if (resumeCompleted.has(archiveNameKey(path.basename(archivePath)))) { - emitProgress(extracted, path.basename(archivePath), "extracting", 100, 0); + emitProgress(extracted, path.basename(archivePath), "extracting", 100, 0, undefined, { archiveDone: true, archiveSuccess: true }); } } @@ -2329,11 +2363,14 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{ emitProgress(extracted + failed, archiveName, "extracting", archivePercent, Date.now() - archiveStartedAt); }, 1100); const hybrid = Boolean(options.hybridMode); - // Insert archive-filename-derived passwords after "" but before custom passwords + // Before the first successful extraction, filename-derived candidates are useful. + // After a known password is learned, try that first to avoid per-archive delays. const filenamePasswords = archiveFilenamePasswords(archiveName); - const archivePasswordCandidates = filenamePasswords.length > 0 - ? Array.from(new Set(["", ...filenamePasswords, ...passwordCandidates.filter((p) => p !== "")])) - : passwordCandidates; + const nonEmptyBasePasswords = passwordCandidates.filter((p) => p !== ""); + const orderedNonEmpty = learnedPassword + ? [learnedPassword, ...nonEmptyBasePasswords.filter((p) => p !== learnedPassword), ...filenamePasswords] + : [...filenamePasswords, ...nonEmptyBasePasswords]; + const archivePasswordCandidates = Array.from(new Set(["", ...orderedNonEmpty])); // Validate generic .001 splits via file signature before attempting extraction const isGenericSplit = /\.\d{3}$/i.test(archiveName) && !/\.(zip|7z)\.\d{3}$/i.test(archiveName); @@ -2368,9 +2405,12 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{ if (preferExternal) { try { const usedPassword = await runExternalExtract(archivePath, options.targetDir, options.conflictMode, archivePasswordCandidates, (value) => { - archivePercent = Math.max(archivePercent, value); + archivePercent = nextArchivePercent(archivePercent, value); emitProgress(extracted + failed, archiveName, "extracting", archivePercent, Date.now() - archiveStartedAt); }, options.signal, hybrid, onPwAttempt); + if (usedPassword) { + learnedPassword = usedPassword; + } passwordCandidates = prioritizePassword(passwordCandidates, usedPassword); } catch (error) { if (isNoExtractorError(String(error))) { @@ -2389,9 +2429,12 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{ } try { const usedPassword = await runExternalExtract(archivePath, options.targetDir, options.conflictMode, archivePasswordCandidates, (value) => { - archivePercent = Math.max(archivePercent, value); + archivePercent = nextArchivePercent(archivePercent, value); emitProgress(extracted + failed, archiveName, "extracting", archivePercent, Date.now() - archiveStartedAt); }, options.signal, hybrid, onPwAttempt); + if (usedPassword) { + learnedPassword = usedPassword; + } passwordCandidates = prioritizePassword(passwordCandidates, usedPassword); } catch (externalError) { if (isNoExtractorError(String(externalError)) || isUnsupportedArchiveFormatError(String(externalError))) { @@ -2403,9 +2446,12 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{ } } else { const usedPassword = await runExternalExtract(archivePath, options.targetDir, options.conflictMode, archivePasswordCandidates, (value) => { - archivePercent = Math.max(archivePercent, value); + archivePercent = nextArchivePercent(archivePercent, value); emitProgress(extracted + failed, archiveName, "extracting", archivePercent, Date.now() - archiveStartedAt); }, options.signal, hybrid, onPwAttempt); + if (usedPassword) { + learnedPassword = usedPassword; + } passwordCandidates = prioritizePassword(passwordCandidates, usedPassword); } extracted += 1; @@ -2415,9 +2461,9 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{ logger.info(`Entpacken erfolgreich: ${path.basename(archivePath)}`); archivePercent = 100; if (hasManyPasswords) { - emitProgress(extracted + failed, archiveName, "extracting", archivePercent, Date.now() - archiveStartedAt, { passwordFound: true }); + emitProgress(extracted + failed, archiveName, "extracting", archivePercent, Date.now() - archiveStartedAt, { passwordFound: true }, { archiveDone: true, archiveSuccess: true }); } else { - emitProgress(extracted + failed, archiveName, "extracting", archivePercent, Date.now() - archiveStartedAt); + emitProgress(extracted + failed, archiveName, "extracting", archivePercent, Date.now() - archiveStartedAt, undefined, { archiveDone: true, archiveSuccess: true }); } } catch (error) { const errorText = String(error); @@ -2428,7 +2474,7 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{ lastError = errorText; const errorCategory = classifyExtractionError(errorText); logger.error(`Entpack-Fehler ${path.basename(archivePath)} [${errorCategory}]: ${errorText}`); - emitProgress(extracted + failed, archiveName, "extracting", archivePercent, Date.now() - archiveStartedAt); + emitProgress(extracted + failed, archiveName, "extracting", archivePercent, Date.now() - archiveStartedAt, undefined, { archiveDone: true, archiveSuccess: false }); if (isNoExtractorError(errorText)) { noExtractorEncountered = true; }