Compare commits

...

115 Commits

Author SHA1 Message Date
NimBold d6b5d6a944 chore(release): prepare Firelink 1.2.0 2026-07-22 16:49:39 +03:30
NimBold efeacf737a fix(i18n): refine Persian status wording 2026-07-22 16:34:08 +03:30
NimBold 6ae790f2c5 fix(ui): restore download table sorting 2026-07-22 14:50:32 +03:30
NimBold 07efb3667d fix(ui): show media connection limits 2026-07-22 14:45:38 +03:30
NimBold 512a77f6bb fix(downloads): bound backend connection counts 2026-07-22 14:37:03 +03:30
NimBold 030db52016 feat(downloads): honor media connection settings 2026-07-22 14:29:01 +03:30
NimBold d1452ce0c1 feat(downloads): expose active Aria2 connections 2026-07-22 14:04:59 +03:30
NimBold 8e706b598c fix(ui): harden download table actions and layout 2026-07-22 13:27:38 +03:30
NimBold 297f2c08fb fix(ui): harden table drag and keychain startup 2026-07-22 11:54:56 +03:30
NimBold 16377aa0d6 fix(ui): stabilize download table column interactions 2026-07-22 10:33:41 +03:30
NimBold 20a136cd1f chore(deps): refresh packages and bundled engines 2026-07-22 09:48:39 +03:30
NimBold e1f83b4f19 fix(i18n): refine localized copy 2026-07-21 23:21:40 +03:30
NimBold a98cce0980 feat(ui): add customizable download table columns
Add persisted column reordering, positional alignment, animated drag feedback, and resize cleanup for the download table. Keep row actions at the trailing edge, preserve RTL/LTR behavior, and include localized column controls and sidebar alignment fixes.
2026-07-21 22:31:34 +03:30
NimBold 81381a5a93 chore(release): prepare 1.1.2 and guard release tags 2026-07-21 20:13:21 +03:30
NimBold 886388d5f2 fix: harden audited download and release paths 2026-07-21 08:39:54 +03:30
NimBold 69ce2b15ba fix(settings): harden audited state synchronization 2026-07-21 05:05:34 +03:30
NimBold 621652ae13 feat(downloads): add opt-in batch folders (#27) 2026-07-20 20:04:07 +03:30
NimBold e0cb124720 feat(downloads): reuse unfinished filename matches
Fixes #26
2026-07-20 19:02:46 +03:30
NimBold c133556d38 fix(downloads): serialize lifecycle control actions 2026-07-20 07:31:58 +03:30
NimBold f423d9bee3 chore(deps): refresh packages and bundled engines 2026-07-20 06:56:09 +03:30
NimBold 2b14b8f58c fix(ui): harden lazy page preloading 2026-07-19 18:18:49 +03:30
NimBold 6e85c0842f fix(startup): enforce keychain consent before credential access
Keep credential-store operations behind a per-process consent gate, prevent duplicate native grant requests, and defer legacy token migration until explicit consent. Harden the RTL/sidebar, custom window controls, bidi copy, and queue editor fixes.

Refs #17
2026-07-19 07:07:29 +03:30
NimBold f47eb7507f fix(ui): preserve LTR left sidebar in RTL
Keep the left sidebar geometry stable across RTL locales and retain the default settings-tab order.

Refs #17
2026-07-19 04:07:33 +03:30
NimBold c427aa2f7a fix(ui): stabilize sidebar and titlebar placement
Keep left RTL sidebars physically aligned with the default layout, swap titlebar actions with side-aware custom controls, and preserve usable titlebar space when the sidebar is visible.

Refs #17
2026-07-19 03:31:37 +03:30
NimBold 23878c2ef8 fix(ui): harden sidebar and custom window controls
Prevent stale inline queue edits, preserve focused queue deletion semantics, and keep Windows custom chrome decoration-free across sidebar layouts.

Refs #17
2026-07-19 03:10:42 +03:30
NimBold 79cd9f0ed1 fix(ui): stabilize side-aware RTL layout
Keep download columns in their default order, mirror sidebar badges by physical side, and reserve titlebar space for desktop controls.

Refs #17
2026-07-19 02:53:37 +03:30
NimBold 60f13f756c fix(ui): harden RTL sidebar and desktop window controls
Keep custom Windows/Linux controls available during platform detection, preserve RTL table order while aligning filenames left, and persist sidebar placement overrides.

Refs #17
2026-07-19 02:34:32 +03:30
NimBold 2668f0b722 fix(media): handle locked browser cookie databases
Retry yt-dlp metadata and media downloads without browser cookies only when the browser database cannot be read, while preserving explicit credentials, cache identity, and cancellation behavior.

Refs #22

Refs #24
2026-07-18 17:22:10 +03:30
NimBold 93ddf427b4 feat(i18n): add localized desktop UI
Refs #17
2026-07-18 13:44:29 +03:30
NimBold e4d7d5ecf0 feat(locations): make directory memory opt-in
Keep the last Add-window directory disabled until enabled in Settings > Locations.

Refs #23
2026-07-18 12:02:17 +03:30
NimBold 160e6af945 feat(locations): remember last Add-window directory
Remember manually selected Add-window folders during the current session, with a persisted Locations toggle and cross-platform path handling.\n\nRefs #23
2026-07-18 11:52:24 +03:30
NimBold c914eeb7b3 feat(i18n): add localization infrastructure and English catalog
Refs #17
2026-07-18 01:10:27 +03:30
NimBold 62365f514e fix(ui): align size header with values 2026-07-17 23:46:06 +03:30
NimBold 9fdd8ac1b8 fix(ui): align size values within column 2026-07-17 23:37:32 +03:30
NimBold 61eb034db1 chore(release): prepare Firelink 1.1.1 2026-07-17 18:58:45 +03:30
NimBold c949cbb9ee fix(ui): align action controls and protect keychain consent 2026-07-17 18:37:50 +03:30
NimBold 566396e629 fix(media): stabilize YouTube size estimates 2026-07-17 17:49:23 +03:30
NimBold db39cd2153 fix: stabilize media progress and cancel controls 2026-07-17 17:12:56 +03:30
NimBold f4b830414d fix: harden audited persistence and download paths 2026-07-17 16:27:39 +03:30
NimBold 0447d1cab7 fix(build): scope Tailwind scanning and remove dead Rust helper
Limit Tailwind source detection to the frontend and root HTML while preserving the CSS-defined theme. Remove redundant test-only nonce validation scaffolding so the Rust library builds without warnings.
2026-07-17 03:20:31 +03:30
NimBold 513143d745 fix(release): harden packaged smoke cleanup 2026-07-17 02:35:36 +03:30
NimBold a8dc4fb447 fix(security): harden browser handoff boundaries 2026-07-17 02:05:41 +03:30
NimBold 50c3da2f5d fix(downloads): close media lifecycle cleanup gaps
Validate every Aria2 mirror URI, keep metadata cleanup errors truthful, and serialize media registration with lifecycle cancellation so abandoned tombstones cannot accumulate.
2026-07-17 01:03:52 +03:30
NimBold 6ef911919d fix(settings): harden settings and diagnostic controls 2026-07-17 00:39:26 +03:30
NimBold 5144ecd39e chore(deps): refresh packages and bundled engines 2026-07-17 00:03:15 +03:30
NimBold a56b859151 fix(downloads): preserve authenticated capture metadata
Keep Gmail attachment filenames and origin-scoped browser cookies intact through metadata redirects, while rejecting Google sign-in responses.\n\nFixes #21
2026-07-16 22:58:12 +03:30
NimBold 469faed7b9 fix(downloads): harden add flow and URL validation 2026-07-16 22:20:12 +03:30
NimBold 79ce0c18a1 fix(downloads): harden aria2 recovery lifecycle 2026-07-16 17:56:00 +03:30
NimBold d195a132b3 perf(downloads): scale playlist rows efficiently 2026-07-16 17:23:43 +03:30
NimBold feb5d8e87d fix(downloads): harden playlist state and scrolling 2026-07-16 16:57:35 +03:30
NimBold a136fa832c feat(downloads): support YouTube playlists 2026-07-16 16:07:00 +03:30
NimBold f3d0e0be13 fix(downloads): recover stalls and honor connection defaults (#19, #20) 2026-07-16 14:28:59 +03:30
NimBold edeef0ac54 fix(startup): enforce consent before download handoffs
Gate clipboard, extension, and deep-link inputs until startup consent is resolved, and serialize extension readiness transitions so native prompts cannot race the app explanation. Identify consent by the build revision so same-version updates re-enter the consent boundary.

Requested by [this X post](https://x.com/ixabolfazl/status/2077356127763804450?s=20).
2026-07-15 21:48:00 +03:30
NimBold 9f333618fc fix(downloads): show total size after completion 2026-07-15 21:30:01 +03:30
NimBold ed54a048ab fix(keychain): version startup consent policy 2026-07-15 21:21:35 +03:30
NimBold d2479f52be fix(startup): restore keychain consent barrier 2026-07-15 21:09:45 +03:30
NimBold 6e6ae51395 fix(downloads): preserve live progress across lifecycle edges 2026-07-15 20:50:46 +03:30
NimBold 1d197432b2 feat(downloads): add live limits clipboard capture and byte progress 2026-07-15 20:35:05 +03:30
NimBold 9917f29743 fix(build): gate platform-specific proxy helper 2026-07-15 12:49:40 +03:30
NimBold df3bc359c4 chore(release): prepare Firelink 1.1.0 2026-07-15 11:55:54 +03:30
NimBold 57f132f53e fix(metadata): decode URL-derived filenames
Decode URL path segments before sanitizing metadata filenames and preserve malformed escapes safely.

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

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

Refs #15

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

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

Refs #15
2026-07-12 23:08:56 +03:30
NimBold 56b4c9f511 fix(release): extract RPM packages with bsdtar 2026-07-12 11:31:52 +03:30
NimBold c1fa87b953 fix(release): parse modern Debian package listings 2026-07-12 11:09:15 +03:30
NimBold c7ec8cd666 chore(release): prepare Firelink 1.0.4 2026-07-12 10:48:00 +03:30
NimBold 9133e3b05b fix(downloads): harden release-critical transfer paths 2026-07-12 07:55:47 +03:30
NimBold 5bbee12602 feat(release): add verified Linux packages 2026-07-12 06:03:54 +03:30
NimBold 7894c05bba fix(ui): keep dialogs clear of window controls 2026-07-11 21:52:01 +03:30
NimBold 33375df2ff fix(downloads): harden queue controls and resumable replacement
Prevent queue action clicks from triggering row double-clicks, preserve aria2/yt-dlp resumable sidecars during duplicate replacement, and make aria2 require resume instead of silently restarting.

Add a persisted, accessible Folders collapse control with reduced-motion animation and regression coverage for replacement sidecar handling.

Fixes #11

Fixes #12

Closes #13

Refs #14
2026-07-11 20:07:33 +03:30
NimBold 1922db8ea0 chore(deps): refresh application dependencies 2026-07-11 19:16:51 +03:30
NimBold ba70662165 feat(downloads): prefill Add modal from clipboard (#10) 2026-07-11 09:01:44 +03:30
NimBold 629a34d1e8 chore: clarify browser extension submodule 2026-07-10 19:23:45 +03:30
NimBold 248f3869ad fix: harden media handoff and live logs
Reject stale extension media cookie headers before yt-dlp metadata work, preserve ordinary capture cookies, and advance the companion extension.

Stream redacted diagnostic logs only while the visible Logs view is active, with bounded batched updates and race-safe snapshot handoff.
2026-07-10 19:02:39 +03:30
NimBold 4f4c655de6 fix: address post-audit regressions across queue, db, and ui
- Preserved extension-captured cookies through the Add modal, with a clean fallback when captured cookies break metadata fetching.
- Prevented batched extension captures from losing URLs or reusing stale cookie/header contexts.
- Fixed pause/resume and enqueue generation races, including cancellation during queue reservation and replay after task removal.
- Made startup database initialization safe under React StrictMode.
- Serialized keyring operations and corrected Linux legacy migration/deletion behavior.
- Restored `Downloading` state after yt-dlp retries.
- Replaced hardcoded media heights with dynamically detected formats, including nonstandard qualities such as 576p and 2880p.
2026-07-10 12:07:25 +03:30
NimBold 3fbd0742be fix(media): reject extension cookie headers for media
Keep explicit media requests on yt-dlp's configured browser-cookie path and preserve cookies for normal captures.
2026-07-10 00:46:00 +03:30
NimBold c5025fd5a0 fix(media): quote forwarded cookie headers
Keep yt-dlp config values intact and require explicit media metadata before downloads can start.
2026-07-10 00:27:44 +03:30
NimBold b1c84a0fb9 fix(downloads): harden enqueue lifecycle races
Reject superseded enqueue generations in the queue manager and coordinate frontend dispatch, pause, removal, and property mutations.
2026-07-10 00:04:21 +03:30
NimBold fbb89cde8e fix(downloads): harden capture and media flows
Scope extension request context to each Add modal row, refresh stale metadata handoffs, and align yt-dlp format and retry behavior with Firelink's transfer contract.
2026-07-09 23:21:16 +03:30
NimBold cd8ab5c12b fix(deps): scope linux keyring entries 2026-07-09 18:15:29 +03:30
NimBold ed7c47cb49 chore(deps): migrate keyring stores 2026-07-09 18:11:26 +03:30
NimBold 8c035167c8 chore(deps): refresh package versions 2026-07-09 18:05:36 +03:30
NimBold 6d9ef68bb0 chore(release): prepare firelink 1.0.3 2026-07-09 04:07:34 +03:30
NimBold 51258b25bc fix(media): smooth transfer speed and eta 2026-07-09 03:40:14 +03:30
NimBold 1baadf6ea6 fix(media): force yt-dlp progress output (#8) 2026-07-09 03:16:44 +03:30
NimBold 60dad5703a fix(media): show live yt-dlp progress (#8)
Buffer yt-dlp output chunks before parsing progress events and update the download row from live progress state instead of imperative DOM writes.
2026-07-09 02:59:37 +03:30
NimBold b8ef712981 ci(release): publish tag builds by default 2026-07-08 22:43:23 +03:30
123 changed files with 28048 additions and 4619 deletions
+109 -15
View File
@@ -6,13 +6,9 @@ on:
workflow_dispatch:
inputs:
publish_release:
description: 'Publish the GitHub release after all platform QA is certified'
description: 'Publish the GitHub release after all platform build and verification jobs pass'
type: boolean
default: false
certified_cross_platform:
description: 'Confirm Windows, Linux, and macOS clean-machine QA passed'
type: boolean
default: false
default: true
permissions:
contents: write
@@ -48,6 +44,8 @@ jobs:
with:
node-version: 22
cache: npm
- name: Verify release version
run: node scripts/verify-release-version.js
- uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.target }}
@@ -65,16 +63,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:
@@ -104,8 +125,55 @@ jobs:
if (-not $installer) { throw "Windows NSIS installer artifact was not produced." }
$extractRoot = "$env:RUNNER_TEMP/firelink-installer"
Remove-Item -Recurse -Force $extractRoot -ErrorAction SilentlyContinue
7z x $installer.FullName "-o$extractRoot" -y
& 7z x $installer.FullName "-o$extractRoot" -y
if ($LASTEXITCODE -ne 0) { throw "7z failed to extract the Windows installer payload (exit code $LASTEXITCODE)." }
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
@@ -132,14 +200,36 @@ 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
if: >-
github.event_name == 'workflow_dispatch' &&
startsWith(github.ref, 'refs/tags/v') &&
inputs.publish_release &&
inputs.certified_cross_platform
(
github.event_name == 'push' ||
(github.event_name == 'workflow_dispatch' && inputs.publish_release)
)
needs: build
runs-on: ubuntu-22.04
steps:
@@ -162,17 +252,21 @@ jobs:
local pattern="$1"
local destination="$2"
local source
source="$(find release-assets -maxdepth 1 -type f -name "$pattern" -print -quit)"
if [[ -z "$source" ]]; then
echo "::error::Missing release asset matching $pattern"
mapfile -d '' -t matches < <(find release-assets -maxdepth 1 -type f -name "$pattern" -print0)
if (( ${#matches[@]} != 1 )); then
echo "::error::Expected exactly one release asset matching $pattern, found ${#matches[@]}"
exit 1
fi
source="${matches[0]}"
mv "$source" "release-assets/$destination"
}
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
+6
View File
@@ -17,8 +17,14 @@ implementation_plan.md
CROSS_PLATFORM_CHECKLIST.md
Cross-platform-checklist-gemini.MD
YouTube_media_download_handoff.md
Release_checklist.md
Release Checklist/
RC Read-only/
RC Read_only/
# Frontend output and logs
.agents/
release_notes.md
node_modules/
dist/
dist-ssr/
+2 -2
View File
@@ -1,3 +1,3 @@
[submodule "Extensions/Firefox"]
path = Extensions/Firefox
[submodule "Extensions/Browser"]
path = Extensions/Browser
url = https://github.com/nimbold/Firelink-Extension.git
+103
View File
@@ -5,6 +5,109 @@ 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.2.0] - 2026-07-22
This release makes everyday download management easier to organize, review, and trust across the desktop app and browser extension.
### New features
- **Localized desktop interface** for Persian, Hebrew, Russian, Ukrainian, and Simplified Chinese, including RTL layouts. These translations are currently produced with help from LLMs and need user review; please report corrections so they can improve.
- **Customizable download table** with selectable columns, drag-to-reorder controls, sorting, clearer size alignment, and better bulk actions.
- **Optional batch folders** for multi-link downloads, addressing [#27](https://github.com/nimbold/Firelink/issues/27). Firelink can suggest an editable folder name from the page title or common filename while keeping the existing category-based behavior as the default.
- **Remember the last Add-window directory**, with an opt-in setting so users remain in control of where it applies.
- **Per-download connection controls** for normal and media transfers, with the active aria2 connection count shown in the download table.
- **Latest Firelink Companion 2.0.6** in the `Extensions/Browser` submodule, including selected-link batch context and safer automatic capture recovery.
### Improvements
- Make pause, resume, retry, remove, redownload, queue, scheduler, and settings actions safer when several operations overlap or background events arrive late.
- Reuse unfinished downloads when their filenames match, while reducing the chance of stale state creating duplicate or misleading rows.
- Improve first-open navigation, lazy page loading, table layout, drag interactions, sorting, and window-control placement.
- Improve RTL behavior while keeping the download table's physical file columns readable.
- Handle locked browser cookie databases more gracefully and keep browser metadata and authenticated captures on the correct path.
- Enforce startup consent before accessing saved credentials and make keychain-related startup behavior more predictable.
- Refresh dependencies and bundled engines, and strengthen checks for macOS, Windows portable, Linux packages, release assets, and release version identity.
- Refine localized wording, including Persian status labels.
### Fixes
- Prevent stale lifecycle work, duplicate terminal events, and overlapping controls from deleting, reviving, or leaving the wrong download in a misleading state.
- Keep missed completion events and connection-limit updates from leaving a finished or active download visually stuck.
- Bound backend connection counts so user-selected limits are respected instead of allowing overlapping settings to exceed them.
- Keep extension handoffs in the Add window and make selected-link batches retain their page context for folder suggestions.
## [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
- Refresh bundled Deno to 2.9.2 and update the TypeScript build toolchain used by the desktop app.
- Keep release publishing aligned with the changelog so tag builds publish GitHub release notes automatically after the platform builds pass.
### Fixed
- Fix YouTube and other yt-dlp downloads that appeared stuck at 0% even while the transfer was active, addressing the progress problem reported around [#8](https://github.com/nimbold/Firelink/issues/8).
- Improve media download speed and ETA updates so short stalls no longer make the main list look frozen or misleading.
- Keep media progress sizes from replacing the final file size until the completed file is actually known.
## [1.0.2] - 2026-07-08
### New
Submodule Extensions/Browser added at 8a9a4c4a63
+49 -78
View File
@@ -5,14 +5,14 @@
**A fast, focused desktop download manager for macOS, Windows, and Linux.**
[![Version](https://img.shields.io/badge/version-1.0.2-6f42c1?style=flat-square)](https://github.com/nimbold/Firelink/releases)
[![macOS](https://img.shields.io/badge/macOS-111111?style=flat-square&logo=apple&logoColor=white)](#platforms)
[![Windows](.github/badges/windows.svg)](#platforms)
[![Linux](https://img.shields.io/badge/Linux-FCC624?style=flat-square&logo=linux&logoColor=black)](#platforms)
[![Version](https://img.shields.io/badge/version-1.2.0-6f42c1?style=flat-square)](https://github.com/nimbold/Firelink/releases)
[![macOS](https://img.shields.io/badge/macOS-111111?style=flat-square&logo=apple&logoColor=white)](#supported-platforms)
[![Windows](.github/badges/windows.svg)](#supported-platforms)
[![Linux](https://img.shields.io/badge/Linux-FCC624?style=flat-square&logo=linux&logoColor=black)](#supported-platforms)
[![License](https://img.shields.io/github/license/nimbold/Firelink?style=flat-square)](LICENSE)
[![CI](https://img.shields.io/github/actions/workflow/status/nimbold/Firelink/ci.yml?branch=main&style=flat-square&label=CI)](https://github.com/nimbold/Firelink/actions/workflows/ci.yml)
[Features](#features) · [Install](#installation) · [Browser Extension](#browser-extension) · [Development](#development) · [Release Notes](CHANGELOG.md)
[Features](#features) · [Install](#installation) · [Browser Extension](#browser-extension) · [Development](#development) · [Changelog](CHANGELOG.md)
</div>
<br/>
@@ -31,22 +31,25 @@
</details>
</div>
## Why Firelink
## Overview
Firelink is a desktop download manager for fast transfers, browser capture, media extraction, scheduling, and clear file placement.
Firelink manages direct downloads, browser captures, media extraction, playlists, scheduling, and file placement from one desktop app. It uses a Rust/Tauri backend with a React and TypeScript interface, and bundles the engines needed for its download and media workflows.
It is now a cross-platform Rust/Tauri app with a React and TypeScript interface. A native backend coordinates downloads with aria2, yt-dlp, FFmpeg, Deno, and SQLite.
The current desktop release is **1.2.0**, paired with [Firelink Companion 2.0.6](https://github.com/nimbold/Firelink-Extension/releases). The release focuses on a more dependable download table and queue, better browser handoffs, broader localization, and safer packaged builds.
## Features
- **Segmented downloads** with aria2, retries, speed limits, and connection controls.
- **Media downloads** with yt-dlp, FFmpeg, and Deno.
- **Add window** for metadata, duplicates, location choices, and captured links.
- **Persistent queues** with pause, resume, retry, redownload, sorting, multi-select, and bulk actions.
- **Scheduling** with start/stop windows, speed rules, and post-queue actions.
- **Media downloads** with yt-dlp, FFmpeg, Deno, live progress, speed, and ETA.
- **Playlist downloads** for YouTube playlists with queueing and efficient rendering for large lists.
- **Customizable download table** with selectable columns, sorting, drag-to-reorder, and bulk actions.
- **Add window** for metadata, duplicate handling, captured links, clipboard-prefilled URLs, save locations, and live connection limits.
- **Persistent queues** with pause, resume, retry, redownload, scheduling, and multi-select actions.
- **Optional batch folders** for grouping related multi-link downloads in a new, editable folder.
- **File organization** with categories, default folders, per-download overrides, and reveal/trash actions.
- **Browser handoff** through local pairing, signed requests, Add window review, replay protection, and server checks.
- **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.
- **Localization** for English, Simplified Chinese, Hebrew, Persian, Ukrainian, and Russian, with RTL support. Non-English translations are LLM-assisted and welcome user review.
- **Diagnostics** with engine health checks, structured logs, and package verification.
## Installation
@@ -57,41 +60,48 @@ 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 notes below. |
| **Linux x64** | `.deb`, `.rpm`, or `.AppImage` | Choose the package for your distribution, or use AppImage as the self-contained option. |
Bundles include the required engines. Users do not need aria2, yt-dlp, FFmpeg, Deno, Python, Homebrew, or another package manager.
All packages include aria2, yt-dlp, FFmpeg, Deno, and SQLite support. You do not need to install those engines separately.
<details>
<summary><strong>Windows portable ZIP notes</strong></summary>
The portable ZIP is a secondary distribution. Extract it to a writable folder and launch `firelink.exe`:
- Keep it out of `Program Files`, read-only media, and folders that block SQLite or WebView writes.
- Settings, queues, logs, and WebView data stay beside the executable 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 or fragment data are not saved in portable queue records.
- Saved site passwords remain in the Windows credential store and are not copied into the archive.
- The 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 the folder to another drive.
- The installer remains the supported path for `firelink://` browser launch registration.
</details>
## Browser Extension
[Firelink Companion](https://github.com/nimbold/Firelink-Extension) connects browser downloads, links, and media pages to Firelink. Captured downloads always open Firelink's Add window so you can review them before starting or queuing them.
It provides automatic capture for regular downloads, explicit **Fetch media** actions, link and selected-text context menus, Firefox and Chromium support, signed local requests, and a safe browser fallback when Firelink is unavailable.
<p align="center">
<a href="https://addons.mozilla.org/en-US/firefox/addon/firelink-companion/"><img src="https://img.shields.io/badge/Install%20from-Firefox%20Add--ons-FF7139?style=for-the-badge&logo=firefox-browser&logoColor=white" alt="Install Firelink Companion from Firefox Add-ons" /></a>
&nbsp;&nbsp;
<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.
Install the extension, open Firelink, and pair it from **Settings -> Integrations**. Use the latest [Firelink Companion 2.0.6 release](https://github.com/nimbold/Firelink-Extension/releases) with Firelink 1.2.0. Chromium users can load `firelink-chromium.zip` by following the [manual installation instructions](https://github.com/nimbold/Firelink-Extension#manual-chromium-installation).
What it adds:
- Automatic capture for regular browser downloads.
- Explicit Fetch media actions from the popup and page context menu.
- Context-menu actions for links and selected text.
- Firefox and Chromium support.
- Signed local requests using the token from **Settings -> Integrations**.
- 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.2.
The extension lives in [Firelink-Extension](https://github.com/nimbold/Firelink-Extension). This repo also vendors it as the `Extensions/Firefox` submodule.
## Platforms
## Supported Platforms
| Target | Status |
| --- | --- |
| **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. |
| **macOS arm64** | Supported with a native build, bundled-engine checks, launch smoke test, and ad-hoc-signed DMG workflow. |
| **Windows x64** | Supported with a native build, engine checks, installer smoke test, NSIS installer, and portable ZIP. |
| **Linux x64** | Supported with native builds, bundled-engine checks, package/AppImage smoke tests, `.deb`, `.rpm`, and AppImage packages. |
## Development
@@ -107,16 +117,10 @@ Clone the repository with its browser-extension submodule:
```sh
git clone --recurse-submodules https://github.com/nimbold/Firelink.git
cd Firelink
```
Install dependencies and launch the desktop app:
```sh
npm install
npm run tauri dev
```
Run the core checks:
Launch the app with `npm run tauri dev`. Run the core checks with:
```sh
node --test scripts/*.node-test.js
@@ -126,49 +130,16 @@ cd src-tauri
cargo test --all-targets
```
Create a production bundle:
```sh
npm run tauri build
```
macOS uses locked payloads in `src-tauri/binaries`. Provision Windows and Linux payloads from checksum-pinned archives:
```sh
node scripts/provision-engines.js --target x86_64-pc-windows-msvc
node scripts/provision-engines.js --target x86_64-unknown-linux-gnu
```
Build staging includes only the current target. See `engines.lock.json`, `engine-sources.lock.json`, and [RELEASE.md](RELEASE.md).
## Repository Structure
```text
.
├── 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
```
See [RELEASE.md](RELEASE.md) for engine provisioning, packaging, and release verification.
## Help and Project Status
- Report bugs or request improvements in [GitHub Issues](https://github.com/nimbold/Firelink/issues).
- Read [CHANGELOG.md](CHANGELOG.md) for release history.
- Review [RELEASE.md](RELEASE.md) for packaging policy and release verification.
- The project is actively maintained, but macOS builds are not notarized and Windows installers are not code-signed yet.
## Technology & Credits
## Technology and License
Firelink is made possible by these open-source projects:
- **[Tauri 2](https://tauri.app/)** for the lightweight desktop runtime
- **[Rust](https://www.rust-lang.org/)** and **[Tokio](https://tokio.rs/)** for native application logic
- **[React](https://react.dev/)** and **[TypeScript](https://www.typescriptlang.org/)** for the interface
- **[Zustand](https://zustand-demo.pmnd.rs/)** for frontend state management
- **[SQLite](https://www.sqlite.org/)** for persistent local data
- **[aria2](https://aria2.github.io/)** for segmented downloading
- **[yt-dlp](https://github.com/yt-dlp/yt-dlp)**, **[FFmpeg](https://ffmpeg.org/)**, and **[Deno](https://deno.com/)** for media extraction and processing
## License
Firelink is built with [Tauri 2](https://tauri.app/), [Rust](https://www.rust-lang.org/), [Tokio](https://tokio.rs/), [React](https://react.dev/), [TypeScript](https://www.typescriptlang.org/), [Zustand](https://zustand-demo.pmnd.rs/), [SQLite](https://www.sqlite.org/), [aria2](https://aria2.github.io/), [yt-dlp](https://github.com/yt-dlp/yt-dlp), [FFmpeg](https://ffmpeg.org/), and [Deno](https://deno.com/).
Firelink is available under the [MIT License](LICENSE).
+13 -7
View File
@@ -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.
+12 -12
View File
@@ -8,14 +8,14 @@
"sha256": "90254845be5282b1f4d843a873abff04f569f857f64250f833fe152b21eec152"
},
"deno": {
"version": "2.9.1",
"url": "https://github.com/denoland/deno/releases/download/v2.9.1/deno-x86_64-pc-windows-msvc.zip",
"sha256": "ab310b4232cca207d40ffa41867e93aaf9f893802bc76756e74f486a6b21b371"
"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"
"version": "8.1.2-29-g703dcc25b9",
"url": "https://github.com/BtbN/FFmpeg-Builds/releases/download/autobuild-2026-07-21-13-38/ffmpeg-n8.1.2-29-g703dcc25b9-win64-gpl-8.1.zip",
"sha256": "ebf57e8b1a10b176b88c3cbc66e68a4aed472cf47520b0fbf003e892fb3be642"
},
"aria2c": {
"version": "1.37.0",
@@ -30,14 +30,14 @@
"sha256": "d7d2d09e900b5ae11821b5784b18cf064984a2bd88b1ca5c798d744bcbe3658b"
},
"deno": {
"version": "2.9.1",
"url": "https://github.com/denoland/deno/releases/download/v2.9.1/deno-x86_64-unknown-linux-gnu.zip",
"sha256": "710c54d63477d1100844ef4818f19507ce0dbf40510903b1d883f19e394446a2"
"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"
"version": "8.1.2-29-g703dcc25b9",
"url": "https://github.com/BtbN/FFmpeg-Builds/releases/download/autobuild-2026-07-21-13-38/ffmpeg-n8.1.2-29-g703dcc25b9-linux64-gpl-8.1.tar.xz",
"sha256": "c6c54589dd3443fb86b441351218d1f0e0cba8221134c3d2e7e3eda80e984747"
},
"aria2c": {
"version": "1.37.0",
+5 -4
View File
@@ -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.1",
"version": "2.9.3",
"source": "https://github.com/denoland/deno",
"build": "official aarch64-apple-darwin executable",
"sha256": "df5e2cca5253ec99b9a630fb059bf01cdbbf586fed1f344017875c5462b2c483"
"sha256": "80c83cdfb20289f8818a71220b570df363f6f0ba93580c29c70f08ab14e93568"
}
},
"runtimeTrees": {
+720 -226
View File
File diff suppressed because it is too large Load Diff
+17 -14
View File
@@ -1,7 +1,7 @@
{
"name": "firelink",
"private": true,
"version": "1.0.2",
"version": "1.2.0",
"description": "A fast cross-platform desktop download manager powered by Rust, Tauri, React, aria2, and yt-dlp.",
"license": "MIT",
"homepage": "https://github.com/nimbold/Firelink",
@@ -31,6 +31,7 @@
"dev": "vite",
"bindings": "cd src-tauri && cargo test export_bindings --lib",
"build": "tsc && vite build",
"check:i18n": "vitest run src/i18n/resources.test.ts",
"check:updates": "node scripts/check-updates.js",
"verify:macos-signing": "node scripts/verify-macos-signing.js",
"preview": "vite preview",
@@ -38,30 +39,32 @@
"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-dialog": "^2.7.2",
"@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",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"i18next": "^26.3.6",
"lucide-react": "^1.25.0",
"react": "^19.2.8",
"react-dom": "^19.2.8",
"react-i18next": "^17.0.10",
"zustand": "^5.0.14"
},
"devDependencies": {
"@tauri-apps/cli": "^2.11.4",
"@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",
"typescript": "^6.0.3",
"vite": "^8.1.3",
"@vitejs/plugin-react": "^6.0.4",
"autoprefixer": "^10.5.4",
"postcss": "^8.5.21",
"tailwindcss": "^4.3.3",
"typescript": "^7.0.2",
"vite": "^8.1.5",
"vitest": "^4.1.10"
}
}
+9
View File
@@ -2,6 +2,7 @@
import fs from 'node:fs';
import path from 'node:path';
import { spawnSync } from 'node:child_process';
import { randomUUID } from 'node:crypto';
import { fileURLToPath } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
@@ -41,6 +42,14 @@ function runNpmScript(script) {
run('npm', ['run', script]);
}
// A packaged Tauri artifact must have its own consent identity. A source or
// commit fingerprint cannot distinguish two fresh signed/package builds made
// from the same checkout, which would let an updated binary silently reuse the
// previous binary's credential-store approval. Keep any configured identity
// as useful provenance, but always add the artifact nonce.
const configuredBuildId = process.env.VITE_BUILD_ID?.trim();
process.env.VITE_BUILD_ID = `${configuredBuildId || 'artifact'}-${randomUUID()}`;
run(process.execPath, ['scripts/stage-engines.js']);
run(process.execPath, ['scripts/verify-binaries.js', '--staged']);
+55 -21
View File
@@ -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) {
+83 -12
View File
@@ -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
View File
@@ -1,5 +1,6 @@
#!/usr/bin/env node
import { execFileSync, spawn } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
function argValue(name) {
@@ -15,6 +16,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
View File
@@ -2,6 +2,7 @@
import fs from 'node:fs';
import path from 'node:path';
import os from 'node:os';
import net from 'node:net';
import { execFileSync, spawn } from 'node:child_process';
import { fileURLToPath } from 'node:url';
@@ -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 {
+272
View File
@@ -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/
);
});
+69
View File
@@ -0,0 +1,69 @@
#!/usr/bin/env node
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const scriptDirectory = path.dirname(fileURLToPath(import.meta.url));
const repositoryRoot = path.resolve(scriptDirectory, '..');
function argValue(name) {
const index = process.argv.indexOf(name);
return index >= 0 ? process.argv[index + 1] : undefined;
}
function readPackageVersion(root) {
return JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8')).version;
}
function readTauriVersion(root) {
return JSON.parse(
fs.readFileSync(path.join(root, 'src-tauri', 'tauri.conf.json'), 'utf8')
).version;
}
function readCargoVersion(root) {
const cargo = fs.readFileSync(path.join(root, 'src-tauri', 'Cargo.toml'), 'utf8');
const packageSection = cargo.match(/^\[package\]\s*([\s\S]*?)(?=^\[)/m)?.[1];
const version = packageSection?.match(/^version\s*=\s*"([^"]+)"/m)?.[1];
if (!version) {
throw new Error('Could not read the [package] version from src-tauri/Cargo.toml.');
}
return version;
}
function versionFromRef(ref) {
if (!ref || !ref.startsWith('v')) {
throw new Error(`Expected a semantic version tag such as v1.2.3, received ${ref || 'nothing'}.`);
}
const version = ref.slice(1);
if (!/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/.test(version)) {
throw new Error(`Release tag ${ref} does not contain a valid semantic version.`);
}
return version;
}
const tag = argValue('--tag') || process.env.GITHUB_REF_NAME;
const expected = versionFromRef(tag);
const versions = {
'package.json': readPackageVersion(repositoryRoot),
'src-tauri/Cargo.toml': readCargoVersion(repositoryRoot),
'src-tauri/tauri.conf.json': readTauriVersion(repositoryRoot),
};
const mismatches = Object.entries(versions)
.filter(([, version]) => version !== expected)
.map(([file, version]) => `${file}=${version}`);
if (mismatches.length > 0) {
console.error(`Release tag ${tag} does not match the application manifests (expected ${expected}).`);
for (const mismatch of mismatches) console.error(` ${mismatch}`);
process.exit(1);
}
const uniqueVersions = new Set(Object.values(versions));
if (uniqueVersions.size !== 1) {
console.error('Application version manifests do not agree:');
for (const [file, version] of Object.entries(versions)) console.error(` ${file}=${version}`);
process.exit(1);
}
console.log(`Release version ${expected} matches ${Object.keys(versions).length} manifests.`);
@@ -0,0 +1,37 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { spawnSync } from 'node:child_process';
import test from 'node:test';
const repositoryRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const verifier = path.join(repositoryRoot, 'scripts', 'verify-release-version.js');
const currentVersion = JSON.parse(
fs.readFileSync(path.join(repositoryRoot, 'package.json'), 'utf8')
).version;
function runVerifier(tag) {
return spawnSync(process.execPath, [verifier, '--tag', tag], {
cwd: repositoryRoot,
encoding: 'utf8',
});
}
test('release version verifier accepts the aligned current version', () => {
const result = runVerifier(`v${currentVersion}`);
assert.equal(result.status, 0, result.stderr);
assert.match(result.stdout, new RegExp(`Release version ${currentVersion} matches`));
});
test('release version verifier rejects the already-published prior tag', () => {
const result = runVerifier('v1.1.1');
assert.equal(result.status, 1);
assert.match(result.stderr, /does not match the application manifests/);
});
test('release version verifier rejects non-semver tag names', () => {
const result = runVerifier('release-candidate');
assert.equal(result.status, 1);
assert.match(result.stderr, /semantic version tag/);
});
+861 -775
View File
File diff suppressed because it is too large Load Diff
+15 -12
View File
@@ -1,6 +1,6 @@
[package]
name = "firelink"
version = "1.0.2"
version = "1.2.0"
description = "A fast cross-platform desktop download manager powered by Rust and Tauri"
authors = ["NimBold"]
edition = "2021"
@@ -25,44 +25,47 @@ tauri-build = { version = "2", features = [] }
[dependencies]
tauri = { version = "2", features = ["macos-private-api", "tray-icon", "image-png", "test"] }
tauri-plugin-opener = "2"
tauri-plugin-dialog = "2"
tauri-plugin-dialog = "2.7.2"
tauri-plugin-shell = "2"
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"
thiserror = "2.0.19"
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"
futures-util = { version = "0.3.32", features = ["sink"] }
tokio-tungstenite = "0.30.0"
futures-util = { version = "0.3.33", 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.
+2 -1
View File
@@ -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
View File
@@ -61,9 +61,8 @@ fn known_download_paths(app_handle: &tauri::AppHandle) -> Result<Vec<PathBuf>, S
}
fn authorize_exact_path(requested: &Path, allowed_paths: &[PathBuf]) -> Result<PathBuf, String> {
if std::fs::symlink_metadata(requested).is_ok_and(|metadata| metadata.file_type().is_symlink())
{
return Err("Download path may not be a symlink".to_string());
if crate::path_has_symlink_component(requested) {
return Err("Download path may not contain symlink components".to_string());
}
let requested = canonicalize_with_missing_leaf(requested)?;
@@ -74,8 +73,9 @@ fn authorize_exact_path(requested: &Path, allowed_paths: &[PathBuf]) -> Result<P
}
let authorized = allowed_paths.iter().any(|allowed| {
canonicalize_with_missing_leaf(allowed)
.is_ok_and(|canonical_allowed| canonical_allowed == requested)
!crate::path_has_symlink_component(allowed)
&& canonicalize_with_missing_leaf(allowed)
.is_ok_and(|canonical_allowed| canonical_allowed == requested)
});
if authorized {
@@ -98,14 +98,30 @@ fn canonicalize_with_missing_leaf(path: &Path) -> Result<PathBuf, String> {
let mut existing = path;
let mut missing = Vec::new();
while !existing.exists() {
let name = existing
.file_name()
.ok_or_else(|| "Download path has no existing ancestor".to_string())?;
missing.push(name.to_owned());
existing = existing
.parent()
.ok_or_else(|| "Download path has no existing ancestor".to_string())?;
loop {
match std::fs::symlink_metadata(existing) {
Ok(metadata) => {
if metadata.file_type().is_symlink() {
return Err("Download path may not contain symlink components".to_string());
}
break;
}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
let name = existing
.file_name()
.ok_or_else(|| "Download path has no existing ancestor".to_string())?;
missing.push(name.to_owned());
existing = existing
.parent()
.ok_or_else(|| "Download path has no existing ancestor".to_string())?;
}
Err(error) => {
return Err(format!(
"Failed to inspect download path '{}': {error}",
path.display()
));
}
}
}
let mut canonical = std::fs::canonicalize(existing)
@@ -144,7 +160,8 @@ mod tests {
#[test]
fn authorizes_only_known_download_files_and_partials() {
let root = tempfile::tempdir().unwrap();
let owned = root.path().join("owned.bin");
let root = fs::canonicalize(root.path()).unwrap();
let owned = root.join("owned.bin");
let outside = tempfile::NamedTempFile::new().unwrap();
fs::write(&owned, b"download").unwrap();
@@ -197,4 +214,22 @@ mod tests {
assert!(authorize_exact_path(&escaped, std::slice::from_ref(&escaped)).is_err());
}
#[cfg(unix)]
#[test]
fn rejects_parent_directory_symlink_escape_from_download_location() {
use std::os::unix::fs::symlink;
let root = tempfile::tempdir().unwrap();
let root_path = fs::canonicalize(root.path()).unwrap();
let outside = tempfile::tempdir().unwrap();
let outside_file = outside.path().join("owned.bin");
fs::write(&outside_file, b"outside").unwrap();
let redirected_parent = root_path.join("downloads");
symlink(outside.path(), &redirected_parent).unwrap();
let escaped = redirected_parent.join("owned.bin");
assert!(authorize_exact_path(&escaped, std::slice::from_ref(&escaped)).is_err());
}
}
+1274 -191
View File
File diff suppressed because it is too large Load Diff
+301 -54
View File
@@ -46,18 +46,29 @@ impl DownloadCoordinator {
.map_err(|_| "download coordinator is unavailable".to_string())
}
pub async fn register_media(&self, id: String) -> Result<watch::Receiver<bool>, String> {
pub async fn register_media(
&self,
id: String,
lifecycle_generation: u64,
) -> Result<watch::Receiver<bool>, String> {
let (cancel_tx, cancel_rx) = watch::channel(false);
self.media_tx
.send(MediaCmd::Register { id, cancel_tx })
.send(MediaCmd::Register {
id,
lifecycle_generation,
cancel_tx,
})
.await
.map_err(|_| "download coordinator is unavailable".to_string())?;
Ok(cancel_rx)
}
pub async fn pause_media(&self, id: String) -> Result<(), String> {
pub async fn pause_media(&self, id: String, lifecycle_generation: u64) -> Result<(), String> {
self.media_tx
.send(MediaCmd::Pause(id))
.send(MediaCmd::Pause {
id,
lifecycle_generation,
})
.await
.map_err(|_| "download coordinator is unavailable".to_string())
}
@@ -65,16 +76,27 @@ impl DownloadCoordinator {
pub async fn pause_media_with_ack(
&self,
id: String,
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();
}
}
+45 -10
View File
@@ -59,7 +59,12 @@ pub fn expected_primary_path(
}
let safe_filename = canonical_download_filename(filename);
Ok(resolved_dest.join(safe_filename))
let path = resolved_dest.join(safe_filename);
if crate::path_has_symlink_component(&path) {
return Err("Download path may not contain symlink components".to_string());
}
crate::canonicalize_with_missing_components(&path)
.ok_or_else(|| "Download path could not be canonicalized".to_string())
}
pub fn register_expected(
@@ -88,13 +93,15 @@ pub fn set_primary_path(
}) {
return Err("Download ownership path traversal is not allowed".to_string());
}
if std::fs::symlink_metadata(path).is_ok_and(|metadata| metadata.file_type().is_symlink()) {
return Err("Download ownership path may not be a symlink".to_string());
if crate::path_has_symlink_component(path) {
return Err("Download ownership path may not contain symlink components".to_string());
}
let canonical_path = crate::canonicalize_with_missing_components(path)
.ok_or_else(|| "Download ownership path could not be canonicalized".to_string())?;
let database = app_handle.state::<crate::db::DbState>();
let connection = database.lock()?;
crate::db::set_ownership(&connection, id, &path.to_string_lossy())
crate::db::set_ownership(&connection, id, &canonical_path.to_string_lossy())
}
pub fn remove(app_handle: &tauri::AppHandle, id: &str) -> Result<(), String> {
@@ -147,11 +154,7 @@ fn legacy_download_queue_paths(app_handle: &tauri::AppHandle) -> Result<Vec<Path
let downloads = {
let database = app_handle.state::<crate::db::DbState>();
let connection = database.lock()?;
crate::db::load_downloads(&connection)?
.into_iter()
.map(|value| serde_json::from_str::<crate::ipc::DownloadItem>(&value))
.collect::<Result<Vec<_>, _>>()
.map_err(|error| format!("Invalid download queue ownership data: {error}"))?
parse_legacy_download_items(crate::db::load_downloads(&connection)?)
};
let mut paths = Vec::new();
@@ -223,9 +226,23 @@ fn legacy_download_queue_paths(app_handle: &tauri::AppHandle) -> Result<Vec<Path
Ok(paths)
}
fn parse_legacy_download_items(values: Vec<String>) -> Vec<crate::ipc::DownloadItem> {
values
.into_iter()
.filter_map(|value| match serde_json::from_str::<crate::ipc::DownloadItem>(&value) {
Ok(download) => Some(download),
Err(error) => {
log::warn!("Skipping malformed download ownership record: {error}");
None
}
})
.collect()
}
#[cfg(test)]
mod tests {
use super::canonical_download_filename;
use super::{canonical_download_filename, parse_legacy_download_items};
use serde_json::json;
#[test]
fn canonicalizes_untrusted_download_filenames() {
@@ -238,4 +255,22 @@ mod tests {
assert_eq!(canonical_download_filename("CON.txt"), "CON-.txt");
assert_eq!(canonical_download_filename("lpt9"), "lpt9-");
}
#[test]
fn malformed_legacy_download_does_not_block_valid_ownership_records() {
let valid = json!({
"id": "download-1",
"url": "https://example.com/file",
"fileName": "file",
"status": "completed",
"category": "Other",
"dateAdded": ""
})
.to_string();
let downloads = parse_legacy_download_items(vec!["not-json".to_string(), valid]);
assert_eq!(downloads.len(), 1);
assert_eq!(downloads[0].id, "download-1");
}
}
+565 -17
View File
@@ -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,25 +71,44 @@ struct ExtensionRequest {
#[serde(default)]
cookies: Option<String>,
#[serde(default)]
cookie_scopes: Option<Vec<ExtensionCookieScope>>,
#[serde(default)]
media: bool,
#[serde(default)]
batch: bool,
#[serde(default)]
batch_name: Option<String>,
}
#[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,
batch: bool,
batch_name: Option<String>,
}
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 +117,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 +134,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 +170,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 +203,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 +232,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 +264,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 +323,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 +374,198 @@ async fn wait_for_frontend(frontend_ready: &SharedFrontendReady) -> bool {
false
}
fn normalize_download(payload: ExtensionRequest) -> Option<ExtensionDownload> {
fn register_extension_ack(
registry: &SharedExtensionAcks,
request_id: String,
) -> Option<oneshot::Receiver<()>> {
let (sender, receiver) = oneshot::channel();
let mut pending = registry.lock().ok()?;
if pending.len() >= MAX_PENDING_EXTENSION_ACKS {
return None;
}
pending.insert(request_id, sender);
Some(receiver)
}
pub fn acknowledge_extension_download(registry: &SharedExtensionAcks, request_id: &str) -> bool {
let Some(sender) = registry
.lock()
.ok()
.and_then(|mut pending| pending.remove(request_id))
else {
return false;
};
sender.send(()).is_ok()
}
fn remove_extension_ack(registry: &SharedExtensionAcks, request_id: &str) {
if let Ok(mut pending) = registry.lock() {
pending.remove(request_id);
}
}
fn normalize_download(mut payload: ExtensionRequest) -> Option<ExtensionDownload> {
if payload.urls.len() > MAX_URL_COUNT {
return None;
}
let mut seen = HashSet::new();
let urls = payload
.urls
.into_iter()
.take(MAX_URL_COUNT)
.filter_map(|raw_url| normalize_url(&raw_url))
.filter(|url| seen.insert(url.clone()))
.collect::<Vec<_>>();
if urls.is_empty() {
return None;
}
if payload.media
&& urls.iter().any(|url| {
Url::parse(url)
.ok()
.is_none_or(|url| !matches!(url.scheme(), "http" | "https"))
})
{
return None;
}
let referer = payload.referer.and_then(|value| {
let url = Url::parse(value.trim()).ok()?;
matches!(url.scheme(), "http" | "https").then(|| url.to_string())
});
let filename = payload.filename.and_then(|value| sanitize_filename(&value));
let batch = payload.batch && urls.len() >= 2;
let batch_name = batch
.then(|| payload.batch_name)
.flatten()
.and_then(|value| {
let value = value.trim().to_string();
(!value.is_empty() && value.chars().count() <= 512).then_some(value)
});
// A multi-URL handoff has no per-URL cookie scope. Keep ordinary
// request headers, but drop credential-bearing 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,
batch,
batch_name,
})
}
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, _)| {
!matches!(
name.trim().to_ascii_lowercase().as_str(),
"authorization"
| "cookie"
| "cookie2"
| "proxy-authorization"
| "set-cookie"
| "set-cookie2"
)
})
.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 +609,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 +657,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 +715,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 +744,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 +766,279 @@ 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,
batch: false,
batch_name: None,
});
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,
batch: false,
batch_name: None,
});
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(&registry, "request-1"));
assert!(!acknowledge_extension_download(&registry, "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={};\nCookie2: stale=1\nAuthorization: Bearer stale\nProxy-Authorization: Basic stale\nSet-Cookie: stale=1\nSet-Cookie2: stale=1\nUser-Agent: Firefox",
"x".repeat(64 * 1024)
)),
cookies: Some(format!("large={}", "x".repeat(64 * 1024))),
cookie_scopes: None,
media: true,
batch: false,
batch_name: None,
})
.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,
batch: false,
batch_name: None,
})
.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,
batch: false,
batch_name: None,
})
.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,
batch: false,
batch_name: None,
})
.expect("valid multi-url handoff");
assert_eq!(download.cookies, None);
assert_eq!(download.headers.as_deref(), Some("User-Agent: Firefox"));
}
#[test]
fn selected_link_batches_preserve_context_only_for_two_or_more_urls() {
let download = normalize_download(ExtensionRequest {
urls: vec![
"https://example.com/one.zip".to_string(),
"https://example.com/two.zip".to_string(),
],
referer: Some("https://example.com/gallery".to_string()),
silent: false,
filename: None,
headers: None,
cookies: None,
cookie_scopes: None,
media: false,
batch: true,
batch_name: Some("Example Gallery / Chapter: 1".to_string()),
})
.expect("valid selected-link batch");
assert!(download.batch);
assert_eq!(
download.batch_name.as_deref(),
Some("Example Gallery / Chapter: 1")
);
}
#[test]
fn selected_link_batch_context_is_dropped_for_single_urls() {
let download = normalize_download(ExtensionRequest {
urls: vec!["https://example.com/one.zip".to_string()],
referer: Some("https://example.com/gallery".to_string()),
silent: false,
filename: None,
headers: None,
cookies: None,
cookie_scopes: None,
media: false,
batch: true,
batch_name: Some("Example Gallery".to_string()),
})
.expect("valid single-link handoff");
assert!(!download.batch);
assert!(download.batch_name.is_none());
}
#[test]
fn signs_server_proof_with_timestamp_nonce_and_bound_port() {
let token = Arc::new(RwLock::new("pairing-token".to_string()));
+30 -8
View File
@@ -2,6 +2,18 @@ use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use ts_rs::TS;
fn default_speed_limit_unit() -> String {
"MB/s".to_string()
}
fn default_language_preference() -> String {
"system".to_string()
}
fn default_sidebar_position() -> String {
"auto".to_string()
}
#[derive(Clone, Copy, Debug, Serialize, Deserialize, TS)]
#[serde(rename_all = "lowercase")]
#[ts(export, export_to = "../../src/bindings/")]
@@ -76,6 +88,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 +128,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)]
@@ -245,6 +265,8 @@ pub struct SchedulerSettings {
#[ts(export, export_to = "../../src/bindings/")]
pub struct PersistedSettings {
pub theme: Theme,
#[serde(default = "default_language_preference")]
pub language: String,
pub base_download_folder: String,
pub category_subfolders_enabled: bool,
pub category_subfolders: HashMap<String, String>,
@@ -255,6 +277,8 @@ pub struct PersistedSettings {
pub speed_limit_preset_values: Vec<f64>,
pub logs_enabled: bool,
pub is_sidebar_visible: bool,
#[serde(default = "default_sidebar_position")]
pub sidebar_position: String,
pub active_settings_tab: SettingsTab,
pub scheduler: SchedulerSettings,
pub scheduler_running: bool,
@@ -262,10 +286,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,
@@ -275,17 +303,10 @@ pub struct PersistedSettings {
pub proxy_port: u16,
pub custom_user_agent: String,
pub ask_where_to_save_each_file: bool,
pub remember_last_used_download_directory: bool,
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 +319,7 @@ pub struct PlatformInfo {
pub os: String,
pub arch: String,
pub target_triple: String,
pub portable: bool,
}
#[derive(Clone, Copy, Debug, Serialize, Deserialize, TS)]
+4286 -569
View File
File diff suppressed because it is too large Load Diff
+7 -4
View File
@@ -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")
+1176 -240
View File
File diff suppressed because it is too large Load Diff
+51 -13
View File
@@ -76,13 +76,14 @@ pub fn backoff_for(strike: usize) -> Duration {
/// that also mentions "timeout" in a URL) still fails fast.
pub fn is_permanent_network_error(message: &str) -> bool {
let m = message.to_ascii_lowercase();
const PERMANENT: [&str; 9] = [
"http 401",
"http 403",
"http 404",
"http 404.",
"http 410",
"http 451",
const PERMANENT_HTTP_STATUS: [&str; 5] = ["401", "403", "404", "410", "451"];
if PERMANENT_HTTP_STATUS
.iter()
.any(|status| contains_http_status(&m, status))
{
return true;
}
const PERMANENT: [&str; 3] = [
"404 not found",
"permission denied",
"no space left on device",
@@ -90,6 +91,32 @@ pub fn is_permanent_network_error(message: &str) -> bool {
PERMANENT.iter().any(|p| m.contains(p))
}
/// Match the HTTP status formats emitted by both clients, including
/// `HTTP/1.1 403` and `HTTP Error 403`, not only the shorthand `HTTP 403`.
fn contains_http_status(message: &str, status: &str) -> bool {
let tokens = message
.split(|character: char| {
character.is_ascii_whitespace()
|| matches!(character, '/' | '.' | ':' | '-' | '_')
})
.filter(|token| !token.is_empty())
.collect::<Vec<_>>();
tokens.windows(2).any(|window| window[0] == "http" && window[1] == status)
|| tokens.windows(3).any(|window| {
window[0] == "http"
&& (window[1] == "error"
|| window[1].chars().all(|character| character.is_ascii_digit()))
&& window[2] == status
})
|| tokens.windows(4).any(|window| {
window[0] == "http"
&& window[1].chars().all(|character| character.is_ascii_digit())
&& window[2].chars().all(|character| character.is_ascii_digit())
&& window[3] == status
})
}
pub fn is_transient_network_error(message: &str) -> bool {
if is_permanent_network_error(message) {
return false;
@@ -97,7 +124,7 @@ pub fn is_transient_network_error(message: &str) -> bool {
let m = message.to_ascii_lowercase();
const TRANSIENT: [&str; 35] = [
const TRANSIENT: [&str; 34] = [
// socket-layer / HTTP-client phrasing surfaced by aria2 and yt-dlp
"timed out",
"timeout",
@@ -113,13 +140,12 @@ pub fn is_transient_network_error(message: &str) -> bool {
"connection aborted",
"error sending request", // reqwest wrapper for connect/send failures
"dns error", // transient resolver failures
"protocol error", // aria2 read/protocol failures after a link drop
"tls handshake failure",
"ssl/tls handshake failure",
// HTTP-level transient
"http 408",
"request timeout",
"http 503",
"503 service unavailable",
"http 429",
"http error 429",
"429 too many requests",
// aria2c HTTP error formats
"status=408",
@@ -138,7 +164,10 @@ pub fn is_transient_network_error(message: &str) -> bool {
"timeout.",
"invalid range header",
];
TRANSIENT.iter().any(|t| m.contains(t))
contains_http_status(&m, "408")
|| contains_http_status(&m, "429")
|| contains_http_status(&m, "503")
|| TRANSIENT.iter().any(|t| m.contains(t))
}
/// Outcome of a cancel-safe backoff sleep wrapped around a transient retry.
@@ -249,6 +278,7 @@ mod tests {
#[test]
fn classifies_http_503_as_transient() {
assert!(is_transient_network_error("HTTP 503 Service Unavailable"));
assert!(is_transient_network_error("HTTP/1.1 503 Service Unavailable"));
assert!(is_transient_network_error(
"http://127.0.0.1/file returned HTTP 503 Service Unavailable"
));
@@ -275,6 +305,12 @@ mod tests {
assert!(is_transient_network_error("The response status is not successful. status=503"));
assert!(is_transient_network_error("The response status is not successful. status=502"));
assert!(is_transient_network_error("Invalid range header. Request: 106954752-361758719/383882118, Response: 106954752-383882117/383882118"));
assert!(is_transient_network_error(
"aria2 error code 1: Failed to receive data, cause: protocol error"
));
assert!(is_transient_network_error(
"SSL/TLS handshake failure: protocol error"
));
}
// --- transient classification: negative cases -------------------------
@@ -282,6 +318,8 @@ mod tests {
#[test]
fn refuses_to_retry_permanent_http_statuses() {
assert!(!is_transient_network_error("HTTP 404 Not Found"));
assert!(!is_transient_network_error("HTTP/1.1 403 Forbidden"));
assert!(!is_transient_network_error("HTTP Error 404: Not Found"));
assert!(!is_transient_network_error("HTTP 403 Forbidden"));
assert!(!is_transient_network_error("HTTP 410 Gone"));
assert!(!is_transient_network_error("HTTP 401 Unauthorized"));
+95 -12
View File
@@ -26,6 +26,26 @@ fn stop_is_due(
&& last_stop_key != stop_key
}
fn overnight_stop_is_due(
stop_time_enabled: bool,
start_minute: Option<u32>,
stop_minute: Option<u32>,
current_minute: u32,
previous_day_allowed: bool,
last_start_key: &str,
previous_start_key: &str,
last_stop_key: &str,
stop_key: &str,
) -> bool {
stop_time_enabled
&& previous_day_allowed
&& start_minute.zip(stop_minute).is_some_and(|(start, stop)| {
stop < start && current_minute >= stop && current_minute < start
})
&& last_start_key == previous_start_key
&& last_stop_key != stop_key
}
pub fn spawn_scheduler(
app_handle: tauri::AppHandle,
settings_cache: Arc<RwLock<Option<crate::ipc::PersistedSettings>>>,
@@ -56,20 +76,20 @@ pub fn spawn_scheduler(
let allowed_today =
scheduler.everyday || scheduler.selected_days.contains(&current_day);
if !allowed_today {
continue;
}
let date_key = now.format("%Y-%m-%d").to_string();
let start_key = format!("{date_key}-start");
let stop_key = format!("{date_key}-stop");
let start_minute = minute_of_day(&scheduler.start_time);
let stop_minute = minute_of_day(&scheduler.stop_time);
let overnight = start_minute
.zip(stop_minute)
.is_some_and(|(start, stop)| stop < start);
let before_stop = !scheduler.stop_time_enabled
|| stop_minute.is_some_and(|stop| current_minute < stop);
if start_minute.is_some_and(|start| current_minute >= start)
&& before_stop
if allowed_today
&& start_minute.is_some_and(|start| current_minute >= start)
&& (overnight || before_stop)
&& scheduler_last_start_key != start_key
&& last_emit
.get("start")
@@ -85,17 +105,43 @@ pub fn spawn_scheduler(
last_emit.insert("start", std::time::Instant::now());
}
if stop_is_due(
let same_day_stop_due = allowed_today
&& !overnight
&& stop_is_due(
scheduler.stop_time_enabled,
stop_minute,
current_minute,
&scheduler_last_start_key,
&start_key,
&scheduler_last_stop_key,
&stop_key,
);
let previous_day = now.date_naive().pred_opt();
let previous_day_allowed = previous_day.is_some_and(|day| {
scheduler.everyday
|| scheduler
.selected_days
.contains(&day.weekday().num_days_from_sunday())
});
let previous_start_key = previous_day
.map(|day| format!("{}-start", day.format("%Y-%m-%d")))
.unwrap_or_default();
let overnight_stop_due = overnight_stop_is_due(
scheduler.stop_time_enabled,
start_minute,
stop_minute,
current_minute,
previous_day_allowed,
&scheduler_last_start_key,
&start_key,
&previous_start_key,
&scheduler_last_stop_key,
&stop_key,
) && last_emit
.get("stop")
.is_none_or(|instant| instant.elapsed() >= Duration::from_secs(5))
);
if (same_day_stop_due || overnight_stop_due)
&& last_emit
.get("stop")
.is_none_or(|instant| instant.elapsed() >= Duration::from_secs(5))
{
let _ = app_handle.emit(
"schedule-trigger",
@@ -113,7 +159,7 @@ pub fn spawn_scheduler(
#[cfg(test)]
mod tests {
use super::{minute_of_day, stop_is_due};
use super::{minute_of_day, overnight_stop_is_due, stop_is_due};
#[test]
fn parses_valid_scheduler_times() {
@@ -150,4 +196,41 @@ mod tests {
"2026-06-22-stop",
));
}
#[test]
fn overnight_stop_uses_the_previous_day_start() {
assert!(overnight_stop_is_due(
true,
Some(1320),
Some(360),
420,
true,
"2026-06-22-start",
"2026-06-22-start",
"",
"2026-06-23-stop",
));
assert!(!overnight_stop_is_due(
true,
Some(1320),
Some(360),
1380,
true,
"2026-06-22-start",
"2026-06-22-start",
"",
"2026-06-22-stop",
));
assert!(!overnight_stop_is_due(
true,
Some(1320),
Some(360),
420,
false,
"2026-06-22-start",
"2026-06-22-start",
"",
"2026-06-23-stop",
));
}
}
+275 -10
View File
@@ -20,6 +20,7 @@ pub fn decode_stored_settings(stored: &Value) -> Result<PersistedSettings, Strin
let document = decode_document(stored)?;
let mut state = settings_state(&document)?.clone();
migrate_location_settings(&mut state)?;
sanitize_persisted_setting_values(&mut state);
let mut merged = serde_json::to_value(default_settings())
.map_err(|error| format!("failed to serialize settings defaults: {error}"))?;
merge_json(&mut merged, &state);
@@ -77,6 +78,41 @@ pub fn preserve_scheduler_runtime_keys(
.map_err(|error| format!("failed to encode persisted settings: {error}"))
}
pub fn preserve_portable_pairing_token(
existing: Option<&str>,
incoming: &str,
) -> Result<String, String> {
let Some(existing) = existing else {
return Ok(incoming.to_string());
};
let existing_document = decode_document(&Value::String(existing.to_string()))?;
let existing_state = settings_state(&existing_document)?;
let Some(token) = existing_state
.get("extensionPairingToken")
.and_then(Value::as_str)
.filter(|value| !value.trim().is_empty())
else {
return Ok(incoming.to_string());
};
let mut incoming_document = decode_document(&Value::String(incoming.to_string()))?;
let incoming_state = settings_state_mut(&mut incoming_document)?;
let incoming_token_present = incoming_state
.get("extensionPairingToken")
.and_then(Value::as_str)
.is_some_and(|value| !value.trim().is_empty());
if !incoming_token_present {
incoming_state.insert(
"extensionPairingToken".to_string(),
Value::String(token.to_string()),
);
}
serde_json::to_string(&incoming_document)
.map_err(|error| format!("failed to encode portable settings: {error}"))
}
fn decode_document(stored: &Value) -> Result<Value, String> {
match stored {
Value::String(text) => serde_json::from_str(text)
@@ -118,13 +154,114 @@ 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,
"language",
&["system", "en", "zh-CN", "he", "fa", "uk", "ru"],
);
sanitize_allowed_string(state, "sidebarPosition", &["auto", "left", "right"]);
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 +269,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> {
@@ -270,6 +416,7 @@ fn derived_location_path(base: &str, subfolder: &str) -> String {
fn default_settings() -> PersistedSettings {
PersistedSettings {
theme: Theme::System,
language: "system".to_string(),
base_download_folder: "~/Downloads".to_string(),
category_subfolders_enabled: true,
category_subfolders: default_category_subfolders(),
@@ -280,6 +427,7 @@ fn default_settings() -> PersistedSettings {
speed_limit_preset_values: vec![1.0, 5.0, 10.0],
logs_enabled: false,
is_sidebar_visible: true,
sidebar_position: "auto".to_string(),
active_settings_tab: SettingsTab::Downloads,
scheduler: SchedulerSettings {
enabled: false,
@@ -296,10 +444,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,
@@ -309,10 +459,10 @@ fn default_settings() -> PersistedSettings {
proxy_port: 8080,
custom_user_agent: String::new(),
ask_where_to_save_each_file: false,
remember_last_used_download_directory: false,
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 +470,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]
@@ -355,6 +508,7 @@ mod tests {
"state": {
"maxConcurrentDownloads": 7,
"globalSpeedLimit": "2M",
"sidebarPosition": "right",
"scheduler": {
"enabled": true,
"startTime": "06:30",
@@ -372,6 +526,7 @@ mod tests {
assert_eq!(settings.max_concurrent_downloads, 7);
assert_eq!(settings.global_speed_limit, "2M");
assert_eq!(settings.sidebar_position, "right");
assert_eq!(settings.speed_limit_preset_values, vec![1.0, 5.0, 10.0]);
assert!(!settings.logs_enabled);
assert!(settings.scheduler.enabled);
@@ -396,6 +551,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 +664,94 @@ mod tests {
assert_eq!(settings.max_concurrent_downloads, 3);
}
#[test]
fn invalid_sidebar_position_uses_automatic_layout() {
let stored = json!({
"state": {"sidebarPosition": "diagonal"},
"version": 5
});
let settings = decode_stored_settings(&Value::String(stored.to_string())).unwrap();
assert_eq!(settings.sidebar_position, "auto");
}
#[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 does_not_remember_last_used_download_directory_by_default() {
assert!(!default_settings().remember_last_used_download_directory);
}
#[test]
fn decodes_disabled_last_used_download_directory_setting() {
let stored = json!({
"state": {
"rememberLastUsedDownloadDirectory": false
},
"version": 3
});
let settings = decode_stored_settings(&Value::String(stored.to_string())).unwrap();
assert!(!settings.remember_last_used_download_directory);
}
#[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 +764,32 @@ mod tests {
assert_eq!(settings.max_concurrent_downloads, 5);
}
#[test]
fn preserves_portable_pairing_token_when_startup_save_races() {
let existing = json!({
"state": {
"extensionPairingToken": "portable-token",
"maxConcurrentDownloads": 3
},
"version": 3
})
.to_string();
let incoming = json!({
"state": {
"extensionPairingToken": "",
"maxConcurrentDownloads": 5
},
"version": 3
})
.to_string();
let merged = preserve_portable_pairing_token(Some(&existing), &incoming).unwrap();
let document: Value = serde_json::from_str(&merged).unwrap();
assert_eq!(
document["state"]["extensionPairingToken"],
"portable-token"
);
assert_eq!(document["state"]["maxConcurrentDownloads"], 5);
}
}
+211
View File
@@ -0,0 +1,211 @@
use std::path::{Path, PathBuf};
use tauri::{AppHandle, Manager, Runtime};
pub const PORTABLE_MARKER: &str = "portable.flag";
const PORTABLE_DATA_DIR: &str = "data";
const PORTABLE_LOG_DIR: &str = "logs";
const PORTABLE_WEBVIEW_DIR: &str = "webview";
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum StorageMode {
Standard,
Portable { root: PathBuf },
}
impl StorageMode {
pub fn detect() -> Self {
let Some(executable) = std::env::current_exe().ok() else {
return Self::Standard;
};
let Some(root) = executable.parent() else {
return Self::Standard;
};
if root.join(PORTABLE_MARKER).is_file() {
Self::Portable {
root: root.to_path_buf(),
}
} else {
Self::Standard
}
}
#[cfg(test)]
fn detect_from_root(root: &Path) -> Self {
if root.join(PORTABLE_MARKER).is_file() {
Self::Portable {
root: root.to_path_buf(),
}
} else {
Self::Standard
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct StorageLayout {
mode: StorageMode,
data_dir: PathBuf,
log_dir: PathBuf,
webview_dir: PathBuf,
}
impl StorageLayout {
pub fn resolve<R: Runtime>(
app_handle: &AppHandle<R>,
mode: StorageMode,
) -> Result<Self, String> {
let (mode, data_dir, log_dir, webview_dir) = match mode {
StorageMode::Standard => (
StorageMode::Standard,
app_handle
.path()
.app_data_dir()
.map_err(|error| format!("failed to resolve app data directory: {error}"))?,
app_handle
.path()
.app_log_dir()
.map_err(|error| format!("failed to resolve app log directory: {error}"))?,
app_handle.path().app_local_data_dir().map_err(|error| {
format!("failed to resolve app local data directory: {error}")
})?,
),
StorageMode::Portable { root } => {
let data_dir = root.join(PORTABLE_DATA_DIR);
(
StorageMode::Portable { root },
data_dir.clone(),
data_dir.join(PORTABLE_LOG_DIR),
data_dir.join(PORTABLE_WEBVIEW_DIR),
)
}
};
Ok(Self {
mode,
data_dir: canonicalize_storage_path(&data_dir)?,
log_dir: canonicalize_storage_path(&log_dir)?,
webview_dir: canonicalize_storage_path(&webview_dir)?,
})
}
pub fn is_portable(&self) -> bool {
matches!(self.mode, StorageMode::Portable { .. })
}
pub fn data_dir(&self) -> &Path {
&self.data_dir
}
pub fn log_dir(&self) -> &Path {
&self.log_dir
}
pub fn webview_dir(&self) -> &Path {
&self.webview_dir
}
}
fn canonicalize_storage_path(path: &Path) -> Result<PathBuf, String> {
if crate::path_has_symlink_component(path) {
return Err(format!(
"storage path contains a symlinked component: '{}'",
path.display()
));
}
let mut existing = path;
let mut missing = Vec::new();
loop {
match std::fs::symlink_metadata(existing) {
Ok(metadata) => {
if metadata.file_type().is_symlink() {
return Err(format!(
"storage path contains a symlinked directory: '{}'",
path.display()
));
}
break;
}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
Err(error) => {
return Err(format!(
"failed to inspect storage path '{}': {error}",
path.display()
));
}
}
missing.push(
existing
.file_name()
.ok_or_else(|| format!("storage path has no existing ancestor: '{}'", path.display()))?
.to_owned(),
);
existing = existing
.parent()
.ok_or_else(|| format!("storage path has no existing ancestor: '{}'", path.display()))?;
}
let mut canonical = std::fs::canonicalize(existing)
.map_err(|error| format!("failed to canonicalize storage path '{}': {error}", path.display()))?;
for component in missing.iter().rev() {
canonical.push(component);
}
Ok(canonical)
}
#[cfg(test)]
mod tests {
use super::{canonicalize_storage_path, StorageMode, PORTABLE_MARKER};
use std::fs;
use std::path::Path;
use tempfile::TempDir;
#[test]
fn marker_selects_portable_mode() {
let root = TempDir::new().unwrap();
fs::write(root.path().join(PORTABLE_MARKER), b"portable\n").unwrap();
assert_eq!(
StorageMode::detect_from_root(root.path()),
StorageMode::Portable {
root: root.path().to_path_buf()
}
);
}
#[test]
fn missing_marker_keeps_standard_mode() {
let root = TempDir::new().unwrap();
assert_eq!(
StorageMode::detect_from_root(root.path()),
StorageMode::Standard
);
}
#[cfg(unix)]
#[test]
fn rejects_symlinked_storage_directories() {
use std::os::unix::fs::symlink;
let root = TempDir::new().unwrap();
let target = TempDir::new().unwrap();
let root_path = fs::canonicalize(root.path()).unwrap();
let redirected = root_path.join("logs");
symlink(target.path(), &redirected).unwrap();
assert!(canonicalize_storage_path(Path::new(&redirected)).is_err());
}
#[cfg(unix)]
#[test]
fn rejects_dangling_symlinked_storage_directories() {
use std::os::unix::fs::symlink;
let root = TempDir::new().unwrap();
let root_path = fs::canonicalize(root.path()).unwrap();
let redirected = root_path.join("logs");
symlink(root_path.join("missing-target"), &redirected).unwrap();
assert!(canonicalize_storage_path(Path::new(&redirected)).is_err());
}
}
+2 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Firelink",
"version": "1.0.2",
"version": "1.2.0",
"identifier": "com.nimbold.firelink",
"build": {
"beforeDevCommand": "node scripts/stage-engines.js && npm run dev",
@@ -13,6 +13,7 @@
"macOSPrivateApi": true,
"windows": [
{
"create": false,
"title": "Firelink",
"width": 1280,
"height": 760,
+15 -2
View File
@@ -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"
}
}
}
}
+3 -10
View File
@@ -2,22 +2,15 @@
"app": {
"windows": [
{
"create": false,
"title": "Firelink",
"width": 1280,
"height": 760,
"minWidth": 960,
"minHeight": 640,
"titleBarStyle": "Overlay",
"trafficLightPosition": {
"x": 17,
"y": 28
},
"hiddenTitle": true,
"transparent": true,
"windowEffects": {
"effects": ["sidebar"],
"state": "active"
}
"decorations": false,
"shadow": false
}
]
},
+3 -4
View File
@@ -2,16 +2,15 @@
"app": {
"windows": [
{
"create": false,
"title": "Firelink",
"width": 1280,
"height": 760,
"minWidth": 960,
"minHeight": 640,
"transparent": true,
"windowEffects": {
"effects": ["mica"],
"state": "active"
}
"decorations": false,
"shadow": false
}
]
},
+683 -8
View File
@@ -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,101 @@ 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 gid_mapping_snapshot_is_invalid_after_epoch_or_gid_replacement() {
let (mgr, _spawner) = make_manager(1);
mgr.remember_gid("snapshot".to_string(), "gid-old".to_string())
.await;
let old_mapping = mgr
.aria2_gid_mapping("gid-old")
.expect("the first gid should be mapped");
assert!(mgr.is_current_aria2_gid_mapping("gid-old", &old_mapping));
let resume_epoch = mgr.next_aria2_control_epoch("snapshot").await;
assert!(mgr
.rebind_aria2_gid_epoch("snapshot", "gid-old", resume_epoch)
.await);
assert!(!mgr.is_current_aria2_gid_mapping("gid-old", &old_mapping));
mgr.remember_gid("snapshot".to_string(), "gid-new".to_string())
.await;
assert!(mgr.aria2_gid_mapping("gid-old").is_none());
let new_mapping = mgr
.aria2_gid_mapping("gid-new")
.expect("the replacement gid should be mapped");
assert!(mgr.is_current_aria2_gid_mapping("gid-new", &new_mapping));
}
#[tokio::test]
async fn forgetting_aria2_gid_clears_mapping_without_releasing_twice() {
let (mgr, _spawner) = make_manager(1);
@@ -332,6 +574,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 +584,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 +603,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 +668,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 +705,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 +1225,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;
+532 -154
View File
File diff suppressed because it is too large Load Diff
-52
View File
@@ -1,52 +0,0 @@
import { Component, ErrorInfo, ReactNode } from "react";
interface Props {
children?: ReactNode;
}
interface State {
hasError: boolean;
error: Error | null;
errorInfo: ErrorInfo | null;
}
export class ErrorBoundary extends Component<Props, State> {
public state: State = {
hasError: false,
error: null,
errorInfo: null
};
public static getDerivedStateFromError(error: Error): State {
return { hasError: true, error, errorInfo: null };
}
public componentDidCatch(error: Error, errorInfo: ErrorInfo) {
console.error("Uncaught error:", error, errorInfo);
this.setState({ errorInfo });
}
public render() {
if (this.state.hasError) {
return (
<div className="flex h-screen w-screen items-center justify-center bg-main-bg p-8 text-text-primary">
<div className="app-card max-w-lg space-y-4 p-6 text-center">
<h1 className="text-xl font-semibold">Firelink could not display this window.</h1>
<p className="text-sm text-text-secondary">
The error was written to Logs. Reload the interface to reconnect to the running download service.
</p>
<button
type="button"
className="app-button app-button-primary px-4"
onClick={() => window.location.reload()}
>
Reload Firelink
</button>
</div>
</div>
);
}
return this.props.children;
}
}
+1 -1
View File
@@ -2,4 +2,4 @@
import type { DownloadCategory } from "./DownloadCategory";
import type { DownloadStatus } from "./DownloadStatus";
export type DownloadItem = { id: string, url: string, fileName: string, status: DownloadStatus, fraction?: number, speed?: string, eta?: string, size?: string, category: DownloadCategory, dateAdded: string, resumable?: boolean, connections?: number, speedLimit?: string, username?: string, password?: string, headers?: string, checksum?: string, cookies?: string, mirrors?: string, destination?: string, isMedia?: boolean, mediaFormatSelector?: string, queueId?: string, queuePosition?: number, hasBeenDispatched?: boolean, lastError?: string, };
export type DownloadItem = { id: string, url: string, fileName: string, status: DownloadStatus, fraction?: number, speed?: string, eta?: string, size?: string, 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 -1
View File
@@ -1,3 +1,3 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type 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, active_connections?: number, requested_connections?: number, };
+1 -1
View File
@@ -1,3 +1,3 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type 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, };
+3
View File
@@ -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, };
+2 -1
View File
@@ -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, batch: boolean, batch_name: string | null, };
+3
View File
@@ -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, };
+4
View File
@@ -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>, };
+1 -10
View File
@@ -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, language: string, 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, sidebarPosition: string, 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, rememberLastUsedDownloadDirectory: boolean, preventsSleepWhileDownloading: boolean, mediaCookieSource: MediaCookieSource, siteLogins: Array<SiteLogin>, autoCheckUpdates: boolean, keychainAccessGranted: boolean, };
+1 -1
View File
@@ -1,3 +1,3 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type PlatformInfo = { os: string, arch: string, targetTriple: string, };
export type PlatformInfo = { os: string, arch: string, targetTriple: string, portable: boolean, };
File diff suppressed because it is too large Load Diff
+34 -9
View File
@@ -1,8 +1,10 @@
import React, { useState, useEffect } from 'react';
import { useDownloadStore } from '../store/useDownloadStore';
import { AlertTriangle } from 'lucide-react';
import { useTranslation } from 'react-i18next';
export const DeleteConfirmationModal: React.FC = () => {
const { t } = useTranslation();
const { deleteModalState, closeDeleteModal, removeDownload } = useDownloadStore();
const [errorMessage, setErrorMessage] = useState('');
const [isRemoving, setIsRemoving] = useState(false);
@@ -14,6 +16,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 = () => {
@@ -41,7 +52,11 @@ export const DeleteConfirmationModal: React.FC = () => {
}
if (failures.length > 0) {
setErrorMessage(`${succeeded} removed, ${failures.length} failed: ${failures[0]}`);
setErrorMessage(t($ => $.dialogs.removeDownload.errorSummary, {
succeeded,
failed: failures.length,
detail: failures[0],
}));
setIsRemoving(false);
return;
}
@@ -51,22 +66,32 @@ export const DeleteConfirmationModal: React.FC = () => {
const handleRemoveFromList = () => removeMany(false);
const handleDeleteFile = () => removeMany(true);
const itemCount = deleteModalState.downloadIds?.length ?? 0;
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">
<div className="p-2 bg-red-500/10 rounded-full flex items-center justify-center">
<AlertTriangle size={20} className="text-red-400" />
</div>
<h2 className="text-lg font-semibold text-text-primary m-0">Remove Download</h2>
<h2 className="text-lg font-semibold text-text-primary m-0">{t($ => $.dialogs.removeDownload.title)}</h2>
</div>
<div className="px-5 py-6 flex-1 text-sm text-text-secondary leading-relaxed">
{`Are you sure you want to remove ${deleteModalState.downloadIds?.length && deleteModalState.downloadIds.length > 1 ? 'these ' + deleteModalState.downloadIds.length + ' items' : 'this item'} from the list? You can also choose to delete the underlying file from your hard drive.`}
{itemCount > 1
? t($ => $.dialogs.removeDownload.confirmationMultiple, { count: itemCount })
: t($ => $.dialogs.removeDownload.confirmationSingle)}
{errorMessage && <div className="mt-3 text-xs text-red-400">{errorMessage}</div>}
</div>
@@ -74,23 +99,23 @@ 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
{t($ => $.actions.cancel)}
</button>
<button
onClick={handleRemoveFromList}
disabled={isRemoving}
className="px-4 py-2 rounded-lg text-sm font-medium transition-colors bg-border-modal hover:bg-border-modal/80 text-text-primary disabled:opacity-50"
>
Remove
{t($ => $.dialogs.removeDownload.remove)}
</button>
<button
onClick={handleDeleteFile}
disabled={isRemoving}
className="px-4 py-2 rounded-lg text-sm font-medium transition-colors bg-red-500/20 text-red-400 hover:bg-red-500/30 disabled:opacity-50"
>
Delete file
{t($ => $.dialogs.removeDownload.deleteFile)}
</button>
</div>
</div>
+322 -175
View File
@@ -1,152 +1,260 @@
import React, { useEffect, useRef } from 'react';
import { useShallow } from 'zustand/react/shallow';
import { useDownloadStore } from '../store/useDownloadStore';
import { useDownloadProgressStore } from '../store/downloadStore';
import React from 'react';
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 { canPauseDownload, canStartDownload } from '../utils/downloadActions';
import { useTranslation } from 'react-i18next';
import {
downloadProgressColorClass,
formatDownloadTotal,
resolveDownloadSizeDisplay
} from '../utils/downloadProgress';
import {
COLUMN_ALIGNMENT_JUSTIFY,
DOWNLOAD_ACTIONS_COLUMN_WIDTH,
DOWNLOAD_ACTIONS_VIEWPORT_INSET,
getColumnGridColumn,
type DownloadColumnAlignment,
type DownloadTableColumnKey
} from '../utils/downloadTableColumns';
interface DownloadItemProps {
downloadId: string;
download: DownloadItemType;
index: number;
queueIndex: number;
queueLength: number;
columnOrder: DownloadTableColumnKey[];
columnAlignments: Record<DownloadTableColumnKey, DownloadColumnAlignment>;
tableGridTemplate: string;
tableMinWidth: number | 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,
columnOrder,
columnAlignments,
tableGridTemplate,
tableMinWidth,
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 queueIndex = queueItems.indexOf(downloadId);
const { t } = useTranslation();
const liveProgress = useDownloadProgressStore(state => state.progressMap[download.id]);
const rowRef = React.useRef<HTMLDivElement>(null);
const [isRowHovered, setIsRowHovered] = React.useState(false);
const [isRowFocused, setIsRowFocused] = React.useState(false);
const [actionPosition, setActionPosition] = React.useState<React.CSSProperties | undefined>();
const isActionVisible = isRowHovered || isRowFocused;
const progressBarRef = useRef<HTMLDivElement>(null);
const statusTextRef = useRef<HTMLSpanElement>(null);
const speedTextRef = useRef<HTMLSpanElement>(null);
const etaTextRef = useRef<HTMLSpanElement>(null);
const updateActionPosition = React.useCallback(() => {
const row = rowRef.current;
const view = row?.closest<HTMLElement>('.downloads-view');
if (!row || !view) return;
useEffect(() => {
if (!download || download.status !== 'downloading') return;
const applyProgress = (progress: any) => {
if (!progress) return;
if (progressBarRef.current) {
progressBarRef.current.style.width = `${progress.fraction * 100}%`;
}
if (statusTextRef.current) {
statusTextRef.current.innerText = `${(progress.fraction * 100).toFixed(0)}%`;
statusTextRef.current.title = `${(progress.fraction * 100).toFixed(0)}%`;
}
if (speedTextRef.current) {
speedTextRef.current.innerText = progress.speed;
speedTextRef.current.title = progress.speed;
}
if (etaTextRef.current) {
etaTextRef.current.innerText = progress.eta;
etaTextRef.current.title = progress.eta;
}
const horizontalViewport = row.closest<HTMLElement>('.download-table-scroll') ?? view;
const verticalViewport = row.closest<HTMLElement>('.download-table-list') ?? view;
const rowRect = row.getBoundingClientRect();
const horizontalViewportRect = horizontalViewport.getBoundingClientRect();
const verticalViewportRect = verticalViewport.getBoundingClientRect();
const viewportTolerance = 1;
const visibleTop = Math.max(rowRect.top, verticalViewportRect.top);
const visibleBottom = Math.min(rowRect.bottom, verticalViewportRect.bottom);
const visibleHeight = Math.max(0, visibleBottom - visibleTop);
const isVisible = visibleHeight > viewportTolerance;
const actionHeight = Math.min(rowRect.height, visibleHeight);
const nextPosition: React.CSSProperties = {
top: visibleTop,
right: Math.max(0, window.innerWidth - horizontalViewportRect.right) + DOWNLOAD_ACTIONS_VIEWPORT_INSET,
height: actionHeight,
overflow: actionHeight < rowRect.height ? 'hidden' : 'visible',
visibility: isVisible ? 'visible' : 'hidden',
};
// Apply immediate state on mount
applyProgress(useDownloadProgressStore.getState().progressMap[downloadId]);
setActionPosition(previous => (
previous?.top === nextPosition.top &&
previous?.right === nextPosition.right &&
previous?.height === nextPosition.height &&
previous?.overflow === nextPosition.overflow &&
previous?.visibility === nextPosition.visibility
? previous
: nextPosition
));
}, []);
const unsubscribe = useDownloadProgressStore.subscribe((state) => {
applyProgress(state.progressMap[downloadId]);
React.useEffect(() => {
if (!isActionVisible) return;
let frame = 0;
const schedulePositionUpdate = () => {
if (frame) window.cancelAnimationFrame(frame);
frame = window.requestAnimationFrame(() => {
frame = 0;
updateActionPosition();
});
};
const row = rowRef.current;
const view = row?.closest<HTMLElement>('.downloads-view');
const horizontalViewport = row?.closest<HTMLElement>('.download-table-scroll');
const verticalViewport = row?.closest<HTMLElement>('.download-table-list');
const resizeObserver = typeof ResizeObserver === 'undefined'
? null
: new ResizeObserver(schedulePositionUpdate);
updateActionPosition();
window.addEventListener('resize', schedulePositionUpdate);
window.addEventListener('scroll', schedulePositionUpdate, true);
[row, view, horizontalViewport, verticalViewport].forEach(element => {
if (element) resizeObserver?.observe(element);
});
return () => unsubscribe();
}, [downloadId, download?.status]);
return () => {
window.removeEventListener('resize', schedulePositionUpdate);
window.removeEventListener('scroll', schedulePositionUpdate, true);
resizeObserver?.disconnect();
if (frame) window.cancelAnimationFrame(frame);
};
}, [isActionVisible, updateActionPosition]);
if (!download) return null;
const displayFraction = download.status === 'downloading'
? liveProgress?.fraction ?? download.fraction ?? 0
: download.fraction ?? 0;
const displayPercent = `${(displayFraction * 100).toFixed(0)}%`;
const displaySpeed = download.status === 'downloading'
? liveProgress?.speed ?? download.speed
: download.status === 'processing'
? t($ => $.downloads.values.processing)
: '-';
const displayEta = download.status === 'downloading'
? liveProgress?.eta ?? download.eta
: download.status === 'processing'
? t($ => $.downloads.values.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 = (() => {
const value = download.status === 'completed' ? formatDownloadTotal(sizeDisplay) : sizeDisplay.fallback;
return value === 'Unknown' ? t($ => $.addDownloads.unknown) : value;
})();
const downloadStatusLabel = t($ => $.downloads.status[download.status]);
const downloadedSizeLabel = sizeDisplay.totalIsEstimate
? t($ => $.downloads.size.downloadedOfApproximate, {
downloaded: sizeDisplay.downloaded ?? '',
total: sizeDisplay.total ?? '',
unit: sizeDisplay.unit ?? '',
})
: t($ => $.downloads.size.downloadedOf, {
downloaded: sizeDisplay.downloaded ?? '',
total: sizeDisplay.total ?? '',
unit: sizeDisplay.unit ?? '',
});
return (
<div
className={`download-row group cursor-default relative ${index % 2 !== 0 ? 'striped' : ''} ${selectedIds.has(downloadId) ? 'is-selected' : ''}`}
style={{ gridTemplateColumns: tableGridTemplate }}
onClick={(e) => onClick(e, download)}
onContextMenu={(e) => {
e.preventDefault();
setContextMenu({ x: e.clientX, y: e.clientY, id: download.id });
}}
>
<div className="download-file-cell">
<span className="shrink-0 text-text-muted">
{getCategoryIcon(download.category)}
</span>
<span className="download-file-name" title={download.fileName}>
{download.fileName}
</span>
const columnStyle = (key: DownloadTableColumnKey): React.CSSProperties => ({
'--column-justify': COLUMN_ALIGNMENT_JUSTIFY[columnAlignments[key]],
gridColumn: getColumnGridColumn(key, columnOrder),
} as React.CSSProperties);
const cells: Record<DownloadTableColumnKey, React.ReactNode> = {
'File Name': (
<div
className="download-column-cell download-file-cell download-column-file-name"
style={columnStyle('File Name')}
>
<div className="download-cell-content">
<span className="shrink-0 text-text-muted">
{getCategoryIcon(download.category)}
</span>
<span className="download-file-name" title={download.fileName}>
{download.fileName}
</span>
</div>
</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'}
</span>
),
Size: (
<div
className="download-column-cell download-cell-truncate download-size-cell tabular-nums"
style={columnStyle('Size')}
title={hasDownloadedAmount ? downloadedSizeLabel : completedSizeLabel}
aria-label={hasDownloadedAmount ? downloadedSizeLabel : completedSizeLabel}
>
<div className="download-cell-content download-size-content">
{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>
</div>
<div className="download-status-cell">
),
Status: (
<div
className="download-column-cell download-status-cell"
data-column-alignment={columnAlignments.Status}
style={columnStyle('Status')}
>
{download.status === 'completed' ? (
<span className="download-status download-status-completed" title="Completed">Completed</span>
<div className="download-cell-content download-status-content download-status-content-static">
<span className="download-status download-status-completed" title={downloadStatusLabel}>
{downloadStatusLabel}
</span>
</div>
) : (
<>
<div className="download-cell-content download-status-content">
<div className="download-progress-track">
<div
ref={progressBarRef}
className={`download-progress-fill ${
download.status === 'paused' ? 'paused' :
download.status === 'paused' ? 'paused' :
download.status === 'processing' ? 'processing' :
download.status === 'queued' || download.status === 'staged' ? 'queued' :
download.status === 'retrying' ? 'retrying' : ''
}`}
style={{ width: `${(download.fraction || 0) * 100}%` }}
style={{ width: `${displayFraction * 100}%` }}
/>
</div>
<span
key={`status-${download.status}`}
ref={statusTextRef}
title={
download.lastError && (download.status === 'failed' || download.status === 'retrying')
? download.lastError
: (download.status === 'queued' || download.status === 'staged') && queueIndex !== -1
? `${download.status === 'staged' ? 'In queue' : 'Queued'} #${queueIndex + 1}`
: download.status === 'downloading'
? `${((download.fraction || 0) * 100).toFixed(0)}%`
<span
title={
download.lastError && (download.status === 'failed' || download.status === 'retrying')
? download.lastError
: (download.status === 'queued' || download.status === 'staged') && queueIndex !== -1
? `${downloadStatusLabel} #${queueIndex + 1}`
: download.status === 'downloading'
? displayPercent
: download.status === 'processing'
? 'Processing'
: download.status.charAt(0).toUpperCase() + download.status.slice(1)
}
className={`download-status flex items-center gap-1.5 ${
download.status === 'paused' ? 'download-status-paused' :
download.status === 'failed' ? 'download-status-failed' :
? downloadStatusLabel
: downloadStatusLabel
}
className={`download-status flex items-center gap-1.5 ${
download.status === 'paused' ? 'download-status-paused' :
download.status === 'failed' ? 'download-status-failed' :
download.status === 'processing' ? 'download-status-processing' :
download.status === 'downloading' ? 'download-status-downloading' :
download.status === 'downloading' ? 'download-status-downloading' :
download.status === 'queued' || download.status === 'staged' ? 'download-status-queued' :
download.status === 'retrying' ? 'download-status-retrying' : ''
}`}
@@ -155,97 +263,136 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
<>
<Clock size={12} className={download.status === 'queued' ? 'animate-pulse shrink-0' : 'shrink-0'} />
<span className="truncate">
{download.status === 'staged' ? 'In queue' : 'Queued'} #{queueIndex + 1}
{downloadStatusLabel} #{queueIndex + 1}
</span>
</>
) : download.status === 'downloading' ? (
`${((download.fraction || 0) * 100).toFixed(0)}%`
displayPercent
) : download.status === 'processing' ? (
'Processing'
downloadStatusLabel
) : (
download.status.charAt(0).toUpperCase() + download.status.slice(1)
downloadStatusLabel
)}
</span>
</>
</div>
)}
</div>
<div className="download-cell-truncate">
<span
key={`speed-${download.status}`}
ref={speedTextRef}
className="tabular-nums"
title={download.status === 'downloading' ? download.speed : download.status === 'processing' ? 'Processing…' : '-'}
>
{download.status === 'downloading' ? download.speed : download.status === 'processing' ? 'Processing…' : '-'}
),
Speed: (
<div className="download-column-cell download-cell-truncate" style={columnStyle('Speed')}>
<span className="download-cell-content tabular-nums" title={displaySpeed}>
{displaySpeed}
</span>
</div>
<div className="download-cell-truncate">
<span
key={`eta-${download.status}`}
ref={etaTextRef}
className="tabular-nums"
title={download.status === 'downloading' ? download.eta : download.status === 'processing' ? 'Muxing…' : '-'}
>
{download.status === 'downloading' ? download.eta : download.status === 'processing' ? 'Muxing…' : '-'}
),
ETA: (
<div className="download-column-cell download-cell-truncate" style={columnStyle('ETA')}>
<span className="download-cell-content tabular-nums" title={displayEta}>
{displayEta}
</span>
</div>
<div className="download-cell-right">
),
'Date Added': (
<div className="download-column-cell download-cell-right download-column-date-added" style={columnStyle('Date Added')}>
<span
className="truncate group-hover:hidden tabular-nums"
className="download-cell-content download-date-value tabular-nums"
title={download.dateAdded ? new Date(download.dateAdded).toLocaleDateString() : '-'}
>
{download.dateAdded ? new Date(download.dateAdded).toLocaleDateString() : '-'}
</span>
<div
className="hidden group-hover:flex items-center justify-end gap-0.5 w-full ml-auto"
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')}
disabled={queueIndex === 0}
className="app-icon-button h-7 w-7 disabled:opacity-40"
title="Move Up"
>
<ArrowUp size={14} />
</button>
<button
onClick={() => moveInQueue(selectedIds.has(download.id) ? Array.from(selectedIds) : download.id, 'down')}
disabled={queueIndex === queueItems.length - 1}
className="app-icon-button h-7 w-7 disabled:opacity-40"
title="Move Down"
>
<ArrowDown size={14} />
</button>
</>
)}
{canPauseDownload(download.status) && (
<button onClick={() => handlePause(download.id)} className="app-icon-button h-7 w-7" title="Pause">
<Pause size={14} fill="currentColor" />
</button>
)}
{canStartDownload(download.status) && (
<button onClick={() => handleResume(download)} className="app-icon-button h-7 w-7" title={startActionLabel(download.status)}>
<Play size={14} fill="currentColor" />
</button>
)}
<button
onClick={(e) => {
e.stopPropagation();
setContextMenu({ x: e.clientX, y: e.clientY, id: download.id });
}}
className="app-icon-button h-7 w-7"
title="Options"
>
<MoreVertical size={14} />
</button>
</div>
</div>
),
};
const rowActions = (
<div
className="download-row-actions items-center gap-0.5"
style={{
...actionPosition,
width: `${DOWNLOAD_ACTIONS_COLUMN_WIDTH}px`,
}}
onClick={(e) => e.stopPropagation()}
onDoubleClick={(e) => e.stopPropagation()}
>
{(download.status === 'queued' || download.status === 'staged') && queueIndex !== -1 && (
<>
<button
onClick={() => onMoveInQueue(download.id, 'up')}
disabled={queueIndex === 0}
className="app-icon-button h-7 w-7 disabled:opacity-40"
title={t($ => $.downloads.actions.moveUp)}
>
<ArrowUp size={14} />
</button>
<button
onClick={() => onMoveInQueue(download.id, 'down')}
disabled={queueIndex === queueLength - 1}
className="app-icon-button h-7 w-7 disabled:opacity-40"
title={t($ => $.downloads.actions.moveDown)}
>
<ArrowDown size={14} />
</button>
</>
)}
{canPauseDownload(download.status) && (
<button onClick={() => handlePause(download.id)} className="app-icon-button h-7 w-7" title={t($ => $.downloads.actions.pause)}>
<Pause size={14} fill="currentColor" />
</button>
)}
{canStartDownload(download.status) && (
<button onClick={() => handleResume(download)} className="app-icon-button h-7 w-7" title={download.status === 'paused' ? t($ => $.downloads.actions.resume) : t($ => $.downloads.actions.start)}>
<Play size={14} fill="currentColor" />
</button>
)}
<button
onClick={(e) => {
e.stopPropagation();
setContextMenu({ x: e.clientX, y: e.clientY, id: download.id });
}}
className="app-icon-button h-7 w-7"
title={t($ => $.downloads.actions.options)}
>
<MoreVertical size={14} />
</button>
</div>
);
return (
<div
ref={rowRef}
className={`download-row group cursor-default relative ${isActionVisible ? 'has-visible-actions' : ''} ${index % 2 !== 0 ? 'striped' : ''} ${isSelected ? 'is-selected' : ''}`}
style={{ gridTemplateColumns: tableGridTemplate, minWidth: tableMinWidth }}
tabIndex={0}
onMouseEnter={() => {
setIsRowHovered(true);
updateActionPosition();
}}
onMouseLeave={event => {
const nextTarget = event.relatedTarget;
if (!(nextTarget instanceof Node) || !event.currentTarget.contains(nextTarget)) {
setIsRowHovered(false);
}
}}
onFocus={() => {
setIsRowFocused(true);
updateActionPosition();
}}
onBlur={event => {
const nextTarget = event.relatedTarget;
if (!(nextTarget instanceof Node) || !event.currentTarget.contains(nextTarget)) {
setIsRowFocused(false);
}
}}
onClick={(e) => onClick(e, download)}
onContextMenu={(e) => {
e.preventDefault();
setContextMenu({ x: e.clientX, y: e.clientY, id: download.id });
}}
>
{columnOrder.map(columnKey => (
<React.Fragment key={columnKey}>{cells[columnKey]}</React.Fragment>
))}
{rowActions}
</div>
);
});
File diff suppressed because it is too large Load Diff
+28 -10
View File
@@ -1,4 +1,5 @@
import { useState } from 'react';
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
export type DuplicateReason = { type: 'url', msg: string } | { type: 'file', msg: string };
type DuplicateResolution = 'rename' | 'replace' | 'skip';
@@ -9,6 +10,7 @@ export interface DuplicateConflict {
reason: DuplicateReason;
resolution: DuplicateResolution;
replaceAllowed?: boolean;
existingDownloadId?: string;
}
interface Props {
@@ -18,18 +20,34 @@ interface Props {
}
export const DuplicateResolutionModal = ({ conflicts: initialConflicts, onConfirm, onCancel }: Props) => {
const { t } = useTranslation();
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>
<p className="text-xs text-text-muted">Some of the downloads you are adding already exist in the queue or on disk. Please choose how to resolve these conflicts.</p>
<h2 className="text-lg font-semibold text-text-primary">{t($ => $.dialogs.duplicateDownloads.title)}</h2>
<p className="text-xs text-text-muted">{t($ => $.dialogs.duplicateDownloads.description)}</p>
</div>
<div className="max-h-[300px] overflow-y-auto p-4 space-y-3">
@@ -44,23 +62,23 @@ export const DuplicateResolutionModal = ({ conflicts: initialConflicts, onConfir
onChange={(e) => updateResolution(conflict.id, e.target.value as DuplicateResolution)}
className="app-control w-24 shrink-0 px-2 py-1 text-xs"
>
<option value="rename">Rename</option>
{conflict.reason.type === 'file' && conflict.replaceAllowed && <option value="replace">Replace</option>}
<option value="skip">Skip</option>
<option value="rename">{t($ => $.dialogs.duplicateDownloads.rename)}</option>
{conflict.reason.type === 'file' && conflict.replaceAllowed && <option value="replace">{t($ => $.dialogs.duplicateDownloads.replace)}</option>}
<option value="skip">{t($ => $.dialogs.duplicateDownloads.skip)}</option>
</select>
</div>
))}
</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">
Cancel
<button onClick={onCancel} className="app-button px-4 text-xs">
{t($ => $.actions.cancel)}
</button>
<button
onClick={() => onConfirm(conflicts.map(c => ({ id: c.id, resolution: c.resolution })))}
className="app-button app-button-primary px-5 text-xs"
>
Continue
{t($ => $.actions.continue)}
</button>
</div>
</div>
+4 -3
View File
@@ -1,4 +1,5 @@
import { Component, ErrorInfo, ReactNode } from 'react';
import i18n from '../i18n';
interface Props {
children: ReactNode;
@@ -38,10 +39,10 @@ export class ErrorBoundary extends Component<Props, State> {
<svg className="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
<h1 className="text-xl font-bold">Something went wrong.</h1>
<h1 className="text-xl font-bold">{i18n.t($ => $.dialogs.errorBoundary.title)}</h1>
</div>
<div className="text-sm text-text-secondary mb-6">
A critical error occurred in the React component tree. The error details below can help identify the root cause.
{i18n.t($ => $.dialogs.errorBoundary.description)}
</div>
<div className="bg-black/50 p-4 rounded text-xs font-mono text-red-300 overflow-x-auto mb-4">
{this.state.error && this.state.error.toString()}
@@ -52,7 +53,7 @@ export class ErrorBoundary extends Component<Props, State> {
className="app-button px-4 py-2 bg-red-600/20 text-red-400 border border-red-600/50 hover:bg-red-600/30 transition-colors rounded"
onClick={() => window.location.reload()}
>
Reload App
{i18n.t($ => $.dialogs.errorBoundary.reload)}
</button>
</div>
</div>
+138 -40
View File
@@ -1,88 +1,183 @@
import React, { useState } from 'react';
import React, { useEffect, useRef, 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';
import { useTranslation } from 'react-i18next';
export const KeychainPermissionModal: React.FC = () => {
const KEYCHAIN_GRANT_TIMEOUT_MS = 30_000;
type KeychainPermissionModalProps = {
consentVersion: string;
};
export const KeychainPermissionModal: React.FC<KeychainPermissionModalProps> = ({ consentVersion }) => {
const { t } = useTranslation();
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 [grantRequestPending, setGrantRequestPending] = useState(false);
const [error, setError] = useState<string | null>(null);
const grantRequestRef = useRef<Promise<PairingTokenHydration> | null>(null);
useEffect(() => {
if (!showKeychainModal || isGranting || grantRequestPending) return;
const handleEscape = (event: KeyboardEvent) => {
if (event.key !== 'Escape') return;
if (consentVersion.trim()) {
dismissKeychainPrompt(consentVersion);
} else {
useSettingsStore.getState().setShowKeychainModal(false);
}
};
window.addEventListener('keydown', handleEscape);
return () => window.removeEventListener('keydown', handleEscape);
}, [consentVersion, dismissKeychainPrompt, grantRequestPending, isGranting, showKeychainModal]);
if (!showKeychainModal) {
return null;
}
const isMac = platform.os === 'macos';
const storeName =
platform.os === 'windows'
? 'Windows Credential Manager'
const pairingStoreName =
platform.portable
? t($ => $.keychain.stores.portable)
: platform.os === 'windows'
? t($ => $.keychain.stores.windows)
: platform.os === 'linux'
? 'your Linux credential store'
? t($ => $.keychain.stores.linux)
: platform.os === 'macos'
? 'macOS Keychain'
: "this system's credential store";
const grantLabel = isMac ? 'Grant Access' : 'Enable Secure Storage';
? t($ => $.keychain.stores.macos)
: t($ => $.keychain.stores.system);
const siteCredentialStoreName = platform.portable
? t($ => $.keychain.stores.siteCredentials)
: pairingStoreName;
const grantLabel = platform.portable
? t($ => $.keychain.grantLabelPortable)
: isMac
? t($ => $.keychain.grantLabelMacos)
: t($ => $.keychain.grantLabelDefault);
const handleGrant = async () => {
// A native credential-store call cannot be cancelled from the webview.
// Keep the request identity until it settles so a UI timeout cannot
// launch a second OS prompt while the first one is still outstanding.
if (grantRequestRef.current) return;
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');
grantRequestRef.current = grantRequest;
setGrantRequestPending(true);
void grantRequest.then(
() => {
if (grantRequestRef.current === grantRequest) grantRequestRef.current = null;
setGrantRequestPending(false);
},
() => {
if (grantRequestRef.current === grantRequest) grantRequestRef.current = null;
setGrantRequestPending(false);
}
);
// A native credential-store call cannot be cancelled by the webview. Keep
// a late successful result useful even if the UI timeout has already
// returned control to the explanation.
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(t($ => $.keychain.timeout))),
KEYCHAIN_GRANT_TIMEOUT_MS
);
})
]);
if (!(await applyPersistentGrant(result))) {
setError(result.error || t($ => $.keychain.unavailable, { store: siteCredentialStoreName }));
}
} catch (e: any) {
setError(e.toString());
} finally {
if (timeoutId !== undefined) window.clearTimeout(timeoutId);
setIsGranting(false);
}
};
const handleLater = () => {
setShowKeychainModal(false);
if (consentVersion.trim()) {
dismissKeychainPrompt(consentVersion);
} else {
// A modal opened by an early user action can render before the async
// app-version lookup completes. Do not persist a dismissal for an
// unknown build; startup must make the final consent decision once the
// identity is known.
useSettingsStore.getState().setShowKeychainModal(false);
}
};
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 && !grantRequestPending) 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">
<div className="p-2 bg-blue-500/10 rounded-full items-center justify-center">
<KeyRound size={20} className="text-blue-500" />
</div>
<h2 className="text-lg font-semibold text-text-primary m-0">Credential Storage Access Needed</h2>
<h2 className="text-lg font-semibold text-text-primary m-0">{t($ => $.keychain.title)}</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}.
{t($ => $.keychain.description, { pairingStore: pairingStoreName, siteCredentialStore: siteCredentialStoreName })}
</p>
<p>
{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.'}
{platform.portable
? t($ => $.keychain.portableExplanation)
: isMac
? t($ => $.keychain.macosExplanation)
: t($ => $.keychain.defaultExplanation)}
</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>{t($ => $.keychain.note)}</strong>{' '}
{platform.portable
? t($ => $.keychain.portableNote)
: t($ => $.keychain.defaultNote)}
</p>
{error && (
@@ -93,25 +188,28 @@ export const KeychainPermissionModal: React.FC = () => {
)}
<div className="bg-bg-modal-accent p-3 rounded-lg border border-border-modal text-xs">
<strong>Hint:</strong> If you select Later, the extension will only work for this session.
You can enable secure storage anytime from <strong>Settings &gt; Integrations</strong>.
<strong>{t($ => $.keychain.hint)}</strong>{' '}
{platform.portable
? t($ => $.keychain.portableHint)
: t($ => $.keychain.defaultHint)}{' '}
{t($ => $.keychain.enableFromSettings)}
</div>
</div>
<div className="px-5 py-4 border-t border-border-modal flex justify-end gap-3 bg-bg-modal-accent">
<button
onClick={handleLater}
disabled={isGranting}
disabled={isGranting || grantRequestPending}
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"
>
Later
{t($ => $.keychain.later)}
</button>
<button
onClick={handleGrant}
disabled={isGranting}
disabled={isGranting || grantRequestPending}
className="px-4 py-2 rounded-lg text-sm font-medium transition-colors bg-accent text-white hover:bg-accent/90 disabled:opacity-50"
>
{isGranting ? 'Enabling...' : grantLabel}
{isGranting || grantRequestPending ? t($ => $.keychain.enabling) : grantLabel}
</button>
</div>
</div>
+185 -81
View File
@@ -1,75 +1,123 @@
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 { useTranslation } from 'react-i18next';
import {
MAX_LOG_LINES,
appendBoundedLogEntries,
liveLogEntry,
mergeLogSnapshotAndLiveEntries,
persistedLogEntry,
pushBoundedLogEntry,
type LogEntry
} from '../utils/logEntries';
export default function LogsView() {
const { t } = useTranslation();
const { addToast } = useToast();
const logsEnabled = useSettingsStore(state => state.logsEnabled);
const setLogsEnabled = useSettingsStore(state => state.setLogsEnabled);
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 +126,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) {
@@ -113,10 +171,10 @@ export default function LogsView() {
if (contextMenu?.text) {
try {
await navigator.clipboard.writeText(contextMenu.text);
addToast({ message: 'Copied to clipboard', variant: 'success' });
addToast({ message: t($ => $.logs.copied), variant: 'success' });
} catch (err) {
console.error('Clipboard write error:', err);
addToast({ message: 'Failed to copy to clipboard', variant: 'error' });
addToast({ message: t($ => $.logs.copyFailed), variant: 'error' });
}
}
setContextMenu(null);
@@ -126,31 +184,75 @@ export default function LogsView() {
try {
const path = await save({
defaultPath: 'Firelink-Support-Logs.log',
filters: [{ name: 'Log Files', extensions: ['log'] }],
filters: [{ name: t($ => $.logs.logFiles), extensions: ['log'] }],
});
if (!path) return;
const logsContent = await invoke('export_logs', {});
await writeTextFile(path, logsContent);
addToast({ message: 'Support logs exported', variant: 'success' });
await invoke('export_logs', { destination: path });
addToast({ message: t($ => $.logs.exported), variant: 'success' });
} catch (e) {
console.error('Export failed:', e);
addToast({ message: `Could not export logs: ${String(e)}`, variant: 'error', isActionable: true });
addToast({ message: t($ => $.logs.exportFailed, { detail: String(e) }), variant: 'error', isActionable: true });
}
};
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: t($ => $.logs.cleared), variant: 'info' });
} catch (error) {
addToast({
message: t($ => $.logs.clearFailed, { detail: 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 ? t($ => $.logs.enabled) : t($ => $.logs.disabled),
variant: 'success'
});
})();
toggleInFlightRef.current = toggleOperation;
setIsToggling(true);
try {
await toggleOperation;
} catch (error) {
addToast({
message: t($ => $.logs.updateFailed, { detail: String(error) }),
variant: 'error',
isActionable: true
});
} finally {
if (toggleInFlightRef.current === toggleOperation) {
toggleInFlightRef.current = null;
}
setIsToggling(false);
}
};
const severityClass = (level: string) => {
@@ -170,12 +272,12 @@ export default function LogsView() {
<div className="logs-toolbar flex items-center justify-between px-4 py-2 shrink-0">
<div className="flex items-center gap-2 text-text-secondary">
<Terminal size={16} strokeWidth={1.8} />
<span className="text-[13px] font-semibold text-text-primary">Logs</span>
<span className="text-[11px] text-text-muted">({logs.length} entries)</span>
<span className="text-[13px] font-semibold text-text-primary">{t($ => $.logs.title)}</span>
<span className="text-[11px] text-text-muted">{t($ => $.logs.entries, { count: logs.length })}</span>
<span className={`rounded-full px-2 py-0.5 text-[10px] font-semibold ${
logsEnabled ? 'bg-accent/15 text-accent' : 'bg-item-hover text-text-muted'
}`}>
{logsEnabled ? 'Collecting' : 'Off'}
{logsEnabled ? t($ => $.logs.collecting) : t($ => $.logs.off)}
</span>
</div>
<div className="flex items-center gap-3">
@@ -186,36 +288,38 @@ export default function LogsView() {
onChange={e => setLevelFilter(e.target.value as LogEntry['level'] | 'All')}
className="bg-bg-input border border-border-modal rounded px-1.5 py-0.5 text-[11px] text-text-primary focus:outline-none focus:border-accent"
>
<option value="All">All Levels</option>
<option value="Error">Error</option>
<option value="Warn">Warn</option>
<option value="Info">Info</option>
<option value="Debug">Debug</option>
<option value="Trace">Trace</option>
<option value="All">{t($ => $.logs.allLevels)}</option>
<option value="Error">{t($ => $.logs.levels.error)}</option>
<option value="Warn">{t($ => $.logs.levels.warn)}</option>
<option value="Info">{t($ => $.logs.levels.info)}</option>
<option value="Debug">{t($ => $.logs.levels.debug)}</option>
<option value="Trace">{t($ => $.logs.levels.trace)}</option>
</select>
</div>
<div className="w-[1px] h-4 bg-border-modal mx-0.5" />
<button
onClick={handleToggleLogging}
className={`app-icon-button ${logsEnabled ? 'text-accent' : ''}`}
title={logsEnabled ? "Pause diagnostic logging" : "Enable diagnostic logging"}
disabled={isToggling}
className={`app-icon-button disabled:cursor-not-allowed disabled:opacity-50 ${logsEnabled ? 'text-accent' : ''}`}
title={logsEnabled ? t($ => $.logs.pauseLogging) : t($ => $.logs.enableLogging)}
>
{logsEnabled ? <Pause size={14} /> : <Play size={14} />}
</button>
<button
onClick={handleClear}
className="app-icon-button"
title="Clear displayed logs"
disabled={isClearing}
className="app-icon-button disabled:cursor-not-allowed disabled:opacity-50"
title={t($ => $.logs.clearDisplayed)}
>
<Trash2 size={14} />
</button>
<button
onClick={handleExport}
className="app-button px-3 text-[11px] gap-1.5"
title="Export logs"
title={t($ => $.logs.export)}
>
<FileDown size={13} />
Export Logs
{t($ => $.logs.exportButton)}
</button>
</div>
</div>
@@ -224,8 +328,8 @@ export default function LogsView() {
<div className="bg-black/10 border-y border-border-modal px-4 py-2 shrink-0 flex items-center gap-2 text-text-muted text-[10px] select-none">
<Info size={12} className="text-text-muted opacity-80 shrink-0" />
<span className="opacity-90 leading-tight">
<strong className="font-medium text-text-primary mr-1">Local diagnostics:</strong>
Diagnostic collection is opt-in, bounded, and local. Common secrets, URL queries, and home-directory paths are redacted during display and export. Nothing is uploaded automatically.
<strong className="font-medium text-text-primary me-1">{t($ => $.logs.localDiagnostics)}</strong>
{t($ => $.logs.diagnosticsDescription)}
</span>
</div>
@@ -238,7 +342,7 @@ export default function LogsView() {
>
{logs.length === 0 && (
<div className="text-text-muted italic select-none">
{logsEnabled ? 'No persisted log entries are available yet.' : 'Diagnostic logging is off. Existing support logs will appear here when available.'}
{logsEnabled ? t($ => $.logs.noEntries) : t($ => $.logs.disabledDescription)}
</div>
)}
{logs.filter(entry => levelFilter === 'All' || entry.level === levelFilter).map((entry, i) => (
@@ -258,11 +362,11 @@ export default function LogsView() {
onClick={(e) => e.stopPropagation()}
>
<button
className="w-full text-left px-3 py-2 flex items-center hover:bg-item-hover transition-colors"
className="w-full text-start px-3 py-2 flex items-center hover:bg-item-hover transition-colors"
onClick={handleCopy}
>
<Copy size={13} className="mr-2 text-text-secondary" />
Copy
<Copy size={13} className="me-2 text-text-secondary" />
{t($ => $.logs.copy)}
</button>
</div>
)}
+195 -52
View File
@@ -1,6 +1,6 @@
import { useState, useEffect } from 'react';
import { useDownloadStore, DownloadItem } from '../store/useDownloadStore';
import { useDownloadProgressStore } from '../store/downloadStore';
import { useDownloadProgressStore } from '../store/downloadProgressStore';
import { useShallow } from 'zustand/react/shallow';
import { useSettingsStore } from '../store/useSettingsStore';
import { ChevronDown, ChevronRight, FolderPlus, Info, CheckCircle, AlertCircle, Play, Pause } from 'lucide-react';
@@ -10,10 +10,37 @@ import {
isIdentityLocked as getIdentityLocked,
isTransferLocked as getTransferLocked
} from '../utils/downloadActions';
import {
downloadProgressColorClass,
formatDownloadTotal,
resolveDownloadSizeDisplay
} from '../utils/downloadProgress';
import { resolveDownloadConnections } from '../utils/downloads';
import { useTranslation } from 'react-i18next';
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 { t } = useTranslation();
const categoryLabel = (category: string) => {
switch (category) {
case 'Musics': return t($ => $.navigation.categories.musics);
case 'Movies': return t($ => $.navigation.categories.movies);
case 'Compressed': return t($ => $.navigation.categories.compressed);
case 'Documents': return t($ => $.navigation.categories.documents);
case 'Pictures': return t($ => $.navigation.categories.pictures);
case 'Applications': return t($ => $.navigation.categories.applications);
default: return t($ => $.navigation.categories.other);
}
};
const selectedPropertiesDownloadId = useDownloadStore(state => state.selectedPropertiesDownloadId);
const setSelectedPropertiesDownloadId = useDownloadStore(state => state.setSelectedPropertiesDownloadId);
const item = useDownloadStore(useShallow(state =>
@@ -33,7 +60,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 +94,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 +133,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;
@@ -126,11 +172,11 @@ export const PropertiesModal = () => {
const handleSave = async () => {
if (!url.trim()) {
setErrorMessage("Enter a valid URL.");
setErrorMessage(t($ => $.properties.enterValidUrl));
return;
}
if (!fileName.trim()) {
setErrorMessage("File name cannot be empty.");
setErrorMessage(t($ => $.properties.fileNameEmpty));
return;
}
@@ -138,7 +184,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 +191,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 {
@@ -159,6 +207,39 @@ export const PropertiesModal = () => {
const identityLocked = getIdentityLocked(item.status);
const transferLocked = getTransferLocked(item.status);
const configuredConnections = resolveDownloadConnections(item.connections, perServerConnections);
const observedConnectionTotal = Math.max(
1,
liveProgress?.requested_connections ?? configuredConnections
);
const observedActiveConnections = liveProgress?.active_connections;
const connectionTelemetryActive = item.status === 'downloading' ||
item.status === 'processing' ||
item.status === 'retrying';
const connectionStatus = (() => {
if (!connectionTelemetryActive) return String(configuredConnections);
// yt-dlp exposes the configured fragment limit through Firelink, but its
// progress stream does not expose a reliable active-worker count. Keep
// the selected limit visible without presenting it as an active count.
if (item.isMedia) {
return t($ => $.properties.connectionCountUnknown, {
total: configuredConnections,
});
}
if (typeof observedActiveConnections === 'number') {
return t($ => $.properties.connectionCount, {
active: observedActiveConnections,
total: observedConnectionTotal,
});
}
if (item.status === 'downloading') {
return t($ => $.properties.connectionCountUnknown, { total: observedConnectionTotal });
}
return t($ => $.properties.connectionCount, {
active: 0,
total: observedConnectionTotal,
});
})();
const displayedFraction = item.status === 'completed'
? 1
: liveProgress?.fraction ?? item.fraction ?? 0;
@@ -168,6 +249,30 @@ 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 = (() => {
const value = item.status === 'completed' ? formatDownloadTotal(sizeDisplay) : sizeDisplay.fallback;
return value === 'Unknown' ? t($ => $.addDownloads.unknown) : value;
})();
const statusLabel = t($ => $.downloads.status[item.status]);
const sizeDescription = sizeDisplay.totalIsEstimate
? t($ => $.downloads.size.downloadedOfApproximate, {
downloaded: sizeDisplay.downloaded ?? '',
total: sizeDisplay.total ?? '',
unit: sizeDisplay.unit ?? '',
})
: t($ => $.downloads.size.downloadedOf, {
downloaded: sizeDisplay.downloaded ?? '',
total: sizeDisplay.total ?? '',
unit: sizeDisplay.unit ?? '',
});
let statusColor = 'text-text-secondary';
let StatusIcon = Info;
@@ -178,7 +283,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 */}
@@ -187,7 +299,7 @@ export const PropertiesModal = () => {
<h2 className="text-base font-semibold truncate text-text-primary pr-4">{item.fileName}</h2>
<span className={`flex items-center gap-1.5 text-xs font-semibold tracking-wide uppercase ${statusColor}`}>
<StatusIcon size={14} />
{item.status}
{statusLabel}
</span>
</div>
@@ -196,21 +308,39 @@ export const PropertiesModal = () => {
</div>
<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">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">{t($ => $.properties.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">{t($ => $.properties.size)}</span>
<span
className="truncate"
title={hasDownloadedAmount
? sizeDescription
: 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">{t($ => $.properties.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">{t($ => $.properties.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-[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 min-w-0"><span className="text-text-muted font-medium shrink-0 whitespace-nowrap">{t($ => $.properties.connections)}</span><span className="text-text-secondary truncate whitespace-nowrap" title={item.connections !== undefined ? t($ => $.properties.savedTooltip) : t($ => $.properties.defaultTooltip)}><bdi>{connectionStatus}</bdi></span></div>
<div className="flex gap-1.5 min-w-0"><span className="text-text-muted font-medium w-[60px] shrink-0">{t($ => $.properties.speedCap)}</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">{t($ => $.properties.category)}</span><span className="text-text-secondary truncate">{categoryLabel(item.category)}</span></div>
<div className="flex gap-1.5"><span className="text-text-muted font-medium w-[50px]">{t($ => $.properties.lastTry)}</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>
<div className="flex gap-1.5 col-span-2"><span className="text-text-muted font-medium w-[90px]">{t($ => $.properties.dateAdded)}</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]">{t($ => $.properties.destination)}</span><span className="text-text-secondary truncate" title={saveLocation}>{saveLocation || baseDownloadFolder}</span></div>
{item.lastError && (item.status === 'failed' || item.status === 'retrying') && (
<div className="flex gap-1.5 col-span-4 min-w-0">
<span className="text-text-muted font-medium w-[90px] shrink-0">Last error</span>
<span className="text-text-muted font-medium w-[90px] shrink-0">{t($ => $.properties.lastError)}</span>
<span className="text-red-400 truncate" title={item.lastError}>{item.lastError}</span>
</div>
)}
@@ -227,41 +357,54 @@ export const PropertiesModal = () => {
{item.status === 'completed' ? <CheckCircle size={16} className="text-green-500" /> : <AlertCircle size={16} className="text-blue-500" />}
<span>
{item.status === 'completed'
? "File identity is read-only. Transfer settings are saved for redownload."
: "Transfer settings can be changed after stopping or pausing. Current transfers keep their existing backend options."}
? t($ => $.properties.identityReadOnly)
: t($ => $.properties.transferSettings)}
</span>
</div>
)}
{/* Download Section */}
<section>
<h3 className="text-sm font-semibold text-text-primary mb-4 pb-1 border-b border-border-modal/50">Download</h3>
<h3 className="text-sm font-semibold text-text-primary mb-4 pb-1 border-b border-border-modal/50">{t($ => $.properties.download)}</h3>
<div className="grid grid-cols-[100px_1fr] gap-y-3.5 gap-x-4 items-center">
<label className="text-xs text-text-muted text-right">URL</label>
<label className="text-xs text-text-muted text-right">{t($ => $.properties.url)}</label>
<input type="text" value={url} onChange={e => setUrl(e.target.value)} disabled={identityLocked} className="bg-bg-input border border-border-modal rounded-lg px-2.5 py-1.5 text-xs text-text-primary font-mono focus:outline-none focus:border-accent disabled:opacity-50" />
<label className="text-xs text-text-muted text-right">File name</label>
<label className="text-xs text-text-muted text-right">{t($ => $.properties.fileName)}</label>
<input type="text" value={fileName} onChange={e => setFileName(e.target.value)} disabled={identityLocked} className="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" />
<label className="text-xs text-text-muted text-right">Save location</label>
<label className="text-xs text-text-muted text-right">{t($ => $.properties.saveLocation)}</label>
<div className="flex gap-2">
<input type="text" value={saveLocation} readOnly disabled={identityLocked} className="flex-1 bg-bg-input border border-border-modal rounded-lg px-2.5 py-1.5 text-xs text-text-primary font-mono focus:outline-none focus:border-accent disabled:opacity-50" />
<button onClick={handleBrowse} disabled={identityLocked} className="bg-item-hover hover:bg-item-hover/80 text-text-primary border border-border-modal px-3 py-1.5 rounded text-xs transition-colors disabled:opacity-40 flex items-center gap-1.5">
<FolderPlus size={14} /> Select
<FolderPlus size={14} /> {t($ => $.properties.select)}
</button>
</div>
<label className="text-xs text-text-muted text-right">Connections</label>
<label className="text-xs text-text-muted text-right">{t($ => $.properties.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" />
<span className="text-xs text-text-muted">per file</span>
<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">{t($ => $.properties.perFile)}</span>
<span className="text-xs text-text-secondary font-mono" aria-live="polite"><bdi>{connectionStatus}</bdi></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"
>
{t($ => $.properties.useCurrentDefault, { count: perServerConnections })}
</button>
)}
</div>
<div className="col-start-2 text-[11px] text-text-muted">
{t($ => $.properties.savedPerDownload)}
</div>
<label className="text-xs text-text-muted text-right">Speed</label>
<label className="text-xs text-text-muted text-right">{t($ => $.properties.speed)}</label>
<div className="flex items-center gap-3">
<label className="flex items-center gap-2 text-xs text-text-primary">
<input type="checkbox" checked={speedLimitEnabled} onChange={e => setSpeedLimitEnabled(e.target.checked)} disabled={transferLocked} className="rounded border-border-modal text-blue-500 focus:ring-blue-500/20 bg-bg-input disabled:opacity-50" />
Limit
{t($ => $.properties.limit)}
</label>
{speedLimitEnabled && (
<div className="flex items-center gap-2">
@@ -276,7 +419,7 @@ export const PropertiesModal = () => {
{/* Site Login Section */}
<section>
<h3 className="text-sm font-semibold text-text-primary mb-4 pb-1 border-b border-border-modal/50">
{item.status === 'completed' ? 'Site Login for Redownload' : 'Site Login'}
{item.status === 'completed' ? t($ => $.properties.siteLoginRedownload) : t($ => $.properties.siteLogin)}
</h3>
<div className="flex gap-1 p-1 bg-border-color rounded-lg mb-4 w-fit mx-auto md:mx-0">
@@ -287,7 +430,7 @@ export const PropertiesModal = () => {
disabled={transferLocked}
className={`px-3 py-1.5 rounded-md text-xs font-medium transition-colors disabled:opacity-50 ${loginMode === mode ? 'bg-bg-modal text-text-primary shadow-sm' : 'text-text-muted hover:text-text-secondary'}`}
>
{mode === 'matching' ? 'Matching site login' : mode === 'custom' ? 'Custom credentials' : 'No login'}
{mode === 'matching' ? t($ => $.properties.matchingSiteLogin) : mode === 'custom' ? t($ => $.properties.customCredentials) : t($ => $.properties.noLogin)}
</button>
))}
</div>
@@ -295,16 +438,16 @@ export const PropertiesModal = () => {
<div className="grid grid-cols-[100px_1fr] gap-y-3.5 gap-x-4 items-center">
{loginMode === 'matching' && (
<div className="col-start-2 text-xs text-text-secondary italic">
Will use saved login if available.
{t($ => $.properties.useSavedLogin)}
</div>
)}
{loginMode === 'custom' && (
<>
<label className="text-xs text-text-muted text-right">Username</label>
<input type="text" value={username} onChange={e=>setUsername(e.target.value)} disabled={transferLocked} placeholder="Username" className="max-w-[250px] 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" />
<label className="text-xs text-text-muted text-right">{t($ => $.properties.username)}</label>
<input type="text" value={username} onChange={e=>setUsername(e.target.value)} disabled={transferLocked} placeholder={t($ => $.properties.username)} className="max-w-[250px] 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" />
<label className="text-xs text-text-muted text-right">Password</label>
<input type="password" value={password} onChange={e=>setPassword(e.target.value)} disabled={transferLocked} placeholder="Password" className="max-w-[250px] 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" />
<label className="text-xs text-text-muted text-right">{t($ => $.properties.password)}</label>
<input type="password" value={password} onChange={e=>setPassword(e.target.value)} disabled={transferLocked} placeholder={t($ => $.properties.password)} className="max-w-[250px] 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" />
</>
)}
</div>
@@ -317,20 +460,20 @@ export const PropertiesModal = () => {
className="flex items-center gap-2 text-sm font-semibold text-text-primary w-full pb-1 border-b border-border-modal/50 hover:text-blue-400 transition-colors"
>
{advancedExpanded ? <ChevronDown size={16} /> : <ChevronRight size={16} />}
{item.status === 'completed' ? 'Advanced Transfer for Redownload' : 'Advanced Transfer'}
{item.status === 'completed' ? t($ => $.properties.advancedTransferRedownload) : t($ => $.properties.advancedTransfer)}
</button>
{advancedExpanded && (
<div className="mt-4 grid grid-cols-[100px_1fr] gap-y-3.5 gap-x-4 items-center pl-6">
<label className="text-xs text-text-muted text-right">Checksum</label>
<label className="text-xs text-text-muted text-right">{t($ => $.properties.checksum)}</label>
<label className="flex items-center gap-2 text-xs text-text-primary">
<input type="checkbox" checked={checksumEnabled} onChange={e => setChecksumEnabled(e.target.checked)} disabled={transferLocked} className="rounded border-border-modal text-blue-500 focus:ring-blue-500/20 bg-bg-input" />
Verify
{t($ => $.properties.verify)}
</label>
{checksumEnabled && (
<>
<label className="text-xs text-text-muted text-right">Algorithm</label>
<label className="text-xs text-text-muted text-right">{t($ => $.properties.algorithm)}</label>
<select value={checksumAlgorithm} onChange={e=>setChecksumAlgorithm(e.target.value)} disabled={transferLocked} className="max-w-[150px] 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">
<option value="MD5">MD5</option>
<option value="SHA-1">SHA-1</option>
@@ -338,21 +481,21 @@ export const PropertiesModal = () => {
<option value="SHA-512">SHA-512</option>
</select>
<label className="text-xs text-text-muted text-right">Digest</label>
<input type="text" value={checksumValue} onChange={e=>setChecksumValue(e.target.value)} disabled={transferLocked} placeholder="Expected digest" className="bg-bg-input border border-border-modal rounded-lg px-2.5 py-1.5 text-xs text-text-primary font-mono focus:outline-none focus:border-accent disabled:opacity-50" />
<label className="text-xs text-text-muted text-right">{t($ => $.properties.digest)}</label>
<input type="text" value={checksumValue} onChange={e=>setChecksumValue(e.target.value)} disabled={transferLocked} placeholder={t($ => $.properties.expectedDigest)} className="bg-bg-input border border-border-modal rounded-lg px-2.5 py-1.5 text-xs text-text-primary font-mono focus:outline-none focus:border-accent disabled:opacity-50" />
</>
)}
<label className="text-xs text-text-muted text-right">Cookies</label>
<input type="text" value={cookies} onChange={e=>setCookies(e.target.value)} disabled={transferLocked} placeholder="Cookies" className="bg-bg-input border border-border-modal rounded-lg px-2.5 py-1.5 text-xs text-text-primary font-mono focus:outline-none focus:border-accent disabled:opacity-50" />
<label className="text-xs text-text-muted text-right">{t($ => $.properties.cookies)}</label>
<input type="password" value={cookies} onChange={e=>setCookies(e.target.value)} disabled={transferLocked} autoComplete="off" placeholder={t($ => $.properties.cookies)} className="bg-bg-input border border-border-modal rounded-lg px-2.5 py-1.5 text-xs text-text-primary font-mono focus:outline-none focus:border-accent disabled:opacity-50" />
<div className="col-span-2 mt-2">
<label className="block text-xs text-text-muted mb-1.5">Headers</label>
<label className="block text-xs text-text-muted mb-1.5">{t($ => $.properties.headers)}</label>
<textarea value={headers} onChange={e=>setHeaders(e.target.value)} disabled={transferLocked} className="w-full h-16 bg-bg-input border border-border-modal rounded-lg px-2.5 py-1.5 text-xs text-text-primary font-mono focus:outline-none focus:border-accent disabled:opacity-50 resize-none"></textarea>
</div>
<div className="col-span-2">
<label className="block text-xs text-text-muted mb-1.5">Mirrors</label>
<label className="block text-xs text-text-muted mb-1.5">{t($ => $.properties.mirrors)}</label>
<textarea value={mirrors} onChange={e=>setMirrors(e.target.value)} disabled={transferLocked} className="w-full h-16 bg-bg-input border border-border-modal rounded-lg px-2.5 py-1.5 text-xs text-text-primary font-mono focus:outline-none focus:border-accent disabled:opacity-50 resize-none"></textarea>
</div>
</div>
@@ -371,9 +514,9 @@ 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
{t($ => $.properties.cancel)}
</button>
<button
onClick={handleSave}
@@ -381,7 +524,7 @@ export const PropertiesModal = () => {
className={`app-button app-button-primary px-4 text-xs ${transferLocked ? 'opacity-50 cursor-not-allowed' : ''}`}
>
<CheckCircle size={14} />
Save
{t($ => $.properties.save)}
</button>
</div>
</div>
+88 -65
View File
@@ -6,25 +6,27 @@ 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';
import { useTranslation } from 'react-i18next';
const days = [
{ value: 0, label: 'Su' },
{ value: 1, label: 'Mo' },
{ value: 2, label: 'Tu' },
{ value: 3, label: 'We' },
{ value: 4, label: 'Th' },
{ value: 5, label: 'Fr' },
{ value: 6, label: 'Sa' },
];
{ value: 0, key: 'su' },
{ value: 1, key: 'mo' },
{ value: 2, key: 'tu' },
{ value: 3, key: 'we' },
{ value: 4, key: 'th' },
{ value: 5, key: 'fr' },
{ value: 6, key: 'sa' },
] as const;
const postActions: { value: PostQueueAction; label: string; icon: typeof Moon }[] = [
{ value: 'none', label: 'Do nothing', icon: CheckCircle2 },
{ value: 'sleep', label: 'Sleep', icon: Moon },
{ value: 'restart', label: 'Restart', icon: RotateCcw },
{ value: 'shutdown', label: 'Shut down', icon: Power },
const postActions: { value: PostQueueAction; icon: typeof Moon }[] = [
{ value: 'none', icon: CheckCircle2 },
{ value: 'sleep', icon: Moon },
{ value: 'restart', icon: RotateCcw },
{ value: 'shutdown', icon: Power },
];
const minuteOfDay = (value: string) => {
@@ -32,8 +34,8 @@ const minuteOfDay = (value: string) => {
return hour * 60 + minute;
};
function nextScheduledRun(settings: SchedulerSettings): string {
if (!settings.enabled) return 'Scheduler is disabled';
function nextScheduledRun(settings: SchedulerSettings): Date | 'disabled' | 'none' {
if (!settings.enabled) return 'disabled';
const [hour, minute] = settings.startTime.split(':').map(Number);
const now = new Date();
@@ -44,20 +46,15 @@ function nextScheduledRun(settings: SchedulerSettings): string {
candidate.setHours(hour, minute, 0, 0);
const allowedDay = settings.everyday || settings.selectedDays.includes(candidate.getDay());
if (allowedDay && candidate > now) {
return candidate.toLocaleString(undefined, {
weekday: 'short',
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: '2-digit'
});
return candidate;
}
}
return 'No scheduled day selected';
return 'none';
}
export default function SchedulerView() {
const { t } = useTranslation();
const savedSettings = useSettingsStore(state => state.scheduler);
const schedulerRunning = useSettingsStore(state => state.schedulerRunning);
const setScheduler = useSettingsStore(state => state.setScheduler);
@@ -74,7 +71,18 @@ export default function SchedulerView() {
}, [savedSettings]);
const nextRun = useMemo(() => nextScheduledRun(draft), [draft]);
const nextRun = useMemo(() => {
const scheduledRun = nextScheduledRun(draft);
if (scheduledRun === 'disabled') return t($ => $.scheduler.disabled);
if (scheduledRun === 'none') return t($ => $.scheduler.noScheduledDay);
return scheduledRun.toLocaleString(undefined, {
weekday: 'short',
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: '2-digit'
});
}, [draft, t]);
const hasUnsavedChanges = useMemo(
() => JSON.stringify(draft) !== JSON.stringify(savedSettings),
[draft, savedSettings]
@@ -115,15 +123,15 @@ export default function SchedulerView() {
const save = () => {
if (draft.enabled && !draft.everyday && draft.selectedDays.length === 0) {
addToast({ message: 'Select at least one day for the scheduler', variant: 'error', isActionable: true });
addToast({ message: t($ => $.scheduler.validationDay), variant: 'error', isActionable: true });
return;
}
if (draft.enabled && effectiveSelectedQueueIds.length === 0) {
addToast({ message: 'Select at least one queue for the scheduler', variant: 'error', isActionable: true });
addToast({ message: t($ => $.scheduler.validationQueue), variant: 'error', isActionable: true });
return;
}
if (draft.enabled && draft.stopTimeEnabled && minuteOfDay(draft.stopTime) <= minuteOfDay(draft.startTime)) {
addToast({ message: 'Stop time must be later than start time', variant: 'error', isActionable: true });
if (draft.enabled && draft.stopTimeEnabled && minuteOfDay(draft.stopTime) === minuteOfDay(draft.startTime)) {
addToast({ message: t($ => $.scheduler.validationStopTime), variant: 'error', isActionable: true });
return;
}
const normalized = {
@@ -133,10 +141,11 @@ export default function SchedulerView() {
};
setScheduler(normalized);
setDraft(normalized);
addToast({ message: 'Scheduler settings saved', variant: 'success' });
addToast({ message: t($ => $.scheduler.saved), variant: 'success' });
};
const runNow = async () => {
const previouslyTrackedIds = new Set(useSettingsStore.getState().schedulerActiveDownloadIds);
const results = await Promise.all(
effectiveSelectedQueueIds.map(queueId => useDownloadStore.getState().startQueue(queueId))
);
@@ -144,17 +153,23 @@ 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])];
if (activeIds.length > 0) {
useSettingsStore.getState().setSchedulerRunning(true);
useSettingsStore.getState().setSchedulerActiveDownloadIds(activeIds);
addToast({ message: `Tracking ${activeIds.length} scheduled download${activeIds.length === 1 ? '' : 's'}`, variant: 'success' });
addToast({
message: activeIds.length === 1
? t($ => $.scheduler.trackingOne)
: t($ => $.scheduler.trackingMany, { count: activeIds.length }),
variant: 'success'
});
} else {
addToast({ message: 'No downloads in the selected queues can be started', variant: 'info' });
addToast({ message: t($ => $.scheduler.noStartableDownloads), variant: 'info' });
}
};
@@ -165,7 +180,14 @@ export default function SchedulerView() {
const count = counts.reduce((total, queueCount) => total + queueCount, 0);
useSettingsStore.getState().setSchedulerRunning(false);
useSettingsStore.getState().setSchedulerActiveDownloadIds([]);
addToast({ message: count > 0 ? `Paused ${count} active download${count === 1 ? '' : 's'}` : 'No active downloads', variant: 'info' });
addToast({
message: count === 0
? t($ => $.scheduler.noActiveDownloads)
: count === 1
? t($ => $.scheduler.pausedOne)
: t($ => $.scheduler.pausedMany, { count }),
variant: 'info'
});
};
const refreshPermissionStatus = useCallback(async (showMessage = false) => {
@@ -175,12 +197,12 @@ export default function SchedulerView() {
await invoke('check_automation_permission');
setAutomationPermissionGranted(true);
if (showMessage) {
setPermissionMessage('Automation permission is available.');
setPermissionMessage(t($ => $.scheduler.permissionAvailable));
}
} catch {
setAutomationPermissionGranted(false);
if (showMessage) {
setPermissionMessage('Automation permission is missing. Enable Firelink under Automation for System Events in System Settings.');
setPermissionMessage(t($ => $.scheduler.permissionMissingDetails));
}
}
}, [isMac]);
@@ -219,18 +241,18 @@ export default function SchedulerView() {
const handlePermissionAction = async () => {
if (automationPermissionGranted) {
await openAutomationSettings('macOS does not allow Firelink to revoke Automation permission directly. Revoke System Events access in System Settings, then return to Firelink.');
await openAutomationSettings(t($ => $.scheduler.revokePermissionDetails));
return;
}
setPermissionMessage('Requesting Automation permission...');
setPermissionMessage(t($ => $.scheduler.requestingPermission));
try {
await invoke('request_automation_permission');
setAutomationPermissionGranted(true);
setPermissionMessage('Automation permission is available.');
setPermissionMessage(t($ => $.scheduler.permissionAvailable));
} catch {
setAutomationPermissionGranted(false);
await openAutomationSettings('Enable Firelink under Automation for System Events in System Settings, then return to Firelink.');
await openAutomationSettings(t($ => $.scheduler.enablePermissionDetails));
}
};
@@ -250,27 +272,27 @@ export default function SchedulerView() {
className={`inline-block h-4 w-4 transform rounded-full bg-white transition duration-200 ease-in-out ${draft.enabled ? 'translate-x-4' : 'translate-x-1'}`}
/>
</button>
Scheduler
{t($ => $.scheduler.title)}
</div>
<span className={`rounded-full px-2.5 py-1 text-[11px] font-semibold ${
schedulerRunning ? 'bg-green-500/15 text-green-500' : 'bg-item-hover text-text-muted'
}`}>
{schedulerRunning ? 'Running' : nextRun}
{schedulerRunning ? t($ => $.scheduler.running) : nextRun}
</span>
{hasUnsavedChanges && (
<span className="rounded-full bg-orange-500/10 px-2.5 py-1 text-[11px] font-semibold text-orange-300">
Unsaved changes
{t($ => $.scheduler.unsavedChanges)}
</span>
)}
<div className="ml-auto flex gap-2">
<button onClick={runNow} className="app-button px-3 text-[11px]">
<Play size={14} /> Run Now
<Play size={14} /> {t($ => $.scheduler.runNow)}
</button>
<button onClick={pauseNow} className="app-button px-3 text-[11px]">
<Pause size={14} /> Pause
<Pause size={14} /> {t($ => $.scheduler.pause)}
</button>
<button onClick={save} className="app-button app-button-primary px-3 text-[11px]">
<Save size={14} /> Save Settings
<Save size={14} /> {t($ => $.scheduler.saveSettings)}
</button>
</div>
</div>
@@ -279,29 +301,29 @@ export default function SchedulerView() {
<div className={`max-w-[760px] space-y-4 ${draft.enabled ? '' : 'opacity-50'}`}>
<section className="app-card p-5">
<div className="mb-5 flex items-center gap-2 font-semibold text-text-primary">
<Clock3 size={17} className="text-accent" /> Timing
<Clock3 size={17} className="text-accent" /> {t($ => $.scheduler.timing)}
</div>
<div className="flex flex-wrap items-end gap-8">
<label className="space-y-2 text-[12px] text-text-secondary">
<span className="block">Start Time</span>
<span className="block">{t($ => $.scheduler.startTime)}</span>
<input type="time" value={draft.startTime} onChange={event => updateDraft('startTime', event.target.value)} disabled={!draft.enabled} className="app-control px-3 py-2 text-text-primary" />
</label>
<div className="space-y-2">
<label className="flex items-center gap-2 text-[12px] text-text-secondary">
<input type="checkbox" checked={draft.stopTimeEnabled} onChange={event => updateDraft('stopTimeEnabled', event.target.checked)} disabled={!draft.enabled} className="accent-accent" />
Stop Time
{t($ => $.scheduler.stopTime)}
</label>
<input type="time" value={draft.stopTime} onChange={event => updateDraft('stopTime', event.target.value)} disabled={!draft.enabled || !draft.stopTimeEnabled} className="app-control px-3 py-2 text-text-primary disabled:opacity-50" />
</div>
</div>
<p className="mt-4 text-[11px] text-text-muted">
If Firelink is asleep at the start time, it starts the selected queues when it returns later that day, unless the stop time has already passed.
{t($ => $.scheduler.timingDescription)}
</p>
<div className="my-5 border-t border-border-color" />
<label className="flex items-center gap-2 text-[13px] font-medium text-text-primary">
<input type="checkbox" checked={draft.everyday} onChange={event => updateDraft('everyday', event.target.checked)} disabled={!draft.enabled} className="accent-accent" />
Run Every Day
{t($ => $.scheduler.runEveryDay)}
</label>
{!draft.everyday && (
<div className="mt-4 flex gap-2">
@@ -317,7 +339,7 @@ export default function SchedulerView() {
selected ? 'bg-accent text-white' : 'bg-bg-input text-text-primary hover:bg-item-hover'
}`}
>
{day.label}
{t($ => $.scheduler.days[day.key])}
</button>
);
})}
@@ -327,7 +349,7 @@ export default function SchedulerView() {
<section className="app-card p-5">
<div className="mb-4 flex items-center gap-2 font-semibold text-text-primary">
<List size={17} className="text-accent" /> Queues to Schedule
<List size={17} className="text-accent" /> {t($ => $.scheduler.queuesToSchedule)}
</div>
<div className="space-y-3">
{queues.map(queue => {
@@ -343,7 +365,7 @@ export default function SchedulerView() {
/>
{queue.name}
{queue.isMain && (
<span className="text-[11px] text-text-muted">Default queue</span>
<span className="text-[11px] text-text-muted">{t($ => $.scheduler.defaultQueue)}</span>
)}
</label>
);
@@ -353,9 +375,9 @@ export default function SchedulerView() {
<section className="app-card p-5">
<div className="mb-2 flex items-center gap-2 font-semibold text-text-primary">
<Power size={17} className="text-accent" /> After Completion
<Power size={17} className="text-accent" /> {t($ => $.scheduler.afterCompletion)}
</div>
<p className="mb-4 text-[12px] text-text-muted">Choose what happens after downloads started by the scheduler finish.</p>
<p className="mb-4 text-[12px] text-text-muted">{t($ => $.scheduler.afterCompletionDescription)}</p>
<div className="grid grid-cols-2 gap-2">
{postActions.map(action => {
const Icon = action.icon;
@@ -365,13 +387,13 @@ export default function SchedulerView() {
}`}>
<input type="radio" name="post-action" checked={draft.postQueueAction === action.value} onChange={() => updateDraft('postQueueAction', action.value)} disabled={!draft.enabled} className="accent-accent" />
<Icon size={15} />
{action.label}
{t($ => $.scheduler.postActions[action.value])}
</label>
);
})}
</div>
{draft.postQueueAction !== 'none' && (
<p className="mt-3 text-[11px] text-orange-400">This action can interrupt other work on the computer. Firelink invokes it immediately after the scheduled queue finishes.</p>
<p className="mt-3 text-[11px] text-orange-400">{t($ => $.scheduler.actionWarning)}</p>
)}
</section>
</div>
@@ -379,27 +401,27 @@ export default function SchedulerView() {
{isMac ? (
<section className="app-card mt-4 max-w-[760px] p-5">
<div className="mb-2 flex items-center gap-2 font-semibold text-text-primary">
<LockKeyhole size={17} className="text-accent" /> System Permissions
<LockKeyhole size={17} className="text-accent" /> {t($ => $.scheduler.systemPermissions)}
</div>
<p className="mb-4 text-[12px] text-text-muted">Sleep, restart, and shut down require macOS Automation permission for System Events.</p>
<p className="mb-4 text-[12px] text-text-muted">{t($ => $.scheduler.macPermissionDescription)}</p>
<div className="mb-4 flex items-center gap-2 text-[12px]">
{automationPermissionGranted ? (
<>
<CheckCircle2 size={16} className="text-green-500" />
<span className="font-medium text-green-500">Automation permission granted</span>
<span className="font-medium text-green-500">{t($ => $.scheduler.permissionGranted)}</span>
</>
) : (
<>
<AlertCircle size={16} className="text-orange-400" />
<span className="font-medium text-orange-400">
{automationPermissionGranted === null ? 'Checking Automation permission...' : 'Automation permission missing'}
{automationPermissionGranted === null ? t($ => $.scheduler.permissionChecking) : t($ => $.scheduler.permissionMissing)}
</span>
</>
)}
</div>
<div className="flex gap-2">
<button onClick={handlePermissionAction} className="app-button app-button-primary px-3 text-[11px]">
{automationPermissionGranted ? 'Revoke permission' : 'Grant permission'}
{automationPermissionGranted ? t($ => $.scheduler.revokePermission) : t($ => $.scheduler.grantPermission)}
</button>
</div>
{permissionMessage && <p className="mt-3 text-[11px] text-text-muted">{permissionMessage}</p>}
@@ -407,11 +429,12 @@ export default function SchedulerView() {
) : (
<section className="app-card mt-4 max-w-[760px] p-5">
<div className="mb-2 flex items-center gap-2 font-semibold text-text-primary">
<LockKeyhole size={17} className="text-accent" /> System Actions
<LockKeyhole size={17} className="text-accent" /> {t($ => $.scheduler.systemActions)}
</div>
<p className="text-[12px] text-text-muted">
Sleep, restart, and shut down use {platform.os === 'windows' ? 'Windows system privileges' : 'your Linux desktop and system policy'}.
Firelink reports any rejected action when it runs; no permanent permission is claimed in advance.
{platform.os === 'windows'
? t($ => $.scheduler.windowsActionsDescription)
: t($ => $.scheduler.linuxActionsDescription)}
</p>
</section>
)}
File diff suppressed because it is too large Load Diff
+261 -75
View File
@@ -3,12 +3,15 @@ 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';
import { useTranslation } from 'react-i18next';
export type SidebarFilter = 'all' | 'active' | 'completed' | 'unfinished' | DownloadCategory | 'settings' | string;
@@ -22,15 +25,29 @@ export const Sidebar: React.FC<SidebarProps> = (props) => {
const { downloads, queues, addQueue, renameQueue, removeQueue, startQueue, pauseQueue } = useDownloadStore();
const { activeView, setActiveView, toggleSidebar } = useSettingsStore();
const { addToast } = useToast();
const { t } = useTranslation();
const [isAddingQueue, setIsAddingQueue] = useState(false);
const [newQueueName, setNewQueueName] = useState('');
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 addQueueCancelRef = useRef(false);
const renameQueueSubmitRef = useRef(false);
const renameQueueCancelRef = useRef<string | null>(null);
const renamingQueueIdRef = useRef<string | null>(null);
const editingQueueNameRef = useRef('');
const rejectedAddQueueNameRef = useRef<string | null>(null);
const rejectedRenameRef = useRef<{ queueId: string; name: string } | null>(null);
useEffect(() => {
const handleCloseMenu = () => setContextMenu(null);
@@ -53,6 +70,23 @@ 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]);
const handleFoldersToggle = () => {
if (foldersListRef.current?.contains(document.activeElement)) {
foldersToggleRef.current?.focus();
}
setFoldersCollapsed(collapsed => !collapsed);
};
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (document.querySelector('.app-modal-backdrop') || document.querySelector('.app-modal')) return;
@@ -61,12 +95,19 @@ export const Sidebar: React.FC<SidebarProps> = (props) => {
if (!isInput && (e.key === 'Delete' || e.key === 'Backspace')) {
if (activeEl && activeEl.closest('.sidebar-inner')) {
if (activeView === 'downloads' && selectedFilter.startsWith('queue:')) {
const queueId = selectedFilter.replace('queue:', '');
const focusedQueueId = activeEl
.closest<HTMLElement>('[data-sidebar-queue-id]')
?.dataset.sidebarQueueId;
if (activeView === 'downloads' && focusedQueueId) {
const queueId = focusedQueueId;
const q = queues.find(q => q.id === queueId);
if (q && !q.isMain) {
e.preventDefault();
if (!window.confirm(t($ => $.sidebar.deleteQueueConfirm, { name: q.name }))) {
return;
}
void removeQueue(queueId).catch(error => {
addToast({ message: `Could not delete queue: ${String(error)}`, variant: 'error', isActionable: true });
addToast({ message: t($ => $.sidebar.deleteQueueFailed, { detail: String(error) }), variant: 'error', isActionable: true });
});
}
}
@@ -75,7 +116,7 @@ export const Sidebar: React.FC<SidebarProps> = (props) => {
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [selectedFilter, activeView, queues]);
}, [addToast, activeView, queues, removeQueue]);
const getCount = (filter: SidebarFilter) => {
if (filter.startsWith('queue:')) {
@@ -84,7 +125,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;
@@ -98,13 +139,13 @@ export const Sidebar: React.FC<SidebarProps> = (props) => {
<button
type="button"
data-active={isSelected}
className="sidebar-nav-item group flex w-full items-center text-[13px] text-left cursor-default font-medium"
className="sidebar-nav-item group flex w-full items-center text-[13px] text-start cursor-default font-medium"
onClick={() => onSelectFilter(filter)}
>
<Icon className="w-[18px] h-[18px] mr-3 shrink-0" strokeWidth={isSelected ? 2.5 : 2} />
<span className="truncate">{label}</span>
<Icon className="w-[18px] h-[18px] me-3 shrink-0" strokeWidth={isSelected ? 2.5 : 2} />
<span className="sidebar-nav-label truncate">{label}</span>
{getCount(filter) > 0 && (
<span className="sidebar-count ml-auto min-w-5 px-1.5 py-0.5 rounded-full text-center text-[10px] leading-none font-bold">
<span className="sidebar-count ms-auto min-w-5 px-1.5 py-0.5 rounded-full text-center text-[10px] leading-none font-bold">
{getCount(filter)}
</span>
)}
@@ -118,16 +159,78 @@ export const Sidebar: React.FC<SidebarProps> = (props) => {
setContextMenu({ x: e.clientX, y: e.clientY, id });
};
const handleAddQueueSubmit = () => {
if (newQueueName.trim()) addQueue(newQueueName.trim());
const handleAddQueueSubmit = (trigger: 'submit' | 'blur' = 'submit') => {
if (addQueueCancelRef.current) {
addQueueCancelRef.current = false;
return;
}
if (addQueueSubmitRef.current) return;
const normalizedName = newQueueName.trim();
if (!normalizedName) {
if (trigger === 'blur') {
setNewQueueName('');
setIsAddingQueue(false);
return;
}
addToast({ message: t($ => $.sidebar.queueNameEmpty), variant: 'error', isActionable: true });
return;
}
if (!addQueue(normalizedName)) {
if (trigger === 'blur' && rejectedAddQueueNameRef.current === normalizedName) {
rejectedAddQueueNameRef.current = null;
setNewQueueName('');
setIsAddingQueue(false);
return;
}
rejectedAddQueueNameRef.current = normalizedName;
addToast({ message: t($ => $.sidebar.queueNameExists), variant: 'error', isActionable: true });
return;
}
rejectedAddQueueNameRef.current = null;
addQueueSubmitRef.current = true;
setNewQueueName('');
setIsAddingQueue(false);
};
const handleRenameQueueSubmit = () => {
if (renamingQueueId && editingQueueName.trim()) {
renameQueue(renamingQueueId, editingQueueName.trim());
const handleRenameQueueSubmit = (queueId: string, trigger: 'submit' | 'blur' = 'submit') => {
if (renameQueueCancelRef.current === queueId) {
renameQueueCancelRef.current = null;
return;
}
if (renamingQueueIdRef.current !== queueId) return;
if (renameQueueSubmitRef.current) return;
const normalizedName = editingQueueNameRef.current.trim();
if (!normalizedName) {
if (trigger === 'blur') {
renamingQueueIdRef.current = null;
editingQueueNameRef.current = '';
setEditingQueueName('');
setRenamingQueueId(null);
return;
}
addToast({ message: t($ => $.sidebar.queueNameEmpty), variant: 'error', isActionable: true });
return;
}
if (!renameQueue(queueId, normalizedName)) {
if (
trigger === 'blur'
&& rejectedRenameRef.current?.queueId === queueId
&& rejectedRenameRef.current.name === normalizedName
) {
rejectedRenameRef.current = null;
renamingQueueIdRef.current = null;
editingQueueNameRef.current = '';
setEditingQueueName('');
setRenamingQueueId(null);
return;
}
rejectedRenameRef.current = { queueId, name: normalizedName };
addToast({ message: t($ => $.sidebar.queueNameExists), variant: 'error', isActionable: true });
return;
}
rejectedRenameRef.current = null;
renameQueueSubmitRef.current = true;
renamingQueueIdRef.current = null;
setRenamingQueueId(null);
};
@@ -138,19 +241,30 @@ export const Sidebar: React.FC<SidebarProps> = (props) => {
if (isRenaming) {
return (
<div className="flex items-center px-2.5 py-1 rounded-lg mb-0.5 bg-item-hover">
<List className="w-4 h-4 mr-2 text-text-secondary" strokeWidth={2} />
<div className="sidebar-queue-editor flex items-center px-2.5 py-1 rounded-lg mb-0.5 bg-item-hover">
<List className="w-4 h-4 me-2 text-text-secondary" strokeWidth={2} />
<input
ref={renameInputRef}
type="text"
className="flex-1 bg-transparent border border-accent rounded px-1 text-[13px] text-text-primary outline-none min-w-0"
value={editingQueueName}
onChange={e => setEditingQueueName(e.target.value)}
onKeyDown={e => {
if (e.key === 'Enter') handleRenameQueueSubmit();
if (e.key === 'Escape') setRenamingQueueId(null);
onChange={e => {
editingQueueNameRef.current = e.target.value;
rejectedRenameRef.current = null;
setEditingQueueName(e.target.value);
}}
onBlur={handleRenameQueueSubmit}
onKeyDown={e => {
if (e.key === 'Enter') handleRenameQueueSubmit(queue.id);
if (e.key === 'Escape') {
e.preventDefault();
renameQueueCancelRef.current = queue.id;
renamingQueueIdRef.current = null;
editingQueueNameRef.current = '';
setEditingQueueName('');
setRenamingQueueId(null);
}
}}
onBlur={() => handleRenameQueueSubmit(queue.id, 'blur')}
/>
</div>
);
@@ -160,14 +274,15 @@ export const Sidebar: React.FC<SidebarProps> = (props) => {
<button
type="button"
data-active={isSelected}
data-sidebar-queue-id={queue.id}
onContextMenu={e => handleQueueContextMenu(e, queue.id)}
onClick={() => onSelectFilter(filterId)}
className="sidebar-nav-item group flex w-full items-center text-[13px] text-left cursor-default font-medium"
className="sidebar-nav-item group flex w-full items-center text-[13px] text-start cursor-default font-medium"
>
<List className="w-[18px] h-[18px] mr-3 shrink-0" strokeWidth={isSelected ? 2.5 : 2} />
<span className="truncate">{queue.name}</span>
<List className="w-[18px] h-[18px] me-3 shrink-0" strokeWidth={isSelected ? 2.5 : 2} />
<span className="sidebar-nav-label truncate">{queue.name}</span>
{getCount(filterId) > 0 && (
<span className="sidebar-count ml-auto min-w-5 px-1.5 py-0.5 rounded-full text-center text-[10px] leading-none font-bold shrink-0">
<span className="sidebar-count ms-auto min-w-5 px-1.5 py-0.5 rounded-full text-center text-[10px] leading-none font-bold shrink-0">
{getCount(filterId)}
</span>
)}
@@ -182,10 +297,10 @@ export const Sidebar: React.FC<SidebarProps> = (props) => {
type="button"
data-active={isSelected}
onClick={() => setActiveView(view)}
className="sidebar-nav-item group flex w-full items-center text-[13px] text-left cursor-default font-medium"
className="sidebar-nav-item group flex w-full items-center text-[13px] text-start cursor-default font-medium"
>
<Icon className="w-[18px] h-[18px] mr-3 shrink-0" strokeWidth={isSelected ? 2.5 : 2} />
<span>{label}</span>
<Icon className="w-[18px] h-[18px] me-3 shrink-0" strokeWidth={isSelected ? 2.5 : 2} />
<span className="sidebar-nav-label">{label}</span>
</button>
);
};
@@ -199,70 +314,108 @@ export const Sidebar: React.FC<SidebarProps> = (props) => {
type="button"
onClick={toggleSidebar}
className="sidebar-toggle-button"
title="Hide Sidebar"
title={t($ => $.actions.hideSidebar)}
>
<PanelLeft size={14} strokeWidth={1.9} />
</button>
</div>
<div className="sidebar-scroll">
<section className="sidebar-section">
<div className="sidebar-section-label">Library</div>
<NavItem icon={Inbox} label="All" filter="all" />
<NavItem icon={Zap} label="Active" filter="active" />
<NavItem icon={CheckCircle2} label="Completed" filter="completed" />
<NavItem icon={CircleDashed} label="Unfinished" filter="unfinished" />
<div className="sidebar-section-label">{t($ => $.navigation.library)}</div>
<NavItem icon={Inbox} label={t($ => $.navigation.filters.all)} filter="all" />
<NavItem icon={Zap} label={t($ => $.navigation.filters.active)} filter="active" />
<NavItem icon={CheckCircle2} label={t($ => $.navigation.filters.completed)} filter="completed" />
<NavItem icon={CircleDashed} label={t($ => $.navigation.filters.unfinished)} filter="unfinished" />
</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={handleFoldersToggle}
>
<span>{t($ => $.navigation.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={t($ => $.navigation.categories.musics)} filter="Musics" />
<NavItem icon={Film} label={t($ => $.navigation.categories.movies)} filter="Movies" />
<NavItem icon={Archive} label={t($ => $.navigation.categories.compressed)} filter="Compressed" />
<NavItem icon={FileText} label={t($ => $.navigation.categories.documents)} filter="Documents" />
<NavItem icon={ImageIcon} label={t($ => $.navigation.categories.pictures)} filter="Pictures" />
<NavItem icon={Box} label={t($ => $.navigation.categories.applications)} filter="Applications" />
<NavItem icon={FileQuestion} label={t($ => $.navigation.categories.other)} filter="Other" />
</div>
</div>
</section>
<section className="sidebar-section">
<div className="sidebar-section-label">Queues</div>
<div className="sidebar-section-label">{t($ => $.navigation.queues)}</div>
{queues.map(queue => (
<QueueItem key={queue.id} queue={queue} />
))}
{isAddingQueue ? (
<div className="flex items-center px-3.5 py-1.5 rounded-lg bg-item-hover mb-1">
<Plus className="w-4 h-4 mr-2 text-text-secondary shrink-0" strokeWidth={2} />
<div className="sidebar-queue-editor flex items-center px-3.5 py-1.5 rounded-lg bg-item-hover mb-1">
<Plus className="w-4 h-4 me-2 text-text-secondary shrink-0" strokeWidth={2} />
<input
ref={addInputRef}
type="text"
placeholder="Queue name"
placeholder={t($ => $.actions.queueName)}
className="flex-1 bg-transparent border border-accent rounded px-1 text-[13px] text-text-primary outline-none min-w-0"
value={newQueueName}
onChange={e => setNewQueueName(e.target.value)}
onChange={e => {
rejectedAddQueueNameRef.current = null;
setNewQueueName(e.target.value);
}}
onKeyDown={e => {
if (e.key === 'Enter') handleAddQueueSubmit();
if (e.key === 'Escape') setIsAddingQueue(false);
if (e.key === 'Escape') {
e.preventDefault();
addQueueCancelRef.current = true;
setNewQueueName('');
setIsAddingQueue(false);
}
}}
onBlur={handleAddQueueSubmit}
onBlur={() => handleAddQueueSubmit('blur')}
/>
</div>
) : (
<button
type="button"
onClick={() => { 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"
onClick={() => {
addQueueSubmitRef.current = false;
addQueueCancelRef.current = false;
rejectedAddQueueNameRef.current = null;
setIsAddingQueue(true);
setNewQueueName('');
}}
className="sidebar-add-queue-button 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} />
<span className="truncate">Add new queue</span>
<Plus className="w-4 h-4 me-2 shrink-0" strokeWidth={2} />
<span className="truncate">{t($ => $.actions.addNewQueue)}</span>
</button>
)}
</section>
<section className="sidebar-section">
<div className="sidebar-section-label">Tools</div>
<ToolItem icon={CalendarClock} label="Scheduler" view="scheduler" />
<ToolItem icon={Gauge} label="Speed Limiter" view="speedLimiter" />
<ToolItem icon={Bug} label="Logs" view="logs" />
<div className="sidebar-section-label">{t($ => $.navigation.tools)}</div>
<ToolItem icon={CalendarClock} label={t($ => $.navigation.scheduler)} view="scheduler" />
<ToolItem icon={Gauge} label={t($ => $.navigation.speedLimiter)} view="speedLimiter" />
<ToolItem icon={Bug} label={t($ => $.navigation.logs)} view="logs" />
</section>
</div>
@@ -271,10 +424,10 @@ export const Sidebar: React.FC<SidebarProps> = (props) => {
type="button"
data-active={activeView === 'settings'}
onClick={() => setActiveView('settings')}
className="sidebar-nav-item sidebar-settings-button group flex w-full items-center text-[13px] text-left cursor-default font-medium transition-colors"
className="sidebar-nav-item sidebar-settings-button group flex w-full items-center text-[13px] text-start cursor-default font-medium transition-colors"
>
<Settings className={`w-[18px] h-[18px] mr-3 shrink-0 ${activeView === 'settings' ? 'text-white' : 'text-text-muted'}`} strokeWidth={activeView === 'settings' ? 2.5 : 2} />
<span>Settings</span>
<Settings className={`w-[18px] h-[18px] me-3 shrink-0 ${activeView === 'settings' ? 'text-white' : 'text-text-muted'}`} strokeWidth={activeView === 'settings' ? 2.5 : 2} />
<span className="sidebar-nav-label">{t($ => $.navigation.settings)}</span>
</button>
</div>
@@ -289,51 +442,84 @@ export const Sidebar: React.FC<SidebarProps> = (props) => {
onClick={e => e.stopPropagation()}
>
<button
className="w-full text-left px-3 py-1.5 flex items-center hover:bg-item-hover"
onClick={() => { startQueue(contextMenu.id); setContextMenu(null); }}
className="w-full text-start px-3 py-1.5 flex items-center hover:bg-item-hover"
onClick={() => {
const queueId = contextMenu.id;
setContextMenu(null);
void startQueue(queueId).catch(error => {
addToast({
message: t($ => $.sidebar.startQueueFailed, { detail: String(error) }),
variant: 'error',
isActionable: true
});
});
}}
>
<Play size={14} className="mr-2 text-text-secondary" />
Start Queue
<Play size={14} className="me-2 text-text-secondary" />
{t($ => $.actions.startQueue)}
</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); }}
className="w-full text-start px-3 py-1.5 flex items-center hover:bg-item-hover"
onClick={() => {
const queueId = contextMenu.id;
setContextMenu(null);
void pauseQueue(queueId).catch(error => {
addToast({
message: t($ => $.sidebar.pauseQueueFailed, { detail: String(error) }),
variant: 'error',
isActionable: true
});
});
}}
>
<Pause size={14} className="mr-2 text-text-secondary" />
Pause Queue
<Pause size={14} className="me-2 text-text-secondary" />
{t($ => $.actions.pauseQueue)}
</button>
<div className="h-px bg-border-color my-1 mx-2" />
<button
className="w-full text-left px-3 py-1.5 flex items-center hover:bg-item-hover"
className="w-full text-start px-3 py-1.5 flex items-center hover:bg-item-hover"
onClick={() => {
const q = queues.find(q => q.id === contextMenu.id);
if (q) {
renameQueueSubmitRef.current = false;
renameQueueCancelRef.current = null;
rejectedRenameRef.current = null;
renamingQueueIdRef.current = q.id;
editingQueueNameRef.current = q.name;
setEditingQueueName(q.name);
setRenamingQueueId(q.id);
}
setContextMenu(null);
}}
>
<Edit2 size={14} className="mr-2 text-text-secondary" />
Rename Queue
<Edit2 size={14} className="me-2 text-text-secondary" />
{t($ => $.actions.renameQueue)}
</button>
{!queues.find(q => q.id === contextMenu.id)?.isMain && (
<button
className="w-full text-left px-3 py-1.5 flex items-center hover:bg-red-500/20 text-red-400"
className="w-full text-start 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(t($ => $.sidebar.deleteQueueConfirm, { name: queue.name }))) {
return;
}
setContextMenu(null);
void removeQueue(queueId).catch(error => {
addToast({
message: `Could not delete queue: ${String(error)}`,
message: t($ => $.sidebar.deleteQueueFailed, { detail: String(error) }),
variant: 'error',
isActionable: true
});
});
}}
>
<Trash2 size={14} className="mr-2" />
Delete Queue
<Trash2 size={14} className="me-2" />
{t($ => $.actions.deleteQueue)}
</button>
)}
</div>
+56
View File
@@ -0,0 +1,56 @@
import { describe, expect, it } from 'vitest';
import {
convertSpeedValue,
clampSpeedDisplayValue,
displayValueFromPresetBase,
formatSpeedLimitForStorage,
formatPresetValue,
parseLimit,
presetBaseFromDisplayValue,
} from './SpeedLimiterView';
describe('SpeedLimiterView speed conversions', () => {
it('converts KB/s and MB/s using binary units consistently', () => {
expect(presetBaseFromDisplayValue(1024, 'KB/s')).toBe(1);
expect(displayValueFromPresetBase(1, 'KB/s')).toBe(1024);
expect(presetBaseFromDisplayValue(5, 'MB/s')).toBe(5);
expect(displayValueFromPresetBase(5, 'MB/s')).toBe(5);
});
it('round-trips non-MiB-aligned presets through the integer KiB backend value', () => {
const presetBase = presetBaseFromDisplayValue(1500, 'KB/s');
expect(displayValueFromPresetBase(presetBase, 'KB/s')).toBe(1500);
expect(convertSpeedValue(1500, 'KB/s', 'MB/s')).toBe(1500 / 1024);
expect(convertSpeedValue(convertSpeedValue(1500, 'KB/s', 'MB/s'), 'MB/s', 'KB/s')).toBe(1500);
});
it('preserves the selected unit when saving a non-MiB-aligned MB/s value', () => {
const storedLimit = formatSpeedLimitForStorage(1.5, 'MB/s');
expect(storedLimit).toBe('1.5M');
expect(parseLimit(storedLimit, 1024)).toEqual({ value: 1.5, unit: 'MB/s' });
});
it('preserves an explicitly selected KB/s unit at MiB boundaries', () => {
const storedLimit = formatSpeedLimitForStorage(1024, 'KB/s');
expect(storedLimit).toBe('1024K');
expect(parseLimit(storedLimit, 1)).toEqual({ value: 1024, unit: 'KB/s' });
});
it('restores the persisted unit while the limiter is disabled', () => {
expect(parseLimit('', 1536, 'MB/s')).toEqual({ value: 1.5, unit: 'MB/s' });
expect(parseLimit('', 1536, 'KB/s')).toEqual({ value: 1536, unit: 'KB/s' });
});
it('allows sub-one MB/s values while enforcing the 1 KiB backend minimum', () => {
expect(clampSpeedDisplayValue(0.5, 'MB/s')).toBe(0.5);
expect(clampSpeedDisplayValue(0, 'MB/s')).toBe(1 / 1024);
expect(clampSpeedDisplayValue(0, 'KB/s')).toBe(1);
});
it('shows the exact stored preset value instead of a rounded label', () => {
expect(formatPresetValue(1.46484375)).toBe('1.46484375');
});
});
+147 -60
View File
@@ -1,101 +1,174 @@
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';
import { useToast } from '../contexts/ToastContext';
import { useTranslation } from 'react-i18next';
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 { t } = useTranslation();
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
? t($ => $.speedLimiter.globalLimitSaved, { value: numericValue, unit })
: t($ => $.speedLimiter.globalLimitDisabled),
variant: 'success'
});
} catch (error) {
addToast({
message: t($ => $.speedLimiter.saveFailed, { detail: 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`,
? t($ => $.speedLimiter.presetAlreadyExists, { value: formatPresetValue(storedPresetDisplayValue), unit })
: t($ => $.speedLimiter.presetAdded, { value: formatPresetValue(storedPresetDisplayValue), unit }),
variant: alreadyExists ? 'info' : 'success'
});
};
@@ -105,11 +178,22 @@ export default function SpeedLimiterView() {
const nextPresets = presetValues.filter(value => value !== presetValue);
setSpeedLimitPresetValues(nextPresets);
addToast({
message: `Removed ${formatPresetValue(displayValue)} ${unit} quick preset`,
message: t($ => $.speedLimiter.presetRemoved, { value: formatPresetValue(displayValue), unit }),
variant: 'info'
});
};
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 +202,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"
>
@@ -126,34 +211,35 @@ export default function SpeedLimiterView() {
className={`inline-block h-4 w-4 transform rounded-full bg-white transition duration-200 ease-in-out ${enabled ? 'translate-x-4' : 'translate-x-1'}`}
/>
</button>
Speed Limiter
{t($ => $.speedLimiter.title)}
</div>
<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}` : t($ => $.speedLimiter.unlimited)}
</span>
<button onClick={save} className="app-button app-button-primary ml-auto px-3 text-[11px]">
<Save size={14} /> Save Limit
<button onClick={() => void save()} disabled={isSaving} className="app-button app-button-primary ms-auto px-3 text-[11px] disabled:opacity-50">
<Save size={14} /> {t($ => $.speedLimiter.saveLimit)}
</button>
</div>
<div className="flex-1 overflow-y-auto p-6">
<section className={`app-card max-w-[760px] p-5 ${enabled ? '' : 'opacity-55'}`}>
<div className="mb-2 flex items-center gap-2 font-semibold text-text-primary">
<Gauge size={18} className="text-accent" /> Global Speed Limit
<Gauge size={18} className="text-accent" /> {t($ => $.speedLimiter.globalSpeedLimit)}
</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.
{t($ => $.speedLimiter.description)}
</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 +247,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'
}`}
@@ -175,7 +261,7 @@ export default function SpeedLimiterView() {
<div className="my-6 border-t border-border-color" />
<div className="mb-3 flex items-center gap-2 text-[12px] font-medium text-text-secondary">
<Zap size={14} /> Quick Presets
<Zap size={14} /> {t($ => $.speedLimiter.quickPresets)}
</div>
<div className="flex flex-wrap items-center gap-2">
{presetValues.map(presetValue => {
@@ -187,43 +273,44 @@ 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"
className="h-full flex-1 px-3 text-start disabled:opacity-50"
>
{formatPresetValue(displayValue)} {unit}
</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`}
aria-label={`Remove ${formatPresetValue(displayValue)} ${unit} preset`}
className="flex h-full w-7 items-center justify-center border-s 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={t($ => $.speedLimiter.removePreset, { value: formatPresetValue(displayValue), unit })}
aria-label={t($ => $.speedLimiter.removePreset, { value: formatPresetValue(displayValue), unit })}
>
<X size={12} />
</button>
</div>
);
})}
<div className="ml-1 flex h-8 items-center gap-1.5 rounded-md border border-border-modal bg-bg-input px-2">
<div className="ms-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}`}
aria-label={t($ => $.speedLimiter.customPresetIn, { 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"
aria-label="Add quick preset"
title={t($ => $.speedLimiter.addQuickPreset)}
aria-label={t($ => $.speedLimiter.addQuickPreset)}
>
<Plus size={14} />
</button>
+20 -10
View File
@@ -1,6 +1,7 @@
import { getCurrentWindow } from '@tauri-apps/api/window';
import { Minus, Square, X } from 'lucide-react';
import { Maximize2, Minus, X } from 'lucide-react';
import type { PointerEvent } from 'react';
import { useTranslation } from 'react-i18next';
const appWindow = getCurrentWindow();
@@ -8,14 +9,23 @@ const stopTitlebarDrag = (event: PointerEvent<HTMLButtonElement>) => {
event.stopPropagation();
};
export function WindowControls() {
interface WindowControlsProps {
side: 'left' | 'right';
}
export function WindowControls({ side }: WindowControlsProps) {
const { t } = useTranslation();
return (
<div className="window-controls" aria-label="Window controls">
<div
className={`window-controls window-controls--${side}`}
aria-label={t($ => $.window.controls)}
>
<button
type="button"
className="window-control close"
title="Close"
aria-label="Close"
title={t($ => $.window.close)}
aria-label={t($ => $.window.close)}
onPointerDown={stopTitlebarDrag}
onClick={(event) => {
event.stopPropagation();
@@ -27,8 +37,8 @@ export function WindowControls() {
<button
type="button"
className="window-control minimize"
title="Minimize"
aria-label="Minimize"
title={t($ => $.window.minimize)}
aria-label={t($ => $.window.minimize)}
onPointerDown={stopTitlebarDrag}
onClick={(event) => {
event.stopPropagation();
@@ -40,15 +50,15 @@ export function WindowControls() {
<button
type="button"
className="window-control maximize"
title="Maximize"
aria-label="Maximize"
title={t($ => $.window.maximize)}
aria-label={t($ => $.window.maximize)}
onPointerDown={stopTitlebarDrag}
onClick={(event) => {
event.stopPropagation();
void appWindow.toggleMaximize();
}}
>
<Square size={8} strokeWidth={3} />
<Maximize2 size={9} strokeWidth={3} />
</button>
</div>
);
+19 -3
View File
@@ -1,5 +1,6 @@
import React, { createContext, useContext, useState, useCallback, ReactNode, useEffect, useRef, useLayoutEffect } from 'react';
import { CheckCircle2, AlertCircle, Info, XCircle, X } from 'lucide-react';
import { useTranslation } from 'react-i18next';
export type ToastVariant = 'success' | 'info' | 'warning' | 'error';
@@ -14,6 +15,7 @@ export interface ToastMessage {
variant?: ToastVariant;
duration?: number;
isActionable?: boolean;
onDismiss?: () => void;
}
interface ToastState extends ToastMessage {
@@ -21,7 +23,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 +35,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) => {
@@ -66,10 +70,12 @@ export const useToast = () => {
};
const ToastItem: React.FC<{ toast: ToastState; removeToast: (id: string) => void; removeToastCompletely: (id: string) => void }> = ({ toast, removeToast, removeToastCompletely }) => {
const { t } = useTranslation();
const [isMounted, setIsMounted] = useState(false);
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 +105,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(() => {
@@ -182,7 +198,7 @@ const ToastItem: React.FC<{ toast: ToastState; removeToast: (id: string) => void
<button
onClick={() => removeToast(toast.id)}
className="ml-2 mt-0.5 shrink-0 rounded-md p-1 text-text-secondary opacity-70 transition-all hover:bg-item-hover hover:text-text-primary hover:opacity-100 active:scale-95"
aria-label="Dismiss notification"
aria-label={t($ => $.actions.dismissNotification)}
>
<X className="w-4 h-4" strokeWidth={2.5} />
</button>
+755
View File
@@ -0,0 +1,755 @@
const common = {
navigation: {
library: 'Library',
filters: {
all: 'All',
active: 'Active',
completed: 'Completed',
unfinished: 'Unfinished',
},
categories: {
musics: 'Music',
movies: 'Movies',
compressed: 'Compressed',
documents: 'Documents',
pictures: 'Pictures',
applications: 'Applications',
other: 'Other',
},
folders: 'Folders',
queues: 'Queues',
tools: 'Tools',
scheduler: 'Scheduler',
speedLimiter: 'Speed Limiter',
logs: 'Logs',
settings: 'Settings',
},
actions: {
hideSidebar: 'Hide Sidebar',
showSidebar: 'Show Sidebar',
resizeSidebar: 'Resize Sidebar',
cancel: 'Cancel',
continue: 'Continue',
dismissNotification: 'Dismiss notification',
queueName: 'Queue name',
addNewQueue: 'Add new queue',
startQueue: 'Start Queue',
pauseQueue: 'Pause Queue',
renameQueue: 'Rename Queue',
deleteQueue: 'Delete Queue',
},
dialogs: {
errorBoundary: {
title: 'Something went wrong.',
description: 'A critical error occurred in the React component tree. The error details below can help identify the root cause.',
reload: 'Reload App',
},
duplicateDownloads: {
title: 'Duplicate Downloads Detected',
description: 'Some of the downloads you are adding already exist in the queue or on disk. Please choose how to resolve these conflicts.',
rename: 'Rename',
replace: 'Replace',
skip: 'Skip',
},
removeDownload: {
title: 'Remove Download',
confirmationSingle: 'Are you sure you want to remove this item from the list? You can also choose to delete the underlying file from your hard drive.',
confirmationMultiple: 'Are you sure you want to remove these {{count}} items from the list? You can also choose to delete the underlying files from your hard drive.',
errorSummary: '{{succeeded}} removed, {{failed}} failed: {{detail}}',
remove: 'Remove',
deleteFile: 'Delete file',
},
},
window: {
controls: 'Window controls',
close: 'Close',
minimize: 'Minimize',
maximize: 'Maximize',
},
downloads: {
actions: {
moveUp: 'Move Up',
moveDown: 'Move Down',
pause: 'Pause',
start: 'Start',
resume: 'Resume',
options: 'Options',
},
size: {
downloadedOf: '{{downloaded}} downloaded of {{total}} {{unit}}',
downloadedOfApproximate: '{{downloaded}} downloaded of approximately {{total}} {{unit}}',
},
status: {
ready: 'Ready',
staged: 'In queue',
queued: 'Queued',
downloading: 'Downloading',
processing: 'Processing',
paused: 'Paused',
completed: 'Completed',
failed: 'Failed',
retrying: 'Retrying',
},
values: {
processing: 'Processing...',
muxing: 'Muxing...',
},
},
speedLimiter: {
title: 'Speed Limiter',
unlimited: 'Unlimited',
saveLimit: 'Save Limit',
globalSpeedLimit: 'Global Speed Limit',
description: 'Applies to new and active aria2 transfers and new yt-dlp media downloads. Explicit per-download limits remain attached to those transfers.',
quickPresets: 'Quick Presets',
removePreset: 'Remove {{value}} {{unit}} preset',
customPresetIn: 'Custom preset in {{unit}}',
addQuickPreset: 'Add quick preset',
globalLimitSaved: 'Global limit saved at {{value}} {{unit}}',
globalLimitDisabled: 'Global speed limit disabled',
saveFailed: 'Could not save global speed limit: {{detail}}',
presetAlreadyExists: '{{value}} {{unit}} is already in quick presets',
presetAdded: 'Added {{value}} {{unit}} quick preset',
presetRemoved: 'Removed {{value}} {{unit}} quick preset',
},
logs: {
title: 'Logs',
entries: '({{count}} entries)',
collecting: 'Collecting',
off: 'Off',
allLevels: 'All Levels',
levels: {
error: 'Error',
warn: 'Warn',
info: 'Info',
debug: 'Debug',
trace: 'Trace',
},
pauseLogging: 'Pause diagnostic logging',
enableLogging: 'Enable diagnostic logging',
clearDisplayed: 'Clear displayed logs',
export: 'Export logs',
exportButton: 'Export Logs',
logFiles: 'Log Files',
localDiagnostics: 'Local diagnostics:',
diagnosticsDescription: 'Diagnostic collection is opt-in, bounded, and local. Common secrets, URL queries, and home-directory paths are redacted during display and export. Nothing is uploaded automatically.',
noEntries: 'No persisted log entries are available yet.',
disabledDescription: 'Diagnostic logging is off. Existing support logs will appear here when available.',
copy: 'Copy',
copied: 'Copied to clipboard',
copyFailed: 'Failed to copy to clipboard',
exported: 'Support logs exported',
exportFailed: 'Could not export logs: {{detail}}',
cleared: 'Logs cleared',
clearFailed: 'Could not clear logs: {{detail}}',
enabled: 'Diagnostic logging enabled',
disabled: 'Diagnostic logging disabled',
updateFailed: 'Could not update diagnostic logging: {{detail}}',
},
scheduler: {
title: 'Scheduler',
running: 'Running',
disabled: 'Scheduler is disabled',
noScheduledDay: 'No scheduled day selected',
unsavedChanges: 'Unsaved changes',
runNow: 'Run Now',
pause: 'Pause',
saveSettings: 'Save Settings',
timing: 'Timing',
startTime: 'Start Time',
stopTime: 'Stop Time',
timingDescription: 'If Firelink is asleep at the start time, it starts the selected queues when it returns later that day, unless the stop time has already passed.',
runEveryDay: 'Run Every Day',
queuesToSchedule: 'Queues to Schedule',
defaultQueue: 'Default queue',
afterCompletion: 'After Completion',
afterCompletionDescription: 'Choose what happens after downloads started by the scheduler finish.',
actionWarning: 'This action can interrupt other work on the computer. Firelink invokes it immediately after the scheduled queue finishes.',
systemPermissions: 'System Permissions',
macPermissionDescription: 'Sleep, restart, and shut down require macOS Automation permission for System Events.',
permissionGranted: 'Automation permission granted',
permissionChecking: 'Checking Automation permission...',
permissionMissing: 'Automation permission missing',
revokePermission: 'Revoke permission',
grantPermission: 'Grant permission',
permissionAvailable: 'Automation permission is available.',
permissionMissingDetails: 'Automation permission is missing. Enable Firelink under Automation for System Events in System Settings.',
revokePermissionDetails: 'macOS does not allow Firelink to revoke Automation permission directly. Revoke System Events access in System Settings, then return to Firelink.',
requestingPermission: 'Requesting Automation permission...',
enablePermissionDetails: 'Enable Firelink under Automation for System Events in System Settings, then return to Firelink.',
systemActions: 'System Actions',
windowsActionsDescription: 'Sleep, restart, and shut down use Windows system privileges. Firelink reports any rejected action when it runs; no permanent permission is claimed in advance.',
linuxActionsDescription: 'Sleep, restart, and shut down use your Linux desktop and system policy. Firelink reports any rejected action when it runs; no permanent permission is claimed in advance.',
validationDay: 'Select at least one day for the scheduler',
validationQueue: 'Select at least one queue for the scheduler',
validationStopTime: 'Stop time must be later than start time',
saved: 'Scheduler settings saved',
trackingOne: 'Tracking 1 scheduled download',
trackingMany: 'Tracking {{count}} scheduled downloads',
noStartableDownloads: 'No downloads in the selected queues can be started',
pausedOne: 'Paused 1 active download',
pausedMany: 'Paused {{count}} active downloads',
noActiveDownloads: 'No active downloads',
days: {
su: 'Su',
mo: 'Mo',
tu: 'Tu',
we: 'We',
th: 'Th',
fr: 'Fr',
sa: 'Sa',
},
postActions: {
none: 'Do nothing',
sleep: 'Sleep',
restart: 'Restart',
shutdown: 'Shut down',
},
},
properties: {
progress: 'Progress',
size: 'Size',
speed: 'Speed',
eta: 'ETA',
connections: 'Connections',
connectionCount: '{{active}}/{{total}}',
connectionCountUnknown: '—/{{total}}',
connectionsUnavailable: '—',
speedCap: 'Speed cap',
category: 'Category',
lastTry: 'Last try',
dateAdded: 'Date added',
destination: 'Destination',
lastError: 'Last error',
saved: ' (saved)',
defaultValue: ' (default)',
savedTooltip: 'Saved for this download; Settings changes apply to new downloads.',
defaultTooltip: 'Using the current default for new downloads.',
identityReadOnly: 'File identity is read-only. Transfer settings are saved for redownload.',
transferSettings: 'Transfer settings can be changed after stopping or pausing. Current transfers keep their existing backend options.',
download: 'Download',
url: 'URL',
fileName: 'File name',
saveLocation: 'Save location',
select: 'Select',
perFile: 'per file',
useCurrentDefault: 'Use current default ({{count}})',
savedPerDownload: 'Saved per download. The Settings default applies to new downloads.',
limit: 'Limit',
siteLogin: 'Site Login',
siteLoginRedownload: 'Site Login for Redownload',
matchingSiteLogin: 'Matching site login',
customCredentials: 'Custom credentials',
noLogin: 'No login',
useSavedLogin: 'Will use saved login if available.',
advancedTransfer: 'Advanced Transfer',
advancedTransferRedownload: 'Advanced Transfer for Redownload',
checksum: 'Checksum',
verify: 'Verify',
algorithm: 'Algorithm',
digest: 'Digest',
expectedDigest: 'Expected digest',
cookies: 'Cookies',
headers: 'Headers',
mirrors: 'Mirrors',
username: 'Username',
password: 'Password',
enterValidUrl: 'Enter a valid URL.',
fileNameEmpty: 'File name cannot be empty.',
cancel: 'Cancel',
save: 'Save',
},
keychain: {
title: 'Credential Storage Access Needed',
stores: {
portable: 'the portable Firelink data folder',
windows: 'Windows Credential Manager',
linux: 'your Linux credential store',
macos: 'macOS Keychain',
system: "this system's credential store",
siteCredentials: "the system's credential store",
},
grantLabelPortable: 'Continue',
grantLabelMacos: 'Grant Access',
grantLabelDefault: 'Enable Secure Storage',
description: 'Firelink uses the browser extension to capture downloads. To keep the extension paired after restarts, Firelink stores its pairing token in {{pairingStore}}. Optional site credentials are stored in {{siteCredentialStore}}.',
portableExplanation: '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.',
macosExplanation: 'macOS may show a Keychain prompt after you grant access.',
defaultExplanation: '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.',
note: 'Note:',
portableNote: 'Portable mode stores only the pairing token in this folder. It does not copy site passwords or browser credentials.',
defaultNote: 'Firelink only writes its own dedicated credential entry. It cannot access other saved passwords or credential items on your system.',
hint: 'Hint:',
portableHint: 'The portable pairing token is already stored with this folder; you can enable it here or select Later.',
defaultHint: 'If you select Later, the extension will only work for this session.',
enableFromSettings: 'You can enable storage anytime from Settings > Integrations.',
later: 'Later',
enabling: 'Enabling...',
timeout: 'Credential storage request timed out. You can select Later and try again.',
unavailable: '{{store}} is unavailable.',
accessRequired: 'Grant credential-store access before regenerating the pairing token.',
storeUnavailable: 'Credential store access is unavailable.',
},
app: {
loading: 'Loading…',
settingsSaveFailed: 'Could not save settings. Check storage permissions and try again.',
systemActionCountdown: '{{action}} in 10 seconds.',
systemActionCancelled: 'System action cancelled because another download is active or queued.',
systemActionFailed: 'Scheduled system action failed: {{detail}}',
downloadCompleteTitle: 'Download Complete',
downloadCompleteBody: '{{fileName}} has finished downloading.',
unknownFile: 'A file',
downloadFailedTitle: 'Download Failed',
downloadFailedBody: '{{fileName}} failed to download.',
initializeDownloadsFailed: 'Could not initialize saved downloads: {{detail}}',
extensionDisconnected: 'Browser extension disconnected because its pairing token changed.',
copyToken: 'Copy token',
integrations: 'Integrations',
copyTokenFailed: 'Could not copy pairing token: {{detail}}',
credentialPersistenceFailed: 'Secure credential persistence is unavailable. Browser pairing works for this session only: {{detail}}',
resumeDownloadsFailed: 'Could not resume saved downloads: {{detail}}',
updateAvailable: 'Firelink {{version}} is available.',
viewRelease: 'View release',
sleepPreventionFailed: 'Could not update sleep prevention: {{detail}}',
schedulerNoQueues: 'Scheduler has no valid queues selected. Update Scheduler settings.',
schedulerPauseOneFailed: 'Scheduler could not pause 1 download.',
schedulerPauseManyFailed: 'Scheduler could not pause {{count}} downloads.',
scheduledIncomplete: 'Scheduled downloads did not all complete. The post-queue system action was skipped.',
scheduledActionSkippedActive: 'Scheduled system action skipped because another download is active or queued.',
notificationsDisabled: 'System notifications are disabled for Firelink.',
notificationsFailed: 'Could not configure notifications: {{detail}}',
},
sidebar: {
deleteQueueConfirm: 'Delete queue "{{name}}"? Its unfinished downloads will move to Main Queue.',
deleteQueueFailed: 'Could not delete queue: {{detail}}',
queueNameEmpty: 'Queue name cannot be empty',
queueNameExists: 'A queue with this name already exists',
startQueueFailed: 'Could not start queue: {{detail}}',
pauseQueueFailed: 'Could not pause queue: {{detail}}',
},
downloadTable: {
unknownQueue: 'Unknown Queue',
allDownloads: 'All Downloads',
active: 'Active',
completed: 'Completed',
unfinished: 'Unfinished',
headers: {
fileName: 'File Name',
size: 'Size',
status: 'Status',
speed: 'Speed',
eta: 'ETA',
dateAdded: 'Date Added',
},
columnOptions: {
open: 'Column options for {{column}}',
dragToReorder: 'Drag to reorder columns',
alignContent: 'Align content position',
left: 'Left',
center: 'Center',
right: 'Right',
resetLayout: 'Reset column layout',
},
addDownload: 'Add Download',
resumeAll: 'Resume All',
pauseAll: 'Pause All',
queueEmpty: 'Queue is empty',
noCompletedDownloads: 'No Completed Downloads',
noDownloads: 'No Downloads',
queueEmptyDescription: 'Add downloads to this queue from an item menu or the Add window.',
completedDescription: 'Completed downloads will appear here.',
clickToAdd: 'Click',
addButtonOr: 'button or',
toAddDownloads: 'to add downloads',
nonResumableOne: '1 download does not support resuming. If you pause it, you will have to start over again later. Are you sure you want to pause?',
nonResumableMany: '{{count}} downloads do not support resuming. If you pause them, you will have to start over again later. Are you sure you want to pause?',
interactionError: '{{message}}: {{detail}}',
openFileFailed: 'Could not open downloaded file',
revealFileFailed: 'Could not show download in Finder',
pauseFailed: 'Could not pause download',
resumeFailed: 'Could not resume {{fileName}}',
moveManyFailed: 'Could not move downloads to queue',
moveOneFailed: 'Could not move download to queue',
copyAddressesFailed: 'Could not copy addresses',
copyAddressFailed: 'Could not copy address',
copyPathFailed: 'Could not copy file path',
missingFileName: 'File name is missing',
redownloadFailed: 'Redownload failed',
startResume: 'Start/Resume',
addToQueue: 'Add to Queue',
copyAddress: 'Copy Address',
remove: 'Remove',
open: 'Open',
showInFolder: 'Show in Folder',
pause: 'Pause',
start: 'Start',
resume: 'Resume',
redownload: 'Redownload',
copyFilePath: 'Copy File Path',
properties: 'Properties',
backendRejectedStart: 'The backend rejected the start/resume request.',
transferActive: 'Cannot change properties while transfer is active. Pause it first.',
redownloadNotFound: 'Cannot redownload: download was not found.',
redownloadActive: 'Cannot redownload a {{status}} download. Pause or wait for it to finish first.',
originalUrlMissing: 'Cannot redownload: original URL is missing.',
pauseBeforeMove: 'Pause {{file}} before moving it to another queue.',
},
addDownloads: {
unknownSize: 'Unknown size',
unknown: 'Unknown',
discardSetup: 'Discard this download setup?',
speedInvalid: 'Speed limit must be greater than zero',
duplicateResolveFailed: 'Could not resolve duplicate downloads: {{detail}}',
addedWithFailures: '{{added}} added, {{failed}} failed. {{detail}}',
addedOne: '1 download added',
addedMany: '{{count}} downloads added',
updatedOne: '1 download updated',
updatedMany: '{{count}} downloads updated',
urlAlreadyQueued: 'URL already in queue',
destinationConflict: 'Another selected download uses this destination',
existingDownloadDestination: 'Existing Firelink download uses this destination',
matchingDownloadFilename: 'An unfinished download already has this filename',
fileExistsOnDisk: 'File exists on disk; rename or skip to avoid deleting unrelated data',
backendRejectedStart: 'Backend rejected download start.',
playlistNoEntries: 'Playlist contains no downloadable entries',
invalidMediaMetadata: 'Invalid media metadata or no formats found',
noAvailableName: 'Could not find an available name for {{file}}.',
pauseBeforeReplace: 'Pause {{file}} before replacing it.',
cannotReplace: 'Cannot replace {{file}}: file is not owned by a Firelink download.',
downloadLinks: 'Download Links',
pastePlaceholder: 'Paste HTTP, HTTPS, FTP, or SFTP URLs here...\n\nFor media downloads, paste links from YouTube, X, TikTok, Instagram, Reddit, etc.',
playlistSummary: 'Playlist “{{title}}”: {{loaded}}{{total}} entries loaded{{truncated}}{{skipped}}',
safeEntryLimit: ' (safe entry limit reached)',
selectedSummary: '{{ready}} selected ready, {{fallback}} fallback, {{mediaRetry}} media retry, {{blocked}} blocked',
clearSelection: 'Clear selection',
selectAll: 'Select all',
refreshMetadata: 'Refresh Metadata',
files: 'Files',
required: 'Required',
free: 'Free',
preview: 'Preview',
file: 'File',
size: 'Size',
status: 'Status',
downloadPreview: 'Download preview',
noLinks: 'No links added yet.',
selectItem: 'Select {{file}}',
fetchingPlaylist: 'Fetching playlist...',
fetching: 'Fetching...',
unsafeUrl: 'Unsafe URL',
playlistFailed: 'Playlist failed',
metadataFailed: 'Metadata failed',
fallback: 'Fallback',
invalid: 'Invalid',
ready: 'Ready',
mediaFormat: 'Media Format',
playlistItem: 'Playlist item {{index}}',
fetchingMediaStreams: 'Fetching media streams...',
availableStreams: 'Available Streams',
availableMediaStreams: 'Available media streams',
metadataUnavailable: 'Metadata unavailable. Refresh metadata before adding this media.',
saveLocation: 'Save Location',
browse: 'Browse',
categoryFolders: 'Files will be organized into category folders.',
sharedFolder: 'All selected downloads will use this folder.',
dedicatedFolder: 'Save in a new folder',
dedicatedFolderName: 'Folder name',
dedicatedFolderDescription: 'Creates this folder inside each automatic category folder.',
dedicatedFolderManualDescription: 'Creates this folder inside the selected save location.',
saveFolderName: 'Save folder name',
dedicatedFolderNameRequired: 'Enter a folder name before saving.',
transferSettings: 'Transfer Settings',
connectionsPerFile: 'Connections per File',
connectionsPerFileAria: 'Connections per file',
limitSpeedPerFile: 'Limit speed per file',
speedLimitPerFileAria: 'Speed limit per file',
authorization: 'Authorization',
useAuthorization: 'Use authorization',
username: 'Username',
password: 'Password',
advancedTransfer: 'Advanced Transfer',
verifyChecksum: 'Verify Checksum',
checksumAlgorithm: 'Checksum algorithm',
expectedDigest: 'Expected digest',
headers: 'Headers',
requestHeaders: 'Request headers',
cookies: 'Cookies',
cookiePlaceholder: 'name=value; other=value',
mirrors: 'Mirrors',
cancel: 'Cancel',
startDownloads: 'Start Downloads',
addToQueue: 'Add to queue',
pasteOneOrMore: 'Paste one or more links.',
selectAtLeastOne: 'Select at least one download.',
correctInvalidOne: 'Correct or remove {{count}} invalid URL before continuing.',
correctInvalidFew: 'Correct or remove {{count}} invalid URLs before continuing.',
correctInvalidMany: 'Correct or remove {{count}} invalid URLs before continuing.',
waitingForMetadataOne: 'Waiting for metadata for {{count}} download.',
waitingForMetadataFew: 'Waiting for metadata for {{count}} downloads.',
waitingForMetadataMany: 'Waiting for metadata for {{count}} downloads.',
removeUnsafeOne: 'Remove {{count}} unsafe URL before continuing.',
removeUnsafeFew: 'Remove {{count}} unsafe URLs before continuing.',
removeUnsafeMany: 'Remove {{count}} unsafe URLs before continuing.',
mediaMetadataUnavailableSummaryOne: 'Media metadata is unavailable for {{count}} item. Refresh metadata before adding.',
mediaMetadataUnavailableSummaryFew: 'Media metadata is unavailable for {{count}} items. Refresh metadata before adding.',
mediaMetadataUnavailableSummaryMany: 'Media metadata is unavailable for {{count}} items. Refresh metadata before adding.',
metadataUnavailableFallback: 'Metadata is unavailable. Downloads can still be added using fallback details.',
fallbackReadyOne: '{{ready}} download ready; {{failed}} will use fallback filename and unknown size.',
fallbackReadyFew: '{{ready}} downloads ready; {{failed}} will use fallback filename and unknown size.',
fallbackReadyMany: '{{ready}} downloads ready; {{failed}} will use fallback filename and unknown size.',
readyToAddOne: 'Ready to add {{count}} download.',
readyToAddFew: 'Ready to add {{count}} downloads.',
readyToAddMany: 'Ready to add {{count}} downloads.',
},
settings: {
tabs: {
downloads: 'Downloads',
lookAndFeel: 'Look and feel',
network: 'Network',
locations: 'Locations',
siteLogins: 'Site Logins',
power: 'Power',
engine: 'Engine',
integrations: 'Integrations',
about: 'About',
},
common: {
thisOs: 'this OS',
checking: 'Checking...',
ready: 'Ready',
errorMissing: 'Error / Missing',
hideTechnicalDetails: 'Hide technical details',
showTechnicalDetails: 'Show technical details',
binary: 'Binary',
expected: 'Expected',
error: 'Error',
tip: 'Tip',
stderr: 'stderr',
daemonProcessAlive: 'Daemon process alive',
rpcReady: 'RPC ready',
rpcPort: 'RPC port',
daemonStderr: 'daemon stderr',
packaging: 'Packaging: PyInstaller onedir (_internal required)',
internalDirectoryFound: '_internal directory found',
pythonRuntimeFound: 'Python runtime found',
externalOpenFailed: 'Could not open {{label}}: {{detail}}',
updateUnexpectedResponse: 'The update check returned an unexpected response.',
updateUntrustedReleaseUrl: 'The update check returned an untrusted release URL.',
updateFailed: 'Update check failed: {{detail}}',
},
downloads: {
defaultConnections: 'Default connections:',
defaultConnectionsDescription: 'New downloads; existing items keep their saved value',
parallelDownloads: 'Parallel downloads:',
parallelDownloadsDescription: 'Max simultaneous active files',
automaticRetries: 'Automatic retries:',
automaticRetriesDescription: 'If a connection fails',
systemNotification: 'Show system notification when download completes',
systemNotificationDescription: 'Uses your operating system notification settings',
completionChime: 'Play in-app completion chime',
completionChimeDescription: 'Optional Firelink sound, independent of system notifications',
clipboardLinks: 'Add clipboard links when Firelink becomes active',
clipboardLinksDescription: 'Opens supported copied links in the Add window. Off by default.',
},
lookAndFeel: {
language: 'Language',
languageDescription: 'Choose the Firelink interface language.',
sidebarPosition: 'Sidebar position',
sidebarPositionDescription: 'Choose where the sidebar appears. Automatic follows the interface direction.',
sidebarPositionAutomatic: 'Automatic (recommended)',
sidebarPositionLeft: 'Left',
sidebarPositionRight: 'Right',
languageSystem: 'System default',
languageEnglish: 'English',
languageChinese: 'Simplified Chinese',
languageHebrew: 'Hebrew',
languagePersian: 'Persian',
languageUkrainian: 'Ukrainian',
languageRussian: 'Russian',
appTheme: 'App Theme',
theme: 'Theme',
ariaLabel: 'App theme',
system: 'System',
light: 'Light',
dark: 'Dark',
dracula: 'Dracula',
nord: 'Nord',
systemAppearance: 'System follows the current {{platform}} light or dark appearance.',
display: 'Display',
fontSize: 'Font size',
fontSizeDescription: 'Scales the download list and compact controls.',
small: 'Small',
standard: 'Standard',
large: 'Large',
listDensity: 'List density',
listDensityDescription: 'Changes row height, spacing, and progress bar scale.',
compact: 'Compact',
comfortable: 'Comfortable',
relaxed: 'Relaxed',
osIntegration: 'OS Integration',
dockBadge: 'Show badge on Dock icon',
dockBadgeDescription: 'Displays active download count on Firelink Dock icon.',
menuBarIcon: 'Show menu bar icon',
statusIndicatorIcon: 'Show status indicator icon',
systemTrayIcon: 'Show system tray icon',
macosTrayDescription: 'Provides quick access from the macOS menu bar.',
windowsTrayDescription: 'Provides quick access from the Windows notification area.',
linuxTrayDescription: 'Provides quick access from the desktop tray or status area when available.',
defaultTrayDescription: 'Provides quick access from the OS tray area when available.',
},
network: {
proxy: 'Proxy',
mode: 'Mode',
modeDescription: 'Controls proxy use for new download requests.',
noProxy: 'No Proxy',
systemProxy: 'System Proxy',
customProxy: 'Custom Proxy',
proxyHost: 'Proxy host',
proxyHostDescription: 'Host name, IP address, or HTTP proxy URL.',
proxyHostPlaceholder: '127.0.0.1 or http://127.0.0.1',
proxyPort: 'Proxy port',
proxyPortDescription: 'Valid range is 1 to 65535.',
noProxyDescription: 'Downloads ignore configured proxies.',
systemProxyDescription: 'Downloads use the detected {{platform}} system proxy. Normal file downloads require an HTTP or HTTPS proxy endpoint; media downloads can use SOCKS.',
customProxyDescription: 'Downloads use the configured HTTP proxy endpoint for metadata and download engines.',
invalidCustomProxy: 'Enter a valid HTTP proxy host and port to enable the custom proxy.',
incompleteCustomProxy: 'Enter a proxy host and port to enable the custom proxy.',
checkingSystemProxy: 'Checking system proxy configuration…',
detectedSystemProxy: 'A system proxy was detected. Normal file downloads require an HTTP or HTTPS endpoint; media downloads can use SOCKS.',
noSystemProxy: 'No usable system proxy was detected. Downloads will use no proxy.',
systemProxyReadFailed: 'System proxy configuration could not be read. Choose No Proxy or try again when it is available.',
identity: 'Identity',
customUserAgent: 'Custom User-Agent',
userAgentDescription: 'Applied to metadata fetches and download engines.',
userAgentPlaceholder: 'Leave blank for Firelink default',
userAgentOverrides: 'Overrides the outbound User-Agent header. Leave blank for Firelink defaults.',
chromeWindows: 'Chrome (Windows)',
chromeMacos: 'Chrome (macOS)',
edgeWindows: 'Edge (Windows)',
firefoxWindows: 'Firefox (Windows)',
firefoxMacos: 'Firefox (macOS)',
safariMacos: 'Safari (macOS)',
windowsDesktop: 'Windows desktop',
macosDesktop: 'macOS desktop',
},
locations: {
baseDownloadFolder: 'Base Download Folder',
baseDownloadFolderDescription: 'Automatic category folders are created inside this folder.',
browse: 'Browse',
baseFolderCreateFailed: 'Base folder saved, but category folders could not be created: {{detail}}',
baseFolderUpdated: 'Base download folder updated',
askWhereToSave: 'Ask where to save when adding downloads',
rememberLastUsedDownloadDirectory: 'Remember the last used download directory',
rememberLastUsedDownloadDirectoryDescription: 'Use the last folder selected in the Add window for the next download until the app restarts.',
automaticCategorySubfolders: 'Automatically save files to category subfolders',
automaticCategorySubfoldersDescription: 'When off, automatic downloads use the base folder.',
categorySubfolders: 'Category Subfolders',
relativeToBase: 'Relative to the base folder',
disabled: 'Disabled',
categorySubfolder: '{{category}} subfolder',
customFolder: 'Custom folder',
useAutomatic: 'Use automatic',
resetDefaults: 'Reset Defaults',
resetToast: 'Reset category locations to default',
},
siteLogins: {
title: 'Site Credentials',
noSavedLogins: 'No saved logins.',
user: 'User: {{username}}',
deleteCredential: 'Delete credential',
deletedCredential: 'Deleted credential',
deleteFailed: 'Could not delete credential: {{detail}}',
addTitle: 'Add Site Credentials',
urlPattern: 'URL Pattern:',
urlPatternPlaceholder: 'e.g. *.example.com or example.com/downloads',
username: 'Username:',
password: 'Password:',
usernamePlaceholder: 'Username',
passwordPlaceholder: 'Password',
saving: 'Saving…',
addLogin: 'Add Login',
urlPatternRequired: 'URL pattern is required.',
urlPatternWhitespace: 'URL pattern cannot contain whitespace.',
usernameRequired: 'Username is required.',
passwordRequired: 'Password is required.',
accessBeforeSave: 'Grant credential-store access before saving a site login.',
saveFailed: 'Failed to save site credential securely.',
addedCredential: 'Added site credential',
},
power: {
title: 'Power Management',
preventSleep: 'Prevent system sleep while downloads are active',
preventSleepDescription: 'The display may still turn off. Firelink only keeps the device awake enough to complete active transfers.',
},
engine: {
title: 'Media Downloader & Engines',
description: 'Successful results are reused for this app session. Recheck runs real validation again.',
recheck: 'Recheck engines',
coreDownloader: 'Core Downloader (aria2)',
version: 'Version:',
status: 'Status:',
mediaExtractors: 'Media Extractors',
browserCookiesSource: 'Browser Cookies Source:',
cookieDescription: 'yt-dlp reads browser cookies to bypass video download limits or access restricted media. Firelink does not save browser cookies.',
none: 'None',
},
integrations: {
title: 'Connect Browser Extension',
description: 'Capture downloads directly from your browser in three easy steps.',
portablePairingEnabled: 'Portable Pairing Enabled',
pairingTokenPersisted: 'Pairing Token Persisted',
portablePersistedDescription: 'Your pairing token is stored with this portable Firelink folder and will persist when the folder is moved. Treat the folder as sensitive.',
systemPersistedDescription: 'Your pairing token is stored in the system credential store and will persist across restarts.',
portablePairingAvailable: 'Portable Pairing Available',
credentialStorageNeeded: 'Credential Storage Needed',
portableAvailableDescription: 'Your pairing token is stored with this portable Firelink folder and will persist across restarts. Enable it here to review the portable-storage warning.',
credentialStorageDescription: "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.",
reviewPortablePairing: 'Review Portable Pairing',
grantCredentialAccess: 'Grant Credential Access',
copyToken: 'Copy Token',
tokenDescription: 'This secure token authorizes your browser extension.',
tokenCopied: 'Token copied to clipboard!',
tokenCopyFailed: 'Could not copy token: {{detail}}',
pairingTokenRegenerated: 'Pairing token regenerated',
regenerateFailed: 'Could not regenerate pairing token: {{detail}}',
getExtension: 'Get Extension',
extensionDescription: 'Install Firelink Companion for Firefox or Chromium browsers.',
firefoxAddons: 'Firefox Add-ons',
chromiumZip: 'Chromium ZIP',
pasteConnect: 'Paste & Connect',
pasteConnectDescription: "Click the Firelink icon in your browser's toolbar and paste the copied token.",
extensionServerStatus: 'Extension Server Status:',
listening: '● Listening on 127.0.0.1:{{port}}',
unavailable: '● Server unavailable',
},
about: {
firelinkIcon: 'Firelink Icon',
unknown: 'Unknown',
version: 'Version {{version}}',
description: 'Cross-platform download manager for macOS, Windows, and Linux.',
sourceCode: 'Source Code',
source: 'Source',
upToDate: 'Firelink {{version}} is up to date.',
updateAvailable: 'Firelink {{version}} is available.',
viewRelease: 'View release',
updates: 'Updates',
checkForUpdates: 'Check for Updates',
updateDescription: 'Firelink checks GitHub Releases for new versions.',
checking: 'Checking...',
checkNow: 'Check Now',
automaticUpdates: 'Automatically check for updates',
createdBy: 'Created by NimBold',
mitLicense: 'MIT License',
credits: 'Credits: aria2, yt-dlp, FFmpeg, and Deno.',
copyright: 'Copyright © 2026 NimBold. All rights reserved.',
},
},
status: {
ready: 'Ready',
active: '{{count}} active',
queued: '{{count}} queued',
done: '{{count}} done',
},
} as const;
export default common;
+755
View File
@@ -0,0 +1,755 @@
const fa = {
navigation: {
library: 'کتابخانه',
filters: {
all: 'همه',
active: 'فعال',
completed: 'تکمیل‌شده',
unfinished: 'ناتمام',
},
categories: {
musics: 'موسیقی',
movies: 'فیلم‌ها',
compressed: 'فشرده',
documents: 'اسناد',
pictures: 'تصاویر',
applications: 'برنامه‌ها',
other: 'سایر',
},
folders: 'پوشه‌ها',
queues: 'صف‌ها',
tools: 'ابزارها',
scheduler: 'زمان‌بندی',
speedLimiter: 'محدودکنندهٔ سرعت',
logs: 'گزارش‌ها',
settings: 'تنظیمات',
},
actions: {
hideSidebar: 'پنهان کردن نوار کناری',
showSidebar: 'نمایش نوار کناری',
resizeSidebar: 'تغییر اندازه نوار کناری',
cancel: 'لغو',
continue: 'ادامه',
dismissNotification: 'بستن اعلان',
queueName: 'نام صف',
addNewQueue: 'افزودن صف جدید',
startQueue: 'شروع صف',
pauseQueue: 'توقف صف',
renameQueue: 'تغییر نام صف',
deleteQueue: 'حذف صف',
},
dialogs: {
errorBoundary: {
title: 'مشکلی پیش آمد.',
description: 'یک خطای بحرانی در درخت اجزای React رخ داده است. جزئیات خطای زیر می‌تواند به شناسایی علت اصلی کمک کند.',
reload: 'بارگذاری مجدد برنامه',
},
duplicateDownloads: {
title: 'شناسایی دانلودهای تکراری',
description: 'برخی از دانلودهایی که اضافه می‌کنید از قبل در صف یا روی دیسک وجود دارند. لطفاً نحوه حل این تداخل‌ها را انتخاب کنید.',
rename: 'تغییر نام',
replace: 'جایگزینی',
skip: 'رد شدن',
},
removeDownload: {
title: 'حذف دانلود',
confirmationSingle: 'آیا مطمئن هستید که می‌خواهید این مورد را از لیست حذف کنید؟ همچنین می‌توانید فایل اصلی را از هارد دیسک خود حذف کنید.',
confirmationMultiple: 'آیا مطمئن هستید که می‌خواهید این {{count}} مورد را از لیست حذف کنید؟ همچنین می‌توانید فایل‌های اصلی را از هارد دیسک خود حذف کنید.',
errorSummary: '{{succeeded}} مورد حذف شد، {{failed}} مورد ناموفق: {{detail}}',
remove: 'حذف',
deleteFile: 'حذف فایل',
},
},
window: {
controls: 'کنترل‌های پنجره',
close: 'بستن',
minimize: 'کمینه کردن',
maximize: 'بیشینه کردن',
},
downloads: {
actions: {
moveUp: 'انتقال به بالا',
moveDown: 'انتقال به پایین',
pause: 'توقف',
start: 'شروع',
resume: 'ادامه',
options: 'گزینه‌ها',
},
size: {
downloadedOf: '{{downloaded}} دانلودشده از {{total}} {{unit}}',
downloadedOfApproximate: '{{downloaded}} دانلودشده از حدود {{total}} {{unit}}',
},
status: {
ready: 'آماده',
staged: 'در صف',
queued: 'در صف',
downloading: 'در حال دانلود',
processing: 'در حال پردازش',
paused: 'متوقف‌شده',
completed: 'تکمیل‌شده',
failed: 'ناموفق',
retrying: 'در حال تلاش مجدد',
},
values: {
processing: 'در حال پردازش…',
muxing: 'در حال ترکیب…',
},
},
speedLimiter: {
title: 'محدودکنندهٔ سرعت',
unlimited: 'نامحدود',
saveLimit: 'ذخیره محدودیت سرعت',
globalSpeedLimit: 'محدودیت سرعت کلی',
description: 'در مورد انتقال‌های جدید و فعال aria2 و دانلودهای رسانه جدید yt-dlp اعمال می‌شود. محدودیت‌های خاص هر دانلود برای آن انتقال‌ها به قوت خود باقی می‌ماند.',
quickPresets: 'پیش‌تنظیم‌های سریع',
removePreset: 'حذف پیش‌تنظیم {{value}} {{unit}}',
customPresetIn: 'پیش‌تنظیم سفارشی بر حسب {{unit}}',
addQuickPreset: 'افزودن پیش‌تنظیم سریع',
globalLimitSaved: 'محدودیت کلی در {{value}} {{unit}} ذخیره شد',
globalLimitDisabled: 'محدودیت سرعت کلی غیرفعال شد',
saveFailed: 'ذخیره محدودیت سرعت کلی ناموفق بود: {{detail}}',
presetAlreadyExists: '{{value}} {{unit}} از قبل در پیش‌تنظیم‌های سریع وجود دارد',
presetAdded: 'پیش‌تنظیم سریع {{value}} {{unit}} اضافه شد',
presetRemoved: 'پیش‌تنظیم سریع {{value}} {{unit}} حذف شد',
},
logs: {
title: 'گزارش‌ها',
entries: '({{count}} ورودی)',
collecting: 'در حال جمع‌آوری',
off: 'خاموش',
allLevels: 'همه سطوح',
levels: {
error: 'خطا',
warn: 'هشدار',
info: 'اطلاعات',
debug: 'دیباگ',
trace: 'ردیابی',
},
pauseLogging: 'توقف گزارش‌گیری تشخیصی',
enableLogging: 'فعال‌سازی گزارش‌گیری تشخیصی',
clearDisplayed: 'پاک کردن گزارش‌های نمایش‌داده‌شده',
export: 'خروجی گزارش‌ها',
exportButton: 'خروجی گزارش‌ها',
logFiles: 'فایل‌های گزارش',
localDiagnostics: 'تشخیص‌های محلی:',
diagnosticsDescription: 'جمع‌آوری اطلاعات تشخیصی اختیاری، محدود و محلی است. اطلاعات حساسی مانند کلیدها، پارامترهای URL و مسیرهای پوشه شخصی هنگام نمایش و خروجی پنهان می‌شوند. هیچ داده‌ای به طور خودکار بارگذاری نمی‌شود.',
noEntries: 'هنوز هیچ گزارش ذخیره‌شده‌ای وجود ندارد.',
disabledDescription: 'گزارش‌گیری تشخیصی خاموش است. گزارش‌های پشتیبانی موجود در صورت در دسترس بودن در اینجا ظاهر می‌شوند.',
copy: 'کپی',
copied: 'در کلیپ‌بورد کپی شد',
copyFailed: 'کپی در کلیپ‌بورد ناموفق بود',
exported: 'گزارش‌های پشتیبانی صادر شدند',
exportFailed: 'صدور گزارش‌ها ناموفق بود: {{detail}}',
cleared: 'گزارش‌ها پاک شدند',
clearFailed: 'پاک کردن گزارش‌ها ناموفق بود: {{detail}}',
enabled: 'گزارش‌گیری تشخیصی فعال شد',
disabled: 'گزارش‌گیری تشخیصی غیرفعال شد',
updateFailed: 'به‌روزرسانی گزارش‌گیری تشخیصی ناموفق بود: {{detail}}',
},
scheduler: {
title: 'زمان‌بند',
running: 'در حال اجرا',
disabled: 'زمان‌بند غیرفعال است',
noScheduledDay: 'هیچ روز زمان‌بندی‌شده‌ای انتخاب نشده است',
unsavedChanges: 'تغییرات ذخیره‌نشده',
runNow: 'اجرای فوری',
pause: 'توقف',
saveSettings: 'ذخیره تنظیمات',
timing: 'زمان‌بندی',
startTime: 'زمان شروع',
stopTime: 'زمان پایان',
timingDescription: 'اگر Firelink در زمان شروع در حالت خواب باشد، صف‌های انتخاب‌شده را هنگام بیدار شدن در ادامه آن روز شروع می‌کند، مگر اینکه زمان پایان گذشته باشد.',
runEveryDay: 'اجرای هر روز',
queuesToSchedule: 'صف‌ها برای زمان‌بندی',
defaultQueue: 'صف پیش‌فرض',
afterCompletion: 'پس از تکمیل',
afterCompletionDescription: 'انتخاب کنید پس از پایان دانلودهای شروع‌شده توسط زمان‌بند، چه اتفاقی بیفتد.',
actionWarning: 'این اقدام می‌تواند سایر کارهای کامپیوتر را مختل کند. Firelink بلافاصله پس از اتمام صف زمان‌بندی‌شده، آن را اجرا می‌کند.',
systemPermissions: 'مجوزهای سیستم',
macPermissionDescription: 'خواب، راه‌اندازی مجدد و خاموش کردن به مجوز macOS Automation برای System Events نیاز دارد.',
permissionGranted: 'مجوز اتوماسیون اعطا شد',
permissionChecking: 'در حال بررسی مجوز اتوماسیون…',
permissionMissing: 'مجوز اتوماسیون موجود نیست',
revokePermission: 'لغو مجوز',
grantPermission: 'اعطای مجوز',
permissionAvailable: 'مجوز اتوماسیون در دسترس است.',
permissionMissingDetails: 'مجوز اتوماسیون موجود نیست. Firelink را در بخش System Settings > Privacy & Security > Automation برای System Events فعال کنید.',
revokePermissionDetails: 'سیستم‌عامل macOS اجازه نمی‌دهد Firelink مجوز اتوماسیون را مستقیماً لغو کند. دسترسی System Events را در System Settings لغو کنید و سپس به Firelink بازگردید.',
requestingPermission: 'در حال درخواست مجوز اتوماسیون…',
enablePermissionDetails: 'Firelink را در بخش System Settings > Privacy & Security > Automation برای System Events فعال کنید و سپس به Firelink بازگردید.',
systemActions: 'اقدامات سیستم',
windowsActionsDescription: 'خواب، راه‌اندازی مجدد و خاموش کردن از دسترسی‌های سیستم Windows استفاده می‌کنند. Firelink هنگام اجرا هر اقدام ردشده‌ای را گزارش می‌دهد؛ هیچ مجوز دائمی از قبل درخواست نمی‌شود.',
linuxActionsDescription: 'خواب، راه‌اندازی مجدد و خاموش کردن از دسکتاپ و سیاست‌های سیستم Linux استفاده می‌کنند. Firelink هنگام اجرا هر اقدام ردشده‌ای را گزارش می‌دهد؛ هیچ مجوز دائمی از قبل درخواست نمی‌شود.',
validationDay: 'حداقل یک روز برای زمان‌بند انتخاب کنید',
validationQueue: 'حداقل یک صف برای زمان‌بند انتخاب کنید',
validationStopTime: 'زمان پایان باید بعد از زمان شروع باشد',
saved: 'تنظیمات زمان‌بند ذخیره شد',
trackingOne: 'در حال پیگیری ۱ دانلود زمان‌بندی‌شده',
trackingMany: 'در حال پیگیری {{count}} دانلود زمان‌بندی‌شده',
noStartableDownloads: 'هیچ دانلودی در صف‌های انتخاب‌شده قابل شروع نیست',
pausedOne: '۱ دانلود فعال متوقف شد',
pausedMany: '{{count}} دانلود فعال متوقف شد',
noActiveDownloads: 'هیچ دانلود فعالی وجود ندارد',
days: {
su: 'ی',
mo: 'د',
tu: 'س',
we: 'چ',
th: 'پ',
fr: 'ج',
sa: 'ش',
},
postActions: {
none: 'هیچ کاری انجام نده',
sleep: 'خواب',
restart: 'راه‌اندازی مجدد',
shutdown: 'خاموش کردن',
},
},
properties: {
progress: 'پیشرفت',
size: 'اندازه',
speed: 'سرعت',
eta: 'زمان باقیمانده',
connections: 'اتصالات',
connectionCount: '{{active}}/{{total}} فعال',
connectionCountUnknown: '—/{{total}} فعال',
connectionsUnavailable: '—',
speedCap: 'سقف سرعت',
category: 'دسته',
lastTry: 'آخرین تلاش',
dateAdded: 'تاریخ افزودن',
destination: 'مقصد',
lastError: 'آخرین خطا',
saved: ' (ذخیره‌شده)',
defaultValue: ' (پیش‌فرض)',
savedTooltip: 'برای این دانلود ذخیره‌شده است؛ تغییرات تنظیمات روی دانلودهای جدید اعمال می‌شود.',
defaultTooltip: 'استفاده از پیش‌فرض کنونی برای دانلودهای جدید.',
identityReadOnly: 'هویت فایل فقط‌خواندنی است. تنظیمات انتقال برای دانلود مجدد ذخیره می‌شوند.',
transferSettings: 'تنظیمات انتقال را می‌توان پس از توقف تغییر داد. انتقال‌های کنونی گزینه‌های فعلی خود را حفظ می‌کنند.',
download: 'دانلود',
url: 'URL',
fileName: 'نام فایل',
saveLocation: 'محل ذخیره',
select: 'انتخاب',
perFile: 'برای هر فایل',
useCurrentDefault: 'استفاده از پیش‌فرض کنونی ({{count}})',
savedPerDownload: 'برای هر دانلود ذخیره‌شده است. پیش‌فرض تنظیمات روی دانلودهای جدید اعمال می‌شود.',
limit: 'محدودیت',
siteLogin: 'ورود به سایت',
siteLoginRedownload: 'ورود به سایت برای دانلود مجدد',
matchingSiteLogin: 'اطلاعات ورود منطبق با سایت',
customCredentials: 'اعتبارنامه‌های سفارشی',
noLogin: 'بدون ورود',
useSavedLogin: 'در صورت وجود از اطلاعات ورود ذخیره‌شده استفاده خواهد شد.',
advancedTransfer: 'انتقال پیشرفته',
advancedTransferRedownload: 'انتقال پیشرفته برای دانلود مجدد',
checksum: 'چک‌سام',
verify: 'تأیید',
algorithm: 'الگوریتم',
digest: 'هش',
expectedDigest: 'هش مورد انتظار',
cookies: 'کوکی‌ها',
headers: 'هدرها',
mirrors: 'آینه‌ها',
username: 'نام کاربری',
password: 'رمز عبور',
enterValidUrl: 'یک URL معتبر وارد کنید.',
fileNameEmpty: 'نام فایل نمی‌تواند خالی باشد.',
cancel: 'لغو',
save: 'ذخیره',
},
keychain: {
title: 'نیاز به دسترسی ذخیره‌سازی اعتبارنامه',
stores: {
portable: 'پوشه داده‌های قابل‌حمل Firelink',
windows: 'مدیر اعتبارنامه Windows',
linux: 'مخزن اعتبارنامه‌های Linux شما',
macos: 'Keychain در macOS',
system: 'مخزن اعتبارنامه‌های این سیستم',
siteCredentials: 'مخزن اعتبارنامه‌های سیستم',
},
grantLabelPortable: 'ادامه',
grantLabelMacos: 'اعطای دسترسی',
grantLabelDefault: 'فعال‌سازی ذخیره‌سازی امن',
description: 'Firelink از افزونه مرورگر برای ثبت دانلودها استفاده می‌کند. برای جفت ماندن افزونه پس از راه‌اندازی مجدد، Firelink توکن جفت‌سازی خود را در {{pairingStore}} ذخیره می‌کند. اعتبارنامه‌های اختیاری سایت در {{siteCredentialStore}} ذخیره می‌شوند.',
portableExplanation: 'توکن جفت‌سازی همراه با این پوشه قابل حمل است. اعتبارنامه‌های سایت در مخزن اعتبارنامه‌های سیستم باقی می‌مانند؛ ممکن است پس از اعطای دسترسی، یک پیغام سیستم ظاهر شود.',
macosExplanation: 'macOS ممکن است پس از اعطای دسترسی، یک پیغام Keychain را نشان دهد.',
defaultExplanation: 'این کار معمولاً بدون پیغام انجام می‌شود. اگر سرویس اعتبارنامه در دسترس نباشد، Firelink خطا را در اینجا نشان می‌دهد و افزونه فقط برای این جلسه جفت باقی می‌ماند.',
note: 'توجه:',
portableNote: 'حالت قابل‌حمل فقط توکن جفت‌سازی را در این پوشه ذخیره می‌کند. این حالت رمزهای عبور سایت یا اعتبارنامه‌های مرورگر را کپی نمی‌کند.',
defaultNote: 'Firelink فقط ورودی اعتبارنامه اختصاصی خود را می‌نویسد و به سایر رمزهای عبور ذخیره‌شده در سیستم شما دسترسی ندارد.',
hint: 'راهنمایی:',
portableHint: 'توکن جفت‌سازی قابل‌حمل از قبل در این پوشه ذخیره‌شده است؛ می‌توانید آن را در اینجا فعال کنید یا بعداً را انتخاب کنید.',
defaultHint: 'اگر بعداً را انتخاب کنید، افزونه فقط برای این جلسه کار خواهد کرد.',
enableFromSettings: 'در هر زمان می‌توانید ذخیره‌سازی را از تنظیمات > یکپارچه‌سازی فعال کنید.',
later: 'بعداً',
enabling: 'در حال فعال‌سازی…',
timeout: 'مهلت درخواست ذخیره‌سازی اعتبارنامه به پایان رسید. می‌توانید بعداً را انتخاب کرده و دوباره امتحان کنید.',
unavailable: '{{store}} در دسترس نیست.',
accessRequired: 'پیش از ایجاد مجدد توکن جفت‌سازی، دسترسی به مخزن اعتبارنامه‌ها را اعطا کنید.',
storeUnavailable: 'دسترسی به مخزن اعتبارنامه در دسترس نیست.',
},
app: {
loading: 'در حال بارگذاری…',
settingsSaveFailed: 'تنظیمات ذخیره نشدند. دسترسی‌های ذخیره‌سازی را بررسی کرده و دوباره امتحان کنید.',
systemActionCountdown: '{{action}} در ۱۰ ثانیه.',
systemActionCancelled: 'اقدام سیستم لغو شد زیرا دانلود دیگری فعال یا در صف است.',
systemActionFailed: 'اقدام سیستم زمان‌بندی‌شده ناموفق بود: {{detail}}',
downloadCompleteTitle: 'دانلود تکمیل‌شده',
downloadCompleteBody: 'دانلود {{fileName}} به پایان رسید.',
unknownFile: 'یک فایل',
downloadFailedTitle: 'دانلود ناموفق',
downloadFailedBody: 'دانلود {{fileName}} با شکست مواجه شد.',
initializeDownloadsFailed: 'راه‌اندازی دانلودهای ذخیره‌شده ناموفق بود: {{detail}}',
extensionDisconnected: 'اتصال افزونه مرورگر قطع شد زیرا توکن جفت‌سازی آن تغییر کرده است.',
copyToken: 'کپی توکن',
integrations: 'یکپارچه‌سازی',
copyTokenFailed: 'کپی توکن جفت‌سازی ناموفق بود: {{detail}}',
credentialPersistenceFailed: 'ذخیره‌سازی امن اعتبارنامه در دسترس نیست. جفت‌سازی مرورگر فقط برای این جلسه کار می‌کند: {{detail}}',
resumeDownloadsFailed: 'ادامه دانلودهای ذخیره‌شده ناموفق بود: {{detail}}',
updateAvailable: 'نسخه جدید Firelink {{version}} در دسترس است.',
viewRelease: 'مشاهده تغییرات نسخه',
sleepPreventionFailed: 'جلوگیری از خواب سیستم به‌روزرسانی نشد: {{detail}}',
schedulerNoQueues: 'زمان‌بند هیچ صف معتبری انتخاب نکرده است. تنظیمات زمان‌بندی را به‌روزرسانی کنید.',
schedulerPauseOneFailed: 'زمان‌بند نتوانست ۱ دانلود را متوقف کند.',
schedulerPauseManyFailed: 'زمان‌بند نتوانست {{count}} دانلود را متوقف کند.',
scheduledIncomplete: 'همه دانلودهای زمان‌بندی‌شده تکمیل نشدند. اقدام سیستم پس از اتمام صف رد شد.',
scheduledActionSkippedActive: 'اقدام سیستم زمان‌بندی‌شده رد شد زیرا دانلود دیگری فعال یا در صف است.',
notificationsDisabled: 'اعلان‌های سیستم برای Firelink غیرفعال‌اند.',
notificationsFailed: 'پیکربندی اعلان‌ها ناموفق بود: {{detail}}',
},
sidebar: {
deleteQueueConfirm: 'حذف صف "{{name}}"؟ دانلودهای ناتمام آن به صف اصلی منتقل خواهند شد.',
deleteQueueFailed: 'حذف صف ناموفق بود: {{detail}}',
queueNameEmpty: 'نام صف نمی‌تواند خالی باشد',
queueNameExists: 'یک صف با این نام از قبل وجود دارد',
startQueueFailed: 'شروع صف ناموفق بود: {{detail}}',
pauseQueueFailed: 'توقف صف ناموفق بود: {{detail}}',
},
downloadTable: {
unknownQueue: 'صف نامشخص',
allDownloads: 'همه دانلودها',
active: 'فعال',
completed: 'تکمیل‌شده',
unfinished: 'ناتمام',
headers: {
fileName: 'نام فایل',
size: 'اندازه',
status: 'وضعیت',
speed: 'سرعت',
eta: 'زمان باقیمانده',
dateAdded: 'تاریخ افزودن',
},
columnOptions: {
open: 'گزینه‌های ستون {{column}}',
dragToReorder: 'برای جابه‌جایی ستون‌ها بکشید',
alignContent: 'تراز محتوا',
left: 'چپ',
center: 'وسط',
right: 'راست',
resetLayout: 'بازنشانی چیدمان ستون‌ها',
},
addDownload: 'افزودن دانلود',
resumeAll: 'ادامه همه',
pauseAll: 'توقف همه',
queueEmpty: 'صف خالی است',
noCompletedDownloads: 'هیچ دانلود تکمیل‌شده‌ای وجود ندارد',
noDownloads: 'هیچ دانلودی وجود ندارد',
queueEmptyDescription: 'دانلودها را از منوی آیتم‌ها یا پنجره افزودن به این صف اضافه کنید.',
completedDescription: 'دانلودهای تکمیل‌شده در اینجا ظاهر می‌شوند.',
clickToAdd: 'برای افزودن دانلودها، روی',
addButtonOr: 'یا',
toAddDownloads: 'کلیک کنید',
nonResumableOne: '۱ دانلود از ادامه‌دادن پشتیبانی نمی‌کند. اگر آن را متوقف کنید، بعداً باید از ابتدا شروع کنید. آیا از توقف آن مطمئن هستید؟',
nonResumableMany: '{{count}} دانلود از ادامه‌دادن پشتیبانی نمی‌کنند. اگر آن‌ها را متوقف کنید، بعداً باید از ابتدا شروع کنید. آیا از توقف آن‌ها مطمئن هستید؟',
interactionError: '{{message}}: {{detail}}',
openFileFailed: 'باز کردن فایل دانلودشده ناموفق بود',
revealFileFailed: 'نمایش فایل دانلودشده در پوشه ناموفق بود',
pauseFailed: 'توقف دانلود ناموفق بود',
resumeFailed: 'ادامه {{fileName}} ناموفق بود',
moveManyFailed: 'انتقال دانلودها به صف ناموفق بود',
moveOneFailed: 'انتقال دانلود به صف ناموفق بود',
copyAddressesFailed: 'آدرس‌ها کپی نشدند',
copyAddressFailed: 'آدرس کپی نشد',
copyPathFailed: 'مسیر فایل کپی نشد',
missingFileName: 'نام فایل وجود ندارد',
redownloadFailed: 'دانلود مجدد ناموفق بود',
startResume: 'شروع/ادامه',
addToQueue: 'افزودن به صف',
copyAddress: 'کپی آدرس',
remove: 'حذف',
open: 'باز کردن',
showInFolder: 'نمایش در پوشه',
pause: 'توقف',
start: 'شروع',
resume: 'ادامه',
redownload: 'دانلود مجدد',
copyFilePath: 'کپی مسیر فایل',
properties: 'ویژگی‌ها',
backendRejectedStart: 'درخواست شروع/ادامه توسط موتور دانلود رد شد.',
transferActive: 'نمی‌توانید ویژگی‌ها را هنگام فعال بودن دانلود تغییر دهید. ابتدا آن را متوقف کنید.',
redownloadNotFound: 'دانلود مجدد ناموفق: دانلود پیدا نشد.',
redownloadActive: 'نمی‌توانید یک دانلود با وضعیت {{status}} را دوباره دانلود کنید. ابتدا آن را متوقف کنید یا منتظر بمانید تا تمام شود.',
originalUrlMissing: 'دانلود مجدد ناموفق: URL اصلی وجود ندارد.',
pauseBeforeMove: 'قبل از انتقال {{file}} به صف دیگر، آن را متوقف کنید.',
},
addDownloads: {
unknownSize: 'اندازه نامشخص',
unknown: 'نامشخص',
discardSetup: 'صرف‌نظر از این پیکربندی دانلود؟',
speedInvalid: 'محدودیت سرعت باید بیشتر از صفر باشد',
duplicateResolveFailed: 'دانلودهای تکراری برطرف نشدند: {{detail}}',
addedWithFailures: '{{added}} اضافه شد، {{failed}} ناموفق بود. {{detail}}',
addedOne: '۱ دانلود اضافه شد',
addedMany: '{{count}} دانلود اضافه شد',
updatedOne: '۱ دانلود به‌روزرسانی شد',
updatedMany: '{{count}} دانلود به‌روزرسانی شد',
urlAlreadyQueued: 'URL در حال حاضر در صف است',
destinationConflict: 'یک دانلود انتخاب‌شده دیگر از این مقصد استفاده می‌کند',
existingDownloadDestination: 'دانلود Firelink موجود از این مقصد استفاده می‌کند',
matchingDownloadFilename: 'یک دانلود ناتمام از قبل همین نام فایل را دارد',
fileExistsOnDisk: 'فایل روی دیسک وجود دارد؛ برای جلوگیری از حذف داده‌های نامرتبط، نام آن را تغییر دهید یا رد شوید',
backendRejectedStart: 'شروع دانلود توسط موتور دانلود رد شد.',
playlistNoEntries: 'لیست پخش شامل هیچ ورودی قابل دانلودی نیست',
invalidMediaMetadata: 'متادیتای رسانه نامعتبر است یا هیچ فرمتی یافت نشد',
noAvailableName: 'یک نام در دسترس برای {{file}} پیدا نشد.',
pauseBeforeReplace: 'قبل از جایگزینی {{file}}، آن را متوقف کنید.',
cannotReplace: 'نمی‌توان {{file}} را جایگزین کرد: فایل متعلق به یک دانلود Firelink نیست.',
downloadLinks: 'پیوندهای دانلود',
pastePlaceholder: '\u2066URL\u2069های \u2066HTTP\u2069، \u2066HTTPS\u2069، \u2066FTP\u2069 یا \u2066SFTP\u2069 را در اینجا جای‌گذاری کنید…\n\nبرای دانلود رسانه، پیوندهایی از \u2066YouTube\u2069، \u2066X\u2069، \u2066TikTok\u2069، \u2066Instagram\u2069، \u2066Reddit\u2069 و غیره جای‌گذاری کنید.',
playlistSummary: 'لیست پخش "{{title}}": {{loaded}} از {{total}} ورودی بارگیری شد{{truncated}}{{skipped}}',
safeEntryLimit: ' (به حد مجاز ایمن ورودی‌ها رسیدیم)',
selectedSummary: '{{ready}} انتخاب‌شده آماده، {{fallback}} اطلاعات جایگزین، {{mediaRetry}} تلاش مجدد رسانه، {{blocked}} مسدودشده',
clearSelection: 'پاک کردن انتخاب',
selectAll: 'انتخاب همه',
refreshMetadata: 'تازه‌سازی متادیتا',
files: 'فایل‌ها',
required: 'الزامی',
free: 'فضای آزاد',
preview: 'پیش‌نمایش',
file: 'فایل',
size: 'اندازه',
status: 'وضعیت',
downloadPreview: 'پیش‌نمایش دانلود',
noLinks: 'هنوز هیچ پیوندی اضافه نشده است.',
selectItem: 'انتخاب {{file}}',
fetchingPlaylist: 'در حال دریافت لیست پخش…',
fetching: 'در حال دریافت…',
unsafeUrl: 'URL ناامن',
playlistFailed: 'دریافت لیست پخش ناموفق بود',
metadataFailed: 'دریافت متادیتا ناموفق بود',
fallback: 'گزینه جایگزین',
invalid: 'نامعتبر',
ready: 'آماده',
mediaFormat: 'فرمت رسانه',
playlistItem: 'مورد لیست پخش {{index}}',
fetchingMediaStreams: 'در حال دریافت جریان‌های رسانه…',
availableStreams: 'جریان‌های موجود',
availableMediaStreams: 'جریان‌های رسانه موجود',
metadataUnavailable: 'متادیتا در دسترس نیست. پیش از افزودن این رسانه، متادیتا را تازه‌سازی کنید.',
saveLocation: 'محل ذخیره',
browse: 'انتخاب…',
categoryFolders: 'فایل‌ها در پوشه‌های دسته‌بندی سازمان‌دهی خواهند شد.',
sharedFolder: 'همه دانلودهای انتخاب‌شده از این پوشه استفاده خواهند کرد.',
dedicatedFolder: 'ذخیره در پوشه جدید',
dedicatedFolderName: 'نام پوشه',
dedicatedFolderDescription: 'این پوشه را داخل هر پوشه دسته‌بندی خودکار ایجاد می‌کند.',
dedicatedFolderManualDescription: 'این پوشه را داخل محل ذخیره انتخاب‌شده ایجاد می‌کند.',
saveFolderName: 'ذخیره نام پوشه',
dedicatedFolderNameRequired: 'پیش از ذخیره، نام پوشه را وارد کنید.',
transferSettings: 'تنظیمات انتقال',
connectionsPerFile: 'اتصالات در هر فایل',
connectionsPerFileAria: 'اتصالات در هر فایل',
limitSpeedPerFile: 'محدودیت سرعت در هر فایل',
speedLimitPerFileAria: 'محدودیت سرعت در هر فایل',
authorization: 'احراز هویت',
useAuthorization: 'استفاده از احراز هویت',
username: 'نام کاربری',
password: 'رمز عبور',
advancedTransfer: 'انتقال پیشرفته',
verifyChecksum: 'تأیید چک‌سام',
checksumAlgorithm: 'الگوریتم چک‌سام',
expectedDigest: 'هش مورد انتظار',
headers: 'هدرها',
requestHeaders: 'هدرهای درخواست',
cookies: 'کوکی‌ها',
cookiePlaceholder: 'name=value; other=value',
mirrors: 'آینه‌ها',
cancel: 'لغو',
startDownloads: 'شروع دانلودها',
addToQueue: 'افزودن به صف',
pasteOneOrMore: 'یک یا چند پیوند را جای‌گذاری کنید.',
selectAtLeastOne: 'حداقل یک دانلود را انتخاب کنید.',
correctInvalidOne: 'قبل از ادامه، {{count}} URL نامعتبر را اصلاح یا حذف کنید.',
correctInvalidFew: 'قبل از ادامه، {{count}} URL نامعتبر را اصلاح یا حذف کنید.',
correctInvalidMany: 'قبل از ادامه، {{count}} URL نامعتبر را اصلاح یا حذف کنید.',
waitingForMetadataOne: 'در انتظار متادیتای {{count}} دانلود…',
waitingForMetadataFew: 'در انتظار متادیتای {{count}} دانلود…',
waitingForMetadataMany: 'در انتظار متادیتای {{count}} دانلود…',
removeUnsafeOne: 'قبل از ادامه، {{count}} URL ناامن را حذف کنید.',
removeUnsafeFew: 'قبل از ادامه، {{count}} URL ناامن را حذف کنید.',
removeUnsafeMany: 'قبل از ادامه، {{count}} URL ناامن را حذف کنید.',
mediaMetadataUnavailableSummaryOne: 'متادیتای رسانه برای {{count}} مورد در دسترس نیست. پیش از افزودن، متادیتا را تازه‌سازی کنید.',
mediaMetadataUnavailableSummaryFew: 'متادیتای رسانه برای {{count}} مورد در دسترس نیست. پیش از افزودن، متادیتا را تازه‌سازی کنید.',
mediaMetadataUnavailableSummaryMany: 'متادیتای رسانه برای {{count}} مورد در دسترس نیست. پیش از افزودن، متادیتا را تازه‌سازی کنید.',
metadataUnavailableFallback: 'متادیتا در دسترس نیست. دانلودها همچنان می‌توانند با اطلاعات جایگزین اضافه شوند.',
fallbackReadyOne: '{{ready}} دانلود آماده؛ برای {{failed}} از نام فایل جایگزین و اندازه نامشخص استفاده خواهد شد.',
fallbackReadyFew: '{{ready}} دانلود آماده؛ برای {{failed}} از نام فایل جایگزین و اندازه نامشخص استفاده خواهد شد.',
fallbackReadyMany: '{{ready}} دانلود آماده؛ برای {{failed}} از نام فایل جایگزین و اندازه نامشخص استفاده خواهد شد.',
readyToAddOne: 'آماده برای افزودن {{count}} دانلود.',
readyToAddFew: 'آماده برای افزودن {{count}} دانلود.',
readyToAddMany: 'آماده برای افزودن {{count}} دانلود.',
},
settings: {
tabs: {
downloads: 'دانلودها',
lookAndFeel: 'ظاهر',
network: 'شبکه',
locations: 'مکان‌ها',
siteLogins: 'ورود به سایت‌ها',
power: 'انرژی',
engine: 'موتور',
integrations: 'یکپارچه‌سازی‌ها',
about: 'درباره',
},
common: {
thisOs: 'این سیستم‌عامل',
checking: 'در حال بررسی…',
ready: 'آماده',
errorMissing: 'خطا / یافت‌نشده',
hideTechnicalDetails: 'پنهان کردن جزئیات فنی',
showTechnicalDetails: 'نمایش جزئیات فنی',
binary: 'فایل باینری',
expected: 'مقدار مورد انتظار',
error: 'خطا',
tip: 'نکته',
stderr: 'stderr',
daemonProcessAlive: 'فرآیند پس‌زمینه (Daemon) فعال است',
rpcReady: 'سرویس RPC آماده است',
rpcPort: 'پورت RPC',
daemonStderr: 'خروجی stderr سرویس پس‌زمینه',
packaging: 'بسته‌بندی: PyInstaller onedir (نیاز به پوشه _internal)',
internalDirectoryFound: 'پوشه _internal پیداشده',
pythonRuntimeFound: 'محیط اجرای Python پیداشده',
externalOpenFailed: 'باز کردن {{label}} ناموفق بود: {{detail}}',
updateUnexpectedResponse: 'بررسی به‌روزرسانی با یک پاسخ غیرمنتظره مواجه شد.',
updateUntrustedReleaseUrl: 'بررسی به‌روزرسانی با یک URL انتشار نامعتبر مواجه شد.',
updateFailed: 'بررسی به‌روزرسانی ناموفق بود: {{detail}}',
},
downloads: {
defaultConnections: 'اتصالات پیش‌فرض:',
defaultConnectionsDescription: 'دانلودهای جدید؛ موارد موجود مقادیر ذخیره‌شده خود را حفظ می‌کنند',
parallelDownloads: 'دانلودهای موازی:',
parallelDownloadsDescription: 'حداکثر فایل‌های فعال همزمان',
automaticRetries: 'تلاش‌های مجدد خودکار:',
automaticRetriesDescription: 'اگر اتصالی ناموفق باشد',
systemNotification: 'نمایش اعلان سیستم هنگام تکمیل دانلود',
systemNotificationDescription: 'از تنظیمات اعلان سیستم‌عامل شما استفاده می‌کند',
completionChime: 'پخش صدای تکمیل درون برنامه‌ای',
completionChimeDescription: 'صدای اختیاری Firelink، مستقل از اعلان‌های سیستم',
clipboardLinks: 'افزودن پیوندهای کلیپ‌بورد وقتی Firelink فعال می‌شود',
clipboardLinksDescription: 'پیوندهای کپی‌شده پشتیبانی‌شده را در پنجره افزودن باز می‌کند. به‌طور پیش‌فرض خاموش است.',
},
lookAndFeel: {
language: 'زبان',
languageDescription: 'زبان رابط Firelink را انتخاب کنید.',
sidebarPosition: 'جایگاه نوار کناری',
sidebarPositionDescription: 'مشخص کنید نوار کناری کجا نمایش داده شود. حالت خودکار از جهت رابط کاربری پیروی می‌کند.',
sidebarPositionAutomatic: 'خودکار (پیشنهادشده)',
sidebarPositionLeft: 'چپ',
sidebarPositionRight: 'راست',
languageSystem: 'زبان سیستم',
languageEnglish: 'انگلیسی',
languageChinese: 'چینی ساده‌شده',
languageHebrew: 'عبری',
languagePersian: 'فارسی',
languageUkrainian: 'اوکراینی',
languageRussian: 'روسی',
appTheme: 'ظاهر برنامه',
theme: 'ظاهر',
ariaLabel: 'ظاهر برنامه',
system: 'سیستم',
light: 'روشن',
dark: 'تاریک',
dracula: 'Dracula',
nord: 'Nord',
systemAppearance: 'برنامه از حالت روشن یا تاریک فعلی {{platform}} پیروی می‌کند.',
display: 'نمایش',
fontSize: 'اندازه قلم',
fontSizeDescription: 'اندازه فهرست دانلود و کنترل‌های فشرده را تغییر می‌دهد.',
small: 'کوچک',
standard: 'استاندارد',
large: 'بزرگ',
listDensity: 'تراکم فهرست',
listDensityDescription: 'ارتفاع سطر، فاصله‌گذاری و مقیاس نوار پیشرفت را تغییر می‌دهد.',
compact: 'فشرده',
comfortable: 'معمولی',
relaxed: 'بسیار راحت',
osIntegration: 'یکپارچه‌سازی سیستم‌عامل',
dockBadge: 'نمایش نشان روی آیکون Dock',
dockBadgeDescription: 'تعداد دانلودهای فعال را روی آیکون Firelink در Dock نشان می‌دهد.',
menuBarIcon: 'نمایش آیکون نوار منو',
statusIndicatorIcon: 'نمایش آیکون نشانگر وضعیت',
systemTrayIcon: 'نمایش آیکون سینی سیستم',
macosTrayDescription: 'دسترسی سریع از نوار منوی macOS فراهم می‌کند.',
windowsTrayDescription: 'دسترسی سریع از ناحیه اعلان‌های Windows فراهم می‌کند.',
linuxTrayDescription: 'در صورت وجود، دسترسی سریع از سینی دسکتاپ یا ناحیه وضعیت فراهم می‌کند.',
defaultTrayDescription: 'در صورت وجود، دسترسی سریع از ناحیه سینی سیستم‌عامل فراهم می‌کند.',
},
network: {
proxy: 'پروکسی',
mode: 'حالت',
modeDescription: 'استفاده از پروکسی را برای درخواست‌های دانلود جدید کنترل می‌کند.',
noProxy: 'بدون پروکسی',
systemProxy: 'پروکسی سیستم',
customProxy: 'پروکسی سفارشی',
proxyHost: 'میزبان پروکسی',
proxyHostDescription: 'نام میزبان، آدرس IP یا URL پروکسی HTTP.',
proxyHostPlaceholder: '127.0.0.1 یا http://127.0.0.1',
proxyPort: 'پورت پروکسی',
proxyPortDescription: 'محدوده معتبر بین ۱ تا ۶۵۵۳۵ است.',
noProxyDescription: 'دانلودها پروکسی‌های پیکربندی‌شده را نادیده می‌گیرند.',
systemProxyDescription: 'دانلودها از پروکسی سیستم شناسایی‌شده {{platform}} استفاده می‌کنند. دانلودهای فایل عادی به یک نقطه پایانی پروکسی HTTP یا HTTPS نیاز دارند؛ دانلودهای رسانه می‌توانند از SOCKS استفاده کنند.',
customProxyDescription: 'دانلودها از نقطه پایانی پروکسی HTTP پیکربندی‌شده برای متادیتا و موتورهای دانلود استفاده می‌کنند.',
invalidCustomProxy: 'برای فعال کردن پروکسی سفارشی یک میزبان و پورت پروکسی HTTP معتبر وارد کنید.',
incompleteCustomProxy: 'برای فعال کردن پروکسی سفارشی یک میزبان و پورت پروکسی وارد کنید.',
checkingSystemProxy: 'در حال بررسی پیکربندی پروکسی سیستم…',
detectedSystemProxy: 'یک پروکسی سیستم شناسایی شد. دانلودهای فایل عادی به یک نقطه پایانی HTTP یا HTTPS نیاز دارند؛ دانلودهای رسانه می‌توانند از SOCKS استفاده کنند.',
noSystemProxy: 'هیچ پروکسی سیستم قابل استفاده‌ای شناسایی نشد. دانلودها از هیچ پروکسی‌ای استفاده نخواهند کرد.',
systemProxyReadFailed: 'پیکربندی پروکسی سیستم قابل خواندن نیست. بدون پروکسی را انتخاب کنید یا هنگامی که در دسترس است دوباره امتحان کنید.',
identity: 'هویت',
customUserAgent: 'User-Agent سفارشی',
userAgentDescription: 'در دریافت‌های متادیتا و موتورهای دانلود اعمال می‌شود.',
userAgentPlaceholder: 'برای پیش‌فرض Firelink خالی بگذارید',
userAgentOverrides: 'هدر User-Agent خروجی را بازنویسی می‌کند. برای پیش‌فرض‌های Firelink خالی بگذارید.',
chromeWindows: 'Chrome (Windows)',
chromeMacos: 'Chrome (macOS)',
edgeWindows: 'Edge (Windows)',
firefoxWindows: 'Firefox (Windows)',
firefoxMacos: 'Firefox (macOS)',
safariMacos: 'Safari (macOS)',
windowsDesktop: 'دسکتاپ Windows',
macosDesktop: 'دسکتاپ macOS',
},
locations: {
baseDownloadFolder: 'پوشه اصلی دانلود',
baseDownloadFolderDescription: 'پوشه‌های دسته خودکار در داخل این پوشه ایجاد می‌شوند.',
browse: 'انتخاب…',
baseFolderCreateFailed: 'پوشه اصلی ذخیره شد، اما ایجاد پوشه‌های دسته ناموفق بود: {{detail}}',
baseFolderUpdated: 'پوشه اصلی دانلود به‌روزرسانی شد',
askWhereToSave: 'هنگام افزودن دانلود بپرس کجا ذخیره شود',
rememberLastUsedDownloadDirectory: 'پوشه دانلود اخیراً استفاده‌شده را به خاطر بسپار',
rememberLastUsedDownloadDirectoryDescription: 'از آخرین پوشه انتخاب‌شده در پنجره افزودن برای دانلود بعدی تا زمان راه‌اندازی مجدد برنامه استفاده کنید.',
automaticCategorySubfolders: 'فایل‌ها را به‌طور خودکار در زیرپوشه‌های دسته ذخیره کن',
automaticCategorySubfoldersDescription: 'وقتی خاموش است، دانلودهای خودکار از پوشه پایه استفاده می‌کنند.',
categorySubfolders: 'زیرپوشه‌های دسته',
relativeToBase: 'نسبت به پوشه پایه',
disabled: 'غیرفعال',
categorySubfolder: 'زیرپوشه {{category}}',
customFolder: 'پوشه سفارشی',
useAutomatic: 'استفاده از حالت خودکار',
resetDefaults: 'بازنشانی به پیش‌فرض‌ها',
resetToast: 'مکان‌های دسته‌ها به پیش‌فرض بازنشانی شد',
},
siteLogins: {
title: 'اعتبارنامه‌های سایت',
noSavedLogins: 'هیچ ورودی ذخیره‌شده‌ای وجود ندارد.',
user: 'کاربر: {{username}}',
deleteCredential: 'حذف اعتبارنامه',
deletedCredential: 'اعتبارنامه حذف شد',
deleteFailed: 'اعتبارنامه حذف نشد: {{detail}}',
addTitle: 'افزودن اعتبارنامه‌های سایت',
urlPattern: 'الگوی URL:',
urlPatternPlaceholder: 'مثلاً *.example.com یا example.com/downloads',
username: 'نام کاربری:',
password: 'رمز عبور:',
usernamePlaceholder: 'نام کاربری',
passwordPlaceholder: 'رمز عبور',
saving: 'در حال ذخیره…',
addLogin: 'افزودن ورود',
urlPatternRequired: 'الگوی URL الزامی است.',
urlPatternWhitespace: 'الگوی URL نمی‌تواند شامل فاصله باشد.',
usernameRequired: 'نام کاربری الزامی است.',
passwordRequired: 'رمز عبور الزامی است.',
accessBeforeSave: 'قبل از ذخیره ورود به سایت، به مخزن اعتبارنامه دسترسی بدهید.',
saveFailed: 'ذخیره ایمن اعتبارنامه سایت ناموفق بود.',
addedCredential: 'اعتبارنامه سایت اضافه شد',
},
power: {
title: 'مدیریت انرژی',
preventSleep: 'جلوگیری از خواب سیستم در حالی که دانلودها فعال هستند',
preventSleepDescription: 'نمایشگر همچنان می‌تواند خاموش شود. Firelink تنها به اندازه‌ای دستگاه را بیدار نگه می‌دارد تا انتقال‌های فعال را تکمیل کند.',
},
engine: {
title: 'دانلودکننده رسانه و موتورها',
description: 'نتایج موفقیت‌آمیز برای این جلسه برنامه استفاده مجدد می‌شوند. بررسی مجدد باعث اعتبارسنجی واقعی دوباره می‌شود.',
recheck: 'بررسی مجدد موتورها',
coreDownloader: 'دانلودکننده اصلی (aria2)',
version: 'نسخه:',
status: 'وضعیت:',
mediaExtractors: 'استخراج‌کننده‌های رسانه',
browserCookiesSource: 'منبع کوکی‌های مرورگر:',
cookieDescription: 'yt-dlp کوکی‌های مرورگر را می‌خواند تا محدودیت‌های دانلود ویدیو را دور بزند یا به رسانه‌های محدود دسترسی پیدا کند. Firelink کوکی‌های مرورگر را ذخیره نمی‌کند.',
none: 'هیچ‌کدام',
},
integrations: {
title: 'اتصال افزونه مرورگر',
description: 'ثبت مستقیم دانلودها از مرورگر خود در سه مرحله آسان.',
portablePairingEnabled: 'جفت‌سازی قابل‌حمل فعال‌شده',
pairingTokenPersisted: 'توکن جفت‌سازی ذخیره‌شده',
portablePersistedDescription: 'توکن جفت‌سازی شما با این پوشه قابل‌حمل Firelink ذخیره‌شده است و هنگام انتقال پوشه باقی می‌ماند. با پوشه به عنوان داده حساس برخورد کنید.',
systemPersistedDescription: 'توکن جفت‌سازی شما در مخزن اعتبارنامه‌های سیستم ذخیره‌شده است و پس از راه‌اندازی مجدد باقی می‌ماند.',
portablePairingAvailable: 'جفت‌سازی قابل‌حمل در دسترس است',
credentialStorageNeeded: 'نیاز به ذخیره‌سازی اعتبارنامه',
portableAvailableDescription: 'توکن جفت‌سازی شما با این پوشه قابل‌حمل Firelink ذخیره‌شده است و پس از راه‌اندازی مجدد باقی می‌ماند. برای مشاهده هشدار ذخیره‌سازی قابل‌حمل آن را اینجا فعال کنید.',
credentialStorageDescription: 'Firelink به دسترسی به مخزن اعتبارنامه‌های این سیستم نیاز دارد تا توکن جفت‌سازی شما را پس از راه‌اندازی مجدد به‌طور امن حفظ کند. در حال حاضر، افزونه شما فقط برای این جلسه متصل باقی می‌ماند.',
reviewPortablePairing: 'بررسی جفت‌سازی قابل‌حمل',
grantCredentialAccess: 'اعطای دسترسی اعتبارنامه',
copyToken: 'کپی توکن',
tokenDescription: 'این توکن امن افزونه مرورگر شما را مجاز می‌سازد.',
tokenCopied: 'توکن در کلیپ‌بورد کپی شد!',
tokenCopyFailed: 'توکن کپی نشد: {{detail}}',
pairingTokenRegenerated: 'توکن جفت‌سازی دوباره ایجاد شد',
regenerateFailed: 'ایجاد دوباره توکن جفت‌سازی ناموفق بود: {{detail}}',
getExtension: 'دریافت افزونه',
extensionDescription: 'Firelink Companion را برای مرورگرهای Firefox یا Chromium نصب کنید.',
firefoxAddons: 'افزونه‌های Firefox',
chromiumZip: 'Chromium ZIP',
pasteConnect: 'جای‌گذاری و اتصال',
pasteConnectDescription: 'روی آیکون Firelink در نوار ابزار مرورگر خود کلیک کنید و توکن کپی‌شده را جای‌گذاری کنید.',
extensionServerStatus: 'وضعیت سرور افزونه:',
listening: '● در حال گوش دادن به 127.0.0.1:{{port}}',
unavailable: '● سرور در دسترس نیست',
},
about: {
firelinkIcon: 'آیکون Firelink',
unknown: 'نامشخص',
version: 'نسخه {{version}}',
description: 'مدیریت دانلود چند پلتفرمی برای macOS، Windows و Linux.',
sourceCode: 'کد منبع',
source: 'منبع',
upToDate: 'Firelink {{version}} به‌روز است.',
updateAvailable: 'Firelink {{version}} در دسترس است.',
viewRelease: 'مشاهده انتشار',
updates: 'به‌روزرسانی‌ها',
checkForUpdates: 'بررسی به‌روزرسانی‌ها',
updateDescription: 'Firelink انتشار‌های GitHub را برای نسخه‌های جدید بررسی می‌کند.',
checking: 'در حال بررسی…',
checkNow: 'اکنون بررسی کن',
automaticUpdates: 'بررسی خودکار برای به‌روزرسانی‌ها',
createdBy: 'ایجادشده توسط NimBold',
mitLicense: 'مجوز MIT',
credits: 'اعتبارات: aria2، yt-dlp، FFmpeg و Deno.',
copyright: 'حق کپی‌رایت © 2026 NimBold. تمامی حقوق محفوظ است.',
},
},
status: {
ready: 'آماده',
active: '{{count}} فعال',
queued: '{{count}} در صف',
done: '{{count}} تکمیل‌شده',
},
} as const;
export default fa;
+755
View File
@@ -0,0 +1,755 @@
const he = {
navigation: {
library: 'ספרייה',
filters: {
all: 'הכול',
active: 'פעילות',
completed: 'הושלמו',
unfinished: 'לא הושלמו',
},
categories: {
musics: 'מוזיקה',
movies: 'סרטים',
compressed: 'קבצים דחוסים',
documents: 'מסמכים',
pictures: 'תמונות',
applications: 'יישומים',
other: 'אחר',
},
folders: 'תיקיות',
queues: 'תורים',
tools: 'כלים',
scheduler: 'מתזמן',
speedLimiter: 'הגבלת מהירות',
logs: 'יומנים',
settings: 'הגדרות',
},
actions: {
hideSidebar: 'הסתרת סרגל צד',
showSidebar: 'הצגת סרגל צד',
resizeSidebar: 'שינוי גודל סרגל הצד',
cancel: 'ביטול',
continue: 'המשך',
dismissNotification: 'סגירת התראה',
queueName: 'שם התור',
addNewQueue: 'הוספת תור חדש',
startQueue: 'הפעלת תור',
pauseQueue: 'השהיית תור',
renameQueue: 'שינוי שם תור',
deleteQueue: 'מחיקת תור',
},
dialogs: {
errorBoundary: {
title: 'משהו השתבש.',
description: 'אירעה שגיאה קריטית בעץ הרכיבים של React. פרטי השגיאה למטה עשויים לעזור באיתור הגורם לה.',
reload: 'טעינה מחדש של האפליקציה',
},
duplicateDownloads: {
title: 'זוהו הורדות כפולות',
description: 'חלק מההורדות שברצונך להוסיף כבר קיימות בתור או בדיסק. נא לבחור כיצד לפתור התנגשויות אלו.',
rename: 'שינוי שם',
replace: 'החלפה',
skip: 'דילוג',
},
removeDownload: {
title: 'הסרת הורדה',
confirmationSingle: 'האם ברצונך להסיר פריט זה מהרשימה? ניתן לבחור למחוק גם את הקובץ מהכונן הקשיח.',
confirmationMultiple: 'האם ברצונך להסיר {{count}} פריטים אלו מהרשימה? ניתן לבחור למחוק גם את הקבצים מהכונן הקשיח.',
errorSummary: '{{succeeded}} הוסרו, {{failed}} נכשלו: {{detail}}',
remove: 'הסרה',
deleteFile: 'מחיקת קובץ',
},
},
window: {
controls: 'פקדי חלון',
close: 'סגירה',
minimize: 'מזעור',
maximize: 'הגדלה',
},
downloads: {
actions: {
moveUp: 'הזזה למעלה',
moveDown: 'הזזה למטה',
pause: 'השהייה',
start: 'הפעלה',
resume: 'חידוש',
options: 'אפשרויות',
},
size: {
downloadedOf: '{{downloaded}} הורדו מתוך {{total}} {{unit}}',
downloadedOfApproximate: '{{downloaded}} הורדו מתוך כ-{{total}} {{unit}}',
},
status: {
ready: 'מוכן',
staged: 'בתור',
queued: 'בתור',
downloading: 'מוריד',
processing: 'מעבד',
paused: 'מושהה',
completed: 'הושלם',
failed: 'נכשל',
retrying: 'ניסיון חוזר',
},
values: {
processing: 'מעבד…',
muxing: 'ממזג…',
},
},
speedLimiter: {
title: 'הגבלת מהירות',
unlimited: 'ללא הגבלה',
saveLimit: 'שמירת הגבלה',
globalSpeedLimit: 'הגבלת מהירות כוללת',
description: 'חלה על העברות aria2 חדשות ופעילות ועל הורדות מדיה חדשות של yt-dlp. הגבלות פרטניות עבור הורדה מסוימת נשמרות.',
quickPresets: 'פרופילים מהירים',
removePreset: 'הסרת פרופיל {{value}} {{unit}}',
customPresetIn: 'פרופיל מותאם אישית ב-{{unit}}',
addQuickPreset: 'הוספת פרופיל מהיר',
globalLimitSaved: 'הגבלה כוללת נשמרה: {{value}} {{unit}}',
globalLimitDisabled: 'הגבלת מהירות כוללת הושבתה',
saveFailed: 'לא ניתן לשמור את הגבלת המהירות הכוללת: {{detail}}',
presetAlreadyExists: '{{value}} {{unit}} כבר קיים בפרופילים מהירים',
presetAdded: 'נוסף פרופיל מהיר של {{value}} {{unit}}',
presetRemoved: 'הוסר פרופיל מהיר של {{value}} {{unit}}',
},
logs: {
title: 'יומנים',
entries: '({{count}} רשומות)',
collecting: 'אוסף',
off: 'כבוי',
allLevels: 'כל הרמות',
levels: {
error: 'שגיאה',
warn: 'אזהרה',
info: 'מידע',
debug: 'ניפוי באגים',
trace: 'מעקב',
},
pauseLogging: 'השהיית רישום אבחון',
enableLogging: 'הפעלת רישום אבחון',
clearDisplayed: 'ניקוי היומנים המוצגים',
export: 'ייצוא יומנים',
exportButton: 'ייצוא יומנים',
logFiles: 'קבצי יומן',
localDiagnostics: 'אבחון מקומי:',
diagnosticsDescription: 'איסוף אבחון נעשה בהסכמה, מוגבל בהיקפו ומקומי. נתונים רגישים, שאילתות URL ונתיבי תיקיית הבית מוסתרים בעת הצגה וייצוא. שום דבר אינו מועלה באופן אוטומטי.',
noEntries: 'אין עדיין רשומות יומן שמורות.',
disabledDescription: 'רישום אבחון כבוי. יומני תמיכה קיימים יופיעו כאן כאשר יהיו זמינים.',
copy: 'העתקה',
copied: 'הועתק ללוח',
copyFailed: 'העתקה ללוח נכשלה',
exported: 'יומני תמיכה יוצאו',
exportFailed: 'לא ניתן לייצא יומנים: {{detail}}',
cleared: 'היומנים נוקו',
clearFailed: 'לא ניתן לנקות יומנים: {{detail}}',
enabled: 'רישום אבחון הופעל',
disabled: 'רישום אבחון הושבת',
updateFailed: 'לא ניתן לעדכן את רישום האבחון: {{detail}}',
},
scheduler: {
title: 'מתזמן',
running: 'פועל',
disabled: 'המתזמן מושבת',
noScheduledDay: 'לא נבחר יום מתוזמן',
unsavedChanges: 'שינויים שלא נשמרו',
runNow: 'הפעלה כעת',
pause: 'השהייה',
saveSettings: 'שמירת הגדרות',
timing: 'תזמון',
startTime: 'שעת התחלה',
stopTime: 'שעת סיום',
timingDescription: 'אם Firelink נמצא במצב שינה בשעת ההתחלה, הוא יפעיל את התורים שנבחרו כשיחזור לפעולה מאוחר יותר באותו יום, אלא אם כן שעת הסיום כבר עברה.',
runEveryDay: 'הפעלה בכל יום',
queuesToSchedule: 'תורים לתזמון',
defaultQueue: 'תור ברירת מחדל',
afterCompletion: 'לאחר הסיום',
afterCompletionDescription: 'בחר מה יתרחש לאחר סיום ההורדות שהופעלו על ידי המתזמן.',
actionWarning: 'פעולה זו עשויה להפריע לעבודה אחרת במחשב. Firelink יבצע אותה מיד לאחר שהתור המתוזמן מסתיים.',
systemPermissions: 'הרשאות מערכת',
macPermissionDescription: 'שינה, הפעלה מחדש וכיבוי דורשים הרשאת Automation עבור System Events ב-macOS.',
permissionGranted: 'ניתנה הרשאת Automation',
permissionChecking: 'בודק הרשאת Automation…',
permissionMissing: 'חסרה הרשאת Automation',
revokePermission: 'ביטול הרשאה',
grantPermission: 'מתן הרשאה',
permissionAvailable: 'הרשאת Automation זמינה.',
permissionMissingDetails: 'חסרה הרשאת Automation. הפעל את Firelink תחת Automation עבור System Events בהגדרות המערכת.',
revokePermissionDetails: 'macOS אינה מאפשרת ל-Firelink לבטל הרשאת Automation ישירות. בטל גישה ל-System Events בהגדרות המערכת, ולאחר מכן חזור ל-Firelink.',
requestingPermission: 'מבקש הרשאת Automation…',
enablePermissionDetails: 'הפעל את Firelink תחת Automation עבור System Events בהגדרות המערכת, ולאחר מכן חזור ל-Firelink.',
systemActions: 'פעולות מערכת',
windowsActionsDescription: 'שינה, הפעלה מחדש וכיבוי משתמשים בהרשאות מערכת של Windows. Firelink מדווח על כל פעולה שנדחתה בעת ביצועה; לא נדרשת הרשאה קבועה מראש.',
linuxActionsDescription: 'שינה, הפעלה מחדש וכיבוי משתמשים בשולחן העבודה ובמדיניות המערכת של Linux. Firelink מדווח על כל פעולה שנדחתה בעת ביצועה; לא נדרשת הרשאה קבועה מראש.',
validationDay: 'יש לבחור לפחות יום אחד למתזמן',
validationQueue: 'יש לבחור לפחות תור אחד למתזמן',
validationStopTime: 'שעת הסיום חייבת להיות מאוחרת משעת ההתחלה',
saved: 'הגדרות המתזמן נשמרו',
trackingOne: 'עוקב אחר הורדה מתוזמנת אחת',
trackingMany: 'עוקב אחר {{count}} הורדות מתוזמנות',
noStartableDownloads: 'אין הורדות שניתן להפעיל בתורים שנבחרו',
pausedOne: 'הושהתה הורדה פעילה אחת',
pausedMany: 'הושהתו {{count}} הורדות פעילות',
noActiveDownloads: 'אין הורדות פעילות',
days: {
su: 'א\'',
mo: 'ב\'',
tu: 'ג\'',
we: 'ד\'',
th: 'ה\'',
fr: 'ו\'',
sa: 'ש\'',
},
postActions: {
none: 'ללא פעולה',
sleep: 'שינה',
restart: 'הפעלה מחדש',
shutdown: 'כיבוי',
},
},
properties: {
progress: 'התקדמות',
size: 'גודל',
speed: 'מהירות',
eta: 'זמן נותר',
connections: 'חיבורים',
connectionCount: '{{active}}/{{total}} פעילות',
connectionCountUnknown: '—/{{total}} פעילות',
connectionsUnavailable: '—',
speedCap: 'הגבלת מהירות',
category: 'קטגוריה',
lastTry: 'ניסיון אחרון',
dateAdded: 'תאריך הוספה',
destination: 'יעד',
lastError: 'שגיאה אחרונה',
saved: ' (נשמר)',
defaultValue: ' (ברירת מחדל)',
savedTooltip: 'נשמר עבור הורדה זו; שינויים בהגדרות יחולו על הורדות חדשות.',
defaultTooltip: 'שימוש בברירת המחדל הנוכחית להורדות חדשות.',
identityReadOnly: 'זהות הקובץ היא לקריאה בלבד. הגדרות ההעברה נשמרות להורדה מחדש.',
transferSettings: 'ניתן לשנות את הגדרות ההעברה לאחר עצירה או השהייה. העברות נוכחיות שומרות על אפשרויות המנוע הקיימות שלהן.',
download: 'הורדה',
url: 'URL',
fileName: 'שם קובץ',
saveLocation: 'מיקום שמירה',
select: 'בחירה',
perFile: 'לקובץ',
useCurrentDefault: 'שימוש בברירת המחדל הנוכחית ({{count}})',
savedPerDownload: 'נשמר עבור כל הורדה. ברירת המחדל בהגדרות חלה על הורדות חדשות.',
limit: 'הגבלה',
siteLogin: 'התחברות לאתר',
siteLoginRedownload: 'התחברות לאתר להורדה מחדש',
matchingSiteLogin: 'פרטי התחברות תואמים לאתר',
customCredentials: 'אישורים מותאמים אישית',
noLogin: 'ללא התחברות',
useSavedLogin: 'יעשה שימוש בהתחברות שמורה אם היא זמינה.',
advancedTransfer: 'העברה מתקדמת',
advancedTransferRedownload: 'העברה מתקדמת להורדה מחדש',
checksum: 'סכום ביקורת',
verify: 'אימות',
algorithm: 'אלגוריתם',
digest: 'ערך גיבוב',
expectedDigest: 'ערך גיבוב צפוי',
cookies: 'עוגיות',
headers: 'כותרות (Headers)',
mirrors: 'מראות',
username: 'שם משתמש',
password: 'סיסמה',
enterValidUrl: 'נא להזין כתובת URL תקינה.',
fileNameEmpty: 'שם הקובץ אינו יכול להיות ריק.',
cancel: 'ביטול',
save: 'שמירה',
},
keychain: {
title: 'נדרשת גישה לאחסון אישורים',
stores: {
portable: 'תיקיית הנתונים הניידת של Firelink',
windows: 'מנהל האישורים של Windows',
linux: 'אחסון האישורים של Linux',
macos: 'Keychain של macOS',
system: 'אחסון האישורים של מערכת זו',
siteCredentials: 'אחסון האישורים של המערכת',
},
grantLabelPortable: 'המשך',
grantLabelMacos: 'מתן גישה',
grantLabelDefault: 'הפעלת אחסון מאובטח',
description: 'Firelink משתמש בתוסף הדפדפן כדי ללכוד הורדות. כדי לשמור על חיבור התוסף לאחר הפעלה מחדש, Firelink שומר את אסימון הצימוד שלו ב-{{pairingStore}}. אישורי אתר אופציונליים נשמרים ב-{{siteCredentialStore}}.',
portableExplanation: 'אסימון הצימוד נייד יחד עם תיקייה זו. אישורי אתרים נשארים באחסון האישורים של המערכת; ייתכן שתופיע בקשת מערכת לאחר מתן הגישה.',
macosExplanation: 'macOS עשויה להציג בקשת Keychain לאחר מתן הגישה.',
defaultExplanation: 'לרוב זה מתבצע ברקע. אם שירות האישורים אינו זמין, Firelink יציג את השגיאה כאן והתוסף יישאר מחובר עבור הפעלה זו בלבד.',
note: 'הערה:',
portableNote: 'מצב נייד שומר רק את אסימון הצימוד בתיקייה זו. הוא אינו מעתיק סיסמאות אתרים או אישורי דפדפן.',
defaultNote: 'Firelink כותב רק רשומת אישור ייעודית משלו. אין לו גישה לסיסמאות שמורות אחרות במערכת שלך.',
hint: 'רמז:',
portableHint: 'אסימון הצימוד הנייד כבר שמור בתיקייה זו; ניתן להפעיל אותו כאן או לבחור "מאוחר יותר".',
defaultHint: 'אם תבחר "מאוחר יותר", התוסף יפעל עבור הפעלה זו בלבד.',
enableFromSettings: 'ניתן להפעיל את האחסון בכל עת דרך הגדרות > שילובים.',
later: 'מאוחר יותר',
enabling: 'מפעיל…',
timeout: 'בקשת האחסון של האישורים הסתיימה. ניתן לבחור "מאוחר יותר" ולנסות שוב.',
unavailable: '{{store}} אינו זמין.',
accessRequired: 'יש להעניק גישה לאחסון האישורים לפני יצירה מחדש של אסימון הצימוד.',
storeUnavailable: 'הגישה לאחסון האישורים אינה זמינה.',
},
app: {
loading: 'טוען…',
settingsSaveFailed: 'לא ניתן לשמור הגדרות. בדוק הרשאות אחסון ונסה שוב.',
systemActionCountdown: '{{action}} בעוד 10 שניות.',
systemActionCancelled: 'פעולת המערכת בוטלה מכיוון שהורדה אחרת פעילה או בתור.',
systemActionFailed: 'פעולת מערכת מתוזמנת נכשלה: {{detail}}',
downloadCompleteTitle: 'ההורדה הושלמה',
downloadCompleteBody: 'הורדת {{fileName}} הסתיימה.',
unknownFile: 'קובץ',
downloadFailedTitle: 'ההורדה נכשלה',
downloadFailedBody: 'הורדת {{fileName}} נכשלה.',
initializeDownloadsFailed: 'לא ניתן לאתחל הורדות שמורות: {{detail}}',
extensionDisconnected: 'תוסף הדפדפן נותק מכיוון שאסימון הצימוד שלו השתנה.',
copyToken: 'העתקת אסימון',
integrations: 'שילובים',
copyTokenFailed: 'לא ניתן להעתיק את אסימון הצימוד: {{detail}}',
credentialPersistenceFailed: 'שמירת אישורים מאובטחת אינה זמינה. צימוד הדפדפן פועל עבור הפעלה זו בלבד: {{detail}}',
resumeDownloadsFailed: 'לא ניתן לחדש הורדות שמורות: {{detail}}',
updateAvailable: 'Firelink {{version}} זמין.',
viewRelease: 'הצגת פרטי שחרור',
sleepPreventionFailed: 'לא ניתן לעדכן את מניעת השינה: {{detail}}',
schedulerNoQueues: 'למתזמן אין תורים תקינים שנבחרו. עדכן את הגדרות המתזמן.',
schedulerPauseOneFailed: 'המתזמן לא הצליח להשהות הורדה אחת.',
schedulerPauseManyFailed: 'המתזמן לא הצליח להשהות {{count}} הורדות.',
scheduledIncomplete: 'ההורדות המתוזמנות לא הושלמו במלואן. פעולת המערכת לאחר התור דולגה.',
scheduledActionSkippedActive: 'פעולת המערכת המתוזמנת דולגה מכיוון שהורדה אחרת פעילה או בתור.',
notificationsDisabled: 'התראות המערכת מושבתות עבור Firelink.',
notificationsFailed: 'לא ניתן להגדיר התראות: {{detail}}',
},
sidebar: {
deleteQueueConfirm: 'למחוק את התור "{{name}}"? ההורדות שלא הושלמו בו יועברו לתור הראשי.',
deleteQueueFailed: 'לא ניתן למחוק את התור: {{detail}}',
queueNameEmpty: 'שם התור אינו יכול להיות ריק',
queueNameExists: 'כבר קיים תור בשם זה',
startQueueFailed: 'לא ניתן להפעיל את התור: {{detail}}',
pauseQueueFailed: 'לא ניתן להשהות את התור: {{detail}}',
},
downloadTable: {
unknownQueue: 'תור לא ידוע',
allDownloads: 'כל ההורדות',
active: 'פעילות',
completed: 'הושלמו',
unfinished: 'לא הושלמו',
headers: {
fileName: 'שם קובץ',
size: 'גודל',
status: 'סטטוס',
speed: 'מהירות',
eta: 'זמן נותר',
dateAdded: 'תאריך הוספה',
},
columnOptions: {
open: 'אפשרויות עמודה: {{column}}',
dragToReorder: 'גררו כדי לשנות את סדר העמודות',
alignContent: 'יישור תוכן העמודה',
left: 'שמאל',
center: 'מרכז',
right: 'ימין',
resetLayout: 'איפוס פריסת העמודות',
},
addDownload: 'הוספת הורדה',
resumeAll: 'חידוש הכל',
pauseAll: 'השהיית הכל',
queueEmpty: 'התור ריק',
noCompletedDownloads: 'אין הורדות שהושלמו',
noDownloads: 'אין הורדות',
queueEmptyDescription: 'הוסף הורדות לתור זה מתפריט הפריט או מחלון ההוספה.',
completedDescription: 'הורדות שהושלמו יופיעו כאן.',
clickToAdd: 'לחץ על לחצן',
addButtonOr: 'או',
toAddDownloads: 'כדי להוסיף הורדות',
nonResumableOne: 'הורדה אחת אינה תומכת בחידוש. אם תשהה אותה, תצטרך להתחיל מהתחלה מאוחר יותר. האם ברצונך להשהות?',
nonResumableMany: '{{count}} הורדות אינן תומכות בחידוש. אם תשהה אותן, תצטרך להתחיל מהתחלה מאוחר יותר. האם ברצונך להשהות?',
interactionError: '{{message}}: {{detail}}',
openFileFailed: 'לא ניתן לפתוח את הקובץ שהורד',
revealFileFailed: 'לא ניתן להציג את הקובץ ב-Finder',
pauseFailed: 'לא ניתן להשהות את ההורדה',
resumeFailed: 'לא ניתן לחדש את {{fileName}}',
moveManyFailed: 'לא ניתן להעביר הורדות לתור',
moveOneFailed: 'לא ניתן להעביר הורדה לתור',
copyAddressesFailed: 'לא ניתן להעתיק כתובות',
copyAddressFailed: 'לא ניתן להעתיק כתובת',
copyPathFailed: 'לא ניתן להעתיק נתיב קובץ',
missingFileName: 'שם הקובץ חסר',
redownloadFailed: 'הורדה מחדש נכשלה',
startResume: 'הפעלה/חידוש',
addToQueue: 'הוספה לתור',
copyAddress: 'העתקת כתובת',
remove: 'הסרה',
open: 'פתיחה',
showInFolder: 'הצגה בתיקייה',
pause: 'השהייה',
start: 'הפעלה',
resume: 'חידוש',
redownload: 'הורדה מחדש',
copyFilePath: 'העתקת נתיב קובץ',
properties: 'מאפיינים',
backendRejectedStart: 'מנוע הרקע דחה את בקשת ההפעלה/החידוש.',
transferActive: 'לא ניתן לשנות מאפיינים בזמן שההעברה פעילה. השהה אותה תחילה.',
redownloadNotFound: 'לא ניתן להוריד מחדש: ההורדה לא נמצאה.',
redownloadActive: 'לא ניתן להוריד מחדש הורדה בסטטוס "{{status}}". השהה או המתן שתסתיים תחילה.',
originalUrlMissing: 'לא ניתן להוריד מחדש: ה-URL המקורי חסר.',
pauseBeforeMove: 'השהה את {{file}} לפני העברתו לתור אחר.',
},
addDownloads: {
unknownSize: 'גודל לא ידוע',
unknown: 'לא ידוע',
discardSetup: 'לבטל את הגדרת ההורדה הזו?',
speedInvalid: 'הגבלת המהירות חייבת להיות גדולה מאפס',
duplicateResolveFailed: 'לא ניתן לפתור הורדות כפולות: {{detail}}',
addedWithFailures: '{{added}} נוספו, {{failed}} נכשלו. {{detail}}',
addedOne: 'הורדה אחת נוספה',
addedMany: '{{count}} הורדות נוספו',
updatedOne: 'הורדה אחת עודכנה',
updatedMany: '{{count}} הורדות עודכנו',
urlAlreadyQueued: 'URL כבר נמצא בתור',
destinationConflict: 'הורדה אחרת שנבחרה משתמשת ביעד זה',
existingDownloadDestination: 'הורדת Firelink קיימת משתמשת ביעד זה',
matchingDownloadFilename: 'להורדה שלא הושלמה יש כבר שם קובץ זה',
fileExistsOnDisk: 'הקובץ קיים בדיסק; שנה שם או דלג כדי למנוע מחיקת נתונים שאינם קשורים',
backendRejectedStart: 'מנוע הרקע דחה את התחלת ההורדה.',
playlistNoEntries: 'רשימת ההשמעה אינה מכילה פריטים הניתנים להורדה',
invalidMediaMetadata: 'מטא נתונים של המדיה אינם תקינים או שלא נמצאו פורמטים',
noAvailableName: 'לא נמצא שם פנוי עבור {{file}}.',
pauseBeforeReplace: 'השהה את {{file}} לפני החלפתו.',
cannotReplace: 'לא ניתן להחליף את {{file}}: הקובץ אינו שייך להורדת Firelink.',
downloadLinks: 'קישורי הורדה',
pastePlaceholder: 'הדבק כתובות \u2066HTTP\u2069, \u2066HTTPS\u2069, \u2066FTP\u2069 או \u2066SFTP\u2069 כאן…\n\nעבור הורדות מדיה, הדבק קישורים מ-\u2066YouTube\u2069, \u2066X\u2069, \u2066TikTok\u2069, \u2066Instagram\u2069, \u2066Reddit\u2069 וכו\'.',
playlistSummary: 'רשימת השמעה "{{title}}": {{loaded}} מתוך {{total}} פריטים נטענו{{truncated}}{{skipped}}',
safeEntryLimit: ' (הושגה מגבלת הפריטים הבטוחה)',
selectedSummary: '{{ready}} נבחרו ומוכנים, {{fallback}} לגיבוי, {{mediaRetry}} מדיה לניסיון חוזר, {{blocked}} חסומים',
clearSelection: 'ניקוי בחירה',
selectAll: 'בחירת הכל',
refreshMetadata: 'רענון מטא נתונים',
files: 'קבצים',
required: 'נדרש',
free: 'פנוי',
preview: 'תצוגה מקדימה',
file: 'קובץ',
size: 'גודל',
status: 'סטטוס',
downloadPreview: 'תצוגה מקדימה של הורדה',
noLinks: 'טרם נוספו קישורים.',
selectItem: 'בחר {{file}}',
fetchingPlaylist: 'טוען את רשימת ההשמעה…',
fetching: 'טוען…',
unsafeUrl: 'URL לא בטוח',
playlistFailed: 'טעינת רשימת ההשמעה נכשלה',
metadataFailed: 'אחזור המטא-נתונים נכשל',
fallback: 'גיבוי',
invalid: 'לא תקין',
ready: 'מוכן',
mediaFormat: 'פורמט מדיה',
playlistItem: 'פריט רשימת השמעה {{index}}',
fetchingMediaStreams: 'טוען זרמי מדיה…',
availableStreams: 'זרמים זמינים',
availableMediaStreams: 'זרמי מדיה זמינים',
metadataUnavailable: 'מטא נתונים אינם זמינים. רענן מטא נתונים לפני הוספת מדיה זו.',
saveLocation: 'מיקום שמירה',
browse: 'עיון…',
categoryFolders: 'הקבצים יאורגנו בתיקיות קטגוריה.',
sharedFolder: 'כל ההורדות שנבחרו ישתמשו בתיקייה זו.',
dedicatedFolder: 'שמירה בתיקייה חדשה',
dedicatedFolderName: 'שם התיקייה',
dedicatedFolderDescription: 'תיקייה זו תיווצר בתוך כל תיקיית קטגוריה אוטומטית.',
dedicatedFolderManualDescription: 'תיקייה זו תיווצר בתוך מיקום השמירה שנבחר.',
saveFolderName: 'שמירת שם התיקייה',
dedicatedFolderNameRequired: 'יש להזין שם תיקייה לפני השמירה.',
transferSettings: 'הגדרות העברה',
connectionsPerFile: 'חיבורים לקובץ',
connectionsPerFileAria: 'חיבורים לקובץ',
limitSpeedPerFile: 'הגבלת מהירות לקובץ',
speedLimitPerFileAria: 'הגבלת מהירות לקובץ',
authorization: 'הרשאה',
useAuthorization: 'שימוש בהרשאה',
username: 'שם משתמש',
password: 'סיסמה',
advancedTransfer: 'העברה מתקדמת',
verifyChecksum: 'אימות סכום ביקורת',
checksumAlgorithm: 'אלגוריתם סכום ביקורת',
expectedDigest: 'ערך גיבוב צפוי',
headers: 'כותרות (Headers)',
requestHeaders: 'כותרות בקשה',
cookies: 'עוגיות',
cookiePlaceholder: 'name=value; other=value',
mirrors: 'מראות',
cancel: 'ביטול',
startDownloads: 'התחלת הורדות',
addToQueue: 'הוספה לתור',
pasteOneOrMore: 'הדבק קישור אחד או יותר.',
selectAtLeastOne: 'בחר לפחות הורדה אחת.',
correctInvalidOne: 'תקן או הסר {{count}} כתובת URL לא תקינה לפני שתמשיך.',
correctInvalidFew: 'תקן או הסר {{count}} כתובות URL לא תקינות לפני שתמשיך.',
correctInvalidMany: 'תקן או הסר {{count}} כתובות URL לא תקינות לפני שתמשיך.',
waitingForMetadataOne: 'ממתין למטא נתונים עבור {{count}} הורדה.',
waitingForMetadataFew: 'ממתין למטא נתונים עבור {{count}} הורדות.',
waitingForMetadataMany: 'ממתין למטא נתונים עבור {{count}} הורדות.',
removeUnsafeOne: 'הסר {{count}} כתובת URL לא בטוחה לפני שתמשיך.',
removeUnsafeFew: 'הסר {{count}} כתובות URL לא בטוחות לפני שתמשיך.',
removeUnsafeMany: 'הסר {{count}} כתובות URL לא בטוחות לפני שתמשיך.',
mediaMetadataUnavailableSummaryOne: 'מטא נתונים של מדיה אינם זמינים עבור {{count}} פריט. רענן מטא נתונים לפני ההוספה.',
mediaMetadataUnavailableSummaryFew: 'מטא נתונים של מדיה אינם זמינים עבור {{count}} פריטים. רענן מטא נתונים לפני ההוספה.',
mediaMetadataUnavailableSummaryMany: 'מטא נתונים של מדיה אינם זמינים עבור {{count}} פריטים. רענן מטא נתונים לפני ההוספה.',
metadataUnavailableFallback: 'מטא נתונים אינם זמינים. עדיין ניתן להוסיף הורדות באמצעות פרטי הגיבוי.',
fallbackReadyOne: '{{ready}} הורדה מוכנה; עבור {{failed}} ייעשה שימוש בשם קובץ גיבוי ובגודל לא ידוע.',
fallbackReadyFew: '{{ready}} הורדות מוכנות; עבור {{failed}} ייעשה שימוש בשם קובץ גיבוי ובגודל לא ידוע.',
fallbackReadyMany: '{{ready}} הורדות מוכנות; עבור {{failed}} ייעשה שימוש בשם קובץ גיבוי ובגודל לא ידוע.',
readyToAddOne: 'מוכן להוסיף {{count}} הורדה.',
readyToAddFew: 'מוכן להוסיף {{count}} הורדות.',
readyToAddMany: 'מוכן להוסיף {{count}} הורדות.',
},
settings: {
tabs: {
downloads: 'הורדות',
lookAndFeel: 'מראה ותחושה',
network: 'רשת',
locations: 'מיקומים',
siteLogins: 'התחברויות לאתרים',
power: 'צריכת חשמל',
engine: 'מנוע',
integrations: 'שילובים',
about: 'אודות',
},
common: {
thisOs: 'מערכת הפעלה זו',
checking: 'בודק…',
ready: 'מוכן',
errorMissing: 'שגיאה / חסר',
hideTechnicalDetails: 'הסתרת פרטים טכניים',
showTechnicalDetails: 'הצגת פרטים טכניים',
binary: 'קובץ בינארי',
expected: 'צפוי',
error: 'שגיאה',
tip: 'טיפ',
stderr: 'stderr',
daemonProcessAlive: 'תהליך רקע פעיל',
rpcReady: 'RPC מוכן',
rpcPort: 'פורט RPC',
daemonStderr: 'stderr של תהליך רקע',
packaging: 'אריזה: PyInstaller onedir (נדרש _internal)',
internalDirectoryFound: 'תיקיית _internal נמצאה',
pythonRuntimeFound: 'סביבת ריצה של Python נמצאה',
externalOpenFailed: 'לא ניתן לפתוח את {{label}}: {{detail}}',
updateUnexpectedResponse: 'בדיקת העדכון החזירה תגובה בלתי צפויה.',
updateUntrustedReleaseUrl: 'בדיקת העדכון החזירה URL שחרור לא מהימן.',
updateFailed: 'בדיקת עדכון נכשלה: {{detail}}',
},
downloads: {
defaultConnections: 'חיבורי ברירת מחדל:',
defaultConnectionsDescription: 'הורדות חדשות; פריטים קיימים שומרים על הערך השמור שלהם',
parallelDownloads: 'הורדות במקביל:',
parallelDownloadsDescription: 'מקסימום קבצים פעילים בו-זמנית',
automaticRetries: 'ניסיונות חוזרים אוטומטיים:',
automaticRetriesDescription: 'אם חיבור נכשל',
systemNotification: 'הצג התראת מערכת כאשר ההורדה מסתיימת',
systemNotificationDescription: 'משתמש בהגדרות ההתראות של מערכת ההפעלה שלך',
completionChime: 'השמע צליל סיום בתוך האפליקציה',
completionChimeDescription: 'צליל Firelink אופציונלי, ללא קשר להתראות המערכת',
clipboardLinks: 'הוסף קישורים מהלוח כאשר Firelink הופך לפעיל',
clipboardLinksDescription: 'פותח קישורים מועתקים נתמכים בחלון ההוספה. כבוי כברירת מחדל.',
},
lookAndFeel: {
language: 'שפה',
languageDescription: 'בחר את שפת הממשק של Firelink.',
sidebarPosition: 'מיקום סרגל הצד',
sidebarPositionDescription: 'בחר היכן יופיע סרגל הצד. מצב אוטומטי עוקב אחר כיוון הממשק.',
sidebarPositionAutomatic: 'אוטומטי (מומלץ)',
sidebarPositionLeft: 'שמאל',
sidebarPositionRight: 'ימין',
languageSystem: 'שפת המערכת',
languageEnglish: 'אנגלית',
languageChinese: 'סינית מפושטת',
languageHebrew: 'עברית',
languagePersian: 'פרסית',
languageUkrainian: 'אוקראינית',
languageRussian: 'רוסית',
appTheme: 'ערכת נושא לאפליקציה',
theme: 'ערכת נושא',
ariaLabel: 'ערכת נושא לאפליקציה',
system: 'מערכת',
light: 'בהירה',
dark: 'כהה',
dracula: 'Dracula',
nord: 'Nord',
systemAppearance: 'מערכת עוקבת אחר המראה הנוכחי (בהיר או כהה) של {{platform}}.',
display: 'תצוגה',
fontSize: 'גודל גופן',
fontSizeDescription: 'משנה את קנה המידה של רשימת ההורדות והפקדים הקומפקטיים.',
small: 'קטן',
standard: 'רגיל',
large: 'גדול',
listDensity: 'צפיפות הרשימה',
listDensityDescription: 'משנה את גובה השורה, הריווח וקנה המידה של פס ההתקדמות.',
compact: 'קומפקטי',
comfortable: 'נוח',
relaxed: 'מרווח',
osIntegration: 'שילוב במערכת ההפעלה',
dockBadge: 'הצג תג על סמל ה-Dock',
dockBadgeDescription: 'מציג את ספירת ההורדות הפעילות על סמל Firelink ב-Dock.',
menuBarIcon: 'הצג סמל בשורת התפריטים',
statusIndicatorIcon: 'הצג סמל מחוון סטטוס',
systemTrayIcon: 'הצג סמל במגש המערכת',
macosTrayDescription: 'מספק גישה מהירה משורת התפריטים של macOS.',
windowsTrayDescription: 'מספק גישה מהירה מאזור ההתראות של Windows.',
linuxTrayDescription: 'מספק גישה מהירה ממגש שולחן העבודה או מאזור הסטטוס כאשר הם זמינים.',
defaultTrayDescription: 'מספק גישה מהירה מאזור המגש של מערכת ההפעלה כאשר הוא זמין.',
},
network: {
proxy: 'פרוקסי',
mode: 'מצב',
modeDescription: 'שולט בשימוש בפרוקסי עבור בקשות הורדה חדשות.',
noProxy: 'ללא פרוקסי',
systemProxy: 'פרוקסי של המערכת',
customProxy: 'פרוקסי מותאם אישית',
proxyHost: 'מארח פרוקסי',
proxyHostDescription: 'שם מארח, כתובת IP או URL של פרוקסי HTTP.',
proxyHostPlaceholder: '127.0.0.1 או http://127.0.0.1',
proxyPort: 'פורט פרוקסי',
proxyPortDescription: 'טווח חוקי הוא 1 עד 65535.',
noProxyDescription: 'הורדות מתעלמות מפרוקסי שהוגדרו.',
systemProxyDescription: 'הורדות משתמשות בפרוקסי המערכת {{platform}} שזוהה. הורדות קבצים רגילות דורשות נקודת קצה של HTTP או HTTPS; הורדות מדיה יכולות להשתמש ב-SOCKS.',
customProxyDescription: 'הורדות משתמשות בנקודת הקצה המוגדרת של פרוקסי HTTP עבור מטא-נתונים ומנועי הורדה.',
invalidCustomProxy: 'הזן מארח ופורט חוקיים של פרוקסי HTTP כדי להפעיל את הפרוקסי המותאם אישית.',
incompleteCustomProxy: 'הזן מארח ופורט של פרוקסי כדי להפעיל את הפרוקסי המותאם אישית.',
checkingSystemProxy: 'בודק את תצורת פרוקסי המערכת…',
detectedSystemProxy: 'זוהה פרוקסי מערכת. הורדות קבצים רגילות דורשות נקודת קצה של HTTP או HTTPS; הורדות מדיה יכולות להשתמש ב-SOCKS.',
noSystemProxy: 'לא זוהה פרוקסי מערכת שימושי. ההורדות לא ישתמשו בפרוקסי.',
systemProxyReadFailed: 'לא ניתן לקרוא את תצורת פרוקסי המערכת. בחר "ללא פרוקסי" או נסה שוב כשהיא תהיה זמינה.',
identity: 'זהות',
customUserAgent: 'User-Agent מותאם אישית',
userAgentDescription: 'מוחל על משיכות מטא נתונים ומנועי הורדה.',
userAgentPlaceholder: 'השאר ריק עבור ברירת המחדל של Firelink',
userAgentOverrides: 'דורס את כותרת ה-User-Agent היוצאת. השאר ריק עבור ברירות המחדל של Firelink.',
chromeWindows: 'Chrome (Windows)',
chromeMacos: 'Chrome (macOS)',
edgeWindows: 'Edge (Windows)',
firefoxWindows: 'Firefox (Windows)',
firefoxMacos: 'Firefox (macOS)',
safariMacos: 'Safari (macOS)',
windowsDesktop: 'שולחן עבודה Windows',
macosDesktop: 'שולחן עבודה macOS',
},
locations: {
baseDownloadFolder: 'תיקיית הורדות בסיסית',
baseDownloadFolderDescription: 'תיקיות קטגוריה אוטומטיות נוצרות בתוך תיקייה זו.',
browse: 'עיון…',
baseFolderCreateFailed: 'תיקיית הבסיס נשמרה, אך לא ניתן היה ליצור תיקיות קטגוריה: {{detail}}',
baseFolderUpdated: 'תיקיית הורדות בסיסית עודכנה',
askWhereToSave: 'שאל היכן לשמור בעת הוספת הורדות',
rememberLastUsedDownloadDirectory: 'זכור את ספריית ההורדות שהייתה בשימוש אחרון',
rememberLastUsedDownloadDirectoryDescription: 'השתמש בתיקייה האחרונה שנבחרה בחלון ההוספה עבור ההורדה הבאה עד שהאפליקציה תופעל מחדש.',
automaticCategorySubfolders: 'שמור קבצים אוטומטית לתיקיות משנה של קטגוריות',
automaticCategorySubfoldersDescription: 'כאשר כבוי, הורדות אוטומטיות משתמשות בתיקיית הבסיס.',
categorySubfolders: 'תיקיות משנה של קטגוריות',
relativeToBase: 'יחסית לתיקיית הבסיס',
disabled: 'מושבת',
categorySubfolder: 'תיקיית משנה {{category}}',
customFolder: 'תיקייה מותאמת אישית',
useAutomatic: 'השתמש באוטומטי',
resetDefaults: 'שחזור ברירות מחדל',
resetToast: 'מיקומי הקטגוריות אופסו לברירת המחדל',
},
siteLogins: {
title: 'אישורי אתרים',
noSavedLogins: 'אין התחברויות שמורות.',
user: 'משתמש: {{username}}',
deleteCredential: 'מחיקת אישור',
deletedCredential: 'אישור נמחק',
deleteFailed: 'לא ניתן למחוק אישור: {{detail}}',
addTitle: 'הוספת אישורי אתר',
urlPattern: 'תבנית URL:',
urlPatternPlaceholder: 'לדוגמה *.example.com או example.com/downloads',
username: 'שם משתמש:',
password: 'סיסמה:',
usernamePlaceholder: 'שם משתמש',
passwordPlaceholder: 'סיסמה',
saving: 'שומר…',
addLogin: 'הוספת התחברות',
urlPatternRequired: 'נדרשת תבנית URL.',
urlPatternWhitespace: 'תבנית URL אינה יכולה להכיל רווחים.',
usernameRequired: 'נדרש שם משתמש.',
passwordRequired: 'נדרשת סיסמה.',
accessBeforeSave: 'הענק גישה לאחסון האישורים לפני שמירת התחברות לאתר.',
saveFailed: 'שמירת אישור האתר באופן מאובטח נכשלה.',
addedCredential: 'אישור אתר נוסף',
},
power: {
title: 'ניהול צריכת חשמל',
preventSleep: 'מנע שינה של המערכת בזמן שהורדות פעילות',
preventSleepDescription: 'התצוגה עדיין עשויה להכבות. Firelink משאיר את המכשיר ער רק במידה המספקת להשלמת העברות פעילות.',
},
engine: {
title: 'מוריד מדיה ומנועים',
description: 'תוצאות מוצלחות נמצאות בשימוש חוזר עבור הפעלה זו של האפליקציה. בדיקה מחדש מפעילה אימות אמיתי מחדש.',
recheck: 'בדיקה מחדש של מנועים',
coreDownloader: 'מנוע ההורדה הראשי (aria2)',
version: 'גרסה:',
status: 'סטטוס:',
mediaExtractors: 'מחלצי מדיה',
browserCookiesSource: 'מקור עוגיות דפדפן (Browser Cookies):',
cookieDescription: 'yt-dlp קורא עוגיות דפדפן כדי לעקוף הגבלות הורדת וידאו או כדי לגשת למדיה מוגבלת. Firelink אינו שומר עוגיות דפדפן.',
none: 'ללא',
},
integrations: {
title: 'חיבור תוסף דפדפן',
description: 'לכוד הורדות ישירות מהדפדפן שלך בשלושה שלבים פשוטים.',
portablePairingEnabled: 'צימוד נייד מופעל',
pairingTokenPersisted: 'אסימון צימוד נשמר',
portablePersistedDescription: 'אסימון הצימוד שלך נשמר יחד עם תיקיית Firelink ניידת זו ויישאר בעת הזזת התיקייה. התייחס לתיקייה כרגישה.',
systemPersistedDescription: 'אסימון הצימוד שלך נשמר באחסון האישורים של המערכת ויישאר לאחר הפעלות מחדש.',
portablePairingAvailable: 'צימוד נייד זמין',
credentialStorageNeeded: 'נדרש אחסון אישורים',
portableAvailableDescription: 'אסימון הצימוד שלך נשמר יחד עם תיקיית Firelink ניידת זו ויישאר לאחר הפעלות מחדש. הפעל אותו כאן כדי לעיין באזהרת האחסון הנייד.',
credentialStorageDescription: 'Firelink זקוק לגישה לאחסון האישורים של מערכת זו כדי לשמור בבטחה את אסימון הצימוד שלך לאחר הפעלות מחדש של האפליקציה. כרגע, התוסף שלך יישאר מחובר עבור הפעלה זו בלבד.',
reviewPortablePairing: 'עיון בצימוד נייד',
grantCredentialAccess: 'הענקת גישה לאישורים',
copyToken: 'העתקת אסימון',
tokenDescription: 'אסימון מאובטח זה מאשר את תוסף הדפדפן שלך.',
tokenCopied: 'האסימון הועתק ללוח!',
tokenCopyFailed: 'לא ניתן להעתיק את האסימון: {{detail}}',
pairingTokenRegenerated: 'אסימון הצימוד נוצר מחדש',
regenerateFailed: 'לא ניתן ליצור מחדש אסימון צימוד: {{detail}}',
getExtension: 'קבלת התוסף',
extensionDescription: 'התקן את Firelink Companion עבור דפדפני Firefox או Chromium.',
firefoxAddons: 'הרחבות Firefox',
chromiumZip: 'Chromium ZIP',
pasteConnect: 'הדבקה וחיבור',
pasteConnectDescription: 'לחץ על סמל Firelink בסרגל הכלים של הדפדפן שלך והדבק את האסימון שהועתק.',
extensionServerStatus: 'סטטוס שרת התוסף:',
listening: '● מאזין ב-127.0.0.1:{{port}}',
unavailable: '● שרת לא זמין',
},
about: {
firelinkIcon: 'סמל Firelink',
unknown: 'לא ידוע',
version: 'גרסה {{version}}',
description: 'מנהל הורדות חוצה-פלטפורמות עבור macOS, Windows ו-Linux.',
sourceCode: 'קוד מקור',
source: 'מקור',
upToDate: 'Firelink {{version}} מעודכן.',
updateAvailable: 'Firelink {{version}} זמין.',
viewRelease: 'הצגת שחרור',
updates: 'עדכונים',
checkForUpdates: 'בדוק עדכונים',
updateDescription: 'Firelink בודק מהדורות ב-GitHub עבור גרסאות חדשות.',
checking: 'בודק…',
checkNow: 'בדוק כעת',
automaticUpdates: 'בדוק עדכונים אוטומטית',
createdBy: 'נוצר על ידי NimBold',
mitLicense: 'רישיון MIT',
credits: 'קרדיטים: aria2, yt-dlp, FFmpeg ו-Deno.',
copyright: 'כל הזכויות שמורות © 2026 NimBold.',
},
},
status: {
ready: 'מוכן',
active: '{{count}} פעילים',
queued: '{{count}} בתור',
done: '{{count}} הושלמו',
},
} as const;
export default he;
+755
View File
@@ -0,0 +1,755 @@
const ru = {
navigation: {
library: 'Библиотека',
filters: {
all: 'Все',
active: 'Активные',
completed: 'Завершённые',
unfinished: 'Незавершённые',
},
categories: {
musics: 'Музыка',
movies: 'Фильмы',
compressed: 'Архивы',
documents: 'Документы',
pictures: 'Изображения',
applications: 'Программы',
other: 'Другое',
},
folders: 'Папки',
queues: 'Очереди',
tools: 'Инструменты',
scheduler: 'Планировщик',
speedLimiter: 'Ограничение скорости',
logs: 'Журнал',
settings: 'Настройки',
},
actions: {
hideSidebar: 'Скрыть боковую панель',
showSidebar: 'Показать боковую панель',
resizeSidebar: 'Изменить размер боковой панели',
cancel: 'Отмена',
continue: 'Продолжить',
dismissNotification: 'Закрыть уведомление',
queueName: 'Название очереди',
addNewQueue: 'Добавить новую очередь',
startQueue: 'Запустить очередь',
pauseQueue: 'Приостановить очередь',
renameQueue: 'Переименовать очередь',
deleteQueue: 'Удалить очередь',
},
dialogs: {
errorBoundary: {
title: 'Что-то пошло не так.',
description: 'Произошла критическая ошибка в дереве компонентов React. Сведения об ошибке ниже помогут определить её причину.',
reload: 'Перезапустить приложение',
},
duplicateDownloads: {
title: 'Обнаружены дубликаты загрузок',
description: 'Некоторые из добавляемых загрузок уже есть в очереди или на диске. Выберите способ разрешения конфликтов.',
rename: 'Переименовать',
replace: 'Заменить',
skip: 'Пропустить',
},
removeDownload: {
title: 'Удалить загрузку',
confirmationSingle: 'Вы действительно хотите удалить этот элемент из списка? Вы также можете удалить файл с диска.',
confirmationMultiple: 'Вы действительно хотите удалить выбранные элементы ({{count}}) из списка? Вы также можете удалить файлы с диска.',
errorSummary: 'Удалено {{succeeded}}, с ошибкой {{failed}}: {{detail}}',
remove: 'Удалить',
deleteFile: 'Удалить файл',
},
},
window: {
controls: 'Управление окном',
close: 'Закрыть',
minimize: 'Свернуть',
maximize: 'Развернуть',
},
downloads: {
actions: {
moveUp: 'Переместить вверх',
moveDown: 'Переместить вниз',
pause: 'Приостановить',
start: 'Запустить',
resume: 'Возобновить',
options: 'Параметры',
},
size: {
downloadedOf: 'Загружено {{downloaded}} из {{total}} {{unit}}',
downloadedOfApproximate: 'Загружено {{downloaded}} из примерно {{total}} {{unit}}',
},
status: {
ready: 'Готово',
staged: 'В очереди',
queued: 'В очереди',
downloading: 'Загрузка',
processing: 'Обработка',
paused: 'Приостановлено',
completed: 'Завершено',
failed: 'Ошибка',
retrying: 'Повторная попытка',
},
values: {
processing: 'Обработка…',
muxing: 'Мультиплексирование…',
},
},
speedLimiter: {
title: 'Ограничение скорости',
unlimited: 'Без ограничений',
saveLimit: 'Сохранить ограничение',
globalSpeedLimit: 'Общее ограничение скорости',
description: 'Применяется к новым и активным загрузкам aria2 и новым загрузкам медиа yt-dlp. Индивидуальные ограничения для отдельных загрузок сохраняются.',
quickPresets: 'Быстрые пресеты',
removePreset: 'Удалить пресет {{value}} {{unit}}',
customPresetIn: 'Пользовательский пресет ({{unit}})',
addQuickPreset: 'Добавить быстрый пресет',
globalLimitSaved: 'Общее ограничение сохранено: {{value}} {{unit}}',
globalLimitDisabled: 'Общее ограничение скорости отключено',
saveFailed: 'Не удалось сохранить общее ограничение скорости: {{detail}}',
presetAlreadyExists: 'Пресет {{value}} {{unit}} уже есть в быстрых пресетах',
presetAdded: 'Добавлен быстрый пресет {{value}} {{unit}}',
presetRemoved: 'Удалён быстрый пресет {{value}} {{unit}}',
},
logs: {
title: 'Журнал',
entries: '({{count}} записей)',
collecting: 'Сбор',
off: 'Выкл.',
allLevels: 'Все уровни',
levels: {
error: 'Ошибка',
warn: 'Предупреждение',
info: 'Информация',
debug: 'Отладка',
trace: 'Трассировка',
},
pauseLogging: 'Приостановить ведение журнала',
enableLogging: 'Включить ведение журнала',
clearDisplayed: 'Очистить отображаемый журнал',
export: 'Экспорт журнала',
exportButton: 'Экспортировать журнал',
logFiles: 'Файлы журнала',
localDiagnostics: 'Локальная диагностика:',
diagnosticsDescription: 'Сбор диагностики выполняется по желанию, ограничен по объёму и хранится локально. Конфиденциальные данные, параметры URL и пути к домашней папке скрываются при отображении и экспорте. Никакие данные не отправляются автоматически.',
noEntries: 'Сохранённых записей журнала пока нет.',
disabledDescription: 'Ведение журнала диагностики отключено. Существующие логи поддержки появятся здесь при наличии.',
copy: 'Копировать',
copied: 'Скопировано в буфер обмена',
copyFailed: 'Не удалось скопировать в буфер обмена',
exported: 'Журнал диагностики экспортирован',
exportFailed: 'Не удалось экспортировать журнал: {{detail}}',
cleared: 'Журнал очищен',
clearFailed: 'Не удалось очистить журнал: {{detail}}',
enabled: 'Ведение журнала диагностики включено',
disabled: 'Ведение журнала диагностики отключено',
updateFailed: 'Не удалось обновить ведение журнала диагностики: {{detail}}',
},
scheduler: {
title: 'Планировщик',
running: 'Работает',
disabled: 'Планировщик отключён',
noScheduledDay: 'Дни выполнения не выбраны',
unsavedChanges: 'Несохранённые изменения',
runNow: 'Запустить сейчас',
pause: 'Приостановить',
saveSettings: 'Сохранить настройки',
timing: 'Расписание',
startTime: 'Время начала',
stopTime: 'Время окончания',
timingDescription: 'Если Firelink находится в спящем режиме во время запуска, он запустит выбранные очереди после пробуждения в этот же день, если время окончания ещё не наступило.',
runEveryDay: 'Запускать каждый день',
queuesToSchedule: 'Очереди для запуска',
defaultQueue: 'Очередь по умолчанию',
afterCompletion: 'После завершения',
afterCompletionDescription: 'Выберите действие после завершения загрузок, запущенных планировщиком.',
actionWarning: 'Это действие может прервать работу других программ на компьютере. Firelink выполнит его сразу после завершения запланированной очереди.',
systemPermissions: 'Системные разрешения',
macPermissionDescription: 'Для перехода в спящий режим, перезагрузки и выключения требуется разрешение macOS «Автоматизация» для System Events.',
permissionGranted: 'Разрешение «Автоматизация» предоставлено',
permissionChecking: 'Проверка разрешения «Автоматизация»…',
permissionMissing: 'Отсутствует разрешение «Автоматизация»',
revokePermission: 'Отозвать разрешение',
grantPermission: 'Предоставить разрешение',
permissionAvailable: 'Разрешение «Автоматизация» доступно.',
permissionMissingDetails: 'Отсутствует разрешение «Автоматизация». В Системных настройках откройте раздел «Автоматизация» для System Events и включите Firelink.',
revokePermissionDetails: 'macOS не позволяет отзывать разрешение «Автоматизация» напрямую из Firelink. Отключите доступ Firelink к System Events в Системных настройках, затем вернитесь в Firelink.',
requestingPermission: 'Запрос разрешения «Автоматизация»…',
enablePermissionDetails: 'В Системных настройках откройте раздел «Автоматизация» для System Events и включите Firelink, затем вернитесь в Firelink.',
systemActions: 'Системные действия',
windowsActionsDescription: 'Спящий режим, перезагрузка и выключение используют системные привилегии Windows. Firelink сообщит о любых отклонённых действиях при их запуске; постоянное разрешение заранее не запрашивается.',
linuxActionsDescription: 'Спящий режим, перезагрузка и выключение используют системную политику и рабочий стол Linux. Firelink сообщит о любых отклонённых действиях при их запуске; постоянное разрешение заранее не запрашивается.',
validationDay: 'Выберите хотя бы один день для планировщика',
validationQueue: 'Выберите хотя бы одну очередь для планировщика',
validationStopTime: 'Время окончания должно быть позже времени начала',
saved: 'Настройки планировщика сохранены',
trackingOne: 'Отслеживается 1 запланированная загрузка',
trackingMany: 'Отслеживается {{count}} запланированных загрузок',
noStartableDownloads: 'В выбранных очередях нет загрузок, которые можно запустить',
pausedOne: 'Приостановлена 1 активная загрузка',
pausedMany: 'Приостановлено {{count}} активных загрузок',
noActiveDownloads: 'Нет активных загрузок',
days: {
su: 'Вс',
mo: 'Пн',
tu: 'Вт',
we: 'Ср',
th: 'Чт',
fr: 'Пт',
sa: 'Сб',
},
postActions: {
none: 'Ничего не делать',
sleep: 'Спящий режим',
restart: 'Перезагрузка',
shutdown: 'Завершение работы',
},
},
properties: {
progress: 'Прогресс',
size: 'Размер',
speed: 'Скорость',
eta: 'Осталось',
connections: 'Соединения',
connectionCount: '{{active}}/{{total}} активных',
connectionCountUnknown: '—/{{total}} активных',
connectionsUnavailable: '—',
speedCap: 'Ограничение скорости',
category: 'Категория',
lastTry: 'Последняя попытка',
dateAdded: 'Дата добавления',
destination: 'Папка назначения',
lastError: 'Последняя ошибка',
saved: ' (сохранено)',
defaultValue: ' (по умолчанию)',
savedTooltip: 'Сохранено для этой загрузки. Изменения в Настройках будут применяться к новым загрузкам.',
defaultTooltip: 'Используется текущее значение по умолчанию для новых загрузок.',
identityReadOnly: 'Идентификация файла доступна только для чтения. Настройки передачи сохранены для повторного скачивания.',
transferSettings: 'Настройки передачи можно изменить после остановки или приостановки. Текущие загрузки сохраняют свои параметры.',
download: 'Загрузка',
url: 'URL',
fileName: 'Имя файла',
saveLocation: 'Место сохранения',
select: 'Выбрать',
perFile: 'на файл',
useCurrentDefault: 'По умолчанию ({{count}})',
savedPerDownload: 'Сохранено для этой загрузки. Значение из Настроек применяется к новым загрузкам.',
limit: 'Ограничение',
siteLogin: 'Авторизация на сайте',
siteLoginRedownload: 'Авторизация для повторного скачивания',
matchingSiteLogin: 'Учётные данные сайта',
customCredentials: 'Пользовательские учётные данные',
noLogin: 'Без авторизации',
useSavedLogin: 'Будет использован сохранённый логин, если он доступен.',
advancedTransfer: 'Дополнительные параметры',
advancedTransferRedownload: 'Дополнительные параметры повторного скачивания',
checksum: 'Контрольная сумма',
verify: 'Проверить',
algorithm: 'Алгоритм',
digest: 'Хеш',
expectedDigest: 'Ожидаемый хеш',
cookies: 'Файлы cookie',
headers: 'Заголовки',
mirrors: 'Зеркала',
username: 'Имя пользователя',
password: 'Пароль',
enterValidUrl: 'Введите корректный URL.',
fileNameEmpty: 'Имя файла не может быть пустым.',
cancel: 'Отмена',
save: 'Сохранить',
},
keychain: {
title: 'Требуется доступ к хранилищу учётных данных',
stores: {
portable: 'портативная папка Firelink',
windows: 'Диспетчер учётных данных Windows',
linux: 'хранилище учётных данных Linux',
macos: 'Связка ключей macOS',
system: 'системное хранилище учётных данных',
siteCredentials: 'системное хранилище учётных данных',
},
grantLabelPortable: 'Продолжить',
grantLabelMacos: 'Предоставить доступ',
grantLabelDefault: 'Включить защищённое хранилище',
description: 'Firelink использует расширение браузера для перехвата загрузок. Чтобы расширение оставалось привязанным после перезапуска, Firelink сохраняет токен сопряжения в {{pairingStore}}. Дополнительные учётные данные сайтов хранятся в {{siteCredentialStore}}.',
portableExplanation: 'Токен сопряжения переносится вместе с этой папкой. Учётные данные сайтов остаются в системном хранилище; после предоставления доступа может появиться системный запрос.',
macosExplanation: 'macOS может показать запрос Связки ключей после предоставления доступа.',
defaultExplanation: 'Обычно это происходит незаметно. Если служба учётных данных недоступна, Firelink покажет ошибку здесь, и расширение останется привязанным только на текущий сеанс.',
note: 'Примечание:',
portableNote: 'В портативном режиме в этой папке сохраняется только токен сопряжения. Пароли сайтов и учётные данные браузера не копируются.',
defaultNote: 'Firelink создаёт только собственную изолированную запись учётных данных. Приложение не имеет доступа к другим сохранённым паролям вашей системы.',
hint: 'Подсказка:',
portableHint: 'Портативный токен сопряжения уже сохранён в этой папке. Вы можете включить его здесь или выбрать «Позже».',
defaultHint: 'Если вы выберете «Позже», расширение будет работать только во время текущего сеанса.',
enableFromSettings: 'Вы можете включить хранилище в любое время в меню «Настройки» > «Интеграции».',
later: 'Позже',
enabling: 'Включение…',
timeout: 'Время ожидания запроса к хранилищу учётных данных истекло. Вы можете выбрать «Позже» и попробовать снова.',
unavailable: 'Хранилище {{store}} недоступно.',
accessRequired: 'Предоставьте доступ к хранилищу учётных данных перед повторной генерацией токена сопряжения.',
storeUnavailable: 'Доступ к хранилищу учётных данных недоступен.',
},
app: {
loading: 'Загрузка…',
settingsSaveFailed: 'Не удалось сохранить настройки. Проверьте разрешения хранилища и попробуйте снова.',
systemActionCountdown: '{{action}} через 10 секунд.',
systemActionCancelled: 'Системное действие отменено, так как есть активная или запланированная загрузка.',
systemActionFailed: 'Не удалось выполнить запланированное системное действие: {{detail}}',
downloadCompleteTitle: 'Загрузка завершена',
downloadCompleteBody: 'Загрузка {{fileName}} завершена.',
unknownFile: 'Файл',
downloadFailedTitle: 'Ошибка загрузки',
downloadFailedBody: 'Не удалось загрузить {{fileName}}.',
initializeDownloadsFailed: 'Не удалось инициализировать сохранённые загрузки: {{detail}}',
extensionDisconnected: 'Расширение браузера отключено, так как его токен сопряжения изменился.',
copyToken: 'Скопировать токен',
integrations: 'Интеграции',
copyTokenFailed: 'Не удалось скопировать токен сопряжения: {{detail}}',
credentialPersistenceFailed: 'Безопасное сохранение учётных данных недоступно. Сопряжение с браузером работает только в текущем сеансе: {{detail}}',
resumeDownloadsFailed: 'Не удалось возобновить сохранённые загрузки: {{detail}}',
updateAvailable: 'Доступна Firelink {{version}}.',
viewRelease: 'Просмотреть релиз',
sleepPreventionFailed: 'Не удалось обновить предотвращение перехода в спящий режим: {{detail}}',
schedulerNoQueues: 'В планировщике не выбраны действительные очереди. Обновите настройки планировщика.',
schedulerPauseOneFailed: 'Планировщик не смог приостановить 1 загрузку.',
schedulerPauseManyFailed: 'Планировщик не смог приостановить {{count}} загрузок.',
scheduledIncomplete: 'Не все запланированные загрузки были завершены. Системное действие после завершения очереди пропущено.',
scheduledActionSkippedActive: 'Запланированное системное действие пропущено, так как есть активная или находящаяся в очереди загрузка.',
notificationsDisabled: 'Системные уведомления отключены для Firelink.',
notificationsFailed: 'Не удалось настроить уведомления: {{detail}}',
},
sidebar: {
deleteQueueConfirm: 'Удалить очередь «{{name}}»? Её незавершённые загрузки будут перемещены в Главную очередь.',
deleteQueueFailed: 'Не удалось удалить очередь: {{detail}}',
queueNameEmpty: 'Название очереди не может быть пустым',
queueNameExists: 'Очередь с таким названием уже существует',
startQueueFailed: 'Не удалось запустить очередь: {{detail}}',
pauseQueueFailed: 'Не удалось приостановить очередь: {{detail}}',
},
downloadTable: {
unknownQueue: 'Неизвестная очередь',
allDownloads: 'Все загрузки',
active: 'Активные',
completed: 'Завершённые',
unfinished: 'Незавершённые',
headers: {
fileName: 'Имя файла',
size: 'Размер',
status: 'Статус',
speed: 'Скорость',
eta: 'Осталось',
dateAdded: 'Дата добавления',
},
columnOptions: {
open: 'Параметры столбца «{{column}}»',
dragToReorder: 'Перетащите для изменения порядка столбцов',
alignContent: 'Выравнивание содержимого',
left: 'По левому краю',
center: 'По центру',
right: 'По правому краю',
resetLayout: 'Сбросить макет столбцов',
},
addDownload: 'Добавить загрузку',
resumeAll: 'Возобновить все',
pauseAll: 'Приостановить все',
queueEmpty: 'Очередь пуста',
noCompletedDownloads: 'Нет завершённых загрузок',
noDownloads: 'Нет загрузок',
queueEmptyDescription: 'Добавьте загрузки в эту очередь из меню элемента или окна добавления.',
completedDescription: 'Завершённые загрузки будут отображаться здесь.',
clickToAdd: 'Нажмите кнопку',
addButtonOr: 'или',
toAddDownloads: 'чтобы добавить загрузки',
nonResumableOne: '1 загрузка не поддерживает возобновление. Если вы приостановите её, позже придётся начинать скачивание заново. Вы действительно хотите приостановить?',
nonResumableMany: '{{count}} загрузок не поддерживают возобновление. Если вы приостановите их, позже придётся начинать скачивание заново. Вы действительно хотите приостановить?',
interactionError: '{{message}}: {{detail}}',
openFileFailed: 'Не удалось открыть скачанный файл',
revealFileFailed: 'Не удалось показать файл в Finder',
pauseFailed: 'Не удалось приостановить загрузку',
resumeFailed: 'Не удалось возобновить {{fileName}}',
moveManyFailed: 'Не удалось переместить загрузки в очередь',
moveOneFailed: 'Не удалось переместить загрузку в очередь',
copyAddressesFailed: 'Не удалось скопировать адреса',
copyAddressFailed: 'Не удалось скопировать адрес',
copyPathFailed: 'Не удалось скопировать путь к файлу',
missingFileName: 'Отсутствует имя файла',
redownloadFailed: 'Не удалось скачать повторно',
startResume: 'Запустить/Возобновить',
addToQueue: 'Добавить в очередь',
copyAddress: 'Скопировать адрес',
remove: 'Удалить',
open: 'Открыть',
showInFolder: 'Показать в папке',
pause: 'Приостановить',
start: 'Запустить',
resume: 'Возобновить',
redownload: 'Скачать повторно',
copyFilePath: 'Скопировать путь к файлу',
properties: 'Свойства',
backendRejectedStart: 'Фоновый модуль отклонил запрос на запуск/возобновление.',
transferActive: 'Нельзя изменять свойства во время активной передачи. Сначала приостановите её.',
redownloadNotFound: 'Невозможно скачать повторно: загрузка не найдена.',
redownloadActive: 'Невозможно повторно скачать загрузку в статусе «{{status}}». Сначала приостановите её или дождитесь завершения.',
originalUrlMissing: 'Невозможно скачать повторно: исходный URL отсутствует.',
pauseBeforeMove: 'Приостановите {{file}} перед перемещением в другую очередь.',
},
addDownloads: {
unknownSize: 'Неизвестный размер',
unknown: 'Неизвестно',
discardSetup: 'Отменить настройку этой загрузки?',
speedInvalid: 'Ограничение скорости должно быть больше нуля',
duplicateResolveFailed: 'Не удалось разрешить дубликаты загрузок: {{detail}}',
addedWithFailures: 'Добавлено: {{added}}, с ошибкой: {{failed}}. {{detail}}',
addedOne: 'Добавлена 1 загрузка',
addedMany: 'Добавлено {{count}} загрузок',
updatedOne: 'Обновлена 1 загрузка',
updatedMany: 'Обновлено {{count}} загрузок',
urlAlreadyQueued: 'URL уже находится в очереди',
destinationConflict: 'Другая выбранная загрузка использует это место назначения',
existingDownloadDestination: 'Существующая загрузка Firelink использует эту папку назначения',
matchingDownloadFilename: 'Незавершённая загрузка уже использует это имя файла',
fileExistsOnDisk: 'Файл уже существует на диске; переименуйте или пропустите его, чтобы не удалить сторонние данные',
backendRejectedStart: 'Фоновый модуль отклонил запуск загрузки.',
playlistNoEntries: 'Плейлист не содержит доступных элементов',
invalidMediaMetadata: 'Некорректные метаданные медиа или форматы не найдены',
noAvailableName: 'Не удалось найти доступное имя для {{file}}.',
pauseBeforeReplace: 'Приостановите {{file}} перед заменой.',
cannotReplace: 'Невозможно заменить {{file}}: файл не принадлежит загрузке Firelink.',
downloadLinks: 'Ссылки для скачивания',
pastePlaceholder: 'Вставьте сюда URL-адреса HTTP, HTTPS, FTP или SFTP…\n\nДля загрузки медиа вставляйте ссылки с YouTube, X, TikTok, Instagram, Reddit и т. д.',
playlistSummary: 'Плейлист «{{title}}»: загружено {{loaded}} из {{total}} элементов{{truncated}}{{skipped}}',
safeEntryLimit: ' (достигнут безопасный лимит элементов)',
selectedSummary: 'Выбрано: {{ready}} готовых, {{fallback}} с резервными данными, {{mediaRetry}} повторов медиа, {{blocked}} заблокировано',
clearSelection: 'Очистить выбор',
selectAll: 'Выбрать все',
refreshMetadata: 'Обновить метаданные',
files: 'Файлы',
required: 'Требуется',
free: 'Свободно',
preview: 'Предпросмотр',
file: 'Файл',
size: 'Размер',
status: 'Статус',
downloadPreview: 'Предпросмотр загрузки',
noLinks: 'Ссылки ещё не добавлены.',
selectItem: 'Выбрать {{file}}',
fetchingPlaylist: 'Получение плейлиста…',
fetching: 'Получение…',
unsafeUrl: 'Небезопасный URL',
playlistFailed: 'Ошибка плейлиста',
metadataFailed: 'Ошибка метаданных',
fallback: 'Резервный режим',
invalid: 'Некорректный',
ready: 'Готово',
mediaFormat: 'Формат медиа',
playlistItem: 'Элемент плейлиста {{index}}',
fetchingMediaStreams: 'Получение медиапотоков…',
availableStreams: 'Доступные потоки',
availableMediaStreams: 'Доступные медиапотоки',
metadataUnavailable: 'Метаданные недоступны. Обновите метаданные перед добавлением этого медиа.',
saveLocation: 'Папка сохранения',
browse: 'Обзор…',
categoryFolders: 'Файлы будут распределены по папкам категорий.',
sharedFolder: 'Все выбранные загрузки будут использовать эту папку.',
dedicatedFolder: 'Сохранить в новую папку',
dedicatedFolderName: 'Имя папки',
dedicatedFolderDescription: 'Создаёт эту папку внутри каждой автоматической папки категории.',
dedicatedFolderManualDescription: 'Создаёт эту папку внутри выбранного места сохранения.',
saveFolderName: 'Имя папки сохранения',
dedicatedFolderNameRequired: 'Введите имя папки перед сохранением.',
transferSettings: 'Настройки передачи',
connectionsPerFile: 'Соединений на файл',
connectionsPerFileAria: 'Соединений на файл',
limitSpeedPerFile: 'Ограничить скорость на файл',
speedLimitPerFileAria: 'Ограничение скорости на файл',
authorization: 'Авторизация',
useAuthorization: 'Использовать авторизацию',
username: 'Имя пользователя',
password: 'Пароль',
advancedTransfer: 'Дополнительные параметры',
verifyChecksum: 'Проверять контрольную сумму',
checksumAlgorithm: 'Алгоритм контрольной суммы',
expectedDigest: 'Ожидаемый хеш',
headers: 'Заголовки',
requestHeaders: 'Заголовки запроса',
cookies: 'Файлы cookie',
cookiePlaceholder: 'name=value; other=value',
mirrors: 'Зеркала',
cancel: 'Отмена',
startDownloads: 'Начать загрузку',
addToQueue: 'Добавить в очередь',
pasteOneOrMore: 'Вставьте одну или несколько ссылок.',
selectAtLeastOne: 'Выберите хотя бы одну загрузку.',
correctInvalidOne: 'Исправьте или удалите {{count}} некорректный URL-адрес перед продолжением.',
correctInvalidFew: 'Исправьте или удалите {{count}} некорректных URL-адреса перед продолжением.',
correctInvalidMany: 'Исправьте или удалите {{count}} некорректных URL-адресов перед продолжением.',
waitingForMetadataOne: 'Ожидание метаданных для {{count}} загрузки…',
waitingForMetadataFew: 'Ожидание метаданных для {{count}} загрузок…',
waitingForMetadataMany: 'Ожидание метаданных для {{count}} загрузок…',
removeUnsafeOne: 'Удалите {{count}} небезопасный URL-адрес перед продолжением.',
removeUnsafeFew: 'Удалите {{count}} небезопасных URL-адреса перед продолжением.',
removeUnsafeMany: 'Удалите {{count}} небезопасных URL-адресов перед продолжением.',
mediaMetadataUnavailableSummaryOne: 'Метаданные медиа недоступны для {{count}} элемента. Обновите метаданные перед добавлением.',
mediaMetadataUnavailableSummaryFew: 'Метаданные медиа недоступны для {{count}} элементов. Обновите метаданные перед добавлением.',
mediaMetadataUnavailableSummaryMany: 'Метаданные медиа недоступны для {{count}} элементов. Обновите метаданные перед добавлением.',
metadataUnavailableFallback: 'Метаданные недоступны. Загрузки всё равно можно добавить с резервными данными.',
fallbackReadyOne: 'Готова {{ready}} загрузка; для {{failed}} будет использовано резервное имя файла и неизвестный размер.',
fallbackReadyFew: 'Готовы {{ready}} загрузки; для {{failed}} будет использовано резервное имя файла и неизвестный размер.',
fallbackReadyMany: 'Готово {{ready}} загрузок; для {{failed}} будет использовано резервное имя файла и неизвестный размер.',
readyToAddOne: 'Готово к добавлению: {{count}} загрузка.',
readyToAddFew: 'Готово к добавлению: {{count}} загрузки.',
readyToAddMany: 'Готово к добавлению: {{count}} загрузок.',
},
settings: {
tabs: {
downloads: 'Загрузки',
lookAndFeel: 'Внешний вид',
network: 'Сеть',
locations: 'Папки',
siteLogins: 'Учётные данные',
power: 'Питание',
engine: 'Движок',
integrations: 'Интеграции',
about: 'О программе',
},
common: {
thisOs: 'этой ОС',
checking: 'Проверка…',
ready: 'Готово',
errorMissing: 'Ошибка / Отсутствует',
hideTechnicalDetails: 'Скрыть технические детали',
showTechnicalDetails: 'Показать технические детали',
binary: 'Исполняемый файл',
expected: 'Ожидается',
error: 'Ошибка',
tip: 'Совет',
stderr: 'stderr',
daemonProcessAlive: 'Процесс фоновой службы активен',
rpcReady: 'RPC готов',
rpcPort: 'Порт RPC',
daemonStderr: 'stderr фоновой службы',
packaging: 'Упаковка: PyInstaller onedir (требуется _internal)',
internalDirectoryFound: 'Каталог _internal найден',
pythonRuntimeFound: 'Среда выполнения Python найдена',
externalOpenFailed: 'Не удалось открыть {{label}}: {{detail}}',
updateUnexpectedResponse: 'Проверка обновлений вернула неожиданный ответ.',
updateUntrustedReleaseUrl: 'Проверка обновлений вернула ненадёжный URL релиза.',
updateFailed: 'Ошибка проверки обновлений: {{detail}}',
},
downloads: {
defaultConnections: 'Соединения по умолчанию:',
defaultConnectionsDescription: 'Для новых загрузок; существующие элементы сохраняют своё значение',
parallelDownloads: 'Параллельные загрузки:',
parallelDownloadsDescription: 'Максимальное число одновременно активных файлов',
automaticRetries: 'Автоматические повторы:',
automaticRetriesDescription: 'При сбое соединения',
systemNotification: 'Показывать системное уведомление по завершении загрузки',
systemNotificationDescription: 'Использует настройки уведомлений вашей операционной системы',
completionChime: 'Воспроизводить звуковой сигнал завершения в приложении',
completionChimeDescription: 'Дополнительный звук Firelink, независимый от системных уведомлений',
clipboardLinks: 'Добавлять ссылки из буфера обмена при активации Firelink',
clipboardLinksDescription: 'Открывает скопированные поддерживаемые ссылки в окне добавления. По умолчанию отключено.',
},
lookAndFeel: {
language: 'Язык',
languageDescription: 'Выберите язык интерфейса Firelink.',
sidebarPosition: 'Положение боковой панели',
sidebarPositionDescription: 'Выберите, где отображается боковая панель. В автоматическом режиме следует направлению интерфейса.',
sidebarPositionAutomatic: 'Автоматически (рекомендуется)',
sidebarPositionLeft: 'Слева',
sidebarPositionRight: 'Справа',
languageSystem: 'Системный по умолчанию',
languageEnglish: 'Английский',
languageChinese: 'Упрощённый китайский',
languageHebrew: 'Иврит',
languagePersian: 'Персидский',
languageUkrainian: 'Украинский',
languageRussian: 'Русский',
appTheme: 'Тема приложения',
theme: 'Тема',
ariaLabel: 'Тема приложения',
system: 'Системная',
light: 'Светлая',
dark: 'Тёмная',
dracula: 'Dracula',
nord: 'Nord',
systemAppearance: 'Системная тема соответствует текущему светлому или тёмному оформлению {{platform}}.',
display: 'Отображение',
fontSize: 'Размер шрифта',
fontSizeDescription: 'Масштабирует список загрузок и компактные элементы управления.',
small: 'Мелкий',
standard: 'Стандартный',
large: 'Крупный',
listDensity: 'Плотность списка',
listDensityDescription: 'Изменяет высоту строк, отступы и масштаб индикатора прогресса.',
compact: 'Компактный',
comfortable: 'Удобный',
relaxed: 'Просторный',
osIntegration: 'Интеграция с ОС',
dockBadge: 'Показывать счётчик на значке в Dock',
dockBadgeDescription: 'Отображает количество активных загрузок на значке Firelink в Dock.',
menuBarIcon: 'Показывать значок в строке меню',
statusIndicatorIcon: 'Показывать значок состояния',
systemTrayIcon: 'Показывать значок в системном трее',
macosTrayDescription: 'Обеспечивает быстрый доступ из строки меню macOS.',
windowsTrayDescription: 'Обеспечивает быстрый доступ из области уведомлений Windows.',
linuxTrayDescription: 'Обеспечивает быстрый доступ из трея рабочего стола или области состояния Linux (при наличии).',
defaultTrayDescription: 'Обеспечивает быстрый доступ из области системного трея ОС (при наличии).',
},
network: {
proxy: 'Прокси',
mode: 'Режим',
modeDescription: 'Управляет использованием прокси для новых запросов на загрузку.',
noProxy: 'Без прокси',
systemProxy: 'Системный прокси',
customProxy: 'Собственный прокси',
proxyHost: 'Хост прокси',
proxyHostDescription: 'Имя хоста, IP-адрес или URL HTTP-прокси.',
proxyHostPlaceholder: '127.0.0.1 или http://127.0.0.1',
proxyPort: 'Порт прокси',
proxyPortDescription: 'Допустимый диапазон от 1 до 65535.',
noProxyDescription: 'Загрузки игнорируют настроенные прокси.',
systemProxyDescription: 'Загрузки используют обнаруженный системный прокси {{platform}}. Для обычных загрузок файлов требуется HTTP- или HTTPS-прокси; для загрузки медиа можно использовать SOCKS.',
customProxyDescription: 'Загрузки используют настроенный HTTP-прокси для получения метаданных и движков скачивания.',
invalidCustomProxy: 'Введите корректный хост и порт HTTP-прокси, чтобы включить собственный прокси.',
incompleteCustomProxy: 'Введите хост и порт прокси, чтобы включить собственный прокси.',
checkingSystemProxy: 'Проверка конфигурации системного прокси…',
detectedSystemProxy: 'Обнаружен системный прокси. Для обычных загрузок файлов требуется HTTP- или HTTPS-прокси; для загрузки медиа можно использовать SOCKS.',
noSystemProxy: 'Подходящий системный прокси не обнаружен. Загрузки будут выполняться без прокси.',
systemProxyReadFailed: 'Не удалось прочитать конфигурацию системного прокси. Выберите «Без прокси» или повторите попытку, когда она станет доступна.',
identity: 'Идентификация',
customUserAgent: 'Собственный User-Agent',
userAgentDescription: 'Применяется при получении метаданных и работе движков загрузки.',
userAgentPlaceholder: 'Оставьте пустым, чтобы использовать значение Firelink по умолчанию',
userAgentOverrides: 'Переопределяет исходящий заголовок User-Agent. Оставьте пустым, чтобы использовать значения Firelink по умолчанию.',
chromeWindows: 'Chrome (Windows)',
chromeMacos: 'Chrome (macOS)',
edgeWindows: 'Edge (Windows)',
firefoxWindows: 'Firefox (Windows)',
firefoxMacos: 'Firefox (macOS)',
safariMacos: 'Safari (macOS)',
windowsDesktop: 'Windows (ПК)',
macosDesktop: 'macOS (ПК)',
},
locations: {
baseDownloadFolder: 'Основная папка загрузок',
baseDownloadFolderDescription: 'Автоматические папки категорий создаются внутри этой папки.',
browse: 'Обзор…',
baseFolderCreateFailed: 'Основная папка сохранена, но не удалось создать папки категорий: {{detail}}',
baseFolderUpdated: 'Основная папка загрузок обновлена',
askWhereToSave: 'Запрашивать место сохранения при добавлении загрузок',
rememberLastUsedDownloadDirectory: 'Запоминать последнюю использованную папку загрузки',
rememberLastUsedDownloadDirectoryDescription: 'Использовать последнюю папку, выбранную в окне добавления, для следующей загрузки до перезапуска приложения.',
automaticCategorySubfolders: 'Автоматически сохранять файлы в подпапки категорий',
automaticCategorySubfoldersDescription: 'Если отключено, автоматические загрузки используют основную папку.',
categorySubfolders: 'Подпапки категорий',
relativeToBase: 'Относительно основной папки',
disabled: 'Отключено',
categorySubfolder: 'Подпапка «{{category}}»',
customFolder: 'Собственная папка',
useAutomatic: 'Автоматически',
resetDefaults: 'Сбросить по умолчанию',
resetToast: 'Настройки папок категорий сброшены по умолчанию',
},
siteLogins: {
title: 'Учётные данные сайтов',
noSavedLogins: 'Нет сохранённых логинов.',
user: 'Пользователь: {{username}}',
deleteCredential: 'Удалить учётные данные',
deletedCredential: 'Учётные данные удалены',
deleteFailed: 'Не удалось удалить учётные данные: {{detail}}',
addTitle: 'Добавить учётные данные сайта',
urlPattern: 'Шаблон URL:',
urlPatternPlaceholder: 'например *.example.com или example.com/downloads',
username: 'Имя пользователя:',
password: 'Пароль:',
usernamePlaceholder: 'Имя пользователя',
passwordPlaceholder: 'Пароль',
saving: 'Сохранение…',
addLogin: 'Добавить логин',
urlPatternRequired: 'Требуется указать шаблон URL.',
urlPatternWhitespace: 'Шаблон URL не может содержать пробелы.',
usernameRequired: 'Требуется указать имя пользователя.',
passwordRequired: 'Требуется указать пароль.',
accessBeforeSave: 'Предоставьте доступ к хранилищу учётных данных перед сохранением логина сайта.',
saveFailed: 'Не удалось безопасно сохранить учётные данные сайта.',
addedCredential: 'Добавлены учётные данные сайта',
},
power: {
title: 'Управление питанием',
preventSleep: 'Запрещать спящий режим системы во время активных загрузок',
preventSleepDescription: 'Экран всё ещё может выключаться. Firelink предотвращает засыпание устройства только настолько, чтобы завершить активные передачи.',
},
engine: {
title: 'Загрузчик медиа и движки',
description: 'Успешные результаты используются повторно в течение сеанса приложения. Повторная проверка запускает реальную валидацию заново.',
recheck: 'Перепроверить движки',
coreDownloader: 'Основной загрузчик (aria2)',
version: 'Версия:',
status: 'Статус:',
mediaExtractors: 'Экстракторы медиа',
browserCookiesSource: 'Источник файлов cookie браузера:',
cookieDescription: 'yt-dlp читает файлы cookie браузера для обхода ограничений на загрузку видео или доступа к закрытым медиа. Firelink не сохраняет файлы cookie браузера.',
none: 'Не использовать',
},
integrations: {
title: 'Подключение расширения браузера',
description: 'Перехватывайте загрузки прямо из браузера за три простых шага.',
portablePairingEnabled: 'Портативное сопряжение включено',
pairingTokenPersisted: 'Токен сопряжения сохранён',
portablePersistedDescription: 'Ваш токен сопряжения хранится в этой портативной папке Firelink и сохранится при перемещении папки. Относитесь к папке как к конфиденциальным данным.',
systemPersistedDescription: 'Ваш токен сопряжения сохранён в системном хранилище учётных данных и сохранится после перезапуска.',
portablePairingAvailable: 'Доступно портативное сопряжение',
credentialStorageNeeded: 'Требуется доступ к хранилищу учётных данных',
portableAvailableDescription: 'Ваш токен сопряжения хранится в этой портативной папке Firelink и сохранится после перезапуска. Включите его здесь, чтобы ознакомиться с предупреждением о портативном хранилище.',
credentialStorageDescription: 'Firelink требуется доступ к системному хранилищу учётных данных для безопасного сохранения токена сопряжения между перезапусками приложения. В настоящее время ваше расширение будет оставаться подключённым только на время этого сеанса.',
reviewPortablePairing: 'Проверить портативное сопряжение',
grantCredentialAccess: 'Предоставить доступ к хранилищу',
copyToken: 'Скопировать токен',
tokenDescription: 'Этот защищённый токен авторизует ваше расширение браузера.',
tokenCopied: 'Токен скопирован в буфер обмена!',
tokenCopyFailed: 'Не удалось скопировать токен: {{detail}}',
pairingTokenRegenerated: 'Токен сопряжения сгенерирован заново',
regenerateFailed: 'Не удалось сгенерировать токен сопряжения заново: {{detail}}',
getExtension: 'Получить расширение',
extensionDescription: 'Установите Firelink Companion для браузеров Firefox или Chromium.',
firefoxAddons: 'Дополнения Firefox',
chromiumZip: 'Chromium ZIP',
pasteConnect: 'Вставить и подключить',
pasteConnectDescription: 'Нажмите на значок Firelink на панели инструментов браузера и вставьте скопированный токен.',
extensionServerStatus: 'Статус сервера расширения:',
listening: '● Прослушивание 127.0.0.1:{{port}}',
unavailable: '● Сервер недоступен',
},
about: {
firelinkIcon: 'Значок Firelink',
unknown: 'Неизвестно',
version: 'Версия {{version}}',
description: 'Кроссплатформенный менеджер загрузок для macOS, Windows и Linux.',
sourceCode: 'Исходный код',
source: 'Исходный код',
upToDate: 'У вас установлена актуальная версия Firelink ({{version}}).',
updateAvailable: 'Доступна новая версия Firelink {{version}}.',
viewRelease: 'Просмотреть релиз',
updates: 'Обновления',
checkForUpdates: 'Проверить обновления',
updateDescription: 'Firelink проверяет наличие новых версий в GitHub Releases.',
checking: 'Проверка…',
checkNow: 'Проверить сейчас',
automaticUpdates: 'Автоматически проверять наличие обновлений',
createdBy: 'Разработчик: NimBold',
mitLicense: 'Лицензия MIT',
credits: 'Благодарности: aria2, yt-dlp, FFmpeg и Deno.',
copyright: 'Copyright © 2026 NimBold. Все права защищены.',
},
},
status: {
ready: 'Готово',
active: '{{count}} активных',
queued: '{{count}} в очереди',
done: '{{count}} завершено',
},
} as const;
export default ru;
+755
View File
@@ -0,0 +1,755 @@
const uk = {
navigation: {
library: 'Бібліотека',
filters: {
all: 'Усі',
active: 'Активні',
completed: 'Завершені',
unfinished: 'Незавершені',
},
categories: {
musics: 'Музика',
movies: 'Відео',
compressed: 'Архіви',
documents: 'Документи',
pictures: 'Зображення',
applications: 'Програми',
other: 'Інше',
},
folders: 'Папки',
queues: 'Черги',
tools: 'Інструменти',
scheduler: 'Планувальник',
speedLimiter: 'Обмеження швидкості',
logs: 'Журнали',
settings: 'Налаштування',
},
actions: {
hideSidebar: 'Сховати бічну панель',
showSidebar: 'Показати бічну панель',
resizeSidebar: 'Змінити розмір бічної панелі',
cancel: 'Скасувати',
continue: 'Продовжити',
dismissNotification: 'Закрити сповіщення',
queueName: 'Назва черги',
addNewQueue: 'Додати нову чергу',
startQueue: 'Запустити чергу',
pauseQueue: 'Призупинити чергу',
renameQueue: 'Перейменувати чергу',
deleteQueue: 'Видалити чергу',
},
dialogs: {
errorBoundary: {
title: 'Щось пішло не так.',
description: 'Сталася критична помилка в дереві компонентів React. Наведені нижче деталі помилки можуть допомогти виявити першопричину.',
reload: 'Перезавантажити програму',
},
duplicateDownloads: {
title: 'Виявлено дублікати',
description: 'Деякі із завантажень, які ви додаєте, вже існують у черзі або на диску. Будь ласка, виберіть, як вирішити ці конфлікти.',
rename: 'Перейменувати',
replace: 'Замінити',
skip: 'Пропустити',
},
removeDownload: {
title: 'Видалити завантаження',
confirmationSingle: 'Ви впевнені, що хочете видалити цей елемент зі списку? Ви також можете видалити сам файл з вашого диска.',
confirmationMultiple: 'Ви впевнені, що хочете видалити вибрані елементи ({{count}}) зі списку? Ви також можете видалити самі файли з диска.',
errorSummary: '{{succeeded}} видалено, {{failed}} не вдалося: {{detail}}',
remove: 'Видалити',
deleteFile: 'Видалити файл',
},
},
window: {
controls: 'Керування вікном',
close: 'Закрити',
minimize: 'Згорнути',
maximize: 'Розгорнути',
},
downloads: {
actions: {
moveUp: 'Перемістити вгору',
moveDown: 'Перемістити вниз',
pause: 'Призупинити',
start: 'Запустити',
resume: 'Відновити',
options: 'Опції',
},
size: {
downloadedOf: '{{downloaded}} завантажено з {{total}} {{unit}}',
downloadedOfApproximate: '{{downloaded}} завантажено з приблизно {{total}} {{unit}}',
},
status: {
ready: 'Готово',
staged: 'У черзі',
queued: 'У черзі',
downloading: 'Завантаження',
processing: 'Обробка',
paused: 'Призупинено',
completed: 'Завершено',
failed: 'Помилка',
retrying: 'Повторна спроба',
},
values: {
processing: 'Обробка…',
muxing: 'Об\'єднання потоків…',
},
},
speedLimiter: {
title: 'Обмеження швидкості',
unlimited: 'Необмежено',
saveLimit: 'Зберегти обмеження',
globalSpeedLimit: 'Глобальне обмеження швидкості',
description: 'Застосовується до нових і активних завантажень aria2 та нових медіазавантажень yt-dlp. Індивідуальні обмеження для кожного завантаження залишаються прикріпленими до цих завантажень.',
quickPresets: 'Швидкі пресети',
removePreset: 'Видалити пресет {{value}} {{unit}}',
customPresetIn: 'Свій пресет у {{unit}}',
addQuickPreset: 'Додати швидкий пресет',
globalLimitSaved: 'Глобальне обмеження збережено на рівні {{value}} {{unit}}',
globalLimitDisabled: 'Глобальне обмеження швидкості вимкнено',
saveFailed: 'Не вдалося зберегти глобальне обмеження швидкості: {{detail}}',
presetAlreadyExists: '{{value}} {{unit}} вже є у швидких пресетах',
presetAdded: 'Додано швидкий пресет {{value}} {{unit}}',
presetRemoved: 'Видалено швидкий пресет {{value}} {{unit}}',
},
logs: {
title: 'Журнали',
entries: '({{count}} записів)',
collecting: 'Збір даних',
off: 'Вимкнено',
allLevels: 'Усі рівні',
levels: {
error: 'Помилка',
warn: 'Попередження',
info: 'Інфо',
debug: 'Налагодження',
trace: 'Трасування',
},
pauseLogging: 'Призупинити діагностичне логування',
enableLogging: 'Увімкнути діагностичне логування',
clearDisplayed: 'Очистити відображені журнали',
export: 'Експортувати журнали',
exportButton: 'Експортувати журнали',
logFiles: 'Файли журналів',
localDiagnostics: 'Локальна діагностика:',
diagnosticsDescription: 'Збір діагностичних даних є добровільним, обмеженим і локальним. Звичайні секрети, запити URL та шляхи до домашнього каталогу приховуються під час відображення та експорту. Нічого не завантажується автоматично.',
noEntries: 'Збережених записів журналів поки немає.',
disabledDescription: 'Діагностичне логування вимкнено. Існуючі журнали підтримки з\'являться тут, коли будуть доступні.',
copy: 'Копіювати',
copied: 'Скопійовано в буфер обміну',
copyFailed: 'Не вдалося скопіювати в буфер обміну',
exported: 'Журнали підтримки експортовано',
exportFailed: 'Не вдалося експортувати журнали: {{detail}}',
cleared: 'Журнали очищено',
clearFailed: 'Не вдалося очистити журнали: {{detail}}',
enabled: 'Діагностичне логування увімкнено',
disabled: 'Діагностичне логування вимкнено',
updateFailed: 'Не вдалося оновити діагностичне логування: {{detail}}',
},
scheduler: {
title: 'Планувальник',
running: 'Працює',
disabled: 'Планувальник вимкнено',
noScheduledDay: 'Не вибрано запланованого дня',
unsavedChanges: 'Незбережені зміни',
runNow: 'Запустити зараз',
pause: 'Призупинити',
saveSettings: 'Зберегти налаштування',
timing: 'Час',
startTime: 'Час початку',
stopTime: 'Час зупинки',
timingDescription: 'Якщо Firelink знаходиться в режимі сну на момент початку, він запустить вибрані черги після повернення пізніше того ж дня, якщо час зупинки ще не минув.',
runEveryDay: 'Запускати щодня',
queuesToSchedule: 'Черги для планування',
defaultQueue: 'Черга за замовчуванням',
afterCompletion: 'Після завершення',
afterCompletionDescription: 'Виберіть, що станеться після завершення завантажень, розпочатих планувальником.',
actionWarning: 'Ця дія може перервати іншу роботу на комп\'ютері. Firelink викликає її відразу після завершення запланованої черги.',
systemPermissions: 'Системні дозволи',
macPermissionDescription: 'Сон, перезавантаження та вимкнення вимагають дозволу на автоматизацію macOS для системних подій (System Events).',
permissionGranted: 'Дозвіл на автоматизацію надано',
permissionChecking: 'Перевірка дозволу на автоматизацію…',
permissionMissing: 'Відсутній дозвіл на автоматизацію',
revokePermission: 'Відкликати дозвіл',
grantPermission: 'Надати дозвіл',
permissionAvailable: 'Дозвіл на автоматизацію доступний.',
permissionMissingDetails: 'Відсутній дозвіл на автоматизацію. Увімкніть Firelink у розділі Автоматизація для системних подій у Налаштуваннях системи.',
revokePermissionDetails: 'macOS не дозволяє Firelink безпосередньо відкликати дозвіл на автоматизацію. Відкличте доступ до системних подій у Налаштуваннях системи, а потім поверніться до Firelink.',
requestingPermission: 'Запит дозволу на автоматизацію…',
enablePermissionDetails: 'Увімкніть Firelink у розділі Автоматизація для системних подій у Налаштуваннях системи, а потім поверніться до Firelink.',
systemActions: 'Системні дії',
windowsActionsDescription: 'Сон, перезавантаження та вимкнення використовують системні привілеї Windows. Firelink повідомляє про будь-яку відхилену дію під час її виконання; жодні постійні дозволи заздалегідь не вимагаються.',
linuxActionsDescription: 'Сон, перезавантаження та вимкнення використовують ваш робочий стіл Linux та системну політику. Firelink повідомляє про будь-яку відхилену дію під час її виконання; жодні постійні дозволи заздалегідь не вимагаються.',
validationDay: 'Виберіть принаймні один день для планувальника',
validationQueue: 'Виберіть принаймні одну чергу для планувальника',
validationStopTime: 'Час зупинки має бути пізнішим за час початку',
saved: 'Налаштування планувальника збережено',
trackingOne: 'Відстеження 1 запланованого завантаження',
trackingMany: 'Відстеження {{count}} запланованих завантажень',
noStartableDownloads: 'Жодне завантаження у вибраних чергах не може бути запущено',
pausedOne: 'Призупинено 1 активне завантаження',
pausedMany: 'Призупинено {{count}} активних завантажень',
noActiveDownloads: 'Немає активних завантажень',
days: {
su: 'Нд',
mo: 'Пн',
tu: 'Вв',
we: 'Ср',
th: 'Чт',
fr: 'Пт',
sa: 'Сб',
},
postActions: {
none: 'Нічого не робити',
sleep: 'Сон',
restart: 'Перезавантажити',
shutdown: 'Вимкнути',
},
},
properties: {
progress: 'Прогрес',
size: 'Розмір',
speed: 'Швидкість',
eta: 'Залишилось',
connections: 'З\'єднання',
connectionCount: '{{active}}/{{total}} активних',
connectionCountUnknown: '—/{{total}} активних',
connectionsUnavailable: '—',
speedCap: 'Обмеження швидкості',
category: 'Категорія',
lastTry: 'Остання спроба',
dateAdded: 'Дата додавання',
destination: 'Призначення',
lastError: 'Остання помилка',
saved: ' (збережено)',
defaultValue: ' (за замовчуванням)',
savedTooltip: 'Збережено для цього завантаження; зміни в налаштуваннях застосовуються до нових завантажень.',
defaultTooltip: 'Використовується поточне значення за замовчуванням для нових завантажень.',
identityReadOnly: 'Ідентифікатор файлу доступний лише для читання. Налаштування передачі збережено для повторного завантаження.',
transferSettings: 'Налаштування передачі можна змінити після зупинки або призупинення. Поточні передачі зберігають свої існуючі налаштування бекенду.',
download: 'Завантаження',
url: 'URL',
fileName: 'Ім\'я файлу',
saveLocation: 'Місце збереження',
select: 'Вибрати',
perFile: 'на файл',
useCurrentDefault: 'Використовувати поточне за замовчуванням ({{count}})',
savedPerDownload: 'Збережено для кожного завантаження. Значення за замовчуванням з Налаштувань застосовується до нових завантажень.',
limit: 'Обмеження',
siteLogin: 'Вхід на сайт',
siteLoginRedownload: 'Вхід на сайт для повторного завантаження',
matchingSiteLogin: 'Відповідний логін сайту',
customCredentials: 'Власні облікові дані',
noLogin: 'Без входу',
useSavedLogin: 'Буде використано збережений логін, якщо доступний.',
advancedTransfer: 'Розширені параметри передачі',
advancedTransferRedownload: 'Розширені параметри передачі для повторного завантаження',
checksum: 'Контрольна сума',
verify: 'Перевірити',
algorithm: 'Алгоритм',
digest: 'Хеш',
expectedDigest: 'Очікуваний хеш',
cookies: 'Файли cookie',
headers: 'Заголовки',
mirrors: 'Дзеркала',
username: 'Ім\'я користувача',
password: 'Пароль',
enterValidUrl: 'Введіть дійсну URL-адресу.',
fileNameEmpty: 'Ім\'я файлу не може бути порожнім.',
cancel: 'Скасувати',
save: 'Зберегти',
},
keychain: {
title: 'Потрібен доступ до сховища облікових даних',
stores: {
portable: 'портативна папка даних Firelink',
windows: 'Диспетчер облікових даних Windows',
linux: 'ваше сховище облікових даних Linux',
macos: 'В\'язка ключів macOS (Keychain)',
system: 'сховище облікових даних цієї системи',
siteCredentials: 'сховище облікових даних системи',
},
grantLabelPortable: 'Продовжити',
grantLabelMacos: 'Надати доступ',
grantLabelDefault: 'Увімкнути безпечне сховище',
description: 'Firelink використовує розширення браузера для захоплення завантажень. Щоб зберегти розширення підключеним після перезапуску, Firelink зберігає свій токен підключення у {{pairingStore}}. Необов\'язкові облікові дані сайтів зберігаються в {{siteCredentialStore}}.',
portableExplanation: 'Токен підключення є портативним і зберігається в цій папці. Облікові дані сайтів залишаються в системному сховищі; після надання доступу може з\'явитися системний запит.',
macosExplanation: 'macOS може показати запит до В\'язки ключів після того, як ви надасте доступ.',
defaultExplanation: 'Зазвичай це виконується у фоновому режимі. Якщо служба облікових даних недоступна, Firelink покаже помилку тут, а розширення залишиться підключеним лише для цього сеансу.',
note: 'Примітка:',
portableNote: 'У портативному режимі в цій папці зберігається лише токен підключення. Він не копіює паролі сайтів або облікові дані браузера.',
defaultNote: 'Firelink записує лише свій власний запис облікових даних. Він не має доступу до інших збережених паролів або елементів облікових даних у вашій системі.',
hint: 'Підказка:',
portableHint: 'Портативний токен підключення вже збережено в цій папці; ви можете увімкнути його тут або вибрати "Пізніше".',
defaultHint: 'Якщо ви виберете "Пізніше", розширення працюватиме лише для цього сеансу.',
enableFromSettings: 'Ви можете увімкнути сховище будь-коли в Налаштування > Інтеграції.',
later: 'Пізніше',
enabling: 'Увімкнення…',
timeout: 'Час очікування запиту до сховища облікових даних вичерпано. Ви можете вибрати "Пізніше" і спробувати ще раз.',
unavailable: '{{store}} недоступно.',
accessRequired: 'Надайте доступ до сховища облікових даних перед повторною генерацією токена підключення.',
storeUnavailable: 'Доступ до сховища облікових даних недоступний.',
},
app: {
loading: 'Завантаження…',
settingsSaveFailed: 'Не вдалося зберегти налаштування. Перевірте дозволи сховища та спробуйте ще раз.',
systemActionCountdown: '{{action}} через 10 секунд.',
systemActionCancelled: 'Системна дія скасована, оскільки активне або в черзі інше завантаження.',
systemActionFailed: 'Не вдалося виконати заплановану системну дію: {{detail}}',
downloadCompleteTitle: 'Завантаження завершено',
downloadCompleteBody: 'Завантаження {{fileName}} завершено.',
unknownFile: 'Файл',
downloadFailedTitle: 'Помилка завантаження',
downloadFailedBody: 'Не вдалося завантажити {{fileName}}.',
initializeDownloadsFailed: 'Не вдалося ініціалізувати збережені завантаження: {{detail}}',
extensionDisconnected: 'Розширення браузера відключено, оскільки його токен підключення змінився.',
copyToken: 'Скопіювати токен',
integrations: 'Інтеграції',
copyTokenFailed: 'Не вдалося скопіювати токен підключення: {{detail}}',
credentialPersistenceFailed: 'Безпечне збереження облікових даних недоступне. Підключення браузера працює лише для цього сеансу: {{detail}}',
resumeDownloadsFailed: 'Не вдалося відновити збережені завантаження: {{detail}}',
updateAvailable: 'Доступна нова версія Firelink {{version}}.',
viewRelease: 'Переглянути реліз',
sleepPreventionFailed: 'Не вдалося оновити запобігання переходу в сплячий режим: {{detail}}',
schedulerNoQueues: 'Для планувальника не вибрано жодної дійсної черги. Оновіть налаштування Планувальника.',
schedulerPauseOneFailed: 'Планувальник не зміг призупинити 1 завантаження.',
schedulerPauseManyFailed: 'Планувальник не зміг призупинити {{count}} завантажень.',
scheduledIncomplete: 'Заплановані завантаження не були завершені повністю. Системну дію після виконання черги пропущено.',
scheduledActionSkippedActive: 'Заплановану системну дію пропущено, оскільки інше завантаження активне або перебуває в черзі.',
notificationsDisabled: 'Системні сповіщення вимкнено для Firelink.',
notificationsFailed: 'Не вдалося налаштувати сповіщення: {{detail}}',
},
sidebar: {
deleteQueueConfirm: 'Видалити чергу "{{name}}"? Її незавершені завантаження перемістяться до Головної черги.',
deleteQueueFailed: 'Не вдалося видалити чергу: {{detail}}',
queueNameEmpty: 'Назва черги не може бути порожньою',
queueNameExists: 'Черга з такою назвою вже існує',
startQueueFailed: 'Не вдалося запустити чергу: {{detail}}',
pauseQueueFailed: 'Не вдалося призупинити чергу: {{detail}}',
},
downloadTable: {
unknownQueue: 'Невідома черга',
allDownloads: 'Усі завантаження',
active: 'Активні',
completed: 'Завершені',
unfinished: 'Незавершені',
headers: {
fileName: 'Ім\'я файлу',
size: 'Розмір',
status: 'Статус',
speed: 'Швидкість',
eta: 'Залишилось',
dateAdded: 'Дата додавання',
},
columnOptions: {
open: 'Параметри стовпця: {{column}}',
dragToReorder: 'Перетягніть, щоб змінити порядок стовпців',
alignContent: 'Позиція вмісту',
left: 'Ліворуч',
center: 'По центру',
right: 'Праворуч',
resetLayout: 'Скинути макет стовпців',
},
addDownload: 'Додати завантаження',
resumeAll: 'Відновити всі',
pauseAll: 'Призупинити всі',
queueEmpty: 'Черга порожня',
noCompletedDownloads: 'Немає завершених завантажень',
noDownloads: 'Немає завантажень',
queueEmptyDescription: 'Додайте завантаження до цієї черги через меню елемента або у вікні "Додати".',
completedDescription: 'Тут з\'являтимуться завершені завантаження.',
clickToAdd: 'Натисніть',
addButtonOr: 'або',
toAddDownloads: 'щоб додати завантаження',
nonResumableOne: '1 завантаження не підтримує відновлення. Якщо ви призупините його, вам доведеться почати спочатку пізніше. Ви впевнені, що хочете призупинити?',
nonResumableMany: '{{count}} завантажень не підтримують відновлення. Якщо ви призупините їх, вам доведеться почати спочатку пізніше. Ви впевнені, що хочете призупинити?',
interactionError: '{{message}}: {{detail}}',
openFileFailed: 'Не вдалося відкрити завантажений файл',
revealFileFailed: 'Не вдалося показати завантаження в Finder',
pauseFailed: 'Не вдалося призупинити завантаження',
resumeFailed: 'Не вдалося відновити {{fileName}}',
moveManyFailed: 'Не вдалося перемістити завантаження до черги',
moveOneFailed: 'Не вдалося перемістити завантаження до черги',
copyAddressesFailed: 'Не вдалося скопіювати адреси',
copyAddressFailed: 'Не вдалося скопіювати адресу',
copyPathFailed: 'Не вдалося скопіювати шлях до файлу',
missingFileName: 'Ім\'я файлу відсутнє',
redownloadFailed: 'Не вдалося повторно завантажити',
startResume: 'Запустити/Відновити',
addToQueue: 'Додати до черги',
copyAddress: 'Скопіювати адресу',
remove: 'Видалити',
open: 'Відкрити',
showInFolder: 'Показати в папці',
pause: 'Призупинити',
start: 'Запустити',
resume: 'Відновити',
redownload: 'Завантажити повторно',
copyFilePath: 'Скопіювати шлях до файлу',
properties: 'Властивості',
backendRejectedStart: 'Бекенд відхилив запит на запуск/відновлення.',
transferActive: 'Неможливо змінити властивості під час активної передачі. Спочатку призупиніть її.',
redownloadNotFound: 'Неможливо завантажити повторно: завантаження не знайдено.',
redownloadActive: 'Неможливо повторно завантажити файл у статусі {{status}}. Спочатку призупиніть або дочекайтеся завершення.',
originalUrlMissing: 'Неможливо завантажити повторно: оригінальна URL-адреса відсутня.',
pauseBeforeMove: 'Призупиніть {{file}} перед переміщенням його до іншої черги.',
},
addDownloads: {
unknownSize: 'Невідомий розмір',
unknown: 'Невідомо',
discardSetup: 'Відкинути це налаштування завантаження?',
speedInvalid: 'Обмеження швидкості має бути більшим за нуль',
duplicateResolveFailed: 'Не вдалося вирішити конфлікт дублікатів завантажень: {{detail}}',
addedWithFailures: '{{added}} додано, {{failed}} не вдалося. {{detail}}',
addedOne: 'Додано 1 завантаження',
addedMany: 'Додано {{count}} завантажень',
updatedOne: 'Оновлено 1 завантаження',
updatedMany: 'Оновлено {{count}} завантажень',
urlAlreadyQueued: 'URL-адреса вже в черзі',
destinationConflict: 'Інше вибране завантаження використовує це місце призначення',
existingDownloadDestination: 'Існуюче завантаження Firelink використовує це місце призначення',
matchingDownloadFilename: 'Незавершене завантаження вже має це ім’я файлу',
fileExistsOnDisk: 'Файл існує на диску; перейменуйте або пропустіть, щоб уникнути видалення незв\'язаних даних',
backendRejectedStart: 'Бекенд відхилив початок завантаження.',
playlistNoEntries: 'Плейлист не містить елементів для завантаження',
invalidMediaMetadata: 'Недійсні метадані медіа або не знайдено форматів',
noAvailableName: 'Не вдалося знайти доступне ім\'я для {{file}}.',
pauseBeforeReplace: 'Призупиніть {{file}} перед заміною.',
cannotReplace: 'Неможливо замінити {{file}}: файл не належить до завантажень Firelink.',
downloadLinks: 'Посилання для завантаження',
pastePlaceholder: 'Вставте URL-адреси HTTP, HTTPS, FTP або SFTP сюди…\n\nДля медіазавантажень вставте посилання з YouTube, X, TikTok, Instagram, Reddit тощо.',
playlistSummary: 'Плейлист “{{title}}”: {{loaded}} з {{total}} елементів завантажено{{truncated}}{{skipped}}',
safeEntryLimit: ' (досягнуто безпечного ліміту елементів)',
selectedSummary: '{{ready}} вибрано готових, {{fallback}} резервних, {{mediaRetry}} повторних медіа, {{blocked}} заблоковано',
clearSelection: 'Очистити вибір',
selectAll: 'Вибрати всі',
refreshMetadata: 'Оновити метадані',
files: 'Файли',
required: 'Обов\'язково',
free: 'Вільно',
preview: 'Попередній перегляд',
file: 'Файл',
size: 'Розмір',
status: 'Статус',
downloadPreview: 'Попередній перегляд завантаження',
noLinks: 'Поки що немає доданих посилань.',
selectItem: 'Вибрати {{file}}',
fetchingPlaylist: 'Отримання плейлиста…',
fetching: 'Отримання…',
unsafeUrl: 'Небезпечна URL-адреса',
playlistFailed: 'Помилка плейлиста',
metadataFailed: 'Помилка метаданих',
fallback: 'Резервний варіант',
invalid: 'Недійсно',
ready: 'Готово',
mediaFormat: 'Формат медіа',
playlistItem: 'Елемент плейлиста {{index}}',
fetchingMediaStreams: 'Отримання медіапотоків…',
availableStreams: 'Доступні потоки',
availableMediaStreams: 'Доступні медіапотоки',
metadataUnavailable: 'Метадані недоступні. Оновіть метадані перед додаванням цього медіа.',
saveLocation: 'Місце збереження',
browse: 'Огляд…',
categoryFolders: 'Файли будуть організовані в папки категорій.',
sharedFolder: 'Усі вибрані завантаження використовуватимуть цю папку.',
dedicatedFolder: 'Зберегти в новій папці',
dedicatedFolderName: 'Назва папки',
dedicatedFolderDescription: 'Цю папку буде створено всередині кожної автоматичної папки категорії.',
dedicatedFolderManualDescription: 'Цю папку буде створено всередині вибраного місця збереження.',
saveFolderName: 'Зберегти назву папки',
dedicatedFolderNameRequired: 'Введіть назву папки перед збереженням.',
transferSettings: 'Налаштування передачі',
connectionsPerFile: 'З\'єднань на файл',
connectionsPerFileAria: 'З\'єднань на файл',
limitSpeedPerFile: 'Обмеження швидкості на файл',
speedLimitPerFileAria: 'Обмеження швидкості на файл',
authorization: 'Авторизація',
useAuthorization: 'Використовувати авторизацію',
username: 'Ім\'я користувача',
password: 'Пароль',
advancedTransfer: 'Розширені параметри передачі',
verifyChecksum: 'Перевірити контрольну суму',
checksumAlgorithm: 'Алгоритм контрольної суми',
expectedDigest: 'Очікуваний хеш',
headers: 'Заголовки',
requestHeaders: 'Заголовки запиту',
cookies: 'Файли cookie',
cookiePlaceholder: 'name=value; other=value',
mirrors: 'Дзеркала',
cancel: 'Скасувати',
startDownloads: 'Почати завантаження',
addToQueue: 'Додати до черги',
pasteOneOrMore: 'Вставте одне або кілька посилань.',
selectAtLeastOne: 'Виберіть хоча б одне завантаження.',
correctInvalidOne: 'Виправте або видаліть {{count}} недійсну URL-адресу перед продовженням.',
correctInvalidFew: 'Виправте або видаліть {{count}} недійсні URL-адреси перед продовженням.',
correctInvalidMany: 'Виправте або видаліть {{count}} недійсних URL-адрес перед продовженням.',
waitingForMetadataOne: 'Очікування метаданих для {{count}} завантаження…',
waitingForMetadataFew: 'Очікування метаданих для {{count}} завантажень…',
waitingForMetadataMany: 'Очікування метаданих для {{count}} завантажень…',
removeUnsafeOne: 'Видаліть {{count}} небезпечну URL-адресу перед продовженням.',
removeUnsafeFew: 'Видаліть {{count}} небезпечні URL-адреси перед продовженням.',
removeUnsafeMany: 'Видаліть {{count}} небезпечних URL-адрес перед продовженням.',
mediaMetadataUnavailableSummaryOne: 'Метадані медіа недоступні для {{count}} елемента. Оновіть метадані перед додаванням.',
mediaMetadataUnavailableSummaryFew: 'Метадані медіа недоступні для {{count}} елементів. Оновіть метадані перед додаванням.',
mediaMetadataUnavailableSummaryMany: 'Метадані медіа недоступні для {{count}} елементів. Оновіть метадані перед додаванням.',
metadataUnavailableFallback: 'Метадані недоступні. Завантаження все ще можна додати, використовуючи резервні дані.',
fallbackReadyOne: '{{ready}} завантаження готове; для {{failed}} буде використано резервне ім\'я файлу та невідомий розмір.',
fallbackReadyFew: '{{ready}} завантаження готові; для {{failed}} буде використано резервне ім\'я файлу та невідомий розмір.',
fallbackReadyMany: '{{ready}} завантажень готово; для {{failed}} буде використано резервне ім\'я файлу та невідомий розмір.',
readyToAddOne: 'Готово до додавання: {{count}} завантаження.',
readyToAddFew: 'Готово до додавання: {{count}} завантаження.',
readyToAddMany: 'Готово до додавання {{count}} завантажень.',
},
settings: {
tabs: {
downloads: 'Завантаження',
lookAndFeel: 'Вигляд та інтерфейс',
network: 'Мережа',
locations: 'Розташування',
siteLogins: 'Вхід на сайти',
power: 'Живлення',
engine: 'Рушій',
integrations: 'Інтеграції',
about: 'Про програму',
},
common: {
thisOs: 'ця ОС',
checking: 'Перевірка…',
ready: 'Готово',
errorMissing: 'Помилка / Відсутнє',
hideTechnicalDetails: 'Сховати технічні деталі',
showTechnicalDetails: 'Показати технічні деталі',
binary: 'Виконуваний файл',
expected: 'Очікується',
error: 'Помилка',
tip: 'Підказка',
stderr: 'stderr',
daemonProcessAlive: 'Процес демона працює',
rpcReady: 'RPC готовий',
rpcPort: 'Порт RPC',
daemonStderr: 'stderr демона',
packaging: 'Пакування: PyInstaller onedir (потрібен _internal)',
internalDirectoryFound: 'Директорію _internal знайдено',
pythonRuntimeFound: 'Середовище виконання Python знайдено',
externalOpenFailed: 'Не вдалося відкрити {{label}}: {{detail}}',
updateUnexpectedResponse: 'Перевірка оновлень повернула неочікувану відповідь.',
updateUntrustedReleaseUrl: 'Перевірка оновлень повернула ненадійну URL-адресу релізу.',
updateFailed: 'Не вдалося перевірити наявність оновлень: {{detail}}',
},
downloads: {
defaultConnections: 'З\'єднання за замовчуванням:',
defaultConnectionsDescription: 'Нові завантаження; існуючі елементи зберігають своє збережене значення',
parallelDownloads: 'Паралельні завантаження:',
parallelDownloadsDescription: 'Макс. одночасних активних файлів',
automaticRetries: 'Автоматичні повторні спроби:',
automaticRetriesDescription: 'Якщо з\'єднання перерветься',
systemNotification: 'Показувати системне сповіщення по завершенні завантаження',
systemNotificationDescription: 'Використовує налаштування сповіщень вашої операційної системи',
completionChime: 'Відтворювати звуковий сигнал по завершенні в програмі',
completionChimeDescription: 'Додатковий звук Firelink, незалежно від системних сповіщень',
clipboardLinks: 'Додавати посилання з буфера обміну, коли Firelink стає активним',
clipboardLinksDescription: 'Відкриває підтримувані скопійовані посилання у вікні "Додати". Вимкнено за замовчуванням.',
},
lookAndFeel: {
language: 'Мова',
languageDescription: 'Виберіть мову інтерфейсу Firelink.',
sidebarPosition: 'Положення бічної панелі',
sidebarPositionDescription: 'Виберіть, де відображати бічну панель. Автоматичний режим враховує напрямок інтерфейсу.',
sidebarPositionAutomatic: 'Автоматично (рекомендовано)',
sidebarPositionLeft: 'Ліворуч',
sidebarPositionRight: 'Праворуч',
languageSystem: 'Мова системи',
languageEnglish: 'Англійська',
languageChinese: 'Спрощена китайська',
languageHebrew: 'Іврит',
languagePersian: 'Перська',
languageUkrainian: 'Українська',
languageRussian: 'Російська',
appTheme: 'Тема програми',
theme: 'Тема',
ariaLabel: 'Тема програми',
system: 'Системна',
light: 'Світла',
dark: 'Темна',
dracula: 'Dracula',
nord: 'Nord',
systemAppearance: 'Система слідкує за поточним світлим або темним виглядом {{platform}}.',
display: 'Відображення',
fontSize: 'Розмір шрифту',
fontSizeDescription: 'Масштабує список завантажень та компактні елементи керування.',
small: 'Дрібний',
standard: 'Стандартний',
large: 'Великий',
listDensity: 'Щільність списку',
listDensityDescription: 'Змінює висоту рядка, інтервали та масштаб індикатора прогресу.',
compact: 'Компактний',
comfortable: 'Комфортний',
relaxed: 'Вільний',
osIntegration: 'Інтеграція з ОС',
dockBadge: 'Показувати значок на іконці Dock',
dockBadgeDescription: 'Відображає кількість активних завантажень на іконці Firelink у Dock.',
menuBarIcon: 'Показувати іконку в рядку меню',
statusIndicatorIcon: 'Показувати іконку індикатора стану',
systemTrayIcon: 'Показувати іконку в системному треї',
macosTrayDescription: 'Надає швидкий доступ з рядка меню macOS.',
windowsTrayDescription: 'Надає швидкий доступ з області сповіщень Windows.',
linuxTrayDescription: 'Надає швидкий доступ із системного трею або області стану, якщо доступно.',
defaultTrayDescription: 'Надає швидкий доступ із системного трею ОС, якщо доступно.',
},
network: {
proxy: 'Проксі',
mode: 'Режим',
modeDescription: 'Керує використанням проксі для нових запитів на завантаження.',
noProxy: 'Без проксі',
systemProxy: 'Системний проксі',
customProxy: 'Власний проксі',
proxyHost: 'Хост проксі',
proxyHostDescription: 'Ім\'я хоста, IP-адреса або URL-адреса HTTP-проксі.',
proxyHostPlaceholder: '127.0.0.1 або http://127.0.0.1',
proxyPort: 'Порт проксі',
proxyPortDescription: 'Дійсний діапазон від 1 до 65535.',
noProxyDescription: 'Завантаження ігнорують налаштовані проксі-сервери.',
systemProxyDescription: 'Завантаження використовують виявлений системний проксі {{platform}}. Звичайні завантаження файлів вимагають HTTP або HTTPS проксі; медіазавантаження можуть використовувати SOCKS.',
customProxyDescription: 'Завантаження використовують налаштовану кінцеву точку HTTP-проксі для метаданих і рушіїв завантаження.',
invalidCustomProxy: 'Введіть дійсний хост і порт HTTP-проксі, щоб увімкнути власний проксі.',
incompleteCustomProxy: 'Введіть хост і порт проксі, щоб увімкнути власний проксі.',
checkingSystemProxy: 'Перевірка конфігурації системного проксі…',
detectedSystemProxy: 'Виявлено системний проксі. Звичайні завантаження файлів вимагають HTTP або HTTPS проксі; медіазавантаження можуть використовувати SOCKS.',
noSystemProxy: 'Не виявлено придатного системного проксі. Завантаження використовуватимуть пряме підключення (без проксі).',
systemProxyReadFailed: 'Не вдалося прочитати конфігурацію системного проксі. Виберіть "Без проксі" або спробуйте ще раз, коли він стане доступним.',
identity: 'Ідентифікація',
customUserAgent: 'Власний User-Agent',
userAgentDescription: 'Застосовується до запитів метаданих та рушіїв завантаження.',
userAgentPlaceholder: 'Залиште порожнім для Firelink за замовчуванням',
userAgentOverrides: 'Перевизначає вихідний заголовок User-Agent. Залиште порожнім для типових значень Firelink.',
chromeWindows: 'Chrome (Windows)',
chromeMacos: 'Chrome (macOS)',
edgeWindows: 'Edge (Windows)',
firefoxWindows: 'Firefox (Windows)',
firefoxMacos: 'Firefox (macOS)',
safariMacos: 'Safari (macOS)',
windowsDesktop: 'Робочий стіл Windows',
macosDesktop: 'Робочий стіл macOS',
},
locations: {
baseDownloadFolder: 'Базова папка завантажень',
baseDownloadFolderDescription: 'Автоматичні папки категорій створюються всередині цієї папки.',
browse: 'Огляд…',
baseFolderCreateFailed: 'Базова папка збережена, але папки категорій не вдалося створити: {{detail}}',
baseFolderUpdated: 'Базову папку завантажень оновлено',
askWhereToSave: 'Запитувати, де зберігати під час додавання завантажень',
rememberLastUsedDownloadDirectory: 'Запам\'ятати останню використану папку завантажень',
rememberLastUsedDownloadDirectoryDescription: 'Використовувати останню папку, вибрану у вікні "Додати", для наступного завантаження до перезапуску програми.',
automaticCategorySubfolders: 'Автоматично зберігати файли у вкладених папках категорій',
automaticCategorySubfoldersDescription: 'Якщо вимкнено, автоматичні завантаження використовують базову папку.',
categorySubfolders: 'Вкладені папки категорій',
relativeToBase: 'Відносно базової папки',
disabled: 'Вимкнено',
categorySubfolder: 'Вкладена папка {{category}}',
customFolder: 'Власна папка',
useAutomatic: 'Використовувати автоматичну',
resetDefaults: 'Скинути за замовчуванням',
resetToast: 'Розташування категорій скинуто до стандартних',
},
siteLogins: {
title: 'Облікові дані сайтів',
noSavedLogins: 'Немає збережених логінів.',
user: 'Користувач: {{username}}',
deleteCredential: 'Видалити облікові дані',
deletedCredential: 'Видалено облікові дані',
deleteFailed: 'Не вдалося видалити облікові дані: {{detail}}',
addTitle: 'Додати облікові дані сайту',
urlPattern: 'Шаблон URL:',
urlPatternPlaceholder: 'наприклад, *.example.com або example.com/downloads',
username: 'Ім\'я користувача:',
password: 'Пароль:',
usernamePlaceholder: 'Ім\'я користувача',
passwordPlaceholder: 'Пароль',
saving: 'Збереження…',
addLogin: 'Додати вхід',
urlPatternRequired: 'Необхідно вказати шаблон URL.',
urlPatternWhitespace: 'Шаблон URL не може містити пробілів.',
usernameRequired: 'Необхідно вказати ім\'я користувача.',
passwordRequired: 'Необхідно вказати пароль.',
accessBeforeSave: 'Надайте доступ до сховища облікових даних перед збереженням логіна сайту.',
saveFailed: 'Не вдалося безпечно зберегти облікові дані сайту.',
addedCredential: 'Додано облікові дані сайту',
},
power: {
title: 'Керування живленням',
preventSleep: 'Запобігати переходу системи в сплячий режим під час активних завантажень',
preventSleepDescription: 'Дисплей може все одно вимкнутися. Firelink лише не дає пристрою заснути, щоб завершити активні передачі.',
},
engine: {
title: 'Завантажувач медіа та рушії',
description: 'Успішні результати повторно використовуються для цього сеансу програми. Повторна перевірка запускає справжню валідацію знову.',
recheck: 'Перевірити рушії знову',
coreDownloader: 'Ядро завантаження (aria2)',
version: 'Версія:',
status: 'Статус:',
mediaExtractors: 'Екстрактори медіа',
browserCookiesSource: 'Джерело Cookies з браузера:',
cookieDescription: 'yt-dlp читає файли cookie з браузера, щоб обійти обмеження на завантаження відео або отримати доступ до медіа з обмеженим доступом. Firelink не зберігає файли cookie з браузера.',
none: 'Немає',
},
integrations: {
title: 'Підключення розширення браузера',
description: 'Захоплюйте завантаження безпосередньо з вашого браузера у три простих кроки.',
portablePairingEnabled: 'Портативне підключення увімкнено',
pairingTokenPersisted: 'Токен підключення збережено',
portablePersistedDescription: 'Ваш токен підключення збережено разом із цією портативною папкою Firelink, і він залишатиметься дійсним при переміщенні папки. Ставтеся до цієї папки як до конфіденційної.',
systemPersistedDescription: 'Ваш токен підключення зберігається в системному сховищі облікових даних і зберігатиметься після перезапусків.',
portablePairingAvailable: 'Доступно портативне підключення',
credentialStorageNeeded: 'Потрібне сховище облікових даних',
portableAvailableDescription: 'Ваш токен підключення збережено разом із цією портативною папкою Firelink, і він зберігатиметься після перезапусків. Увімкніть його тут, щоб переглянути попередження про портативне сховище.',
credentialStorageDescription: 'Firelink потребує доступу до сховища облікових даних цієї системи, щоб безпечно зберегти ваш токен підключення після перезапусків програми. Наразі ваше розширення залишатиметься підключеним лише для цього сеансу.',
reviewPortablePairing: 'Переглянути портативне підключення',
grantCredentialAccess: 'Надати доступ до облікових даних',
copyToken: 'Скопіювати токен',
tokenDescription: 'Цей безпечний токен авторизує ваше розширення браузера.',
tokenCopied: 'Токен скопійовано в буфер обміну!',
tokenCopyFailed: 'Не вдалося скопіювати токен: {{detail}}',
pairingTokenRegenerated: 'Токен підключення згенеровано наново',
regenerateFailed: 'Не вдалося згенерувати токен підключення наново: {{detail}}',
getExtension: 'Отримати розширення',
extensionDescription: 'Встановіть Firelink Companion для браузерів Firefox або Chromium.',
firefoxAddons: 'Додатки Firefox',
chromiumZip: 'Chromium ZIP',
pasteConnect: 'Вставити та підключити',
pasteConnectDescription: 'Натисніть на іконку Firelink на панелі інструментів вашого браузера та вставте скопійований токен.',
extensionServerStatus: 'Статус сервера розширень:',
listening: '● Прослуховування на 127.0.0.1:{{port}}',
unavailable: '● Сервер недоступний',
},
about: {
firelinkIcon: 'Іконка Firelink',
unknown: 'Невідомо',
version: 'Версія {{version}}',
description: 'Кросплатформний менеджер завантажень для macOS, Windows та Linux.',
sourceCode: 'Вихідний код',
source: 'Джерело',
upToDate: 'У вас остання версія Firelink {{version}}.',
updateAvailable: 'Доступна нова версія Firelink {{version}}.',
viewRelease: 'Переглянути реліз',
updates: 'Оновлення',
checkForUpdates: 'Перевірити наявність оновлень',
updateDescription: 'Firelink перевіряє GitHub Releases на наявність нових версій.',
checking: 'Перевірка…',
checkNow: 'Перевірити зараз',
automaticUpdates: 'Автоматично перевіряти наявність оновлень',
createdBy: 'Створено NimBold',
mitLicense: 'Ліцензія MIT',
credits: 'Подяки: aria2, yt-dlp, FFmpeg та Deno.',
copyright: 'Авторське право © 2026 NimBold. Всі права захищені.',
},
},
status: {
ready: 'Готово',
active: '{{count}} активних',
queued: '{{count}} в черзі',
done: '{{count}} завершено',
},
} as const;
export default uk;
+755
View File
@@ -0,0 +1,755 @@
const zhCN = {
navigation: {
library: '库',
filters: {
all: '全部',
active: '进行中',
completed: '已完成',
unfinished: '未完成',
},
categories: {
musics: '音乐',
movies: '视频',
compressed: '压缩包',
documents: '文档',
pictures: '图片',
applications: '应用程序',
other: '其他',
},
folders: '文件夹',
queues: '队列',
tools: '工具',
scheduler: '计划任务',
speedLimiter: '速度限制',
logs: '日志',
settings: '设置',
},
actions: {
hideSidebar: '隐藏侧边栏',
showSidebar: '显示侧边栏',
resizeSidebar: '调整侧边栏大小',
cancel: '取消',
continue: '继续',
dismissNotification: '关闭通知',
queueName: '队列名称',
addNewQueue: '新建队列',
startQueue: '启动队列',
pauseQueue: '暂停队列',
renameQueue: '重命名队列',
deleteQueue: '删除队列',
},
dialogs: {
errorBoundary: {
title: '出现了一些问题。',
description: 'React 组件树中发生了严重错误。下面的错误详情可以帮助确定根本原因。',
reload: '重新加载应用',
},
duplicateDownloads: {
title: '检测到重复下载',
description: '您要添加的一些下载已存在于队列或磁盘上。请选择如何解决这些冲突。',
rename: '重命名',
replace: '替换',
skip: '跳过',
},
removeDownload: {
title: '移除下载',
confirmationSingle: '您确定要从列表中移除此项目吗?您也可以选择同时从磁盘中删除底层文件。',
confirmationMultiple: '您确定要从列表中移除这 {{count}} 个项目吗?您也可以选择同时从磁盘中删除底层文件。',
errorSummary: '成功移除 {{succeeded}} 个,失败 {{failed}} 个:{{detail}}',
remove: '移除',
deleteFile: '删除文件',
},
},
window: {
controls: '窗口控制',
close: '关闭',
minimize: '最小化',
maximize: '最大化',
},
downloads: {
actions: {
moveUp: '上移',
moveDown: '下移',
pause: '暂停',
start: '启动',
resume: '恢复',
options: '选项',
},
size: {
downloadedOf: '已下载 {{downloaded}} / 共 {{total}} {{unit}}',
downloadedOfApproximate: '已下载 {{downloaded}} / 预估共 {{total}} {{unit}}',
},
status: {
ready: '就绪',
staged: '在队列中',
queued: '已排队',
downloading: '下载中',
processing: '处理中',
paused: '已暂停',
completed: '已完成',
failed: '失败',
retrying: '重试中',
},
values: {
processing: '处理中…',
muxing: '混流中…',
},
},
speedLimiter: {
title: '速度限制',
unlimited: '不限速',
saveLimit: '保存限制',
globalSpeedLimit: '全局速度限制',
description: '适用于新的和活动中的 aria2 传输以及新的 yt-dlp 媒体下载。单个下载的专属限制仍将保留。',
quickPresets: '快捷预设',
removePreset: '移除 {{value}} {{unit}} 预设',
customPresetIn: '自定义预设 (单位:{{unit}})',
addQuickPreset: '添加快捷预设',
globalLimitSaved: '全局限制已保存为 {{value}} {{unit}}',
globalLimitDisabled: '全局速度限制已禁用',
saveFailed: '无法保存全局速度限制:{{detail}}',
presetAlreadyExists: '{{value}} {{unit}} 已在快捷预设中',
presetAdded: '已添加 {{value}} {{unit}} 快捷预设',
presetRemoved: '已移除 {{value}} {{unit}} 快捷预设',
},
logs: {
title: '日志',
entries: '({{count}} 条)',
collecting: '收集中',
off: '关闭',
allLevels: '所有级别',
levels: {
error: '错误',
warn: '警告',
info: '信息',
debug: '调试',
trace: '追踪',
},
pauseLogging: '暂停诊断日志记录',
enableLogging: '启用诊断日志记录',
clearDisplayed: '清除显示的日志',
export: '导出日志',
exportButton: '导出日志',
logFiles: '日志文件',
localDiagnostics: '本地诊断:',
diagnosticsDescription: '诊断信息的收集需要主动开启,范围有限,并且仅在本地进行。常见的密钥、URL 查询参数和主目录路径在显示和导出时都会被隐藏。不会自动上传任何数据。',
noEntries: '尚无持久化的日志条目。',
disabledDescription: '诊断日志记录已关闭。现有的支持日志将在可用时显示在此处。',
copy: '复制',
copied: '已复制到剪贴板',
copyFailed: '复制到剪贴板失败',
exported: '支持日志已导出',
exportFailed: '无法导出日志:{{detail}}',
cleared: '日志已清除',
clearFailed: '无法清除日志:{{detail}}',
enabled: '已启用诊断日志记录',
disabled: '已禁用诊断日志记录',
updateFailed: '无法更新诊断日志记录:{{detail}}',
},
scheduler: {
title: '计划任务',
running: '运行中',
disabled: '计划任务已禁用',
noScheduledDay: '未选择计划日期',
unsavedChanges: '未保存的更改',
runNow: '立即运行',
pause: '暂停',
saveSettings: '保存设置',
timing: '时间',
startTime: '开始时间',
stopTime: '停止时间',
timingDescription: '如果在开始时间 Firelink 处于休眠状态,它会在当天晚些时候恢复运行时启动所选队列,除非停止时间已过。',
runEveryDay: '每天运行',
queuesToSchedule: '要计划运行的队列',
defaultQueue: '默认队列',
afterCompletion: '完成后',
afterCompletionDescription: '选择计划任务启动的下载完成后的操作。',
actionWarning: '此操作可能会中断计算机上的其他工作。Firelink 会在计划的队列完成后立即执行它。',
systemPermissions: '系统权限',
macPermissionDescription: '睡眠、重启和关机需要 macOS 系统事件的自动化权限。',
permissionGranted: '已授予自动化权限',
permissionChecking: '正在检查自动化权限…',
permissionMissing: '缺少自动化权限',
revokePermission: '撤销权限',
grantPermission: '授予权限',
permissionAvailable: '自动化权限可用。',
permissionMissingDetails: '缺少自动化权限。请在“系统设置”的“系统事件”的“自动化”下启用 Firelink。',
revokePermissionDetails: 'macOS 不允许 Firelink 直接撤销自动化权限。请在“系统设置”中撤销“系统事件”访问权限,然后返回 Firelink。',
requestingPermission: '正在请求自动化权限…',
enablePermissionDetails: '请在“系统设置”的“系统事件”的“自动化”下启用 Firelink,然后返回 Firelink。',
systemActions: '系统操作',
windowsActionsDescription: '睡眠、重启和关机使用 Windows 系统权限。Firelink 在运行时会报告任何被拒绝的操作;不会提前声明任何永久权限。',
linuxActionsDescription: '睡眠、重启和关机使用您的 Linux 桌面和系统策略。Firelink 在运行时会报告任何被拒绝的操作;不会提前声明任何永久权限。',
validationDay: '至少为计划任务选择一天',
validationQueue: '至少为计划任务选择一个队列',
validationStopTime: '停止时间必须晚于开始时间',
saved: '计划任务设置已保存',
trackingOne: '正在跟踪 1 个计划的下载',
trackingMany: '正在跟踪 {{count}} 个计划的下载',
noStartableDownloads: '所选队列中没有可开始的下载',
pausedOne: '已暂停 1 个活动的下载',
pausedMany: '已暂停 {{count}} 个活动的下载',
noActiveDownloads: '没有活动的下载',
days: {
su: '日',
mo: '一',
tu: '二',
we: '三',
th: '四',
fr: '五',
sa: '六',
},
postActions: {
none: '不执行任何操作',
sleep: '睡眠',
restart: '重启',
shutdown: '关机',
},
},
properties: {
progress: '进度',
size: '大小',
speed: '速度',
eta: '剩余时间',
connections: '连接数',
connectionCount: '{{active}}/{{total}} 个连接',
connectionCountUnknown: '—/{{total}} 个连接',
connectionsUnavailable: '—',
speedCap: '速度上限',
category: '类别',
lastTry: '上次尝试',
dateAdded: '添加日期',
destination: '保存目录',
lastError: '上次错误',
saved: ' (已保存)',
defaultValue: ' (默认)',
savedTooltip: '已为此下载保存;设置中的更改将应用于新的下载。',
defaultTooltip: '对新的下载使用当前默认值。',
identityReadOnly: '文件标识为只读。传输设置会保存以备重新下载使用。',
transferSettings: '停止或暂停后可以更改传输设置。当前的传输会保留其现有的后端选项。',
download: '下载',
url: 'URL',
fileName: '文件名',
saveLocation: '保存位置',
select: '选择',
perFile: '每个文件',
useCurrentDefault: '使用当前默认值 ({{count}})',
savedPerDownload: '按下载保存。设置中的默认值将应用于新下载。',
limit: '限制',
siteLogin: '站点登录',
siteLoginRedownload: '重新下载的站点登录',
matchingSiteLogin: '匹配的站点登录',
customCredentials: '自定义凭据',
noLogin: '无登录',
useSavedLogin: '如果可用,将使用保存的登录信息。',
advancedTransfer: '高级传输',
advancedTransferRedownload: '重新下载的高级传输',
checksum: '校验和',
verify: '验证',
algorithm: '算法',
digest: '哈希值',
expectedDigest: '预期哈希值',
cookies: 'Cookie',
headers: '请求头',
mirrors: '镜像源',
username: '用户名',
password: '密码',
enterValidUrl: '请输入有效的 URL。',
fileNameEmpty: '文件名不能为空。',
cancel: '取消',
save: '保存',
},
keychain: {
title: '需要凭据存储访问权限',
stores: {
portable: '便携式 Firelink 数据文件夹',
windows: 'Windows 凭据管理器',
linux: '您的 Linux 凭据存储',
macos: 'macOS 钥匙串',
system: '此系统的凭据存储',
siteCredentials: '系统的凭据存储',
},
grantLabelPortable: '继续',
grantLabelMacos: '授予访问权限',
grantLabelDefault: '启用安全存储',
description: 'Firelink 使用浏览器扩展来捕获下载。要在重启后保持扩展配对,Firelink 会将其配对令牌存储在{{pairingStore}}中。可选的站点凭据存储在{{siteCredentialStore}}中。',
portableExplanation: '配对令牌可与此文件夹一起移植。站点凭据保留在系统凭据存储中;授予访问权限后可能会出现系统提示。',
macosExplanation: '授予访问权限后,macOS 可能会显示钥匙串提示。',
defaultExplanation: '这通常会在后台静默完成。如果凭据服务不可用,Firelink 将在此处显示错误,并且扩展仅在本次会话期间保持配对状态。',
note: '注意:',
portableNote: '便携模式仅在此文件夹中存储配对令牌。它不会复制站点密码或浏览器凭据。',
defaultNote: 'Firelink 仅写入其自己的专用凭据条目。它无法访问您系统上保存的其他密码或凭据项。',
hint: '提示:',
portableHint: '便携配对令牌已与此文件夹一起存储;您可以在此处启用它,也可以选择“稍后”。',
defaultHint: '如果您选择“稍后”,扩展仅在本次会话中工作。',
enableFromSettings: '您可以随时从“设置 > 集成”中启用存储。',
later: '稍后',
enabling: '正在启用…',
timeout: '凭据存储请求超时。您可以选择“稍后”并重试。',
unavailable: '{{store}}不可用。',
accessRequired: '在重新生成配对令牌之前,请授予凭据存储访问权限。',
storeUnavailable: '凭据存储访问不可用。',
},
app: {
loading: '加载中…',
settingsSaveFailed: '无法保存设置。请检查存储权限并重试。',
systemActionCountdown: '10 秒后{{action}}。',
systemActionCancelled: '系统操作已取消,因为有其他下载正在进行或已排队。',
systemActionFailed: '计划的系统操作失败:{{detail}}',
downloadCompleteTitle: '下载完成',
downloadCompleteBody: '{{fileName}} 已下载完成。',
unknownFile: '未知文件',
downloadFailedTitle: '下载失败',
downloadFailedBody: '{{fileName}} 下载失败。',
initializeDownloadsFailed: '无法初始化已保存的下载:{{detail}}',
extensionDisconnected: '浏览器扩展已断开连接,因为其配对令牌已更改。',
copyToken: '复制令牌',
integrations: '集成',
copyTokenFailed: '无法复制配对令牌:{{detail}}',
credentialPersistenceFailed: '安全凭据持久化不可用。浏览器配对仅适用于本次会话:{{detail}}',
resumeDownloadsFailed: '无法恢复已保存的下载:{{detail}}',
updateAvailable: 'Firelink {{version}} 可用。',
viewRelease: '查看发布版本',
sleepPreventionFailed: '无法更新睡眠阻止状态:{{detail}}',
schedulerNoQueues: '计划任务未选择有效的队列。请更新计划任务设置。',
schedulerPauseOneFailed: '计划任务无法暂停 1 个下载。',
schedulerPauseManyFailed: '计划任务无法暂停 {{count}} 个下载。',
scheduledIncomplete: '并非所有计划的下载都已完成。已跳过队列后系统操作。',
scheduledActionSkippedActive: '已跳过计划的系统操作,因为有其他下载正在进行或已排队。',
notificationsDisabled: '系统的 Firelink 通知已被禁用。',
notificationsFailed: '无法配置通知:{{detail}}',
},
sidebar: {
deleteQueueConfirm: '删除队列“{{name}}”?其未完成的下载将被移至主队列。',
deleteQueueFailed: '无法删除队列:{{detail}}',
queueNameEmpty: '队列名称不能为空',
queueNameExists: '同名队列已存在',
startQueueFailed: '无法启动队列:{{detail}}',
pauseQueueFailed: '无法暂停队列:{{detail}}',
},
downloadTable: {
unknownQueue: '未知队列',
allDownloads: '所有下载',
active: '进行中',
completed: '已完成',
unfinished: '未完成',
headers: {
fileName: '文件名',
size: '大小',
status: '状态',
speed: '速度',
eta: '剩余时间',
dateAdded: '添加日期',
},
columnOptions: {
open: '{{column}}列选项',
dragToReorder: '拖动以重新排列列',
alignContent: '内容位置',
left: '左',
center: '居中',
right: '右',
resetLayout: '重置列布局',
},
addDownload: '添加下载',
resumeAll: '全部恢复',
pauseAll: '全部暂停',
queueEmpty: '队列为空',
noCompletedDownloads: '没有已完成的下载',
noDownloads: '没有下载',
queueEmptyDescription: '通过项目菜单或“添加”窗口将下载添加到此队列。',
completedDescription: '已完成的下载将显示在此处。',
clickToAdd: '点击',
addButtonOr: '按钮或',
toAddDownloads: '来添加下载',
nonResumableOne: '1 个下载不支持断点续传。如果您暂停它,稍后您将不得不重新开始。确定要暂停吗?',
nonResumableMany: '{{count}} 个下载不支持断点续传。如果您暂停它们,稍后您将不得不重新开始。确定要暂停吗?',
interactionError: '{{message}}{{detail}}',
openFileFailed: '无法打开下载的文件',
revealFileFailed: '无法在文件管理器中显示下载',
pauseFailed: '无法暂停下载',
resumeFailed: '无法恢复 {{fileName}}',
moveManyFailed: '无法将下载移动到队列',
moveOneFailed: '无法将下载移动到队列',
copyAddressesFailed: '无法复制地址',
copyAddressFailed: '无法复制地址',
copyPathFailed: '无法复制文件路径',
missingFileName: '缺少文件名',
redownloadFailed: '重新下载失败',
startResume: '启动/恢复',
addToQueue: '添加到队列',
copyAddress: '复制地址',
remove: '移除',
open: '打开',
showInFolder: '在文件夹中显示',
pause: '暂停',
start: '启动',
resume: '恢复',
redownload: '重新下载',
copyFilePath: '复制文件路径',
properties: '属性',
backendRejectedStart: '后端拒绝了启动/恢复请求。',
transferActive: '传输处于活动状态时无法更改属性。请先暂停它。',
redownloadNotFound: '无法重新下载:未找到下载。',
redownloadActive: '无法重新下载{{status}}的下载。请先暂停或等待其完成。',
originalUrlMissing: '无法重新下载:缺少原始 URL。',
pauseBeforeMove: '请在将 {{file}} 移动到另一个队列前暂停它。',
},
addDownloads: {
unknownSize: '未知大小',
unknown: '未知',
discardSetup: '放弃此下载设置吗?',
speedInvalid: '速度限制必须大于零',
duplicateResolveFailed: '无法解决重复的下载:{{detail}}',
addedWithFailures: '添加了 {{added}} 个,失败了 {{failed}} 个。{{detail}}',
addedOne: '添加了 1 个下载',
addedMany: '添加了 {{count}} 个下载',
updatedOne: '已更新 1 个下载',
updatedMany: '已更新 {{count}} 个下载',
urlAlreadyQueued: 'URL 已在队列中',
destinationConflict: '其他选定的下载已使用此目标路径',
existingDownloadDestination: '现有的 Firelink 下载已使用此目标路径',
matchingDownloadFilename: '已有一个未完成的下载使用此文件名',
fileExistsOnDisk: '磁盘上已存在该文件;请重命名或跳过以避免覆盖无关数据',
backendRejectedStart: '后端拒绝开始下载。',
playlistNoEntries: '播放列表不包含可下载的条目',
invalidMediaMetadata: '媒体元数据无效或未找到格式',
noAvailableName: '无法为 {{file}} 找到可用的名称。',
pauseBeforeReplace: '请在替换 {{file}} 前暂停它。',
cannotReplace: '无法替换 {{file}}:文件不属于 Firelink 下载。',
downloadLinks: '下载链接',
pastePlaceholder: '在此粘贴 HTTP、HTTPS、FTP 或 SFTP URL…\n\n对于媒体下载,请粘贴来自 YouTube、X、TikTok、Instagram、Reddit 等的链接。',
playlistSummary: '播放列表“{{title}}”:已加载 {{loaded}} / {{total}} 个条目{{truncated}}{{skipped}}',
safeEntryLimit: ' (达到安全条目限制)',
selectedSummary: '准备就绪 {{ready}} 个,后备项 {{fallback}} 个,媒体重试 {{mediaRetry}} 个,已屏蔽 {{blocked}} 个',
clearSelection: '清除选择',
selectAll: '全选',
refreshMetadata: '刷新元数据',
files: '文件',
required: '必需',
free: '可用空间',
preview: '预览',
file: '文件',
size: '大小',
status: '状态',
downloadPreview: '下载预览',
noLinks: '尚未添加任何链接。',
selectItem: '选择 {{file}}',
fetchingPlaylist: '正在获取播放列表…',
fetching: '正在获取…',
unsafeUrl: '不安全的 URL',
playlistFailed: '播放列表获取失败',
metadataFailed: '元数据获取失败',
fallback: '备用项',
invalid: '无效',
ready: '就绪',
mediaFormat: '媒体格式',
playlistItem: '播放列表项目 {{index}}',
fetchingMediaStreams: '正在获取媒体流…',
availableStreams: '可用流',
availableMediaStreams: '可用的媒体流',
metadataUnavailable: '元数据不可用。在添加此媒体之前,请刷新元数据。',
saveLocation: '保存位置',
browse: '浏览',
categoryFolders: '文件将被组织到类别文件夹中。',
sharedFolder: '所有选定的下载都将使用此文件夹。',
dedicatedFolder: '保存到新文件夹',
dedicatedFolderName: '文件夹名称',
dedicatedFolderDescription: '将在每个自动类别文件夹中创建此文件夹。',
dedicatedFolderManualDescription: '将在选定的保存位置中创建此文件夹。',
saveFolderName: '保存文件夹名称',
dedicatedFolderNameRequired: '保存前请输入文件夹名称。',
transferSettings: '传输设置',
connectionsPerFile: '每个文件的连接数',
connectionsPerFileAria: '每个文件的连接数',
limitSpeedPerFile: '限制每个文件的速度',
speedLimitPerFileAria: '每个文件的速度限制',
authorization: '授权',
useAuthorization: '使用授权',
username: '用户名',
password: '密码',
advancedTransfer: '高级传输',
verifyChecksum: '验证校验和',
checksumAlgorithm: '校验和算法',
expectedDigest: '预期摘要',
headers: '请求头',
requestHeaders: '请求头',
cookies: 'Cookie',
cookiePlaceholder: 'name=value; other=value',
mirrors: '镜像源',
cancel: '取消',
startDownloads: '开始下载',
addToQueue: '添加到队列',
pasteOneOrMore: '粘贴一个或多个链接。',
selectAtLeastOne: '请至少选择一个下载。',
correctInvalidOne: '在继续之前,请更正或移除 {{count}} 个无效的 URL。',
correctInvalidFew: '在继续之前,请更正或移除 {{count}} 个无效的 URL。',
correctInvalidMany: '在继续之前,请更正或移除 {{count}} 个无效的 URL。',
waitingForMetadataOne: '正在等待 {{count}} 个下载的元数据。',
waitingForMetadataFew: '正在等待 {{count}} 个下载的元数据。',
waitingForMetadataMany: '正在等待 {{count}} 个下载的元数据。',
removeUnsafeOne: '在继续之前,请移除 {{count}} 个不安全的 URL。',
removeUnsafeFew: '在继续之前,请移除 {{count}} 个不安全的 URL。',
removeUnsafeMany: '在继续之前,请移除 {{count}} 个不安全的 URL。',
mediaMetadataUnavailableSummaryOne: '{{count}} 个项目的媒体元数据不可用。在添加之前请刷新元数据。',
mediaMetadataUnavailableSummaryFew: '{{count}} 个项目的媒体元数据不可用。在添加之前请刷新元数据。',
mediaMetadataUnavailableSummaryMany: '{{count}} 个项目的媒体元数据不可用。在添加之前请刷新元数据。',
metadataUnavailableFallback: '元数据不可用。下载仍可以使用后备详细信息添加。',
fallbackReadyOne: '{{ready}} 个下载已就绪;{{failed}} 个将使用后备文件名和未知大小。',
fallbackReadyFew: '{{ready}} 个下载已就绪;{{failed}} 个将使用后备文件名和未知大小。',
fallbackReadyMany: '{{ready}} 个下载已就绪;{{failed}} 个将使用后备文件名和未知大小。',
readyToAddOne: '准备添加 {{count}} 个下载。',
readyToAddFew: '准备添加 {{count}} 个下载。',
readyToAddMany: '准备添加 {{count}} 个下载。',
},
settings: {
tabs: {
downloads: '下载',
lookAndFeel: '外观',
network: '网络',
locations: '位置',
siteLogins: '站点登录',
power: '电源',
engine: '引擎',
integrations: '集成',
about: '关于',
},
common: {
thisOs: '此操作系统',
checking: '正在检查…',
ready: '就绪',
errorMissing: '错误 / 缺失',
hideTechnicalDetails: '隐藏技术详细信息',
showTechnicalDetails: '显示技术详细信息',
binary: '二进制文件',
expected: '预期',
error: '错误',
tip: '提示',
stderr: '标准错误',
daemonProcessAlive: '守护进程处于活动状态',
rpcReady: 'RPC 已就绪',
rpcPort: 'RPC 端口',
daemonStderr: '守护进程标准错误',
packaging: '打包:PyInstaller onedir(需要 _internal',
internalDirectoryFound: '找到 _internal 目录',
pythonRuntimeFound: '找到 Python 运行时',
externalOpenFailed: '无法打开 {{label}}{{detail}}',
updateUnexpectedResponse: '更新检查返回了意外的响应。',
updateUntrustedReleaseUrl: '更新检查返回了不受信任的发布版本 URL。',
updateFailed: '更新检查失败:{{detail}}',
},
downloads: {
defaultConnections: '默认连接数:',
defaultConnectionsDescription: '新下载;现有项目保持其保存的值',
parallelDownloads: '并行下载:',
parallelDownloadsDescription: '最大同时活动文件数',
automaticRetries: '自动重试:',
automaticRetriesDescription: '如果连接失败',
systemNotification: '下载完成时显示系统通知',
systemNotificationDescription: '使用操作系统的通知设置',
completionChime: '播放应用内完成提示音',
completionChimeDescription: '可选的 Firelink 声音,独立于系统通知',
clipboardLinks: '当 Firelink 处于活动状态时添加剪贴板链接',
clipboardLinksDescription: '在“添加”窗口中打开支持的已复制链接。默认关闭。',
},
lookAndFeel: {
language: '语言',
languageDescription: '选择 Firelink 的界面语言。',
sidebarPosition: '侧边栏位置',
sidebarPositionDescription: '选择侧边栏显示的位置。“自动”会跟随界面文字方向。',
sidebarPositionAutomatic: '自动(推荐)',
sidebarPositionLeft: '左侧',
sidebarPositionRight: '右侧',
languageSystem: '系统默认',
languageEnglish: '英语',
languageChinese: '简体中文',
languageHebrew: '希伯来语',
languagePersian: '波斯语',
languageUkrainian: '乌克兰语',
languageRussian: '俄语',
appTheme: '应用主题',
theme: '主题',
ariaLabel: '应用主题',
system: '系统',
light: '浅色',
dark: '深色',
dracula: 'Dracula',
nord: 'Nord',
systemAppearance: '系统跟随当前的 {{platform}} 浅色或深色外观。',
display: '显示',
fontSize: '字体大小',
fontSizeDescription: '缩放下载列表和紧凑型控件。',
small: '小',
standard: '标准',
large: '大',
listDensity: '列表密度',
listDensityDescription: '更改行高、间距和进度条的缩放。',
compact: '紧凑',
comfortable: '舒适',
relaxed: '宽松',
osIntegration: '系统集成',
dockBadge: '在 Dock 图标上显示角标',
dockBadgeDescription: '在 Firelink 的 Dock 图标上显示活动下载计数。',
menuBarIcon: '显示菜单栏图标',
statusIndicatorIcon: '显示状态指示器图标',
systemTrayIcon: '显示系统托盘图标',
macosTrayDescription: '提供从 macOS 菜单栏的快速访问。',
windowsTrayDescription: '提供从 Windows 通知区域的快速访问。',
linuxTrayDescription: '在可用时,提供从桌面托盘或状态区域的快速访问。',
defaultTrayDescription: '在可用时,提供从操作系统托盘区域的快速访问。',
},
network: {
proxy: '代理',
mode: '模式',
modeDescription: '控制新下载请求的代理使用。',
noProxy: '无代理',
systemProxy: '系统代理',
customProxy: '自定义代理',
proxyHost: '代理主机',
proxyHostDescription: '主机名、IP 地址或 HTTP 代理 URL。',
proxyHostPlaceholder: '127.0.0.1 或 http://127.0.0.1',
proxyPort: '代理端口',
proxyPortDescription: '有效范围是 1 到 65535。',
noProxyDescription: '下载忽略已配置的代理。',
systemProxyDescription: '下载使用检测到的 {{platform}} 系统代理。普通文件下载需要 HTTP 或 HTTPS 代理端点;媒体下载可以使用 SOCKS。',
customProxyDescription: '下载使用配置的 HTTP 代理端点来获取元数据和进行引擎下载。',
invalidCustomProxy: '输入有效的 HTTP 代理主机和端口以启用自定义代理。',
incompleteCustomProxy: '输入代理主机和端口以启用自定义代理。',
checkingSystemProxy: '正在检查系统代理配置…',
detectedSystemProxy: '检测到系统代理。普通文件下载需要 HTTP 或 HTTPS 端点;媒体下载可以使用 SOCKS。',
noSystemProxy: '未检测到可用的系统代理。下载将不使用代理。',
systemProxyReadFailed: '无法读取系统代理配置。请选择“无代理”,或者在其可用时重试。',
identity: '身份',
customUserAgent: '自定义 User-Agent',
userAgentDescription: '应用于元数据获取和下载引擎。',
userAgentPlaceholder: '留空以使用 Firelink 默认值',
userAgentOverrides: '覆盖传出的 User-Agent 请求头。留空以使用 Firelink 默认值。',
chromeWindows: 'Chrome (Windows)',
chromeMacos: 'Chrome (macOS)',
edgeWindows: 'Edge (Windows)',
firefoxWindows: 'Firefox (Windows)',
firefoxMacos: 'Firefox (macOS)',
safariMacos: 'Safari (macOS)',
windowsDesktop: 'Windows 桌面',
macosDesktop: 'macOS 桌面',
},
locations: {
baseDownloadFolder: '基本下载文件夹',
baseDownloadFolderDescription: '在此文件夹中创建自动类别文件夹。',
browse: '浏览',
baseFolderCreateFailed: '基本文件夹已保存,但无法创建类别文件夹:{{detail}}',
baseFolderUpdated: '基本下载文件夹已更新',
askWhereToSave: '在添加下载时询问保存位置',
rememberLastUsedDownloadDirectory: '记住上次使用的下载目录',
rememberLastUsedDownloadDirectoryDescription: '在下一次下载时,使用在“添加”窗口中最后选择的文件夹,直到应用程序重启。',
automaticCategorySubfolders: '自动将文件保存到类别子文件夹',
automaticCategorySubfoldersDescription: '关闭时,自动下载使用基本文件夹。',
categorySubfolders: '类别子文件夹',
relativeToBase: '相对于基本文件夹',
disabled: '已禁用',
categorySubfolder: '{{category}}子文件夹',
customFolder: '自定义文件夹',
useAutomatic: '使用自动',
resetDefaults: '重置为默认值',
resetToast: '已将类别位置重置为默认值',
},
siteLogins: {
title: '站点凭据',
noSavedLogins: '没有保存的登录信息。',
user: '用户:{{username}}',
deleteCredential: '删除凭据',
deletedCredential: '已删除凭据',
deleteFailed: '无法删除凭据:{{detail}}',
addTitle: '添加站点凭据',
urlPattern: 'URL 模式:',
urlPatternPlaceholder: '例如:*.example.com 或 example.com/downloads',
username: '用户名:',
password: '密码:',
usernamePlaceholder: '用户名',
passwordPlaceholder: '密码',
saving: '正在保存…',
addLogin: '添加登录',
urlPatternRequired: 'URL 模式是必需的。',
urlPatternWhitespace: 'URL 模式不能包含空格。',
usernameRequired: '用户名是必需的。',
passwordRequired: '密码是必需的。',
accessBeforeSave: '在保存站点登录之前,请授予凭据存储访问权限。',
saveFailed: '无法安全地保存站点凭据。',
addedCredential: '已添加站点凭据',
},
power: {
title: '电源管理',
preventSleep: '下载活动时阻止系统睡眠',
preventSleepDescription: '显示器可能仍会关闭。Firelink 仅保持设备唤醒足以完成活动的传输。',
},
engine: {
title: '媒体下载器和引擎',
description: '成功的结果将在此应用会话中重用。重新检查将再次运行真正的验证。',
recheck: '重新检查引擎',
coreDownloader: '核心下载器 (aria2)',
version: '版本:',
status: '状态:',
mediaExtractors: '媒体提取器',
browserCookiesSource: '浏览器 Cookies 来源:',
cookieDescription: 'yt-dlp 读取浏览器 cookies 以绕过视频下载限制或访问受限媒体。Firelink 不会保存浏览器 cookies。',
none: '无',
},
integrations: {
title: '连接浏览器扩展',
description: '通过三个简单的步骤直接从浏览器捕获下载。',
portablePairingEnabled: '便携配对已启用',
pairingTokenPersisted: '配对令牌已持久化',
portablePersistedDescription: '您的配对令牌与此便携式 Firelink 文件夹一起存储,并在文件夹移动时保留。请将该文件夹视为敏感内容。',
systemPersistedDescription: '您的配对令牌已存储在系统凭据存储中,并在重启后保留。',
portablePairingAvailable: '便携配对可用',
credentialStorageNeeded: '需要凭据存储',
portableAvailableDescription: '您的配对令牌与此便携式 Firelink 文件夹一起存储,并在重启后保留。在此处启用它以查看便携存储警告。',
credentialStorageDescription: 'Firelink 需要访问此系统的凭据存储以在应用重启时安全地保存您的配对令牌。目前,您的扩展仅在此会话中保持连接。',
reviewPortablePairing: '查看便携配对',
grantCredentialAccess: '授予凭据访问权限',
copyToken: '复制令牌',
tokenDescription: '此安全令牌用于授权您的浏览器扩展。',
tokenCopied: '令牌已复制到剪贴板!',
tokenCopyFailed: '无法复制令牌:{{detail}}',
pairingTokenRegenerated: '配对令牌已重新生成',
regenerateFailed: '无法重新生成配对令牌:{{detail}}',
getExtension: '获取扩展',
extensionDescription: '安装适用于 Firefox 或 Chromium 浏览器的 Firelink Companion。',
firefoxAddons: 'Firefox 附加组件',
chromiumZip: 'Chromium ZIP',
pasteConnect: '粘贴并连接',
pasteConnectDescription: '点击浏览器工具栏中的 Firelink 图标,然后粘贴复制的令牌。',
extensionServerStatus: '扩展服务器状态:',
listening: '● 正在 127.0.0.1:{{port}} 监听',
unavailable: '● 服务器不可用',
},
about: {
firelinkIcon: 'Firelink 图标',
unknown: '未知',
version: '版本 {{version}}',
description: '适用于 macOS、Windows 和 Linux 的跨平台下载管理器。',
sourceCode: '源代码',
source: '源码',
upToDate: 'Firelink {{version}} 已是最新版本。',
updateAvailable: 'Firelink {{version}} 可用。',
viewRelease: '查看发布版本',
updates: '更新',
checkForUpdates: '检查更新',
updateDescription: 'Firelink 会在 GitHub Releases 检查新版本。',
checking: '正在检查…',
checkNow: '立即检查',
automaticUpdates: '自动检查更新',
createdBy: '由 NimBold 创建',
mitLicense: 'MIT 许可证',
credits: '致谢:aria2、yt-dlp、FFmpeg 和 Deno。',
copyright: '版权所有 © 2026 NimBold。保留所有权利。',
},
},
status: {
ready: '就绪',
active: '{{count}} 个进行中',
queued: '{{count}} 个排队',
done: '{{count}} 个完成',
},
} as const;
export default zhCN;
+64
View File
@@ -0,0 +1,64 @@
import { describe, expect, it, vi } from 'vitest';
import i18n, { changeAppLocale, i18nReady, syncDocumentLocale } from './index';
import { APP_LOCALES, localeDirection, resolveAppLocale } from './locales';
describe('i18n runtime', () => {
it('selects every bundled catalog instead of falling back to English', async () => {
await i18nReady;
const originalLanguage = i18n.language;
try {
const expectedLibraryLabels = {
en: 'Library',
'zh-CN': '库',
he: 'ספרייה',
fa: 'کتابخانه',
uk: 'Бібліотека',
ru: 'Библиотека',
} as const;
for (const locale of APP_LOCALES) {
await changeAppLocale(locale);
expect(i18n.language).toBe(locale);
expect(i18n.t($ => $.navigation.library)).toBe(expectedLibraryLabels[locale]);
}
} finally {
await changeAppLocale(resolveAppLocale(originalLanguage));
}
});
it('syncs document language and direction for every supported locale', async () => {
const documentElement = { lang: '', dir: '' };
vi.stubGlobal('document', { documentElement });
try {
for (const locale of APP_LOCALES) {
await changeAppLocale(locale);
expect(documentElement).toEqual({ lang: locale, dir: localeDirection(locale) });
}
expect(syncDocumentLocale('de-DE')).toBe('en');
expect(documentElement).toEqual({ lang: 'en', dir: 'ltr' });
} finally {
vi.unstubAllGlobals();
}
});
it('uses English resources for an unknown i18next language', async () => {
await i18nReady;
const originalLanguage = i18n.language;
try {
await i18n.changeLanguage('de-DE');
expect(i18n.t($ => $.navigation.library)).toBe('Library');
expect(resolveAppLocale('de-DE')).toBe('en');
expect(resolveAppLocale('ru-RU')).toBe('ru');
expect(resolveAppLocale('fa-IR')).toBe('fa');
expect(resolveAppLocale('he-IL')).toBe('he');
expect(resolveAppLocale('uk-UA')).toBe('uk');
expect(resolveAppLocale('zh-Hans-CN')).toBe('zh-CN');
} finally {
await changeAppLocale(resolveAppLocale(originalLanguage));
}
});
});
+115
View File
@@ -0,0 +1,115 @@
import i18n from 'i18next';
import { initReactI18next } from 'react-i18next';
import englishCommon from './catalogs/en';
import {
APP_LOCALES,
DEFAULT_LOCALE,
localeDirection,
resolveAppLocale,
type AppLocale,
} from './locales';
export { APP_LOCALES, APP_LOCALE_METADATA, APP_LOCALE_PREFERENCES, DEFAULT_LOCALE, isAppLocale, isAppLocalePreference, localeDirection, localePluralVariant, resolveAppLocale } from './locales';
export type { AppLocale, AppLocaleDirection, AppLocalePreference, AppPluralVariant } from './locales';
const defaultNS = 'common' as const;
const initialLocale = resolveAppLocale(
typeof navigator === 'undefined' ? undefined : navigator.language
);
type CommonCatalog = Parameters<typeof i18n.addResourceBundle>[2];
type LocaleLoader = () => Promise<CommonCatalog>;
const localeLoaders: Record<AppLocale, LocaleLoader> = {
en: async () => englishCommon,
'zh-CN': async () => (await import('./catalogs/zh-CN')).default,
he: async () => (await import('./catalogs/he')).default,
fa: async () => (await import('./catalogs/fa')).default,
uk: async () => (await import('./catalogs/uk')).default,
ru: async () => (await import('./catalogs/ru')).default,
};
const loadedLocales = new Set<AppLocale>([DEFAULT_LOCALE]);
const localeLoadPromises = new Map<AppLocale, Promise<void>>();
let localeChangeRequest = 0;
const loadLocale = (locale: AppLocale): Promise<void> => {
if (loadedLocales.has(locale)) return Promise.resolve();
const existingLoad = localeLoadPromises.get(locale);
if (existingLoad) return existingLoad;
const load = localeLoaders[locale]().then(catalog => {
i18n.addResourceBundle(locale, defaultNS, catalog, true, true);
loadedLocales.add(locale);
localeLoadPromises.delete(locale);
}).catch(error => {
localeLoadPromises.delete(locale);
throw error;
});
localeLoadPromises.set(locale, load);
return load;
};
const fallbackToEnglish = async (requestedLocale: AppLocale, error: unknown): Promise<void> => {
console.error(`Failed to load locale "${requestedLocale}"; falling back to English.`, error);
await i18n.changeLanguage(DEFAULT_LOCALE);
syncDocumentLocale(DEFAULT_LOCALE);
};
export const i18nReady = i18n
.use(initReactI18next)
.init({
defaultNS,
fallbackLng: DEFAULT_LOCALE,
initAsync: false,
lng: DEFAULT_LOCALE,
interpolation: {
escapeValue: false,
},
ns: [defaultNS],
resources: {
[DEFAULT_LOCALE]: { [defaultNS]: englishCommon },
},
returnNull: false,
supportedLngs: APP_LOCALES,
})
.then(async () => {
try {
await loadLocale(initialLocale);
await i18n.changeLanguage(initialLocale);
syncDocumentLocale(initialLocale);
} catch (error) {
await fallbackToEnglish(initialLocale, error);
}
});
export const changeAppLocale = async (locale: AppLocale): Promise<void> => {
const request = ++localeChangeRequest;
await i18nReady;
if (request !== localeChangeRequest) return;
try {
await loadLocale(locale);
if (request !== localeChangeRequest) return;
await i18n.changeLanguage(locale);
if (request === localeChangeRequest) syncDocumentLocale(locale);
} catch (error) {
if (request === localeChangeRequest) {
await fallbackToEnglish(locale, error);
}
}
};
export const syncDocumentLocale = (value: string | null | undefined): AppLocale => {
const locale = resolveAppLocale(value);
if (typeof document !== 'undefined') {
document.documentElement.lang = locale;
document.documentElement.dir = localeDirection(locale);
}
return locale;
};
export default i18n;
+56
View File
@@ -0,0 +1,56 @@
import { describe, expect, it } from 'vitest';
import {
APP_LOCALES,
DEFAULT_LOCALE,
isAppLocalePreference,
isAppLocale,
localeDirection,
localePluralVariant,
resolveAppLocale,
} from './locales';
describe('app locale metadata', () => {
it('keeps the planned locale set stable', () => {
expect(APP_LOCALES).toEqual(['en', 'zh-CN', 'he', 'fa', 'uk', 'ru']);
expect(DEFAULT_LOCALE).toBe('en');
});
it('normalizes common system language tags', () => {
expect(resolveAppLocale('en-US')).toBe('en');
expect(resolveAppLocale('zh-Hans-CN')).toBe('zh-CN');
expect(resolveAppLocale('iw-IL')).toBe('he');
expect(resolveAppLocale('fa-IR')).toBe('fa');
expect(resolveAppLocale('uk-UA')).toBe('uk');
expect(resolveAppLocale('ru-RU')).toBe('ru');
expect(resolveAppLocale('de-DE')).toBe('en');
});
it('marks the planned RTL locales correctly', () => {
expect(localeDirection('en')).toBe('ltr');
expect(localeDirection('zh-CN')).toBe('ltr');
expect(localeDirection('he')).toBe('rtl');
expect(localeDirection('fa')).toBe('rtl');
expect(localeDirection('uk')).toBe('ltr');
expect(localeDirection('ru')).toBe('ltr');
});
it('selects locale-aware plural categories', () => {
expect(localePluralVariant('en', 1)).toBe('one');
expect(localePluralVariant('en', 2)).toBe('many');
expect(localePluralVariant('ru-RU', 1)).toBe('one');
expect(localePluralVariant('ru-RU', 2)).toBe('few');
expect(localePluralVariant('ru-RU', 5)).toBe('many');
expect(localePluralVariant('uk-UA', 2)).toBe('few');
});
it('guards locale values before they reach i18next', () => {
expect(isAppLocale('en')).toBe(true);
expect(isAppLocale('fa')).toBe(true);
expect(isAppLocale('ru')).toBe(true);
expect(isAppLocale('de')).toBe(false);
expect(isAppLocale(null)).toBe(false);
expect(isAppLocalePreference('system')).toBe(true);
expect(isAppLocalePreference('fa')).toBe(true);
expect(isAppLocalePreference('de')).toBe(false);
});
});
+58
View File
@@ -0,0 +1,58 @@
export const APP_LOCALES = ['en', 'zh-CN', 'he', 'fa', 'uk', 'ru'] as const;
export const APP_LOCALE_PREFERENCES = ['system', ...APP_LOCALES] as const;
export type AppLocale = typeof APP_LOCALES[number];
export type AppLocalePreference = typeof APP_LOCALE_PREFERENCES[number];
export type AppLocaleDirection = 'ltr' | 'rtl';
export type AppPluralVariant = 'one' | 'few' | 'many';
export const DEFAULT_LOCALE: AppLocale = 'en';
export const APP_LOCALE_METADATA: Record<AppLocale, {
direction: AppLocaleDirection;
isTranslated: boolean;
}> = {
en: { direction: 'ltr', isTranslated: true },
'zh-CN': { direction: 'ltr', isTranslated: true },
he: { direction: 'rtl', isTranslated: true },
fa: { direction: 'rtl', isTranslated: true },
uk: { direction: 'ltr', isTranslated: true },
ru: { direction: 'ltr', isTranslated: true },
};
export const isAppLocale = (value: unknown): value is AppLocale =>
typeof value === 'string' && (APP_LOCALES as readonly string[]).includes(value);
export const isAppLocalePreference = (value: unknown): value is AppLocalePreference =>
typeof value === 'string' && (APP_LOCALE_PREFERENCES as readonly string[]).includes(value);
/**
* Resolve a system or persisted BCP 47 language tag to a bundled Firelink
* locale. Unknown languages fall back to English.
*/
export const resolveAppLocale = (value: string | null | undefined): AppLocale => {
const normalized = value?.trim().replace(/_/g, '-').toLowerCase();
if (!normalized) return DEFAULT_LOCALE;
if (normalized === 'zh-cn' || normalized.startsWith('zh-')) return 'zh-CN';
if (normalized === 'he' || normalized.startsWith('he-') || normalized === 'iw' || normalized.startsWith('iw-')) return 'he';
if (normalized === 'fa' || normalized.startsWith('fa-')) return 'fa';
if (normalized === 'uk' || normalized.startsWith('uk-')) return 'uk';
if (normalized === 'ru' || normalized.startsWith('ru-')) return 'ru';
if (normalized === 'en' || normalized.startsWith('en-')) return 'en';
return DEFAULT_LOCALE;
};
export const localePluralVariant = (
value: string | null | undefined,
count: number
): AppPluralVariant => {
const category = new Intl.PluralRules(resolveAppLocale(value)).select(Math.abs(count));
if (category === 'one') return 'one';
if (category === 'few') return 'few';
return 'many';
};
export const localeDirection = (locale: AppLocale): AppLocaleDirection =>
APP_LOCALE_METADATA[locale].direction;
+111
View File
@@ -0,0 +1,111 @@
import { describe, expect, it } from 'vitest';
import { APP_LOCALES } from './locales';
import { resources } from './resources';
type CatalogNode = string | { readonly [key: string]: CatalogNode };
const flattenCatalog = (
node: CatalogNode,
prefix = ''
): Map<string, string> => {
const entries = new Map<string, string>();
if (typeof node === 'string') {
entries.set(prefix, node);
return entries;
}
for (const [key, value] of Object.entries(node)) {
const path = prefix ? `${prefix}.${key}` : key;
for (const [leafPath, leafValue] of flattenCatalog(value, path)) {
entries.set(leafPath, leafValue);
}
}
return entries;
};
const interpolationTokens = (value: string): string[] =>
[...value.matchAll(/\{\{([A-Za-z0-9_]+)\}\}/g)]
.map((match) => match[1])
.sort();
const catalogFor = (locale: keyof typeof resources): Map<string, string> =>
flattenCatalog(resources[locale].common);
describe('translation catalogs', () => {
it('registers a catalog for every supported locale', () => {
expect(Object.keys(resources).sort()).toEqual([...APP_LOCALES].sort());
});
it('keeps the English catalog free of translated-script contamination', () => {
const suspiciousValues = [...catalogFor('en').entries()]
.filter(([, value]) => /[\u0590-\u05FF\u0600-\u06FF\u0400-\u04FF\u4E00-\u9FFF]/u.test(value))
.map(([key, value]) => `${key} = ${JSON.stringify(value)}`);
expect(suspiciousValues).toEqual([]);
});
it('keeps every locale structurally identical to English', () => {
const englishKeys = [...catalogFor('en').keys()].sort();
for (const locale of APP_LOCALES) {
const localeKeys = [...catalogFor(locale).keys()].sort();
expect({ locale, missing: englishKeys.filter((key) => !localeKeys.includes(key)), extra: localeKeys.filter((key) => !englishKeys.includes(key)) }).toEqual({
locale,
missing: [],
extra: [],
});
}
});
it('preserves interpolation tokens exactly in every translation', () => {
const english = catalogFor('en');
const allMismatches = APP_LOCALES.flatMap((locale) => {
const catalog = catalogFor(locale);
return [...english.entries()]
.map(([key, englishValue]) => ({
locale,
key,
expected: interpolationTokens(englishValue),
actual: interpolationTokens(catalog.get(key) ?? ''),
}))
.filter(({ expected, actual }) => JSON.stringify(expected) !== JSON.stringify(actual));
});
expect(allMismatches).toEqual([]);
});
it('reports exact English duplicates for translation review', () => {
const english = catalogFor('en');
const duplicates = APP_LOCALES.filter((locale) => locale !== 'en').flatMap((locale) => {
const catalog = catalogFor(locale);
return [...english.entries()]
.filter(([key, englishValue]) => englishValue.length >= 4 && catalog.get(key) === englishValue)
.map(([key, value]) => ({ locale, key, value }));
});
const allowedExactKeys = new Set([
'addDownloads.cookiePlaceholder',
'downloadTable.interactionError',
'keychain.stores.windows',
'keychain.stores.macos',
'settings.common.stderr',
'settings.integrations.chromiumZip',
'settings.lookAndFeel.dracula',
'settings.lookAndFeel.nord',
'settings.network.chromeWindows',
'settings.network.chromeMacos',
'settings.network.edgeWindows',
'settings.network.firefoxWindows',
'settings.network.firefoxMacos',
'settings.network.safariMacos',
]);
const unexpectedDuplicates = duplicates
.filter(({ key }) => !allowedExactKeys.has(key))
.map(({ locale, key, value }) => `${locale}:${key} = ${JSON.stringify(value)}`);
expect(unexpectedDuplicates).toEqual([]);
});
});
+17
View File
@@ -0,0 +1,17 @@
import en from "./catalogs/en";
import fa from "./catalogs/fa";
import he from "./catalogs/he";
import ru from "./catalogs/ru";
import uk from "./catalogs/uk";
import zhCN from "./catalogs/zh-CN";
export const defaultNS = "common" as const;
export const resources = {
en: { common: en },
"zh-CN": { common: zhCN },
fa: { common: fa },
he: { common: he },
uk: { common: uk },
ru: { common: ru },
} as const;
+11
View File
@@ -0,0 +1,11 @@
import 'i18next';
import type { defaultNS, resources } from './i18n/resources';
declare module 'i18next' {
interface CustomTypeOptions {
defaultNS: typeof defaultNS;
enableSelector: 'optimize';
resources: typeof resources['en'];
returnNull: false;
}
}
+671 -62
View File
File diff suppressed because it is too large Load Diff
+23 -4
View File
@@ -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,9 +38,10 @@ 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 };
begin_dock_badge_session: { args: undefined; result: number };
update_dock_badge: { args: { count: number; generation: number; session: number }; result: void };
get_platform_info: { args: undefined; result: PlatformInfo };
approve_download_root: { args: { path: string }; result: string };
set_prevent_sleep: { args: { prevent: boolean }; result: void };
@@ -49,14 +56,23 @@ 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 };
authorize_keychain_access: { args: undefined; result: void };
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 +87,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 };
};
+10 -2
View File
@@ -2,6 +2,7 @@ import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import "./index.css";
import App from "./App";
import { i18nReady } from "./i18n";
import { ErrorBoundary } from "./components/ErrorBoundary";
import { ToastProvider } from "./contexts/ToastContext";
import { error as logError, warn as logWarn, initLogger } from "./utils/logger";
@@ -34,7 +35,9 @@ console.warn = (...values: unknown[]) => {
};
const rootElement = document.getElementById("root");
if (rootElement) {
const renderApp = () => {
if (!rootElement) return;
createRoot(rootElement).render(
<StrictMode>
<ErrorBoundary>
@@ -44,7 +47,12 @@ if (rootElement) {
</ErrorBoundary>
</StrictMode>,
);
}
};
void i18nReady.then(renderApp).catch(error => {
console.error('Failed to initialize localization:', error);
renderApp();
});
// Prevent the webview's default context menu ("Reload", etc.) on right-click.
// Individual components that provide custom context menus call preventDefault()
+26
View File
@@ -0,0 +1,26 @@
import { create } from 'zustand';
import type { DownloadProgressEvent } from '../bindings/DownloadProgressEvent';
interface DownloadProgressState {
progressMap: Record<string, DownloadProgressEvent>;
updateDownloadProgress: (id: string, payload: DownloadProgressEvent) => void;
clearDownloadProgress: (id: string) => void;
}
export const useDownloadProgressStore = create<DownloadProgressState>((set) => ({
progressMap: {},
updateDownloadProgress: (id, payload) =>
set((state) => ({
progressMap: {
...state.progressMap,
[id]: payload,
},
})),
clearDownloadProgress: (id) =>
set((state) => {
if (!(id in state.progressMap)) return state;
const next = { ...state.progressMap };
delete next[id];
return { progressMap: next };
}),
}));
+400 -2
View File
@@ -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,388 @@ 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('keeps Aria2 connection telemetry from the live progress 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: 'connection-telemetry',
url: 'https://github.com/example/release.zip',
fileName: 'release.zip',
status: 'downloading',
category: 'Compressed',
dateAdded: ''
}]
});
const release = await initDownloadListener();
handlers['download-progress']({ payload: {
id: 'connection-telemetry',
fraction: 0.2,
speed: '3 MB/s',
eta: '10s',
size: '38 MB',
size_is_final: false,
active_connections: 16,
requested_connections: 16
} });
expect(useDownloadProgressStore.getState().progressMap['connection-telemetry'])
.toMatchObject({ active_connections: 16, requested_connections: 16 });
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();
});
});
+212 -99
View File
@@ -1,133 +1,246 @@
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 shouldUpdateSize = Boolean(payload.size && (!current.isMedia || payload.size_is_final));
if (shouldUpdateSize && current.size !== payload.size) {
mainStore.updateDownload(payload.id, { size: payload.size! });
}
}
});
if (!unlistenState) {
unlistenState = await listen('download-state', (event) => {
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) {
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;
}
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 = '-';
}
if (!current) {
// A removed row can still have one queued sidecar event in flight.
// Do not let that event recreate an orphaned progress entry.
useDownloadProgressStore.getState().clearDownloadProgress(payload.id);
return;
}
// A sidecar can flush one last progress chunk after a pause, failure,
// completion, or lifecycle reset. Do not let that stale chunk repopulate
// the live progress map or overwrite a later lifecycle's first frame.
if (!['downloading', 'processing'].includes(current.status)) {
useDownloadProgressStore.getState().clearDownloadProgress(payload.id);
return;
}
useDownloadProgressStore.getState().updateDownloadProgress(payload.id, payload);
const shouldUpdateSize = Boolean(payload.size && (!current.isMedia || payload.size_is_final));
const updates: Partial<DownloadItem> = {};
if (current.status === 'downloading' || current.status === 'processing') {
updates.fraction = payload.fraction;
updates.speed = payload.speed;
updates.eta = payload.eta;
}
if (shouldUpdateSize && current.size !== payload.size) {
updates.size = payload.size!;
}
if (payload.downloaded_bytes !== null && payload.downloaded_bytes !== undefined) {
updates.downloadedBytes = payload.downloaded_bytes;
}
if (payload.total_bytes !== null && payload.total_bytes !== undefined) {
updates.totalBytes = payload.total_bytes;
}
if (payload.total_is_estimate !== null && payload.total_is_estimate !== undefined) {
updates.totalIsEstimate = payload.total_is_estimate;
}
const observedDownloadedBytes = Math.max(
current.downloadedBytes ?? 0,
payload.downloaded_bytes ?? 0
);
// Older lifecycles may have persisted yt-dlp's temporary fragmented
// estimate (often 1 KiB). Once actual bytes exceed it and the current
// progress frame has no reliable total, discard that stale denominator
// so it cannot survive a pause, queue transition, or app restart.
if (payload.total_bytes == null && hasStaleTemporaryMediaEstimate({
isMedia: current.isMedia,
downloadedBytes: observedDownloadedBytes,
totalBytes: current.totalBytes,
totalIsEstimate: current.totalIsEstimate,
size: current.size
})) {
updates.size = undefined;
updates.totalBytes = undefined;
updates.totalIsEstimate = undefined;
}
if (Object.keys(updates).length > 0) {
mainStore.updateDownload(payload.id, updates);
}
}),
listen('download-state', (event) => {
const payload = event.payload;
const mainStore = useDownloadStore.getState();
const current = mainStore.downloads.find(d => d.id === payload.id);
if (!current) {
useDownloadProgressStore.getState().clearDownloadProgress(payload.id);
return;
}
const status = payload.status as DownloadStatus;
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]);
}
}
// 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 === '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);
// Prevent stale lifecycle events from moving a paused row back into an
// active state. A pause request can finish before one already-emitted
// worker event reaches the frontend. Resume paths set the row to queued
// before asking the backend to resume, so an active event arriving while
// the row is still paused cannot represent a new lifecycle.
if ((current.status === 'completed' || current.status === 'failed') &&
status !== current.status) {
return;
}
if (current.status === 'paused' &&
status !== 'paused' &&
status !== 'completed' &&
status !== 'failed') {
return;
}
const progress = useDownloadProgressStore.getState().progressMap[payload.id];
if (['queued', 'retrying', 'completed', 'failed', 'paused'].includes(status)) {
useDownloadProgressStore.getState().clearDownloadProgress(payload.id);
}
const 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();
};
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+201
View File
@@ -0,0 +1,201 @@
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('last used download directory preference', () => {
it('is disabled by default', () => {
expect(useSettingsStore.getState().rememberLastUsedDownloadDirectory).toBe(false);
});
});
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 dock badge synchronization', () => {
it('increments the badge sync version for every toggle without issuing out-of-band clears', () => {
vi.clearAllMocks();
const initialVersion = useSettingsStore.getState().dockBadgeSyncVersion;
useSettingsStore.getState().setShowDockBadge(false);
useSettingsStore.getState().setShowDockBadge(true);
expect(useSettingsStore.getState().dockBadgeSyncVersion).toBe(initialVersion + 2);
expect(ipc.invokeCommand).not.toHaveBeenCalledWith('update_dock_badge', { count: 0 });
});
});
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();
});
});
+307 -64
View File
@@ -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,102 @@ import {
DEFAULT_CATEGORY_SUBFOLDERS,
normalizeDownloadLocationSettings
} from '../utils/downloadLocations';
import { normalizeSpeedLimitForBackend } from '../utils/downloads';
import i18n from '../i18n';
import { isAppLocalePreference, type AppLocalePreference } from '../i18n/locales';
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 SIDEBAR_POSITION_VALUES = ['auto', 'left', 'right'] 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 & {
language: AppLocalePreference;
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 +128,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 +148,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,
@@ -70,18 +165,25 @@ export type {
Theme
};
export type SidebarPosition = 'auto' | 'left' | 'right';
export interface SettingsState {
theme: Theme;
language: AppLocalePreference;
baseDownloadFolder: string;
categorySubfoldersEnabled: boolean;
categorySubfolders: Record<string, string>;
categoryDirectoryOverrides: Record<string, string>;
rememberLastUsedDownloadDirectory: boolean;
/** Session-only path selected in the Add window; intentionally not persisted. */
lastUsedDownloadDirectory: string | null;
approvedDownloadRoots: string[];
maxConcurrentDownloads: number;
globalSpeedLimit: string;
speedLimitPresetValues: number[];
logsEnabled: boolean;
isSidebarVisible: boolean;
sidebarPosition: SidebarPosition;
activeView: ActiveView;
activeSettingsTab: SettingsTab;
scheduler: SchedulerSettings;
@@ -90,15 +192,19 @@ 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;
/** Forces the App-level badge effect to run for every toggle request. */
dockBadgeSyncVersion: number;
showMenuBarIcon: boolean;
proxyMode: ProxyMode;
proxyHost: string;
@@ -111,16 +217,21 @@ export interface SettingsState {
extensionPairingToken: string;
isPairingTokenPersistent: boolean;
keychainAccessGranted: boolean;
keychainAccessVersion: string;
keychainAccessReady: boolean;
keychainPromptDismissed: boolean;
autoCheckUpdates: boolean;
showKeychainModal: boolean;
setTheme: (theme: Theme) => void;
setLanguage: (language: AppLocalePreference) => void;
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;
setSidebarPosition: (position: SidebarPosition) => void;
setActiveView: (view: ActiveView) => void;
setActiveSettingsTab: (tab: SettingsTab) => void;
setScheduler: (settings: SchedulerSettings) => void;
@@ -129,12 +240,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;
@@ -146,6 +259,8 @@ export interface SettingsState {
setAskWhereToSaveEachFile: (ask: boolean) => void;
setPreventsSleepWhileDownloading: (prevent: boolean) => void;
setMediaCookieSource: (source: MediaCookieSource) => void;
setRememberLastUsedDownloadDirectory: (enabled: boolean) => void;
setLastUsedDownloadDirectory: (path: string) => void;
setCategorySubfoldersEnabled: (enabled: boolean) => void;
setCategorySubfolder: (category: string, subfolder: string) => void;
setCategoryDirectoryOverride: (category: string, path?: string) => void;
@@ -154,44 +269,24 @@ 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',
language: 'system',
baseDownloadFolder: '~/Downloads',
categorySubfoldersEnabled: true,
categorySubfolders: { ...DEFAULT_CATEGORY_SUBFOLDERS },
categoryDirectoryOverrides: {},
rememberLastUsedDownloadDirectory: false,
lastUsedDownloadDirectory: null,
approvedDownloadRoots: [],
maxConcurrentDownloads: 3,
globalSpeedLimit: '',
@@ -200,6 +295,7 @@ export const useSettingsStore = create<SettingsState>()(
activeView: 'downloads',
activeSettingsTab: 'downloads',
isSidebarVisible: true,
sidebarPosition: 'auto',
scheduler: {
enabled: false,
startTime: '00:00',
@@ -215,15 +311,18 @@ 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,
dockBadgeSyncVersion: 0,
showMenuBarIcon: true,
proxyMode: 'none',
proxyHost: '',
@@ -234,12 +333,16 @@ export const useSettingsStore = create<SettingsState>()(
mediaCookieSource: 'none',
siteLogins: [],
extensionPairingToken: '',
isPairingTokenPersistent: true,
isPairingTokenPersistent: false,
keychainAccessGranted: false,
keychainAccessVersion: '',
keychainAccessReady: false,
keychainPromptDismissed: false,
autoCheckUpdates: true,
showKeychainModal: false,
setTheme: (theme) => { info('Settings updated: theme'); set({ theme }); },
setLanguage: (language) => { info('Settings updated: language'); set({ language }); },
setBaseDownloadFolder: (path) => {
info('Settings updated: baseDownloadFolder');
set({ baseDownloadFolder: path });
@@ -255,14 +358,20 @@ 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 });
},
setSpeedLimitPresetValues: (speedLimitPresetValues) => set({ speedLimitPresetValues }),
setLogsEnabled: (logsEnabled) => set({ logsEnabled }),
setSidebarPosition: (sidebarPosition) => set({ sidebarPosition }),
setActiveView: (view) => set({ activeView: view }),
setActiveSettingsTab: (activeSettingsTab) => set({ activeSettingsTab }),
setScheduler: (scheduler) => set({ scheduler }),
@@ -271,17 +380,25 @@ 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) => {
set({ showDockBadge });
if (!showDockBadge) invoke('update_dock_badge', { count: 0 }).catch(console.error);
set(state => ({
showDockBadge,
dockBadgeSyncVersion: state.dockBadgeSyncVersion + 1
}));
},
setShowMenuBarIcon: (showMenuBarIcon) => set({ showMenuBarIcon }),
setProxyMode: (proxyMode) => set({ proxyMode }),
@@ -298,6 +415,18 @@ export const useSettingsStore = create<SettingsState>()(
set({ preventsSleepWhileDownloading });
},
setMediaCookieSource: (mediaCookieSource) => { info('Settings updated: mediaCookieSource'); set({ mediaCookieSource }); },
setRememberLastUsedDownloadDirectory: (rememberLastUsedDownloadDirectory) => {
info('Settings updated: rememberLastUsedDownloadDirectory');
set({
rememberLastUsedDownloadDirectory,
...(rememberLastUsedDownloadDirectory ? {} : { lastUsedDownloadDirectory: null })
});
},
setLastUsedDownloadDirectory: (path) => {
const trimmedPath = path.trim();
if (!trimmedPath) return;
set({ lastUsedDownloadDirectory: trimmedPath });
},
setCategorySubfoldersEnabled: (categorySubfoldersEnabled) => {
info('Settings updated: categorySubfoldersEnabled');
set({ categorySubfoldersEnabled });
@@ -331,31 +460,59 @@ 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(i18n.t($ => $.keychain.accessRequired));
}
const result = await invoke('regenerate_pairing_token');
if (!result.persistent) {
throw new Error(result.error || i18n.t($ => $.keychain.storeUnavailable));
}
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',
storage: createJSONStorage(() => tauriStorage),
version: 3,
version: 5,
migrate: (persistedState) => {
if (!persistedState || typeof persistedState !== 'object') {
return persistedState as SettingsState;
@@ -389,18 +546,21 @@ export const useSettingsStore = create<SettingsState>()(
logsEnabled: persisted.logsEnabled === true
} as SettingsState;
},
partialize: (state): PersistedSettings => ({
partialize: (state): PersistedSettingsSnapshot => ({
theme: state.theme,
language: state.language,
baseDownloadFolder: state.baseDownloadFolder,
categorySubfoldersEnabled: state.categorySubfoldersEnabled,
categorySubfolders: state.categorySubfolders,
categoryDirectoryOverrides: state.categoryDirectoryOverrides,
rememberLastUsedDownloadDirectory: state.rememberLastUsedDownloadDirectory,
approvedDownloadRoots: state.approvedDownloadRoots,
maxConcurrentDownloads: state.maxConcurrentDownloads,
globalSpeedLimit: state.globalSpeedLimit,
speedLimitPresetValues: state.speedLimitPresetValues,
logsEnabled: state.logsEnabled,
isSidebarVisible: state.isSidebarVisible,
sidebarPosition: state.sidebarPosition,
activeSettingsTab: state.activeSettingsTab,
scheduler: state.scheduler,
schedulerRunning: state.schedulerRunning,
@@ -408,11 +568,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 +587,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 +601,91 @@ export const useSettingsStore = create<SettingsState>()(
...currentState,
...persisted,
...locations,
extensionPairingToken: currentState.extensionPairingToken,
// Never hydrate the remembered Add-window path from persisted data.
lastUsedDownloadDirectory: currentState.lastUsedDownloadDirectory,
keychainAccessReady: currentState.keychainAccessReady,
theme: isAllowedSetting(THEME_VALUES, persisted.theme)
? persisted.theme
: currentState.theme,
language: isAppLocalePreference(persisted.language)
? persisted.language
: currentState.language,
appFontSize: isAllowedSetting(APP_FONT_SIZE_VALUES, persisted.appFontSize)
? persisted.appFontSize
: currentState.appFontSize,
listRowDensity: isAllowedSetting(LIST_ROW_DENSITY_VALUES, persisted.listRowDensity)
? persisted.listRowDensity
: currentState.listRowDensity,
sidebarPosition: isAllowedSetting(SIDEBAR_POSITION_VALUES, persisted.sidebarPosition)
? persisted.sidebarPosition
: currentState.sidebarPosition,
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
),
rememberLastUsedDownloadDirectory: persistedBoolean(
persisted.rememberLastUsedDownloadDirectory,
currentState.rememberLastUsedDownloadDirectory
),
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 +698,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
});
}
+357 -1
View File
@@ -1,14 +1,18 @@
import { describe, expect, it } from 'vitest';
import {
appendRequestUrlsAfterVersion,
canSubmitMetadataRows,
mediaFormatSelectorForRow,
mediaFileNameForSelectedFormat,
metadataSummaryMessage,
isYouTubePlaylistUrl,
playlistFilePrefix,
reconcileDownloadRows,
refreshFailedMetadataRows,
updateRowIfCurrent,
type AddDownloadDraftRow
} from './addDownloadMetadata';
import i18n, { changeAppLocale } from '../i18n';
const row = (
overrides: Partial<AddDownloadDraftRow> = {}
@@ -56,6 +60,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 +237,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 +439,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,8 +528,29 @@ 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');
});
it('uses few forms for Russian and Ukrainian metadata summaries', async () => {
const originalLanguage = i18n.language;
const twoReadyRows = [row(), row({ id: 'row-2' })];
try {
await changeAppLocale('ru');
expect(metadataSummaryMessage(twoReadyRows)).toBe('Готово к добавлению: 2 загрузки.');
await changeAppLocale('uk');
expect(metadataSummaryMessage(twoReadyRows)).toBe('Готово до додавання: 2 завантаження.');
} finally {
await changeAppLocale(originalLanguage === 'uk' || originalLanguage === 'ru' ? originalLanguage : 'en');
}
});
});
+298 -27
View File
@@ -3,6 +3,9 @@ import {
fileNameFromUrl,
isMediaUrl
} from './downloads';
import type { MediaPlaylistMetadata } from '../bindings/MediaPlaylistMetadata';
import i18n from '../i18n';
import { localePluralVariant } from '../i18n/locales';
export type MetadataStatus = 'loading' | 'ready' | 'metadata-error' | 'invalid';
@@ -26,10 +29,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 +51,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 +110,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 +164,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 +240,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 +307,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
@@ -180,26 +363,114 @@ export const mediaFileNameForSelectedFormat = (
return canonicalizeDownloadFileName(`${baseName}.${selectedExt}`);
};
export type MetadataSummaryState =
| { type: 'empty' }
| { type: 'none-selected' }
| { type: 'invalid'; count: number }
| { type: 'loading'; count: number }
| { type: 'unsafe'; count: number }
| { type: 'media-error'; count: number }
| { type: 'all-error' }
| { type: 'fallback'; ready: number; failed: number }
| { type: 'ready'; count: number };
export const metadataSummaryState = (rows: AddDownloadDraftRow[]): MetadataSummaryState => {
if (rows.length === 0) return { type: 'empty' };
const selectedRows = rows.filter(row => row.selected !== false);
if (selectedRows.length === 0) return { type: 'none-selected' };
const invalid = selectedRows.filter(row => row.status === 'invalid').length;
if (invalid > 0) return { type: 'invalid', count: invalid };
const loading = selectedRows.filter(row => row.status === 'loading').length;
if (loading > 0) return { type: 'loading', count: loading };
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 { type: 'unsafe', count: blocked };
if (failedMedia > 0) return { type: 'media-error', count: failedMedia };
if (failed === selectedRows.length) return { type: 'all-error' };
if (failed > 0) return { type: 'fallback', ready, failed };
return { type: 'ready', count: ready };
};
export const metadataSummaryMessage = (rows: AddDownloadDraftRow[]): string => {
if (rows.length === 0) return 'Paste one or more links.';
const pluralMessage = (
count: number,
one: () => string,
few: () => string,
many: () => string
): string => {
switch (localePluralVariant(i18n.language, count)) {
case 'one': return one();
case 'few': return few();
case 'many': return many();
}
};
const invalid = rows.filter(row => row.status === 'invalid').length;
if (rows.length === 0) return i18n.t($ => $.addDownloads.pasteOneOrMore);
const selectedRows = rows.filter(row => row.selected !== false);
if (selectedRows.length === 0) return i18n.t($ => $.addDownloads.selectAtLeastOne);
const invalid = selectedRows.filter(row => row.status === 'invalid').length;
if (invalid > 0) {
return `Correct or remove ${invalid} invalid URL${invalid === 1 ? '' : 's'} before continuing.`;
return pluralMessage(
invalid,
() => i18n.t($ => $.addDownloads.correctInvalidOne, { count: invalid }),
() => i18n.t($ => $.addDownloads.correctInvalidFew, { count: invalid }),
() => i18n.t($ => $.addDownloads.correctInvalidMany, { count: invalid })
);
}
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'}.`;
return pluralMessage(
loading,
() => i18n.t($ => $.addDownloads.waitingForMetadataOne, { count: loading }),
() => i18n.t($ => $.addDownloads.waitingForMetadataFew, { count: loading }),
() => i18n.t($ => $.addDownloads.waitingForMetadataMany, { count: loading })
);
}
const failed = rows.filter(row => row.status === 'metadata-error').length;
const ready = rows.filter(row => row.status === 'ready').length;
if (failed === rows.length) {
return 'Metadata is unavailable. Downloads can still be added using fallback details.';
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 pluralMessage(
blocked,
() => i18n.t($ => $.addDownloads.removeUnsafeOne, { count: blocked }),
() => i18n.t($ => $.addDownloads.removeUnsafeFew, { count: blocked }),
() => i18n.t($ => $.addDownloads.removeUnsafeMany, { count: blocked })
);
}
if (failedMedia > 0) {
return pluralMessage(
failedMedia,
() => i18n.t($ => $.addDownloads.mediaMetadataUnavailableSummaryOne, { count: failedMedia }),
() => i18n.t($ => $.addDownloads.mediaMetadataUnavailableSummaryFew, { count: failedMedia }),
() => i18n.t($ => $.addDownloads.mediaMetadataUnavailableSummaryMany, { count: failedMedia })
);
}
if (failed === selectedRows.length) {
return i18n.t($ => $.addDownloads.metadataUnavailableFallback);
}
if (failed > 0) {
return `${ready} download${ready === 1 ? '' : 's'} ready; ${failed} will use fallback filename and unknown size.`;
return pluralMessage(
ready,
() => i18n.t($ => $.addDownloads.fallbackReadyOne, { ready, failed }),
() => i18n.t($ => $.addDownloads.fallbackReadyFew, { ready, failed }),
() => i18n.t($ => $.addDownloads.fallbackReadyMany, { ready, failed })
);
}
return `Ready to add ${ready} download${ready === 1 ? '' : 's'}.`;
return pluralMessage(
ready,
() => i18n.t($ => $.addDownloads.readyToAddOne, { count: ready }),
() => i18n.t($ => $.addDownloads.readyToAddFew, { count: ready }),
() => i18n.t($ => $.addDownloads.readyToAddMany, { count: ready })
);
};
+32
View File
@@ -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);
});
});
+7
View File
@@ -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);
};
+36
View File
@@ -0,0 +1,36 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import * as ipc from '../ipc';
import { updateDockBadge } from './dockBadge';
vi.mock('../ipc', () => ({
invokeCommand: vi.fn()
}));
describe('dock badge synchronization', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(ipc.invokeCommand).mockImplementation(async command => (
command === 'begin_dock_badge_session' ? 1 : undefined
));
});
it('attaches increasing generations to concurrent updates', async () => {
await Promise.all([updateDockBadge(3), updateDockBadge(0)]);
const calls = vi.mocked(ipc.invokeCommand).mock.calls;
expect(calls).toHaveLength(3);
expect(calls[0]).toEqual(['begin_dock_badge_session']);
const badgeCalls = calls.slice(1);
expect(badgeCalls[0]).toEqual([
'update_dock_badge',
{ count: 3, generation: expect.any(Number), session: 1 }
]);
expect(badgeCalls[1]).toEqual([
'update_dock_badge',
{ count: 0, generation: expect.any(Number), session: 1 }
]);
const firstBadgeArgs = badgeCalls[0][1] as { generation: number };
const secondBadgeArgs = badgeCalls[1][1] as { generation: number };
expect(secondBadgeArgs.generation).toBe(firstBadgeArgs.generation + 1);
});
});
+25
View File
@@ -0,0 +1,25 @@
import { invokeCommand } from '../ipc';
let dockBadgeGeneration = 0;
let dockBadgeSessionRequest: Promise<number> | null = null;
const getDockBadgeSession = (): Promise<number> => {
if (!dockBadgeSessionRequest) {
const request = invokeCommand('begin_dock_badge_session');
dockBadgeSessionRequest = request.catch(error => {
dockBadgeSessionRequest = null;
throw error;
});
}
return dockBadgeSessionRequest;
};
/**
* Attach the backend session and a per-session generation to every badge
* update so stale main-thread callbacks cannot overwrite a newer session.
*/
export const updateDockBadge = async (count: number): Promise<void> => {
const session = await getDockBadgeSession();
const generation = ++dockBadgeGeneration;
await invokeCommand('update_dock_badge', { count, generation, session });
};

Some files were not shown because too many files have changed in this diff Show More