mirror of
https://github.com/nimbold/Firelink.git
synced 2026-07-27 12:29:29 +00:00
Compare commits
25 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 61eb034db1 | |||
| c949cbb9ee | |||
| 566396e629 | |||
| db39cd2153 | |||
| f4b830414d | |||
| 0447d1cab7 | |||
| 513143d745 | |||
| a8dc4fb447 | |||
| 50c3da2f5d | |||
| 6ef911919d | |||
| 5144ecd39e | |||
| a56b859151 | |||
| 469faed7b9 | |||
| 79ce0c18a1 | |||
| d195a132b3 | |||
| feb5d8e87d | |||
| a136fa832c | |||
| f3d0e0be13 | |||
| edeef0ac54 | |||
| 9f333618fc | |||
| ed54a048ab | |||
| d2479f52be | |||
| 6e6ae51395 | |||
| 1d197432b2 | |||
| 9917f29743 |
@@ -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.
|
||||
|
||||
+1
-1
Submodule Extensions/Browser updated: 7e09b3f3a7...656ef06d15
@@ -5,7 +5,7 @@
|
||||
|
||||
**A fast, focused desktop download manager for macOS, Windows, and Linux.**
|
||||
|
||||
[](https://github.com/nimbold/Firelink/releases)
|
||||
[](https://github.com/nimbold/Firelink/releases)
|
||||
[](#platforms)
|
||||
[](#platforms)
|
||||
[](#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
@@ -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
@@ -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": {
|
||||
|
||||
Generated
+86
-86
@@ -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
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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() {
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
|
||||
Generated
+114
-112
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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(®istry, "request-1"));
|
||||
assert!(!acknowledge_extension_download(®istry, "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");
|
||||
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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,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",
|
||||
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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,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, };
|
||||
|
||||
@@ -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, };
|
||||
@@ -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, };
|
||||
|
||||
@@ -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, };
|
||||
@@ -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>, };
|
||||
@@ -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, };
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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 = [];
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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 };
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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';
|
||||
}
|
||||
};
|
||||
@@ -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
@@ -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];
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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
@@ -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}`
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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/**",
|
||||
|
||||
Reference in New Issue
Block a user