mirror of
https://github.com/nimbold/Firelink.git
synced 2026-07-27 04:19:19 +00:00
Compare commits
19 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 56b4c9f511 | |||
| c1fa87b953 | |||
| c7ec8cd666 | |||
| 9133e3b05b | |||
| 5bbee12602 | |||
| 7894c05bba | |||
| 33375df2ff | |||
| 1922db8ea0 | |||
| ba70662165 | |||
| 629a34d1e8 | |||
| 248f3869ad | |||
| 4f4c655de6 | |||
| 3fbd0742be | |||
| c5025fd5a0 | |||
| b1c84a0fb9 | |||
| fbb89cde8e | |||
| cd8ab5c12b | |||
| ed7c47cb49 | |||
| 8c035167c8 |
@@ -61,16 +61,39 @@ jobs:
|
||||
libdbus-1-dev \
|
||||
pkg-config \
|
||||
xvfb \
|
||||
libtinfo5
|
||||
libtinfo5 \
|
||||
rpm \
|
||||
cpio \
|
||||
libarchive-tools \
|
||||
desktop-file-utils \
|
||||
xdg-utils
|
||||
- run: npm ci
|
||||
- name: Provision locked engines
|
||||
if: runner.os != 'macOS'
|
||||
run: node scripts/provision-engines.js --target ${{ matrix.target }}
|
||||
- name: Build package
|
||||
if: runner.os != 'Linux'
|
||||
run: npm run tauri build -- -vv --target ${{ matrix.target }} --bundles ${{ matrix.bundles }}
|
||||
env:
|
||||
APPIMAGE_EXTRACT_AND_RUN: 1
|
||||
FIRELINK_OMIT_ENGINE_DIST_FOR_TAURI_BUNDLE: ${{ runner.os == 'Linux' && '1' || '' }}
|
||||
- name: Build Linux native packages
|
||||
if: runner.os == 'Linux'
|
||||
run: npm run tauri build -- -vv --target ${{ matrix.target }} --bundles deb,rpm
|
||||
env:
|
||||
APPIMAGE_EXTRACT_AND_RUN: 1
|
||||
- name: Verify and preserve Linux native packages
|
||||
if: runner.os == 'Linux'
|
||||
run: |
|
||||
node scripts/verify-linux-packages.js --target ${{ matrix.target }}
|
||||
mkdir -p "$RUNNER_TEMP/firelink-native-packages/deb" "$RUNNER_TEMP/firelink-native-packages/rpm"
|
||||
cp src-tauri/target/${{ matrix.target }}/release/bundle/deb/*.deb "$RUNNER_TEMP/firelink-native-packages/deb/"
|
||||
cp src-tauri/target/${{ matrix.target }}/release/bundle/rpm/*.rpm "$RUNNER_TEMP/firelink-native-packages/rpm/"
|
||||
- name: Build Linux AppImage
|
||||
if: runner.os == 'Linux'
|
||||
run: npm run tauri build -- -vv --target ${{ matrix.target }} --bundles appimage
|
||||
env:
|
||||
APPIMAGE_EXTRACT_AND_RUN: 1
|
||||
FIRELINK_OMIT_ENGINE_DIST_FOR_TAURI_BUNDLE: '1'
|
||||
- name: Install pinned appimagetool (Linux only)
|
||||
if: runner.os == 'Linux'
|
||||
env:
|
||||
@@ -128,6 +151,20 @@ jobs:
|
||||
path: ${{ matrix.artifact }}
|
||||
if-no-files-found: error
|
||||
retention-days: 3
|
||||
- uses: actions/upload-artifact@v7
|
||||
if: runner.os == 'Linux'
|
||||
with:
|
||||
name: Firelink-Linux-x64-Deb-${{ github.ref_name }}
|
||||
path: ${{ runner.temp }}/firelink-native-packages/deb/*.deb
|
||||
if-no-files-found: error
|
||||
retention-days: 3
|
||||
- uses: actions/upload-artifact@v7
|
||||
if: runner.os == 'Linux'
|
||||
with:
|
||||
name: Firelink-Linux-x64-RPM-${{ github.ref_name }}
|
||||
path: ${{ runner.temp }}/firelink-native-packages/rpm/*.rpm
|
||||
if-no-files-found: error
|
||||
retention-days: 3
|
||||
|
||||
publish:
|
||||
name: Publish GitHub release
|
||||
@@ -169,6 +206,8 @@ jobs:
|
||||
|
||||
rename_asset '*.dmg' "Firelink_${VERSION}_macOS-ARM64.dmg"
|
||||
rename_asset '*.AppImage' "Firelink_${VERSION}_Linux-x64.AppImage"
|
||||
rename_asset '*.deb' "Firelink_${VERSION}_Linux-x64.deb"
|
||||
rename_asset '*.rpm' "Firelink_${VERSION}_Linux-x64.rpm"
|
||||
rename_asset '*.exe' "Firelink_${VERSION}_Windows-x64-setup.exe"
|
||||
- name: Generate checksums
|
||||
run: |
|
||||
|
||||
@@ -17,6 +17,7 @@ implementation_plan.md
|
||||
CROSS_PLATFORM_CHECKLIST.md
|
||||
Cross-platform-checklist-gemini.MD
|
||||
YouTube_media_download_handoff.md
|
||||
Release_checklist.md
|
||||
|
||||
# Frontend output and logs
|
||||
node_modules/
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
[submodule "Extensions/Firefox"]
|
||||
path = Extensions/Firefox
|
||||
[submodule "Extensions/Browser"]
|
||||
path = Extensions/Browser
|
||||
url = https://github.com/nimbold/Firelink-Extension.git
|
||||
|
||||
@@ -5,6 +5,26 @@ 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.0.4] - 2026-07-12
|
||||
|
||||
### New
|
||||
- Automatically fill the Add window with valid links from the clipboard when you choose **Add link**, addressing [#10](https://github.com/nimbold/Firelink/issues/10).
|
||||
- Add a persistent, accessible collapse control for the **Folders** section so the sidebar can stay tidy, addressing [#13](https://github.com/nimbold/Firelink/issues/13).
|
||||
- Add verified Linux `.deb` and `.rpm` packages alongside the portable AppImage, completing the Linux packaging request in [#3](https://github.com/nimbold/Firelink/issues/3).
|
||||
|
||||
### Improved
|
||||
- Make queue actions safer: rapid clicks no longer open item properties by accident, and replacing an existing download preserves resumable progress instead of starting from zero, addressing [#11](https://github.com/nimbold/Firelink/issues/11) and [#12](https://github.com/nimbold/Firelink/issues/12).
|
||||
- Improve browser-captured batches so each link keeps its own metadata, headers, cookies, and destination instead of sharing stale request details.
|
||||
- Make media downloads more reliable with custom or system proxies, clearer metadata errors, more accurate quality choices, and steadier retry, speed, ETA, and progress updates, continuing the work reported in [#5](https://github.com/nimbold/Firelink/issues/5) and [#8](https://github.com/nimbold/Firelink/issues/8).
|
||||
- Keep the Logs view responsive while it is open and redact local paths and usernames from diagnostic output before it is shown or exported.
|
||||
- Keep dialogs and controls clear of macOS, Windows, and Linux window controls, and strengthen pause, resume, retry, and removal behavior during rapid actions.
|
||||
- Clarify incomplete-download handling: aria2 sidecar files show when a download is unfinished, preserve resume information, and are removed after completion, addressing [#14](https://github.com/nimbold/Firelink/issues/14).
|
||||
|
||||
### Fixed
|
||||
- Prevent stale background queue work from resurrecting, duplicating, or restarting downloads after a newer pause, remove, or edit action wins.
|
||||
- Keep explicit media requests on Firelink's configured browser-cookie source instead of forwarding raw browser cookies, while preserving the browser session for ordinary captured downloads.
|
||||
- Make final HTTP errors visible during metadata requests and prevent internal retry limits from multiplying unexpectedly.
|
||||
|
||||
## [1.0.3] - 2026-07-09
|
||||
|
||||
### Improved
|
||||
|
||||
Submodule
+1
Submodule Extensions/Browser added at 8a6dca9692
Submodule Extensions/Firefox deleted from be632f368c
@@ -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)
|
||||
@@ -37,14 +37,16 @@ Firelink is a desktop download manager for fast transfers, browser capture, medi
|
||||
|
||||
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.
|
||||
|
||||
The current desktop release is **1.0.4**, paired with Firelink Companion **2.0.3**.
|
||||
|
||||
## 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, and captured links.
|
||||
- **Add window** for metadata, duplicates, location choices, captured links, and clipboard-prefilled URLs.
|
||||
- **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, per-download overrides, and reveal/trash actions.
|
||||
- **File organization** with categories, default folders, a collapsible Folders section, per-download overrides, and reveal/trash actions.
|
||||
- **Browser handoff** through local pairing, signed requests, Add window review, replay protection, and server checks.
|
||||
- **Desktop integration** with tray controls, notifications, sounds, sleep prevention, and secure credential storage.
|
||||
- **Diagnostics** with engine health checks, structured logs, and package verification.
|
||||
@@ -57,10 +59,12 @@ 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. |
|
||||
| **Linux x64** | `.AppImage` | Make executable before launching if your desktop environment does not do that automatically. |
|
||||
| **Linux x64** | `.deb`, `.rpm`, or `.AppImage` | Use `.deb` for Debian-family systems, `.rpm` for Fedora/RPM-family systems, or AppImage as the portable fallback. 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, while AppImage remains the portable installation option.
|
||||
|
||||
## Browser Extension
|
||||
|
||||
<p align="center">
|
||||
@@ -81,9 +85,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.2 is the matching extension release for Firelink 1.0.3.
|
||||
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.3 is the matching extension release for Firelink 1.0.4.
|
||||
|
||||
The extension lives in [Firelink-Extension](https://github.com/nimbold/Firelink-Extension). This repo also vendors it as the `Extensions/Firefox` submodule.
|
||||
The extension lives in [Firelink-Extension](https://github.com/nimbold/Firelink-Extension). This repo also vendors it as the `Extensions/Browser` submodule.
|
||||
|
||||
## Platforms
|
||||
|
||||
@@ -91,7 +95,7 @@ The extension lives in [Firelink-Extension](https://github.com/nimbold/Firelink-
|
||||
| --- | --- |
|
||||
| **macOS arm64** | Supported. Native build, engine checks, launch smoke test, ad-hoc-signed DMG workflow. |
|
||||
| **Windows x64** | Supported. Native build, engine checks, silent installer smoke test, NSIS installer. |
|
||||
| **Linux x64** | Supported. Native build, engine checks, xvfb launch smoke test, AppImage. |
|
||||
| **Linux x64** | Supported. Native build, bundled-engine checks, package/AppImage launch smoke tests, `.deb`, `.rpm`, and AppImage. |
|
||||
|
||||
## Development
|
||||
|
||||
@@ -148,7 +152,7 @@ Build staging includes only the current target. See `engines.lock.json`, `engine
|
||||
├── src/ React and TypeScript interface
|
||||
├── src-tauri/ Rust backend, Tauri config, and native tests
|
||||
├── scripts/ Engine provisioning, release, and smoke-test tooling
|
||||
└── Extensions/Firefox/ Firelink Companion submodule
|
||||
└── Extensions/Browser/ Firelink Companion submodule
|
||||
```
|
||||
|
||||
## Help and Project Status
|
||||
|
||||
@@ -5,6 +5,8 @@ Targets:
|
||||
- macOS arm64 DMG
|
||||
- Windows x64 NSIS installer
|
||||
- Linux x64 AppImage
|
||||
- Linux x64 Debian package
|
||||
- Linux x64 RPM package
|
||||
|
||||
## Distribution policy
|
||||
|
||||
@@ -22,6 +24,8 @@ Firelink never falls back to system-installed media tools.
|
||||
- `scripts/stage-engines.js` creates one target-specific bundle payload.
|
||||
- `scripts/verify-binaries.js` runs architecture, packaging, version, and RPC checks.
|
||||
|
||||
Linux `.deb` and `.rpm` packages are built with the complete verified engine payload. The AppImage is built separately with the engine payload temporarily omitted, then repacked from the verified payload because the AppImage tooling can rewrite bundled native binaries.
|
||||
|
||||
yt-dlp must remain its official PyInstaller **onedir** distribution: launcher plus adjacent `_internal` runtime. Onefile builds are rejected because repeated extraction caused roughly 17-second startup latency.
|
||||
|
||||
## Version update
|
||||
|
||||
Generated
+86
-86
@@ -1,15 +1,15 @@
|
||||
{
|
||||
"name": "firelink",
|
||||
"version": "1.0.3",
|
||||
"version": "1.0.4",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "firelink",
|
||||
"version": "1.0.3",
|
||||
"version": "1.0.4",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@formkit/auto-animate": "^0.9.0",
|
||||
"@formkit/auto-animate": "^0.10.0",
|
||||
"@tailwindcss/vite": "^4.3.1",
|
||||
"@tauri-apps/api": "^2.11.1",
|
||||
"@tauri-apps/plugin-clipboard-manager": "^2.3.2",
|
||||
@@ -18,7 +18,7 @@
|
||||
"@tauri-apps/plugin-log": "^2.8.0",
|
||||
"@tauri-apps/plugin-notification": "^2.3.3",
|
||||
"@tauri-apps/plugin-opener": "^2",
|
||||
"lucide-react": "^1.23.0",
|
||||
"lucide-react": "^1.24.0",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"zustand": "^5.0.14"
|
||||
@@ -32,7 +32,7 @@
|
||||
"postcss": "^8.5.15",
|
||||
"tailwindcss": "^4.3.1",
|
||||
"typescript": "^7.0.2",
|
||||
"vite": "^8.1.3",
|
||||
"vite": "^8.1.4",
|
||||
"vitest": "^4.1.10"
|
||||
},
|
||||
"engines": {
|
||||
@@ -71,9 +71,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@formkit/auto-animate": {
|
||||
"version": "0.9.0",
|
||||
"resolved": "https://registry.npmjs.org/@formkit/auto-animate/-/auto-animate-0.9.0.tgz",
|
||||
"integrity": "sha512-VhP4zEAacXS3dfTpJpJ88QdLqMTcabMg0jwpOSxZ/VzfQVfl3GkZSCZThhGC5uhq/TxPHPzW0dzr4H9Bb1OgKA==",
|
||||
"version": "0.10.0",
|
||||
"resolved": "https://registry.npmjs.org/@formkit/auto-animate/-/auto-animate-0.10.0.tgz",
|
||||
"integrity": "sha512-KGomRttjUfORuPUaR/ZGQw+6xfMrTM+sxnILv7JAd9AmabU9rg9i6gF/iC0Ih+QpKCubJpCA/1DX9UHKE8cX+A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@jridgewell/gen-mapping": {
|
||||
@@ -140,18 +140,18 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@oxc-project/types": {
|
||||
"version": "0.137.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.137.0.tgz",
|
||||
"integrity": "sha512-WT+Gb24i8hmvo85AIv2oEYouEXkRlKAlT9WaCa3TfLgNCN+GhrJOGZuIlMouAh38Qe4QOx26eUOVsq70qXrywA==",
|
||||
"version": "0.139.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.139.0.tgz",
|
||||
"integrity": "sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/Boshen"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-android-arm64": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.3.tgz",
|
||||
"integrity": "sha512-DT6Z3PhvioeHMvxo+xHc3KtqggrI7CCTXCmC2h/5zUlp5jVitv7XEy+9q5/7v8IolhlioawpMo8Kg0EEBy7J0g==",
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.5.tgz",
|
||||
"integrity": "sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -165,9 +165,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-darwin-arm64": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.3.tgz",
|
||||
"integrity": "sha512-0NwgwsjM7LrsuVnXMK3koTpagBNOhloc/BNjKqZjv4V5zI5r13qx69uVhRx+o5Z0yy4Hzq+lpy7TAgUG/ocvrw==",
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.5.tgz",
|
||||
"integrity": "sha512-51Bnx9pNiMRKSUNtBfySkNJ9vMU9Hh3I1ozDd6gyPPYzaXCfnptUcEZxXGYFn+ul2dtcMUiqGR1Yai2K10uoTw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -181,9 +181,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-darwin-x64": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.3.tgz",
|
||||
"integrity": "sha512-YtiBp4disu6V560loT6PjMdiRaWmVvDNrUunAalbiFx2ggeJwxdAsgZMcoGP17uyAsTwAj5V1niksxlHnVQ1Sw==",
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.5.tgz",
|
||||
"integrity": "sha512-Tm+gbfC0aHu1tBA/JvKQh32S0K6YgCHkiAF4/W6xX0K0RmNuc94VeK419dJoE65R5aRxmo+noZQSWrAMF6yb6g==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -197,9 +197,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-freebsd-x64": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.3.tgz",
|
||||
"integrity": "sha512-yD3EkEdXk2LypPxnf/kSZHirarsI8gcPzc62SukhR9VJTyvV+F9Q/GxWNuCojc7sXyuVC4DxRGhdDK4X8VSsbw==",
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.5.tgz",
|
||||
"integrity": "sha512-JMzDKCCXq93YccG5gz3hvOs1oXRKAf0XYpfOS88e+wZrC8Iugj6j68867vrYZkvpDDpKn/KoKORThmchMpF6TA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -213,9 +213,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-arm-gnueabihf": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.3.tgz",
|
||||
"integrity": "sha512-c+8vieQbsD7HNAHKIA34w0GJ9FedFFuJGD+7E6vz7Q3uqAIugL5p45fhlsj4UaAsHpcmlqugBWMhA0/j7o0sIg==",
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.5.tgz",
|
||||
"integrity": "sha512-uML21j2K5TfPGutKxub+M+nLjZIrWjXQ5Grx4lCe/nimTj9B4L63zHpjXLl4y0L3mcm2htEQIb06oCG/szerNw==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -229,9 +229,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-arm64-gnu": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.3.tgz",
|
||||
"integrity": "sha512-50jD0uUwLvur7Zz9LHz17kaAdTPjn5wN93hEgjvmYFRZwiR7ZJYovTd5ipyWJDAnXKvZ+wgc+/Ika6dwSF5OcA==",
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.5.tgz",
|
||||
"integrity": "sha512-navSiuTMogvnQoZoM/v+l3ZWo50/NTwSHSzheABx/RCnmUPaKwq9qSo4Br2OYRs21+Fz8uFqITZM3H4opOB0/Q==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -245,9 +245,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-arm64-musl": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.3.tgz",
|
||||
"integrity": "sha512-BO9+oPL8K9poZJBfYPsXNtYjPE5uM3qeehT3aFcW4LITOl+iSqhp0abzjR2nWBUNjIZeKXjAEWBZ64WjNoHd6w==",
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.5.tgz",
|
||||
"integrity": "sha512-lAryqH7IteztmCXQXk0etKj4wBQ7Gx5S6LjKhsgp9zb8I5bsuvU/2llH1hDQcjsFeqIsovMVN339/8pUDDBXxA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -261,9 +261,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-ppc64-gnu": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.3.tgz",
|
||||
"integrity": "sha512-f3VpLB1vQ0Eo6ecr/6cekLnvYMFF4YBFoVGkfkvPLq1bAkbAwHYQPZKoAmG6OJyTcxxoC+AvezGx/S1obNC0Mw==",
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.5.tgz",
|
||||
"integrity": "sha512-fsK/sNBnxzBlL4O1JNrZakVQxPspqpED5dLtNsZS9oOKmtSpdNIzxH2kkol5HYTWJN47sE20ztMJPxfZ89qGOg==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
@@ -277,9 +277,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-s390x-gnu": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.3.tgz",
|
||||
"integrity": "sha512-AmurZ26Pqx/RI9N1gzEOCklkKXl927yjfXWUUS0O7Puh8ARM/Ob8qfrD3qnWksScdw6cSrW5PSHE9DyLu7+PtA==",
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.5.tgz",
|
||||
"integrity": "sha512-gLYb4BIadlfTOYT5gO503n8zQjXflgzpD0FcyKh0Mzx3rqCZKnHoJWV9xe1KXUJ5lx2JfcSHr/mhzS0PC/McAA==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
@@ -293,9 +293,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-x64-gnu": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.3.tgz",
|
||||
"integrity": "sha512-JJpqs8bRGITDOdbkNKnlojzBabbOHrqjSvDr0IVsZObE1lBcPjxItUEY9eWIDbxaJ3cGrXPWGfGkIxFijg/URg==",
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.5.tgz",
|
||||
"integrity": "sha512-FjcpEKUyJygHgs1o50VYNvkt5+7Le/VEdYt0AkRpkL33MnyQfwr8l5mXwMmfmTbyMPr5vJLC+8/Gd9gXnwU1QQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -309,9 +309,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-x64-musl": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.3.tgz",
|
||||
"integrity": "sha512-rSJcdjPxzA/by/6/rYs+v+bXU7UjvnbUWz8MJb6kh6+knqB1dCrtHg0uu7C/4haqJvqdkYHQ5IGn+tCH9GLW/g==",
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.5.tgz",
|
||||
"integrity": "sha512-Me+PfPI2TMeOQk0gYWfLQZtTktrmzbr8cDboqX83XKc7UrgAi55gF+2dUkWdxd19n55Essp2yeca+O9N5rBxHg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -325,9 +325,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-openharmony-arm64": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.3.tgz",
|
||||
"integrity": "sha512-hQ3/PYkDJICgevvyNcVrihVeqq7k1Pp3VZ9lY+dauAYUJKO+auqApvANhvR1An9BhmqYKvW2Mu1F9u4DXSMLxQ==",
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.5.tgz",
|
||||
"integrity": "sha512-yc5WrLzXks6zCQfn9Oxr8pORKyl/pF+QjHmW/Qx3qu0oyrrNC+y2JLTU1E2rcWYAmzlnqngWXHQjy51VzW70Vw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -341,9 +341,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-wasm32-wasi": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.3.tgz",
|
||||
"integrity": "sha512-Elcv/BtML9lXrV6JuKITc/grN2kYV9gjsQpW8Jfw4ioK0TOkjBjye0nnyqQNy9STNaI20lXNaQBRrD5gSgR0Yg==",
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.5.tgz",
|
||||
"integrity": "sha512-VbQGPX2b4r48TAMIM2cjgluIM1HYutm4pcTEJsle7iEP7sB1dFqtPLBVbdLAZCxy1txCcPxf4QFf4v8uvltPqA==",
|
||||
"cpu": [
|
||||
"wasm32"
|
||||
],
|
||||
@@ -359,9 +359,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-win32-arm64-msvc": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.3.tgz",
|
||||
"integrity": "sha512-2DrEfhluH9yhiaFApmsjsjwrSYbNcY1oFTzYSP1a535jDbV98zCFanA/96TBUd0iDFcxGmw9QRExwGCXz3U+/g==",
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.5.tgz",
|
||||
"integrity": "sha512-gHv82k63z4qpV5+Q1y/12KrK0ltWBukVDI8nZcbT7Tt/ZlOIVwppazneq0F93oDxTo3IgAMEDIoQh3E2n6mVsw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -375,9 +375,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-win32-x64-msvc": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.3.tgz",
|
||||
"integrity": "sha512-OL4OMk7UPXOeVGGd3qo5zJyPIljf4AFgk5QAkPPS+OoLuOOozhuaQGC18MxVTnw/06q93gShAJzlwnSCY9YtqA==",
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.5.tgz",
|
||||
"integrity": "sha512-tTZuDBPw85tEN5PQi1pnEBzDy0Z49HtScLAbD5t6hyeU92A95pRWaSMw1GZZi/RwgSgUIl0xrSlXIT/9QzvYSA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -1990,9 +1990,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/lucide-react": {
|
||||
"version": "1.23.0",
|
||||
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.23.0.tgz",
|
||||
"integrity": "sha512-38BpJcD0JhFosxHApP/BYsBetLpQFRoTRzEzstM/XCc3jsAG7wqaY1lgVwxiUe3xqYE+lNxo2PkCmYwXWrwwIw==",
|
||||
"version": "1.24.0",
|
||||
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.24.0.tgz",
|
||||
"integrity": "sha512-YT6mBD8lGKkg4nM39enlm94/sfJIiW0YKUT60fBy4YK8tai31ylg1VhGNWxkpSKHo9UagfnZqwIff3HTDQwXeA==",
|
||||
"license": "ISC",
|
||||
"peerDependencies": {
|
||||
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
@@ -2063,9 +2063,9 @@
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/picomatch": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
|
||||
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
||||
"version": "4.0.5",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz",
|
||||
"integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
@@ -2131,12 +2131,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/rolldown": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.3.tgz",
|
||||
"integrity": "sha512-1F1eEtUBtFvcGm1HQ9TiUIUHPQG7mSAODrhIzjxoUEFuo8OcbrGLiVLkevNgj84TE4lnHvnumwFjhJO5Eu135g==",
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.5.tgz",
|
||||
"integrity": "sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@oxc-project/types": "=0.137.0",
|
||||
"@oxc-project/types": "=0.139.0",
|
||||
"@rolldown/pluginutils": "^1.0.0"
|
||||
},
|
||||
"bin": {
|
||||
@@ -2146,21 +2146,21 @@
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@rolldown/binding-android-arm64": "1.1.3",
|
||||
"@rolldown/binding-darwin-arm64": "1.1.3",
|
||||
"@rolldown/binding-darwin-x64": "1.1.3",
|
||||
"@rolldown/binding-freebsd-x64": "1.1.3",
|
||||
"@rolldown/binding-linux-arm-gnueabihf": "1.1.3",
|
||||
"@rolldown/binding-linux-arm64-gnu": "1.1.3",
|
||||
"@rolldown/binding-linux-arm64-musl": "1.1.3",
|
||||
"@rolldown/binding-linux-ppc64-gnu": "1.1.3",
|
||||
"@rolldown/binding-linux-s390x-gnu": "1.1.3",
|
||||
"@rolldown/binding-linux-x64-gnu": "1.1.3",
|
||||
"@rolldown/binding-linux-x64-musl": "1.1.3",
|
||||
"@rolldown/binding-openharmony-arm64": "1.1.3",
|
||||
"@rolldown/binding-wasm32-wasi": "1.1.3",
|
||||
"@rolldown/binding-win32-arm64-msvc": "1.1.3",
|
||||
"@rolldown/binding-win32-x64-msvc": "1.1.3"
|
||||
"@rolldown/binding-android-arm64": "1.1.5",
|
||||
"@rolldown/binding-darwin-arm64": "1.1.5",
|
||||
"@rolldown/binding-darwin-x64": "1.1.5",
|
||||
"@rolldown/binding-freebsd-x64": "1.1.5",
|
||||
"@rolldown/binding-linux-arm-gnueabihf": "1.1.5",
|
||||
"@rolldown/binding-linux-arm64-gnu": "1.1.5",
|
||||
"@rolldown/binding-linux-arm64-musl": "1.1.5",
|
||||
"@rolldown/binding-linux-ppc64-gnu": "1.1.5",
|
||||
"@rolldown/binding-linux-s390x-gnu": "1.1.5",
|
||||
"@rolldown/binding-linux-x64-gnu": "1.1.5",
|
||||
"@rolldown/binding-linux-x64-musl": "1.1.5",
|
||||
"@rolldown/binding-openharmony-arm64": "1.1.5",
|
||||
"@rolldown/binding-wasm32-wasi": "1.1.5",
|
||||
"@rolldown/binding-win32-arm64-msvc": "1.1.5",
|
||||
"@rolldown/binding-win32-x64-msvc": "1.1.5"
|
||||
}
|
||||
},
|
||||
"node_modules/scheduler": {
|
||||
@@ -2335,15 +2335,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "8.1.3",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-8.1.3.tgz",
|
||||
"integrity": "sha512-Ds+gBRbj0lwRO2Y5hwnUBdxSwlAve9LeRyU4sNnAr0ewW0gWF0n5bgXgUzbgZ49MV9BVUAQUFYVcDUcilUExMA==",
|
||||
"version": "8.1.4",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-8.1.4.tgz",
|
||||
"integrity": "sha512-bTT9PsdWO+MQMNG9ZXIP/qM9wGh37DFxTV/sPq9cFpHr3w4jkgef032PkAL9jAqhk3Nz8NQw3O8n6/xFkqO4QQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"lightningcss": "^1.32.0",
|
||||
"picomatch": "^4.0.4",
|
||||
"picomatch": "^4.0.5",
|
||||
"postcss": "^8.5.16",
|
||||
"rolldown": "~1.1.3",
|
||||
"rolldown": "~1.1.4",
|
||||
"tinyglobby": "^0.2.17"
|
||||
},
|
||||
"bin": {
|
||||
|
||||
+4
-4
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "firelink",
|
||||
"private": true,
|
||||
"version": "1.0.3",
|
||||
"version": "1.0.4",
|
||||
"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",
|
||||
@@ -38,7 +38,7 @@
|
||||
"test": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"@formkit/auto-animate": "^0.9.0",
|
||||
"@formkit/auto-animate": "^0.10.0",
|
||||
"@tailwindcss/vite": "^4.3.1",
|
||||
"@tauri-apps/api": "^2.11.1",
|
||||
"@tauri-apps/plugin-clipboard-manager": "^2.3.2",
|
||||
@@ -47,7 +47,7 @@
|
||||
"@tauri-apps/plugin-log": "^2.8.0",
|
||||
"@tauri-apps/plugin-notification": "^2.3.3",
|
||||
"@tauri-apps/plugin-opener": "^2",
|
||||
"lucide-react": "^1.23.0",
|
||||
"lucide-react": "^1.24.0",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"zustand": "^5.0.14"
|
||||
@@ -61,7 +61,7 @@
|
||||
"postcss": "^8.5.15",
|
||||
"tailwindcss": "^4.3.1",
|
||||
"typescript": "^7.0.2",
|
||||
"vite": "^8.1.3",
|
||||
"vite": "^8.1.4",
|
||||
"vitest": "^4.1.10"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,10 +33,17 @@ function compareVersions(left, right) {
|
||||
}
|
||||
|
||||
function npmOutdated(cwd) {
|
||||
if (!fs.existsSync(path.join(cwd, 'package.json'))) {
|
||||
throw new Error(`npm workspace is missing package.json: ${cwd}`);
|
||||
}
|
||||
try {
|
||||
execFileSync('npm', ['outdated', '--json'], { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
|
||||
return {};
|
||||
} catch (error) {
|
||||
if (error.status !== 1) {
|
||||
const details = error.stderr?.toString().trim();
|
||||
throw new Error(details || `npm outdated failed in ${cwd}`);
|
||||
}
|
||||
const output = error.stdout?.toString() || '{}';
|
||||
return JSON.parse(output || '{}');
|
||||
}
|
||||
@@ -149,8 +156,8 @@ async function main() {
|
||||
|
||||
outdatedCount += printNpmReport('root npm', npmOutdated(repoRoot));
|
||||
outdatedCount += printNpmReport(
|
||||
'Firefox extension npm',
|
||||
npmOutdated(path.join(repoRoot, 'Extensions', 'Firefox'))
|
||||
'Browser extension npm',
|
||||
npmOutdated(path.join(repoRoot, 'Extensions', 'Browser'))
|
||||
);
|
||||
|
||||
const [
|
||||
|
||||
@@ -0,0 +1,264 @@
|
||||
#!/usr/bin/env node
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { collectRegularFiles, sha256 } from './engine-payload-integrity.js';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const repoRoot = path.resolve(__dirname, '..');
|
||||
|
||||
function argValue(name) {
|
||||
const index = process.argv.indexOf(name);
|
||||
return index >= 0 ? process.argv[index + 1] : undefined;
|
||||
}
|
||||
|
||||
function fail(message) {
|
||||
console.error(`[FAIL] ${message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
function run(command, args, options = {}) {
|
||||
const result = spawnSync(command, args, {
|
||||
cwd: options.cwd ?? repoRoot,
|
||||
env: { ...process.env, ...options.env },
|
||||
stdio: options.stdio ?? 'inherit',
|
||||
encoding: options.stdio === 'pipe' ? 'utf8' : undefined,
|
||||
});
|
||||
|
||||
if (result.error) {
|
||||
fail(`Failed to run ${command}: ${result.error.message}`);
|
||||
}
|
||||
if (result.status !== 0) {
|
||||
if (options.stdio === 'pipe') {
|
||||
if (result.stdout) process.stdout.write(result.stdout);
|
||||
if (result.stderr) process.stderr.write(result.stderr);
|
||||
}
|
||||
fail(`${command} exited with status ${result.status}`);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function findSingle(directory, extension, label) {
|
||||
if (!fs.existsSync(directory) || !fs.statSync(directory).isDirectory()) {
|
||||
fail(`${label} directory does not exist: ${directory}`);
|
||||
}
|
||||
|
||||
const matches = fs.readdirSync(directory)
|
||||
.filter(name => name.endsWith(extension))
|
||||
.map(name => path.join(directory, name));
|
||||
if (matches.length !== 1) {
|
||||
fail(`Expected exactly one ${label}, found ${matches.length} in ${directory}`);
|
||||
}
|
||||
return matches[0];
|
||||
}
|
||||
|
||||
function assertPackageListing(packageFile, packageType, expectedPath) {
|
||||
const result = packageType === 'deb'
|
||||
? run('dpkg-deb', ['--contents', packageFile], { stdio: 'pipe' })
|
||||
: run('rpm', ['-qpl', packageFile], { stdio: 'pipe' });
|
||||
const listing = result.stdout ?? '';
|
||||
assertSafePackageListing(listing, packageType);
|
||||
if (!listing.includes(expectedPath)) {
|
||||
fail(`${packageType} package is missing ${expectedPath}`);
|
||||
}
|
||||
if (!/usr\/share\/applications\/[^/]+\.desktop/.test(listing)) {
|
||||
fail(`${packageType} package is missing its desktop entry`);
|
||||
}
|
||||
}
|
||||
|
||||
function assertPackageRecommendations(packageFile, packageType) {
|
||||
const result = packageType === 'deb'
|
||||
? run('dpkg-deb', ['--field', packageFile, 'Recommends'], { stdio: 'pipe' })
|
||||
: run('rpm', ['-qp', '--recommends', packageFile], { stdio: 'pipe' });
|
||||
const recommendations = result.stdout ?? '';
|
||||
const dependencyNames = packageType === 'deb'
|
||||
? recommendations
|
||||
.split(/[,|]/)
|
||||
.map(value => value.trim().split(/\s+/, 1)[0]?.split(':', 1)[0])
|
||||
: recommendations
|
||||
.split('\n')
|
||||
.map(value => value.trim().split(/\s+/, 1)[0]);
|
||||
for (const dependency of ['desktop-file-utils', 'xdg-utils']) {
|
||||
if (!dependencyNames.includes(dependency)) {
|
||||
fail(`${packageType} package is missing its ${dependency} recommendation`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function parseDebianPackagePath(line) {
|
||||
const match = line.match(/^\S+\s+\S+\s+\S+\s+\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}\s+(.*)$/);
|
||||
if (!match) {
|
||||
throw new Error(`Could not parse a Debian package path: ${line}`);
|
||||
}
|
||||
return match[1].replace(/^\.\//, '');
|
||||
}
|
||||
|
||||
function assertSafePackageListing(listing, packageType) {
|
||||
const lines = listing.split('\n').filter(Boolean);
|
||||
const paths = packageType === 'deb'
|
||||
? lines.map(line => {
|
||||
try {
|
||||
return parseDebianPackagePath(line);
|
||||
} catch (error) {
|
||||
fail(error.message);
|
||||
}
|
||||
})
|
||||
: lines.map(line => line.replace(/^\/+/, ''));
|
||||
|
||||
for (const packagePath of paths) {
|
||||
const parts = packagePath.split('/');
|
||||
if (parts.includes('..') || (parts[0] !== 'usr' && packagePath !== 'usr')) {
|
||||
fail(`${packageType} package contains an unsafe path: ${packagePath}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function extractDeb(packageFile, destination) {
|
||||
fs.mkdirSync(destination, { recursive: true });
|
||||
run('dpkg-deb', ['--extract', packageFile, destination]);
|
||||
}
|
||||
|
||||
function extractRpm(packageFile, destination) {
|
||||
fs.mkdirSync(destination, { recursive: true });
|
||||
run('bsdtar', [
|
||||
'--extract',
|
||||
'--file', packageFile,
|
||||
'--directory', destination,
|
||||
'--no-same-owner',
|
||||
'--no-same-permissions',
|
||||
]);
|
||||
}
|
||||
|
||||
function readPayloadManifest(root, label) {
|
||||
const manifestPath = path.join(root, 'payload-manifest.json');
|
||||
if (!fs.existsSync(manifestPath)) {
|
||||
fail(`${label} payload manifest is missing`);
|
||||
}
|
||||
let manifest;
|
||||
try {
|
||||
manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
|
||||
} catch (error) {
|
||||
fail(`${label} payload manifest is invalid: ${error.message}`);
|
||||
}
|
||||
return manifest;
|
||||
}
|
||||
|
||||
function findPayloadRoot(root, target, label) {
|
||||
const expectedBinary = `yt-dlp-${target}`;
|
||||
const matches = [];
|
||||
const walk = directory => {
|
||||
for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
|
||||
const candidate = path.join(directory, entry.name);
|
||||
if (!entry.isDirectory()) continue;
|
||||
if (fs.existsSync(path.join(candidate, expectedBinary)) && fs.existsSync(path.join(candidate, 'payload-manifest.json'))) {
|
||||
matches.push(candidate);
|
||||
}
|
||||
walk(candidate);
|
||||
}
|
||||
};
|
||||
walk(root);
|
||||
if (matches.length !== 1) {
|
||||
fail(`Expected exactly one ${label} engine payload root, found ${matches.length}`);
|
||||
}
|
||||
return matches[0];
|
||||
}
|
||||
|
||||
function assertPayloadMatchesSource(sourceRoot, packagedRoot, target, label) {
|
||||
const sourceManifest = readPayloadManifest(sourceRoot, 'Provisioned engine');
|
||||
if (sourceManifest.target !== target) {
|
||||
fail(`Provisioned engine payload target mismatch: expected ${target}, got ${sourceManifest.target}`);
|
||||
}
|
||||
|
||||
const packagedManifest = readPayloadManifest(packagedRoot, label);
|
||||
if (packagedManifest.target !== target) {
|
||||
fail(`${label} payload target mismatch: expected ${target}, got ${packagedManifest.target}`);
|
||||
}
|
||||
|
||||
const expectedFiles = Object.keys(sourceManifest.files || {}).sort();
|
||||
const packagedFiles = collectRegularFiles(packagedRoot, { ignoredNames: ['payload-manifest.json'] })
|
||||
.map(file => path.relative(packagedRoot, file).split(path.sep).join('/'))
|
||||
.sort();
|
||||
if (JSON.stringify(packagedFiles) !== JSON.stringify(expectedFiles)) {
|
||||
fail(`${label} payload files differ from the provisioned engine manifest`);
|
||||
}
|
||||
|
||||
for (const relative of expectedFiles) {
|
||||
const packagedFile = path.join(packagedRoot, relative);
|
||||
if (sha256(packagedFile) !== sourceManifest.files[relative]) {
|
||||
fail(`${label} payload checksum mismatch: ${relative}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function findExecutable(root) {
|
||||
const candidates = [
|
||||
path.join(root, 'usr', 'bin', 'firelink'),
|
||||
path.join(root, 'usr', 'bin', 'Firelink'),
|
||||
];
|
||||
const executable = candidates.find(candidate => {
|
||||
if (!fs.existsSync(candidate)) return false;
|
||||
const stat = fs.lstatSync(candidate);
|
||||
return stat.isFile() && !stat.isSymbolicLink();
|
||||
});
|
||||
if (!executable) {
|
||||
fail(`Packaged Firelink executable was not found under ${root}`);
|
||||
}
|
||||
return executable;
|
||||
}
|
||||
|
||||
function verifyExtractedPackage(packageType, packageFile, target, root) {
|
||||
const sourceRoot = path.join(repoRoot, 'src-tauri', 'provisioned-engines', target);
|
||||
const packagedRoot = findPayloadRoot(root, target, packageType);
|
||||
assertPayloadMatchesSource(sourceRoot, packagedRoot, target, packageType);
|
||||
run(process.execPath, [
|
||||
path.join(repoRoot, 'scripts', 'verify-binaries.js'),
|
||||
'--search-root',
|
||||
root,
|
||||
'--target',
|
||||
target,
|
||||
]);
|
||||
|
||||
const executable = findExecutable(root);
|
||||
run('xvfb-run', [
|
||||
'-a',
|
||||
process.execPath,
|
||||
path.join(repoRoot, 'scripts', 'smoke-packaged-app.js'),
|
||||
'--executable',
|
||||
executable,
|
||||
], { env: { APPDIR: root } });
|
||||
}
|
||||
|
||||
function main() {
|
||||
const target = argValue('--target');
|
||||
if (!target) fail('Pass --target <Rust target triple>.');
|
||||
if (os.platform() !== 'linux') fail('Linux package verification must run on Linux.');
|
||||
|
||||
const bundleRoot = path.join(repoRoot, 'src-tauri', 'target', target, 'release', 'bundle');
|
||||
const deb = findSingle(path.join(bundleRoot, 'deb'), '.deb', 'Debian package');
|
||||
const rpm = findSingle(path.join(bundleRoot, 'rpm'), '.rpm', 'RPM package');
|
||||
const extractionRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'firelink-linux-packages-'));
|
||||
const debRoot = path.join(extractionRoot, 'deb');
|
||||
const rpmRoot = path.join(extractionRoot, 'rpm');
|
||||
|
||||
try {
|
||||
assertPackageListing(deb, 'deb', 'usr/share/metainfo/com.nimbold.firelink.metainfo.xml');
|
||||
assertPackageRecommendations(deb, 'deb');
|
||||
extractDeb(deb, debRoot);
|
||||
verifyExtractedPackage('deb', deb, target, debRoot);
|
||||
|
||||
assertPackageListing(rpm, 'rpm', 'usr/share/metainfo/com.nimbold.firelink.metainfo.xml');
|
||||
assertPackageRecommendations(rpm, 'rpm');
|
||||
extractRpm(rpm, rpmRoot);
|
||||
verifyExtractedPackage('rpm', rpm, target, rpmRoot);
|
||||
console.log('Linux .deb and .rpm payload and launch verification passed.');
|
||||
} finally {
|
||||
fs.rmSync(extractionRoot, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
if (process.argv[1] && path.resolve(process.argv[1]) === __filename) {
|
||||
main();
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { parseDebianPackagePath } from './verify-linux-packages.js';
|
||||
|
||||
test('parses current dpkg-deb listings without a ./ prefix', () => {
|
||||
assert.equal(
|
||||
parseDebianPackagePath('drwxr-xr-x 0/0 0 2026-07-12 07:24 usr/share/'),
|
||||
'usr/share/'
|
||||
);
|
||||
});
|
||||
|
||||
test('parses legacy dpkg-deb listings with a ./ prefix', () => {
|
||||
assert.equal(
|
||||
parseDebianPackagePath('-rwxr-xr-x root/root 123 2026-07-12 07:24 ./usr/bin/firelink'),
|
||||
'usr/bin/firelink'
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects malformed dpkg-deb listing lines', () => {
|
||||
assert.throws(
|
||||
() => parseDebianPackagePath('not a dpkg-deb listing'),
|
||||
/Could not parse a Debian package path/
|
||||
);
|
||||
});
|
||||
Generated
+636
-313
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "firelink"
|
||||
version = "1.0.3"
|
||||
version = "1.0.4"
|
||||
description = "A fast cross-platform desktop download manager powered by Rust and Tauri"
|
||||
authors = ["NimBold"]
|
||||
edition = "2021"
|
||||
@@ -31,10 +31,12 @@ serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
tokio = { version = "1", features = ["fs", "process", "io-util", "rt", "rt-multi-thread", "macros", "sync", "time"] }
|
||||
regex = "1.10"
|
||||
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json", "stream", "socks"] }
|
||||
reqwest = { version = "0.13", default-features = false, features = ["rustls-no-provider", "json", "stream", "socks"] }
|
||||
rustls = { version = "0.23", default-features = false, features = ["ring", "std", "tls12"] }
|
||||
uuid = { version = "1", features = ["v4"] }
|
||||
ts-rs = { version = "12", features = ["serde-compat", "uuid-impl"] }
|
||||
tauri-plugin-notification = "2.3.3"
|
||||
tauri-plugin-clipboard-manager = "2.3.2"
|
||||
sysinfo = "0.39.3"
|
||||
hmac = "0.13"
|
||||
sha2 = "0.11"
|
||||
@@ -48,7 +50,7 @@ sysproxy = "0.3.0"
|
||||
semver = "1.0.28"
|
||||
keepawake = "0.6.0"
|
||||
system_shutdown = "4.1.0"
|
||||
tokio-tungstenite = "0.29.0"
|
||||
tokio-tungstenite = "0.30.0"
|
||||
futures-util = { version = "0.3.32", features = ["sink"] }
|
||||
chrono = "0.4.38"
|
||||
url = "2"
|
||||
@@ -57,12 +59,13 @@ log = "0.4.32"
|
||||
tauri-plugin-log = "2"
|
||||
trash = "5"
|
||||
async-trait = "0.1"
|
||||
keyring-core = "1.0.0"
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
apple-native-keyring-store = { version = "1.0.0", features = ["keychain"] }
|
||||
objc = "0.2.7"
|
||||
keyring = { version = "3", features = ["apple-native"] }
|
||||
|
||||
[target.'cfg(target_os = "windows")'.dependencies]
|
||||
keyring = { version = "3", features = ["windows-native"] }
|
||||
windows-native-keyring-store = "1.1.0"
|
||||
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
keyring = { version = "3", features = ["sync-secret-service", "vendored"] }
|
||||
zbus-secret-service-keyring-store = { version = "1.0.0", features = ["crypto-rust"] }
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
"dialog:default",
|
||||
"log:default",
|
||||
"notification:default",
|
||||
"notification:allow-is-permission-granted"
|
||||
"notification:allow-is-permission-granted",
|
||||
"clipboard-manager:allow-read-text"
|
||||
]
|
||||
}
|
||||
|
||||
+153
-5
@@ -12,6 +12,7 @@ const CURRENT_SCHEMA_VERSION: i64 = 1;
|
||||
const TOKEN_CHANGED_NOTICE: &str = "pairing-token-changed";
|
||||
pub const PAIRING_TOKEN_KEYCHAIN_ID: &str = "extension-pairing-token";
|
||||
const KEYCHAIN_SERVICE: &str = "com.firelink.app";
|
||||
static KEYRING_OPERATION_LOCK: Mutex<()> = Mutex::new(());
|
||||
|
||||
pub struct DbState {
|
||||
conn: Mutex<Connection>,
|
||||
@@ -864,21 +865,168 @@ pub fn save_pairing_token_to_settings(
|
||||
save_settings(connection, &updated)
|
||||
}
|
||||
|
||||
fn ensure_keyring_store() -> Result<(), String> {
|
||||
if keyring_core::get_default_store().is_some() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
static STORE_INIT_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
|
||||
let _guard = STORE_INIT_LOCK
|
||||
.lock()
|
||||
.map_err(|_| "keyring store initialization lock is unavailable".to_string())?;
|
||||
|
||||
if keyring_core::get_default_store().is_some() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
let store = apple_native_keyring_store::keychain::Store::new()
|
||||
.map_err(|error| error.to_string())?;
|
||||
keyring_core::set_default_store(store);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
let store =
|
||||
windows_native_keyring_store::Store::new().map_err(|error| error.to_string())?;
|
||||
keyring_core::set_default_store(store);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
let store =
|
||||
zbus_secret_service_keyring_store::Store::new().map_err(|error| error.to_string())?;
|
||||
keyring_core::set_default_store(store);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))]
|
||||
{
|
||||
Err("No native keyring store is available for this platform".to_string())
|
||||
}
|
||||
}
|
||||
|
||||
fn keychain_entry_with_target(
|
||||
id: &str,
|
||||
target: Option<&str>,
|
||||
) -> Result<keyring_core::Entry, String> {
|
||||
ensure_keyring_store()?;
|
||||
if let Some(target) = target {
|
||||
return keyring_core::Entry::new_with_modifiers(
|
||||
KEYCHAIN_SERVICE,
|
||||
id,
|
||||
&std::collections::HashMap::from([("target", target)]),
|
||||
)
|
||||
.map_err(|error| error.to_string());
|
||||
}
|
||||
keyring_core::Entry::new(KEYCHAIN_SERVICE, id).map_err(|error| error.to_string())
|
||||
}
|
||||
|
||||
fn keychain_entry(id: &str) -> Result<keyring_core::Entry, String> {
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
return keychain_entry_with_target(id, Some("default"));
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
keychain_entry_with_target(id, None)
|
||||
}
|
||||
|
||||
fn lock_keyring_operations() -> Result<std::sync::MutexGuard<'static, ()>, String> {
|
||||
KEYRING_OPERATION_LOCK
|
||||
.lock()
|
||||
.map_err(|_| "keyring operation lock is unavailable".to_string())
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn legacy_linux_keychain_entries(id: &str) -> Result<Vec<keyring_core::Entry>, String> {
|
||||
ensure_keyring_store()?;
|
||||
let entries = keyring_core::Entry::search(&std::collections::HashMap::from([
|
||||
("service", KEYCHAIN_SERVICE),
|
||||
("username", id),
|
||||
]))
|
||||
.map_err(|error| error.to_string())?;
|
||||
let mut legacy = Vec::new();
|
||||
for entry in entries {
|
||||
let attributes = entry.get_attributes().map_err(|error| error.to_string())?;
|
||||
if !attributes.contains_key("target") {
|
||||
legacy.push(entry);
|
||||
}
|
||||
}
|
||||
Ok(legacy)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn unique_legacy_linux_keychain_entry(id: &str) -> Result<Option<keyring_core::Entry>, String> {
|
||||
let mut entries = legacy_linux_keychain_entries(id)?;
|
||||
match entries.len() {
|
||||
0 => Ok(None),
|
||||
1 => Ok(entries.pop()),
|
||||
count => Err(format!(
|
||||
"Entry is matched by {count} legacy Linux credentials"
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_keychain_password(id: &str, password: &str) -> Result<(), String> {
|
||||
let entry = keyring::Entry::new(KEYCHAIN_SERVICE, id).map_err(|error| error.to_string())?;
|
||||
let _guard = lock_keyring_operations()?;
|
||||
let entry = keychain_entry(id)?;
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
if let Err(error) = entry.get_credential() {
|
||||
match error {
|
||||
keyring_core::Error::NoEntry => {
|
||||
if let Some(legacy) = unique_legacy_linux_keychain_entry(id)? {
|
||||
return legacy
|
||||
.set_password(password)
|
||||
.map_err(|error| error.to_string());
|
||||
}
|
||||
}
|
||||
error => return Err(error.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
entry
|
||||
.set_password(password)
|
||||
.map_err(|error| error.to_string())
|
||||
}
|
||||
|
||||
pub fn get_keychain_password(id: &str) -> Result<String, String> {
|
||||
let entry = keyring::Entry::new(KEYCHAIN_SERVICE, id).map_err(|error| error.to_string())?;
|
||||
entry.get_password().map_err(|error| error.to_string())
|
||||
let _guard = lock_keyring_operations()?;
|
||||
let entry = keychain_entry(id)?;
|
||||
match entry.get_password() {
|
||||
Ok(password) => Ok(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()),
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
Err(error) => Err(error.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn delete_keychain_password(id: &str) -> Result<(), String> {
|
||||
let entry = keyring::Entry::new(KEYCHAIN_SERVICE, id).map_err(|error| error.to_string())?;
|
||||
let _ = entry.delete_credential();
|
||||
let _guard = lock_keyring_operations()?;
|
||||
let entry = keychain_entry(id)?;
|
||||
match entry.delete_credential() {
|
||||
Ok(()) | Err(keyring_core::Error::NoEntry) => {}
|
||||
Err(error) => return Err(error.to_string()),
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
for legacy in legacy_linux_keychain_entries(id)? {
|
||||
match legacy.delete_credential() {
|
||||
Ok(()) | Err(keyring_core::Error::NoEntry) => {}
|
||||
Err(error) => return Err(error.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@@ -308,18 +308,45 @@ fn normalize_download(payload: ExtensionRequest) -> Option<ExtensionDownload> {
|
||||
matches!(url.scheme(), "http" | "https").then(|| url.to_string())
|
||||
});
|
||||
let filename = payload.filename.and_then(|value| sanitize_filename(&value));
|
||||
let headers = normalize_headers(payload.headers, payload.media);
|
||||
|
||||
Some(ExtensionDownload {
|
||||
urls,
|
||||
referer,
|
||||
silent: payload.silent,
|
||||
filename,
|
||||
headers: payload.headers.filter(|value| !value.trim().is_empty()),
|
||||
cookies: payload.cookies.filter(|value| !value.trim().is_empty()),
|
||||
headers,
|
||||
// Explicit media is resolved by yt-dlp, which must use Firelink's
|
||||
// configured browser-cookie source. Forwarding a browser's complete
|
||||
// Cookie header can exceed upstream limits and makes old extension
|
||||
// builds pay for a doomed metadata request before retrying. Ordinary
|
||||
// captured downloads still need their exact request cookies.
|
||||
cookies: (!payload.media)
|
||||
.then_some(payload.cookies)
|
||||
.flatten()
|
||||
.filter(|value| !value.trim().is_empty()),
|
||||
media: payload.media,
|
||||
})
|
||||
}
|
||||
|
||||
fn normalize_headers(headers: Option<String>, media: bool) -> Option<String> {
|
||||
let headers = headers?;
|
||||
if !media {
|
||||
return (!headers.trim().is_empty()).then_some(headers);
|
||||
}
|
||||
|
||||
let filtered = headers
|
||||
.lines()
|
||||
.filter(|line| {
|
||||
line.split_once(':')
|
||||
.map(|(name, _)| !name.trim().eq_ignore_ascii_case("cookie"))
|
||||
.unwrap_or(true)
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
(!filtered.trim().is_empty()).then_some(filtered)
|
||||
}
|
||||
|
||||
fn normalize_url(raw_url: &str) -> Option<String> {
|
||||
let url = Url::parse(raw_url.trim()).ok()?;
|
||||
matches!(url.scheme(), "http" | "https" | "ftp" | "sftp").then(|| url.to_string())
|
||||
@@ -449,8 +476,8 @@ fn is_allowed_origin(origin: &str) -> bool {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
add_server_identity, is_valid_client_nonce, sign_server_proof, PROTOCOL_VERSION_HEADER,
|
||||
SERVER_HEADER,
|
||||
add_server_identity, is_valid_client_nonce, normalize_download, sign_server_proof,
|
||||
ExtensionRequest, PROTOCOL_VERSION_HEADER, SERVER_HEADER,
|
||||
};
|
||||
use axum::{http::StatusCode, middleware, routing::get, Router};
|
||||
use hmac::{Hmac, KeyInit, Mac};
|
||||
@@ -470,6 +497,7 @@ mod tests {
|
||||
axum::serve(listener, app).await.unwrap();
|
||||
});
|
||||
|
||||
crate::ensure_reqwest_crypto_provider();
|
||||
let response = reqwest::get(format!("http://{address}/ping"))
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -491,6 +519,47 @@ mod tests {
|
||||
assert!(!is_valid_client_nonce("0123456789abcdef0123456789abcdeg"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_media_drops_the_extension_cookie_header() {
|
||||
let download = normalize_download(ExtensionRequest {
|
||||
urls: vec!["https://www.youtube.com/watch?v=example".to_string()],
|
||||
referer: None,
|
||||
silent: false,
|
||||
filename: None,
|
||||
headers: Some(format!(
|
||||
"Cookie: stale={};\nUser-Agent: Firefox",
|
||||
"x".repeat(64 * 1024)
|
||||
)),
|
||||
cookies: Some(format!("large={}", "x".repeat(64 * 1024))),
|
||||
media: true,
|
||||
})
|
||||
.expect("valid media handoff");
|
||||
|
||||
assert!(download.media);
|
||||
assert!(download.cookies.is_none());
|
||||
assert_eq!(download.headers.as_deref(), Some("User-Agent: Firefox"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn regular_capture_preserves_the_extension_cookie_header() {
|
||||
let download = normalize_download(ExtensionRequest {
|
||||
urls: vec!["https://example.com/private.zip".to_string()],
|
||||
referer: None,
|
||||
silent: true,
|
||||
filename: None,
|
||||
headers: None,
|
||||
cookies: Some("session=browser-cookie-header".to_string()),
|
||||
media: false,
|
||||
})
|
||||
.expect("valid download handoff");
|
||||
|
||||
assert!(!download.media);
|
||||
assert_eq!(
|
||||
download.cookies.as_deref(),
|
||||
Some("session=browser-cookie-header")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn signs_server_proof_with_timestamp_nonce_and_bound_port() {
|
||||
let token = Arc::new(RwLock::new("pairing-token".to_string()));
|
||||
|
||||
@@ -278,12 +278,11 @@ pub struct PersistedSettings {
|
||||
pub prevents_sleep_while_downloading: bool,
|
||||
pub media_cookie_source: MediaCookieSource,
|
||||
pub site_logins: Vec<SiteLogin>,
|
||||
/// HMAC shared secret for the browser extension. It is persisted in the
|
||||
/// settings database so that startup never needs to touch the OS keychain.
|
||||
/// The keychain is still used as defence-in-depth — grant_keychain_access
|
||||
/// writes the token there — but the DB copy is the primary read path,
|
||||
/// eliminating the OS credential prompt that macOS shows when the binary
|
||||
/// signature changes after an update.
|
||||
// HMAC shared secret for the browser extension. It is persisted in the
|
||||
// settings database so startup never needs to touch the OS keychain.
|
||||
// The keychain is still used as defense-in-depth by grant_keychain_access,
|
||||
// but the DB copy is the primary read path, eliminating the OS credential
|
||||
// prompt that macOS shows when the binary signature changes after an update.
|
||||
#[serde(default)]
|
||||
pub extension_pairing_token: String,
|
||||
pub auto_check_updates: bool,
|
||||
|
||||
+408
-117
@@ -3,7 +3,7 @@
|
||||
// Learn more about Tauri commands at https://tauri.app/develop/calling-rust/
|
||||
use regex::Regex;
|
||||
use serde::Serialize;
|
||||
use std::collections::{HashMap, VecDeque};
|
||||
use std::collections::{BTreeSet, HashMap, VecDeque};
|
||||
use std::hash::{Hash, Hasher};
|
||||
use std::path::PathBuf;
|
||||
use std::sync::OnceLock;
|
||||
@@ -12,6 +12,13 @@ use tauri::{Emitter, Manager};
|
||||
use tauri_plugin_deep_link::DeepLinkExt;
|
||||
use ts_rs::TS;
|
||||
|
||||
pub(crate) fn ensure_reqwest_crypto_provider() {
|
||||
static INSTALL: OnceLock<()> = OnceLock::new();
|
||||
INSTALL.get_or_init(|| {
|
||||
let _ = rustls::crypto::ring::default_provider().install_default();
|
||||
});
|
||||
}
|
||||
|
||||
fn sanitize_metadata_filename(filename: &str) -> Option<String> {
|
||||
let normalized = filename.trim().replace('\\', "/");
|
||||
let basename = std::path::Path::new(&normalized)
|
||||
@@ -119,6 +126,16 @@ fn metadata_filename_from_response(
|
||||
.unwrap_or_else(|| "download".to_string())
|
||||
}
|
||||
|
||||
fn metadata_response_error(status: reqwest::StatusCode) -> Option<String> {
|
||||
(!status.is_success()).then(|| {
|
||||
format!(
|
||||
"Metadata request failed with HTTP {} ({})",
|
||||
status.as_u16(),
|
||||
status.canonical_reason().unwrap_or("unknown status")
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Serialize, TS)]
|
||||
#[ts(export, export_to = "../../src/bindings/")]
|
||||
pub struct MetadataResponse {
|
||||
@@ -305,26 +322,43 @@ fn is_excluded_yt_dlp_format(value: &serde_json::Value) -> bool {
|
||||
}
|
||||
|
||||
fn format_height(value: &serde_json::Value) -> Option<u64> {
|
||||
if let Some(height) = json_u64(value, "height") {
|
||||
if let Some(height) = json_u64(value, "height").filter(|height| *height > 0) {
|
||||
return Some(height);
|
||||
}
|
||||
|
||||
if let Some(resolution) = json_str(value, "resolution") {
|
||||
if let Some((_, height)) = resolution.split_once('x') {
|
||||
if let Ok(parsed) = height.parse::<u64>() {
|
||||
return Some(parsed);
|
||||
if let Some((_, height)) = resolution
|
||||
.split_once('x')
|
||||
.or_else(|| resolution.split_once('X'))
|
||||
{
|
||||
if let Ok(parsed) = height.trim().parse::<u64>() {
|
||||
if parsed > 0 {
|
||||
return Some(parsed);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let note = json_lower(value, "format_note");
|
||||
for height in [4320_u64, 2160, 1440, 1080, 720, 480, 360, 240, 144] {
|
||||
if note.contains(&format!("{height}p")) {
|
||||
return Some(height);
|
||||
let bytes = note.as_bytes();
|
||||
let mut heights = Vec::new();
|
||||
for (index, byte) in bytes.iter().enumerate() {
|
||||
if *byte != b'p' || index == 0 {
|
||||
continue;
|
||||
}
|
||||
let mut start = index;
|
||||
while start > 0 && bytes[start - 1].is_ascii_digit() {
|
||||
start -= 1;
|
||||
}
|
||||
if start < index {
|
||||
if let Ok(height) = note[start..index].parse::<u64>() {
|
||||
if height > 0 {
|
||||
heights.push(height);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
heights.into_iter().max()
|
||||
}
|
||||
|
||||
fn matches_media_height(value: &serde_json::Value, target: u64) -> bool {
|
||||
@@ -332,11 +366,6 @@ fn matches_media_height(value: &serde_json::Value, target: u64) -> bool {
|
||||
return false;
|
||||
}
|
||||
|
||||
let note = json_lower(value, "format_note");
|
||||
if note.contains(&format!("{target}p")) {
|
||||
return true;
|
||||
}
|
||||
|
||||
format_height(value) == Some(target)
|
||||
}
|
||||
|
||||
@@ -529,13 +558,13 @@ fn build_media_format_options(
|
||||
let mut options = Vec::new();
|
||||
|
||||
if has_video {
|
||||
let available_heights: Vec<u64> = [2160_u64, 1440, 1080, 720, 480, 360]
|
||||
let available_heights: Vec<u64> = clean_formats
|
||||
.iter()
|
||||
.filter(|format| has_video_stream(format))
|
||||
.filter_map(|format| format_height(format))
|
||||
.collect::<BTreeSet<_>>()
|
||||
.into_iter()
|
||||
.filter(|height| {
|
||||
clean_formats
|
||||
.iter()
|
||||
.any(|format| matches_media_height(format, *height))
|
||||
})
|
||||
.rev()
|
||||
.collect();
|
||||
|
||||
for height in available_heights {
|
||||
@@ -970,30 +999,47 @@ fn aggregate_media_fraction(
|
||||
((*current_track + *last_fraction) / total_tracks).clamp(0.0, 1.0)
|
||||
}
|
||||
|
||||
struct MediaProgressEmitterState {
|
||||
current_track: f64,
|
||||
last_fraction: f64,
|
||||
speed_sampler: MediaSpeedSampler,
|
||||
last_progress_at: Instant,
|
||||
}
|
||||
|
||||
impl MediaProgressEmitterState {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
current_track: 0.0,
|
||||
last_fraction: 0.0,
|
||||
speed_sampler: MediaSpeedSampler::default(),
|
||||
last_progress_at: Instant::now()
|
||||
.checked_sub(MEDIA_PROGRESS_EMIT_INTERVAL)
|
||||
.unwrap_or_else(Instant::now),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn emit_media_progress(
|
||||
app_handle: &tauri::AppHandle,
|
||||
id: &str,
|
||||
progress: MediaProgress,
|
||||
total_tracks: f64,
|
||||
current_track: &mut f64,
|
||||
last_fraction: &mut f64,
|
||||
speed_sampler: &mut MediaSpeedSampler,
|
||||
last_progress_at: &mut Instant,
|
||||
state: &mut MediaProgressEmitterState,
|
||||
) {
|
||||
let previous_track = *current_track;
|
||||
let previous_track = state.current_track;
|
||||
let overall_fraction = aggregate_media_fraction(
|
||||
total_tracks,
|
||||
current_track,
|
||||
last_fraction,
|
||||
&mut state.current_track,
|
||||
&mut state.last_fraction,
|
||||
progress.fraction,
|
||||
);
|
||||
if *current_track != previous_track {
|
||||
speed_sampler.reset();
|
||||
if state.current_track != previous_track {
|
||||
state.speed_sampler.reset();
|
||||
}
|
||||
let (speed, eta) = media_progress_speed(&progress, Instant::now(), speed_sampler);
|
||||
let (speed, eta) = media_progress_speed(&progress, Instant::now(), &mut state.speed_sampler);
|
||||
|
||||
let now = Instant::now();
|
||||
if now.duration_since(*last_progress_at) >= MEDIA_PROGRESS_EMIT_INTERVAL {
|
||||
if now.duration_since(state.last_progress_at) >= MEDIA_PROGRESS_EMIT_INTERVAL {
|
||||
let _ = app_handle.emit(
|
||||
"download-progress",
|
||||
DownloadProgressEvent {
|
||||
@@ -1005,7 +1051,7 @@ fn emit_media_progress(
|
||||
size_is_final: false,
|
||||
},
|
||||
);
|
||||
*last_progress_at = now;
|
||||
state.last_progress_at = now;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1086,8 +1132,13 @@ fn append_ytdlp_config_option(config: &mut String, option: &str, value: &str) {
|
||||
let safe_value = sanitize_ytdlp_config_value(value);
|
||||
if !safe_value.is_empty() {
|
||||
config.push_str(option);
|
||||
config.push('\n');
|
||||
config.push_str(&safe_value);
|
||||
config.push(' ');
|
||||
// yt-dlp parses one configuration line at a time. Quoting keeps
|
||||
// whitespace and cookie delimiters inside this option's single value,
|
||||
// rather than turning them into additional input URLs.
|
||||
config.push('\'');
|
||||
config.push_str(&safe_value.replace('\'', "'\\''"));
|
||||
config.push('\'');
|
||||
config.push('\n');
|
||||
}
|
||||
}
|
||||
@@ -1195,6 +1246,8 @@ async fn fetch_metadata(
|
||||
cookies: Option<String>,
|
||||
proxy: Option<String>,
|
||||
) -> Result<MetadataResponse, String> {
|
||||
ensure_reqwest_crypto_provider();
|
||||
|
||||
let mut current_url = url.clone();
|
||||
let original_host = reqwest::Url::parse(&url).ok().and_then(|u| u.host_str().map(|s| s.to_string()));
|
||||
let mut redirects = 0;
|
||||
@@ -1324,6 +1377,10 @@ async fn fetch_metadata(
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(error) = metadata_response_error(current_res.status()) {
|
||||
return Err(error);
|
||||
}
|
||||
|
||||
res = current_res;
|
||||
break;
|
||||
}
|
||||
@@ -1399,6 +1456,7 @@ static MEDIA_METADATA_LOCKS: OnceLock<
|
||||
tokio::sync::Mutex<HashMap<u64, std::sync::Arc<tokio::sync::Mutex<()>>>>,
|
||||
> = OnceLock::new();
|
||||
|
||||
#[allow(clippy::too_many_arguments)] // Hash every user-controlled yt-dlp input explicitly.
|
||||
fn media_metadata_cache_key(
|
||||
url: &str,
|
||||
cookie_browser: &Option<String>,
|
||||
@@ -1445,6 +1503,7 @@ fn resolve_metadata_ytdlp_path(
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[allow(clippy::too_many_arguments)] // Keep the generated TypeScript IPC contract flat and stable.
|
||||
async fn fetch_media_metadata(
|
||||
app_handle: tauri::AppHandle,
|
||||
url: String,
|
||||
@@ -1555,6 +1614,7 @@ async fn fetch_media_metadata(
|
||||
result
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)] // Mirrors the stable command contract for the fallback call.
|
||||
async fn fetch_media_metadata_uncached(
|
||||
app_handle: tauri::AppHandle,
|
||||
url: String,
|
||||
@@ -2120,6 +2180,8 @@ pub(crate) async fn rpc_call(
|
||||
method: &str,
|
||||
params: serde_json::Value,
|
||||
) -> Result<serde_json::Value, String> {
|
||||
ensure_reqwest_crypto_provider();
|
||||
|
||||
let url = format!("http://127.0.0.1:{}/jsonrpc", port);
|
||||
let mut payload = serde_json::Map::new();
|
||||
payload.insert("jsonrpc".to_string(), serde_json::json!("2.0"));
|
||||
@@ -2829,12 +2891,7 @@ pub(crate) async fn start_media_download_internal(
|
||||
None
|
||||
};
|
||||
let _keep_alive = config_path;
|
||||
let mut current_track: f64 = 0.0;
|
||||
let mut last_fraction: f64 = 0.0;
|
||||
let mut speed_sampler = MediaSpeedSampler::default();
|
||||
let mut last_progress_at = std::time::Instant::now()
|
||||
.checked_sub(MEDIA_PROGRESS_EMIT_INTERVAL)
|
||||
.unwrap_or_else(std::time::Instant::now);
|
||||
let mut progress_state = MediaProgressEmitterState::new();
|
||||
|
||||
// Resolve absolute paths to bundled binaries
|
||||
let aria2c_path = resolve_bundled_binary_path(&app_handle, "aria2c")?;
|
||||
@@ -2851,11 +2908,11 @@ pub(crate) async fn start_media_download_internal(
|
||||
|
||||
let max_retries = max_tries.unwrap_or(0).max(0) as usize;
|
||||
let mut strike = 0_usize;
|
||||
let mut processing_started = false;
|
||||
let mut effective_cookie_source = cookie_source.clone();
|
||||
let mut browser_cookie_fallback_used = false;
|
||||
|
||||
while strike <= max_retries {
|
||||
let mut processing_started = false;
|
||||
let ytdlp_path = resolve_bundled_binary_path(&app_handle, "yt-dlp")?;
|
||||
let mut cmd = app_handle.shell().command(&ytdlp_path);
|
||||
for arg in media_progress_args() {
|
||||
@@ -2865,9 +2922,15 @@ pub(crate) async fn start_media_download_internal(
|
||||
.arg("--socket-timeout")
|
||||
.arg("20")
|
||||
.arg("--retries")
|
||||
.arg(max_retries.to_string())
|
||||
// Firelink owns the retry budget so `maxAutomaticRetries` means
|
||||
// the same thing for aria2 and media downloads. Letting yt-dlp
|
||||
// also consume that value would multiply the configured retry
|
||||
// count on every outer restart.
|
||||
.arg("0")
|
||||
.arg("--extractor-retries")
|
||||
.arg("3")
|
||||
.arg("0")
|
||||
.arg("--fragment-retries")
|
||||
.arg("0")
|
||||
.arg("--ffmpeg-location")
|
||||
.arg(&ffmpeg_path)
|
||||
.arg("--js-runtimes")
|
||||
@@ -2932,7 +2995,15 @@ pub(crate) async fn start_media_download_internal(
|
||||
} else if safe_filename.ends_with(".webm") {
|
||||
cmd = cmd.arg("--merge-output-format").arg("webm");
|
||||
} else {
|
||||
cmd = cmd.arg("--merge-output-format").arg("mkv");
|
||||
// `--merge-output-format` only affects split video/audio
|
||||
// selections. A progressive MP4/WebM stream already includes
|
||||
// audio, so also request remuxing to keep an MKV option from
|
||||
// producing a mismatched file extension and container.
|
||||
cmd = cmd
|
||||
.arg("--merge-output-format")
|
||||
.arg("mkv")
|
||||
.arg("--remux-video")
|
||||
.arg("mkv");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2941,6 +3012,16 @@ pub(crate) async fn start_media_download_internal(
|
||||
let (mut rx, child) = cmd
|
||||
.spawn()
|
||||
.map_err(|e| format!("Failed to spawn yt-dlp: {}", e))?;
|
||||
if strike > 0 {
|
||||
// The backoff path emits `Retrying`. Restore the live transfer
|
||||
// state when the replacement process actually starts so React
|
||||
// accepts progress from this attempt instead of staying stuck.
|
||||
progress_state.speed_sampler.reset();
|
||||
let _ = app_handle.emit(
|
||||
"download-state",
|
||||
DownloadStateEvent::new(id, crate::ipc::DownloadStatus::Downloading),
|
||||
);
|
||||
}
|
||||
log::info!("yt-dlp spawned for id: {} (strike {})", id, strike);
|
||||
|
||||
let mut stderr_tail = String::new();
|
||||
@@ -2968,10 +3049,7 @@ pub(crate) async fn start_media_download_internal(
|
||||
id,
|
||||
progress,
|
||||
total_tracks,
|
||||
&mut current_track,
|
||||
&mut last_fraction,
|
||||
&mut speed_sampler,
|
||||
&mut last_progress_at,
|
||||
&mut progress_state,
|
||||
);
|
||||
} else {
|
||||
let candidate = line.trim();
|
||||
@@ -2997,10 +3075,7 @@ pub(crate) async fn start_media_download_internal(
|
||||
id,
|
||||
progress,
|
||||
total_tracks,
|
||||
&mut current_track,
|
||||
&mut last_fraction,
|
||||
&mut speed_sampler,
|
||||
&mut last_progress_at,
|
||||
&mut progress_state,
|
||||
);
|
||||
}
|
||||
if !processing_started && is_media_processing_line(&line) {
|
||||
@@ -3039,10 +3114,7 @@ pub(crate) async fn start_media_download_internal(
|
||||
id,
|
||||
progress,
|
||||
total_tracks,
|
||||
&mut current_track,
|
||||
&mut last_fraction,
|
||||
&mut speed_sampler,
|
||||
&mut last_progress_at,
|
||||
&mut progress_state,
|
||||
);
|
||||
} else {
|
||||
let candidate = line.trim();
|
||||
@@ -3061,14 +3133,10 @@ pub(crate) async fn start_media_download_internal(
|
||||
id,
|
||||
progress,
|
||||
total_tracks,
|
||||
&mut current_track,
|
||||
&mut last_fraction,
|
||||
&mut speed_sampler,
|
||||
&mut last_progress_at,
|
||||
&mut progress_state,
|
||||
);
|
||||
}
|
||||
if !processing_started && is_media_processing_line(&line) {
|
||||
processing_started = true;
|
||||
let _ = app_handle.emit(
|
||||
"download-state",
|
||||
DownloadStateEvent::new(
|
||||
@@ -3421,8 +3489,10 @@ async fn remove_download(
|
||||
state: tauri::State<'_, AppState>,
|
||||
id: String,
|
||||
delete_assets: bool,
|
||||
preserve_resumable: Option<bool>,
|
||||
) -> Result<(), String> {
|
||||
log::info!("remove_download called for id: {}", id);
|
||||
let preserve_resumable = preserve_resumable.unwrap_or(false);
|
||||
let primary_path = crate::download_ownership::primary_path_for_id(&app_handle, &id)?;
|
||||
|
||||
let active_kind = state.queue_manager.active_kind(&id).await;
|
||||
@@ -3481,8 +3551,13 @@ async fn remove_download(
|
||||
crate::ipc::DownloadStateEvent::new(id.clone(), crate::ipc::DownloadStatus::Paused),
|
||||
);
|
||||
|
||||
let preserve_assets = preserve_resumable
|
||||
&& primary_path
|
||||
.as_deref()
|
||||
.is_some_and(has_resumable_download_assets);
|
||||
|
||||
let cleanup_result = async {
|
||||
if delete_assets {
|
||||
if delete_assets && !preserve_assets {
|
||||
if let Some(path) = primary_path.as_deref() {
|
||||
remove_download_assets(path, &app_handle).await?;
|
||||
}
|
||||
@@ -3496,6 +3571,14 @@ async fn remove_download(
|
||||
cleanup_result
|
||||
}
|
||||
|
||||
fn has_resumable_download_assets(primary: &std::path::Path) -> bool {
|
||||
[".aria2", ".part", ".ytdl"].iter().any(|suffix| {
|
||||
let mut candidate = primary.as_os_str().to_os_string();
|
||||
candidate.push(suffix);
|
||||
std::path::PathBuf::from(candidate).exists()
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) async fn remove_download_assets<R: tauri::Runtime>(
|
||||
primary: &std::path::Path,
|
||||
app_handle: &tauri::AppHandle<R>,
|
||||
@@ -3913,6 +3996,18 @@ async fn get_pending_order(
|
||||
Ok(state.queue_manager.pending_order(queue_id.as_deref()).await)
|
||||
}
|
||||
|
||||
fn enqueue_lifecycle_generation(item: &queue::EnqueueItem) -> Result<u64, String> {
|
||||
item.lifecycle_generation
|
||||
.as_deref()
|
||||
.map(|generation| {
|
||||
generation
|
||||
.parse::<u64>()
|
||||
.map_err(|_| "Invalid enqueue lifecycle generation".to_string())
|
||||
})
|
||||
.transpose()
|
||||
.map(|generation| generation.unwrap_or_default())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn enqueue_download(
|
||||
app_handle: tauri::AppHandle,
|
||||
@@ -3922,16 +4017,35 @@ async fn enqueue_download(
|
||||
let id = item.id.clone();
|
||||
item.filename = crate::download_ownership::canonical_download_filename(&item.filename);
|
||||
let accepted_filename = item.filename.clone();
|
||||
crate::download_ownership::register_expected(
|
||||
let lifecycle_generation = enqueue_lifecycle_generation(&item).map_err(AppError::Internal)?;
|
||||
let previous_generation = state
|
||||
.queue_manager
|
||||
.reserve_enqueue_generation(&id, lifecycle_generation)
|
||||
.await
|
||||
.map_err(AppError::Internal)?;
|
||||
if let Err(error) = crate::download_ownership::register_expected(
|
||||
&app_handle,
|
||||
&item.id,
|
||||
&item.destination,
|
||||
&item.filename,
|
||||
)?;
|
||||
if let Err(e) = state.queue_manager.push(item.into_task()).await {
|
||||
) {
|
||||
state
|
||||
.queue_manager
|
||||
.rollback_enqueue_reservation(&id, lifecycle_generation, previous_generation)
|
||||
.await;
|
||||
return Err(AppError::Internal(error));
|
||||
}
|
||||
if let Err(error) = state
|
||||
.queue_manager
|
||||
.commit_reserved_enqueue(item.into_task(), lifecycle_generation)
|
||||
.await
|
||||
{
|
||||
let _ = crate::download_ownership::remove(&app_handle, &id);
|
||||
state.queue_manager.release_registered_id(&id).await;
|
||||
return Err(AppError::Internal(e));
|
||||
state
|
||||
.queue_manager
|
||||
.rollback_enqueue_reservation(&id, lifecycle_generation, previous_generation)
|
||||
.await;
|
||||
return Err(AppError::Internal(error));
|
||||
}
|
||||
Ok(crate::ipc::EnqueueAccepted {
|
||||
id,
|
||||
@@ -3939,34 +4053,103 @@ async fn enqueue_download(
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn cancel_enqueue_generation(
|
||||
state: tauri::State<'_, AppState>,
|
||||
id: String,
|
||||
generation: String,
|
||||
) -> Result<(), AppError> {
|
||||
let generation = generation
|
||||
.parse::<u64>()
|
||||
.map_err(|_| AppError::Internal("Invalid enqueue lifecycle generation".to_string()))?;
|
||||
state
|
||||
.queue_manager
|
||||
.cancel_enqueue_generation(&id, generation)
|
||||
.await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn enqueue_many(
|
||||
app_handle: tauri::AppHandle,
|
||||
state: tauri::State<'_, AppState>,
|
||||
mut items: Vec<queue::EnqueueItem>,
|
||||
items: Vec<queue::EnqueueItem>,
|
||||
) -> Result<Vec<crate::ipc::EnqueueResult>, AppError> {
|
||||
for item in &mut items {
|
||||
let mut results = Vec::with_capacity(items.len());
|
||||
for mut item in items {
|
||||
item.filename = crate::download_ownership::canonical_download_filename(&item.filename);
|
||||
}
|
||||
for item in &items {
|
||||
crate::download_ownership::register_expected(
|
||||
let id = item.id.clone();
|
||||
let filename = item.filename.clone();
|
||||
let lifecycle_generation = match enqueue_lifecycle_generation(&item) {
|
||||
Ok(generation) => generation,
|
||||
Err(error) => {
|
||||
results.push(crate::ipc::EnqueueResult {
|
||||
id,
|
||||
success: false,
|
||||
filename: None,
|
||||
error: Some(error),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let previous_generation = match state
|
||||
.queue_manager
|
||||
.reserve_enqueue_generation(&id, lifecycle_generation)
|
||||
.await
|
||||
{
|
||||
Ok(previous) => previous,
|
||||
Err(error) => {
|
||||
results.push(crate::ipc::EnqueueResult {
|
||||
id,
|
||||
success: false,
|
||||
filename: None,
|
||||
error: Some(error),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
};
|
||||
if let Err(error) = crate::download_ownership::register_expected(
|
||||
&app_handle,
|
||||
&item.id,
|
||||
&item.destination,
|
||||
&item.filename,
|
||||
)?;
|
||||
}
|
||||
let tasks = items
|
||||
.into_iter()
|
||||
.map(queue::EnqueueItem::into_task)
|
||||
.collect();
|
||||
let results = state.queue_manager.enqueue_many(tasks).await;
|
||||
|
||||
for result in &results {
|
||||
if !result.success {
|
||||
let _ = crate::download_ownership::remove(&app_handle, &result.id);
|
||||
state.queue_manager.release_registered_id(&result.id).await;
|
||||
) {
|
||||
state
|
||||
.queue_manager
|
||||
.rollback_enqueue_reservation(&id, lifecycle_generation, previous_generation)
|
||||
.await;
|
||||
results.push(crate::ipc::EnqueueResult {
|
||||
id,
|
||||
success: false,
|
||||
filename: None,
|
||||
error: Some(error),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if let Err(error) = state
|
||||
.queue_manager
|
||||
.commit_reserved_enqueue(item.into_task(), lifecycle_generation)
|
||||
.await
|
||||
{
|
||||
let _ = crate::download_ownership::remove(&app_handle, &id);
|
||||
state
|
||||
.queue_manager
|
||||
.rollback_enqueue_reservation(&id, lifecycle_generation, previous_generation)
|
||||
.await;
|
||||
results.push(crate::ipc::EnqueueResult {
|
||||
id,
|
||||
success: false,
|
||||
filename: None,
|
||||
error: Some(error),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
results.push(crate::ipc::EnqueueResult {
|
||||
id,
|
||||
success: true,
|
||||
filename: Some(filename),
|
||||
error: None,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(results)
|
||||
@@ -4388,6 +4571,27 @@ fn redact_log_line(line: &str) -> String {
|
||||
query.replace_all(&redacted, "$1?[redacted]").into_owned()
|
||||
}
|
||||
|
||||
fn redact_log_line_for_output(line: &str) -> String {
|
||||
static HOME_PATHS: OnceLock<(String, String)> = OnceLock::new();
|
||||
let (home, escaped_home) = HOME_PATHS.get_or_init(|| {
|
||||
let home = std::env::var("USERPROFILE")
|
||||
.or_else(|_| std::env::var("HOME"))
|
||||
.unwrap_or_default();
|
||||
let home = if home.len() > 3 { home } else { String::new() };
|
||||
let escaped_home = home.replace('\\', "\\\\");
|
||||
(home, escaped_home)
|
||||
});
|
||||
|
||||
let mut redacted = line.to_string();
|
||||
if !home.is_empty() {
|
||||
redacted = redacted.replace(home, "<HOME>");
|
||||
}
|
||||
if !escaped_home.is_empty() && escaped_home != home {
|
||||
redacted = redacted.replace(escaped_home, "<HOME>");
|
||||
}
|
||||
redact_log_line(&redacted)
|
||||
}
|
||||
|
||||
fn redact_log_line_for_app(line: &str, app_handle: &tauri::AppHandle) -> String {
|
||||
use tauri::Manager;
|
||||
let without_home = app_handle
|
||||
@@ -4618,15 +4822,19 @@ fn set_extension_frontend_ready(state: tauri::State<'_, AppState>, ready: bool)
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
aggregate_media_fraction, append_ytdlp_http_headers, build_media_format_options,
|
||||
aggregate_media_fraction, append_ytdlp_config_option, append_ytdlp_http_headers,
|
||||
build_media_format_options,
|
||||
collect_download_uris, drain_media_output_lines, filename_from_content_disposition,
|
||||
filename_from_url_disposition_query, filename_from_url_path, is_excluded_yt_dlp_format,
|
||||
is_browser_cookie_extraction_error, json_lower, media_metadata_cache_key,
|
||||
media_output_template, media_progress_args, media_progress_speed,
|
||||
metadata_response_error,
|
||||
normalize_speed_limit_for_aria2,
|
||||
parse_firelink_deep_link, parse_ffmpeg_version, parse_media_progress_line,
|
||||
redact_log_line, sanitize_ytdlp_config_value, should_cleanup_media_artifacts_after_failure,
|
||||
FirelinkDeepLink, MediaProgress, MediaSpeedSampler, MEDIA_PROGRESS_PREFIX,
|
||||
redact_log_line, redact_log_line_for_output, sanitize_ytdlp_config_value,
|
||||
has_resumable_download_assets, should_cleanup_media_artifacts_after_failure,
|
||||
FirelinkDeepLink, MediaProgress,
|
||||
MediaSpeedSampler, MEDIA_PROGRESS_PREFIX,
|
||||
};
|
||||
use serde_json::json;
|
||||
use std::time::{Duration, Instant};
|
||||
@@ -4648,6 +4856,27 @@ mod tests {
|
||||
assert_eq!(template, destination.join("clip.mp4"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn metadata_rejects_final_http_errors_but_accepts_partial_content() {
|
||||
assert!(metadata_response_error(reqwest::StatusCode::PARTIAL_CONTENT).is_none());
|
||||
assert_eq!(
|
||||
metadata_response_error(reqwest::StatusCode::NOT_FOUND).as_deref(),
|
||||
Some("Metadata request failed with HTTP 404 (Not Found)")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recognizes_resume_sidecars_without_treating_the_primary_file_as_partial() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let primary = directory.path().join("download.bin");
|
||||
std::fs::write(&primary, b"partial").unwrap();
|
||||
|
||||
assert!(!has_resumable_download_assets(&primary));
|
||||
|
||||
std::fs::write(directory.path().join("download.bin.aria2"), b"control").unwrap();
|
||||
assert!(has_resumable_download_assets(&primary));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ytdlp_progress_args_force_progress_in_quiet_print_mode() {
|
||||
let args = media_progress_args();
|
||||
@@ -4673,12 +4902,22 @@ mod tests {
|
||||
append_ytdlp_http_headers(
|
||||
&mut config,
|
||||
Some("Referer: https://example.com/video"),
|
||||
Some("session=abc\r\n--proxy=http://bad.invalid"),
|
||||
Some("session=abc; preference=high\r\n--proxy=http://bad.invalid"),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert!(config.contains("--add-header\nReferer: https://example.com/video\n"));
|
||||
assert!(config.contains("--add-header\nCookie: session=abc--proxy=http://bad.invalid\n"));
|
||||
assert_eq!(
|
||||
config,
|
||||
"--add-header 'Referer: https://example.com/video'\n--add-header 'Cookie: session=abc; preference=high--proxy=http://bad.invalid'\n"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ytdlp_config_options_quote_embedded_single_quotes() {
|
||||
let mut config = String::new();
|
||||
append_ytdlp_config_option(&mut config, "--username", "sam's account");
|
||||
|
||||
assert_eq!(config, "--username 'sam'\\''s account'\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -4814,6 +5053,15 @@ mod tests {
|
||||
assert!(redacted.contains("[redacted]"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn redacts_live_log_output_before_webview_delivery() {
|
||||
let line = "Cookie: session=abc https://example.com/file?signature=secret";
|
||||
let redacted = redact_log_line_for_output(line);
|
||||
assert!(!redacted.contains("session=abc"));
|
||||
assert!(!redacted.contains("signature=secret"));
|
||||
assert!(redacted.contains("[redacted]"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collects_primary_url_and_unique_mirrors_in_order() {
|
||||
let uris = collect_download_uris(
|
||||
@@ -4973,6 +5221,47 @@ mod tests {
|
||||
assert_eq!(option.filesize_approx, Some(1_468_000_000));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keeps_every_available_video_height_including_nonstandard_qualities() {
|
||||
let formats = vec![
|
||||
json!({
|
||||
"format_id": "401",
|
||||
"ext": "webm",
|
||||
"height": 4320,
|
||||
"vcodec": "av01.0.17M.08",
|
||||
"acodec": "none"
|
||||
}),
|
||||
json!({
|
||||
"format_id": "17",
|
||||
"ext": "mp4",
|
||||
"height": 144,
|
||||
"vcodec": "avc1.42E01E",
|
||||
"acodec": "mp4a.40.2"
|
||||
}),
|
||||
json!({
|
||||
"format_id": "five-k",
|
||||
"ext": "webm",
|
||||
"resolution": "5120X2880",
|
||||
"vcodec": "av01.0.17M.08",
|
||||
"acodec": "none"
|
||||
}),
|
||||
json!({
|
||||
"format_id": "pal",
|
||||
"ext": "mp4",
|
||||
"format_note": "Premium 576p",
|
||||
"vcodec": "avc1.4d401f",
|
||||
"acodec": "none"
|
||||
}),
|
||||
];
|
||||
|
||||
let options = build_media_format_options(&formats, Some(60.0));
|
||||
|
||||
assert!(options.iter().any(|format| format.resolution == "4320p"));
|
||||
assert!(options.iter().any(|format| format.resolution == "2880p"));
|
||||
assert!(options.iter().any(|format| format.resolution == "576p"));
|
||||
assert!(options.iter().any(|format| format.resolution == "144p"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn returns_no_media_options_for_webpage_only_metadata() {
|
||||
let formats = vec![json!({
|
||||
@@ -5253,6 +5542,7 @@ mod tests {
|
||||
}
|
||||
|
||||
static LOG_PAUSED: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(true);
|
||||
static LOG_STREAM_ACTIVE: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
|
||||
|
||||
#[tauri::command]
|
||||
fn toggle_log_pause(pause: bool) {
|
||||
@@ -5264,8 +5554,15 @@ fn is_log_paused() -> bool {
|
||||
LOG_PAUSED.load(std::sync::atomic::Ordering::Relaxed)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn set_log_stream_active(active: bool) {
|
||||
LOG_STREAM_ACTIVE.store(active, std::sync::atomic::Ordering::Release);
|
||||
}
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
ensure_reqwest_crypto_provider();
|
||||
|
||||
let extension_pairing_token = Arc::new(RwLock::new(String::new()));
|
||||
let server_pairing_token = extension_pairing_token.clone();
|
||||
let extension_frontend_ready = Arc::new(AtomicBool::new(false));
|
||||
@@ -5673,7 +5970,13 @@ pub fn run() {
|
||||
tauri_plugin_log::Builder::new()
|
||||
.targets([
|
||||
tauri_plugin_log::Target::new(tauri_plugin_log::TargetKind::Stdout),
|
||||
tauri_plugin_log::Target::new(tauri_plugin_log::TargetKind::LogDir { file_name: None }),
|
||||
tauri_plugin_log::Target::new(tauri_plugin_log::TargetKind::LogDir {
|
||||
file_name: None,
|
||||
}),
|
||||
tauri_plugin_log::Target::new(tauri_plugin_log::TargetKind::Webview)
|
||||
.filter(|_| {
|
||||
LOG_STREAM_ACTIVE.load(std::sync::atomic::Ordering::Acquire)
|
||||
}),
|
||||
])
|
||||
.level(if cfg!(debug_assertions) { log::LevelFilter::Debug } else { log::LevelFilter::Info })
|
||||
.filter(|metadata| {
|
||||
@@ -5691,29 +5994,15 @@ pub fn run() {
|
||||
.max_file_size(10_000_000)
|
||||
.rotation_strategy(tauri_plugin_log::RotationStrategy::KeepSome(3))
|
||||
.timezone_strategy(tauri_plugin_log::TimezoneStrategy::UseLocal)
|
||||
.format({
|
||||
let home_dir = std::env::var("USERPROFILE").or_else(|_| std::env::var("HOME")).unwrap_or_default();
|
||||
let home_dir = if home_dir.len() > 3 { home_dir } else { String::new() };
|
||||
let escaped_home_dir = home_dir.replace("\\", "\\\\");
|
||||
move |out, message, record| {
|
||||
let msg = message.to_string();
|
||||
let redacted = if !home_dir.is_empty() {
|
||||
let mut s = msg.replace(&home_dir, "<HOME>");
|
||||
if !escaped_home_dir.is_empty() && escaped_home_dir != home_dir {
|
||||
s = s.replace(&escaped_home_dir, "<HOME>");
|
||||
}
|
||||
s
|
||||
} else {
|
||||
msg
|
||||
};
|
||||
out.finish(format_args!(
|
||||
"[{}][{}][{}] {}",
|
||||
chrono::Local::now().format("%Y-%m-%d][%H:%M:%S"),
|
||||
record.level(),
|
||||
record.target(),
|
||||
redacted
|
||||
))
|
||||
}
|
||||
.format(|out, message, record| {
|
||||
let redacted = redact_log_line_for_output(&message.to_string());
|
||||
out.finish(format_args!(
|
||||
"[{}][{}][{}] {}",
|
||||
chrono::Local::now().format("%Y-%m-%d][%H:%M:%S"),
|
||||
record.level(),
|
||||
record.target(),
|
||||
redacted
|
||||
))
|
||||
})
|
||||
.build(),
|
||||
)
|
||||
@@ -5721,6 +6010,7 @@ pub fn run() {
|
||||
.plugin(tauri_plugin_shell::init())
|
||||
.plugin(tauri_plugin_opener::init())
|
||||
.plugin(tauri_plugin_notification::init())
|
||||
.plugin(tauri_plugin_clipboard_manager::init())
|
||||
.on_window_event(|window, event| {
|
||||
if window.label() == "main" {
|
||||
if let tauri::WindowEvent::CloseRequested { api, .. } = event {
|
||||
@@ -5741,13 +6031,14 @@ pub fn run() {
|
||||
check_file_exists, toggle_tray_icon, set_extension_pairing_token,
|
||||
get_extension_server_port, set_extension_frontend_ready, set_concurrent_limit, set_global_speed_limit, remove_download,
|
||||
detach_download_for_reconfigure,
|
||||
enqueue_download, enqueue_many, move_in_queue, remove_from_queue, get_pending_order,
|
||||
enqueue_download, enqueue_many, cancel_enqueue_generation, move_in_queue, remove_from_queue, get_pending_order,
|
||||
commands::reveal_in_file_manager, commands::open_downloaded_file,
|
||||
parity::get_system_proxy, parity::get_file_category, parity::check_for_updates, parity::is_supported_media, parity::get_supported_media_domains,
|
||||
parity::create_category_directories,
|
||||
db_save_settings, db_load_settings, db_get_all_downloads, db_replace_downloads,
|
||||
db_get_all_queues, db_replace_queues,
|
||||
read_logs, export_logs, toggle_log_pause, is_log_paused, clear_logs
|
||||
read_logs, export_logs, toggle_log_pause, is_log_paused, clear_logs,
|
||||
set_log_stream_active
|
||||
])
|
||||
.build(tauri::generate_context!())
|
||||
.expect("error while building tauri application")
|
||||
|
||||
@@ -86,10 +86,10 @@ fn proxy_from_environment() -> Option<String> {
|
||||
fn command_stdout(command: &mut Command) -> std::io::Result<String> {
|
||||
let output = command.output()?;
|
||||
if !output.status.success() {
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::Other,
|
||||
format!("command exited with {}", output.status),
|
||||
));
|
||||
return Err(std::io::Error::other(format!(
|
||||
"command exited with {}",
|
||||
output.status
|
||||
)));
|
||||
}
|
||||
Ok(String::from_utf8_lossy(&output.stdout).to_string())
|
||||
}
|
||||
@@ -541,6 +541,7 @@ pub async fn check_for_updates(
|
||||
) -> Result<ReleaseCheckOutcome, String> {
|
||||
let current_version = app_handle.package_info().version.to_string();
|
||||
|
||||
crate::ensure_reqwest_crypto_provider();
|
||||
let client = reqwest::Client::new();
|
||||
let res = client
|
||||
.get("https://api.github.com/repos/nimbold/Firelink/releases?per_page=30")
|
||||
|
||||
+143
-71
@@ -84,6 +84,8 @@ pub trait SidecarSpawner: Send + Sync + 'static {
|
||||
/// The centralized concurrency gatekeeper. One instance lives in AppState.
|
||||
pub struct QueueManager<R: tauri::Runtime = tauri::Wry> {
|
||||
registered_ids: Mutex<HashSet<String>>,
|
||||
enqueue_cancellations: Mutex<HashMap<String, u64>>,
|
||||
enqueue_generations: Mutex<HashMap<String, u64>>,
|
||||
pending: Mutex<VecDeque<QueuedTask>>,
|
||||
semaphore: Arc<Semaphore>,
|
||||
active_permits: Mutex<HashMap<String, OwnedSemaphorePermit>>,
|
||||
@@ -91,7 +93,6 @@ pub struct QueueManager<R: tauri::Runtime = tauri::Wry> {
|
||||
target_capacity: AtomicUsize,
|
||||
slots_to_retire: AtomicUsize,
|
||||
notify: Notify,
|
||||
notify_permit_released: Notify,
|
||||
|
||||
/// aria2 gid -> download id map (shared with the WS poller).
|
||||
pub aria2_gids: Arc<std::sync::RwLock<HashMap<String, String>>>,
|
||||
@@ -108,6 +109,8 @@ pub struct QueueManager<R: tauri::Runtime = tauri::Wry> {
|
||||
|
||||
/// Download ids whose aria2 retry loop must not create another job.
|
||||
aria2_retry_cancelled: Mutex<HashSet<String>>,
|
||||
/// Wakes retry backoff workers when a pause/remove action cancels them.
|
||||
aria2_retry_cancel_notify: Notify,
|
||||
|
||||
/// Monotonic per-download aria2 control generation. Long-running queued
|
||||
/// resume tasks capture this and abort when a later pause/remove wins.
|
||||
@@ -134,6 +137,8 @@ impl<R: tauri::Runtime> QueueManager<R> {
|
||||
) -> Self {
|
||||
Self {
|
||||
registered_ids: Mutex::new(HashSet::new()),
|
||||
enqueue_cancellations: Mutex::new(HashMap::new()),
|
||||
enqueue_generations: Mutex::new(HashMap::new()),
|
||||
pending: Mutex::new(VecDeque::new()),
|
||||
semaphore: Arc::new(Semaphore::new(capacity)),
|
||||
active_permits: Mutex::new(HashMap::new()),
|
||||
@@ -141,12 +146,12 @@ impl<R: tauri::Runtime> QueueManager<R> {
|
||||
target_capacity: AtomicUsize::new(capacity),
|
||||
slots_to_retire: AtomicUsize::new(0),
|
||||
notify: Notify::new(),
|
||||
notify_permit_released: Notify::new(),
|
||||
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_retry_strikes: Mutex::new(HashMap::new()),
|
||||
aria2_retry_cancelled: Mutex::new(HashSet::new()),
|
||||
aria2_retry_cancel_notify: Notify::new(),
|
||||
aria2_control_epochs: Mutex::new(HashMap::new()),
|
||||
spawner,
|
||||
app_handle,
|
||||
@@ -173,6 +178,108 @@ impl<R: tauri::Runtime> QueueManager<R> {
|
||||
self.registered_ids.lock().await.contains(id)
|
||||
}
|
||||
|
||||
/// Reject an in-flight enqueue generation if a newer UI action supersedes it.
|
||||
pub async fn cancel_enqueue_generation(&self, id: &str, generation: u64) {
|
||||
let mut cancellations = self.enqueue_cancellations.lock().await;
|
||||
cancellations
|
||||
.entry(id.to_string())
|
||||
.and_modify(|current| *current = (*current).max(generation))
|
||||
.or_insert(generation);
|
||||
}
|
||||
|
||||
/// Atomically reserve an ID after rejecting cancelled or replayed generations.
|
||||
/// The returned watermark must be passed to `rollback_enqueue_reservation`
|
||||
/// if ownership registration fails before the task is committed.
|
||||
pub async fn reserve_enqueue_generation(
|
||||
&self,
|
||||
id: &str,
|
||||
generation: u64,
|
||||
) -> Result<Option<u64>, String> {
|
||||
let cancellations = self.enqueue_cancellations.lock().await;
|
||||
if cancellations
|
||||
.get(id)
|
||||
.is_some_and(|cancelled| *cancelled >= generation)
|
||||
{
|
||||
return Err("Download enqueue was superseded by a newer user action".to_string());
|
||||
}
|
||||
let mut generations = self.enqueue_generations.lock().await;
|
||||
let previous_generation = generations.get(id).copied();
|
||||
if previous_generation.is_some_and(|seen| seen >= generation) {
|
||||
return Err("Download enqueue was superseded by a newer user action".to_string());
|
||||
}
|
||||
|
||||
let mut registered = self.registered_ids.lock().await;
|
||||
if registered.contains(id) {
|
||||
return Err("Duplicate task".to_string());
|
||||
}
|
||||
registered.insert(id.to_string());
|
||||
generations.insert(id.to_string(), generation);
|
||||
Ok(previous_generation)
|
||||
}
|
||||
|
||||
pub async fn rollback_enqueue_reservation(
|
||||
&self,
|
||||
id: &str,
|
||||
generation: u64,
|
||||
previous_generation: Option<u64>,
|
||||
) {
|
||||
let mut generations = self.enqueue_generations.lock().await;
|
||||
let mut registered = self.registered_ids.lock().await;
|
||||
if generations.get(id).copied() != Some(generation) {
|
||||
return;
|
||||
}
|
||||
registered.remove(id);
|
||||
match previous_generation {
|
||||
Some(previous) => {
|
||||
generations.insert(id.to_string(), previous);
|
||||
}
|
||||
None => {
|
||||
generations.remove(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn commit_reserved_enqueue(
|
||||
&self,
|
||||
task: QueuedTask,
|
||||
generation: u64,
|
||||
) -> Result<(), String> {
|
||||
let id = task.id.clone();
|
||||
let cancellations = self.enqueue_cancellations.lock().await;
|
||||
if cancellations
|
||||
.get(&id)
|
||||
.is_some_and(|cancelled| *cancelled >= generation)
|
||||
{
|
||||
return Err("Download enqueue was superseded by a newer user action".to_string());
|
||||
}
|
||||
self.pending.lock().await.push_back(task);
|
||||
self.emit_state(id, DownloadStatus::Queued);
|
||||
self.notify.notify_one();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Atomically checks the generation watermark before registering a task.
|
||||
pub async fn push_with_generation(
|
||||
&self,
|
||||
task: QueuedTask,
|
||||
generation: u64,
|
||||
) -> Result<(), String> {
|
||||
let id = task.id.clone();
|
||||
let previous_generation = self.reserve_enqueue_generation(&id, generation).await?;
|
||||
if let Err(error) = self.commit_reserved_enqueue(task, generation).await {
|
||||
self.rollback_enqueue_reservation(&id, generation, previous_generation)
|
||||
.await;
|
||||
return Err(error);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Enqueue a task without a frontend lifecycle token. This is retained for
|
||||
/// internal/test callers and still gets replay protection at generation 0.
|
||||
pub async fn push(&self, task: QueuedTask) -> Result<(), String> {
|
||||
self.push_with_generation(task, 0).await
|
||||
}
|
||||
|
||||
pub async fn next_aria2_control_epoch(&self, id: &str) -> u64 {
|
||||
let mut epochs = self.aria2_control_epochs.lock().await;
|
||||
let epoch = epochs.get(id).copied().unwrap_or_default().wrapping_add(1);
|
||||
@@ -198,22 +305,6 @@ impl<R: tauri::Runtime> QueueManager<R> {
|
||||
self.aria2_retry_strikes.lock().await.contains_key(id)
|
||||
}
|
||||
|
||||
/// Enqueue a task. Checks the centralized `registered_ids` for deduplication.
|
||||
pub async fn push(&self, task: QueuedTask) -> Result<(), String> {
|
||||
let id = task.id.clone();
|
||||
let mut registered = self.registered_ids.lock().await;
|
||||
if registered.contains(&id) {
|
||||
return Err("Duplicate task".to_string());
|
||||
}
|
||||
registered.insert(id.clone());
|
||||
drop(registered);
|
||||
|
||||
self.pending.lock().await.push_back(task);
|
||||
self.emit_state(id, DownloadStatus::Queued);
|
||||
self.notify.notify_one();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Pop the next task, or None if empty.
|
||||
pub async fn pop_front(&self) -> Option<QueuedTask> {
|
||||
self.pending.lock().await.pop_front()
|
||||
@@ -294,7 +385,6 @@ impl<R: tauri::Runtime> QueueManager<R> {
|
||||
let removed = self.active_permits.lock().await.remove(id).is_some();
|
||||
self.active_kinds.lock().await.remove(id);
|
||||
if removed {
|
||||
self.notify_permit_released.notify_waiters();
|
||||
self.notify.notify_one();
|
||||
}
|
||||
}
|
||||
@@ -554,6 +644,7 @@ impl<R: tauri::Runtime> QueueManager<R> {
|
||||
.lock()
|
||||
.await
|
||||
.insert(id.to_string());
|
||||
self.aria2_retry_cancel_notify.notify_waiters();
|
||||
}
|
||||
|
||||
pub async fn allow_aria2_retries(&self, id: &str) {
|
||||
@@ -596,22 +687,6 @@ impl<R: tauri::Runtime> QueueManager<R> {
|
||||
removed.last().cloned()
|
||||
}
|
||||
|
||||
async fn wait_permit_released(self: &Arc<Self>, id: &str) {
|
||||
loop {
|
||||
if !self.active_permits.lock().await.contains_key(id) {
|
||||
return;
|
||||
}
|
||||
let notified = self.notify_permit_released.notified();
|
||||
if !self.active_permits.lock().await.contains_key(id) {
|
||||
return;
|
||||
}
|
||||
tokio::select! {
|
||||
_ = notified => {}
|
||||
_ = tokio::time::sleep(std::time::Duration::from_secs(5)) => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Intercept transient `onDownloadError` events: backoff, re-issue
|
||||
/// `addUri`, and rotate the gid mapping. Permanent errors and exhausted
|
||||
/// strikes fall through to a hard `Failed` state.
|
||||
@@ -684,10 +759,24 @@ impl<R: tauri::Runtime> QueueManager<R> {
|
||||
let id_for_task = id.clone();
|
||||
let error_for_emit = error.clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
let retry_cancel = async {
|
||||
loop {
|
||||
if this.is_aria2_retry_cancelled(&id_for_task).await {
|
||||
break;
|
||||
}
|
||||
let notified = this.aria2_retry_cancel_notify.notified();
|
||||
tokio::pin!(notified);
|
||||
notified.as_mut().enable();
|
||||
if this.is_aria2_retry_cancelled(&id_for_task).await {
|
||||
break;
|
||||
}
|
||||
notified.await;
|
||||
}
|
||||
};
|
||||
let outcome = backoff_and_emit(
|
||||
strike,
|
||||
error_for_emit,
|
||||
this.wait_permit_released(&id_for_task),
|
||||
retry_cancel,
|
||||
|reason| {
|
||||
use tauri::Emitter;
|
||||
let _ = this.app_handle.emit(
|
||||
@@ -841,40 +930,6 @@ impl<R: tauri::Runtime> QueueManager<R> {
|
||||
}
|
||||
removed
|
||||
}
|
||||
|
||||
/// Bulk enqueue by appending tasks. Used by startup and start-all.
|
||||
pub async fn enqueue_many(&self, tasks: Vec<QueuedTask>) -> Vec<crate::ipc::EnqueueResult> {
|
||||
let mut results = Vec::new();
|
||||
let mut registered = self.registered_ids.lock().await;
|
||||
let mut pending = self.pending.lock().await;
|
||||
|
||||
for task in tasks {
|
||||
let id = task.id.clone();
|
||||
let filename = task.payload.filename.clone();
|
||||
if registered.contains(&id) {
|
||||
results.push(crate::ipc::EnqueueResult {
|
||||
id: id.clone(),
|
||||
success: false,
|
||||
filename: None,
|
||||
error: Some("Duplicate task".to_string()),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
registered.insert(id.clone());
|
||||
pending.push_back(task);
|
||||
self.emit_state(id.clone(), DownloadStatus::Queued);
|
||||
results.push(crate::ipc::EnqueueResult {
|
||||
id,
|
||||
success: true,
|
||||
filename: Some(filename),
|
||||
error: None,
|
||||
});
|
||||
}
|
||||
drop(pending);
|
||||
drop(registered);
|
||||
self.notify.notify_one();
|
||||
results
|
||||
}
|
||||
}
|
||||
|
||||
fn automatic_retry_limit(max_tries: Option<i32>) -> usize {
|
||||
@@ -882,7 +937,11 @@ fn automatic_retry_limit(max_tries: Option<i32>) -> usize {
|
||||
}
|
||||
|
||||
fn aria2_attempt_limit(max_tries: Option<i32>) -> u32 {
|
||||
(automatic_retry_limit(max_tries) + 1) as u32
|
||||
// Firelink owns the retry budget and performs the backoff/GID rotation.
|
||||
// Keep each aria2 GID to one attempt so `max_tries` is not multiplied by
|
||||
// aria2's own internal retry loop.
|
||||
let _ = max_tries;
|
||||
1
|
||||
}
|
||||
|
||||
fn is_retryable_aria2_error(error: &str) -> bool {
|
||||
@@ -1010,6 +1069,8 @@ async fn probe_bounded_range_support(
|
||||
uri: &str,
|
||||
payload: &SpawnPayload,
|
||||
) -> Result<BoundedRangeSupport, String> {
|
||||
crate::ensure_reqwest_crypto_provider();
|
||||
|
||||
let mut builder = reqwest::Client::builder()
|
||||
.redirect(reqwest::redirect::Policy::limited(5))
|
||||
.timeout(std::time::Duration::from_secs(10));
|
||||
@@ -1190,6 +1251,8 @@ impl SidecarSpawner for ProductionSpawner {
|
||||
options.insert("max-tries".to_string(), serde_json::json!(mt.to_string()));
|
||||
options.insert("retry-wait".to_string(), serde_json::json!("2"));
|
||||
options.insert("continue".to_string(), serde_json::json!("true"));
|
||||
options.insert("always-resume".to_string(), serde_json::json!("true"));
|
||||
options.insert("auto-file-renaming".to_string(), serde_json::json!("false"));
|
||||
if let Some(speed) = payload
|
||||
.speed_limit
|
||||
.as_deref()
|
||||
@@ -1334,6 +1397,9 @@ pub struct EnqueueItem {
|
||||
pub format_selector: Option<String>,
|
||||
pub cookie_source: Option<String>,
|
||||
pub is_media: Option<bool>,
|
||||
#[serde(default)]
|
||||
#[ts(optional)]
|
||||
pub lifecycle_generation: Option<String>,
|
||||
}
|
||||
|
||||
impl EnqueueItem {
|
||||
@@ -1451,4 +1517,10 @@ mod tests {
|
||||
"aria2 error code 3: Resource not found"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn aria2_internal_attempts_do_not_multiply_firelink_retry_budget() {
|
||||
assert_eq!(aria2_attempt_limit(Some(0)), 1);
|
||||
assert_eq!(aria2_attempt_limit(Some(10)), 1);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Firelink",
|
||||
"version": "1.0.3",
|
||||
"version": "1.0.4",
|
||||
"identifier": "com.nimbold.firelink",
|
||||
"build": {
|
||||
"beforeDevCommand": "node scripts/stage-engines.js && npm run dev",
|
||||
|
||||
@@ -13,12 +13,24 @@
|
||||
]
|
||||
},
|
||||
"bundle": {
|
||||
"targets": ["appimage"],
|
||||
"targets": ["appimage", "deb", "rpm"],
|
||||
"linux": {
|
||||
"appimage": {
|
||||
"files": {
|
||||
"usr/share/metainfo/com.nimbold.firelink.metainfo.xml": "linux/com.nimbold.firelink.metainfo.xml"
|
||||
}
|
||||
},
|
||||
"deb": {
|
||||
"recommends": ["desktop-file-utils", "xdg-utils"],
|
||||
"files": {
|
||||
"/usr/share/metainfo/com.nimbold.firelink.metainfo.xml": "linux/com.nimbold.firelink.metainfo.xml"
|
||||
}
|
||||
},
|
||||
"rpm": {
|
||||
"recommends": ["desktop-file-utils", "xdg-utils"],
|
||||
"files": {
|
||||
"/usr/share/metainfo/com.nimbold.firelink.metainfo.xml": "linux/com.nimbold.firelink.metainfo.xml"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -101,6 +101,60 @@ async fn push_appends_to_pending_and_emits_queued() {
|
||||
assert_eq!(order, vec!["a".to_string(), "b".to_string()]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cancelled_enqueue_generation_cannot_register_after_a_newer_user_action() {
|
||||
let (mgr, _spawner) = make_manager(2);
|
||||
mgr.cancel_enqueue_generation("a", 4).await;
|
||||
|
||||
let stale = mgr.push_with_generation(sample_task("a"), 4).await;
|
||||
assert!(
|
||||
stale.is_err(),
|
||||
"cancelled generation must not enter the queue"
|
||||
);
|
||||
assert!(!mgr.is_registered("a").await);
|
||||
|
||||
mgr.push_with_generation(sample_task("a"), 5)
|
||||
.await
|
||||
.expect("newer generation should be accepted");
|
||||
assert_eq!(mgr.pending_order(None).await, vec!["a".to_string()]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cancellation_between_reservation_and_commit_cannot_start_the_task() {
|
||||
let (mgr, _spawner) = make_manager(2);
|
||||
let previous = mgr
|
||||
.reserve_enqueue_generation("a", 7)
|
||||
.await
|
||||
.expect("reservation should succeed");
|
||||
mgr.cancel_enqueue_generation("a", 7).await;
|
||||
|
||||
let committed = mgr.commit_reserved_enqueue(sample_task("a"), 7).await;
|
||||
assert!(committed.is_err(), "cancelled reservation must not commit");
|
||||
mgr.rollback_enqueue_reservation("a", 7, previous).await;
|
||||
|
||||
assert!(!mgr.is_registered("a").await);
|
||||
assert!(mgr.pending_order(None).await.is_empty());
|
||||
assert!(mgr.push_with_generation(sample_task("a"), 7).await.is_err());
|
||||
mgr.push_with_generation(sample_task("a"), 8)
|
||||
.await
|
||||
.expect("a newer generation should remain startable");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn accepted_generation_cannot_be_replayed_after_registry_release() {
|
||||
let (mgr, _spawner) = make_manager(2);
|
||||
mgr.push_with_generation(sample_task("a"), 3)
|
||||
.await
|
||||
.expect("first enqueue should succeed");
|
||||
assert!(mgr.remove_from_pending("a").await);
|
||||
mgr.release_registered_id("a").await;
|
||||
|
||||
assert!(mgr.push_with_generation(sample_task("a"), 3).await.is_err());
|
||||
mgr.push_with_generation(sample_task("a"), 4)
|
||||
.await
|
||||
.expect("only a newer lifecycle may reuse the id");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn release_permit_is_idempotent() {
|
||||
let (mgr, _spawner) = make_manager(2);
|
||||
@@ -460,6 +514,55 @@ async fn aria2_permit_survives_rpc_return() {
|
||||
handle.abort();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn transient_aria2_error_reissues_after_backoff() {
|
||||
use firelink_lib::queue::PendingOutcome;
|
||||
|
||||
let (mgr, spawner) = make_manager(1);
|
||||
let manager = Arc::new(mgr);
|
||||
let mut task = aria2_task("retry");
|
||||
task.payload.max_tries = Some(1);
|
||||
manager.push(task).await.unwrap();
|
||||
|
||||
let dispatcher = {
|
||||
let manager = Arc::clone(&manager);
|
||||
tokio::spawn(async move { manager.run_dispatcher().await })
|
||||
};
|
||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||
assert_eq!(spawner.add_uri_calls.load(Ordering::SeqCst), 1);
|
||||
|
||||
manager
|
||||
.handle_aria2_event(
|
||||
"gid-1",
|
||||
PendingOutcome::Error("Timeout.".to_string()),
|
||||
)
|
||||
.await;
|
||||
|
||||
timeout(Duration::from_secs(4), async {
|
||||
loop {
|
||||
if spawner.add_uri_calls.load(Ordering::SeqCst) >= 2 {
|
||||
break;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("transient aria2 errors must retry after backoff");
|
||||
|
||||
manager
|
||||
.handle_aria2_event(
|
||||
"gid-2",
|
||||
PendingOutcome::Error("Timeout.".to_string()),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(spawner.add_uri_calls.load(Ordering::SeqCst), 2);
|
||||
assert!(manager.aria2_gid_for_download("retry").is_none());
|
||||
assert_eq!(manager.available_permits(), 1);
|
||||
|
||||
manager.release_permit("retry").await;
|
||||
dispatcher.abort();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn gid_completion_before_store_buffers_and_reconciles() {
|
||||
use firelink_lib::queue::PendingOutcome;
|
||||
|
||||
+38
-6
@@ -37,6 +37,20 @@ const waitForSettingsHydration = (): Promise<void> => {
|
||||
});
|
||||
};
|
||||
|
||||
let downloadStateInitialization: Promise<void> | null = null;
|
||||
const initializeDownloadState = (): Promise<void> => {
|
||||
if (!downloadStateInitialization) {
|
||||
downloadStateInitialization = (async () => {
|
||||
await waitForSettingsHydration();
|
||||
await useDownloadStore.getState().initDB();
|
||||
})().catch(error => {
|
||||
downloadStateInitialization = null;
|
||||
throw error;
|
||||
});
|
||||
}
|
||||
return downloadStateInitialization;
|
||||
};
|
||||
|
||||
const getScheduledQueueIds = () => {
|
||||
const downloadState = useDownloadStore.getState();
|
||||
const availableQueueIds = new Set(downloadState.queues.map(queue => queue.id));
|
||||
@@ -118,6 +132,10 @@ function App() {
|
||||
const { addToast } = 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
|
||||
// resolving. The conservative fallback prevents a startup handoff from
|
||||
// briefly rendering underneath native or custom window controls.
|
||||
const hasWindowChrome = isMacUserAgent || ['macos', 'windows', 'linux', 'unknown'].includes(platform.os);
|
||||
|
||||
const acknowledgePairingTokenChange = () => {
|
||||
invoke('acknowledge_pairing_token_change').catch(error => {
|
||||
@@ -227,10 +245,11 @@ function App() {
|
||||
let active = true;
|
||||
const initialize = async () => {
|
||||
try {
|
||||
await waitForSettingsHydration();
|
||||
await useDownloadStore.getState().initDB();
|
||||
if (active) setCoreReady(true);
|
||||
await initializeDownloadState();
|
||||
if (!active) return;
|
||||
setCoreReady(true);
|
||||
} catch (error) {
|
||||
if (!active) return;
|
||||
console.error('Failed to initialize Firelink state:', error);
|
||||
addToast({
|
||||
message: `Could not initialize saved downloads: ${String(error)}`,
|
||||
@@ -382,6 +401,12 @@ function App() {
|
||||
invoke('toggle_log_pause', { pause: !logsEnabled }).catch(console.error);
|
||||
}, [logsEnabled]);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeView !== 'logs') {
|
||||
invoke('set_log_stream_active', { active: false }).catch(console.error);
|
||||
}
|
||||
}, [activeView]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!extensionPairingToken) return;
|
||||
invoke('set_extension_pairing_token', { token: extensionPairingToken }).catch(error => {
|
||||
@@ -442,7 +467,7 @@ function App() {
|
||||
const trackedIds = state.schedulerActiveDownloadIds;
|
||||
if (trackedIds.length > 0) {
|
||||
const pauseResults = await Promise.allSettled(
|
||||
trackedIds.map(id => invoke('pause_download', { id }))
|
||||
trackedIds.map(id => useDownloadStore.getState().pauseDownload(id))
|
||||
);
|
||||
const failedPauses = pauseResults.filter(result => result.status === 'rejected').length;
|
||||
if (failedPauses > 0) {
|
||||
@@ -586,6 +611,7 @@ function App() {
|
||||
|
||||
useEffect(() => {
|
||||
if (!coreReady) return;
|
||||
let disposed = false;
|
||||
const unlistenDownload = initDownloadListener();
|
||||
|
||||
const unlistenTerminalState = listen('download-state', (event) => {
|
||||
@@ -630,10 +656,14 @@ function App() {
|
||||
useDownloadStore.getState().openAddModalWithUrls(event.payload);
|
||||
});
|
||||
Promise.all([unlistenExtension, unlistenDeepLink])
|
||||
.then(() => invoke('set_extension_frontend_ready', { ready: true }))
|
||||
.then(() => {
|
||||
if (disposed) return;
|
||||
return invoke('set_extension_frontend_ready', { ready: true });
|
||||
})
|
||||
.catch(error => console.error('Failed to activate browser extension integration:', error));
|
||||
|
||||
return () => {
|
||||
disposed = true;
|
||||
invoke('set_extension_frontend_ready', { ready: false }).catch(() => {});
|
||||
unlistenTerminalState.then(f => f());
|
||||
unlistenExtension.then(f => f());
|
||||
@@ -643,7 +673,9 @@ function App() {
|
||||
}, [coreReady]);
|
||||
|
||||
return (
|
||||
<div className="app-shell flex h-screen w-screen overflow-hidden text-text-primary">
|
||||
<div className={`app-shell flex h-screen w-screen overflow-hidden text-text-primary ${
|
||||
hasWindowChrome ? 'app-shell--window-chrome' : ''
|
||||
}`}>
|
||||
{(platform.os === 'windows' || platform.os === 'linux') && <WindowControls />}
|
||||
<div
|
||||
className={`app-sidebar-shell relative z-20 shrink-0 transition-all duration-300 ease-[cubic-bezier(0.2,0.8,0.2,1)] ${
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
import { Component, ErrorInfo, ReactNode } from "react";
|
||||
|
||||
interface Props {
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
interface State {
|
||||
hasError: boolean;
|
||||
error: Error | null;
|
||||
errorInfo: ErrorInfo | null;
|
||||
}
|
||||
|
||||
export class ErrorBoundary extends Component<Props, State> {
|
||||
public state: State = {
|
||||
hasError: false,
|
||||
error: null,
|
||||
errorInfo: null
|
||||
};
|
||||
|
||||
public static getDerivedStateFromError(error: Error): State {
|
||||
return { hasError: true, error, errorInfo: null };
|
||||
}
|
||||
|
||||
public componentDidCatch(error: Error, errorInfo: ErrorInfo) {
|
||||
console.error("Uncaught error:", error, errorInfo);
|
||||
this.setState({ errorInfo });
|
||||
}
|
||||
|
||||
public render() {
|
||||
if (this.state.hasError) {
|
||||
return (
|
||||
<div className="flex h-screen w-screen items-center justify-center bg-main-bg p-8 text-text-primary">
|
||||
<div className="app-card max-w-lg space-y-4 p-6 text-center">
|
||||
<h1 className="text-xl font-semibold">Firelink could not display this window.</h1>
|
||||
<p className="text-sm text-text-secondary">
|
||||
The error was written to Logs. Reload the interface to reconnect to the running download service.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
className="app-button app-button-primary px-4"
|
||||
onClick={() => window.location.reload()}
|
||||
>
|
||||
Reload Firelink
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
@@ -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 EnqueueItem = { id: string, queue_id: string, url: string, destination: string, filename: string, connections: number | null, speed_limit: string | null, username: string | null, password: string | null, headers: string | null, checksum: string | null, cookies: string | null, mirrors: string | null, user_agent: string | null, max_tries: number | null, proxy: string | null, format_selector: string | null, cookie_source: string | null, is_media: boolean | null, };
|
||||
export type EnqueueItem = { id: string, queue_id: string, url: string, destination: string, filename: string, connections: number | null, speed_limit: string | null, username: string | null, password: string | null, headers: string | null, checksum: string | null, cookies: string | null, mirrors: string | null, user_agent: string | null, max_tries: number | null, proxy: string | null, format_selector: string | null, cookie_source: string | null, is_media: boolean | null, lifecycle_generation?: string, };
|
||||
|
||||
@@ -8,13 +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, 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>,
|
||||
/**
|
||||
* HMAC shared secret for the browser extension. It is persisted in the
|
||||
* settings database so that startup never needs to touch the OS keychain.
|
||||
* The keychain is still used as defence-in-depth — grant_keychain_access
|
||||
* writes the token there — but the DB copy is the primary read path,
|
||||
* eliminating the OS credential prompt that macOS shows when the binary
|
||||
* signature changes after an update.
|
||||
*/
|
||||
extensionPairingToken: string, 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, 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>, extensionPairingToken: string, autoCheckUpdates: boolean, keychainAccessGranted: boolean, };
|
||||
|
||||
@@ -3,7 +3,8 @@ import {
|
||||
useDownloadStore,
|
||||
getSiteLogin,
|
||||
getProxyArgs,
|
||||
type AddDownloadAction
|
||||
type AddDownloadAction,
|
||||
type PendingAddRequestContext
|
||||
} from '../store/useDownloadStore';
|
||||
import { useSettingsStore } from '../store/useSettingsStore';
|
||||
import { FolderPlus, Settings, Shield, RefreshCw, FileText, HardDrive, Database, Link, ArrowRight, Play, ChevronDown, ChevronRight, Video, Film, Music, type LucideIcon } from 'lucide-react';
|
||||
@@ -22,6 +23,7 @@ import { isTransferLocked } from '../utils/downloadActions';
|
||||
import { useToast } from '../contexts/ToastContext';
|
||||
import {
|
||||
canSubmitMetadataRows,
|
||||
appendRequestUrlsAfterVersion,
|
||||
mediaFileNameForSelectedFormat,
|
||||
mediaFormatSelectorForRow,
|
||||
metadataSummaryMessage,
|
||||
@@ -39,16 +41,6 @@ const formatBytes = (bytes: number) => {
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
|
||||
};
|
||||
|
||||
const isCrossHostRedirect = (sourceUrl: string, downloadUrl: string) => {
|
||||
try {
|
||||
const sourceHost = new URL(sourceUrl).hostname;
|
||||
const downloadHost = new URL(downloadUrl).hostname;
|
||||
return downloadHost !== sourceHost && !downloadHost.endsWith(`.${sourceHost}`);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const normalizeComparableUrl = (rawUrl: string) => {
|
||||
try {
|
||||
return new URL(rawUrl).href;
|
||||
@@ -57,6 +49,27 @@ const normalizeComparableUrl = (rawUrl: string) => {
|
||||
}
|
||||
};
|
||||
|
||||
const urlsHaveDifferentHosts = (sourceUrl: string, targetUrl: string) => {
|
||||
try {
|
||||
return new URL(sourceUrl).hostname.toLowerCase() !== new URL(targetUrl).hostname.toLowerCase();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const extensionHeaders = (context: PendingAddRequestContext | undefined) => [
|
||||
context?.referer ? `Referer: ${context.referer.replace(/[\r\n]/g, '')}` : '',
|
||||
context?.media
|
||||
? (context.headers || '')
|
||||
.split(/\r?\n/)
|
||||
.filter(line => {
|
||||
const separator = line.indexOf(':');
|
||||
return separator < 0 || line.slice(0, separator).trim().toLowerCase() !== 'cookie';
|
||||
})
|
||||
.join('\n')
|
||||
: context?.headers
|
||||
].filter(Boolean).join('\n');
|
||||
|
||||
export const AddDownloadsModal = () => {
|
||||
const { addToast } = useToast();
|
||||
const {
|
||||
@@ -67,6 +80,8 @@ export const AddDownloadsModal = () => {
|
||||
pendingAddHeaders,
|
||||
pendingAddCookies,
|
||||
pendingAddMediaUrls,
|
||||
pendingAddRequestContexts,
|
||||
pendingAddRequestVersion,
|
||||
toggleAddModal,
|
||||
addDownload,
|
||||
queues
|
||||
@@ -95,6 +110,7 @@ export const AddDownloadsModal = () => {
|
||||
const [speedLimitEnabled, setSpeedLimitEnabled] = useState(false);
|
||||
const [speedLimit, setSpeedLimit] = useState('1024');
|
||||
const [freeSpace, setFreeSpace] = useState('Unknown');
|
||||
const freeSpaceRequestRef = useRef(0);
|
||||
|
||||
const [useAuth, setUseAuth] = useState(false);
|
||||
const [username, setUsername] = useState('');
|
||||
@@ -106,43 +122,80 @@ export const AddDownloadsModal = () => {
|
||||
const [checksumValue, setChecksumValue] = useState('');
|
||||
const [headers, setHeaders] = useState('');
|
||||
const [cookies, setCookies] = useState('');
|
||||
const headersFromExtensionRef = useRef(false);
|
||||
const cookiesFromExtensionRef = useRef(false);
|
||||
const headersManuallyEditedRef = useRef(false);
|
||||
const cookiesManuallyEditedRef = useRef(false);
|
||||
const modalSessionRef = useRef(false);
|
||||
const observedRequestVersionRef = useRef(0);
|
||||
const [mirrors, setMirrors] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (isAddModalOpen) {
|
||||
setSaveLocation(baseDownloadFolder);
|
||||
setIsSaveLocationManual(false);
|
||||
setUrls(pendingAddUrls || '');
|
||||
setParsedItems([]);
|
||||
setSelectedItemIndex(null);
|
||||
setPendingUseSharedDestination(false);
|
||||
setPendingDestinationOverrides({});
|
||||
setConnections(perServerConnections);
|
||||
setSpeedLimitEnabled(false);
|
||||
setSpeedLimit('1024');
|
||||
setUseAuth(false);
|
||||
setUsername('');
|
||||
setPassword('');
|
||||
setAdvancedExpanded(false);
|
||||
setChecksumEnabled(false);
|
||||
setChecksumAlgo('SHA-256');
|
||||
setChecksumValue('');
|
||||
const extensionHeaders = [
|
||||
pendingAddReferer ? `Referer: ${pendingAddReferer.replace(/[\r\n]/g, '')}` : '',
|
||||
pendingAddHeaders
|
||||
].filter(Boolean).join('\n');
|
||||
setHeaders(extensionHeaders);
|
||||
headersFromExtensionRef.current = Boolean(extensionHeaders.trim());
|
||||
setCookies(pendingAddCookies);
|
||||
cookiesFromExtensionRef.current = Boolean(pendingAddCookies?.trim());
|
||||
setMirrors('');
|
||||
setIsQueueMenuOpen(false);
|
||||
setIsSubmitting(false);
|
||||
} else {
|
||||
setUrls('');
|
||||
const requestContextForUrl = (url: string) =>
|
||||
pendingAddRequestContexts[normalizeComparableUrl(url)];
|
||||
const hasExtensionRequestContext = Object.keys(pendingAddRequestContexts).length > 0;
|
||||
const headersForRow = (sourceUrl: string) => {
|
||||
if (headersManuallyEditedRef.current) return headers.trim();
|
||||
const context = requestContextForUrl(sourceUrl);
|
||||
if (context) return extensionHeaders(context).trim();
|
||||
return hasExtensionRequestContext ? '' : headers.trim();
|
||||
};
|
||||
const cookiesForRow = (sourceUrl: string, targetUrl = sourceUrl) => {
|
||||
if (cookiesManuallyEditedRef.current) return cookies.trim();
|
||||
const context = requestContextForUrl(sourceUrl);
|
||||
if (context && context.cookies && urlsHaveDifferentHosts(sourceUrl, targetUrl)) {
|
||||
return '';
|
||||
}
|
||||
if (context) return context.cookies.trim();
|
||||
return hasExtensionRequestContext ? '' : cookies.trim();
|
||||
};
|
||||
const suggestedFilenameForRow = (sourceUrl: string) => {
|
||||
const context = requestContextForUrl(sourceUrl);
|
||||
if (context?.filename) return context.filename;
|
||||
return hasExtensionRequestContext ? '' : pendingAddFilename;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAddModalOpen) {
|
||||
modalSessionRef.current = false;
|
||||
setUrls('');
|
||||
return;
|
||||
}
|
||||
|
||||
if (modalSessionRef.current) return;
|
||||
modalSessionRef.current = true;
|
||||
const initialUrls = pendingAddUrls || '';
|
||||
const initialUrlLines = initialUrls.split('\n').map(url => url.trim()).filter(Boolean);
|
||||
observedRequestVersionRef.current = pendingAddRequestVersion;
|
||||
const initialContext = initialUrlLines.length === 1
|
||||
? requestContextForUrl(initialUrlLines[0])
|
||||
: undefined;
|
||||
|
||||
setSaveLocation(baseDownloadFolder);
|
||||
setIsSaveLocationManual(false);
|
||||
setUrls(initialUrls);
|
||||
setParsedItems([]);
|
||||
setSelectedItemIndex(null);
|
||||
setPendingUseSharedDestination(false);
|
||||
setPendingDestinationOverrides({});
|
||||
setConnections(perServerConnections);
|
||||
setFreeSpace('Unknown');
|
||||
setSpeedLimitEnabled(false);
|
||||
setSpeedLimit('1024');
|
||||
setUseAuth(false);
|
||||
setUsername('');
|
||||
setPassword('');
|
||||
setAdvancedExpanded(false);
|
||||
setChecksumEnabled(false);
|
||||
setChecksumAlgo('SHA-256');
|
||||
setChecksumValue('');
|
||||
setHeaders(initialContext ? extensionHeaders(initialContext) : [
|
||||
pendingAddReferer ? `Referer: ${pendingAddReferer.replace(/[\r\n]/g, '')}` : '',
|
||||
pendingAddHeaders
|
||||
].filter(Boolean).join('\n'));
|
||||
headersManuallyEditedRef.current = false;
|
||||
setCookies(initialContext?.cookies || pendingAddCookies);
|
||||
cookiesManuallyEditedRef.current = false;
|
||||
setMirrors('');
|
||||
setIsQueueMenuOpen(false);
|
||||
setIsSubmitting(false);
|
||||
}, [
|
||||
isAddModalOpen,
|
||||
pendingAddUrls,
|
||||
@@ -154,6 +207,18 @@ export const AddDownloadsModal = () => {
|
||||
perServerConnections
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAddModalOpen || !modalSessionRef.current
|
||||
|| observedRequestVersionRef.current === pendingAddRequestVersion) return;
|
||||
const observedVersion = observedRequestVersionRef.current;
|
||||
observedRequestVersionRef.current = pendingAddRequestVersion;
|
||||
setUrls(current => appendRequestUrlsAfterVersion(
|
||||
current,
|
||||
pendingAddRequestContexts,
|
||||
observedVersion
|
||||
));
|
||||
}, [isAddModalOpen, pendingAddRequestContexts, pendingAddRequestVersion]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isQueueMenuOpen) return;
|
||||
const closeMenu = (event: PointerEvent) => {
|
||||
@@ -180,10 +245,20 @@ export const AddDownloadsModal = () => {
|
||||
}, [isQueueMenuOpen, showingDuplicates]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!saveLocation) return;
|
||||
const requestId = ++freeSpaceRequestRef.current;
|
||||
if (!isAddModalOpen || !saveLocation) return;
|
||||
|
||||
invoke('get_free_space', { path: saveLocation })
|
||||
.then(space => setFreeSpace(space))
|
||||
.catch(() => setFreeSpace('Unknown'));
|
||||
.then(space => {
|
||||
if (freeSpaceRequestRef.current === requestId) {
|
||||
setFreeSpace(space);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (freeSpaceRequestRef.current === requestId) {
|
||||
setFreeSpace('Unknown');
|
||||
}
|
||||
});
|
||||
}, [saveLocation, isAddModalOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -194,10 +269,33 @@ export const AddDownloadsModal = () => {
|
||||
return url;
|
||||
}
|
||||
}));
|
||||
setParsedItems(current =>
|
||||
reconcileDownloadRows(urls, current, pendingAddFilename || undefined, forcedMediaUrls)
|
||||
const requestFilenames = Object.fromEntries(
|
||||
Object.entries(pendingAddRequestContexts)
|
||||
.filter(([, context]) => Boolean(context.filename))
|
||||
.map(([url, context]) => [url, context.filename])
|
||||
);
|
||||
}, [urls, pendingAddFilename, pendingAddMediaUrls]);
|
||||
const requestContextVersions = Object.fromEntries(
|
||||
Object.entries(pendingAddRequestContexts)
|
||||
.map(([url, context]) => [url, context.version])
|
||||
);
|
||||
setParsedItems(current =>
|
||||
reconcileDownloadRows(
|
||||
urls,
|
||||
current,
|
||||
hasExtensionRequestContext ? undefined : pendingAddFilename || undefined,
|
||||
forcedMediaUrls,
|
||||
undefined,
|
||||
requestFilenames,
|
||||
requestContextVersions
|
||||
)
|
||||
);
|
||||
}, [
|
||||
urls,
|
||||
pendingAddFilename,
|
||||
pendingAddMediaUrls,
|
||||
pendingAddRequestContexts,
|
||||
hasExtensionRequestContext
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
for (const row of parsedItems) {
|
||||
@@ -223,67 +321,19 @@ export const AddDownloadsModal = () => {
|
||||
}
|
||||
}
|
||||
|
||||
const rowHeaders = headersForRow(row.sourceUrl);
|
||||
const rowCookies = cookiesForRow(row.sourceUrl);
|
||||
const mediaMetadataArgs = {
|
||||
url: row.sourceUrl,
|
||||
cookieBrowser: browserArg,
|
||||
userAgent: settingsStore.customUserAgent.trim() || null,
|
||||
username: useAuth ? username.trim() || null : login?.username || null,
|
||||
password: useAuth ? password || null : keychainPassword,
|
||||
headers: headers?.trim() || null,
|
||||
cookies: cookies?.trim() || null,
|
||||
headers: rowHeaders || null,
|
||||
cookies: rowCookies || null,
|
||||
proxy
|
||||
};
|
||||
const cleanMediaMetadataArgs = {
|
||||
...mediaMetadataArgs,
|
||||
headers: null,
|
||||
cookies: null
|
||||
};
|
||||
const isExtensionMediaRow = pendingAddMediaUrls
|
||||
.map(normalizeComparableUrl)
|
||||
.includes(normalizeComparableUrl(row.sourceUrl));
|
||||
const hasExtensionContext =
|
||||
headersFromExtensionRef.current || cookiesFromExtensionRef.current;
|
||||
let mediaData;
|
||||
if (isExtensionMediaRow && hasExtensionContext) {
|
||||
try {
|
||||
mediaData = await fetchMediaMetadataDeduped(cleanMediaMetadataArgs);
|
||||
if (headersFromExtensionRef.current) {
|
||||
setHeaders('');
|
||||
headersFromExtensionRef.current = false;
|
||||
}
|
||||
if (cookiesFromExtensionRef.current) {
|
||||
setCookies('');
|
||||
cookiesFromExtensionRef.current = false;
|
||||
}
|
||||
} catch (cleanError) {
|
||||
console.warn(
|
||||
'Media metadata failed without extension browser context; retrying with extension headers/cookies',
|
||||
cleanError
|
||||
);
|
||||
mediaData = await fetchMediaMetadataDeduped(mediaMetadataArgs);
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
mediaData = await fetchMediaMetadataDeduped(mediaMetadataArgs);
|
||||
} catch (error) {
|
||||
if (!hasExtensionContext) {
|
||||
throw error;
|
||||
}
|
||||
console.warn(
|
||||
'Media metadata failed with extension browser context; retrying without extension headers/cookies',
|
||||
error
|
||||
);
|
||||
mediaData = await fetchMediaMetadataDeduped(cleanMediaMetadataArgs);
|
||||
if (headersFromExtensionRef.current) {
|
||||
setHeaders('');
|
||||
headersFromExtensionRef.current = false;
|
||||
}
|
||||
if (cookiesFromExtensionRef.current) {
|
||||
setCookies('');
|
||||
cookiesFromExtensionRef.current = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
const mediaData = await fetchMediaMetadataDeduped(mediaMetadataArgs);
|
||||
if (mediaData && mediaData.formats.length > 0) {
|
||||
const mappedFormats = mediaData.formats.map(f => {
|
||||
const quality = f.resolution || 'Video';
|
||||
@@ -337,15 +387,11 @@ export const AddDownloadsModal = () => {
|
||||
userAgent: settingsStore.customUserAgent.trim() || null,
|
||||
username: useAuth ? username.trim() || null : login?.username || null,
|
||||
password: useAuth ? password || null : keychainPassword,
|
||||
headers: headers?.trim() || null,
|
||||
cookies: cookies?.trim() || null,
|
||||
headers: headersForRow(row.sourceUrl) || null,
|
||||
cookies: cookiesForRow(row.sourceUrl) || null,
|
||||
proxy
|
||||
});
|
||||
const nextDownloadUrl = meta.url || row.sourceUrl;
|
||||
if (cookiesFromExtensionRef.current && isCrossHostRedirect(row.sourceUrl, nextDownloadUrl)) {
|
||||
setCookies('');
|
||||
cookiesFromExtensionRef.current = false;
|
||||
}
|
||||
setParsedItems(current => updateRowIfCurrent(
|
||||
current,
|
||||
row.id,
|
||||
@@ -355,8 +401,8 @@ export const AddDownloadsModal = () => {
|
||||
...currentRow,
|
||||
downloadUrl: nextDownloadUrl || currentRow.downloadUrl,
|
||||
file: canonicalizeDownloadFileName(
|
||||
current.length === 1 && pendingAddFilename
|
||||
? pendingAddFilename
|
||||
current.length === 1 && suggestedFilenameForRow(row.sourceUrl)
|
||||
? suggestedFilenameForRow(row.sourceUrl)
|
||||
: meta.filename
|
||||
),
|
||||
size: meta.size_bytes ? meta.size : undefined,
|
||||
@@ -504,8 +550,7 @@ export const AddDownloadsModal = () => {
|
||||
itemLocation,
|
||||
finalFile,
|
||||
platform.os
|
||||
) &&
|
||||
download.status !== 'failed'
|
||||
)
|
||||
) {
|
||||
fileExistsInStore = true;
|
||||
break;
|
||||
@@ -647,8 +692,7 @@ export const AddDownloadsModal = () => {
|
||||
itemLocation,
|
||||
finalFile,
|
||||
platform.os
|
||||
) &&
|
||||
download.status !== 'failed'
|
||||
)
|
||||
) {
|
||||
existingItem = download;
|
||||
break;
|
||||
@@ -662,7 +706,11 @@ export const AddDownloadsModal = () => {
|
||||
if (!existingItem) {
|
||||
throw new Error(`Cannot replace ${finalFile}: file is not owned by a Firelink download.`);
|
||||
}
|
||||
await store.removeDownload(existingItem.id, true);
|
||||
// Let the backend decide whether resumable sidecars still
|
||||
// exist after stopping the old transfer. This avoids a race
|
||||
// where a paused item finishes while the replacement is
|
||||
// being prepared.
|
||||
await store.removeDownload(existingItem.id, true, existingItem.status !== 'completed');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -690,11 +738,11 @@ export const AddDownloadsModal = () => {
|
||||
speedLimit: speedLimitEnabled ? `${speedLimit}K` : '0',
|
||||
username: useAuth ? username.trim() : undefined,
|
||||
password: useAuth ? password.trim() : undefined,
|
||||
headers: headers.trim() || undefined,
|
||||
headers: headersForRow(item.sourceUrl) || undefined,
|
||||
checksum: checksumEnabled && checksumValue.trim()
|
||||
? `${checksumAlgo}=${checksumValue.trim()}`
|
||||
: undefined,
|
||||
cookies: cookies.trim() || undefined,
|
||||
cookies: cookiesForRow(item.sourceUrl, item.downloadUrl) || undefined,
|
||||
mirrors: mirrors.trim() || undefined,
|
||||
destination: useSharedDestination
|
||||
? finalLocation
|
||||
@@ -776,6 +824,10 @@ export const AddDownloadsModal = () => {
|
||||
: 'Unknown';
|
||||
const canSubmit = canSubmitMetadataRows(parsedItems);
|
||||
const failedMetadataCount = parsedItems.filter(item => item.status === 'metadata-error').length;
|
||||
const failedMediaMetadataCount = parsedItems.filter(
|
||||
item => item.status === 'metadata-error' && item.isMedia
|
||||
).length;
|
||||
const fallbackMetadataCount = failedMetadataCount - failedMediaMetadataCount;
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -805,7 +857,7 @@ export const AddDownloadsModal = () => {
|
||||
/>
|
||||
)}
|
||||
<div className="app-modal-backdrop fixed inset-0 z-50 flex items-center justify-center">
|
||||
<div className="app-modal add-download-modal w-[900px] h-[650px] flex flex-col overflow-hidden text-sm">
|
||||
<div className="app-modal add-download-modal flex flex-col overflow-hidden text-sm">
|
||||
|
||||
{/* Main Content Split */}
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
@@ -829,7 +881,7 @@ export const AddDownloadsModal = () => {
|
||||
/>
|
||||
<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, {failedMetadataCount} fallback
|
||||
{parsedItems.filter(item => item.status === 'ready').length} ready, {fallbackMetadataCount} fallback, {failedMediaMetadataCount} media retry
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
@@ -895,7 +947,7 @@ export const AddDownloadsModal = () => {
|
||||
</div>
|
||||
) : (
|
||||
item.status === 'metadata-error'
|
||||
? 'Fallback'
|
||||
? item.isMedia ? 'Metadata failed' : 'Fallback'
|
||||
: item.status === 'invalid'
|
||||
? 'Invalid'
|
||||
: 'Ready'
|
||||
@@ -972,7 +1024,7 @@ export const AddDownloadsModal = () => {
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center py-4 relative z-10">
|
||||
<span className="text-xs text-red-400 font-medium">Metadata unavailable. Default media format will be used.</span>
|
||||
<span className="text-xs text-red-400 font-medium">Metadata unavailable. Refresh metadata before adding this media.</span>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
@@ -1089,7 +1141,7 @@ export const AddDownloadsModal = () => {
|
||||
<textarea
|
||||
value={headers}
|
||||
onChange={e => {
|
||||
headersFromExtensionRef.current = false;
|
||||
headersManuallyEditedRef.current = true;
|
||||
setHeaders(e.target.value);
|
||||
}}
|
||||
className="add-download-control w-full h-12 px-3 py-1.5 text-xs font-mono resize-none"
|
||||
@@ -1102,7 +1154,7 @@ export const AddDownloadsModal = () => {
|
||||
type="text"
|
||||
value={cookies}
|
||||
onChange={e => {
|
||||
cookiesFromExtensionRef.current = false;
|
||||
cookiesManuallyEditedRef.current = true;
|
||||
setCookies(e.target.value);
|
||||
}}
|
||||
placeholder="name=value; other=value"
|
||||
|
||||
@@ -53,9 +53,9 @@ export const DeleteConfirmationModal: React.FC = () => {
|
||||
const handleDeleteFile = () => removeMany(true);
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50 animate-fade-in">
|
||||
<div className="app-modal-backdrop fixed inset-0 z-50 flex items-center justify-center bg-black/40">
|
||||
<div
|
||||
className="bg-bg-modal rounded-xl w-full max-w-md overflow-hidden flex flex-col shadow-2xl border border-border-modal scale-in"
|
||||
className="window-safe-modal bg-bg-modal rounded-xl w-full max-w-md overflow-hidden flex flex-col shadow-2xl border border-border-modal scale-in"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="px-5 py-4 border-b border-border-modal flex items-center gap-3">
|
||||
|
||||
@@ -177,6 +177,7 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
|
||||
|
||||
<div
|
||||
className="hidden group-hover:flex items-center justify-end gap-0.5 w-full ml-auto"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onDoubleClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{(download.status === 'queued' || download.status === 'staged') && queueIndex !== -1 && (
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { useDownloadStore, DownloadItem } from '../store/useDownloadStore';
|
||||
import { useToast } from '../contexts/ToastContext';
|
||||
import { useSettingsStore } from '../store/useSettingsStore';
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
startActionLabel
|
||||
} from '../utils/downloadActions';
|
||||
import { isActiveDownloadStatus } from '../utils/downloads';
|
||||
import { readClipboardDownloadUrls } from '../utils/clipboard';
|
||||
|
||||
interface DownloadTableProps {
|
||||
filter: SidebarFilter;
|
||||
@@ -27,9 +28,19 @@ 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, toggleAddModal, openDeleteModal, redownload } = useDownloadStore();
|
||||
const { downloads, queues, assignToQueue, openDeleteModal, redownload } = useDownloadStore();
|
||||
const { addToast } = useToast();
|
||||
const isMac = navigator.userAgent.includes('Mac');
|
||||
const [isReadingClipboard, setIsReadingClipboard] = useState(false);
|
||||
const clipboardReadInFlightRef = useRef(false);
|
||||
const isMountedRef = useRef(true);
|
||||
|
||||
useEffect(() => {
|
||||
isMountedRef.current = true;
|
||||
return () => {
|
||||
isMountedRef.current = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const [contextMenu, setContextMenu] = useState<{ x: number; y: number; id: string } | null>(null);
|
||||
const [animationParent] = useAutoAnimate<HTMLDivElement>();
|
||||
@@ -338,7 +349,7 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
|
||||
}
|
||||
|
||||
try {
|
||||
await invoke('pause_download', { id });
|
||||
await useDownloadStore.getState().pauseDownload(id);
|
||||
} catch (e) {
|
||||
console.error("Failed to pause:", e);
|
||||
showInteractionError('Could not pause download', e);
|
||||
@@ -355,6 +366,52 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
|
||||
|
||||
const contextItem = contextMenu ? downloads.find(d => d.id === contextMenu.id) : null;
|
||||
|
||||
const handleAddDownload = async () => {
|
||||
if (clipboardReadInFlightRef.current) return;
|
||||
|
||||
clipboardReadInFlightRef.current = true;
|
||||
setIsReadingClipboard(true);
|
||||
const store = useDownloadStore.getState();
|
||||
const initialModalState = {
|
||||
isOpen: store.isAddModalOpen,
|
||||
requestVersion: store.pendingAddRequestVersion,
|
||||
};
|
||||
|
||||
try {
|
||||
const urls = await readClipboardDownloadUrls();
|
||||
if (!isMountedRef.current) return;
|
||||
const currentStore = useDownloadStore.getState();
|
||||
|
||||
// Do not append a late clipboard result to a newer extension, deep-link,
|
||||
// paste, or modal request that arrived while the OS clipboard was read.
|
||||
if (
|
||||
currentStore.isAddModalOpen !== initialModalState.isOpen ||
|
||||
currentStore.pendingAddRequestVersion !== initialModalState.requestVersion
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (urls.length > 0) {
|
||||
currentStore.openAddModalWithUrls(urls.join('\n'));
|
||||
} else {
|
||||
currentStore.toggleAddModal(true);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Could not read clipboard for Add Download:', error);
|
||||
if (!isMountedRef.current) return;
|
||||
const currentStore = useDownloadStore.getState();
|
||||
if (
|
||||
currentStore.isAddModalOpen === initialModalState.isOpen &&
|
||||
currentStore.pendingAddRequestVersion === initialModalState.requestVersion
|
||||
) {
|
||||
currentStore.toggleAddModal(true);
|
||||
}
|
||||
} finally {
|
||||
clipboardReadInFlightRef.current = false;
|
||||
if (isMountedRef.current) setIsReadingClipboard(false);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
const getCategoryIcon = (category: string) => {
|
||||
switch(category) {
|
||||
@@ -378,7 +435,13 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
|
||||
<div className="main-titlebar-title cursor-default" data-tauri-drag-region>Firelink</div>
|
||||
|
||||
<div className="main-control-group">
|
||||
<button className="main-control-button primary" onClick={() => toggleAddModal(true)} title="Add Download">
|
||||
<button
|
||||
className="main-control-button primary"
|
||||
onClick={() => void handleAddDownload()}
|
||||
disabled={isReadingClipboard}
|
||||
aria-busy={isReadingClipboard}
|
||||
title="Add Download"
|
||||
>
|
||||
<Plus size={16} />
|
||||
</button>
|
||||
|
||||
|
||||
@@ -56,9 +56,9 @@ export const KeychainPermissionModal: React.FC = () => {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50 animate-fade-in">
|
||||
<div className="app-modal-backdrop fixed inset-0 z-50 flex items-center justify-center bg-black/40">
|
||||
<div
|
||||
className="bg-bg-modal rounded-xl w-full max-w-md overflow-hidden flex flex-col shadow-2xl border border-border-modal scale-in"
|
||||
className="window-safe-modal bg-bg-modal rounded-xl w-full max-w-md overflow-hidden flex flex-col shadow-2xl border border-border-modal scale-in"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="px-5 py-4 border-b border-border-modal flex items-center gap-3">
|
||||
@@ -68,7 +68,7 @@ export const KeychainPermissionModal: React.FC = () => {
|
||||
<h2 className="text-lg font-semibold text-text-primary m-0">Credential Storage Access Needed</h2>
|
||||
</div>
|
||||
|
||||
<div className="px-5 py-6 flex-1 text-sm text-text-secondary leading-relaxed space-y-4">
|
||||
<div className="px-5 py-6 flex-1 min-h-0 overflow-y-auto text-sm text-text-secondary leading-relaxed space-y-4">
|
||||
<p>
|
||||
Firelink uses the browser extension to capture downloads. To keep the extension paired after restarts,
|
||||
Firelink stores its pairing token in {storeName}.
|
||||
|
||||
+81
-43
@@ -7,11 +7,15 @@ import { FileDown, Trash2, Terminal, Filter, Play, Pause, Info, Copy } from 'luc
|
||||
import { WindowDragRegion } from './WindowDragRegion';
|
||||
import { useToast } from '../contexts/ToastContext';
|
||||
import { useSettingsStore } from '../store/useSettingsStore';
|
||||
|
||||
interface LogEntry {
|
||||
level: 'Trace' | 'Debug' | 'Info' | 'Warn' | 'Error';
|
||||
message: string;
|
||||
}
|
||||
import {
|
||||
MAX_LOG_LINES,
|
||||
appendBoundedLogEntries,
|
||||
liveLogEntry,
|
||||
mergeLogSnapshotAndLiveEntries,
|
||||
persistedLogEntry,
|
||||
pushBoundedLogEntry,
|
||||
type LogEntry
|
||||
} from '../utils/logEntries';
|
||||
|
||||
export default function LogsView() {
|
||||
const { addToast } = useToast();
|
||||
@@ -20,56 +24,75 @@ export default function LogsView() {
|
||||
const [logs, setLogs] = useState<LogEntry[]>([]);
|
||||
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 scrollRef = useRef<HTMLDivElement>(null);
|
||||
const rawLineCountRef = useRef(0);
|
||||
|
||||
const MAX_LOG_LINES = 2000;
|
||||
const liveBatchRef = useRef<LogEntry[]>([]);
|
||||
const liveFrameRef = useRef<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const handleVisibilityChange = () => setPageVisible(document.visibilityState !== 'hidden');
|
||||
document.addEventListener('visibilitychange', handleVisibilityChange);
|
||||
return () => document.removeEventListener('visibilitychange', handleVisibilityChange);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!pageVisible) {
|
||||
void invoke('set_log_stream_active', { active: false }).catch(console.error);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!logsEnabled) {
|
||||
void invoke('set_log_stream_active', { active: false }).catch(console.error);
|
||||
}
|
||||
|
||||
let active = true;
|
||||
let unlisten: (() => void) | undefined;
|
||||
let initialized = false;
|
||||
let pendingLiveEntries: LogEntry[] = [];
|
||||
let unlistenPromise: Promise<() => void> | undefined;
|
||||
|
||||
const scheduleLiveEntry = (entry: LogEntry) => {
|
||||
if (!initialized) {
|
||||
pushBoundedLogEntry(pendingLiveEntries, entry);
|
||||
return;
|
||||
}
|
||||
|
||||
pushBoundedLogEntry(liveBatchRef.current, entry);
|
||||
if (liveFrameRef.current !== null) return;
|
||||
liveFrameRef.current = window.requestAnimationFrame(() => {
|
||||
liveFrameRef.current = null;
|
||||
if (!active || liveBatchRef.current.length === 0) return;
|
||||
const batch = liveBatchRef.current;
|
||||
liveBatchRef.current = [];
|
||||
setLogs(current => appendBoundedLogEntries(current, batch));
|
||||
});
|
||||
};
|
||||
|
||||
const init = async () => {
|
||||
try {
|
||||
await initLogger();
|
||||
const [lines] = await Promise.all([
|
||||
invoke('read_logs', { limit: MAX_LOG_LINES })
|
||||
]);
|
||||
if (!active) return;
|
||||
const initialLogs = lines.map(message => {
|
||||
const level: LogEntry['level'] = message.includes('[ERROR]') ? 'Error'
|
||||
: message.includes('[WARN]') ? 'Warn'
|
||||
: message.includes('[INFO]') ? 'Info'
|
||||
: message.includes('[TRACE]') ? 'Trace'
|
||||
: 'Debug';
|
||||
return { level, message };
|
||||
});
|
||||
|
||||
setLogs(initialLogs);
|
||||
rawLineCountRef.current = initialLogs.length;
|
||||
|
||||
if (logsEnabled) {
|
||||
unlisten = await attachLogger((log) => {
|
||||
unlistenPromise = attachLogger((log) => {
|
||||
if (!active) return;
|
||||
const levelStr: LogEntry['level'] = log.level === 5 ? 'Error'
|
||||
: log.level === 4 ? 'Warn'
|
||||
: log.level === 3 ? 'Info'
|
||||
: log.level === 1 ? 'Trace'
|
||||
: 'Debug';
|
||||
|
||||
const timeStr = new Date().toISOString().replace('T', ' ').substring(0, 19);
|
||||
const formattedMsg = `[${timeStr}] [${levelStr.toUpperCase()}] ${log.message}`;
|
||||
|
||||
setLogs(prev => {
|
||||
const newLogs = [...prev, { level: levelStr, message: formattedMsg }];
|
||||
rawLineCountRef.current = newLogs.length;
|
||||
if (newLogs.length > MAX_LOG_LINES + 500) {
|
||||
return newLogs.slice(newLogs.length - MAX_LOG_LINES);
|
||||
}
|
||||
return newLogs;
|
||||
});
|
||||
scheduleLiveEntry(liveLogEntry(log.level, log.message));
|
||||
});
|
||||
await unlistenPromise;
|
||||
if (!active) return;
|
||||
await invoke('set_log_stream_active', { active: true });
|
||||
if (!active) {
|
||||
await invoke('set_log_stream_active', { active: false }).catch(console.error);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const lines = await invoke('read_logs', { limit: MAX_LOG_LINES });
|
||||
if (!active) return;
|
||||
const snapshot = lines.map(persistedLogEntry);
|
||||
initialized = true;
|
||||
const caughtUpLogs = mergeLogSnapshotAndLiveEntries(snapshot, pendingLiveEntries);
|
||||
pendingLiveEntries = [];
|
||||
setLogs(caughtUpLogs);
|
||||
} catch (e) {
|
||||
console.error('Failed to init logs:', e);
|
||||
}
|
||||
@@ -78,9 +101,19 @@ export default function LogsView() {
|
||||
|
||||
return () => {
|
||||
active = false;
|
||||
if (unlisten) unlisten();
|
||||
liveBatchRef.current = [];
|
||||
if (liveFrameRef.current !== null) {
|
||||
window.cancelAnimationFrame(liveFrameRef.current);
|
||||
liveFrameRef.current = null;
|
||||
}
|
||||
if (logsEnabled) {
|
||||
void invoke('set_log_stream_active', { active: false }).catch(console.error);
|
||||
}
|
||||
if (unlistenPromise) {
|
||||
void unlistenPromise.then(unlisten => unlisten()).catch(console.error);
|
||||
}
|
||||
};
|
||||
}, [logsEnabled]);
|
||||
}, [logsEnabled, pageVisible]);
|
||||
|
||||
useEffect(() => {
|
||||
if (scrollRef.current) {
|
||||
@@ -139,6 +172,11 @@ export default function LogsView() {
|
||||
};
|
||||
|
||||
const handleClear = async () => {
|
||||
liveBatchRef.current = [];
|
||||
if (liveFrameRef.current !== null) {
|
||||
window.cancelAnimationFrame(liveFrameRef.current);
|
||||
liveFrameRef.current = null;
|
||||
}
|
||||
setLogs([]);
|
||||
await invoke('clear_logs').catch(console.error);
|
||||
};
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
Inbox, Zap, CheckCircle2, CircleDashed,
|
||||
Film, Music, FileText, Box, Image as ImageIcon, Archive, FileQuestion,
|
||||
List, CalendarClock, Gauge, Bug, Settings, Plus, Play, Pause, Edit2, Trash2, PanelLeft,
|
||||
ChevronDown,
|
||||
type LucideIcon
|
||||
} from 'lucide-react';
|
||||
import { useDownloadStore, DownloadCategory, Queue } from '../store/useDownloadStore';
|
||||
@@ -28,6 +29,11 @@ export const Sidebar: React.FC<SidebarProps> = (props) => {
|
||||
const [renamingQueueId, setRenamingQueueId] = useState<string | null>(null);
|
||||
const [editingQueueName, setEditingQueueName] = useState('');
|
||||
const [contextMenu, setContextMenu] = useState<{ x: number; y: number; id: string } | null>(null);
|
||||
const [foldersCollapsed, setFoldersCollapsed] = useState(() =>
|
||||
window.localStorage.getItem('firelink-folders-collapsed') === 'true'
|
||||
);
|
||||
const foldersToggleRef = useRef<HTMLButtonElement>(null);
|
||||
const foldersListRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const addInputRef = useRef<HTMLInputElement>(null);
|
||||
const renameInputRef = useRef<HTMLInputElement>(null);
|
||||
@@ -53,6 +59,16 @@ export const Sidebar: React.FC<SidebarProps> = (props) => {
|
||||
if (renamingQueueId) renameInputRef.current?.focus();
|
||||
}, [renamingQueueId]);
|
||||
|
||||
useEffect(() => {
|
||||
window.localStorage.setItem('firelink-folders-collapsed', String(foldersCollapsed));
|
||||
}, [foldersCollapsed]);
|
||||
|
||||
useEffect(() => {
|
||||
if (foldersCollapsed && foldersListRef.current?.contains(document.activeElement)) {
|
||||
foldersToggleRef.current?.focus();
|
||||
}
|
||||
}, [foldersCollapsed]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (document.querySelector('.app-modal-backdrop') || document.querySelector('.app-modal')) return;
|
||||
@@ -214,14 +230,38 @@ export const Sidebar: React.FC<SidebarProps> = (props) => {
|
||||
</section>
|
||||
|
||||
<section className="sidebar-section">
|
||||
<div className="sidebar-section-label">Folders</div>
|
||||
<NavItem icon={Music} label="Musics" filter="Musics" />
|
||||
<NavItem icon={Film} label="Movies" filter="Movies" />
|
||||
<NavItem icon={Archive} label="Compressed" filter="Compressed" />
|
||||
<NavItem icon={FileText} label="Documents" filter="Documents" />
|
||||
<NavItem icon={ImageIcon} label="Pictures" filter="Pictures" />
|
||||
<NavItem icon={Box} label="Applications" filter="Applications" />
|
||||
<NavItem icon={FileQuestion} label="Other" filter="Other" />
|
||||
<button
|
||||
type="button"
|
||||
ref={foldersToggleRef}
|
||||
className="sidebar-section-label sidebar-section-label-toggle"
|
||||
aria-expanded={!foldersCollapsed}
|
||||
aria-controls="sidebar-folders-list"
|
||||
onClick={() => setFoldersCollapsed(collapsed => !collapsed)}
|
||||
>
|
||||
<span>Folders</span>
|
||||
<ChevronDown
|
||||
aria-hidden="true"
|
||||
size={13}
|
||||
className={`sidebar-section-chevron ${foldersCollapsed ? 'is-collapsed' : ''}`}
|
||||
/>
|
||||
</button>
|
||||
<div
|
||||
ref={foldersListRef}
|
||||
id="sidebar-folders-list"
|
||||
className={`sidebar-collapse-grid ${foldersCollapsed ? 'is-collapsed' : ''}`}
|
||||
aria-hidden={foldersCollapsed}
|
||||
inert={foldersCollapsed}
|
||||
>
|
||||
<div className="sidebar-collapse-content">
|
||||
<NavItem icon={Music} label="Musics" filter="Musics" />
|
||||
<NavItem icon={Film} label="Movies" filter="Movies" />
|
||||
<NavItem icon={Archive} label="Compressed" filter="Compressed" />
|
||||
<NavItem icon={FileText} label="Documents" filter="Documents" />
|
||||
<NavItem icon={ImageIcon} label="Pictures" filter="Pictures" />
|
||||
<NavItem icon={Box} label="Applications" filter="Applications" />
|
||||
<NavItem icon={FileQuestion} label="Other" filter="Other" />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="sidebar-section">
|
||||
|
||||
@@ -496,6 +496,20 @@ html[data-list-density="relaxed"] {
|
||||
.app-modal-backdrop {
|
||||
background: hsl(0 0% 0% / 0.2);
|
||||
animation: fade-in 150ms ease-out;
|
||||
overflow: auto;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.app-shell--window-chrome .app-modal-backdrop {
|
||||
padding-top: 56px;
|
||||
}
|
||||
|
||||
.app-modal-backdrop > .app-modal,
|
||||
.app-modal-backdrop > .window-safe-modal {
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.app-modal {
|
||||
@@ -510,6 +524,10 @@ html[data-list-density="relaxed"] {
|
||||
|
||||
/* Add Download window */
|
||||
.add-download-modal {
|
||||
width: min(900px, 100%);
|
||||
height: min(650px, 100%);
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
--add-surface: hsl(var(--surface-raised) / 0.72);
|
||||
--add-highlight: hsl(0 0% 100% / 0.08);
|
||||
background:
|
||||
@@ -525,12 +543,16 @@ html[data-list-density="relaxed"] {
|
||||
}
|
||||
|
||||
.add-download-left {
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
background:
|
||||
radial-gradient(circle at 18% 0%, hsl(var(--accent-color) / 0.055), transparent 34%),
|
||||
hsl(var(--main-bg) / 0.56);
|
||||
}
|
||||
|
||||
.add-download-settings {
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
background: hsl(var(--bg-modal) / 0.7);
|
||||
}
|
||||
|
||||
@@ -1143,6 +1165,75 @@ html[data-list-density="relaxed"] {
|
||||
color: hsl(var(--text-muted));
|
||||
}
|
||||
|
||||
.sidebar-section-label-toggle {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: hsl(var(--text-muted));
|
||||
cursor: default;
|
||||
transition: color 140ms ease;
|
||||
}
|
||||
|
||||
.sidebar-section-label-toggle:hover {
|
||||
color: hsl(var(--text-secondary));
|
||||
}
|
||||
|
||||
.sidebar-section-chevron {
|
||||
flex-shrink: 0;
|
||||
opacity: 0.8;
|
||||
transform: rotate(0deg);
|
||||
transition: transform 240ms cubic-bezier(0.22, 1, 0.36, 1), opacity 140ms ease;
|
||||
}
|
||||
|
||||
.sidebar-section-chevron.is-collapsed {
|
||||
transform: rotate(-90deg);
|
||||
opacity: 0.58;
|
||||
}
|
||||
|
||||
.sidebar-collapse-grid {
|
||||
display: grid;
|
||||
grid-template-rows: 1fr;
|
||||
transition: grid-template-rows 240ms cubic-bezier(0.22, 1, 0.36, 1);
|
||||
}
|
||||
|
||||
.sidebar-collapse-grid.is-collapsed {
|
||||
grid-template-rows: 0fr;
|
||||
}
|
||||
|
||||
.sidebar-collapse-content {
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
transform-origin: top;
|
||||
transition:
|
||||
opacity 150ms ease,
|
||||
transform 240ms cubic-bezier(0.22, 1, 0.36, 1),
|
||||
visibility 0s linear 0s;
|
||||
}
|
||||
|
||||
.sidebar-collapse-grid.is-collapsed .sidebar-collapse-content {
|
||||
opacity: 0;
|
||||
transform: translateY(-6px);
|
||||
pointer-events: none;
|
||||
visibility: hidden;
|
||||
transition:
|
||||
opacity 120ms ease,
|
||||
transform 240ms cubic-bezier(0.22, 1, 0.36, 1),
|
||||
visibility 0s linear 120ms;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.sidebar-section-chevron,
|
||||
.sidebar-collapse-grid,
|
||||
.sidebar-collapse-content {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
.sidebar-section {
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
+3
-1
@@ -32,7 +32,7 @@ type CommandMap = {
|
||||
open_downloaded_file: { args: { path: string }; result: void };
|
||||
pause_download: { args: { id: string }; result: void };
|
||||
resume_download: { args: { id: string }; result: boolean };
|
||||
remove_download: { args: { id: string; deleteAssets: boolean }; result: void };
|
||||
remove_download: { args: { id: string; deleteAssets: boolean; preserveResumable?: boolean }; result: void };
|
||||
detach_download_for_reconfigure: { args: { id: string }; result: void };
|
||||
update_dock_badge: { args: { count: number }; result: void };
|
||||
get_platform_info: { args: undefined; result: PlatformInfo };
|
||||
@@ -76,8 +76,10 @@ type CommandMap = {
|
||||
clear_logs: { args: undefined; result: void };
|
||||
toggle_log_pause: { args: { pause: boolean }; result: void };
|
||||
is_log_paused: { args: undefined; result: boolean };
|
||||
set_log_stream_active: { args: { active: boolean }; result: void };
|
||||
get_pending_order: { args: { queueId: string | null }; result: string[] };
|
||||
enqueue_download: { args: { item: EnqueueItem }; result: EnqueueAccepted };
|
||||
cancel_enqueue_generation: { args: { id: string; generation: string }; result: void };
|
||||
enqueue_many: { args: { items: EnqueueItem[] }; result: import('./bindings/EnqueueResult').EnqueueResult[] };
|
||||
move_in_queue: { args: { id: string; queueId: string; direction: 'up' | 'down' }; result: string[] };
|
||||
remove_from_queue: { args: { id: string }; result: boolean };
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
import { useDownloadProgressStore } from './downloadStore';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { initDownloadListener, useDownloadProgressStore } from './downloadStore';
|
||||
import * as ipc from '../ipc';
|
||||
|
||||
vi.mock('../ipc', () => ({
|
||||
invokeCommand: vi.fn(),
|
||||
listenEvent: vi.fn(),
|
||||
}));
|
||||
|
||||
describe('useDownloadProgressStore', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
useDownloadProgressStore.setState({ progressMap: {} });
|
||||
});
|
||||
|
||||
@@ -20,4 +27,22 @@ describe('useDownloadProgressStore', () => {
|
||||
|
||||
expect(useDownloadProgressStore.getState().progressMap).toEqual({});
|
||||
});
|
||||
|
||||
it('shares listener setup across overlapping consumers and tears down after the last release', async () => {
|
||||
const unlisten = vi.fn();
|
||||
vi.mocked(ipc.listenEvent).mockResolvedValue(unlisten);
|
||||
|
||||
const first = initDownloadListener();
|
||||
const second = initDownloadListener();
|
||||
|
||||
expect(ipc.listenEvent).toHaveBeenCalledTimes(3);
|
||||
|
||||
const releaseFirst = await first;
|
||||
const releaseSecond = await second;
|
||||
releaseFirst();
|
||||
expect(unlisten).not.toHaveBeenCalled();
|
||||
|
||||
releaseSecond();
|
||||
expect(unlisten).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
});
|
||||
|
||||
+79
-43
@@ -35,40 +35,50 @@ export const useDownloadProgressStore = create<DownloadProgressState>((set) => (
|
||||
let unlistenProgress: UnlistenFn | null = null;
|
||||
let unlistenState: UnlistenFn | null = null;
|
||||
let unlistenTray: UnlistenFn | null = null;
|
||||
let listenerSetup: Promise<void> | null = null;
|
||||
let listenerConsumers = 0;
|
||||
|
||||
export async function initDownloadListener() {
|
||||
if (unlistenProgress) return;
|
||||
unlistenProgress = await listen('download-progress', (event) => {
|
||||
const payload = event.payload;
|
||||
useDownloadProgressStore.getState().updateDownloadProgress(payload.id, payload);
|
||||
const disposeDownloadListeners = () => {
|
||||
unlistenProgress?.();
|
||||
unlistenProgress = null;
|
||||
unlistenState?.();
|
||||
unlistenState = null;
|
||||
unlistenTray?.();
|
||||
unlistenTray = null;
|
||||
listenerSetup = null;
|
||||
};
|
||||
|
||||
const mainStore = useDownloadStore.getState();
|
||||
const current = mainStore.downloads.find(d => d.id === payload.id);
|
||||
if (current) {
|
||||
const shouldUpdateSize = Boolean(payload.size && (!current.isMedia || payload.size_is_final));
|
||||
const updates: Partial<DownloadItem> = {};
|
||||
if (current.status === 'downloading' || current.status === 'processing') {
|
||||
updates.fraction = payload.fraction;
|
||||
updates.speed = payload.speed;
|
||||
updates.eta = payload.eta;
|
||||
}
|
||||
if (shouldUpdateSize && current.size !== payload.size) {
|
||||
updates.size = payload.size!;
|
||||
}
|
||||
if (Object.keys(updates).length > 0) {
|
||||
mainStore.updateDownload(payload.id, updates);
|
||||
}
|
||||
}
|
||||
});
|
||||
const startDownloadListeners = async () => {
|
||||
const registrations = await Promise.allSettled([
|
||||
listen('download-progress', (event) => {
|
||||
const payload = event.payload;
|
||||
useDownloadProgressStore.getState().updateDownloadProgress(payload.id, payload);
|
||||
|
||||
if (!unlistenState) {
|
||||
unlistenState = await listen('download-state', (event) => {
|
||||
const mainStore = useDownloadStore.getState();
|
||||
const current = mainStore.downloads.find(d => d.id === payload.id);
|
||||
if (current) {
|
||||
const shouldUpdateSize = Boolean(payload.size && (!current.isMedia || payload.size_is_final));
|
||||
const updates: Partial<DownloadItem> = {};
|
||||
if (current.status === 'downloading' || current.status === 'processing') {
|
||||
updates.fraction = payload.fraction;
|
||||
updates.speed = payload.speed;
|
||||
updates.eta = payload.eta;
|
||||
}
|
||||
if (shouldUpdateSize && current.size !== payload.size) {
|
||||
updates.size = payload.size!;
|
||||
}
|
||||
if (Object.keys(updates).length > 0) {
|
||||
mainStore.updateDownload(payload.id, updates);
|
||||
}
|
||||
}
|
||||
}),
|
||||
listen('download-state', (event) => {
|
||||
const payload = event.payload;
|
||||
const mainStore = useDownloadStore.getState();
|
||||
const current = mainStore.downloads.find(d => d.id === payload.id);
|
||||
if (current) {
|
||||
const status = payload.status as DownloadStatus;
|
||||
|
||||
|
||||
// Prevent race condition: don't transition backwards from terminal state
|
||||
if ((current.status === 'completed' || current.status === 'failed') &&
|
||||
(status !== 'completed' && status !== 'failed')) {
|
||||
@@ -111,32 +121,58 @@ export async function initDownloadListener() {
|
||||
useDownloadProgressStore.getState().clearDownloadProgress(payload.id);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (!unlistenTray) {
|
||||
unlistenTray = await listen('tray-action', (event) => {
|
||||
}),
|
||||
listen('tray-action', (event) => {
|
||||
const mainStore = useDownloadStore.getState();
|
||||
if (event.payload === 'pause-all') {
|
||||
void mainStore.pauseAll();
|
||||
} else if (event.payload === 'resume-all') {
|
||||
void mainStore.startAll();
|
||||
}
|
||||
}),
|
||||
]);
|
||||
|
||||
const failedRegistration = registrations.find(
|
||||
(registration): registration is PromiseRejectedResult => registration.status === 'rejected'
|
||||
);
|
||||
if (failedRegistration) {
|
||||
for (const registration of registrations) {
|
||||
if (registration.status === 'fulfilled') registration.value();
|
||||
}
|
||||
throw failedRegistration.reason;
|
||||
}
|
||||
|
||||
const [progress, state, tray] = registrations as [
|
||||
PromiseFulfilledResult<UnlistenFn>,
|
||||
PromiseFulfilledResult<UnlistenFn>,
|
||||
PromiseFulfilledResult<UnlistenFn>,
|
||||
];
|
||||
unlistenProgress = progress.value;
|
||||
unlistenState = state.value;
|
||||
unlistenTray = tray.value;
|
||||
};
|
||||
|
||||
export async function initDownloadListener(): Promise<() => void> {
|
||||
listenerConsumers += 1;
|
||||
if (!listenerSetup) {
|
||||
listenerSetup = startDownloadListeners().catch(error => {
|
||||
disposeDownloadListeners();
|
||||
throw error;
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
await listenerSetup;
|
||||
} catch (error) {
|
||||
listenerConsumers -= 1;
|
||||
throw error;
|
||||
}
|
||||
|
||||
let released = false;
|
||||
return () => {
|
||||
if (unlistenProgress) {
|
||||
unlistenProgress();
|
||||
unlistenProgress = null;
|
||||
}
|
||||
if (unlistenState) {
|
||||
unlistenState();
|
||||
unlistenState = null;
|
||||
}
|
||||
if (unlistenTray) {
|
||||
unlistenTray();
|
||||
unlistenTray = null;
|
||||
}
|
||||
if (released) return;
|
||||
released = true;
|
||||
listenerConsumers -= 1;
|
||||
if (listenerConsumers === 0) disposeDownloadListeners();
|
||||
};
|
||||
}
|
||||
|
||||
@@ -88,9 +88,21 @@ describe('useDownloadStore', () => {
|
||||
pendingAddHeaders: '',
|
||||
pendingAddCookies: '',
|
||||
pendingAddMediaUrls: [],
|
||||
pendingAddRequestContexts: {},
|
||||
pendingAddRequestVersion: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it('invalidates in-flight Add-modal handoffs when the modal is toggled', () => {
|
||||
const initialVersion = useDownloadStore.getState().pendingAddRequestVersion;
|
||||
|
||||
useDownloadStore.getState().toggleAddModal(true);
|
||||
expect(useDownloadStore.getState().pendingAddRequestVersion).toBe(initialVersion + 1);
|
||||
|
||||
useDownloadStore.getState().toggleAddModal(false);
|
||||
expect(useDownloadStore.getState().pendingAddRequestVersion).toBe(initialVersion + 2);
|
||||
});
|
||||
|
||||
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');
|
||||
@@ -211,6 +223,129 @@ describe('useDownloadStore', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('does not resurrect a row removed while its backend enqueue is in flight', async () => {
|
||||
useDownloadStore.setState({
|
||||
downloads: [
|
||||
{ id: 'late', url: 'http://test', fileName: 'late.bin', destination: '/tmp', status: 'queued', category: 'Other', dateAdded: '', queueId: 'MAIN', hasBeenDispatched: false },
|
||||
] as any[],
|
||||
});
|
||||
|
||||
let resolveEnqueue!: (value: { id: string; filename: string }) => void;
|
||||
const enqueue = new Promise<{ id: string; filename: string }>(resolve => {
|
||||
resolveEnqueue = resolve;
|
||||
});
|
||||
vi.mocked(ipc.invokeCommand).mockImplementation((command: string) => {
|
||||
if (command === 'enqueue_download') return enqueue as never;
|
||||
if (command === 'get_pending_order') return Promise.resolve(['late']) as never;
|
||||
return Promise.resolve(undefined) as never;
|
||||
});
|
||||
|
||||
const start = useDownloadStore.getState().startQueue('MAIN');
|
||||
await vi.waitFor(() => {
|
||||
expect(ipc.invokeCommand).toHaveBeenCalledWith(
|
||||
'enqueue_download',
|
||||
expect.objectContaining({ item: expect.objectContaining({ id: 'late' }) })
|
||||
);
|
||||
});
|
||||
|
||||
await useDownloadStore.getState().removeDownload('late');
|
||||
resolveEnqueue({ id: 'late', filename: 'late.bin' });
|
||||
|
||||
await expect(start).resolves.toEqual([]);
|
||||
expect(useDownloadStore.getState().downloads).toEqual([]);
|
||||
expect(useDownloadStore.getState().backendRegisteredIds.has('late')).toBe(false);
|
||||
expect(
|
||||
vi.mocked(ipc.invokeCommand).mock.calls.filter(([command]) => command === 'remove_download')
|
||||
).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('re-enqueues the edited values only after an obsolete queued dispatch is removed', async () => {
|
||||
useDownloadStore.setState({
|
||||
downloads: [
|
||||
{ id: 'edited', url: 'http://test', fileName: 'old.bin', destination: '/tmp', status: 'queued', category: 'Other', dateAdded: '', queueId: 'MAIN', hasBeenDispatched: false },
|
||||
] as any[],
|
||||
});
|
||||
|
||||
let resolveFirstEnqueue!: (value: { id: string; filename: string }) => void;
|
||||
const firstEnqueue = new Promise<{ id: string; filename: string }>(resolve => {
|
||||
resolveFirstEnqueue = resolve;
|
||||
});
|
||||
let enqueueCount = 0;
|
||||
vi.mocked(ipc.invokeCommand).mockImplementation((command: string) => {
|
||||
if (command === 'enqueue_download') {
|
||||
enqueueCount += 1;
|
||||
return (enqueueCount === 1
|
||||
? firstEnqueue
|
||||
: Promise.resolve({ id: 'edited', filename: 'new.bin' })) as never;
|
||||
}
|
||||
if (command === 'get_pending_order') return Promise.resolve(['edited']) as never;
|
||||
return Promise.resolve(undefined) as never;
|
||||
});
|
||||
|
||||
const start = useDownloadStore.getState().startQueue('MAIN');
|
||||
await vi.waitFor(() => expect(enqueueCount).toBe(1));
|
||||
const update = useDownloadStore.getState().applyProperties('edited', { fileName: 'new.bin' });
|
||||
resolveFirstEnqueue({ id: 'edited', filename: 'old.bin' });
|
||||
|
||||
await expect(update).resolves.toBeUndefined();
|
||||
await expect(start).resolves.toEqual([]);
|
||||
expect(enqueueCount).toBe(2);
|
||||
expect(vi.mocked(ipc.invokeCommand)).toHaveBeenCalledWith(
|
||||
'get_pending_order',
|
||||
{ queueId: 'MAIN' }
|
||||
);
|
||||
expect(useDownloadStore.getState().downloads[0]).toMatchObject({
|
||||
fileName: 'new.bin',
|
||||
hasBeenDispatched: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('settles an in-flight dispatch before pausing so it cannot start after pause succeeds', async () => {
|
||||
useDownloadStore.setState({
|
||||
downloads: [
|
||||
{ id: 'paused', url: 'http://test', fileName: 'paused.bin', destination: '/tmp', status: 'queued', category: 'Other', dateAdded: '', queueId: 'MAIN', hasBeenDispatched: false },
|
||||
] as any[],
|
||||
});
|
||||
|
||||
let resolveEnqueue!: (value: { id: string; filename: string }) => void;
|
||||
const enqueue = new Promise<{ id: string; filename: string }>(resolve => {
|
||||
resolveEnqueue = resolve;
|
||||
});
|
||||
vi.mocked(ipc.invokeCommand).mockImplementation((command: string) => {
|
||||
if (command === 'enqueue_download') return enqueue as never;
|
||||
return Promise.resolve(undefined) as never;
|
||||
});
|
||||
|
||||
const start = useDownloadStore.getState().startQueue('MAIN');
|
||||
await vi.waitFor(() => {
|
||||
expect(ipc.invokeCommand).toHaveBeenCalledWith(
|
||||
'enqueue_download',
|
||||
expect.objectContaining({ item: expect.objectContaining({ id: 'paused' }) })
|
||||
);
|
||||
});
|
||||
const pause = useDownloadStore.getState().pauseDownload('paused');
|
||||
await vi.waitFor(() => {
|
||||
expect(ipc.invokeCommand).toHaveBeenCalledWith(
|
||||
'cancel_enqueue_generation',
|
||||
expect.objectContaining({ id: 'paused' })
|
||||
);
|
||||
});
|
||||
expect(
|
||||
vi.mocked(ipc.invokeCommand).mock.calls.some(([command]) => command === 'pause_download')
|
||||
).toBe(false);
|
||||
resolveEnqueue({ id: 'paused', filename: 'paused.bin' });
|
||||
|
||||
await expect(pause).resolves.toBeUndefined();
|
||||
await expect(start).resolves.toEqual([]);
|
||||
expect(useDownloadStore.getState().downloads[0]).toMatchObject({ status: 'paused' });
|
||||
expect(
|
||||
vi.mocked(ipc.invokeCommand).mock.calls.filter(([command]) => command === 'remove_download')
|
||||
).toHaveLength(1);
|
||||
expect(
|
||||
vi.mocked(ipc.invokeCommand).mock.calls.filter(([command]) => command === 'pause_download')
|
||||
).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('resumeDownload unregisters ID and re-dispatches if un-resumable', async () => {
|
||||
useDownloadStore.setState({
|
||||
downloads: [
|
||||
@@ -533,7 +668,12 @@ describe('useDownloadStore', () => {
|
||||
{ id: 'active', url: 'https://example.com/file', fileName: 'file', status: 'downloading', category: 'Other', dateAdded: '', queueId: 'main' }
|
||||
] as any[]
|
||||
});
|
||||
vi.mocked(ipc.invokeCommand).mockRejectedValueOnce(new Error('writer did not stop'));
|
||||
vi.mocked(ipc.invokeCommand).mockImplementation((command: string) => {
|
||||
if (command === 'remove_download') {
|
||||
return Promise.reject(new Error('writer did not stop')) as never;
|
||||
}
|
||||
return Promise.resolve(undefined) as never;
|
||||
});
|
||||
|
||||
await expect(useDownloadStore.getState().removeDownload('active', true))
|
||||
.rejects.toThrow('writer did not stop');
|
||||
@@ -541,6 +681,23 @@ describe('useDownloadStore', () => {
|
||||
.toEqual(['active']);
|
||||
});
|
||||
|
||||
it('asks the backend to preserve resumable assets during replacement removal', async () => {
|
||||
useDownloadStore.setState({
|
||||
downloads: [
|
||||
{ id: 'paused', url: 'https://example.com/file', fileName: 'file', status: 'paused', category: 'Other', dateAdded: '' }
|
||||
] as any[]
|
||||
});
|
||||
vi.mocked(ipc.invokeCommand).mockResolvedValue(undefined as never);
|
||||
|
||||
await useDownloadStore.getState().removeDownload('paused', true, true);
|
||||
|
||||
expect(ipc.invokeCommand).toHaveBeenCalledWith('remove_download', {
|
||||
id: 'paused',
|
||||
deleteAssets: true,
|
||||
preserveResumable: true
|
||||
});
|
||||
});
|
||||
|
||||
it('starts staged queue items in their persisted queue order', async () => {
|
||||
useDownloadStore.setState({
|
||||
downloads: [
|
||||
@@ -616,7 +773,7 @@ describe('useDownloadStore', () => {
|
||||
expect(state.pendingAddMediaUrls).toEqual([]);
|
||||
});
|
||||
|
||||
it('routes silent extension captures to the Add Modal instead of queuing immediately', async () => {
|
||||
it('routes silent extension captures to the Add Modal instead of queuing immediately', async () => {
|
||||
await useDownloadStore.getState().handleExtensionDownload({
|
||||
urls: ['https://example.com/downloads/report.pdf'],
|
||||
referer: 'https://example.com/page',
|
||||
@@ -637,14 +794,59 @@ describe('useDownloadStore', () => {
|
||||
expect(state.pendingAddMediaUrls).toEqual([]);
|
||||
});
|
||||
|
||||
it('keeps each extension handoff context attached to its own URL while the Add Modal is open', async () => {
|
||||
await useDownloadStore.getState().handleExtensionDownload({
|
||||
urls: ['https://first.example/file.zip'],
|
||||
referer: 'https://first.example/page',
|
||||
silent: true,
|
||||
filename: 'first.zip',
|
||||
headers: 'User-Agent: First Browser',
|
||||
cookies: 'first=session',
|
||||
media: false
|
||||
});
|
||||
await useDownloadStore.getState().handleExtensionDownload({
|
||||
urls: ['https://second.example/file.zip'],
|
||||
referer: 'https://second.example/page',
|
||||
silent: true,
|
||||
filename: 'second.zip',
|
||||
headers: 'User-Agent: Second Browser',
|
||||
cookies: 'second=session',
|
||||
media: false
|
||||
});
|
||||
|
||||
const state = useDownloadStore.getState();
|
||||
expect(state.pendingAddUrls).toBe(
|
||||
'https://first.example/file.zip\nhttps://second.example/file.zip'
|
||||
);
|
||||
expect(state.pendingAddRequestVersion).toBe(2);
|
||||
expect(state.pendingAddRequestContexts).toEqual({
|
||||
'https://first.example/file.zip': {
|
||||
version: 1,
|
||||
referer: 'https://first.example/page',
|
||||
filename: 'first.zip',
|
||||
headers: 'User-Agent: First Browser',
|
||||
cookies: 'first=session',
|
||||
media: false
|
||||
},
|
||||
'https://second.example/file.zip': {
|
||||
version: 2,
|
||||
referer: 'https://second.example/page',
|
||||
filename: 'second.zip',
|
||||
headers: 'User-Agent: Second Browser',
|
||||
cookies: 'second=session',
|
||||
media: false
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it('preserves explicit extension media intent for non-allow-listed pages', async () => {
|
||||
await useDownloadStore.getState().handleExtensionDownload({
|
||||
urls: ['https://adult.example/watch/123'],
|
||||
referer: 'https://adult.example/watch/123',
|
||||
silent: false,
|
||||
filename: null,
|
||||
headers: 'User-Agent: Firefox Test',
|
||||
cookies: 'session=secret',
|
||||
headers: `Cookie: stale=${'x'.repeat(64 * 1024)}\nUser-Agent: Firefox Test`,
|
||||
cookies: `oversized=${'x'.repeat(64 * 1024)}`,
|
||||
media: true
|
||||
});
|
||||
|
||||
@@ -652,6 +854,53 @@ describe('useDownloadStore', () => {
|
||||
expect(state.isAddModalOpen).toBe(true);
|
||||
expect(state.pendingAddUrls).toBe('https://adult.example/watch/123');
|
||||
expect(state.pendingAddMediaUrls).toEqual(['https://adult.example/watch/123']);
|
||||
expect(state.pendingAddCookies).toBe('');
|
||||
expect(state.pendingAddHeaders).toBe('User-Agent: Firefox Test');
|
||||
});
|
||||
|
||||
it('preserves extension cookies for ordinary captured downloads', async () => {
|
||||
await useDownloadStore.getState().handleExtensionDownload({
|
||||
urls: ['https://example.com/private.zip'],
|
||||
referer: 'https://example.com/downloads',
|
||||
silent: true,
|
||||
filename: 'private.zip',
|
||||
headers: null,
|
||||
cookies: 'session=secret',
|
||||
media: false
|
||||
});
|
||||
|
||||
expect(useDownloadStore.getState().pendingAddCookies).toBe('session=secret');
|
||||
});
|
||||
|
||||
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({
|
||||
urls: [url],
|
||||
referer: 'https://example.com/private',
|
||||
silent: true,
|
||||
filename: 'private.zip',
|
||||
headers: 'Authorization: secret',
|
||||
cookies: 'session=secret',
|
||||
media: false
|
||||
});
|
||||
await useDownloadStore.getState().handleExtensionDownload({
|
||||
urls: [url],
|
||||
referer: null,
|
||||
silent: true,
|
||||
filename: null,
|
||||
headers: null,
|
||||
cookies: null,
|
||||
media: false
|
||||
});
|
||||
|
||||
expect(useDownloadStore.getState().pendingAddRequestContexts[url]).toEqual({
|
||||
version: 2,
|
||||
referer: '',
|
||||
filename: '',
|
||||
headers: '',
|
||||
cookies: '',
|
||||
media: false
|
||||
});
|
||||
});
|
||||
|
||||
it('deduplicates forced media URLs and drops stale media intent when opening fresh', async () => {
|
||||
|
||||
+196
-31
@@ -16,10 +16,67 @@ import { canPauseDownload, canStartDownload } from '../utils/downloadActions';
|
||||
export type { DownloadCategory } from '../utils/downloads';
|
||||
|
||||
const backendDispatchPromises = new Map<string, Promise<boolean>>();
|
||||
const downloadLifecycleGenerations = new Map<string, bigint>();
|
||||
|
||||
const advanceDownloadLifecycle = (id: string): bigint => {
|
||||
const nextGeneration = (downloadLifecycleGenerations.get(id) ?? 0n) + 1n;
|
||||
downloadLifecycleGenerations.set(id, nextGeneration);
|
||||
return nextGeneration;
|
||||
};
|
||||
|
||||
const currentDownloadLifecycle = (id: string): bigint =>
|
||||
downloadLifecycleGenerations.get(id) ?? 0n;
|
||||
|
||||
type DispatchInvalidation = {
|
||||
generation: bigint;
|
||||
pendingDispatch?: Promise<boolean>;
|
||||
};
|
||||
|
||||
const invalidateDispatch = async (id: string): Promise<DispatchInvalidation> => {
|
||||
const generation = currentDownloadLifecycle(id);
|
||||
const nextGeneration = advanceDownloadLifecycle(id);
|
||||
try {
|
||||
await invoke('cancel_enqueue_generation', { id, generation: generation.toString() });
|
||||
} catch (error) {
|
||||
console.warn(`Failed to cancel stale backend enqueue for ${id}:`, error);
|
||||
}
|
||||
return { generation: nextGeneration, pendingDispatch: backendDispatchPromises.get(id) };
|
||||
};
|
||||
|
||||
const invalidateAndWaitForDispatch = async (id: string): Promise<boolean> => {
|
||||
const { pendingDispatch } = await invalidateDispatch(id);
|
||||
if (!pendingDispatch) return false;
|
||||
await pendingDispatch;
|
||||
return true;
|
||||
};
|
||||
|
||||
const isCurrentDownloadLifecycle = (id: string, generation: bigint): boolean =>
|
||||
currentDownloadLifecycle(id) === generation &&
|
||||
useDownloadStore.getState().downloads.some(download => download.id === id);
|
||||
|
||||
const removeStaleBackendDispatch = async (id: string): Promise<void> => {
|
||||
try {
|
||||
await invoke('remove_download', { id, deleteAssets: false });
|
||||
} catch (error) {
|
||||
// The original remove request may already have won this race. Either way,
|
||||
// never allow a stale enqueue to make the deleted row live again.
|
||||
console.warn(`Failed to remove stale backend dispatch for ${id}:`, error);
|
||||
}
|
||||
};
|
||||
|
||||
const errorMessage = (error: unknown): string =>
|
||||
error instanceof Error ? error.message : String(error);
|
||||
|
||||
const stripCookieHeaders = (value: string | null | undefined): string =>
|
||||
(value || '')
|
||||
.split(/\r?\n/)
|
||||
.filter(line => {
|
||||
const separator = line.indexOf(':');
|
||||
return separator < 0 || line.slice(0, separator).trim().toLowerCase() !== 'cookie';
|
||||
})
|
||||
.join('\n')
|
||||
.trim();
|
||||
|
||||
const speedLimitForDispatch = (itemSpeedLimit: string | undefined, globalSpeedLimit: string): string | null => {
|
||||
const explicitLimit = itemSpeedLimit?.trim();
|
||||
if (explicitLimit) {
|
||||
@@ -32,15 +89,19 @@ export async function dispatchItem(id: string): Promise<boolean> {
|
||||
if (backendDispatchPromises.has(id)) return backendDispatchPromises.get(id)!;
|
||||
|
||||
const promise = (async () => {
|
||||
let lifecycleGeneration: bigint | null = null;
|
||||
try {
|
||||
const state = useDownloadStore.getState();
|
||||
const item = state.downloads.find(d => d.id === id);
|
||||
if (!item) return false;
|
||||
if (state.backendRegisteredIds.has(id)) return true;
|
||||
lifecycleGeneration = currentDownloadLifecycle(id);
|
||||
|
||||
const settings = useSettingsStore.getState();
|
||||
const destination = item.destination ||
|
||||
await resolveCategoryDestination(settings, item.category);
|
||||
if (!isCurrentDownloadLifecycle(id, lifecycleGeneration)) return false;
|
||||
|
||||
const login = getSiteLogin(item.url, settings);
|
||||
let keychainPassword = null;
|
||||
if (login) {
|
||||
@@ -50,6 +111,10 @@ export async function dispatchItem(id: string): Promise<boolean> {
|
||||
console.warn("Failed to retrieve keychain password for dispatch:", e);
|
||||
}
|
||||
}
|
||||
if (!isCurrentDownloadLifecycle(id, lifecycleGeneration)) return false;
|
||||
|
||||
const proxy = await getProxyArgs(settings);
|
||||
if (!isCurrentDownloadLifecycle(id, lifecycleGeneration)) return false;
|
||||
|
||||
const enqueueItem = {
|
||||
id: item.id,
|
||||
@@ -67,13 +132,19 @@ export async function dispatchItem(id: string): Promise<boolean> {
|
||||
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
|
||||
is_media: item.isMedia || false,
|
||||
lifecycle_generation: lifecycleGeneration.toString(),
|
||||
};
|
||||
|
||||
const accepted = await invoke('enqueue_download', { item: enqueueItem });
|
||||
if (!isCurrentDownloadLifecycle(id, lifecycleGeneration)) {
|
||||
await removeStaleBackendDispatch(id);
|
||||
return false;
|
||||
}
|
||||
|
||||
const acceptedFilename = accepted?.filename || item.fileName;
|
||||
if (acceptedFilename !== item.fileName) {
|
||||
useDownloadStore.getState().updateDownload(id, {
|
||||
@@ -82,16 +153,23 @@ export async function dispatchItem(id: string): Promise<boolean> {
|
||||
});
|
||||
}
|
||||
const order = await invoke('get_pending_order', { queueId: item.queueId || MAIN_QUEUE_ID });
|
||||
if (!isCurrentDownloadLifecycle(id, lifecycleGeneration)) {
|
||||
await removeStaleBackendDispatch(id);
|
||||
return false;
|
||||
}
|
||||
|
||||
useDownloadStore.getState().setPendingOrder(order);
|
||||
useDownloadStore.getState().registerBackendIds([id]);
|
||||
useDownloadStore.getState().updateDownload(id, { lastError: undefined });
|
||||
return true;
|
||||
} catch (e) {
|
||||
console.error(`Failed to dispatch ${id}:`, e);
|
||||
useDownloadStore.getState().updateDownload(id, {
|
||||
status: 'failed',
|
||||
lastError: errorMessage(e)
|
||||
});
|
||||
if (lifecycleGeneration !== null && isCurrentDownloadLifecycle(id, lifecycleGeneration)) {
|
||||
useDownloadStore.getState().updateDownload(id, {
|
||||
status: 'failed',
|
||||
lastError: errorMessage(e)
|
||||
});
|
||||
}
|
||||
return false;
|
||||
} finally {
|
||||
backendDispatchPromises.delete(id);
|
||||
@@ -237,6 +315,14 @@ export type AddDownloadAction =
|
||||
| { type: 'start-now' }
|
||||
| { type: 'add-to-queue'; queueId: string };
|
||||
export type DownloadDraft = Omit<DownloadItem, 'status' | 'queueId' | 'hasBeenDispatched'>;
|
||||
export type PendingAddRequestContext = {
|
||||
version: number;
|
||||
referer: string;
|
||||
filename: string;
|
||||
headers: string;
|
||||
cookies: string;
|
||||
media: boolean;
|
||||
};
|
||||
|
||||
export type DeleteModalState = {
|
||||
isOpen: boolean;
|
||||
@@ -261,6 +347,8 @@ interface DownloadState {
|
||||
pendingAddHeaders: string;
|
||||
pendingAddCookies: string;
|
||||
pendingAddMediaUrls: string[];
|
||||
pendingAddRequestContexts: Record<string, PendingAddRequestContext>;
|
||||
pendingAddRequestVersion: number;
|
||||
selectedPropertiesDownloadId: string | null;
|
||||
toggleAddModal: (isOpen: boolean) => void;
|
||||
openAddModalWithUrls: (
|
||||
@@ -278,7 +366,8 @@ interface DownloadState {
|
||||
setSelectedPropertiesDownloadId: (id: string | null) => void;
|
||||
addDownload: (item: DownloadDraft, action: AddDownloadAction) => Promise<boolean>;
|
||||
updateDownload: (id: string, updates: Partial<DownloadItem>) => void;
|
||||
removeDownload: (id: string, deleteFile?: boolean) => Promise<void>;
|
||||
removeDownload: (id: string, deleteFile?: boolean, preserveResumable?: boolean) => Promise<void>;
|
||||
pauseDownload: (id: string) => Promise<void>;
|
||||
redownload: (id: string) => Promise<void>;
|
||||
resumeDownload: (id: string) => Promise<boolean>;
|
||||
startQueue: (queueId: string) => Promise<string[]>;
|
||||
@@ -394,6 +483,8 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
|
||||
pendingAddHeaders: '',
|
||||
pendingAddCookies: '',
|
||||
pendingAddMediaUrls: [],
|
||||
pendingAddRequestContexts: {},
|
||||
pendingAddRequestVersion: 0,
|
||||
selectedPropertiesDownloadId: null,
|
||||
deleteModalState: { isOpen: false },
|
||||
openDeleteModal: (downloadIds) => set({
|
||||
@@ -403,45 +494,90 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
|
||||
}
|
||||
}),
|
||||
closeDeleteModal: () => set({ deleteModalState: { isOpen: false } }),
|
||||
toggleAddModal: (isOpen) => set({
|
||||
toggleAddModal: (isOpen) => set((state) => ({
|
||||
isAddModalOpen: isOpen,
|
||||
pendingAddUrls: '',
|
||||
pendingAddReferer: '',
|
||||
pendingAddFilename: '',
|
||||
pendingAddHeaders: '',
|
||||
pendingAddCookies: '',
|
||||
pendingAddMediaUrls: []
|
||||
}),
|
||||
pendingAddMediaUrls: [],
|
||||
pendingAddRequestContexts: {},
|
||||
// Invalidate any in-flight Add-modal handoff even when the modal is
|
||||
// opened or closed without URLs.
|
||||
pendingAddRequestVersion: state.pendingAddRequestVersion + 1
|
||||
})),
|
||||
openAddModalWithUrls: (urls, referer, filename, headers, cookies, media = false) => set((state) => {
|
||||
const existingUrls = state.isAddModalOpen && state.pendingAddUrls ? state.pendingAddUrls : '';
|
||||
const isAppending = state.isAddModalOpen && Boolean(state.pendingAddUrls);
|
||||
const existingUrls = isAppending ? state.pendingAddUrls : '';
|
||||
const mergedUrls = existingUrls ? `${existingUrls}\n${urls}` : urls;
|
||||
const existingMediaUrls = state.isAddModalOpen ? state.pendingAddMediaUrls : [];
|
||||
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 requestVersion = state.pendingAddRequestVersion + 1;
|
||||
const pendingAddRequestContexts = isAppending
|
||||
? { ...state.pendingAddRequestContexts }
|
||||
: {};
|
||||
// Every handoff gets a versioned row context, including an intentionally
|
||||
// empty one. Otherwise a later request for the same URL cannot clear stale
|
||||
// cookies/headers from an earlier capture, and batched React renders can
|
||||
// lose all but the most recent appended URL.
|
||||
for (const rawUrl of urls.split('\n')) {
|
||||
const trimmedUrl = rawUrl.trim();
|
||||
if (!trimmedUrl) continue;
|
||||
let key = trimmedUrl;
|
||||
try {
|
||||
key = new URL(trimmedUrl).href;
|
||||
} catch {
|
||||
// The Add modal will mark malformed input invalid; retain its original key here.
|
||||
}
|
||||
pendingAddRequestContexts[key] = {
|
||||
version: requestVersion,
|
||||
referer: cleanReferer,
|
||||
filename: cleanFilename,
|
||||
headers: cleanHeaders,
|
||||
cookies: cleanCookies,
|
||||
media
|
||||
};
|
||||
}
|
||||
return {
|
||||
isAddModalOpen: true,
|
||||
pendingAddUrls: mergedUrls,
|
||||
pendingAddReferer: referer?.trim() || '',
|
||||
pendingAddFilename: filename?.trim() || '',
|
||||
pendingAddHeaders: headers?.trim() || '',
|
||||
pendingAddCookies: cookies?.trim() || '',
|
||||
pendingAddMediaUrls
|
||||
pendingAddReferer: cleanReferer,
|
||||
pendingAddFilename: cleanFilename,
|
||||
pendingAddHeaders: cleanHeaders,
|
||||
pendingAddCookies: cleanCookies,
|
||||
pendingAddMediaUrls,
|
||||
pendingAddRequestContexts,
|
||||
pendingAddRequestVersion: requestVersion
|
||||
};
|
||||
}),
|
||||
handleExtensionDownload: async (request) => {
|
||||
const urls = [...new Set(request.urls.map(url => url.trim()).filter(Boolean))];
|
||||
if (urls.length === 0) return;
|
||||
|
||||
// Explicit media authentication belongs to yt-dlp's configured browser
|
||||
// cookie source. Keep this frontend guard for events from older desktop or
|
||||
// extension builds; ordinary captured downloads retain their cookies.
|
||||
const cookies = request.media === true ? null : request.cookies;
|
||||
const headers = request.media === true
|
||||
? stripCookieHeaders(request.headers) || null
|
||||
: request.headers;
|
||||
|
||||
get().openAddModalWithUrls(
|
||||
urls.join('\n'),
|
||||
request.referer,
|
||||
urls.length === 1 ? request.filename : null,
|
||||
request.headers,
|
||||
request.cookies,
|
||||
headers,
|
||||
cookies,
|
||||
request.media === true
|
||||
);
|
||||
},
|
||||
@@ -463,6 +599,7 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
|
||||
queuePosition,
|
||||
hasBeenDispatched: false
|
||||
};
|
||||
advanceDownloadLifecycle(item.id);
|
||||
set((state) => ({ downloads: [...state.downloads, ownedItem] }));
|
||||
|
||||
if (action.type === 'add-to-queue') {
|
||||
@@ -479,6 +616,7 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
|
||||
return false;
|
||||
},
|
||||
applyProperties: async (id, updates) => {
|
||||
const wasDispatching = await invalidateAndWaitForDispatch(id);
|
||||
const state = get();
|
||||
const item = state.downloads.find(d => d.id === id);
|
||||
if (!item) return;
|
||||
@@ -501,8 +639,11 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
|
||||
state.unregisterBackendIds([id]);
|
||||
}
|
||||
state.updateDownload(id, updates);
|
||||
if (isRegistered) {
|
||||
if (!await dispatchItem(id)) {
|
||||
if (isRegistered || wasDispatching) {
|
||||
const dispatched = await dispatchItem(id);
|
||||
if (dispatched) {
|
||||
state.updateDownload(id, { hasBeenDispatched: true });
|
||||
} else {
|
||||
state.removeFromQueue(id);
|
||||
}
|
||||
}
|
||||
@@ -542,11 +683,16 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
|
||||
syncSystemIntegrations();
|
||||
}
|
||||
},
|
||||
removeDownload: async (id, deleteFile = false) => {
|
||||
removeDownload: async (id, deleteFile = false, preserveResumable = false) => {
|
||||
await invalidateDispatch(id);
|
||||
const item = get().downloads.find(d => d.id === id);
|
||||
|
||||
if (item) {
|
||||
await invoke('remove_download', { id, deleteAssets: deleteFile });
|
||||
await invoke('remove_download', {
|
||||
id,
|
||||
deleteAssets: deleteFile,
|
||||
preserveResumable
|
||||
});
|
||||
}
|
||||
|
||||
set((state) => ({
|
||||
@@ -559,6 +705,20 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
|
||||
info(`Download ${id} removed`);
|
||||
syncSystemIntegrations();
|
||||
},
|
||||
pauseDownload: async (id) => {
|
||||
const { generation, pendingDispatch } = await invalidateDispatch(id);
|
||||
if (pendingDispatch) {
|
||||
await pendingDispatch;
|
||||
}
|
||||
|
||||
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: '-' });
|
||||
}
|
||||
},
|
||||
redownload: async (id) => {
|
||||
const targetItem = get().downloads.find(d => d.id === id);
|
||||
if (!targetItem) {
|
||||
@@ -572,6 +732,8 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
|
||||
const url = targetItem.url?.trim();
|
||||
if (!url) throw new Error('Cannot redownload: original URL is missing.');
|
||||
|
||||
await invalidateAndWaitForDispatch(id);
|
||||
|
||||
// Remove from backend to clear its state and delete the existing file so we can overwrite
|
||||
try {
|
||||
await invoke('remove_download', { id, deleteAssets: true });
|
||||
@@ -699,9 +861,7 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
|
||||
|
||||
if (activeIds.length === 0) return 0;
|
||||
|
||||
const results = await Promise.allSettled(
|
||||
activeIds.map(id => invoke('pause_download', { id }))
|
||||
);
|
||||
const results = await Promise.allSettled(activeIds.map(id => get().pauseDownload(id)));
|
||||
const pausedCount = results.filter(result => result.status === 'fulfilled').length;
|
||||
const failedCount = activeIds.length - pausedCount;
|
||||
if (failedCount > 0) {
|
||||
@@ -731,9 +891,7 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
|
||||
.map(item => item.id);
|
||||
if (activeIds.length === 0) return 0;
|
||||
|
||||
const results = await Promise.allSettled(
|
||||
activeIds.map(id => invoke('pause_download', { id }))
|
||||
);
|
||||
const results = await Promise.allSettled(activeIds.map(id => get().pauseDownload(id)));
|
||||
const pausedCount = results.filter(result => result.status === 'fulfilled').length;
|
||||
syncSystemIntegrations();
|
||||
return pausedCount;
|
||||
@@ -746,7 +904,13 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
|
||||
throw new Error(`Pause ${locked.fileName} before moving it to another queue.`);
|
||||
}
|
||||
|
||||
for (const item of selected) {
|
||||
await Promise.all(
|
||||
selected
|
||||
.filter(item => item.status !== 'completed')
|
||||
.map(item => invalidateAndWaitForDispatch(item.id))
|
||||
);
|
||||
|
||||
for (const item of get().downloads.filter(item => selectedIds.has(item.id))) {
|
||||
if (!get().backendRegisteredIds.has(item.id)) continue;
|
||||
if (item.status === 'queued') {
|
||||
await invoke('remove_from_queue', { id: item.id });
|
||||
@@ -880,7 +1044,8 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
|
||||
proxy: await getProxyArgs(settings),
|
||||
format_selector: item.mediaFormatSelector || null,
|
||||
cookie_source: settings.mediaCookieSource !== 'none' ? settings.mediaCookieSource : null,
|
||||
is_media: item.isMedia || false
|
||||
is_media: item.isMedia || false,
|
||||
lifecycle_generation: currentDownloadLifecycle(item.id).toString(),
|
||||
});
|
||||
}
|
||||
const results = await invoke('enqueue_many', { items: itemsToEnqueue });
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
appendRequestUrlsAfterVersion,
|
||||
canSubmitMetadataRows,
|
||||
mediaFormatSelectorForRow,
|
||||
mediaFileNameForSelectedFormat,
|
||||
@@ -71,6 +72,77 @@ describe('add download metadata workflow', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps extension-provided filenames scoped to their individual URLs', () => {
|
||||
const rows = reconcileDownloadRows(
|
||||
'https://first.example/download\nhttps://second.example/download',
|
||||
[],
|
||||
undefined,
|
||||
new Set(),
|
||||
() => crypto.randomUUID(),
|
||||
{
|
||||
'https://first.example/download': 'first.zip',
|
||||
'https://second.example/download': 'second.zip'
|
||||
}
|
||||
);
|
||||
|
||||
expect(rows.map(item => item.file)).toEqual(['first.zip', 'second.zip']);
|
||||
});
|
||||
|
||||
it('refreshes an existing row when a newer extension handoff changes its request context', () => {
|
||||
const existing = row({
|
||||
isMedia: true,
|
||||
status: 'ready',
|
||||
generation: 4,
|
||||
requestContextVersion: 1,
|
||||
formats: [{
|
||||
name: '1080p MP4',
|
||||
selector: '137+140',
|
||||
ext: 'mp4',
|
||||
formatLabel: 'MP4',
|
||||
detail: '10 MB',
|
||||
type: 'Video',
|
||||
bytes: 10
|
||||
}],
|
||||
selectedFormat: 0
|
||||
});
|
||||
|
||||
const refreshed = reconcileDownloadRows(
|
||||
existing.sourceUrl,
|
||||
[existing],
|
||||
undefined,
|
||||
new Set(),
|
||||
() => 'unused',
|
||||
{},
|
||||
{ [existing.sourceUrl]: 2 }
|
||||
);
|
||||
|
||||
expect(refreshed[0]).toMatchObject({
|
||||
status: 'loading',
|
||||
generation: 5,
|
||||
requestContextVersion: 2,
|
||||
formats: undefined,
|
||||
selectedFormat: undefined
|
||||
});
|
||||
});
|
||||
|
||||
it('appends every unseen handoff after the observed version', () => {
|
||||
const merged = appendRequestUrlsAfterVersion(
|
||||
'https://existing.example/file.zip',
|
||||
{
|
||||
'https://first.example/file.zip': { version: 2 },
|
||||
'https://second.example/file.zip': { version: 3 },
|
||||
'https://existing.example/file.zip': { version: 4 }
|
||||
},
|
||||
1
|
||||
);
|
||||
|
||||
expect(merged).toBe(
|
||||
'https://existing.example/file.zip\n' +
|
||||
'https://first.example/file.zip\n' +
|
||||
'https://second.example/file.zip'
|
||||
);
|
||||
});
|
||||
|
||||
it('upgrades an existing normal row when the user explicitly fetches it as media', () => {
|
||||
const existing = row({
|
||||
sourceUrl: 'https://adult.example/watch/123',
|
||||
@@ -134,11 +206,15 @@ describe('add download metadata workflow', () => {
|
||||
expect(updated[0]).toBe(current);
|
||||
});
|
||||
|
||||
it('allows ready and failed rows but blocks loading and invalid rows', () => {
|
||||
it('allows normal-download fallback but blocks unresolved explicit media', () => {
|
||||
expect(canSubmitMetadataRows([
|
||||
row(),
|
||||
row({ id: 'fallback', status: 'metadata-error' })
|
||||
])).toBe(true);
|
||||
expect(canSubmitMetadataRows([
|
||||
row(),
|
||||
row({ id: 'media-fallback', status: 'metadata-error', isMedia: true })
|
||||
])).toBe(false);
|
||||
expect(canSubmitMetadataRows([row({ status: 'loading' })])).toBe(false);
|
||||
expect(canSubmitMetadataRows([row({ status: 'invalid' })])).toBe(false);
|
||||
});
|
||||
@@ -193,6 +269,9 @@ describe('add download metadata workflow', () => {
|
||||
expect(metadataSummaryMessage([
|
||||
row({ status: 'metadata-error' })
|
||||
])).toContain('can still be added');
|
||||
expect(metadataSummaryMessage([
|
||||
row({ status: 'metadata-error', isMedia: true })
|
||||
])).toContain('Refresh metadata before adding');
|
||||
expect(metadataSummaryMessage([
|
||||
row({ status: 'invalid' })
|
||||
])).toContain('Correct or remove 1 invalid URL');
|
||||
|
||||
@@ -26,6 +26,7 @@ export interface AddDownloadDraftRow {
|
||||
sizeBytes?: number;
|
||||
status: MetadataStatus;
|
||||
generation: number;
|
||||
requestContextVersion?: number;
|
||||
isMedia: boolean;
|
||||
resumable?: boolean;
|
||||
formats?: AddMediaFormat[];
|
||||
@@ -72,7 +73,9 @@ export const reconcileDownloadRows = (
|
||||
currentRows: AddDownloadDraftRow[],
|
||||
pendingFilename?: string,
|
||||
forceMediaUrls: ReadonlySet<string> = new Set(),
|
||||
createId: () => string = () => crypto.randomUUID()
|
||||
createId: () => string = () => crypto.randomUUID(),
|
||||
requestFilenames: Readonly<Record<string, string>> = {},
|
||||
requestContextVersions: Readonly<Record<string, number>> = {}
|
||||
): AddDownloadDraftRow[] => {
|
||||
const inputs = parseInputLines(rawText);
|
||||
const existing = new Map(currentRows.map(row => [row.sourceUrl, row]));
|
||||
@@ -80,23 +83,28 @@ export const reconcileDownloadRows = (
|
||||
return inputs.map(input => {
|
||||
const preserved = existing.get(input.sourceUrl);
|
||||
if (preserved) {
|
||||
if (input.valid && forceMediaUrls.has(input.sourceUrl) && !preserved.isMedia) {
|
||||
const forcedMedia = input.valid && forceMediaUrls.has(input.sourceUrl);
|
||||
const requestContextVersion = requestContextVersions[input.sourceUrl];
|
||||
const contextChanged = requestContextVersion !== undefined
|
||||
&& requestContextVersion !== preserved.requestContextVersion;
|
||||
if ((forcedMedia && !preserved.isMedia) || contextChanged) {
|
||||
return {
|
||||
...preserved,
|
||||
status: 'loading',
|
||||
generation: preserved.generation + 1,
|
||||
isMedia: true,
|
||||
formats: undefined,
|
||||
selectedFormat: undefined
|
||||
requestContextVersion,
|
||||
isMedia: preserved.isMedia || forcedMedia,
|
||||
formats: preserved.isMedia || forcedMedia ? undefined : preserved.formats,
|
||||
selectedFormat: preserved.isMedia || forcedMedia ? undefined : preserved.selectedFormat
|
||||
};
|
||||
}
|
||||
return preserved;
|
||||
}
|
||||
|
||||
const requestedFilename = requestFilenames[input.sourceUrl]
|
||||
|| (inputs.length === 1 ? pendingFilename : undefined);
|
||||
const fallback = canonicalizeDownloadFileName(
|
||||
inputs.length === 1 && pendingFilename
|
||||
? pendingFilename
|
||||
: fileNameFromUrl(input.sourceUrl)
|
||||
requestedFilename || fileNameFromUrl(input.sourceUrl)
|
||||
);
|
||||
|
||||
return {
|
||||
@@ -106,11 +114,41 @@ 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))
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const comparableUrl = (rawUrl: string): string => {
|
||||
try {
|
||||
return new URL(rawUrl).href;
|
||||
} catch {
|
||||
return rawUrl.trim();
|
||||
}
|
||||
};
|
||||
|
||||
export const appendRequestUrlsAfterVersion = (
|
||||
rawText: string,
|
||||
requestContexts: Readonly<Record<string, { version: number }>>,
|
||||
observedVersion: number
|
||||
): string => {
|
||||
const lines = rawText.split('\n').map(line => line.trim()).filter(Boolean);
|
||||
const seen = new Set(lines.map(comparableUrl));
|
||||
const additions = Object.entries(requestContexts)
|
||||
.filter(([, context]) => context.version > observedVersion)
|
||||
.sort(([, left], [, right]) => left.version - right.version);
|
||||
|
||||
for (const [url] of additions) {
|
||||
const identity = comparableUrl(url);
|
||||
if (seen.has(identity)) continue;
|
||||
seen.add(identity);
|
||||
lines.push(url);
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
};
|
||||
|
||||
export const updateRowIfCurrent = (
|
||||
rows: AddDownloadDraftRow[],
|
||||
id: string,
|
||||
@@ -137,7 +175,10 @@ export const refreshFailedMetadataRows = (
|
||||
|
||||
export const canSubmitMetadataRows = (rows: AddDownloadDraftRow[]): boolean =>
|
||||
rows.length > 0
|
||||
&& rows.every(row => row.status === 'ready' || row.status === 'metadata-error');
|
||||
&& rows.every(row =>
|
||||
row.status === 'ready'
|
||||
|| (!row.isMedia && row.status === 'metadata-error')
|
||||
);
|
||||
|
||||
export const mediaFormatSelectorForRow = (
|
||||
row: AddDownloadDraftRow
|
||||
@@ -194,7 +235,11 @@ export const metadataSummaryMessage = (rows: AddDownloadDraftRow[]): string => {
|
||||
}
|
||||
|
||||
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;
|
||||
if (failedMedia > 0) {
|
||||
return `Media metadata is unavailable for ${failedMedia} item${failedMedia === 1 ? '' : 's'}. Refresh metadata before adding.`;
|
||||
}
|
||||
if (failed === rows.length) {
|
||||
return 'Metadata is unavailable. Downloads can still be added using fallback details.';
|
||||
}
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { readClipboardDownloadUrls } from './clipboard';
|
||||
import { readText } from '@tauri-apps/plugin-clipboard-manager';
|
||||
|
||||
vi.mock('@tauri-apps/plugin-clipboard-manager', () => ({
|
||||
readText: vi.fn(),
|
||||
}));
|
||||
|
||||
describe('clipboard URL extraction', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('reads only supported, unique download URLs from clipboard text', async () => {
|
||||
vi.mocked(readText).mockResolvedValue(
|
||||
'https://example.com/file.zip\nhttps://example.com/file.zip ftp://example.com/file.bin sftp://example.com/file.iso mailto:user@example.com'
|
||||
);
|
||||
|
||||
await expect(readClipboardDownloadUrls()).resolves.toEqual([
|
||||
'https://example.com/file.zip',
|
||||
'ftp://example.com/file.bin',
|
||||
'sftp://example.com/file.iso',
|
||||
]);
|
||||
});
|
||||
|
||||
it('preserves clipboard read failures for the caller to handle', async () => {
|
||||
const error = new Error('clipboard unavailable');
|
||||
vi.mocked(readText).mockRejectedValue(error);
|
||||
|
||||
await expect(readClipboardDownloadUrls()).rejects.toBe(error);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,7 @@
|
||||
import { readText } from '@tauri-apps/plugin-clipboard-manager';
|
||||
import { extractValidDownloadUrls } from './url';
|
||||
|
||||
export const readClipboardDownloadUrls = async (): Promise<string[]> => {
|
||||
const clipboardText = await readText();
|
||||
return extractValidDownloadUrls(clipboardText);
|
||||
};
|
||||
@@ -0,0 +1,55 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
appendBoundedLogEntries,
|
||||
liveLogEntry,
|
||||
mergeLogSnapshotAndLiveEntries,
|
||||
persistedLogEntry,
|
||||
pushBoundedLogEntry,
|
||||
type LogEntry
|
||||
} from './logEntries';
|
||||
|
||||
const entry = (message: string): LogEntry => ({ level: 'Info', message });
|
||||
|
||||
describe('log entry streaming', () => {
|
||||
it('derives levels from persisted formatted lines', () => {
|
||||
expect(persistedLogEntry('[2026-07-10][18:00:00][ERROR][firelink] failed')).toEqual({
|
||||
level: 'Error',
|
||||
message: '[2026-07-10][18:00:00][ERROR][firelink] failed'
|
||||
});
|
||||
});
|
||||
|
||||
it('does not double-format backend Webview log lines', () => {
|
||||
const message = '[2026-07-10][18:00:00][WARN][firelink] retrying';
|
||||
expect(liveLogEntry(4, message).message).toBe(message);
|
||||
});
|
||||
|
||||
it('formats an unformatted plugin event with its numeric level', () => {
|
||||
expect(liveLogEntry(3, 'download started', new Date('2026-07-10T14:30:00Z'))).toEqual({
|
||||
level: 'Info',
|
||||
message: '[2026-07-10 14:30:00] [INFO] download started'
|
||||
});
|
||||
});
|
||||
|
||||
it('merges only the ordered snapshot-to-stream overlap', () => {
|
||||
expect(mergeLogSnapshotAndLiveEntries(
|
||||
[entry('one'), entry('repeat'), entry('three')],
|
||||
[entry('three'), entry('repeat'), entry('four')]
|
||||
).map(item => item.message)).toEqual(['one', 'repeat', 'three', 'repeat', 'four']);
|
||||
});
|
||||
|
||||
it('bounds burst updates to the newest entries', () => {
|
||||
expect(appendBoundedLogEntries(
|
||||
[entry('old')],
|
||||
Array.from({ length: 5 }, (_, index) => entry(`new-${index}`)),
|
||||
3
|
||||
).map(item => item.message)).toEqual(['new-2', 'new-3', 'new-4']);
|
||||
});
|
||||
|
||||
it('bounds the mutable pre-render queue without copying on every event', () => {
|
||||
const queue = [entry('old')];
|
||||
for (let index = 0; index < 5; index += 1) {
|
||||
pushBoundedLogEntry(queue, entry(`new-${index}`), 3);
|
||||
}
|
||||
expect(queue.map(item => item.message)).toEqual(['new-2', 'new-3', 'new-4']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,92 @@
|
||||
export type LogLevel = 'Trace' | 'Debug' | 'Info' | 'Warn' | 'Error';
|
||||
|
||||
export interface LogEntry {
|
||||
level: LogLevel;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export const MAX_LOG_LINES = 2000;
|
||||
|
||||
const LEVEL_NAMES: LogLevel[] = ['Trace', 'Debug', 'Info', 'Warn', 'Error'];
|
||||
const LIVE_LEVELS: Record<number, LogLevel> = {
|
||||
1: 'Trace',
|
||||
2: 'Debug',
|
||||
3: 'Info',
|
||||
4: 'Warn',
|
||||
5: 'Error'
|
||||
};
|
||||
|
||||
const levelFromMessage = (message: string): LogLevel | undefined =>
|
||||
LEVEL_NAMES.find(level => message.includes(`[${level.toUpperCase()}]`));
|
||||
|
||||
export const persistedLogEntry = (message: string): LogEntry => ({
|
||||
level: levelFromMessage(message) || 'Debug',
|
||||
message
|
||||
});
|
||||
|
||||
export const liveLogEntry = (
|
||||
numericLevel: number,
|
||||
message: string,
|
||||
now: Date = new Date()
|
||||
): 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);
|
||||
|
||||
return {
|
||||
level,
|
||||
message: alreadyFormatted
|
||||
? message
|
||||
: `[${now.toISOString().replace('T', ' ').substring(0, 19)}] [${level.toUpperCase()}] ${message}`
|
||||
};
|
||||
};
|
||||
|
||||
export const appendBoundedLogEntries = (
|
||||
current: LogEntry[],
|
||||
additions: LogEntry[],
|
||||
limit = MAX_LOG_LINES
|
||||
): LogEntry[] => {
|
||||
if (additions.length === 0) return current;
|
||||
const combined = [...current, ...additions];
|
||||
return combined.length > limit ? combined.slice(combined.length - limit) : combined;
|
||||
};
|
||||
|
||||
export const pushBoundedLogEntry = (
|
||||
queue: LogEntry[],
|
||||
entry: LogEntry,
|
||||
limit = MAX_LOG_LINES
|
||||
): void => {
|
||||
queue.push(entry);
|
||||
if (queue.length > limit) {
|
||||
queue.splice(0, queue.length - limit);
|
||||
}
|
||||
};
|
||||
|
||||
// The live target writes after the file target, so entries received while the
|
||||
// initial disk snapshot is loading can also appear at the snapshot's tail.
|
||||
// Remove only the exact ordered overlap; global message de-duplication would
|
||||
// incorrectly hide legitimate repeated log lines.
|
||||
export const mergeLogSnapshotAndLiveEntries = (
|
||||
snapshot: LogEntry[],
|
||||
liveEntries: LogEntry[],
|
||||
limit = MAX_LOG_LINES
|
||||
): LogEntry[] => {
|
||||
const maxOverlap = Math.min(snapshot.length, liveEntries.length);
|
||||
let overlap = 0;
|
||||
|
||||
for (let candidate = maxOverlap; candidate > 0; candidate -= 1) {
|
||||
const snapshotStart = snapshot.length - candidate;
|
||||
let matches = true;
|
||||
for (let index = 0; index < candidate; index += 1) {
|
||||
if (snapshot[snapshotStart + index].message !== liveEntries[index].message) {
|
||||
matches = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (matches) {
|
||||
overlap = candidate;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return appendBoundedLogEntries(snapshot, liveEntries.slice(overlap), limit);
|
||||
};
|
||||
+1
-1
@@ -11,7 +11,7 @@ export function extractValidDownloadUrls(text: string): string[] {
|
||||
for (const part of parts) {
|
||||
try {
|
||||
const url = new URL(part);
|
||||
if (url.protocol === 'http:' || url.protocol === 'https:' || url.protocol === 'ftp:') {
|
||||
if (url.protocol === 'http:' || url.protocol === 'https:' || url.protocol === 'ftp:' || url.protocol === 'sftp:') {
|
||||
urls.push(url.toString());
|
||||
}
|
||||
} catch (e) {
|
||||
|
||||
Reference in New Issue
Block a user