Compare commits

...

25 Commits

Author SHA1 Message Date
NimBold 61eb034db1 chore(release): prepare Firelink 1.1.1 2026-07-17 18:58:45 +03:30
NimBold c949cbb9ee fix(ui): align action controls and protect keychain consent 2026-07-17 18:37:50 +03:30
NimBold 566396e629 fix(media): stabilize YouTube size estimates 2026-07-17 17:49:23 +03:30
NimBold db39cd2153 fix: stabilize media progress and cancel controls 2026-07-17 17:12:56 +03:30
NimBold f4b830414d fix: harden audited persistence and download paths 2026-07-17 16:27:39 +03:30
NimBold 0447d1cab7 fix(build): scope Tailwind scanning and remove dead Rust helper
Limit Tailwind source detection to the frontend and root HTML while preserving the CSS-defined theme. Remove redundant test-only nonce validation scaffolding so the Rust library builds without warnings.
2026-07-17 03:20:31 +03:30
NimBold 513143d745 fix(release): harden packaged smoke cleanup 2026-07-17 02:35:36 +03:30
NimBold a8dc4fb447 fix(security): harden browser handoff boundaries 2026-07-17 02:05:41 +03:30
NimBold 50c3da2f5d fix(downloads): close media lifecycle cleanup gaps
Validate every Aria2 mirror URI, keep metadata cleanup errors truthful, and serialize media registration with lifecycle cancellation so abandoned tombstones cannot accumulate.
2026-07-17 01:03:52 +03:30
NimBold 6ef911919d fix(settings): harden settings and diagnostic controls 2026-07-17 00:39:26 +03:30
NimBold 5144ecd39e chore(deps): refresh packages and bundled engines 2026-07-17 00:03:15 +03:30
NimBold a56b859151 fix(downloads): preserve authenticated capture metadata
Keep Gmail attachment filenames and origin-scoped browser cookies intact through metadata redirects, while rejecting Google sign-in responses.\n\nFixes #21
2026-07-16 22:58:12 +03:30
NimBold 469faed7b9 fix(downloads): harden add flow and URL validation 2026-07-16 22:20:12 +03:30
NimBold 79ce0c18a1 fix(downloads): harden aria2 recovery lifecycle 2026-07-16 17:56:00 +03:30
NimBold d195a132b3 perf(downloads): scale playlist rows efficiently 2026-07-16 17:23:43 +03:30
NimBold feb5d8e87d fix(downloads): harden playlist state and scrolling 2026-07-16 16:57:35 +03:30
NimBold a136fa832c feat(downloads): support YouTube playlists 2026-07-16 16:07:00 +03:30
NimBold f3d0e0be13 fix(downloads): recover stalls and honor connection defaults (#19, #20) 2026-07-16 14:28:59 +03:30
NimBold edeef0ac54 fix(startup): enforce consent before download handoffs
Gate clipboard, extension, and deep-link inputs until startup consent is resolved, and serialize extension readiness transitions so native prompts cannot race the app explanation. Identify consent by the build revision so same-version updates re-enter the consent boundary.

Requested by [this X post](https://x.com/ixabolfazl/status/2077356127763804450?s=20).
2026-07-15 21:48:00 +03:30
NimBold 9f333618fc fix(downloads): show total size after completion 2026-07-15 21:30:01 +03:30
NimBold ed54a048ab fix(keychain): version startup consent policy 2026-07-15 21:21:35 +03:30
NimBold d2479f52be fix(startup): restore keychain consent barrier 2026-07-15 21:09:45 +03:30
NimBold 6e6ae51395 fix(downloads): preserve live progress across lifecycle edges 2026-07-15 20:50:46 +03:30
NimBold 1d197432b2 feat(downloads): add live limits clipboard capture and byte progress 2026-07-15 20:35:05 +03:30
NimBold 9917f29743 fix(build): gate platform-specific proxy helper 2026-07-15 12:49:40 +03:30
62 changed files with 6181 additions and 865 deletions
+21
View File
@@ -5,6 +5,27 @@ All notable changes to Firelink will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [1.1.1] - 2026-07-17
This patch release focuses on transfer reliability, browser captures, and easier download control.
### New
- Add YouTube playlist downloads with smoother queueing and scrolling for large playlists.
- Add live per-download connection controls, clipboard capture for the Add window, and byte-level progress updates.
### Improved
- Recover slow or stalled transfers more reliably and apply connection defaults consistently, addressing [#19](https://github.com/nimbold/Firelink/issues/19) and [#20](https://github.com/nimbold/Firelink/issues/20).
- Make pause, resume, retry, cancel, remove, and completion handling more consistent when actions or background events overlap.
- Improve playlist state, media size estimates, and large-list performance.
- Keep clipboard, browser, and deep-link handoffs behind the startup consent explanation, and make that boundary clearer.
- Refresh bundled engines and dependencies while strengthening cross-platform package and diagnostic checks.
### Fixed
- Fix Gmail and other authenticated browser downloads that could lose their filename or save a sign-in page after a redirect, including Chrome Incognito, addressing [#21](https://github.com/nimbold/Firelink/issues/21).
- Keep browser-capture metadata, cookies, and destinations tied to the correct download through redirects.
- Prevent invalid URLs, late download events, stale progress, and abandoned media work from creating misleading rows or leftover temporary files.
- Keep sensitive local paths, credentials, and persisted records protected in errors, diagnostics, and download state.
## [1.1.0] - 2026-07-15
This is a stability-focused release with safer downloads, browser handoffs, settings, and cross-platform packages.
+16 -12
View File
@@ -5,7 +5,7 @@
**A fast, focused desktop download manager for macOS, Windows, and Linux.**
[![Version](https://img.shields.io/badge/version-1.1.0-6f42c1?style=flat-square)](https://github.com/nimbold/Firelink/releases)
[![Version](https://img.shields.io/badge/version-1.1.1-6f42c1?style=flat-square)](https://github.com/nimbold/Firelink/releases)
[![macOS](https://img.shields.io/badge/macOS-111111?style=flat-square&logo=apple&logoColor=white)](#platforms)
[![Windows](.github/badges/windows.svg)](#platforms)
[![Linux](https://img.shields.io/badge/Linux-FCC624?style=flat-square&logo=linux&logoColor=black)](#platforms)
@@ -33,19 +33,20 @@
## Why Firelink
Firelink is a desktop download manager for fast transfers, browser capture, media extraction, scheduling, and clear file placement.
Firelink is a cross-platform desktop download manager for direct transfers, browser capture, media extraction, scheduling, and clear file placement.
It is now a cross-platform Rust/Tauri app with a React and TypeScript interface. A native backend coordinates downloads with aria2, yt-dlp, FFmpeg, Deno, and SQLite.
It combines a Rust/Tauri backend with a React and TypeScript interface. Bundled aria2, yt-dlp, FFmpeg, Deno, and SQLite support the download and media workflows.
The current desktop release is **1.1.0**, paired with Firelink Companion **2.0.4**.
The current desktop release is **1.1.1**, paired with Firelink Companion **2.0.5**.
This stability-focused release adds a secure Windows portable build and strengthens queues, browser handoffs, media metadata, persistence, and packaged release checks.
This release adds YouTube playlist downloads, live connection controls, clipboard capture, and clearer byte-level progress. It also improves slow-transfer recovery, authenticated browser captures, and startup consent handling.
## Features
- **Segmented downloads** with aria2, retries, speed limits, and connection controls.
- **Media downloads** with yt-dlp, FFmpeg, Deno, live progress, speed, and ETA.
- **Add window** for metadata, duplicates, location choices, captured links, and clipboard-prefilled URLs.
- **Playlist downloads** for YouTube playlists with queueing and efficient large-list rendering.
- **Add window** for metadata, duplicates, location choices, captured links, clipboard-prefilled URLs, and live connection limits.
- **Persistent queues** with pause, resume, retry, redownload, sorting, multi-select, and bulk actions.
- **Scheduling** with start/stop windows, speed rules, and post-queue actions.
- **File organization** with categories, default folders, a collapsible Folders section, per-download overrides, and reveal/trash actions.
@@ -61,16 +62,17 @@ Download desktop builds from [GitHub Releases](https://github.com/nimbold/Fireli
| --- | --- | --- |
| **macOS Apple silicon** | `.dmg` | Not notarized. If macOS blocks the first launch, approve Firelink in **System Settings -> Privacy & Security**. |
| **Windows x64** | NSIS `.exe` installer | Unsigned. Windows SmartScreen may warn until code signing is added. |
| **Windows x64 portable** | `.zip` archive | Extract to a writable folder and launch `firelink.exe`. App data stays under the archive's `data/` directory. |
| **Windows x64 portable** | `.zip` archive | Extract to a writable folder and launch `firelink.exe`. See the expandable notes below. |
| **Linux x64** | `.deb`, `.rpm`, or `.AppImage` | Use `.deb` for Debian-family systems, `.rpm` for Fedora/RPM-family systems, or AppImage as the self-contained package. AppImage may need executable permission. |
Bundles include the required engines. Users do not need aria2, yt-dlp, FFmpeg, Deno, Python, Homebrew, or another package manager.
The native packages use the distribution's normal desktop runtime dependencies. AppImage is self-contained but uses the normal per-user application-data locations.
### Windows portable
<details>
<summary><strong>Windows portable ZIP notes</strong></summary>
The portable ZIP is an opt-in secondary distribution:
The portable ZIP is an opt-in secondary distribution. Extract it to a writable folder and launch `firelink.exe`:
- Keep the extracted folder writable; avoid `Program Files`, read-only media, and folders that block SQLite or WebView writes.
- Settings, queues, logs, and WebView data stay beside `firelink.exe` under `data/`.
@@ -81,6 +83,8 @@ The portable ZIP is an opt-in secondary distribution:
- Saved absolute download locations may need to be selected again after moving the folder to another drive.
- The installer remains the supported path for `firelink://` browser launch registration.
</details>
## Browser Extension
<p align="center">
@@ -89,7 +93,7 @@ The portable ZIP is an opt-in secondary distribution:
<a href="https://github.com/nimbold/Firelink-Extension#manual-chromium-installation"><img src="https://img.shields.io/badge/Manual%20install-Chromium-4285F4?style=for-the-badge&logo=googlechrome&logoColor=white" alt="Read manual Chromium install instructions" /></a>
</p>
Firelink Companion sends browser links and downloads to the desktop app.
Firelink Companion connects browser links and downloads to the desktop app.
What it adds:
@@ -101,9 +105,9 @@ What it adds:
- Fallback to the browser download when Firelink is closed or rejects a handoff.
- Captured links always open Firelink's Add window before anything is added to the download list.
Install the extension, open Firelink, then pair it from **Settings -> Integrations**. Firefox users can install from Mozilla Add-ons. Chromium users can use the [manual load-unpacked flow](https://github.com/nimbold/Firelink-Extension#manual-chromium-installation) with `firelink-chromium.zip` from the [extension releases](https://github.com/nimbold/Firelink-Extension/releases). Firelink Companion 2.0.4 is the matching extension release for Firelink 1.1.0.
Install the extension, open Firelink, then pair it from **Settings -> Integrations**. Firefox users can install it from Mozilla Add-ons. Chromium users can load `firelink-chromium.zip` from the [extension releases](https://github.com/nimbold/Firelink-Extension/releases) with the [manual Chromium instructions](https://github.com/nimbold/Firelink-Extension#manual-chromium-installation).
The extension lives in [Firelink-Extension](https://github.com/nimbold/Firelink-Extension). This repo also vendors it as the `Extensions/Browser` submodule.
Use the latest [Firelink Companion release](https://github.com/nimbold/Firelink-Extension/releases) with Firelink 1.1.1. The source is in the [Firelink-Extension repository](https://github.com/nimbold/Firelink-Extension), which this repo vendors as the `Extensions/Browser` submodule.
## Platforms
+10 -10
View File
@@ -8,14 +8,14 @@
"sha256": "90254845be5282b1f4d843a873abff04f569f857f64250f833fe152b21eec152"
},
"deno": {
"version": "2.9.2",
"url": "https://github.com/denoland/deno/releases/download/v2.9.2/deno-x86_64-pc-windows-msvc.zip",
"sha256": "5fe194d26ac5ef77fcc5288c2c438c7a0465f3b6180440ebf04092714bf2dcdf"
"version": "2.9.3",
"url": "https://github.com/denoland/deno/releases/download/v2.9.3/deno-x86_64-pc-windows-msvc.zip",
"sha256": "60343461ac5fe3a31f4ef12667f2946bb852e20655c8610aeb7e751e87f7df3a"
},
"ffmpeg": {
"version": "8.1.2-22-g94138f6973",
"url": "https://github.com/BtbN/FFmpeg-Builds/releases/download/autobuild-2026-07-14-13-19/ffmpeg-n8.1.2-22-g94138f6973-win64-gpl-8.1.zip",
"sha256": "fb627ebbf9b16a3142fa48b8acd27cd9196d57aee1f719e1233416e95da7e877"
"url": "https://github.com/BtbN/FFmpeg-Builds/releases/download/autobuild-2026-07-15-14-01/ffmpeg-n8.1.2-22-g94138f6973-win64-gpl-8.1.zip",
"sha256": "0af1621d3a2e418b86d8ff4704f5d5a70bcce611edd6c5110d092533b170952c"
},
"aria2c": {
"version": "1.37.0",
@@ -30,14 +30,14 @@
"sha256": "d7d2d09e900b5ae11821b5784b18cf064984a2bd88b1ca5c798d744bcbe3658b"
},
"deno": {
"version": "2.9.2",
"url": "https://github.com/denoland/deno/releases/download/v2.9.2/deno-x86_64-unknown-linux-gnu.zip",
"sha256": "934d1bd5cb09eaed7f2e4a4fc58208d04a3c5c0fcde9f319d93d735265c67a4a"
"version": "2.9.3",
"url": "https://github.com/denoland/deno/releases/download/v2.9.3/deno-x86_64-unknown-linux-gnu.zip",
"sha256": "8101865641cbede56f08ad19c0a67a87df84bce127fee0d3e3e1f7467717ffa6"
},
"ffmpeg": {
"version": "8.1.2-22-g94138f6973",
"url": "https://github.com/BtbN/FFmpeg-Builds/releases/download/autobuild-2026-07-14-13-19/ffmpeg-n8.1.2-22-g94138f6973-linux64-gpl-8.1.tar.xz",
"sha256": "96cf2585a4b2874044320cbc5d4078af57e266f0c86cca9888507c5faab3aee8"
"url": "https://github.com/BtbN/FFmpeg-Builds/releases/download/autobuild-2026-07-15-14-01/ffmpeg-n8.1.2-22-g94138f6973-linux64-gpl-8.1.tar.xz",
"sha256": "68e304f5518d3e22cd825e6fb738d0d9a04a8f36127873a18fc6646457e95b92"
},
"aria2c": {
"version": "1.37.0",
+2 -2
View File
@@ -23,10 +23,10 @@
"sha256": "f65b4ba782b25e9f4374765d421793c6613f692d8e50b2888079657793619034"
},
"deno": {
"version": "2.9.2",
"version": "2.9.3",
"source": "https://github.com/denoland/deno",
"build": "official aarch64-apple-darwin executable",
"sha256": "218ab752ae8f64f0a7822af710886488f15169fdae153a3aada4861f9635b266"
"sha256": "80c83cdfb20289f8818a71220b570df363f6f0ba93580c29c70f08ab14e93568"
}
},
"runtimeTrees": {
+86 -86
View File
@@ -1,16 +1,16 @@
{
"name": "firelink",
"version": "1.1.0",
"version": "1.1.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "firelink",
"version": "1.1.0",
"version": "1.1.1",
"license": "MIT",
"dependencies": {
"@formkit/auto-animate": "^0.10.0",
"@tailwindcss/vite": "^4.3.1",
"@tailwindcss/vite": "^4.3.3",
"@tauri-apps/api": "^2.11.1",
"@tauri-apps/plugin-clipboard-manager": "^2.3.2",
"@tauri-apps/plugin-dialog": "^2.7.1",
@@ -28,11 +28,11 @@
"@types/react": "^19.1.8",
"@types/react-dom": "^19.1.6",
"@vitejs/plugin-react": "^6.0.3",
"autoprefixer": "^10.5.3",
"autoprefixer": "^10.5.4",
"postcss": "^8.5.19",
"tailwindcss": "^4.3.1",
"tailwindcss": "^4.3.3",
"typescript": "^7.0.2",
"vite": "^8.1.4",
"vite": "^8.1.5",
"vitest": "^4.1.10"
},
"engines": {
@@ -404,47 +404,47 @@
"license": "MIT"
},
"node_modules/@tailwindcss/node": {
"version": "4.3.2",
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.2.tgz",
"integrity": "sha512-yWP/sqEcBLaD8JuA6zNwxoYKr75qxTioYwlRwekj5Jr/I5GXnoJfjetH/psLUIv74cYTH2lBUEzBkinthoYcBg==",
"version": "4.3.3",
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.3.tgz",
"integrity": "sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg==",
"license": "MIT",
"dependencies": {
"@jridgewell/remapping": "^2.3.5",
"enhanced-resolve": "5.21.6",
"enhanced-resolve": "^5.24.1",
"jiti": "^2.7.0",
"lightningcss": "1.32.0",
"magic-string": "^0.30.21",
"source-map-js": "^1.2.1",
"tailwindcss": "4.3.2"
"tailwindcss": "4.3.3"
}
},
"node_modules/@tailwindcss/oxide": {
"version": "4.3.2",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.2.tgz",
"integrity": "sha512-z8ZgnzX8gdNoWLBLqBPoh/sjnxkwvf9ZuWjnO0l0yIzbLa5/9S+eC5QxGZKRobVHIC3/1BoMWjHblqWjcgFgag==",
"version": "4.3.3",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.3.tgz",
"integrity": "sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA==",
"license": "MIT",
"engines": {
"node": ">= 20"
},
"optionalDependencies": {
"@tailwindcss/oxide-android-arm64": "4.3.2",
"@tailwindcss/oxide-darwin-arm64": "4.3.2",
"@tailwindcss/oxide-darwin-x64": "4.3.2",
"@tailwindcss/oxide-freebsd-x64": "4.3.2",
"@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.2",
"@tailwindcss/oxide-linux-arm64-gnu": "4.3.2",
"@tailwindcss/oxide-linux-arm64-musl": "4.3.2",
"@tailwindcss/oxide-linux-x64-gnu": "4.3.2",
"@tailwindcss/oxide-linux-x64-musl": "4.3.2",
"@tailwindcss/oxide-wasm32-wasi": "4.3.2",
"@tailwindcss/oxide-win32-arm64-msvc": "4.3.2",
"@tailwindcss/oxide-win32-x64-msvc": "4.3.2"
"@tailwindcss/oxide-android-arm64": "4.3.3",
"@tailwindcss/oxide-darwin-arm64": "4.3.3",
"@tailwindcss/oxide-darwin-x64": "4.3.3",
"@tailwindcss/oxide-freebsd-x64": "4.3.3",
"@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.3",
"@tailwindcss/oxide-linux-arm64-gnu": "4.3.3",
"@tailwindcss/oxide-linux-arm64-musl": "4.3.3",
"@tailwindcss/oxide-linux-x64-gnu": "4.3.3",
"@tailwindcss/oxide-linux-x64-musl": "4.3.3",
"@tailwindcss/oxide-wasm32-wasi": "4.3.3",
"@tailwindcss/oxide-win32-arm64-msvc": "4.3.3",
"@tailwindcss/oxide-win32-x64-msvc": "4.3.3"
}
},
"node_modules/@tailwindcss/oxide-android-arm64": {
"version": "4.3.2",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.2.tgz",
"integrity": "sha512-WHxqIuHpvZ5VtdX6GTl1Ik/Vp2YuN42Et+0CdeaVd/frQ9jAvGmvR8vLT+jk3e8/Q3x8kECB9+R17pgpp2BulA==",
"version": "4.3.3",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.3.tgz",
"integrity": "sha512-Y85A2gmPSkl5Ve5qR86GL4HT509cFqQh1aes9p3sSkyTPwt0Pppf3GkwGe4JPACcRYjgJIEhQgM6dBClnr0NYw==",
"cpu": [
"arm64"
],
@@ -458,9 +458,9 @@
}
},
"node_modules/@tailwindcss/oxide-darwin-arm64": {
"version": "4.3.2",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.2.tgz",
"integrity": "sha512-GZypeUY/IDJW3877KeM+O67vbXr3MBnbtEL4aYhNErv/JWZhye2vGSWWG9tB6iiqR2MqRNkY8IOUy4NdSZV26w==",
"version": "4.3.3",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.3.tgz",
"integrity": "sha512-BiaWatpBcERQFDlOjRDpIVXuFK5PJez5SA4JMg6VYZdBYU+qKfV/vqjcIs+IYmtitf1xYQZTwXvU/8y4lfZUGw==",
"cpu": [
"arm64"
],
@@ -474,9 +474,9 @@
}
},
"node_modules/@tailwindcss/oxide-darwin-x64": {
"version": "4.3.2",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.2.tgz",
"integrity": "sha512-UIIzmefR6KO1sDU7MzRqAxC8iBpft/VhkGjTjnhoS6k7Z3rQ9wEgA1ODSiyH/tcSYssulNm4Ci3hOeK1jH7ccQ==",
"version": "4.3.3",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.3.tgz",
"integrity": "sha512-fAeUqfV5ndhxRwai8cXGzdLvul9utWOmeTkv69unv4ZXixjn61Z+p9lCWdwOwA3TYboG3BwdVuN/RDjhBRl0mw==",
"cpu": [
"x64"
],
@@ -490,9 +490,9 @@
}
},
"node_modules/@tailwindcss/oxide-freebsd-x64": {
"version": "4.3.2",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.2.tgz",
"integrity": "sha512-GN+uAmcI6DNspnCDwtOAZrTz6oukJnp337qZvxqCGLd3BHBzJpO0ZbTLRvJNdztOeAmTzewewGIMPb0tk2R4WA==",
"version": "4.3.3",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.3.tgz",
"integrity": "sha512-iyf5bV6+wnAlflVeEy7R25dupxTNECZN5QMI0qNT6eT+EgaGdZcKhGkr5SdoaWiLJ3spLqIY9VCeSGrwmtg4kw==",
"cpu": [
"x64"
],
@@ -506,9 +506,9 @@
}
},
"node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": {
"version": "4.3.2",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.2.tgz",
"integrity": "sha512-4ABn7qSbdHRwTiDiuWNegCyb5+2FJ4vKIKc3DmKrvAFw7MU1Lm11dIkTPwUaFdTzc7IsOpDbqBrlh0x6y36U/w==",
"version": "4.3.3",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.3.tgz",
"integrity": "sha512-aAYUprJAJQWWbRrPvtjdroZ56Md+JM8pMiopS6xGEwDfLhqj+2ver2p4nU4Mb3CRqcMmNBjo8KkUgcxhkzVQGQ==",
"cpu": [
"arm"
],
@@ -522,9 +522,9 @@
}
},
"node_modules/@tailwindcss/oxide-linux-arm64-gnu": {
"version": "4.3.2",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.2.tgz",
"integrity": "sha512-wDgEIGwoM8w8pufh9LVt1PahDgNdKXrLC2qfAnV3vAmococ9RWbxeAw4pxPttd/TsJfwjyLf90Dg1y9y8I6Emw==",
"version": "4.3.3",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.3.tgz",
"integrity": "sha512-nDxldcEENOxZRzC2uu9jrutZdAAQtb+8WWDCSnWL1zvBk1+FN+x6MtDViPB5AJMfttVCUhehGWus3XBPgatM/w==",
"cpu": [
"arm64"
],
@@ -538,9 +538,9 @@
}
},
"node_modules/@tailwindcss/oxide-linux-arm64-musl": {
"version": "4.3.2",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.2.tgz",
"integrity": "sha512-J5Nuk0uZQIiMTJj3LEx4sAA9tMFUoXQZFv1J6An+QGYe53HKRJuFDi0rpq/tuouCZeAbOBY3kQ6g8qeD4TUjtA==",
"version": "4.3.3",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.3.tgz",
"integrity": "sha512-Md44bD6veX/PC5iyF8cDVnw4HBIANZepRZZ7a8DQOvkfo5WUBwcp6iAuCUz23u+4SUkhJlD3eL7hNdW8ezd/kA==",
"cpu": [
"arm64"
],
@@ -554,9 +554,9 @@
}
},
"node_modules/@tailwindcss/oxide-linux-x64-gnu": {
"version": "4.3.2",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.2.tgz",
"integrity": "sha512-kqCZpSKOBEJO4mz7OqWoofBZeXTAwaVGPj0ErAj7CojmhKpWVWVOnrt9dE8odoIraZq4oj3ausM37kXi+Tow8w==",
"version": "4.3.3",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.3.tgz",
"integrity": "sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w==",
"cpu": [
"x64"
],
@@ -570,9 +570,9 @@
}
},
"node_modules/@tailwindcss/oxide-linux-x64-musl": {
"version": "4.3.2",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.2.tgz",
"integrity": "sha512-cixpqbh2toJDmkuCRI68nXA8ZxNmdK9Y+9v5h3MC3ZQKy/0BO8AWzlkWyRM7JAFSGBlfig4YVTPsK6MVgqz1uw==",
"version": "4.3.3",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.3.tgz",
"integrity": "sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img==",
"cpu": [
"x64"
],
@@ -586,9 +586,9 @@
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi": {
"version": "4.3.2",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.2.tgz",
"integrity": "sha512-4ec2Z/LOmRsAgU23CS4xeJfcJlmRg94A/XrbGRCF1gyU/zdDfRLYDVsS+ynSZCmGNxQ1jQriQOKMQeQxBA3Isw==",
"version": "4.3.3",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.3.tgz",
"integrity": "sha512-jx1+rPhY/5Ympkktd656HBWEBLxP7dH06losBLjjf5vgCODXvi9KhtftWcMIwTFIDqBr7cRnQkdLnAG+IOlGvQ==",
"bundleDependencies": [
"@napi-rs/wasm-runtime",
"@emnapi/core",
@@ -675,9 +675,9 @@
"optional": true
},
"node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
"version": "4.3.2",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.2.tgz",
"integrity": "sha512-Zyr/M0+XcYZu3bZrUytc7TXvrk0ftWfl8gN2MwekNDzhqhKRUucMPSeOzM0o0wH5AWOU49BsKRrfKxI2atCPMQ==",
"version": "4.3.3",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.3.tgz",
"integrity": "sha512-3rc292Ca2ceK6Ulcc/bAVnTs/3nDtoPhyEKlgPv+yQJQi/JS/AMJlqzxvlDacL1nekbrcf6bTqp/jV4qgnPxNQ==",
"cpu": [
"arm64"
],
@@ -691,9 +691,9 @@
}
},
"node_modules/@tailwindcss/oxide-win32-x64-msvc": {
"version": "4.3.2",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.2.tgz",
"integrity": "sha512-QI9BO7KlNZsp2GuO0jwAAj5jCDABOKXRkCk2XuKTSaNEFSdfzqswYVTtCHBNKHLsqyjFyFkqlDiwkNbTYSssMQ==",
"version": "4.3.3",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.3.tgz",
"integrity": "sha512-yJ0pwIVc/nYeGoV02WtsN8KYyLQv7kyI2wDnkezyJlGGjkd4QLwDGAwl47YpPJeuI0M0ObaXGSPjvWDPeTPggw==",
"cpu": [
"x64"
],
@@ -707,14 +707,14 @@
}
},
"node_modules/@tailwindcss/vite": {
"version": "4.3.2",
"resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.3.2.tgz",
"integrity": "sha512-eHpMeX4JXfVNJDEcsouTeCBubJBTcTLigeaw/NTUW6PB5ATKKXdyonnXgTBX2VuRbjz1hjfz6C5XAhr52ImQXA==",
"version": "4.3.3",
"resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.3.3.tgz",
"integrity": "sha512-yYU8cogLeSh/ms2jh8Fj7jaba/EWa7Ja6GoUqYZaraEuCI5YS6ms6ObZgjjedm+jm6XZjdNRWBpPP6Z86oOxcw==",
"license": "MIT",
"dependencies": {
"@tailwindcss/node": "4.3.2",
"@tailwindcss/oxide": "4.3.2",
"tailwindcss": "4.3.2"
"@tailwindcss/node": "4.3.3",
"@tailwindcss/oxide": "4.3.3",
"tailwindcss": "4.3.3"
},
"peerDependencies": {
"vite": "^5.2.0 || ^6 || ^7 || ^8"
@@ -1546,9 +1546,9 @@
}
},
"node_modules/autoprefixer": {
"version": "10.5.3",
"resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.5.3.tgz",
"integrity": "sha512-bJRzflk8GgE4JX+iZNEwz9f9p460NCHnU7bd+CZ9vIjIlZuTkt6F3WSl2oNO8StZBFx17nLEsiQ6H2wcZiY7nA==",
"version": "10.5.4",
"resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.5.4.tgz",
"integrity": "sha512-MaU0U/za7N3r6brxD4YB/l4NSrFzLPlANv6wEuQVaIPlD3L4W9rFcQPbL/EilY9BHhHvhfcz3gInDLrEtWT4EA==",
"dev": true,
"funding": [
{
@@ -1567,7 +1567,7 @@
"license": "MIT",
"dependencies": {
"browserslist": "^4.28.6",
"caniuse-lite": "^1.0.30001805",
"caniuse-lite": "^1.0.30001806",
"fraction.js": "^5.3.4",
"picocolors": "^1.1.1",
"postcss-value-parser": "^4.2.0"
@@ -1630,9 +1630,9 @@
}
},
"node_modules/caniuse-lite": {
"version": "1.0.30001805",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001805.tgz",
"integrity": "sha512-52noaS3DubycKSXaU30TwPGIp+POyQSUVa5jBEq3vkRkY0kjyb3LQgvhU6WGyCcyXqVLWO0Cw0Q6BSdD0kUfVA==",
"version": "1.0.30001806",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001806.tgz",
"integrity": "sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw==",
"dev": true,
"funding": [
{
@@ -1691,9 +1691,9 @@
"license": "ISC"
},
"node_modules/enhanced-resolve": {
"version": "5.21.6",
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.21.6.tgz",
"integrity": "sha512-aNnGCvbJ/RIyWo1IuhNdVjnNF+EjH9wpzpNHt+ci/m9He9LJvUN8wrCcXjp9cWsGNAuvSpVFTx/vraAFQ8qGjQ==",
"version": "5.24.2",
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.24.2.tgz",
"integrity": "sha512-rpsZEGT1jFuve6QlpyRp9ckQ+kN61hvF9BzCPyMdaKTm8UJce96KBn3sorXOFXlzjPrs3Vc4T1NsSroZ3PxlFw==",
"license": "MIT",
"dependencies": {
"graceful-fs": "^4.2.4",
@@ -2260,9 +2260,9 @@
"license": "MIT"
},
"node_modules/tailwindcss": {
"version": "4.3.2",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.2.tgz",
"integrity": "sha512-WtctNNSH8A9jlMIqxzuYumOHU5uGZyRv0Q5svQl+oEPy5w84YpBxdb7MdqyiSPQge5jTJ6zFQLq0PFygdccSBA==",
"version": "4.3.3",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.3.tgz",
"integrity": "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==",
"license": "MIT"
},
"node_modules/tapable": {
@@ -2395,15 +2395,15 @@
}
},
"node_modules/vite": {
"version": "8.1.4",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.1.4.tgz",
"integrity": "sha512-bTT9PsdWO+MQMNG9ZXIP/qM9wGh37DFxTV/sPq9cFpHr3w4jkgef032PkAL9jAqhk3Nz8NQw3O8n6/xFkqO4QQ==",
"version": "8.1.5",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.1.5.tgz",
"integrity": "sha512-7ULLwsCdYx/nRyrpiEwvqb5TFHrMVZyBt+rg/OAXT7rgj/z+DtTDyKFeLAdDkubDVDKD8jOsndmy7m55XcfUsw==",
"license": "MIT",
"dependencies": {
"lightningcss": "^1.32.0",
"picomatch": "^4.0.5",
"postcss": "^8.5.16",
"rolldown": "~1.1.4",
"postcss": "^8.5.17",
"rolldown": "~1.1.5",
"tinyglobby": "^0.2.17"
},
"bin": {
+5 -5
View File
@@ -1,7 +1,7 @@
{
"name": "firelink",
"private": true,
"version": "1.1.0",
"version": "1.1.1",
"description": "A fast cross-platform desktop download manager powered by Rust, Tauri, React, aria2, and yt-dlp.",
"license": "MIT",
"homepage": "https://github.com/nimbold/Firelink",
@@ -39,7 +39,7 @@
},
"dependencies": {
"@formkit/auto-animate": "^0.10.0",
"@tailwindcss/vite": "^4.3.1",
"@tailwindcss/vite": "^4.3.3",
"@tauri-apps/api": "^2.11.1",
"@tauri-apps/plugin-clipboard-manager": "^2.3.2",
"@tauri-apps/plugin-dialog": "^2.7.1",
@@ -57,11 +57,11 @@
"@types/react": "^19.1.8",
"@types/react-dom": "^19.1.6",
"@vitejs/plugin-react": "^6.0.3",
"autoprefixer": "^10.5.3",
"autoprefixer": "^10.5.4",
"postcss": "^8.5.19",
"tailwindcss": "^4.3.1",
"tailwindcss": "^4.3.3",
"typescript": "^7.0.2",
"vite": "^8.1.4",
"vite": "^8.1.5",
"vitest": "^4.1.10"
}
}
+83 -12
View File
@@ -2,6 +2,8 @@
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { Readable, Transform } from 'node:stream';
import { pipeline } from 'node:stream/promises';
import { execFileSync } from 'node:child_process';
import { fileURLToPath } from 'node:url';
import { collectRegularFiles, sha256 } from './engine-payload-integrity.js';
@@ -35,6 +37,44 @@ const destination = path.join(repoRoot, 'src-tauri', 'provisioned-engines', targ
const temporary = fs.mkdtempSync(path.join(os.tmpdir(), `firelink-engines-${target}-`));
const isWindows = target.includes('windows');
const executableSuffix = isWindows ? '.exe' : '';
const DOWNLOAD_ATTEMPTS = 3;
const DOWNLOAD_IDLE_TIMEOUT_MS = 120_000;
const DOWNLOAD_RETRY_DELAYS_MS = [2_000, 5_000];
const FILE_LOCK_RETRY_DELAYS_MS = [100, 250, 500, 1_000, 2_000];
function sleep(milliseconds) {
return new Promise(resolve => setTimeout(resolve, milliseconds));
}
async function removeFileWithRetry(file) {
for (let attempt = 0; ; attempt += 1) {
try {
fs.rmSync(file, { force: true });
return;
} catch (error) {
const retryable = process.platform === 'win32'
&& ['EACCES', 'EBUSY', 'EPERM'].includes(error?.code);
if (!retryable || attempt >= FILE_LOCK_RETRY_DELAYS_MS.length) {
throw error;
}
await sleep(FILE_LOCK_RETRY_DELAYS_MS[attempt]);
}
}
}
function createDownloadTimeout() {
const controller = new AbortController();
let timer;
const refresh = () => {
clearTimeout(timer);
timer = setTimeout(() => {
controller.abort(new Error(`Download idle for ${DOWNLOAD_IDLE_TIMEOUT_MS}ms`));
}, DOWNLOAD_IDLE_TIMEOUT_MS);
};
const dispose = () => clearTimeout(timer);
refresh();
return { signal: controller.signal, refresh, dispose };
}
async function download(name, source) {
const sourcePath = new URL(source.url).pathname;
@@ -42,20 +82,51 @@ async function download(name, source) {
temporary,
`${name}${sourcePath.endsWith('.tar.xz') ? '.tar.xz' : '.zip'}`
);
const response = await fetch(source.url, { redirect: 'follow' });
if (!response.ok || !response.body) {
throw new Error(`Failed to download ${name}: HTTP ${response.status}`);
}
const output = fs.createWriteStream(archive);
const reader = response.body.getReader();
while (true) {
const { done, value } = await reader.read();
if (done) break;
if (!output.write(Buffer.from(value))) {
await new Promise(resolve => output.once('drain', resolve));
let lastError;
for (let attempt = 1; attempt <= DOWNLOAD_ATTEMPTS; attempt += 1) {
const downloadTimeout = createDownloadTimeout();
try {
const response = await fetch(source.url, {
redirect: 'follow',
signal: downloadTimeout.signal,
});
if (!response.ok || !response.body) {
throw new Error(`Failed to download ${name}: HTTP ${response.status}`);
}
await pipeline(
Readable.fromWeb(response.body),
new Transform({
transform(chunk, encoding, callback) {
downloadTimeout.refresh();
callback(null, chunk, encoding);
},
}),
fs.createWriteStream(archive),
{ signal: downloadTimeout.signal }
);
break;
} catch (error) {
lastError = error;
await removeFileWithRetry(archive);
if (attempt === DOWNLOAD_ATTEMPTS) {
throw new Error(
`Failed to download ${name} after ${DOWNLOAD_ATTEMPTS} attempts: ${
error instanceof Error ? error.message : String(error)
}`,
{ cause: error }
);
}
await sleep(DOWNLOAD_RETRY_DELAYS_MS[attempt - 1]);
} finally {
downloadTimeout.dispose();
}
}
await new Promise(resolve => output.end(resolve));
if (lastError && !fs.existsSync(archive)) {
throw lastError;
}
const actual = sha256(archive);
if (actual !== source.sha256) {
+154 -35
View File
@@ -17,6 +17,7 @@ if (!executableArg) {
const executable = path.resolve(executableArg);
const assertNoVisibleChildWindows = process.argv.includes('--assert-no-visible-child-windows');
const assertPortableData = process.argv.includes('--assert-portable-data');
const READY_PORT_TIMEOUT_MS = 500;
const child = spawn(executable, [], {
cwd: process.env.RUNNER_TEMP || process.env.TMPDIR || process.cwd(),
detached: process.platform !== 'win32',
@@ -60,18 +61,21 @@ async function findReadyPort() {
break;
}
for (let port = 6412; port <= 6422; port += 1) {
const ports = Array.from({ length: 11 }, (_, index) => 6412 + index);
const matches = await Promise.all(ports.map(async port => {
try {
const response = await fetch(`http://127.0.0.1:${port}/ping`);
if (
response.headers.get('x-firelink-server') === '1'
&& response.headers.get('x-firelink-smoke-process-id') === String(child.pid)
) {
readyPort = port;
break;
}
} catch {}
}
const response = await fetch(`http://127.0.0.1:${port}/ping`, {
signal: AbortSignal.timeout(READY_PORT_TIMEOUT_MS),
});
const matchesChild = response.headers.get('x-firelink-server') === '1'
&& response.headers.get('x-firelink-smoke-process-id') === String(child.pid);
await response.body?.cancel();
return matchesChild ? port : null;
} catch {
return null;
}
}));
readyPort = matches.find(port => port !== null) ?? null;
if (readyPort === null) {
await sleep(250);
@@ -134,46 +138,161 @@ function waitForChildExit(timeoutMs) {
}
return new Promise(resolve => {
const timer = setTimeout(() => {
let timer;
const onExit = () => {
clearTimeout(timer);
child.off('exit', onExit);
resolve(true);
};
timer = setTimeout(() => {
child.off('exit', onExit);
resolve(false);
}, timeoutMs);
child.once('exit', () => {
clearTimeout(timer);
resolve(true);
});
child.once('exit', onExit);
});
}
async function terminateChild() {
if (!child.pid || childExit) {
return true;
}
function isProcessGroupAlive(rootPid) {
if (process.platform === 'win32') {
try {
execFileSync('taskkill', ['/pid', String(child.pid), '/t', '/f'], {
stdio: 'ignore',
windowsHide: true,
});
} catch {}
return waitForChildExit(10000);
return false;
}
try {
process.kill(-child.pid, 'SIGTERM');
} catch {
child.kill('SIGTERM');
}
if (await waitForChildExit(5000)) {
process.kill(-rootPid, 0);
return true;
} catch (error) {
return error?.code !== 'ESRCH';
}
}
async function waitForProcessGroupExit(rootPid, timeoutMs) {
if (process.platform === 'win32') {
return true;
}
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
if (!isProcessGroupAlive(rootPid)) {
return true;
}
await sleep(100);
}
return !isProcessGroupAlive(rootPid);
}
function windowsBundleRoot() {
return path.dirname(executable).replaceAll("'", "''");
}
function windowsBundleProcessIds() {
const root = windowsBundleRoot();
const script = `
$root = '${root}'
$rootPrefix = $root.TrimEnd('\\') + '\\'
Get-CimInstance Win32_Process |
Where-Object {
$commandLine = $_.CommandLine
$commandLine -and $commandLine.TrimStart([char]34).StartsWith($rootPrefix, [System.StringComparison]::OrdinalIgnoreCase)
} |
Select-Object -ExpandProperty ProcessId
`;
try {
const output = execFileSync('powershell', ['-NoProfile', '-NonInteractive', '-Command', script], {
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'pipe'],
windowsHide: true,
});
return output
.split(/\r?\n/)
.map(value => Number.parseInt(value.trim(), 10))
.filter(Number.isInteger);
} catch {
return null;
}
}
function terminateWindowsBundleProcesses() {
const root = windowsBundleRoot();
const script = `
$root = '${root}'
$rootPrefix = $root.TrimEnd('\\') + '\\'
Get-CimInstance Win32_Process |
Where-Object {
$commandLine = $_.CommandLine
$commandLine -and $commandLine.TrimStart([char]34).StartsWith($rootPrefix, [System.StringComparison]::OrdinalIgnoreCase)
} |
ForEach-Object { Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue }
`;
try {
execFileSync('powershell', ['-NoProfile', '-NonInteractive', '-Command', script], {
stdio: 'ignore',
windowsHide: true,
});
} catch {}
}
async function waitForWindowsBundleExit(timeoutMs) {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const processIds = windowsBundleProcessIds();
if (processIds?.length === 0) {
return true;
}
await sleep(100);
}
return windowsBundleProcessIds()?.length === 0;
}
async function terminateChild() {
if (!child.pid) {
return true;
}
const childWasRunning = !childExit;
if (process.platform === 'win32') {
if (childWasRunning) {
try {
execFileSync('taskkill', ['/pid', String(child.pid), '/t', '/f'], {
stdio: 'ignore',
windowsHide: true,
});
} catch {}
}
const childExited = await waitForChildExit(10000);
const bundleExited = await waitForWindowsBundleExit(5000);
if (childExited && bundleExited) {
return true;
}
terminateWindowsBundleProcesses();
return await waitForChildExit(5000) && await waitForWindowsBundleExit(5000);
}
if (childWasRunning && !childExit) {
try {
process.kill(-child.pid, 'SIGTERM');
} catch {
if (!childExit) {
child.kill('SIGTERM');
}
}
}
if (await waitForChildExit(5000) && await waitForProcessGroupExit(child.pid, 5000)) {
return true;
}
if (!childWasRunning || childExit) {
return false;
}
try {
process.kill(-child.pid, 'SIGKILL');
} catch {
child.kill('SIGKILL');
if (!childExit) {
child.kill('SIGKILL');
}
}
return waitForChildExit(5000);
return await waitForChildExit(5000) && await waitForProcessGroupExit(child.pid, 5000);
}
async function assertPortableStorage() {
+21 -3
View File
@@ -45,16 +45,34 @@ const searchRoot = argValue('--search-root');
function findEngineRoot(root) {
const expected = `yt-dlp-${targetTriple}${ext}`;
const matches = [];
const resolvedRoot = path.resolve(root);
const hasExpectedEngineFile = directory => {
try {
return fs.lstatSync(path.join(directory, expected)).isFile();
} catch (error) {
if (error?.code === 'ENOENT') return false;
throw new Error(`Unable to inspect packaged engine root '${directory}': ${error.message}`);
}
};
if (hasExpectedEngineFile(resolvedRoot)) {
matches.push(resolvedRoot);
}
const walk = directory => {
for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
let entries;
try {
entries = fs.readdirSync(directory, { withFileTypes: true });
} catch (error) {
throw new Error(`Unable to inspect packaged engine directory '${directory}': ${error.message}`);
}
for (const entry of entries) {
const candidate = path.join(directory, entry.name);
if (entry.isDirectory()) {
if (fs.existsSync(path.join(candidate, expected))) matches.push(candidate);
if (hasExpectedEngineFile(candidate)) matches.push(candidate);
walk(candidate);
}
}
};
walk(path.resolve(root));
walk(resolvedRoot);
if (matches.length !== 1) {
throw new Error(`Expected exactly one packaged engine root under ${root}, found ${matches.length}`);
}
+114 -112
View File
@@ -209,7 +209,7 @@ checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.118",
"syn 2.0.119",
]
[[package]]
@@ -244,7 +244,7 @@ checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.118",
"syn 2.0.119",
]
[[package]]
@@ -369,9 +369,9 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
[[package]]
name = "bitflags"
version = "2.13.0"
version = "2.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8"
checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da"
dependencies = [
"serde_core",
]
@@ -494,7 +494,7 @@ version = "0.18.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ca26ef0159422fb77631dc9d17b102f253b876fe1586b03b803e63a309b4ee2"
dependencies = [
"bitflags 2.13.0",
"bitflags 2.13.1",
"cairo-sys-rs",
"glib",
"libc",
@@ -734,7 +734,7 @@ version = "0.25.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "064badf302c3194842cf2c5d61f56cc88e54a759313879cdf03abdd27d0c3b97"
dependencies = [
"bitflags 2.13.0",
"bitflags 2.13.1",
"core-foundation",
"core-graphics-types",
"foreign-types",
@@ -747,7 +747,7 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb"
dependencies = [
"bitflags 2.13.0",
"bitflags 2.13.1",
"core-foundation",
"libc",
]
@@ -839,7 +839,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13b588ba4ac1a99f7f2964d24b3d896ddc6bf847ee3855dbd4366f058cfcd331"
dependencies = [
"quote",
"syn 2.0.118",
"syn 2.0.119",
]
[[package]]
@@ -898,7 +898,7 @@ dependencies = [
"proc-macro2",
"quote",
"strsim",
"syn 2.0.118",
"syn 2.0.119",
]
[[package]]
@@ -911,7 +911,7 @@ dependencies = [
"proc-macro2",
"quote",
"strsim",
"syn 2.0.118",
"syn 2.0.119",
]
[[package]]
@@ -922,7 +922,7 @@ checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead"
dependencies = [
"darling_core 0.20.11",
"quote",
"syn 2.0.118",
"syn 2.0.119",
]
[[package]]
@@ -933,7 +933,7 @@ checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d"
dependencies = [
"darling_core 0.23.0",
"quote",
"syn 2.0.118",
"syn 2.0.119",
]
[[package]]
@@ -980,7 +980,7 @@ dependencies = [
"darling 0.20.11",
"proc-macro2",
"quote",
"syn 2.0.118",
"syn 2.0.119",
]
[[package]]
@@ -990,7 +990,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c"
dependencies = [
"derive_builder_core",
"syn 2.0.118",
"syn 2.0.119",
]
[[package]]
@@ -1011,7 +1011,7 @@ dependencies = [
"proc-macro2",
"quote",
"rustc_version",
"syn 2.0.118",
"syn 2.0.119",
]
[[package]]
@@ -1064,7 +1064,7 @@ version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38"
dependencies = [
"bitflags 2.13.0",
"bitflags 2.13.1",
"block2",
"libc",
"objc2",
@@ -1078,7 +1078,7 @@ checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.118",
"syn 2.0.119",
]
[[package]]
@@ -1101,7 +1101,7 @@ checksum = "0fbbb781877580993a8707ec48672673ec7b81eeba04cfd2310bd28c08e47c8f"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.118",
"syn 2.0.119",
]
[[package]]
@@ -1194,7 +1194,7 @@ dependencies = [
"cc",
"memchr",
"rustc_version",
"toml 1.1.2+spec-1.1.0",
"toml 1.1.3+spec-1.1.0",
"vswhom",
"winreg 0.55.0",
]
@@ -1238,7 +1238,7 @@ checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.118",
"syn 2.0.119",
]
[[package]]
@@ -1248,7 +1248,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1bf3c259d255ca70051b30e2e95b5446cdb8949ac4cd22c0d7fd634d89f568e2"
dependencies = [
"log 0.4.33",
"regex 1.13.0",
"regex 1.13.1",
]
[[package]]
@@ -1365,7 +1365,7 @@ checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
[[package]]
name = "firelink"
version = "1.1.0"
version = "1.1.1"
dependencies = [
"apple-native-keyring-store",
"async-trait",
@@ -1377,7 +1377,7 @@ dependencies = [
"keyring-core",
"log 0.4.33",
"objc",
"regex 1.13.0",
"regex 1.13.1",
"reqwest",
"rusqlite",
"rustls",
@@ -1463,7 +1463,7 @@ checksum = "1a5c6c585bc94aaf2c7b51dd4c2ba22680844aba4c687be581871a6f518c5742"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.118",
"syn 2.0.119",
]
[[package]]
@@ -1534,7 +1534,7 @@ checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.118",
"syn 2.0.119",
]
[[package]]
@@ -1757,7 +1757,7 @@ version = "0.18.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "233daaf6e83ae6a12a52055f568f9d7cf4671dabb78ff9560ab6da230ce00ee5"
dependencies = [
"bitflags 2.13.0",
"bitflags 2.13.1",
"futures-channel",
"futures-core",
"futures-executor",
@@ -1785,7 +1785,7 @@ dependencies = [
"proc-macro-error",
"proc-macro2",
"quote",
"syn 2.0.118",
"syn 2.0.119",
]
[[package]]
@@ -1864,7 +1864,7 @@ dependencies = [
"proc-macro-error",
"proc-macro2",
"quote",
"syn 2.0.118",
"syn 2.0.119",
]
[[package]]
@@ -2427,7 +2427,7 @@ dependencies = [
"quote",
"rustc_version",
"simd_cesu8",
"syn 2.0.118",
"syn 2.0.119",
]
[[package]]
@@ -2455,7 +2455,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264"
dependencies = [
"quote",
"syn 2.0.118",
"syn 2.0.119",
]
[[package]]
@@ -2512,7 +2512,7 @@ version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b750dcadc39a09dbadd74e118f6dd6598df77fa01df0cfcdc52c28dece74528a"
dependencies = [
"bitflags 2.13.0",
"bitflags 2.13.1",
"serde",
"unicode-segmentation",
]
@@ -2771,7 +2771,7 @@ version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4"
dependencies = [
"bitflags 2.13.0",
"bitflags 2.13.1",
"jni-sys 0.3.1",
"log 0.4.33",
"ndk-sys",
@@ -2937,7 +2937,7 @@ dependencies = [
"proc-macro-crate 3.5.0",
"proc-macro2",
"quote",
"syn 2.0.118",
"syn 2.0.119",
]
[[package]]
@@ -2974,7 +2974,7 @@ version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c"
dependencies = [
"bitflags 2.13.0",
"bitflags 2.13.1",
"block2",
"objc2",
"objc2-core-foundation",
@@ -2988,7 +2988,7 @@ version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "73ad74d880bb43877038da939b7427bba67e9dd42004a18b809ba7d87cee241c"
dependencies = [
"bitflags 2.13.0",
"bitflags 2.13.1",
"objc2",
"objc2-foundation",
]
@@ -3009,7 +3009,7 @@ version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536"
dependencies = [
"bitflags 2.13.0",
"bitflags 2.13.1",
"block2",
"dispatch2",
"libc",
@@ -3022,7 +3022,7 @@ version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807"
dependencies = [
"bitflags 2.13.0",
"bitflags 2.13.1",
"dispatch2",
"objc2",
"objc2-core-foundation",
@@ -3055,7 +3055,7 @@ version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d"
dependencies = [
"bitflags 2.13.0",
"bitflags 2.13.1",
"objc2",
"objc2-core-foundation",
"objc2-core-graphics",
@@ -3082,7 +3082,7 @@ version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272"
dependencies = [
"bitflags 2.13.0",
"bitflags 2.13.1",
"block2",
"libc",
"objc2",
@@ -3095,7 +3095,7 @@ version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "33fafba39597d6dc1fb709123dfa8289d39406734be322956a69f0931c73bb15"
dependencies = [
"bitflags 2.13.0",
"bitflags 2.13.1",
"block2",
"dispatch2",
"libc",
@@ -3109,7 +3109,7 @@ version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d"
dependencies = [
"bitflags 2.13.0",
"bitflags 2.13.1",
"objc2",
"objc2-core-foundation",
]
@@ -3131,7 +3131,7 @@ version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f"
dependencies = [
"bitflags 2.13.0",
"bitflags 2.13.1",
"objc2",
"objc2-core-foundation",
"objc2-foundation",
@@ -3143,7 +3143,7 @@ version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22"
dependencies = [
"bitflags 2.13.0",
"bitflags 2.13.1",
"block2",
"objc2",
"objc2-cloud-kit",
@@ -3174,7 +3174,7 @@ version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b2e5aaab980c433cf470df9d7af96a7b46a9d892d521a2cbbb2f8a4c16751e7f"
dependencies = [
"bitflags 2.13.0",
"bitflags 2.13.1",
"block2",
"objc2",
"objc2-app-kit",
@@ -3359,7 +3359,7 @@ dependencies = [
"phf_shared",
"proc-macro2",
"quote",
"syn 2.0.118",
"syn 2.0.119",
]
[[package]]
@@ -3426,7 +3426,7 @@ version = "0.18.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61"
dependencies = [
"bitflags 2.13.0",
"bitflags 2.13.1",
"crc32fast",
"fdeflate",
"flate2",
@@ -3503,7 +3503,7 @@ version = "3.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f"
dependencies = [
"toml_edit 0.25.12+spec-1.1.0",
"toml_edit 0.25.13+spec-1.1.0",
]
[[package]]
@@ -3654,7 +3654,7 @@ version = "0.5.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
dependencies = [
"bitflags 2.13.0",
"bitflags 2.13.1",
]
[[package]]
@@ -3685,7 +3685,7 @@ checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.118",
"syn 2.0.119",
]
[[package]]
@@ -3703,9 +3703,9 @@ dependencies = [
[[package]]
name = "regex"
version = "1.13.0"
version = "1.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2a0e75113e14dc5acb068cd0786884f214f1312650a3d36d269f5c4f3cdee8a2"
checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d"
dependencies = [
"aho-corasick 1.1.4",
"memchr",
@@ -3715,9 +3715,9 @@ dependencies = [
[[package]]
name = "regex-automata"
version = "0.4.15"
version = "0.4.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1f388202e4b80542a0921078cc23b6333bcf1409c1e3f86404cae4766a6131db"
checksum = "8fcfdb36bda0c880c5931cdc7a2bcdc8ba4556847b9d912bca70bc94708711ad"
dependencies = [
"aho-corasick 1.1.4",
"memchr",
@@ -3832,7 +3832,7 @@ version = "0.40.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "11438310b19e3109b6446c33d1ed5e889428cf2e278407bc7896bc4aaea43323"
dependencies = [
"bitflags 2.13.0",
"bitflags 2.13.1",
"fallible-iterator",
"fallible-streaming-iterator",
"hashlink",
@@ -3872,7 +3872,7 @@ version = "1.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
dependencies = [
"bitflags 2.13.0",
"bitflags 2.13.1",
"errno",
"libc",
"linux-raw-sys",
@@ -4030,7 +4030,7 @@ dependencies = [
"proc-macro2",
"quote",
"serde_derive_internals",
"syn 2.0.118",
"syn 2.0.119",
]
[[package]]
@@ -4064,7 +4064,7 @@ version = "3.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d"
dependencies = [
"bitflags 2.13.0",
"bitflags 2.13.1",
"core-foundation",
"core-foundation-sys",
"libc",
@@ -4087,7 +4087,7 @@ version = "0.36.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c5d9c0c92a92d33f08817311cf3f2c29a3538a8240e94a6a3c622ce652d7e00c"
dependencies = [
"bitflags 2.13.0",
"bitflags 2.13.1",
"cssparser",
"derive_more",
"log 0.4.33",
@@ -4149,7 +4149,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.118",
"syn 2.0.119",
]
[[package]]
@@ -4160,7 +4160,7 @@ checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.118",
"syn 2.0.119",
]
[[package]]
@@ -4195,7 +4195,7 @@ checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.118",
"syn 2.0.119",
]
[[package]]
@@ -4257,7 +4257,7 @@ dependencies = [
"darling 0.23.0",
"proc-macro2",
"quote",
"syn 2.0.118",
"syn 2.0.119",
]
[[package]]
@@ -4279,7 +4279,7 @@ checksum = "772ee033c0916d670af7860b6e1ef7d658a4629a6d0b4c8c3e67f09b3765b75d"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.118",
"syn 2.0.119",
]
[[package]]
@@ -4374,9 +4374,9 @@ dependencies = [
[[package]]
name = "simd-adler32"
version = "0.3.9"
version = "0.3.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214"
checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea"
[[package]]
name = "simd_cesu8"
@@ -4547,9 +4547,9 @@ dependencies = [
[[package]]
name = "syn"
version = "2.0.118"
version = "2.0.119"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422"
checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297"
dependencies = [
"proc-macro2",
"quote",
@@ -4573,7 +4573,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.118",
"syn 2.0.119",
]
[[package]]
@@ -4632,7 +4632,7 @@ version = "0.35.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d1c93047acf68669466a34690ac58cca7010bd1b201e1ec86f1fd0a75d3dd4a9"
dependencies = [
"bitflags 2.13.0",
"bitflags 2.13.1",
"block2",
"core-foundation",
"core-graphics",
@@ -4674,7 +4674,7 @@ checksum = "f4e16beb8b2ac17db28eab8bca40e62dbfbb34c0fcdc6d9826b11b7b5d047dfd"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.118",
"syn 2.0.119",
]
[[package]]
@@ -4774,7 +4774,7 @@ dependencies = [
"serde",
"serde_json",
"sha2 0.10.9",
"syn 2.0.118",
"syn 2.0.119",
"tauri-utils",
"thiserror 2.0.18",
"time",
@@ -4792,7 +4792,7 @@ dependencies = [
"heck 0.5.0",
"proc-macro2",
"quote",
"syn 2.0.118",
"syn 2.0.119",
"tauri-codegen",
"tauri-utils",
]
@@ -4887,7 +4887,7 @@ dependencies = [
"tauri-plugin",
"tauri-utils",
"thiserror 2.0.18",
"toml 1.1.2+spec-1.1.0",
"toml 1.1.3+spec-1.1.0",
"url",
]
@@ -4963,7 +4963,7 @@ dependencies = [
"log 0.4.33",
"open",
"os_pipe",
"regex 1.13.0",
"regex 1.13.1",
"schemars 0.8.22",
"serde",
"serde_json",
@@ -5064,7 +5064,7 @@ dependencies = [
"plist",
"proc-macro2",
"quote",
"regex 1.13.0",
"regex 1.13.1",
"schemars 0.8.22",
"semver",
"serde",
@@ -5073,7 +5073,7 @@ dependencies = [
"serde_with",
"swift-rs",
"thiserror 2.0.18",
"toml 1.1.2+spec-1.1.0",
"toml 1.1.3+spec-1.1.0",
"url",
"urlpattern",
"uuid",
@@ -5088,7 +5088,7 @@ checksum = "cc65d45c68858bfe420dd29e834b5d15dbecf8a07a8a16cf4d532c7b1f69d4b6"
dependencies = [
"dunce",
"embed-resource",
"toml 1.1.2+spec-1.1.0",
"toml 1.1.3+spec-1.1.0",
]
[[package]]
@@ -5159,7 +5159,7 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.118",
"syn 2.0.119",
]
[[package]]
@@ -5170,7 +5170,7 @@ checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.118",
"syn 2.0.119",
]
[[package]]
@@ -5264,9 +5264,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
[[package]]
name = "tokio"
version = "1.52.3"
version = "1.52.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe"
checksum = "317fafbbe3f02fc663dad00ea6186197de963cd4190e86a26d8d0fae095539af"
dependencies = [
"bytes",
"libc",
@@ -5286,7 +5286,7 @@ checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.118",
"syn 2.0.119",
]
[[package]]
@@ -5353,9 +5353,9 @@ dependencies = [
[[package]]
name = "toml"
version = "1.1.2+spec-1.1.0"
version = "1.1.3+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "81f3d15e84cbcd896376e6730314d59fb5a87f31e4b038454184435cd57defee"
checksum = "53c96ecdfa941c8fc4fcaed14f99ada8ebed502eef533015095a07e3301d4c3c"
dependencies = [
"indexmap 2.14.0",
"serde_core",
@@ -5419,9 +5419,9 @@ dependencies = [
[[package]]
name = "toml_edit"
version = "0.25.12+spec-1.1.0"
version = "0.25.13+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d2153edc6955a6c354fad8f5efd38b6a8769bdccf9fe50f8e1329f81b0baa5d7"
checksum = "6975367e4d2ef766d86af01ffad14b622fecc8d4357a998fbc4deb6e9bacaf9b"
dependencies = [
"indexmap 2.14.0",
"toml_datetime 1.1.1+spec-1.1.0",
@@ -5440,9 +5440,9 @@ dependencies = [
[[package]]
name = "toml_writer"
version = "1.1.1+spec-1.1.0"
version = "1.1.2+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db"
checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2"
[[package]]
name = "tower"
@@ -5466,7 +5466,7 @@ version = "0.6.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840"
dependencies = [
"bitflags 2.13.0",
"bitflags 2.13.1",
"bytes",
"futures-util",
"http",
@@ -5484,9 +5484,11 @@ version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b11f75e912b0c2be01b63d8cf8057b8c3f97cf34abb3d431a3a4c8675498e233"
dependencies = [
"bitflags 2.13.0",
"bitflags 2.13.1",
"bytes",
"http",
"http-body",
"http-body-util",
"percent-encoding",
"pin-project-lite",
"tower-layer",
@@ -5525,7 +5527,7 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.118",
"syn 2.0.119",
]
[[package]]
@@ -5613,7 +5615,7 @@ checksum = "38d90eea51bc7988ef9e674bf80a85ba6804739e535e9cab48e4bb34a8b652aa"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.118",
"syn 2.0.119",
"termcolor",
]
@@ -5746,7 +5748,7 @@ version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "70acd30e3aa1450bc2eece896ce2ad0d178e9c079493819301573dae3c37ba6d"
dependencies = [
"regex 1.13.0",
"regex 1.13.1",
"serde",
"unic-ucd-ident",
"url",
@@ -5766,9 +5768,9 @@ checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
[[package]]
name = "uuid"
version = "1.23.5"
version = "1.24.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ea5fab0d6c3c01ae70085a09cb03d4c7a1d6314e2b3e075392783396d724ca0a"
checksum = "bf3923a6f5c4c6382e0b653c4117f48d631ea17f38ed86e2a828e6f7412f5239"
dependencies = [
"getrandom 0.4.3",
"js-sys",
@@ -5890,7 +5892,7 @@ dependencies = [
"bumpalo",
"proc-macro2",
"quote",
"syn 2.0.118",
"syn 2.0.119",
"wasm-bindgen-shared",
]
@@ -5935,7 +5937,7 @@ version = "0.31.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "645c7c96bb74690c3189b5c9cb4ca1627062bb23693a4fad9d8c3de958260144"
dependencies = [
"bitflags 2.13.0",
"bitflags 2.13.1",
"rustix",
"wayland-backend",
"wayland-scanner",
@@ -5947,7 +5949,7 @@ version = "0.32.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "23d0c813de3daa2ed6520af85a3bd49b0e722a3078506899aa9686fea58dc4b6"
dependencies = [
"bitflags 2.13.0",
"bitflags 2.13.1",
"wayland-backend",
"wayland-client",
"wayland-scanner",
@@ -5959,7 +5961,7 @@ version = "0.3.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eb04e52f7836d7c7976c78ca0250d61e33873c34156a2a1fc9474828ec268234"
dependencies = [
"bitflags 2.13.0",
"bitflags 2.13.1",
"wayland-backend",
"wayland-client",
"wayland-protocols",
@@ -6083,7 +6085,7 @@ checksum = "67a921c1b6914c367b2b823cd4cde6f96beec77d30a939c8199bb377cf9b9b54"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.118",
"syn 2.0.119",
]
[[package]]
@@ -6270,7 +6272,7 @@ checksum = "f6fc35f58ecd95a9b71c4f2329b911016e6bec66b3f2e6a4aad86bd2e99e2f9b"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.118",
"syn 2.0.119",
]
[[package]]
@@ -6281,7 +6283,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.118",
"syn 2.0.119",
]
[[package]]
@@ -6292,7 +6294,7 @@ checksum = "08990546bf4edef8f431fa6326e032865f27138718c587dc21bc0265bbcb57cc"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.118",
"syn 2.0.119",
]
[[package]]
@@ -6303,7 +6305,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.118",
"syn 2.0.119",
]
[[package]]
@@ -6326,7 +6328,7 @@ checksum = "063426e76fdec7438d56bb777f67e318a84a25c707b07e575cb8b78e10c028f8"
dependencies = [
"byteorder",
"keyring-core",
"regex 1.13.0",
"regex 1.13.1",
"windows-sys 0.61.2",
"zeroize",
]
@@ -6839,7 +6841,7 @@ checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.118",
"syn 2.0.119",
"synstructure",
]
@@ -6898,7 +6900,7 @@ dependencies = [
"proc-macro-crate 3.5.0",
"proc-macro2",
"quote",
"syn 2.0.118",
"syn 2.0.119",
"zbus_names",
"zvariant",
"zvariant_utils",
@@ -6932,7 +6934,7 @@ checksum = "e2e817b7b52d0c7358d3246da9d69935ebb18116b2b102b4230dac079b4862f5"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.118",
"syn 2.0.119",
]
[[package]]
@@ -6952,7 +6954,7 @@ checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.118",
"syn 2.0.119",
"synstructure",
]
@@ -6992,7 +6994,7 @@ checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.118",
"syn 2.0.119",
]
[[package]]
@@ -7039,7 +7041,7 @@ dependencies = [
"proc-macro-crate 3.5.0",
"proc-macro2",
"quote",
"syn 2.0.118",
"syn 2.0.119",
"zvariant_utils",
]
@@ -7052,6 +7054,6 @@ dependencies = [
"proc-macro2",
"quote",
"serde",
"syn 2.0.118",
"syn 2.0.119",
"winnow 1.0.4",
]
+2 -2
View File
@@ -1,6 +1,6 @@
[package]
name = "firelink"
version = "1.1.0"
version = "1.1.1"
description = "A fast cross-platform desktop download manager powered by Rust and Tauri"
authors = ["NimBold"]
edition = "2021"
@@ -45,7 +45,7 @@ tauri-plugin-single-instance = { version = "2.4.3", features = ["deep-link"] }
tempfile = "3"
thiserror = "2.0.18"
axum = "0.8.9"
tower-http = { version = "0.7", features = ["cors"] }
tower-http = { version = "0.7", features = ["cors", "limit"] }
sysproxy = "0.3.0"
semver = "1.0.28"
keepawake = "0.6.0"
Binary file not shown.
+274 -7
View File
@@ -707,6 +707,188 @@ pub fn save_settings(connection: &Connection, data: &str) -> Result<(), String>
Ok(())
}
fn settings_state_mut(
document: &mut Value,
) -> Result<&mut serde_json::Map<String, Value>, String> {
if document.get("state").is_some() {
document
.get_mut("state")
.and_then(Value::as_object_mut)
.ok_or_else(|| "persisted settings state must be an object".to_string())
} else {
document
.as_object_mut()
.ok_or_else(|| "persisted settings must be an object".to_string())
}
}
fn add_site_login_to_settings(
data: &str,
id: &str,
url_pattern: &str,
username: &str,
) -> Result<String, String> {
let mut document: Value = serde_json::from_str(data)
.map_err(|error| format!("failed to decode settings: {error}"))?;
let state = settings_state_mut(&mut document)?;
let logins = state
.entry("siteLogins")
.or_insert_with(|| Value::Array(Vec::new()));
let logins = logins
.as_array_mut()
.ok_or_else(|| "persisted site logins must be an array".to_string())?;
if logins.iter().any(|login| {
login
.get("id")
.and_then(Value::as_str)
.is_some_and(|existing_id| existing_id == id)
}) {
return Err("site login already exists".to_string());
}
logins.push(serde_json::json!({
"id": id,
"urlPattern": url_pattern,
"username": username,
}));
serde_json::to_string(&document)
.map_err(|error| format!("failed to encode settings: {error}"))
}
fn remove_site_login_from_settings(
data: &str,
id: &str,
) -> Result<(String, bool), String> {
let mut document: Value = serde_json::from_str(data)
.map_err(|error| format!("failed to decode settings: {error}"))?;
let state = settings_state_mut(&mut document)?;
let Some(logins) = state.get_mut("siteLogins") else {
return Ok((data.to_string(), false));
};
let logins = logins
.as_array_mut()
.ok_or_else(|| "persisted site logins must be an array".to_string())?;
let original_len = logins.len();
logins.retain(|login| {
login
.get("id")
.and_then(Value::as_str)
.is_none_or(|existing_id| existing_id != id)
});
if logins.len() == original_len {
return Ok((data.to_string(), false));
}
let updated = serde_json::to_string(&document)
.map_err(|error| format!("failed to encode settings: {error}"))?;
Ok((updated, true))
}
fn validate_site_login_id(id: &str) -> Result<&str, String> {
let trimmed = id.trim();
if trimmed.is_empty()
|| trimmed != id
|| trimmed == PAIRING_TOKEN_KEYCHAIN_ID
{
return Err("invalid site login identifier".to_string());
}
Ok(trimmed)
}
fn validate_site_login_input(
id: &str,
url_pattern: &str,
username: &str,
password: &str,
) -> Result<(), String> {
validate_site_login_id(id)?;
if url_pattern.trim().is_empty() || url_pattern.chars().any(char::is_whitespace) {
return Err("site login URL pattern must be non-empty and contain no whitespace".to_string());
}
if username.trim().is_empty() {
return Err("site login username must be non-empty".to_string());
}
if password.is_empty() {
return Err("site login password must be non-empty".to_string());
}
Ok(())
}
pub fn save_site_login(
connection: &Connection,
id: &str,
url_pattern: &str,
username: &str,
password: &str,
) -> Result<(), String> {
validate_site_login_input(id, url_pattern, username, password)?;
let id = validate_site_login_id(id)?;
let _keyring_guard = lock_keyring_operations()?;
let original = load_settings(connection)?.unwrap_or_else(|| {
// A first-run standard-mode install can grant keychain access before
// any frontend setting has been persisted. Start with a valid
// Zustand envelope so the first site login is not rejected.
serde_json::json!({ "state": {}, "version": 3 }).to_string()
});
if get_keychain_password_if_present_unlocked(id)?.is_some() {
return Err("a credential already exists for this site login".to_string());
}
let updated = add_site_login_to_settings(&original, id, url_pattern, username)?;
// Persist metadata before creating the secret. If the credential-store
// write fails, restore the exact previous settings document so a failed
// add cannot leave an orphaned keychain entry or a visible login row.
save_settings(connection, &updated)?;
if let Err(error) = set_keychain_password_unlocked(id, password) {
let keychain_rollback = delete_keychain_password_unlocked(id);
let settings_rollback = save_settings(connection, &original);
if let Err(rollback_error) = keychain_rollback {
return Err(format!(
"failed to save site login credential: {error}; credential rollback also failed: {rollback_error}"
));
}
if let Err(rollback_error) = settings_rollback {
return Err(format!(
"failed to save site login credential: {error}; settings rollback also failed: {rollback_error}"
));
}
return Err(format!("failed to save site login credential: {error}"));
}
Ok(())
}
pub fn delete_site_login(connection: &Connection, id: &str) -> Result<(), String> {
let id = validate_site_login_id(id)?;
let _keyring_guard = lock_keyring_operations()?;
let Some(original) = load_settings(connection)? else {
return Err("settings are not persisted yet".to_string());
};
let (updated, removed) = remove_site_login_from_settings(&original, id)?;
if !removed {
return Err("site login was not found".to_string());
}
let _existing_password = get_keychain_password_if_present_unlocked(id)?;
// Remove the metadata first only after the keychain has been checked. If
// deleting the secret fails, restore the metadata so the UI cannot lose a
// credential that still exists.
save_settings(connection, &updated)?;
if let Err(error) = delete_keychain_password_unlocked(id) {
if matches!(
get_keychain_password_if_present_unlocked(id),
Ok(None)
) {
return Ok(());
}
if let Err(rollback_error) = save_settings(connection, &original) {
return Err(format!(
"failed to delete site login credential: {error}; settings rollback also failed: {rollback_error}"
));
}
return Err(format!("failed to delete site login credential: {error}"));
}
Ok(())
}
fn save_settings_tx(transaction: &Transaction<'_>, data: &str) -> Result<(), String> {
transaction
.execute(
@@ -1355,22 +1537,25 @@ pub fn set_keychain_password(id: &str, password: &str) -> Result<(), String> {
set_keychain_password_unlocked(id, password)
}
fn get_keychain_password_unlocked(id: &str) -> Result<String, String> {
fn get_keychain_password_if_present_unlocked(id: &str) -> Result<Option<String>, String> {
let entry = keychain_entry(id)?;
match entry.get_password() {
Ok(password) => Ok(password),
Ok(password) => Ok(Some(password)),
#[cfg(target_os = "linux")]
Err(keyring_core::Error::NoEntry) => unique_legacy_linux_keychain_entry(id)?
.ok_or_else(|| keyring_core::Error::NoEntry.to_string())?
.get_password()
.map_err(|error| error.to_string()),
#[cfg(target_os = "linux")]
Err(error) => Err(error.to_string()),
.map(|legacy| legacy.get_password().map_err(|error| error.to_string()))
.transpose(),
#[cfg(not(target_os = "linux"))]
Err(keyring_core::Error::NoEntry) => Ok(None),
Err(error) => Err(error.to_string()),
}
}
fn get_keychain_password_unlocked(id: &str) -> Result<String, String> {
get_keychain_password_if_present_unlocked(id)?
.ok_or_else(|| keyring_core::Error::NoEntry.to_string())
}
pub fn get_keychain_password(id: &str) -> Result<String, String> {
let _guard = lock_keyring_operations()?;
get_keychain_password_unlocked(id)
@@ -1405,6 +1590,88 @@ mod tests {
use serde_json::json;
use tempfile::TempDir;
#[test]
fn site_login_settings_update_preserves_envelope_without_password() {
let original = json!({
"state": {
"theme": "dark",
"siteLogins": []
},
"version": 3
})
.to_string();
let updated = add_site_login_to_settings(
&original,
"login-1",
"https://example.com/*",
"nima",
)
.unwrap();
let document: Value = serde_json::from_str(&updated).unwrap();
assert_eq!(document["state"]["theme"], "dark");
assert_eq!(document["version"], 3);
assert_eq!(document["state"]["siteLogins"][0]["id"], "login-1");
assert_eq!(document["state"]["siteLogins"][0]["username"], "nima");
assert!(!updated.contains("password"));
}
#[test]
fn site_login_settings_update_rejects_duplicate_ids() {
let original = json!({
"state": {
"siteLogins": [{
"id": "login-1",
"urlPattern": "https://example.com/*",
"username": "old-user"
}]
},
"version": 3
})
.to_string();
let error = add_site_login_to_settings(
&original,
"login-1",
"https://example.com/*",
"new-user",
)
.unwrap_err();
assert_eq!(error, "site login already exists");
}
#[test]
fn site_login_settings_removal_reports_whether_metadata_changed() {
let original = json!({
"state": {
"siteLogins": [{
"id": "login-1",
"urlPattern": "https://example.com/*",
"username": "nima"
}]
},
"version": 3
})
.to_string();
let (updated, removed) = remove_site_login_from_settings(&original, "login-1").unwrap();
assert!(removed);
let document: Value = serde_json::from_str(&updated).unwrap();
assert!(document["state"]["siteLogins"].as_array().unwrap().is_empty());
let (unchanged, removed) = remove_site_login_from_settings(&updated, "missing").unwrap();
assert!(!removed);
assert_eq!(unchanged, updated);
}
#[test]
fn site_login_ids_cannot_alias_the_pairing_token_or_hide_whitespace() {
assert_eq!(validate_site_login_id("login-1").unwrap(), "login-1");
assert!(validate_site_login_id(" extension-pairing-token").is_err());
assert!(validate_site_login_id("extension-pairing-token ").is_err());
assert!(validate_site_login_id(" login-1").is_err());
}
#[test]
fn migrates_v0_database_and_creates_backup() {
let temp = TempDir::new().unwrap();
+34 -5
View File
@@ -77,7 +77,7 @@ impl DownloadCoordinator {
&self,
id: String,
lifecycle_generation: u64,
ack: tokio::sync::oneshot::Sender<()>,
ack: tokio::sync::oneshot::Sender<bool>,
) -> Result<(), String> {
self.media_tx
.send(MediaCmd::PauseWithAck {
@@ -128,7 +128,7 @@ enum MediaCmd {
PauseWithAck {
id: String,
lifecycle_generation: u64,
ack: tokio::sync::oneshot::Sender<()>,
ack: tokio::sync::oneshot::Sender<bool>,
},
Finished {
id: String,
@@ -144,7 +144,7 @@ async fn run_coordinator(
let mut active_media = HashMap::<String, (u64, watch::Sender<bool>)>::new();
let mut cancelled_media_generations = HashMap::<String, u64>::new();
let mut pending_media_acks =
HashMap::<(String, u64), tokio::sync::oneshot::Sender<()>>::new();
HashMap::<(String, u64), tokio::sync::oneshot::Sender<bool>>::new();
let mut pending_captured_urls = Vec::<String>::new();
let mut frontend_ready = false;
let mut command_open = true;
@@ -228,7 +228,10 @@ async fn run_coordinator(
.entry(id)
.and_modify(|generation| *generation = (*generation).max(lifecycle_generation))
.or_insert(lifecycle_generation);
let _ = ack.send(());
// No runner was registered for this generation.
// The caller may retire the cancellation tombstone
// while it still owns the per-download lock.
let _ = ack.send(false);
}
}
MediaCmd::Finished { id, lifecycle_generation } => {
@@ -238,7 +241,9 @@ async fn run_coordinator(
if let Some(ack) =
pending_media_acks.remove(&(id.clone(), lifecycle_generation))
{
let _ = ack.send(());
// A pending acknowledgement means the runner was
// registered and has now observed cancellation.
let _ = ack.send(true);
}
if cancelled_media_generations
.get(&id)
@@ -404,6 +409,30 @@ mod tests {
.expect("late media registration was not cancelled");
}
#[tokio::test]
async fn unregistered_media_pause_tombstone_can_be_reconciled() {
let (coordinator, _events) = DownloadCoordinator::spawn_headless();
let (ack_tx, ack_rx) = tokio::sync::oneshot::channel();
coordinator
.pause_media_with_ack("abandoned-media".to_string(), 9, ack_tx)
.await
.unwrap();
assert!(!ack_rx.await.unwrap());
// Once the queue lifecycle is invalidated under its control lock,
// it can retire the tombstone without allowing a late runner to
// start. A later registration must therefore remain uncancelled.
coordinator
.finish_media("abandoned-media".to_string(), 9)
.await;
let cancel_rx = coordinator
.register_media("abandoned-media".to_string(), 9)
.await
.unwrap();
assert!(!*cancel_rx.borrow());
}
#[tokio::test]
async fn stale_media_registration_cannot_replace_a_newer_lifecycle() {
let (coordinator, _events) = DownloadCoordinator::spawn_headless();
+297 -31
View File
@@ -17,13 +17,17 @@ use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex, RwLock};
use std::time::{SystemTime, UNIX_EPOCH};
use tauri::{AppHandle, Emitter, Manager};
use tokio::sync::watch;
use tower_http::cors::{Any, CorsLayer};
use tokio::sync::{oneshot, watch};
use tower_http::{
cors::{Any, CorsLayer},
limit::RequestBodyLimitLayer,
};
use ts_rs::TS;
pub const EXTENSION_SERVER_PORT: u16 = 6412;
pub const EXTENSION_SERVER_PORT_RANGE: std::ops::RangeInclusive<u16> = EXTENSION_SERVER_PORT..=6422;
const MAX_URL_COUNT: usize = 200;
const MAX_REQUEST_BODY_BYTES: usize = 256 * 1024;
const SIGNATURE_MAX_AGE_MS: u64 = 60_000;
const SERVER_HEADER: &str = "x-firelink-server";
const PROTOCOL_VERSION_HEADER: &str = "x-firelink-protocol-version";
@@ -33,11 +37,14 @@ const SERVER_PORT_HEADER: &str = "x-firelink-server-port";
const SMOKE_PROCESS_ID_HEADER: &str = "x-firelink-smoke-process-id";
const SERVER_PROOF_PREFIX: &[u8] = b"firelink-server-proof\n";
const PROTOCOL_VERSION: &str = "4";
const MAX_PENDING_EXTENSION_ACKS: usize = 64;
const EXTENSION_ACK_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(2);
type HmacSha256 = Hmac<Sha256>;
pub type SharedExtensionToken = Arc<RwLock<String>>;
pub type SharedFrontendReady = Arc<AtomicBool>;
pub type SharedServerPort = Arc<RwLock<Option<u16>>>;
pub type SharedExtensionAcks = Arc<Mutex<HashMap<String, oneshot::Sender<()>>>>;
type ReplayCache = Arc<Mutex<HashMap<String, u64>>>;
#[derive(Clone)]
@@ -45,6 +52,7 @@ pub struct ServerState {
pub app_handle: AppHandle,
pub pairing_token: SharedExtensionToken,
pub frontend_ready: SharedFrontendReady,
pub extension_acks: SharedExtensionAcks,
pub replay_cache: ReplayCache,
pub bound_port: u16,
}
@@ -63,18 +71,30 @@ struct ExtensionRequest {
#[serde(default)]
cookies: Option<String>,
#[serde(default)]
cookie_scopes: Option<Vec<ExtensionCookieScope>>,
#[serde(default)]
media: bool,
}
#[derive(Clone, Deserialize, Serialize, TS)]
#[ts(export, export_to = "../../src/bindings/")]
pub struct ExtensionCookieScope {
pub url: String,
pub cookies: String,
}
#[derive(Clone, Serialize, TS)]
#[ts(export, export_to = "../../src/bindings/")]
pub struct ExtensionDownload {
#[ts(optional)]
request_id: Option<String>,
urls: Vec<String>,
referer: Option<String>,
silent: bool,
filename: Option<String>,
headers: Option<String>,
cookies: Option<String>,
cookie_scopes: Option<Vec<ExtensionCookieScope>>,
media: bool,
}
@@ -82,6 +102,7 @@ pub async fn start_server(
app_handle: AppHandle,
pairing_token: SharedExtensionToken,
frontend_ready: SharedFrontendReady,
extension_acks: SharedExtensionAcks,
server_port: SharedServerPort,
mut shutdown_rx: watch::Receiver<bool>,
) -> Result<(), String> {
@@ -90,6 +111,7 @@ pub async fn start_server(
app_handle,
pairing_token,
frontend_ready,
extension_acks,
replay_cache: Arc::new(Mutex::new(HashMap::new())),
bound_port: port,
};
@@ -106,6 +128,7 @@ pub async fn start_server(
.route("/ping", get(ping_handler))
.route("/download", post(download_handler))
.layer(cors)
.layer(RequestBodyLimitLayer::new(MAX_REQUEST_BODY_BYTES))
.layer(middleware::from_fn(add_server_identity))
.with_state(state);
@@ -235,10 +258,11 @@ async fn download_handler(
State(state): State<ServerState>,
headers: HeaderMap,
body: Bytes,
) -> Result<StatusCode, StatusCode> {
if !has_allowed_request_origin(&headers) || !has_valid_optional_nonce(&headers) {
return Err(StatusCode::FORBIDDEN);
}
) -> Result<Response, StatusCode> {
let nonce = match required_client_nonce(&headers) {
Some(nonce) if has_allowed_request_origin(&headers) => nonce,
_ => return Err(StatusCode::FORBIDDEN),
};
let signature = match headers
.get("x-firelink-signature")
@@ -293,15 +317,45 @@ async fn download_handler(
return Err(StatusCode::SERVICE_UNAVAILABLE);
}
let request_id = uuid::Uuid::new_v4().simple().to_string();
let ack_receiver = register_extension_ack(&state.extension_acks, request_id.clone())
.ok_or(StatusCode::SERVICE_UNAVAILABLE)?;
let mut download = download;
download.request_id = Some(request_id.clone());
if state
.app_handle
.emit("extension-add-download", download)
.is_err()
{
remove_extension_ack(&state.extension_acks, &request_id);
return Err(StatusCode::INTERNAL_SERVER_ERROR);
}
Ok(StatusCode::OK)
if tokio::time::timeout(EXTENSION_ACK_TIMEOUT, ack_receiver)
.await
.is_err()
{
remove_extension_ack(&state.extension_acks, &request_id);
// The event may already have reached the frontend even when its
// acknowledgement was delayed or lost. Do not return 503 here:
// extension callers retry 503 and could create a duplicate modal.
return Err(StatusCode::GATEWAY_TIMEOUT);
}
let proof = sign_server_proof(timestamp_str, nonce, state.bound_port, &state.pairing_token)
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
let mut response = Response::new(Body::empty());
response.headers_mut().insert(
SERVER_PROOF_HEADER,
HeaderValue::from_str(&proof).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?,
);
response.headers_mut().insert(
SERVER_PORT_HEADER,
HeaderValue::from_str(&state.bound_port.to_string())
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?,
);
Ok(response)
}
async fn wait_for_frontend(frontend_ready: &SharedFrontendReady) -> bool {
@@ -314,12 +368,45 @@ async fn wait_for_frontend(frontend_ready: &SharedFrontendReady) -> bool {
false
}
fn normalize_download(payload: ExtensionRequest) -> Option<ExtensionDownload> {
fn register_extension_ack(
registry: &SharedExtensionAcks,
request_id: String,
) -> Option<oneshot::Receiver<()>> {
let (sender, receiver) = oneshot::channel();
let mut pending = registry.lock().ok()?;
if pending.len() >= MAX_PENDING_EXTENSION_ACKS {
return None;
}
pending.insert(request_id, sender);
Some(receiver)
}
pub fn acknowledge_extension_download(registry: &SharedExtensionAcks, request_id: &str) -> bool {
let Some(sender) = registry
.lock()
.ok()
.and_then(|mut pending| pending.remove(request_id))
else {
return false;
};
sender.send(()).is_ok()
}
fn remove_extension_ack(registry: &SharedExtensionAcks, request_id: &str) {
if let Ok(mut pending) = registry.lock() {
pending.remove(request_id);
}
}
fn normalize_download(mut payload: ExtensionRequest) -> Option<ExtensionDownload> {
if payload.urls.len() > MAX_URL_COUNT {
return None;
}
let mut seen = HashSet::new();
let urls = payload
.urls
.into_iter()
.take(MAX_URL_COUNT)
.filter_map(|raw_url| normalize_url(&raw_url))
.filter(|url| seen.insert(url.clone()))
.collect::<Vec<_>>();
@@ -345,15 +432,29 @@ fn normalize_download(payload: ExtensionRequest) -> Option<ExtensionDownload> {
// request headers, but drop Cookie headers and the dedicated cookie field
// so a legacy or untrusted caller cannot reuse one session across hosts.
let headers = normalize_headers(payload.headers, payload.media || urls.len() > 1);
let cookies = if !payload.media && urls.len() == 1 {
payload
.cookies
.filter(|value| !value.trim().is_empty())
let cookie_scopes = if !payload.media && urls.len() == 1 {
let mut scopes = payload.cookie_scopes.take().unwrap_or_default();
if let Some(cookies) = payload.cookies.take() {
if !cookies.trim().is_empty() {
scopes.push(ExtensionCookieScope {
url: urls[0].clone(),
cookies,
});
}
}
normalize_cookie_scopes(scopes)
} else {
None
};
let cookies = cookie_scopes.as_ref().and_then(|scopes| {
scopes
.iter()
.find(|scope| same_origin_url(&scope.url, &urls[0]))
.map(|scope| scope.cookies.clone())
});
Some(ExtensionDownload {
request_id: None,
urls,
referer,
silent: payload.silent,
@@ -365,10 +466,62 @@ fn normalize_download(payload: ExtensionRequest) -> Option<ExtensionDownload> {
// builds pay for a doomed metadata request before retrying. Ordinary
// captured downloads still need their exact request cookies.
cookies,
cookie_scopes,
media: payload.media,
})
}
fn normalize_cookie_scopes(scopes: Vec<ExtensionCookieScope>) -> Option<Vec<ExtensionCookieScope>> {
let mut normalized = Vec::new();
let mut seen_origins = HashSet::new();
for scope in scopes {
let Ok(url) = Url::parse(scope.url.trim()) else {
continue;
};
if !matches!(url.scheme(), "http" | "https") {
continue;
}
let cookies = scope.cookies.trim();
if cookies.is_empty() {
continue;
}
let Some(host) = url.host_str() else {
continue;
};
let origin = format!(
"{}://{}:{}",
url.scheme(),
host,
url.port_or_known_default().unwrap_or(443)
);
if !seen_origins.insert(origin) {
continue;
}
normalized.push(ExtensionCookieScope {
url: url.to_string(),
cookies: cookies.to_string(),
});
if normalized.len() >= 16 {
break;
}
}
(!normalized.is_empty()).then_some(normalized)
}
fn same_origin_url(left: &str, right: &str) -> bool {
let Some(left) = Url::parse(left).ok() else {
return false;
};
let Some(right) = Url::parse(right).ok() else {
return false;
};
left.scheme() == right.scheme()
&& left.host() == right.host()
&& left.port_or_known_default() == right.port_or_known_default()
}
fn normalize_headers(headers: Option<String>, media: bool) -> Option<String> {
let headers = headers?;
if !media {
@@ -437,11 +590,11 @@ fn has_allowed_request_origin(headers: &HeaderMap) -> bool {
}
}
fn has_valid_optional_nonce(headers: &HeaderMap) -> bool {
match headers.get(CLIENT_NONCE_HEADER) {
None => true,
Some(nonce) => nonce.to_str().ok().is_some_and(is_valid_client_nonce),
}
fn required_client_nonce(headers: &HeaderMap) -> Option<&str> {
headers
.get(CLIENT_NONCE_HEADER)
.and_then(|nonce| nonce.to_str().ok())
.filter(|nonce| is_valid_client_nonce(nonce))
}
fn sign_server_proof(
@@ -478,16 +631,22 @@ fn claim_request(signature: &str, timestamp: u64, replay_cache: &ReplayCache) ->
Some(now) => now,
None => return false,
};
claim_request_at(signature, timestamp, replay_cache, now)
}
fn claim_request_at(signature: &str, timestamp: u64, replay_cache: &ReplayCache, now: u64) -> bool {
let mut cache = match replay_cache.lock() {
Ok(cache) => cache,
Err(_) => return false,
};
cache.retain(|_, seen_at| now.saturating_sub(*seen_at) < SIGNATURE_MAX_AGE_MS);
if cache.len() > 10_000 {
cache.retain(|_, expires_at| now < *expires_at);
let key = format!("{timestamp}:{}", signature.to_ascii_lowercase());
if cache.len() >= 10_000 && !cache.contains_key(&key) {
return false;
}
let key = format!("{timestamp}:{}", signature.to_ascii_lowercase());
cache.insert(key, now).is_none()
cache
.insert(key, timestamp.saturating_add(SIGNATURE_MAX_AGE_MS))
.is_none()
}
fn current_time_millis() -> Option<u64> {
@@ -530,9 +689,10 @@ fn is_allowed_origin(origin: &str) -> bool {
#[cfg(test)]
mod tests {
use super::{
add_server_identity, claim_request, has_allowed_request_origin, has_valid_optional_nonce,
is_valid_client_nonce, normalize_download, sign_server_proof, ExtensionRequest,
PROTOCOL_VERSION_HEADER, SERVER_HEADER,
acknowledge_extension_download, add_server_identity, claim_request_at,
has_allowed_request_origin, is_valid_client_nonce,
normalize_download, required_client_nonce, sign_server_proof, ExtensionCookieScope,
ExtensionRequest, MAX_URL_COUNT, PROTOCOL_VERSION_HEADER, SERVER_HEADER,
};
use axum::{
http::{HeaderMap, HeaderValue, StatusCode},
@@ -581,10 +741,9 @@ mod tests {
}
#[test]
fn rejects_invalid_origins_and_malformed_optional_nonces() {
fn rejects_invalid_origins() {
let mut headers = HeaderMap::new();
assert!(has_allowed_request_origin(&headers));
assert!(has_valid_optional_nonce(&headers));
headers.insert(
"origin",
@@ -596,12 +755,28 @@ mod tests {
"origin",
HeaderValue::from_static("moz-extension://firelink"),
);
assert!(has_allowed_request_origin(&headers));
}
#[test]
fn requires_a_valid_client_nonce_for_downloads() {
let mut headers = HeaderMap::new();
assert!(required_client_nonce(&headers).is_none());
headers.insert(
"x-firelink-client-nonce",
HeaderValue::from_static("not-a-valid-nonce"),
);
assert!(has_allowed_request_origin(&headers));
assert!(!has_valid_optional_nonce(&headers));
assert!(required_client_nonce(&headers).is_none());
headers.insert(
"x-firelink-client-nonce",
HeaderValue::from_static("0123456789abcdef0123456789abcdef"),
);
assert_eq!(
required_client_nonce(&headers),
Some("0123456789abcdef0123456789abcdef")
);
}
#[test]
@@ -613,19 +788,69 @@ mod tests {
filename: None,
headers: None,
cookies: None,
cookie_scopes: None,
media: true,
});
assert!(download.is_none());
}
#[test]
fn rejects_oversized_url_lists_instead_of_truncating_them() {
let download = normalize_download(ExtensionRequest {
urls: (0..=MAX_URL_COUNT)
.map(|index| format!("https://example.com/file-{index}.bin"))
.collect(),
referer: None,
silent: false,
filename: None,
headers: None,
cookies: None,
cookie_scopes: None,
media: false,
});
assert!(download.is_none());
}
#[test]
fn rejects_replayed_download_signature() {
let cache = Arc::new(Mutex::new(HashMap::new()));
let signature = "a".repeat(64);
let now = 1_000_000;
assert!(claim_request(&signature, 42, &cache));
assert!(!claim_request(&signature, 42, &cache));
assert!(claim_request_at(&signature, now, &cache, now));
assert!(!claim_request_at(&signature, now, &cache, now + 1));
}
#[test]
fn future_timestamp_replay_claim_survives_cache_pruning_window() {
let cache = Arc::new(Mutex::new(HashMap::new()));
let signature = "b".repeat(64);
let now = 1_000_000;
let future_timestamp = now + 30_000;
assert!(claim_request_at(&signature, future_timestamp, &cache, now));
assert!(!claim_request_at(
&signature,
future_timestamp,
&cache,
now + 70_000
));
}
#[tokio::test]
async fn acknowledges_and_removes_pending_extension_event() {
let registry = Arc::new(Mutex::new(HashMap::new()));
let (sender, receiver) = tokio::sync::oneshot::channel();
registry
.lock()
.unwrap()
.insert("request-1".to_string(), sender);
assert!(acknowledge_extension_download(&registry, "request-1"));
assert!(!acknowledge_extension_download(&registry, "request-1"));
assert!(receiver.await.is_ok());
}
#[test]
@@ -640,6 +865,7 @@ mod tests {
"x".repeat(64 * 1024)
)),
cookies: Some(format!("large={}", "x".repeat(64 * 1024))),
cookie_scopes: None,
media: true,
})
.expect("valid media handoff");
@@ -658,6 +884,7 @@ mod tests {
filename: None,
headers: None,
cookies: Some("session=browser-cookie-header".to_string()),
cookie_scopes: None,
media: false,
})
.expect("valid download handoff");
@@ -669,6 +896,44 @@ mod tests {
);
}
#[test]
fn regular_capture_normalizes_host_scoped_cookie_headers() {
let download = normalize_download(ExtensionRequest {
urls: vec!["https://mail.google.com/mail/u/0/?view=att".to_string()],
referer: Some("https://mail.google.com/mail/u/0/".to_string()),
silent: true,
filename: Some("report.zip".to_string()),
headers: None,
cookies: Some("SID=mail-session".to_string()),
cookie_scopes: Some(vec![
ExtensionCookieScope {
url: "https://mail.google.com/".to_string(),
cookies: "SID=mail-session".to_string(),
},
ExtensionCookieScope {
url: "https://accounts.google.com/".to_string(),
cookies: "SID=account-session".to_string(),
},
ExtensionCookieScope {
url: "https://mail.google.com/another-path".to_string(),
cookies: "duplicate=ignored".to_string(),
},
]),
media: false,
})
.expect("valid download handoff");
assert_eq!(download.cookies.as_deref(), Some("SID=mail-session"));
assert_eq!(
download.cookie_scopes.as_ref().map(|scopes| scopes.len()),
Some(2)
);
assert_eq!(
download.cookie_scopes.as_ref().unwrap()[1].cookies,
"SID=account-session"
);
}
#[test]
fn multi_url_capture_drops_cookie_scope_but_preserves_safe_headers() {
let download = normalize_download(ExtensionRequest {
@@ -681,6 +946,7 @@ mod tests {
filename: None,
headers: Some("Cookie: session=secret\nUser-Agent: Firefox".to_string()),
cookies: Some("session=secret".to_string()),
cookie_scopes: None,
media: false,
})
.expect("valid multi-url handoff");
+8
View File
@@ -80,6 +80,12 @@ pub struct DownloadItem {
pub eta: Option<String>,
#[ts(optional)]
pub size: Option<String>,
#[ts(optional)]
pub downloaded_bytes: Option<f64>,
#[ts(optional)]
pub total_bytes: Option<f64>,
#[ts(optional)]
pub total_is_estimate: Option<bool>,
pub category: DownloadCategory,
pub date_added: String,
#[ts(optional)]
@@ -274,6 +280,8 @@ pub struct PersistedSettings {
pub max_automatic_retries: i32,
pub show_notifications: bool,
pub play_completion_sound: bool,
#[serde(default)]
pub auto_add_clipboard_links: bool,
pub app_font_size: AppFontSize,
pub list_row_density: ListRowDensity,
pub show_dock_badge: bool,
+1807 -193
View File
File diff suppressed because it is too large Load Diff
+2
View File
@@ -1,4 +1,5 @@
use serde::{Deserialize, Serialize};
#[cfg(any(target_os = "macos", target_os = "linux"))]
use std::process::Command;
use ts_rs::TS;
@@ -83,6 +84,7 @@ fn proxy_from_environment() -> Option<String> {
})
}
#[cfg(any(target_os = "macos", target_os = "linux"))]
fn command_stdout(command: &mut Command) -> std::io::Result<String> {
let output = command.output()?;
if !output.status.success() {
+154 -15
View File
@@ -57,6 +57,16 @@ pub enum PendingOutcome {
Error(String),
}
/// Result of recycling an aria2 transfer's connections. A refresh can race
/// with daemon completion or leave the transfer paused after an ambiguous
/// unpause failure, so callers must handle the verified daemon outcome.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Aria2RefreshOutcome {
Resumed,
Paused,
Complete,
}
/// What kind of sidecar a queued task spawns. Drives which runner the
/// dispatcher invokes.
#[derive(Debug, Clone)]
@@ -117,7 +127,7 @@ pub trait SidecarSpawner: Send + Sync + 'static {
/// Recycle the connections for an active aria2 transfer without changing
/// its gid or releasing its queue permit. Production uses forcePause /
/// unpause; test spawners can leave this unsupported.
async fn refresh_uri(&self, _gid: &str) -> Result<(), String> {
async fn refresh_uri(&self, _gid: &str) -> Result<Aria2RefreshOutcome, String> {
Err("aria2 connection refresh is unavailable".to_string())
}
@@ -156,6 +166,11 @@ pub struct QueueManager<R: tauri::Runtime = tauri::Wry> {
/// download id -> spawn payload for aria2 transient-error re-addUri retries.
aria2_payloads: Mutex<HashMap<String, SpawnPayload>>,
/// The daemon-wide download cap currently applied to aria2. This mirrors
/// successful RPC changes so the poller can avoid treating an intentional
/// cap as a degraded connection pool.
aria2_global_speed_limit: Arc<StdMutex<Option<String>>>,
/// 0-based transient-error strike counter per aria2 download id.
aria2_retry_strikes: Mutex<HashMap<String, usize>>,
@@ -221,6 +236,7 @@ impl<R: tauri::Runtime> QueueManager<R> {
aria2_gids: Arc::new(std::sync::RwLock::new(HashMap::new())),
pending_completion: Arc::new(Mutex::new(HashMap::new())),
aria2_payloads: Mutex::new(HashMap::new()),
aria2_global_speed_limit: Arc::new(StdMutex::new(None)),
aria2_retry_strikes: Mutex::new(HashMap::new()),
aria2_retry_cancelled: Mutex::new(HashSet::new()),
aria2_retry_inflight: Mutex::new(HashMap::new()),
@@ -467,6 +483,32 @@ impl<R: tauri::Runtime> QueueManager<R> {
.and_then(|payload| payload.connections)
}
pub fn set_aria2_global_speed_limit(&self, limit: Option<String>) {
*self
.aria2_global_speed_limit
.lock()
.unwrap_or_else(|error| error.into_inner()) = limit;
}
pub async fn aria2_speed_limited(&self, id: &str) -> bool {
if self
.aria2_global_speed_limit
.lock()
.unwrap_or_else(|error| error.into_inner())
.is_some()
{
return true;
}
self.aria2_payloads
.lock()
.await
.get(id)
.and_then(|payload| payload.speed_limit.as_deref())
.and_then(crate::normalize_speed_limit_for_aria2)
.is_some()
}
/// Pop the next task, or None if empty.
pub async fn pop_front(&self) -> Option<QueuedTask> {
self.pending.lock().await.pop_front()
@@ -1080,22 +1122,27 @@ impl<R: tauri::Runtime> QueueManager<R> {
/// Recycle an active transfer's connections after the poller observes a
/// persistent connection-pool collapse or a true zero-progress stall.
/// The transfer keeps its gid, partial file, and queue permit.
pub async fn refresh_aria2_connections(&self, id: &str, gid: &str) -> Result<(), String> {
/// The observed epoch must still own the GID before the refresh can act.
pub async fn refresh_aria2_connections(
&self,
id: &str,
gid: &str,
observed_epoch: u64,
) -> Result<(), String> {
let _control_guard = self.acquire_aria2_control(id).await;
if self.aria2_gid_for_download(id).as_deref() != Some(gid)
|| !self.is_registered(id).await
|| self.is_aria2_retry_cancelled(id).await
|| !self.is_aria2_control_epoch_current(id, observed_epoch).await
{
return Ok(());
}
let epoch = self.current_aria2_control_epoch(id).await;
self.spawner.refresh_uri(gid).await?;
let outcome = self.spawner.refresh_uri(gid).await?;
let still_current = self.is_registered(id).await
&& !self.is_aria2_retry_cancelled(id).await
&& self.is_aria2_control_epoch_current(id, epoch).await
&& self.is_aria2_control_epoch_current(id, observed_epoch).await
&& self.aria2_gid_for_download(id).as_deref() == Some(gid);
if !still_current {
log::info!(
@@ -1103,6 +1150,31 @@ impl<R: tauri::Runtime> QueueManager<R> {
id,
gid
);
return Ok(());
}
match outcome {
Aria2RefreshOutcome::Resumed => {}
Aria2RefreshOutcome::Paused => {
self.next_aria2_control_epoch(id).await;
self.cancel_aria2_retries(id).await;
self.release_permit(id).await;
self.emit_state(id, DownloadStatus::Paused);
log::warn!(
"aria2 connection refresh [{}]: gid {} remained paused; released its queue permit",
id,
gid
);
}
Aria2RefreshOutcome::Complete => {
self.apply_completion_locked(id, PendingOutcome::Complete)
.await;
log::info!(
"aria2 connection refresh [{}]: gid {} completed during recovery",
id,
gid
);
}
}
Ok(())
}
@@ -1929,19 +2001,72 @@ impl SidecarSpawner for ProductionSpawner {
}
}
async fn refresh_uri(&self, gid: &str) -> Result<(), String> {
async fn refresh_uri(&self, gid: &str) -> Result<Aria2RefreshOutcome, String> {
let state = self.app_handle.state::<crate::AppState>();
let port = state.aria2_port.load(std::sync::atomic::Ordering::Relaxed);
let secret = &state.aria2_secret;
let paused = crate::rpc_call(port, secret, "aria2.forcePause", serde_json::json!([gid]))
.await
.map_err(|error| format!("failed to refresh aria2 gid {gid}: {error}"))?;
crate::ensure_aria2_gid_result("forcePause", gid, &paused)?;
let pause_error = match crate::rpc_call(
port,
secret,
"aria2.forcePause",
serde_json::json!([gid]),
)
.await
{
Ok(result) => crate::ensure_aria2_gid_result("forcePause", gid, &result)
.err()
.map(|error| error.to_string()),
Err(error) => Some(format!("failed to refresh aria2 gid {gid}: {error}")),
};
let resumed = crate::rpc_call(port, secret, "aria2.unpause", serde_json::json!([gid]))
.await
.map_err(|error| format!("failed to refresh aria2 gid {gid}: {error}"))?;
crate::ensure_aria2_gid_result("unpause", gid, &resumed)
if let Some(error) = pause_error {
match crate::aria2_download_status(port, secret, gid).await {
Ok(status) if status == "paused" => {
log::warn!(
"aria2 connection refresh: forcePause for gid {} failed after the daemon paused it; continuing with unpause",
gid
);
}
Ok(status) if status == "complete" => {
return Ok(Aria2RefreshOutcome::Complete);
}
Ok(status) => {
return Err(format!("{error}; aria2 gid {gid} is still {status}"));
}
Err(status_error) => {
return Err(format!(
"{error}; failed to verify aria2 gid {gid}: {status_error}"
));
}
}
}
let unpause_error = match crate::rpc_call(
port,
secret,
"aria2.unpause",
serde_json::json!([gid]),
)
.await
{
Ok(result) => crate::ensure_aria2_gid_result("unpause", gid, &result)
.err()
.map(|error| error.to_string()),
Err(error) => Some(format!("failed to refresh aria2 gid {gid}: {error}")),
};
let Some(error) = unpause_error else {
return Ok(Aria2RefreshOutcome::Resumed);
};
let status = crate::aria2_download_status(port, secret, gid).await?;
match status.as_str() {
"active" | "waiting" => Ok(Aria2RefreshOutcome::Resumed),
"paused" => Ok(Aria2RefreshOutcome::Paused),
"complete" => Ok(Aria2RefreshOutcome::Complete),
_ => Err(format!(
"{error}; aria2 gid {gid} reports terminal or unknown state {status}"
)),
}
}
async fn run_media(
@@ -1951,10 +2076,24 @@ impl SidecarSpawner for ProductionSpawner {
lifecycle_generation: u64,
) -> Result<(), String> {
let state = self.app_handle.state::<crate::AppState>();
// Serialize registration with pause/remove/detach. If the queue
// lifecycle was invalidated before this worker reached the
// coordinator, do not create a late media registration that could
// outlive the row's permit and ownership.
let control_guard = state.queue_manager.acquire_aria2_control(id).await;
if !state
.queue_manager
.is_registered_generation(id, lifecycle_generation)
.await
{
drop(control_guard);
return Err(crate::queue::MEDIA_RUN_CANCELLED.to_string());
}
let mut cancel_rx = state
.download_coordinator
.register_media(id.to_string(), lifecycle_generation)
.await?;
drop(control_guard);
let outcome = if *cancel_rx.borrow() {
Err(crate::queue::MEDIA_RUN_CANCELLED.to_string())
} else {
+3 -1
View File
@@ -441,6 +441,7 @@ fn default_settings() -> PersistedSettings {
max_automatic_retries: 3,
show_notifications: true,
play_completion_sound: false,
auto_add_clipboard_links: false,
app_font_size: AppFontSize::Standard,
list_row_density: ListRowDensity::Standard,
show_dock_badge: true,
@@ -696,8 +697,9 @@ mod tests {
}
#[test]
fn completion_sound_default_matches_the_frontend_default() {
fn opt_in_defaults_match_the_frontend_defaults() {
assert!(!default_settings().play_completion_sound);
assert!(!default_settings().auto_add_clipboard_links);
}
#[test]
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Firelink",
"version": "1.1.0",
"version": "1.1.1",
"identifier": "com.nimbold.firelink",
"build": {
"beforeDevCommand": "node scripts/stage-engines.js && npm run dev",
+99 -1
View File
@@ -1,5 +1,6 @@
use firelink_lib::queue::{
QueueManager, QueuedTask, SidecarSpawner, SpawnPayload, TaskKind, MEDIA_RUN_CANCELLED,
Aria2RefreshOutcome, QueueManager, QueuedTask, SidecarSpawner, SpawnPayload, TaskKind,
MEDIA_RUN_CANCELLED,
};
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;
@@ -25,6 +26,11 @@ struct FailFirstAria2Spawner {
fail_first: std::sync::atomic::AtomicBool,
}
struct RefreshOutcomeSpawner {
outcome: Aria2RefreshOutcome,
refresh_calls: AtomicUsize,
}
impl FailFirstAria2Spawner {
fn new() -> Self {
Self {
@@ -100,6 +106,31 @@ impl CountingSpawner {
}
}
#[async_trait::async_trait]
impl SidecarSpawner for RefreshOutcomeSpawner {
async fn add_uri(&self, _id: &str, _payload: &SpawnPayload) -> Result<String, String> {
unreachable!("refresh outcome tests do not spawn aria2")
}
async fn remove_uri(&self, _gid: &str) -> Result<(), String> {
unreachable!("refresh outcome tests do not remove aria2")
}
async fn refresh_uri(&self, _gid: &str) -> Result<Aria2RefreshOutcome, String> {
self.refresh_calls.fetch_add(1, Ordering::SeqCst);
Ok(self.outcome)
}
async fn run_media(
&self,
_id: &str,
_payload: &SpawnPayload,
_generation: u64,
) -> Result<(), String> {
unreachable!("refresh outcome tests do not run media")
}
}
#[async_trait::async_trait]
impl firelink_lib::queue::SidecarSpawner for CountingSpawner {
async fn add_uri(&self, _id: &str, _payload: &SpawnPayload) -> Result<String, String> {
@@ -648,6 +679,73 @@ async fn aria2_permit_survives_rpc_return() {
handle.abort();
}
#[tokio::test]
async fn failed_refresh_that_leaves_gid_paused_releases_permit_but_keeps_resume_mapping() {
let app = mock_builder()
.build(mock_context(noop_assets()))
.expect("mock app");
let spawner = Arc::new(RefreshOutcomeSpawner {
outcome: Aria2RefreshOutcome::Paused,
refresh_calls: AtomicUsize::new(0),
});
let manager = QueueManager::test_new(
app.handle().clone(),
1,
Arc::clone(&spawner) as Arc<dyn SidecarSpawner>,
);
manager.push(aria2_task("refresh-paused")).await.unwrap();
assert!(manager.ensure_aria2_permit("refresh-paused").await);
manager
.remember_gid("refresh-paused".to_string(), "gid-refresh-paused".to_string())
.await;
let epoch = manager.current_aria2_control_epoch("refresh-paused").await;
manager
.refresh_aria2_connections("refresh-paused", "gid-refresh-paused", epoch)
.await
.unwrap();
assert_eq!(spawner.refresh_calls.load(Ordering::SeqCst), 1);
assert_eq!(manager.available_permits(), 1);
assert!(!manager.has_active_permit("refresh-paused").await);
assert_eq!(
manager.aria2_gid_for_download("refresh-paused").as_deref(),
Some("gid-refresh-paused")
);
assert!(manager.is_registered("refresh-paused").await);
}
#[tokio::test]
async fn stale_refresh_observation_cannot_touch_a_newer_control_epoch() {
let app = mock_builder()
.build(mock_context(noop_assets()))
.expect("mock app");
let spawner = Arc::new(RefreshOutcomeSpawner {
outcome: Aria2RefreshOutcome::Resumed,
refresh_calls: AtomicUsize::new(0),
});
let manager = QueueManager::test_new(
app.handle().clone(),
1,
Arc::clone(&spawner) as Arc<dyn SidecarSpawner>,
);
manager.push(aria2_task("refresh-stale")).await.unwrap();
assert!(manager.ensure_aria2_permit("refresh-stale").await);
manager
.remember_gid("refresh-stale".to_string(), "gid-refresh-stale".to_string())
.await;
let stale_epoch = manager.current_aria2_control_epoch("refresh-stale").await;
manager.next_aria2_control_epoch("refresh-stale").await;
manager
.refresh_aria2_connections("refresh-stale", "gid-refresh-stale", stale_epoch)
.await
.unwrap();
assert_eq!(spawner.refresh_calls.load(Ordering::SeqCst), 0);
assert!(manager.has_active_permit("refresh-stale").await);
}
#[tokio::test]
async fn transient_aria2_error_reissues_after_backoff() {
use firelink_lib::queue::PendingOutcome;
+193 -27
View File
@@ -8,10 +8,11 @@ import SettingsView from "./components/SettingsView";
import { PropertiesModal } from "./components/PropertiesModal";
import { DeleteConfirmationModal } from "./components/DeleteConfirmationModal";
import { extractValidDownloadUrls } from './utils/url';
import { readClipboardDownloadUrls } from './utils/clipboard';
import { listenEvent as listen, invokeCommand as invoke } from "./ipc";
import { useDownloadStore, MAIN_QUEUE_ID } from './store/useDownloadStore';
import { useDownloadStore, MAIN_QUEUE_ID, type ExtensionDownloadRequest } from './store/useDownloadStore';
import { initDownloadListener } from './store/downloadStore';
import { useSettingsStore } from "./store/useSettingsStore";
import { subscribeToSettingsPersistenceErrors, useSettingsStore } from "./store/useSettingsStore";
import { isPermissionGranted, requestPermission, sendNotification } from '@tauri-apps/plugin-notification';
import SchedulerView from "./components/SchedulerView";
import SpeedLimiterView from "./components/SpeedLimiterView";
@@ -22,10 +23,15 @@ import { useToast } from "./contexts/ToastContext";
import { setLogStreamActive } from './utils/logger';
import { openUrl } from '@tauri-apps/plugin-opener';
import { getPlatformInfo, usePlatformInfo } from './utils/platform';
import { getKeychainStartupDecision } from './utils/keychainStartup';
import {
getKeychainAccessReady,
getKeychainConsentVersion,
getKeychainStartupDecision
} from './utils/keychainStartup';
import { getVersion } from '@tauri-apps/api/app';
import type { PostQueueAction } from './bindings/PostQueueAction';
import { PanelLeft } from 'lucide-react';
import { isTrustedFirelinkReleaseUrl } from './utils/releaseUrls';
let automaticUpdateCheckStarted = false;
const processingScheduleKeys = new Set<string>();
@@ -96,7 +102,7 @@ function App() {
const platform = usePlatformInfo();
const [filter, setFilter] = useState<SidebarFilter>('all');
const [coreReady, setCoreReady] = useState(false);
const [appVersion, setAppVersion] = useState('');
const [keychainConsentVersion, setKeychainConsentVersion] = useState('');
const [sidebarWidth, setSidebarWidth] = useState(() => {
const stored = Number(window.localStorage.getItem('firelink-sidebar-width'));
@@ -110,6 +116,7 @@ function App() {
const appFontSize = useSettingsStore(state => state.appFontSize);
const listRowDensity = useSettingsStore(state => state.listRowDensity);
const autoCheckUpdates = useSettingsStore(state => state.autoCheckUpdates);
const autoAddClipboardLinks = useSettingsStore(state => state.autoAddClipboardLinks);
const showNotifications = useSettingsStore(state => state.showNotifications);
const showDockBadge = useSettingsStore(state => state.showDockBadge);
const showMenuBarIcon = useSettingsStore(state => state.showMenuBarIcon);
@@ -125,10 +132,16 @@ function App() {
const schedulerActiveDownloadIds = useSettingsStore(state => state.schedulerActiveDownloadIds);
const pendingPostActionTimer = useRef<number | null>(null);
const startupResumeStarted = useRef(false);
const startupInputReady = useRef(false);
const frontendReadyUpdate = useRef<Promise<void>>(Promise.resolve());
const pendingStartupInputs = useRef<Array<
| { type: 'extension'; payload: ExtensionDownloadRequest }
| { type: 'deep-link'; payload: string }
>>([]);
const maxConcurrentDownloads = useSettingsStore(state => state.maxConcurrentDownloads);
const preventsSleepWhileDownloading = useSettingsStore(state => state.preventsSleepWhileDownloading);
const activeTransferCount = downloads.filter(download => isTransferActiveStatus(download.status)).length;
const { addToast } = useToast();
const { addToast, removeToast } = useToast();
const isMacUserAgent = navigator.userAgent.includes('Mac');
const usesCustomWindowControls = !isMacUserAgent && platform.os !== 'macos';
// Keep dialogs out of the titlebar area while platform detection is still
@@ -136,6 +149,14 @@ function App() {
// briefly rendering underneath native or custom window controls.
const hasWindowChrome = isMacUserAgent || ['macos', 'windows', 'linux', 'unknown'].includes(platform.os);
useEffect(() => subscribeToSettingsPersistenceErrors(() => {
addToast({
message: 'Could not save settings. Check storage permissions and try again.',
variant: 'error',
isActionable: true
});
}), [addToast]);
const acknowledgePairingTokenChange = () => {
invoke('acknowledge_pairing_token_change').catch(error => {
console.error('Failed to acknowledge pairing token migration notice:', error);
@@ -149,24 +170,33 @@ function App() {
}
}, []);
const queueFrontendReadyUpdate = useCallback((ready: boolean) => {
const update = frontendReadyUpdate.current
.catch(() => undefined)
.then(() => invoke('set_extension_frontend_ready', { ready }));
frontendReadyUpdate.current = update;
return update;
}, []);
const schedulePostQueueAction = useCallback((action: Exclude<PostQueueAction, 'none'>) => {
clearPendingPostActionTimer();
const actionLabel = action === 'shutdown' ? 'Shut down' : action === 'restart' ? 'Restart' : 'Sleep';
let timerId: number | null = null;
let toastId: string | null = null;
const cancel = () => {
if (timerId !== null) {
window.clearTimeout(timerId);
if (pendingPostActionTimer.current === timerId) {
pendingPostActionTimer.current = null;
}
timerId = null;
clearPendingPostActionTimer();
timerId = null;
if (toastId !== null) {
removeToast(toastId);
toastId = null;
}
};
addToast({
toastId = addToast({
variant: 'warning',
isActionable: true,
onDismiss: clearPendingPostActionTimer,
message: (
<div className="flex items-center gap-3">
<span>{actionLabel} in 10 seconds.</span>
@@ -182,6 +212,10 @@ function App() {
});
timerId = window.setTimeout(() => {
if (toastId !== null) {
removeToast(toastId);
toastId = null;
}
if (pendingPostActionTimer.current === timerId) {
pendingPostActionTimer.current = null;
}
@@ -208,7 +242,7 @@ function App() {
});
}, 10_000);
pendingPostActionTimer.current = timerId;
}, [addToast, clearPendingPostActionTimer]);
}, [addToast, clearPendingPostActionTimer, removeToast]);
const startSidebarResize = (event: React.PointerEvent<HTMLDivElement>) => {
event.preventDefault();
@@ -255,7 +289,7 @@ function App() {
let unlistenExtension: (() => void) | null = null;
let unlistenDeepLink: (() => void) | null = null;
const disposeListeners = () => {
invoke('set_extension_frontend_ready', { ready: false }).catch(() => {});
void queueFrontendReadyUpdate(false).catch(() => {});
unlistenTerminalState?.();
unlistenTerminalState = null;
unlistenExtension?.();
@@ -301,11 +335,24 @@ function App() {
}
});
unlistenExtension = await listen('extension-add-download', (event) => {
if (event.payload.request_id) {
void invoke('ack_extension_download', { requestId: event.payload.request_id }).catch(error => {
console.error('Failed to acknowledge browser extension download:', error);
});
}
if (!startupInputReady.current || useSettingsStore.getState().showKeychainModal) {
pendingStartupInputs.current.push({ type: 'extension', payload: event.payload });
return;
}
useDownloadStore.getState().handleExtensionDownload(event.payload).catch(error => {
console.error('Failed to handle browser extension download:', error);
});
});
unlistenDeepLink = await listen('deep-link-add-download', (event) => {
if (!startupInputReady.current || useSettingsStore.getState().showKeychainModal) {
pendingStartupInputs.current.push({ type: 'deep-link', payload: event.payload });
return;
}
useDownloadStore.getState().openAddModalWithUrls(event.payload);
});
@@ -336,13 +383,15 @@ function App() {
getPlatformInfo().catch(() => null)
]);
if (!active) return;
setAppVersion(currentAppVersion);
const currentKeychainConsentVersion = getKeychainConsentVersion(currentAppVersion);
setKeychainConsentVersion(currentKeychainConsentVersion);
try {
const settings = useSettingsStore.getState();
const isStartupActive = () => active;
const { deferKeychainHydration, showKeychainPrompt } = getKeychainStartupDecision({
portable: currentPlatform?.portable === true,
appVersion: currentAppVersion,
appVersion: currentKeychainConsentVersion,
approvedVersion: settings.keychainAccessVersion,
accessGranted: settings.keychainAccessGranted,
promptDismissed: settings.keychainPromptDismissed
@@ -354,15 +403,20 @@ function App() {
// This token is already owned by the backend and does not access
// the OS credential store. Render our explanation before any native
// Keychain/Credential Manager prompt can be user-triggered.
await settings.hydrateSessionPairingToken();
await settings.hydrateSessionPairingToken(isStartupActive);
if (!active) return;
if (showKeychainPrompt) {
settings.setShowKeychainModal(true);
}
} else {
changed = await settings.hydratePairingToken();
settings.setKeychainAccessReady(
currentPlatform?.portable !== true && useSettingsStore.getState().isPairingTokenPersistent
);
changed = await settings.hydratePairingToken(isStartupActive);
if (!active) return;
const currentSettings = useSettingsStore.getState();
settings.setKeychainAccessReady(getKeychainAccessReady({
portable: currentPlatform?.portable === true,
accessGranted: currentSettings.keychainAccessGranted,
persistent: currentSettings.isPairingTokenPersistent
}));
}
if (changed) {
addToast({
@@ -421,17 +475,45 @@ function App() {
if (!active) return;
setCoreReady(true);
invoke('set_extension_frontend_ready', { ready: true }).catch(error => {
console.error('Failed to activate browser extension integration:', error);
});
};
void initialize();
return () => {
active = false;
startupInputReady.current = false;
pendingStartupInputs.current = [];
cleanupListeners?.();
cleanupListeners = null;
};
}, [addToast]);
}, [addToast, queueFrontendReadyUpdate]);
useEffect(() => {
if (!coreReady) return;
// The backend must not emit extension/deep-link handoffs while the
// explanatory Keychain dialog is active. Deep links are buffered by the
// coordinator, and extension callers receive a retryable 503 instead.
void queueFrontendReadyUpdate(!showKeychainModal).catch(error => {
console.error('Failed to update browser extension readiness:', error);
});
}, [coreReady, queueFrontendReadyUpdate, showKeychainModal]);
useEffect(() => {
if (!coreReady || showKeychainModal) {
startupInputReady.current = false;
return;
}
if (startupInputReady.current) return;
startupInputReady.current = true;
const pendingInputs = pendingStartupInputs.current.splice(0);
for (const input of pendingInputs) {
if (input.type === 'extension') {
useDownloadStore.getState().handleExtensionDownload(input.payload).catch(error => {
console.error('Failed to handle queued browser extension download:', error);
});
} else {
useDownloadStore.getState().openAddModalWithUrls(input.payload);
}
}
}, [coreReady, showKeychainModal]);
useEffect(() => {
if (!coreReady || showKeychainModal || startupResumeStarted.current) return;
@@ -462,6 +544,9 @@ function App() {
invoke('check_for_updates')
.then(result => {
if (result.type !== 'UpdateAvailable') return;
if (!isTrustedFirelinkReleaseUrl(result.update.release_url)) {
throw new Error('The update check returned an untrusted release URL.');
}
addToast({
variant: 'info',
isActionable: true,
@@ -604,6 +689,7 @@ function App() {
}, [addToast, clearPendingPostActionTimer, coreReady]);
useEffect(() => {
if (!coreReady) return;
if (!schedulerRunning) return;
if (schedulerActiveDownloadIds.length === 0) return;
clearPendingPostActionTimer();
@@ -634,6 +720,7 @@ function App() {
}, [
addToast,
clearPendingPostActionTimer,
coreReady,
downloads,
schedulePostQueueAction,
schedulerRunning,
@@ -675,6 +762,7 @@ function App() {
useEffect(() => {
const handlePaste = (e: ClipboardEvent) => {
if (!coreReady || useSettingsStore.getState().showKeychainModal) return;
if (
e.target instanceof HTMLInputElement ||
e.target instanceof HTMLTextAreaElement ||
@@ -693,7 +781,85 @@ function App() {
};
window.addEventListener('paste', handlePaste);
return () => window.removeEventListener('paste', handlePaste);
}, []);
}, [coreReady, showKeychainModal]);
useEffect(() => {
if (!coreReady || showKeychainModal || !autoAddClipboardLinks) return;
let active = true;
let wasForeground = false;
let readInFlight = false;
let lastClipboardKey: string | null = null;
const isForeground = () =>
document.visibilityState === 'visible' &&
(typeof document.hasFocus !== 'function' || document.hasFocus());
const readClipboardOnForeground = async () => {
if (!active || readInFlight || !isForeground()) return;
const storeBeforeRead = useDownloadStore.getState();
const requestVersionBeforeRead = storeBeforeRead.pendingAddRequestVersion;
readInFlight = true;
try {
const clipboardUrls = await readClipboardDownloadUrls();
if (!active) return;
const currentSettings = useSettingsStore.getState();
const currentStore = useDownloadStore.getState();
// A user action or extension handoff won while the native clipboard
// read was pending. Let that newer Add-modal request win unchanged.
if (
!currentSettings.autoAddClipboardLinks ||
currentSettings.showKeychainModal ||
currentStore.pendingAddRequestVersion !== requestVersionBeforeRead
) {
return;
}
const clipboardKey = [...clipboardUrls].sort().join('\n');
if (clipboardKey === lastClipboardKey) return;
lastClipboardKey = clipboardKey;
if (clipboardUrls.length === 0) return;
const existingUrls = new Set(extractValidDownloadUrls(currentStore.pendingAddUrls));
const newUrls = clipboardUrls.filter(url => !existingUrls.has(url));
if (newUrls.length > 0) {
currentStore.openAddModalWithUrls(newUrls.join('\n'));
}
} catch (error) {
// Clipboard permissions are optional and this feature is explicitly
// opt-in, so a read failure should not interrupt normal app use.
console.warn('Automatic clipboard capture failed:', error);
} finally {
readInFlight = false;
}
};
const handleForegroundChange = () => {
const foreground = isForeground();
if (!foreground) {
wasForeground = false;
return;
}
if (!wasForeground) {
wasForeground = true;
void readClipboardOnForeground();
}
};
window.addEventListener('focus', handleForegroundChange);
window.addEventListener('blur', handleForegroundChange);
document.addEventListener('visibilitychange', handleForegroundChange);
handleForegroundChange();
return () => {
active = false;
window.removeEventListener('focus', handleForegroundChange);
window.removeEventListener('blur', handleForegroundChange);
document.removeEventListener('visibilitychange', handleForegroundChange);
};
}, [autoAddClipboardLinks, coreReady, showKeychainModal]);
useEffect(() => {
const root = window.document.documentElement;
@@ -796,7 +962,7 @@ function App() {
<AddDownloadsModal />
<PropertiesModal />
<DeleteConfirmationModal />
<KeychainPermissionModal appVersion={appVersion} />
<KeychainPermissionModal consentVersion={keychainConsentVersion} />
</div>
);
+1 -1
View File
@@ -2,4 +2,4 @@
import type { DownloadCategory } from "./DownloadCategory";
import type { DownloadStatus } from "./DownloadStatus";
export type DownloadItem = { id: string, url: string, fileName: string, status: DownloadStatus, fraction?: number, speed?: string, eta?: string, size?: string, category: DownloadCategory, dateAdded: string, resumable?: boolean, connections?: number, speedLimit?: string, username?: string, password?: string, headers?: string, checksum?: string, cookies?: string, mirrors?: string, destination?: string, isMedia?: boolean, mediaFormatSelector?: string, queueId?: string, queuePosition?: number, hasBeenDispatched?: boolean, lastError?: string, lastTry?: string, };
export type DownloadItem = { id: string, url: string, fileName: string, status: DownloadStatus, fraction?: number, speed?: string, eta?: string, size?: string, downloadedBytes?: number, totalBytes?: number, totalIsEstimate?: boolean, category: DownloadCategory, dateAdded: string, resumable?: boolean, connections?: number, speedLimit?: string, username?: string, password?: string, headers?: string, checksum?: string, cookies?: string, mirrors?: string, destination?: string, isMedia?: boolean, mediaFormatSelector?: string, queueId?: string, queuePosition?: number, hasBeenDispatched?: boolean, lastError?: string, lastTry?: string, };
+1 -1
View File
@@ -1,3 +1,3 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type DownloadProgressEvent = { id: string, fraction: number, speed: string, eta: string, size: string | null, size_is_final: boolean, };
export type DownloadProgressEvent = { id: string, fraction: number, speed: string, eta: string, size: string | null, size_is_final: boolean, downloaded_bytes?: number, total_bytes?: number, total_is_estimate?: boolean, };
+3
View File
@@ -0,0 +1,3 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type ExtensionCookieScope = { url: string, cookies: string, };
+2 -1
View File
@@ -1,3 +1,4 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { ExtensionCookieScope } from "./ExtensionCookieScope";
export type ExtensionDownload = { urls: Array<string>, referer: string | null, silent: boolean, filename: string | null, headers: string | null, cookies: string | null, media: boolean, };
export type ExtensionDownload = { request_id?: string, urls: Array<string>, referer: string | null, silent: boolean, filename: string | null, headers: string | null, cookies: string | null, cookie_scopes: Array<ExtensionCookieScope> | null, media: boolean, };
+3
View File
@@ -0,0 +1,3 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type MediaPlaylistEntry = { id: string, url: string, title: string, playlist_index: number | null, };
+4
View File
@@ -0,0 +1,4 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { MediaPlaylistEntry } from "./MediaPlaylistEntry";
export type MediaPlaylistMetadata = { title: string, playlist_id: string | null, entry_count: number | null, skipped_entries: number, truncated: boolean, entries: Array<MediaPlaylistEntry>, };
+1 -1
View File
@@ -8,4 +8,4 @@ import type { SettingsTab } from "./SettingsTab";
import type { SiteLogin } from "./SiteLogin";
import type { Theme } from "./Theme";
export type PersistedSettings = { theme: Theme, baseDownloadFolder: string, categorySubfoldersEnabled: boolean, categorySubfolders: { [key in string]: string }, categoryDirectoryOverrides: { [key in string]: string }, approvedDownloadRoots: Array<string>, maxConcurrentDownloads: number, globalSpeedLimit: string, speedLimitPresetValues: Array<number>, logsEnabled: boolean, isSidebarVisible: boolean, activeSettingsTab: SettingsTab, scheduler: SchedulerSettings, schedulerRunning: boolean, schedulerActiveDownloadIds: Array<string>, schedulerLastStartKey: string, schedulerLastStopKey: string, lastCustomSpeedLimitKiB: number, lastCustomSpeedLimitUnit: string, perServerConnections: number, maxAutomaticRetries: number, showNotifications: boolean, playCompletionSound: boolean, appFontSize: AppFontSize, listRowDensity: ListRowDensity, showDockBadge: boolean, showMenuBarIcon: boolean, proxyMode: ProxyMode, proxyHost: string, proxyPort: number, customUserAgent: string, askWhereToSaveEachFile: boolean, preventsSleepWhileDownloading: boolean, mediaCookieSource: MediaCookieSource, siteLogins: Array<SiteLogin>, autoCheckUpdates: boolean, keychainAccessGranted: boolean, };
export type PersistedSettings = { theme: Theme, baseDownloadFolder: string, categorySubfoldersEnabled: boolean, categorySubfolders: { [key in string]: string }, categoryDirectoryOverrides: { [key in string]: string }, approvedDownloadRoots: Array<string>, maxConcurrentDownloads: number, globalSpeedLimit: string, speedLimitPresetValues: Array<number>, logsEnabled: boolean, isSidebarVisible: boolean, activeSettingsTab: SettingsTab, scheduler: SchedulerSettings, schedulerRunning: boolean, schedulerActiveDownloadIds: Array<string>, schedulerLastStartKey: string, schedulerLastStopKey: string, lastCustomSpeedLimitKiB: number, lastCustomSpeedLimitUnit: string, perServerConnections: number, maxAutomaticRetries: number, showNotifications: boolean, playCompletionSound: boolean, autoAddClipboardLinks: boolean, appFontSize: AppFontSize, listRowDensity: ListRowDensity, showDockBadge: boolean, showMenuBarIcon: boolean, proxyMode: ProxyMode, proxyHost: string, proxyPort: number, customUserAgent: string, askWhereToSaveEachFile: boolean, preventsSleepWhileDownloading: boolean, mediaCookieSource: MediaCookieSource, siteLogins: Array<SiteLogin>, autoCheckUpdates: boolean, keychainAccessGranted: boolean, };
+263 -56
View File
@@ -7,12 +7,13 @@ import {
type PendingAddRequestContext
} from '../store/useDownloadStore';
import { useSettingsStore } from '../store/useSettingsStore';
import type { MediaPlaylistMetadata } from '../bindings/MediaPlaylistMetadata';
import { FolderPlus, Settings, Shield, RefreshCw, FileText, HardDrive, Database, Link, ArrowRight, Play, ChevronDown, ChevronRight, Video, Film, Music, type LucideIcon } from 'lucide-react';
import { open } from '@tauri-apps/plugin-dialog';
import { invokeCommand as invoke } from '../ipc';
import { DuplicateResolutionModal, DuplicateConflict } from './DuplicateResolutionModal';
import { canonicalizeDownloadFileName, categoryForFileName } from '../utils/downloads';
import { fetchMediaMetadataDeduped } from '../utils/mediaMetadata';
import { fetchMediaMetadataDeduped, fetchMediaPlaylistMetadataDeduped } from '../utils/mediaMetadata';
import {
resolveCategoryDestination,
resolveDownloadFilePath,
@@ -27,6 +28,7 @@ import {
mediaFileNameForSelectedFormat,
mediaFormatSelectorForRow,
metadataSummaryMessage,
playlistFilePrefix,
reconcileDownloadRows,
refreshFailedMetadataRows,
updateRowIfCurrent,
@@ -49,9 +51,48 @@ const normalizeComparableUrl = (rawUrl: string) => {
}
};
const urlsHaveDifferentHosts = (sourceUrl: string, targetUrl: string) => {
const urlsHaveDifferentOrigins = (sourceUrl: string, targetUrl: string) => {
try {
return new URL(sourceUrl).hostname.toLowerCase() !== new URL(targetUrl).hostname.toLowerCase();
const source = new URL(sourceUrl);
const target = new URL(targetUrl);
return source.protocol !== target.protocol
|| source.hostname.toLowerCase() !== target.hostname.toLowerCase()
|| source.port !== target.port;
} catch {
return false;
}
};
const cookieScopeForUrl = (context: PendingAddRequestContext | undefined, targetUrl: string) => {
if (!context?.cookieScopes?.length) return '';
try {
const target = new URL(targetUrl);
return context.cookieScopes.find(scope => {
try {
const scopeUrl = new URL(scope.url);
const sameGoogleusercontentSite = scopeUrl.protocol === target.protocol
&& scopeUrl.port === target.port
&& scopeUrl.hostname.toLowerCase() === 'googleusercontent.com'
&& target.hostname.toLowerCase().endsWith('.googleusercontent.com');
return sameGoogleusercontentSite || (scopeUrl.protocol === target.protocol
&& scopeUrl.hostname.toLowerCase() === target.hostname.toLowerCase()
&& scopeUrl.port === target.port);
} catch {
return false;
}
})?.cookies.trim() || '';
} catch {
return '';
}
};
const isGoogleAuthenticatedCaptureUrl = (rawUrl: string) => {
try {
const hostname = new URL(rawUrl).hostname.toLowerCase();
return hostname === 'mail.google.com'
|| hostname === 'accounts.google.com'
|| hostname === 'googleusercontent.com'
|| hostname.endsWith('.googleusercontent.com');
} catch {
return false;
}
@@ -90,13 +131,17 @@ export const AddDownloadsModal = () => {
baseDownloadFolder,
perServerConnections,
keychainAccessReady,
keychainPromptDismissed
keychainPromptDismissed,
showKeychainModal
} = useSettingsStore();
const [urls, setUrls] = useState('');
const [selectedItemIndex, setSelectedItemIndex] = useState<number | null>(null);
const [parsedItems, setParsedItems] = useState<AddDownloadDraftRow[]>([]);
const metadataRequestsRef = useRef(new Set<string>());
const playlistRequestsRef = useRef(new Set<string>());
const latestPlaylistRequestRef = useRef(new Map<string, string>());
const [playlistExpansions, setPlaylistExpansions] = useState<Record<string, MediaPlaylistMetadata>>({});
const [conflicts, setConflicts] = useState<DuplicateConflict[]>([]);
const [showingDuplicates, setShowingDuplicates] = useState(false);
@@ -146,33 +191,40 @@ export const AddDownloadsModal = () => {
const cookiesForRow = (sourceUrl: string, targetUrl = sourceUrl) => {
if (cookiesManuallyEditedRef.current) return cookies.trim();
const context = requestContextForUrl(sourceUrl);
if (context && context.cookies && urlsHaveDifferentHosts(sourceUrl, targetUrl)) {
return '';
}
const scopedCookies = cookieScopeForUrl(context, targetUrl);
if (scopedCookies) return scopedCookies;
if (context && urlsHaveDifferentOrigins(sourceUrl, targetUrl)) return '';
if (context) return context.cookies.trim();
return hasExtensionRequestContext ? '' : cookies.trim();
};
const shouldDeferCookiesForRow = (sourceUrl: string) =>
!cookiesManuallyEditedRef.current && Boolean(requestContextForUrl(sourceUrl));
!cookiesManuallyEditedRef.current
&& Boolean(requestContextForUrl(sourceUrl))
&& !isGoogleAuthenticatedCaptureUrl(sourceUrl);
const suggestedFilenameForRow = (sourceUrl: string) => {
const context = requestContextForUrl(sourceUrl);
if (context?.filename) return context.filename;
return hasExtensionRequestContext ? '' : pendingAddFilename;
};
const requestContextUrlForRow = (row: AddDownloadDraftRow) =>
row.playlistSourceUrl || row.sourceUrl;
const closeModalFromDismissAction = useCallback(() => {
if (isSubmitting || isSubmittingRef.current) return;
if (isSubmitting || isSubmittingRef.current || showKeychainModal) return;
const hasPendingInput = Boolean(
urls.trim() || pendingAddUrls.trim() || parsedItems.length || headers.trim() || cookies.trim()
);
if (hasPendingInput && !window.confirm('Discard this download setup?')) return;
toggleAddModal(false);
}, [cookies, headers, isSubmitting, parsedItems.length, pendingAddUrls, toggleAddModal, urls]);
}, [cookies, headers, isSubmitting, parsedItems.length, pendingAddUrls, showKeychainModal, toggleAddModal, urls]);
useEffect(() => {
if (!isAddModalOpen) {
modalSessionRef.current = false;
setUrls('');
setPlaylistExpansions({});
playlistRequestsRef.current.clear();
latestPlaylistRequestRef.current.clear();
return;
}
@@ -189,6 +241,10 @@ export const AddDownloadsModal = () => {
setIsSaveLocationManual(false);
setUrls(initialUrls);
setParsedItems([]);
setPlaylistExpansions({});
metadataRequestsRef.current.clear();
playlistRequestsRef.current.clear();
latestPlaylistRequestRef.current.clear();
setSelectedItemIndex(null);
setPendingUseSharedDestination(false);
setPendingDestinationOverrides({});
@@ -230,6 +286,11 @@ export const AddDownloadsModal = () => {
|| observedRequestVersionRef.current === pendingAddRequestVersion) return;
const observedVersion = observedRequestVersionRef.current;
observedRequestVersionRef.current = pendingAddRequestVersion;
// Playlist membership and entry access can depend on the handoff's
// browser context. Re-discover playlists when a newer extension context
// arrives instead of reusing entries extracted under stale cookies.
setPlaylistExpansions({});
latestPlaylistRequestRef.current.clear();
setUrls(current => appendRequestUrlsAfterVersion(
current,
pendingAddRequestContexts,
@@ -252,6 +313,7 @@ export const AddDownloadsModal = () => {
if (!isAddModalOpen) return;
const closeOnEscape = (event: KeyboardEvent) => {
if (event.key !== 'Escape') return;
if (showKeychainModal) return;
if (showingDuplicates) {
setShowingDuplicates(false);
} else if (isQueueMenuOpen) {
@@ -262,7 +324,7 @@ export const AddDownloadsModal = () => {
};
window.addEventListener('keydown', closeOnEscape);
return () => window.removeEventListener('keydown', closeOnEscape);
}, [closeModalFromDismissAction, isAddModalOpen, isQueueMenuOpen, showingDuplicates]);
}, [closeModalFromDismissAction, isAddModalOpen, isQueueMenuOpen, showKeychainModal, showingDuplicates]);
useEffect(() => {
const requestId = ++freeSpaceRequestRef.current;
@@ -282,6 +344,21 @@ export const AddDownloadsModal = () => {
}, [saveLocation, isAddModalOpen]);
useEffect(() => {
const activeUrls = new Set(
urls.split('\n').map(url => url.trim()).filter(Boolean).map(normalizeComparableUrl)
);
for (const sourceUrl of latestPlaylistRequestRef.current.keys()) {
if (!activeUrls.has(sourceUrl)) {
latestPlaylistRequestRef.current.delete(sourceUrl);
}
}
setPlaylistExpansions(current => {
const retained = Object.fromEntries(
Object.entries(current).filter(([sourceUrl]) => activeUrls.has(sourceUrl))
);
return Object.keys(retained).length === Object.keys(current).length ? current : retained;
});
const forcedMediaUrls = new Set(pendingAddMediaUrls.map(url => {
try {
return new URL(url).href;
@@ -298,37 +375,62 @@ export const AddDownloadsModal = () => {
Object.entries(pendingAddRequestContexts)
.map(([url, context]) => [url, context.version])
);
setParsedItems(current =>
reconcileDownloadRows(
setParsedItems(current => {
const selectedBySourceUrl = Object.fromEntries(
current.map(row => [row.sourceUrl, row.selected !== false])
);
for (const row of current) {
if (row.playlistSourceUrl && !(row.playlistSourceUrl in selectedBySourceUrl)) {
selectedBySourceUrl[row.playlistSourceUrl] = row.selected !== false;
}
}
return reconcileDownloadRows(
urls,
current,
hasExtensionRequestContext ? undefined : pendingAddFilename || undefined,
forcedMediaUrls,
undefined,
requestFilenames,
requestContextVersions
)
);
requestContextVersions,
playlistExpansions,
selectedBySourceUrl
);
});
}, [
urls,
pendingAddFilename,
pendingAddMediaUrls,
pendingAddRequestContexts,
hasExtensionRequestContext
hasExtensionRequestContext,
playlistExpansions
]);
useEffect(() => {
const maxConcurrentMetadataRequests = 4;
for (const row of parsedItems) {
if (row.status !== 'loading') continue;
if (row.status !== 'loading' || row.selected === false) continue;
const requestKey = `${row.id}:${row.generation}`;
if (metadataRequestsRef.current.has(requestKey)) continue;
metadataRequestsRef.current.add(requestKey);
const requestSet = row.isPlaylist ? playlistRequestsRef.current : metadataRequestsRef.current;
if (requestSet.has(requestKey)) continue;
if (metadataRequestsRef.current.size + playlistRequestsRef.current.size >= maxConcurrentMetadataRequests) {
break;
}
requestSet.add(requestKey);
if (row.isPlaylist) {
// Invalidate stale playlist requests before any asynchronous settings,
// keychain, or network work can yield. Otherwise an old request can
// become the latest request again after the URL or browser context
// has already changed.
latestPlaylistRequestRef.current.set(row.sourceUrl, requestKey);
}
void (async () => {
try {
const settingsStore = useSettingsStore.getState();
const proxy = await getProxyArgs(settingsStore);
const login = getSiteLogin(row.sourceUrl, settingsStore);
const contextUrl = requestContextUrlForRow(row);
const requestContext = requestContextForUrl(contextUrl);
if (login && !useAuth && !keychainAccessReady && !keychainPromptDismissed) {
settingsStore.setShowKeychainModal(true);
return;
@@ -345,8 +447,8 @@ export const AddDownloadsModal = () => {
}
}
const rowHeaders = headersForRow(row.sourceUrl);
const rowCookies = cookiesForRow(row.sourceUrl);
const rowHeaders = headersForRow(contextUrl);
const rowCookies = cookiesForRow(contextUrl, row.sourceUrl);
const mediaMetadataArgs = {
url: row.sourceUrl,
cookieBrowser: browserArg,
@@ -357,6 +459,24 @@ export const AddDownloadsModal = () => {
cookies: rowCookies || null,
proxy
};
if (row.isPlaylist) {
if (playlistExpansions[row.sourceUrl]) return;
const playlistData = await fetchMediaPlaylistMetadataDeduped({
...mediaMetadataArgs,
url: contextUrl
});
if (latestPlaylistRequestRef.current.get(row.sourceUrl) !== requestKey) return;
if (!playlistData.entries.length) {
throw new Error('Playlist contains no downloadable entries');
}
setPlaylistExpansions(current => ({
...current,
[row.sourceUrl]: playlistData
}));
return;
}
const mediaData = await fetchMediaMetadataDeduped(mediaMetadataArgs);
if (mediaData && mediaData.formats.length > 0) {
const mappedFormats = mediaData.formats.map(f => {
@@ -385,12 +505,15 @@ export const AddDownloadsModal = () => {
currentRow => ({
...currentRow,
downloadUrl: row.sourceUrl,
file: canonicalizeDownloadFileName(`${mediaData.title}.${mediaData.formats[0].ext}`),
file: canonicalizeDownloadFileName(
`${playlistFilePrefix(row.playlistIndex, row.playlistCount)}${mediaData.title}.${mediaData.formats[0].ext}`
),
size: mappedFormats[0].bytes ? mappedFormats[0].detail : undefined,
sizeBytes: mappedFormats[0].bytes || undefined,
status: 'ready',
formats: mappedFormats,
selectedFormat: 0
selectedFormat: 0,
playlistError: undefined
})
));
} else {
@@ -410,8 +533,9 @@ export const AddDownloadsModal = () => {
userAgent: settingsStore.customUserAgent.trim() || null,
username: useAuth ? username.trim() || null : login?.username || null,
password: useAuth ? password || null : keychainPassword,
headers: headersForRow(row.sourceUrl) || null,
cookies: cookiesForRow(row.sourceUrl) || null,
headers: headersForRow(contextUrl) || null,
cookies: cookiesForRow(contextUrl, row.sourceUrl) || null,
cookieScopes: requestContext?.cookieScopes || null,
proxy,
deferCookies: shouldDeferCookiesForRow(row.sourceUrl)
});
@@ -425,19 +549,30 @@ export const AddDownloadsModal = () => {
...currentRow,
downloadUrl: nextDownloadUrl || currentRow.downloadUrl,
file: canonicalizeDownloadFileName(
current.length === 1 && suggestedFilenameForRow(row.sourceUrl)
? suggestedFilenameForRow(row.sourceUrl)
current.length === 1 && suggestedFilenameForRow(contextUrl)
? suggestedFilenameForRow(contextUrl)
: meta.filename
),
size: meta.size_bytes ? meta.size : undefined,
sizeBytes: meta.size_bytes || undefined,
status: 'ready',
resumable: meta.resumable
resumable: meta.resumable,
metadataBlockedReason: undefined
})
));
}
} catch (e) {
console.error("Meta fetch failed", e);
const errorMessage = e instanceof Error ? e.message : String(e);
const metadataBlockedReason = [
'SSRF blocked: Invalid URL',
'SSRF blocked: No host',
'SSRF blocked: DNS resolution failed',
'SSRF blocked: No DNS records',
'SSRF blocked: Private/local IP not allowed'
].some(prefix => errorMessage.startsWith(prefix))
? 'unsafe-url' as const
: undefined;
setParsedItems(current => updateRowIfCurrent(
current,
row.id,
@@ -450,15 +585,27 @@ export const AddDownloadsModal = () => {
sizeBytes: undefined,
status: 'metadata-error',
formats: undefined,
selectedFormat: undefined
selectedFormat: undefined,
metadataBlockedReason,
playlistError: row.isPlaylist
? errorMessage
: undefined
})
));
} finally {
metadataRequestsRef.current.delete(requestKey);
requestSet.delete(requestKey);
}
})();
}
}, [keychainAccessReady, keychainPromptDismissed, parsedItems, pendingAddFilename, pendingAddMediaUrls, useAuth]);
}, [
keychainAccessReady,
keychainPromptDismissed,
parsedItems,
pendingAddFilename,
pendingAddMediaUrls,
playlistExpansions,
useAuth
]);
useEffect(() => {
if (parsedItems.length === 0) {
@@ -522,6 +669,7 @@ export const AddDownloadsModal = () => {
const platform = await getPlatformInfo().catch(() => ({ os: 'unknown' }));
if (settings.askWhereToSaveEachFile && parsedItems.length > 0) {
for (const [index, item] of parsedItems.entries()) {
if (item.selected === false) continue;
try {
const suggestedLocation = isSaveLocationManual
? finalLocation
@@ -556,6 +704,7 @@ export const AddDownloadsModal = () => {
for (let i = 0; i < parsedItems.length; i++) {
const item = parsedItems[i];
if (item.selected === false) continue;
let finalFile = item.isMedia
? mediaFileNameForSelectedFormat(item.file, item)
: canonicalizeDownloadFileName(item.file);
@@ -655,7 +804,9 @@ export const AddDownloadsModal = () => {
resolutions?: { id: string, resolution: 'rename' | 'replace' | 'skip' }[],
destinationOverrides: Record<number, string> = {}
) => {
let itemsToAdd: Array<AddDownloadDraftRow | null> = [...parsedItems];
let itemsToAdd: Array<AddDownloadDraftRow | null> = parsedItems.map(item =>
item.selected === false ? null : item
);
const platform = await getPlatformInfo().catch(() => ({ os: 'unknown' }));
if (resolutions) {
@@ -792,6 +943,7 @@ export const AddDownloadsModal = () => {
? mediaFileNameForSelectedFormat(item.file, item)
: canonicalizeDownloadFileName(item.file);
let formatSelector = mediaFormatSelectorForRow(item);
const contextUrl = requestContextUrlForRow(item);
const category = categoryForFileName(finalFile);
const added = await addDownload({
@@ -801,14 +953,14 @@ export const AddDownloadsModal = () => {
category,
dateAdded: new Date().toISOString(),
connections: Number(connections),
speedLimit: speedLimitEnabled ? `${speedLimit}K` : '0',
speedLimit: speedLimitEnabled ? `${speedLimit}K` : undefined,
username: useAuth ? username.trim() : undefined,
password: useAuth ? password.trim() : undefined,
headers: headersForRow(item.sourceUrl) || undefined,
headers: headersForRow(contextUrl) || undefined,
checksum: checksumEnabled && checksumValue.trim()
? `${checksumAlgo}=${checksumValue.trim()}`
: undefined,
cookies: cookiesForRow(item.sourceUrl, item.downloadUrl) || undefined,
cookies: cookiesForRow(contextUrl, item.downloadUrl) || undefined,
mirrors: mirrors.trim() || undefined,
destination: useSharedDestination
? finalLocation
@@ -816,7 +968,8 @@ export const AddDownloadsModal = () => {
isMedia: item.isMedia,
resumable: item.resumable,
mediaFormatSelector: formatSelector,
size: item.size || (item.sizeBytes ? formatBytes(item.sizeBytes) : undefined)
size: item.size || (item.sizeBytes ? formatBytes(item.sizeBytes) : undefined),
sizeBytes: item.sizeBytes
}, action);
if (!added) {
throw new Error('Backend rejected download start.');
@@ -857,6 +1010,19 @@ export const AddDownloadsModal = () => {
</div>
);
const toggleRowSelection = (index: number) => {
setParsedItems(items => items.map((item, itemIndex) =>
itemIndex === index ? { ...item, selected: item.selected === false } : item
));
};
const toggleAllRows = () => {
setParsedItems(items => {
const shouldSelect = items.some(item => item.selected === false);
return items.map(item => ({ ...item, selected: shouldSelect }));
});
};
const selectMediaFormat = (index: number) => {
if (selectedItemIndex === null) return;
const selectedItem = parsedItems[selectedItemIndex];
@@ -879,8 +1045,10 @@ export const AddDownloadsModal = () => {
));
};
const requiredBytes = parsedItems.reduce((acc, item) => acc + (item.sizeBytes || 0), 0);
const hasApproximateSize = parsedItems.some(item =>
const selectedItems = parsedItems.filter(item => item.selected !== false);
const allRowsSelected = parsedItems.length > 0 && selectedItems.length === parsedItems.length;
const requiredBytes = selectedItems.reduce((acc, item) => acc + (item.sizeBytes || 0), 0);
const hasApproximateSize = selectedItems.some(item =>
item.formats?.[item.selectedFormat ?? -1]?.isApproximate
);
const requiredStr = requiredBytes > 0
@@ -889,11 +1057,19 @@ export const AddDownloadsModal = () => {
: `${(requiredBytes / 1024 / 1024 / 1024).toFixed(2)} GB`}`
: 'Unknown';
const canSubmit = canSubmitMetadataRows(parsedItems);
const failedMetadataCount = parsedItems.filter(item => item.status === 'metadata-error').length;
const failedMediaMetadataCount = parsedItems.filter(
const failedMetadataCount = selectedItems.filter(item => item.status === 'metadata-error').length;
const failedMediaMetadataCount = selectedItems.filter(
item => item.status === 'metadata-error' && item.isMedia
).length;
const fallbackMetadataCount = failedMetadataCount - failedMediaMetadataCount;
const blockedMetadataCount = selectedItems.filter(
item => item.metadataBlockedReason === 'unsafe-url'
).length;
const fallbackMetadataCount = failedMetadataCount - failedMediaMetadataCount - blockedMetadataCount;
const activePlaylistUrls = new Set(
urls.split('\n').map(url => url.trim()).filter(Boolean).map(normalizeComparableUrl)
);
const playlistSummaries = Object.entries(playlistExpansions)
.filter(([sourceUrl]) => activePlaylistUrls.has(sourceUrl));
return (
<>
@@ -938,11 +1114,11 @@ export const AddDownloadsModal = () => {
<div className="app-modal add-download-modal flex flex-col overflow-hidden text-sm">
{/* Main Content Split */}
<div className="flex flex-1 overflow-hidden">
<div className="flex flex-1 min-h-0 overflow-hidden">
{/* Left Column: URLs and Preview */}
<div className="add-download-left w-[55%] border-r border-border-modal flex flex-col">
<div className="add-download-pane p-5 flex-1 flex flex-col gap-5">
<div className="add-download-pane p-5 flex-1 min-h-0 min-w-0 flex flex-col gap-5">
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between">
@@ -957,9 +1133,19 @@ export const AddDownloadsModal = () => {
value={urls}
onChange={(e) => setUrls(e.target.value)}
/>
{playlistSummaries.map(([sourceUrl, playlist]) => {
const total = playlist.entry_count || playlist.entries.length;
return (
<p key={sourceUrl} className="px-1 text-[11px] text-purple-500 dark:text-purple-400">
Playlist {playlist.title}: {playlist.entries.length}{total > playlist.entries.length ? ` of ${total}` : ''} entries loaded
{playlist.truncated ? ' (safe entry limit reached)' : ''}
{playlist.skipped_entries > 0 ? `; ${playlist.skipped_entries} skipped, unavailable, duplicated, or outside the safe limit` : ''}.
</p>
);
})}
<div className="flex justify-between items-center px-1">
<span className="text-[11px] text-text-muted font-medium">
{parsedItems.filter(item => item.status === 'ready').length} ready, {fallbackMetadataCount} fallback, {failedMediaMetadataCount} media retry
{selectedItems.filter(item => item.status === 'ready').length} selected ready, {fallbackMetadataCount} fallback, {failedMediaMetadataCount} media retry, {blockedMetadataCount} blocked
</span>
<button
type="button"
@@ -969,28 +1155,36 @@ export const AddDownloadsModal = () => {
>
<RefreshCw size={12} /> Refresh Metadata
</button>
</div>
<button
type="button"
onClick={toggleAllRows}
disabled={parsedItems.length === 0}
className="add-download-link-button ml-3 text-[11px] font-medium"
>
{allRowsSelected ? 'Clear selection' : 'Select all'}
</button>
</div>
</div>
<div className="grid grid-cols-4 gap-3">
<SummaryBox title="Files" value={parsedItems.length} icon={FileText} color="text-blue-500" />
<SummaryBox title="Files" value={selectedItems.length === parsedItems.length ? parsedItems.length : `${selectedItems.length}/${parsedItems.length}`} icon={FileText} color="text-blue-500" />
<SummaryBox title="Required" value={requiredStr} icon={Database} color="text-orange-500" />
<SummaryBox title="Free" value={freeSpace} icon={HardDrive} color="text-green-500" />
<SummaryBox title="Unknown" value={parsedItems.filter(i => !i.sizeBytes).length} icon={FileText} color="text-purple-500" />
<SummaryBox title="Unknown" value={selectedItems.filter(i => !i.sizeBytes).length} icon={FileText} color="text-purple-500" />
</div>
<div className="flex flex-col gap-2 flex-1 overflow-hidden">
<div className="flex flex-col gap-2 flex-1 min-h-0 min-w-0 overflow-hidden">
<div className="add-download-section-title flex items-center gap-2">
<ArrowRight size={16} className="text-blue-500" />
Preview
</div>
<div className="add-download-preview flex-1 overflow-hidden flex flex-col">
<div className="add-download-preview flex-1 min-h-0 min-w-0 overflow-hidden flex flex-col">
<div className="add-download-preview-header px-3 py-2 flex text-[11px] font-semibold text-text-muted uppercase tracking-wider">
<div className="flex-[2]">File</div>
<div className="flex-1">Size</div>
<div className="flex-[1.5]">Status</div>
</div>
<div className="flex-1 overflow-y-auto p-2 space-y-1" role="listbox" aria-label="Download preview">
<div className="flex-1 min-h-0 min-w-0 overflow-y-auto p-2 space-y-1" role="listbox" aria-label="Download preview">
{parsedItems.length === 0 ? (
<div className="add-download-empty h-full flex items-center justify-center text-text-muted text-xs">
No links added yet.
@@ -1014,18 +1208,26 @@ export const AddDownloadsModal = () => {
? 'is-selected border'
: 'border border-transparent'
}`}
>
<div className="flex items-center w-full">
>
<div className="flex items-center w-full">
<input
type="checkbox"
checked={item.selected !== false}
onChange={() => toggleRowSelection(i)}
onClick={event => event.stopPropagation()}
aria-label={`Select ${item.file}`}
className="mr-2 shrink-0 accent-purple-500"
/>
<div className="flex-[2] text-text-primary font-medium truncate pr-2" title={item.file}>{item.file}</div>
<div className={`flex-1 font-mono ${item.status === 'loading' ? 'text-text-muted/50' : 'text-text-muted'}`}>{item.size || 'Unknown'}</div>
<div className={`flex-[1.5] font-medium ${item.status === 'metadata-error' || item.status === 'invalid' ? 'text-red-500' : item.status === 'loading' ? 'text-orange-400' : 'text-blue-500'}`}>
{item.status === 'loading' ? (
<div className="flex items-center gap-1.5">
<RefreshCw size={12} className="animate-spin" /> Fetching...
<RefreshCw size={12} className="animate-spin" /> {item.isPlaylist ? 'Fetching playlist...' : 'Fetching...'}
</div>
) : (
item.status === 'metadata-error'
? item.isMedia ? 'Metadata failed' : 'Fallback'
? item.metadataBlockedReason === 'unsafe-url' ? 'Unsafe URL' : item.isPlaylist ? 'Playlist failed' : item.isMedia ? 'Metadata failed' : 'Fallback'
: item.status === 'invalid'
? 'Invalid'
: 'Ready'
@@ -1054,6 +1256,11 @@ export const AddDownloadsModal = () => {
</div>
<div className="add-download-section-title flex items-center gap-2 mb-3 relative z-10">
<Video size={16} className="text-purple-500" /> Media Format
{parsedItems[selectedItemIndex].playlistSourceUrl && (
<span className="text-[10px] font-normal text-text-muted">
Playlist item {parsedItems[selectedItemIndex].playlistIndex || '?'}
</span>
)}
</div>
{parsedItems[selectedItemIndex].status === 'loading' ? (
@@ -1258,7 +1465,7 @@ export const AddDownloadsModal = () => {
{metadataSummaryMessage(parsedItems)}
</div>
<div className="flex gap-2.5">
<button onClick={closeModalFromDismissAction} disabled={isSubmitting} className="add-download-button add-download-button-cancel px-4 text-xs">
<button onClick={closeModalFromDismissAction} disabled={isSubmitting || showKeychainModal} className="add-download-button add-download-button-secondary px-4 text-xs">
Cancel
</button>
<div ref={actionMenuRef} className="relative flex gap-2.5">
+1 -1
View File
@@ -90,7 +90,7 @@ export const DeleteConfirmationModal: React.FC = () => {
<button
onClick={handleCancel}
disabled={isRemoving}
className="px-4 py-2 rounded-lg text-sm font-medium transition-colors text-text-secondary hover:bg-item-hover hover:text-text-primary disabled:opacity-50"
className="app-button px-4 py-2 rounded-lg text-sm font-medium disabled:opacity-50"
>
Cancel
</button>
+51 -37
View File
@@ -1,54 +1,44 @@
import React from 'react';
import { useShallow } from 'zustand/react/shallow';
import { useDownloadStore } from '../store/useDownloadStore';
import { useDownloadProgressStore } from '../store/downloadProgressStore';
import { Play, Pause, MoreVertical, Clock, ArrowUp, ArrowDown } from 'lucide-react';
import type { DownloadItem as DownloadItemType } from '../bindings/DownloadItem';
import { canPauseDownload, canStartDownload, startActionLabel } from '../utils/downloadActions';
import { isActiveDownloadStatus } from '../utils/downloads';
import {
downloadProgressColorClass,
formatDownloadTotal,
resolveDownloadSizeDisplay
} from '../utils/downloadProgress';
interface DownloadItemProps {
downloadId: string;
download: DownloadItemType;
index: number;
queueIndex: number;
queueLength: number;
tableGridTemplate: string;
setContextMenu: (menu: { x: number; y: number; id: string }) => void;
handlePause: (id: string, skipConfirm?: boolean) => void;
handleResume: (item: DownloadItemType) => void;
getCategoryIcon: (category: string) => React.ReactNode;
selectedIds: Set<string>;
isSelected: boolean;
onMoveInQueue: (id: string, direction: 'up' | 'down') => void;
onClick: (e: React.MouseEvent, item: DownloadItemType) => void;
}
export const DownloadItem = React.memo<DownloadItemProps>(({
downloadId,
download,
index,
queueIndex,
queueLength,
tableGridTemplate,
setContextMenu,
handlePause,
handleResume,
getCategoryIcon,
selectedIds,
isSelected,
onMoveInQueue,
onClick,
}) => {
const download = useDownloadStore(state => state.downloads.find(d => d.id === downloadId));
const queueItems = useDownloadStore(useShallow(state => {
const item = state.downloads.find(candidate => candidate.id === downloadId);
if (!item) return [];
const queueId = item.queueId;
return state.downloads
.filter(candidate =>
candidate.queueId === queueId &&
candidate.status !== 'completed' &&
!(isActiveDownloadStatus(candidate.status) && candidate.status !== 'queued')
)
.sort((left, right) => (left.queuePosition ?? 0) - (right.queuePosition ?? 0))
.map(candidate => candidate.id);
}));
const moveInQueue = useDownloadStore(state => state.moveInQueue);
const liveProgress = useDownloadProgressStore(state => state.progressMap[downloadId]);
const queueIndex = queueItems.indexOf(downloadId);
if (!download) return null;
const liveProgress = useDownloadProgressStore(state => state.progressMap[download.id]);
const displayFraction = download.status === 'downloading'
? liveProgress?.fraction ?? download.fraction ?? 0
@@ -64,10 +54,21 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
: download.status === 'processing'
? 'Muxing...'
: '-';
const sizeDisplay = resolveDownloadSizeDisplay({
downloadedBytes: liveProgress?.downloaded_bytes ?? download.downloadedBytes,
totalBytes: liveProgress?.total_bytes ?? download.totalBytes,
totalIsEstimate: liveProgress?.total_is_estimate ?? download.totalIsEstimate,
fallbackSize: download.size
});
const hasDownloadedAmount = download.status !== 'completed' &&
Boolean(sizeDisplay.downloaded && sizeDisplay.total);
const completedSizeLabel = download.status === 'completed'
? formatDownloadTotal(sizeDisplay)
: sizeDisplay.fallback;
return (
<div
className={`download-row group cursor-default relative ${index % 2 !== 0 ? 'striped' : ''} ${selectedIds.has(downloadId) ? 'is-selected' : ''}`}
className={`download-row group cursor-default relative ${index % 2 !== 0 ? 'striped' : ''} ${isSelected ? 'is-selected' : ''}`}
style={{ gridTemplateColumns: tableGridTemplate }}
onClick={(e) => onClick(e, download)}
onContextMenu={(e) => {
@@ -84,9 +85,25 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
</span>
</div>
<div className="download-cell-truncate">
<span className="tabular-nums" title={download.size && download.size !== '-' ? download.size : 'Unknown'}>
{download.size && download.size !== '-' ? download.size : 'Unknown'}
<div
className="download-cell-truncate download-size-cell tabular-nums"
title={hasDownloadedAmount
? `${sizeDisplay.downloaded} downloaded of ${sizeDisplay.totalIsEstimate ? '~' : ''}${sizeDisplay.total} ${sizeDisplay.unit}`
: completedSizeLabel}
aria-label={hasDownloadedAmount
? `${sizeDisplay.downloaded} downloaded of ${sizeDisplay.totalIsEstimate ? 'approximately ' : ''}${sizeDisplay.total} ${sizeDisplay.unit}`
: completedSizeLabel}
>
{hasDownloadedAmount ? (
<span className="download-size-progress">
<span className={downloadProgressColorClass(download.status)}>{sizeDisplay.downloaded}</span>
<span className="text-text-muted"> / </span>
</span>
) : null}
<span className="download-size-total">
{hasDownloadedAmount
? `${sizeDisplay.totalIsEstimate ? '~' : ''}${sizeDisplay.total} ${sizeDisplay.unit}`
: completedSizeLabel}
</span>
</div>
@@ -106,8 +123,7 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
style={{ width: `${displayFraction * 100}%` }}
/>
</div>
<span
key={`status-${download.status}`}
<span
title={
download.lastError && (download.status === 'failed' || download.status === 'retrying')
? download.lastError
@@ -149,7 +165,6 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
<div className="download-cell-truncate">
<span
key={`speed-${download.status}`}
className="tabular-nums"
title={displaySpeed}
>
@@ -159,7 +174,6 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
<div className="download-cell-truncate">
<span
key={`eta-${download.status}`}
className="tabular-nums"
title={displayEta}
>
@@ -183,7 +197,7 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
{(download.status === 'queued' || download.status === 'staged') && queueIndex !== -1 && (
<>
<button
onClick={() => moveInQueue(selectedIds.has(download.id) ? Array.from(selectedIds) : download.id, 'up')}
onClick={() => onMoveInQueue(download.id, 'up')}
disabled={queueIndex === 0}
className="app-icon-button h-7 w-7 disabled:opacity-40"
title="Move Up"
@@ -191,8 +205,8 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
<ArrowUp size={14} />
</button>
<button
onClick={() => moveInQueue(selectedIds.has(download.id) ? Array.from(selectedIds) : download.id, 'down')}
disabled={queueIndex === queueItems.length - 1}
onClick={() => onMoveInQueue(download.id, 'down')}
disabled={queueIndex === queueLength - 1}
className="app-icon-button h-7 w-7 disabled:opacity-40"
title="Move Down"
>
+81 -33
View File
@@ -1,5 +1,5 @@
import React, { useState, useEffect, useMemo, useRef } from 'react';
import { useDownloadStore, DownloadItem } from '../store/useDownloadStore';
import React, { useState, useEffect, useMemo, useRef, useCallback } from 'react';
import { useDownloadStore, DownloadItem, MAIN_QUEUE_ID } from '../store/useDownloadStore';
import { useToast } from '../contexts/ToastContext';
import { useSettingsStore } from '../store/useSettingsStore';
import { SidebarFilter } from './Sidebar';
@@ -33,7 +33,7 @@ const DEFAULT_COLUMN_WIDTHS = [340, 100, 220, 100, 80, 170];
const COLUMN_WIDTHS_STORAGE_KEY = 'firelink-download-column-widths';
export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
const { downloads, queues, assignToQueue, openDeleteModal, redownload } = useDownloadStore();
const { downloads, queues, assignToQueue, openDeleteModal, redownload, moveInQueue } = useDownloadStore();
const { addToast } = useToast();
const isMac = navigator.userAgent.includes('Mac');
const [isReadingClipboard, setIsReadingClipboard] = useState(false);
@@ -54,8 +54,10 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
const [sortConfig, setSortConfig] = useState<DownloadSortConfig>({ column: 'Date Added', direction: 'desc' });
const [queueSortConfig, setQueueSortConfig] = useState<DownloadSortConfig | null>(null);
const selectedIdsRef = useRef(selectedIds);
const lastSelectedIdRef = useRef(lastSelectedId);
const sortedDownloadsRef = useRef<DownloadItem[]>([]);
selectedIdsRef.current = selectedIds;
lastSelectedIdRef.current = lastSelectedId;
const [columnWidths, setColumnWidths] = useState(() => {
try {
const stored = JSON.parse(window.localStorage.getItem(COLUMN_WIDTHS_STORAGE_KEY) || 'null');
@@ -138,25 +140,25 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
}, []);
const showInteractionError = (message: string, error: unknown) => {
const showInteractionError = useCallback((message: string, error: unknown) => {
const detail = error instanceof Error ? error.message : String(error);
addToast({ message: `${message}: ${detail}`, variant: 'error', isActionable: true });
};
}, [addToast]);
const getDownloadPath = async (item: DownloadItem) => {
const getDownloadPath = useCallback(async (item: DownloadItem) => {
const fileName = item.fileName?.trim();
if (!fileName) return null;
const settings = useSettingsStore.getState();
const destination = item.destination ||
await resolveCategoryDestination(settings, item.category);
return resolveDownloadFilePath(destination, fileName);
};
}, []);
const openProperties = (id: string) => {
const openProperties = useCallback((id: string) => {
useDownloadStore.getState().setSelectedPropertiesDownloadId(id);
};
}, []);
const openDownloadFile = async (item: DownloadItem) => {
const openDownloadFile = useCallback(async (item: DownloadItem) => {
if (item.status !== 'completed') {
openProperties(item.id);
return;
@@ -174,7 +176,7 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
console.error("Failed to open file:", error);
showInteractionError('Could not open downloaded file', error);
}
};
}, [getDownloadPath, openProperties, showInteractionError]);
const revealDownloadFile = async (item: DownloadItem) => {
const pathToReveal = await getDownloadPath(item);
@@ -192,14 +194,14 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
}
};
const handleDownloadDoubleClick = (item: DownloadItem) => {
const handleDownloadDoubleClick = useCallback((item: DownloadItem) => {
if (item.status === 'completed') {
void openDownloadFile(item);
return;
}
openProperties(item.id);
};
}, [openDownloadFile, openProperties]);
const isQueueFilter = filter.startsWith('queue:');
const filteredDownloads = useMemo(() => downloads.filter((d: DownloadItem) => {
@@ -232,6 +234,39 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
})
: sortDownloads(filteredDownloads, isQueueFilter ? queueSortConfig! : sortConfig),
[filteredDownloads, isQueueFilter, queueSortConfig, sortConfig]);
// Each row used to derive this by filtering and sorting the complete store
// independently. That made a 1000-entry playlist perform O(n^2 log n) work
// on every download update. Compute the same queue membership once and pass
// the resulting position to rows instead.
const queuePositionsByDownloadId = useMemo(() => {
const grouped = new Map<string, DownloadItem[]>();
for (const download of downloads) {
if (
download.status === 'completed'
|| (isActiveDownloadStatus(download.status) && download.status !== 'queued')
) {
continue;
}
const queueId = download.queueId || MAIN_QUEUE_ID;
const queueItems = grouped.get(queueId) || [];
queueItems.push(download);
grouped.set(queueId, queueItems);
}
const positions = new Map<string, { index: number; length: number }>();
for (const queueItems of grouped.values()) {
queueItems.sort((left, right) =>
(left.queuePosition ?? Number.MAX_SAFE_INTEGER) -
(right.queuePosition ?? Number.MAX_SAFE_INTEGER) ||
left.id.localeCompare(right.id)
);
queueItems.forEach((download, index) => {
positions.set(download.id, { index, length: queueItems.length });
});
}
return positions;
}, [downloads]);
sortedDownloadsRef.current = sortedDownloads;
useEffect(() => {
@@ -246,27 +281,30 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
useEffect(() => {
setQueueSortConfig(null);
}, [filter, isQueueFilter]);
const handleItemClick = (e: React.MouseEvent, item: DownloadItem) => {
const handleItemClick = useCallback((e: React.MouseEvent, item: DownloadItem) => {
if (e.detail === 2) {
handleDownloadDoubleClick(item);
return;
}
if (e.shiftKey && lastSelectedId) {
const currentIndex = sortedDownloads.findIndex(d => d.id === item.id);
const lastIndex = sortedDownloads.findIndex(d => d.id === lastSelectedId);
const currentSortedDownloads = sortedDownloadsRef.current;
const currentSelectedIds = selectedIdsRef.current;
const currentLastSelectedId = lastSelectedIdRef.current;
if (e.shiftKey && currentLastSelectedId) {
const currentIndex = currentSortedDownloads.findIndex(d => d.id === item.id);
const lastIndex = currentSortedDownloads.findIndex(d => d.id === currentLastSelectedId);
if (currentIndex !== -1 && lastIndex !== -1) {
const start = Math.min(currentIndex, lastIndex);
const end = Math.max(currentIndex, lastIndex);
const newSelected = (e.metaKey || e.ctrlKey) ? new Set(selectedIds) : new Set<string>();
const newSelected = (e.metaKey || e.ctrlKey) ? new Set(currentSelectedIds) : new Set<string>();
for (let i = start; i <= end; i++) {
newSelected.add(sortedDownloads[i].id);
newSelected.add(currentSortedDownloads[i].id);
}
setSelectedIds(newSelected);
}
} else if (e.metaKey || e.ctrlKey) {
const newSelected = new Set(selectedIds);
const newSelected = new Set(currentSelectedIds);
if (newSelected.has(item.id)) {
newSelected.delete(item.id);
} else {
@@ -278,15 +316,22 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
setSelectedIds(new Set([item.id]));
setLastSelectedId(item.id);
}
};
}, [handleDownloadDoubleClick]);
const handleContextMenu = (menu: { x: number; y: number; id: string }) => {
if (!selectedIds.has(menu.id)) {
const handleContextMenu = useCallback((menu: { x: number; y: number; id: string }) => {
if (!selectedIdsRef.current.has(menu.id)) {
setSelectedIds(new Set([menu.id]));
setLastSelectedId(menu.id);
}
setContextMenu(menu);
};
}, []);
const handleMoveInQueue = useCallback((id: string, direction: 'up' | 'down') => {
const ids = selectedIdsRef.current.has(id)
? Array.from(selectedIdsRef.current)
: id;
void moveInQueue(ids, direction);
}, [moveInQueue]);
const handleSort = (column: DownloadSortColumn) => {
const update = (current: DownloadSortConfig | null): DownloadSortConfig =>
@@ -317,7 +362,7 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
}
};
const handlePause = async (id: string, skipConfirm = false) => {
const handlePause = useCallback(async (id: string, skipConfirm = false) => {
const download = useDownloadStore.getState().downloads.find(d => d.id === id);
if (!skipConfirm && download && download.resumable === false) {
const confirmPause = window.confirm("This download does not support resuming. If you pause it, you will have to start over again later. Are you sure you want to pause?");
@@ -332,9 +377,9 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
console.error("Failed to pause:", e);
showInteractionError('Could not pause download', e);
}
};
}, [showInteractionError]);
const handleResume = async (item: DownloadItem) => {
const handleResume = useCallback(async (item: DownloadItem) => {
try {
const resumed = await useDownloadStore.getState().resumeDownload(item.id);
if (!resumed) {
@@ -344,7 +389,7 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
console.error("Failed to resume:", error);
showInteractionError(`Could not resume ${item.fileName}`, error);
}
};
}, [showInteractionError]);
const resumeItemsSequentially = async (items: DownloadItem[]) => {
for (const item of items) {
@@ -408,7 +453,7 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
};
const getCategoryIcon = (category: string) => {
const getCategoryIcon = useCallback((category: string) => {
switch(category) {
case 'Musics': return <Music size={16} className="text-pink-400" />;
case 'Movies': return <Film size={16} className="text-red-400" />;
@@ -419,7 +464,7 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
case 'Other': return <FileQuestion size={16} className="text-gray-400" />;
default: return <FileQuestion size={16} className="text-gray-400" />;
}
}
}, []);
return (
<div className="downloads-view flex-1 flex flex-col h-full min-w-0">
@@ -493,7 +538,7 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
className={`${index === 5 ? 'download-cell-right' : ''} cursor-pointer hover:text-text-primary transition-colors flex items-center justify-between`}
onClick={() => handleSort(label as DownloadSortColumn)}
>
<div className="flex items-center gap-1 w-full h-full select-none">
<div className={`flex items-center gap-1 w-full h-full select-none ${index === 1 ? 'justify-end' : ''}`}>
<span>{label}</span>
{(isQueueFilter ? queueSortConfig : sortConfig)?.column === label && (
(isQueueFilter ? queueSortConfig : sortConfig)?.direction === 'asc'
@@ -544,14 +589,17 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
{sortedDownloads.map((d, index) => (
<DownloadItemComponent
key={d.id}
downloadId={d.id}
download={d}
index={index}
queueIndex={queuePositionsByDownloadId.get(d.id)?.index ?? -1}
queueLength={queuePositionsByDownloadId.get(d.id)?.length ?? 0}
tableGridTemplate={tableGridTemplate}
setContextMenu={handleContextMenu}
handlePause={handlePause}
handleResume={handleResume}
getCategoryIcon={getCategoryIcon}
selectedIds={selectedIds}
isSelected={selectedIds.has(d.id)}
onMoveInQueue={handleMoveInQueue}
onClick={handleItemClick}
/>
))}
+1 -1
View File
@@ -68,7 +68,7 @@ export const DuplicateResolutionModal = ({ conflicts: initialConflicts, onConfir
</div>
<div className="p-4 border-t border-border-modal flex items-center justify-between bg-sidebar-bg/50">
<button onClick={onCancel} className="app-button border-transparent bg-transparent px-4 text-xs text-text-secondary">
<button onClick={onCancel} className="app-button px-4 text-xs">
Cancel
</button>
<button
+45 -20
View File
@@ -3,13 +3,17 @@ import { useSettingsStore } from '../store/useSettingsStore';
import { invokeCommand as invoke } from '../ipc';
import { KeyRound, ShieldAlert } from 'lucide-react';
import { usePlatformInfo } from '../utils/platform';
import { getKeychainConsentVersion } from '../utils/keychainStartup';
import { getVersion } from '@tauri-apps/api/app';
import type { PairingTokenHydration } from '../bindings/PairingTokenHydration';
const KEYCHAIN_GRANT_TIMEOUT_MS = 30_000;
type KeychainPermissionModalProps = {
appVersion: string;
consentVersion: string;
};
export const KeychainPermissionModal: React.FC<KeychainPermissionModalProps> = ({ appVersion }) => {
export const KeychainPermissionModal: React.FC<KeychainPermissionModalProps> = ({ consentVersion }) => {
const showKeychainModal = useSettingsStore(state => state.showKeychainModal);
const dismissKeychainPrompt = useSettingsStore(state => state.dismissKeychainPrompt);
const platform = usePlatformInfo();
@@ -19,11 +23,11 @@ export const KeychainPermissionModal: React.FC<KeychainPermissionModalProps> = (
useEffect(() => {
if (!showKeychainModal || isGranting) return;
const handleEscape = (event: KeyboardEvent) => {
if (event.key === 'Escape') dismissKeychainPrompt(appVersion);
if (event.key === 'Escape') dismissKeychainPrompt(consentVersion);
};
window.addEventListener('keydown', handleEscape);
return () => window.removeEventListener('keydown', handleEscape);
}, [appVersion, dismissKeychainPrompt, isGranting, showKeychainModal]);
}, [consentVersion, dismissKeychainPrompt, isGranting, showKeychainModal]);
if (!showKeychainModal) {
return null;
@@ -53,33 +57,54 @@ export const KeychainPermissionModal: React.FC<KeychainPermissionModalProps> = (
setIsGranting(true);
setError(null);
let timeoutId: number | undefined;
let persistentGrantApplied = false;
const applyPersistentGrant = async (result: PairingTokenHydration): Promise<boolean> => {
if (!result.persistent || persistentGrantApplied) return result.persistent;
persistentGrantApplied = true;
const grantedVersion = consentVersion || getKeychainConsentVersion(await getVersion().catch(() => ''));
// Keep state in sync with the grant result instead of rehydrating
// before Zustand has persisted keychainAccessGranted.
useSettingsStore.setState({
keychainAccessGranted: true,
keychainAccessVersion: grantedVersion,
keychainAccessReady: true,
extensionPairingToken: result.token,
isPairingTokenPersistent: true,
keychainPromptDismissed: false,
showKeychainModal: false
});
return true;
};
const grantRequest = invoke('grant_keychain_access');
// A native credential-store call cannot be cancelled by the webview. Keep
// a late successful result useful even if the UI timeout has already
// restored the Later/retry controls.
grantRequest.then(applyPersistentGrant).catch(() => undefined);
try {
const result = await invoke('grant_keychain_access');
if (result.persistent) {
const grantedVersion = appVersion || await getVersion().catch(() => '');
// Keep state in sync with the grant result instead of rehydrating
// before Zustand has persisted keychainAccessGranted.
useSettingsStore.setState({
keychainAccessGranted: true,
keychainAccessVersion: grantedVersion,
keychainAccessReady: true,
extensionPairingToken: result.token,
isPairingTokenPersistent: true,
keychainPromptDismissed: false,
showKeychainModal: false
});
} else {
const result = await Promise.race([
grantRequest,
new Promise<never>((_, reject) => {
timeoutId = window.setTimeout(
() => reject(new Error('Credential storage request timed out. You can select Later and try again.')),
KEYCHAIN_GRANT_TIMEOUT_MS
);
})
]);
if (!(await applyPersistentGrant(result))) {
setError(result.error || `${siteCredentialStoreName} is unavailable.`);
}
} catch (e: any) {
setError(e.toString());
} finally {
if (timeoutId !== undefined) window.clearTimeout(timeoutId);
setIsGranting(false);
}
};
const handleLater = () => {
dismissKeychainPrompt(appVersion);
dismissKeychainPrompt(consentVersion);
};
return (
+16 -2
View File
@@ -1,6 +1,7 @@
import { useEffect, useRef, useState } from 'react';
import { invokeCommand as invoke } from '../ipc';
import { save } from '@tauri-apps/plugin-dialog';
import { homeDir } from '@tauri-apps/api/path';
import { attachLogger, setLogPaused, initLogger, setLogStreamActive } from '../utils/logger';
import { FileDown, Trash2, Terminal, Filter, Play, Pause, Info, Copy } from 'lucide-react';
import { WindowDragRegion } from './WindowDragRegion';
@@ -24,6 +25,7 @@ export default function LogsView() {
const [levelFilter, setLevelFilter] = useState<LogEntry['level'] | 'All'>('All');
const [contextMenu, setContextMenu] = useState<{ x: number; y: number; text: string } | null>(null);
const [pageVisible, setPageVisible] = useState(() => document.visibilityState !== 'hidden');
const homeDirectoryRef = useRef('');
const scrollRef = useRef<HTMLDivElement>(null);
const liveBatchRef = useRef<LogEntry[]>([]);
const liveFrameRef = useRef<number | null>(null);
@@ -33,6 +35,18 @@ export default function LogsView() {
const [isClearing, setIsClearing] = useState(false);
const [isToggling, setIsToggling] = useState(false);
useEffect(() => {
let active = true;
void homeDir()
.then(directory => {
if (active) homeDirectoryRef.current = directory;
})
.catch(() => undefined);
return () => {
active = false;
};
}, []);
useEffect(() => {
const handleVisibilityChange = () => setPageVisible(document.visibilityState !== 'hidden');
document.addEventListener('visibilitychange', handleVisibilityChange);
@@ -80,7 +94,7 @@ export default function LogsView() {
if (logsEnabled) {
unlistenPromise = attachLogger((log) => {
if (!active) return;
scheduleLiveEntry(liveLogEntry(log.level, log.message));
scheduleLiveEntry(liveLogEntry(log.level, log.message, new Date(), homeDirectoryRef.current));
});
await unlistenPromise;
if (!active) return;
@@ -93,7 +107,7 @@ export default function LogsView() {
const lines = await invoke('read_logs', { limit: MAX_LOG_LINES });
if (!active) return;
const snapshot = lines.map(persistedLogEntry);
const snapshot = lines.map(line => persistedLogEntry(line, homeDirectoryRef.current));
initialized = true;
if (initGeneration !== clearGenerationRef.current) {
pendingLiveEntries = [];
+67 -8
View File
@@ -10,6 +10,12 @@ import {
isIdentityLocked as getIdentityLocked,
isTransferLocked as getTransferLocked
} from '../utils/downloadActions';
import {
downloadProgressColorClass,
formatDownloadTotal,
resolveDownloadSizeDisplay
} from '../utils/downloadProgress';
import { resolveDownloadConnections } from '../utils/downloads';
type LoginMode = 'matching' | 'custom' | 'none';
@@ -41,7 +47,8 @@ export const PropertiesModal = () => {
const [url, setUrl] = useState('');
const [fileName, setFileName] = useState('');
const [saveLocation, setSaveLocation] = useState('');
const [connections, setConnections] = useState(16);
const [connections, setConnections] = useState(() => resolveDownloadConnections(undefined, perServerConnections));
const [connectionsDirty, setConnectionsDirty] = useState(false);
const [speedLimitEnabled, setSpeedLimitEnabled] = useState(false);
const [speedLimitValue, setSpeedLimitValue] = useState('1024'); // KiB/s
@@ -74,7 +81,8 @@ export const PropertiesModal = () => {
activeItem.category
).then(setSaveLocation);
}
setConnections(activeItem.connections || 16);
setConnections(resolveDownloadConnections(activeItem.connections, perServerConnections));
setConnectionsDirty(false);
if (activeItem.speedLimit) {
setSpeedLimitEnabled(true);
@@ -112,7 +120,15 @@ export const PropertiesModal = () => {
setSelectedPropertiesDownloadId(null);
}
}
}, [selectedPropertiesDownloadId, baseDownloadFolder, setSelectedPropertiesDownloadId]);
}, [selectedPropertiesDownloadId, setSelectedPropertiesDownloadId]);
useEffect(() => {
if (!selectedPropertiesDownloadId || connectionsDirty) return;
const activeItem = useDownloadStore.getState().downloads.find(d => d.id === selectedPropertiesDownloadId);
if (activeItem && activeItem.connections === undefined) {
setConnections(resolveDownloadConnections(undefined, perServerConnections));
}
}, [selectedPropertiesDownloadId, perServerConnections, connectionsDirty]);
useEffect(() => {
if (!selectedPropertiesDownloadId) return;
@@ -155,7 +171,6 @@ export const PropertiesModal = () => {
url,
fileName,
destination: saveLocation,
connections: Number(connections),
speedLimit: speedLimitEnabled && speedLimitValue ? `${speedLimitValue}K` : undefined,
username: loginMode === 'custom' ? username.trim() : undefined,
password: loginMode === 'custom' ? password.trim() : undefined,
@@ -163,6 +178,9 @@ export const PropertiesModal = () => {
checksum: checksumEnabled && checksumValue.trim() ? `${checksumAlgorithm}=${checksumValue.trim()}` : undefined,
cookies: cookies.trim() || undefined,
mirrors: mirrors.trim() || undefined,
...(connectionsDirty
? { connections: resolveDownloadConnections(connections, perServerConnections) }
: {}),
};
try {
@@ -185,6 +203,17 @@ export const PropertiesModal = () => {
const displayedEta = item.status === 'completed'
? '-'
: liveProgress?.eta ?? item.eta ?? '-';
const sizeDisplay = resolveDownloadSizeDisplay({
downloadedBytes: liveProgress?.downloaded_bytes ?? item.downloadedBytes,
totalBytes: liveProgress?.total_bytes ?? item.totalBytes,
totalIsEstimate: liveProgress?.total_is_estimate ?? item.totalIsEstimate,
fallbackSize: item.size
});
const hasDownloadedAmount = item.status !== 'completed' &&
Boolean(sizeDisplay.downloaded && sizeDisplay.total);
const completedSizeLabel = item.status === 'completed'
? formatDownloadTotal(sizeDisplay)
: sizeDisplay.fallback;
let statusColor = 'text-text-secondary';
let StatusIcon = Info;
@@ -221,11 +250,29 @@ export const PropertiesModal = () => {
<div className="grid grid-cols-4 gap-y-2 gap-x-4 text-[11px] leading-tight">
<div className="flex gap-1.5 min-w-0"><span className="text-text-muted font-medium w-[90px] shrink-0">Progress</span><span className="text-text-secondary truncate">{`${(displayedFraction * 100).toFixed(0)}%`}</span></div>
<div className="flex gap-1.5 min-w-0"><span className="text-text-muted font-medium w-[40px] shrink-0">Size</span><span className="text-text-secondary truncate">{item.size || '-'}</span></div>
<div className="flex gap-1.5 min-w-0">
<span className="text-text-muted font-medium w-[40px] shrink-0">Size</span>
<span
className="truncate"
title={hasDownloadedAmount
? `${sizeDisplay.downloaded} downloaded of ${sizeDisplay.totalIsEstimate ? 'approximately ' : ''}${sizeDisplay.total} ${sizeDisplay.unit}`
: completedSizeLabel}
>
{hasDownloadedAmount ? (
<>
<span className={downloadProgressColorClass(item.status)}>{sizeDisplay.downloaded}</span>
<span className="text-text-muted"> / </span>
<span className="text-text-secondary">
{sizeDisplay.totalIsEstimate ? '~' : ''}{sizeDisplay.total} {sizeDisplay.unit}
</span>
</>
) : completedSizeLabel}
</span>
</div>
<div className="flex gap-1.5 min-w-0"><span className="text-text-muted font-medium w-[40px] shrink-0">Speed</span><span className="text-text-secondary truncate">{displayedSpeed}</span></div>
<div className="flex gap-1.5 min-w-0"><span className="text-text-muted font-medium w-[30px] shrink-0">ETA</span><span className="text-text-secondary truncate">{displayedEta}</span></div>
<div className="flex gap-1.5 min-w-0"><span className="text-text-muted font-medium w-[90px] shrink-0">Connections</span><span className="text-text-secondary truncate">{item.connections || perServerConnections || '-'}</span></div>
<div className="flex gap-1.5 min-w-0"><span className="text-text-muted font-medium w-[90px] shrink-0">Connections</span><span className="text-text-secondary truncate" title={item.connections !== undefined ? 'Saved for this download; Settings changes apply to new downloads.' : 'Using the current default for new downloads.'}>{resolveDownloadConnections(item.connections, perServerConnections)}{item.connections !== undefined ? ' (saved)' : ' (default)'}</span></div>
<div className="flex gap-1.5 min-w-0"><span className="text-text-muted font-medium w-[60px] shrink-0">Speed cap</span><span className="text-text-secondary truncate">{item.speedLimit || '-'}</span></div>
<div className="flex gap-1.5 min-w-0"><span className="text-text-muted font-medium w-[55px] shrink-0">Category</span><span className="text-text-secondary truncate">{item.category}</span></div>
<div className="flex gap-1.5"><span className="text-text-muted font-medium w-[50px]">Last try</span><span className="text-text-secondary truncate">{formatLastTry(item.lastTry)}</span></div>
@@ -277,8 +324,20 @@ export const PropertiesModal = () => {
<label className="text-xs text-text-muted text-right">Connections</label>
<div className="flex items-center gap-2">
<input type="number" value={connections} min={1} max={16} onChange={e=>setConnections(Number(e.target.value))} disabled={transferLocked} className="w-16 bg-bg-input border border-border-modal rounded-lg px-2.5 py-1.5 text-xs text-text-primary focus:outline-none focus:border-accent disabled:opacity-50" />
<input type="number" value={connections} min={1} max={16} onChange={e=>{ setConnections(Number(e.target.value)); setConnectionsDirty(true); }} disabled={transferLocked} className="w-16 bg-bg-input border border-border-modal rounded-lg px-2.5 py-1.5 text-xs text-text-primary focus:outline-none focus:border-accent disabled:opacity-50" />
<span className="text-xs text-text-muted">per file</span>
{!transferLocked && item.connections !== undefined && item.connections !== perServerConnections && (
<button
type="button"
onClick={() => { setConnections(perServerConnections); setConnectionsDirty(true); }}
className="text-[11px] text-accent hover:underline whitespace-nowrap"
>
Use current default ({perServerConnections})
</button>
)}
</div>
<div className="col-start-2 text-[11px] text-text-muted">
Saved per download. The Settings default applies to new downloads.
</div>
<label className="text-xs text-text-muted text-right">Speed</label>
@@ -395,7 +454,7 @@ export const PropertiesModal = () => {
<div className="flex gap-2">
<button
onClick={() => setSelectedPropertiesDownloadId(null)}
className="app-button border-transparent bg-transparent px-4 text-xs text-text-secondary"
className="app-button px-4 text-xs"
>
Cancel
</button>
+86 -22
View File
@@ -4,6 +4,7 @@ import {
type ListRowDensity,
type SettingsState,
SettingsTab,
runSettingsPersistenceTransaction,
useSettingsStore
} from '../store/useSettingsStore';
import {
@@ -29,6 +30,7 @@ import {
} from '../utils/downloadLocations';
import { usePlatformInfo } from '../utils/platform';
import { isTrustedFirelinkReleaseUrl } from '../utils/releaseUrls';
import { normalizeCustomProxy } from '../store/useDownloadStore';
const settingsTabs: { type: SettingsTab; label: string; icon: typeof Download }[] = [
{ type: 'downloads', label: 'Downloads', icon: Download },
@@ -76,6 +78,8 @@ type ManualUpdateStatus =
| { type: 'update-available'; version: string; releaseUrl: string }
| { type: 'error'; message: string };
type SystemProxyStatus = 'idle' | 'checking' | 'detected' | 'none' | 'error';
const engineStatusCache = new Map<string, EngineStatusItem>();
const engineStatusInFlight = new Map<string, Promise<EngineStatusItem>>();
@@ -276,14 +280,17 @@ const [engineStatus, setEngineStatus] = useState<EngineStatusItem[] | null>(null
const [expandedEngine, setExpandedEngine] = useState<string | null>(null);
const [isRecheckingEngines, setIsRecheckingEngines] = useState(false);
const engineRunId = useRef(0);
const [appVersion, setAppVersion] = useState('Unknown');
const [extensionServerPort, setExtensionServerPort] = useState<number | null>(null);
const [appVersion, setAppVersion] = useState('Unknown');
const [extensionServerPort, setExtensionServerPort] = useState<number | null>(null);
const [systemProxyStatus, setSystemProxyStatus] = useState<SystemProxyStatus>('idle');
// Local state for adding site login
const [loginPattern, setLoginPattern] = useState('');
const [loginUser, setLoginUser] = useState('');
const [loginPass, setLoginPass] = useState('');
const [loginError, setLoginError] = useState('');
const [isSavingLogin, setIsSavingLogin] = useState(false);
const saveLoginInFlight = useRef(false);
const [loginFieldErrors, setLoginFieldErrors] = useState<{
pattern?: string;
username?: string;
@@ -320,6 +327,27 @@ useEffect(() => {
};
}, [settings.activeView, activeTab]);
useEffect(() => {
if (settings.activeView !== 'settings' || activeTab !== 'network' || settings.proxyMode !== 'system') {
setSystemProxyStatus('idle');
return;
}
let active = true;
setSystemProxyStatus('checking');
invoke('get_system_proxy')
.then(proxy => {
if (active) setSystemProxyStatus(typeof proxy === 'string' && proxy.trim() ? 'detected' : 'none');
})
.catch(() => {
if (active) setSystemProxyStatus('error');
});
return () => {
active = false;
};
}, [settings.activeView, settings.proxyMode, activeTab]);
const runEngineChecks = useCallback((force = false) => {
const runId = ++engineRunId.current;
const cached = engineChecks
@@ -521,6 +549,7 @@ runEngineChecks(false);
};
const handleAddLogin = async () => {
if (saveLoginInFlight.current) return;
const fieldErrors: typeof loginFieldErrors = {};
if (!loginPattern.trim()) {
fieldErrors.pattern = 'URL pattern is required.';
@@ -546,22 +575,31 @@ runEngineChecks(false);
setLoginError('Grant credential-store access before saving a site login.');
return;
}
if (loginPass) {
try {
await invoke('set_keychain_password', { id, password: loginPass });
} catch (e) {
console.error("Failed to save password to keychain:", e);
setLoginError("Failed to save password securely.");
return;
}
saveLoginInFlight.current = true;
setIsSavingLogin(true);
try {
await runSettingsPersistenceTransaction(async () => {
await invoke('save_site_login', {
id,
urlPattern: loginPattern.trim(),
username: loginUser.trim(),
password: loginPass
});
settings.addSiteLogin({
id,
urlPattern: loginPattern.trim(),
username: loginUser.trim()
});
});
} catch (e) {
console.error("Failed to save site login:", e);
setLoginError("Failed to save site credential securely.");
return;
} finally {
saveLoginInFlight.current = false;
setIsSavingLogin(false);
}
settings.addSiteLogin({
id,
urlPattern: loginPattern.trim(),
username: loginUser.trim()
});
setLoginPattern('');
setLoginUser('');
setLoginPass('');
@@ -625,7 +663,7 @@ runEngineChecks(false);
<div className="mac-settings-row">
<div className="settings-row-label">
<span>Default connections:</span>
<small>For new downloads</small>
<small>New downloads; existing items keep their saved value</small>
</div>
<input
type="number" min="1" max="16"
@@ -700,6 +738,18 @@ runEngineChecks(false);
className="mac-switch"
/>
</label>
<label className="mac-settings-row cursor-default">
<div className="settings-row-label">
<span>Add clipboard links when Firelink becomes active</span>
<small>Opens supported copied links in the Add window. Off by default.</small>
</div>
<input
type="checkbox"
checked={settings.autoAddClipboardLinks}
onChange={(e) => settings.setAutoAddClipboardLinks(e.target.checked)}
className="mac-switch"
/>
</label>
</div>
</div>
)}
@@ -868,10 +918,20 @@ runEngineChecks(false);
<p className="settings-group-footer">
{settings.proxyMode === 'none' && 'Downloads ignore configured proxies.'}
{settings.proxyMode === 'system' && `Downloads use the detected ${platform.os === 'macos' ? 'macOS' : platform.os === 'windows' ? 'Windows' : 'desktop'} system proxy. Normal file downloads require an HTTP or HTTPS proxy endpoint; media downloads can use SOCKS.`}
{settings.proxyMode === 'custom' && (settings.proxyHost
{settings.proxyMode === 'custom' && (normalizeCustomProxy(settings.proxyHost, settings.proxyPort)
? 'Downloads use the configured HTTP proxy endpoint for metadata and download engines.'
: 'Enter a proxy host and port to enable the custom proxy.')}
: settings.proxyHost
? 'Enter a valid HTTP proxy host and port to enable the custom proxy.'
: 'Enter a proxy host and port to enable the custom proxy.')}
</p>
{settings.proxyMode === 'system' && (
<p className="settings-group-footer" role="status">
{systemProxyStatus === 'checking' && 'Checking system proxy configuration…'}
{systemProxyStatus === 'detected' && 'A system proxy was detected. Normal file downloads require an HTTP or HTTPS endpoint; media downloads can use SOCKS.'}
{systemProxyStatus === 'none' && 'No usable system proxy was detected. Downloads will use no proxy.'}
{systemProxyStatus === 'error' && 'System proxy configuration could not be read. Choose No Proxy or try again when it is available.'}
</p>
)}
</div>
<h2 className="settings-section-title">Identity</h2>
@@ -1060,8 +1120,10 @@ runEngineChecks(false);
return;
}
try {
await invoke('delete_keychain_password', { id: login.id });
settings.removeSiteLogin(login.id);
await runSettingsPersistenceTransaction(async () => {
await invoke('delete_site_login', { id: login.id });
settings.removeSiteLogin(login.id);
});
showToast("Deleted credential", 'success');
} catch (error) {
showToast(`Could not delete credential: ${String(error)}`, 'error');
@@ -1135,10 +1197,12 @@ runEngineChecks(false);
<div className="flex justify-end pt-2">
<button
type="button"
onClick={handleAddLogin}
disabled={isSavingLogin}
className="bg-accent hover:bg-accent text-white px-4 py-1.5 rounded-lg text-xs font-semibold shadow flex items-center gap-1.5"
>
<Plus size={14} /> Add Login
<Plus size={14} /> {isSavingLogin ? 'Saving…' : 'Add Login'}
</button>
</div>
</div>
+26 -4
View File
@@ -38,6 +38,8 @@ export const Sidebar: React.FC<SidebarProps> = (props) => {
const addInputRef = useRef<HTMLInputElement>(null);
const renameInputRef = useRef<HTMLInputElement>(null);
const addQueueSubmitRef = useRef(false);
const renameQueueSubmitRef = useRef(false);
useEffect(() => {
const handleCloseMenu = () => setContextMenu(null);
@@ -139,15 +141,34 @@ export const Sidebar: React.FC<SidebarProps> = (props) => {
};
const handleAddQueueSubmit = () => {
if (newQueueName.trim()) addQueue(newQueueName.trim());
if (addQueueSubmitRef.current) return;
const normalizedName = newQueueName.trim();
if (!normalizedName) {
addToast({ message: 'Queue name cannot be empty', variant: 'error', isActionable: true });
return;
}
if (!addQueue(normalizedName)) {
addToast({ message: 'A queue with this name already exists', variant: 'error', isActionable: true });
return;
}
addQueueSubmitRef.current = true;
setNewQueueName('');
setIsAddingQueue(false);
};
const handleRenameQueueSubmit = () => {
if (renamingQueueId && editingQueueName.trim()) {
renameQueue(renamingQueueId, editingQueueName.trim());
if (renameQueueSubmitRef.current) return;
const normalizedName = editingQueueName.trim();
if (!renamingQueueId) return;
if (!normalizedName) {
addToast({ message: 'Queue name cannot be empty', variant: 'error', isActionable: true });
return;
}
if (!renameQueue(renamingQueueId, normalizedName)) {
addToast({ message: 'A queue with this name already exists', variant: 'error', isActionable: true });
return;
}
renameQueueSubmitRef.current = true;
setRenamingQueueId(null);
};
@@ -293,7 +314,7 @@ export const Sidebar: React.FC<SidebarProps> = (props) => {
) : (
<button
type="button"
onClick={() => { setIsAddingQueue(true); setNewQueueName(''); }}
onClick={() => { addQueueSubmitRef.current = false; setIsAddingQueue(true); setNewQueueName(''); }}
className="flex w-full items-center px-3.5 py-1.5 rounded-lg text-[13px] text-text-muted hover:bg-item-hover hover:text-text-secondary cursor-default transition-colors mb-1"
>
<Plus className="w-4 h-4 mr-2 shrink-0" strokeWidth={2} />
@@ -372,6 +393,7 @@ export const Sidebar: React.FC<SidebarProps> = (props) => {
onClick={() => {
const q = queues.find(q => q.id === contextMenu.id);
if (q) {
renameQueueSubmitRef.current = false;
setEditingQueueName(q.name);
setRenamingQueueId(q.id);
}
+16 -2
View File
@@ -14,6 +14,7 @@ export interface ToastMessage {
variant?: ToastVariant;
duration?: number;
isActionable?: boolean;
onDismiss?: () => void;
}
interface ToastState extends ToastMessage {
@@ -21,7 +22,7 @@ interface ToastState extends ToastMessage {
}
interface ToastContextType {
addToast: (toast: Omit<ToastMessage, 'id'>) => void;
addToast: (toast: Omit<ToastMessage, 'id'>) => string;
removeToast: (id: string) => void;
}
@@ -33,10 +34,12 @@ export const ToastProvider: React.FC<{ children: ReactNode }> = ({ children }) =
const addToast = useCallback((toast: Omit<ToastMessage, 'id'>) => {
nextToastId.current += 1;
const id = `toast-${nextToastId.current}`;
setToasts(prev => {
const next = [...prev, { ...toast, id: `toast-${nextToastId.current}` }];
const next = [...prev, { ...toast, id }];
return next.slice(-MAX_VISIBLE_TOASTS);
});
return id;
}, []);
const removeToast = useCallback((id: string) => {
@@ -70,6 +73,7 @@ const ToastItem: React.FC<{ toast: ToastState; removeToast: (id: string) => void
const [isHovered, setIsHovered] = useState(false);
const timerStartedAt = useRef<number | null>(null);
const remainingDuration = useRef<number | null>(null);
const onDismissCalled = useRef(false);
useLayoutEffect(() => {
const frame = requestAnimationFrame(() => setIsMounted(true));
@@ -99,6 +103,16 @@ const ToastItem: React.FC<{ toast: ToastState; removeToast: (id: string) => void
};
}, [toast, isHovered, removeToast]);
useEffect(() => {
const dismiss = () => {
if (onDismissCalled.current) return;
onDismissCalled.current = true;
toast.onDismiss?.();
};
if (toast.exiting) dismiss();
return dismiss;
}, [toast.exiting, toast.onDismiss]);
useEffect(() => {
if (toast.exiting) {
const fallbackTimer = setTimeout(() => {
+45 -11
View File
@@ -1,4 +1,6 @@
@import "tailwindcss";
@import "tailwindcss" source(none);
@source "../src";
@source "../index.html";
:root {
/* Default/fallback Light (macOS Light Mode) */
@@ -439,7 +441,7 @@ html[data-list-density="relaxed"] {
color: hsl(var(--text-primary));
font-weight: 500;
font-size: 13px;
transition: background-color 100ms ease, transform 100ms ease;
transition: background-color 100ms ease, border-color 100ms ease, box-shadow 100ms ease, transform 100ms ease;
box-shadow: 0 1px 1px hsl(var(--shadow-color));
}
@@ -545,6 +547,7 @@ html[data-list-density="relaxed"] {
.add-download-left {
min-width: 0;
min-height: 0;
overflow: hidden;
background:
radial-gradient(circle at 18% 0%, hsl(var(--accent-color) / 0.055), transparent 34%),
hsl(var(--main-bg) / 0.56);
@@ -643,6 +646,8 @@ html[data-list-density="relaxed"] {
}
.add-download-preview {
min-width: 0;
min-height: 0;
border: 1px solid hsl(var(--border-modal));
border-radius: 11px;
background: hsl(var(--bg-input) / 0.35);
@@ -650,11 +655,13 @@ html[data-list-density="relaxed"] {
}
.add-download-preview-header {
flex-shrink: 0;
border-bottom: 1px solid hsl(var(--border-modal));
background: hsl(var(--surface-raised) / 0.66);
}
.add-download-preview-row {
flex-shrink: 0;
transition:
background-color 100ms ease,
border-color 100ms ease,
@@ -836,15 +843,6 @@ html[data-list-density="relaxed"] {
hsl(var(--bg-input));
}
.add-download-button-cancel {
color: hsl(var(--text-secondary));
}
.add-download-button-cancel:hover:not(:disabled) {
background: hsl(var(--item-hover));
color: hsl(var(--text-primary));
}
.add-download-button-primary {
border-color: hsl(var(--accent-color) / 0.86);
background:
@@ -1780,6 +1778,7 @@ html[data-list-density="relaxed"] {
}
.download-table-header {
flex-shrink: 0;
height: var(--download-header-height);
display: grid;
align-items: center;
@@ -1882,12 +1881,14 @@ body.is-resizing .column-resize-handle:hover::after {
.download-table-list {
height: 100%;
min-height: 0;
overflow: auto;
display: flex;
flex-direction: column;
}
.download-row {
flex: 0 0 var(--download-row-height);
height: var(--download-row-height);
display: grid;
align-items: center;
@@ -1970,6 +1971,39 @@ html[data-list-density="relaxed"] .download-ghost-row {
overflow: hidden;
}
.download-size-cell {
justify-content: flex-end;
text-align: right;
}
.download-size-cell > .download-size-progress {
flex: 1 1 auto;
text-align: right;
}
.download-size-cell > .download-size-total {
flex: 0 0 auto;
text-align: left;
white-space: nowrap;
}
.download-size-cell > .download-size-progress,
.download-size-cell > .download-size-total {
max-width: 100%;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.download-size-cell > .download-size-progress {
text-align: right;
}
.download-row > .download-size-cell {
padding-right: calc(var(--download-column-padding-x) + 6px);
}
.download-cell-truncate > span,
.download-cell-right > span {
display: block;
+13 -1
View File
@@ -5,7 +5,9 @@ import type { DownloadCategory } from './bindings/DownloadCategory';
import type { DownloadProgressEvent } from './bindings/DownloadProgressEvent';
import type { DownloadStateEvent } from './bindings/DownloadStateEvent';
import type { ExtensionDownload } from './bindings/ExtensionDownload';
import type { ExtensionCookieScope } from './bindings/ExtensionCookieScope';
import type { MediaMetadata } from './bindings/MediaMetadata';
import type { MediaPlaylistMetadata } from './bindings/MediaPlaylistMetadata';
import type { MetadataResponse } from './bindings/MetadataResponse';
import type { EngineStatusItem } from './bindings/EngineStatusItem';
import type { PostQueueAction } from './bindings/PostQueueAction';
@@ -17,13 +19,17 @@ import type { PlatformInfo } from './bindings/PlatformInfo';
type CommandMap = {
fetch_metadata: {
args: { url: string; userAgent: string | null; username: string | null; password: string | null; headers: string | null; cookies: string | null; proxy: string | null; deferCookies?: boolean };
args: { url: string; userAgent: string | null; username: string | null; password: string | null; headers: string | null; cookies: string | null; cookieScopes: Array<ExtensionCookieScope> | null; proxy: string | null; deferCookies?: boolean };
result: MetadataResponse;
};
fetch_media_metadata: {
args: { url: string; cookieBrowser: string | null; userAgent: string | null; username: string | null; password: string | null; headers: string | null; cookies: string | null; proxy: string | null };
result: MediaMetadata;
};
fetch_media_playlist_metadata: {
args: { url: string; cookieBrowser: string | null; userAgent: string | null; username: string | null; password: string | null; headers: string | null; cookies: string | null; proxy: string | null };
result: MediaPlaylistMetadata;
};
get_aria2_engine_status: { args: undefined; result: EngineStatusItem };
get_ytdlp_engine_status: { args: undefined; result: EngineStatusItem };
get_ffmpeg_engine_status: { args: undefined; result: EngineStatusItem };
@@ -49,6 +55,11 @@ type CommandMap = {
set_keychain_password: { args: { id: string; password: string }; result: void };
get_keychain_password: { args: { id: string }; result: string };
delete_keychain_password: { args: { id: string }; result: void };
save_site_login: {
args: { id: string; urlPattern: string; username: string; password: string };
result: void;
};
delete_site_login: { args: { id: string }; result: void };
check_file_exists: { args: { path: string }; result: boolean };
toggle_tray_icon: { args: { show: boolean }; result: void };
set_extension_pairing_token: { args: { token: string }; result: void };
@@ -59,6 +70,7 @@ type CommandMap = {
grant_keychain_access: { args: undefined; result: PairingTokenHydration };
acknowledge_pairing_token_change: { args: undefined; result: void };
set_extension_frontend_ready: { args: { ready: boolean }; result: void };
ack_extension_download: { args: { requestId: string }; result: void };
get_system_proxy: { args: undefined; result: string | null };
get_file_category: { args: { filename: string }; result: DownloadCategory };
check_for_updates: { args: undefined; result: ReleaseCheckOutcome };
+229 -1
View File
@@ -1,6 +1,10 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { initDownloadListener, useDownloadProgressStore } from './downloadStore';
import { useDownloadStore } from './useDownloadStore';
import {
clearDownloadControlIntents,
setDownloadControlIntent,
useDownloadStore
} from './useDownloadStore';
import * as ipc from '../ipc';
vi.mock('../ipc', () => ({
@@ -11,7 +15,9 @@ vi.mock('../ipc', () => ({
describe('useDownloadProgressStore', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(ipc.invokeCommand).mockResolvedValue(undefined);
useDownloadProgressStore.setState({ progressMap: {} });
clearDownloadControlIntents();
});
it('prunes terminal progress entries', () => {
@@ -156,4 +162,226 @@ describe('useDownloadProgressStore', () => {
expect(useDownloadProgressStore.getState().progressMap).toEqual({});
release();
});
it('snapshots live progress before clearing it on a terminal transition', async () => {
const handlers: Record<string, (event: any) => void> = {};
vi.mocked(ipc.listenEvent).mockImplementation((event, handler) => {
handlers[event] = handler as (event: any) => void;
return Promise.resolve(vi.fn());
});
useDownloadStore.setState({
downloads: [{
id: 'snapshot',
url: 'https://example.com/file',
fileName: 'file.bin',
status: 'downloading',
category: 'Other',
dateAdded: ''
}]
});
const release = await initDownloadListener();
handlers['download-progress']({ payload: {
id: 'snapshot',
fraction: 0.8,
speed: '1 MB/s',
eta: '2s',
size: '8 MB',
size_is_final: false,
downloaded_bytes: 8192,
total_bytes: 10240,
total_is_estimate: true
} });
handlers['download-state']({ payload: {
id: 'snapshot',
status: 'paused'
} });
const row = useDownloadStore.getState().downloads[0];
expect(row.status).toBe('paused');
expect(row.fraction).toBe(0.8);
expect(row.downloadedBytes).toBe(8192);
expect(row.totalBytes).toBe(10240);
expect(row.totalIsEstimate).toBe(true);
expect(useDownloadProgressStore.getState().progressMap).toEqual({});
release();
});
it('drops a persisted temporary media estimate when fragmented progress has no total', async () => {
const handlers: Record<string, (event: any) => void> = {};
vi.mocked(ipc.listenEvent).mockImplementation((event, handler) => {
handlers[event] = handler as (event: any) => void;
return Promise.resolve(vi.fn());
});
useDownloadStore.setState({
downloads: [{
id: 'stale-media-estimate',
url: 'https://youtube.com/watch?v=stale',
fileName: 'video.mkv',
status: 'downloading',
category: 'Movies',
dateAdded: '',
isMedia: true,
downloadedBytes: 11989,
totalBytes: 1024,
totalIsEstimate: true,
size: '~85.7 MB'
}]
});
const release = await initDownloadListener();
handlers['download-progress']({ payload: {
id: 'stale-media-estimate',
fraction: 0.38,
speed: '2.7 MB/s',
eta: '7s',
size: null,
size_is_final: false,
downloaded_bytes: 13000,
total_bytes: null,
total_is_estimate: null
} });
expect(useDownloadStore.getState().downloads[0]).toMatchObject({
downloadedBytes: 13000,
size: undefined
});
expect(useDownloadStore.getState().downloads[0].totalBytes).toBeUndefined();
expect(useDownloadStore.getState().downloads[0].totalIsEstimate).toBeUndefined();
release();
});
it('removes a stale tiny media size after restart when byte counters were volatile', async () => {
const handlers: Record<string, (event: any) => void> = {};
vi.mocked(ipc.listenEvent).mockImplementation((event, handler) => {
handlers[event] = handler as (event: any) => void;
return Promise.resolve(vi.fn());
});
useDownloadStore.setState({
downloads: [{
id: 'stale-media-size',
url: 'https://youtube.com/watch?v=stale-size',
fileName: 'video.mkv',
status: 'downloading',
category: 'Movies',
dateAdded: '',
isMedia: true,
size: '~1.00 KB'
}]
});
const release = await initDownloadListener();
handlers['download-progress']({ payload: {
id: 'stale-media-size',
fraction: 0.01,
speed: '2.7 MB/s',
eta: '7s',
size: null,
size_is_final: false,
downloaded_bytes: 2048,
total_bytes: null,
total_is_estimate: null
} });
expect(useDownloadStore.getState().downloads[0].size).toBeUndefined();
release();
});
it('ignores stale active state events after pause but accepts terminal reconciliation', async () => {
const handlers: Record<string, (event: any) => void> = {};
vi.mocked(ipc.listenEvent).mockImplementation((event, handler) => {
handlers[event] = handler as (event: any) => void;
return Promise.resolve(vi.fn());
});
useDownloadStore.setState({
downloads: [{
id: 'paused-race',
url: 'https://example.com/file',
fileName: 'file.bin',
status: 'paused',
category: 'Other',
dateAdded: ''
}]
});
const release = await initDownloadListener();
handlers['download-state']({ payload: {
id: 'paused-race',
status: 'downloading'
} });
expect(useDownloadStore.getState().downloads[0].status).toBe('paused');
handlers['download-state']({ payload: {
id: 'paused-race',
status: 'completed'
} });
expect(useDownloadStore.getState().downloads[0].status).toBe('completed');
release();
});
it('ignores a stale paused event during resume and accepts the new active state', async () => {
const handlers: Record<string, (event: any) => void> = {};
vi.mocked(ipc.listenEvent).mockImplementation((event, handler) => {
handlers[event] = handler as (event: any) => void;
return Promise.resolve(vi.fn());
});
useDownloadStore.setState({
downloads: [{
id: 'resume-race',
url: 'https://example.com/file',
fileName: 'file.bin',
status: 'queued',
category: 'Other',
dateAdded: ''
}]
});
setDownloadControlIntent('resume-race', 'resume');
const release = await initDownloadListener();
handlers['download-state']({ payload: {
id: 'resume-race',
status: 'paused'
} });
expect(useDownloadStore.getState().downloads[0].status).toBe('queued');
handlers['download-state']({ payload: {
id: 'resume-race',
status: 'downloading'
} });
expect(useDownloadStore.getState().downloads[0].status).toBe('downloading');
release();
});
it('allows a later genuine pause after consuming the stale resume event', async () => {
const handlers: Record<string, (event: any) => void> = {};
vi.mocked(ipc.listenEvent).mockImplementation((event, handler) => {
handlers[event] = handler as (event: any) => void;
return Promise.resolve(vi.fn());
});
useDownloadStore.setState({
downloads: [{
id: 'resume-pause-race',
url: 'https://example.com/file',
fileName: 'file.bin',
status: 'queued',
category: 'Other',
dateAdded: ''
}]
});
setDownloadControlIntent('resume-pause-race', 'resume');
const release = await initDownloadListener();
handlers['download-state']({ payload: {
id: 'resume-pause-race',
status: 'paused'
} });
expect(useDownloadStore.getState().downloads[0].status).toBe('queued');
handlers['download-state']({ payload: {
id: 'resume-pause-race',
status: 'paused'
} });
expect(useDownloadStore.getState().downloads[0].status).toBe('paused');
release();
});
});
+80 -7
View File
@@ -5,7 +5,12 @@ import type { DownloadItem } from '../bindings/DownloadItem';
import { categoryForFileName } from '../utils/downloads';
import { useDownloadProgressStore } from './downloadProgressStore';
import { useDownloadStore } from './useDownloadStore';
import {
clearDownloadControlIntent,
downloadControlIntentFor,
hasStaleTemporaryMediaEstimate,
useDownloadStore
} from './useDownloadStore';
export { useDownloadProgressStore } from './downloadProgressStore';
@@ -55,6 +60,34 @@ const startDownloadListeners = async () => {
if (shouldUpdateSize && current.size !== payload.size) {
updates.size = payload.size!;
}
if (payload.downloaded_bytes !== null && payload.downloaded_bytes !== undefined) {
updates.downloadedBytes = payload.downloaded_bytes;
}
if (payload.total_bytes !== null && payload.total_bytes !== undefined) {
updates.totalBytes = payload.total_bytes;
}
if (payload.total_is_estimate !== null && payload.total_is_estimate !== undefined) {
updates.totalIsEstimate = payload.total_is_estimate;
}
const observedDownloadedBytes = Math.max(
current.downloadedBytes ?? 0,
payload.downloaded_bytes ?? 0
);
// Older lifecycles may have persisted yt-dlp's temporary fragmented
// estimate (often 1 KiB). Once actual bytes exceed it and the current
// progress frame has no reliable total, discard that stale denominator
// so it cannot survive a pause, queue transition, or app restart.
if (payload.total_bytes == null && hasStaleTemporaryMediaEstimate({
isMedia: current.isMedia,
downloadedBytes: observedDownloadedBytes,
totalBytes: current.totalBytes,
totalIsEstimate: current.totalIsEstimate,
size: current.size
})) {
updates.size = undefined;
updates.totalBytes = undefined;
updates.totalIsEstimate = undefined;
}
if (Object.keys(updates).length > 0) {
mainStore.updateDownload(payload.id, updates);
}
@@ -69,19 +102,62 @@ const startDownloadListeners = async () => {
}
const status = payload.status as DownloadStatus;
// Prevent race condition: don't transition backwards from terminal state
// resume_download queues the row before the backend can emit its new
// active state. A paused event already emitted by the old lifecycle may
// arrive in that gap. Do not let it overwrite the queued transition;
// otherwise the guard below would reject the legitimate downloading
// event and leave the row visibly paused forever.
if (status === 'paused' &&
current.status === 'queued' &&
downloadControlIntentFor(payload.id) === 'resume') {
// Consume only the stale pause event that caused the resume race.
// A later real pause must be allowed through even if the backend has
// not emitted a new active state yet.
clearDownloadControlIntent(payload.id, 'resume');
return;
}
if (status === 'downloading' || status === 'processing' ||
status === 'completed' || status === 'failed') {
clearDownloadControlIntent(payload.id, 'resume');
}
if (status === 'paused') {
clearDownloadControlIntent(payload.id, 'pause');
}
// Prevent stale lifecycle events from moving a paused row back into an
// active state. A pause request can finish before one already-emitted
// worker event reaches the frontend. Resume paths set the row to queued
// before asking the backend to resume, so an active event arriving while
// the row is still paused cannot represent a new lifecycle.
if ((current.status === 'completed' || current.status === 'failed') &&
status !== current.status) {
return;
}
if (current.status === 'paused' &&
status !== 'paused' &&
status !== 'completed' &&
status !== 'failed') {
return;
}
const progress = useDownloadProgressStore.getState().progressMap[payload.id];
if (['queued', 'retrying', 'completed', 'failed', 'paused'].includes(status)) {
useDownloadProgressStore.getState().clearDownloadProgress(payload.id);
}
const progress = useDownloadProgressStore.getState().progressMap[payload.id];
const updates: Partial<DownloadItem> = {
status,
...(progress ? { fraction: progress.fraction } : {}),
...(progress ? {
fraction: progress.fraction,
...(progress.downloaded_bytes != null
? { downloadedBytes: progress.downloaded_bytes }
: {}),
...(progress.total_bytes != null
? { totalBytes: progress.total_bytes }
: {}),
...(progress.total_is_estimate != null
? { totalIsEstimate: progress.total_is_estimate }
: {})
} : {}),
...(payload.error ? { lastError: payload.error } : {}),
...((status === 'downloading' || status === 'retrying')
? { lastTry: new Date().toISOString() }
@@ -113,9 +189,6 @@ const startDownloadListeners = async () => {
} else if (status === 'completed' || status === 'failed') {
mainStore.unregisterBackendIds([payload.id]);
}
if (['queued', 'retrying', 'completed', 'failed', 'paused'].includes(status)) {
useDownloadProgressStore.getState().clearDownloadProgress(payload.id);
}
}),
listen('tray-action', (event) => {
const mainStore = useDownloadStore.getState();
+372 -2
View File
@@ -1,5 +1,5 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { dispatchItem, getProxyArgs, getSiteLogin, normalizeCustomProxy, useDownloadStore } from './useDownloadStore';
import { dispatchItem, getProxyArgs, getSiteLogin, hasStaleTemporaryMediaEstimate, normalizeCustomProxy, normalizePersistedDownloadProgress, normalizePersistedQueueState, normalizePersistedQueues, useDownloadStore } from './useDownloadStore';
import { useDownloadProgressStore } from './downloadProgressStore';
import { useSettingsStore } from './useSettingsStore';
import * as ipc from '../ipc';
@@ -111,12 +111,187 @@ describe('useDownloadStore', () => {
expect(useDownloadStore.getState().pendingAddRequestVersion).toBe(initialVersion + 2);
});
it('replaces stale media intent when an appended handoff reuses a URL', () => {
useDownloadStore.getState().openAddModalWithUrls(
'https://example.com/file.bin', '', '', '', '', true
);
useDownloadStore.getState().openAddModalWithUrls(
'https://example.com/file.bin', '', '', '', '', false
);
const state = useDownloadStore.getState();
expect(state.pendingAddMediaUrls).toEqual([]);
expect(state.pendingAddRequestContexts['https://example.com/file.bin']?.media).toBe(false);
});
it('rejects empty and duplicate queue names', () => {
useDownloadStore.setState({
queues: [
{ id: 'main', name: 'Main Queue', isMain: true },
{ id: 'queue-a', name: 'Downloads', isMain: false }
]
});
expect(useDownloadStore.getState().addQueue('')).toBe(false);
expect(useDownloadStore.getState().addQueue(' downloads ')).toBe(false);
expect(useDownloadStore.getState().addQueue('Archive')).toBe(true);
expect(useDownloadStore.getState().renameQueue('queue-a', ' archive ')).toBe(false);
expect(useDownloadStore.getState().renameQueue('queue-a', '')).toBe(false);
});
it('normalizes malformed persisted queues around one canonical main queue', () => {
expect(normalizePersistedQueues([
{ id: 'custom-a', name: ' Downloads ', isMain: false },
{ id: 'custom-b', name: 'downloads', isMain: false },
{ id: 'custom-a', name: 'Duplicate ID', isMain: false },
{ id: 'legacy-main', name: 'Primary', isMain: true },
{ id: 'empty-name', name: ' ', isMain: false },
{ id: 'main-id', name: 'Ignored Main', isMain: true }
])).toEqual([
{ id: '00000000-0000-0000-0000-000000000001', name: 'Primary', isMain: true },
{ id: 'custom-a', name: 'Downloads', isMain: false },
{ id: 'custom-b', name: 'downloads (2)', isMain: false },
{ id: 'empty-name', name: 'Queue empty-na', isMain: false }
]);
expect(normalizePersistedQueueState([
{ id: 'legacy-main', name: 'Primary', isMain: true }
]).queueIdRemap.get('legacy-main')).toBe('00000000-0000-0000-0000-000000000001');
});
it('remaps persisted downloads when queue records are malformed or missing', async () => {
vi.mocked(ipc.invokeCommand).mockImplementation(async (cmd: string) => {
if (cmd === 'db_get_all_queues') {
return [JSON.stringify({ id: 'legacy-main', name: 'Primary', isMain: true })];
}
if (cmd === 'db_get_all_downloads') {
return [
JSON.stringify({
id: 'legacy-download',
url: 'https://example.com/legacy.bin',
fileName: 'legacy.bin',
status: 'ready',
category: 'Other',
dateAdded: '',
queueId: 'legacy-main'
}),
JSON.stringify({
id: 'orphan-download',
url: 'https://example.com/orphan.bin',
fileName: 'orphan.bin',
status: 'ready',
category: 'Other',
dateAdded: '',
queueId: 'missing-queue'
})
];
}
return undefined;
});
await useDownloadStore.getState().initDB();
expect(useDownloadStore.getState().downloads.map(download => download.queueId))
.toEqual(['00000000-0000-0000-0000-000000000001', '00000000-0000-0000-0000-000000000001']);
});
it('removes persisted temporary media estimates that are smaller than downloaded bytes', async () => {
vi.mocked(ipc.invokeCommand).mockImplementation(async (cmd: string) => {
if (cmd === 'db_get_all_queues') return [];
if (cmd === 'db_get_all_downloads') {
return [JSON.stringify({
id: 'stale-media-estimate',
url: 'https://youtube.com/watch?v=stale',
fileName: 'video.mkv',
status: 'queued',
category: 'Movies',
dateAdded: '',
queueId: '00000000-0000-0000-0000-000000000001',
isMedia: true,
size: '~1.00 KB',
downloadedBytes: 11_989,
totalBytes: 1_024,
totalIsEstimate: true
})];
}
return undefined;
});
await useDownloadStore.getState().initDB();
expect(useDownloadStore.getState().downloads[0]).toMatchObject({
size: undefined,
downloadedBytes: 11_989,
totalBytes: undefined,
totalIsEstimate: undefined
});
});
it('does not discard a legitimate large media estimate when downloaded bytes exceed it', () => {
const media = {
isMedia: true,
downloadedBytes: 90_000_000,
totalBytes: 89_817_907,
totalIsEstimate: true,
size: '~85.7 MB'
} as const;
expect(hasStaleTemporaryMediaEstimate(media)).toBe(false);
expect(normalizePersistedDownloadProgress({
id: 'large-estimate',
url: 'https://youtube.com/watch?v=large',
fileName: 'video.mkv',
status: 'queued',
category: 'Movies',
dateAdded: '',
...media
})).toMatchObject({
size: '~85.7 MB',
downloadedBytes: 90_000_000,
totalBytes: 89_817_907,
totalIsEstimate: true
});
});
it('does not discard a legitimate small media estimate without contradictory progress', () => {
const media = {
isMedia: true,
size: '~500 B',
downloadedBytes: 500,
totalBytes: undefined,
totalIsEstimate: true
} as const;
expect(hasStaleTemporaryMediaEstimate(media)).toBe(false);
expect(normalizePersistedDownloadProgress({
id: 'small-media',
url: 'https://youtube.com/watch?v=small',
fileName: 'short.mkv',
status: 'queued',
category: 'Movies',
dateAdded: '',
...media
})).toMatchObject(media);
});
it('recognizes IEC-formatted temporary media estimates', () => {
expect(hasStaleTemporaryMediaEstimate({
isMedia: true,
size: '~1.00 KiB',
downloadedBytes: 2_048,
totalBytes: undefined,
totalIsEstimate: true
})).toBe(true);
});
it('normalizes proxy settings for download dispatch', async () => {
expect(normalizeCustomProxy('127.0.0.1', 8080)).toBe('http://127.0.0.1:8080');
expect(normalizeCustomProxy('http://proxy.local:9000', 8080)).toBe('http://proxy.local:9000');
expect(normalizeCustomProxy(' socks5://127.0.0.1 ', 1080)).toBeNull();
expect(normalizeCustomProxy('https://proxy.local', 8443)).toBeNull();
expect(normalizeCustomProxy('127.0.0.1', NaN)).toBeNull();
expect(normalizeCustomProxy('127.0.0.1:9000', 8080)).toBeNull();
expect(normalizeCustomProxy('127.0.0.1/path', 8080)).toBeNull();
expect(normalizeCustomProxy('[::1]', 8080)).toBe('http://[::1]:8080');
expect(await getProxyArgs({
proxyMode: 'none',
@@ -131,6 +306,15 @@ describe('useDownloadStore', () => {
proxyPort: 8080
} as ReturnType<typeof useSettingsStore.getState>)).toBe('none');
vi.mocked(ipc.invokeCommand).mockRejectedValueOnce(new Error('system settings unavailable'));
await expect(getProxyArgs({
proxyMode: 'system',
proxyHost: '',
proxyPort: 8080
} as ReturnType<typeof useSettingsStore.getState>)).rejects.toThrow(
'System proxy configuration could not be read: system settings unavailable'
);
expect(await getProxyArgs({
proxyMode: 'custom',
proxyHost: 'http://127.0.0.1',
@@ -138,6 +322,39 @@ describe('useDownloadStore', () => {
} as ReturnType<typeof useSettingsStore.getState>)).toBe('http://127.0.0.1:1080');
});
it('keeps an item queued when system proxy resolution fails closed', async () => {
vi.mocked(useSettingsStore.getState).mockReturnValue({
...useSettingsStore.getState(),
proxyMode: 'system'
} as unknown as ReturnType<typeof useSettingsStore.getState>);
vi.mocked(ipc.invokeCommand).mockImplementation(async (command: string) => {
if (command === 'get_system_proxy') {
throw new Error('system settings unavailable');
}
return undefined;
});
useDownloadStore.setState({
downloads: [{
id: 'system-proxy-blocked',
url: 'https://example.com/file.bin',
fileName: 'file.bin',
destination: '/tmp',
status: 'queued',
category: 'Other',
dateAdded: ''
}] as any[],
backendRegisteredIds: new Set()
});
await expect(dispatchItem('system-proxy-blocked')).resolves.toBe(false);
expect(useDownloadStore.getState().downloads[0]).toMatchObject({
status: 'queued',
lastError: 'System proxy configuration could not be read: system settings unavailable. Choose No Proxy or try again.'
});
expect(ipc.invokeCommand).not.toHaveBeenCalledWith('enqueue_download', expect.anything());
});
it('matches site logins by host, wildcard host, path, and full URL patterns', () => {
const settings = {
siteLogins: [
@@ -454,6 +671,25 @@ describe('useDownloadStore', () => {
expect(ipc.invokeCommand).not.toHaveBeenCalledWith('enqueue_download', expect.anything());
});
it('carries a media format estimate into numeric progress state', async () => {
await useDownloadStore.getState().addDownload({
id: 'media-estimate',
url: 'https://youtube.com/watch?v=estimate',
fileName: 'video.mkv',
category: 'Movies',
dateAdded: '',
isMedia: true,
size: '~85.7 MB',
sizeBytes: 89_817_907
}, { type: 'add-to-queue', queueId: 'queue-b' });
expect(useDownloadStore.getState().downloads[0]).toMatchObject({
totalBytes: 89_817_907,
totalIsEstimate: true
});
expect(useDownloadStore.getState().downloads[0]).not.toHaveProperty('sizeBytes');
});
it('starts immediately in the main queue', async () => {
vi.mocked(ipc.invokeCommand).mockImplementation(async (cmd: string) => {
if (cmd === 'get_pending_order') return ['start-1'];
@@ -510,7 +746,7 @@ describe('useDownloadStore', () => {
);
});
it('uses the global speed limit only when an item has no explicit speed override', async () => {
it('does not copy the global speed limit into a normal download task', async () => {
const defaultSettings = useSettingsStore.getState();
vi.mocked(useSettingsStore.getState).mockReturnValue({
...defaultSettings,
@@ -534,6 +770,69 @@ describe('useDownloadStore', () => {
expect.objectContaining({
item: expect.objectContaining({
id: 'inherits-global',
speed_limit: null
})
})
);
});
it('passes the global speed limit to a new media process when it has no item override', async () => {
const defaultSettings = useSettingsStore.getState();
vi.mocked(useSettingsStore.getState).mockReturnValue({
...defaultSettings,
globalSpeedLimit: '2M'
});
vi.mocked(ipc.invokeCommand).mockImplementation(async (cmd: string) => {
if (cmd === 'get_pending_order') return ['inherits-global-media'];
return undefined;
});
await useDownloadStore.getState().addDownload({
id: 'inherits-global-media',
url: 'https://www.youtube.com/watch?v=example',
fileName: 'media.mp4',
category: 'Movies',
dateAdded: '',
isMedia: true
}, { type: 'start-now' });
expect(ipc.invokeCommand).toHaveBeenCalledWith(
'enqueue_download',
expect.objectContaining({
item: expect.objectContaining({
id: 'inherits-global-media',
speed_limit: '2M'
})
})
);
});
it('treats the legacy media zero sentinel as inheriting the global limit', async () => {
const defaultSettings = useSettingsStore.getState();
vi.mocked(useSettingsStore.getState).mockReturnValue({
...defaultSettings,
globalSpeedLimit: '2M'
});
vi.mocked(ipc.invokeCommand).mockImplementation(async (cmd: string) => {
if (cmd === 'get_pending_order') return ['legacy-media-limit'];
return undefined;
});
await useDownloadStore.getState().addDownload({
id: 'legacy-media-limit',
url: 'https://www.youtube.com/watch?v=legacy',
fileName: 'media.mp4',
category: 'Movies',
dateAdded: '',
isMedia: true,
speedLimit: '0'
}, { type: 'start-now' });
expect(ipc.invokeCommand).toHaveBeenCalledWith(
'enqueue_download',
expect.objectContaining({
item: expect.objectContaining({
id: 'legacy-media-limit',
speed_limit: '2M'
})
})
@@ -625,6 +924,41 @@ describe('useDownloadStore', () => {
});
});
it('keeps all startup items retryable when system proxy resolution fails', async () => {
vi.mocked(useSettingsStore.getState).mockReturnValue({
...useSettingsStore.getState(),
proxyMode: 'system'
} as unknown as ReturnType<typeof useSettingsStore.getState>);
vi.mocked(ipc.invokeCommand).mockImplementation(async (command: string) => {
if (command === 'db_get_all_queues') return [];
if (command === 'db_get_all_downloads') {
return [JSON.stringify({
id: 'startup-proxy-blocked',
url: 'https://example.com/file.bin',
fileName: 'file.bin',
status: 'queued',
category: 'Other',
dateAdded: '',
queueId: '00000000-0000-0000-0000-000000000001',
hasBeenDispatched: true
})];
}
if (command === 'get_system_proxy') {
throw new Error('system settings unavailable');
}
return undefined;
});
await useDownloadStore.getState().initDB();
await useDownloadStore.getState().resumePendingDownloads();
expect(useDownloadStore.getState().downloads[0]).toMatchObject({
status: 'queued',
lastError: 'System proxy configuration could not be read: system settings unavailable. Choose No Proxy or try again.'
});
expect(ipc.invokeCommand).not.toHaveBeenCalledWith('enqueue_many', expect.anything());
});
it('keeps accepted startup registrations when pending-order refresh fails', async () => {
vi.mocked(ipc.invokeCommand).mockImplementation(async (cmd: string) => {
if (cmd === 'db_get_all_queues') return [];
@@ -1172,6 +1506,10 @@ describe('useDownloadStore', () => {
filename: 'file.bin',
headers: 'X-Test: value',
cookies: 'session=secret',
cookie_scopes: [
{ url: 'https://mail.google.com/', cookies: 'SID=mail-session' },
{ url: 'https://accounts.google.com/', cookies: 'SID=account-session' }
],
media: false
});
@@ -1182,6 +1520,10 @@ describe('useDownloadStore', () => {
expect(state.pendingAddFilename).toBe('file.bin');
expect(state.pendingAddHeaders).toBe('X-Test: value');
expect(state.pendingAddCookies).toBe('session=secret');
expect(state.pendingAddRequestContexts['https://example.com/file.bin'].cookieScopes).toEqual([
{ url: 'https://mail.google.com/', cookies: 'SID=mail-session' },
{ url: 'https://accounts.google.com/', cookies: 'SID=account-session' }
]);
expect(state.pendingAddMediaUrls).toEqual([]);
});
@@ -1203,6 +1545,7 @@ describe('useDownloadStore', () => {
filename: null,
headers: 'User-Agent: Firefox Test',
cookies: null,
cookie_scopes: null,
media: false
});
@@ -1223,6 +1566,7 @@ describe('useDownloadStore', () => {
filename: 'report.pdf',
headers: 'User-Agent: Test',
cookies: 'session=secret',
cookie_scopes: null,
media: false
});
@@ -1244,6 +1588,7 @@ describe('useDownloadStore', () => {
filename: 'first.zip',
headers: 'User-Agent: First Browser',
cookies: 'first=session',
cookie_scopes: null,
media: false
});
await useDownloadStore.getState().handleExtensionDownload({
@@ -1253,6 +1598,7 @@ describe('useDownloadStore', () => {
filename: 'second.zip',
headers: 'User-Agent: Second Browser',
cookies: 'second=session',
cookie_scopes: null,
media: false
});
@@ -1289,6 +1635,7 @@ describe('useDownloadStore', () => {
filename: null,
headers: `Cookie: stale=${'x'.repeat(64 * 1024)}\nUser-Agent: Firefox Test`,
cookies: `oversized=${'x'.repeat(64 * 1024)}`,
cookie_scopes: null,
media: true
});
@@ -1308,12 +1655,31 @@ describe('useDownloadStore', () => {
filename: 'private.zip',
headers: null,
cookies: 'session=secret',
cookie_scopes: null,
media: false
});
expect(useDownloadStore.getState().pendingAddCookies).toBe('session=secret');
});
it('drops extension cookie scopes for explicit media captures', async () => {
await useDownloadStore.getState().handleExtensionDownload({
urls: ['https://media.example/watch/123'],
referer: 'https://media.example/watch/123',
silent: true,
filename: null,
headers: null,
cookies: null,
cookie_scopes: [
{ url: 'https://media.example/', cookies: 'session=secret' }
],
media: true
});
expect(useDownloadStore.getState().pendingAddRequestContexts['https://media.example/watch/123']?.cookieScopes)
.toBeUndefined();
});
it('clears stale request context when the same URL is captured without it later', async () => {
const url = 'https://example.com/file.zip';
await useDownloadStore.getState().handleExtensionDownload({
@@ -1323,6 +1689,7 @@ describe('useDownloadStore', () => {
filename: 'private.zip',
headers: 'Authorization: secret',
cookies: 'session=secret',
cookie_scopes: null,
media: false
});
await useDownloadStore.getState().handleExtensionDownload({
@@ -1332,6 +1699,7 @@ describe('useDownloadStore', () => {
filename: null,
headers: null,
cookies: null,
cookie_scopes: null,
media: false
});
@@ -1359,6 +1727,7 @@ describe('useDownloadStore', () => {
filename: null,
headers: 'User-Agent: Firefox Test',
cookies: 'session=secret',
cookie_scopes: null,
media: true
});
@@ -1378,6 +1747,7 @@ describe('useDownloadStore', () => {
filename: 'file.bin',
headers: 'User-Agent: Firefox Test',
cookies: null,
cookie_scopes: null,
media: false
});
+325 -41
View File
@@ -5,10 +5,11 @@ import { invokeCommand as invoke } from '../ipc';
import type { DownloadItem } from '../bindings/DownloadItem';
import type { DownloadStatus } from '../bindings/DownloadStatus';
import type { ExtensionDownload } from '../bindings/ExtensionDownload';
import type { ExtensionCookieScope } from '../bindings/ExtensionCookieScope';
import type { Queue } from '../bindings/Queue';
import { useSettingsStore } from './useSettingsStore';
import { useDownloadProgressStore } from './downloadProgressStore';
import { categoryForFileName, isActiveDownloadStatus, isTransferActiveStatus, normalizeSpeedLimitForBackend, redactDownloadForPersistence } from '../utils/downloads';
import { categoryForFileName, isActiveDownloadStatus, isTransferActiveStatus, normalizeSpeedLimitForBackend, redactDownloadForPersistence, resolveDownloadConnections } from '../utils/downloads';
import {
resolveCategoryDestination
} from '../utils/downloadLocations';
@@ -23,6 +24,29 @@ const queueStartPromises = new Map<string, Promise<string[]>>();
const queueControlGenerations = new Map<string, number>();
let pendingStartupResume: Promise<void> | null = null;
type DownloadControlIntent = 'pause' | 'resume';
const downloadControlIntents = new Map<string, DownloadControlIntent>();
// State events do not carry a lifecycle generation. Keep the intent that
// initiated a control transition long enough for the listener to discard an
// already-emitted event from the previous transition.
export const setDownloadControlIntent = (id: string, intent: DownloadControlIntent): void => {
downloadControlIntents.set(id, intent);
};
export const clearDownloadControlIntent = (id: string, intent?: DownloadControlIntent): void => {
if (intent === undefined || downloadControlIntents.get(id) === intent) {
downloadControlIntents.delete(id);
}
};
export const downloadControlIntentFor = (id: string): DownloadControlIntent | undefined =>
downloadControlIntents.get(id);
export const clearDownloadControlIntents = (): void => {
downloadControlIntents.clear();
};
const waitForPendingStartupResume = async (): Promise<void> => {
const pending = pendingStartupResume;
if (pending) await pending.catch(() => undefined);
@@ -124,6 +148,16 @@ const removeStaleBackendDispatch = async (id: string): Promise<void> => {
const errorMessage = (error: unknown): string =>
error instanceof Error ? error.message : String(error);
export class SystemProxyResolutionError extends Error {
constructor(reason: string) {
super(`System proxy configuration could not be read: ${reason}. Choose No Proxy or try again.`);
this.name = 'SystemProxyResolutionError';
}
}
const isSystemProxyConfigurationError = (error: unknown): boolean =>
error instanceof SystemProxyResolutionError;
const stripCookieHeaders = (value: string | null | undefined): string =>
(value || '')
.split(/\r?\n/)
@@ -134,15 +168,31 @@ const stripCookieHeaders = (value: string | null | undefined): string =>
.join('\n')
.trim();
const speedLimitForDispatch = (itemSpeedLimit: string | undefined, globalSpeedLimit: string): string | null => {
const explicitSpeedLimitForDispatch = (itemSpeedLimit: string | undefined): string | null => {
const explicitLimit = itemSpeedLimit?.trim();
if (explicitLimit) {
return normalizeSpeedLimitForBackend(explicitLimit) || (explicitLimit === '0' ? '0' : null);
}
return null;
};
const speedLimitForDispatch = (
itemSpeedLimit: string | undefined,
globalSpeedLimit: string,
isMedia: boolean | undefined
): string | null => {
// Older Add-window rows used "0" as the no-override sentinel. Media
// downloads do not have aria2's daemon-wide cap, so preserve the intended
// inherit-global behavior when dispatching those persisted rows.
if (isMedia && itemSpeedLimit?.trim() === '0') {
return normalizeSpeedLimitForBackend(globalSpeedLimit);
}
const explicitLimit = explicitSpeedLimitForDispatch(itemSpeedLimit);
if (explicitLimit !== null || !isMedia) return explicitLimit;
return normalizeSpeedLimitForBackend(globalSpeedLimit);
};
export async function dispatchItem(id: string): Promise<boolean> {
export async function dispatchItem(id: string, proxyOverride?: string | null): Promise<boolean> {
await waitForPendingStartupResume();
if (backendDispatchPromises.has(id)) return backendDispatchPromises.get(id)!;
@@ -176,7 +226,9 @@ export async function dispatchItem(id: string): Promise<boolean> {
}
if (!isCurrentDownloadLifecycle(id, lifecycleGeneration)) return false;
const proxy = await getProxyArgs(settings);
const proxy = proxyOverride === undefined
? await getProxyArgs(settings)
: proxyOverride;
if (!isCurrentDownloadLifecycle(id, lifecycleGeneration)) return false;
const enqueueItem = {
@@ -185,8 +237,8 @@ export async function dispatchItem(id: string): Promise<boolean> {
url: item.url,
destination,
filename: item.fileName,
connections: item.connections || settings.perServerConnections || null,
speed_limit: speedLimitForDispatch(item.speedLimit, settings.globalSpeedLimit),
connections: resolveDownloadConnections(item.connections, settings.perServerConnections),
speed_limit: speedLimitForDispatch(item.speedLimit, settings.globalSpeedLimit, item.isMedia),
username: item.username || (login ? login.username : null),
password: item.password || keychainPassword,
headers: item.headers || null,
@@ -235,8 +287,9 @@ export async function dispatchItem(id: string): Promise<boolean> {
await removeStaleBackendDispatch(id);
}
if (lifecycleGeneration !== null && isCurrentDownloadLifecycle(id, lifecycleGeneration)) {
const proxyBlocked = isSystemProxyConfigurationError(e);
useDownloadStore.getState().updateDownload(id, {
status: 'failed',
status: proxyBlocked ? 'queued' : 'failed',
lastError: errorMessage(e)
});
}
@@ -259,6 +312,7 @@ export const normalizeCustomProxy = (host: string, port: number): string | null
try {
const parsed = new URL(trimmedHost);
if (parsed.protocol !== 'http:') return null;
if (!parsed.hostname) return null;
if (!parsed.port) parsed.port = String(normalizedPort);
return parsed.toString().replace(/\/$/, '');
} catch {
@@ -266,7 +320,24 @@ export const normalizeCustomProxy = (host: string, port: number): string | null
}
}
return `http://${trimmedHost}:${normalizedPort}`;
try {
const parsed = new URL(`http://${trimmedHost}:${normalizedPort}`);
if (
!parsed.hostname
|| parsed.username
|| parsed.password
|| parsed.pathname !== '/'
|| parsed.search
|| parsed.hash
|| (parsed.port && Number(parsed.port) !== normalizedPort)
|| (!parsed.port && normalizedPort !== 80)
) {
return null;
}
return `http://${trimmedHost}:${normalizedPort}`;
} catch {
return null;
}
};
export const getProxyArgs = async (settings: ReturnType<typeof useSettingsStore.getState>) => {
@@ -275,8 +346,8 @@ export const getProxyArgs = async (settings: ReturnType<typeof useSettingsStore.
const sysProxy = await invoke('get_system_proxy');
return typeof sysProxy === 'string' && sysProxy ? sysProxy : "none";
} catch (e) {
console.warn("Failed to get system proxy:", e);
return "none";
const reason = e instanceof Error ? e.message : String(e);
throw new SystemProxyResolutionError(reason);
}
}
if (settings.proxyMode === 'custom') {
@@ -376,21 +447,127 @@ const normalizeQueuePositions = (downloads: DownloadItem[]): DownloadItem[] => {
});
};
const TEMPORARY_MEDIA_ESTIMATE_MAX_BYTES = 1024;
const DISPLAYED_SIZE_UNIT_MULTIPLIERS: Record<string, number> = {
B: 1,
KB: 1024,
KIB: 1024,
MB: 1024 ** 2,
MIB: 1024 ** 2,
GB: 1024 ** 3,
GIB: 1024 ** 3,
TB: 1024 ** 4,
TIB: 1024 ** 4
};
const displayedSizeBytes = (size: string | undefined): number | undefined => {
const match = size?.trim().match(/^~\s*([0-9]+(?:\.[0-9]+)?)\s*(B|KB|KIB|MB|MIB|GB|GIB|TB|TIB)$/i);
if (!match) return undefined;
const bytes = Number(match[1]) * DISPLAYED_SIZE_UNIT_MULTIPLIERS[match[2].toUpperCase()];
return Number.isFinite(bytes) ? bytes : undefined;
};
export const hasStaleTemporaryMediaEstimate = (
download: Pick<DownloadItem, 'isMedia' | 'downloadedBytes' | 'totalBytes' | 'totalIsEstimate' | 'size'>
): boolean => {
if (download.isMedia !== true) return false;
const hasImpossibleNumericEstimate = download.totalIsEstimate === true &&
typeof download.totalBytes === 'number' &&
Number.isFinite(download.totalBytes) &&
download.totalBytes > 0 &&
download.totalBytes <= TEMPORARY_MEDIA_ESTIMATE_MAX_BYTES &&
typeof download.downloadedBytes === 'number' &&
Number.isFinite(download.downloadedBytes) &&
download.downloadedBytes > download.totalBytes;
const visibleEstimateBytes = displayedSizeBytes(download.size);
const hasImpossibleVisibleEstimate = visibleEstimateBytes !== undefined &&
visibleEstimateBytes <= TEMPORARY_MEDIA_ESTIMATE_MAX_BYTES &&
typeof download.downloadedBytes === 'number' &&
Number.isFinite(download.downloadedBytes) &&
download.downloadedBytes > visibleEstimateBytes &&
(download.totalBytes == null || download.totalBytes <= TEMPORARY_MEDIA_ESTIMATE_MAX_BYTES);
return hasImpossibleNumericEstimate || hasImpossibleVisibleEstimate;
};
export const normalizePersistedDownloadProgress = (download: DownloadItem): DownloadItem =>
hasStaleTemporaryMediaEstimate(download)
? {
...download,
// The old lifecycle could persist yt-dlp's temporary HLS estimate as
// both the numeric denominator and the visible size. Neither value is
// recoverable after the fact, so remove the false claim on startup.
size: undefined,
totalBytes: undefined,
totalIsEstimate: undefined
}
: download;
export type { DownloadStatus };
export const MAIN_QUEUE_ID = '00000000-0000-0000-0000-000000000001';
const DEFAULT_MAIN_QUEUE_NAME = 'Main Queue';
const queueNameKey = (name: string): string => name.trim().toLowerCase();
export const normalizePersistedQueueState = (queues: Queue[]) => {
const validQueues = queues.filter(queue =>
queue && typeof queue.id === 'string' && typeof queue.name === 'string'
);
const persistedMain = validQueues.find(queue => queue.id === MAIN_QUEUE_ID)
|| validQueues.find(queue => queue.isMain);
const persistedMainId = persistedMain?.id.trim();
const mainName = persistedMain?.name.trim() || DEFAULT_MAIN_QUEUE_NAME;
const normalized: Queue[] = [{ id: MAIN_QUEUE_ID, name: mainName, isMain: true }];
const seenIds = new Set([MAIN_QUEUE_ID]);
const seenNames = new Set([queueNameKey(mainName)]);
const queueIdRemap = new Map<string, string>();
if (persistedMainId && persistedMainId !== MAIN_QUEUE_ID) {
queueIdRemap.set(persistedMainId, MAIN_QUEUE_ID);
}
for (const queue of validQueues) {
const id = queue.id.trim();
if (!id || id === MAIN_QUEUE_ID || id === persistedMainId || seenIds.has(id)) continue;
if (queue.isMain) {
queueIdRemap.set(id, MAIN_QUEUE_ID);
continue;
}
let name = queue.name.trim() || `Queue ${id.slice(0, 8)}`;
const baseName = name;
let suffix = 2;
while (seenNames.has(queueNameKey(name))) {
name = `${baseName} (${suffix})`;
suffix += 1;
}
seenIds.add(id);
seenNames.add(queueNameKey(name));
normalized.push({ id, name, isMain: false });
}
return { queues: normalized, queueIdRemap };
};
export const normalizePersistedQueues = (queues: Queue[]): Queue[] =>
normalizePersistedQueueState(queues).queues;
export type { DownloadItem, Queue };
export type ExtensionDownloadRequest = ExtensionDownload;
export type AddDownloadAction =
| { type: 'start-now' }
| { type: 'add-to-queue'; queueId: string };
export type DownloadDraft = Omit<DownloadItem, 'status' | 'queueId' | 'hasBeenDispatched'>;
export type DownloadDraft = Omit<DownloadItem, 'status' | 'queueId' | 'hasBeenDispatched'> & {
/** Numeric format estimate supplied by the media Add window. */
sizeBytes?: number;
};
export type PendingAddRequestContext = {
version: number;
referer: string;
filename: string;
headers: string;
cookies: string;
cookieScopes?: ExtensionCookieScope[];
media: boolean;
};
@@ -427,7 +604,8 @@ interface DownloadState {
filename?: string | null,
headers?: string | null,
cookies?: string | null,
media?: boolean
media?: boolean,
cookieScopes?: ExtensionCookieScope[] | null
) => void;
handleExtensionDownload: (request: ExtensionDownloadRequest) => Promise<void>;
deleteModalState: DeleteModalState;
@@ -445,8 +623,8 @@ interface DownloadState {
startAll: () => Promise<number>;
pauseAll: () => Promise<number>;
assignToQueue: (ids: string[], queueId: string) => Promise<void>;
addQueue: (name: string) => void;
renameQueue: (id: string, name: string) => void;
addQueue: (name: string) => boolean;
renameQueue: (id: string, name: string) => boolean;
removeQueue: (id: string) => Promise<void>;
resumePendingDownloads: () => Promise<void>;
initDB: () => Promise<void>;
@@ -592,21 +770,20 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
// opened or closed without URLs.
pendingAddRequestVersion: state.pendingAddRequestVersion + 1
})),
openAddModalWithUrls: (urls, referer, filename, headers, cookies, media = false) => set((state) => {
openAddModalWithUrls: (urls, referer, filename, headers, cookies, media = false, cookieScopes) => set((state) => {
const isAppending = state.isAddModalOpen && Boolean(state.pendingAddUrls);
const existingUrls = isAppending ? state.pendingAddUrls : '';
const mergedUrls = existingUrls ? `${existingUrls}\n${urls}` : urls;
const existingMediaUrls = isAppending ? state.pendingAddMediaUrls : [];
const pendingAddMediaUrls = media
? [...new Set([
...existingMediaUrls,
...urls.split('\n').map(url => url.trim()).filter(Boolean)
])]
: existingMediaUrls;
const cleanReferer = referer?.trim() || '';
const cleanFilename = filename?.trim() || '';
const cleanHeaders = headers?.trim() || '';
const cleanCookies = cookies?.trim() || '';
const cleanCookieScopes = cookieScopes
?.map(scope => ({
url: scope.url.trim(),
cookies: scope.cookies.trim()
}))
.filter(scope => scope.url && scope.cookies);
const requestVersion = state.pendingAddRequestVersion + 1;
const pendingAddRequestContexts = isAppending
? { ...state.pendingAddRequestContexts }
@@ -630,9 +807,13 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
filename: cleanFilename,
headers: cleanHeaders,
cookies: cleanCookies,
...(cleanCookieScopes?.length ? { cookieScopes: cleanCookieScopes } : {}),
media
};
}
const pendingAddMediaUrls = Object.entries(pendingAddRequestContexts)
.filter(([, context]) => context.media)
.map(([url]) => url);
return {
isAddModalOpen: true,
pendingAddUrls: mergedUrls,
@@ -663,7 +844,8 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
urls.length === 1 ? request.filename : null,
headers,
cookies,
request.media === true
request.media === true,
request.media === true ? undefined : request.cookie_scopes
);
},
setSelectedPropertiesDownloadId: (id) => set({ selectedPropertiesDownloadId: id }),
@@ -676,8 +858,14 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
);
const maxPos = queueItems.reduce((max, d) => Math.max(max, d.queuePosition ?? 0), -1);
const queuePosition = maxPos + 1;
const { sizeBytes, ...downloadDraft } = item;
const ownedItem: DownloadItem = {
...item,
...downloadDraft,
totalBytes: item.totalBytes ?? sizeBytes,
totalIsEstimate: item.totalIsEstimate ?? (
item.isMedia === true && item.size?.trim().startsWith('~')
),
connections: resolveDownloadConnections(item.connections, settings.perServerConnections),
destination: destPath,
status: action.type === 'add-to-queue' ? 'staged' : 'ready',
queueId,
@@ -772,6 +960,7 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
},
removeDownload: async (id, deleteFile = false, preserveResumable = false) => {
await waitForPendingStartupResume();
clearDownloadControlIntent(id);
const { pendingDispatch } = await invalidateDispatch(id);
if (pendingDispatch) {
await pendingDispatch;
@@ -799,17 +988,23 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
},
pauseDownload: async (id) => {
await waitForPendingStartupResume();
if (!get().downloads.some(download => download.id === id)) return;
setDownloadControlIntent(id, 'pause');
const { generation, pendingDispatch } = await invalidateDispatch(id);
if (pendingDispatch) {
await pendingDispatch;
}
try {
if (pendingDispatch) {
await pendingDispatch;
}
await invoke('pause_download', { id });
await invoke('pause_download', { id });
if (!isCurrentDownloadLifecycle(id, generation)) return;
const current = get().downloads.find(download => download.id === id);
if (current && current.status !== 'completed' && current.status !== 'failed') {
get().updateDownload(id, { status: 'paused', speed: '-', eta: '-' });
if (!isCurrentDownloadLifecycle(id, generation)) return;
const current = get().downloads.find(download => download.id === id);
if (current && current.status !== 'completed' && current.status !== 'failed') {
get().updateDownload(id, { status: 'paused', speed: '-', eta: '-' });
}
} finally {
clearDownloadControlIntent(id, 'pause');
}
},
redownload: async (id) => {
@@ -826,6 +1021,7 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
const url = targetItem.url?.trim();
if (!url) throw new Error('Cannot redownload: original URL is missing.');
setDownloadControlIntent(id, 'resume');
await invalidateAndWaitForDispatch(id);
// Remove from backend to clear its state and delete the existing file so we can overwrite
@@ -834,6 +1030,7 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
get().unregisterBackendIds([id]);
} catch (e) {
console.warn("Could not remove old download from backend", e);
clearDownloadControlIntent(id, 'resume');
throw e;
}
@@ -842,6 +1039,9 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
fraction: 0,
speed: '-',
eta: '-',
downloadedBytes: undefined,
totalBytes: undefined,
totalIsEstimate: undefined,
hasBeenDispatched: false,
dateAdded: new Date().toISOString()
});
@@ -849,6 +1049,7 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
if (!await dispatchItem(id)) {
console.error("Failed to enqueue redownload");
get().updateDownload(id, { status: 'failed' });
clearDownloadControlIntent(id, 'resume');
} else {
get().updateDownload(id, { hasBeenDispatched: true });
info(`Download ${id} redownloaded (queued)`);
@@ -859,6 +1060,7 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
const targetItem = get().downloads.find(d => d.id === id);
if (!targetItem) return false;
setDownloadControlIntent(id, 'resume');
try {
if (targetItem.status === 'ready' || targetItem.status === 'staged') {
get().updateDownload(id, { status: 'queued', hasBeenDispatched: true });
@@ -866,6 +1068,7 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
return true;
}
get().updateDownload(id, { status: targetItem.status });
clearDownloadControlIntent(id, 'resume');
return false;
}
@@ -900,11 +1103,13 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
} else {
console.error("Failed to re-enqueue for resume");
get().updateDownload(id, { status: prevStatus });
clearDownloadControlIntent(id, 'resume');
return false;
}
} catch (e) {
console.error("Failed to resume download:", e);
get().updateDownload(id, { status: targetItem.status });
clearDownloadControlIntent(id, 'resume');
return false;
}
},
@@ -919,6 +1124,39 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
if (runnable.length === 0 || !isCurrentQueueControlGeneration(queueId, requestedGeneration)) return [];
const needsNewDispatch = runnable.some(item => {
const currentItem = get().downloads.find(download => download.id === item.id);
if (!currentItem) return false;
const backendRegistered = get().backendRegisteredIds.has(item.id);
const backendPending = get().pendingOrder.includes(item.id);
if (currentItem.status === 'queued' && backendRegistered && !backendPending) {
return false;
}
return currentItem.status === 'ready' ||
currentItem.status === 'staged' ||
currentItem.status === 'failed' ||
!currentItem.hasBeenDispatched ||
!backendRegistered;
});
let queueProxy: string | null | undefined;
if (needsNewDispatch) {
try {
queueProxy = await getProxyArgs(useSettingsStore.getState());
} catch (error) {
const message = errorMessage(error);
console.error(`Could not safely resolve the proxy for queue ${queueId}:`, error);
const runnableIds = new Set(runnable.map(item => item.id));
set(state => ({
downloads: state.downloads.map(item =>
runnableIds.has(item.id) && item.status !== 'completed'
? { ...item, lastError: message }
: item
)
}));
return [];
}
}
const acceptedIds: string[] = [];
for (const item of runnable) {
if (!isCurrentQueueControlGeneration(queueId, requestedGeneration)) break;
@@ -942,7 +1180,7 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
!currentItem.hasBeenDispatched ||
!backendRegistered
) {
if (await dispatchItem(item.id)) {
if (await dispatchItem(item.id, queueProxy)) {
if (!isCurrentQueueControlGeneration(queueId, requestedGeneration)) {
const afterDispatch = get().downloads.find(download => download.id === item.id);
if (
@@ -1083,22 +1321,37 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
}));
},
addQueue: (name) => {
const normalizedName = name.trim();
if (!normalizedName) return false;
const duplicate = get().queues.some(queue =>
queueNameKey(queue.name) === queueNameKey(normalizedName)
);
if (duplicate) return false;
const id = crypto.randomUUID();
const q = { id, name, isMain: false };
const q = { id, name: normalizedName, isMain: false };
set((state) => ({
queues: [...state.queues, q]
}));
return true;
},
renameQueue: (id, name) => {
const normalizedName = name.trim();
if (!normalizedName) return false;
const duplicate = get().queues.some(queue =>
queue.id !== id
&& queueNameKey(queue.name) === queueNameKey(normalizedName)
);
if (duplicate || !get().queues.some(queue => queue.id === id)) return false;
set((state) => ({
queues: state.queues.map(q => {
if (q.id === id) {
const newQ = { ...q, name };
const newQ = { ...q, name: normalizedName };
return newQ;
}
return q;
})
}));
return true;
},
removeQueue: async (id) => {
if (id === MAIN_QUEUE_ID) return;
@@ -1136,6 +1389,22 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
try {
const settings = useSettingsStore.getState();
let proxy: string | null;
try {
proxy = await getProxyArgs(settings);
} catch (error) {
const message = errorMessage(error);
console.error('Could not safely resolve the system proxy during startup resume:', error);
const activeIds = new Set(active.map(item => item.id));
set(state => ({
downloads: state.downloads.map(item =>
activeIds.has(item.id) && item.status === 'queued'
? { ...item, lastError: message }
: item
)
}));
return;
}
const itemsToEnqueue = [];
for (const pendingItem of active) {
const item = get().downloads.find(download => download.id === pendingItem.id);
@@ -1158,8 +1427,8 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
url: item.url,
destination: destPath,
filename: item.fileName,
connections: item.connections || settings.perServerConnections || null,
speed_limit: speedLimitForDispatch(item.speedLimit, settings.globalSpeedLimit),
connections: resolveDownloadConnections(item.connections, settings.perServerConnections),
speed_limit: speedLimitForDispatch(item.speedLimit, settings.globalSpeedLimit, item.isMedia),
username: item.username || (login ? login.username : null),
password: item.password || keychainPassword,
headers: item.headers || null,
@@ -1168,7 +1437,7 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
mirrors: item.mirrors || null,
user_agent: settings.customUserAgent.trim() || null,
max_tries: settings.maxAutomaticRetries,
proxy: await getProxyArgs(settings),
proxy,
format_selector: item.mediaFormatSelector || null,
cookie_source: settings.mediaCookieSource !== 'none' ? settings.mediaCookieSource : null,
is_media: item.isMedia || false,
@@ -1270,13 +1539,28 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
},
initDB: async () => {
try {
const queues = (await invoke('db_get_all_queues')).map(value => JSON.parse(value) as Queue);
const persistedQueues = (await invoke('db_get_all_queues')).flatMap(value => {
try {
return [JSON.parse(value) as Queue];
} catch {
console.warn('Skipping malformed persisted queue record during startup');
return [];
}
});
const normalizedQueueState = normalizePersistedQueueState(persistedQueues);
const queues = normalizedQueueState.queues;
const knownQueueIds = new Set(queues.map(queue => queue.id));
const downloads = (await invoke('db_get_all_downloads')).map(
value => JSON.parse(value) as DownloadItem
);
).map(download => {
const persistedQueueId = download.queueId || MAIN_QUEUE_ID;
const queueId = normalizedQueueState.queueIdRemap.get(persistedQueueId)
|| (knownQueueIds.has(persistedQueueId) ? persistedQueueId : MAIN_QUEUE_ID);
return normalizePersistedDownloadProgress({ ...download, queueId });
});
set(state => ({
queues: queues.length > 0 ? queues : state.queues,
queues,
downloads: downloads.length > 0
? normalizeQueuePositions(downloads)
: state.downloads
+115 -1
View File
@@ -1,6 +1,11 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { useSettingsStore } from './useSettingsStore';
import {
runSettingsPersistenceTransaction,
subscribeToSettingsPersistenceErrors,
useSettingsStore
} from './useSettingsStore';
import * as ipc from '../ipc';
import type { PairingTokenHydration } from '../bindings/PairingTokenHydration';
vi.mock('../ipc', () => ({
invokeCommand: vi.fn()
@@ -65,4 +70,113 @@ describe('useSettingsStore credential-store startup flow', () => {
expect(useSettingsStore.getState().keychainAccessVersion).toBe('1.0.5');
expect(useSettingsStore.getState().keychainPromptDismissed).toBe(true);
});
it('opens the consent modal instead of regenerating through the credential store', async () => {
await expect(useSettingsStore.getState().regeneratePairingToken())
.rejects.toThrow('Grant credential-store access before regenerating the pairing token.');
expect(ipc.invokeCommand).not.toHaveBeenCalledWith('regenerate_pairing_token');
expect(useSettingsStore.getState().showKeychainModal).toBe(true);
});
it('does not apply pairing hydration after startup becomes inactive', async () => {
vi.mocked(ipc.invokeCommand).mockResolvedValueOnce({
token: 'stale-token',
tokenChanged: true,
persistent: true,
error: null
});
await expect(useSettingsStore.getState().hydratePairingToken(() => false)).resolves.toBe(false);
expect(ipc.invokeCommand).toHaveBeenCalledWith('hydrate_extension_pairing_token');
expect(useSettingsStore.getState().extensionPairingToken).toBe('');
expect(useSettingsStore.getState().isPairingTokenPersistent).toBe(false);
});
it('does not apply session hydration after startup becomes inactive', async () => {
vi.mocked(ipc.invokeCommand).mockResolvedValueOnce({
token: 'stale-session-token',
tokenChanged: false,
persistent: false,
error: null
});
await useSettingsStore.getState().hydrateSessionPairingToken(() => false);
expect(ipc.invokeCommand).toHaveBeenCalledWith('get_session_pairing_token');
expect(useSettingsStore.getState().extensionPairingToken).toBe('');
expect(useSettingsStore.getState().isPairingTokenPersistent).toBe(false);
});
it('shares a concurrent pairing hydration request', async () => {
let resolveRequest!: (value: PairingTokenHydration) => void;
const request = new Promise<PairingTokenHydration>(resolve => {
resolveRequest = resolve;
});
let hydrationRequestCount = 0;
vi.mocked(ipc.invokeCommand).mockImplementation(async (command: string) => {
if (command === 'hydrate_extension_pairing_token') {
hydrationRequestCount += 1;
return request;
}
return undefined;
});
const first = useSettingsStore.getState().hydratePairingToken();
const second = useSettingsStore.getState().hydratePairingToken();
expect(hydrationRequestCount).toBe(1);
resolveRequest({
token: 'shared-token',
tokenChanged: false,
persistent: true,
error: null
});
await Promise.all([first, second]);
expect(useSettingsStore.getState().extensionPairingToken).toBe('shared-token');
expect(useSettingsStore.getState().isPairingTokenPersistent).toBe(true);
});
});
describe('useSettingsStore persistence failures', () => {
it('keeps settings writes queued behind a credential transaction', async () => {
const events: string[] = [];
vi.mocked(ipc.invokeCommand).mockImplementation(async command => {
if (command === 'db_save_settings') events.push('settings-write');
return undefined;
});
await runSettingsPersistenceTransaction(async () => {
events.push('transaction-start');
useSettingsStore.setState({ theme: 'dark' });
events.push('transaction-end');
});
await new Promise(resolve => setTimeout(resolve, 0));
expect(events.slice(0, 2)).toEqual(['transaction-start', 'transaction-end']);
expect(events).toContain('settings-write');
});
it('reports a database save failure and retries the next settings update', async () => {
vi.clearAllMocks();
await new Promise(resolve => setTimeout(resolve, 0));
const onPersistenceError = vi.fn();
const unsubscribe = subscribeToSettingsPersistenceErrors(onPersistenceError);
vi.mocked(ipc.invokeCommand).mockRejectedValueOnce(new Error('database unavailable'));
useSettingsStore.setState({ theme: 'dark' });
await new Promise(resolve => setTimeout(resolve, 0));
expect(onPersistenceError).toHaveBeenCalledTimes(1);
vi.mocked(ipc.invokeCommand).mockResolvedValueOnce(undefined);
useSettingsStore.setState({ theme: 'light' });
await new Promise(resolve => setTimeout(resolve, 0));
expect(onPersistenceError).toHaveBeenCalledTimes(1);
unsubscribe();
});
});
+73 -12
View File
@@ -8,6 +8,7 @@ import type { ListRowDensity } from '../bindings/ListRowDensity';
import type { MediaCookieSource } from '../bindings/MediaCookieSource';
import type { PostQueueAction } from '../bindings/PostQueueAction';
import type { PersistedSettings } from '../bindings/PersistedSettings';
import type { PairingTokenHydration } from '../bindings/PairingTokenHydration';
import type { ProxyMode } from '../bindings/ProxyMode';
import type { SchedulerSettings } from '../bindings/SchedulerSettings';
import type { SettingsTab } from '../bindings/SettingsTab';
@@ -19,10 +20,51 @@ import {
} from '../utils/downloadLocations';
import { normalizeSpeedLimitForBackend } from '../utils/downloads';
let settingsSave = Promise.resolve();
let settingsQueue: Promise<void> = Promise.resolve();
let pairingTokenHydrationRequest: Promise<PairingTokenHydration> | null = null;
const settingsPersistenceErrorListeners = new Set<() => void>();
let settingsPersistenceFailed = false;
const DEFAULT_SCHEDULER_QUEUE_ID = '00000000-0000-0000-0000-000000000001';
export const DEFAULT_SPEED_LIMIT_PRESET_VALUES = [1, 5, 10];
export const subscribeToSettingsPersistenceErrors = (listener: () => void): (() => void) => {
settingsPersistenceErrorListeners.add(listener);
if (settingsPersistenceFailed) listener();
return () => settingsPersistenceErrorListeners.delete(listener);
};
const enqueueSettingsTask = <T>(task: () => Promise<T>): Promise<T> => {
const result = settingsQueue.then(task, task);
settingsQueue = result.then(() => undefined, () => undefined);
return result;
};
const requestPairingTokenHydration = (): Promise<PairingTokenHydration> => {
if (!pairingTokenHydrationRequest) {
pairingTokenHydrationRequest = invoke('hydrate_extension_pairing_token')
.finally(() => {
pairingTokenHydrationRequest = null;
});
}
return pairingTokenHydrationRequest;
};
export const runSettingsPersistenceTransaction = <T>(
operation: () => Promise<T>
): Promise<T> => enqueueSettingsTask(operation);
const notifySettingsPersistenceError = () => {
if (settingsPersistenceFailed) return;
settingsPersistenceFailed = true;
for (const listener of settingsPersistenceErrorListeners) {
try {
listener();
} catch (error) {
console.error('Settings persistence error listener failed', error);
}
}
};
const THEME_VALUES = ['system', 'light', 'dark', 'dracula', 'nord'] as const;
const APP_FONT_SIZE_VALUES = ['small', 'standard', 'large'] as const;
const LIST_ROW_DENSITY_VALUES = ['compact', 'standard', 'relaxed'] as const;
@@ -82,12 +124,15 @@ const tauriStorage: StateStorage = {
},
setItem: async (name: string, value: string): Promise<void> => {
if (name === 'firelink-settings') {
settingsSave = settingsSave
.then(() => invoke('db_save_settings', { data: value }))
.catch(e => {
console.error("Failed to save settings to DB", e);
});
await settingsSave;
await enqueueSettingsTask(async () => {
try {
await invoke('db_save_settings', { data: value });
settingsPersistenceFailed = false;
} catch {
console.error('Failed to save settings to DB');
notifySettingsPersistenceError();
}
});
}
},
removeItem: async (_name: string): Promise<void> => {
@@ -143,6 +188,7 @@ export interface SettingsState {
maxAutomaticRetries: number;
showNotifications: boolean;
playCompletionSound: boolean;
autoAddClipboardLinks: boolean;
appFontSize: AppFontSize;
listRowDensity: ListRowDensity;
showDockBadge: boolean;
@@ -186,6 +232,7 @@ export interface SettingsState {
setMaxAutomaticRetries: (count: number) => void;
setShowNotifications: (show: boolean) => void;
setPlayCompletionSound: (play: boolean) => void;
setAutoAddClipboardLinks: (enabled: boolean) => void;
setAppFontSize: (size: AppFontSize) => void;
setListRowDensity: (density: ListRowDensity) => void;
setShowDockBadge: (show: boolean) => void;
@@ -205,11 +252,11 @@ export interface SettingsState {
removeSiteLogin: (id: string) => void;
regeneratePairingToken: () => Promise<void>;
setAutoCheckUpdates: (autoCheckUpdates: boolean) => void;
hydratePairingToken: () => Promise<boolean>;
hydratePairingToken: (isCurrent?: () => boolean) => Promise<boolean>;
setShowKeychainModal: (show: boolean) => void;
setKeychainAccessReady: (ready: boolean) => void;
dismissKeychainPrompt: (version?: string) => void;
hydrateSessionPairingToken: () => Promise<void>;
hydrateSessionPairingToken: (isCurrent?: () => boolean) => Promise<void>;
}
export const useSettingsStore = create<SettingsState>()(
@@ -250,6 +297,7 @@ export const useSettingsStore = create<SettingsState>()(
maxAutomaticRetries: 3,
showNotifications: true,
playCompletionSound: false,
autoAddClipboardLinks: false,
appFontSize: 'standard',
listRowDensity: 'standard',
showDockBadge: true,
@@ -319,6 +367,7 @@ export const useSettingsStore = create<SettingsState>()(
}),
setShowNotifications: (showNotifications) => set({ showNotifications }),
setPlayCompletionSound: (playCompletionSound) => set({ playCompletionSound }),
setAutoAddClipboardLinks: (autoAddClipboardLinks) => set({ autoAddClipboardLinks }),
setAppFontSize: (appFontSize) => set({ appFontSize }),
setListRowDensity: (listRowDensity) => set({ listRowDensity }),
setShowDockBadge: (showDockBadge) => {
@@ -373,6 +422,11 @@ export const useSettingsStore = create<SettingsState>()(
siteLogins: state.siteLogins.filter((login) => login.id !== id)
})),
regeneratePairingToken: async () => {
const current = get();
if (!current.keychainAccessReady && !current.isPairingTokenPersistent) {
set({ showKeychainModal: true });
throw new Error('Grant credential-store access before regenerating the pairing token.');
}
const result = await invoke('regenerate_pairing_token');
if (!result.persistent) {
throw new Error(result.error || 'Credential store access is unavailable.');
@@ -383,11 +437,12 @@ export const useSettingsStore = create<SettingsState>()(
showKeychainModal: false
});
},
hydratePairingToken: async () => {
hydratePairingToken: async (isCurrent) => {
// The backend migrates legacy settings copies and reads the token from
// the credential store after the app state is ready to receive it.
// Portable mode remains the explicit folder-contained exception.
const result = await invoke('hydrate_extension_pairing_token');
const result = await requestPairingTokenHydration();
if (isCurrent && !isCurrent()) return false;
set({
extensionPairingToken: result.token,
isPairingTokenPersistent: result.persistent,
@@ -395,8 +450,9 @@ export const useSettingsStore = create<SettingsState>()(
});
return result.tokenChanged;
},
hydrateSessionPairingToken: async () => {
hydrateSessionPairingToken: async (isCurrent) => {
const result = await invoke('get_session_pairing_token');
if (isCurrent && !isCurrent()) return;
set({
extensionPairingToken: result.token,
isPairingTokenPersistent: false,
@@ -477,6 +533,7 @@ export const useSettingsStore = create<SettingsState>()(
maxAutomaticRetries: state.maxAutomaticRetries,
showNotifications: state.showNotifications,
playCompletionSound: state.playCompletionSound,
autoAddClipboardLinks: state.autoAddClipboardLinks,
appFontSize: state.appFontSize,
listRowDensity: state.listRowDensity,
showDockBadge: state.showDockBadge,
@@ -525,6 +582,10 @@ export const useSettingsStore = create<SettingsState>()(
: currentState.activeSettingsTab,
showNotifications: persistedBoolean(persisted.showNotifications, currentState.showNotifications),
playCompletionSound: persistedBoolean(persisted.playCompletionSound, currentState.playCompletionSound),
autoAddClipboardLinks: persistedBoolean(
persisted.autoAddClipboardLinks,
currentState.autoAddClipboardLinks
),
showDockBadge: persistedBoolean(persisted.showDockBadge, currentState.showDockBadge),
showMenuBarIcon: persistedBoolean(persisted.showMenuBarIcon, currentState.showMenuBarIcon),
askWhereToSaveEachFile: persistedBoolean(
+238 -2
View File
@@ -5,6 +5,8 @@ import {
mediaFormatSelectorForRow,
mediaFileNameForSelectedFormat,
metadataSummaryMessage,
isYouTubePlaylistUrl,
playlistFilePrefix,
reconcileDownloadRows,
refreshFailedMetadataRows,
updateRowIfCurrent,
@@ -57,6 +59,168 @@ describe('add download metadata workflow', () => {
expect(rows.map(item => item.status)).toEqual(['loading', 'invalid', 'invalid']);
});
it('recognizes pure YouTube playlist URLs without changing video-plus-playlist behavior', () => {
expect(isYouTubePlaylistUrl('https://www.youtube.com/playlist?list=PL123')).toBe(true);
expect(isYouTubePlaylistUrl('https://www.youtube.com/playlist/?list=PL123')).toBe(true);
expect(isYouTubePlaylistUrl('https://music.youtube.com/playlist?list=PL123')).toBe(true);
expect(isYouTubePlaylistUrl('https://www.youtube.com/watch?v=video&list=PL123')).toBe(false);
expect(isYouTubePlaylistUrl('https://example.com/playlist?list=PL123')).toBe(false);
});
it('keeps a playlist as one loading row until discovery succeeds', () => {
const rows = reconcileDownloadRows(
'https://www.youtube.com/playlist?list=PL123',
[]
);
expect(rows[0]).toMatchObject({
isMedia: true,
isPlaylist: true,
status: 'loading'
});
});
it('expands playlist entries into independently identifiable media rows', () => {
const playlistUrl = 'https://www.youtube.com/playlist?list=PL123';
const rows = reconcileDownloadRows(
playlistUrl,
[],
undefined,
new Set(),
undefined,
{},
{ [playlistUrl]: 4 },
{
[playlistUrl]: {
title: 'Example playlist',
playlist_id: 'PL123',
entry_count: 2,
skipped_entries: 1,
truncated: false,
entries: [
{ id: 'one', url: 'https://www.youtube.com/watch?v=one', title: 'First', playlist_index: 1 },
{ id: 'one-duplicate', url: 'https://www.youtube.com/watch?v=one', title: 'Duplicate', playlist_index: 2 },
{ id: 'two', url: 'https://www.youtube.com/watch?v=two', title: 'Second', playlist_index: 3 }
]
}
}
);
expect(rows).toHaveLength(2);
expect(rows.map(item => item.sourceUrl)).toEqual([
'https://www.youtube.com/watch?v=one',
'https://www.youtube.com/watch?v=two'
]);
expect(rows[0]).toMatchObject({
file: '001 - First',
isMedia: true,
playlistSourceUrl: playlistUrl,
playlistTitle: 'Example playlist',
playlistIndex: 1,
playlistCount: 2,
requestContextVersion: 4,
status: 'loading'
});
expect(rows[1].file).toBe('003 - Second');
expect(rows.every(item => !item.isPlaylist)).toBe(true);
});
it('uses a stable three-digit playlist prefix and widens it for four-digit lists', () => {
expect(playlistFilePrefix(1, 12)).toBe('001 - ');
expect(playlistFilePrefix(12, 12)).toBe('012 - ');
expect(playlistFilePrefix(1000, 1000)).toBe('1000 - ');
expect(playlistFilePrefix(undefined, 12)).toBe('');
});
it('propagates a playlist selection to entries discovered after the user deselects it', () => {
const playlistUrl = 'https://www.youtube.com/playlist?list=PL123';
const rows = reconcileDownloadRows(
playlistUrl,
[],
undefined,
new Set(),
undefined,
{},
{},
{
[playlistUrl]: {
title: 'Example playlist',
playlist_id: 'PL123',
entry_count: 1,
skipped_entries: 0,
truncated: false,
entries: [{ id: 'one', url: 'https://www.youtube.com/watch?v=one', title: 'First', playlist_index: 1 }]
}
},
{ [playlistUrl]: false }
);
expect(rows).toHaveLength(1);
expect(rows[0].selected).toBe(false);
});
it('preserves entry-level selection when expanded rows are recreated', () => {
const playlistUrl = 'https://www.youtube.com/playlist?list=PL123';
const expansion = {
[playlistUrl]: {
title: 'Example playlist',
playlist_id: 'PL123',
entry_count: 2,
skipped_entries: 0,
truncated: false,
entries: [
{ id: 'one', url: 'https://www.youtube.com/watch?v=one', title: 'First', playlist_index: 1 },
{ id: 'two', url: 'https://www.youtube.com/watch?v=two', title: 'Second', playlist_index: 2 }
]
}
};
const rows = reconcileDownloadRows(
playlistUrl,
[],
undefined,
new Set(),
undefined,
{},
{},
expansion,
{
'https://www.youtube.com/watch?v=one': false,
'https://www.youtube.com/watch?v=two': true
}
);
expect(rows.map(item => item.selected)).toEqual([false, true]);
});
it('does not leave a loading playlist row when every entry is already present', () => {
const videoUrl = 'https://www.youtube.com/watch?v=one';
const playlistUrl = 'https://www.youtube.com/playlist?list=PL123';
const rows = reconcileDownloadRows(
`${videoUrl}\n${playlistUrl}`,
[],
undefined,
new Set(),
undefined,
{},
{},
{
[playlistUrl]: {
title: 'Example playlist',
playlist_id: 'PL123',
entry_count: 1,
skipped_entries: 0,
truncated: false,
entries: [{ id: 'one', url: videoUrl, title: 'First', playlist_index: 1 }]
}
}
);
expect(rows).toHaveLength(1);
expect(rows[0].sourceUrl).toBe(videoUrl);
expect(rows.some(item => item.isPlaylist)).toBe(false);
});
it('forces explicit extension media fetches through media metadata for any http page', () => {
const rows = reconcileDownloadRows(
'https://adult.example/watch/123',
@@ -129,7 +293,10 @@ describe('add download metadata workflow', () => {
const existing = row({
file: 'old-name.zip',
requestContextVersion: 1,
generation: 2
generation: 2,
size: '10 MB',
sizeBytes: 10,
resumable: true
});
const refreshed = reconcileDownloadRows(
@@ -146,7 +313,47 @@ describe('add download metadata workflow', () => {
file: 'new-name.zip',
status: 'loading',
generation: 3,
requestContextVersion: 2
requestContextVersion: 2,
size: undefined,
sizeBytes: undefined,
resumable: undefined
});
});
it('drops stale playlist provenance when an entry remains after its playlist is removed', () => {
const videoUrl = 'https://www.youtube.com/watch?v=one';
const playlistUrl = 'https://www.youtube.com/playlist?list=PL123';
const existing = row({
sourceUrl: videoUrl,
downloadUrl: videoUrl,
file: '001 - First.mp4',
status: 'ready',
generation: 3,
isMedia: true,
playlistSourceUrl: playlistUrl,
playlistTitle: 'Example playlist',
playlistIndex: 1,
playlistCount: 2,
playlistEntryTitle: 'First',
requestContextVersion: 7,
size: '10 MB',
sizeBytes: 10,
resumable: true
});
const rows = reconcileDownloadRows(videoUrl, [existing]);
expect(rows[0]).toMatchObject({
file: 'watch',
status: 'loading',
generation: 4,
isMedia: true,
playlistSourceUrl: undefined,
playlistIndex: undefined,
size: undefined,
sizeBytes: undefined,
resumable: undefined,
requestContextVersion: undefined
});
});
@@ -236,6 +443,9 @@ describe('add download metadata workflow', () => {
row(),
row({ id: 'fallback', status: 'metadata-error' })
])).toBe(true);
expect(canSubmitMetadataRows([
row({ id: 'unsafe', status: 'metadata-error', metadataBlockedReason: 'unsafe-url' })
])).toBe(false);
expect(canSubmitMetadataRows([
row(),
row({ id: 'media-fallback', status: 'metadata-error', isMedia: true })
@@ -244,6 +454,29 @@ describe('add download metadata workflow', () => {
expect(canSubmitMetadataRows([row({ status: 'invalid' })])).toBe(false);
});
it('validates only selected rows and requires at least one selection', () => {
expect(canSubmitMetadataRows([
row({ status: 'loading' }),
row({ id: 'skipped', status: 'invalid', selected: false })
])).toBe(false);
expect(canSubmitMetadataRows([
row({ status: 'ready' }),
row({ id: 'skipped', status: 'invalid', selected: false })
])).toBe(true);
expect(canSubmitMetadataRows([
row({ selected: false }),
row({ id: 'skipped', selected: false })
])).toBe(false);
expect(metadataSummaryMessage([
row({ status: 'metadata-error', selected: false }),
row({ id: 'ready', status: 'ready' })
])).toContain('Ready to add 1 download');
expect(metadataSummaryMessage([
row({ status: 'metadata-error' }),
row({ id: 'skipped', status: 'ready', selected: false })
])).toContain('can still be added');
});
it('keeps failed media routing without a format selector', () => {
const failedMedia = row({
status: 'metadata-error',
@@ -294,6 +527,9 @@ describe('add download metadata workflow', () => {
expect(metadataSummaryMessage([
row({ status: 'metadata-error' })
])).toContain('can still be added');
expect(metadataSummaryMessage([
row({ status: 'metadata-error', metadataBlockedReason: 'unsafe-url' })
])).toContain('unsafe URL');
expect(metadataSummaryMessage([
row({ status: 'metadata-error', isMedia: true })
])).toContain('Refresh metadata before adding');
+168 -25
View File
@@ -3,6 +3,7 @@ import {
fileNameFromUrl,
isMediaUrl
} from './downloads';
import type { MediaPlaylistMetadata } from '../bindings/MediaPlaylistMetadata';
export type MetadataStatus = 'loading' | 'ready' | 'metadata-error' | 'invalid';
@@ -31,6 +32,15 @@ export interface AddDownloadDraftRow {
resumable?: boolean;
formats?: AddMediaFormat[];
selectedFormat?: number;
isPlaylist?: boolean;
playlistSourceUrl?: string;
playlistTitle?: string;
playlistIndex?: number;
playlistCount?: number;
playlistEntryTitle?: string;
playlistError?: string;
metadataBlockedReason?: 'unsafe-url';
selected?: boolean;
}
const ALLOWED_SCHEMES = new Set(['http:', 'https:', 'ftp:', 'sftp:']);
@@ -39,9 +49,45 @@ type ParsedInput = {
identity: string;
sourceUrl: string;
valid: boolean;
isPlaylist?: boolean;
playlistSourceUrl?: string;
playlistTitle?: string;
playlistIndex?: number;
playlistCount?: number;
playlistEntryTitle?: string;
requestContextVersion?: number;
selected?: boolean;
};
const parseInputLines = (rawText: string): ParsedInput[] => {
export const isYouTubePlaylistUrl = (rawUrl: string): boolean => {
try {
const url = new URL(rawUrl);
const hostname = url.hostname.toLowerCase();
const isYouTube = hostname === 'youtube.com' || hostname.endsWith('.youtube.com');
const pathname = url.pathname.replace(/\/+$/, '') || '/';
return isYouTube && pathname === '/playlist' && Boolean(url.searchParams.get('list'));
} catch {
return false;
}
};
export const playlistFilePrefix = (
playlistIndex: number | undefined,
playlistCount: number | undefined
): string => {
if (!playlistIndex || playlistIndex < 1) return '';
const width = Math.max(3, String(playlistCount || playlistIndex).length);
return `${String(playlistIndex).padStart(width, '0')} - `;
};
type PlaylistExpansions = Readonly<Record<string, MediaPlaylistMetadata>>;
const parseInputLines = (
rawText: string,
playlistExpansions: PlaylistExpansions,
requestContextVersions: Readonly<Record<string, number>>,
selectedBySourceUrl: Readonly<Record<string, boolean>>
): ParsedInput[] => {
const seen = new Set<string>();
const parsed: ParsedInput[] = [];
@@ -62,7 +108,50 @@ const parseInputLines = (rawText: string): ParsedInput[] => {
const identity = valid ? sourceUrl : `invalid:${line}`;
if (seen.has(identity)) continue;
seen.add(identity);
parsed.push({ identity, sourceUrl, valid });
if (valid && isYouTubePlaylistUrl(sourceUrl)) {
const expansion = playlistExpansions[sourceUrl];
if (expansion) {
const playlistSelected = selectedBySourceUrl[sourceUrl] !== false;
for (const [position, entry] of expansion.entries.entries()) {
let entryUrl: string;
try {
const parsedEntryUrl = new URL(entry.url);
if (!ALLOWED_SCHEMES.has(parsedEntryUrl.protocol)) continue;
entryUrl = parsedEntryUrl.href;
} catch {
continue;
}
if (seen.has(entryUrl)) continue;
seen.add(entryUrl);
parsed.push({
identity: entryUrl,
sourceUrl: entryUrl,
valid: true,
playlistSourceUrl: sourceUrl,
playlistTitle: expansion.title,
playlistIndex: entry.playlist_index || position + 1,
playlistCount: expansion.entry_count || expansion.entries.length,
playlistEntryTitle: entry.title,
requestContextVersion: requestContextVersions[sourceUrl],
selected: selectedBySourceUrl[entryUrl] ?? playlistSelected
});
}
// The playlist has been successfully discovered even when every
// entry was already represented by another input row. Do not put the
// source playlist back into loading state in that case.
continue;
}
}
parsed.push({
identity,
sourceUrl,
valid,
isPlaylist: valid && isYouTubePlaylistUrl(sourceUrl),
requestContextVersion: valid ? requestContextVersions[sourceUrl] : undefined,
selected: selectedBySourceUrl[sourceUrl] !== false
});
}
return parsed;
@@ -75,37 +164,68 @@ export const reconcileDownloadRows = (
forceMediaUrls: ReadonlySet<string> = new Set(),
createId: () => string = () => crypto.randomUUID(),
requestFilenames: Readonly<Record<string, string>> = {},
requestContextVersions: Readonly<Record<string, number>> = {}
requestContextVersions: Readonly<Record<string, number>> = {},
playlistExpansions: PlaylistExpansions = {},
selectedBySourceUrl: Readonly<Record<string, boolean>> = {}
): AddDownloadDraftRow[] => {
const inputs = parseInputLines(rawText);
const inputs = parseInputLines(
rawText,
playlistExpansions,
requestContextVersions,
selectedBySourceUrl
);
const existing = new Map(currentRows.map(row => [row.sourceUrl, row]));
return inputs.map(input => {
const preserved = existing.get(input.sourceUrl);
if (preserved) {
const forcedMedia = input.valid && forceMediaUrls.has(input.sourceUrl);
const requestContextVersion = requestContextVersions[input.sourceUrl];
const requestContextVersion = input.requestContextVersion;
const contextChanged = requestContextVersion !== undefined
&& requestContextVersion !== preserved.requestContextVersion;
if ((forcedMedia && !preserved.isMedia) || contextChanged) {
const requestedFilename = requestFilenames[input.sourceUrl];
const playlistContextChanged = preserved.playlistSourceUrl !== input.playlistSourceUrl
|| preserved.playlistTitle !== input.playlistTitle
|| preserved.playlistIndex !== input.playlistIndex
|| preserved.playlistCount !== input.playlistCount
|| preserved.playlistEntryTitle !== input.playlistEntryTitle;
if ((forcedMedia && !preserved.isMedia) || contextChanged || playlistContextChanged) {
const requestedFilename = input.playlistSourceUrl
? `${playlistFilePrefix(input.playlistIndex, input.playlistCount)}${input.playlistEntryTitle || 'video'}`
: requestFilenames[input.sourceUrl];
return {
...preserved,
file: contextChanged
file: contextChanged || playlistContextChanged
? canonicalizeDownloadFileName(requestedFilename || fileNameFromUrl(input.sourceUrl))
: preserved.file,
status: 'loading',
generation: preserved.generation + 1,
requestContextVersion,
isMedia: preserved.isMedia || forcedMedia,
formats: preserved.isMedia || forcedMedia ? undefined : preserved.formats,
selectedFormat: preserved.isMedia || forcedMedia ? undefined : preserved.selectedFormat
isMedia: preserved.isMedia || forcedMedia || Boolean(input.playlistSourceUrl),
size: undefined,
sizeBytes: undefined,
resumable: undefined,
formats: preserved.isMedia || forcedMedia || Boolean(input.playlistSourceUrl)
? undefined
: preserved.formats,
selectedFormat: preserved.isMedia || forcedMedia || Boolean(input.playlistSourceUrl)
? undefined
: preserved.selectedFormat,
isPlaylist: input.isPlaylist,
playlistSourceUrl: input.playlistSourceUrl,
playlistTitle: input.playlistTitle,
playlistIndex: input.playlistIndex,
playlistCount: input.playlistCount,
playlistEntryTitle: input.playlistEntryTitle,
playlistError: undefined,
metadataBlockedReason: undefined
};
}
return preserved;
}
const requestedFilename = requestFilenames[input.sourceUrl]
const requestedFilename = input.playlistSourceUrl
? `${playlistFilePrefix(input.playlistIndex, input.playlistCount)}${input.playlistEntryTitle || 'video'}`
: requestFilenames[input.sourceUrl]
|| (inputs.length === 1 ? pendingFilename : undefined);
const fallback = canonicalizeDownloadFileName(
requestedFilename || fileNameFromUrl(input.sourceUrl)
@@ -118,8 +238,21 @@ export const reconcileDownloadRows = (
file: fallback,
status: input.valid ? 'loading' : 'invalid',
generation: input.valid ? 1 : 0,
requestContextVersion: requestContextVersions[input.sourceUrl],
isMedia: input.valid && (forceMediaUrls.has(input.sourceUrl) || isMediaUrl(input.sourceUrl))
requestContextVersion: input.requestContextVersion,
isMedia: input.valid && (
Boolean(input.isPlaylist)
|| Boolean(input.playlistSourceUrl)
|| forceMediaUrls.has(input.sourceUrl)
|| isMediaUrl(input.sourceUrl)
),
isPlaylist: input.isPlaylist,
playlistSourceUrl: input.playlistSourceUrl,
playlistTitle: input.playlistTitle,
playlistIndex: input.playlistIndex,
playlistCount: input.playlistCount,
playlistEntryTitle: input.playlistEntryTitle,
metadataBlockedReason: undefined,
selected: input.selected !== false
};
});
};
@@ -172,17 +305,20 @@ export const refreshFailedMetadataRows = (
? {
...row,
status: 'loading',
generation: row.generation + 1
generation: row.generation + 1,
metadataBlockedReason: undefined
}
: row
);
export const canSubmitMetadataRows = (rows: AddDownloadDraftRow[]): boolean =>
rows.length > 0
&& rows.every(row =>
export const canSubmitMetadataRows = (rows: AddDownloadDraftRow[]): boolean => {
const selectedRows = rows.filter(row => row.selected !== false);
return selectedRows.length > 0
&& selectedRows.every(row =>
row.status === 'ready'
|| (!row.isMedia && row.status === 'metadata-error')
|| (!row.isMedia && row.status === 'metadata-error' && !row.metadataBlockedReason)
);
};
export const mediaFormatSelectorForRow = (
row: AddDownloadDraftRow
@@ -228,23 +364,30 @@ export const mediaFileNameForSelectedFormat = (
export const metadataSummaryMessage = (rows: AddDownloadDraftRow[]): string => {
if (rows.length === 0) return 'Paste one or more links.';
const invalid = rows.filter(row => row.status === 'invalid').length;
const selectedRows = rows.filter(row => row.selected !== false);
if (selectedRows.length === 0) return 'Select at least one download.';
const invalid = selectedRows.filter(row => row.status === 'invalid').length;
if (invalid > 0) {
return `Correct or remove ${invalid} invalid URL${invalid === 1 ? '' : 's'} before continuing.`;
}
const loading = rows.filter(row => row.status === 'loading').length;
const loading = selectedRows.filter(row => row.status === 'loading').length;
if (loading > 0) {
return `Waiting for metadata for ${loading} download${loading === 1 ? '' : 's'}.`;
}
const failed = rows.filter(row => row.status === 'metadata-error').length;
const failedMedia = rows.filter(row => row.status === 'metadata-error' && row.isMedia).length;
const ready = rows.filter(row => row.status === 'ready').length;
const failed = selectedRows.filter(row => row.status === 'metadata-error').length;
const failedMedia = selectedRows.filter(row => row.status === 'metadata-error' && row.isMedia).length;
const blocked = selectedRows.filter(row => row.metadataBlockedReason === 'unsafe-url').length;
const ready = selectedRows.filter(row => row.status === 'ready').length;
if (blocked > 0) {
return `Remove ${blocked} unsafe URL${blocked === 1 ? '' : 's'} before continuing.`;
}
if (failedMedia > 0) {
return `Media metadata is unavailable for ${failedMedia} item${failedMedia === 1 ? '' : 's'}. Refresh metadata before adding.`;
}
if (failed === rows.length) {
if (failed === selectedRows.length) {
return 'Metadata is unavailable. Downloads can still be added using fallback details.';
}
if (failed > 0) {
+46
View File
@@ -0,0 +1,46 @@
import { describe, expect, it } from 'vitest';
import { formatDownloadBytes, formatDownloadTotal, resolveDownloadSizeDisplay } from './downloadProgress';
describe('download progress size display', () => {
it('formats byte counts using the binary units used by the download engines', () => {
expect(formatDownloadBytes(0)).toBe('0 B');
expect(formatDownloadBytes(1.2 * 1024 ** 3)).toBe('1.20 GB');
});
it('keeps estimated totals distinguishable from exact totals', () => {
expect(resolveDownloadSizeDisplay({
downloadedBytes: 1.2 * 1024 ** 3,
totalBytes: 2.4 * 1024 ** 3,
totalIsEstimate: true,
fallbackSize: 'Unknown'
})).toEqual({
downloaded: '1.20',
total: '2.40',
unit: 'GB',
totalIsEstimate: true,
fallback: 'Unknown'
});
});
it('converts downloaded bytes into the total size unit', () => {
expect(resolveDownloadSizeDisplay({
downloadedBytes: 512 * 1024 ** 2,
totalBytes: 2 * 1024 ** 3,
fallbackSize: '2 GB'
})).toMatchObject({
downloaded: '0.50',
total: '2.00',
unit: 'GB'
});
});
it('formats a completed download using only its total size', () => {
const display = resolveDownloadSizeDisplay({
downloadedBytes: 1.2 * 1024 ** 3,
totalBytes: 2.4 * 1024 ** 3,
fallbackSize: '2.4 GB'
});
expect(formatDownloadTotal(display)).toBe('2.40 GB');
});
});
+84
View File
@@ -0,0 +1,84 @@
export interface DownloadSizeDisplay {
downloaded: string | null;
total: string | null;
unit: string | null;
totalIsEstimate: boolean;
fallback: string;
}
const BYTE_UNITS = ['B', 'KB', 'MB', 'GB', 'TB'] as const;
const isUsableByteCount = (value: number | null | undefined): value is number =>
typeof value === 'number' && Number.isFinite(value) && value >= 0;
const byteUnitIndex = (bytes: number): number => {
let value = bytes;
let unitIndex = 0;
while (value >= 1024 && unitIndex < BYTE_UNITS.length - 1) {
value /= 1024;
unitIndex += 1;
}
return unitIndex;
};
const formatDownloadBytesInUnit = (bytes: number, unitIndex: number): string => {
const value = bytes / 1024 ** unitIndex;
const precision = value >= 100 ? 0 : value >= 10 ? 1 : 2;
return value < 1024 && unitIndex === 0
? `${Math.round(value)}`
: value.toFixed(precision);
};
export const formatDownloadBytes = (bytes: number): string => {
const unitIndex = byteUnitIndex(bytes);
return `${formatDownloadBytesInUnit(bytes, unitIndex)} ${BYTE_UNITS[unitIndex]}`;
};
export const formatDownloadTotal = (display: DownloadSizeDisplay): string =>
display.total && display.unit
? `${display.totalIsEstimate ? '~' : ''}${display.total} ${display.unit}`
: display.fallback;
export const resolveDownloadSizeDisplay = ({
downloadedBytes,
totalBytes,
totalIsEstimate = false,
fallbackSize
}: {
downloadedBytes?: number | null;
totalBytes?: number | null;
totalIsEstimate?: boolean;
fallbackSize?: string | null;
}): DownloadSizeDisplay => ({
downloaded: isUsableByteCount(downloadedBytes) && isUsableByteCount(totalBytes) && totalBytes > 0
? formatDownloadBytesInUnit(downloadedBytes, byteUnitIndex(totalBytes))
: null,
total: isUsableByteCount(totalBytes) && totalBytes > 0
? formatDownloadBytesInUnit(totalBytes, byteUnitIndex(totalBytes))
: null,
unit: isUsableByteCount(totalBytes) && totalBytes > 0
? BYTE_UNITS[byteUnitIndex(totalBytes)]
: null,
totalIsEstimate: Boolean(totalIsEstimate && isUsableByteCount(totalBytes) && totalBytes > 0),
fallback: fallbackSize && fallbackSize !== '-' ? fallbackSize : 'Unknown'
});
export const downloadProgressColorClass = (status: string): string => {
switch (status) {
case 'completed':
return 'download-status-completed';
case 'paused':
return 'download-status-paused';
case 'failed':
return 'download-status-failed';
case 'processing':
return 'download-status-processing';
case 'queued':
case 'staged':
return 'download-status-queued';
case 'retrying':
return 'download-status-retrying';
default:
return 'download-status-downloading';
}
};
+58
View File
@@ -0,0 +1,58 @@
import { describe, expect, it } from 'vitest';
import type { DownloadItem } from '../bindings/DownloadItem';
import { redactDownloadForPersistence, resolveDownloadConnections } from './downloads';
const item = (status: DownloadItem['status']): DownloadItem => ({
id: 'download-1',
url: 'https://example.com/file.bin',
fileName: 'file.bin',
status,
category: 'Other',
dateAdded: '2026-07-15T00:00:00.000Z',
downloadedBytes: 1024,
totalBytes: 4096,
totalIsEstimate: false
});
describe('download persistence progress snapshots', () => {
it('does not write active byte counters on every progress event', () => {
const persisted = redactDownloadForPersistence(item('downloading'));
expect(persisted.downloadedBytes).toBeUndefined();
expect(persisted.totalBytes).toBeUndefined();
expect(persisted.totalIsEstimate).toBeUndefined();
});
it('keeps byte counters for paused snapshots', () => {
const persisted = redactDownloadForPersistence(item('paused'));
expect(persisted.downloadedBytes).toBe(1024);
expect(persisted.totalBytes).toBe(4096);
expect(persisted.totalIsEstimate).toBe(false);
});
it.each(['queued', 'staged', 'retrying', 'processing'] as const)(
'keeps byte counters for %s snapshots',
(status) => {
const persisted = redactDownloadForPersistence(item(status));
expect(persisted.downloadedBytes).toBe(1024);
expect(persisted.totalBytes).toBe(4096);
expect(persisted.totalIsEstimate).toBe(false);
}
);
});
describe('download connection resolution', () => {
it('uses a clamped fallback for legacy rows without a saved value', () => {
expect(resolveDownloadConnections(undefined, 8)).toBe(8);
expect(resolveDownloadConnections(undefined, 0)).toBe(1);
expect(resolveDownloadConnections(undefined, Number.NaN)).toBe(16);
});
it('clamps malformed saved values before dispatch', () => {
expect(resolveDownloadConnections(0, 8)).toBe(1);
expect(resolveDownloadConnections(17, 8)).toBe(16);
expect(resolveDownloadConnections(Number.NaN, 8)).toBe(8);
});
});
+45 -1
View File
@@ -40,6 +40,37 @@ export const isActiveDownloadStatus = (status: DownloadStatus): boolean =>
export const isTransferActiveStatus = (status: DownloadStatus): boolean =>
status === 'downloading' || status === 'processing' || status === 'retrying';
export const DOWNLOAD_CONNECTIONS_MIN = 1;
export const DOWNLOAD_CONNECTIONS_MAX = 16;
/**
* Resolve persisted/user-entered connection values before they cross into the
* backend. Older rows may omit the value, while malformed rows can contain
* zero, NaN, or an out-of-range number.
*/
export const resolveDownloadConnections = (value: unknown, fallback: unknown): number => {
const toFiniteInteger = (candidate: unknown): number | undefined => {
if (typeof candidate === 'number') {
return Number.isFinite(candidate) ? Math.trunc(candidate) : undefined;
}
if (typeof candidate === 'string' && candidate.trim() !== '') {
const parsed = Number(candidate);
return Number.isFinite(parsed) ? Math.trunc(parsed) : undefined;
}
return undefined;
};
const normalizedFallback = toFiniteInteger(fallback) ?? DOWNLOAD_CONNECTIONS_MAX;
const safeFallback = Math.min(
DOWNLOAD_CONNECTIONS_MAX,
Math.max(DOWNLOAD_CONNECTIONS_MIN, normalizedFallback)
);
const candidate = toFiniteInteger(value) ?? safeFallback;
return Math.min(
DOWNLOAD_CONNECTIONS_MAX,
Math.max(DOWNLOAD_CONNECTIONS_MIN, candidate)
);
};
export const normalizeSpeedLimitForBackend = (value?: string | null): string | null => {
const trimmed = value?.trim();
if (!trimmed) return null;
@@ -119,11 +150,19 @@ export const isMediaUrl = (rawUrl: string): boolean => {
* persistence boundary so the user-data database contains no plaintext credentials.
*/
const DOWNLOAD_SECRET_FIELDS = ['password', 'cookies', 'headers'] as const;
const VOLATILE_PROGRESS_STATUSES = new Set([
'downloading'
]);
/**
* Returns a shallow copy of `item` with secret fields removed. Volatile
* progress fields (`fraction`, `speed`, `eta`) are also dropped as in the
* existing persistence path.
* existing persistence path. Numeric byte totals remain for paused, failed,
* and completed rows so those snapshots keep their accurate Size-column
* display after restart; counters for the actively ticking `downloading`
* state stay in memory to avoid a database write for every progress tick.
* Non-ticking states retain counters so paused, queued, staged, retrying, and
* processing snapshots remain useful across restart and reconfiguration.
*
* Note: standard persistence intentionally retains `url` because it is the
* download source. The backend applies a stricter portable-mode policy: URL
@@ -135,6 +174,11 @@ export const redactDownloadForPersistence = (item: DownloadItem): DownloadItem =
delete copy.fraction;
delete copy.speed;
delete copy.eta;
if (VOLATILE_PROGRESS_STATUSES.has(item.status)) {
delete copy.downloadedBytes;
delete copy.totalBytes;
delete copy.totalIsEstimate;
}
for (const field of DOWNLOAD_SECRET_FIELDS) {
delete copy[field];
}
+59 -1
View File
@@ -1,7 +1,52 @@
import { describe, expect, it } from 'vitest';
import { getKeychainStartupDecision } from './keychainStartup';
import {
getKeychainAccessReady,
getKeychainConsentVersion,
getKeychainStartupDecision
} from './keychainStartup';
describe('getKeychainStartupDecision', () => {
it('keeps portable site credentials gated until system-store access is granted', () => {
expect(getKeychainAccessReady({
portable: true,
accessGranted: false,
persistent: true
})).toBe(false);
expect(getKeychainAccessReady({
portable: true,
accessGranted: true,
persistent: true
})).toBe(true);
});
it('uses persistent pairing state for standard-mode readiness', () => {
expect(getKeychainAccessReady({
portable: false,
accessGranted: false,
persistent: true
})).toBe(true);
});
it('changes the consent identity when the credential-access policy changes', () => {
expect(getKeychainConsentVersion('1.1.0')).toMatch(
/^1\.1\.0\|(build-.+|keychain-policy-2)$/
);
expect(getKeychainConsentVersion('')).toBe('');
});
it('re-prompts when the app build keeps the same semantic version', () => {
expect(getKeychainStartupDecision({
portable: false,
appVersion: getKeychainConsentVersion('1.1.0'),
approvedVersion: '1.1.0',
accessGranted: true,
promptDismissed: true
})).toEqual({
deferKeychainHydration: true,
showKeychainPrompt: true
});
});
it('defers persistent hydration and shows the explanation after an update', () => {
expect(getKeychainStartupDecision({
portable: false,
@@ -80,6 +125,19 @@ describe('getKeychainStartupDecision', () => {
});
});
it('keeps persistent access deferred without prompting when the version API is unavailable', () => {
expect(getKeychainStartupDecision({
portable: false,
appVersion: '',
approvedVersion: '1.0.5',
accessGranted: true,
promptDismissed: false
})).toEqual({
deferKeychainHydration: true,
showKeychainPrompt: false
});
});
it('never gates portable pairing on credential-store access', () => {
expect(getKeychainStartupDecision({
portable: true,
+36 -2
View File
@@ -11,6 +11,38 @@ export type KeychainStartupDecision = {
showKeychainPrompt: boolean;
};
export type KeychainAccessReadiness = {
portable: boolean;
accessGranted: boolean;
persistent: boolean;
};
// The semantic app version can remain unchanged across release-candidate and
// packaging rebuilds. Use the build identity so an updated binary cannot skip
// Firelink's explanation and invoke the OS prompt directly. The policy epoch
// remains only as a safe fallback for builds created outside the Git checkout.
const KEYCHAIN_CONSENT_POLICY_VERSION = '2';
const buildId = typeof import.meta.env.VITE_BUILD_ID === 'string'
? import.meta.env.VITE_BUILD_ID.trim()
: '';
export const getKeychainConsentVersion = (appVersion: string): string => {
const normalizedVersion = appVersion.trim();
const consentIdentity = buildId && buildId !== 'unknown'
? `build-${buildId}`
: `keychain-policy-${KEYCHAIN_CONSENT_POLICY_VERSION}`;
return normalizedVersion
? `${normalizedVersion}|${consentIdentity}`
: '';
};
export const getKeychainAccessReady = ({
portable,
accessGranted,
persistent
}: KeychainAccessReadiness): boolean =>
portable ? accessGranted : persistent;
export const getKeychainStartupDecision = ({
portable,
appVersion,
@@ -25,9 +57,11 @@ export const getKeychainStartupDecision = ({
};
}
const versionChanged = Boolean(appVersion) && approvedVersion !== appVersion;
const versionKnown = Boolean(appVersion.trim());
const versionChanged = versionKnown && approvedVersion !== appVersion;
const mustDeferAccess = !accessGranted || !versionKnown || versionChanged;
return {
deferKeychainHydration: !accessGranted || versionChanged,
deferKeychainHydration: mustDeferAccess,
showKeychainPrompt: versionChanged || (!accessGranted && !promptDismissed)
};
};
+24
View File
@@ -5,6 +5,7 @@ import {
mergeLogSnapshotAndLiveEntries,
persistedLogEntry,
pushBoundedLogEntry,
redactLogText,
type LogEntry
} from './logEntries';
@@ -30,6 +31,29 @@ describe('log entry streaming', () => {
});
});
it('redacts live secrets, signed URL components, and the home path', () => {
const message = 'Cookie: session=secret; https://example.com/file?token=signed https://example.com/file#fragment /Users/nima/Downloads/file';
const redacted = redactLogText(message, '/Users/nima');
expect(redacted).toBe('Cookie: [redacted]; https://example.com/file?[redacted] https://example.com/file#[redacted] <HOME>/Downloads/file');
expect(redacted).not.toContain('secret');
expect(redacted).not.toContain('signed');
expect(redacted).not.toContain('/Users/nima');
});
it('redacts live content before it is formatted for display', () => {
expect(liveLogEntry(3, 'Authorization: Bearer secret').message).toContain('Authorization: [redacted]');
expect(liveLogEntry(3, 'Authorization: Bearer secret').message).not.toContain('secret');
});
it('redacts persisted content and quoted credential fields', () => {
const persisted = persistedLogEntry('{"api_key":"json-secret","path":"/Users/nima/file"}', '/Users/nima');
expect(persisted.message).toContain('api_key');
expect(persisted.message).not.toContain('json-secret');
expect(persisted.message).not.toContain('/Users/nima');
});
it('merges only the ordered snapshot-to-stream overlap', () => {
expect(mergeLogSnapshotAndLiveEntries(
[entry('one'), entry('repeat'), entry('three')],
+43 -7
View File
@@ -19,24 +19,60 @@ const LIVE_LEVELS: Record<number, LogLevel> = {
const levelFromMessage = (message: string): LogLevel | undefined =>
LEVEL_NAMES.find(level => message.includes(`[${level.toUpperCase()}]`));
export const persistedLogEntry = (message: string): LogEntry => ({
export const persistedLogEntry = (message: string, homePath = ''): LogEntry => ({
level: levelFromMessage(message) || 'Debug',
message
message: redactLogText(message, homePath)
});
export const redactLogText = (message: string, homePath = ''): string => {
const normalizedHome = homePath.trim();
const normalizedHomeWithForwardSlashes = normalizedHome.replace(/\\/g, '/');
let redacted = message;
if (normalizedHome) {
redacted = redacted.split(normalizedHome).join('<HOME>');
}
if (normalizedHomeWithForwardSlashes && normalizedHomeWithForwardSlashes !== normalizedHome) {
redacted = redacted.split(normalizedHomeWithForwardSlashes).join('<HOME>');
}
redacted = redacted.replace(
/(["'])(authorization|proxy-authorization|cookie|set-cookie|password|token|secret|credential|pairing[-_ ]?token|api[-_ ]?key)(["'])(\s*[:=]\s*)["'][^"\r\n,;]*["']/gi,
'$1$2$3$4[redacted]'
);
redacted = redacted.replace(
/([A-Za-z][A-Za-z0-9+.-]*:\/\/)[^@\s/?#]+@/g,
'$1[redacted]@'
);
redacted = redacted.replace(
/([A-Za-z][A-Za-z0-9+.-]*:\/\/[^\s?]+)\?[^\s]+/g,
'$1?[redacted]'
);
redacted = redacted.replace(
/([A-Za-z][A-Za-z0-9+.-]*:\/\/[^\s?#]+)#\S+/g,
'$1#[redacted]'
);
return redacted.replace(
/(authorization|proxy-authorization|cookie|set-cookie|password|token|secret|credential|pairing[-_ ]?token|api[-_ ]?key)(\s*)([:=])(\s*)([^\r\n,;]+)/gi,
'$1$2$3$4[redacted]'
);
};
export const liveLogEntry = (
numericLevel: number,
message: string,
now: Date = new Date()
now: Date = new Date(),
homePath = ''
): LogEntry => {
const level = levelFromMessage(message) || LIVE_LEVELS[numericLevel] || 'Debug';
const alreadyFormatted = /^\[\d{4}-\d{2}-\d{2}\]\[\d{2}:\d{2}:\d{2}\]\[(TRACE|DEBUG|INFO|WARN|ERROR)\]/.test(message);
const redactedMessage = redactLogText(message, homePath);
const level = levelFromMessage(redactedMessage) || LIVE_LEVELS[numericLevel] || 'Debug';
const alreadyFormatted = /^\[\d{4}-\d{2}-\d{2}\]\[\d{2}:\d{2}:\d{2}\]\[(TRACE|DEBUG|INFO|WARN|ERROR)\]/.test(redactedMessage);
return {
level,
message: alreadyFormatted
? message
: `[${now.toISOString().replace('T', ' ').substring(0, 19)}] [${level.toUpperCase()}] ${message}`
? redactedMessage
: `[${now.toISOString().replace('T', ' ').substring(0, 19)}] [${level.toUpperCase()}] ${redactedMessage}`
};
};
+17
View File
@@ -1,5 +1,6 @@
import { invokeCommand as invoke } from '../ipc';
import type { MediaMetadata } from '../bindings/MediaMetadata';
import type { MediaPlaylistMetadata } from '../bindings/MediaPlaylistMetadata';
type FetchMediaMetadataArgs = {
url: string;
@@ -13,6 +14,7 @@ type FetchMediaMetadataArgs = {
};
const inFlightMediaMetadata = new Map<string, Promise<MediaMetadata>>();
const inFlightMediaPlaylists = new Map<string, Promise<MediaPlaylistMetadata>>();
const metadataKey = (args: FetchMediaMetadataArgs) =>
JSON.stringify([
@@ -38,3 +40,18 @@ export const fetchMediaMetadataDeduped = (args: FetchMediaMetadataArgs): Promise
inFlightMediaMetadata.set(key, request);
return request;
};
export const fetchMediaPlaylistMetadataDeduped = (
args: FetchMediaMetadataArgs
): Promise<MediaPlaylistMetadata> => {
const key = metadataKey(args);
const existing = inFlightMediaPlaylists.get(key);
if (existing) return existing;
const request = invoke('fetch_media_playlist_metadata', args)
.finally(() => {
inFlightMediaPlaylists.delete(key);
});
inFlightMediaPlaylists.set(key, request);
return request;
};
+56
View File
@@ -1,13 +1,69 @@
import { defineConfig } from "vitest/config";
import { createHash } from "node:crypto";
import { existsSync, lstatSync, readdirSync, readFileSync, statSync } from "node:fs";
import { relative, resolve } from "node:path";
import react from "@vitejs/plugin-react";
import tailwindcss from '@tailwindcss/vite';
// @ts-expect-error process is a nodejs global
const host = process.env.TAURI_DEV_HOST;
const buildId = (() => {
// Release-candidate builds can keep the same semantic app version. The
// consent identity must represent the actual input to this build, not only
// the last committed revision: local rebuilds, untracked source files, and
// packaged working trees can have different code-signing identities while
// sharing the same HEAD or having no Git metadata at all.
const configured = process.env.VITE_BUILD_ID?.trim();
if (configured) return configured;
const projectRoot = process.cwd();
const inputRoots = [
'src',
'src-tauri/src',
'src-tauri/capabilities',
'src-tauri/Cargo.toml',
'src-tauri/Cargo.lock',
'src-tauri/build.rs',
'src-tauri/tauri.conf.json',
'index.html',
'package.json',
'package-lock.json',
'vite.config.ts'
];
const files: string[] = [];
const collectFiles = (path: string) => {
if (!existsSync(path) || lstatSync(path).isSymbolicLink()) return;
const stats = statSync(path);
if (stats.isFile()) {
files.push(path);
return;
}
if (!stats.isDirectory()) return;
for (const entry of readdirSync(path).sort()) {
collectFiles(resolve(path, entry));
}
};
inputRoots.forEach(input => collectFiles(resolve(projectRoot, input)));
if (files.length === 0) return process.env.GITHUB_SHA?.trim() || 'unknown';
const fingerprint = createHash('sha256');
for (const file of files.sort()) {
fingerprint.update(relative(projectRoot, file));
fingerprint.update('\0');
fingerprint.update(readFileSync(file));
fingerprint.update('\0');
}
return fingerprint.digest('hex').slice(0, 24);
})();
// https://vite.dev/config/
export default defineConfig(async () => ({
plugins: [react(), tailwindcss()],
define: {
'import.meta.env.VITE_BUILD_ID': JSON.stringify(buildId)
},
test: {
exclude: [
"Extensions/**",