diff --git a/.vscode/settings.json b/.vscode/settings.json index 7987f3d5..0030bc12 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -51,7 +51,9 @@ "**/.hg/**", "**/.svn/**" ], - "css.validate": false, + "css.customData": [".vscode/tailwind.css-data.json"], + "css.lint.unknownAtRules": "ignore", + "css.validate": true, "tailwindCSS.validate": true, "tailwindCSS.emmetCompletions": true, "typescript.preferences.autoImportSpecifierExcludeRegexes": [ diff --git a/.vscode/tailwind.css-data.json b/.vscode/tailwind.css-data.json new file mode 100644 index 00000000..838287ec --- /dev/null +++ b/.vscode/tailwind.css-data.json @@ -0,0 +1,45 @@ +{ + "version": 1.1, + "atDirectives": [ + { + "name": "@apply", + "description": "Inline Tailwind utility classes into a CSS rule." + }, + { + "name": "@config", + "description": "Load a legacy Tailwind JavaScript configuration file." + }, + { + "name": "@custom-variant", + "description": "Define a custom Tailwind variant." + }, + { + "name": "@plugin", + "description": "Load a legacy Tailwind JavaScript plugin." + }, + { + "name": "@reference", + "description": "Import a Tailwind stylesheet for theme and utility references without emitting CSS." + }, + { + "name": "@source", + "description": "Register source files for Tailwind class detection." + }, + { + "name": "@tailwind", + "description": "Insert a Tailwind CSS layer." + }, + { + "name": "@theme", + "description": "Define Tailwind theme variables." + }, + { + "name": "@utility", + "description": "Define a custom Tailwind utility." + }, + { + "name": "@variant", + "description": "Apply a Tailwind variant to CSS rules." + } + ] +} diff --git a/application/scripts/clean-log.py b/application/scripts/clean-log.py deleted file mode 100755 index 1cd1b5b6..00000000 --- a/application/scripts/clean-log.py +++ /dev/null @@ -1,105 +0,0 @@ -#!/usr/bin/env python3 -import re -import sys -from pathlib import Path - -NOISE_PATTERNS = [ - re.compile(r"^\[steam-integration\] (Using cached|Cached) "), - re.compile(r"^task finished "), - re.compile(r"^\[steam-integration\] dispatched response to "), - re.compile(r"^\[fatboy-unpack\] dispatched response to "), - re.compile(r"^\[steamrip-addon\] dispatched response to "), - re.compile(r"^Checking for updates for "), - re.compile(r"^\[steam-integration\] Checking for updates for "), - re.compile(r"^Setting events-available to "), -] - -IMPORTANT_PATTERNS = [ - re.compile(r"Error|UnhandledPromiseRejection|fatal:|failed|Failed|ENOENT|AxiosError|NetworkError|Cloudflare|not a git repository|readyState|timeout|SIGTERM|AbortError", re.I), - re.compile(r"Addon .*updates?|updated successfully|addons failed", re.I), - re.compile(r"Starting addon|Stopping addon|All addons started|Stopping server|Addon Server", re.I), -] - -DUMP_START_PATTERNS = [ - re.compile(r"^\[fatboy-unpack\] directHeader HTMLHeadingElement"), - re.compile(r"^\s+\[Symbol\(impl\)\]:"), -] - -DUMP_END_PATTERNS = [ - re.compile(r"^\[Error:"), - re.compile(r"^\[[^\]]+\]"), - re.compile(r"^task finished "), - re.compile(r"^Sent app details"), - re.compile(r"^dispatched response"), -] - -STACK_CONTINUATION = re.compile(r"^\s+(at |code:|killed:|signal:|cmd:|errno:|syscall:|path:|message:|name:|stack:|isAxiosError:|})") - -def is_noise(line: str) -> bool: - return any(p.search(line) for p in NOISE_PATTERNS) - -def is_important(line: str) -> bool: - return any(p.search(line) for p in IMPORTANT_PATTERNS) - -def clean_log(src: Path, dst: Path) -> None: - lines = src.read_text(errors="replace").splitlines() - out = [] - skipped_noise = 0 - skipped_dump = 0 - in_dump = False - previous_kept_was_blank = False - - for i, line in enumerate(lines, start=1): - if in_dump: - if any(p.search(line) for p in DUMP_END_PATTERNS): - out.append(f"[clean-log] omitted {skipped_dump} lines of verbose object dump") - out.append("") - in_dump = False - skipped_dump = 0 - # fall through and process this line normally - else: - skipped_dump += 1 - continue - - if any(p.search(line) for p in DUMP_START_PATTERNS): - out.append(f"{i}: {line}") - in_dump = True - skipped_dump = 0 - continue - - if is_noise(line) and not is_important(line): - skipped_noise += 1 - continue - - keep = is_important(line) or STACK_CONTINUATION.search(line) or line.strip() == "" or line.startswith("From ") or line.startswith("Updating ") or line.startswith("Fast-forward") - - if keep: - if line.strip() == "": - if previous_kept_was_blank: - continue - previous_kept_was_blank = True - out.append("") - else: - previous_kept_was_blank = False - out.append(f"{i}: {line}") - - if in_dump: - out.append(f"[clean-log] omitted {skipped_dump} lines of verbose object dump") - - header = [ - f"Cleaned log: {src}", - f"Original lines: {len(lines)}", - f"Removed low-value repeated lines: {skipped_noise}", - "", - ] - dst.write_text("\n".join(header + out) + "\n") - -if __name__ == "__main__": - if len(sys.argv) not in (2, 3): - print(f"Usage: {sys.argv[0]} INPUT_LOG [OUTPUT_LOG]", file=sys.stderr) - sys.exit(2) - - src = Path(sys.argv[1]).expanduser() - dst = Path(sys.argv[2]).expanduser() if len(sys.argv) == 3 else src.with_suffix(src.suffix + ".cleaned") - clean_log(src, dst) - print(dst) 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/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/steam-installation.ts b/application/src/electron/lib/steam-installation.ts index 794a6bf2..a41ebc7e 100644 --- a/application/src/electron/lib/steam-installation.ts +++ b/application/src/electron/lib/steam-installation.ts @@ -513,6 +513,7 @@ export const SteamRepositoryLive = ( const configExisted = config.existed; let shortcutsCommitted = false; let configCommitted = false; + let shortcutsBackupWritten = false; const restore = ( filePath: string, fileExisted: boolean, @@ -553,6 +554,13 @@ export const SteamRepositoryLive = ( yield* writeFileAtomic(configPath, options.configSource); configCommitted = true; } + if (existed && !shortcutsBackupWritten) { + yield* writeFileAtomic( + `${shortcutsPath}.ogi-backup`, + original + ); + shortcutsBackupWritten = true; + } const written = yield* Effect.either( write(shortcutsPath, updatedRoot) ); 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/src/electron/tsconfig.json b/application/src/electron/tsconfig.json index 41d7efbb..60cd82dc 100644 --- a/application/src/electron/tsconfig.json +++ b/application/src/electron/tsconfig.json @@ -2,7 +2,7 @@ "compileOnSave": true, "compilerOptions": { "outDir": "../../build", - "baseUrl": ".", + "rootDir": "..", "typeRoots": ["node_modules/@types"], "target": "ES2022", "allowJs": true, diff --git a/application/src/frontend/components/built/UpdateAppModal.svelte b/application/src/frontend/components/built/UpdateAppModal.svelte index a47c69b7..26e990a5 100644 --- a/application/src/frontend/components/built/UpdateAppModal.svelte +++ b/application/src/frontend/components/built/UpdateAppModal.svelte @@ -225,9 +225,16 @@ async function handleDownloadClick( updateVersion: updateVersion, } as SearchResultWithAddon & { isUpdate: boolean; updateVersion: string }; - const started = await runFrontendEffect( - startDownloadEffect(updateResult, appID, event).pipe( + startDownloadEffect(updateResult, appID, event) + .pipe( Effect.as(true), + Effect.tap(() => { + createNotification({ + id: Math.random().toString(36).substring(7), + message: `Starting update download for ${gameName}`, + type: 'info', + }); + }), Effect.catchAll((error) => Effect.sync(() => { logger.sync.error('Failed to start update download:', error); @@ -240,15 +247,8 @@ async function handleDownloadClick( }) ) ) - ); - if (!started) return; + .pipe(Effect.runFork); onClose(); - - createNotification({ - id: Math.random().toString(36).substring(7), - message: `Starting update download for ${gameName}`, - type: 'info', - }); } function toggleAddonCollapse(addonId: string) { 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..9f26ac62 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 once old_files staging is done and again after 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/lib/setup/serialize.ts b/application/src/frontend/lib/setup/serialize.ts new file mode 100644 index 00000000..2c82185f --- /dev/null +++ b/application/src/frontend/lib/setup/serialize.ts @@ -0,0 +1,4 @@ +/** Remove reactive proxies before addon data crosses the RPC boundary. */ +export function toSerializable(value: Value): Value { + return JSON.parse(JSON.stringify(value)) as Value; +} diff --git a/application/src/frontend/lib/setup/setup.ts b/application/src/frontend/lib/setup/setup.ts index 823622a9..6ddad510 100644 --- a/application/src/frontend/lib/setup/setup.ts +++ b/application/src/frontend/lib/setup/setup.ts @@ -17,6 +17,7 @@ import { getApp } from '@/frontend/lib/core/library'; import { updateDownloadStatus } from '@/frontend/lib/downloads/lifecycle'; import { electronRpc } from '@/frontend/lib/electron-rpc'; import { saveFailedSetup } from '@/frontend/lib/recovery/failedSetups'; +import { toSerializable } from '@/frontend/lib/setup/serialize'; import { updatesManager } from '@/frontend/states.svelte'; import { createNotification, @@ -99,9 +100,9 @@ export function createSetupPayload( ...(currentLibraryInfo ? { currentLibraryInfo } : {}), multiPartFiles: downloadedItem.downloadType === 'direct' - ? structuredClone(downloadedItem.files ?? []) + ? toSerializable(downloadedItem.files ?? []) : [], - manifest: structuredClone(downloadedItem.manifest ?? {}), + manifest: toSerializable(downloadedItem.manifest ?? {}), ...additionalData, } as SetupCommandData & { addonID: string }; } diff --git a/application/src/frontend/managers/AppUpdateManager.svelte b/application/src/frontend/managers/AppUpdateManager.svelte index c4ea7612..1f932054 100644 --- a/application/src/frontend/managers/AppUpdateManager.svelte +++ b/application/src/frontend/managers/AppUpdateManager.svelte @@ -96,37 +96,45 @@ function checkForAppUpdates(connectedAddons: AddonInfo[]) { checkableApps, ({ app, addons }) => Effect.gen(function* () { - if (addons.length > 1) { - return yield* Effect.fail( - new UpdateError({ - message: 'Multiple clients found to serve this storefront', - }) + // One addon failing must not hide updates served by the others. + for (const addon of addons) { + const update = yield* Effect.tryPromise({ + try: () => + addonServer.addon(addon.id).checkForUpdates({ + appID: app.appID, + storefront: app.storefront, + currentVersion: app.version, + }) as Promise<{ available: boolean; version: string }>, + catch: (cause) => + new UpdateError({ + message: `Failed to check for updates: ${formatError(cause)}`, + }), + }).pipe( + Effect.catchAll((error) => + logger + .error( + 'Error checking for updates for app', + app.name, + 'via', + addon.name, + error + ) + .pipe(Effect.as(undefined)) + ) ); - } - const update = yield* Effect.tryPromise({ - try: () => - addonServer.addon(addons[0].id).checkForUpdates({ + if (update?.available && runId === updateCheckRunId) { + updatesManager.addAppUpdate({ appID: app.appID, - storefront: app.storefront, - currentVersion: app.version, - }) as Promise<{ available: boolean; version: string }>, - catch: (cause) => - new UpdateError({ - message: `Failed to check for updates: ${formatError(cause)}`, - }), - }); - if (runId === updateCheckRunId && update.available) { - updatesManager.addAppUpdate({ - appID: app.appID, - name: app.name, - updateAvailable: true, - updateVersion: update.version, - }); + name: addon.name, + updateAvailable: true, + updateVersion: update.version, + }); + // The store holds one version per app; keep the first addon's + // answer instead of letting later addons silently overwrite it. + break; + } } }).pipe( - Effect.catchAll((error) => - logger.error('Error checking for updates for app', app.name, error) - ), Effect.ensuring( Effect.sync(() => { if (runId === updateCheckRunId) { diff --git a/application/src/frontend/managers/DownloadManager.svelte b/application/src/frontend/managers/DownloadManager.svelte index 35c151a8..af560939 100644 --- a/application/src/frontend/managers/DownloadManager.svelte +++ b/application/src/frontend/managers/DownloadManager.svelte @@ -1,5 +1,5 @@