From 7d1971d58b71896ef9ea0d29712772eb5b768efd Mon Sep 17 00:00:00 2001 From: shar <66748576+Nat3z@users.noreply.github.com> Date: Sat, 5 Sep 2026 04:33:22 +0000 Subject: [PATCH 01/16] fix(application/electron): wait for completed torrent files before setup --- .../src/electron/handlers/handler.torrent.ts | 3 +- application/src/electron/lib/torrent-files.ts | 53 +++++++++++++++++++ .../electron/manager/manager.webtorrent.ts | 18 +++++++ application/tests/torrent-files.test.ts | 30 +++++++++++ 4 files changed, 103 insertions(+), 1 deletion(-) create mode 100644 application/src/electron/lib/torrent-files.ts create mode 100644 application/tests/torrent-files.test.ts diff --git a/application/src/electron/handlers/handler.torrent.ts b/application/src/electron/handlers/handler.torrent.ts index 1c198fa1..d9b75697 100644 --- a/application/src/electron/handlers/handler.torrent.ts +++ b/application/src/electron/handlers/handler.torrent.ts @@ -88,6 +88,7 @@ interface WebTorrentControls { pause: () => void; resume: () => void; destroy: () => void; + waitUntilFilesReady: () => Effect.Effect; } const downloads = new Map(); @@ -306,7 +307,7 @@ class TorrentDownload { ); yield* Deferred.await(completed); - yield* Effect.sleep('1 second'); + yield* this.wtBlock.waitUntilFilesReady(); if (this.status === 'cancelled' || this.status === 'failed') { return false; diff --git a/application/src/electron/lib/torrent-files.ts b/application/src/electron/lib/torrent-files.ts new file mode 100644 index 00000000..4759848e --- /dev/null +++ b/application/src/electron/lib/torrent-files.ts @@ -0,0 +1,53 @@ +import { open, stat } from 'node:fs/promises'; + +export interface TorrentFileExpectation { + path: string; + length: number; +} + +interface TorrentFileReadinessOptions { + timeoutMs?: number; + intervalMs?: number; + probe?: (file: TorrentFileExpectation) => Promise; +} + +async function probeTorrentFile(file: TorrentFileExpectation): Promise { + const fileStat = await stat(file.path); + if (fileStat.size !== file.length) { + throw new Error( + `Torrent file has size ${fileStat.size}, expected ${file.length}: ${file.path}` + ); + } + + const handle = await open(file.path, 'r'); + await handle.close(); +} + +/** Wait until WebTorrent's completed files can be safely reopened by setup/seeding. */ +export async function waitForTorrentFiles( + files: readonly TorrentFileExpectation[], + options: TorrentFileReadinessOptions = {} +): Promise { + const timeoutMs = options.timeoutMs ?? 10_000; + const intervalMs = options.intervalMs ?? 100; + const probe = options.probe ?? probeTorrentFile; + const deadline = Date.now() + timeoutMs; + let lastError: unknown; + + do { + try { + await Promise.all(files.map(probe)); + return; + } catch (error) { + lastError = error; + if (Date.now() >= deadline) break; + await new Promise((resolveDelay) => + setTimeout(resolveDelay, intervalMs) + ); + } + } while (Date.now() <= deadline); + + throw lastError instanceof Error + ? lastError + : new Error('Torrent files did not become ready'); +} diff --git a/application/src/electron/manager/manager.webtorrent.ts b/application/src/electron/manager/manager.webtorrent.ts index d606e77e..10882216 100644 --- a/application/src/electron/manager/manager.webtorrent.ts +++ b/application/src/electron/manager/manager.webtorrent.ts @@ -1,7 +1,9 @@ +import { resolve as resolvePath } from 'node:path'; import { TorrentError } from '@ogi-sdk/errors'; import { createLogger, LOGGER_PREFIXES } from '@ogi-sdk/logger'; import { Effect } from 'effect'; import webtorrent from 'webtorrent'; +import { waitForTorrentFiles } from '@/electron/lib/torrent-files.js'; const logger = createLogger(LOGGER_PREFIXES.electron); @@ -12,6 +14,7 @@ type TorrentControls = { pause: () => void; resume: () => void; destroy: () => void; + waitUntilFilesReady: () => Effect.Effect; }; export function torrent(torrentId: string | Buffer, path: string) { @@ -82,6 +85,21 @@ export function torrent(torrentId: string | Buffer, path: string) { stopProgressReporting(); activeTorrent.destroy(); }, + waitUntilFilesReady: () => + Effect.tryPromise({ + try: () => + waitForTorrentFiles( + activeTorrent.files.map((file) => ({ + path: resolvePath(path, file.path), + length: file.length, + })) + ), + catch: (cause) => + new TorrentError({ + message: `Torrent files did not become ready: ${String(cause)}`, + cause, + }), + }), }) ); }); diff --git a/application/tests/torrent-files.test.ts b/application/tests/torrent-files.test.ts new file mode 100644 index 00000000..d851b2d8 --- /dev/null +++ b/application/tests/torrent-files.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, test } from 'bun:test'; +import { + type TorrentFileExpectation, + waitForTorrentFiles, +} from '../src/electron/lib/torrent-files.js'; + +describe('torrent file readiness', () => { + test('retries until every completed torrent file can be reopened', async () => { + const files: TorrentFileExpectation[] = [ + { path: '/download/setup.exe', length: 10 }, + { path: '/download/MD5/QuickSFV.ini', length: 20 }, + ]; + const attempts = new Map(); + + await waitForTorrentFiles(files, { + timeoutMs: 100, + intervalMs: 0, + probe: async (file: TorrentFileExpectation): Promise => { + const attempt = (attempts.get(file.path) ?? 0) + 1; + attempts.set(file.path, attempt); + if (file.path.endsWith('QuickSFV.ini') && attempt === 1) { + throw new Error('ENOENT'); + } + }, + }); + + expect(attempts.get('/download/setup.exe')).toBe(2); + expect(attempts.get('/download/MD5/QuickSFV.ini')).toBe(2); + }); +}); From 6ff9fcbc4cd5fb4f846762e627435fd1a0fc8a16 Mon Sep 17 00:00:00 2001 From: shar <66748576+Nat3z@users.noreply.github.com> Date: Sat, 5 Sep 2026 04:33:22 +0000 Subject: [PATCH 02/16] feat(application): report moving-files progress during extraction --- .../src/electron/handlers/handler.fs.ts | 28 +++++++--- packages/ogi-addon/src/extraction.ts | 51 ++++++++++++++++--- 2 files changed, 64 insertions(+), 15 deletions(-) diff --git a/application/src/electron/handlers/handler.fs.ts b/application/src/electron/handlers/handler.fs.ts index 6daa4c8a..dff33fcd 100644 --- a/application/src/electron/handlers/handler.fs.ts +++ b/application/src/electron/handlers/handler.fs.ts @@ -71,15 +71,27 @@ const extractArchive = (arg: { ], }); } + // Throttle progress IPC: per-file move callbacks can fire thousands of + // times for large games. Always let stage changes and completion through. + let lastProgressSent = 0; + let lastStage: string | undefined; yield* fsTryPromise(arg.outputDir, () => - extraction(archivePath, arg.outputDir, (progress) => { - if (arg.downloadId) { - sendIPCMessage('processing:progress', { - id: arg.downloadId, - phase: 'Extracting archive', - progress, - }); - } + extraction(archivePath, arg.outputDir, (progress, stage) => { + if (!arg.downloadId) return; + const now = Date.now(); + if ( + stage === lastStage && + progress !== 1 && + now - lastProgressSent < 100 + ) + return; + lastProgressSent = now; + lastStage = stage; + sendIPCMessage('processing:progress', { + id: arg.downloadId, + phase: stage === 'moving' ? 'Moving files' : 'Extracting archive', + progress, + }); }) ).pipe( Effect.tapError((error) => diff --git a/packages/ogi-addon/src/extraction.ts b/packages/ogi-addon/src/extraction.ts index c7389cab..f052d3e3 100644 --- a/packages/ogi-addon/src/extraction.ts +++ b/packages/ogi-addon/src/extraction.ts @@ -23,7 +23,12 @@ const progressPollIntervalMs = 150; type ExtractionError = FileSystemError | PlatformError; export type ExtractionProgress = number | null; -export type ExtractionProgressCallback = (progress: ExtractionProgress) => void; +/** 'extracting' while the archive unpacks; 'moving' while staged files move into the output directory. */ +export type ExtractionStage = 'extracting' | 'moving'; +export type ExtractionProgressCallback = ( + progress: ExtractionProgress, + stage: ExtractionStage +) => void; const spawnProcess = ( command: string, @@ -197,9 +202,23 @@ const getRegularFileBytes = async (directory: string): Promise => { return total; }; +const countRegularFiles = async (directory: string): Promise => { + let total = 0; + const entries = await fsAsync.readdir(directory, { withFileTypes: true }); + for (const entry of entries) { + if (entry.isDirectory()) { + total += await countRegularFiles(join(directory, entry.name)); + } else if (entry.isFile()) { + total += 1; + } + } + return total; +}; + const mergeDirectory = async ( sourceDir: string, - destinationDir: string + destinationDir: string, + onFileMoved?: () => void ): Promise => { const destinationStat = await fsAsync .lstat(destinationDir) @@ -213,21 +232,23 @@ const mergeDirectory = async ( const source = join(sourceDir, entry.name); const destination = join(destinationDir, entry.name); if (entry.isDirectory()) { - await mergeDirectory(source, destination); + await mergeDirectory(source, destination, onFileMoved); await fsAsync.rm(source, { recursive: true, force: true }); continue; } await fsAsync.rm(destination, { recursive: true, force: true }); await fsAsync.rename(source, destination); + onFileMoved?.(); } }; const reportProgress = ( callback: ExtractionProgressCallback | undefined, - progress: ExtractionProgress + progress: ExtractionProgress, + stage: ExtractionStage = 'extracting' ): void => { try { - callback?.(progress); + callback?.(progress, stage); } catch { // Progress observers must not be able to fail extraction. } @@ -386,8 +407,24 @@ const extractArchiveEffect = ( ) ) ); + // Move staged files into the output directory, reporting per-file + // progress so big games don't look stuck after extraction finishes. yield* Effect.tryPromise({ - try: () => mergeDirectory(stagingDir, outputDir), + try: async () => { + const totalFiles = await countRegularFiles(stagingDir).catch(() => 0); + reportProgress(onProgress, totalFiles > 0 ? 0 : null, 'moving'); + let movedFiles = 0; + await mergeDirectory(stagingDir, outputDir, () => { + movedFiles++; + if (totalFiles > 0) { + reportProgress( + onProgress, + Math.min(movedFiles / totalFiles, 0.99), + 'moving' + ); + } + }); + }, catch: (cause) => new FileSystemError({ message: `Failed to move extracted files: ${String(cause)}`, @@ -395,7 +432,7 @@ const extractArchiveEffect = ( cause, }), }); - reportProgress(onProgress, 1); + reportProgress(onProgress, 1, 'moving'); }).pipe( Effect.ensuring( Effect.promise(() => From ebc180b83fa57274d1d05efad773eba1d1180721 Mon Sep 17 00:00:00 2001 From: shar <66748576+Nat3z@users.noreply.github.com> Date: Sat, 5 Sep 2026 04:33:22 +0000 Subject: [PATCH 03/16] feat(application/frontend): recover post-download processing after a crash --- .../src/frontend/lib/downloads/persistence.ts | 16 +- .../src/frontend/lib/recovery/failedSetups.ts | 40 ++++- .../frontend/managers/DownloadManager.svelte | 141 ++++++++++++------ 3 files changed, 147 insertions(+), 50 deletions(-) diff --git a/application/src/frontend/lib/downloads/persistence.ts b/application/src/frontend/lib/downloads/persistence.ts index 5da5d4b8..5d1c42d1 100644 --- a/application/src/frontend/lib/downloads/persistence.ts +++ b/application/src/frontend/lib/downloads/persistence.ts @@ -15,6 +15,7 @@ const logger = createLogger(LOGGER_PREFIXES.frontend); type PersistableStatus = | 'downloading' + | 'merging' | 'paused' | 'installing-redistributables'; @@ -52,11 +53,24 @@ function isPersistableStatus( ): status is PersistableStatus { return ( status === 'downloading' || + status === 'merging' || status === 'paused' || status === 'installing-redistributables' ); } +// 'merging' is only persistable while the backend merges chunk files: the +// chunk files on disk let a restart resume without re-downloading. Post- +// download processing (moving/extracting) is covered by the failed-setups +// recovery file instead. +function isPersistableDownload(download: DownloadStatusAndInfo): boolean { + return ( + isPersistableStatus(download.status) && + (download.status !== 'merging' || + download.processingPhase === 'Merging chunks') + ); +} + function recordPath(id: string) { return `${PERSIST_DIR}/${id}.json`; } @@ -237,7 +251,7 @@ export function initDownloadPersistence() { latestDownloads = downloads; const nextSnapshot: Record = {}; for (const download of downloads) { - if (!isPersistableStatus(download.status)) continue; + if (!isPersistableDownload(download)) continue; // Addon-enqueued downloads can't be restored (owning addon session is gone). if (download.isAddonDownload) continue; const serialized = JSON.stringify(download); diff --git a/application/src/frontend/lib/recovery/failedSetups.ts b/application/src/frontend/lib/recovery/failedSetups.ts index 79e8341d..891788c3 100644 --- a/application/src/frontend/lib/recovery/failedSetups.ts +++ b/application/src/frontend/lib/recovery/failedSetups.ts @@ -2,6 +2,7 @@ import type { SetupCommandData } from '@ogi-sdk/connect'; import { FileSystemError, formatError } from '@ogi-sdk/errors'; import { createLogger, LOGGER_PREFIXES } from '@ogi-sdk/logger'; import { Effect, Schedule } from 'effect'; +import { get } from 'svelte/store'; import { electronRpc } from '@/frontend/lib/electron-rpc'; import { unrarAndReturnOutputDir, @@ -46,6 +47,13 @@ export function loadFailedSetups() { ) ), Effect.map((files) => { + // Pending recoveries share this directory; hide entries whose download + // is still live in this session so they only surface after a crash. + const activeDownloadIds = new Set( + get(currentDownloads) + .filter((download) => download.status !== 'error') + .map((download) => download.id) + ); const byDownloadId = new Map(); for (const file of files) { if (!file.endsWith('.json')) continue; @@ -54,7 +62,7 @@ export function loadFailedSetups() { window.electronAPI.fs.read(`${FAILED_SETUPS_DIR}/${file}`) ) as FailedSetup; const key = setup.downloadInfo?.id ?? setup.id; - if (!key) continue; + if (!key || activeDownloadIds.has(key)) continue; const existing = byDownloadId.get(key); if (!existing || (setup.timestamp ?? 0) > (existing.timestamp ?? 0)) { byDownloadId.set(key, setup); @@ -114,6 +122,36 @@ export function saveFailedSetup(setupInfo: { } } +/** + * Writes a recovery file to disk without surfacing it in the failed-setups + * store. Saved before post-download processing (moving files, extraction) so + * closing the app mid-processing leaves a recoverable entry on next launch + * instead of forcing a re-download. Deleted once setup completes. + */ +export function savePendingRecovery(setupInfo: { + downloadInfo: DownloadStatusAndInfo; + setupData: SetupCommandData; + should: 'call-addon' | 'call-unrar' | 'call-unzip'; +}): void { + try { + ensureFailedSetupsDir(); + const id = setupInfo.downloadInfo.id; + const saved: FailedSetup = { + id, + timestamp: Date.now(), + ...setupInfo, + error: 'The app was closed before setup could finish.', + retryCount: 0, + }; + window.electronAPI.fs.write( + failedSetupPath(id), + JSON.stringify(saved, null, 2) + ); + } catch (error) { + logger.sync.error('Failed to save pending recovery:', error); + } +} + function updateRetry(failedSetup: FailedSetup, error: unknown): void { const updated = { ...failedSetup, diff --git a/application/src/frontend/managers/DownloadManager.svelte b/application/src/frontend/managers/DownloadManager.svelte index 35c151a8..ff555f8a 100644 --- a/application/src/frontend/managers/DownloadManager.svelte +++ b/application/src/frontend/managers/DownloadManager.svelte @@ -1,5 +1,5 @@