diff --git a/bun.lock b/bun.lock index 017ed65f6..97fcb6fec 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", }, @@ -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", @@ -58,9 +58,9 @@ "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", - "hono": "^4.12.11", + "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", "motion": "^12.38.0", @@ -77,8 +77,8 @@ "tailwindcss": "^4.2.2", "tailwindcss-animate": "^1.0.7", "tw-animate-css": "^1.2.9", - "typescript": "^5.8.3", - "vite": "^8.0.5", + "typescript": "^6.0.2", + "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=="], @@ -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=="], @@ -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,7 +1234,11 @@ "hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="], - "hono": ["hono@4.12.11", "", {}, "sha512-r4xbIa3mGGGoH9nN4A14DOg2wx7y2oQyJEb5O57C/xzETG/qx4c7CVDQ5WMeKHZ7ORk2W0hZ/sQKXTav3cmYBA=="], + "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=="], @@ -1590,7 +1594,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=="], @@ -1904,7 +1908,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=="], @@ -1912,7 +1916,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=="], @@ -1938,7 +1942,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=="], @@ -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=="], @@ -2054,6 +2062,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=="], @@ -2138,8 +2148,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=="], @@ -2240,6 +2248,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,13 +2322,19 @@ "@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=="], - "@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/plist/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], + "@types/keyv/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], + + "@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=="], @@ -2342,8 +2358,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=="], @@ -2418,6 +2432,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 +2480,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/drizzle/0004_add_download_manager.sql b/drizzle/0004_add_download_manager.sql new file mode 100644 index 000000000..cd6721835 --- /dev/null +++ b/drizzle/0004_add_download_manager.sql @@ -0,0 +1,23 @@ +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`); diff --git a/drizzle/meta/0004_snapshot.json b/drizzle/meta/0004_snapshot.json new file mode 100644 index 000000000..585798179 --- /dev/null +++ b/drizzle/meta/0004_snapshot.json @@ -0,0 +1,564 @@ +{ + "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": {} + } +} diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 0291f867c..812d00038 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 } ] } 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 92e540fbe..0ad6eaaca 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" }, @@ -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", @@ -91,9 +91,9 @@ "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", - "hono": "^4.12.11", + "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", "motion": "^12.38.0", @@ -110,8 +110,8 @@ "tailwindcss": "^4.2.2", "tailwindcss-animate": "^1.0.7", "tw-animate-css": "^1.2.9", - "typescript": "^5.8.3", - "vite": "^8.0.5" + "typescript": "^6.0.2", + "vite": "^8.0.6" }, "pnpm": { "onlyBuiltDependencies": [ diff --git a/src/main/controllers/downloads-controller/index.ts b/src/main/controllers/downloads-controller/index.ts new file mode 100644 index 000000000..3abb1efd1 --- /dev/null +++ b/src/main/controllers/downloads-controller/index.ts @@ -0,0 +1,653 @@ +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"; +import { fireDownloadsChanged } from "@/ipc/browser/downloads"; + +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; + 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; + enqueuedAt: number; + timeoutId: ReturnType; +} + +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, 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 + }); + + 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}`); + 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() + }); + fireDownloadsChanged(); + 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; + + 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(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(); + + if (!pendingResume) { + item.setSaveDialogOptions({ + defaultPath, + properties: ["createDirectory", "showOverwriteConfirmation"] + }); + } + + const metadata: DownloadMetadata = { + downloadId, + originProfileId, + progressId: null, + savePath, + lastUpdate: now, + lastBytes: item.getReceivedBytes(), + initialTotalBytes: item.getTotalBytes(), + syncingProgress: false, + lastPersistedAt: 0 + }; + + const activeDownload: ActiveDownload = { item, session, meta: metadata }; + + this.activeDownloads.set(item, metadata); + this.activeDownloadsById.set(downloadId, activeDownload); + + upsertDownloadRecord(this.buildDownloadInsert(item, metadata, existingRecord)); + fireDownloadsChanged(); + + debugPrint("DOWNLOADS", `Download requested: ${suggestedFilename} (${downloadId})`); + + this.queueProgressSync(item, metadata); + + item.on("updated", (_event, state) => { + const currentMeta = this.activeDownloads.get(item); + if (!currentMeta) return; + + const current = this.activeDownloadsById.get(currentMeta.downloadId); + if (!current) return; + + this.queueProgressSync(item, currentMeta); + + if (state === "progressing") { + this.updateMacProgress(currentMeta, item); + } else if (state === "interrupted") { + 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 currentMeta = this.activeDownloads.get(item); + if (!currentMeta) return; + + const current = this.activeDownloadsById.get(currentMeta.downloadId); + this.activeDownloads.delete(item); + 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 { + 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(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); + fireDownloadsChanged(); + debugPrint("DOWNLOADS", `Download ${state}: ${this.getSavePath(active.item) ?? active.item.getFilename()}`); + } + + private buildDownloadInsert( + item: DownloadItem, + meta: DownloadMetadata, + 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 + }; + } + + 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; + } + + 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; + fireDownloadsChanged(); + } + + 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; + } + + 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 && + (record.state === "interrupted" || record.state === "paused") + ); + } + + private enqueuePendingResume( + session: Session, + request: Omit + ): void { + const queue = this.pendingResumeRequests.get(session) ?? []; + + 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; + + 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); + clearTimeout(match.timeoutId); + if (queue.length === 0) { + this.pendingResumeRequests.delete(session); + } + return match; + } + + if (queue.length === 1 && item.getState() === "interrupted") { + const [fallback] = queue.splice(0, 1); + clearTimeout(fallback.timeoutId); + this.pendingResumeRequests.delete(session); + return fallback; + } + + 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()]; + } + + 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 { + 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/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..8e7a2b538 --- /dev/null +++ b/src/main/controllers/sessions-controller/protocols/_protocols/flow/file-icon.ts @@ -0,0 +1,62 @@ +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 { + 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); + } + + // 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) + : "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/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..1fc226783 --- /dev/null +++ b/src/main/ipc/browser/downloads.ts @@ -0,0 +1,77 @@ +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(); +}); + +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) => { + 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", 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/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/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/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; 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..865650e66 --- /dev/null +++ b/src/main/saving/downloads.ts @@ -0,0 +1,76 @@ +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 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) + .set({ + state: "interrupted", + updatedAt: Date.now() + }) + .where(inArray(schema.downloads.state, IN_FLIGHT_DOWNLOAD_STATES)) + .run(); +} diff --git a/src/preload/index.ts b/src/preload/index.ts index c51439f1f..8fce0f491 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,43 @@ 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"); + }, + checkFilesExist: async (downloadIds: string[]) => { + return ipcRenderer.invoke("downloads:check-files-exist", downloadIds); + }, + onChanged: (callback: () => void) => { + return listenOnIPCChannel("downloads:on-changed", callback); + } +}; + // INTERFACE API // const interfaceAPI: FlowInterfaceAPI = { setWindowButtonPosition: (position: { x: number; y: number }) => { @@ -781,6 +820,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/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/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..b3b21b67b --- /dev/null +++ b/src/renderer/src/components/browser-ui/browser-sidebar/_components/bottom/downloads-popover.tsx @@ -0,0 +1,235 @@ +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 { cn } from "@/lib/utils"; +import { DownloadFileIcon } from "@/components/downloads/manager/file-icon"; +import { filenameFromRecord, formatBytes, isActive } from "@/components/downloads/manager/utils"; +import type { DownloadRecord } from "~/types/downloads"; +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(); + 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"; + return new Date(ts).toLocaleDateString(undefined, { month: "short", day: "numeric" }); +} + +function DownloadRow({ + dl, + fileMissing, + setOpen +}: { + dl: DownloadRecord; + fileMissing: boolean; + setOpen: (open: boolean) => void; +}) { + 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) { + void flow.downloads.openFile(dl.id); + } else { + flow.tabs.newTab("flow://downloads", true); + setOpen(false); + } + }; + + 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} +

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

{statusText()}

+
+
+
+ ); +} + +export function DownloadsPopover() { + const [open, setOpen] = useState(false); + const [downloads, setDownloads] = useState([]); + const [fileExistence, setFileExistence] = useState>({}); + + const { isCurrentSpaceLight } = useSpaces(); + const spaceInjectedClasses = cn(isCurrentSpaceLight ? "" : "dark"); + + const fetchDownloads = useCallback(async () => { + try { + const all = await flow.downloads.list(); + setDownloads(all); + 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); + } + } catch { + // silently ignore + } + }, []); + + useEffect(() => { + if (!open) return; + void fetchDownloads(); + const unsubscribe = flow.downloads.onChanged(() => { + void fetchDownloads(); + }); + return unsubscribe; + }, [open, fetchDownloads]); + + 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); + setOpen(false); + }; + + return ( + + + + + + + {/* Header */} +
+
+ Downloads + {activeCount > 0 && ( + + {activeCount} active + + )} +
+ +
+ +
+ + {/* List */} + {shown.length === 0 ? ( +
+
+ +
+

No recent downloads

+
+ ) : ( +
+ + {shown.map((dl) => ( + + ))} + +
+ )} + + + ); +} 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
- +
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..40002b15e --- /dev/null +++ b/src/renderer/src/components/downloads/manager/download-card.tsx @@ -0,0 +1,253 @@ +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 { 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({ + 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/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 new file mode 100644 index 000000000..71938aebe --- /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..3f5efdc9a --- /dev/null +++ b/src/renderer/src/components/downloads/manager/provider.tsx @@ -0,0 +1,74 @@ +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[]; + fileExistence: Record; + isLoading: boolean; + isError: boolean; + refresh: () => void; +} + +const DownloadsContext = createContext(null); + +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/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/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 (
, - 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/downloads/config.tsx b/src/renderer/src/routes/downloads/config.tsx new file mode 100644 index 000000000..5847cd35d --- /dev/null +++ b/src/renderer/src/routes/downloads/config.tsx @@ -0,0 +1,14 @@ +import { ThemeProvider } from "@/components/main/theme"; +import { DownloadsProvider } from "@/components/downloads/manager/provider"; +import { RouteConfigType } from "@/types/routes"; +import { ReactNode } from "react"; + +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..b0c8314de --- /dev/null +++ b/src/renderer/src/routes/downloads/page.tsx @@ -0,0 +1,12 @@ +import { DownloadsManagerMain } from "@/components/downloads/manager/main"; + +function App() { + return ( + <> + Downloads + + + ); +} + +export default App; 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 ( 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 ( <>
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..718bd5d8c --- /dev/null +++ b/src/shared/flow/interfaces/browser/downloads.ts @@ -0,0 +1,16 @@ +import type { IPCListener } from "~/flow/types"; +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; + checkFilesExist: (downloadIds: string[]) => Promise>; + onChanged: IPCListener<[]>; +} 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; +} 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/*"] } } }