mirror of
https://github.com/nimbold/Firelink.git
synced 2026-07-27 04:19:19 +00:00
Compare commits
59 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d6b5d6a944 | |||
| efeacf737a | |||
| 6ae790f2c5 | |||
| 07efb3667d | |||
| 512a77f6bb | |||
| 030db52016 | |||
| d1452ce0c1 | |||
| 8e706b598c | |||
| 297f2c08fb | |||
| 16377aa0d6 | |||
| 20a136cd1f | |||
| e1f83b4f19 | |||
| a98cce0980 | |||
| 81381a5a93 | |||
| 886388d5f2 | |||
| 69ce2b15ba | |||
| 621652ae13 | |||
| e0cb124720 | |||
| c133556d38 | |||
| f423d9bee3 | |||
| 2b14b8f58c | |||
| 6e85c0842f | |||
| f47eb7507f | |||
| c427aa2f7a | |||
| 23878c2ef8 | |||
| 79cd9f0ed1 | |||
| 60f13f756c | |||
| 2668f0b722 | |||
| 93ddf427b4 | |||
| e4d7d5ecf0 | |||
| 160e6af945 | |||
| c914eeb7b3 | |||
| 62365f514e | |||
| 9fdd8ac1b8 | |||
| 61eb034db1 | |||
| c949cbb9ee | |||
| 566396e629 | |||
| db39cd2153 | |||
| f4b830414d | |||
| 0447d1cab7 | |||
| 513143d745 | |||
| a8dc4fb447 | |||
| 50c3da2f5d | |||
| 6ef911919d | |||
| 5144ecd39e | |||
| a56b859151 | |||
| 469faed7b9 | |||
| 79ce0c18a1 | |||
| d195a132b3 | |||
| feb5d8e87d | |||
| a136fa832c | |||
| f3d0e0be13 | |||
| edeef0ac54 | |||
| 9f333618fc | |||
| ed54a048ab | |||
| d2479f52be | |||
| 6e6ae51395 | |||
| 1d197432b2 | |||
| 9917f29743 |
@@ -44,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 }}
|
||||
@@ -123,7 +125,8 @@ 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"
|
||||
@@ -249,11 +252,12 @@ 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"
|
||||
}
|
||||
|
||||
|
||||
@@ -18,8 +18,13 @@ 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/
|
||||
|
||||
@@ -5,6 +5,58 @@ 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.
|
||||
|
||||
+1
-1
Submodule Extensions/Browser updated: 7e09b3f3a7...8a9a4c4a63
@@ -5,14 +5,14 @@
|
||||
|
||||
**A fast, focused desktop download manager for macOS, Windows, and Linux.**
|
||||
|
||||
[](https://github.com/nimbold/Firelink/releases)
|
||||
[](#platforms)
|
||||
[](#platforms)
|
||||
[](#platforms)
|
||||
[](https://github.com/nimbold/Firelink/releases)
|
||||
[](#supported-platforms)
|
||||
[](#supported-platforms)
|
||||
[](#supported-platforms)
|
||||
[](LICENSE)
|
||||
[](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,26 +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.1.0**, paired with Firelink Companion **2.0.4**.
|
||||
|
||||
This stability-focused release adds a secure Windows portable build and strengthens queues, browser handoffs, media metadata, persistence, and packaged release checks.
|
||||
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, Deno, live progress, speed, and ETA.
|
||||
- **Add window** for metadata, duplicates, location choices, captured links, and clipboard-prefilled URLs.
|
||||
- **Persistent queues** with pause, resume, retry, redownload, sorting, multi-select, and bulk actions.
|
||||
- **Scheduling** with start/stop windows, speed rules, and post-queue actions.
|
||||
- **File organization** with categories, default folders, a collapsible Folders section, per-download overrides, and reveal/trash actions.
|
||||
- **Browser handoff** through local pairing, signed requests, Add window review, replay protection, and server checks.
|
||||
- **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.
|
||||
- **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
|
||||
@@ -61,57 +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. |
|
||||
| **Windows x64 portable** | `.zip` archive | Extract to a writable folder and launch `firelink.exe`. App data stays under the archive's `data/` directory. |
|
||||
| **Linux x64** | `.deb`, `.rpm`, or `.AppImage` | Use `.deb` for Debian-family systems, `.rpm` for Fedora/RPM-family systems, or AppImage as the self-contained package. AppImage may need executable permission. |
|
||||
| **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.
|
||||
|
||||
The native packages use the distribution's normal desktop runtime dependencies. AppImage is self-contained but uses the normal per-user application-data locations.
|
||||
<details>
|
||||
<summary><strong>Windows portable ZIP notes</strong></summary>
|
||||
|
||||
### Windows portable
|
||||
The portable ZIP is a secondary distribution. Extract it to a writable folder and launch `firelink.exe`:
|
||||
|
||||
The portable ZIP is an opt-in secondary distribution:
|
||||
|
||||
- Keep the extracted folder writable; avoid `Program Files`, read-only media, and folders that block SQLite or WebView writes.
|
||||
- Settings, queues, logs, and WebView data stay beside `firelink.exe` under `data/`.
|
||||
- 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/fragment data are not saved in portable queue records. Active downloads that depend on them must be added again after restart.
|
||||
- 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, so treat it as sensitive and do not share it.
|
||||
- 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>
|
||||
|
||||
<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.4 is the matching extension release for Firelink 1.1.0.
|
||||
|
||||
The extension lives in [Firelink-Extension](https://github.com/nimbold/Firelink-Extension). This repo also vendors it as the `Extensions/Browser` submodule.
|
||||
|
||||
## 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, bundled-engine checks, package/AppImage launch smoke tests, `.deb`, `.rpm`, and 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
|
||||
|
||||
@@ -127,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
|
||||
@@ -146,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/Browser/ 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).
|
||||
|
||||
+12
-12
@@ -8,14 +8,14 @@
|
||||
"sha256": "90254845be5282b1f4d843a873abff04f569f857f64250f833fe152b21eec152"
|
||||
},
|
||||
"deno": {
|
||||
"version": "2.9.2",
|
||||
"url": "https://github.com/denoland/deno/releases/download/v2.9.2/deno-x86_64-pc-windows-msvc.zip",
|
||||
"sha256": "5fe194d26ac5ef77fcc5288c2c438c7a0465f3b6180440ebf04092714bf2dcdf"
|
||||
"version": "2.9.3",
|
||||
"url": "https://github.com/denoland/deno/releases/download/v2.9.3/deno-x86_64-pc-windows-msvc.zip",
|
||||
"sha256": "60343461ac5fe3a31f4ef12667f2946bb852e20655c8610aeb7e751e87f7df3a"
|
||||
},
|
||||
"ffmpeg": {
|
||||
"version": "8.1.2-22-g94138f6973",
|
||||
"url": "https://github.com/BtbN/FFmpeg-Builds/releases/download/autobuild-2026-07-14-13-19/ffmpeg-n8.1.2-22-g94138f6973-win64-gpl-8.1.zip",
|
||||
"sha256": "fb627ebbf9b16a3142fa48b8acd27cd9196d57aee1f719e1233416e95da7e877"
|
||||
"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.2",
|
||||
"url": "https://github.com/denoland/deno/releases/download/v2.9.2/deno-x86_64-unknown-linux-gnu.zip",
|
||||
"sha256": "934d1bd5cb09eaed7f2e4a4fc58208d04a3c5c0fcde9f319d93d735265c67a4a"
|
||||
"version": "2.9.3",
|
||||
"url": "https://github.com/denoland/deno/releases/download/v2.9.3/deno-x86_64-unknown-linux-gnu.zip",
|
||||
"sha256": "8101865641cbede56f08ad19c0a67a87df84bce127fee0d3e3e1f7467717ffa6"
|
||||
},
|
||||
"ffmpeg": {
|
||||
"version": "8.1.2-22-g94138f6973",
|
||||
"url": "https://github.com/BtbN/FFmpeg-Builds/releases/download/autobuild-2026-07-14-13-19/ffmpeg-n8.1.2-22-g94138f6973-linux64-gpl-8.1.tar.xz",
|
||||
"sha256": "96cf2585a4b2874044320cbc5d4078af57e266f0c86cca9888507c5faab3aee8"
|
||||
"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",
|
||||
|
||||
+2
-2
@@ -23,10 +23,10 @@
|
||||
"sha256": "f65b4ba782b25e9f4374765d421793c6613f692d8e50b2888079657793619034"
|
||||
},
|
||||
"deno": {
|
||||
"version": "2.9.2",
|
||||
"version": "2.9.3",
|
||||
"source": "https://github.com/denoland/deno",
|
||||
"build": "official aarch64-apple-darwin executable",
|
||||
"sha256": "218ab752ae8f64f0a7822af710886488f15169fdae153a3aada4861f9635b266"
|
||||
"sha256": "80c83cdfb20289f8818a71220b570df363f6f0ba93580c29c70f08ab14e93568"
|
||||
}
|
||||
},
|
||||
"runtimeTrees": {
|
||||
|
||||
Generated
+206
-133
@@ -1,44 +1,55 @@
|
||||
{
|
||||
"name": "firelink",
|
||||
"version": "1.1.0",
|
||||
"version": "1.2.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "firelink",
|
||||
"version": "1.1.0",
|
||||
"version": "1.2.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@formkit/auto-animate": "^0.10.0",
|
||||
"@tailwindcss/vite": "^4.3.1",
|
||||
"@tailwindcss/vite": "^4.3.3",
|
||||
"@tauri-apps/api": "^2.11.1",
|
||||
"@tauri-apps/plugin-clipboard-manager": "^2.3.2",
|
||||
"@tauri-apps/plugin-dialog": "^2.7.1",
|
||||
"@tauri-apps/plugin-dialog": "^2.7.2",
|
||||
"@tauri-apps/plugin-fs": "^2.5.1",
|
||||
"@tauri-apps/plugin-log": "^2.9.0",
|
||||
"@tauri-apps/plugin-notification": "^2.3.3",
|
||||
"@tauri-apps/plugin-opener": "^2",
|
||||
"lucide-react": "^1.24.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.3",
|
||||
"postcss": "^8.5.19",
|
||||
"tailwindcss": "^4.3.1",
|
||||
"@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.4",
|
||||
"vite": "^8.1.5",
|
||||
"vitest": "^4.1.10"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/runtime": {
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz",
|
||||
"integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@emnapi/core": {
|
||||
"version": "1.11.1",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz",
|
||||
@@ -404,47 +415,47 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@tailwindcss/node": {
|
||||
"version": "4.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.2.tgz",
|
||||
"integrity": "sha512-yWP/sqEcBLaD8JuA6zNwxoYKr75qxTioYwlRwekj5Jr/I5GXnoJfjetH/psLUIv74cYTH2lBUEzBkinthoYcBg==",
|
||||
"version": "4.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.3.tgz",
|
||||
"integrity": "sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/remapping": "^2.3.5",
|
||||
"enhanced-resolve": "5.21.6",
|
||||
"enhanced-resolve": "^5.24.1",
|
||||
"jiti": "^2.7.0",
|
||||
"lightningcss": "1.32.0",
|
||||
"magic-string": "^0.30.21",
|
||||
"source-map-js": "^1.2.1",
|
||||
"tailwindcss": "4.3.2"
|
||||
"tailwindcss": "4.3.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide": {
|
||||
"version": "4.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.2.tgz",
|
||||
"integrity": "sha512-z8ZgnzX8gdNoWLBLqBPoh/sjnxkwvf9ZuWjnO0l0yIzbLa5/9S+eC5QxGZKRobVHIC3/1BoMWjHblqWjcgFgag==",
|
||||
"version": "4.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.3.tgz",
|
||||
"integrity": "sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 20"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@tailwindcss/oxide-android-arm64": "4.3.2",
|
||||
"@tailwindcss/oxide-darwin-arm64": "4.3.2",
|
||||
"@tailwindcss/oxide-darwin-x64": "4.3.2",
|
||||
"@tailwindcss/oxide-freebsd-x64": "4.3.2",
|
||||
"@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.2",
|
||||
"@tailwindcss/oxide-linux-arm64-gnu": "4.3.2",
|
||||
"@tailwindcss/oxide-linux-arm64-musl": "4.3.2",
|
||||
"@tailwindcss/oxide-linux-x64-gnu": "4.3.2",
|
||||
"@tailwindcss/oxide-linux-x64-musl": "4.3.2",
|
||||
"@tailwindcss/oxide-wasm32-wasi": "4.3.2",
|
||||
"@tailwindcss/oxide-win32-arm64-msvc": "4.3.2",
|
||||
"@tailwindcss/oxide-win32-x64-msvc": "4.3.2"
|
||||
"@tailwindcss/oxide-android-arm64": "4.3.3",
|
||||
"@tailwindcss/oxide-darwin-arm64": "4.3.3",
|
||||
"@tailwindcss/oxide-darwin-x64": "4.3.3",
|
||||
"@tailwindcss/oxide-freebsd-x64": "4.3.3",
|
||||
"@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.3",
|
||||
"@tailwindcss/oxide-linux-arm64-gnu": "4.3.3",
|
||||
"@tailwindcss/oxide-linux-arm64-musl": "4.3.3",
|
||||
"@tailwindcss/oxide-linux-x64-gnu": "4.3.3",
|
||||
"@tailwindcss/oxide-linux-x64-musl": "4.3.3",
|
||||
"@tailwindcss/oxide-wasm32-wasi": "4.3.3",
|
||||
"@tailwindcss/oxide-win32-arm64-msvc": "4.3.3",
|
||||
"@tailwindcss/oxide-win32-x64-msvc": "4.3.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-android-arm64": {
|
||||
"version": "4.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.2.tgz",
|
||||
"integrity": "sha512-WHxqIuHpvZ5VtdX6GTl1Ik/Vp2YuN42Et+0CdeaVd/frQ9jAvGmvR8vLT+jk3e8/Q3x8kECB9+R17pgpp2BulA==",
|
||||
"version": "4.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.3.tgz",
|
||||
"integrity": "sha512-Y85A2gmPSkl5Ve5qR86GL4HT509cFqQh1aes9p3sSkyTPwt0Pppf3GkwGe4JPACcRYjgJIEhQgM6dBClnr0NYw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -458,9 +469,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-darwin-arm64": {
|
||||
"version": "4.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.2.tgz",
|
||||
"integrity": "sha512-GZypeUY/IDJW3877KeM+O67vbXr3MBnbtEL4aYhNErv/JWZhye2vGSWWG9tB6iiqR2MqRNkY8IOUy4NdSZV26w==",
|
||||
"version": "4.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.3.tgz",
|
||||
"integrity": "sha512-BiaWatpBcERQFDlOjRDpIVXuFK5PJez5SA4JMg6VYZdBYU+qKfV/vqjcIs+IYmtitf1xYQZTwXvU/8y4lfZUGw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -474,9 +485,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-darwin-x64": {
|
||||
"version": "4.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.2.tgz",
|
||||
"integrity": "sha512-UIIzmefR6KO1sDU7MzRqAxC8iBpft/VhkGjTjnhoS6k7Z3rQ9wEgA1ODSiyH/tcSYssulNm4Ci3hOeK1jH7ccQ==",
|
||||
"version": "4.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.3.tgz",
|
||||
"integrity": "sha512-fAeUqfV5ndhxRwai8cXGzdLvul9utWOmeTkv69unv4ZXixjn61Z+p9lCWdwOwA3TYboG3BwdVuN/RDjhBRl0mw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -490,9 +501,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-freebsd-x64": {
|
||||
"version": "4.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.2.tgz",
|
||||
"integrity": "sha512-GN+uAmcI6DNspnCDwtOAZrTz6oukJnp337qZvxqCGLd3BHBzJpO0ZbTLRvJNdztOeAmTzewewGIMPb0tk2R4WA==",
|
||||
"version": "4.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.3.tgz",
|
||||
"integrity": "sha512-iyf5bV6+wnAlflVeEy7R25dupxTNECZN5QMI0qNT6eT+EgaGdZcKhGkr5SdoaWiLJ3spLqIY9VCeSGrwmtg4kw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -506,9 +517,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": {
|
||||
"version": "4.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.2.tgz",
|
||||
"integrity": "sha512-4ABn7qSbdHRwTiDiuWNegCyb5+2FJ4vKIKc3DmKrvAFw7MU1Lm11dIkTPwUaFdTzc7IsOpDbqBrlh0x6y36U/w==",
|
||||
"version": "4.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.3.tgz",
|
||||
"integrity": "sha512-aAYUprJAJQWWbRrPvtjdroZ56Md+JM8pMiopS6xGEwDfLhqj+2ver2p4nU4Mb3CRqcMmNBjo8KkUgcxhkzVQGQ==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -522,9 +533,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-linux-arm64-gnu": {
|
||||
"version": "4.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.2.tgz",
|
||||
"integrity": "sha512-wDgEIGwoM8w8pufh9LVt1PahDgNdKXrLC2qfAnV3vAmococ9RWbxeAw4pxPttd/TsJfwjyLf90Dg1y9y8I6Emw==",
|
||||
"version": "4.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.3.tgz",
|
||||
"integrity": "sha512-nDxldcEENOxZRzC2uu9jrutZdAAQtb+8WWDCSnWL1zvBk1+FN+x6MtDViPB5AJMfttVCUhehGWus3XBPgatM/w==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -538,9 +549,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-linux-arm64-musl": {
|
||||
"version": "4.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.2.tgz",
|
||||
"integrity": "sha512-J5Nuk0uZQIiMTJj3LEx4sAA9tMFUoXQZFv1J6An+QGYe53HKRJuFDi0rpq/tuouCZeAbOBY3kQ6g8qeD4TUjtA==",
|
||||
"version": "4.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.3.tgz",
|
||||
"integrity": "sha512-Md44bD6veX/PC5iyF8cDVnw4HBIANZepRZZ7a8DQOvkfo5WUBwcp6iAuCUz23u+4SUkhJlD3eL7hNdW8ezd/kA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -554,9 +565,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-linux-x64-gnu": {
|
||||
"version": "4.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.2.tgz",
|
||||
"integrity": "sha512-kqCZpSKOBEJO4mz7OqWoofBZeXTAwaVGPj0ErAj7CojmhKpWVWVOnrt9dE8odoIraZq4oj3ausM37kXi+Tow8w==",
|
||||
"version": "4.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.3.tgz",
|
||||
"integrity": "sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -570,9 +581,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-linux-x64-musl": {
|
||||
"version": "4.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.2.tgz",
|
||||
"integrity": "sha512-cixpqbh2toJDmkuCRI68nXA8ZxNmdK9Y+9v5h3MC3ZQKy/0BO8AWzlkWyRM7JAFSGBlfig4YVTPsK6MVgqz1uw==",
|
||||
"version": "4.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.3.tgz",
|
||||
"integrity": "sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -586,9 +597,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-wasm32-wasi": {
|
||||
"version": "4.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.2.tgz",
|
||||
"integrity": "sha512-4ec2Z/LOmRsAgU23CS4xeJfcJlmRg94A/XrbGRCF1gyU/zdDfRLYDVsS+ynSZCmGNxQ1jQriQOKMQeQxBA3Isw==",
|
||||
"version": "4.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.3.tgz",
|
||||
"integrity": "sha512-jx1+rPhY/5Ympkktd656HBWEBLxP7dH06losBLjjf5vgCODXvi9KhtftWcMIwTFIDqBr7cRnQkdLnAG+IOlGvQ==",
|
||||
"bundleDependencies": [
|
||||
"@napi-rs/wasm-runtime",
|
||||
"@emnapi/core",
|
||||
@@ -675,9 +686,9 @@
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
|
||||
"version": "4.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.2.tgz",
|
||||
"integrity": "sha512-Zyr/M0+XcYZu3bZrUytc7TXvrk0ftWfl8gN2MwekNDzhqhKRUucMPSeOzM0o0wH5AWOU49BsKRrfKxI2atCPMQ==",
|
||||
"version": "4.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.3.tgz",
|
||||
"integrity": "sha512-3rc292Ca2ceK6Ulcc/bAVnTs/3nDtoPhyEKlgPv+yQJQi/JS/AMJlqzxvlDacL1nekbrcf6bTqp/jV4qgnPxNQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -691,9 +702,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-win32-x64-msvc": {
|
||||
"version": "4.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.2.tgz",
|
||||
"integrity": "sha512-QI9BO7KlNZsp2GuO0jwAAj5jCDABOKXRkCk2XuKTSaNEFSdfzqswYVTtCHBNKHLsqyjFyFkqlDiwkNbTYSssMQ==",
|
||||
"version": "4.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.3.tgz",
|
||||
"integrity": "sha512-yJ0pwIVc/nYeGoV02WtsN8KYyLQv7kyI2wDnkezyJlGGjkd4QLwDGAwl47YpPJeuI0M0ObaXGSPjvWDPeTPggw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -707,14 +718,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/vite": {
|
||||
"version": "4.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.3.2.tgz",
|
||||
"integrity": "sha512-eHpMeX4JXfVNJDEcsouTeCBubJBTcTLigeaw/NTUW6PB5ATKKXdyonnXgTBX2VuRbjz1hjfz6C5XAhr52ImQXA==",
|
||||
"version": "4.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.3.3.tgz",
|
||||
"integrity": "sha512-yYU8cogLeSh/ms2jh8Fj7jaba/EWa7Ja6GoUqYZaraEuCI5YS6ms6ObZgjjedm+jm6XZjdNRWBpPP6Z86oOxcw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@tailwindcss/node": "4.3.2",
|
||||
"@tailwindcss/oxide": "4.3.2",
|
||||
"tailwindcss": "4.3.2"
|
||||
"@tailwindcss/node": "4.3.3",
|
||||
"@tailwindcss/oxide": "4.3.3",
|
||||
"tailwindcss": "4.3.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"vite": "^5.2.0 || ^6 || ^7 || ^8"
|
||||
@@ -957,9 +968,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/plugin-dialog": {
|
||||
"version": "2.7.1",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-dialog/-/plugin-dialog-2.7.1.tgz",
|
||||
"integrity": "sha512-OK1UBXYt+ojcmxMktzzuyonYIFta8CmAASpX+CA+DTGK24KlHjhYI6x2iOJ/TjZF4N7/ACK1oFmEOjIY9IhzOQ==",
|
||||
"version": "2.7.2",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-dialog/-/plugin-dialog-2.7.2.tgz",
|
||||
"integrity": "sha512-pX0IGm1I3I6wc+zeKYcq1GSqogK6okCNX5fOdaNU5ab1AjGS6l1E5wFNjEb7meg7ZFSp0JUs+0jQGQNyOvLrsg==",
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2.11.0"
|
||||
@@ -1063,7 +1074,6 @@
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1080,7 +1090,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1097,7 +1106,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1114,7 +1122,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1131,7 +1138,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1148,7 +1154,6 @@
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1165,7 +1170,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1182,7 +1186,6 @@
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1199,7 +1202,6 @@
|
||||
"cpu": [
|
||||
"mips64el"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1216,7 +1218,6 @@
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1233,7 +1234,6 @@
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1250,7 +1250,6 @@
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1267,7 +1266,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1284,7 +1282,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1301,7 +1298,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1318,7 +1314,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1335,7 +1330,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1352,7 +1346,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1369,7 +1362,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1386,7 +1378,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1397,9 +1388,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitejs/plugin-react": {
|
||||
"version": "6.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.3.tgz",
|
||||
"integrity": "sha512-vmFvco5/QuC2f9Oj+wTk0+9XeDFkHxSamwZKYc7MxYwKICfvUvlMhqKI0VuICPltGqh1neqBKDvO4kes1ya8vg==",
|
||||
"version": "6.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.4.tgz",
|
||||
"integrity": "sha512-XcCQz0TBpBgljhj0gMuuDj49i6Ytqh5q1osT/Gp5uAVJUCTWxyskk/l1jwYYiu2xcNHHipdMz40EGfM1VdamVg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -1546,9 +1537,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/autoprefixer": {
|
||||
"version": "10.5.3",
|
||||
"resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.5.3.tgz",
|
||||
"integrity": "sha512-bJRzflk8GgE4JX+iZNEwz9f9p460NCHnU7bd+CZ9vIjIlZuTkt6F3WSl2oNO8StZBFx17nLEsiQ6H2wcZiY7nA==",
|
||||
"version": "10.5.4",
|
||||
"resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.5.4.tgz",
|
||||
"integrity": "sha512-MaU0U/za7N3r6brxD4YB/l4NSrFzLPlANv6wEuQVaIPlD3L4W9rFcQPbL/EilY9BHhHvhfcz3gInDLrEtWT4EA==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@@ -1567,7 +1558,7 @@
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"browserslist": "^4.28.6",
|
||||
"caniuse-lite": "^1.0.30001805",
|
||||
"caniuse-lite": "^1.0.30001806",
|
||||
"fraction.js": "^5.3.4",
|
||||
"picocolors": "^1.1.1",
|
||||
"postcss-value-parser": "^4.2.0"
|
||||
@@ -1630,9 +1621,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/caniuse-lite": {
|
||||
"version": "1.0.30001805",
|
||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001805.tgz",
|
||||
"integrity": "sha512-52noaS3DubycKSXaU30TwPGIp+POyQSUVa5jBEq3vkRkY0kjyb3LQgvhU6WGyCcyXqVLWO0Cw0Q6BSdD0kUfVA==",
|
||||
"version": "1.0.30001806",
|
||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001806.tgz",
|
||||
"integrity": "sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@@ -1691,9 +1682,9 @@
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/enhanced-resolve": {
|
||||
"version": "5.21.6",
|
||||
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.21.6.tgz",
|
||||
"integrity": "sha512-aNnGCvbJ/RIyWo1IuhNdVjnNF+EjH9wpzpNHt+ci/m9He9LJvUN8wrCcXjp9cWsGNAuvSpVFTx/vraAFQ8qGjQ==",
|
||||
"version": "5.24.2",
|
||||
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.24.2.tgz",
|
||||
"integrity": "sha512-rpsZEGT1jFuve6QlpyRp9ckQ+kN61hvF9BzCPyMdaKTm8UJce96KBn3sorXOFXlzjPrs3Vc4T1NsSroZ3PxlFw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"graceful-fs": "^4.2.4",
|
||||
@@ -1791,6 +1782,43 @@
|
||||
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/html-parse-stringify": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz",
|
||||
"integrity": "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"void-elements": "3.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/i18next": {
|
||||
"version": "26.3.6",
|
||||
"resolved": "https://registry.npmjs.org/i18next/-/i18next-26.3.6.tgz",
|
||||
"integrity": "sha512-Bu5Z2nAXgfVyM8xvW3jk9EKRIuX37PudsrBViThNFx7CR7aaYTpP01cxNB/E4c4UUzTDiAZRstEhsRfPOL/8xA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "individual",
|
||||
"url": "https://www.locize.com/i18next"
|
||||
},
|
||||
{
|
||||
"type": "individual",
|
||||
"url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project"
|
||||
},
|
||||
{
|
||||
"type": "individual",
|
||||
"url": "https://www.locize.com"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"typescript": "^5 || ^6 || ^7"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"typescript": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/jiti": {
|
||||
"version": "2.7.0",
|
||||
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz",
|
||||
@@ -2050,9 +2078,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/lucide-react": {
|
||||
"version": "1.24.0",
|
||||
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.24.0.tgz",
|
||||
"integrity": "sha512-YT6mBD8lGKkg4nM39enlm94/sfJIiW0YKUT60fBy4YK8tai31ylg1VhGNWxkpSKHo9UagfnZqwIff3HTDQwXeA==",
|
||||
"version": "1.25.0",
|
||||
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.25.0.tgz",
|
||||
"integrity": "sha512-/mdJTRbiwcLOQ1NZZK1amZF9rIZyvO18D6r9TngE6TG1NmqHgFuT4eE7Xrkm9UsXMbBJD1NlfwHVltCDWHrOTw==",
|
||||
"license": "ISC",
|
||||
"peerDependencies": {
|
||||
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
@@ -2135,9 +2163,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.5.19",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.19.tgz",
|
||||
"integrity": "sha512-Mz8SaolMd8nB+G13WkORcxQKHZ/NE4xXevtkJHVuG+guo9/wYKlIMTKAqGdEmYOXR2ijPjTYNHssizdaVSUNdQ==",
|
||||
"version": "8.5.21",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.21.tgz",
|
||||
"integrity": "sha512-v4sDNP3fdNiWMfabO7OwOQdOX8TiQSztKyT1Wj0w+j7LDallJThJRBBBmzVGyYj0crMh7jlV4zepPkiNu9UwDQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "opencollective",
|
||||
@@ -2154,7 +2182,7 @@
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"nanoid": "^3.3.12",
|
||||
"nanoid": "^3.3.16",
|
||||
"picocolors": "^1.1.1",
|
||||
"source-map-js": "^1.2.1"
|
||||
},
|
||||
@@ -2170,24 +2198,51 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/react": {
|
||||
"version": "19.2.7",
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz",
|
||||
"integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==",
|
||||
"version": "19.2.8",
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz",
|
||||
"integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-dom": {
|
||||
"version": "19.2.7",
|
||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.7.tgz",
|
||||
"integrity": "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==",
|
||||
"version": "19.2.8",
|
||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.8.tgz",
|
||||
"integrity": "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"scheduler": "^0.27.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^19.2.7"
|
||||
"react": "^19.2.8"
|
||||
}
|
||||
},
|
||||
"node_modules/react-i18next": {
|
||||
"version": "17.0.10",
|
||||
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.0.10.tgz",
|
||||
"integrity": "sha512-XneHftyYA774MJkkccSkZ5oKrUpCnXIPmxio3wemqrVzCRLWiGXOMbIzObrer03fNDEnm8g8R5yYls4HcE+esg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.29.2",
|
||||
"html-parse-stringify": "^3.0.1",
|
||||
"use-sync-external-store": "^1.6.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"i18next": ">= 26.2.0",
|
||||
"react": ">= 16.8.0",
|
||||
"typescript": "^5 || ^6 || ^7"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"react-dom": {
|
||||
"optional": true
|
||||
},
|
||||
"react-native": {
|
||||
"optional": true
|
||||
},
|
||||
"typescript": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/rolldown": {
|
||||
@@ -2260,9 +2315,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tailwindcss": {
|
||||
"version": "4.3.2",
|
||||
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.2.tgz",
|
||||
"integrity": "sha512-WtctNNSH8A9jlMIqxzuYumOHU5uGZyRv0Q5svQl+oEPy5w84YpBxdb7MdqyiSPQge5jTJ6zFQLq0PFygdccSBA==",
|
||||
"version": "4.3.3",
|
||||
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.3.tgz",
|
||||
"integrity": "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tapable": {
|
||||
@@ -2332,7 +2387,7 @@
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz",
|
||||
"integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"tsc": "bin/tsc"
|
||||
@@ -2394,16 +2449,25 @@
|
||||
"browserslist": ">= 4.21.0"
|
||||
}
|
||||
},
|
||||
"node_modules/use-sync-external-store": {
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
|
||||
"integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "8.1.4",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-8.1.4.tgz",
|
||||
"integrity": "sha512-bTT9PsdWO+MQMNG9ZXIP/qM9wGh37DFxTV/sPq9cFpHr3w4jkgef032PkAL9jAqhk3Nz8NQw3O8n6/xFkqO4QQ==",
|
||||
"version": "8.1.5",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-8.1.5.tgz",
|
||||
"integrity": "sha512-7ULLwsCdYx/nRyrpiEwvqb5TFHrMVZyBt+rg/OAXT7rgj/z+DtTDyKFeLAdDkubDVDKD8jOsndmy7m55XcfUsw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"lightningcss": "^1.32.0",
|
||||
"picomatch": "^4.0.5",
|
||||
"postcss": "^8.5.16",
|
||||
"rolldown": "~1.1.4",
|
||||
"postcss": "^8.5.17",
|
||||
"rolldown": "~1.1.5",
|
||||
"tinyglobby": "^0.2.17"
|
||||
},
|
||||
"bin": {
|
||||
@@ -2561,6 +2625,15 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/void-elements": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz",
|
||||
"integrity": "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/why-is-node-running": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
|
||||
|
||||
+14
-11
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "firelink",
|
||||
"private": true,
|
||||
"version": "1.1.0",
|
||||
"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",
|
||||
@@ -39,29 +40,31 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@formkit/auto-animate": "^0.10.0",
|
||||
"@tailwindcss/vite": "^4.3.1",
|
||||
"@tailwindcss/vite": "^4.3.3",
|
||||
"@tauri-apps/api": "^2.11.1",
|
||||
"@tauri-apps/plugin-clipboard-manager": "^2.3.2",
|
||||
"@tauri-apps/plugin-dialog": "^2.7.1",
|
||||
"@tauri-apps/plugin-dialog": "^2.7.2",
|
||||
"@tauri-apps/plugin-fs": "^2.5.1",
|
||||
"@tauri-apps/plugin-log": "^2.9.0",
|
||||
"@tauri-apps/plugin-notification": "^2.3.3",
|
||||
"@tauri-apps/plugin-opener": "^2",
|
||||
"lucide-react": "^1.24.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.3",
|
||||
"postcss": "^8.5.19",
|
||||
"tailwindcss": "^4.3.1",
|
||||
"@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.4",
|
||||
"vite": "^8.1.5",
|
||||
"vitest": "^4.1.10"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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']);
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
+154
-35
@@ -17,6 +17,7 @@ if (!executableArg) {
|
||||
const executable = path.resolve(executableArg);
|
||||
const assertNoVisibleChildWindows = process.argv.includes('--assert-no-visible-child-windows');
|
||||
const assertPortableData = process.argv.includes('--assert-portable-data');
|
||||
const READY_PORT_TIMEOUT_MS = 500;
|
||||
const child = spawn(executable, [], {
|
||||
cwd: process.env.RUNNER_TEMP || process.env.TMPDIR || process.cwd(),
|
||||
detached: process.platform !== 'win32',
|
||||
@@ -60,18 +61,21 @@ async function findReadyPort() {
|
||||
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'
|
||||
&& response.headers.get('x-firelink-smoke-process-id') === String(child.pid)
|
||||
) {
|
||||
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);
|
||||
@@ -134,46 +138,161 @@ function waitForChildExit(timeoutMs) {
|
||||
}
|
||||
|
||||
return new Promise(resolve => {
|
||||
const timer = setTimeout(() => {
|
||||
let timer;
|
||||
const onExit = () => {
|
||||
clearTimeout(timer);
|
||||
child.off('exit', onExit);
|
||||
resolve(true);
|
||||
};
|
||||
timer = setTimeout(() => {
|
||||
child.off('exit', onExit);
|
||||
resolve(false);
|
||||
}, timeoutMs);
|
||||
child.once('exit', () => {
|
||||
clearTimeout(timer);
|
||||
resolve(true);
|
||||
});
|
||||
child.once('exit', onExit);
|
||||
});
|
||||
}
|
||||
|
||||
async function terminateChild() {
|
||||
if (!child.pid || childExit) {
|
||||
return true;
|
||||
}
|
||||
|
||||
function isProcessGroupAlive(rootPid) {
|
||||
if (process.platform === 'win32') {
|
||||
try {
|
||||
execFileSync('taskkill', ['/pid', String(child.pid), '/t', '/f'], {
|
||||
stdio: 'ignore',
|
||||
windowsHide: true,
|
||||
});
|
||||
} catch {}
|
||||
return waitForChildExit(10000);
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
process.kill(-child.pid, 'SIGTERM');
|
||||
} catch {
|
||||
child.kill('SIGTERM');
|
||||
}
|
||||
if (await waitForChildExit(5000)) {
|
||||
process.kill(-rootPid, 0);
|
||||
return true;
|
||||
} catch (error) {
|
||||
return error?.code !== 'ESRCH';
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
child.kill('SIGKILL');
|
||||
if (!childExit) {
|
||||
child.kill('SIGKILL');
|
||||
}
|
||||
}
|
||||
return waitForChildExit(5000);
|
||||
return await waitForChildExit(5000) && await waitForProcessGroupExit(child.pid, 5000);
|
||||
}
|
||||
|
||||
async function assertPortableStorage() {
|
||||
|
||||
@@ -45,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}`);
|
||||
}
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
Generated
+233
-220
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "firelink"
|
||||
version = "1.1.0"
|
||||
version = "1.2.0"
|
||||
description = "A fast cross-platform desktop download manager powered by Rust and Tauri"
|
||||
authors = ["NimBold"]
|
||||
edition = "2021"
|
||||
@@ -25,7 +25,7 @@ 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"
|
||||
@@ -43,15 +43,15 @@ sha2 = "0.11"
|
||||
tauri-plugin-deep-link = "2"
|
||||
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.30.0"
|
||||
futures-util = { version = "0.3.32", features = ["sink"] }
|
||||
futures-util = { version = "0.3.33", features = ["sink"] }
|
||||
chrono = "0.4.38"
|
||||
url = "2"
|
||||
rusqlite = { version = "0.40.1", features = ["bundled"] }
|
||||
|
||||
Binary file not shown.
+284
-45
@@ -45,18 +45,17 @@ struct LegacyData {
|
||||
}
|
||||
|
||||
pub fn init(storage_layout: &crate::storage::StorageLayout) -> Result<DbState, String> {
|
||||
init_at_path_internal(storage_layout.data_dir(), storage_layout.is_portable(), false)
|
||||
init_at_path_internal(storage_layout.data_dir(), storage_layout.is_portable())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn init_at_path(app_data_dir: &Path) -> Result<DbState, String> {
|
||||
init_at_path_internal(app_data_dir, false, false)
|
||||
init_at_path_internal(app_data_dir, false)
|
||||
}
|
||||
|
||||
fn init_at_path_internal(
|
||||
app_data_dir: &Path,
|
||||
portable: bool,
|
||||
migrate_legacy_keychain: bool,
|
||||
) -> Result<DbState, String> {
|
||||
fs::create_dir_all(app_data_dir)
|
||||
.map_err(|error| format!("failed to create app data directory: {error}"))?;
|
||||
@@ -78,12 +77,7 @@ fn init_at_path_internal(
|
||||
}
|
||||
migrate_schema(&mut connection, version)?;
|
||||
|
||||
import_legacy_data(
|
||||
&mut connection,
|
||||
app_data_dir,
|
||||
portable,
|
||||
migrate_legacy_keychain,
|
||||
)?;
|
||||
import_legacy_data(&mut connection, app_data_dir, portable)?;
|
||||
if portable {
|
||||
sanitize_persisted_downloads(&mut connection)?;
|
||||
}
|
||||
@@ -188,7 +182,6 @@ fn import_legacy_data(
|
||||
connection: &mut Connection,
|
||||
app_data_dir: &Path,
|
||||
portable: bool,
|
||||
migrate_keychain: bool,
|
||||
) -> Result<(), String> {
|
||||
let legacy_app_dir = app_data_dir
|
||||
.parent()
|
||||
@@ -226,31 +219,10 @@ fn import_legacy_data(
|
||||
let mut pending_pairing_token = None;
|
||||
if !portable {
|
||||
if let Some(token) = legacy.pairing_token.take() {
|
||||
let migrated = if migrate_keychain {
|
||||
let keychain_has_token = get_keychain_password(PAIRING_TOKEN_KEYCHAIN_ID)
|
||||
.ok()
|
||||
.is_some_and(|value| !value.trim().is_empty());
|
||||
if !keychain_has_token {
|
||||
if let Err(error) =
|
||||
set_keychain_password(PAIRING_TOKEN_KEYCHAIN_ID, &token)
|
||||
{
|
||||
log::warn!(
|
||||
"Legacy pairing token could not be migrated to the credential store; it will remain pending in the current database: {}",
|
||||
error
|
||||
);
|
||||
false
|
||||
} else {
|
||||
true
|
||||
}
|
||||
} else {
|
||||
true
|
||||
}
|
||||
} else {
|
||||
false
|
||||
};
|
||||
if !migrated {
|
||||
pending_pairing_token = Some(token);
|
||||
}
|
||||
// Legacy migration is deliberately deferred until the
|
||||
// explicit frontend consent action. Database initialization
|
||||
// must never touch the OS credential store.
|
||||
pending_pairing_token = Some(token);
|
||||
}
|
||||
}
|
||||
if portable {
|
||||
@@ -707,6 +679,188 @@ pub fn save_settings(connection: &Connection, data: &str) -> Result<(), String>
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn settings_state_mut(
|
||||
document: &mut Value,
|
||||
) -> Result<&mut serde_json::Map<String, Value>, String> {
|
||||
if document.get("state").is_some() {
|
||||
document
|
||||
.get_mut("state")
|
||||
.and_then(Value::as_object_mut)
|
||||
.ok_or_else(|| "persisted settings state must be an object".to_string())
|
||||
} else {
|
||||
document
|
||||
.as_object_mut()
|
||||
.ok_or_else(|| "persisted settings must be an object".to_string())
|
||||
}
|
||||
}
|
||||
|
||||
fn add_site_login_to_settings(
|
||||
data: &str,
|
||||
id: &str,
|
||||
url_pattern: &str,
|
||||
username: &str,
|
||||
) -> Result<String, String> {
|
||||
let mut document: Value = serde_json::from_str(data)
|
||||
.map_err(|error| format!("failed to decode settings: {error}"))?;
|
||||
let state = settings_state_mut(&mut document)?;
|
||||
let logins = state
|
||||
.entry("siteLogins")
|
||||
.or_insert_with(|| Value::Array(Vec::new()));
|
||||
let logins = logins
|
||||
.as_array_mut()
|
||||
.ok_or_else(|| "persisted site logins must be an array".to_string())?;
|
||||
if logins.iter().any(|login| {
|
||||
login
|
||||
.get("id")
|
||||
.and_then(Value::as_str)
|
||||
.is_some_and(|existing_id| existing_id == id)
|
||||
}) {
|
||||
return Err("site login already exists".to_string());
|
||||
}
|
||||
logins.push(serde_json::json!({
|
||||
"id": id,
|
||||
"urlPattern": url_pattern,
|
||||
"username": username,
|
||||
}));
|
||||
serde_json::to_string(&document)
|
||||
.map_err(|error| format!("failed to encode settings: {error}"))
|
||||
}
|
||||
|
||||
fn remove_site_login_from_settings(
|
||||
data: &str,
|
||||
id: &str,
|
||||
) -> Result<(String, bool), String> {
|
||||
let mut document: Value = serde_json::from_str(data)
|
||||
.map_err(|error| format!("failed to decode settings: {error}"))?;
|
||||
let state = settings_state_mut(&mut document)?;
|
||||
let Some(logins) = state.get_mut("siteLogins") else {
|
||||
return Ok((data.to_string(), false));
|
||||
};
|
||||
let logins = logins
|
||||
.as_array_mut()
|
||||
.ok_or_else(|| "persisted site logins must be an array".to_string())?;
|
||||
let original_len = logins.len();
|
||||
logins.retain(|login| {
|
||||
login
|
||||
.get("id")
|
||||
.and_then(Value::as_str)
|
||||
.is_none_or(|existing_id| existing_id != id)
|
||||
});
|
||||
if logins.len() == original_len {
|
||||
return Ok((data.to_string(), false));
|
||||
}
|
||||
let updated = serde_json::to_string(&document)
|
||||
.map_err(|error| format!("failed to encode settings: {error}"))?;
|
||||
Ok((updated, true))
|
||||
}
|
||||
|
||||
fn validate_site_login_id(id: &str) -> Result<&str, String> {
|
||||
let trimmed = id.trim();
|
||||
if trimmed.is_empty()
|
||||
|| trimmed != id
|
||||
|| trimmed == PAIRING_TOKEN_KEYCHAIN_ID
|
||||
{
|
||||
return Err("invalid site login identifier".to_string());
|
||||
}
|
||||
Ok(trimmed)
|
||||
}
|
||||
|
||||
fn validate_site_login_input(
|
||||
id: &str,
|
||||
url_pattern: &str,
|
||||
username: &str,
|
||||
password: &str,
|
||||
) -> Result<(), String> {
|
||||
validate_site_login_id(id)?;
|
||||
if url_pattern.trim().is_empty() || url_pattern.chars().any(char::is_whitespace) {
|
||||
return Err("site login URL pattern must be non-empty and contain no whitespace".to_string());
|
||||
}
|
||||
if username.trim().is_empty() {
|
||||
return Err("site login username must be non-empty".to_string());
|
||||
}
|
||||
if password.is_empty() {
|
||||
return Err("site login password must be non-empty".to_string());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn save_site_login(
|
||||
connection: &Connection,
|
||||
id: &str,
|
||||
url_pattern: &str,
|
||||
username: &str,
|
||||
password: &str,
|
||||
) -> Result<(), String> {
|
||||
validate_site_login_input(id, url_pattern, username, password)?;
|
||||
let id = validate_site_login_id(id)?;
|
||||
let _keyring_guard = lock_keyring_operations()?;
|
||||
let original = load_settings(connection)?.unwrap_or_else(|| {
|
||||
// A first-run standard-mode install can grant keychain access before
|
||||
// any frontend setting has been persisted. Start with a valid
|
||||
// Zustand envelope so the first site login is not rejected.
|
||||
serde_json::json!({ "state": {}, "version": 3 }).to_string()
|
||||
});
|
||||
if get_keychain_password_if_present_unlocked(id)?.is_some() {
|
||||
return Err("a credential already exists for this site login".to_string());
|
||||
}
|
||||
let updated = add_site_login_to_settings(&original, id, url_pattern, username)?;
|
||||
|
||||
// Persist metadata before creating the secret. If the credential-store
|
||||
// write fails, restore the exact previous settings document so a failed
|
||||
// add cannot leave an orphaned keychain entry or a visible login row.
|
||||
save_settings(connection, &updated)?;
|
||||
if let Err(error) = set_keychain_password_unlocked(id, password) {
|
||||
let keychain_rollback = delete_keychain_password_unlocked(id);
|
||||
let settings_rollback = save_settings(connection, &original);
|
||||
if let Err(rollback_error) = keychain_rollback {
|
||||
return Err(format!(
|
||||
"failed to save site login credential: {error}; credential rollback also failed: {rollback_error}"
|
||||
));
|
||||
}
|
||||
if let Err(rollback_error) = settings_rollback {
|
||||
return Err(format!(
|
||||
"failed to save site login credential: {error}; settings rollback also failed: {rollback_error}"
|
||||
));
|
||||
}
|
||||
return Err(format!("failed to save site login credential: {error}"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn delete_site_login(connection: &Connection, id: &str) -> Result<(), String> {
|
||||
let id = validate_site_login_id(id)?;
|
||||
let _keyring_guard = lock_keyring_operations()?;
|
||||
let Some(original) = load_settings(connection)? else {
|
||||
return Err("settings are not persisted yet".to_string());
|
||||
};
|
||||
let (updated, removed) = remove_site_login_from_settings(&original, id)?;
|
||||
if !removed {
|
||||
return Err("site login was not found".to_string());
|
||||
}
|
||||
let _existing_password = get_keychain_password_if_present_unlocked(id)?;
|
||||
|
||||
// Remove the metadata first only after the keychain has been checked. If
|
||||
// deleting the secret fails, restore the metadata so the UI cannot lose a
|
||||
// credential that still exists.
|
||||
save_settings(connection, &updated)?;
|
||||
if let Err(error) = delete_keychain_password_unlocked(id) {
|
||||
if matches!(
|
||||
get_keychain_password_if_present_unlocked(id),
|
||||
Ok(None)
|
||||
) {
|
||||
return Ok(());
|
||||
}
|
||||
if let Err(rollback_error) = save_settings(connection, &original) {
|
||||
return Err(format!(
|
||||
"failed to delete site login credential: {error}; settings rollback also failed: {rollback_error}"
|
||||
));
|
||||
}
|
||||
return Err(format!("failed to delete site login credential: {error}"));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn save_settings_tx(transaction: &Transaction<'_>, data: &str) -> Result<(), String> {
|
||||
transaction
|
||||
.execute(
|
||||
@@ -1355,22 +1509,25 @@ pub fn set_keychain_password(id: &str, password: &str) -> Result<(), String> {
|
||||
set_keychain_password_unlocked(id, password)
|
||||
}
|
||||
|
||||
fn get_keychain_password_unlocked(id: &str) -> Result<String, String> {
|
||||
fn get_keychain_password_if_present_unlocked(id: &str) -> Result<Option<String>, String> {
|
||||
let entry = keychain_entry(id)?;
|
||||
match entry.get_password() {
|
||||
Ok(password) => Ok(password),
|
||||
Ok(password) => Ok(Some(password)),
|
||||
#[cfg(target_os = "linux")]
|
||||
Err(keyring_core::Error::NoEntry) => unique_legacy_linux_keychain_entry(id)?
|
||||
.ok_or_else(|| keyring_core::Error::NoEntry.to_string())?
|
||||
.get_password()
|
||||
.map_err(|error| error.to_string()),
|
||||
#[cfg(target_os = "linux")]
|
||||
Err(error) => Err(error.to_string()),
|
||||
.map(|legacy| legacy.get_password().map_err(|error| error.to_string()))
|
||||
.transpose(),
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
Err(keyring_core::Error::NoEntry) => Ok(None),
|
||||
Err(error) => Err(error.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
fn get_keychain_password_unlocked(id: &str) -> Result<String, String> {
|
||||
get_keychain_password_if_present_unlocked(id)?
|
||||
.ok_or_else(|| keyring_core::Error::NoEntry.to_string())
|
||||
}
|
||||
|
||||
pub fn get_keychain_password(id: &str) -> Result<String, String> {
|
||||
let _guard = lock_keyring_operations()?;
|
||||
get_keychain_password_unlocked(id)
|
||||
@@ -1405,6 +1562,88 @@ mod tests {
|
||||
use serde_json::json;
|
||||
use tempfile::TempDir;
|
||||
|
||||
#[test]
|
||||
fn site_login_settings_update_preserves_envelope_without_password() {
|
||||
let original = json!({
|
||||
"state": {
|
||||
"theme": "dark",
|
||||
"siteLogins": []
|
||||
},
|
||||
"version": 3
|
||||
})
|
||||
.to_string();
|
||||
|
||||
let updated = add_site_login_to_settings(
|
||||
&original,
|
||||
"login-1",
|
||||
"https://example.com/*",
|
||||
"nima",
|
||||
)
|
||||
.unwrap();
|
||||
let document: Value = serde_json::from_str(&updated).unwrap();
|
||||
assert_eq!(document["state"]["theme"], "dark");
|
||||
assert_eq!(document["version"], 3);
|
||||
assert_eq!(document["state"]["siteLogins"][0]["id"], "login-1");
|
||||
assert_eq!(document["state"]["siteLogins"][0]["username"], "nima");
|
||||
assert!(!updated.contains("password"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn site_login_settings_update_rejects_duplicate_ids() {
|
||||
let original = json!({
|
||||
"state": {
|
||||
"siteLogins": [{
|
||||
"id": "login-1",
|
||||
"urlPattern": "https://example.com/*",
|
||||
"username": "old-user"
|
||||
}]
|
||||
},
|
||||
"version": 3
|
||||
})
|
||||
.to_string();
|
||||
|
||||
let error = add_site_login_to_settings(
|
||||
&original,
|
||||
"login-1",
|
||||
"https://example.com/*",
|
||||
"new-user",
|
||||
)
|
||||
.unwrap_err();
|
||||
assert_eq!(error, "site login already exists");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn site_login_settings_removal_reports_whether_metadata_changed() {
|
||||
let original = json!({
|
||||
"state": {
|
||||
"siteLogins": [{
|
||||
"id": "login-1",
|
||||
"urlPattern": "https://example.com/*",
|
||||
"username": "nima"
|
||||
}]
|
||||
},
|
||||
"version": 3
|
||||
})
|
||||
.to_string();
|
||||
|
||||
let (updated, removed) = remove_site_login_from_settings(&original, "login-1").unwrap();
|
||||
assert!(removed);
|
||||
let document: Value = serde_json::from_str(&updated).unwrap();
|
||||
assert!(document["state"]["siteLogins"].as_array().unwrap().is_empty());
|
||||
|
||||
let (unchanged, removed) = remove_site_login_from_settings(&updated, "missing").unwrap();
|
||||
assert!(!removed);
|
||||
assert_eq!(unchanged, updated);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn site_login_ids_cannot_alias_the_pairing_token_or_hide_whitespace() {
|
||||
assert_eq!(validate_site_login_id("login-1").unwrap(), "login-1");
|
||||
assert!(validate_site_login_id(" extension-pairing-token").is_err());
|
||||
assert!(validate_site_login_id("extension-pairing-token ").is_err());
|
||||
assert!(validate_site_login_id(" login-1").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn migrates_v0_database_and_creates_backup() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
@@ -1471,7 +1710,7 @@ mod tests {
|
||||
.unwrap();
|
||||
drop(connection);
|
||||
|
||||
let state = init_at_path_internal(temp.path(), true, true).unwrap();
|
||||
let state = init_at_path_internal(temp.path(), true).unwrap();
|
||||
let connection = state.lock().unwrap();
|
||||
let saved: Value = serde_json::from_str(&load_downloads(&connection).unwrap()[0]).unwrap();
|
||||
assert!(saved.get("password").is_none());
|
||||
@@ -1561,7 +1800,7 @@ mod tests {
|
||||
});
|
||||
fs::write(&store_path, serde_json::to_vec(&store).unwrap()).unwrap();
|
||||
|
||||
let state = init_at_path_internal(¤t, true, true).unwrap();
|
||||
let state = init_at_path_internal(¤t, true).unwrap();
|
||||
let connection = state.lock().unwrap();
|
||||
let saved: Value = serde_json::from_str(&load_downloads(&connection).unwrap()[0]).unwrap();
|
||||
assert!(saved.get("password").is_none());
|
||||
@@ -1837,7 +2076,7 @@ mod tests {
|
||||
drop(connection);
|
||||
drop(state);
|
||||
|
||||
let state = init_at_path_internal(temp.path(), true, true).unwrap();
|
||||
let state = init_at_path_internal(temp.path(), true).unwrap();
|
||||
let connection = state.lock().unwrap();
|
||||
let saved: Value = serde_json::from_str(&load_downloads(&connection).unwrap()[0]).unwrap();
|
||||
assert!(saved.get("password").is_none());
|
||||
|
||||
@@ -77,7 +77,7 @@ impl DownloadCoordinator {
|
||||
&self,
|
||||
id: String,
|
||||
lifecycle_generation: u64,
|
||||
ack: tokio::sync::oneshot::Sender<()>,
|
||||
ack: tokio::sync::oneshot::Sender<bool>,
|
||||
) -> Result<(), String> {
|
||||
self.media_tx
|
||||
.send(MediaCmd::PauseWithAck {
|
||||
@@ -128,7 +128,7 @@ enum MediaCmd {
|
||||
PauseWithAck {
|
||||
id: String,
|
||||
lifecycle_generation: u64,
|
||||
ack: tokio::sync::oneshot::Sender<()>,
|
||||
ack: tokio::sync::oneshot::Sender<bool>,
|
||||
},
|
||||
Finished {
|
||||
id: String,
|
||||
@@ -144,7 +144,7 @@ async fn run_coordinator(
|
||||
let mut active_media = HashMap::<String, (u64, watch::Sender<bool>)>::new();
|
||||
let mut cancelled_media_generations = HashMap::<String, u64>::new();
|
||||
let mut pending_media_acks =
|
||||
HashMap::<(String, u64), tokio::sync::oneshot::Sender<()>>::new();
|
||||
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;
|
||||
@@ -228,7 +228,10 @@ async fn run_coordinator(
|
||||
.entry(id)
|
||||
.and_modify(|generation| *generation = (*generation).max(lifecycle_generation))
|
||||
.or_insert(lifecycle_generation);
|
||||
let _ = ack.send(());
|
||||
// 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 } => {
|
||||
@@ -238,7 +241,9 @@ async fn run_coordinator(
|
||||
if let Some(ack) =
|
||||
pending_media_acks.remove(&(id.clone(), lifecycle_generation))
|
||||
{
|
||||
let _ = ack.send(());
|
||||
// A pending acknowledgement means the runner was
|
||||
// registered and has now observed cancellation.
|
||||
let _ = ack.send(true);
|
||||
}
|
||||
if cancelled_media_generations
|
||||
.get(&id)
|
||||
@@ -404,6 +409,30 @@ mod tests {
|
||||
.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();
|
||||
|
||||
@@ -17,13 +17,17 @@ 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";
|
||||
@@ -33,11 +37,14 @@ 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)]
|
||||
@@ -45,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,
|
||||
}
|
||||
@@ -63,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> {
|
||||
@@ -90,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,
|
||||
};
|
||||
@@ -106,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);
|
||||
|
||||
@@ -235,10 +264,11 @@ async fn download_handler(
|
||||
State(state): State<ServerState>,
|
||||
headers: HeaderMap,
|
||||
body: Bytes,
|
||||
) -> Result<StatusCode, StatusCode> {
|
||||
if !has_allowed_request_origin(&headers) || !has_valid_optional_nonce(&headers) {
|
||||
return Err(StatusCode::FORBIDDEN);
|
||||
}
|
||||
) -> 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")
|
||||
@@ -293,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 {
|
||||
@@ -314,12 +374,45 @@ 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<_>>();
|
||||
@@ -341,19 +434,41 @@ fn normalize_download(payload: ExtensionRequest) -> Option<ExtensionDownload> {
|
||||
matches!(url.scheme(), "http" | "https").then(|| url.to_string())
|
||||
});
|
||||
let filename = payload.filename.and_then(|value| sanitize_filename(&value));
|
||||
let 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 Cookie headers and the dedicated cookie field
|
||||
// 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 cookies = if !payload.media && urls.len() == 1 {
|
||||
payload
|
||||
.cookies
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
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,
|
||||
@@ -365,10 +480,64 @@ fn normalize_download(payload: ExtensionRequest) -> Option<ExtensionDownload> {
|
||||
// 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 {
|
||||
@@ -379,7 +548,17 @@ fn normalize_headers(headers: Option<String>, media: bool) -> Option<String> {
|
||||
.lines()
|
||||
.filter(|line| {
|
||||
line.split_once(':')
|
||||
.map(|(name, _)| !name.trim().eq_ignore_ascii_case("cookie"))
|
||||
.map(|(name, _)| {
|
||||
!matches!(
|
||||
name.trim().to_ascii_lowercase().as_str(),
|
||||
"authorization"
|
||||
| "cookie"
|
||||
| "cookie2"
|
||||
| "proxy-authorization"
|
||||
| "set-cookie"
|
||||
| "set-cookie2"
|
||||
)
|
||||
})
|
||||
.unwrap_or(true)
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
@@ -437,11 +616,11 @@ fn has_allowed_request_origin(headers: &HeaderMap) -> bool {
|
||||
}
|
||||
}
|
||||
|
||||
fn has_valid_optional_nonce(headers: &HeaderMap) -> bool {
|
||||
match headers.get(CLIENT_NONCE_HEADER) {
|
||||
None => true,
|
||||
Some(nonce) => nonce.to_str().ok().is_some_and(is_valid_client_nonce),
|
||||
}
|
||||
fn 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(
|
||||
@@ -478,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> {
|
||||
@@ -530,9 +715,10 @@ fn is_allowed_origin(origin: &str) -> bool {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
add_server_identity, claim_request, has_allowed_request_origin, has_valid_optional_nonce,
|
||||
is_valid_client_nonce, normalize_download, sign_server_proof, ExtensionRequest,
|
||||
PROTOCOL_VERSION_HEADER, SERVER_HEADER,
|
||||
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},
|
||||
@@ -581,10 +767,9 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_invalid_origins_and_malformed_optional_nonces() {
|
||||
fn rejects_invalid_origins() {
|
||||
let mut headers = HeaderMap::new();
|
||||
assert!(has_allowed_request_origin(&headers));
|
||||
assert!(has_valid_optional_nonce(&headers));
|
||||
|
||||
headers.insert(
|
||||
"origin",
|
||||
@@ -596,12 +781,28 @@ mod tests {
|
||||
"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!(has_allowed_request_origin(&headers));
|
||||
assert!(!has_valid_optional_nonce(&headers));
|
||||
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]
|
||||
@@ -613,7 +814,30 @@ mod tests {
|
||||
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());
|
||||
@@ -623,9 +847,40 @@ mod tests {
|
||||
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(&signature, 42, &cache));
|
||||
assert!(!claim_request(&signature, 42, &cache));
|
||||
assert!(claim_request_at(&signature, now, &cache, now));
|
||||
assert!(!claim_request_at(&signature, now, &cache, now + 1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn future_timestamp_replay_claim_survives_cache_pruning_window() {
|
||||
let cache = Arc::new(Mutex::new(HashMap::new()));
|
||||
let signature = "b".repeat(64);
|
||||
let now = 1_000_000;
|
||||
let future_timestamp = now + 30_000;
|
||||
|
||||
assert!(claim_request_at(&signature, future_timestamp, &cache, now));
|
||||
assert!(!claim_request_at(
|
||||
&signature,
|
||||
future_timestamp,
|
||||
&cache,
|
||||
now + 70_000
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn acknowledges_and_removes_pending_extension_event() {
|
||||
let registry = Arc::new(Mutex::new(HashMap::new()));
|
||||
let (sender, receiver) = tokio::sync::oneshot::channel();
|
||||
registry
|
||||
.lock()
|
||||
.unwrap()
|
||||
.insert("request-1".to_string(), sender);
|
||||
|
||||
assert!(acknowledge_extension_download(®istry, "request-1"));
|
||||
assert!(!acknowledge_extension_download(®istry, "request-1"));
|
||||
assert!(receiver.await.is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -636,11 +891,14 @@ mod tests {
|
||||
silent: false,
|
||||
filename: None,
|
||||
headers: Some(format!(
|
||||
"Cookie: stale={};\nUser-Agent: Firefox",
|
||||
"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");
|
||||
|
||||
@@ -658,7 +916,10 @@ mod tests {
|
||||
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");
|
||||
|
||||
@@ -669,6 +930,46 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn regular_capture_normalizes_host_scoped_cookie_headers() {
|
||||
let download = normalize_download(ExtensionRequest {
|
||||
urls: vec!["https://mail.google.com/mail/u/0/?view=att".to_string()],
|
||||
referer: Some("https://mail.google.com/mail/u/0/".to_string()),
|
||||
silent: true,
|
||||
filename: Some("report.zip".to_string()),
|
||||
headers: None,
|
||||
cookies: Some("SID=mail-session".to_string()),
|
||||
cookie_scopes: Some(vec![
|
||||
ExtensionCookieScope {
|
||||
url: "https://mail.google.com/".to_string(),
|
||||
cookies: "SID=mail-session".to_string(),
|
||||
},
|
||||
ExtensionCookieScope {
|
||||
url: "https://accounts.google.com/".to_string(),
|
||||
cookies: "SID=account-session".to_string(),
|
||||
},
|
||||
ExtensionCookieScope {
|
||||
url: "https://mail.google.com/another-path".to_string(),
|
||||
cookies: "duplicate=ignored".to_string(),
|
||||
},
|
||||
]),
|
||||
media: false,
|
||||
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 {
|
||||
@@ -681,7 +982,10 @@ mod tests {
|
||||
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");
|
||||
|
||||
@@ -689,6 +993,52 @@ mod tests {
|
||||
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()));
|
||||
|
||||
@@ -6,6 +6,14 @@ 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/")]
|
||||
@@ -80,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)]
|
||||
@@ -251,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>,
|
||||
@@ -261,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,
|
||||
@@ -274,6 +292,8 @@ pub struct PersistedSettings {
|
||||
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,
|
||||
@@ -283,6 +303,7 @@ 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>,
|
||||
|
||||
+2273
-236
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,5 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
#[cfg(any(target_os = "macos", target_os = "linux"))]
|
||||
use std::process::Command;
|
||||
use ts_rs::TS;
|
||||
|
||||
@@ -83,6 +84,7 @@ fn proxy_from_environment() -> Option<String> {
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "macos", target_os = "linux"))]
|
||||
fn command_stdout(command: &mut Command) -> std::io::Result<String> {
|
||||
let output = command.output()?;
|
||||
if !output.status.success() {
|
||||
|
||||
+264
-26
@@ -1,5 +1,5 @@
|
||||
use crate::ipc::{DownloadStateEvent, DownloadStatus, QueueDirection};
|
||||
use crate::retry::{backoff_and_emit, is_transient_network_error, BackoffOutcome};
|
||||
use crate::retry::{backoff_and_emit, is_transient_network_error, BackoffOutcome, MAX_RETRIES};
|
||||
use log;
|
||||
use serde::Deserialize;
|
||||
use serde_json;
|
||||
@@ -15,10 +15,16 @@ use ts_rs::TS;
|
||||
/// Default capacity when no setting is read yet.
|
||||
pub const DEFAULT_MAX_CONCURRENT: usize = 3;
|
||||
pub const MEDIA_RUN_CANCELLED: &str = "__firelink_media_run_cancelled__";
|
||||
pub const DOWNLOAD_CONNECTIONS_MIN: i32 = 1;
|
||||
pub const DOWNLOAD_CONNECTIONS_MAX: i32 = 16;
|
||||
|
||||
pub fn clamp_download_connections(connections: i32) -> i32 {
|
||||
connections.clamp(DOWNLOAD_CONNECTIONS_MIN, DOWNLOAD_CONNECTIONS_MAX)
|
||||
}
|
||||
|
||||
type Aria2ControlLocks = Arc<StdMutex<HashMap<String, Arc<Mutex<()>>>>>;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Aria2GidMapping {
|
||||
pub id: String,
|
||||
pub epoch: u64,
|
||||
@@ -57,6 +63,16 @@ pub enum PendingOutcome {
|
||||
Error(String),
|
||||
}
|
||||
|
||||
/// Result of recycling an aria2 transfer's connections. A refresh can race
|
||||
/// with daemon completion or leave the transfer paused after an ambiguous
|
||||
/// unpause failure, so callers must handle the verified daemon outcome.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Aria2RefreshOutcome {
|
||||
Resumed,
|
||||
Paused,
|
||||
Complete,
|
||||
}
|
||||
|
||||
/// What kind of sidecar a queued task spawns. Drives which runner the
|
||||
/// dispatcher invokes.
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -117,7 +133,7 @@ pub trait SidecarSpawner: Send + Sync + 'static {
|
||||
/// Recycle the connections for an active aria2 transfer without changing
|
||||
/// its gid or releasing its queue permit. Production uses forcePause /
|
||||
/// unpause; test spawners can leave this unsupported.
|
||||
async fn refresh_uri(&self, _gid: &str) -> Result<(), String> {
|
||||
async fn refresh_uri(&self, _gid: &str) -> Result<Aria2RefreshOutcome, String> {
|
||||
Err("aria2 connection refresh is unavailable".to_string())
|
||||
}
|
||||
|
||||
@@ -156,6 +172,11 @@ pub struct QueueManager<R: tauri::Runtime = tauri::Wry> {
|
||||
/// download id -> spawn payload for aria2 transient-error re-addUri retries.
|
||||
aria2_payloads: Mutex<HashMap<String, SpawnPayload>>,
|
||||
|
||||
/// The daemon-wide download cap currently applied to aria2. This mirrors
|
||||
/// successful RPC changes so the poller can avoid treating an intentional
|
||||
/// cap as a degraded connection pool.
|
||||
aria2_global_speed_limit: Arc<StdMutex<Option<String>>>,
|
||||
|
||||
/// 0-based transient-error strike counter per aria2 download id.
|
||||
aria2_retry_strikes: Mutex<HashMap<String, usize>>,
|
||||
|
||||
@@ -221,6 +242,7 @@ impl<R: tauri::Runtime> QueueManager<R> {
|
||||
aria2_gids: Arc::new(std::sync::RwLock::new(HashMap::new())),
|
||||
pending_completion: Arc::new(Mutex::new(HashMap::new())),
|
||||
aria2_payloads: Mutex::new(HashMap::new()),
|
||||
aria2_global_speed_limit: Arc::new(StdMutex::new(None)),
|
||||
aria2_retry_strikes: Mutex::new(HashMap::new()),
|
||||
aria2_retry_cancelled: Mutex::new(HashSet::new()),
|
||||
aria2_retry_inflight: Mutex::new(HashMap::new()),
|
||||
@@ -465,6 +487,33 @@ impl<R: tauri::Runtime> QueueManager<R> {
|
||||
.await
|
||||
.get(id)
|
||||
.and_then(|payload| payload.connections)
|
||||
.map(clamp_download_connections)
|
||||
}
|
||||
|
||||
pub fn set_aria2_global_speed_limit(&self, limit: Option<String>) {
|
||||
*self
|
||||
.aria2_global_speed_limit
|
||||
.lock()
|
||||
.unwrap_or_else(|error| error.into_inner()) = limit;
|
||||
}
|
||||
|
||||
pub async fn aria2_speed_limited(&self, id: &str) -> bool {
|
||||
if self
|
||||
.aria2_global_speed_limit
|
||||
.lock()
|
||||
.unwrap_or_else(|error| error.into_inner())
|
||||
.is_some()
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
self.aria2_payloads
|
||||
.lock()
|
||||
.await
|
||||
.get(id)
|
||||
.and_then(|payload| payload.speed_limit.as_deref())
|
||||
.and_then(crate::normalize_speed_limit_for_aria2)
|
||||
.is_some()
|
||||
}
|
||||
|
||||
/// Pop the next task, or None if empty.
|
||||
@@ -1069,6 +1118,22 @@ impl<R: tauri::Runtime> QueueManager<R> {
|
||||
.find_map(|(gid, mapping)| (mapping.id == id).then(|| gid.clone()))
|
||||
}
|
||||
|
||||
/// Capture the GID ownership token used by the poller. The mapping's
|
||||
/// epoch must still be current when an asynchronous status snapshot is
|
||||
/// emitted; otherwise a late response from an older GID can be attributed
|
||||
/// to a newer lifecycle for the same download id.
|
||||
pub fn aria2_gid_mapping(&self, gid: &str) -> Option<Aria2GidMapping> {
|
||||
self.aria2_gids.read().unwrap().get(gid).cloned()
|
||||
}
|
||||
|
||||
pub fn is_current_aria2_gid_mapping(
|
||||
&self,
|
||||
gid: &str,
|
||||
expected: &Aria2GidMapping,
|
||||
) -> bool {
|
||||
self.aria2_gid_mapping(gid).as_ref() == Some(expected)
|
||||
}
|
||||
|
||||
pub fn aria2_gid_mappings(&self) -> Vec<(String, String)> {
|
||||
self.aria2_gids
|
||||
.read()
|
||||
@@ -1080,22 +1145,27 @@ impl<R: tauri::Runtime> QueueManager<R> {
|
||||
|
||||
/// Recycle an active transfer's connections after the poller observes a
|
||||
/// persistent connection-pool collapse or a true zero-progress stall.
|
||||
/// The transfer keeps its gid, partial file, and queue permit.
|
||||
pub async fn refresh_aria2_connections(&self, id: &str, gid: &str) -> Result<(), String> {
|
||||
/// The observed epoch must still own the GID before the refresh can act.
|
||||
pub async fn refresh_aria2_connections(
|
||||
&self,
|
||||
id: &str,
|
||||
gid: &str,
|
||||
observed_epoch: u64,
|
||||
) -> Result<(), String> {
|
||||
let _control_guard = self.acquire_aria2_control(id).await;
|
||||
if self.aria2_gid_for_download(id).as_deref() != Some(gid)
|
||||
|| !self.is_registered(id).await
|
||||
|| self.is_aria2_retry_cancelled(id).await
|
||||
|| !self.is_aria2_control_epoch_current(id, observed_epoch).await
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let epoch = self.current_aria2_control_epoch(id).await;
|
||||
self.spawner.refresh_uri(gid).await?;
|
||||
let outcome = self.spawner.refresh_uri(gid).await?;
|
||||
|
||||
let still_current = self.is_registered(id).await
|
||||
&& !self.is_aria2_retry_cancelled(id).await
|
||||
&& self.is_aria2_control_epoch_current(id, epoch).await
|
||||
&& self.is_aria2_control_epoch_current(id, observed_epoch).await
|
||||
&& self.aria2_gid_for_download(id).as_deref() == Some(gid);
|
||||
if !still_current {
|
||||
log::info!(
|
||||
@@ -1103,6 +1173,31 @@ impl<R: tauri::Runtime> QueueManager<R> {
|
||||
id,
|
||||
gid
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
match outcome {
|
||||
Aria2RefreshOutcome::Resumed => {}
|
||||
Aria2RefreshOutcome::Paused => {
|
||||
self.next_aria2_control_epoch(id).await;
|
||||
self.cancel_aria2_retries(id).await;
|
||||
self.release_permit(id).await;
|
||||
self.emit_state(id, DownloadStatus::Paused);
|
||||
log::warn!(
|
||||
"aria2 connection refresh [{}]: gid {} remained paused; released its queue permit",
|
||||
id,
|
||||
gid
|
||||
);
|
||||
}
|
||||
Aria2RefreshOutcome::Complete => {
|
||||
self.apply_completion_locked(id, PendingOutcome::Complete)
|
||||
.await;
|
||||
log::info!(
|
||||
"aria2 connection refresh [{}]: gid {} completed during recovery",
|
||||
id,
|
||||
gid
|
||||
);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -1528,7 +1623,7 @@ impl<R: tauri::Runtime> QueueManager<R> {
|
||||
}
|
||||
|
||||
fn automatic_retry_limit(max_tries: Option<i32>) -> usize {
|
||||
max_tries.unwrap_or(0).max(0) as usize
|
||||
max_tries.unwrap_or(MAX_RETRIES as i32).max(0) as usize
|
||||
}
|
||||
|
||||
fn aria2_attempt_limit(max_tries: Option<i32>) -> u32 {
|
||||
@@ -1582,7 +1677,11 @@ enum BoundedRangeSupport {
|
||||
}
|
||||
|
||||
async fn effective_aria2_connections(id: &str, payload: &SpawnPayload) -> i32 {
|
||||
let requested = payload.connections.unwrap_or(1).max(1);
|
||||
let requested = clamp_download_connections(
|
||||
payload
|
||||
.connections
|
||||
.unwrap_or(DOWNLOAD_CONNECTIONS_MIN),
|
||||
);
|
||||
if requested <= 1 {
|
||||
return requested;
|
||||
}
|
||||
@@ -1782,6 +1881,30 @@ pub struct ProductionSpawner {
|
||||
app_handle: AppHandle<tauri::Wry>,
|
||||
}
|
||||
|
||||
const ARIA2_MIN_SPLIT_SIZE: &str = "1M";
|
||||
|
||||
fn apply_aria2_connection_options(
|
||||
options: &mut serde_json::Map<String, serde_json::Value>,
|
||||
connections: i32,
|
||||
) {
|
||||
let connections = clamp_download_connections(connections);
|
||||
options.insert(
|
||||
"split".to_string(),
|
||||
serde_json::json!(connections.to_string()),
|
||||
);
|
||||
options.insert(
|
||||
"max-connection-per-server".to_string(),
|
||||
serde_json::json!(connections.to_string()),
|
||||
);
|
||||
// aria2's 20M default suppresses segmentation for files smaller than
|
||||
// 40M. Keep the requested connection count useful for ordinary release
|
||||
// assets while retaining a 1M lower bound to avoid tiny range requests.
|
||||
options.insert(
|
||||
"min-split-size".to_string(),
|
||||
serde_json::json!(ARIA2_MIN_SPLIT_SIZE),
|
||||
);
|
||||
}
|
||||
|
||||
impl ProductionSpawner {
|
||||
pub fn new(app_handle: AppHandle<tauri::Wry>) -> Self {
|
||||
Self { app_handle }
|
||||
@@ -1837,11 +1960,7 @@ impl SidecarSpawner for ProductionSpawner {
|
||||
crate::download_ownership::canonical_download_filename(&payload.filename);
|
||||
options.insert("out".to_string(), serde_json::json!(safe_filename));
|
||||
let conn = effective_aria2_connections(id, payload).await;
|
||||
options.insert("split".to_string(), serde_json::json!(conn.to_string()));
|
||||
options.insert(
|
||||
"max-connection-per-server".to_string(),
|
||||
serde_json::json!(conn.to_string()),
|
||||
);
|
||||
apply_aria2_connection_options(&mut options, conn);
|
||||
let mt = aria2_attempt_limit(payload.max_tries);
|
||||
options.insert("max-tries".to_string(), serde_json::json!(mt.to_string()));
|
||||
options.insert("retry-wait".to_string(), serde_json::json!("2"));
|
||||
@@ -1905,8 +2024,9 @@ impl SidecarSpawner for ProductionSpawner {
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("aria2 addUri [{}] failed: {}", id, e);
|
||||
Err(format!("aria2 addUri failed: {e}"))
|
||||
let safe_error = crate::redact_sensitive_text(&e);
|
||||
log::error!("aria2 addUri [{}] failed: {}", id, safe_error);
|
||||
Err(format!("aria2 addUri failed: {safe_error}"))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1929,19 +2049,72 @@ impl SidecarSpawner for ProductionSpawner {
|
||||
}
|
||||
}
|
||||
|
||||
async fn refresh_uri(&self, gid: &str) -> Result<(), String> {
|
||||
async fn refresh_uri(&self, gid: &str) -> Result<Aria2RefreshOutcome, String> {
|
||||
let state = self.app_handle.state::<crate::AppState>();
|
||||
let port = state.aria2_port.load(std::sync::atomic::Ordering::Relaxed);
|
||||
let secret = &state.aria2_secret;
|
||||
let paused = crate::rpc_call(port, secret, "aria2.forcePause", serde_json::json!([gid]))
|
||||
.await
|
||||
.map_err(|error| format!("failed to refresh aria2 gid {gid}: {error}"))?;
|
||||
crate::ensure_aria2_gid_result("forcePause", gid, &paused)?;
|
||||
let pause_error = match crate::rpc_call(
|
||||
port,
|
||||
secret,
|
||||
"aria2.forcePause",
|
||||
serde_json::json!([gid]),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(result) => crate::ensure_aria2_gid_result("forcePause", gid, &result)
|
||||
.err()
|
||||
.map(|error| error.to_string()),
|
||||
Err(error) => Some(format!("failed to refresh aria2 gid {gid}: {error}")),
|
||||
};
|
||||
|
||||
let resumed = crate::rpc_call(port, secret, "aria2.unpause", serde_json::json!([gid]))
|
||||
.await
|
||||
.map_err(|error| format!("failed to refresh aria2 gid {gid}: {error}"))?;
|
||||
crate::ensure_aria2_gid_result("unpause", gid, &resumed)
|
||||
if let Some(error) = pause_error {
|
||||
match crate::aria2_download_status(port, secret, gid).await {
|
||||
Ok(status) if status == "paused" => {
|
||||
log::warn!(
|
||||
"aria2 connection refresh: forcePause for gid {} failed after the daemon paused it; continuing with unpause",
|
||||
gid
|
||||
);
|
||||
}
|
||||
Ok(status) if status == "complete" => {
|
||||
return Ok(Aria2RefreshOutcome::Complete);
|
||||
}
|
||||
Ok(status) => {
|
||||
return Err(format!("{error}; aria2 gid {gid} is still {status}"));
|
||||
}
|
||||
Err(status_error) => {
|
||||
return Err(format!(
|
||||
"{error}; failed to verify aria2 gid {gid}: {status_error}"
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let unpause_error = match crate::rpc_call(
|
||||
port,
|
||||
secret,
|
||||
"aria2.unpause",
|
||||
serde_json::json!([gid]),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(result) => crate::ensure_aria2_gid_result("unpause", gid, &result)
|
||||
.err()
|
||||
.map(|error| error.to_string()),
|
||||
Err(error) => Some(format!("failed to refresh aria2 gid {gid}: {error}")),
|
||||
};
|
||||
|
||||
let Some(error) = unpause_error else {
|
||||
return Ok(Aria2RefreshOutcome::Resumed);
|
||||
};
|
||||
let status = crate::aria2_download_status(port, secret, gid).await?;
|
||||
match status.as_str() {
|
||||
"active" | "waiting" => Ok(Aria2RefreshOutcome::Resumed),
|
||||
"paused" => Ok(Aria2RefreshOutcome::Paused),
|
||||
"complete" => Ok(Aria2RefreshOutcome::Complete),
|
||||
_ => Err(format!(
|
||||
"{error}; aria2 gid {gid} reports terminal or unknown state {status}"
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
async fn run_media(
|
||||
@@ -1951,10 +2124,24 @@ impl SidecarSpawner for ProductionSpawner {
|
||||
lifecycle_generation: u64,
|
||||
) -> Result<(), String> {
|
||||
let state = self.app_handle.state::<crate::AppState>();
|
||||
// Serialize registration with pause/remove/detach. If the queue
|
||||
// lifecycle was invalidated before this worker reached the
|
||||
// coordinator, do not create a late media registration that could
|
||||
// outlive the row's permit and ownership.
|
||||
let control_guard = state.queue_manager.acquire_aria2_control(id).await;
|
||||
if !state
|
||||
.queue_manager
|
||||
.is_registered_generation(id, lifecycle_generation)
|
||||
.await
|
||||
{
|
||||
drop(control_guard);
|
||||
return Err(crate::queue::MEDIA_RUN_CANCELLED.to_string());
|
||||
}
|
||||
let mut cancel_rx = state
|
||||
.download_coordinator
|
||||
.register_media(id.to_string(), lifecycle_generation)
|
||||
.await?;
|
||||
drop(control_guard);
|
||||
let outcome = if *cancel_rx.borrow() {
|
||||
Err(crate::queue::MEDIA_RUN_CANCELLED.to_string())
|
||||
} else {
|
||||
@@ -1965,6 +2152,7 @@ impl SidecarSpawner for ProductionSpawner {
|
||||
payload.destination.clone(),
|
||||
payload.filename.clone(),
|
||||
payload.format_selector.clone(),
|
||||
payload.connections,
|
||||
payload.cookie_source.clone(),
|
||||
payload.speed_limit.clone(),
|
||||
payload.username.clone(),
|
||||
@@ -2068,6 +2256,49 @@ impl EnqueueItem {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn aria2_connection_options_enable_requested_ranges_for_small_release_assets() {
|
||||
let mut options = serde_json::Map::new();
|
||||
|
||||
apply_aria2_connection_options(&mut options, 16);
|
||||
|
||||
assert_eq!(options.get("split"), Some(&serde_json::json!("16")));
|
||||
assert_eq!(
|
||||
options.get("max-connection-per-server"),
|
||||
Some(&serde_json::json!("16"))
|
||||
);
|
||||
assert_eq!(
|
||||
options.get("min-split-size"),
|
||||
Some(&serde_json::json!("1M"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn aria2_connection_options_never_emit_zero_connections() {
|
||||
let mut options = serde_json::Map::new();
|
||||
|
||||
apply_aria2_connection_options(&mut options, 0);
|
||||
|
||||
assert_eq!(options.get("split"), Some(&serde_json::json!("1")));
|
||||
assert_eq!(
|
||||
options.get("max-connection-per-server"),
|
||||
Some(&serde_json::json!("1"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn aria2_connection_options_clamp_untrusted_connection_counts() {
|
||||
let mut options = serde_json::Map::new();
|
||||
|
||||
apply_aria2_connection_options(&mut options, i32::MAX);
|
||||
|
||||
assert_eq!(options.get("split"), Some(&serde_json::json!("16")));
|
||||
assert_eq!(
|
||||
options.get("max-connection-per-server"),
|
||||
Some(&serde_json::json!("16"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bounded_range_probe_accepts_exact_requested_byte() {
|
||||
assert_eq!(
|
||||
@@ -2149,4 +2380,11 @@ mod tests {
|
||||
assert_eq!(aria2_attempt_limit(Some(0)), 1);
|
||||
assert_eq!(aria2_attempt_limit(Some(10)), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn omitted_retry_limit_uses_the_shared_default_but_zero_stays_explicit() {
|
||||
assert_eq!(automatic_retry_limit(None), MAX_RETRIES);
|
||||
assert_eq!(automatic_retry_limit(Some(0)), 0);
|
||||
assert_eq!(automatic_retry_limit(Some(2)), 2);
|
||||
}
|
||||
}
|
||||
|
||||
+95
-12
@@ -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(¤t_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",
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -192,6 +192,12 @@ fn sanitize_persisted_setting_values(state: &mut Value) {
|
||||
"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", &[
|
||||
@@ -410,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(),
|
||||
@@ -420,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,
|
||||
@@ -441,6 +449,7 @@ fn default_settings() -> PersistedSettings {
|
||||
max_automatic_retries: 3,
|
||||
show_notifications: true,
|
||||
play_completion_sound: false,
|
||||
auto_add_clipboard_links: false,
|
||||
app_font_size: AppFontSize::Standard,
|
||||
list_row_density: ListRowDensity::Standard,
|
||||
show_dock_badge: true,
|
||||
@@ -450,6 +459,7 @@ 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(),
|
||||
@@ -498,6 +508,7 @@ mod tests {
|
||||
"state": {
|
||||
"maxConcurrentDownloads": 7,
|
||||
"globalSpeedLimit": "2M",
|
||||
"sidebarPosition": "right",
|
||||
"scheduler": {
|
||||
"enabled": true,
|
||||
"startTime": "06:30",
|
||||
@@ -515,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);
|
||||
@@ -652,6 +664,18 @@ 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!({
|
||||
@@ -696,8 +720,28 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn completion_sound_default_matches_the_frontend_default() {
|
||||
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]
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Firelink",
|
||||
"version": "1.1.0",
|
||||
"version": "1.2.0",
|
||||
"identifier": "com.nimbold.firelink",
|
||||
"build": {
|
||||
"beforeDevCommand": "node scripts/stage-engines.js && npm run dev",
|
||||
|
||||
@@ -8,17 +8,9 @@
|
||||
"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
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -9,10 +9,8 @@
|
||||
"minWidth": 960,
|
||||
"minHeight": 640,
|
||||
"transparent": true,
|
||||
"windowEffects": {
|
||||
"effects": ["mica"],
|
||||
"state": "active"
|
||||
}
|
||||
"decorations": false,
|
||||
"shadow": false
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
@@ -25,6 +26,11 @@ struct FailFirstAria2Spawner {
|
||||
fail_first: std::sync::atomic::AtomicBool,
|
||||
}
|
||||
|
||||
struct RefreshOutcomeSpawner {
|
||||
outcome: Aria2RefreshOutcome,
|
||||
refresh_calls: AtomicUsize,
|
||||
}
|
||||
|
||||
impl FailFirstAria2Spawner {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
@@ -100,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> {
|
||||
@@ -326,6 +357,32 @@ async fn resumed_gid_rebinds_to_the_new_control_epoch() {
|
||||
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);
|
||||
@@ -648,6 +705,73 @@ async fn aria2_permit_survives_rpc_return() {
|
||||
handle.abort();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn failed_refresh_that_leaves_gid_paused_releases_permit_but_keeps_resume_mapping() {
|
||||
let app = mock_builder()
|
||||
.build(mock_context(noop_assets()))
|
||||
.expect("mock app");
|
||||
let spawner = Arc::new(RefreshOutcomeSpawner {
|
||||
outcome: Aria2RefreshOutcome::Paused,
|
||||
refresh_calls: AtomicUsize::new(0),
|
||||
});
|
||||
let manager = QueueManager::test_new(
|
||||
app.handle().clone(),
|
||||
1,
|
||||
Arc::clone(&spawner) as Arc<dyn SidecarSpawner>,
|
||||
);
|
||||
manager.push(aria2_task("refresh-paused")).await.unwrap();
|
||||
assert!(manager.ensure_aria2_permit("refresh-paused").await);
|
||||
manager
|
||||
.remember_gid("refresh-paused".to_string(), "gid-refresh-paused".to_string())
|
||||
.await;
|
||||
|
||||
let epoch = manager.current_aria2_control_epoch("refresh-paused").await;
|
||||
manager
|
||||
.refresh_aria2_connections("refresh-paused", "gid-refresh-paused", epoch)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(spawner.refresh_calls.load(Ordering::SeqCst), 1);
|
||||
assert_eq!(manager.available_permits(), 1);
|
||||
assert!(!manager.has_active_permit("refresh-paused").await);
|
||||
assert_eq!(
|
||||
manager.aria2_gid_for_download("refresh-paused").as_deref(),
|
||||
Some("gid-refresh-paused")
|
||||
);
|
||||
assert!(manager.is_registered("refresh-paused").await);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn stale_refresh_observation_cannot_touch_a_newer_control_epoch() {
|
||||
let app = mock_builder()
|
||||
.build(mock_context(noop_assets()))
|
||||
.expect("mock app");
|
||||
let spawner = Arc::new(RefreshOutcomeSpawner {
|
||||
outcome: Aria2RefreshOutcome::Resumed,
|
||||
refresh_calls: AtomicUsize::new(0),
|
||||
});
|
||||
let manager = QueueManager::test_new(
|
||||
app.handle().clone(),
|
||||
1,
|
||||
Arc::clone(&spawner) as Arc<dyn SidecarSpawner>,
|
||||
);
|
||||
manager.push(aria2_task("refresh-stale")).await.unwrap();
|
||||
assert!(manager.ensure_aria2_permit("refresh-stale").await);
|
||||
manager
|
||||
.remember_gid("refresh-stale".to_string(), "gid-refresh-stale".to_string())
|
||||
.await;
|
||||
let stale_epoch = manager.current_aria2_control_epoch("refresh-stale").await;
|
||||
manager.next_aria2_control_epoch("refresh-stale").await;
|
||||
|
||||
manager
|
||||
.refresh_aria2_connections("refresh-stale", "gid-refresh-stale", stale_epoch)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(spawner.refresh_calls.load(Ordering::SeqCst), 0);
|
||||
assert!(manager.has_active_permit("refresh-stale").await);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn transient_aria2_error_reissues_after_backoff() {
|
||||
use firelink_lib::queue::PendingOutcome;
|
||||
|
||||
+378
-88
@@ -1,31 +1,101 @@
|
||||
import { initMediaDomains, isActiveDownloadStatus, isTransferActiveStatus } from './utils/downloads';
|
||||
import { schedulerCompletionState } from './utils/schedulerCompletion';
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { lazy, Suspense, useCallback, useEffect, useRef, useState } from "react";
|
||||
import { Sidebar, SidebarFilter } from "./components/Sidebar";
|
||||
import { DownloadTable } from "./components/DownloadTable";
|
||||
import { AddDownloadsModal } from "./components/AddDownloadsModal";
|
||||
import SettingsView from "./components/SettingsView";
|
||||
import { PropertiesModal } from "./components/PropertiesModal";
|
||||
import { DeleteConfirmationModal } from "./components/DeleteConfirmationModal";
|
||||
import { KeychainPermissionModal } from './components/KeychainPermissionModal';
|
||||
import { extractValidDownloadUrls } from './utils/url';
|
||||
import { readClipboardDownloadUrls } from './utils/clipboard';
|
||||
import { listenEvent as listen, invokeCommand as invoke } from "./ipc";
|
||||
import { useDownloadStore, MAIN_QUEUE_ID } from './store/useDownloadStore';
|
||||
import { useDownloadStore, MAIN_QUEUE_ID, type ExtensionDownloadRequest } from './store/useDownloadStore';
|
||||
import { initDownloadListener } from './store/downloadStore';
|
||||
import { useSettingsStore } from "./store/useSettingsStore";
|
||||
import { subscribeToSettingsPersistenceErrors, useSettingsStore } from "./store/useSettingsStore";
|
||||
import { isPermissionGranted, requestPermission, sendNotification } from '@tauri-apps/plugin-notification';
|
||||
import SchedulerView from "./components/SchedulerView";
|
||||
import SpeedLimiterView from "./components/SpeedLimiterView";
|
||||
import LogsView from "./components/LogsView";
|
||||
import { KeychainPermissionModal } from "./components/KeychainPermissionModal";
|
||||
import { WindowControls } from "./components/WindowControls";
|
||||
import { useToast } from "./contexts/ToastContext";
|
||||
import { setLogStreamActive } from './utils/logger';
|
||||
import { updateDockBadge } from './utils/dockBadge';
|
||||
import { openUrl } from '@tauri-apps/plugin-opener';
|
||||
import { getPlatformInfo, usePlatformInfo } from './utils/platform';
|
||||
import { getKeychainStartupDecision } from './utils/keychainStartup';
|
||||
import { getPlatformInfo, shouldUseCustomWindowControls, usePlatformInfo } from './utils/platform';
|
||||
import {
|
||||
getKeychainAccessReady,
|
||||
getKeychainConsentVersion,
|
||||
getKeychainStartupDecision
|
||||
} from './utils/keychainStartup';
|
||||
import { getVersion } from '@tauri-apps/api/app';
|
||||
import type { PostQueueAction } from './bindings/PostQueueAction';
|
||||
import { PanelLeft } from 'lucide-react';
|
||||
import { isTrustedFirelinkReleaseUrl } from './utils/releaseUrls';
|
||||
import { changeAppLocale, localeDirection, resolveAppLocale, syncDocumentLocale } from './i18n';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const loadSettingsView = () => import('./components/SettingsView');
|
||||
const loadSchedulerView = () => import('./components/SchedulerView');
|
||||
const loadSpeedLimiterView = () => import('./components/SpeedLimiterView');
|
||||
const loadLogsView = () => import('./components/LogsView');
|
||||
const pageChunkLoaders = [
|
||||
loadSettingsView,
|
||||
loadSchedulerView,
|
||||
loadSpeedLimiterView,
|
||||
loadLogsView,
|
||||
] as const;
|
||||
|
||||
const SettingsView = lazy(loadSettingsView);
|
||||
const SchedulerView = lazy(loadSchedulerView);
|
||||
const SpeedLimiterView = lazy(loadSpeedLimiterView);
|
||||
const LogsView = lazy(loadLogsView);
|
||||
const AddDownloadsModal = lazy(() => import('./components/AddDownloadsModal').then(module => ({
|
||||
default: module.AddDownloadsModal,
|
||||
})));
|
||||
const PropertiesModal = lazy(() => import('./components/PropertiesModal').then(module => ({
|
||||
default: module.PropertiesModal,
|
||||
})));
|
||||
const DeleteConfirmationModal = lazy(() => import('./components/DeleteConfirmationModal').then(module => ({
|
||||
default: module.DeleteConfirmationModal,
|
||||
})));
|
||||
const preloadPageChunks = async () => {
|
||||
for (const load of pageChunkLoaders) {
|
||||
try {
|
||||
await load();
|
||||
} catch (error) {
|
||||
console.warn('Failed to preload page chunk:', error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const scheduleAfterFirstPaint = (task: () => void): (() => void) => {
|
||||
const idleWindow = window as typeof window & {
|
||||
requestIdleCallback?: (callback: () => void, options?: { timeout?: number }) => number;
|
||||
cancelIdleCallback?: (handle: number) => void;
|
||||
};
|
||||
|
||||
if (idleWindow.requestIdleCallback) {
|
||||
const handle = idleWindow.requestIdleCallback(task, { timeout: 1000 });
|
||||
return () => idleWindow.cancelIdleCallback?.(handle);
|
||||
}
|
||||
|
||||
let timeoutHandle: number | null = null;
|
||||
const frameHandle = window.requestAnimationFrame(() => {
|
||||
timeoutHandle = window.setTimeout(task, 0);
|
||||
});
|
||||
return () => {
|
||||
window.cancelAnimationFrame(frameHandle);
|
||||
if (timeoutHandle !== null) window.clearTimeout(timeoutHandle);
|
||||
};
|
||||
};
|
||||
|
||||
const PageLoadingFallback = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 items-center justify-center bg-main-bg" role="status" aria-live="polite">
|
||||
<span className="sr-only">{t($ => $.app.loading)}</span>
|
||||
<div className="h-1.5 w-24 overflow-hidden rounded-full bg-item-hover" aria-hidden="true">
|
||||
<div className="h-full w-1/3 animate-pulse rounded-full bg-accent" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
let automaticUpdateCheckStarted = false;
|
||||
const processingScheduleKeys = new Set<string>();
|
||||
@@ -93,10 +163,21 @@ const playCompletionChime = async () => {
|
||||
};
|
||||
|
||||
function App() {
|
||||
const { i18n, t } = useTranslation();
|
||||
const platform = usePlatformInfo();
|
||||
const [filter, setFilter] = useState<SidebarFilter>('all');
|
||||
const [coreReady, setCoreReady] = useState(false);
|
||||
const [appVersion, setAppVersion] = useState('');
|
||||
const [keychainConsentVersion, setKeychainConsentVersion] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
const handleLanguageChanged = (language?: string) => {
|
||||
syncDocumentLocale(language ?? i18n.language);
|
||||
};
|
||||
|
||||
handleLanguageChanged();
|
||||
i18n.on('languageChanged', handleLanguageChanged);
|
||||
return () => i18n.off('languageChanged', handleLanguageChanged);
|
||||
}, [i18n]);
|
||||
|
||||
const [sidebarWidth, setSidebarWidth] = useState(() => {
|
||||
const stored = Number(window.localStorage.getItem('firelink-sidebar-width'));
|
||||
@@ -104,17 +185,33 @@ function App() {
|
||||
});
|
||||
|
||||
const theme = useSettingsStore(state => state.theme);
|
||||
const languagePreference = useSettingsStore(state => state.language);
|
||||
const isSidebarVisible = useSettingsStore(state => state.isSidebarVisible);
|
||||
const sidebarPosition = useSettingsStore(state => state.sidebarPosition);
|
||||
const toggleSidebar = useSettingsStore(state => state.toggleSidebar);
|
||||
const activeView = useSettingsStore(state => state.activeView);
|
||||
const appFontSize = useSettingsStore(state => state.appFontSize);
|
||||
const listRowDensity = useSettingsStore(state => state.listRowDensity);
|
||||
|
||||
useEffect(() => {
|
||||
const locale = languagePreference === 'system'
|
||||
? resolveAppLocale(typeof navigator === 'undefined' ? undefined : navigator.language)
|
||||
: languagePreference;
|
||||
if (i18n.language !== locale) {
|
||||
void changeAppLocale(locale);
|
||||
}
|
||||
}, [i18n, languagePreference]);
|
||||
const autoCheckUpdates = useSettingsStore(state => state.autoCheckUpdates);
|
||||
const autoAddClipboardLinks = useSettingsStore(state => state.autoAddClipboardLinks);
|
||||
const showNotifications = useSettingsStore(state => state.showNotifications);
|
||||
const showDockBadge = useSettingsStore(state => state.showDockBadge);
|
||||
const dockBadgeSyncVersion = useSettingsStore(state => state.dockBadgeSyncVersion);
|
||||
const showMenuBarIcon = useSettingsStore(state => state.showMenuBarIcon);
|
||||
const extensionPairingToken = useSettingsStore(state => state.extensionPairingToken);
|
||||
const showKeychainModal = useSettingsStore(state => state.showKeychainModal);
|
||||
const isAddModalOpen = useDownloadStore(state => state.isAddModalOpen);
|
||||
const selectedPropertiesDownloadId = useDownloadStore(state => state.selectedPropertiesDownloadId);
|
||||
const isDeleteModalOpen = useDownloadStore(state => state.deleteModalState.isOpen);
|
||||
const downloads = useDownloadStore(state => state.downloads);
|
||||
const activeDownloadCount = downloads.filter(download => isTransferActiveStatus(download.status)).length;
|
||||
const queuedCount = downloads.filter(download =>
|
||||
@@ -125,17 +222,35 @@ function App() {
|
||||
const schedulerActiveDownloadIds = useSettingsStore(state => state.schedulerActiveDownloadIds);
|
||||
const pendingPostActionTimer = useRef<number | null>(null);
|
||||
const startupResumeStarted = useRef(false);
|
||||
const startupInputReady = useRef(false);
|
||||
const frontendReadyUpdate = useRef<Promise<void>>(Promise.resolve());
|
||||
const pendingStartupInputs = useRef<Array<
|
||||
| { type: 'extension'; payload: ExtensionDownloadRequest }
|
||||
| { type: 'deep-link'; payload: string }
|
||||
>>([]);
|
||||
const maxConcurrentDownloads = useSettingsStore(state => state.maxConcurrentDownloads);
|
||||
const preventsSleepWhileDownloading = useSettingsStore(state => state.preventsSleepWhileDownloading);
|
||||
const activeTransferCount = downloads.filter(download => isTransferActiveStatus(download.status)).length;
|
||||
const { addToast } = useToast();
|
||||
const { addToast, removeToast } = useToast();
|
||||
const isMacUserAgent = navigator.userAgent.includes('Mac');
|
||||
const usesCustomWindowControls = !isMacUserAgent && platform.os !== 'macos';
|
||||
const usesCustomWindowControls = shouldUseCustomWindowControls(platform.os, navigator.userAgent);
|
||||
const isRtl = localeDirection(resolveAppLocale(i18n.language)) === 'rtl';
|
||||
const isSidebarOnRight = sidebarPosition === 'right' || (sidebarPosition === 'auto' && isRtl);
|
||||
// Keep dialogs out of the titlebar area while platform detection is still
|
||||
// resolving. The conservative fallback prevents a startup handoff from
|
||||
// briefly rendering underneath native or custom window controls.
|
||||
const hasWindowChrome = isMacUserAgent || ['macos', 'windows', 'linux', 'unknown'].includes(platform.os);
|
||||
|
||||
useEffect(() => scheduleAfterFirstPaint(preloadPageChunks), []);
|
||||
|
||||
useEffect(() => subscribeToSettingsPersistenceErrors(() => {
|
||||
addToast({
|
||||
message: t($ => $.app.settingsSaveFailed),
|
||||
variant: 'error',
|
||||
isActionable: true
|
||||
});
|
||||
}), [addToast]);
|
||||
|
||||
const acknowledgePairingTokenChange = () => {
|
||||
invoke('acknowledge_pairing_token_change').catch(error => {
|
||||
console.error('Failed to acknowledge pairing token migration notice:', error);
|
||||
@@ -149,39 +264,52 @@ function App() {
|
||||
}
|
||||
}, []);
|
||||
|
||||
const queueFrontendReadyUpdate = useCallback((ready: boolean) => {
|
||||
const update = frontendReadyUpdate.current
|
||||
.catch(() => undefined)
|
||||
.then(() => invoke('set_extension_frontend_ready', { ready }));
|
||||
frontendReadyUpdate.current = update;
|
||||
return update;
|
||||
}, []);
|
||||
|
||||
const schedulePostQueueAction = useCallback((action: Exclude<PostQueueAction, 'none'>) => {
|
||||
clearPendingPostActionTimer();
|
||||
|
||||
const actionLabel = action === 'shutdown' ? 'Shut down' : action === 'restart' ? 'Restart' : 'Sleep';
|
||||
const actionLabel = t($ => $.scheduler.postActions[action]);
|
||||
let timerId: number | null = null;
|
||||
let toastId: string | null = null;
|
||||
const cancel = () => {
|
||||
if (timerId !== null) {
|
||||
window.clearTimeout(timerId);
|
||||
if (pendingPostActionTimer.current === timerId) {
|
||||
pendingPostActionTimer.current = null;
|
||||
}
|
||||
timerId = null;
|
||||
clearPendingPostActionTimer();
|
||||
timerId = null;
|
||||
if (toastId !== null) {
|
||||
removeToast(toastId);
|
||||
toastId = null;
|
||||
}
|
||||
};
|
||||
|
||||
addToast({
|
||||
toastId = addToast({
|
||||
variant: 'warning',
|
||||
isActionable: true,
|
||||
onDismiss: clearPendingPostActionTimer,
|
||||
message: (
|
||||
<div className="flex items-center gap-3">
|
||||
<span>{actionLabel} in 10 seconds.</span>
|
||||
<span>{t($ => $.app.systemActionCountdown, { action: actionLabel })}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="app-button px-2 py-1"
|
||||
onClick={cancel}
|
||||
>
|
||||
Cancel
|
||||
{t($ => $.actions.cancel)}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
});
|
||||
|
||||
timerId = window.setTimeout(() => {
|
||||
if (toastId !== null) {
|
||||
removeToast(toastId);
|
||||
toastId = null;
|
||||
}
|
||||
if (pendingPostActionTimer.current === timerId) {
|
||||
pendingPostActionTimer.current = null;
|
||||
}
|
||||
@@ -192,7 +320,7 @@ function App() {
|
||||
);
|
||||
if (activeTransfers) {
|
||||
addToast({
|
||||
message: 'System action cancelled because another download is active or queued.',
|
||||
message: t($ => $.app.systemActionCancelled),
|
||||
variant: 'warning',
|
||||
isActionable: true
|
||||
});
|
||||
@@ -201,14 +329,14 @@ function App() {
|
||||
invoke('perform_system_action', { action }).catch(error => {
|
||||
console.error('Scheduled post action failed:', error);
|
||||
addToast({
|
||||
message: `Scheduled system action failed: ${String(error)}`,
|
||||
message: t($ => $.app.systemActionFailed, { detail: String(error) }),
|
||||
variant: 'error',
|
||||
isActionable: true
|
||||
});
|
||||
});
|
||||
}, 10_000);
|
||||
pendingPostActionTimer.current = timerId;
|
||||
}, [addToast, clearPendingPostActionTimer]);
|
||||
}, [addToast, clearPendingPostActionTimer, removeToast]);
|
||||
|
||||
const startSidebarResize = (event: React.PointerEvent<HTMLDivElement>) => {
|
||||
event.preventDefault();
|
||||
@@ -216,7 +344,10 @@ function App() {
|
||||
const startWidth = sidebarWidth;
|
||||
|
||||
const handlePointerMove = (moveEvent: PointerEvent) => {
|
||||
const nextWidth = Math.min(260, Math.max(190, startWidth + moveEvent.clientX - startX));
|
||||
const delta = isSidebarOnRight
|
||||
? startX - moveEvent.clientX
|
||||
: moveEvent.clientX - startX;
|
||||
const nextWidth = Math.min(260, Math.max(190, startWidth + delta));
|
||||
setSidebarWidth(nextWidth);
|
||||
};
|
||||
|
||||
@@ -255,7 +386,7 @@ function App() {
|
||||
let unlistenExtension: (() => void) | null = null;
|
||||
let unlistenDeepLink: (() => void) | null = null;
|
||||
const disposeListeners = () => {
|
||||
invoke('set_extension_frontend_ready', { ready: false }).catch(() => {});
|
||||
void queueFrontendReadyUpdate(false).catch(() => {});
|
||||
unlistenTerminalState?.();
|
||||
unlistenTerminalState = null;
|
||||
unlistenExtension?.();
|
||||
@@ -279,12 +410,12 @@ function App() {
|
||||
if (!settings.showNotifications) return;
|
||||
|
||||
const item = useDownloadStore.getState().downloads.find(d => d.id === event.payload.id);
|
||||
const fileName = item?.fileName || 'A file';
|
||||
const fileName = item?.fileName || t($ => $.app.unknownFile);
|
||||
if (event.payload.status === 'completed') {
|
||||
try {
|
||||
sendNotification({
|
||||
title: 'Download Complete',
|
||||
body: `${fileName} has finished downloading.`
|
||||
title: t($ => $.app.downloadCompleteTitle),
|
||||
body: t($ => $.app.downloadCompleteBody, { fileName })
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Completion notification failed:', error);
|
||||
@@ -292,8 +423,8 @@ function App() {
|
||||
} else {
|
||||
try {
|
||||
sendNotification({
|
||||
title: 'Download Failed',
|
||||
body: `${fileName} failed to download.`,
|
||||
title: t($ => $.app.downloadFailedTitle),
|
||||
body: t($ => $.app.downloadFailedBody, { fileName }),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failure notification failed:', error);
|
||||
@@ -301,11 +432,24 @@ function App() {
|
||||
}
|
||||
});
|
||||
unlistenExtension = await listen('extension-add-download', (event) => {
|
||||
if (event.payload.request_id) {
|
||||
void invoke('ack_extension_download', { requestId: event.payload.request_id }).catch(error => {
|
||||
console.error('Failed to acknowledge browser extension download:', error);
|
||||
});
|
||||
}
|
||||
if (!startupInputReady.current || useSettingsStore.getState().showKeychainModal) {
|
||||
pendingStartupInputs.current.push({ type: 'extension', payload: event.payload });
|
||||
return;
|
||||
}
|
||||
useDownloadStore.getState().handleExtensionDownload(event.payload).catch(error => {
|
||||
console.error('Failed to handle browser extension download:', error);
|
||||
});
|
||||
});
|
||||
unlistenDeepLink = await listen('deep-link-add-download', (event) => {
|
||||
if (!startupInputReady.current || useSettingsStore.getState().showKeychainModal) {
|
||||
pendingStartupInputs.current.push({ type: 'deep-link', payload: event.payload });
|
||||
return;
|
||||
}
|
||||
useDownloadStore.getState().openAddModalWithUrls(event.payload);
|
||||
});
|
||||
|
||||
@@ -324,7 +468,7 @@ function App() {
|
||||
if (!active) return;
|
||||
console.error('Failed to initialize Firelink state:', error);
|
||||
addToast({
|
||||
message: `Could not initialize saved downloads: ${String(error)}`,
|
||||
message: t($ => $.app.initializeDownloadsFailed, { detail: String(error) }),
|
||||
variant: 'error',
|
||||
isActionable: true
|
||||
});
|
||||
@@ -336,13 +480,15 @@ function App() {
|
||||
getPlatformInfo().catch(() => null)
|
||||
]);
|
||||
if (!active) return;
|
||||
setAppVersion(currentAppVersion);
|
||||
const currentKeychainConsentVersion = getKeychainConsentVersion(currentAppVersion);
|
||||
setKeychainConsentVersion(currentKeychainConsentVersion);
|
||||
|
||||
try {
|
||||
const settings = useSettingsStore.getState();
|
||||
const isStartupActive = () => active;
|
||||
const { deferKeychainHydration, showKeychainPrompt } = getKeychainStartupDecision({
|
||||
portable: currentPlatform?.portable === true,
|
||||
appVersion: currentAppVersion,
|
||||
appVersion: currentKeychainConsentVersion,
|
||||
approvedVersion: settings.keychainAccessVersion,
|
||||
accessGranted: settings.keychainAccessGranted,
|
||||
promptDismissed: settings.keychainPromptDismissed
|
||||
@@ -351,18 +497,32 @@ function App() {
|
||||
let changed = false;
|
||||
if (deferKeychainHydration) {
|
||||
settings.setKeychainAccessReady(false);
|
||||
if (showKeychainPrompt) {
|
||||
// Commit the explanation before the harmless session-token IPC so
|
||||
// a slow startup cannot leave the user facing an unexplained
|
||||
// credential-store request.
|
||||
settings.setShowKeychainModal(true);
|
||||
}
|
||||
// This token is already owned by the backend and does not access
|
||||
// the OS credential store. Render our explanation before any native
|
||||
// Keychain/Credential Manager prompt can be user-triggered.
|
||||
await settings.hydrateSessionPairingToken();
|
||||
if (showKeychainPrompt) {
|
||||
settings.setShowKeychainModal(true);
|
||||
}
|
||||
await settings.hydrateSessionPairingToken(isStartupActive);
|
||||
if (!active) return;
|
||||
} else {
|
||||
changed = await settings.hydratePairingToken();
|
||||
settings.setKeychainAccessReady(
|
||||
currentPlatform?.portable !== true && useSettingsStore.getState().isPairingTokenPersistent
|
||||
);
|
||||
// The backend keeps credential-store access disabled for every new
|
||||
// process. Arm it only after the persisted startup decision has
|
||||
// confirmed that this build was already approved; the hydrate call
|
||||
// below is then the first operation allowed to touch the OS store.
|
||||
await invoke('authorize_keychain_access');
|
||||
if (!active) return;
|
||||
changed = await settings.hydratePairingToken(isStartupActive);
|
||||
if (!active) return;
|
||||
const currentSettings = useSettingsStore.getState();
|
||||
settings.setKeychainAccessReady(getKeychainAccessReady({
|
||||
portable: currentPlatform?.portable === true,
|
||||
accessGranted: currentSettings.keychainAccessGranted,
|
||||
persistent: currentSettings.isPairingTokenPersistent
|
||||
}));
|
||||
}
|
||||
if (changed) {
|
||||
addToast({
|
||||
@@ -370,7 +530,7 @@ function App() {
|
||||
isActionable: true,
|
||||
message: (
|
||||
<div className="flex flex-col gap-2">
|
||||
<p>Browser extension disconnected because its pairing token changed.</p>
|
||||
<p>{t($ => $.app.extensionDisconnected)}</p>
|
||||
<div className="flex gap-2 justify-end">
|
||||
<button
|
||||
type="button"
|
||||
@@ -384,14 +544,14 @@ function App() {
|
||||
acknowledgePairingTokenChange();
|
||||
} catch (error) {
|
||||
addToast({
|
||||
message: `Could not copy pairing token: ${String(error)}`,
|
||||
message: t($ => $.app.copyTokenFailed, { detail: String(error) }),
|
||||
variant: 'error',
|
||||
isActionable: true
|
||||
});
|
||||
}
|
||||
}}
|
||||
>
|
||||
Copy token
|
||||
{t($ => $.app.copyToken)}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -403,7 +563,7 @@ function App() {
|
||||
acknowledgePairingTokenChange();
|
||||
}}
|
||||
>
|
||||
Integrations
|
||||
{t($ => $.app.integrations)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -413,7 +573,7 @@ function App() {
|
||||
} catch (error) {
|
||||
console.error('Failed to hydrate extension pairing token:', error);
|
||||
addToast({
|
||||
message: `Secure credential persistence is unavailable. Browser pairing works for this session only: ${String(error)}`,
|
||||
message: t($ => $.app.credentialPersistenceFailed, { detail: String(error) }),
|
||||
variant: 'error',
|
||||
isActionable: true
|
||||
});
|
||||
@@ -421,17 +581,45 @@ function App() {
|
||||
|
||||
if (!active) return;
|
||||
setCoreReady(true);
|
||||
invoke('set_extension_frontend_ready', { ready: true }).catch(error => {
|
||||
console.error('Failed to activate browser extension integration:', error);
|
||||
});
|
||||
};
|
||||
void initialize();
|
||||
return () => {
|
||||
active = false;
|
||||
startupInputReady.current = false;
|
||||
pendingStartupInputs.current = [];
|
||||
cleanupListeners?.();
|
||||
cleanupListeners = null;
|
||||
};
|
||||
}, [addToast]);
|
||||
}, [addToast, queueFrontendReadyUpdate]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!coreReady) return;
|
||||
// The backend must not emit extension/deep-link handoffs while the
|
||||
// explanatory Keychain dialog is active. Deep links are buffered by the
|
||||
// coordinator, and extension callers receive a retryable 503 instead.
|
||||
void queueFrontendReadyUpdate(!showKeychainModal).catch(error => {
|
||||
console.error('Failed to update browser extension readiness:', error);
|
||||
});
|
||||
}, [coreReady, queueFrontendReadyUpdate, showKeychainModal]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!coreReady || showKeychainModal) {
|
||||
startupInputReady.current = false;
|
||||
return;
|
||||
}
|
||||
if (startupInputReady.current) return;
|
||||
startupInputReady.current = true;
|
||||
const pendingInputs = pendingStartupInputs.current.splice(0);
|
||||
for (const input of pendingInputs) {
|
||||
if (input.type === 'extension') {
|
||||
useDownloadStore.getState().handleExtensionDownload(input.payload).catch(error => {
|
||||
console.error('Failed to handle queued browser extension download:', error);
|
||||
});
|
||||
} else {
|
||||
useDownloadStore.getState().openAddModalWithUrls(input.payload);
|
||||
}
|
||||
}
|
||||
}, [coreReady, showKeychainModal]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!coreReady || showKeychainModal || startupResumeStarted.current) return;
|
||||
@@ -439,7 +627,7 @@ function App() {
|
||||
useDownloadStore.getState().resumePendingDownloads().catch(error => {
|
||||
console.error('Failed to resume saved downloads after startup:', error);
|
||||
addToast({
|
||||
message: `Could not resume saved downloads: ${String(error)}`,
|
||||
message: t($ => $.app.resumeDownloadsFailed, { detail: String(error) }),
|
||||
variant: 'error',
|
||||
isActionable: true
|
||||
});
|
||||
@@ -462,12 +650,15 @@ function App() {
|
||||
invoke('check_for_updates')
|
||||
.then(result => {
|
||||
if (result.type !== 'UpdateAvailable') return;
|
||||
if (!isTrustedFirelinkReleaseUrl(result.update.release_url)) {
|
||||
throw new Error(t($ => $.settings.common.updateUntrustedReleaseUrl));
|
||||
}
|
||||
addToast({
|
||||
variant: 'info',
|
||||
isActionable: true,
|
||||
message: (
|
||||
<div className="flex items-center gap-3">
|
||||
<span>Firelink {result.update.version} is available.</span>
|
||||
<span>{t($ => $.app.updateAvailable, { version: result.update.version })}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="app-button px-2 py-1"
|
||||
@@ -475,7 +666,7 @@ function App() {
|
||||
void openUrl(result.update.release_url);
|
||||
}}
|
||||
>
|
||||
View release
|
||||
{t($ => $.app.viewRelease)}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
@@ -500,9 +691,9 @@ function App() {
|
||||
|
||||
useEffect(() => {
|
||||
if (platform.os === 'macos') {
|
||||
invoke('update_dock_badge', { count: showDockBadge ? activeDownloadCount : 0 }).catch(() => {});
|
||||
updateDockBadge(showDockBadge ? activeDownloadCount : 0).catch(() => {});
|
||||
}
|
||||
}, [platform.os, showDockBadge, activeDownloadCount]);
|
||||
}, [platform.os, showDockBadge, dockBadgeSyncVersion, activeDownloadCount]);
|
||||
|
||||
useEffect(() => {
|
||||
invoke('set_prevent_sleep', {
|
||||
@@ -510,7 +701,7 @@ function App() {
|
||||
}).catch(error => {
|
||||
console.error('Failed to update sleep prevention:', error);
|
||||
addToast({
|
||||
message: `Could not update sleep prevention: ${String(error)}`,
|
||||
message: t($ => $.app.sleepPreventionFailed, { detail: String(error) }),
|
||||
variant: 'error',
|
||||
isActionable: true
|
||||
});
|
||||
@@ -549,7 +740,7 @@ function App() {
|
||||
state.setSchedulerActiveDownloadIds([]);
|
||||
state.setSchedulerRunning(false);
|
||||
addToast({
|
||||
message: 'Scheduler has no valid queues selected. Update Scheduler settings.',
|
||||
message: t($ => $.app.schedulerNoQueues),
|
||||
variant: 'warning',
|
||||
isActionable: true
|
||||
});
|
||||
@@ -583,7 +774,9 @@ function App() {
|
||||
const failedPauses = pauseResults.filter(result => result.status === 'rejected').length;
|
||||
if (failedPauses > 0) {
|
||||
addToast({
|
||||
message: `Scheduler could not pause ${failedPauses} download${failedPauses === 1 ? '' : 's'}.`,
|
||||
message: failedPauses === 1
|
||||
? t($ => $.app.schedulerPauseOneFailed)
|
||||
: t($ => $.app.schedulerPauseManyFailed, { count: failedPauses }),
|
||||
variant: 'error',
|
||||
isActionable: true
|
||||
});
|
||||
@@ -604,6 +797,7 @@ function App() {
|
||||
}, [addToast, clearPendingPostActionTimer, coreReady]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!coreReady) return;
|
||||
if (!schedulerRunning) return;
|
||||
if (schedulerActiveDownloadIds.length === 0) return;
|
||||
clearPendingPostActionTimer();
|
||||
@@ -616,14 +810,14 @@ function App() {
|
||||
|
||||
if (completionState !== 'completed') {
|
||||
addToast({
|
||||
message: 'Scheduled downloads did not all complete. The post-queue system action was skipped.',
|
||||
message: t($ => $.app.scheduledIncomplete),
|
||||
variant: 'warning',
|
||||
isActionable: true
|
||||
});
|
||||
} else if (settings.scheduler.postQueueAction !== 'none') {
|
||||
if (downloads.some(download => isActiveDownloadStatus(download.status))) {
|
||||
addToast({
|
||||
message: 'Scheduled system action skipped because another download is active or queued.',
|
||||
message: t($ => $.app.scheduledActionSkippedActive),
|
||||
variant: 'warning',
|
||||
isActionable: true
|
||||
});
|
||||
@@ -634,6 +828,7 @@ function App() {
|
||||
}, [
|
||||
addToast,
|
||||
clearPendingPostActionTimer,
|
||||
coreReady,
|
||||
downloads,
|
||||
schedulePostQueueAction,
|
||||
schedulerRunning,
|
||||
@@ -649,7 +844,7 @@ function App() {
|
||||
const permission = await requestPermission();
|
||||
if (permission !== 'granted') {
|
||||
addToast({
|
||||
message: 'System notifications are disabled for Firelink.',
|
||||
message: t($ => $.app.notificationsDisabled),
|
||||
variant: 'warning',
|
||||
isActionable: true
|
||||
});
|
||||
@@ -657,7 +852,7 @@ function App() {
|
||||
}
|
||||
} catch (error) {
|
||||
addToast({
|
||||
message: `Could not configure notifications: ${String(error)}`,
|
||||
message: t($ => $.app.notificationsFailed, { detail: String(error) }),
|
||||
variant: 'error',
|
||||
isActionable: true
|
||||
});
|
||||
@@ -675,6 +870,7 @@ function App() {
|
||||
|
||||
useEffect(() => {
|
||||
const handlePaste = (e: ClipboardEvent) => {
|
||||
if (!coreReady || useSettingsStore.getState().showKeychainModal) return;
|
||||
if (
|
||||
e.target instanceof HTMLInputElement ||
|
||||
e.target instanceof HTMLTextAreaElement ||
|
||||
@@ -693,7 +889,85 @@ function App() {
|
||||
};
|
||||
window.addEventListener('paste', handlePaste);
|
||||
return () => window.removeEventListener('paste', handlePaste);
|
||||
}, []);
|
||||
}, [coreReady, showKeychainModal]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!coreReady || showKeychainModal || !autoAddClipboardLinks) return;
|
||||
|
||||
let active = true;
|
||||
let wasForeground = false;
|
||||
let readInFlight = false;
|
||||
let lastClipboardKey: string | null = null;
|
||||
|
||||
const isForeground = () =>
|
||||
document.visibilityState === 'visible' &&
|
||||
(typeof document.hasFocus !== 'function' || document.hasFocus());
|
||||
|
||||
const readClipboardOnForeground = async () => {
|
||||
if (!active || readInFlight || !isForeground()) return;
|
||||
|
||||
const storeBeforeRead = useDownloadStore.getState();
|
||||
const requestVersionBeforeRead = storeBeforeRead.pendingAddRequestVersion;
|
||||
readInFlight = true;
|
||||
try {
|
||||
const clipboardUrls = await readClipboardDownloadUrls();
|
||||
if (!active) return;
|
||||
|
||||
const currentSettings = useSettingsStore.getState();
|
||||
const currentStore = useDownloadStore.getState();
|
||||
// A user action or extension handoff won while the native clipboard
|
||||
// read was pending. Let that newer Add-modal request win unchanged.
|
||||
if (
|
||||
!currentSettings.autoAddClipboardLinks ||
|
||||
currentSettings.showKeychainModal ||
|
||||
currentStore.pendingAddRequestVersion !== requestVersionBeforeRead
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const clipboardKey = [...clipboardUrls].sort().join('\n');
|
||||
if (clipboardKey === lastClipboardKey) return;
|
||||
lastClipboardKey = clipboardKey;
|
||||
if (clipboardUrls.length === 0) return;
|
||||
|
||||
const existingUrls = new Set(extractValidDownloadUrls(currentStore.pendingAddUrls));
|
||||
const newUrls = clipboardUrls.filter(url => !existingUrls.has(url));
|
||||
if (newUrls.length > 0) {
|
||||
currentStore.openAddModalWithUrls(newUrls.join('\n'));
|
||||
}
|
||||
} catch (error) {
|
||||
// Clipboard permissions are optional and this feature is explicitly
|
||||
// opt-in, so a read failure should not interrupt normal app use.
|
||||
console.warn('Automatic clipboard capture failed:', error);
|
||||
} finally {
|
||||
readInFlight = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleForegroundChange = () => {
|
||||
const foreground = isForeground();
|
||||
if (!foreground) {
|
||||
wasForeground = false;
|
||||
return;
|
||||
}
|
||||
if (!wasForeground) {
|
||||
wasForeground = true;
|
||||
void readClipboardOnForeground();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('focus', handleForegroundChange);
|
||||
window.addEventListener('blur', handleForegroundChange);
|
||||
document.addEventListener('visibilitychange', handleForegroundChange);
|
||||
handleForegroundChange();
|
||||
|
||||
return () => {
|
||||
active = false;
|
||||
window.removeEventListener('focus', handleForegroundChange);
|
||||
window.removeEventListener('blur', handleForegroundChange);
|
||||
document.removeEventListener('visibilitychange', handleForegroundChange);
|
||||
};
|
||||
}, [autoAddClipboardLinks, coreReady, showKeychainModal]);
|
||||
|
||||
useEffect(() => {
|
||||
const root = window.document.documentElement;
|
||||
@@ -730,19 +1004,29 @@ function App() {
|
||||
|
||||
return (
|
||||
<div className={`app-shell flex h-screen w-screen overflow-hidden text-text-primary ${
|
||||
isSidebarOnRight ? 'app-shell--sidebar-right' : 'app-shell--sidebar-left'
|
||||
} ${
|
||||
hasWindowChrome ? 'app-shell--window-chrome' : ''
|
||||
}`}>
|
||||
{(platform.os === 'windows' || platform.os === 'linux') && <WindowControls />}
|
||||
{usesCustomWindowControls && (
|
||||
<WindowControls side={isSidebarOnRight ? 'right' : 'left'} />
|
||||
)}
|
||||
<div
|
||||
className={`app-sidebar-shell relative z-20 shrink-0 transition-all duration-300 ease-[cubic-bezier(0.2,0.8,0.2,1)] ${
|
||||
isSidebarOnRight ? 'app-sidebar-shell--right' : 'app-sidebar-shell--left'
|
||||
} ${
|
||||
isSidebarVisible ? 'opacity-100' : 'opacity-0 pointer-events-none'
|
||||
}`}
|
||||
style={{
|
||||
width: sidebarWidth,
|
||||
marginLeft: isSidebarVisible ? 0 : -sidebarWidth
|
||||
marginInlineStart: isSidebarVisible || isSidebarOnRight ? 0 : -sidebarWidth,
|
||||
marginInlineEnd: isSidebarVisible || !isSidebarOnRight ? 0 : -sidebarWidth
|
||||
}}
|
||||
>
|
||||
<div className="app-sidebar-panel h-full w-full">
|
||||
<div
|
||||
className="app-sidebar-panel h-full w-full"
|
||||
dir={isSidebarOnRight ? undefined : 'ltr'}
|
||||
>
|
||||
<Sidebar
|
||||
selectedFilter={filter}
|
||||
onSelectFilter={(f) => {
|
||||
@@ -754,12 +1038,14 @@ function App() {
|
||||
<div
|
||||
className="sidebar-resize-handle"
|
||||
onPointerDown={startSidebarResize}
|
||||
title="Resize Sidebar"
|
||||
title={t($ => $.actions.resizeSidebar)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`app-workspace relative z-0 flex-1 flex flex-col h-full overflow-hidden ${
|
||||
isSidebarOnRight ? 'app-workspace--sidebar-right' : 'app-workspace--sidebar-left'
|
||||
} ${
|
||||
!isSidebarVisible ? 'app-workspace--sidebar-collapsed' : ''
|
||||
} ${usesCustomWindowControls ? 'app-workspace--custom-window-controls' : ''}`}
|
||||
>
|
||||
@@ -768,35 +1054,39 @@ function App() {
|
||||
type="button"
|
||||
onClick={toggleSidebar}
|
||||
className="app-icon-button app-sidebar-reveal-button h-7 w-7"
|
||||
title="Show Sidebar"
|
||||
aria-label="Show Sidebar"
|
||||
title={t($ => $.actions.showSidebar)}
|
||||
aria-label={t($ => $.actions.showSidebar)}
|
||||
>
|
||||
<PanelLeft size={16} strokeWidth={2} />
|
||||
</button>
|
||||
)}
|
||||
<div className="flex-1 flex flex-col overflow-hidden relative">
|
||||
{activeView === 'downloads' && <DownloadTable filter={filter} />}
|
||||
{activeView === 'settings' && <SettingsView />}
|
||||
{activeView === 'scheduler' && <SchedulerView />}
|
||||
{activeView === 'speedLimiter' && <SpeedLimiterView />}
|
||||
{activeView === 'logs' && <LogsView />}
|
||||
<Suspense fallback={<PageLoadingFallback />}>
|
||||
{activeView === 'downloads' && <DownloadTable filter={filter} />}
|
||||
{activeView === 'settings' && <SettingsView />}
|
||||
{activeView === 'scheduler' && <SchedulerView />}
|
||||
{activeView === 'speedLimiter' && <SpeedLimiterView />}
|
||||
{activeView === 'logs' && <LogsView />}
|
||||
</Suspense>
|
||||
</div>
|
||||
|
||||
{/* Status Bar */}
|
||||
<div className="app-statusbar px-[14px] flex items-center justify-between text-text-muted shrink-0">
|
||||
<span>Ready</span>
|
||||
<span>{t($ => $.status.ready)}</span>
|
||||
<div className="flex gap-3 tabular-nums">
|
||||
<span>{activeDownloadCount} active</span>
|
||||
<span>{queuedCount} queued</span>
|
||||
<span>{doneCount} done</span>
|
||||
<span>{t($ => $.status.active, { count: activeDownloadCount })}</span>
|
||||
<span>{t($ => $.status.queued, { count: queuedCount })}</span>
|
||||
<span>{t($ => $.status.done, { count: doneCount })}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AddDownloadsModal />
|
||||
<PropertiesModal />
|
||||
<DeleteConfirmationModal />
|
||||
<KeychainPermissionModal appVersion={appVersion} />
|
||||
<Suspense fallback={null}>
|
||||
{isAddModalOpen && <AddDownloadsModal />}
|
||||
{selectedPropertiesDownloadId !== null && <PropertiesModal />}
|
||||
{isDeleteModalOpen && <DeleteConfirmationModal />}
|
||||
{showKeychainModal && <KeychainPermissionModal consentVersion={keychainConsentVersion} />}
|
||||
</Suspense>
|
||||
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -2,4 +2,4 @@
|
||||
import type { DownloadCategory } from "./DownloadCategory";
|
||||
import type { DownloadStatus } from "./DownloadStatus";
|
||||
|
||||
export type DownloadItem = { id: string, url: string, fileName: string, status: DownloadStatus, fraction?: number, speed?: string, eta?: string, size?: string, category: DownloadCategory, dateAdded: string, resumable?: boolean, connections?: number, speedLimit?: string, username?: string, password?: string, headers?: string, checksum?: string, cookies?: string, mirrors?: string, destination?: string, isMedia?: boolean, mediaFormatSelector?: string, queueId?: string, queuePosition?: number, hasBeenDispatched?: boolean, lastError?: string, lastTry?: string, };
|
||||
export type DownloadItem = { id: string, url: string, fileName: string, status: DownloadStatus, fraction?: number, speed?: string, eta?: string, size?: string, downloadedBytes?: number, totalBytes?: number, totalIsEstimate?: boolean, category: DownloadCategory, dateAdded: string, resumable?: boolean, connections?: number, speedLimit?: string, username?: string, password?: string, headers?: string, checksum?: string, cookies?: string, mirrors?: string, destination?: string, isMedia?: boolean, mediaFormatSelector?: string, queueId?: string, queuePosition?: number, hasBeenDispatched?: boolean, lastError?: string, lastTry?: string, };
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
export type DownloadProgressEvent = { id: string, fraction: number, speed: string, eta: string, size: string | null, size_is_final: boolean, };
|
||||
export type DownloadProgressEvent = { id: string, fraction: number, speed: string, eta: string, size: string | null, size_is_final: boolean, downloaded_bytes?: number, total_bytes?: number, total_is_estimate?: boolean, active_connections?: number, requested_connections?: number, };
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
export type ExtensionCookieScope = { url: string, cookies: string, };
|
||||
@@ -1,3 +1,4 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
import type { ExtensionCookieScope } from "./ExtensionCookieScope";
|
||||
|
||||
export type ExtensionDownload = { urls: Array<string>, referer: string | null, silent: boolean, filename: string | null, headers: string | null, cookies: string | null, media: boolean, };
|
||||
export type ExtensionDownload = { request_id?: string, urls: Array<string>, referer: string | null, silent: boolean, filename: string | null, headers: string | null, cookies: string | null, cookie_scopes: Array<ExtensionCookieScope> | null, media: boolean, batch: boolean, batch_name: string | null, };
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
export type MediaPlaylistEntry = { id: string, url: string, title: string, playlist_index: number | null, };
|
||||
@@ -0,0 +1,4 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
import type { MediaPlaylistEntry } from "./MediaPlaylistEntry";
|
||||
|
||||
export type MediaPlaylistMetadata = { title: string, playlist_id: string | null, entry_count: number | null, skipped_entries: number, truncated: boolean, entries: Array<MediaPlaylistEntry>, };
|
||||
@@ -8,4 +8,4 @@ import type { SettingsTab } from "./SettingsTab";
|
||||
import type { SiteLogin } from "./SiteLogin";
|
||||
import type { Theme } from "./Theme";
|
||||
|
||||
export type PersistedSettings = { theme: Theme, baseDownloadFolder: string, categorySubfoldersEnabled: boolean, categorySubfolders: { [key in string]: string }, categoryDirectoryOverrides: { [key in string]: string }, approvedDownloadRoots: Array<string>, maxConcurrentDownloads: number, globalSpeedLimit: string, speedLimitPresetValues: Array<number>, logsEnabled: boolean, isSidebarVisible: boolean, activeSettingsTab: SettingsTab, scheduler: SchedulerSettings, schedulerRunning: boolean, schedulerActiveDownloadIds: Array<string>, schedulerLastStartKey: string, schedulerLastStopKey: string, lastCustomSpeedLimitKiB: number, lastCustomSpeedLimitUnit: string, perServerConnections: number, maxAutomaticRetries: number, showNotifications: boolean, playCompletionSound: boolean, appFontSize: AppFontSize, listRowDensity: ListRowDensity, showDockBadge: boolean, showMenuBarIcon: boolean, proxyMode: ProxyMode, proxyHost: string, proxyPort: number, customUserAgent: string, askWhereToSaveEachFile: boolean, preventsSleepWhileDownloading: boolean, mediaCookieSource: MediaCookieSource, siteLogins: Array<SiteLogin>, autoCheckUpdates: boolean, keychainAccessGranted: boolean, };
|
||||
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, };
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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);
|
||||
@@ -50,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;
|
||||
}
|
||||
@@ -60,6 +66,7 @@ export const DeleteConfirmationModal: React.FC = () => {
|
||||
|
||||
const handleRemoveFromList = () => removeMany(false);
|
||||
const handleDeleteFile = () => removeMany(true);
|
||||
const itemCount = deleteModalState.downloadIds?.length ?? 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -78,11 +85,13 @@ export const DeleteConfirmationModal: React.FC = () => {
|
||||
<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>
|
||||
|
||||
@@ -90,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>
|
||||
|
||||
+309
-138
@@ -1,54 +1,135 @@
|
||||
import React from 'react';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
import { useDownloadStore } from '../store/useDownloadStore';
|
||||
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 liveProgress = useDownloadProgressStore(state => state.progressMap[downloadId]);
|
||||
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;
|
||||
|
||||
if (!download) return null;
|
||||
const updateActionPosition = React.useCallback(() => {
|
||||
const row = rowRef.current;
|
||||
const view = row?.closest<HTMLElement>('.downloads-view');
|
||||
if (!row || !view) return;
|
||||
|
||||
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',
|
||||
};
|
||||
|
||||
setActionPosition(previous => (
|
||||
previous?.top === nextPosition.top &&
|
||||
previous?.right === nextPosition.right &&
|
||||
previous?.height === nextPosition.height &&
|
||||
previous?.overflow === nextPosition.overflow &&
|
||||
previous?.visibility === nextPosition.visibility
|
||||
? previous
|
||||
: nextPosition
|
||||
));
|
||||
}, []);
|
||||
|
||||
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 () => {
|
||||
window.removeEventListener('resize', schedulePositionUpdate);
|
||||
window.removeEventListener('scroll', schedulePositionUpdate, true);
|
||||
resizeObserver?.disconnect();
|
||||
if (frame) window.cancelAnimationFrame(frame);
|
||||
};
|
||||
}, [isActionVisible, updateActionPosition]);
|
||||
|
||||
const displayFraction = download.status === 'downloading'
|
||||
? liveProgress?.fraction ?? download.fraction ?? 0
|
||||
@@ -57,48 +138,99 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
|
||||
const displaySpeed = download.status === 'downloading'
|
||||
? liveProgress?.speed ?? download.speed
|
||||
: download.status === 'processing'
|
||||
? 'Processing...'
|
||||
? t($ => $.downloads.values.processing)
|
||||
: '-';
|
||||
const displayEta = download.status === 'downloading'
|
||||
? liveProgress?.eta ?? download.eta
|
||||
: download.status === 'processing'
|
||||
? 'Muxing...'
|
||||
? 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
|
||||
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' : ''
|
||||
@@ -106,24 +238,23 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
|
||||
style={{ width: `${displayFraction * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span
|
||||
key={`status-${download.status}`}
|
||||
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'
|
||||
<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' : ''
|
||||
}`}
|
||||
@@ -132,96 +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' ? (
|
||||
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}`}
|
||||
className="tabular-nums"
|
||||
title={displaySpeed}
|
||||
>
|
||||
),
|
||||
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}`}
|
||||
className="tabular-nums"
|
||||
title={displayEta}
|
||||
>
|
||||
),
|
||||
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"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onDoubleClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{(download.status === 'queued' || download.status === 'staged') && queueIndex !== -1 && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => moveInQueue(selectedIds.has(download.id) ? Array.from(selectedIds) : download.id, 'up')}
|
||||
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>
|
||||
);
|
||||
});
|
||||
|
||||
+816
-129
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,5 @@
|
||||
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,6 +20,7 @@ interface Props {
|
||||
}
|
||||
|
||||
export const DuplicateResolutionModal = ({ conflicts: initialConflicts, onConfirm, onCancel }: Props) => {
|
||||
const { t } = useTranslation();
|
||||
const [conflicts, setConflicts] = useState<DuplicateConflict[]>(initialConflicts);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -43,8 +46,8 @@ export const DuplicateResolutionModal = ({ conflicts: initialConflicts, onConfir
|
||||
>
|
||||
<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">
|
||||
@@ -59,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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1,29 +1,42 @@
|
||||
import React, { useEffect, 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';
|
||||
|
||||
const KEYCHAIN_GRANT_TIMEOUT_MS = 30_000;
|
||||
|
||||
type KeychainPermissionModalProps = {
|
||||
appVersion: string;
|
||||
consentVersion: string;
|
||||
};
|
||||
|
||||
export const KeychainPermissionModal: React.FC<KeychainPermissionModalProps> = ({ appVersion }) => {
|
||||
export const KeychainPermissionModal: React.FC<KeychainPermissionModalProps> = ({ consentVersion }) => {
|
||||
const { t } = useTranslation();
|
||||
const showKeychainModal = useSettingsStore(state => state.showKeychainModal);
|
||||
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) return;
|
||||
if (!showKeychainModal || isGranting || grantRequestPending) return;
|
||||
const handleEscape = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') dismissKeychainPrompt(appVersion);
|
||||
if (event.key !== 'Escape') return;
|
||||
if (consentVersion.trim()) {
|
||||
dismissKeychainPrompt(consentVersion);
|
||||
} else {
|
||||
useSettingsStore.getState().setShowKeychainModal(false);
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', handleEscape);
|
||||
return () => window.removeEventListener('keydown', handleEscape);
|
||||
}, [appVersion, dismissKeychainPrompt, isGranting, showKeychainModal]);
|
||||
}, [consentVersion, dismissKeychainPrompt, grantRequestPending, isGranting, showKeychainModal]);
|
||||
|
||||
if (!showKeychainModal) {
|
||||
return null;
|
||||
@@ -32,61 +45,106 @@ export const KeychainPermissionModal: React.FC<KeychainPermissionModalProps> = (
|
||||
const isMac = platform.os === 'macos';
|
||||
const pairingStoreName =
|
||||
platform.portable
|
||||
? 'the portable Firelink data folder'
|
||||
? t($ => $.keychain.stores.portable)
|
||||
: platform.os === 'windows'
|
||||
? 'Windows Credential Manager'
|
||||
? 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";
|
||||
? t($ => $.keychain.stores.macos)
|
||||
: t($ => $.keychain.stores.system);
|
||||
const siteCredentialStoreName = platform.portable
|
||||
? "the system's credential store"
|
||||
? t($ => $.keychain.stores.siteCredentials)
|
||||
: pairingStoreName;
|
||||
const grantLabel = platform.portable
|
||||
? 'Continue'
|
||||
? t($ => $.keychain.grantLabelPortable)
|
||||
: isMac
|
||||
? 'Grant Access'
|
||||
: 'Enable Secure Storage';
|
||||
? 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) {
|
||||
const grantedVersion = appVersion || await getVersion().catch(() => '');
|
||||
// Keep state in sync with the grant result instead of rehydrating
|
||||
// before Zustand has persisted keychainAccessGranted.
|
||||
useSettingsStore.setState({
|
||||
keychainAccessGranted: true,
|
||||
keychainAccessVersion: grantedVersion,
|
||||
keychainAccessReady: true,
|
||||
extensionPairingToken: result.token,
|
||||
isPairingTokenPersistent: true,
|
||||
keychainPromptDismissed: false,
|
||||
showKeychainModal: false
|
||||
});
|
||||
} else {
|
||||
setError(result.error || `${siteCredentialStoreName} 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 = () => {
|
||||
dismissKeychainPrompt(appVersion);
|
||||
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="app-modal-backdrop fixed inset-0 z-50 flex items-center justify-center bg-black/40"
|
||||
onClick={(event) => {
|
||||
if (event.target === event.currentTarget && !isGranting) handleLater();
|
||||
if (event.target === event.currentTarget && !isGranting && !grantRequestPending) handleLater();
|
||||
}}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
@@ -99,28 +157,27 @@ export const KeychainPermissionModal: React.FC<KeychainPermissionModalProps> = (
|
||||
<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 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 {pairingStoreName}. Optional site credentials are stored in {siteCredentialStoreName}.
|
||||
{t($ => $.keychain.description, { pairingStore: pairingStoreName, siteCredentialStore: siteCredentialStoreName })}
|
||||
</p>
|
||||
|
||||
<p>
|
||||
{platform.portable
|
||||
? 'The pairing token is portable with this folder. Site credentials remain in the system credential store; a system prompt may appear after you grant access.'
|
||||
? t($ => $.keychain.portableExplanation)
|
||||
: 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.'}
|
||||
? t($ => $.keychain.macosExplanation)
|
||||
: t($ => $.keychain.defaultExplanation)}
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<strong>Note:</strong>{' '}
|
||||
<strong>{t($ => $.keychain.note)}</strong>{' '}
|
||||
{platform.portable
|
||||
? 'Portable mode stores only the pairing token in this folder. It does not copy site passwords or browser credentials.'
|
||||
: 'Firelink only writes its own dedicated credential entry. It cannot access other saved passwords or credential items on your system.'}
|
||||
? t($ => $.keychain.portableNote)
|
||||
: t($ => $.keychain.defaultNote)}
|
||||
</p>
|
||||
|
||||
{error && (
|
||||
@@ -131,28 +188,28 @@ export const KeychainPermissionModal: React.FC<KeychainPermissionModalProps> = (
|
||||
)}
|
||||
|
||||
<div className="bg-bg-modal-accent p-3 rounded-lg border border-border-modal text-xs">
|
||||
<strong>Hint:</strong>{' '}
|
||||
<strong>{t($ => $.keychain.hint)}</strong>{' '}
|
||||
{platform.portable
|
||||
? 'The portable pairing token is already stored with this folder; you can enable it here or select Later.'
|
||||
: 'If you select Later, the extension will only work for this session.'}
|
||||
You can enable storage anytime from <strong>Settings > Integrations</strong>.
|
||||
? 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>
|
||||
|
||||
+46
-30
@@ -1,11 +1,13 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { invokeCommand as invoke } from '../ipc';
|
||||
import { save } from '@tauri-apps/plugin-dialog';
|
||||
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';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
MAX_LOG_LINES,
|
||||
appendBoundedLogEntries,
|
||||
@@ -17,6 +19,7 @@ import {
|
||||
} 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);
|
||||
@@ -24,6 +27,7 @@ export default function LogsView() {
|
||||
const [levelFilter, setLevelFilter] = useState<LogEntry['level'] | 'All'>('All');
|
||||
const [contextMenu, setContextMenu] = useState<{ x: number; y: number; text: string } | null>(null);
|
||||
const [pageVisible, setPageVisible] = useState(() => document.visibilityState !== 'hidden');
|
||||
const homeDirectoryRef = useRef('');
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const liveBatchRef = useRef<LogEntry[]>([]);
|
||||
const liveFrameRef = useRef<number | null>(null);
|
||||
@@ -33,6 +37,18 @@ export default function LogsView() {
|
||||
const [isClearing, setIsClearing] = useState(false);
|
||||
const [isToggling, setIsToggling] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
void homeDir()
|
||||
.then(directory => {
|
||||
if (active) homeDirectoryRef.current = directory;
|
||||
})
|
||||
.catch(() => undefined);
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const handleVisibilityChange = () => setPageVisible(document.visibilityState !== 'hidden');
|
||||
document.addEventListener('visibilitychange', handleVisibilityChange);
|
||||
@@ -80,7 +96,7 @@ export default function LogsView() {
|
||||
if (logsEnabled) {
|
||||
unlistenPromise = attachLogger((log) => {
|
||||
if (!active) return;
|
||||
scheduleLiveEntry(liveLogEntry(log.level, log.message));
|
||||
scheduleLiveEntry(liveLogEntry(log.level, log.message, new Date(), homeDirectoryRef.current));
|
||||
});
|
||||
await unlistenPromise;
|
||||
if (!active) return;
|
||||
@@ -93,7 +109,7 @@ export default function LogsView() {
|
||||
|
||||
const lines = await invoke('read_logs', { limit: MAX_LOG_LINES });
|
||||
if (!active) return;
|
||||
const snapshot = lines.map(persistedLogEntry);
|
||||
const snapshot = lines.map(line => persistedLogEntry(line, homeDirectoryRef.current));
|
||||
initialized = true;
|
||||
if (initGeneration !== clearGenerationRef.current) {
|
||||
pendingLiveEntries = [];
|
||||
@@ -155,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);
|
||||
@@ -168,14 +184,14 @@ 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;
|
||||
await invoke('export_logs', { destination: path });
|
||||
addToast({ message: 'Support logs exported', variant: 'success' });
|
||||
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 });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -194,10 +210,10 @@ export default function LogsView() {
|
||||
liveFrameRef.current = null;
|
||||
}
|
||||
setLogs([]);
|
||||
addToast({ message: 'Logs cleared', variant: 'info' });
|
||||
addToast({ message: t($ => $.logs.cleared), variant: 'info' });
|
||||
} catch (error) {
|
||||
addToast({
|
||||
message: `Could not clear logs: ${String(error)}`,
|
||||
message: t($ => $.logs.clearFailed, { detail: String(error) }),
|
||||
variant: 'error',
|
||||
isActionable: true
|
||||
});
|
||||
@@ -217,7 +233,7 @@ export default function LogsView() {
|
||||
await setLogPaused(!nextEnabled);
|
||||
setLogsEnabled(nextEnabled);
|
||||
addToast({
|
||||
message: nextEnabled ? 'Diagnostic logging enabled' : 'Diagnostic logging disabled',
|
||||
message: nextEnabled ? t($ => $.logs.enabled) : t($ => $.logs.disabled),
|
||||
variant: 'success'
|
||||
});
|
||||
})();
|
||||
@@ -227,7 +243,7 @@ export default function LogsView() {
|
||||
await toggleOperation;
|
||||
} catch (error) {
|
||||
addToast({
|
||||
message: `Could not update diagnostic logging: ${String(error)}`,
|
||||
message: t($ => $.logs.updateFailed, { detail: String(error) }),
|
||||
variant: 'error',
|
||||
isActionable: true
|
||||
});
|
||||
@@ -256,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">
|
||||
@@ -272,12 +288,12 @@ 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" />
|
||||
@@ -285,7 +301,7 @@ export default function LogsView() {
|
||||
onClick={handleToggleLogging}
|
||||
disabled={isToggling}
|
||||
className={`app-icon-button disabled:cursor-not-allowed disabled:opacity-50 ${logsEnabled ? 'text-accent' : ''}`}
|
||||
title={logsEnabled ? "Pause diagnostic logging" : "Enable diagnostic logging"}
|
||||
title={logsEnabled ? t($ => $.logs.pauseLogging) : t($ => $.logs.enableLogging)}
|
||||
>
|
||||
{logsEnabled ? <Pause size={14} /> : <Play size={14} />}
|
||||
</button>
|
||||
@@ -293,17 +309,17 @@ export default function LogsView() {
|
||||
onClick={handleClear}
|
||||
disabled={isClearing}
|
||||
className="app-icon-button disabled:cursor-not-allowed disabled:opacity-50"
|
||||
title="Clear displayed logs"
|
||||
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>
|
||||
@@ -312,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>
|
||||
|
||||
@@ -326,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) => (
|
||||
@@ -346,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>
|
||||
)}
|
||||
|
||||
@@ -10,6 +10,13 @@ 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';
|
||||
|
||||
@@ -22,6 +29,18 @@ const formatLastTry = (value?: string): string => {
|
||||
};
|
||||
|
||||
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 =>
|
||||
@@ -41,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
|
||||
@@ -74,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);
|
||||
@@ -112,7 +133,15 @@ 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;
|
||||
@@ -143,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;
|
||||
}
|
||||
|
||||
@@ -155,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,
|
||||
@@ -163,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 {
|
||||
@@ -176,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;
|
||||
@@ -185,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;
|
||||
@@ -211,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>
|
||||
|
||||
@@ -220,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">{formatLastTry(item.lastTry)}</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>
|
||||
)}
|
||||
@@ -251,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">
|
||||
@@ -300,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">
|
||||
@@ -311,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>
|
||||
@@ -319,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>
|
||||
@@ -341,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>
|
||||
@@ -362,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>
|
||||
@@ -395,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}
|
||||
@@ -405,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>
|
||||
|
||||
@@ -10,22 +10,23 @@ 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) => {
|
||||
@@ -33,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();
|
||||
@@ -45,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);
|
||||
@@ -75,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]
|
||||
@@ -116,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 = {
|
||||
@@ -134,7 +141,7 @@ 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 () => {
|
||||
@@ -155,9 +162,14 @@ export default function SchedulerView() {
|
||||
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' });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -168,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) => {
|
||||
@@ -178,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]);
|
||||
@@ -222,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));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -253,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>
|
||||
@@ -282,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">
|
||||
@@ -320,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>
|
||||
);
|
||||
})}
|
||||
@@ -330,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 => {
|
||||
@@ -346,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>
|
||||
);
|
||||
@@ -356,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;
|
||||
@@ -368,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>
|
||||
@@ -382,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>}
|
||||
@@ -410,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>
|
||||
)}
|
||||
|
||||
+464
-231
File diff suppressed because it is too large
Load Diff
+191
-77
@@ -11,6 +11,7 @@ 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;
|
||||
|
||||
@@ -24,6 +25,7 @@ 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('');
|
||||
@@ -38,6 +40,14 @@ export const Sidebar: React.FC<SidebarProps> = (props) => {
|
||||
|
||||
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);
|
||||
@@ -70,6 +80,13 @@ export const Sidebar: React.FC<SidebarProps> = (props) => {
|
||||
}
|
||||
}, [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;
|
||||
@@ -78,15 +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) {
|
||||
if (!window.confirm(`Delete queue "${q.name}"? Its unfinished downloads will move to Main Queue.`)) {
|
||||
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 });
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -95,7 +116,7 @@ export const Sidebar: React.FC<SidebarProps> = (props) => {
|
||||
};
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [addToast, activeView, queues, removeQueue, selectedFilter]);
|
||||
}, [addToast, activeView, queues, removeQueue]);
|
||||
|
||||
const getCount = (filter: SidebarFilter) => {
|
||||
if (filter.startsWith('queue:')) {
|
||||
@@ -118,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>
|
||||
)}
|
||||
@@ -138,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);
|
||||
};
|
||||
|
||||
@@ -158,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>
|
||||
);
|
||||
@@ -180,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>
|
||||
)}
|
||||
@@ -202,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>
|
||||
);
|
||||
};
|
||||
@@ -219,18 +314,18 @@ 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">
|
||||
@@ -240,9 +335,9 @@ export const Sidebar: React.FC<SidebarProps> = (props) => {
|
||||
className="sidebar-section-label sidebar-section-label-toggle"
|
||||
aria-expanded={!foldersCollapsed}
|
||||
aria-controls="sidebar-folders-list"
|
||||
onClick={() => setFoldersCollapsed(collapsed => !collapsed)}
|
||||
onClick={handleFoldersToggle}
|
||||
>
|
||||
<span>Folders</span>
|
||||
<span>{t($ => $.navigation.folders)}</span>
|
||||
<ChevronDown
|
||||
aria-hidden="true"
|
||||
size={13}
|
||||
@@ -257,56 +352,70 @@ export const Sidebar: React.FC<SidebarProps> = (props) => {
|
||||
inert={foldersCollapsed}
|
||||
>
|
||||
<div className="sidebar-collapse-content">
|
||||
<NavItem icon={Music} label="Musics" filter="Musics" />
|
||||
<NavItem icon={Film} label="Movies" filter="Movies" />
|
||||
<NavItem icon={Archive} label="Compressed" filter="Compressed" />
|
||||
<NavItem icon={FileText} label="Documents" filter="Documents" />
|
||||
<NavItem icon={ImageIcon} label="Pictures" filter="Pictures" />
|
||||
<NavItem icon={Box} label="Applications" filter="Applications" />
|
||||
<NavItem icon={FileQuestion} label="Other" filter="Other" />
|
||||
<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>
|
||||
|
||||
@@ -315,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>
|
||||
|
||||
@@ -333,57 +442,62 @@ 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"
|
||||
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: `Could not start queue: ${String(error)}`,
|
||||
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"
|
||||
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: `Could not pause queue: ${String(error)}`,
|
||||
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);
|
||||
@@ -391,21 +505,21 @@ export const Sidebar: React.FC<SidebarProps> = (props) => {
|
||||
setContextMenu(null);
|
||||
return;
|
||||
}
|
||||
if (!window.confirm(`Delete queue "${queue.name}"? Its unfinished downloads will move to Main Queue.`)) {
|
||||
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>
|
||||
|
||||
@@ -3,6 +3,7 @@ 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';
|
||||
|
||||
@@ -89,6 +90,7 @@ export function formatPresetValue(value: number): string {
|
||||
}
|
||||
|
||||
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);
|
||||
@@ -132,12 +134,14 @@ export default function SpeedLimiterView() {
|
||||
setLastCustomSpeedLimitKiB(valueKiB);
|
||||
setLastCustomSpeedLimitUnit(unit);
|
||||
addToast({
|
||||
message: enabled ? `Global limit saved at ${numericValue} ${unit}` : 'Global speed limit disabled',
|
||||
message: enabled
|
||||
? t($ => $.speedLimiter.globalLimitSaved, { value: numericValue, unit })
|
||||
: t($ => $.speedLimiter.globalLimitDisabled),
|
||||
variant: 'success'
|
||||
});
|
||||
} catch (error) {
|
||||
addToast({
|
||||
message: `Could not save global speed limit: ${String(error)}`,
|
||||
message: t($ => $.speedLimiter.saveFailed, { detail: String(error) }),
|
||||
variant: 'error',
|
||||
isActionable: true
|
||||
});
|
||||
@@ -163,8 +167,8 @@ export default function SpeedLimiterView() {
|
||||
setValue(String(storedPresetDisplayValue));
|
||||
addToast({
|
||||
message: alreadyExists
|
||||
? `${formatPresetValue(storedPresetDisplayValue)} ${unit} is already in quick presets`
|
||||
: `Added ${formatPresetValue(storedPresetDisplayValue)} ${unit} quick preset`,
|
||||
? t($ => $.speedLimiter.presetAlreadyExists, { value: formatPresetValue(storedPresetDisplayValue), unit })
|
||||
: t($ => $.speedLimiter.presetAdded, { value: formatPresetValue(storedPresetDisplayValue), unit }),
|
||||
variant: alreadyExists ? 'info' : 'success'
|
||||
});
|
||||
};
|
||||
@@ -174,7 +178,7 @@ 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'
|
||||
});
|
||||
};
|
||||
@@ -207,25 +211,25 @@ 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 ? `${currentDisplayValue} ${unit}` : 'Unlimited'}
|
||||
{enabled ? `${currentDisplayValue} ${unit}` : t($ => $.speedLimiter.unlimited)}
|
||||
</span>
|
||||
<button onClick={() => void save()} disabled={isSaving} className="app-button app-button-primary ml-auto px-3 text-[11px] disabled:opacity-50">
|
||||
<Save size={14} /> Save Limit
|
||||
<button 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 new yt-dlp media downloads. Explicit per-download limits remain attached to those transfers.
|
||||
{t($ => $.speedLimiter.description)}
|
||||
</p>
|
||||
|
||||
<div className="mt-6 flex items-center gap-3">
|
||||
@@ -257,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 => {
|
||||
@@ -271,7 +275,7 @@ export default function SpeedLimiterView() {
|
||||
type="button"
|
||||
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>
|
||||
@@ -279,16 +283,16 @@ export default function SpeedLimiterView() {
|
||||
type="button"
|
||||
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={speedValueFromKiB(1, unit)}
|
||||
@@ -297,7 +301,7 @@ export default function SpeedLimiterView() {
|
||||
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
|
||||
@@ -305,8 +309,8 @@ export default function SpeedLimiterView() {
|
||||
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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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));
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
Vendored
+11
@@ -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;
|
||||
}
|
||||
}
|
||||
+574
-61
@@ -1,4 +1,6 @@
|
||||
@import "tailwindcss";
|
||||
@import "tailwindcss" source(none);
|
||||
@source "../src";
|
||||
@source "../index.html";
|
||||
|
||||
:root {
|
||||
/* Default/fallback Light (macOS Light Mode) */
|
||||
@@ -233,7 +235,9 @@
|
||||
}
|
||||
|
||||
body.is-resizing,
|
||||
body.is-resizing * {
|
||||
body.is-resizing *,
|
||||
body.is-column-resizing,
|
||||
body.is-column-resizing * {
|
||||
cursor: col-resize !important;
|
||||
user-select: none !important;
|
||||
}
|
||||
@@ -439,7 +443,7 @@ html[data-list-density="relaxed"] {
|
||||
color: hsl(var(--text-primary));
|
||||
font-weight: 500;
|
||||
font-size: 13px;
|
||||
transition: background-color 100ms ease, transform 100ms ease;
|
||||
transition: background-color 100ms ease, border-color 100ms ease, box-shadow 100ms ease, transform 100ms ease;
|
||||
box-shadow: 0 1px 1px hsl(var(--shadow-color));
|
||||
}
|
||||
|
||||
@@ -545,6 +549,8 @@ html[data-list-density="relaxed"] {
|
||||
.add-download-left {
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
border-inline-end: 1px solid hsl(var(--border-modal));
|
||||
background:
|
||||
radial-gradient(circle at 18% 0%, hsl(var(--accent-color) / 0.055), transparent 34%),
|
||||
hsl(var(--main-bg) / 0.56);
|
||||
@@ -616,6 +622,13 @@ html[data-list-density="relaxed"] {
|
||||
.add-download-links-input {
|
||||
border-radius: 10px;
|
||||
line-height: 1.55;
|
||||
direction: ltr;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.add-download-links-input--rtl:placeholder-shown {
|
||||
direction: rtl;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.add-download-link-button {
|
||||
@@ -643,6 +656,8 @@ html[data-list-density="relaxed"] {
|
||||
}
|
||||
|
||||
.add-download-preview {
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
border: 1px solid hsl(var(--border-modal));
|
||||
border-radius: 11px;
|
||||
background: hsl(var(--bg-input) / 0.35);
|
||||
@@ -650,11 +665,13 @@ html[data-list-density="relaxed"] {
|
||||
}
|
||||
|
||||
.add-download-preview-header {
|
||||
flex-shrink: 0;
|
||||
border-bottom: 1px solid hsl(var(--border-modal));
|
||||
background: hsl(var(--surface-raised) / 0.66);
|
||||
}
|
||||
|
||||
.add-download-preview-row {
|
||||
flex-shrink: 0;
|
||||
transition:
|
||||
background-color 100ms ease,
|
||||
border-color 100ms ease,
|
||||
@@ -836,15 +853,6 @@ html[data-list-density="relaxed"] {
|
||||
hsl(var(--bg-input));
|
||||
}
|
||||
|
||||
.add-download-button-cancel {
|
||||
color: hsl(var(--text-secondary));
|
||||
}
|
||||
|
||||
.add-download-button-cancel:hover:not(:disabled) {
|
||||
background: hsl(var(--item-hover));
|
||||
color: hsl(var(--text-primary));
|
||||
}
|
||||
|
||||
.add-download-button-primary {
|
||||
border-color: hsl(var(--accent-color) / 0.86);
|
||||
background:
|
||||
@@ -1001,18 +1009,34 @@ html[data-list-density="relaxed"] {
|
||||
}
|
||||
|
||||
.app-shell {
|
||||
--window-corner-radius: 18px;
|
||||
direction: ltr;
|
||||
background: hsl(var(--main-bg));
|
||||
border: 1px solid hsl(var(--border-color));
|
||||
border-radius: var(--window-corner-radius);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.app-sidebar-shell {
|
||||
padding: 9px 0 9px 9px;
|
||||
padding-block: 10px;
|
||||
padding-inline-start: 10px;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.app-sidebar-shell--left {
|
||||
order: 0;
|
||||
}
|
||||
|
||||
.app-sidebar-shell--right {
|
||||
order: 2;
|
||||
padding-inline-start: 0;
|
||||
padding-inline-end: 10px;
|
||||
}
|
||||
|
||||
.sidebar-resize-handle {
|
||||
position: absolute;
|
||||
top: 18px;
|
||||
right: -4px;
|
||||
inset-inline-end: -4px;
|
||||
bottom: 18px;
|
||||
z-index: 60;
|
||||
width: 8px;
|
||||
@@ -1024,13 +1048,23 @@ html[data-list-density="relaxed"] {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 3px;
|
||||
inset-inline-end: 3px;
|
||||
bottom: 0;
|
||||
width: 1px;
|
||||
background: transparent;
|
||||
transition: background-color 100ms ease;
|
||||
}
|
||||
|
||||
.app-sidebar-shell--right .sidebar-resize-handle {
|
||||
inset-inline-start: -4px;
|
||||
inset-inline-end: auto;
|
||||
}
|
||||
|
||||
.app-sidebar-shell--right .sidebar-resize-handle::after {
|
||||
inset-inline-start: 3px;
|
||||
inset-inline-end: auto;
|
||||
}
|
||||
|
||||
.sidebar-resize-handle:hover::after,
|
||||
body.is-resizing .sidebar-resize-handle::after {
|
||||
background: hsl(var(--accent-color) / 0.55);
|
||||
@@ -1045,10 +1079,7 @@ html[data-list-density="relaxed"] {
|
||||
background: hsl(var(--sidebar-panel-bg));
|
||||
border: 1px solid hsl(var(--sidebar-border));
|
||||
|
||||
border-top-left-radius: 18px;
|
||||
border-bottom-left-radius: 18px;
|
||||
border-top-right-radius: 17px;
|
||||
border-bottom-right-radius: 17px;
|
||||
border-radius: var(--window-corner-radius);
|
||||
|
||||
box-shadow:
|
||||
inset 0 1px 0 hsl(0 0% 100% / 0.045),
|
||||
@@ -1057,13 +1088,21 @@ html[data-list-density="relaxed"] {
|
||||
|
||||
|
||||
.app-workspace {
|
||||
order: 1;
|
||||
direction: ltr;
|
||||
background: hsl(var(--workspace-bg));
|
||||
}
|
||||
|
||||
html[dir="rtl"] .app-sidebar-panel,
|
||||
html[dir="rtl"] .app-workspace {
|
||||
direction: rtl;
|
||||
}
|
||||
|
||||
.app-sidebar-reveal-button {
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
left: 88px;
|
||||
right: auto;
|
||||
z-index: 80;
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
@@ -1072,6 +1111,25 @@ html[data-list-density="relaxed"] {
|
||||
left: 124px;
|
||||
}
|
||||
|
||||
.app-workspace--sidebar-right .app-sidebar-reveal-button {
|
||||
left: auto;
|
||||
right: 88px;
|
||||
}
|
||||
|
||||
.app-workspace--sidebar-right.app-workspace--custom-window-controls .app-sidebar-reveal-button {
|
||||
right: 124px;
|
||||
}
|
||||
|
||||
.app-sidebar-shell--left .sidebar-nav-item {
|
||||
direction: ltr;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.app-sidebar-shell--right .sidebar-nav-item {
|
||||
direction: rtl;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.app-statusbar {
|
||||
height: 26px;
|
||||
font-size: 10px;
|
||||
@@ -1089,6 +1147,11 @@ html[data-list-density="relaxed"] {
|
||||
transition: background-color 100ms ease;
|
||||
}
|
||||
|
||||
.sidebar-nav-label {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.sidebar-nav-item[data-active="true"] {
|
||||
background: hsl(var(--accent-color)) !important;
|
||||
color: white !important;
|
||||
@@ -1242,7 +1305,7 @@ html[data-list-density="relaxed"] {
|
||||
.sidebar-toggle-button {
|
||||
position: absolute;
|
||||
top: 11px;
|
||||
right: 12px;
|
||||
inset-inline-end: 12px;
|
||||
z-index: 50;
|
||||
|
||||
width: 24px;
|
||||
@@ -1274,6 +1337,11 @@ html[data-list-density="relaxed"] {
|
||||
|
||||
.settings-tab-strip {
|
||||
padding: 16px 32px;
|
||||
direction: ltr;
|
||||
}
|
||||
|
||||
.settings-tab-strip--sidebar-right {
|
||||
flex-direction: row-reverse;
|
||||
}
|
||||
|
||||
.settings-tab-button {
|
||||
@@ -1447,7 +1515,7 @@ html[data-list-density="relaxed"] {
|
||||
background: transparent;
|
||||
color: hsl(var(--text-primary));
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
text-align: start;
|
||||
}
|
||||
|
||||
.settings-combobox-option:last-child {
|
||||
@@ -1574,14 +1642,20 @@ html[data-list-density="relaxed"] {
|
||||
.mac-switch:checked::after {
|
||||
transform: translateX(16px);
|
||||
}
|
||||
|
||||
/* Switch thumbs use physical LTR transforms in every interface direction. */
|
||||
button[role="switch"] {
|
||||
direction: ltr;
|
||||
}
|
||||
.downloads-view {
|
||||
background: hsl(var(--main-bg));
|
||||
}
|
||||
|
||||
.window-controls {
|
||||
position: fixed;
|
||||
top: 15px;
|
||||
top: 20px;
|
||||
left: 22px;
|
||||
right: auto;
|
||||
z-index: 60;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -1590,6 +1664,16 @@ html[data-list-density="relaxed"] {
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.app-shell--sidebar-right .window-controls {
|
||||
left: auto;
|
||||
right: 22px;
|
||||
}
|
||||
|
||||
.window-controls--right {
|
||||
left: auto;
|
||||
right: 22px;
|
||||
}
|
||||
|
||||
.window-control {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
@@ -1626,6 +1710,10 @@ html[data-list-density="relaxed"] {
|
||||
filter: saturate(1.14) brightness(1.05);
|
||||
}
|
||||
|
||||
.window-controls:hover .window-control {
|
||||
color: hsl(0 0% 8% / 0.68);
|
||||
}
|
||||
|
||||
.window-control:active {
|
||||
transform: scale(0.92);
|
||||
filter: brightness(0.9);
|
||||
@@ -1635,19 +1723,35 @@ html[data-list-density="relaxed"] {
|
||||
height: 52px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 18px;
|
||||
direction: ltr;
|
||||
border-bottom: 1px solid hsl(var(--border-color));
|
||||
background: hsl(var(--statusbar-bg));
|
||||
}
|
||||
|
||||
.app-workspace--sidebar-right .main-titlebar {
|
||||
flex-direction: row-reverse;
|
||||
}
|
||||
|
||||
.app-workspace--sidebar-collapsed .main-titlebar {
|
||||
padding-left: 124px;
|
||||
padding-right: 18px;
|
||||
}
|
||||
|
||||
.app-workspace--sidebar-collapsed.app-workspace--custom-window-controls .main-titlebar {
|
||||
padding-left: 160px;
|
||||
}
|
||||
|
||||
.app-workspace--sidebar-right.app-workspace--sidebar-collapsed .main-titlebar {
|
||||
padding-left: 18px;
|
||||
padding-right: 124px;
|
||||
}
|
||||
|
||||
.app-workspace--sidebar-right.app-workspace--sidebar-collapsed.app-workspace--custom-window-controls .main-titlebar {
|
||||
padding-right: 160px;
|
||||
}
|
||||
|
||||
.main-titlebar-title {
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
@@ -1656,7 +1760,7 @@ html[data-list-density="relaxed"] {
|
||||
}
|
||||
|
||||
.main-control-group {
|
||||
margin-left: auto;
|
||||
margin: 0;
|
||||
height: 28px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -1674,7 +1778,7 @@ html[data-list-density="relaxed"] {
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: hsl(var(--text-secondary));
|
||||
border-right: 1px solid hsl(var(--border-color));
|
||||
border-inline-end: 1px solid hsl(var(--border-color));
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
|
||||
@@ -1684,7 +1788,7 @@ html[data-list-density="relaxed"] {
|
||||
}
|
||||
|
||||
.main-control-button:last-child {
|
||||
border-right: 0;
|
||||
border-inline-end: 0;
|
||||
}
|
||||
|
||||
.main-control-button:hover:not(:disabled) {
|
||||
@@ -1780,6 +1884,8 @@ html[data-list-density="relaxed"] {
|
||||
}
|
||||
|
||||
.download-table-header {
|
||||
flex-shrink: 0;
|
||||
position: relative;
|
||||
height: var(--download-header-height);
|
||||
display: grid;
|
||||
align-items: center;
|
||||
@@ -1797,51 +1903,131 @@ html[data-list-density="relaxed"] {
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
overflow-x: hidden;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
}
|
||||
|
||||
.download-table-header,
|
||||
.download-row {
|
||||
direction: ltr;
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
--download-column-padding-x: 14px;
|
||||
}
|
||||
|
||||
.download-table-header > div {
|
||||
.download-column-header {
|
||||
position: relative;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: var(--column-justify, flex-start);
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
padding-inline: var(--download-column-padding-x);
|
||||
padding-right: calc(var(--download-column-padding-x) + 22px);
|
||||
cursor: grab;
|
||||
transition: background-color 120ms ease, opacity 120ms ease, box-shadow 120ms ease;
|
||||
}
|
||||
|
||||
.download-table-header > div:first-child {
|
||||
padding-left: 0;
|
||||
.download-column-header:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.download-table-header > div:last-child {
|
||||
padding-right: 0;
|
||||
.download-column-header.is-dragging {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.download-table-header > div > span {
|
||||
.download-column-header.is-drop-flashing {
|
||||
animation: column-drop-flash 260ms ease-out;
|
||||
}
|
||||
|
||||
.download-column-header-content {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
text-align: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.download-column-header-content > span {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.download-table-header > div:not(:first-child) {
|
||||
padding-left: var(--download-column-padding-x);
|
||||
.download-column-header-content:focus-visible,
|
||||
.download-column-options:focus-visible {
|
||||
outline: 2px solid hsl(var(--accent-color));
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
.download-table-header > [data-column-key="Status"] {
|
||||
padding-inline: 8px;
|
||||
padding-right: 8px;
|
||||
}
|
||||
|
||||
.download-table-header > [data-column-key="Status"]:hover,
|
||||
.download-table-header > [data-column-key="Status"]:focus-within {
|
||||
padding-right: 30px;
|
||||
}
|
||||
|
||||
.download-column-options {
|
||||
position: absolute;
|
||||
right: 8px;
|
||||
left: auto;
|
||||
top: 50%;
|
||||
z-index: 4;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border: 0;
|
||||
border-radius: 6px;
|
||||
background: hsl(var(--surface-overlay) / 0.92);
|
||||
color: hsl(var(--text-secondary));
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transform: translateY(-50%);
|
||||
transition: opacity 120ms ease, color 120ms ease, background-color 120ms ease;
|
||||
}
|
||||
|
||||
.download-column-header:hover .download-column-options,
|
||||
.download-column-options:focus-visible {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.download-column-options:hover {
|
||||
background: hsl(var(--item-hover));
|
||||
color: hsl(var(--text-primary));
|
||||
}
|
||||
|
||||
.download-column-header:first-child {
|
||||
padding-inline-start: 0;
|
||||
}
|
||||
|
||||
.download-column-header[data-column-key="File Name"] {
|
||||
direction: ltr;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.column-resize-handle {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: -4px;
|
||||
left: auto;
|
||||
bottom: 0;
|
||||
z-index: 5;
|
||||
width: 8px;
|
||||
@@ -1862,12 +2048,12 @@ html[data-list-density="relaxed"] {
|
||||
}
|
||||
|
||||
.column-resize-handle:hover,
|
||||
body.is-resizing .column-resize-handle:hover {
|
||||
body.is-column-resizing .column-resize-handle:hover {
|
||||
background: hsl(var(--accent-color) / 0.16);
|
||||
}
|
||||
|
||||
.column-resize-handle:hover::after,
|
||||
body.is-resizing .column-resize-handle:hover::after {
|
||||
body.is-column-resizing .column-resize-handle:hover::after {
|
||||
width: 2px;
|
||||
transform: translateX(-1px);
|
||||
background: hsl(var(--accent-color));
|
||||
@@ -1882,12 +2068,15 @@ body.is-resizing .column-resize-handle:hover::after {
|
||||
|
||||
.download-table-list {
|
||||
height: 100%;
|
||||
overflow: auto;
|
||||
min-height: 0;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.download-row {
|
||||
flex: 0 0 var(--download-row-height);
|
||||
height: var(--download-row-height);
|
||||
display: grid;
|
||||
align-items: center;
|
||||
@@ -1921,15 +2110,13 @@ html[data-list-density="relaxed"] .download-ghost-row {
|
||||
}
|
||||
|
||||
.download-row > div:first-child {
|
||||
padding-left: 0;
|
||||
padding-inline-start: 0;
|
||||
}
|
||||
|
||||
.download-row > div:last-child {
|
||||
padding-right: 0;
|
||||
.download-row > .download-status-cell {
|
||||
padding-inline: 8px;
|
||||
}
|
||||
|
||||
|
||||
|
||||
.download-row.is-selected {
|
||||
background: hsl(var(--accent-color) / 0.26) !important;
|
||||
box-shadow: inset 0 0 0 1px hsl(var(--accent-color) / 0.28);
|
||||
@@ -1939,21 +2126,50 @@ html[data-list-density="relaxed"] .download-ghost-row {
|
||||
background: hsl(var(--accent-color) / 0.32) !important;
|
||||
}
|
||||
|
||||
.download-row:focus-visible {
|
||||
outline: 2px solid hsl(var(--accent-color) / 0.7);
|
||||
outline-offset: -1px;
|
||||
}
|
||||
|
||||
.download-row:hover {
|
||||
background: hsl(var(--item-hover));
|
||||
}
|
||||
|
||||
.download-file-cell {
|
||||
.download-column-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--download-cell-gap);
|
||||
justify-content: var(--column-justify, flex-start);
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.download-file-name {
|
||||
.download-file-cell {
|
||||
direction: ltr;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.download-cell-content {
|
||||
display: block;
|
||||
flex: 1 1 auto;
|
||||
flex: 0 1 auto;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.download-file-cell > .download-cell-content {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--download-cell-gap);
|
||||
width: max-content;
|
||||
}
|
||||
|
||||
.download-file-name {
|
||||
direction: ltr;
|
||||
text-align: left;
|
||||
display: block;
|
||||
flex: 0 1 auto;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
@@ -1963,17 +2179,29 @@ html[data-list-density="relaxed"] .download-ghost-row {
|
||||
}
|
||||
|
||||
.download-cell-truncate {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-align: start;
|
||||
}
|
||||
|
||||
.download-cell-truncate > span,
|
||||
.download-cell-right > span {
|
||||
display: block;
|
||||
text-align: left;
|
||||
.download-size-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: max-content;
|
||||
direction: ltr;
|
||||
text-align: start;
|
||||
}
|
||||
|
||||
.download-size-content > .download-size-progress {
|
||||
flex: 0 1 auto;
|
||||
text-align: start;
|
||||
}
|
||||
|
||||
.download-size-content > .download-size-total {
|
||||
flex: 0 1 auto;
|
||||
text-align: start;
|
||||
}
|
||||
|
||||
.download-size-content > .download-size-progress,
|
||||
.download-size-content > .download-size-total {
|
||||
max-width: 100%;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
@@ -1986,12 +2214,147 @@ html[data-list-density="relaxed"] .download-ghost-row {
|
||||
}
|
||||
|
||||
.download-cell-right {
|
||||
text-align: left;
|
||||
text-align: start;
|
||||
}
|
||||
|
||||
.download-row-actions {
|
||||
position: fixed;
|
||||
right: 0;
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
align-self: stretch;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
z-index: 2;
|
||||
max-width: 100%;
|
||||
min-width: 0;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 120ms ease;
|
||||
}
|
||||
|
||||
.download-row.has-visible-actions .download-row-actions {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.download-row > .download-row-actions {
|
||||
overflow: visible;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.download-row-actions .app-icon-button,
|
||||
.download-row-actions .app-icon-button:hover:not(:disabled),
|
||||
.download-row-actions .app-icon-button:active:not(:disabled) {
|
||||
background: transparent;
|
||||
color: hsl(var(--text-secondary));
|
||||
transition: background-color 120ms ease, box-shadow 120ms ease, color 120ms ease, transform 100ms ease;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.download-row-actions,
|
||||
.download-row-actions .app-icon-button {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
.download-row-actions .app-icon-button:hover:not(:disabled),
|
||||
.download-row-actions .app-icon-button:focus-visible {
|
||||
background: hsl(var(--accent-color) / 0.14);
|
||||
box-shadow: 0 0 0 1px hsl(var(--accent-color) / 0.34);
|
||||
color: hsl(var(--accent-color));
|
||||
}
|
||||
|
||||
.download-row-actions .app-icon-button:active:not(:disabled) {
|
||||
background: hsl(var(--accent-color) / 0.22);
|
||||
box-shadow: 0 0 0 1px hsl(var(--accent-color) / 0.46);
|
||||
color: hsl(var(--accent-color));
|
||||
}
|
||||
|
||||
.download-column-menu {
|
||||
width: 188px;
|
||||
}
|
||||
|
||||
.download-column-menu-title {
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.download-column-menu-label {
|
||||
letter-spacing: 0.12em;
|
||||
}
|
||||
|
||||
.download-column-menu-item[aria-checked="true"] {
|
||||
color: hsl(var(--accent-color));
|
||||
}
|
||||
|
||||
.download-column-drag-preview {
|
||||
position: fixed;
|
||||
z-index: 120;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
height: var(--download-header-height);
|
||||
padding: 0 12px;
|
||||
border: 1px solid hsl(var(--accent-color) / 0.75);
|
||||
border-radius: 8px;
|
||||
background: hsl(var(--surface-overlay) / 0.96);
|
||||
box-shadow: 0 12px 28px hsl(0 0% 0% / 0.28), 0 0 0 1px hsl(var(--accent-color) / 0.18);
|
||||
color: hsl(var(--text-primary));
|
||||
font-size: var(--download-row-font-size);
|
||||
font-weight: 650;
|
||||
pointer-events: none;
|
||||
transition: top 120ms ease-out, width 120ms ease-out;
|
||||
animation: column-drag-preview-in 120ms ease-out;
|
||||
}
|
||||
|
||||
.download-column-drag-preview span {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.download-column-drop-marker {
|
||||
position: fixed;
|
||||
z-index: 119;
|
||||
width: 2px;
|
||||
height: var(--download-header-height);
|
||||
border-radius: 999px;
|
||||
background: hsl(var(--accent-color));
|
||||
box-shadow: 0 0 0 2px hsl(var(--accent-color) / 0.18), 0 0 14px hsl(var(--accent-color) / 0.8);
|
||||
pointer-events: none;
|
||||
transform: translateX(-1px);
|
||||
animation: column-drop-marker-pulse 700ms ease-in-out infinite;
|
||||
}
|
||||
|
||||
.add-download-nested-fields {
|
||||
padding-inline-start: 1.25rem;
|
||||
border-inline-start: 2px solid hsl(var(--border-modal) / 0.5);
|
||||
}
|
||||
|
||||
.add-download-advanced-fields {
|
||||
padding-inline-start: 1.5rem;
|
||||
}
|
||||
|
||||
.add-download-queue-menu {
|
||||
inset-inline-end: 0;
|
||||
}
|
||||
|
||||
.download-context-submenu {
|
||||
inset-inline-start: 100%;
|
||||
margin-inline-start: 0.25rem;
|
||||
}
|
||||
|
||||
.app-workspace--sidebar-right .download-context-submenu {
|
||||
inset-inline-start: auto;
|
||||
inset-inline-end: 100%;
|
||||
margin-inline-start: 0;
|
||||
margin-inline-end: 0.25rem;
|
||||
}
|
||||
|
||||
html[dir="rtl"] .download-context-menu-chevron {
|
||||
transform: scaleX(-1);
|
||||
}
|
||||
|
||||
.download-empty-row {
|
||||
@@ -2018,17 +2381,41 @@ html[data-list-density="relaxed"] .download-ghost-row {
|
||||
.download-status-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
justify-content: var(--column-justify, flex-start);
|
||||
gap: var(--download-status-gap);
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.download-status-cell > span {
|
||||
.download-status-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--download-status-gap);
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
flex: 0 0 auto;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.download-status-content-static {
|
||||
justify-content: var(--column-justify, flex-start);
|
||||
}
|
||||
|
||||
.download-status-cell[data-column-alignment="center"] > .download-status-content,
|
||||
.download-status-cell[data-column-alignment="right"] > .download-status-content {
|
||||
width: max-content;
|
||||
}
|
||||
|
||||
.download-status-cell[data-column-alignment="center"] .download-progress-track,
|
||||
.download-status-cell[data-column-alignment="right"] .download-progress-track {
|
||||
flex: 1 1 128px;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.download-status-cell .download-status {
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
flex: 0 1 auto;
|
||||
text-align: start;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
@@ -2040,6 +2427,7 @@ html[data-list-density="relaxed"] .download-ghost-row {
|
||||
.download-progress-track {
|
||||
flex: 1 1 auto;
|
||||
width: auto;
|
||||
max-width: 100%;
|
||||
min-width: 0;
|
||||
height: var(--download-progress-height);
|
||||
border-radius: max(4px, calc(var(--download-progress-height) / 3));
|
||||
@@ -2234,6 +2622,22 @@ html[data-list-density="relaxed"] .download-ghost-row {
|
||||
to { opacity: 1; transform: translateY(0) scale(1); }
|
||||
}
|
||||
|
||||
@keyframes column-drag-preview-in {
|
||||
from { opacity: 0; transform: translateY(4px) scale(0.98); }
|
||||
to { opacity: 1; transform: translateY(0) scale(1); }
|
||||
}
|
||||
|
||||
@keyframes column-drop-marker-pulse {
|
||||
0%, 100% { opacity: 0.72; }
|
||||
50% { opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes column-drop-flash {
|
||||
0% { box-shadow: inset 0 0 0 0 hsl(var(--accent-color) / 0); }
|
||||
40% { box-shadow: inset 0 0 0 1px hsl(var(--accent-color) / 0.65); }
|
||||
100% { box-shadow: inset 0 0 0 0 hsl(var(--accent-color) / 0); }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*,
|
||||
*::before,
|
||||
@@ -2244,3 +2648,112 @@ html[data-list-density="relaxed"] .download-ghost-row {
|
||||
transition-duration: 0.01ms !important;
|
||||
}
|
||||
}
|
||||
|
||||
html[dir="rtl"] .text-left {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
html[dir="rtl"] .font-mono,
|
||||
html[dir="rtl"] input[type="number"] {
|
||||
direction: ltr;
|
||||
unicode-bidi: isolate;
|
||||
}
|
||||
|
||||
.app-sidebar-shell--right .app-sidebar-panel {
|
||||
box-shadow:
|
||||
inset 0 1px 0 hsl(0 0% 100% / 0.045),
|
||||
-2px 0 10px hsl(0 0% 0% / 0.20);
|
||||
}
|
||||
|
||||
.app-sidebar-shell--left .app-sidebar-panel {
|
||||
direction: ltr;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.app-sidebar-shell--left .sidebar-section-label,
|
||||
.app-sidebar-shell--left .sidebar-section-label-toggle {
|
||||
direction: ltr;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.app-sidebar-shell--left .sidebar-toggle-button {
|
||||
inset-inline-start: auto;
|
||||
inset-inline-end: auto;
|
||||
left: auto;
|
||||
right: 12px;
|
||||
}
|
||||
|
||||
.app-sidebar-shell--right .sidebar-toggle-button {
|
||||
inset-inline-start: auto;
|
||||
inset-inline-end: auto;
|
||||
left: 12px;
|
||||
right: auto;
|
||||
}
|
||||
|
||||
.app-sidebar-shell--left .sidebar-nav-item .sidebar-count {
|
||||
margin-left: auto;
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
.app-sidebar-shell--right .sidebar-nav-item .sidebar-count {
|
||||
margin-left: 0;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.app-sidebar-shell--left .sidebar-nav-item > svg {
|
||||
margin-left: 0;
|
||||
margin-right: 0.75rem;
|
||||
}
|
||||
|
||||
.app-sidebar-shell--left .sidebar-nav-label {
|
||||
direction: ltr;
|
||||
unicode-bidi: isolate;
|
||||
}
|
||||
|
||||
.app-sidebar-shell--right .sidebar-nav-item > svg {
|
||||
margin-left: 0.75rem;
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
.app-sidebar-shell--right .sidebar-nav-label {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.app-sidebar-shell--right .sidebar-section-label {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.app-sidebar-shell--right .sidebar-section-label-toggle {
|
||||
direction: rtl;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.app-sidebar-shell--right .sidebar-add-queue-button,
|
||||
.app-sidebar-shell--right .sidebar-queue-editor {
|
||||
direction: rtl;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
html[dir="ltr"] .app-sidebar-shell--right .sidebar-queue-editor input {
|
||||
direction: ltr;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
html[dir="ltr"] .app-sidebar-shell--right .sidebar-nav-label {
|
||||
direction: ltr;
|
||||
unicode-bidi: isolate;
|
||||
}
|
||||
|
||||
html[dir="rtl"] .app-sidebar-shell--right .sidebar-nav-label {
|
||||
direction: rtl;
|
||||
unicode-bidi: isolate;
|
||||
}
|
||||
|
||||
html[dir="rtl"] .mac-switch::after {
|
||||
left: auto;
|
||||
right: 2px;
|
||||
}
|
||||
|
||||
html[dir="rtl"] .mac-switch:checked::after {
|
||||
transform: translateX(-16px);
|
||||
}
|
||||
|
||||
+16
-2
@@ -5,7 +5,9 @@ import type { DownloadCategory } from './bindings/DownloadCategory';
|
||||
import type { DownloadProgressEvent } from './bindings/DownloadProgressEvent';
|
||||
import type { DownloadStateEvent } from './bindings/DownloadStateEvent';
|
||||
import type { ExtensionDownload } from './bindings/ExtensionDownload';
|
||||
import type { ExtensionCookieScope } from './bindings/ExtensionCookieScope';
|
||||
import type { MediaMetadata } from './bindings/MediaMetadata';
|
||||
import type { MediaPlaylistMetadata } from './bindings/MediaPlaylistMetadata';
|
||||
import type { MetadataResponse } from './bindings/MetadataResponse';
|
||||
import type { EngineStatusItem } from './bindings/EngineStatusItem';
|
||||
import type { PostQueueAction } from './bindings/PostQueueAction';
|
||||
@@ -17,13 +19,17 @@ import type { PlatformInfo } from './bindings/PlatformInfo';
|
||||
|
||||
type CommandMap = {
|
||||
fetch_metadata: {
|
||||
args: { url: string; userAgent: string | null; username: string | null; password: string | null; headers: string | null; cookies: string | null; proxy: string | null; deferCookies?: boolean };
|
||||
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 };
|
||||
@@ -34,7 +40,8 @@ type CommandMap = {
|
||||
resume_download: { args: { id: string }; result: boolean };
|
||||
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,16 +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 };
|
||||
|
||||
+10
-2
@@ -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()
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { initDownloadListener, useDownloadProgressStore } from './downloadStore';
|
||||
import { useDownloadStore } from './useDownloadStore';
|
||||
import {
|
||||
clearDownloadControlIntents,
|
||||
setDownloadControlIntent,
|
||||
useDownloadStore
|
||||
} from './useDownloadStore';
|
||||
import * as ipc from '../ipc';
|
||||
|
||||
vi.mock('../ipc', () => ({
|
||||
@@ -11,7 +15,9 @@ vi.mock('../ipc', () => ({
|
||||
describe('useDownloadProgressStore', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(ipc.invokeCommand).mockResolvedValue(undefined);
|
||||
useDownloadProgressStore.setState({ progressMap: {} });
|
||||
clearDownloadControlIntents();
|
||||
});
|
||||
|
||||
it('prunes terminal progress entries', () => {
|
||||
@@ -84,6 +90,40 @@ describe('useDownloadProgressStore', () => {
|
||||
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) => {
|
||||
@@ -156,4 +196,226 @@ describe('useDownloadProgressStore', () => {
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,7 +5,12 @@ import type { DownloadItem } from '../bindings/DownloadItem';
|
||||
import { categoryForFileName } from '../utils/downloads';
|
||||
import { useDownloadProgressStore } from './downloadProgressStore';
|
||||
|
||||
import { useDownloadStore } from './useDownloadStore';
|
||||
import {
|
||||
clearDownloadControlIntent,
|
||||
downloadControlIntentFor,
|
||||
hasStaleTemporaryMediaEstimate,
|
||||
useDownloadStore
|
||||
} from './useDownloadStore';
|
||||
|
||||
export { useDownloadProgressStore } from './downloadProgressStore';
|
||||
|
||||
@@ -55,6 +60,34 @@ const startDownloadListeners = async () => {
|
||||
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);
|
||||
}
|
||||
@@ -69,19 +102,62 @@ const startDownloadListeners = async () => {
|
||||
}
|
||||
const status = payload.status as DownloadStatus;
|
||||
|
||||
// Prevent race condition: don't transition backwards from terminal state
|
||||
// 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');
|
||||
}
|
||||
|
||||
// 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 progress = useDownloadProgressStore.getState().progressMap[payload.id];
|
||||
const updates: Partial<DownloadItem> = {
|
||||
status,
|
||||
...(progress ? { fraction: progress.fraction } : {}),
|
||||
...(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() }
|
||||
@@ -113,9 +189,6 @@ const startDownloadListeners = async () => {
|
||||
} else if (status === 'completed' || status === 'failed') {
|
||||
mainStore.unregisterBackendIds([payload.id]);
|
||||
}
|
||||
if (['queued', 'retrying', 'completed', 'failed', 'paused'].includes(status)) {
|
||||
useDownloadProgressStore.getState().clearDownloadProgress(payload.id);
|
||||
}
|
||||
}),
|
||||
listen('tray-action', (event) => {
|
||||
const mainStore = useDownloadStore.getState();
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { dispatchItem, getProxyArgs, getSiteLogin, normalizeCustomProxy, useDownloadStore } from './useDownloadStore';
|
||||
import { dispatchItem, getProxyArgs, getSiteLogin, hasStaleTemporaryMediaEstimate, normalizeCustomProxy, normalizePersistedDownloadProgress, normalizePersistedQueueState, normalizePersistedQueues, useDownloadStore } from './useDownloadStore';
|
||||
import { useDownloadProgressStore } from './downloadProgressStore';
|
||||
import { useSettingsStore } from './useSettingsStore';
|
||||
import * as ipc from '../ipc';
|
||||
@@ -95,6 +95,8 @@ describe('useDownloadStore', () => {
|
||||
pendingAddHeaders: '',
|
||||
pendingAddCookies: '',
|
||||
pendingAddMediaUrls: [],
|
||||
pendingAddBatch: false,
|
||||
pendingAddBatchName: '',
|
||||
pendingAddRequestContexts: {},
|
||||
pendingAddRequestVersion: 0,
|
||||
});
|
||||
@@ -111,12 +113,313 @@ describe('useDownloadStore', () => {
|
||||
expect(useDownloadStore.getState().pendingAddRequestVersion).toBe(initialVersion + 2);
|
||||
});
|
||||
|
||||
it('replaces stale media intent when an appended handoff reuses a URL', () => {
|
||||
useDownloadStore.getState().openAddModalWithUrls(
|
||||
'https://example.com/file.bin', '', '', '', '', true
|
||||
);
|
||||
useDownloadStore.getState().openAddModalWithUrls(
|
||||
'https://example.com/file.bin', '', '', '', '', false
|
||||
);
|
||||
|
||||
const state = useDownloadStore.getState();
|
||||
expect(state.pendingAddMediaUrls).toEqual([]);
|
||||
expect(state.pendingAddRequestContexts['https://example.com/file.bin']?.media).toBe(false);
|
||||
});
|
||||
|
||||
it('replaces a paused download URL in place and preserves its progress', async () => {
|
||||
useDownloadStore.setState({
|
||||
downloads: [{
|
||||
id: 'replace-in-place',
|
||||
url: 'https://expired.example/file.bin',
|
||||
fileName: 'file.bin',
|
||||
status: 'paused',
|
||||
category: 'Other',
|
||||
dateAdded: '2026-07-15T00:00:00.000Z',
|
||||
downloadedBytes: 1024,
|
||||
totalBytes: 4096,
|
||||
fraction: 0.25
|
||||
}] as any[]
|
||||
});
|
||||
vi.mocked(ipc.invokeCommand).mockResolvedValue(undefined as never);
|
||||
|
||||
const replaced = await useDownloadStore.getState().replaceDownload(
|
||||
'replace-in-place',
|
||||
{ url: 'https://fresh.example/file.bin', lastError: undefined },
|
||||
{ type: 'add-to-queue', queueId: 'main' }
|
||||
);
|
||||
|
||||
expect(replaced).toBe(true);
|
||||
expect(useDownloadStore.getState().downloads).toEqual([expect.objectContaining({
|
||||
id: 'replace-in-place',
|
||||
url: 'https://fresh.example/file.bin',
|
||||
status: 'paused',
|
||||
downloadedBytes: 1024,
|
||||
totalBytes: 4096,
|
||||
fraction: 0.25
|
||||
})]);
|
||||
expect(ipc.invokeCommand).not.toHaveBeenCalledWith('remove_download', expect.anything());
|
||||
});
|
||||
|
||||
it('resumes a replaced paused download without creating a second row', async () => {
|
||||
useDownloadStore.setState({
|
||||
downloads: [{
|
||||
id: 'replace-and-resume',
|
||||
url: 'https://expired.example/file.bin',
|
||||
fileName: 'file.bin',
|
||||
status: 'paused',
|
||||
category: 'Other',
|
||||
dateAdded: '',
|
||||
destination: '/tmp',
|
||||
downloadedBytes: 2048,
|
||||
totalBytes: 4096
|
||||
}] as any[]
|
||||
});
|
||||
vi.mocked(ipc.invokeCommand).mockImplementation(async (command: string) => {
|
||||
if (command === 'resume_download') return false;
|
||||
if (command === 'enqueue_download') {
|
||||
return { id: 'replace-and-resume', filename: 'file.bin' };
|
||||
}
|
||||
if (command === 'get_pending_order') return [];
|
||||
return undefined;
|
||||
});
|
||||
|
||||
const replaced = await useDownloadStore.getState().replaceDownload(
|
||||
'replace-and-resume',
|
||||
{ url: 'https://fresh.example/file.bin' },
|
||||
{ type: 'start-now' }
|
||||
);
|
||||
|
||||
expect(replaced).toBe(true);
|
||||
expect(useDownloadStore.getState().downloads).toHaveLength(1);
|
||||
expect(useDownloadStore.getState().downloads[0]).toMatchObject({
|
||||
id: 'replace-and-resume',
|
||||
url: 'https://fresh.example/file.bin',
|
||||
downloadedBytes: 2048,
|
||||
totalBytes: 4096,
|
||||
hasBeenDispatched: true
|
||||
});
|
||||
expect(ipc.invokeCommand).not.toHaveBeenCalledWith('remove_download', expect.anything());
|
||||
});
|
||||
|
||||
it('serializes a replacement and a concurrent pause as one lifecycle operation', async () => {
|
||||
useDownloadStore.setState({
|
||||
downloads: [{
|
||||
id: 'replace-pause-race',
|
||||
url: 'https://expired.example/file.bin',
|
||||
fileName: 'file.bin',
|
||||
status: 'paused',
|
||||
category: 'Other',
|
||||
dateAdded: ''
|
||||
}] as any[]
|
||||
});
|
||||
|
||||
let releaseResume!: () => void;
|
||||
let signalResumeStarted!: () => void;
|
||||
const resumeStarted = new Promise<void>(resolve => {
|
||||
signalResumeStarted = resolve;
|
||||
});
|
||||
const resumeGate = new Promise<void>(resolve => {
|
||||
releaseResume = resolve;
|
||||
});
|
||||
vi.mocked(ipc.invokeCommand).mockImplementation(async (command: string) => {
|
||||
if (command === 'resume_download') {
|
||||
signalResumeStarted();
|
||||
await resumeGate;
|
||||
return true;
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
|
||||
const replacing = useDownloadStore.getState().replaceDownload(
|
||||
'replace-pause-race',
|
||||
{ url: 'https://fresh.example/file.bin' },
|
||||
{ type: 'start-now' }
|
||||
);
|
||||
await resumeStarted;
|
||||
|
||||
const pausing = useDownloadStore.getState().pauseDownload('replace-pause-race');
|
||||
await Promise.resolve();
|
||||
expect(ipc.invokeCommand).not.toHaveBeenCalledWith('pause_download', { id: 'replace-pause-race' });
|
||||
|
||||
releaseResume();
|
||||
await replacing;
|
||||
await pausing;
|
||||
|
||||
expect(ipc.invokeCommand).toHaveBeenCalledWith('pause_download', { id: 'replace-pause-race' });
|
||||
expect(useDownloadStore.getState().downloads[0]).toMatchObject({
|
||||
url: 'https://fresh.example/file.bin',
|
||||
status: 'paused'
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects empty and duplicate queue names', () => {
|
||||
useDownloadStore.setState({
|
||||
queues: [
|
||||
{ id: 'main', name: 'Main Queue', isMain: true },
|
||||
{ id: 'queue-a', name: 'Downloads', isMain: false }
|
||||
]
|
||||
});
|
||||
|
||||
expect(useDownloadStore.getState().addQueue('')).toBe(false);
|
||||
expect(useDownloadStore.getState().addQueue(' downloads ')).toBe(false);
|
||||
expect(useDownloadStore.getState().addQueue('Archive')).toBe(true);
|
||||
expect(useDownloadStore.getState().renameQueue('queue-a', ' archive ')).toBe(false);
|
||||
expect(useDownloadStore.getState().renameQueue('queue-a', '')).toBe(false);
|
||||
});
|
||||
|
||||
it('normalizes malformed persisted queues around one canonical main queue', () => {
|
||||
expect(normalizePersistedQueues([
|
||||
{ id: 'custom-a', name: ' Downloads ', isMain: false },
|
||||
{ id: 'custom-b', name: 'downloads', isMain: false },
|
||||
{ id: 'custom-a', name: 'Duplicate ID', isMain: false },
|
||||
{ id: 'legacy-main', name: 'Primary', isMain: true },
|
||||
{ id: 'empty-name', name: ' ', isMain: false },
|
||||
{ id: 'main-id', name: 'Ignored Main', isMain: true }
|
||||
])).toEqual([
|
||||
{ id: '00000000-0000-0000-0000-000000000001', name: 'Primary', isMain: true },
|
||||
{ id: 'custom-a', name: 'Downloads', isMain: false },
|
||||
{ id: 'custom-b', name: 'downloads (2)', isMain: false },
|
||||
{ id: 'empty-name', name: 'Queue empty-na', isMain: false }
|
||||
]);
|
||||
expect(normalizePersistedQueueState([
|
||||
{ id: 'legacy-main', name: 'Primary', isMain: true }
|
||||
]).queueIdRemap.get('legacy-main')).toBe('00000000-0000-0000-0000-000000000001');
|
||||
});
|
||||
|
||||
it('remaps persisted downloads when queue records are malformed or missing', async () => {
|
||||
vi.mocked(ipc.invokeCommand).mockImplementation(async (cmd: string) => {
|
||||
if (cmd === 'db_get_all_queues') {
|
||||
return [JSON.stringify({ id: 'legacy-main', name: 'Primary', isMain: true })];
|
||||
}
|
||||
if (cmd === 'db_get_all_downloads') {
|
||||
return [
|
||||
JSON.stringify({
|
||||
id: 'legacy-download',
|
||||
url: 'https://example.com/legacy.bin',
|
||||
fileName: 'legacy.bin',
|
||||
status: 'ready',
|
||||
category: 'Other',
|
||||
dateAdded: '',
|
||||
queueId: 'legacy-main'
|
||||
}),
|
||||
JSON.stringify({
|
||||
id: 'orphan-download',
|
||||
url: 'https://example.com/orphan.bin',
|
||||
fileName: 'orphan.bin',
|
||||
status: 'ready',
|
||||
category: 'Other',
|
||||
dateAdded: '',
|
||||
queueId: 'missing-queue'
|
||||
})
|
||||
];
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
|
||||
await useDownloadStore.getState().initDB();
|
||||
|
||||
expect(useDownloadStore.getState().downloads.map(download => download.queueId))
|
||||
.toEqual(['00000000-0000-0000-0000-000000000001', '00000000-0000-0000-0000-000000000001']);
|
||||
});
|
||||
|
||||
it('removes persisted temporary media estimates that are smaller than downloaded bytes', async () => {
|
||||
vi.mocked(ipc.invokeCommand).mockImplementation(async (cmd: string) => {
|
||||
if (cmd === 'db_get_all_queues') return [];
|
||||
if (cmd === 'db_get_all_downloads') {
|
||||
return [JSON.stringify({
|
||||
id: 'stale-media-estimate',
|
||||
url: 'https://youtube.com/watch?v=stale',
|
||||
fileName: 'video.mkv',
|
||||
status: 'queued',
|
||||
category: 'Movies',
|
||||
dateAdded: '',
|
||||
queueId: '00000000-0000-0000-0000-000000000001',
|
||||
isMedia: true,
|
||||
size: '~1.00 KB',
|
||||
downloadedBytes: 11_989,
|
||||
totalBytes: 1_024,
|
||||
totalIsEstimate: true
|
||||
})];
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
|
||||
await useDownloadStore.getState().initDB();
|
||||
|
||||
expect(useDownloadStore.getState().downloads[0]).toMatchObject({
|
||||
size: undefined,
|
||||
downloadedBytes: 11_989,
|
||||
totalBytes: undefined,
|
||||
totalIsEstimate: undefined
|
||||
});
|
||||
});
|
||||
|
||||
it('does not discard a legitimate large media estimate when downloaded bytes exceed it', () => {
|
||||
const media = {
|
||||
isMedia: true,
|
||||
downloadedBytes: 90_000_000,
|
||||
totalBytes: 89_817_907,
|
||||
totalIsEstimate: true,
|
||||
size: '~85.7 MB'
|
||||
} as const;
|
||||
|
||||
expect(hasStaleTemporaryMediaEstimate(media)).toBe(false);
|
||||
expect(normalizePersistedDownloadProgress({
|
||||
id: 'large-estimate',
|
||||
url: 'https://youtube.com/watch?v=large',
|
||||
fileName: 'video.mkv',
|
||||
status: 'queued',
|
||||
category: 'Movies',
|
||||
dateAdded: '',
|
||||
...media
|
||||
})).toMatchObject({
|
||||
size: '~85.7 MB',
|
||||
downloadedBytes: 90_000_000,
|
||||
totalBytes: 89_817_907,
|
||||
totalIsEstimate: true
|
||||
});
|
||||
});
|
||||
|
||||
it('does not discard a legitimate small media estimate without contradictory progress', () => {
|
||||
const media = {
|
||||
isMedia: true,
|
||||
size: '~500 B',
|
||||
downloadedBytes: 500,
|
||||
totalBytes: undefined,
|
||||
totalIsEstimate: true
|
||||
} as const;
|
||||
|
||||
expect(hasStaleTemporaryMediaEstimate(media)).toBe(false);
|
||||
expect(normalizePersistedDownloadProgress({
|
||||
id: 'small-media',
|
||||
url: 'https://youtube.com/watch?v=small',
|
||||
fileName: 'short.mkv',
|
||||
status: 'queued',
|
||||
category: 'Movies',
|
||||
dateAdded: '',
|
||||
...media
|
||||
})).toMatchObject(media);
|
||||
});
|
||||
|
||||
it('recognizes IEC-formatted temporary media estimates', () => {
|
||||
expect(hasStaleTemporaryMediaEstimate({
|
||||
isMedia: true,
|
||||
size: '~1.00 KiB',
|
||||
downloadedBytes: 2_048,
|
||||
totalBytes: undefined,
|
||||
totalIsEstimate: true
|
||||
})).toBe(true);
|
||||
});
|
||||
|
||||
it('normalizes proxy settings for download dispatch', async () => {
|
||||
expect(normalizeCustomProxy('127.0.0.1', 8080)).toBe('http://127.0.0.1:8080');
|
||||
expect(normalizeCustomProxy('http://proxy.local:9000', 8080)).toBe('http://proxy.local:9000');
|
||||
expect(normalizeCustomProxy(' socks5://127.0.0.1 ', 1080)).toBeNull();
|
||||
expect(normalizeCustomProxy('https://proxy.local', 8443)).toBeNull();
|
||||
expect(normalizeCustomProxy('127.0.0.1', NaN)).toBeNull();
|
||||
expect(normalizeCustomProxy('127.0.0.1:9000', 8080)).toBeNull();
|
||||
expect(normalizeCustomProxy('127.0.0.1/path', 8080)).toBeNull();
|
||||
expect(normalizeCustomProxy('[::1]', 8080)).toBe('http://[::1]:8080');
|
||||
|
||||
expect(await getProxyArgs({
|
||||
proxyMode: 'none',
|
||||
@@ -131,6 +434,15 @@ describe('useDownloadStore', () => {
|
||||
proxyPort: 8080
|
||||
} as ReturnType<typeof useSettingsStore.getState>)).toBe('none');
|
||||
|
||||
vi.mocked(ipc.invokeCommand).mockRejectedValueOnce(new Error('system settings unavailable'));
|
||||
await expect(getProxyArgs({
|
||||
proxyMode: 'system',
|
||||
proxyHost: '',
|
||||
proxyPort: 8080
|
||||
} as ReturnType<typeof useSettingsStore.getState>)).rejects.toThrow(
|
||||
'System proxy configuration could not be read: system settings unavailable'
|
||||
);
|
||||
|
||||
expect(await getProxyArgs({
|
||||
proxyMode: 'custom',
|
||||
proxyHost: 'http://127.0.0.1',
|
||||
@@ -138,6 +450,39 @@ describe('useDownloadStore', () => {
|
||||
} as ReturnType<typeof useSettingsStore.getState>)).toBe('http://127.0.0.1:1080');
|
||||
});
|
||||
|
||||
it('keeps an item queued when system proxy resolution fails closed', async () => {
|
||||
vi.mocked(useSettingsStore.getState).mockReturnValue({
|
||||
...useSettingsStore.getState(),
|
||||
proxyMode: 'system'
|
||||
} as unknown as ReturnType<typeof useSettingsStore.getState>);
|
||||
vi.mocked(ipc.invokeCommand).mockImplementation(async (command: string) => {
|
||||
if (command === 'get_system_proxy') {
|
||||
throw new Error('system settings unavailable');
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
useDownloadStore.setState({
|
||||
downloads: [{
|
||||
id: 'system-proxy-blocked',
|
||||
url: 'https://example.com/file.bin',
|
||||
fileName: 'file.bin',
|
||||
destination: '/tmp',
|
||||
status: 'queued',
|
||||
category: 'Other',
|
||||
dateAdded: ''
|
||||
}] as any[],
|
||||
backendRegisteredIds: new Set()
|
||||
});
|
||||
|
||||
await expect(dispatchItem('system-proxy-blocked')).resolves.toBe(false);
|
||||
|
||||
expect(useDownloadStore.getState().downloads[0]).toMatchObject({
|
||||
status: 'queued',
|
||||
lastError: 'System proxy configuration could not be read: system settings unavailable. Choose No Proxy or try again.'
|
||||
});
|
||||
expect(ipc.invokeCommand).not.toHaveBeenCalledWith('enqueue_download', expect.anything());
|
||||
});
|
||||
|
||||
it('matches site logins by host, wildcard host, path, and full URL patterns', () => {
|
||||
const settings = {
|
||||
siteLogins: [
|
||||
@@ -391,6 +736,48 @@ describe('useDownloadStore', () => {
|
||||
expect(useDownloadStore.getState().backendRegisteredIds.has('resume-generation')).toBe(true); // Re-registered by dispatchItem
|
||||
});
|
||||
|
||||
it('coalesces duplicate resume actions while the backend transition is in flight', async () => {
|
||||
useDownloadStore.setState({
|
||||
downloads: [{
|
||||
id: 'resume-double-submit',
|
||||
url: 'http://test1',
|
||||
fileName: 'f1',
|
||||
destination: '/tmp',
|
||||
status: 'paused',
|
||||
category: 'Other',
|
||||
dateAdded: '',
|
||||
hasBeenDispatched: true
|
||||
}] as any[],
|
||||
backendRegisteredIds: new Set(['resume-double-submit'])
|
||||
});
|
||||
|
||||
let releaseResume!: () => void;
|
||||
const resumeReleased = new Promise<void>(resolve => {
|
||||
releaseResume = resolve;
|
||||
});
|
||||
vi.mocked(ipc.invokeCommand).mockImplementation(async (cmd: string) => {
|
||||
if (cmd === 'resume_download') {
|
||||
await resumeReleased;
|
||||
return true;
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
|
||||
const first = useDownloadStore.getState().resumeDownload('resume-double-submit');
|
||||
await vi.waitFor(() => {
|
||||
expect(vi.mocked(ipc.invokeCommand).mock.calls.filter(([command]) => command === 'resume_download'))
|
||||
.toHaveLength(1);
|
||||
});
|
||||
const second = useDownloadStore.getState().resumeDownload('resume-double-submit');
|
||||
|
||||
expect(second).toBe(first);
|
||||
releaseResume();
|
||||
await Promise.all([first, second]);
|
||||
expect(
|
||||
vi.mocked(ipc.invokeCommand).mock.calls.filter(([command]) => command === 'resume_download')
|
||||
).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('does not re-enqueue when the existing resume RPC fails', async () => {
|
||||
useDownloadStore.setState({
|
||||
downloads: [
|
||||
@@ -454,6 +841,25 @@ describe('useDownloadStore', () => {
|
||||
expect(ipc.invokeCommand).not.toHaveBeenCalledWith('enqueue_download', expect.anything());
|
||||
});
|
||||
|
||||
it('carries a media format estimate into numeric progress state', async () => {
|
||||
await useDownloadStore.getState().addDownload({
|
||||
id: 'media-estimate',
|
||||
url: 'https://youtube.com/watch?v=estimate',
|
||||
fileName: 'video.mkv',
|
||||
category: 'Movies',
|
||||
dateAdded: '',
|
||||
isMedia: true,
|
||||
size: '~85.7 MB',
|
||||
sizeBytes: 89_817_907
|
||||
}, { type: 'add-to-queue', queueId: 'queue-b' });
|
||||
|
||||
expect(useDownloadStore.getState().downloads[0]).toMatchObject({
|
||||
totalBytes: 89_817_907,
|
||||
totalIsEstimate: true
|
||||
});
|
||||
expect(useDownloadStore.getState().downloads[0]).not.toHaveProperty('sizeBytes');
|
||||
});
|
||||
|
||||
it('starts immediately in the main queue', async () => {
|
||||
vi.mocked(ipc.invokeCommand).mockImplementation(async (cmd: string) => {
|
||||
if (cmd === 'get_pending_order') return ['start-1'];
|
||||
@@ -510,7 +916,7 @@ describe('useDownloadStore', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('uses the global speed limit only when an item has no explicit speed override', async () => {
|
||||
it('does not copy the global speed limit into a normal download task', async () => {
|
||||
const defaultSettings = useSettingsStore.getState();
|
||||
vi.mocked(useSettingsStore.getState).mockReturnValue({
|
||||
...defaultSettings,
|
||||
@@ -534,6 +940,69 @@ describe('useDownloadStore', () => {
|
||||
expect.objectContaining({
|
||||
item: expect.objectContaining({
|
||||
id: 'inherits-global',
|
||||
speed_limit: null
|
||||
})
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('passes the global speed limit to a new media process when it has no item override', async () => {
|
||||
const defaultSettings = useSettingsStore.getState();
|
||||
vi.mocked(useSettingsStore.getState).mockReturnValue({
|
||||
...defaultSettings,
|
||||
globalSpeedLimit: '2M'
|
||||
});
|
||||
vi.mocked(ipc.invokeCommand).mockImplementation(async (cmd: string) => {
|
||||
if (cmd === 'get_pending_order') return ['inherits-global-media'];
|
||||
return undefined;
|
||||
});
|
||||
|
||||
await useDownloadStore.getState().addDownload({
|
||||
id: 'inherits-global-media',
|
||||
url: 'https://www.youtube.com/watch?v=example',
|
||||
fileName: 'media.mp4',
|
||||
category: 'Movies',
|
||||
dateAdded: '',
|
||||
isMedia: true
|
||||
}, { type: 'start-now' });
|
||||
|
||||
expect(ipc.invokeCommand).toHaveBeenCalledWith(
|
||||
'enqueue_download',
|
||||
expect.objectContaining({
|
||||
item: expect.objectContaining({
|
||||
id: 'inherits-global-media',
|
||||
speed_limit: '2M'
|
||||
})
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('treats the legacy media zero sentinel as inheriting the global limit', async () => {
|
||||
const defaultSettings = useSettingsStore.getState();
|
||||
vi.mocked(useSettingsStore.getState).mockReturnValue({
|
||||
...defaultSettings,
|
||||
globalSpeedLimit: '2M'
|
||||
});
|
||||
vi.mocked(ipc.invokeCommand).mockImplementation(async (cmd: string) => {
|
||||
if (cmd === 'get_pending_order') return ['legacy-media-limit'];
|
||||
return undefined;
|
||||
});
|
||||
|
||||
await useDownloadStore.getState().addDownload({
|
||||
id: 'legacy-media-limit',
|
||||
url: 'https://www.youtube.com/watch?v=legacy',
|
||||
fileName: 'media.mp4',
|
||||
category: 'Movies',
|
||||
dateAdded: '',
|
||||
isMedia: true,
|
||||
speedLimit: '0'
|
||||
}, { type: 'start-now' });
|
||||
|
||||
expect(ipc.invokeCommand).toHaveBeenCalledWith(
|
||||
'enqueue_download',
|
||||
expect.objectContaining({
|
||||
item: expect.objectContaining({
|
||||
id: 'legacy-media-limit',
|
||||
speed_limit: '2M'
|
||||
})
|
||||
})
|
||||
@@ -625,6 +1094,41 @@ describe('useDownloadStore', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps all startup items retryable when system proxy resolution fails', async () => {
|
||||
vi.mocked(useSettingsStore.getState).mockReturnValue({
|
||||
...useSettingsStore.getState(),
|
||||
proxyMode: 'system'
|
||||
} as unknown as ReturnType<typeof useSettingsStore.getState>);
|
||||
vi.mocked(ipc.invokeCommand).mockImplementation(async (command: string) => {
|
||||
if (command === 'db_get_all_queues') return [];
|
||||
if (command === 'db_get_all_downloads') {
|
||||
return [JSON.stringify({
|
||||
id: 'startup-proxy-blocked',
|
||||
url: 'https://example.com/file.bin',
|
||||
fileName: 'file.bin',
|
||||
status: 'queued',
|
||||
category: 'Other',
|
||||
dateAdded: '',
|
||||
queueId: '00000000-0000-0000-0000-000000000001',
|
||||
hasBeenDispatched: true
|
||||
})];
|
||||
}
|
||||
if (command === 'get_system_proxy') {
|
||||
throw new Error('system settings unavailable');
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
|
||||
await useDownloadStore.getState().initDB();
|
||||
await useDownloadStore.getState().resumePendingDownloads();
|
||||
|
||||
expect(useDownloadStore.getState().downloads[0]).toMatchObject({
|
||||
status: 'queued',
|
||||
lastError: 'System proxy configuration could not be read: system settings unavailable. Choose No Proxy or try again.'
|
||||
});
|
||||
expect(ipc.invokeCommand).not.toHaveBeenCalledWith('enqueue_many', expect.anything());
|
||||
});
|
||||
|
||||
it('keeps accepted startup registrations when pending-order refresh fails', async () => {
|
||||
vi.mocked(ipc.invokeCommand).mockImplementation(async (cmd: string) => {
|
||||
if (cmd === 'db_get_all_queues') return [];
|
||||
@@ -851,6 +1355,48 @@ describe('useDownloadStore', () => {
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('coalesces duplicate redownloads and serializes a destructive removal behind them', async () => {
|
||||
useDownloadStore.setState({
|
||||
downloads: [{
|
||||
id: 'redownload-double-submit',
|
||||
url: 'https://example.com/file.bin',
|
||||
fileName: 'file.bin',
|
||||
destination: '/tmp',
|
||||
status: 'paused',
|
||||
category: 'Other',
|
||||
dateAdded: ''
|
||||
}] as any[]
|
||||
});
|
||||
|
||||
let releaseRedownloadRemoval!: () => void;
|
||||
const redownloadRemovalReleased = new Promise<void>(resolve => {
|
||||
releaseRedownloadRemoval = resolve;
|
||||
});
|
||||
let removeCallCount = 0;
|
||||
vi.mocked(ipc.invokeCommand).mockImplementation(async (cmd: string) => {
|
||||
if (cmd === 'remove_download') {
|
||||
removeCallCount += 1;
|
||||
if (removeCallCount === 1) await redownloadRemovalReleased;
|
||||
return undefined;
|
||||
}
|
||||
if (cmd === 'get_pending_order') return [];
|
||||
return undefined;
|
||||
});
|
||||
|
||||
const first = useDownloadStore.getState().redownload('redownload-double-submit');
|
||||
await vi.waitFor(() => expect(removeCallCount).toBe(1));
|
||||
const second = useDownloadStore.getState().redownload('redownload-double-submit');
|
||||
const remove = useDownloadStore.getState().removeDownload('redownload-double-submit');
|
||||
|
||||
expect(second).toBe(first);
|
||||
expect(removeCallCount).toBe(1);
|
||||
releaseRedownloadRemoval();
|
||||
await first;
|
||||
await remove;
|
||||
expect(removeCallCount).toBe(2);
|
||||
expect(useDownloadStore.getState().downloads).toEqual([]);
|
||||
});
|
||||
|
||||
it('starts and pauses all items regardless of legacy missing queue ids', async () => {
|
||||
useDownloadStore.setState({
|
||||
downloads: [
|
||||
@@ -1172,7 +1718,13 @@ describe('useDownloadStore', () => {
|
||||
filename: 'file.bin',
|
||||
headers: 'X-Test: value',
|
||||
cookies: 'session=secret',
|
||||
media: false
|
||||
cookie_scopes: [
|
||||
{ url: 'https://mail.google.com/', cookies: 'SID=mail-session' },
|
||||
{ url: 'https://accounts.google.com/', cookies: 'SID=account-session' }
|
||||
],
|
||||
media: false,
|
||||
batch: false,
|
||||
batch_name: null
|
||||
});
|
||||
|
||||
const state = useDownloadStore.getState();
|
||||
@@ -1182,6 +1734,10 @@ describe('useDownloadStore', () => {
|
||||
expect(state.pendingAddFilename).toBe('file.bin');
|
||||
expect(state.pendingAddHeaders).toBe('X-Test: value');
|
||||
expect(state.pendingAddCookies).toBe('session=secret');
|
||||
expect(state.pendingAddRequestContexts['https://example.com/file.bin'].cookieScopes).toEqual([
|
||||
{ url: 'https://mail.google.com/', cookies: 'SID=mail-session' },
|
||||
{ url: 'https://accounts.google.com/', cookies: 'SID=account-session' }
|
||||
]);
|
||||
expect(state.pendingAddMediaUrls).toEqual([]);
|
||||
});
|
||||
|
||||
@@ -1203,7 +1759,10 @@ describe('useDownloadStore', () => {
|
||||
filename: null,
|
||||
headers: 'User-Agent: Firefox Test',
|
||||
cookies: null,
|
||||
media: false
|
||||
cookie_scopes: null,
|
||||
media: false,
|
||||
batch: false,
|
||||
batch_name: null
|
||||
});
|
||||
|
||||
const state = useDownloadStore.getState();
|
||||
@@ -1223,7 +1782,10 @@ describe('useDownloadStore', () => {
|
||||
filename: 'report.pdf',
|
||||
headers: 'User-Agent: Test',
|
||||
cookies: 'session=secret',
|
||||
media: false
|
||||
cookie_scopes: null,
|
||||
media: false,
|
||||
batch: false,
|
||||
batch_name: null
|
||||
});
|
||||
|
||||
const state = useDownloadStore.getState();
|
||||
@@ -1236,6 +1798,30 @@ describe('useDownloadStore', () => {
|
||||
expect(state.pendingAddMediaUrls).toEqual([]);
|
||||
});
|
||||
|
||||
it('tracks selected-link batch context without changing ordinary multi-link handoffs', async () => {
|
||||
await useDownloadStore.getState().handleExtensionDownload({
|
||||
urls: ['https://example.com/one.zip', 'https://example.com/two.zip'],
|
||||
referer: 'https://example.com/gallery',
|
||||
silent: false,
|
||||
filename: null,
|
||||
headers: null,
|
||||
cookies: null,
|
||||
cookie_scopes: null,
|
||||
media: false,
|
||||
batch: true,
|
||||
batch_name: 'Example Gallery'
|
||||
});
|
||||
|
||||
expect(useDownloadStore.getState().pendingAddBatch).toBe(true);
|
||||
expect(useDownloadStore.getState().pendingAddBatchName).toBe('Example Gallery');
|
||||
|
||||
useDownloadStore.getState().toggleAddModal(false);
|
||||
useDownloadStore.getState().openAddModalWithUrls(
|
||||
'https://example.com/one.zip\nhttps://example.com/two.zip'
|
||||
);
|
||||
expect(useDownloadStore.getState().pendingAddBatch).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps each extension handoff context attached to its own URL while the Add Modal is open', async () => {
|
||||
await useDownloadStore.getState().handleExtensionDownload({
|
||||
urls: ['https://first.example/file.zip'],
|
||||
@@ -1244,7 +1830,10 @@ describe('useDownloadStore', () => {
|
||||
filename: 'first.zip',
|
||||
headers: 'User-Agent: First Browser',
|
||||
cookies: 'first=session',
|
||||
media: false
|
||||
cookie_scopes: null,
|
||||
media: false,
|
||||
batch: false,
|
||||
batch_name: null
|
||||
});
|
||||
await useDownloadStore.getState().handleExtensionDownload({
|
||||
urls: ['https://second.example/file.zip'],
|
||||
@@ -1253,7 +1842,10 @@ describe('useDownloadStore', () => {
|
||||
filename: 'second.zip',
|
||||
headers: 'User-Agent: Second Browser',
|
||||
cookies: 'second=session',
|
||||
media: false
|
||||
cookie_scopes: null,
|
||||
media: false,
|
||||
batch: false,
|
||||
batch_name: null
|
||||
});
|
||||
|
||||
const state = useDownloadStore.getState();
|
||||
@@ -1287,9 +1879,12 @@ describe('useDownloadStore', () => {
|
||||
referer: 'https://adult.example/watch/123',
|
||||
silent: false,
|
||||
filename: null,
|
||||
headers: `Cookie: stale=${'x'.repeat(64 * 1024)}\nUser-Agent: Firefox Test`,
|
||||
headers: `Cookie: stale=${'x'.repeat(64 * 1024)}\nCookie2: stale=1\nAuthorization: Bearer stale\nProxy-Authorization: Basic stale\nSet-Cookie: stale=1\nSet-Cookie2: stale=1\nUser-Agent: Firefox Test`,
|
||||
cookies: `oversized=${'x'.repeat(64 * 1024)}`,
|
||||
media: true
|
||||
cookie_scopes: null,
|
||||
media: true,
|
||||
batch: false,
|
||||
batch_name: null
|
||||
});
|
||||
|
||||
const state = useDownloadStore.getState();
|
||||
@@ -1308,12 +1903,35 @@ describe('useDownloadStore', () => {
|
||||
filename: 'private.zip',
|
||||
headers: null,
|
||||
cookies: 'session=secret',
|
||||
media: false
|
||||
cookie_scopes: null,
|
||||
media: false,
|
||||
batch: false,
|
||||
batch_name: null
|
||||
});
|
||||
|
||||
expect(useDownloadStore.getState().pendingAddCookies).toBe('session=secret');
|
||||
});
|
||||
|
||||
it('drops extension cookie scopes for explicit media captures', async () => {
|
||||
await useDownloadStore.getState().handleExtensionDownload({
|
||||
urls: ['https://media.example/watch/123'],
|
||||
referer: 'https://media.example/watch/123',
|
||||
silent: true,
|
||||
filename: null,
|
||||
headers: null,
|
||||
cookies: null,
|
||||
cookie_scopes: [
|
||||
{ url: 'https://media.example/', cookies: 'session=secret' }
|
||||
],
|
||||
media: true,
|
||||
batch: false,
|
||||
batch_name: null
|
||||
});
|
||||
|
||||
expect(useDownloadStore.getState().pendingAddRequestContexts['https://media.example/watch/123']?.cookieScopes)
|
||||
.toBeUndefined();
|
||||
});
|
||||
|
||||
it('clears stale request context when the same URL is captured without it later', async () => {
|
||||
const url = 'https://example.com/file.zip';
|
||||
await useDownloadStore.getState().handleExtensionDownload({
|
||||
@@ -1323,7 +1941,10 @@ describe('useDownloadStore', () => {
|
||||
filename: 'private.zip',
|
||||
headers: 'Authorization: secret',
|
||||
cookies: 'session=secret',
|
||||
media: false
|
||||
cookie_scopes: null,
|
||||
media: false,
|
||||
batch: false,
|
||||
batch_name: null
|
||||
});
|
||||
await useDownloadStore.getState().handleExtensionDownload({
|
||||
urls: [url],
|
||||
@@ -1332,7 +1953,10 @@ describe('useDownloadStore', () => {
|
||||
filename: null,
|
||||
headers: null,
|
||||
cookies: null,
|
||||
media: false
|
||||
cookie_scopes: null,
|
||||
media: false,
|
||||
batch: false,
|
||||
batch_name: null
|
||||
});
|
||||
|
||||
expect(useDownloadStore.getState().pendingAddRequestContexts[url]).toEqual({
|
||||
@@ -1359,7 +1983,10 @@ describe('useDownloadStore', () => {
|
||||
filename: null,
|
||||
headers: 'User-Agent: Firefox Test',
|
||||
cookies: 'session=secret',
|
||||
media: true
|
||||
cookie_scopes: null,
|
||||
media: true,
|
||||
batch: false,
|
||||
batch_name: null
|
||||
});
|
||||
|
||||
expect(useDownloadStore.getState().pendingAddMediaUrls).toEqual([
|
||||
@@ -1378,7 +2005,10 @@ describe('useDownloadStore', () => {
|
||||
filename: 'file.bin',
|
||||
headers: 'User-Agent: Firefox Test',
|
||||
cookies: null,
|
||||
media: false
|
||||
cookie_scopes: null,
|
||||
media: false,
|
||||
batch: false,
|
||||
batch_name: null
|
||||
});
|
||||
|
||||
expect(useDownloadStore.getState().pendingAddMediaUrls).toEqual([]);
|
||||
|
||||
+627
-194
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,11 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { useSettingsStore } from './useSettingsStore';
|
||||
import {
|
||||
runSettingsPersistenceTransaction,
|
||||
subscribeToSettingsPersistenceErrors,
|
||||
useSettingsStore
|
||||
} from './useSettingsStore';
|
||||
import * as ipc from '../ipc';
|
||||
import type { PairingTokenHydration } from '../bindings/PairingTokenHydration';
|
||||
|
||||
vi.mock('../ipc', () => ({
|
||||
invokeCommand: vi.fn()
|
||||
@@ -10,6 +15,12 @@ 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();
|
||||
@@ -26,6 +37,19 @@ describe('useSettingsStore global speed limit persistence', () => {
|
||||
});
|
||||
});
|
||||
|
||||
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();
|
||||
@@ -65,4 +89,113 @@ describe('useSettingsStore credential-store startup flow', () => {
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
+130
-16
@@ -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';
|
||||
@@ -18,14 +19,58 @@ import {
|
||||
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'
|
||||
@@ -35,6 +80,7 @@ const SETTINGS_TAB_VALUES = [
|
||||
] as const;
|
||||
|
||||
type PersistedSettingsSnapshot = PersistedSettings & {
|
||||
language: AppLocalePreference;
|
||||
keychainPromptDismissed: boolean;
|
||||
keychainAccessVersion: string;
|
||||
};
|
||||
@@ -82,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> => {
|
||||
@@ -116,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;
|
||||
@@ -143,9 +199,12 @@ export interface SettingsState {
|
||||
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;
|
||||
@@ -165,12 +224,14 @@ export interface SettingsState {
|
||||
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) => 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;
|
||||
@@ -186,6 +247,7 @@ export interface SettingsState {
|
||||
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;
|
||||
@@ -197,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;
|
||||
@@ -205,21 +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: () => Promise<void>;
|
||||
hydrateSessionPairingToken: (isCurrent?: () => boolean) => Promise<void>;
|
||||
}
|
||||
|
||||
export const useSettingsStore = create<SettingsState>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
theme: 'system',
|
||||
language: 'system',
|
||||
baseDownloadFolder: '~/Downloads',
|
||||
categorySubfoldersEnabled: true,
|
||||
categorySubfolders: { ...DEFAULT_CATEGORY_SUBFOLDERS },
|
||||
categoryDirectoryOverrides: {},
|
||||
rememberLastUsedDownloadDirectory: false,
|
||||
lastUsedDownloadDirectory: null,
|
||||
approvedDownloadRoots: [],
|
||||
maxConcurrentDownloads: 3,
|
||||
globalSpeedLimit: '',
|
||||
@@ -228,6 +295,7 @@ export const useSettingsStore = create<SettingsState>()(
|
||||
activeView: 'downloads',
|
||||
activeSettingsTab: 'downloads',
|
||||
isSidebarVisible: true,
|
||||
sidebarPosition: 'auto',
|
||||
scheduler: {
|
||||
enabled: false,
|
||||
startTime: '00:00',
|
||||
@@ -250,9 +318,11 @@ export const useSettingsStore = create<SettingsState>()(
|
||||
maxAutomaticRetries: 3,
|
||||
showNotifications: true,
|
||||
playCompletionSound: false,
|
||||
autoAddClipboardLinks: false,
|
||||
appFontSize: 'standard',
|
||||
listRowDensity: 'standard',
|
||||
showDockBadge: true,
|
||||
dockBadgeSyncVersion: 0,
|
||||
showMenuBarIcon: true,
|
||||
proxyMode: 'none',
|
||||
proxyHost: '',
|
||||
@@ -272,6 +342,7 @@ export const useSettingsStore = create<SettingsState>()(
|
||||
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 });
|
||||
@@ -300,6 +371,7 @@ export const useSettingsStore = create<SettingsState>()(
|
||||
},
|
||||
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 }),
|
||||
@@ -319,11 +391,14 @@ export const useSettingsStore = create<SettingsState>()(
|
||||
}),
|
||||
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 }),
|
||||
@@ -340,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 });
|
||||
@@ -373,9 +460,14 @@ export const useSettingsStore = create<SettingsState>()(
|
||||
siteLogins: state.siteLogins.filter((login) => login.id !== id)
|
||||
})),
|
||||
regeneratePairingToken: async () => {
|
||||
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 || 'Credential store access is unavailable.');
|
||||
throw new Error(result.error || i18n.t($ => $.keychain.storeUnavailable));
|
||||
}
|
||||
set({
|
||||
extensionPairingToken: result.token,
|
||||
@@ -383,11 +475,12 @@ export const useSettingsStore = create<SettingsState>()(
|
||||
showKeychainModal: false
|
||||
});
|
||||
},
|
||||
hydratePairingToken: async () => {
|
||||
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 invoke('hydrate_extension_pairing_token');
|
||||
const result = await requestPairingTokenHydration();
|
||||
if (isCurrent && !isCurrent()) return false;
|
||||
set({
|
||||
extensionPairingToken: result.token,
|
||||
isPairingTokenPersistent: result.persistent,
|
||||
@@ -395,8 +488,9 @@ export const useSettingsStore = create<SettingsState>()(
|
||||
});
|
||||
return result.tokenChanged;
|
||||
},
|
||||
hydrateSessionPairingToken: async () => {
|
||||
hydrateSessionPairingToken: async (isCurrent) => {
|
||||
const result = await invoke('get_session_pairing_token');
|
||||
if (isCurrent && !isCurrent()) return;
|
||||
set({
|
||||
extensionPairingToken: result.token,
|
||||
isPairingTokenPersistent: false,
|
||||
@@ -418,7 +512,7 @@ export const useSettingsStore = create<SettingsState>()(
|
||||
{
|
||||
name: 'firelink-settings',
|
||||
storage: createJSONStorage(() => tauriStorage),
|
||||
version: 3,
|
||||
version: 5,
|
||||
migrate: (persistedState) => {
|
||||
if (!persistedState || typeof persistedState !== 'object') {
|
||||
return persistedState as SettingsState;
|
||||
@@ -454,16 +548,19 @@ export const useSettingsStore = create<SettingsState>()(
|
||||
},
|
||||
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,
|
||||
@@ -477,6 +574,7 @@ export const useSettingsStore = create<SettingsState>()(
|
||||
maxAutomaticRetries: state.maxAutomaticRetries,
|
||||
showNotifications: state.showNotifications,
|
||||
playCompletionSound: state.playCompletionSound,
|
||||
autoAddClipboardLinks: state.autoAddClipboardLinks,
|
||||
appFontSize: state.appFontSize,
|
||||
listRowDensity: state.listRowDensity,
|
||||
showDockBadge: state.showDockBadge,
|
||||
@@ -504,16 +602,24 @@ export const useSettingsStore = create<SettingsState>()(
|
||||
...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,
|
||||
@@ -525,6 +631,14 @@ export const useSettingsStore = create<SettingsState>()(
|
||||
: 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(
|
||||
|
||||
@@ -5,11 +5,14 @@ import {
|
||||
mediaFormatSelectorForRow,
|
||||
mediaFileNameForSelectedFormat,
|
||||
metadataSummaryMessage,
|
||||
isYouTubePlaylistUrl,
|
||||
playlistFilePrefix,
|
||||
reconcileDownloadRows,
|
||||
refreshFailedMetadataRows,
|
||||
updateRowIfCurrent,
|
||||
type AddDownloadDraftRow
|
||||
} from './addDownloadMetadata';
|
||||
import i18n, { changeAppLocale } from '../i18n';
|
||||
|
||||
const row = (
|
||||
overrides: Partial<AddDownloadDraftRow> = {}
|
||||
@@ -57,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',
|
||||
@@ -129,7 +294,10 @@ describe('add download metadata workflow', () => {
|
||||
const existing = row({
|
||||
file: 'old-name.zip',
|
||||
requestContextVersion: 1,
|
||||
generation: 2
|
||||
generation: 2,
|
||||
size: '10 MB',
|
||||
sizeBytes: 10,
|
||||
resumable: true
|
||||
});
|
||||
|
||||
const refreshed = reconcileDownloadRows(
|
||||
@@ -146,7 +314,47 @@ describe('add download metadata workflow', () => {
|
||||
file: 'new-name.zip',
|
||||
status: 'loading',
|
||||
generation: 3,
|
||||
requestContextVersion: 2
|
||||
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
|
||||
});
|
||||
});
|
||||
|
||||
@@ -236,6 +444,9 @@ describe('add download metadata workflow', () => {
|
||||
row(),
|
||||
row({ id: 'fallback', status: 'metadata-error' })
|
||||
])).toBe(true);
|
||||
expect(canSubmitMetadataRows([
|
||||
row({ id: 'unsafe', status: 'metadata-error', metadataBlockedReason: 'unsafe-url' })
|
||||
])).toBe(false);
|
||||
expect(canSubmitMetadataRows([
|
||||
row(),
|
||||
row({ id: 'media-fallback', status: 'metadata-error', isMedia: true })
|
||||
@@ -244,6 +455,29 @@ describe('add download metadata workflow', () => {
|
||||
expect(canSubmitMetadataRows([row({ status: 'invalid' })])).toBe(false);
|
||||
});
|
||||
|
||||
it('validates only selected rows and requires at least one selection', () => {
|
||||
expect(canSubmitMetadataRows([
|
||||
row({ status: 'loading' }),
|
||||
row({ id: 'skipped', status: 'invalid', selected: false })
|
||||
])).toBe(false);
|
||||
expect(canSubmitMetadataRows([
|
||||
row({ status: 'ready' }),
|
||||
row({ id: 'skipped', status: 'invalid', selected: false })
|
||||
])).toBe(true);
|
||||
expect(canSubmitMetadataRows([
|
||||
row({ selected: false }),
|
||||
row({ id: 'skipped', selected: false })
|
||||
])).toBe(false);
|
||||
expect(metadataSummaryMessage([
|
||||
row({ status: 'metadata-error', selected: false }),
|
||||
row({ id: 'ready', status: 'ready' })
|
||||
])).toContain('Ready to add 1 download');
|
||||
expect(metadataSummaryMessage([
|
||||
row({ status: 'metadata-error' }),
|
||||
row({ id: 'skipped', status: 'ready', selected: false })
|
||||
])).toContain('can still be added');
|
||||
});
|
||||
|
||||
it('keeps failed media routing without a format selector', () => {
|
||||
const failedMedia = row({
|
||||
status: 'metadata-error',
|
||||
@@ -294,6 +528,9 @@ describe('add download metadata workflow', () => {
|
||||
expect(metadataSummaryMessage([
|
||||
row({ status: 'metadata-error' })
|
||||
])).toContain('can still be added');
|
||||
expect(metadataSummaryMessage([
|
||||
row({ status: 'metadata-error', metadataBlockedReason: 'unsafe-url' })
|
||||
])).toContain('unsafe URL');
|
||||
expect(metadataSummaryMessage([
|
||||
row({ status: 'metadata-error', isMedia: true })
|
||||
])).toContain('Refresh metadata before adding');
|
||||
@@ -301,4 +538,19 @@ describe('add download metadata workflow', () => {
|
||||
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');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -31,6 +34,15 @@ export interface AddDownloadDraftRow {
|
||||
resumable?: boolean;
|
||||
formats?: AddMediaFormat[];
|
||||
selectedFormat?: number;
|
||||
isPlaylist?: boolean;
|
||||
playlistSourceUrl?: string;
|
||||
playlistTitle?: string;
|
||||
playlistIndex?: number;
|
||||
playlistCount?: number;
|
||||
playlistEntryTitle?: string;
|
||||
playlistError?: string;
|
||||
metadataBlockedReason?: 'unsafe-url';
|
||||
selected?: boolean;
|
||||
}
|
||||
|
||||
const ALLOWED_SCHEMES = new Set(['http:', 'https:', 'ftp:', 'sftp:']);
|
||||
@@ -39,9 +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[] = [];
|
||||
|
||||
@@ -62,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;
|
||||
@@ -75,37 +166,68 @@ export const reconcileDownloadRows = (
|
||||
forceMediaUrls: ReadonlySet<string> = new Set(),
|
||||
createId: () => string = () => crypto.randomUUID(),
|
||||
requestFilenames: Readonly<Record<string, string>> = {},
|
||||
requestContextVersions: Readonly<Record<string, number>> = {}
|
||||
requestContextVersions: Readonly<Record<string, number>> = {},
|
||||
playlistExpansions: PlaylistExpansions = {},
|
||||
selectedBySourceUrl: Readonly<Record<string, boolean>> = {}
|
||||
): AddDownloadDraftRow[] => {
|
||||
const inputs = parseInputLines(rawText);
|
||||
const inputs = parseInputLines(
|
||||
rawText,
|
||||
playlistExpansions,
|
||||
requestContextVersions,
|
||||
selectedBySourceUrl
|
||||
);
|
||||
const existing = new Map(currentRows.map(row => [row.sourceUrl, row]));
|
||||
|
||||
return inputs.map(input => {
|
||||
const preserved = existing.get(input.sourceUrl);
|
||||
if (preserved) {
|
||||
const forcedMedia = input.valid && forceMediaUrls.has(input.sourceUrl);
|
||||
const requestContextVersion = requestContextVersions[input.sourceUrl];
|
||||
const requestContextVersion = input.requestContextVersion;
|
||||
const contextChanged = requestContextVersion !== undefined
|
||||
&& requestContextVersion !== preserved.requestContextVersion;
|
||||
if ((forcedMedia && !preserved.isMedia) || contextChanged) {
|
||||
const requestedFilename = requestFilenames[input.sourceUrl];
|
||||
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
|
||||
file: contextChanged || playlistContextChanged
|
||||
? canonicalizeDownloadFileName(requestedFilename || fileNameFromUrl(input.sourceUrl))
|
||||
: preserved.file,
|
||||
status: 'loading',
|
||||
generation: preserved.generation + 1,
|
||||
requestContextVersion,
|
||||
isMedia: preserved.isMedia || forcedMedia,
|
||||
formats: preserved.isMedia || forcedMedia ? undefined : preserved.formats,
|
||||
selectedFormat: preserved.isMedia || forcedMedia ? undefined : preserved.selectedFormat
|
||||
isMedia: preserved.isMedia || forcedMedia || Boolean(input.playlistSourceUrl),
|
||||
size: undefined,
|
||||
sizeBytes: undefined,
|
||||
resumable: undefined,
|
||||
formats: preserved.isMedia || forcedMedia || Boolean(input.playlistSourceUrl)
|
||||
? undefined
|
||||
: preserved.formats,
|
||||
selectedFormat: preserved.isMedia || forcedMedia || Boolean(input.playlistSourceUrl)
|
||||
? undefined
|
||||
: preserved.selectedFormat,
|
||||
isPlaylist: input.isPlaylist,
|
||||
playlistSourceUrl: input.playlistSourceUrl,
|
||||
playlistTitle: input.playlistTitle,
|
||||
playlistIndex: input.playlistIndex,
|
||||
playlistCount: input.playlistCount,
|
||||
playlistEntryTitle: input.playlistEntryTitle,
|
||||
playlistError: undefined,
|
||||
metadataBlockedReason: undefined
|
||||
};
|
||||
}
|
||||
return preserved;
|
||||
}
|
||||
|
||||
const requestedFilename = requestFilenames[input.sourceUrl]
|
||||
const requestedFilename = input.playlistSourceUrl
|
||||
? `${playlistFilePrefix(input.playlistIndex, input.playlistCount)}${input.playlistEntryTitle || 'video'}`
|
||||
: requestFilenames[input.sourceUrl]
|
||||
|| (inputs.length === 1 ? pendingFilename : undefined);
|
||||
const fallback = canonicalizeDownloadFileName(
|
||||
requestedFilename || fileNameFromUrl(input.sourceUrl)
|
||||
@@ -118,8 +240,21 @@ export const reconcileDownloadRows = (
|
||||
file: fallback,
|
||||
status: input.valid ? 'loading' : 'invalid',
|
||||
generation: input.valid ? 1 : 0,
|
||||
requestContextVersion: requestContextVersions[input.sourceUrl],
|
||||
isMedia: input.valid && (forceMediaUrls.has(input.sourceUrl) || isMediaUrl(input.sourceUrl))
|
||||
requestContextVersion: input.requestContextVersion,
|
||||
isMedia: input.valid && (
|
||||
Boolean(input.isPlaylist)
|
||||
|| Boolean(input.playlistSourceUrl)
|
||||
|| forceMediaUrls.has(input.sourceUrl)
|
||||
|| isMediaUrl(input.sourceUrl)
|
||||
),
|
||||
isPlaylist: input.isPlaylist,
|
||||
playlistSourceUrl: input.playlistSourceUrl,
|
||||
playlistTitle: input.playlistTitle,
|
||||
playlistIndex: input.playlistIndex,
|
||||
playlistCount: input.playlistCount,
|
||||
playlistEntryTitle: input.playlistEntryTitle,
|
||||
metadataBlockedReason: undefined,
|
||||
selected: input.selected !== false
|
||||
};
|
||||
});
|
||||
};
|
||||
@@ -172,17 +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 =>
|
||||
export const canSubmitMetadataRows = (rows: AddDownloadDraftRow[]): boolean => {
|
||||
const selectedRows = rows.filter(row => row.selected !== false);
|
||||
return selectedRows.length > 0
|
||||
&& selectedRows.every(row =>
|
||||
row.status === 'ready'
|
||||
|| (!row.isMedia && row.status === 'metadata-error')
|
||||
|| (!row.isMedia && row.status === 'metadata-error' && !row.metadataBlockedReason)
|
||||
);
|
||||
};
|
||||
|
||||
export const mediaFormatSelectorForRow = (
|
||||
row: AddDownloadDraftRow
|
||||
@@ -225,30 +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 failedMedia = rows.filter(row => row.status === 'metadata-error' && row.isMedia).length;
|
||||
const ready = rows.filter(row => row.status === 'ready').length;
|
||||
if (failedMedia > 0) {
|
||||
return `Media metadata is unavailable for ${failedMedia} item${failedMedia === 1 ? '' : 's'}. Refresh metadata before adding.`;
|
||||
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 (failed === rows.length) {
|
||||
return 'Metadata is unavailable. Downloads can still be added using fallback details.';
|
||||
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 })
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
};
|
||||
@@ -11,10 +11,14 @@ vi.mock('@tauri-apps/api/path', () => ({
|
||||
import {
|
||||
downloadLocationEquals,
|
||||
DEFAULT_CATEGORY_SUBFOLDERS,
|
||||
deriveBatchFolderName,
|
||||
formatDerivedCategoryPath,
|
||||
normalizeCategorySubfolder,
|
||||
normalizeDownloadLocationSettings,
|
||||
resolveInitialAddWindowLocation,
|
||||
resolveCategoryDestination,
|
||||
resolveSubfolderDestination,
|
||||
sanitizeBatchFolderName,
|
||||
subfolderFromDerivedCategoryPath
|
||||
} from './downloadLocations';
|
||||
|
||||
@@ -24,6 +28,67 @@ describe('download locations', () => {
|
||||
expect(downloadLocationEquals('/Users/Test', 'Movie.MP4', '/users/test', 'movie.mp4', 'macos')).toBe(false);
|
||||
expect(downloadLocationEquals('/home/Test', 'Movie.MP4', '/home/test', 'movie.mp4', 'linux')).toBe(false);
|
||||
});
|
||||
|
||||
it('uses a remembered Add-window directory only when the setting is enabled', () => {
|
||||
expect(resolveInitialAddWindowLocation(
|
||||
'D:\\Downloads',
|
||||
true,
|
||||
'D:\\Course_Videos'
|
||||
)).toEqual({ path: 'D:\\Course_Videos', isManual: true });
|
||||
|
||||
expect(resolveInitialAddWindowLocation(
|
||||
'D:\\Downloads',
|
||||
false,
|
||||
'D:\\Course_Videos'
|
||||
)).toEqual({ path: 'D:\\Downloads', isManual: false });
|
||||
});
|
||||
|
||||
it('falls back to the normalized base folder when no directory was remembered', () => {
|
||||
expect(resolveInitialAddWindowLocation(' ', true, null))
|
||||
.toEqual({ path: '~/Downloads', isManual: false });
|
||||
});
|
||||
|
||||
it('derives safe batch folder names from title, referer, and timestamp fallback', () => {
|
||||
expect(deriveBatchFolderName(
|
||||
'Gallery / Chapter: 1',
|
||||
'https://example.com/gallery'
|
||||
)).toBe('Gallery - Chapter- 1');
|
||||
expect(deriveBatchFolderName(
|
||||
'New Tab',
|
||||
'https://example.com/gallery/part-1?token=secret'
|
||||
)).toBe('example.com-gallery-part-1');
|
||||
expect(deriveBatchFolderName(
|
||||
'New Tab',
|
||||
'https://example.com/gallery',
|
||||
new Date('2026-07-20T12:34:56.789Z'),
|
||||
['example.part1.rar', 'example.part2.rar']
|
||||
)).toBe('example');
|
||||
expect(deriveBatchFolderName(
|
||||
'CON',
|
||||
null,
|
||||
new Date('2026-07-20T12:34:56.789Z')
|
||||
)).toBe('batch-CON');
|
||||
expect(deriveBatchFolderName(
|
||||
'',
|
||||
null,
|
||||
new Date('2026-07-20T12:34:56.789Z')
|
||||
)).toBe('firelink-batch-2026-07-20-12-34-56-789');
|
||||
expect(deriveBatchFolderName(
|
||||
'',
|
||||
null,
|
||||
new Date('2026-07-20T12:34:56.789Z'),
|
||||
['example.part1.rar', 'example.part2.rar']
|
||||
)).toBe('example');
|
||||
expect(sanitizeBatchFolderName('../Example: Parts')).toBe('Example- Parts');
|
||||
expect(sanitizeBatchFolderName('😀'.repeat(100))).toBe('😀'.repeat(96));
|
||||
});
|
||||
|
||||
it('places the optional folder below an existing category destination', async () => {
|
||||
expect(await resolveSubfolderDestination(
|
||||
'/Users/test/Downloads/Compressed',
|
||||
'Example: Parts'
|
||||
)).toBe('/Users/test/Downloads/Compressed/Example- Parts');
|
||||
});
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
@@ -48,6 +48,113 @@ interface LegacyDownloadLocationSettings {
|
||||
downloadDirectories?: unknown;
|
||||
}
|
||||
|
||||
export interface AddWindowLocationSuggestion {
|
||||
path: string;
|
||||
isManual: boolean;
|
||||
}
|
||||
|
||||
const MAX_BATCH_FOLDER_NAME_LENGTH = 96;
|
||||
const WEAK_BATCH_PAGE_TITLES = new Set(['new tab', 'untitled', 'about:blank']);
|
||||
|
||||
const truncateBatchFolderName = (value: string): string => Array.from(value)
|
||||
.filter(character => {
|
||||
const codePoint = character.codePointAt(0) || 0;
|
||||
return codePoint < 0xd800 || codePoint > 0xdfff;
|
||||
})
|
||||
.slice(0, MAX_BATCH_FOLDER_NAME_LENGTH)
|
||||
.join('');
|
||||
|
||||
export const sanitizeBatchFolderName = (value: string): string => {
|
||||
const sanitized = truncateBatchFolderName(
|
||||
value
|
||||
.trim()
|
||||
.replace(/[\u0000-\u001f\u007f]/g, '-')
|
||||
.replace(/[<>:"/\\|?*]/g, '-')
|
||||
.replace(/\s+/g, ' ')
|
||||
.replace(/-+/g, '-')
|
||||
.replace(/^[ .-]+|[ .-]+$/g, '')
|
||||
)
|
||||
.trim()
|
||||
.replace(/[ .-]+$/g, '');
|
||||
|
||||
if (!sanitized || sanitized === '.' || sanitized === '..') return '';
|
||||
if (/^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\..*)?$/i.test(sanitized)) {
|
||||
return `batch-${sanitized}`;
|
||||
}
|
||||
return sanitized;
|
||||
};
|
||||
|
||||
const batchFolderSlugFromReferer = (referer: string): string => {
|
||||
try {
|
||||
const url = new URL(referer);
|
||||
if (!['http:', 'https:'].includes(url.protocol) || !url.hostname) return '';
|
||||
const path = url.pathname.replace(/^\/+|\/+$/g, '');
|
||||
return sanitizeBatchFolderName(`${url.hostname}${path ? `-${path}` : ''}`);
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
const batchFolderNameFromFiles = (fileNames: string[]): string => {
|
||||
const stems = fileNames
|
||||
.map(fileName => fileName.replace(/\\/g, '/').split('/').pop() || '')
|
||||
.map(fileName => fileName.replace(/\.[^.]+$/, ''))
|
||||
.filter(Boolean);
|
||||
if (stems.length === 0) return '';
|
||||
|
||||
const partStems = stems.map(stem => stem.replace(/[._ -]?part\s*\d+$/i, ''));
|
||||
const candidate = partStems.every(stem => stem && stem === partStems[0])
|
||||
? partStems[0]
|
||||
: stems.length === 1 ? stems[0] : '';
|
||||
return candidate ? sanitizeBatchFolderName(candidate) : '';
|
||||
};
|
||||
|
||||
export const deriveBatchFolderName = (
|
||||
pageTitle?: string | null,
|
||||
referer?: string | null,
|
||||
now = new Date(),
|
||||
fileNames: string[] = []
|
||||
): string => {
|
||||
const title = pageTitle?.trim() || '';
|
||||
if (title && !WEAK_BATCH_PAGE_TITLES.has(title.toLocaleLowerCase())) {
|
||||
const safeTitle = sanitizeBatchFolderName(title);
|
||||
if (safeTitle) return safeTitle;
|
||||
}
|
||||
|
||||
const fileNameSlug = batchFolderNameFromFiles(fileNames);
|
||||
if (fileNameSlug) return fileNameSlug;
|
||||
|
||||
const refererSlug = batchFolderSlugFromReferer(referer?.trim() || '');
|
||||
if (refererSlug) return refererSlug;
|
||||
|
||||
const timestamp = now.toISOString().replace(/[.:]/g, '-').replace('T', '-').replace('Z', '');
|
||||
return `firelink-batch-${timestamp}`;
|
||||
};
|
||||
|
||||
export const resolveSubfolderDestination = async (
|
||||
destination: string,
|
||||
folderName: string
|
||||
): Promise<string> => {
|
||||
const root = await expandTilde(destination.trim() || '~/Downloads');
|
||||
const safeFolderName = sanitizeBatchFolderName(folderName);
|
||||
return safeFolderName ? join(root, safeFolderName) : root;
|
||||
};
|
||||
|
||||
export const resolveInitialAddWindowLocation = (
|
||||
baseDownloadFolder: string,
|
||||
rememberLastUsedDownloadDirectory: boolean,
|
||||
lastUsedDownloadDirectory: string | null
|
||||
): AddWindowLocationSuggestion => {
|
||||
const rememberedPath = rememberLastUsedDownloadDirectory
|
||||
? lastUsedDownloadDirectory?.trim()
|
||||
: undefined;
|
||||
const basePath = baseDownloadFolder.trim() || '~/Downloads';
|
||||
return {
|
||||
path: rememberedPath || basePath,
|
||||
isManual: Boolean(rememberedPath)
|
||||
};
|
||||
};
|
||||
|
||||
const stringRecord = (value: unknown): Record<string, string> => {
|
||||
if (!value || typeof value !== 'object') return {};
|
||||
return Object.fromEntries(
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { formatDownloadBytes, formatDownloadTotal, resolveDownloadSizeDisplay } from './downloadProgress';
|
||||
|
||||
describe('download progress size display', () => {
|
||||
it('formats byte counts using the binary units used by the download engines', () => {
|
||||
expect(formatDownloadBytes(0)).toBe('0 B');
|
||||
expect(formatDownloadBytes(1.2 * 1024 ** 3)).toBe('1.20 GB');
|
||||
});
|
||||
|
||||
it('keeps estimated totals distinguishable from exact totals', () => {
|
||||
expect(resolveDownloadSizeDisplay({
|
||||
downloadedBytes: 1.2 * 1024 ** 3,
|
||||
totalBytes: 2.4 * 1024 ** 3,
|
||||
totalIsEstimate: true,
|
||||
fallbackSize: 'Unknown'
|
||||
})).toEqual({
|
||||
downloaded: '1.20',
|
||||
total: '2.40',
|
||||
unit: 'GB',
|
||||
totalIsEstimate: true,
|
||||
fallback: 'Unknown'
|
||||
});
|
||||
});
|
||||
|
||||
it('converts downloaded bytes into the total size unit', () => {
|
||||
expect(resolveDownloadSizeDisplay({
|
||||
downloadedBytes: 512 * 1024 ** 2,
|
||||
totalBytes: 2 * 1024 ** 3,
|
||||
fallbackSize: '2 GB'
|
||||
})).toMatchObject({
|
||||
downloaded: '0.50',
|
||||
total: '2.00',
|
||||
unit: 'GB'
|
||||
});
|
||||
});
|
||||
|
||||
it('formats a completed download using only its total size', () => {
|
||||
const display = resolveDownloadSizeDisplay({
|
||||
downloadedBytes: 1.2 * 1024 ** 3,
|
||||
totalBytes: 2.4 * 1024 ** 3,
|
||||
fallbackSize: '2.4 GB'
|
||||
});
|
||||
|
||||
expect(formatDownloadTotal(display)).toBe('2.40 GB');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
export interface DownloadSizeDisplay {
|
||||
downloaded: string | null;
|
||||
total: string | null;
|
||||
unit: string | null;
|
||||
totalIsEstimate: boolean;
|
||||
fallback: string;
|
||||
}
|
||||
|
||||
const BYTE_UNITS = ['B', 'KB', 'MB', 'GB', 'TB'] as const;
|
||||
|
||||
const isUsableByteCount = (value: number | null | undefined): value is number =>
|
||||
typeof value === 'number' && Number.isFinite(value) && value >= 0;
|
||||
|
||||
const byteUnitIndex = (bytes: number): number => {
|
||||
let value = bytes;
|
||||
let unitIndex = 0;
|
||||
while (value >= 1024 && unitIndex < BYTE_UNITS.length - 1) {
|
||||
value /= 1024;
|
||||
unitIndex += 1;
|
||||
}
|
||||
return unitIndex;
|
||||
};
|
||||
|
||||
const formatDownloadBytesInUnit = (bytes: number, unitIndex: number): string => {
|
||||
const value = bytes / 1024 ** unitIndex;
|
||||
const precision = value >= 100 ? 0 : value >= 10 ? 1 : 2;
|
||||
return value < 1024 && unitIndex === 0
|
||||
? `${Math.round(value)}`
|
||||
: value.toFixed(precision);
|
||||
};
|
||||
|
||||
export const formatDownloadBytes = (bytes: number): string => {
|
||||
const unitIndex = byteUnitIndex(bytes);
|
||||
return `${formatDownloadBytesInUnit(bytes, unitIndex)} ${BYTE_UNITS[unitIndex]}`;
|
||||
};
|
||||
|
||||
export const formatDownloadTotal = (display: DownloadSizeDisplay): string =>
|
||||
display.total && display.unit
|
||||
? `${display.totalIsEstimate ? '~' : ''}${display.total} ${display.unit}`
|
||||
: display.fallback;
|
||||
|
||||
export const resolveDownloadSizeDisplay = ({
|
||||
downloadedBytes,
|
||||
totalBytes,
|
||||
totalIsEstimate = false,
|
||||
fallbackSize
|
||||
}: {
|
||||
downloadedBytes?: number | null;
|
||||
totalBytes?: number | null;
|
||||
totalIsEstimate?: boolean;
|
||||
fallbackSize?: string | null;
|
||||
}): DownloadSizeDisplay => ({
|
||||
downloaded: isUsableByteCount(downloadedBytes) && isUsableByteCount(totalBytes) && totalBytes > 0
|
||||
? formatDownloadBytesInUnit(downloadedBytes, byteUnitIndex(totalBytes))
|
||||
: null,
|
||||
total: isUsableByteCount(totalBytes) && totalBytes > 0
|
||||
? formatDownloadBytesInUnit(totalBytes, byteUnitIndex(totalBytes))
|
||||
: null,
|
||||
unit: isUsableByteCount(totalBytes) && totalBytes > 0
|
||||
? BYTE_UNITS[byteUnitIndex(totalBytes)]
|
||||
: null,
|
||||
totalIsEstimate: Boolean(totalIsEstimate && isUsableByteCount(totalBytes) && totalBytes > 0),
|
||||
fallback: fallbackSize && fallbackSize !== '-' ? fallbackSize : 'Unknown'
|
||||
});
|
||||
|
||||
export const downloadProgressColorClass = (status: string): string => {
|
||||
switch (status) {
|
||||
case 'completed':
|
||||
return 'download-status-completed';
|
||||
case 'paused':
|
||||
return 'download-status-paused';
|
||||
case 'failed':
|
||||
return 'download-status-failed';
|
||||
case 'processing':
|
||||
return 'download-status-processing';
|
||||
case 'queued':
|
||||
case 'staged':
|
||||
return 'download-status-queued';
|
||||
case 'retrying':
|
||||
return 'download-status-retrying';
|
||||
default:
|
||||
return 'download-status-downloading';
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,65 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
DEFAULT_COLUMN_ALIGNMENTS,
|
||||
DEFAULT_COLUMN_ORDER,
|
||||
DEFAULT_COLUMN_WIDTHS,
|
||||
buildColumnGridTemplate,
|
||||
getColumnGridColumn,
|
||||
normalizeColumnAlignments,
|
||||
normalizeColumnOrder,
|
||||
normalizeColumnWidths,
|
||||
} from './downloadTableColumns';
|
||||
|
||||
describe('download table column preferences', () => {
|
||||
it('normalizes a persisted order without losing or duplicating columns', () => {
|
||||
expect(normalizeColumnOrder(['Status', 'Status', 'not-a-column', 'File Name'])).toEqual([
|
||||
'Status',
|
||||
'File Name',
|
||||
'Size',
|
||||
'Speed',
|
||||
'ETA',
|
||||
'Date Added',
|
||||
]);
|
||||
});
|
||||
|
||||
it('clamps persisted widths to column minimums and restores malformed entries', () => {
|
||||
expect(normalizeColumnWidths([10, 70, Number.POSITIVE_INFINITY, 80, '48', 140])).toEqual([
|
||||
160,
|
||||
70,
|
||||
220,
|
||||
80,
|
||||
80,
|
||||
144,
|
||||
]);
|
||||
|
||||
expect(normalizeColumnWidths([0, 100, 220, 100, 80, 170])[0]).toBe(160);
|
||||
});
|
||||
|
||||
it('keeps only supported positional alignment values', () => {
|
||||
expect(normalizeColumnAlignments({
|
||||
'File Name': 'center',
|
||||
Size: 'right',
|
||||
Status: 'invalid',
|
||||
Speed: 'left',
|
||||
})).toEqual({
|
||||
...DEFAULT_COLUMN_ALIGNMENTS,
|
||||
'File Name': 'center',
|
||||
Size: 'right',
|
||||
Speed: 'left',
|
||||
});
|
||||
});
|
||||
|
||||
it('restores the default order for malformed persisted data', () => {
|
||||
expect(normalizeColumnOrder(null)).toEqual([...DEFAULT_COLUMN_ORDER]);
|
||||
expect(normalizeColumnAlignments(null)).toEqual(DEFAULT_COLUMN_ALIGNMENTS);
|
||||
});
|
||||
|
||||
it('keeps user widths fixed while reserving a shared spacer before the final column', () => {
|
||||
expect(buildColumnGridTemplate([...DEFAULT_COLUMN_ORDER], [...DEFAULT_COLUMN_WIDTHS])).toBe(
|
||||
'340px 100px 220px 100px 80px minmax(0, 1fr) 170px'
|
||||
);
|
||||
expect(getColumnGridColumn('Date Added', [...DEFAULT_COLUMN_ORDER])).toBe('7');
|
||||
expect(getColumnGridColumn('Date Added', ['Date Added', ...DEFAULT_COLUMN_ORDER.slice(0, -1)])).toBeUndefined();
|
||||
expect(getColumnGridColumn('ETA', ['Date Added', 'File Name', 'Size', 'Status', 'Speed', 'ETA'])).toBe('7');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,113 @@
|
||||
import type { DownloadSortColumn } from './downloadTableSorting';
|
||||
|
||||
export type DownloadTableColumnKey = DownloadSortColumn;
|
||||
export type DownloadColumnAlignment = 'left' | 'center' | 'right';
|
||||
|
||||
export const DEFAULT_COLUMN_ORDER = [
|
||||
'File Name',
|
||||
'Size',
|
||||
'Status',
|
||||
'Speed',
|
||||
'ETA',
|
||||
'Date Added',
|
||||
] as const satisfies readonly DownloadTableColumnKey[];
|
||||
|
||||
export const DEFAULT_COLUMN_WIDTHS = [340, 100, 220, 100, 80, 170] as const;
|
||||
export const COLUMN_MINIMUMS = [160, 58, 92, 58, 48, 144] as const;
|
||||
// Width of the fixed action rail shown while hovering a row.
|
||||
export const DOWNLOAD_ACTIONS_COLUMN_WIDTH = 120;
|
||||
// Keep the fixed rail clear of the viewport edge and horizontal scrollbar.
|
||||
export const DOWNLOAD_ACTIONS_VIEWPORT_INSET = 8;
|
||||
|
||||
export const COLUMN_WIDTHS_STORAGE_KEY = 'firelink-download-column-widths';
|
||||
export const COLUMN_ORDER_STORAGE_KEY = 'firelink-download-column-order';
|
||||
export const COLUMN_ALIGNMENTS_STORAGE_KEY = 'firelink-download-column-alignments';
|
||||
|
||||
export const DEFAULT_COLUMN_ALIGNMENTS: Record<DownloadTableColumnKey, DownloadColumnAlignment> = {
|
||||
'File Name': 'left',
|
||||
Size: 'left',
|
||||
Status: 'left',
|
||||
Speed: 'left',
|
||||
ETA: 'left',
|
||||
'Date Added': 'left',
|
||||
};
|
||||
|
||||
export const COLUMN_ALIGNMENT_JUSTIFY: Record<DownloadColumnAlignment, 'flex-start' | 'center' | 'flex-end'> = {
|
||||
left: 'flex-start',
|
||||
center: 'center',
|
||||
right: 'flex-end',
|
||||
};
|
||||
|
||||
const isColumnKey = (value: unknown): value is DownloadTableColumnKey =>
|
||||
typeof value === 'string' && (DEFAULT_COLUMN_ORDER as readonly string[]).includes(value);
|
||||
|
||||
const isColumnAlignment = (value: unknown): value is DownloadColumnAlignment =>
|
||||
value === 'left' || value === 'center' || value === 'right';
|
||||
|
||||
export const normalizeColumnOrder = (value: unknown): DownloadTableColumnKey[] => {
|
||||
const seen = new Set<DownloadTableColumnKey>();
|
||||
const normalized: DownloadTableColumnKey[] = [];
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
for (const candidate of value) {
|
||||
if (isColumnKey(candidate) && !seen.has(candidate)) {
|
||||
seen.add(candidate);
|
||||
normalized.push(candidate);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const key of DEFAULT_COLUMN_ORDER) {
|
||||
if (!seen.has(key)) normalized.push(key);
|
||||
}
|
||||
|
||||
return normalized;
|
||||
};
|
||||
|
||||
export const normalizeColumnWidths = (value: unknown): number[] => {
|
||||
if (!Array.isArray(value) || value.length !== DEFAULT_COLUMN_WIDTHS.length) {
|
||||
return [...DEFAULT_COLUMN_WIDTHS];
|
||||
}
|
||||
|
||||
return value.map((width, index) =>
|
||||
typeof width === 'number' && Number.isFinite(width)
|
||||
? Math.max(COLUMN_MINIMUMS[index], width)
|
||||
: DEFAULT_COLUMN_WIDTHS[index]
|
||||
);
|
||||
};
|
||||
|
||||
export const normalizeColumnAlignments = (value: unknown): Record<DownloadTableColumnKey, DownloadColumnAlignment> => {
|
||||
const candidate = value && typeof value === 'object' && !Array.isArray(value)
|
||||
? value as Partial<Record<DownloadTableColumnKey, unknown>>
|
||||
: {};
|
||||
|
||||
return DEFAULT_COLUMN_ORDER.reduce((alignments, key) => {
|
||||
alignments[key] = isColumnAlignment(candidate[key])
|
||||
? candidate[key]
|
||||
: DEFAULT_COLUMN_ALIGNMENTS[key];
|
||||
return alignments;
|
||||
}, {} as Record<DownloadTableColumnKey, DownloadColumnAlignment>);
|
||||
};
|
||||
|
||||
export const columnIndex = (key: DownloadTableColumnKey): number =>
|
||||
DEFAULT_COLUMN_ORDER.indexOf(key);
|
||||
|
||||
export const buildColumnGridTemplate = (
|
||||
order: DownloadTableColumnKey[],
|
||||
widths: number[]
|
||||
): string => {
|
||||
const normalizedWidths = normalizeColumnWidths(widths);
|
||||
const orderedWidths = order.map(key => normalizedWidths[columnIndex(key)]);
|
||||
return [
|
||||
...orderedWidths.slice(0, -1).map(width => `${width}px`),
|
||||
'minmax(0, 1fr)',
|
||||
`${orderedWidths[orderedWidths.length - 1]}px`,
|
||||
].join(' ');
|
||||
};
|
||||
|
||||
export const getColumnGridColumn = (
|
||||
key: DownloadTableColumnKey,
|
||||
order: DownloadTableColumnKey[]
|
||||
): string | undefined => key === order[order.length - 1]
|
||||
? `${order.length + 1}`
|
||||
: undefined;
|
||||
@@ -0,0 +1,88 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { DownloadItem } from '../bindings/DownloadItem';
|
||||
import {
|
||||
downloadFileNamesMatch,
|
||||
downloadMediaKindsMatch,
|
||||
redactDownloadForPersistence,
|
||||
resolveDownloadConnections
|
||||
} from './downloads';
|
||||
|
||||
const item = (status: DownloadItem['status']): DownloadItem => ({
|
||||
id: 'download-1',
|
||||
url: 'https://example.com/file.bin',
|
||||
fileName: 'file.bin',
|
||||
status,
|
||||
category: 'Other',
|
||||
dateAdded: '2026-07-15T00:00:00.000Z',
|
||||
downloadedBytes: 1024,
|
||||
totalBytes: 4096,
|
||||
totalIsEstimate: false
|
||||
});
|
||||
|
||||
describe('download persistence progress snapshots', () => {
|
||||
it('does not write active byte counters on every progress event', () => {
|
||||
const persisted = redactDownloadForPersistence(item('downloading'));
|
||||
|
||||
expect(persisted.downloadedBytes).toBeUndefined();
|
||||
expect(persisted.totalBytes).toBeUndefined();
|
||||
expect(persisted.totalIsEstimate).toBeUndefined();
|
||||
});
|
||||
|
||||
it('keeps byte counters for paused snapshots', () => {
|
||||
const persisted = redactDownloadForPersistence(item('paused'));
|
||||
|
||||
expect(persisted.downloadedBytes).toBe(1024);
|
||||
expect(persisted.totalBytes).toBe(4096);
|
||||
expect(persisted.totalIsEstimate).toBe(false);
|
||||
});
|
||||
|
||||
it.each(['queued', 'staged', 'retrying', 'processing'] as const)(
|
||||
'keeps byte counters for %s snapshots',
|
||||
(status) => {
|
||||
const persisted = redactDownloadForPersistence(item(status));
|
||||
|
||||
expect(persisted.downloadedBytes).toBe(1024);
|
||||
expect(persisted.totalBytes).toBe(4096);
|
||||
expect(persisted.totalIsEstimate).toBe(false);
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
describe('download connection resolution', () => {
|
||||
it('uses a clamped fallback for legacy rows without a saved value', () => {
|
||||
expect(resolveDownloadConnections(undefined, 8)).toBe(8);
|
||||
expect(resolveDownloadConnections(undefined, 0)).toBe(1);
|
||||
expect(resolveDownloadConnections(undefined, Number.NaN)).toBe(16);
|
||||
});
|
||||
|
||||
it('clamps malformed saved values before dispatch', () => {
|
||||
expect(resolveDownloadConnections(0, 8)).toBe(1);
|
||||
expect(resolveDownloadConnections(17, 8)).toBe(16);
|
||||
expect(resolveDownloadConnections(Number.NaN, 8)).toBe(8);
|
||||
});
|
||||
});
|
||||
|
||||
describe('download filename matching', () => {
|
||||
it('matches case and path spelling while preserving the actual filename', () => {
|
||||
expect(downloadFileNamesMatch(
|
||||
'Media\\Example.Show.S01E01.MKV',
|
||||
'example.show.s01e01.mkv'
|
||||
)).toBe(true);
|
||||
});
|
||||
|
||||
it('does not collapse distinct extensions or names', () => {
|
||||
expect(downloadFileNamesMatch('example.zip', 'example.tar')).toBe(false);
|
||||
expect(downloadFileNamesMatch('example-1.zip', 'example.zip')).toBe(false);
|
||||
});
|
||||
|
||||
it('does not auto-match weak metadata fallback names', () => {
|
||||
expect(downloadFileNamesMatch('download', 'download')).toBe(false);
|
||||
expect(downloadFileNamesMatch('identifier', 'IDENTIFIER')).toBe(false);
|
||||
expect(downloadFileNamesMatch('real-file.bin', 'real-file.bin')).toBe(true);
|
||||
});
|
||||
|
||||
it('treats omitted media flags as ordinary downloads', () => {
|
||||
expect(downloadMediaKindsMatch(undefined, false)).toBe(true);
|
||||
expect(downloadMediaKindsMatch(undefined, true)).toBe(false);
|
||||
});
|
||||
});
|
||||
+68
-1
@@ -40,6 +40,37 @@ export const isActiveDownloadStatus = (status: DownloadStatus): boolean =>
|
||||
export const isTransferActiveStatus = (status: DownloadStatus): boolean =>
|
||||
status === 'downloading' || status === 'processing' || status === 'retrying';
|
||||
|
||||
export const DOWNLOAD_CONNECTIONS_MIN = 1;
|
||||
export const DOWNLOAD_CONNECTIONS_MAX = 16;
|
||||
|
||||
/**
|
||||
* Resolve persisted/user-entered connection values before they cross into the
|
||||
* backend. Older rows may omit the value, while malformed rows can contain
|
||||
* zero, NaN, or an out-of-range number.
|
||||
*/
|
||||
export const resolveDownloadConnections = (value: unknown, fallback: unknown): number => {
|
||||
const toFiniteInteger = (candidate: unknown): number | undefined => {
|
||||
if (typeof candidate === 'number') {
|
||||
return Number.isFinite(candidate) ? Math.trunc(candidate) : undefined;
|
||||
}
|
||||
if (typeof candidate === 'string' && candidate.trim() !== '') {
|
||||
const parsed = Number(candidate);
|
||||
return Number.isFinite(parsed) ? Math.trunc(parsed) : undefined;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
const normalizedFallback = toFiniteInteger(fallback) ?? DOWNLOAD_CONNECTIONS_MAX;
|
||||
const safeFallback = Math.min(
|
||||
DOWNLOAD_CONNECTIONS_MAX,
|
||||
Math.max(DOWNLOAD_CONNECTIONS_MIN, normalizedFallback)
|
||||
);
|
||||
const candidate = toFiniteInteger(value) ?? safeFallback;
|
||||
return Math.min(
|
||||
DOWNLOAD_CONNECTIONS_MAX,
|
||||
Math.max(DOWNLOAD_CONNECTIONS_MIN, candidate)
|
||||
);
|
||||
};
|
||||
|
||||
export const normalizeSpeedLimitForBackend = (value?: string | null): string | null => {
|
||||
const trimmed = value?.trim();
|
||||
if (!trimmed) return null;
|
||||
@@ -101,6 +132,29 @@ export const canonicalizeDownloadFileName = (fileName: string): string => {
|
||||
return sanitized && sanitized !== '.' && sanitized !== '..' ? sanitized : 'download';
|
||||
};
|
||||
|
||||
/**
|
||||
* Compare metadata-derived names without allowing path spelling or case to
|
||||
* turn the same download into a second queue entry. Keep the extension and
|
||||
* the rest of the name intact: URL query strings are not part of this value.
|
||||
*/
|
||||
export const normalizeDownloadFileNameForMatch = (fileName: string): string =>
|
||||
canonicalizeDownloadFileName(fileName).normalize('NFKC').toLowerCase();
|
||||
|
||||
export const downloadMediaKindsMatch = (
|
||||
left: boolean | undefined,
|
||||
right: boolean | undefined
|
||||
): boolean => Boolean(left) === Boolean(right);
|
||||
|
||||
const WEAK_DOWNLOAD_FILE_NAMES = new Set(['download', 'identifier', 'view', 'uc']);
|
||||
|
||||
export const downloadFileNamesMatch = (left: string, right: string): boolean => {
|
||||
const normalizedLeft = normalizeDownloadFileNameForMatch(left);
|
||||
const normalizedRight = normalizeDownloadFileNameForMatch(right);
|
||||
return !WEAK_DOWNLOAD_FILE_NAMES.has(normalizedLeft)
|
||||
&& !WEAK_DOWNLOAD_FILE_NAMES.has(normalizedRight)
|
||||
&& normalizedLeft === normalizedRight;
|
||||
};
|
||||
|
||||
export const isMediaUrl = (rawUrl: string): boolean => {
|
||||
try {
|
||||
const url = new URL(rawUrl);
|
||||
@@ -119,11 +173,19 @@ export const isMediaUrl = (rawUrl: string): boolean => {
|
||||
* persistence boundary so the user-data database contains no plaintext credentials.
|
||||
*/
|
||||
const DOWNLOAD_SECRET_FIELDS = ['password', 'cookies', 'headers'] as const;
|
||||
const VOLATILE_PROGRESS_STATUSES = new Set([
|
||||
'downloading'
|
||||
]);
|
||||
|
||||
/**
|
||||
* Returns a shallow copy of `item` with secret fields removed. Volatile
|
||||
* progress fields (`fraction`, `speed`, `eta`) are also dropped as in the
|
||||
* existing persistence path.
|
||||
* existing persistence path. Numeric byte totals remain for paused, failed,
|
||||
* and completed rows so those snapshots keep their accurate Size-column
|
||||
* display after restart; counters for the actively ticking `downloading`
|
||||
* state stay in memory to avoid a database write for every progress tick.
|
||||
* Non-ticking states retain counters so paused, queued, staged, retrying, and
|
||||
* processing snapshots remain useful across restart and reconfiguration.
|
||||
*
|
||||
* Note: standard persistence intentionally retains `url` because it is the
|
||||
* download source. The backend applies a stricter portable-mode policy: URL
|
||||
@@ -135,6 +197,11 @@ export const redactDownloadForPersistence = (item: DownloadItem): DownloadItem =
|
||||
delete copy.fraction;
|
||||
delete copy.speed;
|
||||
delete copy.eta;
|
||||
if (VOLATILE_PROGRESS_STATUSES.has(item.status)) {
|
||||
delete copy.downloadedBytes;
|
||||
delete copy.totalBytes;
|
||||
delete copy.totalIsEstimate;
|
||||
}
|
||||
for (const field of DOWNLOAD_SECRET_FIELDS) {
|
||||
delete copy[field];
|
||||
}
|
||||
|
||||
@@ -1,7 +1,52 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { getKeychainStartupDecision } from './keychainStartup';
|
||||
import {
|
||||
getKeychainAccessReady,
|
||||
getKeychainConsentVersion,
|
||||
getKeychainStartupDecision
|
||||
} from './keychainStartup';
|
||||
|
||||
describe('getKeychainStartupDecision', () => {
|
||||
it('keeps portable site credentials gated until system-store access is granted', () => {
|
||||
expect(getKeychainAccessReady({
|
||||
portable: true,
|
||||
accessGranted: false,
|
||||
persistent: true
|
||||
})).toBe(false);
|
||||
expect(getKeychainAccessReady({
|
||||
portable: true,
|
||||
accessGranted: true,
|
||||
persistent: true
|
||||
})).toBe(true);
|
||||
});
|
||||
|
||||
it('uses persistent pairing state for standard-mode readiness', () => {
|
||||
expect(getKeychainAccessReady({
|
||||
portable: false,
|
||||
accessGranted: false,
|
||||
persistent: true
|
||||
})).toBe(true);
|
||||
});
|
||||
|
||||
it('changes the consent identity when the credential-access policy changes', () => {
|
||||
expect(getKeychainConsentVersion('1.1.0')).toMatch(
|
||||
/^1\.1\.0\|(build-.+|keychain-policy-2)$/
|
||||
);
|
||||
expect(getKeychainConsentVersion('')).toBe('');
|
||||
});
|
||||
|
||||
it('re-prompts when the app build keeps the same semantic version', () => {
|
||||
expect(getKeychainStartupDecision({
|
||||
portable: false,
|
||||
appVersion: getKeychainConsentVersion('1.1.0'),
|
||||
approvedVersion: '1.1.0',
|
||||
accessGranted: true,
|
||||
promptDismissed: true
|
||||
})).toEqual({
|
||||
deferKeychainHydration: true,
|
||||
showKeychainPrompt: true
|
||||
});
|
||||
});
|
||||
|
||||
it('defers persistent hydration and shows the explanation after an update', () => {
|
||||
expect(getKeychainStartupDecision({
|
||||
portable: false,
|
||||
@@ -80,6 +125,19 @@ describe('getKeychainStartupDecision', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps persistent access deferred without prompting when the version API is unavailable', () => {
|
||||
expect(getKeychainStartupDecision({
|
||||
portable: false,
|
||||
appVersion: '',
|
||||
approvedVersion: '1.0.5',
|
||||
accessGranted: true,
|
||||
promptDismissed: false
|
||||
})).toEqual({
|
||||
deferKeychainHydration: true,
|
||||
showKeychainPrompt: false
|
||||
});
|
||||
});
|
||||
|
||||
it('never gates portable pairing on credential-store access', () => {
|
||||
expect(getKeychainStartupDecision({
|
||||
portable: true,
|
||||
|
||||
@@ -11,6 +11,39 @@ export type KeychainStartupDecision = {
|
||||
showKeychainPrompt: boolean;
|
||||
};
|
||||
|
||||
export type KeychainAccessReadiness = {
|
||||
portable: boolean;
|
||||
accessGranted: boolean;
|
||||
persistent: boolean;
|
||||
};
|
||||
|
||||
// The semantic app version can remain unchanged across release-candidate and
|
||||
// packaging rebuilds. The Tauri packaging hook appends a fresh artifact nonce
|
||||
// to the build identity, so replacing the binary cannot skip Firelink's
|
||||
// explanation and invoke the OS prompt directly. The policy epoch remains
|
||||
// only as a safe fallback for builds created outside the Git checkout.
|
||||
const KEYCHAIN_CONSENT_POLICY_VERSION = '2';
|
||||
const buildId = typeof import.meta.env.VITE_BUILD_ID === 'string'
|
||||
? import.meta.env.VITE_BUILD_ID.trim()
|
||||
: '';
|
||||
|
||||
export const getKeychainConsentVersion = (appVersion: string): string => {
|
||||
const normalizedVersion = appVersion.trim();
|
||||
const consentIdentity = buildId && buildId !== 'unknown'
|
||||
? `build-${buildId}`
|
||||
: `keychain-policy-${KEYCHAIN_CONSENT_POLICY_VERSION}`;
|
||||
return normalizedVersion
|
||||
? `${normalizedVersion}|${consentIdentity}`
|
||||
: '';
|
||||
};
|
||||
|
||||
export const getKeychainAccessReady = ({
|
||||
portable,
|
||||
accessGranted,
|
||||
persistent
|
||||
}: KeychainAccessReadiness): boolean =>
|
||||
portable ? accessGranted : persistent;
|
||||
|
||||
export const getKeychainStartupDecision = ({
|
||||
portable,
|
||||
appVersion,
|
||||
@@ -25,9 +58,11 @@ export const getKeychainStartupDecision = ({
|
||||
};
|
||||
}
|
||||
|
||||
const versionChanged = Boolean(appVersion) && approvedVersion !== appVersion;
|
||||
const versionKnown = Boolean(appVersion.trim());
|
||||
const versionChanged = versionKnown && approvedVersion !== appVersion;
|
||||
const mustDeferAccess = !accessGranted || !versionKnown || versionChanged;
|
||||
return {
|
||||
deferKeychainHydration: !accessGranted || versionChanged,
|
||||
deferKeychainHydration: mustDeferAccess,
|
||||
showKeychainPrompt: versionChanged || (!accessGranted && !promptDismissed)
|
||||
};
|
||||
};
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
mergeLogSnapshotAndLiveEntries,
|
||||
persistedLogEntry,
|
||||
pushBoundedLogEntry,
|
||||
redactLogText,
|
||||
type LogEntry
|
||||
} from './logEntries';
|
||||
|
||||
@@ -30,6 +31,29 @@ describe('log entry streaming', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('redacts live secrets, signed URL components, and the home path', () => {
|
||||
const message = 'Cookie: session=secret; https://example.com/file?token=signed https://example.com/file#fragment /Users/nima/Downloads/file';
|
||||
const redacted = redactLogText(message, '/Users/nima');
|
||||
|
||||
expect(redacted).toBe('Cookie: [redacted]; https://example.com/file?[redacted] https://example.com/file#[redacted] <HOME>/Downloads/file');
|
||||
expect(redacted).not.toContain('secret');
|
||||
expect(redacted).not.toContain('signed');
|
||||
expect(redacted).not.toContain('/Users/nima');
|
||||
});
|
||||
|
||||
it('redacts live content before it is formatted for display', () => {
|
||||
expect(liveLogEntry(3, 'Authorization: Bearer secret').message).toContain('Authorization: [redacted]');
|
||||
expect(liveLogEntry(3, 'Authorization: Bearer secret').message).not.toContain('secret');
|
||||
});
|
||||
|
||||
it('redacts persisted content and quoted credential fields', () => {
|
||||
const persisted = persistedLogEntry('{"api_key":"json-secret","path":"/Users/nima/file"}', '/Users/nima');
|
||||
|
||||
expect(persisted.message).toContain('api_key');
|
||||
expect(persisted.message).not.toContain('json-secret');
|
||||
expect(persisted.message).not.toContain('/Users/nima');
|
||||
});
|
||||
|
||||
it('merges only the ordered snapshot-to-stream overlap', () => {
|
||||
expect(mergeLogSnapshotAndLiveEntries(
|
||||
[entry('one'), entry('repeat'), entry('three')],
|
||||
|
||||
+43
-7
@@ -19,24 +19,60 @@ const LIVE_LEVELS: Record<number, LogLevel> = {
|
||||
const levelFromMessage = (message: string): LogLevel | undefined =>
|
||||
LEVEL_NAMES.find(level => message.includes(`[${level.toUpperCase()}]`));
|
||||
|
||||
export const persistedLogEntry = (message: string): LogEntry => ({
|
||||
export const persistedLogEntry = (message: string, homePath = ''): LogEntry => ({
|
||||
level: levelFromMessage(message) || 'Debug',
|
||||
message
|
||||
message: redactLogText(message, homePath)
|
||||
});
|
||||
|
||||
export const redactLogText = (message: string, homePath = ''): string => {
|
||||
const normalizedHome = homePath.trim();
|
||||
const normalizedHomeWithForwardSlashes = normalizedHome.replace(/\\/g, '/');
|
||||
let redacted = message;
|
||||
|
||||
if (normalizedHome) {
|
||||
redacted = redacted.split(normalizedHome).join('<HOME>');
|
||||
}
|
||||
if (normalizedHomeWithForwardSlashes && normalizedHomeWithForwardSlashes !== normalizedHome) {
|
||||
redacted = redacted.split(normalizedHomeWithForwardSlashes).join('<HOME>');
|
||||
}
|
||||
|
||||
redacted = redacted.replace(
|
||||
/(["'])(authorization|proxy-authorization|cookie|set-cookie|password|token|secret|credential|pairing[-_ ]?token|api[-_ ]?key)(["'])(\s*[:=]\s*)["'][^"\r\n,;]*["']/gi,
|
||||
'$1$2$3$4[redacted]'
|
||||
);
|
||||
redacted = redacted.replace(
|
||||
/([A-Za-z][A-Za-z0-9+.-]*:\/\/)[^@\s/?#]+@/g,
|
||||
'$1[redacted]@'
|
||||
);
|
||||
redacted = redacted.replace(
|
||||
/([A-Za-z][A-Za-z0-9+.-]*:\/\/[^\s?]+)\?[^\s]+/g,
|
||||
'$1?[redacted]'
|
||||
);
|
||||
redacted = redacted.replace(
|
||||
/([A-Za-z][A-Za-z0-9+.-]*:\/\/[^\s?#]+)#\S+/g,
|
||||
'$1#[redacted]'
|
||||
);
|
||||
return redacted.replace(
|
||||
/(authorization|proxy-authorization|cookie|set-cookie|password|token|secret|credential|pairing[-_ ]?token|api[-_ ]?key)(\s*)([:=])(\s*)([^\r\n,;]+)/gi,
|
||||
'$1$2$3$4[redacted]'
|
||||
);
|
||||
};
|
||||
|
||||
export const liveLogEntry = (
|
||||
numericLevel: number,
|
||||
message: string,
|
||||
now: Date = new Date()
|
||||
now: Date = new Date(),
|
||||
homePath = ''
|
||||
): LogEntry => {
|
||||
const level = levelFromMessage(message) || LIVE_LEVELS[numericLevel] || 'Debug';
|
||||
const alreadyFormatted = /^\[\d{4}-\d{2}-\d{2}\]\[\d{2}:\d{2}:\d{2}\]\[(TRACE|DEBUG|INFO|WARN|ERROR)\]/.test(message);
|
||||
const redactedMessage = redactLogText(message, homePath);
|
||||
const level = levelFromMessage(redactedMessage) || LIVE_LEVELS[numericLevel] || 'Debug';
|
||||
const alreadyFormatted = /^\[\d{4}-\d{2}-\d{2}\]\[\d{2}:\d{2}:\d{2}\]\[(TRACE|DEBUG|INFO|WARN|ERROR)\]/.test(redactedMessage);
|
||||
|
||||
return {
|
||||
level,
|
||||
message: alreadyFormatted
|
||||
? message
|
||||
: `[${now.toISOString().replace('T', ' ').substring(0, 19)}] [${level.toUpperCase()}] ${message}`
|
||||
? redactedMessage
|
||||
: `[${now.toISOString().replace('T', ' ').substring(0, 19)}] [${level.toUpperCase()}] ${redactedMessage}`
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { invokeCommand as invoke } from '../ipc';
|
||||
import type { MediaMetadata } from '../bindings/MediaMetadata';
|
||||
import type { MediaPlaylistMetadata } from '../bindings/MediaPlaylistMetadata';
|
||||
|
||||
type FetchMediaMetadataArgs = {
|
||||
url: string;
|
||||
@@ -13,6 +14,7 @@ type FetchMediaMetadataArgs = {
|
||||
};
|
||||
|
||||
const inFlightMediaMetadata = new Map<string, Promise<MediaMetadata>>();
|
||||
const inFlightMediaPlaylists = new Map<string, Promise<MediaPlaylistMetadata>>();
|
||||
|
||||
const metadataKey = (args: FetchMediaMetadataArgs) =>
|
||||
JSON.stringify([
|
||||
@@ -38,3 +40,18 @@ export const fetchMediaMetadataDeduped = (args: FetchMediaMetadataArgs): Promise
|
||||
inFlightMediaMetadata.set(key, request);
|
||||
return request;
|
||||
};
|
||||
|
||||
export const fetchMediaPlaylistMetadataDeduped = (
|
||||
args: FetchMediaMetadataArgs
|
||||
): Promise<MediaPlaylistMetadata> => {
|
||||
const key = metadataKey(args);
|
||||
const existing = inFlightMediaPlaylists.get(key);
|
||||
if (existing) return existing;
|
||||
|
||||
const request = invoke('fetch_media_playlist_metadata', args)
|
||||
.finally(() => {
|
||||
inFlightMediaPlaylists.delete(key);
|
||||
});
|
||||
inFlightMediaPlaylists.set(key, request);
|
||||
return request;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { shouldUseCustomWindowControls } from './platform';
|
||||
|
||||
describe('shouldUseCustomWindowControls', () => {
|
||||
it('keeps custom controls present while Windows/Linux detection is unresolved', () => {
|
||||
expect(shouldUseCustomWindowControls('unknown', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)')).toBe(true);
|
||||
expect(shouldUseCustomWindowControls('unknown', 'Mozilla/5.0 (X11; Linux x86_64)')).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps custom controls present for macOS while platform detection is unresolved', () => {
|
||||
expect(shouldUseCustomWindowControls('unknown', 'Mozilla/5.0 (Macintosh; Intel Mac OS X 14_0)')).toBe(true);
|
||||
expect(shouldUseCustomWindowControls('macos', 'Mozilla/5.0 (Macintosh; Intel Mac OS X 14_0)')).toBe(true);
|
||||
});
|
||||
|
||||
it('only opts into the supported desktop platforms', () => {
|
||||
expect(shouldUseCustomWindowControls('windows', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)')).toBe(true);
|
||||
expect(shouldUseCustomWindowControls('linux', 'Mozilla/5.0 (X11; Linux x86_64)')).toBe(true);
|
||||
expect(shouldUseCustomWindowControls('macos', 'Mozilla/5.0 (Macintosh; Intel Mac OS X 14_0)')).toBe(true);
|
||||
expect(shouldUseCustomWindowControls('android', 'Mozilla/5.0 (Linux; Android 14)')).toBe(false);
|
||||
expect(shouldUseCustomWindowControls('unknown', 'Mozilla/5.0 (Linux; Android 14; Mobile)')).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -9,6 +9,16 @@ const fallback: PlatformInfo = {
|
||||
portable: false
|
||||
};
|
||||
|
||||
export const shouldUseCustomWindowControls = (os: string, userAgent: string): boolean => {
|
||||
if (os === 'windows' || os === 'linux' || os === 'macos') return true;
|
||||
if (os !== 'unknown') return false;
|
||||
|
||||
// Keep the custom titlebar visible while the native platform query is
|
||||
// resolving. Mobile user agents are the only unknown targets that must not
|
||||
// receive desktop window controls.
|
||||
return !/Android|iPhone|iPad|iPod|Mobile/i.test(userAgent);
|
||||
};
|
||||
|
||||
let cached: PlatformInfo | null = null;
|
||||
let pending: Promise<PlatformInfo> | null = null;
|
||||
|
||||
|
||||
@@ -1,13 +1,72 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
import { createHash } from "node:crypto";
|
||||
import { existsSync, lstatSync, readdirSync, readFileSync, statSync } from "node:fs";
|
||||
import { relative, resolve } from "node:path";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import tailwindcss from '@tailwindcss/vite';
|
||||
|
||||
// @ts-expect-error process is a nodejs global
|
||||
const host = process.env.TAURI_DEV_HOST;
|
||||
|
||||
const buildId = (() => {
|
||||
// Release-candidate builds can keep the same semantic app version. The
|
||||
// consent identity must represent the actual input to this build, not only
|
||||
// the last committed revision: local rebuilds, untracked source files, and
|
||||
// packaged working trees can have different code-signing identities while
|
||||
// sharing the same HEAD or having no Git metadata at all.
|
||||
const configured = process.env.VITE_BUILD_ID?.trim();
|
||||
if (configured) return configured;
|
||||
|
||||
const projectRoot = process.cwd();
|
||||
const inputRoots = [
|
||||
'src',
|
||||
'src-tauri/src',
|
||||
'src-tauri/capabilities',
|
||||
'src-tauri/Cargo.toml',
|
||||
'src-tauri/Cargo.lock',
|
||||
'src-tauri/build.rs',
|
||||
'src-tauri/tauri.conf.json',
|
||||
'src-tauri/tauri.linux.conf.json',
|
||||
'src-tauri/tauri.macos.conf.json',
|
||||
'src-tauri/tauri.windows.conf.json',
|
||||
'index.html',
|
||||
'package.json',
|
||||
'package-lock.json',
|
||||
'vite.config.ts'
|
||||
];
|
||||
const files: string[] = [];
|
||||
const collectFiles = (path: string) => {
|
||||
if (!existsSync(path) || lstatSync(path).isSymbolicLink()) return;
|
||||
const stats = statSync(path);
|
||||
if (stats.isFile()) {
|
||||
files.push(path);
|
||||
return;
|
||||
}
|
||||
if (!stats.isDirectory()) return;
|
||||
for (const entry of readdirSync(path).sort()) {
|
||||
collectFiles(resolve(path, entry));
|
||||
}
|
||||
};
|
||||
|
||||
inputRoots.forEach(input => collectFiles(resolve(projectRoot, input)));
|
||||
if (files.length === 0) return process.env.GITHUB_SHA?.trim() || 'unknown';
|
||||
|
||||
const fingerprint = createHash('sha256');
|
||||
for (const file of files.sort()) {
|
||||
fingerprint.update(relative(projectRoot, file));
|
||||
fingerprint.update('\0');
|
||||
fingerprint.update(readFileSync(file));
|
||||
fingerprint.update('\0');
|
||||
}
|
||||
return fingerprint.digest('hex').slice(0, 24);
|
||||
})();
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig(async () => ({
|
||||
plugins: [react(), tailwindcss()],
|
||||
define: {
|
||||
'import.meta.env.VITE_BUILD_ID': JSON.stringify(buildId)
|
||||
},
|
||||
test: {
|
||||
exclude: [
|
||||
"Extensions/**",
|
||||
|
||||
Reference in New Issue
Block a user