Compare commits

...

32 Commits

Author SHA1 Message Date
NimBold df3bc359c4 chore(release): prepare Firelink 1.1.0 2026-07-15 11:55:54 +03:30
NimBold 57f132f53e fix(metadata): decode URL-derived filenames
Decode URL path segments before sanitizing metadata filenames and preserve malformed escapes safely.

Fixes #18
2026-07-15 11:29:17 +03:30
NimBold 52b00e5cb4 fix(keychain): gate credential-store startup access 2026-07-15 11:07:48 +03:30
NimBold 8e02a61c3f fix(downloads): preserve terminal startup state 2026-07-15 10:23:06 +03:30
NimBold 82f2914077 fix: close post-release lifecycle state gaps 2026-07-15 10:13:53 +03:30
NimBold 9224117d77 chore(deps): scope release-gate update to npm (#16)
Remove the unrelated Rust lockfile refresh from the prior dependency commit. Keep the Autoprefixer release-gate update scoped to package.json and package-lock.json. Refs #16.
2026-07-15 09:43:19 +03:30
NimBold 3c84b5c1a4 chore(deps): refresh compatible npm and Rust packages (#16)
Refresh Autoprefixer and compatible Rust transitive dependencies after the release hardening work. Refs #16.
2026-07-15 09:40:23 +03:30
NimBold c34c489aef fix(settings): preserve speed limiter units
Persist the selected display unit for disabled limits, preserve in-progress fractional input, and keep exact KiB-backed preset values visible.\n\nNo linked issue.
2026-07-15 09:19:19 +03:30
NimBold b81e8391e1 fix(release): harden speed limits and extension replay checks 2026-07-15 09:02:40 +03:30
NimBold 6ff0047d6c fix(release): make verifier RPC smoke resilient
Use OS-selected localhost ports with bounded bind retries and handle aria2c spawn failures without unhandled child-process errors.

Refs #3
2026-07-15 08:50:34 +03:30
NimBold 4a3fece22b fix(release): harden package and engine verifier cleanup 2026-07-15 08:40:12 +03:30
NimBold 1da0fa7223 fix(security): harden credential and path boundaries
Preserve pending pairing tokens until credential-store migration succeeds, defer keychain access until frontend hydration, reject symlink and malformed ownership paths, and restrict metadata credentials to exact origins.

Refs #15

Refs #16
2026-07-15 08:23:43 +03:30
NimBold d6af4ee2b5 fix(extension): harden local handoff boundaries 2026-07-15 06:06:16 +03:30
NimBold 2479ead4ed fix(downloads): harden lifecycle race handling
Audit Assignment 06 lifecycle paths and preserve truthful state across ambiguous Aria2 resume failures. Clean up partial startup listener registration and prevent stale resume workers from leaking or releasing current permits.
2026-07-15 05:34:32 +03:30
NimBold 80a29356e0 fix(tools): harden scheduler limits and logs
Guard scheduler system actions against pending work, serialize diagnostic log transitions, and keep speed-limit saves truthful after backend failures.\n\nNo linked issue was found for this audit.
2026-07-15 03:00:46 +03:30
NimBold 45bbca0515 fix(settings): harden persisted settings and update checks 2026-07-15 02:25:53 +03:30
NimBold e35b1af731 fix(downloads): close audit lifecycle gaps
Prevent duplicate Add Window submissions and preserve discard confirmation.

Reject duplicate backend primary-path ownership and clear stale lifecycle progress.
2026-07-15 01:51:04 +03:30
NimBold 19953a210e fix(downloads): harden Add Window handoffs 2026-07-15 01:38:16 +03:30
NimBold 76850f2433 fix(ui): harden main window interactions 2026-07-15 01:25:59 +03:30
NimBold 0eee47b97f chore(release): refresh cross-platform FFmpeg payloads 2026-07-15 00:11:04 +03:30
NimBold e8487ee71b fix(downloads): harden lifecycle cancellation races 2026-07-15 00:10:56 +03:30
NimBold e07182fbf2 fix(downloads): harden queue and lifecycle synchronization
Serialize queue controls, make multi-item moves atomic, await stale enqueue cleanup, and guard late media and progress events. Add deterministic table sorting and regression coverage for worst-case lifecycle races.
2026-07-14 18:29:14 +03:30
NimBold 2d9eed99d5 chore(deps): refresh packages after fallback hardening (#16)
Refresh the compatible npm and Rust dependency set after the metadata fallback hardening. Refs #16.
2026-07-14 15:34:35 +03:30
NimBold 80cf835060 fix(downloads): defer captured cookies for metadata probes (#16)
Fixes #16
2026-07-14 01:19:00 +03:30
NimBold c78a72a8d7 fix(downloads): harden aria2 resume lifecycle 2026-07-13 04:40:39 +03:30
NimBold df85a77987 fix(downloads): reconcile lost aria2 completion events 2026-07-13 04:21:38 +03:30
NimBold be2a98fbcd fix(downloads): reconcile completed aria2 pause requests 2026-07-13 04:08:18 +03:30
NimBold fcfdffa6e0 fix(downloads): harden aria2 lifecycle synchronization 2026-07-13 03:31:41 +03:30
NimBold 9805c9288a fix(downloads): harden aria2 recovery and resume lifecycle 2026-07-13 00:35:09 +03:30
NimBold dad5b7bc5e fix(portable): harden persistence and release paths (#15)
Preserve legacy source data when portable sanitization cannot replace it, reject malformed settings without panicking, and make portable pairing regeneration durable before UI state changes.\n\nMake the packaged smoke assertion tolerate slow WebView startup and clarify AppImage storage behavior.\n\nRefs #15
2026-07-13 00:09:09 +03:30
NimBold f441c687f0 fix(security): redact secrets from portable errors (#15)
Sanitize lastError at the portable SQLite boundary using the shared redactor.\nCover spaced credentials and non-HTTP URL queries while preserving standard-mode diagnostics.\n\nRefs #15
2026-07-12 23:50:13 +03:30
NimBold a0f44b79ad feat(portable): add secure Windows portable release (#15)
Implement marker-based portable storage, portable WebView and log paths, secure queue and migration sanitization, and Windows portable ZIP validation while preserving the NSIS installer path.

Refs #15
2026-07-12 23:08:56 +03:30
71 changed files with 7805 additions and 1858 deletions
+54
View File
@@ -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
+20
View File
@@ -5,6 +5,26 @@ All notable changes to Firelink will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [1.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
+21 -5
View File
@@ -5,7 +5,7 @@
**A fast, focused desktop download manager for macOS, Windows, and Linux.**
[![Version](https://img.shields.io/badge/version-1.0.4-6f42c1?style=flat-square)](https://github.com/nimbold/Firelink/releases)
[![Version](https://img.shields.io/badge/version-1.1.0-6f42c1?style=flat-square)](https://github.com/nimbold/Firelink/releases)
[![macOS](https://img.shields.io/badge/macOS-111111?style=flat-square&logo=apple&logoColor=white)](#platforms)
[![Windows](.github/badges/windows.svg)](#platforms)
[![Linux](https://img.shields.io/badge/Linux-FCC624?style=flat-square&logo=linux&logoColor=black)](#platforms)
@@ -37,7 +37,9 @@ Firelink is a desktop download manager for fast transfers, browser capture, medi
It is now a cross-platform Rust/Tauri app with a React and TypeScript interface. A native backend coordinates downloads with aria2, yt-dlp, FFmpeg, Deno, and SQLite.
The current desktop release is **1.0.4**, paired with Firelink Companion **2.0.3**.
The current desktop release is **1.1.0**, paired with Firelink Companion **2.0.4**.
This stability-focused release adds a secure Windows portable build and strengthens queues, browser handoffs, media metadata, persistence, and packaged release checks.
## Features
@@ -59,11 +61,25 @@ 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`. App data stays under the archive's `data/` directory. |
| **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.
### Windows portable
The portable ZIP is an opt-in secondary distribution:
- 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.
## Browser Extension
@@ -85,7 +101,7 @@ 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 from Mozilla Add-ons. Chromium users can use the [manual load-unpacked flow](https://github.com/nimbold/Firelink-Extension#manual-chromium-installation) with `firelink-chromium.zip` from the [extension releases](https://github.com/nimbold/Firelink-Extension/releases). Firelink Companion 2.0.4 is the matching extension release for Firelink 1.1.0.
The extension lives in [Firelink-Extension](https://github.com/nimbold/Firelink-Extension). This repo also vendors it as the `Extensions/Browser` submodule.
+9 -7
View File
@@ -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.
+4 -4
View File
@@ -14,8 +14,8 @@
},
"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-14-13-19/ffmpeg-n8.1.2-22-g94138f6973-win64-gpl-8.1.zip",
"sha256": "fb627ebbf9b16a3142fa48b8acd27cd9196d57aee1f719e1233416e95da7e877"
},
"aria2c": {
"version": "1.37.0",
@@ -36,8 +36,8 @@
},
"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-14-13-19/ffmpeg-n8.1.2-22-g94138f6973-linux64-gpl-8.1.tar.xz",
"sha256": "96cf2585a4b2874044320cbc5d4078af57e266f0c86cca9888507c5faab3aee8"
},
"aria2c": {
"version": "1.37.0",
+3 -2
View File
@@ -16,10 +16,11 @@
"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",
+105 -45
View File
@@ -1,12 +1,12 @@
{
"name": "firelink",
"version": "1.0.4",
"version": "1.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "firelink",
"version": "1.0.4",
"version": "1.1.0",
"license": "MIT",
"dependencies": {
"@formkit/auto-animate": "^0.10.0",
@@ -15,7 +15,7 @@
"@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,8 +28,8 @@
"@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",
"autoprefixer": "^10.5.3",
"postcss": "^8.5.19",
"tailwindcss": "^4.3.1",
"typescript": "^7.0.2",
"vite": "^8.1.4",
@@ -614,6 +614,66 @@
"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",
@@ -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.3",
"resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.5.3.tgz",
"integrity": "sha512-bJRzflk8GgE4JX+iZNEwz9f9p460NCHnU7bd+CZ9vIjIlZuTkt6F3WSl2oNO8StZBFx17nLEsiQ6H2wcZiY7nA==",
"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.30001805",
"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.30001805",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001805.tgz",
"integrity": "sha512-52noaS3DubycKSXaU30TwPGIp+POyQSUVa5jBEq3vkRkY0kjyb3LQgvhU6WGyCcyXqVLWO0Cw0Q6BSdD0kUfVA==",
"dev": true,
"funding": [
{
@@ -1624,9 +1684,9 @@
}
},
"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"
},
@@ -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,9 +2253,9 @@
"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"
},
+4 -4
View File
@@ -1,7 +1,7 @@
{
"name": "firelink",
"private": true,
"version": "1.0.4",
"version": "1.1.0",
"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",
@@ -44,7 +44,7 @@
"@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,8 +57,8 @@
"@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",
"autoprefixer": "^10.5.3",
"postcss": "^8.5.19",
"tailwindcss": "^4.3.1",
"typescript": "^7.0.2",
"vite": "^8.1.4",
+46 -19
View File
@@ -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) {
+88 -15
View File
@@ -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,7 @@ if (!executableArg) {
const executable = path.resolve(executableArg);
const assertNoVisibleChildWindows = process.argv.includes('--assert-no-visible-child-windows');
const assertPortableData = process.argv.includes('--assert-portable-data');
const child = spawn(executable, [], {
cwd: process.env.RUNNER_TEMP || process.env.TMPDIR || process.cwd(),
detached: process.platform !== 'win32',
@@ -30,12 +32,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 +48,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,14 +56,17 @@ 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) {
try {
const response = await fetch(`http://127.0.0.1:${port}/ping`);
if (response.headers.get('x-firelink-server') === '1') {
if (
response.headers.get('x-firelink-server') === '1'
&& response.headers.get('x-firelink-smoke-process-id') === String(child.pid)
) {
readyPort = port;
break;
}
@@ -120,17 +128,35 @@ if ($visible.Count -gt 0) {
}
}
function terminateChild() {
if (!child.pid) {
return;
function waitForChildExit(timeoutMs) {
if (childExit) {
return Promise.resolve(true);
}
return new Promise(resolve => {
const timer = setTimeout(() => {
resolve(false);
}, timeoutMs);
child.once('exit', () => {
clearTimeout(timer);
resolve(true);
});
});
}
async function terminateChild() {
if (!child.pid || childExit) {
return true;
}
if (process.platform === 'win32') {
spawn('taskkill', ['/pid', String(child.pid), '/t', '/f'], {
stdio: 'ignore',
windowsHide: true,
});
return;
try {
execFileSync('taskkill', ['/pid', String(child.pid), '/t', '/f'], {
stdio: 'ignore',
windowsHide: true,
});
} catch {}
return waitForChildExit(10000);
}
try {
@@ -138,25 +164,72 @@ function terminateChild() {
} catch {
child.kill('SIGTERM');
}
if (await waitForChildExit(5000)) {
return true;
}
try {
process.kill(-child.pid, 'SIGKILL');
} catch {
child.kill('SIGKILL');
}
return waitForChildExit(5000);
}
await findReadyPort();
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;
}
}
+182 -49
View File
@@ -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';
@@ -317,6 +318,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 +422,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 +429,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 {
+10 -2
View File
@@ -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}`);
}
}
+14 -1
View File
@@ -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'),
+36 -286
View File
@@ -19,17 +19,6 @@ dependencies = [
"cpufeatures 0.2.17",
]
[[package]]
name = "ahash"
version = "0.7.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "891477e0c6a8957309ee5c45a6368af3ae14bb510732d2684ffa19af310920f9"
dependencies = [
"getrandom 0.2.17",
"once_cell",
"version_check",
]
[[package]]
name = "aho-corasick"
version = "0.6.10"
@@ -97,9 +86,9 @@ checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3"
[[package]]
name = "apple-native-keyring-store"
version = "1.0.0"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a7be2f067ccd8d4b4d4a66ddafe0f32a5dff31732f32dbff85fefc40929b1f72"
checksum = "797f94b6a53d7d10b56dc18290e0d40a2158352f108bb4ff32350825081a9f29"
dependencies = [
"keyring-core",
"log 0.4.33",
@@ -127,12 +116,6 @@ dependencies = [
"x11rb",
]
[[package]]
name = "arrayvec"
version = "0.7.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56"
[[package]]
name = "async-broadcast"
version = "0.7.2"
@@ -393,18 +376,6 @@ dependencies = [
"serde_core",
]
[[package]]
name = "bitvec"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ddcec3d12c579d40898fe0a9a358a803c23e9c52ca3c425707f81c9436211837"
dependencies = [
"funty",
"radium",
"tap",
"wyz",
]
[[package]]
name = "block-buffer"
version = "0.10.4"
@@ -454,30 +425,6 @@ dependencies = [
"piper",
]
[[package]]
name = "borsh"
version = "1.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2f3f6da4992df95bbcd9af42a6c7dcb994498fc9048230405f3b36ff7cd3f145"
dependencies = [
"borsh-derive",
"bytes",
"cfg_aliases",
]
[[package]]
name = "borsh-derive"
version = "1.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3ae8fb4fb5740e4b2c4884ff95f5f32f5e8479db1e8fd8eb49ddbe09eb09bb7c"
dependencies = [
"once_cell",
"proc-macro-crate 3.5.0",
"proc-macro2",
"quote",
"syn 2.0.118",
]
[[package]]
name = "brotli"
version = "8.0.4"
@@ -514,40 +461,6 @@ version = "3.20.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
[[package]]
name = "byte-unit"
version = "5.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4a813de7f2bbedb7dce265b64f1cf5908ebe4d56281ece8d847e98113788b9b0"
dependencies = [
"rust_decimal",
"schemars 1.2.1",
"serde",
"utf8-width",
]
[[package]]
name = "bytecheck"
version = "0.6.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "23cdc57ce23ac53c931e88a43d06d070a6fd142f2617be5855eb75efc9beb1c2"
dependencies = [
"bytecheck_derive",
"ptr_meta",
"simdutf8",
]
[[package]]
name = "bytecheck_derive"
version = "0.6.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3db406d29fbcd95542e92559bed4d8ad92636d1ca8b3b72ede10b4bcc010e659"
dependencies = [
"proc-macro2",
"quote",
"syn 1.0.109",
]
[[package]]
name = "bytemuck"
version = "1.25.1"
@@ -694,12 +607,6 @@ version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "cfg_aliases"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
[[package]]
name = "chacha20"
version = "0.10.1"
@@ -1458,7 +1365,7 @@ checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
[[package]]
name = "firelink"
version = "1.0.4"
version = "1.1.0"
dependencies = [
"apple-native-keyring-store",
"async-trait",
@@ -1574,12 +1481,6 @@ dependencies = [
"percent-encoding",
]
[[package]]
name = "funty"
version = "2.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c"
[[package]]
name = "futures-channel"
version = "0.3.32"
@@ -1997,9 +1898,6 @@ name = "hashbrown"
version = "0.12.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888"
dependencies = [
"ahash",
]
[[package]]
name = "hashbrown"
@@ -2116,9 +2014,9 @@ dependencies = [
[[package]]
name = "http-body"
version = "1.0.1"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184"
checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c"
dependencies = [
"bytes",
"http",
@@ -2126,9 +2024,9 @@ dependencies = [
[[package]]
name = "http-body-util"
version = "0.1.3"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a"
checksum = "e9f41fd6a08e4d4ec69df65976da761afd5ad5e58a9d4acb46bd1c953a9e3ff2"
dependencies = [
"bytes",
"futures-core",
@@ -2744,9 +2642,6 @@ name = "log"
version = "0.4.33"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
dependencies = [
"value-bag",
]
[[package]]
name = "mac-notification-sys"
@@ -2830,9 +2725,9 @@ dependencies = [
[[package]]
name = "mio"
version = "1.2.1"
version = "1.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda"
checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427"
dependencies = [
"libc",
"wasi",
@@ -3295,9 +3190,9 @@ checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
[[package]]
name = "open"
version = "5.3.6"
version = "5.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cd8d3b65c44123a56e0133d2cd06ce4361bd3ca99d41198b2f25e3c3db9b8b4a"
checksum = "a0b3d059e795d52b8a72fef45658620edd4d9c359b338564aa14391ffa511ed5"
dependencies = [
"dunce",
"is-wsl",
@@ -3644,26 +3539,6 @@ dependencies = [
"unicode-ident",
]
[[package]]
name = "ptr_meta"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0738ccf7ea06b608c10564b31debd4f5bc5e197fc8bfe088f68ae5ce81e7a4f1"
dependencies = [
"ptr_meta_derive",
]
[[package]]
name = "ptr_meta_derive"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "16b845dbfca988fa33db069c0e230574d15a3088f147a87b64c7589eb662c9ac"
dependencies = [
"proc-macro2",
"quote",
"syn 1.0.109",
]
[[package]]
name = "pxfm"
version = "0.1.30"
@@ -3721,30 +3596,13 @@ version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
[[package]]
name = "radium"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09"
[[package]]
name = "rand"
version = "0.8.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22f6172bdec972074665ed81ed53b71da00bfc44b65a753cfde883ec4c702a1a"
dependencies = [
"libc",
"rand_chacha 0.3.1",
"rand_core 0.6.4",
]
[[package]]
name = "rand"
version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41"
dependencies = [
"rand_chacha 0.9.0",
"rand_chacha",
"rand_core 0.9.5",
]
@@ -3759,16 +3617,6 @@ dependencies = [
"rand_core 0.10.1",
]
[[package]]
name = "rand_chacha"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
dependencies = [
"ppv-lite86",
"rand_core 0.6.4",
]
[[package]]
name = "rand_chacha"
version = "0.9.0"
@@ -3779,15 +3627,6 @@ dependencies = [
"rand_core 0.9.5",
]
[[package]]
name = "rand_core"
version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
dependencies = [
"getrandom 0.2.17",
]
[[package]]
name = "rand_core"
version = "0.9.5"
@@ -3900,15 +3739,6 @@ version = "0.8.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
[[package]]
name = "rend"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "71fe3824f5629716b1589be05dacd749f6aa084c87e00e016714a8cdfccc997c"
dependencies = [
"bytecheck",
]
[[package]]
name = "reqwest"
version = "0.13.4"
@@ -3986,35 +3816,6 @@ dependencies = [
"windows-sys 0.52.0",
]
[[package]]
name = "rkyv"
version = "0.7.46"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2297bf9c81a3f0dc96bc9521370b88f054168c29826a75e89c55ff196e7ed6a1"
dependencies = [
"bitvec",
"bytecheck",
"bytes",
"hashbrown 0.12.3",
"ptr_meta",
"rend",
"rkyv_derive",
"seahash",
"tinyvec",
"uuid",
]
[[package]]
name = "rkyv_derive"
version = "0.7.46"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "84d7b42d4b8d06048d3ac8db0eb31bcb942cbeb709f0b5f2b2ebde398d3038f5"
dependencies = [
"proc-macro2",
"quote",
"syn 1.0.109",
]
[[package]]
name = "rsqlite-vfs"
version = "0.1.1"
@@ -4050,23 +3851,6 @@ dependencies = [
"ordered-multimap",
]
[[package]]
name = "rust_decimal"
version = "1.42.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "be2a24f50780bc85f09cc6ac299bdf1424302742d77221106859c9d8b102126a"
dependencies = [
"arrayvec",
"borsh",
"bytes",
"num-traits",
"rand 0.8.7",
"rkyv",
"serde",
"serde_json",
"wasm-bindgen",
]
[[package]]
name = "rustc-hash"
version = "2.1.3"
@@ -4097,9 +3881,9 @@ dependencies = [
[[package]]
name = "rustls"
version = "0.23.41"
version = "0.23.42"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6b92b125634d9b795e7beca796cc790df15a7fb38323bf3196fda83292d06b1f"
checksum = "3c54fcab019b409d04215d3a17cb438fd7fbf192ee61461f20f4fe18704bc138"
dependencies = [
"once_cell",
"ring",
@@ -4255,12 +4039,6 @@ version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
[[package]]
name = "seahash"
version = "4.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1c107b6f4780854c8b126e228ea8869f4d7b71260f962fefb57b996b8959ba6b"
[[package]]
name = "secret-service"
version = "5.1.0"
@@ -4602,9 +4380,9 @@ checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214"
[[package]]
name = "simd_cesu8"
version = "1.1.1"
version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94f90157bb87cddf702797c5dadfa0be7d266cdf49e22da2fcaa32eff75b2c33"
checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520"
dependencies = [
"rustc_version",
"simdutf8",
@@ -4636,9 +4414,9 @@ checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90"
[[package]]
name = "socket2"
version = "0.6.4"
version = "0.6.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51"
checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4"
dependencies = [
"libc",
"windows-sys 0.61.2",
@@ -4764,7 +4542,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
@@ -4900,12 +4677,6 @@ dependencies = [
"syn 2.0.118",
]
[[package]]
name = "tap"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369"
[[package]]
name = "target-lexicon"
version = "0.12.16"
@@ -5122,12 +4893,11 @@ dependencies = [
[[package]]
name = "tauri-plugin-log"
version = "2.8.0"
version = "2.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7545bd67f070a4500432c826e2e0682146a1d6712aee22a2786490156b574d93"
checksum = "6792296e6f389268016c77db21ebae1fc0568f2fccf88b1ec7e2ea71330afb4c"
dependencies = [
"android_logger",
"byte-unit",
"fern",
"log 0.4.33",
"objc2",
@@ -5206,15 +4976,16 @@ dependencies = [
[[package]]
name = "tauri-plugin-single-instance"
version = "2.4.2"
version = "2.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c8f29386f5e9fdc699182388a33ee80a56de436d91b67459e86afef426282af"
checksum = "b3214becf9ef5783c0ae99a3bb25adf5353a7a16ebf53e74b909e29205735c6c"
dependencies = [
"serde",
"serde_json",
"tauri",
"tauri-plugin-deep-link",
"thiserror 2.0.18",
"tokio",
"tracing",
"windows-sys 0.60.2",
"zbus",
@@ -5592,7 +5363,7 @@ dependencies = [
"toml_datetime 1.1.1+spec-1.1.0",
"toml_parser",
"toml_writer",
"winnow 1.0.3",
"winnow 1.0.4",
]
[[package]]
@@ -5655,7 +5426,7 @@ dependencies = [
"indexmap 2.14.0",
"toml_datetime 1.1.1+spec-1.1.0",
"toml_parser",
"winnow 1.0.3",
"winnow 1.0.4",
]
[[package]]
@@ -5664,7 +5435,7 @@ version = "1.1.2+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526"
dependencies = [
"winnow 1.0.3",
"winnow 1.0.4",
]
[[package]]
@@ -5987,12 +5758,6 @@ version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7fcfc827f90e53a02eaef5e535ee14266c1d569214c6aa70133a624d8a3164ba"
[[package]]
name = "utf8-width"
version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "159a7cadce548703edd50d24069bc294c5415ecab0a480e0cd1ca06d112dc94a"
[[package]]
name = "utf8_iter"
version = "1.0.4"
@@ -6001,9 +5766,9 @@ checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
[[package]]
name = "uuid"
version = "1.23.4"
version = "1.23.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bf80a72845275afea99e7f2b434723d3bc7e38470fcd1c7ed39a599c73319a53"
checksum = "ea5fab0d6c3c01ae70085a09cb03d4c7a1d6314e2b3e075392783396d724ca0a"
dependencies = [
"getrandom 0.4.3",
"js-sys",
@@ -6011,12 +5776,6 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "value-bag"
version = "1.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7ba6f5989077681266825251a52748b8c1d8a4ad098cc37e440103d0ea717fc0"
[[package]]
name = "vcpkg"
version = "0.2.15"
@@ -6923,9 +6682,9 @@ checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945"
[[package]]
name = "winnow"
version = "1.0.3"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1"
checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81"
dependencies = [
"memchr",
]
@@ -7023,15 +6782,6 @@ dependencies = [
"x11-dl",
]
[[package]]
name = "wyz"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "05f360fc0b24296329c78fda852a1e9ae82de9cf7b27dae4b7f62f118f77b9ed"
dependencies = [
"tap",
]
[[package]]
name = "x11"
version = "2.21.0"
@@ -7122,7 +6872,7 @@ dependencies = [
"uds_windows",
"uuid",
"windows-sys 0.61.2",
"winnow 1.0.3",
"winnow 1.0.4",
"zbus_macros",
"zbus_names",
"zvariant",
@@ -7161,7 +6911,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1039ca249fee9559680f3a9f05b55e0761fee51af4f6c1e7d8c1f31e549721d2"
dependencies = [
"serde",
"winnow 1.0.3",
"winnow 1.0.4",
"zvariant",
]
@@ -7247,9 +6997,9 @@ dependencies = [
[[package]]
name = "zmij"
version = "1.0.21"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
[[package]]
name = "zune-core"
@@ -7275,7 +7025,7 @@ dependencies = [
"endi",
"enumflags2",
"serde",
"winnow 1.0.3",
"winnow 1.0.4",
"zvariant_derive",
"zvariant_utils",
]
@@ -7303,5 +7053,5 @@ dependencies = [
"quote",
"serde",
"syn 2.0.118",
"winnow 1.0.3",
"winnow 1.0.4",
]
+5 -5
View File
@@ -1,6 +1,6 @@
[package]
name = "firelink"
version = "1.0.4"
version = "1.1.0"
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,7 +41,7 @@ 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"
@@ -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.
+49 -14
View File
@@ -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());
}
}
+882 -186
View File
File diff suppressed because it is too large Load Diff
+269 -51
View File
@@ -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,
lifecycle_generation: u64,
ack: tokio::sync::oneshot::Sender<()>,
) -> 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<()>,
},
Finished {
id: String,
lifecycle_generation: u64,
},
}
async fn run_coordinator(
@@ -108,74 +141,120 @@ 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<()>>::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 {
cancelled_media_generations
.entry(id)
.and_modify(|generation| *generation = (*generation).max(lifecycle_generation))
.or_insert(lifecycle_generation);
}
}
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);
let _ = ack.send(());
}
}
MediaCmd::Finished(id) => {
active_media.remove(&id);
if let Some(ack) = pending_media_acks.remove(&id) {
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))
{
let _ = ack.send(());
}
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 +298,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 +351,122 @@ 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 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();
}
}
+45 -10
View File
@@ -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");
}
}
+139 -10
View File
@@ -30,6 +30,7 @@ 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";
@@ -140,6 +141,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 +174,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 +203,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);
}
@@ -216,6 +236,10 @@ async fn download_handler(
headers: HeaderMap,
body: Bytes,
) -> Result<StatusCode, StatusCode> {
if !has_allowed_request_origin(&headers) || !has_valid_optional_nonce(&headers) {
return Err(StatusCode::FORBIDDEN);
}
let signature = match headers
.get("x-firelink-signature")
.and_then(|v| v.to_str().ok())
@@ -302,13 +326,32 @@ fn normalize_download(payload: ExtensionRequest) -> Option<ExtensionDownload> {
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 cookies = if !payload.media && urls.len() == 1 {
payload
.cookies
.filter(|value| !value.trim().is_empty())
} else {
None
};
Some(ExtensionDownload {
urls,
@@ -321,10 +364,7 @@ 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,
media: payload.media,
})
}
@@ -390,6 +430,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 has_valid_optional_nonce(headers: &HeaderMap) -> bool {
match headers.get(CLIENT_NONCE_HEADER) {
None => true,
Some(nonce) => nonce.to_str().ok().is_some_and(is_valid_client_nonce),
}
}
fn sign_server_proof(
timestamp_text: &str,
nonce: &str,
@@ -476,13 +530,20 @@ 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,
add_server_identity, claim_request, has_allowed_request_origin, has_valid_optional_nonce,
is_valid_client_nonce, normalize_download, sign_server_proof, ExtensionRequest,
PROTOCOL_VERSION_HEADER, SERVER_HEADER,
};
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 +580,54 @@ mod tests {
assert!(!is_valid_client_nonce("0123456789abcdef0123456789abcdeg"));
}
#[test]
fn rejects_invalid_origins_and_malformed_optional_nonces() {
let mut headers = HeaderMap::new();
assert!(has_allowed_request_origin(&headers));
assert!(has_valid_optional_nonce(&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"),
);
headers.insert(
"x-firelink-client-nonce",
HeaderValue::from_static("not-a-valid-nonce"),
);
assert!(has_allowed_request_origin(&headers));
assert!(!has_valid_optional_nonce(&headers));
}
#[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,
media: true,
});
assert!(download.is_none());
}
#[test]
fn rejects_replayed_download_signature() {
let cache = Arc::new(Mutex::new(HashMap::new()));
let signature = "a".repeat(64);
assert!(claim_request(&signature, 42, &cache));
assert!(!claim_request(&signature, 42, &cache));
}
#[test]
fn explicit_media_drops_the_extension_cookie_header() {
let download = normalize_download(ExtensionRequest {
@@ -560,6 +669,26 @@ mod tests {
);
}
#[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()),
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()));
+9 -7
View File
@@ -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/")]
@@ -110,6 +114,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,6 +268,8 @@ 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,
@@ -278,13 +286,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 +298,7 @@ pub struct PlatformInfo {
pub os: String,
pub arch: String,
pub target_triple: String,
pub portable: bool,
}
#[derive(Clone, Copy, Debug, Serialize, Deserialize, TS)]
+1415 -237
View File
File diff suppressed because it is too large Load Diff
+790 -164
View File
File diff suppressed because it is too large Load Diff
+51 -13
View File
@@ -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"));
+231 -10
View File
@@ -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,11 @@ 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,
app_font_size: AppFontSize::Standard,
list_row_density: ListRowDensity::Standard,
show_dock_badge: true,
@@ -312,7 +453,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 +460,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 +539,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 +652,62 @@ 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 completion_sound_default_matches_the_frontend_default() {
assert!(!default_settings().play_completion_sound);
}
#[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 +720,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);
}
}
+211
View File
@@ -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());
}
}
+2 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Firelink",
"version": "1.0.4",
"version": "1.1.0",
"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 -1
View File
@@ -2,13 +2,14 @@
"app": {
"windows": [
{
"create": false,
"title": "Firelink",
"width": 1280,
"height": 760,
"minWidth": 960,
"minHeight": 640,
"transparent": false,
"decorations": true
"decorations": false
}
]
},
+1
View File
@@ -2,6 +2,7 @@
"app": {
"windows": [
{
"create": false,
"title": "Firelink",
"width": 1280,
"height": 760,
+1
View File
@@ -2,6 +2,7 @@
"app": {
"windows": [
{
"create": false,
"title": "Firelink",
"width": 1280,
"height": 760,
+457 -9
View File
@@ -16,13 +16,29 @@ 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,
}
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 +47,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 +63,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 {
@@ -66,7 +109,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 +131,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 +227,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 +257,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 +517,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 +527,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 +546,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 +611,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();
}
@@ -534,7 +668,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 +688,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 +699,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 +1101,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;
+152 -96
View File
@@ -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";
@@ -19,8 +19,11 @@ 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 { getKeychainStartupDecision } from './utils/keychainStartup';
import { getVersion } from '@tauri-apps/api/app';
import type { PostQueueAction } from './bindings/PostQueueAction';
import { PanelLeft } from 'lucide-react';
@@ -93,6 +96,7 @@ function App() {
const platform = usePlatformInfo();
const [filter, setFilter] = useState<SidebarFilter>('all');
const [coreReady, setCoreReady] = useState(false);
const [appVersion, setAppVersion] = useState('');
const [sidebarWidth, setSidebarWidth] = useState(() => {
const stored = Number(window.localStorage.getItem('firelink-sidebar-width'));
@@ -109,26 +113,21 @@ function App() {
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 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 activeTransferCount = downloads.filter(download => isTransferActiveStatus(download.status)).length;
const { addToast } = useToast();
const isMacUserAgent = navigator.userAgent.includes('Mac');
const usesCustomWindowControls = !isMacUserAgent && platform.os !== 'macos';
@@ -193,7 +192,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
});
@@ -236,6 +235,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 +248,79 @@ 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 = () => {
invoke('set_extension_frontend_ready', { ready: 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) => {
useDownloadStore.getState().handleExtensionDownload(event.payload).catch(error => {
console.error('Failed to handle browser extension download:', error);
});
});
unlistenDeepLink = await listen('deep-link-add-download', (event) => {
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 +331,39 @@ function App() {
return;
}
const [currentAppVersion, currentPlatform] = await Promise.all([
getVersion().catch(() => ''),
getPlatformInfo().catch(() => null)
]);
if (!active) return;
setAppVersion(currentAppVersion);
try {
const changed = await useSettingsStore.getState().hydratePairingToken();
const settings = useSettingsStore.getState();
const { deferKeychainHydration, showKeychainPrompt } = getKeychainStartupDecision({
portable: currentPlatform?.portable === true,
appVersion: currentAppVersion,
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();
if (showKeychainPrompt) {
settings.setShowKeychainModal(true);
}
} else {
changed = await settings.hydratePairingToken();
settings.setKeychainAccessReady(
currentPlatform?.portable !== true && useSettingsStore.getState().isPairingTokenPersistent
);
}
if (changed) {
addToast({
variant: 'warning',
@@ -315,13 +418,34 @@ function App() {
isActionable: true
});
}
if (!active) return;
setCoreReady(true);
invoke('set_extension_frontend_ready', { ready: true }).catch(error => {
console.error('Failed to activate browser extension integration:', error);
});
};
void initialize();
return () => {
active = false;
cleanupListeners?.();
cleanupListeners = null;
};
}, [addToast]);
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);
}, [appFontSize]);
@@ -397,13 +521,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 +534,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 +556,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 +564,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 +574,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))
);
@@ -510,7 +621,15 @@ 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,
@@ -609,69 +728,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 +796,7 @@ function App() {
<AddDownloadsModal />
<PropertiesModal />
<DeleteConfirmationModal />
<KeychainPermissionModal />
<KeychainPermissionModal appVersion={appVersion} />
</div>
);
+1 -1
View File
@@ -2,4 +2,4 @@
import type { DownloadCategory } from "./DownloadCategory";
import type { DownloadStatus } from "./DownloadStatus";
export type DownloadItem = { id: string, url: string, fileName: string, status: DownloadStatus, fraction?: number, speed?: string, eta?: string, size?: string, category: DownloadCategory, dateAdded: string, resumable?: boolean, connections?: number, speedLimit?: string, username?: string, password?: string, headers?: string, checksum?: string, cookies?: string, mirrors?: string, destination?: string, isMedia?: boolean, mediaFormatSelector?: string, queueId?: string, queuePosition?: number, hasBeenDispatched?: boolean, lastError?: string, };
export type DownloadItem = { id: string, url: string, fileName: string, status: DownloadStatus, fraction?: number, speed?: string, eta?: string, size?: string, category: DownloadCategory, dateAdded: string, resumable?: boolean, connections?: number, speedLimit?: string, username?: string, password?: string, headers?: string, checksum?: string, cookies?: string, mirrors?: string, destination?: string, isMedia?: boolean, mediaFormatSelector?: string, queueId?: string, queuePosition?: number, hasBeenDispatched?: boolean, lastError?: string, lastTry?: string, };
+1 -1
View File
@@ -8,4 +8,4 @@ import type { SettingsTab } from "./SettingsTab";
import type { SiteLogin } from "./SiteLogin";
import type { Theme } from "./Theme";
export type PersistedSettings = { theme: Theme, baseDownloadFolder: string, categorySubfoldersEnabled: boolean, categorySubfolders: { [key in string]: string }, categoryDirectoryOverrides: { [key in string]: string }, approvedDownloadRoots: Array<string>, maxConcurrentDownloads: number, globalSpeedLimit: string, speedLimitPresetValues: Array<number>, logsEnabled: boolean, isSidebarVisible: boolean, activeSettingsTab: SettingsTab, scheduler: SchedulerSettings, schedulerRunning: boolean, schedulerActiveDownloadIds: Array<string>, schedulerLastStartKey: string, schedulerLastStopKey: string, lastCustomSpeedLimitKiB: number, 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, 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 -1
View File
@@ -1,3 +1,3 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type PlatformInfo = { os: string, arch: string, targetTriple: string, };
export type PlatformInfo = { os: string, arch: string, targetTriple: string, portable: boolean, };
+94 -16
View File
@@ -1,4 +1,4 @@
import { useState, useEffect, useRef } from 'react';
import { useCallback, useState, useEffect, useRef } from 'react';
import {
useDownloadStore,
getSiteLogin,
@@ -86,7 +86,12 @@ export const AddDownloadsModal = () => {
addDownload,
queues
} = useDownloadStore();
const { baseDownloadFolder, perServerConnections } = useSettingsStore();
const {
baseDownloadFolder,
perServerConnections,
keychainAccessReady,
keychainPromptDismissed
} = useSettingsStore();
const [urls, setUrls] = useState('');
const [selectedItemIndex, setSelectedItemIndex] = useState<number | null>(null);
@@ -101,6 +106,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
@@ -146,12 +152,23 @@ export const AddDownloadsModal = () => {
if (context) return context.cookies.trim();
return hasExtensionRequestContext ? '' : cookies.trim();
};
const shouldDeferCookiesForRow = (sourceUrl: string) =>
!cookiesManuallyEditedRef.current && Boolean(requestContextForUrl(sourceUrl));
const suggestedFilenameForRow = (sourceUrl: string) => {
const context = requestContextForUrl(sourceUrl);
if (context?.filename) return context.filename;
return hasExtensionRequestContext ? '' : pendingAddFilename;
};
const closeModalFromDismissAction = useCallback(() => {
if (isSubmitting || isSubmittingRef.current) return;
const hasPendingInput = Boolean(
urls.trim() || pendingAddUrls.trim() || parsedItems.length || headers.trim() || cookies.trim()
);
if (hasPendingInput && !window.confirm('Discard this download setup?')) return;
toggleAddModal(false);
}, [cookies, headers, isSubmitting, parsedItems.length, pendingAddUrls, toggleAddModal, urls]);
useEffect(() => {
if (!isAddModalOpen) {
modalSessionRef.current = false;
@@ -195,6 +212,7 @@ export const AddDownloadsModal = () => {
cookiesManuallyEditedRef.current = false;
setMirrors('');
setIsQueueMenuOpen(false);
isSubmittingRef.current = false;
setIsSubmitting(false);
}, [
isAddModalOpen,
@@ -231,18 +249,20 @@ export const AddDownloadsModal = () => {
}, [isQueueMenuOpen]);
useEffect(() => {
if (!isQueueMenuOpen && !showingDuplicates) return;
if (!isAddModalOpen) return;
const closeOnEscape = (event: KeyboardEvent) => {
if (event.key !== 'Escape') 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, showingDuplicates]);
useEffect(() => {
const requestId = ++freeSpaceRequestRef.current;
@@ -308,12 +328,16 @@ export const AddDownloadsModal = () => {
try {
const settingsStore = useSettingsStore.getState();
const proxy = await getProxyArgs(settingsStore);
const login = getSiteLogin(row.sourceUrl, settingsStore);
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) {
@@ -373,9 +397,8 @@ export const AddDownloadsModal = () => {
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) {
@@ -389,7 +412,8 @@ export const AddDownloadsModal = () => {
password: useAuth ? password || null : keychainPassword,
headers: headersForRow(row.sourceUrl) || null,
cookies: cookiesForRow(row.sourceUrl) || null,
proxy
proxy,
deferCookies: shouldDeferCookiesForRow(row.sourceUrl)
});
const nextDownloadUrl = meta.url || row.sourceUrl;
setParsedItems(current => updateRowIfCurrent(
@@ -434,7 +458,7 @@ export const AddDownloadsModal = () => {
}
})();
}
}, [parsedItems, pendingAddFilename, pendingAddMediaUrls]);
}, [keychainAccessReady, keychainPromptDismissed, parsedItems, pendingAddFilename, pendingAddMediaUrls, useAuth]);
useEffect(() => {
if (parsedItems.length === 0) {
@@ -482,13 +506,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;
@@ -511,11 +536,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,6 +552,7 @@ 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];
@@ -536,8 +564,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 +626,7 @@ export const AddDownloadsModal = () => {
});
}
}
plannedTargets.push({ location: itemLocation, fileName: finalFile });
}
if (newConflicts.length > 0) {
@@ -589,6 +635,7 @@ export const AddDownloadsModal = () => {
setPendingUseSharedDestination(useSharedDestination);
setPendingDestinationOverrides(destinationOverrides);
setShowingDuplicates(true);
isSubmittingRef.current = false;
setIsSubmitting(false);
return;
}
@@ -596,6 +643,7 @@ export const AddDownloadsModal = () => {
try {
await executeAddDownloads(action, finalLocation, useSharedDestination, undefined, destinationOverrides);
} finally {
isSubmittingRef.current = false;
setIsSubmitting(false);
}
};
@@ -632,6 +680,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 +719,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) {
@@ -835,6 +901,8 @@ export const AddDownloadsModal = () => {
<DuplicateResolutionModal
conflicts={conflicts}
onConfirm={(resolutions) => {
if (isSubmittingRef.current) return;
isSubmittingRef.current = true;
setShowingDuplicates(false);
setIsSubmitting(true);
void executeAddDownloads(
@@ -851,12 +919,22 @@ 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 */}
@@ -1180,7 +1258,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} className="add-download-button add-download-button-cancel px-4 text-xs">
Cancel
</button>
<div ref={actionMenuRef} className="relative flex gap-2.5">
+17 -1
View File
@@ -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()}
+1 -1
View File
@@ -1,7 +1,7 @@
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';
+125 -128
View File
@@ -1,4 +1,4 @@
import React, { useState, useEffect, useRef } from 'react';
import React, { useState, useEffect, useMemo, useRef } from 'react';
import { useDownloadStore, DownloadItem } from '../store/useDownloadStore';
import { useToast } from '../contexts/ToastContext';
import { useSettingsStore } from '../store/useSettingsStore';
@@ -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;
@@ -46,7 +51,11 @@ 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 sortedDownloadsRef = useRef<DownloadItem[]>([]);
selectedIdsRef.current = selectedIds;
const [columnWidths, setColumnWidths] = useState(() => {
try {
const stored = JSON.parse(window.localStorage.getItem(COLUMN_WIDTHS_STORAGE_KEY) || 'null');
@@ -110,15 +119,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,7 +135,7 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [selectedIds]);
}, []);
const showInteractionError = (message: string, error: unknown) => {
@@ -192,43 +201,24 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
openProperties(item.id);
};
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,40 +226,26 @@ 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]);
sortedDownloadsRef.current = sortedDownloads;
if (aUnfinished && !bUnfinished) return -1;
if (!aUnfinished && bUnfinished) return 1;
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]);
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;
useEffect(() => {
setQueueSortConfig(null);
}, [filter, isQueueFilter]);
const handleItemClick = (e: React.MouseEvent, item: DownloadItem) => {
if (e.detail === 2) {
handleDownloadDoubleClick(item);
@@ -312,15 +288,17 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
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 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));
}
};
@@ -356,8 +334,25 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
}
};
const handleResume = (item: DownloadItem) => {
useDownloadStore.getState().resumeDownload(item.id);
const handleResume = 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);
}
};
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[]) => {
@@ -449,9 +444,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 +490,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">
<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 +514,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>
) : (
@@ -576,27 +581,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 +625,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>
+17 -2
View File
@@ -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>
+58 -17
View File
@@ -1,30 +1,53 @@
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 { getVersion } from '@tauri-apps/api/app';
export const KeychainPermissionModal: React.FC = () => {
type KeychainPermissionModalProps = {
appVersion: string;
};
export const KeychainPermissionModal: React.FC<KeychainPermissionModalProps> = ({ appVersion }) => {
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(appVersion);
};
window.addEventListener('keydown', handleEscape);
return () => window.removeEventListener('keydown', handleEscape);
}, [appVersion, 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);
@@ -33,16 +56,20 @@ export const KeychainPermissionModal: React.FC = () => {
try {
const result = await invoke('grant_keychain_access');
if (result.persistent) {
const grantedVersion = appVersion || await getVersion().catch(() => '');
// Keep state in sync with the grant result instead of rehydrating
// before Zustand has persisted keychainAccessGranted.
useSettingsStore.setState({
keychainAccessGranted: true,
keychainAccessVersion: grantedVersion,
keychainAccessReady: true,
extensionPairingToken: result.token,
isPairingTokenPersistent: true
isPairingTokenPersistent: true,
keychainPromptDismissed: false,
showKeychainModal: false
});
setShowKeychainModal(false);
} else {
setError(result.error || `${storeName} is unavailable.`);
setError(result.error || `${siteCredentialStoreName} is unavailable.`);
}
} catch (e: any) {
setError(e.toString());
@@ -52,11 +79,18 @@ export const KeychainPermissionModal: React.FC = () => {
};
const handleLater = () => {
setShowKeychainModal(false);
dismissKeychainPrompt(appVersion);
};
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 +105,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 +131,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 &gt; 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 &gt; Integrations</strong>.
</div>
</div>
+73 -23
View File
@@ -1,8 +1,7 @@
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 { 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';
@@ -28,6 +27,11 @@ export default function LogsView() {
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(() => {
const handleVisibilityChange = () => setPageVisible(document.visibilityState !== 'hidden');
@@ -37,16 +41,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;
@@ -79,9 +84,9 @@ export default function LogsView() {
});
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;
}
}
@@ -90,6 +95,10 @@ export default function LogsView() {
if (!active) return;
const snapshot = lines.map(persistedLogEntry);
initialized = true;
if (initGeneration !== clearGenerationRef.current) {
pendingLiveEntries = [];
return;
}
const caughtUpLogs = mergeLogSnapshotAndLiveEntries(snapshot, pendingLiveEntries);
pendingLiveEntries = [];
setLogs(caughtUpLogs);
@@ -107,7 +116,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 +171,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 +180,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 +283,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} />
+27 -3
View File
@@ -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';
@@ -13,6 +13,14 @@ import {
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);
@@ -106,6 +114,15 @@ export const PropertiesModal = () => {
}
}, [selectedPropertiesDownloadId, baseDownloadFolder, setSelectedPropertiesDownloadId]);
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;
const handleBrowse = async () => {
@@ -178,7 +195,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 */}
@@ -204,7 +228,7 @@ export const PropertiesModal = () => {
<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-[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>
+4 -1
View File
@@ -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])];
+67 -12
View File
@@ -28,6 +28,7 @@ import {
subfolderFromDerivedCategoryPath
} from '../utils/downloadLocations';
import { usePlatformInfo } from '../utils/platform';
import { isTrustedFirelinkReleaseUrl } from '../utils/releaseUrls';
const settingsTabs: { type: SettingsTab; label: string; icon: typeof Download }[] = [
{ type: 'downloads', label: 'Downloads', icon: Download },
@@ -275,7 +276,7 @@ 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 [appVersion, setAppVersion] = useState('Unknown');
const [extensionServerPort, setExtensionServerPort] = useState<number | null>(null);
// Local state for adding site login
@@ -283,6 +284,11 @@ const [extensionServerPort, setExtensionServerPort] = useState<number | null>(nu
const [loginUser, setLoginUser] = useState('');
const [loginPass, setLoginPass] = useState('');
const [loginError, setLoginError] = useState('');
const [loginFieldErrors, setLoginFieldErrors] = useState<{
pattern?: string;
username?: string;
password?: string;
}>({});
// Toast notifications
const { addToast } = useToast();
@@ -431,6 +437,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,11 +521,31 @@ runEngineChecks(false);
};
const handleAddLogin = async () => {
if (!loginPattern.trim() || !loginUser.trim()) {
setLoginError("Please enter a URL pattern and a username.");
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 (!settings.keychainAccessReady) {
settings.setShowKeychainModal(true);
setLoginError('Grant credential-store access before saving a site login.');
return;
}
if (loginPass) {
try {
@@ -1026,6 +1055,10 @@ 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);
@@ -1057,10 +1090,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 +1106,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,10 +1122,15 @@ 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">
@@ -1214,9 +1262,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 +1276,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>
+36 -4
View File
@@ -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;
@@ -81,6 +82,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 +95,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 +104,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;
@@ -330,14 +334,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
@@ -362,6 +386,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({
+56
View File
@@ -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');
});
});
+130 -47
View File
@@ -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"
+6 -1
View File
@@ -1017,6 +1017,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 +1255,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 +1655,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 +1664,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 +1675,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 {
@@ -1842,6 +1846,7 @@ html[data-list-density="relaxed"] {
z-index: 5;
width: 8px;
cursor: col-resize;
-webkit-app-region: no-drag;
}
.column-resize-handle::after {
+5 -2
View File
@@ -17,7 +17,7 @@ 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; proxy: string | null; deferCookies?: boolean };
result: MetadataResponse;
};
fetch_media_metadata: {
@@ -54,6 +54,8 @@ type CommandMap = {
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 };
@@ -71,7 +73,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 +84,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 };
};
+26
View File
@@ -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 };
}),
}));
+111
View File
@@ -1,5 +1,6 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { initDownloadListener, useDownloadProgressStore } from './downloadStore';
import { useDownloadStore } from './useDownloadStore';
import * as ipc from '../ipc';
vi.mock('../ipc', () => ({
@@ -45,4 +46,114 @@ 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();
});
});
+75 -80
View File
@@ -1,36 +1,13 @@
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';
interface DownloadProgressState {
progressMap: Record<string, DownloadProgressEvent>;
updateDownloadProgress: (id: string, payload: DownloadProgressEvent) => void;
clearDownloadProgress: (id: string) => void;
}
import { useDownloadProgressStore } from './downloadProgressStore';
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,74 +29,92 @@ 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 (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;
}
// Prevent race condition: don't transition backwards from terminal state
if ((current.status === 'completed' || current.status === 'failed') &&
status !== current.status) {
return;
}
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);
if (['queued', 'retrying', 'completed', 'failed', 'paused'].includes(status)) {
useDownloadProgressStore.getState().clearDownloadProgress(payload.id);
}
const progress = useDownloadProgressStore.getState().progressMap[payload.id];
const updates: Partial<DownloadItem> = {
status,
...(progress ? { fraction: progress.fraction } : {}),
...(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 === '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 === '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]);
}
if (status === 'completed' || status === 'failed' || status === 'paused') {
useDownloadProgressStore.getState().clearDownloadProgress(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]);
}
if (['queued', 'retrying', 'completed', 'failed', 'paused'].includes(status)) {
useDownloadProgressStore.getState().clearDownloadProgress(payload.id);
}
}),
listen('tray-action', (event) => {
+450 -8
View File
@@ -1,5 +1,6 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { getProxyArgs, getSiteLogin, normalizeCustomProxy, useDownloadStore } from './useDownloadStore';
import { dispatchItem, getProxyArgs, getSiteLogin, normalizeCustomProxy, useDownloadStore } from './useDownloadStore';
import { useDownloadProgressStore } from './downloadProgressStore';
import { useSettingsStore } from './useSettingsStore';
import * as ipc from '../ipc';
@@ -47,6 +48,9 @@ vi.mock('./useSettingsStore', () => ({
Other: 'Other',
},
categoryDirectoryOverrides: {},
keychainAccessReady: false,
keychainPromptDismissed: false,
setShowKeychainModal: vi.fn(),
})),
}
}));
@@ -76,6 +80,9 @@ describe('useDownloadStore', () => {
Other: 'Other',
},
categoryDirectoryOverrides: {},
keychainAccessReady: false,
keychainPromptDismissed: false,
setShowKeychainModal: vi.fn(),
} as unknown as ReturnType<typeof useSettingsStore.getState>);
useDownloadStore.setState({
downloads: [],
@@ -91,6 +98,7 @@ describe('useDownloadStore', () => {
pendingAddRequestContexts: {},
pendingAddRequestVersion: 0,
});
useDownloadProgressStore.setState({ progressMap: {} });
});
it('invalidates in-flight Add-modal handoffs when the modal is toggled', () => {
@@ -248,8 +256,15 @@ describe('useDownloadStore', () => {
);
});
await useDownloadStore.getState().removeDownload('late');
const remove = useDownloadStore.getState().removeDownload('late');
await vi.waitFor(() => {
expect(ipc.invokeCommand).toHaveBeenCalledWith(
'cancel_enqueue_generation',
expect.objectContaining({ id: 'late' })
);
});
resolveEnqueue({ id: 'late', filename: 'late.bin' });
await remove;
await expect(start).resolves.toEqual([]);
expect(useDownloadStore.getState().downloads).toEqual([]);
@@ -347,26 +362,79 @@ describe('useDownloadStore', () => {
});
it('resumeDownload unregisters ID and re-dispatches if un-resumable', async () => {
let enqueueGeneration: string | undefined;
useDownloadStore.setState({
downloads: [
{ id: '1', url: 'http://test1', fileName: 'f1', destination: '/tmp', status: 'paused', category: 'Other', dateAdded: '', queueId: 'MAIN', hasBeenDispatched: true },
{ id: 'resume-generation', url: 'http://test1', fileName: 'f1', destination: '/tmp', status: 'paused', category: 'Other', dateAdded: '', queueId: 'MAIN', hasBeenDispatched: true },
] as any[],
backendRegisteredIds: new Set(['1']),
backendRegisteredIds: new Set(['resume-generation']),
});
vi.mocked(ipc.invokeCommand).mockImplementation(async (cmd: string) => {
vi.mocked(ipc.invokeCommand).mockImplementation(async (cmd: string, args?: unknown) => {
if (cmd === 'resume_download') return false; // Not resumable
if (cmd === 'get_pending_order') return ['1'];
if (cmd === 'enqueue_download') {
enqueueGeneration = (args as { item: { lifecycle_generation: string } }).item.lifecycle_generation;
return { id: 'resume-generation', filename: 'f1' };
}
if (cmd === 'get_pending_order') return ['resume-generation'];
return undefined;
});
await useDownloadStore.getState().resumeDownload('1');
await useDownloadStore.getState().resumeDownload('resume-generation');
// It should have called resume_download, then unregistered, then enqueue_download
const calls = vi.mocked(ipc.invokeCommand).mock.calls;
expect(calls.some(c => c[0] === 'resume_download')).toBe(true);
expect(calls.some(c => c[0] === 'enqueue_download')).toBe(true);
expect(useDownloadStore.getState().backendRegisteredIds.has('1')).toBe(true); // Re-registered by dispatchItem
expect(enqueueGeneration).toBe('1');
expect(useDownloadStore.getState().downloads[0].lastTry).toEqual(expect.any(String));
expect(useDownloadStore.getState().backendRegisteredIds.has('resume-generation')).toBe(true); // Re-registered by dispatchItem
});
it('does not re-enqueue when the existing resume RPC fails', async () => {
useDownloadStore.setState({
downloads: [
{ id: 'resume-rpc-error', url: 'http://test1', fileName: 'f1', destination: '/tmp', status: 'paused', category: 'Other', dateAdded: '', queueId: 'MAIN', hasBeenDispatched: true },
] as any[],
backendRegisteredIds: new Set(['resume-rpc-error']),
});
vi.mocked(ipc.invokeCommand).mockImplementation(async (cmd: string) => {
if (cmd === 'resume_download') throw new Error('aria2 RPC unavailable');
return undefined;
});
await expect(useDownloadStore.getState().resumeDownload('resume-rpc-error')).resolves.toBe(false);
expect(
vi.mocked(ipc.invokeCommand).mock.calls.some(([command]) => command === 'enqueue_download')
).toBe(false);
expect(useDownloadStore.getState().downloads[0]).toMatchObject({
status: 'paused',
lastTry: expect.any(String),
});
});
it('cleans an accepted backend enqueue when queue reconciliation fails', async () => {
useDownloadStore.setState({
downloads: [
{ id: 'enqueue-reconcile-error', url: 'http://test1', fileName: 'f1', destination: '/tmp', status: 'queued', category: 'Other', dateAdded: '', queueId: 'MAIN', hasBeenDispatched: false },
] as any[],
});
vi.mocked(ipc.invokeCommand).mockImplementation(async (cmd: string) => {
if (cmd === 'enqueue_download') return { id: 'enqueue-reconcile-error', filename: 'f1' };
if (cmd === 'get_pending_order') throw new Error('queue state unavailable');
return undefined;
});
await expect(useDownloadStore.getState().startQueue('MAIN')).resolves.toEqual([]);
expect(
vi.mocked(ipc.invokeCommand).mock.calls.some(([command]) => command === 'remove_download')
).toBe(true);
expect(useDownloadStore.getState().downloads[0]).toMatchObject({
status: 'failed',
lastError: 'queue state unavailable',
});
});
@@ -493,6 +561,35 @@ describe('useDownloadStore', () => {
expect(useDownloadStore.getState().downloads[0].lastError).toBe('backend unavailable');
});
it('blocks credential-backed dispatch until the custom access decision is made', async () => {
const setShowKeychainModal = vi.fn();
vi.mocked(useSettingsStore.getState).mockReturnValue({
...useSettingsStore.getState(),
siteLogins: [{ id: 'dispatch-login', urlPattern: 'secure.example.com', username: 'user' }],
keychainAccessReady: false,
keychainPromptDismissed: false,
setShowKeychainModal
} as unknown as ReturnType<typeof useSettingsStore.getState>);
useDownloadStore.setState({
downloads: [{
id: 'credential-gated-dispatch',
url: 'https://secure.example.com/file.bin',
fileName: 'file.bin',
destination: '/tmp',
status: 'ready',
category: 'Other',
dateAdded: ''
}] as any[],
backendRegisteredIds: new Set()
});
await expect(dispatchItem('credential-gated-dispatch')).resolves.toBe(false);
expect(setShowKeychainModal).toHaveBeenCalledWith(true);
expect(ipc.invokeCommand).not.toHaveBeenCalledWith('get_keychain_password', expect.anything());
expect(ipc.invokeCommand).not.toHaveBeenCalledWith('enqueue_download', expect.anything());
});
it('preserves backend rejection reasons while auto-resuming saved queued items', async () => {
vi.mocked(ipc.invokeCommand).mockImplementation(async (cmd: string) => {
if (cmd === 'db_get_all_queues') return [];
@@ -520,6 +617,7 @@ describe('useDownloadStore', () => {
});
await useDownloadStore.getState().initDB();
await useDownloadStore.getState().resumePendingDownloads();
expect(useDownloadStore.getState().downloads[0]).toMatchObject({
status: 'failed',
@@ -527,6 +625,169 @@ describe('useDownloadStore', () => {
});
});
it('keeps accepted startup registrations when pending-order refresh fails', async () => {
vi.mocked(ipc.invokeCommand).mockImplementation(async (cmd: string) => {
if (cmd === 'db_get_all_queues') return [];
if (cmd === 'db_get_all_downloads') {
return [JSON.stringify({
id: 'startup-accepted',
url: 'https://example.com/file.bin',
fileName: 'file.bin',
status: 'queued',
category: 'Other',
dateAdded: '',
queueId: '00000000-0000-0000-0000-000000000001',
hasBeenDispatched: true
})];
}
if (cmd === 'enqueue_many') {
return [{ id: 'startup-accepted', success: true, filename: 'file.bin' }];
}
if (cmd === 'get_pending_order') throw new Error('queue state unavailable');
if (cmd === 'resume_download') return true;
return undefined;
});
await useDownloadStore.getState().initDB();
await useDownloadStore.getState().resumePendingDownloads();
expect(useDownloadStore.getState().backendRegisteredIds.has('startup-accepted')).toBe(true);
expect(useDownloadStore.getState().downloads[0]).toMatchObject({
status: 'queued',
hasBeenDispatched: true
});
await expect(useDownloadStore.getState().startQueue('00000000-0000-0000-0000-000000000001'))
.resolves.toEqual(['startup-accepted']);
expect(
vi.mocked(ipc.invokeCommand).mock.calls.filter(call => call[0] === 'enqueue_download')
).toHaveLength(0);
});
it('does not restore a registration after a fast startup terminal event', async () => {
vi.mocked(ipc.invokeCommand).mockImplementation(async (cmd: string) => {
if (cmd === 'db_get_all_queues') return [];
if (cmd === 'db_get_all_downloads') {
return [JSON.stringify({
id: 'startup-completed',
url: 'https://example.com/file.bin',
fileName: 'file.bin',
status: 'queued',
category: 'Other',
dateAdded: '',
queueId: '00000000-0000-0000-0000-000000000001',
hasBeenDispatched: true
})];
}
if (cmd === 'enqueue_many') {
useDownloadStore.setState(state => ({
backendRegisteredIds: new Set(),
downloads: state.downloads.map(download => download.id === 'startup-completed'
? { ...download, status: 'completed' as const }
: download)
}));
return [{ id: 'startup-completed', success: true, filename: 'file.bin' }];
}
if (cmd === 'get_pending_order') return [];
return undefined;
});
await useDownloadStore.getState().initDB();
await useDownloadStore.getState().resumePendingDownloads();
expect(useDownloadStore.getState().downloads[0].status).toBe('completed');
expect(useDownloadStore.getState().backendRegisteredIds.has('startup-completed')).toBe(false);
});
it('does not read saved credentials during database initialization', async () => {
vi.mocked(useSettingsStore.getState).mockReturnValue({
...useSettingsStore.getState(),
siteLogins: [{ id: 'secure-login', urlPattern: 'secure.example.com', username: 'user' }],
keychainAccessReady: false
} as unknown as ReturnType<typeof useSettingsStore.getState>);
vi.mocked(ipc.invokeCommand).mockImplementation(async (cmd: string) => {
if (cmd === 'db_get_all_queues') return [];
if (cmd === 'db_get_all_downloads') {
return [JSON.stringify({
id: 'startup-credential-gated',
url: 'https://secure.example.com/file.bin',
fileName: 'file.bin',
status: 'queued',
category: 'Other',
dateAdded: '',
queueId: '00000000-0000-0000-0000-000000000001',
hasBeenDispatched: true
})];
}
if (cmd === 'get_keychain_password') return 'secret';
if (cmd === 'enqueue_many') return [{ id: 'startup-credential-gated', success: true }];
if (cmd === 'get_pending_order') return [];
return undefined;
});
await useDownloadStore.getState().initDB();
expect(ipc.invokeCommand).not.toHaveBeenCalledWith(
'get_keychain_password',
{ id: 'secure-login' }
);
expect(ipc.invokeCommand).not.toHaveBeenCalledWith('enqueue_many', expect.anything());
vi.mocked(useSettingsStore.getState).mockReturnValue({
...useSettingsStore.getState(),
siteLogins: [{ id: 'secure-login', urlPattern: 'secure.example.com', username: 'user' }],
keychainAccessReady: true
} as unknown as ReturnType<typeof useSettingsStore.getState>);
await useDownloadStore.getState().resumePendingDownloads();
expect(ipc.invokeCommand).toHaveBeenCalledWith('get_keychain_password', { id: 'secure-login' });
expect(ipc.invokeCommand).toHaveBeenCalledWith(
'enqueue_many',
expect.objectContaining({
items: [expect.objectContaining({ password: 'secret' })]
})
);
});
it('shares one startup-resume operation when callers race', async () => {
let releaseEnqueue!: () => void;
const enqueueReleased = new Promise<void>(resolve => {
releaseEnqueue = resolve;
});
vi.mocked(ipc.invokeCommand).mockImplementation(async (cmd: string) => {
if (cmd === 'db_get_all_queues') return [];
if (cmd === 'db_get_all_downloads') {
return [JSON.stringify({
id: 'startup-single-flight',
url: 'https://example.com/file.bin',
fileName: 'file.bin',
status: 'queued',
category: 'Other',
dateAdded: '',
queueId: '00000000-0000-0000-0000-000000000001',
hasBeenDispatched: true
})];
}
if (cmd === 'enqueue_many') {
await enqueueReleased;
return [{ id: 'startup-single-flight', success: true }];
}
if (cmd === 'get_pending_order') return [];
return undefined;
});
await useDownloadStore.getState().initDB();
const first = useDownloadStore.getState().resumePendingDownloads();
const second = useDownloadStore.getState().resumePendingDownloads();
expect(second).toBe(first);
await vi.waitFor(() => {
expect(vi.mocked(ipc.invokeCommand).mock.calls.filter(call => call[0] === 'enqueue_many')).toHaveLength(1);
});
releaseEnqueue();
await Promise.all([first, second]);
});
it('redownloads fallback media without requiring a format selector', async () => {
useDownloadStore.setState({
downloads: [{
@@ -558,6 +819,38 @@ describe('useDownloadStore', () => {
);
});
it('does not claim a redownload when removing the old backend lifecycle fails', async () => {
useDownloadStore.setState({
downloads: [{
id: 'redownload-remove-failed',
url: 'https://example.com/file.bin',
fileName: 'file.bin',
destination: '/tmp',
status: 'paused',
category: 'Other',
dateAdded: '2026-07-15T00:00:00.000Z',
hasBeenDispatched: true
}] as any[],
backendRegisteredIds: new Set(['redownload-remove-failed'])
});
vi.mocked(ipc.invokeCommand).mockImplementation(async (cmd: string) => {
if (cmd === 'remove_download') throw new Error('aria2 did not stop');
return undefined;
});
await expect(useDownloadStore.getState().redownload('redownload-remove-failed'))
.rejects.toThrow('aria2 did not stop');
expect(useDownloadStore.getState().downloads[0]).toMatchObject({
status: 'paused',
dateAdded: '2026-07-15T00:00:00.000Z',
hasBeenDispatched: true
});
expect(useDownloadStore.getState().backendRegisteredIds.has('redownload-remove-failed')).toBe(true);
expect(
vi.mocked(ipc.invokeCommand).mock.calls.some(call => call[0] === 'enqueue_download')
).toBe(false);
});
it('starts and pauses all items regardless of legacy missing queue ids', async () => {
useDownloadStore.setState({
downloads: [
@@ -635,6 +928,134 @@ describe('useDownloadStore', () => {
expect(useDownloadStore.getState().downloads.find(item => item.id === 'done')?.queueId).toBe('old');
});
it('does not reassign an item that completes while queue assignment is awaiting cancellation', async () => {
useDownloadStore.setState({
downloads: [
{ id: 'race-ready', status: 'ready', queueId: 'old' },
{ id: 'race-done', status: 'completed', queueId: 'old' }
] as any[]
});
let resolveCancellation!: () => void;
const cancellation = new Promise<void>(resolve => {
resolveCancellation = resolve;
});
vi.mocked(ipc.invokeCommand).mockImplementation((command: string) => {
if (command === 'cancel_enqueue_generation') return cancellation as never;
return Promise.resolve(undefined) as never;
});
const assignment = useDownloadStore.getState().assignToQueue(['race-ready', 'race-done'], 'new');
await vi.waitFor(() => {
expect(ipc.invokeCommand).toHaveBeenCalledWith(
'cancel_enqueue_generation',
expect.objectContaining({ id: 'race-ready' })
);
});
useDownloadStore.getState().updateDownload('race-ready', { status: 'completed' });
resolveCancellation();
await assignment;
expect(useDownloadStore.getState().downloads.find(item => item.id === 'race-ready')).toMatchObject({
status: 'completed',
queueId: 'old'
});
});
it('cancels the rest of a queue start when pause is requested during dispatch', async () => {
useDownloadStore.setState({
downloads: [
{ id: 'queue-first', url: 'http://test/first', fileName: 'first', destination: '/tmp', status: 'ready', category: 'Other', dateAdded: '', queueId: 'race-queue', queuePosition: 0 },
{ id: 'queue-second', url: 'http://test/second', fileName: 'second', destination: '/tmp', status: 'ready', category: 'Other', dateAdded: '', queueId: 'race-queue', queuePosition: 1 }
] as any[]
});
let resolveEnqueue!: (value: { id: string; filename: string }) => void;
const enqueue = new Promise<{ id: string; filename: string }>(resolve => {
resolveEnqueue = resolve;
});
vi.mocked(ipc.invokeCommand).mockImplementation((command: string) => {
if (command === 'enqueue_download') return enqueue as never;
return Promise.resolve(undefined) as never;
});
const start = useDownloadStore.getState().startQueue('race-queue');
await vi.waitFor(() => {
expect(ipc.invokeCommand).toHaveBeenCalledWith(
'enqueue_download',
expect.objectContaining({ item: expect.objectContaining({ id: 'queue-first' }) })
);
});
const pause = useDownloadStore.getState().pauseQueue('race-queue');
resolveEnqueue({ id: 'queue-first', filename: 'first' });
await expect(pause).resolves.toBe(1);
await expect(start).resolves.toEqual([]);
expect(
vi.mocked(ipc.invokeCommand).mock.calls.filter(([command, args]) =>
command === 'enqueue_download' && (args as any)?.item?.id === 'queue-second'
)
).toHaveLength(0);
});
it('uses one atomic backend move and keeps queue positions unique around active transfers', async () => {
useDownloadStore.setState({
downloads: [
{ id: 'active', status: 'downloading', queueId: 'move-queue', queuePosition: 0 },
{ id: 'one', status: 'queued', queueId: 'move-queue', queuePosition: 1 },
{ id: 'two', status: 'queued', queueId: 'move-queue', queuePosition: 2 },
{ id: 'three', status: 'queued', queueId: 'move-queue', queuePosition: 3 }
] as any[],
backendRegisteredIds: new Set(['three']),
pendingOrder: ['one', 'two', 'three']
});
vi.mocked(ipc.invokeCommand).mockImplementation(async (command: string) => {
if (command === 'move_many_in_queue') return ['one', 'three', 'two'];
if (command === 'get_pending_order') return ['one', 'three', 'two'];
return undefined;
});
await useDownloadStore.getState().moveInQueue('three', 'up');
expect(vi.mocked(ipc.invokeCommand)).toHaveBeenCalledWith('move_many_in_queue', {
ids: ['three'],
queueId: 'move-queue',
direction: 'up'
});
expect(vi.mocked(ipc.invokeCommand)).not.toHaveBeenCalledWith('move_in_queue', expect.anything());
const positions = useDownloadStore.getState().downloads
.filter(item => item.queueId === 'move-queue')
.map(item => item.queuePosition);
expect(new Set(positions).size).toBe(4);
});
it('detaches a registered queued item through the backend before reassigning it', async () => {
useDownloadStore.setState({
downloads: [{
id: 'registered-queued',
url: 'https://example.com/file',
fileName: 'file.bin',
status: 'queued',
category: 'Other',
dateAdded: '',
queueId: 'old',
queuePosition: 0
}],
backendRegisteredIds: new Set(['registered-queued']),
pendingOrder: ['registered-queued']
});
vi.mocked(ipc.invokeCommand).mockResolvedValue(undefined as never);
await useDownloadStore.getState().assignToQueue(['registered-queued'], 'new');
expect(vi.mocked(ipc.invokeCommand)).toHaveBeenCalledWith(
'detach_download_for_reconfigure',
{ id: 'registered-queued' }
);
expect(useDownloadStore.getState().pendingOrder).not.toContain('registered-queued');
expect(useDownloadStore.getState().downloads[0].status).toBe('staged');
});
it('disables scheduler when its last selected queue is deleted', async () => {
const originalSettings = useSettingsStore.getState();
const setScheduler = vi.fn();
@@ -681,6 +1102,27 @@ describe('useDownloadStore', () => {
.toEqual(['active']);
});
it('clears live progress when a download is removed', async () => {
useDownloadStore.setState({
downloads: [
{ id: 'removed-progress', url: 'https://example.com/file', fileName: 'file', status: 'paused', category: 'Other', dateAdded: '' }
] as any[]
});
useDownloadProgressStore.getState().updateDownloadProgress('removed-progress', {
id: 'removed-progress',
fraction: 0.5,
speed: '1 MB/s',
eta: '10s',
size: '2 MB',
size_is_final: false
});
vi.mocked(ipc.invokeCommand).mockResolvedValue(undefined as never);
await useDownloadStore.getState().removeDownload('removed-progress');
expect(useDownloadProgressStore.getState().progressMap).toEqual({});
});
it('asks the backend to preserve resumable assets during replacement removal', async () => {
useDownloadStore.setState({
downloads: [
+408 -195
View File
@@ -7,7 +7,8 @@ import type { DownloadStatus } from '../bindings/DownloadStatus';
import type { ExtensionDownload } from '../bindings/ExtensionDownload';
import type { Queue } from '../bindings/Queue';
import { useSettingsStore } from './useSettingsStore';
import { categoryForFileName, isActiveDownloadStatus, normalizeSpeedLimitForBackend, redactDownloadForPersistence } from '../utils/downloads';
import { useDownloadProgressStore } from './downloadProgressStore';
import { categoryForFileName, isActiveDownloadStatus, isTransferActiveStatus, normalizeSpeedLimitForBackend, redactDownloadForPersistence } from '../utils/downloads';
import {
resolveCategoryDestination
} from '../utils/downloadLocations';
@@ -17,6 +18,62 @@ export type { DownloadCategory } from '../utils/downloads';
const backendDispatchPromises = new Map<string, Promise<boolean>>();
const downloadLifecycleGenerations = new Map<string, bigint>();
const queueReorderPromises = new Map<string, Promise<void>>();
const queueStartPromises = new Map<string, Promise<string[]>>();
const queueControlGenerations = new Map<string, number>();
let pendingStartupResume: Promise<void> | null = null;
const waitForPendingStartupResume = async (): Promise<void> => {
const pending = pendingStartupResume;
if (pending) await pending.catch(() => undefined);
};
const currentQueueControlGeneration = (queueId: string): number =>
queueControlGenerations.get(queueId) ?? 0;
const advanceQueueControlGeneration = (queueId: string): number => {
const nextGeneration = currentQueueControlGeneration(queueId) + 1;
queueControlGenerations.set(queueId, nextGeneration);
return nextGeneration;
};
const isCurrentQueueControlGeneration = (queueId: string, generation: number): boolean =>
currentQueueControlGeneration(queueId) === generation;
const queuePositionComparator = (left: DownloadItem, right: DownloadItem): number =>
(left.queuePosition ?? Number.MAX_SAFE_INTEGER) - (right.queuePosition ?? Number.MAX_SAFE_INTEGER) ||
left.id.localeCompare(right.id);
const queueItemsForReordering = (downloads: DownloadItem[], queueId: string): DownloadItem[] =>
downloads
.filter(download =>
(download.queueId || MAIN_QUEUE_ID) === queueId &&
download.status !== 'completed' &&
!(isActiveDownloadStatus(download.status) && download.status !== 'queued')
)
.sort(queuePositionComparator);
const activeQueueItems = (downloads: DownloadItem[], queueId: string): DownloadItem[] =>
downloads
.filter(download =>
(download.queueId || MAIN_QUEUE_ID) === queueId &&
download.status !== 'completed' &&
isActiveDownloadStatus(download.status) &&
download.status !== 'queued'
)
.sort(queuePositionComparator);
const applyQueueOrder = (
downloads: DownloadItem[],
queueId: string,
pendingItems: DownloadItem[]
): DownloadItem[] => {
const orderedItems = [...activeQueueItems(downloads, queueId), ...pendingItems];
const positions = new Map(orderedItems.map((download, position) => [download.id, position]));
return downloads.map(download => positions.has(download.id)
? { ...download, queuePosition: positions.get(download.id) }
: download);
};
const advanceDownloadLifecycle = (id: string): bigint => {
const nextGeneration = (downloadLifecycleGenerations.get(id) ?? 0n) + 1n;
@@ -86,10 +143,12 @@ const speedLimitForDispatch = (itemSpeedLimit: string | undefined, globalSpeedLi
};
export async function dispatchItem(id: string): Promise<boolean> {
await waitForPendingStartupResume();
if (backendDispatchPromises.has(id)) return backendDispatchPromises.get(id)!;
const promise = (async () => {
let lifecycleGeneration: bigint | null = null;
let backendAccepted = false;
try {
const state = useDownloadStore.getState();
const item = state.downloads.find(d => d.id === id);
@@ -103,8 +162,12 @@ export async function dispatchItem(id: string): Promise<boolean> {
if (!isCurrentDownloadLifecycle(id, lifecycleGeneration)) return false;
const login = getSiteLogin(item.url, settings);
if (login && !item.password && !settings.keychainAccessReady && !settings.keychainPromptDismissed) {
settings.setShowKeychainModal(true);
return false;
}
let keychainPassword = null;
if (login) {
if (login && !item.password && settings.keychainAccessReady) {
try {
keychainPassword = await invoke('get_keychain_password', { id: login.id });
} catch (e) {
@@ -139,7 +202,11 @@ export async function dispatchItem(id: string): Promise<boolean> {
lifecycle_generation: lifecycleGeneration.toString(),
};
useDownloadStore.getState().updateDownload(id, {
lastTry: new Date().toISOString()
});
const accepted = await invoke('enqueue_download', { item: enqueueItem });
backendAccepted = true;
if (!isCurrentDownloadLifecycle(id, lifecycleGeneration)) {
await removeStaleBackendDispatch(id);
return false;
@@ -164,6 +231,9 @@ export async function dispatchItem(id: string): Promise<boolean> {
return true;
} catch (e) {
console.error(`Failed to dispatch ${id}:`, e);
if (backendAccepted && lifecycleGeneration !== null) {
await removeStaleBackendDispatch(id);
}
if (lifecycleGeneration !== null && isCurrentDownloadLifecycle(id, lifecycleGeneration)) {
useDownloadStore.getState().updateDownload(id, {
status: 'failed',
@@ -282,7 +352,7 @@ export const getSiteLogin = (url: string, settings: ReturnType<typeof useSetting
const syncSystemIntegrations = () => {
const settings = useSettingsStore.getState();
const activeCount = useDownloadStore.getState().downloads.filter(d => d.status === 'downloading').length;
const activeCount = useDownloadStore.getState().downloads.filter(d => isTransferActiveStatus(d.status)).length;
invoke('update_dock_badge', { count: settings.showDockBadge ? activeCount : 0 }).catch(() => {});
};
@@ -378,6 +448,7 @@ interface DownloadState {
addQueue: (name: string) => void;
renameQueue: (id: string, name: string) => void;
removeQueue: (id: string) => Promise<void>;
resumePendingDownloads: () => Promise<void>;
initDB: () => Promise<void>;
}
@@ -387,73 +458,87 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
queues: [{ id: MAIN_QUEUE_ID, name: 'Main Queue', isMain: true }],
pendingOrder: [],
setPendingOrder: (order) => set({ pendingOrder: order }),
moveInQueue: async (idOrIds, direction) => {
moveInQueue: (idOrIds, direction) => {
const ids = Array.isArray(idOrIds) ? idOrIds : [idOrIds];
if (ids.length === 0) return;
// Assume all items belong to the same queue as the first item
const firstItem = get().downloads.find(d => d.id === ids[0]);
if (!firstItem) return;
if (ids.length === 0) return Promise.resolve();
// Queue moves must be serialized per queue. Otherwise two optimistic
// moves can calculate from the same order and the last RPC silently wins.
const firstItem = get().downloads.find(download => ids.includes(download.id));
if (!firstItem) return Promise.resolve();
const queueId = firstItem.queueId || MAIN_QUEUE_ID;
const queueItems = get().downloads
.filter(download =>
(download.queueId || MAIN_QUEUE_ID) === queueId &&
download.status !== 'completed' &&
!(isActiveDownloadStatus(download.status) && download.status !== 'queued')
)
.sort((left, right) => (left.queuePosition ?? 0) - (right.queuePosition ?? 0));
const selectedItems = queueItems.filter(item => ids.includes(item.id));
if (selectedItems.length === 0) return;
const unselectedItems = queueItems.filter(item => !ids.includes(item.id));
const selectedIndices = selectedItems.map(item => queueItems.findIndex(d => d.id === item.id));
let insertIndex = 0;
if (direction === 'up') {
const firstSelectedIndex = Math.min(...selectedIndices);
insertIndex = Math.max(0, firstSelectedIndex - 1);
} else {
const lastSelectedIndex = Math.max(...selectedIndices);
insertIndex = Math.min(unselectedItems.length, lastSelectedIndex - selectedItems.length + 2);
}
const reordered = [
...unselectedItems.slice(0, insertIndex),
...selectedItems,
...unselectedItems.slice(insertIndex)
];
const positions = new Map(reordered.map((download, position) => [download.id, position]));
const previousDownloads = get().downloads;
set(state => ({
downloads: state.downloads.map(download => positions.has(download.id)
? { ...download, queuePosition: positions.get(download.id) }
: download)
}));
const previousOperation = queueReorderPromises.get(queueId) ?? Promise.resolve();
const operation = previousOperation.catch(() => undefined).then(async () => {
const allDownloads = get().downloads;
const queueItems = queueItemsForReordering(allDownloads, queueId);
const registeredIdsToMove = selectedItems
.filter(item => get().backendRegisteredIds.has(item.id))
.map(item => item.id);
if (registeredIdsToMove.length === 0) return;
// For backend sync, we must call move_in_queue in the correct order to maintain the block
const idsToMove = direction === 'up' ? registeredIdsToMove : [...registeredIdsToMove].reverse();
const selectedItems = queueItems.filter(item => ids.includes(item.id));
if (selectedItems.length === 0) return;
try {
let order: string[] = [];
for (const id of idsToMove) {
order = (await invoke('move_in_queue', { id, queueId, direction })) as string[];
const previousPositions = new Map([
...activeQueueItems(allDownloads, queueId),
...queueItems
].map(item => [item.id, item.queuePosition]));
const unselectedItems = queueItems.filter(item => !ids.includes(item.id));
const selectedIndices = selectedItems.map(item => queueItems.findIndex(d => d.id === item.id));
let insertIndex = 0;
if (direction === 'up') {
const firstSelectedIndex = Math.min(...selectedIndices);
insertIndex = Math.max(0, firstSelectedIndex - 1);
} else {
const lastSelectedIndex = Math.max(...selectedIndices);
insertIndex = Math.min(unselectedItems.length, lastSelectedIndex - selectedItems.length + 2);
}
if (order.length > 0) {
set({ pendingOrder: order });
const reordered = [
...unselectedItems.slice(0, insertIndex),
...selectedItems,
...unselectedItems.slice(insertIndex)
];
set(state => ({ downloads: applyQueueOrder(state.downloads, queueId, reordered) }));
const registeredIdsToMove = selectedItems
.filter(item => get().backendRegisteredIds.has(item.id))
.map(item => item.id);
if (registeredIdsToMove.length === 0) return;
try {
const order = await invoke('move_many_in_queue', {
ids: registeredIdsToMove,
queueId,
direction
}) as string[];
if (Array.isArray(order)) {
const globalOrder = await invoke('get_pending_order', { queueId: null })
.catch(() => null) as string[] | null;
set(state => ({
pendingOrder: Array.isArray(globalOrder)
? globalOrder
: [
...state.pendingOrder.filter(id => !order.includes(id)),
...order
]
}));
}
} catch (error) {
console.error("Failed to move in queue backend:", error);
// The backend operation is atomic. Restore only queue positions so a
// progress/state event received while the RPC was in flight survives.
set(state => ({
downloads: state.downloads.map(download => previousPositions.has(download.id)
? { ...download, queuePosition: previousPositions.get(download.id) }
: download)
}));
}
} catch (e) {
console.error("Failed to move in queue backend:", e);
set({ downloads: previousDownloads });
}
});
const trackedOperation = operation.finally(() => {
if (queueReorderPromises.get(queueId) === trackedOperation) {
queueReorderPromises.delete(queueId);
}
});
queueReorderPromises.set(queueId, trackedOperation);
return trackedOperation;
},
removeFromQueue: async (id) => {
try {
@@ -616,6 +701,7 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
return false;
},
applyProperties: async (id, updates) => {
await waitForPendingStartupResume();
const wasDispatching = await invalidateAndWaitForDispatch(id);
const state = get();
const item = state.downloads.find(d => d.id === id);
@@ -635,8 +721,9 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
if (item.status === 'queued') {
if (isRegistered) {
await invoke('remove_from_queue', { id });
await invoke('detach_download_for_reconfigure', { id });
state.unregisterBackendIds([id]);
set(current => ({ pendingOrder: current.pendingOrder.filter(value => value !== id) }));
}
state.updateDownload(id, updates);
if (isRegistered || wasDispatching) {
@@ -684,7 +771,11 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
}
},
removeDownload: async (id, deleteFile = false, preserveResumable = false) => {
await invalidateDispatch(id);
await waitForPendingStartupResume();
const { pendingDispatch } = await invalidateDispatch(id);
if (pendingDispatch) {
await pendingDispatch;
}
const item = get().downloads.find(d => d.id === id);
if (item) {
@@ -702,10 +793,12 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
Array.from(state.backendRegisteredIds).filter(registeredId => registeredId !== id)
)
}));
useDownloadProgressStore.getState().clearDownloadProgress(id);
info(`Download ${id} removed`);
syncSystemIntegrations();
},
pauseDownload: async (id) => {
await waitForPendingStartupResume();
const { generation, pendingDispatch } = await invalidateDispatch(id);
if (pendingDispatch) {
await pendingDispatch;
@@ -720,6 +813,7 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
}
},
redownload: async (id) => {
await waitForPendingStartupResume();
const targetItem = get().downloads.find(d => d.id === id);
if (!targetItem) {
throw new Error('Cannot redownload: download was not found.');
@@ -740,6 +834,7 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
get().unregisterBackendIds([id]);
} catch (e) {
console.warn("Could not remove old download from backend", e);
throw e;
}
get().updateDownload(id, {
@@ -760,6 +855,7 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
}
},
resumeDownload: async (id) => {
await waitForPendingStartupResume();
const targetItem = get().downloads.find(d => d.id === id);
if (!targetItem) return false;
@@ -783,14 +879,19 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
status: 'queued',
speed: '-',
eta: '-',
queuePosition: maxPos + 1
queuePosition: maxPos + 1,
lastTry: new Date().toISOString()
});
const resumedExisting = await invoke('resume_download', { id }).catch(() => false);
const resumedExisting = await invoke('resume_download', { id });
let dispatchSucceeded = resumedExisting;
if (!dispatchSucceeded) {
get().unregisterBackendIds([id]);
// A terminal aria2 gid is intentionally re-enqueued as a new
// lifecycle. Advance and cancel the old generation before dispatching
// so QueueManager does not reject the legitimate user retry as stale.
await invalidateAndWaitForDispatch(id);
dispatchSucceeded = await dispatchItem(id);
}
@@ -807,56 +908,91 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
return false;
}
},
startQueue: async (queueId) => {
const runnable = get().downloads
.filter(item => item.queueId === queueId && (item.status === 'queued' || canStartDownload(item.status)))
.sort((left, right) => (left.queuePosition ?? 0) - (right.queuePosition ?? 0));
startQueue: (queueId) => {
const requestedGeneration = currentQueueControlGeneration(queueId);
const previousOperation = queueStartPromises.get(queueId) ?? Promise.resolve([]);
const operation = previousOperation.catch(() => []).then(async () => {
await waitForPendingStartupResume();
const runnable = get().downloads
.filter(item => item.queueId === queueId && (item.status === 'queued' || canStartDownload(item.status)))
.sort(queuePositionComparator);
if (runnable.length === 0) return [];
if (runnable.length === 0 || !isCurrentQueueControlGeneration(queueId, requestedGeneration)) return [];
const acceptedIds: string[] = [];
for (const item of runnable) {
const backendRegistered = get().backendRegisteredIds.has(item.id);
const backendPending = get().pendingOrder.includes(item.id);
const acceptedIds: string[] = [];
for (const item of runnable) {
if (!isCurrentQueueControlGeneration(queueId, requestedGeneration)) break;
if (item.status === 'queued' && backendRegistered && !backendPending) {
if (await get().resumeDownload(item.id)) {
const currentItem = get().downloads.find(download => download.id === item.id);
if (!currentItem || currentItem.status === 'completed') continue;
const backendRegistered = get().backendRegisteredIds.has(item.id);
const backendPending = get().pendingOrder.includes(item.id);
if (currentItem.status === 'queued' && backendRegistered && !backendPending) {
if (await get().resumeDownload(item.id)) {
acceptedIds.push(item.id);
}
continue;
}
if (
currentItem.status === 'ready' ||
currentItem.status === 'staged' ||
currentItem.status === 'failed' ||
!currentItem.hasBeenDispatched ||
!backendRegistered
) {
if (await dispatchItem(item.id)) {
if (!isCurrentQueueControlGeneration(queueId, requestedGeneration)) {
const afterDispatch = get().downloads.find(download => download.id === item.id);
if (
backendDispatchPromises.has(item.id) ||
get().backendRegisteredIds.has(item.id) ||
(afterDispatch && canPauseDownload(afterDispatch.status))
) {
await get().pauseDownload(item.id);
}
continue;
}
const current = get().downloads.find(download => download.id === item.id);
get().updateDownload(item.id, {
hasBeenDispatched: true,
...(current?.status === item.status ? { status: 'queued' as const } : {})
});
acceptedIds.push(item.id);
}
} else if (currentItem.status === 'paused' || currentItem.status === 'queued') {
// If it's queued but already dispatched, it might be waiting.
// If it's paused, we resume it.
if (currentItem.status === 'paused') {
if (!await get().resumeDownload(item.id)) continue;
}
acceptedIds.push(item.id);
}
continue;
}
if (
item.status === 'ready' ||
item.status === 'staged' ||
item.status === 'failed' ||
!item.hasBeenDispatched ||
!backendRegistered
) {
if (await dispatchItem(item.id)) {
const current = get().downloads.find(download => download.id === item.id);
get().updateDownload(item.id, {
hasBeenDispatched: true,
...(current?.status === item.status ? { status: 'queued' as const } : {})
});
acceptedIds.push(item.id);
}
} else if (item.status === 'paused' || item.status === 'queued') {
// If it's queued but already dispatched, it might be waiting.
// If it's paused, we resume it.
if (item.status === 'paused') {
if (!await get().resumeDownload(item.id)) continue;
}
acceptedIds.push(item.id);
info(`Queue ${queueId} started, ${acceptedIds.length} items dispatched/resumed`);
return acceptedIds;
});
const trackedOperation = operation.finally(() => {
if (queueStartPromises.get(queueId) === trackedOperation) {
queueStartPromises.delete(queueId);
}
}
info(`Queue ${queueId} started, ${acceptedIds.length} items dispatched/resumed`);
return acceptedIds;
});
queueStartPromises.set(queueId, trackedOperation);
return trackedOperation;
},
pauseQueue: async (queueId) => {
await waitForPendingStartupResume();
// Invalidate queued starts before taking the snapshot. This prevents a
// start loop that is waiting on metadata/IPC from dispatching later rows
// after the user has already requested Pause Queue.
advanceQueueControlGeneration(queueId);
const activeIds = get().downloads
.filter(item => item.queueId === queueId && canPauseDownload(item.status))
.filter(item =>
item.queueId === queueId &&
(canPauseDownload(item.status) || backendDispatchPromises.has(item.id))
)
.map(item => item.id);
if (activeIds.length === 0) return 0;
@@ -886,8 +1022,15 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
return results.reduce((total, ids) => total + ids.length, 0);
},
pauseAll: async () => {
await waitForPendingStartupResume();
const queueIds = new Set(
get().downloads.map(item => item.queueId || MAIN_QUEUE_ID)
);
for (const queueId of queueIds) {
advanceQueueControlGeneration(queueId);
}
const activeIds = get().downloads
.filter(item => canPauseDownload(item.status))
.filter(item => canPauseDownload(item.status) || backendDispatchPromises.has(item.id))
.map(item => item.id);
if (activeIds.length === 0) return 0;
@@ -897,6 +1040,7 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
return pausedCount;
},
assignToQueue: async (ids, queueId) => {
await waitForPendingStartupResume();
const selectedIds = new Set(ids);
const selected = get().downloads.filter(item => selectedIds.has(item.id));
const locked = selected.find(item => isActiveDownloadStatus(item.status) && item.status !== 'queued');
@@ -904,35 +1048,33 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
throw new Error(`Pause ${locked.fileName} before moving it to another queue.`);
}
await Promise.all(
selected
.filter(item => item.status !== 'completed')
.map(item => invalidateAndWaitForDispatch(item.id))
);
const movableSelected = selected.filter(item => item.status !== 'completed');
const movableIds = new Set(movableSelected.map(item => item.id));
await Promise.all(movableSelected.map(item => invalidateAndWaitForDispatch(item.id)));
for (const item of get().downloads.filter(item => selectedIds.has(item.id))) {
for (const item of get().downloads.filter(item => movableIds.has(item.id))) {
if (!get().backendRegisteredIds.has(item.id)) continue;
if (item.status === 'queued') {
await invoke('remove_from_queue', { id: item.id });
} else if (item.status === 'paused') {
await invoke('detach_download_for_reconfigure', { id: item.id });
}
// The UI can still say queued while a dispatch has already reached
// Aria2/media. Detach through the backend lifecycle owner for every
// registered item; remove_from_queue only handles the pending list.
await invoke('detach_download_for_reconfigure', { id: item.id });
get().unregisterBackendIds([item.id]);
set(state => ({ pendingOrder: state.pendingOrder.filter(value => value !== item.id) }));
}
const queueItems = get().downloads.filter(item =>
!selectedIds.has(item.id) &&
!movableIds.has(item.id) &&
(item.queueId || MAIN_QUEUE_ID) === queueId
);
const maxPos = queueItems.reduce((max, d) => Math.max(max, d.queuePosition ?? 0), -1);
const nextPosition = maxPos + 1;
set(state => ({
downloads: state.downloads.map(item =>
selectedIds.has(item.id) && item.status !== 'completed'
movableIds.has(item.id) && item.status !== 'completed'
? {
...item,
queueId,
queuePosition: nextPosition + selected.findIndex(selectedItem => selectedItem.id === item.id),
queuePosition: nextPosition + movableSelected.findIndex(selectedItem => selectedItem.id === item.id),
status: 'staged' as const,
hasBeenDispatched: false
}
@@ -960,6 +1102,7 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
},
removeQueue: async (id) => {
if (id === MAIN_QUEUE_ID) return;
await waitForPendingStartupResume();
const unfinishedIds = get().downloads
.filter(download => download.queueId === id && download.status !== 'completed')
.map(download => download.id);
@@ -982,6 +1125,149 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
});
}
},
resumePendingDownloads: () => {
if (pendingStartupResume) return pendingStartupResume;
const operation = (async () => {
const active = get().downloads
.filter(d => d.status === 'queued')
.sort((a, b) => (a.queuePosition ?? 0) - (b.queuePosition ?? 0));
if (active.length === 0) return;
try {
const settings = useSettingsStore.getState();
const itemsToEnqueue = [];
for (const pendingItem of active) {
const item = get().downloads.find(download => download.id === pendingItem.id);
if (!item || item.status !== 'queued' || get().backendRegisteredIds.has(item.id)) continue;
const login = getSiteLogin(item.url, settings);
let keychainPassword = null;
if (login && !item.password && settings.keychainAccessReady) {
try {
keychainPassword = await invoke('get_keychain_password', { id: login.id });
} catch (e) {
console.warn("Could not fetch keychain password for login:", e);
}
}
const destPath = item.destination ||
await resolveCategoryDestination(settings, item.category);
itemsToEnqueue.push({
id: item.id,
queue_id: item.queueId || MAIN_QUEUE_ID,
url: item.url,
destination: destPath,
filename: item.fileName,
connections: item.connections || settings.perServerConnections || null,
speed_limit: speedLimitForDispatch(item.speedLimit, settings.globalSpeedLimit),
username: item.username || (login ? login.username : null),
password: item.password || keychainPassword,
headers: item.headers || null,
checksum: item.checksum || null,
cookies: item.cookies || null,
mirrors: item.mirrors || null,
user_agent: settings.customUserAgent.trim() || null,
max_tries: settings.maxAutomaticRetries,
proxy: await getProxyArgs(settings),
format_selector: item.mediaFormatSelector || null,
cookie_source: settings.mediaCookieSource !== 'none' ? settings.mediaCookieSource : null,
is_media: item.isMedia || false,
lifecycle_generation: currentDownloadLifecycle(item.id).toString(),
});
}
const currentItems = new Map(get().downloads.map(item => [item.id, item]));
const dispatchableItems = itemsToEnqueue.filter(item => {
const current = currentItems.get(item.id);
return current &&
current.status === 'queued' &&
!get().backendRegisteredIds.has(item.id) &&
!backendDispatchPromises.has(item.id) &&
currentDownloadLifecycle(item.id).toString() === item.lifecycle_generation;
});
if (dispatchableItems.length === 0) return;
const results = await invoke('enqueue_many', { items: dispatchableItems });
const registeredIds = results.filter(result => result.success).map(result => result.id);
const failedErrors = new Map(
results
.filter(result => !result.success)
.map(result => [result.id, result.error || 'Backend rejected the queued download.'])
);
const acceptedIdSet = new Set(registeredIds);
const generationById = new Map(dispatchableItems.map(item => [item.id, item.lifecycle_generation]));
// Commit backend ownership as soon as enqueue_many accepts an item.
// The order query is a separate best-effort view read; if it fails,
// forgetting these registrations would let a later queue start
// enqueue the same backend lifecycle a second time.
set(state => {
// A very fast backend transfer can emit a terminal event before
// this batch result is merged. Preserve that event's ownership
// cleanup instead of re-registering an already-terminal ID.
const liveAcceptedIds = new Set(
state.downloads
.filter(download =>
acceptedIdSet.has(download.id) &&
download.status !== 'completed' &&
download.status !== 'failed' &&
currentDownloadLifecycle(download.id).toString() === generationById.get(download.id)
)
.map(download => download.id)
);
return {
backendRegisteredIds: new Set([
...state.backendRegisteredIds,
...liveAcceptedIds
]),
downloads: state.downloads.map(download =>
failedErrors.has(download.id)
? {
...download,
status: 'failed' as const,
lastError: failedErrors.get(download.id)
}
: liveAcceptedIds.has(download.id)
? { ...download, hasBeenDispatched: true, lastError: undefined }
: download
)
};
});
// A cancellation can race with the batch RPC. Use the lifecycle-aware
// cancellation command for any accepted generation that is no longer
// current; never remove by ID because a newer lifecycle could already
// own that same download row.
await Promise.all(registeredIds
.filter(id => !get().backendRegisteredIds.has(id))
.map(id => {
const generation = generationById.get(id);
if (generation === undefined) return Promise.resolve();
return invoke('cancel_enqueue_generation', {
id,
generation
}).catch(error => {
console.warn(`Failed to cancel stale startup enqueue for ${id}:`, error);
});
}));
try {
const order = await invoke('get_pending_order', { queueId: null });
set({ pendingOrder: order });
} catch (e) {
console.error("Failed to refresh pending order after auto-resume:", e);
}
} catch (e) {
console.error("Failed to auto-resume active downloads:", e);
throw e;
}
})();
const trackedOperation = operation.finally(() => {
if (pendingStartupResume === trackedOperation) pendingStartupResume = null;
});
pendingStartupResume = trackedOperation;
return trackedOperation;
},
initDB: async () => {
try {
const queues = (await invoke('db_get_all_queues')).map(value => JSON.parse(value) as Queue);
@@ -1005,79 +1291,6 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
)
}));
// Auto resume downloads that were active or queued
const active = get().downloads
.filter(d => d.status === 'queued')
.sort((a, b) => (a.queuePosition ?? 0) - (b.queuePosition ?? 0));
if (active.length > 0) {
try {
const settings = useSettingsStore.getState();
const itemsToEnqueue = [];
for (const item of active) {
const login = getSiteLogin(item.url, settings);
let keychainPassword = null;
if (login) {
try {
keychainPassword = await invoke('get_keychain_password', { id: login.id });
} catch (e) {
console.warn("Could not fetch keychain password for login:", e);
}
}
const destPath = item.destination ||
await resolveCategoryDestination(settings, item.category);
itemsToEnqueue.push({
id: item.id,
queue_id: item.queueId || MAIN_QUEUE_ID,
url: item.url,
destination: destPath,
filename: item.fileName,
connections: item.connections || settings.perServerConnections || null,
speed_limit: speedLimitForDispatch(item.speedLimit, settings.globalSpeedLimit),
username: item.username || (login ? login.username : null),
password: item.password || keychainPassword,
headers: item.headers || null,
checksum: item.checksum || null,
cookies: item.cookies || null,
mirrors: item.mirrors || null,
user_agent: settings.customUserAgent.trim() || null,
max_tries: settings.maxAutomaticRetries,
proxy: await getProxyArgs(settings),
format_selector: item.mediaFormatSelector || null,
cookie_source: settings.mediaCookieSource !== 'none' ? settings.mediaCookieSource : null,
is_media: item.isMedia || false,
lifecycle_generation: currentDownloadLifecycle(item.id).toString(),
});
}
const results = await invoke('enqueue_many', { items: itemsToEnqueue });
const registeredIds = results.filter(result => result.success).map(result => result.id);
const failedErrors = new Map(
results
.filter(result => !result.success)
.map(result => [result.id, result.error || 'Backend rejected the queued download.'])
);
const order = await invoke('get_pending_order', { queueId: null });
set(state => ({
pendingOrder: order,
backendRegisteredIds: new Set([
...state.backendRegisteredIds,
...registeredIds
]),
downloads: state.downloads.map(download =>
failedErrors.has(download.id)
? {
...download,
status: 'failed' as const,
lastError: failedErrors.get(download.id)
}
: registeredIds.includes(download.id)
? { ...download, hasBeenDispatched: true, lastError: undefined }
: download
)
}));
} catch (e) {
console.error("Failed to auto-resume active downloads:", e);
}
}
} catch (e) {
console.error("Failed to init DB", e);
throw e;
+68
View File
@@ -0,0 +1,68 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { useSettingsStore } from './useSettingsStore';
import * as ipc from '../ipc';
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);
});
});
+180 -51
View File
@@ -17,11 +17,57 @@ import {
DEFAULT_CATEGORY_SUBFOLDERS,
normalizeDownloadLocationSettings
} from '../utils/downloadLocations';
import { normalizeSpeedLimitForBackend } from '../utils/downloads';
let settingsSave = Promise.resolve();
const DEFAULT_SCHEDULER_QUEUE_ID = '00000000-0000-0000-0000-000000000001';
export const DEFAULT_SPEED_LIMIT_PRESET_VALUES = [1, 5, 10];
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') {
@@ -53,10 +99,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,6 +136,7 @@ export interface SettingsState {
schedulerLastStartKey: string;
schedulerLastStopKey: string;
lastCustomSpeedLimitKiB: number;
lastCustomSpeedLimitUnit: string;
// Replicated SwiftUI App Settings
perServerConnections: number;
@@ -111,6 +158,9 @@ export interface SettingsState {
extensionPairingToken: string;
isPairingTokenPersistent: boolean;
keychainAccessGranted: boolean;
keychainAccessVersion: string;
keychainAccessReady: boolean;
keychainPromptDismissed: boolean;
autoCheckUpdates: boolean;
showKeychainModal: boolean;
@@ -118,7 +168,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,6 +179,7 @@ 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;
@@ -156,37 +207,14 @@ export interface SettingsState {
setAutoCheckUpdates: (autoCheckUpdates: boolean) => void;
hydratePairingToken: () => Promise<boolean>;
setShowKeychainModal: (show: boolean) => void;
setKeychainAccessReady: (ready: boolean) => void;
dismissKeychainPrompt: (version?: string) => void;
hydrateSessionPairingToken: () => 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,6 +243,7 @@ export const useSettingsStore = create<SettingsState>()(
schedulerLastStartKey: '',
schedulerLastStopKey: '',
lastCustomSpeedLimitKiB: 1024,
lastCustomSpeedLimitUnit: 'MB/s',
// Replicated SwiftUI defaults
perServerConnections: 16,
@@ -234,8 +263,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 +287,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,10 +308,15 @@ 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 }),
setAppFontSize: (appFontSize) => set({ appFontSize }),
@@ -331,26 +373,47 @@ 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 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.
// The backend migrates legacy settings copies and reads the token from
// the credential store after the app state is ready to receive it.
// Portable mode remains the explicit folder-contained exception.
const result = await invoke('hydrate_extension_pairing_token');
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 () => {
const result = await invoke('get_session_pairing_token');
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 +452,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,6 +471,7 @@ export const useSettingsStore = create<SettingsState>()(
schedulerLastStartKey: state.schedulerLastStartKey,
schedulerLastStopKey: state.schedulerLastStopKey,
lastCustomSpeedLimitKiB: state.lastCustomSpeedLimitKiB,
lastCustomSpeedLimitUnit: state.lastCustomSpeedLimitUnit,
perServerConnections: state.perServerConnections,
maxAutomaticRetries: state.maxAutomaticRetries,
@@ -425,8 +489,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 +503,75 @@ 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),
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 +584,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
});
}
+25
View File
@@ -125,6 +125,31 @@ 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
});
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
});
});
it('appends every unseen handoff after the observed version', () => {
const merged = appendRequestUrlsAfterVersion(
'https://existing.example/file.zip',
+4
View File
@@ -88,8 +88,12 @@ export const reconcileDownloadRows = (
const contextChanged = requestContextVersion !== undefined
&& requestContextVersion !== preserved.requestContextVersion;
if ((forcedMedia && !preserved.isMedia) || contextChanged) {
const requestedFilename = requestFilenames[input.sourceUrl];
return {
...preserved,
file: contextChanged
? canonicalizeDownloadFileName(requestedFilename || fileNameFromUrl(input.sourceUrl))
: preserved.file,
status: 'loading',
generation: preserved.generation + 1,
requestContextVersion,
+60
View File
@@ -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();
});
});
+110
View File
@@ -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;
};
+9 -5
View File
@@ -36,6 +36,10 @@ 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 normalizeSpeedLimitForBackend = (value?: string | null): string | null => {
const trimmed = value?.trim();
if (!trimmed) return null;
@@ -121,14 +125,14 @@ const DOWNLOAD_SECRET_FIELDS = ['password', 'cookies', 'headers'] as const;
* progress fields (`fraction`, `speed`, `eta`) are also dropped as in the
* existing persistence path.
*
* 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;
for (const field of DOWNLOAD_SECRET_FIELDS) {
+95
View File
@@ -0,0 +1,95 @@
import { describe, expect, it } from 'vitest';
import { getKeychainStartupDecision } from './keychainStartup';
describe('getKeychainStartupDecision', () => {
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('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
});
});
});
+33
View File
@@ -0,0 +1,33 @@
export type KeychainStartupState = {
portable: boolean;
appVersion: string;
approvedVersion: string;
accessGranted: boolean;
promptDismissed: boolean;
};
export type KeychainStartupDecision = {
deferKeychainHydration: boolean;
showKeychainPrompt: boolean;
};
export const getKeychainStartupDecision = ({
portable,
appVersion,
approvedVersion,
accessGranted,
promptDismissed
}: KeychainStartupState): KeychainStartupDecision => {
if (portable) {
return {
deferKeychainHydration: false,
showKeychainPrompt: false
};
}
const versionChanged = Boolean(appVersion) && approvedVersion !== appVersion;
return {
deferKeychainHydration: !accessGranted || versionChanged,
showKeychainPrompt: versionChanged || (!accessGranted && !promptDismissed)
};
};
+61
View File
@@ -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
View File
@@ -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;
+2 -1
View File
@@ -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;
+17
View File
@@ -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);
});
});
+27
View File
@@ -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;
}
};