From 50a633fedad83dabd58443de44538b5f43510587 Mon Sep 17 00:00:00 2001 From: Evan Date: Fri, 27 Mar 2026 18:19:19 +0000 Subject: [PATCH 01/16] feat: new download controller with finder progress bar handling --- .../controllers/downloads-controller/index.ts | 203 ++++++++++++++++++ .../downloads-controller/macos-progress.ts | 155 +++++++++++++ .../default-session/index.ts | 2 + .../sessions-controller/handlers/index.ts | 3 + src/main/modules/output.ts | 3 +- 5 files changed, 365 insertions(+), 1 deletion(-) create mode 100644 src/main/controllers/downloads-controller/index.ts create mode 100644 src/main/controllers/downloads-controller/macos-progress.ts diff --git a/src/main/controllers/downloads-controller/index.ts b/src/main/controllers/downloads-controller/index.ts new file mode 100644 index 000000000..64ad1ac77 --- /dev/null +++ b/src/main/controllers/downloads-controller/index.ts @@ -0,0 +1,203 @@ +import { app, type DownloadItem, type Session } from "electron"; +import path from "path"; +import { debugError, debugPrint } from "@/modules/output"; + +type MacOSProgressModule = typeof import("./macos-progress"); + +interface DownloadMetadata { + progressId: string | null; + savePath: string | null; + lastUpdate: number; + lastBytes: number; + initialTotalBytes: number; + syncingProgress: boolean; +} + +class DownloadsController { + private readonly activeDownloads = new WeakMap(); + private readonly registeredSessions = new WeakSet(); + private macosProgress: MacOSProgressModule | null = null; + private macosProgressLoad: Promise | null = null; + + public registerSession(session: Session): void { + if (this.registeredSessions.has(session)) { + return; + } + + session.on("will-download", (_event, item) => { + this.handleWillDownload(item); + }); + + this.registeredSessions.add(session); + debugPrint("DOWNLOADS", "Download handler registered for session"); + } + + private async ensureMacosProgressModule(): Promise { + if (process.platform !== "darwin") return null; + if (this.macosProgress) return this.macosProgress; + + if (!this.macosProgressLoad) { + this.macosProgressLoad = import("./macos-progress") + .then((module) => { + this.macosProgress = module; + return module; + }) + .catch((err) => { + debugError("DOWNLOADS", "Failed to load macOS progress module:", err); + return null; + }); + } + + return this.macosProgressLoad; + } + + private handleWillDownload(item: DownloadItem): void { + const suggestedFilename = item.getFilename(); + const defaultPath = path.join(app.getPath("downloads"), suggestedFilename); + + item.setSaveDialogOptions({ + defaultPath, + properties: ["createDirectory", "showOverwriteConfirmation"] + }); + + const metadata: DownloadMetadata = { + progressId: null, + savePath: null, + lastUpdate: Date.now(), + lastBytes: 0, + initialTotalBytes: item.getTotalBytes(), + syncingProgress: false + }; + + this.activeDownloads.set(item, metadata); + + debugPrint("DOWNLOADS", `Download requested: ${suggestedFilename}`); + + this.queueProgressSync(item, metadata); + + item.on("updated", (_event, state) => { + const current = this.activeDownloads.get(item); + if (!current) return; + + this.queueProgressSync(item, current); + + if (state === "progressing") { + this.updateMacProgress(current, item); + } else if (state === "interrupted") { + debugPrint("DOWNLOADS", `Download interrupted: ${item.getFilename()}`); + } + }); + + item.once("done", (_event, state) => { + const current = this.activeDownloads.get(item); + if (!current) return; + + this.activeDownloads.delete(item); + void this.handleDone(item, current, state); + }); + } + + private queueProgressSync(item: DownloadItem, meta: DownloadMetadata): void { + if (meta.syncingProgress) return; + + meta.syncingProgress = true; + void this.syncMacProgress(item, meta).finally(() => { + meta.syncingProgress = false; + }); + } + + private async syncMacProgress(item: DownloadItem, meta: DownloadMetadata): Promise { + const mp = await this.ensureMacosProgressModule(); + if (!mp) return; + + const savePath = this.getSavePath(item); + if (!savePath) return; + + if (!meta.progressId) { + meta.savePath = savePath; + meta.initialTotalBytes = item.getTotalBytes(); + meta.progressId = mp.createFileProgress(savePath, meta.initialTotalBytes, () => { + debugPrint("DOWNLOADS", `Cancel requested from Finder for: ${item.getFilename()}`); + item.cancel(); + }); + return; + } + + if (meta.savePath && meta.savePath !== savePath) { + const nextProgressId = mp.recreateFileProgressAtPath(meta.progressId, savePath, () => { + debugPrint("DOWNLOADS", `Cancel requested from Finder for: ${item.getFilename()}`); + item.cancel(); + }); + + if (nextProgressId) { + meta.progressId = nextProgressId; + } + meta.savePath = savePath; + } + } + + private updateMacProgress(meta: DownloadMetadata, item: DownloadItem): void { + if (!this.macosProgress || !meta.progressId) return; + + const receivedBytes = item.getReceivedBytes(); + const totalBytes = item.getTotalBytes(); + + this.macosProgress.updateFileProgress(meta.progressId, receivedBytes); + + if (totalBytes > 0 && totalBytes !== meta.initialTotalBytes) { + this.macosProgress.updateFileProgressTotal(meta.progressId, totalBytes); + meta.initialTotalBytes = totalBytes; + } + + const now = Date.now(); + const timeDelta = (now - meta.lastUpdate) / 1000; + + if (timeDelta >= 0.5) { + const bytesDelta = receivedBytes - meta.lastBytes; + const bytesPerSecond = bytesDelta / timeDelta; + + this.macosProgress.updateFileProgressThroughput(meta.progressId, bytesPerSecond); + + if (bytesPerSecond > 0 && totalBytes > 0) { + const remainingBytes = totalBytes - receivedBytes; + const secondsRemaining = remainingBytes / bytesPerSecond; + this.macosProgress.updateFileProgressEstimatedTime(meta.progressId, secondsRemaining); + } + + meta.lastUpdate = now; + meta.lastBytes = receivedBytes; + } + } + + private async handleDone( + item: DownloadItem, + meta: DownloadMetadata, + state: "completed" | "cancelled" | "interrupted" + ): Promise { + await this.syncMacProgress(item, meta); + + if (!this.macosProgress || !meta.progressId) { + debugPrint("DOWNLOADS", `Download ${state}: ${item.getFilename()}`); + return; + } + + if (state === "completed") { + this.macosProgress.completeFileProgress(meta.progressId, item.getReceivedBytes()); + } else { + this.macosProgress.cancelFileProgress(meta.progressId); + } + + debugPrint("DOWNLOADS", `Download ${state}: ${this.getSavePath(item) ?? item.getFilename()}`); + } + + private getSavePath(item: DownloadItem): string | null { + try { + const savePath = item.getSavePath(); + return savePath || null; + } catch { + return null; + } + } +} + +export const downloadsController = new DownloadsController(); diff --git a/src/main/controllers/downloads-controller/macos-progress.ts b/src/main/controllers/downloads-controller/macos-progress.ts new file mode 100644 index 000000000..2fa2fae84 --- /dev/null +++ b/src/main/controllers/downloads-controller/macos-progress.ts @@ -0,0 +1,155 @@ +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"; + +const activeProgressMap = new Map(); +const cancelCallbackMap = new Map void>(); + +export function createFileProgress(filePath: string, totalBytes: number, onCancel?: () => void): string | null { + try { + const progressId = `${filePath}-${Date.now()}`; + const progress = NSProgress.discreteProgressWithTotalUnitCount$(Math.max(totalBytes, 0)); + + progress.setKind$(NSStringFromString(NSProgressKind.File)); + progress.setFileOperationKind$(NSStringFromString(NSProgressFileOperationKind.Downloading)); + progress.setFileURL$(NSURL.fileURLWithPath$(NSStringFromString(filePath))); + progress.setCompletedUnitCount$(0); + progress.setCancellable$(true); + progress.setPausable$(false); + + if (onCancel) { + cancelCallbackMap.set(progressId, onCancel); + progress.setCancellationHandler$(() => { + const callback = cancelCallbackMap.get(progressId); + if (callback) { + debugPrint("DOWNLOADS", `macOS: cancel requested from Finder for ${filePath}`); + callback(); + } + }); + } + + progress.publish(); + activeProgressMap.set(progressId, progress); + + debugPrint("DOWNLOADS", `macOS: created progress for ${filePath}`); + return progressId; + } catch (err) { + debugError("DOWNLOADS", "macOS: createFileProgress failed:", err); + return null; + } +} + +export function updateFileProgress(progressId: string, completedBytes: number): void { + try { + const progress = activeProgressMap.get(progressId); + if (!progress) return; + progress.setCompletedUnitCount$(Math.max(completedBytes, 0)); + } catch (err) { + debugError("DOWNLOADS", "macOS: updateFileProgress failed:", err); + } +} + +export function updateFileProgressTotal(progressId: string, totalBytes: number): void { + try { + const progress = activeProgressMap.get(progressId); + if (!progress) return; + progress.setTotalUnitCount$(Math.max(totalBytes, 0)); + } catch (err) { + debugError("DOWNLOADS", "macOS: updateFileProgressTotal failed:", err); + } +} + +export function updateFileProgressThroughput(progressId: string, bytesPerSecond: number): void { + try { + const progress = activeProgressMap.get(progressId); + if (!progress) return; + progress.setThroughput$(NSNumber.numberWithDouble$(Math.max(bytesPerSecond, 0))); + } catch (err) { + debugError("DOWNLOADS", "macOS: updateFileProgressThroughput failed:", err); + } +} + +export function updateFileProgressEstimatedTime(progressId: string, seconds: number): void { + try { + const progress = activeProgressMap.get(progressId); + if (!progress) return; + progress.setEstimatedTimeRemaining$(NSNumber.numberWithDouble$(Math.max(seconds, 0))); + } catch (err) { + debugError("DOWNLOADS", "macOS: updateFileProgressEstimatedTime failed:", err); + } +} + +export function completeFileProgress(progressId: string, completedBytes: number): void { + try { + const progress = activeProgressMap.get(progressId); + if (!progress) return; + + const finalCount = Math.max(completedBytes, progress.totalUnitCount(), 0); + progress.setTotalUnitCount$(finalCount); + progress.setCompletedUnitCount$(finalCount); + progress.setCancellationHandler$(null); + cancelCallbackMap.delete(progressId); + progress.unpublish(); + activeProgressMap.delete(progressId); + + debugPrint("DOWNLOADS", `macOS: completed progress for ID ${progressId}`); + } catch (err) { + debugError("DOWNLOADS", "macOS: completeFileProgress failed:", err); + } +} + +export function cancelFileProgress(progressId: string): void { + try { + const progress = activeProgressMap.get(progressId); + if (!progress) return; + + progress.setCancellationHandler$(null); + cancelCallbackMap.delete(progressId); + progress.cancel(); + progress.unpublish(); + activeProgressMap.delete(progressId); + + debugPrint("DOWNLOADS", `macOS: cancelled progress for ID ${progressId}`); + } catch (err) { + debugError("DOWNLOADS", "macOS: cancelFileProgress failed:", err); + } +} + +export function recreateFileProgressAtPath( + progressId: string, + newFilePath: string, + onCancel?: () => void +): string | null { + try { + const oldProgress = activeProgressMap.get(progressId); + if (!oldProgress) return null; + + const completedBytes = oldProgress.completedUnitCount(); + const totalBytes = oldProgress.totalUnitCount(); + const throughput = oldProgress.throughput(); + const estimatedTime = oldProgress.estimatedTimeRemaining(); + + cancelFileProgress(progressId); + + const newProgressId = createFileProgress(newFilePath, totalBytes, onCancel); + if (!newProgressId) return null; + + 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/controllers/sessions-controller/default-session/index.ts b/src/main/controllers/sessions-controller/default-session/index.ts index 4d0c1638e..e0a1e98da 100644 --- a/src/main/controllers/sessions-controller/default-session/index.ts +++ b/src/main/controllers/sessions-controller/default-session/index.ts @@ -1,6 +1,7 @@ import { sleep } from "@/modules/utils"; import { registerProtocolsWithSession } from "../protocols"; import { app, session } from "electron"; +import { downloadsController } from "@/controllers/downloads-controller"; import { setupInterceptRules } from "@/controllers/sessions-controller/intercept-rules"; import { registerPreloadScripts } from "@/controllers/sessions-controller/preload-scripts"; @@ -11,6 +12,7 @@ function initializeDefaultSession() { setupInterceptRules(defaultSession); registerPreloadScripts(defaultSession); + downloadsController.registerSession(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..f357168a6 100644 --- a/src/main/controllers/sessions-controller/handlers/index.ts +++ b/src/main/controllers/sessions-controller/handlers/index.ts @@ -1,8 +1,11 @@ +import { downloadsController } from "@/controllers/downloads-controller"; import { debugPrint } from "@/modules/output"; import { setAlwaysOpenExternal, shouldAlwaysOpenExternal } from "@/saving/open-external"; import { app, dialog, OpenExternalPermissionRequest, type Session } from "electron"; export function registerHandlersWithSession(session: Session) { + downloadsController.registerSession(session); + session.setPermissionRequestHandler(async (webContents, permission, callback, details) => { debugPrint("PERMISSIONS", "permission request", webContents?.getURL() || "unknown-url", permission); diff --git a/src/main/modules/output.ts b/src/main/modules/output.ts index 87cb4a658..fe3dd43a6 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: false // @/controllers/downloads-controller } as const; export type DEBUG_AREA = keyof typeof DEBUG_AREAS; From a4c620e838c3b52293afdaa9ba0ef7e8a92ecb6f Mon Sep 17 00:00:00 2001 From: Evan Date: Fri, 27 Mar 2026 18:46:15 +0000 Subject: [PATCH 02/16] feat: download manager backend --- drizzle/0004_add_download_manager.sql | 22 + drizzle/meta/0004_snapshot.json | 583 ++++++++++++++++++ drizzle/meta/_journal.json | 9 +- .../controllers/downloads-controller/index.ts | 429 ++++++++++++- src/main/saving/db/schema.ts | 30 + src/main/saving/downloads.ts | 71 +++ src/shared/types/downloads.ts | 23 + 7 files changed, 1135 insertions(+), 32 deletions(-) create mode 100644 drizzle/0004_add_download_manager.sql create mode 100644 drizzle/meta/0004_snapshot.json create mode 100644 src/main/saving/downloads.ts create mode 100644 src/shared/types/downloads.ts diff --git a/drizzle/0004_add_download_manager.sql b/drizzle/0004_add_download_manager.sql new file mode 100644 index 000000000..6ddeef9b0 --- /dev/null +++ b/drizzle/0004_add_download_manager.sql @@ -0,0 +1,22 @@ +CREATE TABLE `downloads` ( + `id` text PRIMARY KEY NOT NULL, + `origin_profile_id` text, + `url` text NOT NULL, + `url_chain` text NOT NULL, + `suggested_filename` text NOT NULL, + `save_path` text, + `mime_type` text, + `state` text NOT NULL, + `received_bytes` integer DEFAULT 0 NOT NULL, + `total_bytes` integer DEFAULT 0 NOT NULL, + `start_time` integer NOT NULL, + `end_time` integer, + `etag` text, + `last_modified` text, + `can_resume` integer DEFAULT false NOT NULL, + `created_at` integer NOT NULL, + `updated_at` integer NOT NULL +); +--> statement-breakpoint +CREATE INDEX `idx_downloads_state` ON `downloads` (`state`);--> statement-breakpoint +CREATE INDEX `idx_downloads_updated_at` ON `downloads` (`updated_at`); \ No newline at end of file diff --git a/drizzle/meta/0004_snapshot.json b/drizzle/meta/0004_snapshot.json new file mode 100644 index 000000000..6e4f10279 --- /dev/null +++ b/drizzle/meta/0004_snapshot.json @@ -0,0 +1,583 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "3b5d9b5b-57bb-4016-b831-c35b1db3c2b1", + "prevId": "d167eef2-6061-44ad-a781-c9e745868372", + "tables": { + "downloads": { + "name": "downloads", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "origin_profile_id": { + "name": "origin_profile_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "url_chain": { + "name": "url_chain", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "suggested_filename": { + "name": "suggested_filename", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "save_path": { + "name": "save_path", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "received_bytes": { + "name": "received_bytes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "total_bytes": { + "name": "total_bytes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "start_time": { + "name": "start_time", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "end_time": { + "name": "end_time", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "etag": { + "name": "etag", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_modified": { + "name": "last_modified", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "can_resume": { + "name": "can_resume", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_downloads_state": { + "name": "idx_downloads_state", + "columns": [ + "state" + ], + "isUnique": false + }, + "idx_downloads_updated_at": { + "name": "idx_downloads_updated_at", + "columns": [ + "updated_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "history_urls": { + "name": "history_urls", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "profile_id": { + "name": "profile_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "visit_count": { + "name": "visit_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "typed_count": { + "name": "typed_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "last_visit_time": { + "name": "last_visit_time", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_history_urls_profile_url": { + "name": "idx_history_urls_profile_url", + "columns": [ + "profile_id", + "url" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "history_visits": { + "name": "history_visits", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "url_id": { + "name": "url_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "visit_time": { + "name": "visit_time", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "typed": { + "name": "typed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + } + }, + "indexes": { + "idx_history_visits_url_id": { + "name": "idx_history_visits_url_id", + "columns": [ + "url_id" + ], + "isUnique": false + }, + "idx_history_visits_visit_time": { + "name": "idx_history_visits_visit_time", + "columns": [ + "visit_time" + ], + "isUnique": false + } + }, + "foreignKeys": { + "history_visits_url_id_history_urls_id_fk": { + "name": "history_visits_url_id_history_urls_id_fk", + "tableFrom": "history_visits", + "tableTo": "history_urls", + "columnsFrom": [ + "url_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "pinned_tabs": { + "name": "pinned_tabs", + "columns": { + "unique_id": { + "name": "unique_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "profile_id": { + "name": "profile_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "default_url": { + "name": "default_url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "favicon_url": { + "name": "favicon_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_pinned_tabs_profile_id": { + "name": "idx_pinned_tabs_profile_id", + "columns": [ + "profile_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "tab_groups": { + "name": "tab_groups", + "columns": { + "group_id": { + "name": "group_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "mode": { + "name": "mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "profile_id": { + "name": "profile_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "space_id": { + "name": "space_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tab_unique_ids": { + "name": "tab_unique_ids", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "glance_front_tab_unique_id": { + "name": "glance_front_tab_unique_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "tabs": { + "name": "tabs", + "columns": { + "unique_id": { + "name": "unique_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "schema_version": { + "name": "schema_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_active_at": { + "name": "last_active_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "profile_id": { + "name": "profile_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "space_id": { + "name": "space_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "window_group_id": { + "name": "window_group_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "favicon_url": { + "name": "favicon_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "muted": { + "name": "muted", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "nav_history": { + "name": "nav_history", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "nav_history_index": { + "name": "nav_history_index", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_tabs_window_group_id": { + "name": "idx_tabs_window_group_id", + "columns": [ + "window_group_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "window_states": { + "name": "window_states", + "columns": { + "window_group_id": { + "name": "window_group_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "width": { + "name": "width", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "height": { + "name": "height", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "x": { + "name": "x", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "y": { + "name": "y", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_popup": { + "name": "is_popup", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 0291f867c..b94388cda 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -29,6 +29,13 @@ "when": 1774447068044, "tag": "0003_remove_recently_closed", "breakpoints": true + }, + { + "idx": 4, + "version": "6", + "when": 1774637085063, + "tag": "0004_add_download_manager", + "breakpoints": true } ] -} +} \ No newline at end of file diff --git a/src/main/controllers/downloads-controller/index.ts b/src/main/controllers/downloads-controller/index.ts index 64ad1ac77..67d6375de 100644 --- a/src/main/controllers/downloads-controller/index.ts +++ b/src/main/controllers/downloads-controller/index.ts @@ -1,37 +1,194 @@ -import { app, type DownloadItem, type Session } from "electron"; +import { randomUUID } from "crypto"; +import { app, session as electronSession, type DownloadItem, type Session, type WebContents } from "electron"; import path from "path"; +import { FLOW_DATA_DIR } from "@/modules/paths"; import { debugError, debugPrint } from "@/modules/output"; +import { + getDownloadRecord, + listDownloads as listPersistedDownloads, + reconcileDownloadsOnStartup, + updateDownloadRecord, + upsertDownloadRecord +} from "@/saving/downloads"; +import type { DownloadInsert, DownloadRow } from "@/saving/db/schema"; type MacOSProgressModule = typeof import("./macos-progress"); +const PROFILES_DIR = path.join(FLOW_DATA_DIR, "Profiles"); +const DOWNLOAD_PROGRESS_PERSIST_INTERVAL_MS = 1000; + interface DownloadMetadata { + downloadId: string; + originProfileId: string | null; progressId: string | null; savePath: string | null; lastUpdate: number; lastBytes: number; initialTotalBytes: number; syncingProgress: boolean; + lastPersistedAt: number; +} + +interface ActiveDownload { + item: DownloadItem; + session: Session; + meta: DownloadMetadata; +} + +interface PendingResumeRequest { + downloadId: string; + savePath: string; + lastUrl: string; + autoResume: boolean; } class DownloadsController { private readonly activeDownloads = new WeakMap(); + private readonly activeDownloadsById = new Map(); private readonly registeredSessions = new WeakSet(); + private readonly pendingResumeRequests = new WeakMap(); + + private didInitializePersistence = false; private macosProgress: MacOSProgressModule | null = null; private macosProgressLoad: Promise | null = null; public registerSession(session: Session): void { + this.ensurePersistenceInitialized(); + if (this.registeredSessions.has(session)) { return; } - session.on("will-download", (_event, item) => { - this.handleWillDownload(item); + session.on("will-download", (_event, item, webContents) => { + this.handleWillDownload(session, item, webContents); }); this.registeredSessions.add(session); debugPrint("DOWNLOADS", "Download handler registered for session"); } + public listDownloads(): DownloadRow[] { + this.ensurePersistenceInitialized(); + return listPersistedDownloads(); + } + + public getDownload(downloadId: string): DownloadRow | undefined { + this.ensurePersistenceInitialized(); + return getDownloadRecord(downloadId); + } + + public pauseDownload(downloadId: string): boolean { + this.ensurePersistenceInitialized(); + + const active = this.activeDownloadsById.get(downloadId); + if (!active) return false; + + try { + if (!active.item.isPaused()) { + active.item.pause(); + } + + this.persistDownloadSnapshot(active, "paused", true); + debugPrint("DOWNLOADS", `Paused download ${downloadId}`); + return true; + } catch (err) { + debugError("DOWNLOADS", `Failed to pause download ${downloadId}:`, err); + return false; + } + } + + public resumeDownload(downloadId: string): boolean { + this.ensurePersistenceInitialized(); + + const active = this.activeDownloadsById.get(downloadId); + if (active) { + try { + if (!active.item.isPaused() && active.item.getState() !== "interrupted") { + return false; + } + if (active.item.getState() === "interrupted" && !active.item.canResume()) { + return false; + } + + active.item.resume(); + this.persistDownloadSnapshot(active, "progressing", true); + debugPrint("DOWNLOADS", `Resumed active download ${downloadId}`); + return true; + } catch (err) { + debugError("DOWNLOADS", `Failed to resume active download ${downloadId}:`, err); + return false; + } + } + + const record = getDownloadRecord(downloadId); + if (!record || !this.canRestoreDownload(record)) { + return false; + } + + try { + const targetSession = this.getSessionForDownload(record.originProfileId); + this.registerSession(targetSession); + this.enqueuePendingResume(targetSession, { + downloadId, + savePath: record.savePath!, + lastUrl: record.urlChain[record.urlChain.length - 1] ?? record.url, + autoResume: true + }); + + targetSession.createInterruptedDownload({ + path: record.savePath!, + urlChain: record.urlChain, + mimeType: record.mimeType ?? undefined, + offset: record.receivedBytes, + length: record.totalBytes, + lastModified: record.lastModified ?? undefined, + eTag: record.eTag ?? undefined, + startTime: Math.floor(record.startTime / 1000) + }); + + debugPrint("DOWNLOADS", `Queued interrupted download restore for ${downloadId}`); + return true; + } catch (err) { + debugError("DOWNLOADS", `Failed to recreate interrupted download ${downloadId}:`, err); + return false; + } + } + + public cancelDownload(downloadId: string): boolean { + this.ensurePersistenceInitialized(); + + const active = this.activeDownloadsById.get(downloadId); + if (active) { + try { + active.item.cancel(); + debugPrint("DOWNLOADS", `Cancelled active download ${downloadId}`); + return true; + } catch (err) { + debugError("DOWNLOADS", `Failed to cancel active download ${downloadId}:`, err); + return false; + } + } + + const record = getDownloadRecord(downloadId); + if (!record || (record.state !== "interrupted" && record.state !== "paused")) { + return false; + } + + updateDownloadRecord(downloadId, { + state: "cancelled", + canResume: false, + endTime: Date.now() + }); + debugPrint("DOWNLOADS", `Marked inactive download ${downloadId} as cancelled`); + return true; + } + + private ensurePersistenceInitialized(): void { + if (this.didInitializePersistence) return; + reconcileDownloadsOnStartup(); + this.didInitializePersistence = true; + } + private async ensureMacosProgressModule(): Promise { if (process.platform !== "darwin") return null; if (this.macosProgress) return this.macosProgress; @@ -51,50 +208,90 @@ class DownloadsController { return this.macosProgressLoad; } - private handleWillDownload(item: DownloadItem): void { + private handleWillDownload(session: Session, item: DownloadItem, webContents?: WebContents): void { + const pendingResume = this.consumePendingResume(session, item); + const existingRecord = pendingResume ? getDownloadRecord(pendingResume.downloadId) : undefined; + + const downloadId = pendingResume?.downloadId ?? randomUUID(); const suggestedFilename = item.getFilename(); const defaultPath = path.join(app.getPath("downloads"), suggestedFilename); + const savePath = this.getSavePath(item); + const originProfileId = existingRecord?.originProfileId ?? this.resolveOriginProfileId(session, webContents); + const now = Date.now(); - item.setSaveDialogOptions({ - defaultPath, - properties: ["createDirectory", "showOverwriteConfirmation"] - }); + if (!pendingResume) { + item.setSaveDialogOptions({ + defaultPath, + properties: ["createDirectory", "showOverwriteConfirmation"] + }); + } const metadata: DownloadMetadata = { + downloadId, + originProfileId, progressId: null, - savePath: null, - lastUpdate: Date.now(), - lastBytes: 0, + savePath, + lastUpdate: now, + lastBytes: item.getReceivedBytes(), initialTotalBytes: item.getTotalBytes(), - syncingProgress: false + syncingProgress: false, + lastPersistedAt: 0 }; + const activeDownload: ActiveDownload = { item, session, meta: metadata }; + this.activeDownloads.set(item, metadata); + this.activeDownloadsById.set(downloadId, activeDownload); - debugPrint("DOWNLOADS", `Download requested: ${suggestedFilename}`); + upsertDownloadRecord(this.buildDownloadInsert(item, metadata, existingRecord)); + + debugPrint("DOWNLOADS", `Download requested: ${suggestedFilename} (${downloadId})`); this.queueProgressSync(item, metadata); item.on("updated", (_event, state) => { - const current = this.activeDownloads.get(item); + const currentMeta = this.activeDownloads.get(item); + if (!currentMeta) return; + + const current = this.activeDownloadsById.get(currentMeta.downloadId); if (!current) return; - this.queueProgressSync(item, current); + this.queueProgressSync(item, currentMeta); if (state === "progressing") { - this.updateMacProgress(current, item); + this.updateMacProgress(currentMeta, item); } else if (state === "interrupted") { - debugPrint("DOWNLOADS", `Download interrupted: ${item.getFilename()}`); + debugPrint("DOWNLOADS", `Download interrupted: ${item.getFilename()} (${currentMeta.downloadId})`); } + + const persistedState = this.getPersistedState(item, state); + this.persistDownloadSnapshot(current, persistedState, persistedState !== "progressing"); }); item.once("done", (_event, state) => { - const current = this.activeDownloads.get(item); - if (!current) return; + const currentMeta = this.activeDownloads.get(item); + if (!currentMeta) return; + const current = this.activeDownloadsById.get(currentMeta.downloadId); this.activeDownloads.delete(item); - void this.handleDone(item, current, state); + this.activeDownloadsById.delete(currentMeta.downloadId); + + if (current) { + void this.handleDone(current, state); + } }); + + if (pendingResume?.autoResume) { + queueMicrotask(() => { + try { + item.resume(); + this.persistDownloadSnapshot(activeDownload, "progressing", true); + debugPrint("DOWNLOADS", `Auto-resumed interrupted download ${downloadId}`); + } catch (err) { + debugError("DOWNLOADS", `Failed to auto-resume interrupted download ${downloadId}:`, err); + } + }); + } } private queueProgressSync(item: DownloadItem, meta: DownloadMetadata): void { @@ -169,25 +366,195 @@ class DownloadsController { } } - private async handleDone( + private async handleDone(active: ActiveDownload, state: "completed" | "cancelled" | "interrupted"): Promise { + await this.syncMacProgress(active.item, active.meta); + + if (this.macosProgress && active.meta.progressId) { + if (state === "completed") { + this.macosProgress.completeFileProgress(active.meta.progressId, active.item.getReceivedBytes()); + } else { + this.macosProgress.cancelFileProgress(active.meta.progressId); + } + } + + this.persistDownloadSnapshot(active, state, true); + debugPrint("DOWNLOADS", `Download ${state}: ${this.getSavePath(active.item) ?? active.item.getFilename()}`); + } + + private buildDownloadInsert( item: DownloadItem, meta: DownloadMetadata, - state: "completed" | "cancelled" | "interrupted" - ): Promise { - await this.syncMacProgress(item, meta); + existingRecord?: DownloadRow + ): DownloadInsert { + const now = Date.now(); + const urlChain = this.getUrlChain(item); + + return { + id: meta.downloadId, + originProfileId: meta.originProfileId, + url: item.getURL(), + urlChain, + suggestedFilename: item.getFilename(), + savePath: meta.savePath, + mimeType: this.emptyToNull(item.getMimeType()) ?? existingRecord?.mimeType ?? null, + state: this.getPersistedState(item), + receivedBytes: item.getReceivedBytes(), + totalBytes: item.getTotalBytes(), + startTime: existingRecord?.startTime ?? this.getDownloadStartTimeMs(item, now), + endTime: null, + eTag: this.emptyToNull(item.getETag()) ?? existingRecord?.eTag ?? null, + lastModified: this.emptyToNull(item.getLastModifiedTime()) ?? existingRecord?.lastModified ?? null, + canResume: item.canResume(), + createdAt: existingRecord?.createdAt ?? now, + updatedAt: now + }; + } - if (!this.macosProgress || !meta.progressId) { - debugPrint("DOWNLOADS", `Download ${state}: ${item.getFilename()}`); + private persistDownloadSnapshot( + active: ActiveDownload, + explicitState?: DownloadInsert["state"], + force: boolean = false + ): void { + const now = Date.now(); + const { item, meta } = active; + const state = explicitState ?? this.getPersistedState(item); + + if (!force && state === "progressing" && now - meta.lastPersistedAt < DOWNLOAD_PROGRESS_PERSIST_INTERVAL_MS) { return; } - if (state === "completed") { - this.macosProgress.completeFileProgress(meta.progressId, item.getReceivedBytes()); - } else { - this.macosProgress.cancelFileProgress(meta.progressId); + const savePath = this.getSavePath(item); + if (savePath) { + meta.savePath = savePath; + } + + updateDownloadRecord(meta.downloadId, { + originProfileId: meta.originProfileId, + url: item.getURL(), + urlChain: this.getUrlChain(item), + suggestedFilename: item.getFilename(), + savePath: meta.savePath, + mimeType: this.emptyToNull(item.getMimeType()), + state, + receivedBytes: item.getReceivedBytes(), + totalBytes: item.getTotalBytes(), + startTime: this.getDownloadStartTimeMs(item), + endTime: this.shouldSetEndTime(state, item.canResume()) ? this.getDownloadEndTimeMs(item, now) : null, + eTag: this.emptyToNull(item.getETag()), + lastModified: this.emptyToNull(item.getLastModifiedTime()), + canResume: item.canResume(), + updatedAt: now + }); + + meta.lastPersistedAt = now; + } + + private getPersistedState( + item: DownloadItem, + stateHint?: "progressing" | "interrupted" | "completed" | "cancelled" + ): DownloadInsert["state"] { + if (item.isPaused()) return "paused"; + return stateHint ?? item.getState(); + } + + private resolveOriginProfileId(session: Session, webContents?: WebContents): string | null { + if (webContents) { + const fromWebContentsSession = this.getProfileIdFromStoragePath(webContents.session.getStoragePath()); + if (fromWebContentsSession) return fromWebContentsSession; + } + + return this.getProfileIdFromStoragePath(session.getStoragePath()); + } + + private getProfileIdFromStoragePath(storagePath: string | null): string | null { + if (!storagePath) return null; + + const relativePath = path.relative(PROFILES_DIR, storagePath); + if (!relativePath || relativePath.startsWith("..") || path.isAbsolute(relativePath)) { + return null; } - debugPrint("DOWNLOADS", `Download ${state}: ${this.getSavePath(item) ?? item.getFilename()}`); + const [profileId] = relativePath.split(path.sep); + return profileId || null; + } + + private getSessionForDownload(originProfileId: string | null): Session { + if (!originProfileId) { + return electronSession.defaultSession; + } + + return electronSession.fromPath(path.join(PROFILES_DIR, originProfileId)); + } + + private canRestoreDownload(record: DownloadRow): boolean { + return !!record.canResume && !!record.savePath && record.urlChain.length > 0 && record.totalBytes > 0; + } + + private enqueuePendingResume(session: Session, request: PendingResumeRequest): void { + const queue = this.pendingResumeRequests.get(session) ?? []; + queue.push(request); + this.pendingResumeRequests.set(session, queue); + } + + private consumePendingResume(session: Session, item: DownloadItem): PendingResumeRequest | undefined { + const queue = this.pendingResumeRequests.get(session); + if (!queue || queue.length === 0) return undefined; + + const savePath = this.getSavePath(item); + const lastUrl = this.getUrlChain(item).at(-1) ?? item.getURL(); + + const matchIndex = queue.findIndex((candidate) => { + if (savePath && candidate.savePath !== savePath) return false; + if (lastUrl && candidate.lastUrl !== lastUrl) return false; + return true; + }); + + if (matchIndex >= 0) { + const [match] = queue.splice(matchIndex, 1); + if (queue.length === 0) { + this.pendingResumeRequests.delete(session); + } + return match; + } + + if (queue.length === 1 && item.getState() === "interrupted") { + const [fallback] = queue.splice(0, 1); + this.pendingResumeRequests.delete(session); + return fallback; + } + + return undefined; + } + + private getUrlChain(item: DownloadItem): string[] { + const chain = item.getURLChain(); + return chain.length > 0 ? chain : [item.getURL()]; + } + + private shouldSetEndTime(state: DownloadInsert["state"], canResume: boolean): boolean { + if (state === "completed" || state === "cancelled") return true; + if (state === "interrupted" && !canResume) return true; + return false; + } + + private getDownloadStartTimeMs(item: DownloadItem, fallback: number = Date.now()): number { + const startTimeSeconds = item.getStartTime(); + if (startTimeSeconds > 0) { + return Math.round(startTimeSeconds * 1000); + } + return fallback; + } + + private getDownloadEndTimeMs(item: DownloadItem, fallback: number = Date.now()): number { + const endTimeSeconds = item.getEndTime(); + if (endTimeSeconds > 0) { + return Math.round(endTimeSeconds * 1000); + } + return fallback; + } + + private emptyToNull(value: string): string | null { + return value.trim() ? value : null; } private getSavePath(item: DownloadItem): string | null { diff --git a/src/main/saving/db/schema.ts b/src/main/saving/db/schema.ts index 6f92b6061..bc4d27b37 100644 --- a/src/main/saving/db/schema.ts +++ b/src/main/saving/db/schema.ts @@ -1,5 +1,6 @@ import { sqliteTable, text, integer, index, uniqueIndex } from "drizzle-orm/sqlite-core"; import { NavigationEntry, TabGroupMode } from "~/types/tabs"; +import type { DownloadState } from "~/types/downloads"; // --- Tabs Table --- @@ -73,6 +74,35 @@ export const pinnedTabs = sqliteTable( export type PinnedTabRow = typeof pinnedTabs.$inferSelect; export type PinnedTabInsert = typeof pinnedTabs.$inferInsert; +// --- Downloads Table --- + +export const downloads = sqliteTable( + "downloads", + { + id: text("id").primaryKey(), + originProfileId: text("origin_profile_id"), + url: text("url").notNull(), + urlChain: text("url_chain", { mode: "json" }).$type().notNull(), + suggestedFilename: text("suggested_filename").notNull(), + savePath: text("save_path"), + mimeType: text("mime_type"), + state: text("state").$type().notNull(), + receivedBytes: integer("received_bytes").notNull().default(0), + totalBytes: integer("total_bytes").notNull().default(0), + startTime: integer("start_time").notNull(), + endTime: integer("end_time"), + eTag: text("etag"), + lastModified: text("last_modified"), + canResume: integer("can_resume", { mode: "boolean" }).notNull().default(false), + createdAt: integer("created_at").notNull(), + updatedAt: integer("updated_at").notNull() + }, + (table) => [index("idx_downloads_state").on(table.state), index("idx_downloads_updated_at").on(table.updatedAt)] +); + +export type DownloadRow = typeof downloads.$inferSelect; +export type DownloadInsert = typeof downloads.$inferInsert; + // --- Browsing history (Chromium-inspired urls + visits; see design/chromium-inspired-browsing-history.md) --- export const historyUrls = sqliteTable( diff --git a/src/main/saving/downloads.ts b/src/main/saving/downloads.ts new file mode 100644 index 000000000..c8150f98c --- /dev/null +++ b/src/main/saving/downloads.ts @@ -0,0 +1,71 @@ +import { desc, eq, inArray } from "drizzle-orm"; +import { getDb, schema } from "@/saving/db"; +import type { DownloadInsert, DownloadRow } from "@/saving/db/schema"; +import type { DownloadState } from "~/types/downloads"; + +type DownloadRecordUpdate = Partial>; + +const IN_FLIGHT_DOWNLOAD_STATES: DownloadState[] = ["progressing", "paused"]; + +function buildUpsertSet(record: DownloadInsert): Omit { + return { + originProfileId: record.originProfileId, + url: record.url, + urlChain: record.urlChain, + suggestedFilename: record.suggestedFilename, + savePath: record.savePath, + mimeType: record.mimeType, + state: record.state, + receivedBytes: record.receivedBytes, + totalBytes: record.totalBytes, + startTime: record.startTime, + endTime: record.endTime, + eTag: record.eTag, + lastModified: record.lastModified, + canResume: record.canResume, + updatedAt: record.updatedAt + }; +} + +export function upsertDownloadRecord(record: DownloadInsert): void { + getDb() + .insert(schema.downloads) + .values(record) + .onConflictDoUpdate({ + target: schema.downloads.id, + set: buildUpsertSet(record) + }) + .run(); +} + +export function updateDownloadRecord(downloadId: string, patch: DownloadRecordUpdate): void { + if (Object.keys(patch).length === 0) return; + + getDb() + .update(schema.downloads) + .set({ + ...patch, + updatedAt: patch.updatedAt ?? Date.now() + }) + .where(eq(schema.downloads.id, downloadId)) + .run(); +} + +export function getDownloadRecord(downloadId: string): DownloadRow | undefined { + return getDb().select().from(schema.downloads).where(eq(schema.downloads.id, downloadId)).get(); +} + +export function listDownloads(): DownloadRow[] { + return getDb().select().from(schema.downloads).orderBy(desc(schema.downloads.updatedAt)).all(); +} + +export function reconcileDownloadsOnStartup(): void { + getDb() + .update(schema.downloads) + .set({ + state: "interrupted", + updatedAt: Date.now() + }) + .where(inArray(schema.downloads.state, IN_FLIGHT_DOWNLOAD_STATES)) + .run(); +} diff --git a/src/shared/types/downloads.ts b/src/shared/types/downloads.ts new file mode 100644 index 000000000..0b144f04b --- /dev/null +++ b/src/shared/types/downloads.ts @@ -0,0 +1,23 @@ +export const DOWNLOAD_STATES = ["progressing", "paused", "interrupted", "completed", "cancelled"] as const; + +export type DownloadState = (typeof DOWNLOAD_STATES)[number]; + +export interface DownloadRecord { + id: string; + originProfileId: string | null; + url: string; + urlChain: string[]; + suggestedFilename: string; + savePath: string | null; + mimeType: string | null; + state: DownloadState; + receivedBytes: number; + totalBytes: number; + startTime: number; + endTime: number | null; + eTag: string | null; + lastModified: string | null; + canResume: boolean; + createdAt: number; + updatedAt: number; +} From f0de032f29423cd8319290992227295a8acfad8a Mon Sep 17 00:00:00 2001 From: Evan Date: Fri, 27 Mar 2026 18:46:29 +0000 Subject: [PATCH 03/16] chore: format --- drizzle/meta/0004_snapshot.json | 39 +++++++++------------------------ drizzle/meta/_journal.json | 2 +- 2 files changed, 11 insertions(+), 30 deletions(-) diff --git a/drizzle/meta/0004_snapshot.json b/drizzle/meta/0004_snapshot.json index 6e4f10279..585798179 100644 --- a/drizzle/meta/0004_snapshot.json +++ b/drizzle/meta/0004_snapshot.json @@ -133,16 +133,12 @@ "indexes": { "idx_downloads_state": { "name": "idx_downloads_state", - "columns": [ - "state" - ], + "columns": ["state"], "isUnique": false }, "idx_downloads_updated_at": { "name": "idx_downloads_updated_at", - "columns": [ - "updated_at" - ], + "columns": ["updated_at"], "isUnique": false } }, @@ -209,10 +205,7 @@ "indexes": { "idx_history_urls_profile_url": { "name": "idx_history_urls_profile_url", - "columns": [ - "profile_id", - "url" - ], + "columns": ["profile_id", "url"], "isUnique": true } }, @@ -257,16 +250,12 @@ "indexes": { "idx_history_visits_url_id": { "name": "idx_history_visits_url_id", - "columns": [ - "url_id" - ], + "columns": ["url_id"], "isUnique": false }, "idx_history_visits_visit_time": { "name": "idx_history_visits_visit_time", - "columns": [ - "visit_time" - ], + "columns": ["visit_time"], "isUnique": false } }, @@ -275,12 +264,8 @@ "name": "history_visits_url_id_history_urls_id_fk", "tableFrom": "history_visits", "tableTo": "history_urls", - "columnsFrom": [ - "url_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["url_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } @@ -331,9 +316,7 @@ "indexes": { "idx_pinned_tabs_profile_id": { "name": "idx_pinned_tabs_profile_id", - "columns": [ - "profile_id" - ], + "columns": ["profile_id"], "isUnique": false } }, @@ -506,9 +489,7 @@ "indexes": { "idx_tabs_window_group_id": { "name": "idx_tabs_window_group_id", - "columns": [ - "window_group_id" - ], + "columns": ["window_group_id"], "isUnique": false } }, @@ -580,4 +561,4 @@ "internal": { "indexes": {} } -} \ No newline at end of file +} diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index b94388cda..812d00038 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -38,4 +38,4 @@ "breakpoints": true } ] -} \ No newline at end of file +} From e2c63b4cd2119cc7167583e7463a3ec4593ce844 Mon Sep 17 00:00:00 2001 From: Evan Date: Fri, 27 Mar 2026 20:01:38 +0000 Subject: [PATCH 04/16] feat: download manager UI --- .../protocols/static-domains/config.ts | 8 + src/main/ipc/browser/downloads.ts | 50 ++ src/main/ipc/index.ts | 1 + src/main/saving/downloads.ts | 5 + src/preload/index.ts | 36 +- src/renderer/src/routes/downloads/config.tsx | 28 + src/renderer/src/routes/downloads/page.tsx | 485 ++++++++++++++++++ src/shared/flow/flow.ts | 2 + .../flow/interfaces/browser/downloads.ts | 13 + 9 files changed, 627 insertions(+), 1 deletion(-) create mode 100644 src/main/ipc/browser/downloads.ts create mode 100644 src/renderer/src/routes/downloads/config.tsx create mode 100644 src/renderer/src/routes/downloads/page.tsx create mode 100644 src/shared/flow/interfaces/browser/downloads.ts diff --git a/src/main/controllers/sessions-controller/protocols/static-domains/config.ts b/src/main/controllers/sessions-controller/protocols/static-domains/config.ts index 96dc4ed26..c8ac8a4a8 100644 --- a/src/main/controllers/sessions-controller/protocols/static-domains/config.ts +++ b/src/main/controllers/sessions-controller/protocols/static-domains/config.ts @@ -100,6 +100,14 @@ export const STATIC_DOMAINS: StaticDomainInfo[] = [ route: "history" } }, + { + protocol: "flow", + hostname: "downloads", + actual: { + type: "route", + route: "downloads" + } + }, { protocol: "flow", hostname: "bangs", diff --git a/src/main/ipc/browser/downloads.ts b/src/main/ipc/browser/downloads.ts new file mode 100644 index 000000000..2fd497e5c --- /dev/null +++ b/src/main/ipc/browser/downloads.ts @@ -0,0 +1,50 @@ +import { ipcMain, shell } from "electron"; +import { downloadsController } from "@/controllers/downloads-controller"; +import { deleteDownloadRecord, getDownloadRecord, listDownloads } from "@/saving/downloads"; + +ipcMain.handle("downloads:list", () => { + return downloadsController.listDownloads(); +}); + +ipcMain.handle("downloads:get", (_event, downloadId: string) => { + return downloadsController.getDownload(downloadId); +}); + +ipcMain.handle("downloads:pause", (_event, downloadId: string) => { + return downloadsController.pauseDownload(downloadId); +}); + +ipcMain.handle("downloads:resume", (_event, downloadId: string) => { + return downloadsController.resumeDownload(downloadId); +}); + +ipcMain.handle("downloads:cancel", (_event, downloadId: string) => { + return downloadsController.cancelDownload(downloadId); +}); + +ipcMain.handle("downloads:show-in-folder", (_event, downloadId: string) => { + const record = getDownloadRecord(downloadId); + if (!record?.savePath) return false; + shell.showItemInFolder(record.savePath); + return true; +}); + +ipcMain.handle("downloads:open-file", (_event, downloadId: string) => { + const record = getDownloadRecord(downloadId); + if (!record?.savePath || record.state !== "completed") return false; + shell.openPath(record.savePath); + return true; +}); + +ipcMain.handle("downloads:remove-record", (_event, downloadId: string) => { + return deleteDownloadRecord(downloadId); +}); + +ipcMain.handle("downloads:clear-completed", () => { + const downloads = listDownloads(); + for (const dl of downloads) { + if (dl.state === "completed" || dl.state === "cancelled") { + deleteDownloadRecord(dl.id); + } + } +}); diff --git a/src/main/ipc/index.ts b/src/main/ipc/index.ts index 097ffe569..15f709b94 100644 --- a/src/main/ipc/index.ts +++ b/src/main/ipc/index.ts @@ -12,6 +12,7 @@ import "@/ipc/browser/pinned-tabs"; import "@/ipc/browser/page"; import "@/ipc/browser/navigation"; import "@/ipc/browser/history"; +import "@/ipc/browser/downloads"; import "@/ipc/browser/interface"; import "@/ipc/browser/find-in-page"; import "@/ipc/window/omnibox"; diff --git a/src/main/saving/downloads.ts b/src/main/saving/downloads.ts index c8150f98c..865650e66 100644 --- a/src/main/saving/downloads.ts +++ b/src/main/saving/downloads.ts @@ -59,6 +59,11 @@ export function listDownloads(): DownloadRow[] { return getDb().select().from(schema.downloads).orderBy(desc(schema.downloads.updatedAt)).all(); } +export function deleteDownloadRecord(downloadId: string): boolean { + const result = getDb().delete(schema.downloads).where(eq(schema.downloads.id, downloadId)).run(); + return result.changes > 0; +} + export function reconcileDownloadsOnStartup(): void { getDb() .update(schema.downloads) diff --git a/src/preload/index.ts b/src/preload/index.ts index c51439f1f..a8945c6c8 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -41,6 +41,7 @@ import { FlowShortcutsAPI, ShortcutsData } from "~/flow/interfaces/app/shortcuts import { FlowFindInPageAPI, FindInPageResult } from "~/flow/interfaces/browser/find-in-page"; import { FlowHistoryAPI } from "~/flow/interfaces/browser/history"; import { FlowPasskeyAPI } from "~/flow/interfaces/browser/passkey"; +import { FlowDownloadsAPI } from "~/flow/interfaces/browser/downloads"; import type { ConditionalPasskeyRequest, PasskeyCredential } from "~/types/passkey"; // const isIFrame = !process.isMainFrame; @@ -76,6 +77,7 @@ function hasPermission(permission: Permission) { // Extensions const isExtensions = isLocation("flow:", "extensions"); const isHistoryPage = isLocation("flow:", "history"); + const isDownloadsPage = isLocation("flow:", "downloads"); switch (permission) { case "all": @@ -83,7 +85,7 @@ function hasPermission(permission: Permission) { case "app": return isInternalProtocols || isExtensions; case "browser": - return isBrowserUI || isOmnibox || isHistoryPage; + return isBrowserUI || isOmnibox || isHistoryPage || isDownloadsPage; case "session": return isFlowInternalProtocol || isOmnibox || isBrowserUI; case "settings": @@ -407,6 +409,37 @@ const passkeyAPI: FlowPasskeyAPI = { } }; +// DOWNLOADS API // +const downloadsAPI: FlowDownloadsAPI = { + list: async () => { + return ipcRenderer.invoke("downloads:list"); + }, + get: async (downloadId: string) => { + return ipcRenderer.invoke("downloads:get", downloadId); + }, + pause: async (downloadId: string) => { + return ipcRenderer.invoke("downloads:pause", downloadId); + }, + resume: async (downloadId: string) => { + return ipcRenderer.invoke("downloads:resume", downloadId); + }, + cancel: async (downloadId: string) => { + return ipcRenderer.invoke("downloads:cancel", downloadId); + }, + showInFolder: async (downloadId: string) => { + return ipcRenderer.invoke("downloads:show-in-folder", downloadId); + }, + openFile: async (downloadId: string) => { + return ipcRenderer.invoke("downloads:open-file", downloadId); + }, + removeRecord: async (downloadId: string) => { + return ipcRenderer.invoke("downloads:remove-record", downloadId); + }, + clearCompleted: async () => { + return ipcRenderer.invoke("downloads:clear-completed"); + } +}; + // INTERFACE API // const interfaceAPI: FlowInterfaceAPI = { setWindowButtonPosition: (position: { x: number; y: number }) => { @@ -781,6 +814,7 @@ const flowAPI: typeof flow = { navigation: wrapAPI(navigationAPI, "browser"), history: wrapAPI(historyAPI, "browser"), passkey: wrapAPI(passkeyAPI, "browser"), + downloads: wrapAPI(downloadsAPI, "browser"), interface: wrapAPI(interfaceAPI, "browser", { moveWindowTo: "all", resizeWindowTo: "all" diff --git a/src/renderer/src/routes/downloads/config.tsx b/src/renderer/src/routes/downloads/config.tsx new file mode 100644 index 000000000..1c4b84547 --- /dev/null +++ b/src/renderer/src/routes/downloads/config.tsx @@ -0,0 +1,28 @@ +import { ThemeProvider } from "@/components/main/theme"; +import { RouteConfigType } from "@/types/routes"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { ReactNode, useState } from "react"; + +function DownloadsQueryProvider({ children }: { children: ReactNode }) { + const [queryClient] = useState( + () => + new QueryClient({ + defaultOptions: { + queries: { + staleTime: 5_000 + } + } + }) + ); + return {children}; +} + +export const RouteConfig: RouteConfigType = { + Providers: ({ children }: { children: ReactNode }) => { + return ( + + {children} + + ); + } +}; diff --git a/src/renderer/src/routes/downloads/page.tsx b/src/renderer/src/routes/downloads/page.tsx new file mode 100644 index 000000000..9b7e825b4 --- /dev/null +++ b/src/renderer/src/routes/downloads/page.tsx @@ -0,0 +1,485 @@ +import { useEffect, useMemo, useRef, useState } from "react"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { motion } from "motion/react"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger +} from "@/components/ui/alert-dialog"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader } from "@/components/ui/card"; +import { + ContextMenu, + ContextMenuContent, + ContextMenuItem, + ContextMenuSeparator, + ContextMenuTrigger +} from "@/components/ui/context-menu"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger +} from "@/components/ui/dropdown-menu"; +import { Progress } from "@/components/ui/progress"; +import type { DownloadRecord, DownloadState } from "~/types/downloads"; +import { + CheckCircle2, + Download, + ExternalLink, + File, + FolderOpen, + MoreHorizontal, + Pause, + Play, + Search, + Trash2, + X, + XCircle +} from "lucide-react"; +import { toast } from "sonner"; + +const POLL_INTERVAL_MS = 1500; + +function formatBytes(bytes: number): string { + if (bytes === 0) return "0 B"; + const units = ["B", "KB", "MB", "GB", "TB"]; + const i = Math.floor(Math.log(bytes) / Math.log(1024)); + const value = bytes / Math.pow(1024, i); + return `${value.toFixed(i > 0 ? 1 : 0)} ${units[i]}`; +} + +function formatTime(ms: number): string { + const d = new Date(ms); + return d.toLocaleTimeString(undefined, { hour: "numeric", minute: "2-digit" }); +} + +function stateLabel(state: DownloadState): string { + switch (state) { + case "progressing": + return "Downloading"; + case "paused": + return "Paused"; + case "interrupted": + return "Interrupted"; + case "completed": + return "Completed"; + case "cancelled": + return "Cancelled"; + } +} + +function StateIcon({ state }: { state: DownloadState }) { + switch (state) { + case "completed": + return ; + case "cancelled": + return ; + case "interrupted": + return ; + case "paused": + return ; + case "progressing": + return ; + } +} + +function filenameFromRecord(record: DownloadRecord): string { + if (record.savePath) { + const parts = record.savePath.split(/[/\\]/); + return parts[parts.length - 1] || record.suggestedFilename; + } + return record.suggestedFilename; +} + +function startOfLocalDay(ts: number): number { + const d = new Date(ts); + return new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime(); +} + +function daySectionLabel(ts: number): string { + const d = new Date(ts); + const now = new Date(); + const t0 = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime(); + const t1 = t0 - 86400000; + const fullDate = d.toLocaleDateString(undefined, { weekday: "long", year: "numeric", month: "long", day: "numeric" }); + if (ts >= t0) return `Today - ${fullDate}`; + if (ts >= t1) return `Yesterday - ${fullDate}`; + return fullDate; +} + +type DayGroup = { dayStart: number; label: string; items: DownloadRecord[] }; + +function groupByDay(downloads: DownloadRecord[]): DayGroup[] { + const map = new Map(); + for (const dl of downloads) { + const key = startOfLocalDay(dl.startTime); + const list = map.get(key) ?? []; + list.push(dl); + map.set(key, list); + } + return [...map.entries()] + .sort((a, b) => b[0] - a[0]) + .map(([dayStart, items]) => ({ + dayStart, + label: daySectionLabel(dayStart), + items: items.sort((a, b) => b.startTime - a.startTime) + })); +} + +function isActive(state: DownloadState): boolean { + return state === "progressing" || state === "paused"; +} + +function DownloadItem({ + record, + invalidate +}: { + record: DownloadRecord; + invalidate: () => void; +}) { + const filename = filenameFromRecord(record); + const progress = + record.totalBytes > 0 ? Math.round((record.receivedBytes / record.totalBytes) * 100) : 0; + + const handlePause = async () => { + const ok = await flow.downloads.pause(record.id); + if (ok) invalidate(); + else toast.error("Could not pause download"); + }; + + const handleResume = async () => { + const ok = await flow.downloads.resume(record.id); + if (ok) invalidate(); + else toast.error("Could not resume download"); + }; + + const handleCancel = async () => { + const ok = await flow.downloads.cancel(record.id); + if (ok) invalidate(); + else toast.error("Could not cancel download"); + }; + + const handleShowInFolder = async () => { + const ok = await flow.downloads.showInFolder(record.id); + if (!ok) toast.error("File not found"); + }; + + const handleOpenFile = async () => { + const ok = await flow.downloads.openFile(record.id); + if (!ok) toast.error("Could not open file"); + }; + + const handleRemove = async () => { + const ok = await flow.downloads.removeRecord(record.id); + if (ok) { + toast.success("Removed from downloads"); + invalidate(); + } else { + toast.error("Could not remove download"); + } + }; + + return ( + + +
  • +
    + +
    + +
    +
    + + {filename} + +
    + + {isActive(record.state) && record.totalBytes > 0 && ( + + )} + +
    + + {stateLabel(record.state)} + {record.totalBytes > 0 && ( + <> + - + + {formatBytes(record.receivedBytes)} + {record.state !== "completed" && ` / ${formatBytes(record.totalBytes)}`} + + + )} + - + +
    +
    + + {/* Inline actions */} +
    + {record.state === "progressing" && ( + + )} + {(record.state === "paused" || (record.state === "interrupted" && record.canResume)) && ( + + )} + {isActive(record.state) && ( + + )} + + + + + + {record.state === "completed" && ( + void handleOpenFile()}> + + Open file + + )} + {record.savePath && ( + void handleShowInFolder()}> + + Show in folder + + )} + {(record.state === "completed" || record.savePath) && } + void handleRemove()}> + + Remove from list + + + +
    +
  • +
    + + {record.state === "completed" && ( + void handleOpenFile()}>Open file + )} + {record.savePath && ( + void handleShowInFolder()}>Show in folder + )} + { + void navigator.clipboard.writeText(record.url).then( + () => toast.success("URL copied"), + () => toast.error("Could not copy URL") + ); + }} + > + Copy download URL + + + {isActive(record.state) && ( + void handleCancel()}> + Cancel download + + )} + void handleRemove()}> + Remove from list + + +
    + ); +} + +function DownloadsPage() { + const [search, setSearch] = useState(""); + const [debouncedSearch, setDebouncedSearch] = useState(""); + const searchRef = useRef(null); + const queryClient = useQueryClient(); + + useEffect(() => { + const t = window.setTimeout(() => setDebouncedSearch(search.trim().toLowerCase()), 300); + return () => window.clearTimeout(t); + }, [search]); + + const { data, isError, isPending, refetch } = useQuery({ + queryKey: ["downloads"], + queryFn: () => flow.downloads.list(), + refetchInterval: POLL_INTERVAL_MS + }); + + useEffect(() => { + if (isError) toast.error("Could not load downloads"); + }, [isError]); + + const filtered = useMemo(() => { + if (!data) return []; + if (!debouncedSearch) return data; + return data.filter((dl) => { + const filename = filenameFromRecord(dl).toLowerCase(); + const url = dl.url.toLowerCase(); + return filename.includes(debouncedSearch) || url.includes(debouncedSearch); + }); + }, [data, debouncedSearch]); + + const grouped = useMemo(() => groupByDay(filtered), [filtered]); + + const hasActiveDownloads = useMemo( + () => data?.some((dl) => isActive(dl.state)) ?? false, + [data] + ); + + const invalidate = () => { + void queryClient.invalidateQueries({ queryKey: ["downloads"] }); + }; + + const clearCompleted = async () => { + await flow.downloads.clearCompleted(); + toast.success("Cleared completed downloads"); + invalidate(); + }; + + return ( +
    + {/* Sticky top bar */} +
    +
    +

    Downloads

    + +
    + + setSearch(e.target.value)} + placeholder="Search downloads" + aria-label="Search downloads" + className="w-full h-9 pl-9 pr-3 rounded-lg border border-input bg-muted/40 text-sm text-foreground placeholder:text-muted-foreground transition-[border-color,box-shadow] outline-none focus:border-ring focus:ring-2 focus:ring-ring/30 focus:bg-background" + /> +
    + + + + + + + + Clear completed downloads? + + This removes completed and cancelled downloads from the list. Files on disk are not affected. + + + + Cancel + void clearCompleted()}>Clear + + + +
    +
    + + {/* Content */} + + {isPending ? ( +
    Loading...
    + ) : isError ? ( +
    +

    Could not load downloads

    + +
    + ) : filtered.length === 0 ? ( +
    + +

    No downloads found

    +

    + {debouncedSearch ? "Try a different search." : "Files you download appear here."} +

    +
    + ) : ( + grouped.map((group) => ( + + + + {group.label} + + + +
      + {group.items.map((dl) => ( + + ))} +
    +
    +
    + )) + )} + + {filtered.length > 0 && !hasActiveDownloads && ( +
    + End of downloads +
    + )} +
    +
    + ); +} + +function App() { + return ( + <> + Downloads + + + ); +} + +export default App; diff --git a/src/shared/flow/flow.ts b/src/shared/flow/flow.ts index faac7ed41..aa21d2ffd 100644 --- a/src/shared/flow/flow.ts +++ b/src/shared/flow/flow.ts @@ -13,6 +13,7 @@ import { FlowNewTabAPI } from "~/flow/interfaces/browser/newTab"; import { FlowFindInPageAPI } from "~/flow/interfaces/browser/find-in-page"; import { FlowHistoryAPI } from "~/flow/interfaces/browser/history"; import { FlowPasskeyAPI } from "~/flow/interfaces/browser/passkey"; +import { FlowDownloadsAPI } from "~/flow/interfaces/browser/downloads"; import { FlowProfilesAPI } from "~/flow/interfaces/sessions/profiles"; import { FlowSpacesAPI } from "~/flow/interfaces/sessions/spaces"; @@ -46,6 +47,7 @@ declare global { page: FlowPageAPI; navigation: FlowNavigationAPI; history: FlowHistoryAPI; + downloads: FlowDownloadsAPI; interface: FlowInterfaceAPI; passkey: FlowPasskeyAPI; omnibox: FlowOmniboxAPI; diff --git a/src/shared/flow/interfaces/browser/downloads.ts b/src/shared/flow/interfaces/browser/downloads.ts new file mode 100644 index 000000000..18aee4260 --- /dev/null +++ b/src/shared/flow/interfaces/browser/downloads.ts @@ -0,0 +1,13 @@ +import type { DownloadRecord } from "~/types/downloads"; + +export interface FlowDownloadsAPI { + list: () => Promise; + get: (downloadId: string) => Promise; + pause: (downloadId: string) => Promise; + resume: (downloadId: string) => Promise; + cancel: (downloadId: string) => Promise; + showInFolder: (downloadId: string) => Promise; + openFile: (downloadId: string) => Promise; + removeRecord: (downloadId: string) => Promise; + clearCompleted: () => Promise; +} From fc594c2f9b98d3f0ae75e8ad06efecfcc2558d39 Mon Sep 17 00:00:00 2001 From: Evan Date: Fri, 27 Mar 2026 20:06:27 +0000 Subject: [PATCH 05/16] feat: downloads popover menu on sidebar --- .../_components/bottom/downloads-popover.tsx | 170 ++++++++++++++++++ .../browser-ui/browser-sidebar/inner.tsx | 12 +- 2 files changed, 172 insertions(+), 10 deletions(-) create mode 100644 src/renderer/src/components/browser-ui/browser-sidebar/_components/bottom/downloads-popover.tsx diff --git a/src/renderer/src/components/browser-ui/browser-sidebar/_components/bottom/downloads-popover.tsx b/src/renderer/src/components/browser-ui/browser-sidebar/_components/bottom/downloads-popover.tsx new file mode 100644 index 000000000..b1b670965 --- /dev/null +++ b/src/renderer/src/components/browser-ui/browser-sidebar/_components/bottom/downloads-popover.tsx @@ -0,0 +1,170 @@ +import { PortalPopover } from "@/components/portal/popover"; +import { useSpaces } from "@/components/providers/spaces-provider"; +import { Button } from "@/components/ui/button"; +import { PopoverTrigger } from "@/components/ui/popover"; +import { Progress } from "@/components/ui/progress"; +import { cn } from "@/lib/utils"; +import type { DownloadRecord, DownloadState } from "~/types/downloads"; +import { CheckCircle2, DownloadIcon, ExternalLink, File, Pause, Play, X, XCircle } from "lucide-react"; +import { useCallback, useEffect, useState } from "react"; + +const POLL_MS = 1500; + +function formatBytes(bytes: number): string { + if (bytes === 0) return "0 B"; + const units = ["B", "KB", "MB", "GB"]; + const i = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1); + return `${(bytes / Math.pow(1024, i)).toFixed(i > 0 ? 1 : 0)} ${units[i]}`; +} + +function filenameFromRecord(r: DownloadRecord): string { + if (r.savePath) { + const parts = r.savePath.split(/[/\\]/); + return parts[parts.length - 1] || r.suggestedFilename; + } + return r.suggestedFilename; +} + +function StateIcon({ state, className }: { state: DownloadState; className?: string }) { + const base = cn("size-3.5 shrink-0", className); + switch (state) { + case "completed": + return ; + case "cancelled": + case "interrupted": + return ; + case "paused": + return ; + case "progressing": + return ; + } +} + +function DownloadRow({ dl }: { dl: DownloadRecord }) { + const filename = filenameFromRecord(dl); + const progress = dl.totalBytes > 0 ? Math.round((dl.receivedBytes / dl.totalBytes) * 100) : 0; + const isActive = dl.state === "progressing" || dl.state === "paused"; + + const handlePause = () => void flow.downloads.pause(dl.id); + const handleResume = () => void flow.downloads.resume(dl.id); + const handleCancel = () => void flow.downloads.cancel(dl.id); + const handleOpen = () => void flow.downloads.openFile(dl.id); + + return ( +
    + +
    +
    {filename}
    + {isActive && dl.totalBytes > 0 ? ( +
    + + {progress}% +
    + ) : ( +
    + + + {dl.state === "completed" && formatBytes(dl.receivedBytes)} + {dl.state === "progressing" && dl.totalBytes === 0 && formatBytes(dl.receivedBytes)} + {dl.state === "paused" && "Paused"} + {dl.state === "interrupted" && "Interrupted"} + {dl.state === "cancelled" && "Cancelled"} + +
    + )} +
    +
    + {dl.state === "progressing" && ( + + )} + {(dl.state === "paused" || (dl.state === "interrupted" && dl.canResume)) && ( + + )} + {isActive && ( + + )} + {dl.state === "completed" && ( + + )} +
    +
    + ); +} + +export function DownloadsPopover() { + const [open, setOpen] = useState(false); + const [downloads, setDownloads] = useState([]); + + const { isCurrentSpaceLight } = useSpaces(); + const spaceInjectedClasses = cn(isCurrentSpaceLight ? "" : "dark"); + + const fetchDownloads = useCallback(async () => { + try { + const all = await flow.downloads.list(); + setDownloads(all); + } catch { + // silently ignore + } + }, []); + + useEffect(() => { + if (!open) return; + void fetchDownloads(); + const id = setInterval(() => void fetchDownloads(), POLL_MS); + return () => clearInterval(id); + }, [open, fetchDownloads]); + + // Show up to 5 most recent, prioritizing active downloads + const active = downloads.filter((d) => d.state === "progressing" || d.state === "paused"); + const recent = downloads.filter((d) => d.state !== "progressing" && d.state !== "paused"); + const shown = [...active, ...recent].slice(0, 5); + const hasActive = active.length > 0; + + const openDownloadsPage = () => { + flow.tabs.newTab("flow://downloads", true); + setOpen(false); + }; + + return ( + + + + + + {shown.length === 0 ? ( +
    + +

    No downloads

    +
    + ) : ( +
    + {shown.map((dl) => ( + + ))} +
    + )} +
    +
    + Show all downloads +
    +
    +
    +
    + ); +} diff --git a/src/renderer/src/components/browser-ui/browser-sidebar/inner.tsx b/src/renderer/src/components/browser-ui/browser-sidebar/inner.tsx index f26fe1d7d..cbd7c464b 100644 --- a/src/renderer/src/components/browser-ui/browser-sidebar/inner.tsx +++ b/src/renderer/src/components/browser-ui/browser-sidebar/inner.tsx @@ -12,12 +12,11 @@ import { SlotMachinePinGrid, resetSlotMachine } from "@/components/browser-ui/browser-sidebar/_components/pin-grid/slot-machine/main"; -import { DownloadIcon } from "lucide-react"; -import { Button } from "@/components/ui/button"; import { SpaceSwitcher } from "@/components/browser-ui/browser-sidebar/_components/bottom/space-switcher"; import { SpacePagesCarousel } from "@/components/browser-ui/browser-sidebar/_components/space-pages-carousel"; import { UpdateBanner } from "@/components/browser-ui/browser-sidebar/_components/update-banner"; import { BottomExtrasMenu } from "@/components/browser-ui/browser-sidebar/_components/bottom/bottom-extras-menu"; +import { DownloadsPopover } from "@/components/browser-ui/browser-sidebar/_components/bottom/downloads-popover"; function SidebarIcon({ className }: { className?: string }) { return ( @@ -82,14 +81,7 @@ export function SidebarInner({ direction, variant }: { direction: AttachedDirect
    - +
    From 5c22e320ff52e84ca9d13400ccad7d76d6699c05 Mon Sep 17 00:00:00 2001 From: Evan Date: Fri, 27 Mar 2026 20:33:56 +0000 Subject: [PATCH 06/16] feat: add strikethrough for missing files --- src/main/ipc/browser/downloads.ts | 14 ++++++ src/preload/index.ts | 3 ++ .../_components/bottom/downloads-popover.tsx | 27 ++++++++--- src/renderer/src/routes/downloads/page.tsx | 45 ++++++++++++------- .../flow/interfaces/browser/downloads.ts | 1 + 5 files changed, 68 insertions(+), 22 deletions(-) diff --git a/src/main/ipc/browser/downloads.ts b/src/main/ipc/browser/downloads.ts index 2fd497e5c..691d0237e 100644 --- a/src/main/ipc/browser/downloads.ts +++ b/src/main/ipc/browser/downloads.ts @@ -1,3 +1,4 @@ +import fs from "node:fs"; import { ipcMain, shell } from "electron"; import { downloadsController } from "@/controllers/downloads-controller"; import { deleteDownloadRecord, getDownloadRecord, listDownloads } from "@/saving/downloads"; @@ -48,3 +49,16 @@ ipcMain.handle("downloads:clear-completed", () => { } } }); + +ipcMain.handle("downloads:check-files-exist", (_event, downloadIds: string[]) => { + const result: Record = {}; + for (const id of downloadIds) { + const record = getDownloadRecord(id); + if (!record?.savePath) { + result[id] = false; + } else { + result[id] = fs.existsSync(record.savePath); + } + } + return result; +}); diff --git a/src/preload/index.ts b/src/preload/index.ts index a8945c6c8..62814cf81 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -437,6 +437,9 @@ const downloadsAPI: FlowDownloadsAPI = { }, clearCompleted: async () => { return ipcRenderer.invoke("downloads:clear-completed"); + }, + checkFilesExist: async (downloadIds: string[]) => { + return ipcRenderer.invoke("downloads:check-files-exist", downloadIds); } }; diff --git a/src/renderer/src/components/browser-ui/browser-sidebar/_components/bottom/downloads-popover.tsx b/src/renderer/src/components/browser-ui/browser-sidebar/_components/bottom/downloads-popover.tsx index b1b670965..e3c77d837 100644 --- a/src/renderer/src/components/browser-ui/browser-sidebar/_components/bottom/downloads-popover.tsx +++ b/src/renderer/src/components/browser-ui/browser-sidebar/_components/bottom/downloads-popover.tsx @@ -40,7 +40,7 @@ function StateIcon({ state, className }: { state: DownloadState; className?: str } } -function DownloadRow({ dl }: { dl: DownloadRecord }) { +function DownloadRow({ dl, fileMissing }: { dl: DownloadRecord; fileMissing: boolean }) { const filename = filenameFromRecord(dl); const progress = dl.totalBytes > 0 ? Math.round((dl.receivedBytes / dl.totalBytes) * 100) : 0; const isActive = dl.state === "progressing" || dl.state === "paused"; @@ -54,7 +54,14 @@ function DownloadRow({ dl }: { dl: DownloadRecord }) {
    -
    {filename}
    +
    + {filename} +
    {isActive && dl.totalBytes > 0 ? (
    @@ -89,7 +96,7 @@ function DownloadRow({ dl }: { dl: DownloadRecord }) { )} - {dl.state === "completed" && ( + {dl.state === "completed" && !fileMissing && ( @@ -102,6 +109,7 @@ function DownloadRow({ dl }: { dl: DownloadRecord }) { export function DownloadsPopover() { const [open, setOpen] = useState(false); const [downloads, setDownloads] = useState([]); + const [fileExistence, setFileExistence] = useState>({}); const { isCurrentSpaceLight } = useSpaces(); const spaceInjectedClasses = cn(isCurrentSpaceLight ? "" : "dark"); @@ -110,6 +118,13 @@ export function DownloadsPopover() { try { const all = await flow.downloads.list(); setDownloads(all); + const idsToCheck = all + .filter((d) => d.state !== "progressing" && d.state !== "paused" && d.savePath) + .map((d) => d.id); + if (idsToCheck.length > 0) { + const existence = await flow.downloads.checkFilesExist(idsToCheck); + setFileExistence(existence); + } } catch { // silently ignore } @@ -138,9 +153,7 @@ export function DownloadsPopover() { @@ -152,7 +165,7 @@ export function DownloadsPopover() { ) : (
    {shown.map((dl) => ( - + ))}
    )} diff --git a/src/renderer/src/routes/downloads/page.tsx b/src/renderer/src/routes/downloads/page.tsx index 9b7e825b4..b82350b4e 100644 --- a/src/renderer/src/routes/downloads/page.tsx +++ b/src/renderer/src/routes/downloads/page.tsx @@ -29,6 +29,7 @@ import { DropdownMenuTrigger } from "@/components/ui/dropdown-menu"; import { Progress } from "@/components/ui/progress"; +import { cn } from "@/lib/utils"; import type { DownloadRecord, DownloadState } from "~/types/downloads"; import { CheckCircle2, @@ -140,14 +141,15 @@ function isActive(state: DownloadState): boolean { function DownloadItem({ record, - invalidate + invalidate, + fileMissing }: { record: DownloadRecord; invalidate: () => void; + fileMissing: boolean; }) { const filename = filenameFromRecord(record); - const progress = - record.totalBytes > 0 ? Math.round((record.receivedBytes / record.totalBytes) * 100) : 0; + const progress = record.totalBytes > 0 ? Math.round((record.receivedBytes / record.totalBytes) * 100) : 0; const handlePause = async () => { const ok = await flow.downloads.pause(record.id); @@ -197,14 +199,17 @@ function DownloadItem({
    - + {filename}
    - {isActive(record.state) && record.totalBytes > 0 && ( - - )} + {isActive(record.state) && record.totalBytes > 0 && }
    @@ -219,9 +224,7 @@ function DownloadItem({ )} - - +
    @@ -336,6 +339,8 @@ function DownloadsPage() { return () => window.clearTimeout(t); }, [search]); + const [fileExistence, setFileExistence] = useState>({}); + const { data, isError, isPending, refetch } = useQuery({ queryKey: ["downloads"], queryFn: () => flow.downloads.list(), @@ -346,6 +351,14 @@ function DownloadsPage() { if (isError) toast.error("Could not load downloads"); }, [isError]); + // Check file existence for non-active downloads that have a savePath + useEffect(() => { + if (!data) return; + const idsToCheck = data.filter((dl) => !isActive(dl.state) && dl.savePath).map((dl) => dl.id); + if (idsToCheck.length === 0) return; + void flow.downloads.checkFilesExist(idsToCheck).then(setFileExistence); + }, [data]); + const filtered = useMemo(() => { if (!data) return []; if (!debouncedSearch) return data; @@ -358,10 +371,7 @@ function DownloadsPage() { const grouped = useMemo(() => groupByDay(filtered), [filtered]); - const hasActiveDownloads = useMemo( - () => data?.some((dl) => isActive(dl.state)) ?? false, - [data] - ); + const hasActiveDownloads = useMemo(() => data?.some((dl) => isActive(dl.state)) ?? false, [data]); const invalidate = () => { void queryClient.invalidateQueries({ queryKey: ["downloads"] }); @@ -455,7 +465,12 @@ function DownloadsPage() {
      {group.items.map((dl) => ( - + ))}
    diff --git a/src/shared/flow/interfaces/browser/downloads.ts b/src/shared/flow/interfaces/browser/downloads.ts index 18aee4260..da856d6da 100644 --- a/src/shared/flow/interfaces/browser/downloads.ts +++ b/src/shared/flow/interfaces/browser/downloads.ts @@ -10,4 +10,5 @@ export interface FlowDownloadsAPI { openFile: (downloadId: string) => Promise; removeRecord: (downloadId: string) => Promise; clearCompleted: () => Promise; + checkFilesExist: (downloadIds: string[]) => Promise>; } From 999787e567f80a00fb3b3eb2f7f4f9afcc6402ab Mon Sep 17 00:00:00 2001 From: Evan Date: Fri, 27 Mar 2026 22:14:02 +0000 Subject: [PATCH 07/16] chore: update styling --- .../_components/bottom/downloads-popover.tsx | 142 +++-- src/renderer/src/routes/downloads/page.tsx | 512 +++++++++--------- 2 files changed, 318 insertions(+), 336 deletions(-) diff --git a/src/renderer/src/components/browser-ui/browser-sidebar/_components/bottom/downloads-popover.tsx b/src/renderer/src/components/browser-ui/browser-sidebar/_components/bottom/downloads-popover.tsx index e3c77d837..60cdb07d1 100644 --- a/src/renderer/src/components/browser-ui/browser-sidebar/_components/bottom/downloads-popover.tsx +++ b/src/renderer/src/components/browser-ui/browser-sidebar/_components/bottom/downloads-popover.tsx @@ -2,21 +2,13 @@ import { PortalPopover } from "@/components/portal/popover"; import { useSpaces } from "@/components/providers/spaces-provider"; import { Button } from "@/components/ui/button"; import { PopoverTrigger } from "@/components/ui/popover"; -import { Progress } from "@/components/ui/progress"; import { cn } from "@/lib/utils"; -import type { DownloadRecord, DownloadState } from "~/types/downloads"; -import { CheckCircle2, DownloadIcon, ExternalLink, File, Pause, Play, X, XCircle } from "lucide-react"; +import type { DownloadRecord } from "~/types/downloads"; +import { DownloadIcon, FileText } from "lucide-react"; import { useCallback, useEffect, useState } from "react"; const POLL_MS = 1500; -function formatBytes(bytes: number): string { - if (bytes === 0) return "0 B"; - const units = ["B", "KB", "MB", "GB"]; - const i = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1); - return `${(bytes / Math.pow(1024, i)).toFixed(i > 0 ? 1 : 0)} ${units[i]}`; -} - function filenameFromRecord(r: DownloadRecord): string { if (r.savePath) { const parts = r.savePath.split(/[/\\]/); @@ -25,82 +17,73 @@ function filenameFromRecord(r: DownloadRecord): string { return r.suggestedFilename; } -function StateIcon({ state, className }: { state: DownloadState; className?: string }) { - const base = cn("size-3.5 shrink-0", className); - switch (state) { - case "completed": - return ; - case "cancelled": - case "interrupted": - return ; - case "paused": - return ; - case "progressing": - return ; - } +function relativeTime(ts: number): string { + const now = Date.now(); + const diffSec = Math.floor((now - ts) / 1000); + if (diffSec < 60) return "Just now"; + const diffMin = Math.floor(diffSec / 60); + if (diffMin < 60) return `${diffMin}m ago`; + const diffHr = Math.floor(diffMin / 60); + if (diffHr < 24) return `${diffHr}h ago`; + const diffDay = Math.floor(diffHr / 24); + if (diffDay === 1) return "Yesterday"; + if (diffDay < 7) return `${diffDay}d ago`; + const diffWeek = Math.floor(diffDay / 7); + if (diffWeek === 1) return "1 week ago"; + if (diffWeek < 5) return `${diffWeek} weeks ago`; + return new Date(ts).toLocaleDateString(undefined, { month: "short", day: "numeric" }); } -function DownloadRow({ dl, fileMissing }: { dl: DownloadRecord; fileMissing: boolean }) { +function DownloadRow({ + dl, + fileMissing, + setOpen +}: { + dl: DownloadRecord; + fileMissing: boolean; + setOpen: (open: boolean) => void; +}) { const filename = filenameFromRecord(dl); - const progress = dl.totalBytes > 0 ? Math.round((dl.receivedBytes / dl.totalBytes) * 100) : 0; const isActive = dl.state === "progressing" || dl.state === "paused"; - const handlePause = () => void flow.downloads.pause(dl.id); - const handleResume = () => void flow.downloads.resume(dl.id); - const handleCancel = () => void flow.downloads.cancel(dl.id); - const handleOpen = () => void flow.downloads.openFile(dl.id); + const handleClick = () => { + if (dl.state === "completed" && !fileMissing) { + void flow.downloads.openFile(dl.id); + } else { + flow.tabs.newTab("flow://downloads", true); + setOpen(false); + } + }; return ( -
    - +
    + {/* File icon */} +
    + +
    + + {/* Text */}
    -
    {filename} -
    - {isActive && dl.totalBytes > 0 ? ( -
    - - {progress}% -
    - ) : ( -
    - - - {dl.state === "completed" && formatBytes(dl.receivedBytes)} - {dl.state === "progressing" && dl.totalBytes === 0 && formatBytes(dl.receivedBytes)} - {dl.state === "paused" && "Paused"} - {dl.state === "interrupted" && "Interrupted"} - {dl.state === "cancelled" && "Cancelled"} - -
    - )} -
    -
    - {dl.state === "progressing" && ( - - )} - {(dl.state === "paused" || (dl.state === "interrupted" && dl.canResume)) && ( - - )} - {isActive && ( - - )} - {dl.state === "completed" && !fileMissing && ( - - )} +

    +

    + {isActive + ? dl.state === "paused" + ? "Paused" + : "Downloading…" + : fileMissing + ? "Deleted" + : relativeTime(dl.endTime ?? dl.startTime)} +

    ); @@ -158,14 +141,19 @@ export function DownloadsPopover() { {shown.length === 0 ? ( -
    +

    No downloads

    ) : ( -
    +
    {shown.map((dl) => ( - + ))}
    )} diff --git a/src/renderer/src/routes/downloads/page.tsx b/src/renderer/src/routes/downloads/page.tsx index b82350b4e..f1684940b 100644 --- a/src/renderer/src/routes/downloads/page.tsx +++ b/src/renderer/src/routes/downloads/page.tsx @@ -13,7 +13,6 @@ import { AlertDialogTrigger } from "@/components/ui/alert-dialog"; import { Button } from "@/components/ui/button"; -import { Card, CardContent, CardHeader } from "@/components/ui/card"; import { ContextMenu, ContextMenuContent, @@ -21,30 +20,16 @@ import { ContextMenuSeparator, ContextMenuTrigger } from "@/components/ui/context-menu"; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; +import { cn } from "@/lib/utils"; +import type { DownloadRecord, DownloadState } from "~/types/downloads"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, - DropdownMenuSeparator, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"; -import { Progress } from "@/components/ui/progress"; -import { cn } from "@/lib/utils"; -import type { DownloadRecord, DownloadState } from "~/types/downloads"; -import { - CheckCircle2, - Download, - ExternalLink, - File, - FolderOpen, - MoreHorizontal, - Pause, - Play, - Search, - Trash2, - X, - XCircle -} from "lucide-react"; +import { Download, FileText, FolderOpen, Link2, MoreVertical, Pause, Play, Search, Trash2, X } from "lucide-react"; import { toast } from "sonner"; const POLL_INTERVAL_MS = 1500; @@ -57,38 +42,12 @@ function formatBytes(bytes: number): string { return `${value.toFixed(i > 0 ? 1 : 0)} ${units[i]}`; } -function formatTime(ms: number): string { - const d = new Date(ms); - return d.toLocaleTimeString(undefined, { hour: "numeric", minute: "2-digit" }); -} - -function stateLabel(state: DownloadState): string { - switch (state) { - case "progressing": - return "Downloading"; - case "paused": - return "Paused"; - case "interrupted": - return "Interrupted"; - case "completed": - return "Completed"; - case "cancelled": - return "Cancelled"; - } -} - -function StateIcon({ state }: { state: DownloadState }) { - switch (state) { - case "completed": - return ; - case "cancelled": - return ; - case "interrupted": - return ; - case "paused": - return ; - case "progressing": - return ; +function simplifyUrl(url: string): string { + try { + const u = new URL(url); + return u.hostname; + } catch { + return url; } } @@ -106,14 +65,12 @@ function startOfLocalDay(ts: number): number { } function daySectionLabel(ts: number): string { - const d = new Date(ts); const now = new Date(); const t0 = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime(); const t1 = t0 - 86400000; - const fullDate = d.toLocaleDateString(undefined, { weekday: "long", year: "numeric", month: "long", day: "numeric" }); - if (ts >= t0) return `Today - ${fullDate}`; - if (ts >= t1) return `Yesterday - ${fullDate}`; - return fullDate; + if (ts >= t0) return "Today"; + if (ts >= t1) return "Yesterday"; + return new Date(ts).toLocaleDateString(undefined, { year: "numeric", month: "long", day: "numeric" }); } type DayGroup = { dayStart: number; label: string; items: DownloadRecord[] }; @@ -139,7 +96,39 @@ function isActive(state: DownloadState): boolean { return state === "progressing" || state === "paused"; } -function DownloadItem({ +function IconButton({ + onClick, + label, + children, + className +}: { + onClick: () => void; + label: string; + children: React.ReactNode; + className?: string; +}) { + return ( + + + + + + {label} + + + ); +} + +function DownloadCard({ record, invalidate, fileMissing @@ -150,6 +139,7 @@ function DownloadItem({ }) { const filename = filenameFromRecord(record); const progress = record.totalBytes > 0 ? Math.round((record.receivedBytes / record.totalBytes) * 100) : 0; + const active = isActive(record.state); const handlePause = async () => { const ok = await flow.downloads.pause(record.id); @@ -181,141 +171,159 @@ function DownloadItem({ const handleRemove = async () => { const ok = await flow.downloads.removeRecord(record.id); - if (ok) { - toast.success("Removed from downloads"); - invalidate(); - } else { - toast.error("Could not remove download"); - } + if (ok) invalidate(); + else toast.error("Could not remove download"); + }; + + const handleCopyUrl = () => { + void navigator.clipboard.writeText(record.url).then( + () => toast.success("URL copied"), + () => toast.error("Could not copy URL") + ); }; return ( -
  • -
    - +
    + {/* File icon */} +
    +
    -
    -
    + {/* Info */} +
    + {/* Filename */} + {record.state === "completed" && !fileMissing ? ( + + ) : ( {filename} -
    - - {isActive(record.state) && record.totalBytes > 0 && } + )} -
    - - {stateLabel(record.state)} - {record.totalBytes > 0 && ( - <> - - - + {/* Subtitle: source URL or status */} + {active ? ( +
    +

    From {simplifyUrl(record.url)}

    + {record.totalBytes > 0 && ( + <> +

    + {formatBytes(record.receivedBytes)} of {formatBytes(record.totalBytes)} + {record.state === "paused" && " - Paused"} +

    +
    +
    +
    + + )} + {record.totalBytes === 0 && ( +

    {formatBytes(record.receivedBytes)} - {record.state !== "completed" && ` / ${formatBytes(record.totalBytes)}`} - - - )} - - - -

    + {record.state === "paused" && " - Paused"} +

    + )} +
    + ) : fileMissing ? ( +

    Deleted

    + ) : record.state === "interrupted" ? ( +

    Interrupted

    + ) : record.state === "cancelled" ? ( +

    Cancelled

    + ) : null}
    - {/* Inline actions */} -
    + {/* Actions */} +
    + {/* Active: pause/resume + cancel */} {record.state === "progressing" && ( - + void handlePause()} label="Pause"> + + )} - {(record.state === "paused" || (record.state === "interrupted" && record.canResume)) && ( - + {active && record.state === "paused" && ( + void handleResume()} label="Resume"> + + )} - {isActive(record.state) && ( - + {active && ( + void handleCancel()} label="Cancel"> + + )} - - - - - - {record.state === "completed" && ( - void handleOpenFile()}> - - Open file - + {active && ( + + + + )} + + {/* Inactive */} + {!active && ( + <> + + + + {record.savePath && !fileMissing && ( + void handleShowInFolder()} label="Show in folder"> + + )} - {record.savePath && ( - void handleShowInFolder()}> - - Show in folder - + void handleRemove()} label="Remove from list"> + + + {/* Overflow menu for resumable interrupted downloads */} + {record.canResume && ( + + + + + + void handleResume()}> + + Resume download + + + )} - {(record.state === "completed" || record.savePath) && } - void handleRemove()}> - - Remove from list - - - + + )}
    -
  • +
    - {record.state === "completed" && ( + {record.state === "completed" && !fileMissing && ( void handleOpenFile()}>Open file )} - {record.savePath && ( + {record.savePath && !fileMissing && ( void handleShowInFolder()}>Show in folder )} - { - void navigator.clipboard.writeText(record.url).then( - () => toast.success("URL copied"), - () => toast.error("Could not copy URL") - ); - }} - > - Copy download URL - + {!active && record.canResume && ( + void handleResume()}>Resume download + )} + Copy download link - {isActive(record.state) && ( + {active && ( void handleCancel()}> Cancel download @@ -371,8 +379,6 @@ function DownloadsPage() { const grouped = useMemo(() => groupByDay(filtered), [filtered]); - const hasActiveDownloads = useMemo(() => data?.some((dl) => isActive(dl.state)) ?? false, [data]); - const invalidate = () => { void queryClient.invalidateQueries({ queryKey: ["downloads"] }); }; @@ -384,107 +390,95 @@ function DownloadsPage() { }; return ( -
    - {/* Sticky top bar */} -
    -
    -

    Downloads

    - -
    - - setSearch(e.target.value)} - placeholder="Search downloads" - aria-label="Search downloads" - className="w-full h-9 pl-9 pr-3 rounded-lg border border-input bg-muted/40 text-sm text-foreground placeholder:text-muted-foreground transition-[border-color,box-shadow] outline-none focus:border-ring focus:ring-2 focus:ring-ring/30 focus:bg-background" - /> + +
    + {/* Sticky top bar */} +
    +
    +

    Downloads

    + +
    + + setSearch(e.target.value)} + placeholder="Search downloads" + aria-label="Search downloads" + className="w-full h-9 pl-9 pr-3 rounded-lg border border-input bg-muted/40 text-sm text-foreground placeholder:text-muted-foreground transition-[border-color,box-shadow] outline-none focus:border-ring focus:ring-2 focus:ring-ring/30 focus:bg-background" + /> +
    + + + + + + + + Clear completed downloads? + + This removes completed and cancelled downloads from the list. Files on disk are not affected. + + + + Cancel + void clearCompleted()}>Clear + + +
    +
    - - - - - - - Clear completed downloads? - - This removes completed and cancelled downloads from the list. Files on disk are not affected. - - - - Cancel - void clearCompleted()}>Clear - - - -
    +
    + ) : filtered.length === 0 ? ( +
    + +

    No downloads found

    +

    + {debouncedSearch ? "Try a different search." : "Files you download appear here."} +

    +
    + ) : ( + grouped.map((group) => ( +
    +

    {group.label}

    + {group.items.map((dl) => ( + + ))} +
    + )) + )} +
    - - {/* Content */} - - {isPending ? ( -
    Loading...
    - ) : isError ? ( -
    -

    Could not load downloads

    - -
    - ) : filtered.length === 0 ? ( -
    - -

    No downloads found

    -

    - {debouncedSearch ? "Try a different search." : "Files you download appear here."} -

    -
    - ) : ( - grouped.map((group) => ( - - - - {group.label} - - - -
      - {group.items.map((dl) => ( - - ))} -
    -
    -
    - )) - )} - - {filtered.length > 0 && !hasActiveDownloads && ( -
    - End of downloads -
    - )} -
    -
    + ); } From c240dd2db3453415c8a17a8827c201489872741f Mon Sep 17 00:00:00 2001 From: Evan Date: Fri, 27 Mar 2026 23:04:45 +0000 Subject: [PATCH 08/16] refactor: remove polling, add DownloadsProvider and reorganise --- .../controllers/downloads-controller/index.ts | 6 + src/main/ipc/browser/downloads.ts | 12 +- src/preload/index.ts | 3 + .../_components/bottom/downloads-popover.tsx | 32 +- .../downloads/manager/download-card.tsx | 249 +++++++++ .../src/components/downloads/manager/main.tsx | 135 +++++ .../components/downloads/manager/provider.tsx | 77 +++ .../src/components/downloads/manager/utils.ts | 62 +++ src/renderer/src/routes/downloads/config.tsx | 24 +- src/renderer/src/routes/downloads/page.tsx | 486 +----------------- .../flow/interfaces/browser/downloads.ts | 2 + 11 files changed, 563 insertions(+), 525 deletions(-) create mode 100644 src/renderer/src/components/downloads/manager/download-card.tsx create mode 100644 src/renderer/src/components/downloads/manager/main.tsx create mode 100644 src/renderer/src/components/downloads/manager/provider.tsx create mode 100644 src/renderer/src/components/downloads/manager/utils.ts diff --git a/src/main/controllers/downloads-controller/index.ts b/src/main/controllers/downloads-controller/index.ts index 67d6375de..f0ec21367 100644 --- a/src/main/controllers/downloads-controller/index.ts +++ b/src/main/controllers/downloads-controller/index.ts @@ -11,6 +11,7 @@ import { upsertDownloadRecord } from "@/saving/downloads"; import type { DownloadInsert, DownloadRow } from "@/saving/db/schema"; +import { fireDownloadsChanged } from "@/ipc/browser/downloads"; type MacOSProgressModule = typeof import("./macos-progress"); @@ -146,6 +147,7 @@ class DownloadsController { startTime: Math.floor(record.startTime / 1000) }); + fireDownloadsChanged(); debugPrint("DOWNLOADS", `Queued interrupted download restore for ${downloadId}`); return true; } catch (err) { @@ -179,6 +181,7 @@ class DownloadsController { canResume: false, endTime: Date.now() }); + fireDownloadsChanged(); debugPrint("DOWNLOADS", `Marked inactive download ${downloadId} as cancelled`); return true; } @@ -244,6 +247,7 @@ class DownloadsController { this.activeDownloadsById.set(downloadId, activeDownload); upsertDownloadRecord(this.buildDownloadInsert(item, metadata, existingRecord)); + fireDownloadsChanged(); debugPrint("DOWNLOADS", `Download requested: ${suggestedFilename} (${downloadId})`); @@ -378,6 +382,7 @@ class DownloadsController { } this.persistDownloadSnapshot(active, state, true); + fireDownloadsChanged(); debugPrint("DOWNLOADS", `Download ${state}: ${this.getSavePath(active.item) ?? active.item.getFilename()}`); } @@ -447,6 +452,7 @@ class DownloadsController { }); meta.lastPersistedAt = now; + fireDownloadsChanged(); } private getPersistedState( diff --git a/src/main/ipc/browser/downloads.ts b/src/main/ipc/browser/downloads.ts index 691d0237e..6eda5599b 100644 --- a/src/main/ipc/browser/downloads.ts +++ b/src/main/ipc/browser/downloads.ts @@ -2,6 +2,11 @@ import fs from "node:fs"; import { ipcMain, shell } from "electron"; import { downloadsController } from "@/controllers/downloads-controller"; import { deleteDownloadRecord, getDownloadRecord, listDownloads } from "@/saving/downloads"; +import { sendMessageToListeners } from "@/ipc/listeners-manager"; + +export function fireDownloadsChanged() { + sendMessageToListeners("downloads:on-changed"); +} ipcMain.handle("downloads:list", () => { return downloadsController.listDownloads(); @@ -38,16 +43,21 @@ ipcMain.handle("downloads:open-file", (_event, downloadId: string) => { }); ipcMain.handle("downloads:remove-record", (_event, downloadId: string) => { - return deleteDownloadRecord(downloadId); + const ok = deleteDownloadRecord(downloadId); + if (ok) fireDownloadsChanged(); + return ok; }); ipcMain.handle("downloads:clear-completed", () => { const downloads = listDownloads(); + let changed = false; for (const dl of downloads) { if (dl.state === "completed" || dl.state === "cancelled") { deleteDownloadRecord(dl.id); + changed = true; } } + if (changed) fireDownloadsChanged(); }); ipcMain.handle("downloads:check-files-exist", (_event, downloadIds: string[]) => { diff --git a/src/preload/index.ts b/src/preload/index.ts index 62814cf81..8fce0f491 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -440,6 +440,9 @@ const downloadsAPI: FlowDownloadsAPI = { }, checkFilesExist: async (downloadIds: string[]) => { return ipcRenderer.invoke("downloads:check-files-exist", downloadIds); + }, + onChanged: (callback: () => void) => { + return listenOnIPCChannel("downloads:on-changed", callback); } }; diff --git a/src/renderer/src/components/browser-ui/browser-sidebar/_components/bottom/downloads-popover.tsx b/src/renderer/src/components/browser-ui/browser-sidebar/_components/bottom/downloads-popover.tsx index 60cdb07d1..a9eeffb8d 100644 --- a/src/renderer/src/components/browser-ui/browser-sidebar/_components/bottom/downloads-popover.tsx +++ b/src/renderer/src/components/browser-ui/browser-sidebar/_components/bottom/downloads-popover.tsx @@ -3,20 +3,11 @@ import { useSpaces } from "@/components/providers/spaces-provider"; import { Button } from "@/components/ui/button"; import { PopoverTrigger } from "@/components/ui/popover"; import { cn } from "@/lib/utils"; +import { filenameFromRecord, isActive } from "@/components/downloads/manager/utils"; import type { DownloadRecord } from "~/types/downloads"; import { DownloadIcon, FileText } from "lucide-react"; import { useCallback, useEffect, useState } from "react"; -const POLL_MS = 1500; - -function filenameFromRecord(r: DownloadRecord): string { - if (r.savePath) { - const parts = r.savePath.split(/[/\\]/); - return parts[parts.length - 1] || r.suggestedFilename; - } - return r.suggestedFilename; -} - function relativeTime(ts: number): string { const now = Date.now(); const diffSec = Math.floor((now - ts) / 1000); @@ -44,7 +35,7 @@ function DownloadRow({ setOpen: (open: boolean) => void; }) { const filename = filenameFromRecord(dl); - const isActive = dl.state === "progressing" || dl.state === "paused"; + const active = isActive(dl.state); const handleClick = () => { if (dl.state === "completed" && !fileMissing) { @@ -60,12 +51,9 @@ function DownloadRow({ onClick={handleClick} className="flex items-center gap-3 px-3 py-2.5 rounded-lg hover:bg-accent/50 cursor-pointer transition-colors" > - {/* File icon */}
    - - {/* Text */}

    - {isActive + {active ? dl.state === "paused" ? "Paused" : "Downloading…" @@ -102,7 +90,7 @@ export function DownloadsPopover() { const all = await flow.downloads.list(); setDownloads(all); const idsToCheck = all - .filter((d) => d.state !== "progressing" && d.state !== "paused" && d.savePath) + .filter((d) => !isActive(d.state) && d.savePath) .map((d) => d.id); if (idsToCheck.length > 0) { const existence = await flow.downloads.checkFilesExist(idsToCheck); @@ -113,16 +101,18 @@ export function DownloadsPopover() { } }, []); + // Fetch on open + listen for changes while open useEffect(() => { if (!open) return; void fetchDownloads(); - const id = setInterval(() => void fetchDownloads(), POLL_MS); - return () => clearInterval(id); + const unsubscribe = flow.downloads.onChanged(() => { + void fetchDownloads(); + }); + return unsubscribe; }, [open, fetchDownloads]); - // Show up to 5 most recent, prioritizing active downloads - const active = downloads.filter((d) => d.state === "progressing" || d.state === "paused"); - const recent = downloads.filter((d) => d.state !== "progressing" && d.state !== "paused"); + const active = downloads.filter((d) => isActive(d.state)); + const recent = downloads.filter((d) => !isActive(d.state)); const shown = [...active, ...recent].slice(0, 5); const hasActive = active.length > 0; diff --git a/src/renderer/src/components/downloads/manager/download-card.tsx b/src/renderer/src/components/downloads/manager/download-card.tsx new file mode 100644 index 000000000..bf26bf129 --- /dev/null +++ b/src/renderer/src/components/downloads/manager/download-card.tsx @@ -0,0 +1,249 @@ +import { + ContextMenu, + ContextMenuContent, + ContextMenuItem, + ContextMenuSeparator, + ContextMenuTrigger +} from "@/components/ui/context-menu"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger +} from "@/components/ui/dropdown-menu"; +import { cn } from "@/lib/utils"; +import type { DownloadRecord } from "~/types/downloads"; +import { FileText, FolderOpen, Link2, MoreVertical, Pause, Play, X } from "lucide-react"; +import { toast } from "sonner"; +import { useDownloads } from "./provider"; +import { filenameFromRecord, formatBytes, isActive, simplifyUrl } from "./utils"; + +function IconButton({ + onClick, + label, + children, + className +}: { + onClick: () => void; + label: string; + children: React.ReactNode; + className?: string; +}) { + return ( + + + + + + {label} + + + ); +} + +export function DownloadCard({ record }: { record: DownloadRecord }) { + const { fileExistence } = useDownloads(); + const fileMissing = record.id in fileExistence && !fileExistence[record.id]; + const filename = filenameFromRecord(record); + const progress = record.totalBytes > 0 ? Math.round((record.receivedBytes / record.totalBytes) * 100) : 0; + const active = isActive(record.state); + + const handlePause = async () => { + const ok = await flow.downloads.pause(record.id); + if (!ok) toast.error("Could not pause download"); + }; + + const handleResume = async () => { + const ok = await flow.downloads.resume(record.id); + if (!ok) toast.error("Could not resume download"); + }; + + const handleCancel = async () => { + const ok = await flow.downloads.cancel(record.id); + if (!ok) toast.error("Could not cancel download"); + }; + + const handleShowInFolder = async () => { + const ok = await flow.downloads.showInFolder(record.id); + if (!ok) toast.error("File not found"); + }; + + const handleOpenFile = async () => { + const ok = await flow.downloads.openFile(record.id); + if (!ok) toast.error("Could not open file"); + }; + + const handleRemove = async () => { + const ok = await flow.downloads.removeRecord(record.id); + if (!ok) toast.error("Could not remove download"); + }; + + const handleCopyUrl = () => { + void navigator.clipboard.writeText(record.url).then( + () => toast.success("URL copied"), + () => toast.error("Could not copy URL") + ); + }; + + return ( + + +

    + {/* File icon */} +
    + +
    + + {/* Info */} +
    + {/* Filename */} + {record.state === "completed" && !fileMissing ? ( + + ) : ( + + {filename} + + )} + + {/* Subtitle: source URL or status */} + {active ? ( +
    +

    From {simplifyUrl(record.url)}

    + {record.totalBytes > 0 && ( + <> +

    + {formatBytes(record.receivedBytes)} of {formatBytes(record.totalBytes)} + {record.state === "paused" && " - Paused"} +

    +
    +
    +
    + + )} + {record.totalBytes === 0 && ( +

    + {formatBytes(record.receivedBytes)} + {record.state === "paused" && " - Paused"} +

    + )} +
    + ) : fileMissing ? ( +

    Deleted

    + ) : record.state === "interrupted" ? ( +

    Interrupted

    + ) : record.state === "cancelled" ? ( +

    Cancelled

    + ) : null} +
    + + {/* Actions */} +
    + {/* Active: pause/resume + cancel */} + {record.state === "progressing" && ( + void handlePause()} label="Pause"> + + + )} + {active && record.state === "paused" && ( + void handleResume()} label="Resume"> + + + )} + {active && ( + void handleCancel()} label="Cancel"> + + + )} + {active && ( + + + + )} + + {/* Inactive */} + {!active && ( + <> + + + + {record.savePath && !fileMissing && ( + void handleShowInFolder()} label="Show in folder"> + + + )} + void handleRemove()} label="Remove from list"> + + + {record.canResume && ( + + + + + + void handleResume()}> + + Resume download + + + + )} + + )} +
    +
    + + + {record.state === "completed" && !fileMissing && ( + void handleOpenFile()}>Open file + )} + {record.savePath && !fileMissing && ( + void handleShowInFolder()}>Show in folder + )} + {!active && record.canResume && ( + void handleResume()}>Resume download + )} + Copy download link + + {active && ( + void handleCancel()}> + Cancel download + + )} + void handleRemove()}> + Remove from list + + + + ); +} diff --git a/src/renderer/src/components/downloads/manager/main.tsx b/src/renderer/src/components/downloads/manager/main.tsx new file mode 100644 index 000000000..af347279b --- /dev/null +++ b/src/renderer/src/components/downloads/manager/main.tsx @@ -0,0 +1,135 @@ +import { useEffect, useMemo, useRef, useState } from "react"; +import { motion } from "motion/react"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger +} from "@/components/ui/alert-dialog"; +import { Button } from "@/components/ui/button"; +import { TooltipProvider } from "@/components/ui/tooltip"; +import { Download, Search, Trash2 } from "lucide-react"; +import { toast } from "sonner"; +import { useDownloads } from "./provider"; +import { DownloadCard } from "./download-card"; +import { filenameFromRecord, groupByDay } from "./utils"; + +export function DownloadsManagerMain() { + const { downloads, isLoading, isError, refresh } = useDownloads(); + const [search, setSearch] = useState(""); + const [debouncedSearch, setDebouncedSearch] = useState(""); + const searchRef = useRef(null); + + useEffect(() => { + const t = window.setTimeout(() => setDebouncedSearch(search.trim().toLowerCase()), 300); + return () => window.clearTimeout(t); + }, [search]); + + const filtered = useMemo(() => { + if (!debouncedSearch) return downloads; + return downloads.filter((dl) => { + const filename = filenameFromRecord(dl).toLowerCase(); + const url = dl.url.toLowerCase(); + return filename.includes(debouncedSearch) || url.includes(debouncedSearch); + }); + }, [downloads, debouncedSearch]); + + const grouped = useMemo(() => groupByDay(filtered), [filtered]); + + const clearCompleted = async () => { + await flow.downloads.clearCompleted(); + toast.success("Cleared completed downloads"); + }; + + return ( + +
    + {/* Sticky top bar */} +
    +
    +

    Downloads

    + +
    + + setSearch(e.target.value)} + placeholder="Search downloads" + aria-label="Search downloads" + className="w-full h-9 pl-9 pr-3 rounded-lg border border-input bg-muted/40 text-sm text-foreground placeholder:text-muted-foreground transition-[border-color,box-shadow] outline-none focus:border-ring focus:ring-2 focus:ring-ring/30 focus:bg-background" + /> +
    + + + + + + + + Clear completed downloads? + + This removes completed and cancelled downloads from the list. Files on disk are not affected. + + + + Cancel + void clearCompleted()}>Clear + + + +
    +
    + + {/* Content */} + + {isLoading ? ( +
    Loading...
    + ) : isError ? ( +
    +

    Could not load downloads

    + +
    + ) : filtered.length === 0 ? ( +
    + +

    No downloads found

    +

    + {debouncedSearch ? "Try a different search." : "Files you download appear here."} +

    +
    + ) : ( + grouped.map((group) => ( +
    +

    {group.label}

    + {group.items.map((dl) => ( + + ))} +
    + )) + )} +
    +
    +
    + ); +} diff --git a/src/renderer/src/components/downloads/manager/provider.tsx b/src/renderer/src/components/downloads/manager/provider.tsx new file mode 100644 index 000000000..d57f68490 --- /dev/null +++ b/src/renderer/src/components/downloads/manager/provider.tsx @@ -0,0 +1,77 @@ +import type { DownloadRecord, DownloadState } from "~/types/downloads"; +import { createContext, useCallback, useContext, useEffect, useRef, useState, type ReactNode } from "react"; + +interface DownloadsContextValue { + downloads: DownloadRecord[]; + fileExistence: Record; + isLoading: boolean; + isError: boolean; + refresh: () => void; +} + +const DownloadsContext = createContext(null); + +function isActive(state: DownloadState): boolean { + return state === "progressing" || state === "paused"; +} + +export function DownloadsProvider({ children }: { children: ReactNode }) { + const [downloads, setDownloads] = useState([]); + const [fileExistence, setFileExistence] = useState>({}); + const [isLoading, setIsLoading] = useState(true); + const [isError, setIsError] = useState(false); + const mountedRef = useRef(true); + + const fetchDownloads = useCallback(async () => { + try { + const all = await flow.downloads.list(); + if (!mountedRef.current) return; + setDownloads(all); + setIsError(false); + + // Check file existence for non-active downloads + const idsToCheck = all.filter((dl) => !isActive(dl.state) && dl.savePath).map((dl) => dl.id); + if (idsToCheck.length > 0) { + const existence = await flow.downloads.checkFilesExist(idsToCheck); + if (mountedRef.current) setFileExistence(existence); + } + } catch { + if (mountedRef.current) setIsError(true); + } finally { + if (mountedRef.current) setIsLoading(false); + } + }, []); + + // Initial fetch + useEffect(() => { + mountedRef.current = true; + void fetchDownloads(); + return () => { + mountedRef.current = false; + }; + }, [fetchDownloads]); + + // Listen for changes from backend + useEffect(() => { + const unsubscribe = flow.downloads.onChanged(() => { + void fetchDownloads(); + }); + return unsubscribe; + }, [fetchDownloads]); + + const value: DownloadsContextValue = { + downloads, + fileExistence, + isLoading, + isError, + refresh: fetchDownloads + }; + + return {children}; +} + +export function useDownloads(): DownloadsContextValue { + const ctx = useContext(DownloadsContext); + if (!ctx) throw new Error("useDownloads must be used within a DownloadsProvider"); + return ctx; +} diff --git a/src/renderer/src/components/downloads/manager/utils.ts b/src/renderer/src/components/downloads/manager/utils.ts new file mode 100644 index 000000000..d50c96410 --- /dev/null +++ b/src/renderer/src/components/downloads/manager/utils.ts @@ -0,0 +1,62 @@ +import type { DownloadRecord, DownloadState } from "~/types/downloads"; + +export function formatBytes(bytes: number): string { + if (bytes === 0) return "0 B"; + const units = ["B", "KB", "MB", "GB", "TB"]; + const i = Math.floor(Math.log(bytes) / Math.log(1024)); + const value = bytes / Math.pow(1024, i); + return `${value.toFixed(i > 0 ? 1 : 0)} ${units[i]}`; +} + +export function simplifyUrl(url: string): string { + try { + return new URL(url).hostname; + } catch { + return url; + } +} + +export function filenameFromRecord(record: DownloadRecord): string { + if (record.savePath) { + const parts = record.savePath.split(/[/\\]/); + return parts[parts.length - 1] || record.suggestedFilename; + } + return record.suggestedFilename; +} + +export function isActive(state: DownloadState): boolean { + return state === "progressing" || state === "paused"; +} + +export function startOfLocalDay(ts: number): number { + const d = new Date(ts); + return new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime(); +} + +export function daySectionLabel(ts: number): string { + const now = new Date(); + const t0 = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime(); + const t1 = t0 - 86400000; + if (ts >= t0) return "Today"; + if (ts >= t1) return "Yesterday"; + return new Date(ts).toLocaleDateString(undefined, { year: "numeric", month: "long", day: "numeric" }); +} + +export type DayGroup = { dayStart: number; label: string; items: DownloadRecord[] }; + +export function groupByDay(downloads: DownloadRecord[]): DayGroup[] { + const map = new Map(); + for (const dl of downloads) { + const key = startOfLocalDay(dl.startTime); + const list = map.get(key) ?? []; + list.push(dl); + map.set(key, list); + } + return [...map.entries()] + .sort((a, b) => b[0] - a[0]) + .map(([dayStart, items]) => ({ + dayStart, + label: daySectionLabel(dayStart), + items: items.sort((a, b) => b.startTime - a.startTime) + })); +} diff --git a/src/renderer/src/routes/downloads/config.tsx b/src/renderer/src/routes/downloads/config.tsx index 1c4b84547..5847cd35d 100644 --- a/src/renderer/src/routes/downloads/config.tsx +++ b/src/renderer/src/routes/downloads/config.tsx @@ -1,28 +1,14 @@ import { ThemeProvider } from "@/components/main/theme"; +import { DownloadsProvider } from "@/components/downloads/manager/provider"; import { RouteConfigType } from "@/types/routes"; -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { ReactNode, useState } from "react"; - -function DownloadsQueryProvider({ children }: { children: ReactNode }) { - const [queryClient] = useState( - () => - new QueryClient({ - defaultOptions: { - queries: { - staleTime: 5_000 - } - } - }) - ); - return {children}; -} +import { ReactNode } from "react"; export const RouteConfig: RouteConfigType = { Providers: ({ children }: { children: ReactNode }) => { return ( - - {children} - + + {children} + ); } }; diff --git a/src/renderer/src/routes/downloads/page.tsx b/src/renderer/src/routes/downloads/page.tsx index f1684940b..b0c8314de 100644 --- a/src/renderer/src/routes/downloads/page.tsx +++ b/src/renderer/src/routes/downloads/page.tsx @@ -1,492 +1,10 @@ -import { useEffect, useMemo, useRef, useState } from "react"; -import { useQuery, useQueryClient } from "@tanstack/react-query"; -import { motion } from "motion/react"; -import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, - AlertDialogTrigger -} from "@/components/ui/alert-dialog"; -import { Button } from "@/components/ui/button"; -import { - ContextMenu, - ContextMenuContent, - ContextMenuItem, - ContextMenuSeparator, - ContextMenuTrigger -} from "@/components/ui/context-menu"; -import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; -import { cn } from "@/lib/utils"; -import type { DownloadRecord, DownloadState } from "~/types/downloads"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger -} from "@/components/ui/dropdown-menu"; -import { Download, FileText, FolderOpen, Link2, MoreVertical, Pause, Play, Search, Trash2, X } from "lucide-react"; -import { toast } from "sonner"; - -const POLL_INTERVAL_MS = 1500; - -function formatBytes(bytes: number): string { - if (bytes === 0) return "0 B"; - const units = ["B", "KB", "MB", "GB", "TB"]; - const i = Math.floor(Math.log(bytes) / Math.log(1024)); - const value = bytes / Math.pow(1024, i); - return `${value.toFixed(i > 0 ? 1 : 0)} ${units[i]}`; -} - -function simplifyUrl(url: string): string { - try { - const u = new URL(url); - return u.hostname; - } catch { - return url; - } -} - -function filenameFromRecord(record: DownloadRecord): string { - if (record.savePath) { - const parts = record.savePath.split(/[/\\]/); - return parts[parts.length - 1] || record.suggestedFilename; - } - return record.suggestedFilename; -} - -function startOfLocalDay(ts: number): number { - const d = new Date(ts); - return new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime(); -} - -function daySectionLabel(ts: number): string { - const now = new Date(); - const t0 = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime(); - const t1 = t0 - 86400000; - if (ts >= t0) return "Today"; - if (ts >= t1) return "Yesterday"; - return new Date(ts).toLocaleDateString(undefined, { year: "numeric", month: "long", day: "numeric" }); -} - -type DayGroup = { dayStart: number; label: string; items: DownloadRecord[] }; - -function groupByDay(downloads: DownloadRecord[]): DayGroup[] { - const map = new Map(); - for (const dl of downloads) { - const key = startOfLocalDay(dl.startTime); - const list = map.get(key) ?? []; - list.push(dl); - map.set(key, list); - } - return [...map.entries()] - .sort((a, b) => b[0] - a[0]) - .map(([dayStart, items]) => ({ - dayStart, - label: daySectionLabel(dayStart), - items: items.sort((a, b) => b.startTime - a.startTime) - })); -} - -function isActive(state: DownloadState): boolean { - return state === "progressing" || state === "paused"; -} - -function IconButton({ - onClick, - label, - children, - className -}: { - onClick: () => void; - label: string; - children: React.ReactNode; - className?: string; -}) { - return ( - - - - - - {label} - - - ); -} - -function DownloadCard({ - record, - invalidate, - fileMissing -}: { - record: DownloadRecord; - invalidate: () => void; - fileMissing: boolean; -}) { - const filename = filenameFromRecord(record); - const progress = record.totalBytes > 0 ? Math.round((record.receivedBytes / record.totalBytes) * 100) : 0; - const active = isActive(record.state); - - const handlePause = async () => { - const ok = await flow.downloads.pause(record.id); - if (ok) invalidate(); - else toast.error("Could not pause download"); - }; - - const handleResume = async () => { - const ok = await flow.downloads.resume(record.id); - if (ok) invalidate(); - else toast.error("Could not resume download"); - }; - - const handleCancel = async () => { - const ok = await flow.downloads.cancel(record.id); - if (ok) invalidate(); - else toast.error("Could not cancel download"); - }; - - const handleShowInFolder = async () => { - const ok = await flow.downloads.showInFolder(record.id); - if (!ok) toast.error("File not found"); - }; - - const handleOpenFile = async () => { - const ok = await flow.downloads.openFile(record.id); - if (!ok) toast.error("Could not open file"); - }; - - const handleRemove = async () => { - const ok = await flow.downloads.removeRecord(record.id); - if (ok) invalidate(); - else toast.error("Could not remove download"); - }; - - const handleCopyUrl = () => { - void navigator.clipboard.writeText(record.url).then( - () => toast.success("URL copied"), - () => toast.error("Could not copy URL") - ); - }; - - return ( - - -
    - {/* File icon */} -
    - -
    - - {/* Info */} -
    - {/* Filename */} - {record.state === "completed" && !fileMissing ? ( - - ) : ( - - {filename} - - )} - - {/* Subtitle: source URL or status */} - {active ? ( -
    -

    From {simplifyUrl(record.url)}

    - {record.totalBytes > 0 && ( - <> -

    - {formatBytes(record.receivedBytes)} of {formatBytes(record.totalBytes)} - {record.state === "paused" && " - Paused"} -

    -
    -
    -
    - - )} - {record.totalBytes === 0 && ( -

    - {formatBytes(record.receivedBytes)} - {record.state === "paused" && " - Paused"} -

    - )} -
    - ) : fileMissing ? ( -

    Deleted

    - ) : record.state === "interrupted" ? ( -

    Interrupted

    - ) : record.state === "cancelled" ? ( -

    Cancelled

    - ) : null} -
    - - {/* Actions */} -
    - {/* Active: pause/resume + cancel */} - {record.state === "progressing" && ( - void handlePause()} label="Pause"> - - - )} - {active && record.state === "paused" && ( - void handleResume()} label="Resume"> - - - )} - {active && ( - void handleCancel()} label="Cancel"> - - - )} - {active && ( - - - - )} - - {/* Inactive */} - {!active && ( - <> - - - - {record.savePath && !fileMissing && ( - void handleShowInFolder()} label="Show in folder"> - - - )} - void handleRemove()} label="Remove from list"> - - - {/* Overflow menu for resumable interrupted downloads */} - {record.canResume && ( - - - - - - void handleResume()}> - - Resume download - - - - )} - - )} -
    -
    - - - {record.state === "completed" && !fileMissing && ( - void handleOpenFile()}>Open file - )} - {record.savePath && !fileMissing && ( - void handleShowInFolder()}>Show in folder - )} - {!active && record.canResume && ( - void handleResume()}>Resume download - )} - Copy download link - - {active && ( - void handleCancel()}> - Cancel download - - )} - void handleRemove()}> - Remove from list - - - - ); -} - -function DownloadsPage() { - const [search, setSearch] = useState(""); - const [debouncedSearch, setDebouncedSearch] = useState(""); - const searchRef = useRef(null); - const queryClient = useQueryClient(); - - useEffect(() => { - const t = window.setTimeout(() => setDebouncedSearch(search.trim().toLowerCase()), 300); - return () => window.clearTimeout(t); - }, [search]); - - const [fileExistence, setFileExistence] = useState>({}); - - const { data, isError, isPending, refetch } = useQuery({ - queryKey: ["downloads"], - queryFn: () => flow.downloads.list(), - refetchInterval: POLL_INTERVAL_MS - }); - - useEffect(() => { - if (isError) toast.error("Could not load downloads"); - }, [isError]); - - // Check file existence for non-active downloads that have a savePath - useEffect(() => { - if (!data) return; - const idsToCheck = data.filter((dl) => !isActive(dl.state) && dl.savePath).map((dl) => dl.id); - if (idsToCheck.length === 0) return; - void flow.downloads.checkFilesExist(idsToCheck).then(setFileExistence); - }, [data]); - - const filtered = useMemo(() => { - if (!data) return []; - if (!debouncedSearch) return data; - return data.filter((dl) => { - const filename = filenameFromRecord(dl).toLowerCase(); - const url = dl.url.toLowerCase(); - return filename.includes(debouncedSearch) || url.includes(debouncedSearch); - }); - }, [data, debouncedSearch]); - - const grouped = useMemo(() => groupByDay(filtered), [filtered]); - - const invalidate = () => { - void queryClient.invalidateQueries({ queryKey: ["downloads"] }); - }; - - const clearCompleted = async () => { - await flow.downloads.clearCompleted(); - toast.success("Cleared completed downloads"); - invalidate(); - }; - - return ( - -
    - {/* Sticky top bar */} -
    -
    -

    Downloads

    - -
    - - setSearch(e.target.value)} - placeholder="Search downloads" - aria-label="Search downloads" - className="w-full h-9 pl-9 pr-3 rounded-lg border border-input bg-muted/40 text-sm text-foreground placeholder:text-muted-foreground transition-[border-color,box-shadow] outline-none focus:border-ring focus:ring-2 focus:ring-ring/30 focus:bg-background" - /> -
    - - - - - - - - Clear completed downloads? - - This removes completed and cancelled downloads from the list. Files on disk are not affected. - - - - Cancel - void clearCompleted()}>Clear - - - -
    -
    - - {/* Content */} - - {isPending ? ( -
    Loading...
    - ) : isError ? ( -
    -

    Could not load downloads

    - -
    - ) : filtered.length === 0 ? ( -
    - -

    No downloads found

    -

    - {debouncedSearch ? "Try a different search." : "Files you download appear here."} -

    -
    - ) : ( - grouped.map((group) => ( -
    -

    {group.label}

    - {group.items.map((dl) => ( - - ))} -
    - )) - )} -
    -
    -
    - ); -} +import { DownloadsManagerMain } from "@/components/downloads/manager/main"; function App() { return ( <> Downloads - + ); } diff --git a/src/shared/flow/interfaces/browser/downloads.ts b/src/shared/flow/interfaces/browser/downloads.ts index da856d6da..718bd5d8c 100644 --- a/src/shared/flow/interfaces/browser/downloads.ts +++ b/src/shared/flow/interfaces/browser/downloads.ts @@ -1,3 +1,4 @@ +import type { IPCListener } from "~/flow/types"; import type { DownloadRecord } from "~/types/downloads"; export interface FlowDownloadsAPI { @@ -11,4 +12,5 @@ export interface FlowDownloadsAPI { removeRecord: (downloadId: string) => Promise; clearCompleted: () => Promise; checkFilesExist: (downloadIds: string[]) => Promise>; + onChanged: IPCListener<[]>; } From c08a893be3e430a8ee350c0cf5b36b71300291cf Mon Sep 17 00:00:00 2001 From: Evan Date: Sat, 28 Mar 2026 13:13:02 +0000 Subject: [PATCH 09/16] feat: use actual file icons --- .../protocols/_protocols/flow/file-icon.ts | 39 ++++++++++++++ .../protocols/_protocols/flow/index.ts | 2 + .../_components/bottom/downloads-popover.tsx | 16 +++--- .../downloads/manager/download-card.tsx | 12 +++-- .../downloads/manager/file-icon.tsx | 53 +++++++++++++++++++ .../src/components/downloads/manager/main.tsx | 2 +- 6 files changed, 112 insertions(+), 12 deletions(-) create mode 100644 src/main/controllers/sessions-controller/protocols/_protocols/flow/file-icon.ts create mode 100644 src/renderer/src/components/downloads/manager/file-icon.tsx diff --git a/src/main/controllers/sessions-controller/protocols/_protocols/flow/file-icon.ts b/src/main/controllers/sessions-controller/protocols/_protocols/flow/file-icon.ts new file mode 100644 index 000000000..a6605cb7b --- /dev/null +++ b/src/main/controllers/sessions-controller/protocols/_protocols/flow/file-icon.ts @@ -0,0 +1,39 @@ +import { app as electronApp } from "electron"; +import { HonoApp } from "."; +import { bufferToArrayBuffer } from "@/modules/utils"; +import path from "path"; + +type FileIconSize = "small" | "normal" | "large"; + +const FILE_ICON_SIZES = new Set(["small", "normal", "large"]); + +export function registerFileIconRoutes(app: HonoApp) { + app.get("/file-icon", async (c) => { + try { + const explicitPath = c.req.query("path"); + const filename = c.req.query("name"); + const requestedSize = c.req.query("size"); + + const targetPath = explicitPath ?? (filename ? path.join(electronApp.getPath("downloads"), filename) : null); + + if (!targetPath) { + return c.text("No file path or filename provided", 400); + } + + const size: FileIconSize = + requestedSize && FILE_ICON_SIZES.has(requestedSize as FileIconSize) + ? (requestedSize as FileIconSize) + : "normal"; + const icon = await electronApp.getFileIcon(targetPath, { size }); + + if (icon.isEmpty()) { + return c.text("No icon found", 404); + } + + return c.body(bufferToArrayBuffer(icon.toPNG()), 200, { "Content-Type": "image/png" }); + } catch (error) { + console.error("Error retrieving file icon:", error); + return c.text("Internal server error", 500); + } + }); +} diff --git a/src/main/controllers/sessions-controller/protocols/_protocols/flow/index.ts b/src/main/controllers/sessions-controller/protocols/_protocols/flow/index.ts index 39d648eef..62b2c31b9 100644 --- a/src/main/controllers/sessions-controller/protocols/_protocols/flow/index.ts +++ b/src/main/controllers/sessions-controller/protocols/_protocols/flow/index.ts @@ -1,6 +1,7 @@ import { registerFaviconRoutes } from "./favicon"; import { registerAssetsRoutes } from "./assets"; import { registerExtensionIconRoutes } from "./extension-icon"; +import { registerFileIconRoutes } from "./file-icon"; import { registerPdfCacheRoutes } from "./pdf-cache"; import { transformPathForRequest } from "../../utils"; import { type Protocol } from "electron"; @@ -17,6 +18,7 @@ export type HonoApp = typeof app; registerFaviconRoutes(app); registerAssetsRoutes(app); registerExtensionIconRoutes(app); +registerFileIconRoutes(app); registerPdfCacheRoutes(app); // Catch-all Route diff --git a/src/renderer/src/components/browser-ui/browser-sidebar/_components/bottom/downloads-popover.tsx b/src/renderer/src/components/browser-ui/browser-sidebar/_components/bottom/downloads-popover.tsx index a9eeffb8d..7b2ec4a07 100644 --- a/src/renderer/src/components/browser-ui/browser-sidebar/_components/bottom/downloads-popover.tsx +++ b/src/renderer/src/components/browser-ui/browser-sidebar/_components/bottom/downloads-popover.tsx @@ -3,9 +3,10 @@ import { useSpaces } from "@/components/providers/spaces-provider"; import { Button } from "@/components/ui/button"; import { PopoverTrigger } from "@/components/ui/popover"; import { cn } from "@/lib/utils"; +import { DownloadFileIcon } from "@/components/downloads/manager/file-icon"; import { filenameFromRecord, isActive } from "@/components/downloads/manager/utils"; import type { DownloadRecord } from "~/types/downloads"; -import { DownloadIcon, FileText } from "lucide-react"; +import { DownloadIcon } from "lucide-react"; import { useCallback, useEffect, useState } from "react"; function relativeTime(ts: number): string { @@ -51,9 +52,12 @@ function DownloadRow({ onClick={handleClick} className="flex items-center gap-3 px-3 py-2.5 rounded-lg hover:bg-accent/50 cursor-pointer transition-colors" > -
    - -
    +

    !isActive(d.state) && d.savePath) - .map((d) => d.id); + const idsToCheck = all.filter((d) => !isActive(d.state) && d.savePath).map((d) => d.id); if (idsToCheck.length > 0) { const existence = await flow.downloads.checkFilesExist(idsToCheck); setFileExistence(existence); diff --git a/src/renderer/src/components/downloads/manager/download-card.tsx b/src/renderer/src/components/downloads/manager/download-card.tsx index bf26bf129..40002b15e 100644 --- a/src/renderer/src/components/downloads/manager/download-card.tsx +++ b/src/renderer/src/components/downloads/manager/download-card.tsx @@ -14,9 +14,10 @@ import { } from "@/components/ui/dropdown-menu"; import { cn } from "@/lib/utils"; import type { DownloadRecord } from "~/types/downloads"; -import { FileText, FolderOpen, Link2, MoreVertical, Pause, Play, X } from "lucide-react"; +import { FolderOpen, Link2, MoreVertical, Pause, Play, X } from "lucide-react"; import { toast } from "sonner"; import { useDownloads } from "./provider"; +import { DownloadFileIcon } from "./file-icon"; import { filenameFromRecord, formatBytes, isActive, simplifyUrl } from "./utils"; function IconButton({ @@ -100,9 +101,12 @@ export function DownloadCard({ record }: { record: DownloadRecord }) {

    {/* File icon */} -
    - -
    + {/* Info */}
    diff --git a/src/renderer/src/components/downloads/manager/file-icon.tsx b/src/renderer/src/components/downloads/manager/file-icon.tsx new file mode 100644 index 000000000..51f91056c --- /dev/null +++ b/src/renderer/src/components/downloads/manager/file-icon.tsx @@ -0,0 +1,53 @@ +import { FileText } from "lucide-react"; +import { useEffect, useMemo, useState } from "react"; +import type { DownloadRecord } from "~/types/downloads"; + +export function DownloadFileIcon({ + record, + className, + imageClassName, + fallbackClassName, + size = "normal" +}: { + record: DownloadRecord; + className?: string; + imageClassName?: string; + fallbackClassName?: string; + size?: "small" | "normal" | "large"; +}) { + const [hasError, setHasError] = useState(false); + + const src = useMemo(() => { + if (!record.savePath && !record.suggestedFilename) return null; + + const iconUrl = new URL("flow://file-icon"); + if (record.savePath) { + iconUrl.searchParams.set("path", record.savePath); + } else { + iconUrl.searchParams.set("name", record.suggestedFilename); + } + iconUrl.searchParams.set("size", size); + return iconUrl.toString(); + }, [record.savePath, record.suggestedFilename, size]); + + useEffect(() => { + setHasError(false); + }, [src]); + + return ( +
    + {!src || hasError ? ( + + ) : ( + setHasError(true)} + /> + )} +
    + ); +} diff --git a/src/renderer/src/components/downloads/manager/main.tsx b/src/renderer/src/components/downloads/manager/main.tsx index af347279b..71938aebe 100644 --- a/src/renderer/src/components/downloads/manager/main.tsx +++ b/src/renderer/src/components/downloads/manager/main.tsx @@ -112,7 +112,7 @@ export function DownloadsManagerMain() {
    ) : filtered.length === 0 ? (
    - +

    No downloads found

    {debouncedSearch ? "Try a different search." : "Files you download appear here."} From 644e5b16424294501eaed8c25cca211f7b00ec2a Mon Sep 17 00:00:00 2001 From: Evan Date: Tue, 7 Apr 2026 13:31:19 +0100 Subject: [PATCH 10/16] chore: redesign downloads popover --- .../_components/bottom/downloads-popover.tsx | 176 ++++++++++++------ 1 file changed, 124 insertions(+), 52 deletions(-) diff --git a/src/renderer/src/components/browser-ui/browser-sidebar/_components/bottom/downloads-popover.tsx b/src/renderer/src/components/browser-ui/browser-sidebar/_components/bottom/downloads-popover.tsx index 7b2ec4a07..b3b21b67b 100644 --- a/src/renderer/src/components/browser-ui/browser-sidebar/_components/bottom/downloads-popover.tsx +++ b/src/renderer/src/components/browser-ui/browser-sidebar/_components/bottom/downloads-popover.tsx @@ -4,10 +4,11 @@ import { Button } from "@/components/ui/button"; import { PopoverTrigger } from "@/components/ui/popover"; import { cn } from "@/lib/utils"; import { DownloadFileIcon } from "@/components/downloads/manager/file-icon"; -import { filenameFromRecord, isActive } from "@/components/downloads/manager/utils"; +import { filenameFromRecord, formatBytes, isActive } from "@/components/downloads/manager/utils"; import type { DownloadRecord } from "~/types/downloads"; -import { DownloadIcon } from "lucide-react"; +import { DownloadIcon, ChevronRight, AlertTriangle } from "lucide-react"; import { useCallback, useEffect, useState } from "react"; +import { motion, AnimatePresence } from "motion/react"; function relativeTime(ts: number): string { const now = Date.now(); @@ -19,10 +20,6 @@ function relativeTime(ts: number): string { if (diffHr < 24) return `${diffHr}h ago`; const diffDay = Math.floor(diffHr / 24); if (diffDay === 1) return "Yesterday"; - if (diffDay < 7) return `${diffDay}d ago`; - const diffWeek = Math.floor(diffDay / 7); - if (diffWeek === 1) return "1 week ago"; - if (diffWeek < 5) return `${diffWeek} weeks ago`; return new Date(ts).toLocaleDateString(undefined, { month: "short", day: "numeric" }); } @@ -37,6 +34,8 @@ function DownloadRow({ }) { const filename = filenameFromRecord(dl); const active = isActive(dl.state); + const progress = dl.totalBytes > 0 ? (dl.receivedBytes / dl.totalBytes) * 100 : 0; + const showBar = active && dl.totalBytes > 0; const handleClick = () => { if (dl.state === "completed" && !fileMissing) { @@ -47,37 +46,93 @@ function DownloadRow({ } }; + const statusText = (): string => { + if (dl.state === "progressing") { + if (dl.totalBytes > 0) return `${formatBytes(dl.receivedBytes)} of ${formatBytes(dl.totalBytes)}`; + return formatBytes(dl.receivedBytes); + } + if (dl.state === "paused") { + if (dl.totalBytes > 0) return `${formatBytes(dl.receivedBytes)} of ${formatBytes(dl.totalBytes)}`; + return "Paused"; + } + if (dl.state === "completed") { + if (fileMissing) return "File deleted"; + const size = dl.totalBytes > 0 ? formatBytes(dl.totalBytes) : null; + const time = relativeTime(dl.endTime ?? dl.startTime); + return [size, time].filter(Boolean).join(" · "); + } + if (dl.state === "interrupted") return "Interrupted"; + if (dl.state === "cancelled") return "Cancelled"; + return ""; + }; + + const statusColor = + dl.state === "progressing" + ? "text-blue-400" + : dl.state === "paused" + ? "text-amber-400" + : dl.state === "interrupted" + ? "text-amber-400" + : "text-muted-foreground"; + return ( -

    - + {/* File icon */} +
    + + {dl.state === "progressing" && ( + + )} + {dl.state === "interrupted" && ( + + )} +
    + + {/* Info */}

    {filename}

    -

    - {active - ? dl.state === "paused" - ? "Paused" - : "Downloading…" - : fileMissing - ? "Deleted" - : relativeTime(dl.endTime ?? dl.startTime)} -

    + + {/* Progress bar */} + {showBar && ( +
    + +
    + )} + +
    + {dl.state === "paused" && ( + Paused · + )} + {dl.state === "interrupted" && } +

    {statusText()}

    +
    -
    + ); } @@ -103,7 +158,6 @@ export function DownloadsPopover() { } }, []); - // Fetch on open + listen for changes while open useEffect(() => { if (!open) return; void fetchDownloads(); @@ -113,10 +167,9 @@ export function DownloadsPopover() { return unsubscribe; }, [open, fetchDownloads]); - const active = downloads.filter((d) => isActive(d.state)); - const recent = downloads.filter((d) => !isActive(d.state)); - const shown = [...active, ...recent].slice(0, 5); - const hasActive = active.length > 0; + const shown = [...downloads].sort((a, b) => b.updatedAt - a.updatedAt).slice(0, 5); + const activeCount = downloads.filter((d) => isActive(d.state)).length; + const hasActive = activeCount > 0; const openDownloadsPage = () => { flow.tabs.newTab("flow://downloads", true); @@ -128,35 +181,54 @@ export function DownloadsPopover() { - + + + {/* Header */} +
    +
    + Downloads + {activeCount > 0 && ( + + {activeCount} active + + )} +
    + +
    + +
    + + {/* List */} {shown.length === 0 ? ( -
    - -

    No downloads

    +
    +
    + +
    +

    No recent downloads

    ) : ( -
    - {shown.map((dl) => ( - - ))} +
    + + {shown.map((dl) => ( + + ))} +
    )} -
    -
    - Show all downloads -
    -
    ); From 26df532f6edae92ae8890ee4ea25669dce0d4bf4 Mon Sep 17 00:00:00 2001 From: Evan Date: Tue, 7 Apr 2026 13:40:53 +0100 Subject: [PATCH 11/16] fix: issues --- drizzle/0004_add_download_manager.sql | 5 +- .../controllers/downloads-controller/index.ts | 103 +++++++++++++++--- .../protocols/_protocols/flow/file-icon.ts | 23 ++++ src/main/ipc/browser/downloads.ts | 25 +++-- .../components/downloads/manager/provider.tsx | 7 +- 5 files changed, 132 insertions(+), 31 deletions(-) diff --git a/drizzle/0004_add_download_manager.sql b/drizzle/0004_add_download_manager.sql index 6ddeef9b0..cd6721835 100644 --- a/drizzle/0004_add_download_manager.sql +++ b/drizzle/0004_add_download_manager.sql @@ -18,5 +18,6 @@ CREATE TABLE `downloads` ( `updated_at` integer NOT NULL ); --> statement-breakpoint -CREATE INDEX `idx_downloads_state` ON `downloads` (`state`);--> statement-breakpoint -CREATE INDEX `idx_downloads_updated_at` ON `downloads` (`updated_at`); \ No newline at end of file +CREATE INDEX `idx_downloads_state` ON `downloads` (`state`); +--> statement-breakpoint +CREATE INDEX `idx_downloads_updated_at` ON `downloads` (`updated_at`); diff --git a/src/main/controllers/downloads-controller/index.ts b/src/main/controllers/downloads-controller/index.ts index f0ec21367..3abb1efd1 100644 --- a/src/main/controllers/downloads-controller/index.ts +++ b/src/main/controllers/downloads-controller/index.ts @@ -17,6 +17,7 @@ type MacOSProgressModule = typeof import("./macos-progress"); const PROFILES_DIR = path.join(FLOW_DATA_DIR, "Profiles"); const DOWNLOAD_PROGRESS_PERSIST_INTERVAL_MS = 1000; +const PENDING_RESUME_TIMEOUT_MS = 30_000; interface DownloadMetadata { downloadId: string; @@ -41,6 +42,8 @@ interface PendingResumeRequest { savePath: string; lastUrl: string; autoResume: boolean; + enqueuedAt: number; + timeoutId: ReturnType; } class DownloadsController { @@ -136,16 +139,22 @@ class DownloadsController { autoResume: true }); - targetSession.createInterruptedDownload({ - path: record.savePath!, - urlChain: record.urlChain, - mimeType: record.mimeType ?? undefined, - offset: record.receivedBytes, - length: record.totalBytes, - lastModified: record.lastModified ?? undefined, - eTag: record.eTag ?? undefined, - startTime: Math.floor(record.startTime / 1000) - }); + try { + targetSession.createInterruptedDownload({ + path: record.savePath!, + urlChain: record.urlChain, + mimeType: record.mimeType ?? undefined, + offset: record.receivedBytes, + length: record.totalBytes, + lastModified: record.lastModified ?? undefined, + eTag: record.eTag ?? undefined, + startTime: Math.floor(record.startTime / 1000) + }); + } catch (createErr) { + // Clean up the pending request since createInterruptedDownload failed synchronously + this.removePendingResume(targetSession, downloadId); + throw createErr; + } fireDownloadsChanged(); debugPrint("DOWNLOADS", `Queued interrupted download restore for ${downloadId}`); @@ -493,15 +502,64 @@ class DownloadsController { } private canRestoreDownload(record: DownloadRow): boolean { - return !!record.canResume && !!record.savePath && record.urlChain.length > 0 && record.totalBytes > 0; + return ( + !!record.canResume && + !!record.savePath && + record.urlChain.length > 0 && + record.totalBytes > 0 && + (record.state === "interrupted" || record.state === "paused") + ); } - private enqueuePendingResume(session: Session, request: PendingResumeRequest): void { + private enqueuePendingResume( + session: Session, + request: Omit + ): void { const queue = this.pendingResumeRequests.get(session) ?? []; - queue.push(request); + + const timeoutId = setTimeout(() => { + this.expirePendingResume(session, request.downloadId); + }, PENDING_RESUME_TIMEOUT_MS); + + const fullRequest: PendingResumeRequest = { + ...request, + enqueuedAt: Date.now(), + timeoutId + }; + + queue.push(fullRequest); this.pendingResumeRequests.set(session, queue); } + private expirePendingResume(session: Session, downloadId: string): void { + const queue = this.pendingResumeRequests.get(session); + if (!queue) return; + + const index = queue.findIndex((r) => r.downloadId === downloadId); + if (index < 0) return; + + const [expired] = queue.splice(index, 1); + if (queue.length === 0) { + this.pendingResumeRequests.delete(session); + } + + debugError( + "DOWNLOADS", + `Pending resume request for download ${expired.downloadId} timed out after ${PENDING_RESUME_TIMEOUT_MS}ms. ` + + `The will-download event was never fired. This may indicate that createInterruptedDownload failed silently.` + ); + + // Mark the download as failed so the user knows it didn't resume + const record = getDownloadRecord(downloadId); + if (record && (record.state === "interrupted" || record.state === "paused")) { + updateDownloadRecord(downloadId, { + canResume: false, + updatedAt: Date.now() + }); + fireDownloadsChanged(); + } + } + private consumePendingResume(session: Session, item: DownloadItem): PendingResumeRequest | undefined { const queue = this.pendingResumeRequests.get(session); if (!queue || queue.length === 0) return undefined; @@ -517,6 +575,7 @@ class DownloadsController { if (matchIndex >= 0) { const [match] = queue.splice(matchIndex, 1); + clearTimeout(match.timeoutId); if (queue.length === 0) { this.pendingResumeRequests.delete(session); } @@ -525,6 +584,7 @@ class DownloadsController { if (queue.length === 1 && item.getState() === "interrupted") { const [fallback] = queue.splice(0, 1); + clearTimeout(fallback.timeoutId); this.pendingResumeRequests.delete(session); return fallback; } @@ -532,6 +592,23 @@ class DownloadsController { return undefined; } + private removePendingResume(session: Session, downloadId: string): boolean { + const queue = this.pendingResumeRequests.get(session); + if (!queue) return false; + + const index = queue.findIndex((r) => r.downloadId === downloadId); + if (index < 0) return false; + + const [removed] = queue.splice(index, 1); + clearTimeout(removed.timeoutId); + + if (queue.length === 0) { + this.pendingResumeRequests.delete(session); + } + + return true; + } + private getUrlChain(item: DownloadItem): string[] { const chain = item.getURLChain(); return chain.length > 0 ? chain : [item.getURL()]; diff --git a/src/main/controllers/sessions-controller/protocols/_protocols/flow/file-icon.ts b/src/main/controllers/sessions-controller/protocols/_protocols/flow/file-icon.ts index a6605cb7b..8e7a2b538 100644 --- a/src/main/controllers/sessions-controller/protocols/_protocols/flow/file-icon.ts +++ b/src/main/controllers/sessions-controller/protocols/_protocols/flow/file-icon.ts @@ -1,12 +1,29 @@ import { app as electronApp } from "electron"; import { HonoApp } from "."; import { bufferToArrayBuffer } from "@/modules/utils"; +import { FLOW_DATA_DIR } from "@/modules/paths"; import path from "path"; type FileIconSize = "small" | "normal" | "large"; const FILE_ICON_SIZES = new Set(["small", "normal", "large"]); +/** + * Validates that a given path is within one of the allowed directories. + * This prevents path traversal attacks and file existence probing. + */ +function isPathAllowed(targetPath: string): boolean { + const resolvedPath = path.resolve(targetPath); + const allowedDirs = [electronApp.getPath("downloads"), FLOW_DATA_DIR]; + + return allowedDirs.some((allowedDir) => { + const resolvedAllowedDir = path.resolve(allowedDir); + // Ensure the path starts with the allowed directory followed by a separator + // to prevent matching partial directory names (e.g., /downloads-evil) + return resolvedPath === resolvedAllowedDir || resolvedPath.startsWith(resolvedAllowedDir + path.sep); + }); +} + export function registerFileIconRoutes(app: HonoApp) { app.get("/file-icon", async (c) => { try { @@ -20,6 +37,12 @@ export function registerFileIconRoutes(app: HonoApp) { return c.text("No file path or filename provided", 400); } + // Validate the path is within allowed directories to prevent + // path traversal attacks and file existence probing + if (!isPathAllowed(targetPath)) { + return c.text("Access denied: path outside allowed directories", 403); + } + const size: FileIconSize = requestedSize && FILE_ICON_SIZES.has(requestedSize as FileIconSize) ? (requestedSize as FileIconSize) diff --git a/src/main/ipc/browser/downloads.ts b/src/main/ipc/browser/downloads.ts index 6eda5599b..1fc226783 100644 --- a/src/main/ipc/browser/downloads.ts +++ b/src/main/ipc/browser/downloads.ts @@ -60,15 +60,18 @@ ipcMain.handle("downloads:clear-completed", () => { if (changed) fireDownloadsChanged(); }); -ipcMain.handle("downloads:check-files-exist", (_event, downloadIds: string[]) => { - const result: Record = {}; - for (const id of downloadIds) { - const record = getDownloadRecord(id); - if (!record?.savePath) { - result[id] = false; - } else { - result[id] = fs.existsSync(record.savePath); - } - } - return result; +ipcMain.handle("downloads:check-files-exist", async (_event, downloadIds: string[]) => { + const checks = await Promise.all( + downloadIds.map(async (id) => { + const record = getDownloadRecord(id); + if (!record?.savePath) return [id, false] as const; + try { + await fs.promises.access(record.savePath); + return [id, true] as const; + } catch { + return [id, false] as const; + } + }) + ); + return Object.fromEntries(checks); }); diff --git a/src/renderer/src/components/downloads/manager/provider.tsx b/src/renderer/src/components/downloads/manager/provider.tsx index d57f68490..3f5efdc9a 100644 --- a/src/renderer/src/components/downloads/manager/provider.tsx +++ b/src/renderer/src/components/downloads/manager/provider.tsx @@ -1,5 +1,6 @@ -import type { DownloadRecord, DownloadState } from "~/types/downloads"; +import type { DownloadRecord } from "~/types/downloads"; import { createContext, useCallback, useContext, useEffect, useRef, useState, type ReactNode } from "react"; +import { isActive } from "./utils"; interface DownloadsContextValue { downloads: DownloadRecord[]; @@ -11,10 +12,6 @@ interface DownloadsContextValue { const DownloadsContext = createContext(null); -function isActive(state: DownloadState): boolean { - return state === "progressing" || state === "paused"; -} - export function DownloadsProvider({ children }: { children: ReactNode }) { const [downloads, setDownloads] = useState([]); const [fileExistence, setFileExistence] = useState>({}); From d34a5903ae9fc33be72af2bee3b8d5d970c09b2a Mon Sep 17 00:00:00 2001 From: Evan Date: Tue, 7 Apr 2026 15:55:15 +0100 Subject: [PATCH 12/16] chore: bump dependencies --- bun.lock | 62 ++++++++++++++++++++++++++++++++++++++++++++-------- package.json | 6 ++--- 2 files changed, 56 insertions(+), 12 deletions(-) diff --git a/bun.lock b/bun.lock index 017ed65f6..b016a3e26 100644 --- a/bun.lock +++ b/bun.lock @@ -19,7 +19,7 @@ "knex": "^3.2.9", "mime-types": "^3.0.1", "objcjs-types": "^0.8.0", - "posthog-node": "^5.28.11", + "posthog-node": "^5.29.0", "sharp": "^0.34.5", "tldts": "^7.0.28", }, @@ -60,7 +60,7 @@ "eslint-plugin-react": "^7.37.5", "eslint-plugin-react-hooks": "^5.2.0", "eslint-plugin-react-refresh": "^0.4.20", - "hono": "^4.12.11", + "hono": "^4.12.12", "jju": "^1.4.0", "lucide-react": "^1.7.0", "motion": "^12.38.0", @@ -78,7 +78,7 @@ "tailwindcss-animate": "^1.0.7", "tw-animate-css": "^1.2.9", "typescript": "^5.8.3", - "vite": "^8.0.5", + "vite": "^8.0.6", }, "optionalDependencies": { "objc-js": "^1.5.0", @@ -194,11 +194,11 @@ "@electron/windows-sign": ["@electron/windows-sign@1.2.2", "", { "dependencies": { "cross-dirname": "^0.1.0", "debug": "^4.3.4", "fs-extra": "^11.1.1", "minimist": "^1.2.8", "postject": "^1.0.0-alpha.6" }, "bin": { "electron-windows-sign": "bin/electron-windows-sign.js" } }, "sha512-dfZeox66AvdPtb2lD8OsIIQh12Tp0GNCRUDfBHIKGpbmopZto2/A8nSpYYLoedPIHpqkeblZ/k8OV0Gy7PYuyQ=="], - "@emnapi/core": ["@emnapi/core@1.7.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" } }, "sha512-o1uhUASyo921r2XtHYOHy7gdkGLge8ghBEQHMWmyJFoXlpU58kIrhhN3w26lpQb6dspetweapMn2CSNwQ8I4wg=="], + "@emnapi/core": ["@emnapi/core@1.9.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.0", "tslib": "^2.4.0" } }, "sha512-mukuNALVsoix/w1BJwFzwXBN/dHeejQtuVzcDsfOEsdpCumXb/E9j8w11h5S54tT1xhifGfbbSm/ICrObRb3KA=="], "@emnapi/runtime": ["@emnapi/runtime@1.7.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-PVtJr5CmLwYAU9PZDMITZoR5iAOShYREoR45EyyLrbntV50mdePTgUn4AmOw90Ifcj+x2kRjdzr1HP3RrNiHGA=="], - "@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.1.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ=="], + "@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-N10dEJNSsUx41Z6pZsXU8FjPjpBEplgH24sfkmITrBED1/U2Esum9F3lfLrMjKHHjmi557zQn7kR9R+XWXu5Rg=="], "@epic-web/invariant": ["@epic-web/invariant@1.0.0", "", {}, "sha512-lrTPqgvfFQtR/eY/qkIzp98OGdNJu0m5ji3q/nJI8v3SXkRKEnWiOxMmbvcSoAIzv/cGiuvRy57k4suKQSAdwA=="], @@ -450,7 +450,7 @@ "@pkgr/core": ["@pkgr/core@0.2.7", "", {}, "sha512-YLT9Zo3oNPJoBjBc4q8G2mjU4tqIbf5CEOORbUUr48dCD9q3umJ3IPlVqOqDakPfd2HuwccBaqlGhN4Gmr5OWg=="], - "@posthog/core": ["@posthog/core@1.24.6", "", {}, "sha512-9WkcRKqmXSWIJcca6m3VwA9YbFd4HiG2hKEtDq6FcwEHlvfDhQQUZ5/sJZ47Fw8OtyNMHQ6rW4+COttk4Bg5NQ=="], + "@posthog/core": ["@posthog/core@1.25.0", "", {}, "sha512-XKaHvRFIIN7Dw84r1eKimV1rl9DS+9XMCPPZ7P3+l8fE+rDsmumebiTFsY+q40bVXflcGW9wB+57LH0lvcGmhw=="], "@radix-ui/number": ["@radix-ui/number@1.1.1", "", {}, "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g=="], @@ -1234,7 +1234,7 @@ "hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="], - "hono": ["hono@4.12.11", "", {}, "sha512-r4xbIa3mGGGoH9nN4A14DOg2wx7y2oQyJEb5O57C/xzETG/qx4c7CVDQ5WMeKHZ7ORk2W0hZ/sQKXTav3cmYBA=="], + "hono": ["hono@4.12.12", "", {}, "sha512-p1JfQMKaceuCbpJKAPKVqyqviZdS0eUxH9v82oWo1kb9xjQ5wA6iP3FNVAPDFlz5/p7d45lO+BpSk1tuSZMF4Q=="], "hosted-git-info": ["hosted-git-info@4.1.0", "", { "dependencies": { "lru-cache": "^6.0.0" } }, "sha512-kyCuEOWjJqZuDbRHzL8V93NzQhwIB71oFWSyzVo+KPZI+pnQPPxucdkrOZvkLRnrf5URsQM+IJ09Dw29cRALIA=="], @@ -1590,7 +1590,7 @@ "postcss-selector-parser": ["postcss-selector-parser@6.0.10", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w=="], - "posthog-node": ["posthog-node@5.28.11", "", { "dependencies": { "@posthog/core": "1.24.6" }, "peerDependencies": { "rxjs": "^7.0.0" }, "optionalPeers": ["rxjs"] }, "sha512-H4FOiqKUBO8SVXyXlU5tyifeS11hyTGVwBirFPR5rPtw8X6OFs5xVLx38YL7ZBLjaa9u8is+nIWXKBwWsZ2vlw=="], + "posthog-node": ["posthog-node@5.29.0", "", { "dependencies": { "@posthog/core": "1.25.0" }, "peerDependencies": { "rxjs": "^7.0.0" }, "optionalPeers": ["rxjs"] }, "sha512-po7N55haSKxV8VOulkBZJja938yILShl6+fFjoUV3iQgOBCg4Muu615/xRg8mpNiz+UASvL0EEiGvIxdhXfj6Q=="], "postject": ["postject@1.0.0-alpha.6", "", { "dependencies": { "commander": "^9.4.0" }, "bin": { "postject": "dist/cli.js" } }, "sha512-b9Eb8h2eVqNE8edvKdwqkrY6O7kAwmI8kcnBv1NScolYJbo59XUF0noFq+lxbC1yN20bmC0WBEbDC5H/7ASb0A=="], @@ -1938,7 +1938,7 @@ "verror": ["verror@1.10.1", "", { "dependencies": { "assert-plus": "^1.0.0", "core-util-is": "1.0.2", "extsprintf": "^1.2.0" } }, "sha512-veufcmxri4e3XSrT0xwfUR7kguIkaxBeosDg00yDWhk49wdwkSUrvvsm7nc75e1PUyvIeZj6nS8VQRYz2/S4Xg=="], - "vite": ["vite@8.0.5", "", { "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", "postcss": "^8.5.8", "rolldown": "1.0.0-rc.12", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.1.0", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-nmu43Qvq9UopTRfMx2jOYW5l16pb3iDC1JH6yMuPkpVbzK0k+L7dfsEDH4jRgYFmsg0sTAqkojoZgzLMlwHsCQ=="], + "vite": ["vite@8.0.6", "", { "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", "postcss": "^8.5.8", "rolldown": "1.0.0-rc.13", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.1.0", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-jeOXoY6N8rOfit/mZADMd0misLqjRdWBB3/S23ZQNuPcbVsfMBJutWD8b4ftdczMOsNyMBnKro0Z1Kt0HIqq5Q=="], "wcwidth": ["wcwidth@1.0.1", "", { "dependencies": { "defaults": "^1.0.3" } }, "sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg=="], @@ -2054,6 +2054,8 @@ "@malept/flatpak-bundler/fs-extra": ["fs-extra@9.1.0", "", { "dependencies": { "at-least-node": "^1.0.0", "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ=="], + "@napi-rs/wasm-runtime/@emnapi/core": ["@emnapi/core@1.7.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" } }, "sha512-o1uhUASyo921r2XtHYOHy7gdkGLge8ghBEQHMWmyJFoXlpU58kIrhhN3w26lpQb6dspetweapMn2CSNwQ8I4wg=="], + "@npmcli/agent/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], "@npmcli/fs/semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="], @@ -2240,6 +2242,8 @@ "vite/picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="], + "vite/rolldown": ["rolldown@1.0.0-rc.13", "", { "dependencies": { "@oxc-project/types": "=0.123.0", "@rolldown/pluginutils": "1.0.0-rc.13" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.0.0-rc.13", "@rolldown/binding-darwin-arm64": "1.0.0-rc.13", "@rolldown/binding-darwin-x64": "1.0.0-rc.13", "@rolldown/binding-freebsd-x64": "1.0.0-rc.13", "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.13", "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.13", "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.13", "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.13", "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.13", "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.13", "@rolldown/binding-linux-x64-musl": "1.0.0-rc.13", "@rolldown/binding-openharmony-arm64": "1.0.0-rc.13", "@rolldown/binding-wasm32-wasi": "1.0.0-rc.13", "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.13", "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.13" }, "bin": { "rolldown": "bin/cli.mjs" } }, "sha512-bvVj8YJmf0rq4pSFmH7laLa6pYrhghv3PRzrCdRAr23g66zOKVJ4wkvFtgohtPLWmthgg8/rkaqRHrpUEh0Zbw=="], + "@babel/helper-module-imports/@babel/traverse/@babel/generator": ["@babel/generator@7.27.5", "", { "dependencies": { "@babel/parser": "^7.27.5", "@babel/types": "^7.27.3", "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.25", "jsesc": "^3.0.2" } }, "sha512-ZGhA37l0e/g2s1Cnzdix0O3aLYm66eF8aufiVteOgnwxgnRP8GoyMj7VWsgWnQbVKXyge7hqrFh2K2TQM6t1Hw=="], "@babel/helper-module-imports/@babel/traverse/@babel/parser": ["@babel/parser@7.27.5", "", { "dependencies": { "@babel/types": "^7.27.3" }, "bin": "./bin/babel-parser.js" }, "sha512-OsQd175SxWkGlzbny8J3K8TnnDD0N3lrIUtB92xwyRpzaenGZhxDvxN/JgU00U3CDZNj9tPuDJ5H0WS4Nt3vKg=="], @@ -2312,6 +2316,8 @@ "@jimp/core/file-type/token-types": ["token-types@4.2.1", "", { "dependencies": { "@tokenizer/token": "^0.3.0", "ieee754": "^1.2.1" } }, "sha512-6udB24Q737UD/SDsKAHI9FCRP7Bqc9D/MQUV02ORQg5iskjtLJlZJNdN4kKtcdtwCeWIwIHDGaUsTsCCAa8sFQ=="], + "@napi-rs/wasm-runtime/@emnapi/core/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.1.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ=="], + "@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime/@emnapi/core": ["@emnapi/core@1.7.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" } }, "sha512-o1uhUASyo921r2XtHYOHy7gdkGLge8ghBEQHMWmyJFoXlpU58kIrhhN3w26lpQb6dspetweapMn2CSNwQ8I4wg=="], "@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime/@emnapi/runtime": ["@emnapi/runtime@1.7.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-PVtJr5CmLwYAU9PZDMITZoR5iAOShYREoR45EyyLrbntV50mdePTgUn4AmOw90Ifcj+x2kRjdzr1HP3RrNiHGA=="], @@ -2418,6 +2424,40 @@ "tsx/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.7", "", { "os": "win32", "cpu": "x64" }, "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg=="], + "vite/rolldown/@oxc-project/types": ["@oxc-project/types@0.123.0", "", {}, "sha512-YtECP/y8Mj1lSHiUWGSRzy/C6teUKlS87dEfuVKT09LgQbUsBW1rNg+MiJ4buGu3yuADV60gbIvo9/HplA56Ew=="], + + "vite/rolldown/@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.0.0-rc.13", "", { "os": "android", "cpu": "arm64" }, "sha512-5ZiiecKH2DXAVJTNN13gNMUcCDg4Jy8ZjbXEsPnqa248wgOVeYRX0iqXXD5Jz4bI9BFHgKsI2qmyJynstbmr+g=="], + + "vite/rolldown/@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.0.0-rc.13", "", { "os": "darwin", "cpu": "arm64" }, "sha512-tz/v/8G77seu8zAB3A5sK3UFoOl06zcshEzhUO62sAEtrEuW/H1CcyoupOrD+NbQJytYgA4CppXPzlrmp4JZKA=="], + + "vite/rolldown/@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.0.0-rc.13", "", { "os": "darwin", "cpu": "x64" }, "sha512-8DakphqOz8JrMYWTJmWA+vDJxut6LijZ8Xcdc4flOlAhU7PNVwo2MaWBF9iXjJAPo5rC/IxEFZDhJ3GC7NHvug=="], + + "vite/rolldown/@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.0.0-rc.13", "", { "os": "freebsd", "cpu": "x64" }, "sha512-4wBQFfjDuXYN/SVI8inBF3Aa+isq40rc6VMFbk5jcpolUBTe5cYnMsHZ51nFWsx3PVyyNN3vgoESki0Hmr/4BA=="], + + "vite/rolldown/@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.13", "", { "os": "linux", "cpu": "arm" }, "sha512-JW/e4yPIXLms+jmnbwwy5LA/LxVwZUWLN8xug+V200wzaVi5TEGIWQlh8o91gWYFxW609euI98OCCemmWGuPrw=="], + + "vite/rolldown/@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.0.0-rc.13", "", { "os": "linux", "cpu": "arm64" }, "sha512-ZfKWpXiUymDnavepCaM6KG/uGydJ4l2nBmMxg60Ci4CbeefpqjPWpfaZM7PThOhk2dssqBAcwLc6rAyr0uTdXg=="], + + "vite/rolldown/@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.0.0-rc.13", "", { "os": "linux", "cpu": "arm64" }, "sha512-bmRg3O6Z0gq9yodKKWCIpnlH051sEfdVwt+6m5UDffAQMUUqU0xjnQqqAUm+Gu7ofAAly9DqiQDtKu2nPDEABA=="], + + "vite/rolldown/@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.13", "", { "os": "linux", "cpu": "ppc64" }, "sha512-8Wtnbw4k7pMYN9B/mOEAsQ8HOiq7AZ31Ig4M9BKn2So4xRaFEhtCSa4ZJaOutOWq50zpgR4N5+L/opnlaCx8wQ=="], + + "vite/rolldown/@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.0.0-rc.13", "", { "os": "linux", "cpu": "s390x" }, "sha512-D/0Nlo8mQuxSMohNJUF2lDXWRsFDsHldfRRgD9bRgktj+EndGPj4DOV37LqDKPYS+osdyhZEH7fTakTAEcW7qg=="], + + "vite/rolldown/@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.0.0-rc.13", "", { "os": "linux", "cpu": "x64" }, "sha512-eRrPvat2YaVQcwwKi/JzOP6MKf1WRnOCr+VaI3cTWz3ZoLcP/654z90lVCJ4dAuMEpPdke0n+qyAqXDZdIC4rA=="], + + "vite/rolldown/@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.0.0-rc.13", "", { "os": "linux", "cpu": "x64" }, "sha512-PsdONiFRp8hR8KgVjTWjZ9s7uA3uueWL0t74/cKHfM4dR5zXYv4AjB8BvA+QDToqxAFg4ZkcVEqeu5F7inoz5w=="], + + "vite/rolldown/@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.0.0-rc.13", "", { "os": "none", "cpu": "arm64" }, "sha512-hCNXgC5dI3TVOLrPT++PKFNZ+1EtS0mLQwfXXXSUD/+rGlB65gZDwN/IDuxLpQP4x8RYYHqGomlUXzpO8aVI2w=="], + + "vite/rolldown/@rolldown/binding-wasm32-wasi": ["@rolldown/binding-wasm32-wasi@1.0.0-rc.13", "", { "dependencies": { "@emnapi/core": "1.9.1", "@emnapi/runtime": "1.9.1", "@napi-rs/wasm-runtime": "^1.1.2" }, "cpu": "none" }, "sha512-viLS5C5et8NFtLWw9Sw3M/w4vvnVkbWkO7wSNh3C+7G1+uCkGpr6PcjNDSFcNtmXY/4trjPBqUfcOL+P3sWy/g=="], + + "vite/rolldown/@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.0.0-rc.13", "", { "os": "win32", "cpu": "arm64" }, "sha512-Fqa3Tlt1xL4wzmAYxGNFV36Hb+VfPc9PYU+E25DAnswXv3ODDu/yyWjQDbXMo5AGWkQVjLgQExuVu8I/UaZhPQ=="], + + "vite/rolldown/@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.0.0-rc.13", "", { "os": "win32", "cpu": "x64" }, "sha512-/pLI5kPkGEi44TDlnbio3St/5gUFeN51YWNAk/Gnv6mEQBOahRBh52qVFVBpmrnU01n2yysvBML9Ynu7K4kGAQ=="], + + "vite/rolldown/@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.13", "", {}, "sha512-3ngTAv6F/Py35BsYbeeLeecvhMKdsKm4AoOETVhAA+Qc8nrA2I0kF7oa93mE9qnIurngOSpMnQ0x2nQY2FPviA=="], + "@babel/helper-module-imports/@babel/traverse/@babel/generator/@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.8", "", { "dependencies": { "@jridgewell/set-array": "^1.2.1", "@jridgewell/sourcemap-codec": "^1.4.10", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-imAbBGkb+ebQyxKgzv5Hu2nmROxoDOXHh80evxdoXNOrvAnVx7zimzc1Oo5h9RlfV4vPXaE2iM5pOFbvOCClWA=="], "@babel/helper-module-imports/@babel/traverse/@babel/generator/@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.25", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ=="], @@ -2432,6 +2472,10 @@ "cli-truncate/string-width/strip-ansi/ansi-regex": ["ansi-regex@6.1.0", "", {}, "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA=="], + "vite/rolldown/@rolldown/binding-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.9.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-VYi5+ZVLhpgK4hQ0TAjiQiZ6ol0oe4mBx7mVv7IflsiEp0OWoVsp/+f9Vc1hOhE0TtkORVrI1GvzyreqpgWtkA=="], + + "vite/rolldown/@rolldown/binding-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.2", "", { "dependencies": { "@tybys/wasm-util": "^0.10.1" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" } }, "sha512-sNXv5oLJ7ob93xkZ1XnxisYhGYXfaG9f65/ZgYuAu3qt7b3NadcOEhLvx28hv31PgX8SZJRYrAIPQilQmFpLVw=="], + "@babel/helper-module-imports/@babel/traverse/@babel/generator/@jridgewell/gen-mapping/@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.0", "", {}, "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ=="], "@babel/helper-module-imports/@babel/traverse/@babel/generator/@jridgewell/trace-mapping/@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.0", "", {}, "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ=="], diff --git a/package.json b/package.json index 92e540fbe..e1749f234 100644 --- a/package.json +++ b/package.json @@ -49,7 +49,7 @@ "knex": "^3.2.9", "mime-types": "^3.0.1", "objcjs-types": "^0.8.0", - "posthog-node": "^5.28.11", + "posthog-node": "^5.29.0", "sharp": "^0.34.5", "tldts": "^7.0.28" }, @@ -93,7 +93,7 @@ "eslint-plugin-react": "^7.37.5", "eslint-plugin-react-hooks": "^5.2.0", "eslint-plugin-react-refresh": "^0.4.20", - "hono": "^4.12.11", + "hono": "^4.12.12", "jju": "^1.4.0", "lucide-react": "^1.7.0", "motion": "^12.38.0", @@ -111,7 +111,7 @@ "tailwindcss-animate": "^1.0.7", "tw-animate-css": "^1.2.9", "typescript": "^5.8.3", - "vite": "^8.0.5" + "vite": "^8.0.6" }, "pnpm": { "onlyBuiltDependencies": [ From ac210f4fc7c8fb52865c051737165f0f05321d8a Mon Sep 17 00:00:00 2001 From: Evan Date: Tue, 7 Apr 2026 16:08:43 +0100 Subject: [PATCH 13/16] chore: upgrade typescript --- bun.lock | 4 ++-- package.json | 2 +- src/main/ipc/webauthn/index.ts | 4 +++- src/shared/types/fido2-types.ts | 2 +- tsconfig.node.json | 5 ++--- tsconfig.scripts.json | 3 +-- tsconfig.web.json | 6 +++--- 7 files changed, 13 insertions(+), 13 deletions(-) diff --git a/bun.lock b/bun.lock index b016a3e26..040efdf25 100644 --- a/bun.lock +++ b/bun.lock @@ -77,7 +77,7 @@ "tailwindcss": "^4.2.2", "tailwindcss-animate": "^1.0.7", "tw-animate-css": "^1.2.9", - "typescript": "^5.8.3", + "typescript": "^6.0.2", "vite": "^8.0.6", }, "optionalDependencies": { @@ -1904,7 +1904,7 @@ "typed-array-length": ["typed-array-length@1.0.7", "", { "dependencies": { "call-bind": "^1.0.7", "for-each": "^0.3.3", "gopd": "^1.0.1", "is-typed-array": "^1.1.13", "possible-typed-array-names": "^1.0.0", "reflect.getprototypeof": "^1.0.6" } }, "sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg=="], - "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + "typescript": ["typescript@6.0.2", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-bGdAIrZ0wiGDo5l8c++HWtbaNCWTS4UTv7RaTH/ThVIgjkveJt83m74bBHMJkuCbslY8ixgLBVZJIOiQlQTjfQ=="], "typescript-eslint": ["typescript-eslint@8.34.0", "", { "dependencies": { "@typescript-eslint/eslint-plugin": "8.34.0", "@typescript-eslint/parser": "8.34.0", "@typescript-eslint/utils": "8.34.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <5.9.0" } }, "sha512-MRpfN7uYjTrTGigFCt8sRyNqJFhjN0WwZecldaqhWm+wy0gaRt8Edb/3cuUy0zdq2opJWT6iXINKAtewnDOltQ=="], diff --git a/package.json b/package.json index e1749f234..9fc17ebfc 100644 --- a/package.json +++ b/package.json @@ -110,7 +110,7 @@ "tailwindcss": "^4.2.2", "tailwindcss-animate": "^1.0.7", "tw-animate-css": "^1.2.9", - "typescript": "^5.8.3", + "typescript": "^6.0.2", "vite": "^8.0.6" }, "pnpm": { diff --git a/src/main/ipc/webauthn/index.ts b/src/main/ipc/webauthn/index.ts index 1a6e9b02a..7838e19ab 100644 --- a/src/main/ipc/webauthn/index.ts +++ b/src/main/ipc/webauthn/index.ts @@ -85,7 +85,9 @@ ipcMain.handle( return result.error; } - return result.data; + // types error in electron-webauthn (TODO: fix this) + // result.data.extensions.prf.first should be `string`, but its `string | undefined` in the package types + return result.data as CreateCredentialResult; } ); diff --git a/src/shared/types/fido2-types.ts b/src/shared/types/fido2-types.ts index 9a0c42e4f..c2dda95d7 100644 --- a/src/shared/types/fido2-types.ts +++ b/src/shared/types/fido2-types.ts @@ -149,7 +149,7 @@ export interface CreateCredentialResult { prf?: { enabled?: boolean; results: { - first?: string; // b64 encoded + first: string; // b64 encoded second?: string; // b64 encoded }; }; diff --git a/tsconfig.node.json b/tsconfig.node.json index b18f2aa7e..40045a3e5 100644 --- a/tsconfig.node.json +++ b/tsconfig.node.json @@ -5,12 +5,11 @@ "composite": true, "moduleResolution": "bundler", "types": ["electron-vite/node"], - "baseUrl": ".", "paths": { "@/*": [ - "src/main/*" + "./src/main/*" ], - "~/*": ["src/shared/*"] + "~/*": ["./src/shared/*"] } } } diff --git a/tsconfig.scripts.json b/tsconfig.scripts.json index 1da3955c9..229d1a781 100644 --- a/tsconfig.scripts.json +++ b/tsconfig.scripts.json @@ -5,9 +5,8 @@ "composite": true, "moduleResolution": "bundler", "types": ["electron-vite/node"], - "baseUrl": ".", "paths": { - "@/*": ["scripts/*"] + "@/*": ["./scripts/*"] } } } diff --git a/tsconfig.web.json b/tsconfig.web.json index 0564c58d7..5a83c42dd 100644 --- a/tsconfig.web.json +++ b/tsconfig.web.json @@ -9,13 +9,13 @@ ], "compilerOptions": { "composite": true, + "types": ["@types/chrome"], "jsx": "react-jsx", - "baseUrl": ".", "paths": { "@/*": [ - "src/renderer/src/*" + "./src/renderer/src/*" ], - "~/*": ["src/shared/*"] + "~/*": ["./src/shared/*"] } } } From 84335aa8e99180c19821c21ab3cf0a0b224305d7 Mon Sep 17 00:00:00 2001 From: Evan Date: Tue, 7 Apr 2026 16:14:49 +0100 Subject: [PATCH 14/16] chore: update `@types/node` to match electron node version --- bun.lock | 18 +++++++++--------- package.json | 2 +- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/bun.lock b/bun.lock index 040efdf25..aaf1562c3 100644 --- a/bun.lock +++ b/bun.lock @@ -41,7 +41,7 @@ "@types/d3-drag": "^3.0.7", "@types/d3-selection": "^3.0.11", "@types/jju": "^1.4.5", - "@types/node": "^22.19.11", + "@types/node": "^24.12.2", "@types/react": "^19.2.14", "@types/react-dom": "^19.1.5", "@vitejs/plugin-react": "^6.0.1", @@ -722,7 +722,7 @@ "@types/ms": ["@types/ms@2.1.0", "", {}, "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="], - "@types/node": ["@types/node@22.19.17", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q=="], + "@types/node": ["@types/node@24.12.2", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-A1sre26ke7HDIuY/M23nd9gfB+nrmhtYyMINbjI1zHJxYteKR6qSMX56FsmjMcDb3SMcjJg5BiRRgOCC/yBD0g=="], "@types/plist": ["@types/plist@3.0.5", "", { "dependencies": { "@types/node": "*", "xmlbuilder": ">=11.0.1" } }, "sha512-E6OCaRmAe4WDmWNsL/9RMqdkkzDCY1etutkflWk4c+AcjDU07Pcz1fQwTX0TQz+Pxqn9i4L1TU3UFpjnrcDgxA=="], @@ -1912,7 +1912,7 @@ "unbox-primitive": ["unbox-primitive@1.1.0", "", { "dependencies": { "call-bound": "^1.0.3", "has-bigints": "^1.0.2", "has-symbols": "^1.1.0", "which-boxed-primitive": "^1.1.1" } }, "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw=="], - "undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], + "undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], "unique-filename": ["unique-filename@4.0.0", "", { "dependencies": { "unique-slug": "^5.0.0" } }, "sha512-XSnEewXmQ+veP7xX2dS5Q4yZAvO40cBN2MWkJ7D/6sW4Dg6wYBNwM1Vrnz1FhH5AdeLIlUXRI9e28z1YZi71NQ=="], @@ -2140,8 +2140,6 @@ "dmg-builder/js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="], - "electron/@types/node": ["@types/node@24.10.13", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-oH72nZRfDv9lADUBSo104Aq7gPHpQZc4BTx38r9xf9pg5LfP6EzSyH2n7qFmmxRQXh7YlUXODcYsg6PuTDSxGg=="], - "electron-chrome-web-store/@types/chrome": ["@types/chrome@0.0.287", "", { "dependencies": { "@types/filesystem": "*", "@types/har-format": "*" } }, "sha512-wWhBNPNXZHwycHKNYnexUcpSbrihVZu++0rdp6GEk5ZgAglenLx+RwdEouh6FrHS0XQiOxSd62yaujM1OoQlZQ=="], "electron-updater/semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="], @@ -2322,9 +2320,13 @@ "@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime/@emnapi/runtime": ["@emnapi/runtime@1.7.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-PVtJr5CmLwYAU9PZDMITZoR5iAOShYREoR45EyyLrbntV50mdePTgUn4AmOw90Ifcj+x2kRjdzr1HP3RrNiHGA=="], - "@types/fs-extra/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], + "@types/cacheable-request/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], + + "@types/keyv/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], - "@types/plist/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], + "@types/responselike/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], + + "@types/yauzl/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], "@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="], @@ -2348,8 +2350,6 @@ "electron-winstaller/fs-extra/universalify": ["universalify@0.1.2", "", {}, "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg=="], - "electron/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], - "filelist/minimatch/brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="], "form-data/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], diff --git a/package.json b/package.json index 9fc17ebfc..952a25564 100644 --- a/package.json +++ b/package.json @@ -74,7 +74,7 @@ "@types/d3-drag": "^3.0.7", "@types/d3-selection": "^3.0.11", "@types/jju": "^1.4.5", - "@types/node": "^22.19.11", + "@types/node": "^24.12.2", "@types/react": "^19.2.14", "@types/react-dom": "^19.1.5", "@vitejs/plugin-react": "^6.0.1", From 69c9fb97c189782723b50cc23ab7cf6807de1bb5 Mon Sep 17 00:00:00 2001 From: Evan Date: Tue, 7 Apr 2026 17:03:41 +0100 Subject: [PATCH 15/16] chore: bump eslint plugin versions --- bun.lock | 16 ++- eslint.config.mjs | 28 +++-- package.json | 4 +- .../src/components/ui/resizable-sidebar.tsx | 4 +- src/renderer/src/components/ui/sidebar.tsx | 4 +- src/renderer/src/hooks/use-bounding-rect.tsx | 2 + .../src/hooks/use-css-size-to-pixels.tsx | 111 ------------------ src/renderer/src/hooks/use-favicon-color.ts | 15 +-- .../routes/pdf-viewer/pdf-viewer/index.tsx | 11 +- 9 files changed, 44 insertions(+), 151 deletions(-) delete mode 100644 src/renderer/src/hooks/use-css-size-to-pixels.tsx diff --git a/bun.lock b/bun.lock index aaf1562c3..97fcb6fec 100644 --- a/bun.lock +++ b/bun.lock @@ -58,8 +58,8 @@ "electron-vite": "^5.0.0", "eslint": "^9.26.0", "eslint-plugin-react": "^7.37.5", - "eslint-plugin-react-hooks": "^5.2.0", - "eslint-plugin-react-refresh": "^0.4.20", + "eslint-plugin-react-hooks": "^7.0.1", + "eslint-plugin-react-refresh": "^0.5.2", "hono": "^4.12.12", "jju": "^1.4.0", "lucide-react": "^1.7.0", @@ -1078,9 +1078,9 @@ "eslint-plugin-react": ["eslint-plugin-react@7.37.5", "", { "dependencies": { "array-includes": "^3.1.8", "array.prototype.findlast": "^1.2.5", "array.prototype.flatmap": "^1.3.3", "array.prototype.tosorted": "^1.1.4", "doctrine": "^2.1.0", "es-iterator-helpers": "^1.2.1", "estraverse": "^5.3.0", "hasown": "^2.0.2", "jsx-ast-utils": "^2.4.1 || ^3.0.0", "minimatch": "^3.1.2", "object.entries": "^1.1.9", "object.fromentries": "^2.0.8", "object.values": "^1.2.1", "prop-types": "^15.8.1", "resolve": "^2.0.0-next.5", "semver": "^6.3.1", "string.prototype.matchall": "^4.0.12", "string.prototype.repeat": "^1.0.0" }, "peerDependencies": { "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7" } }, "sha512-Qteup0SqU15kdocexFNAJMvCJEfa2xUKNV4CC1xsVMrIIqEy3SQ/rqyxCWNzfrd3/ldy6HMlD2e0JDVpDg2qIA=="], - "eslint-plugin-react-hooks": ["eslint-plugin-react-hooks@5.2.0", "", { "peerDependencies": { "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" } }, "sha512-+f15FfK64YQwZdJNELETdn5ibXEUQmW1DZL6KXhNnc2heoy/sg9VJJeT7n8TlMWouzWqSWavFkIhHyIbIAEapg=="], + "eslint-plugin-react-hooks": ["eslint-plugin-react-hooks@7.0.1", "", { "dependencies": { "@babel/core": "^7.24.4", "@babel/parser": "^7.24.4", "hermes-parser": "^0.25.1", "zod": "^3.25.0 || ^4.0.0", "zod-validation-error": "^3.5.0 || ^4.0.0" }, "peerDependencies": { "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" } }, "sha512-O0d0m04evaNzEPoSW+59Mezf8Qt0InfgGIBJnpC0h3NH/WjUAR7BIKUfysC6todmtiZ/A0oUVS8Gce0WhBrHsA=="], - "eslint-plugin-react-refresh": ["eslint-plugin-react-refresh@0.4.26", "", { "peerDependencies": { "eslint": ">=8.40" } }, "sha512-1RETEylht2O6FM/MvgnyvT+8K21wLqDNg4qD51Zj3guhjt433XbnnkVttHMyaVyAFD03QSV4LPS5iE3VQmO7XQ=="], + "eslint-plugin-react-refresh": ["eslint-plugin-react-refresh@0.5.2", "", { "peerDependencies": { "eslint": "^9 || ^10" } }, "sha512-hmgTH57GfzoTFjVN0yBwTggnsVUF2tcqi7RJZHqi9lIezSs4eFyAMktA68YD4r5kNw1mxyY4dmkyoFDb3FIqrA=="], "eslint-scope": ["eslint-scope@8.4.0", "", { "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^5.2.0" } }, "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg=="], @@ -1234,6 +1234,10 @@ "hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="], + "hermes-estree": ["hermes-estree@0.25.1", "", {}, "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw=="], + + "hermes-parser": ["hermes-parser@0.25.1", "", { "dependencies": { "hermes-estree": "0.25.1" } }, "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA=="], + "hono": ["hono@4.12.12", "", {}, "sha512-p1JfQMKaceuCbpJKAPKVqyqviZdS0eUxH9v82oWo1kb9xjQ5wA6iP3FNVAPDFlz5/p7d45lO+BpSk1tuSZMF4Q=="], "hosted-git-info": ["hosted-git-info@4.1.0", "", { "dependencies": { "lru-cache": "^6.0.0" } }, "sha512-kyCuEOWjJqZuDbRHzL8V93NzQhwIB71oFWSyzVo+KPZI+pnQPPxucdkrOZvkLRnrf5URsQM+IJ09Dw29cRALIA=="], @@ -1980,6 +1984,10 @@ "yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="], + "zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="], + + "zod-validation-error": ["zod-validation-error@4.0.2", "", { "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ=="], + "zustand": ["zustand@5.0.9", "", { "peerDependencies": { "@types/react": ">=18.0.0", "immer": ">=9.0.6", "react": ">=18.0.0", "use-sync-external-store": ">=1.2.0" }, "optionalPeers": ["@types/react", "immer", "react", "use-sync-external-store"] }, "sha512-ALBtUj0AfjJt3uNRQoL1tL2tMvj6Gp/6e39dnfT6uzpelGru8v1tPOGBzayOWbPJvujM8JojDk3E1LxeFisBNg=="], "@atlaskit/pragmatic-drag-and-drop-hitbox/@atlaskit/pragmatic-drag-and-drop": ["@atlaskit/pragmatic-drag-and-drop@1.7.2", "", { "dependencies": { "@babel/runtime": "^7.0.0", "bind-event-listener": "^3.0.0", "raf-schd": "^4.0.3" } }, "sha512-GFlFVusm+PpzNwpk4ju8+w9a9pWD5NIGi4DoJ9g6CXTUMlQ4BCsivvmUk+azsV31luEKZtf2J0tg1y3vdltrTQ=="], diff --git a/eslint.config.mjs b/eslint.config.mjs index 164de1924..ecac8007e 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -1,29 +1,31 @@ import tseslint from "@electron-toolkit/eslint-config-ts"; import eslintPluginReact from "eslint-plugin-react"; import eslintPluginReactHooks from "eslint-plugin-react-hooks"; -import eslintPluginReactRefresh from "eslint-plugin-react-refresh"; - +import { reactRefresh } from "eslint-plugin-react-refresh"; export default tseslint.config( { ignores: ["**/node_modules", "**/dist", "**/out", "**/public", "src/renderer/src/lib/omnibox-new/bangs.ts"] }, tseslint.configs.recommended, eslintPluginReact.configs.flat.recommended, eslintPluginReact.configs.flat["jsx-runtime"], { - settings: { - react: { - version: "detect" - } + files: ["**/*.{ts,tsx}"], + ...eslintPluginReactHooks.configs.flat.recommended, + rules: { + "react-hooks/refs": "off", + "react-hooks/set-state-in-effect": "off", + "react-hooks/purity": "off", + "react-hooks/immutability": "off" } }, { files: ["**/*.{ts,tsx}"], - plugins: { - "react-hooks": eslintPluginReactHooks, - "react-refresh": eslintPluginReactRefresh - }, - rules: { - ...eslintPluginReactHooks.configs.recommended.rules, - ...eslintPluginReactRefresh.configs.vite.rules + ...reactRefresh.configs.vite() + }, + { + settings: { + react: { + version: "detect" + } } }, { diff --git a/package.json b/package.json index 952a25564..0ad6eaaca 100644 --- a/package.json +++ b/package.json @@ -91,8 +91,8 @@ "electron-vite": "^5.0.0", "eslint": "^9.26.0", "eslint-plugin-react": "^7.37.5", - "eslint-plugin-react-hooks": "^5.2.0", - "eslint-plugin-react-refresh": "^0.4.20", + "eslint-plugin-react-hooks": "^7.0.1", + "eslint-plugin-react-refresh": "^0.5.2", "hono": "^4.12.12", "jju": "^1.4.0", "lucide-react": "^1.7.0", diff --git a/src/renderer/src/components/ui/resizable-sidebar.tsx b/src/renderer/src/components/ui/resizable-sidebar.tsx index ce84d6e4d..b25062ed7 100644 --- a/src/renderer/src/components/ui/resizable-sidebar.tsx +++ b/src/renderer/src/components/ui/resizable-sidebar.tsx @@ -632,9 +632,9 @@ function SidebarMenuSkeleton({ showIcon?: boolean; }) { // Random width between 50 to 90%. - const width = React.useMemo(() => { + const [width] = React.useState(() => { return `${Math.floor(Math.random() * 40) + 50}%`; - }, []); + }); return (
    { + const [width] = React.useState(() => { return `${Math.floor(Math.random() * 40) + 50}%`; - }, []); + }); return (
    ( const keepGoing = loopRef.current || stableFramesRef.current < SETTLE_FRAMES; if (keepGoing) { + // `tick` will never change + // eslint-disable-next-line react-hooks/immutability rafIdRef.current = requestAnimationFrame(tick); } else { runningRef.current = false; diff --git a/src/renderer/src/hooks/use-css-size-to-pixels.tsx b/src/renderer/src/hooks/use-css-size-to-pixels.tsx deleted file mode 100644 index 030b0911c..000000000 --- a/src/renderer/src/hooks/use-css-size-to-pixels.tsx +++ /dev/null @@ -1,111 +0,0 @@ -import { useState, useEffect, useMemo, RefObject } from "react"; -// Assuming your function is exported from this path -import { cssSizeToPixels } from "@/lib/css"; - -// Helper to check if ResizeObserver is supported -const hasResizeObserver = typeof window !== "undefined" && typeof ResizeObserver !== "undefined"; - -/** - * React hook to convert a CSS size string (e.g., "16px", "1.5em", "50vw", "25%") - * to its equivalent value in pixels, updating automatically when relevant - * inputs or browser states change. - * - * NOTE: This hook MUST be run in a browser environment. - * NOTE: '%' conversion accuracy depends heavily on providing the correct - * `propertyName` and the context element being attached to the DOM - * with a resolvable parent dimension/font-size. If context is - * missing for '%', it defaults to 0 pixels with a warning. - * NOTE: Uses ResizeObserver for better accuracy with '%' and 'em' units if available. - * - * @param cssSizeString The CSS size string to convert (e.g., "10px", "2rem", "50%"). - * @param contextRef Optional React ref object pointing to the DOM element to use - * as context for relative units ('em', '%'). If omitted or ref is null, - * document.documentElement is used as the context. - * @param propertyName Optional. The name of the CSS property this size applies to - * (e.g., 'width', 'height', 'font-size'). Crucial for calculating '%'. - * @returns The calculated size in pixels. Returns 0 for ambiguous/unresolved '%'. - */ -export function useCssSizeToPixels( - cssSizeString: string, - contextRef?: RefObject, - propertyName?: string -): number { - const [pixelValue, setPixelValue] = useState(0); - - // Get the current element from the ref, or null if not available/provided - const contextElement = contextRef?.current ?? null; - - // Memoize parent lookup to stabilize useEffect dependencies - const parentElement = useMemo(() => contextElement?.parentElement ?? null, [contextElement]); - - useEffect(() => { - // Element to pass to the core calculation function. - // Fallback to documentElement if ref is null or not provided. - const elementForCalc = contextElement ?? document.documentElement; - - // Function to perform the calculation and update state - const calculate = () => { - const result = cssSizeToPixels(cssSizeString, elementForCalc, propertyName); - // Only update state if the value has actually changed - setPixelValue((prev) => (prev !== result ? result : prev)); - }; - - // Initial calculation when effect runs - calculate(); - - // --- Set up observers and listeners --- - - // 1. Window resize listener (always needed for vw/vh units) - window.addEventListener("resize", calculate); - - // 2. ResizeObserver for the context element itself - // Needed for 'em' (if font-size changes based on its own size) - // and 'line-height: %' - let contextObserver: ResizeObserver | null = null; - if (hasResizeObserver && contextElement) { - try { - contextObserver = new ResizeObserver(calculate); - contextObserver.observe(elementForCalc); // elementForCalc is contextElement here - } catch (error) { - console.error("Failed to observe context element:", error); - contextObserver = null; // Ensure it's null if observe fails - } - } - - // 3. ResizeObserver for the parent element - // Needed for width/height/margin/padding % units - let parentObserver: ResizeObserver | null = null; - const needsParentObservation = propertyName && cssSizeString.includes("%") && parentElement; - - if (hasResizeObserver && needsParentObservation) { - try { - parentObserver = new ResizeObserver(calculate); - parentObserver.observe(parentElement); // parentElement is guaranteed non-null here - } catch (error) { - console.error("Failed to observe parent element:", error); - parentObserver = null; // Ensure it's null if observe fails - } - } - - // --- Cleanup function --- - return () => { - window.removeEventListener("resize", calculate); - try { - contextObserver?.disconnect(); - } catch (error) { - console.error("Error disconnecting context observer:", error); - } - try { - parentObserver?.disconnect(); - } catch (error) { - console.error("Error disconnecting parent observer:", error); - } - }; - - // --- Effect Dependencies --- - // Recalculate if the core inputs change, or if the referenced - // elements themselves change (captured by contextElement and parentElement) - }, [cssSizeString, propertyName, contextElement, parentElement]); - - return pixelValue; -} diff --git a/src/renderer/src/hooks/use-favicon-color.ts b/src/renderer/src/hooks/use-favicon-color.ts index bfd2aea1a..7c4d55a6d 100644 --- a/src/renderer/src/hooks/use-favicon-color.ts +++ b/src/renderer/src/hooks/use-favicon-color.ts @@ -198,21 +198,18 @@ const colorCache = new Map(); * Hook to extract colors from favicon corners and center for creating position-matched gradients. */ export function useFaviconColors(faviconUrl: string | null | undefined): FaviconColors | null { - const [colors, setColors] = useState(() => { - if (!faviconUrl) return null; - return colorCache.get(faviconUrl) ?? null; - }); + const [_colors, setColors] = useState(null); + + const cachedColors = faviconUrl ? colorCache.get(faviconUrl) : null; + const colors = faviconUrl ? _colors : null; useEffect(() => { if (!faviconUrl) { - setColors(null); return; } // Check cache first - const cached = colorCache.get(faviconUrl); - if (cached !== undefined) { - setColors(cached); + if (cachedColors) { return; } @@ -221,7 +218,7 @@ export function useFaviconColors(faviconUrl: string | null | undefined): Favicon colorCache.set(faviconUrl, extractedColors); setColors(extractedColors); }); - }, [faviconUrl]); + }, [faviconUrl, cachedColors]); return colors; } diff --git a/src/renderer/src/routes/pdf-viewer/pdf-viewer/index.tsx b/src/renderer/src/routes/pdf-viewer/pdf-viewer/index.tsx index b2338b318..cc2be9359 100644 --- a/src/renderer/src/routes/pdf-viewer/pdf-viewer/index.tsx +++ b/src/renderer/src/routes/pdf-viewer/pdf-viewer/index.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from "react"; +import { useState } from "react"; import { usePDFSlick } from "@pdfslick/react"; import Toolbar from "./Toolbar"; import Thumbsbar from "./Thumbsbar"; @@ -8,7 +8,7 @@ type PDFViewerAppProps = { }; export function PDFViewerApp({ pdfFilePath }: PDFViewerAppProps) { - const [isThumbsbarOpen, setIsThumbsbarOpen] = useState(false); + const [_isThumbsbarOpen, setIsThumbsbarOpen] = useState(true); const [loadedPerc, setLoadedPerc] = useState(0); const { isDocumentLoaded, viewerRef, thumbsRef, usePDFSlickStore, PDFSlickViewer } = usePDFSlick(pdfFilePath, { getDocumentParams: { @@ -23,12 +23,7 @@ export function PDFViewerApp({ pdfFilePath }: PDFViewerAppProps) { } }); - useEffect(() => { - if (isDocumentLoaded) { - setIsThumbsbarOpen(true); - } - }, [isDocumentLoaded]); - + const isThumbsbarOpen = isDocumentLoaded && _isThumbsbarOpen; return ( <>
    From 9aa4d99575a0931d1790b7d3f4e0cc9ee4e51dbc Mon Sep 17 00:00:00 2001 From: Evan Date: Tue, 7 Apr 2026 17:05:28 +0100 Subject: [PATCH 16/16] fix: lint warnings --- src/renderer/src/components/browser-ui/browser-content.tsx | 1 - .../src/components/settings/sections/spaces/section.tsx | 1 - src/renderer/src/hooks/use-bounding-rect.tsx | 2 -- src/renderer/src/routes/error/page.tsx | 1 - .../src/routes/pdf-viewer/pdf-viewer/Toolbar/SearchBar.tsx | 1 - 5 files changed, 6 deletions(-) diff --git a/src/renderer/src/components/browser-ui/browser-content.tsx b/src/renderer/src/components/browser-ui/browser-content.tsx index 2c13beb4c..c9ab12231 100644 --- a/src/renderer/src/components/browser-ui/browser-content.tsx +++ b/src/renderer/src/components/browser-ui/browser-content.tsx @@ -113,7 +113,6 @@ function BrowserContent() { // topbar, direction). Uses the ref for sidebarWidth since it's always current. useLayoutEffect(() => { sendLayoutParams(recordedSidebarSizeRef.current); - // eslint-disable-next-line react-hooks/exhaustive-deps }, [topbarHeight, topbarVisible, sidebarVisible, sidebarSide, isAnimating, contentTopOffset]); // Subscribe to sidebar resize (drag) events. The callback fires outside diff --git a/src/renderer/src/components/settings/sections/spaces/section.tsx b/src/renderer/src/components/settings/sections/spaces/section.tsx index 9fbf44608..fd190b65f 100644 --- a/src/renderer/src/components/settings/sections/spaces/section.tsx +++ b/src/renderer/src/components/settings/sections/spaces/section.tsx @@ -68,7 +68,6 @@ export function SpacesSettings({ initialSelectedProfile, initialSelectedSpace }: // Load data on component mount useEffect(() => { fetchData(); - // eslint-disable-next-line react-hooks/exhaustive-deps }, []); // Set active space when initialSelectedSpace changes diff --git a/src/renderer/src/hooks/use-bounding-rect.tsx b/src/renderer/src/hooks/use-bounding-rect.tsx index b67cd4896..563bed189 100644 --- a/src/renderer/src/hooks/use-bounding-rect.tsx +++ b/src/renderer/src/hooks/use-bounding-rect.tsx @@ -83,8 +83,6 @@ export function useBoundingRect( const keepGoing = loopRef.current || stableFramesRef.current < SETTLE_FRAMES; if (keepGoing) { - // `tick` will never change - // eslint-disable-next-line react-hooks/immutability rafIdRef.current = requestAnimationFrame(tick); } else { runningRef.current = false; diff --git a/src/renderer/src/routes/error/page.tsx b/src/renderer/src/routes/error/page.tsx index 9cea5856e..195b2cd10 100644 --- a/src/renderer/src/routes/error/page.tsx +++ b/src/renderer/src/routes/error/page.tsx @@ -73,7 +73,6 @@ function Page() { } else { handleReload(); } - // eslint-disable-next-line react-hooks/exhaustive-deps }, []); if (!url) { diff --git a/src/renderer/src/routes/pdf-viewer/pdf-viewer/Toolbar/SearchBar.tsx b/src/renderer/src/routes/pdf-viewer/pdf-viewer/Toolbar/SearchBar.tsx index 198c3b072..2b20bb53d 100644 --- a/src/renderer/src/routes/pdf-viewer/pdf-viewer/Toolbar/SearchBar.tsx +++ b/src/renderer/src/routes/pdf-viewer/pdf-viewer/Toolbar/SearchBar.tsx @@ -50,7 +50,6 @@ const SearchBar = ({ usePDFSlickStore }: SearchBarProps) => { pdfSlick.eventBus.dispatch("findbarclose", {}); } } - // eslint-disable-next-line react-hooks/exhaustive-deps }, [isOpen]); return (