diff --git a/src/main/controllers/sessions-controller/default-session/index.ts b/src/main/controllers/sessions-controller/default-session/index.ts index 4d0c1638e..10edaaa9f 100644 --- a/src/main/controllers/sessions-controller/default-session/index.ts +++ b/src/main/controllers/sessions-controller/default-session/index.ts @@ -3,6 +3,7 @@ import { registerProtocolsWithSession } from "../protocols"; import { app, session } from "electron"; import { setupInterceptRules } from "@/controllers/sessions-controller/intercept-rules"; import { registerPreloadScripts } from "@/controllers/sessions-controller/preload-scripts"; +import { registerDownloadHandler } from "@/modules/downloads/handler"; function initializeDefaultSession() { const defaultSession = session.defaultSession; @@ -11,6 +12,9 @@ function initializeDefaultSession() { setupInterceptRules(defaultSession); registerPreloadScripts(defaultSession); + + // Register download handler for Chrome-like .crdownload behavior + registerDownloadHandler(defaultSession); } export let isDefaultSessionReady = false; diff --git a/src/main/controllers/sessions-controller/handlers/index.ts b/src/main/controllers/sessions-controller/handlers/index.ts index 298640b87..2e41f0a83 100644 --- a/src/main/controllers/sessions-controller/handlers/index.ts +++ b/src/main/controllers/sessions-controller/handlers/index.ts @@ -1,8 +1,12 @@ import { debugPrint } from "@/modules/output"; +import { registerDownloadHandler } from "@/modules/downloads/handler"; import { setAlwaysOpenExternal, shouldAlwaysOpenExternal } from "@/saving/open-external"; import { app, dialog, OpenExternalPermissionRequest, type Session } from "electron"; export function registerHandlersWithSession(session: Session) { + // Register download handler for Chrome-like .crdownload behavior + registerDownloadHandler(session); + session.setPermissionRequestHandler(async (webContents, permission, callback, details) => { debugPrint("PERMISSIONS", "permission request", webContents?.getURL() || "unknown-url", permission); diff --git a/src/main/modules/downloads/handler.ts b/src/main/modules/downloads/handler.ts new file mode 100644 index 000000000..3bce0ce00 --- /dev/null +++ b/src/main/modules/downloads/handler.ts @@ -0,0 +1,517 @@ +/** + * Chrome-like download handler with .crdownload temporary files. + * + * Flow: + * 1. Start download to `Downloads/Unconfirmed {id}.crdownload` (visible temp file). + * 2. Show save dialog to user (async, doesn't block download). + * 3. When user confirms: + * - If final location is in Downloads folder → keep using same file + * - If final location is different folder → move temp file there + * 4. On completion: rename `.crdownload` to final filename. + */ + +import { app, dialog, type DownloadItem, type Session, type WebContents } from "electron"; +import path from "path"; +import fs from "fs/promises"; +import { debugError, debugPrint } from "@/modules/output"; +import { browserWindowsController } from "@/controllers/windows-controller/interfaces/browser"; + +// Conditionally import macOS progress module +type MacOSProgress = typeof import("./macos-progress"); +let macosProgress: MacOSProgress | null = null; +async function ensureMacosProgressModule(): Promise { + if (process.platform !== "darwin") return null; + if (macosProgress) return macosProgress; + try { + const mod = await import("./macos-progress"); + macosProgress = mod; + return mod; + } catch (err) { + debugError("DOWNLOADS", "Failed to load macOS progress module:", err); + return null; + } +} +ensureMacosProgressModule(); + +/** + * How we moved the .crdownload file to the user's chosen directory. + * - `same-dir` — final location is same directory, no move needed + * - `moved` — file renamed to new directory successfully + * - `hardlink` / `symlink` — two paths point at same bytes (fallback for cross-device) + * - `placeholder` — decoy empty file only (last resort) + * - `failed` — could not move; download stays in original location + */ +type MirrorKind = "same-dir" | "moved" | "hardlink" | "symlink" | "placeholder" | "failed"; + +interface DownloadMetadata { + /** Where the bytes live *right now* (Downloads temp, or moved to final dir). */ + crdownloadPath: string; + finalPath: string | null; // null until user confirms save dialog + /** True when the chosen path existed at dialog-confirm time and the user approved replacing it. */ + overwriteApproved: boolean; + progressId: string | null; + lastUpdate: number; + lastBytes: number; + initialTotalBytes: number; + /** Ensures move runs once, on first `progressing` tick after user confirms. */ + mirrorSetup: boolean; + mirrorKind?: MirrorKind; + /** True once the user confirms the save dialog. Events before this are handled immediately. */ + saveConfirmed: boolean; + /** True once the user dismisses the save dialog without choosing a destination. */ + saveRejected: boolean; + /** Download finished/cancelled before user confirmed save dialog. */ + earlyCompletion?: { state: "completed" | "cancelled" | "interrupted" }; +} + +const activeDownloads = new Map(); + +async function pathExists(filePath: string): Promise { + try { + await fs.access(filePath); + return true; + } catch { + return false; + } +} + +function generateCrdownloadNumber(): string { + return Math.floor(100000 + Math.random() * 900000).toString(); +} + +/** Visible basename only, e.g. `Unconfirmed 685304.crdownload`. */ +function generateCrdownloadBasename(): string { + return `Unconfirmed ${generateCrdownloadNumber()}.crdownload`; +} + +/** + * Move the in-progress download to the user's chosen directory. + * Tries cheapest/best first; `rename` fails across volumes (`EXDEV`), so we fall back to links. + */ +async function moveCrdownloadToFinalDir( + currentPath: string, + finalDir: string, + crdownloadBasename: string +): Promise<{ kind: MirrorKind; newPath: string }> { + const targetPath = path.join(finalDir, crdownloadBasename); + + // Same directory - no move needed + if (path.dirname(currentPath) === finalDir) { + return { kind: "same-dir", newPath: currentPath }; + } + + // Remove existing file at target if it exists + if (await pathExists(targetPath)) { + try { + await fs.unlink(targetPath); + } catch { + /* ignore */ + } + } + + // Same volume: one inode moves to target; open FD from Chromium keeps working. + try { + await fs.rename(currentPath, targetPath); + return { kind: "moved", newPath: targetPath }; + } catch { + /* continue */ + } + + // Same volume, second name for the same inode. + try { + await fs.link(currentPath, targetPath); + return { kind: "hardlink", newPath: targetPath }; + } catch { + /* continue */ + } + + // Cross-volume: symlink to absolute path. + try { + await fs.symlink(currentPath, targetPath); + return { kind: "symlink", newPath: targetPath }; + } catch { + /* continue */ + } + + // Last resort: empty decoy at target path. + try { + await fs.writeFile(targetPath, ""); + return { kind: "placeholder", newPath: currentPath }; + } catch (err) { + debugError("DOWNLOADS", "Could not move .crdownload to user path:", err); + return { kind: "failed", newPath: currentPath }; + } +} + +/** Removes the symlink/hardlink/placeholder if one was created. */ +async function removeSecondaryPath(primaryPath: string, secondaryPath: string): Promise { + if (primaryPath === secondaryPath) return; + try { + if (await pathExists(secondaryPath)) { + const st = await fs.lstat(secondaryPath); + if (st.isSymbolicLink() || st.isFile()) { + await fs.unlink(secondaryPath); + } + } + } catch (err) { + debugError("DOWNLOADS", "Failed to remove secondary .crdownload path:", err); + } +} + +/** Helper to check if mirrorKind requires secondary path cleanup. */ +function needsSecondaryCleanup(kind?: MirrorKind): boolean { + return !!(kind && kind !== "same-dir" && kind !== "moved" && kind !== "failed"); +} + +/** Clean up secondary path if one was created (hardlink/symlink/placeholder). */ +async function cleanupSecondaryPath(meta: DownloadMetadata, crdownloadBasename: string): Promise { + if (needsSecondaryCleanup(meta.mirrorKind) && meta.finalPath) { + const secondaryPath = path.join(path.dirname(meta.finalPath), crdownloadBasename); + await removeSecondaryPath(meta.crdownloadPath, secondaryPath); + } +} + +/** Complete or cancel macOS progress indicator. */ +function finalizeMacProgress(mp: MacOSProgress | null, progressId: string | null, completed: boolean): void { + if (!mp || !progressId) return; + if (completed) { + mp.completeFileProgress(progressId); + } else { + mp.cancelFileProgress(progressId); + } +} + +/** Delete a file safely with error logging. */ +async function deleteFile(filePath: string, description: string): Promise { + try { + if (await pathExists(filePath)) { + await fs.unlink(filePath); + debugPrint("DOWNLOADS", `Deleted ${description}: ${filePath}`); + } + } catch (err) { + debugError("DOWNLOADS", `Failed to delete ${description}:`, err); + } +} + +function getPathParts(filePath: string): { dir: string; ext: string; base: string } { + const parsed = path.parse(filePath); + return { dir: parsed.dir, ext: parsed.ext, base: parsed.name }; +} + +async function getAvailableFinalPath(filePath: string): Promise { + const { dir, ext, base } = getPathParts(filePath); + + let index = 1; + let candidatePath = path.join(dir, `${base} (${index})${ext}`); + while (await pathExists(candidatePath)) { + index += 1; + candidatePath = path.join(dir, `${base} (${index})${ext}`); + } + + return candidatePath; +} + +/** Move .crdownload to final path (rename or copy+delete). */ +async function moveTempToFinal( + crdownloadPath: string, + finalPath: string, + overwriteApproved: boolean +): Promise<{ success: boolean; finalPath: string }> { + let targetPath = finalPath; + const targetExists = await pathExists(finalPath); + + if (targetExists && overwriteApproved) { + try { + await fs.unlink(finalPath); + } catch { + /* ignore */ + } + } else if (targetExists) { + targetPath = await getAvailableFinalPath(finalPath); + debugPrint("DOWNLOADS", `Final path became occupied, using alternate path: ${targetPath}`); + } + + // Try rename first (fastest) + try { + await fs.rename(crdownloadPath, targetPath); + debugPrint("DOWNLOADS", `Moved to final path: ${targetPath}`); + return { success: true, finalPath: targetPath }; + } catch { + // Fall back to copy+delete for cross-device moves + try { + await fs.copyFile(crdownloadPath, targetPath); + await fs.unlink(crdownloadPath); + debugPrint("DOWNLOADS", `Copied to final path: ${targetPath}`); + return { success: true, finalPath: targetPath }; + } catch (copyErr) { + debugError("DOWNLOADS", `Failed to move download:`, copyErr); + return { success: false, finalPath: targetPath }; + } + } +} + +async function cleanupRejectedDownload( + meta: DownloadMetadata, + mp: MacOSProgress | null, + crdownloadBasename: string, + state: "completed" | "cancelled" | "interrupted" +): Promise { + finalizeMacProgress(mp, meta.progressId, false); + await cleanupSecondaryPath(meta, crdownloadBasename); + + const description = state === "completed" ? "completed download after user cancelled" : "partial download"; + await deleteFile(meta.crdownloadPath, description); +} + +/** Update macOS progress with current download stats. */ +function updateMacProgress(meta: DownloadMetadata, receivedBytes: number, totalBytes: number): void { + if (!macosProgress || !meta.progressId) return; + + macosProgress.updateFileProgress(meta.progressId, receivedBytes); + + // Update total if we didn't have it initially + if (totalBytes > 0 && meta.initialTotalBytes === 0) { + macosProgress.updateFileProgressTotal(meta.progressId, totalBytes); + meta.initialTotalBytes = totalBytes; + } + + // Throttle derived stats (speed/ETA) to avoid hammering AppKit + const now = Date.now(); + const timeDelta = (now - meta.lastUpdate) / 1000; + if (timeDelta > 0.5) { + const bytesDelta = receivedBytes - meta.lastBytes; + const bytesPerSecond = bytesDelta / timeDelta; + macosProgress.updateFileProgressThroughput(meta.progressId, bytesPerSecond); + + if (bytesPerSecond > 0 && totalBytes > 0) { + const remainingBytes = totalBytes - receivedBytes; + const secondsRemaining = remainingBytes / bytesPerSecond; + macosProgress.updateFileProgressEstimatedTime(meta.progressId, secondsRemaining); + } + + meta.lastUpdate = now; + meta.lastBytes = receivedBytes; + } +} + +/** + * Handles download completion/cancellation logic. + * Separated so it can be called both immediately (if user confirmed) or deferred (if not). + */ +async function handleDownloadCompletion( + _item: DownloadItem, + meta: DownloadMetadata, + state: "completed" | "cancelled" | "interrupted", + mp: MacOSProgress | null, + crdownloadBasename: string +): Promise { + debugPrint("DOWNLOADS", `Download ${state}: ${meta.crdownloadPath}`); + + if (state === "completed") { + finalizeMacProgress(mp, meta.progressId, true); + + // Only move to final path if user confirmed save dialog + if (meta.saveConfirmed && meta.finalPath) { + await cleanupSecondaryPath(meta, crdownloadBasename); + const result = await moveTempToFinal(meta.crdownloadPath, meta.finalPath, meta.overwriteApproved); + if (result.success) { + meta.finalPath = result.finalPath; + } + } else { + // Download completed before user chose save location; leave temp file + debugPrint("DOWNLOADS", `No save location chosen yet`); + } + } else if (state === "cancelled") { + finalizeMacProgress(mp, meta.progressId, false); + await cleanupSecondaryPath(meta, crdownloadBasename); + await deleteFile(meta.crdownloadPath, "partial download"); + } else if (state === "interrupted") { + finalizeMacProgress(mp, meta.progressId, false); + // Leave partial files on disk for recovery; only remove secondary path if present + await cleanupSecondaryPath(meta, crdownloadBasename); + } +} + +/** Main `will-download` handler: sync `setSavePath`, async dialog and filesystem work, then event-driven completion. */ +export function handleDownload(_webContents: WebContents, item: DownloadItem): void { + const suggestedFilename = item.getFilename(); + const downloadsDir = app.getPath("downloads"); + const defaultPath = path.join(downloadsDir, suggestedFilename); + + // Generate a temporary crdownload file like how Chromium does it. + const crdownloadBasename = generateCrdownloadBasename(); + const crdownloadPath = path.join(downloadsDir, crdownloadBasename); + + debugPrint("DOWNLOADS", `Download requested: ${suggestedFilename}`); + debugPrint("DOWNLOADS", ` temp file: ${crdownloadPath}`); + + // Electron requires `setSavePath` before this handler returns. + item.setSavePath(crdownloadPath); + + const window = browserWindowsController.getWindowFromWebContents(_webContents); + if (!window) { + item.cancel(); + return; + } + + // Create metadata IMMEDIATELY so events can be processed even before save dialog completes. + const totalBytes = item.getTotalBytes(); + const metadata: DownloadMetadata = { + crdownloadPath, + finalPath: null, // Will be set after save dialog + overwriteApproved: false, + progressId: null, // Will be set after macOS progress loads + lastUpdate: Date.now(), + lastBytes: 0, + initialTotalBytes: totalBytes, + mirrorSetup: false, + saveConfirmed: false, + saveRejected: false + }; + activeDownloads.set(item, metadata); + + // Dialog + NSProgress cannot block the synchronous `will-download` return path. + void (async () => { + const mp = await ensureMacosProgressModule(); + + let progressId: string | null = null; + if (mp) { + progressId = mp.createFileProgress(crdownloadPath, totalBytes > 0 ? totalBytes : 0, () => { + debugPrint("DOWNLOADS", `Cancel requested from Finder for: ${suggestedFilename}`); + item.cancel(); + }); + debugPrint("DOWNLOADS", `macOS progress created: ${progressId}`); + metadata.progressId = progressId; + } + + const { filePath: chosenPath, canceled } = await dialog.showSaveDialog(window.browserWindow, { + defaultPath, + properties: ["createDirectory", "showOverwriteConfirmation"] + }); + + if (canceled || !chosenPath) { + debugPrint("DOWNLOADS", `Download cancelled by user: ${suggestedFilename}`); + metadata.saveRejected = true; + + // If the download already finished before the dialog was dismissed, clean it up now. + if (metadata.earlyCompletion) { + await cleanupRejectedDownload(metadata, mp, crdownloadBasename, metadata.earlyCompletion.state); + activeDownloads.delete(item); + } else { + item.cancel(); + } + return; + } + + const finalPath = chosenPath; + debugPrint("DOWNLOADS", `User chose final path: ${finalPath}`); + + // Update metadata with final path and mark as confirmed + metadata.finalPath = finalPath; + metadata.overwriteApproved = await pathExists(finalPath); + metadata.saveConfirmed = true; + + // If download already completed/cancelled before dialog finished, handle it now + if (metadata.earlyCompletion) { + debugPrint("DOWNLOADS", `Handling early completion (${metadata.earlyCompletion.state})`); + await handleDownloadCompletion(item, metadata, metadata.earlyCompletion.state, mp, crdownloadBasename); + activeDownloads.delete(item); + } + })(); + + item.on("updated", (_event, state) => { + const meta = activeDownloads.get(item); + if (!meta) return; + + // Only move file if user has confirmed save location and file exists + if (state === "progressing" && !meta.mirrorSetup && meta.saveConfirmed && meta.finalPath) { + meta.mirrorSetup = true; + void (async () => { + if (!(await pathExists(meta.crdownloadPath))) { + meta.mirrorSetup = false; + return; + } + const finalDir = path.dirname(meta.finalPath!); + const originalPath = meta.crdownloadPath; + const result = await moveCrdownloadToFinalDir(meta.crdownloadPath, finalDir, crdownloadBasename); + meta.mirrorKind = result.kind; + + debugPrint("DOWNLOADS", `In-progress .crdownload (${result.kind}): ${result.newPath}`); + + // Only update macOS progress path if we successfully MOVED the file (not hardlink/symlink/placeholder) + if (result.kind === "moved") { + meta.crdownloadPath = result.newPath; + + // Verify file exists at new location before updating progress + if (await pathExists(result.newPath)) { + if (macosProgress && meta.progressId) { + debugPrint("DOWNLOADS", `Recreating macOS progress from ${originalPath} to ${result.newPath}`); + // Recreate progress at new location (more reliable than updating path) + const newProgressId = macosProgress.recreateFileProgressAtPath(meta.progressId, result.newPath, () => { + debugPrint("DOWNLOADS", `Cancel requested from Finder for: ${item.getFilename()}`); + item.cancel(); + }); + if (newProgressId) { + meta.progressId = newProgressId; + } + } + } else { + debugError("DOWNLOADS", `File doesn't exist at new path after move: ${result.newPath}`); + } + } else if (result.kind === "hardlink" || result.kind === "symlink") { + // Keep tracking original file, but user sees progress on the link in final directory + debugPrint("DOWNLOADS", `Keeping macOS progress on original file: ${meta.crdownloadPath}`); + } + })(); + } + + if (state === "progressing") { + const receivedBytes = item.getReceivedBytes(); + const total = item.getTotalBytes(); + + updateMacProgress(meta, receivedBytes, total); + + if (total > 0) { + const percent = Math.round((receivedBytes / total) * 100); + debugPrint("DOWNLOADS", `Progress: ${percent}% (${receivedBytes}/${total} bytes)`); + } + } else if (state === "interrupted") { + debugPrint("DOWNLOADS", `Download interrupted: ${meta.crdownloadPath}`); + } + }); + + item.once("done", async (_event, state) => { + const meta = activeDownloads.get(item); + if (!meta) return; + + if (meta.saveRejected) { + debugPrint("DOWNLOADS", `Download finished (${state}) after save dialog rejection, cleaning up`); + activeDownloads.delete(item); + const mp = await ensureMacosProgressModule(); + await cleanupRejectedDownload(meta, mp, crdownloadBasename, state); + return; + } + + // If save dialog hasn't been confirmed yet, mark as early completion + // The async dialog handler will process it when ready + if (!meta.saveConfirmed) { + debugPrint("DOWNLOADS", `Download finished (${state}) before save dialog confirmed, deferring cleanup`); + meta.earlyCompletion = { state }; + return; + } + + // Save confirmed, handle immediately + activeDownloads.delete(item); + const mp = await ensureMacosProgressModule(); + await handleDownloadCompletion(item, meta, state, mp, crdownloadBasename); + }); +} + +export function registerDownloadHandler(session: Session): void { + session.on("will-download", (_event, item, webContents) => { + // Register per item inside `handleDownload` (`on` / `once` on `DownloadItem`). + handleDownload(webContents, item); + }); + + debugPrint("DOWNLOADS", "Download handler registered for session"); +} diff --git a/src/main/modules/downloads/macos-progress.ts b/src/main/modules/downloads/macos-progress.ts new file mode 100644 index 000000000..10c195e89 --- /dev/null +++ b/src/main/modules/downloads/macos-progress.ts @@ -0,0 +1,296 @@ +/** + * macOS-specific file download progress using NSProgress. + * + * Uses the NSProgress publish API to show native download progress + * indicators on files in Finder (e.g., the progress bar on .crdownload files). + */ + +import { NSProgress, NSURL, NSNumber, type _NSProgress } from "objcjs-types/Foundation"; +import { NSProgressFileOperationKind, NSProgressKind } from "objcjs-types/Foundation"; +import { NSStringFromString } from "objcjs-types/helpers"; +import { debugError, debugPrint } from "@/modules/output"; + +// Track active progress instances so we can unpublish them +const activeProgressMap = new Map(); + +// Track cancel callbacks +const cancelCallbackMap = new Map void>(); + +/** + * Create and publish an NSProgress for a file download. + * This shows the native macOS progress bar on the file in Finder. + * + * @param filePath - The absolute path to the file being downloaded + * @param totalBytes - Total size of the download in bytes + * @param onCancel - Callback invoked when user clicks cancel in Finder + * @returns A unique ID to reference this progress, or null on failure + */ +export function createFileProgress(filePath: string, totalBytes: number, onCancel?: () => void): string | null { + try { + // Generate a unique ID first (needed for the closure) + const progressId = `${filePath}-${Date.now()}`; + + // Create a discrete progress (not attached to any parent) + const progress = NSProgress.discreteProgressWithTotalUnitCount$(totalBytes); + + // Configure as a file download + progress.setKind$(NSStringFromString(NSProgressKind.File)); + progress.setFileOperationKind$(NSStringFromString(NSProgressFileOperationKind.Downloading)); + + // Set the file URL + const nsPath = NSStringFromString(filePath); + const fileURL = NSURL.fileURLWithPath$(nsPath); + progress.setFileURL$(fileURL); + + // Set initial completed count to 0 + progress.setCompletedUnitCount$(0); + + // Make cancellable but not pausable (matching typical download behavior) + progress.setCancellable$(true); + progress.setPausable$(false); + + // Set up cancellation handler if callback provided + if (onCancel) { + cancelCallbackMap.set(progressId, onCancel); + progress.setCancellationHandler$(() => { + debugPrint("DOWNLOADS", `macOS: cancel requested from Finder for ${progressId}`); + const callback = cancelCallbackMap.get(progressId); + if (callback) { + callback(); + } + }); + } + + // Publish the progress so Finder can observe it + progress.publish(); + + // Store the progress + activeProgressMap.set(progressId, progress); + + debugPrint("DOWNLOADS", `macOS: created progress for ${filePath}, total: ${totalBytes} bytes`); + return progressId; + } catch (err) { + debugError("DOWNLOADS", "macOS: createFileProgress failed:", err); + return null; + } +} + +/** + * Update the progress of a file download. + * + * @param progressId - The ID returned from createFileProgress + * @param completedBytes - Number of bytes downloaded so far + */ +export function updateFileProgress(progressId: string, completedBytes: number): void { + try { + const progress = activeProgressMap.get(progressId); + if (!progress) { + debugError("DOWNLOADS", `macOS: no progress found for ID ${progressId}`); + return; + } + + progress.setCompletedUnitCount$(completedBytes); + } catch (err) { + debugError("DOWNLOADS", "macOS: updateFileProgress failed:", err); + } +} + +/** + * Update the total size of a download (useful when total size becomes known later). + * + * @param progressId - The ID returned from createFileProgress + * @param totalBytes - Total size of the download in bytes + */ +export function updateFileProgressTotal(progressId: string, totalBytes: number): void { + try { + const progress = activeProgressMap.get(progressId); + if (!progress) { + debugError("DOWNLOADS", `macOS: no progress found for ID ${progressId}`); + return; + } + + progress.setTotalUnitCount$(totalBytes); + } catch (err) { + debugError("DOWNLOADS", "macOS: updateFileProgressTotal failed:", err); + } +} + +/** + * Set throughput (download speed) for display. + * + * @param progressId - The ID returned from createFileProgress + * @param bytesPerSecond - Current download speed in bytes per second + */ +export function updateFileProgressThroughput(progressId: string, bytesPerSecond: number): void { + try { + const progress = activeProgressMap.get(progressId); + if (!progress) { + return; + } + + const throughput = NSNumber.numberWithDouble$(bytesPerSecond); + progress.setThroughput$(throughput); + } catch (err) { + debugError("DOWNLOADS", "macOS: updateFileProgressThroughput failed:", err); + } +} + +/** + * Set estimated time remaining. + * + * @param progressId - The ID returned from createFileProgress + * @param seconds - Estimated seconds remaining + */ +export function updateFileProgressEstimatedTime(progressId: string, seconds: number): void { + try { + const progress = activeProgressMap.get(progressId); + if (!progress) { + return; + } + + const estimatedTime = NSNumber.numberWithDouble$(seconds); + progress.setEstimatedTimeRemaining$(estimatedTime); + } catch (err) { + debugError("DOWNLOADS", "macOS: updateFileProgressEstimatedTime failed:", err); + } +} + +/** + * Complete and unpublish a file download progress. + * + * @param progressId - The ID returned from createFileProgress + */ +export function completeFileProgress(progressId: string): void { + try { + const progress = activeProgressMap.get(progressId); + if (!progress) { + debugError("DOWNLOADS", `macOS: no progress found for ID ${progressId}`); + return; + } + + // Mark as complete by setting completed = total + const total = progress.totalUnitCount(); + progress.setCompletedUnitCount$(total); + + // Clear the cancellation handler + progress.setCancellationHandler$(null); + cancelCallbackMap.delete(progressId); + + // Unpublish and remove from tracking + progress.unpublish(); + activeProgressMap.delete(progressId); + + debugPrint("DOWNLOADS", `macOS: completed progress for ID ${progressId}`); + } catch (err) { + debugError("DOWNLOADS", "macOS: completeFileProgress failed:", err); + } +} + +/** + * Cancel and unpublish a file download progress. + * + * @param progressId - The ID returned from createFileProgress + */ +export function cancelFileProgress(progressId: string): void { + try { + const progress = activeProgressMap.get(progressId); + if (!progress) { + return; + } + + // Clear the cancellation handler first to avoid re-triggering + progress.setCancellationHandler$(null); + cancelCallbackMap.delete(progressId); + + // Cancel the progress + progress.cancel(); + + // Unpublish and remove from tracking + progress.unpublish(); + activeProgressMap.delete(progressId); + + debugPrint("DOWNLOADS", `macOS: cancelled progress for ID ${progressId}`); + } catch (err) { + debugError("DOWNLOADS", "macOS: cancelFileProgress failed:", err); + } +} + +/** + * Update the file URL for a progress (used when renaming from .crdownload to final name). + * + * @param progressId - The ID returned from createFileProgress + * @param newFilePath - The new absolute path to the file + */ +export function updateFileProgressPath(progressId: string, newFilePath: string): void { + try { + const progress = activeProgressMap.get(progressId); + if (!progress) { + debugError("DOWNLOADS", `macOS: no progress found for ID ${progressId}`); + return; + } + + const nsPath = NSStringFromString(newFilePath); + const fileURL = NSURL.fileURLWithPath$(nsPath); + progress.setFileURL$(fileURL); + + debugPrint("DOWNLOADS", `macOS: updated progress path to ${newFilePath}`); + } catch (err) { + debugError("DOWNLOADS", "macOS: updateFileProgressPath failed:", err); + } +} + +/** + * Recreate progress at a new location (more reliable than updateFileProgressPath). + * Cancels old progress and creates new one at new path with current progress state. + * + * @param progressId - The ID returned from createFileProgress + * @param newFilePath - The new absolute path to the file + * @param onCancel - Callback invoked when user clicks cancel in Finder + * @returns New progress ID, or null on failure + */ +export function recreateFileProgressAtPath( + progressId: string, + newFilePath: string, + onCancel?: () => void +): string | null { + try { + const oldProgress = activeProgressMap.get(progressId); + if (!oldProgress) { + debugError("DOWNLOADS", `macOS: no progress found for ID ${progressId}`); + return null; + } + + // Capture current state before cancelling + const completedBytes = oldProgress.completedUnitCount(); + const totalBytes = oldProgress.totalUnitCount(); + const throughput = oldProgress.throughput(); + const estimatedTime = oldProgress.estimatedTimeRemaining(); + + // Cancel old progress (unpublishes it) + cancelFileProgress(progressId); + + // Create new progress at new location + const newProgressId = createFileProgress(newFilePath, totalBytes, onCancel); + if (!newProgressId) { + return null; + } + + // Restore state + const newProgress = activeProgressMap.get(newProgressId); + if (newProgress) { + newProgress.setCompletedUnitCount$(completedBytes); + if (throughput) { + newProgress.setThroughput$(throughput); + } + if (estimatedTime) { + newProgress.setEstimatedTimeRemaining$(estimatedTime); + } + } + + debugPrint("DOWNLOADS", `macOS: recreated progress at ${newFilePath}`); + return newProgressId; + } catch (err) { + debugError("DOWNLOADS", "macOS: recreateFileProgressAtPath failed:", err); + return null; + } +} diff --git a/src/main/modules/output.ts b/src/main/modules/output.ts index 87cb4a658..6d906b0e9 100644 --- a/src/main/modules/output.ts +++ b/src/main/modules/output.ts @@ -22,7 +22,8 @@ const DEBUG_AREAS = { WEB_REQUESTS_INTERCEPTION: false, // @/browser/utility/web-requests.ts WEB_REQUESTS: false, // @/browser/utility/web-requests.ts MATCH_PATTERN: false, // @/browser/utility/match-pattern.ts - WINDOWS: true // @/controllers/windows-controller + WINDOWS: true, // @/controllers/windows-controller + DOWNLOADS: true // @/modules/downloads/handler.ts } as const; export type DEBUG_AREA = keyof typeof DEBUG_AREAS;