mirror of
https://github.com/nimbold/Firelink.git
synced 2026-07-26 20:18:37 +00:00
Compare commits
57 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 61eb034db1 | |||
| c949cbb9ee | |||
| 566396e629 | |||
| db39cd2153 | |||
| f4b830414d | |||
| 0447d1cab7 | |||
| 513143d745 | |||
| a8dc4fb447 | |||
| 50c3da2f5d | |||
| 6ef911919d | |||
| 5144ecd39e | |||
| a56b859151 | |||
| 469faed7b9 | |||
| 79ce0c18a1 | |||
| d195a132b3 | |||
| feb5d8e87d | |||
| a136fa832c | |||
| f3d0e0be13 | |||
| edeef0ac54 | |||
| 9f333618fc | |||
| ed54a048ab | |||
| d2479f52be | |||
| 6e6ae51395 | |||
| 1d197432b2 | |||
| 9917f29743 | |||
| df3bc359c4 | |||
| 57f132f53e | |||
| 52b00e5cb4 | |||
| 8e02a61c3f | |||
| 82f2914077 | |||
| 9224117d77 | |||
| 3c84b5c1a4 | |||
| c34c489aef | |||
| b81e8391e1 | |||
| 6ff0047d6c | |||
| 4a3fece22b | |||
| 1da0fa7223 | |||
| d6af4ee2b5 | |||
| 2479ead4ed | |||
| 80a29356e0 | |||
| 45bbca0515 | |||
| e35b1af731 | |||
| 19953a210e | |||
| 76850f2433 | |||
| 0eee47b97f | |||
| e8487ee71b | |||
| e07182fbf2 | |||
| 2d9eed99d5 | |||
| 80cf835060 | |||
| c78a72a8d7 | |||
| df85a77987 | |||
| be2a98fbcd | |||
| fcfdffa6e0 | |||
| 9805c9288a | |||
| dad5b7bc5e | |||
| f441c687f0 | |||
| a0f44b79ad |
@@ -125,6 +125,52 @@ jobs:
|
||||
Remove-Item -Recurse -Force $extractRoot -ErrorAction SilentlyContinue
|
||||
7z x $installer.FullName "-o$extractRoot" -y
|
||||
node scripts/verify-binaries.js --search-root "$extractRoot" --target ${{ matrix.target }}
|
||||
|
||||
$portableRoot = "$env:RUNNER_TEMP/firelink-portable-payload"
|
||||
$portableArtifactDir = "$env:RUNNER_TEMP/firelink-portable"
|
||||
Remove-Item -Recurse -Force $portableRoot -ErrorAction SilentlyContinue
|
||||
Remove-Item -Recurse -Force $portableArtifactDir -ErrorAction SilentlyContinue
|
||||
New-Item -ItemType Directory -Path $portableRoot -Force | Out-Null
|
||||
New-Item -ItemType Directory -Path $portableArtifactDir -Force | Out-Null
|
||||
$payloadExe = Get-ChildItem $extractRoot -Recurse -File -ErrorAction SilentlyContinue |
|
||||
Where-Object { $_.Name -ieq "firelink.exe" } |
|
||||
Sort-Object FullName |
|
||||
Select-Object -First 1
|
||||
if (-not $payloadExe) { throw "firelink.exe was not found in the extracted installer payload." }
|
||||
Copy-Item (Join-Path $payloadExe.Directory.FullName '*') $portableRoot -Recurse -Force
|
||||
Set-Content -Path (Join-Path $portableRoot 'portable.flag') -Value 'portable' -NoNewline
|
||||
@"
|
||||
Firelink portable
|
||||
|
||||
Extract this folder to a writable location and launch firelink.exe.
|
||||
Close Firelink before copying or moving this folder.
|
||||
Settings, queues, logs, and WebView data are stored under data\.
|
||||
Only one Firelink instance can run at a time; close the installed app before launching this copy.
|
||||
Credentials, browser cookies, and URL query/fragment data are not persisted in portable queue records.
|
||||
Saved site passwords remain in the Windows credential store and are not portable.
|
||||
The portable folder contains the extension pairing credential; treat it as sensitive and do not share it.
|
||||
Saved absolute download locations may need to be selected again after moving to another drive.
|
||||
The portable archive does not register the firelink:// protocol; use the installer for browser launch integration.
|
||||
"@ | Set-Content -Path (Join-Path $portableRoot 'PORTABLE_README.txt')
|
||||
New-Item -ItemType Directory -Path (Join-Path $portableRoot 'data') -Force | Out-Null
|
||||
$portableExe = Join-Path $portableRoot 'firelink.exe'
|
||||
node scripts/verify-binaries.js --search-root "$portableRoot" --target ${{ matrix.target }}
|
||||
node scripts/smoke-packaged-app.js --executable $portableExe --assert-no-visible-child-windows --assert-portable-data
|
||||
Get-ChildItem $portableRoot -Recurse -File -ErrorAction SilentlyContinue |
|
||||
Where-Object { $_.Name -match '^(unins|Uninstall).*\.exe$' } |
|
||||
Remove-Item -Force -ErrorAction SilentlyContinue
|
||||
$portableDataDir = Join-Path $portableRoot 'data'
|
||||
for ($attempt = 1; $attempt -le 10; $attempt++) {
|
||||
Remove-Item -Recurse -Force $portableDataDir -ErrorAction SilentlyContinue
|
||||
if (-not (Test-Path $portableDataDir)) { break }
|
||||
Start-Sleep -Milliseconds (200 * $attempt)
|
||||
}
|
||||
if (Test-Path $portableDataDir) {
|
||||
throw "Portable test data could not be removed after smoke; refusing to package a ZIP containing runtime data."
|
||||
}
|
||||
$portableZip = "$portableArtifactDir/Firelink_${{ github.ref_name }}_Windows-x64-portable.zip"
|
||||
7z a -tzip $portableZip "$portableRoot\*" -y
|
||||
|
||||
$installRoot = "$env:RUNNER_TEMP\FirelinkSmoke"
|
||||
Remove-Item -Recurse -Force $installRoot -ErrorAction SilentlyContinue
|
||||
$install = Start-Process -FilePath $installer.FullName -ArgumentList @("/S", "/D=$installRoot") -Wait -PassThru
|
||||
@@ -151,6 +197,13 @@ jobs:
|
||||
path: ${{ matrix.artifact }}
|
||||
if-no-files-found: error
|
||||
retention-days: 3
|
||||
- uses: actions/upload-artifact@v7
|
||||
if: runner.os == 'Windows'
|
||||
with:
|
||||
name: Firelink-Windows-x64-portable-${{ github.ref_name }}
|
||||
path: ${{ runner.temp }}/firelink-portable/*.zip
|
||||
if-no-files-found: error
|
||||
retention-days: 3
|
||||
- uses: actions/upload-artifact@v7
|
||||
if: runner.os == 'Linux'
|
||||
with:
|
||||
@@ -209,6 +262,7 @@ jobs:
|
||||
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"
|
||||
rename_asset '*.zip' "Firelink_${VERSION}_Windows-x64-portable.zip"
|
||||
- name: Generate checksums
|
||||
run: |
|
||||
cd release-assets
|
||||
|
||||
@@ -5,6 +5,47 @@ All notable changes to Firelink will be documented in this file.
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [1.1.1] - 2026-07-17
|
||||
|
||||
This patch release focuses on transfer reliability, browser captures, and easier download control.
|
||||
|
||||
### New
|
||||
- Add YouTube playlist downloads with smoother queueing and scrolling for large playlists.
|
||||
- Add live per-download connection controls, clipboard capture for the Add window, and byte-level progress updates.
|
||||
|
||||
### Improved
|
||||
- Recover slow or stalled transfers more reliably and apply connection defaults consistently, addressing [#19](https://github.com/nimbold/Firelink/issues/19) and [#20](https://github.com/nimbold/Firelink/issues/20).
|
||||
- Make pause, resume, retry, cancel, remove, and completion handling more consistent when actions or background events overlap.
|
||||
- Improve playlist state, media size estimates, and large-list performance.
|
||||
- Keep clipboard, browser, and deep-link handoffs behind the startup consent explanation, and make that boundary clearer.
|
||||
- Refresh bundled engines and dependencies while strengthening cross-platform package and diagnostic checks.
|
||||
|
||||
### Fixed
|
||||
- Fix Gmail and other authenticated browser downloads that could lose their filename or save a sign-in page after a redirect, including Chrome Incognito, addressing [#21](https://github.com/nimbold/Firelink/issues/21).
|
||||
- Keep browser-capture metadata, cookies, and destinations tied to the correct download through redirects.
|
||||
- Prevent invalid URLs, late download events, stale progress, and abandoned media work from creating misleading rows or leftover temporary files.
|
||||
- Keep sensitive local paths, credentials, and persisted records protected in errors, diagnostics, and download state.
|
||||
|
||||
## [1.1.0] - 2026-07-15
|
||||
|
||||
This is a stability-focused release with safer downloads, browser handoffs, settings, and cross-platform packages.
|
||||
|
||||
### New
|
||||
- Add a secure Windows portable ZIP, addressing the portable-version request in [#15](https://github.com/nimbold/Firelink/issues/15). Settings, queues, logs, and WebView data stay with the portable folder, while saved site passwords remain protected in Windows Credential Manager.
|
||||
|
||||
### Improved
|
||||
- Make pause, resume, retry, remove, redownload, and queue actions reliable when several operations happen close together. Downloads now recover their state more consistently instead of restarting unexpectedly or appearing stuck after completion.
|
||||
- Make browser captures and the Add window safer and more dependable. Captured cookies are no longer sent to metadata checks unless authentication is actually needed, while ordinary downloads still keep the browser session they need. This resolves the fallback error reported in [#16](https://github.com/nimbold/Firelink/issues/16).
|
||||
- Improve settings and startup behavior: speed-limit units stay consistent, saved settings are handled more safely, and credential-store access waits until it is needed.
|
||||
- Improve browser handoff safety with stronger local request, replay, path, and credential checks.
|
||||
- Strengthen macOS, Windows, and Linux release verification, refresh the bundled FFmpeg payload, and make package and diagnostic checks more reliable.
|
||||
|
||||
### Fixed
|
||||
- Prevent late or duplicate background events from bringing back downloads after a newer pause, remove, or edit action has already won.
|
||||
- Reconcile completed downloads even when a background completion event is missed, so finished files do not remain visually stuck at the end of the transfer.
|
||||
- Decode URL-derived filenames so links containing encoded characters such as `%20` produce readable names, addressing [#18](https://github.com/nimbold/Firelink/issues/18).
|
||||
- Keep local paths, usernames, credentials, and other sensitive values out of errors and diagnostic output where they are not needed.
|
||||
|
||||
## [1.0.4] - 2026-07-12
|
||||
|
||||
### New
|
||||
|
||||
+1
-1
Submodule Extensions/Browser updated: 8a6dca9692...656ef06d15
@@ -5,7 +5,7 @@
|
||||
|
||||
**A fast, focused desktop download manager for macOS, Windows, and Linux.**
|
||||
|
||||
[](https://github.com/nimbold/Firelink/releases)
|
||||
[](https://github.com/nimbold/Firelink/releases)
|
||||
[](#platforms)
|
||||
[](#platforms)
|
||||
[](#platforms)
|
||||
@@ -33,17 +33,20 @@
|
||||
|
||||
## Why Firelink
|
||||
|
||||
Firelink is a desktop download manager for fast transfers, browser capture, media extraction, scheduling, and clear file placement.
|
||||
Firelink is a cross-platform desktop download manager for direct transfers, browser capture, media extraction, scheduling, and clear file placement.
|
||||
|
||||
It is now a cross-platform Rust/Tauri app with a React and TypeScript interface. A native backend coordinates downloads with aria2, yt-dlp, FFmpeg, Deno, and SQLite.
|
||||
It combines a Rust/Tauri backend with a React and TypeScript interface. Bundled aria2, yt-dlp, FFmpeg, Deno, and SQLite support the download and media workflows.
|
||||
|
||||
The current desktop release is **1.0.4**, paired with Firelink Companion **2.0.3**.
|
||||
The current desktop release is **1.1.1**, paired with Firelink Companion **2.0.5**.
|
||||
|
||||
This release adds YouTube playlist downloads, live connection controls, clipboard capture, and clearer byte-level progress. It also improves slow-transfer recovery, authenticated browser captures, and startup consent handling.
|
||||
|
||||
## Features
|
||||
|
||||
- **Segmented downloads** with aria2, retries, speed limits, and connection controls.
|
||||
- **Media downloads** with yt-dlp, FFmpeg, Deno, live progress, speed, and ETA.
|
||||
- **Add window** for metadata, duplicates, location choices, captured links, and clipboard-prefilled URLs.
|
||||
- **Playlist downloads** for YouTube playlists with queueing and efficient large-list rendering.
|
||||
- **Add window** for metadata, duplicates, location choices, captured links, clipboard-prefilled URLs, and live connection limits.
|
||||
- **Persistent queues** with pause, resume, retry, redownload, sorting, multi-select, and bulk actions.
|
||||
- **Scheduling** with start/stop windows, speed rules, and post-queue actions.
|
||||
- **File organization** with categories, default folders, a collapsible Folders section, per-download overrides, and reveal/trash actions.
|
||||
@@ -59,11 +62,28 @@ 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** | `.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. |
|
||||
| **Windows x64 portable** | `.zip` archive | Extract to a writable folder and launch `firelink.exe`. See the expandable notes below. |
|
||||
| **Linux x64** | `.deb`, `.rpm`, or `.AppImage` | Use `.deb` for Debian-family systems, `.rpm` for Fedora/RPM-family systems, or AppImage as the self-contained package. AppImage may need executable permission. |
|
||||
|
||||
Bundles include the required engines. Users do not need aria2, yt-dlp, FFmpeg, Deno, Python, Homebrew, or another package manager.
|
||||
|
||||
The native packages use the distribution's normal desktop runtime dependencies, while AppImage remains the portable installation option.
|
||||
The native packages use the distribution's normal desktop runtime dependencies. AppImage is self-contained but uses the normal per-user application-data locations.
|
||||
|
||||
<details>
|
||||
<summary><strong>Windows portable ZIP notes</strong></summary>
|
||||
|
||||
The portable ZIP is an opt-in secondary distribution. Extract it to a writable folder and launch `firelink.exe`:
|
||||
|
||||
- Keep the extracted folder writable; avoid `Program Files`, read-only media, and folders that block SQLite or WebView writes.
|
||||
- Settings, queues, logs, and WebView data stay beside `firelink.exe` under `data/`.
|
||||
- Close Firelink before moving or copying the folder, and close the installed app before launching the portable copy.
|
||||
- Credentials, browser cookies, and URL query/fragment data are not saved in portable queue records. Active downloads that depend on them must be added again after restart.
|
||||
- Saved site passwords remain in the Windows credential store and are not copied into the archive.
|
||||
- The folder contains the extension pairing credential, so treat it as sensitive and do not share it.
|
||||
- Saved absolute download locations may need to be selected again after moving the folder to another drive.
|
||||
- The installer remains the supported path for `firelink://` browser launch registration.
|
||||
|
||||
</details>
|
||||
|
||||
## Browser Extension
|
||||
|
||||
@@ -73,7 +93,7 @@ The native packages use the distribution's normal desktop runtime dependencies,
|
||||
<a href="https://github.com/nimbold/Firelink-Extension#manual-chromium-installation"><img src="https://img.shields.io/badge/Manual%20install-Chromium-4285F4?style=for-the-badge&logo=googlechrome&logoColor=white" alt="Read manual Chromium install instructions" /></a>
|
||||
</p>
|
||||
|
||||
Firelink Companion sends browser links and downloads to the desktop app.
|
||||
Firelink Companion connects browser links and downloads to the desktop app.
|
||||
|
||||
What it adds:
|
||||
|
||||
@@ -85,9 +105,9 @@ What it adds:
|
||||
- Fallback to the browser download when Firelink is closed or rejects a handoff.
|
||||
- Captured links always open Firelink's Add window before anything is added to the download list.
|
||||
|
||||
Install the extension, open Firelink, then pair it from **Settings -> Integrations**. Firefox users can install from Mozilla Add-ons. Chromium users can use the [manual load-unpacked flow](https://github.com/nimbold/Firelink-Extension#manual-chromium-installation) with `firelink-chromium.zip` from the [extension releases](https://github.com/nimbold/Firelink-Extension/releases). Firelink Companion 2.0.3 is the matching extension release for Firelink 1.0.4.
|
||||
Install the extension, open Firelink, then pair it from **Settings -> Integrations**. Firefox users can install it from Mozilla Add-ons. Chromium users can load `firelink-chromium.zip` from the [extension releases](https://github.com/nimbold/Firelink-Extension/releases) with the [manual Chromium instructions](https://github.com/nimbold/Firelink-Extension#manual-chromium-installation).
|
||||
|
||||
The extension lives in [Firelink-Extension](https://github.com/nimbold/Firelink-Extension). This repo also vendors it as the `Extensions/Browser` submodule.
|
||||
Use the latest [Firelink Companion release](https://github.com/nimbold/Firelink-Extension/releases) with Firelink 1.1.1. The source is in the [Firelink-Extension repository](https://github.com/nimbold/Firelink-Extension), which this repo vendors as the `Extensions/Browser` submodule.
|
||||
|
||||
## Platforms
|
||||
|
||||
|
||||
+9
-7
@@ -4,13 +4,14 @@ Targets:
|
||||
|
||||
- macOS arm64 DMG
|
||||
- Windows x64 NSIS installer
|
||||
- Windows x64 portable ZIP
|
||||
- Linux x64 AppImage
|
||||
- Linux x64 Debian package
|
||||
- Linux x64 RPM package
|
||||
|
||||
## Distribution policy
|
||||
|
||||
Firelink does not use an Apple Developer account. macOS releases are unsigned and not notarized. Users must explicitly approve the downloaded app through Finder or macOS Privacy & Security. Release copy must never describe these builds as signed, notarized, or Gatekeeper-approved.
|
||||
Firelink does not use an Apple Developer account. macOS releases are ad-hoc signed but not notarized or Gatekeeper-approved. Users may still need to explicitly approve the downloaded app through Finder or macOS Privacy & Security. Release copy must not describe these builds as Developer ID signed, notarized, or Gatekeeper-approved.
|
||||
|
||||
Windows releases are currently unsigned. SmartScreen may warn until code signing is added.
|
||||
|
||||
@@ -57,10 +58,12 @@ node scripts/verify-binaries.js --search-root "$APP" --target aarch64-apple-darw
|
||||
node scripts/smoke-packaged-app.js --executable "$APP/Contents/MacOS/firelink"
|
||||
```
|
||||
|
||||
GitHub release publication is intentionally manual. Tag pushes build and upload
|
||||
artifacts, but the `publish` job only runs from a `workflow_dispatch` on a `v*`
|
||||
tag when both release-certification inputs are checked after Windows, Linux,
|
||||
and macOS clean-machine QA.
|
||||
GitHub release publication follows `.github/workflows/release.yml`. A `v*` tag
|
||||
push builds, verifies, and publishes the GitHub release after the platform jobs
|
||||
pass. A `workflow_dispatch` on a `v*` tag also publishes when its
|
||||
`publish_release` input is enabled. The current workflow has no separate
|
||||
release-certification inputs; clean-machine QA remains a release-owner gate
|
||||
before pushing the tag.
|
||||
|
||||
## Automated release builds
|
||||
|
||||
@@ -73,7 +76,6 @@ git push origin v<version>
|
||||
|
||||
GitHub Actions builds all targets on native runners, verifies engines inside
|
||||
final package contents, performs packaged launch smoke where supported, and
|
||||
uploads artifacts. Publish the GitHub Release with a manual `workflow_dispatch`
|
||||
run on the same tag after clean-machine QA is complete.
|
||||
publishes the GitHub Release after the build matrix passes.
|
||||
|
||||
No target may silently skip missing engines, failed extraction, checksum mismatch, or missing package output.
|
||||
|
||||
+10
-10
@@ -8,14 +8,14 @@
|
||||
"sha256": "90254845be5282b1f4d843a873abff04f569f857f64250f833fe152b21eec152"
|
||||
},
|
||||
"deno": {
|
||||
"version": "2.9.2",
|
||||
"url": "https://github.com/denoland/deno/releases/download/v2.9.2/deno-x86_64-pc-windows-msvc.zip",
|
||||
"sha256": "5fe194d26ac5ef77fcc5288c2c438c7a0465f3b6180440ebf04092714bf2dcdf"
|
||||
"version": "2.9.3",
|
||||
"url": "https://github.com/denoland/deno/releases/download/v2.9.3/deno-x86_64-pc-windows-msvc.zip",
|
||||
"sha256": "60343461ac5fe3a31f4ef12667f2946bb852e20655c8610aeb7e751e87f7df3a"
|
||||
},
|
||||
"ffmpeg": {
|
||||
"version": "8.1.2-22-g94138f6973",
|
||||
"url": "https://github.com/BtbN/FFmpeg-Builds/releases/download/autobuild-2026-07-06-14-19/ffmpeg-n8.1.2-22-g94138f6973-win64-gpl-8.1.zip",
|
||||
"sha256": "a758e2836a8f33c5f21fc44270cb00392acc6d0085dd0ba14fe14ae75935813d"
|
||||
"url": "https://github.com/BtbN/FFmpeg-Builds/releases/download/autobuild-2026-07-15-14-01/ffmpeg-n8.1.2-22-g94138f6973-win64-gpl-8.1.zip",
|
||||
"sha256": "0af1621d3a2e418b86d8ff4704f5d5a70bcce611edd6c5110d092533b170952c"
|
||||
},
|
||||
"aria2c": {
|
||||
"version": "1.37.0",
|
||||
@@ -30,14 +30,14 @@
|
||||
"sha256": "d7d2d09e900b5ae11821b5784b18cf064984a2bd88b1ca5c798d744bcbe3658b"
|
||||
},
|
||||
"deno": {
|
||||
"version": "2.9.2",
|
||||
"url": "https://github.com/denoland/deno/releases/download/v2.9.2/deno-x86_64-unknown-linux-gnu.zip",
|
||||
"sha256": "934d1bd5cb09eaed7f2e4a4fc58208d04a3c5c0fcde9f319d93d735265c67a4a"
|
||||
"version": "2.9.3",
|
||||
"url": "https://github.com/denoland/deno/releases/download/v2.9.3/deno-x86_64-unknown-linux-gnu.zip",
|
||||
"sha256": "8101865641cbede56f08ad19c0a67a87df84bce127fee0d3e3e1f7467717ffa6"
|
||||
},
|
||||
"ffmpeg": {
|
||||
"version": "8.1.2-22-g94138f6973",
|
||||
"url": "https://github.com/BtbN/FFmpeg-Builds/releases/download/autobuild-2026-07-06-14-19/ffmpeg-n8.1.2-22-g94138f6973-linux64-gpl-8.1.tar.xz",
|
||||
"sha256": "df99ffb3803ee56dc68954f43f950ea9f33685a3595a5da8a3e73ef4bef37e3c"
|
||||
"url": "https://github.com/BtbN/FFmpeg-Builds/releases/download/autobuild-2026-07-15-14-01/ffmpeg-n8.1.2-22-g94138f6973-linux64-gpl-8.1.tar.xz",
|
||||
"sha256": "68e304f5518d3e22cd825e6fb738d0d9a04a8f36127873a18fc6646457e95b92"
|
||||
},
|
||||
"aria2c": {
|
||||
"version": "1.37.0",
|
||||
|
||||
+5
-4
@@ -16,16 +16,17 @@
|
||||
"sha256": "111b2f5ed760f1e1a2ec06117c4e8094fcde336ba16122dda1c5e7209bf1862d"
|
||||
},
|
||||
"ffmpeg": {
|
||||
"version": "N-125450-gfad2e0bc50",
|
||||
"version": "N-125610-g312c830916",
|
||||
"source": "https://ffmpeg.org/",
|
||||
"build": "GPLv3 build identified by binary as https://www.martin-riedl.de",
|
||||
"sha256": "be2c39e5c9ef923f60da6cb62f5a209ed98b4da8a732d9f06de4355d5ea99e58"
|
||||
"url": "https://ffmpeg.martin-riedl.de/download/macos/arm64/1784052810_N-125610-g312c830916/ffmpeg.zip",
|
||||
"sha256": "f65b4ba782b25e9f4374765d421793c6613f692d8e50b2888079657793619034"
|
||||
},
|
||||
"deno": {
|
||||
"version": "2.9.2",
|
||||
"version": "2.9.3",
|
||||
"source": "https://github.com/denoland/deno",
|
||||
"build": "official aarch64-apple-darwin executable",
|
||||
"sha256": "218ab752ae8f64f0a7822af710886488f15169fdae153a3aada4861f9635b266"
|
||||
"sha256": "80c83cdfb20289f8818a71220b570df363f6f0ba93580c29c70f08ab14e93568"
|
||||
}
|
||||
},
|
||||
"runtimeTrees": {
|
||||
|
||||
Generated
+181
-121
@@ -1,21 +1,21 @@
|
||||
{
|
||||
"name": "firelink",
|
||||
"version": "1.0.4",
|
||||
"version": "1.1.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "firelink",
|
||||
"version": "1.0.4",
|
||||
"version": "1.1.1",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@formkit/auto-animate": "^0.10.0",
|
||||
"@tailwindcss/vite": "^4.3.1",
|
||||
"@tailwindcss/vite": "^4.3.3",
|
||||
"@tauri-apps/api": "^2.11.1",
|
||||
"@tauri-apps/plugin-clipboard-manager": "^2.3.2",
|
||||
"@tauri-apps/plugin-dialog": "^2.7.1",
|
||||
"@tauri-apps/plugin-fs": "^2.5.1",
|
||||
"@tauri-apps/plugin-log": "^2.8.0",
|
||||
"@tauri-apps/plugin-log": "^2.9.0",
|
||||
"@tauri-apps/plugin-notification": "^2.3.3",
|
||||
"@tauri-apps/plugin-opener": "^2",
|
||||
"lucide-react": "^1.24.0",
|
||||
@@ -28,11 +28,11 @@
|
||||
"@types/react": "^19.1.8",
|
||||
"@types/react-dom": "^19.1.6",
|
||||
"@vitejs/plugin-react": "^6.0.3",
|
||||
"autoprefixer": "^10.5.2",
|
||||
"postcss": "^8.5.15",
|
||||
"tailwindcss": "^4.3.1",
|
||||
"autoprefixer": "^10.5.4",
|
||||
"postcss": "^8.5.19",
|
||||
"tailwindcss": "^4.3.3",
|
||||
"typescript": "^7.0.2",
|
||||
"vite": "^8.1.4",
|
||||
"vite": "^8.1.5",
|
||||
"vitest": "^4.1.10"
|
||||
},
|
||||
"engines": {
|
||||
@@ -404,47 +404,47 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@tailwindcss/node": {
|
||||
"version": "4.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.2.tgz",
|
||||
"integrity": "sha512-yWP/sqEcBLaD8JuA6zNwxoYKr75qxTioYwlRwekj5Jr/I5GXnoJfjetH/psLUIv74cYTH2lBUEzBkinthoYcBg==",
|
||||
"version": "4.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.3.tgz",
|
||||
"integrity": "sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/remapping": "^2.3.5",
|
||||
"enhanced-resolve": "5.21.6",
|
||||
"enhanced-resolve": "^5.24.1",
|
||||
"jiti": "^2.7.0",
|
||||
"lightningcss": "1.32.0",
|
||||
"magic-string": "^0.30.21",
|
||||
"source-map-js": "^1.2.1",
|
||||
"tailwindcss": "4.3.2"
|
||||
"tailwindcss": "4.3.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide": {
|
||||
"version": "4.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.2.tgz",
|
||||
"integrity": "sha512-z8ZgnzX8gdNoWLBLqBPoh/sjnxkwvf9ZuWjnO0l0yIzbLa5/9S+eC5QxGZKRobVHIC3/1BoMWjHblqWjcgFgag==",
|
||||
"version": "4.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.3.tgz",
|
||||
"integrity": "sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 20"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@tailwindcss/oxide-android-arm64": "4.3.2",
|
||||
"@tailwindcss/oxide-darwin-arm64": "4.3.2",
|
||||
"@tailwindcss/oxide-darwin-x64": "4.3.2",
|
||||
"@tailwindcss/oxide-freebsd-x64": "4.3.2",
|
||||
"@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.2",
|
||||
"@tailwindcss/oxide-linux-arm64-gnu": "4.3.2",
|
||||
"@tailwindcss/oxide-linux-arm64-musl": "4.3.2",
|
||||
"@tailwindcss/oxide-linux-x64-gnu": "4.3.2",
|
||||
"@tailwindcss/oxide-linux-x64-musl": "4.3.2",
|
||||
"@tailwindcss/oxide-wasm32-wasi": "4.3.2",
|
||||
"@tailwindcss/oxide-win32-arm64-msvc": "4.3.2",
|
||||
"@tailwindcss/oxide-win32-x64-msvc": "4.3.2"
|
||||
"@tailwindcss/oxide-android-arm64": "4.3.3",
|
||||
"@tailwindcss/oxide-darwin-arm64": "4.3.3",
|
||||
"@tailwindcss/oxide-darwin-x64": "4.3.3",
|
||||
"@tailwindcss/oxide-freebsd-x64": "4.3.3",
|
||||
"@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.3",
|
||||
"@tailwindcss/oxide-linux-arm64-gnu": "4.3.3",
|
||||
"@tailwindcss/oxide-linux-arm64-musl": "4.3.3",
|
||||
"@tailwindcss/oxide-linux-x64-gnu": "4.3.3",
|
||||
"@tailwindcss/oxide-linux-x64-musl": "4.3.3",
|
||||
"@tailwindcss/oxide-wasm32-wasi": "4.3.3",
|
||||
"@tailwindcss/oxide-win32-arm64-msvc": "4.3.3",
|
||||
"@tailwindcss/oxide-win32-x64-msvc": "4.3.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-android-arm64": {
|
||||
"version": "4.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.2.tgz",
|
||||
"integrity": "sha512-WHxqIuHpvZ5VtdX6GTl1Ik/Vp2YuN42Et+0CdeaVd/frQ9jAvGmvR8vLT+jk3e8/Q3x8kECB9+R17pgpp2BulA==",
|
||||
"version": "4.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.3.tgz",
|
||||
"integrity": "sha512-Y85A2gmPSkl5Ve5qR86GL4HT509cFqQh1aes9p3sSkyTPwt0Pppf3GkwGe4JPACcRYjgJIEhQgM6dBClnr0NYw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -458,9 +458,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-darwin-arm64": {
|
||||
"version": "4.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.2.tgz",
|
||||
"integrity": "sha512-GZypeUY/IDJW3877KeM+O67vbXr3MBnbtEL4aYhNErv/JWZhye2vGSWWG9tB6iiqR2MqRNkY8IOUy4NdSZV26w==",
|
||||
"version": "4.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.3.tgz",
|
||||
"integrity": "sha512-BiaWatpBcERQFDlOjRDpIVXuFK5PJez5SA4JMg6VYZdBYU+qKfV/vqjcIs+IYmtitf1xYQZTwXvU/8y4lfZUGw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -474,9 +474,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-darwin-x64": {
|
||||
"version": "4.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.2.tgz",
|
||||
"integrity": "sha512-UIIzmefR6KO1sDU7MzRqAxC8iBpft/VhkGjTjnhoS6k7Z3rQ9wEgA1ODSiyH/tcSYssulNm4Ci3hOeK1jH7ccQ==",
|
||||
"version": "4.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.3.tgz",
|
||||
"integrity": "sha512-fAeUqfV5ndhxRwai8cXGzdLvul9utWOmeTkv69unv4ZXixjn61Z+p9lCWdwOwA3TYboG3BwdVuN/RDjhBRl0mw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -490,9 +490,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-freebsd-x64": {
|
||||
"version": "4.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.2.tgz",
|
||||
"integrity": "sha512-GN+uAmcI6DNspnCDwtOAZrTz6oukJnp337qZvxqCGLd3BHBzJpO0ZbTLRvJNdztOeAmTzewewGIMPb0tk2R4WA==",
|
||||
"version": "4.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.3.tgz",
|
||||
"integrity": "sha512-iyf5bV6+wnAlflVeEy7R25dupxTNECZN5QMI0qNT6eT+EgaGdZcKhGkr5SdoaWiLJ3spLqIY9VCeSGrwmtg4kw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -506,9 +506,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": {
|
||||
"version": "4.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.2.tgz",
|
||||
"integrity": "sha512-4ABn7qSbdHRwTiDiuWNegCyb5+2FJ4vKIKc3DmKrvAFw7MU1Lm11dIkTPwUaFdTzc7IsOpDbqBrlh0x6y36U/w==",
|
||||
"version": "4.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.3.tgz",
|
||||
"integrity": "sha512-aAYUprJAJQWWbRrPvtjdroZ56Md+JM8pMiopS6xGEwDfLhqj+2ver2p4nU4Mb3CRqcMmNBjo8KkUgcxhkzVQGQ==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -522,9 +522,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-linux-arm64-gnu": {
|
||||
"version": "4.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.2.tgz",
|
||||
"integrity": "sha512-wDgEIGwoM8w8pufh9LVt1PahDgNdKXrLC2qfAnV3vAmococ9RWbxeAw4pxPttd/TsJfwjyLf90Dg1y9y8I6Emw==",
|
||||
"version": "4.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.3.tgz",
|
||||
"integrity": "sha512-nDxldcEENOxZRzC2uu9jrutZdAAQtb+8WWDCSnWL1zvBk1+FN+x6MtDViPB5AJMfttVCUhehGWus3XBPgatM/w==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -538,9 +538,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-linux-arm64-musl": {
|
||||
"version": "4.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.2.tgz",
|
||||
"integrity": "sha512-J5Nuk0uZQIiMTJj3LEx4sAA9tMFUoXQZFv1J6An+QGYe53HKRJuFDi0rpq/tuouCZeAbOBY3kQ6g8qeD4TUjtA==",
|
||||
"version": "4.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.3.tgz",
|
||||
"integrity": "sha512-Md44bD6veX/PC5iyF8cDVnw4HBIANZepRZZ7a8DQOvkfo5WUBwcp6iAuCUz23u+4SUkhJlD3eL7hNdW8ezd/kA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -554,9 +554,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-linux-x64-gnu": {
|
||||
"version": "4.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.2.tgz",
|
||||
"integrity": "sha512-kqCZpSKOBEJO4mz7OqWoofBZeXTAwaVGPj0ErAj7CojmhKpWVWVOnrt9dE8odoIraZq4oj3ausM37kXi+Tow8w==",
|
||||
"version": "4.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.3.tgz",
|
||||
"integrity": "sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -570,9 +570,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-linux-x64-musl": {
|
||||
"version": "4.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.2.tgz",
|
||||
"integrity": "sha512-cixpqbh2toJDmkuCRI68nXA8ZxNmdK9Y+9v5h3MC3ZQKy/0BO8AWzlkWyRM7JAFSGBlfig4YVTPsK6MVgqz1uw==",
|
||||
"version": "4.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.3.tgz",
|
||||
"integrity": "sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -586,9 +586,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-wasm32-wasi": {
|
||||
"version": "4.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.2.tgz",
|
||||
"integrity": "sha512-4ec2Z/LOmRsAgU23CS4xeJfcJlmRg94A/XrbGRCF1gyU/zdDfRLYDVsS+ynSZCmGNxQ1jQriQOKMQeQxBA3Isw==",
|
||||
"version": "4.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.3.tgz",
|
||||
"integrity": "sha512-jx1+rPhY/5Ympkktd656HBWEBLxP7dH06losBLjjf5vgCODXvi9KhtftWcMIwTFIDqBr7cRnQkdLnAG+IOlGvQ==",
|
||||
"bundleDependencies": [
|
||||
"@napi-rs/wasm-runtime",
|
||||
"@emnapi/core",
|
||||
@@ -614,10 +614,70 @@
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": {
|
||||
"version": "1.11.1",
|
||||
"inBundle": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@emnapi/wasi-threads": "1.2.2",
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": {
|
||||
"version": "1.11.1",
|
||||
"inBundle": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": {
|
||||
"version": "1.2.2",
|
||||
"inBundle": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": {
|
||||
"version": "1.1.4",
|
||||
"inBundle": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@tybys/wasm-util": "^0.10.1"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@emnapi/core": "^1.7.1",
|
||||
"@emnapi/runtime": "^1.7.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": {
|
||||
"version": "0.10.2",
|
||||
"inBundle": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": {
|
||||
"version": "2.8.1",
|
||||
"inBundle": true,
|
||||
"license": "0BSD",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
|
||||
"version": "4.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.2.tgz",
|
||||
"integrity": "sha512-Zyr/M0+XcYZu3bZrUytc7TXvrk0ftWfl8gN2MwekNDzhqhKRUucMPSeOzM0o0wH5AWOU49BsKRrfKxI2atCPMQ==",
|
||||
"version": "4.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.3.tgz",
|
||||
"integrity": "sha512-3rc292Ca2ceK6Ulcc/bAVnTs/3nDtoPhyEKlgPv+yQJQi/JS/AMJlqzxvlDacL1nekbrcf6bTqp/jV4qgnPxNQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -631,9 +691,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-win32-x64-msvc": {
|
||||
"version": "4.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.2.tgz",
|
||||
"integrity": "sha512-QI9BO7KlNZsp2GuO0jwAAj5jCDABOKXRkCk2XuKTSaNEFSdfzqswYVTtCHBNKHLsqyjFyFkqlDiwkNbTYSssMQ==",
|
||||
"version": "4.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.3.tgz",
|
||||
"integrity": "sha512-yJ0pwIVc/nYeGoV02WtsN8KYyLQv7kyI2wDnkezyJlGGjkd4QLwDGAwl47YpPJeuI0M0ObaXGSPjvWDPeTPggw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -647,14 +707,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/vite": {
|
||||
"version": "4.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.3.2.tgz",
|
||||
"integrity": "sha512-eHpMeX4JXfVNJDEcsouTeCBubJBTcTLigeaw/NTUW6PB5ATKKXdyonnXgTBX2VuRbjz1hjfz6C5XAhr52ImQXA==",
|
||||
"version": "4.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.3.3.tgz",
|
||||
"integrity": "sha512-yYU8cogLeSh/ms2jh8Fj7jaba/EWa7Ja6GoUqYZaraEuCI5YS6ms6ObZgjjedm+jm6XZjdNRWBpPP6Z86oOxcw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@tailwindcss/node": "4.3.2",
|
||||
"@tailwindcss/oxide": "4.3.2",
|
||||
"tailwindcss": "4.3.2"
|
||||
"@tailwindcss/node": "4.3.3",
|
||||
"@tailwindcss/oxide": "4.3.3",
|
||||
"tailwindcss": "4.3.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"vite": "^5.2.0 || ^6 || ^7 || ^8"
|
||||
@@ -915,12 +975,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/plugin-log": {
|
||||
"version": "2.8.0",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-log/-/plugin-log-2.8.0.tgz",
|
||||
"integrity": "sha512-a+7rOq3MJwpTOLLKbL8d0qGZ85hgHw5pNOWusA9o3cf7cEgtYHiGY/+O8fj8MvywQIGqFv0da2bYQDlrqLE7rw==",
|
||||
"version": "2.9.0",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-log/-/plugin-log-2.9.0.tgz",
|
||||
"integrity": "sha512-Ql8okrnsguk0eDq1GvRfttFV5KaeW/7vcao6bdbkXCRJ1+2sWE15ZJvJVEKVANrOKy1mRngqC3IFIAP+wP5qSw==",
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2.8.0"
|
||||
"@tauri-apps/api": "^2.11.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/plugin-notification": {
|
||||
@@ -1486,9 +1546,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/autoprefixer": {
|
||||
"version": "10.5.2",
|
||||
"resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.5.2.tgz",
|
||||
"integrity": "sha512-rD5t5DwOjJdmSORcTq64j8MawTC+tbQ+HHqjR4NDumamy/ambn1UJrlKL+KdwujWxMkFjPM3pPHOEA9tl4767Q==",
|
||||
"version": "10.5.4",
|
||||
"resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.5.4.tgz",
|
||||
"integrity": "sha512-MaU0U/za7N3r6brxD4YB/l4NSrFzLPlANv6wEuQVaIPlD3L4W9rFcQPbL/EilY9BHhHvhfcz3gInDLrEtWT4EA==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@@ -1506,8 +1566,8 @@
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"browserslist": "^4.28.4",
|
||||
"caniuse-lite": "^1.0.30001799",
|
||||
"browserslist": "^4.28.6",
|
||||
"caniuse-lite": "^1.0.30001806",
|
||||
"fraction.js": "^5.3.4",
|
||||
"picocolors": "^1.1.1",
|
||||
"postcss-value-parser": "^4.2.0"
|
||||
@@ -1523,9 +1583,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/baseline-browser-mapping": {
|
||||
"version": "2.10.40",
|
||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.40.tgz",
|
||||
"integrity": "sha512-BSSLZ9/Cjjv7Gtj5B68ZzXcXUg8iOf3fme+FCuh8rC/Go+Kmh8cox7M3A8dolou16s64QjLPOSdngh7GxXvkSw==",
|
||||
"version": "2.10.43",
|
||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.43.tgz",
|
||||
"integrity": "sha512-AjYpR78kDWAY3Efj+cDTFH9t9SCoL7OoTp1BOb0mQV7S+6CiLwnWM3FyxhJtdPufDFKzmCSFoUncKjWgJEZTCQ==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
@@ -1536,9 +1596,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/browserslist": {
|
||||
"version": "4.28.4",
|
||||
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.4.tgz",
|
||||
"integrity": "sha512-MTc8i/x9jBQd1iMw2CFGS+rwMa07eYjLR0CCTLDACl9xhxy+nIs3KeML/biicXtk9JrZ6dnnTatmc7ErPXIxqw==",
|
||||
"version": "4.28.6",
|
||||
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.6.tgz",
|
||||
"integrity": "sha512-FQBYNK15VMslhLHpA7+n+n1GOlF1kId2xcCg7/j95f24AOF6VDYMNH4mFxF7KuaTdv627faazpOAjFzMrfJOUw==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@@ -1556,10 +1616,10 @@
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"baseline-browser-mapping": "^2.10.38",
|
||||
"caniuse-lite": "^1.0.30001799",
|
||||
"electron-to-chromium": "^1.5.376",
|
||||
"node-releases": "^2.0.48",
|
||||
"baseline-browser-mapping": "^2.10.42",
|
||||
"caniuse-lite": "^1.0.30001803",
|
||||
"electron-to-chromium": "^1.5.389",
|
||||
"node-releases": "^2.0.51",
|
||||
"update-browserslist-db": "^1.2.3"
|
||||
},
|
||||
"bin": {
|
||||
@@ -1570,9 +1630,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/caniuse-lite": {
|
||||
"version": "1.0.30001800",
|
||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001800.tgz",
|
||||
"integrity": "sha512-MMHtuAz9Ys840zAY5F4k6fV5GaivZ9sPk+nz0mY+GYVzRBnYkN0mpqkSR92oWRQ19yQWo4HvBV/FnC16AJX8MA==",
|
||||
"version": "1.0.30001806",
|
||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001806.tgz",
|
||||
"integrity": "sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@@ -1624,16 +1684,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/electron-to-chromium": {
|
||||
"version": "1.5.382",
|
||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.382.tgz",
|
||||
"integrity": "sha512-8ETaWbV6SZOrno+G93Ffd9ENsMtetqdnqj4nlfxFW90Sm5GgnuV28Kf62hqQVD6VUgzm7qFQKsTsAPmeUiU3Ug==",
|
||||
"version": "1.5.389",
|
||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.389.tgz",
|
||||
"integrity": "sha512-cEto7aeOqBfU1D+c5py5pE+ooscKE75JifxLBdFUZsqAxRS6y7kebtxAZvICszSl05gPjYHDTjY+lXpyGvpJbg==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/enhanced-resolve": {
|
||||
"version": "5.21.6",
|
||||
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.21.6.tgz",
|
||||
"integrity": "sha512-aNnGCvbJ/RIyWo1IuhNdVjnNF+EjH9wpzpNHt+ci/m9He9LJvUN8wrCcXjp9cWsGNAuvSpVFTx/vraAFQ8qGjQ==",
|
||||
"version": "5.24.2",
|
||||
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.24.2.tgz",
|
||||
"integrity": "sha512-rpsZEGT1jFuve6QlpyRp9ckQ+kN61hvF9BzCPyMdaKTm8UJce96KBn3sorXOFXlzjPrs3Vc4T1NsSroZ3PxlFw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"graceful-fs": "^4.2.4",
|
||||
@@ -1644,9 +1704,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/es-module-lexer": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.2.0.tgz",
|
||||
"integrity": "sha512-3lGxdTXCLfe1MYfTz1y2ksAAUM4NAOP6rPEjxGJVKO7TZ5+tvHCaQWGpC4Y3IXvW3ece0Cz1cIP4FWBxOnGCTQ==",
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.1.tgz",
|
||||
"integrity": "sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
@@ -2008,9 +2068,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/nanoid": {
|
||||
"version": "3.3.15",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz",
|
||||
"integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==",
|
||||
"version": "3.3.16",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz",
|
||||
"integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
@@ -2026,9 +2086,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/node-releases": {
|
||||
"version": "2.0.50",
|
||||
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.50.tgz",
|
||||
"integrity": "sha512-J6l92tKHX6w8Jy5nO1Vuc01NoIiRGi/d6qBKVxh+IQ8Cr3b6HbVNfKiF8ZpFKufTwpwxMmce2W3iQZ861ZRyTg==",
|
||||
"version": "2.0.51",
|
||||
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.51.tgz",
|
||||
"integrity": "sha512-wRNIrw4DmVLKQlbgOMdkMx27Wrpzes2hh5Jtbi2bjPd+4wJstWIqP5A+lscnqbm0xxmT5Bpg8Lec5ItEBwx6BQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -2075,9 +2135,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.5.16",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz",
|
||||
"integrity": "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==",
|
||||
"version": "8.5.19",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.19.tgz",
|
||||
"integrity": "sha512-Mz8SaolMd8nB+G13WkORcxQKHZ/NE4xXevtkJHVuG+guo9/wYKlIMTKAqGdEmYOXR2ijPjTYNHssizdaVSUNdQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "opencollective",
|
||||
@@ -2193,16 +2253,16 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/std-env": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz",
|
||||
"integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==",
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz",
|
||||
"integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tailwindcss": {
|
||||
"version": "4.3.2",
|
||||
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.2.tgz",
|
||||
"integrity": "sha512-WtctNNSH8A9jlMIqxzuYumOHU5uGZyRv0Q5svQl+oEPy5w84YpBxdb7MdqyiSPQge5jTJ6zFQLq0PFygdccSBA==",
|
||||
"version": "4.3.3",
|
||||
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.3.tgz",
|
||||
"integrity": "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tapable": {
|
||||
@@ -2335,15 +2395,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "8.1.4",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-8.1.4.tgz",
|
||||
"integrity": "sha512-bTT9PsdWO+MQMNG9ZXIP/qM9wGh37DFxTV/sPq9cFpHr3w4jkgef032PkAL9jAqhk3Nz8NQw3O8n6/xFkqO4QQ==",
|
||||
"version": "8.1.5",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-8.1.5.tgz",
|
||||
"integrity": "sha512-7ULLwsCdYx/nRyrpiEwvqb5TFHrMVZyBt+rg/OAXT7rgj/z+DtTDyKFeLAdDkubDVDKD8jOsndmy7m55XcfUsw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"lightningcss": "^1.32.0",
|
||||
"picomatch": "^4.0.5",
|
||||
"postcss": "^8.5.16",
|
||||
"rolldown": "~1.1.4",
|
||||
"postcss": "^8.5.17",
|
||||
"rolldown": "~1.1.5",
|
||||
"tinyglobby": "^0.2.17"
|
||||
},
|
||||
"bin": {
|
||||
|
||||
+7
-7
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "firelink",
|
||||
"private": true,
|
||||
"version": "1.0.4",
|
||||
"version": "1.1.1",
|
||||
"description": "A fast cross-platform desktop download manager powered by Rust, Tauri, React, aria2, and yt-dlp.",
|
||||
"license": "MIT",
|
||||
"homepage": "https://github.com/nimbold/Firelink",
|
||||
@@ -39,12 +39,12 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@formkit/auto-animate": "^0.10.0",
|
||||
"@tailwindcss/vite": "^4.3.1",
|
||||
"@tailwindcss/vite": "^4.3.3",
|
||||
"@tauri-apps/api": "^2.11.1",
|
||||
"@tauri-apps/plugin-clipboard-manager": "^2.3.2",
|
||||
"@tauri-apps/plugin-dialog": "^2.7.1",
|
||||
"@tauri-apps/plugin-fs": "^2.5.1",
|
||||
"@tauri-apps/plugin-log": "^2.8.0",
|
||||
"@tauri-apps/plugin-log": "^2.9.0",
|
||||
"@tauri-apps/plugin-notification": "^2.3.3",
|
||||
"@tauri-apps/plugin-opener": "^2",
|
||||
"lucide-react": "^1.24.0",
|
||||
@@ -57,11 +57,11 @@
|
||||
"@types/react": "^19.1.8",
|
||||
"@types/react-dom": "^19.1.6",
|
||||
"@vitejs/plugin-react": "^6.0.3",
|
||||
"autoprefixer": "^10.5.2",
|
||||
"postcss": "^8.5.15",
|
||||
"tailwindcss": "^4.3.1",
|
||||
"autoprefixer": "^10.5.4",
|
||||
"postcss": "^8.5.19",
|
||||
"tailwindcss": "^4.3.3",
|
||||
"typescript": "^7.0.2",
|
||||
"vite": "^8.1.4",
|
||||
"vite": "^8.1.5",
|
||||
"vitest": "^4.1.10"
|
||||
}
|
||||
}
|
||||
|
||||
+46
-19
@@ -83,22 +83,38 @@ async function latestMartinRiedlMacArm64Release() {
|
||||
async function latestMartinRiedlMacArm64Snapshot() {
|
||||
const html = await fetchText('https://ffmpeg.martin-riedl.de/');
|
||||
const snapshotSection = html.split('Download Snapshot Build')[1]?.split('Download Release Build')[0] || '';
|
||||
const card = snapshotSection.match(/<h3>macOS \(Apple Silicon\/arm64\)<\/h3>[\s\S]*?<\/div>/)?.[0] || '';
|
||||
const match =
|
||||
snapshotSection.match(/macOS \(Apple Silicon\/arm64\)[\s\S]*?<b>Release:\s*<\/b>\s*([A-Za-z0-9.-]+)/) ||
|
||||
snapshotSection.match(/macOS \(Apple Silicon\/arm64\)[\s\S]*?Release:\s*([A-Za-z0-9.-]+)/);
|
||||
return match?.[1];
|
||||
card.match(/<b>Release:\s*<\/b>\s*([A-Za-z0-9.-]+)/) ||
|
||||
card.match(/Release:\s*([A-Za-z0-9.-]+)/);
|
||||
const url = card.match(/href="([^"]+\/ffmpeg\.zip)"/)?.[1];
|
||||
return match?.[1]
|
||||
? { version: match[1], url: url ? new URL(url, 'https://ffmpeg.martin-riedl.de').href : undefined }
|
||||
: undefined;
|
||||
}
|
||||
|
||||
async function latestBtbnFfmpegN81Build() {
|
||||
const releases = await fetchJson('https://api.github.com/repos/BtbN/FFmpeg-Builds/releases?per_page=10');
|
||||
for (const release of releases) {
|
||||
if (release.tag_name === 'latest') continue;
|
||||
const versions = (release.assets || [])
|
||||
.map(asset => asset.name.match(/^ffmpeg-n(8\.1\.\d+-\d+-g[0-9a-f]+)-(?:win64-gpl-8\.1\.zip|linux64-gpl-8\.1\.tar\.xz)$/)?.[1])
|
||||
const assets = (release.assets || [])
|
||||
.map(asset => {
|
||||
const match = asset.name.match(/^ffmpeg-n(8\.1\.\d+-\d+-g[0-9a-f]+)-(win64|linux64)-gpl-8\.1\.(?:zip|tar\.xz)$/);
|
||||
if (!match) return undefined;
|
||||
return {
|
||||
target: match[2] === 'win64' ? 'windows' : 'linux',
|
||||
version: match[1],
|
||||
url: asset.browser_download_url,
|
||||
};
|
||||
})
|
||||
.filter(Boolean);
|
||||
const unique = [...new Set(versions)];
|
||||
if (unique.length === 1 && versions.length >= 2) {
|
||||
return unique[0];
|
||||
const unique = [...new Set(assets.map(asset => asset.version))];
|
||||
const byTarget = Object.fromEntries(assets.map(asset => [asset.target, asset]));
|
||||
if (unique.length === 1 && byTarget.windows && byTarget.linux) {
|
||||
return {
|
||||
version: unique[0],
|
||||
urls: { windows: byTarget.windows.url, linux: byTarget.linux.url },
|
||||
};
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
@@ -131,22 +147,26 @@ function packagedEngineVersions(engineLock) {
|
||||
const rows = [];
|
||||
for (const [target, targetLock] of Object.entries(engineLock.targets || {})) {
|
||||
for (const [engine, meta] of Object.entries(targetLock.engines || {})) {
|
||||
rows.push({ target, engine, version: meta.version });
|
||||
rows.push({ target, engine, version: meta.version, url: meta.url });
|
||||
}
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
function checkRows(rows, latestByEngine, latestByTargetEngine = {}) {
|
||||
function checkRows(rows, latestByEngine, latestByTargetEngine = {}, latestUrlsByTargetEngine = {}) {
|
||||
let outdated = 0;
|
||||
for (const row of rows) {
|
||||
const latest = latestByTargetEngine[`${row.target}:${row.engine}`] || latestByEngine[row.engine];
|
||||
if (!latest) continue;
|
||||
const current = normalizeVersion(row.version);
|
||||
const wanted = normalizeVersion(latest);
|
||||
const status = compareVersions(current, wanted) < 0 ? 'outdated' : 'current';
|
||||
if (status === 'outdated') outdated += 1;
|
||||
const latestUrl = latestUrlsByTargetEngine[`${row.target}:${row.engine}`];
|
||||
const versionOutdated = compareVersions(current, wanted) < 0;
|
||||
const sourceOutdated = Boolean(latestUrl && row.url && row.url !== latestUrl);
|
||||
const status = versionOutdated ? 'outdated' : sourceOutdated ? 'source-outdated' : 'current';
|
||||
if (status !== 'current') outdated += 1;
|
||||
console.log(` ${row.target} ${row.engine}: ${current} -> ${wanted} ${status}`);
|
||||
if (sourceOutdated) console.log(` source: ${row.url} -> ${latestUrl}`);
|
||||
}
|
||||
return outdated;
|
||||
}
|
||||
@@ -184,9 +204,14 @@ async function main() {
|
||||
ffmpeg,
|
||||
};
|
||||
const latestByTargetEngine = {
|
||||
'x86_64-pc-windows-msvc:ffmpeg': btbnFfmpegN81Build || ffmpeg,
|
||||
'x86_64-unknown-linux-gnu:ffmpeg': btbnFfmpegN81Build || ffmpeg,
|
||||
'aarch64-apple-darwin:ffmpeg': martinRiedlMacArm64Snapshot || martinRiedlMacArm64Ffmpeg,
|
||||
'x86_64-pc-windows-msvc:ffmpeg': btbnFfmpegN81Build?.version || ffmpeg,
|
||||
'x86_64-unknown-linux-gnu:ffmpeg': btbnFfmpegN81Build?.version || ffmpeg,
|
||||
'aarch64-apple-darwin:ffmpeg': martinRiedlMacArm64Snapshot?.version || martinRiedlMacArm64Ffmpeg,
|
||||
};
|
||||
const latestUrlsByTargetEngine = {
|
||||
'x86_64-pc-windows-msvc:ffmpeg': btbnFfmpegN81Build?.urls.windows,
|
||||
'x86_64-unknown-linux-gnu:ffmpeg': btbnFfmpegN81Build?.urls.linux,
|
||||
'aarch64-apple-darwin:ffmpeg': martinRiedlMacArm64Snapshot?.url,
|
||||
};
|
||||
|
||||
console.log('\nlatest engines:');
|
||||
@@ -194,21 +219,23 @@ async function main() {
|
||||
console.log(` ${engine}: ${normalizeVersion(version)}`);
|
||||
}
|
||||
console.log('\nlatest engine provider builds:');
|
||||
console.log(` BtbN FFmpeg n8.1 Windows/Linux: ${normalizeVersion(btbnFfmpegN81Build || ffmpeg)}`);
|
||||
console.log(` Martin Riedl FFmpeg macOS arm64 snapshot: ${normalizeVersion(martinRiedlMacArm64Snapshot || martinRiedlMacArm64Ffmpeg)}`);
|
||||
console.log(` BtbN FFmpeg n8.1 Windows/Linux: ${normalizeVersion(btbnFfmpegN81Build?.version || ffmpeg)}`);
|
||||
console.log(` Martin Riedl FFmpeg macOS arm64 snapshot: ${normalizeVersion(martinRiedlMacArm64Snapshot?.version || martinRiedlMacArm64Ffmpeg)}`);
|
||||
|
||||
console.log('\nengine source lock:');
|
||||
outdatedCount += checkRows(
|
||||
sourceEngineVersions(parseJsonFile('engine-sources.lock.json')),
|
||||
latestByEngine,
|
||||
latestByTargetEngine
|
||||
latestByTargetEngine,
|
||||
latestUrlsByTargetEngine
|
||||
);
|
||||
|
||||
console.log('\npackaged engine lock:');
|
||||
outdatedCount += checkRows(
|
||||
packagedEngineVersions(parseJsonFile('engines.lock.json')),
|
||||
latestByEngine,
|
||||
latestByTargetEngine
|
||||
latestByTargetEngine,
|
||||
latestUrlsByTargetEngine
|
||||
);
|
||||
|
||||
if (outdatedCount > 0) {
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { Readable, Transform } from 'node:stream';
|
||||
import { pipeline } from 'node:stream/promises';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { collectRegularFiles, sha256 } from './engine-payload-integrity.js';
|
||||
@@ -35,6 +37,44 @@ const destination = path.join(repoRoot, 'src-tauri', 'provisioned-engines', targ
|
||||
const temporary = fs.mkdtempSync(path.join(os.tmpdir(), `firelink-engines-${target}-`));
|
||||
const isWindows = target.includes('windows');
|
||||
const executableSuffix = isWindows ? '.exe' : '';
|
||||
const DOWNLOAD_ATTEMPTS = 3;
|
||||
const DOWNLOAD_IDLE_TIMEOUT_MS = 120_000;
|
||||
const DOWNLOAD_RETRY_DELAYS_MS = [2_000, 5_000];
|
||||
const FILE_LOCK_RETRY_DELAYS_MS = [100, 250, 500, 1_000, 2_000];
|
||||
|
||||
function sleep(milliseconds) {
|
||||
return new Promise(resolve => setTimeout(resolve, milliseconds));
|
||||
}
|
||||
|
||||
async function removeFileWithRetry(file) {
|
||||
for (let attempt = 0; ; attempt += 1) {
|
||||
try {
|
||||
fs.rmSync(file, { force: true });
|
||||
return;
|
||||
} catch (error) {
|
||||
const retryable = process.platform === 'win32'
|
||||
&& ['EACCES', 'EBUSY', 'EPERM'].includes(error?.code);
|
||||
if (!retryable || attempt >= FILE_LOCK_RETRY_DELAYS_MS.length) {
|
||||
throw error;
|
||||
}
|
||||
await sleep(FILE_LOCK_RETRY_DELAYS_MS[attempt]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function createDownloadTimeout() {
|
||||
const controller = new AbortController();
|
||||
let timer;
|
||||
const refresh = () => {
|
||||
clearTimeout(timer);
|
||||
timer = setTimeout(() => {
|
||||
controller.abort(new Error(`Download idle for ${DOWNLOAD_IDLE_TIMEOUT_MS}ms`));
|
||||
}, DOWNLOAD_IDLE_TIMEOUT_MS);
|
||||
};
|
||||
const dispose = () => clearTimeout(timer);
|
||||
refresh();
|
||||
return { signal: controller.signal, refresh, dispose };
|
||||
}
|
||||
|
||||
async function download(name, source) {
|
||||
const sourcePath = new URL(source.url).pathname;
|
||||
@@ -42,20 +82,51 @@ async function download(name, source) {
|
||||
temporary,
|
||||
`${name}${sourcePath.endsWith('.tar.xz') ? '.tar.xz' : '.zip'}`
|
||||
);
|
||||
const response = await fetch(source.url, { redirect: 'follow' });
|
||||
if (!response.ok || !response.body) {
|
||||
throw new Error(`Failed to download ${name}: HTTP ${response.status}`);
|
||||
}
|
||||
const output = fs.createWriteStream(archive);
|
||||
const reader = response.body.getReader();
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
if (!output.write(Buffer.from(value))) {
|
||||
await new Promise(resolve => output.once('drain', resolve));
|
||||
|
||||
let lastError;
|
||||
for (let attempt = 1; attempt <= DOWNLOAD_ATTEMPTS; attempt += 1) {
|
||||
const downloadTimeout = createDownloadTimeout();
|
||||
try {
|
||||
const response = await fetch(source.url, {
|
||||
redirect: 'follow',
|
||||
signal: downloadTimeout.signal,
|
||||
});
|
||||
if (!response.ok || !response.body) {
|
||||
throw new Error(`Failed to download ${name}: HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
await pipeline(
|
||||
Readable.fromWeb(response.body),
|
||||
new Transform({
|
||||
transform(chunk, encoding, callback) {
|
||||
downloadTimeout.refresh();
|
||||
callback(null, chunk, encoding);
|
||||
},
|
||||
}),
|
||||
fs.createWriteStream(archive),
|
||||
{ signal: downloadTimeout.signal }
|
||||
);
|
||||
break;
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
await removeFileWithRetry(archive);
|
||||
if (attempt === DOWNLOAD_ATTEMPTS) {
|
||||
throw new Error(
|
||||
`Failed to download ${name} after ${DOWNLOAD_ATTEMPTS} attempts: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
{ cause: error }
|
||||
);
|
||||
}
|
||||
await sleep(DOWNLOAD_RETRY_DELAYS_MS[attempt - 1]);
|
||||
} finally {
|
||||
downloadTimeout.dispose();
|
||||
}
|
||||
}
|
||||
await new Promise(resolve => output.end(resolve));
|
||||
|
||||
if (lastError && !fs.existsSync(archive)) {
|
||||
throw lastError;
|
||||
}
|
||||
|
||||
const actual = sha256(archive);
|
||||
if (actual !== source.sha256) {
|
||||
|
||||
+217
-25
@@ -1,5 +1,6 @@
|
||||
#!/usr/bin/env node
|
||||
import { execFileSync, spawn } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
function argValue(name) {
|
||||
@@ -15,6 +16,8 @@ if (!executableArg) {
|
||||
|
||||
const executable = path.resolve(executableArg);
|
||||
const assertNoVisibleChildWindows = process.argv.includes('--assert-no-visible-child-windows');
|
||||
const assertPortableData = process.argv.includes('--assert-portable-data');
|
||||
const READY_PORT_TIMEOUT_MS = 500;
|
||||
const child = spawn(executable, [], {
|
||||
cwd: process.env.RUNNER_TEMP || process.env.TMPDIR || process.cwd(),
|
||||
detached: process.platform !== 'win32',
|
||||
@@ -30,12 +33,14 @@ const child = spawn(executable, [], {
|
||||
let stderr = '';
|
||||
let spawnError = null;
|
||||
let readyPort = null;
|
||||
let childExit = null;
|
||||
|
||||
child.on('error', error => {
|
||||
spawnError = error;
|
||||
});
|
||||
|
||||
child.on('exit', (code, signal) => {
|
||||
childExit = { code, signal };
|
||||
if (readyPort === null) {
|
||||
console.error(`Child exited prematurely with code ${code} signal ${signal}`);
|
||||
}
|
||||
@@ -44,6 +49,7 @@ child.on('exit', (code, signal) => {
|
||||
child.stderr.on('data', data => {
|
||||
stderr += data.toString();
|
||||
});
|
||||
child.stdout.on('data', () => {});
|
||||
|
||||
function sleep(ms) {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
@@ -51,19 +57,25 @@ function sleep(ms) {
|
||||
|
||||
async function findReadyPort() {
|
||||
for (let attempt = 0; attempt < 200 && readyPort === null; attempt += 1) {
|
||||
if (spawnError) {
|
||||
if (spawnError || childExit) {
|
||||
break;
|
||||
}
|
||||
|
||||
for (let port = 6412; port <= 6422; port += 1) {
|
||||
const ports = Array.from({ length: 11 }, (_, index) => 6412 + index);
|
||||
const matches = await Promise.all(ports.map(async port => {
|
||||
try {
|
||||
const response = await fetch(`http://127.0.0.1:${port}/ping`);
|
||||
if (response.headers.get('x-firelink-server') === '1') {
|
||||
readyPort = port;
|
||||
break;
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
const response = await fetch(`http://127.0.0.1:${port}/ping`, {
|
||||
signal: AbortSignal.timeout(READY_PORT_TIMEOUT_MS),
|
||||
});
|
||||
const matchesChild = response.headers.get('x-firelink-server') === '1'
|
||||
&& response.headers.get('x-firelink-smoke-process-id') === String(child.pid);
|
||||
await response.body?.cancel();
|
||||
return matchesChild ? port : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}));
|
||||
readyPort = matches.find(port => port !== null) ?? null;
|
||||
|
||||
if (readyPort === null) {
|
||||
await sleep(250);
|
||||
@@ -120,43 +132,223 @@ if ($visible.Count -gt 0) {
|
||||
}
|
||||
}
|
||||
|
||||
function terminateChild() {
|
||||
if (!child.pid) {
|
||||
return;
|
||||
function waitForChildExit(timeoutMs) {
|
||||
if (childExit) {
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
|
||||
return new Promise(resolve => {
|
||||
let timer;
|
||||
const onExit = () => {
|
||||
clearTimeout(timer);
|
||||
child.off('exit', onExit);
|
||||
resolve(true);
|
||||
};
|
||||
timer = setTimeout(() => {
|
||||
child.off('exit', onExit);
|
||||
resolve(false);
|
||||
}, timeoutMs);
|
||||
child.once('exit', onExit);
|
||||
});
|
||||
}
|
||||
|
||||
function isProcessGroupAlive(rootPid) {
|
||||
if (process.platform === 'win32') {
|
||||
spawn('taskkill', ['/pid', String(child.pid), '/t', '/f'], {
|
||||
stdio: 'ignore',
|
||||
windowsHide: true,
|
||||
});
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
process.kill(-child.pid, 'SIGTERM');
|
||||
} catch {
|
||||
child.kill('SIGTERM');
|
||||
process.kill(-rootPid, 0);
|
||||
return true;
|
||||
} catch (error) {
|
||||
return error?.code !== 'ESRCH';
|
||||
}
|
||||
}
|
||||
|
||||
await findReadyPort();
|
||||
async function waitForProcessGroupExit(rootPid, timeoutMs) {
|
||||
if (process.platform === 'win32') {
|
||||
return true;
|
||||
}
|
||||
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
if (!isProcessGroupAlive(rootPid)) {
|
||||
return true;
|
||||
}
|
||||
await sleep(100);
|
||||
}
|
||||
|
||||
return !isProcessGroupAlive(rootPid);
|
||||
}
|
||||
|
||||
function windowsBundleRoot() {
|
||||
return path.dirname(executable).replaceAll("'", "''");
|
||||
}
|
||||
|
||||
function windowsBundleProcessIds() {
|
||||
const root = windowsBundleRoot();
|
||||
const script = `
|
||||
$root = '${root}'
|
||||
$rootPrefix = $root.TrimEnd('\\') + '\\'
|
||||
Get-CimInstance Win32_Process |
|
||||
Where-Object {
|
||||
$commandLine = $_.CommandLine
|
||||
$commandLine -and $commandLine.TrimStart([char]34).StartsWith($rootPrefix, [System.StringComparison]::OrdinalIgnoreCase)
|
||||
} |
|
||||
Select-Object -ExpandProperty ProcessId
|
||||
`;
|
||||
try {
|
||||
const output = execFileSync('powershell', ['-NoProfile', '-NonInteractive', '-Command', script], {
|
||||
encoding: 'utf8',
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
windowsHide: true,
|
||||
});
|
||||
return output
|
||||
.split(/\r?\n/)
|
||||
.map(value => Number.parseInt(value.trim(), 10))
|
||||
.filter(Number.isInteger);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function terminateWindowsBundleProcesses() {
|
||||
const root = windowsBundleRoot();
|
||||
const script = `
|
||||
$root = '${root}'
|
||||
$rootPrefix = $root.TrimEnd('\\') + '\\'
|
||||
Get-CimInstance Win32_Process |
|
||||
Where-Object {
|
||||
$commandLine = $_.CommandLine
|
||||
$commandLine -and $commandLine.TrimStart([char]34).StartsWith($rootPrefix, [System.StringComparison]::OrdinalIgnoreCase)
|
||||
} |
|
||||
ForEach-Object { Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue }
|
||||
`;
|
||||
try {
|
||||
execFileSync('powershell', ['-NoProfile', '-NonInteractive', '-Command', script], {
|
||||
stdio: 'ignore',
|
||||
windowsHide: true,
|
||||
});
|
||||
} catch {}
|
||||
}
|
||||
|
||||
async function waitForWindowsBundleExit(timeoutMs) {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
const processIds = windowsBundleProcessIds();
|
||||
if (processIds?.length === 0) {
|
||||
return true;
|
||||
}
|
||||
await sleep(100);
|
||||
}
|
||||
return windowsBundleProcessIds()?.length === 0;
|
||||
}
|
||||
|
||||
async function terminateChild() {
|
||||
if (!child.pid) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const childWasRunning = !childExit;
|
||||
if (process.platform === 'win32') {
|
||||
if (childWasRunning) {
|
||||
try {
|
||||
execFileSync('taskkill', ['/pid', String(child.pid), '/t', '/f'], {
|
||||
stdio: 'ignore',
|
||||
windowsHide: true,
|
||||
});
|
||||
} catch {}
|
||||
}
|
||||
const childExited = await waitForChildExit(10000);
|
||||
const bundleExited = await waitForWindowsBundleExit(5000);
|
||||
if (childExited && bundleExited) {
|
||||
return true;
|
||||
}
|
||||
terminateWindowsBundleProcesses();
|
||||
return await waitForChildExit(5000) && await waitForWindowsBundleExit(5000);
|
||||
}
|
||||
|
||||
if (childWasRunning && !childExit) {
|
||||
try {
|
||||
process.kill(-child.pid, 'SIGTERM');
|
||||
} catch {
|
||||
if (!childExit) {
|
||||
child.kill('SIGTERM');
|
||||
}
|
||||
}
|
||||
}
|
||||
if (await waitForChildExit(5000) && await waitForProcessGroupExit(child.pid, 5000)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!childWasRunning || childExit) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
process.kill(-child.pid, 'SIGKILL');
|
||||
} catch {
|
||||
if (!childExit) {
|
||||
child.kill('SIGKILL');
|
||||
}
|
||||
}
|
||||
return await waitForChildExit(5000) && await waitForProcessGroupExit(child.pid, 5000);
|
||||
}
|
||||
|
||||
async function assertPortableStorage() {
|
||||
const portableRoot = path.dirname(executable);
|
||||
const marker = path.join(portableRoot, 'portable.flag');
|
||||
const database = path.join(portableRoot, 'data', 'firelink.sqlite');
|
||||
const webviewData = path.join(portableRoot, 'data', 'webview');
|
||||
|
||||
for (let attempt = 0; attempt < 40; attempt += 1) {
|
||||
const markerReady = fs.statSync(marker, { throwIfNoEntry: false })?.isFile();
|
||||
const databaseReady = fs.statSync(database, { throwIfNoEntry: false })?.isFile();
|
||||
const webviewReady = fs.statSync(webviewData, { throwIfNoEntry: false })?.isDirectory();
|
||||
if (markerReady && databaseReady && webviewReady) {
|
||||
return;
|
||||
}
|
||||
await sleep(250);
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`Portable storage was not ready: marker=${marker}, database=${database}, webview=${webviewData}`,
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
await findReadyPort();
|
||||
|
||||
if (readyPort === null) {
|
||||
if (spawnError) {
|
||||
console.error(`Packaged Firelink failed to start: ${spawnError.message}`);
|
||||
throw new Error(`Packaged Firelink failed to start: ${spawnError.message}`);
|
||||
} else if (childExit) {
|
||||
throw new Error(
|
||||
`Packaged Firelink exited before exposing extension ping endpoint with code ${childExit.code} signal ${childExit.signal}.`,
|
||||
);
|
||||
} else {
|
||||
console.error(`Packaged Firelink did not expose extension ping endpoint. Stderr:\n${stderr.slice(-1000)}`);
|
||||
throw new Error(`Packaged Firelink did not expose extension ping endpoint. Stderr:\n${stderr.slice(-1000)}`);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (assertNoVisibleChildWindows) {
|
||||
assertNoVisibleWindows(child.pid);
|
||||
}
|
||||
if (assertPortableData) {
|
||||
await assertPortableStorage();
|
||||
}
|
||||
|
||||
if (childExit) {
|
||||
throw new Error(`Packaged Firelink exited after exposing extension ping endpoint with code ${childExit.code} signal ${childExit.signal}.`);
|
||||
}
|
||||
|
||||
console.log(`Packaged Firelink smoke passed on 127.0.0.1:${readyPort}`);
|
||||
} catch (error) {
|
||||
console.error(error instanceof Error ? error.message : String(error));
|
||||
process.exitCode = 1;
|
||||
} finally {
|
||||
terminateChild();
|
||||
if (!await terminateChild()) {
|
||||
console.error('Packaged Firelink could not be terminated cleanly; refusing to report smoke success.');
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
|
||||
+203
-52
@@ -2,6 +2,7 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import os from 'node:os';
|
||||
import net from 'node:net';
|
||||
import { execFileSync, spawn } from 'node:child_process';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
@@ -44,16 +45,34 @@ const searchRoot = argValue('--search-root');
|
||||
function findEngineRoot(root) {
|
||||
const expected = `yt-dlp-${targetTriple}${ext}`;
|
||||
const matches = [];
|
||||
const resolvedRoot = path.resolve(root);
|
||||
const hasExpectedEngineFile = directory => {
|
||||
try {
|
||||
return fs.lstatSync(path.join(directory, expected)).isFile();
|
||||
} catch (error) {
|
||||
if (error?.code === 'ENOENT') return false;
|
||||
throw new Error(`Unable to inspect packaged engine root '${directory}': ${error.message}`);
|
||||
}
|
||||
};
|
||||
if (hasExpectedEngineFile(resolvedRoot)) {
|
||||
matches.push(resolvedRoot);
|
||||
}
|
||||
const walk = directory => {
|
||||
for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
|
||||
let entries;
|
||||
try {
|
||||
entries = fs.readdirSync(directory, { withFileTypes: true });
|
||||
} catch (error) {
|
||||
throw new Error(`Unable to inspect packaged engine directory '${directory}': ${error.message}`);
|
||||
}
|
||||
for (const entry of entries) {
|
||||
const candidate = path.join(directory, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
if (fs.existsSync(path.join(candidate, expected))) matches.push(candidate);
|
||||
if (hasExpectedEngineFile(candidate)) matches.push(candidate);
|
||||
walk(candidate);
|
||||
}
|
||||
}
|
||||
};
|
||||
walk(path.resolve(root));
|
||||
walk(resolvedRoot);
|
||||
if (matches.length !== 1) {
|
||||
throw new Error(`Expected exactly one packaged engine root under ${root}, found ${matches.length}`);
|
||||
}
|
||||
@@ -317,6 +336,82 @@ function runEngine(label, engine, args, timeout = 30000) {
|
||||
}
|
||||
}
|
||||
|
||||
function waitForProcessExit(proc, timeoutMs) {
|
||||
if (proc.exitCode !== null || proc.signalCode !== null) {
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
|
||||
return new Promise(resolve => {
|
||||
let settled = false;
|
||||
let timer;
|
||||
const finish = value => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
proc.removeListener('exit', onExit);
|
||||
resolve(value);
|
||||
};
|
||||
const onExit = () => finish(true);
|
||||
|
||||
timer = setTimeout(() => finish(false), timeoutMs);
|
||||
proc.once('exit', onExit);
|
||||
if (proc.exitCode !== null || proc.signalCode !== null) {
|
||||
finish(true);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function terminateProcess(proc, label) {
|
||||
if (proc.exitCode === null && proc.signalCode === null) {
|
||||
try {
|
||||
proc.kill('SIGTERM');
|
||||
} catch {}
|
||||
if (await waitForProcessExit(proc, 2000)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
proc.kill('SIGKILL');
|
||||
} catch {}
|
||||
if (await waitForProcessExit(proc, 2000)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
fail(`${label} did not terminate after SIGTERM and SIGKILL.`);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function findAvailablePort() {
|
||||
return new Promise((resolve, reject) => {
|
||||
const server = net.createServer();
|
||||
server.once('error', reject);
|
||||
server.listen({ host: '127.0.0.1', port: 0 }, () => {
|
||||
const address = server.address();
|
||||
if (!address || typeof address === 'string') {
|
||||
server.close();
|
||||
reject(new Error('Could not determine the verifier RPC port.'));
|
||||
return;
|
||||
}
|
||||
|
||||
const port = address.port;
|
||||
server.close(error => {
|
||||
if (error) {
|
||||
reject(error);
|
||||
} else {
|
||||
resolve(port);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function isPortBindingFailure(message) {
|
||||
return /address already in use|failed to bind|could not bind|listen failed/i.test(message);
|
||||
}
|
||||
|
||||
const coldStartTimeout = isMacOS ? 120000 : 30000;
|
||||
if (canExecuteTarget) {
|
||||
runEngine('yt-dlp cold start', 'yt-dlp', ['--version'], coldStartTimeout);
|
||||
@@ -345,25 +440,6 @@ if (canExecuteTarget) {
|
||||
return;
|
||||
}
|
||||
|
||||
const port = 16801 + (process.pid % 1000);
|
||||
const proc = spawn(p, [
|
||||
'--enable-rpc',
|
||||
`--rpc-listen-port=${port}`,
|
||||
'--rpc-max-request-size=1K',
|
||||
'--quiet',
|
||||
'--console-log-level=error',
|
||||
'--rpc-listen-all=false',
|
||||
], {
|
||||
env: engineEnv('aria2c'),
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
timeout: 15000,
|
||||
});
|
||||
|
||||
let rpcStderr = '';
|
||||
proc.stderr.on('data', (d) => {
|
||||
rpcStderr += d.toString();
|
||||
});
|
||||
|
||||
const body = JSON.stringify({
|
||||
jsonrpc: '2.0',
|
||||
id: 'firelink-verify',
|
||||
@@ -371,39 +447,114 @@ if (canExecuteTarget) {
|
||||
params: [],
|
||||
});
|
||||
|
||||
const result = await new Promise((resolve) => {
|
||||
const maxAttempts = 20;
|
||||
let attempts = 0;
|
||||
|
||||
function tryFetch() {
|
||||
attempts++;
|
||||
fetch(`http://127.0.0.1:${port}/jsonrpc`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body,
|
||||
})
|
||||
.then(async (res) => {
|
||||
resolve({ ok: true, data: await res.text() });
|
||||
})
|
||||
.catch(() => {
|
||||
if (attempts >= maxAttempts) {
|
||||
resolve({ ok: false, error: `RPC not ready after ${maxAttempts} attempts` });
|
||||
return;
|
||||
}
|
||||
setTimeout(tryFetch, 300);
|
||||
});
|
||||
let result = { ok: false, error: 'RPC test did not run.' };
|
||||
let rpcStderr = '';
|
||||
const maxPortAttempts = 3;
|
||||
for (let portAttempt = 0; portAttempt < maxPortAttempts; portAttempt += 1) {
|
||||
let port;
|
||||
try {
|
||||
port = await findAvailablePort();
|
||||
} catch (error) {
|
||||
result = { ok: false, error: `Could not reserve an RPC port: ${error.message}` };
|
||||
break;
|
||||
}
|
||||
|
||||
tryFetch();
|
||||
});
|
||||
const proc = spawn(p, [
|
||||
'--enable-rpc',
|
||||
`--rpc-listen-port=${port}`,
|
||||
'--rpc-max-request-size=1K',
|
||||
'--quiet',
|
||||
'--console-log-level=error',
|
||||
'--rpc-listen-all=false',
|
||||
], {
|
||||
env: engineEnv('aria2c'),
|
||||
stdio: ['ignore', 'ignore', 'pipe'],
|
||||
timeout: 15000,
|
||||
});
|
||||
|
||||
// Clean up
|
||||
proc.kill('SIGTERM');
|
||||
setTimeout(() => {
|
||||
try {
|
||||
proc.kill('SIGKILL');
|
||||
} catch {}
|
||||
}, 2000);
|
||||
let spawnError = null;
|
||||
let childExit = null;
|
||||
let attemptStderr = '';
|
||||
proc.once('error', error => {
|
||||
spawnError = error;
|
||||
});
|
||||
proc.once('exit', (code, signal) => {
|
||||
childExit = { code, signal };
|
||||
});
|
||||
proc.stderr.on('data', data => {
|
||||
attemptStderr += data.toString();
|
||||
});
|
||||
|
||||
const attemptResult = await new Promise(resolve => {
|
||||
const maxAttempts = 20;
|
||||
let attempts = 0;
|
||||
|
||||
function resolveProcessFailure() {
|
||||
if (spawnError) {
|
||||
resolve({ ok: false, error: `aria2c failed to spawn: ${spawnError.message}` });
|
||||
} else if (childExit) {
|
||||
resolve({
|
||||
ok: false,
|
||||
error: `aria2c exited before RPC became ready with code ${childExit.code} signal ${childExit.signal}.`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function tryFetch() {
|
||||
if (spawnError || childExit) {
|
||||
resolveProcessFailure();
|
||||
return;
|
||||
}
|
||||
|
||||
attempts += 1;
|
||||
fetch(`http://127.0.0.1:${port}/jsonrpc`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body,
|
||||
})
|
||||
.then(async response => {
|
||||
resolve({ ok: true, data: await response.text() });
|
||||
})
|
||||
.catch(() => {
|
||||
if (spawnError || childExit) {
|
||||
resolveProcessFailure();
|
||||
} else if (attempts >= maxAttempts) {
|
||||
resolve({ ok: false, error: `RPC not ready after ${maxAttempts} attempts` });
|
||||
} else {
|
||||
setTimeout(tryFetch, 300);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
tryFetch();
|
||||
});
|
||||
|
||||
const exitedBeforeCleanup = childExit;
|
||||
const terminated = proc.pid
|
||||
? await terminateProcess(proc, 'aria2 RPC verifier process')
|
||||
: true;
|
||||
rpcStderr = attemptStderr;
|
||||
result = attemptResult;
|
||||
|
||||
if (
|
||||
result.ok
|
||||
&& exitedBeforeCleanup
|
||||
&& (exitedBeforeCleanup.code !== 0 || exitedBeforeCleanup.signal !== null)
|
||||
) {
|
||||
result = {
|
||||
ok: false,
|
||||
error: `aria2c exited after responding with code ${exitedBeforeCleanup.code} signal ${exitedBeforeCleanup.signal}.`,
|
||||
};
|
||||
}
|
||||
|
||||
if (!terminated || result.ok || !isPortBindingFailure(`${result.error || ''}\n${rpcStderr}`)) {
|
||||
break;
|
||||
}
|
||||
|
||||
if (portAttempt + 1 < maxPortAttempts) {
|
||||
console.log(`[INFO] RPC port ${port} became unavailable; retrying with a new port.`);
|
||||
}
|
||||
}
|
||||
|
||||
if (result.ok) {
|
||||
try {
|
||||
|
||||
@@ -96,6 +96,15 @@ export function parseDebianPackagePath(line) {
|
||||
return match[1].replace(/^\.\//, '');
|
||||
}
|
||||
|
||||
export function isSafePackagePath(packagePath) {
|
||||
if (packagePath === '') {
|
||||
return true;
|
||||
}
|
||||
|
||||
const parts = packagePath.split('/');
|
||||
return !parts.includes('..') && (parts[0] === 'usr' || packagePath === 'usr');
|
||||
}
|
||||
|
||||
function assertSafePackageListing(listing, packageType) {
|
||||
const lines = listing.split('\n').filter(Boolean);
|
||||
const paths = packageType === 'deb'
|
||||
@@ -109,8 +118,7 @@ function assertSafePackageListing(listing, packageType) {
|
||||
: lines.map(line => line.replace(/^\/+/, ''));
|
||||
|
||||
for (const packagePath of paths) {
|
||||
const parts = packagePath.split('/');
|
||||
if (parts.includes('..') || (parts[0] !== 'usr' && packagePath !== 'usr')) {
|
||||
if (!isSafePackagePath(packagePath)) {
|
||||
fail(`${packageType} package contains an unsafe path: ${packagePath}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { parseDebianPackagePath } from './verify-linux-packages.js';
|
||||
import { isSafePackagePath, parseDebianPackagePath } from './verify-linux-packages.js';
|
||||
|
||||
test('parses current dpkg-deb listings without a ./ prefix', () => {
|
||||
assert.equal(
|
||||
@@ -17,6 +17,19 @@ test('parses legacy dpkg-deb listings with a ./ prefix', () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('accepts the package root in legacy dpkg-deb listings', () => {
|
||||
assert.equal(
|
||||
parseDebianPackagePath('drwxr-xr-x root/root 0 2026-07-12 07:24 ./'),
|
||||
''
|
||||
);
|
||||
assert.equal(isSafePackagePath(''), true);
|
||||
});
|
||||
|
||||
test('rejects paths outside the package usr tree', () => {
|
||||
assert.equal(isSafePackagePath('../tmp/firelink'), false);
|
||||
assert.equal(isSafePackagePath('etc/firelink'), false);
|
||||
});
|
||||
|
||||
test('rejects malformed dpkg-deb listing lines', () => {
|
||||
assert.throws(
|
||||
() => parseDebianPackagePath('not a dpkg-deb listing'),
|
||||
|
||||
Generated
+147
-395
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "firelink"
|
||||
version = "1.0.4"
|
||||
version = "1.1.1"
|
||||
description = "A fast cross-platform desktop download manager powered by Rust and Tauri"
|
||||
authors = ["NimBold"]
|
||||
edition = "2021"
|
||||
@@ -32,7 +32,7 @@ serde_json = "1"
|
||||
tokio = { version = "1", features = ["fs", "process", "io-util", "rt", "rt-multi-thread", "macros", "sync", "time"] }
|
||||
regex = "1.10"
|
||||
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"] }
|
||||
rustls = { version = "0.23.42", 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"
|
||||
@@ -41,11 +41,11 @@ sysinfo = "0.39.3"
|
||||
hmac = "0.13"
|
||||
sha2 = "0.11"
|
||||
tauri-plugin-deep-link = "2"
|
||||
tauri-plugin-single-instance = { version = "2", features = ["deep-link"] }
|
||||
tauri-plugin-single-instance = { version = "2.4.3", features = ["deep-link"] }
|
||||
tempfile = "3"
|
||||
thiserror = "2.0.18"
|
||||
axum = "0.8.9"
|
||||
tower-http = { version = "0.7", features = ["cors"] }
|
||||
tower-http = { version = "0.7", features = ["cors", "limit"] }
|
||||
sysproxy = "0.3.0"
|
||||
semver = "1.0.28"
|
||||
keepawake = "0.6.0"
|
||||
@@ -56,12 +56,12 @@ chrono = "0.4.38"
|
||||
url = "2"
|
||||
rusqlite = { version = "0.40.1", features = ["bundled"] }
|
||||
log = "0.4.32"
|
||||
tauri-plugin-log = "2"
|
||||
tauri-plugin-log = "2.9.0"
|
||||
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"] }
|
||||
apple-native-keyring-store = { version = "1.0.1", features = ["keychain"] }
|
||||
objc = "0.2.7"
|
||||
|
||||
[target.'cfg(target_os = "windows")'.dependencies]
|
||||
|
||||
Binary file not shown.
Binary file not shown.
+49
-14
@@ -61,9 +61,8 @@ fn known_download_paths(app_handle: &tauri::AppHandle) -> Result<Vec<PathBuf>, S
|
||||
}
|
||||
|
||||
fn authorize_exact_path(requested: &Path, allowed_paths: &[PathBuf]) -> Result<PathBuf, String> {
|
||||
if std::fs::symlink_metadata(requested).is_ok_and(|metadata| metadata.file_type().is_symlink())
|
||||
{
|
||||
return Err("Download path may not be a symlink".to_string());
|
||||
if crate::path_has_symlink_component(requested) {
|
||||
return Err("Download path may not contain symlink components".to_string());
|
||||
}
|
||||
|
||||
let requested = canonicalize_with_missing_leaf(requested)?;
|
||||
@@ -74,8 +73,9 @@ fn authorize_exact_path(requested: &Path, allowed_paths: &[PathBuf]) -> Result<P
|
||||
}
|
||||
|
||||
let authorized = allowed_paths.iter().any(|allowed| {
|
||||
canonicalize_with_missing_leaf(allowed)
|
||||
.is_ok_and(|canonical_allowed| canonical_allowed == requested)
|
||||
!crate::path_has_symlink_component(allowed)
|
||||
&& canonicalize_with_missing_leaf(allowed)
|
||||
.is_ok_and(|canonical_allowed| canonical_allowed == requested)
|
||||
});
|
||||
|
||||
if authorized {
|
||||
@@ -98,14 +98,30 @@ fn canonicalize_with_missing_leaf(path: &Path) -> Result<PathBuf, String> {
|
||||
|
||||
let mut existing = path;
|
||||
let mut missing = Vec::new();
|
||||
while !existing.exists() {
|
||||
let name = existing
|
||||
.file_name()
|
||||
.ok_or_else(|| "Download path has no existing ancestor".to_string())?;
|
||||
missing.push(name.to_owned());
|
||||
existing = existing
|
||||
.parent()
|
||||
.ok_or_else(|| "Download path has no existing ancestor".to_string())?;
|
||||
loop {
|
||||
match std::fs::symlink_metadata(existing) {
|
||||
Ok(metadata) => {
|
||||
if metadata.file_type().is_symlink() {
|
||||
return Err("Download path may not contain symlink components".to_string());
|
||||
}
|
||||
break;
|
||||
}
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
|
||||
let name = existing
|
||||
.file_name()
|
||||
.ok_or_else(|| "Download path has no existing ancestor".to_string())?;
|
||||
missing.push(name.to_owned());
|
||||
existing = existing
|
||||
.parent()
|
||||
.ok_or_else(|| "Download path has no existing ancestor".to_string())?;
|
||||
}
|
||||
Err(error) => {
|
||||
return Err(format!(
|
||||
"Failed to inspect download path '{}': {error}",
|
||||
path.display()
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut canonical = std::fs::canonicalize(existing)
|
||||
@@ -144,7 +160,8 @@ mod tests {
|
||||
#[test]
|
||||
fn authorizes_only_known_download_files_and_partials() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let owned = root.path().join("owned.bin");
|
||||
let root = fs::canonicalize(root.path()).unwrap();
|
||||
let owned = root.join("owned.bin");
|
||||
let outside = tempfile::NamedTempFile::new().unwrap();
|
||||
fs::write(&owned, b"download").unwrap();
|
||||
|
||||
@@ -197,4 +214,22 @@ mod tests {
|
||||
|
||||
assert!(authorize_exact_path(&escaped, std::slice::from_ref(&escaped)).is_err());
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn rejects_parent_directory_symlink_escape_from_download_location() {
|
||||
use std::os::unix::fs::symlink;
|
||||
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let root_path = fs::canonicalize(root.path()).unwrap();
|
||||
let outside = tempfile::tempdir().unwrap();
|
||||
let outside_file = outside.path().join("owned.bin");
|
||||
fs::write(&outside_file, b"outside").unwrap();
|
||||
|
||||
let redirected_parent = root_path.join("downloads");
|
||||
symlink(outside.path(), &redirected_parent).unwrap();
|
||||
let escaped = redirected_parent.join("owned.bin");
|
||||
|
||||
assert!(authorize_exact_path(&escaped, std::slice::from_ref(&escaped)).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
+1155
-192
File diff suppressed because it is too large
Load Diff
+301
-54
@@ -46,18 +46,29 @@ impl DownloadCoordinator {
|
||||
.map_err(|_| "download coordinator is unavailable".to_string())
|
||||
}
|
||||
|
||||
pub async fn register_media(&self, id: String) -> Result<watch::Receiver<bool>, String> {
|
||||
pub async fn register_media(
|
||||
&self,
|
||||
id: String,
|
||||
lifecycle_generation: u64,
|
||||
) -> Result<watch::Receiver<bool>, String> {
|
||||
let (cancel_tx, cancel_rx) = watch::channel(false);
|
||||
self.media_tx
|
||||
.send(MediaCmd::Register { id, cancel_tx })
|
||||
.send(MediaCmd::Register {
|
||||
id,
|
||||
lifecycle_generation,
|
||||
cancel_tx,
|
||||
})
|
||||
.await
|
||||
.map_err(|_| "download coordinator is unavailable".to_string())?;
|
||||
Ok(cancel_rx)
|
||||
}
|
||||
|
||||
pub async fn pause_media(&self, id: String) -> Result<(), String> {
|
||||
pub async fn pause_media(&self, id: String, lifecycle_generation: u64) -> Result<(), String> {
|
||||
self.media_tx
|
||||
.send(MediaCmd::Pause(id))
|
||||
.send(MediaCmd::Pause {
|
||||
id,
|
||||
lifecycle_generation,
|
||||
})
|
||||
.await
|
||||
.map_err(|_| "download coordinator is unavailable".to_string())
|
||||
}
|
||||
@@ -65,16 +76,27 @@ impl DownloadCoordinator {
|
||||
pub async fn pause_media_with_ack(
|
||||
&self,
|
||||
id: String,
|
||||
ack: tokio::sync::oneshot::Sender<()>,
|
||||
lifecycle_generation: u64,
|
||||
ack: tokio::sync::oneshot::Sender<bool>,
|
||||
) -> Result<(), String> {
|
||||
self.media_tx
|
||||
.send(MediaCmd::PauseWithAck(id, ack))
|
||||
.send(MediaCmd::PauseWithAck {
|
||||
id,
|
||||
lifecycle_generation,
|
||||
ack,
|
||||
})
|
||||
.await
|
||||
.map_err(|_| "download coordinator is unavailable".to_string())
|
||||
}
|
||||
|
||||
pub async fn finish_media(&self, id: String) {
|
||||
let _ = self.media_tx.send(MediaCmd::Finished(id)).await;
|
||||
pub async fn finish_media(&self, id: String, lifecycle_generation: u64) {
|
||||
let _ = self
|
||||
.media_tx
|
||||
.send(MediaCmd::Finished {
|
||||
id,
|
||||
lifecycle_generation,
|
||||
})
|
||||
.await;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -96,11 +118,22 @@ impl CoordinatorEventSink {
|
||||
enum MediaCmd {
|
||||
Register {
|
||||
id: String,
|
||||
lifecycle_generation: u64,
|
||||
cancel_tx: watch::Sender<bool>,
|
||||
},
|
||||
Pause(String),
|
||||
PauseWithAck(String, tokio::sync::oneshot::Sender<()>),
|
||||
Finished(String),
|
||||
Pause {
|
||||
id: String,
|
||||
lifecycle_generation: u64,
|
||||
},
|
||||
PauseWithAck {
|
||||
id: String,
|
||||
lifecycle_generation: u64,
|
||||
ack: tokio::sync::oneshot::Sender<bool>,
|
||||
},
|
||||
Finished {
|
||||
id: String,
|
||||
lifecycle_generation: u64,
|
||||
},
|
||||
}
|
||||
|
||||
async fn run_coordinator(
|
||||
@@ -108,74 +141,125 @@ async fn run_coordinator(
|
||||
mut command_rx: mpsc::Receiver<DownloadCmd>,
|
||||
mut media_rx: mpsc::Receiver<MediaCmd>,
|
||||
) {
|
||||
let mut active_media = HashMap::<String, watch::Sender<bool>>::new();
|
||||
let mut pending_media_acks = HashMap::<String, tokio::sync::oneshot::Sender<()>>::new();
|
||||
let mut active_media = HashMap::<String, (u64, watch::Sender<bool>)>::new();
|
||||
let mut cancelled_media_generations = HashMap::<String, u64>::new();
|
||||
let mut pending_media_acks =
|
||||
HashMap::<(String, u64), tokio::sync::oneshot::Sender<bool>>::new();
|
||||
let mut pending_captured_urls = Vec::<String>::new();
|
||||
let mut frontend_ready = false;
|
||||
let mut command_open = true;
|
||||
let mut media_open = true;
|
||||
|
||||
loop {
|
||||
while command_open || media_open {
|
||||
tokio::select! {
|
||||
command = command_rx.recv() => {
|
||||
let Some(command) = command else {
|
||||
break;
|
||||
};
|
||||
|
||||
command = command_rx.recv(), if command_open => {
|
||||
match command {
|
||||
DownloadCmd::CaptureUrls(urls) => {
|
||||
append_unique_urls(&mut pending_captured_urls, urls);
|
||||
if frontend_ready && !pending_captured_urls.is_empty() {
|
||||
let payload = pending_captured_urls.join("\n");
|
||||
if events.emit_captured_urls(payload) {
|
||||
pending_captured_urls.clear();
|
||||
Some(command) => match command {
|
||||
DownloadCmd::CaptureUrls(urls) => {
|
||||
append_unique_urls(&mut pending_captured_urls, urls);
|
||||
if frontend_ready && !pending_captured_urls.is_empty() {
|
||||
let payload = pending_captured_urls.join("\n");
|
||||
if events.emit_captured_urls(payload) {
|
||||
pending_captured_urls.clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
DownloadCmd::FrontendReady(ready) => {
|
||||
frontend_ready = ready;
|
||||
if ready && !pending_captured_urls.is_empty() {
|
||||
let payload = pending_captured_urls.join("\n");
|
||||
if events.emit_captured_urls(payload) {
|
||||
pending_captured_urls.clear();
|
||||
DownloadCmd::FrontendReady(ready) => {
|
||||
frontend_ready = ready;
|
||||
if ready && !pending_captured_urls.is_empty() {
|
||||
let payload = pending_captured_urls.join("\n");
|
||||
if events.emit_captured_urls(payload) {
|
||||
pending_captured_urls.clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
None => command_open = false,
|
||||
}
|
||||
}
|
||||
command = media_rx.recv() => {
|
||||
let Some(command) = command else {
|
||||
continue;
|
||||
};
|
||||
command = media_rx.recv(), if media_open => {
|
||||
match command {
|
||||
MediaCmd::Register { id, cancel_tx } => {
|
||||
if let Some(previous) = active_media.insert(id, cancel_tx) {
|
||||
Some(command) => match command {
|
||||
MediaCmd::Register { id, lifecycle_generation, cancel_tx } => {
|
||||
if active_media
|
||||
.get(&id)
|
||||
.is_some_and(|(generation, _)| *generation > lifecycle_generation)
|
||||
{
|
||||
let _ = cancel_tx.send(true);
|
||||
continue;
|
||||
}
|
||||
let pending_cancel = cancelled_media_generations.get(&id).copied();
|
||||
if pending_cancel.is_some_and(|generation| generation < lifecycle_generation) {
|
||||
cancelled_media_generations.remove(&id);
|
||||
}
|
||||
let cancelled = pending_cancel.is_some_and(|generation| generation >= lifecycle_generation);
|
||||
if let Some((_, previous)) = active_media.insert(id.clone(), (lifecycle_generation, cancel_tx)) {
|
||||
let _ = previous.send(true);
|
||||
}
|
||||
}
|
||||
MediaCmd::Pause(id) => {
|
||||
if let Some(cancel_tx) = active_media.remove(&id) {
|
||||
let _ = cancel_tx.send(true);
|
||||
if cancelled {
|
||||
if let Some((_, cancel_tx)) = active_media.get(&id) {
|
||||
let _ = cancel_tx.send(true);
|
||||
}
|
||||
if pending_cancel == Some(lifecycle_generation) {
|
||||
cancelled_media_generations.remove(&id);
|
||||
}
|
||||
}
|
||||
}
|
||||
MediaCmd::PauseWithAck(id, ack) => {
|
||||
if let Some(cancel_tx) = active_media.remove(&id) {
|
||||
let _ = cancel_tx.send(true);
|
||||
pending_media_acks.insert(id, ack);
|
||||
MediaCmd::Pause { id, lifecycle_generation } => {
|
||||
if active_media.get(&id).is_some_and(|(generation, _)| *generation == lifecycle_generation) {
|
||||
if let Some((_, cancel_tx)) = active_media.remove(&id) {
|
||||
let _ = cancel_tx.send(true);
|
||||
}
|
||||
} else {
|
||||
let _ = ack.send(());
|
||||
cancelled_media_generations
|
||||
.entry(id)
|
||||
.and_modify(|generation| *generation = (*generation).max(lifecycle_generation))
|
||||
.or_insert(lifecycle_generation);
|
||||
}
|
||||
}
|
||||
MediaCmd::Finished(id) => {
|
||||
active_media.remove(&id);
|
||||
if let Some(ack) = pending_media_acks.remove(&id) {
|
||||
let _ = ack.send(());
|
||||
MediaCmd::PauseWithAck { id, lifecycle_generation, ack } => {
|
||||
if active_media.get(&id).is_some_and(|(generation, _)| *generation == lifecycle_generation) {
|
||||
if let Some((_, cancel_tx)) = active_media.remove(&id) {
|
||||
let _ = cancel_tx.send(true);
|
||||
pending_media_acks.insert((id, lifecycle_generation), ack);
|
||||
}
|
||||
} else {
|
||||
cancelled_media_generations
|
||||
.entry(id)
|
||||
.and_modify(|generation| *generation = (*generation).max(lifecycle_generation))
|
||||
.or_insert(lifecycle_generation);
|
||||
// No runner was registered for this generation.
|
||||
// The caller may retire the cancellation tombstone
|
||||
// while it still owns the per-download lock.
|
||||
let _ = ack.send(false);
|
||||
}
|
||||
}
|
||||
MediaCmd::Finished { id, lifecycle_generation } => {
|
||||
if active_media.get(&id).is_some_and(|(generation, _)| *generation == lifecycle_generation) {
|
||||
active_media.remove(&id);
|
||||
}
|
||||
if let Some(ack) =
|
||||
pending_media_acks.remove(&(id.clone(), lifecycle_generation))
|
||||
{
|
||||
// A pending acknowledgement means the runner was
|
||||
// registered and has now observed cancellation.
|
||||
let _ = ack.send(true);
|
||||
}
|
||||
if cancelled_media_generations
|
||||
.get(&id)
|
||||
.is_some_and(|generation| *generation <= lifecycle_generation)
|
||||
{
|
||||
cancelled_media_generations.remove(&id);
|
||||
}
|
||||
}
|
||||
},
|
||||
None => media_open = false,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (_, cancel_tx) in active_media {
|
||||
for (_, (_, cancel_tx)) in active_media {
|
||||
let _ = cancel_tx.send(true);
|
||||
}
|
||||
}
|
||||
@@ -219,9 +303,30 @@ pub(crate) fn format_duration(seconds: f64) -> String {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{DownloadCmd, DownloadCoordinator, DownloadEvent};
|
||||
use super::{CoordinatorEventSink, DownloadCmd, DownloadCoordinator, DownloadEvent};
|
||||
use tokio::sync::mpsc;
|
||||
use std::time::Duration;
|
||||
|
||||
#[tokio::test]
|
||||
async fn coordinator_exits_when_both_command_channels_close() {
|
||||
let (event_tx, _event_rx) = mpsc::unbounded_channel();
|
||||
let (command_tx, command_rx) = mpsc::channel(1);
|
||||
let (media_tx, media_rx) = mpsc::channel(1);
|
||||
let coordinator = tokio::spawn(super::run_coordinator(
|
||||
CoordinatorEventSink::Headless(event_tx),
|
||||
command_rx,
|
||||
media_rx,
|
||||
));
|
||||
|
||||
drop(command_tx);
|
||||
drop(media_tx);
|
||||
|
||||
tokio::time::timeout(Duration::from_secs(1), coordinator)
|
||||
.await
|
||||
.expect("coordinator did not exit after both channels closed")
|
||||
.expect("coordinator task panicked");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn buffers_captured_urls_until_frontend_is_ready() {
|
||||
let (coordinator, mut events) = DownloadCoordinator::spawn_headless();
|
||||
@@ -251,4 +356,146 @@ mod tests {
|
||||
DownloadEvent::CapturedUrls("https://example.com/startup.zip".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn stale_media_finish_cannot_remove_a_newer_lifecycle() {
|
||||
let (coordinator, _events) = DownloadCoordinator::spawn_headless();
|
||||
let mut old_cancel = coordinator.register_media("same-id".to_string(), 1).await.unwrap();
|
||||
let mut new_cancel = coordinator.register_media("same-id".to_string(), 2).await.unwrap();
|
||||
|
||||
tokio::time::timeout(Duration::from_secs(1), old_cancel.changed())
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
coordinator.finish_media("same-id".to_string(), 1).await;
|
||||
|
||||
let (ack_tx, ack_rx) = tokio::sync::oneshot::channel();
|
||||
coordinator
|
||||
.pause_media_with_ack("same-id".to_string(), 2, ack_tx)
|
||||
.await
|
||||
.unwrap();
|
||||
coordinator.finish_media("same-id".to_string(), 2).await;
|
||||
tokio::time::timeout(Duration::from_secs(1), ack_rx)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert!(*new_cancel.borrow_and_update());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn pause_before_media_registration_cancels_the_late_lifecycle() {
|
||||
let (coordinator, _events) = DownloadCoordinator::spawn_headless();
|
||||
let (ack_tx, ack_rx) = tokio::sync::oneshot::channel();
|
||||
|
||||
coordinator
|
||||
.pause_media_with_ack("late-media".to_string(), 7, ack_tx)
|
||||
.await
|
||||
.unwrap();
|
||||
tokio::time::timeout(Duration::from_secs(1), ack_rx)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
|
||||
let mut cancel_rx = coordinator
|
||||
.register_media("late-media".to_string(), 7)
|
||||
.await
|
||||
.unwrap();
|
||||
tokio::time::timeout(Duration::from_secs(1), async {
|
||||
while !*cancel_rx.borrow_and_update() {
|
||||
cancel_rx.changed().await.unwrap();
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("late media registration was not cancelled");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn unregistered_media_pause_tombstone_can_be_reconciled() {
|
||||
let (coordinator, _events) = DownloadCoordinator::spawn_headless();
|
||||
let (ack_tx, ack_rx) = tokio::sync::oneshot::channel();
|
||||
|
||||
coordinator
|
||||
.pause_media_with_ack("abandoned-media".to_string(), 9, ack_tx)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(!ack_rx.await.unwrap());
|
||||
|
||||
// Once the queue lifecycle is invalidated under its control lock,
|
||||
// it can retire the tombstone without allowing a late runner to
|
||||
// start. A later registration must therefore remain uncancelled.
|
||||
coordinator
|
||||
.finish_media("abandoned-media".to_string(), 9)
|
||||
.await;
|
||||
let cancel_rx = coordinator
|
||||
.register_media("abandoned-media".to_string(), 9)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(!*cancel_rx.borrow());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn stale_media_registration_cannot_replace_a_newer_lifecycle() {
|
||||
let (coordinator, _events) = DownloadCoordinator::spawn_headless();
|
||||
let mut new_cancel = coordinator
|
||||
.register_media("same-id".to_string(), 2)
|
||||
.await
|
||||
.unwrap();
|
||||
let mut old_cancel = coordinator
|
||||
.register_media("same-id".to_string(), 1)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
tokio::time::timeout(Duration::from_secs(1), old_cancel.changed())
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert!(*old_cancel.borrow_and_update());
|
||||
|
||||
let (ack_tx, ack_rx) = tokio::sync::oneshot::channel();
|
||||
coordinator
|
||||
.pause_media_with_ack("same-id".to_string(), 2, ack_tx)
|
||||
.await
|
||||
.unwrap();
|
||||
coordinator.finish_media("same-id".to_string(), 2).await;
|
||||
tokio::time::timeout(Duration::from_secs(1), ack_rx)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert!(*new_cancel.borrow_and_update());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn media_pause_ack_is_preserved_across_lifecycle_replacement() {
|
||||
let (coordinator, _events) = DownloadCoordinator::spawn_headless();
|
||||
let _old_cancel = coordinator
|
||||
.register_media("same-id".to_string(), 1)
|
||||
.await
|
||||
.unwrap();
|
||||
let (old_ack_tx, old_ack_rx) = tokio::sync::oneshot::channel();
|
||||
coordinator
|
||||
.pause_media_with_ack("same-id".to_string(), 1, old_ack_tx)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let _new_cancel = coordinator
|
||||
.register_media("same-id".to_string(), 2)
|
||||
.await
|
||||
.unwrap();
|
||||
let (new_ack_tx, new_ack_rx) = tokio::sync::oneshot::channel();
|
||||
coordinator
|
||||
.pause_media_with_ack("same-id".to_string(), 2, new_ack_tx)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
coordinator.finish_media("same-id".to_string(), 1).await;
|
||||
tokio::time::timeout(Duration::from_secs(1), old_ack_rx)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
coordinator.finish_media("same-id".to_string(), 2).await;
|
||||
tokio::time::timeout(Duration::from_secs(1), new_ack_rx)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,7 +59,12 @@ pub fn expected_primary_path(
|
||||
}
|
||||
|
||||
let safe_filename = canonical_download_filename(filename);
|
||||
Ok(resolved_dest.join(safe_filename))
|
||||
let path = resolved_dest.join(safe_filename);
|
||||
if crate::path_has_symlink_component(&path) {
|
||||
return Err("Download path may not contain symlink components".to_string());
|
||||
}
|
||||
crate::canonicalize_with_missing_components(&path)
|
||||
.ok_or_else(|| "Download path could not be canonicalized".to_string())
|
||||
}
|
||||
|
||||
pub fn register_expected(
|
||||
@@ -88,13 +93,15 @@ pub fn set_primary_path(
|
||||
}) {
|
||||
return Err("Download ownership path traversal is not allowed".to_string());
|
||||
}
|
||||
if std::fs::symlink_metadata(path).is_ok_and(|metadata| metadata.file_type().is_symlink()) {
|
||||
return Err("Download ownership path may not be a symlink".to_string());
|
||||
if crate::path_has_symlink_component(path) {
|
||||
return Err("Download ownership path may not contain symlink components".to_string());
|
||||
}
|
||||
let canonical_path = crate::canonicalize_with_missing_components(path)
|
||||
.ok_or_else(|| "Download ownership path could not be canonicalized".to_string())?;
|
||||
|
||||
let database = app_handle.state::<crate::db::DbState>();
|
||||
let connection = database.lock()?;
|
||||
crate::db::set_ownership(&connection, id, &path.to_string_lossy())
|
||||
crate::db::set_ownership(&connection, id, &canonical_path.to_string_lossy())
|
||||
}
|
||||
|
||||
pub fn remove(app_handle: &tauri::AppHandle, id: &str) -> Result<(), String> {
|
||||
@@ -147,11 +154,7 @@ fn legacy_download_queue_paths(app_handle: &tauri::AppHandle) -> Result<Vec<Path
|
||||
let downloads = {
|
||||
let database = app_handle.state::<crate::db::DbState>();
|
||||
let connection = database.lock()?;
|
||||
crate::db::load_downloads(&connection)?
|
||||
.into_iter()
|
||||
.map(|value| serde_json::from_str::<crate::ipc::DownloadItem>(&value))
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
.map_err(|error| format!("Invalid download queue ownership data: {error}"))?
|
||||
parse_legacy_download_items(crate::db::load_downloads(&connection)?)
|
||||
};
|
||||
|
||||
let mut paths = Vec::new();
|
||||
@@ -223,9 +226,23 @@ fn legacy_download_queue_paths(app_handle: &tauri::AppHandle) -> Result<Vec<Path
|
||||
Ok(paths)
|
||||
}
|
||||
|
||||
fn parse_legacy_download_items(values: Vec<String>) -> Vec<crate::ipc::DownloadItem> {
|
||||
values
|
||||
.into_iter()
|
||||
.filter_map(|value| match serde_json::from_str::<crate::ipc::DownloadItem>(&value) {
|
||||
Ok(download) => Some(download),
|
||||
Err(error) => {
|
||||
log::warn!("Skipping malformed download ownership record: {error}");
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::canonical_download_filename;
|
||||
use super::{canonical_download_filename, parse_legacy_download_items};
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn canonicalizes_untrusted_download_filenames() {
|
||||
@@ -238,4 +255,22 @@ mod tests {
|
||||
assert_eq!(canonical_download_filename("CON.txt"), "CON-.txt");
|
||||
assert_eq!(canonical_download_filename("lpt9"), "lpt9-");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_legacy_download_does_not_block_valid_ownership_records() {
|
||||
let valid = json!({
|
||||
"id": "download-1",
|
||||
"url": "https://example.com/file",
|
||||
"fileName": "file",
|
||||
"status": "completed",
|
||||
"category": "Other",
|
||||
"dateAdded": ""
|
||||
})
|
||||
.to_string();
|
||||
|
||||
let downloads = parse_legacy_download_items(vec!["not-json".to_string(), valid]);
|
||||
|
||||
assert_eq!(downloads.len(), 1);
|
||||
assert_eq!(downloads[0].id, "download-1");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,26 +17,34 @@ use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Arc, Mutex, RwLock};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
use tauri::{AppHandle, Emitter, Manager};
|
||||
use tokio::sync::watch;
|
||||
use tower_http::cors::{Any, CorsLayer};
|
||||
use tokio::sync::{oneshot, watch};
|
||||
use tower_http::{
|
||||
cors::{Any, CorsLayer},
|
||||
limit::RequestBodyLimitLayer,
|
||||
};
|
||||
use ts_rs::TS;
|
||||
|
||||
pub const EXTENSION_SERVER_PORT: u16 = 6412;
|
||||
pub const EXTENSION_SERVER_PORT_RANGE: std::ops::RangeInclusive<u16> = EXTENSION_SERVER_PORT..=6422;
|
||||
const MAX_URL_COUNT: usize = 200;
|
||||
const MAX_REQUEST_BODY_BYTES: usize = 256 * 1024;
|
||||
const SIGNATURE_MAX_AGE_MS: u64 = 60_000;
|
||||
const SERVER_HEADER: &str = "x-firelink-server";
|
||||
const PROTOCOL_VERSION_HEADER: &str = "x-firelink-protocol-version";
|
||||
const CLIENT_NONCE_HEADER: &str = "x-firelink-client-nonce";
|
||||
const SERVER_PROOF_HEADER: &str = "x-firelink-server-proof";
|
||||
const SERVER_PORT_HEADER: &str = "x-firelink-server-port";
|
||||
const SMOKE_PROCESS_ID_HEADER: &str = "x-firelink-smoke-process-id";
|
||||
const SERVER_PROOF_PREFIX: &[u8] = b"firelink-server-proof\n";
|
||||
const PROTOCOL_VERSION: &str = "4";
|
||||
const MAX_PENDING_EXTENSION_ACKS: usize = 64;
|
||||
const EXTENSION_ACK_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(2);
|
||||
|
||||
type HmacSha256 = Hmac<Sha256>;
|
||||
pub type SharedExtensionToken = Arc<RwLock<String>>;
|
||||
pub type SharedFrontendReady = Arc<AtomicBool>;
|
||||
pub type SharedServerPort = Arc<RwLock<Option<u16>>>;
|
||||
pub type SharedExtensionAcks = Arc<Mutex<HashMap<String, oneshot::Sender<()>>>>;
|
||||
type ReplayCache = Arc<Mutex<HashMap<String, u64>>>;
|
||||
|
||||
#[derive(Clone)]
|
||||
@@ -44,6 +52,7 @@ pub struct ServerState {
|
||||
pub app_handle: AppHandle,
|
||||
pub pairing_token: SharedExtensionToken,
|
||||
pub frontend_ready: SharedFrontendReady,
|
||||
pub extension_acks: SharedExtensionAcks,
|
||||
pub replay_cache: ReplayCache,
|
||||
pub bound_port: u16,
|
||||
}
|
||||
@@ -62,18 +71,30 @@ struct ExtensionRequest {
|
||||
#[serde(default)]
|
||||
cookies: Option<String>,
|
||||
#[serde(default)]
|
||||
cookie_scopes: Option<Vec<ExtensionCookieScope>>,
|
||||
#[serde(default)]
|
||||
media: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Deserialize, Serialize, TS)]
|
||||
#[ts(export, export_to = "../../src/bindings/")]
|
||||
pub struct ExtensionCookieScope {
|
||||
pub url: String,
|
||||
pub cookies: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize, TS)]
|
||||
#[ts(export, export_to = "../../src/bindings/")]
|
||||
pub struct ExtensionDownload {
|
||||
#[ts(optional)]
|
||||
request_id: Option<String>,
|
||||
urls: Vec<String>,
|
||||
referer: Option<String>,
|
||||
silent: bool,
|
||||
filename: Option<String>,
|
||||
headers: Option<String>,
|
||||
cookies: Option<String>,
|
||||
cookie_scopes: Option<Vec<ExtensionCookieScope>>,
|
||||
media: bool,
|
||||
}
|
||||
|
||||
@@ -81,6 +102,7 @@ pub async fn start_server(
|
||||
app_handle: AppHandle,
|
||||
pairing_token: SharedExtensionToken,
|
||||
frontend_ready: SharedFrontendReady,
|
||||
extension_acks: SharedExtensionAcks,
|
||||
server_port: SharedServerPort,
|
||||
mut shutdown_rx: watch::Receiver<bool>,
|
||||
) -> Result<(), String> {
|
||||
@@ -89,6 +111,7 @@ pub async fn start_server(
|
||||
app_handle,
|
||||
pairing_token,
|
||||
frontend_ready,
|
||||
extension_acks,
|
||||
replay_cache: Arc::new(Mutex::new(HashMap::new())),
|
||||
bound_port: port,
|
||||
};
|
||||
@@ -105,6 +128,7 @@ pub async fn start_server(
|
||||
.route("/ping", get(ping_handler))
|
||||
.route("/download", post(download_handler))
|
||||
.layer(cors)
|
||||
.layer(RequestBodyLimitLayer::new(MAX_REQUEST_BODY_BYTES))
|
||||
.layer(middleware::from_fn(add_server_identity))
|
||||
.with_state(state);
|
||||
|
||||
@@ -140,6 +164,13 @@ async fn add_server_identity(request: Request<Body>, next: Next) -> Response {
|
||||
PROTOCOL_VERSION_HEADER,
|
||||
HeaderValue::from_static(PROTOCOL_VERSION),
|
||||
);
|
||||
if std::env::var_os("FIRELINK_SMOKE_TEST").is_some() {
|
||||
if let Ok(process_id) = HeaderValue::from_str(&std::process::id().to_string()) {
|
||||
response
|
||||
.headers_mut()
|
||||
.insert(SMOKE_PROCESS_ID_HEADER, process_id);
|
||||
}
|
||||
}
|
||||
response
|
||||
}
|
||||
|
||||
@@ -166,6 +197,10 @@ async fn ping_handler(
|
||||
headers: HeaderMap,
|
||||
body: Bytes,
|
||||
) -> Result<Response, StatusCode> {
|
||||
if !has_allowed_request_origin(&headers) {
|
||||
return Err(StatusCode::FORBIDDEN);
|
||||
}
|
||||
|
||||
let signature = match headers
|
||||
.get("x-firelink-signature")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
@@ -191,7 +226,15 @@ async fn ping_handler(
|
||||
None => return Err(StatusCode::FORBIDDEN),
|
||||
};
|
||||
|
||||
if verify_signature(signature, timestamp_str, &body, &state.pairing_token).is_err() {
|
||||
let timestamp = match verify_signature(signature, timestamp_str, &body, &state.pairing_token) {
|
||||
Ok(timestamp) => timestamp,
|
||||
Err(_) => return Err(StatusCode::FORBIDDEN),
|
||||
};
|
||||
|
||||
// Discovery probes are authenticated requests too. Claim the verified
|
||||
// signature before signing a proof so a captured /ping signature cannot
|
||||
// be replayed with arbitrary client nonces during its validity window.
|
||||
if !claim_request(signature, timestamp, &state.replay_cache) {
|
||||
return Err(StatusCode::FORBIDDEN);
|
||||
}
|
||||
|
||||
@@ -215,7 +258,12 @@ async fn download_handler(
|
||||
State(state): State<ServerState>,
|
||||
headers: HeaderMap,
|
||||
body: Bytes,
|
||||
) -> Result<StatusCode, StatusCode> {
|
||||
) -> Result<Response, StatusCode> {
|
||||
let nonce = match required_client_nonce(&headers) {
|
||||
Some(nonce) if has_allowed_request_origin(&headers) => nonce,
|
||||
_ => return Err(StatusCode::FORBIDDEN),
|
||||
};
|
||||
|
||||
let signature = match headers
|
||||
.get("x-firelink-signature")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
@@ -269,15 +317,45 @@ async fn download_handler(
|
||||
return Err(StatusCode::SERVICE_UNAVAILABLE);
|
||||
}
|
||||
|
||||
let request_id = uuid::Uuid::new_v4().simple().to_string();
|
||||
let ack_receiver = register_extension_ack(&state.extension_acks, request_id.clone())
|
||||
.ok_or(StatusCode::SERVICE_UNAVAILABLE)?;
|
||||
let mut download = download;
|
||||
download.request_id = Some(request_id.clone());
|
||||
|
||||
if state
|
||||
.app_handle
|
||||
.emit("extension-add-download", download)
|
||||
.is_err()
|
||||
{
|
||||
remove_extension_ack(&state.extension_acks, &request_id);
|
||||
return Err(StatusCode::INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
|
||||
Ok(StatusCode::OK)
|
||||
if tokio::time::timeout(EXTENSION_ACK_TIMEOUT, ack_receiver)
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
remove_extension_ack(&state.extension_acks, &request_id);
|
||||
// The event may already have reached the frontend even when its
|
||||
// acknowledgement was delayed or lost. Do not return 503 here:
|
||||
// extension callers retry 503 and could create a duplicate modal.
|
||||
return Err(StatusCode::GATEWAY_TIMEOUT);
|
||||
}
|
||||
|
||||
let proof = sign_server_proof(timestamp_str, nonce, state.bound_port, &state.pairing_token)
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
let mut response = Response::new(Body::empty());
|
||||
response.headers_mut().insert(
|
||||
SERVER_PROOF_HEADER,
|
||||
HeaderValue::from_str(&proof).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?,
|
||||
);
|
||||
response.headers_mut().insert(
|
||||
SERVER_PORT_HEADER,
|
||||
HeaderValue::from_str(&state.bound_port.to_string())
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?,
|
||||
);
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
async fn wait_for_frontend(frontend_ready: &SharedFrontendReady) -> bool {
|
||||
@@ -290,27 +368,93 @@ async fn wait_for_frontend(frontend_ready: &SharedFrontendReady) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
fn normalize_download(payload: ExtensionRequest) -> Option<ExtensionDownload> {
|
||||
fn register_extension_ack(
|
||||
registry: &SharedExtensionAcks,
|
||||
request_id: String,
|
||||
) -> Option<oneshot::Receiver<()>> {
|
||||
let (sender, receiver) = oneshot::channel();
|
||||
let mut pending = registry.lock().ok()?;
|
||||
if pending.len() >= MAX_PENDING_EXTENSION_ACKS {
|
||||
return None;
|
||||
}
|
||||
pending.insert(request_id, sender);
|
||||
Some(receiver)
|
||||
}
|
||||
|
||||
pub fn acknowledge_extension_download(registry: &SharedExtensionAcks, request_id: &str) -> bool {
|
||||
let Some(sender) = registry
|
||||
.lock()
|
||||
.ok()
|
||||
.and_then(|mut pending| pending.remove(request_id))
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
sender.send(()).is_ok()
|
||||
}
|
||||
|
||||
fn remove_extension_ack(registry: &SharedExtensionAcks, request_id: &str) {
|
||||
if let Ok(mut pending) = registry.lock() {
|
||||
pending.remove(request_id);
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_download(mut payload: ExtensionRequest) -> Option<ExtensionDownload> {
|
||||
if payload.urls.len() > MAX_URL_COUNT {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut seen = HashSet::new();
|
||||
let urls = payload
|
||||
.urls
|
||||
.into_iter()
|
||||
.take(MAX_URL_COUNT)
|
||||
.filter_map(|raw_url| normalize_url(&raw_url))
|
||||
.filter(|url| seen.insert(url.clone()))
|
||||
.collect::<Vec<_>>();
|
||||
if urls.is_empty() {
|
||||
return None;
|
||||
}
|
||||
if payload.media
|
||||
&& urls.iter().any(|url| {
|
||||
Url::parse(url)
|
||||
.ok()
|
||||
.is_none_or(|url| !matches!(url.scheme(), "http" | "https"))
|
||||
})
|
||||
{
|
||||
return None;
|
||||
}
|
||||
|
||||
let referer = payload.referer.and_then(|value| {
|
||||
let url = Url::parse(value.trim()).ok()?;
|
||||
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);
|
||||
// A multi-URL handoff has no per-URL cookie scope. Keep ordinary
|
||||
// request headers, but drop Cookie headers and the dedicated cookie field
|
||||
// so a legacy or untrusted caller cannot reuse one session across hosts.
|
||||
let headers = normalize_headers(payload.headers, payload.media || urls.len() > 1);
|
||||
let cookie_scopes = if !payload.media && urls.len() == 1 {
|
||||
let mut scopes = payload.cookie_scopes.take().unwrap_or_default();
|
||||
if let Some(cookies) = payload.cookies.take() {
|
||||
if !cookies.trim().is_empty() {
|
||||
scopes.push(ExtensionCookieScope {
|
||||
url: urls[0].clone(),
|
||||
cookies,
|
||||
});
|
||||
}
|
||||
}
|
||||
normalize_cookie_scopes(scopes)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let cookies = cookie_scopes.as_ref().and_then(|scopes| {
|
||||
scopes
|
||||
.iter()
|
||||
.find(|scope| same_origin_url(&scope.url, &urls[0]))
|
||||
.map(|scope| scope.cookies.clone())
|
||||
});
|
||||
|
||||
Some(ExtensionDownload {
|
||||
request_id: None,
|
||||
urls,
|
||||
referer,
|
||||
silent: payload.silent,
|
||||
@@ -321,14 +465,63 @@ fn normalize_download(payload: ExtensionRequest) -> Option<ExtensionDownload> {
|
||||
// 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()),
|
||||
cookies,
|
||||
cookie_scopes,
|
||||
media: payload.media,
|
||||
})
|
||||
}
|
||||
|
||||
fn normalize_cookie_scopes(scopes: Vec<ExtensionCookieScope>) -> Option<Vec<ExtensionCookieScope>> {
|
||||
let mut normalized = Vec::new();
|
||||
let mut seen_origins = HashSet::new();
|
||||
|
||||
for scope in scopes {
|
||||
let Ok(url) = Url::parse(scope.url.trim()) else {
|
||||
continue;
|
||||
};
|
||||
if !matches!(url.scheme(), "http" | "https") {
|
||||
continue;
|
||||
}
|
||||
let cookies = scope.cookies.trim();
|
||||
if cookies.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let Some(host) = url.host_str() else {
|
||||
continue;
|
||||
};
|
||||
let origin = format!(
|
||||
"{}://{}:{}",
|
||||
url.scheme(),
|
||||
host,
|
||||
url.port_or_known_default().unwrap_or(443)
|
||||
);
|
||||
if !seen_origins.insert(origin) {
|
||||
continue;
|
||||
}
|
||||
normalized.push(ExtensionCookieScope {
|
||||
url: url.to_string(),
|
||||
cookies: cookies.to_string(),
|
||||
});
|
||||
if normalized.len() >= 16 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
(!normalized.is_empty()).then_some(normalized)
|
||||
}
|
||||
|
||||
fn same_origin_url(left: &str, right: &str) -> bool {
|
||||
let Some(left) = Url::parse(left).ok() else {
|
||||
return false;
|
||||
};
|
||||
let Some(right) = Url::parse(right).ok() else {
|
||||
return false;
|
||||
};
|
||||
left.scheme() == right.scheme()
|
||||
&& left.host() == right.host()
|
||||
&& left.port_or_known_default() == right.port_or_known_default()
|
||||
}
|
||||
|
||||
fn normalize_headers(headers: Option<String>, media: bool) -> Option<String> {
|
||||
let headers = headers?;
|
||||
if !media {
|
||||
@@ -390,6 +583,20 @@ fn is_valid_client_nonce(value: &str) -> bool {
|
||||
value.len() == 32 && value.bytes().all(|byte| byte.is_ascii_hexdigit())
|
||||
}
|
||||
|
||||
fn has_allowed_request_origin(headers: &HeaderMap) -> bool {
|
||||
match headers.get("origin") {
|
||||
None => true,
|
||||
Some(origin) => origin.to_str().ok().is_some_and(is_allowed_origin),
|
||||
}
|
||||
}
|
||||
|
||||
fn required_client_nonce(headers: &HeaderMap) -> Option<&str> {
|
||||
headers
|
||||
.get(CLIENT_NONCE_HEADER)
|
||||
.and_then(|nonce| nonce.to_str().ok())
|
||||
.filter(|nonce| is_valid_client_nonce(nonce))
|
||||
}
|
||||
|
||||
fn sign_server_proof(
|
||||
timestamp_text: &str,
|
||||
nonce: &str,
|
||||
@@ -424,16 +631,22 @@ fn claim_request(signature: &str, timestamp: u64, replay_cache: &ReplayCache) ->
|
||||
Some(now) => now,
|
||||
None => return false,
|
||||
};
|
||||
claim_request_at(signature, timestamp, replay_cache, now)
|
||||
}
|
||||
|
||||
fn claim_request_at(signature: &str, timestamp: u64, replay_cache: &ReplayCache, now: u64) -> bool {
|
||||
let mut cache = match replay_cache.lock() {
|
||||
Ok(cache) => cache,
|
||||
Err(_) => return false,
|
||||
};
|
||||
cache.retain(|_, seen_at| now.saturating_sub(*seen_at) < SIGNATURE_MAX_AGE_MS);
|
||||
if cache.len() > 10_000 {
|
||||
cache.retain(|_, expires_at| now < *expires_at);
|
||||
let key = format!("{timestamp}:{}", signature.to_ascii_lowercase());
|
||||
if cache.len() >= 10_000 && !cache.contains_key(&key) {
|
||||
return false;
|
||||
}
|
||||
let key = format!("{timestamp}:{}", signature.to_ascii_lowercase());
|
||||
cache.insert(key, now).is_none()
|
||||
cache
|
||||
.insert(key, timestamp.saturating_add(SIGNATURE_MAX_AGE_MS))
|
||||
.is_none()
|
||||
}
|
||||
|
||||
fn current_time_millis() -> Option<u64> {
|
||||
@@ -476,13 +689,21 @@ fn is_allowed_origin(origin: &str) -> bool {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
add_server_identity, is_valid_client_nonce, normalize_download, sign_server_proof,
|
||||
ExtensionRequest, PROTOCOL_VERSION_HEADER, SERVER_HEADER,
|
||||
acknowledge_extension_download, add_server_identity, claim_request_at,
|
||||
has_allowed_request_origin, is_valid_client_nonce,
|
||||
normalize_download, required_client_nonce, sign_server_proof, ExtensionCookieScope,
|
||||
ExtensionRequest, MAX_URL_COUNT, PROTOCOL_VERSION_HEADER, SERVER_HEADER,
|
||||
};
|
||||
use axum::{
|
||||
http::{HeaderMap, HeaderValue, StatusCode},
|
||||
middleware,
|
||||
routing::get,
|
||||
Router,
|
||||
};
|
||||
use axum::{http::StatusCode, middleware, routing::get, Router};
|
||||
use hmac::{Hmac, KeyInit, Mac};
|
||||
use sha2::Sha256;
|
||||
use std::sync::{Arc, RwLock};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, Mutex, RwLock};
|
||||
|
||||
#[tokio::test]
|
||||
async fn identifies_every_extension_server_response() {
|
||||
@@ -519,6 +740,119 @@ mod tests {
|
||||
assert!(!is_valid_client_nonce("0123456789abcdef0123456789abcdeg"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_invalid_origins() {
|
||||
let mut headers = HeaderMap::new();
|
||||
assert!(has_allowed_request_origin(&headers));
|
||||
|
||||
headers.insert(
|
||||
"origin",
|
||||
HeaderValue::from_static("https://not-firelink.example"),
|
||||
);
|
||||
assert!(!has_allowed_request_origin(&headers));
|
||||
|
||||
headers.insert(
|
||||
"origin",
|
||||
HeaderValue::from_static("moz-extension://firelink"),
|
||||
);
|
||||
assert!(has_allowed_request_origin(&headers));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn requires_a_valid_client_nonce_for_downloads() {
|
||||
let mut headers = HeaderMap::new();
|
||||
assert!(required_client_nonce(&headers).is_none());
|
||||
|
||||
headers.insert(
|
||||
"x-firelink-client-nonce",
|
||||
HeaderValue::from_static("not-a-valid-nonce"),
|
||||
);
|
||||
assert!(required_client_nonce(&headers).is_none());
|
||||
|
||||
headers.insert(
|
||||
"x-firelink-client-nonce",
|
||||
HeaderValue::from_static("0123456789abcdef0123456789abcdef"),
|
||||
);
|
||||
assert_eq!(
|
||||
required_client_nonce(&headers),
|
||||
Some("0123456789abcdef0123456789abcdef")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn media_handoffs_reject_non_http_page_urls() {
|
||||
let download = normalize_download(ExtensionRequest {
|
||||
urls: vec!["ftp://example.com/audio.mp3".to_string()],
|
||||
referer: None,
|
||||
silent: false,
|
||||
filename: None,
|
||||
headers: None,
|
||||
cookies: None,
|
||||
cookie_scopes: None,
|
||||
media: true,
|
||||
});
|
||||
|
||||
assert!(download.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_oversized_url_lists_instead_of_truncating_them() {
|
||||
let download = normalize_download(ExtensionRequest {
|
||||
urls: (0..=MAX_URL_COUNT)
|
||||
.map(|index| format!("https://example.com/file-{index}.bin"))
|
||||
.collect(),
|
||||
referer: None,
|
||||
silent: false,
|
||||
filename: None,
|
||||
headers: None,
|
||||
cookies: None,
|
||||
cookie_scopes: None,
|
||||
media: false,
|
||||
});
|
||||
|
||||
assert!(download.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_replayed_download_signature() {
|
||||
let cache = Arc::new(Mutex::new(HashMap::new()));
|
||||
let signature = "a".repeat(64);
|
||||
let now = 1_000_000;
|
||||
|
||||
assert!(claim_request_at(&signature, now, &cache, now));
|
||||
assert!(!claim_request_at(&signature, now, &cache, now + 1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn future_timestamp_replay_claim_survives_cache_pruning_window() {
|
||||
let cache = Arc::new(Mutex::new(HashMap::new()));
|
||||
let signature = "b".repeat(64);
|
||||
let now = 1_000_000;
|
||||
let future_timestamp = now + 30_000;
|
||||
|
||||
assert!(claim_request_at(&signature, future_timestamp, &cache, now));
|
||||
assert!(!claim_request_at(
|
||||
&signature,
|
||||
future_timestamp,
|
||||
&cache,
|
||||
now + 70_000
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn acknowledges_and_removes_pending_extension_event() {
|
||||
let registry = Arc::new(Mutex::new(HashMap::new()));
|
||||
let (sender, receiver) = tokio::sync::oneshot::channel();
|
||||
registry
|
||||
.lock()
|
||||
.unwrap()
|
||||
.insert("request-1".to_string(), sender);
|
||||
|
||||
assert!(acknowledge_extension_download(®istry, "request-1"));
|
||||
assert!(!acknowledge_extension_download(®istry, "request-1"));
|
||||
assert!(receiver.await.is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_media_drops_the_extension_cookie_header() {
|
||||
let download = normalize_download(ExtensionRequest {
|
||||
@@ -531,6 +865,7 @@ mod tests {
|
||||
"x".repeat(64 * 1024)
|
||||
)),
|
||||
cookies: Some(format!("large={}", "x".repeat(64 * 1024))),
|
||||
cookie_scopes: None,
|
||||
media: true,
|
||||
})
|
||||
.expect("valid media handoff");
|
||||
@@ -549,6 +884,7 @@ mod tests {
|
||||
filename: None,
|
||||
headers: None,
|
||||
cookies: Some("session=browser-cookie-header".to_string()),
|
||||
cookie_scopes: None,
|
||||
media: false,
|
||||
})
|
||||
.expect("valid download handoff");
|
||||
@@ -560,6 +896,65 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn regular_capture_normalizes_host_scoped_cookie_headers() {
|
||||
let download = normalize_download(ExtensionRequest {
|
||||
urls: vec!["https://mail.google.com/mail/u/0/?view=att".to_string()],
|
||||
referer: Some("https://mail.google.com/mail/u/0/".to_string()),
|
||||
silent: true,
|
||||
filename: Some("report.zip".to_string()),
|
||||
headers: None,
|
||||
cookies: Some("SID=mail-session".to_string()),
|
||||
cookie_scopes: Some(vec![
|
||||
ExtensionCookieScope {
|
||||
url: "https://mail.google.com/".to_string(),
|
||||
cookies: "SID=mail-session".to_string(),
|
||||
},
|
||||
ExtensionCookieScope {
|
||||
url: "https://accounts.google.com/".to_string(),
|
||||
cookies: "SID=account-session".to_string(),
|
||||
},
|
||||
ExtensionCookieScope {
|
||||
url: "https://mail.google.com/another-path".to_string(),
|
||||
cookies: "duplicate=ignored".to_string(),
|
||||
},
|
||||
]),
|
||||
media: false,
|
||||
})
|
||||
.expect("valid download handoff");
|
||||
|
||||
assert_eq!(download.cookies.as_deref(), Some("SID=mail-session"));
|
||||
assert_eq!(
|
||||
download.cookie_scopes.as_ref().map(|scopes| scopes.len()),
|
||||
Some(2)
|
||||
);
|
||||
assert_eq!(
|
||||
download.cookie_scopes.as_ref().unwrap()[1].cookies,
|
||||
"SID=account-session"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multi_url_capture_drops_cookie_scope_but_preserves_safe_headers() {
|
||||
let download = normalize_download(ExtensionRequest {
|
||||
urls: vec![
|
||||
"https://one.example/private.zip".to_string(),
|
||||
"https://two.example/file.zip".to_string(),
|
||||
],
|
||||
referer: None,
|
||||
silent: true,
|
||||
filename: None,
|
||||
headers: Some("Cookie: session=secret\nUser-Agent: Firefox".to_string()),
|
||||
cookies: Some("session=secret".to_string()),
|
||||
cookie_scopes: None,
|
||||
media: false,
|
||||
})
|
||||
.expect("valid multi-url handoff");
|
||||
|
||||
assert_eq!(download.cookies, None);
|
||||
assert_eq!(download.headers.as_deref(), Some("User-Agent: Firefox"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn signs_server_proof_with_timestamp_nonce_and_bound_port() {
|
||||
let token = Arc::new(RwLock::new("pairing-token".to_string()));
|
||||
|
||||
+17
-7
@@ -2,6 +2,10 @@ use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use ts_rs::TS;
|
||||
|
||||
fn default_speed_limit_unit() -> String {
|
||||
"MB/s".to_string()
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Serialize, Deserialize, TS)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
#[ts(export, export_to = "../../src/bindings/")]
|
||||
@@ -76,6 +80,12 @@ pub struct DownloadItem {
|
||||
pub eta: Option<String>,
|
||||
#[ts(optional)]
|
||||
pub size: Option<String>,
|
||||
#[ts(optional)]
|
||||
pub downloaded_bytes: Option<f64>,
|
||||
#[ts(optional)]
|
||||
pub total_bytes: Option<f64>,
|
||||
#[ts(optional)]
|
||||
pub total_is_estimate: Option<bool>,
|
||||
pub category: DownloadCategory,
|
||||
pub date_added: String,
|
||||
#[ts(optional)]
|
||||
@@ -110,6 +120,8 @@ pub struct DownloadItem {
|
||||
pub has_been_dispatched: Option<bool>,
|
||||
#[ts(optional)]
|
||||
pub last_error: Option<String>,
|
||||
#[ts(optional)]
|
||||
pub last_try: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, TS)]
|
||||
@@ -262,10 +274,14 @@ pub struct PersistedSettings {
|
||||
pub scheduler_last_start_key: String,
|
||||
pub scheduler_last_stop_key: String,
|
||||
pub last_custom_speed_limit_ki_b: u32,
|
||||
#[serde(default = "default_speed_limit_unit")]
|
||||
pub last_custom_speed_limit_unit: String,
|
||||
pub per_server_connections: i32,
|
||||
pub max_automatic_retries: i32,
|
||||
pub show_notifications: bool,
|
||||
pub play_completion_sound: bool,
|
||||
#[serde(default)]
|
||||
pub auto_add_clipboard_links: bool,
|
||||
pub app_font_size: AppFontSize,
|
||||
pub list_row_density: ListRowDensity,
|
||||
pub show_dock_badge: bool,
|
||||
@@ -278,13 +294,6 @@ 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 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,
|
||||
#[serde(default)]
|
||||
pub keychain_access_granted: bool,
|
||||
@@ -297,6 +306,7 @@ pub struct PlatformInfo {
|
||||
pub os: String,
|
||||
pub arch: String,
|
||||
pub target_triple: String,
|
||||
pub portable: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Serialize, Deserialize, TS)]
|
||||
|
||||
+3139
-347
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,5 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
#[cfg(any(target_os = "macos", target_os = "linux"))]
|
||||
use std::process::Command;
|
||||
use ts_rs::TS;
|
||||
|
||||
@@ -83,6 +84,7 @@ fn proxy_from_environment() -> Option<String> {
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "macos", target_os = "linux"))]
|
||||
fn command_stdout(command: &mut Command) -> std::io::Result<String> {
|
||||
let output = command.output()?;
|
||||
if !output.status.success() {
|
||||
|
||||
+929
-164
File diff suppressed because it is too large
Load Diff
+51
-13
@@ -76,13 +76,14 @@ pub fn backoff_for(strike: usize) -> Duration {
|
||||
/// that also mentions "timeout" in a URL) still fails fast.
|
||||
pub fn is_permanent_network_error(message: &str) -> bool {
|
||||
let m = message.to_ascii_lowercase();
|
||||
const PERMANENT: [&str; 9] = [
|
||||
"http 401",
|
||||
"http 403",
|
||||
"http 404",
|
||||
"http 404.",
|
||||
"http 410",
|
||||
"http 451",
|
||||
const PERMANENT_HTTP_STATUS: [&str; 5] = ["401", "403", "404", "410", "451"];
|
||||
if PERMANENT_HTTP_STATUS
|
||||
.iter()
|
||||
.any(|status| contains_http_status(&m, status))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
const PERMANENT: [&str; 3] = [
|
||||
"404 not found",
|
||||
"permission denied",
|
||||
"no space left on device",
|
||||
@@ -90,6 +91,32 @@ pub fn is_permanent_network_error(message: &str) -> bool {
|
||||
PERMANENT.iter().any(|p| m.contains(p))
|
||||
}
|
||||
|
||||
/// Match the HTTP status formats emitted by both clients, including
|
||||
/// `HTTP/1.1 403` and `HTTP Error 403`, not only the shorthand `HTTP 403`.
|
||||
fn contains_http_status(message: &str, status: &str) -> bool {
|
||||
let tokens = message
|
||||
.split(|character: char| {
|
||||
character.is_ascii_whitespace()
|
||||
|| matches!(character, '/' | '.' | ':' | '-' | '_')
|
||||
})
|
||||
.filter(|token| !token.is_empty())
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
tokens.windows(2).any(|window| window[0] == "http" && window[1] == status)
|
||||
|| tokens.windows(3).any(|window| {
|
||||
window[0] == "http"
|
||||
&& (window[1] == "error"
|
||||
|| window[1].chars().all(|character| character.is_ascii_digit()))
|
||||
&& window[2] == status
|
||||
})
|
||||
|| tokens.windows(4).any(|window| {
|
||||
window[0] == "http"
|
||||
&& window[1].chars().all(|character| character.is_ascii_digit())
|
||||
&& window[2].chars().all(|character| character.is_ascii_digit())
|
||||
&& window[3] == status
|
||||
})
|
||||
}
|
||||
|
||||
pub fn is_transient_network_error(message: &str) -> bool {
|
||||
if is_permanent_network_error(message) {
|
||||
return false;
|
||||
@@ -97,7 +124,7 @@ pub fn is_transient_network_error(message: &str) -> bool {
|
||||
|
||||
let m = message.to_ascii_lowercase();
|
||||
|
||||
const TRANSIENT: [&str; 35] = [
|
||||
const TRANSIENT: [&str; 34] = [
|
||||
// socket-layer / HTTP-client phrasing surfaced by aria2 and yt-dlp
|
||||
"timed out",
|
||||
"timeout",
|
||||
@@ -113,13 +140,12 @@ pub fn is_transient_network_error(message: &str) -> bool {
|
||||
"connection aborted",
|
||||
"error sending request", // reqwest wrapper for connect/send failures
|
||||
"dns error", // transient resolver failures
|
||||
"protocol error", // aria2 read/protocol failures after a link drop
|
||||
"tls handshake failure",
|
||||
"ssl/tls handshake failure",
|
||||
// HTTP-level transient
|
||||
"http 408",
|
||||
"request timeout",
|
||||
"http 503",
|
||||
"503 service unavailable",
|
||||
"http 429",
|
||||
"http error 429",
|
||||
"429 too many requests",
|
||||
// aria2c HTTP error formats
|
||||
"status=408",
|
||||
@@ -138,7 +164,10 @@ pub fn is_transient_network_error(message: &str) -> bool {
|
||||
"timeout.",
|
||||
"invalid range header",
|
||||
];
|
||||
TRANSIENT.iter().any(|t| m.contains(t))
|
||||
contains_http_status(&m, "408")
|
||||
|| contains_http_status(&m, "429")
|
||||
|| contains_http_status(&m, "503")
|
||||
|| TRANSIENT.iter().any(|t| m.contains(t))
|
||||
}
|
||||
|
||||
/// Outcome of a cancel-safe backoff sleep wrapped around a transient retry.
|
||||
@@ -249,6 +278,7 @@ mod tests {
|
||||
#[test]
|
||||
fn classifies_http_503_as_transient() {
|
||||
assert!(is_transient_network_error("HTTP 503 Service Unavailable"));
|
||||
assert!(is_transient_network_error("HTTP/1.1 503 Service Unavailable"));
|
||||
assert!(is_transient_network_error(
|
||||
"http://127.0.0.1/file returned HTTP 503 Service Unavailable"
|
||||
));
|
||||
@@ -275,6 +305,12 @@ mod tests {
|
||||
assert!(is_transient_network_error("The response status is not successful. status=503"));
|
||||
assert!(is_transient_network_error("The response status is not successful. status=502"));
|
||||
assert!(is_transient_network_error("Invalid range header. Request: 106954752-361758719/383882118, Response: 106954752-383882117/383882118"));
|
||||
assert!(is_transient_network_error(
|
||||
"aria2 error code 1: Failed to receive data, cause: protocol error"
|
||||
));
|
||||
assert!(is_transient_network_error(
|
||||
"SSL/TLS handshake failure: protocol error"
|
||||
));
|
||||
}
|
||||
|
||||
// --- transient classification: negative cases -------------------------
|
||||
@@ -282,6 +318,8 @@ mod tests {
|
||||
#[test]
|
||||
fn refuses_to_retry_permanent_http_statuses() {
|
||||
assert!(!is_transient_network_error("HTTP 404 Not Found"));
|
||||
assert!(!is_transient_network_error("HTTP/1.1 403 Forbidden"));
|
||||
assert!(!is_transient_network_error("HTTP Error 404: Not Found"));
|
||||
assert!(!is_transient_network_error("HTTP 403 Forbidden"));
|
||||
assert!(!is_transient_network_error("HTTP 410 Gone"));
|
||||
assert!(!is_transient_network_error("HTTP 401 Unauthorized"));
|
||||
|
||||
+233
-10
@@ -20,6 +20,7 @@ pub fn decode_stored_settings(stored: &Value) -> Result<PersistedSettings, Strin
|
||||
let document = decode_document(stored)?;
|
||||
let mut state = settings_state(&document)?.clone();
|
||||
migrate_location_settings(&mut state)?;
|
||||
sanitize_persisted_setting_values(&mut state);
|
||||
let mut merged = serde_json::to_value(default_settings())
|
||||
.map_err(|error| format!("failed to serialize settings defaults: {error}"))?;
|
||||
merge_json(&mut merged, &state);
|
||||
@@ -77,6 +78,41 @@ pub fn preserve_scheduler_runtime_keys(
|
||||
.map_err(|error| format!("failed to encode persisted settings: {error}"))
|
||||
}
|
||||
|
||||
pub fn preserve_portable_pairing_token(
|
||||
existing: Option<&str>,
|
||||
incoming: &str,
|
||||
) -> Result<String, String> {
|
||||
let Some(existing) = existing else {
|
||||
return Ok(incoming.to_string());
|
||||
};
|
||||
|
||||
let existing_document = decode_document(&Value::String(existing.to_string()))?;
|
||||
let existing_state = settings_state(&existing_document)?;
|
||||
let Some(token) = existing_state
|
||||
.get("extensionPairingToken")
|
||||
.and_then(Value::as_str)
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
else {
|
||||
return Ok(incoming.to_string());
|
||||
};
|
||||
|
||||
let mut incoming_document = decode_document(&Value::String(incoming.to_string()))?;
|
||||
let incoming_state = settings_state_mut(&mut incoming_document)?;
|
||||
let incoming_token_present = incoming_state
|
||||
.get("extensionPairingToken")
|
||||
.and_then(Value::as_str)
|
||||
.is_some_and(|value| !value.trim().is_empty());
|
||||
if !incoming_token_present {
|
||||
incoming_state.insert(
|
||||
"extensionPairingToken".to_string(),
|
||||
Value::String(token.to_string()),
|
||||
);
|
||||
}
|
||||
|
||||
serde_json::to_string(&incoming_document)
|
||||
.map_err(|error| format!("failed to encode portable settings: {error}"))
|
||||
}
|
||||
|
||||
fn decode_document(stored: &Value) -> Result<Value, String> {
|
||||
match stored {
|
||||
Value::String(text) => serde_json::from_str(text)
|
||||
@@ -118,13 +154,108 @@ fn merge_json(target: &mut Value, source: &Value) {
|
||||
(Value::Object(target), Value::Object(source)) => {
|
||||
for (key, value) in source {
|
||||
if let Some(target_value) = target.get_mut(key) {
|
||||
merge_json(target_value, value);
|
||||
if json_value_types_match(target_value, value) {
|
||||
merge_json(target_value, value);
|
||||
}
|
||||
} else {
|
||||
target.insert(key.clone(), value.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
(target, source) => *target = source.clone(),
|
||||
(target, source) if json_value_types_match(target, source) => *target = source.clone(),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn json_value_types_match(left: &Value, right: &Value) -> bool {
|
||||
matches!(
|
||||
(left, right),
|
||||
(Value::Null, Value::Null)
|
||||
| (Value::Bool(_), Value::Bool(_))
|
||||
| (Value::Number(_), Value::Number(_))
|
||||
| (Value::String(_), Value::String(_))
|
||||
| (Value::Array(_), Value::Array(_))
|
||||
| (Value::Object(_), Value::Object(_))
|
||||
)
|
||||
}
|
||||
|
||||
fn sanitize_persisted_setting_values(state: &mut Value) {
|
||||
let Some(state) = state.as_object_mut() else {
|
||||
return;
|
||||
};
|
||||
|
||||
sanitize_integer_setting(state, "maxConcurrentDownloads", |value| value.as_u64().is_some());
|
||||
sanitize_integer_setting(state, "perServerConnections", |value| value.as_i64().is_some());
|
||||
sanitize_integer_setting(state, "maxAutomaticRetries", |value| value.as_i64().is_some());
|
||||
sanitize_allowed_string(
|
||||
state,
|
||||
"theme",
|
||||
&["system", "light", "dark", "dracula", "nord"],
|
||||
);
|
||||
sanitize_allowed_string(state, "appFontSize", &["small", "standard", "large"]);
|
||||
sanitize_allowed_string(state, "listRowDensity", &["compact", "standard", "relaxed"]);
|
||||
sanitize_allowed_string(state, "activeSettingsTab", &[
|
||||
"downloads",
|
||||
"lookandfeel",
|
||||
"network",
|
||||
"locations",
|
||||
"sitelogins",
|
||||
"power",
|
||||
"engine",
|
||||
"integrations",
|
||||
"about",
|
||||
]);
|
||||
sanitize_allowed_string(state, "proxyMode", &["none", "system", "custom"]);
|
||||
sanitize_allowed_string(
|
||||
state,
|
||||
"mediaCookieSource",
|
||||
&[
|
||||
"none", "safari", "chrome", "chromium", "firefox", "edge", "brave", "opera",
|
||||
"vivaldi", "whale",
|
||||
],
|
||||
);
|
||||
|
||||
if let Some(scheduler) = state.get_mut("scheduler").and_then(Value::as_object_mut) {
|
||||
sanitize_allowed_string(
|
||||
scheduler,
|
||||
"postQueueAction",
|
||||
&["none", "sleep", "restart", "shutdown"],
|
||||
);
|
||||
}
|
||||
|
||||
if let Some(logins) = state.get_mut("siteLogins").and_then(Value::as_array_mut) {
|
||||
logins.retain(|login| {
|
||||
let Some(login) = login.as_object() else {
|
||||
return false;
|
||||
};
|
||||
["id", "urlPattern", "username"]
|
||||
.into_iter()
|
||||
.all(|key| login.get(key).and_then(Value::as_str).is_some())
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn sanitize_integer_setting(
|
||||
state: &mut serde_json::Map<String, Value>,
|
||||
key: &str,
|
||||
is_valid: impl Fn(&Value) -> bool,
|
||||
) {
|
||||
if state.get(key).is_some_and(|value| !is_valid(value)) {
|
||||
state.remove(key);
|
||||
}
|
||||
}
|
||||
|
||||
fn sanitize_allowed_string(
|
||||
state: &mut serde_json::Map<String, Value>,
|
||||
key: &str,
|
||||
allowed: &[&str],
|
||||
) {
|
||||
let valid = state
|
||||
.get(key)
|
||||
.and_then(Value::as_str)
|
||||
.is_some_and(|value| allowed.contains(&value));
|
||||
if state.contains_key(key) && !valid {
|
||||
state.remove(key);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -132,6 +263,15 @@ fn validate_settings(settings: &mut PersistedSettings) {
|
||||
if settings.max_concurrent_downloads == 0 {
|
||||
settings.max_concurrent_downloads = default_settings().max_concurrent_downloads;
|
||||
}
|
||||
settings.max_concurrent_downloads = settings.max_concurrent_downloads.min(12);
|
||||
settings.per_server_connections = settings.per_server_connections.clamp(1, 16);
|
||||
settings.max_automatic_retries = settings.max_automatic_retries.clamp(0, 10);
|
||||
if !matches!(
|
||||
settings.last_custom_speed_limit_unit.as_str(),
|
||||
"KB/s" | "MB/s"
|
||||
) {
|
||||
settings.last_custom_speed_limit_unit = default_settings().last_custom_speed_limit_unit;
|
||||
}
|
||||
}
|
||||
|
||||
fn default_category_subfolders() -> HashMap<String, String> {
|
||||
@@ -296,10 +436,12 @@ fn default_settings() -> PersistedSettings {
|
||||
scheduler_last_start_key: String::new(),
|
||||
scheduler_last_stop_key: String::new(),
|
||||
last_custom_speed_limit_ki_b: 1024,
|
||||
last_custom_speed_limit_unit: "MB/s".to_string(),
|
||||
per_server_connections: 16,
|
||||
max_automatic_retries: 3,
|
||||
show_notifications: true,
|
||||
play_completion_sound: true,
|
||||
play_completion_sound: false,
|
||||
auto_add_clipboard_links: false,
|
||||
app_font_size: AppFontSize::Standard,
|
||||
list_row_density: ListRowDensity::Standard,
|
||||
show_dock_badge: true,
|
||||
@@ -312,7 +454,6 @@ fn default_settings() -> PersistedSettings {
|
||||
prevents_sleep_while_downloading: true,
|
||||
media_cookie_source: MediaCookieSource::default(),
|
||||
site_logins: Vec::new(),
|
||||
extension_pairing_token: String::new(),
|
||||
auto_check_updates: true,
|
||||
keychain_access_granted: false,
|
||||
}
|
||||
@@ -320,7 +461,10 @@ fn default_settings() -> PersistedSettings {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{decode_stored_settings, preserve_scheduler_runtime_keys};
|
||||
use super::{
|
||||
decode_stored_settings, default_settings, preserve_portable_pairing_token,
|
||||
preserve_scheduler_runtime_keys,
|
||||
};
|
||||
use serde_json::{json, Value};
|
||||
|
||||
#[test]
|
||||
@@ -396,6 +540,7 @@ mod tests {
|
||||
|
||||
assert_eq!(settings.max_concurrent_downloads, 5);
|
||||
assert_eq!(settings.global_speed_limit, "512K");
|
||||
assert_eq!(settings.last_custom_speed_limit_unit, "MB/s");
|
||||
assert_eq!(settings.speed_limit_preset_values, vec![1.0, 5.0, 10.0]);
|
||||
assert!(!settings.logs_enabled);
|
||||
assert!(!settings.scheduler.enabled);
|
||||
@@ -508,13 +653,63 @@ mod tests {
|
||||
assert_eq!(settings.max_concurrent_downloads, 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clamps_out_of_range_download_settings() {
|
||||
let stored = json!({
|
||||
"state": {
|
||||
"maxConcurrentDownloads": 99,
|
||||
"perServerConnections": -4,
|
||||
"maxAutomaticRetries": 99
|
||||
},
|
||||
"version": 3
|
||||
});
|
||||
|
||||
let settings = decode_stored_settings(&Value::String(stored.to_string())).unwrap();
|
||||
|
||||
assert_eq!(settings.max_concurrent_downloads, 12);
|
||||
assert_eq!(settings.per_server_connections, 1);
|
||||
assert_eq!(settings.max_automatic_retries, 10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_malformed_setting_types_without_dropping_valid_values() {
|
||||
let stored = json!({
|
||||
"state": {
|
||||
"baseDownloadFolder": "/Users/test/Downloads",
|
||||
"maxConcurrentDownloads": "not-a-number",
|
||||
"perServerConnections": 5,
|
||||
"showNotifications": "yes",
|
||||
"theme": "not-a-theme",
|
||||
"siteLogins": [{"id": "valid", "urlPattern": "example.com", "username": "user"}, {"id": 3}]
|
||||
},
|
||||
"version": 3
|
||||
});
|
||||
|
||||
let settings = decode_stored_settings(&Value::String(stored.to_string())).unwrap();
|
||||
|
||||
assert_eq!(settings.base_download_folder, "/Users/test/Downloads");
|
||||
assert_eq!(settings.max_concurrent_downloads, 3);
|
||||
assert_eq!(settings.per_server_connections, 5);
|
||||
assert!(settings.show_notifications);
|
||||
assert!(matches!(settings.theme, crate::ipc::Theme::System));
|
||||
assert_eq!(settings.site_logins.len(), 1);
|
||||
assert_eq!(settings.site_logins[0].id, "valid");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opt_in_defaults_match_the_frontend_defaults() {
|
||||
assert!(!default_settings().play_completion_sound);
|
||||
assert!(!default_settings().auto_add_clipboard_links);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_legacy_extension_pairing_token_field() {
|
||||
// Older versions persisted `extensionPairingToken` as plaintext inside
|
||||
// the settings document. It now lives in the OS keychain and is no
|
||||
// longer part of PersistedSettings. serde ignores the unknown field so
|
||||
// existing installs decode without error; the plaintext value is
|
||||
// simply dropped and a fresh token is minted by the frontend.
|
||||
// Older standard installs persisted `extensionPairingToken` as
|
||||
// plaintext inside the settings document. It now lives in the OS
|
||||
// keychain and is no longer part of PersistedSettings; portable mode
|
||||
// deliberately keeps its token in the portable settings document.
|
||||
// serde ignores the unknown field so existing installs decode without
|
||||
// error; standard-mode migration drops the plaintext value.
|
||||
let stored = json!({
|
||||
"state": {
|
||||
"extensionPairingToken": "plaintext-leaked-secret",
|
||||
@@ -527,4 +722,32 @@ mod tests {
|
||||
|
||||
assert_eq!(settings.max_concurrent_downloads, 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preserves_portable_pairing_token_when_startup_save_races() {
|
||||
let existing = json!({
|
||||
"state": {
|
||||
"extensionPairingToken": "portable-token",
|
||||
"maxConcurrentDownloads": 3
|
||||
},
|
||||
"version": 3
|
||||
})
|
||||
.to_string();
|
||||
let incoming = json!({
|
||||
"state": {
|
||||
"extensionPairingToken": "",
|
||||
"maxConcurrentDownloads": 5
|
||||
},
|
||||
"version": 3
|
||||
})
|
||||
.to_string();
|
||||
|
||||
let merged = preserve_portable_pairing_token(Some(&existing), &incoming).unwrap();
|
||||
let document: Value = serde_json::from_str(&merged).unwrap();
|
||||
assert_eq!(
|
||||
document["state"]["extensionPairingToken"],
|
||||
"portable-token"
|
||||
);
|
||||
assert_eq!(document["state"]["maxConcurrentDownloads"], 5);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
use std::path::{Path, PathBuf};
|
||||
use tauri::{AppHandle, Manager, Runtime};
|
||||
|
||||
pub const PORTABLE_MARKER: &str = "portable.flag";
|
||||
const PORTABLE_DATA_DIR: &str = "data";
|
||||
const PORTABLE_LOG_DIR: &str = "logs";
|
||||
const PORTABLE_WEBVIEW_DIR: &str = "webview";
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum StorageMode {
|
||||
Standard,
|
||||
Portable { root: PathBuf },
|
||||
}
|
||||
|
||||
impl StorageMode {
|
||||
pub fn detect() -> Self {
|
||||
let Some(executable) = std::env::current_exe().ok() else {
|
||||
return Self::Standard;
|
||||
};
|
||||
let Some(root) = executable.parent() else {
|
||||
return Self::Standard;
|
||||
};
|
||||
|
||||
if root.join(PORTABLE_MARKER).is_file() {
|
||||
Self::Portable {
|
||||
root: root.to_path_buf(),
|
||||
}
|
||||
} else {
|
||||
Self::Standard
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn detect_from_root(root: &Path) -> Self {
|
||||
if root.join(PORTABLE_MARKER).is_file() {
|
||||
Self::Portable {
|
||||
root: root.to_path_buf(),
|
||||
}
|
||||
} else {
|
||||
Self::Standard
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct StorageLayout {
|
||||
mode: StorageMode,
|
||||
data_dir: PathBuf,
|
||||
log_dir: PathBuf,
|
||||
webview_dir: PathBuf,
|
||||
}
|
||||
|
||||
impl StorageLayout {
|
||||
pub fn resolve<R: Runtime>(
|
||||
app_handle: &AppHandle<R>,
|
||||
mode: StorageMode,
|
||||
) -> Result<Self, String> {
|
||||
let (mode, data_dir, log_dir, webview_dir) = match mode {
|
||||
StorageMode::Standard => (
|
||||
StorageMode::Standard,
|
||||
app_handle
|
||||
.path()
|
||||
.app_data_dir()
|
||||
.map_err(|error| format!("failed to resolve app data directory: {error}"))?,
|
||||
app_handle
|
||||
.path()
|
||||
.app_log_dir()
|
||||
.map_err(|error| format!("failed to resolve app log directory: {error}"))?,
|
||||
app_handle.path().app_local_data_dir().map_err(|error| {
|
||||
format!("failed to resolve app local data directory: {error}")
|
||||
})?,
|
||||
),
|
||||
StorageMode::Portable { root } => {
|
||||
let data_dir = root.join(PORTABLE_DATA_DIR);
|
||||
(
|
||||
StorageMode::Portable { root },
|
||||
data_dir.clone(),
|
||||
data_dir.join(PORTABLE_LOG_DIR),
|
||||
data_dir.join(PORTABLE_WEBVIEW_DIR),
|
||||
)
|
||||
}
|
||||
};
|
||||
|
||||
Ok(Self {
|
||||
mode,
|
||||
data_dir: canonicalize_storage_path(&data_dir)?,
|
||||
log_dir: canonicalize_storage_path(&log_dir)?,
|
||||
webview_dir: canonicalize_storage_path(&webview_dir)?,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn is_portable(&self) -> bool {
|
||||
matches!(self.mode, StorageMode::Portable { .. })
|
||||
}
|
||||
|
||||
pub fn data_dir(&self) -> &Path {
|
||||
&self.data_dir
|
||||
}
|
||||
|
||||
pub fn log_dir(&self) -> &Path {
|
||||
&self.log_dir
|
||||
}
|
||||
|
||||
pub fn webview_dir(&self) -> &Path {
|
||||
&self.webview_dir
|
||||
}
|
||||
}
|
||||
|
||||
fn canonicalize_storage_path(path: &Path) -> Result<PathBuf, String> {
|
||||
if crate::path_has_symlink_component(path) {
|
||||
return Err(format!(
|
||||
"storage path contains a symlinked component: '{}'",
|
||||
path.display()
|
||||
));
|
||||
}
|
||||
let mut existing = path;
|
||||
let mut missing = Vec::new();
|
||||
loop {
|
||||
match std::fs::symlink_metadata(existing) {
|
||||
Ok(metadata) => {
|
||||
if metadata.file_type().is_symlink() {
|
||||
return Err(format!(
|
||||
"storage path contains a symlinked directory: '{}'",
|
||||
path.display()
|
||||
));
|
||||
}
|
||||
break;
|
||||
}
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
|
||||
Err(error) => {
|
||||
return Err(format!(
|
||||
"failed to inspect storage path '{}': {error}",
|
||||
path.display()
|
||||
));
|
||||
}
|
||||
}
|
||||
missing.push(
|
||||
existing
|
||||
.file_name()
|
||||
.ok_or_else(|| format!("storage path has no existing ancestor: '{}'", path.display()))?
|
||||
.to_owned(),
|
||||
);
|
||||
existing = existing
|
||||
.parent()
|
||||
.ok_or_else(|| format!("storage path has no existing ancestor: '{}'", path.display()))?;
|
||||
}
|
||||
let mut canonical = std::fs::canonicalize(existing)
|
||||
.map_err(|error| format!("failed to canonicalize storage path '{}': {error}", path.display()))?;
|
||||
for component in missing.iter().rev() {
|
||||
canonical.push(component);
|
||||
}
|
||||
Ok(canonical)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{canonicalize_storage_path, StorageMode, PORTABLE_MARKER};
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
use tempfile::TempDir;
|
||||
|
||||
#[test]
|
||||
fn marker_selects_portable_mode() {
|
||||
let root = TempDir::new().unwrap();
|
||||
fs::write(root.path().join(PORTABLE_MARKER), b"portable\n").unwrap();
|
||||
|
||||
assert_eq!(
|
||||
StorageMode::detect_from_root(root.path()),
|
||||
StorageMode::Portable {
|
||||
root: root.path().to_path_buf()
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_marker_keeps_standard_mode() {
|
||||
let root = TempDir::new().unwrap();
|
||||
|
||||
assert_eq!(
|
||||
StorageMode::detect_from_root(root.path()),
|
||||
StorageMode::Standard
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn rejects_symlinked_storage_directories() {
|
||||
use std::os::unix::fs::symlink;
|
||||
|
||||
let root = TempDir::new().unwrap();
|
||||
let target = TempDir::new().unwrap();
|
||||
let root_path = fs::canonicalize(root.path()).unwrap();
|
||||
let redirected = root_path.join("logs");
|
||||
symlink(target.path(), &redirected).unwrap();
|
||||
|
||||
assert!(canonicalize_storage_path(Path::new(&redirected)).is_err());
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn rejects_dangling_symlinked_storage_directories() {
|
||||
use std::os::unix::fs::symlink;
|
||||
|
||||
let root = TempDir::new().unwrap();
|
||||
let root_path = fs::canonicalize(root.path()).unwrap();
|
||||
let redirected = root_path.join("logs");
|
||||
symlink(root_path.join("missing-target"), &redirected).unwrap();
|
||||
|
||||
assert!(canonicalize_storage_path(Path::new(&redirected)).is_err());
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Firelink",
|
||||
"version": "1.0.4",
|
||||
"version": "1.1.1",
|
||||
"identifier": "com.nimbold.firelink",
|
||||
"build": {
|
||||
"beforeDevCommand": "node scripts/stage-engines.js && npm run dev",
|
||||
@@ -13,6 +13,7 @@
|
||||
"macOSPrivateApi": true,
|
||||
"windows": [
|
||||
{
|
||||
"create": false,
|
||||
"title": "Firelink",
|
||||
"width": 1280,
|
||||
"height": 760,
|
||||
|
||||
@@ -2,13 +2,14 @@
|
||||
"app": {
|
||||
"windows": [
|
||||
{
|
||||
"create": false,
|
||||
"title": "Firelink",
|
||||
"width": 1280,
|
||||
"height": 760,
|
||||
"minWidth": 960,
|
||||
"minHeight": 640,
|
||||
"transparent": false,
|
||||
"decorations": true
|
||||
"decorations": false
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
"app": {
|
||||
"windows": [
|
||||
{
|
||||
"create": false,
|
||||
"title": "Firelink",
|
||||
"width": 1280,
|
||||
"height": 760,
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
"app": {
|
||||
"windows": [
|
||||
{
|
||||
"create": false,
|
||||
"title": "Firelink",
|
||||
"width": 1280,
|
||||
"height": 760,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use firelink_lib::queue::{
|
||||
QueueManager, QueuedTask, SidecarSpawner, SpawnPayload, TaskKind, MEDIA_RUN_CANCELLED,
|
||||
Aria2RefreshOutcome, QueueManager, QueuedTask, SidecarSpawner, SpawnPayload, TaskKind,
|
||||
MEDIA_RUN_CANCELLED,
|
||||
};
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::sync::Arc;
|
||||
@@ -16,13 +17,34 @@ struct CountingSpawner {
|
||||
|
||||
struct DelayedAria2Spawner {
|
||||
gid_tx: tokio::sync::Mutex<Option<tokio::sync::oneshot::Sender<()>>>,
|
||||
add_uri_calls: AtomicUsize,
|
||||
remove_uri_calls: AtomicUsize,
|
||||
}
|
||||
|
||||
struct FailFirstAria2Spawner {
|
||||
add_uri_calls: AtomicUsize,
|
||||
fail_first: std::sync::atomic::AtomicBool,
|
||||
}
|
||||
|
||||
struct RefreshOutcomeSpawner {
|
||||
outcome: Aria2RefreshOutcome,
|
||||
refresh_calls: AtomicUsize,
|
||||
}
|
||||
|
||||
impl FailFirstAria2Spawner {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
add_uri_calls: AtomicUsize::new(0),
|
||||
fail_first: std::sync::atomic::AtomicBool::new(true),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl DelayedAria2Spawner {
|
||||
fn new(gid_tx: tokio::sync::oneshot::Sender<()>) -> Self {
|
||||
Self {
|
||||
gid_tx: tokio::sync::Mutex::new(Some(gid_tx)),
|
||||
add_uri_calls: AtomicUsize::new(0),
|
||||
remove_uri_calls: AtomicUsize::new(0),
|
||||
}
|
||||
}
|
||||
@@ -31,10 +53,14 @@ impl DelayedAria2Spawner {
|
||||
#[async_trait::async_trait]
|
||||
impl SidecarSpawner for DelayedAria2Spawner {
|
||||
async fn add_uri(&self, _id: &str, _payload: &SpawnPayload) -> Result<String, String> {
|
||||
let tx = self.gid_tx.lock().await.take().expect("gid release sender");
|
||||
let _ = tx.send(());
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
Ok("late-gid".to_string())
|
||||
let call = self.add_uri_calls.fetch_add(1, Ordering::SeqCst) + 1;
|
||||
if let Some(tx) = self.gid_tx.lock().await.take() {
|
||||
let _ = tx.send(());
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
Ok("late-gid".to_string())
|
||||
} else {
|
||||
Ok(format!("gid-{call}"))
|
||||
}
|
||||
}
|
||||
|
||||
async fn remove_uri(&self, gid: &str) -> Result<(), String> {
|
||||
@@ -43,11 +69,34 @@ impl SidecarSpawner for DelayedAria2Spawner {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn run_media(&self, _id: &str, _payload: &SpawnPayload) -> Result<(), String> {
|
||||
async fn run_media(&self, _id: &str, _payload: &SpawnPayload, _generation: u64) -> Result<(), String> {
|
||||
unreachable!("media is not used by delayed aria2 tests")
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl SidecarSpawner for FailFirstAria2Spawner {
|
||||
async fn add_uri(&self, _id: &str, _payload: &SpawnPayload) -> Result<String, String> {
|
||||
let call = self.add_uri_calls.fetch_add(1, Ordering::SeqCst) + 1;
|
||||
if self
|
||||
.fail_first
|
||||
.swap(false, std::sync::atomic::Ordering::SeqCst)
|
||||
{
|
||||
Err("initial aria2 RPC failure".to_string())
|
||||
} else {
|
||||
Ok(format!("gid-{call}"))
|
||||
}
|
||||
}
|
||||
|
||||
async fn remove_uri(&self, _gid: &str) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn run_media(&self, _id: &str, _payload: &SpawnPayload, _generation: u64) -> Result<(), String> {
|
||||
unreachable!("media is not used by fail-first aria2 tests")
|
||||
}
|
||||
}
|
||||
|
||||
impl CountingSpawner {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
@@ -57,6 +106,31 @@ impl CountingSpawner {
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl SidecarSpawner for RefreshOutcomeSpawner {
|
||||
async fn add_uri(&self, _id: &str, _payload: &SpawnPayload) -> Result<String, String> {
|
||||
unreachable!("refresh outcome tests do not spawn aria2")
|
||||
}
|
||||
|
||||
async fn remove_uri(&self, _gid: &str) -> Result<(), String> {
|
||||
unreachable!("refresh outcome tests do not remove aria2")
|
||||
}
|
||||
|
||||
async fn refresh_uri(&self, _gid: &str) -> Result<Aria2RefreshOutcome, String> {
|
||||
self.refresh_calls.fetch_add(1, Ordering::SeqCst);
|
||||
Ok(self.outcome)
|
||||
}
|
||||
|
||||
async fn run_media(
|
||||
&self,
|
||||
_id: &str,
|
||||
_payload: &SpawnPayload,
|
||||
_generation: u64,
|
||||
) -> Result<(), String> {
|
||||
unreachable!("refresh outcome tests do not run media")
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl firelink_lib::queue::SidecarSpawner for CountingSpawner {
|
||||
async fn add_uri(&self, _id: &str, _payload: &SpawnPayload) -> Result<String, String> {
|
||||
@@ -66,7 +140,7 @@ impl firelink_lib::queue::SidecarSpawner for CountingSpawner {
|
||||
async fn remove_uri(&self, _gid: &str) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
async fn run_media(&self, _id: &str, _payload: &SpawnPayload) -> Result<(), String> {
|
||||
async fn run_media(&self, _id: &str, _payload: &SpawnPayload, _generation: u64) -> Result<(), String> {
|
||||
self.media_calls.fetch_add(1, Ordering::SeqCst);
|
||||
Ok(())
|
||||
}
|
||||
@@ -88,6 +162,7 @@ fn sample_task(id: &str) -> QueuedTask {
|
||||
id: id.to_string(),
|
||||
queue_id: "main".to_string(),
|
||||
kind: TaskKind::Aria2,
|
||||
lifecycle_generation: 0,
|
||||
payload: SpawnPayload::default(),
|
||||
}
|
||||
}
|
||||
@@ -183,6 +258,24 @@ async fn ensure_aria2_permit_does_not_double_acquire() {
|
||||
assert_eq!(mgr.available_permits(), 2);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn stale_aria2_permit_candidate_cannot_replace_current_permit() {
|
||||
let (mgr, _spawner) = make_manager(2);
|
||||
let existing = mgr.acquire_permit().await.unwrap();
|
||||
assert!(mgr
|
||||
.park_aria2_permit_if_missing("a", existing)
|
||||
.await);
|
||||
|
||||
let candidate = mgr.acquire_aria2_permit_candidate().await.unwrap();
|
||||
assert!(!mgr
|
||||
.park_aria2_permit_if_missing("a", candidate)
|
||||
.await);
|
||||
assert_eq!(mgr.available_permits(), 1);
|
||||
|
||||
mgr.release_permit("a").await;
|
||||
assert_eq!(mgr.available_permits(), 2);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn aria2_control_epoch_invalidates_stale_resume_workers() {
|
||||
let (mgr, _spawner) = make_manager(1);
|
||||
@@ -195,6 +288,75 @@ async fn aria2_control_epoch_invalidates_stale_resume_workers() {
|
||||
assert!(mgr.is_aria2_control_epoch_current("a", pause).await);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn stale_terminal_event_cannot_complete_a_newer_control_epoch() {
|
||||
use firelink_lib::queue::PendingOutcome;
|
||||
|
||||
let (mgr, _spawner) = make_manager(1);
|
||||
let manager = Arc::new(mgr);
|
||||
manager.push(aria2_task("stale-event")).await.unwrap();
|
||||
let permit = manager.acquire_permit().await.expect("permit");
|
||||
manager.park_permit("stale-event", permit).await;
|
||||
|
||||
let old_epoch = manager.next_aria2_control_epoch("stale-event").await;
|
||||
manager
|
||||
.remember_gid("stale-event".to_string(), "gid-old".to_string())
|
||||
.await;
|
||||
manager.next_aria2_control_epoch("stale-event").await;
|
||||
|
||||
manager
|
||||
.handle_aria2_event("gid-old", PendingOutcome::Complete)
|
||||
.await;
|
||||
|
||||
assert!(
|
||||
!manager
|
||||
.is_aria2_control_epoch_current("stale-event", old_epoch)
|
||||
.await
|
||||
);
|
||||
assert_eq!(
|
||||
manager.available_permits(),
|
||||
0,
|
||||
"a terminal event from an older epoch must not release the newer lifecycle permit"
|
||||
);
|
||||
manager.forget_aria2_gid("stale-event").await;
|
||||
manager.release_permit("stale-event").await;
|
||||
manager.release_registered_id("stale-event").await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn resumed_gid_rebinds_to_the_new_control_epoch() {
|
||||
use firelink_lib::queue::PendingOutcome;
|
||||
|
||||
let (mgr, _spawner) = make_manager(1);
|
||||
let manager = Arc::new(mgr);
|
||||
manager.push(aria2_task("resumed-gid")).await.unwrap();
|
||||
let permit = manager.acquire_permit().await.expect("permit");
|
||||
manager.park_permit("resumed-gid", permit).await;
|
||||
manager
|
||||
.remember_gid("resumed-gid".to_string(), "gid-resumed".to_string())
|
||||
.await;
|
||||
|
||||
let resume_epoch = manager.next_aria2_control_epoch("resumed-gid").await;
|
||||
manager
|
||||
.handle_aria2_event("gid-resumed", PendingOutcome::Complete)
|
||||
.await;
|
||||
assert_eq!(
|
||||
manager.available_permits(),
|
||||
0,
|
||||
"the old GID epoch must not complete the resumed lifecycle"
|
||||
);
|
||||
|
||||
assert!(manager
|
||||
.rebind_aria2_gid_epoch("resumed-gid", "gid-resumed", resume_epoch)
|
||||
.await);
|
||||
manager
|
||||
.handle_aria2_event("gid-resumed", PendingOutcome::Complete)
|
||||
.await;
|
||||
|
||||
assert_eq!(manager.available_permits(), 1);
|
||||
assert!(manager.aria2_gid_for_download("resumed-gid").is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn forgetting_aria2_gid_clears_mapping_without_releasing_twice() {
|
||||
let (mgr, _spawner) = make_manager(1);
|
||||
@@ -386,6 +548,7 @@ fn aria2_task(id: &str) -> QueuedTask {
|
||||
id: id.to_string(),
|
||||
queue_id: "main".to_string(),
|
||||
kind: TaskKind::Aria2,
|
||||
lifecycle_generation: 0,
|
||||
payload: SpawnPayload::default(),
|
||||
}
|
||||
}
|
||||
@@ -395,6 +558,7 @@ fn media_task(id: &str) -> QueuedTask {
|
||||
id: id.to_string(),
|
||||
queue_id: "main".to_string(),
|
||||
kind: TaskKind::Media,
|
||||
lifecycle_generation: 0,
|
||||
payload: SpawnPayload::default(),
|
||||
}
|
||||
}
|
||||
@@ -413,7 +577,7 @@ impl SidecarSpawner for FixedMediaSpawner {
|
||||
unreachable!("aria2 is not used by media terminal-state tests")
|
||||
}
|
||||
|
||||
async fn run_media(&self, _id: &str, _payload: &SpawnPayload) -> Result<(), String> {
|
||||
async fn run_media(&self, _id: &str, _payload: &SpawnPayload, _generation: u64) -> Result<(), String> {
|
||||
self.outcome.clone()
|
||||
}
|
||||
}
|
||||
@@ -478,6 +642,7 @@ async fn media_cancellation_does_not_emit_completed() {
|
||||
assert!(!statuses.iter().any(|status| status == "failed"));
|
||||
assert!(!statuses.iter().any(|status| status == "completed"));
|
||||
assert_eq!(manager.available_permits(), 1);
|
||||
assert!(!manager.is_registered("media-cancelled").await);
|
||||
|
||||
dispatcher.abort();
|
||||
}
|
||||
@@ -514,6 +679,73 @@ async fn aria2_permit_survives_rpc_return() {
|
||||
handle.abort();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn failed_refresh_that_leaves_gid_paused_releases_permit_but_keeps_resume_mapping() {
|
||||
let app = mock_builder()
|
||||
.build(mock_context(noop_assets()))
|
||||
.expect("mock app");
|
||||
let spawner = Arc::new(RefreshOutcomeSpawner {
|
||||
outcome: Aria2RefreshOutcome::Paused,
|
||||
refresh_calls: AtomicUsize::new(0),
|
||||
});
|
||||
let manager = QueueManager::test_new(
|
||||
app.handle().clone(),
|
||||
1,
|
||||
Arc::clone(&spawner) as Arc<dyn SidecarSpawner>,
|
||||
);
|
||||
manager.push(aria2_task("refresh-paused")).await.unwrap();
|
||||
assert!(manager.ensure_aria2_permit("refresh-paused").await);
|
||||
manager
|
||||
.remember_gid("refresh-paused".to_string(), "gid-refresh-paused".to_string())
|
||||
.await;
|
||||
|
||||
let epoch = manager.current_aria2_control_epoch("refresh-paused").await;
|
||||
manager
|
||||
.refresh_aria2_connections("refresh-paused", "gid-refresh-paused", epoch)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(spawner.refresh_calls.load(Ordering::SeqCst), 1);
|
||||
assert_eq!(manager.available_permits(), 1);
|
||||
assert!(!manager.has_active_permit("refresh-paused").await);
|
||||
assert_eq!(
|
||||
manager.aria2_gid_for_download("refresh-paused").as_deref(),
|
||||
Some("gid-refresh-paused")
|
||||
);
|
||||
assert!(manager.is_registered("refresh-paused").await);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn stale_refresh_observation_cannot_touch_a_newer_control_epoch() {
|
||||
let app = mock_builder()
|
||||
.build(mock_context(noop_assets()))
|
||||
.expect("mock app");
|
||||
let spawner = Arc::new(RefreshOutcomeSpawner {
|
||||
outcome: Aria2RefreshOutcome::Resumed,
|
||||
refresh_calls: AtomicUsize::new(0),
|
||||
});
|
||||
let manager = QueueManager::test_new(
|
||||
app.handle().clone(),
|
||||
1,
|
||||
Arc::clone(&spawner) as Arc<dyn SidecarSpawner>,
|
||||
);
|
||||
manager.push(aria2_task("refresh-stale")).await.unwrap();
|
||||
assert!(manager.ensure_aria2_permit("refresh-stale").await);
|
||||
manager
|
||||
.remember_gid("refresh-stale".to_string(), "gid-refresh-stale".to_string())
|
||||
.await;
|
||||
let stale_epoch = manager.current_aria2_control_epoch("refresh-stale").await;
|
||||
manager.next_aria2_control_epoch("refresh-stale").await;
|
||||
|
||||
manager
|
||||
.refresh_aria2_connections("refresh-stale", "gid-refresh-stale", stale_epoch)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(spawner.refresh_calls.load(Ordering::SeqCst), 0);
|
||||
assert!(manager.has_active_permit("refresh-stale").await);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn transient_aria2_error_reissues_after_backoff() {
|
||||
use firelink_lib::queue::PendingOutcome;
|
||||
@@ -534,7 +766,9 @@ async fn transient_aria2_error_reissues_after_backoff() {
|
||||
manager
|
||||
.handle_aria2_event(
|
||||
"gid-1",
|
||||
PendingOutcome::Error("Timeout.".to_string()),
|
||||
PendingOutcome::Error(
|
||||
"aria2 error code 1: Failed to receive data, cause: protocol error".to_string(),
|
||||
),
|
||||
)
|
||||
.await;
|
||||
|
||||
@@ -552,7 +786,7 @@ async fn transient_aria2_error_reissues_after_backoff() {
|
||||
manager
|
||||
.handle_aria2_event(
|
||||
"gid-2",
|
||||
PendingOutcome::Error("Timeout.".to_string()),
|
||||
PendingOutcome::Error("SSL/TLS handshake failure: protocol error".to_string()),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(spawner.add_uri_calls.load(Ordering::SeqCst), 2);
|
||||
@@ -563,6 +797,296 @@ async fn transient_aria2_error_reissues_after_backoff() {
|
||||
dispatcher.abort();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn duplicate_transient_events_schedule_only_one_retry_worker() {
|
||||
use firelink_lib::queue::PendingOutcome;
|
||||
|
||||
let (mgr, spawner) = make_manager(1);
|
||||
let manager = Arc::new(mgr);
|
||||
let mut task = aria2_task("duplicate-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;
|
||||
|
||||
let error = PendingOutcome::Error(
|
||||
"aria2 error code 1: Failed to receive data, cause: protocol error".to_string(),
|
||||
);
|
||||
manager.handle_aria2_event("gid-1", error.clone()).await;
|
||||
manager.handle_aria2_event("gid-1", error).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("one retry should be issued");
|
||||
assert_eq!(
|
||||
spawner.add_uri_calls.load(Ordering::SeqCst),
|
||||
2,
|
||||
"duplicate terminal events must not create duplicate aria2 jobs"
|
||||
);
|
||||
|
||||
manager
|
||||
.handle_aria2_event(
|
||||
"gid-2",
|
||||
PendingOutcome::Error("HTTP 404 Not Found".to_string()),
|
||||
)
|
||||
.await;
|
||||
dispatcher.abort();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn stale_retry_worker_cannot_reenter_after_new_control_epoch() {
|
||||
use firelink_lib::queue::PendingOutcome;
|
||||
|
||||
let (mgr, spawner) = make_manager(1);
|
||||
let manager = Arc::new(mgr);
|
||||
let mut task = aria2_task("stale-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;
|
||||
manager
|
||||
.handle_aria2_event(
|
||||
"gid-1",
|
||||
PendingOutcome::Error(
|
||||
"aria2 error code 1: Failed to receive data, cause: protocol error".to_string(),
|
||||
),
|
||||
)
|
||||
.await;
|
||||
|
||||
// Simulate a newer pause/resume lifecycle while the old worker is in its
|
||||
// cancel-safe backoff. Clearing the reusable cancellation flag must not
|
||||
// revive the worker because its control epoch is stale.
|
||||
manager.next_aria2_control_epoch("stale-retry").await;
|
||||
manager.allow_aria2_retries("stale-retry").await;
|
||||
tokio::time::sleep(Duration::from_secs(3)).await;
|
||||
|
||||
assert_eq!(
|
||||
spawner.add_uri_calls.load(Ordering::SeqCst),
|
||||
1,
|
||||
"a retry worker from an older lifecycle must not add a new gid"
|
||||
);
|
||||
manager.release_permit("stale-retry").await;
|
||||
dispatcher.abort();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn completion_event_for_retrying_gid_cannot_release_new_lifecycle_permit() {
|
||||
use firelink_lib::queue::PendingOutcome;
|
||||
|
||||
let (mgr, spawner) = make_manager(1);
|
||||
let manager = Arc::new(mgr);
|
||||
let mut task = aria2_task("retry-complete-race");
|
||||
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;
|
||||
manager
|
||||
.handle_aria2_event(
|
||||
"gid-1",
|
||||
PendingOutcome::Error(
|
||||
"aria2 error code 1: Failed to receive data, cause: protocol error".to_string(),
|
||||
),
|
||||
)
|
||||
.await;
|
||||
manager
|
||||
.handle_aria2_event("gid-1", PendingOutcome::Complete)
|
||||
.await;
|
||||
assert_eq!(
|
||||
manager.available_permits(),
|
||||
0,
|
||||
"a duplicate completion for the retrying gid must not free the permit"
|
||||
);
|
||||
|
||||
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("retry should create the next gid");
|
||||
manager
|
||||
.handle_aria2_event("gid-2", PendingOutcome::Complete)
|
||||
.await;
|
||||
assert_eq!(manager.available_permits(), 1);
|
||||
dispatcher.abort();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn initial_aria2_add_failure_releases_registry_for_restart() {
|
||||
let app = mock_builder()
|
||||
.build(mock_context(noop_assets()))
|
||||
.expect("mock app");
|
||||
let spawner = Arc::new(FailFirstAria2Spawner::new());
|
||||
let manager = Arc::new(QueueManager::test_new(
|
||||
app.handle().clone(),
|
||||
1,
|
||||
spawner.clone(),
|
||||
));
|
||||
manager
|
||||
.push_with_generation(aria2_task("initial-failure"), 1)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let dispatcher = {
|
||||
let manager = Arc::clone(&manager);
|
||||
tokio::spawn(async move { manager.run_dispatcher().await })
|
||||
};
|
||||
timeout(Duration::from_secs(1), async {
|
||||
loop {
|
||||
if !manager.is_registered("initial-failure").await {
|
||||
break;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(20)).await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("failed initial addUri must release the registry id");
|
||||
|
||||
manager
|
||||
.push_with_generation(aria2_task("initial-failure"), 2)
|
||||
.await
|
||||
.expect("the same download must be restartable after initial add failure");
|
||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||
assert_eq!(spawner.add_uri_calls.load(Ordering::SeqCst), 2);
|
||||
manager.release_permit("initial-failure").await;
|
||||
dispatcher.abort();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn late_initial_gid_cannot_attach_to_a_newer_lifecycle() {
|
||||
let app = mock_builder()
|
||||
.build(mock_context(noop_assets()))
|
||||
.expect("mock app");
|
||||
let (gid_started_tx, gid_started_rx) = tokio::sync::oneshot::channel();
|
||||
let spawner = Arc::new(DelayedAria2Spawner::new(gid_started_tx));
|
||||
let manager = Arc::new(QueueManager::test_new(
|
||||
app.handle().clone(),
|
||||
1,
|
||||
spawner.clone(),
|
||||
));
|
||||
manager
|
||||
.push_with_generation(aria2_task("dispatch-race"), 1)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let dispatcher = {
|
||||
let manager = Arc::clone(&manager);
|
||||
tokio::spawn(async move { manager.run_dispatcher().await })
|
||||
};
|
||||
gid_started_rx.await.expect("first addUri should start");
|
||||
|
||||
// Model pause while the first addUri is still resolving, followed by a
|
||||
// new enqueue for the same frontend download id.
|
||||
manager.next_aria2_control_epoch("dispatch-race").await;
|
||||
manager.cancel_aria2_retries("dispatch-race").await;
|
||||
manager.clear_aria2_retry_state("dispatch-race").await;
|
||||
manager.release_permit("dispatch-race").await;
|
||||
manager.release_registered_id("dispatch-race").await;
|
||||
manager
|
||||
.push_with_generation(aria2_task("dispatch-race"), 2)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
timeout(Duration::from_secs(1), async {
|
||||
loop {
|
||||
if manager.aria2_gid_for_download("dispatch-race").as_deref() == Some("gid-2") {
|
||||
break;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("new lifecycle should own the mapped gid");
|
||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||
|
||||
assert_eq!(spawner.add_uri_calls.load(Ordering::SeqCst), 2);
|
||||
assert_eq!(
|
||||
spawner.remove_uri_calls.load(Ordering::SeqCst),
|
||||
1,
|
||||
"the late gid from the old lifecycle must be removed"
|
||||
);
|
||||
assert_eq!(
|
||||
manager.aria2_gid_for_download("dispatch-race").as_deref(),
|
||||
Some("gid-2")
|
||||
);
|
||||
manager.release_permit("dispatch-race").await;
|
||||
dispatcher.abort();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn transient_error_buffered_before_gid_mapping_still_retries() {
|
||||
use firelink_lib::queue::PendingOutcome;
|
||||
|
||||
let app = mock_builder()
|
||||
.build(mock_context(noop_assets()))
|
||||
.expect("mock app");
|
||||
let (gid_started_tx, gid_started_rx) = tokio::sync::oneshot::channel();
|
||||
let spawner = Arc::new(DelayedAria2Spawner::new(gid_started_tx));
|
||||
let manager = Arc::new(QueueManager::test_new(
|
||||
app.handle().clone(),
|
||||
1,
|
||||
spawner.clone(),
|
||||
));
|
||||
let mut task = aria2_task("early-error");
|
||||
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 })
|
||||
};
|
||||
gid_started_rx.await.expect("initial addUri should start");
|
||||
manager
|
||||
.handle_aria2_event(
|
||||
"late-gid",
|
||||
PendingOutcome::Error(
|
||||
"aria2 error code 1: Failed to receive data, cause: protocol error".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("the buffered transient error must enter the retry loop");
|
||||
|
||||
manager
|
||||
.handle_aria2_event(
|
||||
"gid-2",
|
||||
PendingOutcome::Error("HTTP 404 Not Found".to_string()),
|
||||
)
|
||||
.await;
|
||||
dispatcher.abort();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn gid_completion_before_store_buffers_and_reconciles() {
|
||||
use firelink_lib::queue::PendingOutcome;
|
||||
@@ -675,6 +1199,28 @@ async fn move_up_down_reorders_pending() {
|
||||
assert_eq!(mgr_arc.pending_order(None).await, vec!["c", "a", "b"]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn multi_move_reorders_selected_items_as_one_atomic_block() {
|
||||
use firelink_lib::ipc::QueueDirection;
|
||||
|
||||
let (mgr, _spawner) = make_manager(3);
|
||||
for id in ["a", "b", "c", "d", "e"] {
|
||||
mgr.push(sample_task(id)).await.unwrap();
|
||||
}
|
||||
|
||||
let selected = vec!["b".to_string(), "d".to_string()];
|
||||
assert_eq!(
|
||||
mgr.move_many_in_queue(&selected, "main", QueueDirection::Up)
|
||||
.await,
|
||||
vec!["b", "d", "a", "c", "e"]
|
||||
);
|
||||
assert_eq!(
|
||||
mgr.move_many_in_queue(&selected, "main", QueueDirection::Down)
|
||||
.await,
|
||||
vec!["a", "b", "d", "c", "e"]
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn moving_one_queue_does_not_reorder_another_queue() {
|
||||
use firelink_lib::ipc::QueueDirection;
|
||||
|
||||
+331
-109
@@ -1,4 +1,4 @@
|
||||
import { initMediaDomains, isActiveDownloadStatus, normalizeSpeedLimitForBackend } from './utils/downloads';
|
||||
import { initMediaDomains, isActiveDownloadStatus, isTransferActiveStatus } from './utils/downloads';
|
||||
import { schedulerCompletionState } from './utils/schedulerCompletion';
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { Sidebar, SidebarFilter } from "./components/Sidebar";
|
||||
@@ -8,10 +8,11 @@ import SettingsView from "./components/SettingsView";
|
||||
import { PropertiesModal } from "./components/PropertiesModal";
|
||||
import { DeleteConfirmationModal } from "./components/DeleteConfirmationModal";
|
||||
import { extractValidDownloadUrls } from './utils/url';
|
||||
import { readClipboardDownloadUrls } from './utils/clipboard';
|
||||
import { listenEvent as listen, invokeCommand as invoke } from "./ipc";
|
||||
import { useDownloadStore, MAIN_QUEUE_ID } from './store/useDownloadStore';
|
||||
import { useDownloadStore, MAIN_QUEUE_ID, type ExtensionDownloadRequest } from './store/useDownloadStore';
|
||||
import { initDownloadListener } from './store/downloadStore';
|
||||
import { useSettingsStore } from "./store/useSettingsStore";
|
||||
import { subscribeToSettingsPersistenceErrors, useSettingsStore } from "./store/useSettingsStore";
|
||||
import { isPermissionGranted, requestPermission, sendNotification } from '@tauri-apps/plugin-notification';
|
||||
import SchedulerView from "./components/SchedulerView";
|
||||
import SpeedLimiterView from "./components/SpeedLimiterView";
|
||||
@@ -19,10 +20,18 @@ import LogsView from "./components/LogsView";
|
||||
import { KeychainPermissionModal } from "./components/KeychainPermissionModal";
|
||||
import { WindowControls } from "./components/WindowControls";
|
||||
import { useToast } from "./contexts/ToastContext";
|
||||
import { setLogStreamActive } from './utils/logger';
|
||||
import { openUrl } from '@tauri-apps/plugin-opener';
|
||||
import { usePlatformInfo } from './utils/platform';
|
||||
import { getPlatformInfo, usePlatformInfo } from './utils/platform';
|
||||
import {
|
||||
getKeychainAccessReady,
|
||||
getKeychainConsentVersion,
|
||||
getKeychainStartupDecision
|
||||
} from './utils/keychainStartup';
|
||||
import { getVersion } from '@tauri-apps/api/app';
|
||||
import type { PostQueueAction } from './bindings/PostQueueAction';
|
||||
import { PanelLeft } from 'lucide-react';
|
||||
import { isTrustedFirelinkReleaseUrl } from './utils/releaseUrls';
|
||||
|
||||
let automaticUpdateCheckStarted = false;
|
||||
const processingScheduleKeys = new Set<string>();
|
||||
@@ -93,6 +102,7 @@ function App() {
|
||||
const platform = usePlatformInfo();
|
||||
const [filter, setFilter] = useState<SidebarFilter>('all');
|
||||
const [coreReady, setCoreReady] = useState(false);
|
||||
const [keychainConsentVersion, setKeychainConsentVersion] = useState('');
|
||||
|
||||
const [sidebarWidth, setSidebarWidth] = useState(() => {
|
||||
const stored = Number(window.localStorage.getItem('firelink-sidebar-width'));
|
||||
@@ -106,30 +116,32 @@ function App() {
|
||||
const appFontSize = useSettingsStore(state => state.appFontSize);
|
||||
const listRowDensity = useSettingsStore(state => state.listRowDensity);
|
||||
const autoCheckUpdates = useSettingsStore(state => state.autoCheckUpdates);
|
||||
const autoAddClipboardLinks = useSettingsStore(state => state.autoAddClipboardLinks);
|
||||
const showNotifications = useSettingsStore(state => state.showNotifications);
|
||||
const showDockBadge = useSettingsStore(state => state.showDockBadge);
|
||||
const showMenuBarIcon = useSettingsStore(state => state.showMenuBarIcon);
|
||||
const logsEnabled = useSettingsStore(state => state.logsEnabled);
|
||||
const extensionPairingToken = useSettingsStore(state => state.extensionPairingToken);
|
||||
const showKeychainModal = useSettingsStore(state => state.showKeychainModal);
|
||||
const downloads = useDownloadStore(state => state.downloads);
|
||||
const activeDownloadCount = downloads.filter(download => download.status === 'downloading').length;
|
||||
const activeDownloadCount = downloads.filter(download => isTransferActiveStatus(download.status)).length;
|
||||
const queuedCount = downloads.filter(download =>
|
||||
download.status === 'queued' || download.status === 'staged'
|
||||
).length;
|
||||
const doneCount = downloads.filter(download => download.status === 'completed').length;
|
||||
const schedulerRunning = useSettingsStore(state => state.schedulerRunning);
|
||||
const schedulerActiveDownloadIds = useSettingsStore(state => state.schedulerActiveDownloadIds);
|
||||
const globalSpeedLimit = useSettingsStore(state => state.globalSpeedLimit);
|
||||
const previousSpeedLimit = useRef<string | null>(null);
|
||||
const pendingPostActionTimer = useRef<number | null>(null);
|
||||
const startupResumeStarted = useRef(false);
|
||||
const startupInputReady = useRef(false);
|
||||
const frontendReadyUpdate = useRef<Promise<void>>(Promise.resolve());
|
||||
const pendingStartupInputs = useRef<Array<
|
||||
| { type: 'extension'; payload: ExtensionDownloadRequest }
|
||||
| { type: 'deep-link'; payload: string }
|
||||
>>([]);
|
||||
const maxConcurrentDownloads = useSettingsStore(state => state.maxConcurrentDownloads);
|
||||
const preventsSleepWhileDownloading = useSettingsStore(state => state.preventsSleepWhileDownloading);
|
||||
const activeTransferCount = downloads.filter(download =>
|
||||
download.status === 'downloading' ||
|
||||
download.status === 'processing' ||
|
||||
download.status === 'retrying'
|
||||
).length;
|
||||
const { addToast } = useToast();
|
||||
const activeTransferCount = downloads.filter(download => isTransferActiveStatus(download.status)).length;
|
||||
const { addToast, removeToast } = useToast();
|
||||
const isMacUserAgent = navigator.userAgent.includes('Mac');
|
||||
const usesCustomWindowControls = !isMacUserAgent && platform.os !== 'macos';
|
||||
// Keep dialogs out of the titlebar area while platform detection is still
|
||||
@@ -137,6 +149,14 @@ function App() {
|
||||
// briefly rendering underneath native or custom window controls.
|
||||
const hasWindowChrome = isMacUserAgent || ['macos', 'windows', 'linux', 'unknown'].includes(platform.os);
|
||||
|
||||
useEffect(() => subscribeToSettingsPersistenceErrors(() => {
|
||||
addToast({
|
||||
message: 'Could not save settings. Check storage permissions and try again.',
|
||||
variant: 'error',
|
||||
isActionable: true
|
||||
});
|
||||
}), [addToast]);
|
||||
|
||||
const acknowledgePairingTokenChange = () => {
|
||||
invoke('acknowledge_pairing_token_change').catch(error => {
|
||||
console.error('Failed to acknowledge pairing token migration notice:', error);
|
||||
@@ -150,24 +170,33 @@ function App() {
|
||||
}
|
||||
}, []);
|
||||
|
||||
const queueFrontendReadyUpdate = useCallback((ready: boolean) => {
|
||||
const update = frontendReadyUpdate.current
|
||||
.catch(() => undefined)
|
||||
.then(() => invoke('set_extension_frontend_ready', { ready }));
|
||||
frontendReadyUpdate.current = update;
|
||||
return update;
|
||||
}, []);
|
||||
|
||||
const schedulePostQueueAction = useCallback((action: Exclude<PostQueueAction, 'none'>) => {
|
||||
clearPendingPostActionTimer();
|
||||
|
||||
const actionLabel = action === 'shutdown' ? 'Shut down' : action === 'restart' ? 'Restart' : 'Sleep';
|
||||
let timerId: number | null = null;
|
||||
let toastId: string | null = null;
|
||||
const cancel = () => {
|
||||
if (timerId !== null) {
|
||||
window.clearTimeout(timerId);
|
||||
if (pendingPostActionTimer.current === timerId) {
|
||||
pendingPostActionTimer.current = null;
|
||||
}
|
||||
timerId = null;
|
||||
clearPendingPostActionTimer();
|
||||
timerId = null;
|
||||
if (toastId !== null) {
|
||||
removeToast(toastId);
|
||||
toastId = null;
|
||||
}
|
||||
};
|
||||
|
||||
addToast({
|
||||
toastId = addToast({
|
||||
variant: 'warning',
|
||||
isActionable: true,
|
||||
onDismiss: clearPendingPostActionTimer,
|
||||
message: (
|
||||
<div className="flex items-center gap-3">
|
||||
<span>{actionLabel} in 10 seconds.</span>
|
||||
@@ -183,6 +212,10 @@ function App() {
|
||||
});
|
||||
|
||||
timerId = window.setTimeout(() => {
|
||||
if (toastId !== null) {
|
||||
removeToast(toastId);
|
||||
toastId = null;
|
||||
}
|
||||
if (pendingPostActionTimer.current === timerId) {
|
||||
pendingPostActionTimer.current = null;
|
||||
}
|
||||
@@ -193,7 +226,7 @@ function App() {
|
||||
);
|
||||
if (activeTransfers) {
|
||||
addToast({
|
||||
message: 'System action cancelled because another download is active.',
|
||||
message: 'System action cancelled because another download is active or queued.',
|
||||
variant: 'warning',
|
||||
isActionable: true
|
||||
});
|
||||
@@ -209,7 +242,7 @@ function App() {
|
||||
});
|
||||
}, 10_000);
|
||||
pendingPostActionTimer.current = timerId;
|
||||
}, [addToast, clearPendingPostActionTimer]);
|
||||
}, [addToast, clearPendingPostActionTimer, removeToast]);
|
||||
|
||||
const startSidebarResize = (event: React.PointerEvent<HTMLDivElement>) => {
|
||||
event.preventDefault();
|
||||
@@ -236,6 +269,12 @@ function App() {
|
||||
return clearPendingPostActionTimer;
|
||||
}, [clearPendingPostActionTimer]);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeTransferCount > 0) {
|
||||
clearPendingPostActionTimer();
|
||||
}
|
||||
}, [activeTransferCount, clearPendingPostActionTimer]);
|
||||
|
||||
useEffect(() => {
|
||||
initMediaDomains();
|
||||
window.localStorage.setItem('firelink-sidebar-width', String(sidebarWidth));
|
||||
@@ -243,12 +282,92 @@ function App() {
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
let cleanupListeners: (() => void) | null = null;
|
||||
const initialize = async () => {
|
||||
let unlistenDownload: (() => void) | null = null;
|
||||
let unlistenTerminalState: (() => void) | null = null;
|
||||
let unlistenExtension: (() => void) | null = null;
|
||||
let unlistenDeepLink: (() => void) | null = null;
|
||||
const disposeListeners = () => {
|
||||
void queueFrontendReadyUpdate(false).catch(() => {});
|
||||
unlistenTerminalState?.();
|
||||
unlistenTerminalState = null;
|
||||
unlistenExtension?.();
|
||||
unlistenExtension = null;
|
||||
unlistenDeepLink?.();
|
||||
unlistenDeepLink = null;
|
||||
unlistenDownload?.();
|
||||
unlistenDownload = null;
|
||||
};
|
||||
|
||||
try {
|
||||
unlistenDownload = await initDownloadListener();
|
||||
unlistenTerminalState = await listen('download-state', (event) => {
|
||||
if (event.payload.status !== 'completed' && event.payload.status !== 'failed') return;
|
||||
const settings = useSettingsStore.getState();
|
||||
if (event.payload.status === 'completed' && settings.playCompletionSound) {
|
||||
playCompletionChime().catch(error => {
|
||||
console.error('Completion sound failed:', error);
|
||||
});
|
||||
}
|
||||
if (!settings.showNotifications) return;
|
||||
|
||||
const item = useDownloadStore.getState().downloads.find(d => d.id === event.payload.id);
|
||||
const fileName = item?.fileName || 'A file';
|
||||
if (event.payload.status === 'completed') {
|
||||
try {
|
||||
sendNotification({
|
||||
title: 'Download Complete',
|
||||
body: `${fileName} has finished downloading.`
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Completion notification failed:', error);
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
sendNotification({
|
||||
title: 'Download Failed',
|
||||
body: `${fileName} failed to download.`,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failure notification failed:', error);
|
||||
}
|
||||
}
|
||||
});
|
||||
unlistenExtension = await listen('extension-add-download', (event) => {
|
||||
if (event.payload.request_id) {
|
||||
void invoke('ack_extension_download', { requestId: event.payload.request_id }).catch(error => {
|
||||
console.error('Failed to acknowledge browser extension download:', error);
|
||||
});
|
||||
}
|
||||
if (!startupInputReady.current || useSettingsStore.getState().showKeychainModal) {
|
||||
pendingStartupInputs.current.push({ type: 'extension', payload: event.payload });
|
||||
return;
|
||||
}
|
||||
useDownloadStore.getState().handleExtensionDownload(event.payload).catch(error => {
|
||||
console.error('Failed to handle browser extension download:', error);
|
||||
});
|
||||
});
|
||||
unlistenDeepLink = await listen('deep-link-add-download', (event) => {
|
||||
if (!startupInputReady.current || useSettingsStore.getState().showKeychainModal) {
|
||||
pendingStartupInputs.current.push({ type: 'deep-link', payload: event.payload });
|
||||
return;
|
||||
}
|
||||
useDownloadStore.getState().openAddModalWithUrls(event.payload);
|
||||
});
|
||||
|
||||
cleanupListeners = disposeListeners;
|
||||
if (!active) {
|
||||
disposeListeners();
|
||||
cleanupListeners = null;
|
||||
return;
|
||||
}
|
||||
|
||||
await initializeDownloadState();
|
||||
if (!active) return;
|
||||
setCoreReady(true);
|
||||
} catch (error) {
|
||||
disposeListeners();
|
||||
cleanupListeners = null;
|
||||
if (!active) return;
|
||||
console.error('Failed to initialize Firelink state:', error);
|
||||
addToast({
|
||||
@@ -259,8 +378,46 @@ function App() {
|
||||
return;
|
||||
}
|
||||
|
||||
const [currentAppVersion, currentPlatform] = await Promise.all([
|
||||
getVersion().catch(() => ''),
|
||||
getPlatformInfo().catch(() => null)
|
||||
]);
|
||||
if (!active) return;
|
||||
const currentKeychainConsentVersion = getKeychainConsentVersion(currentAppVersion);
|
||||
setKeychainConsentVersion(currentKeychainConsentVersion);
|
||||
|
||||
try {
|
||||
const changed = await useSettingsStore.getState().hydratePairingToken();
|
||||
const settings = useSettingsStore.getState();
|
||||
const isStartupActive = () => active;
|
||||
const { deferKeychainHydration, showKeychainPrompt } = getKeychainStartupDecision({
|
||||
portable: currentPlatform?.portable === true,
|
||||
appVersion: currentKeychainConsentVersion,
|
||||
approvedVersion: settings.keychainAccessVersion,
|
||||
accessGranted: settings.keychainAccessGranted,
|
||||
promptDismissed: settings.keychainPromptDismissed
|
||||
});
|
||||
|
||||
let changed = false;
|
||||
if (deferKeychainHydration) {
|
||||
settings.setKeychainAccessReady(false);
|
||||
// This token is already owned by the backend and does not access
|
||||
// the OS credential store. Render our explanation before any native
|
||||
// Keychain/Credential Manager prompt can be user-triggered.
|
||||
await settings.hydrateSessionPairingToken(isStartupActive);
|
||||
if (!active) return;
|
||||
if (showKeychainPrompt) {
|
||||
settings.setShowKeychainModal(true);
|
||||
}
|
||||
} else {
|
||||
changed = await settings.hydratePairingToken(isStartupActive);
|
||||
if (!active) return;
|
||||
const currentSettings = useSettingsStore.getState();
|
||||
settings.setKeychainAccessReady(getKeychainAccessReady({
|
||||
portable: currentPlatform?.portable === true,
|
||||
accessGranted: currentSettings.keychainAccessGranted,
|
||||
persistent: currentSettings.isPairingTokenPersistent
|
||||
}));
|
||||
}
|
||||
if (changed) {
|
||||
addToast({
|
||||
variant: 'warning',
|
||||
@@ -315,12 +472,61 @@ function App() {
|
||||
isActionable: true
|
||||
});
|
||||
}
|
||||
|
||||
if (!active) return;
|
||||
setCoreReady(true);
|
||||
};
|
||||
void initialize();
|
||||
return () => {
|
||||
active = false;
|
||||
startupInputReady.current = false;
|
||||
pendingStartupInputs.current = [];
|
||||
cleanupListeners?.();
|
||||
cleanupListeners = null;
|
||||
};
|
||||
}, [addToast]);
|
||||
}, [addToast, queueFrontendReadyUpdate]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!coreReady) return;
|
||||
// The backend must not emit extension/deep-link handoffs while the
|
||||
// explanatory Keychain dialog is active. Deep links are buffered by the
|
||||
// coordinator, and extension callers receive a retryable 503 instead.
|
||||
void queueFrontendReadyUpdate(!showKeychainModal).catch(error => {
|
||||
console.error('Failed to update browser extension readiness:', error);
|
||||
});
|
||||
}, [coreReady, queueFrontendReadyUpdate, showKeychainModal]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!coreReady || showKeychainModal) {
|
||||
startupInputReady.current = false;
|
||||
return;
|
||||
}
|
||||
if (startupInputReady.current) return;
|
||||
startupInputReady.current = true;
|
||||
const pendingInputs = pendingStartupInputs.current.splice(0);
|
||||
for (const input of pendingInputs) {
|
||||
if (input.type === 'extension') {
|
||||
useDownloadStore.getState().handleExtensionDownload(input.payload).catch(error => {
|
||||
console.error('Failed to handle queued browser extension download:', error);
|
||||
});
|
||||
} else {
|
||||
useDownloadStore.getState().openAddModalWithUrls(input.payload);
|
||||
}
|
||||
}
|
||||
}, [coreReady, showKeychainModal]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!coreReady || showKeychainModal || startupResumeStarted.current) return;
|
||||
startupResumeStarted.current = true;
|
||||
useDownloadStore.getState().resumePendingDownloads().catch(error => {
|
||||
console.error('Failed to resume saved downloads after startup:', error);
|
||||
addToast({
|
||||
message: `Could not resume saved downloads: ${String(error)}`,
|
||||
variant: 'error',
|
||||
isActionable: true
|
||||
});
|
||||
});
|
||||
}, [addToast, coreReady, showKeychainModal]);
|
||||
|
||||
useEffect(() => {
|
||||
window.document.documentElement.setAttribute('data-font-size', appFontSize);
|
||||
@@ -338,6 +544,9 @@ function App() {
|
||||
invoke('check_for_updates')
|
||||
.then(result => {
|
||||
if (result.type !== 'UpdateAvailable') return;
|
||||
if (!isTrustedFirelinkReleaseUrl(result.update.release_url)) {
|
||||
throw new Error('The update check returned an untrusted release URL.');
|
||||
}
|
||||
addToast({
|
||||
variant: 'info',
|
||||
isActionable: true,
|
||||
@@ -397,13 +606,9 @@ function App() {
|
||||
invoke('toggle_tray_icon', { show: showMenuBarIcon }).catch(console.error);
|
||||
}, [showMenuBarIcon]);
|
||||
|
||||
useEffect(() => {
|
||||
invoke('toggle_log_pause', { pause: !logsEnabled }).catch(console.error);
|
||||
}, [logsEnabled]);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeView !== 'logs') {
|
||||
invoke('set_log_stream_active', { active: false }).catch(console.error);
|
||||
setLogStreamActive(false).catch(console.error);
|
||||
}
|
||||
}, [activeView]);
|
||||
|
||||
@@ -414,17 +619,6 @@ function App() {
|
||||
});
|
||||
}, [extensionPairingToken]);
|
||||
|
||||
useEffect(() => {
|
||||
if (previousSpeedLimit.current === globalSpeedLimit) return;
|
||||
previousSpeedLimit.current = globalSpeedLimit;
|
||||
|
||||
const formattedLimit = normalizeSpeedLimitForBackend(globalSpeedLimit);
|
||||
|
||||
invoke('set_global_speed_limit', { limit: formattedLimit }).catch(error => {
|
||||
console.error('Failed to apply global speed limit:', error);
|
||||
});
|
||||
}, [globalSpeedLimit]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!coreReady) return;
|
||||
const unlisten = listen('schedule-trigger', async (event) => {
|
||||
@@ -447,6 +641,7 @@ function App() {
|
||||
await invoke('ack_schedule_trigger', { action: 'start', key: payload.key });
|
||||
return;
|
||||
}
|
||||
const previouslyTrackedIds = new Set(state.schedulerActiveDownloadIds);
|
||||
const startedResults = await Promise.all(
|
||||
scheduledQueueIds.map(queueId => useDownloadStore.getState().startQueue(queueId))
|
||||
);
|
||||
@@ -454,6 +649,7 @@ function App() {
|
||||
const scheduledQueueSet = new Set(scheduledQueueIds);
|
||||
const trackedIds = useDownloadStore.getState().downloads
|
||||
.filter(download =>
|
||||
previouslyTrackedIds.has(download.id) &&
|
||||
scheduledQueueSet.has(download.queueId || MAIN_QUEUE_ID) &&
|
||||
isActiveDownloadStatus(download.status)
|
||||
)
|
||||
@@ -463,9 +659,9 @@ function App() {
|
||||
state.setSchedulerRunning(activeIds.length > 0);
|
||||
await invoke('ack_schedule_trigger', { action: 'start', key: payload.key });
|
||||
} else if (payload.action === 'stop') {
|
||||
clearPendingPostActionTimer();
|
||||
const trackedIds = state.schedulerActiveDownloadIds;
|
||||
if (trackedIds.length > 0) {
|
||||
clearPendingPostActionTimer();
|
||||
const pauseResults = await Promise.allSettled(
|
||||
trackedIds.map(id => useDownloadStore.getState().pauseDownload(id))
|
||||
);
|
||||
@@ -493,6 +689,7 @@ function App() {
|
||||
}, [addToast, clearPendingPostActionTimer, coreReady]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!coreReady) return;
|
||||
if (!schedulerRunning) return;
|
||||
if (schedulerActiveDownloadIds.length === 0) return;
|
||||
clearPendingPostActionTimer();
|
||||
@@ -510,11 +707,20 @@ function App() {
|
||||
isActionable: true
|
||||
});
|
||||
} else if (settings.scheduler.postQueueAction !== 'none') {
|
||||
schedulePostQueueAction(settings.scheduler.postQueueAction);
|
||||
if (downloads.some(download => isActiveDownloadStatus(download.status))) {
|
||||
addToast({
|
||||
message: 'Scheduled system action skipped because another download is active or queued.',
|
||||
variant: 'warning',
|
||||
isActionable: true
|
||||
});
|
||||
} else {
|
||||
schedulePostQueueAction(settings.scheduler.postQueueAction);
|
||||
}
|
||||
}
|
||||
}, [
|
||||
addToast,
|
||||
clearPendingPostActionTimer,
|
||||
coreReady,
|
||||
downloads,
|
||||
schedulePostQueueAction,
|
||||
schedulerRunning,
|
||||
@@ -556,6 +762,7 @@ function App() {
|
||||
|
||||
useEffect(() => {
|
||||
const handlePaste = (e: ClipboardEvent) => {
|
||||
if (!coreReady || useSettingsStore.getState().showKeychainModal) return;
|
||||
if (
|
||||
e.target instanceof HTMLInputElement ||
|
||||
e.target instanceof HTMLTextAreaElement ||
|
||||
@@ -574,7 +781,85 @@ function App() {
|
||||
};
|
||||
window.addEventListener('paste', handlePaste);
|
||||
return () => window.removeEventListener('paste', handlePaste);
|
||||
}, []);
|
||||
}, [coreReady, showKeychainModal]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!coreReady || showKeychainModal || !autoAddClipboardLinks) return;
|
||||
|
||||
let active = true;
|
||||
let wasForeground = false;
|
||||
let readInFlight = false;
|
||||
let lastClipboardKey: string | null = null;
|
||||
|
||||
const isForeground = () =>
|
||||
document.visibilityState === 'visible' &&
|
||||
(typeof document.hasFocus !== 'function' || document.hasFocus());
|
||||
|
||||
const readClipboardOnForeground = async () => {
|
||||
if (!active || readInFlight || !isForeground()) return;
|
||||
|
||||
const storeBeforeRead = useDownloadStore.getState();
|
||||
const requestVersionBeforeRead = storeBeforeRead.pendingAddRequestVersion;
|
||||
readInFlight = true;
|
||||
try {
|
||||
const clipboardUrls = await readClipboardDownloadUrls();
|
||||
if (!active) return;
|
||||
|
||||
const currentSettings = useSettingsStore.getState();
|
||||
const currentStore = useDownloadStore.getState();
|
||||
// A user action or extension handoff won while the native clipboard
|
||||
// read was pending. Let that newer Add-modal request win unchanged.
|
||||
if (
|
||||
!currentSettings.autoAddClipboardLinks ||
|
||||
currentSettings.showKeychainModal ||
|
||||
currentStore.pendingAddRequestVersion !== requestVersionBeforeRead
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const clipboardKey = [...clipboardUrls].sort().join('\n');
|
||||
if (clipboardKey === lastClipboardKey) return;
|
||||
lastClipboardKey = clipboardKey;
|
||||
if (clipboardUrls.length === 0) return;
|
||||
|
||||
const existingUrls = new Set(extractValidDownloadUrls(currentStore.pendingAddUrls));
|
||||
const newUrls = clipboardUrls.filter(url => !existingUrls.has(url));
|
||||
if (newUrls.length > 0) {
|
||||
currentStore.openAddModalWithUrls(newUrls.join('\n'));
|
||||
}
|
||||
} catch (error) {
|
||||
// Clipboard permissions are optional and this feature is explicitly
|
||||
// opt-in, so a read failure should not interrupt normal app use.
|
||||
console.warn('Automatic clipboard capture failed:', error);
|
||||
} finally {
|
||||
readInFlight = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleForegroundChange = () => {
|
||||
const foreground = isForeground();
|
||||
if (!foreground) {
|
||||
wasForeground = false;
|
||||
return;
|
||||
}
|
||||
if (!wasForeground) {
|
||||
wasForeground = true;
|
||||
void readClipboardOnForeground();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('focus', handleForegroundChange);
|
||||
window.addEventListener('blur', handleForegroundChange);
|
||||
document.addEventListener('visibilitychange', handleForegroundChange);
|
||||
handleForegroundChange();
|
||||
|
||||
return () => {
|
||||
active = false;
|
||||
window.removeEventListener('focus', handleForegroundChange);
|
||||
window.removeEventListener('blur', handleForegroundChange);
|
||||
document.removeEventListener('visibilitychange', handleForegroundChange);
|
||||
};
|
||||
}, [autoAddClipboardLinks, coreReady, showKeychainModal]);
|
||||
|
||||
useEffect(() => {
|
||||
const root = window.document.documentElement;
|
||||
@@ -609,69 +894,6 @@ function App() {
|
||||
}
|
||||
}, [theme]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!coreReady) return;
|
||||
let disposed = false;
|
||||
const unlistenDownload = initDownloadListener();
|
||||
|
||||
const unlistenTerminalState = listen('download-state', (event) => {
|
||||
if (event.payload.status !== 'completed' && event.payload.status !== 'failed') return;
|
||||
const settings = useSettingsStore.getState();
|
||||
if (event.payload.status === 'completed' && settings.playCompletionSound) {
|
||||
playCompletionChime().catch(error => {
|
||||
console.error('Completion sound failed:', error);
|
||||
});
|
||||
}
|
||||
if (!settings.showNotifications) return;
|
||||
|
||||
const item = useDownloadStore.getState().downloads.find(d => d.id === event.payload.id);
|
||||
const fileName = item?.fileName || 'A file';
|
||||
if (event.payload.status === 'completed') {
|
||||
try {
|
||||
sendNotification({
|
||||
title: 'Download Complete',
|
||||
body: `${fileName} has finished downloading.`
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Completion notification failed:', error);
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
sendNotification({
|
||||
title: 'Download Failed',
|
||||
body: `${fileName} failed to download.`,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failure notification failed:', error);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const unlistenExtension = listen('extension-add-download', (event) => {
|
||||
useDownloadStore.getState().handleExtensionDownload(event.payload).catch(error => {
|
||||
console.error('Failed to handle browser extension download:', error);
|
||||
});
|
||||
});
|
||||
const unlistenDeepLink = listen('deep-link-add-download', (event) => {
|
||||
useDownloadStore.getState().openAddModalWithUrls(event.payload);
|
||||
});
|
||||
Promise.all([unlistenExtension, unlistenDeepLink])
|
||||
.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());
|
||||
unlistenDeepLink.then(f => f());
|
||||
unlistenDownload.then(f => { if (f) f(); });
|
||||
};
|
||||
}, [coreReady]);
|
||||
|
||||
return (
|
||||
<div className={`app-shell flex h-screen w-screen overflow-hidden text-text-primary ${
|
||||
hasWindowChrome ? 'app-shell--window-chrome' : ''
|
||||
@@ -740,7 +962,7 @@ function App() {
|
||||
<AddDownloadsModal />
|
||||
<PropertiesModal />
|
||||
<DeleteConfirmationModal />
|
||||
<KeychainPermissionModal />
|
||||
<KeychainPermissionModal consentVersion={keychainConsentVersion} />
|
||||
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -2,4 +2,4 @@
|
||||
import type { DownloadCategory } from "./DownloadCategory";
|
||||
import type { DownloadStatus } from "./DownloadStatus";
|
||||
|
||||
export type DownloadItem = { id: string, url: string, fileName: string, status: DownloadStatus, fraction?: number, speed?: string, eta?: string, size?: string, category: DownloadCategory, dateAdded: string, resumable?: boolean, connections?: number, speedLimit?: string, username?: string, password?: string, headers?: string, checksum?: string, cookies?: string, mirrors?: string, destination?: string, isMedia?: boolean, mediaFormatSelector?: string, queueId?: string, queuePosition?: number, hasBeenDispatched?: boolean, lastError?: string, };
|
||||
export type DownloadItem = { id: string, url: string, fileName: string, status: DownloadStatus, fraction?: number, speed?: string, eta?: string, size?: string, downloadedBytes?: number, totalBytes?: number, totalIsEstimate?: boolean, category: DownloadCategory, dateAdded: string, resumable?: boolean, connections?: number, speedLimit?: string, username?: string, password?: string, headers?: string, checksum?: string, cookies?: string, mirrors?: string, destination?: string, isMedia?: boolean, mediaFormatSelector?: string, queueId?: string, queuePosition?: number, hasBeenDispatched?: boolean, lastError?: string, lastTry?: string, };
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
export type DownloadProgressEvent = { id: string, fraction: number, speed: string, eta: string, size: string | null, size_is_final: boolean, };
|
||||
export type DownloadProgressEvent = { id: string, fraction: number, speed: string, eta: string, size: string | null, size_is_final: boolean, downloaded_bytes?: number, total_bytes?: number, total_is_estimate?: boolean, };
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
export type ExtensionCookieScope = { url: string, cookies: string, };
|
||||
@@ -1,3 +1,4 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
import type { ExtensionCookieScope } from "./ExtensionCookieScope";
|
||||
|
||||
export type ExtensionDownload = { urls: Array<string>, referer: string | null, silent: boolean, filename: string | null, headers: string | null, cookies: string | null, media: boolean, };
|
||||
export type ExtensionDownload = { request_id?: string, urls: Array<string>, referer: string | null, silent: boolean, filename: string | null, headers: string | null, cookies: string | null, cookie_scopes: Array<ExtensionCookieScope> | null, media: boolean, };
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
export type MediaPlaylistEntry = { id: string, url: string, title: string, playlist_index: number | null, };
|
||||
@@ -0,0 +1,4 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
import type { MediaPlaylistEntry } from "./MediaPlaylistEntry";
|
||||
|
||||
export type MediaPlaylistMetadata = { title: string, playlist_id: string | null, entry_count: number | null, skipped_entries: number, truncated: boolean, entries: Array<MediaPlaylistEntry>, };
|
||||
@@ -8,4 +8,4 @@ import type { SettingsTab } from "./SettingsTab";
|
||||
import type { SiteLogin } from "./SiteLogin";
|
||||
import type { Theme } from "./Theme";
|
||||
|
||||
export type PersistedSettings = { theme: Theme, baseDownloadFolder: string, categorySubfoldersEnabled: boolean, categorySubfolders: { [key in string]: string }, categoryDirectoryOverrides: { [key in string]: string }, approvedDownloadRoots: Array<string>, maxConcurrentDownloads: number, globalSpeedLimit: string, speedLimitPresetValues: Array<number>, logsEnabled: boolean, isSidebarVisible: boolean, activeSettingsTab: SettingsTab, scheduler: SchedulerSettings, schedulerRunning: boolean, schedulerActiveDownloadIds: Array<string>, schedulerLastStartKey: string, schedulerLastStopKey: string, lastCustomSpeedLimitKiB: number, 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, };
|
||||
export type PersistedSettings = { theme: Theme, baseDownloadFolder: string, categorySubfoldersEnabled: boolean, categorySubfolders: { [key in string]: string }, categoryDirectoryOverrides: { [key in string]: string }, approvedDownloadRoots: Array<string>, maxConcurrentDownloads: number, globalSpeedLimit: string, speedLimitPresetValues: Array<number>, logsEnabled: boolean, isSidebarVisible: boolean, activeSettingsTab: SettingsTab, scheduler: SchedulerSettings, schedulerRunning: boolean, schedulerActiveDownloadIds: Array<string>, schedulerLastStartKey: string, schedulerLastStopKey: string, lastCustomSpeedLimitKiB: number, lastCustomSpeedLimitUnit: string, perServerConnections: number, maxAutomaticRetries: number, showNotifications: boolean, playCompletionSound: boolean, autoAddClipboardLinks: boolean, appFontSize: AppFontSize, listRowDensity: ListRowDensity, showDockBadge: boolean, showMenuBarIcon: boolean, proxyMode: ProxyMode, proxyHost: string, proxyPort: number, customUserAgent: string, askWhereToSaveEachFile: boolean, preventsSleepWhileDownloading: boolean, mediaCookieSource: MediaCookieSource, siteLogins: Array<SiteLogin>, autoCheckUpdates: boolean, keychainAccessGranted: boolean, };
|
||||
|
||||
@@ -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 PlatformInfo = { os: string, arch: string, targetTriple: string, };
|
||||
export type PlatformInfo = { os: string, arch: string, targetTriple: string, portable: boolean, };
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { useCallback, useState, useEffect, useRef } from 'react';
|
||||
import {
|
||||
useDownloadStore,
|
||||
getSiteLogin,
|
||||
@@ -7,12 +7,13 @@ import {
|
||||
type PendingAddRequestContext
|
||||
} from '../store/useDownloadStore';
|
||||
import { useSettingsStore } from '../store/useSettingsStore';
|
||||
import type { MediaPlaylistMetadata } from '../bindings/MediaPlaylistMetadata';
|
||||
import { FolderPlus, Settings, Shield, RefreshCw, FileText, HardDrive, Database, Link, ArrowRight, Play, ChevronDown, ChevronRight, Video, Film, Music, type LucideIcon } from 'lucide-react';
|
||||
import { open } from '@tauri-apps/plugin-dialog';
|
||||
import { invokeCommand as invoke } from '../ipc';
|
||||
import { DuplicateResolutionModal, DuplicateConflict } from './DuplicateResolutionModal';
|
||||
import { canonicalizeDownloadFileName, categoryForFileName } from '../utils/downloads';
|
||||
import { fetchMediaMetadataDeduped } from '../utils/mediaMetadata';
|
||||
import { fetchMediaMetadataDeduped, fetchMediaPlaylistMetadataDeduped } from '../utils/mediaMetadata';
|
||||
import {
|
||||
resolveCategoryDestination,
|
||||
resolveDownloadFilePath,
|
||||
@@ -27,6 +28,7 @@ import {
|
||||
mediaFileNameForSelectedFormat,
|
||||
mediaFormatSelectorForRow,
|
||||
metadataSummaryMessage,
|
||||
playlistFilePrefix,
|
||||
reconcileDownloadRows,
|
||||
refreshFailedMetadataRows,
|
||||
updateRowIfCurrent,
|
||||
@@ -49,9 +51,48 @@ const normalizeComparableUrl = (rawUrl: string) => {
|
||||
}
|
||||
};
|
||||
|
||||
const urlsHaveDifferentHosts = (sourceUrl: string, targetUrl: string) => {
|
||||
const urlsHaveDifferentOrigins = (sourceUrl: string, targetUrl: string) => {
|
||||
try {
|
||||
return new URL(sourceUrl).hostname.toLowerCase() !== new URL(targetUrl).hostname.toLowerCase();
|
||||
const source = new URL(sourceUrl);
|
||||
const target = new URL(targetUrl);
|
||||
return source.protocol !== target.protocol
|
||||
|| source.hostname.toLowerCase() !== target.hostname.toLowerCase()
|
||||
|| source.port !== target.port;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const cookieScopeForUrl = (context: PendingAddRequestContext | undefined, targetUrl: string) => {
|
||||
if (!context?.cookieScopes?.length) return '';
|
||||
try {
|
||||
const target = new URL(targetUrl);
|
||||
return context.cookieScopes.find(scope => {
|
||||
try {
|
||||
const scopeUrl = new URL(scope.url);
|
||||
const sameGoogleusercontentSite = scopeUrl.protocol === target.protocol
|
||||
&& scopeUrl.port === target.port
|
||||
&& scopeUrl.hostname.toLowerCase() === 'googleusercontent.com'
|
||||
&& target.hostname.toLowerCase().endsWith('.googleusercontent.com');
|
||||
return sameGoogleusercontentSite || (scopeUrl.protocol === target.protocol
|
||||
&& scopeUrl.hostname.toLowerCase() === target.hostname.toLowerCase()
|
||||
&& scopeUrl.port === target.port);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
})?.cookies.trim() || '';
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
const isGoogleAuthenticatedCaptureUrl = (rawUrl: string) => {
|
||||
try {
|
||||
const hostname = new URL(rawUrl).hostname.toLowerCase();
|
||||
return hostname === 'mail.google.com'
|
||||
|| hostname === 'accounts.google.com'
|
||||
|| hostname === 'googleusercontent.com'
|
||||
|| hostname.endsWith('.googleusercontent.com');
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
@@ -86,12 +127,21 @@ export const AddDownloadsModal = () => {
|
||||
addDownload,
|
||||
queues
|
||||
} = useDownloadStore();
|
||||
const { baseDownloadFolder, perServerConnections } = useSettingsStore();
|
||||
const {
|
||||
baseDownloadFolder,
|
||||
perServerConnections,
|
||||
keychainAccessReady,
|
||||
keychainPromptDismissed,
|
||||
showKeychainModal
|
||||
} = useSettingsStore();
|
||||
|
||||
const [urls, setUrls] = useState('');
|
||||
const [selectedItemIndex, setSelectedItemIndex] = useState<number | null>(null);
|
||||
const [parsedItems, setParsedItems] = useState<AddDownloadDraftRow[]>([]);
|
||||
const metadataRequestsRef = useRef(new Set<string>());
|
||||
const playlistRequestsRef = useRef(new Set<string>());
|
||||
const latestPlaylistRequestRef = useRef(new Map<string, string>());
|
||||
const [playlistExpansions, setPlaylistExpansions] = useState<Record<string, MediaPlaylistMetadata>>({});
|
||||
|
||||
const [conflicts, setConflicts] = useState<DuplicateConflict[]>([]);
|
||||
const [showingDuplicates, setShowingDuplicates] = useState(false);
|
||||
@@ -101,6 +151,7 @@ export const AddDownloadsModal = () => {
|
||||
const [resolvedLocation, setResolvedLocation] = useState('');
|
||||
const [isQueueMenuOpen, setIsQueueMenuOpen] = useState(false);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const isSubmittingRef = useRef(false);
|
||||
const actionMenuRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Right Form
|
||||
@@ -140,22 +191,40 @@ export const AddDownloadsModal = () => {
|
||||
const cookiesForRow = (sourceUrl: string, targetUrl = sourceUrl) => {
|
||||
if (cookiesManuallyEditedRef.current) return cookies.trim();
|
||||
const context = requestContextForUrl(sourceUrl);
|
||||
if (context && context.cookies && urlsHaveDifferentHosts(sourceUrl, targetUrl)) {
|
||||
return '';
|
||||
}
|
||||
const scopedCookies = cookieScopeForUrl(context, targetUrl);
|
||||
if (scopedCookies) return scopedCookies;
|
||||
if (context && urlsHaveDifferentOrigins(sourceUrl, targetUrl)) return '';
|
||||
if (context) return context.cookies.trim();
|
||||
return hasExtensionRequestContext ? '' : cookies.trim();
|
||||
};
|
||||
const shouldDeferCookiesForRow = (sourceUrl: string) =>
|
||||
!cookiesManuallyEditedRef.current
|
||||
&& Boolean(requestContextForUrl(sourceUrl))
|
||||
&& !isGoogleAuthenticatedCaptureUrl(sourceUrl);
|
||||
const suggestedFilenameForRow = (sourceUrl: string) => {
|
||||
const context = requestContextForUrl(sourceUrl);
|
||||
if (context?.filename) return context.filename;
|
||||
return hasExtensionRequestContext ? '' : pendingAddFilename;
|
||||
};
|
||||
const requestContextUrlForRow = (row: AddDownloadDraftRow) =>
|
||||
row.playlistSourceUrl || row.sourceUrl;
|
||||
|
||||
const closeModalFromDismissAction = useCallback(() => {
|
||||
if (isSubmitting || isSubmittingRef.current || showKeychainModal) return;
|
||||
const hasPendingInput = Boolean(
|
||||
urls.trim() || pendingAddUrls.trim() || parsedItems.length || headers.trim() || cookies.trim()
|
||||
);
|
||||
if (hasPendingInput && !window.confirm('Discard this download setup?')) return;
|
||||
toggleAddModal(false);
|
||||
}, [cookies, headers, isSubmitting, parsedItems.length, pendingAddUrls, showKeychainModal, toggleAddModal, urls]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAddModalOpen) {
|
||||
modalSessionRef.current = false;
|
||||
setUrls('');
|
||||
setPlaylistExpansions({});
|
||||
playlistRequestsRef.current.clear();
|
||||
latestPlaylistRequestRef.current.clear();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -172,6 +241,10 @@ export const AddDownloadsModal = () => {
|
||||
setIsSaveLocationManual(false);
|
||||
setUrls(initialUrls);
|
||||
setParsedItems([]);
|
||||
setPlaylistExpansions({});
|
||||
metadataRequestsRef.current.clear();
|
||||
playlistRequestsRef.current.clear();
|
||||
latestPlaylistRequestRef.current.clear();
|
||||
setSelectedItemIndex(null);
|
||||
setPendingUseSharedDestination(false);
|
||||
setPendingDestinationOverrides({});
|
||||
@@ -195,6 +268,7 @@ export const AddDownloadsModal = () => {
|
||||
cookiesManuallyEditedRef.current = false;
|
||||
setMirrors('');
|
||||
setIsQueueMenuOpen(false);
|
||||
isSubmittingRef.current = false;
|
||||
setIsSubmitting(false);
|
||||
}, [
|
||||
isAddModalOpen,
|
||||
@@ -212,6 +286,11 @@ export const AddDownloadsModal = () => {
|
||||
|| observedRequestVersionRef.current === pendingAddRequestVersion) return;
|
||||
const observedVersion = observedRequestVersionRef.current;
|
||||
observedRequestVersionRef.current = pendingAddRequestVersion;
|
||||
// Playlist membership and entry access can depend on the handoff's
|
||||
// browser context. Re-discover playlists when a newer extension context
|
||||
// arrives instead of reusing entries extracted under stale cookies.
|
||||
setPlaylistExpansions({});
|
||||
latestPlaylistRequestRef.current.clear();
|
||||
setUrls(current => appendRequestUrlsAfterVersion(
|
||||
current,
|
||||
pendingAddRequestContexts,
|
||||
@@ -231,18 +310,21 @@ export const AddDownloadsModal = () => {
|
||||
}, [isQueueMenuOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isQueueMenuOpen && !showingDuplicates) return;
|
||||
if (!isAddModalOpen) return;
|
||||
const closeOnEscape = (event: KeyboardEvent) => {
|
||||
if (event.key !== 'Escape') return;
|
||||
if (showKeychainModal) return;
|
||||
if (showingDuplicates) {
|
||||
setShowingDuplicates(false);
|
||||
} else {
|
||||
} else if (isQueueMenuOpen) {
|
||||
setIsQueueMenuOpen(false);
|
||||
} else {
|
||||
closeModalFromDismissAction();
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', closeOnEscape);
|
||||
return () => window.removeEventListener('keydown', closeOnEscape);
|
||||
}, [isQueueMenuOpen, showingDuplicates]);
|
||||
}, [closeModalFromDismissAction, isAddModalOpen, isQueueMenuOpen, showKeychainModal, showingDuplicates]);
|
||||
|
||||
useEffect(() => {
|
||||
const requestId = ++freeSpaceRequestRef.current;
|
||||
@@ -262,6 +344,21 @@ export const AddDownloadsModal = () => {
|
||||
}, [saveLocation, isAddModalOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
const activeUrls = new Set(
|
||||
urls.split('\n').map(url => url.trim()).filter(Boolean).map(normalizeComparableUrl)
|
||||
);
|
||||
for (const sourceUrl of latestPlaylistRequestRef.current.keys()) {
|
||||
if (!activeUrls.has(sourceUrl)) {
|
||||
latestPlaylistRequestRef.current.delete(sourceUrl);
|
||||
}
|
||||
}
|
||||
setPlaylistExpansions(current => {
|
||||
const retained = Object.fromEntries(
|
||||
Object.entries(current).filter(([sourceUrl]) => activeUrls.has(sourceUrl))
|
||||
);
|
||||
return Object.keys(retained).length === Object.keys(current).length ? current : retained;
|
||||
});
|
||||
|
||||
const forcedMediaUrls = new Set(pendingAddMediaUrls.map(url => {
|
||||
try {
|
||||
return new URL(url).href;
|
||||
@@ -278,42 +375,71 @@ export const AddDownloadsModal = () => {
|
||||
Object.entries(pendingAddRequestContexts)
|
||||
.map(([url, context]) => [url, context.version])
|
||||
);
|
||||
setParsedItems(current =>
|
||||
reconcileDownloadRows(
|
||||
setParsedItems(current => {
|
||||
const selectedBySourceUrl = Object.fromEntries(
|
||||
current.map(row => [row.sourceUrl, row.selected !== false])
|
||||
);
|
||||
for (const row of current) {
|
||||
if (row.playlistSourceUrl && !(row.playlistSourceUrl in selectedBySourceUrl)) {
|
||||
selectedBySourceUrl[row.playlistSourceUrl] = row.selected !== false;
|
||||
}
|
||||
}
|
||||
return reconcileDownloadRows(
|
||||
urls,
|
||||
current,
|
||||
hasExtensionRequestContext ? undefined : pendingAddFilename || undefined,
|
||||
forcedMediaUrls,
|
||||
undefined,
|
||||
requestFilenames,
|
||||
requestContextVersions
|
||||
)
|
||||
);
|
||||
requestContextVersions,
|
||||
playlistExpansions,
|
||||
selectedBySourceUrl
|
||||
);
|
||||
});
|
||||
}, [
|
||||
urls,
|
||||
pendingAddFilename,
|
||||
pendingAddMediaUrls,
|
||||
pendingAddRequestContexts,
|
||||
hasExtensionRequestContext
|
||||
hasExtensionRequestContext,
|
||||
playlistExpansions
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
const maxConcurrentMetadataRequests = 4;
|
||||
for (const row of parsedItems) {
|
||||
if (row.status !== 'loading') continue;
|
||||
if (row.status !== 'loading' || row.selected === false) continue;
|
||||
const requestKey = `${row.id}:${row.generation}`;
|
||||
if (metadataRequestsRef.current.has(requestKey)) continue;
|
||||
metadataRequestsRef.current.add(requestKey);
|
||||
const requestSet = row.isPlaylist ? playlistRequestsRef.current : metadataRequestsRef.current;
|
||||
if (requestSet.has(requestKey)) continue;
|
||||
if (metadataRequestsRef.current.size + playlistRequestsRef.current.size >= maxConcurrentMetadataRequests) {
|
||||
break;
|
||||
}
|
||||
requestSet.add(requestKey);
|
||||
if (row.isPlaylist) {
|
||||
// Invalidate stale playlist requests before any asynchronous settings,
|
||||
// keychain, or network work can yield. Otherwise an old request can
|
||||
// become the latest request again after the URL or browser context
|
||||
// has already changed.
|
||||
latestPlaylistRequestRef.current.set(row.sourceUrl, requestKey);
|
||||
}
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
const settingsStore = useSettingsStore.getState();
|
||||
const proxy = await getProxyArgs(settingsStore);
|
||||
const login = getSiteLogin(row.sourceUrl, settingsStore);
|
||||
const contextUrl = requestContextUrlForRow(row);
|
||||
const requestContext = requestContextForUrl(contextUrl);
|
||||
if (login && !useAuth && !keychainAccessReady && !keychainPromptDismissed) {
|
||||
settingsStore.setShowKeychainModal(true);
|
||||
return;
|
||||
}
|
||||
if (row.isMedia) {
|
||||
const { mediaCookieSource } = settingsStore;
|
||||
const browserArg = mediaCookieSource !== 'none' ? mediaCookieSource : null;
|
||||
const login = getSiteLogin(row.sourceUrl, settingsStore);
|
||||
let keychainPassword = null;
|
||||
if (login) {
|
||||
if (login && !useAuth && keychainAccessReady) {
|
||||
try {
|
||||
keychainPassword = await invoke('get_keychain_password', { id: login.id });
|
||||
} catch (e) {
|
||||
@@ -321,8 +447,8 @@ export const AddDownloadsModal = () => {
|
||||
}
|
||||
}
|
||||
|
||||
const rowHeaders = headersForRow(row.sourceUrl);
|
||||
const rowCookies = cookiesForRow(row.sourceUrl);
|
||||
const rowHeaders = headersForRow(contextUrl);
|
||||
const rowCookies = cookiesForRow(contextUrl, row.sourceUrl);
|
||||
const mediaMetadataArgs = {
|
||||
url: row.sourceUrl,
|
||||
cookieBrowser: browserArg,
|
||||
@@ -333,6 +459,24 @@ export const AddDownloadsModal = () => {
|
||||
cookies: rowCookies || null,
|
||||
proxy
|
||||
};
|
||||
|
||||
if (row.isPlaylist) {
|
||||
if (playlistExpansions[row.sourceUrl]) return;
|
||||
const playlistData = await fetchMediaPlaylistMetadataDeduped({
|
||||
...mediaMetadataArgs,
|
||||
url: contextUrl
|
||||
});
|
||||
if (latestPlaylistRequestRef.current.get(row.sourceUrl) !== requestKey) return;
|
||||
if (!playlistData.entries.length) {
|
||||
throw new Error('Playlist contains no downloadable entries');
|
||||
}
|
||||
setPlaylistExpansions(current => ({
|
||||
...current,
|
||||
[row.sourceUrl]: playlistData
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
const mediaData = await fetchMediaMetadataDeduped(mediaMetadataArgs);
|
||||
if (mediaData && mediaData.formats.length > 0) {
|
||||
const mappedFormats = mediaData.formats.map(f => {
|
||||
@@ -361,21 +505,23 @@ export const AddDownloadsModal = () => {
|
||||
currentRow => ({
|
||||
...currentRow,
|
||||
downloadUrl: row.sourceUrl,
|
||||
file: canonicalizeDownloadFileName(`${mediaData.title}.${mediaData.formats[0].ext}`),
|
||||
file: canonicalizeDownloadFileName(
|
||||
`${playlistFilePrefix(row.playlistIndex, row.playlistCount)}${mediaData.title}.${mediaData.formats[0].ext}`
|
||||
),
|
||||
size: mappedFormats[0].bytes ? mappedFormats[0].detail : undefined,
|
||||
sizeBytes: mappedFormats[0].bytes || undefined,
|
||||
status: 'ready',
|
||||
formats: mappedFormats,
|
||||
selectedFormat: 0
|
||||
selectedFormat: 0,
|
||||
playlistError: undefined
|
||||
})
|
||||
));
|
||||
} else {
|
||||
throw new Error("Invalid media metadata or no formats found");
|
||||
}
|
||||
} else {
|
||||
const login = getSiteLogin(row.sourceUrl, settingsStore);
|
||||
let keychainPassword = null;
|
||||
if (login) {
|
||||
if (login && !useAuth && keychainAccessReady) {
|
||||
try {
|
||||
keychainPassword = await invoke('get_keychain_password', { id: login.id });
|
||||
} catch (e) {
|
||||
@@ -387,9 +533,11 @@ export const AddDownloadsModal = () => {
|
||||
userAgent: settingsStore.customUserAgent.trim() || null,
|
||||
username: useAuth ? username.trim() || null : login?.username || null,
|
||||
password: useAuth ? password || null : keychainPassword,
|
||||
headers: headersForRow(row.sourceUrl) || null,
|
||||
cookies: cookiesForRow(row.sourceUrl) || null,
|
||||
proxy
|
||||
headers: headersForRow(contextUrl) || null,
|
||||
cookies: cookiesForRow(contextUrl, row.sourceUrl) || null,
|
||||
cookieScopes: requestContext?.cookieScopes || null,
|
||||
proxy,
|
||||
deferCookies: shouldDeferCookiesForRow(row.sourceUrl)
|
||||
});
|
||||
const nextDownloadUrl = meta.url || row.sourceUrl;
|
||||
setParsedItems(current => updateRowIfCurrent(
|
||||
@@ -401,19 +549,30 @@ export const AddDownloadsModal = () => {
|
||||
...currentRow,
|
||||
downloadUrl: nextDownloadUrl || currentRow.downloadUrl,
|
||||
file: canonicalizeDownloadFileName(
|
||||
current.length === 1 && suggestedFilenameForRow(row.sourceUrl)
|
||||
? suggestedFilenameForRow(row.sourceUrl)
|
||||
current.length === 1 && suggestedFilenameForRow(contextUrl)
|
||||
? suggestedFilenameForRow(contextUrl)
|
||||
: meta.filename
|
||||
),
|
||||
size: meta.size_bytes ? meta.size : undefined,
|
||||
sizeBytes: meta.size_bytes || undefined,
|
||||
status: 'ready',
|
||||
resumable: meta.resumable
|
||||
resumable: meta.resumable,
|
||||
metadataBlockedReason: undefined
|
||||
})
|
||||
));
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Meta fetch failed", e);
|
||||
const errorMessage = e instanceof Error ? e.message : String(e);
|
||||
const metadataBlockedReason = [
|
||||
'SSRF blocked: Invalid URL',
|
||||
'SSRF blocked: No host',
|
||||
'SSRF blocked: DNS resolution failed',
|
||||
'SSRF blocked: No DNS records',
|
||||
'SSRF blocked: Private/local IP not allowed'
|
||||
].some(prefix => errorMessage.startsWith(prefix))
|
||||
? 'unsafe-url' as const
|
||||
: undefined;
|
||||
setParsedItems(current => updateRowIfCurrent(
|
||||
current,
|
||||
row.id,
|
||||
@@ -426,15 +585,27 @@ export const AddDownloadsModal = () => {
|
||||
sizeBytes: undefined,
|
||||
status: 'metadata-error',
|
||||
formats: undefined,
|
||||
selectedFormat: undefined
|
||||
selectedFormat: undefined,
|
||||
metadataBlockedReason,
|
||||
playlistError: row.isPlaylist
|
||||
? errorMessage
|
||||
: undefined
|
||||
})
|
||||
));
|
||||
} finally {
|
||||
metadataRequestsRef.current.delete(requestKey);
|
||||
requestSet.delete(requestKey);
|
||||
}
|
||||
})();
|
||||
}
|
||||
}, [parsedItems, pendingAddFilename, pendingAddMediaUrls]);
|
||||
}, [
|
||||
keychainAccessReady,
|
||||
keychainPromptDismissed,
|
||||
parsedItems,
|
||||
pendingAddFilename,
|
||||
pendingAddMediaUrls,
|
||||
playlistExpansions,
|
||||
useAuth
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (parsedItems.length === 0) {
|
||||
@@ -482,13 +653,14 @@ export const AddDownloadsModal = () => {
|
||||
};
|
||||
|
||||
const handleAction = async (action: AddDownloadAction) => {
|
||||
if (isSubmitting || !canSubmitMetadataRows(parsedItems)) {
|
||||
if (isSubmitting || isSubmittingRef.current || !canSubmitMetadataRows(parsedItems)) {
|
||||
return;
|
||||
}
|
||||
if (speedLimitEnabled && (!Number.isFinite(Number(speedLimit)) || Number(speedLimit) <= 0)) {
|
||||
addToast({ message: 'Speed limit must be greater than zero', variant: 'error', isActionable: true });
|
||||
return;
|
||||
}
|
||||
isSubmittingRef.current = true;
|
||||
setIsSubmitting(true);
|
||||
let finalLocation = saveLocation;
|
||||
let useSharedDestination = isSaveLocationManual;
|
||||
@@ -497,6 +669,7 @@ export const AddDownloadsModal = () => {
|
||||
const platform = await getPlatformInfo().catch(() => ({ os: 'unknown' }));
|
||||
if (settings.askWhereToSaveEachFile && parsedItems.length > 0) {
|
||||
for (const [index, item] of parsedItems.entries()) {
|
||||
if (item.selected === false) continue;
|
||||
try {
|
||||
const suggestedLocation = isSaveLocationManual
|
||||
? finalLocation
|
||||
@@ -511,11 +684,13 @@ export const AddDownloadsModal = () => {
|
||||
const approvedPath = await useSettingsStore.getState().approveDownloadRoot(selected);
|
||||
destinationOverrides[index] = approvedPath;
|
||||
} else {
|
||||
isSubmittingRef.current = false;
|
||||
setIsSubmitting(false);
|
||||
return;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to select folder:", e);
|
||||
isSubmittingRef.current = false;
|
||||
setIsSubmitting(false);
|
||||
return;
|
||||
}
|
||||
@@ -525,9 +700,11 @@ export const AddDownloadsModal = () => {
|
||||
setResolvedLocation(finalLocation);
|
||||
const store = useDownloadStore.getState();
|
||||
const newConflicts: DuplicateConflict[] = [];
|
||||
const plannedTargets: Array<{ location: string; fileName: string }> = [];
|
||||
|
||||
for (let i = 0; i < parsedItems.length; i++) {
|
||||
const item = parsedItems[i];
|
||||
if (item.selected === false) continue;
|
||||
let finalFile = item.isMedia
|
||||
? mediaFileNameForSelectedFormat(item.file, item)
|
||||
: canonicalizeDownloadFileName(item.file);
|
||||
@@ -536,8 +713,25 @@ export const AddDownloadsModal = () => {
|
||||
: destinationOverrides[i] || await categoryLocationForFile(finalFile);
|
||||
|
||||
const isUrlDupe = store.downloads.some(d => d.url === item.downloadUrl && d.status !== 'failed' && d.status !== 'completed');
|
||||
const hasBatchConflict = plannedTargets.some(target =>
|
||||
downloadLocationEquals(
|
||||
target.location,
|
||||
target.fileName,
|
||||
itemLocation,
|
||||
finalFile,
|
||||
platform.os
|
||||
)
|
||||
);
|
||||
if (isUrlDupe) {
|
||||
newConflicts.push({ id: i.toString(), fileName: finalFile, reason: { type: 'url', msg: 'URL already in queue' }, resolution: 'rename' });
|
||||
} else if (hasBatchConflict) {
|
||||
newConflicts.push({
|
||||
id: i.toString(),
|
||||
fileName: finalFile,
|
||||
reason: { type: 'file', msg: 'Another selected download uses this destination' },
|
||||
resolution: 'rename',
|
||||
replaceAllowed: false
|
||||
});
|
||||
} else {
|
||||
let fileExistsInStore = false;
|
||||
for (const download of store.downloads) {
|
||||
@@ -581,6 +775,7 @@ export const AddDownloadsModal = () => {
|
||||
});
|
||||
}
|
||||
}
|
||||
plannedTargets.push({ location: itemLocation, fileName: finalFile });
|
||||
}
|
||||
|
||||
if (newConflicts.length > 0) {
|
||||
@@ -589,6 +784,7 @@ export const AddDownloadsModal = () => {
|
||||
setPendingUseSharedDestination(useSharedDestination);
|
||||
setPendingDestinationOverrides(destinationOverrides);
|
||||
setShowingDuplicates(true);
|
||||
isSubmittingRef.current = false;
|
||||
setIsSubmitting(false);
|
||||
return;
|
||||
}
|
||||
@@ -596,6 +792,7 @@ export const AddDownloadsModal = () => {
|
||||
try {
|
||||
await executeAddDownloads(action, finalLocation, useSharedDestination, undefined, destinationOverrides);
|
||||
} finally {
|
||||
isSubmittingRef.current = false;
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
@@ -607,7 +804,9 @@ export const AddDownloadsModal = () => {
|
||||
resolutions?: { id: string, resolution: 'rename' | 'replace' | 'skip' }[],
|
||||
destinationOverrides: Record<number, string> = {}
|
||||
) => {
|
||||
let itemsToAdd: Array<AddDownloadDraftRow | null> = [...parsedItems];
|
||||
let itemsToAdd: Array<AddDownloadDraftRow | null> = parsedItems.map(item =>
|
||||
item.selected === false ? null : item
|
||||
);
|
||||
const platform = await getPlatformInfo().catch(() => ({ os: 'unknown' }));
|
||||
|
||||
if (resolutions) {
|
||||
@@ -632,6 +831,17 @@ export const AddDownloadsModal = () => {
|
||||
const ext = finalFile.includes('.') ? finalFile.substring(finalFile.lastIndexOf('.')) : '';
|
||||
let newName = finalFile;
|
||||
let exists = true;
|
||||
const batchTargets: Array<{ location: string; fileName: string }> = [];
|
||||
for (const [candidateIndex, candidate] of itemsToAdd.entries()) {
|
||||
if (!candidate || candidateIndex === idx) continue;
|
||||
const candidateFile = candidate.isMedia
|
||||
? mediaFileNameForSelectedFormat(candidate.file, candidate)
|
||||
: canonicalizeDownloadFileName(candidate.file);
|
||||
const candidateLocation = useSharedDestination
|
||||
? finalLocation
|
||||
: destinationOverrides[candidateIndex] || await categoryLocationForFile(candidateFile);
|
||||
batchTargets.push({ location: candidateLocation, fileName: candidateFile });
|
||||
}
|
||||
|
||||
while (exists && count < 1000) {
|
||||
newName = `${base} (${count})${ext}`;
|
||||
@@ -660,7 +870,14 @@ export const AddDownloadsModal = () => {
|
||||
path: await resolveDownloadFilePath(itemLocation, newName)
|
||||
});
|
||||
} catch(e) {}
|
||||
exists = storeHas || diskHas;
|
||||
const batchHas = batchTargets.some(target => downloadLocationEquals(
|
||||
target.location,
|
||||
target.fileName,
|
||||
itemLocation,
|
||||
newName,
|
||||
platform.os
|
||||
));
|
||||
exists = storeHas || diskHas || batchHas;
|
||||
count++;
|
||||
}
|
||||
if (exists) {
|
||||
@@ -726,6 +943,7 @@ export const AddDownloadsModal = () => {
|
||||
? mediaFileNameForSelectedFormat(item.file, item)
|
||||
: canonicalizeDownloadFileName(item.file);
|
||||
let formatSelector = mediaFormatSelectorForRow(item);
|
||||
const contextUrl = requestContextUrlForRow(item);
|
||||
|
||||
const category = categoryForFileName(finalFile);
|
||||
const added = await addDownload({
|
||||
@@ -735,14 +953,14 @@ export const AddDownloadsModal = () => {
|
||||
category,
|
||||
dateAdded: new Date().toISOString(),
|
||||
connections: Number(connections),
|
||||
speedLimit: speedLimitEnabled ? `${speedLimit}K` : '0',
|
||||
speedLimit: speedLimitEnabled ? `${speedLimit}K` : undefined,
|
||||
username: useAuth ? username.trim() : undefined,
|
||||
password: useAuth ? password.trim() : undefined,
|
||||
headers: headersForRow(item.sourceUrl) || undefined,
|
||||
headers: headersForRow(contextUrl) || undefined,
|
||||
checksum: checksumEnabled && checksumValue.trim()
|
||||
? `${checksumAlgo}=${checksumValue.trim()}`
|
||||
: undefined,
|
||||
cookies: cookiesForRow(item.sourceUrl, item.downloadUrl) || undefined,
|
||||
cookies: cookiesForRow(contextUrl, item.downloadUrl) || undefined,
|
||||
mirrors: mirrors.trim() || undefined,
|
||||
destination: useSharedDestination
|
||||
? finalLocation
|
||||
@@ -750,7 +968,8 @@ export const AddDownloadsModal = () => {
|
||||
isMedia: item.isMedia,
|
||||
resumable: item.resumable,
|
||||
mediaFormatSelector: formatSelector,
|
||||
size: item.size || (item.sizeBytes ? formatBytes(item.sizeBytes) : undefined)
|
||||
size: item.size || (item.sizeBytes ? formatBytes(item.sizeBytes) : undefined),
|
||||
sizeBytes: item.sizeBytes
|
||||
}, action);
|
||||
if (!added) {
|
||||
throw new Error('Backend rejected download start.');
|
||||
@@ -791,6 +1010,19 @@ export const AddDownloadsModal = () => {
|
||||
</div>
|
||||
);
|
||||
|
||||
const toggleRowSelection = (index: number) => {
|
||||
setParsedItems(items => items.map((item, itemIndex) =>
|
||||
itemIndex === index ? { ...item, selected: item.selected === false } : item
|
||||
));
|
||||
};
|
||||
|
||||
const toggleAllRows = () => {
|
||||
setParsedItems(items => {
|
||||
const shouldSelect = items.some(item => item.selected === false);
|
||||
return items.map(item => ({ ...item, selected: shouldSelect }));
|
||||
});
|
||||
};
|
||||
|
||||
const selectMediaFormat = (index: number) => {
|
||||
if (selectedItemIndex === null) return;
|
||||
const selectedItem = parsedItems[selectedItemIndex];
|
||||
@@ -813,8 +1045,10 @@ export const AddDownloadsModal = () => {
|
||||
));
|
||||
};
|
||||
|
||||
const requiredBytes = parsedItems.reduce((acc, item) => acc + (item.sizeBytes || 0), 0);
|
||||
const hasApproximateSize = parsedItems.some(item =>
|
||||
const selectedItems = parsedItems.filter(item => item.selected !== false);
|
||||
const allRowsSelected = parsedItems.length > 0 && selectedItems.length === parsedItems.length;
|
||||
const requiredBytes = selectedItems.reduce((acc, item) => acc + (item.sizeBytes || 0), 0);
|
||||
const hasApproximateSize = selectedItems.some(item =>
|
||||
item.formats?.[item.selectedFormat ?? -1]?.isApproximate
|
||||
);
|
||||
const requiredStr = requiredBytes > 0
|
||||
@@ -823,11 +1057,19 @@ export const AddDownloadsModal = () => {
|
||||
: `${(requiredBytes / 1024 / 1024 / 1024).toFixed(2)} GB`}`
|
||||
: 'Unknown';
|
||||
const canSubmit = canSubmitMetadataRows(parsedItems);
|
||||
const failedMetadataCount = parsedItems.filter(item => item.status === 'metadata-error').length;
|
||||
const failedMediaMetadataCount = parsedItems.filter(
|
||||
const failedMetadataCount = selectedItems.filter(item => item.status === 'metadata-error').length;
|
||||
const failedMediaMetadataCount = selectedItems.filter(
|
||||
item => item.status === 'metadata-error' && item.isMedia
|
||||
).length;
|
||||
const fallbackMetadataCount = failedMetadataCount - failedMediaMetadataCount;
|
||||
const blockedMetadataCount = selectedItems.filter(
|
||||
item => item.metadataBlockedReason === 'unsafe-url'
|
||||
).length;
|
||||
const fallbackMetadataCount = failedMetadataCount - failedMediaMetadataCount - blockedMetadataCount;
|
||||
const activePlaylistUrls = new Set(
|
||||
urls.split('\n').map(url => url.trim()).filter(Boolean).map(normalizeComparableUrl)
|
||||
);
|
||||
const playlistSummaries = Object.entries(playlistExpansions)
|
||||
.filter(([sourceUrl]) => activePlaylistUrls.has(sourceUrl));
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -835,6 +1077,8 @@ export const AddDownloadsModal = () => {
|
||||
<DuplicateResolutionModal
|
||||
conflicts={conflicts}
|
||||
onConfirm={(resolutions) => {
|
||||
if (isSubmittingRef.current) return;
|
||||
isSubmittingRef.current = true;
|
||||
setShowingDuplicates(false);
|
||||
setIsSubmitting(true);
|
||||
void executeAddDownloads(
|
||||
@@ -851,20 +1095,30 @@ export const AddDownloadsModal = () => {
|
||||
isActionable: true
|
||||
});
|
||||
})
|
||||
.finally(() => setIsSubmitting(false));
|
||||
.finally(() => {
|
||||
isSubmittingRef.current = false;
|
||||
setIsSubmitting(false);
|
||||
});
|
||||
}}
|
||||
onCancel={() => setShowingDuplicates(false)}
|
||||
/>
|
||||
)}
|
||||
<div className="app-modal-backdrop fixed inset-0 z-50 flex items-center justify-center">
|
||||
<div
|
||||
className="app-modal-backdrop fixed inset-0 z-50 flex items-center justify-center"
|
||||
onClick={(event) => {
|
||||
if (event.target === event.currentTarget) closeModalFromDismissAction();
|
||||
}}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
>
|
||||
<div className="app-modal add-download-modal flex flex-col overflow-hidden text-sm">
|
||||
|
||||
{/* Main Content Split */}
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
<div className="flex flex-1 min-h-0 overflow-hidden">
|
||||
|
||||
{/* Left Column: URLs and Preview */}
|
||||
<div className="add-download-left w-[55%] border-r border-border-modal flex flex-col">
|
||||
<div className="add-download-pane p-5 flex-1 flex flex-col gap-5">
|
||||
<div className="add-download-pane p-5 flex-1 min-h-0 min-w-0 flex flex-col gap-5">
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center justify-between">
|
||||
@@ -879,9 +1133,19 @@ export const AddDownloadsModal = () => {
|
||||
value={urls}
|
||||
onChange={(e) => setUrls(e.target.value)}
|
||||
/>
|
||||
{playlistSummaries.map(([sourceUrl, playlist]) => {
|
||||
const total = playlist.entry_count || playlist.entries.length;
|
||||
return (
|
||||
<p key={sourceUrl} className="px-1 text-[11px] text-purple-500 dark:text-purple-400">
|
||||
Playlist “{playlist.title}”: {playlist.entries.length}{total > playlist.entries.length ? ` of ${total}` : ''} entries loaded
|
||||
{playlist.truncated ? ' (safe entry limit reached)' : ''}
|
||||
{playlist.skipped_entries > 0 ? `; ${playlist.skipped_entries} skipped, unavailable, duplicated, or outside the safe limit` : ''}.
|
||||
</p>
|
||||
);
|
||||
})}
|
||||
<div className="flex justify-between items-center px-1">
|
||||
<span className="text-[11px] text-text-muted font-medium">
|
||||
{parsedItems.filter(item => item.status === 'ready').length} ready, {fallbackMetadataCount} fallback, {failedMediaMetadataCount} media retry
|
||||
{selectedItems.filter(item => item.status === 'ready').length} selected ready, {fallbackMetadataCount} fallback, {failedMediaMetadataCount} media retry, {blockedMetadataCount} blocked
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
@@ -891,28 +1155,36 @@ export const AddDownloadsModal = () => {
|
||||
>
|
||||
<RefreshCw size={12} /> Refresh Metadata
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleAllRows}
|
||||
disabled={parsedItems.length === 0}
|
||||
className="add-download-link-button ml-3 text-[11px] font-medium"
|
||||
>
|
||||
{allRowsSelected ? 'Clear selection' : 'Select all'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-4 gap-3">
|
||||
<SummaryBox title="Files" value={parsedItems.length} icon={FileText} color="text-blue-500" />
|
||||
<SummaryBox title="Files" value={selectedItems.length === parsedItems.length ? parsedItems.length : `${selectedItems.length}/${parsedItems.length}`} icon={FileText} color="text-blue-500" />
|
||||
<SummaryBox title="Required" value={requiredStr} icon={Database} color="text-orange-500" />
|
||||
<SummaryBox title="Free" value={freeSpace} icon={HardDrive} color="text-green-500" />
|
||||
<SummaryBox title="Unknown" value={parsedItems.filter(i => !i.sizeBytes).length} icon={FileText} color="text-purple-500" />
|
||||
<SummaryBox title="Unknown" value={selectedItems.filter(i => !i.sizeBytes).length} icon={FileText} color="text-purple-500" />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2 flex-1 overflow-hidden">
|
||||
<div className="flex flex-col gap-2 flex-1 min-h-0 min-w-0 overflow-hidden">
|
||||
<div className="add-download-section-title flex items-center gap-2">
|
||||
<ArrowRight size={16} className="text-blue-500" />
|
||||
Preview
|
||||
</div>
|
||||
<div className="add-download-preview flex-1 overflow-hidden flex flex-col">
|
||||
<div className="add-download-preview flex-1 min-h-0 min-w-0 overflow-hidden flex flex-col">
|
||||
<div className="add-download-preview-header px-3 py-2 flex text-[11px] font-semibold text-text-muted uppercase tracking-wider">
|
||||
<div className="flex-[2]">File</div>
|
||||
<div className="flex-1">Size</div>
|
||||
<div className="flex-[1.5]">Status</div>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto p-2 space-y-1" role="listbox" aria-label="Download preview">
|
||||
<div className="flex-1 min-h-0 min-w-0 overflow-y-auto p-2 space-y-1" role="listbox" aria-label="Download preview">
|
||||
{parsedItems.length === 0 ? (
|
||||
<div className="add-download-empty h-full flex items-center justify-center text-text-muted text-xs">
|
||||
No links added yet.
|
||||
@@ -936,18 +1208,26 @@ export const AddDownloadsModal = () => {
|
||||
? 'is-selected border'
|
||||
: 'border border-transparent'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center w-full">
|
||||
>
|
||||
<div className="flex items-center w-full">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={item.selected !== false}
|
||||
onChange={() => toggleRowSelection(i)}
|
||||
onClick={event => event.stopPropagation()}
|
||||
aria-label={`Select ${item.file}`}
|
||||
className="mr-2 shrink-0 accent-purple-500"
|
||||
/>
|
||||
<div className="flex-[2] text-text-primary font-medium truncate pr-2" title={item.file}>{item.file}</div>
|
||||
<div className={`flex-1 font-mono ${item.status === 'loading' ? 'text-text-muted/50' : 'text-text-muted'}`}>{item.size || 'Unknown'}</div>
|
||||
<div className={`flex-[1.5] font-medium ${item.status === 'metadata-error' || item.status === 'invalid' ? 'text-red-500' : item.status === 'loading' ? 'text-orange-400' : 'text-blue-500'}`}>
|
||||
{item.status === 'loading' ? (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<RefreshCw size={12} className="animate-spin" /> Fetching...
|
||||
<RefreshCw size={12} className="animate-spin" /> {item.isPlaylist ? 'Fetching playlist...' : 'Fetching...'}
|
||||
</div>
|
||||
) : (
|
||||
item.status === 'metadata-error'
|
||||
? item.isMedia ? 'Metadata failed' : 'Fallback'
|
||||
? item.metadataBlockedReason === 'unsafe-url' ? 'Unsafe URL' : item.isPlaylist ? 'Playlist failed' : item.isMedia ? 'Metadata failed' : 'Fallback'
|
||||
: item.status === 'invalid'
|
||||
? 'Invalid'
|
||||
: 'Ready'
|
||||
@@ -976,6 +1256,11 @@ export const AddDownloadsModal = () => {
|
||||
</div>
|
||||
<div className="add-download-section-title flex items-center gap-2 mb-3 relative z-10">
|
||||
<Video size={16} className="text-purple-500" /> Media Format
|
||||
{parsedItems[selectedItemIndex].playlistSourceUrl && (
|
||||
<span className="text-[10px] font-normal text-text-muted">
|
||||
Playlist item {parsedItems[selectedItemIndex].playlistIndex || '?'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{parsedItems[selectedItemIndex].status === 'loading' ? (
|
||||
@@ -1180,7 +1465,7 @@ export const AddDownloadsModal = () => {
|
||||
{metadataSummaryMessage(parsedItems)}
|
||||
</div>
|
||||
<div className="flex gap-2.5">
|
||||
<button onClick={() => toggleAddModal(false)} className="add-download-button add-download-button-cancel px-4 text-xs">
|
||||
<button onClick={closeModalFromDismissAction} disabled={isSubmitting || showKeychainModal} className="add-download-button add-download-button-secondary px-4 text-xs">
|
||||
Cancel
|
||||
</button>
|
||||
<div ref={actionMenuRef} className="relative flex gap-2.5">
|
||||
|
||||
@@ -14,6 +14,15 @@ export const DeleteConfirmationModal: React.FC = () => {
|
||||
}
|
||||
}, [deleteModalState.isOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!deleteModalState.isOpen || isRemoving) return;
|
||||
const handleEscape = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') closeDeleteModal();
|
||||
};
|
||||
window.addEventListener('keydown', handleEscape);
|
||||
return () => window.removeEventListener('keydown', handleEscape);
|
||||
}, [closeDeleteModal, deleteModalState.isOpen, isRemoving]);
|
||||
|
||||
if (!deleteModalState.isOpen) return null;
|
||||
|
||||
const handleCancel = () => {
|
||||
@@ -53,7 +62,14 @@ export const DeleteConfirmationModal: React.FC = () => {
|
||||
const handleDeleteFile = () => removeMany(true);
|
||||
|
||||
return (
|
||||
<div className="app-modal-backdrop fixed inset-0 z-50 flex items-center justify-center bg-black/40">
|
||||
<div
|
||||
className="app-modal-backdrop fixed inset-0 z-50 flex items-center justify-center bg-black/40"
|
||||
onClick={(event) => {
|
||||
if (event.target === event.currentTarget && !isRemoving) handleCancel();
|
||||
}}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
>
|
||||
<div
|
||||
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()}
|
||||
@@ -74,7 +90,7 @@ export const DeleteConfirmationModal: React.FC = () => {
|
||||
<button
|
||||
onClick={handleCancel}
|
||||
disabled={isRemoving}
|
||||
className="px-4 py-2 rounded-lg text-sm font-medium transition-colors text-text-secondary hover:bg-item-hover hover:text-text-primary disabled:opacity-50"
|
||||
className="app-button px-4 py-2 rounded-lg text-sm font-medium disabled:opacity-50"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
|
||||
@@ -1,54 +1,44 @@
|
||||
import React from 'react';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
import { useDownloadStore } from '../store/useDownloadStore';
|
||||
import { useDownloadProgressStore } from '../store/downloadStore';
|
||||
import { useDownloadProgressStore } from '../store/downloadProgressStore';
|
||||
import { Play, Pause, MoreVertical, Clock, ArrowUp, ArrowDown } from 'lucide-react';
|
||||
import type { DownloadItem as DownloadItemType } from '../bindings/DownloadItem';
|
||||
import { canPauseDownload, canStartDownload, startActionLabel } from '../utils/downloadActions';
|
||||
import { isActiveDownloadStatus } from '../utils/downloads';
|
||||
import {
|
||||
downloadProgressColorClass,
|
||||
formatDownloadTotal,
|
||||
resolveDownloadSizeDisplay
|
||||
} from '../utils/downloadProgress';
|
||||
|
||||
interface DownloadItemProps {
|
||||
downloadId: string;
|
||||
download: DownloadItemType;
|
||||
index: number;
|
||||
queueIndex: number;
|
||||
queueLength: number;
|
||||
tableGridTemplate: string;
|
||||
setContextMenu: (menu: { x: number; y: number; id: string }) => void;
|
||||
handlePause: (id: string, skipConfirm?: boolean) => void;
|
||||
handleResume: (item: DownloadItemType) => void;
|
||||
getCategoryIcon: (category: string) => React.ReactNode;
|
||||
selectedIds: Set<string>;
|
||||
isSelected: boolean;
|
||||
onMoveInQueue: (id: string, direction: 'up' | 'down') => void;
|
||||
onClick: (e: React.MouseEvent, item: DownloadItemType) => void;
|
||||
}
|
||||
|
||||
export const DownloadItem = React.memo<DownloadItemProps>(({
|
||||
downloadId,
|
||||
download,
|
||||
index,
|
||||
queueIndex,
|
||||
queueLength,
|
||||
tableGridTemplate,
|
||||
setContextMenu,
|
||||
handlePause,
|
||||
handleResume,
|
||||
getCategoryIcon,
|
||||
selectedIds,
|
||||
isSelected,
|
||||
onMoveInQueue,
|
||||
onClick,
|
||||
}) => {
|
||||
const download = useDownloadStore(state => state.downloads.find(d => d.id === downloadId));
|
||||
const queueItems = useDownloadStore(useShallow(state => {
|
||||
const item = state.downloads.find(candidate => candidate.id === downloadId);
|
||||
if (!item) return [];
|
||||
const queueId = item.queueId;
|
||||
return state.downloads
|
||||
.filter(candidate =>
|
||||
candidate.queueId === queueId &&
|
||||
candidate.status !== 'completed' &&
|
||||
!(isActiveDownloadStatus(candidate.status) && candidate.status !== 'queued')
|
||||
)
|
||||
.sort((left, right) => (left.queuePosition ?? 0) - (right.queuePosition ?? 0))
|
||||
.map(candidate => candidate.id);
|
||||
}));
|
||||
const moveInQueue = useDownloadStore(state => state.moveInQueue);
|
||||
const liveProgress = useDownloadProgressStore(state => state.progressMap[downloadId]);
|
||||
const queueIndex = queueItems.indexOf(downloadId);
|
||||
|
||||
if (!download) return null;
|
||||
const liveProgress = useDownloadProgressStore(state => state.progressMap[download.id]);
|
||||
|
||||
const displayFraction = download.status === 'downloading'
|
||||
? liveProgress?.fraction ?? download.fraction ?? 0
|
||||
@@ -64,10 +54,21 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
|
||||
: download.status === 'processing'
|
||||
? 'Muxing...'
|
||||
: '-';
|
||||
const sizeDisplay = resolveDownloadSizeDisplay({
|
||||
downloadedBytes: liveProgress?.downloaded_bytes ?? download.downloadedBytes,
|
||||
totalBytes: liveProgress?.total_bytes ?? download.totalBytes,
|
||||
totalIsEstimate: liveProgress?.total_is_estimate ?? download.totalIsEstimate,
|
||||
fallbackSize: download.size
|
||||
});
|
||||
const hasDownloadedAmount = download.status !== 'completed' &&
|
||||
Boolean(sizeDisplay.downloaded && sizeDisplay.total);
|
||||
const completedSizeLabel = download.status === 'completed'
|
||||
? formatDownloadTotal(sizeDisplay)
|
||||
: sizeDisplay.fallback;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`download-row group cursor-default relative ${index % 2 !== 0 ? 'striped' : ''} ${selectedIds.has(downloadId) ? 'is-selected' : ''}`}
|
||||
className={`download-row group cursor-default relative ${index % 2 !== 0 ? 'striped' : ''} ${isSelected ? 'is-selected' : ''}`}
|
||||
style={{ gridTemplateColumns: tableGridTemplate }}
|
||||
onClick={(e) => onClick(e, download)}
|
||||
onContextMenu={(e) => {
|
||||
@@ -84,9 +85,25 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="download-cell-truncate">
|
||||
<span className="tabular-nums" title={download.size && download.size !== '-' ? download.size : 'Unknown'}>
|
||||
{download.size && download.size !== '-' ? download.size : 'Unknown'}
|
||||
<div
|
||||
className="download-cell-truncate download-size-cell tabular-nums"
|
||||
title={hasDownloadedAmount
|
||||
? `${sizeDisplay.downloaded} downloaded of ${sizeDisplay.totalIsEstimate ? '~' : ''}${sizeDisplay.total} ${sizeDisplay.unit}`
|
||||
: completedSizeLabel}
|
||||
aria-label={hasDownloadedAmount
|
||||
? `${sizeDisplay.downloaded} downloaded of ${sizeDisplay.totalIsEstimate ? 'approximately ' : ''}${sizeDisplay.total} ${sizeDisplay.unit}`
|
||||
: completedSizeLabel}
|
||||
>
|
||||
{hasDownloadedAmount ? (
|
||||
<span className="download-size-progress">
|
||||
<span className={downloadProgressColorClass(download.status)}>{sizeDisplay.downloaded}</span>
|
||||
<span className="text-text-muted"> / </span>
|
||||
</span>
|
||||
) : null}
|
||||
<span className="download-size-total">
|
||||
{hasDownloadedAmount
|
||||
? `${sizeDisplay.totalIsEstimate ? '~' : ''}${sizeDisplay.total} ${sizeDisplay.unit}`
|
||||
: completedSizeLabel}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -106,8 +123,7 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
|
||||
style={{ width: `${displayFraction * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span
|
||||
key={`status-${download.status}`}
|
||||
<span
|
||||
title={
|
||||
download.lastError && (download.status === 'failed' || download.status === 'retrying')
|
||||
? download.lastError
|
||||
@@ -149,7 +165,6 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
|
||||
|
||||
<div className="download-cell-truncate">
|
||||
<span
|
||||
key={`speed-${download.status}`}
|
||||
className="tabular-nums"
|
||||
title={displaySpeed}
|
||||
>
|
||||
@@ -159,7 +174,6 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
|
||||
|
||||
<div className="download-cell-truncate">
|
||||
<span
|
||||
key={`eta-${download.status}`}
|
||||
className="tabular-nums"
|
||||
title={displayEta}
|
||||
>
|
||||
@@ -183,7 +197,7 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
|
||||
{(download.status === 'queued' || download.status === 'staged') && queueIndex !== -1 && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => moveInQueue(selectedIds.has(download.id) ? Array.from(selectedIds) : download.id, 'up')}
|
||||
onClick={() => onMoveInQueue(download.id, 'up')}
|
||||
disabled={queueIndex === 0}
|
||||
className="app-icon-button h-7 w-7 disabled:opacity-40"
|
||||
title="Move Up"
|
||||
@@ -191,8 +205,8 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
|
||||
<ArrowUp size={14} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => moveInQueue(selectedIds.has(download.id) ? Array.from(selectedIds) : download.id, 'down')}
|
||||
disabled={queueIndex === queueItems.length - 1}
|
||||
onClick={() => onMoveInQueue(download.id, 'down')}
|
||||
disabled={queueIndex === queueLength - 1}
|
||||
className="app-icon-button h-7 w-7 disabled:opacity-40"
|
||||
title="Move Down"
|
||||
>
|
||||
|
||||
+203
-158
@@ -1,5 +1,5 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { useDownloadStore, DownloadItem } from '../store/useDownloadStore';
|
||||
import React, { useState, useEffect, useMemo, useRef, useCallback } from 'react';
|
||||
import { useDownloadStore, DownloadItem, MAIN_QUEUE_ID } from '../store/useDownloadStore';
|
||||
import { useToast } from '../contexts/ToastContext';
|
||||
import { useSettingsStore } from '../store/useSettingsStore';
|
||||
import { SidebarFilter } from './Sidebar';
|
||||
@@ -17,8 +17,13 @@ import {
|
||||
canStartDownload,
|
||||
startActionLabel
|
||||
} from '../utils/downloadActions';
|
||||
import { isActiveDownloadStatus } from '../utils/downloads';
|
||||
import { isActiveDownloadStatus, isTransferActiveStatus } from '../utils/downloads';
|
||||
import { readClipboardDownloadUrls } from '../utils/clipboard';
|
||||
import {
|
||||
sortDownloads,
|
||||
type DownloadSortColumn,
|
||||
type DownloadSortConfig
|
||||
} from '../utils/downloadTableSorting';
|
||||
|
||||
interface DownloadTableProps {
|
||||
filter: SidebarFilter;
|
||||
@@ -28,7 +33,7 @@ const DEFAULT_COLUMN_WIDTHS = [340, 100, 220, 100, 80, 170];
|
||||
const COLUMN_WIDTHS_STORAGE_KEY = 'firelink-download-column-widths';
|
||||
|
||||
export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
|
||||
const { downloads, queues, assignToQueue, openDeleteModal, redownload } = useDownloadStore();
|
||||
const { downloads, queues, assignToQueue, openDeleteModal, redownload, moveInQueue } = useDownloadStore();
|
||||
const { addToast } = useToast();
|
||||
const isMac = navigator.userAgent.includes('Mac');
|
||||
const [isReadingClipboard, setIsReadingClipboard] = useState(false);
|
||||
@@ -46,7 +51,13 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
|
||||
const [animationParent] = useAutoAnimate<HTMLDivElement>();
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
||||
const [lastSelectedId, setLastSelectedId] = useState<string | null>(null);
|
||||
const [sortConfig, setSortConfig] = useState<{ column: string; direction: 'asc' | 'desc' } | null>({ column: 'Date Added', direction: 'desc' });
|
||||
const [sortConfig, setSortConfig] = useState<DownloadSortConfig>({ column: 'Date Added', direction: 'desc' });
|
||||
const [queueSortConfig, setQueueSortConfig] = useState<DownloadSortConfig | null>(null);
|
||||
const selectedIdsRef = useRef(selectedIds);
|
||||
const lastSelectedIdRef = useRef(lastSelectedId);
|
||||
const sortedDownloadsRef = useRef<DownloadItem[]>([]);
|
||||
selectedIdsRef.current = selectedIds;
|
||||
lastSelectedIdRef.current = lastSelectedId;
|
||||
const [columnWidths, setColumnWidths] = useState(() => {
|
||||
try {
|
||||
const stored = JSON.parse(window.localStorage.getItem(COLUMN_WIDTHS_STORAGE_KEY) || 'null');
|
||||
@@ -110,15 +121,15 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
|
||||
if (!isInput) {
|
||||
if ((e.key === 'a' || e.key === 'A') && (e.metaKey || e.ctrlKey)) {
|
||||
e.preventDefault();
|
||||
const allIds = sortedDownloads.map(d => d.id);
|
||||
const allIds = sortedDownloadsRef.current.map(d => d.id);
|
||||
setSelectedIds(new Set(allIds));
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.key === 'Delete' || e.key === 'Backspace') {
|
||||
if (!activeEl || !activeEl.closest('.sidebar-inner')) {
|
||||
if (selectedIds.size > 0) {
|
||||
handleDelete(Array.from(selectedIds));
|
||||
if (selectedIdsRef.current.size > 0) {
|
||||
handleDelete(Array.from(selectedIdsRef.current));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -126,28 +137,28 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
|
||||
};
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [selectedIds]);
|
||||
}, []);
|
||||
|
||||
|
||||
const showInteractionError = (message: string, error: unknown) => {
|
||||
const showInteractionError = useCallback((message: string, error: unknown) => {
|
||||
const detail = error instanceof Error ? error.message : String(error);
|
||||
addToast({ message: `${message}: ${detail}`, variant: 'error', isActionable: true });
|
||||
};
|
||||
}, [addToast]);
|
||||
|
||||
const getDownloadPath = async (item: DownloadItem) => {
|
||||
const getDownloadPath = useCallback(async (item: DownloadItem) => {
|
||||
const fileName = item.fileName?.trim();
|
||||
if (!fileName) return null;
|
||||
const settings = useSettingsStore.getState();
|
||||
const destination = item.destination ||
|
||||
await resolveCategoryDestination(settings, item.category);
|
||||
return resolveDownloadFilePath(destination, fileName);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const openProperties = (id: string) => {
|
||||
const openProperties = useCallback((id: string) => {
|
||||
useDownloadStore.getState().setSelectedPropertiesDownloadId(id);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const openDownloadFile = async (item: DownloadItem) => {
|
||||
const openDownloadFile = useCallback(async (item: DownloadItem) => {
|
||||
if (item.status !== 'completed') {
|
||||
openProperties(item.id);
|
||||
return;
|
||||
@@ -165,7 +176,7 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
|
||||
console.error("Failed to open file:", error);
|
||||
showInteractionError('Could not open downloaded file', error);
|
||||
}
|
||||
};
|
||||
}, [getDownloadPath, openProperties, showInteractionError]);
|
||||
|
||||
const revealDownloadFile = async (item: DownloadItem) => {
|
||||
const pathToReveal = await getDownloadPath(item);
|
||||
@@ -183,52 +194,33 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleDownloadDoubleClick = (item: DownloadItem) => {
|
||||
const handleDownloadDoubleClick = useCallback((item: DownloadItem) => {
|
||||
if (item.status === 'completed') {
|
||||
void openDownloadFile(item);
|
||||
return;
|
||||
}
|
||||
|
||||
openProperties(item.id);
|
||||
};
|
||||
}, [openDownloadFile, openProperties]);
|
||||
|
||||
const filteredDownloads = downloads.filter((d: DownloadItem) => {
|
||||
if (filter.startsWith('queue:')) {
|
||||
const isQueueFilter = filter.startsWith('queue:');
|
||||
const filteredDownloads = useMemo(() => downloads.filter((d: DownloadItem) => {
|
||||
if (isQueueFilter) {
|
||||
return d.queueId === filter.replace('queue:', '') && d.status !== 'completed';
|
||||
}
|
||||
switch (filter) {
|
||||
case 'all': return true;
|
||||
case 'active': return d.status === 'downloading';
|
||||
case 'active': return isTransferActiveStatus(d.status);
|
||||
case 'completed': return d.status === 'completed';
|
||||
case 'unfinished': return d.status !== 'completed';
|
||||
default: return d.category === filter;
|
||||
}
|
||||
});
|
||||
}), [downloads, filter, isQueueFilter]);
|
||||
|
||||
const parseSpeed = (speedStr?: string) => {
|
||||
if (!speedStr || speedStr === '-') return 0;
|
||||
const val = parseFloat(speedStr);
|
||||
if (speedStr.includes('KB/s')) return val * 1024;
|
||||
if (speedStr.includes('MB/s')) return val * 1024 * 1024;
|
||||
if (speedStr.includes('GB/s')) return val * 1024 * 1024 * 1024;
|
||||
return val;
|
||||
};
|
||||
|
||||
const parseEta = (etaStr?: string) => {
|
||||
if (!etaStr || etaStr === '-') return Infinity;
|
||||
let seconds = 0;
|
||||
const hours = etaStr.match(/(\d+)h/);
|
||||
const minutes = etaStr.match(/(\d+)m/);
|
||||
const secs = etaStr.match(/(\d+)s/);
|
||||
if (hours) seconds += parseInt(hours[1]) * 3600;
|
||||
if (minutes) seconds += parseInt(minutes[1]) * 60;
|
||||
if (secs) seconds += parseInt(secs[1]);
|
||||
return seconds;
|
||||
};
|
||||
|
||||
// Sort by queue position when viewing a specific queue so the visual
|
||||
// order matches the queue order and move-up/down buttons reflect reality.
|
||||
const sortedDownloads = filter.startsWith('queue:')
|
||||
// Queue views use the persisted queue order until the user explicitly sorts
|
||||
// a column. This keeps move-up/down controls truthful while still making
|
||||
// every header a working sort target.
|
||||
const sortedDownloads = useMemo(() => isQueueFilter && !queueSortConfig
|
||||
? [...filteredDownloads].sort((left, right) => {
|
||||
const leftActive = isActiveDownloadStatus(left.status) && left.status !== 'queued';
|
||||
const rightActive = isActiveDownloadStatus(right.status) && right.status !== 'queued';
|
||||
@@ -236,61 +228,83 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
|
||||
if (leftActive && !rightActive) return -1;
|
||||
if (!leftActive && rightActive) return 1;
|
||||
|
||||
return (left.queuePosition ?? 0) - (right.queuePosition ?? 0);
|
||||
const positionComparison = (left.queuePosition ?? Number.MAX_SAFE_INTEGER) -
|
||||
(right.queuePosition ?? Number.MAX_SAFE_INTEGER);
|
||||
return positionComparison || left.id.localeCompare(right.id);
|
||||
})
|
||||
: sortConfig
|
||||
? [...filteredDownloads].sort((a, b) => {
|
||||
const aUnfinished = a.status !== 'completed';
|
||||
const bUnfinished = b.status !== 'completed';
|
||||
: sortDownloads(filteredDownloads, isQueueFilter ? queueSortConfig! : sortConfig),
|
||||
[filteredDownloads, isQueueFilter, queueSortConfig, sortConfig]);
|
||||
|
||||
if (aUnfinished && !bUnfinished) return -1;
|
||||
if (!aUnfinished && bUnfinished) return 1;
|
||||
// Each row used to derive this by filtering and sorting the complete store
|
||||
// independently. That made a 1000-entry playlist perform O(n^2 log n) work
|
||||
// on every download update. Compute the same queue membership once and pass
|
||||
// the resulting position to rows instead.
|
||||
const queuePositionsByDownloadId = useMemo(() => {
|
||||
const grouped = new Map<string, DownloadItem[]>();
|
||||
for (const download of downloads) {
|
||||
if (
|
||||
download.status === 'completed'
|
||||
|| (isActiveDownloadStatus(download.status) && download.status !== 'queued')
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const queueId = download.queueId || MAIN_QUEUE_ID;
|
||||
const queueItems = grouped.get(queueId) || [];
|
||||
queueItems.push(download);
|
||||
grouped.set(queueId, queueItems);
|
||||
}
|
||||
|
||||
let comparison = 0;
|
||||
switch (sortConfig.column) {
|
||||
case 'File Name':
|
||||
comparison = (a.fileName || a.url || '').localeCompare(b.fileName || b.url || '');
|
||||
break;
|
||||
case 'Size':
|
||||
comparison = parseInt(a.size || '0', 10) - parseInt(b.size || '0', 10);
|
||||
break;
|
||||
case 'Status':
|
||||
comparison = a.status.localeCompare(b.status);
|
||||
break;
|
||||
case 'Speed':
|
||||
comparison = parseSpeed(a.speed) - parseSpeed(b.speed);
|
||||
break;
|
||||
case 'ETA':
|
||||
comparison = parseEta(a.eta) - parseEta(b.eta);
|
||||
break;
|
||||
case 'Date Added':
|
||||
comparison = new Date(a.dateAdded || 0).getTime() - new Date(b.dateAdded || 0).getTime();
|
||||
break;
|
||||
}
|
||||
return sortConfig.direction === 'asc' ? comparison : -comparison;
|
||||
})
|
||||
: filteredDownloads;
|
||||
const handleItemClick = (e: React.MouseEvent, item: DownloadItem) => {
|
||||
const positions = new Map<string, { index: number; length: number }>();
|
||||
for (const queueItems of grouped.values()) {
|
||||
queueItems.sort((left, right) =>
|
||||
(left.queuePosition ?? Number.MAX_SAFE_INTEGER) -
|
||||
(right.queuePosition ?? Number.MAX_SAFE_INTEGER) ||
|
||||
left.id.localeCompare(right.id)
|
||||
);
|
||||
queueItems.forEach((download, index) => {
|
||||
positions.set(download.id, { index, length: queueItems.length });
|
||||
});
|
||||
}
|
||||
return positions;
|
||||
}, [downloads]);
|
||||
sortedDownloadsRef.current = sortedDownloads;
|
||||
|
||||
useEffect(() => {
|
||||
const visibleIds = new Set(sortedDownloads.map(download => download.id));
|
||||
setSelectedIds(current => {
|
||||
const next = new Set(Array.from(current).filter(id => visibleIds.has(id)));
|
||||
return next.size === current.size ? current : next;
|
||||
});
|
||||
setLastSelectedId(current => current && visibleIds.has(current) ? current : null);
|
||||
}, [sortedDownloads]);
|
||||
|
||||
useEffect(() => {
|
||||
setQueueSortConfig(null);
|
||||
}, [filter, isQueueFilter]);
|
||||
const handleItemClick = useCallback((e: React.MouseEvent, item: DownloadItem) => {
|
||||
if (e.detail === 2) {
|
||||
handleDownloadDoubleClick(item);
|
||||
return;
|
||||
}
|
||||
if (e.shiftKey && lastSelectedId) {
|
||||
const currentIndex = sortedDownloads.findIndex(d => d.id === item.id);
|
||||
const lastIndex = sortedDownloads.findIndex(d => d.id === lastSelectedId);
|
||||
const currentSortedDownloads = sortedDownloadsRef.current;
|
||||
const currentSelectedIds = selectedIdsRef.current;
|
||||
const currentLastSelectedId = lastSelectedIdRef.current;
|
||||
if (e.shiftKey && currentLastSelectedId) {
|
||||
const currentIndex = currentSortedDownloads.findIndex(d => d.id === item.id);
|
||||
const lastIndex = currentSortedDownloads.findIndex(d => d.id === currentLastSelectedId);
|
||||
|
||||
if (currentIndex !== -1 && lastIndex !== -1) {
|
||||
const start = Math.min(currentIndex, lastIndex);
|
||||
const end = Math.max(currentIndex, lastIndex);
|
||||
|
||||
const newSelected = (e.metaKey || e.ctrlKey) ? new Set(selectedIds) : new Set<string>();
|
||||
const newSelected = (e.metaKey || e.ctrlKey) ? new Set(currentSelectedIds) : new Set<string>();
|
||||
for (let i = start; i <= end; i++) {
|
||||
newSelected.add(sortedDownloads[i].id);
|
||||
newSelected.add(currentSortedDownloads[i].id);
|
||||
}
|
||||
setSelectedIds(newSelected);
|
||||
}
|
||||
} else if (e.metaKey || e.ctrlKey) {
|
||||
const newSelected = new Set(selectedIds);
|
||||
const newSelected = new Set(currentSelectedIds);
|
||||
if (newSelected.has(item.id)) {
|
||||
newSelected.delete(item.id);
|
||||
} else {
|
||||
@@ -302,25 +316,34 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
|
||||
setSelectedIds(new Set([item.id]));
|
||||
setLastSelectedId(item.id);
|
||||
}
|
||||
};
|
||||
}, [handleDownloadDoubleClick]);
|
||||
|
||||
const handleContextMenu = (menu: { x: number; y: number; id: string }) => {
|
||||
if (!selectedIds.has(menu.id)) {
|
||||
const handleContextMenu = useCallback((menu: { x: number; y: number; id: string }) => {
|
||||
if (!selectedIdsRef.current.has(menu.id)) {
|
||||
setSelectedIds(new Set([menu.id]));
|
||||
setLastSelectedId(menu.id);
|
||||
}
|
||||
setContextMenu(menu);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleSort = (column: string) => {
|
||||
if (filter.startsWith('queue:')) return; // Disable custom sorting in queues
|
||||
setSortConfig(current => {
|
||||
if (current?.column === column) {
|
||||
if (current.direction === 'desc') return null; // Reset sort
|
||||
return { column, direction: 'desc' };
|
||||
}
|
||||
return { column, direction: 'asc' };
|
||||
});
|
||||
const handleMoveInQueue = useCallback((id: string, direction: 'up' | 'down') => {
|
||||
const ids = selectedIdsRef.current.has(id)
|
||||
? Array.from(selectedIdsRef.current)
|
||||
: id;
|
||||
void moveInQueue(ids, direction);
|
||||
}, [moveInQueue]);
|
||||
|
||||
const handleSort = (column: DownloadSortColumn) => {
|
||||
const update = (current: DownloadSortConfig | null): DownloadSortConfig =>
|
||||
current?.column === column
|
||||
? { column, direction: current.direction === 'asc' ? 'desc' : 'asc' }
|
||||
: { column, direction: 'asc' };
|
||||
|
||||
if (isQueueFilter) {
|
||||
setQueueSortConfig(update);
|
||||
} else {
|
||||
setSortConfig(current => update(current));
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -339,7 +362,7 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
|
||||
}
|
||||
};
|
||||
|
||||
const handlePause = async (id: string, skipConfirm = false) => {
|
||||
const handlePause = useCallback(async (id: string, skipConfirm = false) => {
|
||||
const download = useDownloadStore.getState().downloads.find(d => d.id === id);
|
||||
if (!skipConfirm && download && download.resumable === false) {
|
||||
const confirmPause = window.confirm("This download does not support resuming. If you pause it, you will have to start over again later. Are you sure you want to pause?");
|
||||
@@ -354,10 +377,27 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
|
||||
console.error("Failed to pause:", e);
|
||||
showInteractionError('Could not pause download', e);
|
||||
}
|
||||
};
|
||||
}, [showInteractionError]);
|
||||
|
||||
const handleResume = (item: DownloadItem) => {
|
||||
useDownloadStore.getState().resumeDownload(item.id);
|
||||
const handleResume = useCallback(async (item: DownloadItem) => {
|
||||
try {
|
||||
const resumed = await useDownloadStore.getState().resumeDownload(item.id);
|
||||
if (!resumed) {
|
||||
throw new Error('The backend rejected the start/resume request.');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to resume:", error);
|
||||
showInteractionError(`Could not resume ${item.fileName}`, error);
|
||||
}
|
||||
}, [showInteractionError]);
|
||||
|
||||
const resumeItemsSequentially = async (items: DownloadItem[]) => {
|
||||
for (const item of items) {
|
||||
const current = useDownloadStore.getState().downloads.find(download => download.id === item.id);
|
||||
if (current && canStartDownload(current.status)) {
|
||||
await handleResume(current);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = (ids: string | string[]) => {
|
||||
@@ -413,7 +453,7 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
|
||||
};
|
||||
|
||||
|
||||
const getCategoryIcon = (category: string) => {
|
||||
const getCategoryIcon = useCallback((category: string) => {
|
||||
switch(category) {
|
||||
case 'Musics': return <Music size={16} className="text-pink-400" />;
|
||||
case 'Movies': return <Film size={16} className="text-red-400" />;
|
||||
@@ -424,7 +464,7 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
|
||||
case 'Other': return <FileQuestion size={16} className="text-gray-400" />;
|
||||
default: return <FileQuestion size={16} className="text-gray-400" />;
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="downloads-view flex-1 flex flex-col h-full min-w-0">
|
||||
@@ -449,9 +489,7 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
|
||||
className="main-control-button"
|
||||
disabled={sortedDownloads.length === 0}
|
||||
onClick={() => {
|
||||
sortedDownloads
|
||||
.filter(d => canStartDownload(d.status))
|
||||
.forEach(d => handleResume(d));
|
||||
void resumeItemsSequentially(sortedDownloads.filter(d => canStartDownload(d.status)));
|
||||
}}
|
||||
title="Resume All"
|
||||
>
|
||||
@@ -497,13 +535,15 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
|
||||
{['File Name', 'Size', 'Status', 'Speed', 'ETA', 'Date Added'].map((label, index) => (
|
||||
<div
|
||||
key={label}
|
||||
className={`${index === 5 ? 'download-cell-right' : ''} ${filter.startsWith('queue:') ? 'opacity-70 cursor-default' : 'cursor-pointer hover:text-text-primary transition-colors'} flex items-center justify-between`}
|
||||
onClick={() => handleSort(label)}
|
||||
className={`${index === 5 ? 'download-cell-right' : ''} cursor-pointer hover:text-text-primary transition-colors flex items-center justify-between`}
|
||||
onClick={() => handleSort(label as DownloadSortColumn)}
|
||||
>
|
||||
<div className="flex items-center gap-1 w-full h-full select-none">
|
||||
<div className={`flex items-center gap-1 w-full h-full select-none ${index === 1 ? 'justify-end' : ''}`}>
|
||||
<span>{label}</span>
|
||||
{sortConfig?.column === label && (
|
||||
sortConfig.direction === 'asc' ? <ChevronUp size={14} /> : <ChevronDown size={14} />
|
||||
{(isQueueFilter ? queueSortConfig : sortConfig)?.column === label && (
|
||||
(isQueueFilter ? queueSortConfig : sortConfig)?.direction === 'asc'
|
||||
? <ChevronUp size={14} />
|
||||
: <ChevronDown size={14} />
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
@@ -519,19 +559,29 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
|
||||
{sortedDownloads.length === 0 ? (
|
||||
<div className="downloads-empty-state">
|
||||
<ArrowDownCircle aria-hidden="true" />
|
||||
<div className="downloads-empty-title">No Downloads</div>
|
||||
<div className="downloads-empty-title">
|
||||
{isQueueFilter ? 'Queue is empty' : filter === 'completed' ? 'No Completed Downloads' : 'No Downloads'}
|
||||
</div>
|
||||
<div className="downloads-empty-description flex items-center justify-center mt-2.5 text-[13px] text-text-muted">
|
||||
Click <Plus size={15} className="text-accent stroke-[3] mx-1.5" /> button or
|
||||
<span className="flex items-center mx-1.5">
|
||||
<span className="flex items-center justify-center px-1.5 py-0.5 bg-item-hover rounded border border-border-color shadow-sm min-w-[22px] min-h-[22px]">
|
||||
{isMac ? <Command size={12} strokeWidth={2.5} className="text-text-primary" /> : <span className="text-[10px] font-bold text-text-primary">Ctrl</span>}
|
||||
</span>
|
||||
<span className="text-accent font-bold mx-1.5 text-[14px]">+</span>
|
||||
<span className="flex items-center justify-center px-1.5 py-0.5 bg-item-hover rounded border border-border-color shadow-sm min-w-[22px] min-h-[22px]">
|
||||
<span className="text-[11px] font-bold text-text-primary">V</span>
|
||||
</span>
|
||||
</span>
|
||||
to add downloads
|
||||
{isQueueFilter ? (
|
||||
'Add downloads to this queue from an item menu or the Add window.'
|
||||
) : filter === 'completed' ? (
|
||||
'Completed downloads will appear here.'
|
||||
) : (
|
||||
<>
|
||||
Click <Plus size={15} className="text-accent stroke-[3] mx-1.5" /> button or
|
||||
<span className="flex items-center mx-1.5">
|
||||
<span className="flex items-center justify-center px-1.5 py-0.5 bg-item-hover rounded border border-border-color shadow-sm min-w-[22px] min-h-[22px]">
|
||||
{isMac ? <Command size={12} strokeWidth={2.5} className="text-text-primary" /> : <span className="text-[10px] font-bold text-text-primary">Ctrl</span>}
|
||||
</span>
|
||||
<span className="text-accent font-bold mx-1.5 text-[14px]">+</span>
|
||||
<span className="flex items-center justify-center px-1.5 py-0.5 bg-item-hover rounded border border-border-color shadow-sm min-w-[22px] min-h-[22px]">
|
||||
<span className="text-[11px] font-bold text-text-primary">V</span>
|
||||
</span>
|
||||
</span>
|
||||
to add downloads
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
@@ -539,14 +589,17 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
|
||||
{sortedDownloads.map((d, index) => (
|
||||
<DownloadItemComponent
|
||||
key={d.id}
|
||||
downloadId={d.id}
|
||||
download={d}
|
||||
index={index}
|
||||
queueIndex={queuePositionsByDownloadId.get(d.id)?.index ?? -1}
|
||||
queueLength={queuePositionsByDownloadId.get(d.id)?.length ?? 0}
|
||||
tableGridTemplate={tableGridTemplate}
|
||||
setContextMenu={handleContextMenu}
|
||||
handlePause={handlePause}
|
||||
handleResume={handleResume}
|
||||
getCategoryIcon={getCategoryIcon}
|
||||
selectedIds={selectedIds}
|
||||
isSelected={selectedIds.has(d.id)}
|
||||
onMoveInQueue={handleMoveInQueue}
|
||||
onClick={handleItemClick}
|
||||
/>
|
||||
))}
|
||||
@@ -576,27 +629,17 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
|
||||
|
||||
const itemsToResume = selectedDownloads.filter(d => canStartDownload(d.status));
|
||||
const itemsToPause = selectedDownloads.filter(d => canPauseDownload(d.status));
|
||||
const itemsToQueue = selectedDownloads.filter(d => d.status !== 'completed');
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Multi-Select Context Menu */}
|
||||
{itemsToResume.length > 0 && (
|
||||
<button
|
||||
onClick={() => {
|
||||
setContextMenu(null);
|
||||
if (itemsToResume.length > 0) {
|
||||
const activeQueueIds = Array.from(new Set(itemsToResume.map(i => i.queueId).filter(Boolean)));
|
||||
// We can use assignToQueue for bulk resume to the same queue
|
||||
if (activeQueueIds.length === 1 && activeQueueIds[0]) {
|
||||
void assignToQueue(itemsToResume.map(i => i.id), activeQueueIds[0]).catch(error => {
|
||||
showInteractionError('Could not bulk resume downloads', error);
|
||||
});
|
||||
} else {
|
||||
// Fallback for missing queue or multiple queues
|
||||
itemsToResume.forEach(handleResume);
|
||||
}
|
||||
}
|
||||
}}
|
||||
onClick={() => {
|
||||
setContextMenu(null);
|
||||
void resumeItemsSequentially(itemsToResume);
|
||||
}}
|
||||
className="w-full text-left px-3 py-2 hover:bg-item-hover transition-colors"
|
||||
>
|
||||
Start/Resume
|
||||
@@ -630,24 +673,26 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
|
||||
<div className="h-[1px] bg-border-modal/60 my-1.5 mx-2"></div>
|
||||
)}
|
||||
|
||||
<div className="group relative">
|
||||
<button className="w-full text-left px-3 py-2 hover:bg-item-hover transition-colors flex justify-between items-center">
|
||||
Add to Queue
|
||||
<ChevronRight size={14} />
|
||||
</button>
|
||||
<div className="absolute left-full top-0 hidden group-hover:block ml-1 min-w-[150px] bg-bg-modal border border-border-modal rounded-lg shadow-lg py-1.5 z-50">
|
||||
{queues.map(q => (
|
||||
<button key={q.id} onClick={() => {
|
||||
setContextMenu(null);
|
||||
void assignToQueue(Array.from(selectedIds), q.id).catch(error => {
|
||||
showInteractionError('Could not move downloads to queue', error);
|
||||
});
|
||||
}} className="w-full text-left px-3 py-2 hover:bg-item-hover transition-colors text-[12px]">
|
||||
{q.name}
|
||||
</button>
|
||||
))}
|
||||
{itemsToQueue.length > 0 && (
|
||||
<div className="group relative">
|
||||
<button className="w-full text-left px-3 py-2 hover:bg-item-hover transition-colors flex justify-between items-center">
|
||||
Add to Queue
|
||||
<ChevronRight size={14} />
|
||||
</button>
|
||||
<div className="absolute left-full top-0 hidden group-hover:block ml-1 min-w-[150px] bg-bg-modal border border-border-modal rounded-lg shadow-lg py-1.5 z-50">
|
||||
{queues.map(q => (
|
||||
<button key={q.id} onClick={() => {
|
||||
setContextMenu(null);
|
||||
void assignToQueue(itemsToQueue.map(item => item.id), q.id).catch(error => {
|
||||
showInteractionError('Could not move downloads to queue', error);
|
||||
});
|
||||
}} className="w-full text-left px-3 py-2 hover:bg-item-hover transition-colors text-[12px]">
|
||||
{q.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="h-[1px] bg-border-modal/60 my-1.5 mx-2"></div>
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
export type DuplicateReason = { type: 'url', msg: string } | { type: 'file', msg: string };
|
||||
type DuplicateResolution = 'rename' | 'replace' | 'skip';
|
||||
@@ -20,12 +20,27 @@ interface Props {
|
||||
export const DuplicateResolutionModal = ({ conflicts: initialConflicts, onConfirm, onCancel }: Props) => {
|
||||
const [conflicts, setConflicts] = useState<DuplicateConflict[]>(initialConflicts);
|
||||
|
||||
useEffect(() => {
|
||||
const handleEscape = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') onCancel();
|
||||
};
|
||||
window.addEventListener('keydown', handleEscape);
|
||||
return () => window.removeEventListener('keydown', handleEscape);
|
||||
}, [onCancel]);
|
||||
|
||||
const updateResolution = (id: string, resolution: DuplicateResolution) => {
|
||||
setConflicts(conflicts.map(c => c.id === id ? { ...c, resolution } : c));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="app-modal-backdrop fixed inset-0 z-[60] flex items-center justify-center">
|
||||
<div
|
||||
className="app-modal-backdrop fixed inset-0 z-[60] flex items-center justify-center"
|
||||
onClick={(event) => {
|
||||
if (event.target === event.currentTarget) onCancel();
|
||||
}}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
>
|
||||
<div className="app-modal w-[500px] flex flex-col overflow-hidden text-sm">
|
||||
<div className="p-4 border-b border-border-modal flex flex-col gap-2">
|
||||
<h2 className="text-lg font-semibold text-text-primary">Duplicate Downloads Detected</h2>
|
||||
@@ -53,7 +68,7 @@ export const DuplicateResolutionModal = ({ conflicts: initialConflicts, onConfir
|
||||
</div>
|
||||
|
||||
<div className="p-4 border-t border-border-modal flex items-center justify-between bg-sidebar-bg/50">
|
||||
<button onClick={onCancel} className="app-button border-transparent bg-transparent px-4 text-xs text-text-secondary">
|
||||
<button onClick={onCancel} className="app-button px-4 text-xs">
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
|
||||
@@ -1,62 +1,121 @@
|
||||
import React, { useState } from 'react';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useSettingsStore } from '../store/useSettingsStore';
|
||||
import { invokeCommand as invoke } from '../ipc';
|
||||
import { KeyRound, ShieldAlert } from 'lucide-react';
|
||||
import { usePlatformInfo } from '../utils/platform';
|
||||
import { getKeychainConsentVersion } from '../utils/keychainStartup';
|
||||
import { getVersion } from '@tauri-apps/api/app';
|
||||
import type { PairingTokenHydration } from '../bindings/PairingTokenHydration';
|
||||
|
||||
export const KeychainPermissionModal: React.FC = () => {
|
||||
const KEYCHAIN_GRANT_TIMEOUT_MS = 30_000;
|
||||
|
||||
type KeychainPermissionModalProps = {
|
||||
consentVersion: string;
|
||||
};
|
||||
|
||||
export const KeychainPermissionModal: React.FC<KeychainPermissionModalProps> = ({ consentVersion }) => {
|
||||
const showKeychainModal = useSettingsStore(state => state.showKeychainModal);
|
||||
const setShowKeychainModal = useSettingsStore(state => state.setShowKeychainModal);
|
||||
const dismissKeychainPrompt = useSettingsStore(state => state.dismissKeychainPrompt);
|
||||
const platform = usePlatformInfo();
|
||||
const [isGranting, setIsGranting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!showKeychainModal || isGranting) return;
|
||||
const handleEscape = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') dismissKeychainPrompt(consentVersion);
|
||||
};
|
||||
window.addEventListener('keydown', handleEscape);
|
||||
return () => window.removeEventListener('keydown', handleEscape);
|
||||
}, [consentVersion, dismissKeychainPrompt, isGranting, showKeychainModal]);
|
||||
|
||||
if (!showKeychainModal) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const isMac = platform.os === 'macos';
|
||||
const storeName =
|
||||
platform.os === 'windows'
|
||||
const pairingStoreName =
|
||||
platform.portable
|
||||
? 'the portable Firelink data folder'
|
||||
: platform.os === 'windows'
|
||||
? 'Windows Credential Manager'
|
||||
: platform.os === 'linux'
|
||||
? 'your Linux credential store'
|
||||
: platform.os === 'macos'
|
||||
? 'macOS Keychain'
|
||||
: "this system's credential store";
|
||||
const grantLabel = isMac ? 'Grant Access' : 'Enable Secure Storage';
|
||||
const siteCredentialStoreName = platform.portable
|
||||
? "the system's credential store"
|
||||
: pairingStoreName;
|
||||
const grantLabel = platform.portable
|
||||
? 'Continue'
|
||||
: isMac
|
||||
? 'Grant Access'
|
||||
: 'Enable Secure Storage';
|
||||
|
||||
const handleGrant = async () => {
|
||||
setIsGranting(true);
|
||||
setError(null);
|
||||
|
||||
let timeoutId: number | undefined;
|
||||
let persistentGrantApplied = false;
|
||||
const applyPersistentGrant = async (result: PairingTokenHydration): Promise<boolean> => {
|
||||
if (!result.persistent || persistentGrantApplied) return result.persistent;
|
||||
persistentGrantApplied = true;
|
||||
const grantedVersion = consentVersion || getKeychainConsentVersion(await getVersion().catch(() => ''));
|
||||
// Keep state in sync with the grant result instead of rehydrating
|
||||
// before Zustand has persisted keychainAccessGranted.
|
||||
useSettingsStore.setState({
|
||||
keychainAccessGranted: true,
|
||||
keychainAccessVersion: grantedVersion,
|
||||
keychainAccessReady: true,
|
||||
extensionPairingToken: result.token,
|
||||
isPairingTokenPersistent: true,
|
||||
keychainPromptDismissed: false,
|
||||
showKeychainModal: false
|
||||
});
|
||||
return true;
|
||||
};
|
||||
const grantRequest = invoke('grant_keychain_access');
|
||||
// A native credential-store call cannot be cancelled by the webview. Keep
|
||||
// a late successful result useful even if the UI timeout has already
|
||||
// restored the Later/retry controls.
|
||||
grantRequest.then(applyPersistentGrant).catch(() => undefined);
|
||||
|
||||
try {
|
||||
const result = await invoke('grant_keychain_access');
|
||||
if (result.persistent) {
|
||||
// Keep state in sync with the grant result instead of rehydrating
|
||||
// before Zustand has persisted keychainAccessGranted.
|
||||
useSettingsStore.setState({
|
||||
keychainAccessGranted: true,
|
||||
extensionPairingToken: result.token,
|
||||
isPairingTokenPersistent: true
|
||||
});
|
||||
setShowKeychainModal(false);
|
||||
} else {
|
||||
setError(result.error || `${storeName} is unavailable.`);
|
||||
const result = await Promise.race([
|
||||
grantRequest,
|
||||
new Promise<never>((_, reject) => {
|
||||
timeoutId = window.setTimeout(
|
||||
() => reject(new Error('Credential storage request timed out. You can select Later and try again.')),
|
||||
KEYCHAIN_GRANT_TIMEOUT_MS
|
||||
);
|
||||
})
|
||||
]);
|
||||
if (!(await applyPersistentGrant(result))) {
|
||||
setError(result.error || `${siteCredentialStoreName} is unavailable.`);
|
||||
}
|
||||
} catch (e: any) {
|
||||
setError(e.toString());
|
||||
} finally {
|
||||
if (timeoutId !== undefined) window.clearTimeout(timeoutId);
|
||||
setIsGranting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleLater = () => {
|
||||
setShowKeychainModal(false);
|
||||
dismissKeychainPrompt(consentVersion);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="app-modal-backdrop fixed inset-0 z-50 flex items-center justify-center bg-black/40">
|
||||
<div
|
||||
className="app-modal-backdrop fixed inset-0 z-50 flex items-center justify-center bg-black/40"
|
||||
onClick={(event) => {
|
||||
if (event.target === event.currentTarget && !isGranting) handleLater();
|
||||
}}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
>
|
||||
<div
|
||||
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()}
|
||||
@@ -71,18 +130,22 @@ export const KeychainPermissionModal: React.FC = () => {
|
||||
<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}.
|
||||
Firelink stores its pairing token in {pairingStoreName}. Optional site credentials are stored in {siteCredentialStoreName}.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
{isMac
|
||||
{platform.portable
|
||||
? 'The pairing token is portable with this folder. Site credentials remain in the system credential store; a system prompt may appear after you grant access.'
|
||||
: isMac
|
||||
? 'macOS may show a Keychain prompt after you grant access.'
|
||||
: 'This usually completes silently. If the credential service is unavailable, Firelink will show the error here and the extension will stay paired for this session only.'}
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<strong>Note:</strong> Firelink only writes its own dedicated credential entry. It cannot access other
|
||||
saved passwords or credential items on your system.
|
||||
<strong>Note:</strong>{' '}
|
||||
{platform.portable
|
||||
? 'Portable mode stores only the pairing token in this folder. It does not copy site passwords or browser credentials.'
|
||||
: 'Firelink only writes its own dedicated credential entry. It cannot access other saved passwords or credential items on your system.'}
|
||||
</p>
|
||||
|
||||
{error && (
|
||||
@@ -93,8 +156,11 @@ export const KeychainPermissionModal: React.FC = () => {
|
||||
)}
|
||||
|
||||
<div className="bg-bg-modal-accent p-3 rounded-lg border border-border-modal text-xs">
|
||||
<strong>Hint:</strong> If you select Later, the extension will only work for this session.
|
||||
You can enable secure storage anytime from <strong>Settings > Integrations</strong>.
|
||||
<strong>Hint:</strong>{' '}
|
||||
{platform.portable
|
||||
? 'The portable pairing token is already stored with this folder; you can enable it here or select Later.'
|
||||
: 'If you select Later, the extension will only work for this session.'}
|
||||
You can enable storage anytime from <strong>Settings > Integrations</strong>.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
+89
-25
@@ -1,8 +1,8 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { invokeCommand as invoke } from '../ipc';
|
||||
import { save } from '@tauri-apps/plugin-dialog';
|
||||
import { writeTextFile } from '@tauri-apps/plugin-fs';
|
||||
import { attachLogger, setLogPaused, initLogger } from '../utils/logger';
|
||||
import { homeDir } from '@tauri-apps/api/path';
|
||||
import { attachLogger, setLogPaused, initLogger, setLogStreamActive } from '../utils/logger';
|
||||
import { FileDown, Trash2, Terminal, Filter, Play, Pause, Info, Copy } from 'lucide-react';
|
||||
import { WindowDragRegion } from './WindowDragRegion';
|
||||
import { useToast } from '../contexts/ToastContext';
|
||||
@@ -25,9 +25,27 @@ export default function LogsView() {
|
||||
const [levelFilter, setLevelFilter] = useState<LogEntry['level'] | 'All'>('All');
|
||||
const [contextMenu, setContextMenu] = useState<{ x: number; y: number; text: string } | null>(null);
|
||||
const [pageVisible, setPageVisible] = useState(() => document.visibilityState !== 'hidden');
|
||||
const homeDirectoryRef = useRef('');
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const liveBatchRef = useRef<LogEntry[]>([]);
|
||||
const liveFrameRef = useRef<number | null>(null);
|
||||
const clearGenerationRef = useRef(0);
|
||||
const clearInFlightRef = useRef<Promise<void> | null>(null);
|
||||
const toggleInFlightRef = useRef<Promise<void> | null>(null);
|
||||
const [isClearing, setIsClearing] = useState(false);
|
||||
const [isToggling, setIsToggling] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
void homeDir()
|
||||
.then(directory => {
|
||||
if (active) homeDirectoryRef.current = directory;
|
||||
})
|
||||
.catch(() => undefined);
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const handleVisibilityChange = () => setPageVisible(document.visibilityState !== 'hidden');
|
||||
@@ -37,16 +55,17 @@ export default function LogsView() {
|
||||
|
||||
useEffect(() => {
|
||||
if (!pageVisible) {
|
||||
void invoke('set_log_stream_active', { active: false }).catch(console.error);
|
||||
void setLogStreamActive(false).catch(console.error);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!logsEnabled) {
|
||||
void invoke('set_log_stream_active', { active: false }).catch(console.error);
|
||||
void setLogStreamActive(false).catch(console.error);
|
||||
}
|
||||
|
||||
let active = true;
|
||||
let initialized = false;
|
||||
const initGeneration = clearGenerationRef.current;
|
||||
let pendingLiveEntries: LogEntry[] = [];
|
||||
let unlistenPromise: Promise<() => void> | undefined;
|
||||
|
||||
@@ -75,21 +94,25 @@ export default function LogsView() {
|
||||
if (logsEnabled) {
|
||||
unlistenPromise = attachLogger((log) => {
|
||||
if (!active) return;
|
||||
scheduleLiveEntry(liveLogEntry(log.level, log.message));
|
||||
scheduleLiveEntry(liveLogEntry(log.level, log.message, new Date(), homeDirectoryRef.current));
|
||||
});
|
||||
await unlistenPromise;
|
||||
if (!active) return;
|
||||
await invoke('set_log_stream_active', { active: true });
|
||||
await setLogStreamActive(true);
|
||||
if (!active) {
|
||||
await invoke('set_log_stream_active', { active: false }).catch(console.error);
|
||||
await setLogStreamActive(false).catch(console.error);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const lines = await invoke('read_logs', { limit: MAX_LOG_LINES });
|
||||
if (!active) return;
|
||||
const snapshot = lines.map(persistedLogEntry);
|
||||
const snapshot = lines.map(line => persistedLogEntry(line, homeDirectoryRef.current));
|
||||
initialized = true;
|
||||
if (initGeneration !== clearGenerationRef.current) {
|
||||
pendingLiveEntries = [];
|
||||
return;
|
||||
}
|
||||
const caughtUpLogs = mergeLogSnapshotAndLiveEntries(snapshot, pendingLiveEntries);
|
||||
pendingLiveEntries = [];
|
||||
setLogs(caughtUpLogs);
|
||||
@@ -107,7 +130,7 @@ export default function LogsView() {
|
||||
liveFrameRef.current = null;
|
||||
}
|
||||
if (logsEnabled) {
|
||||
void invoke('set_log_stream_active', { active: false }).catch(console.error);
|
||||
void setLogStreamActive(false).catch(console.error);
|
||||
}
|
||||
if (unlistenPromise) {
|
||||
void unlistenPromise.then(unlisten => unlisten()).catch(console.error);
|
||||
@@ -162,8 +185,7 @@ export default function LogsView() {
|
||||
filters: [{ name: 'Log Files', extensions: ['log'] }],
|
||||
});
|
||||
if (!path) return;
|
||||
const logsContent = await invoke('export_logs', {});
|
||||
await writeTextFile(path, logsContent);
|
||||
await invoke('export_logs', { destination: path });
|
||||
addToast({ message: 'Support logs exported', variant: 'success' });
|
||||
} catch (e) {
|
||||
console.error('Export failed:', e);
|
||||
@@ -172,23 +194,63 @@ export default function LogsView() {
|
||||
};
|
||||
|
||||
const handleClear = async () => {
|
||||
liveBatchRef.current = [];
|
||||
if (liveFrameRef.current !== null) {
|
||||
window.cancelAnimationFrame(liveFrameRef.current);
|
||||
liveFrameRef.current = null;
|
||||
if (clearInFlightRef.current) return;
|
||||
|
||||
const clearOperation = invoke('clear_logs');
|
||||
clearInFlightRef.current = clearOperation;
|
||||
setIsClearing(true);
|
||||
try {
|
||||
await clearOperation;
|
||||
clearGenerationRef.current += 1;
|
||||
liveBatchRef.current = [];
|
||||
if (liveFrameRef.current !== null) {
|
||||
window.cancelAnimationFrame(liveFrameRef.current);
|
||||
liveFrameRef.current = null;
|
||||
}
|
||||
setLogs([]);
|
||||
addToast({ message: 'Logs cleared', variant: 'info' });
|
||||
} catch (error) {
|
||||
addToast({
|
||||
message: `Could not clear logs: ${String(error)}`,
|
||||
variant: 'error',
|
||||
isActionable: true
|
||||
});
|
||||
} finally {
|
||||
if (clearInFlightRef.current === clearOperation) {
|
||||
clearInFlightRef.current = null;
|
||||
}
|
||||
setIsClearing(false);
|
||||
}
|
||||
setLogs([]);
|
||||
await invoke('clear_logs').catch(console.error);
|
||||
};
|
||||
|
||||
const handleToggleLogging = async () => {
|
||||
if (toggleInFlightRef.current) return;
|
||||
|
||||
const nextEnabled = !logsEnabled;
|
||||
setLogsEnabled(nextEnabled);
|
||||
await setLogPaused(!nextEnabled);
|
||||
addToast({
|
||||
message: nextEnabled ? 'Diagnostic logging enabled' : 'Diagnostic logging disabled',
|
||||
variant: 'success'
|
||||
});
|
||||
const toggleOperation = (async () => {
|
||||
await setLogPaused(!nextEnabled);
|
||||
setLogsEnabled(nextEnabled);
|
||||
addToast({
|
||||
message: nextEnabled ? 'Diagnostic logging enabled' : 'Diagnostic logging disabled',
|
||||
variant: 'success'
|
||||
});
|
||||
})();
|
||||
toggleInFlightRef.current = toggleOperation;
|
||||
setIsToggling(true);
|
||||
try {
|
||||
await toggleOperation;
|
||||
} catch (error) {
|
||||
addToast({
|
||||
message: `Could not update diagnostic logging: ${String(error)}`,
|
||||
variant: 'error',
|
||||
isActionable: true
|
||||
});
|
||||
} finally {
|
||||
if (toggleInFlightRef.current === toggleOperation) {
|
||||
toggleInFlightRef.current = null;
|
||||
}
|
||||
setIsToggling(false);
|
||||
}
|
||||
};
|
||||
|
||||
const severityClass = (level: string) => {
|
||||
@@ -235,14 +297,16 @@ export default function LogsView() {
|
||||
<div className="w-[1px] h-4 bg-border-modal mx-0.5" />
|
||||
<button
|
||||
onClick={handleToggleLogging}
|
||||
className={`app-icon-button ${logsEnabled ? 'text-accent' : ''}`}
|
||||
disabled={isToggling}
|
||||
className={`app-icon-button disabled:cursor-not-allowed disabled:opacity-50 ${logsEnabled ? 'text-accent' : ''}`}
|
||||
title={logsEnabled ? "Pause diagnostic logging" : "Enable diagnostic logging"}
|
||||
>
|
||||
{logsEnabled ? <Pause size={14} /> : <Play size={14} />}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleClear}
|
||||
className="app-icon-button"
|
||||
disabled={isClearing}
|
||||
className="app-icon-button disabled:cursor-not-allowed disabled:opacity-50"
|
||||
title="Clear displayed logs"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useDownloadStore, DownloadItem } from '../store/useDownloadStore';
|
||||
import { useDownloadProgressStore } from '../store/downloadStore';
|
||||
import { useDownloadProgressStore } from '../store/downloadProgressStore';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
import { useSettingsStore } from '../store/useSettingsStore';
|
||||
import { ChevronDown, ChevronRight, FolderPlus, Info, CheckCircle, AlertCircle, Play, Pause } from 'lucide-react';
|
||||
@@ -10,9 +10,23 @@ import {
|
||||
isIdentityLocked as getIdentityLocked,
|
||||
isTransferLocked as getTransferLocked
|
||||
} from '../utils/downloadActions';
|
||||
import {
|
||||
downloadProgressColorClass,
|
||||
formatDownloadTotal,
|
||||
resolveDownloadSizeDisplay
|
||||
} from '../utils/downloadProgress';
|
||||
import { resolveDownloadConnections } from '../utils/downloads';
|
||||
|
||||
type LoginMode = 'matching' | 'custom' | 'none';
|
||||
|
||||
const formatLastTry = (value?: string): string => {
|
||||
if (!value) return '-';
|
||||
const date = new Date(value);
|
||||
return Number.isNaN(date.getTime())
|
||||
? '-'
|
||||
: date.toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short' });
|
||||
};
|
||||
|
||||
export const PropertiesModal = () => {
|
||||
const selectedPropertiesDownloadId = useDownloadStore(state => state.selectedPropertiesDownloadId);
|
||||
const setSelectedPropertiesDownloadId = useDownloadStore(state => state.setSelectedPropertiesDownloadId);
|
||||
@@ -33,7 +47,8 @@ export const PropertiesModal = () => {
|
||||
const [url, setUrl] = useState('');
|
||||
const [fileName, setFileName] = useState('');
|
||||
const [saveLocation, setSaveLocation] = useState('');
|
||||
const [connections, setConnections] = useState(16);
|
||||
const [connections, setConnections] = useState(() => resolveDownloadConnections(undefined, perServerConnections));
|
||||
const [connectionsDirty, setConnectionsDirty] = useState(false);
|
||||
|
||||
const [speedLimitEnabled, setSpeedLimitEnabled] = useState(false);
|
||||
const [speedLimitValue, setSpeedLimitValue] = useState('1024'); // KiB/s
|
||||
@@ -66,7 +81,8 @@ export const PropertiesModal = () => {
|
||||
activeItem.category
|
||||
).then(setSaveLocation);
|
||||
}
|
||||
setConnections(activeItem.connections || 16);
|
||||
setConnections(resolveDownloadConnections(activeItem.connections, perServerConnections));
|
||||
setConnectionsDirty(false);
|
||||
|
||||
if (activeItem.speedLimit) {
|
||||
setSpeedLimitEnabled(true);
|
||||
@@ -104,7 +120,24 @@ export const PropertiesModal = () => {
|
||||
setSelectedPropertiesDownloadId(null);
|
||||
}
|
||||
}
|
||||
}, [selectedPropertiesDownloadId, baseDownloadFolder, setSelectedPropertiesDownloadId]);
|
||||
}, [selectedPropertiesDownloadId, setSelectedPropertiesDownloadId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedPropertiesDownloadId || connectionsDirty) return;
|
||||
const activeItem = useDownloadStore.getState().downloads.find(d => d.id === selectedPropertiesDownloadId);
|
||||
if (activeItem && activeItem.connections === undefined) {
|
||||
setConnections(resolveDownloadConnections(undefined, perServerConnections));
|
||||
}
|
||||
}, [selectedPropertiesDownloadId, perServerConnections, connectionsDirty]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedPropertiesDownloadId) return;
|
||||
const handleEscape = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') setSelectedPropertiesDownloadId(null);
|
||||
};
|
||||
window.addEventListener('keydown', handleEscape);
|
||||
return () => window.removeEventListener('keydown', handleEscape);
|
||||
}, [selectedPropertiesDownloadId, setSelectedPropertiesDownloadId]);
|
||||
|
||||
if (!selectedPropertiesDownloadId || !item) return null;
|
||||
|
||||
@@ -138,7 +171,6 @@ export const PropertiesModal = () => {
|
||||
url,
|
||||
fileName,
|
||||
destination: saveLocation,
|
||||
connections: Number(connections),
|
||||
speedLimit: speedLimitEnabled && speedLimitValue ? `${speedLimitValue}K` : undefined,
|
||||
username: loginMode === 'custom' ? username.trim() : undefined,
|
||||
password: loginMode === 'custom' ? password.trim() : undefined,
|
||||
@@ -146,6 +178,9 @@ export const PropertiesModal = () => {
|
||||
checksum: checksumEnabled && checksumValue.trim() ? `${checksumAlgorithm}=${checksumValue.trim()}` : undefined,
|
||||
cookies: cookies.trim() || undefined,
|
||||
mirrors: mirrors.trim() || undefined,
|
||||
...(connectionsDirty
|
||||
? { connections: resolveDownloadConnections(connections, perServerConnections) }
|
||||
: {}),
|
||||
};
|
||||
|
||||
try {
|
||||
@@ -168,6 +203,17 @@ export const PropertiesModal = () => {
|
||||
const displayedEta = item.status === 'completed'
|
||||
? '-'
|
||||
: liveProgress?.eta ?? item.eta ?? '-';
|
||||
const sizeDisplay = resolveDownloadSizeDisplay({
|
||||
downloadedBytes: liveProgress?.downloaded_bytes ?? item.downloadedBytes,
|
||||
totalBytes: liveProgress?.total_bytes ?? item.totalBytes,
|
||||
totalIsEstimate: liveProgress?.total_is_estimate ?? item.totalIsEstimate,
|
||||
fallbackSize: item.size
|
||||
});
|
||||
const hasDownloadedAmount = item.status !== 'completed' &&
|
||||
Boolean(sizeDisplay.downloaded && sizeDisplay.total);
|
||||
const completedSizeLabel = item.status === 'completed'
|
||||
? formatDownloadTotal(sizeDisplay)
|
||||
: sizeDisplay.fallback;
|
||||
|
||||
let statusColor = 'text-text-secondary';
|
||||
let StatusIcon = Info;
|
||||
@@ -178,7 +224,14 @@ export const PropertiesModal = () => {
|
||||
else if (item.status === 'failed') { statusColor = 'text-red-500'; StatusIcon = AlertCircle; }
|
||||
|
||||
return (
|
||||
<div className="app-modal-backdrop fixed inset-0 z-50 flex items-center justify-center">
|
||||
<div
|
||||
className="app-modal-backdrop fixed inset-0 z-50 flex items-center justify-center"
|
||||
onClick={(event) => {
|
||||
if (event.target === event.currentTarget) setSelectedPropertiesDownloadId(null);
|
||||
}}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
>
|
||||
<div className="app-modal w-[720px] h-[580px] flex flex-col overflow-hidden text-sm">
|
||||
|
||||
{/* Header Summary */}
|
||||
@@ -197,14 +250,32 @@ export const PropertiesModal = () => {
|
||||
|
||||
<div className="grid grid-cols-4 gap-y-2 gap-x-4 text-[11px] leading-tight">
|
||||
<div className="flex gap-1.5 min-w-0"><span className="text-text-muted font-medium w-[90px] shrink-0">Progress</span><span className="text-text-secondary truncate">{`${(displayedFraction * 100).toFixed(0)}%`}</span></div>
|
||||
<div className="flex gap-1.5 min-w-0"><span className="text-text-muted font-medium w-[40px] shrink-0">Size</span><span className="text-text-secondary truncate">{item.size || '-'}</span></div>
|
||||
<div className="flex gap-1.5 min-w-0">
|
||||
<span className="text-text-muted font-medium w-[40px] shrink-0">Size</span>
|
||||
<span
|
||||
className="truncate"
|
||||
title={hasDownloadedAmount
|
||||
? `${sizeDisplay.downloaded} downloaded of ${sizeDisplay.totalIsEstimate ? 'approximately ' : ''}${sizeDisplay.total} ${sizeDisplay.unit}`
|
||||
: completedSizeLabel}
|
||||
>
|
||||
{hasDownloadedAmount ? (
|
||||
<>
|
||||
<span className={downloadProgressColorClass(item.status)}>{sizeDisplay.downloaded}</span>
|
||||
<span className="text-text-muted"> / </span>
|
||||
<span className="text-text-secondary">
|
||||
{sizeDisplay.totalIsEstimate ? '~' : ''}{sizeDisplay.total} {sizeDisplay.unit}
|
||||
</span>
|
||||
</>
|
||||
) : completedSizeLabel}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex gap-1.5 min-w-0"><span className="text-text-muted font-medium w-[40px] shrink-0">Speed</span><span className="text-text-secondary truncate">{displayedSpeed}</span></div>
|
||||
<div className="flex gap-1.5 min-w-0"><span className="text-text-muted font-medium w-[30px] shrink-0">ETA</span><span className="text-text-secondary truncate">{displayedEta}</span></div>
|
||||
|
||||
<div className="flex gap-1.5 min-w-0"><span className="text-text-muted font-medium w-[90px] shrink-0">Connections</span><span className="text-text-secondary truncate">{item.connections || perServerConnections || '-'}</span></div>
|
||||
<div className="flex gap-1.5 min-w-0"><span className="text-text-muted font-medium w-[90px] shrink-0">Connections</span><span className="text-text-secondary truncate" title={item.connections !== undefined ? 'Saved for this download; Settings changes apply to new downloads.' : 'Using the current default for new downloads.'}>{resolveDownloadConnections(item.connections, perServerConnections)}{item.connections !== undefined ? ' (saved)' : ' (default)'}</span></div>
|
||||
<div className="flex gap-1.5 min-w-0"><span className="text-text-muted font-medium w-[60px] shrink-0">Speed cap</span><span className="text-text-secondary truncate">{item.speedLimit || '-'}</span></div>
|
||||
<div className="flex gap-1.5 min-w-0"><span className="text-text-muted font-medium w-[55px] shrink-0">Category</span><span className="text-text-secondary truncate">{item.category}</span></div>
|
||||
<div className="flex gap-1.5"><span className="text-text-muted font-medium w-[50px]">Last try</span><span className="text-text-secondary truncate">-</span></div>
|
||||
<div className="flex gap-1.5"><span className="text-text-muted font-medium w-[50px]">Last try</span><span className="text-text-secondary truncate">{formatLastTry(item.lastTry)}</span></div>
|
||||
|
||||
<div className="flex gap-1.5 col-span-2"><span className="text-text-muted font-medium w-[90px]">Date added</span><span className="text-text-secondary truncate">{new Date(item.dateAdded).toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short' })}</span></div>
|
||||
<div className="flex gap-1.5 col-span-2"><span className="text-text-muted font-medium w-[70px]">Destination</span><span className="text-text-secondary truncate" title={saveLocation}>{saveLocation || baseDownloadFolder}</span></div>
|
||||
@@ -253,8 +324,20 @@ export const PropertiesModal = () => {
|
||||
|
||||
<label className="text-xs text-text-muted text-right">Connections</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input type="number" value={connections} min={1} max={16} onChange={e=>setConnections(Number(e.target.value))} disabled={transferLocked} className="w-16 bg-bg-input border border-border-modal rounded-lg px-2.5 py-1.5 text-xs text-text-primary focus:outline-none focus:border-accent disabled:opacity-50" />
|
||||
<input type="number" value={connections} min={1} max={16} onChange={e=>{ setConnections(Number(e.target.value)); setConnectionsDirty(true); }} disabled={transferLocked} className="w-16 bg-bg-input border border-border-modal rounded-lg px-2.5 py-1.5 text-xs text-text-primary focus:outline-none focus:border-accent disabled:opacity-50" />
|
||||
<span className="text-xs text-text-muted">per file</span>
|
||||
{!transferLocked && item.connections !== undefined && item.connections !== perServerConnections && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setConnections(perServerConnections); setConnectionsDirty(true); }}
|
||||
className="text-[11px] text-accent hover:underline whitespace-nowrap"
|
||||
>
|
||||
Use current default ({perServerConnections})
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="col-start-2 text-[11px] text-text-muted">
|
||||
Saved per download. The Settings default applies to new downloads.
|
||||
</div>
|
||||
|
||||
<label className="text-xs text-text-muted text-right">Speed</label>
|
||||
@@ -371,7 +454,7 @@ export const PropertiesModal = () => {
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => setSelectedPropertiesDownloadId(null)}
|
||||
className="app-button border-transparent bg-transparent px-4 text-xs text-text-secondary"
|
||||
className="app-button px-4 text-xs"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
} from 'lucide-react';
|
||||
import { PostQueueAction, SchedulerSettings, useSettingsStore } from '../store/useSettingsStore';
|
||||
import { MAIN_QUEUE_ID, useDownloadStore } from '../store/useDownloadStore';
|
||||
import { isActiveDownloadStatus } from '../utils/downloads';
|
||||
import { WindowDragRegion } from './WindowDragRegion';
|
||||
import { useToast } from '../contexts/ToastContext';
|
||||
import { usePlatformInfo } from '../utils/platform';
|
||||
@@ -137,6 +138,7 @@ export default function SchedulerView() {
|
||||
};
|
||||
|
||||
const runNow = async () => {
|
||||
const previouslyTrackedIds = new Set(useSettingsStore.getState().schedulerActiveDownloadIds);
|
||||
const results = await Promise.all(
|
||||
effectiveSelectedQueueIds.map(queueId => useDownloadStore.getState().startQueue(queueId))
|
||||
);
|
||||
@@ -144,8 +146,9 @@ export default function SchedulerView() {
|
||||
const selectedQueueSet = new Set(effectiveSelectedQueueIds);
|
||||
const trackedIds = useDownloadStore.getState().downloads
|
||||
.filter(download =>
|
||||
previouslyTrackedIds.has(download.id) &&
|
||||
selectedQueueSet.has(download.queueId || MAIN_QUEUE_ID) &&
|
||||
['queued', 'downloading', 'processing', 'retrying'].includes(download.status)
|
||||
isActiveDownloadStatus(download.status)
|
||||
)
|
||||
.map(download => download.id);
|
||||
const activeIds = [...new Set([...acceptedIds, ...trackedIds])];
|
||||
|
||||
+152
-33
@@ -4,6 +4,7 @@ import {
|
||||
type ListRowDensity,
|
||||
type SettingsState,
|
||||
SettingsTab,
|
||||
runSettingsPersistenceTransaction,
|
||||
useSettingsStore
|
||||
} from '../store/useSettingsStore';
|
||||
import {
|
||||
@@ -28,6 +29,8 @@ import {
|
||||
subfolderFromDerivedCategoryPath
|
||||
} from '../utils/downloadLocations';
|
||||
import { usePlatformInfo } from '../utils/platform';
|
||||
import { isTrustedFirelinkReleaseUrl } from '../utils/releaseUrls';
|
||||
import { normalizeCustomProxy } from '../store/useDownloadStore';
|
||||
|
||||
const settingsTabs: { type: SettingsTab; label: string; icon: typeof Download }[] = [
|
||||
{ type: 'downloads', label: 'Downloads', icon: Download },
|
||||
@@ -75,6 +78,8 @@ type ManualUpdateStatus =
|
||||
| { type: 'update-available'; version: string; releaseUrl: string }
|
||||
| { type: 'error'; message: string };
|
||||
|
||||
type SystemProxyStatus = 'idle' | 'checking' | 'detected' | 'none' | 'error';
|
||||
|
||||
const engineStatusCache = new Map<string, EngineStatusItem>();
|
||||
const engineStatusInFlight = new Map<string, Promise<EngineStatusItem>>();
|
||||
|
||||
@@ -275,14 +280,22 @@ const [engineStatus, setEngineStatus] = useState<EngineStatusItem[] | null>(null
|
||||
const [expandedEngine, setExpandedEngine] = useState<string | null>(null);
|
||||
const [isRecheckingEngines, setIsRecheckingEngines] = useState(false);
|
||||
const engineRunId = useRef(0);
|
||||
const [appVersion, setAppVersion] = useState('1.0.1');
|
||||
const [extensionServerPort, setExtensionServerPort] = useState<number | null>(null);
|
||||
const [appVersion, setAppVersion] = useState('Unknown');
|
||||
const [extensionServerPort, setExtensionServerPort] = useState<number | null>(null);
|
||||
const [systemProxyStatus, setSystemProxyStatus] = useState<SystemProxyStatus>('idle');
|
||||
|
||||
// Local state for adding site login
|
||||
const [loginPattern, setLoginPattern] = useState('');
|
||||
const [loginUser, setLoginUser] = useState('');
|
||||
const [loginPass, setLoginPass] = useState('');
|
||||
const [loginError, setLoginError] = useState('');
|
||||
const [isSavingLogin, setIsSavingLogin] = useState(false);
|
||||
const saveLoginInFlight = useRef(false);
|
||||
const [loginFieldErrors, setLoginFieldErrors] = useState<{
|
||||
pattern?: string;
|
||||
username?: string;
|
||||
password?: string;
|
||||
}>({});
|
||||
|
||||
// Toast notifications
|
||||
const { addToast } = useToast();
|
||||
@@ -314,6 +327,27 @@ useEffect(() => {
|
||||
};
|
||||
}, [settings.activeView, activeTab]);
|
||||
|
||||
useEffect(() => {
|
||||
if (settings.activeView !== 'settings' || activeTab !== 'network' || settings.proxyMode !== 'system') {
|
||||
setSystemProxyStatus('idle');
|
||||
return;
|
||||
}
|
||||
|
||||
let active = true;
|
||||
setSystemProxyStatus('checking');
|
||||
invoke('get_system_proxy')
|
||||
.then(proxy => {
|
||||
if (active) setSystemProxyStatus(typeof proxy === 'string' && proxy.trim() ? 'detected' : 'none');
|
||||
})
|
||||
.catch(() => {
|
||||
if (active) setSystemProxyStatus('error');
|
||||
});
|
||||
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [settings.activeView, settings.proxyMode, activeTab]);
|
||||
|
||||
const runEngineChecks = useCallback((force = false) => {
|
||||
const runId = ++engineRunId.current;
|
||||
const cached = engineChecks
|
||||
@@ -431,6 +465,9 @@ runEngineChecks(false);
|
||||
localVersion: result.local_version
|
||||
});
|
||||
} else if (result.type === 'UpdateAvailable') {
|
||||
if (!isTrustedFirelinkReleaseUrl(result.update.release_url)) {
|
||||
throw new Error('The update check returned an untrusted release URL.');
|
||||
}
|
||||
setManualUpdateStatus({
|
||||
type: 'update-available',
|
||||
version: result.update.version,
|
||||
@@ -512,27 +549,57 @@ runEngineChecks(false);
|
||||
};
|
||||
|
||||
const handleAddLogin = async () => {
|
||||
if (!loginPattern.trim() || !loginUser.trim()) {
|
||||
setLoginError("Please enter a URL pattern and a username.");
|
||||
if (saveLoginInFlight.current) return;
|
||||
const fieldErrors: typeof loginFieldErrors = {};
|
||||
if (!loginPattern.trim()) {
|
||||
fieldErrors.pattern = 'URL pattern is required.';
|
||||
} else if (/\s/.test(loginPattern.trim())) {
|
||||
fieldErrors.pattern = 'URL pattern cannot contain whitespace.';
|
||||
}
|
||||
if (!loginUser.trim()) {
|
||||
fieldErrors.username = 'Username is required.';
|
||||
}
|
||||
if (!loginPass) {
|
||||
fieldErrors.password = 'Password is required.';
|
||||
}
|
||||
if (Object.keys(fieldErrors).length > 0) {
|
||||
setLoginFieldErrors(fieldErrors);
|
||||
setLoginError('');
|
||||
return;
|
||||
}
|
||||
setLoginFieldErrors({});
|
||||
const id = crypto.randomUUID();
|
||||
|
||||
if (loginPass) {
|
||||
try {
|
||||
await invoke('set_keychain_password', { id, password: loginPass });
|
||||
} catch (e) {
|
||||
console.error("Failed to save password to keychain:", e);
|
||||
setLoginError("Failed to save password securely.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!settings.keychainAccessReady) {
|
||||
settings.setShowKeychainModal(true);
|
||||
setLoginError('Grant credential-store access before saving a site login.');
|
||||
return;
|
||||
}
|
||||
saveLoginInFlight.current = true;
|
||||
setIsSavingLogin(true);
|
||||
try {
|
||||
await runSettingsPersistenceTransaction(async () => {
|
||||
await invoke('save_site_login', {
|
||||
id,
|
||||
urlPattern: loginPattern.trim(),
|
||||
username: loginUser.trim(),
|
||||
password: loginPass
|
||||
});
|
||||
settings.addSiteLogin({
|
||||
id,
|
||||
urlPattern: loginPattern.trim(),
|
||||
username: loginUser.trim()
|
||||
});
|
||||
});
|
||||
} catch (e) {
|
||||
console.error("Failed to save site login:", e);
|
||||
setLoginError("Failed to save site credential securely.");
|
||||
return;
|
||||
} finally {
|
||||
saveLoginInFlight.current = false;
|
||||
setIsSavingLogin(false);
|
||||
}
|
||||
|
||||
settings.addSiteLogin({
|
||||
id,
|
||||
urlPattern: loginPattern.trim(),
|
||||
username: loginUser.trim()
|
||||
});
|
||||
setLoginPattern('');
|
||||
setLoginUser('');
|
||||
setLoginPass('');
|
||||
@@ -596,7 +663,7 @@ runEngineChecks(false);
|
||||
<div className="mac-settings-row">
|
||||
<div className="settings-row-label">
|
||||
<span>Default connections:</span>
|
||||
<small>For new downloads</small>
|
||||
<small>New downloads; existing items keep their saved value</small>
|
||||
</div>
|
||||
<input
|
||||
type="number" min="1" max="16"
|
||||
@@ -671,6 +738,18 @@ runEngineChecks(false);
|
||||
className="mac-switch"
|
||||
/>
|
||||
</label>
|
||||
<label className="mac-settings-row cursor-default">
|
||||
<div className="settings-row-label">
|
||||
<span>Add clipboard links when Firelink becomes active</span>
|
||||
<small>Opens supported copied links in the Add window. Off by default.</small>
|
||||
</div>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.autoAddClipboardLinks}
|
||||
onChange={(e) => settings.setAutoAddClipboardLinks(e.target.checked)}
|
||||
className="mac-switch"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -839,10 +918,20 @@ runEngineChecks(false);
|
||||
<p className="settings-group-footer">
|
||||
{settings.proxyMode === 'none' && 'Downloads ignore configured proxies.'}
|
||||
{settings.proxyMode === 'system' && `Downloads use the detected ${platform.os === 'macos' ? 'macOS' : platform.os === 'windows' ? 'Windows' : 'desktop'} system proxy. Normal file downloads require an HTTP or HTTPS proxy endpoint; media downloads can use SOCKS.`}
|
||||
{settings.proxyMode === 'custom' && (settings.proxyHost
|
||||
{settings.proxyMode === 'custom' && (normalizeCustomProxy(settings.proxyHost, settings.proxyPort)
|
||||
? 'Downloads use the configured HTTP proxy endpoint for metadata and download engines.'
|
||||
: 'Enter a proxy host and port to enable the custom proxy.')}
|
||||
: settings.proxyHost
|
||||
? 'Enter a valid HTTP proxy host and port to enable the custom proxy.'
|
||||
: 'Enter a proxy host and port to enable the custom proxy.')}
|
||||
</p>
|
||||
{settings.proxyMode === 'system' && (
|
||||
<p className="settings-group-footer" role="status">
|
||||
{systemProxyStatus === 'checking' && 'Checking system proxy configuration…'}
|
||||
{systemProxyStatus === 'detected' && 'A system proxy was detected. Normal file downloads require an HTTP or HTTPS endpoint; media downloads can use SOCKS.'}
|
||||
{systemProxyStatus === 'none' && 'No usable system proxy was detected. Downloads will use no proxy.'}
|
||||
{systemProxyStatus === 'error' && 'System proxy configuration could not be read. Choose No Proxy or try again when it is available.'}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<h2 className="settings-section-title">Identity</h2>
|
||||
@@ -1026,9 +1115,15 @@ runEngineChecks(false);
|
||||
</div>
|
||||
<button
|
||||
onClick={async () => {
|
||||
if (!settings.keychainAccessReady) {
|
||||
settings.setShowKeychainModal(true);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await invoke('delete_keychain_password', { id: login.id });
|
||||
settings.removeSiteLogin(login.id);
|
||||
await runSettingsPersistenceTransaction(async () => {
|
||||
await invoke('delete_site_login', { id: login.id });
|
||||
settings.removeSiteLogin(login.id);
|
||||
});
|
||||
showToast("Deleted credential", 'success');
|
||||
} catch (error) {
|
||||
showToast(`Could not delete credential: ${String(error)}`, 'error');
|
||||
@@ -1057,10 +1152,15 @@ runEngineChecks(false);
|
||||
<input
|
||||
type="text"
|
||||
value={loginPattern}
|
||||
onChange={(e) => setLoginPattern(e.target.value)}
|
||||
onChange={(e) => {
|
||||
setLoginPattern(e.target.value);
|
||||
setLoginFieldErrors(current => ({ ...current, pattern: undefined }));
|
||||
}}
|
||||
placeholder="e.g. *.example.com or example.com/downloads"
|
||||
aria-invalid={Boolean(loginFieldErrors.pattern)}
|
||||
className="bg-bg-input border border-border-modal rounded-md px-3 py-1.5 w-full text-text-primary focus:outline-none"
|
||||
/>
|
||||
{loginFieldErrors.pattern && <p className="text-red-500 text-xs mt-1">{loginFieldErrors.pattern}</p>}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-[150px_1fr] items-center gap-4 text-[13px]">
|
||||
@@ -1068,10 +1168,15 @@ runEngineChecks(false);
|
||||
<input
|
||||
type="text"
|
||||
value={loginUser}
|
||||
onChange={(e) => setLoginUser(e.target.value)}
|
||||
onChange={(e) => {
|
||||
setLoginUser(e.target.value);
|
||||
setLoginFieldErrors(current => ({ ...current, username: undefined }));
|
||||
}}
|
||||
placeholder="Username"
|
||||
aria-invalid={Boolean(loginFieldErrors.username)}
|
||||
className="bg-bg-input border border-border-modal rounded-md px-3 py-1.5 w-full text-text-primary focus:outline-none"
|
||||
/>
|
||||
{loginFieldErrors.username && <p className="text-red-500 text-xs mt-1">{loginFieldErrors.username}</p>}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-[150px_1fr] items-center gap-4 text-[13px]">
|
||||
@@ -1079,18 +1184,25 @@ runEngineChecks(false);
|
||||
<input
|
||||
type="password"
|
||||
value={loginPass}
|
||||
onChange={(e) => setLoginPass(e.target.value)}
|
||||
onChange={(e) => {
|
||||
setLoginPass(e.target.value);
|
||||
setLoginFieldErrors(current => ({ ...current, password: undefined }));
|
||||
}}
|
||||
placeholder="Password"
|
||||
aria-invalid={Boolean(loginFieldErrors.password)}
|
||||
className="bg-bg-input border border-border-modal rounded-md px-3 py-1.5 w-full text-text-primary focus:outline-none"
|
||||
/>
|
||||
{loginFieldErrors.password && <p className="text-red-500 text-xs mt-1">{loginFieldErrors.password}</p>}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end pt-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleAddLogin}
|
||||
disabled={isSavingLogin}
|
||||
className="bg-accent hover:bg-accent text-white px-4 py-1.5 rounded-lg text-xs font-semibold shadow flex items-center gap-1.5"
|
||||
>
|
||||
<Plus size={14} /> Add Login
|
||||
<Plus size={14} /> {isSavingLogin ? 'Saving…' : 'Add Login'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1214,9 +1326,13 @@ className="app-button px-3 py-1.5 text-[12px] flex items-center gap-1.5 disabled
|
||||
<Check size={16} strokeWidth={2.5} />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h4 className="text-sm font-semibold text-green-500 m-0">Credential Storage Available</h4>
|
||||
<h4 className="text-sm font-semibold text-green-500 m-0">
|
||||
{platform.portable ? 'Portable Pairing Enabled' : 'Pairing Token Persisted'}
|
||||
</h4>
|
||||
<p className="text-xs text-text-secondary m-0 mt-0.5">
|
||||
Your pairing token is securely saved in this system's credential store and will persist across restarts.
|
||||
{platform.portable
|
||||
? 'Your pairing token is stored with this portable Firelink folder and will persist when the folder is moved. Treat the folder as sensitive.'
|
||||
: 'Your pairing token is stored in the system credential store and will persist across restarts.'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1224,16 +1340,19 @@ className="app-button px-3 py-1.5 text-[12px] flex items-center gap-1.5 disabled
|
||||
<div className="bg-orange-500/10 border border-orange-500/20 rounded-lg p-4 flex items-start gap-3">
|
||||
<ShieldAlert className="w-5 h-5 text-orange-500 flex-shrink-0 mt-0.5" />
|
||||
<div className="flex-1">
|
||||
<h4 className="text-sm font-semibold text-text-primary mb-1">Credential Storage Needed</h4>
|
||||
<h4 className="text-sm font-semibold text-text-primary mb-1">
|
||||
{platform.portable ? 'Portable Pairing Available' : 'Credential Storage Needed'}
|
||||
</h4>
|
||||
<p className="text-xs text-text-secondary mb-3">
|
||||
Firelink needs access to this system's credential store to securely save your pairing token across app restarts.
|
||||
Currently, your extension will only stay connected for this session.
|
||||
{platform.portable
|
||||
? 'Your pairing token is stored with this portable Firelink folder and will persist across restarts. Enable it here to review the portable-storage warning.'
|
||||
: "Firelink needs access to this system's credential store to securely save your pairing token across app restarts. Currently, your extension will only stay connected for this session."}
|
||||
</p>
|
||||
<button
|
||||
onClick={() => settings.setShowKeychainModal(true)}
|
||||
className="px-4 py-1.5 rounded-md text-xs font-medium transition-colors bg-accent text-white hover:bg-accent/90 shadow-sm"
|
||||
>
|
||||
Grant Credential Access
|
||||
{platform.portable ? 'Review Portable Pairing' : 'Grant Credential Access'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -10,6 +10,7 @@ import { useDownloadStore, DownloadCategory, Queue } from '../store/useDownloadS
|
||||
import { ActiveView, useSettingsStore } from '../store/useSettingsStore';
|
||||
import { WindowDragRegion } from './WindowDragRegion';
|
||||
import { useToast } from '../contexts/ToastContext';
|
||||
import { isTransferActiveStatus } from '../utils/downloads';
|
||||
|
||||
export type SidebarFilter = 'all' | 'active' | 'completed' | 'unfinished' | DownloadCategory | 'settings' | string;
|
||||
|
||||
@@ -37,6 +38,8 @@ export const Sidebar: React.FC<SidebarProps> = (props) => {
|
||||
|
||||
const addInputRef = useRef<HTMLInputElement>(null);
|
||||
const renameInputRef = useRef<HTMLInputElement>(null);
|
||||
const addQueueSubmitRef = useRef(false);
|
||||
const renameQueueSubmitRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
const handleCloseMenu = () => setContextMenu(null);
|
||||
@@ -81,6 +84,9 @@ export const Sidebar: React.FC<SidebarProps> = (props) => {
|
||||
const queueId = selectedFilter.replace('queue:', '');
|
||||
const q = queues.find(q => q.id === queueId);
|
||||
if (q && !q.isMain) {
|
||||
if (!window.confirm(`Delete queue "${q.name}"? Its unfinished downloads will move to Main Queue.`)) {
|
||||
return;
|
||||
}
|
||||
void removeQueue(queueId).catch(error => {
|
||||
addToast({ message: `Could not delete queue: ${String(error)}`, variant: 'error', isActionable: true });
|
||||
});
|
||||
@@ -91,7 +97,7 @@ export const Sidebar: React.FC<SidebarProps> = (props) => {
|
||||
};
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [selectedFilter, activeView, queues]);
|
||||
}, [addToast, activeView, queues, removeQueue, selectedFilter]);
|
||||
|
||||
const getCount = (filter: SidebarFilter) => {
|
||||
if (filter.startsWith('queue:')) {
|
||||
@@ -100,7 +106,7 @@ export const Sidebar: React.FC<SidebarProps> = (props) => {
|
||||
}
|
||||
switch (filter) {
|
||||
case 'all': return downloads.length;
|
||||
case 'active': return downloads.filter(d => d.status === 'downloading').length;
|
||||
case 'active': return downloads.filter(d => isTransferActiveStatus(d.status)).length;
|
||||
case 'completed': return downloads.filter(d => d.status === 'completed').length;
|
||||
case 'unfinished': return downloads.filter(d => d.status !== 'completed').length;
|
||||
default: return downloads.filter(d => d.category === filter as DownloadCategory).length;
|
||||
@@ -135,15 +141,34 @@ export const Sidebar: React.FC<SidebarProps> = (props) => {
|
||||
};
|
||||
|
||||
const handleAddQueueSubmit = () => {
|
||||
if (newQueueName.trim()) addQueue(newQueueName.trim());
|
||||
if (addQueueSubmitRef.current) return;
|
||||
const normalizedName = newQueueName.trim();
|
||||
if (!normalizedName) {
|
||||
addToast({ message: 'Queue name cannot be empty', variant: 'error', isActionable: true });
|
||||
return;
|
||||
}
|
||||
if (!addQueue(normalizedName)) {
|
||||
addToast({ message: 'A queue with this name already exists', variant: 'error', isActionable: true });
|
||||
return;
|
||||
}
|
||||
addQueueSubmitRef.current = true;
|
||||
setNewQueueName('');
|
||||
setIsAddingQueue(false);
|
||||
};
|
||||
|
||||
const handleRenameQueueSubmit = () => {
|
||||
if (renamingQueueId && editingQueueName.trim()) {
|
||||
renameQueue(renamingQueueId, editingQueueName.trim());
|
||||
if (renameQueueSubmitRef.current) return;
|
||||
const normalizedName = editingQueueName.trim();
|
||||
if (!renamingQueueId) return;
|
||||
if (!normalizedName) {
|
||||
addToast({ message: 'Queue name cannot be empty', variant: 'error', isActionable: true });
|
||||
return;
|
||||
}
|
||||
if (!renameQueue(renamingQueueId, normalizedName)) {
|
||||
addToast({ message: 'A queue with this name already exists', variant: 'error', isActionable: true });
|
||||
return;
|
||||
}
|
||||
renameQueueSubmitRef.current = true;
|
||||
setRenamingQueueId(null);
|
||||
};
|
||||
|
||||
@@ -289,7 +314,7 @@ export const Sidebar: React.FC<SidebarProps> = (props) => {
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setIsAddingQueue(true); setNewQueueName(''); }}
|
||||
onClick={() => { addQueueSubmitRef.current = false; setIsAddingQueue(true); setNewQueueName(''); }}
|
||||
className="flex w-full items-center px-3.5 py-1.5 rounded-lg text-[13px] text-text-muted hover:bg-item-hover hover:text-text-secondary cursor-default transition-colors mb-1"
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-2 shrink-0" strokeWidth={2} />
|
||||
@@ -330,14 +355,34 @@ export const Sidebar: React.FC<SidebarProps> = (props) => {
|
||||
>
|
||||
<button
|
||||
className="w-full text-left px-3 py-1.5 flex items-center hover:bg-item-hover"
|
||||
onClick={() => { startQueue(contextMenu.id); setContextMenu(null); }}
|
||||
onClick={() => {
|
||||
const queueId = contextMenu.id;
|
||||
setContextMenu(null);
|
||||
void startQueue(queueId).catch(error => {
|
||||
addToast({
|
||||
message: `Could not start queue: ${String(error)}`,
|
||||
variant: 'error',
|
||||
isActionable: true
|
||||
});
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Play size={14} className="mr-2 text-text-secondary" />
|
||||
Start Queue
|
||||
</button>
|
||||
<button
|
||||
className="w-full text-left px-3 py-1.5 flex items-center hover:bg-item-hover"
|
||||
onClick={() => { pauseQueue(contextMenu.id); setContextMenu(null); }}
|
||||
onClick={() => {
|
||||
const queueId = contextMenu.id;
|
||||
setContextMenu(null);
|
||||
void pauseQueue(queueId).catch(error => {
|
||||
addToast({
|
||||
message: `Could not pause queue: ${String(error)}`,
|
||||
variant: 'error',
|
||||
isActionable: true
|
||||
});
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Pause size={14} className="mr-2 text-text-secondary" />
|
||||
Pause Queue
|
||||
@@ -348,6 +393,7 @@ export const Sidebar: React.FC<SidebarProps> = (props) => {
|
||||
onClick={() => {
|
||||
const q = queues.find(q => q.id === contextMenu.id);
|
||||
if (q) {
|
||||
renameQueueSubmitRef.current = false;
|
||||
setEditingQueueName(q.name);
|
||||
setRenamingQueueId(q.id);
|
||||
}
|
||||
@@ -362,6 +408,14 @@ export const Sidebar: React.FC<SidebarProps> = (props) => {
|
||||
className="w-full text-left px-3 py-1.5 flex items-center hover:bg-red-500/20 text-red-400"
|
||||
onClick={() => {
|
||||
const queueId = contextMenu.id;
|
||||
const queue = queues.find(q => q.id === queueId);
|
||||
if (!queue || queue.isMain) {
|
||||
setContextMenu(null);
|
||||
return;
|
||||
}
|
||||
if (!window.confirm(`Delete queue "${queue.name}"? Its unfinished downloads will move to Main Queue.`)) {
|
||||
return;
|
||||
}
|
||||
setContextMenu(null);
|
||||
void removeQueue(queueId).catch(error => {
|
||||
addToast({
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
convertSpeedValue,
|
||||
clampSpeedDisplayValue,
|
||||
displayValueFromPresetBase,
|
||||
formatSpeedLimitForStorage,
|
||||
formatPresetValue,
|
||||
parseLimit,
|
||||
presetBaseFromDisplayValue,
|
||||
} from './SpeedLimiterView';
|
||||
|
||||
describe('SpeedLimiterView speed conversions', () => {
|
||||
it('converts KB/s and MB/s using binary units consistently', () => {
|
||||
expect(presetBaseFromDisplayValue(1024, 'KB/s')).toBe(1);
|
||||
expect(displayValueFromPresetBase(1, 'KB/s')).toBe(1024);
|
||||
expect(presetBaseFromDisplayValue(5, 'MB/s')).toBe(5);
|
||||
expect(displayValueFromPresetBase(5, 'MB/s')).toBe(5);
|
||||
});
|
||||
|
||||
it('round-trips non-MiB-aligned presets through the integer KiB backend value', () => {
|
||||
const presetBase = presetBaseFromDisplayValue(1500, 'KB/s');
|
||||
|
||||
expect(displayValueFromPresetBase(presetBase, 'KB/s')).toBe(1500);
|
||||
expect(convertSpeedValue(1500, 'KB/s', 'MB/s')).toBe(1500 / 1024);
|
||||
expect(convertSpeedValue(convertSpeedValue(1500, 'KB/s', 'MB/s'), 'MB/s', 'KB/s')).toBe(1500);
|
||||
});
|
||||
|
||||
it('preserves the selected unit when saving a non-MiB-aligned MB/s value', () => {
|
||||
const storedLimit = formatSpeedLimitForStorage(1.5, 'MB/s');
|
||||
|
||||
expect(storedLimit).toBe('1.5M');
|
||||
expect(parseLimit(storedLimit, 1024)).toEqual({ value: 1.5, unit: 'MB/s' });
|
||||
});
|
||||
|
||||
it('preserves an explicitly selected KB/s unit at MiB boundaries', () => {
|
||||
const storedLimit = formatSpeedLimitForStorage(1024, 'KB/s');
|
||||
|
||||
expect(storedLimit).toBe('1024K');
|
||||
expect(parseLimit(storedLimit, 1)).toEqual({ value: 1024, unit: 'KB/s' });
|
||||
});
|
||||
|
||||
it('restores the persisted unit while the limiter is disabled', () => {
|
||||
expect(parseLimit('', 1536, 'MB/s')).toEqual({ value: 1.5, unit: 'MB/s' });
|
||||
expect(parseLimit('', 1536, 'KB/s')).toEqual({ value: 1536, unit: 'KB/s' });
|
||||
});
|
||||
|
||||
it('allows sub-one MB/s values while enforcing the 1 KiB backend minimum', () => {
|
||||
expect(clampSpeedDisplayValue(0.5, 'MB/s')).toBe(0.5);
|
||||
expect(clampSpeedDisplayValue(0, 'MB/s')).toBe(1 / 1024);
|
||||
expect(clampSpeedDisplayValue(0, 'KB/s')).toBe(1);
|
||||
});
|
||||
|
||||
it('shows the exact stored preset value instead of a rounded label', () => {
|
||||
expect(formatPresetValue(1.46484375)).toBe('1.46484375');
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Gauge, Plus, Save, X, Zap } from 'lucide-react';
|
||||
import { useSettingsStore } from '../store/useSettingsStore';
|
||||
import { WindowDragRegion } from './WindowDragRegion';
|
||||
@@ -8,94 +8,163 @@ type SpeedUnit = 'KB/s' | 'MB/s';
|
||||
|
||||
const MAX_LIMIT_KIB = 10_485_760;
|
||||
const MAX_LIMIT_MB = 10240;
|
||||
const KIB_PER_MIB = 1024;
|
||||
|
||||
function parseLimit(limit: string, fallback: number): { value: number; unit: SpeedUnit } {
|
||||
export function speedValueToKiB(value: number, unit: SpeedUnit): number {
|
||||
const numericValue = Number.isFinite(value) ? value : 1;
|
||||
const valueKiB = unit === 'MB/s' ? numericValue * KIB_PER_MIB : numericValue;
|
||||
return Math.max(1, Math.min(MAX_LIMIT_KIB, Math.round(valueKiB)));
|
||||
}
|
||||
|
||||
export function speedValueFromKiB(valueKiB: number, unit: SpeedUnit): number {
|
||||
const normalizedKiB = Math.max(1, Math.min(MAX_LIMIT_KIB, Math.round(valueKiB)));
|
||||
return unit === 'MB/s' ? normalizedKiB / KIB_PER_MIB : normalizedKiB;
|
||||
}
|
||||
|
||||
export function convertSpeedValue(value: number, fromUnit: SpeedUnit, toUnit: SpeedUnit): number {
|
||||
return speedValueFromKiB(speedValueToKiB(value, fromUnit), toUnit);
|
||||
}
|
||||
|
||||
export function parseLimit(
|
||||
limit: string,
|
||||
fallback: number,
|
||||
fallbackUnit: SpeedUnit = 'MB/s'
|
||||
): { value: number; unit: SpeedUnit } {
|
||||
const match = limit.trim().match(/^(\d+(?:\.\d+)?)\s*([km]?)b?(?:\/s)?$/i);
|
||||
const suffix = match?.[2].toLowerCase();
|
||||
const valueKiB = match
|
||||
? Math.max(1, Math.round(Number(match[1]) * (match[2].toLowerCase() === 'm' ? 1024 : 1)))
|
||||
: fallback;
|
||||
? speedValueToKiB(Number(match[1]) * (suffix === 'm' ? KIB_PER_MIB : 1), 'KB/s')
|
||||
: speedValueToKiB(fallback, 'KB/s');
|
||||
|
||||
if (!match) {
|
||||
return { value: speedValueFromKiB(valueKiB, fallbackUnit), unit: fallbackUnit };
|
||||
}
|
||||
|
||||
if (suffix === 'm') {
|
||||
return { value: speedValueFromKiB(valueKiB, 'MB/s'), unit: 'MB/s' };
|
||||
}
|
||||
|
||||
if (suffix === 'k') {
|
||||
return { value: valueKiB, unit: 'KB/s' };
|
||||
}
|
||||
|
||||
return valueKiB >= 1024 && valueKiB % 1024 === 0
|
||||
? { value: valueKiB / 1024, unit: 'MB/s' }
|
||||
: { value: valueKiB, unit: 'KB/s' };
|
||||
}
|
||||
|
||||
export function formatSpeedLimitForStorage(value: number, unit: SpeedUnit): string {
|
||||
const valueKiB = speedValueToKiB(value, unit);
|
||||
return unit === 'MB/s'
|
||||
? `${speedValueFromKiB(valueKiB, 'MB/s')}M`
|
||||
: `${valueKiB}K`;
|
||||
}
|
||||
|
||||
export function clampSpeedDisplayValue(value: number, unit: SpeedUnit): number {
|
||||
const numericValue = Number.isFinite(value) ? value : speedValueFromKiB(1, unit);
|
||||
const minimum = speedValueFromKiB(1, unit);
|
||||
const maximum = speedValueFromKiB(MAX_LIMIT_KIB, unit);
|
||||
return Math.max(minimum, Math.min(maximum, numericValue));
|
||||
}
|
||||
|
||||
function sanitizePresetValues(values: number[]): number[] {
|
||||
const cleaned = values
|
||||
.map(value => Number(value))
|
||||
.filter(value => Number.isFinite(value) && value > 0)
|
||||
.map(value => Math.min(MAX_LIMIT_MB, Math.round(value * 100) / 100));
|
||||
.map(value => speedValueFromKiB(speedValueToKiB(value, 'MB/s'), 'MB/s'));
|
||||
|
||||
return Array.from(new Set(cleaned)).sort((a, b) => a - b);
|
||||
}
|
||||
|
||||
function presetBaseFromDisplayValue(value: number, unit: SpeedUnit): number {
|
||||
return unit === 'MB/s' ? value : value / 1000;
|
||||
export function presetBaseFromDisplayValue(value: number, unit: SpeedUnit): number {
|
||||
return speedValueFromKiB(speedValueToKiB(value, unit), 'MB/s');
|
||||
}
|
||||
|
||||
function displayValueFromPresetBase(value: number, unit: SpeedUnit): number {
|
||||
return unit === 'MB/s' ? value : Math.round(value * 1000);
|
||||
export function displayValueFromPresetBase(value: number, unit: SpeedUnit): number {
|
||||
return speedValueFromKiB(speedValueToKiB(value, 'MB/s'), unit);
|
||||
}
|
||||
|
||||
function formatPresetValue(value: number): string {
|
||||
return Number.isInteger(value) ? String(value) : value.toFixed(2).replace(/\.?0+$/, '');
|
||||
export function formatPresetValue(value: number): string {
|
||||
return String(value);
|
||||
}
|
||||
|
||||
export default function SpeedLimiterView() {
|
||||
const globalSpeedLimit = useSettingsStore(state => state.globalSpeedLimit);
|
||||
const lastCustomSpeedLimitKiB = useSettingsStore(state => state.lastCustomSpeedLimitKiB);
|
||||
const lastCustomSpeedLimitUnit = useSettingsStore(state => state.lastCustomSpeedLimitUnit);
|
||||
const speedLimitPresetValues = useSettingsStore(state => state.speedLimitPresetValues);
|
||||
const setGlobalSpeedLimit = useSettingsStore(state => state.setGlobalSpeedLimit);
|
||||
const setLastCustomSpeedLimitKiB = useSettingsStore(state => state.setLastCustomSpeedLimitKiB);
|
||||
const setLastCustomSpeedLimitUnit = useSettingsStore(state => state.setLastCustomSpeedLimitUnit);
|
||||
const setSpeedLimitPresetValues = useSettingsStore(state => state.setSpeedLimitPresetValues);
|
||||
const initial = parseLimit(globalSpeedLimit, lastCustomSpeedLimitKiB);
|
||||
const fallbackUnit: SpeedUnit = lastCustomSpeedLimitUnit === 'KB/s' ? 'KB/s' : 'MB/s';
|
||||
const initial = parseLimit(globalSpeedLimit, lastCustomSpeedLimitKiB, fallbackUnit);
|
||||
const [enabled, setEnabled] = useState(Boolean(globalSpeedLimit));
|
||||
const [value, setValue] = useState(initial.value);
|
||||
const [value, setValue] = useState(String(initial.value));
|
||||
const [unit, setUnit] = useState<SpeedUnit>(initial.unit);
|
||||
const [customPresetValue, setCustomPresetValue] = useState(initial.value);
|
||||
const [customPresetValue, setCustomPresetValue] = useState(String(initial.value));
|
||||
const { addToast } = useToast();
|
||||
const savingRef = useRef(false);
|
||||
const presetValues = useMemo(
|
||||
() => sanitizePresetValues(speedLimitPresetValues),
|
||||
[speedLimitPresetValues]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const parsed = parseLimit(globalSpeedLimit, lastCustomSpeedLimitKiB);
|
||||
const parsed = parseLimit(globalSpeedLimit, lastCustomSpeedLimitKiB, fallbackUnit);
|
||||
setEnabled(Boolean(globalSpeedLimit));
|
||||
setValue(parsed.value);
|
||||
setValue(String(parsed.value));
|
||||
setUnit(parsed.unit);
|
||||
setCustomPresetValue(parsed.value);
|
||||
}, [globalSpeedLimit, lastCustomSpeedLimitKiB]);
|
||||
setCustomPresetValue(String(parsed.value));
|
||||
}, [globalSpeedLimit, lastCustomSpeedLimitKiB, fallbackUnit]);
|
||||
|
||||
|
||||
const save = () => {
|
||||
const numericValue = Math.max(1, Math.min(Number(value) || 1, unit === 'MB/s' ? 10240 : MAX_LIMIT_KIB));
|
||||
const valueKiB = Math.min(MAX_LIMIT_KIB, Math.round(unit === 'MB/s' ? numericValue * 1024 : numericValue));
|
||||
setLastCustomSpeedLimitKiB(valueKiB);
|
||||
setGlobalSpeedLimit(enabled ? `${valueKiB}K` : '');
|
||||
addToast({
|
||||
message: enabled ? `Global limit saved at ${numericValue} ${unit}` : 'Global speed limit disabled',
|
||||
variant: 'success'
|
||||
});
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
const save = async () => {
|
||||
if (savingRef.current) return;
|
||||
savingRef.current = true;
|
||||
const numericValue = clampSpeedDisplayValue(Number(value), unit);
|
||||
const valueKiB = speedValueToKiB(numericValue, unit);
|
||||
setIsSaving(true);
|
||||
try {
|
||||
await setGlobalSpeedLimit(enabled ? formatSpeedLimitForStorage(numericValue, unit) : '');
|
||||
setLastCustomSpeedLimitKiB(valueKiB);
|
||||
setLastCustomSpeedLimitUnit(unit);
|
||||
addToast({
|
||||
message: enabled ? `Global limit saved at ${numericValue} ${unit}` : 'Global speed limit disabled',
|
||||
variant: 'success'
|
||||
});
|
||||
} catch (error) {
|
||||
addToast({
|
||||
message: `Could not save global speed limit: ${String(error)}`,
|
||||
variant: 'error',
|
||||
isActionable: true
|
||||
});
|
||||
} finally {
|
||||
savingRef.current = false;
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const preset = (presetValue: number) => {
|
||||
setEnabled(true);
|
||||
setValue(displayValueFromPresetBase(presetValue, unit));
|
||||
setValue(String(displayValueFromPresetBase(presetValue, unit)));
|
||||
};
|
||||
|
||||
const applyCustomPreset = () => {
|
||||
const numericValue = Math.max(1, Math.min(Number(customPresetValue) || 1, unit === 'MB/s' ? MAX_LIMIT_MB : MAX_LIMIT_KIB));
|
||||
const numericValue = clampSpeedDisplayValue(Number(customPresetValue), unit);
|
||||
const presetBaseValue = Math.min(MAX_LIMIT_MB, presetBaseFromDisplayValue(numericValue, unit));
|
||||
const nextPresets = sanitizePresetValues([...presetValues, presetBaseValue]);
|
||||
const alreadyExists = nextPresets.length === presetValues.length;
|
||||
const storedPresetDisplayValue = displayValueFromPresetBase(presetBaseValue, unit);
|
||||
setSpeedLimitPresetValues(nextPresets);
|
||||
setEnabled(true);
|
||||
setValue(numericValue);
|
||||
setValue(String(storedPresetDisplayValue));
|
||||
addToast({
|
||||
message: alreadyExists
|
||||
? `${formatPresetValue(numericValue)} ${unit} is already in quick presets`
|
||||
: `Added ${formatPresetValue(numericValue)} ${unit} quick preset`,
|
||||
? `${formatPresetValue(storedPresetDisplayValue)} ${unit} is already in quick presets`
|
||||
: `Added ${formatPresetValue(storedPresetDisplayValue)} ${unit} quick preset`,
|
||||
variant: alreadyExists ? 'info' : 'success'
|
||||
});
|
||||
};
|
||||
@@ -110,6 +179,17 @@ export default function SpeedLimiterView() {
|
||||
});
|
||||
};
|
||||
|
||||
const changeUnit = (nextUnit: SpeedUnit) => {
|
||||
if (nextUnit === unit) return;
|
||||
setValue(String(convertSpeedValue(Number(value), unit, nextUnit)));
|
||||
setCustomPresetValue(String(convertSpeedValue(Number(customPresetValue), unit, nextUnit)));
|
||||
setUnit(nextUnit);
|
||||
};
|
||||
|
||||
const currentDisplayValue = Number.isFinite(Number(value)) && Number(value) > 0
|
||||
? value
|
||||
: String(speedValueFromKiB(1, unit));
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex h-full flex-col overflow-hidden bg-main-bg">
|
||||
<WindowDragRegion />
|
||||
@@ -118,7 +198,8 @@ export default function SpeedLimiterView() {
|
||||
<div className="flex items-center gap-3 text-[17px] font-semibold tracking-tight text-text-primary select-none">
|
||||
<button
|
||||
onClick={() => setEnabled(!enabled)}
|
||||
className={`relative inline-flex h-5 w-9 cursor-pointer items-center rounded-full transition-colors duration-200 ease-in-out focus:outline-none ${enabled ? 'bg-accent' : 'bg-item-hover'}`}
|
||||
disabled={isSaving}
|
||||
className={`relative inline-flex h-5 w-9 cursor-pointer items-center rounded-full transition-colors duration-200 ease-in-out focus:outline-none disabled:cursor-not-allowed ${enabled ? 'bg-accent' : 'bg-item-hover'}`}
|
||||
aria-checked={enabled}
|
||||
role="switch"
|
||||
>
|
||||
@@ -131,9 +212,9 @@ export default function SpeedLimiterView() {
|
||||
<span className={`rounded-full px-2.5 py-1 text-[11px] font-semibold ${
|
||||
enabled ? 'bg-accent/15 text-accent' : 'bg-item-hover text-text-muted'
|
||||
}`}>
|
||||
{enabled ? `${value} ${unit}` : 'Unlimited'}
|
||||
{enabled ? `${currentDisplayValue} ${unit}` : 'Unlimited'}
|
||||
</span>
|
||||
<button onClick={save} className="app-button app-button-primary ml-auto px-3 text-[11px]">
|
||||
<button onClick={() => void save()} disabled={isSaving} className="app-button app-button-primary ml-auto px-3 text-[11px] disabled:opacity-50">
|
||||
<Save size={14} /> Save Limit
|
||||
</button>
|
||||
</div>
|
||||
@@ -144,16 +225,17 @@ export default function SpeedLimiterView() {
|
||||
<Gauge size={18} className="text-accent" /> Global Speed Limit
|
||||
</div>
|
||||
<p className="max-w-2xl text-[12px] leading-relaxed text-text-muted">
|
||||
Applies to new and active aria2 transfers and yt-dlp media downloads. Per-download limits still take precedence.
|
||||
Applies to new and active aria2 transfers and new yt-dlp media downloads. Explicit per-download limits remain attached to those transfers.
|
||||
</p>
|
||||
|
||||
<div className="mt-6 flex items-center gap-3">
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
min={speedValueFromKiB(1, unit)}
|
||||
step="any"
|
||||
value={value}
|
||||
disabled={!enabled}
|
||||
onChange={event => setValue(Math.max(1, Number(event.target.value) || 1))}
|
||||
disabled={!enabled || isSaving}
|
||||
onChange={event => setValue(event.target.value)}
|
||||
className="app-control w-28 px-3 py-2 text-right font-mono"
|
||||
/>
|
||||
<div className="flex rounded-md border border-border-modal bg-bg-input p-1">
|
||||
@@ -161,8 +243,8 @@ export default function SpeedLimiterView() {
|
||||
<button
|
||||
key={option}
|
||||
type="button"
|
||||
disabled={!enabled}
|
||||
onClick={() => setUnit(option)}
|
||||
disabled={!enabled || isSaving}
|
||||
onClick={() => changeUnit(option)}
|
||||
className={`rounded px-3 py-1.5 text-[12px] font-medium ${
|
||||
unit === option ? 'bg-accent text-white' : 'text-text-secondary hover:bg-item-hover'
|
||||
}`}
|
||||
@@ -187,7 +269,7 @@ export default function SpeedLimiterView() {
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!enabled}
|
||||
disabled={!enabled || isSaving}
|
||||
onClick={() => preset(presetValue)}
|
||||
className="h-full flex-1 px-3 text-left disabled:opacity-50"
|
||||
>
|
||||
@@ -195,7 +277,7 @@ export default function SpeedLimiterView() {
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!enabled}
|
||||
disabled={!enabled || isSaving}
|
||||
onClick={() => removePreset(presetValue)}
|
||||
className="flex h-full w-7 items-center justify-center border-l border-border-modal text-text-muted transition-colors hover:bg-red-500/10 hover:text-red-400 focus-visible:bg-red-500/10 focus-visible:text-red-400 disabled:opacity-50"
|
||||
title={`Remove ${formatPresetValue(displayValue)} ${unit} preset`}
|
||||
@@ -209,17 +291,18 @@ export default function SpeedLimiterView() {
|
||||
<div className="ml-1 flex h-8 items-center gap-1.5 rounded-md border border-border-modal bg-bg-input px-2">
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
min={speedValueFromKiB(1, unit)}
|
||||
step="any"
|
||||
value={customPresetValue}
|
||||
disabled={!enabled}
|
||||
onChange={event => setCustomPresetValue(Math.max(1, Number(event.target.value) || 1))}
|
||||
disabled={!enabled || isSaving}
|
||||
onChange={event => setCustomPresetValue(event.target.value)}
|
||||
className="w-12 bg-transparent text-right font-mono text-[12px] text-text-primary outline-none disabled:opacity-50"
|
||||
aria-label={`Custom preset in ${unit}`}
|
||||
/>
|
||||
<span className="text-[11px] text-text-muted">{unit}</span>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!enabled}
|
||||
disabled={!enabled || isSaving}
|
||||
onClick={applyCustomPreset}
|
||||
className="app-icon-button h-6 w-6 disabled:opacity-50"
|
||||
title="Add quick preset"
|
||||
|
||||
@@ -14,6 +14,7 @@ export interface ToastMessage {
|
||||
variant?: ToastVariant;
|
||||
duration?: number;
|
||||
isActionable?: boolean;
|
||||
onDismiss?: () => void;
|
||||
}
|
||||
|
||||
interface ToastState extends ToastMessage {
|
||||
@@ -21,7 +22,7 @@ interface ToastState extends ToastMessage {
|
||||
}
|
||||
|
||||
interface ToastContextType {
|
||||
addToast: (toast: Omit<ToastMessage, 'id'>) => void;
|
||||
addToast: (toast: Omit<ToastMessage, 'id'>) => string;
|
||||
removeToast: (id: string) => void;
|
||||
}
|
||||
|
||||
@@ -33,10 +34,12 @@ export const ToastProvider: React.FC<{ children: ReactNode }> = ({ children }) =
|
||||
|
||||
const addToast = useCallback((toast: Omit<ToastMessage, 'id'>) => {
|
||||
nextToastId.current += 1;
|
||||
const id = `toast-${nextToastId.current}`;
|
||||
setToasts(prev => {
|
||||
const next = [...prev, { ...toast, id: `toast-${nextToastId.current}` }];
|
||||
const next = [...prev, { ...toast, id }];
|
||||
return next.slice(-MAX_VISIBLE_TOASTS);
|
||||
});
|
||||
return id;
|
||||
}, []);
|
||||
|
||||
const removeToast = useCallback((id: string) => {
|
||||
@@ -70,6 +73,7 @@ const ToastItem: React.FC<{ toast: ToastState; removeToast: (id: string) => void
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
const timerStartedAt = useRef<number | null>(null);
|
||||
const remainingDuration = useRef<number | null>(null);
|
||||
const onDismissCalled = useRef(false);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const frame = requestAnimationFrame(() => setIsMounted(true));
|
||||
@@ -99,6 +103,16 @@ const ToastItem: React.FC<{ toast: ToastState; removeToast: (id: string) => void
|
||||
};
|
||||
}, [toast, isHovered, removeToast]);
|
||||
|
||||
useEffect(() => {
|
||||
const dismiss = () => {
|
||||
if (onDismissCalled.current) return;
|
||||
onDismissCalled.current = true;
|
||||
toast.onDismiss?.();
|
||||
};
|
||||
if (toast.exiting) dismiss();
|
||||
return dismiss;
|
||||
}, [toast.exiting, toast.onDismiss]);
|
||||
|
||||
useEffect(() => {
|
||||
if (toast.exiting) {
|
||||
const fallbackTimer = setTimeout(() => {
|
||||
|
||||
+51
-12
@@ -1,4 +1,6 @@
|
||||
@import "tailwindcss";
|
||||
@import "tailwindcss" source(none);
|
||||
@source "../src";
|
||||
@source "../index.html";
|
||||
|
||||
:root {
|
||||
/* Default/fallback Light (macOS Light Mode) */
|
||||
@@ -439,7 +441,7 @@ html[data-list-density="relaxed"] {
|
||||
color: hsl(var(--text-primary));
|
||||
font-weight: 500;
|
||||
font-size: 13px;
|
||||
transition: background-color 100ms ease, transform 100ms ease;
|
||||
transition: background-color 100ms ease, border-color 100ms ease, box-shadow 100ms ease, transform 100ms ease;
|
||||
box-shadow: 0 1px 1px hsl(var(--shadow-color));
|
||||
}
|
||||
|
||||
@@ -545,6 +547,7 @@ html[data-list-density="relaxed"] {
|
||||
.add-download-left {
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
background:
|
||||
radial-gradient(circle at 18% 0%, hsl(var(--accent-color) / 0.055), transparent 34%),
|
||||
hsl(var(--main-bg) / 0.56);
|
||||
@@ -643,6 +646,8 @@ html[data-list-density="relaxed"] {
|
||||
}
|
||||
|
||||
.add-download-preview {
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
border: 1px solid hsl(var(--border-modal));
|
||||
border-radius: 11px;
|
||||
background: hsl(var(--bg-input) / 0.35);
|
||||
@@ -650,11 +655,13 @@ html[data-list-density="relaxed"] {
|
||||
}
|
||||
|
||||
.add-download-preview-header {
|
||||
flex-shrink: 0;
|
||||
border-bottom: 1px solid hsl(var(--border-modal));
|
||||
background: hsl(var(--surface-raised) / 0.66);
|
||||
}
|
||||
|
||||
.add-download-preview-row {
|
||||
flex-shrink: 0;
|
||||
transition:
|
||||
background-color 100ms ease,
|
||||
border-color 100ms ease,
|
||||
@@ -836,15 +843,6 @@ html[data-list-density="relaxed"] {
|
||||
hsl(var(--bg-input));
|
||||
}
|
||||
|
||||
.add-download-button-cancel {
|
||||
color: hsl(var(--text-secondary));
|
||||
}
|
||||
|
||||
.add-download-button-cancel:hover:not(:disabled) {
|
||||
background: hsl(var(--item-hover));
|
||||
color: hsl(var(--text-primary));
|
||||
}
|
||||
|
||||
.add-download-button-primary {
|
||||
border-color: hsl(var(--accent-color) / 0.86);
|
||||
background:
|
||||
@@ -1017,6 +1015,7 @@ html[data-list-density="relaxed"] {
|
||||
z-index: 60;
|
||||
width: 8px;
|
||||
cursor: col-resize;
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
|
||||
.sidebar-resize-handle::after {
|
||||
@@ -1254,6 +1253,7 @@ html[data-list-density="relaxed"] {
|
||||
border-radius: 6px;
|
||||
color: hsl(var(--text-secondary));
|
||||
background: transparent;
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
|
||||
.sidebar-toggle-button:hover {
|
||||
@@ -1653,7 +1653,7 @@ html[data-list-density="relaxed"] {
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.main-control-group {
|
||||
.main-control-group {
|
||||
margin-left: auto;
|
||||
height: 28px;
|
||||
display: inline-flex;
|
||||
@@ -1662,6 +1662,7 @@ html[data-list-density="relaxed"] {
|
||||
border-radius: 15px;
|
||||
border: 1px solid hsl(var(--border-modal));
|
||||
background: hsl(var(--bg-input));
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
|
||||
.main-control-button {
|
||||
@@ -1672,6 +1673,7 @@ html[data-list-density="relaxed"] {
|
||||
justify-content: center;
|
||||
color: hsl(var(--text-secondary));
|
||||
border-right: 1px solid hsl(var(--border-color));
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
|
||||
.main-control-button svg {
|
||||
@@ -1776,6 +1778,7 @@ html[data-list-density="relaxed"] {
|
||||
}
|
||||
|
||||
.download-table-header {
|
||||
flex-shrink: 0;
|
||||
height: var(--download-header-height);
|
||||
display: grid;
|
||||
align-items: center;
|
||||
@@ -1842,6 +1845,7 @@ html[data-list-density="relaxed"] {
|
||||
z-index: 5;
|
||||
width: 8px;
|
||||
cursor: col-resize;
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
|
||||
.column-resize-handle::after {
|
||||
@@ -1877,12 +1881,14 @@ body.is-resizing .column-resize-handle:hover::after {
|
||||
|
||||
.download-table-list {
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.download-row {
|
||||
flex: 0 0 var(--download-row-height);
|
||||
height: var(--download-row-height);
|
||||
display: grid;
|
||||
align-items: center;
|
||||
@@ -1965,6 +1971,39 @@ html[data-list-density="relaxed"] .download-ghost-row {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.download-size-cell {
|
||||
justify-content: flex-end;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.download-size-cell > .download-size-progress {
|
||||
flex: 1 1 auto;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.download-size-cell > .download-size-total {
|
||||
flex: 0 0 auto;
|
||||
text-align: left;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.download-size-cell > .download-size-progress,
|
||||
.download-size-cell > .download-size-total {
|
||||
max-width: 100%;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.download-size-cell > .download-size-progress {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.download-row > .download-size-cell {
|
||||
padding-right: calc(var(--download-column-padding-x) + 6px);
|
||||
}
|
||||
|
||||
.download-cell-truncate > span,
|
||||
.download-cell-right > span {
|
||||
display: block;
|
||||
|
||||
+17
-2
@@ -5,7 +5,9 @@ import type { DownloadCategory } from './bindings/DownloadCategory';
|
||||
import type { DownloadProgressEvent } from './bindings/DownloadProgressEvent';
|
||||
import type { DownloadStateEvent } from './bindings/DownloadStateEvent';
|
||||
import type { ExtensionDownload } from './bindings/ExtensionDownload';
|
||||
import type { ExtensionCookieScope } from './bindings/ExtensionCookieScope';
|
||||
import type { MediaMetadata } from './bindings/MediaMetadata';
|
||||
import type { MediaPlaylistMetadata } from './bindings/MediaPlaylistMetadata';
|
||||
import type { MetadataResponse } from './bindings/MetadataResponse';
|
||||
import type { EngineStatusItem } from './bindings/EngineStatusItem';
|
||||
import type { PostQueueAction } from './bindings/PostQueueAction';
|
||||
@@ -17,13 +19,17 @@ import type { PlatformInfo } from './bindings/PlatformInfo';
|
||||
|
||||
type CommandMap = {
|
||||
fetch_metadata: {
|
||||
args: { url: string; userAgent: string | null; username: string | null; password: string | null; headers: string | null; cookies: string | null; proxy: string | null };
|
||||
args: { url: string; userAgent: string | null; username: string | null; password: string | null; headers: string | null; cookies: string | null; cookieScopes: Array<ExtensionCookieScope> | null; proxy: string | null; deferCookies?: boolean };
|
||||
result: MetadataResponse;
|
||||
};
|
||||
fetch_media_metadata: {
|
||||
args: { url: string; cookieBrowser: string | null; userAgent: string | null; username: string | null; password: string | null; headers: string | null; cookies: string | null; proxy: string | null };
|
||||
result: MediaMetadata;
|
||||
};
|
||||
fetch_media_playlist_metadata: {
|
||||
args: { url: string; cookieBrowser: string | null; userAgent: string | null; username: string | null; password: string | null; headers: string | null; cookies: string | null; proxy: string | null };
|
||||
result: MediaPlaylistMetadata;
|
||||
};
|
||||
get_aria2_engine_status: { args: undefined; result: EngineStatusItem };
|
||||
get_ytdlp_engine_status: { args: undefined; result: EngineStatusItem };
|
||||
get_ffmpeg_engine_status: { args: undefined; result: EngineStatusItem };
|
||||
@@ -49,14 +55,22 @@ type CommandMap = {
|
||||
set_keychain_password: { args: { id: string; password: string }; result: void };
|
||||
get_keychain_password: { args: { id: string }; result: string };
|
||||
delete_keychain_password: { args: { id: string }; result: void };
|
||||
save_site_login: {
|
||||
args: { id: string; urlPattern: string; username: string; password: string };
|
||||
result: void;
|
||||
};
|
||||
delete_site_login: { args: { id: string }; result: void };
|
||||
check_file_exists: { args: { path: string }; result: boolean };
|
||||
toggle_tray_icon: { args: { show: boolean }; result: void };
|
||||
set_extension_pairing_token: { args: { token: string }; result: void };
|
||||
get_extension_server_port: { args: undefined; result: number | null };
|
||||
hydrate_extension_pairing_token: { args: undefined; result: PairingTokenHydration };
|
||||
get_session_pairing_token: { args: undefined; result: PairingTokenHydration };
|
||||
regenerate_pairing_token: { args: undefined; result: PairingTokenHydration };
|
||||
grant_keychain_access: { args: undefined; result: PairingTokenHydration };
|
||||
acknowledge_pairing_token_change: { args: undefined; result: void };
|
||||
set_extension_frontend_ready: { args: { ready: boolean }; result: void };
|
||||
ack_extension_download: { args: { requestId: string }; result: void };
|
||||
get_system_proxy: { args: undefined; result: string | null };
|
||||
get_file_category: { args: { filename: string }; result: DownloadCategory };
|
||||
check_for_updates: { args: undefined; result: ReleaseCheckOutcome };
|
||||
@@ -71,7 +85,7 @@ type CommandMap = {
|
||||
args: { baseFolder: string; subfolders: Record<string, string> };
|
||||
result: void;
|
||||
};
|
||||
export_logs: { args: Record<string, never>; result: string };
|
||||
export_logs: { args: { destination?: string }; result: string };
|
||||
read_logs: { args: { limit: number }; result: string[] };
|
||||
clear_logs: { args: undefined; result: void };
|
||||
toggle_log_pause: { args: { pause: boolean }; result: void };
|
||||
@@ -82,6 +96,7 @@ type CommandMap = {
|
||||
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[] };
|
||||
move_many_in_queue: { args: { ids: string[]; queueId: string; direction: 'up' | 'down' }; result: string[] };
|
||||
remove_from_queue: { args: { id: string }; result: boolean };
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { create } from 'zustand';
|
||||
import type { DownloadProgressEvent } from '../bindings/DownloadProgressEvent';
|
||||
|
||||
interface DownloadProgressState {
|
||||
progressMap: Record<string, DownloadProgressEvent>;
|
||||
updateDownloadProgress: (id: string, payload: DownloadProgressEvent) => void;
|
||||
clearDownloadProgress: (id: string) => void;
|
||||
}
|
||||
|
||||
export const useDownloadProgressStore = create<DownloadProgressState>((set) => ({
|
||||
progressMap: {},
|
||||
updateDownloadProgress: (id, payload) =>
|
||||
set((state) => ({
|
||||
progressMap: {
|
||||
...state.progressMap,
|
||||
[id]: payload,
|
||||
},
|
||||
})),
|
||||
clearDownloadProgress: (id) =>
|
||||
set((state) => {
|
||||
if (!(id in state.progressMap)) return state;
|
||||
const next = { ...state.progressMap };
|
||||
delete next[id];
|
||||
return { progressMap: next };
|
||||
}),
|
||||
}));
|
||||
@@ -1,5 +1,10 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { initDownloadListener, useDownloadProgressStore } from './downloadStore';
|
||||
import {
|
||||
clearDownloadControlIntents,
|
||||
setDownloadControlIntent,
|
||||
useDownloadStore
|
||||
} from './useDownloadStore';
|
||||
import * as ipc from '../ipc';
|
||||
|
||||
vi.mock('../ipc', () => ({
|
||||
@@ -10,7 +15,9 @@ vi.mock('../ipc', () => ({
|
||||
describe('useDownloadProgressStore', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(ipc.invokeCommand).mockResolvedValue(undefined);
|
||||
useDownloadProgressStore.setState({ progressMap: {} });
|
||||
clearDownloadControlIntents();
|
||||
});
|
||||
|
||||
it('prunes terminal progress entries', () => {
|
||||
@@ -45,4 +52,336 @@ describe('useDownloadProgressStore', () => {
|
||||
releaseSecond();
|
||||
expect(unlisten).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('ignores late progress and opposite terminal events from an older lifecycle', async () => {
|
||||
const handlers: Record<string, (event: any) => void> = {};
|
||||
vi.mocked(ipc.listenEvent).mockImplementation((event, handler) => {
|
||||
handlers[event] = handler as (event: any) => void;
|
||||
return Promise.resolve(vi.fn());
|
||||
});
|
||||
useDownloadStore.setState({
|
||||
downloads: [{
|
||||
id: 'terminal',
|
||||
url: 'https://example.com/file',
|
||||
fileName: 'file.bin',
|
||||
status: 'completed',
|
||||
category: 'Other',
|
||||
dateAdded: ''
|
||||
}]
|
||||
});
|
||||
|
||||
const release = await initDownloadListener();
|
||||
handlers['download-progress']({ payload: {
|
||||
id: 'terminal',
|
||||
fraction: 0.1,
|
||||
speed: '1 MB/s',
|
||||
eta: '10s',
|
||||
size: '1 MB',
|
||||
size_is_final: false
|
||||
} });
|
||||
handlers['download-state']({ payload: {
|
||||
id: 'terminal',
|
||||
status: 'failed',
|
||||
error: 'stale failure'
|
||||
} });
|
||||
|
||||
expect(useDownloadProgressStore.getState().progressMap).toEqual({});
|
||||
expect(useDownloadStore.getState().downloads[0].status).toBe('completed');
|
||||
release();
|
||||
});
|
||||
|
||||
it('clears progress when events arrive after a download row was removed', async () => {
|
||||
const handlers: Record<string, (event: any) => void> = {};
|
||||
vi.mocked(ipc.listenEvent).mockImplementation((event, handler) => {
|
||||
handlers[event] = handler as (event: any) => void;
|
||||
return Promise.resolve(vi.fn());
|
||||
});
|
||||
useDownloadProgressStore.getState().updateDownloadProgress('removed', {
|
||||
id: 'removed',
|
||||
fraction: 0.8,
|
||||
speed: '1 MB/s',
|
||||
eta: '2s',
|
||||
size: '8 MB',
|
||||
size_is_final: false
|
||||
});
|
||||
useDownloadStore.setState({ downloads: [] });
|
||||
|
||||
const release = await initDownloadListener();
|
||||
handlers['download-progress']({ payload: {
|
||||
id: 'removed',
|
||||
fraction: 0.9,
|
||||
speed: '2 MB/s',
|
||||
eta: '1s',
|
||||
size: '9 MB',
|
||||
size_is_final: false
|
||||
} });
|
||||
|
||||
expect(useDownloadProgressStore.getState().progressMap).toEqual({});
|
||||
release();
|
||||
});
|
||||
|
||||
it('drops stale progress when a download returns to a queued lifecycle', async () => {
|
||||
const handlers: Record<string, (event: any) => void> = {};
|
||||
vi.mocked(ipc.listenEvent).mockImplementation((event, handler) => {
|
||||
handlers[event] = handler as (event: any) => void;
|
||||
return Promise.resolve(vi.fn());
|
||||
});
|
||||
useDownloadStore.setState({
|
||||
downloads: [{
|
||||
id: 'reused',
|
||||
url: 'https://example.com/file',
|
||||
fileName: 'file.bin',
|
||||
status: 'downloading',
|
||||
category: 'Other',
|
||||
dateAdded: ''
|
||||
}]
|
||||
});
|
||||
|
||||
const release = await initDownloadListener();
|
||||
handlers['download-progress']({ payload: {
|
||||
id: 'reused',
|
||||
fraction: 0.8,
|
||||
speed: '1 MB/s',
|
||||
eta: '2s',
|
||||
size: '8 MB',
|
||||
size_is_final: false
|
||||
} });
|
||||
handlers['download-state']({ payload: {
|
||||
id: 'reused',
|
||||
status: 'queued'
|
||||
} });
|
||||
handlers['download-progress']({ payload: {
|
||||
id: 'reused',
|
||||
fraction: 0.9,
|
||||
speed: '2 MB/s',
|
||||
eta: '1s',
|
||||
size: '9 MB',
|
||||
size_is_final: false
|
||||
} });
|
||||
|
||||
expect(useDownloadProgressStore.getState().progressMap).toEqual({});
|
||||
release();
|
||||
});
|
||||
|
||||
it('snapshots live progress before clearing it on a terminal transition', async () => {
|
||||
const handlers: Record<string, (event: any) => void> = {};
|
||||
vi.mocked(ipc.listenEvent).mockImplementation((event, handler) => {
|
||||
handlers[event] = handler as (event: any) => void;
|
||||
return Promise.resolve(vi.fn());
|
||||
});
|
||||
useDownloadStore.setState({
|
||||
downloads: [{
|
||||
id: 'snapshot',
|
||||
url: 'https://example.com/file',
|
||||
fileName: 'file.bin',
|
||||
status: 'downloading',
|
||||
category: 'Other',
|
||||
dateAdded: ''
|
||||
}]
|
||||
});
|
||||
|
||||
const release = await initDownloadListener();
|
||||
handlers['download-progress']({ payload: {
|
||||
id: 'snapshot',
|
||||
fraction: 0.8,
|
||||
speed: '1 MB/s',
|
||||
eta: '2s',
|
||||
size: '8 MB',
|
||||
size_is_final: false,
|
||||
downloaded_bytes: 8192,
|
||||
total_bytes: 10240,
|
||||
total_is_estimate: true
|
||||
} });
|
||||
handlers['download-state']({ payload: {
|
||||
id: 'snapshot',
|
||||
status: 'paused'
|
||||
} });
|
||||
|
||||
const row = useDownloadStore.getState().downloads[0];
|
||||
expect(row.status).toBe('paused');
|
||||
expect(row.fraction).toBe(0.8);
|
||||
expect(row.downloadedBytes).toBe(8192);
|
||||
expect(row.totalBytes).toBe(10240);
|
||||
expect(row.totalIsEstimate).toBe(true);
|
||||
expect(useDownloadProgressStore.getState().progressMap).toEqual({});
|
||||
release();
|
||||
});
|
||||
|
||||
it('drops a persisted temporary media estimate when fragmented progress has no total', async () => {
|
||||
const handlers: Record<string, (event: any) => void> = {};
|
||||
vi.mocked(ipc.listenEvent).mockImplementation((event, handler) => {
|
||||
handlers[event] = handler as (event: any) => void;
|
||||
return Promise.resolve(vi.fn());
|
||||
});
|
||||
useDownloadStore.setState({
|
||||
downloads: [{
|
||||
id: 'stale-media-estimate',
|
||||
url: 'https://youtube.com/watch?v=stale',
|
||||
fileName: 'video.mkv',
|
||||
status: 'downloading',
|
||||
category: 'Movies',
|
||||
dateAdded: '',
|
||||
isMedia: true,
|
||||
downloadedBytes: 11989,
|
||||
totalBytes: 1024,
|
||||
totalIsEstimate: true,
|
||||
size: '~85.7 MB'
|
||||
}]
|
||||
});
|
||||
|
||||
const release = await initDownloadListener();
|
||||
handlers['download-progress']({ payload: {
|
||||
id: 'stale-media-estimate',
|
||||
fraction: 0.38,
|
||||
speed: '2.7 MB/s',
|
||||
eta: '7s',
|
||||
size: null,
|
||||
size_is_final: false,
|
||||
downloaded_bytes: 13000,
|
||||
total_bytes: null,
|
||||
total_is_estimate: null
|
||||
} });
|
||||
|
||||
expect(useDownloadStore.getState().downloads[0]).toMatchObject({
|
||||
downloadedBytes: 13000,
|
||||
size: undefined
|
||||
});
|
||||
expect(useDownloadStore.getState().downloads[0].totalBytes).toBeUndefined();
|
||||
expect(useDownloadStore.getState().downloads[0].totalIsEstimate).toBeUndefined();
|
||||
release();
|
||||
});
|
||||
|
||||
it('removes a stale tiny media size after restart when byte counters were volatile', async () => {
|
||||
const handlers: Record<string, (event: any) => void> = {};
|
||||
vi.mocked(ipc.listenEvent).mockImplementation((event, handler) => {
|
||||
handlers[event] = handler as (event: any) => void;
|
||||
return Promise.resolve(vi.fn());
|
||||
});
|
||||
useDownloadStore.setState({
|
||||
downloads: [{
|
||||
id: 'stale-media-size',
|
||||
url: 'https://youtube.com/watch?v=stale-size',
|
||||
fileName: 'video.mkv',
|
||||
status: 'downloading',
|
||||
category: 'Movies',
|
||||
dateAdded: '',
|
||||
isMedia: true,
|
||||
size: '~1.00 KB'
|
||||
}]
|
||||
});
|
||||
|
||||
const release = await initDownloadListener();
|
||||
handlers['download-progress']({ payload: {
|
||||
id: 'stale-media-size',
|
||||
fraction: 0.01,
|
||||
speed: '2.7 MB/s',
|
||||
eta: '7s',
|
||||
size: null,
|
||||
size_is_final: false,
|
||||
downloaded_bytes: 2048,
|
||||
total_bytes: null,
|
||||
total_is_estimate: null
|
||||
} });
|
||||
|
||||
expect(useDownloadStore.getState().downloads[0].size).toBeUndefined();
|
||||
release();
|
||||
});
|
||||
|
||||
it('ignores stale active state events after pause but accepts terminal reconciliation', async () => {
|
||||
const handlers: Record<string, (event: any) => void> = {};
|
||||
vi.mocked(ipc.listenEvent).mockImplementation((event, handler) => {
|
||||
handlers[event] = handler as (event: any) => void;
|
||||
return Promise.resolve(vi.fn());
|
||||
});
|
||||
useDownloadStore.setState({
|
||||
downloads: [{
|
||||
id: 'paused-race',
|
||||
url: 'https://example.com/file',
|
||||
fileName: 'file.bin',
|
||||
status: 'paused',
|
||||
category: 'Other',
|
||||
dateAdded: ''
|
||||
}]
|
||||
});
|
||||
|
||||
const release = await initDownloadListener();
|
||||
handlers['download-state']({ payload: {
|
||||
id: 'paused-race',
|
||||
status: 'downloading'
|
||||
} });
|
||||
expect(useDownloadStore.getState().downloads[0].status).toBe('paused');
|
||||
|
||||
handlers['download-state']({ payload: {
|
||||
id: 'paused-race',
|
||||
status: 'completed'
|
||||
} });
|
||||
expect(useDownloadStore.getState().downloads[0].status).toBe('completed');
|
||||
release();
|
||||
});
|
||||
|
||||
it('ignores a stale paused event during resume and accepts the new active state', async () => {
|
||||
const handlers: Record<string, (event: any) => void> = {};
|
||||
vi.mocked(ipc.listenEvent).mockImplementation((event, handler) => {
|
||||
handlers[event] = handler as (event: any) => void;
|
||||
return Promise.resolve(vi.fn());
|
||||
});
|
||||
useDownloadStore.setState({
|
||||
downloads: [{
|
||||
id: 'resume-race',
|
||||
url: 'https://example.com/file',
|
||||
fileName: 'file.bin',
|
||||
status: 'queued',
|
||||
category: 'Other',
|
||||
dateAdded: ''
|
||||
}]
|
||||
});
|
||||
setDownloadControlIntent('resume-race', 'resume');
|
||||
|
||||
const release = await initDownloadListener();
|
||||
handlers['download-state']({ payload: {
|
||||
id: 'resume-race',
|
||||
status: 'paused'
|
||||
} });
|
||||
expect(useDownloadStore.getState().downloads[0].status).toBe('queued');
|
||||
|
||||
handlers['download-state']({ payload: {
|
||||
id: 'resume-race',
|
||||
status: 'downloading'
|
||||
} });
|
||||
expect(useDownloadStore.getState().downloads[0].status).toBe('downloading');
|
||||
release();
|
||||
});
|
||||
|
||||
it('allows a later genuine pause after consuming the stale resume event', async () => {
|
||||
const handlers: Record<string, (event: any) => void> = {};
|
||||
vi.mocked(ipc.listenEvent).mockImplementation((event, handler) => {
|
||||
handlers[event] = handler as (event: any) => void;
|
||||
return Promise.resolve(vi.fn());
|
||||
});
|
||||
useDownloadStore.setState({
|
||||
downloads: [{
|
||||
id: 'resume-pause-race',
|
||||
url: 'https://example.com/file',
|
||||
fileName: 'file.bin',
|
||||
status: 'queued',
|
||||
category: 'Other',
|
||||
dateAdded: ''
|
||||
}]
|
||||
});
|
||||
setDownloadControlIntent('resume-pause-race', 'resume');
|
||||
|
||||
const release = await initDownloadListener();
|
||||
handlers['download-state']({ payload: {
|
||||
id: 'resume-pause-race',
|
||||
status: 'paused'
|
||||
} });
|
||||
expect(useDownloadStore.getState().downloads[0].status).toBe('queued');
|
||||
|
||||
handlers['download-state']({ payload: {
|
||||
id: 'resume-pause-race',
|
||||
status: 'paused'
|
||||
} });
|
||||
expect(useDownloadStore.getState().downloads[0].status).toBe('paused');
|
||||
release();
|
||||
});
|
||||
});
|
||||
|
||||
+149
-81
@@ -1,36 +1,18 @@
|
||||
import { create } from 'zustand';
|
||||
import type { UnlistenFn } from '@tauri-apps/api/event';
|
||||
import type { DownloadProgressEvent } from '../bindings/DownloadProgressEvent';
|
||||
import type { DownloadStatus } from '../bindings/DownloadStatus';
|
||||
import { listenEvent as listen } from '../ipc';
|
||||
import type { DownloadItem } from '../bindings/DownloadItem';
|
||||
import { categoryForFileName } from '../utils/downloads';
|
||||
import { useDownloadProgressStore } from './downloadProgressStore';
|
||||
|
||||
interface DownloadProgressState {
|
||||
progressMap: Record<string, DownloadProgressEvent>;
|
||||
updateDownloadProgress: (id: string, payload: DownloadProgressEvent) => void;
|
||||
clearDownloadProgress: (id: string) => void;
|
||||
}
|
||||
import {
|
||||
clearDownloadControlIntent,
|
||||
downloadControlIntentFor,
|
||||
hasStaleTemporaryMediaEstimate,
|
||||
useDownloadStore
|
||||
} from './useDownloadStore';
|
||||
|
||||
import { useDownloadStore } from './useDownloadStore';
|
||||
|
||||
export const useDownloadProgressStore = create<DownloadProgressState>((set) => ({
|
||||
progressMap: {},
|
||||
updateDownloadProgress: (id, payload) =>
|
||||
set((state) => ({
|
||||
progressMap: {
|
||||
...state.progressMap,
|
||||
[id]: payload,
|
||||
},
|
||||
})),
|
||||
clearDownloadProgress: (id) =>
|
||||
set((state) => {
|
||||
if (!(id in state.progressMap)) return state;
|
||||
const next = { ...state.progressMap };
|
||||
delete next[id];
|
||||
return { progressMap: next };
|
||||
}),
|
||||
}));
|
||||
export { useDownloadProgressStore } from './downloadProgressStore';
|
||||
|
||||
let unlistenProgress: UnlistenFn | null = null;
|
||||
let unlistenState: UnlistenFn | null = null;
|
||||
@@ -52,75 +34,161 @@ const startDownloadListeners = async () => {
|
||||
const registrations = await Promise.allSettled([
|
||||
listen('download-progress', (event) => {
|
||||
const payload = event.payload;
|
||||
useDownloadProgressStore.getState().updateDownloadProgress(payload.id, payload);
|
||||
|
||||
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);
|
||||
}
|
||||
if (!current) {
|
||||
// A removed row can still have one queued sidecar event in flight.
|
||||
// Do not let that event recreate an orphaned progress entry.
|
||||
useDownloadProgressStore.getState().clearDownloadProgress(payload.id);
|
||||
return;
|
||||
}
|
||||
// A sidecar can flush one last progress chunk after a pause, failure,
|
||||
// completion, or lifecycle reset. Do not let that stale chunk repopulate
|
||||
// the live progress map or overwrite a later lifecycle's first frame.
|
||||
if (!['downloading', 'processing'].includes(current.status)) {
|
||||
useDownloadProgressStore.getState().clearDownloadProgress(payload.id);
|
||||
return;
|
||||
}
|
||||
useDownloadProgressStore.getState().updateDownloadProgress(payload.id, payload);
|
||||
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 (payload.downloaded_bytes !== null && payload.downloaded_bytes !== undefined) {
|
||||
updates.downloadedBytes = payload.downloaded_bytes;
|
||||
}
|
||||
if (payload.total_bytes !== null && payload.total_bytes !== undefined) {
|
||||
updates.totalBytes = payload.total_bytes;
|
||||
}
|
||||
if (payload.total_is_estimate !== null && payload.total_is_estimate !== undefined) {
|
||||
updates.totalIsEstimate = payload.total_is_estimate;
|
||||
}
|
||||
const observedDownloadedBytes = Math.max(
|
||||
current.downloadedBytes ?? 0,
|
||||
payload.downloaded_bytes ?? 0
|
||||
);
|
||||
// Older lifecycles may have persisted yt-dlp's temporary fragmented
|
||||
// estimate (often 1 KiB). Once actual bytes exceed it and the current
|
||||
// progress frame has no reliable total, discard that stale denominator
|
||||
// so it cannot survive a pause, queue transition, or app restart.
|
||||
if (payload.total_bytes == null && hasStaleTemporaryMediaEstimate({
|
||||
isMedia: current.isMedia,
|
||||
downloadedBytes: observedDownloadedBytes,
|
||||
totalBytes: current.totalBytes,
|
||||
totalIsEstimate: current.totalIsEstimate,
|
||||
size: current.size
|
||||
})) {
|
||||
updates.size = undefined;
|
||||
updates.totalBytes = undefined;
|
||||
updates.totalIsEstimate = undefined;
|
||||
}
|
||||
if (Object.keys(updates).length > 0) {
|
||||
mainStore.updateDownload(payload.id, updates);
|
||||
}
|
||||
}),
|
||||
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;
|
||||
if (!current) {
|
||||
useDownloadProgressStore.getState().clearDownloadProgress(payload.id);
|
||||
return;
|
||||
}
|
||||
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')) {
|
||||
return;
|
||||
}
|
||||
// resume_download queues the row before the backend can emit its new
|
||||
// active state. A paused event already emitted by the old lifecycle may
|
||||
// arrive in that gap. Do not let it overwrite the queued transition;
|
||||
// otherwise the guard below would reject the legitimate downloading
|
||||
// event and leave the row visibly paused forever.
|
||||
if (status === 'paused' &&
|
||||
current.status === 'queued' &&
|
||||
downloadControlIntentFor(payload.id) === 'resume') {
|
||||
// Consume only the stale pause event that caused the resume race.
|
||||
// A later real pause must be allowed through even if the backend has
|
||||
// not emitted a new active state yet.
|
||||
clearDownloadControlIntent(payload.id, 'resume');
|
||||
return;
|
||||
}
|
||||
if (status === 'downloading' || status === 'processing' ||
|
||||
status === 'completed' || status === 'failed') {
|
||||
clearDownloadControlIntent(payload.id, 'resume');
|
||||
}
|
||||
if (status === 'paused') {
|
||||
clearDownloadControlIntent(payload.id, 'pause');
|
||||
}
|
||||
|
||||
const progress = useDownloadProgressStore.getState().progressMap[payload.id];
|
||||
const updates: Partial<DownloadItem> = {
|
||||
status,
|
||||
...(progress ? { fraction: progress.fraction } : {}),
|
||||
...(payload.error ? { lastError: payload.error } : {})
|
||||
};
|
||||
if (!payload.error && status !== 'failed' && status !== 'retrying') {
|
||||
updates.lastError = undefined;
|
||||
}
|
||||
if (payload.fileName && payload.fileName !== current.fileName) {
|
||||
updates.fileName = payload.fileName;
|
||||
updates.category = categoryForFileName(payload.fileName);
|
||||
}
|
||||
if (status !== 'downloading') {
|
||||
updates.speed = '-';
|
||||
updates.eta = '-';
|
||||
}
|
||||
mainStore.updateDownload(payload.id, updates);
|
||||
// Prevent stale lifecycle events from moving a paused row back into an
|
||||
// active state. A pause request can finish before one already-emitted
|
||||
// worker event reaches the frontend. Resume paths set the row to queued
|
||||
// before asking the backend to resume, so an active event arriving while
|
||||
// the row is still paused cannot represent a new lifecycle.
|
||||
if ((current.status === 'completed' || current.status === 'failed') &&
|
||||
status !== current.status) {
|
||||
return;
|
||||
}
|
||||
if (current.status === 'paused' &&
|
||||
status !== 'paused' &&
|
||||
status !== 'completed' &&
|
||||
status !== 'failed') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (status === 'completed' || status === 'failed' || status === 'paused') {
|
||||
mainStore.setPendingOrder(mainStore.pendingOrder.filter(id => id !== payload.id));
|
||||
} else if (status === 'queued') {
|
||||
if (!mainStore.pendingOrder.includes(payload.id)) {
|
||||
mainStore.setPendingOrder([...mainStore.pendingOrder, payload.id]);
|
||||
}
|
||||
}
|
||||
const progress = useDownloadProgressStore.getState().progressMap[payload.id];
|
||||
if (['queued', 'retrying', 'completed', 'failed', 'paused'].includes(status)) {
|
||||
useDownloadProgressStore.getState().clearDownloadProgress(payload.id);
|
||||
}
|
||||
const updates: Partial<DownloadItem> = {
|
||||
status,
|
||||
...(progress ? {
|
||||
fraction: progress.fraction,
|
||||
...(progress.downloaded_bytes != null
|
||||
? { downloadedBytes: progress.downloaded_bytes }
|
||||
: {}),
|
||||
...(progress.total_bytes != null
|
||||
? { totalBytes: progress.total_bytes }
|
||||
: {}),
|
||||
...(progress.total_is_estimate != null
|
||||
? { totalIsEstimate: progress.total_is_estimate }
|
||||
: {})
|
||||
} : {}),
|
||||
...(payload.error ? { lastError: payload.error } : {}),
|
||||
...((status === 'downloading' || status === 'retrying')
|
||||
? { lastTry: new Date().toISOString() }
|
||||
: {})
|
||||
};
|
||||
if (!payload.error && status !== 'failed' && status !== 'retrying') {
|
||||
updates.lastError = undefined;
|
||||
}
|
||||
if (payload.fileName && payload.fileName !== current.fileName) {
|
||||
updates.fileName = payload.fileName;
|
||||
updates.category = categoryForFileName(payload.fileName);
|
||||
}
|
||||
if (status !== 'downloading') {
|
||||
updates.speed = '-';
|
||||
updates.eta = '-';
|
||||
}
|
||||
mainStore.updateDownload(payload.id, updates);
|
||||
|
||||
if (status === 'queued' || status === 'downloading' || status === 'processing' || status === 'retrying') {
|
||||
mainStore.registerBackendIds([payload.id]);
|
||||
} else if (status === 'completed' || status === 'failed') {
|
||||
mainStore.unregisterBackendIds([payload.id]);
|
||||
}
|
||||
if (status === 'completed' || status === 'failed' || status === 'paused') {
|
||||
useDownloadProgressStore.getState().clearDownloadProgress(payload.id);
|
||||
if (status === 'completed' || status === 'failed' || status === 'paused') {
|
||||
mainStore.setPendingOrder(mainStore.pendingOrder.filter(id => id !== payload.id));
|
||||
} else if (status === 'queued') {
|
||||
if (!mainStore.pendingOrder.includes(payload.id)) {
|
||||
mainStore.setPendingOrder([...mainStore.pendingOrder, payload.id]);
|
||||
}
|
||||
}
|
||||
|
||||
if (status === 'queued' || status === 'downloading' || status === 'processing' || status === 'retrying') {
|
||||
mainStore.registerBackendIds([payload.id]);
|
||||
} else if (status === 'completed' || status === 'failed') {
|
||||
mainStore.unregisterBackendIds([payload.id]);
|
||||
}
|
||||
}),
|
||||
listen('tray-action', (event) => {
|
||||
const mainStore = useDownloadStore.getState();
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+727
-230
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,182 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
runSettingsPersistenceTransaction,
|
||||
subscribeToSettingsPersistenceErrors,
|
||||
useSettingsStore
|
||||
} from './useSettingsStore';
|
||||
import * as ipc from '../ipc';
|
||||
import type { PairingTokenHydration } from '../bindings/PairingTokenHydration';
|
||||
|
||||
vi.mock('../ipc', () => ({
|
||||
invokeCommand: vi.fn()
|
||||
}));
|
||||
|
||||
vi.mock('../utils/logger', () => ({
|
||||
info: vi.fn()
|
||||
}));
|
||||
|
||||
describe('useSettingsStore global speed limit persistence', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
useSettingsStore.setState({ globalSpeedLimit: '2M' });
|
||||
});
|
||||
|
||||
it('keeps the saved value when the backend rejects a limit change', async () => {
|
||||
vi.mocked(ipc.invokeCommand).mockRejectedValueOnce(new Error('aria2 unavailable'));
|
||||
|
||||
await expect(useSettingsStore.getState().setGlobalSpeedLimit('3M')).rejects.toThrow('aria2 unavailable');
|
||||
|
||||
expect(useSettingsStore.getState().globalSpeedLimit).toBe('2M');
|
||||
expect(ipc.invokeCommand).toHaveBeenCalledWith('set_global_speed_limit', { limit: '3M' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('useSettingsStore credential-store startup flow', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
useSettingsStore.setState({
|
||||
extensionPairingToken: '',
|
||||
isPairingTokenPersistent: false,
|
||||
keychainAccessGranted: false,
|
||||
keychainAccessVersion: '',
|
||||
keychainAccessReady: false,
|
||||
keychainPromptDismissed: false,
|
||||
showKeychainModal: false
|
||||
});
|
||||
});
|
||||
|
||||
it('loads the session pairing token without invoking the credential store', async () => {
|
||||
vi.mocked(ipc.invokeCommand).mockResolvedValueOnce({
|
||||
token: 'session-token',
|
||||
tokenChanged: false,
|
||||
persistent: false,
|
||||
error: null
|
||||
});
|
||||
|
||||
await useSettingsStore.getState().hydrateSessionPairingToken();
|
||||
|
||||
expect(ipc.invokeCommand).toHaveBeenCalledWith('get_session_pairing_token');
|
||||
expect(useSettingsStore.getState().extensionPairingToken).toBe('session-token');
|
||||
expect(useSettingsStore.getState().isPairingTokenPersistent).toBe(false);
|
||||
});
|
||||
|
||||
it('clears the approved startup state when the user defers credential access', () => {
|
||||
useSettingsStore.setState({ keychainAccessGranted: true });
|
||||
|
||||
useSettingsStore.getState().dismissKeychainPrompt('1.0.5');
|
||||
|
||||
expect(useSettingsStore.getState().keychainAccessGranted).toBe(false);
|
||||
expect(useSettingsStore.getState().keychainAccessReady).toBe(false);
|
||||
expect(useSettingsStore.getState().keychainAccessVersion).toBe('1.0.5');
|
||||
expect(useSettingsStore.getState().keychainPromptDismissed).toBe(true);
|
||||
});
|
||||
|
||||
it('opens the consent modal instead of regenerating through the credential store', async () => {
|
||||
await expect(useSettingsStore.getState().regeneratePairingToken())
|
||||
.rejects.toThrow('Grant credential-store access before regenerating the pairing token.');
|
||||
|
||||
expect(ipc.invokeCommand).not.toHaveBeenCalledWith('regenerate_pairing_token');
|
||||
expect(useSettingsStore.getState().showKeychainModal).toBe(true);
|
||||
});
|
||||
|
||||
it('does not apply pairing hydration after startup becomes inactive', async () => {
|
||||
vi.mocked(ipc.invokeCommand).mockResolvedValueOnce({
|
||||
token: 'stale-token',
|
||||
tokenChanged: true,
|
||||
persistent: true,
|
||||
error: null
|
||||
});
|
||||
|
||||
await expect(useSettingsStore.getState().hydratePairingToken(() => false)).resolves.toBe(false);
|
||||
|
||||
expect(ipc.invokeCommand).toHaveBeenCalledWith('hydrate_extension_pairing_token');
|
||||
expect(useSettingsStore.getState().extensionPairingToken).toBe('');
|
||||
expect(useSettingsStore.getState().isPairingTokenPersistent).toBe(false);
|
||||
});
|
||||
|
||||
it('does not apply session hydration after startup becomes inactive', async () => {
|
||||
vi.mocked(ipc.invokeCommand).mockResolvedValueOnce({
|
||||
token: 'stale-session-token',
|
||||
tokenChanged: false,
|
||||
persistent: false,
|
||||
error: null
|
||||
});
|
||||
|
||||
await useSettingsStore.getState().hydrateSessionPairingToken(() => false);
|
||||
|
||||
expect(ipc.invokeCommand).toHaveBeenCalledWith('get_session_pairing_token');
|
||||
expect(useSettingsStore.getState().extensionPairingToken).toBe('');
|
||||
expect(useSettingsStore.getState().isPairingTokenPersistent).toBe(false);
|
||||
});
|
||||
|
||||
it('shares a concurrent pairing hydration request', async () => {
|
||||
let resolveRequest!: (value: PairingTokenHydration) => void;
|
||||
const request = new Promise<PairingTokenHydration>(resolve => {
|
||||
resolveRequest = resolve;
|
||||
});
|
||||
let hydrationRequestCount = 0;
|
||||
vi.mocked(ipc.invokeCommand).mockImplementation(async (command: string) => {
|
||||
if (command === 'hydrate_extension_pairing_token') {
|
||||
hydrationRequestCount += 1;
|
||||
return request;
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
|
||||
const first = useSettingsStore.getState().hydratePairingToken();
|
||||
const second = useSettingsStore.getState().hydratePairingToken();
|
||||
|
||||
expect(hydrationRequestCount).toBe(1);
|
||||
resolveRequest({
|
||||
token: 'shared-token',
|
||||
tokenChanged: false,
|
||||
persistent: true,
|
||||
error: null
|
||||
});
|
||||
await Promise.all([first, second]);
|
||||
|
||||
expect(useSettingsStore.getState().extensionPairingToken).toBe('shared-token');
|
||||
expect(useSettingsStore.getState().isPairingTokenPersistent).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('useSettingsStore persistence failures', () => {
|
||||
it('keeps settings writes queued behind a credential transaction', async () => {
|
||||
const events: string[] = [];
|
||||
vi.mocked(ipc.invokeCommand).mockImplementation(async command => {
|
||||
if (command === 'db_save_settings') events.push('settings-write');
|
||||
return undefined;
|
||||
});
|
||||
|
||||
await runSettingsPersistenceTransaction(async () => {
|
||||
events.push('transaction-start');
|
||||
useSettingsStore.setState({ theme: 'dark' });
|
||||
events.push('transaction-end');
|
||||
});
|
||||
await new Promise(resolve => setTimeout(resolve, 0));
|
||||
|
||||
expect(events.slice(0, 2)).toEqual(['transaction-start', 'transaction-end']);
|
||||
expect(events).toContain('settings-write');
|
||||
});
|
||||
|
||||
it('reports a database save failure and retries the next settings update', async () => {
|
||||
vi.clearAllMocks();
|
||||
await new Promise(resolve => setTimeout(resolve, 0));
|
||||
|
||||
const onPersistenceError = vi.fn();
|
||||
const unsubscribe = subscribeToSettingsPersistenceErrors(onPersistenceError);
|
||||
vi.mocked(ipc.invokeCommand).mockRejectedValueOnce(new Error('database unavailable'));
|
||||
|
||||
useSettingsStore.setState({ theme: 'dark' });
|
||||
await new Promise(resolve => setTimeout(resolve, 0));
|
||||
|
||||
expect(onPersistenceError).toHaveBeenCalledTimes(1);
|
||||
|
||||
vi.mocked(ipc.invokeCommand).mockResolvedValueOnce(undefined);
|
||||
useSettingsStore.setState({ theme: 'light' });
|
||||
await new Promise(resolve => setTimeout(resolve, 0));
|
||||
|
||||
expect(onPersistenceError).toHaveBeenCalledTimes(1);
|
||||
unsubscribe();
|
||||
});
|
||||
});
|
||||
+251
-61
@@ -8,6 +8,7 @@ import type { ListRowDensity } from '../bindings/ListRowDensity';
|
||||
import type { MediaCookieSource } from '../bindings/MediaCookieSource';
|
||||
import type { PostQueueAction } from '../bindings/PostQueueAction';
|
||||
import type { PersistedSettings } from '../bindings/PersistedSettings';
|
||||
import type { PairingTokenHydration } from '../bindings/PairingTokenHydration';
|
||||
import type { ProxyMode } from '../bindings/ProxyMode';
|
||||
import type { SchedulerSettings } from '../bindings/SchedulerSettings';
|
||||
import type { SettingsTab } from '../bindings/SettingsTab';
|
||||
@@ -17,11 +18,98 @@ import {
|
||||
DEFAULT_CATEGORY_SUBFOLDERS,
|
||||
normalizeDownloadLocationSettings
|
||||
} from '../utils/downloadLocations';
|
||||
import { normalizeSpeedLimitForBackend } from '../utils/downloads';
|
||||
|
||||
let settingsSave = Promise.resolve();
|
||||
let settingsQueue: Promise<void> = Promise.resolve();
|
||||
let pairingTokenHydrationRequest: Promise<PairingTokenHydration> | null = null;
|
||||
const settingsPersistenceErrorListeners = new Set<() => void>();
|
||||
let settingsPersistenceFailed = false;
|
||||
const DEFAULT_SCHEDULER_QUEUE_ID = '00000000-0000-0000-0000-000000000001';
|
||||
export const DEFAULT_SPEED_LIMIT_PRESET_VALUES = [1, 5, 10];
|
||||
|
||||
export const subscribeToSettingsPersistenceErrors = (listener: () => void): (() => void) => {
|
||||
settingsPersistenceErrorListeners.add(listener);
|
||||
if (settingsPersistenceFailed) listener();
|
||||
return () => settingsPersistenceErrorListeners.delete(listener);
|
||||
};
|
||||
|
||||
const enqueueSettingsTask = <T>(task: () => Promise<T>): Promise<T> => {
|
||||
const result = settingsQueue.then(task, task);
|
||||
settingsQueue = result.then(() => undefined, () => undefined);
|
||||
return result;
|
||||
};
|
||||
|
||||
const requestPairingTokenHydration = (): Promise<PairingTokenHydration> => {
|
||||
if (!pairingTokenHydrationRequest) {
|
||||
pairingTokenHydrationRequest = invoke('hydrate_extension_pairing_token')
|
||||
.finally(() => {
|
||||
pairingTokenHydrationRequest = null;
|
||||
});
|
||||
}
|
||||
return pairingTokenHydrationRequest;
|
||||
};
|
||||
|
||||
export const runSettingsPersistenceTransaction = <T>(
|
||||
operation: () => Promise<T>
|
||||
): Promise<T> => enqueueSettingsTask(operation);
|
||||
|
||||
const notifySettingsPersistenceError = () => {
|
||||
if (settingsPersistenceFailed) return;
|
||||
settingsPersistenceFailed = true;
|
||||
for (const listener of settingsPersistenceErrorListeners) {
|
||||
try {
|
||||
listener();
|
||||
} catch (error) {
|
||||
console.error('Settings persistence error listener failed', error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const THEME_VALUES = ['system', 'light', 'dark', 'dracula', 'nord'] as const;
|
||||
const APP_FONT_SIZE_VALUES = ['small', 'standard', 'large'] as const;
|
||||
const LIST_ROW_DENSITY_VALUES = ['compact', 'standard', 'relaxed'] as const;
|
||||
const PROXY_MODE_VALUES = ['none', 'system', 'custom'] as const;
|
||||
const MEDIA_COOKIE_SOURCE_VALUES = [
|
||||
'none', 'safari', 'chrome', 'chromium', 'firefox', 'edge', 'brave', 'opera', 'vivaldi', 'whale'
|
||||
] as const;
|
||||
const SETTINGS_TAB_VALUES = [
|
||||
'downloads', 'lookandfeel', 'network', 'locations', 'sitelogins', 'power', 'engine', 'integrations', 'about'
|
||||
] as const;
|
||||
|
||||
type PersistedSettingsSnapshot = PersistedSettings & {
|
||||
keychainPromptDismissed: boolean;
|
||||
keychainAccessVersion: string;
|
||||
};
|
||||
|
||||
const clampSettingInteger = (
|
||||
value: unknown,
|
||||
minimum: number,
|
||||
maximum: number,
|
||||
fallback: number
|
||||
) => {
|
||||
const numeric = typeof value === 'number' ? value : Number(value);
|
||||
if (!Number.isFinite(numeric)) return fallback;
|
||||
return Math.min(maximum, Math.max(minimum, Math.trunc(numeric)));
|
||||
};
|
||||
|
||||
const isAllowedSetting = <T extends string>(values: readonly T[], value: unknown): value is T =>
|
||||
typeof value === 'string' && values.includes(value as T);
|
||||
|
||||
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
||||
value !== null && typeof value === 'object' && !Array.isArray(value);
|
||||
|
||||
const sanitizeSiteLogins = (value: unknown): SiteLogin[] => {
|
||||
if (!Array.isArray(value)) return [];
|
||||
return value.filter(isRecord).filter((login): login is SiteLogin =>
|
||||
typeof login.id === 'string'
|
||||
&& typeof login.urlPattern === 'string'
|
||||
&& typeof login.username === 'string'
|
||||
);
|
||||
};
|
||||
|
||||
const persistedBoolean = (value: unknown, fallback: boolean) =>
|
||||
typeof value === 'boolean' ? value : fallback;
|
||||
|
||||
const tauriStorage: StateStorage = {
|
||||
getItem: async (name: string): Promise<string | null> => {
|
||||
if (name === 'firelink-settings') {
|
||||
@@ -36,12 +124,15 @@ const tauriStorage: StateStorage = {
|
||||
},
|
||||
setItem: async (name: string, value: string): Promise<void> => {
|
||||
if (name === 'firelink-settings') {
|
||||
settingsSave = settingsSave
|
||||
.then(() => invoke('db_save_settings', { data: value }))
|
||||
.catch(e => {
|
||||
console.error("Failed to save settings to DB", e);
|
||||
});
|
||||
await settingsSave;
|
||||
await enqueueSettingsTask(async () => {
|
||||
try {
|
||||
await invoke('db_save_settings', { data: value });
|
||||
settingsPersistenceFailed = false;
|
||||
} catch {
|
||||
console.error('Failed to save settings to DB');
|
||||
notifySettingsPersistenceError();
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
removeItem: async (_name: string): Promise<void> => {
|
||||
@@ -53,10 +144,10 @@ const tauriStorage: StateStorage = {
|
||||
* Keychain identifier for the browser-extension pairing token. The token is an
|
||||
* HMAC shared secret and is therefore persisted via the OS keychain rather
|
||||
* than the user-data database. Legacy plaintext values are migrated into the
|
||||
* Keychain before being removed from persisted settings.
|
||||
* Keychain before being removed from persisted settings. Portable mode is the
|
||||
* explicit exception: its pairing token is persisted with the portable folder
|
||||
* so extension pairing follows that folder.
|
||||
*/
|
||||
const PAIRING_TOKEN_KEYCHAIN_ID = 'extension-pairing-token';
|
||||
|
||||
export type {
|
||||
ActiveView,
|
||||
AppFontSize,
|
||||
@@ -90,12 +181,14 @@ export interface SettingsState {
|
||||
schedulerLastStartKey: string;
|
||||
schedulerLastStopKey: string;
|
||||
lastCustomSpeedLimitKiB: number;
|
||||
lastCustomSpeedLimitUnit: string;
|
||||
|
||||
// Replicated SwiftUI App Settings
|
||||
perServerConnections: number;
|
||||
maxAutomaticRetries: number;
|
||||
showNotifications: boolean;
|
||||
playCompletionSound: boolean;
|
||||
autoAddClipboardLinks: boolean;
|
||||
appFontSize: AppFontSize;
|
||||
listRowDensity: ListRowDensity;
|
||||
showDockBadge: boolean;
|
||||
@@ -111,6 +204,9 @@ export interface SettingsState {
|
||||
extensionPairingToken: string;
|
||||
isPairingTokenPersistent: boolean;
|
||||
keychainAccessGranted: boolean;
|
||||
keychainAccessVersion: string;
|
||||
keychainAccessReady: boolean;
|
||||
keychainPromptDismissed: boolean;
|
||||
autoCheckUpdates: boolean;
|
||||
showKeychainModal: boolean;
|
||||
|
||||
@@ -118,7 +214,7 @@ export interface SettingsState {
|
||||
setBaseDownloadFolder: (path: string) => void;
|
||||
approveDownloadRoot: (path: string) => Promise<string>;
|
||||
setMaxConcurrentDownloads: (count: number) => void;
|
||||
setGlobalSpeedLimit: (limit: string) => void;
|
||||
setGlobalSpeedLimit: (limit: string) => Promise<void>;
|
||||
setSpeedLimitPresetValues: (values: number[]) => void;
|
||||
setLogsEnabled: (enabled: boolean) => void;
|
||||
setActiveView: (view: ActiveView) => void;
|
||||
@@ -129,12 +225,14 @@ export interface SettingsState {
|
||||
setSchedulerLastStartKey: (key: string) => void;
|
||||
setSchedulerLastStopKey: (key: string) => void;
|
||||
setLastCustomSpeedLimitKiB: (limit: number) => void;
|
||||
setLastCustomSpeedLimitUnit: (unit: string) => void;
|
||||
toggleSidebar: () => void;
|
||||
|
||||
setPerServerConnections: (count: number) => void;
|
||||
setMaxAutomaticRetries: (count: number) => void;
|
||||
setShowNotifications: (show: boolean) => void;
|
||||
setPlayCompletionSound: (play: boolean) => void;
|
||||
setAutoAddClipboardLinks: (enabled: boolean) => void;
|
||||
setAppFontSize: (size: AppFontSize) => void;
|
||||
setListRowDensity: (density: ListRowDensity) => void;
|
||||
setShowDockBadge: (show: boolean) => void;
|
||||
@@ -154,39 +252,16 @@ export interface SettingsState {
|
||||
removeSiteLogin: (id: string) => void;
|
||||
regeneratePairingToken: () => Promise<void>;
|
||||
setAutoCheckUpdates: (autoCheckUpdates: boolean) => void;
|
||||
hydratePairingToken: () => Promise<boolean>;
|
||||
hydratePairingToken: (isCurrent?: () => boolean) => Promise<boolean>;
|
||||
setShowKeychainModal: (show: boolean) => void;
|
||||
setKeychainAccessReady: (ready: boolean) => void;
|
||||
dismissKeychainPrompt: (version?: string) => void;
|
||||
hydrateSessionPairingToken: (isCurrent?: () => boolean) => Promise<void>;
|
||||
}
|
||||
|
||||
const generateSecureToken = () => {
|
||||
try {
|
||||
const cryptoObj = typeof window !== 'undefined'
|
||||
? (window as Window & { msCrypto?: Crypto }).crypto
|
||||
|| (window as Window & { msCrypto?: Crypto }).msCrypto
|
||||
: null;
|
||||
if (cryptoObj && cryptoObj.getRandomValues) {
|
||||
const arr = new Uint8Array(24);
|
||||
cryptoObj.getRandomValues(arr);
|
||||
let binary = '';
|
||||
for (let i = 0; i < arr.byteLength; i++) {
|
||||
binary += String.fromCharCode(arr[i]);
|
||||
}
|
||||
return btoa(binary);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("Secure token generation failed, falling back to random characters", e);
|
||||
}
|
||||
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
|
||||
let token = '';
|
||||
for (let i = 0; i < 32; i++) {
|
||||
token += chars.charAt(Math.floor(Math.random() * chars.length));
|
||||
}
|
||||
return token;
|
||||
};
|
||||
|
||||
export const useSettingsStore = create<SettingsState>()(
|
||||
persist(
|
||||
(set, _get) => ({
|
||||
(set, get) => ({
|
||||
theme: 'system',
|
||||
baseDownloadFolder: '~/Downloads',
|
||||
categorySubfoldersEnabled: true,
|
||||
@@ -215,12 +290,14 @@ export const useSettingsStore = create<SettingsState>()(
|
||||
schedulerLastStartKey: '',
|
||||
schedulerLastStopKey: '',
|
||||
lastCustomSpeedLimitKiB: 1024,
|
||||
lastCustomSpeedLimitUnit: 'MB/s',
|
||||
|
||||
// Replicated SwiftUI defaults
|
||||
perServerConnections: 16,
|
||||
maxAutomaticRetries: 3,
|
||||
showNotifications: true,
|
||||
playCompletionSound: false,
|
||||
autoAddClipboardLinks: false,
|
||||
appFontSize: 'standard',
|
||||
listRowDensity: 'standard',
|
||||
showDockBadge: true,
|
||||
@@ -234,8 +311,11 @@ export const useSettingsStore = create<SettingsState>()(
|
||||
mediaCookieSource: 'none',
|
||||
siteLogins: [],
|
||||
extensionPairingToken: '',
|
||||
isPairingTokenPersistent: true,
|
||||
isPairingTokenPersistent: false,
|
||||
keychainAccessGranted: false,
|
||||
keychainAccessVersion: '',
|
||||
keychainAccessReady: false,
|
||||
keychainPromptDismissed: false,
|
||||
autoCheckUpdates: true,
|
||||
showKeychainModal: false,
|
||||
|
||||
@@ -255,9 +335,14 @@ export const useSettingsStore = create<SettingsState>()(
|
||||
},
|
||||
setMaxConcurrentDownloads: (max) => {
|
||||
info('Settings updated: maxConcurrentDownloads');
|
||||
set({ maxConcurrentDownloads: max });
|
||||
set({
|
||||
maxConcurrentDownloads: clampSettingInteger(max, 1, 12, 3)
|
||||
});
|
||||
},
|
||||
setGlobalSpeedLimit: (limit) => {
|
||||
setGlobalSpeedLimit: async (limit) => {
|
||||
await invoke('set_global_speed_limit', {
|
||||
limit: normalizeSpeedLimitForBackend(limit)
|
||||
});
|
||||
info('Settings updated: globalSpeedLimit');
|
||||
set({ globalSpeedLimit: limit });
|
||||
},
|
||||
@@ -271,12 +356,18 @@ export const useSettingsStore = create<SettingsState>()(
|
||||
setSchedulerLastStartKey: (schedulerLastStartKey) => set({ schedulerLastStartKey }),
|
||||
setSchedulerLastStopKey: (schedulerLastStopKey) => set({ schedulerLastStopKey }),
|
||||
setLastCustomSpeedLimitKiB: (lastCustomSpeedLimitKiB) => set({ lastCustomSpeedLimitKiB }),
|
||||
setLastCustomSpeedLimitUnit: (lastCustomSpeedLimitUnit) => set({ lastCustomSpeedLimitUnit }),
|
||||
toggleSidebar: () => set((state) => ({ isSidebarVisible: !state.isSidebarVisible })),
|
||||
|
||||
setPerServerConnections: (perServerConnections) => set({ perServerConnections }),
|
||||
setMaxAutomaticRetries: (maxAutomaticRetries) => set({ maxAutomaticRetries }),
|
||||
setPerServerConnections: (perServerConnections) => set({
|
||||
perServerConnections: clampSettingInteger(perServerConnections, 1, 16, 16)
|
||||
}),
|
||||
setMaxAutomaticRetries: (maxAutomaticRetries) => set({
|
||||
maxAutomaticRetries: clampSettingInteger(maxAutomaticRetries, 0, 10, 3)
|
||||
}),
|
||||
setShowNotifications: (showNotifications) => set({ showNotifications }),
|
||||
setPlayCompletionSound: (playCompletionSound) => set({ playCompletionSound }),
|
||||
setAutoAddClipboardLinks: (autoAddClipboardLinks) => set({ autoAddClipboardLinks }),
|
||||
setAppFontSize: (appFontSize) => set({ appFontSize }),
|
||||
setListRowDensity: (listRowDensity) => set({ listRowDensity }),
|
||||
setShowDockBadge: (showDockBadge) => {
|
||||
@@ -331,26 +422,54 @@ export const useSettingsStore = create<SettingsState>()(
|
||||
siteLogins: state.siteLogins.filter((login) => login.id !== id)
|
||||
})),
|
||||
regeneratePairingToken: async () => {
|
||||
const token = generateSecureToken();
|
||||
await invoke('set_keychain_password', { id: PAIRING_TOKEN_KEYCHAIN_ID, password: token });
|
||||
set({ extensionPairingToken: token });
|
||||
const current = get();
|
||||
if (!current.keychainAccessReady && !current.isPairingTokenPersistent) {
|
||||
set({ showKeychainModal: true });
|
||||
throw new Error('Grant credential-store access before regenerating the pairing token.');
|
||||
}
|
||||
const result = await invoke('regenerate_pairing_token');
|
||||
if (!result.persistent) {
|
||||
throw new Error(result.error || 'Credential store access is unavailable.');
|
||||
}
|
||||
set({
|
||||
extensionPairingToken: result.token,
|
||||
isPairingTokenPersistent: true,
|
||||
showKeychainModal: false
|
||||
});
|
||||
},
|
||||
hydratePairingToken: async () => {
|
||||
// Always use the safe hydration path that never touches the OS keychain
|
||||
// on its own. The modal will be shown when needed and only the explicit
|
||||
// "Grant Access" action (→ grant_keychain_access) triggers the OS prompt.
|
||||
const result = await invoke('hydrate_extension_pairing_token');
|
||||
hydratePairingToken: async (isCurrent) => {
|
||||
// The backend migrates legacy settings copies and reads the token from
|
||||
// the credential store after the app state is ready to receive it.
|
||||
// Portable mode remains the explicit folder-contained exception.
|
||||
const result = await requestPairingTokenHydration();
|
||||
if (isCurrent && !isCurrent()) return false;
|
||||
set({
|
||||
extensionPairingToken: result.token,
|
||||
isPairingTokenPersistent: result.persistent
|
||||
isPairingTokenPersistent: result.persistent,
|
||||
showKeychainModal: !result.persistent && !get().keychainPromptDismissed
|
||||
});
|
||||
if (!result.persistent) {
|
||||
set({ showKeychainModal: true });
|
||||
}
|
||||
return result.tokenChanged;
|
||||
},
|
||||
hydrateSessionPairingToken: async (isCurrent) => {
|
||||
const result = await invoke('get_session_pairing_token');
|
||||
if (isCurrent && !isCurrent()) return;
|
||||
set({
|
||||
extensionPairingToken: result.token,
|
||||
isPairingTokenPersistent: false,
|
||||
keychainAccessReady: false
|
||||
});
|
||||
},
|
||||
setAutoCheckUpdates: (autoCheckUpdates: boolean) => set({ autoCheckUpdates }),
|
||||
setShowKeychainModal: (show: boolean) => set({ showKeychainModal: show }),
|
||||
setKeychainAccessReady: (ready: boolean) => set({ keychainAccessReady: ready }),
|
||||
dismissKeychainPrompt: (version?: string) => set(state => ({
|
||||
keychainAccessGranted: false,
|
||||
isPairingTokenPersistent: false,
|
||||
keychainAccessReady: false,
|
||||
keychainAccessVersion: version || state.keychainAccessVersion,
|
||||
keychainPromptDismissed: true,
|
||||
showKeychainModal: false
|
||||
})),
|
||||
}),
|
||||
{
|
||||
name: 'firelink-settings',
|
||||
@@ -389,7 +508,7 @@ export const useSettingsStore = create<SettingsState>()(
|
||||
logsEnabled: persisted.logsEnabled === true
|
||||
} as SettingsState;
|
||||
},
|
||||
partialize: (state): PersistedSettings => ({
|
||||
partialize: (state): PersistedSettingsSnapshot => ({
|
||||
theme: state.theme,
|
||||
baseDownloadFolder: state.baseDownloadFolder,
|
||||
categorySubfoldersEnabled: state.categorySubfoldersEnabled,
|
||||
@@ -408,11 +527,13 @@ export const useSettingsStore = create<SettingsState>()(
|
||||
schedulerLastStartKey: state.schedulerLastStartKey,
|
||||
schedulerLastStopKey: state.schedulerLastStopKey,
|
||||
lastCustomSpeedLimitKiB: state.lastCustomSpeedLimitKiB,
|
||||
lastCustomSpeedLimitUnit: state.lastCustomSpeedLimitUnit,
|
||||
|
||||
perServerConnections: state.perServerConnections,
|
||||
maxAutomaticRetries: state.maxAutomaticRetries,
|
||||
showNotifications: state.showNotifications,
|
||||
playCompletionSound: state.playCompletionSound,
|
||||
autoAddClipboardLinks: state.autoAddClipboardLinks,
|
||||
appFontSize: state.appFontSize,
|
||||
listRowDensity: state.listRowDensity,
|
||||
showDockBadge: state.showDockBadge,
|
||||
@@ -425,8 +546,9 @@ export const useSettingsStore = create<SettingsState>()(
|
||||
preventsSleepWhileDownloading: state.preventsSleepWhileDownloading,
|
||||
mediaCookieSource: state.mediaCookieSource,
|
||||
siteLogins: state.siteLogins,
|
||||
extensionPairingToken: state.extensionPairingToken,
|
||||
keychainAccessGranted: state.keychainAccessGranted,
|
||||
keychainAccessVersion: state.keychainAccessVersion,
|
||||
keychainPromptDismissed: state.keychainPromptDismissed,
|
||||
autoCheckUpdates: state.autoCheckUpdates
|
||||
}),
|
||||
merge: (persistedState: unknown, currentState) => {
|
||||
@@ -438,9 +560,79 @@ export const useSettingsStore = create<SettingsState>()(
|
||||
...currentState,
|
||||
...persisted,
|
||||
...locations,
|
||||
extensionPairingToken: currentState.extensionPairingToken,
|
||||
keychainAccessReady: currentState.keychainAccessReady,
|
||||
theme: isAllowedSetting(THEME_VALUES, persisted.theme)
|
||||
? persisted.theme
|
||||
: currentState.theme,
|
||||
appFontSize: isAllowedSetting(APP_FONT_SIZE_VALUES, persisted.appFontSize)
|
||||
? persisted.appFontSize
|
||||
: currentState.appFontSize,
|
||||
listRowDensity: isAllowedSetting(LIST_ROW_DENSITY_VALUES, persisted.listRowDensity)
|
||||
? persisted.listRowDensity
|
||||
: currentState.listRowDensity,
|
||||
proxyMode: isAllowedSetting(PROXY_MODE_VALUES, persisted.proxyMode)
|
||||
? persisted.proxyMode
|
||||
: currentState.proxyMode,
|
||||
mediaCookieSource: isAllowedSetting(MEDIA_COOKIE_SOURCE_VALUES, persisted.mediaCookieSource)
|
||||
? persisted.mediaCookieSource
|
||||
: 'none',
|
||||
activeSettingsTab: isAllowedSetting(SETTINGS_TAB_VALUES, persisted.activeSettingsTab)
|
||||
? persisted.activeSettingsTab
|
||||
: currentState.activeSettingsTab,
|
||||
showNotifications: persistedBoolean(persisted.showNotifications, currentState.showNotifications),
|
||||
playCompletionSound: persistedBoolean(persisted.playCompletionSound, currentState.playCompletionSound),
|
||||
autoAddClipboardLinks: persistedBoolean(
|
||||
persisted.autoAddClipboardLinks,
|
||||
currentState.autoAddClipboardLinks
|
||||
),
|
||||
showDockBadge: persistedBoolean(persisted.showDockBadge, currentState.showDockBadge),
|
||||
showMenuBarIcon: persistedBoolean(persisted.showMenuBarIcon, currentState.showMenuBarIcon),
|
||||
askWhereToSaveEachFile: persistedBoolean(
|
||||
persisted.askWhereToSaveEachFile,
|
||||
currentState.askWhereToSaveEachFile
|
||||
),
|
||||
preventsSleepWhileDownloading: persistedBoolean(
|
||||
persisted.preventsSleepWhileDownloading,
|
||||
currentState.preventsSleepWhileDownloading
|
||||
),
|
||||
keychainAccessGranted: persistedBoolean(
|
||||
persisted.keychainAccessGranted,
|
||||
currentState.keychainAccessGranted
|
||||
),
|
||||
keychainAccessVersion: typeof persisted.keychainAccessVersion === 'string'
|
||||
? persisted.keychainAccessVersion
|
||||
: currentState.keychainAccessVersion,
|
||||
keychainPromptDismissed: persistedBoolean(
|
||||
persisted.keychainPromptDismissed,
|
||||
currentState.keychainPromptDismissed
|
||||
),
|
||||
autoCheckUpdates: persistedBoolean(persisted.autoCheckUpdates, currentState.autoCheckUpdates),
|
||||
maxConcurrentDownloads: clampSettingInteger(
|
||||
persisted.maxConcurrentDownloads,
|
||||
1,
|
||||
12,
|
||||
currentState.maxConcurrentDownloads
|
||||
),
|
||||
perServerConnections: clampSettingInteger(
|
||||
persisted.perServerConnections,
|
||||
1,
|
||||
16,
|
||||
currentState.perServerConnections
|
||||
),
|
||||
maxAutomaticRetries: clampSettingInteger(
|
||||
persisted.maxAutomaticRetries,
|
||||
0,
|
||||
10,
|
||||
currentState.maxAutomaticRetries
|
||||
),
|
||||
speedLimitPresetValues: Array.isArray(persisted.speedLimitPresetValues)
|
||||
? persisted.speedLimitPresetValues
|
||||
: currentState.speedLimitPresetValues,
|
||||
lastCustomSpeedLimitUnit: persisted.lastCustomSpeedLimitUnit === 'KB/s'
|
||||
|| persisted.lastCustomSpeedLimitUnit === 'MB/s'
|
||||
? persisted.lastCustomSpeedLimitUnit
|
||||
: currentState.lastCustomSpeedLimitUnit,
|
||||
logsEnabled: persisted.logsEnabled === true,
|
||||
approvedDownloadRoots: Array.isArray(persisted.approvedDownloadRoots)
|
||||
? persisted.approvedDownloadRoots
|
||||
@@ -453,10 +645,8 @@ export const useSettingsStore = create<SettingsState>()(
|
||||
? persisted.scheduler.selectedQueueIds
|
||||
: currentState.scheduler.selectedQueueIds
|
||||
},
|
||||
appFontSize: persisted.appFontSize || currentState.appFontSize,
|
||||
listRowDensity: persisted.listRowDensity || currentState.listRowDensity,
|
||||
siteLogins: Array.isArray(persisted.siteLogins)
|
||||
? persisted.siteLogins
|
||||
? sanitizeSiteLogins(persisted.siteLogins)
|
||||
: currentState.siteLogins
|
||||
});
|
||||
}
|
||||
|
||||
@@ -5,6 +5,8 @@ import {
|
||||
mediaFormatSelectorForRow,
|
||||
mediaFileNameForSelectedFormat,
|
||||
metadataSummaryMessage,
|
||||
isYouTubePlaylistUrl,
|
||||
playlistFilePrefix,
|
||||
reconcileDownloadRows,
|
||||
refreshFailedMetadataRows,
|
||||
updateRowIfCurrent,
|
||||
@@ -57,6 +59,168 @@ describe('add download metadata workflow', () => {
|
||||
expect(rows.map(item => item.status)).toEqual(['loading', 'invalid', 'invalid']);
|
||||
});
|
||||
|
||||
it('recognizes pure YouTube playlist URLs without changing video-plus-playlist behavior', () => {
|
||||
expect(isYouTubePlaylistUrl('https://www.youtube.com/playlist?list=PL123')).toBe(true);
|
||||
expect(isYouTubePlaylistUrl('https://www.youtube.com/playlist/?list=PL123')).toBe(true);
|
||||
expect(isYouTubePlaylistUrl('https://music.youtube.com/playlist?list=PL123')).toBe(true);
|
||||
expect(isYouTubePlaylistUrl('https://www.youtube.com/watch?v=video&list=PL123')).toBe(false);
|
||||
expect(isYouTubePlaylistUrl('https://example.com/playlist?list=PL123')).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps a playlist as one loading row until discovery succeeds', () => {
|
||||
const rows = reconcileDownloadRows(
|
||||
'https://www.youtube.com/playlist?list=PL123',
|
||||
[]
|
||||
);
|
||||
|
||||
expect(rows[0]).toMatchObject({
|
||||
isMedia: true,
|
||||
isPlaylist: true,
|
||||
status: 'loading'
|
||||
});
|
||||
});
|
||||
|
||||
it('expands playlist entries into independently identifiable media rows', () => {
|
||||
const playlistUrl = 'https://www.youtube.com/playlist?list=PL123';
|
||||
const rows = reconcileDownloadRows(
|
||||
playlistUrl,
|
||||
[],
|
||||
undefined,
|
||||
new Set(),
|
||||
undefined,
|
||||
{},
|
||||
{ [playlistUrl]: 4 },
|
||||
{
|
||||
[playlistUrl]: {
|
||||
title: 'Example playlist',
|
||||
playlist_id: 'PL123',
|
||||
entry_count: 2,
|
||||
skipped_entries: 1,
|
||||
truncated: false,
|
||||
entries: [
|
||||
{ id: 'one', url: 'https://www.youtube.com/watch?v=one', title: 'First', playlist_index: 1 },
|
||||
{ id: 'one-duplicate', url: 'https://www.youtube.com/watch?v=one', title: 'Duplicate', playlist_index: 2 },
|
||||
{ id: 'two', url: 'https://www.youtube.com/watch?v=two', title: 'Second', playlist_index: 3 }
|
||||
]
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
expect(rows).toHaveLength(2);
|
||||
expect(rows.map(item => item.sourceUrl)).toEqual([
|
||||
'https://www.youtube.com/watch?v=one',
|
||||
'https://www.youtube.com/watch?v=two'
|
||||
]);
|
||||
expect(rows[0]).toMatchObject({
|
||||
file: '001 - First',
|
||||
isMedia: true,
|
||||
playlistSourceUrl: playlistUrl,
|
||||
playlistTitle: 'Example playlist',
|
||||
playlistIndex: 1,
|
||||
playlistCount: 2,
|
||||
requestContextVersion: 4,
|
||||
status: 'loading'
|
||||
});
|
||||
expect(rows[1].file).toBe('003 - Second');
|
||||
expect(rows.every(item => !item.isPlaylist)).toBe(true);
|
||||
});
|
||||
|
||||
it('uses a stable three-digit playlist prefix and widens it for four-digit lists', () => {
|
||||
expect(playlistFilePrefix(1, 12)).toBe('001 - ');
|
||||
expect(playlistFilePrefix(12, 12)).toBe('012 - ');
|
||||
expect(playlistFilePrefix(1000, 1000)).toBe('1000 - ');
|
||||
expect(playlistFilePrefix(undefined, 12)).toBe('');
|
||||
});
|
||||
|
||||
it('propagates a playlist selection to entries discovered after the user deselects it', () => {
|
||||
const playlistUrl = 'https://www.youtube.com/playlist?list=PL123';
|
||||
const rows = reconcileDownloadRows(
|
||||
playlistUrl,
|
||||
[],
|
||||
undefined,
|
||||
new Set(),
|
||||
undefined,
|
||||
{},
|
||||
{},
|
||||
{
|
||||
[playlistUrl]: {
|
||||
title: 'Example playlist',
|
||||
playlist_id: 'PL123',
|
||||
entry_count: 1,
|
||||
skipped_entries: 0,
|
||||
truncated: false,
|
||||
entries: [{ id: 'one', url: 'https://www.youtube.com/watch?v=one', title: 'First', playlist_index: 1 }]
|
||||
}
|
||||
},
|
||||
{ [playlistUrl]: false }
|
||||
);
|
||||
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0].selected).toBe(false);
|
||||
});
|
||||
|
||||
it('preserves entry-level selection when expanded rows are recreated', () => {
|
||||
const playlistUrl = 'https://www.youtube.com/playlist?list=PL123';
|
||||
const expansion = {
|
||||
[playlistUrl]: {
|
||||
title: 'Example playlist',
|
||||
playlist_id: 'PL123',
|
||||
entry_count: 2,
|
||||
skipped_entries: 0,
|
||||
truncated: false,
|
||||
entries: [
|
||||
{ id: 'one', url: 'https://www.youtube.com/watch?v=one', title: 'First', playlist_index: 1 },
|
||||
{ id: 'two', url: 'https://www.youtube.com/watch?v=two', title: 'Second', playlist_index: 2 }
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
const rows = reconcileDownloadRows(
|
||||
playlistUrl,
|
||||
[],
|
||||
undefined,
|
||||
new Set(),
|
||||
undefined,
|
||||
{},
|
||||
{},
|
||||
expansion,
|
||||
{
|
||||
'https://www.youtube.com/watch?v=one': false,
|
||||
'https://www.youtube.com/watch?v=two': true
|
||||
}
|
||||
);
|
||||
|
||||
expect(rows.map(item => item.selected)).toEqual([false, true]);
|
||||
});
|
||||
|
||||
it('does not leave a loading playlist row when every entry is already present', () => {
|
||||
const videoUrl = 'https://www.youtube.com/watch?v=one';
|
||||
const playlistUrl = 'https://www.youtube.com/playlist?list=PL123';
|
||||
const rows = reconcileDownloadRows(
|
||||
`${videoUrl}\n${playlistUrl}`,
|
||||
[],
|
||||
undefined,
|
||||
new Set(),
|
||||
undefined,
|
||||
{},
|
||||
{},
|
||||
{
|
||||
[playlistUrl]: {
|
||||
title: 'Example playlist',
|
||||
playlist_id: 'PL123',
|
||||
entry_count: 1,
|
||||
skipped_entries: 0,
|
||||
truncated: false,
|
||||
entries: [{ id: 'one', url: videoUrl, title: 'First', playlist_index: 1 }]
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0].sourceUrl).toBe(videoUrl);
|
||||
expect(rows.some(item => item.isPlaylist)).toBe(false);
|
||||
});
|
||||
|
||||
it('forces explicit extension media fetches through media metadata for any http page', () => {
|
||||
const rows = reconcileDownloadRows(
|
||||
'https://adult.example/watch/123',
|
||||
@@ -125,6 +289,74 @@ describe('add download metadata workflow', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('replaces a stale filename when a newer handoff supplies a new one', () => {
|
||||
const existing = row({
|
||||
file: 'old-name.zip',
|
||||
requestContextVersion: 1,
|
||||
generation: 2,
|
||||
size: '10 MB',
|
||||
sizeBytes: 10,
|
||||
resumable: true
|
||||
});
|
||||
|
||||
const refreshed = reconcileDownloadRows(
|
||||
existing.sourceUrl,
|
||||
[existing],
|
||||
undefined,
|
||||
new Set(),
|
||||
() => 'unused',
|
||||
{ [existing.sourceUrl]: 'new-name.zip' },
|
||||
{ [existing.sourceUrl]: 2 }
|
||||
);
|
||||
|
||||
expect(refreshed[0]).toMatchObject({
|
||||
file: 'new-name.zip',
|
||||
status: 'loading',
|
||||
generation: 3,
|
||||
requestContextVersion: 2,
|
||||
size: undefined,
|
||||
sizeBytes: undefined,
|
||||
resumable: undefined
|
||||
});
|
||||
});
|
||||
|
||||
it('drops stale playlist provenance when an entry remains after its playlist is removed', () => {
|
||||
const videoUrl = 'https://www.youtube.com/watch?v=one';
|
||||
const playlistUrl = 'https://www.youtube.com/playlist?list=PL123';
|
||||
const existing = row({
|
||||
sourceUrl: videoUrl,
|
||||
downloadUrl: videoUrl,
|
||||
file: '001 - First.mp4',
|
||||
status: 'ready',
|
||||
generation: 3,
|
||||
isMedia: true,
|
||||
playlistSourceUrl: playlistUrl,
|
||||
playlistTitle: 'Example playlist',
|
||||
playlistIndex: 1,
|
||||
playlistCount: 2,
|
||||
playlistEntryTitle: 'First',
|
||||
requestContextVersion: 7,
|
||||
size: '10 MB',
|
||||
sizeBytes: 10,
|
||||
resumable: true
|
||||
});
|
||||
|
||||
const rows = reconcileDownloadRows(videoUrl, [existing]);
|
||||
|
||||
expect(rows[0]).toMatchObject({
|
||||
file: 'watch',
|
||||
status: 'loading',
|
||||
generation: 4,
|
||||
isMedia: true,
|
||||
playlistSourceUrl: undefined,
|
||||
playlistIndex: undefined,
|
||||
size: undefined,
|
||||
sizeBytes: undefined,
|
||||
resumable: undefined,
|
||||
requestContextVersion: undefined
|
||||
});
|
||||
});
|
||||
|
||||
it('appends every unseen handoff after the observed version', () => {
|
||||
const merged = appendRequestUrlsAfterVersion(
|
||||
'https://existing.example/file.zip',
|
||||
@@ -211,6 +443,9 @@ describe('add download metadata workflow', () => {
|
||||
row(),
|
||||
row({ id: 'fallback', status: 'metadata-error' })
|
||||
])).toBe(true);
|
||||
expect(canSubmitMetadataRows([
|
||||
row({ id: 'unsafe', status: 'metadata-error', metadataBlockedReason: 'unsafe-url' })
|
||||
])).toBe(false);
|
||||
expect(canSubmitMetadataRows([
|
||||
row(),
|
||||
row({ id: 'media-fallback', status: 'metadata-error', isMedia: true })
|
||||
@@ -219,6 +454,29 @@ describe('add download metadata workflow', () => {
|
||||
expect(canSubmitMetadataRows([row({ status: 'invalid' })])).toBe(false);
|
||||
});
|
||||
|
||||
it('validates only selected rows and requires at least one selection', () => {
|
||||
expect(canSubmitMetadataRows([
|
||||
row({ status: 'loading' }),
|
||||
row({ id: 'skipped', status: 'invalid', selected: false })
|
||||
])).toBe(false);
|
||||
expect(canSubmitMetadataRows([
|
||||
row({ status: 'ready' }),
|
||||
row({ id: 'skipped', status: 'invalid', selected: false })
|
||||
])).toBe(true);
|
||||
expect(canSubmitMetadataRows([
|
||||
row({ selected: false }),
|
||||
row({ id: 'skipped', selected: false })
|
||||
])).toBe(false);
|
||||
expect(metadataSummaryMessage([
|
||||
row({ status: 'metadata-error', selected: false }),
|
||||
row({ id: 'ready', status: 'ready' })
|
||||
])).toContain('Ready to add 1 download');
|
||||
expect(metadataSummaryMessage([
|
||||
row({ status: 'metadata-error' }),
|
||||
row({ id: 'skipped', status: 'ready', selected: false })
|
||||
])).toContain('can still be added');
|
||||
});
|
||||
|
||||
it('keeps failed media routing without a format selector', () => {
|
||||
const failedMedia = row({
|
||||
status: 'metadata-error',
|
||||
@@ -269,6 +527,9 @@ describe('add download metadata workflow', () => {
|
||||
expect(metadataSummaryMessage([
|
||||
row({ status: 'metadata-error' })
|
||||
])).toContain('can still be added');
|
||||
expect(metadataSummaryMessage([
|
||||
row({ status: 'metadata-error', metadataBlockedReason: 'unsafe-url' })
|
||||
])).toContain('unsafe URL');
|
||||
expect(metadataSummaryMessage([
|
||||
row({ status: 'metadata-error', isMedia: true })
|
||||
])).toContain('Refresh metadata before adding');
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
fileNameFromUrl,
|
||||
isMediaUrl
|
||||
} from './downloads';
|
||||
import type { MediaPlaylistMetadata } from '../bindings/MediaPlaylistMetadata';
|
||||
|
||||
export type MetadataStatus = 'loading' | 'ready' | 'metadata-error' | 'invalid';
|
||||
|
||||
@@ -31,6 +32,15 @@ export interface AddDownloadDraftRow {
|
||||
resumable?: boolean;
|
||||
formats?: AddMediaFormat[];
|
||||
selectedFormat?: number;
|
||||
isPlaylist?: boolean;
|
||||
playlistSourceUrl?: string;
|
||||
playlistTitle?: string;
|
||||
playlistIndex?: number;
|
||||
playlistCount?: number;
|
||||
playlistEntryTitle?: string;
|
||||
playlistError?: string;
|
||||
metadataBlockedReason?: 'unsafe-url';
|
||||
selected?: boolean;
|
||||
}
|
||||
|
||||
const ALLOWED_SCHEMES = new Set(['http:', 'https:', 'ftp:', 'sftp:']);
|
||||
@@ -39,9 +49,45 @@ type ParsedInput = {
|
||||
identity: string;
|
||||
sourceUrl: string;
|
||||
valid: boolean;
|
||||
isPlaylist?: boolean;
|
||||
playlistSourceUrl?: string;
|
||||
playlistTitle?: string;
|
||||
playlistIndex?: number;
|
||||
playlistCount?: number;
|
||||
playlistEntryTitle?: string;
|
||||
requestContextVersion?: number;
|
||||
selected?: boolean;
|
||||
};
|
||||
|
||||
const parseInputLines = (rawText: string): ParsedInput[] => {
|
||||
export const isYouTubePlaylistUrl = (rawUrl: string): boolean => {
|
||||
try {
|
||||
const url = new URL(rawUrl);
|
||||
const hostname = url.hostname.toLowerCase();
|
||||
const isYouTube = hostname === 'youtube.com' || hostname.endsWith('.youtube.com');
|
||||
const pathname = url.pathname.replace(/\/+$/, '') || '/';
|
||||
return isYouTube && pathname === '/playlist' && Boolean(url.searchParams.get('list'));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
export const playlistFilePrefix = (
|
||||
playlistIndex: number | undefined,
|
||||
playlistCount: number | undefined
|
||||
): string => {
|
||||
if (!playlistIndex || playlistIndex < 1) return '';
|
||||
const width = Math.max(3, String(playlistCount || playlistIndex).length);
|
||||
return `${String(playlistIndex).padStart(width, '0')} - `;
|
||||
};
|
||||
|
||||
type PlaylistExpansions = Readonly<Record<string, MediaPlaylistMetadata>>;
|
||||
|
||||
const parseInputLines = (
|
||||
rawText: string,
|
||||
playlistExpansions: PlaylistExpansions,
|
||||
requestContextVersions: Readonly<Record<string, number>>,
|
||||
selectedBySourceUrl: Readonly<Record<string, boolean>>
|
||||
): ParsedInput[] => {
|
||||
const seen = new Set<string>();
|
||||
const parsed: ParsedInput[] = [];
|
||||
|
||||
@@ -62,7 +108,50 @@ const parseInputLines = (rawText: string): ParsedInput[] => {
|
||||
const identity = valid ? sourceUrl : `invalid:${line}`;
|
||||
if (seen.has(identity)) continue;
|
||||
seen.add(identity);
|
||||
parsed.push({ identity, sourceUrl, valid });
|
||||
|
||||
if (valid && isYouTubePlaylistUrl(sourceUrl)) {
|
||||
const expansion = playlistExpansions[sourceUrl];
|
||||
if (expansion) {
|
||||
const playlistSelected = selectedBySourceUrl[sourceUrl] !== false;
|
||||
for (const [position, entry] of expansion.entries.entries()) {
|
||||
let entryUrl: string;
|
||||
try {
|
||||
const parsedEntryUrl = new URL(entry.url);
|
||||
if (!ALLOWED_SCHEMES.has(parsedEntryUrl.protocol)) continue;
|
||||
entryUrl = parsedEntryUrl.href;
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (seen.has(entryUrl)) continue;
|
||||
seen.add(entryUrl);
|
||||
parsed.push({
|
||||
identity: entryUrl,
|
||||
sourceUrl: entryUrl,
|
||||
valid: true,
|
||||
playlistSourceUrl: sourceUrl,
|
||||
playlistTitle: expansion.title,
|
||||
playlistIndex: entry.playlist_index || position + 1,
|
||||
playlistCount: expansion.entry_count || expansion.entries.length,
|
||||
playlistEntryTitle: entry.title,
|
||||
requestContextVersion: requestContextVersions[sourceUrl],
|
||||
selected: selectedBySourceUrl[entryUrl] ?? playlistSelected
|
||||
});
|
||||
}
|
||||
// The playlist has been successfully discovered even when every
|
||||
// entry was already represented by another input row. Do not put the
|
||||
// source playlist back into loading state in that case.
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
parsed.push({
|
||||
identity,
|
||||
sourceUrl,
|
||||
valid,
|
||||
isPlaylist: valid && isYouTubePlaylistUrl(sourceUrl),
|
||||
requestContextVersion: valid ? requestContextVersions[sourceUrl] : undefined,
|
||||
selected: selectedBySourceUrl[sourceUrl] !== false
|
||||
});
|
||||
}
|
||||
|
||||
return parsed;
|
||||
@@ -75,33 +164,68 @@ export const reconcileDownloadRows = (
|
||||
forceMediaUrls: ReadonlySet<string> = new Set(),
|
||||
createId: () => string = () => crypto.randomUUID(),
|
||||
requestFilenames: Readonly<Record<string, string>> = {},
|
||||
requestContextVersions: Readonly<Record<string, number>> = {}
|
||||
requestContextVersions: Readonly<Record<string, number>> = {},
|
||||
playlistExpansions: PlaylistExpansions = {},
|
||||
selectedBySourceUrl: Readonly<Record<string, boolean>> = {}
|
||||
): AddDownloadDraftRow[] => {
|
||||
const inputs = parseInputLines(rawText);
|
||||
const inputs = parseInputLines(
|
||||
rawText,
|
||||
playlistExpansions,
|
||||
requestContextVersions,
|
||||
selectedBySourceUrl
|
||||
);
|
||||
const existing = new Map(currentRows.map(row => [row.sourceUrl, row]));
|
||||
|
||||
return inputs.map(input => {
|
||||
const preserved = existing.get(input.sourceUrl);
|
||||
if (preserved) {
|
||||
const forcedMedia = input.valid && forceMediaUrls.has(input.sourceUrl);
|
||||
const requestContextVersion = requestContextVersions[input.sourceUrl];
|
||||
const requestContextVersion = input.requestContextVersion;
|
||||
const contextChanged = requestContextVersion !== undefined
|
||||
&& requestContextVersion !== preserved.requestContextVersion;
|
||||
if ((forcedMedia && !preserved.isMedia) || contextChanged) {
|
||||
const playlistContextChanged = preserved.playlistSourceUrl !== input.playlistSourceUrl
|
||||
|| preserved.playlistTitle !== input.playlistTitle
|
||||
|| preserved.playlistIndex !== input.playlistIndex
|
||||
|| preserved.playlistCount !== input.playlistCount
|
||||
|| preserved.playlistEntryTitle !== input.playlistEntryTitle;
|
||||
if ((forcedMedia && !preserved.isMedia) || contextChanged || playlistContextChanged) {
|
||||
const requestedFilename = input.playlistSourceUrl
|
||||
? `${playlistFilePrefix(input.playlistIndex, input.playlistCount)}${input.playlistEntryTitle || 'video'}`
|
||||
: requestFilenames[input.sourceUrl];
|
||||
return {
|
||||
...preserved,
|
||||
file: contextChanged || playlistContextChanged
|
||||
? canonicalizeDownloadFileName(requestedFilename || fileNameFromUrl(input.sourceUrl))
|
||||
: preserved.file,
|
||||
status: 'loading',
|
||||
generation: preserved.generation + 1,
|
||||
requestContextVersion,
|
||||
isMedia: preserved.isMedia || forcedMedia,
|
||||
formats: preserved.isMedia || forcedMedia ? undefined : preserved.formats,
|
||||
selectedFormat: preserved.isMedia || forcedMedia ? undefined : preserved.selectedFormat
|
||||
isMedia: preserved.isMedia || forcedMedia || Boolean(input.playlistSourceUrl),
|
||||
size: undefined,
|
||||
sizeBytes: undefined,
|
||||
resumable: undefined,
|
||||
formats: preserved.isMedia || forcedMedia || Boolean(input.playlistSourceUrl)
|
||||
? undefined
|
||||
: preserved.formats,
|
||||
selectedFormat: preserved.isMedia || forcedMedia || Boolean(input.playlistSourceUrl)
|
||||
? undefined
|
||||
: preserved.selectedFormat,
|
||||
isPlaylist: input.isPlaylist,
|
||||
playlistSourceUrl: input.playlistSourceUrl,
|
||||
playlistTitle: input.playlistTitle,
|
||||
playlistIndex: input.playlistIndex,
|
||||
playlistCount: input.playlistCount,
|
||||
playlistEntryTitle: input.playlistEntryTitle,
|
||||
playlistError: undefined,
|
||||
metadataBlockedReason: undefined
|
||||
};
|
||||
}
|
||||
return preserved;
|
||||
}
|
||||
|
||||
const requestedFilename = requestFilenames[input.sourceUrl]
|
||||
const requestedFilename = input.playlistSourceUrl
|
||||
? `${playlistFilePrefix(input.playlistIndex, input.playlistCount)}${input.playlistEntryTitle || 'video'}`
|
||||
: requestFilenames[input.sourceUrl]
|
||||
|| (inputs.length === 1 ? pendingFilename : undefined);
|
||||
const fallback = canonicalizeDownloadFileName(
|
||||
requestedFilename || fileNameFromUrl(input.sourceUrl)
|
||||
@@ -114,8 +238,21 @@ export const reconcileDownloadRows = (
|
||||
file: fallback,
|
||||
status: input.valid ? 'loading' : 'invalid',
|
||||
generation: input.valid ? 1 : 0,
|
||||
requestContextVersion: requestContextVersions[input.sourceUrl],
|
||||
isMedia: input.valid && (forceMediaUrls.has(input.sourceUrl) || isMediaUrl(input.sourceUrl))
|
||||
requestContextVersion: input.requestContextVersion,
|
||||
isMedia: input.valid && (
|
||||
Boolean(input.isPlaylist)
|
||||
|| Boolean(input.playlistSourceUrl)
|
||||
|| forceMediaUrls.has(input.sourceUrl)
|
||||
|| isMediaUrl(input.sourceUrl)
|
||||
),
|
||||
isPlaylist: input.isPlaylist,
|
||||
playlistSourceUrl: input.playlistSourceUrl,
|
||||
playlistTitle: input.playlistTitle,
|
||||
playlistIndex: input.playlistIndex,
|
||||
playlistCount: input.playlistCount,
|
||||
playlistEntryTitle: input.playlistEntryTitle,
|
||||
metadataBlockedReason: undefined,
|
||||
selected: input.selected !== false
|
||||
};
|
||||
});
|
||||
};
|
||||
@@ -168,17 +305,20 @@ export const refreshFailedMetadataRows = (
|
||||
? {
|
||||
...row,
|
||||
status: 'loading',
|
||||
generation: row.generation + 1
|
||||
generation: row.generation + 1,
|
||||
metadataBlockedReason: undefined
|
||||
}
|
||||
: row
|
||||
);
|
||||
|
||||
export const canSubmitMetadataRows = (rows: AddDownloadDraftRow[]): boolean =>
|
||||
rows.length > 0
|
||||
&& rows.every(row =>
|
||||
export const canSubmitMetadataRows = (rows: AddDownloadDraftRow[]): boolean => {
|
||||
const selectedRows = rows.filter(row => row.selected !== false);
|
||||
return selectedRows.length > 0
|
||||
&& selectedRows.every(row =>
|
||||
row.status === 'ready'
|
||||
|| (!row.isMedia && row.status === 'metadata-error')
|
||||
|| (!row.isMedia && row.status === 'metadata-error' && !row.metadataBlockedReason)
|
||||
);
|
||||
};
|
||||
|
||||
export const mediaFormatSelectorForRow = (
|
||||
row: AddDownloadDraftRow
|
||||
@@ -224,23 +364,30 @@ export const mediaFileNameForSelectedFormat = (
|
||||
export const metadataSummaryMessage = (rows: AddDownloadDraftRow[]): string => {
|
||||
if (rows.length === 0) return 'Paste one or more links.';
|
||||
|
||||
const invalid = rows.filter(row => row.status === 'invalid').length;
|
||||
const selectedRows = rows.filter(row => row.selected !== false);
|
||||
if (selectedRows.length === 0) return 'Select at least one download.';
|
||||
|
||||
const invalid = selectedRows.filter(row => row.status === 'invalid').length;
|
||||
if (invalid > 0) {
|
||||
return `Correct or remove ${invalid} invalid URL${invalid === 1 ? '' : 's'} before continuing.`;
|
||||
}
|
||||
|
||||
const loading = rows.filter(row => row.status === 'loading').length;
|
||||
const loading = selectedRows.filter(row => row.status === 'loading').length;
|
||||
if (loading > 0) {
|
||||
return `Waiting for metadata for ${loading} download${loading === 1 ? '' : 's'}.`;
|
||||
}
|
||||
|
||||
const failed = rows.filter(row => row.status === 'metadata-error').length;
|
||||
const failedMedia = rows.filter(row => row.status === 'metadata-error' && row.isMedia).length;
|
||||
const ready = rows.filter(row => row.status === 'ready').length;
|
||||
const failed = selectedRows.filter(row => row.status === 'metadata-error').length;
|
||||
const failedMedia = selectedRows.filter(row => row.status === 'metadata-error' && row.isMedia).length;
|
||||
const blocked = selectedRows.filter(row => row.metadataBlockedReason === 'unsafe-url').length;
|
||||
const ready = selectedRows.filter(row => row.status === 'ready').length;
|
||||
if (blocked > 0) {
|
||||
return `Remove ${blocked} unsafe URL${blocked === 1 ? '' : 's'} before continuing.`;
|
||||
}
|
||||
if (failedMedia > 0) {
|
||||
return `Media metadata is unavailable for ${failedMedia} item${failedMedia === 1 ? '' : 's'}. Refresh metadata before adding.`;
|
||||
}
|
||||
if (failed === rows.length) {
|
||||
if (failed === selectedRows.length) {
|
||||
return 'Metadata is unavailable. Downloads can still be added using fallback details.';
|
||||
}
|
||||
if (failed > 0) {
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { formatDownloadBytes, formatDownloadTotal, resolveDownloadSizeDisplay } from './downloadProgress';
|
||||
|
||||
describe('download progress size display', () => {
|
||||
it('formats byte counts using the binary units used by the download engines', () => {
|
||||
expect(formatDownloadBytes(0)).toBe('0 B');
|
||||
expect(formatDownloadBytes(1.2 * 1024 ** 3)).toBe('1.20 GB');
|
||||
});
|
||||
|
||||
it('keeps estimated totals distinguishable from exact totals', () => {
|
||||
expect(resolveDownloadSizeDisplay({
|
||||
downloadedBytes: 1.2 * 1024 ** 3,
|
||||
totalBytes: 2.4 * 1024 ** 3,
|
||||
totalIsEstimate: true,
|
||||
fallbackSize: 'Unknown'
|
||||
})).toEqual({
|
||||
downloaded: '1.20',
|
||||
total: '2.40',
|
||||
unit: 'GB',
|
||||
totalIsEstimate: true,
|
||||
fallback: 'Unknown'
|
||||
});
|
||||
});
|
||||
|
||||
it('converts downloaded bytes into the total size unit', () => {
|
||||
expect(resolveDownloadSizeDisplay({
|
||||
downloadedBytes: 512 * 1024 ** 2,
|
||||
totalBytes: 2 * 1024 ** 3,
|
||||
fallbackSize: '2 GB'
|
||||
})).toMatchObject({
|
||||
downloaded: '0.50',
|
||||
total: '2.00',
|
||||
unit: 'GB'
|
||||
});
|
||||
});
|
||||
|
||||
it('formats a completed download using only its total size', () => {
|
||||
const display = resolveDownloadSizeDisplay({
|
||||
downloadedBytes: 1.2 * 1024 ** 3,
|
||||
totalBytes: 2.4 * 1024 ** 3,
|
||||
fallbackSize: '2.4 GB'
|
||||
});
|
||||
|
||||
expect(formatDownloadTotal(display)).toBe('2.40 GB');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
export interface DownloadSizeDisplay {
|
||||
downloaded: string | null;
|
||||
total: string | null;
|
||||
unit: string | null;
|
||||
totalIsEstimate: boolean;
|
||||
fallback: string;
|
||||
}
|
||||
|
||||
const BYTE_UNITS = ['B', 'KB', 'MB', 'GB', 'TB'] as const;
|
||||
|
||||
const isUsableByteCount = (value: number | null | undefined): value is number =>
|
||||
typeof value === 'number' && Number.isFinite(value) && value >= 0;
|
||||
|
||||
const byteUnitIndex = (bytes: number): number => {
|
||||
let value = bytes;
|
||||
let unitIndex = 0;
|
||||
while (value >= 1024 && unitIndex < BYTE_UNITS.length - 1) {
|
||||
value /= 1024;
|
||||
unitIndex += 1;
|
||||
}
|
||||
return unitIndex;
|
||||
};
|
||||
|
||||
const formatDownloadBytesInUnit = (bytes: number, unitIndex: number): string => {
|
||||
const value = bytes / 1024 ** unitIndex;
|
||||
const precision = value >= 100 ? 0 : value >= 10 ? 1 : 2;
|
||||
return value < 1024 && unitIndex === 0
|
||||
? `${Math.round(value)}`
|
||||
: value.toFixed(precision);
|
||||
};
|
||||
|
||||
export const formatDownloadBytes = (bytes: number): string => {
|
||||
const unitIndex = byteUnitIndex(bytes);
|
||||
return `${formatDownloadBytesInUnit(bytes, unitIndex)} ${BYTE_UNITS[unitIndex]}`;
|
||||
};
|
||||
|
||||
export const formatDownloadTotal = (display: DownloadSizeDisplay): string =>
|
||||
display.total && display.unit
|
||||
? `${display.totalIsEstimate ? '~' : ''}${display.total} ${display.unit}`
|
||||
: display.fallback;
|
||||
|
||||
export const resolveDownloadSizeDisplay = ({
|
||||
downloadedBytes,
|
||||
totalBytes,
|
||||
totalIsEstimate = false,
|
||||
fallbackSize
|
||||
}: {
|
||||
downloadedBytes?: number | null;
|
||||
totalBytes?: number | null;
|
||||
totalIsEstimate?: boolean;
|
||||
fallbackSize?: string | null;
|
||||
}): DownloadSizeDisplay => ({
|
||||
downloaded: isUsableByteCount(downloadedBytes) && isUsableByteCount(totalBytes) && totalBytes > 0
|
||||
? formatDownloadBytesInUnit(downloadedBytes, byteUnitIndex(totalBytes))
|
||||
: null,
|
||||
total: isUsableByteCount(totalBytes) && totalBytes > 0
|
||||
? formatDownloadBytesInUnit(totalBytes, byteUnitIndex(totalBytes))
|
||||
: null,
|
||||
unit: isUsableByteCount(totalBytes) && totalBytes > 0
|
||||
? BYTE_UNITS[byteUnitIndex(totalBytes)]
|
||||
: null,
|
||||
totalIsEstimate: Boolean(totalIsEstimate && isUsableByteCount(totalBytes) && totalBytes > 0),
|
||||
fallback: fallbackSize && fallbackSize !== '-' ? fallbackSize : 'Unknown'
|
||||
});
|
||||
|
||||
export const downloadProgressColorClass = (status: string): string => {
|
||||
switch (status) {
|
||||
case 'completed':
|
||||
return 'download-status-completed';
|
||||
case 'paused':
|
||||
return 'download-status-paused';
|
||||
case 'failed':
|
||||
return 'download-status-failed';
|
||||
case 'processing':
|
||||
return 'download-status-processing';
|
||||
case 'queued':
|
||||
case 'staged':
|
||||
return 'download-status-queued';
|
||||
case 'retrying':
|
||||
return 'download-status-retrying';
|
||||
default:
|
||||
return 'download-status-downloading';
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,60 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { DownloadItem } from '../bindings/DownloadItem';
|
||||
import {
|
||||
parseDownloadEta,
|
||||
parseDownloadSize,
|
||||
sortDownloads,
|
||||
type DownloadSortConfig
|
||||
} from './downloadTableSorting';
|
||||
import { redactDownloadForPersistence } from './downloads';
|
||||
|
||||
const item = (id: string, overrides: Partial<DownloadItem> = {}): DownloadItem => ({
|
||||
id,
|
||||
url: `https://example.test/${id}`,
|
||||
fileName: id,
|
||||
status: 'queued',
|
||||
category: 'Other',
|
||||
dateAdded: '2026-07-14T00:00:00.000Z',
|
||||
...overrides
|
||||
});
|
||||
|
||||
const sortedIds = (downloads: DownloadItem[], config: DownloadSortConfig): string[] =>
|
||||
sortDownloads(downloads, config).map(download => download.id);
|
||||
|
||||
describe('download table sorting', () => {
|
||||
it('compares human-readable sizes by bytes instead of their leading number', () => {
|
||||
expect(parseDownloadSize('1 MB')).toBe(1024 ** 2);
|
||||
expect(sortedIds([
|
||||
item('one-mb', { size: '1 MB' }),
|
||||
item('900-kb', { size: '900 KB' }),
|
||||
item('two-mb', { size: '2 MB' })
|
||||
], { column: 'Size', direction: 'asc' })).toEqual(['900-kb', 'one-mb', 'two-mb']);
|
||||
});
|
||||
|
||||
it('supports clock and unit ETA values and keeps unknown values last', () => {
|
||||
expect(parseDownloadEta('01:02:03')).toBe(3723);
|
||||
expect(parseDownloadEta('2m 5s')).toBe(125);
|
||||
expect(sortedIds([
|
||||
item('unknown', { eta: '-' }),
|
||||
item('long', { eta: '2m' }),
|
||||
item('short', { eta: '10s' })
|
||||
], { column: 'ETA', direction: 'asc' })).toEqual(['short', 'long', 'unknown']);
|
||||
});
|
||||
|
||||
it('sorts descending on the second click without reverting to an unsorted list', () => {
|
||||
const downloads = [item('b', { fileName: 'Beta' }), item('a', { fileName: 'Alpha' })];
|
||||
expect(sortedIds(downloads, { column: 'File Name', direction: 'asc' })).toEqual(['a', 'b']);
|
||||
expect(sortedIds(downloads, { column: 'File Name', direction: 'desc' })).toEqual(['b', 'a']);
|
||||
});
|
||||
|
||||
it('does not persist volatile progress fields', () => {
|
||||
const persisted = redactDownloadForPersistence(item('volatile', {
|
||||
fraction: 0.75,
|
||||
speed: '1 MB/s',
|
||||
eta: '10s'
|
||||
}));
|
||||
expect(persisted.fraction).toBeUndefined();
|
||||
expect(persisted.speed).toBeUndefined();
|
||||
expect(persisted.eta).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,110 @@
|
||||
import type { DownloadItem } from '../bindings/DownloadItem';
|
||||
|
||||
export type DownloadSortColumn = 'File Name' | 'Size' | 'Status' | 'Speed' | 'ETA' | 'Date Added';
|
||||
export type DownloadSortDirection = 'asc' | 'desc';
|
||||
|
||||
export type DownloadSortConfig = {
|
||||
column: DownloadSortColumn;
|
||||
direction: DownloadSortDirection;
|
||||
};
|
||||
|
||||
const SIZE_UNITS: Record<string, number> = {
|
||||
B: 1,
|
||||
KB: 1024,
|
||||
KIB: 1024,
|
||||
MB: 1024 ** 2,
|
||||
MIB: 1024 ** 2,
|
||||
GB: 1024 ** 3,
|
||||
GIB: 1024 ** 3,
|
||||
TB: 1024 ** 4,
|
||||
TIB: 1024 ** 4,
|
||||
};
|
||||
|
||||
const valueOrNull = (value?: string): string | null => {
|
||||
const trimmed = value?.trim();
|
||||
return trimmed && trimmed !== '-' && !/^unknown$/i.test(trimmed) ? trimmed : null;
|
||||
};
|
||||
|
||||
const parseUnitValue = (value?: string, units = SIZE_UNITS): number | null => {
|
||||
const normalized = valueOrNull(value);
|
||||
if (!normalized) return null;
|
||||
const match = normalized.match(/^([\d.,]+)\s*([KMGT]?I?B)(?:\/s)?$/i);
|
||||
if (!match) {
|
||||
const number = Number(normalized.replace(/,/g, ''));
|
||||
return Number.isFinite(number) ? number : null;
|
||||
}
|
||||
|
||||
const amount = Number(match[1].replace(/,/g, ''));
|
||||
const multiplier = units[match[2].toUpperCase()];
|
||||
return Number.isFinite(amount) && multiplier ? amount * multiplier : null;
|
||||
};
|
||||
|
||||
export const parseDownloadSize = (value?: string): number | null => parseUnitValue(value);
|
||||
|
||||
export const parseDownloadSpeed = (value?: string): number | null =>
|
||||
parseUnitValue(value, SIZE_UNITS);
|
||||
|
||||
export const parseDownloadEta = (value?: string): number | null => {
|
||||
const normalized = valueOrNull(value);
|
||||
if (!normalized) return null;
|
||||
|
||||
const clockParts = normalized.split(':').map(part => Number(part));
|
||||
if (clockParts.length >= 2 && clockParts.every(Number.isFinite)) {
|
||||
return clockParts.reduce((total, part, index) => total + part * 60 ** (clockParts.length - index - 1), 0);
|
||||
}
|
||||
|
||||
let seconds = 0;
|
||||
let matched = false;
|
||||
for (const [pattern, multiplier] of [[/(\d+(?:\.\d+)?)h/i, 3600], [/(\d+(?:\.\d+)?)m/i, 60], [/(\d+(?:\.\d+)?)s/i, 1]] as const) {
|
||||
const match = normalized.match(pattern);
|
||||
if (match) {
|
||||
seconds += Number(match[1]) * multiplier;
|
||||
matched = true;
|
||||
}
|
||||
}
|
||||
return matched && Number.isFinite(seconds) ? seconds : null;
|
||||
};
|
||||
|
||||
const compareValues = (left: string | number | null, right: string | number | null): number => {
|
||||
if (left === null && right === null) return 0;
|
||||
if (left === null) return 1;
|
||||
if (right === null) return -1;
|
||||
if (typeof left === 'number' && typeof right === 'number') return left - right;
|
||||
return String(left).localeCompare(String(right), undefined, { numeric: true, sensitivity: 'base' });
|
||||
};
|
||||
|
||||
const parseDownloadDate = (value?: string): number | null => {
|
||||
if (!value?.trim()) return null;
|
||||
const timestamp = new Date(value).getTime();
|
||||
return Number.isFinite(timestamp) ? timestamp : null;
|
||||
};
|
||||
|
||||
export const sortDownloads = (downloads: DownloadItem[], config: DownloadSortConfig): DownloadItem[] => {
|
||||
const sorted = [...downloads].sort((left, right) => {
|
||||
let comparison: number;
|
||||
switch (config.column) {
|
||||
case 'File Name':
|
||||
comparison = compareValues(left.fileName || left.url, right.fileName || right.url);
|
||||
break;
|
||||
case 'Size':
|
||||
comparison = compareValues(parseDownloadSize(left.size), parseDownloadSize(right.size));
|
||||
break;
|
||||
case 'Status':
|
||||
comparison = compareValues(left.status, right.status);
|
||||
break;
|
||||
case 'Speed':
|
||||
comparison = compareValues(parseDownloadSpeed(left.speed), parseDownloadSpeed(right.speed));
|
||||
break;
|
||||
case 'ETA':
|
||||
comparison = compareValues(parseDownloadEta(left.eta), parseDownloadEta(right.eta));
|
||||
break;
|
||||
case 'Date Added':
|
||||
comparison = compareValues(parseDownloadDate(left.dateAdded), parseDownloadDate(right.dateAdded));
|
||||
break;
|
||||
}
|
||||
|
||||
if (comparison === 0) comparison = left.id.localeCompare(right.id);
|
||||
return config.direction === 'asc' ? comparison : -comparison;
|
||||
});
|
||||
return sorted;
|
||||
};
|
||||
@@ -0,0 +1,58 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { DownloadItem } from '../bindings/DownloadItem';
|
||||
import { redactDownloadForPersistence, resolveDownloadConnections } from './downloads';
|
||||
|
||||
const item = (status: DownloadItem['status']): DownloadItem => ({
|
||||
id: 'download-1',
|
||||
url: 'https://example.com/file.bin',
|
||||
fileName: 'file.bin',
|
||||
status,
|
||||
category: 'Other',
|
||||
dateAdded: '2026-07-15T00:00:00.000Z',
|
||||
downloadedBytes: 1024,
|
||||
totalBytes: 4096,
|
||||
totalIsEstimate: false
|
||||
});
|
||||
|
||||
describe('download persistence progress snapshots', () => {
|
||||
it('does not write active byte counters on every progress event', () => {
|
||||
const persisted = redactDownloadForPersistence(item('downloading'));
|
||||
|
||||
expect(persisted.downloadedBytes).toBeUndefined();
|
||||
expect(persisted.totalBytes).toBeUndefined();
|
||||
expect(persisted.totalIsEstimate).toBeUndefined();
|
||||
});
|
||||
|
||||
it('keeps byte counters for paused snapshots', () => {
|
||||
const persisted = redactDownloadForPersistence(item('paused'));
|
||||
|
||||
expect(persisted.downloadedBytes).toBe(1024);
|
||||
expect(persisted.totalBytes).toBe(4096);
|
||||
expect(persisted.totalIsEstimate).toBe(false);
|
||||
});
|
||||
|
||||
it.each(['queued', 'staged', 'retrying', 'processing'] as const)(
|
||||
'keeps byte counters for %s snapshots',
|
||||
(status) => {
|
||||
const persisted = redactDownloadForPersistence(item(status));
|
||||
|
||||
expect(persisted.downloadedBytes).toBe(1024);
|
||||
expect(persisted.totalBytes).toBe(4096);
|
||||
expect(persisted.totalIsEstimate).toBe(false);
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
describe('download connection resolution', () => {
|
||||
it('uses a clamped fallback for legacy rows without a saved value', () => {
|
||||
expect(resolveDownloadConnections(undefined, 8)).toBe(8);
|
||||
expect(resolveDownloadConnections(undefined, 0)).toBe(1);
|
||||
expect(resolveDownloadConnections(undefined, Number.NaN)).toBe(16);
|
||||
});
|
||||
|
||||
it('clamps malformed saved values before dispatch', () => {
|
||||
expect(resolveDownloadConnections(0, 8)).toBe(1);
|
||||
expect(resolveDownloadConnections(17, 8)).toBe(16);
|
||||
expect(resolveDownloadConnections(Number.NaN, 8)).toBe(8);
|
||||
});
|
||||
});
|
||||
+54
-6
@@ -36,6 +36,41 @@ const ACTIVE_DOWNLOAD_STATUSES: ReadonlySet<DownloadStatus> = new Set([
|
||||
export const isActiveDownloadStatus = (status: DownloadStatus): boolean =>
|
||||
ACTIVE_DOWNLOAD_STATUSES.has(status);
|
||||
|
||||
/** Transfer states that consume a worker/permit. Queued is intentionally excluded. */
|
||||
export const isTransferActiveStatus = (status: DownloadStatus): boolean =>
|
||||
status === 'downloading' || status === 'processing' || status === 'retrying';
|
||||
|
||||
export const DOWNLOAD_CONNECTIONS_MIN = 1;
|
||||
export const DOWNLOAD_CONNECTIONS_MAX = 16;
|
||||
|
||||
/**
|
||||
* Resolve persisted/user-entered connection values before they cross into the
|
||||
* backend. Older rows may omit the value, while malformed rows can contain
|
||||
* zero, NaN, or an out-of-range number.
|
||||
*/
|
||||
export const resolveDownloadConnections = (value: unknown, fallback: unknown): number => {
|
||||
const toFiniteInteger = (candidate: unknown): number | undefined => {
|
||||
if (typeof candidate === 'number') {
|
||||
return Number.isFinite(candidate) ? Math.trunc(candidate) : undefined;
|
||||
}
|
||||
if (typeof candidate === 'string' && candidate.trim() !== '') {
|
||||
const parsed = Number(candidate);
|
||||
return Number.isFinite(parsed) ? Math.trunc(parsed) : undefined;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
const normalizedFallback = toFiniteInteger(fallback) ?? DOWNLOAD_CONNECTIONS_MAX;
|
||||
const safeFallback = Math.min(
|
||||
DOWNLOAD_CONNECTIONS_MAX,
|
||||
Math.max(DOWNLOAD_CONNECTIONS_MIN, normalizedFallback)
|
||||
);
|
||||
const candidate = toFiniteInteger(value) ?? safeFallback;
|
||||
return Math.min(
|
||||
DOWNLOAD_CONNECTIONS_MAX,
|
||||
Math.max(DOWNLOAD_CONNECTIONS_MIN, candidate)
|
||||
);
|
||||
};
|
||||
|
||||
export const normalizeSpeedLimitForBackend = (value?: string | null): string | null => {
|
||||
const trimmed = value?.trim();
|
||||
if (!trimmed) return null;
|
||||
@@ -115,22 +150,35 @@ export const isMediaUrl = (rawUrl: string): boolean => {
|
||||
* persistence boundary so the user-data database contains no plaintext credentials.
|
||||
*/
|
||||
const DOWNLOAD_SECRET_FIELDS = ['password', 'cookies', 'headers'] as const;
|
||||
const VOLATILE_PROGRESS_STATUSES = new Set([
|
||||
'downloading'
|
||||
]);
|
||||
|
||||
/**
|
||||
* Returns a shallow copy of `item` with secret fields removed. Volatile
|
||||
* progress fields (`fraction`, `speed`, `eta`) are also dropped as in the
|
||||
* existing persistence path.
|
||||
* existing persistence path. Numeric byte totals remain for paused, failed,
|
||||
* and completed rows so those snapshots keep their accurate Size-column
|
||||
* display after restart; counters for the actively ticking `downloading`
|
||||
* state stay in memory to avoid a database write for every progress tick.
|
||||
* Non-ticking states retain counters so paused, queued, staged, retrying, and
|
||||
* processing snapshots remain useful across restart and reconfiguration.
|
||||
*
|
||||
* Note: `url` is intentionally retained even though it may contain signed
|
||||
* query parameters — redacting it would break resume/retry since the URL is
|
||||
* the download source. Ad-hoc credentials entered in the Add Downloads modal
|
||||
* are therefore session-scoped; site-login passwords (Keychain-backed) are
|
||||
* unaffected by this redaction.
|
||||
* Note: standard persistence intentionally retains `url` because it is the
|
||||
* download source. The backend applies a stricter portable-mode policy: URL
|
||||
* userinfo, query, and fragment components are removed before portable data
|
||||
* is written, and affected active records are not auto-resumed.
|
||||
*/
|
||||
export const redactDownloadForPersistence = (item: DownloadItem): DownloadItem => {
|
||||
const copy: DownloadItem = { ...item };
|
||||
delete copy.fraction;
|
||||
delete copy.speed;
|
||||
delete copy.eta;
|
||||
if (VOLATILE_PROGRESS_STATUSES.has(item.status)) {
|
||||
delete copy.downloadedBytes;
|
||||
delete copy.totalBytes;
|
||||
delete copy.totalIsEstimate;
|
||||
}
|
||||
for (const field of DOWNLOAD_SECRET_FIELDS) {
|
||||
delete copy[field];
|
||||
}
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
getKeychainAccessReady,
|
||||
getKeychainConsentVersion,
|
||||
getKeychainStartupDecision
|
||||
} from './keychainStartup';
|
||||
|
||||
describe('getKeychainStartupDecision', () => {
|
||||
it('keeps portable site credentials gated until system-store access is granted', () => {
|
||||
expect(getKeychainAccessReady({
|
||||
portable: true,
|
||||
accessGranted: false,
|
||||
persistent: true
|
||||
})).toBe(false);
|
||||
expect(getKeychainAccessReady({
|
||||
portable: true,
|
||||
accessGranted: true,
|
||||
persistent: true
|
||||
})).toBe(true);
|
||||
});
|
||||
|
||||
it('uses persistent pairing state for standard-mode readiness', () => {
|
||||
expect(getKeychainAccessReady({
|
||||
portable: false,
|
||||
accessGranted: false,
|
||||
persistent: true
|
||||
})).toBe(true);
|
||||
});
|
||||
|
||||
it('changes the consent identity when the credential-access policy changes', () => {
|
||||
expect(getKeychainConsentVersion('1.1.0')).toMatch(
|
||||
/^1\.1\.0\|(build-.+|keychain-policy-2)$/
|
||||
);
|
||||
expect(getKeychainConsentVersion('')).toBe('');
|
||||
});
|
||||
|
||||
it('re-prompts when the app build keeps the same semantic version', () => {
|
||||
expect(getKeychainStartupDecision({
|
||||
portable: false,
|
||||
appVersion: getKeychainConsentVersion('1.1.0'),
|
||||
approvedVersion: '1.1.0',
|
||||
accessGranted: true,
|
||||
promptDismissed: true
|
||||
})).toEqual({
|
||||
deferKeychainHydration: true,
|
||||
showKeychainPrompt: true
|
||||
});
|
||||
});
|
||||
|
||||
it('defers persistent hydration and shows the explanation after an update', () => {
|
||||
expect(getKeychainStartupDecision({
|
||||
portable: false,
|
||||
appVersion: '1.0.5',
|
||||
approvedVersion: '1.0.4',
|
||||
accessGranted: true,
|
||||
promptDismissed: false
|
||||
})).toEqual({
|
||||
deferKeychainHydration: true,
|
||||
showKeychainPrompt: true
|
||||
});
|
||||
});
|
||||
|
||||
it('hydrates automatically after the current version was explicitly approved', () => {
|
||||
expect(getKeychainStartupDecision({
|
||||
portable: false,
|
||||
appVersion: '1.0.5',
|
||||
approvedVersion: '1.0.5',
|
||||
accessGranted: true,
|
||||
promptDismissed: false
|
||||
})).toEqual({
|
||||
deferKeychainHydration: false,
|
||||
showKeychainPrompt: false
|
||||
});
|
||||
});
|
||||
|
||||
it('shows the explanation on first run without touching the credential store', () => {
|
||||
expect(getKeychainStartupDecision({
|
||||
portable: false,
|
||||
appVersion: '1.0.5',
|
||||
approvedVersion: '',
|
||||
accessGranted: false,
|
||||
promptDismissed: false
|
||||
})).toEqual({
|
||||
deferKeychainHydration: true,
|
||||
showKeychainPrompt: true
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps a deliberately deferred session quiet on later launches', () => {
|
||||
expect(getKeychainStartupDecision({
|
||||
portable: false,
|
||||
appVersion: '1.0.5',
|
||||
approvedVersion: '1.0.5',
|
||||
accessGranted: false,
|
||||
promptDismissed: true
|
||||
})).toEqual({
|
||||
deferKeychainHydration: true,
|
||||
showKeychainPrompt: false
|
||||
});
|
||||
});
|
||||
|
||||
it('reoffers the explanation after a later update even if the previous one was deferred', () => {
|
||||
expect(getKeychainStartupDecision({
|
||||
portable: false,
|
||||
appVersion: '1.0.6',
|
||||
approvedVersion: '1.0.5',
|
||||
accessGranted: false,
|
||||
promptDismissed: true
|
||||
})).toEqual({
|
||||
deferKeychainHydration: true,
|
||||
showKeychainPrompt: true
|
||||
});
|
||||
});
|
||||
|
||||
it('does not repeat a deferred prompt when the version API is unavailable', () => {
|
||||
expect(getKeychainStartupDecision({
|
||||
portable: false,
|
||||
appVersion: '',
|
||||
approvedVersion: '',
|
||||
accessGranted: false,
|
||||
promptDismissed: true
|
||||
})).toEqual({
|
||||
deferKeychainHydration: true,
|
||||
showKeychainPrompt: false
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps persistent access deferred without prompting when the version API is unavailable', () => {
|
||||
expect(getKeychainStartupDecision({
|
||||
portable: false,
|
||||
appVersion: '',
|
||||
approvedVersion: '1.0.5',
|
||||
accessGranted: true,
|
||||
promptDismissed: false
|
||||
})).toEqual({
|
||||
deferKeychainHydration: true,
|
||||
showKeychainPrompt: false
|
||||
});
|
||||
});
|
||||
|
||||
it('never gates portable pairing on credential-store access', () => {
|
||||
expect(getKeychainStartupDecision({
|
||||
portable: true,
|
||||
appVersion: '1.0.5',
|
||||
approvedVersion: '',
|
||||
accessGranted: false,
|
||||
promptDismissed: false
|
||||
})).toEqual({
|
||||
deferKeychainHydration: false,
|
||||
showKeychainPrompt: false
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
export type KeychainStartupState = {
|
||||
portable: boolean;
|
||||
appVersion: string;
|
||||
approvedVersion: string;
|
||||
accessGranted: boolean;
|
||||
promptDismissed: boolean;
|
||||
};
|
||||
|
||||
export type KeychainStartupDecision = {
|
||||
deferKeychainHydration: boolean;
|
||||
showKeychainPrompt: boolean;
|
||||
};
|
||||
|
||||
export type KeychainAccessReadiness = {
|
||||
portable: boolean;
|
||||
accessGranted: boolean;
|
||||
persistent: boolean;
|
||||
};
|
||||
|
||||
// The semantic app version can remain unchanged across release-candidate and
|
||||
// packaging rebuilds. Use the build identity so an updated binary cannot skip
|
||||
// Firelink's explanation and invoke the OS prompt directly. The policy epoch
|
||||
// remains only as a safe fallback for builds created outside the Git checkout.
|
||||
const KEYCHAIN_CONSENT_POLICY_VERSION = '2';
|
||||
const buildId = typeof import.meta.env.VITE_BUILD_ID === 'string'
|
||||
? import.meta.env.VITE_BUILD_ID.trim()
|
||||
: '';
|
||||
|
||||
export const getKeychainConsentVersion = (appVersion: string): string => {
|
||||
const normalizedVersion = appVersion.trim();
|
||||
const consentIdentity = buildId && buildId !== 'unknown'
|
||||
? `build-${buildId}`
|
||||
: `keychain-policy-${KEYCHAIN_CONSENT_POLICY_VERSION}`;
|
||||
return normalizedVersion
|
||||
? `${normalizedVersion}|${consentIdentity}`
|
||||
: '';
|
||||
};
|
||||
|
||||
export const getKeychainAccessReady = ({
|
||||
portable,
|
||||
accessGranted,
|
||||
persistent
|
||||
}: KeychainAccessReadiness): boolean =>
|
||||
portable ? accessGranted : persistent;
|
||||
|
||||
export const getKeychainStartupDecision = ({
|
||||
portable,
|
||||
appVersion,
|
||||
approvedVersion,
|
||||
accessGranted,
|
||||
promptDismissed
|
||||
}: KeychainStartupState): KeychainStartupDecision => {
|
||||
if (portable) {
|
||||
return {
|
||||
deferKeychainHydration: false,
|
||||
showKeychainPrompt: false
|
||||
};
|
||||
}
|
||||
|
||||
const versionKnown = Boolean(appVersion.trim());
|
||||
const versionChanged = versionKnown && approvedVersion !== appVersion;
|
||||
const mustDeferAccess = !accessGranted || !versionKnown || versionChanged;
|
||||
return {
|
||||
deferKeychainHydration: mustDeferAccess,
|
||||
showKeychainPrompt: versionChanged || (!accessGranted && !promptDismissed)
|
||||
};
|
||||
};
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
mergeLogSnapshotAndLiveEntries,
|
||||
persistedLogEntry,
|
||||
pushBoundedLogEntry,
|
||||
redactLogText,
|
||||
type LogEntry
|
||||
} from './logEntries';
|
||||
|
||||
@@ -30,6 +31,29 @@ describe('log entry streaming', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('redacts live secrets, signed URL components, and the home path', () => {
|
||||
const message = 'Cookie: session=secret; https://example.com/file?token=signed https://example.com/file#fragment /Users/nima/Downloads/file';
|
||||
const redacted = redactLogText(message, '/Users/nima');
|
||||
|
||||
expect(redacted).toBe('Cookie: [redacted]; https://example.com/file?[redacted] https://example.com/file#[redacted] <HOME>/Downloads/file');
|
||||
expect(redacted).not.toContain('secret');
|
||||
expect(redacted).not.toContain('signed');
|
||||
expect(redacted).not.toContain('/Users/nima');
|
||||
});
|
||||
|
||||
it('redacts live content before it is formatted for display', () => {
|
||||
expect(liveLogEntry(3, 'Authorization: Bearer secret').message).toContain('Authorization: [redacted]');
|
||||
expect(liveLogEntry(3, 'Authorization: Bearer secret').message).not.toContain('secret');
|
||||
});
|
||||
|
||||
it('redacts persisted content and quoted credential fields', () => {
|
||||
const persisted = persistedLogEntry('{"api_key":"json-secret","path":"/Users/nima/file"}', '/Users/nima');
|
||||
|
||||
expect(persisted.message).toContain('api_key');
|
||||
expect(persisted.message).not.toContain('json-secret');
|
||||
expect(persisted.message).not.toContain('/Users/nima');
|
||||
});
|
||||
|
||||
it('merges only the ordered snapshot-to-stream overlap', () => {
|
||||
expect(mergeLogSnapshotAndLiveEntries(
|
||||
[entry('one'), entry('repeat'), entry('three')],
|
||||
|
||||
+43
-7
@@ -19,24 +19,60 @@ const LIVE_LEVELS: Record<number, LogLevel> = {
|
||||
const levelFromMessage = (message: string): LogLevel | undefined =>
|
||||
LEVEL_NAMES.find(level => message.includes(`[${level.toUpperCase()}]`));
|
||||
|
||||
export const persistedLogEntry = (message: string): LogEntry => ({
|
||||
export const persistedLogEntry = (message: string, homePath = ''): LogEntry => ({
|
||||
level: levelFromMessage(message) || 'Debug',
|
||||
message
|
||||
message: redactLogText(message, homePath)
|
||||
});
|
||||
|
||||
export const redactLogText = (message: string, homePath = ''): string => {
|
||||
const normalizedHome = homePath.trim();
|
||||
const normalizedHomeWithForwardSlashes = normalizedHome.replace(/\\/g, '/');
|
||||
let redacted = message;
|
||||
|
||||
if (normalizedHome) {
|
||||
redacted = redacted.split(normalizedHome).join('<HOME>');
|
||||
}
|
||||
if (normalizedHomeWithForwardSlashes && normalizedHomeWithForwardSlashes !== normalizedHome) {
|
||||
redacted = redacted.split(normalizedHomeWithForwardSlashes).join('<HOME>');
|
||||
}
|
||||
|
||||
redacted = redacted.replace(
|
||||
/(["'])(authorization|proxy-authorization|cookie|set-cookie|password|token|secret|credential|pairing[-_ ]?token|api[-_ ]?key)(["'])(\s*[:=]\s*)["'][^"\r\n,;]*["']/gi,
|
||||
'$1$2$3$4[redacted]'
|
||||
);
|
||||
redacted = redacted.replace(
|
||||
/([A-Za-z][A-Za-z0-9+.-]*:\/\/)[^@\s/?#]+@/g,
|
||||
'$1[redacted]@'
|
||||
);
|
||||
redacted = redacted.replace(
|
||||
/([A-Za-z][A-Za-z0-9+.-]*:\/\/[^\s?]+)\?[^\s]+/g,
|
||||
'$1?[redacted]'
|
||||
);
|
||||
redacted = redacted.replace(
|
||||
/([A-Za-z][A-Za-z0-9+.-]*:\/\/[^\s?#]+)#\S+/g,
|
||||
'$1#[redacted]'
|
||||
);
|
||||
return redacted.replace(
|
||||
/(authorization|proxy-authorization|cookie|set-cookie|password|token|secret|credential|pairing[-_ ]?token|api[-_ ]?key)(\s*)([:=])(\s*)([^\r\n,;]+)/gi,
|
||||
'$1$2$3$4[redacted]'
|
||||
);
|
||||
};
|
||||
|
||||
export const liveLogEntry = (
|
||||
numericLevel: number,
|
||||
message: string,
|
||||
now: Date = new Date()
|
||||
now: Date = new Date(),
|
||||
homePath = ''
|
||||
): LogEntry => {
|
||||
const level = levelFromMessage(message) || LIVE_LEVELS[numericLevel] || 'Debug';
|
||||
const alreadyFormatted = /^\[\d{4}-\d{2}-\d{2}\]\[\d{2}:\d{2}:\d{2}\]\[(TRACE|DEBUG|INFO|WARN|ERROR)\]/.test(message);
|
||||
const redactedMessage = redactLogText(message, homePath);
|
||||
const level = levelFromMessage(redactedMessage) || LIVE_LEVELS[numericLevel] || 'Debug';
|
||||
const alreadyFormatted = /^\[\d{4}-\d{2}-\d{2}\]\[\d{2}:\d{2}:\d{2}\]\[(TRACE|DEBUG|INFO|WARN|ERROR)\]/.test(redactedMessage);
|
||||
|
||||
return {
|
||||
level,
|
||||
message: alreadyFormatted
|
||||
? message
|
||||
: `[${now.toISOString().replace('T', ' ').substring(0, 19)}] [${level.toUpperCase()}] ${message}`
|
||||
? redactedMessage
|
||||
: `[${now.toISOString().replace('T', ' ').substring(0, 19)}] [${level.toUpperCase()}] ${redactedMessage}`
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const { invoke } = vi.hoisted(() => ({ invoke: vi.fn() }));
|
||||
|
||||
vi.mock('@tauri-apps/api/core', () => ({ invoke }));
|
||||
vi.mock('@tauri-apps/plugin-log', () => ({
|
||||
attachLogger: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
error: vi.fn(),
|
||||
info: vi.fn(),
|
||||
trace: vi.fn(),
|
||||
warn: vi.fn()
|
||||
}));
|
||||
|
||||
import { setLogPaused, setLogStreamActive } from './logger';
|
||||
|
||||
describe('logger stream transitions', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('serializes enable and disable transitions', async () => {
|
||||
let releaseFirst!: () => void;
|
||||
invoke
|
||||
.mockImplementationOnce(() => new Promise<void>(resolve => { releaseFirst = resolve; }))
|
||||
.mockResolvedValueOnce(undefined);
|
||||
|
||||
const enabling = setLogStreamActive(true);
|
||||
const disabling = setLogStreamActive(false);
|
||||
await Promise.resolve();
|
||||
|
||||
expect(invoke).toHaveBeenCalledTimes(1);
|
||||
releaseFirst();
|
||||
await Promise.all([enabling, disabling]);
|
||||
|
||||
expect(invoke.mock.calls).toEqual([
|
||||
['set_log_stream_active', { active: true }],
|
||||
['set_log_stream_active', { active: false }]
|
||||
]);
|
||||
});
|
||||
|
||||
it('serializes pause transitions so the backend cannot finish out of order', async () => {
|
||||
let releaseFirst!: () => void;
|
||||
invoke
|
||||
.mockImplementationOnce(() => new Promise<void>(resolve => { releaseFirst = resolve; }))
|
||||
.mockResolvedValueOnce(undefined);
|
||||
|
||||
const pausing = setLogPaused(true);
|
||||
const resuming = setLogPaused(false);
|
||||
await Promise.resolve();
|
||||
|
||||
expect(invoke).toHaveBeenCalledTimes(1);
|
||||
releaseFirst();
|
||||
await Promise.all([pausing, resuming]);
|
||||
|
||||
expect(invoke.mock.calls).toEqual([
|
||||
['toggle_log_pause', { pause: true }],
|
||||
['toggle_log_pause', { pause: false }]
|
||||
]);
|
||||
});
|
||||
});
|
||||
+17
-3
@@ -5,6 +5,8 @@ import { invoke } from '@tauri-apps/api/core';
|
||||
let isPaused = true;
|
||||
|
||||
let initPromise: Promise<void> | null = null;
|
||||
let logPauseTransition = Promise.resolve();
|
||||
let logStreamTransition = Promise.resolve();
|
||||
|
||||
export const initLogger = () => {
|
||||
if (!initPromise) {
|
||||
@@ -15,9 +17,21 @@ export const initLogger = () => {
|
||||
return initPromise;
|
||||
};
|
||||
|
||||
export const setLogPaused = async (pause: boolean) => {
|
||||
isPaused = pause;
|
||||
await invoke('toggle_log_pause', { pause }).catch(console.error);
|
||||
export const setLogPaused = (pause: boolean): Promise<void> => {
|
||||
const transition = logPauseTransition.then(async () => {
|
||||
await invoke('toggle_log_pause', { pause });
|
||||
isPaused = pause;
|
||||
});
|
||||
logPauseTransition = transition.catch(() => undefined);
|
||||
return transition;
|
||||
};
|
||||
|
||||
export const setLogStreamActive = (active: boolean): Promise<void> => {
|
||||
const transition = logStreamTransition.then(async () => {
|
||||
await invoke('set_log_stream_active', { active });
|
||||
});
|
||||
logStreamTransition = transition.catch(() => undefined);
|
||||
return transition;
|
||||
};
|
||||
|
||||
export const getLogPaused = () => isPaused;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { invokeCommand as invoke } from '../ipc';
|
||||
import type { MediaMetadata } from '../bindings/MediaMetadata';
|
||||
import type { MediaPlaylistMetadata } from '../bindings/MediaPlaylistMetadata';
|
||||
|
||||
type FetchMediaMetadataArgs = {
|
||||
url: string;
|
||||
@@ -13,6 +14,7 @@ type FetchMediaMetadataArgs = {
|
||||
};
|
||||
|
||||
const inFlightMediaMetadata = new Map<string, Promise<MediaMetadata>>();
|
||||
const inFlightMediaPlaylists = new Map<string, Promise<MediaPlaylistMetadata>>();
|
||||
|
||||
const metadataKey = (args: FetchMediaMetadataArgs) =>
|
||||
JSON.stringify([
|
||||
@@ -38,3 +40,18 @@ export const fetchMediaMetadataDeduped = (args: FetchMediaMetadataArgs): Promise
|
||||
inFlightMediaMetadata.set(key, request);
|
||||
return request;
|
||||
};
|
||||
|
||||
export const fetchMediaPlaylistMetadataDeduped = (
|
||||
args: FetchMediaMetadataArgs
|
||||
): Promise<MediaPlaylistMetadata> => {
|
||||
const key = metadataKey(args);
|
||||
const existing = inFlightMediaPlaylists.get(key);
|
||||
if (existing) return existing;
|
||||
|
||||
const request = invoke('fetch_media_playlist_metadata', args)
|
||||
.finally(() => {
|
||||
inFlightMediaPlaylists.delete(key);
|
||||
});
|
||||
inFlightMediaPlaylists.set(key, request);
|
||||
return request;
|
||||
};
|
||||
|
||||
@@ -5,7 +5,8 @@ import { invokeCommand as invoke } from '../ipc';
|
||||
const fallback: PlatformInfo = {
|
||||
os: 'unknown',
|
||||
arch: 'unknown',
|
||||
targetTriple: 'unknown'
|
||||
targetTriple: 'unknown',
|
||||
portable: false
|
||||
};
|
||||
|
||||
let cached: PlatformInfo | null = null;
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { isTrustedFirelinkReleaseUrl } from './releaseUrls';
|
||||
|
||||
describe('Firelink release URLs', () => {
|
||||
it('accepts the repository release page and exact release tags', () => {
|
||||
expect(isTrustedFirelinkReleaseUrl('https://github.com/nimbold/Firelink/releases')).toBe(true);
|
||||
expect(isTrustedFirelinkReleaseUrl('https://github.com/nimbold/Firelink/releases/tag/v1.0.5')).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects lookalike paths and URLs with authority or path tricks', () => {
|
||||
expect(isTrustedFirelinkReleaseUrl('https://github.com/nimbold/Firelink/releases-evil')).toBe(false);
|
||||
expect(isTrustedFirelinkReleaseUrl('https://github.com/nimbold/Firelink/releases%2F..%2Fother')).toBe(false);
|
||||
expect(isTrustedFirelinkReleaseUrl('https://github.com/nimbold/Firelink/releases/tag/v1.0.5%5C..%5Cuser%5Crepo')).toBe(false);
|
||||
expect(isTrustedFirelinkReleaseUrl('https://github.com.evil.example/nimbold/Firelink/releases')).toBe(false);
|
||||
expect(isTrustedFirelinkReleaseUrl('https://github.com/nimbold/Firelink/releases/tag/v1.0.5?redirect=evil')).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
const FIRELINK_RELEASES_PATH = '/nimbold/Firelink/releases';
|
||||
const FIRELINK_RELEASE_TAG_PATTERN = new RegExp(
|
||||
`^${FIRELINK_RELEASES_PATH}/tag/[A-Za-z0-9._-]+$`
|
||||
);
|
||||
|
||||
export const isTrustedFirelinkReleaseUrl = (value: string) => {
|
||||
try {
|
||||
const parsed = new URL(value);
|
||||
if (
|
||||
parsed.protocol !== 'https:'
|
||||
|| parsed.hostname !== 'github.com'
|
||||
|| parsed.port
|
||||
|| parsed.username
|
||||
|| parsed.password
|
||||
|| parsed.search
|
||||
|| parsed.hash
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const pathname = decodeURIComponent(parsed.pathname);
|
||||
return pathname === FIRELINK_RELEASES_PATH
|
||||
|| FIRELINK_RELEASE_TAG_PATTERN.test(pathname);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
@@ -1,13 +1,69 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
import { createHash } from "node:crypto";
|
||||
import { existsSync, lstatSync, readdirSync, readFileSync, statSync } from "node:fs";
|
||||
import { relative, resolve } from "node:path";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import tailwindcss from '@tailwindcss/vite';
|
||||
|
||||
// @ts-expect-error process is a nodejs global
|
||||
const host = process.env.TAURI_DEV_HOST;
|
||||
|
||||
const buildId = (() => {
|
||||
// Release-candidate builds can keep the same semantic app version. The
|
||||
// consent identity must represent the actual input to this build, not only
|
||||
// the last committed revision: local rebuilds, untracked source files, and
|
||||
// packaged working trees can have different code-signing identities while
|
||||
// sharing the same HEAD or having no Git metadata at all.
|
||||
const configured = process.env.VITE_BUILD_ID?.trim();
|
||||
if (configured) return configured;
|
||||
|
||||
const projectRoot = process.cwd();
|
||||
const inputRoots = [
|
||||
'src',
|
||||
'src-tauri/src',
|
||||
'src-tauri/capabilities',
|
||||
'src-tauri/Cargo.toml',
|
||||
'src-tauri/Cargo.lock',
|
||||
'src-tauri/build.rs',
|
||||
'src-tauri/tauri.conf.json',
|
||||
'index.html',
|
||||
'package.json',
|
||||
'package-lock.json',
|
||||
'vite.config.ts'
|
||||
];
|
||||
const files: string[] = [];
|
||||
const collectFiles = (path: string) => {
|
||||
if (!existsSync(path) || lstatSync(path).isSymbolicLink()) return;
|
||||
const stats = statSync(path);
|
||||
if (stats.isFile()) {
|
||||
files.push(path);
|
||||
return;
|
||||
}
|
||||
if (!stats.isDirectory()) return;
|
||||
for (const entry of readdirSync(path).sort()) {
|
||||
collectFiles(resolve(path, entry));
|
||||
}
|
||||
};
|
||||
|
||||
inputRoots.forEach(input => collectFiles(resolve(projectRoot, input)));
|
||||
if (files.length === 0) return process.env.GITHUB_SHA?.trim() || 'unknown';
|
||||
|
||||
const fingerprint = createHash('sha256');
|
||||
for (const file of files.sort()) {
|
||||
fingerprint.update(relative(projectRoot, file));
|
||||
fingerprint.update('\0');
|
||||
fingerprint.update(readFileSync(file));
|
||||
fingerprint.update('\0');
|
||||
}
|
||||
return fingerprint.digest('hex').slice(0, 24);
|
||||
})();
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig(async () => ({
|
||||
plugins: [react(), tailwindcss()],
|
||||
define: {
|
||||
'import.meta.env.VITE_BUILD_ID': JSON.stringify(buildId)
|
||||
},
|
||||
test: {
|
||||
exclude: [
|
||||
"Extensions/**",
|
||||
|
||||
Reference in New Issue
Block a user