mirror of
https://github.com/nimbold/Firelink.git
synced 2026-07-27 12:29:29 +00:00
Compare commits
79 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5db9388d07 | |||
| 61dfcf5d8a | |||
| d5c3fc8b27 | |||
| d895fb1b73 | |||
| 5372a76eda | |||
| 54693456b1 | |||
| edc0c1c6ad | |||
| c10b889a41 | |||
| 530324ab9c | |||
| 240055497d | |||
| 7787f4e6c0 | |||
| cf09bfa2e4 | |||
| 931e1f2f1e | |||
| 956b70dc10 | |||
| dd7c2196fe | |||
| d62e0429a4 | |||
| f3af5b0584 | |||
| 5614022dbd | |||
| b1659fa3d0 | |||
| 9a40b30a0f | |||
| 4f89a6c79d | |||
| f5d918ec1e | |||
| b579c35e42 | |||
| 1f1ec828f9 | |||
| 228db1eaa2 | |||
| 9a790073f5 | |||
| f3eff02069 | |||
| 4374219685 | |||
| 4e3ceb21a5 | |||
| 6adbccdf43 | |||
| 454fad712b | |||
| 7f450a802f | |||
| f2b1fb9d4a | |||
| 9302911ac7 | |||
| 18e9200b74 | |||
| 3559c69968 | |||
| 3587fb0c0d | |||
| b60818d3af | |||
| 5463d27ed3 | |||
| 3f298715a6 | |||
| ceab8a5fdf | |||
| f4e5e211cc | |||
| 9d737598c6 | |||
| d3e5e8c208 | |||
| 17eb8e5d39 | |||
| 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 |
@@ -44,6 +44,8 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
node-version: 22
|
node-version: 22
|
||||||
cache: npm
|
cache: npm
|
||||||
|
- name: Verify release version
|
||||||
|
run: node scripts/verify-release-version.js
|
||||||
- uses: dtolnay/rust-toolchain@stable
|
- uses: dtolnay/rust-toolchain@stable
|
||||||
with:
|
with:
|
||||||
targets: ${{ matrix.target }}
|
targets: ${{ matrix.target }}
|
||||||
@@ -123,7 +125,8 @@ jobs:
|
|||||||
if (-not $installer) { throw "Windows NSIS installer artifact was not produced." }
|
if (-not $installer) { throw "Windows NSIS installer artifact was not produced." }
|
||||||
$extractRoot = "$env:RUNNER_TEMP/firelink-installer"
|
$extractRoot = "$env:RUNNER_TEMP/firelink-installer"
|
||||||
Remove-Item -Recurse -Force $extractRoot -ErrorAction SilentlyContinue
|
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 }}
|
node scripts/verify-binaries.js --search-root "$extractRoot" --target ${{ matrix.target }}
|
||||||
|
|
||||||
$portableRoot = "$env:RUNNER_TEMP/firelink-portable-payload"
|
$portableRoot = "$env:RUNNER_TEMP/firelink-portable-payload"
|
||||||
@@ -249,11 +252,12 @@ jobs:
|
|||||||
local pattern="$1"
|
local pattern="$1"
|
||||||
local destination="$2"
|
local destination="$2"
|
||||||
local source
|
local source
|
||||||
source="$(find release-assets -maxdepth 1 -type f -name "$pattern" -print -quit)"
|
mapfile -d '' -t matches < <(find release-assets -maxdepth 1 -type f -name "$pattern" -print0)
|
||||||
if [[ -z "$source" ]]; then
|
if (( ${#matches[@]} != 1 )); then
|
||||||
echo "::error::Missing release asset matching $pattern"
|
echo "::error::Expected exactly one release asset matching $pattern, found ${#matches[@]}"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
source="${matches[0]}"
|
||||||
mv "$source" "release-assets/$destination"
|
mv "$source" "release-assets/$destination"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -18,8 +18,13 @@ CROSS_PLATFORM_CHECKLIST.md
|
|||||||
Cross-platform-checklist-gemini.MD
|
Cross-platform-checklist-gemini.MD
|
||||||
YouTube_media_download_handoff.md
|
YouTube_media_download_handoff.md
|
||||||
Release_checklist.md
|
Release_checklist.md
|
||||||
|
Release Checklist/
|
||||||
|
RC Read-only/
|
||||||
|
RC Read_only/
|
||||||
|
|
||||||
# Frontend output and logs
|
# Frontend output and logs
|
||||||
|
.agents/
|
||||||
|
release_notes.md
|
||||||
node_modules/
|
node_modules/
|
||||||
dist/
|
dist/
|
||||||
dist-ssr/
|
dist-ssr/
|
||||||
|
|||||||
@@ -5,6 +5,37 @@ 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/),
|
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).
|
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, responding to the Chinese-language request in [#17](https://github.com/nimbold/Firelink/issues/17). 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, addressing [#23](https://github.com/nimbold/Firelink/issues/23).
|
||||||
|
- **Per-download connection controls** for normal and media transfers, with the active aria2 connection count shown in the download table. This also follows the connection-setting report in [#20](https://github.com/nimbold/Firelink/issues/20).
|
||||||
|
- **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, addressing [#26](https://github.com/nimbold/Firelink/issues/26), 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, addressing the media reports in [#22](https://github.com/nimbold/Firelink/issues/22) and [#24](https://github.com/nimbold/Firelink/issues/24).
|
||||||
|
- 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
|
## [1.1.1] - 2026-07-17
|
||||||
|
|
||||||
This patch release focuses on transfer reliability, browser captures, and easier download control.
|
This patch release focuses on transfer reliability, browser captures, and easier download control.
|
||||||
|
|||||||
+1
-1
Submodule Extensions/Browser updated: 656ef06d15...b5118f1009
@@ -1,121 +1,89 @@
|
|||||||
<div align="center">
|
<p align="center">
|
||||||
<img src="src/assets/app-icon.png" alt="Firelink" width="112" height="112" />
|
<img src="src/assets/app-icon.png" alt="Firelink app icon" width="128" height="128" />
|
||||||
|
</p>
|
||||||
|
|
||||||
# Firelink
|
# Firelink
|
||||||
|
|
||||||
**A fast, focused desktop download manager for macOS, Windows, and Linux.**
|
> A fast, focused desktop download manager for macOS, Windows, and Linux.
|
||||||
|
|
||||||
[](https://github.com/nimbold/Firelink/releases)
|
[](https://github.com/nimbold/Firelink/releases/latest)
|
||||||
[](#platforms)
|
[](https://github.com/nimbold/Firelink/actions/workflows/ci.yml)
|
||||||
[](#platforms)
|
[](LICENSE)
|
||||||
[](#platforms)
|
[](#installation)
|
||||||
[](LICENSE)
|
[](#installation)
|
||||||
[](https://github.com/nimbold/Firelink/actions/workflows/ci.yml)
|
[](#installation)
|
||||||
|
|
||||||
[Features](#features) · [Install](#installation) · [Browser Extension](#browser-extension) · [Development](#development) · [Release Notes](CHANGELOG.md)
|
<p align="center">
|
||||||
</div>
|
|
||||||
|
|
||||||
<br/>
|
|
||||||
|
|
||||||
<div align="center">
|
|
||||||
<img src="Screenshots/Dark%20theme%20-%20main.png" width="24%" alt="Firelink dark theme main window" />
|
<img src="Screenshots/Dark%20theme%20-%20main.png" width="24%" alt="Firelink dark theme main window" />
|
||||||
<img src="Screenshots/Dark%20theme%20-%20add%20window.png" width="24%" alt="Firelink dark theme add window" />
|
<img src="Screenshots/Dark%20theme%20-%20add%20window.png" width="24%" alt="Firelink dark theme add window" />
|
||||||
<img src="Screenshots/Light%20theme%20-%20main.png" width="24%" alt="Firelink light theme main window" />
|
<img src="Screenshots/Light%20theme%20-%20main.png" width="24%" alt="Firelink light theme main window" />
|
||||||
<img src="Screenshots/Light%20theme%20-%20add%20window.png" width="24%" alt="Firelink light theme add window" />
|
<img src="Screenshots/Light%20theme%20-%20add%20window.png" width="24%" alt="Firelink light theme add window" />
|
||||||
|
</p>
|
||||||
|
|
||||||
<details>
|
## What is Firelink?
|
||||||
<summary><b>View more screenshots</b></summary>
|
|
||||||
<br/>
|
|
||||||
<img src="Screenshots/Dark%20theme%20-%20settings.png" width="32%" alt="Firelink dark theme settings" />
|
|
||||||
<img src="Screenshots/Light%20theme%20-%20settings.png" width="32%" alt="Firelink light theme settings" />
|
|
||||||
</details>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
## Why Firelink
|
Firelink manages direct downloads, browser captures, media, playlists, queues, and scheduling from one desktop app.
|
||||||
|
|
||||||
Firelink is a cross-platform desktop download manager for direct transfers, browser capture, media extraction, scheduling, and clear file placement.
|
It uses a Rust and Tauri backend with a React and TypeScript interface. Required download and media engines are bundled with the app.
|
||||||
|
|
||||||
It combines a Rust/Tauri backend with a React and TypeScript interface. Bundled aria2, yt-dlp, FFmpeg, Deno, and SQLite support the download and media workflows.
|
## Status
|
||||||
|
|
||||||
The current desktop release is **1.1.1**, paired with Firelink Companion **2.0.5**.
|
Firelink `1.2.0` is the latest desktop release.
|
||||||
|
|
||||||
This release adds YouTube playlist downloads, live connection controls, clipboard capture, and clearer byte-level progress. It also improves slow-transfer recovery, authenticated browser captures, and startup consent handling.
|
It is paired with [Firelink Companion `2.0.6`](https://github.com/nimbold/Firelink-Extension/releases/tag/v2.0.6).
|
||||||
|
|
||||||
|
The project is actively maintained. See the [changelog](CHANGELOG.md) for release history and current work.
|
||||||
|
|
||||||
|
Translations are available for English, Simplified Chinese, Hebrew, Persian, Ukrainian, and Russian. Translation corrections are welcome.
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- **Segmented downloads** with aria2, retries, speed limits, and connection controls.
|
- Segmented HTTP and HTTPS downloads with retries, speed limits, and connection controls.
|
||||||
- **Media downloads** with yt-dlp, FFmpeg, Deno, live progress, speed, and ETA.
|
- Media and playlist downloads through yt-dlp, FFmpeg, and Deno.
|
||||||
- **Playlist downloads** for YouTube playlists with queueing and efficient large-list rendering.
|
- An Add window for metadata, duplicate handling, save locations, and download options.
|
||||||
- **Add window** for metadata, duplicates, location choices, captured links, clipboard-prefilled URLs, and live connection limits.
|
- Persistent queues with pause, resume, retry, redownload, and scheduling.
|
||||||
- **Persistent queues** with pause, resume, retry, redownload, sorting, multi-select, and bulk actions.
|
- A customizable download table with sorting, column selection, reordering, and bulk actions.
|
||||||
- **Scheduling** with start/stop windows, speed rules, and post-queue actions.
|
- File organization with categories, default folders, and per-download locations.
|
||||||
- **File organization** with categories, default folders, a collapsible Folders section, per-download overrides, and reveal/trash actions.
|
- Browser handoff through local pairing and signed requests.
|
||||||
- **Browser handoff** through local pairing, signed requests, Add window review, replay protection, and server checks.
|
- Tray controls, notifications, sounds, sleep prevention, and secure credential storage.
|
||||||
- **Desktop integration** with tray controls, notifications, sounds, sleep prevention, and secure credential storage.
|
- RTL support for Hebrew and Persian.
|
||||||
- **Diagnostics** with engine health checks, structured logs, and package verification.
|
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
Download desktop builds from [GitHub Releases](https://github.com/nimbold/Firelink/releases).
|
Download the [latest Firelink release](https://github.com/nimbold/Firelink/releases/latest).
|
||||||
|
|
||||||
| Platform | Package | Notes |
|
| Platform | Package | Notes |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| **macOS Apple silicon** | `.dmg` | Not notarized. If macOS blocks the first launch, approve Firelink in **System Settings -> Privacy & Security**. |
|
| macOS Apple silicon | `.dmg` | Ad-hoc signed and not notarized. macOS may require approval in **System Settings -> Privacy & Security**. |
|
||||||
| **Windows x64** | NSIS `.exe` installer | Unsigned. Windows SmartScreen may warn until code signing is added. |
|
| Windows x64 | NSIS `.exe` | Unsigned. Windows SmartScreen may display a warning. |
|
||||||
| **Windows x64 portable** | `.zip` archive | Extract to a writable folder and launch `firelink.exe`. See the expandable notes below. |
|
| Windows x64 portable | `.zip` | Extract to a writable folder and launch `firelink.exe`. |
|
||||||
| **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. |
|
| Linux x64 | `.deb`, `.rpm`, or `.AppImage` | Choose the package for your distribution, or use AppImage. |
|
||||||
|
|
||||||
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. No separate engine installation is required.
|
||||||
|
|
||||||
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>
|
<details>
|
||||||
<summary><strong>Windows portable ZIP notes</strong></summary>
|
<summary><strong>Windows portable ZIP notes</strong></summary>
|
||||||
|
|
||||||
The portable ZIP is an opt-in secondary distribution. Extract it to a writable folder and launch `firelink.exe`:
|
- Keep the folder writable. Avoid `Program Files` and read-only media.
|
||||||
|
- Settings, queues, logs, and WebView data are stored beside the executable in `data/`.
|
||||||
- Keep the extracted folder writable; avoid `Program Files`, read-only media, and folders that block SQLite or WebView writes.
|
- Close Firelink before moving or copying the folder.
|
||||||
- Settings, queues, logs, and WebView data stay beside `firelink.exe` under `data/`.
|
- Treat the folder as sensitive because it contains the extension pairing credential.
|
||||||
- Close Firelink before moving or copying the folder, and close the installed app before launching the portable copy.
|
|
||||||
- Credentials, browser cookies, and URL query/fragment data are not saved in portable queue records. Active downloads that depend on them must be added again after restart.
|
|
||||||
- Saved site passwords remain in the Windows credential store and are not copied into the archive.
|
|
||||||
- The folder contains the extension pairing credential, so treat it as sensitive and do not share it.
|
|
||||||
- Saved absolute download locations may need to be selected again after moving the folder to another drive.
|
|
||||||
- The installer remains the supported path for `firelink://` browser launch registration.
|
- The installer remains the supported path for `firelink://` browser launch registration.
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
## Browser Extension
|
## Browser integration
|
||||||
|
|
||||||
<p align="center">
|
[Firelink Companion](https://github.com/nimbold/Firelink-Extension) connects browser downloads, links, and media pages to Firelink.
|
||||||
<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>
|
|
||||||
|
Captured links open Firelink's Add window for review before they are started or queued.
|
||||||
<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>
|
||||||
|
<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 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="Manual install for Chromium browsers" /></a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
Firelink Companion connects browser links and downloads to the desktop app.
|
See the [Companion README](https://github.com/nimbold/Firelink-Extension#readme) for browser installation, pairing, and privacy details.
|
||||||
|
|
||||||
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 it from Mozilla Add-ons. Chromium users can load `firelink-chromium.zip` from the [extension releases](https://github.com/nimbold/Firelink-Extension/releases) with the [manual Chromium instructions](https://github.com/nimbold/Firelink-Extension#manual-chromium-installation).
|
|
||||||
|
|
||||||
Use the latest [Firelink Companion release](https://github.com/nimbold/Firelink-Extension/releases) with Firelink 1.1.1. The source is in the [Firelink-Extension repository](https://github.com/nimbold/Firelink-Extension), which this repo vendors as the `Extensions/Browser` submodule.
|
|
||||||
|
|
||||||
## Platforms
|
|
||||||
|
|
||||||
| 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. |
|
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
@@ -126,73 +94,50 @@ Use the latest [Firelink Companion release](https://github.com/nimbold/Firelink-
|
|||||||
- Rust and Cargo
|
- Rust and Cargo
|
||||||
- [Tauri 2 platform prerequisites](https://v2.tauri.app/start/prerequisites/)
|
- [Tauri 2 platform prerequisites](https://v2.tauri.app/start/prerequisites/)
|
||||||
|
|
||||||
Clone the repository with its browser-extension submodule:
|
### Quick start
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
git clone --recurse-submodules https://github.com/nimbold/Firelink.git
|
git clone --recurse-submodules https://github.com/nimbold/Firelink.git
|
||||||
cd Firelink
|
cd Firelink
|
||||||
```
|
|
||||||
|
|
||||||
Install dependencies and launch the desktop app:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
npm install
|
npm install
|
||||||
npm run tauri dev
|
npm run tauri dev
|
||||||
```
|
```
|
||||||
|
|
||||||
Run the core checks:
|
Run the main checks with:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
node --test scripts/*.node-test.js
|
node --test scripts/*.node-test.js
|
||||||
npm test -- --run
|
npm test -- --run
|
||||||
npm run build
|
npm run build
|
||||||
cd src-tauri
|
cd src-tauri && cargo test --all-targets
|
||||||
cargo test --all-targets
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Create a production bundle:
|
See [RELEASE.md](RELEASE.md) for engine provisioning, packaging, and release verification.
|
||||||
|
|
||||||
```sh
|
## Contributing and support
|
||||||
npm run tauri build
|
|
||||||
```
|
|
||||||
|
|
||||||
macOS uses locked payloads in `src-tauri/binaries`. Provision Windows and Linux payloads from checksum-pinned archives:
|
- Report bugs and request features in [GitHub Issues](https://github.com/nimbold/Firelink/issues).
|
||||||
|
- Open focused pull requests with tests for behavior changes.
|
||||||
```sh
|
- Report translation corrections in an issue or pull request.
|
||||||
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
|
|
||||||
```
|
|
||||||
|
|
||||||
## 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.
|
- Read [CHANGELOG.md](CHANGELOG.md) for release history.
|
||||||
- Review [RELEASE.md](RELEASE.md) for packaging policy and release verification.
|
|
||||||
|
|
||||||
## Technology & Credits
|
## Credits
|
||||||
|
|
||||||
Firelink is made possible by these open-source projects:
|
Core technologies:
|
||||||
|
|
||||||
- **[Tauri 2](https://tauri.app/)** for the lightweight desktop runtime
|
- [Tauri 2](https://v2.tauri.app/), [Rust](https://www.rust-lang.org/), [Tokio](https://tokio.rs/)
|
||||||
- **[Rust](https://www.rust-lang.org/)** and **[Tokio](https://tokio.rs/)** for native application logic
|
- [React](https://react.dev/), [TypeScript](https://www.typescriptlang.org/), [Zustand](https://zustand-demo.pmnd.rs/)
|
||||||
- **[React](https://react.dev/)** and **[TypeScript](https://www.typescriptlang.org/)** for the interface
|
- [SQLite](https://www.sqlite.org/)
|
||||||
- **[Zustand](https://zustand-demo.pmnd.rs/)** for frontend state management
|
|
||||||
- **[SQLite](https://www.sqlite.org/)** for persistent local data
|
Bundled engines:
|
||||||
- **[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
|
- [aria2](https://aria2.github.io/)
|
||||||
|
- [yt-dlp](https://github.com/yt-dlp/yt-dlp)
|
||||||
|
- [FFmpeg](https://ffmpeg.org/)
|
||||||
|
- [Deno](https://deno.com/)
|
||||||
|
|
||||||
|
Font licenses, engine versions, source references, and third-party notices are documented in [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md).
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
Firelink is available under the [MIT License](LICENSE).
|
Firelink is available under the [MIT License](LICENSE). Bundled tools and fonts retain their own licenses.
|
||||||
|
|||||||
@@ -4,6 +4,23 @@ Firelink distributes separate executable tools. Firelink's MIT license does not
|
|||||||
|
|
||||||
Exact versions, target hashes, sources, and build descriptions are pinned in `engines.lock.json`.
|
Exact versions, target hashes, sources, and build descriptions are pinned in `engines.lock.json`.
|
||||||
|
|
||||||
|
## Bundled fonts
|
||||||
|
|
||||||
|
Firelink bundles variable WOFF2 web fonts through Fontsource. Each font is
|
||||||
|
licensed under the SIL Open Font License, Version 1.1. The corresponding
|
||||||
|
license and author information are available from the linked project sources.
|
||||||
|
|
||||||
|
- Inter: <https://github.com/rsms/inter>
|
||||||
|
- Outfit: <https://github.com/Outfitio/Outfit-Fonts>
|
||||||
|
- Roboto: <https://github.com/googlefonts/roboto-classic>
|
||||||
|
- Vazirmatn: <https://github.com/rastikerdar/vazirmatn>
|
||||||
|
- Noto Sans Hebrew: <https://github.com/notofonts/hebrew>
|
||||||
|
- Noto Sans SC: <https://github.com/notofonts/noto-cjk>
|
||||||
|
|
||||||
|
Fontsource distribution: <https://github.com/fontsource/font-files>
|
||||||
|
|
||||||
|
License text: <https://openfontlicense.org/open-font-license-official-text/>
|
||||||
|
|
||||||
## aria2
|
## aria2
|
||||||
|
|
||||||
- Project: <https://aria2.github.io/>
|
- Project: <https://aria2.github.io/>
|
||||||
|
|||||||
+12
-12
@@ -8,14 +8,14 @@
|
|||||||
"sha256": "90254845be5282b1f4d843a873abff04f569f857f64250f833fe152b21eec152"
|
"sha256": "90254845be5282b1f4d843a873abff04f569f857f64250f833fe152b21eec152"
|
||||||
},
|
},
|
||||||
"deno": {
|
"deno": {
|
||||||
"version": "2.9.3",
|
"version": "2.9.4",
|
||||||
"url": "https://github.com/denoland/deno/releases/download/v2.9.3/deno-x86_64-pc-windows-msvc.zip",
|
"url": "https://github.com/denoland/deno/releases/download/v2.9.4/deno-x86_64-pc-windows-msvc.zip",
|
||||||
"sha256": "60343461ac5fe3a31f4ef12667f2946bb852e20655c8610aeb7e751e87f7df3a"
|
"sha256": "68ed08b05c56cf887e9aa509947dc3f468f7e12f47a13e5c1abd51d46d1453ef"
|
||||||
},
|
},
|
||||||
"ffmpeg": {
|
"ffmpeg": {
|
||||||
"version": "8.1.2-22-g94138f6973",
|
"version": "8.1.2-31-g8c9502e9b0",
|
||||||
"url": "https://github.com/BtbN/FFmpeg-Builds/releases/download/autobuild-2026-07-15-14-01/ffmpeg-n8.1.2-22-g94138f6973-win64-gpl-8.1.zip",
|
"url": "https://github.com/BtbN/FFmpeg-Builds/releases/download/autobuild-2026-07-26-13-28/ffmpeg-n8.1.2-31-g8c9502e9b0-win64-gpl-8.1.zip",
|
||||||
"sha256": "0af1621d3a2e418b86d8ff4704f5d5a70bcce611edd6c5110d092533b170952c"
|
"sha256": "2b6a26a3943892dc61ea62545253efc1495580f67346285a6b12713d70c1a2ff"
|
||||||
},
|
},
|
||||||
"aria2c": {
|
"aria2c": {
|
||||||
"version": "1.37.0",
|
"version": "1.37.0",
|
||||||
@@ -30,14 +30,14 @@
|
|||||||
"sha256": "d7d2d09e900b5ae11821b5784b18cf064984a2bd88b1ca5c798d744bcbe3658b"
|
"sha256": "d7d2d09e900b5ae11821b5784b18cf064984a2bd88b1ca5c798d744bcbe3658b"
|
||||||
},
|
},
|
||||||
"deno": {
|
"deno": {
|
||||||
"version": "2.9.3",
|
"version": "2.9.4",
|
||||||
"url": "https://github.com/denoland/deno/releases/download/v2.9.3/deno-x86_64-unknown-linux-gnu.zip",
|
"url": "https://github.com/denoland/deno/releases/download/v2.9.4/deno-x86_64-unknown-linux-gnu.zip",
|
||||||
"sha256": "8101865641cbede56f08ad19c0a67a87df84bce127fee0d3e3e1f7467717ffa6"
|
"sha256": "c24f955d9fbfe0ea5ae2b501c8e71ae76e31e4c9782390a54a284b3364fda725"
|
||||||
},
|
},
|
||||||
"ffmpeg": {
|
"ffmpeg": {
|
||||||
"version": "8.1.2-22-g94138f6973",
|
"version": "8.1.2-31-g8c9502e9b0",
|
||||||
"url": "https://github.com/BtbN/FFmpeg-Builds/releases/download/autobuild-2026-07-15-14-01/ffmpeg-n8.1.2-22-g94138f6973-linux64-gpl-8.1.tar.xz",
|
"url": "https://github.com/BtbN/FFmpeg-Builds/releases/download/autobuild-2026-07-26-13-28/ffmpeg-n8.1.2-31-g8c9502e9b0-linux64-gpl-8.1.tar.xz",
|
||||||
"sha256": "68e304f5518d3e22cd825e6fb738d0d9a04a8f36127873a18fc6646457e95b92"
|
"sha256": "00a4cce5118f477da5d6758630ecd70f71ac7601dca377d819b619613cb577a0"
|
||||||
},
|
},
|
||||||
"aria2c": {
|
"aria2c": {
|
||||||
"version": "1.37.0",
|
"version": "1.37.0",
|
||||||
|
|||||||
+2
-2
@@ -23,10 +23,10 @@
|
|||||||
"sha256": "f65b4ba782b25e9f4374765d421793c6613f692d8e50b2888079657793619034"
|
"sha256": "f65b4ba782b25e9f4374765d421793c6613f692d8e50b2888079657793619034"
|
||||||
},
|
},
|
||||||
"deno": {
|
"deno": {
|
||||||
"version": "2.9.3",
|
"version": "2.9.4",
|
||||||
"source": "https://github.com/denoland/deno",
|
"source": "https://github.com/denoland/deno",
|
||||||
"build": "official aarch64-apple-darwin executable",
|
"build": "official aarch64-apple-darwin executable",
|
||||||
"sha256": "80c83cdfb20289f8818a71220b570df363f6f0ba93580c29c70f08ab14e93568"
|
"sha256": "433088c827fa0e39ff162ab0e475f1fd4c7690eaedec500cf678edc3865e9287"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"runtimeTrees": {
|
"runtimeTrees": {
|
||||||
|
|||||||
@@ -5,9 +5,6 @@
|
|||||||
<link rel="icon" type="image/png" href="/src/assets/app-icon.png" />
|
<link rel="icon" type="image/png" href="/src/assets/app-icon.png" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Firelink Download Manager</title>
|
<title>Firelink Download Manager</title>
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&family=Outfit:wght@300;400;500;600;700&display=swap" rel="stylesheet">
|
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
Generated
+175
-51
@@ -1,35 +1,43 @@
|
|||||||
{
|
{
|
||||||
"name": "firelink",
|
"name": "firelink",
|
||||||
"version": "1.1.1",
|
"version": "1.2.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "firelink",
|
"name": "firelink",
|
||||||
"version": "1.1.1",
|
"version": "1.2.0",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@fontsource-variable/inter": "^5.3.0",
|
||||||
|
"@fontsource-variable/noto-sans-hebrew": "^5.3.0",
|
||||||
|
"@fontsource-variable/noto-sans-sc": "^5.3.0",
|
||||||
|
"@fontsource-variable/outfit": "^5.3.0",
|
||||||
|
"@fontsource-variable/roboto": "^5.3.0",
|
||||||
|
"@fontsource-variable/vazirmatn": "^5.3.0",
|
||||||
"@formkit/auto-animate": "^0.10.0",
|
"@formkit/auto-animate": "^0.10.0",
|
||||||
"@tailwindcss/vite": "^4.3.3",
|
"@tailwindcss/vite": "^4.3.3",
|
||||||
"@tauri-apps/api": "^2.11.1",
|
"@tauri-apps/api": "^2.11.1",
|
||||||
"@tauri-apps/plugin-clipboard-manager": "^2.3.2",
|
"@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-fs": "^2.5.1",
|
||||||
"@tauri-apps/plugin-log": "^2.9.0",
|
"@tauri-apps/plugin-log": "^2.9.0",
|
||||||
"@tauri-apps/plugin-notification": "^2.3.3",
|
"@tauri-apps/plugin-notification": "^2.3.3",
|
||||||
"@tauri-apps/plugin-opener": "^2",
|
"@tauri-apps/plugin-opener": "^2",
|
||||||
"lucide-react": "^1.24.0",
|
"i18next": "^26.3.6",
|
||||||
"react": "^19.1.0",
|
"lucide-react": "^1.27.0",
|
||||||
"react-dom": "^19.1.0",
|
"react": "^19.2.8",
|
||||||
|
"react-dom": "^19.2.8",
|
||||||
|
"react-i18next": "^17.0.11",
|
||||||
"zustand": "^5.0.14"
|
"zustand": "^5.0.14"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tauri-apps/cli": "^2.11.4",
|
"@tauri-apps/cli": "^2.11.4",
|
||||||
"@types/react": "^19.1.8",
|
"@types/react": "^19.2.17",
|
||||||
"@types/react-dom": "^19.1.6",
|
"@types/react-dom": "^19.2.3",
|
||||||
"@vitejs/plugin-react": "^6.0.3",
|
"@vitejs/plugin-react": "^6.0.4",
|
||||||
"autoprefixer": "^10.5.4",
|
"autoprefixer": "^10.5.4",
|
||||||
"postcss": "^8.5.19",
|
"postcss": "^8.5.23",
|
||||||
"tailwindcss": "^4.3.3",
|
"tailwindcss": "^4.3.3",
|
||||||
"typescript": "^7.0.2",
|
"typescript": "^7.0.2",
|
||||||
"vite": "^8.1.5",
|
"vite": "^8.1.5",
|
||||||
@@ -39,6 +47,15 @@
|
|||||||
"node": ">=22"
|
"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": {
|
"node_modules/@emnapi/core": {
|
||||||
"version": "1.11.1",
|
"version": "1.11.1",
|
||||||
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz",
|
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz",
|
||||||
@@ -70,6 +87,60 @@
|
|||||||
"tslib": "^2.4.0"
|
"tslib": "^2.4.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@fontsource-variable/inter": {
|
||||||
|
"version": "5.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@fontsource-variable/inter/-/inter-5.3.0.tgz",
|
||||||
|
"integrity": "sha512-OupL48va4JNofb97w6NYeF9S7W/kHNKM0Er8Dem5nqi4jeOLrVJDoE8tZEpnMJmtkvNbB1EIPPwHcdkF6b1oUA==",
|
||||||
|
"license": "OFL-1.1",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ayuhito"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@fontsource-variable/noto-sans-hebrew": {
|
||||||
|
"version": "5.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@fontsource-variable/noto-sans-hebrew/-/noto-sans-hebrew-5.3.0.tgz",
|
||||||
|
"integrity": "sha512-jGIZ2EOCTtT1ZhLthz6nEpPXGMniSPI/0tv5CDFDZ7MPYyoj6FskW7WJ0YTyesFADdzjx7F2Pg5W1PWD6y9UFw==",
|
||||||
|
"license": "OFL-1.1",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ayuhito"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@fontsource-variable/noto-sans-sc": {
|
||||||
|
"version": "5.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@fontsource-variable/noto-sans-sc/-/noto-sans-sc-5.3.0.tgz",
|
||||||
|
"integrity": "sha512-lNar1dF7Ik/lHNPo/7JWG0TolXY29LtsqYgMvEysooZ5bsO9uH4shJmRrwyJ3PjyTPljhpMJEK0jDuLSU4vJ1w==",
|
||||||
|
"license": "OFL-1.1",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ayuhito"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@fontsource-variable/outfit": {
|
||||||
|
"version": "5.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@fontsource-variable/outfit/-/outfit-5.3.0.tgz",
|
||||||
|
"integrity": "sha512-fPb5XnYK0k7SbR++HEmm4+ZqDszmLKQ+sPRUr8oQ/KNC0vy6r6hazJFe8iYozv4cqlHbvJ/JECkJWrfDodUMVQ==",
|
||||||
|
"license": "OFL-1.1",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ayuhito"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@fontsource-variable/roboto": {
|
||||||
|
"version": "5.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@fontsource-variable/roboto/-/roboto-5.3.0.tgz",
|
||||||
|
"integrity": "sha512-BoaaZiQf8fgtxv07KXkTwx1bKz/EoqIlTXq2OOKKXINxp6qXWdnlChqV+hSaDANS49yyUASFl3OVBMMDTCIHFA==",
|
||||||
|
"license": "OFL-1.1",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ayuhito"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@fontsource-variable/vazirmatn": {
|
||||||
|
"version": "5.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@fontsource-variable/vazirmatn/-/vazirmatn-5.3.0.tgz",
|
||||||
|
"integrity": "sha512-ZA68W8JHbMdNzzwjYHUWKB2PH22QpxNEnk0ukcXM+XiqYMV6ZWJZAKuNXcrMR2JjuIAwsfAU3KknCXPI6wXUvw==",
|
||||||
|
"license": "OFL-1.1",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ayuhito"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@formkit/auto-animate": {
|
"node_modules/@formkit/auto-animate": {
|
||||||
"version": "0.10.0",
|
"version": "0.10.0",
|
||||||
"resolved": "https://registry.npmjs.org/@formkit/auto-animate/-/auto-animate-0.10.0.tgz",
|
"resolved": "https://registry.npmjs.org/@formkit/auto-animate/-/auto-animate-0.10.0.tgz",
|
||||||
@@ -957,9 +1028,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@tauri-apps/plugin-dialog": {
|
"node_modules/@tauri-apps/plugin-dialog": {
|
||||||
"version": "2.7.1",
|
"version": "2.7.2",
|
||||||
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-dialog/-/plugin-dialog-2.7.1.tgz",
|
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-dialog/-/plugin-dialog-2.7.2.tgz",
|
||||||
"integrity": "sha512-OK1UBXYt+ojcmxMktzzuyonYIFta8CmAASpX+CA+DTGK24KlHjhYI6x2iOJ/TjZF4N7/ACK1oFmEOjIY9IhzOQ==",
|
"integrity": "sha512-pX0IGm1I3I6wc+zeKYcq1GSqogK6okCNX5fOdaNU5ab1AjGS6l1E5wFNjEb7meg7ZFSp0JUs+0jQGQNyOvLrsg==",
|
||||||
"license": "MIT OR Apache-2.0",
|
"license": "MIT OR Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tauri-apps/api": "^2.11.0"
|
"@tauri-apps/api": "^2.11.0"
|
||||||
@@ -1063,7 +1134,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1080,7 +1150,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1097,7 +1166,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1114,7 +1182,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1131,7 +1198,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1148,7 +1214,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1165,7 +1230,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1182,7 +1246,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"loong64"
|
"loong64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1199,7 +1262,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"mips64el"
|
"mips64el"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1216,7 +1278,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1233,7 +1294,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"riscv64"
|
"riscv64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1250,7 +1310,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"s390x"
|
"s390x"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1267,7 +1326,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1284,7 +1342,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1301,7 +1358,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1318,7 +1374,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1335,7 +1390,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1352,7 +1406,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1369,7 +1422,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1386,7 +1438,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1397,9 +1448,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@vitejs/plugin-react": {
|
"node_modules/@vitejs/plugin-react": {
|
||||||
"version": "6.0.3",
|
"version": "6.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.4.tgz",
|
||||||
"integrity": "sha512-vmFvco5/QuC2f9Oj+wTk0+9XeDFkHxSamwZKYc7MxYwKICfvUvlMhqKI0VuICPltGqh1neqBKDvO4kes1ya8vg==",
|
"integrity": "sha512-XcCQz0TBpBgljhj0gMuuDj49i6Ytqh5q1osT/Gp5uAVJUCTWxyskk/l1jwYYiu2xcNHHipdMz40EGfM1VdamVg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -1791,6 +1842,43 @@
|
|||||||
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
|
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/html-parse-stringify": {
|
||||||
|
"version": "4.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-4.0.1.tgz",
|
||||||
|
"integrity": "sha512-0zHsZJrK7S3K2aucXWL6ycoYJ/iNtIcFHC/nYQgFklPtrv5LpJctIiSCroWZWeuoXvuyFdzp6KzjJQ+OT5MfFw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://locize.com"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"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": {
|
"node_modules/jiti": {
|
||||||
"version": "2.7.0",
|
"version": "2.7.0",
|
||||||
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz",
|
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz",
|
||||||
@@ -2050,9 +2138,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/lucide-react": {
|
"node_modules/lucide-react": {
|
||||||
"version": "1.24.0",
|
"version": "1.27.0",
|
||||||
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.24.0.tgz",
|
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.27.0.tgz",
|
||||||
"integrity": "sha512-YT6mBD8lGKkg4nM39enlm94/sfJIiW0YKUT60fBy4YK8tai31ylg1VhGNWxkpSKHo9UagfnZqwIff3HTDQwXeA==",
|
"integrity": "sha512-rJicGl/3Fly/E0rOH1YmPZ6e49JCnKknh1ox1vpHnkfjujAkKA6sqUZvH3MTAaXXjgexyUwgNwTJzTtYuAFYJw==",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
@@ -2135,9 +2223,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/postcss": {
|
"node_modules/postcss": {
|
||||||
"version": "8.5.19",
|
"version": "8.5.23",
|
||||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.19.tgz",
|
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.23.tgz",
|
||||||
"integrity": "sha512-Mz8SaolMd8nB+G13WkORcxQKHZ/NE4xXevtkJHVuG+guo9/wYKlIMTKAqGdEmYOXR2ijPjTYNHssizdaVSUNdQ==",
|
"integrity": "sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==",
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
"type": "opencollective",
|
"type": "opencollective",
|
||||||
@@ -2154,7 +2242,7 @@
|
|||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"nanoid": "^3.3.12",
|
"nanoid": "^3.3.16",
|
||||||
"picocolors": "^1.1.1",
|
"picocolors": "^1.1.1",
|
||||||
"source-map-js": "^1.2.1"
|
"source-map-js": "^1.2.1"
|
||||||
},
|
},
|
||||||
@@ -2170,24 +2258,51 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/react": {
|
"node_modules/react": {
|
||||||
"version": "19.2.7",
|
"version": "19.2.8",
|
||||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz",
|
"resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz",
|
||||||
"integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==",
|
"integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/react-dom": {
|
"node_modules/react-dom": {
|
||||||
"version": "19.2.7",
|
"version": "19.2.8",
|
||||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.7.tgz",
|
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.8.tgz",
|
||||||
"integrity": "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==",
|
"integrity": "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"scheduler": "^0.27.0"
|
"scheduler": "^0.27.0"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"react": "^19.2.7"
|
"react": "^19.2.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/react-i18next": {
|
||||||
|
"version": "17.0.11",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.0.11.tgz",
|
||||||
|
"integrity": "sha512-cDtkXgxjuFTWUH6V+aQn1Ve5vDiUztCNPWW5GtSHDccsgRXO1nE6QFWCEmc1KAutrb3OUv87wFShJL5RhUwPXg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@babel/runtime": "^7.29.2",
|
||||||
|
"html-parse-stringify": "^4.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": {
|
"node_modules/rolldown": {
|
||||||
@@ -2332,7 +2447,7 @@
|
|||||||
"version": "7.0.2",
|
"version": "7.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz",
|
||||||
"integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==",
|
"integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==",
|
||||||
"dev": true,
|
"devOptional": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"bin": {
|
"bin": {
|
||||||
"tsc": "bin/tsc"
|
"tsc": "bin/tsc"
|
||||||
@@ -2394,6 +2509,15 @@
|
|||||||
"browserslist": ">= 4.21.0"
|
"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": {
|
"node_modules/vite": {
|
||||||
"version": "8.1.5",
|
"version": "8.1.5",
|
||||||
"resolved": "https://registry.npmjs.org/vite/-/vite-8.1.5.tgz",
|
"resolved": "https://registry.npmjs.org/vite/-/vite-8.1.5.tgz",
|
||||||
|
|||||||
+18
-9
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "firelink",
|
"name": "firelink",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.1.1",
|
"version": "1.2.0",
|
||||||
"description": "A fast cross-platform desktop download manager powered by Rust, Tauri, React, aria2, and yt-dlp.",
|
"description": "A fast cross-platform desktop download manager powered by Rust, Tauri, React, aria2, and yt-dlp.",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"homepage": "https://github.com/nimbold/Firelink",
|
"homepage": "https://github.com/nimbold/Firelink",
|
||||||
@@ -31,6 +31,7 @@
|
|||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"bindings": "cd src-tauri && cargo test export_bindings --lib",
|
"bindings": "cd src-tauri && cargo test export_bindings --lib",
|
||||||
"build": "tsc && vite build",
|
"build": "tsc && vite build",
|
||||||
|
"check:i18n": "vitest run src/i18n/resources.test.ts",
|
||||||
"check:updates": "node scripts/check-updates.js",
|
"check:updates": "node scripts/check-updates.js",
|
||||||
"verify:macos-signing": "node scripts/verify-macos-signing.js",
|
"verify:macos-signing": "node scripts/verify-macos-signing.js",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
@@ -38,27 +39,35 @@
|
|||||||
"test": "vitest"
|
"test": "vitest"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@fontsource-variable/inter": "^5.3.0",
|
||||||
|
"@fontsource-variable/noto-sans-hebrew": "^5.3.0",
|
||||||
|
"@fontsource-variable/noto-sans-sc": "^5.3.0",
|
||||||
|
"@fontsource-variable/outfit": "^5.3.0",
|
||||||
|
"@fontsource-variable/roboto": "^5.3.0",
|
||||||
|
"@fontsource-variable/vazirmatn": "^5.3.0",
|
||||||
"@formkit/auto-animate": "^0.10.0",
|
"@formkit/auto-animate": "^0.10.0",
|
||||||
"@tailwindcss/vite": "^4.3.3",
|
"@tailwindcss/vite": "^4.3.3",
|
||||||
"@tauri-apps/api": "^2.11.1",
|
"@tauri-apps/api": "^2.11.1",
|
||||||
"@tauri-apps/plugin-clipboard-manager": "^2.3.2",
|
"@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-fs": "^2.5.1",
|
||||||
"@tauri-apps/plugin-log": "^2.9.0",
|
"@tauri-apps/plugin-log": "^2.9.0",
|
||||||
"@tauri-apps/plugin-notification": "^2.3.3",
|
"@tauri-apps/plugin-notification": "^2.3.3",
|
||||||
"@tauri-apps/plugin-opener": "^2",
|
"@tauri-apps/plugin-opener": "^2",
|
||||||
"lucide-react": "^1.24.0",
|
"i18next": "^26.3.6",
|
||||||
"react": "^19.1.0",
|
"lucide-react": "^1.27.0",
|
||||||
"react-dom": "^19.1.0",
|
"react": "^19.2.8",
|
||||||
|
"react-dom": "^19.2.8",
|
||||||
|
"react-i18next": "^17.0.11",
|
||||||
"zustand": "^5.0.14"
|
"zustand": "^5.0.14"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tauri-apps/cli": "^2.11.4",
|
"@tauri-apps/cli": "^2.11.4",
|
||||||
"@types/react": "^19.1.8",
|
"@types/react": "^19.2.17",
|
||||||
"@types/react-dom": "^19.1.6",
|
"@types/react-dom": "^19.2.3",
|
||||||
"@vitejs/plugin-react": "^6.0.3",
|
"@vitejs/plugin-react": "^6.0.4",
|
||||||
"autoprefixer": "^10.5.4",
|
"autoprefixer": "^10.5.4",
|
||||||
"postcss": "^8.5.19",
|
"postcss": "^8.5.23",
|
||||||
"tailwindcss": "^4.3.3",
|
"tailwindcss": "^4.3.3",
|
||||||
"typescript": "^7.0.2",
|
"typescript": "^7.0.2",
|
||||||
"vite": "^8.1.5",
|
"vite": "^8.1.5",
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { spawnSync } from 'node:child_process';
|
import { spawnSync } from 'node:child_process';
|
||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
@@ -41,6 +42,14 @@ function runNpmScript(script) {
|
|||||||
run('npm', ['run', 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/stage-engines.js']);
|
||||||
run(process.execPath, ['scripts/verify-binaries.js', '--staged']);
|
run(process.execPath, ['scripts/verify-binaries.js', '--staged']);
|
||||||
|
|
||||||
|
|||||||
@@ -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
+140
-138
@@ -80,9 +80,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "anyhow"
|
name = "anyhow"
|
||||||
version = "1.0.103"
|
version = "1.0.104"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3"
|
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "apple-native-keyring-store"
|
name = "apple-native-keyring-store"
|
||||||
@@ -238,13 +238,13 @@ checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "async-trait"
|
name = "async-trait"
|
||||||
version = "0.1.89"
|
version = "0.1.91"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb"
|
checksum = "ae36dc4177970ef04fde5178d3e2429882def40e57a451f919c098f72baa6cec"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn 2.0.119",
|
"syn 3.0.3",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -463,9 +463,9 @@ checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bytemuck"
|
name = "bytemuck"
|
||||||
version = "1.25.1"
|
version = "1.25.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d6aedf8ae72766347502cf3cb4f41cf5e9cc37d28bee90f1fdaaae15f9cf9424"
|
checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "byteorder"
|
name = "byteorder"
|
||||||
@@ -542,7 +542,7 @@ dependencies = [
|
|||||||
"semver",
|
"semver",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.19",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -566,9 +566,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cc"
|
name = "cc"
|
||||||
version = "1.2.67"
|
version = "1.4.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e17dd265a7d0f31ef544e1b20e03add05d3b45b491b633b10d67145d2acc1a38"
|
checksum = "5add81bb678e6cb321aff7fa0dc7689ad82b112dbc032cea19f91d6b8e3582b9"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"find-msvc-tools",
|
"find-msvc-tools",
|
||||||
"shlex",
|
"shlex",
|
||||||
@@ -1319,9 +1319,9 @@ checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "fastrand"
|
name = "fastrand"
|
||||||
version = "2.4.1"
|
version = "2.5.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6"
|
checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "fax"
|
name = "fax"
|
||||||
@@ -1365,7 +1365,7 @@ checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "firelink"
|
name = "firelink"
|
||||||
version = "1.1.1"
|
version = "1.2.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"apple-native-keyring-store",
|
"apple-native-keyring-store",
|
||||||
"async-trait",
|
"async-trait",
|
||||||
@@ -1399,7 +1399,7 @@ dependencies = [
|
|||||||
"tauri-plugin-shell",
|
"tauri-plugin-shell",
|
||||||
"tauri-plugin-single-instance",
|
"tauri-plugin-single-instance",
|
||||||
"tempfile",
|
"tempfile",
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.19",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tokio-tungstenite",
|
"tokio-tungstenite",
|
||||||
"tower-http 0.7.0",
|
"tower-http 0.7.0",
|
||||||
@@ -1457,13 +1457,13 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "foreign-types-macros"
|
name = "foreign-types-macros"
|
||||||
version = "0.2.3"
|
version = "0.2.4"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "1a5c6c585bc94aaf2c7b51dd4c2ba22680844aba4c687be581871a6f518c5742"
|
checksum = "ea5190182e6915eb873ddbc16e23b711b6eb1f9c00a0d0a3a91b5f6228475225"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn 2.0.119",
|
"syn 3.0.3",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1483,24 +1483,24 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "futures-channel"
|
name = "futures-channel"
|
||||||
version = "0.3.32"
|
version = "0.3.33"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d"
|
checksum = "262590f4fe6afeb0bc83be1daa64e52657fe185690a958af7f3ad0e92085c5ae"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"futures-core",
|
"futures-core",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "futures-core"
|
name = "futures-core"
|
||||||
version = "0.3.32"
|
version = "0.3.33"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d"
|
checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "futures-executor"
|
name = "futures-executor"
|
||||||
version = "0.3.32"
|
version = "0.3.33"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d"
|
checksum = "6754879cc9f2c66f88c6e5c35344bb0bdb0708b0352b1201815667c7eabc7458"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"futures-core",
|
"futures-core",
|
||||||
"futures-task",
|
"futures-task",
|
||||||
@@ -1509,9 +1509,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "futures-io"
|
name = "futures-io"
|
||||||
version = "0.3.32"
|
version = "0.3.33"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718"
|
checksum = "4577ecaa3c4f96589d473f679a71b596316f6641bc350038b962a5daf0085d7a"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "futures-lite"
|
name = "futures-lite"
|
||||||
@@ -1528,9 +1528,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "futures-macro"
|
name = "futures-macro"
|
||||||
version = "0.3.32"
|
version = "0.3.33"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b"
|
checksum = "2d6d3cde68c518367be28956066ddfef33813991b77a55005a69dae04bf3b10b"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
@@ -1539,21 +1539,21 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "futures-sink"
|
name = "futures-sink"
|
||||||
version = "0.3.32"
|
version = "0.3.33"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893"
|
checksum = "e34418ac499d6305c2fb5ad0ed2f6ac998c5f8ca209b4510f7f94242c647e307"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "futures-task"
|
name = "futures-task"
|
||||||
version = "0.3.32"
|
version = "0.3.33"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393"
|
checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "futures-util"
|
name = "futures-util"
|
||||||
version = "0.3.32"
|
version = "0.3.33"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6"
|
checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"futures-core",
|
"futures-core",
|
||||||
"futures-io",
|
"futures-io",
|
||||||
@@ -1800,9 +1800,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "glob"
|
name = "glob"
|
||||||
version = "0.3.3"
|
version = "0.3.4"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280"
|
checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "gobject-sys"
|
name = "gobject-sys"
|
||||||
@@ -2058,9 +2058,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "hyper"
|
name = "hyper"
|
||||||
version = "1.10.1"
|
version = "1.11.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498"
|
checksum = "d22053281f852e11534f5198498373cbb59295120a20771d90f7ed1897490a72"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"atomic-waker",
|
"atomic-waker",
|
||||||
"bytes",
|
"bytes",
|
||||||
@@ -2412,7 +2412,7 @@ dependencies = [
|
|||||||
"jni-sys 0.4.1",
|
"jni-sys 0.4.1",
|
||||||
"log 0.4.33",
|
"log 0.4.33",
|
||||||
"simd_cesu8",
|
"simd_cesu8",
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.19",
|
||||||
"walkdir",
|
"walkdir",
|
||||||
"windows-link 0.2.1",
|
"windows-link 0.2.1",
|
||||||
]
|
]
|
||||||
@@ -2501,7 +2501,7 @@ dependencies = [
|
|||||||
"derive_builder",
|
"derive_builder",
|
||||||
"objc2-core-foundation",
|
"objc2-core-foundation",
|
||||||
"objc2-io-kit",
|
"objc2-io-kit",
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.19",
|
||||||
"windows 0.62.2",
|
"windows 0.62.2",
|
||||||
"zbus",
|
"zbus",
|
||||||
]
|
]
|
||||||
@@ -2564,9 +2564,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "libc"
|
name = "libc"
|
||||||
version = "0.2.186"
|
version = "0.2.189"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
|
checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "libdbus-sys"
|
name = "libdbus-sys"
|
||||||
@@ -2761,7 +2761,7 @@ dependencies = [
|
|||||||
"once_cell",
|
"once_cell",
|
||||||
"png 0.18.1",
|
"png 0.18.1",
|
||||||
"serde",
|
"serde",
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.19",
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -3402,7 +3402,7 @@ checksum = "7da1d65da6dd5d1e44199ac0f58712d241c0f439f80adea8924d832384087f85"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"base64 0.22.1",
|
"base64 0.22.1",
|
||||||
"indexmap 2.14.0",
|
"indexmap 2.14.0",
|
||||||
"quick-xml 0.41.0",
|
"quick-xml",
|
||||||
"serde",
|
"serde",
|
||||||
"time",
|
"time",
|
||||||
]
|
]
|
||||||
@@ -3532,9 +3532,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "proc-macro2"
|
name = "proc-macro2"
|
||||||
version = "1.0.106"
|
version = "1.0.107"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
|
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"unicode-ident",
|
"unicode-ident",
|
||||||
]
|
]
|
||||||
@@ -3557,15 +3557,6 @@ version = "2.0.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3"
|
checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "quick-xml"
|
|
||||||
version = "0.39.4"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "cdcc8dd4e2f670d309a5f0e83fe36dfdc05af317008fea29144da1a2ac858e5e"
|
|
||||||
dependencies = [
|
|
||||||
"memchr",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "quick-xml"
|
name = "quick-xml"
|
||||||
version = "0.41.0"
|
version = "0.41.0"
|
||||||
@@ -3577,9 +3568,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "quote"
|
name = "quote"
|
||||||
version = "1.0.46"
|
version = "1.0.47"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368"
|
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
]
|
]
|
||||||
@@ -3665,27 +3656,27 @@ checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"getrandom 0.2.17",
|
"getrandom 0.2.17",
|
||||||
"libredox",
|
"libredox",
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.19",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ref-cast"
|
name = "ref-cast"
|
||||||
version = "1.0.25"
|
version = "1.0.26"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d"
|
checksum = "216e8f773d7923bcba9ceb86a86c93cabb3903a11872fc3f138c49630e50b96d"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"ref-cast-impl",
|
"ref-cast-impl",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ref-cast-impl"
|
name = "ref-cast-impl"
|
||||||
version = "1.0.25"
|
version = "1.0.26"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da"
|
checksum = "2c9283685feec7d69af75fb0e858d5e7378f33fe4fc699383b2916ab9273e03c"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn 2.0.119",
|
"syn 3.0.3",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -3823,7 +3814,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "c51c9ae4df8a7fba42103df5c621fa3c37eccf3a3c650879e90fc48b11cc192c"
|
checksum = "c51c9ae4df8a7fba42103df5c621fa3c37eccf3a3c650879e90fc48b11cc192c"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"hashbrown 0.16.1",
|
"hashbrown 0.16.1",
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.19",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -3907,9 +3898,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rustls-pki-types"
|
name = "rustls-pki-types"
|
||||||
version = "1.15.0"
|
version = "1.15.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "764899a24af3980067ee14bc143654f297b22eaebfe3c7b6b211920a5a59b046"
|
checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"zeroize",
|
"zeroize",
|
||||||
]
|
]
|
||||||
@@ -4112,9 +4103,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "serde"
|
name = "serde"
|
||||||
version = "1.0.228"
|
version = "1.0.229"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
|
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"serde_core",
|
"serde_core",
|
||||||
"serde_derive",
|
"serde_derive",
|
||||||
@@ -4134,22 +4125,22 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "serde_core"
|
name = "serde_core"
|
||||||
version = "1.0.228"
|
version = "1.0.229"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
|
checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"serde_derive",
|
"serde_derive",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "serde_derive"
|
name = "serde_derive"
|
||||||
version = "1.0.228"
|
version = "1.0.229"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
|
checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn 2.0.119",
|
"syn 3.0.3",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -4165,9 +4156,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "serde_json"
|
name = "serde_json"
|
||||||
version = "1.0.150"
|
version = "1.0.151"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9"
|
checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"itoa",
|
"itoa",
|
||||||
"memchr",
|
"memchr",
|
||||||
@@ -4189,13 +4180,13 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "serde_repr"
|
name = "serde_repr"
|
||||||
version = "0.1.20"
|
version = "0.1.21"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c"
|
checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn 2.0.119",
|
"syn 3.0.3",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -4556,6 +4547,17 @@ dependencies = [
|
|||||||
"unicode-ident",
|
"unicode-ident",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "syn"
|
||||||
|
version = "3.0.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"unicode-ident",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "sync_wrapper"
|
name = "sync_wrapper"
|
||||||
version = "1.0.2"
|
version = "1.0.2"
|
||||||
@@ -4725,7 +4727,7 @@ dependencies = [
|
|||||||
"tauri-runtime",
|
"tauri-runtime",
|
||||||
"tauri-runtime-wry",
|
"tauri-runtime-wry",
|
||||||
"tauri-utils",
|
"tauri-utils",
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.19",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tray-icon",
|
"tray-icon",
|
||||||
"url",
|
"url",
|
||||||
@@ -4776,7 +4778,7 @@ dependencies = [
|
|||||||
"sha2 0.10.9",
|
"sha2 0.10.9",
|
||||||
"syn 2.0.119",
|
"syn 2.0.119",
|
||||||
"tauri-utils",
|
"tauri-utils",
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.19",
|
||||||
"time",
|
"time",
|
||||||
"url",
|
"url",
|
||||||
"uuid",
|
"uuid",
|
||||||
@@ -4825,7 +4827,7 @@ dependencies = [
|
|||||||
"serde_json",
|
"serde_json",
|
||||||
"tauri",
|
"tauri",
|
||||||
"tauri-plugin",
|
"tauri-plugin",
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.19",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -4842,7 +4844,7 @@ dependencies = [
|
|||||||
"tauri",
|
"tauri",
|
||||||
"tauri-plugin",
|
"tauri-plugin",
|
||||||
"tauri-utils",
|
"tauri-utils",
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.19",
|
||||||
"tracing",
|
"tracing",
|
||||||
"url",
|
"url",
|
||||||
"windows-registry",
|
"windows-registry",
|
||||||
@@ -4851,9 +4853,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tauri-plugin-dialog"
|
name = "tauri-plugin-dialog"
|
||||||
version = "2.7.1"
|
version = "2.7.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "65981abb771e74e571a38196c3baa11c459379164791eba0e67abc1a5fac9884"
|
checksum = "b2d3c1dbe38037e7f590cdf2492594d5ceebe031e7bc7e827509b22a999d2940"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"log 0.4.33",
|
"log 0.4.33",
|
||||||
"raw-window-handle",
|
"raw-window-handle",
|
||||||
@@ -4863,7 +4865,7 @@ dependencies = [
|
|||||||
"tauri",
|
"tauri",
|
||||||
"tauri-plugin",
|
"tauri-plugin",
|
||||||
"tauri-plugin-fs",
|
"tauri-plugin-fs",
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.19",
|
||||||
"url",
|
"url",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -4886,7 +4888,7 @@ dependencies = [
|
|||||||
"tauri",
|
"tauri",
|
||||||
"tauri-plugin",
|
"tauri-plugin",
|
||||||
"tauri-utils",
|
"tauri-utils",
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.19",
|
||||||
"toml 1.1.3+spec-1.1.0",
|
"toml 1.1.3+spec-1.1.0",
|
||||||
"url",
|
"url",
|
||||||
]
|
]
|
||||||
@@ -4908,7 +4910,7 @@ dependencies = [
|
|||||||
"swift-rs",
|
"swift-rs",
|
||||||
"tauri",
|
"tauri",
|
||||||
"tauri-plugin",
|
"tauri-plugin",
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.19",
|
||||||
"time",
|
"time",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -4926,7 +4928,7 @@ dependencies = [
|
|||||||
"serde_repr",
|
"serde_repr",
|
||||||
"tauri",
|
"tauri",
|
||||||
"tauri-plugin",
|
"tauri-plugin",
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.19",
|
||||||
"time",
|
"time",
|
||||||
"url",
|
"url",
|
||||||
]
|
]
|
||||||
@@ -4947,7 +4949,7 @@ dependencies = [
|
|||||||
"serde_json",
|
"serde_json",
|
||||||
"tauri",
|
"tauri",
|
||||||
"tauri-plugin",
|
"tauri-plugin",
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.19",
|
||||||
"url",
|
"url",
|
||||||
"windows 0.61.3",
|
"windows 0.61.3",
|
||||||
"zbus",
|
"zbus",
|
||||||
@@ -4970,7 +4972,7 @@ dependencies = [
|
|||||||
"shared_child",
|
"shared_child",
|
||||||
"tauri",
|
"tauri",
|
||||||
"tauri-plugin",
|
"tauri-plugin",
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.19",
|
||||||
"tokio",
|
"tokio",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -4984,7 +4986,7 @@ dependencies = [
|
|||||||
"serde_json",
|
"serde_json",
|
||||||
"tauri",
|
"tauri",
|
||||||
"tauri-plugin-deep-link",
|
"tauri-plugin-deep-link",
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.19",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tracing",
|
"tracing",
|
||||||
"windows-sys 0.60.2",
|
"windows-sys 0.60.2",
|
||||||
@@ -5009,7 +5011,7 @@ dependencies = [
|
|||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"tauri-utils",
|
"tauri-utils",
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.19",
|
||||||
"url",
|
"url",
|
||||||
"webkit2gtk",
|
"webkit2gtk",
|
||||||
"webview2-com",
|
"webview2-com",
|
||||||
@@ -5072,7 +5074,7 @@ dependencies = [
|
|||||||
"serde_json",
|
"serde_json",
|
||||||
"serde_with",
|
"serde_with",
|
||||||
"swift-rs",
|
"swift-rs",
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.19",
|
||||||
"toml 1.1.3+spec-1.1.0",
|
"toml 1.1.3+spec-1.1.0",
|
||||||
"url",
|
"url",
|
||||||
"urlpattern",
|
"urlpattern",
|
||||||
@@ -5097,7 +5099,7 @@ version = "0.7.3"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9ed071c670382e85fc2f48ae706492d8c338f4f89bf72520d32f8abfe880aade"
|
checksum = "9ed071c670382e85fc2f48ae706492d8c338f4f89bf72520d32f8abfe880aade"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.19",
|
||||||
"windows 0.61.3",
|
"windows 0.61.3",
|
||||||
"windows-version",
|
"windows-version",
|
||||||
]
|
]
|
||||||
@@ -5144,11 +5146,11 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "thiserror"
|
name = "thiserror"
|
||||||
version = "2.0.18"
|
version = "2.0.19"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4"
|
checksum = "09a43598840e33d5b0331f38c5e30d13bb11c11210a4b58f0d9b18a5a5eefcd9"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"thiserror-impl 2.0.18",
|
"thiserror-impl 2.0.19",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -5164,13 +5166,13 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "thiserror-impl"
|
name = "thiserror-impl"
|
||||||
version = "2.0.18"
|
version = "2.0.19"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5"
|
checksum = "43cbfe0cf76104d42a574802844187e84a305e531ed54455f11fbde0f10541cd"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn 2.0.119",
|
"syn 3.0.3",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -5198,9 +5200,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "time"
|
name = "time"
|
||||||
version = "0.3.53"
|
version = "0.3.54"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "18dfaaeddcb932337b5e7866ee7d0ce9b76d2fd092997146f187ec09b4558a50"
|
checksum = "3e1d5e639ff6bab73cb6885cc7e7b1de96c3f32c68ec55f3952614bec1092244"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"deranged",
|
"deranged",
|
||||||
"libc",
|
"libc",
|
||||||
@@ -5220,9 +5222,9 @@ checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "time-macros"
|
name = "time-macros"
|
||||||
version = "0.2.31"
|
version = "0.2.32"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c431b87111666e491a90baa837f914fb45cd5dc3c268591b0220ff5057f2085f"
|
checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"num-conv",
|
"num-conv",
|
||||||
"time-core",
|
"time-core",
|
||||||
@@ -5264,9 +5266,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tokio"
|
name = "tokio"
|
||||||
version = "1.52.4"
|
version = "1.53.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "317fafbbe3f02fc663dad00ea6186197de963cd4190e86a26d8d0fae095539af"
|
checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bytes",
|
"bytes",
|
||||||
"libc",
|
"libc",
|
||||||
@@ -5280,9 +5282,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tokio-macros"
|
name = "tokio-macros"
|
||||||
version = "2.7.0"
|
version = "2.7.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496"
|
checksum = "6328af13490e73a9b4694030fafd93f8c8c6a9dede33e821c3fc63eddf8042ba"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
@@ -5313,9 +5315,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tokio-util"
|
name = "tokio-util"
|
||||||
version = "0.7.18"
|
version = "0.7.19"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098"
|
checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bytes",
|
"bytes",
|
||||||
"futures-core",
|
"futures-core",
|
||||||
@@ -5575,7 +5577,7 @@ dependencies = [
|
|||||||
"once_cell",
|
"once_cell",
|
||||||
"png 0.18.1",
|
"png 0.18.1",
|
||||||
"serde",
|
"serde",
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.19",
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -5602,7 +5604,7 @@ version = "12.0.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "756050066659291d47a554a9f558125db17428b073c5ffce1daf5dcb0f7231d8"
|
checksum = "756050066659291d47a554a9f558125db17428b073c5ffce1daf5dcb0f7231d8"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.19",
|
||||||
"ts-rs-macros",
|
"ts-rs-macros",
|
||||||
"uuid",
|
"uuid",
|
||||||
]
|
]
|
||||||
@@ -5632,7 +5634,7 @@ dependencies = [
|
|||||||
"log 0.4.33",
|
"log 0.4.33",
|
||||||
"rand 0.10.2",
|
"rand 0.10.2",
|
||||||
"sha1",
|
"sha1",
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.19",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -5920,9 +5922,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wayland-backend"
|
name = "wayland-backend"
|
||||||
version = "0.3.15"
|
version = "0.3.16"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "2857dd20b54e916ec7253b3d6b4d5c4d7d4ca2c33c2e11c6c76a99bd8744755d"
|
checksum = "016ccf01d1c58b6f8999612813e17c9b2390f7d70671428869913310f83f54b8"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cc",
|
"cc",
|
||||||
"downcast-rs",
|
"downcast-rs",
|
||||||
@@ -5933,9 +5935,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wayland-client"
|
name = "wayland-client"
|
||||||
version = "0.31.14"
|
version = "0.31.15"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "645c7c96bb74690c3189b5c9cb4ca1627062bb23693a4fad9d8c3de958260144"
|
checksum = "e3c36a0f861ad76d0901f2800b46321410d9f73f2ea88aac0650d86c32688073"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.13.1",
|
"bitflags 2.13.1",
|
||||||
"rustix",
|
"rustix",
|
||||||
@@ -5970,12 +5972,12 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wayland-scanner"
|
name = "wayland-scanner"
|
||||||
version = "0.31.10"
|
version = "0.31.11"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9c324a910fd86ebdc364a3e61ec1f11737d3b1d6c273c0239ee8ff4bc0d24b4a"
|
checksum = "338e30461b3a2b67d70eb30a6d89f8e0c93a833e07d2ae89085cd070c4a00ac0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quick-xml 0.39.4",
|
"quick-xml",
|
||||||
"quote",
|
"quote",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -6056,9 +6058,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "webpki-root-certs"
|
name = "webpki-root-certs"
|
||||||
version = "1.0.8"
|
version = "1.0.9"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "0d46a5a140e6f7afeccd8eae97eff335163939eac8b929834875168b29b3d267"
|
checksum = "b96554aa2acc8ccdb7e1c9a58a7a68dd5d13bccc69cd124cb09406db612a1c9b"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"rustls-pki-types",
|
"rustls-pki-types",
|
||||||
]
|
]
|
||||||
@@ -6094,7 +6096,7 @@ version = "0.38.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "381336cfffd772377d291702245447a5251a2ffa5bad679c99e61bc48bacbf9c"
|
checksum = "381336cfffd772377d291702245447a5251a2ffa5bad679c99e61bc48bacbf9c"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.19",
|
||||||
"windows 0.61.3",
|
"windows 0.61.3",
|
||||||
"windows-core 0.61.2",
|
"windows-core 0.61.2",
|
||||||
]
|
]
|
||||||
@@ -6726,7 +6728,7 @@ dependencies = [
|
|||||||
"log 0.4.33",
|
"log 0.4.33",
|
||||||
"os_pipe",
|
"os_pipe",
|
||||||
"rustix",
|
"rustix",
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.19",
|
||||||
"tree_magic_mini",
|
"tree_magic_mini",
|
||||||
"wayland-backend",
|
"wayland-backend",
|
||||||
"wayland-client",
|
"wayland-client",
|
||||||
@@ -6773,7 +6775,7 @@ dependencies = [
|
|||||||
"sha2 0.10.9",
|
"sha2 0.10.9",
|
||||||
"soup3",
|
"soup3",
|
||||||
"tao-macros",
|
"tao-macros",
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.19",
|
||||||
"url",
|
"url",
|
||||||
"webkit2gtk",
|
"webkit2gtk",
|
||||||
"webkit2gtk-sys",
|
"webkit2gtk-sys",
|
||||||
@@ -6847,9 +6849,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zbus"
|
name = "zbus"
|
||||||
version = "5.17.0"
|
version = "5.18.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "a28b97f866896a4be7aefd2b5a8e01bb6773d19a775d54ab28b4d094b9a4480e"
|
checksum = "fe18fb60dc696039e738717b76eaea21e7a4489bbb1885020b43c94236d7e98a"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-broadcast",
|
"async-broadcast",
|
||||||
"async-executor",
|
"async-executor",
|
||||||
@@ -6893,9 +6895,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zbus_macros"
|
name = "zbus_macros"
|
||||||
version = "5.17.0"
|
version = "5.18.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "5e05ad887425eecf5e8384dc2406a4a9313eb73468712fc1cdea362eb4fe0469"
|
checksum = "fe96480bed92df2b442a1a30df364e12d08eed03aeb061f2b8dc6afb2be91119"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro-crate 3.5.0",
|
"proc-macro-crate 3.5.0",
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
@@ -6908,9 +6910,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zbus_names"
|
name = "zbus_names"
|
||||||
version = "4.3.3"
|
version = "4.3.4"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "1039ca249fee9559680f3a9f05b55e0761fee51af4f6c1e7d8c1f31e549721d2"
|
checksum = "d8bf88b4a3ff53e883001e0e0115b297a9d53c31b9c1edd2bfdd853e3428624e"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"serde",
|
"serde",
|
||||||
"winnow 1.0.4",
|
"winnow 1.0.4",
|
||||||
@@ -6919,18 +6921,18 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zerocopy"
|
name = "zerocopy"
|
||||||
version = "0.8.54"
|
version = "0.8.55"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b7cbbc0a705a0fd05cc3676525980d2bf5a9bc4adac6d6475209a7887cf59d19"
|
checksum = "b5a105cd7b140f6eeec8acff2ea38135d3cab283ada58540f629fe51e46696eb"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"zerocopy-derive",
|
"zerocopy-derive",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zerocopy-derive"
|
name = "zerocopy-derive"
|
||||||
version = "0.8.54"
|
version = "0.8.55"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e2e817b7b52d0c7358d3246da9d69935ebb18116b2b102b4230dac079b4862f5"
|
checksum = "0fe976fb70c78cd64cccfe3a6fc142244e8a77b70959b30faf9d0ac37ee228eb"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
@@ -7020,9 +7022,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zvariant"
|
name = "zvariant"
|
||||||
version = "5.13.0"
|
version = "5.13.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7cf057bb00bf5c9ad77abb6147b0ca4818236a1858416e9d988e40d6322fefa7"
|
checksum = "bee2a0bcd2a907786a456fff45aaaaf54c9ba5f50b71ae9ec1a4edd200c94911"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"endi",
|
"endi",
|
||||||
"enumflags2",
|
"enumflags2",
|
||||||
@@ -7034,9 +7036,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zvariant_derive"
|
name = "zvariant_derive"
|
||||||
version = "5.13.0"
|
version = "5.13.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "8118ca6bda77bfc0ab51d660db0c955f2505eef854c9a449435bccb616933b31"
|
checksum = "38a708216a18780796770bfe3f4739c7c83a3e8f789b755534bbbc06e4e23e12"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro-crate 3.5.0",
|
"proc-macro-crate 3.5.0",
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "firelink"
|
name = "firelink"
|
||||||
version = "1.1.1"
|
version = "1.2.0"
|
||||||
description = "A fast cross-platform desktop download manager powered by Rust and Tauri"
|
description = "A fast cross-platform desktop download manager powered by Rust and Tauri"
|
||||||
authors = ["NimBold"]
|
authors = ["NimBold"]
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
@@ -25,7 +25,7 @@ tauri-build = { version = "2", features = [] }
|
|||||||
[dependencies]
|
[dependencies]
|
||||||
tauri = { version = "2", features = ["macos-private-api", "tray-icon", "image-png", "test"] }
|
tauri = { version = "2", features = ["macos-private-api", "tray-icon", "image-png", "test"] }
|
||||||
tauri-plugin-opener = "2"
|
tauri-plugin-opener = "2"
|
||||||
tauri-plugin-dialog = "2"
|
tauri-plugin-dialog = "2.7.2"
|
||||||
tauri-plugin-shell = "2"
|
tauri-plugin-shell = "2"
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
@@ -43,7 +43,7 @@ sha2 = "0.11"
|
|||||||
tauri-plugin-deep-link = "2"
|
tauri-plugin-deep-link = "2"
|
||||||
tauri-plugin-single-instance = { version = "2.4.3", features = ["deep-link"] }
|
tauri-plugin-single-instance = { version = "2.4.3", features = ["deep-link"] }
|
||||||
tempfile = "3"
|
tempfile = "3"
|
||||||
thiserror = "2.0.18"
|
thiserror = "2.0.19"
|
||||||
axum = "0.8.9"
|
axum = "0.8.9"
|
||||||
tower-http = { version = "0.7", features = ["cors", "limit"] }
|
tower-http = { version = "0.7", features = ["cors", "limit"] }
|
||||||
sysproxy = "0.3.0"
|
sysproxy = "0.3.0"
|
||||||
@@ -51,7 +51,7 @@ semver = "1.0.28"
|
|||||||
keepawake = "0.6.0"
|
keepawake = "0.6.0"
|
||||||
system_shutdown = "4.1.0"
|
system_shutdown = "4.1.0"
|
||||||
tokio-tungstenite = "0.30.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"
|
chrono = "0.4.38"
|
||||||
url = "2"
|
url = "2"
|
||||||
rusqlite = { version = "0.40.1", features = ["bundled"] }
|
rusqlite = { version = "0.40.1", features = ["bundled"] }
|
||||||
|
|||||||
Binary file not shown.
+10
-38
@@ -45,18 +45,17 @@ struct LegacyData {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn init(storage_layout: &crate::storage::StorageLayout) -> Result<DbState, String> {
|
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)]
|
#[cfg(test)]
|
||||||
fn init_at_path(app_data_dir: &Path) -> Result<DbState, String> {
|
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(
|
fn init_at_path_internal(
|
||||||
app_data_dir: &Path,
|
app_data_dir: &Path,
|
||||||
portable: bool,
|
portable: bool,
|
||||||
migrate_legacy_keychain: bool,
|
|
||||||
) -> Result<DbState, String> {
|
) -> Result<DbState, String> {
|
||||||
fs::create_dir_all(app_data_dir)
|
fs::create_dir_all(app_data_dir)
|
||||||
.map_err(|error| format!("failed to create app data directory: {error}"))?;
|
.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)?;
|
migrate_schema(&mut connection, version)?;
|
||||||
|
|
||||||
import_legacy_data(
|
import_legacy_data(&mut connection, app_data_dir, portable)?;
|
||||||
&mut connection,
|
|
||||||
app_data_dir,
|
|
||||||
portable,
|
|
||||||
migrate_legacy_keychain,
|
|
||||||
)?;
|
|
||||||
if portable {
|
if portable {
|
||||||
sanitize_persisted_downloads(&mut connection)?;
|
sanitize_persisted_downloads(&mut connection)?;
|
||||||
}
|
}
|
||||||
@@ -188,7 +182,6 @@ fn import_legacy_data(
|
|||||||
connection: &mut Connection,
|
connection: &mut Connection,
|
||||||
app_data_dir: &Path,
|
app_data_dir: &Path,
|
||||||
portable: bool,
|
portable: bool,
|
||||||
migrate_keychain: bool,
|
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
let legacy_app_dir = app_data_dir
|
let legacy_app_dir = app_data_dir
|
||||||
.parent()
|
.parent()
|
||||||
@@ -226,31 +219,10 @@ fn import_legacy_data(
|
|||||||
let mut pending_pairing_token = None;
|
let mut pending_pairing_token = None;
|
||||||
if !portable {
|
if !portable {
|
||||||
if let Some(token) = legacy.pairing_token.take() {
|
if let Some(token) = legacy.pairing_token.take() {
|
||||||
let migrated = if migrate_keychain {
|
// Legacy migration is deliberately deferred until the
|
||||||
let keychain_has_token = get_keychain_password(PAIRING_TOKEN_KEYCHAIN_ID)
|
// explicit frontend consent action. Database initialization
|
||||||
.ok()
|
// must never touch the OS credential store.
|
||||||
.is_some_and(|value| !value.trim().is_empty());
|
pending_pairing_token = Some(token);
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if portable {
|
if portable {
|
||||||
@@ -1738,7 +1710,7 @@ mod tests {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
drop(connection);
|
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 connection = state.lock().unwrap();
|
||||||
let saved: Value = serde_json::from_str(&load_downloads(&connection).unwrap()[0]).unwrap();
|
let saved: Value = serde_json::from_str(&load_downloads(&connection).unwrap()[0]).unwrap();
|
||||||
assert!(saved.get("password").is_none());
|
assert!(saved.get("password").is_none());
|
||||||
@@ -1828,7 +1800,7 @@ mod tests {
|
|||||||
});
|
});
|
||||||
fs::write(&store_path, serde_json::to_vec(&store).unwrap()).unwrap();
|
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 connection = state.lock().unwrap();
|
||||||
let saved: Value = serde_json::from_str(&load_downloads(&connection).unwrap()[0]).unwrap();
|
let saved: Value = serde_json::from_str(&load_downloads(&connection).unwrap()[0]).unwrap();
|
||||||
assert!(saved.get("password").is_none());
|
assert!(saved.get("password").is_none());
|
||||||
@@ -2104,7 +2076,7 @@ mod tests {
|
|||||||
drop(connection);
|
drop(connection);
|
||||||
drop(state);
|
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 connection = state.lock().unwrap();
|
||||||
let saved: Value = serde_json::from_str(&load_downloads(&connection).unwrap()[0]).unwrap();
|
let saved: Value = serde_json::from_str(&load_downloads(&connection).unwrap()[0]).unwrap();
|
||||||
assert!(saved.get("password").is_none());
|
assert!(saved.get("password").is_none());
|
||||||
|
|||||||
@@ -74,6 +74,10 @@ struct ExtensionRequest {
|
|||||||
cookie_scopes: Option<Vec<ExtensionCookieScope>>,
|
cookie_scopes: Option<Vec<ExtensionCookieScope>>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
media: bool,
|
media: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
batch: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
batch_name: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Deserialize, Serialize, TS)]
|
#[derive(Clone, Deserialize, Serialize, TS)]
|
||||||
@@ -96,6 +100,8 @@ pub struct ExtensionDownload {
|
|||||||
cookies: Option<String>,
|
cookies: Option<String>,
|
||||||
cookie_scopes: Option<Vec<ExtensionCookieScope>>,
|
cookie_scopes: Option<Vec<ExtensionCookieScope>>,
|
||||||
media: bool,
|
media: bool,
|
||||||
|
batch: bool,
|
||||||
|
batch_name: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn start_server(
|
pub async fn start_server(
|
||||||
@@ -428,8 +434,16 @@ fn normalize_download(mut payload: ExtensionRequest) -> Option<ExtensionDownload
|
|||||||
matches!(url.scheme(), "http" | "https").then(|| url.to_string())
|
matches!(url.scheme(), "http" | "https").then(|| url.to_string())
|
||||||
});
|
});
|
||||||
let filename = payload.filename.and_then(|value| sanitize_filename(&value));
|
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
|
// 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.
|
// so a legacy or untrusted caller cannot reuse one session across hosts.
|
||||||
let headers = normalize_headers(payload.headers, payload.media || urls.len() > 1);
|
let headers = normalize_headers(payload.headers, payload.media || urls.len() > 1);
|
||||||
let cookie_scopes = if !payload.media && urls.len() == 1 {
|
let cookie_scopes = if !payload.media && urls.len() == 1 {
|
||||||
@@ -468,6 +482,8 @@ fn normalize_download(mut payload: ExtensionRequest) -> Option<ExtensionDownload
|
|||||||
cookies,
|
cookies,
|
||||||
cookie_scopes,
|
cookie_scopes,
|
||||||
media: payload.media,
|
media: payload.media,
|
||||||
|
batch,
|
||||||
|
batch_name,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -532,7 +548,17 @@ fn normalize_headers(headers: Option<String>, media: bool) -> Option<String> {
|
|||||||
.lines()
|
.lines()
|
||||||
.filter(|line| {
|
.filter(|line| {
|
||||||
line.split_once(':')
|
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)
|
.unwrap_or(true)
|
||||||
})
|
})
|
||||||
.collect::<Vec<_>>()
|
.collect::<Vec<_>>()
|
||||||
@@ -690,9 +716,9 @@ fn is_allowed_origin(origin: &str) -> bool {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::{
|
use super::{
|
||||||
acknowledge_extension_download, add_server_identity, claim_request_at,
|
acknowledge_extension_download, add_server_identity, claim_request_at,
|
||||||
has_allowed_request_origin, is_valid_client_nonce,
|
has_allowed_request_origin, is_valid_client_nonce, normalize_download,
|
||||||
normalize_download, required_client_nonce, sign_server_proof, ExtensionCookieScope,
|
required_client_nonce, sign_server_proof, ExtensionCookieScope, ExtensionRequest,
|
||||||
ExtensionRequest, MAX_URL_COUNT, PROTOCOL_VERSION_HEADER, SERVER_HEADER,
|
MAX_URL_COUNT, PROTOCOL_VERSION_HEADER, SERVER_HEADER,
|
||||||
};
|
};
|
||||||
use axum::{
|
use axum::{
|
||||||
http::{HeaderMap, HeaderValue, StatusCode},
|
http::{HeaderMap, HeaderValue, StatusCode},
|
||||||
@@ -790,6 +816,8 @@ mod tests {
|
|||||||
cookies: None,
|
cookies: None,
|
||||||
cookie_scopes: None,
|
cookie_scopes: None,
|
||||||
media: true,
|
media: true,
|
||||||
|
batch: false,
|
||||||
|
batch_name: None,
|
||||||
});
|
});
|
||||||
|
|
||||||
assert!(download.is_none());
|
assert!(download.is_none());
|
||||||
@@ -808,6 +836,8 @@ mod tests {
|
|||||||
cookies: None,
|
cookies: None,
|
||||||
cookie_scopes: None,
|
cookie_scopes: None,
|
||||||
media: false,
|
media: false,
|
||||||
|
batch: false,
|
||||||
|
batch_name: None,
|
||||||
});
|
});
|
||||||
|
|
||||||
assert!(download.is_none());
|
assert!(download.is_none());
|
||||||
@@ -861,12 +891,14 @@ mod tests {
|
|||||||
silent: false,
|
silent: false,
|
||||||
filename: None,
|
filename: None,
|
||||||
headers: Some(format!(
|
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)
|
"x".repeat(64 * 1024)
|
||||||
)),
|
)),
|
||||||
cookies: Some(format!("large={}", "x".repeat(64 * 1024))),
|
cookies: Some(format!("large={}", "x".repeat(64 * 1024))),
|
||||||
cookie_scopes: None,
|
cookie_scopes: None,
|
||||||
media: true,
|
media: true,
|
||||||
|
batch: false,
|
||||||
|
batch_name: None,
|
||||||
})
|
})
|
||||||
.expect("valid media handoff");
|
.expect("valid media handoff");
|
||||||
|
|
||||||
@@ -886,6 +918,8 @@ mod tests {
|
|||||||
cookies: Some("session=browser-cookie-header".to_string()),
|
cookies: Some("session=browser-cookie-header".to_string()),
|
||||||
cookie_scopes: None,
|
cookie_scopes: None,
|
||||||
media: false,
|
media: false,
|
||||||
|
batch: false,
|
||||||
|
batch_name: None,
|
||||||
})
|
})
|
||||||
.expect("valid download handoff");
|
.expect("valid download handoff");
|
||||||
|
|
||||||
@@ -920,6 +954,8 @@ mod tests {
|
|||||||
},
|
},
|
||||||
]),
|
]),
|
||||||
media: false,
|
media: false,
|
||||||
|
batch: false,
|
||||||
|
batch_name: None,
|
||||||
})
|
})
|
||||||
.expect("valid download handoff");
|
.expect("valid download handoff");
|
||||||
|
|
||||||
@@ -948,6 +984,8 @@ mod tests {
|
|||||||
cookies: Some("session=secret".to_string()),
|
cookies: Some("session=secret".to_string()),
|
||||||
cookie_scopes: None,
|
cookie_scopes: None,
|
||||||
media: false,
|
media: false,
|
||||||
|
batch: false,
|
||||||
|
batch_name: None,
|
||||||
})
|
})
|
||||||
.expect("valid multi-url handoff");
|
.expect("valid multi-url handoff");
|
||||||
|
|
||||||
@@ -955,6 +993,52 @@ mod tests {
|
|||||||
assert_eq!(download.headers.as_deref(), Some("User-Agent: Firefox"));
|
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]
|
#[test]
|
||||||
fn signs_server_proof_with_timestamp_nonce_and_bound_port() {
|
fn signs_server_proof_with_timestamp_nonce_and_bound_port() {
|
||||||
let token = Arc::new(RwLock::new("pairing-token".to_string()));
|
let token = Arc::new(RwLock::new("pairing-token".to_string()));
|
||||||
|
|||||||
@@ -6,6 +6,14 @@ fn default_speed_limit_unit() -> String {
|
|||||||
"MB/s".to_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)]
|
#[derive(Clone, Copy, Debug, Serialize, Deserialize, TS)]
|
||||||
#[serde(rename_all = "lowercase")]
|
#[serde(rename_all = "lowercase")]
|
||||||
#[ts(export, export_to = "../../src/bindings/")]
|
#[ts(export, export_to = "../../src/bindings/")]
|
||||||
@@ -62,6 +70,18 @@ pub struct Queue {
|
|||||||
pub id: String,
|
pub id: String,
|
||||||
pub name: String,
|
pub name: String,
|
||||||
pub is_main: bool,
|
pub is_main: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
#[ts(optional)]
|
||||||
|
pub max_concurrent: Option<usize>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Serialize, Deserialize, TS)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
#[ts(export, export_to = "../../src/bindings/")]
|
||||||
|
pub struct QueueConcurrencyConfig {
|
||||||
|
pub id: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub max_concurrent: Option<usize>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, Serialize, Deserialize, TS)]
|
#[derive(Clone, Debug, Serialize, Deserialize, TS)]
|
||||||
@@ -113,6 +133,8 @@ pub struct DownloadItem {
|
|||||||
#[ts(optional)]
|
#[ts(optional)]
|
||||||
pub media_format_selector: Option<String>,
|
pub media_format_selector: Option<String>,
|
||||||
#[ts(optional)]
|
#[ts(optional)]
|
||||||
|
pub media_quality: Option<String>,
|
||||||
|
#[ts(optional)]
|
||||||
pub queue_id: Option<String>,
|
pub queue_id: Option<String>,
|
||||||
#[ts(optional)]
|
#[ts(optional)]
|
||||||
pub queue_position: Option<i32>,
|
pub queue_position: Option<i32>,
|
||||||
@@ -192,6 +214,59 @@ pub enum Theme {
|
|||||||
Nord,
|
Nord,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Serialize, Deserialize, TS)]
|
||||||
|
#[serde(rename_all = "kebab-case")]
|
||||||
|
#[ts(export, export_to = "../../src/bindings/")]
|
||||||
|
pub enum FontFamily {
|
||||||
|
System,
|
||||||
|
Inter,
|
||||||
|
Outfit,
|
||||||
|
Vazirmatn,
|
||||||
|
NotoSansHebrew,
|
||||||
|
NotoSansSc,
|
||||||
|
Roboto,
|
||||||
|
Serif,
|
||||||
|
Monospace,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for FontFamily {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::System
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Serialize, Deserialize, TS)]
|
||||||
|
#[serde(rename_all = "lowercase")]
|
||||||
|
#[ts(export, export_to = "../../src/bindings/")]
|
||||||
|
pub enum WindowControlStyle {
|
||||||
|
Auto,
|
||||||
|
Macos,
|
||||||
|
Windows,
|
||||||
|
Gnome,
|
||||||
|
Minimal,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for WindowControlStyle {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::Auto
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Serialize, Deserialize, TS)]
|
||||||
|
#[serde(rename_all = "lowercase")]
|
||||||
|
#[ts(export, export_to = "../../src/bindings/")]
|
||||||
|
pub enum CalendarPreference {
|
||||||
|
Gregorian,
|
||||||
|
Persian,
|
||||||
|
Hebrew,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for CalendarPreference {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::Gregorian
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, Serialize, Deserialize, TS)]
|
#[derive(Clone, Debug, Serialize, Deserialize, TS)]
|
||||||
#[serde(rename_all = "lowercase")]
|
#[serde(rename_all = "lowercase")]
|
||||||
#[ts(export, export_to = "../../src/bindings/")]
|
#[ts(export, export_to = "../../src/bindings/")]
|
||||||
@@ -257,6 +332,14 @@ pub struct SchedulerSettings {
|
|||||||
#[ts(export, export_to = "../../src/bindings/")]
|
#[ts(export, export_to = "../../src/bindings/")]
|
||||||
pub struct PersistedSettings {
|
pub struct PersistedSettings {
|
||||||
pub theme: Theme,
|
pub theme: Theme,
|
||||||
|
#[serde(default)]
|
||||||
|
pub font_family: FontFamily,
|
||||||
|
#[serde(default)]
|
||||||
|
pub window_control_style: WindowControlStyle,
|
||||||
|
#[serde(default)]
|
||||||
|
pub calendar_preference: CalendarPreference,
|
||||||
|
#[serde(default = "default_language_preference")]
|
||||||
|
pub language: String,
|
||||||
pub base_download_folder: String,
|
pub base_download_folder: String,
|
||||||
pub category_subfolders_enabled: bool,
|
pub category_subfolders_enabled: bool,
|
||||||
pub category_subfolders: HashMap<String, String>,
|
pub category_subfolders: HashMap<String, String>,
|
||||||
@@ -267,6 +350,8 @@ pub struct PersistedSettings {
|
|||||||
pub speed_limit_preset_values: Vec<f64>,
|
pub speed_limit_preset_values: Vec<f64>,
|
||||||
pub logs_enabled: bool,
|
pub logs_enabled: bool,
|
||||||
pub is_sidebar_visible: bool,
|
pub is_sidebar_visible: bool,
|
||||||
|
#[serde(default = "default_sidebar_position")]
|
||||||
|
pub sidebar_position: String,
|
||||||
pub active_settings_tab: SettingsTab,
|
pub active_settings_tab: SettingsTab,
|
||||||
pub scheduler: SchedulerSettings,
|
pub scheduler: SchedulerSettings,
|
||||||
pub scheduler_running: bool,
|
pub scheduler_running: bool,
|
||||||
@@ -291,7 +376,10 @@ pub struct PersistedSettings {
|
|||||||
pub proxy_port: u16,
|
pub proxy_port: u16,
|
||||||
pub custom_user_agent: String,
|
pub custom_user_agent: String,
|
||||||
pub ask_where_to_save_each_file: bool,
|
pub ask_where_to_save_each_file: bool,
|
||||||
|
pub remember_last_used_download_directory: bool,
|
||||||
pub prevents_sleep_while_downloading: bool,
|
pub prevents_sleep_while_downloading: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
pub prevents_display_sleep_while_downloading: bool,
|
||||||
pub media_cookie_source: MediaCookieSource,
|
pub media_cookie_source: MediaCookieSource,
|
||||||
pub site_logins: Vec<SiteLogin>,
|
pub site_logins: Vec<SiteLogin>,
|
||||||
pub auto_check_updates: bool,
|
pub auto_check_updates: bool,
|
||||||
|
|||||||
+1202
-215
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,419 @@
|
|||||||
|
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
|
||||||
|
use std::sync::mpsc::{self, Receiver, Sender};
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
use std::thread::{self, JoinHandle};
|
||||||
|
|
||||||
|
const ASSERTION_REASON: &str = "Firelink active download";
|
||||||
|
|
||||||
|
/// Owns the OS power assertions for the whole backend transfer lifecycle.
|
||||||
|
///
|
||||||
|
/// The frontend may request the policy, but it does not own the assertion:
|
||||||
|
/// queue permits are the source of truth for whether a transfer is actually
|
||||||
|
/// active. Keeping the two assertions separate also means display sleep can
|
||||||
|
/// be opted into without changing the system-sleep policy.
|
||||||
|
struct PowerState {
|
||||||
|
runtime_enabled: AtomicBool,
|
||||||
|
prevent_system_sleep: AtomicBool,
|
||||||
|
prevent_display_sleep: AtomicBool,
|
||||||
|
active_transfers: AtomicUsize,
|
||||||
|
}
|
||||||
|
|
||||||
|
enum PowerCommand {
|
||||||
|
Reconcile(Option<Sender<Result<(), String>>>),
|
||||||
|
}
|
||||||
|
|
||||||
|
struct PowerWorker {
|
||||||
|
sender: Option<Sender<PowerCommand>>,
|
||||||
|
join_handle: Option<JoinHandle<()>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PowerWorker {
|
||||||
|
fn start(state: Arc<PowerState>) -> Result<Self, String> {
|
||||||
|
let (sender, receiver) = mpsc::channel();
|
||||||
|
let join_handle = thread::Builder::new()
|
||||||
|
.name("firelink-power".to_string())
|
||||||
|
.spawn(move || run_power_worker(state, receiver))
|
||||||
|
.map_err(|error| format!("failed to start power-management worker: {error}"))?;
|
||||||
|
Ok(Self {
|
||||||
|
sender: Some(sender),
|
||||||
|
join_handle: Some(join_handle),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn send(&self, command: PowerCommand) -> Result<(), String> {
|
||||||
|
self.sender
|
||||||
|
.as_ref()
|
||||||
|
.ok_or_else(|| "power-management worker is stopping".to_string())?
|
||||||
|
.send(command)
|
||||||
|
.map_err(|_| "power-management worker stopped unexpectedly".to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for PowerWorker {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
// Closing the sender lets the worker drop its OS assertions on the
|
||||||
|
// same thread that created them. This matters on Windows, where
|
||||||
|
// SetThreadExecutionState is thread-local, and avoids calling the
|
||||||
|
// Linux keepawake D-Bus destructor from an arbitrary queue thread.
|
||||||
|
self.sender.take();
|
||||||
|
if let Some(join_handle) = self.join_handle.take() {
|
||||||
|
let _ = join_handle.join();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct PowerManager {
|
||||||
|
state: Arc<PowerState>,
|
||||||
|
worker: Mutex<Option<PowerWorker>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PowerManager {
|
||||||
|
pub fn new() -> Arc<Self> {
|
||||||
|
Arc::new(Self {
|
||||||
|
state: Arc::new(PowerState {
|
||||||
|
runtime_enabled: AtomicBool::new(false),
|
||||||
|
prevent_system_sleep: AtomicBool::new(true),
|
||||||
|
prevent_display_sleep: AtomicBool::new(false),
|
||||||
|
active_transfers: AtomicUsize::new(0),
|
||||||
|
}),
|
||||||
|
worker: Mutex::new(None),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Enables OS integration after AppState has been registered. This keeps
|
||||||
|
/// QueueManager tests and pre-window startup work from creating real
|
||||||
|
/// assertions in the test process or during partial initialization.
|
||||||
|
pub fn activate(&self) -> Result<(), String> {
|
||||||
|
self.state.runtime_enabled.store(true, Ordering::Release);
|
||||||
|
self.reconcile()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_preferences(
|
||||||
|
&self,
|
||||||
|
prevent_system_sleep: bool,
|
||||||
|
prevent_display_sleep: bool,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
self.state
|
||||||
|
.prevent_system_sleep
|
||||||
|
.store(prevent_system_sleep, Ordering::Release);
|
||||||
|
self.state
|
||||||
|
.prevent_display_sleep
|
||||||
|
.store(prevent_display_sleep, Ordering::Release);
|
||||||
|
self.reconcile()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_system_prevention(&self, enabled: bool) -> Result<(), String> {
|
||||||
|
self.state
|
||||||
|
.prevent_system_sleep
|
||||||
|
.store(enabled, Ordering::Release);
|
||||||
|
self.reconcile()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_display_prevention(&self, enabled: bool) -> Result<(), String> {
|
||||||
|
self.state
|
||||||
|
.prevent_display_sleep
|
||||||
|
.store(enabled, Ordering::Release);
|
||||||
|
self.reconcile()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_active_transfer_count(self: &Arc<Self>, count: usize) {
|
||||||
|
self.state.active_transfers.store(count, Ordering::Release);
|
||||||
|
if self.state.runtime_enabled.load(Ordering::Acquire) {
|
||||||
|
if let Err(error) = self.enqueue_reconcile() {
|
||||||
|
log::error!(
|
||||||
|
"power: failed to schedule assertion reconciliation for {count} active transfer(s): {error}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn active_transfer_count(&self) -> usize {
|
||||||
|
self.state.active_transfers.load(Ordering::Acquire)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn reconcile(&self) -> Result<(), String> {
|
||||||
|
if !self.state.runtime_enabled.load(Ordering::Acquire) {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
self.ensure_worker()?;
|
||||||
|
let (response_sender, response_receiver) = mpsc::channel();
|
||||||
|
if let Err(error) = self.send_command(PowerCommand::Reconcile(Some(response_sender))) {
|
||||||
|
self.restart_worker();
|
||||||
|
return Err(error);
|
||||||
|
}
|
||||||
|
match response_receiver.recv() {
|
||||||
|
Ok(result) => result,
|
||||||
|
Err(_) => {
|
||||||
|
self.restart_worker();
|
||||||
|
Err("power-management worker stopped before reporting reconciliation".to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn enqueue_reconcile(&self) -> Result<(), String> {
|
||||||
|
self.ensure_worker()?;
|
||||||
|
if let Err(error) = self.send_command(PowerCommand::Reconcile(None)) {
|
||||||
|
self.restart_worker();
|
||||||
|
return Err(error);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ensure_worker(&self) -> Result<(), String> {
|
||||||
|
let mut worker = self
|
||||||
|
.worker
|
||||||
|
.lock()
|
||||||
|
.unwrap_or_else(|error| error.into_inner());
|
||||||
|
if worker.is_none() {
|
||||||
|
*worker = Some(PowerWorker::start(Arc::clone(&self.state))?);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn send_command(&self, command: PowerCommand) -> Result<(), String> {
|
||||||
|
let worker = self
|
||||||
|
.worker
|
||||||
|
.lock()
|
||||||
|
.unwrap_or_else(|error| error.into_inner());
|
||||||
|
worker
|
||||||
|
.as_ref()
|
||||||
|
.ok_or_else(|| "power-management worker is not running".to_string())?
|
||||||
|
.send(command)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn restart_worker(&self) {
|
||||||
|
let mut worker = self
|
||||||
|
.worker
|
||||||
|
.lock()
|
||||||
|
.unwrap_or_else(|error| error.into_inner());
|
||||||
|
worker.take();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct OsPowerAssertions {
|
||||||
|
#[cfg(windows)]
|
||||||
|
assertion: Option<keepawake::KeepAwake>,
|
||||||
|
#[cfg(not(windows))]
|
||||||
|
system: Option<keepawake::KeepAwake>,
|
||||||
|
#[cfg(not(windows))]
|
||||||
|
display: Option<keepawake::KeepAwake>,
|
||||||
|
system_enabled: bool,
|
||||||
|
display_enabled: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl OsPowerAssertions {
|
||||||
|
fn reconcile(&mut self, state: &PowerState) -> Result<(), String> {
|
||||||
|
let active = state.active_transfers.load(Ordering::Acquire);
|
||||||
|
let desired_system = active > 0 && state.prevent_system_sleep.load(Ordering::Acquire);
|
||||||
|
let desired_display = active > 0 && state.prevent_display_sleep.load(Ordering::Acquire);
|
||||||
|
|
||||||
|
#[cfg(windows)]
|
||||||
|
{
|
||||||
|
return self.reconcile_combined(desired_system, desired_display, active);
|
||||||
|
}
|
||||||
|
#[cfg(not(windows))]
|
||||||
|
self.reconcile_independent(desired_system, desired_display, active)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(windows)]
|
||||||
|
fn reconcile_combined(
|
||||||
|
&mut self,
|
||||||
|
desired_system: bool,
|
||||||
|
desired_display: bool,
|
||||||
|
active: usize,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
if self.system_enabled == desired_system && self.display_enabled == desired_display {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(assertion) = self.assertion.take() {
|
||||||
|
drop_assertion(assertion, "combined");
|
||||||
|
if self.system_enabled {
|
||||||
|
log::info!("power: system-sleep prevention released");
|
||||||
|
}
|
||||||
|
if self.display_enabled {
|
||||||
|
log::info!("power: display-sleep prevention released");
|
||||||
|
}
|
||||||
|
self.system_enabled = false;
|
||||||
|
self.display_enabled = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if desired_system || desired_display {
|
||||||
|
let assertion = create_assertion(|| {
|
||||||
|
keepawake::Builder::default()
|
||||||
|
.display(desired_display)
|
||||||
|
.idle(desired_system)
|
||||||
|
.sleep(desired_system)
|
||||||
|
.reason(ASSERTION_REASON)
|
||||||
|
.create()
|
||||||
|
})
|
||||||
|
.map_err(|error| format!("failed to apply power prevention: {error}"))?;
|
||||||
|
self.assertion = Some(assertion);
|
||||||
|
self.system_enabled = desired_system;
|
||||||
|
self.display_enabled = desired_display;
|
||||||
|
if desired_system {
|
||||||
|
log::info!(
|
||||||
|
"power: system-sleep prevention enabled for {active} active transfer(s)"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if desired_display {
|
||||||
|
log::info!(
|
||||||
|
"power: display-sleep prevention enabled for {active} active transfer(s)"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(windows))]
|
||||||
|
fn reconcile_independent(
|
||||||
|
&mut self,
|
||||||
|
desired_system: bool,
|
||||||
|
desired_display: bool,
|
||||||
|
active: usize,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let mut first_error = None;
|
||||||
|
if desired_system {
|
||||||
|
if self.system.is_none() {
|
||||||
|
match create_assertion(|| {
|
||||||
|
keepawake::Builder::default()
|
||||||
|
.idle(true)
|
||||||
|
.sleep(true)
|
||||||
|
.reason(ASSERTION_REASON)
|
||||||
|
.create()
|
||||||
|
}) {
|
||||||
|
Ok(assertion) => {
|
||||||
|
self.system = Some(assertion);
|
||||||
|
self.system_enabled = true;
|
||||||
|
log::info!(
|
||||||
|
"power: system-sleep prevention enabled for {active} active transfer(s)"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Err(error) => {
|
||||||
|
first_error = Some(format!("failed to prevent system sleep: {error}"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if let Some(assertion) = self.system.take() {
|
||||||
|
drop_assertion(assertion, "system-sleep");
|
||||||
|
self.system_enabled = false;
|
||||||
|
log::info!("power: system-sleep prevention released");
|
||||||
|
}
|
||||||
|
|
||||||
|
if desired_display {
|
||||||
|
if self.display.is_none() {
|
||||||
|
match create_assertion(|| {
|
||||||
|
keepawake::Builder::default()
|
||||||
|
.display(true)
|
||||||
|
.reason(ASSERTION_REASON)
|
||||||
|
.create()
|
||||||
|
}) {
|
||||||
|
Ok(assertion) => {
|
||||||
|
self.display = Some(assertion);
|
||||||
|
self.display_enabled = true;
|
||||||
|
log::info!(
|
||||||
|
"power: display-sleep prevention enabled for {active} active transfer(s)"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Err(error) => {
|
||||||
|
let display_error = format!("failed to prevent display sleep: {error}");
|
||||||
|
if first_error.is_none() {
|
||||||
|
first_error = Some(display_error);
|
||||||
|
} else {
|
||||||
|
log::error!("power: {display_error}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if let Some(assertion) = self.display.take() {
|
||||||
|
drop_assertion(assertion, "display-sleep");
|
||||||
|
self.display_enabled = false;
|
||||||
|
log::info!("power: display-sleep prevention released");
|
||||||
|
}
|
||||||
|
|
||||||
|
first_error.map_or(Ok(()), Err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn create_assertion(
|
||||||
|
create: impl FnOnce() -> keepawake::Result<keepawake::KeepAwake>,
|
||||||
|
) -> Result<keepawake::KeepAwake, String> {
|
||||||
|
std::panic::catch_unwind(std::panic::AssertUnwindSafe(create))
|
||||||
|
.map_err(|_| "keepawake assertion creation panicked".to_string())?
|
||||||
|
.map_err(|error| error.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn drop_assertion(assertion: keepawake::KeepAwake, kind: &str) {
|
||||||
|
if std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| drop(assertion))).is_err() {
|
||||||
|
log::error!("power: keepawake {kind} assertion cleanup panicked");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run_power_worker(state: Arc<PowerState>, receiver: Receiver<PowerCommand>) {
|
||||||
|
let mut assertions = OsPowerAssertions {
|
||||||
|
#[cfg(windows)]
|
||||||
|
assertion: None,
|
||||||
|
#[cfg(not(windows))]
|
||||||
|
system: None,
|
||||||
|
#[cfg(not(windows))]
|
||||||
|
display: None,
|
||||||
|
system_enabled: false,
|
||||||
|
display_enabled: false,
|
||||||
|
};
|
||||||
|
while let Ok(PowerCommand::Reconcile(response)) = receiver.recv() {
|
||||||
|
let result = assertions.reconcile(&state);
|
||||||
|
if let Some(response) = response {
|
||||||
|
let _ = response.send(result);
|
||||||
|
} else if let Err(error) = result {
|
||||||
|
let active = state.active_transfers.load(Ordering::Acquire);
|
||||||
|
log::error!(
|
||||||
|
"power: failed to reconcile assertions for {active} active transfer(s): {error}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#[cfg(windows)]
|
||||||
|
if let Some(assertion) = assertions.assertion.take() {
|
||||||
|
drop_assertion(assertion, "combined");
|
||||||
|
}
|
||||||
|
#[cfg(not(windows))]
|
||||||
|
{
|
||||||
|
if let Some(assertion) = assertions.display.take() {
|
||||||
|
drop_assertion(assertion, "display-sleep");
|
||||||
|
}
|
||||||
|
if let Some(assertion) = assertions.system.take() {
|
||||||
|
drop_assertion(assertion, "system-sleep");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::PowerManager;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tracks_transfer_activity_before_runtime_activation() {
|
||||||
|
let manager = PowerManager::new();
|
||||||
|
manager.set_active_transfer_count(2);
|
||||||
|
assert_eq!(manager.active_transfer_count(), 2);
|
||||||
|
|
||||||
|
manager.set_active_transfer_count(0);
|
||||||
|
assert_eq!(manager.active_transfer_count(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn preference_changes_are_safe_before_runtime_activation() {
|
||||||
|
let manager = PowerManager::new();
|
||||||
|
assert!(manager.set_preferences(false, true).is_ok());
|
||||||
|
assert!(manager.set_system_prevention(true).is_ok());
|
||||||
|
assert!(manager.set_display_prevention(false).is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn runtime_worker_can_activate_without_active_transfers() {
|
||||||
|
let manager = PowerManager::new();
|
||||||
|
assert!(manager.activate().is_ok());
|
||||||
|
manager.set_active_transfer_count(0);
|
||||||
|
assert_eq!(manager.active_transfer_count(), 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
+1037
-75
File diff suppressed because it is too large
Load Diff
+95
-12
@@ -26,6 +26,26 @@ fn stop_is_due(
|
|||||||
&& last_stop_key != stop_key
|
&& 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(
|
pub fn spawn_scheduler(
|
||||||
app_handle: tauri::AppHandle,
|
app_handle: tauri::AppHandle,
|
||||||
settings_cache: Arc<RwLock<Option<crate::ipc::PersistedSettings>>>,
|
settings_cache: Arc<RwLock<Option<crate::ipc::PersistedSettings>>>,
|
||||||
@@ -56,20 +76,20 @@ pub fn spawn_scheduler(
|
|||||||
|
|
||||||
let allowed_today =
|
let allowed_today =
|
||||||
scheduler.everyday || scheduler.selected_days.contains(¤t_day);
|
scheduler.everyday || scheduler.selected_days.contains(¤t_day);
|
||||||
if !allowed_today {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
let date_key = now.format("%Y-%m-%d").to_string();
|
let date_key = now.format("%Y-%m-%d").to_string();
|
||||||
let start_key = format!("{date_key}-start");
|
let start_key = format!("{date_key}-start");
|
||||||
let stop_key = format!("{date_key}-stop");
|
let stop_key = format!("{date_key}-stop");
|
||||||
let start_minute = minute_of_day(&scheduler.start_time);
|
let start_minute = minute_of_day(&scheduler.start_time);
|
||||||
let stop_minute = minute_of_day(&scheduler.stop_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
|
let before_stop = !scheduler.stop_time_enabled
|
||||||
|| stop_minute.is_some_and(|stop| current_minute < stop);
|
|| stop_minute.is_some_and(|stop| current_minute < stop);
|
||||||
|
|
||||||
if start_minute.is_some_and(|start| current_minute >= start)
|
if allowed_today
|
||||||
&& before_stop
|
&& start_minute.is_some_and(|start| current_minute >= start)
|
||||||
|
&& (overnight || before_stop)
|
||||||
&& scheduler_last_start_key != start_key
|
&& scheduler_last_start_key != start_key
|
||||||
&& last_emit
|
&& last_emit
|
||||||
.get("start")
|
.get("start")
|
||||||
@@ -85,17 +105,43 @@ pub fn spawn_scheduler(
|
|||||||
last_emit.insert("start", std::time::Instant::now());
|
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,
|
scheduler.stop_time_enabled,
|
||||||
|
start_minute,
|
||||||
stop_minute,
|
stop_minute,
|
||||||
current_minute,
|
current_minute,
|
||||||
|
previous_day_allowed,
|
||||||
&scheduler_last_start_key,
|
&scheduler_last_start_key,
|
||||||
&start_key,
|
&previous_start_key,
|
||||||
&scheduler_last_stop_key,
|
&scheduler_last_stop_key,
|
||||||
&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(
|
let _ = app_handle.emit(
|
||||||
"schedule-trigger",
|
"schedule-trigger",
|
||||||
@@ -113,7 +159,7 @@ pub fn spawn_scheduler(
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::{minute_of_day, stop_is_due};
|
use super::{minute_of_day, overnight_stop_is_due, stop_is_due};
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parses_valid_scheduler_times() {
|
fn parses_valid_scheduler_times() {
|
||||||
@@ -150,4 +196,41 @@ mod tests {
|
|||||||
"2026-06-22-stop",
|
"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",
|
||||||
|
));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+117
-2
@@ -1,6 +1,7 @@
|
|||||||
use crate::ipc::{
|
use crate::ipc::{
|
||||||
AppFontSize, ListRowDensity, MediaCookieSource, PersistedSettings, PostQueueAction, ProxyMode,
|
AppFontSize, CalendarPreference, FontFamily, ListRowDensity, MediaCookieSource,
|
||||||
SchedulerSettings, SettingsTab, Theme,
|
PersistedSettings, PostQueueAction, ProxyMode, SchedulerSettings, SettingsTab, Theme,
|
||||||
|
WindowControlStyle,
|
||||||
};
|
};
|
||||||
use serde_json::{Map, Value};
|
use serde_json::{Map, Value};
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
@@ -192,6 +193,33 @@ fn sanitize_persisted_setting_values(state: &mut Value) {
|
|||||||
"theme",
|
"theme",
|
||||||
&["system", "light", "dark", "dracula", "nord"],
|
&["system", "light", "dark", "dracula", "nord"],
|
||||||
);
|
);
|
||||||
|
sanitize_allowed_string(
|
||||||
|
state,
|
||||||
|
"fontFamily",
|
||||||
|
&[
|
||||||
|
"system",
|
||||||
|
"inter",
|
||||||
|
"outfit",
|
||||||
|
"vazirmatn",
|
||||||
|
"noto-sans-hebrew",
|
||||||
|
"noto-sans-sc",
|
||||||
|
"roboto",
|
||||||
|
"serif",
|
||||||
|
"monospace",
|
||||||
|
],
|
||||||
|
);
|
||||||
|
sanitize_allowed_string(
|
||||||
|
state,
|
||||||
|
"windowControlStyle",
|
||||||
|
&["auto", "macos", "windows", "gnome", "minimal"],
|
||||||
|
);
|
||||||
|
sanitize_allowed_string(
|
||||||
|
state,
|
||||||
|
"language",
|
||||||
|
&["system", "en", "zh-CN", "he", "fa", "uk", "ru"],
|
||||||
|
);
|
||||||
|
sanitize_allowed_string(state, "calendarPreference", &["gregorian", "persian", "hebrew"]);
|
||||||
|
sanitize_allowed_string(state, "sidebarPosition", &["auto", "left", "right"]);
|
||||||
sanitize_allowed_string(state, "appFontSize", &["small", "standard", "large"]);
|
sanitize_allowed_string(state, "appFontSize", &["small", "standard", "large"]);
|
||||||
sanitize_allowed_string(state, "listRowDensity", &["compact", "standard", "relaxed"]);
|
sanitize_allowed_string(state, "listRowDensity", &["compact", "standard", "relaxed"]);
|
||||||
sanitize_allowed_string(state, "activeSettingsTab", &[
|
sanitize_allowed_string(state, "activeSettingsTab", &[
|
||||||
@@ -410,6 +438,10 @@ fn derived_location_path(base: &str, subfolder: &str) -> String {
|
|||||||
fn default_settings() -> PersistedSettings {
|
fn default_settings() -> PersistedSettings {
|
||||||
PersistedSettings {
|
PersistedSettings {
|
||||||
theme: Theme::System,
|
theme: Theme::System,
|
||||||
|
font_family: FontFamily::System,
|
||||||
|
window_control_style: WindowControlStyle::Auto,
|
||||||
|
calendar_preference: CalendarPreference::Gregorian,
|
||||||
|
language: "system".to_string(),
|
||||||
base_download_folder: "~/Downloads".to_string(),
|
base_download_folder: "~/Downloads".to_string(),
|
||||||
category_subfolders_enabled: true,
|
category_subfolders_enabled: true,
|
||||||
category_subfolders: default_category_subfolders(),
|
category_subfolders: default_category_subfolders(),
|
||||||
@@ -420,6 +452,7 @@ fn default_settings() -> PersistedSettings {
|
|||||||
speed_limit_preset_values: vec![1.0, 5.0, 10.0],
|
speed_limit_preset_values: vec![1.0, 5.0, 10.0],
|
||||||
logs_enabled: false,
|
logs_enabled: false,
|
||||||
is_sidebar_visible: true,
|
is_sidebar_visible: true,
|
||||||
|
sidebar_position: "auto".to_string(),
|
||||||
active_settings_tab: SettingsTab::Downloads,
|
active_settings_tab: SettingsTab::Downloads,
|
||||||
scheduler: SchedulerSettings {
|
scheduler: SchedulerSettings {
|
||||||
enabled: false,
|
enabled: false,
|
||||||
@@ -451,7 +484,9 @@ fn default_settings() -> PersistedSettings {
|
|||||||
proxy_port: 8080,
|
proxy_port: 8080,
|
||||||
custom_user_agent: String::new(),
|
custom_user_agent: String::new(),
|
||||||
ask_where_to_save_each_file: false,
|
ask_where_to_save_each_file: false,
|
||||||
|
remember_last_used_download_directory: false,
|
||||||
prevents_sleep_while_downloading: true,
|
prevents_sleep_while_downloading: true,
|
||||||
|
prevents_display_sleep_while_downloading: false,
|
||||||
media_cookie_source: MediaCookieSource::default(),
|
media_cookie_source: MediaCookieSource::default(),
|
||||||
site_logins: Vec::new(),
|
site_logins: Vec::new(),
|
||||||
auto_check_updates: true,
|
auto_check_updates: true,
|
||||||
@@ -461,6 +496,7 @@ fn default_settings() -> PersistedSettings {
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
|
use crate::ipc::{FontFamily, WindowControlStyle};
|
||||||
use super::{
|
use super::{
|
||||||
decode_stored_settings, default_settings, preserve_portable_pairing_token,
|
decode_stored_settings, default_settings, preserve_portable_pairing_token,
|
||||||
preserve_scheduler_runtime_keys,
|
preserve_scheduler_runtime_keys,
|
||||||
@@ -499,6 +535,7 @@ mod tests {
|
|||||||
"state": {
|
"state": {
|
||||||
"maxConcurrentDownloads": 7,
|
"maxConcurrentDownloads": 7,
|
||||||
"globalSpeedLimit": "2M",
|
"globalSpeedLimit": "2M",
|
||||||
|
"sidebarPosition": "right",
|
||||||
"scheduler": {
|
"scheduler": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"startTime": "06:30",
|
"startTime": "06:30",
|
||||||
@@ -516,6 +553,7 @@ mod tests {
|
|||||||
|
|
||||||
assert_eq!(settings.max_concurrent_downloads, 7);
|
assert_eq!(settings.max_concurrent_downloads, 7);
|
||||||
assert_eq!(settings.global_speed_limit, "2M");
|
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_eq!(settings.speed_limit_preset_values, vec![1.0, 5.0, 10.0]);
|
||||||
assert!(!settings.logs_enabled);
|
assert!(!settings.logs_enabled);
|
||||||
assert!(settings.scheduler.enabled);
|
assert!(settings.scheduler.enabled);
|
||||||
@@ -653,6 +691,59 @@ mod tests {
|
|||||||
assert_eq!(settings.max_concurrent_downloads, 3);
|
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 invalid_font_family_uses_system_font() {
|
||||||
|
let stored = json!({
|
||||||
|
"state": {"fontFamily": "comic-sans"},
|
||||||
|
"version": 5
|
||||||
|
});
|
||||||
|
|
||||||
|
let settings = decode_stored_settings(&Value::String(stored.to_string())).unwrap();
|
||||||
|
|
||||||
|
assert!(matches!(settings.font_family, FontFamily::System));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn curated_font_families_are_preserved() {
|
||||||
|
for value in ["vazirmatn", "noto-sans-hebrew", "noto-sans-sc", "roboto"] {
|
||||||
|
let stored = json!({
|
||||||
|
"state": {"fontFamily": value},
|
||||||
|
"version": 5
|
||||||
|
});
|
||||||
|
|
||||||
|
let settings = decode_stored_settings(&Value::String(stored.to_string())).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(serde_json::to_value(settings.font_family).unwrap(), json!(value));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn invalid_window_control_style_uses_automatic_layout() {
|
||||||
|
let stored = json!({
|
||||||
|
"state": {"windowControlStyle": "neon"},
|
||||||
|
"version": 5
|
||||||
|
});
|
||||||
|
|
||||||
|
let settings = decode_stored_settings(&Value::String(stored.to_string())).unwrap();
|
||||||
|
|
||||||
|
assert!(matches!(
|
||||||
|
settings.window_control_style,
|
||||||
|
WindowControlStyle::Auto
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn clamps_out_of_range_download_settings() {
|
fn clamps_out_of_range_download_settings() {
|
||||||
let stored = json!({
|
let stored = json!({
|
||||||
@@ -680,6 +771,7 @@ mod tests {
|
|||||||
"perServerConnections": 5,
|
"perServerConnections": 5,
|
||||||
"showNotifications": "yes",
|
"showNotifications": "yes",
|
||||||
"theme": "not-a-theme",
|
"theme": "not-a-theme",
|
||||||
|
"calendarPreference": "lunar",
|
||||||
"siteLogins": [{"id": "valid", "urlPattern": "example.com", "username": "user"}, {"id": 3}]
|
"siteLogins": [{"id": "valid", "urlPattern": "example.com", "username": "user"}, {"id": 3}]
|
||||||
},
|
},
|
||||||
"version": 3
|
"version": 3
|
||||||
@@ -692,6 +784,10 @@ mod tests {
|
|||||||
assert_eq!(settings.per_server_connections, 5);
|
assert_eq!(settings.per_server_connections, 5);
|
||||||
assert!(settings.show_notifications);
|
assert!(settings.show_notifications);
|
||||||
assert!(matches!(settings.theme, crate::ipc::Theme::System));
|
assert!(matches!(settings.theme, crate::ipc::Theme::System));
|
||||||
|
assert!(matches!(
|
||||||
|
settings.calendar_preference,
|
||||||
|
crate::ipc::CalendarPreference::Gregorian
|
||||||
|
));
|
||||||
assert_eq!(settings.site_logins.len(), 1);
|
assert_eq!(settings.site_logins.len(), 1);
|
||||||
assert_eq!(settings.site_logins[0].id, "valid");
|
assert_eq!(settings.site_logins[0].id, "valid");
|
||||||
}
|
}
|
||||||
@@ -702,6 +798,25 @@ mod tests {
|
|||||||
assert!(!default_settings().auto_add_clipboard_links);
|
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]
|
#[test]
|
||||||
fn ignores_legacy_extension_pairing_token_field() {
|
fn ignores_legacy_extension_pairing_token_field() {
|
||||||
// Older standard installs persisted `extensionPairingToken` as
|
// Older standard installs persisted `extensionPairingToken` as
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://schema.tauri.app/config/2",
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
"productName": "Firelink",
|
"productName": "Firelink",
|
||||||
"version": "1.1.1",
|
"version": "1.2.0",
|
||||||
"identifier": "com.nimbold.firelink",
|
"identifier": "com.nimbold.firelink",
|
||||||
"build": {
|
"build": {
|
||||||
"beforeDevCommand": "node scripts/stage-engines.js && npm run dev",
|
"beforeDevCommand": "node scripts/stage-engines.js && npm run dev",
|
||||||
@@ -23,7 +23,7 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"security": {
|
"security": {
|
||||||
"csp": "default-src 'self'; img-src 'self' data: https:; script-src 'self'; style-src 'self' 'unsafe-inline'; connect-src 'self' ws://localhost:* http://localhost:* http://127.0.0.1:* ws://127.0.0.1:*"
|
"csp": "default-src 'self'; img-src 'self' data: https:; script-src 'self'; style-src 'self' 'unsafe-inline'; font-src 'self'; connect-src 'self' ws://localhost:* http://localhost:* http://127.0.0.1:* ws://127.0.0.1:*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"bundle": {
|
"bundle": {
|
||||||
|
|||||||
@@ -8,17 +8,9 @@
|
|||||||
"height": 760,
|
"height": 760,
|
||||||
"minWidth": 960,
|
"minWidth": 960,
|
||||||
"minHeight": 640,
|
"minHeight": 640,
|
||||||
"titleBarStyle": "Overlay",
|
|
||||||
"trafficLightPosition": {
|
|
||||||
"x": 17,
|
|
||||||
"y": 28
|
|
||||||
},
|
|
||||||
"hiddenTitle": true,
|
|
||||||
"transparent": true,
|
"transparent": true,
|
||||||
"windowEffects": {
|
"decorations": false,
|
||||||
"effects": ["sidebar"],
|
"shadow": false
|
||||||
"state": "active"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -9,10 +9,8 @@
|
|||||||
"minWidth": 960,
|
"minWidth": 960,
|
||||||
"minHeight": 640,
|
"minHeight": 640,
|
||||||
"transparent": true,
|
"transparent": true,
|
||||||
"windowEffects": {
|
"decorations": false,
|
||||||
"effects": ["mica"],
|
"shadow": false
|
||||||
"state": "active"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
use firelink_lib::queue::{
|
use firelink_lib::queue::{
|
||||||
Aria2RefreshOutcome, QueueManager, QueuedTask, SidecarSpawner, SpawnPayload, TaskKind,
|
Aria2RecreateOutcome, Aria2RefreshOutcome, QueueManager, QueuedTask, SidecarSpawner,
|
||||||
MEDIA_RUN_CANCELLED,
|
SpawnPayload, TaskKind, MEDIA_RUN_CANCELLED,
|
||||||
};
|
};
|
||||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
@@ -13,6 +13,12 @@ use tokio::time::timeout;
|
|||||||
struct CountingSpawner {
|
struct CountingSpawner {
|
||||||
add_uri_calls: AtomicUsize,
|
add_uri_calls: AtomicUsize,
|
||||||
media_calls: AtomicUsize,
|
media_calls: AtomicUsize,
|
||||||
|
speed_limit_calls: AtomicUsize,
|
||||||
|
last_speed_limit: std::sync::Mutex<Option<String>>,
|
||||||
|
add_speed_limits: std::sync::Mutex<Vec<Option<String>>>,
|
||||||
|
block_speed_limit: std::sync::atomic::AtomicBool,
|
||||||
|
speed_limit_started: tokio::sync::Notify,
|
||||||
|
speed_limit_release: tokio::sync::Notify,
|
||||||
}
|
}
|
||||||
|
|
||||||
struct DelayedAria2Spawner {
|
struct DelayedAria2Spawner {
|
||||||
@@ -21,6 +27,13 @@ struct DelayedAria2Spawner {
|
|||||||
remove_uri_calls: AtomicUsize,
|
remove_uri_calls: AtomicUsize,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct BlockingAria2Spawner {
|
||||||
|
first_started: tokio::sync::Notify,
|
||||||
|
second_started: tokio::sync::Notify,
|
||||||
|
release_first: tokio::sync::Notify,
|
||||||
|
add_uri_calls: AtomicUsize,
|
||||||
|
}
|
||||||
|
|
||||||
struct FailFirstAria2Spawner {
|
struct FailFirstAria2Spawner {
|
||||||
add_uri_calls: AtomicUsize,
|
add_uri_calls: AtomicUsize,
|
||||||
fail_first: std::sync::atomic::AtomicBool,
|
fail_first: std::sync::atomic::AtomicBool,
|
||||||
@@ -31,6 +44,34 @@ struct RefreshOutcomeSpawner {
|
|||||||
refresh_calls: AtomicUsize,
|
refresh_calls: AtomicUsize,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct RecreateSpawner {
|
||||||
|
recreate_calls: AtomicUsize,
|
||||||
|
remove_calls: AtomicUsize,
|
||||||
|
recreate_started: tokio::sync::Notify,
|
||||||
|
release_recreate: tokio::sync::Notify,
|
||||||
|
block_recreate: std::sync::atomic::AtomicBool,
|
||||||
|
outcome: std::sync::Mutex<Aria2RecreateOutcome>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RecreateSpawner {
|
||||||
|
fn new(block_recreate: bool) -> Self {
|
||||||
|
Self {
|
||||||
|
recreate_calls: AtomicUsize::new(0),
|
||||||
|
remove_calls: AtomicUsize::new(0),
|
||||||
|
recreate_started: tokio::sync::Notify::new(),
|
||||||
|
release_recreate: tokio::sync::Notify::new(),
|
||||||
|
block_recreate: std::sync::atomic::AtomicBool::new(block_recreate),
|
||||||
|
outcome: std::sync::Mutex::new(Aria2RecreateOutcome::NewGid(
|
||||||
|
"gid-recreate-new".to_string(),
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_outcome(&self, outcome: Aria2RecreateOutcome) {
|
||||||
|
*self.outcome.lock().unwrap() = outcome;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl FailFirstAria2Spawner {
|
impl FailFirstAria2Spawner {
|
||||||
fn new() -> Self {
|
fn new() -> Self {
|
||||||
Self {
|
Self {
|
||||||
@@ -50,6 +91,49 @@ impl DelayedAria2Spawner {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl BlockingAria2Spawner {
|
||||||
|
fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
first_started: tokio::sync::Notify::new(),
|
||||||
|
second_started: tokio::sync::Notify::new(),
|
||||||
|
release_first: tokio::sync::Notify::new(),
|
||||||
|
add_uri_calls: AtomicUsize::new(0),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl SidecarSpawner for BlockingAria2Spawner {
|
||||||
|
async fn add_uri(&self, _id: &str, _payload: &SpawnPayload) -> Result<String, String> {
|
||||||
|
let call = self.add_uri_calls.fetch_add(1, Ordering::SeqCst) + 1;
|
||||||
|
match call {
|
||||||
|
1 => {
|
||||||
|
self.first_started.notify_one();
|
||||||
|
self.release_first.notified().await;
|
||||||
|
Ok("gid-first".to_string())
|
||||||
|
}
|
||||||
|
2 => {
|
||||||
|
self.second_started.notify_one();
|
||||||
|
Ok("gid-second".to_string())
|
||||||
|
}
|
||||||
|
_ => Ok(format!("gid-{call}")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn remove_uri(&self, _gid: &str) -> Result<(), String> {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn run_media(
|
||||||
|
&self,
|
||||||
|
_id: &str,
|
||||||
|
_payload: &SpawnPayload,
|
||||||
|
_generation: u64,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
unreachable!("media is not used by blocking aria2 tests")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[async_trait::async_trait]
|
#[async_trait::async_trait]
|
||||||
impl SidecarSpawner for DelayedAria2Spawner {
|
impl SidecarSpawner for DelayedAria2Spawner {
|
||||||
async fn add_uri(&self, _id: &str, _payload: &SpawnPayload) -> Result<String, String> {
|
async fn add_uri(&self, _id: &str, _payload: &SpawnPayload) -> Result<String, String> {
|
||||||
@@ -102,6 +186,12 @@ impl CountingSpawner {
|
|||||||
Self {
|
Self {
|
||||||
add_uri_calls: AtomicUsize::new(0),
|
add_uri_calls: AtomicUsize::new(0),
|
||||||
media_calls: AtomicUsize::new(0),
|
media_calls: AtomicUsize::new(0),
|
||||||
|
speed_limit_calls: AtomicUsize::new(0),
|
||||||
|
last_speed_limit: std::sync::Mutex::new(None),
|
||||||
|
add_speed_limits: std::sync::Mutex::new(Vec::new()),
|
||||||
|
block_speed_limit: std::sync::atomic::AtomicBool::new(false),
|
||||||
|
speed_limit_started: tokio::sync::Notify::new(),
|
||||||
|
speed_limit_release: tokio::sync::Notify::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -132,14 +222,70 @@ impl SidecarSpawner for RefreshOutcomeSpawner {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[async_trait::async_trait]
|
#[async_trait::async_trait]
|
||||||
impl firelink_lib::queue::SidecarSpawner for CountingSpawner {
|
impl SidecarSpawner for RecreateSpawner {
|
||||||
async fn add_uri(&self, _id: &str, _payload: &SpawnPayload) -> Result<String, String> {
|
async fn add_uri(&self, _id: &str, _payload: &SpawnPayload) -> Result<String, String> {
|
||||||
|
Ok("gid-recreate-old".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn remove_uri(&self, gid: &str) -> Result<(), String> {
|
||||||
|
assert_eq!(gid, "gid-recreate-new");
|
||||||
|
self.remove_calls.fetch_add(1, Ordering::SeqCst);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn recreate_uri(
|
||||||
|
&self,
|
||||||
|
_id: &str,
|
||||||
|
_gid: &str,
|
||||||
|
_payload: &SpawnPayload,
|
||||||
|
) -> Result<Aria2RecreateOutcome, String> {
|
||||||
|
self.recreate_calls.fetch_add(1, Ordering::SeqCst);
|
||||||
|
self.recreate_started.notify_one();
|
||||||
|
if self.block_recreate.load(Ordering::SeqCst) {
|
||||||
|
self.release_recreate.notified().await;
|
||||||
|
}
|
||||||
|
Ok(self.outcome.lock().unwrap().clone())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn run_media(
|
||||||
|
&self,
|
||||||
|
_id: &str,
|
||||||
|
_payload: &SpawnPayload,
|
||||||
|
_generation: u64,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
unreachable!("media is not used by recreation tests")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl firelink_lib::queue::SidecarSpawner for CountingSpawner {
|
||||||
|
async fn add_uri(&self, _id: &str, payload: &SpawnPayload) -> Result<String, String> {
|
||||||
self.add_uri_calls.fetch_add(1, Ordering::SeqCst);
|
self.add_uri_calls.fetch_add(1, Ordering::SeqCst);
|
||||||
|
self.add_speed_limits
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.push(payload.speed_limit.clone());
|
||||||
Ok(format!("gid-{}", self.add_uri_calls.load(Ordering::SeqCst)))
|
Ok(format!("gid-{}", self.add_uri_calls.load(Ordering::SeqCst)))
|
||||||
}
|
}
|
||||||
async fn remove_uri(&self, _gid: &str) -> Result<(), String> {
|
async fn remove_uri(&self, _gid: &str) -> Result<(), String> {
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
async fn set_download_speed_limit(
|
||||||
|
&self,
|
||||||
|
_gid: &str,
|
||||||
|
limit: Option<&str>,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
self.speed_limit_calls.fetch_add(1, Ordering::SeqCst);
|
||||||
|
*self.last_speed_limit.lock().unwrap() = limit.map(str::to_string);
|
||||||
|
if self
|
||||||
|
.block_speed_limit
|
||||||
|
.load(std::sync::atomic::Ordering::SeqCst)
|
||||||
|
{
|
||||||
|
self.speed_limit_started.notify_one();
|
||||||
|
self.speed_limit_release.notified().await;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
async fn run_media(&self, _id: &str, _payload: &SpawnPayload, _generation: u64) -> Result<(), String> {
|
async fn run_media(&self, _id: &str, _payload: &SpawnPayload, _generation: u64) -> Result<(), String> {
|
||||||
self.media_calls.fetch_add(1, Ordering::SeqCst);
|
self.media_calls.fetch_add(1, Ordering::SeqCst);
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -258,6 +404,207 @@ async fn ensure_aria2_permit_does_not_double_acquire() {
|
|||||||
assert_eq!(mgr.available_permits(), 2);
|
assert_eq!(mgr.available_permits(), 2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn backend_power_activity_follows_active_permits_not_queue_reservations() {
|
||||||
|
let (mgr, _spawner) = make_manager(2);
|
||||||
|
assert_eq!(mgr.power_manager().active_transfer_count(), 0);
|
||||||
|
|
||||||
|
let reservation = mgr
|
||||||
|
.reserve_enqueue_generation("queued", 1)
|
||||||
|
.await
|
||||||
|
.expect("queue reservation should succeed");
|
||||||
|
assert_eq!(mgr.power_manager().active_transfer_count(), 0);
|
||||||
|
mgr.rollback_enqueue_reservation("queued", 1, reservation)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
assert!(mgr.ensure_aria2_permit("active").await);
|
||||||
|
assert_eq!(mgr.power_manager().active_transfer_count(), 1);
|
||||||
|
mgr.release_permit("active").await;
|
||||||
|
assert_eq!(mgr.power_manager().active_transfer_count(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn live_aria2_speed_limit_updates_the_current_gid_and_payload() {
|
||||||
|
let (manager, spawner) = make_manager(1);
|
||||||
|
let manager = Arc::new(manager);
|
||||||
|
let mut task = aria2_task("speed-limit");
|
||||||
|
task.payload.speed_limit = Some("1M".to_string());
|
||||||
|
manager.push(task).await.unwrap();
|
||||||
|
|
||||||
|
let dispatcher = {
|
||||||
|
let manager = Arc::clone(&manager);
|
||||||
|
tokio::spawn(async move { manager.run_dispatcher().await })
|
||||||
|
};
|
||||||
|
timeout(Duration::from_secs(1), async {
|
||||||
|
loop {
|
||||||
|
if manager.aria2_gid_for_download("speed-limit").is_some() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.expect("aria2 dispatch should register a gid");
|
||||||
|
|
||||||
|
manager
|
||||||
|
.set_aria2_download_speed_limit("speed-limit", Some("512K".to_string()))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(spawner.speed_limit_calls.load(Ordering::SeqCst), 1);
|
||||||
|
assert_eq!(
|
||||||
|
spawner.last_speed_limit.lock().unwrap().as_deref(),
|
||||||
|
Some("512K")
|
||||||
|
);
|
||||||
|
assert!(manager.aria2_speed_limited("speed-limit").await);
|
||||||
|
|
||||||
|
manager
|
||||||
|
.set_aria2_download_speed_limit("speed-limit", None)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(spawner.speed_limit_calls.load(Ordering::SeqCst), 2);
|
||||||
|
assert!(spawner.last_speed_limit.lock().unwrap().is_none());
|
||||||
|
assert!(!manager.aria2_speed_limited("speed-limit").await);
|
||||||
|
|
||||||
|
manager
|
||||||
|
.apply_completion(
|
||||||
|
"speed-limit",
|
||||||
|
firelink_lib::queue::PendingOutcome::Complete,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
dispatcher.abort();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn live_aria2_speed_limit_rejects_invalid_and_non_active_requests() {
|
||||||
|
let (manager, spawner) = make_manager(1);
|
||||||
|
assert!(manager
|
||||||
|
.set_aria2_download_speed_limit("missing", Some("not-a-rate".to_string()))
|
||||||
|
.await
|
||||||
|
.is_err());
|
||||||
|
assert!(manager
|
||||||
|
.set_aria2_download_speed_limit("missing", Some("512K".to_string()))
|
||||||
|
.await
|
||||||
|
.is_err());
|
||||||
|
assert_eq!(spawner.speed_limit_calls.load(Ordering::SeqCst), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn live_aria2_speed_limit_does_not_update_payload_after_gid_replacement() {
|
||||||
|
let (manager, spawner) = make_manager(1);
|
||||||
|
let manager = Arc::new(manager);
|
||||||
|
let mut task = aria2_task("speed-stale");
|
||||||
|
task.payload.speed_limit = Some("1M".to_string());
|
||||||
|
manager.push(task).await.unwrap();
|
||||||
|
|
||||||
|
let dispatcher = {
|
||||||
|
let manager = Arc::clone(&manager);
|
||||||
|
tokio::spawn(async move { manager.run_dispatcher().await })
|
||||||
|
};
|
||||||
|
timeout(Duration::from_secs(1), async {
|
||||||
|
loop {
|
||||||
|
if manager.aria2_gid_for_download("speed-stale").is_some() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.expect("aria2 dispatch should register a gid");
|
||||||
|
|
||||||
|
spawner
|
||||||
|
.block_speed_limit
|
||||||
|
.store(true, std::sync::atomic::Ordering::SeqCst);
|
||||||
|
let started = spawner.speed_limit_started.notified();
|
||||||
|
let setter = {
|
||||||
|
let manager = Arc::clone(&manager);
|
||||||
|
tokio::spawn(async move {
|
||||||
|
manager
|
||||||
|
.set_aria2_download_speed_limit("speed-stale", Some("512K".to_string()))
|
||||||
|
.await
|
||||||
|
})
|
||||||
|
};
|
||||||
|
timeout(Duration::from_secs(1), started)
|
||||||
|
.await
|
||||||
|
.expect("speed RPC should start");
|
||||||
|
|
||||||
|
manager
|
||||||
|
.remember_gid("speed-stale".to_string(), "gid-replaced".to_string())
|
||||||
|
.await;
|
||||||
|
spawner.speed_limit_release.notify_one();
|
||||||
|
assert!(setter.await.unwrap().is_err());
|
||||||
|
assert!(manager.aria2_speed_limited("speed-stale").await);
|
||||||
|
|
||||||
|
manager
|
||||||
|
.apply_completion(
|
||||||
|
"speed-stale",
|
||||||
|
firelink_lib::queue::PendingOutcome::Complete,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
dispatcher.abort();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn retry_readds_aria2_with_the_latest_live_speed_limit() {
|
||||||
|
use firelink_lib::queue::PendingOutcome;
|
||||||
|
|
||||||
|
let (mgr, spawner) = make_manager(1);
|
||||||
|
let manager = Arc::new(mgr);
|
||||||
|
let mut task = aria2_task("speed-retry");
|
||||||
|
task.payload.max_tries = Some(1);
|
||||||
|
task.payload.speed_limit = Some("1M".to_string());
|
||||||
|
manager.push(task).await.unwrap();
|
||||||
|
let dispatcher = {
|
||||||
|
let manager = Arc::clone(&manager);
|
||||||
|
tokio::spawn(async move { manager.run_dispatcher().await })
|
||||||
|
};
|
||||||
|
|
||||||
|
timeout(Duration::from_secs(1), async {
|
||||||
|
loop {
|
||||||
|
if spawner.add_uri_calls.load(Ordering::SeqCst) >= 1 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.expect("initial aria2 add should run");
|
||||||
|
manager
|
||||||
|
.handle_aria2_event(
|
||||||
|
"gid-1",
|
||||||
|
PendingOutcome::Error(
|
||||||
|
"aria2 error code 1: Failed to receive data, cause: protocol error".to_string(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
manager
|
||||||
|
.set_aria2_download_speed_limit("speed-retry", Some("512K".to_string()))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
timeout(Duration::from_secs(4), async {
|
||||||
|
loop {
|
||||||
|
if spawner.add_uri_calls.load(Ordering::SeqCst) >= 2 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
tokio::time::sleep(Duration::from_millis(25)).await;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.expect("retry should re-add after backoff");
|
||||||
|
assert_eq!(
|
||||||
|
spawner.add_speed_limits.lock().unwrap().as_slice(),
|
||||||
|
&[Some("1M".to_string()), Some("512K".to_string())]
|
||||||
|
);
|
||||||
|
|
||||||
|
manager
|
||||||
|
.handle_aria2_event(
|
||||||
|
"gid-2",
|
||||||
|
PendingOutcome::Error("permanent failure".to_string()),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
dispatcher.abort();
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn stale_aria2_permit_candidate_cannot_replace_current_permit() {
|
async fn stale_aria2_permit_candidate_cannot_replace_current_permit() {
|
||||||
let (mgr, _spawner) = make_manager(2);
|
let (mgr, _spawner) = make_manager(2);
|
||||||
@@ -357,6 +704,32 @@ async fn resumed_gid_rebinds_to_the_new_control_epoch() {
|
|||||||
assert!(manager.aria2_gid_for_download("resumed-gid").is_none());
|
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]
|
#[tokio::test]
|
||||||
async fn forgetting_aria2_gid_clears_mapping_without_releasing_twice() {
|
async fn forgetting_aria2_gid_clears_mapping_without_releasing_twice() {
|
||||||
let (mgr, _spawner) = make_manager(1);
|
let (mgr, _spawner) = make_manager(1);
|
||||||
@@ -543,6 +916,285 @@ async fn aria2_resume_waits_for_shrunk_capacity() {
|
|||||||
.expect("resume task should not panic"));
|
.expect("resume task should not panic"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn dispatcher_skips_a_full_front_queue_for_later_eligible_work() {
|
||||||
|
let (manager, _spawner) = make_manager(2);
|
||||||
|
let manager = Arc::new(manager);
|
||||||
|
manager
|
||||||
|
.replace_queue_limits(vec![
|
||||||
|
("queue-a".to_string(), Some(1)),
|
||||||
|
("queue-b".to_string(), Some(1)),
|
||||||
|
])
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
manager.push(aria2_task_in_queue("a1", "queue-a")).await.unwrap();
|
||||||
|
manager.push(aria2_task_in_queue("a2", "queue-a")).await.unwrap();
|
||||||
|
manager.push(aria2_task_in_queue("b1", "queue-b")).await.unwrap();
|
||||||
|
let dispatcher = {
|
||||||
|
let manager = Arc::clone(&manager);
|
||||||
|
tokio::spawn(async move { manager.run_dispatcher().await })
|
||||||
|
};
|
||||||
|
|
||||||
|
timeout(Duration::from_secs(1), async {
|
||||||
|
while manager.aria2_gid_for_download("a1").is_none()
|
||||||
|
|| manager.aria2_gid_for_download("b1").is_none()
|
||||||
|
{
|
||||||
|
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.expect("both eligible queues should finish dispatching");
|
||||||
|
assert!(manager.aria2_gid_for_download("a1").is_some());
|
||||||
|
assert!(manager.aria2_gid_for_download("b1").is_some());
|
||||||
|
assert!(manager.aria2_gid_for_download("a2").is_none());
|
||||||
|
|
||||||
|
dispatcher.abort();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn dispatcher_admits_next_task_while_first_add_uri_is_blocked() {
|
||||||
|
let app = mock_builder()
|
||||||
|
.build(mock_context(noop_assets()))
|
||||||
|
.expect("mock app");
|
||||||
|
let spawner = Arc::new(BlockingAria2Spawner::new());
|
||||||
|
let manager = Arc::new(QueueManager::test_new(
|
||||||
|
app.handle().clone(),
|
||||||
|
2,
|
||||||
|
spawner.clone(),
|
||||||
|
));
|
||||||
|
manager.push(aria2_task("blocked-first")).await.unwrap();
|
||||||
|
manager.push(aria2_task("admitted-second")).await.unwrap();
|
||||||
|
|
||||||
|
let first_started = spawner.first_started.notified();
|
||||||
|
let dispatcher = {
|
||||||
|
let manager = Arc::clone(&manager);
|
||||||
|
tokio::spawn(async move { manager.run_dispatcher().await })
|
||||||
|
};
|
||||||
|
timeout(Duration::from_secs(1), first_started)
|
||||||
|
.await
|
||||||
|
.expect("the first addUri should start");
|
||||||
|
|
||||||
|
timeout(Duration::from_secs(1), spawner.second_started.notified())
|
||||||
|
.await
|
||||||
|
.expect("a blocked addUri must not serialize admission of the next task");
|
||||||
|
|
||||||
|
spawner.release_first.notify_one();
|
||||||
|
timeout(Duration::from_secs(1), async {
|
||||||
|
while manager.aria2_gid_for_download("blocked-first").is_none()
|
||||||
|
|| manager.aria2_gid_for_download("admitted-second").is_none()
|
||||||
|
{
|
||||||
|
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.expect("both admitted tasks should finish dispatching");
|
||||||
|
|
||||||
|
dispatcher.abort();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn dispatcher_rotates_eligible_queues_without_starving_later_work() {
|
||||||
|
let (manager, spawner) = make_manager(1);
|
||||||
|
let manager = Arc::new(manager);
|
||||||
|
manager
|
||||||
|
.replace_queue_limits(vec![
|
||||||
|
("queue-a".to_string(), Some(1)),
|
||||||
|
("queue-b".to_string(), Some(1)),
|
||||||
|
])
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
manager.push(aria2_task_in_queue("a1", "queue-a")).await.unwrap();
|
||||||
|
manager.push(aria2_task_in_queue("a2", "queue-a")).await.unwrap();
|
||||||
|
manager.push(aria2_task_in_queue("b1", "queue-b")).await.unwrap();
|
||||||
|
let dispatcher = {
|
||||||
|
let manager = Arc::clone(&manager);
|
||||||
|
tokio::spawn(async move { manager.run_dispatcher().await })
|
||||||
|
};
|
||||||
|
|
||||||
|
timeout(Duration::from_secs(1), async {
|
||||||
|
while spawner.add_uri_calls.load(Ordering::SeqCst) < 1 {
|
||||||
|
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.expect("first queue task should dispatch");
|
||||||
|
manager.release_permit("a1").await;
|
||||||
|
|
||||||
|
timeout(Duration::from_secs(1), async {
|
||||||
|
while manager.aria2_gid_for_download("b1").is_none() {
|
||||||
|
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.expect("later queue should be selected before another task from queue-a");
|
||||||
|
assert!(manager.aria2_gid_for_download("a2").is_none());
|
||||||
|
|
||||||
|
dispatcher.abort();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn queue_limit_increase_wakes_waiting_work_and_decrease_keeps_active_tasks() {
|
||||||
|
let (manager, spawner) = make_manager(3);
|
||||||
|
let manager = Arc::new(manager);
|
||||||
|
manager
|
||||||
|
.replace_queue_limits(vec![("queue-a".to_string(), Some(2))])
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
for id in ["a1", "a2", "a3"] {
|
||||||
|
manager.push(aria2_task_in_queue(id, "queue-a")).await.unwrap();
|
||||||
|
}
|
||||||
|
let dispatcher = {
|
||||||
|
let manager = Arc::clone(&manager);
|
||||||
|
tokio::spawn(async move { manager.run_dispatcher().await })
|
||||||
|
};
|
||||||
|
|
||||||
|
timeout(Duration::from_secs(1), async {
|
||||||
|
while spawner.add_uri_calls.load(Ordering::SeqCst) < 2 {
|
||||||
|
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.expect("queue override should allow two active transfers");
|
||||||
|
manager
|
||||||
|
.replace_queue_limits(vec![("queue-a".to_string(), Some(1))])
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(spawner.add_uri_calls.load(Ordering::SeqCst), 2);
|
||||||
|
|
||||||
|
manager.release_permit("a1").await;
|
||||||
|
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||||
|
assert_eq!(
|
||||||
|
spawner.add_uri_calls.load(Ordering::SeqCst),
|
||||||
|
2,
|
||||||
|
"reducing a queue limit must not admit work while one active transfer remains"
|
||||||
|
);
|
||||||
|
|
||||||
|
manager.release_permit("a2").await;
|
||||||
|
timeout(Duration::from_secs(1), async {
|
||||||
|
while spawner.add_uri_calls.load(Ordering::SeqCst) < 3 {
|
||||||
|
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.expect("queue work should resume after active count falls below the reduced limit");
|
||||||
|
|
||||||
|
dispatcher.abort();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn queue_overrides_never_exceed_the_global_ceiling() {
|
||||||
|
let (manager, spawner) = make_manager(2);
|
||||||
|
let manager = Arc::new(manager);
|
||||||
|
manager
|
||||||
|
.replace_queue_limits(vec![
|
||||||
|
("queue-a".to_string(), Some(12)),
|
||||||
|
("queue-b".to_string(), Some(12)),
|
||||||
|
])
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
for (id, queue_id) in [
|
||||||
|
("a1", "queue-a"),
|
||||||
|
("a2", "queue-a"),
|
||||||
|
("b1", "queue-b"),
|
||||||
|
("b2", "queue-b"),
|
||||||
|
] {
|
||||||
|
manager.push(aria2_task_in_queue(id, queue_id)).await.unwrap();
|
||||||
|
}
|
||||||
|
let dispatcher = {
|
||||||
|
let manager = Arc::clone(&manager);
|
||||||
|
tokio::spawn(async move { manager.run_dispatcher().await })
|
||||||
|
};
|
||||||
|
|
||||||
|
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||||
|
assert_eq!(spawner.add_uri_calls.load(Ordering::SeqCst), 2);
|
||||||
|
assert_eq!(manager.available_permits(), 0);
|
||||||
|
|
||||||
|
dispatcher.abort();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn queue_limit_configuration_rejects_malformed_input() {
|
||||||
|
let (manager, _spawner) = make_manager(2);
|
||||||
|
assert!(manager
|
||||||
|
.replace_queue_limits(vec![(String::new(), Some(1))])
|
||||||
|
.await
|
||||||
|
.is_err());
|
||||||
|
assert!(manager
|
||||||
|
.replace_queue_limits(vec![("queue-a".to_string(), Some(0))])
|
||||||
|
.await
|
||||||
|
.is_err());
|
||||||
|
assert!(manager
|
||||||
|
.replace_queue_limits(vec![
|
||||||
|
("queue-a".to_string(), Some(1)),
|
||||||
|
("queue-a".to_string(), None),
|
||||||
|
])
|
||||||
|
.await
|
||||||
|
.is_err());
|
||||||
|
manager
|
||||||
|
.replace_queue_limits(vec![("queue-a".to_string(), None)])
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn stale_queue_resume_reservation_is_removed_when_activation_fails() {
|
||||||
|
let (manager, _spawner) = make_manager(1);
|
||||||
|
let previous = manager
|
||||||
|
.reserve_enqueue_generation("candidate", 1)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert!(previous.is_none());
|
||||||
|
let candidate = manager
|
||||||
|
.acquire_aria2_permit_candidate_for_queue("candidate", "queue-a", 1)
|
||||||
|
.await
|
||||||
|
.expect("candidate should reserve the queue slot");
|
||||||
|
|
||||||
|
manager.release_registered_id("candidate").await;
|
||||||
|
assert!(!manager
|
||||||
|
.park_aria2_permit_if_missing_for_queue("candidate", "queue-a", 1, candidate)
|
||||||
|
.await);
|
||||||
|
assert_eq!(manager.available_permits(), 1);
|
||||||
|
|
||||||
|
assert!(manager.ensure_aria2_permit_for_queue("replacement", "queue-a").await);
|
||||||
|
manager.release_permit("replacement").await;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn duplicate_pending_id_cannot_replace_an_existing_queue_ownership() {
|
||||||
|
let (manager, spawner) = make_manager(2);
|
||||||
|
let manager = Arc::new(manager);
|
||||||
|
manager
|
||||||
|
.replace_queue_limits(vec![
|
||||||
|
("queue-a".to_string(), Some(1)),
|
||||||
|
("queue-b".to_string(), Some(1)),
|
||||||
|
])
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert!(manager
|
||||||
|
.ensure_aria2_permit_for_queue("duplicate", "queue-b")
|
||||||
|
.await);
|
||||||
|
manager
|
||||||
|
.reserve_enqueue_generation("duplicate", 1)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
manager
|
||||||
|
.commit_reserved_enqueue(aria2_task_in_queue("duplicate", "queue-a"), 1)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let dispatcher = {
|
||||||
|
let manager = Arc::clone(&manager);
|
||||||
|
tokio::spawn(async move { manager.run_dispatcher().await })
|
||||||
|
};
|
||||||
|
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||||
|
|
||||||
|
assert_eq!(spawner.add_uri_calls.load(Ordering::SeqCst), 0);
|
||||||
|
assert_eq!(manager.available_permits(), 1);
|
||||||
|
manager.release_permit("duplicate").await;
|
||||||
|
dispatcher.abort();
|
||||||
|
}
|
||||||
|
|
||||||
fn aria2_task(id: &str) -> QueuedTask {
|
fn aria2_task(id: &str) -> QueuedTask {
|
||||||
QueuedTask {
|
QueuedTask {
|
||||||
id: id.to_string(),
|
id: id.to_string(),
|
||||||
@@ -553,6 +1205,12 @@ fn aria2_task(id: &str) -> QueuedTask {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn aria2_task_in_queue(id: &str, queue_id: &str) -> QueuedTask {
|
||||||
|
let mut task = aria2_task(id);
|
||||||
|
task.queue_id = queue_id.to_string();
|
||||||
|
task
|
||||||
|
}
|
||||||
|
|
||||||
fn media_task(id: &str) -> QueuedTask {
|
fn media_task(id: &str) -> QueuedTask {
|
||||||
QueuedTask {
|
QueuedTask {
|
||||||
id: id.to_string(),
|
id: id.to_string(),
|
||||||
@@ -688,11 +1346,11 @@ async fn failed_refresh_that_leaves_gid_paused_releases_permit_but_keeps_resume_
|
|||||||
outcome: Aria2RefreshOutcome::Paused,
|
outcome: Aria2RefreshOutcome::Paused,
|
||||||
refresh_calls: AtomicUsize::new(0),
|
refresh_calls: AtomicUsize::new(0),
|
||||||
});
|
});
|
||||||
let manager = QueueManager::test_new(
|
let manager = Arc::new(QueueManager::test_new(
|
||||||
app.handle().clone(),
|
app.handle().clone(),
|
||||||
1,
|
1,
|
||||||
Arc::clone(&spawner) as Arc<dyn SidecarSpawner>,
|
Arc::clone(&spawner) as Arc<dyn SidecarSpawner>,
|
||||||
);
|
));
|
||||||
manager.push(aria2_task("refresh-paused")).await.unwrap();
|
manager.push(aria2_task("refresh-paused")).await.unwrap();
|
||||||
assert!(manager.ensure_aria2_permit("refresh-paused").await);
|
assert!(manager.ensure_aria2_permit("refresh-paused").await);
|
||||||
manager
|
manager
|
||||||
@@ -715,6 +1373,159 @@ async fn failed_refresh_that_leaves_gid_paused_releases_permit_but_keeps_resume_
|
|||||||
assert!(manager.is_registered("refresh-paused").await);
|
assert!(manager.is_registered("refresh-paused").await);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn connection_recovery_recreates_gid_without_releasing_transfer_permit() {
|
||||||
|
let app = mock_builder()
|
||||||
|
.build(mock_context(noop_assets()))
|
||||||
|
.expect("mock app");
|
||||||
|
let spawner = Arc::new(RecreateSpawner::new(false));
|
||||||
|
let manager = Arc::new(QueueManager::test_new(
|
||||||
|
app.handle().clone(),
|
||||||
|
1,
|
||||||
|
Arc::clone(&spawner) as Arc<dyn SidecarSpawner>,
|
||||||
|
));
|
||||||
|
manager.push(aria2_task("recreate")).await.unwrap();
|
||||||
|
let dispatcher = {
|
||||||
|
let manager = Arc::clone(&manager);
|
||||||
|
tokio::spawn(async move { manager.run_dispatcher().await })
|
||||||
|
};
|
||||||
|
|
||||||
|
timeout(Duration::from_secs(1), async {
|
||||||
|
loop {
|
||||||
|
if manager.aria2_gid_for_download("recreate").as_deref() == Some("gid-recreate-old") {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.expect("initial gid should be mapped");
|
||||||
|
|
||||||
|
let epoch = manager.current_aria2_control_epoch("recreate").await;
|
||||||
|
manager
|
||||||
|
.refresh_aria2_connections("recreate", "gid-recreate-old", epoch)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(spawner.recreate_calls.load(Ordering::SeqCst), 1);
|
||||||
|
assert_eq!(
|
||||||
|
manager.aria2_gid_for_download("recreate").as_deref(),
|
||||||
|
Some("gid-recreate-new")
|
||||||
|
);
|
||||||
|
assert!(manager.has_active_permit("recreate").await);
|
||||||
|
assert_eq!(manager.available_permits(), 0);
|
||||||
|
|
||||||
|
manager.release_permit("recreate").await;
|
||||||
|
dispatcher.abort();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn stale_recreated_gid_is_removed_without_rebinding_or_leaking_permit() {
|
||||||
|
let app = mock_builder()
|
||||||
|
.build(mock_context(noop_assets()))
|
||||||
|
.expect("mock app");
|
||||||
|
let spawner = Arc::new(RecreateSpawner::new(true));
|
||||||
|
let manager = Arc::new(QueueManager::test_new(
|
||||||
|
app.handle().clone(),
|
||||||
|
1,
|
||||||
|
Arc::clone(&spawner) as Arc<dyn SidecarSpawner>,
|
||||||
|
));
|
||||||
|
manager.push(aria2_task("recreate-stale")).await.unwrap();
|
||||||
|
let dispatcher = {
|
||||||
|
let manager = Arc::clone(&manager);
|
||||||
|
tokio::spawn(async move { manager.run_dispatcher().await })
|
||||||
|
};
|
||||||
|
|
||||||
|
timeout(Duration::from_secs(1), async {
|
||||||
|
loop {
|
||||||
|
if manager.aria2_gid_for_download("recreate-stale").as_deref()
|
||||||
|
== Some("gid-recreate-old")
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.expect("initial gid should be mapped");
|
||||||
|
|
||||||
|
let epoch = manager.current_aria2_control_epoch("recreate-stale").await;
|
||||||
|
let refresh = {
|
||||||
|
let manager = Arc::clone(&manager);
|
||||||
|
tokio::spawn(async move {
|
||||||
|
manager
|
||||||
|
.refresh_aria2_connections("recreate-stale", "gid-recreate-old", epoch)
|
||||||
|
.await
|
||||||
|
})
|
||||||
|
};
|
||||||
|
timeout(Duration::from_secs(1), spawner.recreate_started.notified())
|
||||||
|
.await
|
||||||
|
.expect("recreation should reach its async boundary");
|
||||||
|
manager.next_aria2_control_epoch("recreate-stale").await;
|
||||||
|
spawner.release_recreate.notify_one();
|
||||||
|
refresh.await.unwrap().unwrap();
|
||||||
|
|
||||||
|
assert_eq!(spawner.remove_calls.load(Ordering::SeqCst), 1);
|
||||||
|
assert_eq!(
|
||||||
|
manager.aria2_gid_for_download("recreate-stale").as_deref(),
|
||||||
|
Some("gid-recreate-old")
|
||||||
|
);
|
||||||
|
assert!(manager.has_active_permit("recreate-stale").await);
|
||||||
|
assert_eq!(manager.available_permits(), 0);
|
||||||
|
|
||||||
|
manager.release_permit("recreate-stale").await;
|
||||||
|
dispatcher.abort();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn unavailable_recreated_gid_retires_stale_lifecycle_for_manual_resume() {
|
||||||
|
let app = mock_builder()
|
||||||
|
.build(mock_context(noop_assets()))
|
||||||
|
.expect("mock app");
|
||||||
|
let spawner = Arc::new(RecreateSpawner::new(false));
|
||||||
|
spawner.set_outcome(Aria2RecreateOutcome::Unavailable(
|
||||||
|
"aria2.addUri unavailable".to_string(),
|
||||||
|
));
|
||||||
|
let manager = Arc::new(QueueManager::test_new(
|
||||||
|
app.handle().clone(),
|
||||||
|
1,
|
||||||
|
Arc::clone(&spawner) as Arc<dyn SidecarSpawner>,
|
||||||
|
));
|
||||||
|
manager.push(aria2_task("recreate-unavailable")).await.unwrap();
|
||||||
|
let dispatcher = {
|
||||||
|
let manager = Arc::clone(&manager);
|
||||||
|
tokio::spawn(async move { manager.run_dispatcher().await })
|
||||||
|
};
|
||||||
|
|
||||||
|
timeout(Duration::from_secs(1), async {
|
||||||
|
loop {
|
||||||
|
if manager
|
||||||
|
.aria2_gid_for_download("recreate-unavailable")
|
||||||
|
.as_deref()
|
||||||
|
== Some("gid-recreate-old")
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.expect("initial gid should be mapped");
|
||||||
|
|
||||||
|
let epoch = manager.current_aria2_control_epoch("recreate-unavailable").await;
|
||||||
|
manager
|
||||||
|
.refresh_aria2_connections("recreate-unavailable", "gid-recreate-old", epoch)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(manager.aria2_gid_for_download("recreate-unavailable"), None);
|
||||||
|
assert!(!manager.has_active_permit("recreate-unavailable").await);
|
||||||
|
assert!(!manager.is_registered("recreate-unavailable").await);
|
||||||
|
assert_eq!(manager.available_permits(), 1);
|
||||||
|
|
||||||
|
dispatcher.abort();
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn stale_refresh_observation_cannot_touch_a_newer_control_epoch() {
|
async fn stale_refresh_observation_cannot_touch_a_newer_control_epoch() {
|
||||||
let app = mock_builder()
|
let app = mock_builder()
|
||||||
@@ -724,11 +1535,11 @@ async fn stale_refresh_observation_cannot_touch_a_newer_control_epoch() {
|
|||||||
outcome: Aria2RefreshOutcome::Resumed,
|
outcome: Aria2RefreshOutcome::Resumed,
|
||||||
refresh_calls: AtomicUsize::new(0),
|
refresh_calls: AtomicUsize::new(0),
|
||||||
});
|
});
|
||||||
let manager = QueueManager::test_new(
|
let manager = Arc::new(QueueManager::test_new(
|
||||||
app.handle().clone(),
|
app.handle().clone(),
|
||||||
1,
|
1,
|
||||||
Arc::clone(&spawner) as Arc<dyn SidecarSpawner>,
|
Arc::clone(&spawner) as Arc<dyn SidecarSpawner>,
|
||||||
);
|
));
|
||||||
manager.push(aria2_task("refresh-stale")).await.unwrap();
|
manager.push(aria2_task("refresh-stale")).await.unwrap();
|
||||||
assert!(manager.ensure_aria2_permit("refresh-stale").await);
|
assert!(manager.ensure_aria2_permit("refresh-stale").await);
|
||||||
manager
|
manager
|
||||||
@@ -1221,6 +2032,33 @@ async fn multi_move_reorders_selected_items_as_one_atomic_block() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn target_move_reorders_a_selected_block_and_clamps_the_target() {
|
||||||
|
use firelink_lib::ipc::QueueDirection;
|
||||||
|
|
||||||
|
let (mgr, _spawner) = make_manager(3);
|
||||||
|
for id in ["a", "b", "c", "d", "e"] {
|
||||||
|
mgr.push(sample_task(id)).await.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
let selected = vec!["b".to_string(), "d".to_string()];
|
||||||
|
assert_eq!(
|
||||||
|
mgr.move_many_in_queue_to(&selected, "main", 1).await,
|
||||||
|
vec!["a", "b", "d", "c", "e"]
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
mgr.move_many_in_queue_to(&selected, "main", usize::MAX).await,
|
||||||
|
vec!["a", "c", "e", "b", "d"]
|
||||||
|
);
|
||||||
|
|
||||||
|
// The original direction API remains unchanged for keyboard/button moves.
|
||||||
|
assert_eq!(
|
||||||
|
mgr.move_many_in_queue(&selected, "main", QueueDirection::Up)
|
||||||
|
.await,
|
||||||
|
vec!["a", "c", "b", "d", "e"]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn moving_one_queue_does_not_reorder_another_queue() {
|
async fn moving_one_queue_does_not_reorder_another_queue() {
|
||||||
use firelink_lib::ipc::QueueDirection;
|
use firelink_lib::ipc::QueueDirection;
|
||||||
|
|||||||
+356
-163
@@ -1,12 +1,12 @@
|
|||||||
import { initMediaDomains, isActiveDownloadStatus, isTransferActiveStatus } from './utils/downloads';
|
import { initMediaDomains, isActiveDownloadStatus, isTransferActiveStatus } from './utils/downloads';
|
||||||
import { schedulerCompletionState } from './utils/schedulerCompletion';
|
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 { Sidebar, SidebarFilter } from "./components/Sidebar";
|
||||||
import { DownloadTable } from "./components/DownloadTable";
|
import { DownloadTable, type DownloadTableStatusSummary } from "./components/DownloadTable";
|
||||||
import { AddDownloadsModal } from "./components/AddDownloadsModal";
|
// Keep the primary Add action eager so the modal cannot disappear behind a
|
||||||
import SettingsView from "./components/SettingsView";
|
// null Suspense fallback while its development chunk is being transformed.
|
||||||
import { PropertiesModal } from "./components/PropertiesModal";
|
import { AddDownloadsModal } from './components/AddDownloadsModal';
|
||||||
import { DeleteConfirmationModal } from "./components/DeleteConfirmationModal";
|
import { KeychainPermissionModal } from './components/KeychainPermissionModal';
|
||||||
import { extractValidDownloadUrls } from './utils/url';
|
import { extractValidDownloadUrls } from './utils/url';
|
||||||
import { readClipboardDownloadUrls } from './utils/clipboard';
|
import { readClipboardDownloadUrls } from './utils/clipboard';
|
||||||
import { listenEvent as listen, invokeCommand as invoke } from "./ipc";
|
import { listenEvent as listen, invokeCommand as invoke } from "./ipc";
|
||||||
@@ -14,15 +14,13 @@ import { useDownloadStore, MAIN_QUEUE_ID, type ExtensionDownloadRequest } from '
|
|||||||
import { initDownloadListener } from './store/downloadStore';
|
import { initDownloadListener } from './store/downloadStore';
|
||||||
import { subscribeToSettingsPersistenceErrors, useSettingsStore } from "./store/useSettingsStore";
|
import { subscribeToSettingsPersistenceErrors, useSettingsStore } from "./store/useSettingsStore";
|
||||||
import { isPermissionGranted, requestPermission, sendNotification } from '@tauri-apps/plugin-notification';
|
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 { WindowControls } from "./components/WindowControls";
|
||||||
import { useToast } from "./contexts/ToastContext";
|
import { useToast } from "./contexts/ToastContext";
|
||||||
import { setLogStreamActive } from './utils/logger';
|
import { setLogStreamActive } from './utils/logger';
|
||||||
|
import { updateDockBadge } from './utils/dockBadge';
|
||||||
import { openUrl } from '@tauri-apps/plugin-opener';
|
import { openUrl } from '@tauri-apps/plugin-opener';
|
||||||
import { getPlatformInfo, usePlatformInfo } from './utils/platform';
|
import { getPlatformInfo, shouldUseCustomWindowControls, usePlatformInfo } from './utils/platform';
|
||||||
|
import { resolveWindowControlStyle } from './utils/windowControlStyle';
|
||||||
import {
|
import {
|
||||||
getKeychainAccessReady,
|
getKeychainAccessReady,
|
||||||
getKeychainConsentVersion,
|
getKeychainConsentVersion,
|
||||||
@@ -32,9 +30,78 @@ import { getVersion } from '@tauri-apps/api/app';
|
|||||||
import type { PostQueueAction } from './bindings/PostQueueAction';
|
import type { PostQueueAction } from './bindings/PostQueueAction';
|
||||||
import { PanelLeft } from 'lucide-react';
|
import { PanelLeft } from 'lucide-react';
|
||||||
import { isTrustedFirelinkReleaseUrl } from './utils/releaseUrls';
|
import { isTrustedFirelinkReleaseUrl } from './utils/releaseUrls';
|
||||||
|
import { changeAppLocale, localeDirection, resolveAppLocale, syncDocumentLocale } from './i18n';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { formatDownloadBytes } from './utils/downloadProgress';
|
||||||
|
|
||||||
|
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 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;
|
let automaticUpdateCheckStarted = false;
|
||||||
const processingScheduleKeys = new Set<string>();
|
const processingScheduleKeys = new Set<string>();
|
||||||
|
let powerPreferencesSync: Promise<void> = Promise.resolve();
|
||||||
|
|
||||||
const waitForSettingsHydration = (): Promise<void> => {
|
const waitForSettingsHydration = (): Promise<void> => {
|
||||||
if (useSettingsStore.persist.hasHydrated()) return Promise.resolve();
|
if (useSettingsStore.persist.hasHydrated()) return Promise.resolve();
|
||||||
@@ -99,35 +166,74 @@ const playCompletionChime = async () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
|
const { i18n, t } = useTranslation();
|
||||||
const platform = usePlatformInfo();
|
const platform = usePlatformInfo();
|
||||||
const [filter, setFilter] = useState<SidebarFilter>('all');
|
const [filter, setFilter] = useState<SidebarFilter>('all');
|
||||||
|
const [downloadTableSummary, setDownloadTableSummary] = useState<DownloadTableStatusSummary | null>(null);
|
||||||
const [coreReady, setCoreReady] = useState(false);
|
const [coreReady, setCoreReady] = useState(false);
|
||||||
const [keychainConsentVersion, setKeychainConsentVersion] = 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 [sidebarWidth, setSidebarWidth] = useState(() => {
|
||||||
const stored = Number(window.localStorage.getItem('firelink-sidebar-width'));
|
const stored = Number(window.localStorage.getItem('firelink-sidebar-width'));
|
||||||
return Number.isFinite(stored) && stored >= 190 && stored <= 260 ? stored : 220;
|
return Number.isFinite(stored) && stored >= 190 && stored <= 260 ? stored : 220;
|
||||||
});
|
});
|
||||||
|
|
||||||
const theme = useSettingsStore(state => state.theme);
|
const theme = useSettingsStore(state => state.theme);
|
||||||
|
const windowControlStylePreference = useSettingsStore(state => state.windowControlStyle);
|
||||||
|
const languagePreference = useSettingsStore(state => state.language);
|
||||||
const isSidebarVisible = useSettingsStore(state => state.isSidebarVisible);
|
const isSidebarVisible = useSettingsStore(state => state.isSidebarVisible);
|
||||||
|
const sidebarPosition = useSettingsStore(state => state.sidebarPosition);
|
||||||
const toggleSidebar = useSettingsStore(state => state.toggleSidebar);
|
const toggleSidebar = useSettingsStore(state => state.toggleSidebar);
|
||||||
const activeView = useSettingsStore(state => state.activeView);
|
const activeView = useSettingsStore(state => state.activeView);
|
||||||
|
const fontFamily = useSettingsStore(state => state.fontFamily);
|
||||||
const appFontSize = useSettingsStore(state => state.appFontSize);
|
const appFontSize = useSettingsStore(state => state.appFontSize);
|
||||||
const listRowDensity = useSettingsStore(state => state.listRowDensity);
|
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 autoCheckUpdates = useSettingsStore(state => state.autoCheckUpdates);
|
||||||
const autoAddClipboardLinks = useSettingsStore(state => state.autoAddClipboardLinks);
|
const autoAddClipboardLinks = useSettingsStore(state => state.autoAddClipboardLinks);
|
||||||
const showNotifications = useSettingsStore(state => state.showNotifications);
|
const showNotifications = useSettingsStore(state => state.showNotifications);
|
||||||
const showDockBadge = useSettingsStore(state => state.showDockBadge);
|
const showDockBadge = useSettingsStore(state => state.showDockBadge);
|
||||||
|
const dockBadgeSyncVersion = useSettingsStore(state => state.dockBadgeSyncVersion);
|
||||||
const showMenuBarIcon = useSettingsStore(state => state.showMenuBarIcon);
|
const showMenuBarIcon = useSettingsStore(state => state.showMenuBarIcon);
|
||||||
const extensionPairingToken = useSettingsStore(state => state.extensionPairingToken);
|
const extensionPairingToken = useSettingsStore(state => state.extensionPairingToken);
|
||||||
const showKeychainModal = useSettingsStore(state => state.showKeychainModal);
|
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 downloads = useDownloadStore(state => state.downloads);
|
||||||
const activeDownloadCount = downloads.filter(download => isTransferActiveStatus(download.status)).length;
|
const activeDownloadCount = downloads.filter(download => isTransferActiveStatus(download.status)).length;
|
||||||
const queuedCount = downloads.filter(download =>
|
const queuedCount = downloads.filter(download =>
|
||||||
download.status === 'queued' || download.status === 'staged'
|
download.status === 'queued' || download.status === 'staged'
|
||||||
).length;
|
).length;
|
||||||
const doneCount = downloads.filter(download => download.status === 'completed').length;
|
const doneCount = downloads.filter(download => download.status === 'completed').length;
|
||||||
|
const handleDownloadTableSummaryChange = useCallback((summary: DownloadTableStatusSummary | null) => {
|
||||||
|
setDownloadTableSummary(summary);
|
||||||
|
}, []);
|
||||||
|
const formatStatusSummaryBytes = (value: number | null, isEstimated = false): string => {
|
||||||
|
if (value === null) return t($ => $.downloadTable.summary.unknown);
|
||||||
|
const formatted = formatDownloadBytes(value);
|
||||||
|
return isEstimated
|
||||||
|
? t($ => $.downloadTable.summary.estimated, { value: formatted })
|
||||||
|
: formatted;
|
||||||
|
};
|
||||||
const schedulerRunning = useSettingsStore(state => state.schedulerRunning);
|
const schedulerRunning = useSettingsStore(state => state.schedulerRunning);
|
||||||
const schedulerActiveDownloadIds = useSettingsStore(state => state.schedulerActiveDownloadIds);
|
const schedulerActiveDownloadIds = useSettingsStore(state => state.schedulerActiveDownloadIds);
|
||||||
const pendingPostActionTimer = useRef<number | null>(null);
|
const pendingPostActionTimer = useRef<number | null>(null);
|
||||||
@@ -140,18 +246,26 @@ function App() {
|
|||||||
>>([]);
|
>>([]);
|
||||||
const maxConcurrentDownloads = useSettingsStore(state => state.maxConcurrentDownloads);
|
const maxConcurrentDownloads = useSettingsStore(state => state.maxConcurrentDownloads);
|
||||||
const preventsSleepWhileDownloading = useSettingsStore(state => state.preventsSleepWhileDownloading);
|
const preventsSleepWhileDownloading = useSettingsStore(state => state.preventsSleepWhileDownloading);
|
||||||
|
const preventsDisplaySleepWhileDownloading = useSettingsStore(
|
||||||
|
state => state.preventsDisplaySleepWhileDownloading
|
||||||
|
);
|
||||||
const activeTransferCount = downloads.filter(download => isTransferActiveStatus(download.status)).length;
|
const activeTransferCount = downloads.filter(download => isTransferActiveStatus(download.status)).length;
|
||||||
const { addToast, removeToast } = useToast();
|
const { addToast, removeToast } = useToast();
|
||||||
const isMacUserAgent = navigator.userAgent.includes('Mac');
|
const isMacUserAgent = navigator.userAgent.includes('Mac');
|
||||||
const usesCustomWindowControls = !isMacUserAgent && platform.os !== 'macos';
|
const usesCustomWindowControls = shouldUseCustomWindowControls(platform.os, navigator.userAgent);
|
||||||
|
const windowControlStyle = resolveWindowControlStyle(windowControlStylePreference, 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
|
// Keep dialogs out of the titlebar area while platform detection is still
|
||||||
// resolving. The conservative fallback prevents a startup handoff from
|
// resolving. The conservative fallback prevents a startup handoff from
|
||||||
// briefly rendering underneath native or custom window controls.
|
// briefly rendering underneath native or custom window controls.
|
||||||
const hasWindowChrome = isMacUserAgent || ['macos', 'windows', 'linux', 'unknown'].includes(platform.os);
|
const hasWindowChrome = isMacUserAgent || ['macos', 'windows', 'linux', 'unknown'].includes(platform.os);
|
||||||
|
|
||||||
|
useEffect(() => scheduleAfterFirstPaint(preloadPageChunks), []);
|
||||||
|
|
||||||
useEffect(() => subscribeToSettingsPersistenceErrors(() => {
|
useEffect(() => subscribeToSettingsPersistenceErrors(() => {
|
||||||
addToast({
|
addToast({
|
||||||
message: 'Could not save settings. Check storage permissions and try again.',
|
message: t($ => $.app.settingsSaveFailed),
|
||||||
variant: 'error',
|
variant: 'error',
|
||||||
isActionable: true
|
isActionable: true
|
||||||
});
|
});
|
||||||
@@ -181,7 +295,7 @@ function App() {
|
|||||||
const schedulePostQueueAction = useCallback((action: Exclude<PostQueueAction, 'none'>) => {
|
const schedulePostQueueAction = useCallback((action: Exclude<PostQueueAction, 'none'>) => {
|
||||||
clearPendingPostActionTimer();
|
clearPendingPostActionTimer();
|
||||||
|
|
||||||
const actionLabel = action === 'shutdown' ? 'Shut down' : action === 'restart' ? 'Restart' : 'Sleep';
|
const actionLabel = t($ => $.scheduler.postActions[action]);
|
||||||
let timerId: number | null = null;
|
let timerId: number | null = null;
|
||||||
let toastId: string | null = null;
|
let toastId: string | null = null;
|
||||||
const cancel = () => {
|
const cancel = () => {
|
||||||
@@ -199,13 +313,13 @@ function App() {
|
|||||||
onDismiss: clearPendingPostActionTimer,
|
onDismiss: clearPendingPostActionTimer,
|
||||||
message: (
|
message: (
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<span>{actionLabel} in 10 seconds.</span>
|
<span>{t($ => $.app.systemActionCountdown, { action: actionLabel })}</span>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="app-button px-2 py-1"
|
className="app-button px-2 py-1"
|
||||||
onClick={cancel}
|
onClick={cancel}
|
||||||
>
|
>
|
||||||
Cancel
|
{t($ => $.actions.cancel)}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -226,7 +340,7 @@ function App() {
|
|||||||
);
|
);
|
||||||
if (activeTransfers) {
|
if (activeTransfers) {
|
||||||
addToast({
|
addToast({
|
||||||
message: 'System action cancelled because another download is active or queued.',
|
message: t($ => $.app.systemActionCancelled),
|
||||||
variant: 'warning',
|
variant: 'warning',
|
||||||
isActionable: true
|
isActionable: true
|
||||||
});
|
});
|
||||||
@@ -235,7 +349,7 @@ function App() {
|
|||||||
invoke('perform_system_action', { action }).catch(error => {
|
invoke('perform_system_action', { action }).catch(error => {
|
||||||
console.error('Scheduled post action failed:', error);
|
console.error('Scheduled post action failed:', error);
|
||||||
addToast({
|
addToast({
|
||||||
message: `Scheduled system action failed: ${String(error)}`,
|
message: t($ => $.app.systemActionFailed, { detail: String(error) }),
|
||||||
variant: 'error',
|
variant: 'error',
|
||||||
isActionable: true
|
isActionable: true
|
||||||
});
|
});
|
||||||
@@ -250,7 +364,10 @@ function App() {
|
|||||||
const startWidth = sidebarWidth;
|
const startWidth = sidebarWidth;
|
||||||
|
|
||||||
const handlePointerMove = (moveEvent: PointerEvent) => {
|
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);
|
setSidebarWidth(nextWidth);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -300,6 +417,101 @@ function App() {
|
|||||||
unlistenDownload = null;
|
unlistenDownload = null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Establish the consent barrier before registering listeners or loading
|
||||||
|
// persisted downloads. Those later startup steps can cause user-facing
|
||||||
|
// work to begin; the explanation must already be committed before any
|
||||||
|
// path is allowed to reach the credential store.
|
||||||
|
await waitForSettingsHydration();
|
||||||
|
if (!active) return;
|
||||||
|
const [currentAppVersion, currentPlatform] = await Promise.all([
|
||||||
|
getVersion().catch(() => ''),
|
||||||
|
getPlatformInfo().catch(() => null)
|
||||||
|
]);
|
||||||
|
if (!active) return;
|
||||||
|
const currentKeychainConsentVersion = getKeychainConsentVersion(currentAppVersion);
|
||||||
|
setKeychainConsentVersion(currentKeychainConsentVersion);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const settings = useSettingsStore.getState();
|
||||||
|
const isStartupActive = () => active;
|
||||||
|
const { deferKeychainHydration, showKeychainPrompt } = getKeychainStartupDecision({
|
||||||
|
portable: currentPlatform?.portable === true,
|
||||||
|
appVersion: currentKeychainConsentVersion,
|
||||||
|
approvedVersion: settings.keychainAccessVersion,
|
||||||
|
accessGranted: settings.keychainAccessGranted,
|
||||||
|
promptDismissed: settings.keychainPromptDismissed
|
||||||
|
});
|
||||||
|
let changed = false;
|
||||||
|
if (deferKeychainHydration) {
|
||||||
|
settings.setKeychainAccessReady(false);
|
||||||
|
if (showKeychainPrompt) settings.setShowKeychainModal(true);
|
||||||
|
await settings.hydrateSessionPairingToken(isStartupActive);
|
||||||
|
if (!active) return;
|
||||||
|
} else {
|
||||||
|
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({
|
||||||
|
variant: 'warning',
|
||||||
|
isActionable: true,
|
||||||
|
message: (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<p>{t($ => $.app.extensionDisconnected)}</p>
|
||||||
|
<div className="flex gap-2 justify-end">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="app-button px-2 py-1 bg-surface-raised border border-border-color rounded"
|
||||||
|
onClick={async () => {
|
||||||
|
const token = useSettingsStore.getState().extensionPairingToken;
|
||||||
|
try {
|
||||||
|
if (token) await navigator.clipboard.writeText(token);
|
||||||
|
acknowledgePairingTokenChange();
|
||||||
|
} catch (error) {
|
||||||
|
addToast({
|
||||||
|
message: t($ => $.app.copyTokenFailed, { detail: String(error) }),
|
||||||
|
variant: 'error',
|
||||||
|
isActionable: true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t($ => $.app.copyToken)}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="app-button px-2 py-1 bg-surface-raised border border-border-color rounded"
|
||||||
|
onClick={() => {
|
||||||
|
const settings = useSettingsStore.getState();
|
||||||
|
settings.setActiveSettingsTab('integrations');
|
||||||
|
settings.setActiveView('settings');
|
||||||
|
acknowledgePairingTokenChange();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t($ => $.app.integrations)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to hydrate extension pairing token:', error);
|
||||||
|
addToast({
|
||||||
|
message: t($ => $.app.credentialPersistenceFailed, { detail: String(error) }),
|
||||||
|
variant: 'error',
|
||||||
|
isActionable: true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
unlistenDownload = await initDownloadListener();
|
unlistenDownload = await initDownloadListener();
|
||||||
unlistenTerminalState = await listen('download-state', (event) => {
|
unlistenTerminalState = await listen('download-state', (event) => {
|
||||||
@@ -313,12 +525,12 @@ function App() {
|
|||||||
if (!settings.showNotifications) return;
|
if (!settings.showNotifications) return;
|
||||||
|
|
||||||
const item = useDownloadStore.getState().downloads.find(d => d.id === event.payload.id);
|
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') {
|
if (event.payload.status === 'completed') {
|
||||||
try {
|
try {
|
||||||
sendNotification({
|
sendNotification({
|
||||||
title: 'Download Complete',
|
title: t($ => $.app.downloadCompleteTitle),
|
||||||
body: `${fileName} has finished downloading.`
|
body: t($ => $.app.downloadCompleteBody, { fileName })
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Completion notification failed:', error);
|
console.error('Completion notification failed:', error);
|
||||||
@@ -326,8 +538,8 @@ function App() {
|
|||||||
} else {
|
} else {
|
||||||
try {
|
try {
|
||||||
sendNotification({
|
sendNotification({
|
||||||
title: 'Download Failed',
|
title: t($ => $.app.downloadFailedTitle),
|
||||||
body: `${fileName} failed to download.`,
|
body: t($ => $.app.downloadFailedBody, { fileName }),
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failure notification failed:', error);
|
console.error('Failure notification failed:', error);
|
||||||
@@ -362,7 +574,20 @@ function App() {
|
|||||||
cleanupListeners = null;
|
cleanupListeners = null;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
} catch (error) {
|
||||||
|
disposeListeners();
|
||||||
|
cleanupListeners = null;
|
||||||
|
if (!active) return;
|
||||||
|
console.error('Failed to initialize Firelink listeners:', error);
|
||||||
|
addToast({
|
||||||
|
message: t($ => $.app.initializeDownloadsFailed, { detail: String(error) }),
|
||||||
|
variant: 'error',
|
||||||
|
isActionable: true
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
await initializeDownloadState();
|
await initializeDownloadState();
|
||||||
if (!active) return;
|
if (!active) return;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -371,108 +596,13 @@ function App() {
|
|||||||
if (!active) return;
|
if (!active) return;
|
||||||
console.error('Failed to initialize Firelink state:', error);
|
console.error('Failed to initialize Firelink state:', error);
|
||||||
addToast({
|
addToast({
|
||||||
message: `Could not initialize saved downloads: ${String(error)}`,
|
message: t($ => $.app.initializeDownloadsFailed, { detail: String(error) }),
|
||||||
variant: 'error',
|
variant: 'error',
|
||||||
isActionable: true
|
isActionable: true
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const [currentAppVersion, currentPlatform] = await Promise.all([
|
|
||||||
getVersion().catch(() => ''),
|
|
||||||
getPlatformInfo().catch(() => null)
|
|
||||||
]);
|
|
||||||
if (!active) return;
|
|
||||||
const currentKeychainConsentVersion = getKeychainConsentVersion(currentAppVersion);
|
|
||||||
setKeychainConsentVersion(currentKeychainConsentVersion);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const settings = useSettingsStore.getState();
|
|
||||||
const isStartupActive = () => active;
|
|
||||||
const { deferKeychainHydration, showKeychainPrompt } = getKeychainStartupDecision({
|
|
||||||
portable: currentPlatform?.portable === true,
|
|
||||||
appVersion: currentKeychainConsentVersion,
|
|
||||||
approvedVersion: settings.keychainAccessVersion,
|
|
||||||
accessGranted: settings.keychainAccessGranted,
|
|
||||||
promptDismissed: settings.keychainPromptDismissed
|
|
||||||
});
|
|
||||||
|
|
||||||
let changed = false;
|
|
||||||
if (deferKeychainHydration) {
|
|
||||||
settings.setKeychainAccessReady(false);
|
|
||||||
// This token is already owned by the backend and does not access
|
|
||||||
// the OS credential store. Render our explanation before any native
|
|
||||||
// Keychain/Credential Manager prompt can be user-triggered.
|
|
||||||
await settings.hydrateSessionPairingToken(isStartupActive);
|
|
||||||
if (!active) return;
|
|
||||||
if (showKeychainPrompt) {
|
|
||||||
settings.setShowKeychainModal(true);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
changed = await settings.hydratePairingToken(isStartupActive);
|
|
||||||
if (!active) return;
|
|
||||||
const currentSettings = useSettingsStore.getState();
|
|
||||||
settings.setKeychainAccessReady(getKeychainAccessReady({
|
|
||||||
portable: currentPlatform?.portable === true,
|
|
||||||
accessGranted: currentSettings.keychainAccessGranted,
|
|
||||||
persistent: currentSettings.isPairingTokenPersistent
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
if (changed) {
|
|
||||||
addToast({
|
|
||||||
variant: 'warning',
|
|
||||||
isActionable: true,
|
|
||||||
message: (
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<p>Browser extension disconnected because its pairing token changed.</p>
|
|
||||||
<div className="flex gap-2 justify-end">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="app-button px-2 py-1 bg-surface-raised border border-border-color rounded"
|
|
||||||
onClick={async () => {
|
|
||||||
const token = useSettingsStore.getState().extensionPairingToken;
|
|
||||||
try {
|
|
||||||
if (token) {
|
|
||||||
await navigator.clipboard.writeText(token);
|
|
||||||
}
|
|
||||||
acknowledgePairingTokenChange();
|
|
||||||
} catch (error) {
|
|
||||||
addToast({
|
|
||||||
message: `Could not copy pairing token: ${String(error)}`,
|
|
||||||
variant: 'error',
|
|
||||||
isActionable: true
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Copy token
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="app-button px-2 py-1 bg-surface-raised border border-border-color rounded"
|
|
||||||
onClick={() => {
|
|
||||||
const settings = useSettingsStore.getState();
|
|
||||||
settings.setActiveSettingsTab('integrations');
|
|
||||||
settings.setActiveView('settings');
|
|
||||||
acknowledgePairingTokenChange();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Integrations
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} 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)}`,
|
|
||||||
variant: 'error',
|
|
||||||
isActionable: true
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!active) return;
|
if (!active) return;
|
||||||
setCoreReady(true);
|
setCoreReady(true);
|
||||||
};
|
};
|
||||||
@@ -521,13 +651,17 @@ function App() {
|
|||||||
useDownloadStore.getState().resumePendingDownloads().catch(error => {
|
useDownloadStore.getState().resumePendingDownloads().catch(error => {
|
||||||
console.error('Failed to resume saved downloads after startup:', error);
|
console.error('Failed to resume saved downloads after startup:', error);
|
||||||
addToast({
|
addToast({
|
||||||
message: `Could not resume saved downloads: ${String(error)}`,
|
message: t($ => $.app.resumeDownloadsFailed, { detail: String(error) }),
|
||||||
variant: 'error',
|
variant: 'error',
|
||||||
isActionable: true
|
isActionable: true
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}, [addToast, coreReady, showKeychainModal]);
|
}, [addToast, coreReady, showKeychainModal]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
window.document.documentElement.setAttribute('data-font-family', fontFamily);
|
||||||
|
}, [fontFamily]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
window.document.documentElement.setAttribute('data-font-size', appFontSize);
|
window.document.documentElement.setAttribute('data-font-size', appFontSize);
|
||||||
}, [appFontSize]);
|
}, [appFontSize]);
|
||||||
@@ -545,14 +679,14 @@ function App() {
|
|||||||
.then(result => {
|
.then(result => {
|
||||||
if (result.type !== 'UpdateAvailable') return;
|
if (result.type !== 'UpdateAvailable') return;
|
||||||
if (!isTrustedFirelinkReleaseUrl(result.update.release_url)) {
|
if (!isTrustedFirelinkReleaseUrl(result.update.release_url)) {
|
||||||
throw new Error('The update check returned an untrusted release URL.');
|
throw new Error(t($ => $.settings.common.updateUntrustedReleaseUrl));
|
||||||
}
|
}
|
||||||
addToast({
|
addToast({
|
||||||
variant: 'info',
|
variant: 'info',
|
||||||
isActionable: true,
|
isActionable: true,
|
||||||
message: (
|
message: (
|
||||||
<div className="flex items-center gap-3">
|
<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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="app-button px-2 py-1"
|
className="app-button px-2 py-1"
|
||||||
@@ -560,7 +694,7 @@ function App() {
|
|||||||
void openUrl(result.update.release_url);
|
void openUrl(result.update.release_url);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
View release
|
{t($ => $.app.viewRelease)}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -585,22 +719,39 @@ function App() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (platform.os === 'macos') {
|
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(() => {
|
useEffect(() => {
|
||||||
invoke('set_prevent_sleep', {
|
const sync = () => {
|
||||||
prevent: preventsSleepWhileDownloading && activeTransferCount > 0
|
powerPreferencesSync = powerPreferencesSync
|
||||||
}).catch(error => {
|
.catch(() => undefined)
|
||||||
console.error('Failed to update sleep prevention:', error);
|
.then(() => invoke('set_power_preferences', {
|
||||||
addToast({
|
preventSystemSleep: preventsSleepWhileDownloading,
|
||||||
message: `Could not update sleep prevention: ${String(error)}`,
|
preventDisplaySleep: preventsDisplaySleepWhileDownloading
|
||||||
variant: 'error',
|
}))
|
||||||
isActionable: true
|
.catch(error => {
|
||||||
});
|
console.error('Failed to update power prevention:', error);
|
||||||
});
|
addToast({
|
||||||
}, [addToast, preventsSleepWhileDownloading, activeTransferCount]);
|
message: t($ => $.app.sleepPreventionFailed, { detail: String(error) }),
|
||||||
|
variant: 'error',
|
||||||
|
isActionable: true
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
if (useSettingsStore.persist.hasHydrated()) {
|
||||||
|
sync();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
return useSettingsStore.persist.onFinishHydration(sync);
|
||||||
|
}, [
|
||||||
|
addToast,
|
||||||
|
preventsDisplaySleepWhileDownloading,
|
||||||
|
preventsSleepWhileDownloading,
|
||||||
|
t
|
||||||
|
]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
invoke('toggle_tray_icon', { show: showMenuBarIcon }).catch(console.error);
|
invoke('toggle_tray_icon', { show: showMenuBarIcon }).catch(console.error);
|
||||||
@@ -634,7 +785,7 @@ function App() {
|
|||||||
state.setSchedulerActiveDownloadIds([]);
|
state.setSchedulerActiveDownloadIds([]);
|
||||||
state.setSchedulerRunning(false);
|
state.setSchedulerRunning(false);
|
||||||
addToast({
|
addToast({
|
||||||
message: 'Scheduler has no valid queues selected. Update Scheduler settings.',
|
message: t($ => $.app.schedulerNoQueues),
|
||||||
variant: 'warning',
|
variant: 'warning',
|
||||||
isActionable: true
|
isActionable: true
|
||||||
});
|
});
|
||||||
@@ -668,7 +819,9 @@ function App() {
|
|||||||
const failedPauses = pauseResults.filter(result => result.status === 'rejected').length;
|
const failedPauses = pauseResults.filter(result => result.status === 'rejected').length;
|
||||||
if (failedPauses > 0) {
|
if (failedPauses > 0) {
|
||||||
addToast({
|
addToast({
|
||||||
message: `Scheduler could not pause ${failedPauses} download${failedPauses === 1 ? '' : 's'}.`,
|
message: failedPauses === 1
|
||||||
|
? t($ => $.app.schedulerPauseOneFailed)
|
||||||
|
: t($ => $.app.schedulerPauseManyFailed, { count: failedPauses }),
|
||||||
variant: 'error',
|
variant: 'error',
|
||||||
isActionable: true
|
isActionable: true
|
||||||
});
|
});
|
||||||
@@ -702,14 +855,14 @@ function App() {
|
|||||||
|
|
||||||
if (completionState !== 'completed') {
|
if (completionState !== 'completed') {
|
||||||
addToast({
|
addToast({
|
||||||
message: 'Scheduled downloads did not all complete. The post-queue system action was skipped.',
|
message: t($ => $.app.scheduledIncomplete),
|
||||||
variant: 'warning',
|
variant: 'warning',
|
||||||
isActionable: true
|
isActionable: true
|
||||||
});
|
});
|
||||||
} else if (settings.scheduler.postQueueAction !== 'none') {
|
} else if (settings.scheduler.postQueueAction !== 'none') {
|
||||||
if (downloads.some(download => isActiveDownloadStatus(download.status))) {
|
if (downloads.some(download => isActiveDownloadStatus(download.status))) {
|
||||||
addToast({
|
addToast({
|
||||||
message: 'Scheduled system action skipped because another download is active or queued.',
|
message: t($ => $.app.scheduledActionSkippedActive),
|
||||||
variant: 'warning',
|
variant: 'warning',
|
||||||
isActionable: true
|
isActionable: true
|
||||||
});
|
});
|
||||||
@@ -736,7 +889,7 @@ function App() {
|
|||||||
const permission = await requestPermission();
|
const permission = await requestPermission();
|
||||||
if (permission !== 'granted') {
|
if (permission !== 'granted') {
|
||||||
addToast({
|
addToast({
|
||||||
message: 'System notifications are disabled for Firelink.',
|
message: t($ => $.app.notificationsDisabled),
|
||||||
variant: 'warning',
|
variant: 'warning',
|
||||||
isActionable: true
|
isActionable: true
|
||||||
});
|
});
|
||||||
@@ -744,7 +897,7 @@ function App() {
|
|||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
addToast({
|
addToast({
|
||||||
message: `Could not configure notifications: ${String(error)}`,
|
message: t($ => $.app.notificationsFailed, { detail: String(error) }),
|
||||||
variant: 'error',
|
variant: 'error',
|
||||||
isActionable: true
|
isActionable: true
|
||||||
});
|
});
|
||||||
@@ -896,19 +1049,32 @@ function App() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`app-shell flex h-screen w-screen overflow-hidden text-text-primary ${
|
<div className={`app-shell flex h-screen w-screen overflow-hidden text-text-primary ${
|
||||||
|
isSidebarOnRight ? 'app-shell--sidebar-right' : 'app-shell--sidebar-left'
|
||||||
|
} ${
|
||||||
hasWindowChrome ? 'app-shell--window-chrome' : ''
|
hasWindowChrome ? 'app-shell--window-chrome' : ''
|
||||||
}`}>
|
}`}>
|
||||||
{(platform.os === 'windows' || platform.os === 'linux') && <WindowControls />}
|
{usesCustomWindowControls && (
|
||||||
|
<WindowControls
|
||||||
|
side={isSidebarOnRight ? 'right' : 'left'}
|
||||||
|
controlStyle={windowControlStyle}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<div
|
<div
|
||||||
className={`app-sidebar-shell relative z-20 shrink-0 transition-all duration-300 ease-[cubic-bezier(0.2,0.8,0.2,1)] ${
|
className={`app-sidebar-shell relative z-20 shrink-0 transition-[width,margin-inline-start,margin-inline-end,opacity] 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'
|
isSidebarVisible ? 'opacity-100' : 'opacity-0 pointer-events-none'
|
||||||
}`}
|
}`}
|
||||||
style={{
|
style={{
|
||||||
width: sidebarWidth,
|
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
|
<Sidebar
|
||||||
selectedFilter={filter}
|
selectedFilter={filter}
|
||||||
onSelectFilter={(f) => {
|
onSelectFilter={(f) => {
|
||||||
@@ -920,12 +1086,14 @@ function App() {
|
|||||||
<div
|
<div
|
||||||
className="sidebar-resize-handle"
|
className="sidebar-resize-handle"
|
||||||
onPointerDown={startSidebarResize}
|
onPointerDown={startSidebarResize}
|
||||||
title="Resize Sidebar"
|
title={t($ => $.actions.resizeSidebar)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
className={`app-workspace relative z-0 flex-1 flex flex-col h-full overflow-hidden ${
|
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' : ''
|
!isSidebarVisible ? 'app-workspace--sidebar-collapsed' : ''
|
||||||
} ${usesCustomWindowControls ? 'app-workspace--custom-window-controls' : ''}`}
|
} ${usesCustomWindowControls ? 'app-workspace--custom-window-controls' : ''}`}
|
||||||
>
|
>
|
||||||
@@ -934,35 +1102,60 @@ function App() {
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={toggleSidebar}
|
onClick={toggleSidebar}
|
||||||
className="app-icon-button app-sidebar-reveal-button h-7 w-7"
|
className="app-icon-button app-sidebar-reveal-button h-7 w-7"
|
||||||
title="Show Sidebar"
|
title={t($ => $.actions.showSidebar)}
|
||||||
aria-label="Show Sidebar"
|
aria-label={t($ => $.actions.showSidebar)}
|
||||||
>
|
>
|
||||||
<PanelLeft size={16} strokeWidth={2} />
|
<PanelLeft size={16} strokeWidth={2} />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<div className="flex-1 flex flex-col overflow-hidden relative">
|
<div className="flex-1 flex flex-col overflow-hidden relative">
|
||||||
{activeView === 'downloads' && <DownloadTable filter={filter} />}
|
<Suspense fallback={<PageLoadingFallback />}>
|
||||||
{activeView === 'settings' && <SettingsView />}
|
{activeView === 'downloads' && (
|
||||||
{activeView === 'scheduler' && <SchedulerView />}
|
<DownloadTable
|
||||||
{activeView === 'speedLimiter' && <SpeedLimiterView />}
|
filter={filter}
|
||||||
{activeView === 'logs' && <LogsView />}
|
onSummaryChange={handleDownloadTableSummaryChange}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{activeView === 'settings' && <SettingsView />}
|
||||||
|
{activeView === 'scheduler' && <SchedulerView />}
|
||||||
|
{activeView === 'speedLimiter' && <SpeedLimiterView />}
|
||||||
|
{activeView === 'logs' && <LogsView />}
|
||||||
|
</Suspense>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Status Bar */}
|
{/* Status Bar */}
|
||||||
<div className="app-statusbar px-[14px] flex items-center justify-between text-text-muted shrink-0">
|
<div className="app-statusbar px-[14px] flex items-center justify-between text-text-muted shrink-0">
|
||||||
<span>Ready</span>
|
<span>{t($ => $.status.ready)}</span>
|
||||||
|
{activeView === 'downloads' && downloadTableSummary ? (
|
||||||
|
<div className="app-statusbar-summary" dir="ltr" aria-live="polite">
|
||||||
|
<span className="app-statusbar-summary-metric">
|
||||||
|
<span dir="auto">{t($ => $.downloadTable.summary.downloaded)}</span>
|
||||||
|
<strong dir="auto">{formatStatusSummaryBytes(downloadTableSummary.summary.downloadedBytes)}</strong>
|
||||||
|
</span>
|
||||||
|
<span className="app-statusbar-summary-metric">
|
||||||
|
<span dir="auto">{t($ => $.downloadTable.summary.remaining)}</span>
|
||||||
|
<strong dir="auto">{formatStatusSummaryBytes(
|
||||||
|
downloadTableSummary.summary.remainingBytes,
|
||||||
|
downloadTableSummary.summary.remainingIsEstimated
|
||||||
|
)}</strong>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
<div className="flex gap-3 tabular-nums">
|
<div className="flex gap-3 tabular-nums">
|
||||||
<span>{activeDownloadCount} active</span>
|
<span>{t($ => $.status.active, { count: activeDownloadCount })}</span>
|
||||||
<span>{queuedCount} queued</span>
|
<span>{t($ => $.status.queued, { count: queuedCount })}</span>
|
||||||
<span>{doneCount} done</span>
|
<span>{t($ => $.status.done, { count: doneCount })}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<AddDownloadsModal />
|
{isAddModalOpen && <AddDownloadsModal />}
|
||||||
<PropertiesModal />
|
|
||||||
<DeleteConfirmationModal />
|
<Suspense fallback={null}>
|
||||||
<KeychainPermissionModal consentVersion={keychainConsentVersion} />
|
{selectedPropertiesDownloadId !== null && <PropertiesModal />}
|
||||||
|
{isDeleteModalOpen && <DeleteConfirmationModal />}
|
||||||
|
{showKeychainModal && <KeychainPermissionModal consentVersion={keychainConsentVersion} />}
|
||||||
|
</Suspense>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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 CalendarPreference = "gregorian" | "persian" | "hebrew";
|
||||||
@@ -2,4 +2,4 @@
|
|||||||
import type { DownloadCategory } from "./DownloadCategory";
|
import type { DownloadCategory } from "./DownloadCategory";
|
||||||
import type { DownloadStatus } from "./DownloadStatus";
|
import type { DownloadStatus } from "./DownloadStatus";
|
||||||
|
|
||||||
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, };
|
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, mediaQuality?: 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.
|
// 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, downloaded_bytes?: number, total_bytes?: number, total_is_estimate?: boolean, };
|
export type DownloadProgressEvent = { id: string, fraction: number, speed: string, eta: string, size: string | null, size_is_final: boolean, downloaded_bytes?: number, total_bytes?: number, total_is_estimate?: boolean, active_connections?: number, requested_connections?: number, };
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
// 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";
|
import type { ExtensionCookieScope } from "./ExtensionCookieScope";
|
||||||
|
|
||||||
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, };
|
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 FontFamily = "system" | "inter" | "outfit" | "vazirmatn" | "noto-sans-hebrew" | "noto-sans-sc" | "roboto" | "serif" | "monospace";
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||||
import type { AppFontSize } from "./AppFontSize";
|
import type { AppFontSize } from "./AppFontSize";
|
||||||
|
import type { CalendarPreference } from "./CalendarPreference";
|
||||||
|
import type { FontFamily } from "./FontFamily";
|
||||||
import type { ListRowDensity } from "./ListRowDensity";
|
import type { ListRowDensity } from "./ListRowDensity";
|
||||||
import type { MediaCookieSource } from "./MediaCookieSource";
|
import type { MediaCookieSource } from "./MediaCookieSource";
|
||||||
import type { ProxyMode } from "./ProxyMode";
|
import type { ProxyMode } from "./ProxyMode";
|
||||||
@@ -7,5 +9,6 @@ import type { SchedulerSettings } from "./SchedulerSettings";
|
|||||||
import type { SettingsTab } from "./SettingsTab";
|
import type { SettingsTab } from "./SettingsTab";
|
||||||
import type { SiteLogin } from "./SiteLogin";
|
import type { SiteLogin } from "./SiteLogin";
|
||||||
import type { Theme } from "./Theme";
|
import type { Theme } from "./Theme";
|
||||||
|
import type { WindowControlStyle } from "./WindowControlStyle";
|
||||||
|
|
||||||
export type PersistedSettings = { theme: Theme, baseDownloadFolder: string, categorySubfoldersEnabled: boolean, categorySubfolders: { [key in string]: string }, categoryDirectoryOverrides: { [key in string]: string }, approvedDownloadRoots: Array<string>, maxConcurrentDownloads: number, globalSpeedLimit: string, speedLimitPresetValues: Array<number>, logsEnabled: boolean, isSidebarVisible: boolean, activeSettingsTab: SettingsTab, scheduler: SchedulerSettings, schedulerRunning: boolean, schedulerActiveDownloadIds: Array<string>, schedulerLastStartKey: string, schedulerLastStopKey: string, lastCustomSpeedLimitKiB: number, lastCustomSpeedLimitUnit: string, perServerConnections: number, maxAutomaticRetries: number, showNotifications: boolean, playCompletionSound: boolean, autoAddClipboardLinks: boolean, appFontSize: AppFontSize, listRowDensity: ListRowDensity, showDockBadge: boolean, showMenuBarIcon: boolean, proxyMode: ProxyMode, proxyHost: string, proxyPort: number, customUserAgent: string, askWhereToSaveEachFile: boolean, preventsSleepWhileDownloading: boolean, mediaCookieSource: MediaCookieSource, siteLogins: Array<SiteLogin>, autoCheckUpdates: boolean, keychainAccessGranted: boolean, };
|
export type PersistedSettings = { theme: Theme, fontFamily: FontFamily, windowControlStyle: WindowControlStyle, calendarPreference: CalendarPreference, 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, preventsDisplaySleepWhileDownloading: boolean, mediaCookieSource: MediaCookieSource, siteLogins: Array<SiteLogin>, autoCheckUpdates: boolean, keychainAccessGranted: boolean, };
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||||
|
|
||||||
export type Queue = { id: string, name: string, isMain: boolean, };
|
export type Queue = { id: string, name: string, isMain: boolean, maxConcurrent?: 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 QueueConcurrencyConfig = { id: string, maxConcurrent: number | 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 WindowControlStyle = "auto" | "macos" | "windows" | "gnome" | "minimal";
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,8 +1,10 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { useDownloadStore } from '../store/useDownloadStore';
|
import { useDownloadStore } from '../store/useDownloadStore';
|
||||||
import { AlertTriangle } from 'lucide-react';
|
import { AlertTriangle } from 'lucide-react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
export const DeleteConfirmationModal: React.FC = () => {
|
export const DeleteConfirmationModal: React.FC = () => {
|
||||||
|
const { t } = useTranslation();
|
||||||
const { deleteModalState, closeDeleteModal, removeDownload } = useDownloadStore();
|
const { deleteModalState, closeDeleteModal, removeDownload } = useDownloadStore();
|
||||||
const [errorMessage, setErrorMessage] = useState('');
|
const [errorMessage, setErrorMessage] = useState('');
|
||||||
const [isRemoving, setIsRemoving] = useState(false);
|
const [isRemoving, setIsRemoving] = useState(false);
|
||||||
@@ -50,7 +52,11 @@ export const DeleteConfirmationModal: React.FC = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (failures.length > 0) {
|
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);
|
setIsRemoving(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -60,6 +66,7 @@ export const DeleteConfirmationModal: React.FC = () => {
|
|||||||
|
|
||||||
const handleRemoveFromList = () => removeMany(false);
|
const handleRemoveFromList = () => removeMany(false);
|
||||||
const handleDeleteFile = () => removeMany(true);
|
const handleDeleteFile = () => removeMany(true);
|
||||||
|
const itemCount = deleteModalState.downloadIds?.length ?? 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<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">
|
<div className="p-2 bg-red-500/10 rounded-full flex items-center justify-center">
|
||||||
<AlertTriangle size={20} className="text-red-400" />
|
<AlertTriangle size={20} className="text-red-400" />
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
<div className="px-5 py-6 flex-1 text-sm text-text-secondary leading-relaxed">
|
<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>}
|
{errorMessage && <div className="mt-3 text-xs text-red-400">{errorMessage}</div>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -92,21 +101,21 @@ export const DeleteConfirmationModal: React.FC = () => {
|
|||||||
disabled={isRemoving}
|
disabled={isRemoving}
|
||||||
className="app-button px-4 py-2 rounded-lg text-sm font-medium 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>
|
||||||
<button
|
<button
|
||||||
onClick={handleRemoveFromList}
|
onClick={handleRemoveFromList}
|
||||||
disabled={isRemoving}
|
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"
|
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>
|
||||||
<button
|
<button
|
||||||
onClick={handleDeleteFile}
|
onClick={handleDeleteFile}
|
||||||
disabled={isRemoving}
|
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"
|
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+321
-131
@@ -1,44 +1,145 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { useDownloadProgressStore } from '../store/downloadProgressStore';
|
import { useDownloadProgressStore } from '../store/downloadProgressStore';
|
||||||
import { Play, Pause, MoreVertical, Clock, ArrowUp, ArrowDown } from 'lucide-react';
|
import { Play, Pause, MoreVertical, Clock } from 'lucide-react';
|
||||||
import type { DownloadItem as DownloadItemType } from '../bindings/DownloadItem';
|
import type { DownloadItem as DownloadItemType } from '../bindings/DownloadItem';
|
||||||
import { canPauseDownload, canStartDownload, startActionLabel } from '../utils/downloadActions';
|
import { canPauseDownload, canStartDownload } from '../utils/downloadActions';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { useSettingsStore } from '../store/useSettingsStore';
|
||||||
|
import { formatDateTime } from '../utils/dateTime';
|
||||||
import {
|
import {
|
||||||
downloadProgressColorClass,
|
downloadProgressColorClass,
|
||||||
formatDownloadTotal,
|
formatDownloadTotal,
|
||||||
resolveDownloadSizeDisplay
|
resolveDownloadSizeDisplay
|
||||||
} from '../utils/downloadProgress';
|
} from '../utils/downloadProgress';
|
||||||
|
import {
|
||||||
|
COLUMN_ALIGNMENT_JUSTIFY,
|
||||||
|
DOWNLOAD_ACTIONS_COLUMN_WIDTH,
|
||||||
|
getDownloadActionPosition,
|
||||||
|
getColumnGridColumn,
|
||||||
|
type DownloadColumnAlignment,
|
||||||
|
type DownloadTableColumnKey
|
||||||
|
} from '../utils/downloadTableColumns';
|
||||||
|
|
||||||
interface DownloadItemProps {
|
interface DownloadItemProps {
|
||||||
download: DownloadItemType;
|
download: DownloadItemType;
|
||||||
index: number;
|
|
||||||
queueIndex: number;
|
queueIndex: number;
|
||||||
queueLength: number;
|
columnOrder: DownloadTableColumnKey[];
|
||||||
|
columnAlignments: Record<DownloadTableColumnKey, DownloadColumnAlignment>;
|
||||||
tableGridTemplate: string;
|
tableGridTemplate: string;
|
||||||
|
tableMinWidth: number | string;
|
||||||
setContextMenu: (menu: { x: number; y: number; id: string }) => void;
|
setContextMenu: (menu: { x: number; y: number; id: string }) => void;
|
||||||
handlePause: (id: string, skipConfirm?: boolean) => void;
|
handlePause: (id: string, skipConfirm?: boolean) => void;
|
||||||
handleResume: (item: DownloadItemType) => void;
|
handleResume: (item: DownloadItemType) => void;
|
||||||
getCategoryIcon: (category: string) => React.ReactNode;
|
getCategoryIcon: (category: string) => React.ReactNode;
|
||||||
isSelected: boolean;
|
isSelected: boolean;
|
||||||
|
isQueueReorderable: boolean;
|
||||||
|
isQueueDragSource: boolean;
|
||||||
onMoveInQueue: (id: string, direction: 'up' | 'down') => void;
|
onMoveInQueue: (id: string, direction: 'up' | 'down') => void;
|
||||||
|
onQueueDragStart: (id: string, event: React.PointerEvent<HTMLDivElement>) => void;
|
||||||
onClick: (e: React.MouseEvent, item: DownloadItemType) => void;
|
onClick: (e: React.MouseEvent, item: DownloadItemType) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DownloadItem = React.memo<DownloadItemProps>(({
|
export const DownloadItem = React.memo<DownloadItemProps>(({
|
||||||
download,
|
download,
|
||||||
index,
|
|
||||||
queueIndex,
|
queueIndex,
|
||||||
queueLength,
|
columnOrder,
|
||||||
|
columnAlignments,
|
||||||
tableGridTemplate,
|
tableGridTemplate,
|
||||||
|
tableMinWidth,
|
||||||
setContextMenu,
|
setContextMenu,
|
||||||
handlePause,
|
handlePause,
|
||||||
handleResume,
|
handleResume,
|
||||||
getCategoryIcon,
|
getCategoryIcon,
|
||||||
isSelected,
|
isSelected,
|
||||||
|
isQueueReorderable,
|
||||||
|
isQueueDragSource,
|
||||||
onMoveInQueue,
|
onMoveInQueue,
|
||||||
|
onQueueDragStart,
|
||||||
onClick,
|
onClick,
|
||||||
}) => {
|
}) => {
|
||||||
|
const { t, i18n } = useTranslation();
|
||||||
|
const calendarPreference = useSettingsStore(state => state.calendarPreference);
|
||||||
const liveProgress = useDownloadProgressStore(state => state.progressMap[download.id]);
|
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 hasRowActions = download.status !== 'completed';
|
||||||
|
const isActionVisible = hasRowActions && (isRowHovered || isRowFocused);
|
||||||
|
const mediaQualityLabel = (() => {
|
||||||
|
if (!download.isMedia || typeof download.mediaQuality !== 'string') return undefined;
|
||||||
|
const normalized = download.mediaQuality.replace(/[\u0000-\u001f\u007f]+/g, ' ').replace(/\s+/g, ' ').trim();
|
||||||
|
return normalized.length > 0 && normalized.length <= 48 ? normalized : undefined;
|
||||||
|
})();
|
||||||
|
const dateAddedLabel = download.dateAdded
|
||||||
|
? formatDateTime(download.dateAdded, {
|
||||||
|
locale: i18n.language,
|
||||||
|
calendar: calendarPreference
|
||||||
|
})
|
||||||
|
: '-';
|
||||||
|
|
||||||
|
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 nextPosition = getDownloadActionPosition(
|
||||||
|
rowRect,
|
||||||
|
horizontalViewportRect,
|
||||||
|
verticalViewportRect,
|
||||||
|
window.innerWidth
|
||||||
|
);
|
||||||
|
|
||||||
|
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: number | null = null;
|
||||||
|
const schedulePositionUpdate = () => {
|
||||||
|
if (frame !== null) window.cancelAnimationFrame(frame);
|
||||||
|
frame = window.requestAnimationFrame(() => {
|
||||||
|
frame = null;
|
||||||
|
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 !== null) window.cancelAnimationFrame(frame);
|
||||||
|
};
|
||||||
|
}, [isActionVisible, updateActionPosition]);
|
||||||
|
|
||||||
const displayFraction = download.status === 'downloading'
|
const displayFraction = download.status === 'downloading'
|
||||||
? liveProgress?.fraction ?? download.fraction ?? 0
|
? liveProgress?.fraction ?? download.fraction ?? 0
|
||||||
@@ -47,12 +148,12 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
|
|||||||
const displaySpeed = download.status === 'downloading'
|
const displaySpeed = download.status === 'downloading'
|
||||||
? liveProgress?.speed ?? download.speed
|
? liveProgress?.speed ?? download.speed
|
||||||
: download.status === 'processing'
|
: download.status === 'processing'
|
||||||
? 'Processing...'
|
? t($ => $.downloads.values.processing)
|
||||||
: '-';
|
: '-';
|
||||||
const displayEta = download.status === 'downloading'
|
const displayEta = download.status === 'downloading'
|
||||||
? liveProgress?.eta ?? download.eta
|
? liveProgress?.eta ?? download.eta
|
||||||
: download.status === 'processing'
|
: download.status === 'processing'
|
||||||
? 'Muxing...'
|
? t($ => $.downloads.values.muxing)
|
||||||
: '-';
|
: '-';
|
||||||
const sizeDisplay = resolveDownloadSizeDisplay({
|
const sizeDisplay = resolveDownloadSizeDisplay({
|
||||||
downloadedBytes: liveProgress?.downloaded_bytes ?? download.downloadedBytes,
|
downloadedBytes: liveProgress?.downloaded_bytes ?? download.downloadedBytes,
|
||||||
@@ -62,56 +163,88 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
|
|||||||
});
|
});
|
||||||
const hasDownloadedAmount = download.status !== 'completed' &&
|
const hasDownloadedAmount = download.status !== 'completed' &&
|
||||||
Boolean(sizeDisplay.downloaded && sizeDisplay.total);
|
Boolean(sizeDisplay.downloaded && sizeDisplay.total);
|
||||||
const completedSizeLabel = download.status === 'completed'
|
const completedSizeLabel = (() => {
|
||||||
? formatDownloadTotal(sizeDisplay)
|
const value = download.status === 'completed' ? formatDownloadTotal(sizeDisplay) : sizeDisplay.fallback;
|
||||||
: 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 (
|
const columnStyle = (key: DownloadTableColumnKey): React.CSSProperties => ({
|
||||||
<div
|
'--column-justify': COLUMN_ALIGNMENT_JUSTIFY[columnAlignments[key]],
|
||||||
className={`download-row group cursor-default relative ${index % 2 !== 0 ? 'striped' : ''} ${isSelected ? 'is-selected' : ''}`}
|
gridColumn: getColumnGridColumn(key, columnOrder),
|
||||||
style={{ gridTemplateColumns: tableGridTemplate }}
|
} as React.CSSProperties);
|
||||||
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>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
const cells: Record<DownloadTableColumnKey, React.ReactNode> = {
|
||||||
|
'File Name': (
|
||||||
<div
|
<div
|
||||||
className="download-cell-truncate download-size-cell tabular-nums"
|
className="download-column-cell download-file-cell download-column-file-name"
|
||||||
title={hasDownloadedAmount
|
data-column-key="File Name"
|
||||||
? `${sizeDisplay.downloaded} downloaded of ${sizeDisplay.totalIsEstimate ? '~' : ''}${sizeDisplay.total} ${sizeDisplay.unit}`
|
style={columnStyle('File Name')}
|
||||||
: completedSizeLabel}
|
|
||||||
aria-label={hasDownloadedAmount
|
|
||||||
? `${sizeDisplay.downloaded} downloaded of ${sizeDisplay.totalIsEstimate ? 'approximately ' : ''}${sizeDisplay.total} ${sizeDisplay.unit}`
|
|
||||||
: completedSizeLabel}
|
|
||||||
>
|
>
|
||||||
{hasDownloadedAmount ? (
|
<div className="download-cell-content">
|
||||||
<span className="download-size-progress">
|
<span className="shrink-0 text-text-muted">
|
||||||
<span className={downloadProgressColorClass(download.status)}>{sizeDisplay.downloaded}</span>
|
{getCategoryIcon(download.category)}
|
||||||
<span className="text-text-muted"> / </span>
|
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
<span className="download-file-name" title={download.fileName}>
|
||||||
<span className="download-size-total">
|
{download.fileName}
|
||||||
{hasDownloadedAmount
|
</span>
|
||||||
? `${sizeDisplay.totalIsEstimate ? '~' : ''}${sizeDisplay.total} ${sizeDisplay.unit}`
|
{mediaQualityLabel ? (
|
||||||
: completedSizeLabel}
|
<span className="download-quality-chip shrink-0" title={t($ => $.addDownloads.quality)}>
|
||||||
</span>
|
{mediaQualityLabel}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
),
|
||||||
<div className="download-status-cell">
|
Size: (
|
||||||
|
<div
|
||||||
|
className="download-column-cell download-cell-truncate download-size-cell tabular-nums"
|
||||||
|
data-column-key="Size"
|
||||||
|
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>
|
||||||
|
),
|
||||||
|
Status: (
|
||||||
|
<div
|
||||||
|
className="download-column-cell download-status-cell"
|
||||||
|
data-column-key="Status"
|
||||||
|
data-column-alignment={columnAlignments.Status}
|
||||||
|
style={columnStyle('Status')}
|
||||||
|
>
|
||||||
{download.status === 'completed' ? (
|
{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-track">
|
||||||
<div
|
<div
|
||||||
className={`download-progress-fill ${
|
className={`download-progress-fill ${
|
||||||
@@ -124,20 +257,20 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<span
|
<span
|
||||||
title={
|
title={
|
||||||
download.lastError && (download.status === 'failed' || download.status === 'retrying')
|
download.lastError && (download.status === 'failed' || download.status === 'retrying')
|
||||||
? download.lastError
|
? download.lastError
|
||||||
: (download.status === 'queued' || download.status === 'staged') && queueIndex !== -1
|
: (download.status === 'queued' || download.status === 'staged') && queueIndex !== -1
|
||||||
? `${download.status === 'staged' ? 'In queue' : 'Queued'} #${queueIndex + 1}`
|
? `${downloadStatusLabel} #${queueIndex + 1}`
|
||||||
: download.status === 'downloading'
|
: download.status === 'downloading'
|
||||||
? displayPercent
|
? displayPercent
|
||||||
: download.status === 'processing'
|
: download.status === 'processing'
|
||||||
? 'Processing'
|
? downloadStatusLabel
|
||||||
: download.status.charAt(0).toUpperCase() + download.status.slice(1)
|
: downloadStatusLabel
|
||||||
}
|
}
|
||||||
className={`download-status flex items-center gap-1.5 ${
|
className={`download-status flex items-center gap-1.5 ${
|
||||||
download.status === 'paused' ? 'download-status-paused' :
|
download.status === 'paused' ? 'download-status-paused' :
|
||||||
download.status === 'failed' ? 'download-status-failed' :
|
download.status === 'failed' ? 'download-status-failed' :
|
||||||
download.status === 'processing' ? 'download-status-processing' :
|
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 === 'queued' || download.status === 'staged' ? 'download-status-queued' :
|
||||||
@@ -146,96 +279,153 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
|
|||||||
>
|
>
|
||||||
{(download.status === 'queued' || download.status === 'staged') && queueIndex !== -1 ? (
|
{(download.status === 'queued' || download.status === 'staged') && queueIndex !== -1 ? (
|
||||||
<>
|
<>
|
||||||
<Clock size={12} className={download.status === 'queued' ? 'animate-pulse shrink-0' : 'shrink-0'} />
|
<Clock size={12} className={download.status === 'queued' ? 'animate-pulse motion-reduce:animate-none shrink-0' : 'shrink-0'} />
|
||||||
<span className="truncate">
|
<span className="truncate">
|
||||||
{download.status === 'staged' ? 'In queue' : 'Queued'} #{queueIndex + 1}
|
{downloadStatusLabel} #{queueIndex + 1}
|
||||||
</span>
|
</span>
|
||||||
</>
|
</>
|
||||||
) : download.status === 'downloading' ? (
|
) : download.status === 'downloading' ? (
|
||||||
displayPercent
|
displayPercent
|
||||||
) : download.status === 'processing' ? (
|
) : download.status === 'processing' ? (
|
||||||
'Processing'
|
downloadStatusLabel
|
||||||
) : (
|
) : (
|
||||||
download.status.charAt(0).toUpperCase() + download.status.slice(1)
|
downloadStatusLabel
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
</>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
),
|
||||||
<div className="download-cell-truncate">
|
Speed: (
|
||||||
<span
|
<div className="download-column-cell download-cell-truncate" data-column-key="Speed" style={columnStyle('Speed')}>
|
||||||
className="tabular-nums"
|
<span className="download-cell-content tabular-nums" title={displaySpeed}>
|
||||||
title={displaySpeed}
|
|
||||||
>
|
|
||||||
{displaySpeed}
|
{displaySpeed}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
),
|
||||||
<div className="download-cell-truncate">
|
ETA: (
|
||||||
<span
|
<div className="download-column-cell download-cell-truncate" data-column-key="ETA" style={columnStyle('ETA')}>
|
||||||
className="tabular-nums"
|
<span className="download-cell-content tabular-nums" title={displayEta}>
|
||||||
title={displayEta}
|
|
||||||
>
|
|
||||||
{displayEta}
|
{displayEta}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
),
|
||||||
<div className="download-cell-right">
|
'Date Added': (
|
||||||
|
<div className="download-column-cell download-cell-right download-column-date-added" data-column-key="Date Added" style={columnStyle('Date Added')}>
|
||||||
<span
|
<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() : '-'}
|
title={dateAddedLabel}
|
||||||
>
|
>
|
||||||
{download.dateAdded ? new Date(download.dateAdded).toLocaleDateString() : '-'}
|
{dateAddedLabel}
|
||||||
</span>
|
</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={() => onMoveInQueue(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={() => onMoveInQueue(download.id, 'down')}
|
|
||||||
disabled={queueIndex === queueLength - 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>
|
</div>
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
const rowActions = hasRowActions ? (
|
||||||
|
<div
|
||||||
|
className="download-row-actions items-center gap-0.5"
|
||||||
|
style={{
|
||||||
|
...actionPosition,
|
||||||
|
visibility: isActionVisible && actionPosition?.visibility === 'visible' ? 'visible' : 'hidden',
|
||||||
|
width: `${DOWNLOAD_ACTIONS_COLUMN_WIDTH}px`,
|
||||||
|
}}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
onDoubleClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{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>
|
||||||
|
) : null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={rowRef}
|
||||||
|
data-download-id={download.id}
|
||||||
|
className={`download-row group cursor-default relative ${isActionVisible ? 'has-visible-actions' : ''} ${isSelected ? 'is-selected' : ''} ${isQueueReorderable ? 'is-queue-reorderable' : ''} ${isQueueDragSource ? 'is-queue-drag-source' : ''}`}
|
||||||
|
style={{ minWidth: tableMinWidth }}
|
||||||
|
tabIndex={0}
|
||||||
|
onMouseEnter={() => {
|
||||||
|
setIsRowHovered(true);
|
||||||
|
if (hasRowActions) updateActionPosition();
|
||||||
|
}}
|
||||||
|
onMouseLeave={event => {
|
||||||
|
const nextTarget = event.relatedTarget;
|
||||||
|
if (!(nextTarget instanceof Node) || !event.currentTarget.contains(nextTarget)) {
|
||||||
|
setIsRowHovered(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onFocus={() => {
|
||||||
|
setIsRowFocused(true);
|
||||||
|
if (hasRowActions) updateActionPosition();
|
||||||
|
}}
|
||||||
|
onBlur={event => {
|
||||||
|
const nextTarget = event.relatedTarget;
|
||||||
|
if (!(nextTarget instanceof Node) || !event.currentTarget.contains(nextTarget)) {
|
||||||
|
setIsRowFocused(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onPointerDown={event => {
|
||||||
|
// Modifier clicks belong to selection. Starting a row drag first can
|
||||||
|
// capture the pointer and suppress the click that applies Cmd/Ctrl or
|
||||||
|
// Shift selection.
|
||||||
|
if (
|
||||||
|
isQueueReorderable &&
|
||||||
|
!event.shiftKey &&
|
||||||
|
!event.metaKey &&
|
||||||
|
!event.ctrlKey &&
|
||||||
|
!event.altKey
|
||||||
|
) {
|
||||||
|
onQueueDragStart(download.id, event);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onClick={(e) => onClick(e, download)}
|
||||||
|
onKeyDown={event => {
|
||||||
|
if (
|
||||||
|
isQueueReorderable &&
|
||||||
|
event.altKey &&
|
||||||
|
!event.metaKey &&
|
||||||
|
!event.ctrlKey &&
|
||||||
|
!event.shiftKey &&
|
||||||
|
(event.key === 'ArrowUp' || event.key === 'ArrowDown')
|
||||||
|
) {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
onMoveInQueue(download.id, event.key === 'ArrowUp' ? 'up' : 'down');
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onContextMenu={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setContextMenu({ x: e.clientX, y: e.clientY, id: download.id });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="download-row-motion"
|
||||||
|
style={{ gridTemplateColumns: tableGridTemplate, minWidth: tableMinWidth }}
|
||||||
|
>
|
||||||
|
{columnOrder.map(columnKey => (
|
||||||
|
<React.Fragment key={columnKey}>{cells[columnKey]}</React.Fragment>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{rowActions}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
+1795
-155
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,5 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
export type DuplicateReason = { type: 'url', msg: string } | { type: 'file', msg: string };
|
export type DuplicateReason = { type: 'url', msg: string } | { type: 'file', msg: string };
|
||||||
type DuplicateResolution = 'rename' | 'replace' | 'skip';
|
type DuplicateResolution = 'rename' | 'replace' | 'skip';
|
||||||
@@ -9,6 +10,7 @@ export interface DuplicateConflict {
|
|||||||
reason: DuplicateReason;
|
reason: DuplicateReason;
|
||||||
resolution: DuplicateResolution;
|
resolution: DuplicateResolution;
|
||||||
replaceAllowed?: boolean;
|
replaceAllowed?: boolean;
|
||||||
|
existingDownloadId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -18,6 +20,7 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const DuplicateResolutionModal = ({ conflicts: initialConflicts, onConfirm, onCancel }: Props) => {
|
export const DuplicateResolutionModal = ({ conflicts: initialConflicts, onConfirm, onCancel }: Props) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
const [conflicts, setConflicts] = useState<DuplicateConflict[]>(initialConflicts);
|
const [conflicts, setConflicts] = useState<DuplicateConflict[]>(initialConflicts);
|
||||||
|
|
||||||
useEffect(() => {
|
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="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">
|
<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>
|
<h2 className="text-lg font-semibold text-text-primary">{t($ => $.dialogs.duplicateDownloads.title)}</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>
|
<p className="text-xs text-text-muted">{t($ => $.dialogs.duplicateDownloads.description)}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="max-h-[300px] overflow-y-auto p-4 space-y-3">
|
<div className="max-h-[300px] overflow-y-auto p-4 space-y-3">
|
||||||
@@ -59,9 +62,9 @@ export const DuplicateResolutionModal = ({ conflicts: initialConflicts, onConfir
|
|||||||
onChange={(e) => updateResolution(conflict.id, e.target.value as DuplicateResolution)}
|
onChange={(e) => updateResolution(conflict.id, e.target.value as DuplicateResolution)}
|
||||||
className="app-control w-24 shrink-0 px-2 py-1 text-xs"
|
className="app-control w-24 shrink-0 px-2 py-1 text-xs"
|
||||||
>
|
>
|
||||||
<option value="rename">Rename</option>
|
<option value="rename">{t($ => $.dialogs.duplicateDownloads.rename)}</option>
|
||||||
{conflict.reason.type === 'file' && conflict.replaceAllowed && <option value="replace">Replace</option>}
|
{conflict.reason.type === 'file' && conflict.replaceAllowed && <option value="replace">{t($ => $.dialogs.duplicateDownloads.replace)}</option>}
|
||||||
<option value="skip">Skip</option>
|
<option value="skip">{t($ => $.dialogs.duplicateDownloads.skip)}</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -69,13 +72,13 @@ export const DuplicateResolutionModal = ({ conflicts: initialConflicts, onConfir
|
|||||||
|
|
||||||
<div className="p-4 border-t border-border-modal flex items-center justify-between bg-sidebar-bg/50">
|
<div className="p-4 border-t border-border-modal flex items-center justify-between bg-sidebar-bg/50">
|
||||||
<button onClick={onCancel} className="app-button px-4 text-xs">
|
<button onClick={onCancel} className="app-button px-4 text-xs">
|
||||||
Cancel
|
{t($ => $.actions.cancel)}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => onConfirm(conflicts.map(c => ({ id: c.id, resolution: c.resolution })))}
|
onClick={() => onConfirm(conflicts.map(c => ({ id: c.id, resolution: c.resolution })))}
|
||||||
className="app-button app-button-primary px-5 text-xs"
|
className="app-button app-button-primary px-5 text-xs"
|
||||||
>
|
>
|
||||||
Continue
|
{t($ => $.actions.continue)}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Component, ErrorInfo, ReactNode } from 'react';
|
import { Component, ErrorInfo, ReactNode } from 'react';
|
||||||
|
import i18n from '../i18n';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
children: ReactNode;
|
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">
|
<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" />
|
<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>
|
</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>
|
||||||
<div className="text-sm text-text-secondary mb-6">
|
<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>
|
||||||
<div className="bg-black/50 p-4 rounded text-xs font-mono text-red-300 overflow-x-auto mb-4">
|
<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()}
|
{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"
|
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()}
|
onClick={() => window.location.reload()}
|
||||||
>
|
>
|
||||||
Reload App
|
{i18n.t($ => $.dialogs.errorBoundary.reload)}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useRef, useState } from 'react';
|
||||||
import { useSettingsStore } from '../store/useSettingsStore';
|
import { useSettingsStore } from '../store/useSettingsStore';
|
||||||
import { invokeCommand as invoke } from '../ipc';
|
import { invokeCommand as invoke } from '../ipc';
|
||||||
import { KeyRound, ShieldAlert } from 'lucide-react';
|
import { KeyRound, ShieldAlert } from 'lucide-react';
|
||||||
@@ -6,6 +6,7 @@ import { usePlatformInfo } from '../utils/platform';
|
|||||||
import { getKeychainConsentVersion } from '../utils/keychainStartup';
|
import { getKeychainConsentVersion } from '../utils/keychainStartup';
|
||||||
import { getVersion } from '@tauri-apps/api/app';
|
import { getVersion } from '@tauri-apps/api/app';
|
||||||
import type { PairingTokenHydration } from '../bindings/PairingTokenHydration';
|
import type { PairingTokenHydration } from '../bindings/PairingTokenHydration';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
const KEYCHAIN_GRANT_TIMEOUT_MS = 30_000;
|
const KEYCHAIN_GRANT_TIMEOUT_MS = 30_000;
|
||||||
|
|
||||||
@@ -14,20 +15,34 @@ type KeychainPermissionModalProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const KeychainPermissionModal: React.FC<KeychainPermissionModalProps> = ({ consentVersion }) => {
|
export const KeychainPermissionModal: React.FC<KeychainPermissionModalProps> = ({ consentVersion }) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
const showKeychainModal = useSettingsStore(state => state.showKeychainModal);
|
const showKeychainModal = useSettingsStore(state => state.showKeychainModal);
|
||||||
const dismissKeychainPrompt = useSettingsStore(state => state.dismissKeychainPrompt);
|
const dismissKeychainPrompt = useSettingsStore(state => state.dismissKeychainPrompt);
|
||||||
const platform = usePlatformInfo();
|
const platform = usePlatformInfo();
|
||||||
const [isGranting, setIsGranting] = useState(false);
|
const [isGranting, setIsGranting] = useState(false);
|
||||||
|
const [grantRequestPending, setGrantRequestPending] = useState(false);
|
||||||
|
const [grantRequestTimedOut, setGrantRequestTimedOut] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const isMountedRef = useRef(true);
|
||||||
|
const grantRequestRef = useRef<Promise<PairingTokenHydration> | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => () => {
|
||||||
|
isMountedRef.current = false;
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!showKeychainModal || isGranting) return;
|
if (!showKeychainModal || isGranting || grantRequestPending) return;
|
||||||
const handleEscape = (event: KeyboardEvent) => {
|
const handleEscape = (event: KeyboardEvent) => {
|
||||||
if (event.key === 'Escape') dismissKeychainPrompt(consentVersion);
|
if (event.key !== 'Escape') return;
|
||||||
|
if (consentVersion.trim()) {
|
||||||
|
dismissKeychainPrompt(consentVersion);
|
||||||
|
} else {
|
||||||
|
useSettingsStore.getState().setShowKeychainModal(false);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
window.addEventListener('keydown', handleEscape);
|
window.addEventListener('keydown', handleEscape);
|
||||||
return () => window.removeEventListener('keydown', handleEscape);
|
return () => window.removeEventListener('keydown', handleEscape);
|
||||||
}, [consentVersion, dismissKeychainPrompt, isGranting, showKeychainModal]);
|
}, [consentVersion, dismissKeychainPrompt, grantRequestPending, isGranting, showKeychainModal]);
|
||||||
|
|
||||||
if (!showKeychainModal) {
|
if (!showKeychainModal) {
|
||||||
return null;
|
return null;
|
||||||
@@ -36,25 +51,30 @@ export const KeychainPermissionModal: React.FC<KeychainPermissionModalProps> = (
|
|||||||
const isMac = platform.os === 'macos';
|
const isMac = platform.os === 'macos';
|
||||||
const pairingStoreName =
|
const pairingStoreName =
|
||||||
platform.portable
|
platform.portable
|
||||||
? 'the portable Firelink data folder'
|
? t($ => $.keychain.stores.portable)
|
||||||
: platform.os === 'windows'
|
: platform.os === 'windows'
|
||||||
? 'Windows Credential Manager'
|
? t($ => $.keychain.stores.windows)
|
||||||
: platform.os === 'linux'
|
: platform.os === 'linux'
|
||||||
? 'your Linux credential store'
|
? t($ => $.keychain.stores.linux)
|
||||||
: platform.os === 'macos'
|
: platform.os === 'macos'
|
||||||
? 'macOS Keychain'
|
? t($ => $.keychain.stores.macos)
|
||||||
: "this system's credential store";
|
: t($ => $.keychain.stores.system);
|
||||||
const siteCredentialStoreName = platform.portable
|
const siteCredentialStoreName = platform.portable
|
||||||
? "the system's credential store"
|
? t($ => $.keychain.stores.siteCredentials)
|
||||||
: pairingStoreName;
|
: pairingStoreName;
|
||||||
const grantLabel = platform.portable
|
const grantLabel = platform.portable
|
||||||
? 'Continue'
|
? t($ => $.keychain.grantLabelPortable)
|
||||||
: isMac
|
: isMac
|
||||||
? 'Grant Access'
|
? t($ => $.keychain.grantLabelMacos)
|
||||||
: 'Enable Secure Storage';
|
: t($ => $.keychain.grantLabelDefault);
|
||||||
|
|
||||||
const handleGrant = async () => {
|
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);
|
setIsGranting(true);
|
||||||
|
setGrantRequestTimedOut(false);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
let timeoutId: number | undefined;
|
let timeoutId: number | undefined;
|
||||||
@@ -77,9 +97,27 @@ export const KeychainPermissionModal: React.FC<KeychainPermissionModalProps> = (
|
|||||||
return true;
|
return true;
|
||||||
};
|
};
|
||||||
const grantRequest = invoke('grant_keychain_access');
|
const grantRequest = invoke('grant_keychain_access');
|
||||||
|
grantRequestRef.current = grantRequest;
|
||||||
|
setGrantRequestPending(true);
|
||||||
|
void grantRequest.then(
|
||||||
|
() => {
|
||||||
|
if (grantRequestRef.current === grantRequest) grantRequestRef.current = null;
|
||||||
|
if (isMountedRef.current) {
|
||||||
|
setGrantRequestPending(false);
|
||||||
|
setGrantRequestTimedOut(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
() => {
|
||||||
|
if (grantRequestRef.current === grantRequest) grantRequestRef.current = null;
|
||||||
|
if (isMountedRef.current) {
|
||||||
|
setGrantRequestPending(false);
|
||||||
|
setGrantRequestTimedOut(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
// A native credential-store call cannot be cancelled by the webview. Keep
|
// A native credential-store call cannot be cancelled by the webview. Keep
|
||||||
// a late successful result useful even if the UI timeout has already
|
// a late successful result useful even if the UI timeout has already
|
||||||
// restored the Later/retry controls.
|
// returned control to the explanation.
|
||||||
grantRequest.then(applyPersistentGrant).catch(() => undefined);
|
grantRequest.then(applyPersistentGrant).catch(() => undefined);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -87,34 +125,48 @@ export const KeychainPermissionModal: React.FC<KeychainPermissionModalProps> = (
|
|||||||
grantRequest,
|
grantRequest,
|
||||||
new Promise<never>((_, reject) => {
|
new Promise<never>((_, reject) => {
|
||||||
timeoutId = window.setTimeout(
|
timeoutId = window.setTimeout(
|
||||||
() => reject(new Error('Credential storage request timed out. You can select Later and try again.')),
|
() => {
|
||||||
|
if (isMountedRef.current) setGrantRequestTimedOut(true);
|
||||||
|
reject(new Error(t($ => $.keychain.timeout)));
|
||||||
|
},
|
||||||
KEYCHAIN_GRANT_TIMEOUT_MS
|
KEYCHAIN_GRANT_TIMEOUT_MS
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
]);
|
]);
|
||||||
if (!(await applyPersistentGrant(result))) {
|
if (!(await applyPersistentGrant(result))) {
|
||||||
setError(result.error || `${siteCredentialStoreName} is unavailable.`);
|
if (isMountedRef.current) {
|
||||||
|
setError(result.error || t($ => $.keychain.unavailable, { store: siteCredentialStoreName }));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
setError(e.toString());
|
if (isMountedRef.current) setError(e.toString());
|
||||||
} finally {
|
} finally {
|
||||||
if (timeoutId !== undefined) window.clearTimeout(timeoutId);
|
if (timeoutId !== undefined) window.clearTimeout(timeoutId);
|
||||||
setIsGranting(false);
|
if (isMountedRef.current) setIsGranting(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleLater = () => {
|
const handleLater = () => {
|
||||||
dismissKeychainPrompt(consentVersion);
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
className="app-modal-backdrop fixed inset-0 z-50 flex items-center justify-center bg-black/40"
|
className="app-modal-backdrop fixed inset-0 z-50 flex items-center justify-center bg-black/40"
|
||||||
onClick={(event) => {
|
onClick={(event) => {
|
||||||
if (event.target === event.currentTarget && !isGranting) handleLater();
|
if (event.target === event.currentTarget && !isGranting && !grantRequestPending) handleLater();
|
||||||
}}
|
}}
|
||||||
role="dialog"
|
role="dialog"
|
||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
|
aria-labelledby="keychain-permission-title"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className="window-safe-modal bg-bg-modal rounded-xl w-full max-w-md overflow-hidden flex flex-col shadow-2xl border border-border-modal scale-in"
|
className="window-safe-modal bg-bg-modal rounded-xl w-full max-w-md overflow-hidden flex flex-col shadow-2xl border border-border-modal scale-in"
|
||||||
@@ -124,28 +176,27 @@ export const KeychainPermissionModal: React.FC<KeychainPermissionModalProps> = (
|
|||||||
<div className="p-2 bg-blue-500/10 rounded-full items-center justify-center">
|
<div className="p-2 bg-blue-500/10 rounded-full items-center justify-center">
|
||||||
<KeyRound size={20} className="text-blue-500" />
|
<KeyRound size={20} className="text-blue-500" />
|
||||||
</div>
|
</div>
|
||||||
<h2 className="text-lg font-semibold text-text-primary m-0">Credential Storage Access Needed</h2>
|
<h2 id="keychain-permission-title" className="text-lg font-semibold text-text-primary m-0">{t($ => $.keychain.title)}</h2>
|
||||||
</div>
|
</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">
|
<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>
|
<p>
|
||||||
Firelink uses the browser extension to capture downloads. To keep the extension paired after restarts,
|
{t($ => $.keychain.description, { pairingStore: pairingStoreName, siteCredentialStore: siteCredentialStoreName })}
|
||||||
Firelink stores its pairing token in {pairingStoreName}. Optional site credentials are stored in {siteCredentialStoreName}.
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p>
|
<p>
|
||||||
{platform.portable
|
{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
|
: isMac
|
||||||
? 'macOS may show a Keychain prompt after you grant access.'
|
? t($ => $.keychain.macosExplanation)
|
||||||
: '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.defaultExplanation)}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p>
|
<p>
|
||||||
<strong>Note:</strong>{' '}
|
<strong>{t($ => $.keychain.note)}</strong>{' '}
|
||||||
{platform.portable
|
{platform.portable
|
||||||
? 'Portable mode stores only the pairing token in this folder. It does not copy site passwords or browser credentials.'
|
? t($ => $.keychain.portableNote)
|
||||||
: 'Firelink only writes its own dedicated credential entry. It cannot access other saved passwords or credential items on your system.'}
|
: t($ => $.keychain.defaultNote)}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
@@ -156,28 +207,30 @@ export const KeychainPermissionModal: React.FC<KeychainPermissionModalProps> = (
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="bg-bg-modal-accent p-3 rounded-lg border border-border-modal text-xs">
|
<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
|
{platform.portable
|
||||||
? 'The portable pairing token is already stored with this folder; you can enable it here or select Later.'
|
? t($ => $.keychain.portableHint)
|
||||||
: 'If you select Later, the extension will only work for this session.'}
|
: t($ => $.keychain.defaultHint)}{' '}
|
||||||
You can enable storage anytime from <strong>Settings > Integrations</strong>.
|
{t($ => $.keychain.enableFromSettings)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="px-5 py-4 border-t border-border-modal flex justify-end gap-3 bg-bg-modal-accent">
|
<div className="px-5 py-4 border-t border-border-modal flex justify-end gap-3 bg-bg-modal-accent">
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
onClick={handleLater}
|
onClick={handleLater}
|
||||||
disabled={isGranting}
|
disabled={isGranting || (grantRequestPending && !grantRequestTimedOut)}
|
||||||
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="keychain-modal-action px-4 py-2 rounded-lg text-sm font-medium text-text-secondary hover:bg-item-hover hover:text-text-primary disabled:opacity-50"
|
||||||
>
|
>
|
||||||
Later
|
{t($ => $.keychain.later)}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
onClick={handleGrant}
|
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"
|
className="keychain-modal-action px-4 py-2 rounded-lg text-sm font-medium bg-accent text-accent-foreground hover:bg-accent/90 disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{isGranting ? 'Enabling...' : grantLabel}
|
{isGranting || grantRequestPending ? t($ => $.keychain.enabling) : grantLabel}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+30
-28
@@ -7,6 +7,7 @@ import { FileDown, Trash2, Terminal, Filter, Play, Pause, Info, Copy } from 'luc
|
|||||||
import { WindowDragRegion } from './WindowDragRegion';
|
import { WindowDragRegion } from './WindowDragRegion';
|
||||||
import { useToast } from '../contexts/ToastContext';
|
import { useToast } from '../contexts/ToastContext';
|
||||||
import { useSettingsStore } from '../store/useSettingsStore';
|
import { useSettingsStore } from '../store/useSettingsStore';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
import {
|
import {
|
||||||
MAX_LOG_LINES,
|
MAX_LOG_LINES,
|
||||||
appendBoundedLogEntries,
|
appendBoundedLogEntries,
|
||||||
@@ -18,6 +19,7 @@ import {
|
|||||||
} from '../utils/logEntries';
|
} from '../utils/logEntries';
|
||||||
|
|
||||||
export default function LogsView() {
|
export default function LogsView() {
|
||||||
|
const { t } = useTranslation();
|
||||||
const { addToast } = useToast();
|
const { addToast } = useToast();
|
||||||
const logsEnabled = useSettingsStore(state => state.logsEnabled);
|
const logsEnabled = useSettingsStore(state => state.logsEnabled);
|
||||||
const setLogsEnabled = useSettingsStore(state => state.setLogsEnabled);
|
const setLogsEnabled = useSettingsStore(state => state.setLogsEnabled);
|
||||||
@@ -169,10 +171,10 @@ export default function LogsView() {
|
|||||||
if (contextMenu?.text) {
|
if (contextMenu?.text) {
|
||||||
try {
|
try {
|
||||||
await navigator.clipboard.writeText(contextMenu.text);
|
await navigator.clipboard.writeText(contextMenu.text);
|
||||||
addToast({ message: 'Copied to clipboard', variant: 'success' });
|
addToast({ message: t($ => $.logs.copied), variant: 'success' });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Clipboard write error:', 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);
|
setContextMenu(null);
|
||||||
@@ -182,14 +184,14 @@ export default function LogsView() {
|
|||||||
try {
|
try {
|
||||||
const path = await save({
|
const path = await save({
|
||||||
defaultPath: 'Firelink-Support-Logs.log',
|
defaultPath: 'Firelink-Support-Logs.log',
|
||||||
filters: [{ name: 'Log Files', extensions: ['log'] }],
|
filters: [{ name: t($ => $.logs.logFiles), extensions: ['log'] }],
|
||||||
});
|
});
|
||||||
if (!path) return;
|
if (!path) return;
|
||||||
await invoke('export_logs', { destination: path });
|
await invoke('export_logs', { destination: path });
|
||||||
addToast({ message: 'Support logs exported', variant: 'success' });
|
addToast({ message: t($ => $.logs.exported), variant: 'success' });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Export failed:', 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 });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -208,10 +210,10 @@ export default function LogsView() {
|
|||||||
liveFrameRef.current = null;
|
liveFrameRef.current = null;
|
||||||
}
|
}
|
||||||
setLogs([]);
|
setLogs([]);
|
||||||
addToast({ message: 'Logs cleared', variant: 'info' });
|
addToast({ message: t($ => $.logs.cleared), variant: 'info' });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
addToast({
|
addToast({
|
||||||
message: `Could not clear logs: ${String(error)}`,
|
message: t($ => $.logs.clearFailed, { detail: String(error) }),
|
||||||
variant: 'error',
|
variant: 'error',
|
||||||
isActionable: true
|
isActionable: true
|
||||||
});
|
});
|
||||||
@@ -231,7 +233,7 @@ export default function LogsView() {
|
|||||||
await setLogPaused(!nextEnabled);
|
await setLogPaused(!nextEnabled);
|
||||||
setLogsEnabled(nextEnabled);
|
setLogsEnabled(nextEnabled);
|
||||||
addToast({
|
addToast({
|
||||||
message: nextEnabled ? 'Diagnostic logging enabled' : 'Diagnostic logging disabled',
|
message: nextEnabled ? t($ => $.logs.enabled) : t($ => $.logs.disabled),
|
||||||
variant: 'success'
|
variant: 'success'
|
||||||
});
|
});
|
||||||
})();
|
})();
|
||||||
@@ -241,7 +243,7 @@ export default function LogsView() {
|
|||||||
await toggleOperation;
|
await toggleOperation;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
addToast({
|
addToast({
|
||||||
message: `Could not update diagnostic logging: ${String(error)}`,
|
message: t($ => $.logs.updateFailed, { detail: String(error) }),
|
||||||
variant: 'error',
|
variant: 'error',
|
||||||
isActionable: true
|
isActionable: true
|
||||||
});
|
});
|
||||||
@@ -270,12 +272,12 @@ export default function LogsView() {
|
|||||||
<div className="logs-toolbar flex items-center justify-between px-4 py-2 shrink-0">
|
<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">
|
<div className="flex items-center gap-2 text-text-secondary">
|
||||||
<Terminal size={16} strokeWidth={1.8} />
|
<Terminal size={16} strokeWidth={1.8} />
|
||||||
<span className="text-[13px] font-semibold text-text-primary">Logs</span>
|
<span className="text-[13px] font-semibold text-text-primary">{t($ => $.logs.title)}</span>
|
||||||
<span className="text-[11px] text-text-muted">({logs.length} entries)</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 ${
|
<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 ? 'bg-accent/15 text-accent' : 'bg-item-hover text-text-muted'
|
||||||
}`}>
|
}`}>
|
||||||
{logsEnabled ? 'Collecting' : 'Off'}
|
{logsEnabled ? t($ => $.logs.collecting) : t($ => $.logs.off)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
@@ -286,12 +288,12 @@ export default function LogsView() {
|
|||||||
onChange={e => setLevelFilter(e.target.value as LogEntry['level'] | 'All')}
|
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"
|
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="All">{t($ => $.logs.allLevels)}</option>
|
||||||
<option value="Error">Error</option>
|
<option value="Error">{t($ => $.logs.levels.error)}</option>
|
||||||
<option value="Warn">Warn</option>
|
<option value="Warn">{t($ => $.logs.levels.warn)}</option>
|
||||||
<option value="Info">Info</option>
|
<option value="Info">{t($ => $.logs.levels.info)}</option>
|
||||||
<option value="Debug">Debug</option>
|
<option value="Debug">{t($ => $.logs.levels.debug)}</option>
|
||||||
<option value="Trace">Trace</option>
|
<option value="Trace">{t($ => $.logs.levels.trace)}</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div className="w-[1px] h-4 bg-border-modal mx-0.5" />
|
<div className="w-[1px] h-4 bg-border-modal mx-0.5" />
|
||||||
@@ -299,7 +301,7 @@ export default function LogsView() {
|
|||||||
onClick={handleToggleLogging}
|
onClick={handleToggleLogging}
|
||||||
disabled={isToggling}
|
disabled={isToggling}
|
||||||
className={`app-icon-button disabled:cursor-not-allowed disabled:opacity-50 ${logsEnabled ? 'text-accent' : ''}`}
|
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} />}
|
{logsEnabled ? <Pause size={14} /> : <Play size={14} />}
|
||||||
</button>
|
</button>
|
||||||
@@ -307,17 +309,17 @@ export default function LogsView() {
|
|||||||
onClick={handleClear}
|
onClick={handleClear}
|
||||||
disabled={isClearing}
|
disabled={isClearing}
|
||||||
className="app-icon-button disabled:cursor-not-allowed disabled:opacity-50"
|
className="app-icon-button disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
title="Clear displayed logs"
|
title={t($ => $.logs.clearDisplayed)}
|
||||||
>
|
>
|
||||||
<Trash2 size={14} />
|
<Trash2 size={14} />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={handleExport}
|
onClick={handleExport}
|
||||||
className="app-button px-3 text-[11px] gap-1.5"
|
className="app-button px-3 text-[11px] gap-1.5"
|
||||||
title="Export logs"
|
title={t($ => $.logs.export)}
|
||||||
>
|
>
|
||||||
<FileDown size={13} />
|
<FileDown size={13} />
|
||||||
Export Logs
|
{t($ => $.logs.exportButton)}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -326,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">
|
<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" />
|
<Info size={12} className="text-text-muted opacity-80 shrink-0" />
|
||||||
<span className="opacity-90 leading-tight">
|
<span className="opacity-90 leading-tight">
|
||||||
<strong className="font-medium text-text-primary mr-1">Local diagnostics:</strong>
|
<strong className="font-medium text-text-primary me-1">{t($ => $.logs.localDiagnostics)}</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.
|
{t($ => $.logs.diagnosticsDescription)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -340,7 +342,7 @@ export default function LogsView() {
|
|||||||
>
|
>
|
||||||
{logs.length === 0 && (
|
{logs.length === 0 && (
|
||||||
<div className="text-text-muted italic select-none">
|
<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>
|
</div>
|
||||||
)}
|
)}
|
||||||
{logs.filter(entry => levelFilter === 'All' || entry.level === levelFilter).map((entry, i) => (
|
{logs.filter(entry => levelFilter === 'All' || entry.level === levelFilter).map((entry, i) => (
|
||||||
@@ -360,11 +362,11 @@ export default function LogsView() {
|
|||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
<button
|
<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}
|
onClick={handleCopy}
|
||||||
>
|
>
|
||||||
<Copy size={13} className="mr-2 text-text-secondary" />
|
<Copy size={13} className="me-2 text-text-secondary" />
|
||||||
Copy
|
{t($ => $.logs.copy)}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { ChevronDown, ChevronRight, FolderPlus, Info, CheckCircle, AlertCircle,
|
|||||||
import { open } from '@tauri-apps/plugin-dialog';
|
import { open } from '@tauri-apps/plugin-dialog';
|
||||||
import { resolveCategoryDestination } from '../utils/downloadLocations';
|
import { resolveCategoryDestination } from '../utils/downloadLocations';
|
||||||
import {
|
import {
|
||||||
|
getPauseResumeAction,
|
||||||
isIdentityLocked as getIdentityLocked,
|
isIdentityLocked as getIdentityLocked,
|
||||||
isTransferLocked as getTransferLocked
|
isTransferLocked as getTransferLocked
|
||||||
} from '../utils/downloadActions';
|
} from '../utils/downloadActions';
|
||||||
@@ -16,18 +17,37 @@ import {
|
|||||||
resolveDownloadSizeDisplay
|
resolveDownloadSizeDisplay
|
||||||
} from '../utils/downloadProgress';
|
} from '../utils/downloadProgress';
|
||||||
import { resolveDownloadConnections } from '../utils/downloads';
|
import { resolveDownloadConnections } from '../utils/downloads';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { formatDateTime, type CalendarPreference } from '../utils/dateTime';
|
||||||
|
|
||||||
type LoginMode = 'matching' | 'custom' | 'none';
|
type LoginMode = 'matching' | 'custom' | 'none';
|
||||||
|
|
||||||
const formatLastTry = (value?: string): string => {
|
const formatLastTry = (
|
||||||
|
value: string | undefined,
|
||||||
|
locale: string,
|
||||||
|
calendar: CalendarPreference
|
||||||
|
): string => {
|
||||||
if (!value) return '-';
|
if (!value) return '-';
|
||||||
const date = new Date(value);
|
return formatDateTime(value, {
|
||||||
return Number.isNaN(date.getTime())
|
locale,
|
||||||
? '-'
|
calendar,
|
||||||
: date.toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short' });
|
options: { dateStyle: 'medium', timeStyle: 'short' }
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
export const PropertiesModal = () => {
|
export const PropertiesModal = () => {
|
||||||
|
const { t, i18n } = 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 selectedPropertiesDownloadId = useDownloadStore(state => state.selectedPropertiesDownloadId);
|
||||||
const setSelectedPropertiesDownloadId = useDownloadStore(state => state.setSelectedPropertiesDownloadId);
|
const setSelectedPropertiesDownloadId = useDownloadStore(state => state.setSelectedPropertiesDownloadId);
|
||||||
const item = useDownloadStore(useShallow(state =>
|
const item = useDownloadStore(useShallow(state =>
|
||||||
@@ -41,7 +61,7 @@ export const PropertiesModal = () => {
|
|||||||
: undefined
|
: undefined
|
||||||
));
|
));
|
||||||
|
|
||||||
const { baseDownloadFolder, perServerConnections } = useSettingsStore();
|
const { baseDownloadFolder, perServerConnections, calendarPreference } = useSettingsStore();
|
||||||
|
|
||||||
// Form states
|
// Form states
|
||||||
const [url, setUrl] = useState('');
|
const [url, setUrl] = useState('');
|
||||||
@@ -52,6 +72,8 @@ export const PropertiesModal = () => {
|
|||||||
|
|
||||||
const [speedLimitEnabled, setSpeedLimitEnabled] = useState(false);
|
const [speedLimitEnabled, setSpeedLimitEnabled] = useState(false);
|
||||||
const [speedLimitValue, setSpeedLimitValue] = useState('1024'); // KiB/s
|
const [speedLimitValue, setSpeedLimitValue] = useState('1024'); // KiB/s
|
||||||
|
const [liveSpeedLimitValue, setLiveSpeedLimitValue] = useState('');
|
||||||
|
const [isLiveSpeedLimitPending, setIsLiveSpeedLimitPending] = useState(false);
|
||||||
|
|
||||||
const [loginMode, setLoginMode] = useState<LoginMode>('matching');
|
const [loginMode, setLoginMode] = useState<LoginMode>('matching');
|
||||||
const [username, setUsername] = useState('');
|
const [username, setUsername] = useState('');
|
||||||
@@ -66,6 +88,7 @@ export const PropertiesModal = () => {
|
|||||||
const [mirrors, setMirrors] = useState('');
|
const [mirrors, setMirrors] = useState('');
|
||||||
|
|
||||||
const [errorMessage, setErrorMessage] = useState('');
|
const [errorMessage, setErrorMessage] = useState('');
|
||||||
|
const [isPauseResumePending, setIsPauseResumePending] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (selectedPropertiesDownloadId) {
|
if (selectedPropertiesDownloadId) {
|
||||||
@@ -122,6 +145,11 @@ export const PropertiesModal = () => {
|
|||||||
}
|
}
|
||||||
}, [selectedPropertiesDownloadId, setSelectedPropertiesDownloadId]);
|
}, [selectedPropertiesDownloadId, setSelectedPropertiesDownloadId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const activeLimit = item?.speedLimit?.trim();
|
||||||
|
setLiveSpeedLimitValue(activeLimit && activeLimit !== '0' ? activeLimit : '');
|
||||||
|
}, [item?.speedLimit, selectedPropertiesDownloadId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!selectedPropertiesDownloadId || connectionsDirty) return;
|
if (!selectedPropertiesDownloadId || connectionsDirty) return;
|
||||||
const activeItem = useDownloadStore.getState().downloads.find(d => d.id === selectedPropertiesDownloadId);
|
const activeItem = useDownloadStore.getState().downloads.find(d => d.id === selectedPropertiesDownloadId);
|
||||||
@@ -159,11 +187,11 @@ export const PropertiesModal = () => {
|
|||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
if (!url.trim()) {
|
if (!url.trim()) {
|
||||||
setErrorMessage("Enter a valid URL.");
|
setErrorMessage(t($ => $.properties.enterValidUrl));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!fileName.trim()) {
|
if (!fileName.trim()) {
|
||||||
setErrorMessage("File name cannot be empty.");
|
setErrorMessage(t($ => $.properties.fileNameEmpty));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -192,8 +220,92 @@ export const PropertiesModal = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handlePauseResume = async () => {
|
||||||
|
const currentItem = useDownloadStore.getState().downloads.find(download => download.id === item.id);
|
||||||
|
const action = currentItem ? getPauseResumeAction(currentItem.status) : null;
|
||||||
|
if (!currentItem || !action || isPauseResumePending) return;
|
||||||
|
|
||||||
|
if (action === 'pause' && currentItem.resumable === false) {
|
||||||
|
const confirmPause = window.confirm(t($ => $.downloadTable.nonResumableOne));
|
||||||
|
if (!confirmPause) return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setErrorMessage('');
|
||||||
|
setIsPauseResumePending(true);
|
||||||
|
try {
|
||||||
|
if (action === 'pause') {
|
||||||
|
await useDownloadStore.getState().pauseDownload(currentItem.id);
|
||||||
|
} else {
|
||||||
|
const resumed = await useDownloadStore.getState().resumeDownload(currentItem.id);
|
||||||
|
if (!resumed) {
|
||||||
|
throw new Error(t($ => $.downloadTable.backendRejectedStart));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
const detail = error instanceof Error ? error.message : String(error);
|
||||||
|
const message = action === 'pause'
|
||||||
|
? t($ => $.downloadTable.pauseFailed)
|
||||||
|
: t($ => $.downloadTable.resumeFailed, { fileName: currentItem.fileName });
|
||||||
|
setErrorMessage(t($ => $.downloadTable.interactionError, { message, detail }));
|
||||||
|
} finally {
|
||||||
|
setIsPauseResumePending(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLiveSpeedLimit = async (limit: string | null) => {
|
||||||
|
if (isLiveSpeedLimitPending || item.isMedia || !['downloading', 'retrying'].includes(item.status)) return;
|
||||||
|
|
||||||
|
setErrorMessage('');
|
||||||
|
setIsLiveSpeedLimitPending(true);
|
||||||
|
try {
|
||||||
|
await useDownloadStore.getState().setDownloadSpeedLimit(item.id, limit);
|
||||||
|
if (limit === null) setLiveSpeedLimitValue('');
|
||||||
|
} catch (error) {
|
||||||
|
setErrorMessage(t($ => $.properties.liveSpeedLimitFailed, {
|
||||||
|
detail: error instanceof Error ? error.message : String(error)
|
||||||
|
}));
|
||||||
|
} finally {
|
||||||
|
setIsLiveSpeedLimitPending(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const identityLocked = getIdentityLocked(item.status);
|
const identityLocked = getIdentityLocked(item.status);
|
||||||
const transferLocked = getTransferLocked(item.status);
|
const transferLocked = getTransferLocked(item.status);
|
||||||
|
const liveSpeedLimitAvailable = !item.isMedia && ['downloading', 'retrying'].includes(item.status);
|
||||||
|
const liveSpeedLimitUnavailable = item.isMedia && ['downloading', 'processing', 'retrying'].includes(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'
|
const displayedFraction = item.status === 'completed'
|
||||||
? 1
|
? 1
|
||||||
: liveProgress?.fraction ?? item.fraction ?? 0;
|
: liveProgress?.fraction ?? item.fraction ?? 0;
|
||||||
@@ -211,9 +323,27 @@ export const PropertiesModal = () => {
|
|||||||
});
|
});
|
||||||
const hasDownloadedAmount = item.status !== 'completed' &&
|
const hasDownloadedAmount = item.status !== 'completed' &&
|
||||||
Boolean(sizeDisplay.downloaded && sizeDisplay.total);
|
Boolean(sizeDisplay.downloaded && sizeDisplay.total);
|
||||||
const completedSizeLabel = item.status === 'completed'
|
const completedSizeLabel = (() => {
|
||||||
? formatDownloadTotal(sizeDisplay)
|
const value = item.status === 'completed' ? formatDownloadTotal(sizeDisplay) : sizeDisplay.fallback;
|
||||||
: sizeDisplay.fallback;
|
return value === 'Unknown' ? t($ => $.addDownloads.unknown) : value;
|
||||||
|
})();
|
||||||
|
const statusLabel = t($ => $.downloads.status[item.status]);
|
||||||
|
const pauseResumeAction = getPauseResumeAction(item.status);
|
||||||
|
const pauseResumeLabel = pauseResumeAction === 'pause'
|
||||||
|
? t($ => $.downloadTable.pause)
|
||||||
|
: t($ => $.downloadTable.resume);
|
||||||
|
const PauseResumeIcon = pauseResumeAction === 'pause' ? Pause : Play;
|
||||||
|
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 statusColor = 'text-text-secondary';
|
||||||
let StatusIcon = Info;
|
let StatusIcon = Info;
|
||||||
@@ -240,7 +370,7 @@ export const PropertiesModal = () => {
|
|||||||
<h2 className="text-base font-semibold truncate text-text-primary pr-4">{item.fileName}</h2>
|
<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}`}>
|
<span className={`flex items-center gap-1.5 text-xs font-semibold tracking-wide uppercase ${statusColor}`}>
|
||||||
<StatusIcon size={14} />
|
<StatusIcon size={14} />
|
||||||
{item.status}
|
{statusLabel}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -249,13 +379,13 @@ export const PropertiesModal = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-4 gap-y-2 gap-x-4 text-[11px] leading-tight">
|
<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-[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">
|
<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-muted font-medium w-[40px] shrink-0">{t($ => $.properties.size)}</span>
|
||||||
<span
|
<span
|
||||||
className="truncate"
|
className="truncate"
|
||||||
title={hasDownloadedAmount
|
title={hasDownloadedAmount
|
||||||
? `${sizeDisplay.downloaded} downloaded of ${sizeDisplay.totalIsEstimate ? 'approximately ' : ''}${sizeDisplay.total} ${sizeDisplay.unit}`
|
? sizeDescription
|
||||||
: completedSizeLabel}
|
: completedSizeLabel}
|
||||||
>
|
>
|
||||||
{hasDownloadedAmount ? (
|
{hasDownloadedAmount ? (
|
||||||
@@ -269,19 +399,19 @@ export const PropertiesModal = () => {
|
|||||||
) : completedSizeLabel}
|
) : completedSizeLabel}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</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-[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">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-[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" title={item.connections !== undefined ? 'Saved for this download; Settings changes apply to new downloads.' : 'Using the current default for new downloads.'}>{resolveDownloadConnections(item.connections, perServerConnections)}{item.connections !== undefined ? ' (saved)' : ' (default)'}</span></div>
|
<div className="flex gap-1.5 min-w-0"><span className="text-text-muted font-medium 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">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-[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">Category</span><span className="text-text-secondary truncate">{item.category}</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]">Last try</span><span className="text-text-secondary truncate">{formatLastTry(item.lastTry)}</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, i18n.language, calendarPreference)}</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-[90px]">{t($ => $.properties.dateAdded)}</span><span className="text-text-secondary truncate">{formatDateTime(item.dateAdded, { locale: i18n.language, calendar: calendarPreference, options: { 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-[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') && (
|
{item.lastError && (item.status === 'failed' || item.status === 'retrying') && (
|
||||||
<div className="flex gap-1.5 col-span-4 min-w-0">
|
<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>
|
<span className="text-red-400 truncate" title={item.lastError}>{item.lastError}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -298,68 +428,133 @@ export const PropertiesModal = () => {
|
|||||||
{item.status === 'completed' ? <CheckCircle size={16} className="text-green-500" /> : <AlertCircle size={16} className="text-blue-500" />}
|
{item.status === 'completed' ? <CheckCircle size={16} className="text-green-500" /> : <AlertCircle size={16} className="text-blue-500" />}
|
||||||
<span>
|
<span>
|
||||||
{item.status === 'completed'
|
{item.status === 'completed'
|
||||||
? "File identity is read-only. Transfer settings are saved for redownload."
|
? t($ => $.properties.identityReadOnly)
|
||||||
: "Transfer settings can be changed after stopping or pausing. Current transfers keep their existing backend options."}
|
: t($ => $.properties.transferSettings)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Download Section */}
|
{/* Download Section */}
|
||||||
<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">
|
<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" />
|
<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" />
|
<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">
|
<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" />
|
<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">
|
<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>
|
</button>
|
||||||
</div>
|
</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">
|
<div className="flex items-center gap-2">
|
||||||
<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" />
|
<input type="number" value={connections} min={1} max={16} onChange={e=>{ setConnections(Number(e.target.value)); setConnectionsDirty(true); }} disabled={transferLocked} className="w-16 bg-bg-input border border-border-modal rounded-lg px-2.5 py-1.5 text-xs text-text-primary focus:outline-none focus:border-accent disabled:opacity-50" />
|
||||||
<span className="text-xs text-text-muted">per file</span>
|
<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 && (
|
{!transferLocked && item.connections !== undefined && item.connections !== perServerConnections && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => { setConnections(perServerConnections); setConnectionsDirty(true); }}
|
onClick={() => { setConnections(perServerConnections); setConnectionsDirty(true); }}
|
||||||
className="text-[11px] text-accent hover:underline whitespace-nowrap"
|
className="text-[11px] text-accent hover:underline whitespace-nowrap"
|
||||||
>
|
>
|
||||||
Use current default ({perServerConnections})
|
{t($ => $.properties.useCurrentDefault, { count: perServerConnections })}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="col-start-2 text-[11px] text-text-muted">
|
<div className="col-start-2 text-[11px] text-text-muted">
|
||||||
Saved per download. The Settings default applies to new downloads.
|
{t($ => $.properties.savedPerDownload)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<label className="text-xs text-text-muted text-right">Speed</label>
|
<label className="text-xs text-text-muted text-right">{t($ => $.properties.speedCap)}</label>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<label className="flex items-center gap-2 text-xs text-text-primary">
|
<label className="inline-flex min-h-7 items-center gap-2 rounded-md border border-border-modal bg-bg-input px-2.5 py-1.5 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" />
|
<input
|
||||||
Limit
|
type="checkbox"
|
||||||
|
checked={speedLimitEnabled}
|
||||||
|
onChange={e => setSpeedLimitEnabled(e.target.checked)}
|
||||||
|
disabled={transferLocked}
|
||||||
|
className="accent-accent disabled:opacity-50"
|
||||||
|
/>
|
||||||
|
{t($ => $.properties.limit)}
|
||||||
</label>
|
</label>
|
||||||
{speedLimitEnabled && (
|
{speedLimitEnabled && (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<input type="number" value={speedLimitValue} min={1} step={128} onChange={e=>setSpeedLimitValue(e.target.value)} disabled={transferLocked} className="w-20 bg-bg-input border border-border-modal rounded-lg px-2.5 py-1.5 text-xs text-text-primary focus:outline-none focus:border-accent disabled:opacity-50" />
|
<input
|
||||||
|
type="number"
|
||||||
|
value={speedLimitValue}
|
||||||
|
min={1}
|
||||||
|
step={128}
|
||||||
|
onChange={e => setSpeedLimitValue(e.target.value)}
|
||||||
|
disabled={transferLocked}
|
||||||
|
className="app-control w-24 px-2.5 py-1.5 text-end text-xs font-mono disabled:opacity-50"
|
||||||
|
/>
|
||||||
<span className="text-xs text-text-muted">KiB/s</span>
|
<span className="text-xs text-text-muted">KiB/s</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
<div className="col-start-2 text-[11px] text-text-muted">
|
||||||
|
{t($ => $.properties.savedPerDownload)}
|
||||||
|
</div>
|
||||||
|
{(liveSpeedLimitAvailable || liveSpeedLimitUnavailable) && (
|
||||||
|
<div className="col-start-2 rounded-lg border border-border-modal bg-bg-input/30 p-3 space-y-2">
|
||||||
|
{liveSpeedLimitAvailable ? (
|
||||||
|
<>
|
||||||
|
<label htmlFor="live-speed-limit" className="block text-xs font-semibold text-text-primary">
|
||||||
|
{t($ => $.properties.liveSpeedLimit)}
|
||||||
|
</label>
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<input
|
||||||
|
id="live-speed-limit"
|
||||||
|
type="text"
|
||||||
|
inputMode="decimal"
|
||||||
|
value={liveSpeedLimitValue}
|
||||||
|
onChange={event => setLiveSpeedLimitValue(event.currentTarget.value)}
|
||||||
|
placeholder={t($ => $.properties.liveSpeedLimitPlaceholder)}
|
||||||
|
disabled={isLiveSpeedLimitPending}
|
||||||
|
aria-describedby="live-speed-limit-hint"
|
||||||
|
className="app-control w-32 px-2.5 py-1.5 text-xs font-mono disabled:opacity-50"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void handleLiveSpeedLimit(liveSpeedLimitValue)}
|
||||||
|
disabled={isLiveSpeedLimitPending}
|
||||||
|
className="app-button app-button-primary px-3 text-xs disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{t($ => $.properties.liveSpeedLimitApply)}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void handleLiveSpeedLimit(null)}
|
||||||
|
disabled={isLiveSpeedLimitPending || !liveSpeedLimitValue}
|
||||||
|
className="app-button px-3 text-xs disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{t($ => $.properties.liveSpeedLimitClear)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p id="live-speed-limit-hint" className="text-[11px] text-text-muted">
|
||||||
|
{t($ => $.properties.liveSpeedLimitHint)}
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<p className="text-[11px] text-text-muted">
|
||||||
|
{t($ => $.properties.liveSpeedLimitUnavailable)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Site Login Section */}
|
{/* Site Login Section */}
|
||||||
<section>
|
<section>
|
||||||
<h3 className="text-sm font-semibold text-text-primary mb-4 pb-1 border-b border-border-modal/50">
|
<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>
|
</h3>
|
||||||
|
|
||||||
<div className="flex gap-1 p-1 bg-border-color rounded-lg mb-4 w-fit mx-auto md:mx-0">
|
<div className="flex gap-1 p-1 bg-border-color rounded-lg mb-4 w-fit mx-auto md:mx-0">
|
||||||
@@ -370,7 +565,7 @@ export const PropertiesModal = () => {
|
|||||||
disabled={transferLocked}
|
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'}`}
|
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>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -378,16 +573,16 @@ export const PropertiesModal = () => {
|
|||||||
<div className="grid grid-cols-[100px_1fr] gap-y-3.5 gap-x-4 items-center">
|
<div className="grid grid-cols-[100px_1fr] gap-y-3.5 gap-x-4 items-center">
|
||||||
{loginMode === 'matching' && (
|
{loginMode === 'matching' && (
|
||||||
<div className="col-start-2 text-xs text-text-secondary italic">
|
<div className="col-start-2 text-xs text-text-secondary italic">
|
||||||
Will use saved login if available.
|
{t($ => $.properties.useSavedLogin)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{loginMode === 'custom' && (
|
{loginMode === 'custom' && (
|
||||||
<>
|
<>
|
||||||
<label className="text-xs text-text-muted text-right">Username</label>
|
<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="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" />
|
<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>
|
<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="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" />
|
<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>
|
</div>
|
||||||
@@ -400,20 +595,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"
|
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} />}
|
{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>
|
</button>
|
||||||
|
|
||||||
{advancedExpanded && (
|
{advancedExpanded && (
|
||||||
<div className="mt-4 grid grid-cols-[100px_1fr] gap-y-3.5 gap-x-4 items-center pl-6">
|
<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">
|
<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" />
|
<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>
|
</label>
|
||||||
|
|
||||||
{checksumEnabled && (
|
{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">
|
<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="MD5">MD5</option>
|
||||||
<option value="SHA-1">SHA-1</option>
|
<option value="SHA-1">SHA-1</option>
|
||||||
@@ -421,21 +616,21 @@ export const PropertiesModal = () => {
|
|||||||
<option value="SHA-512">SHA-512</option>
|
<option value="SHA-512">SHA-512</option>
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
<label className="text-xs text-text-muted text-right">Digest</label>
|
<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="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" />
|
<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>
|
<label className="text-xs text-text-muted text-right">{t($ => $.properties.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" />
|
<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">
|
<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>
|
<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>
|
||||||
|
|
||||||
<div className="col-span-2">
|
<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>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
@@ -453,18 +648,33 @@ export const PropertiesModal = () => {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
onClick={() => setSelectedPropertiesDownloadId(null)}
|
onClick={() => setSelectedPropertiesDownloadId(null)}
|
||||||
className="app-button px-4 text-xs"
|
className="app-button px-4 text-xs"
|
||||||
>
|
>
|
||||||
Cancel
|
{t($ => $.properties.cancel)}
|
||||||
</button>
|
</button>
|
||||||
|
{pauseResumeAction && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void handlePauseResume()}
|
||||||
|
disabled={isPauseResumePending}
|
||||||
|
aria-label={pauseResumeLabel}
|
||||||
|
title={pauseResumeLabel}
|
||||||
|
className={`app-button px-4 text-xs ${isPauseResumePending ? 'opacity-50 cursor-not-allowed' : ''}`}
|
||||||
|
>
|
||||||
|
<PauseResumeIcon size={14} fill="currentColor" />
|
||||||
|
{pauseResumeLabel}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
onClick={handleSave}
|
onClick={handleSave}
|
||||||
disabled={transferLocked}
|
disabled={transferLocked}
|
||||||
className={`app-button app-button-primary px-4 text-xs ${transferLocked ? 'opacity-50 cursor-not-allowed' : ''}`}
|
className={`app-button app-button-primary px-4 text-xs ${transferLocked ? 'opacity-50 cursor-not-allowed' : ''}`}
|
||||||
>
|
>
|
||||||
<CheckCircle size={14} />
|
<CheckCircle size={14} />
|
||||||
Save
|
{t($ => $.properties.save)}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -10,22 +10,24 @@ import { isActiveDownloadStatus } from '../utils/downloads';
|
|||||||
import { WindowDragRegion } from './WindowDragRegion';
|
import { WindowDragRegion } from './WindowDragRegion';
|
||||||
import { useToast } from '../contexts/ToastContext';
|
import { useToast } from '../contexts/ToastContext';
|
||||||
import { usePlatformInfo } from '../utils/platform';
|
import { usePlatformInfo } from '../utils/platform';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { formatDateTime } from '../utils/dateTime';
|
||||||
|
|
||||||
const days = [
|
const days = [
|
||||||
{ value: 0, label: 'Su' },
|
{ value: 0, key: 'su' },
|
||||||
{ value: 1, label: 'Mo' },
|
{ value: 1, key: 'mo' },
|
||||||
{ value: 2, label: 'Tu' },
|
{ value: 2, key: 'tu' },
|
||||||
{ value: 3, label: 'We' },
|
{ value: 3, key: 'we' },
|
||||||
{ value: 4, label: 'Th' },
|
{ value: 4, key: 'th' },
|
||||||
{ value: 5, label: 'Fr' },
|
{ value: 5, key: 'fr' },
|
||||||
{ value: 6, label: 'Sa' },
|
{ value: 6, key: 'sa' },
|
||||||
];
|
] as const;
|
||||||
|
|
||||||
const postActions: { value: PostQueueAction; label: string; icon: typeof Moon }[] = [
|
const postActions: { value: PostQueueAction; icon: typeof Moon }[] = [
|
||||||
{ value: 'none', label: 'Do nothing', icon: CheckCircle2 },
|
{ value: 'none', icon: CheckCircle2 },
|
||||||
{ value: 'sleep', label: 'Sleep', icon: Moon },
|
{ value: 'sleep', icon: Moon },
|
||||||
{ value: 'restart', label: 'Restart', icon: RotateCcw },
|
{ value: 'restart', icon: RotateCcw },
|
||||||
{ value: 'shutdown', label: 'Shut down', icon: Power },
|
{ value: 'shutdown', icon: Power },
|
||||||
];
|
];
|
||||||
|
|
||||||
const minuteOfDay = (value: string) => {
|
const minuteOfDay = (value: string) => {
|
||||||
@@ -33,8 +35,8 @@ const minuteOfDay = (value: string) => {
|
|||||||
return hour * 60 + minute;
|
return hour * 60 + minute;
|
||||||
};
|
};
|
||||||
|
|
||||||
function nextScheduledRun(settings: SchedulerSettings): string {
|
function nextScheduledRun(settings: SchedulerSettings): Date | 'disabled' | 'none' {
|
||||||
if (!settings.enabled) return 'Scheduler is disabled';
|
if (!settings.enabled) return 'disabled';
|
||||||
|
|
||||||
const [hour, minute] = settings.startTime.split(':').map(Number);
|
const [hour, minute] = settings.startTime.split(':').map(Number);
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
@@ -45,22 +47,18 @@ function nextScheduledRun(settings: SchedulerSettings): string {
|
|||||||
candidate.setHours(hour, minute, 0, 0);
|
candidate.setHours(hour, minute, 0, 0);
|
||||||
const allowedDay = settings.everyday || settings.selectedDays.includes(candidate.getDay());
|
const allowedDay = settings.everyday || settings.selectedDays.includes(candidate.getDay());
|
||||||
if (allowedDay && candidate > now) {
|
if (allowedDay && candidate > now) {
|
||||||
return candidate.toLocaleString(undefined, {
|
return candidate;
|
||||||
weekday: 'short',
|
|
||||||
month: 'short',
|
|
||||||
day: 'numeric',
|
|
||||||
hour: 'numeric',
|
|
||||||
minute: '2-digit'
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return 'No scheduled day selected';
|
return 'none';
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function SchedulerView() {
|
export default function SchedulerView() {
|
||||||
|
const { t, i18n } = useTranslation();
|
||||||
const savedSettings = useSettingsStore(state => state.scheduler);
|
const savedSettings = useSettingsStore(state => state.scheduler);
|
||||||
const schedulerRunning = useSettingsStore(state => state.schedulerRunning);
|
const schedulerRunning = useSettingsStore(state => state.schedulerRunning);
|
||||||
|
const calendarPreference = useSettingsStore(state => state.calendarPreference);
|
||||||
const setScheduler = useSettingsStore(state => state.setScheduler);
|
const setScheduler = useSettingsStore(state => state.setScheduler);
|
||||||
const queues = useDownloadStore(state => state.queues);
|
const queues = useDownloadStore(state => state.queues);
|
||||||
const [draft, setDraft] = useState<SchedulerSettings>(savedSettings);
|
const [draft, setDraft] = useState<SchedulerSettings>(savedSettings);
|
||||||
@@ -75,7 +73,22 @@ export default function SchedulerView() {
|
|||||||
}, [savedSettings]);
|
}, [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 formatDateTime(scheduledRun, {
|
||||||
|
locale: i18n.language,
|
||||||
|
calendar: calendarPreference,
|
||||||
|
options: {
|
||||||
|
weekday: 'short',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
hour: 'numeric',
|
||||||
|
minute: '2-digit'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, [calendarPreference, draft, i18n.language, t]);
|
||||||
const hasUnsavedChanges = useMemo(
|
const hasUnsavedChanges = useMemo(
|
||||||
() => JSON.stringify(draft) !== JSON.stringify(savedSettings),
|
() => JSON.stringify(draft) !== JSON.stringify(savedSettings),
|
||||||
[draft, savedSettings]
|
[draft, savedSettings]
|
||||||
@@ -116,15 +129,15 @@ export default function SchedulerView() {
|
|||||||
|
|
||||||
const save = () => {
|
const save = () => {
|
||||||
if (draft.enabled && !draft.everyday && draft.selectedDays.length === 0) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
if (draft.enabled && effectiveSelectedQueueIds.length === 0) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
if (draft.enabled && draft.stopTimeEnabled && minuteOfDay(draft.stopTime) <= minuteOfDay(draft.startTime)) {
|
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 });
|
addToast({ message: t($ => $.scheduler.validationStopTime), variant: 'error', isActionable: true });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const normalized = {
|
const normalized = {
|
||||||
@@ -134,7 +147,7 @@ export default function SchedulerView() {
|
|||||||
};
|
};
|
||||||
setScheduler(normalized);
|
setScheduler(normalized);
|
||||||
setDraft(normalized);
|
setDraft(normalized);
|
||||||
addToast({ message: 'Scheduler settings saved', variant: 'success' });
|
addToast({ message: t($ => $.scheduler.saved), variant: 'success' });
|
||||||
};
|
};
|
||||||
|
|
||||||
const runNow = async () => {
|
const runNow = async () => {
|
||||||
@@ -155,9 +168,14 @@ export default function SchedulerView() {
|
|||||||
if (activeIds.length > 0) {
|
if (activeIds.length > 0) {
|
||||||
useSettingsStore.getState().setSchedulerRunning(true);
|
useSettingsStore.getState().setSchedulerRunning(true);
|
||||||
useSettingsStore.getState().setSchedulerActiveDownloadIds(activeIds);
|
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 {
|
} else {
|
||||||
addToast({ message: 'No downloads in the selected queues can be started', variant: 'info' });
|
addToast({ message: t($ => $.scheduler.noStartableDownloads), variant: 'info' });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -168,7 +186,14 @@ export default function SchedulerView() {
|
|||||||
const count = counts.reduce((total, queueCount) => total + queueCount, 0);
|
const count = counts.reduce((total, queueCount) => total + queueCount, 0);
|
||||||
useSettingsStore.getState().setSchedulerRunning(false);
|
useSettingsStore.getState().setSchedulerRunning(false);
|
||||||
useSettingsStore.getState().setSchedulerActiveDownloadIds([]);
|
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) => {
|
const refreshPermissionStatus = useCallback(async (showMessage = false) => {
|
||||||
@@ -178,12 +203,12 @@ export default function SchedulerView() {
|
|||||||
await invoke('check_automation_permission');
|
await invoke('check_automation_permission');
|
||||||
setAutomationPermissionGranted(true);
|
setAutomationPermissionGranted(true);
|
||||||
if (showMessage) {
|
if (showMessage) {
|
||||||
setPermissionMessage('Automation permission is available.');
|
setPermissionMessage(t($ => $.scheduler.permissionAvailable));
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
setAutomationPermissionGranted(false);
|
setAutomationPermissionGranted(false);
|
||||||
if (showMessage) {
|
if (showMessage) {
|
||||||
setPermissionMessage('Automation permission is missing. Enable Firelink under Automation for System Events in System Settings.');
|
setPermissionMessage(t($ => $.scheduler.permissionMissingDetails));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [isMac]);
|
}, [isMac]);
|
||||||
@@ -222,18 +247,18 @@ export default function SchedulerView() {
|
|||||||
|
|
||||||
const handlePermissionAction = async () => {
|
const handlePermissionAction = async () => {
|
||||||
if (automationPermissionGranted) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setPermissionMessage('Requesting Automation permission...');
|
setPermissionMessage(t($ => $.scheduler.requestingPermission));
|
||||||
try {
|
try {
|
||||||
await invoke('request_automation_permission');
|
await invoke('request_automation_permission');
|
||||||
setAutomationPermissionGranted(true);
|
setAutomationPermissionGranted(true);
|
||||||
setPermissionMessage('Automation permission is available.');
|
setPermissionMessage(t($ => $.scheduler.permissionAvailable));
|
||||||
} catch {
|
} catch {
|
||||||
setAutomationPermissionGranted(false);
|
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 +278,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'}`}
|
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>
|
</button>
|
||||||
Scheduler
|
{t($ => $.scheduler.title)}
|
||||||
</div>
|
</div>
|
||||||
<span className={`rounded-full px-2.5 py-1 text-[11px] font-semibold ${
|
<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 ? 'bg-green-500/15 text-green-500' : 'bg-item-hover text-text-muted'
|
||||||
}`}>
|
}`}>
|
||||||
{schedulerRunning ? 'Running' : nextRun}
|
{schedulerRunning ? t($ => $.scheduler.running) : nextRun}
|
||||||
</span>
|
</span>
|
||||||
{hasUnsavedChanges && (
|
{hasUnsavedChanges && (
|
||||||
<span className="rounded-full bg-orange-500/10 px-2.5 py-1 text-[11px] font-semibold text-orange-300">
|
<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>
|
</span>
|
||||||
)}
|
)}
|
||||||
<div className="ml-auto flex gap-2">
|
<div className="ml-auto flex gap-2">
|
||||||
<button onClick={runNow} className="app-button px-3 text-[11px]">
|
<button onClick={runNow} className="app-button px-3 text-[11px]">
|
||||||
<Play size={14} /> Run Now
|
<Play size={14} /> {t($ => $.scheduler.runNow)}
|
||||||
</button>
|
</button>
|
||||||
<button onClick={pauseNow} className="app-button px-3 text-[11px]">
|
<button onClick={pauseNow} className="app-button px-3 text-[11px]">
|
||||||
<Pause size={14} /> Pause
|
<Pause size={14} /> {t($ => $.scheduler.pause)}
|
||||||
</button>
|
</button>
|
||||||
<button onClick={save} className="app-button app-button-primary px-3 text-[11px]">
|
<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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -282,29 +307,29 @@ export default function SchedulerView() {
|
|||||||
<div className={`max-w-[760px] space-y-4 ${draft.enabled ? '' : 'opacity-50'}`}>
|
<div className={`max-w-[760px] space-y-4 ${draft.enabled ? '' : 'opacity-50'}`}>
|
||||||
<section className="app-card p-5">
|
<section className="app-card p-5">
|
||||||
<div className="mb-5 flex items-center gap-2 font-semibold text-text-primary">
|
<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>
|
||||||
<div className="flex flex-wrap items-end gap-8">
|
<div className="flex flex-wrap items-end gap-8">
|
||||||
<label className="space-y-2 text-[12px] text-text-secondary">
|
<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" />
|
<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>
|
</label>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="flex items-center gap-2 text-[12px] text-text-secondary">
|
<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" />
|
<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>
|
</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" />
|
<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>
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-4 text-[11px] text-text-muted">
|
<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>
|
</p>
|
||||||
|
|
||||||
<div className="my-5 border-t border-border-color" />
|
<div className="my-5 border-t border-border-color" />
|
||||||
<label className="flex items-center gap-2 text-[13px] font-medium text-text-primary">
|
<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" />
|
<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>
|
</label>
|
||||||
{!draft.everyday && (
|
{!draft.everyday && (
|
||||||
<div className="mt-4 flex gap-2">
|
<div className="mt-4 flex gap-2">
|
||||||
@@ -317,10 +342,10 @@ export default function SchedulerView() {
|
|||||||
disabled={!draft.enabled}
|
disabled={!draft.enabled}
|
||||||
onClick={() => toggleDay(day.value)}
|
onClick={() => toggleDay(day.value)}
|
||||||
className={`h-8 w-8 rounded-full text-[12px] font-semibold ${
|
className={`h-8 w-8 rounded-full text-[12px] font-semibold ${
|
||||||
selected ? 'bg-accent text-white' : 'bg-bg-input text-text-primary hover:bg-item-hover'
|
selected ? 'bg-accent text-accent-foreground' : 'bg-bg-input text-text-primary hover:bg-item-hover'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{day.label}
|
{t($ => $.scheduler.days[day.key])}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -330,7 +355,7 @@ export default function SchedulerView() {
|
|||||||
|
|
||||||
<section className="app-card p-5">
|
<section className="app-card p-5">
|
||||||
<div className="mb-4 flex items-center gap-2 font-semibold text-text-primary">
|
<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>
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{queues.map(queue => {
|
{queues.map(queue => {
|
||||||
@@ -346,7 +371,7 @@ export default function SchedulerView() {
|
|||||||
/>
|
/>
|
||||||
{queue.name}
|
{queue.name}
|
||||||
{queue.isMain && (
|
{queue.isMain && (
|
||||||
<span className="text-[11px] text-text-muted">Default queue</span>
|
<span className="text-[11px] text-text-muted">{t($ => $.scheduler.defaultQueue)}</span>
|
||||||
)}
|
)}
|
||||||
</label>
|
</label>
|
||||||
);
|
);
|
||||||
@@ -356,9 +381,9 @@ export default function SchedulerView() {
|
|||||||
|
|
||||||
<section className="app-card p-5">
|
<section className="app-card p-5">
|
||||||
<div className="mb-2 flex items-center gap-2 font-semibold text-text-primary">
|
<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>
|
</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">
|
<div className="grid grid-cols-2 gap-2">
|
||||||
{postActions.map(action => {
|
{postActions.map(action => {
|
||||||
const Icon = action.icon;
|
const Icon = action.icon;
|
||||||
@@ -368,13 +393,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" />
|
<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} />
|
<Icon size={15} />
|
||||||
{action.label}
|
{t($ => $.scheduler.postActions[action.value])}
|
||||||
</label>
|
</label>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
{draft.postQueueAction !== 'none' && (
|
{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>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
@@ -382,27 +407,27 @@ export default function SchedulerView() {
|
|||||||
{isMac ? (
|
{isMac ? (
|
||||||
<section className="app-card mt-4 max-w-[760px] p-5">
|
<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">
|
<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>
|
</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]">
|
<div className="mb-4 flex items-center gap-2 text-[12px]">
|
||||||
{automationPermissionGranted ? (
|
{automationPermissionGranted ? (
|
||||||
<>
|
<>
|
||||||
<CheckCircle2 size={16} className="text-green-500" />
|
<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" />
|
<AlertCircle size={16} className="text-orange-400" />
|
||||||
<span className="font-medium 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>
|
</span>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<button onClick={handlePermissionAction} className="app-button app-button-primary px-3 text-[11px]">
|
<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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{permissionMessage && <p className="mt-3 text-[11px] text-text-muted">{permissionMessage}</p>}
|
{permissionMessage && <p className="mt-3 text-[11px] text-text-muted">{permissionMessage}</p>}
|
||||||
@@ -410,11 +435,12 @@ export default function SchedulerView() {
|
|||||||
) : (
|
) : (
|
||||||
<section className="app-card mt-4 max-w-[760px] p-5">
|
<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">
|
<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>
|
</div>
|
||||||
<p className="text-[12px] text-text-muted">
|
<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'}.
|
{platform.os === 'windows'
|
||||||
Firelink reports any rejected action when it runs; no permanent permission is claimed in advance.
|
? t($ => $.scheduler.windowsActionsDescription)
|
||||||
|
: t($ => $.scheduler.linuxActionsDescription)}
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|||||||
+491
-229
File diff suppressed because it is too large
Load Diff
+213
-82
@@ -11,6 +11,7 @@ import { ActiveView, useSettingsStore } from '../store/useSettingsStore';
|
|||||||
import { WindowDragRegion } from './WindowDragRegion';
|
import { WindowDragRegion } from './WindowDragRegion';
|
||||||
import { useToast } from '../contexts/ToastContext';
|
import { useToast } from '../contexts/ToastContext';
|
||||||
import { isTransferActiveStatus } from '../utils/downloads';
|
import { isTransferActiveStatus } from '../utils/downloads';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
export type SidebarFilter = 'all' | 'active' | 'completed' | 'unfinished' | DownloadCategory | 'settings' | string;
|
export type SidebarFilter = 'all' | 'active' | 'completed' | 'unfinished' | DownloadCategory | 'settings' | string;
|
||||||
|
|
||||||
@@ -21,9 +22,10 @@ interface SidebarProps {
|
|||||||
|
|
||||||
export const Sidebar: React.FC<SidebarProps> = (props) => {
|
export const Sidebar: React.FC<SidebarProps> = (props) => {
|
||||||
const { selectedFilter, onSelectFilter } = props;
|
const { selectedFilter, onSelectFilter } = props;
|
||||||
const { downloads, queues, addQueue, renameQueue, removeQueue, startQueue, pauseQueue } = useDownloadStore();
|
const { downloads, queues, addQueue, renameQueue, removeQueue, startQueue, pauseQueue, setQueueConcurrency } = useDownloadStore();
|
||||||
const { activeView, setActiveView, toggleSidebar } = useSettingsStore();
|
const { activeView, setActiveView, toggleSidebar } = useSettingsStore();
|
||||||
const { addToast } = useToast();
|
const { addToast } = useToast();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const [isAddingQueue, setIsAddingQueue] = useState(false);
|
const [isAddingQueue, setIsAddingQueue] = useState(false);
|
||||||
const [newQueueName, setNewQueueName] = useState('');
|
const [newQueueName, setNewQueueName] = useState('');
|
||||||
@@ -39,7 +41,13 @@ export const Sidebar: React.FC<SidebarProps> = (props) => {
|
|||||||
const addInputRef = useRef<HTMLInputElement>(null);
|
const addInputRef = useRef<HTMLInputElement>(null);
|
||||||
const renameInputRef = useRef<HTMLInputElement>(null);
|
const renameInputRef = useRef<HTMLInputElement>(null);
|
||||||
const addQueueSubmitRef = useRef(false);
|
const addQueueSubmitRef = useRef(false);
|
||||||
|
const addQueueCancelRef = useRef(false);
|
||||||
const renameQueueSubmitRef = 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(() => {
|
useEffect(() => {
|
||||||
const handleCloseMenu = () => setContextMenu(null);
|
const handleCloseMenu = () => setContextMenu(null);
|
||||||
@@ -72,6 +80,13 @@ export const Sidebar: React.FC<SidebarProps> = (props) => {
|
|||||||
}
|
}
|
||||||
}, [foldersCollapsed]);
|
}, [foldersCollapsed]);
|
||||||
|
|
||||||
|
const handleFoldersToggle = () => {
|
||||||
|
if (foldersListRef.current?.contains(document.activeElement)) {
|
||||||
|
foldersToggleRef.current?.focus();
|
||||||
|
}
|
||||||
|
setFoldersCollapsed(collapsed => !collapsed);
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
if (document.querySelector('.app-modal-backdrop') || document.querySelector('.app-modal')) return;
|
if (document.querySelector('.app-modal-backdrop') || document.querySelector('.app-modal')) return;
|
||||||
@@ -80,15 +95,19 @@ export const Sidebar: React.FC<SidebarProps> = (props) => {
|
|||||||
|
|
||||||
if (!isInput && (e.key === 'Delete' || e.key === 'Backspace')) {
|
if (!isInput && (e.key === 'Delete' || e.key === 'Backspace')) {
|
||||||
if (activeEl && activeEl.closest('.sidebar-inner')) {
|
if (activeEl && activeEl.closest('.sidebar-inner')) {
|
||||||
if (activeView === 'downloads' && selectedFilter.startsWith('queue:')) {
|
const focusedQueueId = activeEl
|
||||||
const queueId = selectedFilter.replace('queue:', '');
|
.closest<HTMLElement>('[data-sidebar-queue-id]')
|
||||||
|
?.dataset.sidebarQueueId;
|
||||||
|
if (activeView === 'downloads' && focusedQueueId) {
|
||||||
|
const queueId = focusedQueueId;
|
||||||
const q = queues.find(q => q.id === queueId);
|
const q = queues.find(q => q.id === queueId);
|
||||||
if (q && !q.isMain) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
void removeQueue(queueId).catch(error => {
|
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 });
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -97,7 +116,7 @@ export const Sidebar: React.FC<SidebarProps> = (props) => {
|
|||||||
};
|
};
|
||||||
window.addEventListener('keydown', handleKeyDown);
|
window.addEventListener('keydown', handleKeyDown);
|
||||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||||
}, [addToast, activeView, queues, removeQueue, selectedFilter]);
|
}, [addToast, activeView, queues, removeQueue]);
|
||||||
|
|
||||||
const getCount = (filter: SidebarFilter) => {
|
const getCount = (filter: SidebarFilter) => {
|
||||||
if (filter.startsWith('queue:')) {
|
if (filter.startsWith('queue:')) {
|
||||||
@@ -120,13 +139,13 @@ export const Sidebar: React.FC<SidebarProps> = (props) => {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
data-active={isSelected}
|
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)}
|
onClick={() => onSelectFilter(filter)}
|
||||||
>
|
>
|
||||||
<Icon className="w-[18px] h-[18px] mr-3 shrink-0" strokeWidth={isSelected ? 2.5 : 2} />
|
<Icon className="w-[18px] h-[18px] me-3 shrink-0" strokeWidth={isSelected ? 2.5 : 2} />
|
||||||
<span className="truncate">{label}</span>
|
<span className="sidebar-nav-label truncate">{label}</span>
|
||||||
{getCount(filter) > 0 && (
|
{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)}
|
{getCount(filter)}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -140,35 +159,78 @@ export const Sidebar: React.FC<SidebarProps> = (props) => {
|
|||||||
setContextMenu({ x: e.clientX, y: e.clientY, id });
|
setContextMenu({ x: e.clientX, y: e.clientY, id });
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleAddQueueSubmit = () => {
|
const handleAddQueueSubmit = (trigger: 'submit' | 'blur' = 'submit') => {
|
||||||
|
if (addQueueCancelRef.current) {
|
||||||
|
addQueueCancelRef.current = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (addQueueSubmitRef.current) return;
|
if (addQueueSubmitRef.current) return;
|
||||||
const normalizedName = newQueueName.trim();
|
const normalizedName = newQueueName.trim();
|
||||||
if (!normalizedName) {
|
if (!normalizedName) {
|
||||||
addToast({ message: 'Queue name cannot be empty', variant: 'error', isActionable: true });
|
if (trigger === 'blur') {
|
||||||
|
setNewQueueName('');
|
||||||
|
setIsAddingQueue(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
addToast({ message: t($ => $.sidebar.queueNameEmpty), variant: 'error', isActionable: true });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!addQueue(normalizedName)) {
|
if (!addQueue(normalizedName)) {
|
||||||
addToast({ message: 'A queue with this name already exists', variant: 'error', isActionable: true });
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
rejectedAddQueueNameRef.current = null;
|
||||||
addQueueSubmitRef.current = true;
|
addQueueSubmitRef.current = true;
|
||||||
setNewQueueName('');
|
setNewQueueName('');
|
||||||
setIsAddingQueue(false);
|
setIsAddingQueue(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleRenameQueueSubmit = () => {
|
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;
|
if (renameQueueSubmitRef.current) return;
|
||||||
const normalizedName = editingQueueName.trim();
|
const normalizedName = editingQueueNameRef.current.trim();
|
||||||
if (!renamingQueueId) return;
|
|
||||||
if (!normalizedName) {
|
if (!normalizedName) {
|
||||||
addToast({ message: 'Queue name cannot be empty', variant: 'error', isActionable: true });
|
if (trigger === 'blur') {
|
||||||
|
renamingQueueIdRef.current = null;
|
||||||
|
editingQueueNameRef.current = '';
|
||||||
|
setEditingQueueName('');
|
||||||
|
setRenamingQueueId(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
addToast({ message: t($ => $.sidebar.queueNameEmpty), variant: 'error', isActionable: true });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!renameQueue(renamingQueueId, normalizedName)) {
|
if (!renameQueue(queueId, normalizedName)) {
|
||||||
addToast({ message: 'A queue with this name already exists', variant: 'error', isActionable: true });
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
rejectedRenameRef.current = null;
|
||||||
renameQueueSubmitRef.current = true;
|
renameQueueSubmitRef.current = true;
|
||||||
|
renamingQueueIdRef.current = null;
|
||||||
setRenamingQueueId(null);
|
setRenamingQueueId(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -179,19 +241,30 @@ export const Sidebar: React.FC<SidebarProps> = (props) => {
|
|||||||
|
|
||||||
if (isRenaming) {
|
if (isRenaming) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center px-2.5 py-1 rounded-lg mb-0.5 bg-item-hover">
|
<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 mr-2 text-text-secondary" strokeWidth={2} />
|
<List className="w-4 h-4 me-2 text-text-secondary" strokeWidth={2} />
|
||||||
<input
|
<input
|
||||||
ref={renameInputRef}
|
ref={renameInputRef}
|
||||||
type="text"
|
type="text"
|
||||||
className="flex-1 bg-transparent border border-accent rounded px-1 text-[13px] text-text-primary outline-none min-w-0"
|
className="flex-1 bg-transparent border border-accent rounded px-1 text-[13px] text-text-primary outline-none min-w-0"
|
||||||
value={editingQueueName}
|
value={editingQueueName}
|
||||||
onChange={e => setEditingQueueName(e.target.value)}
|
onChange={e => {
|
||||||
onKeyDown={e => {
|
editingQueueNameRef.current = e.target.value;
|
||||||
if (e.key === 'Enter') handleRenameQueueSubmit();
|
rejectedRenameRef.current = null;
|
||||||
if (e.key === 'Escape') setRenamingQueueId(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>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -201,14 +274,15 @@ export const Sidebar: React.FC<SidebarProps> = (props) => {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
data-active={isSelected}
|
data-active={isSelected}
|
||||||
|
data-sidebar-queue-id={queue.id}
|
||||||
onContextMenu={e => handleQueueContextMenu(e, queue.id)}
|
onContextMenu={e => handleQueueContextMenu(e, queue.id)}
|
||||||
onClick={() => onSelectFilter(filterId)}
|
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} />
|
<List className="w-[18px] h-[18px] me-3 shrink-0" strokeWidth={isSelected ? 2.5 : 2} />
|
||||||
<span className="truncate">{queue.name}</span>
|
<span className="sidebar-nav-label truncate">{queue.name}</span>
|
||||||
{getCount(filterId) > 0 && (
|
{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)}
|
{getCount(filterId)}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -223,10 +297,10 @@ export const Sidebar: React.FC<SidebarProps> = (props) => {
|
|||||||
type="button"
|
type="button"
|
||||||
data-active={isSelected}
|
data-active={isSelected}
|
||||||
onClick={() => setActiveView(view)}
|
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} />
|
<Icon className="w-[18px] h-[18px] me-3 shrink-0" strokeWidth={isSelected ? 2.5 : 2} />
|
||||||
<span>{label}</span>
|
<span className="sidebar-nav-label">{label}</span>
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -240,18 +314,18 @@ export const Sidebar: React.FC<SidebarProps> = (props) => {
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={toggleSidebar}
|
onClick={toggleSidebar}
|
||||||
className="sidebar-toggle-button"
|
className="sidebar-toggle-button"
|
||||||
title="Hide Sidebar"
|
title={t($ => $.actions.hideSidebar)}
|
||||||
>
|
>
|
||||||
<PanelLeft size={14} strokeWidth={1.9} />
|
<PanelLeft size={14} strokeWidth={1.9} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="sidebar-scroll">
|
<div className="sidebar-scroll">
|
||||||
<section className="sidebar-section">
|
<section className="sidebar-section">
|
||||||
<div className="sidebar-section-label">Library</div>
|
<div className="sidebar-section-label">{t($ => $.navigation.library)}</div>
|
||||||
<NavItem icon={Inbox} label="All" filter="all" />
|
<NavItem icon={Inbox} label={t($ => $.navigation.filters.all)} filter="all" />
|
||||||
<NavItem icon={Zap} label="Active" filter="active" />
|
<NavItem icon={Zap} label={t($ => $.navigation.filters.active)} filter="active" />
|
||||||
<NavItem icon={CheckCircle2} label="Completed" filter="completed" />
|
<NavItem icon={CheckCircle2} label={t($ => $.navigation.filters.completed)} filter="completed" />
|
||||||
<NavItem icon={CircleDashed} label="Unfinished" filter="unfinished" />
|
<NavItem icon={CircleDashed} label={t($ => $.navigation.filters.unfinished)} filter="unfinished" />
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="sidebar-section">
|
<section className="sidebar-section">
|
||||||
@@ -261,9 +335,9 @@ export const Sidebar: React.FC<SidebarProps> = (props) => {
|
|||||||
className="sidebar-section-label sidebar-section-label-toggle"
|
className="sidebar-section-label sidebar-section-label-toggle"
|
||||||
aria-expanded={!foldersCollapsed}
|
aria-expanded={!foldersCollapsed}
|
||||||
aria-controls="sidebar-folders-list"
|
aria-controls="sidebar-folders-list"
|
||||||
onClick={() => setFoldersCollapsed(collapsed => !collapsed)}
|
onClick={handleFoldersToggle}
|
||||||
>
|
>
|
||||||
<span>Folders</span>
|
<span>{t($ => $.navigation.folders)}</span>
|
||||||
<ChevronDown
|
<ChevronDown
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
size={13}
|
size={13}
|
||||||
@@ -278,56 +352,70 @@ export const Sidebar: React.FC<SidebarProps> = (props) => {
|
|||||||
inert={foldersCollapsed}
|
inert={foldersCollapsed}
|
||||||
>
|
>
|
||||||
<div className="sidebar-collapse-content">
|
<div className="sidebar-collapse-content">
|
||||||
<NavItem icon={Music} label="Musics" filter="Musics" />
|
<NavItem icon={Music} label={t($ => $.navigation.categories.musics)} filter="Musics" />
|
||||||
<NavItem icon={Film} label="Movies" filter="Movies" />
|
<NavItem icon={Film} label={t($ => $.navigation.categories.movies)} filter="Movies" />
|
||||||
<NavItem icon={Archive} label="Compressed" filter="Compressed" />
|
<NavItem icon={Archive} label={t($ => $.navigation.categories.compressed)} filter="Compressed" />
|
||||||
<NavItem icon={FileText} label="Documents" filter="Documents" />
|
<NavItem icon={FileText} label={t($ => $.navigation.categories.documents)} filter="Documents" />
|
||||||
<NavItem icon={ImageIcon} label="Pictures" filter="Pictures" />
|
<NavItem icon={ImageIcon} label={t($ => $.navigation.categories.pictures)} filter="Pictures" />
|
||||||
<NavItem icon={Box} label="Applications" filter="Applications" />
|
<NavItem icon={Box} label={t($ => $.navigation.categories.applications)} filter="Applications" />
|
||||||
<NavItem icon={FileQuestion} label="Other" filter="Other" />
|
<NavItem icon={FileQuestion} label={t($ => $.navigation.categories.other)} filter="Other" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="sidebar-section">
|
<section className="sidebar-section">
|
||||||
<div className="sidebar-section-label">Queues</div>
|
<div className="sidebar-section-label">{t($ => $.navigation.queues)}</div>
|
||||||
{queues.map(queue => (
|
{queues.map(queue => (
|
||||||
<QueueItem key={queue.id} queue={queue} />
|
<QueueItem key={queue.id} queue={queue} />
|
||||||
))}
|
))}
|
||||||
{isAddingQueue ? (
|
{isAddingQueue ? (
|
||||||
<div className="flex items-center px-3.5 py-1.5 rounded-lg bg-item-hover mb-1">
|
<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 mr-2 text-text-secondary shrink-0" strokeWidth={2} />
|
<Plus className="w-4 h-4 me-2 text-text-secondary shrink-0" strokeWidth={2} />
|
||||||
<input
|
<input
|
||||||
ref={addInputRef}
|
ref={addInputRef}
|
||||||
type="text"
|
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"
|
className="flex-1 bg-transparent border border-accent rounded px-1 text-[13px] text-text-primary outline-none min-w-0"
|
||||||
value={newQueueName}
|
value={newQueueName}
|
||||||
onChange={e => setNewQueueName(e.target.value)}
|
onChange={e => {
|
||||||
|
rejectedAddQueueNameRef.current = null;
|
||||||
|
setNewQueueName(e.target.value);
|
||||||
|
}}
|
||||||
onKeyDown={e => {
|
onKeyDown={e => {
|
||||||
if (e.key === 'Enter') handleAddQueueSubmit();
|
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>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => { addQueueSubmitRef.current = false; setIsAddingQueue(true); setNewQueueName(''); }}
|
onClick={() => {
|
||||||
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"
|
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} />
|
<Plus className="w-4 h-4 me-2 shrink-0" strokeWidth={2} />
|
||||||
<span className="truncate">Add new queue</span>
|
<span className="truncate">{t($ => $.actions.addNewQueue)}</span>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="sidebar-section">
|
<section className="sidebar-section">
|
||||||
<div className="sidebar-section-label">Tools</div>
|
<div className="sidebar-section-label">{t($ => $.navigation.tools)}</div>
|
||||||
<ToolItem icon={CalendarClock} label="Scheduler" view="scheduler" />
|
<ToolItem icon={CalendarClock} label={t($ => $.navigation.scheduler)} view="scheduler" />
|
||||||
<ToolItem icon={Gauge} label="Speed Limiter" view="speedLimiter" />
|
<ToolItem icon={Gauge} label={t($ => $.navigation.speedLimiter)} view="speedLimiter" />
|
||||||
<ToolItem icon={Bug} label="Logs" view="logs" />
|
<ToolItem icon={Bug} label={t($ => $.navigation.logs)} view="logs" />
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -336,10 +424,10 @@ export const Sidebar: React.FC<SidebarProps> = (props) => {
|
|||||||
type="button"
|
type="button"
|
||||||
data-active={activeView === 'settings'}
|
data-active={activeView === 'settings'}
|
||||||
onClick={() => setActiveView('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} />
|
<Settings className={`w-[18px] h-[18px] me-3 shrink-0 ${activeView === 'settings' ? 'text-accent-foreground' : 'text-text-muted'}`} strokeWidth={activeView === 'settings' ? 2.5 : 2} />
|
||||||
<span>Settings</span>
|
<span className="sidebar-nav-label">{t($ => $.navigation.settings)}</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -354,58 +442,101 @@ export const Sidebar: React.FC<SidebarProps> = (props) => {
|
|||||||
onClick={e => e.stopPropagation()}
|
onClick={e => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
<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={() => {
|
onClick={() => {
|
||||||
const queueId = contextMenu.id;
|
const queueId = contextMenu.id;
|
||||||
setContextMenu(null);
|
setContextMenu(null);
|
||||||
void startQueue(queueId).catch(error => {
|
void startQueue(queueId).catch(error => {
|
||||||
addToast({
|
addToast({
|
||||||
message: `Could not start queue: ${String(error)}`,
|
message: t($ => $.sidebar.startQueueFailed, { detail: String(error) }),
|
||||||
variant: 'error',
|
variant: 'error',
|
||||||
isActionable: true
|
isActionable: true
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Play size={14} className="mr-2 text-text-secondary" />
|
<Play size={14} className="me-2 text-text-secondary" />
|
||||||
Start Queue
|
{t($ => $.actions.startQueue)}
|
||||||
</button>
|
</button>
|
||||||
<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={() => {
|
onClick={() => {
|
||||||
const queueId = contextMenu.id;
|
const queueId = contextMenu.id;
|
||||||
setContextMenu(null);
|
setContextMenu(null);
|
||||||
void pauseQueue(queueId).catch(error => {
|
void pauseQueue(queueId).catch(error => {
|
||||||
addToast({
|
addToast({
|
||||||
message: `Could not pause queue: ${String(error)}`,
|
message: t($ => $.sidebar.pauseQueueFailed, { detail: String(error) }),
|
||||||
variant: 'error',
|
variant: 'error',
|
||||||
isActionable: true
|
isActionable: true
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Pause size={14} className="mr-2 text-text-secondary" />
|
<Pause size={14} className="me-2 text-text-secondary" />
|
||||||
Pause Queue
|
{t($ => $.actions.pauseQueue)}
|
||||||
</button>
|
</button>
|
||||||
|
{(() => {
|
||||||
|
const queue = queues.find(candidate => candidate.id === contextMenu.id);
|
||||||
|
if (!queue) return null;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="group"
|
||||||
|
aria-labelledby="queue-concurrency-label"
|
||||||
|
className="px-3 py-2 text-[12px] text-text-secondary"
|
||||||
|
onClick={event => event.stopPropagation()}
|
||||||
|
>
|
||||||
|
<label id="queue-concurrency-label" htmlFor="queue-concurrency-select" className="block mb-1">
|
||||||
|
{t($ => $.sidebar.queueConcurrency)}
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="queue-concurrency-select"
|
||||||
|
aria-label={t($ => $.sidebar.queueConcurrency)}
|
||||||
|
value={queue.maxConcurrent?.toString() ?? ''}
|
||||||
|
className="w-full rounded border border-border-color bg-bg-context-menu px-1.5 py-1 text-[12px] text-text-primary outline-none focus:border-accent"
|
||||||
|
onChange={event => {
|
||||||
|
const value = event.currentTarget.value === ''
|
||||||
|
? null
|
||||||
|
: Number(event.currentTarget.value);
|
||||||
|
void setQueueConcurrency(queue.id, value).catch(error => {
|
||||||
|
addToast({
|
||||||
|
message: t($ => $.sidebar.queueConcurrencyFailed, { detail: String(error) }),
|
||||||
|
variant: 'error',
|
||||||
|
isActionable: true
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="">{t($ => $.sidebar.queueConcurrencyGlobal)}</option>
|
||||||
|
{Array.from({ length: 12 }, (_, index) => index + 1).map(value => (
|
||||||
|
<option key={value} value={value}>{value}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
<div className="h-px bg-border-color my-1 mx-2" />
|
<div className="h-px bg-border-color my-1 mx-2" />
|
||||||
<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={() => {
|
onClick={() => {
|
||||||
const q = queues.find(q => q.id === contextMenu.id);
|
const q = queues.find(q => q.id === contextMenu.id);
|
||||||
if (q) {
|
if (q) {
|
||||||
renameQueueSubmitRef.current = false;
|
renameQueueSubmitRef.current = false;
|
||||||
|
renameQueueCancelRef.current = null;
|
||||||
|
rejectedRenameRef.current = null;
|
||||||
|
renamingQueueIdRef.current = q.id;
|
||||||
|
editingQueueNameRef.current = q.name;
|
||||||
setEditingQueueName(q.name);
|
setEditingQueueName(q.name);
|
||||||
setRenamingQueueId(q.id);
|
setRenamingQueueId(q.id);
|
||||||
}
|
}
|
||||||
setContextMenu(null);
|
setContextMenu(null);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Edit2 size={14} className="mr-2 text-text-secondary" />
|
<Edit2 size={14} className="me-2 text-text-secondary" />
|
||||||
Rename Queue
|
{t($ => $.actions.renameQueue)}
|
||||||
</button>
|
</button>
|
||||||
{!queues.find(q => q.id === contextMenu.id)?.isMain && (
|
{!queues.find(q => q.id === contextMenu.id)?.isMain && (
|
||||||
<button
|
<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={() => {
|
onClick={() => {
|
||||||
const queueId = contextMenu.id;
|
const queueId = contextMenu.id;
|
||||||
const queue = queues.find(q => q.id === queueId);
|
const queue = queues.find(q => q.id === queueId);
|
||||||
@@ -413,21 +544,21 @@ export const Sidebar: React.FC<SidebarProps> = (props) => {
|
|||||||
setContextMenu(null);
|
setContextMenu(null);
|
||||||
return;
|
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;
|
return;
|
||||||
}
|
}
|
||||||
setContextMenu(null);
|
setContextMenu(null);
|
||||||
void removeQueue(queueId).catch(error => {
|
void removeQueue(queueId).catch(error => {
|
||||||
addToast({
|
addToast({
|
||||||
message: `Could not delete queue: ${String(error)}`,
|
message: t($ => $.sidebar.deleteQueueFailed, { detail: String(error) }),
|
||||||
variant: 'error',
|
variant: 'error',
|
||||||
isActionable: true
|
isActionable: true
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Trash2 size={14} className="mr-2" />
|
<Trash2 size={14} className="me-2" />
|
||||||
Delete Queue
|
{t($ => $.actions.deleteQueue)}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { Gauge, Plus, Save, X, Zap } from 'lucide-react';
|
|||||||
import { useSettingsStore } from '../store/useSettingsStore';
|
import { useSettingsStore } from '../store/useSettingsStore';
|
||||||
import { WindowDragRegion } from './WindowDragRegion';
|
import { WindowDragRegion } from './WindowDragRegion';
|
||||||
import { useToast } from '../contexts/ToastContext';
|
import { useToast } from '../contexts/ToastContext';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
type SpeedUnit = 'KB/s' | 'MB/s';
|
type SpeedUnit = 'KB/s' | 'MB/s';
|
||||||
|
|
||||||
@@ -89,6 +90,7 @@ export function formatPresetValue(value: number): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function SpeedLimiterView() {
|
export default function SpeedLimiterView() {
|
||||||
|
const { t } = useTranslation();
|
||||||
const globalSpeedLimit = useSettingsStore(state => state.globalSpeedLimit);
|
const globalSpeedLimit = useSettingsStore(state => state.globalSpeedLimit);
|
||||||
const lastCustomSpeedLimitKiB = useSettingsStore(state => state.lastCustomSpeedLimitKiB);
|
const lastCustomSpeedLimitKiB = useSettingsStore(state => state.lastCustomSpeedLimitKiB);
|
||||||
const lastCustomSpeedLimitUnit = useSettingsStore(state => state.lastCustomSpeedLimitUnit);
|
const lastCustomSpeedLimitUnit = useSettingsStore(state => state.lastCustomSpeedLimitUnit);
|
||||||
@@ -132,12 +134,14 @@ export default function SpeedLimiterView() {
|
|||||||
setLastCustomSpeedLimitKiB(valueKiB);
|
setLastCustomSpeedLimitKiB(valueKiB);
|
||||||
setLastCustomSpeedLimitUnit(unit);
|
setLastCustomSpeedLimitUnit(unit);
|
||||||
addToast({
|
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'
|
variant: 'success'
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
addToast({
|
addToast({
|
||||||
message: `Could not save global speed limit: ${String(error)}`,
|
message: t($ => $.speedLimiter.saveFailed, { detail: String(error) }),
|
||||||
variant: 'error',
|
variant: 'error',
|
||||||
isActionable: true
|
isActionable: true
|
||||||
});
|
});
|
||||||
@@ -163,8 +167,8 @@ export default function SpeedLimiterView() {
|
|||||||
setValue(String(storedPresetDisplayValue));
|
setValue(String(storedPresetDisplayValue));
|
||||||
addToast({
|
addToast({
|
||||||
message: alreadyExists
|
message: alreadyExists
|
||||||
? `${formatPresetValue(storedPresetDisplayValue)} ${unit} is already in quick presets`
|
? t($ => $.speedLimiter.presetAlreadyExists, { value: formatPresetValue(storedPresetDisplayValue), unit })
|
||||||
: `Added ${formatPresetValue(storedPresetDisplayValue)} ${unit} quick preset`,
|
: t($ => $.speedLimiter.presetAdded, { value: formatPresetValue(storedPresetDisplayValue), unit }),
|
||||||
variant: alreadyExists ? 'info' : 'success'
|
variant: alreadyExists ? 'info' : 'success'
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -174,7 +178,7 @@ export default function SpeedLimiterView() {
|
|||||||
const nextPresets = presetValues.filter(value => value !== presetValue);
|
const nextPresets = presetValues.filter(value => value !== presetValue);
|
||||||
setSpeedLimitPresetValues(nextPresets);
|
setSpeedLimitPresetValues(nextPresets);
|
||||||
addToast({
|
addToast({
|
||||||
message: `Removed ${formatPresetValue(displayValue)} ${unit} quick preset`,
|
message: t($ => $.speedLimiter.presetRemoved, { value: formatPresetValue(displayValue), unit }),
|
||||||
variant: 'info'
|
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'}`}
|
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>
|
</button>
|
||||||
Speed Limiter
|
{t($ => $.speedLimiter.title)}
|
||||||
</div>
|
</div>
|
||||||
<span className={`rounded-full px-2.5 py-1 text-[11px] font-semibold ${
|
<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 ? 'bg-accent/15 text-accent' : 'bg-item-hover text-text-muted'
|
||||||
}`}>
|
}`}>
|
||||||
{enabled ? `${currentDisplayValue} ${unit}` : 'Unlimited'}
|
{enabled ? `${currentDisplayValue} ${unit}` : t($ => $.speedLimiter.unlimited)}
|
||||||
</span>
|
</span>
|
||||||
<button onClick={() => void save()} disabled={isSaving} className="app-button app-button-primary ml-auto px-3 text-[11px] disabled:opacity-50">
|
<button onClick={() => void save()} disabled={isSaving} className="app-button app-button-primary ms-auto px-3 text-[11px] disabled:opacity-50">
|
||||||
<Save size={14} /> Save Limit
|
<Save size={14} /> {t($ => $.speedLimiter.saveLimit)}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto p-6">
|
<div className="flex-1 overflow-y-auto p-6">
|
||||||
<section className={`app-card max-w-[760px] p-5 ${enabled ? '' : 'opacity-55'}`}>
|
<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">
|
<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>
|
</div>
|
||||||
<p className="max-w-2xl text-[12px] leading-relaxed text-text-muted">
|
<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>
|
</p>
|
||||||
|
|
||||||
<div className="mt-6 flex items-center gap-3">
|
<div className="mt-6 flex items-center gap-3">
|
||||||
@@ -246,7 +250,7 @@ export default function SpeedLimiterView() {
|
|||||||
disabled={!enabled || isSaving}
|
disabled={!enabled || isSaving}
|
||||||
onClick={() => changeUnit(option)}
|
onClick={() => changeUnit(option)}
|
||||||
className={`rounded px-3 py-1.5 text-[12px] font-medium ${
|
className={`rounded px-3 py-1.5 text-[12px] font-medium ${
|
||||||
unit === option ? 'bg-accent text-white' : 'text-text-secondary hover:bg-item-hover'
|
unit === option ? 'bg-accent text-accent-foreground' : 'text-text-secondary hover:bg-item-hover'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{option}
|
{option}
|
||||||
@@ -257,7 +261,7 @@ export default function SpeedLimiterView() {
|
|||||||
|
|
||||||
<div className="my-6 border-t border-border-color" />
|
<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">
|
<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>
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
{presetValues.map(presetValue => {
|
{presetValues.map(presetValue => {
|
||||||
@@ -271,7 +275,7 @@ export default function SpeedLimiterView() {
|
|||||||
type="button"
|
type="button"
|
||||||
disabled={!enabled || isSaving}
|
disabled={!enabled || isSaving}
|
||||||
onClick={() => preset(presetValue)}
|
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}
|
{formatPresetValue(displayValue)} {unit}
|
||||||
</button>
|
</button>
|
||||||
@@ -279,16 +283,16 @@ export default function SpeedLimiterView() {
|
|||||||
type="button"
|
type="button"
|
||||||
disabled={!enabled || isSaving}
|
disabled={!enabled || isSaving}
|
||||||
onClick={() => removePreset(presetValue)}
|
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"
|
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={`Remove ${formatPresetValue(displayValue)} ${unit} preset`}
|
title={t($ => $.speedLimiter.removePreset, { value: formatPresetValue(displayValue), unit })}
|
||||||
aria-label={`Remove ${formatPresetValue(displayValue)} ${unit} preset`}
|
aria-label={t($ => $.speedLimiter.removePreset, { value: formatPresetValue(displayValue), unit })}
|
||||||
>
|
>
|
||||||
<X size={12} />
|
<X size={12} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
min={speedValueFromKiB(1, unit)}
|
min={speedValueFromKiB(1, unit)}
|
||||||
@@ -297,7 +301,7 @@ export default function SpeedLimiterView() {
|
|||||||
disabled={!enabled || isSaving}
|
disabled={!enabled || isSaving}
|
||||||
onChange={event => setCustomPresetValue(event.target.value)}
|
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"
|
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>
|
<span className="text-[11px] text-text-muted">{unit}</span>
|
||||||
<button
|
<button
|
||||||
@@ -305,8 +309,8 @@ export default function SpeedLimiterView() {
|
|||||||
disabled={!enabled || isSaving}
|
disabled={!enabled || isSaving}
|
||||||
onClick={applyCustomPreset}
|
onClick={applyCustomPreset}
|
||||||
className="app-icon-button h-6 w-6 disabled:opacity-50"
|
className="app-icon-button h-6 w-6 disabled:opacity-50"
|
||||||
title="Add quick preset"
|
title={t($ => $.speedLimiter.addQuickPreset)}
|
||||||
aria-label="Add quick preset"
|
aria-label={t($ => $.speedLimiter.addQuickPreset)}
|
||||||
>
|
>
|
||||||
<Plus size={14} />
|
<Plus size={14} />
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { getCurrentWindow } from '@tauri-apps/api/window';
|
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 type { PointerEvent } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import type { ResolvedWindowControlStyle } from '../utils/windowControlStyle';
|
||||||
|
|
||||||
const appWindow = getCurrentWindow();
|
const appWindow = getCurrentWindow();
|
||||||
|
|
||||||
@@ -8,14 +10,24 @@ const stopTitlebarDrag = (event: PointerEvent<HTMLButtonElement>) => {
|
|||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
};
|
};
|
||||||
|
|
||||||
export function WindowControls() {
|
interface WindowControlsProps {
|
||||||
|
side: 'left' | 'right';
|
||||||
|
controlStyle: ResolvedWindowControlStyle;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WindowControls({ side, controlStyle }: WindowControlsProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="window-controls" aria-label="Window controls">
|
<div
|
||||||
|
className={`window-controls window-controls--${side} window-controls--style-${controlStyle}`}
|
||||||
|
aria-label={t($ => $.window.controls)}
|
||||||
|
>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="window-control close"
|
className="window-control close"
|
||||||
title="Close"
|
title={t($ => $.window.close)}
|
||||||
aria-label="Close"
|
aria-label={t($ => $.window.close)}
|
||||||
onPointerDown={stopTitlebarDrag}
|
onPointerDown={stopTitlebarDrag}
|
||||||
onClick={(event) => {
|
onClick={(event) => {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
@@ -27,8 +39,8 @@ export function WindowControls() {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="window-control minimize"
|
className="window-control minimize"
|
||||||
title="Minimize"
|
title={t($ => $.window.minimize)}
|
||||||
aria-label="Minimize"
|
aria-label={t($ => $.window.minimize)}
|
||||||
onPointerDown={stopTitlebarDrag}
|
onPointerDown={stopTitlebarDrag}
|
||||||
onClick={(event) => {
|
onClick={(event) => {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
@@ -40,15 +52,15 @@ export function WindowControls() {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="window-control maximize"
|
className="window-control maximize"
|
||||||
title="Maximize"
|
title={t($ => $.window.maximize)}
|
||||||
aria-label="Maximize"
|
aria-label={t($ => $.window.maximize)}
|
||||||
onPointerDown={stopTitlebarDrag}
|
onPointerDown={stopTitlebarDrag}
|
||||||
onClick={(event) => {
|
onClick={(event) => {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
void appWindow.toggleMaximize();
|
void appWindow.toggleMaximize();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Square size={8} strokeWidth={3} />
|
<Maximize2 size={9} strokeWidth={3} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import React, { createContext, useContext, useState, useCallback, ReactNode, useEffect, useRef, useLayoutEffect } from 'react';
|
import React, { createContext, useContext, useState, useCallback, ReactNode, useEffect, useRef, useLayoutEffect } from 'react';
|
||||||
import { CheckCircle2, AlertCircle, Info, XCircle, X } from 'lucide-react';
|
import { CheckCircle2, AlertCircle, Info, XCircle, X } from 'lucide-react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
export type ToastVariant = 'success' | 'info' | 'warning' | 'error';
|
export type ToastVariant = 'success' | 'info' | 'warning' | 'error';
|
||||||
|
|
||||||
@@ -69,6 +70,7 @@ export const useToast = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const ToastItem: React.FC<{ toast: ToastState; removeToast: (id: string) => void; removeToastCompletely: (id: string) => void }> = ({ toast, removeToast, removeToastCompletely }) => {
|
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 [isMounted, setIsMounted] = useState(false);
|
||||||
const [isHovered, setIsHovered] = useState(false);
|
const [isHovered, setIsHovered] = useState(false);
|
||||||
const timerStartedAt = useRef<number | null>(null);
|
const timerStartedAt = useRef<number | null>(null);
|
||||||
@@ -196,7 +198,7 @@ const ToastItem: React.FC<{ toast: ToastState; removeToast: (id: string) => void
|
|||||||
<button
|
<button
|
||||||
onClick={() => removeToast(toast.id)}
|
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"
|
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} />
|
<X className="w-4 h-4" strokeWidth={2.5} />
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -0,0 +1,815 @@
|
|||||||
|
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',
|
||||||
|
liveSpeedLimit: 'Live speed cap',
|
||||||
|
liveSpeedLimitHint: 'Applies to active normal downloads only. Media downloads cannot be changed while running.',
|
||||||
|
liveSpeedLimitPlaceholder: 'e.g. 1024K',
|
||||||
|
liveSpeedLimitApply: 'Apply',
|
||||||
|
liveSpeedLimitClear: 'Clear',
|
||||||
|
liveSpeedLimitFailed: 'Could not update live speed cap: {{detail}}',
|
||||||
|
liveSpeedLimitUnavailable: 'Live speed control is unavailable for media downloads while running.',
|
||||||
|
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}}',
|
||||||
|
queueConcurrency: 'Concurrent downloads',
|
||||||
|
queueConcurrencyGlobal: 'Use global limit',
|
||||||
|
queueConcurrencyFailed: 'Could not update queue concurrency: {{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',
|
||||||
|
summary: {
|
||||||
|
items: '{{count}} items',
|
||||||
|
selected: '{{count}} selected',
|
||||||
|
downloaded: 'Downloaded',
|
||||||
|
remaining: 'Remaining',
|
||||||
|
active: 'Active',
|
||||||
|
unknown: 'Unknown',
|
||||||
|
estimated: 'Estimated {{value}}',
|
||||||
|
},
|
||||||
|
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',
|
||||||
|
queueReorderHint: 'Drag rows to reorder or use the hotkeys:',
|
||||||
|
queueReorderShortcut: '{{key}} + Arrow Up/Down moves the focused row',
|
||||||
|
queuePriorityControls: 'Move selected downloads in the queue',
|
||||||
|
queueDragHandle: 'Drag {{fileName}} to reorder',
|
||||||
|
queueReorderFailed: 'Could not reorder 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',
|
||||||
|
quality: 'Quality',
|
||||||
|
format: 'Format',
|
||||||
|
video: 'Video',
|
||||||
|
audio: 'Audio',
|
||||||
|
noCommonFormat: 'No single format is available for all selected items.',
|
||||||
|
noCommonQuality: 'No single quality is available for all selected items.',
|
||||||
|
playlistQuality: 'Playlist quality',
|
||||||
|
playlistQualitySelected: '{{selected}} of {{total}} selected',
|
||||||
|
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',
|
||||||
|
languageAndRegion: 'Language & region',
|
||||||
|
layout: 'Layout',
|
||||||
|
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',
|
||||||
|
windowControls: 'Window controls',
|
||||||
|
windowControlStyle: 'Control style',
|
||||||
|
windowControlsDescription: 'Choose how Firelink draws its custom window buttons. Automatic follows your operating system.',
|
||||||
|
windowControlStyleAutomatic: 'Automatic (recommended)',
|
||||||
|
windowControlStyleMacos: 'macOS Traffic Lights',
|
||||||
|
windowControlStyleWindows: 'Windows 11 Fluent',
|
||||||
|
windowControlStyleGnome: 'GNOME / Adwaita',
|
||||||
|
windowControlStyleMinimal: 'Minimal',
|
||||||
|
languageSystem: 'System default',
|
||||||
|
languageEnglish: 'English',
|
||||||
|
languageChinese: 'Simplified Chinese',
|
||||||
|
languageHebrew: 'Hebrew',
|
||||||
|
languagePersian: 'Persian',
|
||||||
|
languageUkrainian: 'Ukrainian',
|
||||||
|
languageRussian: 'Russian',
|
||||||
|
calendar: 'Calendar',
|
||||||
|
calendarDescription: 'Choose the calendar used for displayed dates and times. Gregorian is the default; this does not change scheduling or stored timestamps.',
|
||||||
|
calendarGregorian: 'Gregorian',
|
||||||
|
calendarPersian: 'Persian',
|
||||||
|
calendarHebrew: 'Hebrew',
|
||||||
|
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',
|
||||||
|
fontFamily: 'Font',
|
||||||
|
fontFamilyDescription: 'Choose the interface font. System uses platform and locale-aware fallbacks.',
|
||||||
|
fontFamilySystem: 'System UI (recommended)',
|
||||||
|
fontFamilyInter: 'Inter',
|
||||||
|
fontFamilyOutfit: 'Outfit',
|
||||||
|
fontFamilyVazirmatn: 'Vazirmatn',
|
||||||
|
fontFamilyNotoSansHebrew: 'Noto Sans Hebrew',
|
||||||
|
fontFamilyNotoSansSc: 'Noto Sans SC',
|
||||||
|
fontFamilyRoboto: 'Roboto',
|
||||||
|
fontFamilySerif: 'Serif',
|
||||||
|
fontFamilyMonospace: 'Monospace',
|
||||||
|
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.',
|
||||||
|
preventDisplaySleep: 'Keep the display awake while downloads are active',
|
||||||
|
preventDisplaySleepDescription: 'Optional and separate from system-sleep prevention. The display will remain on until active transfers finish.',
|
||||||
|
},
|
||||||
|
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,815 @@
|
|||||||
|
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: 'سقف سرعت',
|
||||||
|
liveSpeedLimit: 'سقف سرعت زنده',
|
||||||
|
liveSpeedLimitHint: 'فقط برای دانلودهای عادیِ فعال اعمال میشود. سرعت دانلودهای رسانهای هنگام اجرا قابل تغییر نیست.',
|
||||||
|
liveSpeedLimitPlaceholder: 'مثلاً 1024K',
|
||||||
|
liveSpeedLimitApply: 'اعمال',
|
||||||
|
liveSpeedLimitClear: 'پاک کردن',
|
||||||
|
liveSpeedLimitFailed: 'بهروزرسانی سقف سرعت زنده ممکن نیست: {{detail}}',
|
||||||
|
liveSpeedLimitUnavailable: 'تغییر زنده سرعت دانلودهای رسانهای هنگام اجرا در دسترس نیست.',
|
||||||
|
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}}',
|
||||||
|
queueConcurrency: 'دانلودهای همزمان',
|
||||||
|
queueConcurrencyGlobal: 'استفاده از محدودیت کلی',
|
||||||
|
queueConcurrencyFailed: 'بهروزرسانی همزمانی صف ناموفق بود: {{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: 'توقف همه',
|
||||||
|
summary: {
|
||||||
|
items: '{{count}} مورد',
|
||||||
|
selected: '{{count}} انتخابشده',
|
||||||
|
downloaded: 'دانلودشده',
|
||||||
|
remaining: 'باقیمانده',
|
||||||
|
active: 'فعال',
|
||||||
|
unknown: 'نامشخص',
|
||||||
|
estimated: '{{value}} (تخمینی)',
|
||||||
|
},
|
||||||
|
queueEmpty: 'صف خالی است',
|
||||||
|
noCompletedDownloads: 'هیچ دانلود تکمیلشدهای وجود ندارد',
|
||||||
|
noDownloads: 'هیچ دانلودی وجود ندارد',
|
||||||
|
queueEmptyDescription: 'دانلودها را از منوی آیتمها یا پنجره افزودن به این صف اضافه کنید.',
|
||||||
|
completedDescription: 'دانلودهای تکمیلشده در اینجا ظاهر میشوند.',
|
||||||
|
clickToAdd: 'برای افزودن دانلودها، روی',
|
||||||
|
addButtonOr: 'یا',
|
||||||
|
toAddDownloads: 'کلیک کنید',
|
||||||
|
queueReorderHint: 'ردیفها را بکشید تا مرتب شوند یا از کلیدهای میانبر استفاده کنید:',
|
||||||
|
queueReorderShortcut: '{{key}} + فلش بالا/پایین ردیف انتخابشده را جابهجا میکند',
|
||||||
|
queuePriorityControls: 'جابهجایی دانلودهای انتخابشده در صف',
|
||||||
|
queueDragHandle: 'کشیدن {{fileName}} برای جابهجایی',
|
||||||
|
queueReorderFailed: 'جابهجایی دانلودها ناموفق بود',
|
||||||
|
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: 'جریانهای رسانه موجود',
|
||||||
|
quality: 'کیفیت',
|
||||||
|
format: 'قالب',
|
||||||
|
video: 'ویدئو',
|
||||||
|
audio: 'صوتی',
|
||||||
|
noCommonFormat: 'هیچ فرمت مشترکی برای همه موارد انتخابشده وجود ندارد.',
|
||||||
|
noCommonQuality: 'کیفیت یکسانی برای همه موارد انتخابشده وجود ندارد.',
|
||||||
|
playlistQuality: 'کیفیت فهرست پخش',
|
||||||
|
playlistQualitySelected: '{{selected}} از {{total}} مورد انتخاب شده',
|
||||||
|
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: 'زبان',
|
||||||
|
languageAndRegion: 'زبان و منطقه',
|
||||||
|
layout: 'چیدمان',
|
||||||
|
languageDescription: 'زبان رابط Firelink را انتخاب کنید.',
|
||||||
|
sidebarPosition: 'جایگاه نوار کناری',
|
||||||
|
sidebarPositionDescription: 'مشخص کنید نوار کناری کجا نمایش داده شود. حالت خودکار از جهت رابط کاربری پیروی میکند.',
|
||||||
|
sidebarPositionAutomatic: 'خودکار (پیشنهادشده)',
|
||||||
|
sidebarPositionLeft: 'چپ',
|
||||||
|
sidebarPositionRight: 'راست',
|
||||||
|
windowControls: 'کنترلهای پنجره',
|
||||||
|
windowControlStyle: 'سبک کنترلها',
|
||||||
|
windowControlsDescription: 'نحوه نمایش دکمههای سفارشی پنجره Firelink را انتخاب کنید. حالت خودکار از سیستمعامل شما پیروی میکند.',
|
||||||
|
windowControlStyleAutomatic: 'خودکار (پیشنهادشده)',
|
||||||
|
windowControlStyleMacos: 'چراغهای macOS',
|
||||||
|
windowControlStyleWindows: 'Windows 11 Fluent',
|
||||||
|
windowControlStyleGnome: 'GNOME / Adwaita',
|
||||||
|
windowControlStyleMinimal: 'ساده',
|
||||||
|
languageSystem: 'زبان سیستم',
|
||||||
|
languageEnglish: 'انگلیسی',
|
||||||
|
languageChinese: 'چینی سادهشده',
|
||||||
|
languageHebrew: 'عبری',
|
||||||
|
languagePersian: 'فارسی',
|
||||||
|
languageUkrainian: 'اوکراینی',
|
||||||
|
languageRussian: 'روسی',
|
||||||
|
calendar: 'تقویم',
|
||||||
|
calendarDescription: 'تقویم مورد استفاده برای نمایش تاریخ و زمان را انتخاب کنید. تقویم میلادی پیشفرض است و زمانبندی یا مُهرهای زمانی ذخیرهشده را تغییر نمیدهد.',
|
||||||
|
calendarGregorian: 'میلادی',
|
||||||
|
calendarPersian: 'هجری شمسی',
|
||||||
|
calendarHebrew: 'عبری',
|
||||||
|
appTheme: 'ظاهر برنامه',
|
||||||
|
theme: 'ظاهر',
|
||||||
|
ariaLabel: 'ظاهر برنامه',
|
||||||
|
system: 'سیستم',
|
||||||
|
light: 'روشن',
|
||||||
|
dark: 'تاریک',
|
||||||
|
dracula: 'Dracula',
|
||||||
|
nord: 'Nord',
|
||||||
|
systemAppearance: 'برنامه از حالت روشن یا تاریک فعلی {{platform}} پیروی میکند.',
|
||||||
|
display: 'نمایش',
|
||||||
|
fontFamily: 'فونت',
|
||||||
|
fontFamilyDescription: 'فونت رابط کاربری را انتخاب کنید. حالت سیستم از فونتهای سیستمی متناسب با سیستمعامل و زبان استفاده میکند.',
|
||||||
|
fontFamilySystem: 'System UI (recommended)',
|
||||||
|
fontFamilyInter: 'Inter',
|
||||||
|
fontFamilyOutfit: 'Outfit',
|
||||||
|
fontFamilyVazirmatn: 'Vazirmatn',
|
||||||
|
fontFamilyNotoSansHebrew: 'Noto Sans Hebrew',
|
||||||
|
fontFamilyNotoSansSc: 'Noto Sans SC',
|
||||||
|
fontFamilyRoboto: 'Roboto',
|
||||||
|
fontFamilySerif: 'Serif',
|
||||||
|
fontFamilyMonospace: 'Monospace',
|
||||||
|
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 تنها به اندازهای دستگاه را بیدار نگه میدارد تا انتقالهای فعال را تکمیل کند.',
|
||||||
|
preventDisplaySleep: 'روشن نگه داشتن نمایشگر هنگام فعال بودن دانلودها',
|
||||||
|
preventDisplaySleepDescription: 'این گزینه جدا از جلوگیری از خواب سیستم است و اختیاری است. نمایشگر تا پایان انتقالهای فعال روشن میماند.',
|
||||||
|
},
|
||||||
|
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,815 @@
|
|||||||
|
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: 'הגבלת מהירות',
|
||||||
|
liveSpeedLimit: 'הגבלת מהירות בזמן אמת',
|
||||||
|
liveSpeedLimitHint: 'חל על הורדות רגילות פעילות בלבד. אי אפשר לשנות הורדות מדיה בזמן שהן פועלות.',
|
||||||
|
liveSpeedLimitPlaceholder: 'לדוגמה 1024K',
|
||||||
|
liveSpeedLimitApply: 'החל',
|
||||||
|
liveSpeedLimitClear: 'נקה',
|
||||||
|
liveSpeedLimitFailed: 'לא ניתן לעדכן את הגבלת המהירות בזמן אמת: {{detail}}',
|
||||||
|
liveSpeedLimitUnavailable: 'שליטה במהירות בזמן אמת אינה זמינה להורדות מדיה בזמן שהן פועלות.',
|
||||||
|
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}}',
|
||||||
|
queueConcurrency: 'הורדות בו-זמניות',
|
||||||
|
queueConcurrencyGlobal: 'שימוש במגבלה הכללית',
|
||||||
|
queueConcurrencyFailed: 'לא ניתן לעדכן את מקביליות התור: {{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: 'השהיית הכל',
|
||||||
|
summary: {
|
||||||
|
items: '{{count}} פריטים',
|
||||||
|
selected: '{{count}} נבחרו',
|
||||||
|
downloaded: 'הורדו',
|
||||||
|
remaining: 'נותרו',
|
||||||
|
active: 'פעילות',
|
||||||
|
unknown: 'לא ידוע',
|
||||||
|
estimated: '{{value}} (הערכה)',
|
||||||
|
},
|
||||||
|
queueEmpty: 'התור ריק',
|
||||||
|
noCompletedDownloads: 'אין הורדות שהושלמו',
|
||||||
|
noDownloads: 'אין הורדות',
|
||||||
|
queueEmptyDescription: 'הוסף הורדות לתור זה מתפריט הפריט או מחלון ההוספה.',
|
||||||
|
completedDescription: 'הורדות שהושלמו יופיעו כאן.',
|
||||||
|
clickToAdd: 'לחץ על לחצן',
|
||||||
|
addButtonOr: 'או',
|
||||||
|
toAddDownloads: 'כדי להוסיף הורדות',
|
||||||
|
queueReorderHint: 'גררו שורות כדי לשנות את הסדר או השתמשו במקשי הקיצור:',
|
||||||
|
queueReorderShortcut: '{{key}} + חץ למעלה/למטה מזיז את השורה הממוקדת',
|
||||||
|
queuePriorityControls: 'הזזת ההורדות שנבחרו בתור',
|
||||||
|
queueDragHandle: 'גררו את {{fileName}} כדי לשנות את הסדר',
|
||||||
|
queueReorderFailed: 'לא ניתן לשנות את סדר ההורדות',
|
||||||
|
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: 'זרמי מדיה זמינים',
|
||||||
|
quality: 'איכות',
|
||||||
|
format: 'פורמט',
|
||||||
|
video: 'וידאו',
|
||||||
|
audio: 'שמע',
|
||||||
|
noCommonFormat: 'אין פורמט אחד זמין לכל הפריטים שנבחרו.',
|
||||||
|
noCommonQuality: 'אין איכות אחת זמינה לכל הפריטים שנבחרו.',
|
||||||
|
playlistQuality: 'איכות רשימת השמעה',
|
||||||
|
playlistQualitySelected: '{{selected}} מתוך {{total}} נבחרו',
|
||||||
|
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: 'שפה',
|
||||||
|
languageAndRegion: 'שפה ואזור',
|
||||||
|
layout: 'פריסה',
|
||||||
|
languageDescription: 'בחר את שפת הממשק של Firelink.',
|
||||||
|
sidebarPosition: 'מיקום סרגל הצד',
|
||||||
|
sidebarPositionDescription: 'בחר היכן יופיע סרגל הצד. מצב אוטומטי עוקב אחר כיוון הממשק.',
|
||||||
|
sidebarPositionAutomatic: 'אוטומטי (מומלץ)',
|
||||||
|
sidebarPositionLeft: 'שמאל',
|
||||||
|
sidebarPositionRight: 'ימין',
|
||||||
|
windowControls: 'פקדי חלון',
|
||||||
|
windowControlStyle: 'סגנון פקדים',
|
||||||
|
windowControlsDescription: 'בחר כיצד Firelink מצייר את כפתורי החלון המותאמים אישית. מצב אוטומטי עוקב אחר מערכת ההפעלה שלך.',
|
||||||
|
windowControlStyleAutomatic: 'אוטומטי (מומלץ)',
|
||||||
|
windowControlStyleMacos: 'רמזורי macOS',
|
||||||
|
windowControlStyleWindows: 'Windows 11 Fluent',
|
||||||
|
windowControlStyleGnome: 'GNOME / Adwaita',
|
||||||
|
windowControlStyleMinimal: 'מינימלי',
|
||||||
|
languageSystem: 'שפת המערכת',
|
||||||
|
languageEnglish: 'אנגלית',
|
||||||
|
languageChinese: 'סינית מפושטת',
|
||||||
|
languageHebrew: 'עברית',
|
||||||
|
languagePersian: 'פרסית',
|
||||||
|
languageUkrainian: 'אוקראינית',
|
||||||
|
languageRussian: 'רוסית',
|
||||||
|
calendar: 'לוח שנה',
|
||||||
|
calendarDescription: 'בחר את לוח השנה לתצוגת תאריכים ושעות. הלוח הגרגוריאני הוא ברירת המחדל; הבחירה אינה משנה תזמון או חותמות זמן שמורות.',
|
||||||
|
calendarGregorian: 'גרגוריאני',
|
||||||
|
calendarPersian: 'פרסי',
|
||||||
|
calendarHebrew: 'עברי',
|
||||||
|
appTheme: 'ערכת נושא לאפליקציה',
|
||||||
|
theme: 'ערכת נושא',
|
||||||
|
ariaLabel: 'ערכת נושא לאפליקציה',
|
||||||
|
system: 'מערכת',
|
||||||
|
light: 'בהירה',
|
||||||
|
dark: 'כהה',
|
||||||
|
dracula: 'Dracula',
|
||||||
|
nord: 'Nord',
|
||||||
|
systemAppearance: 'מערכת עוקבת אחר המראה הנוכחי (בהיר או כהה) של {{platform}}.',
|
||||||
|
display: 'תצוגה',
|
||||||
|
fontFamily: 'גופן',
|
||||||
|
fontFamilyDescription: 'בחר את גופן הממשק. מצב המערכת משתמש בגופני מערכת המתאימים לפלטפורמה ולשפה.',
|
||||||
|
fontFamilySystem: 'System UI (recommended)',
|
||||||
|
fontFamilyInter: 'Inter',
|
||||||
|
fontFamilyOutfit: 'Outfit',
|
||||||
|
fontFamilyVazirmatn: 'Vazirmatn',
|
||||||
|
fontFamilyNotoSansHebrew: 'Noto Sans Hebrew',
|
||||||
|
fontFamilyNotoSansSc: 'Noto Sans SC',
|
||||||
|
fontFamilyRoboto: 'Roboto',
|
||||||
|
fontFamilySerif: 'Serif',
|
||||||
|
fontFamilyMonospace: 'Monospace',
|
||||||
|
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 משאיר את המכשיר ער רק במידה המספקת להשלמת העברות פעילות.',
|
||||||
|
preventDisplaySleep: 'השאר את התצוגה פעילה בזמן שהורדות פעילות',
|
||||||
|
preventDisplaySleepDescription: 'אפשרות זו נפרדת ממניעת שינת המערכת. התצוגה תישאר פעילה עד לסיום ההעברות הפעילות.',
|
||||||
|
},
|
||||||
|
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,815 @@
|
|||||||
|
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: 'Ограничение скорости',
|
||||||
|
liveSpeedLimit: 'Текущее ограничение скорости',
|
||||||
|
liveSpeedLimitHint: 'Применяется только к активным обычным загрузкам. Скорость медиазагрузок нельзя изменить во время работы.',
|
||||||
|
liveSpeedLimitPlaceholder: 'например, 1024K',
|
||||||
|
liveSpeedLimitApply: 'Применить',
|
||||||
|
liveSpeedLimitClear: 'Очистить',
|
||||||
|
liveSpeedLimitFailed: 'Не удалось обновить текущее ограничение скорости: {{detail}}',
|
||||||
|
liveSpeedLimitUnavailable: 'Изменение скорости медиазагрузок во время работы недоступно.',
|
||||||
|
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}}',
|
||||||
|
queueConcurrency: 'Одновременные загрузки',
|
||||||
|
queueConcurrencyGlobal: 'Использовать общий лимит',
|
||||||
|
queueConcurrencyFailed: 'Не удалось обновить параллельность очереди: {{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: 'Приостановить все',
|
||||||
|
summary: {
|
||||||
|
items: '{{count}} элементов',
|
||||||
|
selected: 'Выбрано: {{count}}',
|
||||||
|
downloaded: 'Загружено',
|
||||||
|
remaining: 'Осталось',
|
||||||
|
active: 'Активные',
|
||||||
|
unknown: 'Неизвестно',
|
||||||
|
estimated: 'Около {{value}}',
|
||||||
|
},
|
||||||
|
queueEmpty: 'Очередь пуста',
|
||||||
|
noCompletedDownloads: 'Нет завершённых загрузок',
|
||||||
|
noDownloads: 'Нет загрузок',
|
||||||
|
queueEmptyDescription: 'Добавьте загрузки в эту очередь из меню элемента или окна добавления.',
|
||||||
|
completedDescription: 'Завершённые загрузки будут отображаться здесь.',
|
||||||
|
clickToAdd: 'Нажмите кнопку',
|
||||||
|
addButtonOr: 'или',
|
||||||
|
toAddDownloads: 'чтобы добавить загрузки',
|
||||||
|
queueReorderHint: 'Перетаскивайте строки, чтобы изменить порядок, или используйте горячие клавиши:',
|
||||||
|
queueReorderShortcut: '{{key}} + стрелка вверх/вниз перемещает сфокусированную строку',
|
||||||
|
queuePriorityControls: 'Переместить выбранные загрузки в очереди',
|
||||||
|
queueDragHandle: 'Перетащите {{fileName}} для изменения порядка',
|
||||||
|
queueReorderFailed: 'Не удалось изменить порядок загрузок',
|
||||||
|
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: 'Доступные медиапотоки',
|
||||||
|
quality: 'Качество',
|
||||||
|
format: 'Формат',
|
||||||
|
video: 'Видео',
|
||||||
|
audio: 'Аудио',
|
||||||
|
noCommonFormat: 'Для всех выбранных элементов нет общего формата.',
|
||||||
|
noCommonQuality: 'Для всех выбранных элементов нет общего качества.',
|
||||||
|
playlistQuality: 'Качество плейлиста',
|
||||||
|
playlistQualitySelected: 'Выбрано: {{selected}} из {{total}}',
|
||||||
|
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: 'Язык',
|
||||||
|
languageAndRegion: 'Язык и регион',
|
||||||
|
layout: 'Компоновка',
|
||||||
|
languageDescription: 'Выберите язык интерфейса Firelink.',
|
||||||
|
sidebarPosition: 'Положение боковой панели',
|
||||||
|
sidebarPositionDescription: 'Выберите, где отображается боковая панель. В автоматическом режиме следует направлению интерфейса.',
|
||||||
|
sidebarPositionAutomatic: 'Автоматически (рекомендуется)',
|
||||||
|
sidebarPositionLeft: 'Слева',
|
||||||
|
sidebarPositionRight: 'Справа',
|
||||||
|
windowControls: 'Элементы управления окном',
|
||||||
|
windowControlStyle: 'Стиль элементов управления',
|
||||||
|
windowControlsDescription: 'Выберите, как Firelink отображает пользовательские кнопки окна. Автоматический режим учитывает вашу ОС.',
|
||||||
|
windowControlStyleAutomatic: 'Автоматически (рекомендуется)',
|
||||||
|
windowControlStyleMacos: 'Светофоры macOS',
|
||||||
|
windowControlStyleWindows: 'Windows 11 Fluent',
|
||||||
|
windowControlStyleGnome: 'GNOME / Adwaita',
|
||||||
|
windowControlStyleMinimal: 'Минималистичный',
|
||||||
|
languageSystem: 'Системный по умолчанию',
|
||||||
|
languageEnglish: 'Английский',
|
||||||
|
languageChinese: 'Упрощённый китайский',
|
||||||
|
languageHebrew: 'Иврит',
|
||||||
|
languagePersian: 'Персидский',
|
||||||
|
languageUkrainian: 'Украинский',
|
||||||
|
languageRussian: 'Русский',
|
||||||
|
calendar: 'Календарь',
|
||||||
|
calendarDescription: 'Выберите календарь для отображения дат и времени. По умолчанию используется григорианский; расписание и сохранённые метки времени не изменяются.',
|
||||||
|
calendarGregorian: 'Григорианский',
|
||||||
|
calendarPersian: 'Персидский',
|
||||||
|
calendarHebrew: 'Еврейский',
|
||||||
|
appTheme: 'Тема приложения',
|
||||||
|
theme: 'Тема',
|
||||||
|
ariaLabel: 'Тема приложения',
|
||||||
|
system: 'Системная',
|
||||||
|
light: 'Светлая',
|
||||||
|
dark: 'Тёмная',
|
||||||
|
dracula: 'Dracula',
|
||||||
|
nord: 'Nord',
|
||||||
|
systemAppearance: 'Системная тема соответствует текущему светлому или тёмному оформлению {{platform}}.',
|
||||||
|
display: 'Отображение',
|
||||||
|
fontFamily: 'Шрифт',
|
||||||
|
fontFamilyDescription: 'Выберите шрифт интерфейса. Системный режим использует системные шрифты с учётом ОС и языка.',
|
||||||
|
fontFamilySystem: 'System UI (recommended)',
|
||||||
|
fontFamilyInter: 'Inter',
|
||||||
|
fontFamilyOutfit: 'Outfit',
|
||||||
|
fontFamilyVazirmatn: 'Vazirmatn',
|
||||||
|
fontFamilyNotoSansHebrew: 'Noto Sans Hebrew',
|
||||||
|
fontFamilyNotoSansSc: 'Noto Sans SC',
|
||||||
|
fontFamilyRoboto: 'Roboto',
|
||||||
|
fontFamilySerif: 'Serif',
|
||||||
|
fontFamilyMonospace: 'Monospace',
|
||||||
|
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 предотвращает засыпание устройства только настолько, чтобы завершить активные передачи.',
|
||||||
|
preventDisplaySleep: 'Не выключать экран во время активных загрузок',
|
||||||
|
preventDisplaySleepDescription: 'Это отдельная необязательная настройка, не связанная с запретом сна системы. Экран останется включённым до завершения передач.',
|
||||||
|
},
|
||||||
|
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,815 @@
|
|||||||
|
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: 'Обмеження швидкості',
|
||||||
|
liveSpeedLimit: 'Поточне обмеження швидкості',
|
||||||
|
liveSpeedLimitHint: 'Застосовується лише до активних звичайних завантажень. Швидкість медіазавантажень не можна змінити під час роботи.',
|
||||||
|
liveSpeedLimitPlaceholder: 'наприклад, 1024K',
|
||||||
|
liveSpeedLimitApply: 'Застосувати',
|
||||||
|
liveSpeedLimitClear: 'Очистити',
|
||||||
|
liveSpeedLimitFailed: 'Не вдалося оновити поточне обмеження швидкості: {{detail}}',
|
||||||
|
liveSpeedLimitUnavailable: 'Зміна швидкості медіазавантажень під час роботи недоступна.',
|
||||||
|
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}}',
|
||||||
|
queueConcurrency: 'Одночасні завантаження',
|
||||||
|
queueConcurrencyGlobal: 'Використовувати загальний ліміт',
|
||||||
|
queueConcurrencyFailed: 'Не вдалося оновити паралельність черги: {{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: 'Призупинити всі',
|
||||||
|
summary: {
|
||||||
|
items: '{{count}} елементів',
|
||||||
|
selected: 'Вибрано: {{count}}',
|
||||||
|
downloaded: 'Завантажено',
|
||||||
|
remaining: 'Залишилось',
|
||||||
|
active: 'Активні',
|
||||||
|
unknown: 'Невідомо',
|
||||||
|
estimated: 'Приблизно {{value}}',
|
||||||
|
},
|
||||||
|
queueEmpty: 'Черга порожня',
|
||||||
|
noCompletedDownloads: 'Немає завершених завантажень',
|
||||||
|
noDownloads: 'Немає завантажень',
|
||||||
|
queueEmptyDescription: 'Додайте завантаження до цієї черги через меню елемента або у вікні "Додати".',
|
||||||
|
completedDescription: 'Тут з\'являтимуться завершені завантаження.',
|
||||||
|
clickToAdd: 'Натисніть',
|
||||||
|
addButtonOr: 'або',
|
||||||
|
toAddDownloads: 'щоб додати завантаження',
|
||||||
|
queueReorderHint: 'Перетягуйте рядки, щоб змінити порядок, або використовуйте гарячі клавіші:',
|
||||||
|
queueReorderShortcut: '{{key}} + стрілка вгору/вниз переміщує сфокусований рядок',
|
||||||
|
queuePriorityControls: 'Перемістити вибрані завантаження в черзі',
|
||||||
|
queueDragHandle: 'Перетягніть {{fileName}}, щоб змінити порядок',
|
||||||
|
queueReorderFailed: 'Не вдалося змінити порядок завантажень',
|
||||||
|
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: 'Доступні медіапотоки',
|
||||||
|
quality: 'Якість',
|
||||||
|
format: 'Формат',
|
||||||
|
video: 'Відео',
|
||||||
|
audio: 'Аудіо',
|
||||||
|
noCommonFormat: 'Немає спільного формату для всіх вибраних елементів.',
|
||||||
|
noCommonQuality: 'Немає спільної якості для всіх вибраних елементів.',
|
||||||
|
playlistQuality: 'Якість плейлиста',
|
||||||
|
playlistQualitySelected: 'Вибрано: {{selected}} із {{total}}',
|
||||||
|
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: 'Мова',
|
||||||
|
languageAndRegion: 'Мова й регіон',
|
||||||
|
layout: 'Компонування',
|
||||||
|
languageDescription: 'Виберіть мову інтерфейсу Firelink.',
|
||||||
|
sidebarPosition: 'Положення бічної панелі',
|
||||||
|
sidebarPositionDescription: 'Виберіть, де відображати бічну панель. Автоматичний режим враховує напрямок інтерфейсу.',
|
||||||
|
sidebarPositionAutomatic: 'Автоматично (рекомендовано)',
|
||||||
|
sidebarPositionLeft: 'Ліворуч',
|
||||||
|
sidebarPositionRight: 'Праворуч',
|
||||||
|
windowControls: 'Керування вікном',
|
||||||
|
windowControlStyle: 'Стиль елементів керування',
|
||||||
|
windowControlsDescription: 'Виберіть, як Firelink відображає власні кнопки вікна. Автоматичний режим враховує вашу ОС.',
|
||||||
|
windowControlStyleAutomatic: 'Автоматично (рекомендовано)',
|
||||||
|
windowControlStyleMacos: 'Світлофори macOS',
|
||||||
|
windowControlStyleWindows: 'Windows 11 Fluent',
|
||||||
|
windowControlStyleGnome: 'GNOME / Adwaita',
|
||||||
|
windowControlStyleMinimal: 'Мінімальний',
|
||||||
|
languageSystem: 'Мова системи',
|
||||||
|
languageEnglish: 'Англійська',
|
||||||
|
languageChinese: 'Спрощена китайська',
|
||||||
|
languageHebrew: 'Іврит',
|
||||||
|
languagePersian: 'Перська',
|
||||||
|
languageUkrainian: 'Українська',
|
||||||
|
languageRussian: 'Російська',
|
||||||
|
calendar: 'Календар',
|
||||||
|
calendarDescription: 'Виберіть календар для відображення дат і часу. Типово використовується григоріанський; розклад і збережені мітки часу не змінюються.',
|
||||||
|
calendarGregorian: 'Григоріанський',
|
||||||
|
calendarPersian: 'Перський',
|
||||||
|
calendarHebrew: 'Єврейський',
|
||||||
|
appTheme: 'Тема програми',
|
||||||
|
theme: 'Тема',
|
||||||
|
ariaLabel: 'Тема програми',
|
||||||
|
system: 'Системна',
|
||||||
|
light: 'Світла',
|
||||||
|
dark: 'Темна',
|
||||||
|
dracula: 'Dracula',
|
||||||
|
nord: 'Nord',
|
||||||
|
systemAppearance: 'Система слідкує за поточним світлим або темним виглядом {{platform}}.',
|
||||||
|
display: 'Відображення',
|
||||||
|
fontFamily: 'Шрифт',
|
||||||
|
fontFamilyDescription: 'Виберіть шрифт інтерфейсу. Системний режим використовує системні шрифти з урахуванням ОС і мови.',
|
||||||
|
fontFamilySystem: 'System UI (recommended)',
|
||||||
|
fontFamilyInter: 'Inter',
|
||||||
|
fontFamilyOutfit: 'Outfit',
|
||||||
|
fontFamilyVazirmatn: 'Vazirmatn',
|
||||||
|
fontFamilyNotoSansHebrew: 'Noto Sans Hebrew',
|
||||||
|
fontFamilyNotoSansSc: 'Noto Sans SC',
|
||||||
|
fontFamilyRoboto: 'Roboto',
|
||||||
|
fontFamilySerif: 'Serif',
|
||||||
|
fontFamilyMonospace: 'Monospace',
|
||||||
|
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 лише не дає пристрою заснути, щоб завершити активні передачі.',
|
||||||
|
preventDisplaySleep: 'Не вимикати дисплей під час активних завантажень',
|
||||||
|
preventDisplaySleepDescription: 'Це окреме необов’язкове налаштування, незалежне від запобігання сну системи. Дисплей залишатиметься ввімкненим до завершення передач.',
|
||||||
|
},
|
||||||
|
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,815 @@
|
|||||||
|
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: '速度上限',
|
||||||
|
liveSpeedLimit: '实时速度上限',
|
||||||
|
liveSpeedLimitHint: '仅适用于正在进行的普通下载。媒体下载运行时无法更改速度。',
|
||||||
|
liveSpeedLimitPlaceholder: '例如 1024K',
|
||||||
|
liveSpeedLimitApply: '应用',
|
||||||
|
liveSpeedLimitClear: '清除',
|
||||||
|
liveSpeedLimitFailed: '无法更新实时速度上限:{{detail}}',
|
||||||
|
liveSpeedLimitUnavailable: '媒体下载运行时无法使用实时速度控制。',
|
||||||
|
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}}',
|
||||||
|
queueConcurrency: '并行下载数',
|
||||||
|
queueConcurrencyGlobal: '使用全局限制',
|
||||||
|
queueConcurrencyFailed: '无法更新队列并发数:{{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: '全部暂停',
|
||||||
|
summary: {
|
||||||
|
items: '{{count}} 个项目',
|
||||||
|
selected: '已选择 {{count}}',
|
||||||
|
downloaded: '已下载',
|
||||||
|
remaining: '剩余',
|
||||||
|
active: '进行中',
|
||||||
|
unknown: '未知',
|
||||||
|
estimated: '估计 {{value}}',
|
||||||
|
},
|
||||||
|
queueEmpty: '队列为空',
|
||||||
|
noCompletedDownloads: '没有已完成的下载',
|
||||||
|
noDownloads: '没有下载',
|
||||||
|
queueEmptyDescription: '通过项目菜单或“添加”窗口将下载添加到此队列。',
|
||||||
|
completedDescription: '已完成的下载将显示在此处。',
|
||||||
|
clickToAdd: '点击',
|
||||||
|
addButtonOr: '按钮或',
|
||||||
|
toAddDownloads: '来添加下载',
|
||||||
|
queueReorderHint: '拖动行调整顺序,或使用快捷键:',
|
||||||
|
queueReorderShortcut: '{{key}} + 上/下箭头可移动当前行',
|
||||||
|
queuePriorityControls: '移动队列中选中的下载',
|
||||||
|
queueDragHandle: '拖动 {{fileName}} 调整顺序',
|
||||||
|
queueReorderFailed: '无法调整下载顺序',
|
||||||
|
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: '可用的媒体流',
|
||||||
|
quality: '画质',
|
||||||
|
format: '格式',
|
||||||
|
video: '视频',
|
||||||
|
audio: '音频',
|
||||||
|
noCommonFormat: '所选项目没有共同格式。',
|
||||||
|
noCommonQuality: '所选项目没有共同画质。',
|
||||||
|
playlistQuality: '播放列表画质',
|
||||||
|
playlistQualitySelected: '已选择 {{selected}} / {{total}}',
|
||||||
|
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: '语言',
|
||||||
|
languageAndRegion: '语言和地区',
|
||||||
|
layout: '布局',
|
||||||
|
languageDescription: '选择 Firelink 的界面语言。',
|
||||||
|
sidebarPosition: '侧边栏位置',
|
||||||
|
sidebarPositionDescription: '选择侧边栏显示的位置。“自动”会跟随界面文字方向。',
|
||||||
|
sidebarPositionAutomatic: '自动(推荐)',
|
||||||
|
sidebarPositionLeft: '左侧',
|
||||||
|
sidebarPositionRight: '右侧',
|
||||||
|
windowControls: '窗口控件',
|
||||||
|
windowControlStyle: '控件样式',
|
||||||
|
windowControlsDescription: '选择 Firelink 绘制自定义窗口按钮的方式。“自动”会跟随操作系统。',
|
||||||
|
windowControlStyleAutomatic: '自动(推荐)',
|
||||||
|
windowControlStyleMacos: 'macOS 交通灯',
|
||||||
|
windowControlStyleWindows: 'Windows 11 Fluent',
|
||||||
|
windowControlStyleGnome: 'GNOME / Adwaita',
|
||||||
|
windowControlStyleMinimal: '极简',
|
||||||
|
languageSystem: '系统默认',
|
||||||
|
languageEnglish: '英语',
|
||||||
|
languageChinese: '简体中文',
|
||||||
|
languageHebrew: '希伯来语',
|
||||||
|
languagePersian: '波斯语',
|
||||||
|
languageUkrainian: '乌克兰语',
|
||||||
|
languageRussian: '俄语',
|
||||||
|
calendar: '日历',
|
||||||
|
calendarDescription: '选择显示日期和时间所使用的日历。默认使用公历;此选择不会改变计划或已保存的时间戳。',
|
||||||
|
calendarGregorian: '公历',
|
||||||
|
calendarPersian: '波斯历',
|
||||||
|
calendarHebrew: '希伯来历',
|
||||||
|
appTheme: '应用主题',
|
||||||
|
theme: '主题',
|
||||||
|
ariaLabel: '应用主题',
|
||||||
|
system: '系统',
|
||||||
|
light: '浅色',
|
||||||
|
dark: '深色',
|
||||||
|
dracula: 'Dracula',
|
||||||
|
nord: 'Nord',
|
||||||
|
systemAppearance: '系统跟随当前的 {{platform}} 浅色或深色外观。',
|
||||||
|
display: '显示',
|
||||||
|
fontFamily: '字体',
|
||||||
|
fontFamilyDescription: '选择界面字体。“系统”会使用适合操作系统和语言的系统字体及备用字体。',
|
||||||
|
fontFamilySystem: 'System UI (recommended)',
|
||||||
|
fontFamilyInter: 'Inter',
|
||||||
|
fontFamilyOutfit: 'Outfit',
|
||||||
|
fontFamilyVazirmatn: 'Vazirmatn',
|
||||||
|
fontFamilyNotoSansHebrew: 'Noto Sans Hebrew',
|
||||||
|
fontFamilyNotoSansSc: 'Noto Sans SC',
|
||||||
|
fontFamilyRoboto: 'Roboto',
|
||||||
|
fontFamilySerif: 'Serif',
|
||||||
|
fontFamilyMonospace: 'Monospace',
|
||||||
|
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 仅保持设备唤醒足以完成活动的传输。',
|
||||||
|
preventDisplaySleep: '下载活动时保持显示器唤醒',
|
||||||
|
preventDisplaySleepDescription: '这是与阻止系统睡眠分开的可选设置。活动传输完成前,显示器将保持开启。',
|
||||||
|
},
|
||||||
|
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,122 @@
|
|||||||
|
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.lookAndFeel.fontFamilyInter',
|
||||||
|
'settings.lookAndFeel.fontFamilyOutfit',
|
||||||
|
'settings.lookAndFeel.fontFamilyVazirmatn',
|
||||||
|
'settings.lookAndFeel.fontFamilyNotoSansHebrew',
|
||||||
|
'settings.lookAndFeel.fontFamilyNotoSansSc',
|
||||||
|
'settings.lookAndFeel.fontFamilyRoboto',
|
||||||
|
'settings.lookAndFeel.fontFamilySystem',
|
||||||
|
'settings.lookAndFeel.fontFamilySerif',
|
||||||
|
'settings.lookAndFeel.fontFamilyMonospace',
|
||||||
|
'settings.lookAndFeel.windowControlStyleWindows',
|
||||||
|
'settings.lookAndFeel.windowControlStyleGnome',
|
||||||
|
'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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+1304
-122
File diff suppressed because it is too large
Load Diff
+12
-3
@@ -16,6 +16,7 @@ import type { PairingTokenHydration } from './bindings/PairingTokenHydration';
|
|||||||
import type { EnqueueItem } from './bindings/EnqueueItem';
|
import type { EnqueueItem } from './bindings/EnqueueItem';
|
||||||
import type { EnqueueAccepted } from './bindings/EnqueueAccepted';
|
import type { EnqueueAccepted } from './bindings/EnqueueAccepted';
|
||||||
import type { PlatformInfo } from './bindings/PlatformInfo';
|
import type { PlatformInfo } from './bindings/PlatformInfo';
|
||||||
|
import type { QueueConcurrencyConfig } from './bindings/QueueConcurrencyConfig';
|
||||||
|
|
||||||
type CommandMap = {
|
type CommandMap = {
|
||||||
fetch_metadata: {
|
fetch_metadata: {
|
||||||
@@ -37,16 +38,23 @@ type CommandMap = {
|
|||||||
reveal_in_file_manager: { args: { path: string }; result: void };
|
reveal_in_file_manager: { args: { path: string }; result: void };
|
||||||
open_downloaded_file: { args: { path: string }; result: void };
|
open_downloaded_file: { args: { path: string }; result: void };
|
||||||
pause_download: { args: { id: string }; result: void };
|
pause_download: { args: { id: string }; result: void };
|
||||||
resume_download: { args: { id: string }; result: boolean };
|
resume_download: { args: { id: string; queueId: string }; result: boolean };
|
||||||
remove_download: { args: { id: string; deleteAssets: boolean; preserveResumable?: boolean }; result: void };
|
remove_download: { args: { id: string; deleteAssets: boolean; preserveResumable?: boolean }; result: void };
|
||||||
detach_download_for_reconfigure: { args: { id: string }; 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 };
|
get_platform_info: { args: undefined; result: PlatformInfo };
|
||||||
approve_download_root: { args: { path: string }; result: string };
|
approve_download_root: { args: { path: string }; result: string };
|
||||||
set_prevent_sleep: { args: { prevent: boolean }; result: void };
|
set_prevent_sleep: { args: { prevent: boolean }; result: void };
|
||||||
|
set_power_preferences: {
|
||||||
|
args: { preventSystemSleep: boolean; preventDisplaySleep: boolean };
|
||||||
|
result: void;
|
||||||
|
};
|
||||||
perform_system_action: { args: { action: PostQueueAction }; result: void };
|
perform_system_action: { args: { action: PostQueueAction }; result: void };
|
||||||
ack_schedule_trigger: { args: { action: 'start' | 'stop'; key: string }; result: void };
|
ack_schedule_trigger: { args: { action: 'start' | 'stop'; key: string }; result: void };
|
||||||
set_concurrent_limit: { args: { limit: number }; result: void };
|
set_concurrent_limit: { args: { limit: number }; result: void };
|
||||||
|
set_queue_concurrency_limits: { args: { limits: QueueConcurrencyConfig[] }; result: void };
|
||||||
|
set_download_speed_limit: { args: { id: string; limit: string | null }; result: void };
|
||||||
set_global_speed_limit: { args: { limit: string | null }; result: void };
|
set_global_speed_limit: { args: { limit: string | null }; result: void };
|
||||||
request_automation_permission: { args: undefined; result: void };
|
request_automation_permission: { args: undefined; result: void };
|
||||||
check_automation_permission: { args: undefined; result: void };
|
check_automation_permission: { args: undefined; result: void };
|
||||||
@@ -66,6 +74,7 @@ type CommandMap = {
|
|||||||
get_extension_server_port: { args: undefined; result: number | null };
|
get_extension_server_port: { args: undefined; result: number | null };
|
||||||
hydrate_extension_pairing_token: { args: undefined; result: PairingTokenHydration };
|
hydrate_extension_pairing_token: { args: undefined; result: PairingTokenHydration };
|
||||||
get_session_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 };
|
regenerate_pairing_token: { args: undefined; result: PairingTokenHydration };
|
||||||
grant_keychain_access: { args: undefined; result: PairingTokenHydration };
|
grant_keychain_access: { args: undefined; result: PairingTokenHydration };
|
||||||
acknowledge_pairing_token_change: { args: undefined; result: void };
|
acknowledge_pairing_token_change: { args: undefined; result: void };
|
||||||
@@ -96,7 +105,7 @@ type CommandMap = {
|
|||||||
cancel_enqueue_generation: { args: { id: string; generation: string }; result: void };
|
cancel_enqueue_generation: { args: { id: string; generation: string }; result: void };
|
||||||
enqueue_many: { args: { items: EnqueueItem[] }; result: import('./bindings/EnqueueResult').EnqueueResult[] };
|
enqueue_many: { args: { items: EnqueueItem[] }; result: import('./bindings/EnqueueResult').EnqueueResult[] };
|
||||||
move_in_queue: { args: { id: string; queueId: string; direction: 'up' | 'down' }; result: string[] };
|
move_in_queue: { args: { id: string; queueId: string; direction: 'up' | 'down' }; result: string[] };
|
||||||
move_many_in_queue: { args: { ids: string[]; queueId: string; direction: 'up' | 'down' }; result: string[] };
|
move_many_in_queue: { args: { ids: string[]; queueId: string; direction: 'up' | 'down'; targetIndex?: number }; result: string[] };
|
||||||
remove_from_queue: { args: { id: string }; result: boolean };
|
remove_from_queue: { args: { id: string }; result: boolean };
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
+16
-2
@@ -1,7 +1,14 @@
|
|||||||
import { StrictMode } from "react";
|
import { StrictMode } from "react";
|
||||||
import { createRoot } from "react-dom/client";
|
import { createRoot } from "react-dom/client";
|
||||||
|
import "@fontsource-variable/inter/wght.css";
|
||||||
|
import "@fontsource-variable/noto-sans-hebrew/wght.css";
|
||||||
|
import "@fontsource-variable/noto-sans-sc/wght.css";
|
||||||
|
import "@fontsource-variable/outfit/wght.css";
|
||||||
|
import "@fontsource-variable/roboto/wght.css";
|
||||||
|
import "@fontsource-variable/vazirmatn/wght.css";
|
||||||
import "./index.css";
|
import "./index.css";
|
||||||
import App from "./App";
|
import App from "./App";
|
||||||
|
import { i18nReady } from "./i18n";
|
||||||
import { ErrorBoundary } from "./components/ErrorBoundary";
|
import { ErrorBoundary } from "./components/ErrorBoundary";
|
||||||
import { ToastProvider } from "./contexts/ToastContext";
|
import { ToastProvider } from "./contexts/ToastContext";
|
||||||
import { error as logError, warn as logWarn, initLogger } from "./utils/logger";
|
import { error as logError, warn as logWarn, initLogger } from "./utils/logger";
|
||||||
@@ -34,7 +41,9 @@ console.warn = (...values: unknown[]) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const rootElement = document.getElementById("root");
|
const rootElement = document.getElementById("root");
|
||||||
if (rootElement) {
|
const renderApp = () => {
|
||||||
|
if (!rootElement) return;
|
||||||
|
|
||||||
createRoot(rootElement).render(
|
createRoot(rootElement).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
<ErrorBoundary>
|
<ErrorBoundary>
|
||||||
@@ -44,7 +53,12 @@ if (rootElement) {
|
|||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
</StrictMode>,
|
</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.
|
// Prevent the webview's default context menu ("Reload", etc.) on right-click.
|
||||||
// Individual components that provide custom context menus call preventDefault()
|
// Individual components that provide custom context menus call preventDefault()
|
||||||
|
|||||||
@@ -90,6 +90,40 @@ describe('useDownloadProgressStore', () => {
|
|||||||
release();
|
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 () => {
|
it('clears progress when events arrive after a download row was removed', async () => {
|
||||||
const handlers: Record<string, (event: any) => void> = {};
|
const handlers: Record<string, (event: any) => void> = {};
|
||||||
vi.mocked(ipc.listenEvent).mockImplementation((event, handler) => {
|
vi.mocked(ipc.listenEvent).mockImplementation((event, handler) => {
|
||||||
|
|||||||
@@ -95,6 +95,8 @@ describe('useDownloadStore', () => {
|
|||||||
pendingAddHeaders: '',
|
pendingAddHeaders: '',
|
||||||
pendingAddCookies: '',
|
pendingAddCookies: '',
|
||||||
pendingAddMediaUrls: [],
|
pendingAddMediaUrls: [],
|
||||||
|
pendingAddBatch: false,
|
||||||
|
pendingAddBatchName: '',
|
||||||
pendingAddRequestContexts: {},
|
pendingAddRequestContexts: {},
|
||||||
pendingAddRequestVersion: 0,
|
pendingAddRequestVersion: 0,
|
||||||
});
|
});
|
||||||
@@ -124,6 +126,132 @@ describe('useDownloadStore', () => {
|
|||||||
expect(state.pendingAddRequestContexts['https://example.com/file.bin']?.media).toBe(false);
|
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', () => {
|
it('rejects empty and duplicate queue names', () => {
|
||||||
useDownloadStore.setState({
|
useDownloadStore.setState({
|
||||||
queues: [
|
queues: [
|
||||||
@@ -139,6 +267,166 @@ describe('useDownloadStore', () => {
|
|||||||
expect(useDownloadStore.getState().renameQueue('queue-a', '')).toBe(false);
|
expect(useDownloadStore.getState().renameQueue('queue-a', '')).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('persists a queue concurrency override only after backend synchronization', async () => {
|
||||||
|
useDownloadStore.setState({
|
||||||
|
queues: [
|
||||||
|
{ id: 'main', name: 'Main Queue', isMain: true },
|
||||||
|
{ id: 'queue-a', name: 'Downloads', isMain: false }
|
||||||
|
]
|
||||||
|
});
|
||||||
|
vi.mocked(ipc.invokeCommand).mockResolvedValue(undefined as never);
|
||||||
|
|
||||||
|
await useDownloadStore.getState().setQueueConcurrency('queue-a', 2);
|
||||||
|
|
||||||
|
expect(useDownloadStore.getState().queues).toEqual([
|
||||||
|
{ id: 'main', name: 'Main Queue', isMain: true },
|
||||||
|
{ id: 'queue-a', name: 'Downloads', isMain: false, maxConcurrent: 2 }
|
||||||
|
]);
|
||||||
|
expect(vi.mocked(ipc.invokeCommand)).toHaveBeenCalledWith(
|
||||||
|
'set_queue_concurrency_limits',
|
||||||
|
{
|
||||||
|
limits: [
|
||||||
|
{ id: 'main', maxConcurrent: null },
|
||||||
|
{ id: 'queue-a', maxConcurrent: 2 }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('retains the previous queue concurrency after backend synchronization fails', async () => {
|
||||||
|
useDownloadStore.setState({
|
||||||
|
queues: [
|
||||||
|
{ id: 'main', name: 'Main Queue', isMain: true },
|
||||||
|
{ id: 'queue-a', name: 'Downloads', isMain: false, maxConcurrent: 2 }
|
||||||
|
]
|
||||||
|
});
|
||||||
|
vi.mocked(ipc.invokeCommand).mockRejectedValue(new Error('backend unavailable'));
|
||||||
|
|
||||||
|
await expect(useDownloadStore.getState().setQueueConcurrency('queue-a', 3))
|
||||||
|
.rejects.toThrow('backend unavailable');
|
||||||
|
expect(useDownloadStore.getState().queues[1].maxConcurrent).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rebases queue concurrency updates when queue state changes during IPC', async () => {
|
||||||
|
useDownloadStore.setState({
|
||||||
|
queues: [
|
||||||
|
{ id: 'main', name: 'Main Queue', isMain: true },
|
||||||
|
{ id: 'queue-a', name: 'Downloads', isMain: false }
|
||||||
|
]
|
||||||
|
});
|
||||||
|
let releaseFirstSync!: () => void;
|
||||||
|
const firstSyncReleased = new Promise<void>(resolve => { releaseFirstSync = resolve; });
|
||||||
|
let syncCalls = 0;
|
||||||
|
vi.mocked(ipc.invokeCommand).mockImplementation(async command => {
|
||||||
|
if (command === 'set_queue_concurrency_limits') {
|
||||||
|
syncCalls += 1;
|
||||||
|
if (syncCalls === 1) await firstSyncReleased;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
});
|
||||||
|
|
||||||
|
const update = useDownloadStore.getState().setQueueConcurrency('queue-a', 3);
|
||||||
|
await vi.waitFor(() => expect(syncCalls).toBe(1));
|
||||||
|
useDownloadStore.setState(state => ({
|
||||||
|
queues: state.queues.filter(queue => queue.id !== 'queue-a')
|
||||||
|
}));
|
||||||
|
releaseFirstSync();
|
||||||
|
|
||||||
|
await expect(update).rejects.toThrow('Queue no longer exists.');
|
||||||
|
expect(useDownloadStore.getState().queues).toEqual([
|
||||||
|
{ id: 'main', name: 'Main Queue', isMain: true }
|
||||||
|
]);
|
||||||
|
expect(syncCalls).toBe(2);
|
||||||
|
const configCalls = vi.mocked(ipc.invokeCommand).mock.calls
|
||||||
|
.filter(([command]) => command === 'set_queue_concurrency_limits');
|
||||||
|
expect(configCalls[1]).toEqual([
|
||||||
|
'set_queue_concurrency_limits',
|
||||||
|
{ limits: [{ id: 'main', maxConcurrent: null }] }
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updates an active normal download after applying a live speed limit', async () => {
|
||||||
|
useDownloadStore.setState({
|
||||||
|
downloads: [{
|
||||||
|
id: 'live-speed',
|
||||||
|
status: 'downloading',
|
||||||
|
isMedia: false,
|
||||||
|
speedLimit: '512K'
|
||||||
|
}] as any[]
|
||||||
|
});
|
||||||
|
vi.mocked(ipc.invokeCommand).mockResolvedValue(undefined as never);
|
||||||
|
|
||||||
|
await useDownloadStore.getState().setDownloadSpeedLimit('live-speed', '2M');
|
||||||
|
|
||||||
|
expect(ipc.invokeCommand).toHaveBeenCalledWith('set_download_speed_limit', {
|
||||||
|
id: 'live-speed',
|
||||||
|
limit: '2M'
|
||||||
|
});
|
||||||
|
expect(useDownloadStore.getState().downloads[0].speedLimit).toBe('2M');
|
||||||
|
|
||||||
|
await useDownloadStore.getState().setDownloadSpeedLimit('live-speed', null);
|
||||||
|
const speedLimitCalls = vi.mocked(ipc.invokeCommand).mock.calls
|
||||||
|
.filter(([command]) => command === 'set_download_speed_limit');
|
||||||
|
expect(speedLimitCalls[speedLimitCalls.length - 1]).toEqual(['set_download_speed_limit', {
|
||||||
|
id: 'live-speed',
|
||||||
|
limit: null
|
||||||
|
}]);
|
||||||
|
expect(useDownloadStore.getState().downloads[0].speedLimit).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects live speed changes for media and inactive downloads', async () => {
|
||||||
|
useDownloadStore.setState({
|
||||||
|
downloads: [
|
||||||
|
{ id: 'media-speed', status: 'downloading', isMedia: true, speedLimit: '1M' },
|
||||||
|
{ id: 'paused-speed', status: 'paused', isMedia: false, speedLimit: '1M' }
|
||||||
|
] as any[]
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(useDownloadStore.getState().setDownloadSpeedLimit('media-speed', '2M'))
|
||||||
|
.rejects.toThrow('media downloads');
|
||||||
|
await expect(useDownloadStore.getState().setDownloadSpeedLimit('paused-speed', '2M'))
|
||||||
|
.rejects.toThrow('active download');
|
||||||
|
expect(ipc.invokeCommand).not.toHaveBeenCalledWith('set_download_speed_limit', expect.anything());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the prior live speed limit when the backend rejects the update', async () => {
|
||||||
|
useDownloadStore.setState({
|
||||||
|
downloads: [{
|
||||||
|
id: 'live-speed-failure',
|
||||||
|
status: 'downloading',
|
||||||
|
isMedia: false,
|
||||||
|
speedLimit: '512K'
|
||||||
|
}] as any[]
|
||||||
|
});
|
||||||
|
vi.mocked(ipc.invokeCommand).mockImplementation(async command => {
|
||||||
|
if (command === 'set_download_speed_limit') throw new Error('aria2 unavailable');
|
||||||
|
return undefined;
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(useDownloadStore.getState().setDownloadSpeedLimit('live-speed-failure', '2M'))
|
||||||
|
.rejects.toThrow('aria2 unavailable');
|
||||||
|
expect(useDownloadStore.getState().downloads[0].speedLimit).toBe('512K');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('coalesces duplicate live speed updates for one download', async () => {
|
||||||
|
useDownloadStore.setState({
|
||||||
|
downloads: [{ id: 'live-speed-duplicate', status: 'downloading', isMedia: false }] as any[]
|
||||||
|
});
|
||||||
|
let releaseBackend!: () => void;
|
||||||
|
const backendFinished = new Promise<void>(resolve => { releaseBackend = resolve; });
|
||||||
|
vi.mocked(ipc.invokeCommand).mockImplementation(async () => backendFinished);
|
||||||
|
|
||||||
|
const first = useDownloadStore.getState().setDownloadSpeedLimit('live-speed-duplicate', '2M');
|
||||||
|
const second = useDownloadStore.getState().setDownloadSpeedLimit('live-speed-duplicate', '3M');
|
||||||
|
expect(second).toBe(first);
|
||||||
|
releaseBackend();
|
||||||
|
await first;
|
||||||
|
|
||||||
|
expect(vi.mocked(ipc.invokeCommand).mock.calls.filter(([command]) => command === 'set_download_speed_limit'))
|
||||||
|
.toHaveLength(1);
|
||||||
|
expect(useDownloadStore.getState().downloads[0].speedLimit).toBe('2M');
|
||||||
|
});
|
||||||
|
|
||||||
it('normalizes malformed persisted queues around one canonical main queue', () => {
|
it('normalizes malformed persisted queues around one canonical main queue', () => {
|
||||||
expect(normalizePersistedQueues([
|
expect(normalizePersistedQueues([
|
||||||
{ id: 'custom-a', name: ' Downloads ', isMain: false },
|
{ id: 'custom-a', name: ' Downloads ', isMain: false },
|
||||||
@@ -158,6 +446,47 @@ describe('useDownloadStore', () => {
|
|||||||
]).queueIdRemap.get('legacy-main')).toBe('00000000-0000-0000-0000-000000000001');
|
]).queueIdRemap.get('legacy-main')).toBe('00000000-0000-0000-0000-000000000001');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('keeps only valid persisted queue concurrency overrides', () => {
|
||||||
|
expect(normalizePersistedQueues([
|
||||||
|
{ id: 'main', name: 'Main', isMain: true, maxConcurrent: 4 },
|
||||||
|
{ id: 'valid', name: 'Valid', isMain: false, maxConcurrent: 12 },
|
||||||
|
{ id: 'zero', name: 'Zero', isMain: false, maxConcurrent: 0 },
|
||||||
|
{ id: 'large', name: 'Large', isMain: false, maxConcurrent: 13 },
|
||||||
|
{ id: 'null', name: 'Null', isMain: false, maxConcurrent: null }
|
||||||
|
])).toEqual([
|
||||||
|
{ id: '00000000-0000-0000-0000-000000000001', name: 'Main', isMain: true, maxConcurrent: 4 },
|
||||||
|
{ id: 'valid', name: 'Valid', isMain: false, maxConcurrent: 12 },
|
||||||
|
{ id: 'zero', name: 'Zero', isMain: false },
|
||||||
|
{ id: 'large', name: 'Large', isMain: false },
|
||||||
|
{ id: 'null', name: 'Null', isMain: false }
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('synchronizes normalized queue limits before startup resume can run', async () => {
|
||||||
|
vi.mocked(ipc.invokeCommand).mockImplementation(async (cmd: string) => {
|
||||||
|
if (cmd === 'db_get_all_queues') {
|
||||||
|
return [
|
||||||
|
JSON.stringify({ id: 'main', name: 'Main', isMain: true, maxConcurrent: 4 }),
|
||||||
|
JSON.stringify({ id: 'queue-a', name: 'Queue A', isMain: false, maxConcurrent: 0 })
|
||||||
|
];
|
||||||
|
}
|
||||||
|
if (cmd === 'db_get_all_downloads') return [];
|
||||||
|
return undefined;
|
||||||
|
});
|
||||||
|
|
||||||
|
await useDownloadStore.getState().initDB();
|
||||||
|
|
||||||
|
expect(vi.mocked(ipc.invokeCommand)).toHaveBeenCalledWith(
|
||||||
|
'set_queue_concurrency_limits',
|
||||||
|
{
|
||||||
|
limits: [
|
||||||
|
{ id: '00000000-0000-0000-0000-000000000001', maxConcurrent: 4 },
|
||||||
|
{ id: 'queue-a', maxConcurrent: null }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it('remaps persisted downloads when queue records are malformed or missing', async () => {
|
it('remaps persisted downloads when queue records are malformed or missing', async () => {
|
||||||
vi.mocked(ipc.invokeCommand).mockImplementation(async (cmd: string) => {
|
vi.mocked(ipc.invokeCommand).mockImplementation(async (cmd: string) => {
|
||||||
if (cmd === 'db_get_all_queues') {
|
if (cmd === 'db_get_all_queues') {
|
||||||
@@ -603,11 +932,57 @@ describe('useDownloadStore', () => {
|
|||||||
const calls = vi.mocked(ipc.invokeCommand).mock.calls;
|
const calls = vi.mocked(ipc.invokeCommand).mock.calls;
|
||||||
expect(calls.some(c => c[0] === 'resume_download')).toBe(true);
|
expect(calls.some(c => c[0] === 'resume_download')).toBe(true);
|
||||||
expect(calls.some(c => c[0] === 'enqueue_download')).toBe(true);
|
expect(calls.some(c => c[0] === 'enqueue_download')).toBe(true);
|
||||||
|
expect(calls.find(c => c[0] === 'resume_download')?.[1]).toEqual({
|
||||||
|
id: 'resume-generation',
|
||||||
|
queueId: 'MAIN'
|
||||||
|
});
|
||||||
expect(enqueueGeneration).toBe('1');
|
expect(enqueueGeneration).toBe('1');
|
||||||
expect(useDownloadStore.getState().downloads[0].lastTry).toEqual(expect.any(String));
|
expect(useDownloadStore.getState().downloads[0].lastTry).toEqual(expect.any(String));
|
||||||
expect(useDownloadStore.getState().backendRegisteredIds.has('resume-generation')).toBe(true); // Re-registered by dispatchItem
|
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 () => {
|
it('does not re-enqueue when the existing resume RPC fails', async () => {
|
||||||
useDownloadStore.setState({
|
useDownloadStore.setState({
|
||||||
downloads: [
|
downloads: [
|
||||||
@@ -1185,6 +1560,48 @@ describe('useDownloadStore', () => {
|
|||||||
).toBe(false);
|
).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 () => {
|
it('starts and pauses all items regardless of legacy missing queue ids', async () => {
|
||||||
useDownloadStore.setState({
|
useDownloadStore.setState({
|
||||||
downloads: [
|
downloads: [
|
||||||
@@ -1363,6 +1780,102 @@ describe('useDownloadStore', () => {
|
|||||||
expect(new Set(positions).size).toBe(4);
|
expect(new Set(positions).size).toBe(4);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('rolls back and rejects a failed keyboard or header queue move', async () => {
|
||||||
|
useDownloadStore.setState({
|
||||||
|
downloads: [
|
||||||
|
{ id: 'first', status: 'queued', queueId: 'move-failure-queue', queuePosition: 0 },
|
||||||
|
{ id: 'second', status: 'queued', queueId: 'move-failure-queue', queuePosition: 1 }
|
||||||
|
] as any[],
|
||||||
|
backendRegisteredIds: new Set(['second'])
|
||||||
|
});
|
||||||
|
vi.mocked(ipc.invokeCommand).mockRejectedValue(new Error('backend unavailable'));
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
useDownloadStore.getState().moveInQueue('second', 'up')
|
||||||
|
).rejects.toThrow('backend unavailable');
|
||||||
|
expect(useDownloadStore.getState().downloads.map(item => item.queuePosition)).toEqual([0, 1]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('translates a drag target around staged rows before the atomic backend move', async () => {
|
||||||
|
useDownloadStore.setState({
|
||||||
|
downloads: [
|
||||||
|
{ id: 'a', status: 'queued', queueId: 'drag-queue', queuePosition: 0 },
|
||||||
|
{ id: 'staged', status: 'staged', queueId: 'drag-queue', queuePosition: 1 },
|
||||||
|
{ id: 'b', status: 'queued', queueId: 'drag-queue', queuePosition: 2 },
|
||||||
|
{ id: 'c', status: 'queued', queueId: 'drag-queue', queuePosition: 3 }
|
||||||
|
] as any[],
|
||||||
|
backendRegisteredIds: new Set(['a', 'b', 'c']),
|
||||||
|
pendingOrder: ['a', 'b', 'c']
|
||||||
|
});
|
||||||
|
vi.mocked(ipc.invokeCommand).mockImplementation(async (command: string) => {
|
||||||
|
if (command === 'move_many_in_queue') return ['c', 'a', 'b'];
|
||||||
|
if (command === 'get_pending_order') return ['c', 'a', 'b'];
|
||||||
|
return undefined;
|
||||||
|
});
|
||||||
|
|
||||||
|
await useDownloadStore.getState().moveManyInQueueToPosition('c', 'drag-queue', 0);
|
||||||
|
|
||||||
|
expect(vi.mocked(ipc.invokeCommand)).toHaveBeenCalledWith('move_many_in_queue', {
|
||||||
|
ids: ['c'],
|
||||||
|
queueId: 'drag-queue',
|
||||||
|
direction: 'up',
|
||||||
|
targetIndex: 0
|
||||||
|
});
|
||||||
|
expect(useDownloadStore.getState().downloads.map(item => item.id).sort()).toEqual(['a', 'b', 'c', 'staged']);
|
||||||
|
expect(useDownloadStore.getState().downloads.find(item => item.id === 'c')?.queuePosition).toBe(0);
|
||||||
|
expect(useDownloadStore.getState().downloads.find(item => item.id === 'staged')?.queuePosition).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps a drag anchored when the queue changes before its serialized operation runs', async () => {
|
||||||
|
useDownloadStore.setState({
|
||||||
|
downloads: [
|
||||||
|
{ id: 'a', status: 'queued', queueId: 'concurrent-drag-queue', queuePosition: 0 },
|
||||||
|
{ id: 'b', status: 'queued', queueId: 'concurrent-drag-queue', queuePosition: 1 },
|
||||||
|
{ id: 'c', status: 'queued', queueId: 'concurrent-drag-queue', queuePosition: 2 }
|
||||||
|
] as any[],
|
||||||
|
backendRegisteredIds: new Set()
|
||||||
|
});
|
||||||
|
|
||||||
|
const operation = useDownloadStore.getState().moveManyInQueueToPosition(
|
||||||
|
'b',
|
||||||
|
'concurrent-drag-queue',
|
||||||
|
2,
|
||||||
|
'c'
|
||||||
|
);
|
||||||
|
useDownloadStore.setState({
|
||||||
|
downloads: [
|
||||||
|
{ id: 'a', status: 'queued', queueId: 'concurrent-drag-queue', queuePosition: 0 },
|
||||||
|
{ id: 'b', status: 'queued', queueId: 'concurrent-drag-queue', queuePosition: 1 },
|
||||||
|
{ id: 'new', status: 'queued', queueId: 'concurrent-drag-queue', queuePosition: 2 },
|
||||||
|
{ id: 'c', status: 'queued', queueId: 'concurrent-drag-queue', queuePosition: 3 }
|
||||||
|
] as any[]
|
||||||
|
});
|
||||||
|
|
||||||
|
await operation;
|
||||||
|
|
||||||
|
expect(useDownloadStore.getState().downloads
|
||||||
|
.filter(item => item.queueId === 'concurrent-drag-queue')
|
||||||
|
.sort((left, right) => (left.queuePosition ?? 0) - (right.queuePosition ?? 0))
|
||||||
|
.map(item => item.id)
|
||||||
|
).toEqual(['a', 'new', 'b', 'c']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rolls back a failed drag move and rejects so the UI can report the failure', async () => {
|
||||||
|
useDownloadStore.setState({
|
||||||
|
downloads: [
|
||||||
|
{ id: 'first', status: 'queued', queueId: 'rollback-queue', queuePosition: 0 },
|
||||||
|
{ id: 'second', status: 'queued', queueId: 'rollback-queue', queuePosition: 1 }
|
||||||
|
] as any[],
|
||||||
|
backendRegisteredIds: new Set(['first', 'second'])
|
||||||
|
});
|
||||||
|
vi.mocked(ipc.invokeCommand).mockRejectedValue(new Error('backend unavailable'));
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
useDownloadStore.getState().moveManyInQueueToPosition('second', 'rollback-queue', 0)
|
||||||
|
).rejects.toThrow('backend unavailable');
|
||||||
|
expect(useDownloadStore.getState().downloads.map(item => item.queuePosition)).toEqual([0, 1]);
|
||||||
|
});
|
||||||
|
|
||||||
it('detaches a registered queued item through the backend before reassigning it', async () => {
|
it('detaches a registered queued item through the backend before reassigning it', async () => {
|
||||||
useDownloadStore.setState({
|
useDownloadStore.setState({
|
||||||
downloads: [{
|
downloads: [{
|
||||||
@@ -1510,7 +2023,9 @@ describe('useDownloadStore', () => {
|
|||||||
{ url: 'https://mail.google.com/', cookies: 'SID=mail-session' },
|
{ url: 'https://mail.google.com/', cookies: 'SID=mail-session' },
|
||||||
{ url: 'https://accounts.google.com/', cookies: 'SID=account-session' }
|
{ url: 'https://accounts.google.com/', cookies: 'SID=account-session' }
|
||||||
],
|
],
|
||||||
media: false
|
media: false,
|
||||||
|
batch: false,
|
||||||
|
batch_name: null
|
||||||
});
|
});
|
||||||
|
|
||||||
const state = useDownloadStore.getState();
|
const state = useDownloadStore.getState();
|
||||||
@@ -1546,7 +2061,9 @@ describe('useDownloadStore', () => {
|
|||||||
headers: 'User-Agent: Firefox Test',
|
headers: 'User-Agent: Firefox Test',
|
||||||
cookies: null,
|
cookies: null,
|
||||||
cookie_scopes: null,
|
cookie_scopes: null,
|
||||||
media: false
|
media: false,
|
||||||
|
batch: false,
|
||||||
|
batch_name: null
|
||||||
});
|
});
|
||||||
|
|
||||||
const state = useDownloadStore.getState();
|
const state = useDownloadStore.getState();
|
||||||
@@ -1567,7 +2084,9 @@ describe('useDownloadStore', () => {
|
|||||||
headers: 'User-Agent: Test',
|
headers: 'User-Agent: Test',
|
||||||
cookies: 'session=secret',
|
cookies: 'session=secret',
|
||||||
cookie_scopes: null,
|
cookie_scopes: null,
|
||||||
media: false
|
media: false,
|
||||||
|
batch: false,
|
||||||
|
batch_name: null
|
||||||
});
|
});
|
||||||
|
|
||||||
const state = useDownloadStore.getState();
|
const state = useDownloadStore.getState();
|
||||||
@@ -1580,6 +2099,30 @@ describe('useDownloadStore', () => {
|
|||||||
expect(state.pendingAddMediaUrls).toEqual([]);
|
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 () => {
|
it('keeps each extension handoff context attached to its own URL while the Add Modal is open', async () => {
|
||||||
await useDownloadStore.getState().handleExtensionDownload({
|
await useDownloadStore.getState().handleExtensionDownload({
|
||||||
urls: ['https://first.example/file.zip'],
|
urls: ['https://first.example/file.zip'],
|
||||||
@@ -1589,7 +2132,9 @@ describe('useDownloadStore', () => {
|
|||||||
headers: 'User-Agent: First Browser',
|
headers: 'User-Agent: First Browser',
|
||||||
cookies: 'first=session',
|
cookies: 'first=session',
|
||||||
cookie_scopes: null,
|
cookie_scopes: null,
|
||||||
media: false
|
media: false,
|
||||||
|
batch: false,
|
||||||
|
batch_name: null
|
||||||
});
|
});
|
||||||
await useDownloadStore.getState().handleExtensionDownload({
|
await useDownloadStore.getState().handleExtensionDownload({
|
||||||
urls: ['https://second.example/file.zip'],
|
urls: ['https://second.example/file.zip'],
|
||||||
@@ -1599,7 +2144,9 @@ describe('useDownloadStore', () => {
|
|||||||
headers: 'User-Agent: Second Browser',
|
headers: 'User-Agent: Second Browser',
|
||||||
cookies: 'second=session',
|
cookies: 'second=session',
|
||||||
cookie_scopes: null,
|
cookie_scopes: null,
|
||||||
media: false
|
media: false,
|
||||||
|
batch: false,
|
||||||
|
batch_name: null
|
||||||
});
|
});
|
||||||
|
|
||||||
const state = useDownloadStore.getState();
|
const state = useDownloadStore.getState();
|
||||||
@@ -1633,10 +2180,12 @@ describe('useDownloadStore', () => {
|
|||||||
referer: 'https://adult.example/watch/123',
|
referer: 'https://adult.example/watch/123',
|
||||||
silent: false,
|
silent: false,
|
||||||
filename: null,
|
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)}`,
|
cookies: `oversized=${'x'.repeat(64 * 1024)}`,
|
||||||
cookie_scopes: null,
|
cookie_scopes: null,
|
||||||
media: true
|
media: true,
|
||||||
|
batch: false,
|
||||||
|
batch_name: null
|
||||||
});
|
});
|
||||||
|
|
||||||
const state = useDownloadStore.getState();
|
const state = useDownloadStore.getState();
|
||||||
@@ -1656,7 +2205,9 @@ describe('useDownloadStore', () => {
|
|||||||
headers: null,
|
headers: null,
|
||||||
cookies: 'session=secret',
|
cookies: 'session=secret',
|
||||||
cookie_scopes: null,
|
cookie_scopes: null,
|
||||||
media: false
|
media: false,
|
||||||
|
batch: false,
|
||||||
|
batch_name: null
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(useDownloadStore.getState().pendingAddCookies).toBe('session=secret');
|
expect(useDownloadStore.getState().pendingAddCookies).toBe('session=secret');
|
||||||
@@ -1673,7 +2224,9 @@ describe('useDownloadStore', () => {
|
|||||||
cookie_scopes: [
|
cookie_scopes: [
|
||||||
{ url: 'https://media.example/', cookies: 'session=secret' }
|
{ url: 'https://media.example/', cookies: 'session=secret' }
|
||||||
],
|
],
|
||||||
media: true
|
media: true,
|
||||||
|
batch: false,
|
||||||
|
batch_name: null
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(useDownloadStore.getState().pendingAddRequestContexts['https://media.example/watch/123']?.cookieScopes)
|
expect(useDownloadStore.getState().pendingAddRequestContexts['https://media.example/watch/123']?.cookieScopes)
|
||||||
@@ -1690,7 +2243,9 @@ describe('useDownloadStore', () => {
|
|||||||
headers: 'Authorization: secret',
|
headers: 'Authorization: secret',
|
||||||
cookies: 'session=secret',
|
cookies: 'session=secret',
|
||||||
cookie_scopes: null,
|
cookie_scopes: null,
|
||||||
media: false
|
media: false,
|
||||||
|
batch: false,
|
||||||
|
batch_name: null
|
||||||
});
|
});
|
||||||
await useDownloadStore.getState().handleExtensionDownload({
|
await useDownloadStore.getState().handleExtensionDownload({
|
||||||
urls: [url],
|
urls: [url],
|
||||||
@@ -1700,7 +2255,9 @@ describe('useDownloadStore', () => {
|
|||||||
headers: null,
|
headers: null,
|
||||||
cookies: null,
|
cookies: null,
|
||||||
cookie_scopes: null,
|
cookie_scopes: null,
|
||||||
media: false
|
media: false,
|
||||||
|
batch: false,
|
||||||
|
batch_name: null
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(useDownloadStore.getState().pendingAddRequestContexts[url]).toEqual({
|
expect(useDownloadStore.getState().pendingAddRequestContexts[url]).toEqual({
|
||||||
@@ -1728,7 +2285,9 @@ describe('useDownloadStore', () => {
|
|||||||
headers: 'User-Agent: Firefox Test',
|
headers: 'User-Agent: Firefox Test',
|
||||||
cookies: 'session=secret',
|
cookies: 'session=secret',
|
||||||
cookie_scopes: null,
|
cookie_scopes: null,
|
||||||
media: true
|
media: true,
|
||||||
|
batch: false,
|
||||||
|
batch_name: null
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(useDownloadStore.getState().pendingAddMediaUrls).toEqual([
|
expect(useDownloadStore.getState().pendingAddMediaUrls).toEqual([
|
||||||
@@ -1748,7 +2307,9 @@ describe('useDownloadStore', () => {
|
|||||||
headers: 'User-Agent: Firefox Test',
|
headers: 'User-Agent: Firefox Test',
|
||||||
cookies: null,
|
cookies: null,
|
||||||
cookie_scopes: null,
|
cookie_scopes: null,
|
||||||
media: false
|
media: false,
|
||||||
|
batch: false,
|
||||||
|
batch_name: null
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(useDownloadStore.getState().pendingAddMediaUrls).toEqual([]);
|
expect(useDownloadStore.getState().pendingAddMediaUrls).toEqual([]);
|
||||||
|
|||||||
+539
-167
@@ -14,6 +14,12 @@ import {
|
|||||||
resolveCategoryDestination
|
resolveCategoryDestination
|
||||||
} from '../utils/downloadLocations';
|
} from '../utils/downloadLocations';
|
||||||
import { canPauseDownload, canStartDownload } from '../utils/downloadActions';
|
import { canPauseDownload, canStartDownload } from '../utils/downloadActions';
|
||||||
|
import { updateDockBadge } from '../utils/dockBadge';
|
||||||
|
import {
|
||||||
|
moveSelectedBlockToIndex,
|
||||||
|
targetIndexForDesiredOrder
|
||||||
|
} from '../utils/queueOrdering';
|
||||||
|
import i18n from '../i18n';
|
||||||
|
|
||||||
export type { DownloadCategory } from '../utils/downloads';
|
export type { DownloadCategory } from '../utils/downloads';
|
||||||
|
|
||||||
@@ -22,6 +28,13 @@ const downloadLifecycleGenerations = new Map<string, bigint>();
|
|||||||
const queueReorderPromises = new Map<string, Promise<void>>();
|
const queueReorderPromises = new Map<string, Promise<void>>();
|
||||||
const queueStartPromises = new Map<string, Promise<string[]>>();
|
const queueStartPromises = new Map<string, Promise<string[]>>();
|
||||||
const queueControlGenerations = new Map<string, number>();
|
const queueControlGenerations = new Map<string, number>();
|
||||||
|
let queueConfigurationQueue: Promise<void> = Promise.resolve();
|
||||||
|
type DownloadLifecycleOperation = {
|
||||||
|
kind: string;
|
||||||
|
promise: Promise<unknown>;
|
||||||
|
};
|
||||||
|
const downloadLifecycleOperations = new Map<string, DownloadLifecycleOperation>();
|
||||||
|
const preemptDispatch = ['dispatch'] as const;
|
||||||
let pendingStartupResume: Promise<void> | null = null;
|
let pendingStartupResume: Promise<void> | null = null;
|
||||||
|
|
||||||
type DownloadControlIntent = 'pause' | 'resume';
|
type DownloadControlIntent = 'pause' | 'resume';
|
||||||
@@ -47,6 +60,49 @@ export const clearDownloadControlIntents = (): void => {
|
|||||||
downloadControlIntents.clear();
|
downloadControlIntents.clear();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const runDownloadLifecycleOperation = <T>(
|
||||||
|
id: string,
|
||||||
|
kind: string,
|
||||||
|
operation: () => Promise<T>,
|
||||||
|
coalesce = true,
|
||||||
|
preemptKinds: readonly string[] = []
|
||||||
|
): Promise<T> => {
|
||||||
|
const current = downloadLifecycleOperations.get(id);
|
||||||
|
if (coalesce && current?.kind === kind) {
|
||||||
|
return current.promise as Promise<T>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const operationPromise = current && !preemptKinds.includes(current.kind)
|
||||||
|
? current.promise.catch(() => undefined).then(operation)
|
||||||
|
: operation();
|
||||||
|
const trackedOperation = operationPromise.finally(() => {
|
||||||
|
if (downloadLifecycleOperations.get(id)?.promise === trackedOperation) {
|
||||||
|
downloadLifecycleOperations.delete(id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
downloadLifecycleOperations.set(id, { kind, promise: trackedOperation });
|
||||||
|
return trackedOperation;
|
||||||
|
};
|
||||||
|
|
||||||
|
const runDownloadLifecycleOperations = <T>(
|
||||||
|
ids: string[],
|
||||||
|
kind: string,
|
||||||
|
operation: () => Promise<T>
|
||||||
|
): Promise<T> => {
|
||||||
|
const orderedIds = [...new Set(ids)].sort();
|
||||||
|
const runNext = (index: number): Promise<T> => {
|
||||||
|
if (index === orderedIds.length) return operation();
|
||||||
|
return runDownloadLifecycleOperation(
|
||||||
|
orderedIds[index],
|
||||||
|
kind,
|
||||||
|
() => runNext(index + 1),
|
||||||
|
false,
|
||||||
|
preemptDispatch
|
||||||
|
);
|
||||||
|
};
|
||||||
|
return runNext(0);
|
||||||
|
};
|
||||||
|
|
||||||
const waitForPendingStartupResume = async (): Promise<void> => {
|
const waitForPendingStartupResume = async (): Promise<void> => {
|
||||||
const pending = pendingStartupResume;
|
const pending = pendingStartupResume;
|
||||||
if (pending) await pending.catch(() => undefined);
|
if (pending) await pending.catch(() => undefined);
|
||||||
@@ -158,12 +214,21 @@ export class SystemProxyResolutionError extends Error {
|
|||||||
const isSystemProxyConfigurationError = (error: unknown): boolean =>
|
const isSystemProxyConfigurationError = (error: unknown): boolean =>
|
||||||
error instanceof SystemProxyResolutionError;
|
error instanceof SystemProxyResolutionError;
|
||||||
|
|
||||||
const stripCookieHeaders = (value: string | null | undefined): string =>
|
const stripSensitiveMediaHeaders = (value: string | null | undefined): string =>
|
||||||
(value || '')
|
(value || '')
|
||||||
.split(/\r?\n/)
|
.split(/\r?\n/)
|
||||||
.filter(line => {
|
.filter(line => {
|
||||||
const separator = line.indexOf(':');
|
const separator = line.indexOf(':');
|
||||||
return separator < 0 || line.slice(0, separator).trim().toLowerCase() !== 'cookie';
|
if (separator < 0) return true;
|
||||||
|
const name = line.slice(0, separator).trim().toLowerCase();
|
||||||
|
return ![
|
||||||
|
'authorization',
|
||||||
|
'cookie',
|
||||||
|
'cookie2',
|
||||||
|
'proxy-authorization',
|
||||||
|
'set-cookie',
|
||||||
|
'set-cookie2'
|
||||||
|
].includes(name);
|
||||||
})
|
})
|
||||||
.join('\n')
|
.join('\n')
|
||||||
.trim();
|
.trim();
|
||||||
@@ -192,7 +257,7 @@ const speedLimitForDispatch = (
|
|||||||
return normalizeSpeedLimitForBackend(globalSpeedLimit);
|
return normalizeSpeedLimitForBackend(globalSpeedLimit);
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function dispatchItem(id: string, proxyOverride?: string | null): Promise<boolean> {
|
async function dispatchItemInternal(id: string, proxyOverride?: string | null): Promise<boolean> {
|
||||||
await waitForPendingStartupResume();
|
await waitForPendingStartupResume();
|
||||||
if (backendDispatchPromises.has(id)) return backendDispatchPromises.get(id)!;
|
if (backendDispatchPromises.has(id)) return backendDispatchPromises.get(id)!;
|
||||||
|
|
||||||
@@ -204,6 +269,7 @@ export async function dispatchItem(id: string, proxyOverride?: string | null): P
|
|||||||
const item = state.downloads.find(d => d.id === id);
|
const item = state.downloads.find(d => d.id === id);
|
||||||
if (!item) return false;
|
if (!item) return false;
|
||||||
if (state.backendRegisteredIds.has(id)) return true;
|
if (state.backendRegisteredIds.has(id)) return true;
|
||||||
|
if (!['ready', 'staged', 'failed', 'queued'].includes(item.status)) return false;
|
||||||
lifecycleGeneration = currentDownloadLifecycle(id);
|
lifecycleGeneration = currentDownloadLifecycle(id);
|
||||||
|
|
||||||
const settings = useSettingsStore.getState();
|
const settings = useSettingsStore.getState();
|
||||||
@@ -303,6 +369,18 @@ export async function dispatchItem(id: string, proxyOverride?: string | null): P
|
|||||||
return promise;
|
return promise;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function dispatchItem(id: string, proxyOverride?: string | null): Promise<boolean> {
|
||||||
|
const current = downloadLifecycleOperations.get(id);
|
||||||
|
if (current && current.kind !== 'dispatch') {
|
||||||
|
return current.promise.then(() => false, () => false);
|
||||||
|
}
|
||||||
|
return runDownloadLifecycleOperation(
|
||||||
|
id,
|
||||||
|
'dispatch',
|
||||||
|
() => dispatchItemInternal(id, proxyOverride)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export const normalizeCustomProxy = (host: string, port: number): string | null => {
|
export const normalizeCustomProxy = (host: string, port: number): string | null => {
|
||||||
const trimmedHost = host.trim();
|
const trimmedHost = host.trim();
|
||||||
const normalizedPort = Number.isFinite(port) ? Math.trunc(port) : NaN;
|
const normalizedPort = Number.isFinite(port) ? Math.trunc(port) : NaN;
|
||||||
@@ -424,7 +502,7 @@ export const getSiteLogin = (url: string, settings: ReturnType<typeof useSetting
|
|||||||
const syncSystemIntegrations = () => {
|
const syncSystemIntegrations = () => {
|
||||||
const settings = useSettingsStore.getState();
|
const settings = useSettingsStore.getState();
|
||||||
const activeCount = useDownloadStore.getState().downloads.filter(d => isTransferActiveStatus(d.status)).length;
|
const activeCount = useDownloadStore.getState().downloads.filter(d => isTransferActiveStatus(d.status)).length;
|
||||||
invoke('update_dock_badge', { count: settings.showDockBadge ? activeCount : 0 }).catch(() => {});
|
updateDockBadge(settings.showDockBadge ? activeCount : 0).catch(() => {});
|
||||||
};
|
};
|
||||||
|
|
||||||
const effectiveDestinationForItem = async (
|
const effectiveDestinationForItem = async (
|
||||||
@@ -508,18 +586,39 @@ export const normalizePersistedDownloadProgress = (download: DownloadItem): Down
|
|||||||
export type { DownloadStatus };
|
export type { DownloadStatus };
|
||||||
export const MAIN_QUEUE_ID = '00000000-0000-0000-0000-000000000001';
|
export const MAIN_QUEUE_ID = '00000000-0000-0000-0000-000000000001';
|
||||||
const DEFAULT_MAIN_QUEUE_NAME = 'Main Queue';
|
const DEFAULT_MAIN_QUEUE_NAME = 'Main Queue';
|
||||||
|
const MAX_QUEUE_CONCURRENT = 12;
|
||||||
|
|
||||||
const queueNameKey = (name: string): string => name.trim().toLowerCase();
|
const queueNameKey = (name: string): string => name.trim().toLowerCase();
|
||||||
|
|
||||||
export const normalizePersistedQueueState = (queues: Queue[]) => {
|
const normalizeQueueConcurrency = (value: unknown): number | undefined => {
|
||||||
|
if (typeof value !== 'number' || !Number.isInteger(value)) return undefined;
|
||||||
|
return value >= 1 && value <= MAX_QUEUE_CONCURRENT ? value : undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
type PersistedQueue = Omit<Queue, 'maxConcurrent'> & {
|
||||||
|
maxConcurrent?: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const queueWithNormalizedConcurrency = (queue: PersistedQueue): Queue => {
|
||||||
|
const maxConcurrent = normalizeQueueConcurrency(queue.maxConcurrent);
|
||||||
|
return maxConcurrent === undefined
|
||||||
|
? { id: queue.id, name: queue.name, isMain: queue.isMain }
|
||||||
|
: { id: queue.id, name: queue.name, isMain: queue.isMain, maxConcurrent };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const normalizePersistedQueueState = (queues: PersistedQueue[]) => {
|
||||||
const validQueues = queues.filter(queue =>
|
const validQueues = queues.filter(queue =>
|
||||||
queue && typeof queue.id === 'string' && typeof queue.name === 'string'
|
queue && typeof queue.id === 'string' && typeof queue.name === 'string'
|
||||||
);
|
).map(queueWithNormalizedConcurrency);
|
||||||
const persistedMain = validQueues.find(queue => queue.id === MAIN_QUEUE_ID)
|
const persistedMain = validQueues.find(queue => queue.id === MAIN_QUEUE_ID)
|
||||||
|| validQueues.find(queue => queue.isMain);
|
|| validQueues.find(queue => queue.isMain);
|
||||||
const persistedMainId = persistedMain?.id.trim();
|
const persistedMainId = persistedMain?.id.trim();
|
||||||
const mainName = persistedMain?.name.trim() || DEFAULT_MAIN_QUEUE_NAME;
|
const mainName = persistedMain?.name.trim() || DEFAULT_MAIN_QUEUE_NAME;
|
||||||
const normalized: Queue[] = [{ id: MAIN_QUEUE_ID, name: mainName, isMain: true }];
|
const normalizedMain: Queue = { id: MAIN_QUEUE_ID, name: mainName, isMain: true };
|
||||||
|
if (persistedMain?.maxConcurrent !== undefined) {
|
||||||
|
normalizedMain.maxConcurrent = persistedMain.maxConcurrent;
|
||||||
|
}
|
||||||
|
const normalized: Queue[] = [normalizedMain];
|
||||||
const seenIds = new Set([MAIN_QUEUE_ID]);
|
const seenIds = new Set([MAIN_QUEUE_ID]);
|
||||||
const seenNames = new Set([queueNameKey(mainName)]);
|
const seenNames = new Set([queueNameKey(mainName)]);
|
||||||
const queueIdRemap = new Map<string, string>();
|
const queueIdRemap = new Map<string, string>();
|
||||||
@@ -543,15 +642,37 @@ export const normalizePersistedQueueState = (queues: Queue[]) => {
|
|||||||
}
|
}
|
||||||
seenIds.add(id);
|
seenIds.add(id);
|
||||||
seenNames.add(queueNameKey(name));
|
seenNames.add(queueNameKey(name));
|
||||||
normalized.push({ id, name, isMain: false });
|
const normalizedQueue = { id, name, isMain: false } as Queue;
|
||||||
|
if (queue.maxConcurrent !== undefined) {
|
||||||
|
const maxConcurrent = normalizeQueueConcurrency(queue.maxConcurrent);
|
||||||
|
if (maxConcurrent !== undefined) normalizedQueue.maxConcurrent = maxConcurrent;
|
||||||
|
}
|
||||||
|
normalized.push(normalizedQueue);
|
||||||
}
|
}
|
||||||
|
|
||||||
return { queues: normalized, queueIdRemap };
|
return { queues: normalized, queueIdRemap };
|
||||||
};
|
};
|
||||||
|
|
||||||
export const normalizePersistedQueues = (queues: Queue[]): Queue[] =>
|
export const normalizePersistedQueues = (queues: PersistedQueue[]): Queue[] =>
|
||||||
normalizePersistedQueueState(queues).queues;
|
normalizePersistedQueueState(queues).queues;
|
||||||
|
|
||||||
|
const synchronizeQueueConcurrencyLimits = async (queues: Queue[]): Promise<void> => {
|
||||||
|
await invoke('set_queue_concurrency_limits', {
|
||||||
|
limits: queues.map(queue => ({
|
||||||
|
id: queue.id,
|
||||||
|
maxConcurrent: queue.maxConcurrent ?? null
|
||||||
|
}))
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const sameQueueConcurrencyConfig = (left: Queue[], right: Queue[]): boolean =>
|
||||||
|
left.length === right.length && left.every((queue, index) => {
|
||||||
|
const other = right[index];
|
||||||
|
return other !== undefined
|
||||||
|
&& queue.id === other.id
|
||||||
|
&& (queue.maxConcurrent ?? null) === (other.maxConcurrent ?? null);
|
||||||
|
});
|
||||||
|
|
||||||
export type { DownloadItem, Queue };
|
export type { DownloadItem, Queue };
|
||||||
export type ExtensionDownloadRequest = ExtensionDownload;
|
export type ExtensionDownloadRequest = ExtensionDownload;
|
||||||
export type AddDownloadAction =
|
export type AddDownloadAction =
|
||||||
@@ -586,6 +707,12 @@ interface DownloadState {
|
|||||||
unregisterBackendIds: (ids: string[]) => void;
|
unregisterBackendIds: (ids: string[]) => void;
|
||||||
applyProperties: (id: string, updates: Partial<DownloadItem>) => Promise<void>;
|
applyProperties: (id: string, updates: Partial<DownloadItem>) => Promise<void>;
|
||||||
moveInQueue: (ids: string | string[], direction: 'up' | 'down') => Promise<void>;
|
moveInQueue: (ids: string | string[], direction: 'up' | 'down') => Promise<void>;
|
||||||
|
moveManyInQueueToPosition: (
|
||||||
|
ids: string | string[],
|
||||||
|
queueId: string,
|
||||||
|
targetIndex: number,
|
||||||
|
beforeId?: string | null
|
||||||
|
) => Promise<void>;
|
||||||
removeFromQueue: (id: string) => Promise<void>;
|
removeFromQueue: (id: string) => Promise<void>;
|
||||||
isAddModalOpen: boolean;
|
isAddModalOpen: boolean;
|
||||||
pendingAddUrls: string;
|
pendingAddUrls: string;
|
||||||
@@ -594,6 +721,8 @@ interface DownloadState {
|
|||||||
pendingAddHeaders: string;
|
pendingAddHeaders: string;
|
||||||
pendingAddCookies: string;
|
pendingAddCookies: string;
|
||||||
pendingAddMediaUrls: string[];
|
pendingAddMediaUrls: string[];
|
||||||
|
pendingAddBatch: boolean;
|
||||||
|
pendingAddBatchName: string;
|
||||||
pendingAddRequestContexts: Record<string, PendingAddRequestContext>;
|
pendingAddRequestContexts: Record<string, PendingAddRequestContext>;
|
||||||
pendingAddRequestVersion: number;
|
pendingAddRequestVersion: number;
|
||||||
selectedPropertiesDownloadId: string | null;
|
selectedPropertiesDownloadId: string | null;
|
||||||
@@ -605,7 +734,9 @@ interface DownloadState {
|
|||||||
headers?: string | null,
|
headers?: string | null,
|
||||||
cookies?: string | null,
|
cookies?: string | null,
|
||||||
media?: boolean,
|
media?: boolean,
|
||||||
cookieScopes?: ExtensionCookieScope[] | null
|
cookieScopes?: ExtensionCookieScope[] | null,
|
||||||
|
batch?: boolean,
|
||||||
|
batchName?: string | null
|
||||||
) => void;
|
) => void;
|
||||||
handleExtensionDownload: (request: ExtensionDownloadRequest) => Promise<void>;
|
handleExtensionDownload: (request: ExtensionDownloadRequest) => Promise<void>;
|
||||||
deleteModalState: DeleteModalState;
|
deleteModalState: DeleteModalState;
|
||||||
@@ -613,6 +744,7 @@ interface DownloadState {
|
|||||||
closeDeleteModal: () => void;
|
closeDeleteModal: () => void;
|
||||||
setSelectedPropertiesDownloadId: (id: string | null) => void;
|
setSelectedPropertiesDownloadId: (id: string | null) => void;
|
||||||
addDownload: (item: DownloadDraft, action: AddDownloadAction) => Promise<boolean>;
|
addDownload: (item: DownloadDraft, action: AddDownloadAction) => Promise<boolean>;
|
||||||
|
replaceDownload: (id: string, updates: Partial<DownloadItem>, action: AddDownloadAction) => Promise<boolean>;
|
||||||
updateDownload: (id: string, updates: Partial<DownloadItem>) => void;
|
updateDownload: (id: string, updates: Partial<DownloadItem>) => void;
|
||||||
removeDownload: (id: string, deleteFile?: boolean, preserveResumable?: boolean) => Promise<void>;
|
removeDownload: (id: string, deleteFile?: boolean, preserveResumable?: boolean) => Promise<void>;
|
||||||
pauseDownload: (id: string) => Promise<void>;
|
pauseDownload: (id: string) => Promise<void>;
|
||||||
@@ -623,6 +755,8 @@ interface DownloadState {
|
|||||||
startAll: () => Promise<number>;
|
startAll: () => Promise<number>;
|
||||||
pauseAll: () => Promise<number>;
|
pauseAll: () => Promise<number>;
|
||||||
assignToQueue: (ids: string[], queueId: string) => Promise<void>;
|
assignToQueue: (ids: string[], queueId: string) => Promise<void>;
|
||||||
|
setDownloadSpeedLimit: (id: string, limit: string | null) => Promise<void>;
|
||||||
|
setQueueConcurrency: (id: string, maxConcurrent: number | null) => Promise<void>;
|
||||||
addQueue: (name: string) => boolean;
|
addQueue: (name: string) => boolean;
|
||||||
renameQueue: (id: string, name: string) => boolean;
|
renameQueue: (id: string, name: string) => boolean;
|
||||||
removeQueue: (id: string) => Promise<void>;
|
removeQueue: (id: string) => Promise<void>;
|
||||||
@@ -631,7 +765,118 @@ interface DownloadState {
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useDownloadStore = create<DownloadState>((set, get) => ({
|
export const useDownloadStore = create<DownloadState>((set, get) => {
|
||||||
|
const applyPropertiesInternal = async (id: string, updates: Partial<DownloadItem>): Promise<void> => {
|
||||||
|
await waitForPendingStartupResume();
|
||||||
|
const wasDispatching = await invalidateAndWaitForDispatch(id);
|
||||||
|
const state = get();
|
||||||
|
const item = state.downloads.find(d => d.id === id);
|
||||||
|
if (!item) return;
|
||||||
|
|
||||||
|
if (item.status === 'downloading' || item.status === 'processing' || item.status === 'retrying') {
|
||||||
|
throw new Error(i18n.t($ => $.downloadTable.transferActive));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item.status === 'ready' || item.status === 'staged' || item.status === 'completed' || item.status === 'failed') {
|
||||||
|
state.updateDownload(id, updates);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Queued or Paused
|
||||||
|
const isRegistered = state.backendRegisteredIds.has(id);
|
||||||
|
|
||||||
|
if (item.status === 'queued') {
|
||||||
|
if (isRegistered) {
|
||||||
|
await invoke('detach_download_for_reconfigure', { id });
|
||||||
|
state.unregisterBackendIds([id]);
|
||||||
|
set(current => ({ pendingOrder: current.pendingOrder.filter(value => value !== id) }));
|
||||||
|
}
|
||||||
|
state.updateDownload(id, updates);
|
||||||
|
if (isRegistered || wasDispatching) {
|
||||||
|
const dispatched = await dispatchItemInternal(id);
|
||||||
|
if (dispatched) {
|
||||||
|
state.updateDownload(id, { hasBeenDispatched: true });
|
||||||
|
} else {
|
||||||
|
state.removeFromQueue(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (item.status === 'paused') {
|
||||||
|
if (isRegistered) {
|
||||||
|
try {
|
||||||
|
await invoke('detach_download_for_reconfigure', { id });
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failed to detach for reconfigure:", e);
|
||||||
|
throw e; // Preserve old properties if detach fails
|
||||||
|
}
|
||||||
|
state.unregisterBackendIds([id]);
|
||||||
|
}
|
||||||
|
state.updateDownload(id, updates);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const resumeDownloadInternal = async (id: string): Promise<boolean> => {
|
||||||
|
await waitForPendingStartupResume();
|
||||||
|
const targetItem = get().downloads.find(d => d.id === id);
|
||||||
|
if (!targetItem) return false;
|
||||||
|
|
||||||
|
setDownloadControlIntent(id, 'resume');
|
||||||
|
try {
|
||||||
|
if (targetItem.status === 'ready' || targetItem.status === 'staged') {
|
||||||
|
get().updateDownload(id, { status: 'queued', hasBeenDispatched: true });
|
||||||
|
if (await dispatchItemInternal(id)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
get().updateDownload(id, { status: targetItem.status });
|
||||||
|
clearDownloadControlIntent(id, 'resume');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const prevStatus = targetItem.status;
|
||||||
|
const queueItems = get().downloads.filter(d =>
|
||||||
|
(d.queueId || MAIN_QUEUE_ID) === (targetItem.queueId || MAIN_QUEUE_ID)
|
||||||
|
);
|
||||||
|
const maxPos = queueItems.reduce((max, d) => Math.max(max, d.queuePosition ?? 0), -1);
|
||||||
|
|
||||||
|
get().updateDownload(id, {
|
||||||
|
status: 'queued',
|
||||||
|
speed: '-',
|
||||||
|
eta: '-',
|
||||||
|
queuePosition: maxPos + 1,
|
||||||
|
lastTry: new Date().toISOString()
|
||||||
|
});
|
||||||
|
|
||||||
|
const resumedExisting = await invoke('resume_download', {
|
||||||
|
id,
|
||||||
|
queueId: targetItem.queueId || MAIN_QUEUE_ID
|
||||||
|
});
|
||||||
|
|
||||||
|
let dispatchSucceeded = resumedExisting;
|
||||||
|
if (!dispatchSucceeded) {
|
||||||
|
get().unregisterBackendIds([id]);
|
||||||
|
// A terminal aria2 gid is intentionally re-enqueued as a new
|
||||||
|
// lifecycle. Advance and cancel the old generation before dispatching
|
||||||
|
// so QueueManager does not reject the legitimate user retry as stale.
|
||||||
|
await invalidateAndWaitForDispatch(id);
|
||||||
|
dispatchSucceeded = await dispatchItemInternal(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dispatchSucceeded) {
|
||||||
|
return true;
|
||||||
|
} else {
|
||||||
|
console.error("Failed to re-enqueue for resume");
|
||||||
|
get().updateDownload(id, { status: prevStatus });
|
||||||
|
clearDownloadControlIntent(id, 'resume');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failed to resume download:", e);
|
||||||
|
get().updateDownload(id, { status: targetItem.status });
|
||||||
|
clearDownloadControlIntent(id, 'resume');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
downloads: [],
|
downloads: [],
|
||||||
queues: [{ id: MAIN_QUEUE_ID, name: 'Main Queue', isMain: true }],
|
queues: [{ id: MAIN_QUEUE_ID, name: 'Main Queue', isMain: true }],
|
||||||
pendingOrder: [],
|
pendingOrder: [],
|
||||||
@@ -708,6 +953,92 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
|
|||||||
? { ...download, queuePosition: previousPositions.get(download.id) }
|
? { ...download, queuePosition: previousPositions.get(download.id) }
|
||||||
: download)
|
: download)
|
||||||
}));
|
}));
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const trackedOperation = operation.finally(() => {
|
||||||
|
if (queueReorderPromises.get(queueId) === trackedOperation) {
|
||||||
|
queueReorderPromises.delete(queueId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
queueReorderPromises.set(queueId, trackedOperation);
|
||||||
|
return trackedOperation;
|
||||||
|
},
|
||||||
|
moveManyInQueueToPosition: (idOrIds, queueId, targetIndex, beforeId) => {
|
||||||
|
const ids = Array.isArray(idOrIds) ? idOrIds : [idOrIds];
|
||||||
|
if (ids.length === 0) return Promise.resolve();
|
||||||
|
|
||||||
|
// Dragging must be one serialized, atomic queue operation. This prevents
|
||||||
|
// a second drag or a keyboard move from calculating against stale order
|
||||||
|
// while the backend is still applying the first drop.
|
||||||
|
const previousOperation = queueReorderPromises.get(queueId) ?? Promise.resolve();
|
||||||
|
const operation = previousOperation.catch(() => undefined).then(async () => {
|
||||||
|
const allDownloads = get().downloads;
|
||||||
|
const queueItems = queueItemsForReordering(allDownloads, queueId);
|
||||||
|
const selectedItems = queueItems.filter(item => ids.includes(item.id));
|
||||||
|
if (selectedItems.length === 0) return;
|
||||||
|
|
||||||
|
const selectedIds = new Set(selectedItems.map(item => item.id));
|
||||||
|
const previousPositions = new Map([
|
||||||
|
...activeQueueItems(allDownloads, queueId),
|
||||||
|
...queueItems
|
||||||
|
].map(item => [item.id, item.queuePosition]));
|
||||||
|
const unselectedItems = queueItems.filter(item => !selectedIds.has(item.id));
|
||||||
|
const anchoredTargetIndex = beforeId
|
||||||
|
? unselectedItems.findIndex(item => item.id === beforeId)
|
||||||
|
: -1;
|
||||||
|
const resolvedTargetIndex = anchoredTargetIndex >= 0
|
||||||
|
? anchoredTargetIndex
|
||||||
|
: Math.max(0, Math.min(targetIndex, unselectedItems.length));
|
||||||
|
const reordered = moveSelectedBlockToIndex(queueItems, selectedIds, resolvedTargetIndex);
|
||||||
|
set(state => ({ downloads: applyQueueOrder(state.downloads, queueId, reordered) }));
|
||||||
|
|
||||||
|
const registeredIdsToMove = selectedItems
|
||||||
|
.filter(item => get().backendRegisteredIds.has(item.id))
|
||||||
|
.map(item => item.id);
|
||||||
|
if (registeredIdsToMove.length === 0) return;
|
||||||
|
|
||||||
|
// Staged rows are deliberately not registered with the backend. Convert
|
||||||
|
// the desired local order to a registered-only target before IPC so a
|
||||||
|
// staged row never shifts the backend insertion index.
|
||||||
|
const registeredItems = queueItems.filter(item => get().backendRegisteredIds.has(item.id));
|
||||||
|
const registeredSelectedIds = new Set(registeredIdsToMove);
|
||||||
|
const registeredDesiredOrder = reordered.filter(item => get().backendRegisteredIds.has(item.id));
|
||||||
|
const backendTargetIndex = targetIndexForDesiredOrder(
|
||||||
|
registeredItems,
|
||||||
|
registeredSelectedIds,
|
||||||
|
registeredDesiredOrder
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const order = await invoke('move_many_in_queue', {
|
||||||
|
ids: registeredIdsToMove,
|
||||||
|
queueId,
|
||||||
|
direction: 'up',
|
||||||
|
targetIndex: backendTargetIndex
|
||||||
|
}) as string[];
|
||||||
|
if (Array.isArray(order)) {
|
||||||
|
const globalOrder = await invoke('get_pending_order', { queueId: null })
|
||||||
|
.catch(() => null) as string[] | null;
|
||||||
|
set(state => ({
|
||||||
|
pendingOrder: Array.isArray(globalOrder)
|
||||||
|
? globalOrder
|
||||||
|
: [
|
||||||
|
...state.pendingOrder.filter(id => !order.includes(id)),
|
||||||
|
...order
|
||||||
|
]
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to move queue block to position:", error);
|
||||||
|
// The backend operation is atomic. Restore only queue positions so a
|
||||||
|
// progress/state event received while the RPC was in flight survives.
|
||||||
|
set(state => ({
|
||||||
|
downloads: state.downloads.map(download => previousPositions.has(download.id)
|
||||||
|
? { ...download, queuePosition: previousPositions.get(download.id) }
|
||||||
|
: download)
|
||||||
|
}));
|
||||||
|
throw error;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
const trackedOperation = operation.finally(() => {
|
const trackedOperation = operation.finally(() => {
|
||||||
@@ -746,6 +1077,8 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
|
|||||||
pendingAddHeaders: '',
|
pendingAddHeaders: '',
|
||||||
pendingAddCookies: '',
|
pendingAddCookies: '',
|
||||||
pendingAddMediaUrls: [],
|
pendingAddMediaUrls: [],
|
||||||
|
pendingAddBatch: false,
|
||||||
|
pendingAddBatchName: '',
|
||||||
pendingAddRequestContexts: {},
|
pendingAddRequestContexts: {},
|
||||||
pendingAddRequestVersion: 0,
|
pendingAddRequestVersion: 0,
|
||||||
selectedPropertiesDownloadId: null,
|
selectedPropertiesDownloadId: null,
|
||||||
@@ -765,12 +1098,24 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
|
|||||||
pendingAddHeaders: '',
|
pendingAddHeaders: '',
|
||||||
pendingAddCookies: '',
|
pendingAddCookies: '',
|
||||||
pendingAddMediaUrls: [],
|
pendingAddMediaUrls: [],
|
||||||
|
pendingAddBatch: false,
|
||||||
|
pendingAddBatchName: '',
|
||||||
pendingAddRequestContexts: {},
|
pendingAddRequestContexts: {},
|
||||||
// Invalidate any in-flight Add-modal handoff even when the modal is
|
// Invalidate any in-flight Add-modal handoff even when the modal is
|
||||||
// opened or closed without URLs.
|
// opened or closed without URLs.
|
||||||
pendingAddRequestVersion: state.pendingAddRequestVersion + 1
|
pendingAddRequestVersion: state.pendingAddRequestVersion + 1
|
||||||
})),
|
})),
|
||||||
openAddModalWithUrls: (urls, referer, filename, headers, cookies, media = false, cookieScopes) => set((state) => {
|
openAddModalWithUrls: (
|
||||||
|
urls,
|
||||||
|
referer,
|
||||||
|
filename,
|
||||||
|
headers,
|
||||||
|
cookies,
|
||||||
|
media = false,
|
||||||
|
cookieScopes,
|
||||||
|
batch = false,
|
||||||
|
batchName
|
||||||
|
) => set((state) => {
|
||||||
const isAppending = state.isAddModalOpen && Boolean(state.pendingAddUrls);
|
const isAppending = state.isAddModalOpen && Boolean(state.pendingAddUrls);
|
||||||
const existingUrls = isAppending ? state.pendingAddUrls : '';
|
const existingUrls = isAppending ? state.pendingAddUrls : '';
|
||||||
const mergedUrls = existingUrls ? `${existingUrls}\n${urls}` : urls;
|
const mergedUrls = existingUrls ? `${existingUrls}\n${urls}` : urls;
|
||||||
@@ -778,6 +1123,13 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
|
|||||||
const cleanFilename = filename?.trim() || '';
|
const cleanFilename = filename?.trim() || '';
|
||||||
const cleanHeaders = headers?.trim() || '';
|
const cleanHeaders = headers?.trim() || '';
|
||||||
const cleanCookies = cookies?.trim() || '';
|
const cleanCookies = cookies?.trim() || '';
|
||||||
|
// Keep the first modal request's grouping decision stable while later
|
||||||
|
// handoffs append URLs. This avoids moving an already-visible destination
|
||||||
|
// when a second request races with the user's Add-window setup.
|
||||||
|
const nextBatch = isAppending ? state.pendingAddBatch : batch;
|
||||||
|
const nextBatchName = nextBatch
|
||||||
|
? (isAppending ? state.pendingAddBatchName : batchName?.trim() || '')
|
||||||
|
: '';
|
||||||
const cleanCookieScopes = cookieScopes
|
const cleanCookieScopes = cookieScopes
|
||||||
?.map(scope => ({
|
?.map(scope => ({
|
||||||
url: scope.url.trim(),
|
url: scope.url.trim(),
|
||||||
@@ -822,6 +1174,8 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
|
|||||||
pendingAddHeaders: cleanHeaders,
|
pendingAddHeaders: cleanHeaders,
|
||||||
pendingAddCookies: cleanCookies,
|
pendingAddCookies: cleanCookies,
|
||||||
pendingAddMediaUrls,
|
pendingAddMediaUrls,
|
||||||
|
pendingAddBatch: nextBatch,
|
||||||
|
pendingAddBatchName: nextBatchName,
|
||||||
pendingAddRequestContexts,
|
pendingAddRequestContexts,
|
||||||
pendingAddRequestVersion: requestVersion
|
pendingAddRequestVersion: requestVersion
|
||||||
};
|
};
|
||||||
@@ -835,7 +1189,7 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
|
|||||||
// extension builds; ordinary captured downloads retain their cookies.
|
// extension builds; ordinary captured downloads retain their cookies.
|
||||||
const cookies = request.media === true ? null : request.cookies;
|
const cookies = request.media === true ? null : request.cookies;
|
||||||
const headers = request.media === true
|
const headers = request.media === true
|
||||||
? stripCookieHeaders(request.headers) || null
|
? stripSensitiveMediaHeaders(request.headers) || null
|
||||||
: request.headers;
|
: request.headers;
|
||||||
|
|
||||||
get().openAddModalWithUrls(
|
get().openAddModalWithUrls(
|
||||||
@@ -845,7 +1199,9 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
|
|||||||
headers,
|
headers,
|
||||||
cookies,
|
cookies,
|
||||||
request.media === true,
|
request.media === true,
|
||||||
request.media === true ? undefined : request.cookie_scopes
|
request.media === true ? undefined : request.cookie_scopes,
|
||||||
|
request.batch === true && urls.length >= 2,
|
||||||
|
request.batch_name
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
setSelectedPropertiesDownloadId: (id) => set({ selectedPropertiesDownloadId: id }),
|
setSelectedPropertiesDownloadId: (id) => set({ selectedPropertiesDownloadId: id }),
|
||||||
@@ -888,53 +1244,30 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
|
|||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
},
|
},
|
||||||
applyProperties: async (id, updates) => {
|
replaceDownload: (id, updates, action) => runDownloadLifecycleOperation(
|
||||||
await waitForPendingStartupResume();
|
id,
|
||||||
const wasDispatching = await invalidateAndWaitForDispatch(id);
|
'replace',
|
||||||
const state = get();
|
async () => {
|
||||||
const item = state.downloads.find(d => d.id === id);
|
if (!get().downloads.some(download => download.id === id)) return false;
|
||||||
if (!item) return;
|
await applyPropertiesInternal(id, updates);
|
||||||
|
if (!get().downloads.some(download => download.id === id)) return false;
|
||||||
if (item.status === 'downloading' || item.status === 'processing' || item.status === 'retrying') {
|
if (action.type === 'start-now') {
|
||||||
throw new Error("Cannot change properties while transfer is active. Pause it first.");
|
const resumed = await resumeDownloadInternal(id);
|
||||||
}
|
if (resumed) get().updateDownload(id, { hasBeenDispatched: true });
|
||||||
|
return resumed;
|
||||||
if (item.status === 'ready' || item.status === 'staged' || item.status === 'completed' || item.status === 'failed') {
|
|
||||||
state.updateDownload(id, updates);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Queued or Paused
|
|
||||||
const isRegistered = state.backendRegisteredIds.has(id);
|
|
||||||
|
|
||||||
if (item.status === 'queued') {
|
|
||||||
if (isRegistered) {
|
|
||||||
await invoke('detach_download_for_reconfigure', { id });
|
|
||||||
state.unregisterBackendIds([id]);
|
|
||||||
set(current => ({ pendingOrder: current.pendingOrder.filter(value => value !== id) }));
|
|
||||||
}
|
}
|
||||||
state.updateDownload(id, updates);
|
return true;
|
||||||
if (isRegistered || wasDispatching) {
|
},
|
||||||
const dispatched = await dispatchItem(id);
|
false,
|
||||||
if (dispatched) {
|
preemptDispatch
|
||||||
state.updateDownload(id, { hasBeenDispatched: true });
|
),
|
||||||
} else {
|
applyProperties: (id, updates) => runDownloadLifecycleOperation(
|
||||||
state.removeFromQueue(id);
|
id,
|
||||||
}
|
'properties',
|
||||||
}
|
() => applyPropertiesInternal(id, updates),
|
||||||
} else if (item.status === 'paused') {
|
false,
|
||||||
if (isRegistered) {
|
preemptDispatch
|
||||||
try {
|
),
|
||||||
await invoke('detach_download_for_reconfigure', { id });
|
|
||||||
} catch (e) {
|
|
||||||
console.error("Failed to detach for reconfigure:", e);
|
|
||||||
throw e; // Preserve old properties if detach fails
|
|
||||||
}
|
|
||||||
state.unregisterBackendIds([id]);
|
|
||||||
}
|
|
||||||
state.updateDownload(id, updates);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
updateDownload: (id, updates) => {
|
updateDownload: (id, updates) => {
|
||||||
set((state) => ({
|
set((state) => ({
|
||||||
downloads: state.downloads.map(d => {
|
downloads: state.downloads.map(d => {
|
||||||
@@ -958,7 +1291,10 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
|
|||||||
syncSystemIntegrations();
|
syncSystemIntegrations();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
removeDownload: async (id, deleteFile = false, preserveResumable = false) => {
|
removeDownload: (id, deleteFile = false, preserveResumable = false) => runDownloadLifecycleOperation(
|
||||||
|
id,
|
||||||
|
`remove:${deleteFile}:${preserveResumable}`,
|
||||||
|
async () => {
|
||||||
await waitForPendingStartupResume();
|
await waitForPendingStartupResume();
|
||||||
clearDownloadControlIntent(id);
|
clearDownloadControlIntent(id);
|
||||||
const { pendingDispatch } = await invalidateDispatch(id);
|
const { pendingDispatch } = await invalidateDispatch(id);
|
||||||
@@ -985,8 +1321,11 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
|
|||||||
useDownloadProgressStore.getState().clearDownloadProgress(id);
|
useDownloadProgressStore.getState().clearDownloadProgress(id);
|
||||||
info(`Download ${id} removed`);
|
info(`Download ${id} removed`);
|
||||||
syncSystemIntegrations();
|
syncSystemIntegrations();
|
||||||
},
|
},
|
||||||
pauseDownload: async (id) => {
|
true,
|
||||||
|
preemptDispatch
|
||||||
|
),
|
||||||
|
pauseDownload: (id) => runDownloadLifecycleOperation(id, 'pause', async () => {
|
||||||
await waitForPendingStartupResume();
|
await waitForPendingStartupResume();
|
||||||
if (!get().downloads.some(download => download.id === id)) return;
|
if (!get().downloads.some(download => download.id === id)) return;
|
||||||
setDownloadControlIntent(id, 'pause');
|
setDownloadControlIntent(id, 'pause');
|
||||||
@@ -1006,20 +1345,22 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
|
|||||||
} finally {
|
} finally {
|
||||||
clearDownloadControlIntent(id, 'pause');
|
clearDownloadControlIntent(id, 'pause');
|
||||||
}
|
}
|
||||||
},
|
}, true, preemptDispatch),
|
||||||
redownload: async (id) => {
|
redownload: (id) => runDownloadLifecycleOperation(id, 'redownload', async () => {
|
||||||
await waitForPendingStartupResume();
|
await waitForPendingStartupResume();
|
||||||
const targetItem = get().downloads.find(d => d.id === id);
|
const targetItem = get().downloads.find(d => d.id === id);
|
||||||
if (!targetItem) {
|
if (!targetItem) {
|
||||||
throw new Error('Cannot redownload: download was not found.');
|
throw new Error(i18n.t($ => $.downloadTable.redownloadNotFound));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!['completed', 'failed', 'paused'].includes(targetItem.status)) {
|
if (!['completed', 'failed', 'paused'].includes(targetItem.status)) {
|
||||||
throw new Error(`Cannot redownload a ${targetItem.status} download. Pause or wait for it to finish first.`);
|
throw new Error(i18n.t($ => $.downloadTable.redownloadActive, {
|
||||||
|
status: i18n.t($ => $.downloads.status[targetItem.status])
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
const url = targetItem.url?.trim();
|
const url = targetItem.url?.trim();
|
||||||
if (!url) throw new Error('Cannot redownload: original URL is missing.');
|
if (!url) throw new Error(i18n.t($ => $.downloadTable.originalUrlMissing));
|
||||||
|
|
||||||
setDownloadControlIntent(id, 'resume');
|
setDownloadControlIntent(id, 'resume');
|
||||||
await invalidateAndWaitForDispatch(id);
|
await invalidateAndWaitForDispatch(id);
|
||||||
@@ -1046,7 +1387,7 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
|
|||||||
dateAdded: new Date().toISOString()
|
dateAdded: new Date().toISOString()
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!await dispatchItem(id)) {
|
if (!await dispatchItemInternal(id)) {
|
||||||
console.error("Failed to enqueue redownload");
|
console.error("Failed to enqueue redownload");
|
||||||
get().updateDownload(id, { status: 'failed' });
|
get().updateDownload(id, { status: 'failed' });
|
||||||
clearDownloadControlIntent(id, 'resume');
|
clearDownloadControlIntent(id, 'resume');
|
||||||
@@ -1054,65 +1395,14 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
|
|||||||
get().updateDownload(id, { hasBeenDispatched: true });
|
get().updateDownload(id, { hasBeenDispatched: true });
|
||||||
info(`Download ${id} redownloaded (queued)`);
|
info(`Download ${id} redownloaded (queued)`);
|
||||||
}
|
}
|
||||||
},
|
}, true, preemptDispatch),
|
||||||
resumeDownload: async (id) => {
|
resumeDownload: (id) => runDownloadLifecycleOperation(
|
||||||
await waitForPendingStartupResume();
|
id,
|
||||||
const targetItem = get().downloads.find(d => d.id === id);
|
'resume',
|
||||||
if (!targetItem) return false;
|
() => resumeDownloadInternal(id),
|
||||||
|
true,
|
||||||
setDownloadControlIntent(id, 'resume');
|
preemptDispatch
|
||||||
try {
|
),
|
||||||
if (targetItem.status === 'ready' || targetItem.status === 'staged') {
|
|
||||||
get().updateDownload(id, { status: 'queued', hasBeenDispatched: true });
|
|
||||||
if (await dispatchItem(id)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
get().updateDownload(id, { status: targetItem.status });
|
|
||||||
clearDownloadControlIntent(id, 'resume');
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const prevStatus = targetItem.status;
|
|
||||||
const queueItems = get().downloads.filter(d =>
|
|
||||||
(d.queueId || MAIN_QUEUE_ID) === (targetItem.queueId || MAIN_QUEUE_ID)
|
|
||||||
);
|
|
||||||
const maxPos = queueItems.reduce((max, d) => Math.max(max, d.queuePosition ?? 0), -1);
|
|
||||||
|
|
||||||
get().updateDownload(id, {
|
|
||||||
status: 'queued',
|
|
||||||
speed: '-',
|
|
||||||
eta: '-',
|
|
||||||
queuePosition: maxPos + 1,
|
|
||||||
lastTry: new Date().toISOString()
|
|
||||||
});
|
|
||||||
|
|
||||||
const resumedExisting = await invoke('resume_download', { id });
|
|
||||||
|
|
||||||
let dispatchSucceeded = resumedExisting;
|
|
||||||
if (!dispatchSucceeded) {
|
|
||||||
get().unregisterBackendIds([id]);
|
|
||||||
// A terminal aria2 gid is intentionally re-enqueued as a new
|
|
||||||
// lifecycle. Advance and cancel the old generation before dispatching
|
|
||||||
// so QueueManager does not reject the legitimate user retry as stale.
|
|
||||||
await invalidateAndWaitForDispatch(id);
|
|
||||||
dispatchSucceeded = await dispatchItem(id);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (dispatchSucceeded) {
|
|
||||||
return true;
|
|
||||||
} else {
|
|
||||||
console.error("Failed to re-enqueue for resume");
|
|
||||||
get().updateDownload(id, { status: prevStatus });
|
|
||||||
clearDownloadControlIntent(id, 'resume');
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.error("Failed to resume download:", e);
|
|
||||||
get().updateDownload(id, { status: targetItem.status });
|
|
||||||
clearDownloadControlIntent(id, 'resume');
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
startQueue: (queueId) => {
|
startQueue: (queueId) => {
|
||||||
const requestedGeneration = currentQueueControlGeneration(queueId);
|
const requestedGeneration = currentQueueControlGeneration(queueId);
|
||||||
const previousOperation = queueStartPromises.get(queueId) ?? Promise.resolve([]);
|
const previousOperation = queueStartPromises.get(queueId) ?? Promise.resolve([]);
|
||||||
@@ -1279,46 +1569,122 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
|
|||||||
},
|
},
|
||||||
assignToQueue: async (ids, queueId) => {
|
assignToQueue: async (ids, queueId) => {
|
||||||
await waitForPendingStartupResume();
|
await waitForPendingStartupResume();
|
||||||
const selectedIds = new Set(ids);
|
const lockedIds = ids.filter(id => get().downloads.some(item => item.id === id));
|
||||||
const selected = get().downloads.filter(item => selectedIds.has(item.id));
|
return runDownloadLifecycleOperations(lockedIds, 'assign-to-queue', async () => {
|
||||||
const locked = selected.find(item => isActiveDownloadStatus(item.status) && item.status !== 'queued');
|
const selectedIds = new Set(ids);
|
||||||
if (locked) {
|
const selected = get().downloads.filter(item => selectedIds.has(item.id));
|
||||||
throw new Error(`Pause ${locked.fileName} before moving it to another queue.`);
|
const locked = selected.find(item => isActiveDownloadStatus(item.status) && item.status !== 'queued');
|
||||||
}
|
if (locked) {
|
||||||
|
throw new Error(i18n.t($ => $.downloadTable.pauseBeforeMove, { file: locked.fileName }));
|
||||||
|
}
|
||||||
|
|
||||||
const movableSelected = selected.filter(item => item.status !== 'completed');
|
const movableSelected = selected.filter(item => item.status !== 'completed');
|
||||||
const movableIds = new Set(movableSelected.map(item => item.id));
|
const movableIds = new Set(movableSelected.map(item => item.id));
|
||||||
await Promise.all(movableSelected.map(item => invalidateAndWaitForDispatch(item.id)));
|
await Promise.all(movableSelected.map(item => invalidateAndWaitForDispatch(item.id)));
|
||||||
|
|
||||||
for (const item of get().downloads.filter(item => movableIds.has(item.id))) {
|
for (const item of get().downloads.filter(item => movableIds.has(item.id))) {
|
||||||
if (!get().backendRegisteredIds.has(item.id)) continue;
|
if (!get().backendRegisteredIds.has(item.id)) continue;
|
||||||
// The UI can still say queued while a dispatch has already reached
|
// The UI can still say queued while a dispatch has already reached
|
||||||
// Aria2/media. Detach through the backend lifecycle owner for every
|
// Aria2/media. Detach through the backend lifecycle owner for every
|
||||||
// registered item; remove_from_queue only handles the pending list.
|
// registered item; remove_from_queue only handles the pending list.
|
||||||
await invoke('detach_download_for_reconfigure', { id: item.id });
|
await invoke('detach_download_for_reconfigure', { id: item.id });
|
||||||
get().unregisterBackendIds([item.id]);
|
get().unregisterBackendIds([item.id]);
|
||||||
set(state => ({ pendingOrder: state.pendingOrder.filter(value => value !== item.id) }));
|
set(state => ({ pendingOrder: state.pendingOrder.filter(value => value !== item.id) }));
|
||||||
}
|
}
|
||||||
|
|
||||||
const queueItems = get().downloads.filter(item =>
|
const queueItems = get().downloads.filter(item =>
|
||||||
!movableIds.has(item.id) &&
|
!movableIds.has(item.id) &&
|
||||||
(item.queueId || MAIN_QUEUE_ID) === queueId
|
(item.queueId || MAIN_QUEUE_ID) === queueId
|
||||||
);
|
);
|
||||||
const maxPos = queueItems.reduce((max, d) => Math.max(max, d.queuePosition ?? 0), -1);
|
const maxPos = queueItems.reduce((max, d) => Math.max(max, d.queuePosition ?? 0), -1);
|
||||||
const nextPosition = maxPos + 1;
|
const nextPosition = maxPos + 1;
|
||||||
set(state => ({
|
set(state => ({
|
||||||
downloads: state.downloads.map(item =>
|
downloads: state.downloads.map(item =>
|
||||||
movableIds.has(item.id) && item.status !== 'completed'
|
movableIds.has(item.id) && item.status !== 'completed'
|
||||||
? {
|
? {
|
||||||
...item,
|
...item,
|
||||||
queueId,
|
queueId,
|
||||||
queuePosition: nextPosition + movableSelected.findIndex(selectedItem => selectedItem.id === item.id),
|
queuePosition: nextPosition + movableSelected.findIndex(selectedItem => selectedItem.id === item.id),
|
||||||
status: 'staged' as const,
|
status: 'staged' as const,
|
||||||
hasBeenDispatched: false
|
hasBeenDispatched: false
|
||||||
}
|
}
|
||||||
: item
|
: item
|
||||||
)
|
)
|
||||||
}));
|
}));
|
||||||
|
});
|
||||||
|
},
|
||||||
|
setDownloadSpeedLimit: (id, limit) => runDownloadLifecycleOperation(
|
||||||
|
id,
|
||||||
|
'speed-limit',
|
||||||
|
async () => {
|
||||||
|
await waitForPendingStartupResume();
|
||||||
|
const item = get().downloads.find(download => download.id === id);
|
||||||
|
if (!item) throw new Error('Download no longer exists.');
|
||||||
|
if (item.isMedia) {
|
||||||
|
throw new Error('Live speed control is unavailable for media downloads.');
|
||||||
|
}
|
||||||
|
if (!['downloading', 'retrying'].includes(item.status)) {
|
||||||
|
throw new Error('Live speed control requires an active download.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const trimmed = limit?.trim() || '';
|
||||||
|
const normalizedLimit = trimmed
|
||||||
|
? normalizeSpeedLimitForBackend(trimmed)
|
||||||
|
: null;
|
||||||
|
if (trimmed && normalizedLimit === null) {
|
||||||
|
throw new Error('Enter a valid speed limit.');
|
||||||
|
}
|
||||||
|
|
||||||
|
await invoke('set_download_speed_limit', {
|
||||||
|
id,
|
||||||
|
limit: normalizedLimit
|
||||||
|
});
|
||||||
|
if (get().downloads.some(download => download.id === id)) {
|
||||||
|
get().updateDownload(id, { speedLimit: normalizedLimit ?? undefined });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
true,
|
||||||
|
preemptDispatch
|
||||||
|
),
|
||||||
|
setQueueConcurrency: (id, maxConcurrent) => {
|
||||||
|
const operation = queueConfigurationQueue.then(async () => {
|
||||||
|
if (
|
||||||
|
maxConcurrent !== null
|
||||||
|
&& (!Number.isInteger(maxConcurrent) || maxConcurrent < 1 || maxConcurrent > MAX_QUEUE_CONCURRENT)
|
||||||
|
) {
|
||||||
|
throw new Error('Queue concurrency must be between 1 and 12.');
|
||||||
|
}
|
||||||
|
const currentQueues = get().queues;
|
||||||
|
if (!currentQueues.some(queue => queue.id === id)) {
|
||||||
|
throw new Error('Queue no longer exists.');
|
||||||
|
}
|
||||||
|
const nextQueues = currentQueues.map(queue =>
|
||||||
|
queue.id === id
|
||||||
|
? maxConcurrent === null
|
||||||
|
? { id: queue.id, name: queue.name, isMain: queue.isMain }
|
||||||
|
: { ...queue, maxConcurrent }
|
||||||
|
: queue
|
||||||
|
);
|
||||||
|
await synchronizeQueueConcurrencyLimits(nextQueues);
|
||||||
|
const latestQueues = get().queues;
|
||||||
|
if (!latestQueues.some(queue => queue.id === id)) {
|
||||||
|
await synchronizeQueueConcurrencyLimits(latestQueues);
|
||||||
|
throw new Error('Queue no longer exists.');
|
||||||
|
}
|
||||||
|
const rebasedQueues = latestQueues.map(queue =>
|
||||||
|
queue.id === id
|
||||||
|
? maxConcurrent === null
|
||||||
|
? { id: queue.id, name: queue.name, isMain: queue.isMain }
|
||||||
|
: { ...queue, maxConcurrent }
|
||||||
|
: queue
|
||||||
|
);
|
||||||
|
if (!sameQueueConcurrencyConfig(nextQueues, rebasedQueues)) {
|
||||||
|
await synchronizeQueueConcurrencyLimits(rebasedQueues);
|
||||||
|
}
|
||||||
|
set({ queues: rebasedQueues });
|
||||||
|
});
|
||||||
|
queueConfigurationQueue = operation.then(() => undefined, () => undefined);
|
||||||
|
return operation;
|
||||||
},
|
},
|
||||||
addQueue: (name) => {
|
addQueue: (name) => {
|
||||||
const normalizedName = name.trim();
|
const normalizedName = name.trim();
|
||||||
@@ -1541,7 +1907,7 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
|
|||||||
try {
|
try {
|
||||||
const persistedQueues = (await invoke('db_get_all_queues')).flatMap(value => {
|
const persistedQueues = (await invoke('db_get_all_queues')).flatMap(value => {
|
||||||
try {
|
try {
|
||||||
return [JSON.parse(value) as Queue];
|
return [JSON.parse(value) as PersistedQueue];
|
||||||
} catch {
|
} catch {
|
||||||
console.warn('Skipping malformed persisted queue record during startup');
|
console.warn('Skipping malformed persisted queue record during startup');
|
||||||
return [];
|
return [];
|
||||||
@@ -1566,6 +1932,11 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
|
|||||||
: state.downloads
|
: state.downloads
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
// The backend dispatcher is live before the frontend finishes startup.
|
||||||
|
// Synchronize the normalized queue policy before any saved download is
|
||||||
|
// allowed to claim a permit.
|
||||||
|
await synchronizeQueueConcurrencyLimits(queues);
|
||||||
|
|
||||||
// Reset interrupted active downloads to queued.
|
// Reset interrupted active downloads to queued.
|
||||||
set((state) => ({
|
set((state) => ({
|
||||||
downloads: state.downloads.map(d =>
|
downloads: state.downloads.map(d =>
|
||||||
@@ -1580,7 +1951,8 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
|
|||||||
throw e;
|
throw e;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}));
|
};
|
||||||
|
});
|
||||||
|
|
||||||
let lastSavedDownloads = '';
|
let lastSavedDownloads = '';
|
||||||
let isSavingDownloads = false;
|
let isSavingDownloads = false;
|
||||||
|
|||||||
@@ -15,6 +15,30 @@ vi.mock('../utils/logger', () => ({
|
|||||||
info: vi.fn()
|
info: vi.fn()
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
describe('last used download directory preference', () => {
|
||||||
|
it('is disabled by default', () => {
|
||||||
|
expect(useSettingsStore.getState().rememberLastUsedDownloadDirectory).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('calendar preference', () => {
|
||||||
|
it('keeps Gregorian as the default and persists explicit calendar choices', async () => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
useSettingsStore.setState({ calendarPreference: 'gregorian' });
|
||||||
|
expect(useSettingsStore.getState().calendarPreference).toBe('gregorian');
|
||||||
|
|
||||||
|
useSettingsStore.getState().setCalendarPreference('persian');
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 0));
|
||||||
|
|
||||||
|
expect(useSettingsStore.getState().calendarPreference).toBe('persian');
|
||||||
|
const save = vi.mocked(ipc.invokeCommand).mock.calls
|
||||||
|
.filter(([command]) => command === 'db_save_settings')
|
||||||
|
.slice(-1)[0];
|
||||||
|
expect(save).toBeDefined();
|
||||||
|
expect(JSON.parse((save?.[1] as { data: string }).data).state.calendarPreference).toBe('persian');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('useSettingsStore global speed limit persistence', () => {
|
describe('useSettingsStore global speed limit persistence', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
@@ -31,6 +55,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', () => {
|
describe('useSettingsStore credential-store startup flow', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { invokeCommand as invoke } from '../ipc';
|
|||||||
import { info } from '../utils/logger';
|
import { info } from '../utils/logger';
|
||||||
import type { ActiveView } from '../bindings/ActiveView';
|
import type { ActiveView } from '../bindings/ActiveView';
|
||||||
import type { AppFontSize } from '../bindings/AppFontSize';
|
import type { AppFontSize } from '../bindings/AppFontSize';
|
||||||
|
import type { FontFamily } from '../bindings/FontFamily';
|
||||||
import type { ListRowDensity } from '../bindings/ListRowDensity';
|
import type { ListRowDensity } from '../bindings/ListRowDensity';
|
||||||
import type { MediaCookieSource } from '../bindings/MediaCookieSource';
|
import type { MediaCookieSource } from '../bindings/MediaCookieSource';
|
||||||
import type { PostQueueAction } from '../bindings/PostQueueAction';
|
import type { PostQueueAction } from '../bindings/PostQueueAction';
|
||||||
@@ -14,11 +15,19 @@ import type { SchedulerSettings } from '../bindings/SchedulerSettings';
|
|||||||
import type { SettingsTab } from '../bindings/SettingsTab';
|
import type { SettingsTab } from '../bindings/SettingsTab';
|
||||||
import type { SiteLogin } from '../bindings/SiteLogin';
|
import type { SiteLogin } from '../bindings/SiteLogin';
|
||||||
import type { Theme } from '../bindings/Theme';
|
import type { Theme } from '../bindings/Theme';
|
||||||
|
import type { WindowControlStyle } from '../bindings/WindowControlStyle';
|
||||||
import {
|
import {
|
||||||
DEFAULT_CATEGORY_SUBFOLDERS,
|
DEFAULT_CATEGORY_SUBFOLDERS,
|
||||||
normalizeDownloadLocationSettings
|
normalizeDownloadLocationSettings
|
||||||
} from '../utils/downloadLocations';
|
} from '../utils/downloadLocations';
|
||||||
import { normalizeSpeedLimitForBackend } from '../utils/downloads';
|
import { normalizeSpeedLimitForBackend } from '../utils/downloads';
|
||||||
|
import i18n from '../i18n';
|
||||||
|
import { isAppLocalePreference, type AppLocalePreference } from '../i18n/locales';
|
||||||
|
import {
|
||||||
|
DEFAULT_CALENDAR_PREFERENCE,
|
||||||
|
isCalendarPreference,
|
||||||
|
type CalendarPreference
|
||||||
|
} from '../utils/dateTime';
|
||||||
|
|
||||||
let settingsQueue: Promise<void> = Promise.resolve();
|
let settingsQueue: Promise<void> = Promise.resolve();
|
||||||
let pairingTokenHydrationRequest: Promise<PairingTokenHydration> | null = null;
|
let pairingTokenHydrationRequest: Promise<PairingTokenHydration> | null = null;
|
||||||
@@ -66,8 +75,21 @@ const notifySettingsPersistenceError = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const THEME_VALUES = ['system', 'light', 'dark', 'dracula', 'nord'] as const;
|
const THEME_VALUES = ['system', 'light', 'dark', 'dracula', 'nord'] as const;
|
||||||
|
const FONT_FAMILY_VALUES = [
|
||||||
|
'system',
|
||||||
|
'inter',
|
||||||
|
'outfit',
|
||||||
|
'vazirmatn',
|
||||||
|
'noto-sans-hebrew',
|
||||||
|
'noto-sans-sc',
|
||||||
|
'roboto',
|
||||||
|
'serif',
|
||||||
|
'monospace',
|
||||||
|
] as const;
|
||||||
|
const WINDOW_CONTROL_STYLE_VALUES = ['auto', 'macos', 'windows', 'gnome', 'minimal'] as const;
|
||||||
const APP_FONT_SIZE_VALUES = ['small', 'standard', 'large'] as const;
|
const APP_FONT_SIZE_VALUES = ['small', 'standard', 'large'] as const;
|
||||||
const LIST_ROW_DENSITY_VALUES = ['compact', 'standard', 'relaxed'] 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 PROXY_MODE_VALUES = ['none', 'system', 'custom'] as const;
|
||||||
const MEDIA_COOKIE_SOURCE_VALUES = [
|
const MEDIA_COOKIE_SOURCE_VALUES = [
|
||||||
'none', 'safari', 'chrome', 'chromium', 'firefox', 'edge', 'brave', 'opera', 'vivaldi', 'whale'
|
'none', 'safari', 'chrome', 'chromium', 'firefox', 'edge', 'brave', 'opera', 'vivaldi', 'whale'
|
||||||
@@ -77,6 +99,7 @@ const SETTINGS_TAB_VALUES = [
|
|||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
type PersistedSettingsSnapshot = PersistedSettings & {
|
type PersistedSettingsSnapshot = PersistedSettings & {
|
||||||
|
language: AppLocalePreference;
|
||||||
keychainPromptDismissed: boolean;
|
keychainPromptDismissed: boolean;
|
||||||
keychainAccessVersion: string;
|
keychainAccessVersion: string;
|
||||||
};
|
};
|
||||||
@@ -151,6 +174,8 @@ const tauriStorage: StateStorage = {
|
|||||||
export type {
|
export type {
|
||||||
ActiveView,
|
ActiveView,
|
||||||
AppFontSize,
|
AppFontSize,
|
||||||
|
FontFamily,
|
||||||
|
CalendarPreference,
|
||||||
ListRowDensity,
|
ListRowDensity,
|
||||||
MediaCookieSource,
|
MediaCookieSource,
|
||||||
PostQueueAction,
|
PostQueueAction,
|
||||||
@@ -158,21 +183,32 @@ export type {
|
|||||||
SchedulerSettings,
|
SchedulerSettings,
|
||||||
SettingsTab,
|
SettingsTab,
|
||||||
SiteLogin,
|
SiteLogin,
|
||||||
Theme
|
Theme,
|
||||||
|
WindowControlStyle
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type SidebarPosition = 'auto' | 'left' | 'right';
|
||||||
|
|
||||||
export interface SettingsState {
|
export interface SettingsState {
|
||||||
theme: Theme;
|
theme: Theme;
|
||||||
|
fontFamily: FontFamily;
|
||||||
|
windowControlStyle: WindowControlStyle;
|
||||||
|
calendarPreference: CalendarPreference;
|
||||||
|
language: AppLocalePreference;
|
||||||
baseDownloadFolder: string;
|
baseDownloadFolder: string;
|
||||||
categorySubfoldersEnabled: boolean;
|
categorySubfoldersEnabled: boolean;
|
||||||
categorySubfolders: Record<string, string>;
|
categorySubfolders: Record<string, string>;
|
||||||
categoryDirectoryOverrides: 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[];
|
approvedDownloadRoots: string[];
|
||||||
maxConcurrentDownloads: number;
|
maxConcurrentDownloads: number;
|
||||||
globalSpeedLimit: string;
|
globalSpeedLimit: string;
|
||||||
speedLimitPresetValues: number[];
|
speedLimitPresetValues: number[];
|
||||||
logsEnabled: boolean;
|
logsEnabled: boolean;
|
||||||
isSidebarVisible: boolean;
|
isSidebarVisible: boolean;
|
||||||
|
sidebarPosition: SidebarPosition;
|
||||||
activeView: ActiveView;
|
activeView: ActiveView;
|
||||||
activeSettingsTab: SettingsTab;
|
activeSettingsTab: SettingsTab;
|
||||||
scheduler: SchedulerSettings;
|
scheduler: SchedulerSettings;
|
||||||
@@ -192,6 +228,8 @@ export interface SettingsState {
|
|||||||
appFontSize: AppFontSize;
|
appFontSize: AppFontSize;
|
||||||
listRowDensity: ListRowDensity;
|
listRowDensity: ListRowDensity;
|
||||||
showDockBadge: boolean;
|
showDockBadge: boolean;
|
||||||
|
/** Forces the App-level badge effect to run for every toggle request. */
|
||||||
|
dockBadgeSyncVersion: number;
|
||||||
showMenuBarIcon: boolean;
|
showMenuBarIcon: boolean;
|
||||||
proxyMode: ProxyMode;
|
proxyMode: ProxyMode;
|
||||||
proxyHost: string;
|
proxyHost: string;
|
||||||
@@ -199,6 +237,7 @@ export interface SettingsState {
|
|||||||
customUserAgent: string;
|
customUserAgent: string;
|
||||||
askWhereToSaveEachFile: boolean;
|
askWhereToSaveEachFile: boolean;
|
||||||
preventsSleepWhileDownloading: boolean;
|
preventsSleepWhileDownloading: boolean;
|
||||||
|
preventsDisplaySleepWhileDownloading: boolean;
|
||||||
mediaCookieSource: MediaCookieSource;
|
mediaCookieSource: MediaCookieSource;
|
||||||
siteLogins: SiteLogin[];
|
siteLogins: SiteLogin[];
|
||||||
extensionPairingToken: string;
|
extensionPairingToken: string;
|
||||||
@@ -211,12 +250,17 @@ export interface SettingsState {
|
|||||||
showKeychainModal: boolean;
|
showKeychainModal: boolean;
|
||||||
|
|
||||||
setTheme: (theme: Theme) => void;
|
setTheme: (theme: Theme) => void;
|
||||||
|
setFontFamily: (fontFamily: FontFamily) => void;
|
||||||
|
setWindowControlStyle: (style: WindowControlStyle) => void;
|
||||||
|
setCalendarPreference: (calendarPreference: CalendarPreference) => void;
|
||||||
|
setLanguage: (language: AppLocalePreference) => void;
|
||||||
setBaseDownloadFolder: (path: string) => void;
|
setBaseDownloadFolder: (path: string) => void;
|
||||||
approveDownloadRoot: (path: string) => Promise<string>;
|
approveDownloadRoot: (path: string) => Promise<string>;
|
||||||
setMaxConcurrentDownloads: (count: number) => void;
|
setMaxConcurrentDownloads: (count: number) => void;
|
||||||
setGlobalSpeedLimit: (limit: string) => Promise<void>;
|
setGlobalSpeedLimit: (limit: string) => Promise<void>;
|
||||||
setSpeedLimitPresetValues: (values: number[]) => void;
|
setSpeedLimitPresetValues: (values: number[]) => void;
|
||||||
setLogsEnabled: (enabled: boolean) => void;
|
setLogsEnabled: (enabled: boolean) => void;
|
||||||
|
setSidebarPosition: (position: SidebarPosition) => void;
|
||||||
setActiveView: (view: ActiveView) => void;
|
setActiveView: (view: ActiveView) => void;
|
||||||
setActiveSettingsTab: (tab: SettingsTab) => void;
|
setActiveSettingsTab: (tab: SettingsTab) => void;
|
||||||
setScheduler: (settings: SchedulerSettings) => void;
|
setScheduler: (settings: SchedulerSettings) => void;
|
||||||
@@ -243,7 +287,10 @@ export interface SettingsState {
|
|||||||
setCustomUserAgent: (userAgent: string) => void;
|
setCustomUserAgent: (userAgent: string) => void;
|
||||||
setAskWhereToSaveEachFile: (ask: boolean) => void;
|
setAskWhereToSaveEachFile: (ask: boolean) => void;
|
||||||
setPreventsSleepWhileDownloading: (prevent: boolean) => void;
|
setPreventsSleepWhileDownloading: (prevent: boolean) => void;
|
||||||
|
setPreventsDisplaySleepWhileDownloading: (prevent: boolean) => void;
|
||||||
setMediaCookieSource: (source: MediaCookieSource) => void;
|
setMediaCookieSource: (source: MediaCookieSource) => void;
|
||||||
|
setRememberLastUsedDownloadDirectory: (enabled: boolean) => void;
|
||||||
|
setLastUsedDownloadDirectory: (path: string) => void;
|
||||||
setCategorySubfoldersEnabled: (enabled: boolean) => void;
|
setCategorySubfoldersEnabled: (enabled: boolean) => void;
|
||||||
setCategorySubfolder: (category: string, subfolder: string) => void;
|
setCategorySubfolder: (category: string, subfolder: string) => void;
|
||||||
setCategoryDirectoryOverride: (category: string, path?: string) => void;
|
setCategoryDirectoryOverride: (category: string, path?: string) => void;
|
||||||
@@ -263,10 +310,16 @@ export const useSettingsStore = create<SettingsState>()(
|
|||||||
persist(
|
persist(
|
||||||
(set, get) => ({
|
(set, get) => ({
|
||||||
theme: 'system',
|
theme: 'system',
|
||||||
|
fontFamily: 'system',
|
||||||
|
windowControlStyle: 'auto',
|
||||||
|
calendarPreference: DEFAULT_CALENDAR_PREFERENCE,
|
||||||
|
language: 'system',
|
||||||
baseDownloadFolder: '~/Downloads',
|
baseDownloadFolder: '~/Downloads',
|
||||||
categorySubfoldersEnabled: true,
|
categorySubfoldersEnabled: true,
|
||||||
categorySubfolders: { ...DEFAULT_CATEGORY_SUBFOLDERS },
|
categorySubfolders: { ...DEFAULT_CATEGORY_SUBFOLDERS },
|
||||||
categoryDirectoryOverrides: {},
|
categoryDirectoryOverrides: {},
|
||||||
|
rememberLastUsedDownloadDirectory: false,
|
||||||
|
lastUsedDownloadDirectory: null,
|
||||||
approvedDownloadRoots: [],
|
approvedDownloadRoots: [],
|
||||||
maxConcurrentDownloads: 3,
|
maxConcurrentDownloads: 3,
|
||||||
globalSpeedLimit: '',
|
globalSpeedLimit: '',
|
||||||
@@ -275,6 +328,7 @@ export const useSettingsStore = create<SettingsState>()(
|
|||||||
activeView: 'downloads',
|
activeView: 'downloads',
|
||||||
activeSettingsTab: 'downloads',
|
activeSettingsTab: 'downloads',
|
||||||
isSidebarVisible: true,
|
isSidebarVisible: true,
|
||||||
|
sidebarPosition: 'auto',
|
||||||
scheduler: {
|
scheduler: {
|
||||||
enabled: false,
|
enabled: false,
|
||||||
startTime: '00:00',
|
startTime: '00:00',
|
||||||
@@ -301,6 +355,7 @@ export const useSettingsStore = create<SettingsState>()(
|
|||||||
appFontSize: 'standard',
|
appFontSize: 'standard',
|
||||||
listRowDensity: 'standard',
|
listRowDensity: 'standard',
|
||||||
showDockBadge: true,
|
showDockBadge: true,
|
||||||
|
dockBadgeSyncVersion: 0,
|
||||||
showMenuBarIcon: true,
|
showMenuBarIcon: true,
|
||||||
proxyMode: 'none',
|
proxyMode: 'none',
|
||||||
proxyHost: '',
|
proxyHost: '',
|
||||||
@@ -308,6 +363,7 @@ export const useSettingsStore = create<SettingsState>()(
|
|||||||
customUserAgent: '',
|
customUserAgent: '',
|
||||||
askWhereToSaveEachFile: false,
|
askWhereToSaveEachFile: false,
|
||||||
preventsSleepWhileDownloading: true,
|
preventsSleepWhileDownloading: true,
|
||||||
|
preventsDisplaySleepWhileDownloading: false,
|
||||||
mediaCookieSource: 'none',
|
mediaCookieSource: 'none',
|
||||||
siteLogins: [],
|
siteLogins: [],
|
||||||
extensionPairingToken: '',
|
extensionPairingToken: '',
|
||||||
@@ -320,6 +376,19 @@ export const useSettingsStore = create<SettingsState>()(
|
|||||||
showKeychainModal: false,
|
showKeychainModal: false,
|
||||||
|
|
||||||
setTheme: (theme) => { info('Settings updated: theme'); set({ theme }); },
|
setTheme: (theme) => { info('Settings updated: theme'); set({ theme }); },
|
||||||
|
setFontFamily: (fontFamily) => {
|
||||||
|
info('Settings updated: fontFamily');
|
||||||
|
set({ fontFamily });
|
||||||
|
},
|
||||||
|
setWindowControlStyle: (windowControlStyle) => {
|
||||||
|
info('Settings updated: windowControlStyle');
|
||||||
|
set({ windowControlStyle });
|
||||||
|
},
|
||||||
|
setCalendarPreference: (calendarPreference) => {
|
||||||
|
info('Settings updated: calendarPreference');
|
||||||
|
set({ calendarPreference });
|
||||||
|
},
|
||||||
|
setLanguage: (language) => { info('Settings updated: language'); set({ language }); },
|
||||||
setBaseDownloadFolder: (path) => {
|
setBaseDownloadFolder: (path) => {
|
||||||
info('Settings updated: baseDownloadFolder');
|
info('Settings updated: baseDownloadFolder');
|
||||||
set({ baseDownloadFolder: path });
|
set({ baseDownloadFolder: path });
|
||||||
@@ -348,6 +417,7 @@ export const useSettingsStore = create<SettingsState>()(
|
|||||||
},
|
},
|
||||||
setSpeedLimitPresetValues: (speedLimitPresetValues) => set({ speedLimitPresetValues }),
|
setSpeedLimitPresetValues: (speedLimitPresetValues) => set({ speedLimitPresetValues }),
|
||||||
setLogsEnabled: (logsEnabled) => set({ logsEnabled }),
|
setLogsEnabled: (logsEnabled) => set({ logsEnabled }),
|
||||||
|
setSidebarPosition: (sidebarPosition) => set({ sidebarPosition }),
|
||||||
setActiveView: (view) => set({ activeView: view }),
|
setActiveView: (view) => set({ activeView: view }),
|
||||||
setActiveSettingsTab: (activeSettingsTab) => set({ activeSettingsTab }),
|
setActiveSettingsTab: (activeSettingsTab) => set({ activeSettingsTab }),
|
||||||
setScheduler: (scheduler) => set({ scheduler }),
|
setScheduler: (scheduler) => set({ scheduler }),
|
||||||
@@ -371,8 +441,10 @@ export const useSettingsStore = create<SettingsState>()(
|
|||||||
setAppFontSize: (appFontSize) => set({ appFontSize }),
|
setAppFontSize: (appFontSize) => set({ appFontSize }),
|
||||||
setListRowDensity: (listRowDensity) => set({ listRowDensity }),
|
setListRowDensity: (listRowDensity) => set({ listRowDensity }),
|
||||||
setShowDockBadge: (showDockBadge) => {
|
setShowDockBadge: (showDockBadge) => {
|
||||||
set({ showDockBadge });
|
set(state => ({
|
||||||
if (!showDockBadge) invoke('update_dock_badge', { count: 0 }).catch(console.error);
|
showDockBadge,
|
||||||
|
dockBadgeSyncVersion: state.dockBadgeSyncVersion + 1
|
||||||
|
}));
|
||||||
},
|
},
|
||||||
setShowMenuBarIcon: (showMenuBarIcon) => set({ showMenuBarIcon }),
|
setShowMenuBarIcon: (showMenuBarIcon) => set({ showMenuBarIcon }),
|
||||||
setProxyMode: (proxyMode) => set({ proxyMode }),
|
setProxyMode: (proxyMode) => set({ proxyMode }),
|
||||||
@@ -388,7 +460,23 @@ export const useSettingsStore = create<SettingsState>()(
|
|||||||
info('Settings updated: preventsSleepWhileDownloading');
|
info('Settings updated: preventsSleepWhileDownloading');
|
||||||
set({ preventsSleepWhileDownloading });
|
set({ preventsSleepWhileDownloading });
|
||||||
},
|
},
|
||||||
|
setPreventsDisplaySleepWhileDownloading: (preventsDisplaySleepWhileDownloading) => {
|
||||||
|
info('Settings updated: preventsDisplaySleepWhileDownloading');
|
||||||
|
set({ preventsDisplaySleepWhileDownloading });
|
||||||
|
},
|
||||||
setMediaCookieSource: (mediaCookieSource) => { info('Settings updated: mediaCookieSource'); set({ mediaCookieSource }); },
|
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) => {
|
setCategorySubfoldersEnabled: (categorySubfoldersEnabled) => {
|
||||||
info('Settings updated: categorySubfoldersEnabled');
|
info('Settings updated: categorySubfoldersEnabled');
|
||||||
set({ categorySubfoldersEnabled });
|
set({ categorySubfoldersEnabled });
|
||||||
@@ -425,11 +513,11 @@ export const useSettingsStore = create<SettingsState>()(
|
|||||||
const current = get();
|
const current = get();
|
||||||
if (!current.keychainAccessReady && !current.isPairingTokenPersistent) {
|
if (!current.keychainAccessReady && !current.isPairingTokenPersistent) {
|
||||||
set({ showKeychainModal: true });
|
set({ showKeychainModal: true });
|
||||||
throw new Error('Grant credential-store access before regenerating the pairing token.');
|
throw new Error(i18n.t($ => $.keychain.accessRequired));
|
||||||
}
|
}
|
||||||
const result = await invoke('regenerate_pairing_token');
|
const result = await invoke('regenerate_pairing_token');
|
||||||
if (!result.persistent) {
|
if (!result.persistent) {
|
||||||
throw new Error(result.error || 'Credential store access is unavailable.');
|
throw new Error(result.error || i18n.t($ => $.keychain.storeUnavailable));
|
||||||
}
|
}
|
||||||
set({
|
set({
|
||||||
extensionPairingToken: result.token,
|
extensionPairingToken: result.token,
|
||||||
@@ -474,7 +562,7 @@ export const useSettingsStore = create<SettingsState>()(
|
|||||||
{
|
{
|
||||||
name: 'firelink-settings',
|
name: 'firelink-settings',
|
||||||
storage: createJSONStorage(() => tauriStorage),
|
storage: createJSONStorage(() => tauriStorage),
|
||||||
version: 3,
|
version: 6,
|
||||||
migrate: (persistedState) => {
|
migrate: (persistedState) => {
|
||||||
if (!persistedState || typeof persistedState !== 'object') {
|
if (!persistedState || typeof persistedState !== 'object') {
|
||||||
return persistedState as SettingsState;
|
return persistedState as SettingsState;
|
||||||
@@ -510,16 +598,22 @@ export const useSettingsStore = create<SettingsState>()(
|
|||||||
},
|
},
|
||||||
partialize: (state): PersistedSettingsSnapshot => ({
|
partialize: (state): PersistedSettingsSnapshot => ({
|
||||||
theme: state.theme,
|
theme: state.theme,
|
||||||
|
fontFamily: state.fontFamily,
|
||||||
|
windowControlStyle: state.windowControlStyle,
|
||||||
|
calendarPreference: state.calendarPreference,
|
||||||
|
language: state.language,
|
||||||
baseDownloadFolder: state.baseDownloadFolder,
|
baseDownloadFolder: state.baseDownloadFolder,
|
||||||
categorySubfoldersEnabled: state.categorySubfoldersEnabled,
|
categorySubfoldersEnabled: state.categorySubfoldersEnabled,
|
||||||
categorySubfolders: state.categorySubfolders,
|
categorySubfolders: state.categorySubfolders,
|
||||||
categoryDirectoryOverrides: state.categoryDirectoryOverrides,
|
categoryDirectoryOverrides: state.categoryDirectoryOverrides,
|
||||||
|
rememberLastUsedDownloadDirectory: state.rememberLastUsedDownloadDirectory,
|
||||||
approvedDownloadRoots: state.approvedDownloadRoots,
|
approvedDownloadRoots: state.approvedDownloadRoots,
|
||||||
maxConcurrentDownloads: state.maxConcurrentDownloads,
|
maxConcurrentDownloads: state.maxConcurrentDownloads,
|
||||||
globalSpeedLimit: state.globalSpeedLimit,
|
globalSpeedLimit: state.globalSpeedLimit,
|
||||||
speedLimitPresetValues: state.speedLimitPresetValues,
|
speedLimitPresetValues: state.speedLimitPresetValues,
|
||||||
logsEnabled: state.logsEnabled,
|
logsEnabled: state.logsEnabled,
|
||||||
isSidebarVisible: state.isSidebarVisible,
|
isSidebarVisible: state.isSidebarVisible,
|
||||||
|
sidebarPosition: state.sidebarPosition,
|
||||||
activeSettingsTab: state.activeSettingsTab,
|
activeSettingsTab: state.activeSettingsTab,
|
||||||
scheduler: state.scheduler,
|
scheduler: state.scheduler,
|
||||||
schedulerRunning: state.schedulerRunning,
|
schedulerRunning: state.schedulerRunning,
|
||||||
@@ -544,6 +638,7 @@ export const useSettingsStore = create<SettingsState>()(
|
|||||||
customUserAgent: state.customUserAgent,
|
customUserAgent: state.customUserAgent,
|
||||||
askWhereToSaveEachFile: state.askWhereToSaveEachFile,
|
askWhereToSaveEachFile: state.askWhereToSaveEachFile,
|
||||||
preventsSleepWhileDownloading: state.preventsSleepWhileDownloading,
|
preventsSleepWhileDownloading: state.preventsSleepWhileDownloading,
|
||||||
|
preventsDisplaySleepWhileDownloading: state.preventsDisplaySleepWhileDownloading,
|
||||||
mediaCookieSource: state.mediaCookieSource,
|
mediaCookieSource: state.mediaCookieSource,
|
||||||
siteLogins: state.siteLogins,
|
siteLogins: state.siteLogins,
|
||||||
keychainAccessGranted: state.keychainAccessGranted,
|
keychainAccessGranted: state.keychainAccessGranted,
|
||||||
@@ -561,16 +656,33 @@ export const useSettingsStore = create<SettingsState>()(
|
|||||||
...persisted,
|
...persisted,
|
||||||
...locations,
|
...locations,
|
||||||
extensionPairingToken: currentState.extensionPairingToken,
|
extensionPairingToken: currentState.extensionPairingToken,
|
||||||
|
// Never hydrate the remembered Add-window path from persisted data.
|
||||||
|
lastUsedDownloadDirectory: currentState.lastUsedDownloadDirectory,
|
||||||
keychainAccessReady: currentState.keychainAccessReady,
|
keychainAccessReady: currentState.keychainAccessReady,
|
||||||
theme: isAllowedSetting(THEME_VALUES, persisted.theme)
|
theme: isAllowedSetting(THEME_VALUES, persisted.theme)
|
||||||
? persisted.theme
|
? persisted.theme
|
||||||
: currentState.theme,
|
: currentState.theme,
|
||||||
|
fontFamily: isAllowedSetting(FONT_FAMILY_VALUES, persisted.fontFamily)
|
||||||
|
? persisted.fontFamily
|
||||||
|
: currentState.fontFamily,
|
||||||
|
windowControlStyle: isAllowedSetting(WINDOW_CONTROL_STYLE_VALUES, persisted.windowControlStyle)
|
||||||
|
? persisted.windowControlStyle
|
||||||
|
: currentState.windowControlStyle,
|
||||||
|
calendarPreference: isCalendarPreference(persisted.calendarPreference)
|
||||||
|
? persisted.calendarPreference
|
||||||
|
: currentState.calendarPreference,
|
||||||
|
language: isAppLocalePreference(persisted.language)
|
||||||
|
? persisted.language
|
||||||
|
: currentState.language,
|
||||||
appFontSize: isAllowedSetting(APP_FONT_SIZE_VALUES, persisted.appFontSize)
|
appFontSize: isAllowedSetting(APP_FONT_SIZE_VALUES, persisted.appFontSize)
|
||||||
? persisted.appFontSize
|
? persisted.appFontSize
|
||||||
: currentState.appFontSize,
|
: currentState.appFontSize,
|
||||||
listRowDensity: isAllowedSetting(LIST_ROW_DENSITY_VALUES, persisted.listRowDensity)
|
listRowDensity: isAllowedSetting(LIST_ROW_DENSITY_VALUES, persisted.listRowDensity)
|
||||||
? persisted.listRowDensity
|
? persisted.listRowDensity
|
||||||
: currentState.listRowDensity,
|
: currentState.listRowDensity,
|
||||||
|
sidebarPosition: isAllowedSetting(SIDEBAR_POSITION_VALUES, persisted.sidebarPosition)
|
||||||
|
? persisted.sidebarPosition
|
||||||
|
: currentState.sidebarPosition,
|
||||||
proxyMode: isAllowedSetting(PROXY_MODE_VALUES, persisted.proxyMode)
|
proxyMode: isAllowedSetting(PROXY_MODE_VALUES, persisted.proxyMode)
|
||||||
? persisted.proxyMode
|
? persisted.proxyMode
|
||||||
: currentState.proxyMode,
|
: currentState.proxyMode,
|
||||||
@@ -586,6 +698,10 @@ export const useSettingsStore = create<SettingsState>()(
|
|||||||
persisted.autoAddClipboardLinks,
|
persisted.autoAddClipboardLinks,
|
||||||
currentState.autoAddClipboardLinks
|
currentState.autoAddClipboardLinks
|
||||||
),
|
),
|
||||||
|
rememberLastUsedDownloadDirectory: persistedBoolean(
|
||||||
|
persisted.rememberLastUsedDownloadDirectory,
|
||||||
|
currentState.rememberLastUsedDownloadDirectory
|
||||||
|
),
|
||||||
showDockBadge: persistedBoolean(persisted.showDockBadge, currentState.showDockBadge),
|
showDockBadge: persistedBoolean(persisted.showDockBadge, currentState.showDockBadge),
|
||||||
showMenuBarIcon: persistedBoolean(persisted.showMenuBarIcon, currentState.showMenuBarIcon),
|
showMenuBarIcon: persistedBoolean(persisted.showMenuBarIcon, currentState.showMenuBarIcon),
|
||||||
askWhereToSaveEachFile: persistedBoolean(
|
askWhereToSaveEachFile: persistedBoolean(
|
||||||
@@ -596,6 +712,10 @@ export const useSettingsStore = create<SettingsState>()(
|
|||||||
persisted.preventsSleepWhileDownloading,
|
persisted.preventsSleepWhileDownloading,
|
||||||
currentState.preventsSleepWhileDownloading
|
currentState.preventsSleepWhileDownloading
|
||||||
),
|
),
|
||||||
|
preventsDisplaySleepWhileDownloading: persistedBoolean(
|
||||||
|
persisted.preventsDisplaySleepWhileDownloading,
|
||||||
|
currentState.preventsDisplaySleepWhileDownloading
|
||||||
|
),
|
||||||
keychainAccessGranted: persistedBoolean(
|
keychainAccessGranted: persistedBoolean(
|
||||||
persisted.keychainAccessGranted,
|
persisted.keychainAccessGranted,
|
||||||
currentState.keychainAccessGranted
|
currentState.keychainAccessGranted
|
||||||
|
|||||||
@@ -1,17 +1,25 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import {
|
import {
|
||||||
appendRequestUrlsAfterVersion,
|
appendRequestUrlsAfterVersion,
|
||||||
|
commonMediaFormatsForRows,
|
||||||
canSubmitMetadataRows,
|
canSubmitMetadataRows,
|
||||||
|
commonMediaQualitiesForRows,
|
||||||
mediaFormatSelectorForRow,
|
mediaFormatSelectorForRow,
|
||||||
mediaFileNameForSelectedFormat,
|
mediaFileNameForSelectedFormat,
|
||||||
|
mediaFormatForFormat,
|
||||||
|
mediaQualityForRow,
|
||||||
|
mediaTypeForFormat,
|
||||||
metadataSummaryMessage,
|
metadataSummaryMessage,
|
||||||
isYouTubePlaylistUrl,
|
isYouTubePlaylistUrl,
|
||||||
playlistFilePrefix,
|
playlistFilePrefix,
|
||||||
reconcileDownloadRows,
|
reconcileDownloadRows,
|
||||||
refreshFailedMetadataRows,
|
refreshFailedMetadataRows,
|
||||||
|
selectExactMediaSelection,
|
||||||
|
selectExactMediaQuality,
|
||||||
updateRowIfCurrent,
|
updateRowIfCurrent,
|
||||||
type AddDownloadDraftRow
|
type AddDownloadDraftRow
|
||||||
} from './addDownloadMetadata';
|
} from './addDownloadMetadata';
|
||||||
|
import i18n, { changeAppLocale } from '../i18n';
|
||||||
|
|
||||||
const row = (
|
const row = (
|
||||||
overrides: Partial<AddDownloadDraftRow> = {}
|
overrides: Partial<AddDownloadDraftRow> = {}
|
||||||
@@ -537,4 +545,215 @@ describe('add download metadata workflow', () => {
|
|||||||
row({ status: 'invalid' })
|
row({ status: 'invalid' })
|
||||||
])).toContain('Correct or remove 1 invalid URL');
|
])).toContain('Correct or remove 1 invalid URL');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('offers only qualities shared by every selected ready media row', () => {
|
||||||
|
const formats = (qualities: string[]) => qualities.map((quality, index) => ({
|
||||||
|
name: `${quality} MP4`,
|
||||||
|
quality,
|
||||||
|
selector: `${quality}-${index}`,
|
||||||
|
ext: 'mp4',
|
||||||
|
formatLabel: quality,
|
||||||
|
detail: '10 MB',
|
||||||
|
type: 'Video',
|
||||||
|
bytes: 10
|
||||||
|
}));
|
||||||
|
const rows = [
|
||||||
|
row({ id: 'one', isMedia: true, formats: formats(['1080p', '720p']), selectedFormat: 0 }),
|
||||||
|
row({ id: 'two', isMedia: true, formats: formats(['720p', '480p']), selectedFormat: 0 })
|
||||||
|
];
|
||||||
|
|
||||||
|
expect(commonMediaQualitiesForRows(rows)).toEqual(['720p']);
|
||||||
|
expect(mediaQualityForRow(rows[0])).toBe('1080p');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps playlist choices available when one ready row remains selected', () => {
|
||||||
|
const formats = [
|
||||||
|
{
|
||||||
|
name: '720p MP4',
|
||||||
|
quality: '720p',
|
||||||
|
selector: '720',
|
||||||
|
ext: 'mp4',
|
||||||
|
formatLabel: 'MP4 • H.264',
|
||||||
|
detail: '10 MB',
|
||||||
|
type: 'Video',
|
||||||
|
bytes: 10
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Audio only M4A',
|
||||||
|
quality: 'Audio only',
|
||||||
|
selector: 'audio',
|
||||||
|
ext: 'm4a',
|
||||||
|
formatLabel: 'M4A • AAC',
|
||||||
|
detail: '4 MB',
|
||||||
|
type: 'Audio',
|
||||||
|
bytes: 4
|
||||||
|
}
|
||||||
|
];
|
||||||
|
const selectedRow = row({ isMedia: true, formats, selectedFormat: 0 });
|
||||||
|
|
||||||
|
expect(commonMediaFormatsForRows([selectedRow], 'Video')).toEqual(['MP4']);
|
||||||
|
expect(commonMediaQualitiesForRows([selectedRow], 'Video', 'MP4')).toEqual(['720p']);
|
||||||
|
expect(commonMediaFormatsForRows([selectedRow], 'Audio')).toEqual(['M4A']);
|
||||||
|
expect(commonMediaQualitiesForRows([selectedRow], 'Audio', 'M4A')).toEqual(['Audio only']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not offer a shared format when its qualities do not overlap', () => {
|
||||||
|
const format = (quality: string, id: string) => [{
|
||||||
|
name: `${quality} MP4`,
|
||||||
|
quality,
|
||||||
|
selector: id,
|
||||||
|
ext: 'mp4',
|
||||||
|
formatLabel: 'MP4 • H.264',
|
||||||
|
detail: '10 MB',
|
||||||
|
type: 'Video',
|
||||||
|
bytes: 10
|
||||||
|
}];
|
||||||
|
const rows = [
|
||||||
|
row({ id: 'one', isMedia: true, formats: format('1080p', 'one'), selectedFormat: 0 }),
|
||||||
|
row({ id: 'two', isMedia: true, formats: format('720p', 'two'), selectedFormat: 0 })
|
||||||
|
];
|
||||||
|
|
||||||
|
expect(commonMediaFormatsForRows(rows, 'Video')).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('filters playlist choices by media type and format', () => {
|
||||||
|
const formats = (qualities: string[], idPrefix: string) => [
|
||||||
|
...qualities.flatMap((quality, index) => [
|
||||||
|
{
|
||||||
|
name: `${quality} MKV`,
|
||||||
|
quality,
|
||||||
|
selector: `${idPrefix}-mkv-${index}`,
|
||||||
|
ext: 'mkv',
|
||||||
|
formatLabel: 'MKV • H.264',
|
||||||
|
detail: '10 MB',
|
||||||
|
type: 'Video',
|
||||||
|
bytes: 10
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: `${quality} MP4`,
|
||||||
|
quality,
|
||||||
|
selector: `${idPrefix}-mp4-${index}`,
|
||||||
|
ext: 'mp4',
|
||||||
|
formatLabel: 'MP4 • H.264',
|
||||||
|
detail: '10 MB',
|
||||||
|
type: 'Video',
|
||||||
|
bytes: 10
|
||||||
|
}
|
||||||
|
]),
|
||||||
|
{
|
||||||
|
name: 'Audio only M4A',
|
||||||
|
quality: 'Audio only',
|
||||||
|
selector: `${idPrefix}-m4a`,
|
||||||
|
ext: 'm4a',
|
||||||
|
formatLabel: 'M4A • AAC',
|
||||||
|
detail: '4 MB',
|
||||||
|
type: 'Audio',
|
||||||
|
bytes: 4
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Audio only WEBM',
|
||||||
|
quality: 'Audio only',
|
||||||
|
selector: `${idPrefix}-webm`,
|
||||||
|
ext: 'webm',
|
||||||
|
formatLabel: 'WEBM • Opus',
|
||||||
|
detail: '4 MB',
|
||||||
|
type: 'Audio',
|
||||||
|
bytes: 4
|
||||||
|
}
|
||||||
|
];
|
||||||
|
const rows = [
|
||||||
|
row({ id: 'one', isMedia: true, formats: formats(['1080p', '720p'], 'one'), selectedFormat: 0 }),
|
||||||
|
row({ id: 'two', isMedia: true, formats: formats(['720p', '480p'], 'two'), selectedFormat: 0 })
|
||||||
|
];
|
||||||
|
|
||||||
|
expect(commonMediaFormatsForRows(rows, 'Video')).toEqual(['MKV', 'MP4']);
|
||||||
|
expect(commonMediaQualitiesForRows(rows, 'Video', 'MP4')).toEqual(['720p']);
|
||||||
|
expect(commonMediaFormatsForRows(rows, 'Audio')).toEqual(['M4A', 'WEBM']);
|
||||||
|
expect(commonMediaQualitiesForRows(rows, 'Audio', 'M4A')).toEqual(['Audio only']);
|
||||||
|
expect(mediaTypeForFormat(rows[0].formats![4])).toBe('Audio');
|
||||||
|
expect(mediaFormatForFormat(rows[0].formats![4])).toBe('M4A');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies an exact playlist media selection without falling back to another format', () => {
|
||||||
|
const formats = (id: string) => [
|
||||||
|
{
|
||||||
|
name: '720p MKV',
|
||||||
|
quality: '720p',
|
||||||
|
selector: `${id}-mkv`,
|
||||||
|
ext: 'mkv',
|
||||||
|
formatLabel: 'MKV • H.264',
|
||||||
|
detail: '10 MB',
|
||||||
|
type: 'Video',
|
||||||
|
bytes: 10
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: '720p MP4',
|
||||||
|
quality: '720p',
|
||||||
|
selector: `${id}-mp4`,
|
||||||
|
ext: 'mp4',
|
||||||
|
formatLabel: 'MP4 • H.264',
|
||||||
|
detail: '9 MB',
|
||||||
|
type: 'Video',
|
||||||
|
bytes: 9
|
||||||
|
}
|
||||||
|
];
|
||||||
|
const rows = [
|
||||||
|
row({ id: 'one', isMedia: true, file: 'one.mkv', formats: formats('one'), selectedFormat: 0 }),
|
||||||
|
row({ id: 'two', isMedia: true, file: 'two.mkv', formats: formats('two'), selectedFormat: 0 })
|
||||||
|
];
|
||||||
|
|
||||||
|
const selected = selectExactMediaSelection(rows, ['one', 'two'], {
|
||||||
|
mediaType: 'Video',
|
||||||
|
format: 'MP4',
|
||||||
|
quality: '720p'
|
||||||
|
});
|
||||||
|
expect(selected[0]).toMatchObject({ selectedFormat: 1, file: 'one.mp4' });
|
||||||
|
expect(selected[1]).toMatchObject({ selectedFormat: 1, file: 'two.mp4' });
|
||||||
|
expect(selectExactMediaSelection(rows, ['one', 'two'], {
|
||||||
|
mediaType: 'Video',
|
||||||
|
format: 'WEBM',
|
||||||
|
quality: '720p'
|
||||||
|
})).toEqual(rows);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies an exact bulk quality without falling back to a higher or lower stream', () => {
|
||||||
|
const mediaRow = row({
|
||||||
|
id: 'media',
|
||||||
|
file: 'clip.mp4',
|
||||||
|
isMedia: true,
|
||||||
|
formats: [{
|
||||||
|
name: '1080p MP4',
|
||||||
|
quality: '1080p',
|
||||||
|
selector: '1080',
|
||||||
|
ext: 'mp4',
|
||||||
|
formatLabel: '1080p',
|
||||||
|
detail: '10 MB',
|
||||||
|
type: 'Video',
|
||||||
|
bytes: 10
|
||||||
|
}],
|
||||||
|
selectedFormat: 0
|
||||||
|
});
|
||||||
|
|
||||||
|
const unchanged = selectExactMediaQuality([mediaRow], ['media'], '720p');
|
||||||
|
expect(unchanged[0]).toBe(mediaRow);
|
||||||
|
expect(selectExactMediaQuality([mediaRow], ['media'], '1080p')[0]).toMatchObject({
|
||||||
|
selectedFormat: 0,
|
||||||
|
file: 'clip.mp4'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
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');
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,11 +4,14 @@ import {
|
|||||||
isMediaUrl
|
isMediaUrl
|
||||||
} from './downloads';
|
} from './downloads';
|
||||||
import type { MediaPlaylistMetadata } from '../bindings/MediaPlaylistMetadata';
|
import type { MediaPlaylistMetadata } from '../bindings/MediaPlaylistMetadata';
|
||||||
|
import i18n from '../i18n';
|
||||||
|
import { localePluralVariant } from '../i18n/locales';
|
||||||
|
|
||||||
export type MetadataStatus = 'loading' | 'ready' | 'metadata-error' | 'invalid';
|
export type MetadataStatus = 'loading' | 'ready' | 'metadata-error' | 'invalid';
|
||||||
|
|
||||||
export interface AddMediaFormat {
|
export interface AddMediaFormat {
|
||||||
name: string;
|
name: string;
|
||||||
|
quality?: string;
|
||||||
selector: string;
|
selector: string;
|
||||||
ext: string;
|
ext: string;
|
||||||
formatLabel: string;
|
formatLabel: string;
|
||||||
@@ -18,6 +21,14 @@ export interface AddMediaFormat {
|
|||||||
isApproximate?: boolean;
|
isApproximate?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type MediaType = 'Audio' | 'Video';
|
||||||
|
|
||||||
|
export interface MediaSelection {
|
||||||
|
mediaType: MediaType;
|
||||||
|
format: string;
|
||||||
|
quality: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface AddDownloadDraftRow {
|
export interface AddDownloadDraftRow {
|
||||||
id: string;
|
id: string;
|
||||||
sourceUrl: string;
|
sourceUrl: string;
|
||||||
@@ -329,6 +340,137 @@ export const mediaFormatSelectorForRow = (
|
|||||||
return row.formats?.[row.selectedFormat]?.selector;
|
return row.formats?.[row.selectedFormat]?.selector;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const normalizeMediaQualityLabel = (value: string | undefined): string => {
|
||||||
|
const normalized = value
|
||||||
|
?.replace(/[\u0000-\u001f\u007f]+/g, ' ')
|
||||||
|
.replace(/\s+/g, ' ')
|
||||||
|
.trim();
|
||||||
|
return normalized && normalized.length <= 48 ? normalized : '';
|
||||||
|
};
|
||||||
|
|
||||||
|
export const mediaQualityForFormat = (
|
||||||
|
format: Pick<AddMediaFormat, 'quality' | 'name' | 'type' | 'formatLabel' | 'ext'>
|
||||||
|
): string => {
|
||||||
|
const explicitQuality = normalizeMediaQualityLabel(format.quality);
|
||||||
|
if (explicitQuality) return explicitQuality;
|
||||||
|
|
||||||
|
const nameQuality = normalizeMediaQualityLabel(format.name.trim().split(/\s+/)[0]);
|
||||||
|
if (nameQuality) return nameQuality;
|
||||||
|
|
||||||
|
const label = normalizeMediaQualityLabel(format.formatLabel);
|
||||||
|
return label || normalizeMediaQualityLabel(format.type) || normalizeMediaQualityLabel(format.ext.toUpperCase()) || 'Media';
|
||||||
|
};
|
||||||
|
|
||||||
|
export const mediaTypeForFormat = (
|
||||||
|
format: Pick<AddMediaFormat, 'type'>
|
||||||
|
): MediaType => format.type.toLowerCase().includes('audio') ? 'Audio' : 'Video';
|
||||||
|
|
||||||
|
export const mediaFormatForFormat = (
|
||||||
|
format: Pick<AddMediaFormat, 'ext'>
|
||||||
|
): string => normalizeMediaQualityLabel(format.ext.replace(/^\.+/, '').toUpperCase()) || 'MEDIA';
|
||||||
|
|
||||||
|
export const mediaQualityForRow = (
|
||||||
|
row: Pick<AddDownloadDraftRow, 'isMedia' | 'status' | 'formats' | 'selectedFormat'>
|
||||||
|
): string | undefined => {
|
||||||
|
if (!row.isMedia || row.status !== 'ready' || row.selectedFormat === undefined) return undefined;
|
||||||
|
const format = row.formats?.[row.selectedFormat];
|
||||||
|
return format ? mediaQualityForFormat(format) : undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const commonMediaQualitiesForRows = (
|
||||||
|
rows: ReadonlyArray<Pick<AddDownloadDraftRow, 'isMedia' | 'status' | 'formats'>>,
|
||||||
|
mediaType?: MediaType,
|
||||||
|
mediaFormat?: string
|
||||||
|
): string[] => {
|
||||||
|
const readyMediaRows = rows.filter(row => row.isMedia && row.status === 'ready' && row.formats?.length);
|
||||||
|
if (readyMediaRows.length < 1) return [];
|
||||||
|
|
||||||
|
const matches = (format: AddMediaFormat) =>
|
||||||
|
(!mediaType || mediaTypeForFormat(format) === mediaType)
|
||||||
|
&& (!mediaFormat || mediaFormatForFormat(format) === mediaFormat);
|
||||||
|
|
||||||
|
const firstQualities = Array.from(new Set(
|
||||||
|
readyMediaRows[0].formats!.filter(matches).map(mediaQualityForFormat)
|
||||||
|
));
|
||||||
|
return firstQualities.filter(quality => readyMediaRows.every(row =>
|
||||||
|
row.formats!.some(format => matches(format) && mediaQualityForFormat(format) === quality)
|
||||||
|
));
|
||||||
|
};
|
||||||
|
|
||||||
|
export const commonMediaFormatsForRows = (
|
||||||
|
rows: ReadonlyArray<Pick<AddDownloadDraftRow, 'isMedia' | 'status' | 'formats'>>,
|
||||||
|
mediaType: MediaType
|
||||||
|
): string[] => {
|
||||||
|
const readyMediaRows = rows.filter(row => row.isMedia && row.status === 'ready' && row.formats?.length);
|
||||||
|
if (readyMediaRows.length < 1) return [];
|
||||||
|
|
||||||
|
const firstFormats = Array.from(new Set(
|
||||||
|
readyMediaRows[0].formats!
|
||||||
|
.filter(format => mediaTypeForFormat(format) === mediaType)
|
||||||
|
.map(mediaFormatForFormat)
|
||||||
|
));
|
||||||
|
return firstFormats.filter(mediaFormat =>
|
||||||
|
readyMediaRows.every(row =>
|
||||||
|
row.formats!.some(format =>
|
||||||
|
mediaTypeForFormat(format) === mediaType && mediaFormatForFormat(format) === mediaFormat
|
||||||
|
)
|
||||||
|
)
|
||||||
|
&& commonMediaQualitiesForRows(readyMediaRows, mediaType, mediaFormat).length > 0
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const selectExactMediaQuality = (
|
||||||
|
rows: AddDownloadDraftRow[],
|
||||||
|
selectedIds: ReadonlySet<string> | readonly string[],
|
||||||
|
quality: string
|
||||||
|
): AddDownloadDraftRow[] => {
|
||||||
|
const selected = selectedIds instanceof Set ? selectedIds : new Set(selectedIds);
|
||||||
|
return rows.map(row => {
|
||||||
|
if (!selected.has(row.id) || !row.isMedia || row.status !== 'ready' || !row.formats) return row;
|
||||||
|
const selectedFormat = row.formats.findIndex(format => mediaQualityForFormat(format) === quality);
|
||||||
|
if (selectedFormat === -1) return row;
|
||||||
|
const format = row.formats[selectedFormat];
|
||||||
|
return {
|
||||||
|
...row,
|
||||||
|
selectedFormat,
|
||||||
|
size: format.bytes ? format.detail : undefined,
|
||||||
|
sizeBytes: format.bytes || undefined,
|
||||||
|
file: mediaFileNameForSelectedFormat(row.file, {
|
||||||
|
formats: row.formats,
|
||||||
|
selectedFormat
|
||||||
|
})
|
||||||
|
};
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const selectExactMediaSelection = (
|
||||||
|
rows: AddDownloadDraftRow[],
|
||||||
|
selectedIds: ReadonlySet<string> | readonly string[],
|
||||||
|
selection: MediaSelection
|
||||||
|
): AddDownloadDraftRow[] => {
|
||||||
|
const selected = selectedIds instanceof Set ? selectedIds : new Set(selectedIds);
|
||||||
|
return rows.map(row => {
|
||||||
|
if (!selected.has(row.id) || !row.isMedia || row.status !== 'ready' || !row.formats) return row;
|
||||||
|
const selectedFormat = row.formats.findIndex(format =>
|
||||||
|
mediaTypeForFormat(format) === selection.mediaType
|
||||||
|
&& mediaFormatForFormat(format) === selection.format
|
||||||
|
&& mediaQualityForFormat(format) === selection.quality
|
||||||
|
);
|
||||||
|
if (selectedFormat === -1) return row;
|
||||||
|
const format = row.formats[selectedFormat];
|
||||||
|
return {
|
||||||
|
...row,
|
||||||
|
selectedFormat,
|
||||||
|
size: format.bytes ? format.detail : undefined,
|
||||||
|
sizeBytes: format.bytes || undefined,
|
||||||
|
file: mediaFileNameForSelectedFormat(row.file, {
|
||||||
|
formats: row.formats,
|
||||||
|
selectedFormat
|
||||||
|
})
|
||||||
|
};
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
export const mediaFileNameForSelectedFormat = (
|
export const mediaFileNameForSelectedFormat = (
|
||||||
fileName: string,
|
fileName: string,
|
||||||
row: Pick<AddDownloadDraftRow, 'formats' | 'selectedFormat'>
|
row: Pick<AddDownloadDraftRow, 'formats' | 'selectedFormat'>
|
||||||
@@ -361,20 +503,77 @@ export const mediaFileNameForSelectedFormat = (
|
|||||||
return canonicalizeDownloadFileName(`${baseName}.${selectedExt}`);
|
return canonicalizeDownloadFileName(`${baseName}.${selectedExt}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const metadataSummaryMessage = (rows: AddDownloadDraftRow[]): string => {
|
export type MetadataSummaryState =
|
||||||
if (rows.length === 0) return 'Paste one or more links.';
|
| { 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);
|
const selectedRows = rows.filter(row => row.selected !== false);
|
||||||
if (selectedRows.length === 0) return 'Select at least one download.';
|
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 => {
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
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;
|
const invalid = selectedRows.filter(row => row.status === 'invalid').length;
|
||||||
if (invalid > 0) {
|
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 = selectedRows.filter(row => row.status === 'loading').length;
|
const loading = selectedRows.filter(row => row.status === 'loading').length;
|
||||||
if (loading > 0) {
|
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 = selectedRows.filter(row => row.status === 'metadata-error').length;
|
const failed = selectedRows.filter(row => row.status === 'metadata-error').length;
|
||||||
@@ -382,16 +581,36 @@ export const metadataSummaryMessage = (rows: AddDownloadDraftRow[]): string => {
|
|||||||
const blocked = selectedRows.filter(row => row.metadataBlockedReason === 'unsafe-url').length;
|
const blocked = selectedRows.filter(row => row.metadataBlockedReason === 'unsafe-url').length;
|
||||||
const ready = selectedRows.filter(row => row.status === 'ready').length;
|
const ready = selectedRows.filter(row => row.status === 'ready').length;
|
||||||
if (blocked > 0) {
|
if (blocked > 0) {
|
||||||
return `Remove ${blocked} unsafe URL${blocked === 1 ? '' : 's'} before continuing.`;
|
return pluralMessage(
|
||||||
|
blocked,
|
||||||
|
() => i18n.t($ => $.addDownloads.removeUnsafeOne, { count: blocked }),
|
||||||
|
() => i18n.t($ => $.addDownloads.removeUnsafeFew, { count: blocked }),
|
||||||
|
() => i18n.t($ => $.addDownloads.removeUnsafeMany, { count: blocked })
|
||||||
|
);
|
||||||
}
|
}
|
||||||
if (failedMedia > 0) {
|
if (failedMedia > 0) {
|
||||||
return `Media metadata is unavailable for ${failedMedia} item${failedMedia === 1 ? '' : 's'}. Refresh metadata before adding.`;
|
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) {
|
if (failed === selectedRows.length) {
|
||||||
return 'Metadata is unavailable. Downloads can still be added using fallback details.';
|
return i18n.t($ => $.addDownloads.metadataUnavailableFallback);
|
||||||
}
|
}
|
||||||
if (failed > 0) {
|
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,35 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import {
|
||||||
|
DEFAULT_CALENDAR_PREFERENCE,
|
||||||
|
formatDateTime,
|
||||||
|
isCalendarPreference
|
||||||
|
} from './dateTime';
|
||||||
|
|
||||||
|
describe('date/time formatting', () => {
|
||||||
|
const instant = new Date('2026-03-21T14:30:00.000Z');
|
||||||
|
|
||||||
|
it('uses Gregorian explicitly by default, including in localized UI languages', () => {
|
||||||
|
const options: Intl.DateTimeFormatOptions = { dateStyle: 'medium', timeStyle: 'short' };
|
||||||
|
expect(formatDateTime(instant, { locale: 'fa', options })).toBe(
|
||||||
|
new Intl.DateTimeFormat('fa-u-ca-gregory', options).format(instant)
|
||||||
|
);
|
||||||
|
expect(DEFAULT_CALENDAR_PREFERENCE).toBe('gregorian');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('supports the opt-in Persian and Hebrew calendars', () => {
|
||||||
|
const options: Intl.DateTimeFormatOptions = { dateStyle: 'long' };
|
||||||
|
expect(formatDateTime(instant, { locale: 'fa', calendar: 'persian', options })).toBe(
|
||||||
|
new Intl.DateTimeFormat('fa-u-ca-persian', options).format(instant)
|
||||||
|
);
|
||||||
|
expect(formatDateTime(instant, { locale: 'he', calendar: 'hebrew', options })).toBe(
|
||||||
|
new Intl.DateTimeFormat('he-u-ca-hebrew', options).format(instant)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns a safe placeholder for malformed timestamps and rejects unknown preferences', () => {
|
||||||
|
expect(formatDateTime('not-a-timestamp', { locale: 'en' })).toBe('-');
|
||||||
|
expect(isCalendarPreference('gregorian')).toBe(true);
|
||||||
|
expect(isCalendarPreference('lunar')).toBe(false);
|
||||||
|
expect(isCalendarPreference(null)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import type { CalendarPreference } from '../bindings/CalendarPreference';
|
||||||
|
import { resolveAppLocale } from '../i18n/locales';
|
||||||
|
|
||||||
|
export type { CalendarPreference } from '../bindings/CalendarPreference';
|
||||||
|
|
||||||
|
export const CALENDAR_PREFERENCES = ['gregorian', 'persian', 'hebrew'] as const satisfies readonly CalendarPreference[];
|
||||||
|
export const DEFAULT_CALENDAR_PREFERENCE: CalendarPreference = 'gregorian';
|
||||||
|
|
||||||
|
export const isCalendarPreference = (value: unknown): value is CalendarPreference =>
|
||||||
|
typeof value === 'string' && (CALENDAR_PREFERENCES as readonly string[]).includes(value);
|
||||||
|
|
||||||
|
type DateTimeInput = Date | string | number;
|
||||||
|
|
||||||
|
interface DateTimeFormatConfig {
|
||||||
|
locale?: string | null;
|
||||||
|
calendar?: CalendarPreference;
|
||||||
|
options?: Intl.DateTimeFormatOptions;
|
||||||
|
}
|
||||||
|
|
||||||
|
const calendarIdentifier = (calendar: CalendarPreference): string =>
|
||||||
|
calendar === 'gregorian' ? 'gregory' : calendar;
|
||||||
|
|
||||||
|
const localeWithCalendar = (locale: string | null | undefined, calendar: CalendarPreference): string => {
|
||||||
|
const resolvedLocale = resolveAppLocale(locale);
|
||||||
|
return `${resolvedLocale}-u-ca-${calendarIdentifier(calendar)}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const dateFromInput = (value: DateTimeInput): Date =>
|
||||||
|
value instanceof Date ? new Date(value.getTime()) : new Date(value);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format a user-facing timestamp with an explicit calendar. Gregorian is
|
||||||
|
* passed explicitly because some localized browser defaults use a regional
|
||||||
|
* calendar even when the user has not opted into one.
|
||||||
|
*/
|
||||||
|
export const formatDateTime = (
|
||||||
|
value: DateTimeInput,
|
||||||
|
config: DateTimeFormatConfig = {}
|
||||||
|
): string => {
|
||||||
|
const date = dateFromInput(value);
|
||||||
|
if (Number.isNaN(date.getTime())) return '-';
|
||||||
|
|
||||||
|
const calendar = config.calendar && isCalendarPreference(config.calendar)
|
||||||
|
? config.calendar
|
||||||
|
: DEFAULT_CALENDAR_PREFERENCE;
|
||||||
|
const options = config.options ?? {};
|
||||||
|
|
||||||
|
try {
|
||||||
|
return new Intl.DateTimeFormat(
|
||||||
|
localeWithCalendar(config.locale, calendar),
|
||||||
|
options
|
||||||
|
).format(date);
|
||||||
|
} catch {
|
||||||
|
// WebViews can have incomplete ICU calendar data. Keep the display useful
|
||||||
|
// and deterministic rather than exposing a RangeError to the UI.
|
||||||
|
try {
|
||||||
|
return new Intl.DateTimeFormat(
|
||||||
|
localeWithCalendar(config.locale, DEFAULT_CALENDAR_PREFERENCE),
|
||||||
|
options
|
||||||
|
).format(date);
|
||||||
|
} catch {
|
||||||
|
return '-';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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 });
|
||||||
|
};
|
||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
canPauseDownload,
|
canPauseDownload,
|
||||||
canRedownload,
|
canRedownload,
|
||||||
canStartDownload,
|
canStartDownload,
|
||||||
|
getPauseResumeAction,
|
||||||
isIdentityLocked,
|
isIdentityLocked,
|
||||||
isTransferLocked,
|
isTransferLocked,
|
||||||
startActionLabel,
|
startActionLabel,
|
||||||
@@ -27,6 +28,18 @@ describe('download action policy', () => {
|
|||||||
expect(canRedownload('downloading')).toBe(false);
|
expect(canRedownload('downloading')).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('only exposes pause or resume for the details-view toggle', () => {
|
||||||
|
expect(getPauseResumeAction('queued')).toBe('pause');
|
||||||
|
expect(getPauseResumeAction('downloading')).toBe('pause');
|
||||||
|
expect(getPauseResumeAction('processing')).toBe('pause');
|
||||||
|
expect(getPauseResumeAction('retrying')).toBe('pause');
|
||||||
|
expect(getPauseResumeAction('paused')).toBe('resume');
|
||||||
|
|
||||||
|
for (const status of ['ready', 'staged', 'completed', 'failed'] as const) {
|
||||||
|
expect(getPauseResumeAction(status)).toBeNull();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it('provides consistent labels and edit locks', () => {
|
it('provides consistent labels and edit locks', () => {
|
||||||
expect(startActionLabel('ready')).toBe('Start');
|
expect(startActionLabel('ready')).toBe('Start');
|
||||||
expect(startActionLabel('failed')).toBe('Start');
|
expect(startActionLabel('failed')).toBe('Start');
|
||||||
|
|||||||
@@ -26,6 +26,14 @@ export const canStartDownload = (status: DownloadStatus): boolean =>
|
|||||||
export const canPauseDownload = (status: DownloadStatus): boolean =>
|
export const canPauseDownload = (status: DownloadStatus): boolean =>
|
||||||
PAUSABLE_STATUSES.has(status);
|
PAUSABLE_STATUSES.has(status);
|
||||||
|
|
||||||
|
export type PauseResumeAction = 'pause' | 'resume';
|
||||||
|
|
||||||
|
export const getPauseResumeAction = (status: DownloadStatus): PauseResumeAction | null => {
|
||||||
|
if (canPauseDownload(status)) return 'pause';
|
||||||
|
if (status === 'paused') return 'resume';
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
export const canRedownload = (status: DownloadStatus): boolean =>
|
export const canRedownload = (status: DownloadStatus): boolean =>
|
||||||
REDOWNLOADABLE_STATUSES.has(status);
|
REDOWNLOADABLE_STATUSES.has(status);
|
||||||
|
|
||||||
|
|||||||
@@ -11,10 +11,14 @@ vi.mock('@tauri-apps/api/path', () => ({
|
|||||||
import {
|
import {
|
||||||
downloadLocationEquals,
|
downloadLocationEquals,
|
||||||
DEFAULT_CATEGORY_SUBFOLDERS,
|
DEFAULT_CATEGORY_SUBFOLDERS,
|
||||||
|
deriveBatchFolderName,
|
||||||
formatDerivedCategoryPath,
|
formatDerivedCategoryPath,
|
||||||
normalizeCategorySubfolder,
|
normalizeCategorySubfolder,
|
||||||
normalizeDownloadLocationSettings,
|
normalizeDownloadLocationSettings,
|
||||||
|
resolveInitialAddWindowLocation,
|
||||||
resolveCategoryDestination,
|
resolveCategoryDestination,
|
||||||
|
resolveSubfolderDestination,
|
||||||
|
sanitizeBatchFolderName,
|
||||||
subfolderFromDerivedCategoryPath
|
subfolderFromDerivedCategoryPath
|
||||||
} from './downloadLocations';
|
} from './downloadLocations';
|
||||||
|
|
||||||
@@ -24,6 +28,67 @@ describe('download locations', () => {
|
|||||||
expect(downloadLocationEquals('/Users/Test', 'Movie.MP4', '/users/test', 'movie.mp4', 'macos')).toBe(false);
|
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);
|
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(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -48,6 +48,113 @@ interface LegacyDownloadLocationSettings {
|
|||||||
downloadDirectories?: unknown;
|
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> => {
|
const stringRecord = (value: unknown): Record<string, string> => {
|
||||||
if (!value || typeof value !== 'object') return {};
|
if (!value || typeof value !== 'object') return {};
|
||||||
return Object.fromEntries(
|
return Object.fromEntries(
|
||||||
|
|||||||
@@ -0,0 +1,180 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import type { DownloadItem } from '../bindings/DownloadItem';
|
||||||
|
import type { DownloadProgressEvent } from '../bindings/DownloadProgressEvent';
|
||||||
|
import { summarizeDownloads } from './downloadSummary';
|
||||||
|
|
||||||
|
const item = (id: string, overrides: Partial<DownloadItem> = {}): DownloadItem => ({
|
||||||
|
id,
|
||||||
|
url: `https://example.com/${id}`,
|
||||||
|
fileName: `${id}.bin`,
|
||||||
|
status: 'ready',
|
||||||
|
category: 'Other',
|
||||||
|
dateAdded: '2026-01-01T00:00:00.000Z',
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
|
||||||
|
const progress = (id: string, overrides: Partial<DownloadProgressEvent> = {}): DownloadProgressEvent => ({
|
||||||
|
id,
|
||||||
|
fraction: 0.5,
|
||||||
|
speed: '1 MiB/s',
|
||||||
|
eta: '1m',
|
||||||
|
size: null,
|
||||||
|
size_is_final: false,
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('download summaries', () => {
|
||||||
|
it('aggregates exact bytes and transfer-active counts', () => {
|
||||||
|
expect(summarizeDownloads([
|
||||||
|
item('active', { status: 'downloading', downloadedBytes: 1024, totalBytes: 4096 }),
|
||||||
|
item('done', { status: 'completed', totalBytes: 2048 }),
|
||||||
|
])).toEqual({
|
||||||
|
itemCount: 2,
|
||||||
|
activeCount: 1,
|
||||||
|
downloadedBytes: 3072,
|
||||||
|
remainingBytes: 3072,
|
||||||
|
remainingIsEstimated: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('treats completed downloads as having no remaining bytes despite stale progress', () => {
|
||||||
|
expect(summarizeDownloads([
|
||||||
|
item('done', {
|
||||||
|
status: 'completed',
|
||||||
|
totalBytes: 40 * 1024 ** 2,
|
||||||
|
downloadedBytes: 40 * 1024 ** 2,
|
||||||
|
}),
|
||||||
|
], {
|
||||||
|
done: progress('done', {
|
||||||
|
downloaded_bytes: 40 * 1024 ** 2 - 555 * 1024,
|
||||||
|
total_bytes: 555 * 1024,
|
||||||
|
}),
|
||||||
|
})).toEqual({
|
||||||
|
itemCount: 1,
|
||||||
|
activeCount: 0,
|
||||||
|
downloadedBytes: 40 * 1024 ** 2,
|
||||||
|
remainingBytes: 0,
|
||||||
|
remainingIsEstimated: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not present partial byte totals as complete aggregates', () => {
|
||||||
|
expect(summarizeDownloads([
|
||||||
|
item('known', { totalBytes: 100, downloadedBytes: 20 }),
|
||||||
|
item('unknown', { status: 'failed' }),
|
||||||
|
])).toMatchObject({
|
||||||
|
itemCount: 2,
|
||||||
|
downloadedBytes: null,
|
||||||
|
remainingBytes: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('infers zero bytes for an unstarted paused item but preserves partial unknowns', () => {
|
||||||
|
expect(summarizeDownloads([
|
||||||
|
item('paused', { status: 'paused', totalBytes: 1000 }),
|
||||||
|
])).toMatchObject({
|
||||||
|
downloadedBytes: 0,
|
||||||
|
remainingBytes: 1000,
|
||||||
|
remainingIsEstimated: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(summarizeDownloads([
|
||||||
|
item('partial', { status: 'paused', fraction: 0.4, totalBytes: 1000 }),
|
||||||
|
]).downloadedBytes).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('treats a fresh media item as having no downloaded bytes before its first progress event', () => {
|
||||||
|
expect(summarizeDownloads([
|
||||||
|
item('media', {
|
||||||
|
status: 'ready',
|
||||||
|
isMedia: true,
|
||||||
|
totalBytes: 1024,
|
||||||
|
totalIsEstimate: true,
|
||||||
|
}),
|
||||||
|
])).toMatchObject({
|
||||||
|
downloadedBytes: 0,
|
||||||
|
remainingBytes: 1024,
|
||||||
|
remainingIsEstimated: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps non-final media progress on the stored estimated denominator', () => {
|
||||||
|
expect(summarizeDownloads([
|
||||||
|
item('media', {
|
||||||
|
status: 'downloading',
|
||||||
|
isMedia: true,
|
||||||
|
totalBytes: 2 * 1024 ** 2,
|
||||||
|
size: '~2 MB',
|
||||||
|
}),
|
||||||
|
], {
|
||||||
|
media: progress('media', {
|
||||||
|
downloaded_bytes: 512 * 1024,
|
||||||
|
total_bytes: 1024,
|
||||||
|
total_is_estimate: true,
|
||||||
|
}),
|
||||||
|
})).toEqual({
|
||||||
|
itemCount: 1,
|
||||||
|
activeCount: 1,
|
||||||
|
downloadedBytes: 512 * 1024,
|
||||||
|
remainingBytes: 2 * 1024 ** 2 - 512 * 1024,
|
||||||
|
remainingIsEstimated: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not claim zero remaining when an estimate is already below observed bytes', () => {
|
||||||
|
expect(summarizeDownloads([
|
||||||
|
item('media', {
|
||||||
|
status: 'downloading',
|
||||||
|
isMedia: true,
|
||||||
|
downloadedBytes: 150,
|
||||||
|
totalBytes: 100,
|
||||||
|
totalIsEstimate: true,
|
||||||
|
}),
|
||||||
|
])).toMatchObject({
|
||||||
|
downloadedBytes: 150,
|
||||||
|
remainingBytes: null,
|
||||||
|
remainingIsEstimated: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses a final media progress total when one is available', () => {
|
||||||
|
expect(summarizeDownloads([
|
||||||
|
item('media', {
|
||||||
|
status: 'processing',
|
||||||
|
isMedia: true,
|
||||||
|
totalBytes: 2 * 1024 ** 2,
|
||||||
|
totalIsEstimate: true,
|
||||||
|
}),
|
||||||
|
], {
|
||||||
|
media: progress('media', {
|
||||||
|
downloaded_bytes: 3 * 1024 ** 2,
|
||||||
|
total_bytes: 3 * 1024 ** 2,
|
||||||
|
size_is_final: true,
|
||||||
|
total_is_estimate: false,
|
||||||
|
}),
|
||||||
|
})).toMatchObject({
|
||||||
|
downloadedBytes: 3 * 1024 ** 2,
|
||||||
|
remainingBytes: 0,
|
||||||
|
remainingIsEstimated: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not overflow aggregate byte counters', () => {
|
||||||
|
const huge = Number.MAX_VALUE;
|
||||||
|
expect(summarizeDownloads([
|
||||||
|
item('one', { totalBytes: huge, downloadedBytes: huge }),
|
||||||
|
item('two', { totalBytes: huge, downloadedBytes: huge }),
|
||||||
|
])).toMatchObject({
|
||||||
|
downloadedBytes: null,
|
||||||
|
remainingBytes: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(summarizeDownloads([
|
||||||
|
item('three', { totalBytes: huge, downloadedBytes: 0 }),
|
||||||
|
item('four', { totalBytes: huge, downloadedBytes: 0 }),
|
||||||
|
])).toMatchObject({
|
||||||
|
downloadedBytes: 0,
|
||||||
|
remainingBytes: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
import type { DownloadItem } from '../bindings/DownloadItem';
|
||||||
|
import type { DownloadProgressEvent } from '../bindings/DownloadProgressEvent';
|
||||||
|
import { isTransferActiveStatus } from './downloads';
|
||||||
|
|
||||||
|
export interface DownloadSummary {
|
||||||
|
itemCount: number;
|
||||||
|
activeCount: number;
|
||||||
|
downloadedBytes: number | null;
|
||||||
|
remainingBytes: number | null;
|
||||||
|
remainingIsEstimated: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
type ProgressMap = Readonly<Record<string, DownloadProgressEvent | undefined>>;
|
||||||
|
|
||||||
|
const usableBytes = (value: number | null | undefined): number | undefined =>
|
||||||
|
typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : undefined;
|
||||||
|
|
||||||
|
const isFreshDownloadStatus = (status: DownloadItem['status']): boolean =>
|
||||||
|
status === 'ready' ||
|
||||||
|
status === 'staged' ||
|
||||||
|
status === 'queued' ||
|
||||||
|
status === 'downloading' ||
|
||||||
|
status === 'processing' ||
|
||||||
|
status === 'retrying';
|
||||||
|
|
||||||
|
const hasPositiveProgress = (download: DownloadItem): boolean =>
|
||||||
|
typeof download.fraction === 'number' &&
|
||||||
|
Number.isFinite(download.fraction) &&
|
||||||
|
download.fraction > 0;
|
||||||
|
|
||||||
|
const canInferNoDownloadedBytes = (download: DownloadItem): boolean =>
|
||||||
|
!hasPositiveProgress(download) &&
|
||||||
|
(isFreshDownloadStatus(download.status) || download.status === 'paused');
|
||||||
|
|
||||||
|
const effectiveByteState = (
|
||||||
|
download: DownloadItem,
|
||||||
|
progress: DownloadProgressEvent | undefined
|
||||||
|
): { downloadedBytes?: number; totalBytes?: number; totalIsEstimate: boolean } => {
|
||||||
|
const usesStoredMediaTotal = download.isMedia === true && progress && !progress.size_is_final;
|
||||||
|
const storedTotalIsEstimate = download.totalIsEstimate === true ||
|
||||||
|
download.size?.trim().startsWith('~') === true;
|
||||||
|
const totalBytes = usesStoredMediaTotal
|
||||||
|
? usableBytes(download.totalBytes)
|
||||||
|
: usableBytes(progress?.total_bytes) ?? usableBytes(download.totalBytes);
|
||||||
|
const downloadedBytes = download.status === 'completed'
|
||||||
|
? usableBytes(download.downloadedBytes) ??
|
||||||
|
usableBytes(progress?.downloaded_bytes) ??
|
||||||
|
totalBytes
|
||||||
|
: usableBytes(progress?.downloaded_bytes) ??
|
||||||
|
usableBytes(download.downloadedBytes) ??
|
||||||
|
(canInferNoDownloadedBytes(download) ? 0 : undefined);
|
||||||
|
const totalIsEstimate = usesStoredMediaTotal
|
||||||
|
? storedTotalIsEstimate
|
||||||
|
: (progress?.total_is_estimate ?? storedTotalIsEstimate) === true;
|
||||||
|
|
||||||
|
return { downloadedBytes, totalBytes, totalIsEstimate };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const summarizeDownloads = (
|
||||||
|
downloads: readonly DownloadItem[],
|
||||||
|
progressMap: ProgressMap = {}
|
||||||
|
): DownloadSummary => {
|
||||||
|
if (downloads.length === 0) {
|
||||||
|
return {
|
||||||
|
itemCount: 0,
|
||||||
|
activeCount: 0,
|
||||||
|
downloadedBytes: null,
|
||||||
|
remainingBytes: null,
|
||||||
|
remainingIsEstimated: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let downloadedBytes = 0;
|
||||||
|
let remainingBytes = 0;
|
||||||
|
let downloadedKnown = true;
|
||||||
|
let remainingKnown = true;
|
||||||
|
let remainingIsEstimated = false;
|
||||||
|
let activeCount = 0;
|
||||||
|
|
||||||
|
for (const download of downloads) {
|
||||||
|
const state = effectiveByteState(download, progressMap[download.id]);
|
||||||
|
if (isTransferActiveStatus(download.status)) activeCount += 1;
|
||||||
|
if (state.downloadedBytes === undefined) {
|
||||||
|
downloadedKnown = false;
|
||||||
|
} else {
|
||||||
|
const nextDownloadedBytes = downloadedBytes + state.downloadedBytes;
|
||||||
|
if (Number.isFinite(nextDownloadedBytes)) {
|
||||||
|
downloadedBytes = nextDownloadedBytes;
|
||||||
|
} else {
|
||||||
|
downloadedKnown = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (download.status === 'completed') {
|
||||||
|
// A completed row is terminal even when a delayed progress event still
|
||||||
|
// carries an old partial denominator. Never expose that stale value as
|
||||||
|
// remaining work in the aggregate status bar.
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
state.totalBytes === undefined ||
|
||||||
|
state.downloadedBytes === undefined ||
|
||||||
|
state.downloadedBytes > state.totalBytes
|
||||||
|
) {
|
||||||
|
remainingKnown = false;
|
||||||
|
} else {
|
||||||
|
const nextRemainingBytes = remainingBytes + Math.max(0, state.totalBytes - state.downloadedBytes);
|
||||||
|
if (Number.isFinite(nextRemainingBytes)) {
|
||||||
|
remainingBytes = nextRemainingBytes;
|
||||||
|
remainingIsEstimated ||= state.totalIsEstimate;
|
||||||
|
} else {
|
||||||
|
remainingKnown = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
itemCount: downloads.length,
|
||||||
|
activeCount,
|
||||||
|
downloadedBytes: downloadedKnown ? downloadedBytes : null,
|
||||||
|
remainingBytes: remainingKnown ? remainingBytes : null,
|
||||||
|
remainingIsEstimated,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import {
|
||||||
|
DEFAULT_COLUMN_ALIGNMENTS,
|
||||||
|
DEFAULT_COLUMN_ORDER,
|
||||||
|
DEFAULT_COLUMN_WIDTHS,
|
||||||
|
buildColumnGridTemplate,
|
||||||
|
getDownloadActionPosition,
|
||||||
|
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 every data column fixed while reserving a flexible fill track after them', () => {
|
||||||
|
expect(buildColumnGridTemplate([...DEFAULT_COLUMN_ORDER], [...DEFAULT_COLUMN_WIDTHS])).toBe(
|
||||||
|
'340px 100px 220px 100px 80px 170px minmax(0, 1fr)'
|
||||||
|
);
|
||||||
|
expect(getColumnGridColumn('Date Added', [...DEFAULT_COLUMN_ORDER])).toBe('6');
|
||||||
|
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('6');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps row actions inside the visible row edge while preserving viewport anchoring for clipped rows', () => {
|
||||||
|
const viewport = { top: 0, right: 800, bottom: 600, left: 0 };
|
||||||
|
expect(getDownloadActionPosition(
|
||||||
|
{ top: 40, right: 1000, bottom: 72, left: -200 },
|
||||||
|
viewport,
|
||||||
|
viewport,
|
||||||
|
800
|
||||||
|
)).toMatchObject({
|
||||||
|
right: 8,
|
||||||
|
height: 30,
|
||||||
|
visibility: 'visible',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(getDownloadActionPosition(
|
||||||
|
{ top: 40, right: 400, bottom: 72, left: 100 },
|
||||||
|
viewport,
|
||||||
|
viewport,
|
||||||
|
800
|
||||||
|
)).toMatchObject({
|
||||||
|
right: 408,
|
||||||
|
visibility: 'visible',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(getDownloadActionPosition(
|
||||||
|
{ top: 40, right: -20, bottom: 72, left: -300 },
|
||||||
|
viewport,
|
||||||
|
viewport,
|
||||||
|
800
|
||||||
|
).visibility).toBe('hidden');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
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 compact action rail shown while hovering or focusing a row.
|
||||||
|
export const DOWNLOAD_ACTIONS_COLUMN_WIDTH = 64;
|
||||||
|
// Keep the viewport-anchored rail clear of the window edge and scrollbar.
|
||||||
|
export const DOWNLOAD_ACTIONS_VIEWPORT_INSET = 8;
|
||||||
|
export const DOWNLOAD_ACTIONS_MAX_HEIGHT = 30;
|
||||||
|
export const DOWNLOAD_ACTIONS_VIEWPORT_TOLERANCE = 1;
|
||||||
|
|
||||||
|
export interface DownloadActionViewportRect {
|
||||||
|
top: number;
|
||||||
|
right: number;
|
||||||
|
bottom: number;
|
||||||
|
left: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DownloadActionPosition {
|
||||||
|
top: number;
|
||||||
|
right: number;
|
||||||
|
height: number;
|
||||||
|
overflow: 'hidden' | 'visible';
|
||||||
|
visibility: 'hidden' | 'visible';
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getDownloadActionPosition = (
|
||||||
|
rowRect: DownloadActionViewportRect,
|
||||||
|
horizontalViewportRect: DownloadActionViewportRect,
|
||||||
|
verticalViewportRect: DownloadActionViewportRect,
|
||||||
|
windowWidth: number
|
||||||
|
): DownloadActionPosition => {
|
||||||
|
const visibleTop = Math.max(rowRect.top, verticalViewportRect.top);
|
||||||
|
const visibleBottom = Math.min(rowRect.bottom, verticalViewportRect.bottom);
|
||||||
|
const visibleHeight = Math.max(0, visibleBottom - visibleTop);
|
||||||
|
const visibleLeft = Math.max(rowRect.left, horizontalViewportRect.left);
|
||||||
|
const visibleRight = Math.min(rowRect.right, horizontalViewportRect.right);
|
||||||
|
const visibleWidth = Math.max(0, visibleRight - visibleLeft);
|
||||||
|
const isVisible = visibleHeight > DOWNLOAD_ACTIONS_VIEWPORT_TOLERANCE &&
|
||||||
|
visibleWidth > DOWNLOAD_ACTIONS_VIEWPORT_TOLERANCE;
|
||||||
|
const actionHeight = Math.min(DOWNLOAD_ACTIONS_MAX_HEIGHT, rowRect.bottom - rowRect.top, visibleHeight);
|
||||||
|
const actionRightEdge = Math.min(rowRect.right, horizontalViewportRect.right);
|
||||||
|
|
||||||
|
return {
|
||||||
|
top: visibleTop + Math.max(0, (visibleHeight - actionHeight) / 2),
|
||||||
|
right: Math.max(0, windowWidth - actionRightEdge) + DOWNLOAD_ACTIONS_VIEWPORT_INSET,
|
||||||
|
height: actionHeight,
|
||||||
|
overflow: actionHeight < rowRect.bottom - rowRect.top ? 'hidden' : 'visible',
|
||||||
|
visibility: isVisible ? 'visible' : 'hidden',
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
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.map(width => `${width}px`),
|
||||||
|
'minmax(0, 1fr)',
|
||||||
|
].join(' ');
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getColumnGridColumn = (
|
||||||
|
key: DownloadTableColumnKey,
|
||||||
|
order: DownloadTableColumnKey[]
|
||||||
|
): string | undefined => key === order[order.length - 1]
|
||||||
|
? `${order.length}`
|
||||||
|
: undefined;
|
||||||
@@ -1,6 +1,11 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import type { DownloadItem } from '../bindings/DownloadItem';
|
import type { DownloadItem } from '../bindings/DownloadItem';
|
||||||
import { redactDownloadForPersistence, resolveDownloadConnections } from './downloads';
|
import {
|
||||||
|
downloadFileNamesMatch,
|
||||||
|
downloadMediaKindsMatch,
|
||||||
|
redactDownloadForPersistence,
|
||||||
|
resolveDownloadConnections
|
||||||
|
} from './downloads';
|
||||||
|
|
||||||
const item = (status: DownloadItem['status']): DownloadItem => ({
|
const item = (status: DownloadItem['status']): DownloadItem => ({
|
||||||
id: 'download-1',
|
id: 'download-1',
|
||||||
@@ -56,3 +61,28 @@ describe('download connection resolution', () => {
|
|||||||
expect(resolveDownloadConnections(Number.NaN, 8)).toBe(8);
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -132,6 +132,29 @@ export const canonicalizeDownloadFileName = (fileName: string): string => {
|
|||||||
return sanitized && sanitized !== '.' && sanitized !== '..' ? sanitized : 'download';
|
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 => {
|
export const isMediaUrl = (rawUrl: string): boolean => {
|
||||||
try {
|
try {
|
||||||
const url = new URL(rawUrl);
|
const url = new URL(rawUrl);
|
||||||
|
|||||||
@@ -18,9 +18,10 @@ export type KeychainAccessReadiness = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// The semantic app version can remain unchanged across release-candidate and
|
// The semantic app version can remain unchanged across release-candidate and
|
||||||
// packaging rebuilds. Use the build identity so an updated binary cannot skip
|
// packaging rebuilds. The Tauri packaging hook appends a fresh artifact nonce
|
||||||
// Firelink's explanation and invoke the OS prompt directly. The policy epoch
|
// to the build identity, so replacing the binary cannot skip Firelink's
|
||||||
// remains only as a safe fallback for builds created outside the Git checkout.
|
// 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 KEYCHAIN_CONSENT_POLICY_VERSION = '2';
|
||||||
const buildId = typeof import.meta.env.VITE_BUILD_ID === 'string'
|
const buildId = typeof import.meta.env.VITE_BUILD_ID === 'string'
|
||||||
? import.meta.env.VITE_BUILD_ID.trim()
|
? import.meta.env.VITE_BUILD_ID.trim()
|
||||||
|
|||||||
@@ -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
|
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 cached: PlatformInfo | null = null;
|
||||||
let pending: Promise<PlatformInfo> | null = null;
|
let pending: Promise<PlatformInfo> | null = null;
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import {
|
||||||
|
moveSelectedBlockToIndex,
|
||||||
|
targetIndexForBoundary,
|
||||||
|
targetIndexForDesiredOrder
|
||||||
|
} from './queueOrdering';
|
||||||
|
|
||||||
|
const items = ['a', 'b', 'c', 'd'].map(id => ({ id }));
|
||||||
|
|
||||||
|
describe('queue ordering', () => {
|
||||||
|
it('moves discontiguous selection as one block', () => {
|
||||||
|
expect(moveSelectedBlockToIndex(items, ['b', 'd'], 1).map(item => item.id))
|
||||||
|
.toEqual(['a', 'b', 'd', 'c']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('moves a selected block to the end for a downward drop', () => {
|
||||||
|
expect(moveSelectedBlockToIndex(items, ['b', 'c'], 2).map(item => item.id))
|
||||||
|
.toEqual(['a', 'd', 'b', 'c']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('translates pointer boundaries after selected rows are removed', () => {
|
||||||
|
expect(targetIndexForBoundary(items, ['b', 'd'], 2)).toBe(1);
|
||||||
|
expect(targetIndexForBoundary(items, ['b', 'd'], 3)).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('computes a registered-only backend target from the desired local order', () => {
|
||||||
|
const current = [{ id: 'a' }, { id: 'staged' }, { id: 'b' }, { id: 'c' }];
|
||||||
|
const desired = [{ id: 'a' }, { id: 'b' }, { id: 'c' }, { id: 'staged' }];
|
||||||
|
expect(targetIndexForDesiredOrder(current, ['c'], desired)).toBe(2);
|
||||||
|
|
||||||
|
expect(targetIndexForDesiredOrder(
|
||||||
|
[{ id: 'a' }, { id: 'c' }],
|
||||||
|
['c'],
|
||||||
|
[{ id: 'a' }, { id: 'staged' }, { id: 'c' }]
|
||||||
|
)).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
export interface QueueOrderItem {
|
||||||
|
id: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Move the selected rows as one block into an insertion position among the
|
||||||
|
* unselected rows. The position is intentionally defined after selection is
|
||||||
|
* removed so callers can use the same semantics for UI and backend queues.
|
||||||
|
*/
|
||||||
|
export const moveSelectedBlockToIndex = <T extends QueueOrderItem>(
|
||||||
|
items: T[],
|
||||||
|
selectedIds: ReadonlySet<string> | readonly string[],
|
||||||
|
targetIndex: number
|
||||||
|
): T[] => {
|
||||||
|
const selected = selectedIds instanceof Set ? selectedIds : new Set(selectedIds);
|
||||||
|
const selectedItems = items.filter(item => selected.has(item.id));
|
||||||
|
const unselectedItems = items.filter(item => !selected.has(item.id));
|
||||||
|
const insertionIndex = Math.max(0, Math.min(targetIndex, unselectedItems.length));
|
||||||
|
|
||||||
|
return [
|
||||||
|
...unselectedItems.slice(0, insertionIndex),
|
||||||
|
...selectedItems,
|
||||||
|
...unselectedItems.slice(insertionIndex)
|
||||||
|
];
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert a pointer boundary in the original list to the insertion index
|
||||||
|
* used by moveSelectedBlockToIndex. A boundary is between rows and ranges
|
||||||
|
* from 0 (before the first row) through items.length (after the last row).
|
||||||
|
*/
|
||||||
|
export const targetIndexForBoundary = <T extends QueueOrderItem>(
|
||||||
|
items: T[],
|
||||||
|
selectedIds: ReadonlySet<string> | readonly string[],
|
||||||
|
boundaryIndex: number
|
||||||
|
): number => {
|
||||||
|
const selected = selectedIds instanceof Set ? selectedIds : new Set(selectedIds);
|
||||||
|
const boundary = Math.max(0, Math.min(boundaryIndex, items.length));
|
||||||
|
return items
|
||||||
|
.slice(0, boundary)
|
||||||
|
.reduce((count, item) => count + (selected.has(item.id) ? 0 : 1), 0);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Translate a desired local order to the backend's registered-only queue.
|
||||||
|
* Staged rows are not registered with the backend, so they must not affect
|
||||||
|
* the target index sent over IPC.
|
||||||
|
*/
|
||||||
|
export const targetIndexForDesiredOrder = <T extends QueueOrderItem>(
|
||||||
|
currentItems: T[],
|
||||||
|
selectedIds: ReadonlySet<string> | readonly string[],
|
||||||
|
desiredItems: T[]
|
||||||
|
): number => {
|
||||||
|
const selected = selectedIds instanceof Set ? selectedIds : new Set(selectedIds);
|
||||||
|
const currentIds = new Set(currentItems.map(item => item.id));
|
||||||
|
const firstSelectedIndex = desiredItems.findIndex(item => selected.has(item.id));
|
||||||
|
if (firstSelectedIndex === -1) return currentItems.filter(item => !selected.has(item.id)).length;
|
||||||
|
return desiredItems
|
||||||
|
.slice(0, firstSelectedIndex)
|
||||||
|
.reduce((count, item) => count + (selected.has(item.id) || !currentIds.has(item.id) ? 0 : 1), 0);
|
||||||
|
};
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { resolveWindowControlStyle } from './windowControlStyle';
|
||||||
|
|
||||||
|
describe('resolveWindowControlStyle', () => {
|
||||||
|
it('uses the platform convention for automatic style', () => {
|
||||||
|
expect(resolveWindowControlStyle('auto', 'macos')).toBe('macos');
|
||||||
|
expect(resolveWindowControlStyle('auto', 'windows')).toBe('windows');
|
||||||
|
expect(resolveWindowControlStyle('auto', 'linux')).toBe('gnome');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to macOS styling while an unsupported platform is unresolved', () => {
|
||||||
|
expect(resolveWindowControlStyle('auto', 'unknown')).toBe('macos');
|
||||||
|
expect(resolveWindowControlStyle('auto', 'android')).toBe('macos');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses the desktop user agent while native platform detection is unresolved', () => {
|
||||||
|
expect(resolveWindowControlStyle('auto', 'unknown', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)')).toBe('windows');
|
||||||
|
expect(resolveWindowControlStyle('auto', 'unknown', 'Mozilla/5.0 (X11; Linux x86_64)')).toBe('gnome');
|
||||||
|
expect(resolveWindowControlStyle('auto', 'unknown', 'Mozilla/5.0 (Linux; Android 14)')).toBe('macos');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves an explicit style across platforms', () => {
|
||||||
|
expect(resolveWindowControlStyle('macos', 'windows')).toBe('macos');
|
||||||
|
expect(resolveWindowControlStyle('windows', 'linux')).toBe('windows');
|
||||||
|
expect(resolveWindowControlStyle('gnome', 'macos')).toBe('gnome');
|
||||||
|
expect(resolveWindowControlStyle('minimal', 'windows')).toBe('minimal');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import type { WindowControlStyle } from '../bindings/WindowControlStyle';
|
||||||
|
|
||||||
|
export type ResolvedWindowControlStyle = Exclude<WindowControlStyle, 'auto'>;
|
||||||
|
|
||||||
|
export const resolveWindowControlStyle = (
|
||||||
|
style: WindowControlStyle,
|
||||||
|
os: string,
|
||||||
|
userAgent = ''
|
||||||
|
): ResolvedWindowControlStyle => {
|
||||||
|
if (style !== 'auto') return style;
|
||||||
|
if (os === 'windows') return 'windows';
|
||||||
|
if (os === 'linux') return 'gnome';
|
||||||
|
if (os === 'unknown' && /Windows/i.test(userAgent)) return 'windows';
|
||||||
|
if (os === 'unknown' && /Linux/i.test(userAgent) && !/Android/i.test(userAgent)) return 'gnome';
|
||||||
|
return 'macos';
|
||||||
|
};
|
||||||
@@ -26,6 +26,9 @@ const buildId = (() => {
|
|||||||
'src-tauri/Cargo.lock',
|
'src-tauri/Cargo.lock',
|
||||||
'src-tauri/build.rs',
|
'src-tauri/build.rs',
|
||||||
'src-tauri/tauri.conf.json',
|
'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',
|
'index.html',
|
||||||
'package.json',
|
'package.json',
|
||||||
'package-lock.json',
|
'package-lock.json',
|
||||||
|
|||||||
Reference in New Issue
Block a user