mirror of
https://github.com/nimbold/Firelink.git
synced 2026-07-26 20:18:37 +00:00
Compare commits
76 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 61eb034db1 | |||
| c949cbb9ee | |||
| 566396e629 | |||
| db39cd2153 | |||
| f4b830414d | |||
| 0447d1cab7 | |||
| 513143d745 | |||
| a8dc4fb447 | |||
| 50c3da2f5d | |||
| 6ef911919d | |||
| 5144ecd39e | |||
| a56b859151 | |||
| 469faed7b9 | |||
| 79ce0c18a1 | |||
| d195a132b3 | |||
| feb5d8e87d | |||
| a136fa832c | |||
| f3d0e0be13 | |||
| edeef0ac54 | |||
| 9f333618fc | |||
| ed54a048ab | |||
| d2479f52be | |||
| 6e6ae51395 | |||
| 1d197432b2 | |||
| 9917f29743 | |||
| df3bc359c4 | |||
| 57f132f53e | |||
| 52b00e5cb4 | |||
| 8e02a61c3f | |||
| 82f2914077 | |||
| 9224117d77 | |||
| 3c84b5c1a4 | |||
| c34c489aef | |||
| b81e8391e1 | |||
| 6ff0047d6c | |||
| 4a3fece22b | |||
| 1da0fa7223 | |||
| d6af4ee2b5 | |||
| 2479ead4ed | |||
| 80a29356e0 | |||
| 45bbca0515 | |||
| e35b1af731 | |||
| 19953a210e | |||
| 76850f2433 | |||
| 0eee47b97f | |||
| e8487ee71b | |||
| e07182fbf2 | |||
| 2d9eed99d5 | |||
| 80cf835060 | |||
| c78a72a8d7 | |||
| df85a77987 | |||
| be2a98fbcd | |||
| fcfdffa6e0 | |||
| 9805c9288a | |||
| dad5b7bc5e | |||
| f441c687f0 | |||
| a0f44b79ad | |||
| 56b4c9f511 | |||
| c1fa87b953 | |||
| c7ec8cd666 | |||
| 9133e3b05b | |||
| 5bbee12602 | |||
| 7894c05bba | |||
| 33375df2ff | |||
| 1922db8ea0 | |||
| ba70662165 | |||
| 629a34d1e8 | |||
| 248f3869ad | |||
| 4f4c655de6 | |||
| 3fbd0742be | |||
| c5025fd5a0 | |||
| b1c84a0fb9 | |||
| fbb89cde8e | |||
| cd8ab5c12b | |||
| ed7c47cb49 | |||
| 8c035167c8 |
@@ -61,16 +61,39 @@ jobs:
|
||||
libdbus-1-dev \
|
||||
pkg-config \
|
||||
xvfb \
|
||||
libtinfo5
|
||||
libtinfo5 \
|
||||
rpm \
|
||||
cpio \
|
||||
libarchive-tools \
|
||||
desktop-file-utils \
|
||||
xdg-utils
|
||||
- run: npm ci
|
||||
- name: Provision locked engines
|
||||
if: runner.os != 'macOS'
|
||||
run: node scripts/provision-engines.js --target ${{ matrix.target }}
|
||||
- name: Build package
|
||||
if: runner.os != 'Linux'
|
||||
run: npm run tauri build -- -vv --target ${{ matrix.target }} --bundles ${{ matrix.bundles }}
|
||||
env:
|
||||
APPIMAGE_EXTRACT_AND_RUN: 1
|
||||
FIRELINK_OMIT_ENGINE_DIST_FOR_TAURI_BUNDLE: ${{ runner.os == 'Linux' && '1' || '' }}
|
||||
- name: Build Linux native packages
|
||||
if: runner.os == 'Linux'
|
||||
run: npm run tauri build -- -vv --target ${{ matrix.target }} --bundles deb,rpm
|
||||
env:
|
||||
APPIMAGE_EXTRACT_AND_RUN: 1
|
||||
- name: Verify and preserve Linux native packages
|
||||
if: runner.os == 'Linux'
|
||||
run: |
|
||||
node scripts/verify-linux-packages.js --target ${{ matrix.target }}
|
||||
mkdir -p "$RUNNER_TEMP/firelink-native-packages/deb" "$RUNNER_TEMP/firelink-native-packages/rpm"
|
||||
cp src-tauri/target/${{ matrix.target }}/release/bundle/deb/*.deb "$RUNNER_TEMP/firelink-native-packages/deb/"
|
||||
cp src-tauri/target/${{ matrix.target }}/release/bundle/rpm/*.rpm "$RUNNER_TEMP/firelink-native-packages/rpm/"
|
||||
- name: Build Linux AppImage
|
||||
if: runner.os == 'Linux'
|
||||
run: npm run tauri build -- -vv --target ${{ matrix.target }} --bundles appimage
|
||||
env:
|
||||
APPIMAGE_EXTRACT_AND_RUN: 1
|
||||
FIRELINK_OMIT_ENGINE_DIST_FOR_TAURI_BUNDLE: '1'
|
||||
- name: Install pinned appimagetool (Linux only)
|
||||
if: runner.os == 'Linux'
|
||||
env:
|
||||
@@ -102,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
|
||||
@@ -128,6 +197,27 @@ 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:
|
||||
name: Firelink-Linux-x64-Deb-${{ github.ref_name }}
|
||||
path: ${{ runner.temp }}/firelink-native-packages/deb/*.deb
|
||||
if-no-files-found: error
|
||||
retention-days: 3
|
||||
- uses: actions/upload-artifact@v7
|
||||
if: runner.os == 'Linux'
|
||||
with:
|
||||
name: Firelink-Linux-x64-RPM-${{ github.ref_name }}
|
||||
path: ${{ runner.temp }}/firelink-native-packages/rpm/*.rpm
|
||||
if-no-files-found: error
|
||||
retention-days: 3
|
||||
|
||||
publish:
|
||||
name: Publish GitHub release
|
||||
@@ -169,7 +259,10 @@ jobs:
|
||||
|
||||
rename_asset '*.dmg' "Firelink_${VERSION}_macOS-ARM64.dmg"
|
||||
rename_asset '*.AppImage' "Firelink_${VERSION}_Linux-x64.AppImage"
|
||||
rename_asset '*.deb' "Firelink_${VERSION}_Linux-x64.deb"
|
||||
rename_asset '*.rpm' "Firelink_${VERSION}_Linux-x64.rpm"
|
||||
rename_asset '*.exe' "Firelink_${VERSION}_Windows-x64-setup.exe"
|
||||
rename_asset '*.zip' "Firelink_${VERSION}_Windows-x64-portable.zip"
|
||||
- name: Generate checksums
|
||||
run: |
|
||||
cd release-assets
|
||||
|
||||
@@ -17,6 +17,7 @@ implementation_plan.md
|
||||
CROSS_PLATFORM_CHECKLIST.md
|
||||
Cross-platform-checklist-gemini.MD
|
||||
YouTube_media_download_handoff.md
|
||||
Release_checklist.md
|
||||
|
||||
# Frontend output and logs
|
||||
node_modules/
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
[submodule "Extensions/Firefox"]
|
||||
path = Extensions/Firefox
|
||||
[submodule "Extensions/Browser"]
|
||||
path = Extensions/Browser
|
||||
url = https://github.com/nimbold/Firelink-Extension.git
|
||||
|
||||
@@ -5,6 +5,67 @@ All notable changes to Firelink will be documented in this file.
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [1.1.1] - 2026-07-17
|
||||
|
||||
This patch release focuses on transfer reliability, browser captures, and easier download control.
|
||||
|
||||
### New
|
||||
- Add YouTube playlist downloads with smoother queueing and scrolling for large playlists.
|
||||
- Add live per-download connection controls, clipboard capture for the Add window, and byte-level progress updates.
|
||||
|
||||
### Improved
|
||||
- Recover slow or stalled transfers more reliably and apply connection defaults consistently, addressing [#19](https://github.com/nimbold/Firelink/issues/19) and [#20](https://github.com/nimbold/Firelink/issues/20).
|
||||
- Make pause, resume, retry, cancel, remove, and completion handling more consistent when actions or background events overlap.
|
||||
- Improve playlist state, media size estimates, and large-list performance.
|
||||
- Keep clipboard, browser, and deep-link handoffs behind the startup consent explanation, and make that boundary clearer.
|
||||
- Refresh bundled engines and dependencies while strengthening cross-platform package and diagnostic checks.
|
||||
|
||||
### Fixed
|
||||
- Fix Gmail and other authenticated browser downloads that could lose their filename or save a sign-in page after a redirect, including Chrome Incognito, addressing [#21](https://github.com/nimbold/Firelink/issues/21).
|
||||
- Keep browser-capture metadata, cookies, and destinations tied to the correct download through redirects.
|
||||
- Prevent invalid URLs, late download events, stale progress, and abandoned media work from creating misleading rows or leftover temporary files.
|
||||
- Keep sensitive local paths, credentials, and persisted records protected in errors, diagnostics, and download state.
|
||||
|
||||
## [1.1.0] - 2026-07-15
|
||||
|
||||
This is a stability-focused release with safer downloads, browser handoffs, settings, and cross-platform packages.
|
||||
|
||||
### New
|
||||
- Add a secure Windows portable ZIP, addressing the portable-version request in [#15](https://github.com/nimbold/Firelink/issues/15). Settings, queues, logs, and WebView data stay with the portable folder, while saved site passwords remain protected in Windows Credential Manager.
|
||||
|
||||
### Improved
|
||||
- Make pause, resume, retry, remove, redownload, and queue actions reliable when several operations happen close together. Downloads now recover their state more consistently instead of restarting unexpectedly or appearing stuck after completion.
|
||||
- Make browser captures and the Add window safer and more dependable. Captured cookies are no longer sent to metadata checks unless authentication is actually needed, while ordinary downloads still keep the browser session they need. This resolves the fallback error reported in [#16](https://github.com/nimbold/Firelink/issues/16).
|
||||
- Improve settings and startup behavior: speed-limit units stay consistent, saved settings are handled more safely, and credential-store access waits until it is needed.
|
||||
- Improve browser handoff safety with stronger local request, replay, path, and credential checks.
|
||||
- Strengthen macOS, Windows, and Linux release verification, refresh the bundled FFmpeg payload, and make package and diagnostic checks more reliable.
|
||||
|
||||
### Fixed
|
||||
- Prevent late or duplicate background events from bringing back downloads after a newer pause, remove, or edit action has already won.
|
||||
- Reconcile completed downloads even when a background completion event is missed, so finished files do not remain visually stuck at the end of the transfer.
|
||||
- Decode URL-derived filenames so links containing encoded characters such as `%20` produce readable names, addressing [#18](https://github.com/nimbold/Firelink/issues/18).
|
||||
- Keep local paths, usernames, credentials, and other sensitive values out of errors and diagnostic output where they are not needed.
|
||||
|
||||
## [1.0.4] - 2026-07-12
|
||||
|
||||
### New
|
||||
- Automatically fill the Add window with valid links from the clipboard when you choose **Add link**, addressing [#10](https://github.com/nimbold/Firelink/issues/10).
|
||||
- Add a persistent, accessible collapse control for the **Folders** section so the sidebar can stay tidy, addressing [#13](https://github.com/nimbold/Firelink/issues/13).
|
||||
- Add verified Linux `.deb` and `.rpm` packages alongside the portable AppImage, completing the Linux packaging request in [#3](https://github.com/nimbold/Firelink/issues/3).
|
||||
|
||||
### Improved
|
||||
- Make queue actions safer: rapid clicks no longer open item properties by accident, and replacing an existing download preserves resumable progress instead of starting from zero, addressing [#11](https://github.com/nimbold/Firelink/issues/11) and [#12](https://github.com/nimbold/Firelink/issues/12).
|
||||
- Improve browser-captured batches so each link keeps its own metadata, headers, cookies, and destination instead of sharing stale request details.
|
||||
- Make media downloads more reliable with custom or system proxies, clearer metadata errors, more accurate quality choices, and steadier retry, speed, ETA, and progress updates, continuing the work reported in [#5](https://github.com/nimbold/Firelink/issues/5) and [#8](https://github.com/nimbold/Firelink/issues/8).
|
||||
- Keep the Logs view responsive while it is open and redact local paths and usernames from diagnostic output before it is shown or exported.
|
||||
- Keep dialogs and controls clear of macOS, Windows, and Linux window controls, and strengthen pause, resume, retry, and removal behavior during rapid actions.
|
||||
- Clarify incomplete-download handling: aria2 sidecar files show when a download is unfinished, preserve resume information, and are removed after completion, addressing [#14](https://github.com/nimbold/Firelink/issues/14).
|
||||
|
||||
### Fixed
|
||||
- Prevent stale background queue work from resurrecting, duplicating, or restarting downloads after a newer pause, remove, or edit action wins.
|
||||
- Keep explicit media requests on Firelink's configured browser-cookie source instead of forwarding raw browser cookies, while preserving the browser session for ordinary captured downloads.
|
||||
- Make final HTTP errors visible during metadata requests and prevent internal retry limits from multiplying unexpectedly.
|
||||
|
||||
## [1.0.3] - 2026-07-09
|
||||
|
||||
### Improved
|
||||
|
||||
Submodule
+1
Submodule Extensions/Browser added at 656ef06d15
Submodule Extensions/Firefox deleted from be632f368c
@@ -5,7 +5,7 @@
|
||||
|
||||
**A fast, focused desktop download manager for macOS, Windows, and Linux.**
|
||||
|
||||
[](https://github.com/nimbold/Firelink/releases)
|
||||
[](https://github.com/nimbold/Firelink/releases)
|
||||
[](#platforms)
|
||||
[](#platforms)
|
||||
[](#platforms)
|
||||
@@ -33,18 +33,23 @@
|
||||
|
||||
## Why Firelink
|
||||
|
||||
Firelink is a desktop download manager for fast transfers, browser capture, media extraction, scheduling, and clear file placement.
|
||||
Firelink is a cross-platform desktop download manager for direct transfers, browser capture, media extraction, scheduling, and clear file placement.
|
||||
|
||||
It is now a cross-platform Rust/Tauri app with a React and TypeScript interface. A native backend coordinates downloads with aria2, yt-dlp, FFmpeg, Deno, and SQLite.
|
||||
It combines a Rust/Tauri backend with a React and TypeScript interface. Bundled aria2, yt-dlp, FFmpeg, Deno, and SQLite support the download and media workflows.
|
||||
|
||||
The current desktop release is **1.1.1**, paired with Firelink Companion **2.0.5**.
|
||||
|
||||
This release adds YouTube playlist downloads, live connection controls, clipboard capture, and clearer byte-level progress. It also improves slow-transfer recovery, authenticated browser captures, and startup consent handling.
|
||||
|
||||
## Features
|
||||
|
||||
- **Segmented downloads** with aria2, retries, speed limits, and connection controls.
|
||||
- **Media downloads** with yt-dlp, FFmpeg, Deno, live progress, speed, and ETA.
|
||||
- **Add window** for metadata, duplicates, location choices, and captured links.
|
||||
- **Playlist downloads** for YouTube playlists with queueing and efficient large-list rendering.
|
||||
- **Add window** for metadata, duplicates, location choices, captured links, clipboard-prefilled URLs, and live connection limits.
|
||||
- **Persistent queues** with pause, resume, retry, redownload, sorting, multi-select, and bulk actions.
|
||||
- **Scheduling** with start/stop windows, speed rules, and post-queue actions.
|
||||
- **File organization** with categories, default folders, per-download overrides, and reveal/trash actions.
|
||||
- **File organization** with categories, default folders, a collapsible Folders section, per-download overrides, and reveal/trash actions.
|
||||
- **Browser handoff** through local pairing, signed requests, Add window review, replay protection, and server checks.
|
||||
- **Desktop integration** with tray controls, notifications, sounds, sleep prevention, and secure credential storage.
|
||||
- **Diagnostics** with engine health checks, structured logs, and package verification.
|
||||
@@ -57,10 +62,29 @@ Download desktop builds from [GitHub Releases](https://github.com/nimbold/Fireli
|
||||
| --- | --- | --- |
|
||||
| **macOS Apple silicon** | `.dmg` | Not notarized. If macOS blocks the first launch, approve Firelink in **System Settings -> Privacy & Security**. |
|
||||
| **Windows x64** | NSIS `.exe` installer | Unsigned. Windows SmartScreen may warn until code signing is added. |
|
||||
| **Linux x64** | `.AppImage` | Make executable before launching if your desktop environment does not do that automatically. |
|
||||
| **Windows x64 portable** | `.zip` archive | Extract to a writable folder and launch `firelink.exe`. See the expandable notes below. |
|
||||
| **Linux x64** | `.deb`, `.rpm`, or `.AppImage` | Use `.deb` for Debian-family systems, `.rpm` for Fedora/RPM-family systems, or AppImage as the self-contained package. AppImage may need executable permission. |
|
||||
|
||||
Bundles include the required engines. Users do not need aria2, yt-dlp, FFmpeg, Deno, Python, Homebrew, or another package manager.
|
||||
|
||||
The native packages use the distribution's normal desktop runtime dependencies. AppImage is self-contained but uses the normal per-user application-data locations.
|
||||
|
||||
<details>
|
||||
<summary><strong>Windows portable ZIP notes</strong></summary>
|
||||
|
||||
The portable ZIP is an opt-in secondary distribution. Extract it to a writable folder and launch `firelink.exe`:
|
||||
|
||||
- Keep the extracted folder writable; avoid `Program Files`, read-only media, and folders that block SQLite or WebView writes.
|
||||
- Settings, queues, logs, and WebView data stay beside `firelink.exe` under `data/`.
|
||||
- Close Firelink before moving or copying the folder, and close the installed app before launching the portable copy.
|
||||
- Credentials, browser cookies, and URL query/fragment data are not saved in portable queue records. Active downloads that depend on them must be added again after restart.
|
||||
- Saved site passwords remain in the Windows credential store and are not copied into the archive.
|
||||
- The folder contains the extension pairing credential, so treat it as sensitive and do not share it.
|
||||
- Saved absolute download locations may need to be selected again after moving the folder to another drive.
|
||||
- The installer remains the supported path for `firelink://` browser launch registration.
|
||||
|
||||
</details>
|
||||
|
||||
## Browser Extension
|
||||
|
||||
<p align="center">
|
||||
@@ -69,7 +93,7 @@ Bundles include the required engines. Users do not need aria2, yt-dlp, FFmpeg, D
|
||||
<a href="https://github.com/nimbold/Firelink-Extension#manual-chromium-installation"><img src="https://img.shields.io/badge/Manual%20install-Chromium-4285F4?style=for-the-badge&logo=googlechrome&logoColor=white" alt="Read manual Chromium install instructions" /></a>
|
||||
</p>
|
||||
|
||||
Firelink Companion sends browser links and downloads to the desktop app.
|
||||
Firelink Companion connects browser links and downloads to the desktop app.
|
||||
|
||||
What it adds:
|
||||
|
||||
@@ -81,9 +105,9 @@ What it adds:
|
||||
- Fallback to the browser download when Firelink is closed or rejects a handoff.
|
||||
- Captured links always open Firelink's Add window before anything is added to the download list.
|
||||
|
||||
Install the extension, open Firelink, then pair it from **Settings -> Integrations**. Firefox users can install from Mozilla Add-ons. Chromium users can use the [manual load-unpacked flow](https://github.com/nimbold/Firelink-Extension#manual-chromium-installation) with `firelink-chromium.zip` from the [extension releases](https://github.com/nimbold/Firelink-Extension/releases). Firelink Companion 2.0.2 is the matching extension release for Firelink 1.0.3.
|
||||
Install the extension, open Firelink, then pair it from **Settings -> Integrations**. Firefox users can install it from Mozilla Add-ons. Chromium users can load `firelink-chromium.zip` from the [extension releases](https://github.com/nimbold/Firelink-Extension/releases) with the [manual Chromium instructions](https://github.com/nimbold/Firelink-Extension#manual-chromium-installation).
|
||||
|
||||
The extension lives in [Firelink-Extension](https://github.com/nimbold/Firelink-Extension). This repo also vendors it as the `Extensions/Firefox` submodule.
|
||||
Use the latest [Firelink Companion release](https://github.com/nimbold/Firelink-Extension/releases) with Firelink 1.1.1. The source is in the [Firelink-Extension repository](https://github.com/nimbold/Firelink-Extension), which this repo vendors as the `Extensions/Browser` submodule.
|
||||
|
||||
## Platforms
|
||||
|
||||
@@ -91,7 +115,7 @@ The extension lives in [Firelink-Extension](https://github.com/nimbold/Firelink-
|
||||
| --- | --- |
|
||||
| **macOS arm64** | Supported. Native build, engine checks, launch smoke test, ad-hoc-signed DMG workflow. |
|
||||
| **Windows x64** | Supported. Native build, engine checks, silent installer smoke test, NSIS installer. |
|
||||
| **Linux x64** | Supported. Native build, engine checks, xvfb launch smoke test, AppImage. |
|
||||
| **Linux x64** | Supported. Native build, bundled-engine checks, package/AppImage launch smoke tests, `.deb`, `.rpm`, and AppImage. |
|
||||
|
||||
## Development
|
||||
|
||||
@@ -148,7 +172,7 @@ Build staging includes only the current target. See `engines.lock.json`, `engine
|
||||
├── src/ React and TypeScript interface
|
||||
├── src-tauri/ Rust backend, Tauri config, and native tests
|
||||
├── scripts/ Engine provisioning, release, and smoke-test tooling
|
||||
└── Extensions/Firefox/ Firelink Companion submodule
|
||||
└── Extensions/Browser/ Firelink Companion submodule
|
||||
```
|
||||
|
||||
## Help and Project Status
|
||||
|
||||
+13
-7
@@ -4,11 +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.
|
||||
|
||||
@@ -22,6 +25,8 @@ Firelink never falls back to system-installed media tools.
|
||||
- `scripts/stage-engines.js` creates one target-specific bundle payload.
|
||||
- `scripts/verify-binaries.js` runs architecture, packaging, version, and RPC checks.
|
||||
|
||||
Linux `.deb` and `.rpm` packages are built with the complete verified engine payload. The AppImage is built separately with the engine payload temporarily omitted, then repacked from the verified payload because the AppImage tooling can rewrite bundled native binaries.
|
||||
|
||||
yt-dlp must remain its official PyInstaller **onedir** distribution: launcher plus adjacent `_internal` runtime. Onefile builds are rejected because repeated extraction caused roughly 17-second startup latency.
|
||||
|
||||
## Version update
|
||||
@@ -53,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
|
||||
|
||||
@@ -69,7 +76,6 @@ git push origin v<version>
|
||||
|
||||
GitHub Actions builds all targets on native runners, verifies engines inside
|
||||
final package contents, performs packaged launch smoke where supported, and
|
||||
uploads artifacts. Publish the GitHub Release with a manual `workflow_dispatch`
|
||||
run on the same tag after clean-machine QA is complete.
|
||||
publishes the GitHub Release after the build matrix passes.
|
||||
|
||||
No target may silently skip missing engines, failed extraction, checksum mismatch, or missing package output.
|
||||
|
||||
+10
-10
@@ -8,14 +8,14 @@
|
||||
"sha256": "90254845be5282b1f4d843a873abff04f569f857f64250f833fe152b21eec152"
|
||||
},
|
||||
"deno": {
|
||||
"version": "2.9.2",
|
||||
"url": "https://github.com/denoland/deno/releases/download/v2.9.2/deno-x86_64-pc-windows-msvc.zip",
|
||||
"sha256": "5fe194d26ac5ef77fcc5288c2c438c7a0465f3b6180440ebf04092714bf2dcdf"
|
||||
"version": "2.9.3",
|
||||
"url": "https://github.com/denoland/deno/releases/download/v2.9.3/deno-x86_64-pc-windows-msvc.zip",
|
||||
"sha256": "60343461ac5fe3a31f4ef12667f2946bb852e20655c8610aeb7e751e87f7df3a"
|
||||
},
|
||||
"ffmpeg": {
|
||||
"version": "8.1.2-22-g94138f6973",
|
||||
"url": "https://github.com/BtbN/FFmpeg-Builds/releases/download/autobuild-2026-07-06-14-19/ffmpeg-n8.1.2-22-g94138f6973-win64-gpl-8.1.zip",
|
||||
"sha256": "a758e2836a8f33c5f21fc44270cb00392acc6d0085dd0ba14fe14ae75935813d"
|
||||
"url": "https://github.com/BtbN/FFmpeg-Builds/releases/download/autobuild-2026-07-15-14-01/ffmpeg-n8.1.2-22-g94138f6973-win64-gpl-8.1.zip",
|
||||
"sha256": "0af1621d3a2e418b86d8ff4704f5d5a70bcce611edd6c5110d092533b170952c"
|
||||
},
|
||||
"aria2c": {
|
||||
"version": "1.37.0",
|
||||
@@ -30,14 +30,14 @@
|
||||
"sha256": "d7d2d09e900b5ae11821b5784b18cf064984a2bd88b1ca5c798d744bcbe3658b"
|
||||
},
|
||||
"deno": {
|
||||
"version": "2.9.2",
|
||||
"url": "https://github.com/denoland/deno/releases/download/v2.9.2/deno-x86_64-unknown-linux-gnu.zip",
|
||||
"sha256": "934d1bd5cb09eaed7f2e4a4fc58208d04a3c5c0fcde9f319d93d735265c67a4a"
|
||||
"version": "2.9.3",
|
||||
"url": "https://github.com/denoland/deno/releases/download/v2.9.3/deno-x86_64-unknown-linux-gnu.zip",
|
||||
"sha256": "8101865641cbede56f08ad19c0a67a87df84bce127fee0d3e3e1f7467717ffa6"
|
||||
},
|
||||
"ffmpeg": {
|
||||
"version": "8.1.2-22-g94138f6973",
|
||||
"url": "https://github.com/BtbN/FFmpeg-Builds/releases/download/autobuild-2026-07-06-14-19/ffmpeg-n8.1.2-22-g94138f6973-linux64-gpl-8.1.tar.xz",
|
||||
"sha256": "df99ffb3803ee56dc68954f43f950ea9f33685a3595a5da8a3e73ef4bef37e3c"
|
||||
"url": "https://github.com/BtbN/FFmpeg-Builds/releases/download/autobuild-2026-07-15-14-01/ffmpeg-n8.1.2-22-g94138f6973-linux64-gpl-8.1.tar.xz",
|
||||
"sha256": "68e304f5518d3e22cd825e6fb738d0d9a04a8f36127873a18fc6646457e95b92"
|
||||
},
|
||||
"aria2c": {
|
||||
"version": "1.37.0",
|
||||
|
||||
+5
-4
@@ -16,16 +16,17 @@
|
||||
"sha256": "111b2f5ed760f1e1a2ec06117c4e8094fcde336ba16122dda1c5e7209bf1862d"
|
||||
},
|
||||
"ffmpeg": {
|
||||
"version": "N-125450-gfad2e0bc50",
|
||||
"version": "N-125610-g312c830916",
|
||||
"source": "https://ffmpeg.org/",
|
||||
"build": "GPLv3 build identified by binary as https://www.martin-riedl.de",
|
||||
"sha256": "be2c39e5c9ef923f60da6cb62f5a209ed98b4da8a732d9f06de4355d5ea99e58"
|
||||
"url": "https://ffmpeg.martin-riedl.de/download/macos/arm64/1784052810_N-125610-g312c830916/ffmpeg.zip",
|
||||
"sha256": "f65b4ba782b25e9f4374765d421793c6613f692d8e50b2888079657793619034"
|
||||
},
|
||||
"deno": {
|
||||
"version": "2.9.2",
|
||||
"version": "2.9.3",
|
||||
"source": "https://github.com/denoland/deno",
|
||||
"build": "official aarch64-apple-darwin executable",
|
||||
"sha256": "218ab752ae8f64f0a7822af710886488f15169fdae153a3aada4861f9635b266"
|
||||
"sha256": "80c83cdfb20289f8818a71220b570df363f6f0ba93580c29c70f08ab14e93568"
|
||||
}
|
||||
},
|
||||
"runtimeTrees": {
|
||||
|
||||
Generated
+260
-200
@@ -1,24 +1,24 @@
|
||||
{
|
||||
"name": "firelink",
|
||||
"version": "1.0.3",
|
||||
"version": "1.1.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "firelink",
|
||||
"version": "1.0.3",
|
||||
"version": "1.1.1",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@formkit/auto-animate": "^0.9.0",
|
||||
"@tailwindcss/vite": "^4.3.1",
|
||||
"@formkit/auto-animate": "^0.10.0",
|
||||
"@tailwindcss/vite": "^4.3.3",
|
||||
"@tauri-apps/api": "^2.11.1",
|
||||
"@tauri-apps/plugin-clipboard-manager": "^2.3.2",
|
||||
"@tauri-apps/plugin-dialog": "^2.7.1",
|
||||
"@tauri-apps/plugin-fs": "^2.5.1",
|
||||
"@tauri-apps/plugin-log": "^2.8.0",
|
||||
"@tauri-apps/plugin-log": "^2.9.0",
|
||||
"@tauri-apps/plugin-notification": "^2.3.3",
|
||||
"@tauri-apps/plugin-opener": "^2",
|
||||
"lucide-react": "^1.23.0",
|
||||
"lucide-react": "^1.24.0",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"zustand": "^5.0.14"
|
||||
@@ -28,11 +28,11 @@
|
||||
"@types/react": "^19.1.8",
|
||||
"@types/react-dom": "^19.1.6",
|
||||
"@vitejs/plugin-react": "^6.0.3",
|
||||
"autoprefixer": "^10.5.2",
|
||||
"postcss": "^8.5.15",
|
||||
"tailwindcss": "^4.3.1",
|
||||
"autoprefixer": "^10.5.4",
|
||||
"postcss": "^8.5.19",
|
||||
"tailwindcss": "^4.3.3",
|
||||
"typescript": "^7.0.2",
|
||||
"vite": "^8.1.3",
|
||||
"vite": "^8.1.5",
|
||||
"vitest": "^4.1.10"
|
||||
},
|
||||
"engines": {
|
||||
@@ -71,9 +71,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@formkit/auto-animate": {
|
||||
"version": "0.9.0",
|
||||
"resolved": "https://registry.npmjs.org/@formkit/auto-animate/-/auto-animate-0.9.0.tgz",
|
||||
"integrity": "sha512-VhP4zEAacXS3dfTpJpJ88QdLqMTcabMg0jwpOSxZ/VzfQVfl3GkZSCZThhGC5uhq/TxPHPzW0dzr4H9Bb1OgKA==",
|
||||
"version": "0.10.0",
|
||||
"resolved": "https://registry.npmjs.org/@formkit/auto-animate/-/auto-animate-0.10.0.tgz",
|
||||
"integrity": "sha512-KGomRttjUfORuPUaR/ZGQw+6xfMrTM+sxnILv7JAd9AmabU9rg9i6gF/iC0Ih+QpKCubJpCA/1DX9UHKE8cX+A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@jridgewell/gen-mapping": {
|
||||
@@ -140,18 +140,18 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@oxc-project/types": {
|
||||
"version": "0.137.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.137.0.tgz",
|
||||
"integrity": "sha512-WT+Gb24i8hmvo85AIv2oEYouEXkRlKAlT9WaCa3TfLgNCN+GhrJOGZuIlMouAh38Qe4QOx26eUOVsq70qXrywA==",
|
||||
"version": "0.139.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.139.0.tgz",
|
||||
"integrity": "sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/Boshen"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-android-arm64": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.3.tgz",
|
||||
"integrity": "sha512-DT6Z3PhvioeHMvxo+xHc3KtqggrI7CCTXCmC2h/5zUlp5jVitv7XEy+9q5/7v8IolhlioawpMo8Kg0EEBy7J0g==",
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.5.tgz",
|
||||
"integrity": "sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -165,9 +165,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-darwin-arm64": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.3.tgz",
|
||||
"integrity": "sha512-0NwgwsjM7LrsuVnXMK3koTpagBNOhloc/BNjKqZjv4V5zI5r13qx69uVhRx+o5Z0yy4Hzq+lpy7TAgUG/ocvrw==",
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.5.tgz",
|
||||
"integrity": "sha512-51Bnx9pNiMRKSUNtBfySkNJ9vMU9Hh3I1ozDd6gyPPYzaXCfnptUcEZxXGYFn+ul2dtcMUiqGR1Yai2K10uoTw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -181,9 +181,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-darwin-x64": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.3.tgz",
|
||||
"integrity": "sha512-YtiBp4disu6V560loT6PjMdiRaWmVvDNrUunAalbiFx2ggeJwxdAsgZMcoGP17uyAsTwAj5V1niksxlHnVQ1Sw==",
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.5.tgz",
|
||||
"integrity": "sha512-Tm+gbfC0aHu1tBA/JvKQh32S0K6YgCHkiAF4/W6xX0K0RmNuc94VeK419dJoE65R5aRxmo+noZQSWrAMF6yb6g==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -197,9 +197,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-freebsd-x64": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.3.tgz",
|
||||
"integrity": "sha512-yD3EkEdXk2LypPxnf/kSZHirarsI8gcPzc62SukhR9VJTyvV+F9Q/GxWNuCojc7sXyuVC4DxRGhdDK4X8VSsbw==",
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.5.tgz",
|
||||
"integrity": "sha512-JMzDKCCXq93YccG5gz3hvOs1oXRKAf0XYpfOS88e+wZrC8Iugj6j68867vrYZkvpDDpKn/KoKORThmchMpF6TA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -213,9 +213,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-arm-gnueabihf": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.3.tgz",
|
||||
"integrity": "sha512-c+8vieQbsD7HNAHKIA34w0GJ9FedFFuJGD+7E6vz7Q3uqAIugL5p45fhlsj4UaAsHpcmlqugBWMhA0/j7o0sIg==",
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.5.tgz",
|
||||
"integrity": "sha512-uML21j2K5TfPGutKxub+M+nLjZIrWjXQ5Grx4lCe/nimTj9B4L63zHpjXLl4y0L3mcm2htEQIb06oCG/szerNw==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -229,9 +229,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-arm64-gnu": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.3.tgz",
|
||||
"integrity": "sha512-50jD0uUwLvur7Zz9LHz17kaAdTPjn5wN93hEgjvmYFRZwiR7ZJYovTd5ipyWJDAnXKvZ+wgc+/Ika6dwSF5OcA==",
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.5.tgz",
|
||||
"integrity": "sha512-navSiuTMogvnQoZoM/v+l3ZWo50/NTwSHSzheABx/RCnmUPaKwq9qSo4Br2OYRs21+Fz8uFqITZM3H4opOB0/Q==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -245,9 +245,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-arm64-musl": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.3.tgz",
|
||||
"integrity": "sha512-BO9+oPL8K9poZJBfYPsXNtYjPE5uM3qeehT3aFcW4LITOl+iSqhp0abzjR2nWBUNjIZeKXjAEWBZ64WjNoHd6w==",
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.5.tgz",
|
||||
"integrity": "sha512-lAryqH7IteztmCXQXk0etKj4wBQ7Gx5S6LjKhsgp9zb8I5bsuvU/2llH1hDQcjsFeqIsovMVN339/8pUDDBXxA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -261,9 +261,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-ppc64-gnu": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.3.tgz",
|
||||
"integrity": "sha512-f3VpLB1vQ0Eo6ecr/6cekLnvYMFF4YBFoVGkfkvPLq1bAkbAwHYQPZKoAmG6OJyTcxxoC+AvezGx/S1obNC0Mw==",
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.5.tgz",
|
||||
"integrity": "sha512-fsK/sNBnxzBlL4O1JNrZakVQxPspqpED5dLtNsZS9oOKmtSpdNIzxH2kkol5HYTWJN47sE20ztMJPxfZ89qGOg==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
@@ -277,9 +277,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-s390x-gnu": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.3.tgz",
|
||||
"integrity": "sha512-AmurZ26Pqx/RI9N1gzEOCklkKXl927yjfXWUUS0O7Puh8ARM/Ob8qfrD3qnWksScdw6cSrW5PSHE9DyLu7+PtA==",
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.5.tgz",
|
||||
"integrity": "sha512-gLYb4BIadlfTOYT5gO503n8zQjXflgzpD0FcyKh0Mzx3rqCZKnHoJWV9xe1KXUJ5lx2JfcSHr/mhzS0PC/McAA==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
@@ -293,9 +293,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-x64-gnu": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.3.tgz",
|
||||
"integrity": "sha512-JJpqs8bRGITDOdbkNKnlojzBabbOHrqjSvDr0IVsZObE1lBcPjxItUEY9eWIDbxaJ3cGrXPWGfGkIxFijg/URg==",
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.5.tgz",
|
||||
"integrity": "sha512-FjcpEKUyJygHgs1o50VYNvkt5+7Le/VEdYt0AkRpkL33MnyQfwr8l5mXwMmfmTbyMPr5vJLC+8/Gd9gXnwU1QQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -309,9 +309,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-x64-musl": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.3.tgz",
|
||||
"integrity": "sha512-rSJcdjPxzA/by/6/rYs+v+bXU7UjvnbUWz8MJb6kh6+knqB1dCrtHg0uu7C/4haqJvqdkYHQ5IGn+tCH9GLW/g==",
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.5.tgz",
|
||||
"integrity": "sha512-Me+PfPI2TMeOQk0gYWfLQZtTktrmzbr8cDboqX83XKc7UrgAi55gF+2dUkWdxd19n55Essp2yeca+O9N5rBxHg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -325,9 +325,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-openharmony-arm64": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.3.tgz",
|
||||
"integrity": "sha512-hQ3/PYkDJICgevvyNcVrihVeqq7k1Pp3VZ9lY+dauAYUJKO+auqApvANhvR1An9BhmqYKvW2Mu1F9u4DXSMLxQ==",
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.5.tgz",
|
||||
"integrity": "sha512-yc5WrLzXks6zCQfn9Oxr8pORKyl/pF+QjHmW/Qx3qu0oyrrNC+y2JLTU1E2rcWYAmzlnqngWXHQjy51VzW70Vw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -341,9 +341,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-wasm32-wasi": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.3.tgz",
|
||||
"integrity": "sha512-Elcv/BtML9lXrV6JuKITc/grN2kYV9gjsQpW8Jfw4ioK0TOkjBjye0nnyqQNy9STNaI20lXNaQBRrD5gSgR0Yg==",
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.5.tgz",
|
||||
"integrity": "sha512-VbQGPX2b4r48TAMIM2cjgluIM1HYutm4pcTEJsle7iEP7sB1dFqtPLBVbdLAZCxy1txCcPxf4QFf4v8uvltPqA==",
|
||||
"cpu": [
|
||||
"wasm32"
|
||||
],
|
||||
@@ -359,9 +359,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-win32-arm64-msvc": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.3.tgz",
|
||||
"integrity": "sha512-2DrEfhluH9yhiaFApmsjsjwrSYbNcY1oFTzYSP1a535jDbV98zCFanA/96TBUd0iDFcxGmw9QRExwGCXz3U+/g==",
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.5.tgz",
|
||||
"integrity": "sha512-gHv82k63z4qpV5+Q1y/12KrK0ltWBukVDI8nZcbT7Tt/ZlOIVwppazneq0F93oDxTo3IgAMEDIoQh3E2n6mVsw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -375,9 +375,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-win32-x64-msvc": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.3.tgz",
|
||||
"integrity": "sha512-OL4OMk7UPXOeVGGd3qo5zJyPIljf4AFgk5QAkPPS+OoLuOOozhuaQGC18MxVTnw/06q93gShAJzlwnSCY9YtqA==",
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.5.tgz",
|
||||
"integrity": "sha512-tTZuDBPw85tEN5PQi1pnEBzDy0Z49HtScLAbD5t6hyeU92A95pRWaSMw1GZZi/RwgSgUIl0xrSlXIT/9QzvYSA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -404,47 +404,47 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@tailwindcss/node": {
|
||||
"version": "4.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.2.tgz",
|
||||
"integrity": "sha512-yWP/sqEcBLaD8JuA6zNwxoYKr75qxTioYwlRwekj5Jr/I5GXnoJfjetH/psLUIv74cYTH2lBUEzBkinthoYcBg==",
|
||||
"version": "4.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.3.tgz",
|
||||
"integrity": "sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/remapping": "^2.3.5",
|
||||
"enhanced-resolve": "5.21.6",
|
||||
"enhanced-resolve": "^5.24.1",
|
||||
"jiti": "^2.7.0",
|
||||
"lightningcss": "1.32.0",
|
||||
"magic-string": "^0.30.21",
|
||||
"source-map-js": "^1.2.1",
|
||||
"tailwindcss": "4.3.2"
|
||||
"tailwindcss": "4.3.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide": {
|
||||
"version": "4.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.2.tgz",
|
||||
"integrity": "sha512-z8ZgnzX8gdNoWLBLqBPoh/sjnxkwvf9ZuWjnO0l0yIzbLa5/9S+eC5QxGZKRobVHIC3/1BoMWjHblqWjcgFgag==",
|
||||
"version": "4.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.3.tgz",
|
||||
"integrity": "sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 20"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@tailwindcss/oxide-android-arm64": "4.3.2",
|
||||
"@tailwindcss/oxide-darwin-arm64": "4.3.2",
|
||||
"@tailwindcss/oxide-darwin-x64": "4.3.2",
|
||||
"@tailwindcss/oxide-freebsd-x64": "4.3.2",
|
||||
"@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.2",
|
||||
"@tailwindcss/oxide-linux-arm64-gnu": "4.3.2",
|
||||
"@tailwindcss/oxide-linux-arm64-musl": "4.3.2",
|
||||
"@tailwindcss/oxide-linux-x64-gnu": "4.3.2",
|
||||
"@tailwindcss/oxide-linux-x64-musl": "4.3.2",
|
||||
"@tailwindcss/oxide-wasm32-wasi": "4.3.2",
|
||||
"@tailwindcss/oxide-win32-arm64-msvc": "4.3.2",
|
||||
"@tailwindcss/oxide-win32-x64-msvc": "4.3.2"
|
||||
"@tailwindcss/oxide-android-arm64": "4.3.3",
|
||||
"@tailwindcss/oxide-darwin-arm64": "4.3.3",
|
||||
"@tailwindcss/oxide-darwin-x64": "4.3.3",
|
||||
"@tailwindcss/oxide-freebsd-x64": "4.3.3",
|
||||
"@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.3",
|
||||
"@tailwindcss/oxide-linux-arm64-gnu": "4.3.3",
|
||||
"@tailwindcss/oxide-linux-arm64-musl": "4.3.3",
|
||||
"@tailwindcss/oxide-linux-x64-gnu": "4.3.3",
|
||||
"@tailwindcss/oxide-linux-x64-musl": "4.3.3",
|
||||
"@tailwindcss/oxide-wasm32-wasi": "4.3.3",
|
||||
"@tailwindcss/oxide-win32-arm64-msvc": "4.3.3",
|
||||
"@tailwindcss/oxide-win32-x64-msvc": "4.3.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-android-arm64": {
|
||||
"version": "4.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.2.tgz",
|
||||
"integrity": "sha512-WHxqIuHpvZ5VtdX6GTl1Ik/Vp2YuN42Et+0CdeaVd/frQ9jAvGmvR8vLT+jk3e8/Q3x8kECB9+R17pgpp2BulA==",
|
||||
"version": "4.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.3.tgz",
|
||||
"integrity": "sha512-Y85A2gmPSkl5Ve5qR86GL4HT509cFqQh1aes9p3sSkyTPwt0Pppf3GkwGe4JPACcRYjgJIEhQgM6dBClnr0NYw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -458,9 +458,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-darwin-arm64": {
|
||||
"version": "4.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.2.tgz",
|
||||
"integrity": "sha512-GZypeUY/IDJW3877KeM+O67vbXr3MBnbtEL4aYhNErv/JWZhye2vGSWWG9tB6iiqR2MqRNkY8IOUy4NdSZV26w==",
|
||||
"version": "4.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.3.tgz",
|
||||
"integrity": "sha512-BiaWatpBcERQFDlOjRDpIVXuFK5PJez5SA4JMg6VYZdBYU+qKfV/vqjcIs+IYmtitf1xYQZTwXvU/8y4lfZUGw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -474,9 +474,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-darwin-x64": {
|
||||
"version": "4.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.2.tgz",
|
||||
"integrity": "sha512-UIIzmefR6KO1sDU7MzRqAxC8iBpft/VhkGjTjnhoS6k7Z3rQ9wEgA1ODSiyH/tcSYssulNm4Ci3hOeK1jH7ccQ==",
|
||||
"version": "4.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.3.tgz",
|
||||
"integrity": "sha512-fAeUqfV5ndhxRwai8cXGzdLvul9utWOmeTkv69unv4ZXixjn61Z+p9lCWdwOwA3TYboG3BwdVuN/RDjhBRl0mw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -490,9 +490,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-freebsd-x64": {
|
||||
"version": "4.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.2.tgz",
|
||||
"integrity": "sha512-GN+uAmcI6DNspnCDwtOAZrTz6oukJnp337qZvxqCGLd3BHBzJpO0ZbTLRvJNdztOeAmTzewewGIMPb0tk2R4WA==",
|
||||
"version": "4.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.3.tgz",
|
||||
"integrity": "sha512-iyf5bV6+wnAlflVeEy7R25dupxTNECZN5QMI0qNT6eT+EgaGdZcKhGkr5SdoaWiLJ3spLqIY9VCeSGrwmtg4kw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -506,9 +506,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": {
|
||||
"version": "4.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.2.tgz",
|
||||
"integrity": "sha512-4ABn7qSbdHRwTiDiuWNegCyb5+2FJ4vKIKc3DmKrvAFw7MU1Lm11dIkTPwUaFdTzc7IsOpDbqBrlh0x6y36U/w==",
|
||||
"version": "4.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.3.tgz",
|
||||
"integrity": "sha512-aAYUprJAJQWWbRrPvtjdroZ56Md+JM8pMiopS6xGEwDfLhqj+2ver2p4nU4Mb3CRqcMmNBjo8KkUgcxhkzVQGQ==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -522,9 +522,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-linux-arm64-gnu": {
|
||||
"version": "4.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.2.tgz",
|
||||
"integrity": "sha512-wDgEIGwoM8w8pufh9LVt1PahDgNdKXrLC2qfAnV3vAmococ9RWbxeAw4pxPttd/TsJfwjyLf90Dg1y9y8I6Emw==",
|
||||
"version": "4.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.3.tgz",
|
||||
"integrity": "sha512-nDxldcEENOxZRzC2uu9jrutZdAAQtb+8WWDCSnWL1zvBk1+FN+x6MtDViPB5AJMfttVCUhehGWus3XBPgatM/w==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -538,9 +538,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-linux-arm64-musl": {
|
||||
"version": "4.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.2.tgz",
|
||||
"integrity": "sha512-J5Nuk0uZQIiMTJj3LEx4sAA9tMFUoXQZFv1J6An+QGYe53HKRJuFDi0rpq/tuouCZeAbOBY3kQ6g8qeD4TUjtA==",
|
||||
"version": "4.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.3.tgz",
|
||||
"integrity": "sha512-Md44bD6veX/PC5iyF8cDVnw4HBIANZepRZZ7a8DQOvkfo5WUBwcp6iAuCUz23u+4SUkhJlD3eL7hNdW8ezd/kA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -554,9 +554,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-linux-x64-gnu": {
|
||||
"version": "4.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.2.tgz",
|
||||
"integrity": "sha512-kqCZpSKOBEJO4mz7OqWoofBZeXTAwaVGPj0ErAj7CojmhKpWVWVOnrt9dE8odoIraZq4oj3ausM37kXi+Tow8w==",
|
||||
"version": "4.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.3.tgz",
|
||||
"integrity": "sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -570,9 +570,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-linux-x64-musl": {
|
||||
"version": "4.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.2.tgz",
|
||||
"integrity": "sha512-cixpqbh2toJDmkuCRI68nXA8ZxNmdK9Y+9v5h3MC3ZQKy/0BO8AWzlkWyRM7JAFSGBlfig4YVTPsK6MVgqz1uw==",
|
||||
"version": "4.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.3.tgz",
|
||||
"integrity": "sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -586,9 +586,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-wasm32-wasi": {
|
||||
"version": "4.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.2.tgz",
|
||||
"integrity": "sha512-4ec2Z/LOmRsAgU23CS4xeJfcJlmRg94A/XrbGRCF1gyU/zdDfRLYDVsS+ynSZCmGNxQ1jQriQOKMQeQxBA3Isw==",
|
||||
"version": "4.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.3.tgz",
|
||||
"integrity": "sha512-jx1+rPhY/5Ympkktd656HBWEBLxP7dH06losBLjjf5vgCODXvi9KhtftWcMIwTFIDqBr7cRnQkdLnAG+IOlGvQ==",
|
||||
"bundleDependencies": [
|
||||
"@napi-rs/wasm-runtime",
|
||||
"@emnapi/core",
|
||||
@@ -614,10 +614,70 @@
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": {
|
||||
"version": "1.11.1",
|
||||
"inBundle": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@emnapi/wasi-threads": "1.2.2",
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": {
|
||||
"version": "1.11.1",
|
||||
"inBundle": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": {
|
||||
"version": "1.2.2",
|
||||
"inBundle": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": {
|
||||
"version": "1.1.4",
|
||||
"inBundle": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@tybys/wasm-util": "^0.10.1"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@emnapi/core": "^1.7.1",
|
||||
"@emnapi/runtime": "^1.7.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": {
|
||||
"version": "0.10.2",
|
||||
"inBundle": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": {
|
||||
"version": "2.8.1",
|
||||
"inBundle": true,
|
||||
"license": "0BSD",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
|
||||
"version": "4.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.2.tgz",
|
||||
"integrity": "sha512-Zyr/M0+XcYZu3bZrUytc7TXvrk0ftWfl8gN2MwekNDzhqhKRUucMPSeOzM0o0wH5AWOU49BsKRrfKxI2atCPMQ==",
|
||||
"version": "4.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.3.tgz",
|
||||
"integrity": "sha512-3rc292Ca2ceK6Ulcc/bAVnTs/3nDtoPhyEKlgPv+yQJQi/JS/AMJlqzxvlDacL1nekbrcf6bTqp/jV4qgnPxNQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -631,9 +691,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-win32-x64-msvc": {
|
||||
"version": "4.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.2.tgz",
|
||||
"integrity": "sha512-QI9BO7KlNZsp2GuO0jwAAj5jCDABOKXRkCk2XuKTSaNEFSdfzqswYVTtCHBNKHLsqyjFyFkqlDiwkNbTYSssMQ==",
|
||||
"version": "4.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.3.tgz",
|
||||
"integrity": "sha512-yJ0pwIVc/nYeGoV02WtsN8KYyLQv7kyI2wDnkezyJlGGjkd4QLwDGAwl47YpPJeuI0M0ObaXGSPjvWDPeTPggw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -647,14 +707,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/vite": {
|
||||
"version": "4.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.3.2.tgz",
|
||||
"integrity": "sha512-eHpMeX4JXfVNJDEcsouTeCBubJBTcTLigeaw/NTUW6PB5ATKKXdyonnXgTBX2VuRbjz1hjfz6C5XAhr52ImQXA==",
|
||||
"version": "4.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.3.3.tgz",
|
||||
"integrity": "sha512-yYU8cogLeSh/ms2jh8Fj7jaba/EWa7Ja6GoUqYZaraEuCI5YS6ms6ObZgjjedm+jm6XZjdNRWBpPP6Z86oOxcw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@tailwindcss/node": "4.3.2",
|
||||
"@tailwindcss/oxide": "4.3.2",
|
||||
"tailwindcss": "4.3.2"
|
||||
"@tailwindcss/node": "4.3.3",
|
||||
"@tailwindcss/oxide": "4.3.3",
|
||||
"tailwindcss": "4.3.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"vite": "^5.2.0 || ^6 || ^7 || ^8"
|
||||
@@ -915,12 +975,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/plugin-log": {
|
||||
"version": "2.8.0",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-log/-/plugin-log-2.8.0.tgz",
|
||||
"integrity": "sha512-a+7rOq3MJwpTOLLKbL8d0qGZ85hgHw5pNOWusA9o3cf7cEgtYHiGY/+O8fj8MvywQIGqFv0da2bYQDlrqLE7rw==",
|
||||
"version": "2.9.0",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-log/-/plugin-log-2.9.0.tgz",
|
||||
"integrity": "sha512-Ql8okrnsguk0eDq1GvRfttFV5KaeW/7vcao6bdbkXCRJ1+2sWE15ZJvJVEKVANrOKy1mRngqC3IFIAP+wP5qSw==",
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2.8.0"
|
||||
"@tauri-apps/api": "^2.11.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/plugin-notification": {
|
||||
@@ -1486,9 +1546,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/autoprefixer": {
|
||||
"version": "10.5.2",
|
||||
"resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.5.2.tgz",
|
||||
"integrity": "sha512-rD5t5DwOjJdmSORcTq64j8MawTC+tbQ+HHqjR4NDumamy/ambn1UJrlKL+KdwujWxMkFjPM3pPHOEA9tl4767Q==",
|
||||
"version": "10.5.4",
|
||||
"resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.5.4.tgz",
|
||||
"integrity": "sha512-MaU0U/za7N3r6brxD4YB/l4NSrFzLPlANv6wEuQVaIPlD3L4W9rFcQPbL/EilY9BHhHvhfcz3gInDLrEtWT4EA==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@@ -1506,8 +1566,8 @@
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"browserslist": "^4.28.4",
|
||||
"caniuse-lite": "^1.0.30001799",
|
||||
"browserslist": "^4.28.6",
|
||||
"caniuse-lite": "^1.0.30001806",
|
||||
"fraction.js": "^5.3.4",
|
||||
"picocolors": "^1.1.1",
|
||||
"postcss-value-parser": "^4.2.0"
|
||||
@@ -1523,9 +1583,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/baseline-browser-mapping": {
|
||||
"version": "2.10.40",
|
||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.40.tgz",
|
||||
"integrity": "sha512-BSSLZ9/Cjjv7Gtj5B68ZzXcXUg8iOf3fme+FCuh8rC/Go+Kmh8cox7M3A8dolou16s64QjLPOSdngh7GxXvkSw==",
|
||||
"version": "2.10.43",
|
||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.43.tgz",
|
||||
"integrity": "sha512-AjYpR78kDWAY3Efj+cDTFH9t9SCoL7OoTp1BOb0mQV7S+6CiLwnWM3FyxhJtdPufDFKzmCSFoUncKjWgJEZTCQ==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
@@ -1536,9 +1596,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/browserslist": {
|
||||
"version": "4.28.4",
|
||||
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.4.tgz",
|
||||
"integrity": "sha512-MTc8i/x9jBQd1iMw2CFGS+rwMa07eYjLR0CCTLDACl9xhxy+nIs3KeML/biicXtk9JrZ6dnnTatmc7ErPXIxqw==",
|
||||
"version": "4.28.6",
|
||||
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.6.tgz",
|
||||
"integrity": "sha512-FQBYNK15VMslhLHpA7+n+n1GOlF1kId2xcCg7/j95f24AOF6VDYMNH4mFxF7KuaTdv627faazpOAjFzMrfJOUw==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@@ -1556,10 +1616,10 @@
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"baseline-browser-mapping": "^2.10.38",
|
||||
"caniuse-lite": "^1.0.30001799",
|
||||
"electron-to-chromium": "^1.5.376",
|
||||
"node-releases": "^2.0.48",
|
||||
"baseline-browser-mapping": "^2.10.42",
|
||||
"caniuse-lite": "^1.0.30001803",
|
||||
"electron-to-chromium": "^1.5.389",
|
||||
"node-releases": "^2.0.51",
|
||||
"update-browserslist-db": "^1.2.3"
|
||||
},
|
||||
"bin": {
|
||||
@@ -1570,9 +1630,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/caniuse-lite": {
|
||||
"version": "1.0.30001800",
|
||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001800.tgz",
|
||||
"integrity": "sha512-MMHtuAz9Ys840zAY5F4k6fV5GaivZ9sPk+nz0mY+GYVzRBnYkN0mpqkSR92oWRQ19yQWo4HvBV/FnC16AJX8MA==",
|
||||
"version": "1.0.30001806",
|
||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001806.tgz",
|
||||
"integrity": "sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@@ -1624,16 +1684,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/electron-to-chromium": {
|
||||
"version": "1.5.382",
|
||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.382.tgz",
|
||||
"integrity": "sha512-8ETaWbV6SZOrno+G93Ffd9ENsMtetqdnqj4nlfxFW90Sm5GgnuV28Kf62hqQVD6VUgzm7qFQKsTsAPmeUiU3Ug==",
|
||||
"version": "1.5.389",
|
||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.389.tgz",
|
||||
"integrity": "sha512-cEto7aeOqBfU1D+c5py5pE+ooscKE75JifxLBdFUZsqAxRS6y7kebtxAZvICszSl05gPjYHDTjY+lXpyGvpJbg==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/enhanced-resolve": {
|
||||
"version": "5.21.6",
|
||||
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.21.6.tgz",
|
||||
"integrity": "sha512-aNnGCvbJ/RIyWo1IuhNdVjnNF+EjH9wpzpNHt+ci/m9He9LJvUN8wrCcXjp9cWsGNAuvSpVFTx/vraAFQ8qGjQ==",
|
||||
"version": "5.24.2",
|
||||
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.24.2.tgz",
|
||||
"integrity": "sha512-rpsZEGT1jFuve6QlpyRp9ckQ+kN61hvF9BzCPyMdaKTm8UJce96KBn3sorXOFXlzjPrs3Vc4T1NsSroZ3PxlFw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"graceful-fs": "^4.2.4",
|
||||
@@ -1644,9 +1704,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/es-module-lexer": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.2.0.tgz",
|
||||
"integrity": "sha512-3lGxdTXCLfe1MYfTz1y2ksAAUM4NAOP6rPEjxGJVKO7TZ5+tvHCaQWGpC4Y3IXvW3ece0Cz1cIP4FWBxOnGCTQ==",
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.1.tgz",
|
||||
"integrity": "sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
@@ -1990,9 +2050,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/lucide-react": {
|
||||
"version": "1.23.0",
|
||||
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.23.0.tgz",
|
||||
"integrity": "sha512-38BpJcD0JhFosxHApP/BYsBetLpQFRoTRzEzstM/XCc3jsAG7wqaY1lgVwxiUe3xqYE+lNxo2PkCmYwXWrwwIw==",
|
||||
"version": "1.24.0",
|
||||
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.24.0.tgz",
|
||||
"integrity": "sha512-YT6mBD8lGKkg4nM39enlm94/sfJIiW0YKUT60fBy4YK8tai31ylg1VhGNWxkpSKHo9UagfnZqwIff3HTDQwXeA==",
|
||||
"license": "ISC",
|
||||
"peerDependencies": {
|
||||
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
@@ -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": {
|
||||
@@ -2063,9 +2123,9 @@
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/picomatch": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
|
||||
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
||||
"version": "4.0.5",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz",
|
||||
"integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
@@ -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",
|
||||
@@ -2131,12 +2191,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/rolldown": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.3.tgz",
|
||||
"integrity": "sha512-1F1eEtUBtFvcGm1HQ9TiUIUHPQG7mSAODrhIzjxoUEFuo8OcbrGLiVLkevNgj84TE4lnHvnumwFjhJO5Eu135g==",
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.5.tgz",
|
||||
"integrity": "sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@oxc-project/types": "=0.137.0",
|
||||
"@oxc-project/types": "=0.139.0",
|
||||
"@rolldown/pluginutils": "^1.0.0"
|
||||
},
|
||||
"bin": {
|
||||
@@ -2146,21 +2206,21 @@
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@rolldown/binding-android-arm64": "1.1.3",
|
||||
"@rolldown/binding-darwin-arm64": "1.1.3",
|
||||
"@rolldown/binding-darwin-x64": "1.1.3",
|
||||
"@rolldown/binding-freebsd-x64": "1.1.3",
|
||||
"@rolldown/binding-linux-arm-gnueabihf": "1.1.3",
|
||||
"@rolldown/binding-linux-arm64-gnu": "1.1.3",
|
||||
"@rolldown/binding-linux-arm64-musl": "1.1.3",
|
||||
"@rolldown/binding-linux-ppc64-gnu": "1.1.3",
|
||||
"@rolldown/binding-linux-s390x-gnu": "1.1.3",
|
||||
"@rolldown/binding-linux-x64-gnu": "1.1.3",
|
||||
"@rolldown/binding-linux-x64-musl": "1.1.3",
|
||||
"@rolldown/binding-openharmony-arm64": "1.1.3",
|
||||
"@rolldown/binding-wasm32-wasi": "1.1.3",
|
||||
"@rolldown/binding-win32-arm64-msvc": "1.1.3",
|
||||
"@rolldown/binding-win32-x64-msvc": "1.1.3"
|
||||
"@rolldown/binding-android-arm64": "1.1.5",
|
||||
"@rolldown/binding-darwin-arm64": "1.1.5",
|
||||
"@rolldown/binding-darwin-x64": "1.1.5",
|
||||
"@rolldown/binding-freebsd-x64": "1.1.5",
|
||||
"@rolldown/binding-linux-arm-gnueabihf": "1.1.5",
|
||||
"@rolldown/binding-linux-arm64-gnu": "1.1.5",
|
||||
"@rolldown/binding-linux-arm64-musl": "1.1.5",
|
||||
"@rolldown/binding-linux-ppc64-gnu": "1.1.5",
|
||||
"@rolldown/binding-linux-s390x-gnu": "1.1.5",
|
||||
"@rolldown/binding-linux-x64-gnu": "1.1.5",
|
||||
"@rolldown/binding-linux-x64-musl": "1.1.5",
|
||||
"@rolldown/binding-openharmony-arm64": "1.1.5",
|
||||
"@rolldown/binding-wasm32-wasi": "1.1.5",
|
||||
"@rolldown/binding-win32-arm64-msvc": "1.1.5",
|
||||
"@rolldown/binding-win32-x64-msvc": "1.1.5"
|
||||
}
|
||||
},
|
||||
"node_modules/scheduler": {
|
||||
@@ -2193,16 +2253,16 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/std-env": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz",
|
||||
"integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==",
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz",
|
||||
"integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tailwindcss": {
|
||||
"version": "4.3.2",
|
||||
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.2.tgz",
|
||||
"integrity": "sha512-WtctNNSH8A9jlMIqxzuYumOHU5uGZyRv0Q5svQl+oEPy5w84YpBxdb7MdqyiSPQge5jTJ6zFQLq0PFygdccSBA==",
|
||||
"version": "4.3.3",
|
||||
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.3.tgz",
|
||||
"integrity": "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tapable": {
|
||||
@@ -2335,15 +2395,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "8.1.3",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-8.1.3.tgz",
|
||||
"integrity": "sha512-Ds+gBRbj0lwRO2Y5hwnUBdxSwlAve9LeRyU4sNnAr0ewW0gWF0n5bgXgUzbgZ49MV9BVUAQUFYVcDUcilUExMA==",
|
||||
"version": "8.1.5",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-8.1.5.tgz",
|
||||
"integrity": "sha512-7ULLwsCdYx/nRyrpiEwvqb5TFHrMVZyBt+rg/OAXT7rgj/z+DtTDyKFeLAdDkubDVDKD8jOsndmy7m55XcfUsw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"lightningcss": "^1.32.0",
|
||||
"picomatch": "^4.0.4",
|
||||
"postcss": "^8.5.16",
|
||||
"rolldown": "~1.1.3",
|
||||
"picomatch": "^4.0.5",
|
||||
"postcss": "^8.5.17",
|
||||
"rolldown": "~1.1.5",
|
||||
"tinyglobby": "^0.2.17"
|
||||
},
|
||||
"bin": {
|
||||
|
||||
+9
-9
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "firelink",
|
||||
"private": true,
|
||||
"version": "1.0.3",
|
||||
"version": "1.1.1",
|
||||
"description": "A fast cross-platform desktop download manager powered by Rust, Tauri, React, aria2, and yt-dlp.",
|
||||
"license": "MIT",
|
||||
"homepage": "https://github.com/nimbold/Firelink",
|
||||
@@ -38,16 +38,16 @@
|
||||
"test": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"@formkit/auto-animate": "^0.9.0",
|
||||
"@tailwindcss/vite": "^4.3.1",
|
||||
"@formkit/auto-animate": "^0.10.0",
|
||||
"@tailwindcss/vite": "^4.3.3",
|
||||
"@tauri-apps/api": "^2.11.1",
|
||||
"@tauri-apps/plugin-clipboard-manager": "^2.3.2",
|
||||
"@tauri-apps/plugin-dialog": "^2.7.1",
|
||||
"@tauri-apps/plugin-fs": "^2.5.1",
|
||||
"@tauri-apps/plugin-log": "^2.8.0",
|
||||
"@tauri-apps/plugin-log": "^2.9.0",
|
||||
"@tauri-apps/plugin-notification": "^2.3.3",
|
||||
"@tauri-apps/plugin-opener": "^2",
|
||||
"lucide-react": "^1.23.0",
|
||||
"lucide-react": "^1.24.0",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"zustand": "^5.0.14"
|
||||
@@ -57,11 +57,11 @@
|
||||
"@types/react": "^19.1.8",
|
||||
"@types/react-dom": "^19.1.6",
|
||||
"@vitejs/plugin-react": "^6.0.3",
|
||||
"autoprefixer": "^10.5.2",
|
||||
"postcss": "^8.5.15",
|
||||
"tailwindcss": "^4.3.1",
|
||||
"autoprefixer": "^10.5.4",
|
||||
"postcss": "^8.5.19",
|
||||
"tailwindcss": "^4.3.3",
|
||||
"typescript": "^7.0.2",
|
||||
"vite": "^8.1.3",
|
||||
"vite": "^8.1.5",
|
||||
"vitest": "^4.1.10"
|
||||
}
|
||||
}
|
||||
|
||||
+55
-21
@@ -33,10 +33,17 @@ function compareVersions(left, right) {
|
||||
}
|
||||
|
||||
function npmOutdated(cwd) {
|
||||
if (!fs.existsSync(path.join(cwd, 'package.json'))) {
|
||||
throw new Error(`npm workspace is missing package.json: ${cwd}`);
|
||||
}
|
||||
try {
|
||||
execFileSync('npm', ['outdated', '--json'], { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
|
||||
return {};
|
||||
} catch (error) {
|
||||
if (error.status !== 1) {
|
||||
const details = error.stderr?.toString().trim();
|
||||
throw new Error(details || `npm outdated failed in ${cwd}`);
|
||||
}
|
||||
const output = error.stdout?.toString() || '{}';
|
||||
return JSON.parse(output || '{}');
|
||||
}
|
||||
@@ -76,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;
|
||||
@@ -124,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;
|
||||
}
|
||||
@@ -149,8 +176,8 @@ async function main() {
|
||||
|
||||
outdatedCount += printNpmReport('root npm', npmOutdated(repoRoot));
|
||||
outdatedCount += printNpmReport(
|
||||
'Firefox extension npm',
|
||||
npmOutdated(path.join(repoRoot, 'Extensions', 'Firefox'))
|
||||
'Browser extension npm',
|
||||
npmOutdated(path.join(repoRoot, 'Extensions', 'Browser'))
|
||||
);
|
||||
|
||||
const [
|
||||
@@ -177,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:');
|
||||
@@ -187,21 +219,23 @@ async function main() {
|
||||
console.log(` ${engine}: ${normalizeVersion(version)}`);
|
||||
}
|
||||
console.log('\nlatest engine provider builds:');
|
||||
console.log(` BtbN FFmpeg n8.1 Windows/Linux: ${normalizeVersion(btbnFfmpegN81Build || ffmpeg)}`);
|
||||
console.log(` Martin Riedl FFmpeg macOS arm64 snapshot: ${normalizeVersion(martinRiedlMacArm64Snapshot || martinRiedlMacArm64Ffmpeg)}`);
|
||||
console.log(` BtbN FFmpeg n8.1 Windows/Linux: ${normalizeVersion(btbnFfmpegN81Build?.version || ffmpeg)}`);
|
||||
console.log(` Martin Riedl FFmpeg macOS arm64 snapshot: ${normalizeVersion(martinRiedlMacArm64Snapshot?.version || martinRiedlMacArm64Ffmpeg)}`);
|
||||
|
||||
console.log('\nengine source lock:');
|
||||
outdatedCount += checkRows(
|
||||
sourceEngineVersions(parseJsonFile('engine-sources.lock.json')),
|
||||
latestByEngine,
|
||||
latestByTargetEngine
|
||||
latestByTargetEngine,
|
||||
latestUrlsByTargetEngine
|
||||
);
|
||||
|
||||
console.log('\npackaged engine lock:');
|
||||
outdatedCount += checkRows(
|
||||
packagedEngineVersions(parseJsonFile('engines.lock.json')),
|
||||
latestByEngine,
|
||||
latestByTargetEngine
|
||||
latestByTargetEngine,
|
||||
latestUrlsByTargetEngine
|
||||
);
|
||||
|
||||
if (outdatedCount > 0) {
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { Readable, Transform } from 'node:stream';
|
||||
import { pipeline } from 'node:stream/promises';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { collectRegularFiles, sha256 } from './engine-payload-integrity.js';
|
||||
@@ -35,6 +37,44 @@ const destination = path.join(repoRoot, 'src-tauri', 'provisioned-engines', targ
|
||||
const temporary = fs.mkdtempSync(path.join(os.tmpdir(), `firelink-engines-${target}-`));
|
||||
const isWindows = target.includes('windows');
|
||||
const executableSuffix = isWindows ? '.exe' : '';
|
||||
const DOWNLOAD_ATTEMPTS = 3;
|
||||
const DOWNLOAD_IDLE_TIMEOUT_MS = 120_000;
|
||||
const DOWNLOAD_RETRY_DELAYS_MS = [2_000, 5_000];
|
||||
const FILE_LOCK_RETRY_DELAYS_MS = [100, 250, 500, 1_000, 2_000];
|
||||
|
||||
function sleep(milliseconds) {
|
||||
return new Promise(resolve => setTimeout(resolve, milliseconds));
|
||||
}
|
||||
|
||||
async function removeFileWithRetry(file) {
|
||||
for (let attempt = 0; ; attempt += 1) {
|
||||
try {
|
||||
fs.rmSync(file, { force: true });
|
||||
return;
|
||||
} catch (error) {
|
||||
const retryable = process.platform === 'win32'
|
||||
&& ['EACCES', 'EBUSY', 'EPERM'].includes(error?.code);
|
||||
if (!retryable || attempt >= FILE_LOCK_RETRY_DELAYS_MS.length) {
|
||||
throw error;
|
||||
}
|
||||
await sleep(FILE_LOCK_RETRY_DELAYS_MS[attempt]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function createDownloadTimeout() {
|
||||
const controller = new AbortController();
|
||||
let timer;
|
||||
const refresh = () => {
|
||||
clearTimeout(timer);
|
||||
timer = setTimeout(() => {
|
||||
controller.abort(new Error(`Download idle for ${DOWNLOAD_IDLE_TIMEOUT_MS}ms`));
|
||||
}, DOWNLOAD_IDLE_TIMEOUT_MS);
|
||||
};
|
||||
const dispose = () => clearTimeout(timer);
|
||||
refresh();
|
||||
return { signal: controller.signal, refresh, dispose };
|
||||
}
|
||||
|
||||
async function download(name, source) {
|
||||
const sourcePath = new URL(source.url).pathname;
|
||||
@@ -42,20 +82,51 @@ async function download(name, source) {
|
||||
temporary,
|
||||
`${name}${sourcePath.endsWith('.tar.xz') ? '.tar.xz' : '.zip'}`
|
||||
);
|
||||
const response = await fetch(source.url, { redirect: 'follow' });
|
||||
if (!response.ok || !response.body) {
|
||||
throw new Error(`Failed to download ${name}: HTTP ${response.status}`);
|
||||
}
|
||||
const output = fs.createWriteStream(archive);
|
||||
const reader = response.body.getReader();
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
if (!output.write(Buffer.from(value))) {
|
||||
await new Promise(resolve => output.once('drain', resolve));
|
||||
|
||||
let lastError;
|
||||
for (let attempt = 1; attempt <= DOWNLOAD_ATTEMPTS; attempt += 1) {
|
||||
const downloadTimeout = createDownloadTimeout();
|
||||
try {
|
||||
const response = await fetch(source.url, {
|
||||
redirect: 'follow',
|
||||
signal: downloadTimeout.signal,
|
||||
});
|
||||
if (!response.ok || !response.body) {
|
||||
throw new Error(`Failed to download ${name}: HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
await pipeline(
|
||||
Readable.fromWeb(response.body),
|
||||
new Transform({
|
||||
transform(chunk, encoding, callback) {
|
||||
downloadTimeout.refresh();
|
||||
callback(null, chunk, encoding);
|
||||
},
|
||||
}),
|
||||
fs.createWriteStream(archive),
|
||||
{ signal: downloadTimeout.signal }
|
||||
);
|
||||
break;
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
await removeFileWithRetry(archive);
|
||||
if (attempt === DOWNLOAD_ATTEMPTS) {
|
||||
throw new Error(
|
||||
`Failed to download ${name} after ${DOWNLOAD_ATTEMPTS} attempts: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
{ cause: error }
|
||||
);
|
||||
}
|
||||
await sleep(DOWNLOAD_RETRY_DELAYS_MS[attempt - 1]);
|
||||
} finally {
|
||||
downloadTimeout.dispose();
|
||||
}
|
||||
}
|
||||
await new Promise(resolve => output.end(resolve));
|
||||
|
||||
if (lastError && !fs.existsSync(archive)) {
|
||||
throw lastError;
|
||||
}
|
||||
|
||||
const actual = sha256(archive);
|
||||
if (actual !== source.sha256) {
|
||||
|
||||
+217
-25
@@ -1,5 +1,6 @@
|
||||
#!/usr/bin/env node
|
||||
import { execFileSync, spawn } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
function argValue(name) {
|
||||
@@ -15,6 +16,8 @@ if (!executableArg) {
|
||||
|
||||
const executable = path.resolve(executableArg);
|
||||
const assertNoVisibleChildWindows = process.argv.includes('--assert-no-visible-child-windows');
|
||||
const assertPortableData = process.argv.includes('--assert-portable-data');
|
||||
const READY_PORT_TIMEOUT_MS = 500;
|
||||
const child = spawn(executable, [], {
|
||||
cwd: process.env.RUNNER_TEMP || process.env.TMPDIR || process.cwd(),
|
||||
detached: process.platform !== 'win32',
|
||||
@@ -30,12 +33,14 @@ const child = spawn(executable, [], {
|
||||
let stderr = '';
|
||||
let spawnError = null;
|
||||
let readyPort = null;
|
||||
let childExit = null;
|
||||
|
||||
child.on('error', error => {
|
||||
spawnError = error;
|
||||
});
|
||||
|
||||
child.on('exit', (code, signal) => {
|
||||
childExit = { code, signal };
|
||||
if (readyPort === null) {
|
||||
console.error(`Child exited prematurely with code ${code} signal ${signal}`);
|
||||
}
|
||||
@@ -44,6 +49,7 @@ child.on('exit', (code, signal) => {
|
||||
child.stderr.on('data', data => {
|
||||
stderr += data.toString();
|
||||
});
|
||||
child.stdout.on('data', () => {});
|
||||
|
||||
function sleep(ms) {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
@@ -51,19 +57,25 @@ function sleep(ms) {
|
||||
|
||||
async function findReadyPort() {
|
||||
for (let attempt = 0; attempt < 200 && readyPort === null; attempt += 1) {
|
||||
if (spawnError) {
|
||||
if (spawnError || childExit) {
|
||||
break;
|
||||
}
|
||||
|
||||
for (let port = 6412; port <= 6422; port += 1) {
|
||||
const ports = Array.from({ length: 11 }, (_, index) => 6412 + index);
|
||||
const matches = await Promise.all(ports.map(async port => {
|
||||
try {
|
||||
const response = await fetch(`http://127.0.0.1:${port}/ping`);
|
||||
if (response.headers.get('x-firelink-server') === '1') {
|
||||
readyPort = port;
|
||||
break;
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
const response = await fetch(`http://127.0.0.1:${port}/ping`, {
|
||||
signal: AbortSignal.timeout(READY_PORT_TIMEOUT_MS),
|
||||
});
|
||||
const matchesChild = response.headers.get('x-firelink-server') === '1'
|
||||
&& response.headers.get('x-firelink-smoke-process-id') === String(child.pid);
|
||||
await response.body?.cancel();
|
||||
return matchesChild ? port : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}));
|
||||
readyPort = matches.find(port => port !== null) ?? null;
|
||||
|
||||
if (readyPort === null) {
|
||||
await sleep(250);
|
||||
@@ -120,43 +132,223 @@ if ($visible.Count -gt 0) {
|
||||
}
|
||||
}
|
||||
|
||||
function terminateChild() {
|
||||
if (!child.pid) {
|
||||
return;
|
||||
function waitForChildExit(timeoutMs) {
|
||||
if (childExit) {
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
|
||||
return new Promise(resolve => {
|
||||
let timer;
|
||||
const onExit = () => {
|
||||
clearTimeout(timer);
|
||||
child.off('exit', onExit);
|
||||
resolve(true);
|
||||
};
|
||||
timer = setTimeout(() => {
|
||||
child.off('exit', onExit);
|
||||
resolve(false);
|
||||
}, timeoutMs);
|
||||
child.once('exit', onExit);
|
||||
});
|
||||
}
|
||||
|
||||
function isProcessGroupAlive(rootPid) {
|
||||
if (process.platform === 'win32') {
|
||||
spawn('taskkill', ['/pid', String(child.pid), '/t', '/f'], {
|
||||
stdio: 'ignore',
|
||||
windowsHide: true,
|
||||
});
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
process.kill(-child.pid, 'SIGTERM');
|
||||
} catch {
|
||||
child.kill('SIGTERM');
|
||||
process.kill(-rootPid, 0);
|
||||
return true;
|
||||
} catch (error) {
|
||||
return error?.code !== 'ESRCH';
|
||||
}
|
||||
}
|
||||
|
||||
await findReadyPort();
|
||||
async function waitForProcessGroupExit(rootPid, timeoutMs) {
|
||||
if (process.platform === 'win32') {
|
||||
return true;
|
||||
}
|
||||
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
if (!isProcessGroupAlive(rootPid)) {
|
||||
return true;
|
||||
}
|
||||
await sleep(100);
|
||||
}
|
||||
|
||||
return !isProcessGroupAlive(rootPid);
|
||||
}
|
||||
|
||||
function windowsBundleRoot() {
|
||||
return path.dirname(executable).replaceAll("'", "''");
|
||||
}
|
||||
|
||||
function windowsBundleProcessIds() {
|
||||
const root = windowsBundleRoot();
|
||||
const script = `
|
||||
$root = '${root}'
|
||||
$rootPrefix = $root.TrimEnd('\\') + '\\'
|
||||
Get-CimInstance Win32_Process |
|
||||
Where-Object {
|
||||
$commandLine = $_.CommandLine
|
||||
$commandLine -and $commandLine.TrimStart([char]34).StartsWith($rootPrefix, [System.StringComparison]::OrdinalIgnoreCase)
|
||||
} |
|
||||
Select-Object -ExpandProperty ProcessId
|
||||
`;
|
||||
try {
|
||||
const output = execFileSync('powershell', ['-NoProfile', '-NonInteractive', '-Command', script], {
|
||||
encoding: 'utf8',
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
windowsHide: true,
|
||||
});
|
||||
return output
|
||||
.split(/\r?\n/)
|
||||
.map(value => Number.parseInt(value.trim(), 10))
|
||||
.filter(Number.isInteger);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function terminateWindowsBundleProcesses() {
|
||||
const root = windowsBundleRoot();
|
||||
const script = `
|
||||
$root = '${root}'
|
||||
$rootPrefix = $root.TrimEnd('\\') + '\\'
|
||||
Get-CimInstance Win32_Process |
|
||||
Where-Object {
|
||||
$commandLine = $_.CommandLine
|
||||
$commandLine -and $commandLine.TrimStart([char]34).StartsWith($rootPrefix, [System.StringComparison]::OrdinalIgnoreCase)
|
||||
} |
|
||||
ForEach-Object { Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue }
|
||||
`;
|
||||
try {
|
||||
execFileSync('powershell', ['-NoProfile', '-NonInteractive', '-Command', script], {
|
||||
stdio: 'ignore',
|
||||
windowsHide: true,
|
||||
});
|
||||
} catch {}
|
||||
}
|
||||
|
||||
async function waitForWindowsBundleExit(timeoutMs) {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
const processIds = windowsBundleProcessIds();
|
||||
if (processIds?.length === 0) {
|
||||
return true;
|
||||
}
|
||||
await sleep(100);
|
||||
}
|
||||
return windowsBundleProcessIds()?.length === 0;
|
||||
}
|
||||
|
||||
async function terminateChild() {
|
||||
if (!child.pid) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const childWasRunning = !childExit;
|
||||
if (process.platform === 'win32') {
|
||||
if (childWasRunning) {
|
||||
try {
|
||||
execFileSync('taskkill', ['/pid', String(child.pid), '/t', '/f'], {
|
||||
stdio: 'ignore',
|
||||
windowsHide: true,
|
||||
});
|
||||
} catch {}
|
||||
}
|
||||
const childExited = await waitForChildExit(10000);
|
||||
const bundleExited = await waitForWindowsBundleExit(5000);
|
||||
if (childExited && bundleExited) {
|
||||
return true;
|
||||
}
|
||||
terminateWindowsBundleProcesses();
|
||||
return await waitForChildExit(5000) && await waitForWindowsBundleExit(5000);
|
||||
}
|
||||
|
||||
if (childWasRunning && !childExit) {
|
||||
try {
|
||||
process.kill(-child.pid, 'SIGTERM');
|
||||
} catch {
|
||||
if (!childExit) {
|
||||
child.kill('SIGTERM');
|
||||
}
|
||||
}
|
||||
}
|
||||
if (await waitForChildExit(5000) && await waitForProcessGroupExit(child.pid, 5000)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!childWasRunning || childExit) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
process.kill(-child.pid, 'SIGKILL');
|
||||
} catch {
|
||||
if (!childExit) {
|
||||
child.kill('SIGKILL');
|
||||
}
|
||||
}
|
||||
return await waitForChildExit(5000) && await waitForProcessGroupExit(child.pid, 5000);
|
||||
}
|
||||
|
||||
async function assertPortableStorage() {
|
||||
const portableRoot = path.dirname(executable);
|
||||
const marker = path.join(portableRoot, 'portable.flag');
|
||||
const database = path.join(portableRoot, 'data', 'firelink.sqlite');
|
||||
const webviewData = path.join(portableRoot, 'data', 'webview');
|
||||
|
||||
for (let attempt = 0; attempt < 40; attempt += 1) {
|
||||
const markerReady = fs.statSync(marker, { throwIfNoEntry: false })?.isFile();
|
||||
const databaseReady = fs.statSync(database, { throwIfNoEntry: false })?.isFile();
|
||||
const webviewReady = fs.statSync(webviewData, { throwIfNoEntry: false })?.isDirectory();
|
||||
if (markerReady && databaseReady && webviewReady) {
|
||||
return;
|
||||
}
|
||||
await sleep(250);
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`Portable storage was not ready: marker=${marker}, database=${database}, webview=${webviewData}`,
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
await findReadyPort();
|
||||
|
||||
if (readyPort === null) {
|
||||
if (spawnError) {
|
||||
console.error(`Packaged Firelink failed to start: ${spawnError.message}`);
|
||||
throw new Error(`Packaged Firelink failed to start: ${spawnError.message}`);
|
||||
} else if (childExit) {
|
||||
throw new Error(
|
||||
`Packaged Firelink exited before exposing extension ping endpoint with code ${childExit.code} signal ${childExit.signal}.`,
|
||||
);
|
||||
} else {
|
||||
console.error(`Packaged Firelink did not expose extension ping endpoint. Stderr:\n${stderr.slice(-1000)}`);
|
||||
throw new Error(`Packaged Firelink did not expose extension ping endpoint. Stderr:\n${stderr.slice(-1000)}`);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (assertNoVisibleChildWindows) {
|
||||
assertNoVisibleWindows(child.pid);
|
||||
}
|
||||
if (assertPortableData) {
|
||||
await assertPortableStorage();
|
||||
}
|
||||
|
||||
if (childExit) {
|
||||
throw new Error(`Packaged Firelink exited after exposing extension ping endpoint with code ${childExit.code} signal ${childExit.signal}.`);
|
||||
}
|
||||
|
||||
console.log(`Packaged Firelink smoke passed on 127.0.0.1:${readyPort}`);
|
||||
} catch (error) {
|
||||
console.error(error instanceof Error ? error.message : String(error));
|
||||
process.exitCode = 1;
|
||||
} finally {
|
||||
terminateChild();
|
||||
if (!await terminateChild()) {
|
||||
console.error('Packaged Firelink could not be terminated cleanly; refusing to report smoke success.');
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
|
||||
+203
-52
@@ -2,6 +2,7 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import os from 'node:os';
|
||||
import net from 'node:net';
|
||||
import { execFileSync, spawn } from 'node:child_process';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
@@ -44,16 +45,34 @@ const searchRoot = argValue('--search-root');
|
||||
function findEngineRoot(root) {
|
||||
const expected = `yt-dlp-${targetTriple}${ext}`;
|
||||
const matches = [];
|
||||
const resolvedRoot = path.resolve(root);
|
||||
const hasExpectedEngineFile = directory => {
|
||||
try {
|
||||
return fs.lstatSync(path.join(directory, expected)).isFile();
|
||||
} catch (error) {
|
||||
if (error?.code === 'ENOENT') return false;
|
||||
throw new Error(`Unable to inspect packaged engine root '${directory}': ${error.message}`);
|
||||
}
|
||||
};
|
||||
if (hasExpectedEngineFile(resolvedRoot)) {
|
||||
matches.push(resolvedRoot);
|
||||
}
|
||||
const walk = directory => {
|
||||
for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
|
||||
let entries;
|
||||
try {
|
||||
entries = fs.readdirSync(directory, { withFileTypes: true });
|
||||
} catch (error) {
|
||||
throw new Error(`Unable to inspect packaged engine directory '${directory}': ${error.message}`);
|
||||
}
|
||||
for (const entry of entries) {
|
||||
const candidate = path.join(directory, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
if (fs.existsSync(path.join(candidate, expected))) matches.push(candidate);
|
||||
if (hasExpectedEngineFile(candidate)) matches.push(candidate);
|
||||
walk(candidate);
|
||||
}
|
||||
}
|
||||
};
|
||||
walk(path.resolve(root));
|
||||
walk(resolvedRoot);
|
||||
if (matches.length !== 1) {
|
||||
throw new Error(`Expected exactly one packaged engine root under ${root}, found ${matches.length}`);
|
||||
}
|
||||
@@ -317,6 +336,82 @@ function runEngine(label, engine, args, timeout = 30000) {
|
||||
}
|
||||
}
|
||||
|
||||
function waitForProcessExit(proc, timeoutMs) {
|
||||
if (proc.exitCode !== null || proc.signalCode !== null) {
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
|
||||
return new Promise(resolve => {
|
||||
let settled = false;
|
||||
let timer;
|
||||
const finish = value => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
proc.removeListener('exit', onExit);
|
||||
resolve(value);
|
||||
};
|
||||
const onExit = () => finish(true);
|
||||
|
||||
timer = setTimeout(() => finish(false), timeoutMs);
|
||||
proc.once('exit', onExit);
|
||||
if (proc.exitCode !== null || proc.signalCode !== null) {
|
||||
finish(true);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function terminateProcess(proc, label) {
|
||||
if (proc.exitCode === null && proc.signalCode === null) {
|
||||
try {
|
||||
proc.kill('SIGTERM');
|
||||
} catch {}
|
||||
if (await waitForProcessExit(proc, 2000)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
proc.kill('SIGKILL');
|
||||
} catch {}
|
||||
if (await waitForProcessExit(proc, 2000)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
fail(`${label} did not terminate after SIGTERM and SIGKILL.`);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function findAvailablePort() {
|
||||
return new Promise((resolve, reject) => {
|
||||
const server = net.createServer();
|
||||
server.once('error', reject);
|
||||
server.listen({ host: '127.0.0.1', port: 0 }, () => {
|
||||
const address = server.address();
|
||||
if (!address || typeof address === 'string') {
|
||||
server.close();
|
||||
reject(new Error('Could not determine the verifier RPC port.'));
|
||||
return;
|
||||
}
|
||||
|
||||
const port = address.port;
|
||||
server.close(error => {
|
||||
if (error) {
|
||||
reject(error);
|
||||
} else {
|
||||
resolve(port);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function isPortBindingFailure(message) {
|
||||
return /address already in use|failed to bind|could not bind|listen failed/i.test(message);
|
||||
}
|
||||
|
||||
const coldStartTimeout = isMacOS ? 120000 : 30000;
|
||||
if (canExecuteTarget) {
|
||||
runEngine('yt-dlp cold start', 'yt-dlp', ['--version'], coldStartTimeout);
|
||||
@@ -345,25 +440,6 @@ if (canExecuteTarget) {
|
||||
return;
|
||||
}
|
||||
|
||||
const port = 16801 + (process.pid % 1000);
|
||||
const proc = spawn(p, [
|
||||
'--enable-rpc',
|
||||
`--rpc-listen-port=${port}`,
|
||||
'--rpc-max-request-size=1K',
|
||||
'--quiet',
|
||||
'--console-log-level=error',
|
||||
'--rpc-listen-all=false',
|
||||
], {
|
||||
env: engineEnv('aria2c'),
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
timeout: 15000,
|
||||
});
|
||||
|
||||
let rpcStderr = '';
|
||||
proc.stderr.on('data', (d) => {
|
||||
rpcStderr += d.toString();
|
||||
});
|
||||
|
||||
const body = JSON.stringify({
|
||||
jsonrpc: '2.0',
|
||||
id: 'firelink-verify',
|
||||
@@ -371,39 +447,114 @@ if (canExecuteTarget) {
|
||||
params: [],
|
||||
});
|
||||
|
||||
const result = await new Promise((resolve) => {
|
||||
const maxAttempts = 20;
|
||||
let attempts = 0;
|
||||
|
||||
function tryFetch() {
|
||||
attempts++;
|
||||
fetch(`http://127.0.0.1:${port}/jsonrpc`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body,
|
||||
})
|
||||
.then(async (res) => {
|
||||
resolve({ ok: true, data: await res.text() });
|
||||
})
|
||||
.catch(() => {
|
||||
if (attempts >= maxAttempts) {
|
||||
resolve({ ok: false, error: `RPC not ready after ${maxAttempts} attempts` });
|
||||
return;
|
||||
}
|
||||
setTimeout(tryFetch, 300);
|
||||
});
|
||||
let result = { ok: false, error: 'RPC test did not run.' };
|
||||
let rpcStderr = '';
|
||||
const maxPortAttempts = 3;
|
||||
for (let portAttempt = 0; portAttempt < maxPortAttempts; portAttempt += 1) {
|
||||
let port;
|
||||
try {
|
||||
port = await findAvailablePort();
|
||||
} catch (error) {
|
||||
result = { ok: false, error: `Could not reserve an RPC port: ${error.message}` };
|
||||
break;
|
||||
}
|
||||
|
||||
tryFetch();
|
||||
});
|
||||
const proc = spawn(p, [
|
||||
'--enable-rpc',
|
||||
`--rpc-listen-port=${port}`,
|
||||
'--rpc-max-request-size=1K',
|
||||
'--quiet',
|
||||
'--console-log-level=error',
|
||||
'--rpc-listen-all=false',
|
||||
], {
|
||||
env: engineEnv('aria2c'),
|
||||
stdio: ['ignore', 'ignore', 'pipe'],
|
||||
timeout: 15000,
|
||||
});
|
||||
|
||||
// Clean up
|
||||
proc.kill('SIGTERM');
|
||||
setTimeout(() => {
|
||||
try {
|
||||
proc.kill('SIGKILL');
|
||||
} catch {}
|
||||
}, 2000);
|
||||
let spawnError = null;
|
||||
let childExit = null;
|
||||
let attemptStderr = '';
|
||||
proc.once('error', error => {
|
||||
spawnError = error;
|
||||
});
|
||||
proc.once('exit', (code, signal) => {
|
||||
childExit = { code, signal };
|
||||
});
|
||||
proc.stderr.on('data', data => {
|
||||
attemptStderr += data.toString();
|
||||
});
|
||||
|
||||
const attemptResult = await new Promise(resolve => {
|
||||
const maxAttempts = 20;
|
||||
let attempts = 0;
|
||||
|
||||
function resolveProcessFailure() {
|
||||
if (spawnError) {
|
||||
resolve({ ok: false, error: `aria2c failed to spawn: ${spawnError.message}` });
|
||||
} else if (childExit) {
|
||||
resolve({
|
||||
ok: false,
|
||||
error: `aria2c exited before RPC became ready with code ${childExit.code} signal ${childExit.signal}.`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function tryFetch() {
|
||||
if (spawnError || childExit) {
|
||||
resolveProcessFailure();
|
||||
return;
|
||||
}
|
||||
|
||||
attempts += 1;
|
||||
fetch(`http://127.0.0.1:${port}/jsonrpc`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body,
|
||||
})
|
||||
.then(async response => {
|
||||
resolve({ ok: true, data: await response.text() });
|
||||
})
|
||||
.catch(() => {
|
||||
if (spawnError || childExit) {
|
||||
resolveProcessFailure();
|
||||
} else if (attempts >= maxAttempts) {
|
||||
resolve({ ok: false, error: `RPC not ready after ${maxAttempts} attempts` });
|
||||
} else {
|
||||
setTimeout(tryFetch, 300);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
tryFetch();
|
||||
});
|
||||
|
||||
const exitedBeforeCleanup = childExit;
|
||||
const terminated = proc.pid
|
||||
? await terminateProcess(proc, 'aria2 RPC verifier process')
|
||||
: true;
|
||||
rpcStderr = attemptStderr;
|
||||
result = attemptResult;
|
||||
|
||||
if (
|
||||
result.ok
|
||||
&& exitedBeforeCleanup
|
||||
&& (exitedBeforeCleanup.code !== 0 || exitedBeforeCleanup.signal !== null)
|
||||
) {
|
||||
result = {
|
||||
ok: false,
|
||||
error: `aria2c exited after responding with code ${exitedBeforeCleanup.code} signal ${exitedBeforeCleanup.signal}.`,
|
||||
};
|
||||
}
|
||||
|
||||
if (!terminated || result.ok || !isPortBindingFailure(`${result.error || ''}\n${rpcStderr}`)) {
|
||||
break;
|
||||
}
|
||||
|
||||
if (portAttempt + 1 < maxPortAttempts) {
|
||||
console.log(`[INFO] RPC port ${port} became unavailable; retrying with a new port.`);
|
||||
}
|
||||
}
|
||||
|
||||
if (result.ok) {
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,272 @@
|
||||
#!/usr/bin/env node
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { collectRegularFiles, sha256 } from './engine-payload-integrity.js';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const repoRoot = path.resolve(__dirname, '..');
|
||||
|
||||
function argValue(name) {
|
||||
const index = process.argv.indexOf(name);
|
||||
return index >= 0 ? process.argv[index + 1] : undefined;
|
||||
}
|
||||
|
||||
function fail(message) {
|
||||
console.error(`[FAIL] ${message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
function run(command, args, options = {}) {
|
||||
const result = spawnSync(command, args, {
|
||||
cwd: options.cwd ?? repoRoot,
|
||||
env: { ...process.env, ...options.env },
|
||||
stdio: options.stdio ?? 'inherit',
|
||||
encoding: options.stdio === 'pipe' ? 'utf8' : undefined,
|
||||
});
|
||||
|
||||
if (result.error) {
|
||||
fail(`Failed to run ${command}: ${result.error.message}`);
|
||||
}
|
||||
if (result.status !== 0) {
|
||||
if (options.stdio === 'pipe') {
|
||||
if (result.stdout) process.stdout.write(result.stdout);
|
||||
if (result.stderr) process.stderr.write(result.stderr);
|
||||
}
|
||||
fail(`${command} exited with status ${result.status}`);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function findSingle(directory, extension, label) {
|
||||
if (!fs.existsSync(directory) || !fs.statSync(directory).isDirectory()) {
|
||||
fail(`${label} directory does not exist: ${directory}`);
|
||||
}
|
||||
|
||||
const matches = fs.readdirSync(directory)
|
||||
.filter(name => name.endsWith(extension))
|
||||
.map(name => path.join(directory, name));
|
||||
if (matches.length !== 1) {
|
||||
fail(`Expected exactly one ${label}, found ${matches.length} in ${directory}`);
|
||||
}
|
||||
return matches[0];
|
||||
}
|
||||
|
||||
function assertPackageListing(packageFile, packageType, expectedPath) {
|
||||
const result = packageType === 'deb'
|
||||
? run('dpkg-deb', ['--contents', packageFile], { stdio: 'pipe' })
|
||||
: run('rpm', ['-qpl', packageFile], { stdio: 'pipe' });
|
||||
const listing = result.stdout ?? '';
|
||||
assertSafePackageListing(listing, packageType);
|
||||
if (!listing.includes(expectedPath)) {
|
||||
fail(`${packageType} package is missing ${expectedPath}`);
|
||||
}
|
||||
if (!/usr\/share\/applications\/[^/]+\.desktop/.test(listing)) {
|
||||
fail(`${packageType} package is missing its desktop entry`);
|
||||
}
|
||||
}
|
||||
|
||||
function assertPackageRecommendations(packageFile, packageType) {
|
||||
const result = packageType === 'deb'
|
||||
? run('dpkg-deb', ['--field', packageFile, 'Recommends'], { stdio: 'pipe' })
|
||||
: run('rpm', ['-qp', '--recommends', packageFile], { stdio: 'pipe' });
|
||||
const recommendations = result.stdout ?? '';
|
||||
const dependencyNames = packageType === 'deb'
|
||||
? recommendations
|
||||
.split(/[,|]/)
|
||||
.map(value => value.trim().split(/\s+/, 1)[0]?.split(':', 1)[0])
|
||||
: recommendations
|
||||
.split('\n')
|
||||
.map(value => value.trim().split(/\s+/, 1)[0]);
|
||||
for (const dependency of ['desktop-file-utils', 'xdg-utils']) {
|
||||
if (!dependencyNames.includes(dependency)) {
|
||||
fail(`${packageType} package is missing its ${dependency} recommendation`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function parseDebianPackagePath(line) {
|
||||
const match = line.match(/^\S+\s+\S+\s+\S+\s+\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}\s+(.*)$/);
|
||||
if (!match) {
|
||||
throw new Error(`Could not parse a Debian package path: ${line}`);
|
||||
}
|
||||
return match[1].replace(/^\.\//, '');
|
||||
}
|
||||
|
||||
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'
|
||||
? lines.map(line => {
|
||||
try {
|
||||
return parseDebianPackagePath(line);
|
||||
} catch (error) {
|
||||
fail(error.message);
|
||||
}
|
||||
})
|
||||
: lines.map(line => line.replace(/^\/+/, ''));
|
||||
|
||||
for (const packagePath of paths) {
|
||||
if (!isSafePackagePath(packagePath)) {
|
||||
fail(`${packageType} package contains an unsafe path: ${packagePath}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function extractDeb(packageFile, destination) {
|
||||
fs.mkdirSync(destination, { recursive: true });
|
||||
run('dpkg-deb', ['--extract', packageFile, destination]);
|
||||
}
|
||||
|
||||
function extractRpm(packageFile, destination) {
|
||||
fs.mkdirSync(destination, { recursive: true });
|
||||
run('bsdtar', [
|
||||
'--extract',
|
||||
'--file', packageFile,
|
||||
'--directory', destination,
|
||||
'--no-same-owner',
|
||||
'--no-same-permissions',
|
||||
]);
|
||||
}
|
||||
|
||||
function readPayloadManifest(root, label) {
|
||||
const manifestPath = path.join(root, 'payload-manifest.json');
|
||||
if (!fs.existsSync(manifestPath)) {
|
||||
fail(`${label} payload manifest is missing`);
|
||||
}
|
||||
let manifest;
|
||||
try {
|
||||
manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
|
||||
} catch (error) {
|
||||
fail(`${label} payload manifest is invalid: ${error.message}`);
|
||||
}
|
||||
return manifest;
|
||||
}
|
||||
|
||||
function findPayloadRoot(root, target, label) {
|
||||
const expectedBinary = `yt-dlp-${target}`;
|
||||
const matches = [];
|
||||
const walk = directory => {
|
||||
for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
|
||||
const candidate = path.join(directory, entry.name);
|
||||
if (!entry.isDirectory()) continue;
|
||||
if (fs.existsSync(path.join(candidate, expectedBinary)) && fs.existsSync(path.join(candidate, 'payload-manifest.json'))) {
|
||||
matches.push(candidate);
|
||||
}
|
||||
walk(candidate);
|
||||
}
|
||||
};
|
||||
walk(root);
|
||||
if (matches.length !== 1) {
|
||||
fail(`Expected exactly one ${label} engine payload root, found ${matches.length}`);
|
||||
}
|
||||
return matches[0];
|
||||
}
|
||||
|
||||
function assertPayloadMatchesSource(sourceRoot, packagedRoot, target, label) {
|
||||
const sourceManifest = readPayloadManifest(sourceRoot, 'Provisioned engine');
|
||||
if (sourceManifest.target !== target) {
|
||||
fail(`Provisioned engine payload target mismatch: expected ${target}, got ${sourceManifest.target}`);
|
||||
}
|
||||
|
||||
const packagedManifest = readPayloadManifest(packagedRoot, label);
|
||||
if (packagedManifest.target !== target) {
|
||||
fail(`${label} payload target mismatch: expected ${target}, got ${packagedManifest.target}`);
|
||||
}
|
||||
|
||||
const expectedFiles = Object.keys(sourceManifest.files || {}).sort();
|
||||
const packagedFiles = collectRegularFiles(packagedRoot, { ignoredNames: ['payload-manifest.json'] })
|
||||
.map(file => path.relative(packagedRoot, file).split(path.sep).join('/'))
|
||||
.sort();
|
||||
if (JSON.stringify(packagedFiles) !== JSON.stringify(expectedFiles)) {
|
||||
fail(`${label} payload files differ from the provisioned engine manifest`);
|
||||
}
|
||||
|
||||
for (const relative of expectedFiles) {
|
||||
const packagedFile = path.join(packagedRoot, relative);
|
||||
if (sha256(packagedFile) !== sourceManifest.files[relative]) {
|
||||
fail(`${label} payload checksum mismatch: ${relative}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function findExecutable(root) {
|
||||
const candidates = [
|
||||
path.join(root, 'usr', 'bin', 'firelink'),
|
||||
path.join(root, 'usr', 'bin', 'Firelink'),
|
||||
];
|
||||
const executable = candidates.find(candidate => {
|
||||
if (!fs.existsSync(candidate)) return false;
|
||||
const stat = fs.lstatSync(candidate);
|
||||
return stat.isFile() && !stat.isSymbolicLink();
|
||||
});
|
||||
if (!executable) {
|
||||
fail(`Packaged Firelink executable was not found under ${root}`);
|
||||
}
|
||||
return executable;
|
||||
}
|
||||
|
||||
function verifyExtractedPackage(packageType, packageFile, target, root) {
|
||||
const sourceRoot = path.join(repoRoot, 'src-tauri', 'provisioned-engines', target);
|
||||
const packagedRoot = findPayloadRoot(root, target, packageType);
|
||||
assertPayloadMatchesSource(sourceRoot, packagedRoot, target, packageType);
|
||||
run(process.execPath, [
|
||||
path.join(repoRoot, 'scripts', 'verify-binaries.js'),
|
||||
'--search-root',
|
||||
root,
|
||||
'--target',
|
||||
target,
|
||||
]);
|
||||
|
||||
const executable = findExecutable(root);
|
||||
run('xvfb-run', [
|
||||
'-a',
|
||||
process.execPath,
|
||||
path.join(repoRoot, 'scripts', 'smoke-packaged-app.js'),
|
||||
'--executable',
|
||||
executable,
|
||||
], { env: { APPDIR: root } });
|
||||
}
|
||||
|
||||
function main() {
|
||||
const target = argValue('--target');
|
||||
if (!target) fail('Pass --target <Rust target triple>.');
|
||||
if (os.platform() !== 'linux') fail('Linux package verification must run on Linux.');
|
||||
|
||||
const bundleRoot = path.join(repoRoot, 'src-tauri', 'target', target, 'release', 'bundle');
|
||||
const deb = findSingle(path.join(bundleRoot, 'deb'), '.deb', 'Debian package');
|
||||
const rpm = findSingle(path.join(bundleRoot, 'rpm'), '.rpm', 'RPM package');
|
||||
const extractionRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'firelink-linux-packages-'));
|
||||
const debRoot = path.join(extractionRoot, 'deb');
|
||||
const rpmRoot = path.join(extractionRoot, 'rpm');
|
||||
|
||||
try {
|
||||
assertPackageListing(deb, 'deb', 'usr/share/metainfo/com.nimbold.firelink.metainfo.xml');
|
||||
assertPackageRecommendations(deb, 'deb');
|
||||
extractDeb(deb, debRoot);
|
||||
verifyExtractedPackage('deb', deb, target, debRoot);
|
||||
|
||||
assertPackageListing(rpm, 'rpm', 'usr/share/metainfo/com.nimbold.firelink.metainfo.xml');
|
||||
assertPackageRecommendations(rpm, 'rpm');
|
||||
extractRpm(rpm, rpmRoot);
|
||||
verifyExtractedPackage('rpm', rpm, target, rpmRoot);
|
||||
console.log('Linux .deb and .rpm payload and launch verification passed.');
|
||||
} finally {
|
||||
fs.rmSync(extractionRoot, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
if (process.argv[1] && path.resolve(process.argv[1]) === __filename) {
|
||||
main();
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { isSafePackagePath, parseDebianPackagePath } from './verify-linux-packages.js';
|
||||
|
||||
test('parses current dpkg-deb listings without a ./ prefix', () => {
|
||||
assert.equal(
|
||||
parseDebianPackagePath('drwxr-xr-x 0/0 0 2026-07-12 07:24 usr/share/'),
|
||||
'usr/share/'
|
||||
);
|
||||
});
|
||||
|
||||
test('parses legacy dpkg-deb listings with a ./ prefix', () => {
|
||||
assert.equal(
|
||||
parseDebianPackagePath('-rwxr-xr-x root/root 123 2026-07-12 07:24 ./usr/bin/firelink'),
|
||||
'usr/bin/firelink'
|
||||
);
|
||||
});
|
||||
|
||||
test('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'),
|
||||
/Could not parse a Debian package path/
|
||||
);
|
||||
});
|
||||
Generated
+753
-678
File diff suppressed because it is too large
Load Diff
+12
-9
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "firelink"
|
||||
version = "1.0.3"
|
||||
version = "1.1.1"
|
||||
description = "A fast cross-platform desktop download manager powered by Rust and Tauri"
|
||||
authors = ["NimBold"]
|
||||
edition = "2021"
|
||||
@@ -31,38 +31,41 @@ serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
tokio = { version = "1", features = ["fs", "process", "io-util", "rt", "rt-multi-thread", "macros", "sync", "time"] }
|
||||
regex = "1.10"
|
||||
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json", "stream", "socks"] }
|
||||
reqwest = { version = "0.13", default-features = false, features = ["rustls-no-provider", "json", "stream", "socks"] }
|
||||
rustls = { version = "0.23.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"
|
||||
tauri-plugin-clipboard-manager = "2.3.2"
|
||||
sysinfo = "0.39.3"
|
||||
hmac = "0.13"
|
||||
sha2 = "0.11"
|
||||
tauri-plugin-deep-link = "2"
|
||||
tauri-plugin-single-instance = { version = "2", features = ["deep-link"] }
|
||||
tauri-plugin-single-instance = { version = "2.4.3", features = ["deep-link"] }
|
||||
tempfile = "3"
|
||||
thiserror = "2.0.18"
|
||||
axum = "0.8.9"
|
||||
tower-http = { version = "0.7", features = ["cors"] }
|
||||
tower-http = { version = "0.7", features = ["cors", "limit"] }
|
||||
sysproxy = "0.3.0"
|
||||
semver = "1.0.28"
|
||||
keepawake = "0.6.0"
|
||||
system_shutdown = "4.1.0"
|
||||
tokio-tungstenite = "0.29.0"
|
||||
tokio-tungstenite = "0.30.0"
|
||||
futures-util = { version = "0.3.32", features = ["sink"] }
|
||||
chrono = "0.4.38"
|
||||
url = "2"
|
||||
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.1", features = ["keychain"] }
|
||||
objc = "0.2.7"
|
||||
keyring = { version = "3", features = ["apple-native"] }
|
||||
|
||||
[target.'cfg(target_os = "windows")'.dependencies]
|
||||
keyring = { version = "3", features = ["windows-native"] }
|
||||
windows-native-keyring-store = "1.1.0"
|
||||
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
keyring = { version = "3", features = ["sync-secret-service", "vendored"] }
|
||||
zbus-secret-service-keyring-store = { version = "1.0.0", features = ["crypto-rust"] }
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -13,6 +13,7 @@
|
||||
"dialog:default",
|
||||
"log:default",
|
||||
"notification:default",
|
||||
"notification:allow-is-permission-granted"
|
||||
"notification:allow-is-permission-granted",
|
||||
"clipboard-manager:allow-read-text"
|
||||
]
|
||||
}
|
||||
|
||||
+49
-14
@@ -61,9 +61,8 @@ fn known_download_paths(app_handle: &tauri::AppHandle) -> Result<Vec<PathBuf>, S
|
||||
}
|
||||
|
||||
fn authorize_exact_path(requested: &Path, allowed_paths: &[PathBuf]) -> Result<PathBuf, String> {
|
||||
if std::fs::symlink_metadata(requested).is_ok_and(|metadata| metadata.file_type().is_symlink())
|
||||
{
|
||||
return Err("Download path may not be a symlink".to_string());
|
||||
if crate::path_has_symlink_component(requested) {
|
||||
return Err("Download path may not contain symlink components".to_string());
|
||||
}
|
||||
|
||||
let requested = canonicalize_with_missing_leaf(requested)?;
|
||||
@@ -74,8 +73,9 @@ fn authorize_exact_path(requested: &Path, allowed_paths: &[PathBuf]) -> Result<P
|
||||
}
|
||||
|
||||
let authorized = allowed_paths.iter().any(|allowed| {
|
||||
canonicalize_with_missing_leaf(allowed)
|
||||
.is_ok_and(|canonical_allowed| canonical_allowed == requested)
|
||||
!crate::path_has_symlink_component(allowed)
|
||||
&& canonicalize_with_missing_leaf(allowed)
|
||||
.is_ok_and(|canonical_allowed| canonical_allowed == requested)
|
||||
});
|
||||
|
||||
if authorized {
|
||||
@@ -98,14 +98,30 @@ fn canonicalize_with_missing_leaf(path: &Path) -> Result<PathBuf, String> {
|
||||
|
||||
let mut existing = path;
|
||||
let mut missing = Vec::new();
|
||||
while !existing.exists() {
|
||||
let name = existing
|
||||
.file_name()
|
||||
.ok_or_else(|| "Download path has no existing ancestor".to_string())?;
|
||||
missing.push(name.to_owned());
|
||||
existing = existing
|
||||
.parent()
|
||||
.ok_or_else(|| "Download path has no existing ancestor".to_string())?;
|
||||
loop {
|
||||
match std::fs::symlink_metadata(existing) {
|
||||
Ok(metadata) => {
|
||||
if metadata.file_type().is_symlink() {
|
||||
return Err("Download path may not contain symlink components".to_string());
|
||||
}
|
||||
break;
|
||||
}
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
|
||||
let name = existing
|
||||
.file_name()
|
||||
.ok_or_else(|| "Download path has no existing ancestor".to_string())?;
|
||||
missing.push(name.to_owned());
|
||||
existing = existing
|
||||
.parent()
|
||||
.ok_or_else(|| "Download path has no existing ancestor".to_string())?;
|
||||
}
|
||||
Err(error) => {
|
||||
return Err(format!(
|
||||
"Failed to inspect download path '{}': {error}",
|
||||
path.display()
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut canonical = std::fs::canonicalize(existing)
|
||||
@@ -144,7 +160,8 @@ mod tests {
|
||||
#[test]
|
||||
fn authorizes_only_known_download_files_and_partials() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let owned = root.path().join("owned.bin");
|
||||
let root = fs::canonicalize(root.path()).unwrap();
|
||||
let owned = root.join("owned.bin");
|
||||
let outside = tempfile::NamedTempFile::new().unwrap();
|
||||
fs::write(&owned, b"download").unwrap();
|
||||
|
||||
@@ -197,4 +214,22 @@ mod tests {
|
||||
|
||||
assert!(authorize_exact_path(&escaped, std::slice::from_ref(&escaped)).is_err());
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn rejects_parent_directory_symlink_escape_from_download_location() {
|
||||
use std::os::unix::fs::symlink;
|
||||
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let root_path = fs::canonicalize(root.path()).unwrap();
|
||||
let outside = tempfile::tempdir().unwrap();
|
||||
let outside_file = outside.path().join("owned.bin");
|
||||
fs::write(&outside_file, b"outside").unwrap();
|
||||
|
||||
let redirected_parent = root_path.join("downloads");
|
||||
symlink(outside.path(), &redirected_parent).unwrap();
|
||||
let escaped = redirected_parent.join("owned.bin");
|
||||
|
||||
assert!(authorize_exact_path(&escaped, std::slice::from_ref(&escaped)).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
+1300
-189
File diff suppressed because it is too large
Load Diff
+301
-54
@@ -46,18 +46,29 @@ impl DownloadCoordinator {
|
||||
.map_err(|_| "download coordinator is unavailable".to_string())
|
||||
}
|
||||
|
||||
pub async fn register_media(&self, id: String) -> Result<watch::Receiver<bool>, String> {
|
||||
pub async fn register_media(
|
||||
&self,
|
||||
id: String,
|
||||
lifecycle_generation: u64,
|
||||
) -> Result<watch::Receiver<bool>, String> {
|
||||
let (cancel_tx, cancel_rx) = watch::channel(false);
|
||||
self.media_tx
|
||||
.send(MediaCmd::Register { id, cancel_tx })
|
||||
.send(MediaCmd::Register {
|
||||
id,
|
||||
lifecycle_generation,
|
||||
cancel_tx,
|
||||
})
|
||||
.await
|
||||
.map_err(|_| "download coordinator is unavailable".to_string())?;
|
||||
Ok(cancel_rx)
|
||||
}
|
||||
|
||||
pub async fn pause_media(&self, id: String) -> Result<(), String> {
|
||||
pub async fn pause_media(&self, id: String, lifecycle_generation: u64) -> Result<(), String> {
|
||||
self.media_tx
|
||||
.send(MediaCmd::Pause(id))
|
||||
.send(MediaCmd::Pause {
|
||||
id,
|
||||
lifecycle_generation,
|
||||
})
|
||||
.await
|
||||
.map_err(|_| "download coordinator is unavailable".to_string())
|
||||
}
|
||||
@@ -65,16 +76,27 @@ impl DownloadCoordinator {
|
||||
pub async fn pause_media_with_ack(
|
||||
&self,
|
||||
id: String,
|
||||
ack: tokio::sync::oneshot::Sender<()>,
|
||||
lifecycle_generation: u64,
|
||||
ack: tokio::sync::oneshot::Sender<bool>,
|
||||
) -> Result<(), String> {
|
||||
self.media_tx
|
||||
.send(MediaCmd::PauseWithAck(id, ack))
|
||||
.send(MediaCmd::PauseWithAck {
|
||||
id,
|
||||
lifecycle_generation,
|
||||
ack,
|
||||
})
|
||||
.await
|
||||
.map_err(|_| "download coordinator is unavailable".to_string())
|
||||
}
|
||||
|
||||
pub async fn finish_media(&self, id: String) {
|
||||
let _ = self.media_tx.send(MediaCmd::Finished(id)).await;
|
||||
pub async fn finish_media(&self, id: String, lifecycle_generation: u64) {
|
||||
let _ = self
|
||||
.media_tx
|
||||
.send(MediaCmd::Finished {
|
||||
id,
|
||||
lifecycle_generation,
|
||||
})
|
||||
.await;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -96,11 +118,22 @@ impl CoordinatorEventSink {
|
||||
enum MediaCmd {
|
||||
Register {
|
||||
id: String,
|
||||
lifecycle_generation: u64,
|
||||
cancel_tx: watch::Sender<bool>,
|
||||
},
|
||||
Pause(String),
|
||||
PauseWithAck(String, tokio::sync::oneshot::Sender<()>),
|
||||
Finished(String),
|
||||
Pause {
|
||||
id: String,
|
||||
lifecycle_generation: u64,
|
||||
},
|
||||
PauseWithAck {
|
||||
id: String,
|
||||
lifecycle_generation: u64,
|
||||
ack: tokio::sync::oneshot::Sender<bool>,
|
||||
},
|
||||
Finished {
|
||||
id: String,
|
||||
lifecycle_generation: u64,
|
||||
},
|
||||
}
|
||||
|
||||
async fn run_coordinator(
|
||||
@@ -108,74 +141,125 @@ async fn run_coordinator(
|
||||
mut command_rx: mpsc::Receiver<DownloadCmd>,
|
||||
mut media_rx: mpsc::Receiver<MediaCmd>,
|
||||
) {
|
||||
let mut active_media = HashMap::<String, watch::Sender<bool>>::new();
|
||||
let mut pending_media_acks = HashMap::<String, tokio::sync::oneshot::Sender<()>>::new();
|
||||
let mut active_media = HashMap::<String, (u64, watch::Sender<bool>)>::new();
|
||||
let mut cancelled_media_generations = HashMap::<String, u64>::new();
|
||||
let mut pending_media_acks =
|
||||
HashMap::<(String, u64), tokio::sync::oneshot::Sender<bool>>::new();
|
||||
let mut pending_captured_urls = Vec::<String>::new();
|
||||
let mut frontend_ready = false;
|
||||
let mut command_open = true;
|
||||
let mut media_open = true;
|
||||
|
||||
loop {
|
||||
while command_open || media_open {
|
||||
tokio::select! {
|
||||
command = command_rx.recv() => {
|
||||
let Some(command) = command else {
|
||||
break;
|
||||
};
|
||||
|
||||
command = command_rx.recv(), if command_open => {
|
||||
match command {
|
||||
DownloadCmd::CaptureUrls(urls) => {
|
||||
append_unique_urls(&mut pending_captured_urls, urls);
|
||||
if frontend_ready && !pending_captured_urls.is_empty() {
|
||||
let payload = pending_captured_urls.join("\n");
|
||||
if events.emit_captured_urls(payload) {
|
||||
pending_captured_urls.clear();
|
||||
Some(command) => match command {
|
||||
DownloadCmd::CaptureUrls(urls) => {
|
||||
append_unique_urls(&mut pending_captured_urls, urls);
|
||||
if frontend_ready && !pending_captured_urls.is_empty() {
|
||||
let payload = pending_captured_urls.join("\n");
|
||||
if events.emit_captured_urls(payload) {
|
||||
pending_captured_urls.clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
DownloadCmd::FrontendReady(ready) => {
|
||||
frontend_ready = ready;
|
||||
if ready && !pending_captured_urls.is_empty() {
|
||||
let payload = pending_captured_urls.join("\n");
|
||||
if events.emit_captured_urls(payload) {
|
||||
pending_captured_urls.clear();
|
||||
DownloadCmd::FrontendReady(ready) => {
|
||||
frontend_ready = ready;
|
||||
if ready && !pending_captured_urls.is_empty() {
|
||||
let payload = pending_captured_urls.join("\n");
|
||||
if events.emit_captured_urls(payload) {
|
||||
pending_captured_urls.clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
None => command_open = false,
|
||||
}
|
||||
}
|
||||
command = media_rx.recv() => {
|
||||
let Some(command) = command else {
|
||||
continue;
|
||||
};
|
||||
command = media_rx.recv(), if media_open => {
|
||||
match command {
|
||||
MediaCmd::Register { id, cancel_tx } => {
|
||||
if let Some(previous) = active_media.insert(id, cancel_tx) {
|
||||
Some(command) => match command {
|
||||
MediaCmd::Register { id, lifecycle_generation, cancel_tx } => {
|
||||
if active_media
|
||||
.get(&id)
|
||||
.is_some_and(|(generation, _)| *generation > lifecycle_generation)
|
||||
{
|
||||
let _ = cancel_tx.send(true);
|
||||
continue;
|
||||
}
|
||||
let pending_cancel = cancelled_media_generations.get(&id).copied();
|
||||
if pending_cancel.is_some_and(|generation| generation < lifecycle_generation) {
|
||||
cancelled_media_generations.remove(&id);
|
||||
}
|
||||
let cancelled = pending_cancel.is_some_and(|generation| generation >= lifecycle_generation);
|
||||
if let Some((_, previous)) = active_media.insert(id.clone(), (lifecycle_generation, cancel_tx)) {
|
||||
let _ = previous.send(true);
|
||||
}
|
||||
}
|
||||
MediaCmd::Pause(id) => {
|
||||
if let Some(cancel_tx) = active_media.remove(&id) {
|
||||
let _ = cancel_tx.send(true);
|
||||
if cancelled {
|
||||
if let Some((_, cancel_tx)) = active_media.get(&id) {
|
||||
let _ = cancel_tx.send(true);
|
||||
}
|
||||
if pending_cancel == Some(lifecycle_generation) {
|
||||
cancelled_media_generations.remove(&id);
|
||||
}
|
||||
}
|
||||
}
|
||||
MediaCmd::PauseWithAck(id, ack) => {
|
||||
if let Some(cancel_tx) = active_media.remove(&id) {
|
||||
let _ = cancel_tx.send(true);
|
||||
pending_media_acks.insert(id, ack);
|
||||
MediaCmd::Pause { id, lifecycle_generation } => {
|
||||
if active_media.get(&id).is_some_and(|(generation, _)| *generation == lifecycle_generation) {
|
||||
if let Some((_, cancel_tx)) = active_media.remove(&id) {
|
||||
let _ = cancel_tx.send(true);
|
||||
}
|
||||
} else {
|
||||
let _ = ack.send(());
|
||||
cancelled_media_generations
|
||||
.entry(id)
|
||||
.and_modify(|generation| *generation = (*generation).max(lifecycle_generation))
|
||||
.or_insert(lifecycle_generation);
|
||||
}
|
||||
}
|
||||
MediaCmd::Finished(id) => {
|
||||
active_media.remove(&id);
|
||||
if let Some(ack) = pending_media_acks.remove(&id) {
|
||||
let _ = ack.send(());
|
||||
MediaCmd::PauseWithAck { id, lifecycle_generation, ack } => {
|
||||
if active_media.get(&id).is_some_and(|(generation, _)| *generation == lifecycle_generation) {
|
||||
if let Some((_, cancel_tx)) = active_media.remove(&id) {
|
||||
let _ = cancel_tx.send(true);
|
||||
pending_media_acks.insert((id, lifecycle_generation), ack);
|
||||
}
|
||||
} else {
|
||||
cancelled_media_generations
|
||||
.entry(id)
|
||||
.and_modify(|generation| *generation = (*generation).max(lifecycle_generation))
|
||||
.or_insert(lifecycle_generation);
|
||||
// No runner was registered for this generation.
|
||||
// The caller may retire the cancellation tombstone
|
||||
// while it still owns the per-download lock.
|
||||
let _ = ack.send(false);
|
||||
}
|
||||
}
|
||||
MediaCmd::Finished { id, lifecycle_generation } => {
|
||||
if active_media.get(&id).is_some_and(|(generation, _)| *generation == lifecycle_generation) {
|
||||
active_media.remove(&id);
|
||||
}
|
||||
if let Some(ack) =
|
||||
pending_media_acks.remove(&(id.clone(), lifecycle_generation))
|
||||
{
|
||||
// A pending acknowledgement means the runner was
|
||||
// registered and has now observed cancellation.
|
||||
let _ = ack.send(true);
|
||||
}
|
||||
if cancelled_media_generations
|
||||
.get(&id)
|
||||
.is_some_and(|generation| *generation <= lifecycle_generation)
|
||||
{
|
||||
cancelled_media_generations.remove(&id);
|
||||
}
|
||||
}
|
||||
},
|
||||
None => media_open = false,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (_, cancel_tx) in active_media {
|
||||
for (_, (_, cancel_tx)) in active_media {
|
||||
let _ = cancel_tx.send(true);
|
||||
}
|
||||
}
|
||||
@@ -219,9 +303,30 @@ pub(crate) fn format_duration(seconds: f64) -> String {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{DownloadCmd, DownloadCoordinator, DownloadEvent};
|
||||
use super::{CoordinatorEventSink, DownloadCmd, DownloadCoordinator, DownloadEvent};
|
||||
use tokio::sync::mpsc;
|
||||
use std::time::Duration;
|
||||
|
||||
#[tokio::test]
|
||||
async fn coordinator_exits_when_both_command_channels_close() {
|
||||
let (event_tx, _event_rx) = mpsc::unbounded_channel();
|
||||
let (command_tx, command_rx) = mpsc::channel(1);
|
||||
let (media_tx, media_rx) = mpsc::channel(1);
|
||||
let coordinator = tokio::spawn(super::run_coordinator(
|
||||
CoordinatorEventSink::Headless(event_tx),
|
||||
command_rx,
|
||||
media_rx,
|
||||
));
|
||||
|
||||
drop(command_tx);
|
||||
drop(media_tx);
|
||||
|
||||
tokio::time::timeout(Duration::from_secs(1), coordinator)
|
||||
.await
|
||||
.expect("coordinator did not exit after both channels closed")
|
||||
.expect("coordinator task panicked");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn buffers_captured_urls_until_frontend_is_ready() {
|
||||
let (coordinator, mut events) = DownloadCoordinator::spawn_headless();
|
||||
@@ -251,4 +356,146 @@ mod tests {
|
||||
DownloadEvent::CapturedUrls("https://example.com/startup.zip".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn stale_media_finish_cannot_remove_a_newer_lifecycle() {
|
||||
let (coordinator, _events) = DownloadCoordinator::spawn_headless();
|
||||
let mut old_cancel = coordinator.register_media("same-id".to_string(), 1).await.unwrap();
|
||||
let mut new_cancel = coordinator.register_media("same-id".to_string(), 2).await.unwrap();
|
||||
|
||||
tokio::time::timeout(Duration::from_secs(1), old_cancel.changed())
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
coordinator.finish_media("same-id".to_string(), 1).await;
|
||||
|
||||
let (ack_tx, ack_rx) = tokio::sync::oneshot::channel();
|
||||
coordinator
|
||||
.pause_media_with_ack("same-id".to_string(), 2, ack_tx)
|
||||
.await
|
||||
.unwrap();
|
||||
coordinator.finish_media("same-id".to_string(), 2).await;
|
||||
tokio::time::timeout(Duration::from_secs(1), ack_rx)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert!(*new_cancel.borrow_and_update());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn pause_before_media_registration_cancels_the_late_lifecycle() {
|
||||
let (coordinator, _events) = DownloadCoordinator::spawn_headless();
|
||||
let (ack_tx, ack_rx) = tokio::sync::oneshot::channel();
|
||||
|
||||
coordinator
|
||||
.pause_media_with_ack("late-media".to_string(), 7, ack_tx)
|
||||
.await
|
||||
.unwrap();
|
||||
tokio::time::timeout(Duration::from_secs(1), ack_rx)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
|
||||
let mut cancel_rx = coordinator
|
||||
.register_media("late-media".to_string(), 7)
|
||||
.await
|
||||
.unwrap();
|
||||
tokio::time::timeout(Duration::from_secs(1), async {
|
||||
while !*cancel_rx.borrow_and_update() {
|
||||
cancel_rx.changed().await.unwrap();
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("late media registration was not cancelled");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn unregistered_media_pause_tombstone_can_be_reconciled() {
|
||||
let (coordinator, _events) = DownloadCoordinator::spawn_headless();
|
||||
let (ack_tx, ack_rx) = tokio::sync::oneshot::channel();
|
||||
|
||||
coordinator
|
||||
.pause_media_with_ack("abandoned-media".to_string(), 9, ack_tx)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(!ack_rx.await.unwrap());
|
||||
|
||||
// Once the queue lifecycle is invalidated under its control lock,
|
||||
// it can retire the tombstone without allowing a late runner to
|
||||
// start. A later registration must therefore remain uncancelled.
|
||||
coordinator
|
||||
.finish_media("abandoned-media".to_string(), 9)
|
||||
.await;
|
||||
let cancel_rx = coordinator
|
||||
.register_media("abandoned-media".to_string(), 9)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(!*cancel_rx.borrow());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn stale_media_registration_cannot_replace_a_newer_lifecycle() {
|
||||
let (coordinator, _events) = DownloadCoordinator::spawn_headless();
|
||||
let mut new_cancel = coordinator
|
||||
.register_media("same-id".to_string(), 2)
|
||||
.await
|
||||
.unwrap();
|
||||
let mut old_cancel = coordinator
|
||||
.register_media("same-id".to_string(), 1)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
tokio::time::timeout(Duration::from_secs(1), old_cancel.changed())
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert!(*old_cancel.borrow_and_update());
|
||||
|
||||
let (ack_tx, ack_rx) = tokio::sync::oneshot::channel();
|
||||
coordinator
|
||||
.pause_media_with_ack("same-id".to_string(), 2, ack_tx)
|
||||
.await
|
||||
.unwrap();
|
||||
coordinator.finish_media("same-id".to_string(), 2).await;
|
||||
tokio::time::timeout(Duration::from_secs(1), ack_rx)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert!(*new_cancel.borrow_and_update());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn media_pause_ack_is_preserved_across_lifecycle_replacement() {
|
||||
let (coordinator, _events) = DownloadCoordinator::spawn_headless();
|
||||
let _old_cancel = coordinator
|
||||
.register_media("same-id".to_string(), 1)
|
||||
.await
|
||||
.unwrap();
|
||||
let (old_ack_tx, old_ack_rx) = tokio::sync::oneshot::channel();
|
||||
coordinator
|
||||
.pause_media_with_ack("same-id".to_string(), 1, old_ack_tx)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let _new_cancel = coordinator
|
||||
.register_media("same-id".to_string(), 2)
|
||||
.await
|
||||
.unwrap();
|
||||
let (new_ack_tx, new_ack_rx) = tokio::sync::oneshot::channel();
|
||||
coordinator
|
||||
.pause_media_with_ack("same-id".to_string(), 2, new_ack_tx)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
coordinator.finish_media("same-id".to_string(), 1).await;
|
||||
tokio::time::timeout(Duration::from_secs(1), old_ack_rx)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
coordinator.finish_media("same-id".to_string(), 2).await;
|
||||
tokio::time::timeout(Duration::from_secs(1), new_ack_rx)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,7 +59,12 @@ pub fn expected_primary_path(
|
||||
}
|
||||
|
||||
let safe_filename = canonical_download_filename(filename);
|
||||
Ok(resolved_dest.join(safe_filename))
|
||||
let path = resolved_dest.join(safe_filename);
|
||||
if crate::path_has_symlink_component(&path) {
|
||||
return Err("Download path may not contain symlink components".to_string());
|
||||
}
|
||||
crate::canonicalize_with_missing_components(&path)
|
||||
.ok_or_else(|| "Download path could not be canonicalized".to_string())
|
||||
}
|
||||
|
||||
pub fn register_expected(
|
||||
@@ -88,13 +93,15 @@ pub fn set_primary_path(
|
||||
}) {
|
||||
return Err("Download ownership path traversal is not allowed".to_string());
|
||||
}
|
||||
if std::fs::symlink_metadata(path).is_ok_and(|metadata| metadata.file_type().is_symlink()) {
|
||||
return Err("Download ownership path may not be a symlink".to_string());
|
||||
if crate::path_has_symlink_component(path) {
|
||||
return Err("Download ownership path may not contain symlink components".to_string());
|
||||
}
|
||||
let canonical_path = crate::canonicalize_with_missing_components(path)
|
||||
.ok_or_else(|| "Download ownership path could not be canonicalized".to_string())?;
|
||||
|
||||
let database = app_handle.state::<crate::db::DbState>();
|
||||
let connection = database.lock()?;
|
||||
crate::db::set_ownership(&connection, id, &path.to_string_lossy())
|
||||
crate::db::set_ownership(&connection, id, &canonical_path.to_string_lossy())
|
||||
}
|
||||
|
||||
pub fn remove(app_handle: &tauri::AppHandle, id: &str) -> Result<(), String> {
|
||||
@@ -147,11 +154,7 @@ fn legacy_download_queue_paths(app_handle: &tauri::AppHandle) -> Result<Vec<Path
|
||||
let downloads = {
|
||||
let database = app_handle.state::<crate::db::DbState>();
|
||||
let connection = database.lock()?;
|
||||
crate::db::load_downloads(&connection)?
|
||||
.into_iter()
|
||||
.map(|value| serde_json::from_str::<crate::ipc::DownloadItem>(&value))
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
.map_err(|error| format!("Invalid download queue ownership data: {error}"))?
|
||||
parse_legacy_download_items(crate::db::load_downloads(&connection)?)
|
||||
};
|
||||
|
||||
let mut paths = Vec::new();
|
||||
@@ -223,9 +226,23 @@ fn legacy_download_queue_paths(app_handle: &tauri::AppHandle) -> Result<Vec<Path
|
||||
Ok(paths)
|
||||
}
|
||||
|
||||
fn parse_legacy_download_items(values: Vec<String>) -> Vec<crate::ipc::DownloadItem> {
|
||||
values
|
||||
.into_iter()
|
||||
.filter_map(|value| match serde_json::from_str::<crate::ipc::DownloadItem>(&value) {
|
||||
Ok(download) => Some(download),
|
||||
Err(error) => {
|
||||
log::warn!("Skipping malformed download ownership record: {error}");
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::canonical_download_filename;
|
||||
use super::{canonical_download_filename, parse_legacy_download_items};
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn canonicalizes_untrusted_download_filenames() {
|
||||
@@ -238,4 +255,22 @@ mod tests {
|
||||
assert_eq!(canonical_download_filename("CON.txt"), "CON-.txt");
|
||||
assert_eq!(canonical_download_filename("lpt9"), "lpt9-");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_legacy_download_does_not_block_valid_ownership_records() {
|
||||
let valid = json!({
|
||||
"id": "download-1",
|
||||
"url": "https://example.com/file",
|
||||
"fileName": "file",
|
||||
"status": "completed",
|
||||
"category": "Other",
|
||||
"dateAdded": ""
|
||||
})
|
||||
.to_string();
|
||||
|
||||
let downloads = parse_legacy_download_items(vec!["not-json".to_string(), valid]);
|
||||
|
||||
assert_eq!(downloads.len(), 1);
|
||||
assert_eq!(downloads[0].id, "download-1");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,26 +17,34 @@ use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Arc, Mutex, RwLock};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
use tauri::{AppHandle, Emitter, Manager};
|
||||
use tokio::sync::watch;
|
||||
use tower_http::cors::{Any, CorsLayer};
|
||||
use tokio::sync::{oneshot, watch};
|
||||
use tower_http::{
|
||||
cors::{Any, CorsLayer},
|
||||
limit::RequestBodyLimitLayer,
|
||||
};
|
||||
use ts_rs::TS;
|
||||
|
||||
pub const EXTENSION_SERVER_PORT: u16 = 6412;
|
||||
pub const EXTENSION_SERVER_PORT_RANGE: std::ops::RangeInclusive<u16> = EXTENSION_SERVER_PORT..=6422;
|
||||
const MAX_URL_COUNT: usize = 200;
|
||||
const MAX_REQUEST_BODY_BYTES: usize = 256 * 1024;
|
||||
const SIGNATURE_MAX_AGE_MS: u64 = 60_000;
|
||||
const SERVER_HEADER: &str = "x-firelink-server";
|
||||
const PROTOCOL_VERSION_HEADER: &str = "x-firelink-protocol-version";
|
||||
const CLIENT_NONCE_HEADER: &str = "x-firelink-client-nonce";
|
||||
const SERVER_PROOF_HEADER: &str = "x-firelink-server-proof";
|
||||
const SERVER_PORT_HEADER: &str = "x-firelink-server-port";
|
||||
const SMOKE_PROCESS_ID_HEADER: &str = "x-firelink-smoke-process-id";
|
||||
const SERVER_PROOF_PREFIX: &[u8] = b"firelink-server-proof\n";
|
||||
const PROTOCOL_VERSION: &str = "4";
|
||||
const MAX_PENDING_EXTENSION_ACKS: usize = 64;
|
||||
const EXTENSION_ACK_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(2);
|
||||
|
||||
type HmacSha256 = Hmac<Sha256>;
|
||||
pub type SharedExtensionToken = Arc<RwLock<String>>;
|
||||
pub type SharedFrontendReady = Arc<AtomicBool>;
|
||||
pub type SharedServerPort = Arc<RwLock<Option<u16>>>;
|
||||
pub type SharedExtensionAcks = Arc<Mutex<HashMap<String, oneshot::Sender<()>>>>;
|
||||
type ReplayCache = Arc<Mutex<HashMap<String, u64>>>;
|
||||
|
||||
#[derive(Clone)]
|
||||
@@ -44,6 +52,7 @@ pub struct ServerState {
|
||||
pub app_handle: AppHandle,
|
||||
pub pairing_token: SharedExtensionToken,
|
||||
pub frontend_ready: SharedFrontendReady,
|
||||
pub extension_acks: SharedExtensionAcks,
|
||||
pub replay_cache: ReplayCache,
|
||||
pub bound_port: u16,
|
||||
}
|
||||
@@ -62,18 +71,30 @@ struct ExtensionRequest {
|
||||
#[serde(default)]
|
||||
cookies: Option<String>,
|
||||
#[serde(default)]
|
||||
cookie_scopes: Option<Vec<ExtensionCookieScope>>,
|
||||
#[serde(default)]
|
||||
media: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Deserialize, Serialize, TS)]
|
||||
#[ts(export, export_to = "../../src/bindings/")]
|
||||
pub struct ExtensionCookieScope {
|
||||
pub url: String,
|
||||
pub cookies: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize, TS)]
|
||||
#[ts(export, export_to = "../../src/bindings/")]
|
||||
pub struct ExtensionDownload {
|
||||
#[ts(optional)]
|
||||
request_id: Option<String>,
|
||||
urls: Vec<String>,
|
||||
referer: Option<String>,
|
||||
silent: bool,
|
||||
filename: Option<String>,
|
||||
headers: Option<String>,
|
||||
cookies: Option<String>,
|
||||
cookie_scopes: Option<Vec<ExtensionCookieScope>>,
|
||||
media: bool,
|
||||
}
|
||||
|
||||
@@ -81,6 +102,7 @@ pub async fn start_server(
|
||||
app_handle: AppHandle,
|
||||
pairing_token: SharedExtensionToken,
|
||||
frontend_ready: SharedFrontendReady,
|
||||
extension_acks: SharedExtensionAcks,
|
||||
server_port: SharedServerPort,
|
||||
mut shutdown_rx: watch::Receiver<bool>,
|
||||
) -> Result<(), String> {
|
||||
@@ -89,6 +111,7 @@ pub async fn start_server(
|
||||
app_handle,
|
||||
pairing_token,
|
||||
frontend_ready,
|
||||
extension_acks,
|
||||
replay_cache: Arc::new(Mutex::new(HashMap::new())),
|
||||
bound_port: port,
|
||||
};
|
||||
@@ -105,6 +128,7 @@ pub async fn start_server(
|
||||
.route("/ping", get(ping_handler))
|
||||
.route("/download", post(download_handler))
|
||||
.layer(cors)
|
||||
.layer(RequestBodyLimitLayer::new(MAX_REQUEST_BODY_BYTES))
|
||||
.layer(middleware::from_fn(add_server_identity))
|
||||
.with_state(state);
|
||||
|
||||
@@ -140,6 +164,13 @@ async fn add_server_identity(request: Request<Body>, next: Next) -> Response {
|
||||
PROTOCOL_VERSION_HEADER,
|
||||
HeaderValue::from_static(PROTOCOL_VERSION),
|
||||
);
|
||||
if std::env::var_os("FIRELINK_SMOKE_TEST").is_some() {
|
||||
if let Ok(process_id) = HeaderValue::from_str(&std::process::id().to_string()) {
|
||||
response
|
||||
.headers_mut()
|
||||
.insert(SMOKE_PROCESS_ID_HEADER, process_id);
|
||||
}
|
||||
}
|
||||
response
|
||||
}
|
||||
|
||||
@@ -166,6 +197,10 @@ async fn ping_handler(
|
||||
headers: HeaderMap,
|
||||
body: Bytes,
|
||||
) -> Result<Response, StatusCode> {
|
||||
if !has_allowed_request_origin(&headers) {
|
||||
return Err(StatusCode::FORBIDDEN);
|
||||
}
|
||||
|
||||
let signature = match headers
|
||||
.get("x-firelink-signature")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
@@ -191,7 +226,15 @@ async fn ping_handler(
|
||||
None => return Err(StatusCode::FORBIDDEN),
|
||||
};
|
||||
|
||||
if verify_signature(signature, timestamp_str, &body, &state.pairing_token).is_err() {
|
||||
let timestamp = match verify_signature(signature, timestamp_str, &body, &state.pairing_token) {
|
||||
Ok(timestamp) => timestamp,
|
||||
Err(_) => return Err(StatusCode::FORBIDDEN),
|
||||
};
|
||||
|
||||
// Discovery probes are authenticated requests too. Claim the verified
|
||||
// signature before signing a proof so a captured /ping signature cannot
|
||||
// be replayed with arbitrary client nonces during its validity window.
|
||||
if !claim_request(signature, timestamp, &state.replay_cache) {
|
||||
return Err(StatusCode::FORBIDDEN);
|
||||
}
|
||||
|
||||
@@ -215,7 +258,12 @@ async fn download_handler(
|
||||
State(state): State<ServerState>,
|
||||
headers: HeaderMap,
|
||||
body: Bytes,
|
||||
) -> Result<StatusCode, StatusCode> {
|
||||
) -> Result<Response, StatusCode> {
|
||||
let nonce = match required_client_nonce(&headers) {
|
||||
Some(nonce) if has_allowed_request_origin(&headers) => nonce,
|
||||
_ => return Err(StatusCode::FORBIDDEN),
|
||||
};
|
||||
|
||||
let signature = match headers
|
||||
.get("x-firelink-signature")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
@@ -269,15 +317,45 @@ async fn download_handler(
|
||||
return Err(StatusCode::SERVICE_UNAVAILABLE);
|
||||
}
|
||||
|
||||
let request_id = uuid::Uuid::new_v4().simple().to_string();
|
||||
let ack_receiver = register_extension_ack(&state.extension_acks, request_id.clone())
|
||||
.ok_or(StatusCode::SERVICE_UNAVAILABLE)?;
|
||||
let mut download = download;
|
||||
download.request_id = Some(request_id.clone());
|
||||
|
||||
if state
|
||||
.app_handle
|
||||
.emit("extension-add-download", download)
|
||||
.is_err()
|
||||
{
|
||||
remove_extension_ack(&state.extension_acks, &request_id);
|
||||
return Err(StatusCode::INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
|
||||
Ok(StatusCode::OK)
|
||||
if tokio::time::timeout(EXTENSION_ACK_TIMEOUT, ack_receiver)
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
remove_extension_ack(&state.extension_acks, &request_id);
|
||||
// The event may already have reached the frontend even when its
|
||||
// acknowledgement was delayed or lost. Do not return 503 here:
|
||||
// extension callers retry 503 and could create a duplicate modal.
|
||||
return Err(StatusCode::GATEWAY_TIMEOUT);
|
||||
}
|
||||
|
||||
let proof = sign_server_proof(timestamp_str, nonce, state.bound_port, &state.pairing_token)
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
let mut response = Response::new(Body::empty());
|
||||
response.headers_mut().insert(
|
||||
SERVER_PROOF_HEADER,
|
||||
HeaderValue::from_str(&proof).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?,
|
||||
);
|
||||
response.headers_mut().insert(
|
||||
SERVER_PORT_HEADER,
|
||||
HeaderValue::from_str(&state.bound_port.to_string())
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?,
|
||||
);
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
async fn wait_for_frontend(frontend_ready: &SharedFrontendReady) -> bool {
|
||||
@@ -290,36 +368,178 @@ async fn wait_for_frontend(frontend_ready: &SharedFrontendReady) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
fn normalize_download(payload: ExtensionRequest) -> Option<ExtensionDownload> {
|
||||
fn register_extension_ack(
|
||||
registry: &SharedExtensionAcks,
|
||||
request_id: String,
|
||||
) -> Option<oneshot::Receiver<()>> {
|
||||
let (sender, receiver) = oneshot::channel();
|
||||
let mut pending = registry.lock().ok()?;
|
||||
if pending.len() >= MAX_PENDING_EXTENSION_ACKS {
|
||||
return None;
|
||||
}
|
||||
pending.insert(request_id, sender);
|
||||
Some(receiver)
|
||||
}
|
||||
|
||||
pub fn acknowledge_extension_download(registry: &SharedExtensionAcks, request_id: &str) -> bool {
|
||||
let Some(sender) = registry
|
||||
.lock()
|
||||
.ok()
|
||||
.and_then(|mut pending| pending.remove(request_id))
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
sender.send(()).is_ok()
|
||||
}
|
||||
|
||||
fn remove_extension_ack(registry: &SharedExtensionAcks, request_id: &str) {
|
||||
if let Ok(mut pending) = registry.lock() {
|
||||
pending.remove(request_id);
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_download(mut payload: ExtensionRequest) -> Option<ExtensionDownload> {
|
||||
if payload.urls.len() > MAX_URL_COUNT {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut seen = HashSet::new();
|
||||
let urls = payload
|
||||
.urls
|
||||
.into_iter()
|
||||
.take(MAX_URL_COUNT)
|
||||
.filter_map(|raw_url| normalize_url(&raw_url))
|
||||
.filter(|url| seen.insert(url.clone()))
|
||||
.collect::<Vec<_>>();
|
||||
if urls.is_empty() {
|
||||
return None;
|
||||
}
|
||||
if payload.media
|
||||
&& urls.iter().any(|url| {
|
||||
Url::parse(url)
|
||||
.ok()
|
||||
.is_none_or(|url| !matches!(url.scheme(), "http" | "https"))
|
||||
})
|
||||
{
|
||||
return None;
|
||||
}
|
||||
|
||||
let referer = payload.referer.and_then(|value| {
|
||||
let url = Url::parse(value.trim()).ok()?;
|
||||
matches!(url.scheme(), "http" | "https").then(|| url.to_string())
|
||||
});
|
||||
let filename = payload.filename.and_then(|value| sanitize_filename(&value));
|
||||
// A multi-URL handoff has no per-URL cookie scope. Keep ordinary
|
||||
// request headers, but drop Cookie headers and the dedicated cookie field
|
||||
// so a legacy or untrusted caller cannot reuse one session across hosts.
|
||||
let headers = normalize_headers(payload.headers, payload.media || urls.len() > 1);
|
||||
let cookie_scopes = if !payload.media && urls.len() == 1 {
|
||||
let mut scopes = payload.cookie_scopes.take().unwrap_or_default();
|
||||
if let Some(cookies) = payload.cookies.take() {
|
||||
if !cookies.trim().is_empty() {
|
||||
scopes.push(ExtensionCookieScope {
|
||||
url: urls[0].clone(),
|
||||
cookies,
|
||||
});
|
||||
}
|
||||
}
|
||||
normalize_cookie_scopes(scopes)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let cookies = cookie_scopes.as_ref().and_then(|scopes| {
|
||||
scopes
|
||||
.iter()
|
||||
.find(|scope| same_origin_url(&scope.url, &urls[0]))
|
||||
.map(|scope| scope.cookies.clone())
|
||||
});
|
||||
|
||||
Some(ExtensionDownload {
|
||||
request_id: None,
|
||||
urls,
|
||||
referer,
|
||||
silent: payload.silent,
|
||||
filename,
|
||||
headers: payload.headers.filter(|value| !value.trim().is_empty()),
|
||||
cookies: payload.cookies.filter(|value| !value.trim().is_empty()),
|
||||
headers,
|
||||
// Explicit media is resolved by yt-dlp, which must use Firelink's
|
||||
// configured browser-cookie source. Forwarding a browser's complete
|
||||
// Cookie header can exceed upstream limits and makes old extension
|
||||
// builds pay for a doomed metadata request before retrying. Ordinary
|
||||
// captured downloads still need their exact request cookies.
|
||||
cookies,
|
||||
cookie_scopes,
|
||||
media: payload.media,
|
||||
})
|
||||
}
|
||||
|
||||
fn normalize_cookie_scopes(scopes: Vec<ExtensionCookieScope>) -> Option<Vec<ExtensionCookieScope>> {
|
||||
let mut normalized = Vec::new();
|
||||
let mut seen_origins = HashSet::new();
|
||||
|
||||
for scope in scopes {
|
||||
let Ok(url) = Url::parse(scope.url.trim()) else {
|
||||
continue;
|
||||
};
|
||||
if !matches!(url.scheme(), "http" | "https") {
|
||||
continue;
|
||||
}
|
||||
let cookies = scope.cookies.trim();
|
||||
if cookies.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let Some(host) = url.host_str() else {
|
||||
continue;
|
||||
};
|
||||
let origin = format!(
|
||||
"{}://{}:{}",
|
||||
url.scheme(),
|
||||
host,
|
||||
url.port_or_known_default().unwrap_or(443)
|
||||
);
|
||||
if !seen_origins.insert(origin) {
|
||||
continue;
|
||||
}
|
||||
normalized.push(ExtensionCookieScope {
|
||||
url: url.to_string(),
|
||||
cookies: cookies.to_string(),
|
||||
});
|
||||
if normalized.len() >= 16 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
(!normalized.is_empty()).then_some(normalized)
|
||||
}
|
||||
|
||||
fn same_origin_url(left: &str, right: &str) -> bool {
|
||||
let Some(left) = Url::parse(left).ok() else {
|
||||
return false;
|
||||
};
|
||||
let Some(right) = Url::parse(right).ok() else {
|
||||
return false;
|
||||
};
|
||||
left.scheme() == right.scheme()
|
||||
&& left.host() == right.host()
|
||||
&& left.port_or_known_default() == right.port_or_known_default()
|
||||
}
|
||||
|
||||
fn normalize_headers(headers: Option<String>, media: bool) -> Option<String> {
|
||||
let headers = headers?;
|
||||
if !media {
|
||||
return (!headers.trim().is_empty()).then_some(headers);
|
||||
}
|
||||
|
||||
let filtered = headers
|
||||
.lines()
|
||||
.filter(|line| {
|
||||
line.split_once(':')
|
||||
.map(|(name, _)| !name.trim().eq_ignore_ascii_case("cookie"))
|
||||
.unwrap_or(true)
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
(!filtered.trim().is_empty()).then_some(filtered)
|
||||
}
|
||||
|
||||
fn normalize_url(raw_url: &str) -> Option<String> {
|
||||
let url = Url::parse(raw_url.trim()).ok()?;
|
||||
matches!(url.scheme(), "http" | "https" | "ftp" | "sftp").then(|| url.to_string())
|
||||
@@ -363,6 +583,20 @@ fn is_valid_client_nonce(value: &str) -> bool {
|
||||
value.len() == 32 && value.bytes().all(|byte| byte.is_ascii_hexdigit())
|
||||
}
|
||||
|
||||
fn has_allowed_request_origin(headers: &HeaderMap) -> bool {
|
||||
match headers.get("origin") {
|
||||
None => true,
|
||||
Some(origin) => origin.to_str().ok().is_some_and(is_allowed_origin),
|
||||
}
|
||||
}
|
||||
|
||||
fn required_client_nonce(headers: &HeaderMap) -> Option<&str> {
|
||||
headers
|
||||
.get(CLIENT_NONCE_HEADER)
|
||||
.and_then(|nonce| nonce.to_str().ok())
|
||||
.filter(|nonce| is_valid_client_nonce(nonce))
|
||||
}
|
||||
|
||||
fn sign_server_proof(
|
||||
timestamp_text: &str,
|
||||
nonce: &str,
|
||||
@@ -397,16 +631,22 @@ fn claim_request(signature: &str, timestamp: u64, replay_cache: &ReplayCache) ->
|
||||
Some(now) => now,
|
||||
None => return false,
|
||||
};
|
||||
claim_request_at(signature, timestamp, replay_cache, now)
|
||||
}
|
||||
|
||||
fn claim_request_at(signature: &str, timestamp: u64, replay_cache: &ReplayCache, now: u64) -> bool {
|
||||
let mut cache = match replay_cache.lock() {
|
||||
Ok(cache) => cache,
|
||||
Err(_) => return false,
|
||||
};
|
||||
cache.retain(|_, seen_at| now.saturating_sub(*seen_at) < SIGNATURE_MAX_AGE_MS);
|
||||
if cache.len() > 10_000 {
|
||||
cache.retain(|_, expires_at| now < *expires_at);
|
||||
let key = format!("{timestamp}:{}", signature.to_ascii_lowercase());
|
||||
if cache.len() >= 10_000 && !cache.contains_key(&key) {
|
||||
return false;
|
||||
}
|
||||
let key = format!("{timestamp}:{}", signature.to_ascii_lowercase());
|
||||
cache.insert(key, now).is_none()
|
||||
cache
|
||||
.insert(key, timestamp.saturating_add(SIGNATURE_MAX_AGE_MS))
|
||||
.is_none()
|
||||
}
|
||||
|
||||
fn current_time_millis() -> Option<u64> {
|
||||
@@ -449,13 +689,21 @@ fn is_allowed_origin(origin: &str) -> bool {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
add_server_identity, is_valid_client_nonce, sign_server_proof, PROTOCOL_VERSION_HEADER,
|
||||
SERVER_HEADER,
|
||||
acknowledge_extension_download, add_server_identity, claim_request_at,
|
||||
has_allowed_request_origin, is_valid_client_nonce,
|
||||
normalize_download, required_client_nonce, sign_server_proof, ExtensionCookieScope,
|
||||
ExtensionRequest, MAX_URL_COUNT, PROTOCOL_VERSION_HEADER, SERVER_HEADER,
|
||||
};
|
||||
use axum::{
|
||||
http::{HeaderMap, HeaderValue, StatusCode},
|
||||
middleware,
|
||||
routing::get,
|
||||
Router,
|
||||
};
|
||||
use axum::{http::StatusCode, middleware, routing::get, Router};
|
||||
use hmac::{Hmac, KeyInit, Mac};
|
||||
use sha2::Sha256;
|
||||
use std::sync::{Arc, RwLock};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, Mutex, RwLock};
|
||||
|
||||
#[tokio::test]
|
||||
async fn identifies_every_extension_server_response() {
|
||||
@@ -470,6 +718,7 @@ mod tests {
|
||||
axum::serve(listener, app).await.unwrap();
|
||||
});
|
||||
|
||||
crate::ensure_reqwest_crypto_provider();
|
||||
let response = reqwest::get(format!("http://{address}/ping"))
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -491,6 +740,221 @@ mod tests {
|
||||
assert!(!is_valid_client_nonce("0123456789abcdef0123456789abcdeg"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_invalid_origins() {
|
||||
let mut headers = HeaderMap::new();
|
||||
assert!(has_allowed_request_origin(&headers));
|
||||
|
||||
headers.insert(
|
||||
"origin",
|
||||
HeaderValue::from_static("https://not-firelink.example"),
|
||||
);
|
||||
assert!(!has_allowed_request_origin(&headers));
|
||||
|
||||
headers.insert(
|
||||
"origin",
|
||||
HeaderValue::from_static("moz-extension://firelink"),
|
||||
);
|
||||
assert!(has_allowed_request_origin(&headers));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn requires_a_valid_client_nonce_for_downloads() {
|
||||
let mut headers = HeaderMap::new();
|
||||
assert!(required_client_nonce(&headers).is_none());
|
||||
|
||||
headers.insert(
|
||||
"x-firelink-client-nonce",
|
||||
HeaderValue::from_static("not-a-valid-nonce"),
|
||||
);
|
||||
assert!(required_client_nonce(&headers).is_none());
|
||||
|
||||
headers.insert(
|
||||
"x-firelink-client-nonce",
|
||||
HeaderValue::from_static("0123456789abcdef0123456789abcdef"),
|
||||
);
|
||||
assert_eq!(
|
||||
required_client_nonce(&headers),
|
||||
Some("0123456789abcdef0123456789abcdef")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn media_handoffs_reject_non_http_page_urls() {
|
||||
let download = normalize_download(ExtensionRequest {
|
||||
urls: vec!["ftp://example.com/audio.mp3".to_string()],
|
||||
referer: None,
|
||||
silent: false,
|
||||
filename: None,
|
||||
headers: None,
|
||||
cookies: None,
|
||||
cookie_scopes: None,
|
||||
media: true,
|
||||
});
|
||||
|
||||
assert!(download.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_oversized_url_lists_instead_of_truncating_them() {
|
||||
let download = normalize_download(ExtensionRequest {
|
||||
urls: (0..=MAX_URL_COUNT)
|
||||
.map(|index| format!("https://example.com/file-{index}.bin"))
|
||||
.collect(),
|
||||
referer: None,
|
||||
silent: false,
|
||||
filename: None,
|
||||
headers: None,
|
||||
cookies: None,
|
||||
cookie_scopes: None,
|
||||
media: false,
|
||||
});
|
||||
|
||||
assert!(download.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_replayed_download_signature() {
|
||||
let cache = Arc::new(Mutex::new(HashMap::new()));
|
||||
let signature = "a".repeat(64);
|
||||
let now = 1_000_000;
|
||||
|
||||
assert!(claim_request_at(&signature, now, &cache, now));
|
||||
assert!(!claim_request_at(&signature, now, &cache, now + 1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn future_timestamp_replay_claim_survives_cache_pruning_window() {
|
||||
let cache = Arc::new(Mutex::new(HashMap::new()));
|
||||
let signature = "b".repeat(64);
|
||||
let now = 1_000_000;
|
||||
let future_timestamp = now + 30_000;
|
||||
|
||||
assert!(claim_request_at(&signature, future_timestamp, &cache, now));
|
||||
assert!(!claim_request_at(
|
||||
&signature,
|
||||
future_timestamp,
|
||||
&cache,
|
||||
now + 70_000
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn acknowledges_and_removes_pending_extension_event() {
|
||||
let registry = Arc::new(Mutex::new(HashMap::new()));
|
||||
let (sender, receiver) = tokio::sync::oneshot::channel();
|
||||
registry
|
||||
.lock()
|
||||
.unwrap()
|
||||
.insert("request-1".to_string(), sender);
|
||||
|
||||
assert!(acknowledge_extension_download(®istry, "request-1"));
|
||||
assert!(!acknowledge_extension_download(®istry, "request-1"));
|
||||
assert!(receiver.await.is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_media_drops_the_extension_cookie_header() {
|
||||
let download = normalize_download(ExtensionRequest {
|
||||
urls: vec!["https://www.youtube.com/watch?v=example".to_string()],
|
||||
referer: None,
|
||||
silent: false,
|
||||
filename: None,
|
||||
headers: Some(format!(
|
||||
"Cookie: stale={};\nUser-Agent: Firefox",
|
||||
"x".repeat(64 * 1024)
|
||||
)),
|
||||
cookies: Some(format!("large={}", "x".repeat(64 * 1024))),
|
||||
cookie_scopes: None,
|
||||
media: true,
|
||||
})
|
||||
.expect("valid media handoff");
|
||||
|
||||
assert!(download.media);
|
||||
assert!(download.cookies.is_none());
|
||||
assert_eq!(download.headers.as_deref(), Some("User-Agent: Firefox"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn regular_capture_preserves_the_extension_cookie_header() {
|
||||
let download = normalize_download(ExtensionRequest {
|
||||
urls: vec!["https://example.com/private.zip".to_string()],
|
||||
referer: None,
|
||||
silent: true,
|
||||
filename: None,
|
||||
headers: None,
|
||||
cookies: Some("session=browser-cookie-header".to_string()),
|
||||
cookie_scopes: None,
|
||||
media: false,
|
||||
})
|
||||
.expect("valid download handoff");
|
||||
|
||||
assert!(!download.media);
|
||||
assert_eq!(
|
||||
download.cookies.as_deref(),
|
||||
Some("session=browser-cookie-header")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn regular_capture_normalizes_host_scoped_cookie_headers() {
|
||||
let download = normalize_download(ExtensionRequest {
|
||||
urls: vec!["https://mail.google.com/mail/u/0/?view=att".to_string()],
|
||||
referer: Some("https://mail.google.com/mail/u/0/".to_string()),
|
||||
silent: true,
|
||||
filename: Some("report.zip".to_string()),
|
||||
headers: None,
|
||||
cookies: Some("SID=mail-session".to_string()),
|
||||
cookie_scopes: Some(vec![
|
||||
ExtensionCookieScope {
|
||||
url: "https://mail.google.com/".to_string(),
|
||||
cookies: "SID=mail-session".to_string(),
|
||||
},
|
||||
ExtensionCookieScope {
|
||||
url: "https://accounts.google.com/".to_string(),
|
||||
cookies: "SID=account-session".to_string(),
|
||||
},
|
||||
ExtensionCookieScope {
|
||||
url: "https://mail.google.com/another-path".to_string(),
|
||||
cookies: "duplicate=ignored".to_string(),
|
||||
},
|
||||
]),
|
||||
media: false,
|
||||
})
|
||||
.expect("valid download handoff");
|
||||
|
||||
assert_eq!(download.cookies.as_deref(), Some("SID=mail-session"));
|
||||
assert_eq!(
|
||||
download.cookie_scopes.as_ref().map(|scopes| scopes.len()),
|
||||
Some(2)
|
||||
);
|
||||
assert_eq!(
|
||||
download.cookie_scopes.as_ref().unwrap()[1].cookies,
|
||||
"SID=account-session"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multi_url_capture_drops_cookie_scope_but_preserves_safe_headers() {
|
||||
let download = normalize_download(ExtensionRequest {
|
||||
urls: vec![
|
||||
"https://one.example/private.zip".to_string(),
|
||||
"https://two.example/file.zip".to_string(),
|
||||
],
|
||||
referer: None,
|
||||
silent: true,
|
||||
filename: None,
|
||||
headers: Some("Cookie: session=secret\nUser-Agent: Firefox".to_string()),
|
||||
cookies: Some("session=secret".to_string()),
|
||||
cookie_scopes: None,
|
||||
media: false,
|
||||
})
|
||||
.expect("valid multi-url handoff");
|
||||
|
||||
assert_eq!(download.cookies, None);
|
||||
assert_eq!(download.headers.as_deref(), Some("User-Agent: Firefox"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn signs_server_proof_with_timestamp_nonce_and_bound_port() {
|
||||
let token = Arc::new(RwLock::new("pairing-token".to_string()));
|
||||
|
||||
+17
-8
@@ -2,6 +2,10 @@ use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use ts_rs::TS;
|
||||
|
||||
fn default_speed_limit_unit() -> String {
|
||||
"MB/s".to_string()
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Serialize, Deserialize, TS)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
#[ts(export, export_to = "../../src/bindings/")]
|
||||
@@ -76,6 +80,12 @@ pub struct DownloadItem {
|
||||
pub eta: Option<String>,
|
||||
#[ts(optional)]
|
||||
pub size: Option<String>,
|
||||
#[ts(optional)]
|
||||
pub downloaded_bytes: Option<f64>,
|
||||
#[ts(optional)]
|
||||
pub total_bytes: Option<f64>,
|
||||
#[ts(optional)]
|
||||
pub total_is_estimate: Option<bool>,
|
||||
pub category: DownloadCategory,
|
||||
pub date_added: String,
|
||||
#[ts(optional)]
|
||||
@@ -110,6 +120,8 @@ pub struct DownloadItem {
|
||||
pub has_been_dispatched: Option<bool>,
|
||||
#[ts(optional)]
|
||||
pub last_error: Option<String>,
|
||||
#[ts(optional)]
|
||||
pub last_try: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, TS)]
|
||||
@@ -262,10 +274,14 @@ pub struct PersistedSettings {
|
||||
pub scheduler_last_start_key: String,
|
||||
pub scheduler_last_stop_key: String,
|
||||
pub last_custom_speed_limit_ki_b: u32,
|
||||
#[serde(default = "default_speed_limit_unit")]
|
||||
pub last_custom_speed_limit_unit: String,
|
||||
pub per_server_connections: i32,
|
||||
pub max_automatic_retries: i32,
|
||||
pub show_notifications: bool,
|
||||
pub play_completion_sound: bool,
|
||||
#[serde(default)]
|
||||
pub auto_add_clipboard_links: bool,
|
||||
pub app_font_size: AppFontSize,
|
||||
pub list_row_density: ListRowDensity,
|
||||
pub show_dock_badge: bool,
|
||||
@@ -278,14 +294,6 @@ pub struct PersistedSettings {
|
||||
pub prevents_sleep_while_downloading: bool,
|
||||
pub media_cookie_source: MediaCookieSource,
|
||||
pub site_logins: Vec<SiteLogin>,
|
||||
/// HMAC shared secret for the browser extension. It is persisted in the
|
||||
/// settings database so that startup never needs to touch the OS keychain.
|
||||
/// The keychain is still used as defence-in-depth — grant_keychain_access
|
||||
/// writes the token there — but the DB copy is the primary read path,
|
||||
/// eliminating the OS credential prompt that macOS shows when the binary
|
||||
/// signature changes after an update.
|
||||
#[serde(default)]
|
||||
pub extension_pairing_token: String,
|
||||
pub auto_check_updates: bool,
|
||||
#[serde(default)]
|
||||
pub keychain_access_granted: bool,
|
||||
@@ -298,6 +306,7 @@ pub struct PlatformInfo {
|
||||
pub os: String,
|
||||
pub arch: String,
|
||||
pub target_triple: String,
|
||||
pub portable: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Serialize, Deserialize, TS)]
|
||||
|
||||
+3536
-453
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,5 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
#[cfg(any(target_os = "macos", target_os = "linux"))]
|
||||
use std::process::Command;
|
||||
use ts_rs::TS;
|
||||
|
||||
@@ -83,13 +84,14 @@ fn proxy_from_environment() -> Option<String> {
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "macos", target_os = "linux"))]
|
||||
fn command_stdout(command: &mut Command) -> std::io::Result<String> {
|
||||
let output = command.output()?;
|
||||
if !output.status.success() {
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::Other,
|
||||
format!("command exited with {}", output.status),
|
||||
));
|
||||
return Err(std::io::Error::other(format!(
|
||||
"command exited with {}",
|
||||
output.status
|
||||
)));
|
||||
}
|
||||
Ok(String::from_utf8_lossy(&output.stdout).to_string())
|
||||
}
|
||||
@@ -541,6 +543,7 @@ pub async fn check_for_updates(
|
||||
) -> Result<ReleaseCheckOutcome, String> {
|
||||
let current_version = app_handle.package_info().version.to_string();
|
||||
|
||||
crate::ensure_reqwest_crypto_provider();
|
||||
let client = reqwest::Client::new();
|
||||
let res = client
|
||||
.get("https://api.github.com/repos/nimbold/Firelink/releases?per_page=30")
|
||||
|
||||
+1067
-230
File diff suppressed because it is too large
Load Diff
+51
-13
@@ -76,13 +76,14 @@ pub fn backoff_for(strike: usize) -> Duration {
|
||||
/// that also mentions "timeout" in a URL) still fails fast.
|
||||
pub fn is_permanent_network_error(message: &str) -> bool {
|
||||
let m = message.to_ascii_lowercase();
|
||||
const PERMANENT: [&str; 9] = [
|
||||
"http 401",
|
||||
"http 403",
|
||||
"http 404",
|
||||
"http 404.",
|
||||
"http 410",
|
||||
"http 451",
|
||||
const PERMANENT_HTTP_STATUS: [&str; 5] = ["401", "403", "404", "410", "451"];
|
||||
if PERMANENT_HTTP_STATUS
|
||||
.iter()
|
||||
.any(|status| contains_http_status(&m, status))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
const PERMANENT: [&str; 3] = [
|
||||
"404 not found",
|
||||
"permission denied",
|
||||
"no space left on device",
|
||||
@@ -90,6 +91,32 @@ pub fn is_permanent_network_error(message: &str) -> bool {
|
||||
PERMANENT.iter().any(|p| m.contains(p))
|
||||
}
|
||||
|
||||
/// Match the HTTP status formats emitted by both clients, including
|
||||
/// `HTTP/1.1 403` and `HTTP Error 403`, not only the shorthand `HTTP 403`.
|
||||
fn contains_http_status(message: &str, status: &str) -> bool {
|
||||
let tokens = message
|
||||
.split(|character: char| {
|
||||
character.is_ascii_whitespace()
|
||||
|| matches!(character, '/' | '.' | ':' | '-' | '_')
|
||||
})
|
||||
.filter(|token| !token.is_empty())
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
tokens.windows(2).any(|window| window[0] == "http" && window[1] == status)
|
||||
|| tokens.windows(3).any(|window| {
|
||||
window[0] == "http"
|
||||
&& (window[1] == "error"
|
||||
|| window[1].chars().all(|character| character.is_ascii_digit()))
|
||||
&& window[2] == status
|
||||
})
|
||||
|| tokens.windows(4).any(|window| {
|
||||
window[0] == "http"
|
||||
&& window[1].chars().all(|character| character.is_ascii_digit())
|
||||
&& window[2].chars().all(|character| character.is_ascii_digit())
|
||||
&& window[3] == status
|
||||
})
|
||||
}
|
||||
|
||||
pub fn is_transient_network_error(message: &str) -> bool {
|
||||
if is_permanent_network_error(message) {
|
||||
return false;
|
||||
@@ -97,7 +124,7 @@ pub fn is_transient_network_error(message: &str) -> bool {
|
||||
|
||||
let m = message.to_ascii_lowercase();
|
||||
|
||||
const TRANSIENT: [&str; 35] = [
|
||||
const TRANSIENT: [&str; 34] = [
|
||||
// socket-layer / HTTP-client phrasing surfaced by aria2 and yt-dlp
|
||||
"timed out",
|
||||
"timeout",
|
||||
@@ -113,13 +140,12 @@ pub fn is_transient_network_error(message: &str) -> bool {
|
||||
"connection aborted",
|
||||
"error sending request", // reqwest wrapper for connect/send failures
|
||||
"dns error", // transient resolver failures
|
||||
"protocol error", // aria2 read/protocol failures after a link drop
|
||||
"tls handshake failure",
|
||||
"ssl/tls handshake failure",
|
||||
// HTTP-level transient
|
||||
"http 408",
|
||||
"request timeout",
|
||||
"http 503",
|
||||
"503 service unavailable",
|
||||
"http 429",
|
||||
"http error 429",
|
||||
"429 too many requests",
|
||||
// aria2c HTTP error formats
|
||||
"status=408",
|
||||
@@ -138,7 +164,10 @@ pub fn is_transient_network_error(message: &str) -> bool {
|
||||
"timeout.",
|
||||
"invalid range header",
|
||||
];
|
||||
TRANSIENT.iter().any(|t| m.contains(t))
|
||||
contains_http_status(&m, "408")
|
||||
|| contains_http_status(&m, "429")
|
||||
|| contains_http_status(&m, "503")
|
||||
|| TRANSIENT.iter().any(|t| m.contains(t))
|
||||
}
|
||||
|
||||
/// Outcome of a cancel-safe backoff sleep wrapped around a transient retry.
|
||||
@@ -249,6 +278,7 @@ mod tests {
|
||||
#[test]
|
||||
fn classifies_http_503_as_transient() {
|
||||
assert!(is_transient_network_error("HTTP 503 Service Unavailable"));
|
||||
assert!(is_transient_network_error("HTTP/1.1 503 Service Unavailable"));
|
||||
assert!(is_transient_network_error(
|
||||
"http://127.0.0.1/file returned HTTP 503 Service Unavailable"
|
||||
));
|
||||
@@ -275,6 +305,12 @@ mod tests {
|
||||
assert!(is_transient_network_error("The response status is not successful. status=503"));
|
||||
assert!(is_transient_network_error("The response status is not successful. status=502"));
|
||||
assert!(is_transient_network_error("Invalid range header. Request: 106954752-361758719/383882118, Response: 106954752-383882117/383882118"));
|
||||
assert!(is_transient_network_error(
|
||||
"aria2 error code 1: Failed to receive data, cause: protocol error"
|
||||
));
|
||||
assert!(is_transient_network_error(
|
||||
"SSL/TLS handshake failure: protocol error"
|
||||
));
|
||||
}
|
||||
|
||||
// --- transient classification: negative cases -------------------------
|
||||
@@ -282,6 +318,8 @@ mod tests {
|
||||
#[test]
|
||||
fn refuses_to_retry_permanent_http_statuses() {
|
||||
assert!(!is_transient_network_error("HTTP 404 Not Found"));
|
||||
assert!(!is_transient_network_error("HTTP/1.1 403 Forbidden"));
|
||||
assert!(!is_transient_network_error("HTTP Error 404: Not Found"));
|
||||
assert!(!is_transient_network_error("HTTP 403 Forbidden"));
|
||||
assert!(!is_transient_network_error("HTTP 410 Gone"));
|
||||
assert!(!is_transient_network_error("HTTP 401 Unauthorized"));
|
||||
|
||||
+233
-10
@@ -20,6 +20,7 @@ pub fn decode_stored_settings(stored: &Value) -> Result<PersistedSettings, Strin
|
||||
let document = decode_document(stored)?;
|
||||
let mut state = settings_state(&document)?.clone();
|
||||
migrate_location_settings(&mut state)?;
|
||||
sanitize_persisted_setting_values(&mut state);
|
||||
let mut merged = serde_json::to_value(default_settings())
|
||||
.map_err(|error| format!("failed to serialize settings defaults: {error}"))?;
|
||||
merge_json(&mut merged, &state);
|
||||
@@ -77,6 +78,41 @@ pub fn preserve_scheduler_runtime_keys(
|
||||
.map_err(|error| format!("failed to encode persisted settings: {error}"))
|
||||
}
|
||||
|
||||
pub fn preserve_portable_pairing_token(
|
||||
existing: Option<&str>,
|
||||
incoming: &str,
|
||||
) -> Result<String, String> {
|
||||
let Some(existing) = existing else {
|
||||
return Ok(incoming.to_string());
|
||||
};
|
||||
|
||||
let existing_document = decode_document(&Value::String(existing.to_string()))?;
|
||||
let existing_state = settings_state(&existing_document)?;
|
||||
let Some(token) = existing_state
|
||||
.get("extensionPairingToken")
|
||||
.and_then(Value::as_str)
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
else {
|
||||
return Ok(incoming.to_string());
|
||||
};
|
||||
|
||||
let mut incoming_document = decode_document(&Value::String(incoming.to_string()))?;
|
||||
let incoming_state = settings_state_mut(&mut incoming_document)?;
|
||||
let incoming_token_present = incoming_state
|
||||
.get("extensionPairingToken")
|
||||
.and_then(Value::as_str)
|
||||
.is_some_and(|value| !value.trim().is_empty());
|
||||
if !incoming_token_present {
|
||||
incoming_state.insert(
|
||||
"extensionPairingToken".to_string(),
|
||||
Value::String(token.to_string()),
|
||||
);
|
||||
}
|
||||
|
||||
serde_json::to_string(&incoming_document)
|
||||
.map_err(|error| format!("failed to encode portable settings: {error}"))
|
||||
}
|
||||
|
||||
fn decode_document(stored: &Value) -> Result<Value, String> {
|
||||
match stored {
|
||||
Value::String(text) => serde_json::from_str(text)
|
||||
@@ -118,13 +154,108 @@ fn merge_json(target: &mut Value, source: &Value) {
|
||||
(Value::Object(target), Value::Object(source)) => {
|
||||
for (key, value) in source {
|
||||
if let Some(target_value) = target.get_mut(key) {
|
||||
merge_json(target_value, value);
|
||||
if json_value_types_match(target_value, value) {
|
||||
merge_json(target_value, value);
|
||||
}
|
||||
} else {
|
||||
target.insert(key.clone(), value.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
(target, source) => *target = source.clone(),
|
||||
(target, source) if json_value_types_match(target, source) => *target = source.clone(),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn json_value_types_match(left: &Value, right: &Value) -> bool {
|
||||
matches!(
|
||||
(left, right),
|
||||
(Value::Null, Value::Null)
|
||||
| (Value::Bool(_), Value::Bool(_))
|
||||
| (Value::Number(_), Value::Number(_))
|
||||
| (Value::String(_), Value::String(_))
|
||||
| (Value::Array(_), Value::Array(_))
|
||||
| (Value::Object(_), Value::Object(_))
|
||||
)
|
||||
}
|
||||
|
||||
fn sanitize_persisted_setting_values(state: &mut Value) {
|
||||
let Some(state) = state.as_object_mut() else {
|
||||
return;
|
||||
};
|
||||
|
||||
sanitize_integer_setting(state, "maxConcurrentDownloads", |value| value.as_u64().is_some());
|
||||
sanitize_integer_setting(state, "perServerConnections", |value| value.as_i64().is_some());
|
||||
sanitize_integer_setting(state, "maxAutomaticRetries", |value| value.as_i64().is_some());
|
||||
sanitize_allowed_string(
|
||||
state,
|
||||
"theme",
|
||||
&["system", "light", "dark", "dracula", "nord"],
|
||||
);
|
||||
sanitize_allowed_string(state, "appFontSize", &["small", "standard", "large"]);
|
||||
sanitize_allowed_string(state, "listRowDensity", &["compact", "standard", "relaxed"]);
|
||||
sanitize_allowed_string(state, "activeSettingsTab", &[
|
||||
"downloads",
|
||||
"lookandfeel",
|
||||
"network",
|
||||
"locations",
|
||||
"sitelogins",
|
||||
"power",
|
||||
"engine",
|
||||
"integrations",
|
||||
"about",
|
||||
]);
|
||||
sanitize_allowed_string(state, "proxyMode", &["none", "system", "custom"]);
|
||||
sanitize_allowed_string(
|
||||
state,
|
||||
"mediaCookieSource",
|
||||
&[
|
||||
"none", "safari", "chrome", "chromium", "firefox", "edge", "brave", "opera",
|
||||
"vivaldi", "whale",
|
||||
],
|
||||
);
|
||||
|
||||
if let Some(scheduler) = state.get_mut("scheduler").and_then(Value::as_object_mut) {
|
||||
sanitize_allowed_string(
|
||||
scheduler,
|
||||
"postQueueAction",
|
||||
&["none", "sleep", "restart", "shutdown"],
|
||||
);
|
||||
}
|
||||
|
||||
if let Some(logins) = state.get_mut("siteLogins").and_then(Value::as_array_mut) {
|
||||
logins.retain(|login| {
|
||||
let Some(login) = login.as_object() else {
|
||||
return false;
|
||||
};
|
||||
["id", "urlPattern", "username"]
|
||||
.into_iter()
|
||||
.all(|key| login.get(key).and_then(Value::as_str).is_some())
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn sanitize_integer_setting(
|
||||
state: &mut serde_json::Map<String, Value>,
|
||||
key: &str,
|
||||
is_valid: impl Fn(&Value) -> bool,
|
||||
) {
|
||||
if state.get(key).is_some_and(|value| !is_valid(value)) {
|
||||
state.remove(key);
|
||||
}
|
||||
}
|
||||
|
||||
fn sanitize_allowed_string(
|
||||
state: &mut serde_json::Map<String, Value>,
|
||||
key: &str,
|
||||
allowed: &[&str],
|
||||
) {
|
||||
let valid = state
|
||||
.get(key)
|
||||
.and_then(Value::as_str)
|
||||
.is_some_and(|value| allowed.contains(&value));
|
||||
if state.contains_key(key) && !valid {
|
||||
state.remove(key);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -132,6 +263,15 @@ fn validate_settings(settings: &mut PersistedSettings) {
|
||||
if settings.max_concurrent_downloads == 0 {
|
||||
settings.max_concurrent_downloads = default_settings().max_concurrent_downloads;
|
||||
}
|
||||
settings.max_concurrent_downloads = settings.max_concurrent_downloads.min(12);
|
||||
settings.per_server_connections = settings.per_server_connections.clamp(1, 16);
|
||||
settings.max_automatic_retries = settings.max_automatic_retries.clamp(0, 10);
|
||||
if !matches!(
|
||||
settings.last_custom_speed_limit_unit.as_str(),
|
||||
"KB/s" | "MB/s"
|
||||
) {
|
||||
settings.last_custom_speed_limit_unit = default_settings().last_custom_speed_limit_unit;
|
||||
}
|
||||
}
|
||||
|
||||
fn default_category_subfolders() -> HashMap<String, String> {
|
||||
@@ -296,10 +436,12 @@ fn default_settings() -> PersistedSettings {
|
||||
scheduler_last_start_key: String::new(),
|
||||
scheduler_last_stop_key: String::new(),
|
||||
last_custom_speed_limit_ki_b: 1024,
|
||||
last_custom_speed_limit_unit: "MB/s".to_string(),
|
||||
per_server_connections: 16,
|
||||
max_automatic_retries: 3,
|
||||
show_notifications: true,
|
||||
play_completion_sound: true,
|
||||
play_completion_sound: false,
|
||||
auto_add_clipboard_links: false,
|
||||
app_font_size: AppFontSize::Standard,
|
||||
list_row_density: ListRowDensity::Standard,
|
||||
show_dock_badge: true,
|
||||
@@ -312,7 +454,6 @@ fn default_settings() -> PersistedSettings {
|
||||
prevents_sleep_while_downloading: true,
|
||||
media_cookie_source: MediaCookieSource::default(),
|
||||
site_logins: Vec::new(),
|
||||
extension_pairing_token: String::new(),
|
||||
auto_check_updates: true,
|
||||
keychain_access_granted: false,
|
||||
}
|
||||
@@ -320,7 +461,10 @@ fn default_settings() -> PersistedSettings {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{decode_stored_settings, preserve_scheduler_runtime_keys};
|
||||
use super::{
|
||||
decode_stored_settings, default_settings, preserve_portable_pairing_token,
|
||||
preserve_scheduler_runtime_keys,
|
||||
};
|
||||
use serde_json::{json, Value};
|
||||
|
||||
#[test]
|
||||
@@ -396,6 +540,7 @@ mod tests {
|
||||
|
||||
assert_eq!(settings.max_concurrent_downloads, 5);
|
||||
assert_eq!(settings.global_speed_limit, "512K");
|
||||
assert_eq!(settings.last_custom_speed_limit_unit, "MB/s");
|
||||
assert_eq!(settings.speed_limit_preset_values, vec![1.0, 5.0, 10.0]);
|
||||
assert!(!settings.logs_enabled);
|
||||
assert!(!settings.scheduler.enabled);
|
||||
@@ -508,13 +653,63 @@ mod tests {
|
||||
assert_eq!(settings.max_concurrent_downloads, 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clamps_out_of_range_download_settings() {
|
||||
let stored = json!({
|
||||
"state": {
|
||||
"maxConcurrentDownloads": 99,
|
||||
"perServerConnections": -4,
|
||||
"maxAutomaticRetries": 99
|
||||
},
|
||||
"version": 3
|
||||
});
|
||||
|
||||
let settings = decode_stored_settings(&Value::String(stored.to_string())).unwrap();
|
||||
|
||||
assert_eq!(settings.max_concurrent_downloads, 12);
|
||||
assert_eq!(settings.per_server_connections, 1);
|
||||
assert_eq!(settings.max_automatic_retries, 10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_malformed_setting_types_without_dropping_valid_values() {
|
||||
let stored = json!({
|
||||
"state": {
|
||||
"baseDownloadFolder": "/Users/test/Downloads",
|
||||
"maxConcurrentDownloads": "not-a-number",
|
||||
"perServerConnections": 5,
|
||||
"showNotifications": "yes",
|
||||
"theme": "not-a-theme",
|
||||
"siteLogins": [{"id": "valid", "urlPattern": "example.com", "username": "user"}, {"id": 3}]
|
||||
},
|
||||
"version": 3
|
||||
});
|
||||
|
||||
let settings = decode_stored_settings(&Value::String(stored.to_string())).unwrap();
|
||||
|
||||
assert_eq!(settings.base_download_folder, "/Users/test/Downloads");
|
||||
assert_eq!(settings.max_concurrent_downloads, 3);
|
||||
assert_eq!(settings.per_server_connections, 5);
|
||||
assert!(settings.show_notifications);
|
||||
assert!(matches!(settings.theme, crate::ipc::Theme::System));
|
||||
assert_eq!(settings.site_logins.len(), 1);
|
||||
assert_eq!(settings.site_logins[0].id, "valid");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opt_in_defaults_match_the_frontend_defaults() {
|
||||
assert!(!default_settings().play_completion_sound);
|
||||
assert!(!default_settings().auto_add_clipboard_links);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_legacy_extension_pairing_token_field() {
|
||||
// Older versions persisted `extensionPairingToken` as plaintext inside
|
||||
// the settings document. It now lives in the OS keychain and is no
|
||||
// longer part of PersistedSettings. serde ignores the unknown field so
|
||||
// existing installs decode without error; the plaintext value is
|
||||
// simply dropped and a fresh token is minted by the frontend.
|
||||
// Older standard installs persisted `extensionPairingToken` as
|
||||
// plaintext inside the settings document. It now lives in the OS
|
||||
// keychain and is no longer part of PersistedSettings; portable mode
|
||||
// deliberately keeps its token in the portable settings document.
|
||||
// serde ignores the unknown field so existing installs decode without
|
||||
// error; standard-mode migration drops the plaintext value.
|
||||
let stored = json!({
|
||||
"state": {
|
||||
"extensionPairingToken": "plaintext-leaked-secret",
|
||||
@@ -527,4 +722,32 @@ mod tests {
|
||||
|
||||
assert_eq!(settings.max_concurrent_downloads, 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preserves_portable_pairing_token_when_startup_save_races() {
|
||||
let existing = json!({
|
||||
"state": {
|
||||
"extensionPairingToken": "portable-token",
|
||||
"maxConcurrentDownloads": 3
|
||||
},
|
||||
"version": 3
|
||||
})
|
||||
.to_string();
|
||||
let incoming = json!({
|
||||
"state": {
|
||||
"extensionPairingToken": "",
|
||||
"maxConcurrentDownloads": 5
|
||||
},
|
||||
"version": 3
|
||||
})
|
||||
.to_string();
|
||||
|
||||
let merged = preserve_portable_pairing_token(Some(&existing), &incoming).unwrap();
|
||||
let document: Value = serde_json::from_str(&merged).unwrap();
|
||||
assert_eq!(
|
||||
document["state"]["extensionPairingToken"],
|
||||
"portable-token"
|
||||
);
|
||||
assert_eq!(document["state"]["maxConcurrentDownloads"], 5);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
use std::path::{Path, PathBuf};
|
||||
use tauri::{AppHandle, Manager, Runtime};
|
||||
|
||||
pub const PORTABLE_MARKER: &str = "portable.flag";
|
||||
const PORTABLE_DATA_DIR: &str = "data";
|
||||
const PORTABLE_LOG_DIR: &str = "logs";
|
||||
const PORTABLE_WEBVIEW_DIR: &str = "webview";
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum StorageMode {
|
||||
Standard,
|
||||
Portable { root: PathBuf },
|
||||
}
|
||||
|
||||
impl StorageMode {
|
||||
pub fn detect() -> Self {
|
||||
let Some(executable) = std::env::current_exe().ok() else {
|
||||
return Self::Standard;
|
||||
};
|
||||
let Some(root) = executable.parent() else {
|
||||
return Self::Standard;
|
||||
};
|
||||
|
||||
if root.join(PORTABLE_MARKER).is_file() {
|
||||
Self::Portable {
|
||||
root: root.to_path_buf(),
|
||||
}
|
||||
} else {
|
||||
Self::Standard
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn detect_from_root(root: &Path) -> Self {
|
||||
if root.join(PORTABLE_MARKER).is_file() {
|
||||
Self::Portable {
|
||||
root: root.to_path_buf(),
|
||||
}
|
||||
} else {
|
||||
Self::Standard
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct StorageLayout {
|
||||
mode: StorageMode,
|
||||
data_dir: PathBuf,
|
||||
log_dir: PathBuf,
|
||||
webview_dir: PathBuf,
|
||||
}
|
||||
|
||||
impl StorageLayout {
|
||||
pub fn resolve<R: Runtime>(
|
||||
app_handle: &AppHandle<R>,
|
||||
mode: StorageMode,
|
||||
) -> Result<Self, String> {
|
||||
let (mode, data_dir, log_dir, webview_dir) = match mode {
|
||||
StorageMode::Standard => (
|
||||
StorageMode::Standard,
|
||||
app_handle
|
||||
.path()
|
||||
.app_data_dir()
|
||||
.map_err(|error| format!("failed to resolve app data directory: {error}"))?,
|
||||
app_handle
|
||||
.path()
|
||||
.app_log_dir()
|
||||
.map_err(|error| format!("failed to resolve app log directory: {error}"))?,
|
||||
app_handle.path().app_local_data_dir().map_err(|error| {
|
||||
format!("failed to resolve app local data directory: {error}")
|
||||
})?,
|
||||
),
|
||||
StorageMode::Portable { root } => {
|
||||
let data_dir = root.join(PORTABLE_DATA_DIR);
|
||||
(
|
||||
StorageMode::Portable { root },
|
||||
data_dir.clone(),
|
||||
data_dir.join(PORTABLE_LOG_DIR),
|
||||
data_dir.join(PORTABLE_WEBVIEW_DIR),
|
||||
)
|
||||
}
|
||||
};
|
||||
|
||||
Ok(Self {
|
||||
mode,
|
||||
data_dir: canonicalize_storage_path(&data_dir)?,
|
||||
log_dir: canonicalize_storage_path(&log_dir)?,
|
||||
webview_dir: canonicalize_storage_path(&webview_dir)?,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn is_portable(&self) -> bool {
|
||||
matches!(self.mode, StorageMode::Portable { .. })
|
||||
}
|
||||
|
||||
pub fn data_dir(&self) -> &Path {
|
||||
&self.data_dir
|
||||
}
|
||||
|
||||
pub fn log_dir(&self) -> &Path {
|
||||
&self.log_dir
|
||||
}
|
||||
|
||||
pub fn webview_dir(&self) -> &Path {
|
||||
&self.webview_dir
|
||||
}
|
||||
}
|
||||
|
||||
fn canonicalize_storage_path(path: &Path) -> Result<PathBuf, String> {
|
||||
if crate::path_has_symlink_component(path) {
|
||||
return Err(format!(
|
||||
"storage path contains a symlinked component: '{}'",
|
||||
path.display()
|
||||
));
|
||||
}
|
||||
let mut existing = path;
|
||||
let mut missing = Vec::new();
|
||||
loop {
|
||||
match std::fs::symlink_metadata(existing) {
|
||||
Ok(metadata) => {
|
||||
if metadata.file_type().is_symlink() {
|
||||
return Err(format!(
|
||||
"storage path contains a symlinked directory: '{}'",
|
||||
path.display()
|
||||
));
|
||||
}
|
||||
break;
|
||||
}
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
|
||||
Err(error) => {
|
||||
return Err(format!(
|
||||
"failed to inspect storage path '{}': {error}",
|
||||
path.display()
|
||||
));
|
||||
}
|
||||
}
|
||||
missing.push(
|
||||
existing
|
||||
.file_name()
|
||||
.ok_or_else(|| format!("storage path has no existing ancestor: '{}'", path.display()))?
|
||||
.to_owned(),
|
||||
);
|
||||
existing = existing
|
||||
.parent()
|
||||
.ok_or_else(|| format!("storage path has no existing ancestor: '{}'", path.display()))?;
|
||||
}
|
||||
let mut canonical = std::fs::canonicalize(existing)
|
||||
.map_err(|error| format!("failed to canonicalize storage path '{}': {error}", path.display()))?;
|
||||
for component in missing.iter().rev() {
|
||||
canonical.push(component);
|
||||
}
|
||||
Ok(canonical)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{canonicalize_storage_path, StorageMode, PORTABLE_MARKER};
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
use tempfile::TempDir;
|
||||
|
||||
#[test]
|
||||
fn marker_selects_portable_mode() {
|
||||
let root = TempDir::new().unwrap();
|
||||
fs::write(root.path().join(PORTABLE_MARKER), b"portable\n").unwrap();
|
||||
|
||||
assert_eq!(
|
||||
StorageMode::detect_from_root(root.path()),
|
||||
StorageMode::Portable {
|
||||
root: root.path().to_path_buf()
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_marker_keeps_standard_mode() {
|
||||
let root = TempDir::new().unwrap();
|
||||
|
||||
assert_eq!(
|
||||
StorageMode::detect_from_root(root.path()),
|
||||
StorageMode::Standard
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn rejects_symlinked_storage_directories() {
|
||||
use std::os::unix::fs::symlink;
|
||||
|
||||
let root = TempDir::new().unwrap();
|
||||
let target = TempDir::new().unwrap();
|
||||
let root_path = fs::canonicalize(root.path()).unwrap();
|
||||
let redirected = root_path.join("logs");
|
||||
symlink(target.path(), &redirected).unwrap();
|
||||
|
||||
assert!(canonicalize_storage_path(Path::new(&redirected)).is_err());
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn rejects_dangling_symlinked_storage_directories() {
|
||||
use std::os::unix::fs::symlink;
|
||||
|
||||
let root = TempDir::new().unwrap();
|
||||
let root_path = fs::canonicalize(root.path()).unwrap();
|
||||
let redirected = root_path.join("logs");
|
||||
symlink(root_path.join("missing-target"), &redirected).unwrap();
|
||||
|
||||
assert!(canonicalize_storage_path(Path::new(&redirected)).is_err());
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Firelink",
|
||||
"version": "1.0.3",
|
||||
"version": "1.1.1",
|
||||
"identifier": "com.nimbold.firelink",
|
||||
"build": {
|
||||
"beforeDevCommand": "node scripts/stage-engines.js && npm run dev",
|
||||
@@ -13,6 +13,7 @@
|
||||
"macOSPrivateApi": true,
|
||||
"windows": [
|
||||
{
|
||||
"create": false,
|
||||
"title": "Firelink",
|
||||
"width": 1280,
|
||||
"height": 760,
|
||||
|
||||
@@ -2,23 +2,36 @@
|
||||
"app": {
|
||||
"windows": [
|
||||
{
|
||||
"create": false,
|
||||
"title": "Firelink",
|
||||
"width": 1280,
|
||||
"height": 760,
|
||||
"minWidth": 960,
|
||||
"minHeight": 640,
|
||||
"transparent": false,
|
||||
"decorations": true
|
||||
"decorations": false
|
||||
}
|
||||
]
|
||||
},
|
||||
"bundle": {
|
||||
"targets": ["appimage"],
|
||||
"targets": ["appimage", "deb", "rpm"],
|
||||
"linux": {
|
||||
"appimage": {
|
||||
"files": {
|
||||
"usr/share/metainfo/com.nimbold.firelink.metainfo.xml": "linux/com.nimbold.firelink.metainfo.xml"
|
||||
}
|
||||
},
|
||||
"deb": {
|
||||
"recommends": ["desktop-file-utils", "xdg-utils"],
|
||||
"files": {
|
||||
"/usr/share/metainfo/com.nimbold.firelink.metainfo.xml": "linux/com.nimbold.firelink.metainfo.xml"
|
||||
}
|
||||
},
|
||||
"rpm": {
|
||||
"recommends": ["desktop-file-utils", "xdg-utils"],
|
||||
"files": {
|
||||
"/usr/share/metainfo/com.nimbold.firelink.metainfo.xml": "linux/com.nimbold.firelink.metainfo.xml"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
"app": {
|
||||
"windows": [
|
||||
{
|
||||
"create": false,
|
||||
"title": "Firelink",
|
||||
"width": 1280,
|
||||
"height": 760,
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
"app": {
|
||||
"windows": [
|
||||
{
|
||||
"create": false,
|
||||
"title": "Firelink",
|
||||
"width": 1280,
|
||||
"height": 760,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use firelink_lib::queue::{
|
||||
QueueManager, QueuedTask, SidecarSpawner, SpawnPayload, TaskKind, MEDIA_RUN_CANCELLED,
|
||||
Aria2RefreshOutcome, QueueManager, QueuedTask, SidecarSpawner, SpawnPayload, TaskKind,
|
||||
MEDIA_RUN_CANCELLED,
|
||||
};
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::sync::Arc;
|
||||
@@ -16,13 +17,34 @@ struct CountingSpawner {
|
||||
|
||||
struct DelayedAria2Spawner {
|
||||
gid_tx: tokio::sync::Mutex<Option<tokio::sync::oneshot::Sender<()>>>,
|
||||
add_uri_calls: AtomicUsize,
|
||||
remove_uri_calls: AtomicUsize,
|
||||
}
|
||||
|
||||
struct FailFirstAria2Spawner {
|
||||
add_uri_calls: AtomicUsize,
|
||||
fail_first: std::sync::atomic::AtomicBool,
|
||||
}
|
||||
|
||||
struct RefreshOutcomeSpawner {
|
||||
outcome: Aria2RefreshOutcome,
|
||||
refresh_calls: AtomicUsize,
|
||||
}
|
||||
|
||||
impl FailFirstAria2Spawner {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
add_uri_calls: AtomicUsize::new(0),
|
||||
fail_first: std::sync::atomic::AtomicBool::new(true),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl DelayedAria2Spawner {
|
||||
fn new(gid_tx: tokio::sync::oneshot::Sender<()>) -> Self {
|
||||
Self {
|
||||
gid_tx: tokio::sync::Mutex::new(Some(gid_tx)),
|
||||
add_uri_calls: AtomicUsize::new(0),
|
||||
remove_uri_calls: AtomicUsize::new(0),
|
||||
}
|
||||
}
|
||||
@@ -31,10 +53,14 @@ impl DelayedAria2Spawner {
|
||||
#[async_trait::async_trait]
|
||||
impl SidecarSpawner for DelayedAria2Spawner {
|
||||
async fn add_uri(&self, _id: &str, _payload: &SpawnPayload) -> Result<String, String> {
|
||||
let tx = self.gid_tx.lock().await.take().expect("gid release sender");
|
||||
let _ = tx.send(());
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
Ok("late-gid".to_string())
|
||||
let call = self.add_uri_calls.fetch_add(1, Ordering::SeqCst) + 1;
|
||||
if let Some(tx) = self.gid_tx.lock().await.take() {
|
||||
let _ = tx.send(());
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
Ok("late-gid".to_string())
|
||||
} else {
|
||||
Ok(format!("gid-{call}"))
|
||||
}
|
||||
}
|
||||
|
||||
async fn remove_uri(&self, gid: &str) -> Result<(), String> {
|
||||
@@ -43,11 +69,34 @@ impl SidecarSpawner for DelayedAria2Spawner {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn run_media(&self, _id: &str, _payload: &SpawnPayload) -> Result<(), String> {
|
||||
async fn run_media(&self, _id: &str, _payload: &SpawnPayload, _generation: u64) -> Result<(), String> {
|
||||
unreachable!("media is not used by delayed aria2 tests")
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl SidecarSpawner for FailFirstAria2Spawner {
|
||||
async fn add_uri(&self, _id: &str, _payload: &SpawnPayload) -> Result<String, String> {
|
||||
let call = self.add_uri_calls.fetch_add(1, Ordering::SeqCst) + 1;
|
||||
if self
|
||||
.fail_first
|
||||
.swap(false, std::sync::atomic::Ordering::SeqCst)
|
||||
{
|
||||
Err("initial aria2 RPC failure".to_string())
|
||||
} else {
|
||||
Ok(format!("gid-{call}"))
|
||||
}
|
||||
}
|
||||
|
||||
async fn remove_uri(&self, _gid: &str) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn run_media(&self, _id: &str, _payload: &SpawnPayload, _generation: u64) -> Result<(), String> {
|
||||
unreachable!("media is not used by fail-first aria2 tests")
|
||||
}
|
||||
}
|
||||
|
||||
impl CountingSpawner {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
@@ -57,6 +106,31 @@ impl CountingSpawner {
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl SidecarSpawner for RefreshOutcomeSpawner {
|
||||
async fn add_uri(&self, _id: &str, _payload: &SpawnPayload) -> Result<String, String> {
|
||||
unreachable!("refresh outcome tests do not spawn aria2")
|
||||
}
|
||||
|
||||
async fn remove_uri(&self, _gid: &str) -> Result<(), String> {
|
||||
unreachable!("refresh outcome tests do not remove aria2")
|
||||
}
|
||||
|
||||
async fn refresh_uri(&self, _gid: &str) -> Result<Aria2RefreshOutcome, String> {
|
||||
self.refresh_calls.fetch_add(1, Ordering::SeqCst);
|
||||
Ok(self.outcome)
|
||||
}
|
||||
|
||||
async fn run_media(
|
||||
&self,
|
||||
_id: &str,
|
||||
_payload: &SpawnPayload,
|
||||
_generation: u64,
|
||||
) -> Result<(), String> {
|
||||
unreachable!("refresh outcome tests do not run media")
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl firelink_lib::queue::SidecarSpawner for CountingSpawner {
|
||||
async fn add_uri(&self, _id: &str, _payload: &SpawnPayload) -> Result<String, String> {
|
||||
@@ -66,7 +140,7 @@ impl firelink_lib::queue::SidecarSpawner for CountingSpawner {
|
||||
async fn remove_uri(&self, _gid: &str) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
async fn run_media(&self, _id: &str, _payload: &SpawnPayload) -> Result<(), String> {
|
||||
async fn run_media(&self, _id: &str, _payload: &SpawnPayload, _generation: u64) -> Result<(), String> {
|
||||
self.media_calls.fetch_add(1, Ordering::SeqCst);
|
||||
Ok(())
|
||||
}
|
||||
@@ -88,6 +162,7 @@ fn sample_task(id: &str) -> QueuedTask {
|
||||
id: id.to_string(),
|
||||
queue_id: "main".to_string(),
|
||||
kind: TaskKind::Aria2,
|
||||
lifecycle_generation: 0,
|
||||
payload: SpawnPayload::default(),
|
||||
}
|
||||
}
|
||||
@@ -101,6 +176,60 @@ async fn push_appends_to_pending_and_emits_queued() {
|
||||
assert_eq!(order, vec!["a".to_string(), "b".to_string()]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cancelled_enqueue_generation_cannot_register_after_a_newer_user_action() {
|
||||
let (mgr, _spawner) = make_manager(2);
|
||||
mgr.cancel_enqueue_generation("a", 4).await;
|
||||
|
||||
let stale = mgr.push_with_generation(sample_task("a"), 4).await;
|
||||
assert!(
|
||||
stale.is_err(),
|
||||
"cancelled generation must not enter the queue"
|
||||
);
|
||||
assert!(!mgr.is_registered("a").await);
|
||||
|
||||
mgr.push_with_generation(sample_task("a"), 5)
|
||||
.await
|
||||
.expect("newer generation should be accepted");
|
||||
assert_eq!(mgr.pending_order(None).await, vec!["a".to_string()]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cancellation_between_reservation_and_commit_cannot_start_the_task() {
|
||||
let (mgr, _spawner) = make_manager(2);
|
||||
let previous = mgr
|
||||
.reserve_enqueue_generation("a", 7)
|
||||
.await
|
||||
.expect("reservation should succeed");
|
||||
mgr.cancel_enqueue_generation("a", 7).await;
|
||||
|
||||
let committed = mgr.commit_reserved_enqueue(sample_task("a"), 7).await;
|
||||
assert!(committed.is_err(), "cancelled reservation must not commit");
|
||||
mgr.rollback_enqueue_reservation("a", 7, previous).await;
|
||||
|
||||
assert!(!mgr.is_registered("a").await);
|
||||
assert!(mgr.pending_order(None).await.is_empty());
|
||||
assert!(mgr.push_with_generation(sample_task("a"), 7).await.is_err());
|
||||
mgr.push_with_generation(sample_task("a"), 8)
|
||||
.await
|
||||
.expect("a newer generation should remain startable");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn accepted_generation_cannot_be_replayed_after_registry_release() {
|
||||
let (mgr, _spawner) = make_manager(2);
|
||||
mgr.push_with_generation(sample_task("a"), 3)
|
||||
.await
|
||||
.expect("first enqueue should succeed");
|
||||
assert!(mgr.remove_from_pending("a").await);
|
||||
mgr.release_registered_id("a").await;
|
||||
|
||||
assert!(mgr.push_with_generation(sample_task("a"), 3).await.is_err());
|
||||
mgr.push_with_generation(sample_task("a"), 4)
|
||||
.await
|
||||
.expect("only a newer lifecycle may reuse the id");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn release_permit_is_idempotent() {
|
||||
let (mgr, _spawner) = make_manager(2);
|
||||
@@ -129,6 +258,24 @@ async fn ensure_aria2_permit_does_not_double_acquire() {
|
||||
assert_eq!(mgr.available_permits(), 2);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn stale_aria2_permit_candidate_cannot_replace_current_permit() {
|
||||
let (mgr, _spawner) = make_manager(2);
|
||||
let existing = mgr.acquire_permit().await.unwrap();
|
||||
assert!(mgr
|
||||
.park_aria2_permit_if_missing("a", existing)
|
||||
.await);
|
||||
|
||||
let candidate = mgr.acquire_aria2_permit_candidate().await.unwrap();
|
||||
assert!(!mgr
|
||||
.park_aria2_permit_if_missing("a", candidate)
|
||||
.await);
|
||||
assert_eq!(mgr.available_permits(), 1);
|
||||
|
||||
mgr.release_permit("a").await;
|
||||
assert_eq!(mgr.available_permits(), 2);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn aria2_control_epoch_invalidates_stale_resume_workers() {
|
||||
let (mgr, _spawner) = make_manager(1);
|
||||
@@ -141,6 +288,75 @@ async fn aria2_control_epoch_invalidates_stale_resume_workers() {
|
||||
assert!(mgr.is_aria2_control_epoch_current("a", pause).await);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn stale_terminal_event_cannot_complete_a_newer_control_epoch() {
|
||||
use firelink_lib::queue::PendingOutcome;
|
||||
|
||||
let (mgr, _spawner) = make_manager(1);
|
||||
let manager = Arc::new(mgr);
|
||||
manager.push(aria2_task("stale-event")).await.unwrap();
|
||||
let permit = manager.acquire_permit().await.expect("permit");
|
||||
manager.park_permit("stale-event", permit).await;
|
||||
|
||||
let old_epoch = manager.next_aria2_control_epoch("stale-event").await;
|
||||
manager
|
||||
.remember_gid("stale-event".to_string(), "gid-old".to_string())
|
||||
.await;
|
||||
manager.next_aria2_control_epoch("stale-event").await;
|
||||
|
||||
manager
|
||||
.handle_aria2_event("gid-old", PendingOutcome::Complete)
|
||||
.await;
|
||||
|
||||
assert!(
|
||||
!manager
|
||||
.is_aria2_control_epoch_current("stale-event", old_epoch)
|
||||
.await
|
||||
);
|
||||
assert_eq!(
|
||||
manager.available_permits(),
|
||||
0,
|
||||
"a terminal event from an older epoch must not release the newer lifecycle permit"
|
||||
);
|
||||
manager.forget_aria2_gid("stale-event").await;
|
||||
manager.release_permit("stale-event").await;
|
||||
manager.release_registered_id("stale-event").await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn resumed_gid_rebinds_to_the_new_control_epoch() {
|
||||
use firelink_lib::queue::PendingOutcome;
|
||||
|
||||
let (mgr, _spawner) = make_manager(1);
|
||||
let manager = Arc::new(mgr);
|
||||
manager.push(aria2_task("resumed-gid")).await.unwrap();
|
||||
let permit = manager.acquire_permit().await.expect("permit");
|
||||
manager.park_permit("resumed-gid", permit).await;
|
||||
manager
|
||||
.remember_gid("resumed-gid".to_string(), "gid-resumed".to_string())
|
||||
.await;
|
||||
|
||||
let resume_epoch = manager.next_aria2_control_epoch("resumed-gid").await;
|
||||
manager
|
||||
.handle_aria2_event("gid-resumed", PendingOutcome::Complete)
|
||||
.await;
|
||||
assert_eq!(
|
||||
manager.available_permits(),
|
||||
0,
|
||||
"the old GID epoch must not complete the resumed lifecycle"
|
||||
);
|
||||
|
||||
assert!(manager
|
||||
.rebind_aria2_gid_epoch("resumed-gid", "gid-resumed", resume_epoch)
|
||||
.await);
|
||||
manager
|
||||
.handle_aria2_event("gid-resumed", PendingOutcome::Complete)
|
||||
.await;
|
||||
|
||||
assert_eq!(manager.available_permits(), 1);
|
||||
assert!(manager.aria2_gid_for_download("resumed-gid").is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn forgetting_aria2_gid_clears_mapping_without_releasing_twice() {
|
||||
let (mgr, _spawner) = make_manager(1);
|
||||
@@ -332,6 +548,7 @@ fn aria2_task(id: &str) -> QueuedTask {
|
||||
id: id.to_string(),
|
||||
queue_id: "main".to_string(),
|
||||
kind: TaskKind::Aria2,
|
||||
lifecycle_generation: 0,
|
||||
payload: SpawnPayload::default(),
|
||||
}
|
||||
}
|
||||
@@ -341,6 +558,7 @@ fn media_task(id: &str) -> QueuedTask {
|
||||
id: id.to_string(),
|
||||
queue_id: "main".to_string(),
|
||||
kind: TaskKind::Media,
|
||||
lifecycle_generation: 0,
|
||||
payload: SpawnPayload::default(),
|
||||
}
|
||||
}
|
||||
@@ -359,7 +577,7 @@ impl SidecarSpawner for FixedMediaSpawner {
|
||||
unreachable!("aria2 is not used by media terminal-state tests")
|
||||
}
|
||||
|
||||
async fn run_media(&self, _id: &str, _payload: &SpawnPayload) -> Result<(), String> {
|
||||
async fn run_media(&self, _id: &str, _payload: &SpawnPayload, _generation: u64) -> Result<(), String> {
|
||||
self.outcome.clone()
|
||||
}
|
||||
}
|
||||
@@ -424,6 +642,7 @@ async fn media_cancellation_does_not_emit_completed() {
|
||||
assert!(!statuses.iter().any(|status| status == "failed"));
|
||||
assert!(!statuses.iter().any(|status| status == "completed"));
|
||||
assert_eq!(manager.available_permits(), 1);
|
||||
assert!(!manager.is_registered("media-cancelled").await);
|
||||
|
||||
dispatcher.abort();
|
||||
}
|
||||
@@ -460,6 +679,414 @@ async fn aria2_permit_survives_rpc_return() {
|
||||
handle.abort();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn failed_refresh_that_leaves_gid_paused_releases_permit_but_keeps_resume_mapping() {
|
||||
let app = mock_builder()
|
||||
.build(mock_context(noop_assets()))
|
||||
.expect("mock app");
|
||||
let spawner = Arc::new(RefreshOutcomeSpawner {
|
||||
outcome: Aria2RefreshOutcome::Paused,
|
||||
refresh_calls: AtomicUsize::new(0),
|
||||
});
|
||||
let manager = QueueManager::test_new(
|
||||
app.handle().clone(),
|
||||
1,
|
||||
Arc::clone(&spawner) as Arc<dyn SidecarSpawner>,
|
||||
);
|
||||
manager.push(aria2_task("refresh-paused")).await.unwrap();
|
||||
assert!(manager.ensure_aria2_permit("refresh-paused").await);
|
||||
manager
|
||||
.remember_gid("refresh-paused".to_string(), "gid-refresh-paused".to_string())
|
||||
.await;
|
||||
|
||||
let epoch = manager.current_aria2_control_epoch("refresh-paused").await;
|
||||
manager
|
||||
.refresh_aria2_connections("refresh-paused", "gid-refresh-paused", epoch)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(spawner.refresh_calls.load(Ordering::SeqCst), 1);
|
||||
assert_eq!(manager.available_permits(), 1);
|
||||
assert!(!manager.has_active_permit("refresh-paused").await);
|
||||
assert_eq!(
|
||||
manager.aria2_gid_for_download("refresh-paused").as_deref(),
|
||||
Some("gid-refresh-paused")
|
||||
);
|
||||
assert!(manager.is_registered("refresh-paused").await);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn stale_refresh_observation_cannot_touch_a_newer_control_epoch() {
|
||||
let app = mock_builder()
|
||||
.build(mock_context(noop_assets()))
|
||||
.expect("mock app");
|
||||
let spawner = Arc::new(RefreshOutcomeSpawner {
|
||||
outcome: Aria2RefreshOutcome::Resumed,
|
||||
refresh_calls: AtomicUsize::new(0),
|
||||
});
|
||||
let manager = QueueManager::test_new(
|
||||
app.handle().clone(),
|
||||
1,
|
||||
Arc::clone(&spawner) as Arc<dyn SidecarSpawner>,
|
||||
);
|
||||
manager.push(aria2_task("refresh-stale")).await.unwrap();
|
||||
assert!(manager.ensure_aria2_permit("refresh-stale").await);
|
||||
manager
|
||||
.remember_gid("refresh-stale".to_string(), "gid-refresh-stale".to_string())
|
||||
.await;
|
||||
let stale_epoch = manager.current_aria2_control_epoch("refresh-stale").await;
|
||||
manager.next_aria2_control_epoch("refresh-stale").await;
|
||||
|
||||
manager
|
||||
.refresh_aria2_connections("refresh-stale", "gid-refresh-stale", stale_epoch)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(spawner.refresh_calls.load(Ordering::SeqCst), 0);
|
||||
assert!(manager.has_active_permit("refresh-stale").await);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn transient_aria2_error_reissues_after_backoff() {
|
||||
use firelink_lib::queue::PendingOutcome;
|
||||
|
||||
let (mgr, spawner) = make_manager(1);
|
||||
let manager = Arc::new(mgr);
|
||||
let mut task = aria2_task("retry");
|
||||
task.payload.max_tries = Some(1);
|
||||
manager.push(task).await.unwrap();
|
||||
|
||||
let dispatcher = {
|
||||
let manager = Arc::clone(&manager);
|
||||
tokio::spawn(async move { manager.run_dispatcher().await })
|
||||
};
|
||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||
assert_eq!(spawner.add_uri_calls.load(Ordering::SeqCst), 1);
|
||||
|
||||
manager
|
||||
.handle_aria2_event(
|
||||
"gid-1",
|
||||
PendingOutcome::Error(
|
||||
"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("transient aria2 errors must retry after backoff");
|
||||
|
||||
manager
|
||||
.handle_aria2_event(
|
||||
"gid-2",
|
||||
PendingOutcome::Error("SSL/TLS handshake failure: protocol error".to_string()),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(spawner.add_uri_calls.load(Ordering::SeqCst), 2);
|
||||
assert!(manager.aria2_gid_for_download("retry").is_none());
|
||||
assert_eq!(manager.available_permits(), 1);
|
||||
|
||||
manager.release_permit("retry").await;
|
||||
dispatcher.abort();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn 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;
|
||||
@@ -572,6 +1199,28 @@ async fn move_up_down_reorders_pending() {
|
||||
assert_eq!(mgr_arc.pending_order(None).await, vec!["c", "a", "b"]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn multi_move_reorders_selected_items_as_one_atomic_block() {
|
||||
use firelink_lib::ipc::QueueDirection;
|
||||
|
||||
let (mgr, _spawner) = make_manager(3);
|
||||
for id in ["a", "b", "c", "d", "e"] {
|
||||
mgr.push(sample_task(id)).await.unwrap();
|
||||
}
|
||||
|
||||
let selected = vec!["b".to_string(), "d".to_string()];
|
||||
assert_eq!(
|
||||
mgr.move_many_in_queue(&selected, "main", QueueDirection::Up)
|
||||
.await,
|
||||
vec!["b", "d", "a", "c", "e"]
|
||||
);
|
||||
assert_eq!(
|
||||
mgr.move_many_in_queue(&selected, "main", QueueDirection::Down)
|
||||
.await,
|
||||
vec!["a", "b", "d", "c", "e"]
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn moving_one_queue_does_not_reorder_another_queue() {
|
||||
use firelink_lib::ipc::QueueDirection;
|
||||
|
||||
+359
-105
@@ -1,4 +1,4 @@
|
||||
import { initMediaDomains, isActiveDownloadStatus, normalizeSpeedLimitForBackend } from './utils/downloads';
|
||||
import { initMediaDomains, isActiveDownloadStatus, isTransferActiveStatus } from './utils/downloads';
|
||||
import { schedulerCompletionState } from './utils/schedulerCompletion';
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { Sidebar, SidebarFilter } from "./components/Sidebar";
|
||||
@@ -8,10 +8,11 @@ import SettingsView from "./components/SettingsView";
|
||||
import { PropertiesModal } from "./components/PropertiesModal";
|
||||
import { DeleteConfirmationModal } from "./components/DeleteConfirmationModal";
|
||||
import { extractValidDownloadUrls } from './utils/url';
|
||||
import { readClipboardDownloadUrls } from './utils/clipboard';
|
||||
import { listenEvent as listen, invokeCommand as invoke } from "./ipc";
|
||||
import { useDownloadStore, MAIN_QUEUE_ID } from './store/useDownloadStore';
|
||||
import { useDownloadStore, MAIN_QUEUE_ID, type ExtensionDownloadRequest } from './store/useDownloadStore';
|
||||
import { initDownloadListener } from './store/downloadStore';
|
||||
import { useSettingsStore } from "./store/useSettingsStore";
|
||||
import { subscribeToSettingsPersistenceErrors, useSettingsStore } from "./store/useSettingsStore";
|
||||
import { isPermissionGranted, requestPermission, sendNotification } from '@tauri-apps/plugin-notification';
|
||||
import SchedulerView from "./components/SchedulerView";
|
||||
import SpeedLimiterView from "./components/SpeedLimiterView";
|
||||
@@ -19,10 +20,18 @@ import LogsView from "./components/LogsView";
|
||||
import { KeychainPermissionModal } from "./components/KeychainPermissionModal";
|
||||
import { WindowControls } from "./components/WindowControls";
|
||||
import { useToast } from "./contexts/ToastContext";
|
||||
import { setLogStreamActive } from './utils/logger';
|
||||
import { openUrl } from '@tauri-apps/plugin-opener';
|
||||
import { usePlatformInfo } from './utils/platform';
|
||||
import { getPlatformInfo, usePlatformInfo } from './utils/platform';
|
||||
import {
|
||||
getKeychainAccessReady,
|
||||
getKeychainConsentVersion,
|
||||
getKeychainStartupDecision
|
||||
} from './utils/keychainStartup';
|
||||
import { getVersion } from '@tauri-apps/api/app';
|
||||
import type { PostQueueAction } from './bindings/PostQueueAction';
|
||||
import { PanelLeft } from 'lucide-react';
|
||||
import { isTrustedFirelinkReleaseUrl } from './utils/releaseUrls';
|
||||
|
||||
let automaticUpdateCheckStarted = false;
|
||||
const processingScheduleKeys = new Set<string>();
|
||||
@@ -37,6 +46,20 @@ const waitForSettingsHydration = (): Promise<void> => {
|
||||
});
|
||||
};
|
||||
|
||||
let downloadStateInitialization: Promise<void> | null = null;
|
||||
const initializeDownloadState = (): Promise<void> => {
|
||||
if (!downloadStateInitialization) {
|
||||
downloadStateInitialization = (async () => {
|
||||
await waitForSettingsHydration();
|
||||
await useDownloadStore.getState().initDB();
|
||||
})().catch(error => {
|
||||
downloadStateInitialization = null;
|
||||
throw error;
|
||||
});
|
||||
}
|
||||
return downloadStateInitialization;
|
||||
};
|
||||
|
||||
const getScheduledQueueIds = () => {
|
||||
const downloadState = useDownloadStore.getState();
|
||||
const availableQueueIds = new Set(downloadState.queues.map(queue => queue.id));
|
||||
@@ -79,6 +102,7 @@ function App() {
|
||||
const platform = usePlatformInfo();
|
||||
const [filter, setFilter] = useState<SidebarFilter>('all');
|
||||
const [coreReady, setCoreReady] = useState(false);
|
||||
const [keychainConsentVersion, setKeychainConsentVersion] = useState('');
|
||||
|
||||
const [sidebarWidth, setSidebarWidth] = useState(() => {
|
||||
const stored = Number(window.localStorage.getItem('firelink-sidebar-width'));
|
||||
@@ -92,32 +116,46 @@ function App() {
|
||||
const appFontSize = useSettingsStore(state => state.appFontSize);
|
||||
const listRowDensity = useSettingsStore(state => state.listRowDensity);
|
||||
const autoCheckUpdates = useSettingsStore(state => state.autoCheckUpdates);
|
||||
const autoAddClipboardLinks = useSettingsStore(state => state.autoAddClipboardLinks);
|
||||
const showNotifications = useSettingsStore(state => state.showNotifications);
|
||||
const showDockBadge = useSettingsStore(state => state.showDockBadge);
|
||||
const showMenuBarIcon = useSettingsStore(state => state.showMenuBarIcon);
|
||||
const logsEnabled = useSettingsStore(state => state.logsEnabled);
|
||||
const extensionPairingToken = useSettingsStore(state => state.extensionPairingToken);
|
||||
const showKeychainModal = useSettingsStore(state => state.showKeychainModal);
|
||||
const downloads = useDownloadStore(state => state.downloads);
|
||||
const activeDownloadCount = downloads.filter(download => download.status === 'downloading').length;
|
||||
const activeDownloadCount = downloads.filter(download => isTransferActiveStatus(download.status)).length;
|
||||
const queuedCount = downloads.filter(download =>
|
||||
download.status === 'queued' || download.status === 'staged'
|
||||
).length;
|
||||
const doneCount = downloads.filter(download => download.status === 'completed').length;
|
||||
const schedulerRunning = useSettingsStore(state => state.schedulerRunning);
|
||||
const schedulerActiveDownloadIds = useSettingsStore(state => state.schedulerActiveDownloadIds);
|
||||
const globalSpeedLimit = useSettingsStore(state => state.globalSpeedLimit);
|
||||
const previousSpeedLimit = useRef<string | null>(null);
|
||||
const pendingPostActionTimer = useRef<number | null>(null);
|
||||
const startupResumeStarted = useRef(false);
|
||||
const startupInputReady = useRef(false);
|
||||
const frontendReadyUpdate = useRef<Promise<void>>(Promise.resolve());
|
||||
const pendingStartupInputs = useRef<Array<
|
||||
| { type: 'extension'; payload: ExtensionDownloadRequest }
|
||||
| { type: 'deep-link'; payload: string }
|
||||
>>([]);
|
||||
const maxConcurrentDownloads = useSettingsStore(state => state.maxConcurrentDownloads);
|
||||
const preventsSleepWhileDownloading = useSettingsStore(state => state.preventsSleepWhileDownloading);
|
||||
const activeTransferCount = downloads.filter(download =>
|
||||
download.status === 'downloading' ||
|
||||
download.status === 'processing' ||
|
||||
download.status === 'retrying'
|
||||
).length;
|
||||
const { addToast } = useToast();
|
||||
const activeTransferCount = downloads.filter(download => isTransferActiveStatus(download.status)).length;
|
||||
const { addToast, removeToast } = useToast();
|
||||
const isMacUserAgent = navigator.userAgent.includes('Mac');
|
||||
const usesCustomWindowControls = !isMacUserAgent && platform.os !== 'macos';
|
||||
// Keep dialogs out of the titlebar area while platform detection is still
|
||||
// resolving. The conservative fallback prevents a startup handoff from
|
||||
// briefly rendering underneath native or custom window controls.
|
||||
const hasWindowChrome = isMacUserAgent || ['macos', 'windows', 'linux', 'unknown'].includes(platform.os);
|
||||
|
||||
useEffect(() => subscribeToSettingsPersistenceErrors(() => {
|
||||
addToast({
|
||||
message: 'Could not save settings. Check storage permissions and try again.',
|
||||
variant: 'error',
|
||||
isActionable: true
|
||||
});
|
||||
}), [addToast]);
|
||||
|
||||
const acknowledgePairingTokenChange = () => {
|
||||
invoke('acknowledge_pairing_token_change').catch(error => {
|
||||
@@ -132,24 +170,33 @@ function App() {
|
||||
}
|
||||
}, []);
|
||||
|
||||
const queueFrontendReadyUpdate = useCallback((ready: boolean) => {
|
||||
const update = frontendReadyUpdate.current
|
||||
.catch(() => undefined)
|
||||
.then(() => invoke('set_extension_frontend_ready', { ready }));
|
||||
frontendReadyUpdate.current = update;
|
||||
return update;
|
||||
}, []);
|
||||
|
||||
const schedulePostQueueAction = useCallback((action: Exclude<PostQueueAction, 'none'>) => {
|
||||
clearPendingPostActionTimer();
|
||||
|
||||
const actionLabel = action === 'shutdown' ? 'Shut down' : action === 'restart' ? 'Restart' : 'Sleep';
|
||||
let timerId: number | null = null;
|
||||
let toastId: string | null = null;
|
||||
const cancel = () => {
|
||||
if (timerId !== null) {
|
||||
window.clearTimeout(timerId);
|
||||
if (pendingPostActionTimer.current === timerId) {
|
||||
pendingPostActionTimer.current = null;
|
||||
}
|
||||
timerId = null;
|
||||
clearPendingPostActionTimer();
|
||||
timerId = null;
|
||||
if (toastId !== null) {
|
||||
removeToast(toastId);
|
||||
toastId = null;
|
||||
}
|
||||
};
|
||||
|
||||
addToast({
|
||||
toastId = addToast({
|
||||
variant: 'warning',
|
||||
isActionable: true,
|
||||
onDismiss: clearPendingPostActionTimer,
|
||||
message: (
|
||||
<div className="flex items-center gap-3">
|
||||
<span>{actionLabel} in 10 seconds.</span>
|
||||
@@ -165,6 +212,10 @@ function App() {
|
||||
});
|
||||
|
||||
timerId = window.setTimeout(() => {
|
||||
if (toastId !== null) {
|
||||
removeToast(toastId);
|
||||
toastId = null;
|
||||
}
|
||||
if (pendingPostActionTimer.current === timerId) {
|
||||
pendingPostActionTimer.current = null;
|
||||
}
|
||||
@@ -175,7 +226,7 @@ function App() {
|
||||
);
|
||||
if (activeTransfers) {
|
||||
addToast({
|
||||
message: 'System action cancelled because another download is active.',
|
||||
message: 'System action cancelled because another download is active or queued.',
|
||||
variant: 'warning',
|
||||
isActionable: true
|
||||
});
|
||||
@@ -191,7 +242,7 @@ function App() {
|
||||
});
|
||||
}, 10_000);
|
||||
pendingPostActionTimer.current = timerId;
|
||||
}, [addToast, clearPendingPostActionTimer]);
|
||||
}, [addToast, clearPendingPostActionTimer, removeToast]);
|
||||
|
||||
const startSidebarResize = (event: React.PointerEvent<HTMLDivElement>) => {
|
||||
event.preventDefault();
|
||||
@@ -218,6 +269,12 @@ function App() {
|
||||
return clearPendingPostActionTimer;
|
||||
}, [clearPendingPostActionTimer]);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeTransferCount > 0) {
|
||||
clearPendingPostActionTimer();
|
||||
}
|
||||
}, [activeTransferCount, clearPendingPostActionTimer]);
|
||||
|
||||
useEffect(() => {
|
||||
initMediaDomains();
|
||||
window.localStorage.setItem('firelink-sidebar-width', String(sidebarWidth));
|
||||
@@ -225,12 +282,93 @@ function App() {
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
let cleanupListeners: (() => void) | null = null;
|
||||
const initialize = async () => {
|
||||
let unlistenDownload: (() => void) | null = null;
|
||||
let unlistenTerminalState: (() => void) | null = null;
|
||||
let unlistenExtension: (() => void) | null = null;
|
||||
let unlistenDeepLink: (() => void) | null = null;
|
||||
const disposeListeners = () => {
|
||||
void queueFrontendReadyUpdate(false).catch(() => {});
|
||||
unlistenTerminalState?.();
|
||||
unlistenTerminalState = null;
|
||||
unlistenExtension?.();
|
||||
unlistenExtension = null;
|
||||
unlistenDeepLink?.();
|
||||
unlistenDeepLink = null;
|
||||
unlistenDownload?.();
|
||||
unlistenDownload = null;
|
||||
};
|
||||
|
||||
try {
|
||||
await waitForSettingsHydration();
|
||||
await useDownloadStore.getState().initDB();
|
||||
if (active) setCoreReady(true);
|
||||
unlistenDownload = await initDownloadListener();
|
||||
unlistenTerminalState = await listen('download-state', (event) => {
|
||||
if (event.payload.status !== 'completed' && event.payload.status !== 'failed') return;
|
||||
const settings = useSettingsStore.getState();
|
||||
if (event.payload.status === 'completed' && settings.playCompletionSound) {
|
||||
playCompletionChime().catch(error => {
|
||||
console.error('Completion sound failed:', error);
|
||||
});
|
||||
}
|
||||
if (!settings.showNotifications) return;
|
||||
|
||||
const item = useDownloadStore.getState().downloads.find(d => d.id === event.payload.id);
|
||||
const fileName = item?.fileName || 'A file';
|
||||
if (event.payload.status === 'completed') {
|
||||
try {
|
||||
sendNotification({
|
||||
title: 'Download Complete',
|
||||
body: `${fileName} has finished downloading.`
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Completion notification failed:', error);
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
sendNotification({
|
||||
title: 'Download Failed',
|
||||
body: `${fileName} failed to download.`,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failure notification failed:', error);
|
||||
}
|
||||
}
|
||||
});
|
||||
unlistenExtension = await listen('extension-add-download', (event) => {
|
||||
if (event.payload.request_id) {
|
||||
void invoke('ack_extension_download', { requestId: event.payload.request_id }).catch(error => {
|
||||
console.error('Failed to acknowledge browser extension download:', error);
|
||||
});
|
||||
}
|
||||
if (!startupInputReady.current || useSettingsStore.getState().showKeychainModal) {
|
||||
pendingStartupInputs.current.push({ type: 'extension', payload: event.payload });
|
||||
return;
|
||||
}
|
||||
useDownloadStore.getState().handleExtensionDownload(event.payload).catch(error => {
|
||||
console.error('Failed to handle browser extension download:', error);
|
||||
});
|
||||
});
|
||||
unlistenDeepLink = await listen('deep-link-add-download', (event) => {
|
||||
if (!startupInputReady.current || useSettingsStore.getState().showKeychainModal) {
|
||||
pendingStartupInputs.current.push({ type: 'deep-link', payload: event.payload });
|
||||
return;
|
||||
}
|
||||
useDownloadStore.getState().openAddModalWithUrls(event.payload);
|
||||
});
|
||||
|
||||
cleanupListeners = disposeListeners;
|
||||
if (!active) {
|
||||
disposeListeners();
|
||||
cleanupListeners = null;
|
||||
return;
|
||||
}
|
||||
|
||||
await initializeDownloadState();
|
||||
if (!active) return;
|
||||
} catch (error) {
|
||||
disposeListeners();
|
||||
cleanupListeners = null;
|
||||
if (!active) return;
|
||||
console.error('Failed to initialize Firelink state:', error);
|
||||
addToast({
|
||||
message: `Could not initialize saved downloads: ${String(error)}`,
|
||||
@@ -240,8 +378,46 @@ function App() {
|
||||
return;
|
||||
}
|
||||
|
||||
const [currentAppVersion, currentPlatform] = await Promise.all([
|
||||
getVersion().catch(() => ''),
|
||||
getPlatformInfo().catch(() => null)
|
||||
]);
|
||||
if (!active) return;
|
||||
const currentKeychainConsentVersion = getKeychainConsentVersion(currentAppVersion);
|
||||
setKeychainConsentVersion(currentKeychainConsentVersion);
|
||||
|
||||
try {
|
||||
const changed = await useSettingsStore.getState().hydratePairingToken();
|
||||
const settings = useSettingsStore.getState();
|
||||
const isStartupActive = () => active;
|
||||
const { deferKeychainHydration, showKeychainPrompt } = getKeychainStartupDecision({
|
||||
portable: currentPlatform?.portable === true,
|
||||
appVersion: currentKeychainConsentVersion,
|
||||
approvedVersion: settings.keychainAccessVersion,
|
||||
accessGranted: settings.keychainAccessGranted,
|
||||
promptDismissed: settings.keychainPromptDismissed
|
||||
});
|
||||
|
||||
let changed = false;
|
||||
if (deferKeychainHydration) {
|
||||
settings.setKeychainAccessReady(false);
|
||||
// This token is already owned by the backend and does not access
|
||||
// the OS credential store. Render our explanation before any native
|
||||
// Keychain/Credential Manager prompt can be user-triggered.
|
||||
await settings.hydrateSessionPairingToken(isStartupActive);
|
||||
if (!active) return;
|
||||
if (showKeychainPrompt) {
|
||||
settings.setShowKeychainModal(true);
|
||||
}
|
||||
} else {
|
||||
changed = await settings.hydratePairingToken(isStartupActive);
|
||||
if (!active) return;
|
||||
const currentSettings = useSettingsStore.getState();
|
||||
settings.setKeychainAccessReady(getKeychainAccessReady({
|
||||
portable: currentPlatform?.portable === true,
|
||||
accessGranted: currentSettings.keychainAccessGranted,
|
||||
persistent: currentSettings.isPairingTokenPersistent
|
||||
}));
|
||||
}
|
||||
if (changed) {
|
||||
addToast({
|
||||
variant: 'warning',
|
||||
@@ -296,12 +472,61 @@ function App() {
|
||||
isActionable: true
|
||||
});
|
||||
}
|
||||
|
||||
if (!active) return;
|
||||
setCoreReady(true);
|
||||
};
|
||||
void initialize();
|
||||
return () => {
|
||||
active = false;
|
||||
startupInputReady.current = false;
|
||||
pendingStartupInputs.current = [];
|
||||
cleanupListeners?.();
|
||||
cleanupListeners = null;
|
||||
};
|
||||
}, [addToast]);
|
||||
}, [addToast, queueFrontendReadyUpdate]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!coreReady) return;
|
||||
// The backend must not emit extension/deep-link handoffs while the
|
||||
// explanatory Keychain dialog is active. Deep links are buffered by the
|
||||
// coordinator, and extension callers receive a retryable 503 instead.
|
||||
void queueFrontendReadyUpdate(!showKeychainModal).catch(error => {
|
||||
console.error('Failed to update browser extension readiness:', error);
|
||||
});
|
||||
}, [coreReady, queueFrontendReadyUpdate, showKeychainModal]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!coreReady || showKeychainModal) {
|
||||
startupInputReady.current = false;
|
||||
return;
|
||||
}
|
||||
if (startupInputReady.current) return;
|
||||
startupInputReady.current = true;
|
||||
const pendingInputs = pendingStartupInputs.current.splice(0);
|
||||
for (const input of pendingInputs) {
|
||||
if (input.type === 'extension') {
|
||||
useDownloadStore.getState().handleExtensionDownload(input.payload).catch(error => {
|
||||
console.error('Failed to handle queued browser extension download:', error);
|
||||
});
|
||||
} else {
|
||||
useDownloadStore.getState().openAddModalWithUrls(input.payload);
|
||||
}
|
||||
}
|
||||
}, [coreReady, showKeychainModal]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!coreReady || showKeychainModal || startupResumeStarted.current) return;
|
||||
startupResumeStarted.current = true;
|
||||
useDownloadStore.getState().resumePendingDownloads().catch(error => {
|
||||
console.error('Failed to resume saved downloads after startup:', error);
|
||||
addToast({
|
||||
message: `Could not resume saved downloads: ${String(error)}`,
|
||||
variant: 'error',
|
||||
isActionable: true
|
||||
});
|
||||
});
|
||||
}, [addToast, coreReady, showKeychainModal]);
|
||||
|
||||
useEffect(() => {
|
||||
window.document.documentElement.setAttribute('data-font-size', appFontSize);
|
||||
@@ -319,6 +544,9 @@ function App() {
|
||||
invoke('check_for_updates')
|
||||
.then(result => {
|
||||
if (result.type !== 'UpdateAvailable') return;
|
||||
if (!isTrustedFirelinkReleaseUrl(result.update.release_url)) {
|
||||
throw new Error('The update check returned an untrusted release URL.');
|
||||
}
|
||||
addToast({
|
||||
variant: 'info',
|
||||
isActionable: true,
|
||||
@@ -379,8 +607,10 @@ function App() {
|
||||
}, [showMenuBarIcon]);
|
||||
|
||||
useEffect(() => {
|
||||
invoke('toggle_log_pause', { pause: !logsEnabled }).catch(console.error);
|
||||
}, [logsEnabled]);
|
||||
if (activeView !== 'logs') {
|
||||
setLogStreamActive(false).catch(console.error);
|
||||
}
|
||||
}, [activeView]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!extensionPairingToken) return;
|
||||
@@ -389,17 +619,6 @@ function App() {
|
||||
});
|
||||
}, [extensionPairingToken]);
|
||||
|
||||
useEffect(() => {
|
||||
if (previousSpeedLimit.current === globalSpeedLimit) return;
|
||||
previousSpeedLimit.current = globalSpeedLimit;
|
||||
|
||||
const formattedLimit = normalizeSpeedLimitForBackend(globalSpeedLimit);
|
||||
|
||||
invoke('set_global_speed_limit', { limit: formattedLimit }).catch(error => {
|
||||
console.error('Failed to apply global speed limit:', error);
|
||||
});
|
||||
}, [globalSpeedLimit]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!coreReady) return;
|
||||
const unlisten = listen('schedule-trigger', async (event) => {
|
||||
@@ -422,6 +641,7 @@ function App() {
|
||||
await invoke('ack_schedule_trigger', { action: 'start', key: payload.key });
|
||||
return;
|
||||
}
|
||||
const previouslyTrackedIds = new Set(state.schedulerActiveDownloadIds);
|
||||
const startedResults = await Promise.all(
|
||||
scheduledQueueIds.map(queueId => useDownloadStore.getState().startQueue(queueId))
|
||||
);
|
||||
@@ -429,6 +649,7 @@ function App() {
|
||||
const scheduledQueueSet = new Set(scheduledQueueIds);
|
||||
const trackedIds = useDownloadStore.getState().downloads
|
||||
.filter(download =>
|
||||
previouslyTrackedIds.has(download.id) &&
|
||||
scheduledQueueSet.has(download.queueId || MAIN_QUEUE_ID) &&
|
||||
isActiveDownloadStatus(download.status)
|
||||
)
|
||||
@@ -438,11 +659,11 @@ 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 => invoke('pause_download', { id }))
|
||||
trackedIds.map(id => useDownloadStore.getState().pauseDownload(id))
|
||||
);
|
||||
const failedPauses = pauseResults.filter(result => result.status === 'rejected').length;
|
||||
if (failedPauses > 0) {
|
||||
@@ -468,6 +689,7 @@ function App() {
|
||||
}, [addToast, clearPendingPostActionTimer, coreReady]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!coreReady) return;
|
||||
if (!schedulerRunning) return;
|
||||
if (schedulerActiveDownloadIds.length === 0) return;
|
||||
clearPendingPostActionTimer();
|
||||
@@ -485,11 +707,20 @@ function App() {
|
||||
isActionable: true
|
||||
});
|
||||
} else if (settings.scheduler.postQueueAction !== 'none') {
|
||||
schedulePostQueueAction(settings.scheduler.postQueueAction);
|
||||
if (downloads.some(download => isActiveDownloadStatus(download.status))) {
|
||||
addToast({
|
||||
message: 'Scheduled system action skipped because another download is active or queued.',
|
||||
variant: 'warning',
|
||||
isActionable: true
|
||||
});
|
||||
} else {
|
||||
schedulePostQueueAction(settings.scheduler.postQueueAction);
|
||||
}
|
||||
}
|
||||
}, [
|
||||
addToast,
|
||||
clearPendingPostActionTimer,
|
||||
coreReady,
|
||||
downloads,
|
||||
schedulePostQueueAction,
|
||||
schedulerRunning,
|
||||
@@ -531,6 +762,7 @@ function App() {
|
||||
|
||||
useEffect(() => {
|
||||
const handlePaste = (e: ClipboardEvent) => {
|
||||
if (!coreReady || useSettingsStore.getState().showKeychainModal) return;
|
||||
if (
|
||||
e.target instanceof HTMLInputElement ||
|
||||
e.target instanceof HTMLTextAreaElement ||
|
||||
@@ -549,7 +781,85 @@ function App() {
|
||||
};
|
||||
window.addEventListener('paste', handlePaste);
|
||||
return () => window.removeEventListener('paste', handlePaste);
|
||||
}, []);
|
||||
}, [coreReady, showKeychainModal]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!coreReady || showKeychainModal || !autoAddClipboardLinks) return;
|
||||
|
||||
let active = true;
|
||||
let wasForeground = false;
|
||||
let readInFlight = false;
|
||||
let lastClipboardKey: string | null = null;
|
||||
|
||||
const isForeground = () =>
|
||||
document.visibilityState === 'visible' &&
|
||||
(typeof document.hasFocus !== 'function' || document.hasFocus());
|
||||
|
||||
const readClipboardOnForeground = async () => {
|
||||
if (!active || readInFlight || !isForeground()) return;
|
||||
|
||||
const storeBeforeRead = useDownloadStore.getState();
|
||||
const requestVersionBeforeRead = storeBeforeRead.pendingAddRequestVersion;
|
||||
readInFlight = true;
|
||||
try {
|
||||
const clipboardUrls = await readClipboardDownloadUrls();
|
||||
if (!active) return;
|
||||
|
||||
const currentSettings = useSettingsStore.getState();
|
||||
const currentStore = useDownloadStore.getState();
|
||||
// A user action or extension handoff won while the native clipboard
|
||||
// read was pending. Let that newer Add-modal request win unchanged.
|
||||
if (
|
||||
!currentSettings.autoAddClipboardLinks ||
|
||||
currentSettings.showKeychainModal ||
|
||||
currentStore.pendingAddRequestVersion !== requestVersionBeforeRead
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const clipboardKey = [...clipboardUrls].sort().join('\n');
|
||||
if (clipboardKey === lastClipboardKey) return;
|
||||
lastClipboardKey = clipboardKey;
|
||||
if (clipboardUrls.length === 0) return;
|
||||
|
||||
const existingUrls = new Set(extractValidDownloadUrls(currentStore.pendingAddUrls));
|
||||
const newUrls = clipboardUrls.filter(url => !existingUrls.has(url));
|
||||
if (newUrls.length > 0) {
|
||||
currentStore.openAddModalWithUrls(newUrls.join('\n'));
|
||||
}
|
||||
} catch (error) {
|
||||
// Clipboard permissions are optional and this feature is explicitly
|
||||
// opt-in, so a read failure should not interrupt normal app use.
|
||||
console.warn('Automatic clipboard capture failed:', error);
|
||||
} finally {
|
||||
readInFlight = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleForegroundChange = () => {
|
||||
const foreground = isForeground();
|
||||
if (!foreground) {
|
||||
wasForeground = false;
|
||||
return;
|
||||
}
|
||||
if (!wasForeground) {
|
||||
wasForeground = true;
|
||||
void readClipboardOnForeground();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('focus', handleForegroundChange);
|
||||
window.addEventListener('blur', handleForegroundChange);
|
||||
document.addEventListener('visibilitychange', handleForegroundChange);
|
||||
handleForegroundChange();
|
||||
|
||||
return () => {
|
||||
active = false;
|
||||
window.removeEventListener('focus', handleForegroundChange);
|
||||
window.removeEventListener('blur', handleForegroundChange);
|
||||
document.removeEventListener('visibilitychange', handleForegroundChange);
|
||||
};
|
||||
}, [autoAddClipboardLinks, coreReady, showKeychainModal]);
|
||||
|
||||
useEffect(() => {
|
||||
const root = window.document.documentElement;
|
||||
@@ -584,66 +894,10 @@ function App() {
|
||||
}
|
||||
}, [theme]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!coreReady) return;
|
||||
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(() => invoke('set_extension_frontend_ready', { ready: true }))
|
||||
.catch(error => console.error('Failed to activate browser extension integration:', error));
|
||||
|
||||
return () => {
|
||||
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">
|
||||
<div className={`app-shell flex h-screen w-screen overflow-hidden text-text-primary ${
|
||||
hasWindowChrome ? 'app-shell--window-chrome' : ''
|
||||
}`}>
|
||||
{(platform.os === 'windows' || platform.os === 'linux') && <WindowControls />}
|
||||
<div
|
||||
className={`app-sidebar-shell relative z-20 shrink-0 transition-all duration-300 ease-[cubic-bezier(0.2,0.8,0.2,1)] ${
|
||||
@@ -708,7 +962,7 @@ function App() {
|
||||
<AddDownloadsModal />
|
||||
<PropertiesModal />
|
||||
<DeleteConfirmationModal />
|
||||
<KeychainPermissionModal />
|
||||
<KeychainPermissionModal consentVersion={keychainConsentVersion} />
|
||||
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
import { Component, ErrorInfo, ReactNode } from "react";
|
||||
|
||||
interface Props {
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
interface State {
|
||||
hasError: boolean;
|
||||
error: Error | null;
|
||||
errorInfo: ErrorInfo | null;
|
||||
}
|
||||
|
||||
export class ErrorBoundary extends Component<Props, State> {
|
||||
public state: State = {
|
||||
hasError: false,
|
||||
error: null,
|
||||
errorInfo: null
|
||||
};
|
||||
|
||||
public static getDerivedStateFromError(error: Error): State {
|
||||
return { hasError: true, error, errorInfo: null };
|
||||
}
|
||||
|
||||
public componentDidCatch(error: Error, errorInfo: ErrorInfo) {
|
||||
console.error("Uncaught error:", error, errorInfo);
|
||||
this.setState({ errorInfo });
|
||||
}
|
||||
|
||||
public render() {
|
||||
if (this.state.hasError) {
|
||||
return (
|
||||
<div className="flex h-screen w-screen items-center justify-center bg-main-bg p-8 text-text-primary">
|
||||
<div className="app-card max-w-lg space-y-4 p-6 text-center">
|
||||
<h1 className="text-xl font-semibold">Firelink could not display this window.</h1>
|
||||
<p className="text-sm text-text-secondary">
|
||||
The error was written to Logs. Reload the interface to reconnect to the running download service.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
className="app-button app-button-primary px-4"
|
||||
onClick={() => window.location.reload()}
|
||||
>
|
||||
Reload Firelink
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
@@ -2,4 +2,4 @@
|
||||
import type { DownloadCategory } from "./DownloadCategory";
|
||||
import type { DownloadStatus } from "./DownloadStatus";
|
||||
|
||||
export type DownloadItem = { id: string, url: string, fileName: string, status: DownloadStatus, fraction?: number, speed?: string, eta?: string, size?: string, category: DownloadCategory, dateAdded: string, resumable?: boolean, connections?: number, speedLimit?: string, username?: string, password?: string, headers?: string, checksum?: string, cookies?: string, mirrors?: string, destination?: string, isMedia?: boolean, mediaFormatSelector?: string, queueId?: string, queuePosition?: number, hasBeenDispatched?: boolean, lastError?: string, };
|
||||
export type DownloadItem = { id: string, url: string, fileName: string, status: DownloadStatus, fraction?: number, speed?: string, eta?: string, size?: string, downloadedBytes?: number, totalBytes?: number, totalIsEstimate?: boolean, category: DownloadCategory, dateAdded: string, resumable?: boolean, connections?: number, speedLimit?: string, username?: string, password?: string, headers?: string, checksum?: string, cookies?: string, mirrors?: string, destination?: string, isMedia?: boolean, mediaFormatSelector?: string, queueId?: string, queuePosition?: number, hasBeenDispatched?: boolean, lastError?: string, lastTry?: string, };
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
export type DownloadProgressEvent = { id: string, fraction: number, speed: string, eta: string, size: string | null, size_is_final: boolean, };
|
||||
export type DownloadProgressEvent = { id: string, fraction: number, speed: string, eta: string, size: string | null, size_is_final: boolean, downloaded_bytes?: number, total_bytes?: number, total_is_estimate?: boolean, };
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
export type EnqueueItem = { id: string, queue_id: string, url: string, destination: string, filename: string, connections: number | null, speed_limit: string | null, username: string | null, password: string | null, headers: string | null, checksum: string | null, cookies: string | null, mirrors: string | null, user_agent: string | null, max_tries: number | null, proxy: string | null, format_selector: string | null, cookie_source: string | null, is_media: boolean | null, };
|
||||
export type EnqueueItem = { id: string, queue_id: string, url: string, destination: string, filename: string, connections: number | null, speed_limit: string | null, username: string | null, password: string | null, headers: string | null, checksum: string | null, cookies: string | null, mirrors: string | null, user_agent: string | null, max_tries: number | null, proxy: string | null, format_selector: string | null, cookie_source: string | null, is_media: boolean | null, lifecycle_generation?: string, };
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
export type ExtensionCookieScope = { url: string, cookies: string, };
|
||||
@@ -1,3 +1,4 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
import type { ExtensionCookieScope } from "./ExtensionCookieScope";
|
||||
|
||||
export type ExtensionDownload = { urls: Array<string>, referer: string | null, silent: boolean, filename: string | null, headers: string | null, cookies: string | null, media: boolean, };
|
||||
export type ExtensionDownload = { request_id?: string, urls: Array<string>, referer: string | null, silent: boolean, filename: string | null, headers: string | null, cookies: string | null, cookie_scopes: Array<ExtensionCookieScope> | null, media: boolean, };
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
export type MediaPlaylistEntry = { id: string, url: string, title: string, playlist_index: number | null, };
|
||||
@@ -0,0 +1,4 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
import type { MediaPlaylistEntry } from "./MediaPlaylistEntry";
|
||||
|
||||
export type MediaPlaylistMetadata = { title: string, playlist_id: string | null, entry_count: number | null, skipped_entries: number, truncated: boolean, entries: Array<MediaPlaylistEntry>, };
|
||||
@@ -8,13 +8,4 @@ import type { SettingsTab } from "./SettingsTab";
|
||||
import type { SiteLogin } from "./SiteLogin";
|
||||
import type { Theme } from "./Theme";
|
||||
|
||||
export type PersistedSettings = { theme: Theme, baseDownloadFolder: string, categorySubfoldersEnabled: boolean, categorySubfolders: { [key in string]: string }, categoryDirectoryOverrides: { [key in string]: string }, approvedDownloadRoots: Array<string>, maxConcurrentDownloads: number, globalSpeedLimit: string, speedLimitPresetValues: Array<number>, logsEnabled: boolean, isSidebarVisible: boolean, activeSettingsTab: SettingsTab, scheduler: SchedulerSettings, schedulerRunning: boolean, schedulerActiveDownloadIds: Array<string>, schedulerLastStartKey: string, schedulerLastStopKey: string, lastCustomSpeedLimitKiB: number, perServerConnections: number, maxAutomaticRetries: number, showNotifications: boolean, playCompletionSound: boolean, appFontSize: AppFontSize, listRowDensity: ListRowDensity, showDockBadge: boolean, showMenuBarIcon: boolean, proxyMode: ProxyMode, proxyHost: string, proxyPort: number, customUserAgent: string, askWhereToSaveEachFile: boolean, preventsSleepWhileDownloading: boolean, mediaCookieSource: MediaCookieSource, siteLogins: Array<SiteLogin>,
|
||||
/**
|
||||
* HMAC shared secret for the browser extension. It is persisted in the
|
||||
* settings database so that startup never needs to touch the OS keychain.
|
||||
* The keychain is still used as defence-in-depth — grant_keychain_access
|
||||
* writes the token there — but the DB copy is the primary read path,
|
||||
* eliminating the OS credential prompt that macOS shows when the binary
|
||||
* signature changes after an update.
|
||||
*/
|
||||
extensionPairingToken: string, autoCheckUpdates: boolean, keychainAccessGranted: boolean, };
|
||||
export type PersistedSettings = { theme: Theme, baseDownloadFolder: string, categorySubfoldersEnabled: boolean, categorySubfolders: { [key in string]: string }, categoryDirectoryOverrides: { [key in string]: string }, approvedDownloadRoots: Array<string>, maxConcurrentDownloads: number, globalSpeedLimit: string, speedLimitPresetValues: Array<number>, logsEnabled: boolean, isSidebarVisible: boolean, activeSettingsTab: SettingsTab, scheduler: SchedulerSettings, schedulerRunning: boolean, schedulerActiveDownloadIds: Array<string>, schedulerLastStartKey: string, schedulerLastStopKey: string, lastCustomSpeedLimitKiB: number, lastCustomSpeedLimitUnit: string, perServerConnections: number, maxAutomaticRetries: number, showNotifications: boolean, playCompletionSound: boolean, autoAddClipboardLinks: boolean, appFontSize: AppFontSize, listRowDensity: ListRowDensity, showDockBadge: boolean, showMenuBarIcon: boolean, proxyMode: ProxyMode, proxyHost: string, proxyPort: number, customUserAgent: string, askWhereToSaveEachFile: boolean, preventsSleepWhileDownloading: boolean, mediaCookieSource: MediaCookieSource, siteLogins: Array<SiteLogin>, autoCheckUpdates: boolean, keychainAccessGranted: boolean, };
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
export type PlatformInfo = { os: string, arch: string, targetTriple: string, };
|
||||
export type PlatformInfo = { os: string, arch: string, targetTriple: string, portable: boolean, };
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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,9 +62,16 @@ export const DeleteConfirmationModal: React.FC = () => {
|
||||
const handleDeleteFile = () => removeMany(true);
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50 animate-fade-in">
|
||||
<div
|
||||
className="app-modal-backdrop fixed inset-0 z-50 flex items-center justify-center bg-black/40"
|
||||
onClick={(event) => {
|
||||
if (event.target === event.currentTarget && !isRemoving) handleCancel();
|
||||
}}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
>
|
||||
<div
|
||||
className="bg-bg-modal rounded-xl w-full max-w-md overflow-hidden flex flex-col shadow-2xl border border-border-modal scale-in"
|
||||
className="window-safe-modal bg-bg-modal rounded-xl w-full max-w-md overflow-hidden flex flex-col shadow-2xl border border-border-modal scale-in"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="px-5 py-4 border-b border-border-modal flex items-center gap-3">
|
||||
@@ -74,7 +90,7 @@ export const DeleteConfirmationModal: React.FC = () => {
|
||||
<button
|
||||
onClick={handleCancel}
|
||||
disabled={isRemoving}
|
||||
className="px-4 py-2 rounded-lg text-sm font-medium transition-colors text-text-secondary hover:bg-item-hover hover:text-text-primary disabled:opacity-50"
|
||||
className="app-button px-4 py-2 rounded-lg text-sm font-medium disabled:opacity-50"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
|
||||
@@ -1,54 +1,44 @@
|
||||
import React from 'react';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
import { useDownloadStore } from '../store/useDownloadStore';
|
||||
import { useDownloadProgressStore } from '../store/downloadStore';
|
||||
import { useDownloadProgressStore } from '../store/downloadProgressStore';
|
||||
import { Play, Pause, MoreVertical, Clock, ArrowUp, ArrowDown } from 'lucide-react';
|
||||
import type { DownloadItem as DownloadItemType } from '../bindings/DownloadItem';
|
||||
import { canPauseDownload, canStartDownload, startActionLabel } from '../utils/downloadActions';
|
||||
import { isActiveDownloadStatus } from '../utils/downloads';
|
||||
import {
|
||||
downloadProgressColorClass,
|
||||
formatDownloadTotal,
|
||||
resolveDownloadSizeDisplay
|
||||
} from '../utils/downloadProgress';
|
||||
|
||||
interface DownloadItemProps {
|
||||
downloadId: string;
|
||||
download: DownloadItemType;
|
||||
index: number;
|
||||
queueIndex: number;
|
||||
queueLength: number;
|
||||
tableGridTemplate: string;
|
||||
setContextMenu: (menu: { x: number; y: number; id: string }) => void;
|
||||
handlePause: (id: string, skipConfirm?: boolean) => void;
|
||||
handleResume: (item: DownloadItemType) => void;
|
||||
getCategoryIcon: (category: string) => React.ReactNode;
|
||||
selectedIds: Set<string>;
|
||||
isSelected: boolean;
|
||||
onMoveInQueue: (id: string, direction: 'up' | 'down') => void;
|
||||
onClick: (e: React.MouseEvent, item: DownloadItemType) => void;
|
||||
}
|
||||
|
||||
export const DownloadItem = React.memo<DownloadItemProps>(({
|
||||
downloadId,
|
||||
download,
|
||||
index,
|
||||
queueIndex,
|
||||
queueLength,
|
||||
tableGridTemplate,
|
||||
setContextMenu,
|
||||
handlePause,
|
||||
handleResume,
|
||||
getCategoryIcon,
|
||||
selectedIds,
|
||||
isSelected,
|
||||
onMoveInQueue,
|
||||
onClick,
|
||||
}) => {
|
||||
const download = useDownloadStore(state => state.downloads.find(d => d.id === downloadId));
|
||||
const queueItems = useDownloadStore(useShallow(state => {
|
||||
const item = state.downloads.find(candidate => candidate.id === downloadId);
|
||||
if (!item) return [];
|
||||
const queueId = item.queueId;
|
||||
return state.downloads
|
||||
.filter(candidate =>
|
||||
candidate.queueId === queueId &&
|
||||
candidate.status !== 'completed' &&
|
||||
!(isActiveDownloadStatus(candidate.status) && candidate.status !== 'queued')
|
||||
)
|
||||
.sort((left, right) => (left.queuePosition ?? 0) - (right.queuePosition ?? 0))
|
||||
.map(candidate => candidate.id);
|
||||
}));
|
||||
const moveInQueue = useDownloadStore(state => state.moveInQueue);
|
||||
const liveProgress = useDownloadProgressStore(state => state.progressMap[downloadId]);
|
||||
const queueIndex = queueItems.indexOf(downloadId);
|
||||
|
||||
if (!download) return null;
|
||||
const liveProgress = useDownloadProgressStore(state => state.progressMap[download.id]);
|
||||
|
||||
const displayFraction = download.status === 'downloading'
|
||||
? liveProgress?.fraction ?? download.fraction ?? 0
|
||||
@@ -64,10 +54,21 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
|
||||
: download.status === 'processing'
|
||||
? 'Muxing...'
|
||||
: '-';
|
||||
const sizeDisplay = resolveDownloadSizeDisplay({
|
||||
downloadedBytes: liveProgress?.downloaded_bytes ?? download.downloadedBytes,
|
||||
totalBytes: liveProgress?.total_bytes ?? download.totalBytes,
|
||||
totalIsEstimate: liveProgress?.total_is_estimate ?? download.totalIsEstimate,
|
||||
fallbackSize: download.size
|
||||
});
|
||||
const hasDownloadedAmount = download.status !== 'completed' &&
|
||||
Boolean(sizeDisplay.downloaded && sizeDisplay.total);
|
||||
const completedSizeLabel = download.status === 'completed'
|
||||
? formatDownloadTotal(sizeDisplay)
|
||||
: sizeDisplay.fallback;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`download-row group cursor-default relative ${index % 2 !== 0 ? 'striped' : ''} ${selectedIds.has(downloadId) ? 'is-selected' : ''}`}
|
||||
className={`download-row group cursor-default relative ${index % 2 !== 0 ? 'striped' : ''} ${isSelected ? 'is-selected' : ''}`}
|
||||
style={{ gridTemplateColumns: tableGridTemplate }}
|
||||
onClick={(e) => onClick(e, download)}
|
||||
onContextMenu={(e) => {
|
||||
@@ -84,9 +85,25 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="download-cell-truncate">
|
||||
<span className="tabular-nums" title={download.size && download.size !== '-' ? download.size : 'Unknown'}>
|
||||
{download.size && download.size !== '-' ? download.size : 'Unknown'}
|
||||
<div
|
||||
className="download-cell-truncate download-size-cell tabular-nums"
|
||||
title={hasDownloadedAmount
|
||||
? `${sizeDisplay.downloaded} downloaded of ${sizeDisplay.totalIsEstimate ? '~' : ''}${sizeDisplay.total} ${sizeDisplay.unit}`
|
||||
: completedSizeLabel}
|
||||
aria-label={hasDownloadedAmount
|
||||
? `${sizeDisplay.downloaded} downloaded of ${sizeDisplay.totalIsEstimate ? 'approximately ' : ''}${sizeDisplay.total} ${sizeDisplay.unit}`
|
||||
: completedSizeLabel}
|
||||
>
|
||||
{hasDownloadedAmount ? (
|
||||
<span className="download-size-progress">
|
||||
<span className={downloadProgressColorClass(download.status)}>{sizeDisplay.downloaded}</span>
|
||||
<span className="text-text-muted"> / </span>
|
||||
</span>
|
||||
) : null}
|
||||
<span className="download-size-total">
|
||||
{hasDownloadedAmount
|
||||
? `${sizeDisplay.totalIsEstimate ? '~' : ''}${sizeDisplay.total} ${sizeDisplay.unit}`
|
||||
: completedSizeLabel}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -106,8 +123,7 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
|
||||
style={{ width: `${displayFraction * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span
|
||||
key={`status-${download.status}`}
|
||||
<span
|
||||
title={
|
||||
download.lastError && (download.status === 'failed' || download.status === 'retrying')
|
||||
? download.lastError
|
||||
@@ -149,7 +165,6 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
|
||||
|
||||
<div className="download-cell-truncate">
|
||||
<span
|
||||
key={`speed-${download.status}`}
|
||||
className="tabular-nums"
|
||||
title={displaySpeed}
|
||||
>
|
||||
@@ -159,7 +174,6 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
|
||||
|
||||
<div className="download-cell-truncate">
|
||||
<span
|
||||
key={`eta-${download.status}`}
|
||||
className="tabular-nums"
|
||||
title={displayEta}
|
||||
>
|
||||
@@ -177,12 +191,13 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
|
||||
|
||||
<div
|
||||
className="hidden group-hover:flex items-center justify-end gap-0.5 w-full ml-auto"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onDoubleClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{(download.status === 'queued' || download.status === 'staged') && queueIndex !== -1 && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => moveInQueue(selectedIds.has(download.id) ? Array.from(selectedIds) : download.id, 'up')}
|
||||
onClick={() => onMoveInQueue(download.id, 'up')}
|
||||
disabled={queueIndex === 0}
|
||||
className="app-icon-button h-7 w-7 disabled:opacity-40"
|
||||
title="Move Up"
|
||||
@@ -190,8 +205,8 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
|
||||
<ArrowUp size={14} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => moveInQueue(selectedIds.has(download.id) ? Array.from(selectedIds) : download.id, 'down')}
|
||||
disabled={queueIndex === queueItems.length - 1}
|
||||
onClick={() => onMoveInQueue(download.id, 'down')}
|
||||
disabled={queueIndex === queueLength - 1}
|
||||
className="app-icon-button h-7 w-7 disabled:opacity-40"
|
||||
title="Move Down"
|
||||
>
|
||||
|
||||
+268
-160
@@ -1,5 +1,5 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useDownloadStore, DownloadItem } from '../store/useDownloadStore';
|
||||
import React, { useState, useEffect, useMemo, useRef, useCallback } from 'react';
|
||||
import { useDownloadStore, DownloadItem, MAIN_QUEUE_ID } from '../store/useDownloadStore';
|
||||
import { useToast } from '../contexts/ToastContext';
|
||||
import { useSettingsStore } from '../store/useSettingsStore';
|
||||
import { SidebarFilter } from './Sidebar';
|
||||
@@ -17,7 +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;
|
||||
@@ -27,15 +33,31 @@ const DEFAULT_COLUMN_WIDTHS = [340, 100, 220, 100, 80, 170];
|
||||
const COLUMN_WIDTHS_STORAGE_KEY = 'firelink-download-column-widths';
|
||||
|
||||
export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
|
||||
const { downloads, queues, assignToQueue, toggleAddModal, openDeleteModal, redownload } = useDownloadStore();
|
||||
const { downloads, queues, assignToQueue, openDeleteModal, redownload, moveInQueue } = useDownloadStore();
|
||||
const { addToast } = useToast();
|
||||
const isMac = navigator.userAgent.includes('Mac');
|
||||
const [isReadingClipboard, setIsReadingClipboard] = useState(false);
|
||||
const clipboardReadInFlightRef = useRef(false);
|
||||
const isMountedRef = useRef(true);
|
||||
|
||||
useEffect(() => {
|
||||
isMountedRef.current = true;
|
||||
return () => {
|
||||
isMountedRef.current = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const [contextMenu, setContextMenu] = useState<{ x: number; y: number; id: string } | null>(null);
|
||||
const [animationParent] = useAutoAnimate<HTMLDivElement>();
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
||||
const [lastSelectedId, setLastSelectedId] = useState<string | null>(null);
|
||||
const [sortConfig, setSortConfig] = useState<{ column: string; direction: 'asc' | 'desc' } | null>({ column: 'Date Added', direction: 'desc' });
|
||||
const [sortConfig, setSortConfig] = useState<DownloadSortConfig>({ column: 'Date Added', direction: 'desc' });
|
||||
const [queueSortConfig, setQueueSortConfig] = useState<DownloadSortConfig | null>(null);
|
||||
const selectedIdsRef = useRef(selectedIds);
|
||||
const lastSelectedIdRef = useRef(lastSelectedId);
|
||||
const sortedDownloadsRef = useRef<DownloadItem[]>([]);
|
||||
selectedIdsRef.current = selectedIds;
|
||||
lastSelectedIdRef.current = lastSelectedId;
|
||||
const [columnWidths, setColumnWidths] = useState(() => {
|
||||
try {
|
||||
const stored = JSON.parse(window.localStorage.getItem(COLUMN_WIDTHS_STORAGE_KEY) || 'null');
|
||||
@@ -99,15 +121,15 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
|
||||
if (!isInput) {
|
||||
if ((e.key === 'a' || e.key === 'A') && (e.metaKey || e.ctrlKey)) {
|
||||
e.preventDefault();
|
||||
const allIds = sortedDownloads.map(d => d.id);
|
||||
const allIds = sortedDownloadsRef.current.map(d => d.id);
|
||||
setSelectedIds(new Set(allIds));
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.key === 'Delete' || e.key === 'Backspace') {
|
||||
if (!activeEl || !activeEl.closest('.sidebar-inner')) {
|
||||
if (selectedIds.size > 0) {
|
||||
handleDelete(Array.from(selectedIds));
|
||||
if (selectedIdsRef.current.size > 0) {
|
||||
handleDelete(Array.from(selectedIdsRef.current));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -115,28 +137,28 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
|
||||
};
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [selectedIds]);
|
||||
}, []);
|
||||
|
||||
|
||||
const showInteractionError = (message: string, error: unknown) => {
|
||||
const showInteractionError = useCallback((message: string, error: unknown) => {
|
||||
const detail = error instanceof Error ? error.message : String(error);
|
||||
addToast({ message: `${message}: ${detail}`, variant: 'error', isActionable: true });
|
||||
};
|
||||
}, [addToast]);
|
||||
|
||||
const getDownloadPath = async (item: DownloadItem) => {
|
||||
const getDownloadPath = useCallback(async (item: DownloadItem) => {
|
||||
const fileName = item.fileName?.trim();
|
||||
if (!fileName) return null;
|
||||
const settings = useSettingsStore.getState();
|
||||
const destination = item.destination ||
|
||||
await resolveCategoryDestination(settings, item.category);
|
||||
return resolveDownloadFilePath(destination, fileName);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const openProperties = (id: string) => {
|
||||
const openProperties = useCallback((id: string) => {
|
||||
useDownloadStore.getState().setSelectedPropertiesDownloadId(id);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const openDownloadFile = async (item: DownloadItem) => {
|
||||
const openDownloadFile = useCallback(async (item: DownloadItem) => {
|
||||
if (item.status !== 'completed') {
|
||||
openProperties(item.id);
|
||||
return;
|
||||
@@ -154,7 +176,7 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
|
||||
console.error("Failed to open file:", error);
|
||||
showInteractionError('Could not open downloaded file', error);
|
||||
}
|
||||
};
|
||||
}, [getDownloadPath, openProperties, showInteractionError]);
|
||||
|
||||
const revealDownloadFile = async (item: DownloadItem) => {
|
||||
const pathToReveal = await getDownloadPath(item);
|
||||
@@ -172,52 +194,33 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleDownloadDoubleClick = (item: DownloadItem) => {
|
||||
const handleDownloadDoubleClick = useCallback((item: DownloadItem) => {
|
||||
if (item.status === 'completed') {
|
||||
void openDownloadFile(item);
|
||||
return;
|
||||
}
|
||||
|
||||
openProperties(item.id);
|
||||
};
|
||||
}, [openDownloadFile, openProperties]);
|
||||
|
||||
const filteredDownloads = downloads.filter((d: DownloadItem) => {
|
||||
if (filter.startsWith('queue:')) {
|
||||
const isQueueFilter = filter.startsWith('queue:');
|
||||
const filteredDownloads = useMemo(() => downloads.filter((d: DownloadItem) => {
|
||||
if (isQueueFilter) {
|
||||
return d.queueId === filter.replace('queue:', '') && d.status !== 'completed';
|
||||
}
|
||||
switch (filter) {
|
||||
case 'all': return true;
|
||||
case 'active': return d.status === 'downloading';
|
||||
case 'active': return isTransferActiveStatus(d.status);
|
||||
case 'completed': return d.status === 'completed';
|
||||
case 'unfinished': return d.status !== 'completed';
|
||||
default: return d.category === filter;
|
||||
}
|
||||
});
|
||||
}), [downloads, filter, isQueueFilter]);
|
||||
|
||||
const parseSpeed = (speedStr?: string) => {
|
||||
if (!speedStr || speedStr === '-') return 0;
|
||||
const val = parseFloat(speedStr);
|
||||
if (speedStr.includes('KB/s')) return val * 1024;
|
||||
if (speedStr.includes('MB/s')) return val * 1024 * 1024;
|
||||
if (speedStr.includes('GB/s')) return val * 1024 * 1024 * 1024;
|
||||
return val;
|
||||
};
|
||||
|
||||
const parseEta = (etaStr?: string) => {
|
||||
if (!etaStr || etaStr === '-') return Infinity;
|
||||
let seconds = 0;
|
||||
const hours = etaStr.match(/(\d+)h/);
|
||||
const minutes = etaStr.match(/(\d+)m/);
|
||||
const secs = etaStr.match(/(\d+)s/);
|
||||
if (hours) seconds += parseInt(hours[1]) * 3600;
|
||||
if (minutes) seconds += parseInt(minutes[1]) * 60;
|
||||
if (secs) seconds += parseInt(secs[1]);
|
||||
return seconds;
|
||||
};
|
||||
|
||||
// Sort by queue position when viewing a specific queue so the visual
|
||||
// order matches the queue order and move-up/down buttons reflect reality.
|
||||
const sortedDownloads = filter.startsWith('queue:')
|
||||
// Queue views use the persisted queue order until the user explicitly sorts
|
||||
// a column. This keeps move-up/down controls truthful while still making
|
||||
// every header a working sort target.
|
||||
const sortedDownloads = useMemo(() => isQueueFilter && !queueSortConfig
|
||||
? [...filteredDownloads].sort((left, right) => {
|
||||
const leftActive = isActiveDownloadStatus(left.status) && left.status !== 'queued';
|
||||
const rightActive = isActiveDownloadStatus(right.status) && right.status !== 'queued';
|
||||
@@ -225,61 +228,83 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
|
||||
if (leftActive && !rightActive) return -1;
|
||||
if (!leftActive && rightActive) return 1;
|
||||
|
||||
return (left.queuePosition ?? 0) - (right.queuePosition ?? 0);
|
||||
const positionComparison = (left.queuePosition ?? Number.MAX_SAFE_INTEGER) -
|
||||
(right.queuePosition ?? Number.MAX_SAFE_INTEGER);
|
||||
return positionComparison || left.id.localeCompare(right.id);
|
||||
})
|
||||
: sortConfig
|
||||
? [...filteredDownloads].sort((a, b) => {
|
||||
const aUnfinished = a.status !== 'completed';
|
||||
const bUnfinished = b.status !== 'completed';
|
||||
: sortDownloads(filteredDownloads, isQueueFilter ? queueSortConfig! : sortConfig),
|
||||
[filteredDownloads, isQueueFilter, queueSortConfig, sortConfig]);
|
||||
|
||||
if (aUnfinished && !bUnfinished) return -1;
|
||||
if (!aUnfinished && bUnfinished) return 1;
|
||||
// Each row used to derive this by filtering and sorting the complete store
|
||||
// independently. That made a 1000-entry playlist perform O(n^2 log n) work
|
||||
// on every download update. Compute the same queue membership once and pass
|
||||
// the resulting position to rows instead.
|
||||
const queuePositionsByDownloadId = useMemo(() => {
|
||||
const grouped = new Map<string, DownloadItem[]>();
|
||||
for (const download of downloads) {
|
||||
if (
|
||||
download.status === 'completed'
|
||||
|| (isActiveDownloadStatus(download.status) && download.status !== 'queued')
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const queueId = download.queueId || MAIN_QUEUE_ID;
|
||||
const queueItems = grouped.get(queueId) || [];
|
||||
queueItems.push(download);
|
||||
grouped.set(queueId, queueItems);
|
||||
}
|
||||
|
||||
let comparison = 0;
|
||||
switch (sortConfig.column) {
|
||||
case 'File Name':
|
||||
comparison = (a.fileName || a.url || '').localeCompare(b.fileName || b.url || '');
|
||||
break;
|
||||
case 'Size':
|
||||
comparison = parseInt(a.size || '0', 10) - parseInt(b.size || '0', 10);
|
||||
break;
|
||||
case 'Status':
|
||||
comparison = a.status.localeCompare(b.status);
|
||||
break;
|
||||
case 'Speed':
|
||||
comparison = parseSpeed(a.speed) - parseSpeed(b.speed);
|
||||
break;
|
||||
case 'ETA':
|
||||
comparison = parseEta(a.eta) - parseEta(b.eta);
|
||||
break;
|
||||
case 'Date Added':
|
||||
comparison = new Date(a.dateAdded || 0).getTime() - new Date(b.dateAdded || 0).getTime();
|
||||
break;
|
||||
}
|
||||
return sortConfig.direction === 'asc' ? comparison : -comparison;
|
||||
})
|
||||
: filteredDownloads;
|
||||
const handleItemClick = (e: React.MouseEvent, item: DownloadItem) => {
|
||||
const positions = new Map<string, { index: number; length: number }>();
|
||||
for (const queueItems of grouped.values()) {
|
||||
queueItems.sort((left, right) =>
|
||||
(left.queuePosition ?? Number.MAX_SAFE_INTEGER) -
|
||||
(right.queuePosition ?? Number.MAX_SAFE_INTEGER) ||
|
||||
left.id.localeCompare(right.id)
|
||||
);
|
||||
queueItems.forEach((download, index) => {
|
||||
positions.set(download.id, { index, length: queueItems.length });
|
||||
});
|
||||
}
|
||||
return positions;
|
||||
}, [downloads]);
|
||||
sortedDownloadsRef.current = sortedDownloads;
|
||||
|
||||
useEffect(() => {
|
||||
const visibleIds = new Set(sortedDownloads.map(download => download.id));
|
||||
setSelectedIds(current => {
|
||||
const next = new Set(Array.from(current).filter(id => visibleIds.has(id)));
|
||||
return next.size === current.size ? current : next;
|
||||
});
|
||||
setLastSelectedId(current => current && visibleIds.has(current) ? current : null);
|
||||
}, [sortedDownloads]);
|
||||
|
||||
useEffect(() => {
|
||||
setQueueSortConfig(null);
|
||||
}, [filter, isQueueFilter]);
|
||||
const handleItemClick = useCallback((e: React.MouseEvent, item: DownloadItem) => {
|
||||
if (e.detail === 2) {
|
||||
handleDownloadDoubleClick(item);
|
||||
return;
|
||||
}
|
||||
if (e.shiftKey && lastSelectedId) {
|
||||
const currentIndex = sortedDownloads.findIndex(d => d.id === item.id);
|
||||
const lastIndex = sortedDownloads.findIndex(d => d.id === lastSelectedId);
|
||||
const currentSortedDownloads = sortedDownloadsRef.current;
|
||||
const currentSelectedIds = selectedIdsRef.current;
|
||||
const currentLastSelectedId = lastSelectedIdRef.current;
|
||||
if (e.shiftKey && currentLastSelectedId) {
|
||||
const currentIndex = currentSortedDownloads.findIndex(d => d.id === item.id);
|
||||
const lastIndex = currentSortedDownloads.findIndex(d => d.id === currentLastSelectedId);
|
||||
|
||||
if (currentIndex !== -1 && lastIndex !== -1) {
|
||||
const start = Math.min(currentIndex, lastIndex);
|
||||
const end = Math.max(currentIndex, lastIndex);
|
||||
|
||||
const newSelected = (e.metaKey || e.ctrlKey) ? new Set(selectedIds) : new Set<string>();
|
||||
const newSelected = (e.metaKey || e.ctrlKey) ? new Set(currentSelectedIds) : new Set<string>();
|
||||
for (let i = start; i <= end; i++) {
|
||||
newSelected.add(sortedDownloads[i].id);
|
||||
newSelected.add(currentSortedDownloads[i].id);
|
||||
}
|
||||
setSelectedIds(newSelected);
|
||||
}
|
||||
} else if (e.metaKey || e.ctrlKey) {
|
||||
const newSelected = new Set(selectedIds);
|
||||
const newSelected = new Set(currentSelectedIds);
|
||||
if (newSelected.has(item.id)) {
|
||||
newSelected.delete(item.id);
|
||||
} else {
|
||||
@@ -291,25 +316,34 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
|
||||
setSelectedIds(new Set([item.id]));
|
||||
setLastSelectedId(item.id);
|
||||
}
|
||||
};
|
||||
}, [handleDownloadDoubleClick]);
|
||||
|
||||
const handleContextMenu = (menu: { x: number; y: number; id: string }) => {
|
||||
if (!selectedIds.has(menu.id)) {
|
||||
const handleContextMenu = useCallback((menu: { x: number; y: number; id: string }) => {
|
||||
if (!selectedIdsRef.current.has(menu.id)) {
|
||||
setSelectedIds(new Set([menu.id]));
|
||||
setLastSelectedId(menu.id);
|
||||
}
|
||||
setContextMenu(menu);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleSort = (column: string) => {
|
||||
if (filter.startsWith('queue:')) return; // Disable custom sorting in queues
|
||||
setSortConfig(current => {
|
||||
if (current?.column === column) {
|
||||
if (current.direction === 'desc') return null; // Reset sort
|
||||
return { column, direction: 'desc' };
|
||||
}
|
||||
return { column, direction: 'asc' };
|
||||
});
|
||||
const handleMoveInQueue = useCallback((id: string, direction: 'up' | 'down') => {
|
||||
const ids = selectedIdsRef.current.has(id)
|
||||
? Array.from(selectedIdsRef.current)
|
||||
: id;
|
||||
void moveInQueue(ids, direction);
|
||||
}, [moveInQueue]);
|
||||
|
||||
const handleSort = (column: DownloadSortColumn) => {
|
||||
const update = (current: DownloadSortConfig | null): DownloadSortConfig =>
|
||||
current?.column === column
|
||||
? { column, direction: current.direction === 'asc' ? 'desc' : 'asc' }
|
||||
: { column, direction: 'asc' };
|
||||
|
||||
if (isQueueFilter) {
|
||||
setQueueSortConfig(update);
|
||||
} else {
|
||||
setSortConfig(current => update(current));
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -328,7 +362,7 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
|
||||
}
|
||||
};
|
||||
|
||||
const handlePause = async (id: string, skipConfirm = false) => {
|
||||
const handlePause = useCallback(async (id: string, skipConfirm = false) => {
|
||||
const download = useDownloadStore.getState().downloads.find(d => d.id === id);
|
||||
if (!skipConfirm && download && download.resumable === false) {
|
||||
const confirmPause = window.confirm("This download does not support resuming. If you pause it, you will have to start over again later. Are you sure you want to pause?");
|
||||
@@ -338,15 +372,32 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
|
||||
}
|
||||
|
||||
try {
|
||||
await invoke('pause_download', { id });
|
||||
await useDownloadStore.getState().pauseDownload(id);
|
||||
} catch (e) {
|
||||
console.error("Failed to pause:", e);
|
||||
showInteractionError('Could not pause download', e);
|
||||
}
|
||||
};
|
||||
}, [showInteractionError]);
|
||||
|
||||
const handleResume = (item: DownloadItem) => {
|
||||
useDownloadStore.getState().resumeDownload(item.id);
|
||||
const handleResume = useCallback(async (item: DownloadItem) => {
|
||||
try {
|
||||
const resumed = await useDownloadStore.getState().resumeDownload(item.id);
|
||||
if (!resumed) {
|
||||
throw new Error('The backend rejected the start/resume request.');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to resume:", error);
|
||||
showInteractionError(`Could not resume ${item.fileName}`, error);
|
||||
}
|
||||
}, [showInteractionError]);
|
||||
|
||||
const resumeItemsSequentially = async (items: DownloadItem[]) => {
|
||||
for (const item of items) {
|
||||
const current = useDownloadStore.getState().downloads.find(download => download.id === item.id);
|
||||
if (current && canStartDownload(current.status)) {
|
||||
await handleResume(current);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = (ids: string | string[]) => {
|
||||
@@ -355,8 +406,54 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
|
||||
|
||||
const contextItem = contextMenu ? downloads.find(d => d.id === contextMenu.id) : null;
|
||||
|
||||
const handleAddDownload = async () => {
|
||||
if (clipboardReadInFlightRef.current) return;
|
||||
|
||||
const getCategoryIcon = (category: string) => {
|
||||
clipboardReadInFlightRef.current = true;
|
||||
setIsReadingClipboard(true);
|
||||
const store = useDownloadStore.getState();
|
||||
const initialModalState = {
|
||||
isOpen: store.isAddModalOpen,
|
||||
requestVersion: store.pendingAddRequestVersion,
|
||||
};
|
||||
|
||||
try {
|
||||
const urls = await readClipboardDownloadUrls();
|
||||
if (!isMountedRef.current) return;
|
||||
const currentStore = useDownloadStore.getState();
|
||||
|
||||
// Do not append a late clipboard result to a newer extension, deep-link,
|
||||
// paste, or modal request that arrived while the OS clipboard was read.
|
||||
if (
|
||||
currentStore.isAddModalOpen !== initialModalState.isOpen ||
|
||||
currentStore.pendingAddRequestVersion !== initialModalState.requestVersion
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (urls.length > 0) {
|
||||
currentStore.openAddModalWithUrls(urls.join('\n'));
|
||||
} else {
|
||||
currentStore.toggleAddModal(true);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Could not read clipboard for Add Download:', error);
|
||||
if (!isMountedRef.current) return;
|
||||
const currentStore = useDownloadStore.getState();
|
||||
if (
|
||||
currentStore.isAddModalOpen === initialModalState.isOpen &&
|
||||
currentStore.pendingAddRequestVersion === initialModalState.requestVersion
|
||||
) {
|
||||
currentStore.toggleAddModal(true);
|
||||
}
|
||||
} finally {
|
||||
clipboardReadInFlightRef.current = false;
|
||||
if (isMountedRef.current) setIsReadingClipboard(false);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
const getCategoryIcon = useCallback((category: string) => {
|
||||
switch(category) {
|
||||
case 'Musics': return <Music size={16} className="text-pink-400" />;
|
||||
case 'Movies': return <Film size={16} className="text-red-400" />;
|
||||
@@ -367,7 +464,7 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
|
||||
case 'Other': return <FileQuestion size={16} className="text-gray-400" />;
|
||||
default: return <FileQuestion size={16} className="text-gray-400" />;
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="downloads-view flex-1 flex flex-col h-full min-w-0">
|
||||
@@ -378,7 +475,13 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
|
||||
<div className="main-titlebar-title cursor-default" data-tauri-drag-region>Firelink</div>
|
||||
|
||||
<div className="main-control-group">
|
||||
<button className="main-control-button primary" onClick={() => toggleAddModal(true)} title="Add Download">
|
||||
<button
|
||||
className="main-control-button primary"
|
||||
onClick={() => void handleAddDownload()}
|
||||
disabled={isReadingClipboard}
|
||||
aria-busy={isReadingClipboard}
|
||||
title="Add Download"
|
||||
>
|
||||
<Plus size={16} />
|
||||
</button>
|
||||
|
||||
@@ -386,9 +489,7 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
|
||||
className="main-control-button"
|
||||
disabled={sortedDownloads.length === 0}
|
||||
onClick={() => {
|
||||
sortedDownloads
|
||||
.filter(d => canStartDownload(d.status))
|
||||
.forEach(d => handleResume(d));
|
||||
void resumeItemsSequentially(sortedDownloads.filter(d => canStartDownload(d.status)));
|
||||
}}
|
||||
title="Resume All"
|
||||
>
|
||||
@@ -434,13 +535,15 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
|
||||
{['File Name', 'Size', 'Status', 'Speed', 'ETA', 'Date Added'].map((label, index) => (
|
||||
<div
|
||||
key={label}
|
||||
className={`${index === 5 ? 'download-cell-right' : ''} ${filter.startsWith('queue:') ? 'opacity-70 cursor-default' : 'cursor-pointer hover:text-text-primary transition-colors'} flex items-center justify-between`}
|
||||
onClick={() => handleSort(label)}
|
||||
className={`${index === 5 ? 'download-cell-right' : ''} cursor-pointer hover:text-text-primary transition-colors flex items-center justify-between`}
|
||||
onClick={() => handleSort(label as DownloadSortColumn)}
|
||||
>
|
||||
<div className="flex items-center gap-1 w-full h-full select-none">
|
||||
<div className={`flex items-center gap-1 w-full h-full select-none ${index === 1 ? 'justify-end' : ''}`}>
|
||||
<span>{label}</span>
|
||||
{sortConfig?.column === label && (
|
||||
sortConfig.direction === 'asc' ? <ChevronUp size={14} /> : <ChevronDown size={14} />
|
||||
{(isQueueFilter ? queueSortConfig : sortConfig)?.column === label && (
|
||||
(isQueueFilter ? queueSortConfig : sortConfig)?.direction === 'asc'
|
||||
? <ChevronUp size={14} />
|
||||
: <ChevronDown size={14} />
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
@@ -456,19 +559,29 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
|
||||
{sortedDownloads.length === 0 ? (
|
||||
<div className="downloads-empty-state">
|
||||
<ArrowDownCircle aria-hidden="true" />
|
||||
<div className="downloads-empty-title">No Downloads</div>
|
||||
<div className="downloads-empty-title">
|
||||
{isQueueFilter ? 'Queue is empty' : filter === 'completed' ? 'No Completed Downloads' : 'No Downloads'}
|
||||
</div>
|
||||
<div className="downloads-empty-description flex items-center justify-center mt-2.5 text-[13px] text-text-muted">
|
||||
Click <Plus size={15} className="text-accent stroke-[3] mx-1.5" /> button or
|
||||
<span className="flex items-center mx-1.5">
|
||||
<span className="flex items-center justify-center px-1.5 py-0.5 bg-item-hover rounded border border-border-color shadow-sm min-w-[22px] min-h-[22px]">
|
||||
{isMac ? <Command size={12} strokeWidth={2.5} className="text-text-primary" /> : <span className="text-[10px] font-bold text-text-primary">Ctrl</span>}
|
||||
</span>
|
||||
<span className="text-accent font-bold mx-1.5 text-[14px]">+</span>
|
||||
<span className="flex items-center justify-center px-1.5 py-0.5 bg-item-hover rounded border border-border-color shadow-sm min-w-[22px] min-h-[22px]">
|
||||
<span className="text-[11px] font-bold text-text-primary">V</span>
|
||||
</span>
|
||||
</span>
|
||||
to add downloads
|
||||
{isQueueFilter ? (
|
||||
'Add downloads to this queue from an item menu or the Add window.'
|
||||
) : filter === 'completed' ? (
|
||||
'Completed downloads will appear here.'
|
||||
) : (
|
||||
<>
|
||||
Click <Plus size={15} className="text-accent stroke-[3] mx-1.5" /> button or
|
||||
<span className="flex items-center mx-1.5">
|
||||
<span className="flex items-center justify-center px-1.5 py-0.5 bg-item-hover rounded border border-border-color shadow-sm min-w-[22px] min-h-[22px]">
|
||||
{isMac ? <Command size={12} strokeWidth={2.5} className="text-text-primary" /> : <span className="text-[10px] font-bold text-text-primary">Ctrl</span>}
|
||||
</span>
|
||||
<span className="text-accent font-bold mx-1.5 text-[14px]">+</span>
|
||||
<span className="flex items-center justify-center px-1.5 py-0.5 bg-item-hover rounded border border-border-color shadow-sm min-w-[22px] min-h-[22px]">
|
||||
<span className="text-[11px] font-bold text-text-primary">V</span>
|
||||
</span>
|
||||
</span>
|
||||
to add downloads
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
@@ -476,14 +589,17 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
|
||||
{sortedDownloads.map((d, index) => (
|
||||
<DownloadItemComponent
|
||||
key={d.id}
|
||||
downloadId={d.id}
|
||||
download={d}
|
||||
index={index}
|
||||
queueIndex={queuePositionsByDownloadId.get(d.id)?.index ?? -1}
|
||||
queueLength={queuePositionsByDownloadId.get(d.id)?.length ?? 0}
|
||||
tableGridTemplate={tableGridTemplate}
|
||||
setContextMenu={handleContextMenu}
|
||||
handlePause={handlePause}
|
||||
handleResume={handleResume}
|
||||
getCategoryIcon={getCategoryIcon}
|
||||
selectedIds={selectedIds}
|
||||
isSelected={selectedIds.has(d.id)}
|
||||
onMoveInQueue={handleMoveInQueue}
|
||||
onClick={handleItemClick}
|
||||
/>
|
||||
))}
|
||||
@@ -513,27 +629,17 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
|
||||
|
||||
const itemsToResume = selectedDownloads.filter(d => canStartDownload(d.status));
|
||||
const itemsToPause = selectedDownloads.filter(d => canPauseDownload(d.status));
|
||||
const itemsToQueue = selectedDownloads.filter(d => d.status !== 'completed');
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Multi-Select Context Menu */}
|
||||
{itemsToResume.length > 0 && (
|
||||
<button
|
||||
onClick={() => {
|
||||
setContextMenu(null);
|
||||
if (itemsToResume.length > 0) {
|
||||
const activeQueueIds = Array.from(new Set(itemsToResume.map(i => i.queueId).filter(Boolean)));
|
||||
// We can use assignToQueue for bulk resume to the same queue
|
||||
if (activeQueueIds.length === 1 && activeQueueIds[0]) {
|
||||
void assignToQueue(itemsToResume.map(i => i.id), activeQueueIds[0]).catch(error => {
|
||||
showInteractionError('Could not bulk resume downloads', error);
|
||||
});
|
||||
} else {
|
||||
// Fallback for missing queue or multiple queues
|
||||
itemsToResume.forEach(handleResume);
|
||||
}
|
||||
}
|
||||
}}
|
||||
onClick={() => {
|
||||
setContextMenu(null);
|
||||
void resumeItemsSequentially(itemsToResume);
|
||||
}}
|
||||
className="w-full text-left px-3 py-2 hover:bg-item-hover transition-colors"
|
||||
>
|
||||
Start/Resume
|
||||
@@ -567,24 +673,26 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
|
||||
<div className="h-[1px] bg-border-modal/60 my-1.5 mx-2"></div>
|
||||
)}
|
||||
|
||||
<div className="group relative">
|
||||
<button className="w-full text-left px-3 py-2 hover:bg-item-hover transition-colors flex justify-between items-center">
|
||||
Add to Queue
|
||||
<ChevronRight size={14} />
|
||||
</button>
|
||||
<div className="absolute left-full top-0 hidden group-hover:block ml-1 min-w-[150px] bg-bg-modal border border-border-modal rounded-lg shadow-lg py-1.5 z-50">
|
||||
{queues.map(q => (
|
||||
<button key={q.id} onClick={() => {
|
||||
setContextMenu(null);
|
||||
void assignToQueue(Array.from(selectedIds), q.id).catch(error => {
|
||||
showInteractionError('Could not move downloads to queue', error);
|
||||
});
|
||||
}} className="w-full text-left px-3 py-2 hover:bg-item-hover transition-colors text-[12px]">
|
||||
{q.name}
|
||||
</button>
|
||||
))}
|
||||
{itemsToQueue.length > 0 && (
|
||||
<div className="group relative">
|
||||
<button className="w-full text-left px-3 py-2 hover:bg-item-hover transition-colors flex justify-between items-center">
|
||||
Add to Queue
|
||||
<ChevronRight size={14} />
|
||||
</button>
|
||||
<div className="absolute left-full top-0 hidden group-hover:block ml-1 min-w-[150px] bg-bg-modal border border-border-modal rounded-lg shadow-lg py-1.5 z-50">
|
||||
{queues.map(q => (
|
||||
<button key={q.id} onClick={() => {
|
||||
setContextMenu(null);
|
||||
void assignToQueue(itemsToQueue.map(item => item.id), q.id).catch(error => {
|
||||
showInteractionError('Could not move downloads to queue', error);
|
||||
});
|
||||
}} className="w-full text-left px-3 py-2 hover:bg-item-hover transition-colors text-[12px]">
|
||||
{q.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="h-[1px] bg-border-modal/60 my-1.5 mx-2"></div>
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
export type DuplicateReason = { type: 'url', msg: string } | { type: 'file', msg: string };
|
||||
type DuplicateResolution = 'rename' | 'replace' | 'skip';
|
||||
@@ -20,12 +20,27 @@ interface Props {
|
||||
export const DuplicateResolutionModal = ({ conflicts: initialConflicts, onConfirm, onCancel }: Props) => {
|
||||
const [conflicts, setConflicts] = useState<DuplicateConflict[]>(initialConflicts);
|
||||
|
||||
useEffect(() => {
|
||||
const handleEscape = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') onCancel();
|
||||
};
|
||||
window.addEventListener('keydown', handleEscape);
|
||||
return () => window.removeEventListener('keydown', handleEscape);
|
||||
}, [onCancel]);
|
||||
|
||||
const updateResolution = (id: string, resolution: DuplicateResolution) => {
|
||||
setConflicts(conflicts.map(c => c.id === id ? { ...c, resolution } : c));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="app-modal-backdrop fixed inset-0 z-[60] flex items-center justify-center">
|
||||
<div
|
||||
className="app-modal-backdrop fixed inset-0 z-[60] flex items-center justify-center"
|
||||
onClick={(event) => {
|
||||
if (event.target === event.currentTarget) onCancel();
|
||||
}}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
>
|
||||
<div className="app-modal w-[500px] flex flex-col overflow-hidden text-sm">
|
||||
<div className="p-4 border-b border-border-modal flex flex-col gap-2">
|
||||
<h2 className="text-lg font-semibold text-text-primary">Duplicate Downloads Detected</h2>
|
||||
@@ -53,7 +68,7 @@ export const DuplicateResolutionModal = ({ conflicts: initialConflicts, onConfir
|
||||
</div>
|
||||
|
||||
<div className="p-4 border-t border-border-modal flex items-center justify-between bg-sidebar-bg/50">
|
||||
<button onClick={onCancel} className="app-button border-transparent bg-transparent px-4 text-xs text-text-secondary">
|
||||
<button onClick={onCancel} className="app-button px-4 text-xs">
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
|
||||
@@ -1,64 +1,123 @@
|
||||
import React, { useState } from 'react';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useSettingsStore } from '../store/useSettingsStore';
|
||||
import { invokeCommand as invoke } from '../ipc';
|
||||
import { KeyRound, ShieldAlert } from 'lucide-react';
|
||||
import { usePlatformInfo } from '../utils/platform';
|
||||
import { getKeychainConsentVersion } from '../utils/keychainStartup';
|
||||
import { getVersion } from '@tauri-apps/api/app';
|
||||
import type { PairingTokenHydration } from '../bindings/PairingTokenHydration';
|
||||
|
||||
export const KeychainPermissionModal: React.FC = () => {
|
||||
const KEYCHAIN_GRANT_TIMEOUT_MS = 30_000;
|
||||
|
||||
type KeychainPermissionModalProps = {
|
||||
consentVersion: string;
|
||||
};
|
||||
|
||||
export const KeychainPermissionModal: React.FC<KeychainPermissionModalProps> = ({ consentVersion }) => {
|
||||
const showKeychainModal = useSettingsStore(state => state.showKeychainModal);
|
||||
const setShowKeychainModal = useSettingsStore(state => state.setShowKeychainModal);
|
||||
const dismissKeychainPrompt = useSettingsStore(state => state.dismissKeychainPrompt);
|
||||
const platform = usePlatformInfo();
|
||||
const [isGranting, setIsGranting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!showKeychainModal || isGranting) return;
|
||||
const handleEscape = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') dismissKeychainPrompt(consentVersion);
|
||||
};
|
||||
window.addEventListener('keydown', handleEscape);
|
||||
return () => window.removeEventListener('keydown', handleEscape);
|
||||
}, [consentVersion, dismissKeychainPrompt, isGranting, showKeychainModal]);
|
||||
|
||||
if (!showKeychainModal) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const isMac = platform.os === 'macos';
|
||||
const storeName =
|
||||
platform.os === 'windows'
|
||||
const pairingStoreName =
|
||||
platform.portable
|
||||
? 'the portable Firelink data folder'
|
||||
: platform.os === 'windows'
|
||||
? 'Windows Credential Manager'
|
||||
: platform.os === 'linux'
|
||||
? 'your Linux credential store'
|
||||
: platform.os === 'macos'
|
||||
? 'macOS Keychain'
|
||||
: "this system's credential store";
|
||||
const grantLabel = isMac ? 'Grant Access' : 'Enable Secure Storage';
|
||||
const siteCredentialStoreName = platform.portable
|
||||
? "the system's credential store"
|
||||
: pairingStoreName;
|
||||
const grantLabel = platform.portable
|
||||
? 'Continue'
|
||||
: isMac
|
||||
? 'Grant Access'
|
||||
: 'Enable Secure Storage';
|
||||
|
||||
const handleGrant = async () => {
|
||||
setIsGranting(true);
|
||||
setError(null);
|
||||
|
||||
let timeoutId: number | undefined;
|
||||
let persistentGrantApplied = false;
|
||||
const applyPersistentGrant = async (result: PairingTokenHydration): Promise<boolean> => {
|
||||
if (!result.persistent || persistentGrantApplied) return result.persistent;
|
||||
persistentGrantApplied = true;
|
||||
const grantedVersion = consentVersion || getKeychainConsentVersion(await getVersion().catch(() => ''));
|
||||
// Keep state in sync with the grant result instead of rehydrating
|
||||
// before Zustand has persisted keychainAccessGranted.
|
||||
useSettingsStore.setState({
|
||||
keychainAccessGranted: true,
|
||||
keychainAccessVersion: grantedVersion,
|
||||
keychainAccessReady: true,
|
||||
extensionPairingToken: result.token,
|
||||
isPairingTokenPersistent: true,
|
||||
keychainPromptDismissed: false,
|
||||
showKeychainModal: false
|
||||
});
|
||||
return true;
|
||||
};
|
||||
const grantRequest = invoke('grant_keychain_access');
|
||||
// A native credential-store call cannot be cancelled by the webview. Keep
|
||||
// a late successful result useful even if the UI timeout has already
|
||||
// restored the Later/retry controls.
|
||||
grantRequest.then(applyPersistentGrant).catch(() => undefined);
|
||||
|
||||
try {
|
||||
const result = await invoke('grant_keychain_access');
|
||||
if (result.persistent) {
|
||||
// Keep state in sync with the grant result instead of rehydrating
|
||||
// before Zustand has persisted keychainAccessGranted.
|
||||
useSettingsStore.setState({
|
||||
keychainAccessGranted: true,
|
||||
extensionPairingToken: result.token,
|
||||
isPairingTokenPersistent: true
|
||||
});
|
||||
setShowKeychainModal(false);
|
||||
} else {
|
||||
setError(result.error || `${storeName} is unavailable.`);
|
||||
const result = await Promise.race([
|
||||
grantRequest,
|
||||
new Promise<never>((_, reject) => {
|
||||
timeoutId = window.setTimeout(
|
||||
() => reject(new Error('Credential storage request timed out. You can select Later and try again.')),
|
||||
KEYCHAIN_GRANT_TIMEOUT_MS
|
||||
);
|
||||
})
|
||||
]);
|
||||
if (!(await applyPersistentGrant(result))) {
|
||||
setError(result.error || `${siteCredentialStoreName} is unavailable.`);
|
||||
}
|
||||
} catch (e: any) {
|
||||
setError(e.toString());
|
||||
} finally {
|
||||
if (timeoutId !== undefined) window.clearTimeout(timeoutId);
|
||||
setIsGranting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleLater = () => {
|
||||
setShowKeychainModal(false);
|
||||
dismissKeychainPrompt(consentVersion);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50 animate-fade-in">
|
||||
<div
|
||||
className="app-modal-backdrop fixed inset-0 z-50 flex items-center justify-center bg-black/40"
|
||||
onClick={(event) => {
|
||||
if (event.target === event.currentTarget && !isGranting) handleLater();
|
||||
}}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
>
|
||||
<div
|
||||
className="bg-bg-modal rounded-xl w-full max-w-md overflow-hidden flex flex-col shadow-2xl border border-border-modal scale-in"
|
||||
className="window-safe-modal bg-bg-modal rounded-xl w-full max-w-md overflow-hidden flex flex-col shadow-2xl border border-border-modal scale-in"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="px-5 py-4 border-b border-border-modal flex items-center gap-3">
|
||||
@@ -68,21 +127,25 @@ export const KeychainPermissionModal: React.FC = () => {
|
||||
<h2 className="text-lg font-semibold text-text-primary m-0">Credential Storage Access Needed</h2>
|
||||
</div>
|
||||
|
||||
<div className="px-5 py-6 flex-1 text-sm text-text-secondary leading-relaxed space-y-4">
|
||||
<div className="px-5 py-6 flex-1 min-h-0 overflow-y-auto text-sm text-text-secondary leading-relaxed space-y-4">
|
||||
<p>
|
||||
Firelink uses the browser extension to capture downloads. To keep the extension paired after restarts,
|
||||
Firelink stores its pairing token in {storeName}.
|
||||
Firelink stores its pairing token in {pairingStoreName}. Optional site credentials are stored in {siteCredentialStoreName}.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
{isMac
|
||||
{platform.portable
|
||||
? 'The pairing token is portable with this folder. Site credentials remain in the system credential store; a system prompt may appear after you grant access.'
|
||||
: isMac
|
||||
? 'macOS may show a Keychain prompt after you grant access.'
|
||||
: 'This usually completes silently. If the credential service is unavailable, Firelink will show the error here and the extension will stay paired for this session only.'}
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<strong>Note:</strong> Firelink only writes its own dedicated credential entry. It cannot access other
|
||||
saved passwords or credential items on your system.
|
||||
<strong>Note:</strong>{' '}
|
||||
{platform.portable
|
||||
? 'Portable mode stores only the pairing token in this folder. It does not copy site passwords or browser credentials.'
|
||||
: 'Firelink only writes its own dedicated credential entry. It cannot access other saved passwords or credential items on your system.'}
|
||||
</p>
|
||||
|
||||
{error && (
|
||||
@@ -93,8 +156,11 @@ export const KeychainPermissionModal: React.FC = () => {
|
||||
)}
|
||||
|
||||
<div className="bg-bg-modal-accent p-3 rounded-lg border border-border-modal text-xs">
|
||||
<strong>Hint:</strong> If you select Later, the extension will only work for this session.
|
||||
You can enable secure storage anytime from <strong>Settings > Integrations</strong>.
|
||||
<strong>Hint:</strong>{' '}
|
||||
{platform.portable
|
||||
? 'The portable pairing token is already stored with this folder; you can enable it here or select Later.'
|
||||
: 'If you select Later, the extension will only work for this session.'}
|
||||
You can enable storage anytime from <strong>Settings > Integrations</strong>.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
+159
-57
@@ -1,17 +1,21 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { invokeCommand as invoke } from '../ipc';
|
||||
import { save } from '@tauri-apps/plugin-dialog';
|
||||
import { writeTextFile } from '@tauri-apps/plugin-fs';
|
||||
import { attachLogger, setLogPaused, initLogger } from '../utils/logger';
|
||||
import { homeDir } from '@tauri-apps/api/path';
|
||||
import { attachLogger, setLogPaused, initLogger, setLogStreamActive } from '../utils/logger';
|
||||
import { FileDown, Trash2, Terminal, Filter, Play, Pause, Info, Copy } from 'lucide-react';
|
||||
import { WindowDragRegion } from './WindowDragRegion';
|
||||
import { useToast } from '../contexts/ToastContext';
|
||||
import { useSettingsStore } from '../store/useSettingsStore';
|
||||
|
||||
interface LogEntry {
|
||||
level: 'Trace' | 'Debug' | 'Info' | 'Warn' | 'Error';
|
||||
message: string;
|
||||
}
|
||||
import {
|
||||
MAX_LOG_LINES,
|
||||
appendBoundedLogEntries,
|
||||
liveLogEntry,
|
||||
mergeLogSnapshotAndLiveEntries,
|
||||
persistedLogEntry,
|
||||
pushBoundedLogEntry,
|
||||
type LogEntry
|
||||
} from '../utils/logEntries';
|
||||
|
||||
export default function LogsView() {
|
||||
const { addToast } = useToast();
|
||||
@@ -20,56 +24,98 @@ export default function LogsView() {
|
||||
const [logs, setLogs] = useState<LogEntry[]>([]);
|
||||
const [levelFilter, setLevelFilter] = useState<LogEntry['level'] | 'All'>('All');
|
||||
const [contextMenu, setContextMenu] = useState<{ x: number; y: number; text: string } | null>(null);
|
||||
const [pageVisible, setPageVisible] = useState(() => document.visibilityState !== 'hidden');
|
||||
const homeDirectoryRef = useRef('');
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const rawLineCountRef = useRef(0);
|
||||
|
||||
const MAX_LOG_LINES = 2000;
|
||||
const liveBatchRef = useRef<LogEntry[]>([]);
|
||||
const liveFrameRef = useRef<number | null>(null);
|
||||
const clearGenerationRef = useRef(0);
|
||||
const clearInFlightRef = useRef<Promise<void> | null>(null);
|
||||
const toggleInFlightRef = useRef<Promise<void> | null>(null);
|
||||
const [isClearing, setIsClearing] = useState(false);
|
||||
const [isToggling, setIsToggling] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
let unlisten: (() => void) | undefined;
|
||||
void homeDir()
|
||||
.then(directory => {
|
||||
if (active) homeDirectoryRef.current = directory;
|
||||
})
|
||||
.catch(() => undefined);
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const handleVisibilityChange = () => setPageVisible(document.visibilityState !== 'hidden');
|
||||
document.addEventListener('visibilitychange', handleVisibilityChange);
|
||||
return () => document.removeEventListener('visibilitychange', handleVisibilityChange);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!pageVisible) {
|
||||
void setLogStreamActive(false).catch(console.error);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!logsEnabled) {
|
||||
void setLogStreamActive(false).catch(console.error);
|
||||
}
|
||||
|
||||
let active = true;
|
||||
let initialized = false;
|
||||
const initGeneration = clearGenerationRef.current;
|
||||
let pendingLiveEntries: LogEntry[] = [];
|
||||
let unlistenPromise: Promise<() => void> | undefined;
|
||||
|
||||
const scheduleLiveEntry = (entry: LogEntry) => {
|
||||
if (!initialized) {
|
||||
pushBoundedLogEntry(pendingLiveEntries, entry);
|
||||
return;
|
||||
}
|
||||
|
||||
pushBoundedLogEntry(liveBatchRef.current, entry);
|
||||
if (liveFrameRef.current !== null) return;
|
||||
liveFrameRef.current = window.requestAnimationFrame(() => {
|
||||
liveFrameRef.current = null;
|
||||
if (!active || liveBatchRef.current.length === 0) return;
|
||||
const batch = liveBatchRef.current;
|
||||
liveBatchRef.current = [];
|
||||
setLogs(current => appendBoundedLogEntries(current, batch));
|
||||
});
|
||||
};
|
||||
|
||||
const init = async () => {
|
||||
try {
|
||||
await initLogger();
|
||||
const [lines] = await Promise.all([
|
||||
invoke('read_logs', { limit: MAX_LOG_LINES })
|
||||
]);
|
||||
if (!active) return;
|
||||
const initialLogs = lines.map(message => {
|
||||
const level: LogEntry['level'] = message.includes('[ERROR]') ? 'Error'
|
||||
: message.includes('[WARN]') ? 'Warn'
|
||||
: message.includes('[INFO]') ? 'Info'
|
||||
: message.includes('[TRACE]') ? 'Trace'
|
||||
: 'Debug';
|
||||
return { level, message };
|
||||
});
|
||||
|
||||
setLogs(initialLogs);
|
||||
rawLineCountRef.current = initialLogs.length;
|
||||
|
||||
if (logsEnabled) {
|
||||
unlisten = await attachLogger((log) => {
|
||||
unlistenPromise = attachLogger((log) => {
|
||||
if (!active) return;
|
||||
const levelStr: LogEntry['level'] = log.level === 5 ? 'Error'
|
||||
: log.level === 4 ? 'Warn'
|
||||
: log.level === 3 ? 'Info'
|
||||
: log.level === 1 ? 'Trace'
|
||||
: 'Debug';
|
||||
|
||||
const timeStr = new Date().toISOString().replace('T', ' ').substring(0, 19);
|
||||
const formattedMsg = `[${timeStr}] [${levelStr.toUpperCase()}] ${log.message}`;
|
||||
|
||||
setLogs(prev => {
|
||||
const newLogs = [...prev, { level: levelStr, message: formattedMsg }];
|
||||
rawLineCountRef.current = newLogs.length;
|
||||
if (newLogs.length > MAX_LOG_LINES + 500) {
|
||||
return newLogs.slice(newLogs.length - MAX_LOG_LINES);
|
||||
}
|
||||
return newLogs;
|
||||
});
|
||||
scheduleLiveEntry(liveLogEntry(log.level, log.message, new Date(), homeDirectoryRef.current));
|
||||
});
|
||||
await unlistenPromise;
|
||||
if (!active) return;
|
||||
await setLogStreamActive(true);
|
||||
if (!active) {
|
||||
await setLogStreamActive(false).catch(console.error);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const lines = await invoke('read_logs', { limit: MAX_LOG_LINES });
|
||||
if (!active) return;
|
||||
const snapshot = lines.map(line => persistedLogEntry(line, homeDirectoryRef.current));
|
||||
initialized = true;
|
||||
if (initGeneration !== clearGenerationRef.current) {
|
||||
pendingLiveEntries = [];
|
||||
return;
|
||||
}
|
||||
const caughtUpLogs = mergeLogSnapshotAndLiveEntries(snapshot, pendingLiveEntries);
|
||||
pendingLiveEntries = [];
|
||||
setLogs(caughtUpLogs);
|
||||
} catch (e) {
|
||||
console.error('Failed to init logs:', e);
|
||||
}
|
||||
@@ -78,9 +124,19 @@ export default function LogsView() {
|
||||
|
||||
return () => {
|
||||
active = false;
|
||||
if (unlisten) unlisten();
|
||||
liveBatchRef.current = [];
|
||||
if (liveFrameRef.current !== null) {
|
||||
window.cancelAnimationFrame(liveFrameRef.current);
|
||||
liveFrameRef.current = null;
|
||||
}
|
||||
if (logsEnabled) {
|
||||
void setLogStreamActive(false).catch(console.error);
|
||||
}
|
||||
if (unlistenPromise) {
|
||||
void unlistenPromise.then(unlisten => unlisten()).catch(console.error);
|
||||
}
|
||||
};
|
||||
}, [logsEnabled]);
|
||||
}, [logsEnabled, pageVisible]);
|
||||
|
||||
useEffect(() => {
|
||||
if (scrollRef.current) {
|
||||
@@ -129,8 +185,7 @@ export default function LogsView() {
|
||||
filters: [{ name: 'Log Files', extensions: ['log'] }],
|
||||
});
|
||||
if (!path) return;
|
||||
const logsContent = await invoke('export_logs', {});
|
||||
await writeTextFile(path, logsContent);
|
||||
await invoke('export_logs', { destination: path });
|
||||
addToast({ message: 'Support logs exported', variant: 'success' });
|
||||
} catch (e) {
|
||||
console.error('Export failed:', e);
|
||||
@@ -139,18 +194,63 @@ export default function LogsView() {
|
||||
};
|
||||
|
||||
const handleClear = async () => {
|
||||
setLogs([]);
|
||||
await invoke('clear_logs').catch(console.error);
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
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) => {
|
||||
@@ -197,14 +297,16 @@ export default function LogsView() {
|
||||
<div className="w-[1px] h-4 bg-border-modal mx-0.5" />
|
||||
<button
|
||||
onClick={handleToggleLogging}
|
||||
className={`app-icon-button ${logsEnabled ? 'text-accent' : ''}`}
|
||||
disabled={isToggling}
|
||||
className={`app-icon-button disabled:cursor-not-allowed disabled:opacity-50 ${logsEnabled ? 'text-accent' : ''}`}
|
||||
title={logsEnabled ? "Pause diagnostic logging" : "Enable diagnostic logging"}
|
||||
>
|
||||
{logsEnabled ? <Pause size={14} /> : <Play size={14} />}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleClear}
|
||||
className="app-icon-button"
|
||||
disabled={isClearing}
|
||||
className="app-icon-button disabled:cursor-not-allowed disabled:opacity-50"
|
||||
title="Clear displayed logs"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useDownloadStore, DownloadItem } from '../store/useDownloadStore';
|
||||
import { useDownloadProgressStore } from '../store/downloadStore';
|
||||
import { useDownloadProgressStore } from '../store/downloadProgressStore';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
import { useSettingsStore } from '../store/useSettingsStore';
|
||||
import { ChevronDown, ChevronRight, FolderPlus, Info, CheckCircle, AlertCircle, Play, Pause } from 'lucide-react';
|
||||
@@ -10,9 +10,23 @@ import {
|
||||
isIdentityLocked as getIdentityLocked,
|
||||
isTransferLocked as getTransferLocked
|
||||
} from '../utils/downloadActions';
|
||||
import {
|
||||
downloadProgressColorClass,
|
||||
formatDownloadTotal,
|
||||
resolveDownloadSizeDisplay
|
||||
} from '../utils/downloadProgress';
|
||||
import { resolveDownloadConnections } from '../utils/downloads';
|
||||
|
||||
type LoginMode = 'matching' | 'custom' | 'none';
|
||||
|
||||
const formatLastTry = (value?: string): string => {
|
||||
if (!value) return '-';
|
||||
const date = new Date(value);
|
||||
return Number.isNaN(date.getTime())
|
||||
? '-'
|
||||
: date.toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short' });
|
||||
};
|
||||
|
||||
export const PropertiesModal = () => {
|
||||
const selectedPropertiesDownloadId = useDownloadStore(state => state.selectedPropertiesDownloadId);
|
||||
const setSelectedPropertiesDownloadId = useDownloadStore(state => state.setSelectedPropertiesDownloadId);
|
||||
@@ -33,7 +47,8 @@ export const PropertiesModal = () => {
|
||||
const [url, setUrl] = useState('');
|
||||
const [fileName, setFileName] = useState('');
|
||||
const [saveLocation, setSaveLocation] = useState('');
|
||||
const [connections, setConnections] = useState(16);
|
||||
const [connections, setConnections] = useState(() => resolveDownloadConnections(undefined, perServerConnections));
|
||||
const [connectionsDirty, setConnectionsDirty] = useState(false);
|
||||
|
||||
const [speedLimitEnabled, setSpeedLimitEnabled] = useState(false);
|
||||
const [speedLimitValue, setSpeedLimitValue] = useState('1024'); // KiB/s
|
||||
@@ -66,7 +81,8 @@ export const PropertiesModal = () => {
|
||||
activeItem.category
|
||||
).then(setSaveLocation);
|
||||
}
|
||||
setConnections(activeItem.connections || 16);
|
||||
setConnections(resolveDownloadConnections(activeItem.connections, perServerConnections));
|
||||
setConnectionsDirty(false);
|
||||
|
||||
if (activeItem.speedLimit) {
|
||||
setSpeedLimitEnabled(true);
|
||||
@@ -104,7 +120,24 @@ export const PropertiesModal = () => {
|
||||
setSelectedPropertiesDownloadId(null);
|
||||
}
|
||||
}
|
||||
}, [selectedPropertiesDownloadId, baseDownloadFolder, setSelectedPropertiesDownloadId]);
|
||||
}, [selectedPropertiesDownloadId, setSelectedPropertiesDownloadId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedPropertiesDownloadId || connectionsDirty) return;
|
||||
const activeItem = useDownloadStore.getState().downloads.find(d => d.id === selectedPropertiesDownloadId);
|
||||
if (activeItem && activeItem.connections === undefined) {
|
||||
setConnections(resolveDownloadConnections(undefined, perServerConnections));
|
||||
}
|
||||
}, [selectedPropertiesDownloadId, perServerConnections, connectionsDirty]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedPropertiesDownloadId) return;
|
||||
const handleEscape = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') setSelectedPropertiesDownloadId(null);
|
||||
};
|
||||
window.addEventListener('keydown', handleEscape);
|
||||
return () => window.removeEventListener('keydown', handleEscape);
|
||||
}, [selectedPropertiesDownloadId, setSelectedPropertiesDownloadId]);
|
||||
|
||||
if (!selectedPropertiesDownloadId || !item) return null;
|
||||
|
||||
@@ -138,7 +171,6 @@ export const PropertiesModal = () => {
|
||||
url,
|
||||
fileName,
|
||||
destination: saveLocation,
|
||||
connections: Number(connections),
|
||||
speedLimit: speedLimitEnabled && speedLimitValue ? `${speedLimitValue}K` : undefined,
|
||||
username: loginMode === 'custom' ? username.trim() : undefined,
|
||||
password: loginMode === 'custom' ? password.trim() : undefined,
|
||||
@@ -146,6 +178,9 @@ export const PropertiesModal = () => {
|
||||
checksum: checksumEnabled && checksumValue.trim() ? `${checksumAlgorithm}=${checksumValue.trim()}` : undefined,
|
||||
cookies: cookies.trim() || undefined,
|
||||
mirrors: mirrors.trim() || undefined,
|
||||
...(connectionsDirty
|
||||
? { connections: resolveDownloadConnections(connections, perServerConnections) }
|
||||
: {}),
|
||||
};
|
||||
|
||||
try {
|
||||
@@ -168,6 +203,17 @@ export const PropertiesModal = () => {
|
||||
const displayedEta = item.status === 'completed'
|
||||
? '-'
|
||||
: liveProgress?.eta ?? item.eta ?? '-';
|
||||
const sizeDisplay = resolveDownloadSizeDisplay({
|
||||
downloadedBytes: liveProgress?.downloaded_bytes ?? item.downloadedBytes,
|
||||
totalBytes: liveProgress?.total_bytes ?? item.totalBytes,
|
||||
totalIsEstimate: liveProgress?.total_is_estimate ?? item.totalIsEstimate,
|
||||
fallbackSize: item.size
|
||||
});
|
||||
const hasDownloadedAmount = item.status !== 'completed' &&
|
||||
Boolean(sizeDisplay.downloaded && sizeDisplay.total);
|
||||
const completedSizeLabel = item.status === 'completed'
|
||||
? formatDownloadTotal(sizeDisplay)
|
||||
: sizeDisplay.fallback;
|
||||
|
||||
let statusColor = 'text-text-secondary';
|
||||
let StatusIcon = Info;
|
||||
@@ -178,7 +224,14 @@ export const PropertiesModal = () => {
|
||||
else if (item.status === 'failed') { statusColor = 'text-red-500'; StatusIcon = AlertCircle; }
|
||||
|
||||
return (
|
||||
<div className="app-modal-backdrop fixed inset-0 z-50 flex items-center justify-center">
|
||||
<div
|
||||
className="app-modal-backdrop fixed inset-0 z-50 flex items-center justify-center"
|
||||
onClick={(event) => {
|
||||
if (event.target === event.currentTarget) setSelectedPropertiesDownloadId(null);
|
||||
}}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
>
|
||||
<div className="app-modal w-[720px] h-[580px] flex flex-col overflow-hidden text-sm">
|
||||
|
||||
{/* Header Summary */}
|
||||
@@ -197,14 +250,32 @@ export const PropertiesModal = () => {
|
||||
|
||||
<div className="grid grid-cols-4 gap-y-2 gap-x-4 text-[11px] leading-tight">
|
||||
<div className="flex gap-1.5 min-w-0"><span className="text-text-muted font-medium w-[90px] shrink-0">Progress</span><span className="text-text-secondary truncate">{`${(displayedFraction * 100).toFixed(0)}%`}</span></div>
|
||||
<div className="flex gap-1.5 min-w-0"><span className="text-text-muted font-medium w-[40px] shrink-0">Size</span><span className="text-text-secondary truncate">{item.size || '-'}</span></div>
|
||||
<div className="flex gap-1.5 min-w-0">
|
||||
<span className="text-text-muted font-medium w-[40px] shrink-0">Size</span>
|
||||
<span
|
||||
className="truncate"
|
||||
title={hasDownloadedAmount
|
||||
? `${sizeDisplay.downloaded} downloaded of ${sizeDisplay.totalIsEstimate ? 'approximately ' : ''}${sizeDisplay.total} ${sizeDisplay.unit}`
|
||||
: completedSizeLabel}
|
||||
>
|
||||
{hasDownloadedAmount ? (
|
||||
<>
|
||||
<span className={downloadProgressColorClass(item.status)}>{sizeDisplay.downloaded}</span>
|
||||
<span className="text-text-muted"> / </span>
|
||||
<span className="text-text-secondary">
|
||||
{sizeDisplay.totalIsEstimate ? '~' : ''}{sizeDisplay.total} {sizeDisplay.unit}
|
||||
</span>
|
||||
</>
|
||||
) : completedSizeLabel}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex gap-1.5 min-w-0"><span className="text-text-muted font-medium w-[40px] shrink-0">Speed</span><span className="text-text-secondary truncate">{displayedSpeed}</span></div>
|
||||
<div className="flex gap-1.5 min-w-0"><span className="text-text-muted font-medium w-[30px] shrink-0">ETA</span><span className="text-text-secondary truncate">{displayedEta}</span></div>
|
||||
|
||||
<div className="flex gap-1.5 min-w-0"><span className="text-text-muted font-medium w-[90px] shrink-0">Connections</span><span className="text-text-secondary truncate">{item.connections || perServerConnections || '-'}</span></div>
|
||||
<div className="flex gap-1.5 min-w-0"><span className="text-text-muted font-medium w-[90px] shrink-0">Connections</span><span className="text-text-secondary truncate" title={item.connections !== undefined ? 'Saved for this download; Settings changes apply to new downloads.' : 'Using the current default for new downloads.'}>{resolveDownloadConnections(item.connections, perServerConnections)}{item.connections !== undefined ? ' (saved)' : ' (default)'}</span></div>
|
||||
<div className="flex gap-1.5 min-w-0"><span className="text-text-muted font-medium w-[60px] shrink-0">Speed cap</span><span className="text-text-secondary truncate">{item.speedLimit || '-'}</span></div>
|
||||
<div className="flex gap-1.5 min-w-0"><span className="text-text-muted font-medium w-[55px] shrink-0">Category</span><span className="text-text-secondary truncate">{item.category}</span></div>
|
||||
<div className="flex gap-1.5"><span className="text-text-muted font-medium w-[50px]">Last try</span><span className="text-text-secondary truncate">-</span></div>
|
||||
<div className="flex gap-1.5"><span className="text-text-muted font-medium w-[50px]">Last try</span><span className="text-text-secondary truncate">{formatLastTry(item.lastTry)}</span></div>
|
||||
|
||||
<div className="flex gap-1.5 col-span-2"><span className="text-text-muted font-medium w-[90px]">Date added</span><span className="text-text-secondary truncate">{new Date(item.dateAdded).toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short' })}</span></div>
|
||||
<div className="flex gap-1.5 col-span-2"><span className="text-text-muted font-medium w-[70px]">Destination</span><span className="text-text-secondary truncate" title={saveLocation}>{saveLocation || baseDownloadFolder}</span></div>
|
||||
@@ -253,8 +324,20 @@ export const PropertiesModal = () => {
|
||||
|
||||
<label className="text-xs text-text-muted text-right">Connections</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input type="number" value={connections} min={1} max={16} onChange={e=>setConnections(Number(e.target.value))} disabled={transferLocked} className="w-16 bg-bg-input border border-border-modal rounded-lg px-2.5 py-1.5 text-xs text-text-primary focus:outline-none focus:border-accent disabled:opacity-50" />
|
||||
<input type="number" value={connections} min={1} max={16} onChange={e=>{ setConnections(Number(e.target.value)); setConnectionsDirty(true); }} disabled={transferLocked} className="w-16 bg-bg-input border border-border-modal rounded-lg px-2.5 py-1.5 text-xs text-text-primary focus:outline-none focus:border-accent disabled:opacity-50" />
|
||||
<span className="text-xs text-text-muted">per file</span>
|
||||
{!transferLocked && item.connections !== undefined && item.connections !== perServerConnections && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setConnections(perServerConnections); setConnectionsDirty(true); }}
|
||||
className="text-[11px] text-accent hover:underline whitespace-nowrap"
|
||||
>
|
||||
Use current default ({perServerConnections})
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="col-start-2 text-[11px] text-text-muted">
|
||||
Saved per download. The Settings default applies to new downloads.
|
||||
</div>
|
||||
|
||||
<label className="text-xs text-text-muted text-right">Speed</label>
|
||||
@@ -371,7 +454,7 @@ export const PropertiesModal = () => {
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => setSelectedPropertiesDownloadId(null)}
|
||||
className="app-button border-transparent bg-transparent px-4 text-xs text-text-secondary"
|
||||
className="app-button px-4 text-xs"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
} from 'lucide-react';
|
||||
import { PostQueueAction, SchedulerSettings, useSettingsStore } from '../store/useSettingsStore';
|
||||
import { MAIN_QUEUE_ID, useDownloadStore } from '../store/useDownloadStore';
|
||||
import { isActiveDownloadStatus } from '../utils/downloads';
|
||||
import { WindowDragRegion } from './WindowDragRegion';
|
||||
import { useToast } from '../contexts/ToastContext';
|
||||
import { usePlatformInfo } from '../utils/platform';
|
||||
@@ -137,6 +138,7 @@ export default function SchedulerView() {
|
||||
};
|
||||
|
||||
const runNow = async () => {
|
||||
const previouslyTrackedIds = new Set(useSettingsStore.getState().schedulerActiveDownloadIds);
|
||||
const results = await Promise.all(
|
||||
effectiveSelectedQueueIds.map(queueId => useDownloadStore.getState().startQueue(queueId))
|
||||
);
|
||||
@@ -144,8 +146,9 @@ export default function SchedulerView() {
|
||||
const selectedQueueSet = new Set(effectiveSelectedQueueIds);
|
||||
const trackedIds = useDownloadStore.getState().downloads
|
||||
.filter(download =>
|
||||
previouslyTrackedIds.has(download.id) &&
|
||||
selectedQueueSet.has(download.queueId || MAIN_QUEUE_ID) &&
|
||||
['queued', 'downloading', 'processing', 'retrying'].includes(download.status)
|
||||
isActiveDownloadStatus(download.status)
|
||||
)
|
||||
.map(download => download.id);
|
||||
const activeIds = [...new Set([...acceptedIds, ...trackedIds])];
|
||||
|
||||
+152
-33
@@ -4,6 +4,7 @@ import {
|
||||
type ListRowDensity,
|
||||
type SettingsState,
|
||||
SettingsTab,
|
||||
runSettingsPersistenceTransaction,
|
||||
useSettingsStore
|
||||
} from '../store/useSettingsStore';
|
||||
import {
|
||||
@@ -28,6 +29,8 @@ import {
|
||||
subfolderFromDerivedCategoryPath
|
||||
} from '../utils/downloadLocations';
|
||||
import { usePlatformInfo } from '../utils/platform';
|
||||
import { isTrustedFirelinkReleaseUrl } from '../utils/releaseUrls';
|
||||
import { normalizeCustomProxy } from '../store/useDownloadStore';
|
||||
|
||||
const settingsTabs: { type: SettingsTab; label: string; icon: typeof Download }[] = [
|
||||
{ type: 'downloads', label: 'Downloads', icon: Download },
|
||||
@@ -75,6 +78,8 @@ type ManualUpdateStatus =
|
||||
| { type: 'update-available'; version: string; releaseUrl: string }
|
||||
| { type: 'error'; message: string };
|
||||
|
||||
type SystemProxyStatus = 'idle' | 'checking' | 'detected' | 'none' | 'error';
|
||||
|
||||
const engineStatusCache = new Map<string, EngineStatusItem>();
|
||||
const engineStatusInFlight = new Map<string, Promise<EngineStatusItem>>();
|
||||
|
||||
@@ -275,14 +280,22 @@ const [engineStatus, setEngineStatus] = useState<EngineStatusItem[] | null>(null
|
||||
const [expandedEngine, setExpandedEngine] = useState<string | null>(null);
|
||||
const [isRecheckingEngines, setIsRecheckingEngines] = useState(false);
|
||||
const engineRunId = useRef(0);
|
||||
const [appVersion, setAppVersion] = useState('1.0.1');
|
||||
const [extensionServerPort, setExtensionServerPort] = useState<number | null>(null);
|
||||
const [appVersion, setAppVersion] = useState('Unknown');
|
||||
const [extensionServerPort, setExtensionServerPort] = useState<number | null>(null);
|
||||
const [systemProxyStatus, setSystemProxyStatus] = useState<SystemProxyStatus>('idle');
|
||||
|
||||
// Local state for adding site login
|
||||
const [loginPattern, setLoginPattern] = useState('');
|
||||
const [loginUser, setLoginUser] = useState('');
|
||||
const [loginPass, setLoginPass] = useState('');
|
||||
const [loginError, setLoginError] = useState('');
|
||||
const [isSavingLogin, setIsSavingLogin] = useState(false);
|
||||
const saveLoginInFlight = useRef(false);
|
||||
const [loginFieldErrors, setLoginFieldErrors] = useState<{
|
||||
pattern?: string;
|
||||
username?: string;
|
||||
password?: string;
|
||||
}>({});
|
||||
|
||||
// Toast notifications
|
||||
const { addToast } = useToast();
|
||||
@@ -314,6 +327,27 @@ useEffect(() => {
|
||||
};
|
||||
}, [settings.activeView, activeTab]);
|
||||
|
||||
useEffect(() => {
|
||||
if (settings.activeView !== 'settings' || activeTab !== 'network' || settings.proxyMode !== 'system') {
|
||||
setSystemProxyStatus('idle');
|
||||
return;
|
||||
}
|
||||
|
||||
let active = true;
|
||||
setSystemProxyStatus('checking');
|
||||
invoke('get_system_proxy')
|
||||
.then(proxy => {
|
||||
if (active) setSystemProxyStatus(typeof proxy === 'string' && proxy.trim() ? 'detected' : 'none');
|
||||
})
|
||||
.catch(() => {
|
||||
if (active) setSystemProxyStatus('error');
|
||||
});
|
||||
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [settings.activeView, settings.proxyMode, activeTab]);
|
||||
|
||||
const runEngineChecks = useCallback((force = false) => {
|
||||
const runId = ++engineRunId.current;
|
||||
const cached = engineChecks
|
||||
@@ -431,6 +465,9 @@ runEngineChecks(false);
|
||||
localVersion: result.local_version
|
||||
});
|
||||
} else if (result.type === 'UpdateAvailable') {
|
||||
if (!isTrustedFirelinkReleaseUrl(result.update.release_url)) {
|
||||
throw new Error('The update check returned an untrusted release URL.');
|
||||
}
|
||||
setManualUpdateStatus({
|
||||
type: 'update-available',
|
||||
version: result.update.version,
|
||||
@@ -512,27 +549,57 @@ runEngineChecks(false);
|
||||
};
|
||||
|
||||
const handleAddLogin = async () => {
|
||||
if (!loginPattern.trim() || !loginUser.trim()) {
|
||||
setLoginError("Please enter a URL pattern and a username.");
|
||||
if (saveLoginInFlight.current) return;
|
||||
const fieldErrors: typeof loginFieldErrors = {};
|
||||
if (!loginPattern.trim()) {
|
||||
fieldErrors.pattern = 'URL pattern is required.';
|
||||
} else if (/\s/.test(loginPattern.trim())) {
|
||||
fieldErrors.pattern = 'URL pattern cannot contain whitespace.';
|
||||
}
|
||||
if (!loginUser.trim()) {
|
||||
fieldErrors.username = 'Username is required.';
|
||||
}
|
||||
if (!loginPass) {
|
||||
fieldErrors.password = 'Password is required.';
|
||||
}
|
||||
if (Object.keys(fieldErrors).length > 0) {
|
||||
setLoginFieldErrors(fieldErrors);
|
||||
setLoginError('');
|
||||
return;
|
||||
}
|
||||
setLoginFieldErrors({});
|
||||
const id = crypto.randomUUID();
|
||||
|
||||
if (loginPass) {
|
||||
try {
|
||||
await invoke('set_keychain_password', { id, password: loginPass });
|
||||
} catch (e) {
|
||||
console.error("Failed to save password to keychain:", e);
|
||||
setLoginError("Failed to save password securely.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!settings.keychainAccessReady) {
|
||||
settings.setShowKeychainModal(true);
|
||||
setLoginError('Grant credential-store access before saving a site login.');
|
||||
return;
|
||||
}
|
||||
saveLoginInFlight.current = true;
|
||||
setIsSavingLogin(true);
|
||||
try {
|
||||
await runSettingsPersistenceTransaction(async () => {
|
||||
await invoke('save_site_login', {
|
||||
id,
|
||||
urlPattern: loginPattern.trim(),
|
||||
username: loginUser.trim(),
|
||||
password: loginPass
|
||||
});
|
||||
settings.addSiteLogin({
|
||||
id,
|
||||
urlPattern: loginPattern.trim(),
|
||||
username: loginUser.trim()
|
||||
});
|
||||
});
|
||||
} catch (e) {
|
||||
console.error("Failed to save site login:", e);
|
||||
setLoginError("Failed to save site credential securely.");
|
||||
return;
|
||||
} finally {
|
||||
saveLoginInFlight.current = false;
|
||||
setIsSavingLogin(false);
|
||||
}
|
||||
|
||||
settings.addSiteLogin({
|
||||
id,
|
||||
urlPattern: loginPattern.trim(),
|
||||
username: loginUser.trim()
|
||||
});
|
||||
setLoginPattern('');
|
||||
setLoginUser('');
|
||||
setLoginPass('');
|
||||
@@ -596,7 +663,7 @@ runEngineChecks(false);
|
||||
<div className="mac-settings-row">
|
||||
<div className="settings-row-label">
|
||||
<span>Default connections:</span>
|
||||
<small>For new downloads</small>
|
||||
<small>New downloads; existing items keep their saved value</small>
|
||||
</div>
|
||||
<input
|
||||
type="number" min="1" max="16"
|
||||
@@ -671,6 +738,18 @@ runEngineChecks(false);
|
||||
className="mac-switch"
|
||||
/>
|
||||
</label>
|
||||
<label className="mac-settings-row cursor-default">
|
||||
<div className="settings-row-label">
|
||||
<span>Add clipboard links when Firelink becomes active</span>
|
||||
<small>Opens supported copied links in the Add window. Off by default.</small>
|
||||
</div>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.autoAddClipboardLinks}
|
||||
onChange={(e) => settings.setAutoAddClipboardLinks(e.target.checked)}
|
||||
className="mac-switch"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -839,10 +918,20 @@ runEngineChecks(false);
|
||||
<p className="settings-group-footer">
|
||||
{settings.proxyMode === 'none' && 'Downloads ignore configured proxies.'}
|
||||
{settings.proxyMode === 'system' && `Downloads use the detected ${platform.os === 'macos' ? 'macOS' : platform.os === 'windows' ? 'Windows' : 'desktop'} system proxy. Normal file downloads require an HTTP or HTTPS proxy endpoint; media downloads can use SOCKS.`}
|
||||
{settings.proxyMode === 'custom' && (settings.proxyHost
|
||||
{settings.proxyMode === 'custom' && (normalizeCustomProxy(settings.proxyHost, settings.proxyPort)
|
||||
? 'Downloads use the configured HTTP proxy endpoint for metadata and download engines.'
|
||||
: 'Enter a proxy host and port to enable the custom proxy.')}
|
||||
: settings.proxyHost
|
||||
? 'Enter a valid HTTP proxy host and port to enable the custom proxy.'
|
||||
: 'Enter a proxy host and port to enable the custom proxy.')}
|
||||
</p>
|
||||
{settings.proxyMode === 'system' && (
|
||||
<p className="settings-group-footer" role="status">
|
||||
{systemProxyStatus === 'checking' && 'Checking system proxy configuration…'}
|
||||
{systemProxyStatus === 'detected' && 'A system proxy was detected. Normal file downloads require an HTTP or HTTPS endpoint; media downloads can use SOCKS.'}
|
||||
{systemProxyStatus === 'none' && 'No usable system proxy was detected. Downloads will use no proxy.'}
|
||||
{systemProxyStatus === 'error' && 'System proxy configuration could not be read. Choose No Proxy or try again when it is available.'}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<h2 className="settings-section-title">Identity</h2>
|
||||
@@ -1026,9 +1115,15 @@ runEngineChecks(false);
|
||||
</div>
|
||||
<button
|
||||
onClick={async () => {
|
||||
if (!settings.keychainAccessReady) {
|
||||
settings.setShowKeychainModal(true);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await invoke('delete_keychain_password', { id: login.id });
|
||||
settings.removeSiteLogin(login.id);
|
||||
await runSettingsPersistenceTransaction(async () => {
|
||||
await invoke('delete_site_login', { id: login.id });
|
||||
settings.removeSiteLogin(login.id);
|
||||
});
|
||||
showToast("Deleted credential", 'success');
|
||||
} catch (error) {
|
||||
showToast(`Could not delete credential: ${String(error)}`, 'error');
|
||||
@@ -1057,10 +1152,15 @@ runEngineChecks(false);
|
||||
<input
|
||||
type="text"
|
||||
value={loginPattern}
|
||||
onChange={(e) => setLoginPattern(e.target.value)}
|
||||
onChange={(e) => {
|
||||
setLoginPattern(e.target.value);
|
||||
setLoginFieldErrors(current => ({ ...current, pattern: undefined }));
|
||||
}}
|
||||
placeholder="e.g. *.example.com or example.com/downloads"
|
||||
aria-invalid={Boolean(loginFieldErrors.pattern)}
|
||||
className="bg-bg-input border border-border-modal rounded-md px-3 py-1.5 w-full text-text-primary focus:outline-none"
|
||||
/>
|
||||
{loginFieldErrors.pattern && <p className="text-red-500 text-xs mt-1">{loginFieldErrors.pattern}</p>}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-[150px_1fr] items-center gap-4 text-[13px]">
|
||||
@@ -1068,10 +1168,15 @@ runEngineChecks(false);
|
||||
<input
|
||||
type="text"
|
||||
value={loginUser}
|
||||
onChange={(e) => setLoginUser(e.target.value)}
|
||||
onChange={(e) => {
|
||||
setLoginUser(e.target.value);
|
||||
setLoginFieldErrors(current => ({ ...current, username: undefined }));
|
||||
}}
|
||||
placeholder="Username"
|
||||
aria-invalid={Boolean(loginFieldErrors.username)}
|
||||
className="bg-bg-input border border-border-modal rounded-md px-3 py-1.5 w-full text-text-primary focus:outline-none"
|
||||
/>
|
||||
{loginFieldErrors.username && <p className="text-red-500 text-xs mt-1">{loginFieldErrors.username}</p>}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-[150px_1fr] items-center gap-4 text-[13px]">
|
||||
@@ -1079,18 +1184,25 @@ runEngineChecks(false);
|
||||
<input
|
||||
type="password"
|
||||
value={loginPass}
|
||||
onChange={(e) => setLoginPass(e.target.value)}
|
||||
onChange={(e) => {
|
||||
setLoginPass(e.target.value);
|
||||
setLoginFieldErrors(current => ({ ...current, password: undefined }));
|
||||
}}
|
||||
placeholder="Password"
|
||||
aria-invalid={Boolean(loginFieldErrors.password)}
|
||||
className="bg-bg-input border border-border-modal rounded-md px-3 py-1.5 w-full text-text-primary focus:outline-none"
|
||||
/>
|
||||
{loginFieldErrors.password && <p className="text-red-500 text-xs mt-1">{loginFieldErrors.password}</p>}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end pt-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleAddLogin}
|
||||
disabled={isSavingLogin}
|
||||
className="bg-accent hover:bg-accent text-white px-4 py-1.5 rounded-lg text-xs font-semibold shadow flex items-center gap-1.5"
|
||||
>
|
||||
<Plus size={14} /> Add Login
|
||||
<Plus size={14} /> {isSavingLogin ? 'Saving…' : 'Add Login'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1214,9 +1326,13 @@ className="app-button px-3 py-1.5 text-[12px] flex items-center gap-1.5 disabled
|
||||
<Check size={16} strokeWidth={2.5} />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h4 className="text-sm font-semibold text-green-500 m-0">Credential Storage Available</h4>
|
||||
<h4 className="text-sm font-semibold text-green-500 m-0">
|
||||
{platform.portable ? 'Portable Pairing Enabled' : 'Pairing Token Persisted'}
|
||||
</h4>
|
||||
<p className="text-xs text-text-secondary m-0 mt-0.5">
|
||||
Your pairing token is securely saved in this system's credential store and will persist across restarts.
|
||||
{platform.portable
|
||||
? 'Your pairing token is stored with this portable Firelink folder and will persist when the folder is moved. Treat the folder as sensitive.'
|
||||
: 'Your pairing token is stored in the system credential store and will persist across restarts.'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1224,16 +1340,19 @@ className="app-button px-3 py-1.5 text-[12px] flex items-center gap-1.5 disabled
|
||||
<div className="bg-orange-500/10 border border-orange-500/20 rounded-lg p-4 flex items-start gap-3">
|
||||
<ShieldAlert className="w-5 h-5 text-orange-500 flex-shrink-0 mt-0.5" />
|
||||
<div className="flex-1">
|
||||
<h4 className="text-sm font-semibold text-text-primary mb-1">Credential Storage Needed</h4>
|
||||
<h4 className="text-sm font-semibold text-text-primary mb-1">
|
||||
{platform.portable ? 'Portable Pairing Available' : 'Credential Storage Needed'}
|
||||
</h4>
|
||||
<p className="text-xs text-text-secondary mb-3">
|
||||
Firelink needs access to this system's credential store to securely save your pairing token across app restarts.
|
||||
Currently, your extension will only stay connected for this session.
|
||||
{platform.portable
|
||||
? 'Your pairing token is stored with this portable Firelink folder and will persist across restarts. Enable it here to review the portable-storage warning.'
|
||||
: "Firelink needs access to this system's credential store to securely save your pairing token across app restarts. Currently, your extension will only stay connected for this session."}
|
||||
</p>
|
||||
<button
|
||||
onClick={() => settings.setShowKeychainModal(true)}
|
||||
className="px-4 py-1.5 rounded-md text-xs font-medium transition-colors bg-accent text-white hover:bg-accent/90 shadow-sm"
|
||||
>
|
||||
Grant Credential Access
|
||||
{platform.portable ? 'Review Portable Pairing' : 'Grant Credential Access'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+110
-16
@@ -3,12 +3,14 @@ import {
|
||||
Inbox, Zap, CheckCircle2, CircleDashed,
|
||||
Film, Music, FileText, Box, Image as ImageIcon, Archive, FileQuestion,
|
||||
List, CalendarClock, Gauge, Bug, Settings, Plus, Play, Pause, Edit2, Trash2, PanelLeft,
|
||||
ChevronDown,
|
||||
type LucideIcon
|
||||
} from 'lucide-react';
|
||||
import { useDownloadStore, DownloadCategory, Queue } from '../store/useDownloadStore';
|
||||
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;
|
||||
|
||||
@@ -28,9 +30,16 @@ export const Sidebar: React.FC<SidebarProps> = (props) => {
|
||||
const [renamingQueueId, setRenamingQueueId] = useState<string | null>(null);
|
||||
const [editingQueueName, setEditingQueueName] = useState('');
|
||||
const [contextMenu, setContextMenu] = useState<{ x: number; y: number; id: string } | null>(null);
|
||||
const [foldersCollapsed, setFoldersCollapsed] = useState(() =>
|
||||
window.localStorage.getItem('firelink-folders-collapsed') === 'true'
|
||||
);
|
||||
const foldersToggleRef = useRef<HTMLButtonElement>(null);
|
||||
const foldersListRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const addInputRef = useRef<HTMLInputElement>(null);
|
||||
const renameInputRef = useRef<HTMLInputElement>(null);
|
||||
const addQueueSubmitRef = useRef(false);
|
||||
const renameQueueSubmitRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
const handleCloseMenu = () => setContextMenu(null);
|
||||
@@ -53,6 +62,16 @@ export const Sidebar: React.FC<SidebarProps> = (props) => {
|
||||
if (renamingQueueId) renameInputRef.current?.focus();
|
||||
}, [renamingQueueId]);
|
||||
|
||||
useEffect(() => {
|
||||
window.localStorage.setItem('firelink-folders-collapsed', String(foldersCollapsed));
|
||||
}, [foldersCollapsed]);
|
||||
|
||||
useEffect(() => {
|
||||
if (foldersCollapsed && foldersListRef.current?.contains(document.activeElement)) {
|
||||
foldersToggleRef.current?.focus();
|
||||
}
|
||||
}, [foldersCollapsed]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (document.querySelector('.app-modal-backdrop') || document.querySelector('.app-modal')) return;
|
||||
@@ -65,6 +84,9 @@ export const Sidebar: React.FC<SidebarProps> = (props) => {
|
||||
const queueId = selectedFilter.replace('queue:', '');
|
||||
const q = queues.find(q => q.id === queueId);
|
||||
if (q && !q.isMain) {
|
||||
if (!window.confirm(`Delete queue "${q.name}"? Its unfinished downloads will move to Main Queue.`)) {
|
||||
return;
|
||||
}
|
||||
void removeQueue(queueId).catch(error => {
|
||||
addToast({ message: `Could not delete queue: ${String(error)}`, variant: 'error', isActionable: true });
|
||||
});
|
||||
@@ -75,7 +97,7 @@ export const Sidebar: React.FC<SidebarProps> = (props) => {
|
||||
};
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [selectedFilter, activeView, queues]);
|
||||
}, [addToast, activeView, queues, removeQueue, selectedFilter]);
|
||||
|
||||
const getCount = (filter: SidebarFilter) => {
|
||||
if (filter.startsWith('queue:')) {
|
||||
@@ -84,7 +106,7 @@ export const Sidebar: React.FC<SidebarProps> = (props) => {
|
||||
}
|
||||
switch (filter) {
|
||||
case 'all': return downloads.length;
|
||||
case 'active': return downloads.filter(d => d.status === 'downloading').length;
|
||||
case 'active': return downloads.filter(d => isTransferActiveStatus(d.status)).length;
|
||||
case 'completed': return downloads.filter(d => d.status === 'completed').length;
|
||||
case 'unfinished': return downloads.filter(d => d.status !== 'completed').length;
|
||||
default: return downloads.filter(d => d.category === filter as DownloadCategory).length;
|
||||
@@ -119,15 +141,34 @@ export const Sidebar: React.FC<SidebarProps> = (props) => {
|
||||
};
|
||||
|
||||
const handleAddQueueSubmit = () => {
|
||||
if (newQueueName.trim()) addQueue(newQueueName.trim());
|
||||
if (addQueueSubmitRef.current) return;
|
||||
const normalizedName = newQueueName.trim();
|
||||
if (!normalizedName) {
|
||||
addToast({ message: 'Queue name cannot be empty', variant: 'error', isActionable: true });
|
||||
return;
|
||||
}
|
||||
if (!addQueue(normalizedName)) {
|
||||
addToast({ message: 'A queue with this name already exists', variant: 'error', isActionable: true });
|
||||
return;
|
||||
}
|
||||
addQueueSubmitRef.current = true;
|
||||
setNewQueueName('');
|
||||
setIsAddingQueue(false);
|
||||
};
|
||||
|
||||
const handleRenameQueueSubmit = () => {
|
||||
if (renamingQueueId && editingQueueName.trim()) {
|
||||
renameQueue(renamingQueueId, editingQueueName.trim());
|
||||
if (renameQueueSubmitRef.current) return;
|
||||
const normalizedName = editingQueueName.trim();
|
||||
if (!renamingQueueId) return;
|
||||
if (!normalizedName) {
|
||||
addToast({ message: 'Queue name cannot be empty', variant: 'error', isActionable: true });
|
||||
return;
|
||||
}
|
||||
if (!renameQueue(renamingQueueId, normalizedName)) {
|
||||
addToast({ message: 'A queue with this name already exists', variant: 'error', isActionable: true });
|
||||
return;
|
||||
}
|
||||
renameQueueSubmitRef.current = true;
|
||||
setRenamingQueueId(null);
|
||||
};
|
||||
|
||||
@@ -214,14 +255,38 @@ export const Sidebar: React.FC<SidebarProps> = (props) => {
|
||||
</section>
|
||||
|
||||
<section className="sidebar-section">
|
||||
<div className="sidebar-section-label">Folders</div>
|
||||
<NavItem icon={Music} label="Musics" filter="Musics" />
|
||||
<NavItem icon={Film} label="Movies" filter="Movies" />
|
||||
<NavItem icon={Archive} label="Compressed" filter="Compressed" />
|
||||
<NavItem icon={FileText} label="Documents" filter="Documents" />
|
||||
<NavItem icon={ImageIcon} label="Pictures" filter="Pictures" />
|
||||
<NavItem icon={Box} label="Applications" filter="Applications" />
|
||||
<NavItem icon={FileQuestion} label="Other" filter="Other" />
|
||||
<button
|
||||
type="button"
|
||||
ref={foldersToggleRef}
|
||||
className="sidebar-section-label sidebar-section-label-toggle"
|
||||
aria-expanded={!foldersCollapsed}
|
||||
aria-controls="sidebar-folders-list"
|
||||
onClick={() => setFoldersCollapsed(collapsed => !collapsed)}
|
||||
>
|
||||
<span>Folders</span>
|
||||
<ChevronDown
|
||||
aria-hidden="true"
|
||||
size={13}
|
||||
className={`sidebar-section-chevron ${foldersCollapsed ? 'is-collapsed' : ''}`}
|
||||
/>
|
||||
</button>
|
||||
<div
|
||||
ref={foldersListRef}
|
||||
id="sidebar-folders-list"
|
||||
className={`sidebar-collapse-grid ${foldersCollapsed ? 'is-collapsed' : ''}`}
|
||||
aria-hidden={foldersCollapsed}
|
||||
inert={foldersCollapsed}
|
||||
>
|
||||
<div className="sidebar-collapse-content">
|
||||
<NavItem icon={Music} label="Musics" filter="Musics" />
|
||||
<NavItem icon={Film} label="Movies" filter="Movies" />
|
||||
<NavItem icon={Archive} label="Compressed" filter="Compressed" />
|
||||
<NavItem icon={FileText} label="Documents" filter="Documents" />
|
||||
<NavItem icon={ImageIcon} label="Pictures" filter="Pictures" />
|
||||
<NavItem icon={Box} label="Applications" filter="Applications" />
|
||||
<NavItem icon={FileQuestion} label="Other" filter="Other" />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="sidebar-section">
|
||||
@@ -249,7 +314,7 @@ export const Sidebar: React.FC<SidebarProps> = (props) => {
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setIsAddingQueue(true); setNewQueueName(''); }}
|
||||
onClick={() => { addQueueSubmitRef.current = false; setIsAddingQueue(true); setNewQueueName(''); }}
|
||||
className="flex w-full items-center px-3.5 py-1.5 rounded-lg text-[13px] text-text-muted hover:bg-item-hover hover:text-text-secondary cursor-default transition-colors mb-1"
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-2 shrink-0" strokeWidth={2} />
|
||||
@@ -290,14 +355,34 @@ export const Sidebar: React.FC<SidebarProps> = (props) => {
|
||||
>
|
||||
<button
|
||||
className="w-full text-left px-3 py-1.5 flex items-center hover:bg-item-hover"
|
||||
onClick={() => { startQueue(contextMenu.id); setContextMenu(null); }}
|
||||
onClick={() => {
|
||||
const queueId = contextMenu.id;
|
||||
setContextMenu(null);
|
||||
void startQueue(queueId).catch(error => {
|
||||
addToast({
|
||||
message: `Could not start queue: ${String(error)}`,
|
||||
variant: 'error',
|
||||
isActionable: true
|
||||
});
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Play size={14} className="mr-2 text-text-secondary" />
|
||||
Start Queue
|
||||
</button>
|
||||
<button
|
||||
className="w-full text-left px-3 py-1.5 flex items-center hover:bg-item-hover"
|
||||
onClick={() => { pauseQueue(contextMenu.id); setContextMenu(null); }}
|
||||
onClick={() => {
|
||||
const queueId = contextMenu.id;
|
||||
setContextMenu(null);
|
||||
void pauseQueue(queueId).catch(error => {
|
||||
addToast({
|
||||
message: `Could not pause queue: ${String(error)}`,
|
||||
variant: 'error',
|
||||
isActionable: true
|
||||
});
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Pause size={14} className="mr-2 text-text-secondary" />
|
||||
Pause Queue
|
||||
@@ -308,6 +393,7 @@ export const Sidebar: React.FC<SidebarProps> = (props) => {
|
||||
onClick={() => {
|
||||
const q = queues.find(q => q.id === contextMenu.id);
|
||||
if (q) {
|
||||
renameQueueSubmitRef.current = false;
|
||||
setEditingQueueName(q.name);
|
||||
setRenamingQueueId(q.id);
|
||||
}
|
||||
@@ -322,6 +408,14 @@ export const Sidebar: React.FC<SidebarProps> = (props) => {
|
||||
className="w-full text-left px-3 py-1.5 flex items-center hover:bg-red-500/20 text-red-400"
|
||||
onClick={() => {
|
||||
const queueId = contextMenu.id;
|
||||
const queue = queues.find(q => q.id === queueId);
|
||||
if (!queue || queue.isMain) {
|
||||
setContextMenu(null);
|
||||
return;
|
||||
}
|
||||
if (!window.confirm(`Delete queue "${queue.name}"? Its unfinished downloads will move to Main Queue.`)) {
|
||||
return;
|
||||
}
|
||||
setContextMenu(null);
|
||||
void removeQueue(queueId).catch(error => {
|
||||
addToast({
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
convertSpeedValue,
|
||||
clampSpeedDisplayValue,
|
||||
displayValueFromPresetBase,
|
||||
formatSpeedLimitForStorage,
|
||||
formatPresetValue,
|
||||
parseLimit,
|
||||
presetBaseFromDisplayValue,
|
||||
} from './SpeedLimiterView';
|
||||
|
||||
describe('SpeedLimiterView speed conversions', () => {
|
||||
it('converts KB/s and MB/s using binary units consistently', () => {
|
||||
expect(presetBaseFromDisplayValue(1024, 'KB/s')).toBe(1);
|
||||
expect(displayValueFromPresetBase(1, 'KB/s')).toBe(1024);
|
||||
expect(presetBaseFromDisplayValue(5, 'MB/s')).toBe(5);
|
||||
expect(displayValueFromPresetBase(5, 'MB/s')).toBe(5);
|
||||
});
|
||||
|
||||
it('round-trips non-MiB-aligned presets through the integer KiB backend value', () => {
|
||||
const presetBase = presetBaseFromDisplayValue(1500, 'KB/s');
|
||||
|
||||
expect(displayValueFromPresetBase(presetBase, 'KB/s')).toBe(1500);
|
||||
expect(convertSpeedValue(1500, 'KB/s', 'MB/s')).toBe(1500 / 1024);
|
||||
expect(convertSpeedValue(convertSpeedValue(1500, 'KB/s', 'MB/s'), 'MB/s', 'KB/s')).toBe(1500);
|
||||
});
|
||||
|
||||
it('preserves the selected unit when saving a non-MiB-aligned MB/s value', () => {
|
||||
const storedLimit = formatSpeedLimitForStorage(1.5, 'MB/s');
|
||||
|
||||
expect(storedLimit).toBe('1.5M');
|
||||
expect(parseLimit(storedLimit, 1024)).toEqual({ value: 1.5, unit: 'MB/s' });
|
||||
});
|
||||
|
||||
it('preserves an explicitly selected KB/s unit at MiB boundaries', () => {
|
||||
const storedLimit = formatSpeedLimitForStorage(1024, 'KB/s');
|
||||
|
||||
expect(storedLimit).toBe('1024K');
|
||||
expect(parseLimit(storedLimit, 1)).toEqual({ value: 1024, unit: 'KB/s' });
|
||||
});
|
||||
|
||||
it('restores the persisted unit while the limiter is disabled', () => {
|
||||
expect(parseLimit('', 1536, 'MB/s')).toEqual({ value: 1.5, unit: 'MB/s' });
|
||||
expect(parseLimit('', 1536, 'KB/s')).toEqual({ value: 1536, unit: 'KB/s' });
|
||||
});
|
||||
|
||||
it('allows sub-one MB/s values while enforcing the 1 KiB backend minimum', () => {
|
||||
expect(clampSpeedDisplayValue(0.5, 'MB/s')).toBe(0.5);
|
||||
expect(clampSpeedDisplayValue(0, 'MB/s')).toBe(1 / 1024);
|
||||
expect(clampSpeedDisplayValue(0, 'KB/s')).toBe(1);
|
||||
});
|
||||
|
||||
it('shows the exact stored preset value instead of a rounded label', () => {
|
||||
expect(formatPresetValue(1.46484375)).toBe('1.46484375');
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Gauge, Plus, Save, X, Zap } from 'lucide-react';
|
||||
import { useSettingsStore } from '../store/useSettingsStore';
|
||||
import { WindowDragRegion } from './WindowDragRegion';
|
||||
@@ -8,94 +8,163 @@ type SpeedUnit = 'KB/s' | 'MB/s';
|
||||
|
||||
const MAX_LIMIT_KIB = 10_485_760;
|
||||
const MAX_LIMIT_MB = 10240;
|
||||
const KIB_PER_MIB = 1024;
|
||||
|
||||
function parseLimit(limit: string, fallback: number): { value: number; unit: SpeedUnit } {
|
||||
export function speedValueToKiB(value: number, unit: SpeedUnit): number {
|
||||
const numericValue = Number.isFinite(value) ? value : 1;
|
||||
const valueKiB = unit === 'MB/s' ? numericValue * KIB_PER_MIB : numericValue;
|
||||
return Math.max(1, Math.min(MAX_LIMIT_KIB, Math.round(valueKiB)));
|
||||
}
|
||||
|
||||
export function speedValueFromKiB(valueKiB: number, unit: SpeedUnit): number {
|
||||
const normalizedKiB = Math.max(1, Math.min(MAX_LIMIT_KIB, Math.round(valueKiB)));
|
||||
return unit === 'MB/s' ? normalizedKiB / KIB_PER_MIB : normalizedKiB;
|
||||
}
|
||||
|
||||
export function convertSpeedValue(value: number, fromUnit: SpeedUnit, toUnit: SpeedUnit): number {
|
||||
return speedValueFromKiB(speedValueToKiB(value, fromUnit), toUnit);
|
||||
}
|
||||
|
||||
export function parseLimit(
|
||||
limit: string,
|
||||
fallback: number,
|
||||
fallbackUnit: SpeedUnit = 'MB/s'
|
||||
): { value: number; unit: SpeedUnit } {
|
||||
const match = limit.trim().match(/^(\d+(?:\.\d+)?)\s*([km]?)b?(?:\/s)?$/i);
|
||||
const suffix = match?.[2].toLowerCase();
|
||||
const valueKiB = match
|
||||
? Math.max(1, Math.round(Number(match[1]) * (match[2].toLowerCase() === 'm' ? 1024 : 1)))
|
||||
: fallback;
|
||||
? speedValueToKiB(Number(match[1]) * (suffix === 'm' ? KIB_PER_MIB : 1), 'KB/s')
|
||||
: speedValueToKiB(fallback, 'KB/s');
|
||||
|
||||
if (!match) {
|
||||
return { value: speedValueFromKiB(valueKiB, fallbackUnit), unit: fallbackUnit };
|
||||
}
|
||||
|
||||
if (suffix === 'm') {
|
||||
return { value: speedValueFromKiB(valueKiB, 'MB/s'), unit: 'MB/s' };
|
||||
}
|
||||
|
||||
if (suffix === 'k') {
|
||||
return { value: valueKiB, unit: 'KB/s' };
|
||||
}
|
||||
|
||||
return valueKiB >= 1024 && valueKiB % 1024 === 0
|
||||
? { value: valueKiB / 1024, unit: 'MB/s' }
|
||||
: { value: valueKiB, unit: 'KB/s' };
|
||||
}
|
||||
|
||||
export function formatSpeedLimitForStorage(value: number, unit: SpeedUnit): string {
|
||||
const valueKiB = speedValueToKiB(value, unit);
|
||||
return unit === 'MB/s'
|
||||
? `${speedValueFromKiB(valueKiB, 'MB/s')}M`
|
||||
: `${valueKiB}K`;
|
||||
}
|
||||
|
||||
export function clampSpeedDisplayValue(value: number, unit: SpeedUnit): number {
|
||||
const numericValue = Number.isFinite(value) ? value : speedValueFromKiB(1, unit);
|
||||
const minimum = speedValueFromKiB(1, unit);
|
||||
const maximum = speedValueFromKiB(MAX_LIMIT_KIB, unit);
|
||||
return Math.max(minimum, Math.min(maximum, numericValue));
|
||||
}
|
||||
|
||||
function sanitizePresetValues(values: number[]): number[] {
|
||||
const cleaned = values
|
||||
.map(value => Number(value))
|
||||
.filter(value => Number.isFinite(value) && value > 0)
|
||||
.map(value => Math.min(MAX_LIMIT_MB, Math.round(value * 100) / 100));
|
||||
.map(value => speedValueFromKiB(speedValueToKiB(value, 'MB/s'), 'MB/s'));
|
||||
|
||||
return Array.from(new Set(cleaned)).sort((a, b) => a - b);
|
||||
}
|
||||
|
||||
function presetBaseFromDisplayValue(value: number, unit: SpeedUnit): number {
|
||||
return unit === 'MB/s' ? value : value / 1000;
|
||||
export function presetBaseFromDisplayValue(value: number, unit: SpeedUnit): number {
|
||||
return speedValueFromKiB(speedValueToKiB(value, unit), 'MB/s');
|
||||
}
|
||||
|
||||
function displayValueFromPresetBase(value: number, unit: SpeedUnit): number {
|
||||
return unit === 'MB/s' ? value : Math.round(value * 1000);
|
||||
export function displayValueFromPresetBase(value: number, unit: SpeedUnit): number {
|
||||
return speedValueFromKiB(speedValueToKiB(value, 'MB/s'), unit);
|
||||
}
|
||||
|
||||
function formatPresetValue(value: number): string {
|
||||
return Number.isInteger(value) ? String(value) : value.toFixed(2).replace(/\.?0+$/, '');
|
||||
export function formatPresetValue(value: number): string {
|
||||
return String(value);
|
||||
}
|
||||
|
||||
export default function SpeedLimiterView() {
|
||||
const globalSpeedLimit = useSettingsStore(state => state.globalSpeedLimit);
|
||||
const lastCustomSpeedLimitKiB = useSettingsStore(state => state.lastCustomSpeedLimitKiB);
|
||||
const lastCustomSpeedLimitUnit = useSettingsStore(state => state.lastCustomSpeedLimitUnit);
|
||||
const speedLimitPresetValues = useSettingsStore(state => state.speedLimitPresetValues);
|
||||
const setGlobalSpeedLimit = useSettingsStore(state => state.setGlobalSpeedLimit);
|
||||
const setLastCustomSpeedLimitKiB = useSettingsStore(state => state.setLastCustomSpeedLimitKiB);
|
||||
const setLastCustomSpeedLimitUnit = useSettingsStore(state => state.setLastCustomSpeedLimitUnit);
|
||||
const setSpeedLimitPresetValues = useSettingsStore(state => state.setSpeedLimitPresetValues);
|
||||
const initial = parseLimit(globalSpeedLimit, lastCustomSpeedLimitKiB);
|
||||
const fallbackUnit: SpeedUnit = lastCustomSpeedLimitUnit === 'KB/s' ? 'KB/s' : 'MB/s';
|
||||
const initial = parseLimit(globalSpeedLimit, lastCustomSpeedLimitKiB, fallbackUnit);
|
||||
const [enabled, setEnabled] = useState(Boolean(globalSpeedLimit));
|
||||
const [value, setValue] = useState(initial.value);
|
||||
const [value, setValue] = useState(String(initial.value));
|
||||
const [unit, setUnit] = useState<SpeedUnit>(initial.unit);
|
||||
const [customPresetValue, setCustomPresetValue] = useState(initial.value);
|
||||
const [customPresetValue, setCustomPresetValue] = useState(String(initial.value));
|
||||
const { addToast } = useToast();
|
||||
const savingRef = useRef(false);
|
||||
const presetValues = useMemo(
|
||||
() => sanitizePresetValues(speedLimitPresetValues),
|
||||
[speedLimitPresetValues]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const parsed = parseLimit(globalSpeedLimit, lastCustomSpeedLimitKiB);
|
||||
const parsed = parseLimit(globalSpeedLimit, lastCustomSpeedLimitKiB, fallbackUnit);
|
||||
setEnabled(Boolean(globalSpeedLimit));
|
||||
setValue(parsed.value);
|
||||
setValue(String(parsed.value));
|
||||
setUnit(parsed.unit);
|
||||
setCustomPresetValue(parsed.value);
|
||||
}, [globalSpeedLimit, lastCustomSpeedLimitKiB]);
|
||||
setCustomPresetValue(String(parsed.value));
|
||||
}, [globalSpeedLimit, lastCustomSpeedLimitKiB, fallbackUnit]);
|
||||
|
||||
|
||||
const save = () => {
|
||||
const numericValue = Math.max(1, Math.min(Number(value) || 1, unit === 'MB/s' ? 10240 : MAX_LIMIT_KIB));
|
||||
const valueKiB = Math.min(MAX_LIMIT_KIB, Math.round(unit === 'MB/s' ? numericValue * 1024 : numericValue));
|
||||
setLastCustomSpeedLimitKiB(valueKiB);
|
||||
setGlobalSpeedLimit(enabled ? `${valueKiB}K` : '');
|
||||
addToast({
|
||||
message: enabled ? `Global limit saved at ${numericValue} ${unit}` : 'Global speed limit disabled',
|
||||
variant: 'success'
|
||||
});
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
const save = async () => {
|
||||
if (savingRef.current) return;
|
||||
savingRef.current = true;
|
||||
const numericValue = clampSpeedDisplayValue(Number(value), unit);
|
||||
const valueKiB = speedValueToKiB(numericValue, unit);
|
||||
setIsSaving(true);
|
||||
try {
|
||||
await setGlobalSpeedLimit(enabled ? formatSpeedLimitForStorage(numericValue, unit) : '');
|
||||
setLastCustomSpeedLimitKiB(valueKiB);
|
||||
setLastCustomSpeedLimitUnit(unit);
|
||||
addToast({
|
||||
message: enabled ? `Global limit saved at ${numericValue} ${unit}` : 'Global speed limit disabled',
|
||||
variant: 'success'
|
||||
});
|
||||
} catch (error) {
|
||||
addToast({
|
||||
message: `Could not save global speed limit: ${String(error)}`,
|
||||
variant: 'error',
|
||||
isActionable: true
|
||||
});
|
||||
} finally {
|
||||
savingRef.current = false;
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const preset = (presetValue: number) => {
|
||||
setEnabled(true);
|
||||
setValue(displayValueFromPresetBase(presetValue, unit));
|
||||
setValue(String(displayValueFromPresetBase(presetValue, unit)));
|
||||
};
|
||||
|
||||
const applyCustomPreset = () => {
|
||||
const numericValue = Math.max(1, Math.min(Number(customPresetValue) || 1, unit === 'MB/s' ? MAX_LIMIT_MB : MAX_LIMIT_KIB));
|
||||
const numericValue = clampSpeedDisplayValue(Number(customPresetValue), unit);
|
||||
const presetBaseValue = Math.min(MAX_LIMIT_MB, presetBaseFromDisplayValue(numericValue, unit));
|
||||
const nextPresets = sanitizePresetValues([...presetValues, presetBaseValue]);
|
||||
const alreadyExists = nextPresets.length === presetValues.length;
|
||||
const storedPresetDisplayValue = displayValueFromPresetBase(presetBaseValue, unit);
|
||||
setSpeedLimitPresetValues(nextPresets);
|
||||
setEnabled(true);
|
||||
setValue(numericValue);
|
||||
setValue(String(storedPresetDisplayValue));
|
||||
addToast({
|
||||
message: alreadyExists
|
||||
? `${formatPresetValue(numericValue)} ${unit} is already in quick presets`
|
||||
: `Added ${formatPresetValue(numericValue)} ${unit} quick preset`,
|
||||
? `${formatPresetValue(storedPresetDisplayValue)} ${unit} is already in quick presets`
|
||||
: `Added ${formatPresetValue(storedPresetDisplayValue)} ${unit} quick preset`,
|
||||
variant: alreadyExists ? 'info' : 'success'
|
||||
});
|
||||
};
|
||||
@@ -110,6 +179,17 @@ export default function SpeedLimiterView() {
|
||||
});
|
||||
};
|
||||
|
||||
const changeUnit = (nextUnit: SpeedUnit) => {
|
||||
if (nextUnit === unit) return;
|
||||
setValue(String(convertSpeedValue(Number(value), unit, nextUnit)));
|
||||
setCustomPresetValue(String(convertSpeedValue(Number(customPresetValue), unit, nextUnit)));
|
||||
setUnit(nextUnit);
|
||||
};
|
||||
|
||||
const currentDisplayValue = Number.isFinite(Number(value)) && Number(value) > 0
|
||||
? value
|
||||
: String(speedValueFromKiB(1, unit));
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex h-full flex-col overflow-hidden bg-main-bg">
|
||||
<WindowDragRegion />
|
||||
@@ -118,7 +198,8 @@ export default function SpeedLimiterView() {
|
||||
<div className="flex items-center gap-3 text-[17px] font-semibold tracking-tight text-text-primary select-none">
|
||||
<button
|
||||
onClick={() => setEnabled(!enabled)}
|
||||
className={`relative inline-flex h-5 w-9 cursor-pointer items-center rounded-full transition-colors duration-200 ease-in-out focus:outline-none ${enabled ? 'bg-accent' : 'bg-item-hover'}`}
|
||||
disabled={isSaving}
|
||||
className={`relative inline-flex h-5 w-9 cursor-pointer items-center rounded-full transition-colors duration-200 ease-in-out focus:outline-none disabled:cursor-not-allowed ${enabled ? 'bg-accent' : 'bg-item-hover'}`}
|
||||
aria-checked={enabled}
|
||||
role="switch"
|
||||
>
|
||||
@@ -131,9 +212,9 @@ export default function SpeedLimiterView() {
|
||||
<span className={`rounded-full px-2.5 py-1 text-[11px] font-semibold ${
|
||||
enabled ? 'bg-accent/15 text-accent' : 'bg-item-hover text-text-muted'
|
||||
}`}>
|
||||
{enabled ? `${value} ${unit}` : 'Unlimited'}
|
||||
{enabled ? `${currentDisplayValue} ${unit}` : 'Unlimited'}
|
||||
</span>
|
||||
<button onClick={save} className="app-button app-button-primary ml-auto px-3 text-[11px]">
|
||||
<button onClick={() => void save()} disabled={isSaving} className="app-button app-button-primary ml-auto px-3 text-[11px] disabled:opacity-50">
|
||||
<Save size={14} /> Save Limit
|
||||
</button>
|
||||
</div>
|
||||
@@ -144,16 +225,17 @@ export default function SpeedLimiterView() {
|
||||
<Gauge size={18} className="text-accent" /> Global Speed Limit
|
||||
</div>
|
||||
<p className="max-w-2xl text-[12px] leading-relaxed text-text-muted">
|
||||
Applies to new and active aria2 transfers and yt-dlp media downloads. Per-download limits still take precedence.
|
||||
Applies to new and active aria2 transfers and new yt-dlp media downloads. Explicit per-download limits remain attached to those transfers.
|
||||
</p>
|
||||
|
||||
<div className="mt-6 flex items-center gap-3">
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
min={speedValueFromKiB(1, unit)}
|
||||
step="any"
|
||||
value={value}
|
||||
disabled={!enabled}
|
||||
onChange={event => setValue(Math.max(1, Number(event.target.value) || 1))}
|
||||
disabled={!enabled || isSaving}
|
||||
onChange={event => setValue(event.target.value)}
|
||||
className="app-control w-28 px-3 py-2 text-right font-mono"
|
||||
/>
|
||||
<div className="flex rounded-md border border-border-modal bg-bg-input p-1">
|
||||
@@ -161,8 +243,8 @@ export default function SpeedLimiterView() {
|
||||
<button
|
||||
key={option}
|
||||
type="button"
|
||||
disabled={!enabled}
|
||||
onClick={() => setUnit(option)}
|
||||
disabled={!enabled || isSaving}
|
||||
onClick={() => changeUnit(option)}
|
||||
className={`rounded px-3 py-1.5 text-[12px] font-medium ${
|
||||
unit === option ? 'bg-accent text-white' : 'text-text-secondary hover:bg-item-hover'
|
||||
}`}
|
||||
@@ -187,7 +269,7 @@ export default function SpeedLimiterView() {
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!enabled}
|
||||
disabled={!enabled || isSaving}
|
||||
onClick={() => preset(presetValue)}
|
||||
className="h-full flex-1 px-3 text-left disabled:opacity-50"
|
||||
>
|
||||
@@ -195,7 +277,7 @@ export default function SpeedLimiterView() {
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!enabled}
|
||||
disabled={!enabled || isSaving}
|
||||
onClick={() => removePreset(presetValue)}
|
||||
className="flex h-full w-7 items-center justify-center border-l border-border-modal text-text-muted transition-colors hover:bg-red-500/10 hover:text-red-400 focus-visible:bg-red-500/10 focus-visible:text-red-400 disabled:opacity-50"
|
||||
title={`Remove ${formatPresetValue(displayValue)} ${unit} preset`}
|
||||
@@ -209,17 +291,18 @@ export default function SpeedLimiterView() {
|
||||
<div className="ml-1 flex h-8 items-center gap-1.5 rounded-md border border-border-modal bg-bg-input px-2">
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
min={speedValueFromKiB(1, unit)}
|
||||
step="any"
|
||||
value={customPresetValue}
|
||||
disabled={!enabled}
|
||||
onChange={event => setCustomPresetValue(Math.max(1, Number(event.target.value) || 1))}
|
||||
disabled={!enabled || isSaving}
|
||||
onChange={event => setCustomPresetValue(event.target.value)}
|
||||
className="w-12 bg-transparent text-right font-mono text-[12px] text-text-primary outline-none disabled:opacity-50"
|
||||
aria-label={`Custom preset in ${unit}`}
|
||||
/>
|
||||
<span className="text-[11px] text-text-muted">{unit}</span>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!enabled}
|
||||
disabled={!enabled || isSaving}
|
||||
onClick={applyCustomPreset}
|
||||
className="app-icon-button h-6 w-6 disabled:opacity-50"
|
||||
title="Add quick preset"
|
||||
|
||||
@@ -14,6 +14,7 @@ export interface ToastMessage {
|
||||
variant?: ToastVariant;
|
||||
duration?: number;
|
||||
isActionable?: boolean;
|
||||
onDismiss?: () => void;
|
||||
}
|
||||
|
||||
interface ToastState extends ToastMessage {
|
||||
@@ -21,7 +22,7 @@ interface ToastState extends ToastMessage {
|
||||
}
|
||||
|
||||
interface ToastContextType {
|
||||
addToast: (toast: Omit<ToastMessage, 'id'>) => void;
|
||||
addToast: (toast: Omit<ToastMessage, 'id'>) => string;
|
||||
removeToast: (id: string) => void;
|
||||
}
|
||||
|
||||
@@ -33,10 +34,12 @@ export const ToastProvider: React.FC<{ children: ReactNode }> = ({ children }) =
|
||||
|
||||
const addToast = useCallback((toast: Omit<ToastMessage, 'id'>) => {
|
||||
nextToastId.current += 1;
|
||||
const id = `toast-${nextToastId.current}`;
|
||||
setToasts(prev => {
|
||||
const next = [...prev, { ...toast, id: `toast-${nextToastId.current}` }];
|
||||
const next = [...prev, { ...toast, id }];
|
||||
return next.slice(-MAX_VISIBLE_TOASTS);
|
||||
});
|
||||
return id;
|
||||
}, []);
|
||||
|
||||
const removeToast = useCallback((id: string) => {
|
||||
@@ -70,6 +73,7 @@ const ToastItem: React.FC<{ toast: ToastState; removeToast: (id: string) => void
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
const timerStartedAt = useRef<number | null>(null);
|
||||
const remainingDuration = useRef<number | null>(null);
|
||||
const onDismissCalled = useRef(false);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const frame = requestAnimationFrame(() => setIsMounted(true));
|
||||
@@ -99,6 +103,16 @@ const ToastItem: React.FC<{ toast: ToastState; removeToast: (id: string) => void
|
||||
};
|
||||
}, [toast, isHovered, removeToast]);
|
||||
|
||||
useEffect(() => {
|
||||
const dismiss = () => {
|
||||
if (onDismissCalled.current) return;
|
||||
onDismissCalled.current = true;
|
||||
toast.onDismiss?.();
|
||||
};
|
||||
if (toast.exiting) dismiss();
|
||||
return dismiss;
|
||||
}, [toast.exiting, toast.onDismiss]);
|
||||
|
||||
useEffect(() => {
|
||||
if (toast.exiting) {
|
||||
const fallbackTimer = setTimeout(() => {
|
||||
|
||||
+142
-12
@@ -1,4 +1,6 @@
|
||||
@import "tailwindcss";
|
||||
@import "tailwindcss" source(none);
|
||||
@source "../src";
|
||||
@source "../index.html";
|
||||
|
||||
:root {
|
||||
/* Default/fallback Light (macOS Light Mode) */
|
||||
@@ -439,7 +441,7 @@ html[data-list-density="relaxed"] {
|
||||
color: hsl(var(--text-primary));
|
||||
font-weight: 500;
|
||||
font-size: 13px;
|
||||
transition: background-color 100ms ease, transform 100ms ease;
|
||||
transition: background-color 100ms ease, border-color 100ms ease, box-shadow 100ms ease, transform 100ms ease;
|
||||
box-shadow: 0 1px 1px hsl(var(--shadow-color));
|
||||
}
|
||||
|
||||
@@ -496,6 +498,20 @@ html[data-list-density="relaxed"] {
|
||||
.app-modal-backdrop {
|
||||
background: hsl(0 0% 0% / 0.2);
|
||||
animation: fade-in 150ms ease-out;
|
||||
overflow: auto;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.app-shell--window-chrome .app-modal-backdrop {
|
||||
padding-top: 56px;
|
||||
}
|
||||
|
||||
.app-modal-backdrop > .app-modal,
|
||||
.app-modal-backdrop > .window-safe-modal {
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.app-modal {
|
||||
@@ -510,6 +526,10 @@ html[data-list-density="relaxed"] {
|
||||
|
||||
/* Add Download window */
|
||||
.add-download-modal {
|
||||
width: min(900px, 100%);
|
||||
height: min(650px, 100%);
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
--add-surface: hsl(var(--surface-raised) / 0.72);
|
||||
--add-highlight: hsl(0 0% 100% / 0.08);
|
||||
background:
|
||||
@@ -525,12 +545,17 @@ html[data-list-density="relaxed"] {
|
||||
}
|
||||
|
||||
.add-download-left {
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
background:
|
||||
radial-gradient(circle at 18% 0%, hsl(var(--accent-color) / 0.055), transparent 34%),
|
||||
hsl(var(--main-bg) / 0.56);
|
||||
}
|
||||
|
||||
.add-download-settings {
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
background: hsl(var(--bg-modal) / 0.7);
|
||||
}
|
||||
|
||||
@@ -621,6 +646,8 @@ html[data-list-density="relaxed"] {
|
||||
}
|
||||
|
||||
.add-download-preview {
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
border: 1px solid hsl(var(--border-modal));
|
||||
border-radius: 11px;
|
||||
background: hsl(var(--bg-input) / 0.35);
|
||||
@@ -628,11 +655,13 @@ html[data-list-density="relaxed"] {
|
||||
}
|
||||
|
||||
.add-download-preview-header {
|
||||
flex-shrink: 0;
|
||||
border-bottom: 1px solid hsl(var(--border-modal));
|
||||
background: hsl(var(--surface-raised) / 0.66);
|
||||
}
|
||||
|
||||
.add-download-preview-row {
|
||||
flex-shrink: 0;
|
||||
transition:
|
||||
background-color 100ms ease,
|
||||
border-color 100ms ease,
|
||||
@@ -814,15 +843,6 @@ html[data-list-density="relaxed"] {
|
||||
hsl(var(--bg-input));
|
||||
}
|
||||
|
||||
.add-download-button-cancel {
|
||||
color: hsl(var(--text-secondary));
|
||||
}
|
||||
|
||||
.add-download-button-cancel:hover:not(:disabled) {
|
||||
background: hsl(var(--item-hover));
|
||||
color: hsl(var(--text-primary));
|
||||
}
|
||||
|
||||
.add-download-button-primary {
|
||||
border-color: hsl(var(--accent-color) / 0.86);
|
||||
background:
|
||||
@@ -995,6 +1015,7 @@ html[data-list-density="relaxed"] {
|
||||
z-index: 60;
|
||||
width: 8px;
|
||||
cursor: col-resize;
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
|
||||
.sidebar-resize-handle::after {
|
||||
@@ -1143,6 +1164,75 @@ html[data-list-density="relaxed"] {
|
||||
color: hsl(var(--text-muted));
|
||||
}
|
||||
|
||||
.sidebar-section-label-toggle {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: hsl(var(--text-muted));
|
||||
cursor: default;
|
||||
transition: color 140ms ease;
|
||||
}
|
||||
|
||||
.sidebar-section-label-toggle:hover {
|
||||
color: hsl(var(--text-secondary));
|
||||
}
|
||||
|
||||
.sidebar-section-chevron {
|
||||
flex-shrink: 0;
|
||||
opacity: 0.8;
|
||||
transform: rotate(0deg);
|
||||
transition: transform 240ms cubic-bezier(0.22, 1, 0.36, 1), opacity 140ms ease;
|
||||
}
|
||||
|
||||
.sidebar-section-chevron.is-collapsed {
|
||||
transform: rotate(-90deg);
|
||||
opacity: 0.58;
|
||||
}
|
||||
|
||||
.sidebar-collapse-grid {
|
||||
display: grid;
|
||||
grid-template-rows: 1fr;
|
||||
transition: grid-template-rows 240ms cubic-bezier(0.22, 1, 0.36, 1);
|
||||
}
|
||||
|
||||
.sidebar-collapse-grid.is-collapsed {
|
||||
grid-template-rows: 0fr;
|
||||
}
|
||||
|
||||
.sidebar-collapse-content {
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
transform-origin: top;
|
||||
transition:
|
||||
opacity 150ms ease,
|
||||
transform 240ms cubic-bezier(0.22, 1, 0.36, 1),
|
||||
visibility 0s linear 0s;
|
||||
}
|
||||
|
||||
.sidebar-collapse-grid.is-collapsed .sidebar-collapse-content {
|
||||
opacity: 0;
|
||||
transform: translateY(-6px);
|
||||
pointer-events: none;
|
||||
visibility: hidden;
|
||||
transition:
|
||||
opacity 120ms ease,
|
||||
transform 240ms cubic-bezier(0.22, 1, 0.36, 1),
|
||||
visibility 0s linear 120ms;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.sidebar-section-chevron,
|
||||
.sidebar-collapse-grid,
|
||||
.sidebar-collapse-content {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
.sidebar-section {
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
@@ -1163,6 +1253,7 @@ html[data-list-density="relaxed"] {
|
||||
border-radius: 6px;
|
||||
color: hsl(var(--text-secondary));
|
||||
background: transparent;
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
|
||||
.sidebar-toggle-button:hover {
|
||||
@@ -1562,7 +1653,7 @@ html[data-list-density="relaxed"] {
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.main-control-group {
|
||||
.main-control-group {
|
||||
margin-left: auto;
|
||||
height: 28px;
|
||||
display: inline-flex;
|
||||
@@ -1571,6 +1662,7 @@ html[data-list-density="relaxed"] {
|
||||
border-radius: 15px;
|
||||
border: 1px solid hsl(var(--border-modal));
|
||||
background: hsl(var(--bg-input));
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
|
||||
.main-control-button {
|
||||
@@ -1581,6 +1673,7 @@ html[data-list-density="relaxed"] {
|
||||
justify-content: center;
|
||||
color: hsl(var(--text-secondary));
|
||||
border-right: 1px solid hsl(var(--border-color));
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
|
||||
.main-control-button svg {
|
||||
@@ -1685,6 +1778,7 @@ html[data-list-density="relaxed"] {
|
||||
}
|
||||
|
||||
.download-table-header {
|
||||
flex-shrink: 0;
|
||||
height: var(--download-header-height);
|
||||
display: grid;
|
||||
align-items: center;
|
||||
@@ -1751,6 +1845,7 @@ html[data-list-density="relaxed"] {
|
||||
z-index: 5;
|
||||
width: 8px;
|
||||
cursor: col-resize;
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
|
||||
.column-resize-handle::after {
|
||||
@@ -1786,12 +1881,14 @@ body.is-resizing .column-resize-handle:hover::after {
|
||||
|
||||
.download-table-list {
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.download-row {
|
||||
flex: 0 0 var(--download-row-height);
|
||||
height: var(--download-row-height);
|
||||
display: grid;
|
||||
align-items: center;
|
||||
@@ -1874,6 +1971,39 @@ html[data-list-density="relaxed"] .download-ghost-row {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.download-size-cell {
|
||||
justify-content: flex-end;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.download-size-cell > .download-size-progress {
|
||||
flex: 1 1 auto;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.download-size-cell > .download-size-total {
|
||||
flex: 0 0 auto;
|
||||
text-align: left;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.download-size-cell > .download-size-progress,
|
||||
.download-size-cell > .download-size-total {
|
||||
max-width: 100%;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.download-size-cell > .download-size-progress {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.download-row > .download-size-cell {
|
||||
padding-right: calc(var(--download-column-padding-x) + 6px);
|
||||
}
|
||||
|
||||
.download-cell-truncate > span,
|
||||
.download-cell-right > span {
|
||||
display: block;
|
||||
|
||||
+20
-3
@@ -5,7 +5,9 @@ import type { DownloadCategory } from './bindings/DownloadCategory';
|
||||
import type { DownloadProgressEvent } from './bindings/DownloadProgressEvent';
|
||||
import type { DownloadStateEvent } from './bindings/DownloadStateEvent';
|
||||
import type { ExtensionDownload } from './bindings/ExtensionDownload';
|
||||
import type { ExtensionCookieScope } from './bindings/ExtensionCookieScope';
|
||||
import type { MediaMetadata } from './bindings/MediaMetadata';
|
||||
import type { MediaPlaylistMetadata } from './bindings/MediaPlaylistMetadata';
|
||||
import type { MetadataResponse } from './bindings/MetadataResponse';
|
||||
import type { EngineStatusItem } from './bindings/EngineStatusItem';
|
||||
import type { PostQueueAction } from './bindings/PostQueueAction';
|
||||
@@ -17,13 +19,17 @@ import type { PlatformInfo } from './bindings/PlatformInfo';
|
||||
|
||||
type CommandMap = {
|
||||
fetch_metadata: {
|
||||
args: { url: string; userAgent: string | null; username: string | null; password: string | null; headers: string | null; cookies: string | null; proxy: string | null };
|
||||
args: { url: string; userAgent: string | null; username: string | null; password: string | null; headers: string | null; cookies: string | null; cookieScopes: Array<ExtensionCookieScope> | null; proxy: string | null; deferCookies?: boolean };
|
||||
result: MetadataResponse;
|
||||
};
|
||||
fetch_media_metadata: {
|
||||
args: { url: string; cookieBrowser: string | null; userAgent: string | null; username: string | null; password: string | null; headers: string | null; cookies: string | null; proxy: string | null };
|
||||
result: MediaMetadata;
|
||||
};
|
||||
fetch_media_playlist_metadata: {
|
||||
args: { url: string; cookieBrowser: string | null; userAgent: string | null; username: string | null; password: string | null; headers: string | null; cookies: string | null; proxy: string | null };
|
||||
result: MediaPlaylistMetadata;
|
||||
};
|
||||
get_aria2_engine_status: { args: undefined; result: EngineStatusItem };
|
||||
get_ytdlp_engine_status: { args: undefined; result: EngineStatusItem };
|
||||
get_ffmpeg_engine_status: { args: undefined; result: EngineStatusItem };
|
||||
@@ -32,7 +38,7 @@ type CommandMap = {
|
||||
open_downloaded_file: { args: { path: string }; result: void };
|
||||
pause_download: { args: { id: string }; result: void };
|
||||
resume_download: { args: { id: string }; result: boolean };
|
||||
remove_download: { args: { id: string; deleteAssets: boolean }; result: void };
|
||||
remove_download: { args: { id: string; deleteAssets: boolean; preserveResumable?: boolean }; result: void };
|
||||
detach_download_for_reconfigure: { args: { id: string }; result: void };
|
||||
update_dock_badge: { args: { count: number }; result: void };
|
||||
get_platform_info: { args: undefined; result: PlatformInfo };
|
||||
@@ -49,14 +55,22 @@ type CommandMap = {
|
||||
set_keychain_password: { args: { id: string; password: string }; result: void };
|
||||
get_keychain_password: { args: { id: string }; result: string };
|
||||
delete_keychain_password: { args: { id: string }; result: void };
|
||||
save_site_login: {
|
||||
args: { id: string; urlPattern: string; username: string; password: string };
|
||||
result: void;
|
||||
};
|
||||
delete_site_login: { args: { id: string }; result: void };
|
||||
check_file_exists: { args: { path: string }; result: boolean };
|
||||
toggle_tray_icon: { args: { show: boolean }; result: void };
|
||||
set_extension_pairing_token: { args: { token: string }; result: void };
|
||||
get_extension_server_port: { args: undefined; result: number | null };
|
||||
hydrate_extension_pairing_token: { args: undefined; result: PairingTokenHydration };
|
||||
get_session_pairing_token: { args: undefined; result: PairingTokenHydration };
|
||||
regenerate_pairing_token: { args: undefined; result: PairingTokenHydration };
|
||||
grant_keychain_access: { args: undefined; result: PairingTokenHydration };
|
||||
acknowledge_pairing_token_change: { args: undefined; result: void };
|
||||
set_extension_frontend_ready: { args: { ready: boolean }; result: void };
|
||||
ack_extension_download: { args: { requestId: string }; result: void };
|
||||
get_system_proxy: { args: undefined; result: string | null };
|
||||
get_file_category: { args: { filename: string }; result: DownloadCategory };
|
||||
check_for_updates: { args: undefined; result: ReleaseCheckOutcome };
|
||||
@@ -71,15 +85,18 @@ 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 };
|
||||
is_log_paused: { args: undefined; result: boolean };
|
||||
set_log_stream_active: { args: { active: boolean }; result: void };
|
||||
get_pending_order: { args: { queueId: string | null }; result: string[] };
|
||||
enqueue_download: { args: { item: EnqueueItem }; result: EnqueueAccepted };
|
||||
cancel_enqueue_generation: { args: { id: string; generation: string }; result: void };
|
||||
enqueue_many: { args: { items: EnqueueItem[] }; result: import('./bindings/EnqueueResult').EnqueueResult[] };
|
||||
move_in_queue: { args: { id: string; queueId: string; direction: 'up' | 'down' }; result: string[] };
|
||||
move_many_in_queue: { args: { ids: string[]; queueId: string; direction: 'up' | 'down' }; result: string[] };
|
||||
remove_from_queue: { args: { id: string }; result: boolean };
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { create } from 'zustand';
|
||||
import type { DownloadProgressEvent } from '../bindings/DownloadProgressEvent';
|
||||
|
||||
interface DownloadProgressState {
|
||||
progressMap: Record<string, DownloadProgressEvent>;
|
||||
updateDownloadProgress: (id: string, payload: DownloadProgressEvent) => void;
|
||||
clearDownloadProgress: (id: string) => void;
|
||||
}
|
||||
|
||||
export const useDownloadProgressStore = create<DownloadProgressState>((set) => ({
|
||||
progressMap: {},
|
||||
updateDownloadProgress: (id, payload) =>
|
||||
set((state) => ({
|
||||
progressMap: {
|
||||
...state.progressMap,
|
||||
[id]: payload,
|
||||
},
|
||||
})),
|
||||
clearDownloadProgress: (id) =>
|
||||
set((state) => {
|
||||
if (!(id in state.progressMap)) return state;
|
||||
const next = { ...state.progressMap };
|
||||
delete next[id];
|
||||
return { progressMap: next };
|
||||
}),
|
||||
}));
|
||||
@@ -1,9 +1,23 @@
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
import { useDownloadProgressStore } from './downloadStore';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { initDownloadListener, useDownloadProgressStore } from './downloadStore';
|
||||
import {
|
||||
clearDownloadControlIntents,
|
||||
setDownloadControlIntent,
|
||||
useDownloadStore
|
||||
} from './useDownloadStore';
|
||||
import * as ipc from '../ipc';
|
||||
|
||||
vi.mock('../ipc', () => ({
|
||||
invokeCommand: vi.fn(),
|
||||
listenEvent: vi.fn(),
|
||||
}));
|
||||
|
||||
describe('useDownloadProgressStore', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(ipc.invokeCommand).mockResolvedValue(undefined);
|
||||
useDownloadProgressStore.setState({ progressMap: {} });
|
||||
clearDownloadControlIntents();
|
||||
});
|
||||
|
||||
it('prunes terminal progress entries', () => {
|
||||
@@ -20,4 +34,354 @@ describe('useDownloadProgressStore', () => {
|
||||
|
||||
expect(useDownloadProgressStore.getState().progressMap).toEqual({});
|
||||
});
|
||||
|
||||
it('shares listener setup across overlapping consumers and tears down after the last release', async () => {
|
||||
const unlisten = vi.fn();
|
||||
vi.mocked(ipc.listenEvent).mockResolvedValue(unlisten);
|
||||
|
||||
const first = initDownloadListener();
|
||||
const second = initDownloadListener();
|
||||
|
||||
expect(ipc.listenEvent).toHaveBeenCalledTimes(3);
|
||||
|
||||
const releaseFirst = await first;
|
||||
const releaseSecond = await second;
|
||||
releaseFirst();
|
||||
expect(unlisten).not.toHaveBeenCalled();
|
||||
|
||||
releaseSecond();
|
||||
expect(unlisten).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('ignores late progress and opposite terminal events from an older lifecycle', async () => {
|
||||
const handlers: Record<string, (event: any) => void> = {};
|
||||
vi.mocked(ipc.listenEvent).mockImplementation((event, handler) => {
|
||||
handlers[event] = handler as (event: any) => void;
|
||||
return Promise.resolve(vi.fn());
|
||||
});
|
||||
useDownloadStore.setState({
|
||||
downloads: [{
|
||||
id: 'terminal',
|
||||
url: 'https://example.com/file',
|
||||
fileName: 'file.bin',
|
||||
status: 'completed',
|
||||
category: 'Other',
|
||||
dateAdded: ''
|
||||
}]
|
||||
});
|
||||
|
||||
const release = await initDownloadListener();
|
||||
handlers['download-progress']({ payload: {
|
||||
id: 'terminal',
|
||||
fraction: 0.1,
|
||||
speed: '1 MB/s',
|
||||
eta: '10s',
|
||||
size: '1 MB',
|
||||
size_is_final: false
|
||||
} });
|
||||
handlers['download-state']({ payload: {
|
||||
id: 'terminal',
|
||||
status: 'failed',
|
||||
error: 'stale failure'
|
||||
} });
|
||||
|
||||
expect(useDownloadProgressStore.getState().progressMap).toEqual({});
|
||||
expect(useDownloadStore.getState().downloads[0].status).toBe('completed');
|
||||
release();
|
||||
});
|
||||
|
||||
it('clears progress when events arrive after a download row was removed', async () => {
|
||||
const handlers: Record<string, (event: any) => void> = {};
|
||||
vi.mocked(ipc.listenEvent).mockImplementation((event, handler) => {
|
||||
handlers[event] = handler as (event: any) => void;
|
||||
return Promise.resolve(vi.fn());
|
||||
});
|
||||
useDownloadProgressStore.getState().updateDownloadProgress('removed', {
|
||||
id: 'removed',
|
||||
fraction: 0.8,
|
||||
speed: '1 MB/s',
|
||||
eta: '2s',
|
||||
size: '8 MB',
|
||||
size_is_final: false
|
||||
});
|
||||
useDownloadStore.setState({ downloads: [] });
|
||||
|
||||
const release = await initDownloadListener();
|
||||
handlers['download-progress']({ payload: {
|
||||
id: 'removed',
|
||||
fraction: 0.9,
|
||||
speed: '2 MB/s',
|
||||
eta: '1s',
|
||||
size: '9 MB',
|
||||
size_is_final: false
|
||||
} });
|
||||
|
||||
expect(useDownloadProgressStore.getState().progressMap).toEqual({});
|
||||
release();
|
||||
});
|
||||
|
||||
it('drops stale progress when a download returns to a queued lifecycle', async () => {
|
||||
const handlers: Record<string, (event: any) => void> = {};
|
||||
vi.mocked(ipc.listenEvent).mockImplementation((event, handler) => {
|
||||
handlers[event] = handler as (event: any) => void;
|
||||
return Promise.resolve(vi.fn());
|
||||
});
|
||||
useDownloadStore.setState({
|
||||
downloads: [{
|
||||
id: 'reused',
|
||||
url: 'https://example.com/file',
|
||||
fileName: 'file.bin',
|
||||
status: 'downloading',
|
||||
category: 'Other',
|
||||
dateAdded: ''
|
||||
}]
|
||||
});
|
||||
|
||||
const release = await initDownloadListener();
|
||||
handlers['download-progress']({ payload: {
|
||||
id: 'reused',
|
||||
fraction: 0.8,
|
||||
speed: '1 MB/s',
|
||||
eta: '2s',
|
||||
size: '8 MB',
|
||||
size_is_final: false
|
||||
} });
|
||||
handlers['download-state']({ payload: {
|
||||
id: 'reused',
|
||||
status: 'queued'
|
||||
} });
|
||||
handlers['download-progress']({ payload: {
|
||||
id: 'reused',
|
||||
fraction: 0.9,
|
||||
speed: '2 MB/s',
|
||||
eta: '1s',
|
||||
size: '9 MB',
|
||||
size_is_final: false
|
||||
} });
|
||||
|
||||
expect(useDownloadProgressStore.getState().progressMap).toEqual({});
|
||||
release();
|
||||
});
|
||||
|
||||
it('snapshots live progress before clearing it on a terminal transition', async () => {
|
||||
const handlers: Record<string, (event: any) => void> = {};
|
||||
vi.mocked(ipc.listenEvent).mockImplementation((event, handler) => {
|
||||
handlers[event] = handler as (event: any) => void;
|
||||
return Promise.resolve(vi.fn());
|
||||
});
|
||||
useDownloadStore.setState({
|
||||
downloads: [{
|
||||
id: 'snapshot',
|
||||
url: 'https://example.com/file',
|
||||
fileName: 'file.bin',
|
||||
status: 'downloading',
|
||||
category: 'Other',
|
||||
dateAdded: ''
|
||||
}]
|
||||
});
|
||||
|
||||
const release = await initDownloadListener();
|
||||
handlers['download-progress']({ payload: {
|
||||
id: 'snapshot',
|
||||
fraction: 0.8,
|
||||
speed: '1 MB/s',
|
||||
eta: '2s',
|
||||
size: '8 MB',
|
||||
size_is_final: false,
|
||||
downloaded_bytes: 8192,
|
||||
total_bytes: 10240,
|
||||
total_is_estimate: true
|
||||
} });
|
||||
handlers['download-state']({ payload: {
|
||||
id: 'snapshot',
|
||||
status: 'paused'
|
||||
} });
|
||||
|
||||
const row = useDownloadStore.getState().downloads[0];
|
||||
expect(row.status).toBe('paused');
|
||||
expect(row.fraction).toBe(0.8);
|
||||
expect(row.downloadedBytes).toBe(8192);
|
||||
expect(row.totalBytes).toBe(10240);
|
||||
expect(row.totalIsEstimate).toBe(true);
|
||||
expect(useDownloadProgressStore.getState().progressMap).toEqual({});
|
||||
release();
|
||||
});
|
||||
|
||||
it('drops a persisted temporary media estimate when fragmented progress has no total', async () => {
|
||||
const handlers: Record<string, (event: any) => void> = {};
|
||||
vi.mocked(ipc.listenEvent).mockImplementation((event, handler) => {
|
||||
handlers[event] = handler as (event: any) => void;
|
||||
return Promise.resolve(vi.fn());
|
||||
});
|
||||
useDownloadStore.setState({
|
||||
downloads: [{
|
||||
id: 'stale-media-estimate',
|
||||
url: 'https://youtube.com/watch?v=stale',
|
||||
fileName: 'video.mkv',
|
||||
status: 'downloading',
|
||||
category: 'Movies',
|
||||
dateAdded: '',
|
||||
isMedia: true,
|
||||
downloadedBytes: 11989,
|
||||
totalBytes: 1024,
|
||||
totalIsEstimate: true,
|
||||
size: '~85.7 MB'
|
||||
}]
|
||||
});
|
||||
|
||||
const release = await initDownloadListener();
|
||||
handlers['download-progress']({ payload: {
|
||||
id: 'stale-media-estimate',
|
||||
fraction: 0.38,
|
||||
speed: '2.7 MB/s',
|
||||
eta: '7s',
|
||||
size: null,
|
||||
size_is_final: false,
|
||||
downloaded_bytes: 13000,
|
||||
total_bytes: null,
|
||||
total_is_estimate: null
|
||||
} });
|
||||
|
||||
expect(useDownloadStore.getState().downloads[0]).toMatchObject({
|
||||
downloadedBytes: 13000,
|
||||
size: undefined
|
||||
});
|
||||
expect(useDownloadStore.getState().downloads[0].totalBytes).toBeUndefined();
|
||||
expect(useDownloadStore.getState().downloads[0].totalIsEstimate).toBeUndefined();
|
||||
release();
|
||||
});
|
||||
|
||||
it('removes a stale tiny media size after restart when byte counters were volatile', async () => {
|
||||
const handlers: Record<string, (event: any) => void> = {};
|
||||
vi.mocked(ipc.listenEvent).mockImplementation((event, handler) => {
|
||||
handlers[event] = handler as (event: any) => void;
|
||||
return Promise.resolve(vi.fn());
|
||||
});
|
||||
useDownloadStore.setState({
|
||||
downloads: [{
|
||||
id: 'stale-media-size',
|
||||
url: 'https://youtube.com/watch?v=stale-size',
|
||||
fileName: 'video.mkv',
|
||||
status: 'downloading',
|
||||
category: 'Movies',
|
||||
dateAdded: '',
|
||||
isMedia: true,
|
||||
size: '~1.00 KB'
|
||||
}]
|
||||
});
|
||||
|
||||
const release = await initDownloadListener();
|
||||
handlers['download-progress']({ payload: {
|
||||
id: 'stale-media-size',
|
||||
fraction: 0.01,
|
||||
speed: '2.7 MB/s',
|
||||
eta: '7s',
|
||||
size: null,
|
||||
size_is_final: false,
|
||||
downloaded_bytes: 2048,
|
||||
total_bytes: null,
|
||||
total_is_estimate: null
|
||||
} });
|
||||
|
||||
expect(useDownloadStore.getState().downloads[0].size).toBeUndefined();
|
||||
release();
|
||||
});
|
||||
|
||||
it('ignores stale active state events after pause but accepts terminal reconciliation', async () => {
|
||||
const handlers: Record<string, (event: any) => void> = {};
|
||||
vi.mocked(ipc.listenEvent).mockImplementation((event, handler) => {
|
||||
handlers[event] = handler as (event: any) => void;
|
||||
return Promise.resolve(vi.fn());
|
||||
});
|
||||
useDownloadStore.setState({
|
||||
downloads: [{
|
||||
id: 'paused-race',
|
||||
url: 'https://example.com/file',
|
||||
fileName: 'file.bin',
|
||||
status: 'paused',
|
||||
category: 'Other',
|
||||
dateAdded: ''
|
||||
}]
|
||||
});
|
||||
|
||||
const release = await initDownloadListener();
|
||||
handlers['download-state']({ payload: {
|
||||
id: 'paused-race',
|
||||
status: 'downloading'
|
||||
} });
|
||||
expect(useDownloadStore.getState().downloads[0].status).toBe('paused');
|
||||
|
||||
handlers['download-state']({ payload: {
|
||||
id: 'paused-race',
|
||||
status: 'completed'
|
||||
} });
|
||||
expect(useDownloadStore.getState().downloads[0].status).toBe('completed');
|
||||
release();
|
||||
});
|
||||
|
||||
it('ignores a stale paused event during resume and accepts the new active state', async () => {
|
||||
const handlers: Record<string, (event: any) => void> = {};
|
||||
vi.mocked(ipc.listenEvent).mockImplementation((event, handler) => {
|
||||
handlers[event] = handler as (event: any) => void;
|
||||
return Promise.resolve(vi.fn());
|
||||
});
|
||||
useDownloadStore.setState({
|
||||
downloads: [{
|
||||
id: 'resume-race',
|
||||
url: 'https://example.com/file',
|
||||
fileName: 'file.bin',
|
||||
status: 'queued',
|
||||
category: 'Other',
|
||||
dateAdded: ''
|
||||
}]
|
||||
});
|
||||
setDownloadControlIntent('resume-race', 'resume');
|
||||
|
||||
const release = await initDownloadListener();
|
||||
handlers['download-state']({ payload: {
|
||||
id: 'resume-race',
|
||||
status: 'paused'
|
||||
} });
|
||||
expect(useDownloadStore.getState().downloads[0].status).toBe('queued');
|
||||
|
||||
handlers['download-state']({ payload: {
|
||||
id: 'resume-race',
|
||||
status: 'downloading'
|
||||
} });
|
||||
expect(useDownloadStore.getState().downloads[0].status).toBe('downloading');
|
||||
release();
|
||||
});
|
||||
|
||||
it('allows a later genuine pause after consuming the stale resume event', async () => {
|
||||
const handlers: Record<string, (event: any) => void> = {};
|
||||
vi.mocked(ipc.listenEvent).mockImplementation((event, handler) => {
|
||||
handlers[event] = handler as (event: any) => void;
|
||||
return Promise.resolve(vi.fn());
|
||||
});
|
||||
useDownloadStore.setState({
|
||||
downloads: [{
|
||||
id: 'resume-pause-race',
|
||||
url: 'https://example.com/file',
|
||||
fileName: 'file.bin',
|
||||
status: 'queued',
|
||||
category: 'Other',
|
||||
dateAdded: ''
|
||||
}]
|
||||
});
|
||||
setDownloadControlIntent('resume-pause-race', 'resume');
|
||||
|
||||
const release = await initDownloadListener();
|
||||
handlers['download-state']({ payload: {
|
||||
id: 'resume-pause-race',
|
||||
status: 'paused'
|
||||
} });
|
||||
expect(useDownloadStore.getState().downloads[0].status).toBe('queued');
|
||||
|
||||
handlers['download-state']({ payload: {
|
||||
id: 'resume-pause-race',
|
||||
status: 'paused'
|
||||
} });
|
||||
expect(useDownloadStore.getState().downloads[0].status).toBe('paused');
|
||||
release();
|
||||
});
|
||||
});
|
||||
|
||||
+199
-95
@@ -1,50 +1,55 @@
|
||||
import { create } from 'zustand';
|
||||
import type { UnlistenFn } from '@tauri-apps/api/event';
|
||||
import type { DownloadProgressEvent } from '../bindings/DownloadProgressEvent';
|
||||
import type { DownloadStatus } from '../bindings/DownloadStatus';
|
||||
import { listenEvent as listen } from '../ipc';
|
||||
import type { DownloadItem } from '../bindings/DownloadItem';
|
||||
import { categoryForFileName } from '../utils/downloads';
|
||||
import { useDownloadProgressStore } from './downloadProgressStore';
|
||||
|
||||
interface DownloadProgressState {
|
||||
progressMap: Record<string, DownloadProgressEvent>;
|
||||
updateDownloadProgress: (id: string, payload: DownloadProgressEvent) => void;
|
||||
clearDownloadProgress: (id: string) => void;
|
||||
}
|
||||
import {
|
||||
clearDownloadControlIntent,
|
||||
downloadControlIntentFor,
|
||||
hasStaleTemporaryMediaEstimate,
|
||||
useDownloadStore
|
||||
} from './useDownloadStore';
|
||||
|
||||
import { useDownloadStore } from './useDownloadStore';
|
||||
|
||||
export const useDownloadProgressStore = create<DownloadProgressState>((set) => ({
|
||||
progressMap: {},
|
||||
updateDownloadProgress: (id, payload) =>
|
||||
set((state) => ({
|
||||
progressMap: {
|
||||
...state.progressMap,
|
||||
[id]: payload,
|
||||
},
|
||||
})),
|
||||
clearDownloadProgress: (id) =>
|
||||
set((state) => {
|
||||
if (!(id in state.progressMap)) return state;
|
||||
const next = { ...state.progressMap };
|
||||
delete next[id];
|
||||
return { progressMap: next };
|
||||
}),
|
||||
}));
|
||||
export { useDownloadProgressStore } from './downloadProgressStore';
|
||||
|
||||
let unlistenProgress: UnlistenFn | null = null;
|
||||
let unlistenState: UnlistenFn | null = null;
|
||||
let unlistenTray: UnlistenFn | null = null;
|
||||
let listenerSetup: Promise<void> | null = null;
|
||||
let listenerConsumers = 0;
|
||||
|
||||
export async function initDownloadListener() {
|
||||
if (unlistenProgress) return;
|
||||
unlistenProgress = await listen('download-progress', (event) => {
|
||||
const payload = event.payload;
|
||||
useDownloadProgressStore.getState().updateDownloadProgress(payload.id, payload);
|
||||
const disposeDownloadListeners = () => {
|
||||
unlistenProgress?.();
|
||||
unlistenProgress = null;
|
||||
unlistenState?.();
|
||||
unlistenState = null;
|
||||
unlistenTray?.();
|
||||
unlistenTray = null;
|
||||
listenerSetup = null;
|
||||
};
|
||||
|
||||
const mainStore = useDownloadStore.getState();
|
||||
const current = mainStore.downloads.find(d => d.id === payload.id);
|
||||
if (current) {
|
||||
const startDownloadListeners = async () => {
|
||||
const registrations = await Promise.allSettled([
|
||||
listen('download-progress', (event) => {
|
||||
const payload = event.payload;
|
||||
const mainStore = useDownloadStore.getState();
|
||||
const current = mainStore.downloads.find(d => d.id === payload.id);
|
||||
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') {
|
||||
@@ -55,88 +60,187 @@ export async function initDownloadListener() {
|
||||
if (shouldUpdateSize && current.size !== payload.size) {
|
||||
updates.size = payload.size!;
|
||||
}
|
||||
if (payload.downloaded_bytes !== null && payload.downloaded_bytes !== undefined) {
|
||||
updates.downloadedBytes = payload.downloaded_bytes;
|
||||
}
|
||||
if (payload.total_bytes !== null && payload.total_bytes !== undefined) {
|
||||
updates.totalBytes = payload.total_bytes;
|
||||
}
|
||||
if (payload.total_is_estimate !== null && payload.total_is_estimate !== undefined) {
|
||||
updates.totalIsEstimate = payload.total_is_estimate;
|
||||
}
|
||||
const observedDownloadedBytes = Math.max(
|
||||
current.downloadedBytes ?? 0,
|
||||
payload.downloaded_bytes ?? 0
|
||||
);
|
||||
// Older lifecycles may have persisted yt-dlp's temporary fragmented
|
||||
// estimate (often 1 KiB). Once actual bytes exceed it and the current
|
||||
// progress frame has no reliable total, discard that stale denominator
|
||||
// so it cannot survive a pause, queue transition, or app restart.
|
||||
if (payload.total_bytes == null && hasStaleTemporaryMediaEstimate({
|
||||
isMedia: current.isMedia,
|
||||
downloadedBytes: observedDownloadedBytes,
|
||||
totalBytes: current.totalBytes,
|
||||
totalIsEstimate: current.totalIsEstimate,
|
||||
size: current.size
|
||||
})) {
|
||||
updates.size = undefined;
|
||||
updates.totalBytes = undefined;
|
||||
updates.totalIsEstimate = undefined;
|
||||
}
|
||||
if (Object.keys(updates).length > 0) {
|
||||
mainStore.updateDownload(payload.id, updates);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (!unlistenState) {
|
||||
unlistenState = await listen('download-state', (event) => {
|
||||
}),
|
||||
listen('download-state', (event) => {
|
||||
const payload = event.payload;
|
||||
const mainStore = useDownloadStore.getState();
|
||||
const current = mainStore.downloads.find(d => d.id === payload.id);
|
||||
if (current) {
|
||||
const status = payload.status as DownloadStatus;
|
||||
|
||||
// Prevent race condition: don't transition backwards from terminal state
|
||||
if ((current.status === 'completed' || current.status === 'failed') &&
|
||||
(status !== 'completed' && status !== 'failed')) {
|
||||
return;
|
||||
}
|
||||
if (!current) {
|
||||
useDownloadProgressStore.getState().clearDownloadProgress(payload.id);
|
||||
return;
|
||||
}
|
||||
const status = payload.status as DownloadStatus;
|
||||
|
||||
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);
|
||||
// resume_download queues the row before the backend can emit its new
|
||||
// active state. A paused event already emitted by the old lifecycle may
|
||||
// arrive in that gap. Do not let it overwrite the queued transition;
|
||||
// otherwise the guard below would reject the legitimate downloading
|
||||
// event and leave the row visibly paused forever.
|
||||
if (status === 'paused' &&
|
||||
current.status === 'queued' &&
|
||||
downloadControlIntentFor(payload.id) === 'resume') {
|
||||
// Consume only the stale pause event that caused the resume race.
|
||||
// A later real pause must be allowed through even if the backend has
|
||||
// not emitted a new active state yet.
|
||||
clearDownloadControlIntent(payload.id, 'resume');
|
||||
return;
|
||||
}
|
||||
if (status === 'downloading' || status === 'processing' ||
|
||||
status === 'completed' || status === 'failed') {
|
||||
clearDownloadControlIntent(payload.id, 'resume');
|
||||
}
|
||||
if (status === 'paused') {
|
||||
clearDownloadControlIntent(payload.id, 'pause');
|
||||
}
|
||||
|
||||
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]);
|
||||
}
|
||||
}
|
||||
// Prevent stale lifecycle events from moving a paused row back into an
|
||||
// active state. A pause request can finish before one already-emitted
|
||||
// worker event reaches the frontend. Resume paths set the row to queued
|
||||
// before asking the backend to resume, so an active event arriving while
|
||||
// the row is still paused cannot represent a new lifecycle.
|
||||
if ((current.status === 'completed' || current.status === 'failed') &&
|
||||
status !== current.status) {
|
||||
return;
|
||||
}
|
||||
if (current.status === 'paused' &&
|
||||
status !== 'paused' &&
|
||||
status !== 'completed' &&
|
||||
status !== 'failed') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (status === '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);
|
||||
const progress = useDownloadProgressStore.getState().progressMap[payload.id];
|
||||
if (['queued', 'retrying', 'completed', 'failed', 'paused'].includes(status)) {
|
||||
useDownloadProgressStore.getState().clearDownloadProgress(payload.id);
|
||||
}
|
||||
const updates: Partial<DownloadItem> = {
|
||||
status,
|
||||
...(progress ? {
|
||||
fraction: progress.fraction,
|
||||
...(progress.downloaded_bytes != null
|
||||
? { downloadedBytes: progress.downloaded_bytes }
|
||||
: {}),
|
||||
...(progress.total_bytes != null
|
||||
? { totalBytes: progress.total_bytes }
|
||||
: {}),
|
||||
...(progress.total_is_estimate != null
|
||||
? { totalIsEstimate: progress.total_is_estimate }
|
||||
: {})
|
||||
} : {}),
|
||||
...(payload.error ? { lastError: payload.error } : {}),
|
||||
...((status === 'downloading' || status === 'retrying')
|
||||
? { lastTry: new Date().toISOString() }
|
||||
: {})
|
||||
};
|
||||
if (!payload.error && status !== 'failed' && status !== 'retrying') {
|
||||
updates.lastError = undefined;
|
||||
}
|
||||
if (payload.fileName && payload.fileName !== current.fileName) {
|
||||
updates.fileName = payload.fileName;
|
||||
updates.category = categoryForFileName(payload.fileName);
|
||||
}
|
||||
if (status !== 'downloading') {
|
||||
updates.speed = '-';
|
||||
updates.eta = '-';
|
||||
}
|
||||
mainStore.updateDownload(payload.id, updates);
|
||||
|
||||
if (status === '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 (!unlistenTray) {
|
||||
unlistenTray = await listen('tray-action', (event) => {
|
||||
if (status === 'queued' || status === 'downloading' || status === 'processing' || status === 'retrying') {
|
||||
mainStore.registerBackendIds([payload.id]);
|
||||
} else if (status === 'completed' || status === 'failed') {
|
||||
mainStore.unregisterBackendIds([payload.id]);
|
||||
}
|
||||
}),
|
||||
listen('tray-action', (event) => {
|
||||
const mainStore = useDownloadStore.getState();
|
||||
if (event.payload === 'pause-all') {
|
||||
void mainStore.pauseAll();
|
||||
} else if (event.payload === 'resume-all') {
|
||||
void mainStore.startAll();
|
||||
}
|
||||
}),
|
||||
]);
|
||||
|
||||
const failedRegistration = registrations.find(
|
||||
(registration): registration is PromiseRejectedResult => registration.status === 'rejected'
|
||||
);
|
||||
if (failedRegistration) {
|
||||
for (const registration of registrations) {
|
||||
if (registration.status === 'fulfilled') registration.value();
|
||||
}
|
||||
throw failedRegistration.reason;
|
||||
}
|
||||
|
||||
const [progress, state, tray] = registrations as [
|
||||
PromiseFulfilledResult<UnlistenFn>,
|
||||
PromiseFulfilledResult<UnlistenFn>,
|
||||
PromiseFulfilledResult<UnlistenFn>,
|
||||
];
|
||||
unlistenProgress = progress.value;
|
||||
unlistenState = state.value;
|
||||
unlistenTray = tray.value;
|
||||
};
|
||||
|
||||
export async function initDownloadListener(): Promise<() => void> {
|
||||
listenerConsumers += 1;
|
||||
if (!listenerSetup) {
|
||||
listenerSetup = startDownloadListeners().catch(error => {
|
||||
disposeDownloadListeners();
|
||||
throw error;
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
await listenerSetup;
|
||||
} catch (error) {
|
||||
listenerConsumers -= 1;
|
||||
throw error;
|
||||
}
|
||||
|
||||
let released = false;
|
||||
return () => {
|
||||
if (unlistenProgress) {
|
||||
unlistenProgress();
|
||||
unlistenProgress = null;
|
||||
}
|
||||
if (unlistenState) {
|
||||
unlistenState();
|
||||
unlistenState = null;
|
||||
}
|
||||
if (unlistenTray) {
|
||||
unlistenTray();
|
||||
unlistenTray = null;
|
||||
}
|
||||
if (released) return;
|
||||
released = true;
|
||||
listenerConsumers -= 1;
|
||||
if (listenerConsumers === 0) disposeDownloadListeners();
|
||||
};
|
||||
}
|
||||
|
||||
+1074
-13
File diff suppressed because it is too large
Load Diff
+903
-241
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,182 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
runSettingsPersistenceTransaction,
|
||||
subscribeToSettingsPersistenceErrors,
|
||||
useSettingsStore
|
||||
} from './useSettingsStore';
|
||||
import * as ipc from '../ipc';
|
||||
import type { PairingTokenHydration } from '../bindings/PairingTokenHydration';
|
||||
|
||||
vi.mock('../ipc', () => ({
|
||||
invokeCommand: vi.fn()
|
||||
}));
|
||||
|
||||
vi.mock('../utils/logger', () => ({
|
||||
info: vi.fn()
|
||||
}));
|
||||
|
||||
describe('useSettingsStore global speed limit persistence', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
useSettingsStore.setState({ globalSpeedLimit: '2M' });
|
||||
});
|
||||
|
||||
it('keeps the saved value when the backend rejects a limit change', async () => {
|
||||
vi.mocked(ipc.invokeCommand).mockRejectedValueOnce(new Error('aria2 unavailable'));
|
||||
|
||||
await expect(useSettingsStore.getState().setGlobalSpeedLimit('3M')).rejects.toThrow('aria2 unavailable');
|
||||
|
||||
expect(useSettingsStore.getState().globalSpeedLimit).toBe('2M');
|
||||
expect(ipc.invokeCommand).toHaveBeenCalledWith('set_global_speed_limit', { limit: '3M' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('useSettingsStore credential-store startup flow', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
useSettingsStore.setState({
|
||||
extensionPairingToken: '',
|
||||
isPairingTokenPersistent: false,
|
||||
keychainAccessGranted: false,
|
||||
keychainAccessVersion: '',
|
||||
keychainAccessReady: false,
|
||||
keychainPromptDismissed: false,
|
||||
showKeychainModal: false
|
||||
});
|
||||
});
|
||||
|
||||
it('loads the session pairing token without invoking the credential store', async () => {
|
||||
vi.mocked(ipc.invokeCommand).mockResolvedValueOnce({
|
||||
token: 'session-token',
|
||||
tokenChanged: false,
|
||||
persistent: false,
|
||||
error: null
|
||||
});
|
||||
|
||||
await useSettingsStore.getState().hydrateSessionPairingToken();
|
||||
|
||||
expect(ipc.invokeCommand).toHaveBeenCalledWith('get_session_pairing_token');
|
||||
expect(useSettingsStore.getState().extensionPairingToken).toBe('session-token');
|
||||
expect(useSettingsStore.getState().isPairingTokenPersistent).toBe(false);
|
||||
});
|
||||
|
||||
it('clears the approved startup state when the user defers credential access', () => {
|
||||
useSettingsStore.setState({ keychainAccessGranted: true });
|
||||
|
||||
useSettingsStore.getState().dismissKeychainPrompt('1.0.5');
|
||||
|
||||
expect(useSettingsStore.getState().keychainAccessGranted).toBe(false);
|
||||
expect(useSettingsStore.getState().keychainAccessReady).toBe(false);
|
||||
expect(useSettingsStore.getState().keychainAccessVersion).toBe('1.0.5');
|
||||
expect(useSettingsStore.getState().keychainPromptDismissed).toBe(true);
|
||||
});
|
||||
|
||||
it('opens the consent modal instead of regenerating through the credential store', async () => {
|
||||
await expect(useSettingsStore.getState().regeneratePairingToken())
|
||||
.rejects.toThrow('Grant credential-store access before regenerating the pairing token.');
|
||||
|
||||
expect(ipc.invokeCommand).not.toHaveBeenCalledWith('regenerate_pairing_token');
|
||||
expect(useSettingsStore.getState().showKeychainModal).toBe(true);
|
||||
});
|
||||
|
||||
it('does not apply pairing hydration after startup becomes inactive', async () => {
|
||||
vi.mocked(ipc.invokeCommand).mockResolvedValueOnce({
|
||||
token: 'stale-token',
|
||||
tokenChanged: true,
|
||||
persistent: true,
|
||||
error: null
|
||||
});
|
||||
|
||||
await expect(useSettingsStore.getState().hydratePairingToken(() => false)).resolves.toBe(false);
|
||||
|
||||
expect(ipc.invokeCommand).toHaveBeenCalledWith('hydrate_extension_pairing_token');
|
||||
expect(useSettingsStore.getState().extensionPairingToken).toBe('');
|
||||
expect(useSettingsStore.getState().isPairingTokenPersistent).toBe(false);
|
||||
});
|
||||
|
||||
it('does not apply session hydration after startup becomes inactive', async () => {
|
||||
vi.mocked(ipc.invokeCommand).mockResolvedValueOnce({
|
||||
token: 'stale-session-token',
|
||||
tokenChanged: false,
|
||||
persistent: false,
|
||||
error: null
|
||||
});
|
||||
|
||||
await useSettingsStore.getState().hydrateSessionPairingToken(() => false);
|
||||
|
||||
expect(ipc.invokeCommand).toHaveBeenCalledWith('get_session_pairing_token');
|
||||
expect(useSettingsStore.getState().extensionPairingToken).toBe('');
|
||||
expect(useSettingsStore.getState().isPairingTokenPersistent).toBe(false);
|
||||
});
|
||||
|
||||
it('shares a concurrent pairing hydration request', async () => {
|
||||
let resolveRequest!: (value: PairingTokenHydration) => void;
|
||||
const request = new Promise<PairingTokenHydration>(resolve => {
|
||||
resolveRequest = resolve;
|
||||
});
|
||||
let hydrationRequestCount = 0;
|
||||
vi.mocked(ipc.invokeCommand).mockImplementation(async (command: string) => {
|
||||
if (command === 'hydrate_extension_pairing_token') {
|
||||
hydrationRequestCount += 1;
|
||||
return request;
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
|
||||
const first = useSettingsStore.getState().hydratePairingToken();
|
||||
const second = useSettingsStore.getState().hydratePairingToken();
|
||||
|
||||
expect(hydrationRequestCount).toBe(1);
|
||||
resolveRequest({
|
||||
token: 'shared-token',
|
||||
tokenChanged: false,
|
||||
persistent: true,
|
||||
error: null
|
||||
});
|
||||
await Promise.all([first, second]);
|
||||
|
||||
expect(useSettingsStore.getState().extensionPairingToken).toBe('shared-token');
|
||||
expect(useSettingsStore.getState().isPairingTokenPersistent).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('useSettingsStore persistence failures', () => {
|
||||
it('keeps settings writes queued behind a credential transaction', async () => {
|
||||
const events: string[] = [];
|
||||
vi.mocked(ipc.invokeCommand).mockImplementation(async command => {
|
||||
if (command === 'db_save_settings') events.push('settings-write');
|
||||
return undefined;
|
||||
});
|
||||
|
||||
await runSettingsPersistenceTransaction(async () => {
|
||||
events.push('transaction-start');
|
||||
useSettingsStore.setState({ theme: 'dark' });
|
||||
events.push('transaction-end');
|
||||
});
|
||||
await new Promise(resolve => setTimeout(resolve, 0));
|
||||
|
||||
expect(events.slice(0, 2)).toEqual(['transaction-start', 'transaction-end']);
|
||||
expect(events).toContain('settings-write');
|
||||
});
|
||||
|
||||
it('reports a database save failure and retries the next settings update', async () => {
|
||||
vi.clearAllMocks();
|
||||
await new Promise(resolve => setTimeout(resolve, 0));
|
||||
|
||||
const onPersistenceError = vi.fn();
|
||||
const unsubscribe = subscribeToSettingsPersistenceErrors(onPersistenceError);
|
||||
vi.mocked(ipc.invokeCommand).mockRejectedValueOnce(new Error('database unavailable'));
|
||||
|
||||
useSettingsStore.setState({ theme: 'dark' });
|
||||
await new Promise(resolve => setTimeout(resolve, 0));
|
||||
|
||||
expect(onPersistenceError).toHaveBeenCalledTimes(1);
|
||||
|
||||
vi.mocked(ipc.invokeCommand).mockResolvedValueOnce(undefined);
|
||||
useSettingsStore.setState({ theme: 'light' });
|
||||
await new Promise(resolve => setTimeout(resolve, 0));
|
||||
|
||||
expect(onPersistenceError).toHaveBeenCalledTimes(1);
|
||||
unsubscribe();
|
||||
});
|
||||
});
|
||||
+251
-61
@@ -8,6 +8,7 @@ import type { ListRowDensity } from '../bindings/ListRowDensity';
|
||||
import type { MediaCookieSource } from '../bindings/MediaCookieSource';
|
||||
import type { PostQueueAction } from '../bindings/PostQueueAction';
|
||||
import type { PersistedSettings } from '../bindings/PersistedSettings';
|
||||
import type { PairingTokenHydration } from '../bindings/PairingTokenHydration';
|
||||
import type { ProxyMode } from '../bindings/ProxyMode';
|
||||
import type { SchedulerSettings } from '../bindings/SchedulerSettings';
|
||||
import type { SettingsTab } from '../bindings/SettingsTab';
|
||||
@@ -17,11 +18,98 @@ import {
|
||||
DEFAULT_CATEGORY_SUBFOLDERS,
|
||||
normalizeDownloadLocationSettings
|
||||
} from '../utils/downloadLocations';
|
||||
import { normalizeSpeedLimitForBackend } from '../utils/downloads';
|
||||
|
||||
let settingsSave = Promise.resolve();
|
||||
let settingsQueue: Promise<void> = Promise.resolve();
|
||||
let pairingTokenHydrationRequest: Promise<PairingTokenHydration> | null = null;
|
||||
const settingsPersistenceErrorListeners = new Set<() => void>();
|
||||
let settingsPersistenceFailed = false;
|
||||
const DEFAULT_SCHEDULER_QUEUE_ID = '00000000-0000-0000-0000-000000000001';
|
||||
export const DEFAULT_SPEED_LIMIT_PRESET_VALUES = [1, 5, 10];
|
||||
|
||||
export const subscribeToSettingsPersistenceErrors = (listener: () => void): (() => void) => {
|
||||
settingsPersistenceErrorListeners.add(listener);
|
||||
if (settingsPersistenceFailed) listener();
|
||||
return () => settingsPersistenceErrorListeners.delete(listener);
|
||||
};
|
||||
|
||||
const enqueueSettingsTask = <T>(task: () => Promise<T>): Promise<T> => {
|
||||
const result = settingsQueue.then(task, task);
|
||||
settingsQueue = result.then(() => undefined, () => undefined);
|
||||
return result;
|
||||
};
|
||||
|
||||
const requestPairingTokenHydration = (): Promise<PairingTokenHydration> => {
|
||||
if (!pairingTokenHydrationRequest) {
|
||||
pairingTokenHydrationRequest = invoke('hydrate_extension_pairing_token')
|
||||
.finally(() => {
|
||||
pairingTokenHydrationRequest = null;
|
||||
});
|
||||
}
|
||||
return pairingTokenHydrationRequest;
|
||||
};
|
||||
|
||||
export const runSettingsPersistenceTransaction = <T>(
|
||||
operation: () => Promise<T>
|
||||
): Promise<T> => enqueueSettingsTask(operation);
|
||||
|
||||
const notifySettingsPersistenceError = () => {
|
||||
if (settingsPersistenceFailed) return;
|
||||
settingsPersistenceFailed = true;
|
||||
for (const listener of settingsPersistenceErrorListeners) {
|
||||
try {
|
||||
listener();
|
||||
} catch (error) {
|
||||
console.error('Settings persistence error listener failed', error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const THEME_VALUES = ['system', 'light', 'dark', 'dracula', 'nord'] as const;
|
||||
const APP_FONT_SIZE_VALUES = ['small', 'standard', 'large'] as const;
|
||||
const LIST_ROW_DENSITY_VALUES = ['compact', 'standard', 'relaxed'] as const;
|
||||
const PROXY_MODE_VALUES = ['none', 'system', 'custom'] as const;
|
||||
const MEDIA_COOKIE_SOURCE_VALUES = [
|
||||
'none', 'safari', 'chrome', 'chromium', 'firefox', 'edge', 'brave', 'opera', 'vivaldi', 'whale'
|
||||
] as const;
|
||||
const SETTINGS_TAB_VALUES = [
|
||||
'downloads', 'lookandfeel', 'network', 'locations', 'sitelogins', 'power', 'engine', 'integrations', 'about'
|
||||
] as const;
|
||||
|
||||
type PersistedSettingsSnapshot = PersistedSettings & {
|
||||
keychainPromptDismissed: boolean;
|
||||
keychainAccessVersion: string;
|
||||
};
|
||||
|
||||
const clampSettingInteger = (
|
||||
value: unknown,
|
||||
minimum: number,
|
||||
maximum: number,
|
||||
fallback: number
|
||||
) => {
|
||||
const numeric = typeof value === 'number' ? value : Number(value);
|
||||
if (!Number.isFinite(numeric)) return fallback;
|
||||
return Math.min(maximum, Math.max(minimum, Math.trunc(numeric)));
|
||||
};
|
||||
|
||||
const isAllowedSetting = <T extends string>(values: readonly T[], value: unknown): value is T =>
|
||||
typeof value === 'string' && values.includes(value as T);
|
||||
|
||||
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
||||
value !== null && typeof value === 'object' && !Array.isArray(value);
|
||||
|
||||
const sanitizeSiteLogins = (value: unknown): SiteLogin[] => {
|
||||
if (!Array.isArray(value)) return [];
|
||||
return value.filter(isRecord).filter((login): login is SiteLogin =>
|
||||
typeof login.id === 'string'
|
||||
&& typeof login.urlPattern === 'string'
|
||||
&& typeof login.username === 'string'
|
||||
);
|
||||
};
|
||||
|
||||
const persistedBoolean = (value: unknown, fallback: boolean) =>
|
||||
typeof value === 'boolean' ? value : fallback;
|
||||
|
||||
const tauriStorage: StateStorage = {
|
||||
getItem: async (name: string): Promise<string | null> => {
|
||||
if (name === 'firelink-settings') {
|
||||
@@ -36,12 +124,15 @@ const tauriStorage: StateStorage = {
|
||||
},
|
||||
setItem: async (name: string, value: string): Promise<void> => {
|
||||
if (name === 'firelink-settings') {
|
||||
settingsSave = settingsSave
|
||||
.then(() => invoke('db_save_settings', { data: value }))
|
||||
.catch(e => {
|
||||
console.error("Failed to save settings to DB", e);
|
||||
});
|
||||
await settingsSave;
|
||||
await enqueueSettingsTask(async () => {
|
||||
try {
|
||||
await invoke('db_save_settings', { data: value });
|
||||
settingsPersistenceFailed = false;
|
||||
} catch {
|
||||
console.error('Failed to save settings to DB');
|
||||
notifySettingsPersistenceError();
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
removeItem: async (_name: string): Promise<void> => {
|
||||
@@ -53,10 +144,10 @@ const tauriStorage: StateStorage = {
|
||||
* Keychain identifier for the browser-extension pairing token. The token is an
|
||||
* HMAC shared secret and is therefore persisted via the OS keychain rather
|
||||
* than the user-data database. Legacy plaintext values are migrated into the
|
||||
* Keychain before being removed from persisted settings.
|
||||
* Keychain before being removed from persisted settings. Portable mode is the
|
||||
* explicit exception: its pairing token is persisted with the portable folder
|
||||
* so extension pairing follows that folder.
|
||||
*/
|
||||
const PAIRING_TOKEN_KEYCHAIN_ID = 'extension-pairing-token';
|
||||
|
||||
export type {
|
||||
ActiveView,
|
||||
AppFontSize,
|
||||
@@ -90,12 +181,14 @@ export interface SettingsState {
|
||||
schedulerLastStartKey: string;
|
||||
schedulerLastStopKey: string;
|
||||
lastCustomSpeedLimitKiB: number;
|
||||
lastCustomSpeedLimitUnit: string;
|
||||
|
||||
// Replicated SwiftUI App Settings
|
||||
perServerConnections: number;
|
||||
maxAutomaticRetries: number;
|
||||
showNotifications: boolean;
|
||||
playCompletionSound: boolean;
|
||||
autoAddClipboardLinks: boolean;
|
||||
appFontSize: AppFontSize;
|
||||
listRowDensity: ListRowDensity;
|
||||
showDockBadge: boolean;
|
||||
@@ -111,6 +204,9 @@ export interface SettingsState {
|
||||
extensionPairingToken: string;
|
||||
isPairingTokenPersistent: boolean;
|
||||
keychainAccessGranted: boolean;
|
||||
keychainAccessVersion: string;
|
||||
keychainAccessReady: boolean;
|
||||
keychainPromptDismissed: boolean;
|
||||
autoCheckUpdates: boolean;
|
||||
showKeychainModal: boolean;
|
||||
|
||||
@@ -118,7 +214,7 @@ export interface SettingsState {
|
||||
setBaseDownloadFolder: (path: string) => void;
|
||||
approveDownloadRoot: (path: string) => Promise<string>;
|
||||
setMaxConcurrentDownloads: (count: number) => void;
|
||||
setGlobalSpeedLimit: (limit: string) => void;
|
||||
setGlobalSpeedLimit: (limit: string) => Promise<void>;
|
||||
setSpeedLimitPresetValues: (values: number[]) => void;
|
||||
setLogsEnabled: (enabled: boolean) => void;
|
||||
setActiveView: (view: ActiveView) => void;
|
||||
@@ -129,12 +225,14 @@ export interface SettingsState {
|
||||
setSchedulerLastStartKey: (key: string) => void;
|
||||
setSchedulerLastStopKey: (key: string) => void;
|
||||
setLastCustomSpeedLimitKiB: (limit: number) => void;
|
||||
setLastCustomSpeedLimitUnit: (unit: string) => void;
|
||||
toggleSidebar: () => void;
|
||||
|
||||
setPerServerConnections: (count: number) => void;
|
||||
setMaxAutomaticRetries: (count: number) => void;
|
||||
setShowNotifications: (show: boolean) => void;
|
||||
setPlayCompletionSound: (play: boolean) => void;
|
||||
setAutoAddClipboardLinks: (enabled: boolean) => void;
|
||||
setAppFontSize: (size: AppFontSize) => void;
|
||||
setListRowDensity: (density: ListRowDensity) => void;
|
||||
setShowDockBadge: (show: boolean) => void;
|
||||
@@ -154,39 +252,16 @@ export interface SettingsState {
|
||||
removeSiteLogin: (id: string) => void;
|
||||
regeneratePairingToken: () => Promise<void>;
|
||||
setAutoCheckUpdates: (autoCheckUpdates: boolean) => void;
|
||||
hydratePairingToken: () => Promise<boolean>;
|
||||
hydratePairingToken: (isCurrent?: () => boolean) => Promise<boolean>;
|
||||
setShowKeychainModal: (show: boolean) => void;
|
||||
setKeychainAccessReady: (ready: boolean) => void;
|
||||
dismissKeychainPrompt: (version?: string) => void;
|
||||
hydrateSessionPairingToken: (isCurrent?: () => boolean) => Promise<void>;
|
||||
}
|
||||
|
||||
const generateSecureToken = () => {
|
||||
try {
|
||||
const cryptoObj = typeof window !== 'undefined'
|
||||
? (window as Window & { msCrypto?: Crypto }).crypto
|
||||
|| (window as Window & { msCrypto?: Crypto }).msCrypto
|
||||
: null;
|
||||
if (cryptoObj && cryptoObj.getRandomValues) {
|
||||
const arr = new Uint8Array(24);
|
||||
cryptoObj.getRandomValues(arr);
|
||||
let binary = '';
|
||||
for (let i = 0; i < arr.byteLength; i++) {
|
||||
binary += String.fromCharCode(arr[i]);
|
||||
}
|
||||
return btoa(binary);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("Secure token generation failed, falling back to random characters", e);
|
||||
}
|
||||
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
|
||||
let token = '';
|
||||
for (let i = 0; i < 32; i++) {
|
||||
token += chars.charAt(Math.floor(Math.random() * chars.length));
|
||||
}
|
||||
return token;
|
||||
};
|
||||
|
||||
export const useSettingsStore = create<SettingsState>()(
|
||||
persist(
|
||||
(set, _get) => ({
|
||||
(set, get) => ({
|
||||
theme: 'system',
|
||||
baseDownloadFolder: '~/Downloads',
|
||||
categorySubfoldersEnabled: true,
|
||||
@@ -215,12 +290,14 @@ export const useSettingsStore = create<SettingsState>()(
|
||||
schedulerLastStartKey: '',
|
||||
schedulerLastStopKey: '',
|
||||
lastCustomSpeedLimitKiB: 1024,
|
||||
lastCustomSpeedLimitUnit: 'MB/s',
|
||||
|
||||
// Replicated SwiftUI defaults
|
||||
perServerConnections: 16,
|
||||
maxAutomaticRetries: 3,
|
||||
showNotifications: true,
|
||||
playCompletionSound: false,
|
||||
autoAddClipboardLinks: false,
|
||||
appFontSize: 'standard',
|
||||
listRowDensity: 'standard',
|
||||
showDockBadge: true,
|
||||
@@ -234,8 +311,11 @@ export const useSettingsStore = create<SettingsState>()(
|
||||
mediaCookieSource: 'none',
|
||||
siteLogins: [],
|
||||
extensionPairingToken: '',
|
||||
isPairingTokenPersistent: true,
|
||||
isPairingTokenPersistent: false,
|
||||
keychainAccessGranted: false,
|
||||
keychainAccessVersion: '',
|
||||
keychainAccessReady: false,
|
||||
keychainPromptDismissed: false,
|
||||
autoCheckUpdates: true,
|
||||
showKeychainModal: false,
|
||||
|
||||
@@ -255,9 +335,14 @@ export const useSettingsStore = create<SettingsState>()(
|
||||
},
|
||||
setMaxConcurrentDownloads: (max) => {
|
||||
info('Settings updated: maxConcurrentDownloads');
|
||||
set({ maxConcurrentDownloads: max });
|
||||
set({
|
||||
maxConcurrentDownloads: clampSettingInteger(max, 1, 12, 3)
|
||||
});
|
||||
},
|
||||
setGlobalSpeedLimit: (limit) => {
|
||||
setGlobalSpeedLimit: async (limit) => {
|
||||
await invoke('set_global_speed_limit', {
|
||||
limit: normalizeSpeedLimitForBackend(limit)
|
||||
});
|
||||
info('Settings updated: globalSpeedLimit');
|
||||
set({ globalSpeedLimit: limit });
|
||||
},
|
||||
@@ -271,12 +356,18 @@ export const useSettingsStore = create<SettingsState>()(
|
||||
setSchedulerLastStartKey: (schedulerLastStartKey) => set({ schedulerLastStartKey }),
|
||||
setSchedulerLastStopKey: (schedulerLastStopKey) => set({ schedulerLastStopKey }),
|
||||
setLastCustomSpeedLimitKiB: (lastCustomSpeedLimitKiB) => set({ lastCustomSpeedLimitKiB }),
|
||||
setLastCustomSpeedLimitUnit: (lastCustomSpeedLimitUnit) => set({ lastCustomSpeedLimitUnit }),
|
||||
toggleSidebar: () => set((state) => ({ isSidebarVisible: !state.isSidebarVisible })),
|
||||
|
||||
setPerServerConnections: (perServerConnections) => set({ perServerConnections }),
|
||||
setMaxAutomaticRetries: (maxAutomaticRetries) => set({ maxAutomaticRetries }),
|
||||
setPerServerConnections: (perServerConnections) => set({
|
||||
perServerConnections: clampSettingInteger(perServerConnections, 1, 16, 16)
|
||||
}),
|
||||
setMaxAutomaticRetries: (maxAutomaticRetries) => set({
|
||||
maxAutomaticRetries: clampSettingInteger(maxAutomaticRetries, 0, 10, 3)
|
||||
}),
|
||||
setShowNotifications: (showNotifications) => set({ showNotifications }),
|
||||
setPlayCompletionSound: (playCompletionSound) => set({ playCompletionSound }),
|
||||
setAutoAddClipboardLinks: (autoAddClipboardLinks) => set({ autoAddClipboardLinks }),
|
||||
setAppFontSize: (appFontSize) => set({ appFontSize }),
|
||||
setListRowDensity: (listRowDensity) => set({ listRowDensity }),
|
||||
setShowDockBadge: (showDockBadge) => {
|
||||
@@ -331,26 +422,54 @@ export const useSettingsStore = create<SettingsState>()(
|
||||
siteLogins: state.siteLogins.filter((login) => login.id !== id)
|
||||
})),
|
||||
regeneratePairingToken: async () => {
|
||||
const token = generateSecureToken();
|
||||
await invoke('set_keychain_password', { id: PAIRING_TOKEN_KEYCHAIN_ID, password: token });
|
||||
set({ extensionPairingToken: token });
|
||||
const current = get();
|
||||
if (!current.keychainAccessReady && !current.isPairingTokenPersistent) {
|
||||
set({ showKeychainModal: true });
|
||||
throw new Error('Grant credential-store access before regenerating the pairing token.');
|
||||
}
|
||||
const result = await invoke('regenerate_pairing_token');
|
||||
if (!result.persistent) {
|
||||
throw new Error(result.error || 'Credential store access is unavailable.');
|
||||
}
|
||||
set({
|
||||
extensionPairingToken: result.token,
|
||||
isPairingTokenPersistent: true,
|
||||
showKeychainModal: false
|
||||
});
|
||||
},
|
||||
hydratePairingToken: async () => {
|
||||
// Always use the safe hydration path that never touches the OS keychain
|
||||
// on its own. The modal will be shown when needed and only the explicit
|
||||
// "Grant Access" action (→ grant_keychain_access) triggers the OS prompt.
|
||||
const result = await invoke('hydrate_extension_pairing_token');
|
||||
hydratePairingToken: async (isCurrent) => {
|
||||
// The backend migrates legacy settings copies and reads the token from
|
||||
// the credential store after the app state is ready to receive it.
|
||||
// Portable mode remains the explicit folder-contained exception.
|
||||
const result = await requestPairingTokenHydration();
|
||||
if (isCurrent && !isCurrent()) return false;
|
||||
set({
|
||||
extensionPairingToken: result.token,
|
||||
isPairingTokenPersistent: result.persistent
|
||||
isPairingTokenPersistent: result.persistent,
|
||||
showKeychainModal: !result.persistent && !get().keychainPromptDismissed
|
||||
});
|
||||
if (!result.persistent) {
|
||||
set({ showKeychainModal: true });
|
||||
}
|
||||
return result.tokenChanged;
|
||||
},
|
||||
hydrateSessionPairingToken: async (isCurrent) => {
|
||||
const result = await invoke('get_session_pairing_token');
|
||||
if (isCurrent && !isCurrent()) return;
|
||||
set({
|
||||
extensionPairingToken: result.token,
|
||||
isPairingTokenPersistent: false,
|
||||
keychainAccessReady: false
|
||||
});
|
||||
},
|
||||
setAutoCheckUpdates: (autoCheckUpdates: boolean) => set({ autoCheckUpdates }),
|
||||
setShowKeychainModal: (show: boolean) => set({ showKeychainModal: show }),
|
||||
setKeychainAccessReady: (ready: boolean) => set({ keychainAccessReady: ready }),
|
||||
dismissKeychainPrompt: (version?: string) => set(state => ({
|
||||
keychainAccessGranted: false,
|
||||
isPairingTokenPersistent: false,
|
||||
keychainAccessReady: false,
|
||||
keychainAccessVersion: version || state.keychainAccessVersion,
|
||||
keychainPromptDismissed: true,
|
||||
showKeychainModal: false
|
||||
})),
|
||||
}),
|
||||
{
|
||||
name: 'firelink-settings',
|
||||
@@ -389,7 +508,7 @@ export const useSettingsStore = create<SettingsState>()(
|
||||
logsEnabled: persisted.logsEnabled === true
|
||||
} as SettingsState;
|
||||
},
|
||||
partialize: (state): PersistedSettings => ({
|
||||
partialize: (state): PersistedSettingsSnapshot => ({
|
||||
theme: state.theme,
|
||||
baseDownloadFolder: state.baseDownloadFolder,
|
||||
categorySubfoldersEnabled: state.categorySubfoldersEnabled,
|
||||
@@ -408,11 +527,13 @@ export const useSettingsStore = create<SettingsState>()(
|
||||
schedulerLastStartKey: state.schedulerLastStartKey,
|
||||
schedulerLastStopKey: state.schedulerLastStopKey,
|
||||
lastCustomSpeedLimitKiB: state.lastCustomSpeedLimitKiB,
|
||||
lastCustomSpeedLimitUnit: state.lastCustomSpeedLimitUnit,
|
||||
|
||||
perServerConnections: state.perServerConnections,
|
||||
maxAutomaticRetries: state.maxAutomaticRetries,
|
||||
showNotifications: state.showNotifications,
|
||||
playCompletionSound: state.playCompletionSound,
|
||||
autoAddClipboardLinks: state.autoAddClipboardLinks,
|
||||
appFontSize: state.appFontSize,
|
||||
listRowDensity: state.listRowDensity,
|
||||
showDockBadge: state.showDockBadge,
|
||||
@@ -425,8 +546,9 @@ export const useSettingsStore = create<SettingsState>()(
|
||||
preventsSleepWhileDownloading: state.preventsSleepWhileDownloading,
|
||||
mediaCookieSource: state.mediaCookieSource,
|
||||
siteLogins: state.siteLogins,
|
||||
extensionPairingToken: state.extensionPairingToken,
|
||||
keychainAccessGranted: state.keychainAccessGranted,
|
||||
keychainAccessVersion: state.keychainAccessVersion,
|
||||
keychainPromptDismissed: state.keychainPromptDismissed,
|
||||
autoCheckUpdates: state.autoCheckUpdates
|
||||
}),
|
||||
merge: (persistedState: unknown, currentState) => {
|
||||
@@ -438,9 +560,79 @@ export const useSettingsStore = create<SettingsState>()(
|
||||
...currentState,
|
||||
...persisted,
|
||||
...locations,
|
||||
extensionPairingToken: currentState.extensionPairingToken,
|
||||
keychainAccessReady: currentState.keychainAccessReady,
|
||||
theme: isAllowedSetting(THEME_VALUES, persisted.theme)
|
||||
? persisted.theme
|
||||
: currentState.theme,
|
||||
appFontSize: isAllowedSetting(APP_FONT_SIZE_VALUES, persisted.appFontSize)
|
||||
? persisted.appFontSize
|
||||
: currentState.appFontSize,
|
||||
listRowDensity: isAllowedSetting(LIST_ROW_DENSITY_VALUES, persisted.listRowDensity)
|
||||
? persisted.listRowDensity
|
||||
: currentState.listRowDensity,
|
||||
proxyMode: isAllowedSetting(PROXY_MODE_VALUES, persisted.proxyMode)
|
||||
? persisted.proxyMode
|
||||
: currentState.proxyMode,
|
||||
mediaCookieSource: isAllowedSetting(MEDIA_COOKIE_SOURCE_VALUES, persisted.mediaCookieSource)
|
||||
? persisted.mediaCookieSource
|
||||
: 'none',
|
||||
activeSettingsTab: isAllowedSetting(SETTINGS_TAB_VALUES, persisted.activeSettingsTab)
|
||||
? persisted.activeSettingsTab
|
||||
: currentState.activeSettingsTab,
|
||||
showNotifications: persistedBoolean(persisted.showNotifications, currentState.showNotifications),
|
||||
playCompletionSound: persistedBoolean(persisted.playCompletionSound, currentState.playCompletionSound),
|
||||
autoAddClipboardLinks: persistedBoolean(
|
||||
persisted.autoAddClipboardLinks,
|
||||
currentState.autoAddClipboardLinks
|
||||
),
|
||||
showDockBadge: persistedBoolean(persisted.showDockBadge, currentState.showDockBadge),
|
||||
showMenuBarIcon: persistedBoolean(persisted.showMenuBarIcon, currentState.showMenuBarIcon),
|
||||
askWhereToSaveEachFile: persistedBoolean(
|
||||
persisted.askWhereToSaveEachFile,
|
||||
currentState.askWhereToSaveEachFile
|
||||
),
|
||||
preventsSleepWhileDownloading: persistedBoolean(
|
||||
persisted.preventsSleepWhileDownloading,
|
||||
currentState.preventsSleepWhileDownloading
|
||||
),
|
||||
keychainAccessGranted: persistedBoolean(
|
||||
persisted.keychainAccessGranted,
|
||||
currentState.keychainAccessGranted
|
||||
),
|
||||
keychainAccessVersion: typeof persisted.keychainAccessVersion === 'string'
|
||||
? persisted.keychainAccessVersion
|
||||
: currentState.keychainAccessVersion,
|
||||
keychainPromptDismissed: persistedBoolean(
|
||||
persisted.keychainPromptDismissed,
|
||||
currentState.keychainPromptDismissed
|
||||
),
|
||||
autoCheckUpdates: persistedBoolean(persisted.autoCheckUpdates, currentState.autoCheckUpdates),
|
||||
maxConcurrentDownloads: clampSettingInteger(
|
||||
persisted.maxConcurrentDownloads,
|
||||
1,
|
||||
12,
|
||||
currentState.maxConcurrentDownloads
|
||||
),
|
||||
perServerConnections: clampSettingInteger(
|
||||
persisted.perServerConnections,
|
||||
1,
|
||||
16,
|
||||
currentState.perServerConnections
|
||||
),
|
||||
maxAutomaticRetries: clampSettingInteger(
|
||||
persisted.maxAutomaticRetries,
|
||||
0,
|
||||
10,
|
||||
currentState.maxAutomaticRetries
|
||||
),
|
||||
speedLimitPresetValues: Array.isArray(persisted.speedLimitPresetValues)
|
||||
? persisted.speedLimitPresetValues
|
||||
: currentState.speedLimitPresetValues,
|
||||
lastCustomSpeedLimitUnit: persisted.lastCustomSpeedLimitUnit === 'KB/s'
|
||||
|| persisted.lastCustomSpeedLimitUnit === 'MB/s'
|
||||
? persisted.lastCustomSpeedLimitUnit
|
||||
: currentState.lastCustomSpeedLimitUnit,
|
||||
logsEnabled: persisted.logsEnabled === true,
|
||||
approvedDownloadRoots: Array.isArray(persisted.approvedDownloadRoots)
|
||||
? persisted.approvedDownloadRoots
|
||||
@@ -453,10 +645,8 @@ export const useSettingsStore = create<SettingsState>()(
|
||||
? persisted.scheduler.selectedQueueIds
|
||||
: currentState.scheduler.selectedQueueIds
|
||||
},
|
||||
appFontSize: persisted.appFontSize || currentState.appFontSize,
|
||||
listRowDensity: persisted.listRowDensity || currentState.listRowDensity,
|
||||
siteLogins: Array.isArray(persisted.siteLogins)
|
||||
? persisted.siteLogins
|
||||
? sanitizeSiteLogins(persisted.siteLogins)
|
||||
: currentState.siteLogins
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
appendRequestUrlsAfterVersion,
|
||||
canSubmitMetadataRows,
|
||||
mediaFormatSelectorForRow,
|
||||
mediaFileNameForSelectedFormat,
|
||||
metadataSummaryMessage,
|
||||
isYouTubePlaylistUrl,
|
||||
playlistFilePrefix,
|
||||
reconcileDownloadRows,
|
||||
refreshFailedMetadataRows,
|
||||
updateRowIfCurrent,
|
||||
@@ -56,6 +59,168 @@ describe('add download metadata workflow', () => {
|
||||
expect(rows.map(item => item.status)).toEqual(['loading', 'invalid', 'invalid']);
|
||||
});
|
||||
|
||||
it('recognizes pure YouTube playlist URLs without changing video-plus-playlist behavior', () => {
|
||||
expect(isYouTubePlaylistUrl('https://www.youtube.com/playlist?list=PL123')).toBe(true);
|
||||
expect(isYouTubePlaylistUrl('https://www.youtube.com/playlist/?list=PL123')).toBe(true);
|
||||
expect(isYouTubePlaylistUrl('https://music.youtube.com/playlist?list=PL123')).toBe(true);
|
||||
expect(isYouTubePlaylistUrl('https://www.youtube.com/watch?v=video&list=PL123')).toBe(false);
|
||||
expect(isYouTubePlaylistUrl('https://example.com/playlist?list=PL123')).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps a playlist as one loading row until discovery succeeds', () => {
|
||||
const rows = reconcileDownloadRows(
|
||||
'https://www.youtube.com/playlist?list=PL123',
|
||||
[]
|
||||
);
|
||||
|
||||
expect(rows[0]).toMatchObject({
|
||||
isMedia: true,
|
||||
isPlaylist: true,
|
||||
status: 'loading'
|
||||
});
|
||||
});
|
||||
|
||||
it('expands playlist entries into independently identifiable media rows', () => {
|
||||
const playlistUrl = 'https://www.youtube.com/playlist?list=PL123';
|
||||
const rows = reconcileDownloadRows(
|
||||
playlistUrl,
|
||||
[],
|
||||
undefined,
|
||||
new Set(),
|
||||
undefined,
|
||||
{},
|
||||
{ [playlistUrl]: 4 },
|
||||
{
|
||||
[playlistUrl]: {
|
||||
title: 'Example playlist',
|
||||
playlist_id: 'PL123',
|
||||
entry_count: 2,
|
||||
skipped_entries: 1,
|
||||
truncated: false,
|
||||
entries: [
|
||||
{ id: 'one', url: 'https://www.youtube.com/watch?v=one', title: 'First', playlist_index: 1 },
|
||||
{ id: 'one-duplicate', url: 'https://www.youtube.com/watch?v=one', title: 'Duplicate', playlist_index: 2 },
|
||||
{ id: 'two', url: 'https://www.youtube.com/watch?v=two', title: 'Second', playlist_index: 3 }
|
||||
]
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
expect(rows).toHaveLength(2);
|
||||
expect(rows.map(item => item.sourceUrl)).toEqual([
|
||||
'https://www.youtube.com/watch?v=one',
|
||||
'https://www.youtube.com/watch?v=two'
|
||||
]);
|
||||
expect(rows[0]).toMatchObject({
|
||||
file: '001 - First',
|
||||
isMedia: true,
|
||||
playlistSourceUrl: playlistUrl,
|
||||
playlistTitle: 'Example playlist',
|
||||
playlistIndex: 1,
|
||||
playlistCount: 2,
|
||||
requestContextVersion: 4,
|
||||
status: 'loading'
|
||||
});
|
||||
expect(rows[1].file).toBe('003 - Second');
|
||||
expect(rows.every(item => !item.isPlaylist)).toBe(true);
|
||||
});
|
||||
|
||||
it('uses a stable three-digit playlist prefix and widens it for four-digit lists', () => {
|
||||
expect(playlistFilePrefix(1, 12)).toBe('001 - ');
|
||||
expect(playlistFilePrefix(12, 12)).toBe('012 - ');
|
||||
expect(playlistFilePrefix(1000, 1000)).toBe('1000 - ');
|
||||
expect(playlistFilePrefix(undefined, 12)).toBe('');
|
||||
});
|
||||
|
||||
it('propagates a playlist selection to entries discovered after the user deselects it', () => {
|
||||
const playlistUrl = 'https://www.youtube.com/playlist?list=PL123';
|
||||
const rows = reconcileDownloadRows(
|
||||
playlistUrl,
|
||||
[],
|
||||
undefined,
|
||||
new Set(),
|
||||
undefined,
|
||||
{},
|
||||
{},
|
||||
{
|
||||
[playlistUrl]: {
|
||||
title: 'Example playlist',
|
||||
playlist_id: 'PL123',
|
||||
entry_count: 1,
|
||||
skipped_entries: 0,
|
||||
truncated: false,
|
||||
entries: [{ id: 'one', url: 'https://www.youtube.com/watch?v=one', title: 'First', playlist_index: 1 }]
|
||||
}
|
||||
},
|
||||
{ [playlistUrl]: false }
|
||||
);
|
||||
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0].selected).toBe(false);
|
||||
});
|
||||
|
||||
it('preserves entry-level selection when expanded rows are recreated', () => {
|
||||
const playlistUrl = 'https://www.youtube.com/playlist?list=PL123';
|
||||
const expansion = {
|
||||
[playlistUrl]: {
|
||||
title: 'Example playlist',
|
||||
playlist_id: 'PL123',
|
||||
entry_count: 2,
|
||||
skipped_entries: 0,
|
||||
truncated: false,
|
||||
entries: [
|
||||
{ id: 'one', url: 'https://www.youtube.com/watch?v=one', title: 'First', playlist_index: 1 },
|
||||
{ id: 'two', url: 'https://www.youtube.com/watch?v=two', title: 'Second', playlist_index: 2 }
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
const rows = reconcileDownloadRows(
|
||||
playlistUrl,
|
||||
[],
|
||||
undefined,
|
||||
new Set(),
|
||||
undefined,
|
||||
{},
|
||||
{},
|
||||
expansion,
|
||||
{
|
||||
'https://www.youtube.com/watch?v=one': false,
|
||||
'https://www.youtube.com/watch?v=two': true
|
||||
}
|
||||
);
|
||||
|
||||
expect(rows.map(item => item.selected)).toEqual([false, true]);
|
||||
});
|
||||
|
||||
it('does not leave a loading playlist row when every entry is already present', () => {
|
||||
const videoUrl = 'https://www.youtube.com/watch?v=one';
|
||||
const playlistUrl = 'https://www.youtube.com/playlist?list=PL123';
|
||||
const rows = reconcileDownloadRows(
|
||||
`${videoUrl}\n${playlistUrl}`,
|
||||
[],
|
||||
undefined,
|
||||
new Set(),
|
||||
undefined,
|
||||
{},
|
||||
{},
|
||||
{
|
||||
[playlistUrl]: {
|
||||
title: 'Example playlist',
|
||||
playlist_id: 'PL123',
|
||||
entry_count: 1,
|
||||
skipped_entries: 0,
|
||||
truncated: false,
|
||||
entries: [{ id: 'one', url: videoUrl, title: 'First', playlist_index: 1 }]
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0].sourceUrl).toBe(videoUrl);
|
||||
expect(rows.some(item => item.isPlaylist)).toBe(false);
|
||||
});
|
||||
|
||||
it('forces explicit extension media fetches through media metadata for any http page', () => {
|
||||
const rows = reconcileDownloadRows(
|
||||
'https://adult.example/watch/123',
|
||||
@@ -71,6 +236,145 @@ describe('add download metadata workflow', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps extension-provided filenames scoped to their individual URLs', () => {
|
||||
const rows = reconcileDownloadRows(
|
||||
'https://first.example/download\nhttps://second.example/download',
|
||||
[],
|
||||
undefined,
|
||||
new Set(),
|
||||
() => crypto.randomUUID(),
|
||||
{
|
||||
'https://first.example/download': 'first.zip',
|
||||
'https://second.example/download': 'second.zip'
|
||||
}
|
||||
);
|
||||
|
||||
expect(rows.map(item => item.file)).toEqual(['first.zip', 'second.zip']);
|
||||
});
|
||||
|
||||
it('refreshes an existing row when a newer extension handoff changes its request context', () => {
|
||||
const existing = row({
|
||||
isMedia: true,
|
||||
status: 'ready',
|
||||
generation: 4,
|
||||
requestContextVersion: 1,
|
||||
formats: [{
|
||||
name: '1080p MP4',
|
||||
selector: '137+140',
|
||||
ext: 'mp4',
|
||||
formatLabel: 'MP4',
|
||||
detail: '10 MB',
|
||||
type: 'Video',
|
||||
bytes: 10
|
||||
}],
|
||||
selectedFormat: 0
|
||||
});
|
||||
|
||||
const refreshed = reconcileDownloadRows(
|
||||
existing.sourceUrl,
|
||||
[existing],
|
||||
undefined,
|
||||
new Set(),
|
||||
() => 'unused',
|
||||
{},
|
||||
{ [existing.sourceUrl]: 2 }
|
||||
);
|
||||
|
||||
expect(refreshed[0]).toMatchObject({
|
||||
status: 'loading',
|
||||
generation: 5,
|
||||
requestContextVersion: 2,
|
||||
formats: undefined,
|
||||
selectedFormat: undefined
|
||||
});
|
||||
});
|
||||
|
||||
it('replaces a stale filename when a newer handoff supplies a new one', () => {
|
||||
const existing = row({
|
||||
file: 'old-name.zip',
|
||||
requestContextVersion: 1,
|
||||
generation: 2,
|
||||
size: '10 MB',
|
||||
sizeBytes: 10,
|
||||
resumable: true
|
||||
});
|
||||
|
||||
const refreshed = reconcileDownloadRows(
|
||||
existing.sourceUrl,
|
||||
[existing],
|
||||
undefined,
|
||||
new Set(),
|
||||
() => 'unused',
|
||||
{ [existing.sourceUrl]: 'new-name.zip' },
|
||||
{ [existing.sourceUrl]: 2 }
|
||||
);
|
||||
|
||||
expect(refreshed[0]).toMatchObject({
|
||||
file: 'new-name.zip',
|
||||
status: 'loading',
|
||||
generation: 3,
|
||||
requestContextVersion: 2,
|
||||
size: undefined,
|
||||
sizeBytes: undefined,
|
||||
resumable: undefined
|
||||
});
|
||||
});
|
||||
|
||||
it('drops stale playlist provenance when an entry remains after its playlist is removed', () => {
|
||||
const videoUrl = 'https://www.youtube.com/watch?v=one';
|
||||
const playlistUrl = 'https://www.youtube.com/playlist?list=PL123';
|
||||
const existing = row({
|
||||
sourceUrl: videoUrl,
|
||||
downloadUrl: videoUrl,
|
||||
file: '001 - First.mp4',
|
||||
status: 'ready',
|
||||
generation: 3,
|
||||
isMedia: true,
|
||||
playlistSourceUrl: playlistUrl,
|
||||
playlistTitle: 'Example playlist',
|
||||
playlistIndex: 1,
|
||||
playlistCount: 2,
|
||||
playlistEntryTitle: 'First',
|
||||
requestContextVersion: 7,
|
||||
size: '10 MB',
|
||||
sizeBytes: 10,
|
||||
resumable: true
|
||||
});
|
||||
|
||||
const rows = reconcileDownloadRows(videoUrl, [existing]);
|
||||
|
||||
expect(rows[0]).toMatchObject({
|
||||
file: 'watch',
|
||||
status: 'loading',
|
||||
generation: 4,
|
||||
isMedia: true,
|
||||
playlistSourceUrl: undefined,
|
||||
playlistIndex: undefined,
|
||||
size: undefined,
|
||||
sizeBytes: undefined,
|
||||
resumable: undefined,
|
||||
requestContextVersion: undefined
|
||||
});
|
||||
});
|
||||
|
||||
it('appends every unseen handoff after the observed version', () => {
|
||||
const merged = appendRequestUrlsAfterVersion(
|
||||
'https://existing.example/file.zip',
|
||||
{
|
||||
'https://first.example/file.zip': { version: 2 },
|
||||
'https://second.example/file.zip': { version: 3 },
|
||||
'https://existing.example/file.zip': { version: 4 }
|
||||
},
|
||||
1
|
||||
);
|
||||
|
||||
expect(merged).toBe(
|
||||
'https://existing.example/file.zip\n' +
|
||||
'https://first.example/file.zip\n' +
|
||||
'https://second.example/file.zip'
|
||||
);
|
||||
});
|
||||
|
||||
it('upgrades an existing normal row when the user explicitly fetches it as media', () => {
|
||||
const existing = row({
|
||||
sourceUrl: 'https://adult.example/watch/123',
|
||||
@@ -134,15 +438,45 @@ describe('add download metadata workflow', () => {
|
||||
expect(updated[0]).toBe(current);
|
||||
});
|
||||
|
||||
it('allows ready and failed rows but blocks loading and invalid rows', () => {
|
||||
it('allows normal-download fallback but blocks unresolved explicit media', () => {
|
||||
expect(canSubmitMetadataRows([
|
||||
row(),
|
||||
row({ id: 'fallback', status: 'metadata-error' })
|
||||
])).toBe(true);
|
||||
expect(canSubmitMetadataRows([
|
||||
row({ id: 'unsafe', status: 'metadata-error', metadataBlockedReason: 'unsafe-url' })
|
||||
])).toBe(false);
|
||||
expect(canSubmitMetadataRows([
|
||||
row(),
|
||||
row({ id: 'media-fallback', status: 'metadata-error', isMedia: true })
|
||||
])).toBe(false);
|
||||
expect(canSubmitMetadataRows([row({ status: 'loading' })])).toBe(false);
|
||||
expect(canSubmitMetadataRows([row({ status: 'invalid' })])).toBe(false);
|
||||
});
|
||||
|
||||
it('validates only selected rows and requires at least one selection', () => {
|
||||
expect(canSubmitMetadataRows([
|
||||
row({ status: 'loading' }),
|
||||
row({ id: 'skipped', status: 'invalid', selected: false })
|
||||
])).toBe(false);
|
||||
expect(canSubmitMetadataRows([
|
||||
row({ status: 'ready' }),
|
||||
row({ id: 'skipped', status: 'invalid', selected: false })
|
||||
])).toBe(true);
|
||||
expect(canSubmitMetadataRows([
|
||||
row({ selected: false }),
|
||||
row({ id: 'skipped', selected: false })
|
||||
])).toBe(false);
|
||||
expect(metadataSummaryMessage([
|
||||
row({ status: 'metadata-error', selected: false }),
|
||||
row({ id: 'ready', status: 'ready' })
|
||||
])).toContain('Ready to add 1 download');
|
||||
expect(metadataSummaryMessage([
|
||||
row({ status: 'metadata-error' }),
|
||||
row({ id: 'skipped', status: 'ready', selected: false })
|
||||
])).toContain('can still be added');
|
||||
});
|
||||
|
||||
it('keeps failed media routing without a format selector', () => {
|
||||
const failedMedia = row({
|
||||
status: 'metadata-error',
|
||||
@@ -193,6 +527,12 @@ describe('add download metadata workflow', () => {
|
||||
expect(metadataSummaryMessage([
|
||||
row({ status: 'metadata-error' })
|
||||
])).toContain('can still be added');
|
||||
expect(metadataSummaryMessage([
|
||||
row({ status: 'metadata-error', metadataBlockedReason: 'unsafe-url' })
|
||||
])).toContain('unsafe URL');
|
||||
expect(metadataSummaryMessage([
|
||||
row({ status: 'metadata-error', isMedia: true })
|
||||
])).toContain('Refresh metadata before adding');
|
||||
expect(metadataSummaryMessage([
|
||||
row({ status: 'invalid' })
|
||||
])).toContain('Correct or remove 1 invalid URL');
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
fileNameFromUrl,
|
||||
isMediaUrl
|
||||
} from './downloads';
|
||||
import type { MediaPlaylistMetadata } from '../bindings/MediaPlaylistMetadata';
|
||||
|
||||
export type MetadataStatus = 'loading' | 'ready' | 'metadata-error' | 'invalid';
|
||||
|
||||
@@ -26,10 +27,20 @@ export interface AddDownloadDraftRow {
|
||||
sizeBytes?: number;
|
||||
status: MetadataStatus;
|
||||
generation: number;
|
||||
requestContextVersion?: number;
|
||||
isMedia: boolean;
|
||||
resumable?: boolean;
|
||||
formats?: AddMediaFormat[];
|
||||
selectedFormat?: number;
|
||||
isPlaylist?: boolean;
|
||||
playlistSourceUrl?: string;
|
||||
playlistTitle?: string;
|
||||
playlistIndex?: number;
|
||||
playlistCount?: number;
|
||||
playlistEntryTitle?: string;
|
||||
playlistError?: string;
|
||||
metadataBlockedReason?: 'unsafe-url';
|
||||
selected?: boolean;
|
||||
}
|
||||
|
||||
const ALLOWED_SCHEMES = new Set(['http:', 'https:', 'ftp:', 'sftp:']);
|
||||
@@ -38,9 +49,45 @@ type ParsedInput = {
|
||||
identity: string;
|
||||
sourceUrl: string;
|
||||
valid: boolean;
|
||||
isPlaylist?: boolean;
|
||||
playlistSourceUrl?: string;
|
||||
playlistTitle?: string;
|
||||
playlistIndex?: number;
|
||||
playlistCount?: number;
|
||||
playlistEntryTitle?: string;
|
||||
requestContextVersion?: number;
|
||||
selected?: boolean;
|
||||
};
|
||||
|
||||
const parseInputLines = (rawText: string): ParsedInput[] => {
|
||||
export const isYouTubePlaylistUrl = (rawUrl: string): boolean => {
|
||||
try {
|
||||
const url = new URL(rawUrl);
|
||||
const hostname = url.hostname.toLowerCase();
|
||||
const isYouTube = hostname === 'youtube.com' || hostname.endsWith('.youtube.com');
|
||||
const pathname = url.pathname.replace(/\/+$/, '') || '/';
|
||||
return isYouTube && pathname === '/playlist' && Boolean(url.searchParams.get('list'));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
export const playlistFilePrefix = (
|
||||
playlistIndex: number | undefined,
|
||||
playlistCount: number | undefined
|
||||
): string => {
|
||||
if (!playlistIndex || playlistIndex < 1) return '';
|
||||
const width = Math.max(3, String(playlistCount || playlistIndex).length);
|
||||
return `${String(playlistIndex).padStart(width, '0')} - `;
|
||||
};
|
||||
|
||||
type PlaylistExpansions = Readonly<Record<string, MediaPlaylistMetadata>>;
|
||||
|
||||
const parseInputLines = (
|
||||
rawText: string,
|
||||
playlistExpansions: PlaylistExpansions,
|
||||
requestContextVersions: Readonly<Record<string, number>>,
|
||||
selectedBySourceUrl: Readonly<Record<string, boolean>>
|
||||
): ParsedInput[] => {
|
||||
const seen = new Set<string>();
|
||||
const parsed: ParsedInput[] = [];
|
||||
|
||||
@@ -61,7 +108,50 @@ const parseInputLines = (rawText: string): ParsedInput[] => {
|
||||
const identity = valid ? sourceUrl : `invalid:${line}`;
|
||||
if (seen.has(identity)) continue;
|
||||
seen.add(identity);
|
||||
parsed.push({ identity, sourceUrl, valid });
|
||||
|
||||
if (valid && isYouTubePlaylistUrl(sourceUrl)) {
|
||||
const expansion = playlistExpansions[sourceUrl];
|
||||
if (expansion) {
|
||||
const playlistSelected = selectedBySourceUrl[sourceUrl] !== false;
|
||||
for (const [position, entry] of expansion.entries.entries()) {
|
||||
let entryUrl: string;
|
||||
try {
|
||||
const parsedEntryUrl = new URL(entry.url);
|
||||
if (!ALLOWED_SCHEMES.has(parsedEntryUrl.protocol)) continue;
|
||||
entryUrl = parsedEntryUrl.href;
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (seen.has(entryUrl)) continue;
|
||||
seen.add(entryUrl);
|
||||
parsed.push({
|
||||
identity: entryUrl,
|
||||
sourceUrl: entryUrl,
|
||||
valid: true,
|
||||
playlistSourceUrl: sourceUrl,
|
||||
playlistTitle: expansion.title,
|
||||
playlistIndex: entry.playlist_index || position + 1,
|
||||
playlistCount: expansion.entry_count || expansion.entries.length,
|
||||
playlistEntryTitle: entry.title,
|
||||
requestContextVersion: requestContextVersions[sourceUrl],
|
||||
selected: selectedBySourceUrl[entryUrl] ?? playlistSelected
|
||||
});
|
||||
}
|
||||
// The playlist has been successfully discovered even when every
|
||||
// entry was already represented by another input row. Do not put the
|
||||
// source playlist back into loading state in that case.
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
parsed.push({
|
||||
identity,
|
||||
sourceUrl,
|
||||
valid,
|
||||
isPlaylist: valid && isYouTubePlaylistUrl(sourceUrl),
|
||||
requestContextVersion: valid ? requestContextVersions[sourceUrl] : undefined,
|
||||
selected: selectedBySourceUrl[sourceUrl] !== false
|
||||
});
|
||||
}
|
||||
|
||||
return parsed;
|
||||
@@ -72,31 +162,73 @@ export const reconcileDownloadRows = (
|
||||
currentRows: AddDownloadDraftRow[],
|
||||
pendingFilename?: string,
|
||||
forceMediaUrls: ReadonlySet<string> = new Set(),
|
||||
createId: () => string = () => crypto.randomUUID()
|
||||
createId: () => string = () => crypto.randomUUID(),
|
||||
requestFilenames: Readonly<Record<string, string>> = {},
|
||||
requestContextVersions: Readonly<Record<string, number>> = {},
|
||||
playlistExpansions: PlaylistExpansions = {},
|
||||
selectedBySourceUrl: Readonly<Record<string, boolean>> = {}
|
||||
): AddDownloadDraftRow[] => {
|
||||
const inputs = parseInputLines(rawText);
|
||||
const inputs = parseInputLines(
|
||||
rawText,
|
||||
playlistExpansions,
|
||||
requestContextVersions,
|
||||
selectedBySourceUrl
|
||||
);
|
||||
const existing = new Map(currentRows.map(row => [row.sourceUrl, row]));
|
||||
|
||||
return inputs.map(input => {
|
||||
const preserved = existing.get(input.sourceUrl);
|
||||
if (preserved) {
|
||||
if (input.valid && forceMediaUrls.has(input.sourceUrl) && !preserved.isMedia) {
|
||||
const forcedMedia = input.valid && forceMediaUrls.has(input.sourceUrl);
|
||||
const requestContextVersion = input.requestContextVersion;
|
||||
const contextChanged = requestContextVersion !== undefined
|
||||
&& requestContextVersion !== preserved.requestContextVersion;
|
||||
const playlistContextChanged = preserved.playlistSourceUrl !== input.playlistSourceUrl
|
||||
|| preserved.playlistTitle !== input.playlistTitle
|
||||
|| preserved.playlistIndex !== input.playlistIndex
|
||||
|| preserved.playlistCount !== input.playlistCount
|
||||
|| preserved.playlistEntryTitle !== input.playlistEntryTitle;
|
||||
if ((forcedMedia && !preserved.isMedia) || contextChanged || playlistContextChanged) {
|
||||
const requestedFilename = input.playlistSourceUrl
|
||||
? `${playlistFilePrefix(input.playlistIndex, input.playlistCount)}${input.playlistEntryTitle || 'video'}`
|
||||
: requestFilenames[input.sourceUrl];
|
||||
return {
|
||||
...preserved,
|
||||
file: contextChanged || playlistContextChanged
|
||||
? canonicalizeDownloadFileName(requestedFilename || fileNameFromUrl(input.sourceUrl))
|
||||
: preserved.file,
|
||||
status: 'loading',
|
||||
generation: preserved.generation + 1,
|
||||
isMedia: true,
|
||||
formats: undefined,
|
||||
selectedFormat: undefined
|
||||
requestContextVersion,
|
||||
isMedia: preserved.isMedia || forcedMedia || Boolean(input.playlistSourceUrl),
|
||||
size: undefined,
|
||||
sizeBytes: undefined,
|
||||
resumable: undefined,
|
||||
formats: preserved.isMedia || forcedMedia || Boolean(input.playlistSourceUrl)
|
||||
? undefined
|
||||
: preserved.formats,
|
||||
selectedFormat: preserved.isMedia || forcedMedia || Boolean(input.playlistSourceUrl)
|
||||
? undefined
|
||||
: preserved.selectedFormat,
|
||||
isPlaylist: input.isPlaylist,
|
||||
playlistSourceUrl: input.playlistSourceUrl,
|
||||
playlistTitle: input.playlistTitle,
|
||||
playlistIndex: input.playlistIndex,
|
||||
playlistCount: input.playlistCount,
|
||||
playlistEntryTitle: input.playlistEntryTitle,
|
||||
playlistError: undefined,
|
||||
metadataBlockedReason: undefined
|
||||
};
|
||||
}
|
||||
return preserved;
|
||||
}
|
||||
|
||||
const requestedFilename = input.playlistSourceUrl
|
||||
? `${playlistFilePrefix(input.playlistIndex, input.playlistCount)}${input.playlistEntryTitle || 'video'}`
|
||||
: requestFilenames[input.sourceUrl]
|
||||
|| (inputs.length === 1 ? pendingFilename : undefined);
|
||||
const fallback = canonicalizeDownloadFileName(
|
||||
inputs.length === 1 && pendingFilename
|
||||
? pendingFilename
|
||||
: fileNameFromUrl(input.sourceUrl)
|
||||
requestedFilename || fileNameFromUrl(input.sourceUrl)
|
||||
);
|
||||
|
||||
return {
|
||||
@@ -106,11 +238,54 @@ export const reconcileDownloadRows = (
|
||||
file: fallback,
|
||||
status: input.valid ? 'loading' : 'invalid',
|
||||
generation: input.valid ? 1 : 0,
|
||||
isMedia: input.valid && (forceMediaUrls.has(input.sourceUrl) || isMediaUrl(input.sourceUrl))
|
||||
requestContextVersion: input.requestContextVersion,
|
||||
isMedia: input.valid && (
|
||||
Boolean(input.isPlaylist)
|
||||
|| Boolean(input.playlistSourceUrl)
|
||||
|| forceMediaUrls.has(input.sourceUrl)
|
||||
|| isMediaUrl(input.sourceUrl)
|
||||
),
|
||||
isPlaylist: input.isPlaylist,
|
||||
playlistSourceUrl: input.playlistSourceUrl,
|
||||
playlistTitle: input.playlistTitle,
|
||||
playlistIndex: input.playlistIndex,
|
||||
playlistCount: input.playlistCount,
|
||||
playlistEntryTitle: input.playlistEntryTitle,
|
||||
metadataBlockedReason: undefined,
|
||||
selected: input.selected !== false
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const comparableUrl = (rawUrl: string): string => {
|
||||
try {
|
||||
return new URL(rawUrl).href;
|
||||
} catch {
|
||||
return rawUrl.trim();
|
||||
}
|
||||
};
|
||||
|
||||
export const appendRequestUrlsAfterVersion = (
|
||||
rawText: string,
|
||||
requestContexts: Readonly<Record<string, { version: number }>>,
|
||||
observedVersion: number
|
||||
): string => {
|
||||
const lines = rawText.split('\n').map(line => line.trim()).filter(Boolean);
|
||||
const seen = new Set(lines.map(comparableUrl));
|
||||
const additions = Object.entries(requestContexts)
|
||||
.filter(([, context]) => context.version > observedVersion)
|
||||
.sort(([, left], [, right]) => left.version - right.version);
|
||||
|
||||
for (const [url] of additions) {
|
||||
const identity = comparableUrl(url);
|
||||
if (seen.has(identity)) continue;
|
||||
seen.add(identity);
|
||||
lines.push(url);
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
};
|
||||
|
||||
export const updateRowIfCurrent = (
|
||||
rows: AddDownloadDraftRow[],
|
||||
id: string,
|
||||
@@ -130,14 +305,20 @@ export const refreshFailedMetadataRows = (
|
||||
? {
|
||||
...row,
|
||||
status: 'loading',
|
||||
generation: row.generation + 1
|
||||
generation: row.generation + 1,
|
||||
metadataBlockedReason: undefined
|
||||
}
|
||||
: row
|
||||
);
|
||||
|
||||
export const canSubmitMetadataRows = (rows: AddDownloadDraftRow[]): boolean =>
|
||||
rows.length > 0
|
||||
&& rows.every(row => row.status === 'ready' || row.status === 'metadata-error');
|
||||
export const canSubmitMetadataRows = (rows: AddDownloadDraftRow[]): boolean => {
|
||||
const selectedRows = rows.filter(row => row.selected !== false);
|
||||
return selectedRows.length > 0
|
||||
&& selectedRows.every(row =>
|
||||
row.status === 'ready'
|
||||
|| (!row.isMedia && row.status === 'metadata-error' && !row.metadataBlockedReason)
|
||||
);
|
||||
};
|
||||
|
||||
export const mediaFormatSelectorForRow = (
|
||||
row: AddDownloadDraftRow
|
||||
@@ -183,19 +364,30 @@ export const mediaFileNameForSelectedFormat = (
|
||||
export const metadataSummaryMessage = (rows: AddDownloadDraftRow[]): string => {
|
||||
if (rows.length === 0) return 'Paste one or more links.';
|
||||
|
||||
const invalid = rows.filter(row => row.status === 'invalid').length;
|
||||
const selectedRows = rows.filter(row => row.selected !== false);
|
||||
if (selectedRows.length === 0) return 'Select at least one download.';
|
||||
|
||||
const invalid = selectedRows.filter(row => row.status === 'invalid').length;
|
||||
if (invalid > 0) {
|
||||
return `Correct or remove ${invalid} invalid URL${invalid === 1 ? '' : 's'} before continuing.`;
|
||||
}
|
||||
|
||||
const loading = rows.filter(row => row.status === 'loading').length;
|
||||
const loading = selectedRows.filter(row => row.status === 'loading').length;
|
||||
if (loading > 0) {
|
||||
return `Waiting for metadata for ${loading} download${loading === 1 ? '' : 's'}.`;
|
||||
}
|
||||
|
||||
const failed = rows.filter(row => row.status === 'metadata-error').length;
|
||||
const ready = rows.filter(row => row.status === 'ready').length;
|
||||
if (failed === rows.length) {
|
||||
const failed = selectedRows.filter(row => row.status === 'metadata-error').length;
|
||||
const failedMedia = selectedRows.filter(row => row.status === 'metadata-error' && row.isMedia).length;
|
||||
const blocked = selectedRows.filter(row => row.metadataBlockedReason === 'unsafe-url').length;
|
||||
const ready = selectedRows.filter(row => row.status === 'ready').length;
|
||||
if (blocked > 0) {
|
||||
return `Remove ${blocked} unsafe URL${blocked === 1 ? '' : 's'} before continuing.`;
|
||||
}
|
||||
if (failedMedia > 0) {
|
||||
return `Media metadata is unavailable for ${failedMedia} item${failedMedia === 1 ? '' : 's'}. Refresh metadata before adding.`;
|
||||
}
|
||||
if (failed === selectedRows.length) {
|
||||
return 'Metadata is unavailable. Downloads can still be added using fallback details.';
|
||||
}
|
||||
if (failed > 0) {
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { readClipboardDownloadUrls } from './clipboard';
|
||||
import { readText } from '@tauri-apps/plugin-clipboard-manager';
|
||||
|
||||
vi.mock('@tauri-apps/plugin-clipboard-manager', () => ({
|
||||
readText: vi.fn(),
|
||||
}));
|
||||
|
||||
describe('clipboard URL extraction', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('reads only supported, unique download URLs from clipboard text', async () => {
|
||||
vi.mocked(readText).mockResolvedValue(
|
||||
'https://example.com/file.zip\nhttps://example.com/file.zip ftp://example.com/file.bin sftp://example.com/file.iso mailto:user@example.com'
|
||||
);
|
||||
|
||||
await expect(readClipboardDownloadUrls()).resolves.toEqual([
|
||||
'https://example.com/file.zip',
|
||||
'ftp://example.com/file.bin',
|
||||
'sftp://example.com/file.iso',
|
||||
]);
|
||||
});
|
||||
|
||||
it('preserves clipboard read failures for the caller to handle', async () => {
|
||||
const error = new Error('clipboard unavailable');
|
||||
vi.mocked(readText).mockRejectedValue(error);
|
||||
|
||||
await expect(readClipboardDownloadUrls()).rejects.toBe(error);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,7 @@
|
||||
import { readText } from '@tauri-apps/plugin-clipboard-manager';
|
||||
import { extractValidDownloadUrls } from './url';
|
||||
|
||||
export const readClipboardDownloadUrls = async (): Promise<string[]> => {
|
||||
const clipboardText = await readText();
|
||||
return extractValidDownloadUrls(clipboardText);
|
||||
};
|
||||
@@ -0,0 +1,46 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { formatDownloadBytes, formatDownloadTotal, resolveDownloadSizeDisplay } from './downloadProgress';
|
||||
|
||||
describe('download progress size display', () => {
|
||||
it('formats byte counts using the binary units used by the download engines', () => {
|
||||
expect(formatDownloadBytes(0)).toBe('0 B');
|
||||
expect(formatDownloadBytes(1.2 * 1024 ** 3)).toBe('1.20 GB');
|
||||
});
|
||||
|
||||
it('keeps estimated totals distinguishable from exact totals', () => {
|
||||
expect(resolveDownloadSizeDisplay({
|
||||
downloadedBytes: 1.2 * 1024 ** 3,
|
||||
totalBytes: 2.4 * 1024 ** 3,
|
||||
totalIsEstimate: true,
|
||||
fallbackSize: 'Unknown'
|
||||
})).toEqual({
|
||||
downloaded: '1.20',
|
||||
total: '2.40',
|
||||
unit: 'GB',
|
||||
totalIsEstimate: true,
|
||||
fallback: 'Unknown'
|
||||
});
|
||||
});
|
||||
|
||||
it('converts downloaded bytes into the total size unit', () => {
|
||||
expect(resolveDownloadSizeDisplay({
|
||||
downloadedBytes: 512 * 1024 ** 2,
|
||||
totalBytes: 2 * 1024 ** 3,
|
||||
fallbackSize: '2 GB'
|
||||
})).toMatchObject({
|
||||
downloaded: '0.50',
|
||||
total: '2.00',
|
||||
unit: 'GB'
|
||||
});
|
||||
});
|
||||
|
||||
it('formats a completed download using only its total size', () => {
|
||||
const display = resolveDownloadSizeDisplay({
|
||||
downloadedBytes: 1.2 * 1024 ** 3,
|
||||
totalBytes: 2.4 * 1024 ** 3,
|
||||
fallbackSize: '2.4 GB'
|
||||
});
|
||||
|
||||
expect(formatDownloadTotal(display)).toBe('2.40 GB');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
export interface DownloadSizeDisplay {
|
||||
downloaded: string | null;
|
||||
total: string | null;
|
||||
unit: string | null;
|
||||
totalIsEstimate: boolean;
|
||||
fallback: string;
|
||||
}
|
||||
|
||||
const BYTE_UNITS = ['B', 'KB', 'MB', 'GB', 'TB'] as const;
|
||||
|
||||
const isUsableByteCount = (value: number | null | undefined): value is number =>
|
||||
typeof value === 'number' && Number.isFinite(value) && value >= 0;
|
||||
|
||||
const byteUnitIndex = (bytes: number): number => {
|
||||
let value = bytes;
|
||||
let unitIndex = 0;
|
||||
while (value >= 1024 && unitIndex < BYTE_UNITS.length - 1) {
|
||||
value /= 1024;
|
||||
unitIndex += 1;
|
||||
}
|
||||
return unitIndex;
|
||||
};
|
||||
|
||||
const formatDownloadBytesInUnit = (bytes: number, unitIndex: number): string => {
|
||||
const value = bytes / 1024 ** unitIndex;
|
||||
const precision = value >= 100 ? 0 : value >= 10 ? 1 : 2;
|
||||
return value < 1024 && unitIndex === 0
|
||||
? `${Math.round(value)}`
|
||||
: value.toFixed(precision);
|
||||
};
|
||||
|
||||
export const formatDownloadBytes = (bytes: number): string => {
|
||||
const unitIndex = byteUnitIndex(bytes);
|
||||
return `${formatDownloadBytesInUnit(bytes, unitIndex)} ${BYTE_UNITS[unitIndex]}`;
|
||||
};
|
||||
|
||||
export const formatDownloadTotal = (display: DownloadSizeDisplay): string =>
|
||||
display.total && display.unit
|
||||
? `${display.totalIsEstimate ? '~' : ''}${display.total} ${display.unit}`
|
||||
: display.fallback;
|
||||
|
||||
export const resolveDownloadSizeDisplay = ({
|
||||
downloadedBytes,
|
||||
totalBytes,
|
||||
totalIsEstimate = false,
|
||||
fallbackSize
|
||||
}: {
|
||||
downloadedBytes?: number | null;
|
||||
totalBytes?: number | null;
|
||||
totalIsEstimate?: boolean;
|
||||
fallbackSize?: string | null;
|
||||
}): DownloadSizeDisplay => ({
|
||||
downloaded: isUsableByteCount(downloadedBytes) && isUsableByteCount(totalBytes) && totalBytes > 0
|
||||
? formatDownloadBytesInUnit(downloadedBytes, byteUnitIndex(totalBytes))
|
||||
: null,
|
||||
total: isUsableByteCount(totalBytes) && totalBytes > 0
|
||||
? formatDownloadBytesInUnit(totalBytes, byteUnitIndex(totalBytes))
|
||||
: null,
|
||||
unit: isUsableByteCount(totalBytes) && totalBytes > 0
|
||||
? BYTE_UNITS[byteUnitIndex(totalBytes)]
|
||||
: null,
|
||||
totalIsEstimate: Boolean(totalIsEstimate && isUsableByteCount(totalBytes) && totalBytes > 0),
|
||||
fallback: fallbackSize && fallbackSize !== '-' ? fallbackSize : 'Unknown'
|
||||
});
|
||||
|
||||
export const downloadProgressColorClass = (status: string): string => {
|
||||
switch (status) {
|
||||
case 'completed':
|
||||
return 'download-status-completed';
|
||||
case 'paused':
|
||||
return 'download-status-paused';
|
||||
case 'failed':
|
||||
return 'download-status-failed';
|
||||
case 'processing':
|
||||
return 'download-status-processing';
|
||||
case 'queued':
|
||||
case 'staged':
|
||||
return 'download-status-queued';
|
||||
case 'retrying':
|
||||
return 'download-status-retrying';
|
||||
default:
|
||||
return 'download-status-downloading';
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,60 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { DownloadItem } from '../bindings/DownloadItem';
|
||||
import {
|
||||
parseDownloadEta,
|
||||
parseDownloadSize,
|
||||
sortDownloads,
|
||||
type DownloadSortConfig
|
||||
} from './downloadTableSorting';
|
||||
import { redactDownloadForPersistence } from './downloads';
|
||||
|
||||
const item = (id: string, overrides: Partial<DownloadItem> = {}): DownloadItem => ({
|
||||
id,
|
||||
url: `https://example.test/${id}`,
|
||||
fileName: id,
|
||||
status: 'queued',
|
||||
category: 'Other',
|
||||
dateAdded: '2026-07-14T00:00:00.000Z',
|
||||
...overrides
|
||||
});
|
||||
|
||||
const sortedIds = (downloads: DownloadItem[], config: DownloadSortConfig): string[] =>
|
||||
sortDownloads(downloads, config).map(download => download.id);
|
||||
|
||||
describe('download table sorting', () => {
|
||||
it('compares human-readable sizes by bytes instead of their leading number', () => {
|
||||
expect(parseDownloadSize('1 MB')).toBe(1024 ** 2);
|
||||
expect(sortedIds([
|
||||
item('one-mb', { size: '1 MB' }),
|
||||
item('900-kb', { size: '900 KB' }),
|
||||
item('two-mb', { size: '2 MB' })
|
||||
], { column: 'Size', direction: 'asc' })).toEqual(['900-kb', 'one-mb', 'two-mb']);
|
||||
});
|
||||
|
||||
it('supports clock and unit ETA values and keeps unknown values last', () => {
|
||||
expect(parseDownloadEta('01:02:03')).toBe(3723);
|
||||
expect(parseDownloadEta('2m 5s')).toBe(125);
|
||||
expect(sortedIds([
|
||||
item('unknown', { eta: '-' }),
|
||||
item('long', { eta: '2m' }),
|
||||
item('short', { eta: '10s' })
|
||||
], { column: 'ETA', direction: 'asc' })).toEqual(['short', 'long', 'unknown']);
|
||||
});
|
||||
|
||||
it('sorts descending on the second click without reverting to an unsorted list', () => {
|
||||
const downloads = [item('b', { fileName: 'Beta' }), item('a', { fileName: 'Alpha' })];
|
||||
expect(sortedIds(downloads, { column: 'File Name', direction: 'asc' })).toEqual(['a', 'b']);
|
||||
expect(sortedIds(downloads, { column: 'File Name', direction: 'desc' })).toEqual(['b', 'a']);
|
||||
});
|
||||
|
||||
it('does not persist volatile progress fields', () => {
|
||||
const persisted = redactDownloadForPersistence(item('volatile', {
|
||||
fraction: 0.75,
|
||||
speed: '1 MB/s',
|
||||
eta: '10s'
|
||||
}));
|
||||
expect(persisted.fraction).toBeUndefined();
|
||||
expect(persisted.speed).toBeUndefined();
|
||||
expect(persisted.eta).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,110 @@
|
||||
import type { DownloadItem } from '../bindings/DownloadItem';
|
||||
|
||||
export type DownloadSortColumn = 'File Name' | 'Size' | 'Status' | 'Speed' | 'ETA' | 'Date Added';
|
||||
export type DownloadSortDirection = 'asc' | 'desc';
|
||||
|
||||
export type DownloadSortConfig = {
|
||||
column: DownloadSortColumn;
|
||||
direction: DownloadSortDirection;
|
||||
};
|
||||
|
||||
const SIZE_UNITS: Record<string, number> = {
|
||||
B: 1,
|
||||
KB: 1024,
|
||||
KIB: 1024,
|
||||
MB: 1024 ** 2,
|
||||
MIB: 1024 ** 2,
|
||||
GB: 1024 ** 3,
|
||||
GIB: 1024 ** 3,
|
||||
TB: 1024 ** 4,
|
||||
TIB: 1024 ** 4,
|
||||
};
|
||||
|
||||
const valueOrNull = (value?: string): string | null => {
|
||||
const trimmed = value?.trim();
|
||||
return trimmed && trimmed !== '-' && !/^unknown$/i.test(trimmed) ? trimmed : null;
|
||||
};
|
||||
|
||||
const parseUnitValue = (value?: string, units = SIZE_UNITS): number | null => {
|
||||
const normalized = valueOrNull(value);
|
||||
if (!normalized) return null;
|
||||
const match = normalized.match(/^([\d.,]+)\s*([KMGT]?I?B)(?:\/s)?$/i);
|
||||
if (!match) {
|
||||
const number = Number(normalized.replace(/,/g, ''));
|
||||
return Number.isFinite(number) ? number : null;
|
||||
}
|
||||
|
||||
const amount = Number(match[1].replace(/,/g, ''));
|
||||
const multiplier = units[match[2].toUpperCase()];
|
||||
return Number.isFinite(amount) && multiplier ? amount * multiplier : null;
|
||||
};
|
||||
|
||||
export const parseDownloadSize = (value?: string): number | null => parseUnitValue(value);
|
||||
|
||||
export const parseDownloadSpeed = (value?: string): number | null =>
|
||||
parseUnitValue(value, SIZE_UNITS);
|
||||
|
||||
export const parseDownloadEta = (value?: string): number | null => {
|
||||
const normalized = valueOrNull(value);
|
||||
if (!normalized) return null;
|
||||
|
||||
const clockParts = normalized.split(':').map(part => Number(part));
|
||||
if (clockParts.length >= 2 && clockParts.every(Number.isFinite)) {
|
||||
return clockParts.reduce((total, part, index) => total + part * 60 ** (clockParts.length - index - 1), 0);
|
||||
}
|
||||
|
||||
let seconds = 0;
|
||||
let matched = false;
|
||||
for (const [pattern, multiplier] of [[/(\d+(?:\.\d+)?)h/i, 3600], [/(\d+(?:\.\d+)?)m/i, 60], [/(\d+(?:\.\d+)?)s/i, 1]] as const) {
|
||||
const match = normalized.match(pattern);
|
||||
if (match) {
|
||||
seconds += Number(match[1]) * multiplier;
|
||||
matched = true;
|
||||
}
|
||||
}
|
||||
return matched && Number.isFinite(seconds) ? seconds : null;
|
||||
};
|
||||
|
||||
const compareValues = (left: string | number | null, right: string | number | null): number => {
|
||||
if (left === null && right === null) return 0;
|
||||
if (left === null) return 1;
|
||||
if (right === null) return -1;
|
||||
if (typeof left === 'number' && typeof right === 'number') return left - right;
|
||||
return String(left).localeCompare(String(right), undefined, { numeric: true, sensitivity: 'base' });
|
||||
};
|
||||
|
||||
const parseDownloadDate = (value?: string): number | null => {
|
||||
if (!value?.trim()) return null;
|
||||
const timestamp = new Date(value).getTime();
|
||||
return Number.isFinite(timestamp) ? timestamp : null;
|
||||
};
|
||||
|
||||
export const sortDownloads = (downloads: DownloadItem[], config: DownloadSortConfig): DownloadItem[] => {
|
||||
const sorted = [...downloads].sort((left, right) => {
|
||||
let comparison: number;
|
||||
switch (config.column) {
|
||||
case 'File Name':
|
||||
comparison = compareValues(left.fileName || left.url, right.fileName || right.url);
|
||||
break;
|
||||
case 'Size':
|
||||
comparison = compareValues(parseDownloadSize(left.size), parseDownloadSize(right.size));
|
||||
break;
|
||||
case 'Status':
|
||||
comparison = compareValues(left.status, right.status);
|
||||
break;
|
||||
case 'Speed':
|
||||
comparison = compareValues(parseDownloadSpeed(left.speed), parseDownloadSpeed(right.speed));
|
||||
break;
|
||||
case 'ETA':
|
||||
comparison = compareValues(parseDownloadEta(left.eta), parseDownloadEta(right.eta));
|
||||
break;
|
||||
case 'Date Added':
|
||||
comparison = compareValues(parseDownloadDate(left.dateAdded), parseDownloadDate(right.dateAdded));
|
||||
break;
|
||||
}
|
||||
|
||||
if (comparison === 0) comparison = left.id.localeCompare(right.id);
|
||||
return config.direction === 'asc' ? comparison : -comparison;
|
||||
});
|
||||
return sorted;
|
||||
};
|
||||
@@ -0,0 +1,58 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { DownloadItem } from '../bindings/DownloadItem';
|
||||
import { redactDownloadForPersistence, resolveDownloadConnections } from './downloads';
|
||||
|
||||
const item = (status: DownloadItem['status']): DownloadItem => ({
|
||||
id: 'download-1',
|
||||
url: 'https://example.com/file.bin',
|
||||
fileName: 'file.bin',
|
||||
status,
|
||||
category: 'Other',
|
||||
dateAdded: '2026-07-15T00:00:00.000Z',
|
||||
downloadedBytes: 1024,
|
||||
totalBytes: 4096,
|
||||
totalIsEstimate: false
|
||||
});
|
||||
|
||||
describe('download persistence progress snapshots', () => {
|
||||
it('does not write active byte counters on every progress event', () => {
|
||||
const persisted = redactDownloadForPersistence(item('downloading'));
|
||||
|
||||
expect(persisted.downloadedBytes).toBeUndefined();
|
||||
expect(persisted.totalBytes).toBeUndefined();
|
||||
expect(persisted.totalIsEstimate).toBeUndefined();
|
||||
});
|
||||
|
||||
it('keeps byte counters for paused snapshots', () => {
|
||||
const persisted = redactDownloadForPersistence(item('paused'));
|
||||
|
||||
expect(persisted.downloadedBytes).toBe(1024);
|
||||
expect(persisted.totalBytes).toBe(4096);
|
||||
expect(persisted.totalIsEstimate).toBe(false);
|
||||
});
|
||||
|
||||
it.each(['queued', 'staged', 'retrying', 'processing'] as const)(
|
||||
'keeps byte counters for %s snapshots',
|
||||
(status) => {
|
||||
const persisted = redactDownloadForPersistence(item(status));
|
||||
|
||||
expect(persisted.downloadedBytes).toBe(1024);
|
||||
expect(persisted.totalBytes).toBe(4096);
|
||||
expect(persisted.totalIsEstimate).toBe(false);
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
describe('download connection resolution', () => {
|
||||
it('uses a clamped fallback for legacy rows without a saved value', () => {
|
||||
expect(resolveDownloadConnections(undefined, 8)).toBe(8);
|
||||
expect(resolveDownloadConnections(undefined, 0)).toBe(1);
|
||||
expect(resolveDownloadConnections(undefined, Number.NaN)).toBe(16);
|
||||
});
|
||||
|
||||
it('clamps malformed saved values before dispatch', () => {
|
||||
expect(resolveDownloadConnections(0, 8)).toBe(1);
|
||||
expect(resolveDownloadConnections(17, 8)).toBe(16);
|
||||
expect(resolveDownloadConnections(Number.NaN, 8)).toBe(8);
|
||||
});
|
||||
});
|
||||
+54
-6
@@ -36,6 +36,41 @@ const ACTIVE_DOWNLOAD_STATUSES: ReadonlySet<DownloadStatus> = new Set([
|
||||
export const isActiveDownloadStatus = (status: DownloadStatus): boolean =>
|
||||
ACTIVE_DOWNLOAD_STATUSES.has(status);
|
||||
|
||||
/** Transfer states that consume a worker/permit. Queued is intentionally excluded. */
|
||||
export const isTransferActiveStatus = (status: DownloadStatus): boolean =>
|
||||
status === 'downloading' || status === 'processing' || status === 'retrying';
|
||||
|
||||
export const DOWNLOAD_CONNECTIONS_MIN = 1;
|
||||
export const DOWNLOAD_CONNECTIONS_MAX = 16;
|
||||
|
||||
/**
|
||||
* Resolve persisted/user-entered connection values before they cross into the
|
||||
* backend. Older rows may omit the value, while malformed rows can contain
|
||||
* zero, NaN, or an out-of-range number.
|
||||
*/
|
||||
export const resolveDownloadConnections = (value: unknown, fallback: unknown): number => {
|
||||
const toFiniteInteger = (candidate: unknown): number | undefined => {
|
||||
if (typeof candidate === 'number') {
|
||||
return Number.isFinite(candidate) ? Math.trunc(candidate) : undefined;
|
||||
}
|
||||
if (typeof candidate === 'string' && candidate.trim() !== '') {
|
||||
const parsed = Number(candidate);
|
||||
return Number.isFinite(parsed) ? Math.trunc(parsed) : undefined;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
const normalizedFallback = toFiniteInteger(fallback) ?? DOWNLOAD_CONNECTIONS_MAX;
|
||||
const safeFallback = Math.min(
|
||||
DOWNLOAD_CONNECTIONS_MAX,
|
||||
Math.max(DOWNLOAD_CONNECTIONS_MIN, normalizedFallback)
|
||||
);
|
||||
const candidate = toFiniteInteger(value) ?? safeFallback;
|
||||
return Math.min(
|
||||
DOWNLOAD_CONNECTIONS_MAX,
|
||||
Math.max(DOWNLOAD_CONNECTIONS_MIN, candidate)
|
||||
);
|
||||
};
|
||||
|
||||
export const normalizeSpeedLimitForBackend = (value?: string | null): string | null => {
|
||||
const trimmed = value?.trim();
|
||||
if (!trimmed) return null;
|
||||
@@ -115,22 +150,35 @@ export const isMediaUrl = (rawUrl: string): boolean => {
|
||||
* persistence boundary so the user-data database contains no plaintext credentials.
|
||||
*/
|
||||
const DOWNLOAD_SECRET_FIELDS = ['password', 'cookies', 'headers'] as const;
|
||||
const VOLATILE_PROGRESS_STATUSES = new Set([
|
||||
'downloading'
|
||||
]);
|
||||
|
||||
/**
|
||||
* Returns a shallow copy of `item` with secret fields removed. Volatile
|
||||
* progress fields (`fraction`, `speed`, `eta`) are also dropped as in the
|
||||
* existing persistence path.
|
||||
* existing persistence path. Numeric byte totals remain for paused, failed,
|
||||
* and completed rows so those snapshots keep their accurate Size-column
|
||||
* display after restart; counters for the actively ticking `downloading`
|
||||
* state stay in memory to avoid a database write for every progress tick.
|
||||
* Non-ticking states retain counters so paused, queued, staged, retrying, and
|
||||
* processing snapshots remain useful across restart and reconfiguration.
|
||||
*
|
||||
* Note: `url` is intentionally retained even though it may contain signed
|
||||
* query parameters — redacting it would break resume/retry since the URL is
|
||||
* the download source. Ad-hoc credentials entered in the Add Downloads modal
|
||||
* are therefore session-scoped; site-login passwords (Keychain-backed) are
|
||||
* unaffected by this redaction.
|
||||
* Note: standard persistence intentionally retains `url` because it is the
|
||||
* download source. The backend applies a stricter portable-mode policy: URL
|
||||
* userinfo, query, and fragment components are removed before portable data
|
||||
* is written, and affected active records are not auto-resumed.
|
||||
*/
|
||||
export const redactDownloadForPersistence = (item: DownloadItem): DownloadItem => {
|
||||
const copy: DownloadItem = { ...item };
|
||||
delete copy.fraction;
|
||||
delete copy.speed;
|
||||
delete copy.eta;
|
||||
if (VOLATILE_PROGRESS_STATUSES.has(item.status)) {
|
||||
delete copy.downloadedBytes;
|
||||
delete copy.totalBytes;
|
||||
delete copy.totalIsEstimate;
|
||||
}
|
||||
for (const field of DOWNLOAD_SECRET_FIELDS) {
|
||||
delete copy[field];
|
||||
}
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
getKeychainAccessReady,
|
||||
getKeychainConsentVersion,
|
||||
getKeychainStartupDecision
|
||||
} from './keychainStartup';
|
||||
|
||||
describe('getKeychainStartupDecision', () => {
|
||||
it('keeps portable site credentials gated until system-store access is granted', () => {
|
||||
expect(getKeychainAccessReady({
|
||||
portable: true,
|
||||
accessGranted: false,
|
||||
persistent: true
|
||||
})).toBe(false);
|
||||
expect(getKeychainAccessReady({
|
||||
portable: true,
|
||||
accessGranted: true,
|
||||
persistent: true
|
||||
})).toBe(true);
|
||||
});
|
||||
|
||||
it('uses persistent pairing state for standard-mode readiness', () => {
|
||||
expect(getKeychainAccessReady({
|
||||
portable: false,
|
||||
accessGranted: false,
|
||||
persistent: true
|
||||
})).toBe(true);
|
||||
});
|
||||
|
||||
it('changes the consent identity when the credential-access policy changes', () => {
|
||||
expect(getKeychainConsentVersion('1.1.0')).toMatch(
|
||||
/^1\.1\.0\|(build-.+|keychain-policy-2)$/
|
||||
);
|
||||
expect(getKeychainConsentVersion('')).toBe('');
|
||||
});
|
||||
|
||||
it('re-prompts when the app build keeps the same semantic version', () => {
|
||||
expect(getKeychainStartupDecision({
|
||||
portable: false,
|
||||
appVersion: getKeychainConsentVersion('1.1.0'),
|
||||
approvedVersion: '1.1.0',
|
||||
accessGranted: true,
|
||||
promptDismissed: true
|
||||
})).toEqual({
|
||||
deferKeychainHydration: true,
|
||||
showKeychainPrompt: true
|
||||
});
|
||||
});
|
||||
|
||||
it('defers persistent hydration and shows the explanation after an update', () => {
|
||||
expect(getKeychainStartupDecision({
|
||||
portable: false,
|
||||
appVersion: '1.0.5',
|
||||
approvedVersion: '1.0.4',
|
||||
accessGranted: true,
|
||||
promptDismissed: false
|
||||
})).toEqual({
|
||||
deferKeychainHydration: true,
|
||||
showKeychainPrompt: true
|
||||
});
|
||||
});
|
||||
|
||||
it('hydrates automatically after the current version was explicitly approved', () => {
|
||||
expect(getKeychainStartupDecision({
|
||||
portable: false,
|
||||
appVersion: '1.0.5',
|
||||
approvedVersion: '1.0.5',
|
||||
accessGranted: true,
|
||||
promptDismissed: false
|
||||
})).toEqual({
|
||||
deferKeychainHydration: false,
|
||||
showKeychainPrompt: false
|
||||
});
|
||||
});
|
||||
|
||||
it('shows the explanation on first run without touching the credential store', () => {
|
||||
expect(getKeychainStartupDecision({
|
||||
portable: false,
|
||||
appVersion: '1.0.5',
|
||||
approvedVersion: '',
|
||||
accessGranted: false,
|
||||
promptDismissed: false
|
||||
})).toEqual({
|
||||
deferKeychainHydration: true,
|
||||
showKeychainPrompt: true
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps a deliberately deferred session quiet on later launches', () => {
|
||||
expect(getKeychainStartupDecision({
|
||||
portable: false,
|
||||
appVersion: '1.0.5',
|
||||
approvedVersion: '1.0.5',
|
||||
accessGranted: false,
|
||||
promptDismissed: true
|
||||
})).toEqual({
|
||||
deferKeychainHydration: true,
|
||||
showKeychainPrompt: false
|
||||
});
|
||||
});
|
||||
|
||||
it('reoffers the explanation after a later update even if the previous one was deferred', () => {
|
||||
expect(getKeychainStartupDecision({
|
||||
portable: false,
|
||||
appVersion: '1.0.6',
|
||||
approvedVersion: '1.0.5',
|
||||
accessGranted: false,
|
||||
promptDismissed: true
|
||||
})).toEqual({
|
||||
deferKeychainHydration: true,
|
||||
showKeychainPrompt: true
|
||||
});
|
||||
});
|
||||
|
||||
it('does not repeat a deferred prompt when the version API is unavailable', () => {
|
||||
expect(getKeychainStartupDecision({
|
||||
portable: false,
|
||||
appVersion: '',
|
||||
approvedVersion: '',
|
||||
accessGranted: false,
|
||||
promptDismissed: true
|
||||
})).toEqual({
|
||||
deferKeychainHydration: true,
|
||||
showKeychainPrompt: false
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps persistent access deferred without prompting when the version API is unavailable', () => {
|
||||
expect(getKeychainStartupDecision({
|
||||
portable: false,
|
||||
appVersion: '',
|
||||
approvedVersion: '1.0.5',
|
||||
accessGranted: true,
|
||||
promptDismissed: false
|
||||
})).toEqual({
|
||||
deferKeychainHydration: true,
|
||||
showKeychainPrompt: false
|
||||
});
|
||||
});
|
||||
|
||||
it('never gates portable pairing on credential-store access', () => {
|
||||
expect(getKeychainStartupDecision({
|
||||
portable: true,
|
||||
appVersion: '1.0.5',
|
||||
approvedVersion: '',
|
||||
accessGranted: false,
|
||||
promptDismissed: false
|
||||
})).toEqual({
|
||||
deferKeychainHydration: false,
|
||||
showKeychainPrompt: false
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
export type KeychainStartupState = {
|
||||
portable: boolean;
|
||||
appVersion: string;
|
||||
approvedVersion: string;
|
||||
accessGranted: boolean;
|
||||
promptDismissed: boolean;
|
||||
};
|
||||
|
||||
export type KeychainStartupDecision = {
|
||||
deferKeychainHydration: boolean;
|
||||
showKeychainPrompt: boolean;
|
||||
};
|
||||
|
||||
export type KeychainAccessReadiness = {
|
||||
portable: boolean;
|
||||
accessGranted: boolean;
|
||||
persistent: boolean;
|
||||
};
|
||||
|
||||
// The semantic app version can remain unchanged across release-candidate and
|
||||
// packaging rebuilds. Use the build identity so an updated binary cannot skip
|
||||
// Firelink's explanation and invoke the OS prompt directly. The policy epoch
|
||||
// remains only as a safe fallback for builds created outside the Git checkout.
|
||||
const KEYCHAIN_CONSENT_POLICY_VERSION = '2';
|
||||
const buildId = typeof import.meta.env.VITE_BUILD_ID === 'string'
|
||||
? import.meta.env.VITE_BUILD_ID.trim()
|
||||
: '';
|
||||
|
||||
export const getKeychainConsentVersion = (appVersion: string): string => {
|
||||
const normalizedVersion = appVersion.trim();
|
||||
const consentIdentity = buildId && buildId !== 'unknown'
|
||||
? `build-${buildId}`
|
||||
: `keychain-policy-${KEYCHAIN_CONSENT_POLICY_VERSION}`;
|
||||
return normalizedVersion
|
||||
? `${normalizedVersion}|${consentIdentity}`
|
||||
: '';
|
||||
};
|
||||
|
||||
export const getKeychainAccessReady = ({
|
||||
portable,
|
||||
accessGranted,
|
||||
persistent
|
||||
}: KeychainAccessReadiness): boolean =>
|
||||
portable ? accessGranted : persistent;
|
||||
|
||||
export const getKeychainStartupDecision = ({
|
||||
portable,
|
||||
appVersion,
|
||||
approvedVersion,
|
||||
accessGranted,
|
||||
promptDismissed
|
||||
}: KeychainStartupState): KeychainStartupDecision => {
|
||||
if (portable) {
|
||||
return {
|
||||
deferKeychainHydration: false,
|
||||
showKeychainPrompt: false
|
||||
};
|
||||
}
|
||||
|
||||
const versionKnown = Boolean(appVersion.trim());
|
||||
const versionChanged = versionKnown && approvedVersion !== appVersion;
|
||||
const mustDeferAccess = !accessGranted || !versionKnown || versionChanged;
|
||||
return {
|
||||
deferKeychainHydration: mustDeferAccess,
|
||||
showKeychainPrompt: versionChanged || (!accessGranted && !promptDismissed)
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,79 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
appendBoundedLogEntries,
|
||||
liveLogEntry,
|
||||
mergeLogSnapshotAndLiveEntries,
|
||||
persistedLogEntry,
|
||||
pushBoundedLogEntry,
|
||||
redactLogText,
|
||||
type LogEntry
|
||||
} from './logEntries';
|
||||
|
||||
const entry = (message: string): LogEntry => ({ level: 'Info', message });
|
||||
|
||||
describe('log entry streaming', () => {
|
||||
it('derives levels from persisted formatted lines', () => {
|
||||
expect(persistedLogEntry('[2026-07-10][18:00:00][ERROR][firelink] failed')).toEqual({
|
||||
level: 'Error',
|
||||
message: '[2026-07-10][18:00:00][ERROR][firelink] failed'
|
||||
});
|
||||
});
|
||||
|
||||
it('does not double-format backend Webview log lines', () => {
|
||||
const message = '[2026-07-10][18:00:00][WARN][firelink] retrying';
|
||||
expect(liveLogEntry(4, message).message).toBe(message);
|
||||
});
|
||||
|
||||
it('formats an unformatted plugin event with its numeric level', () => {
|
||||
expect(liveLogEntry(3, 'download started', new Date('2026-07-10T14:30:00Z'))).toEqual({
|
||||
level: 'Info',
|
||||
message: '[2026-07-10 14:30:00] [INFO] download started'
|
||||
});
|
||||
});
|
||||
|
||||
it('redacts live secrets, signed URL components, and the home path', () => {
|
||||
const message = 'Cookie: session=secret; https://example.com/file?token=signed https://example.com/file#fragment /Users/nima/Downloads/file';
|
||||
const redacted = redactLogText(message, '/Users/nima');
|
||||
|
||||
expect(redacted).toBe('Cookie: [redacted]; https://example.com/file?[redacted] https://example.com/file#[redacted] <HOME>/Downloads/file');
|
||||
expect(redacted).not.toContain('secret');
|
||||
expect(redacted).not.toContain('signed');
|
||||
expect(redacted).not.toContain('/Users/nima');
|
||||
});
|
||||
|
||||
it('redacts live content before it is formatted for display', () => {
|
||||
expect(liveLogEntry(3, 'Authorization: Bearer secret').message).toContain('Authorization: [redacted]');
|
||||
expect(liveLogEntry(3, 'Authorization: Bearer secret').message).not.toContain('secret');
|
||||
});
|
||||
|
||||
it('redacts persisted content and quoted credential fields', () => {
|
||||
const persisted = persistedLogEntry('{"api_key":"json-secret","path":"/Users/nima/file"}', '/Users/nima');
|
||||
|
||||
expect(persisted.message).toContain('api_key');
|
||||
expect(persisted.message).not.toContain('json-secret');
|
||||
expect(persisted.message).not.toContain('/Users/nima');
|
||||
});
|
||||
|
||||
it('merges only the ordered snapshot-to-stream overlap', () => {
|
||||
expect(mergeLogSnapshotAndLiveEntries(
|
||||
[entry('one'), entry('repeat'), entry('three')],
|
||||
[entry('three'), entry('repeat'), entry('four')]
|
||||
).map(item => item.message)).toEqual(['one', 'repeat', 'three', 'repeat', 'four']);
|
||||
});
|
||||
|
||||
it('bounds burst updates to the newest entries', () => {
|
||||
expect(appendBoundedLogEntries(
|
||||
[entry('old')],
|
||||
Array.from({ length: 5 }, (_, index) => entry(`new-${index}`)),
|
||||
3
|
||||
).map(item => item.message)).toEqual(['new-2', 'new-3', 'new-4']);
|
||||
});
|
||||
|
||||
it('bounds the mutable pre-render queue without copying on every event', () => {
|
||||
const queue = [entry('old')];
|
||||
for (let index = 0; index < 5; index += 1) {
|
||||
pushBoundedLogEntry(queue, entry(`new-${index}`), 3);
|
||||
}
|
||||
expect(queue.map(item => item.message)).toEqual(['new-2', 'new-3', 'new-4']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,128 @@
|
||||
export type LogLevel = 'Trace' | 'Debug' | 'Info' | 'Warn' | 'Error';
|
||||
|
||||
export interface LogEntry {
|
||||
level: LogLevel;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export const MAX_LOG_LINES = 2000;
|
||||
|
||||
const LEVEL_NAMES: LogLevel[] = ['Trace', 'Debug', 'Info', 'Warn', 'Error'];
|
||||
const LIVE_LEVELS: Record<number, LogLevel> = {
|
||||
1: 'Trace',
|
||||
2: 'Debug',
|
||||
3: 'Info',
|
||||
4: 'Warn',
|
||||
5: 'Error'
|
||||
};
|
||||
|
||||
const levelFromMessage = (message: string): LogLevel | undefined =>
|
||||
LEVEL_NAMES.find(level => message.includes(`[${level.toUpperCase()}]`));
|
||||
|
||||
export const persistedLogEntry = (message: string, homePath = ''): LogEntry => ({
|
||||
level: levelFromMessage(message) || 'Debug',
|
||||
message: redactLogText(message, homePath)
|
||||
});
|
||||
|
||||
export const redactLogText = (message: string, homePath = ''): string => {
|
||||
const normalizedHome = homePath.trim();
|
||||
const normalizedHomeWithForwardSlashes = normalizedHome.replace(/\\/g, '/');
|
||||
let redacted = message;
|
||||
|
||||
if (normalizedHome) {
|
||||
redacted = redacted.split(normalizedHome).join('<HOME>');
|
||||
}
|
||||
if (normalizedHomeWithForwardSlashes && normalizedHomeWithForwardSlashes !== normalizedHome) {
|
||||
redacted = redacted.split(normalizedHomeWithForwardSlashes).join('<HOME>');
|
||||
}
|
||||
|
||||
redacted = redacted.replace(
|
||||
/(["'])(authorization|proxy-authorization|cookie|set-cookie|password|token|secret|credential|pairing[-_ ]?token|api[-_ ]?key)(["'])(\s*[:=]\s*)["'][^"\r\n,;]*["']/gi,
|
||||
'$1$2$3$4[redacted]'
|
||||
);
|
||||
redacted = redacted.replace(
|
||||
/([A-Za-z][A-Za-z0-9+.-]*:\/\/)[^@\s/?#]+@/g,
|
||||
'$1[redacted]@'
|
||||
);
|
||||
redacted = redacted.replace(
|
||||
/([A-Za-z][A-Za-z0-9+.-]*:\/\/[^\s?]+)\?[^\s]+/g,
|
||||
'$1?[redacted]'
|
||||
);
|
||||
redacted = redacted.replace(
|
||||
/([A-Za-z][A-Za-z0-9+.-]*:\/\/[^\s?#]+)#\S+/g,
|
||||
'$1#[redacted]'
|
||||
);
|
||||
return redacted.replace(
|
||||
/(authorization|proxy-authorization|cookie|set-cookie|password|token|secret|credential|pairing[-_ ]?token|api[-_ ]?key)(\s*)([:=])(\s*)([^\r\n,;]+)/gi,
|
||||
'$1$2$3$4[redacted]'
|
||||
);
|
||||
};
|
||||
|
||||
export const liveLogEntry = (
|
||||
numericLevel: number,
|
||||
message: string,
|
||||
now: Date = new Date(),
|
||||
homePath = ''
|
||||
): LogEntry => {
|
||||
const redactedMessage = redactLogText(message, homePath);
|
||||
const level = levelFromMessage(redactedMessage) || LIVE_LEVELS[numericLevel] || 'Debug';
|
||||
const alreadyFormatted = /^\[\d{4}-\d{2}-\d{2}\]\[\d{2}:\d{2}:\d{2}\]\[(TRACE|DEBUG|INFO|WARN|ERROR)\]/.test(redactedMessage);
|
||||
|
||||
return {
|
||||
level,
|
||||
message: alreadyFormatted
|
||||
? redactedMessage
|
||||
: `[${now.toISOString().replace('T', ' ').substring(0, 19)}] [${level.toUpperCase()}] ${redactedMessage}`
|
||||
};
|
||||
};
|
||||
|
||||
export const appendBoundedLogEntries = (
|
||||
current: LogEntry[],
|
||||
additions: LogEntry[],
|
||||
limit = MAX_LOG_LINES
|
||||
): LogEntry[] => {
|
||||
if (additions.length === 0) return current;
|
||||
const combined = [...current, ...additions];
|
||||
return combined.length > limit ? combined.slice(combined.length - limit) : combined;
|
||||
};
|
||||
|
||||
export const pushBoundedLogEntry = (
|
||||
queue: LogEntry[],
|
||||
entry: LogEntry,
|
||||
limit = MAX_LOG_LINES
|
||||
): void => {
|
||||
queue.push(entry);
|
||||
if (queue.length > limit) {
|
||||
queue.splice(0, queue.length - limit);
|
||||
}
|
||||
};
|
||||
|
||||
// The live target writes after the file target, so entries received while the
|
||||
// initial disk snapshot is loading can also appear at the snapshot's tail.
|
||||
// Remove only the exact ordered overlap; global message de-duplication would
|
||||
// incorrectly hide legitimate repeated log lines.
|
||||
export const mergeLogSnapshotAndLiveEntries = (
|
||||
snapshot: LogEntry[],
|
||||
liveEntries: LogEntry[],
|
||||
limit = MAX_LOG_LINES
|
||||
): LogEntry[] => {
|
||||
const maxOverlap = Math.min(snapshot.length, liveEntries.length);
|
||||
let overlap = 0;
|
||||
|
||||
for (let candidate = maxOverlap; candidate > 0; candidate -= 1) {
|
||||
const snapshotStart = snapshot.length - candidate;
|
||||
let matches = true;
|
||||
for (let index = 0; index < candidate; index += 1) {
|
||||
if (snapshot[snapshotStart + index].message !== liveEntries[index].message) {
|
||||
matches = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (matches) {
|
||||
overlap = candidate;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return appendBoundedLogEntries(snapshot, liveEntries.slice(overlap), limit);
|
||||
};
|
||||
@@ -0,0 +1,61 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const { invoke } = vi.hoisted(() => ({ invoke: vi.fn() }));
|
||||
|
||||
vi.mock('@tauri-apps/api/core', () => ({ invoke }));
|
||||
vi.mock('@tauri-apps/plugin-log', () => ({
|
||||
attachLogger: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
error: vi.fn(),
|
||||
info: vi.fn(),
|
||||
trace: vi.fn(),
|
||||
warn: vi.fn()
|
||||
}));
|
||||
|
||||
import { setLogPaused, setLogStreamActive } from './logger';
|
||||
|
||||
describe('logger stream transitions', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('serializes enable and disable transitions', async () => {
|
||||
let releaseFirst!: () => void;
|
||||
invoke
|
||||
.mockImplementationOnce(() => new Promise<void>(resolve => { releaseFirst = resolve; }))
|
||||
.mockResolvedValueOnce(undefined);
|
||||
|
||||
const enabling = setLogStreamActive(true);
|
||||
const disabling = setLogStreamActive(false);
|
||||
await Promise.resolve();
|
||||
|
||||
expect(invoke).toHaveBeenCalledTimes(1);
|
||||
releaseFirst();
|
||||
await Promise.all([enabling, disabling]);
|
||||
|
||||
expect(invoke.mock.calls).toEqual([
|
||||
['set_log_stream_active', { active: true }],
|
||||
['set_log_stream_active', { active: false }]
|
||||
]);
|
||||
});
|
||||
|
||||
it('serializes pause transitions so the backend cannot finish out of order', async () => {
|
||||
let releaseFirst!: () => void;
|
||||
invoke
|
||||
.mockImplementationOnce(() => new Promise<void>(resolve => { releaseFirst = resolve; }))
|
||||
.mockResolvedValueOnce(undefined);
|
||||
|
||||
const pausing = setLogPaused(true);
|
||||
const resuming = setLogPaused(false);
|
||||
await Promise.resolve();
|
||||
|
||||
expect(invoke).toHaveBeenCalledTimes(1);
|
||||
releaseFirst();
|
||||
await Promise.all([pausing, resuming]);
|
||||
|
||||
expect(invoke.mock.calls).toEqual([
|
||||
['toggle_log_pause', { pause: true }],
|
||||
['toggle_log_pause', { pause: false }]
|
||||
]);
|
||||
});
|
||||
});
|
||||
+17
-3
@@ -5,6 +5,8 @@ import { invoke } from '@tauri-apps/api/core';
|
||||
let isPaused = true;
|
||||
|
||||
let initPromise: Promise<void> | null = null;
|
||||
let logPauseTransition = Promise.resolve();
|
||||
let logStreamTransition = Promise.resolve();
|
||||
|
||||
export const initLogger = () => {
|
||||
if (!initPromise) {
|
||||
@@ -15,9 +17,21 @@ export const initLogger = () => {
|
||||
return initPromise;
|
||||
};
|
||||
|
||||
export const setLogPaused = async (pause: boolean) => {
|
||||
isPaused = pause;
|
||||
await invoke('toggle_log_pause', { pause }).catch(console.error);
|
||||
export const setLogPaused = (pause: boolean): Promise<void> => {
|
||||
const transition = logPauseTransition.then(async () => {
|
||||
await invoke('toggle_log_pause', { pause });
|
||||
isPaused = pause;
|
||||
});
|
||||
logPauseTransition = transition.catch(() => undefined);
|
||||
return transition;
|
||||
};
|
||||
|
||||
export const setLogStreamActive = (active: boolean): Promise<void> => {
|
||||
const transition = logStreamTransition.then(async () => {
|
||||
await invoke('set_log_stream_active', { active });
|
||||
});
|
||||
logStreamTransition = transition.catch(() => undefined);
|
||||
return transition;
|
||||
};
|
||||
|
||||
export const getLogPaused = () => isPaused;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { invokeCommand as invoke } from '../ipc';
|
||||
import type { MediaMetadata } from '../bindings/MediaMetadata';
|
||||
import type { MediaPlaylistMetadata } from '../bindings/MediaPlaylistMetadata';
|
||||
|
||||
type FetchMediaMetadataArgs = {
|
||||
url: string;
|
||||
@@ -13,6 +14,7 @@ type FetchMediaMetadataArgs = {
|
||||
};
|
||||
|
||||
const inFlightMediaMetadata = new Map<string, Promise<MediaMetadata>>();
|
||||
const inFlightMediaPlaylists = new Map<string, Promise<MediaPlaylistMetadata>>();
|
||||
|
||||
const metadataKey = (args: FetchMediaMetadataArgs) =>
|
||||
JSON.stringify([
|
||||
@@ -38,3 +40,18 @@ export const fetchMediaMetadataDeduped = (args: FetchMediaMetadataArgs): Promise
|
||||
inFlightMediaMetadata.set(key, request);
|
||||
return request;
|
||||
};
|
||||
|
||||
export const fetchMediaPlaylistMetadataDeduped = (
|
||||
args: FetchMediaMetadataArgs
|
||||
): Promise<MediaPlaylistMetadata> => {
|
||||
const key = metadataKey(args);
|
||||
const existing = inFlightMediaPlaylists.get(key);
|
||||
if (existing) return existing;
|
||||
|
||||
const request = invoke('fetch_media_playlist_metadata', args)
|
||||
.finally(() => {
|
||||
inFlightMediaPlaylists.delete(key);
|
||||
});
|
||||
inFlightMediaPlaylists.set(key, request);
|
||||
return request;
|
||||
};
|
||||
|
||||
@@ -5,7 +5,8 @@ import { invokeCommand as invoke } from '../ipc';
|
||||
const fallback: PlatformInfo = {
|
||||
os: 'unknown',
|
||||
arch: 'unknown',
|
||||
targetTriple: 'unknown'
|
||||
targetTriple: 'unknown',
|
||||
portable: false
|
||||
};
|
||||
|
||||
let cached: PlatformInfo | null = null;
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { isTrustedFirelinkReleaseUrl } from './releaseUrls';
|
||||
|
||||
describe('Firelink release URLs', () => {
|
||||
it('accepts the repository release page and exact release tags', () => {
|
||||
expect(isTrustedFirelinkReleaseUrl('https://github.com/nimbold/Firelink/releases')).toBe(true);
|
||||
expect(isTrustedFirelinkReleaseUrl('https://github.com/nimbold/Firelink/releases/tag/v1.0.5')).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects lookalike paths and URLs with authority or path tricks', () => {
|
||||
expect(isTrustedFirelinkReleaseUrl('https://github.com/nimbold/Firelink/releases-evil')).toBe(false);
|
||||
expect(isTrustedFirelinkReleaseUrl('https://github.com/nimbold/Firelink/releases%2F..%2Fother')).toBe(false);
|
||||
expect(isTrustedFirelinkReleaseUrl('https://github.com/nimbold/Firelink/releases/tag/v1.0.5%5C..%5Cuser%5Crepo')).toBe(false);
|
||||
expect(isTrustedFirelinkReleaseUrl('https://github.com.evil.example/nimbold/Firelink/releases')).toBe(false);
|
||||
expect(isTrustedFirelinkReleaseUrl('https://github.com/nimbold/Firelink/releases/tag/v1.0.5?redirect=evil')).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
const FIRELINK_RELEASES_PATH = '/nimbold/Firelink/releases';
|
||||
const FIRELINK_RELEASE_TAG_PATTERN = new RegExp(
|
||||
`^${FIRELINK_RELEASES_PATH}/tag/[A-Za-z0-9._-]+$`
|
||||
);
|
||||
|
||||
export const isTrustedFirelinkReleaseUrl = (value: string) => {
|
||||
try {
|
||||
const parsed = new URL(value);
|
||||
if (
|
||||
parsed.protocol !== 'https:'
|
||||
|| parsed.hostname !== 'github.com'
|
||||
|| parsed.port
|
||||
|| parsed.username
|
||||
|| parsed.password
|
||||
|| parsed.search
|
||||
|| parsed.hash
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const pathname = decodeURIComponent(parsed.pathname);
|
||||
return pathname === FIRELINK_RELEASES_PATH
|
||||
|| FIRELINK_RELEASE_TAG_PATTERN.test(pathname);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
+1
-1
@@ -11,7 +11,7 @@ export function extractValidDownloadUrls(text: string): string[] {
|
||||
for (const part of parts) {
|
||||
try {
|
||||
const url = new URL(part);
|
||||
if (url.protocol === 'http:' || url.protocol === 'https:' || url.protocol === 'ftp:') {
|
||||
if (url.protocol === 'http:' || url.protocol === 'https:' || url.protocol === 'ftp:' || url.protocol === 'sftp:') {
|
||||
urls.push(url.toString());
|
||||
}
|
||||
} catch (e) {
|
||||
|
||||
@@ -1,13 +1,69 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
import { createHash } from "node:crypto";
|
||||
import { existsSync, lstatSync, readdirSync, readFileSync, statSync } from "node:fs";
|
||||
import { relative, resolve } from "node:path";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import tailwindcss from '@tailwindcss/vite';
|
||||
|
||||
// @ts-expect-error process is a nodejs global
|
||||
const host = process.env.TAURI_DEV_HOST;
|
||||
|
||||
const buildId = (() => {
|
||||
// Release-candidate builds can keep the same semantic app version. The
|
||||
// consent identity must represent the actual input to this build, not only
|
||||
// the last committed revision: local rebuilds, untracked source files, and
|
||||
// packaged working trees can have different code-signing identities while
|
||||
// sharing the same HEAD or having no Git metadata at all.
|
||||
const configured = process.env.VITE_BUILD_ID?.trim();
|
||||
if (configured) return configured;
|
||||
|
||||
const projectRoot = process.cwd();
|
||||
const inputRoots = [
|
||||
'src',
|
||||
'src-tauri/src',
|
||||
'src-tauri/capabilities',
|
||||
'src-tauri/Cargo.toml',
|
||||
'src-tauri/Cargo.lock',
|
||||
'src-tauri/build.rs',
|
||||
'src-tauri/tauri.conf.json',
|
||||
'index.html',
|
||||
'package.json',
|
||||
'package-lock.json',
|
||||
'vite.config.ts'
|
||||
];
|
||||
const files: string[] = [];
|
||||
const collectFiles = (path: string) => {
|
||||
if (!existsSync(path) || lstatSync(path).isSymbolicLink()) return;
|
||||
const stats = statSync(path);
|
||||
if (stats.isFile()) {
|
||||
files.push(path);
|
||||
return;
|
||||
}
|
||||
if (!stats.isDirectory()) return;
|
||||
for (const entry of readdirSync(path).sort()) {
|
||||
collectFiles(resolve(path, entry));
|
||||
}
|
||||
};
|
||||
|
||||
inputRoots.forEach(input => collectFiles(resolve(projectRoot, input)));
|
||||
if (files.length === 0) return process.env.GITHUB_SHA?.trim() || 'unknown';
|
||||
|
||||
const fingerprint = createHash('sha256');
|
||||
for (const file of files.sort()) {
|
||||
fingerprint.update(relative(projectRoot, file));
|
||||
fingerprint.update('\0');
|
||||
fingerprint.update(readFileSync(file));
|
||||
fingerprint.update('\0');
|
||||
}
|
||||
return fingerprint.digest('hex').slice(0, 24);
|
||||
})();
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig(async () => ({
|
||||
plugins: [react(), tailwindcss()],
|
||||
define: {
|
||||
'import.meta.env.VITE_BUILD_ID': JSON.stringify(buildId)
|
||||
},
|
||||
test: {
|
||||
exclude: [
|
||||
"Extensions/**",
|
||||
|
||||
Reference in New Issue
Block a user