Compare commits

...

34 Commits

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

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

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

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

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

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

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

Refs #22

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

Refs #23
2026-07-18 12:02:17 +03:30
NimBold 160e6af945 feat(locations): remember last Add-window directory
Remember manually selected Add-window folders during the current session, with a persisted Locations toggle and cross-platform path handling.\n\nRefs #23
2026-07-18 11:52:24 +03:30
NimBold c914eeb7b3 feat(i18n): add localization infrastructure and English catalog
Refs #17
2026-07-18 01:10:27 +03:30
NimBold 62365f514e fix(ui): align size header with values 2026-07-17 23:46:06 +03:30
NimBold 9fdd8ac1b8 fix(ui): align size values within column 2026-07-17 23:37:32 +03:30
78 changed files with 10815 additions and 1642 deletions
+8 -4
View File
@@ -44,6 +44,8 @@ jobs:
with:
node-version: 22
cache: npm
- name: Verify release version
run: node scripts/verify-release-version.js
- uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.target }}
@@ -123,7 +125,8 @@ jobs:
if (-not $installer) { throw "Windows NSIS installer artifact was not produced." }
$extractRoot = "$env:RUNNER_TEMP/firelink-installer"
Remove-Item -Recurse -Force $extractRoot -ErrorAction SilentlyContinue
7z x $installer.FullName "-o$extractRoot" -y
& 7z x $installer.FullName "-o$extractRoot" -y
if ($LASTEXITCODE -ne 0) { throw "7z failed to extract the Windows installer payload (exit code $LASTEXITCODE)." }
node scripts/verify-binaries.js --search-root "$extractRoot" --target ${{ matrix.target }}
$portableRoot = "$env:RUNNER_TEMP/firelink-portable-payload"
@@ -249,11 +252,12 @@ jobs:
local pattern="$1"
local destination="$2"
local source
source="$(find release-assets -maxdepth 1 -type f -name "$pattern" -print -quit)"
if [[ -z "$source" ]]; then
echo "::error::Missing release asset matching $pattern"
mapfile -d '' -t matches < <(find release-assets -maxdepth 1 -type f -name "$pattern" -print0)
if (( ${#matches[@]} != 1 )); then
echo "::error::Expected exactly one release asset matching $pattern, found ${#matches[@]}"
exit 1
fi
source="${matches[0]}"
mv "$source" "release-assets/$destination"
}
+5
View File
@@ -18,8 +18,13 @@ CROSS_PLATFORM_CHECKLIST.md
Cross-platform-checklist-gemini.MD
YouTube_media_download_handoff.md
Release_checklist.md
Release Checklist/
RC Read-only/
RC Read_only/
# Frontend output and logs
.agents/
release_notes.md
node_modules/
dist/
dist-ssr/
+31
View File
@@ -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/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [1.2.0] - 2026-07-22
This release makes everyday download management easier to organize, review, and trust across the desktop app and browser extension.
### New features
- **Localized desktop interface** for Persian, Hebrew, Russian, Ukrainian, and Simplified Chinese, including RTL layouts. These translations are currently produced with help from LLMs and need user review; please report corrections so they can improve.
- **Customizable download table** with selectable columns, drag-to-reorder controls, sorting, clearer size alignment, and better bulk actions.
- **Optional batch folders** for multi-link downloads, addressing [#27](https://github.com/nimbold/Firelink/issues/27). Firelink can suggest an editable folder name from the page title or common filename while keeping the existing category-based behavior as the default.
- **Remember the last Add-window directory**, with an opt-in setting so users remain in control of where it applies.
- **Per-download connection controls** for normal and media transfers, with the active aria2 connection count shown in the download table.
- **Latest Firelink Companion 2.0.6** in the `Extensions/Browser` submodule, including selected-link batch context and safer automatic capture recovery.
### Improvements
- Make pause, resume, retry, remove, redownload, queue, scheduler, and settings actions safer when several operations overlap or background events arrive late.
- Reuse unfinished downloads when their filenames match, while reducing the chance of stale state creating duplicate or misleading rows.
- Improve first-open navigation, lazy page loading, table layout, drag interactions, sorting, and window-control placement.
- Improve RTL behavior while keeping the download table's physical file columns readable.
- Handle locked browser cookie databases more gracefully and keep browser metadata and authenticated captures on the correct path.
- Enforce startup consent before accessing saved credentials and make keychain-related startup behavior more predictable.
- Refresh dependencies and bundled engines, and strengthen checks for macOS, Windows portable, Linux packages, release assets, and release version identity.
- Refine localized wording, including Persian status labels.
### Fixes
- Prevent stale lifecycle work, duplicate terminal events, and overlapping controls from deleting, reviving, or leaving the wrong download in a misleading state.
- Keep missed completion events and connection-limit updates from leaving a finished or active download visually stuck.
- Bound backend connection counts so user-selected limits are respected instead of allowing overlapping settings to exceed them.
- Keep extension handoffs in the Add window and make selected-link batches retain their page context for folder suggestions.
## [1.1.1] - 2026-07-17
This patch release focuses on transfer reliability, browser captures, and easier download control.
+38 -91
View File
@@ -5,14 +5,14 @@
**A fast, focused desktop download manager for macOS, Windows, and Linux.**
[![Version](https://img.shields.io/badge/version-1.1.1-6f42c1?style=flat-square)](https://github.com/nimbold/Firelink/releases)
[![macOS](https://img.shields.io/badge/macOS-111111?style=flat-square&logo=apple&logoColor=white)](#platforms)
[![Windows](.github/badges/windows.svg)](#platforms)
[![Linux](https://img.shields.io/badge/Linux-FCC624?style=flat-square&logo=linux&logoColor=black)](#platforms)
[![Version](https://img.shields.io/badge/version-1.2.0-6f42c1?style=flat-square)](https://github.com/nimbold/Firelink/releases)
[![macOS](https://img.shields.io/badge/macOS-111111?style=flat-square&logo=apple&logoColor=white)](#supported-platforms)
[![Windows](.github/badges/windows.svg)](#supported-platforms)
[![Linux](https://img.shields.io/badge/Linux-FCC624?style=flat-square&logo=linux&logoColor=black)](#supported-platforms)
[![License](https://img.shields.io/github/license/nimbold/Firelink?style=flat-square)](LICENSE)
[![CI](https://img.shields.io/github/actions/workflow/status/nimbold/Firelink/ci.yml?branch=main&style=flat-square&label=CI)](https://github.com/nimbold/Firelink/actions/workflows/ci.yml)
[Features](#features) · [Install](#installation) · [Browser Extension](#browser-extension) · [Development](#development) · [Release Notes](CHANGELOG.md)
[Features](#features) · [Install](#installation) · [Browser Extension](#browser-extension) · [Development](#development) · [Changelog](CHANGELOG.md)
</div>
<br/>
@@ -31,27 +31,25 @@
</details>
</div>
## Why Firelink
## Overview
Firelink is a cross-platform desktop download manager for direct transfers, browser capture, media extraction, scheduling, and clear file placement.
Firelink manages direct downloads, browser captures, media extraction, playlists, scheduling, and file placement from one desktop app. It uses a Rust/Tauri backend with a React and TypeScript interface, and bundles the engines needed for its download and media workflows.
It combines a Rust/Tauri backend with a React and TypeScript interface. Bundled aria2, yt-dlp, FFmpeg, Deno, and SQLite support the download and media workflows.
The current desktop release is **1.1.1**, paired with Firelink Companion **2.0.5**.
This release adds YouTube playlist downloads, live connection controls, clipboard capture, and clearer byte-level progress. It also improves slow-transfer recovery, authenticated browser captures, and startup consent handling.
The current desktop release is **1.2.0**, paired with [Firelink Companion 2.0.6](https://github.com/nimbold/Firelink-Extension/releases). The release focuses on a more dependable download table and queue, better browser handoffs, broader localization, and safer packaged builds.
## Features
- **Segmented downloads** with aria2, retries, speed limits, and connection controls.
- **Media downloads** with yt-dlp, FFmpeg, Deno, live progress, speed, and ETA.
- **Playlist downloads** for YouTube playlists with queueing and efficient large-list rendering.
- **Add window** for metadata, duplicates, location choices, captured links, clipboard-prefilled URLs, and live connection limits.
- **Persistent queues** with pause, resume, retry, redownload, sorting, multi-select, and bulk actions.
- **Scheduling** with start/stop windows, speed rules, and post-queue actions.
- **File organization** with categories, default folders, a collapsible Folders section, per-download overrides, and reveal/trash actions.
- **Browser handoff** through local pairing, signed requests, Add window review, replay protection, and server checks.
- **Playlist downloads** for YouTube playlists with queueing and efficient rendering for large lists.
- **Customizable download table** with selectable columns, sorting, drag-to-reorder, and bulk actions.
- **Add window** for metadata, duplicate handling, captured links, clipboard-prefilled URLs, save locations, and live connection limits.
- **Persistent queues** with pause, resume, retry, redownload, scheduling, and multi-select actions.
- **Optional batch folders** for grouping related multi-link downloads in a new, editable folder.
- **File organization** with categories, default folders, per-download overrides, and reveal/trash actions.
- **Browser handoff** through local pairing, signed requests, Add-window review, replay protection, and server checks.
- **Desktop integration** with tray controls, notifications, sounds, sleep prevention, and secure credential storage.
- **Localization** for English, Simplified Chinese, Hebrew, Persian, Ukrainian, and Russian, with RTL support. Non-English translations are LLM-assisted and welcome user review.
- **Diagnostics** with engine health checks, structured logs, and package verification.
## Installation
@@ -62,24 +60,22 @@ Download desktop builds from [GitHub Releases](https://github.com/nimbold/Fireli
| --- | --- | --- |
| **macOS Apple silicon** | `.dmg` | Not notarized. If macOS blocks the first launch, approve Firelink in **System Settings -> Privacy & Security**. |
| **Windows x64** | NSIS `.exe` installer | Unsigned. Windows SmartScreen may warn until code signing is added. |
| **Windows x64 portable** | `.zip` archive | Extract to a writable folder and launch `firelink.exe`. See the expandable notes below. |
| **Linux x64** | `.deb`, `.rpm`, or `.AppImage` | Use `.deb` for Debian-family systems, `.rpm` for Fedora/RPM-family systems, or AppImage as the self-contained package. AppImage may need executable permission. |
| **Windows x64 portable** | `.zip` archive | Extract to a writable folder and launch `firelink.exe`. See the notes below. |
| **Linux x64** | `.deb`, `.rpm`, or `.AppImage` | Choose the package for your distribution, or use AppImage as the self-contained option. |
Bundles include the required engines. Users do not need aria2, yt-dlp, FFmpeg, Deno, Python, Homebrew, or another package manager.
The native packages use the distribution's normal desktop runtime dependencies. AppImage is self-contained but uses the normal per-user application-data locations.
All packages include aria2, yt-dlp, FFmpeg, Deno, and SQLite support. You do not need to install those engines separately.
<details>
<summary><strong>Windows portable ZIP notes</strong></summary>
The portable ZIP is an opt-in secondary distribution. Extract it to a writable folder and launch `firelink.exe`:
The portable ZIP is a secondary distribution. Extract it to a writable folder and launch `firelink.exe`:
- Keep the extracted folder writable; avoid `Program Files`, read-only media, and folders that block SQLite or WebView writes.
- Settings, queues, logs, and WebView data stay beside `firelink.exe` under `data/`.
- Keep it out of `Program Files`, read-only media, and folders that block SQLite or WebView writes.
- Settings, queues, logs, and WebView data stay beside the executable under `data/`.
- Close Firelink before moving or copying the folder, and close the installed app before launching the portable copy.
- Credentials, browser cookies, and URL query/fragment data are not saved in portable queue records. Active downloads that depend on them must be added again after restart.
- Credentials, browser cookies, and URL query or fragment data are not saved in portable queue records.
- Saved site passwords remain in the Windows credential store and are not copied into the archive.
- The folder contains the extension pairing credential, so treat it as sensitive and do not share it.
- The folder contains the extension pairing credential. Treat it as sensitive and do not share it.
- Saved absolute download locations may need to be selected again after moving the folder to another drive.
- The installer remains the supported path for `firelink://` browser launch registration.
@@ -87,35 +83,25 @@ The portable ZIP is an opt-in secondary distribution. Extract it to a writable f
## Browser Extension
[Firelink Companion](https://github.com/nimbold/Firelink-Extension) connects browser downloads, links, and media pages to Firelink. Captured downloads always open Firelink's Add window so you can review them before starting or queuing them.
It provides automatic capture for regular downloads, explicit **Fetch media** actions, link and selected-text context menus, Firefox and Chromium support, signed local requests, and a safe browser fallback when Firelink is unavailable.
<p align="center">
<a href="https://addons.mozilla.org/en-US/firefox/addon/firelink-companion/"><img src="https://img.shields.io/badge/Install%20from-Firefox%20Add--ons-FF7139?style=for-the-badge&logo=firefox-browser&logoColor=white" alt="Install Firelink Companion from Firefox Add-ons" /></a>
&nbsp;&nbsp;
<a href="https://github.com/nimbold/Firelink-Extension#manual-chromium-installation"><img src="https://img.shields.io/badge/Manual%20install-Chromium-4285F4?style=for-the-badge&logo=googlechrome&logoColor=white" alt="Read manual Chromium install instructions" /></a>
</p>
Firelink Companion connects browser links and downloads to the desktop app.
Install the extension, open Firelink, and pair it from **Settings -> Integrations**. Use the latest [Firelink Companion 2.0.6 release](https://github.com/nimbold/Firelink-Extension/releases) with Firelink 1.2.0. Chromium users can load `firelink-chromium.zip` by following the [manual installation instructions](https://github.com/nimbold/Firelink-Extension#manual-chromium-installation).
What it adds:
- Automatic capture for regular browser downloads.
- Explicit Fetch media actions from the popup and page context menu.
- Context-menu actions for links and selected text.
- Firefox and Chromium support.
- Signed local requests using the token from **Settings -> Integrations**.
- Fallback to the browser download when Firelink is closed or rejects a handoff.
- Captured links always open Firelink's Add window before anything is added to the download list.
Install the extension, open Firelink, then pair it from **Settings -> Integrations**. Firefox users can install 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
## Supported Platforms
| Target | Status |
| --- | --- |
| **macOS arm64** | Supported. Native build, engine checks, launch smoke test, ad-hoc-signed DMG workflow. |
| **Windows x64** | Supported. Native build, engine checks, silent installer smoke test, NSIS installer. |
| **Linux x64** | Supported. Native build, bundled-engine checks, package/AppImage launch smoke tests, `.deb`, `.rpm`, and AppImage. |
| **macOS arm64** | Supported with a native build, bundled-engine checks, launch smoke test, and ad-hoc-signed DMG workflow. |
| **Windows x64** | Supported with a native build, engine checks, installer smoke test, NSIS installer, and portable ZIP. |
| **Linux x64** | Supported with native builds, bundled-engine checks, package/AppImage smoke tests, `.deb`, `.rpm`, and AppImage packages. |
## Development
@@ -131,16 +117,10 @@ Clone the repository with its browser-extension submodule:
```sh
git clone --recurse-submodules https://github.com/nimbold/Firelink.git
cd Firelink
```
Install dependencies and launch the desktop app:
```sh
npm install
npm run tauri dev
```
Run the core checks:
Launch the app with `npm run tauri dev`. Run the core checks with:
```sh
node --test scripts/*.node-test.js
@@ -150,49 +130,16 @@ cd src-tauri
cargo test --all-targets
```
Create a production bundle:
```sh
npm run tauri build
```
macOS uses locked payloads in `src-tauri/binaries`. Provision Windows and Linux payloads from checksum-pinned archives:
```sh
node scripts/provision-engines.js --target x86_64-pc-windows-msvc
node scripts/provision-engines.js --target x86_64-unknown-linux-gnu
```
Build staging includes only the current target. See `engines.lock.json`, `engine-sources.lock.json`, and [RELEASE.md](RELEASE.md).
## Repository Structure
```text
.
├── src/ React and TypeScript interface
├── src-tauri/ Rust backend, Tauri config, and native tests
├── scripts/ Engine provisioning, release, and smoke-test tooling
└── Extensions/Browser/ Firelink Companion submodule
```
See [RELEASE.md](RELEASE.md) for engine provisioning, packaging, and release verification.
## Help and Project Status
- Report bugs or request improvements in [GitHub Issues](https://github.com/nimbold/Firelink/issues).
- Read [CHANGELOG.md](CHANGELOG.md) for release history.
- Review [RELEASE.md](RELEASE.md) for packaging policy and release verification.
- The project is actively maintained, but macOS builds are not notarized and Windows installers are not code-signed yet.
## Technology & Credits
## Technology and License
Firelink is made possible by these open-source projects:
- **[Tauri 2](https://tauri.app/)** for the lightweight desktop runtime
- **[Rust](https://www.rust-lang.org/)** and **[Tokio](https://tokio.rs/)** for native application logic
- **[React](https://react.dev/)** and **[TypeScript](https://www.typescriptlang.org/)** for the interface
- **[Zustand](https://zustand-demo.pmnd.rs/)** for frontend state management
- **[SQLite](https://www.sqlite.org/)** for persistent local data
- **[aria2](https://aria2.github.io/)** for segmented downloading
- **[yt-dlp](https://github.com/yt-dlp/yt-dlp)**, **[FFmpeg](https://ffmpeg.org/)**, and **[Deno](https://deno.com/)** for media extraction and processing
## License
Firelink is built with [Tauri 2](https://tauri.app/), [Rust](https://www.rust-lang.org/), [Tokio](https://tokio.rs/), [React](https://react.dev/), [TypeScript](https://www.typescriptlang.org/), [Zustand](https://zustand-demo.pmnd.rs/), [SQLite](https://www.sqlite.org/), [aria2](https://aria2.github.io/), [yt-dlp](https://github.com/yt-dlp/yt-dlp), [FFmpeg](https://ffmpeg.org/), and [Deno](https://deno.com/).
Firelink is available under the [MIT License](LICENSE).
+6 -6
View File
@@ -13,9 +13,9 @@
"sha256": "60343461ac5fe3a31f4ef12667f2946bb852e20655c8610aeb7e751e87f7df3a"
},
"ffmpeg": {
"version": "8.1.2-22-g94138f6973",
"url": "https://github.com/BtbN/FFmpeg-Builds/releases/download/autobuild-2026-07-15-14-01/ffmpeg-n8.1.2-22-g94138f6973-win64-gpl-8.1.zip",
"sha256": "0af1621d3a2e418b86d8ff4704f5d5a70bcce611edd6c5110d092533b170952c"
"version": "8.1.2-29-g703dcc25b9",
"url": "https://github.com/BtbN/FFmpeg-Builds/releases/download/autobuild-2026-07-21-13-38/ffmpeg-n8.1.2-29-g703dcc25b9-win64-gpl-8.1.zip",
"sha256": "ebf57e8b1a10b176b88c3cbc66e68a4aed472cf47520b0fbf003e892fb3be642"
},
"aria2c": {
"version": "1.37.0",
@@ -35,9 +35,9 @@
"sha256": "8101865641cbede56f08ad19c0a67a87df84bce127fee0d3e3e1f7467717ffa6"
},
"ffmpeg": {
"version": "8.1.2-22-g94138f6973",
"url": "https://github.com/BtbN/FFmpeg-Builds/releases/download/autobuild-2026-07-15-14-01/ffmpeg-n8.1.2-22-g94138f6973-linux64-gpl-8.1.tar.xz",
"sha256": "68e304f5518d3e22cd825e6fb738d0d9a04a8f36127873a18fc6646457e95b92"
"version": "8.1.2-29-g703dcc25b9",
"url": "https://github.com/BtbN/FFmpeg-Builds/releases/download/autobuild-2026-07-21-13-38/ffmpeg-n8.1.2-29-g703dcc25b9-linux64-gpl-8.1.tar.xz",
"sha256": "c6c54589dd3443fb86b441351218d1f0e0cba8221134c3d2e7e3eda80e984747"
},
"aria2c": {
"version": "1.37.0",
+122 -49
View File
@@ -1,35 +1,37 @@
{
"name": "firelink",
"version": "1.1.1",
"version": "1.2.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "firelink",
"version": "1.1.1",
"version": "1.2.0",
"license": "MIT",
"dependencies": {
"@formkit/auto-animate": "^0.10.0",
"@tailwindcss/vite": "^4.3.3",
"@tauri-apps/api": "^2.11.1",
"@tauri-apps/plugin-clipboard-manager": "^2.3.2",
"@tauri-apps/plugin-dialog": "^2.7.1",
"@tauri-apps/plugin-dialog": "^2.7.2",
"@tauri-apps/plugin-fs": "^2.5.1",
"@tauri-apps/plugin-log": "^2.9.0",
"@tauri-apps/plugin-notification": "^2.3.3",
"@tauri-apps/plugin-opener": "^2",
"lucide-react": "^1.24.0",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"i18next": "^26.3.6",
"lucide-react": "^1.25.0",
"react": "^19.2.8",
"react-dom": "^19.2.8",
"react-i18next": "^17.0.10",
"zustand": "^5.0.14"
},
"devDependencies": {
"@tauri-apps/cli": "^2.11.4",
"@types/react": "^19.1.8",
"@types/react-dom": "^19.1.6",
"@vitejs/plugin-react": "^6.0.3",
"@vitejs/plugin-react": "^6.0.4",
"autoprefixer": "^10.5.4",
"postcss": "^8.5.19",
"postcss": "^8.5.21",
"tailwindcss": "^4.3.3",
"typescript": "^7.0.2",
"vite": "^8.1.5",
@@ -39,6 +41,15 @@
"node": ">=22"
}
},
"node_modules/@babel/runtime": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz",
"integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==",
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@emnapi/core": {
"version": "1.11.1",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz",
@@ -957,9 +968,9 @@
}
},
"node_modules/@tauri-apps/plugin-dialog": {
"version": "2.7.1",
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-dialog/-/plugin-dialog-2.7.1.tgz",
"integrity": "sha512-OK1UBXYt+ojcmxMktzzuyonYIFta8CmAASpX+CA+DTGK24KlHjhYI6x2iOJ/TjZF4N7/ACK1oFmEOjIY9IhzOQ==",
"version": "2.7.2",
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-dialog/-/plugin-dialog-2.7.2.tgz",
"integrity": "sha512-pX0IGm1I3I6wc+zeKYcq1GSqogK6okCNX5fOdaNU5ab1AjGS6l1E5wFNjEb7meg7ZFSp0JUs+0jQGQNyOvLrsg==",
"license": "MIT OR Apache-2.0",
"dependencies": {
"@tauri-apps/api": "^2.11.0"
@@ -1063,7 +1074,6 @@
"cpu": [
"ppc64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -1080,7 +1090,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -1097,7 +1106,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -1114,7 +1122,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -1131,7 +1138,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -1148,7 +1154,6 @@
"cpu": [
"arm"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -1165,7 +1170,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -1182,7 +1186,6 @@
"cpu": [
"loong64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -1199,7 +1202,6 @@
"cpu": [
"mips64el"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -1216,7 +1218,6 @@
"cpu": [
"ppc64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -1233,7 +1234,6 @@
"cpu": [
"riscv64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -1250,7 +1250,6 @@
"cpu": [
"s390x"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -1267,7 +1266,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -1284,7 +1282,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -1301,7 +1298,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -1318,7 +1314,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -1335,7 +1330,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -1352,7 +1346,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -1369,7 +1362,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -1386,7 +1378,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -1397,9 +1388,9 @@
}
},
"node_modules/@vitejs/plugin-react": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.3.tgz",
"integrity": "sha512-vmFvco5/QuC2f9Oj+wTk0+9XeDFkHxSamwZKYc7MxYwKICfvUvlMhqKI0VuICPltGqh1neqBKDvO4kes1ya8vg==",
"version": "6.0.4",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.4.tgz",
"integrity": "sha512-XcCQz0TBpBgljhj0gMuuDj49i6Ytqh5q1osT/Gp5uAVJUCTWxyskk/l1jwYYiu2xcNHHipdMz40EGfM1VdamVg==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -1791,6 +1782,43 @@
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
"license": "ISC"
},
"node_modules/html-parse-stringify": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz",
"integrity": "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==",
"license": "MIT",
"dependencies": {
"void-elements": "3.1.0"
}
},
"node_modules/i18next": {
"version": "26.3.6",
"resolved": "https://registry.npmjs.org/i18next/-/i18next-26.3.6.tgz",
"integrity": "sha512-Bu5Z2nAXgfVyM8xvW3jk9EKRIuX37PudsrBViThNFx7CR7aaYTpP01cxNB/E4c4UUzTDiAZRstEhsRfPOL/8xA==",
"funding": [
{
"type": "individual",
"url": "https://www.locize.com/i18next"
},
{
"type": "individual",
"url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project"
},
{
"type": "individual",
"url": "https://www.locize.com"
}
],
"license": "MIT",
"peerDependencies": {
"typescript": "^5 || ^6 || ^7"
},
"peerDependenciesMeta": {
"typescript": {
"optional": true
}
}
},
"node_modules/jiti": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz",
@@ -2050,9 +2078,9 @@
}
},
"node_modules/lucide-react": {
"version": "1.24.0",
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.24.0.tgz",
"integrity": "sha512-YT6mBD8lGKkg4nM39enlm94/sfJIiW0YKUT60fBy4YK8tai31ylg1VhGNWxkpSKHo9UagfnZqwIff3HTDQwXeA==",
"version": "1.25.0",
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.25.0.tgz",
"integrity": "sha512-/mdJTRbiwcLOQ1NZZK1amZF9rIZyvO18D6r9TngE6TG1NmqHgFuT4eE7Xrkm9UsXMbBJD1NlfwHVltCDWHrOTw==",
"license": "ISC",
"peerDependencies": {
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
@@ -2135,9 +2163,9 @@
}
},
"node_modules/postcss": {
"version": "8.5.19",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.19.tgz",
"integrity": "sha512-Mz8SaolMd8nB+G13WkORcxQKHZ/NE4xXevtkJHVuG+guo9/wYKlIMTKAqGdEmYOXR2ijPjTYNHssizdaVSUNdQ==",
"version": "8.5.21",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.21.tgz",
"integrity": "sha512-v4sDNP3fdNiWMfabO7OwOQdOX8TiQSztKyT1Wj0w+j7LDallJThJRBBBmzVGyYj0crMh7jlV4zepPkiNu9UwDQ==",
"funding": [
{
"type": "opencollective",
@@ -2154,7 +2182,7 @@
],
"license": "MIT",
"dependencies": {
"nanoid": "^3.3.12",
"nanoid": "^3.3.16",
"picocolors": "^1.1.1",
"source-map-js": "^1.2.1"
},
@@ -2170,24 +2198,51 @@
"license": "MIT"
},
"node_modules/react": {
"version": "19.2.7",
"resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz",
"integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==",
"version": "19.2.8",
"resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz",
"integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/react-dom": {
"version": "19.2.7",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.7.tgz",
"integrity": "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==",
"version": "19.2.8",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.8.tgz",
"integrity": "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==",
"license": "MIT",
"dependencies": {
"scheduler": "^0.27.0"
},
"peerDependencies": {
"react": "^19.2.7"
"react": "^19.2.8"
}
},
"node_modules/react-i18next": {
"version": "17.0.10",
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.0.10.tgz",
"integrity": "sha512-XneHftyYA774MJkkccSkZ5oKrUpCnXIPmxio3wemqrVzCRLWiGXOMbIzObrer03fNDEnm8g8R5yYls4HcE+esg==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.29.2",
"html-parse-stringify": "^3.0.1",
"use-sync-external-store": "^1.6.0"
},
"peerDependencies": {
"i18next": ">= 26.2.0",
"react": ">= 16.8.0",
"typescript": "^5 || ^6 || ^7"
},
"peerDependenciesMeta": {
"react-dom": {
"optional": true
},
"react-native": {
"optional": true
},
"typescript": {
"optional": true
}
}
},
"node_modules/rolldown": {
@@ -2332,7 +2387,7 @@
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz",
"integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==",
"dev": true,
"devOptional": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc"
@@ -2394,6 +2449,15 @@
"browserslist": ">= 4.21.0"
}
},
"node_modules/use-sync-external-store": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
"integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
"license": "MIT",
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/vite": {
"version": "8.1.5",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.1.5.tgz",
@@ -2561,6 +2625,15 @@
}
}
},
"node_modules/void-elements": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz",
"integrity": "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/why-is-node-running": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
+10 -7
View File
@@ -1,7 +1,7 @@
{
"name": "firelink",
"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.",
"license": "MIT",
"homepage": "https://github.com/nimbold/Firelink",
@@ -31,6 +31,7 @@
"dev": "vite",
"bindings": "cd src-tauri && cargo test export_bindings --lib",
"build": "tsc && vite build",
"check:i18n": "vitest run src/i18n/resources.test.ts",
"check:updates": "node scripts/check-updates.js",
"verify:macos-signing": "node scripts/verify-macos-signing.js",
"preview": "vite preview",
@@ -42,23 +43,25 @@
"@tailwindcss/vite": "^4.3.3",
"@tauri-apps/api": "^2.11.1",
"@tauri-apps/plugin-clipboard-manager": "^2.3.2",
"@tauri-apps/plugin-dialog": "^2.7.1",
"@tauri-apps/plugin-dialog": "^2.7.2",
"@tauri-apps/plugin-fs": "^2.5.1",
"@tauri-apps/plugin-log": "^2.9.0",
"@tauri-apps/plugin-notification": "^2.3.3",
"@tauri-apps/plugin-opener": "^2",
"lucide-react": "^1.24.0",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"i18next": "^26.3.6",
"lucide-react": "^1.25.0",
"react": "^19.2.8",
"react-dom": "^19.2.8",
"react-i18next": "^17.0.10",
"zustand": "^5.0.14"
},
"devDependencies": {
"@tauri-apps/cli": "^2.11.4",
"@types/react": "^19.1.8",
"@types/react-dom": "^19.1.6",
"@vitejs/plugin-react": "^6.0.3",
"@vitejs/plugin-react": "^6.0.4",
"autoprefixer": "^10.5.4",
"postcss": "^8.5.19",
"postcss": "^8.5.21",
"tailwindcss": "^4.3.3",
"typescript": "^7.0.2",
"vite": "^8.1.5",
+9
View File
@@ -2,6 +2,7 @@
import fs from 'node:fs';
import path from 'node:path';
import { spawnSync } from 'node:child_process';
import { randomUUID } from 'node:crypto';
import { fileURLToPath } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
@@ -41,6 +42,14 @@ function runNpmScript(script) {
run('npm', ['run', script]);
}
// A packaged Tauri artifact must have its own consent identity. A source or
// commit fingerprint cannot distinguish two fresh signed/package builds made
// from the same checkout, which would let an updated binary silently reuse the
// previous binary's credential-store approval. Keep any configured identity
// as useful provenance, but always add the artifact nonce.
const configuredBuildId = process.env.VITE_BUILD_ID?.trim();
process.env.VITE_BUILD_ID = `${configuredBuildId || 'artifact'}-${randomUUID()}`;
run(process.execPath, ['scripts/stage-engines.js']);
run(process.execPath, ['scripts/verify-binaries.js', '--staged']);
+69
View File
@@ -0,0 +1,69 @@
#!/usr/bin/env node
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const scriptDirectory = path.dirname(fileURLToPath(import.meta.url));
const repositoryRoot = path.resolve(scriptDirectory, '..');
function argValue(name) {
const index = process.argv.indexOf(name);
return index >= 0 ? process.argv[index + 1] : undefined;
}
function readPackageVersion(root) {
return JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8')).version;
}
function readTauriVersion(root) {
return JSON.parse(
fs.readFileSync(path.join(root, 'src-tauri', 'tauri.conf.json'), 'utf8')
).version;
}
function readCargoVersion(root) {
const cargo = fs.readFileSync(path.join(root, 'src-tauri', 'Cargo.toml'), 'utf8');
const packageSection = cargo.match(/^\[package\]\s*([\s\S]*?)(?=^\[)/m)?.[1];
const version = packageSection?.match(/^version\s*=\s*"([^"]+)"/m)?.[1];
if (!version) {
throw new Error('Could not read the [package] version from src-tauri/Cargo.toml.');
}
return version;
}
function versionFromRef(ref) {
if (!ref || !ref.startsWith('v')) {
throw new Error(`Expected a semantic version tag such as v1.2.3, received ${ref || 'nothing'}.`);
}
const version = ref.slice(1);
if (!/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/.test(version)) {
throw new Error(`Release tag ${ref} does not contain a valid semantic version.`);
}
return version;
}
const tag = argValue('--tag') || process.env.GITHUB_REF_NAME;
const expected = versionFromRef(tag);
const versions = {
'package.json': readPackageVersion(repositoryRoot),
'src-tauri/Cargo.toml': readCargoVersion(repositoryRoot),
'src-tauri/tauri.conf.json': readTauriVersion(repositoryRoot),
};
const mismatches = Object.entries(versions)
.filter(([, version]) => version !== expected)
.map(([file, version]) => `${file}=${version}`);
if (mismatches.length > 0) {
console.error(`Release tag ${tag} does not match the application manifests (expected ${expected}).`);
for (const mismatch of mismatches) console.error(` ${mismatch}`);
process.exit(1);
}
const uniqueVersions = new Set(Object.values(versions));
if (uniqueVersions.size !== 1) {
console.error('Application version manifests do not agree:');
for (const [file, version] of Object.entries(versions)) console.error(` ${file}=${version}`);
process.exit(1);
}
console.log(`Release version ${expected} matches ${Object.keys(versions).length} manifests.`);
@@ -0,0 +1,37 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { spawnSync } from 'node:child_process';
import test from 'node:test';
const repositoryRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const verifier = path.join(repositoryRoot, 'scripts', 'verify-release-version.js');
const currentVersion = JSON.parse(
fs.readFileSync(path.join(repositoryRoot, 'package.json'), 'utf8')
).version;
function runVerifier(tag) {
return spawnSync(process.execPath, [verifier, '--tag', tag], {
cwd: repositoryRoot,
encoding: 'utf8',
});
}
test('release version verifier accepts the aligned current version', () => {
const result = runVerifier(`v${currentVersion}`);
assert.equal(result.status, 0, result.stderr);
assert.match(result.stdout, new RegExp(`Release version ${currentVersion} matches`));
});
test('release version verifier rejects the already-published prior tag', () => {
const result = runVerifier('v1.1.1');
assert.equal(result.status, 1);
assert.match(result.stderr, /does not match the application manifests/);
});
test('release version verifier rejects non-semver tag names', () => {
const result = runVerifier('release-candidate');
assert.equal(result.status, 1);
assert.match(result.stderr, /semantic version tag/);
});
+127 -116
View File
@@ -80,9 +80,9 @@ dependencies = [
[[package]]
name = "anyhow"
version = "1.0.103"
version = "1.0.104"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3"
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
[[package]]
name = "apple-native-keyring-store"
@@ -238,13 +238,13 @@ checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de"
[[package]]
name = "async-trait"
version = "0.1.89"
version = "0.1.91"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb"
checksum = "ae36dc4177970ef04fde5178d3e2429882def40e57a451f919c098f72baa6cec"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
"syn 3.0.3",
]
[[package]]
@@ -463,9 +463,9 @@ checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
[[package]]
name = "bytemuck"
version = "1.25.1"
version = "1.25.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6aedf8ae72766347502cf3cb4f41cf5e9cc37d28bee90f1fdaaae15f9cf9424"
checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797"
[[package]]
name = "byteorder"
@@ -542,7 +542,7 @@ dependencies = [
"semver",
"serde",
"serde_json",
"thiserror 2.0.18",
"thiserror 2.0.19",
]
[[package]]
@@ -566,9 +566,9 @@ dependencies = [
[[package]]
name = "cc"
version = "1.2.67"
version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e17dd265a7d0f31ef544e1b20e03add05d3b45b491b633b10d67145d2acc1a38"
checksum = "c89588d05638b5b4594a3348a2d6c20277e43a7f5c5202b05cc56888475a47b8"
dependencies = [
"find-msvc-tools",
"shlex",
@@ -1319,9 +1319,9 @@ checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a"
[[package]]
name = "fastrand"
version = "2.4.1"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6"
checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223"
[[package]]
name = "fax"
@@ -1365,7 +1365,7 @@ checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
[[package]]
name = "firelink"
version = "1.1.1"
version = "1.2.0"
dependencies = [
"apple-native-keyring-store",
"async-trait",
@@ -1399,7 +1399,7 @@ dependencies = [
"tauri-plugin-shell",
"tauri-plugin-single-instance",
"tempfile",
"thiserror 2.0.18",
"thiserror 2.0.19",
"tokio",
"tokio-tungstenite",
"tower-http 0.7.0",
@@ -1483,24 +1483,24 @@ dependencies = [
[[package]]
name = "futures-channel"
version = "0.3.32"
version = "0.3.33"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d"
checksum = "262590f4fe6afeb0bc83be1daa64e52657fe185690a958af7f3ad0e92085c5ae"
dependencies = [
"futures-core",
]
[[package]]
name = "futures-core"
version = "0.3.32"
version = "0.3.33"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d"
checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7"
[[package]]
name = "futures-executor"
version = "0.3.32"
version = "0.3.33"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d"
checksum = "6754879cc9f2c66f88c6e5c35344bb0bdb0708b0352b1201815667c7eabc7458"
dependencies = [
"futures-core",
"futures-task",
@@ -1509,9 +1509,9 @@ dependencies = [
[[package]]
name = "futures-io"
version = "0.3.32"
version = "0.3.33"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718"
checksum = "4577ecaa3c4f96589d473f679a71b596316f6641bc350038b962a5daf0085d7a"
[[package]]
name = "futures-lite"
@@ -1528,9 +1528,9 @@ dependencies = [
[[package]]
name = "futures-macro"
version = "0.3.32"
version = "0.3.33"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b"
checksum = "2d6d3cde68c518367be28956066ddfef33813991b77a55005a69dae04bf3b10b"
dependencies = [
"proc-macro2",
"quote",
@@ -1539,21 +1539,21 @@ dependencies = [
[[package]]
name = "futures-sink"
version = "0.3.32"
version = "0.3.33"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893"
checksum = "e34418ac499d6305c2fb5ad0ed2f6ac998c5f8ca209b4510f7f94242c647e307"
[[package]]
name = "futures-task"
version = "0.3.32"
version = "0.3.33"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393"
checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109"
[[package]]
name = "futures-util"
version = "0.3.32"
version = "0.3.33"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6"
checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa"
dependencies = [
"futures-core",
"futures-io",
@@ -1800,9 +1800,9 @@ dependencies = [
[[package]]
name = "glob"
version = "0.3.3"
version = "0.3.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280"
checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b"
[[package]]
name = "gobject-sys"
@@ -2058,9 +2058,9 @@ dependencies = [
[[package]]
name = "hyper"
version = "1.10.1"
version = "1.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498"
checksum = "d22053281f852e11534f5198498373cbb59295120a20771d90f7ed1897490a72"
dependencies = [
"atomic-waker",
"bytes",
@@ -2412,7 +2412,7 @@ dependencies = [
"jni-sys 0.4.1",
"log 0.4.33",
"simd_cesu8",
"thiserror 2.0.18",
"thiserror 2.0.19",
"walkdir",
"windows-link 0.2.1",
]
@@ -2501,7 +2501,7 @@ dependencies = [
"derive_builder",
"objc2-core-foundation",
"objc2-io-kit",
"thiserror 2.0.18",
"thiserror 2.0.19",
"windows 0.62.2",
"zbus",
]
@@ -2564,9 +2564,9 @@ dependencies = [
[[package]]
name = "libc"
version = "0.2.186"
version = "0.2.189"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
[[package]]
name = "libdbus-sys"
@@ -2761,7 +2761,7 @@ dependencies = [
"once_cell",
"png 0.18.1",
"serde",
"thiserror 2.0.18",
"thiserror 2.0.19",
"windows-sys 0.61.2",
]
@@ -3532,9 +3532,9 @@ dependencies = [
[[package]]
name = "proc-macro2"
version = "1.0.106"
version = "1.0.107"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
dependencies = [
"unicode-ident",
]
@@ -3577,9 +3577,9 @@ dependencies = [
[[package]]
name = "quote"
version = "1.0.46"
version = "1.0.47"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368"
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
dependencies = [
"proc-macro2",
]
@@ -3665,27 +3665,27 @@ checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac"
dependencies = [
"getrandom 0.2.17",
"libredox",
"thiserror 2.0.18",
"thiserror 2.0.19",
]
[[package]]
name = "ref-cast"
version = "1.0.25"
version = "1.0.26"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d"
checksum = "216e8f773d7923bcba9ceb86a86c93cabb3903a11872fc3f138c49630e50b96d"
dependencies = [
"ref-cast-impl",
]
[[package]]
name = "ref-cast-impl"
version = "1.0.25"
version = "1.0.26"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da"
checksum = "2c9283685feec7d69af75fb0e858d5e7378f33fe4fc699383b2916ab9273e03c"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
"syn 3.0.3",
]
[[package]]
@@ -3823,7 +3823,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c51c9ae4df8a7fba42103df5c621fa3c37eccf3a3c650879e90fc48b11cc192c"
dependencies = [
"hashbrown 0.16.1",
"thiserror 2.0.18",
"thiserror 2.0.19",
]
[[package]]
@@ -4112,9 +4112,9 @@ dependencies = [
[[package]]
name = "serde"
version = "1.0.228"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
dependencies = [
"serde_core",
"serde_derive",
@@ -4134,22 +4134,22 @@ dependencies = [
[[package]]
name = "serde_core"
version = "1.0.228"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.228"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
"syn 3.0.3",
]
[[package]]
@@ -4165,9 +4165,9 @@ dependencies = [
[[package]]
name = "serde_json"
version = "1.0.150"
version = "1.0.151"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9"
checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14"
dependencies = [
"itoa",
"memchr",
@@ -4189,13 +4189,13 @@ dependencies = [
[[package]]
name = "serde_repr"
version = "0.1.20"
version = "0.1.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c"
checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
"syn 3.0.3",
]
[[package]]
@@ -4556,6 +4556,17 @@ dependencies = [
"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]]
name = "sync_wrapper"
version = "1.0.2"
@@ -4725,7 +4736,7 @@ dependencies = [
"tauri-runtime",
"tauri-runtime-wry",
"tauri-utils",
"thiserror 2.0.18",
"thiserror 2.0.19",
"tokio",
"tray-icon",
"url",
@@ -4776,7 +4787,7 @@ dependencies = [
"sha2 0.10.9",
"syn 2.0.119",
"tauri-utils",
"thiserror 2.0.18",
"thiserror 2.0.19",
"time",
"url",
"uuid",
@@ -4825,7 +4836,7 @@ dependencies = [
"serde_json",
"tauri",
"tauri-plugin",
"thiserror 2.0.18",
"thiserror 2.0.19",
]
[[package]]
@@ -4842,7 +4853,7 @@ dependencies = [
"tauri",
"tauri-plugin",
"tauri-utils",
"thiserror 2.0.18",
"thiserror 2.0.19",
"tracing",
"url",
"windows-registry",
@@ -4851,9 +4862,9 @@ dependencies = [
[[package]]
name = "tauri-plugin-dialog"
version = "2.7.1"
version = "2.7.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "65981abb771e74e571a38196c3baa11c459379164791eba0e67abc1a5fac9884"
checksum = "b2d3c1dbe38037e7f590cdf2492594d5ceebe031e7bc7e827509b22a999d2940"
dependencies = [
"log 0.4.33",
"raw-window-handle",
@@ -4863,7 +4874,7 @@ dependencies = [
"tauri",
"tauri-plugin",
"tauri-plugin-fs",
"thiserror 2.0.18",
"thiserror 2.0.19",
"url",
]
@@ -4886,7 +4897,7 @@ dependencies = [
"tauri",
"tauri-plugin",
"tauri-utils",
"thiserror 2.0.18",
"thiserror 2.0.19",
"toml 1.1.3+spec-1.1.0",
"url",
]
@@ -4908,7 +4919,7 @@ dependencies = [
"swift-rs",
"tauri",
"tauri-plugin",
"thiserror 2.0.18",
"thiserror 2.0.19",
"time",
]
@@ -4926,7 +4937,7 @@ dependencies = [
"serde_repr",
"tauri",
"tauri-plugin",
"thiserror 2.0.18",
"thiserror 2.0.19",
"time",
"url",
]
@@ -4947,7 +4958,7 @@ dependencies = [
"serde_json",
"tauri",
"tauri-plugin",
"thiserror 2.0.18",
"thiserror 2.0.19",
"url",
"windows 0.61.3",
"zbus",
@@ -4970,7 +4981,7 @@ dependencies = [
"shared_child",
"tauri",
"tauri-plugin",
"thiserror 2.0.18",
"thiserror 2.0.19",
"tokio",
]
@@ -4984,7 +4995,7 @@ dependencies = [
"serde_json",
"tauri",
"tauri-plugin-deep-link",
"thiserror 2.0.18",
"thiserror 2.0.19",
"tokio",
"tracing",
"windows-sys 0.60.2",
@@ -5009,7 +5020,7 @@ dependencies = [
"serde",
"serde_json",
"tauri-utils",
"thiserror 2.0.18",
"thiserror 2.0.19",
"url",
"webkit2gtk",
"webview2-com",
@@ -5072,7 +5083,7 @@ dependencies = [
"serde_json",
"serde_with",
"swift-rs",
"thiserror 2.0.18",
"thiserror 2.0.19",
"toml 1.1.3+spec-1.1.0",
"url",
"urlpattern",
@@ -5097,7 +5108,7 @@ version = "0.7.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ed071c670382e85fc2f48ae706492d8c338f4f89bf72520d32f8abfe880aade"
dependencies = [
"thiserror 2.0.18",
"thiserror 2.0.19",
"windows 0.61.3",
"windows-version",
]
@@ -5144,11 +5155,11 @@ dependencies = [
[[package]]
name = "thiserror"
version = "2.0.18"
version = "2.0.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4"
checksum = "09a43598840e33d5b0331f38c5e30d13bb11c11210a4b58f0d9b18a5a5eefcd9"
dependencies = [
"thiserror-impl 2.0.18",
"thiserror-impl 2.0.19",
]
[[package]]
@@ -5164,13 +5175,13 @@ dependencies = [
[[package]]
name = "thiserror-impl"
version = "2.0.18"
version = "2.0.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5"
checksum = "43cbfe0cf76104d42a574802844187e84a305e531ed54455f11fbde0f10541cd"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
"syn 3.0.3",
]
[[package]]
@@ -5198,9 +5209,9 @@ dependencies = [
[[package]]
name = "time"
version = "0.3.53"
version = "0.3.54"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "18dfaaeddcb932337b5e7866ee7d0ce9b76d2fd092997146f187ec09b4558a50"
checksum = "3e1d5e639ff6bab73cb6885cc7e7b1de96c3f32c68ec55f3952614bec1092244"
dependencies = [
"deranged",
"libc",
@@ -5220,9 +5231,9 @@ checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109"
[[package]]
name = "time-macros"
version = "0.2.31"
version = "0.2.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c431b87111666e491a90baa837f914fb45cd5dc3c268591b0220ff5057f2085f"
checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85"
dependencies = [
"num-conv",
"time-core",
@@ -5264,9 +5275,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
[[package]]
name = "tokio"
version = "1.52.4"
version = "1.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "317fafbbe3f02fc663dad00ea6186197de963cd4190e86a26d8d0fae095539af"
checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed"
dependencies = [
"bytes",
"libc",
@@ -5280,9 +5291,9 @@ dependencies = [
[[package]]
name = "tokio-macros"
version = "2.7.0"
version = "2.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496"
checksum = "6328af13490e73a9b4694030fafd93f8c8c6a9dede33e821c3fc63eddf8042ba"
dependencies = [
"proc-macro2",
"quote",
@@ -5313,9 +5324,9 @@ dependencies = [
[[package]]
name = "tokio-util"
version = "0.7.18"
version = "0.7.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098"
checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52"
dependencies = [
"bytes",
"futures-core",
@@ -5575,7 +5586,7 @@ dependencies = [
"once_cell",
"png 0.18.1",
"serde",
"thiserror 2.0.18",
"thiserror 2.0.19",
"windows-sys 0.61.2",
]
@@ -5602,7 +5613,7 @@ version = "12.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "756050066659291d47a554a9f558125db17428b073c5ffce1daf5dcb0f7231d8"
dependencies = [
"thiserror 2.0.18",
"thiserror 2.0.19",
"ts-rs-macros",
"uuid",
]
@@ -5632,7 +5643,7 @@ dependencies = [
"log 0.4.33",
"rand 0.10.2",
"sha1",
"thiserror 2.0.18",
"thiserror 2.0.19",
]
[[package]]
@@ -6056,9 +6067,9 @@ dependencies = [
[[package]]
name = "webpki-root-certs"
version = "1.0.8"
version = "1.0.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0d46a5a140e6f7afeccd8eae97eff335163939eac8b929834875168b29b3d267"
checksum = "b96554aa2acc8ccdb7e1c9a58a7a68dd5d13bccc69cd124cb09406db612a1c9b"
dependencies = [
"rustls-pki-types",
]
@@ -6094,7 +6105,7 @@ version = "0.38.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "381336cfffd772377d291702245447a5251a2ffa5bad679c99e61bc48bacbf9c"
dependencies = [
"thiserror 2.0.18",
"thiserror 2.0.19",
"windows 0.61.3",
"windows-core 0.61.2",
]
@@ -6726,7 +6737,7 @@ dependencies = [
"log 0.4.33",
"os_pipe",
"rustix",
"thiserror 2.0.18",
"thiserror 2.0.19",
"tree_magic_mini",
"wayland-backend",
"wayland-client",
@@ -6773,7 +6784,7 @@ dependencies = [
"sha2 0.10.9",
"soup3",
"tao-macros",
"thiserror 2.0.18",
"thiserror 2.0.19",
"url",
"webkit2gtk",
"webkit2gtk-sys",
@@ -6847,9 +6858,9 @@ dependencies = [
[[package]]
name = "zbus"
version = "5.17.0"
version = "5.18.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a28b97f866896a4be7aefd2b5a8e01bb6773d19a775d54ab28b4d094b9a4480e"
checksum = "fe18fb60dc696039e738717b76eaea21e7a4489bbb1885020b43c94236d7e98a"
dependencies = [
"async-broadcast",
"async-executor",
@@ -6893,9 +6904,9 @@ dependencies = [
[[package]]
name = "zbus_macros"
version = "5.17.0"
version = "5.18.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e05ad887425eecf5e8384dc2406a4a9313eb73468712fc1cdea362eb4fe0469"
checksum = "fe96480bed92df2b442a1a30df364e12d08eed03aeb061f2b8dc6afb2be91119"
dependencies = [
"proc-macro-crate 3.5.0",
"proc-macro2",
@@ -6908,9 +6919,9 @@ dependencies = [
[[package]]
name = "zbus_names"
version = "4.3.3"
version = "4.3.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1039ca249fee9559680f3a9f05b55e0761fee51af4f6c1e7d8c1f31e549721d2"
checksum = "d8bf88b4a3ff53e883001e0e0115b297a9d53c31b9c1edd2bfdd853e3428624e"
dependencies = [
"serde",
"winnow 1.0.4",
@@ -6919,18 +6930,18 @@ dependencies = [
[[package]]
name = "zerocopy"
version = "0.8.54"
version = "0.8.55"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7cbbc0a705a0fd05cc3676525980d2bf5a9bc4adac6d6475209a7887cf59d19"
checksum = "b5a105cd7b140f6eeec8acff2ea38135d3cab283ada58540f629fe51e46696eb"
dependencies = [
"zerocopy-derive",
]
[[package]]
name = "zerocopy-derive"
version = "0.8.54"
version = "0.8.55"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e2e817b7b52d0c7358d3246da9d69935ebb18116b2b102b4230dac079b4862f5"
checksum = "0fe976fb70c78cd64cccfe3a6fc142244e8a77b70959b30faf9d0ac37ee228eb"
dependencies = [
"proc-macro2",
"quote",
@@ -7020,9 +7031,9 @@ dependencies = [
[[package]]
name = "zvariant"
version = "5.13.0"
version = "5.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7cf057bb00bf5c9ad77abb6147b0ca4818236a1858416e9d988e40d6322fefa7"
checksum = "bee2a0bcd2a907786a456fff45aaaaf54c9ba5f50b71ae9ec1a4edd200c94911"
dependencies = [
"endi",
"enumflags2",
@@ -7034,9 +7045,9 @@ dependencies = [
[[package]]
name = "zvariant_derive"
version = "5.13.0"
version = "5.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8118ca6bda77bfc0ab51d660db0c955f2505eef854c9a449435bccb616933b31"
checksum = "38a708216a18780796770bfe3f4739c7c83a3e8f789b755534bbbc06e4e23e12"
dependencies = [
"proc-macro-crate 3.5.0",
"proc-macro2",
+4 -4
View File
@@ -1,6 +1,6 @@
[package]
name = "firelink"
version = "1.1.1"
version = "1.2.0"
description = "A fast cross-platform desktop download manager powered by Rust and Tauri"
authors = ["NimBold"]
edition = "2021"
@@ -25,7 +25,7 @@ tauri-build = { version = "2", features = [] }
[dependencies]
tauri = { version = "2", features = ["macos-private-api", "tray-icon", "image-png", "test"] }
tauri-plugin-opener = "2"
tauri-plugin-dialog = "2"
tauri-plugin-dialog = "2.7.2"
tauri-plugin-shell = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
@@ -43,7 +43,7 @@ sha2 = "0.11"
tauri-plugin-deep-link = "2"
tauri-plugin-single-instance = { version = "2.4.3", features = ["deep-link"] }
tempfile = "3"
thiserror = "2.0.18"
thiserror = "2.0.19"
axum = "0.8.9"
tower-http = { version = "0.7", features = ["cors", "limit"] }
sysproxy = "0.3.0"
@@ -51,7 +51,7 @@ semver = "1.0.28"
keepawake = "0.6.0"
system_shutdown = "4.1.0"
tokio-tungstenite = "0.30.0"
futures-util = { version = "0.3.32", features = ["sink"] }
futures-util = { version = "0.3.33", features = ["sink"] }
chrono = "0.4.38"
url = "2"
rusqlite = { version = "0.40.1", features = ["bundled"] }
+10 -38
View File
@@ -45,18 +45,17 @@ struct LegacyData {
}
pub fn init(storage_layout: &crate::storage::StorageLayout) -> Result<DbState, String> {
init_at_path_internal(storage_layout.data_dir(), storage_layout.is_portable(), false)
init_at_path_internal(storage_layout.data_dir(), storage_layout.is_portable())
}
#[cfg(test)]
fn init_at_path(app_data_dir: &Path) -> Result<DbState, String> {
init_at_path_internal(app_data_dir, false, false)
init_at_path_internal(app_data_dir, false)
}
fn init_at_path_internal(
app_data_dir: &Path,
portable: bool,
migrate_legacy_keychain: bool,
) -> Result<DbState, String> {
fs::create_dir_all(app_data_dir)
.map_err(|error| format!("failed to create app data directory: {error}"))?;
@@ -78,12 +77,7 @@ fn init_at_path_internal(
}
migrate_schema(&mut connection, version)?;
import_legacy_data(
&mut connection,
app_data_dir,
portable,
migrate_legacy_keychain,
)?;
import_legacy_data(&mut connection, app_data_dir, portable)?;
if portable {
sanitize_persisted_downloads(&mut connection)?;
}
@@ -188,7 +182,6 @@ fn import_legacy_data(
connection: &mut Connection,
app_data_dir: &Path,
portable: bool,
migrate_keychain: bool,
) -> Result<(), String> {
let legacy_app_dir = app_data_dir
.parent()
@@ -226,31 +219,10 @@ fn import_legacy_data(
let mut pending_pairing_token = None;
if !portable {
if let Some(token) = legacy.pairing_token.take() {
let migrated = if migrate_keychain {
let keychain_has_token = get_keychain_password(PAIRING_TOKEN_KEYCHAIN_ID)
.ok()
.is_some_and(|value| !value.trim().is_empty());
if !keychain_has_token {
if let Err(error) =
set_keychain_password(PAIRING_TOKEN_KEYCHAIN_ID, &token)
{
log::warn!(
"Legacy pairing token could not be migrated to the credential store; it will remain pending in the current database: {}",
error
);
false
} else {
true
}
} else {
true
}
} else {
false
};
if !migrated {
pending_pairing_token = Some(token);
}
// Legacy migration is deliberately deferred until the
// explicit frontend consent action. Database initialization
// must never touch the OS credential store.
pending_pairing_token = Some(token);
}
}
if portable {
@@ -1738,7 +1710,7 @@ mod tests {
.unwrap();
drop(connection);
let state = init_at_path_internal(temp.path(), true, true).unwrap();
let state = init_at_path_internal(temp.path(), true).unwrap();
let connection = state.lock().unwrap();
let saved: Value = serde_json::from_str(&load_downloads(&connection).unwrap()[0]).unwrap();
assert!(saved.get("password").is_none());
@@ -1828,7 +1800,7 @@ mod tests {
});
fs::write(&store_path, serde_json::to_vec(&store).unwrap()).unwrap();
let state = init_at_path_internal(&current, true, true).unwrap();
let state = init_at_path_internal(&current, true).unwrap();
let connection = state.lock().unwrap();
let saved: Value = serde_json::from_str(&load_downloads(&connection).unwrap()[0]).unwrap();
assert!(saved.get("password").is_none());
@@ -2104,7 +2076,7 @@ mod tests {
drop(connection);
drop(state);
let state = init_at_path_internal(temp.path(), true, true).unwrap();
let state = init_at_path_internal(temp.path(), true).unwrap();
let connection = state.lock().unwrap();
let saved: Value = serde_json::from_str(&load_downloads(&connection).unwrap()[0]).unwrap();
assert!(saved.get("password").is_none());
+90 -6
View File
@@ -74,6 +74,10 @@ struct ExtensionRequest {
cookie_scopes: Option<Vec<ExtensionCookieScope>>,
#[serde(default)]
media: bool,
#[serde(default)]
batch: bool,
#[serde(default)]
batch_name: Option<String>,
}
#[derive(Clone, Deserialize, Serialize, TS)]
@@ -96,6 +100,8 @@ pub struct ExtensionDownload {
cookies: Option<String>,
cookie_scopes: Option<Vec<ExtensionCookieScope>>,
media: bool,
batch: bool,
batch_name: Option<String>,
}
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())
});
let filename = payload.filename.and_then(|value| sanitize_filename(&value));
let batch = payload.batch && urls.len() >= 2;
let batch_name = batch
.then(|| payload.batch_name)
.flatten()
.and_then(|value| {
let value = value.trim().to_string();
(!value.is_empty() && value.chars().count() <= 512).then_some(value)
});
// A multi-URL handoff has no per-URL cookie scope. Keep ordinary
// request headers, but drop Cookie headers and the dedicated cookie field
// request headers, but drop credential-bearing headers and the dedicated cookie field
// so a legacy or untrusted caller cannot reuse one session across hosts.
let headers = normalize_headers(payload.headers, payload.media || urls.len() > 1);
let cookie_scopes = if !payload.media && urls.len() == 1 {
@@ -468,6 +482,8 @@ fn normalize_download(mut payload: ExtensionRequest) -> Option<ExtensionDownload
cookies,
cookie_scopes,
media: payload.media,
batch,
batch_name,
})
}
@@ -532,7 +548,17 @@ fn normalize_headers(headers: Option<String>, media: bool) -> Option<String> {
.lines()
.filter(|line| {
line.split_once(':')
.map(|(name, _)| !name.trim().eq_ignore_ascii_case("cookie"))
.map(|(name, _)| {
!matches!(
name.trim().to_ascii_lowercase().as_str(),
"authorization"
| "cookie"
| "cookie2"
| "proxy-authorization"
| "set-cookie"
| "set-cookie2"
)
})
.unwrap_or(true)
})
.collect::<Vec<_>>()
@@ -690,9 +716,9 @@ fn is_allowed_origin(origin: &str) -> bool {
mod tests {
use super::{
acknowledge_extension_download, add_server_identity, claim_request_at,
has_allowed_request_origin, is_valid_client_nonce,
normalize_download, required_client_nonce, sign_server_proof, ExtensionCookieScope,
ExtensionRequest, MAX_URL_COUNT, PROTOCOL_VERSION_HEADER, SERVER_HEADER,
has_allowed_request_origin, is_valid_client_nonce, normalize_download,
required_client_nonce, sign_server_proof, ExtensionCookieScope, ExtensionRequest,
MAX_URL_COUNT, PROTOCOL_VERSION_HEADER, SERVER_HEADER,
};
use axum::{
http::{HeaderMap, HeaderValue, StatusCode},
@@ -790,6 +816,8 @@ mod tests {
cookies: None,
cookie_scopes: None,
media: true,
batch: false,
batch_name: None,
});
assert!(download.is_none());
@@ -808,6 +836,8 @@ mod tests {
cookies: None,
cookie_scopes: None,
media: false,
batch: false,
batch_name: None,
});
assert!(download.is_none());
@@ -861,12 +891,14 @@ mod tests {
silent: false,
filename: None,
headers: Some(format!(
"Cookie: stale={};\nUser-Agent: Firefox",
"Cookie: stale={};\nCookie2: stale=1\nAuthorization: Bearer stale\nProxy-Authorization: Basic stale\nSet-Cookie: stale=1\nSet-Cookie2: stale=1\nUser-Agent: Firefox",
"x".repeat(64 * 1024)
)),
cookies: Some(format!("large={}", "x".repeat(64 * 1024))),
cookie_scopes: None,
media: true,
batch: false,
batch_name: None,
})
.expect("valid media handoff");
@@ -886,6 +918,8 @@ mod tests {
cookies: Some("session=browser-cookie-header".to_string()),
cookie_scopes: None,
media: false,
batch: false,
batch_name: None,
})
.expect("valid download handoff");
@@ -920,6 +954,8 @@ mod tests {
},
]),
media: false,
batch: false,
batch_name: None,
})
.expect("valid download handoff");
@@ -948,6 +984,8 @@ mod tests {
cookies: Some("session=secret".to_string()),
cookie_scopes: None,
media: false,
batch: false,
batch_name: None,
})
.expect("valid multi-url handoff");
@@ -955,6 +993,52 @@ mod tests {
assert_eq!(download.headers.as_deref(), Some("User-Agent: Firefox"));
}
#[test]
fn selected_link_batches_preserve_context_only_for_two_or_more_urls() {
let download = normalize_download(ExtensionRequest {
urls: vec![
"https://example.com/one.zip".to_string(),
"https://example.com/two.zip".to_string(),
],
referer: Some("https://example.com/gallery".to_string()),
silent: false,
filename: None,
headers: None,
cookies: None,
cookie_scopes: None,
media: false,
batch: true,
batch_name: Some("Example Gallery / Chapter: 1".to_string()),
})
.expect("valid selected-link batch");
assert!(download.batch);
assert_eq!(
download.batch_name.as_deref(),
Some("Example Gallery / Chapter: 1")
);
}
#[test]
fn selected_link_batch_context_is_dropped_for_single_urls() {
let download = normalize_download(ExtensionRequest {
urls: vec!["https://example.com/one.zip".to_string()],
referer: Some("https://example.com/gallery".to_string()),
silent: false,
filename: None,
headers: None,
cookies: None,
cookie_scopes: None,
media: false,
batch: true,
batch_name: Some("Example Gallery".to_string()),
})
.expect("valid single-link handoff");
assert!(!download.batch);
assert!(download.batch_name.is_none());
}
#[test]
fn signs_server_proof_with_timestamp_nonce_and_bound_port() {
let token = Arc::new(RwLock::new("pairing-token".to_string()));
+13
View File
@@ -6,6 +6,14 @@ fn default_speed_limit_unit() -> String {
"MB/s".to_string()
}
fn default_language_preference() -> String {
"system".to_string()
}
fn default_sidebar_position() -> String {
"auto".to_string()
}
#[derive(Clone, Copy, Debug, Serialize, Deserialize, TS)]
#[serde(rename_all = "lowercase")]
#[ts(export, export_to = "../../src/bindings/")]
@@ -257,6 +265,8 @@ pub struct SchedulerSettings {
#[ts(export, export_to = "../../src/bindings/")]
pub struct PersistedSettings {
pub theme: Theme,
#[serde(default = "default_language_preference")]
pub language: String,
pub base_download_folder: String,
pub category_subfolders_enabled: bool,
pub category_subfolders: HashMap<String, String>,
@@ -267,6 +277,8 @@ pub struct PersistedSettings {
pub speed_limit_preset_values: Vec<f64>,
pub logs_enabled: bool,
pub is_sidebar_visible: bool,
#[serde(default = "default_sidebar_position")]
pub sidebar_position: String,
pub active_settings_tab: SettingsTab,
pub scheduler: SchedulerSettings,
pub scheduler_running: bool,
@@ -291,6 +303,7 @@ pub struct PersistedSettings {
pub proxy_port: u16,
pub custom_user_agent: String,
pub ask_where_to_save_each_file: bool,
pub remember_last_used_download_directory: bool,
pub prevents_sleep_while_downloading: bool,
pub media_cookie_source: MediaCookieSource,
pub site_logins: Vec<SiteLogin>,
+469 -46
View File
@@ -1089,7 +1089,7 @@ fn parse_media_progress_line(line: &str) -> Option<MediaProgress> {
None
} else {
progress_json_string(&progress, "_total_bytes_str")
.or_else(|| exact_total.map(|total| crate::download::format_size(total)))
.or_else(|| exact_total.map(crate::download::format_size))
};
return Some(MediaProgress {
@@ -1464,6 +1464,8 @@ fn emit_media_progress(
downloaded_bytes,
total_bytes,
total_is_estimate,
active_connections: None,
requested_connections: None,
},
);
state.last_progress_at = now;
@@ -1520,25 +1522,83 @@ async fn cleanup_media_artifacts(out_path: &std::path::Path, remove_primary: boo
let Some(name) = path.file_name().and_then(|name| name.to_str()) else {
continue;
};
if !name.starts_with(base_name) && !name.starts_with(base_stem) {
continue;
}
let yt_dlp_format_fragment = name
.strip_prefix(base_stem)
.and_then(|suffix| suffix.strip_prefix(".f"))
.and_then(|suffix| suffix.chars().next())
.is_some_and(|ch| ch.is_ascii_digit());
let looks_like_media_temp = name.contains(".part")
|| name.contains(".ytdl")
|| name.contains(".temp")
|| name.contains(".tmp")
|| yt_dlp_format_fragment;
if looks_like_media_temp {
if is_media_artifact_name(name, base_name, base_stem) {
remove_file_best_effort_with_retry(&path).await;
}
}
}
fn is_media_artifact_name(name: &str, base_name: &str, base_stem: &str) -> bool {
let suffix = name.strip_prefix(base_name).or_else(|| {
(base_stem != base_name)
.then(|| name.strip_prefix(base_stem))
.flatten()
});
let Some(suffix) = suffix else {
return false;
};
if matches!(suffix, ".part" | ".ytdl" | ".temp" | ".tmp") {
return true;
}
for marker in [".part", ".ytdl", ".temp", ".tmp"] {
if let Some(extension) = suffix.strip_suffix(marker) {
if is_known_media_extension(extension.strip_prefix('.').unwrap_or(extension)) {
return true;
}
}
}
let Some(format_suffix) = suffix.strip_prefix(".f") else {
return false;
};
let Some((format_id, extension)) = format_suffix.split_once('.') else {
return false;
};
if format_id.is_empty() || !format_id.chars().all(|ch| ch.is_ascii_digit()) {
return false;
}
let extension = [".part", ".ytdl", ".temp", ".tmp"]
.iter()
.find_map(|marker| extension.strip_suffix(marker))
.unwrap_or(extension);
is_known_media_extension(extension)
}
fn is_known_media_extension(extension: &str) -> bool {
matches!(
extension.to_ascii_lowercase().as_str(),
"3gp"
| "aac"
| "ass"
| "avi"
| "flac"
| "flv"
| "jpg"
| "jpeg"
| "m4a"
| "m4v"
| "mka"
| "mkv"
| "mov"
| "mp3"
| "mp4"
| "oga"
| "ogg"
| "opus"
| "srt"
| "ts"
| "wav"
| "webm"
| "webp"
| "wmv"
| "vtt"
)
}
fn sanitize_ytdlp_config_value(value: &str) -> String {
value.replace(['\n', '\r'], "")
}
@@ -1605,6 +1665,23 @@ fn is_browser_cookie_extraction_error(message: &str) -> bool {
|| lower.contains("failed to decrypt with dpapi")
}
fn should_retry_without_browser_cookies(
cookie_browser: Option<&str>,
failure_reason: &str,
fallback_used: bool,
) -> bool {
// Browser cookies are an optional authentication enhancement. Chromium on
// Windows can deny access to its database independently of whether the
// requested media needs authentication, so only this narrowly classified
// extraction failure may downgrade to a public-media attempt.
!fallback_used
&& cookie_browser.is_some_and(|source| {
let source = source.trim();
!source.is_empty() && !source.eq_ignore_ascii_case("none")
})
&& is_browser_cookie_extraction_error(failure_reason)
}
fn should_cleanup_media_artifacts_after_failure(
failure_reason: &str,
strike: usize,
@@ -1837,6 +1914,8 @@ async fn fetch_metadata(
let mut current_res = match head_req.send().await {
Ok(response) => response,
Err(head_error) => build_get_range().send().await.map_err(|get_error| {
let head_error = crate::redact_sensitive_text(&head_error.to_string());
let get_error = crate::redact_sensitive_text(&get_error.to_string());
format!(
"HEAD metadata request failed ({head_error}); ranged GET fallback failed ({get_error})"
)
@@ -1982,6 +2061,11 @@ const MEDIA_METADATA_TIMEOUT: Duration = Duration::from_secs(55);
const MEDIA_METADATA_CACHE_MAX_ENTRIES: usize = 128;
const FILE_METADATA_TIMEOUT: Duration = Duration::from_secs(20);
const MAX_SHELL_OUTPUT_BYTES: usize = 32 * 1024 * 1024;
fn normalize_media_connections(connections: Option<i32>) -> i32 {
crate::queue::clamp_download_connections(
connections.unwrap_or(crate::queue::DOWNLOAD_CONNECTIONS_MAX),
)
}
struct ShellCommandOutput {
status_code: Option<i32>,
@@ -2204,6 +2288,11 @@ async fn fetch_media_metadata(
}
drop(cache_guard);
// A fallback result was resolved without the configured browser session;
// keep that effective identity separate so a later successful cookie
// extraction can refresh authenticated formats instead of hitting stale
// public-only metadata.
let mut result_cache_key = cache_key;
let result = fetch_media_metadata_uncached(
app_handle.clone(),
url.clone(),
@@ -2219,11 +2308,34 @@ async fn fetch_media_metadata(
let result = match (result, cookie_browser.as_deref()) {
(Err(error), Some(browser))
if !browser.trim().is_empty() && is_browser_cookie_extraction_error(&error) =>
if should_retry_without_browser_cookies(Some(browser), &error, false) =>
{
Err(format!(
"Browser cookie extraction failed for {browser}: {error}"
))
log::warn!(
"yt-dlp could not read browser cookies from {}; retrying media metadata without browser cookies",
browser
);
result_cache_key = media_metadata_cache_key(
&url,
&None,
&user_agent,
&username,
&password,
&headers,
&cookies,
&proxy,
);
fetch_media_metadata_uncached(
app_handle,
url,
None,
user_agent,
username,
password,
headers,
cookies,
proxy,
)
.await
}
(result, _) => result,
};
@@ -2235,7 +2347,7 @@ async fn fetch_media_metadata(
Ok(metadata) => {
let mut cache_guard = cache.lock().await;
if cache_guard.len() >= MEDIA_METADATA_CACHE_MAX_ENTRIES
&& !cache_guard.contains_key(&cache_key)
&& !cache_guard.contains_key(&result_cache_key)
{
if let Some(oldest_key) = cache_guard
.iter()
@@ -2245,7 +2357,7 @@ async fn fetch_media_metadata(
cache_guard.remove(&oldest_key);
}
}
cache_guard.insert(cache_key, (Instant::now(), metadata.clone()));
cache_guard.insert(result_cache_key, (Instant::now(), metadata.clone()));
Ok(metadata)
}
Err(error) => Err(error),
@@ -2272,6 +2384,56 @@ async fn fetch_media_playlist_metadata(
) -> Result<MediaPlaylistMetadata, String> {
validate_url_ssrf(&url).await?;
let result = fetch_media_playlist_metadata_uncached(
app_handle.clone(),
url.clone(),
cookie_browser.clone(),
user_agent.clone(),
username.clone(),
password.clone(),
headers.clone(),
cookies.clone(),
proxy.clone(),
)
.await;
match (result, cookie_browser.as_deref()) {
(Err(error), Some(browser))
if should_retry_without_browser_cookies(Some(browser), &error, false) =>
{
log::warn!(
"yt-dlp could not read browser cookies from {}; retrying playlist metadata without browser cookies",
browser
);
fetch_media_playlist_metadata_uncached(
app_handle,
url,
None,
user_agent,
username,
password,
headers,
cookies,
proxy,
)
.await
}
(result, _) => result,
}
}
#[allow(clippy::too_many_arguments)]
async fn fetch_media_playlist_metadata_uncached(
app_handle: tauri::AppHandle,
url: String,
cookie_browser: Option<String>,
user_agent: Option<String>,
username: Option<String>,
password: Option<String>,
headers: Option<String>,
cookies: Option<String>,
proxy: Option<String>,
) -> Result<MediaPlaylistMetadata, String> {
let deno_path = resolve_bundled_binary_path(&app_handle, "deno")
.map_err(|e| format!("failed to find bundled deno: {e}"))?;
let ffmpeg_path = resolve_bundled_binary_path(&app_handle, "ffmpeg")
@@ -2662,6 +2824,9 @@ fn approve_download_root(app_handle: tauri::AppHandle, path: String) -> Result<S
if !resolved.is_absolute() {
return Err("Download root must be an absolute path".to_string());
}
if crate::path_has_symlink_component(&resolved) {
return Err("Download root may not contain symlink components".to_string());
}
let canonical = std::fs::canonicalize(&resolved)
.map_err(|error| format!("Failed to resolve download root: {error}"))?;
if !canonical.is_dir() {
@@ -2764,6 +2929,12 @@ pub enum TaskHandle {
pub struct AppState {
pub download_coordinator: download::DownloadCoordinator,
pub storage_layout: crate::storage::StorageLayout,
/// Credential-store access is denied until the frontend has completed the
/// startup consent decision for this process. This is intentionally
/// process-local: a new binary must pass through the consent barrier
/// again before any stale or early IPC caller can reach the OS store.
pub keychain_access_authorized: Arc<AtomicBool>,
pub keychain_grant_in_progress: Arc<AtomicBool>,
pub extension_pairing_token: extension_server::SharedExtensionToken,
pub extension_frontend_ready: extension_server::SharedFrontendReady,
pub extension_acks: extension_server::SharedExtensionAcks,
@@ -2777,6 +2948,38 @@ pub struct AppState {
pub queue_manager: Arc<queue::QueueManager>,
}
const KEYCHAIN_CONSENT_REQUIRED_ERROR: &str =
"Credential-store access requires explicit user consent";
const KEYCHAIN_GRANT_IN_PROGRESS_ERROR: &str =
"Credential-store access is already being requested";
fn require_keychain_access(state: &AppState) -> Result<(), String> {
if state
.keychain_access_authorized
.load(Ordering::Acquire)
{
Ok(())
} else {
Err(KEYCHAIN_CONSENT_REQUIRED_ERROR.to_string())
}
}
struct KeychainGrantGuard(Arc<AtomicBool>);
impl Drop for KeychainGrantGuard {
fn drop(&mut self) {
self.0.store(false, Ordering::Release);
}
}
fn begin_keychain_grant(state: &AppState) -> Result<KeychainGrantGuard, String> {
state
.keychain_grant_in_progress
.compare_exchange(false, true, Ordering::Acquire, Ordering::Relaxed)
.map(|_| KeychainGrantGuard(Arc::clone(&state.keychain_grant_in_progress)))
.map_err(|_| KEYCHAIN_GRANT_IN_PROGRESS_ERROR.to_string())
}
#[derive(Clone, Serialize, TS)]
#[ts(export, export_to = "../../src/bindings/")]
pub struct DownloadProgressEvent {
@@ -2792,6 +2995,10 @@ pub struct DownloadProgressEvent {
total_bytes: Option<f64>,
#[ts(optional)]
total_is_estimate: Option<bool>,
#[ts(optional)]
active_connections: Option<i32>,
#[ts(optional)]
requested_connections: Option<i32>,
}
#[derive(Debug, Clone, Serialize, TS)]
@@ -3619,6 +3826,7 @@ pub(crate) async fn start_media_download_internal(
destination: String,
filename: String,
format_selector: Option<String>,
connections: Option<i32>,
cookie_source: Option<String>,
speed_limit: Option<String>,
username: Option<String>,
@@ -3701,11 +3909,18 @@ pub(crate) async fn start_media_download_internal(
// user-installed tools, or platform-specific executable aliases.
let trusted_path = crate::platform::trusted_system_path()?;
let max_retries = max_tries.unwrap_or(0).max(0) as usize;
let max_retries = max_tries
.unwrap_or(crate::retry::MAX_RETRIES as i32)
.max(0) as usize;
let concurrent_fragments = normalize_media_connections(connections);
let mut strike = 0_usize;
let effective_cookie_source = cookie_source.clone();
let mut effective_cookie_source = cookie_source.clone();
let mut browser_cookie_fallback_used = false;
while strike <= max_retries {
if *cancel_rx.borrow() {
return Err(crate::queue::MEDIA_RUN_CANCELLED.to_string());
}
let mut processing_started = false;
let ytdlp_path = resolve_bundled_binary_path(&app_handle, "yt-dlp")?;
let mut cmd = app_handle.shell().command(&ytdlp_path);
@@ -3730,7 +3945,7 @@ pub(crate) async fn start_media_download_internal(
.arg("--js-runtimes")
.arg(format!("deno:{}", deno_path.to_string_lossy()))
.arg("--concurrent-fragments")
.arg("4")
.arg(concurrent_fragments.to_string())
.arg("--no-warnings")
// Playlist expansion is owned by Firelink. Every persisted media
// row must remain a single-video lifecycle, even when yt-dlp's
@@ -3807,6 +4022,10 @@ pub(crate) async fn start_media_download_internal(
cmd = cmd.arg("--").arg(&url);
if *cancel_rx.borrow() {
return Err(crate::queue::MEDIA_RUN_CANCELLED.to_string());
}
let (mut rx, child) = cmd
.spawn()
.map_err(|e| format!("Failed to spawn yt-dlp: {}", e))?;
@@ -3894,6 +4113,8 @@ pub(crate) async fn start_media_download_internal(
downloaded_bytes: None,
total_bytes: None,
total_is_estimate: Some(false),
active_connections: None,
requested_connections: None,
});
}
let lower = line.to_lowercase();
@@ -3965,6 +4186,8 @@ pub(crate) async fn start_media_download_internal(
downloaded_bytes: Some(metadata.len() as f64),
total_bytes: Some(metadata.len() as f64),
total_is_estimate: Some(false),
active_connections: None,
requested_connections: None,
});
}
}
@@ -3995,15 +4218,20 @@ pub(crate) async fn start_media_download_internal(
if should_cleanup_media_artifacts_after_failure(&failure_reason, strike, max_retries) {
cleanup_media_artifacts(&out_path, false).await;
}
if effective_cookie_source
.as_deref()
.is_some_and(|source| !source.trim().is_empty() && source != "none")
&& is_browser_cookie_extraction_error(&failure_reason)
if should_retry_without_browser_cookies(
effective_cookie_source.as_deref(),
&failure_reason,
browser_cookie_fallback_used,
)
{
let source = effective_cookie_source.clone().unwrap_or_default();
return Err(format!(
"Browser cookie extraction failed for {source}: {failure_reason}"
));
log::warn!(
"yt-dlp could not read browser cookies from {}; retrying media download without browser cookies",
source
);
effective_cookie_source = None;
browser_cookie_fallback_used = true;
continue;
}
if !(transient && strikes_left) {
return Err(failure_reason);
@@ -4949,16 +5177,50 @@ async fn wait_for_aria2_stopped(port: u16, secret: &str, gid: &str) -> Result<()
))
}
static NEXT_DOCK_BADGE_SESSION: std::sync::atomic::AtomicU64 =
std::sync::atomic::AtomicU64::new(1);
fn should_apply_dock_badge_update(
current_session: u64,
current_generation: u64,
session: u64,
generation: u64,
) -> bool {
session > current_session || (session == current_session && generation >= current_generation)
}
#[tauri::command]
fn begin_dock_badge_session() -> u64 {
NEXT_DOCK_BADGE_SESSION.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
}
#[tauri::command]
#[allow(unused_variables)]
fn update_dock_badge(app_handle: tauri::AppHandle, count: i32) {
fn update_dock_badge(
app_handle: tauri::AppHandle,
count: i32,
generation: u64,
session: u64,
) {
#[cfg(target_os = "macos")]
{
use objc::runtime::Object;
use objc::{class, msg_send, sel, sel_impl};
use std::ffi::CString;
use std::sync::{Mutex, OnceLock};
static LAST_DOCK_BADGE_STATE: OnceLock<Mutex<(u64, u64)>> = OnceLock::new();
let _ = app_handle.run_on_main_thread(move || {
let state = LAST_DOCK_BADGE_STATE.get_or_init(|| Mutex::new((0, 0)));
let Ok(mut state) = state.lock() else {
return;
};
if !should_apply_dock_badge_update(state.0, state.1, session, generation) {
return;
}
*state = (session, generation);
drop(state);
unsafe {
let app_class = class!(NSApplication);
let app: *mut Object = msg_send![app_class, sharedApplication];
@@ -5568,6 +5830,7 @@ fn set_keychain_password(
crate::db::save_pairing_token_to_settings(&connection, &password, true)?;
return Ok(());
}
require_keychain_access(&state)?;
crate::db::set_keychain_password(&id, &password)
}
@@ -5581,6 +5844,7 @@ fn get_keychain_password(
{
return Err("portable pairing token is stored in portable settings".to_string());
}
require_keychain_access(&state)?;
crate::db::get_keychain_password(&id)
}
@@ -5594,17 +5858,20 @@ fn delete_keychain_password(
{
return Ok(());
}
require_keychain_access(&state)?;
crate::db::delete_keychain_password(&id)
}
#[tauri::command]
fn save_site_login(
database: tauri::State<'_, crate::db::DbState>,
state: tauri::State<'_, AppState>,
id: String,
url_pattern: String,
username: String,
password: String,
) -> Result<(), String> {
require_keychain_access(&state)?;
let connection = database.lock()?;
crate::db::save_site_login(&connection, &id, &url_pattern, &username, &password)
}
@@ -5612,12 +5879,28 @@ fn save_site_login(
#[tauri::command]
fn delete_site_login(
database: tauri::State<'_, crate::db::DbState>,
state: tauri::State<'_, AppState>,
id: String,
) -> Result<(), String> {
require_keychain_access(&state)?;
let connection = database.lock()?;
crate::db::delete_site_login(&connection, &id)
}
/// Arm credential-store access after the frontend has completed its startup
/// decision. The process-local gate prevents any stale or early IPC caller
/// from producing a native OS prompt before the explanation is visible.
#[tauri::command]
fn authorize_keychain_access(state: tauri::State<'_, AppState>) -> Result<(), String> {
if state.storage_layout.is_portable() {
return Ok(());
}
state
.keychain_access_authorized
.store(true, Ordering::Release);
Ok(())
}
#[derive(Serialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export, export_to = "../../src/bindings/")]
@@ -5653,6 +5936,7 @@ fn hydrate_extension_pairing_token(
});
}
require_keychain_access(&app_state)?;
let migration_error = crate::db::migrate_legacy_pairing_token(&connection).err();
let keychain_token = crate::db::get_keychain_password(crate::db::PAIRING_TOKEN_KEYCHAIN_ID)
.ok()
@@ -5713,6 +5997,7 @@ fn regenerate_pairing_token(
if app_state.storage_layout.is_portable() {
crate::db::save_pairing_token_to_settings(&connection, &generated, true)?;
} else {
require_keychain_access(&app_state)?;
if let Err(error) = crate::db::migrate_legacy_pairing_token(&connection) {
let token = app_state
.extension_pairing_token
@@ -5760,6 +6045,7 @@ fn grant_keychain_access(
database: tauri::State<'_, crate::db::DbState>,
app_state: tauri::State<'_, AppState>,
) -> Result<PairingTokenHydration, String> {
let _grant_guard = begin_keychain_grant(&app_state)?;
let connection = database.lock()?;
if app_state.storage_layout.is_portable() {
@@ -5774,6 +6060,9 @@ fn grant_keychain_access(
if let Ok(mut pairing_token) = app_state.extension_pairing_token.write() {
*pairing_token = token.clone();
}
app_state
.keychain_access_authorized
.store(true, Ordering::Release);
return Ok(PairingTokenHydration {
token,
token_changed: false,
@@ -5825,6 +6114,9 @@ fn grant_keychain_access(
*pairing_token = token.clone();
}
}
app_state
.keychain_access_authorized
.store(true, Ordering::Release);
Ok(PairingTokenHydration {
token,
token_changed: false,
@@ -6313,19 +6605,55 @@ mod tests {
normalize_speed_limit_for_aria2,
parse_firelink_deep_link, parse_ffmpeg_version, parse_media_progress_line,
redact_log_line, redact_log_line_for_output, sanitize_ytdlp_config_value,
has_resumable_download_assets, should_cleanup_media_artifacts_after_failure,
has_resumable_download_assets, is_media_artifact_name,
should_cleanup_media_artifacts_after_failure,
should_apply_dock_badge_update,
should_retry_without_browser_cookies,
retry_metadata_with_cookies, should_retry_metadata_with_cookies,
should_send_metadata_credentials, collect_log_files, FirelinkDeepLink,
percent_decode_metadata_value, MediaProgress,
MediaProgressEmitterState, MediaSpeedSampler, MEDIA_PROGRESS_PREFIX,
observe_aria2_connections, observe_aria2_connections_with_epoch,
Aria2ConnectionObservation, Aria2ConnectionSample, Aria2RecoveryReason,
aria2_active_connection_count,
parse_media_playlist_metadata,
normalize_media_connections,
validate_enqueue_url, validate_enqueue_uris,
};
use serde_json::json;
use std::time::{Duration, Instant};
#[test]
fn aria2_active_connection_count_uses_only_nonnegative_daemon_values() {
assert_eq!(
aria2_active_connection_count(&json!({"connections": "16"})),
16
);
assert_eq!(
aria2_active_connection_count(&json!({"connections": 16})),
16
);
assert_eq!(
aria2_active_connection_count(&json!({"connections": "-1"})),
0
);
assert_eq!(
aria2_active_connection_count(&json!({"connections": -1})),
0
);
assert_eq!(aria2_active_connection_count(&json!({})), 0);
}
#[test]
fn normalize_media_connections_uses_safe_user_selected_range() {
assert_eq!(normalize_media_connections(Some(16)), 16);
assert_eq!(normalize_media_connections(Some(7)), 7);
assert_eq!(normalize_media_connections(None), 16);
assert_eq!(normalize_media_connections(Some(0)), 1);
assert_eq!(normalize_media_connections(Some(-4)), 1);
assert_eq!(normalize_media_connections(Some(99)), 16);
}
#[tokio::test]
async fn enqueue_url_validation_blocks_local_http_but_preserves_ftp() {
assert_eq!(
@@ -7061,10 +7389,21 @@ mod tests {
&Some("session=one".to_string()),
&None,
);
let without_browser = media_metadata_cache_key(
"https://example.com/watch?v=1",
&None,
&Some("Custom UA A".to_string()),
&None,
&None,
&Some("User-Agent: Browser A".to_string()),
&Some("session=one".to_string()),
&None,
);
assert_ne!(base, changed_headers);
assert_ne!(base, changed_cookies);
assert_ne!(base, changed_user_agent);
assert_ne!(base, without_browser);
}
#[test]
@@ -7086,6 +7425,29 @@ mod tests {
));
}
#[test]
fn media_cleanup_requires_exact_artifact_boundaries() {
assert!(is_media_artifact_name("video.mp4.part", "video.mp4", "video"));
assert!(is_media_artifact_name("video.mp4.tmp", "video.mp4", "video"));
assert!(is_media_artifact_name("video.f137.mp4", "video.mp4", "video"));
assert!(is_media_artifact_name("video.f137.mp4.part", "video.mp4", "video"));
assert!(is_media_artifact_name("video.vtt.part", "video.mp4", "video"));
assert!(is_media_artifact_name("video.jpg.tmp", "video.mp4", "video"));
assert!(!is_media_artifact_name("video.part1.rar", "video.mp4", "video"));
assert!(!is_media_artifact_name("video.mp4.part1.rar", "video.mp4", "video"));
assert!(!is_media_artifact_name("videography.mp4.part", "video.mp4", "video"));
assert!(!is_media_artifact_name("video.f1-backup.tar.gz", "video.mp4", "video"));
assert!(!is_media_artifact_name("video.f1.backup", "video.mp4", "video"));
}
#[test]
fn dock_badge_updates_reject_stale_sessions_and_generations() {
assert!(should_apply_dock_badge_update(1, 99, 2, 1));
assert!(should_apply_dock_badge_update(2, 1, 2, 2));
assert!(!should_apply_dock_badge_update(2, 2, 2, 1));
assert!(!should_apply_dock_badge_update(2, 2, 1, 99));
}
#[test]
fn metadata_filename_prefers_content_disposition_filename() {
assert_eq!(
@@ -7481,6 +7843,38 @@ mod tests {
));
}
#[test]
fn retries_once_without_browser_cookies_only_for_cookie_database_failures() {
let cookie_database_error =
"ERROR: Could not copy Chrome cookie database. See https://github.com/yt-dlp/yt-dlp/issues/7271";
assert!(should_retry_without_browser_cookies(
Some("chrome"),
cookie_database_error,
false
));
assert!(!should_retry_without_browser_cookies(
Some("chrome"),
cookie_database_error,
true
));
assert!(!should_retry_without_browser_cookies(
Some("none"),
cookie_database_error,
false
));
assert!(!should_retry_without_browser_cookies(
None,
cookie_database_error,
false
));
assert!(!should_retry_without_browser_cookies(
Some("chrome"),
"ERROR: Sign in to confirm you are not a bot",
false
));
}
#[test]
#[ignore = "requires network and a local yt-dlp executable"]
fn filters_live_youtube_metadata_from_env() {
@@ -7968,6 +8362,19 @@ struct Aria2ConnectionSample<'a> {
now: Instant,
}
fn aria2_active_connection_count(status_info: &serde_json::Value) -> i32 {
status_info
.get("connections")
.and_then(|value| {
value
.as_str()
.and_then(|value| value.parse::<i32>().ok())
.or_else(|| value.as_i64().and_then(|value| i32::try_from(value).ok()))
})
.filter(|value| *value >= 0)
.unwrap_or(0)
}
const ARIA2_CONNECTION_RECOVERY_DELAY: Duration = Duration::from_secs(30);
const ARIA2_CONNECTION_RECOVERY_COOLDOWN: Duration = Duration::from_secs(45);
const ARIA2_MIN_REMAINING_FOR_CONNECTION_RECOVERY: u64 = 1024 * 1024;
@@ -8290,6 +8697,8 @@ pub fn run() {
app.manage(AppState {
download_coordinator: download::DownloadCoordinator::spawn(app.handle().clone()),
storage_layout,
keychain_access_authorized: Arc::new(AtomicBool::new(false)),
keychain_grant_in_progress: Arc::new(AtomicBool::new(false)),
extension_pairing_token,
extension_frontend_ready,
extension_acks,
@@ -8582,28 +8991,39 @@ pub fn run() {
let mut seen_gids = HashSet::new();
for status_info in active_arr {
let gid = status_info.get("gid").and_then(|s| s.as_str()).unwrap_or("");
let id = poll_mgr
.aria2_gids
.read()
.unwrap()
.get(gid)
.map(|mapping| mapping.id.clone());
if let Some(id) = id {
seen_ids.insert(id.clone());
seen_gids.insert(gid.to_string());
let Some(mapping) = poll_mgr.aria2_gid_mapping(gid) else {
continue;
};
let id = mapping.id.clone();
{
let status = status_info.get("status").and_then(|value| value.as_str()).unwrap_or("");
let total = status_info.get("totalLength").and_then(|s| s.as_str()).unwrap_or("0").parse::<u64>().unwrap_or(0);
let completed = status_info.get("completedLength").and_then(|s| s.as_str()).unwrap_or("0").parse::<u64>().unwrap_or(0);
let speed_bytes = status_info.get("downloadSpeed").and_then(|s| s.as_str()).unwrap_or("0").parse::<f64>().unwrap_or(0.0);
let active_connections = status_info.get("connections").and_then(|s| s.as_str()).unwrap_or("0").parse::<i32>().unwrap_or(0);
let active_connections =
aria2_active_connection_count(status_info);
let requested_connections = poll_mgr
.aria2_requested_connections(&id)
.await
.unwrap_or(1)
.max(1);
let speed_limited = poll_mgr.aria2_speed_limited(&id).await;
let control_epoch =
poll_mgr.current_aria2_control_epoch(&id).await;
let control_epoch = mapping.epoch;
// The status snapshot and the requested
// connection lookup both await. A pause,
// retry, or same-GID resume may replace the
// mapping while those awaits are in
// flight. Only emit telemetry if this
// snapshot still owns the same GID epoch.
if !poll_mgr.is_current_aria2_gid_mapping(gid, &mapping)
|| !poll_mgr
.is_aria2_control_epoch_current(&id, control_epoch)
.await
{
continue;
}
seen_ids.insert(id.clone());
seen_gids.insert(gid.to_string());
let now = Instant::now();
let observation = observations.entry(id.clone()).or_default();
let recovery_reason = observe_aria2_connections_with_epoch(
@@ -8646,6 +9066,8 @@ pub fn run() {
downloaded_bytes: Some(completed as f64),
total_bytes: (total > 0).then_some(total as f64),
total_is_estimate: Some(false),
active_connections: Some(active_connections),
requested_connections: Some(requested_connections),
});
if let Some(reason) = recovery_reason {
@@ -8813,12 +9235,13 @@ pub fn run() {
get_engine_status, get_aria2_engine_status, get_ytdlp_engine_status, get_ffmpeg_engine_status,
get_deno_engine_status, test_ytdlp, test_aria2c, test_ffmpeg, test_deno,
pause_download, resume_download, fetch_metadata, fetch_media_metadata, fetch_media_playlist_metadata,
update_dock_badge, get_platform_info, approve_download_root, set_prevent_sleep, get_free_space, perform_system_action,
begin_dock_badge_session, update_dock_badge, get_platform_info, approve_download_root, set_prevent_sleep, get_free_space, perform_system_action,
ack_schedule_trigger,
check_automation_permission, request_automation_permission, open_automation_settings,
set_keychain_password, get_keychain_password, delete_keychain_password,
save_site_login, delete_site_login,
hydrate_extension_pairing_token, get_session_pairing_token, regenerate_pairing_token, grant_keychain_access,
authorize_keychain_access,
acknowledge_pairing_token_change,
check_file_exists, toggle_tray_icon, set_extension_pairing_token,
get_extension_server_port, set_extension_frontend_ready, ack_extension_download, set_concurrent_limit, set_global_speed_limit, remove_download,
+110 -11
View File
@@ -1,5 +1,5 @@
use crate::ipc::{DownloadStateEvent, DownloadStatus, QueueDirection};
use crate::retry::{backoff_and_emit, is_transient_network_error, BackoffOutcome};
use crate::retry::{backoff_and_emit, is_transient_network_error, BackoffOutcome, MAX_RETRIES};
use log;
use serde::Deserialize;
use serde_json;
@@ -15,10 +15,16 @@ use ts_rs::TS;
/// Default capacity when no setting is read yet.
pub const DEFAULT_MAX_CONCURRENT: usize = 3;
pub const MEDIA_RUN_CANCELLED: &str = "__firelink_media_run_cancelled__";
pub const DOWNLOAD_CONNECTIONS_MIN: i32 = 1;
pub const DOWNLOAD_CONNECTIONS_MAX: i32 = 16;
pub fn clamp_download_connections(connections: i32) -> i32 {
connections.clamp(DOWNLOAD_CONNECTIONS_MIN, DOWNLOAD_CONNECTIONS_MAX)
}
type Aria2ControlLocks = Arc<StdMutex<HashMap<String, Arc<Mutex<()>>>>>;
#[derive(Debug, Clone)]
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Aria2GidMapping {
pub id: String,
pub epoch: u64,
@@ -481,6 +487,7 @@ impl<R: tauri::Runtime> QueueManager<R> {
.await
.get(id)
.and_then(|payload| payload.connections)
.map(clamp_download_connections)
}
pub fn set_aria2_global_speed_limit(&self, limit: Option<String>) {
@@ -1111,6 +1118,22 @@ impl<R: tauri::Runtime> QueueManager<R> {
.find_map(|(gid, mapping)| (mapping.id == id).then(|| gid.clone()))
}
/// Capture the GID ownership token used by the poller. The mapping's
/// epoch must still be current when an asynchronous status snapshot is
/// emitted; otherwise a late response from an older GID can be attributed
/// to a newer lifecycle for the same download id.
pub fn aria2_gid_mapping(&self, gid: &str) -> Option<Aria2GidMapping> {
self.aria2_gids.read().unwrap().get(gid).cloned()
}
pub fn is_current_aria2_gid_mapping(
&self,
gid: &str,
expected: &Aria2GidMapping,
) -> bool {
self.aria2_gid_mapping(gid).as_ref() == Some(expected)
}
pub fn aria2_gid_mappings(&self) -> Vec<(String, String)> {
self.aria2_gids
.read()
@@ -1600,7 +1623,7 @@ impl<R: tauri::Runtime> QueueManager<R> {
}
fn automatic_retry_limit(max_tries: Option<i32>) -> usize {
max_tries.unwrap_or(0).max(0) as usize
max_tries.unwrap_or(MAX_RETRIES as i32).max(0) as usize
}
fn aria2_attempt_limit(max_tries: Option<i32>) -> u32 {
@@ -1654,7 +1677,11 @@ enum BoundedRangeSupport {
}
async fn effective_aria2_connections(id: &str, payload: &SpawnPayload) -> i32 {
let requested = payload.connections.unwrap_or(1).max(1);
let requested = clamp_download_connections(
payload
.connections
.unwrap_or(DOWNLOAD_CONNECTIONS_MIN),
);
if requested <= 1 {
return requested;
}
@@ -1854,6 +1881,30 @@ pub struct ProductionSpawner {
app_handle: AppHandle<tauri::Wry>,
}
const ARIA2_MIN_SPLIT_SIZE: &str = "1M";
fn apply_aria2_connection_options(
options: &mut serde_json::Map<String, serde_json::Value>,
connections: i32,
) {
let connections = clamp_download_connections(connections);
options.insert(
"split".to_string(),
serde_json::json!(connections.to_string()),
);
options.insert(
"max-connection-per-server".to_string(),
serde_json::json!(connections.to_string()),
);
// aria2's 20M default suppresses segmentation for files smaller than
// 40M. Keep the requested connection count useful for ordinary release
// assets while retaining a 1M lower bound to avoid tiny range requests.
options.insert(
"min-split-size".to_string(),
serde_json::json!(ARIA2_MIN_SPLIT_SIZE),
);
}
impl ProductionSpawner {
pub fn new(app_handle: AppHandle<tauri::Wry>) -> Self {
Self { app_handle }
@@ -1909,11 +1960,7 @@ impl SidecarSpawner for ProductionSpawner {
crate::download_ownership::canonical_download_filename(&payload.filename);
options.insert("out".to_string(), serde_json::json!(safe_filename));
let conn = effective_aria2_connections(id, payload).await;
options.insert("split".to_string(), serde_json::json!(conn.to_string()));
options.insert(
"max-connection-per-server".to_string(),
serde_json::json!(conn.to_string()),
);
apply_aria2_connection_options(&mut options, conn);
let mt = aria2_attempt_limit(payload.max_tries);
options.insert("max-tries".to_string(), serde_json::json!(mt.to_string()));
options.insert("retry-wait".to_string(), serde_json::json!("2"));
@@ -1977,8 +2024,9 @@ impl SidecarSpawner for ProductionSpawner {
}
}
Err(e) => {
log::error!("aria2 addUri [{}] failed: {}", id, e);
Err(format!("aria2 addUri failed: {e}"))
let safe_error = crate::redact_sensitive_text(&e);
log::error!("aria2 addUri [{}] failed: {}", id, safe_error);
Err(format!("aria2 addUri failed: {safe_error}"))
}
}
}
@@ -2104,6 +2152,7 @@ impl SidecarSpawner for ProductionSpawner {
payload.destination.clone(),
payload.filename.clone(),
payload.format_selector.clone(),
payload.connections,
payload.cookie_source.clone(),
payload.speed_limit.clone(),
payload.username.clone(),
@@ -2207,6 +2256,49 @@ impl EnqueueItem {
mod tests {
use super::*;
#[test]
fn aria2_connection_options_enable_requested_ranges_for_small_release_assets() {
let mut options = serde_json::Map::new();
apply_aria2_connection_options(&mut options, 16);
assert_eq!(options.get("split"), Some(&serde_json::json!("16")));
assert_eq!(
options.get("max-connection-per-server"),
Some(&serde_json::json!("16"))
);
assert_eq!(
options.get("min-split-size"),
Some(&serde_json::json!("1M"))
);
}
#[test]
fn aria2_connection_options_never_emit_zero_connections() {
let mut options = serde_json::Map::new();
apply_aria2_connection_options(&mut options, 0);
assert_eq!(options.get("split"), Some(&serde_json::json!("1")));
assert_eq!(
options.get("max-connection-per-server"),
Some(&serde_json::json!("1"))
);
}
#[test]
fn aria2_connection_options_clamp_untrusted_connection_counts() {
let mut options = serde_json::Map::new();
apply_aria2_connection_options(&mut options, i32::MAX);
assert_eq!(options.get("split"), Some(&serde_json::json!("16")));
assert_eq!(
options.get("max-connection-per-server"),
Some(&serde_json::json!("16"))
);
}
#[test]
fn bounded_range_probe_accepts_exact_requested_byte() {
assert_eq!(
@@ -2288,4 +2380,11 @@ mod tests {
assert_eq!(aria2_attempt_limit(Some(0)), 1);
assert_eq!(aria2_attempt_limit(Some(10)), 1);
}
#[test]
fn omitted_retry_limit_uses_the_shared_default_but_zero_stays_explicit() {
assert_eq!(automatic_retry_limit(None), MAX_RETRIES);
assert_eq!(automatic_retry_limit(Some(0)), 0);
assert_eq!(automatic_retry_limit(Some(2)), 2);
}
}
+95 -12
View File
@@ -26,6 +26,26 @@ fn stop_is_due(
&& last_stop_key != stop_key
}
fn overnight_stop_is_due(
stop_time_enabled: bool,
start_minute: Option<u32>,
stop_minute: Option<u32>,
current_minute: u32,
previous_day_allowed: bool,
last_start_key: &str,
previous_start_key: &str,
last_stop_key: &str,
stop_key: &str,
) -> bool {
stop_time_enabled
&& previous_day_allowed
&& start_minute.zip(stop_minute).is_some_and(|(start, stop)| {
stop < start && current_minute >= stop && current_minute < start
})
&& last_start_key == previous_start_key
&& last_stop_key != stop_key
}
pub fn spawn_scheduler(
app_handle: tauri::AppHandle,
settings_cache: Arc<RwLock<Option<crate::ipc::PersistedSettings>>>,
@@ -56,20 +76,20 @@ pub fn spawn_scheduler(
let allowed_today =
scheduler.everyday || scheduler.selected_days.contains(&current_day);
if !allowed_today {
continue;
}
let date_key = now.format("%Y-%m-%d").to_string();
let start_key = format!("{date_key}-start");
let stop_key = format!("{date_key}-stop");
let start_minute = minute_of_day(&scheduler.start_time);
let stop_minute = minute_of_day(&scheduler.stop_time);
let overnight = start_minute
.zip(stop_minute)
.is_some_and(|(start, stop)| stop < start);
let before_stop = !scheduler.stop_time_enabled
|| stop_minute.is_some_and(|stop| current_minute < stop);
if start_minute.is_some_and(|start| current_minute >= start)
&& before_stop
if allowed_today
&& start_minute.is_some_and(|start| current_minute >= start)
&& (overnight || before_stop)
&& scheduler_last_start_key != start_key
&& last_emit
.get("start")
@@ -85,17 +105,43 @@ pub fn spawn_scheduler(
last_emit.insert("start", std::time::Instant::now());
}
if stop_is_due(
let same_day_stop_due = allowed_today
&& !overnight
&& stop_is_due(
scheduler.stop_time_enabled,
stop_minute,
current_minute,
&scheduler_last_start_key,
&start_key,
&scheduler_last_stop_key,
&stop_key,
);
let previous_day = now.date_naive().pred_opt();
let previous_day_allowed = previous_day.is_some_and(|day| {
scheduler.everyday
|| scheduler
.selected_days
.contains(&day.weekday().num_days_from_sunday())
});
let previous_start_key = previous_day
.map(|day| format!("{}-start", day.format("%Y-%m-%d")))
.unwrap_or_default();
let overnight_stop_due = overnight_stop_is_due(
scheduler.stop_time_enabled,
start_minute,
stop_minute,
current_minute,
previous_day_allowed,
&scheduler_last_start_key,
&start_key,
&previous_start_key,
&scheduler_last_stop_key,
&stop_key,
) && last_emit
.get("stop")
.is_none_or(|instant| instant.elapsed() >= Duration::from_secs(5))
);
if (same_day_stop_due || overnight_stop_due)
&& last_emit
.get("stop")
.is_none_or(|instant| instant.elapsed() >= Duration::from_secs(5))
{
let _ = app_handle.emit(
"schedule-trigger",
@@ -113,7 +159,7 @@ pub fn spawn_scheduler(
#[cfg(test)]
mod tests {
use super::{minute_of_day, stop_is_due};
use super::{minute_of_day, overnight_stop_is_due, stop_is_due};
#[test]
fn parses_valid_scheduler_times() {
@@ -150,4 +196,41 @@ mod tests {
"2026-06-22-stop",
));
}
#[test]
fn overnight_stop_uses_the_previous_day_start() {
assert!(overnight_stop_is_due(
true,
Some(1320),
Some(360),
420,
true,
"2026-06-22-start",
"2026-06-22-start",
"",
"2026-06-23-stop",
));
assert!(!overnight_stop_is_due(
true,
Some(1320),
Some(360),
1380,
true,
"2026-06-22-start",
"2026-06-22-start",
"",
"2026-06-22-stop",
));
assert!(!overnight_stop_is_due(
true,
Some(1320),
Some(360),
420,
false,
"2026-06-22-start",
"2026-06-22-start",
"",
"2026-06-23-stop",
));
}
}
+42
View File
@@ -192,6 +192,12 @@ fn sanitize_persisted_setting_values(state: &mut Value) {
"theme",
&["system", "light", "dark", "dracula", "nord"],
);
sanitize_allowed_string(
state,
"language",
&["system", "en", "zh-CN", "he", "fa", "uk", "ru"],
);
sanitize_allowed_string(state, "sidebarPosition", &["auto", "left", "right"]);
sanitize_allowed_string(state, "appFontSize", &["small", "standard", "large"]);
sanitize_allowed_string(state, "listRowDensity", &["compact", "standard", "relaxed"]);
sanitize_allowed_string(state, "activeSettingsTab", &[
@@ -410,6 +416,7 @@ fn derived_location_path(base: &str, subfolder: &str) -> String {
fn default_settings() -> PersistedSettings {
PersistedSettings {
theme: Theme::System,
language: "system".to_string(),
base_download_folder: "~/Downloads".to_string(),
category_subfolders_enabled: true,
category_subfolders: default_category_subfolders(),
@@ -420,6 +427,7 @@ fn default_settings() -> PersistedSettings {
speed_limit_preset_values: vec![1.0, 5.0, 10.0],
logs_enabled: false,
is_sidebar_visible: true,
sidebar_position: "auto".to_string(),
active_settings_tab: SettingsTab::Downloads,
scheduler: SchedulerSettings {
enabled: false,
@@ -451,6 +459,7 @@ fn default_settings() -> PersistedSettings {
proxy_port: 8080,
custom_user_agent: String::new(),
ask_where_to_save_each_file: false,
remember_last_used_download_directory: false,
prevents_sleep_while_downloading: true,
media_cookie_source: MediaCookieSource::default(),
site_logins: Vec::new(),
@@ -499,6 +508,7 @@ mod tests {
"state": {
"maxConcurrentDownloads": 7,
"globalSpeedLimit": "2M",
"sidebarPosition": "right",
"scheduler": {
"enabled": true,
"startTime": "06:30",
@@ -516,6 +526,7 @@ mod tests {
assert_eq!(settings.max_concurrent_downloads, 7);
assert_eq!(settings.global_speed_limit, "2M");
assert_eq!(settings.sidebar_position, "right");
assert_eq!(settings.speed_limit_preset_values, vec![1.0, 5.0, 10.0]);
assert!(!settings.logs_enabled);
assert!(settings.scheduler.enabled);
@@ -653,6 +664,18 @@ mod tests {
assert_eq!(settings.max_concurrent_downloads, 3);
}
#[test]
fn invalid_sidebar_position_uses_automatic_layout() {
let stored = json!({
"state": {"sidebarPosition": "diagonal"},
"version": 5
});
let settings = decode_stored_settings(&Value::String(stored.to_string())).unwrap();
assert_eq!(settings.sidebar_position, "auto");
}
#[test]
fn clamps_out_of_range_download_settings() {
let stored = json!({
@@ -702,6 +725,25 @@ mod tests {
assert!(!default_settings().auto_add_clipboard_links);
}
#[test]
fn does_not_remember_last_used_download_directory_by_default() {
assert!(!default_settings().remember_last_used_download_directory);
}
#[test]
fn decodes_disabled_last_used_download_directory_setting() {
let stored = json!({
"state": {
"rememberLastUsedDownloadDirectory": false
},
"version": 3
});
let settings = decode_stored_settings(&Value::String(stored.to_string())).unwrap();
assert!(!settings.remember_last_used_download_directory);
}
#[test]
fn ignores_legacy_extension_pairing_token_field() {
// Older standard installs persisted `extensionPairingToken` as
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Firelink",
"version": "1.1.1",
"version": "1.2.0",
"identifier": "com.nimbold.firelink",
"build": {
"beforeDevCommand": "node scripts/stage-engines.js && npm run dev",
+2 -10
View File
@@ -8,17 +8,9 @@
"height": 760,
"minWidth": 960,
"minHeight": 640,
"titleBarStyle": "Overlay",
"trafficLightPosition": {
"x": 17,
"y": 28
},
"hiddenTitle": true,
"transparent": true,
"windowEffects": {
"effects": ["sidebar"],
"state": "active"
}
"decorations": false,
"shadow": false
}
]
},
+2 -4
View File
@@ -9,10 +9,8 @@
"minWidth": 960,
"minHeight": 640,
"transparent": true,
"windowEffects": {
"effects": ["mica"],
"state": "active"
}
"decorations": false,
"shadow": false
}
]
},
+26
View File
@@ -357,6 +357,32 @@ async fn resumed_gid_rebinds_to_the_new_control_epoch() {
assert!(manager.aria2_gid_for_download("resumed-gid").is_none());
}
#[tokio::test]
async fn gid_mapping_snapshot_is_invalid_after_epoch_or_gid_replacement() {
let (mgr, _spawner) = make_manager(1);
mgr.remember_gid("snapshot".to_string(), "gid-old".to_string())
.await;
let old_mapping = mgr
.aria2_gid_mapping("gid-old")
.expect("the first gid should be mapped");
assert!(mgr.is_current_aria2_gid_mapping("gid-old", &old_mapping));
let resume_epoch = mgr.next_aria2_control_epoch("snapshot").await;
assert!(mgr
.rebind_aria2_gid_epoch("snapshot", "gid-old", resume_epoch)
.await);
assert!(!mgr.is_current_aria2_gid_mapping("gid-old", &old_mapping));
mgr.remember_gid("snapshot".to_string(), "gid-new".to_string())
.await;
assert!(mgr.aria2_gid_mapping("gid-old").is_none());
let new_mapping = mgr
.aria2_gid_mapping("gid-new")
.expect("the replacement gid should be mapped");
assert!(mgr.is_current_aria2_gid_mapping("gid-new", &new_mapping));
}
#[tokio::test]
async fn forgetting_aria2_gid_clears_mapping_without_releasing_twice() {
let (mgr, _spawner) = make_manager(1);
+188 -64
View File
@@ -1,12 +1,9 @@
import { initMediaDomains, isActiveDownloadStatus, isTransferActiveStatus } from './utils/downloads';
import { schedulerCompletionState } from './utils/schedulerCompletion';
import { useCallback, useEffect, useRef, useState } from "react";
import { lazy, Suspense, useCallback, useEffect, useRef, useState } from "react";
import { Sidebar, SidebarFilter } from "./components/Sidebar";
import { DownloadTable } from "./components/DownloadTable";
import { AddDownloadsModal } from "./components/AddDownloadsModal";
import SettingsView from "./components/SettingsView";
import { PropertiesModal } from "./components/PropertiesModal";
import { DeleteConfirmationModal } from "./components/DeleteConfirmationModal";
import { KeychainPermissionModal } from './components/KeychainPermissionModal';
import { extractValidDownloadUrls } from './utils/url';
import { readClipboardDownloadUrls } from './utils/clipboard';
import { listenEvent as listen, invokeCommand as invoke } from "./ipc";
@@ -14,15 +11,12 @@ import { useDownloadStore, MAIN_QUEUE_ID, type ExtensionDownloadRequest } from '
import { initDownloadListener } from './store/downloadStore';
import { subscribeToSettingsPersistenceErrors, useSettingsStore } from "./store/useSettingsStore";
import { isPermissionGranted, requestPermission, sendNotification } from '@tauri-apps/plugin-notification';
import SchedulerView from "./components/SchedulerView";
import SpeedLimiterView from "./components/SpeedLimiterView";
import LogsView from "./components/LogsView";
import { KeychainPermissionModal } from "./components/KeychainPermissionModal";
import { WindowControls } from "./components/WindowControls";
import { useToast } from "./contexts/ToastContext";
import { setLogStreamActive } from './utils/logger';
import { updateDockBadge } from './utils/dockBadge';
import { openUrl } from '@tauri-apps/plugin-opener';
import { getPlatformInfo, usePlatformInfo } from './utils/platform';
import { getPlatformInfo, shouldUseCustomWindowControls, usePlatformInfo } from './utils/platform';
import {
getKeychainAccessReady,
getKeychainConsentVersion,
@@ -32,6 +26,76 @@ import { getVersion } from '@tauri-apps/api/app';
import type { PostQueueAction } from './bindings/PostQueueAction';
import { PanelLeft } from 'lucide-react';
import { isTrustedFirelinkReleaseUrl } from './utils/releaseUrls';
import { changeAppLocale, localeDirection, resolveAppLocale, syncDocumentLocale } from './i18n';
import { useTranslation } from 'react-i18next';
const loadSettingsView = () => import('./components/SettingsView');
const loadSchedulerView = () => import('./components/SchedulerView');
const loadSpeedLimiterView = () => import('./components/SpeedLimiterView');
const loadLogsView = () => import('./components/LogsView');
const pageChunkLoaders = [
loadSettingsView,
loadSchedulerView,
loadSpeedLimiterView,
loadLogsView,
] as const;
const SettingsView = lazy(loadSettingsView);
const SchedulerView = lazy(loadSchedulerView);
const SpeedLimiterView = lazy(loadSpeedLimiterView);
const LogsView = lazy(loadLogsView);
const AddDownloadsModal = lazy(() => import('./components/AddDownloadsModal').then(module => ({
default: module.AddDownloadsModal,
})));
const PropertiesModal = lazy(() => import('./components/PropertiesModal').then(module => ({
default: module.PropertiesModal,
})));
const DeleteConfirmationModal = lazy(() => import('./components/DeleteConfirmationModal').then(module => ({
default: module.DeleteConfirmationModal,
})));
const preloadPageChunks = async () => {
for (const load of pageChunkLoaders) {
try {
await load();
} catch (error) {
console.warn('Failed to preload page chunk:', error);
}
}
};
const scheduleAfterFirstPaint = (task: () => void): (() => void) => {
const idleWindow = window as typeof window & {
requestIdleCallback?: (callback: () => void, options?: { timeout?: number }) => number;
cancelIdleCallback?: (handle: number) => void;
};
if (idleWindow.requestIdleCallback) {
const handle = idleWindow.requestIdleCallback(task, { timeout: 1000 });
return () => idleWindow.cancelIdleCallback?.(handle);
}
let timeoutHandle: number | null = null;
const frameHandle = window.requestAnimationFrame(() => {
timeoutHandle = window.setTimeout(task, 0);
});
return () => {
window.cancelAnimationFrame(frameHandle);
if (timeoutHandle !== null) window.clearTimeout(timeoutHandle);
};
};
const PageLoadingFallback = () => {
const { t } = useTranslation();
return (
<div className="flex flex-1 items-center justify-center bg-main-bg" role="status" aria-live="polite">
<span className="sr-only">{t($ => $.app.loading)}</span>
<div className="h-1.5 w-24 overflow-hidden rounded-full bg-item-hover" aria-hidden="true">
<div className="h-full w-1/3 animate-pulse rounded-full bg-accent" />
</div>
</div>
);
};
let automaticUpdateCheckStarted = false;
const processingScheduleKeys = new Set<string>();
@@ -99,29 +163,55 @@ const playCompletionChime = async () => {
};
function App() {
const { i18n, t } = useTranslation();
const platform = usePlatformInfo();
const [filter, setFilter] = useState<SidebarFilter>('all');
const [coreReady, setCoreReady] = useState(false);
const [keychainConsentVersion, setKeychainConsentVersion] = useState('');
useEffect(() => {
const handleLanguageChanged = (language?: string) => {
syncDocumentLocale(language ?? i18n.language);
};
handleLanguageChanged();
i18n.on('languageChanged', handleLanguageChanged);
return () => i18n.off('languageChanged', handleLanguageChanged);
}, [i18n]);
const [sidebarWidth, setSidebarWidth] = useState(() => {
const stored = Number(window.localStorage.getItem('firelink-sidebar-width'));
return Number.isFinite(stored) && stored >= 190 && stored <= 260 ? stored : 220;
});
const theme = useSettingsStore(state => state.theme);
const languagePreference = useSettingsStore(state => state.language);
const isSidebarVisible = useSettingsStore(state => state.isSidebarVisible);
const sidebarPosition = useSettingsStore(state => state.sidebarPosition);
const toggleSidebar = useSettingsStore(state => state.toggleSidebar);
const activeView = useSettingsStore(state => state.activeView);
const appFontSize = useSettingsStore(state => state.appFontSize);
const listRowDensity = useSettingsStore(state => state.listRowDensity);
useEffect(() => {
const locale = languagePreference === 'system'
? resolveAppLocale(typeof navigator === 'undefined' ? undefined : navigator.language)
: languagePreference;
if (i18n.language !== locale) {
void changeAppLocale(locale);
}
}, [i18n, languagePreference]);
const autoCheckUpdates = useSettingsStore(state => state.autoCheckUpdates);
const autoAddClipboardLinks = useSettingsStore(state => state.autoAddClipboardLinks);
const showNotifications = useSettingsStore(state => state.showNotifications);
const showDockBadge = useSettingsStore(state => state.showDockBadge);
const dockBadgeSyncVersion = useSettingsStore(state => state.dockBadgeSyncVersion);
const showMenuBarIcon = useSettingsStore(state => state.showMenuBarIcon);
const extensionPairingToken = useSettingsStore(state => state.extensionPairingToken);
const showKeychainModal = useSettingsStore(state => state.showKeychainModal);
const isAddModalOpen = useDownloadStore(state => state.isAddModalOpen);
const selectedPropertiesDownloadId = useDownloadStore(state => state.selectedPropertiesDownloadId);
const isDeleteModalOpen = useDownloadStore(state => state.deleteModalState.isOpen);
const downloads = useDownloadStore(state => state.downloads);
const activeDownloadCount = downloads.filter(download => isTransferActiveStatus(download.status)).length;
const queuedCount = downloads.filter(download =>
@@ -143,15 +233,19 @@ function App() {
const activeTransferCount = downloads.filter(download => isTransferActiveStatus(download.status)).length;
const { addToast, removeToast } = useToast();
const isMacUserAgent = navigator.userAgent.includes('Mac');
const usesCustomWindowControls = !isMacUserAgent && platform.os !== 'macos';
const usesCustomWindowControls = shouldUseCustomWindowControls(platform.os, navigator.userAgent);
const isRtl = localeDirection(resolveAppLocale(i18n.language)) === 'rtl';
const isSidebarOnRight = sidebarPosition === 'right' || (sidebarPosition === 'auto' && isRtl);
// Keep dialogs out of the titlebar area while platform detection is still
// resolving. The conservative fallback prevents a startup handoff from
// briefly rendering underneath native or custom window controls.
const hasWindowChrome = isMacUserAgent || ['macos', 'windows', 'linux', 'unknown'].includes(platform.os);
useEffect(() => scheduleAfterFirstPaint(preloadPageChunks), []);
useEffect(() => subscribeToSettingsPersistenceErrors(() => {
addToast({
message: 'Could not save settings. Check storage permissions and try again.',
message: t($ => $.app.settingsSaveFailed),
variant: 'error',
isActionable: true
});
@@ -181,7 +275,7 @@ function App() {
const schedulePostQueueAction = useCallback((action: Exclude<PostQueueAction, 'none'>) => {
clearPendingPostActionTimer();
const actionLabel = action === 'shutdown' ? 'Shut down' : action === 'restart' ? 'Restart' : 'Sleep';
const actionLabel = t($ => $.scheduler.postActions[action]);
let timerId: number | null = null;
let toastId: string | null = null;
const cancel = () => {
@@ -199,13 +293,13 @@ function App() {
onDismiss: clearPendingPostActionTimer,
message: (
<div className="flex items-center gap-3">
<span>{actionLabel} in 10 seconds.</span>
<span>{t($ => $.app.systemActionCountdown, { action: actionLabel })}</span>
<button
type="button"
className="app-button px-2 py-1"
onClick={cancel}
>
Cancel
{t($ => $.actions.cancel)}
</button>
</div>
)
@@ -226,7 +320,7 @@ function App() {
);
if (activeTransfers) {
addToast({
message: 'System action cancelled because another download is active or queued.',
message: t($ => $.app.systemActionCancelled),
variant: 'warning',
isActionable: true
});
@@ -235,7 +329,7 @@ function App() {
invoke('perform_system_action', { action }).catch(error => {
console.error('Scheduled post action failed:', error);
addToast({
message: `Scheduled system action failed: ${String(error)}`,
message: t($ => $.app.systemActionFailed, { detail: String(error) }),
variant: 'error',
isActionable: true
});
@@ -250,7 +344,10 @@ function App() {
const startWidth = sidebarWidth;
const handlePointerMove = (moveEvent: PointerEvent) => {
const nextWidth = Math.min(260, Math.max(190, startWidth + moveEvent.clientX - startX));
const delta = isSidebarOnRight
? startX - moveEvent.clientX
: moveEvent.clientX - startX;
const nextWidth = Math.min(260, Math.max(190, startWidth + delta));
setSidebarWidth(nextWidth);
};
@@ -313,12 +410,12 @@ function App() {
if (!settings.showNotifications) return;
const item = useDownloadStore.getState().downloads.find(d => d.id === event.payload.id);
const fileName = item?.fileName || 'A file';
const fileName = item?.fileName || t($ => $.app.unknownFile);
if (event.payload.status === 'completed') {
try {
sendNotification({
title: 'Download Complete',
body: `${fileName} has finished downloading.`
title: t($ => $.app.downloadCompleteTitle),
body: t($ => $.app.downloadCompleteBody, { fileName })
});
} catch (error) {
console.error('Completion notification failed:', error);
@@ -326,8 +423,8 @@ function App() {
} else {
try {
sendNotification({
title: 'Download Failed',
body: `${fileName} failed to download.`,
title: t($ => $.app.downloadFailedTitle),
body: t($ => $.app.downloadFailedBody, { fileName }),
});
} catch (error) {
console.error('Failure notification failed:', error);
@@ -371,7 +468,7 @@ function App() {
if (!active) return;
console.error('Failed to initialize Firelink state:', error);
addToast({
message: `Could not initialize saved downloads: ${String(error)}`,
message: t($ => $.app.initializeDownloadsFailed, { detail: String(error) }),
variant: 'error',
isActionable: true
});
@@ -400,15 +497,24 @@ function App() {
let changed = false;
if (deferKeychainHydration) {
settings.setKeychainAccessReady(false);
if (showKeychainPrompt) {
// Commit the explanation before the harmless session-token IPC so
// a slow startup cannot leave the user facing an unexplained
// credential-store request.
settings.setShowKeychainModal(true);
}
// This token is already owned by the backend and does not access
// the OS credential store. Render our explanation before any native
// Keychain/Credential Manager prompt can be user-triggered.
await settings.hydrateSessionPairingToken(isStartupActive);
if (!active) return;
if (showKeychainPrompt) {
settings.setShowKeychainModal(true);
}
} else {
// The backend keeps credential-store access disabled for every new
// process. Arm it only after the persisted startup decision has
// confirmed that this build was already approved; the hydrate call
// below is then the first operation allowed to touch the OS store.
await invoke('authorize_keychain_access');
if (!active) return;
changed = await settings.hydratePairingToken(isStartupActive);
if (!active) return;
const currentSettings = useSettingsStore.getState();
@@ -424,7 +530,7 @@ function App() {
isActionable: true,
message: (
<div className="flex flex-col gap-2">
<p>Browser extension disconnected because its pairing token changed.</p>
<p>{t($ => $.app.extensionDisconnected)}</p>
<div className="flex gap-2 justify-end">
<button
type="button"
@@ -438,14 +544,14 @@ function App() {
acknowledgePairingTokenChange();
} catch (error) {
addToast({
message: `Could not copy pairing token: ${String(error)}`,
message: t($ => $.app.copyTokenFailed, { detail: String(error) }),
variant: 'error',
isActionable: true
});
}
}}
>
Copy token
{t($ => $.app.copyToken)}
</button>
<button
type="button"
@@ -457,7 +563,7 @@ function App() {
acknowledgePairingTokenChange();
}}
>
Integrations
{t($ => $.app.integrations)}
</button>
</div>
</div>
@@ -467,7 +573,7 @@ function App() {
} catch (error) {
console.error('Failed to hydrate extension pairing token:', error);
addToast({
message: `Secure credential persistence is unavailable. Browser pairing works for this session only: ${String(error)}`,
message: t($ => $.app.credentialPersistenceFailed, { detail: String(error) }),
variant: 'error',
isActionable: true
});
@@ -521,7 +627,7 @@ function App() {
useDownloadStore.getState().resumePendingDownloads().catch(error => {
console.error('Failed to resume saved downloads after startup:', error);
addToast({
message: `Could not resume saved downloads: ${String(error)}`,
message: t($ => $.app.resumeDownloadsFailed, { detail: String(error) }),
variant: 'error',
isActionable: true
});
@@ -545,14 +651,14 @@ function App() {
.then(result => {
if (result.type !== 'UpdateAvailable') return;
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({
variant: 'info',
isActionable: true,
message: (
<div className="flex items-center gap-3">
<span>Firelink {result.update.version} is available.</span>
<span>{t($ => $.app.updateAvailable, { version: result.update.version })}</span>
<button
type="button"
className="app-button px-2 py-1"
@@ -560,7 +666,7 @@ function App() {
void openUrl(result.update.release_url);
}}
>
View release
{t($ => $.app.viewRelease)}
</button>
</div>
)
@@ -585,9 +691,9 @@ function App() {
useEffect(() => {
if (platform.os === 'macos') {
invoke('update_dock_badge', { count: showDockBadge ? activeDownloadCount : 0 }).catch(() => {});
updateDockBadge(showDockBadge ? activeDownloadCount : 0).catch(() => {});
}
}, [platform.os, showDockBadge, activeDownloadCount]);
}, [platform.os, showDockBadge, dockBadgeSyncVersion, activeDownloadCount]);
useEffect(() => {
invoke('set_prevent_sleep', {
@@ -595,7 +701,7 @@ function App() {
}).catch(error => {
console.error('Failed to update sleep prevention:', error);
addToast({
message: `Could not update sleep prevention: ${String(error)}`,
message: t($ => $.app.sleepPreventionFailed, { detail: String(error) }),
variant: 'error',
isActionable: true
});
@@ -634,7 +740,7 @@ function App() {
state.setSchedulerActiveDownloadIds([]);
state.setSchedulerRunning(false);
addToast({
message: 'Scheduler has no valid queues selected. Update Scheduler settings.',
message: t($ => $.app.schedulerNoQueues),
variant: 'warning',
isActionable: true
});
@@ -668,7 +774,9 @@ function App() {
const failedPauses = pauseResults.filter(result => result.status === 'rejected').length;
if (failedPauses > 0) {
addToast({
message: `Scheduler could not pause ${failedPauses} download${failedPauses === 1 ? '' : 's'}.`,
message: failedPauses === 1
? t($ => $.app.schedulerPauseOneFailed)
: t($ => $.app.schedulerPauseManyFailed, { count: failedPauses }),
variant: 'error',
isActionable: true
});
@@ -702,14 +810,14 @@ function App() {
if (completionState !== 'completed') {
addToast({
message: 'Scheduled downloads did not all complete. The post-queue system action was skipped.',
message: t($ => $.app.scheduledIncomplete),
variant: 'warning',
isActionable: true
});
} else if (settings.scheduler.postQueueAction !== 'none') {
if (downloads.some(download => isActiveDownloadStatus(download.status))) {
addToast({
message: 'Scheduled system action skipped because another download is active or queued.',
message: t($ => $.app.scheduledActionSkippedActive),
variant: 'warning',
isActionable: true
});
@@ -736,7 +844,7 @@ function App() {
const permission = await requestPermission();
if (permission !== 'granted') {
addToast({
message: 'System notifications are disabled for Firelink.',
message: t($ => $.app.notificationsDisabled),
variant: 'warning',
isActionable: true
});
@@ -744,7 +852,7 @@ function App() {
}
} catch (error) {
addToast({
message: `Could not configure notifications: ${String(error)}`,
message: t($ => $.app.notificationsFailed, { detail: String(error) }),
variant: 'error',
isActionable: true
});
@@ -896,19 +1004,29 @@ function App() {
return (
<div className={`app-shell flex h-screen w-screen overflow-hidden text-text-primary ${
isSidebarOnRight ? 'app-shell--sidebar-right' : 'app-shell--sidebar-left'
} ${
hasWindowChrome ? 'app-shell--window-chrome' : ''
}`}>
{(platform.os === 'windows' || platform.os === 'linux') && <WindowControls />}
{usesCustomWindowControls && (
<WindowControls side={isSidebarOnRight ? 'right' : 'left'} />
)}
<div
className={`app-sidebar-shell relative z-20 shrink-0 transition-all duration-300 ease-[cubic-bezier(0.2,0.8,0.2,1)] ${
isSidebarOnRight ? 'app-sidebar-shell--right' : 'app-sidebar-shell--left'
} ${
isSidebarVisible ? 'opacity-100' : 'opacity-0 pointer-events-none'
}`}
style={{
width: sidebarWidth,
marginLeft: isSidebarVisible ? 0 : -sidebarWidth
marginInlineStart: isSidebarVisible || isSidebarOnRight ? 0 : -sidebarWidth,
marginInlineEnd: isSidebarVisible || !isSidebarOnRight ? 0 : -sidebarWidth
}}
>
<div className="app-sidebar-panel h-full w-full">
<div
className="app-sidebar-panel h-full w-full"
dir={isSidebarOnRight ? undefined : 'ltr'}
>
<Sidebar
selectedFilter={filter}
onSelectFilter={(f) => {
@@ -920,12 +1038,14 @@ function App() {
<div
className="sidebar-resize-handle"
onPointerDown={startSidebarResize}
title="Resize Sidebar"
title={t($ => $.actions.resizeSidebar)}
/>
</div>
<div
className={`app-workspace relative z-0 flex-1 flex flex-col h-full overflow-hidden ${
isSidebarOnRight ? 'app-workspace--sidebar-right' : 'app-workspace--sidebar-left'
} ${
!isSidebarVisible ? 'app-workspace--sidebar-collapsed' : ''
} ${usesCustomWindowControls ? 'app-workspace--custom-window-controls' : ''}`}
>
@@ -934,35 +1054,39 @@ function App() {
type="button"
onClick={toggleSidebar}
className="app-icon-button app-sidebar-reveal-button h-7 w-7"
title="Show Sidebar"
aria-label="Show Sidebar"
title={t($ => $.actions.showSidebar)}
aria-label={t($ => $.actions.showSidebar)}
>
<PanelLeft size={16} strokeWidth={2} />
</button>
)}
<div className="flex-1 flex flex-col overflow-hidden relative">
{activeView === 'downloads' && <DownloadTable filter={filter} />}
{activeView === 'settings' && <SettingsView />}
{activeView === 'scheduler' && <SchedulerView />}
{activeView === 'speedLimiter' && <SpeedLimiterView />}
{activeView === 'logs' && <LogsView />}
<Suspense fallback={<PageLoadingFallback />}>
{activeView === 'downloads' && <DownloadTable filter={filter} />}
{activeView === 'settings' && <SettingsView />}
{activeView === 'scheduler' && <SchedulerView />}
{activeView === 'speedLimiter' && <SpeedLimiterView />}
{activeView === 'logs' && <LogsView />}
</Suspense>
</div>
{/* Status Bar */}
<div className="app-statusbar px-[14px] flex items-center justify-between text-text-muted shrink-0">
<span>Ready</span>
<span>{t($ => $.status.ready)}</span>
<div className="flex gap-3 tabular-nums">
<span>{activeDownloadCount} active</span>
<span>{queuedCount} queued</span>
<span>{doneCount} done</span>
<span>{t($ => $.status.active, { count: activeDownloadCount })}</span>
<span>{t($ => $.status.queued, { count: queuedCount })}</span>
<span>{t($ => $.status.done, { count: doneCount })}</span>
</div>
</div>
</div>
<AddDownloadsModal />
<PropertiesModal />
<DeleteConfirmationModal />
<KeychainPermissionModal consentVersion={keychainConsentVersion} />
<Suspense fallback={null}>
{isAddModalOpen && <AddDownloadsModal />}
{selectedPropertiesDownloadId !== null && <PropertiesModal />}
{isDeleteModalOpen && <DeleteConfirmationModal />}
{showKeychainModal && <KeychainPermissionModal consentVersion={keychainConsentVersion} />}
</Suspense>
</div>
);
+1 -1
View File
@@ -1,3 +1,3 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type DownloadProgressEvent = { id: string, fraction: number, speed: string, eta: string, size: string | null, size_is_final: boolean, 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 -1
View File
@@ -1,4 +1,4 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { ExtensionCookieScope } from "./ExtensionCookieScope";
export type ExtensionDownload = { 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, };
+1 -1
View File
@@ -8,4 +8,4 @@ import type { SettingsTab } from "./SettingsTab";
import type { SiteLogin } from "./SiteLogin";
import type { Theme } from "./Theme";
export type PersistedSettings = { theme: Theme, baseDownloadFolder: string, categorySubfoldersEnabled: boolean, categorySubfolders: { [key in string]: string }, categoryDirectoryOverrides: { [key in string]: string }, approvedDownloadRoots: Array<string>, maxConcurrentDownloads: number, globalSpeedLimit: string, speedLimitPresetValues: Array<number>, logsEnabled: boolean, isSidebarVisible: boolean, activeSettingsTab: SettingsTab, scheduler: SchedulerSettings, schedulerRunning: boolean, schedulerActiveDownloadIds: Array<string>, schedulerLastStartKey: string, schedulerLastStopKey: string, lastCustomSpeedLimitKiB: number, 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, language: string, baseDownloadFolder: string, categorySubfoldersEnabled: boolean, categorySubfolders: { [key in string]: string }, categoryDirectoryOverrides: { [key in string]: string }, approvedDownloadRoots: Array<string>, maxConcurrentDownloads: number, globalSpeedLimit: string, speedLimitPresetValues: Array<number>, logsEnabled: boolean, isSidebarVisible: boolean, sidebarPosition: string, activeSettingsTab: SettingsTab, scheduler: SchedulerSettings, schedulerRunning: boolean, schedulerActiveDownloadIds: Array<string>, schedulerLastStartKey: string, schedulerLastStopKey: string, lastCustomSpeedLimitKiB: number, lastCustomSpeedLimitUnit: string, perServerConnections: number, maxAutomaticRetries: number, showNotifications: boolean, playCompletionSound: boolean, autoAddClipboardLinks: boolean, appFontSize: AppFontSize, listRowDensity: ListRowDensity, showDockBadge: boolean, showMenuBarIcon: boolean, proxyMode: ProxyMode, proxyHost: string, proxyPort: number, customUserAgent: string, askWhereToSaveEachFile: boolean, rememberLastUsedDownloadDirectory: boolean, preventsSleepWhileDownloading: boolean, mediaCookieSource: MediaCookieSource, siteLogins: Array<SiteLogin>, autoCheckUpdates: boolean, keychainAccessGranted: boolean, };
File diff suppressed because it is too large Load Diff
+15 -6
View File
@@ -1,8 +1,10 @@
import React, { useState, useEffect } from 'react';
import { useDownloadStore } from '../store/useDownloadStore';
import { AlertTriangle } from 'lucide-react';
import { useTranslation } from 'react-i18next';
export const DeleteConfirmationModal: React.FC = () => {
const { t } = useTranslation();
const { deleteModalState, closeDeleteModal, removeDownload } = useDownloadStore();
const [errorMessage, setErrorMessage] = useState('');
const [isRemoving, setIsRemoving] = useState(false);
@@ -50,7 +52,11 @@ export const DeleteConfirmationModal: React.FC = () => {
}
if (failures.length > 0) {
setErrorMessage(`${succeeded} removed, ${failures.length} failed: ${failures[0]}`);
setErrorMessage(t($ => $.dialogs.removeDownload.errorSummary, {
succeeded,
failed: failures.length,
detail: failures[0],
}));
setIsRemoving(false);
return;
}
@@ -60,6 +66,7 @@ export const DeleteConfirmationModal: React.FC = () => {
const handleRemoveFromList = () => removeMany(false);
const handleDeleteFile = () => removeMany(true);
const itemCount = deleteModalState.downloadIds?.length ?? 0;
return (
<div
@@ -78,11 +85,13 @@ export const DeleteConfirmationModal: React.FC = () => {
<div className="p-2 bg-red-500/10 rounded-full flex items-center justify-center">
<AlertTriangle size={20} className="text-red-400" />
</div>
<h2 className="text-lg font-semibold text-text-primary m-0">Remove Download</h2>
<h2 className="text-lg font-semibold text-text-primary m-0">{t($ => $.dialogs.removeDownload.title)}</h2>
</div>
<div className="px-5 py-6 flex-1 text-sm text-text-secondary leading-relaxed">
{`Are you sure you want to remove ${deleteModalState.downloadIds?.length && deleteModalState.downloadIds.length > 1 ? 'these ' + deleteModalState.downloadIds.length + ' items' : 'this item'} from the list? You can also choose to delete the underlying file from your hard drive.`}
{itemCount > 1
? t($ => $.dialogs.removeDownload.confirmationMultiple, { count: itemCount })
: t($ => $.dialogs.removeDownload.confirmationSingle)}
{errorMessage && <div className="mt-3 text-xs text-red-400">{errorMessage}</div>}
</div>
@@ -92,21 +101,21 @@ export const DeleteConfirmationModal: React.FC = () => {
disabled={isRemoving}
className="app-button px-4 py-2 rounded-lg text-sm font-medium disabled:opacity-50"
>
Cancel
{t($ => $.actions.cancel)}
</button>
<button
onClick={handleRemoveFromList}
disabled={isRemoving}
className="px-4 py-2 rounded-lg text-sm font-medium transition-colors bg-border-modal hover:bg-border-modal/80 text-text-primary disabled:opacity-50"
>
Remove
{t($ => $.dialogs.removeDownload.remove)}
</button>
<button
onClick={handleDeleteFile}
disabled={isRemoving}
className="px-4 py-2 rounded-lg text-sm font-medium transition-colors bg-red-500/20 text-red-400 hover:bg-red-500/30 disabled:opacity-50"
>
Delete file
{t($ => $.dialogs.removeDownload.deleteFile)}
</button>
</div>
</div>
+283 -126
View File
@@ -2,19 +2,31 @@ import React from 'react';
import { useDownloadProgressStore } from '../store/downloadProgressStore';
import { Play, Pause, MoreVertical, Clock, ArrowUp, ArrowDown } from 'lucide-react';
import type { DownloadItem as DownloadItemType } from '../bindings/DownloadItem';
import { canPauseDownload, canStartDownload, startActionLabel } from '../utils/downloadActions';
import { canPauseDownload, canStartDownload } from '../utils/downloadActions';
import { useTranslation } from 'react-i18next';
import {
downloadProgressColorClass,
formatDownloadTotal,
resolveDownloadSizeDisplay
} from '../utils/downloadProgress';
import {
COLUMN_ALIGNMENT_JUSTIFY,
DOWNLOAD_ACTIONS_COLUMN_WIDTH,
DOWNLOAD_ACTIONS_VIEWPORT_INSET,
getColumnGridColumn,
type DownloadColumnAlignment,
type DownloadTableColumnKey
} from '../utils/downloadTableColumns';
interface DownloadItemProps {
download: DownloadItemType;
index: number;
queueIndex: number;
queueLength: number;
columnOrder: DownloadTableColumnKey[];
columnAlignments: Record<DownloadTableColumnKey, DownloadColumnAlignment>;
tableGridTemplate: string;
tableMinWidth: number | string;
setContextMenu: (menu: { x: number; y: number; id: string }) => void;
handlePause: (id: string, skipConfirm?: boolean) => void;
handleResume: (item: DownloadItemType) => void;
@@ -29,7 +41,10 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
index,
queueIndex,
queueLength,
columnOrder,
columnAlignments,
tableGridTemplate,
tableMinWidth,
setContextMenu,
handlePause,
handleResume,
@@ -38,7 +53,83 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
onMoveInQueue,
onClick,
}) => {
const { t } = useTranslation();
const liveProgress = useDownloadProgressStore(state => state.progressMap[download.id]);
const rowRef = React.useRef<HTMLDivElement>(null);
const [isRowHovered, setIsRowHovered] = React.useState(false);
const [isRowFocused, setIsRowFocused] = React.useState(false);
const [actionPosition, setActionPosition] = React.useState<React.CSSProperties | undefined>();
const isActionVisible = isRowHovered || isRowFocused;
const updateActionPosition = React.useCallback(() => {
const row = rowRef.current;
const view = row?.closest<HTMLElement>('.downloads-view');
if (!row || !view) return;
const horizontalViewport = row.closest<HTMLElement>('.download-table-scroll') ?? view;
const verticalViewport = row.closest<HTMLElement>('.download-table-list') ?? view;
const rowRect = row.getBoundingClientRect();
const horizontalViewportRect = horizontalViewport.getBoundingClientRect();
const verticalViewportRect = verticalViewport.getBoundingClientRect();
const viewportTolerance = 1;
const visibleTop = Math.max(rowRect.top, verticalViewportRect.top);
const visibleBottom = Math.min(rowRect.bottom, verticalViewportRect.bottom);
const visibleHeight = Math.max(0, visibleBottom - visibleTop);
const isVisible = visibleHeight > viewportTolerance;
const actionHeight = Math.min(rowRect.height, visibleHeight);
const nextPosition: React.CSSProperties = {
top: visibleTop,
right: Math.max(0, window.innerWidth - horizontalViewportRect.right) + DOWNLOAD_ACTIONS_VIEWPORT_INSET,
height: actionHeight,
overflow: actionHeight < rowRect.height ? 'hidden' : 'visible',
visibility: isVisible ? 'visible' : 'hidden',
};
setActionPosition(previous => (
previous?.top === nextPosition.top &&
previous?.right === nextPosition.right &&
previous?.height === nextPosition.height &&
previous?.overflow === nextPosition.overflow &&
previous?.visibility === nextPosition.visibility
? previous
: nextPosition
));
}, []);
React.useEffect(() => {
if (!isActionVisible) return;
let frame = 0;
const schedulePositionUpdate = () => {
if (frame) window.cancelAnimationFrame(frame);
frame = window.requestAnimationFrame(() => {
frame = 0;
updateActionPosition();
});
};
const row = rowRef.current;
const view = row?.closest<HTMLElement>('.downloads-view');
const horizontalViewport = row?.closest<HTMLElement>('.download-table-scroll');
const verticalViewport = row?.closest<HTMLElement>('.download-table-list');
const resizeObserver = typeof ResizeObserver === 'undefined'
? null
: new ResizeObserver(schedulePositionUpdate);
updateActionPosition();
window.addEventListener('resize', schedulePositionUpdate);
window.addEventListener('scroll', schedulePositionUpdate, true);
[row, view, horizontalViewport, verticalViewport].forEach(element => {
if (element) resizeObserver?.observe(element);
});
return () => {
window.removeEventListener('resize', schedulePositionUpdate);
window.removeEventListener('scroll', schedulePositionUpdate, true);
resizeObserver?.disconnect();
if (frame) window.cancelAnimationFrame(frame);
};
}, [isActionVisible, updateActionPosition]);
const displayFraction = download.status === 'downloading'
? liveProgress?.fraction ?? download.fraction ?? 0
@@ -47,12 +138,12 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
const displaySpeed = download.status === 'downloading'
? liveProgress?.speed ?? download.speed
: download.status === 'processing'
? 'Processing...'
? t($ => $.downloads.values.processing)
: '-';
const displayEta = download.status === 'downloading'
? liveProgress?.eta ?? download.eta
: download.status === 'processing'
? 'Muxing...'
? t($ => $.downloads.values.muxing)
: '-';
const sizeDisplay = resolveDownloadSizeDisplay({
downloadedBytes: liveProgress?.downloaded_bytes ?? download.downloadedBytes,
@@ -62,60 +153,84 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
});
const hasDownloadedAmount = download.status !== 'completed' &&
Boolean(sizeDisplay.downloaded && sizeDisplay.total);
const completedSizeLabel = download.status === 'completed'
? formatDownloadTotal(sizeDisplay)
: sizeDisplay.fallback;
const completedSizeLabel = (() => {
const value = download.status === 'completed' ? formatDownloadTotal(sizeDisplay) : sizeDisplay.fallback;
return value === 'Unknown' ? t($ => $.addDownloads.unknown) : value;
})();
const downloadStatusLabel = t($ => $.downloads.status[download.status]);
const downloadedSizeLabel = sizeDisplay.totalIsEstimate
? t($ => $.downloads.size.downloadedOfApproximate, {
downloaded: sizeDisplay.downloaded ?? '',
total: sizeDisplay.total ?? '',
unit: sizeDisplay.unit ?? '',
})
: t($ => $.downloads.size.downloadedOf, {
downloaded: sizeDisplay.downloaded ?? '',
total: sizeDisplay.total ?? '',
unit: sizeDisplay.unit ?? '',
});
return (
<div
className={`download-row group cursor-default relative ${index % 2 !== 0 ? 'striped' : ''} ${isSelected ? 'is-selected' : ''}`}
style={{ gridTemplateColumns: tableGridTemplate }}
onClick={(e) => onClick(e, download)}
onContextMenu={(e) => {
e.preventDefault();
setContextMenu({ x: e.clientX, y: e.clientY, id: download.id });
}}
>
<div className="download-file-cell">
<span className="shrink-0 text-text-muted">
{getCategoryIcon(download.category)}
</span>
<span className="download-file-name" title={download.fileName}>
{download.fileName}
</span>
</div>
const columnStyle = (key: DownloadTableColumnKey): React.CSSProperties => ({
'--column-justify': COLUMN_ALIGNMENT_JUSTIFY[columnAlignments[key]],
gridColumn: getColumnGridColumn(key, columnOrder),
} as React.CSSProperties);
const cells: Record<DownloadTableColumnKey, React.ReactNode> = {
'File Name': (
<div
className="download-cell-truncate download-size-cell tabular-nums"
title={hasDownloadedAmount
? `${sizeDisplay.downloaded} downloaded of ${sizeDisplay.totalIsEstimate ? '~' : ''}${sizeDisplay.total} ${sizeDisplay.unit}`
: completedSizeLabel}
aria-label={hasDownloadedAmount
? `${sizeDisplay.downloaded} downloaded of ${sizeDisplay.totalIsEstimate ? 'approximately ' : ''}${sizeDisplay.total} ${sizeDisplay.unit}`
: completedSizeLabel}
className="download-column-cell download-file-cell download-column-file-name"
style={columnStyle('File Name')}
>
{hasDownloadedAmount ? (
<span className="download-size-progress">
<span className={downloadProgressColorClass(download.status)}>{sizeDisplay.downloaded}</span>
<span className="text-text-muted"> / </span>
<div className="download-cell-content">
<span className="shrink-0 text-text-muted">
{getCategoryIcon(download.category)}
</span>
) : null}
<span className="download-size-total">
{hasDownloadedAmount
? `${sizeDisplay.totalIsEstimate ? '~' : ''}${sizeDisplay.total} ${sizeDisplay.unit}`
: completedSizeLabel}
</span>
<span className="download-file-name" title={download.fileName}>
{download.fileName}
</span>
</div>
</div>
<div className="download-status-cell">
),
Size: (
<div
className="download-column-cell download-cell-truncate download-size-cell tabular-nums"
style={columnStyle('Size')}
title={hasDownloadedAmount ? downloadedSizeLabel : completedSizeLabel}
aria-label={hasDownloadedAmount ? downloadedSizeLabel : completedSizeLabel}
>
<div className="download-cell-content download-size-content">
{hasDownloadedAmount ? (
<span className="download-size-progress">
<span className={downloadProgressColorClass(download.status)}>{sizeDisplay.downloaded}</span>
<span className="text-text-muted"> / </span>
</span>
) : null}
<span className="download-size-total">
{hasDownloadedAmount
? `${sizeDisplay.totalIsEstimate ? '~' : ''}${sizeDisplay.total} ${sizeDisplay.unit}`
: completedSizeLabel}
</span>
</div>
</div>
),
Status: (
<div
className="download-column-cell download-status-cell"
data-column-alignment={columnAlignments.Status}
style={columnStyle('Status')}
>
{download.status === 'completed' ? (
<span className="download-status download-status-completed" title="Completed">Completed</span>
<div className="download-cell-content download-status-content download-status-content-static">
<span className="download-status download-status-completed" title={downloadStatusLabel}>
{downloadStatusLabel}
</span>
</div>
) : (
<>
<div className="download-cell-content download-status-content">
<div className="download-progress-track">
<div
className={`download-progress-fill ${
download.status === 'paused' ? 'paused' :
download.status === 'paused' ? 'paused' :
download.status === 'processing' ? 'processing' :
download.status === 'queued' || download.status === 'staged' ? 'queued' :
download.status === 'retrying' ? 'retrying' : ''
@@ -124,22 +239,22 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
/>
</div>
<span
title={
download.lastError && (download.status === 'failed' || download.status === 'retrying')
? download.lastError
: (download.status === 'queued' || download.status === 'staged') && queueIndex !== -1
? `${download.status === 'staged' ? 'In queue' : 'Queued'} #${queueIndex + 1}`
: download.status === 'downloading'
title={
download.lastError && (download.status === 'failed' || download.status === 'retrying')
? download.lastError
: (download.status === 'queued' || download.status === 'staged') && queueIndex !== -1
? `${downloadStatusLabel} #${queueIndex + 1}`
: download.status === 'downloading'
? displayPercent
: download.status === 'processing'
? 'Processing'
: download.status.charAt(0).toUpperCase() + download.status.slice(1)
}
className={`download-status flex items-center gap-1.5 ${
download.status === 'paused' ? 'download-status-paused' :
download.status === 'failed' ? 'download-status-failed' :
? downloadStatusLabel
: downloadStatusLabel
}
className={`download-status flex items-center gap-1.5 ${
download.status === 'paused' ? 'download-status-paused' :
download.status === 'failed' ? 'download-status-failed' :
download.status === 'processing' ? 'download-status-processing' :
download.status === 'downloading' ? 'download-status-downloading' :
download.status === 'downloading' ? 'download-status-downloading' :
download.status === 'queued' || download.status === 'staged' ? 'download-status-queued' :
download.status === 'retrying' ? 'download-status-retrying' : ''
}`}
@@ -148,94 +263,136 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
<>
<Clock size={12} className={download.status === 'queued' ? 'animate-pulse shrink-0' : 'shrink-0'} />
<span className="truncate">
{download.status === 'staged' ? 'In queue' : 'Queued'} #{queueIndex + 1}
{downloadStatusLabel} #{queueIndex + 1}
</span>
</>
) : download.status === 'downloading' ? (
displayPercent
) : download.status === 'processing' ? (
'Processing'
downloadStatusLabel
) : (
download.status.charAt(0).toUpperCase() + download.status.slice(1)
downloadStatusLabel
)}
</span>
</>
</div>
)}
</div>
<div className="download-cell-truncate">
<span
className="tabular-nums"
title={displaySpeed}
>
),
Speed: (
<div className="download-column-cell download-cell-truncate" style={columnStyle('Speed')}>
<span className="download-cell-content tabular-nums" title={displaySpeed}>
{displaySpeed}
</span>
</div>
<div className="download-cell-truncate">
<span
className="tabular-nums"
title={displayEta}
>
),
ETA: (
<div className="download-column-cell download-cell-truncate" style={columnStyle('ETA')}>
<span className="download-cell-content tabular-nums" title={displayEta}>
{displayEta}
</span>
</div>
<div className="download-cell-right">
),
'Date Added': (
<div className="download-column-cell download-cell-right download-column-date-added" style={columnStyle('Date Added')}>
<span
className="truncate group-hover:hidden tabular-nums"
className="download-cell-content download-date-value tabular-nums"
title={download.dateAdded ? new Date(download.dateAdded).toLocaleDateString() : '-'}
>
{download.dateAdded ? new Date(download.dateAdded).toLocaleDateString() : '-'}
</span>
<div
className="hidden group-hover:flex items-center justify-end gap-0.5 w-full ml-auto"
onClick={(e) => e.stopPropagation()}
onDoubleClick={(e) => e.stopPropagation()}
>
{(download.status === 'queued' || download.status === 'staged') && queueIndex !== -1 && (
<>
<button
onClick={() => 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>
),
};
const rowActions = (
<div
className="download-row-actions items-center gap-0.5"
style={{
...actionPosition,
width: `${DOWNLOAD_ACTIONS_COLUMN_WIDTH}px`,
}}
onClick={(e) => e.stopPropagation()}
onDoubleClick={(e) => e.stopPropagation()}
>
{(download.status === 'queued' || download.status === 'staged') && queueIndex !== -1 && (
<>
<button
onClick={() => onMoveInQueue(download.id, 'up')}
disabled={queueIndex === 0}
className="app-icon-button h-7 w-7 disabled:opacity-40"
title={t($ => $.downloads.actions.moveUp)}
>
<ArrowUp size={14} />
</button>
<button
onClick={() => onMoveInQueue(download.id, 'down')}
disabled={queueIndex === queueLength - 1}
className="app-icon-button h-7 w-7 disabled:opacity-40"
title={t($ => $.downloads.actions.moveDown)}
>
<ArrowDown size={14} />
</button>
</>
)}
{canPauseDownload(download.status) && (
<button onClick={() => handlePause(download.id)} className="app-icon-button h-7 w-7" title={t($ => $.downloads.actions.pause)}>
<Pause size={14} fill="currentColor" />
</button>
)}
{canStartDownload(download.status) && (
<button onClick={() => handleResume(download)} className="app-icon-button h-7 w-7" title={download.status === 'paused' ? t($ => $.downloads.actions.resume) : t($ => $.downloads.actions.start)}>
<Play size={14} fill="currentColor" />
</button>
)}
<button
onClick={(e) => {
e.stopPropagation();
setContextMenu({ x: e.clientX, y: e.clientY, id: download.id });
}}
className="app-icon-button h-7 w-7"
title={t($ => $.downloads.actions.options)}
>
<MoreVertical size={14} />
</button>
</div>
);
return (
<div
ref={rowRef}
className={`download-row group cursor-default relative ${isActionVisible ? 'has-visible-actions' : ''} ${index % 2 !== 0 ? 'striped' : ''} ${isSelected ? 'is-selected' : ''}`}
style={{ gridTemplateColumns: tableGridTemplate, minWidth: tableMinWidth }}
tabIndex={0}
onMouseEnter={() => {
setIsRowHovered(true);
updateActionPosition();
}}
onMouseLeave={event => {
const nextTarget = event.relatedTarget;
if (!(nextTarget instanceof Node) || !event.currentTarget.contains(nextTarget)) {
setIsRowHovered(false);
}
}}
onFocus={() => {
setIsRowFocused(true);
updateActionPosition();
}}
onBlur={event => {
const nextTarget = event.relatedTarget;
if (!(nextTarget instanceof Node) || !event.currentTarget.contains(nextTarget)) {
setIsRowFocused(false);
}
}}
onClick={(e) => onClick(e, download)}
onContextMenu={(e) => {
e.preventDefault();
setContextMenu({ x: e.clientX, y: e.clientY, id: download.id });
}}
>
{columnOrder.map(columnKey => (
<React.Fragment key={columnKey}>{cells[columnKey]}</React.Fragment>
))}
{rowActions}
</div>
);
});
File diff suppressed because it is too large Load Diff
+10 -7
View File
@@ -1,4 +1,5 @@
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
export type DuplicateReason = { type: 'url', msg: string } | { type: 'file', msg: string };
type DuplicateResolution = 'rename' | 'replace' | 'skip';
@@ -9,6 +10,7 @@ export interface DuplicateConflict {
reason: DuplicateReason;
resolution: DuplicateResolution;
replaceAllowed?: boolean;
existingDownloadId?: string;
}
interface Props {
@@ -18,6 +20,7 @@ interface Props {
}
export const DuplicateResolutionModal = ({ conflicts: initialConflicts, onConfirm, onCancel }: Props) => {
const { t } = useTranslation();
const [conflicts, setConflicts] = useState<DuplicateConflict[]>(initialConflicts);
useEffect(() => {
@@ -43,8 +46,8 @@ export const DuplicateResolutionModal = ({ conflicts: initialConflicts, onConfir
>
<div className="app-modal w-[500px] flex flex-col overflow-hidden text-sm">
<div className="p-4 border-b border-border-modal flex flex-col gap-2">
<h2 className="text-lg font-semibold text-text-primary">Duplicate Downloads Detected</h2>
<p className="text-xs text-text-muted">Some of the downloads you are adding already exist in the queue or on disk. Please choose how to resolve these conflicts.</p>
<h2 className="text-lg font-semibold text-text-primary">{t($ => $.dialogs.duplicateDownloads.title)}</h2>
<p className="text-xs text-text-muted">{t($ => $.dialogs.duplicateDownloads.description)}</p>
</div>
<div className="max-h-[300px] overflow-y-auto p-4 space-y-3">
@@ -59,9 +62,9 @@ export const DuplicateResolutionModal = ({ conflicts: initialConflicts, onConfir
onChange={(e) => updateResolution(conflict.id, e.target.value as DuplicateResolution)}
className="app-control w-24 shrink-0 px-2 py-1 text-xs"
>
<option value="rename">Rename</option>
{conflict.reason.type === 'file' && conflict.replaceAllowed && <option value="replace">Replace</option>}
<option value="skip">Skip</option>
<option value="rename">{t($ => $.dialogs.duplicateDownloads.rename)}</option>
{conflict.reason.type === 'file' && conflict.replaceAllowed && <option value="replace">{t($ => $.dialogs.duplicateDownloads.replace)}</option>}
<option value="skip">{t($ => $.dialogs.duplicateDownloads.skip)}</option>
</select>
</div>
))}
@@ -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">
<button onClick={onCancel} className="app-button px-4 text-xs">
Cancel
{t($ => $.actions.cancel)}
</button>
<button
onClick={() => onConfirm(conflicts.map(c => ({ id: c.id, resolution: c.resolution })))}
className="app-button app-button-primary px-5 text-xs"
>
Continue
{t($ => $.actions.continue)}
</button>
</div>
</div>
+4 -3
View File
@@ -1,4 +1,5 @@
import { Component, ErrorInfo, ReactNode } from 'react';
import i18n from '../i18n';
interface Props {
children: ReactNode;
@@ -38,10 +39,10 @@ export class ErrorBoundary extends Component<Props, State> {
<svg className="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
<h1 className="text-xl font-bold">Something went wrong.</h1>
<h1 className="text-xl font-bold">{i18n.t($ => $.dialogs.errorBoundary.title)}</h1>
</div>
<div className="text-sm text-text-secondary mb-6">
A critical error occurred in the React component tree. The error details below can help identify the root cause.
{i18n.t($ => $.dialogs.errorBoundary.description)}
</div>
<div className="bg-black/50 p-4 rounded text-xs font-mono text-red-300 overflow-x-auto mb-4">
{this.state.error && this.state.error.toString()}
@@ -52,7 +53,7 @@ export class ErrorBoundary extends Component<Props, State> {
className="app-button px-4 py-2 bg-red-600/20 text-red-400 border border-red-600/50 hover:bg-red-600/30 transition-colors rounded"
onClick={() => window.location.reload()}
>
Reload App
{i18n.t($ => $.dialogs.errorBoundary.reload)}
</button>
</div>
</div>
+67 -35
View File
@@ -1,4 +1,4 @@
import React, { useEffect, useState } from 'react';
import React, { useEffect, useRef, useState } from 'react';
import { useSettingsStore } from '../store/useSettingsStore';
import { invokeCommand as invoke } from '../ipc';
import { KeyRound, ShieldAlert } from 'lucide-react';
@@ -6,6 +6,7 @@ import { usePlatformInfo } from '../utils/platform';
import { getKeychainConsentVersion } from '../utils/keychainStartup';
import { getVersion } from '@tauri-apps/api/app';
import type { PairingTokenHydration } from '../bindings/PairingTokenHydration';
import { useTranslation } from 'react-i18next';
const KEYCHAIN_GRANT_TIMEOUT_MS = 30_000;
@@ -14,20 +15,28 @@ type KeychainPermissionModalProps = {
};
export const KeychainPermissionModal: React.FC<KeychainPermissionModalProps> = ({ consentVersion }) => {
const { t } = useTranslation();
const showKeychainModal = useSettingsStore(state => state.showKeychainModal);
const dismissKeychainPrompt = useSettingsStore(state => state.dismissKeychainPrompt);
const platform = usePlatformInfo();
const [isGranting, setIsGranting] = useState(false);
const [grantRequestPending, setGrantRequestPending] = useState(false);
const [error, setError] = useState<string | null>(null);
const grantRequestRef = useRef<Promise<PairingTokenHydration> | null>(null);
useEffect(() => {
if (!showKeychainModal || isGranting) return;
if (!showKeychainModal || isGranting || grantRequestPending) return;
const handleEscape = (event: KeyboardEvent) => {
if (event.key === 'Escape') dismissKeychainPrompt(consentVersion);
if (event.key !== 'Escape') return;
if (consentVersion.trim()) {
dismissKeychainPrompt(consentVersion);
} else {
useSettingsStore.getState().setShowKeychainModal(false);
}
};
window.addEventListener('keydown', handleEscape);
return () => window.removeEventListener('keydown', handleEscape);
}, [consentVersion, dismissKeychainPrompt, isGranting, showKeychainModal]);
}, [consentVersion, dismissKeychainPrompt, grantRequestPending, isGranting, showKeychainModal]);
if (!showKeychainModal) {
return null;
@@ -36,24 +45,28 @@ export const KeychainPermissionModal: React.FC<KeychainPermissionModalProps> = (
const isMac = platform.os === 'macos';
const pairingStoreName =
platform.portable
? 'the portable Firelink data folder'
? t($ => $.keychain.stores.portable)
: platform.os === 'windows'
? 'Windows Credential Manager'
? t($ => $.keychain.stores.windows)
: platform.os === 'linux'
? 'your Linux credential store'
? t($ => $.keychain.stores.linux)
: platform.os === 'macos'
? 'macOS Keychain'
: "this system's credential store";
? t($ => $.keychain.stores.macos)
: t($ => $.keychain.stores.system);
const siteCredentialStoreName = platform.portable
? "the system's credential store"
? t($ => $.keychain.stores.siteCredentials)
: pairingStoreName;
const grantLabel = platform.portable
? 'Continue'
? t($ => $.keychain.grantLabelPortable)
: isMac
? 'Grant Access'
: 'Enable Secure Storage';
? t($ => $.keychain.grantLabelMacos)
: t($ => $.keychain.grantLabelDefault);
const handleGrant = async () => {
// A native credential-store call cannot be cancelled from the webview.
// Keep the request identity until it settles so a UI timeout cannot
// launch a second OS prompt while the first one is still outstanding.
if (grantRequestRef.current) return;
setIsGranting(true);
setError(null);
@@ -77,9 +90,21 @@ export const KeychainPermissionModal: React.FC<KeychainPermissionModalProps> = (
return true;
};
const grantRequest = invoke('grant_keychain_access');
grantRequestRef.current = grantRequest;
setGrantRequestPending(true);
void grantRequest.then(
() => {
if (grantRequestRef.current === grantRequest) grantRequestRef.current = null;
setGrantRequestPending(false);
},
() => {
if (grantRequestRef.current === grantRequest) grantRequestRef.current = null;
setGrantRequestPending(false);
}
);
// A native credential-store call cannot be cancelled by the webview. Keep
// a late successful result useful even if the UI timeout has already
// restored the Later/retry controls.
// returned control to the explanation.
grantRequest.then(applyPersistentGrant).catch(() => undefined);
try {
@@ -87,13 +112,13 @@ export const KeychainPermissionModal: React.FC<KeychainPermissionModalProps> = (
grantRequest,
new Promise<never>((_, reject) => {
timeoutId = window.setTimeout(
() => reject(new Error('Credential storage request timed out. You can select Later and try again.')),
() => reject(new Error(t($ => $.keychain.timeout))),
KEYCHAIN_GRANT_TIMEOUT_MS
);
})
]);
if (!(await applyPersistentGrant(result))) {
setError(result.error || `${siteCredentialStoreName} is unavailable.`);
setError(result.error || t($ => $.keychain.unavailable, { store: siteCredentialStoreName }));
}
} catch (e: any) {
setError(e.toString());
@@ -104,14 +129,22 @@ export const KeychainPermissionModal: React.FC<KeychainPermissionModalProps> = (
};
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 (
<div
className="app-modal-backdrop fixed inset-0 z-50 flex items-center justify-center bg-black/40"
onClick={(event) => {
if (event.target === event.currentTarget && !isGranting) handleLater();
if (event.target === event.currentTarget && !isGranting && !grantRequestPending) handleLater();
}}
role="dialog"
aria-modal="true"
@@ -124,28 +157,27 @@ export const KeychainPermissionModal: React.FC<KeychainPermissionModalProps> = (
<div className="p-2 bg-blue-500/10 rounded-full items-center justify-center">
<KeyRound size={20} className="text-blue-500" />
</div>
<h2 className="text-lg font-semibold text-text-primary m-0">Credential Storage Access Needed</h2>
<h2 className="text-lg font-semibold text-text-primary m-0">{t($ => $.keychain.title)}</h2>
</div>
<div className="px-5 py-6 flex-1 min-h-0 overflow-y-auto text-sm text-text-secondary leading-relaxed space-y-4">
<p>
Firelink uses the browser extension to capture downloads. To keep the extension paired after restarts,
Firelink stores its pairing token in {pairingStoreName}. Optional site credentials are stored in {siteCredentialStoreName}.
{t($ => $.keychain.description, { pairingStore: pairingStoreName, siteCredentialStore: siteCredentialStoreName })}
</p>
<p>
{platform.portable
? 'The pairing token is portable with this folder. Site credentials remain in the system credential store; a system prompt may appear after you grant access.'
? t($ => $.keychain.portableExplanation)
: isMac
? 'macOS may show a Keychain prompt after you grant access.'
: 'This usually completes silently. If the credential service is unavailable, Firelink will show the error here and the extension will stay paired for this session only.'}
? t($ => $.keychain.macosExplanation)
: t($ => $.keychain.defaultExplanation)}
</p>
<p>
<strong>Note:</strong>{' '}
<strong>{t($ => $.keychain.note)}</strong>{' '}
{platform.portable
? 'Portable mode stores only the pairing token in this folder. It does not copy site passwords or browser credentials.'
: 'Firelink only writes its own dedicated credential entry. It cannot access other saved passwords or credential items on your system.'}
? t($ => $.keychain.portableNote)
: t($ => $.keychain.defaultNote)}
</p>
{error && (
@@ -156,28 +188,28 @@ export const KeychainPermissionModal: React.FC<KeychainPermissionModalProps> = (
)}
<div className="bg-bg-modal-accent p-3 rounded-lg border border-border-modal text-xs">
<strong>Hint:</strong>{' '}
<strong>{t($ => $.keychain.hint)}</strong>{' '}
{platform.portable
? 'The portable pairing token is already stored with this folder; you can enable it here or select Later.'
: 'If you select Later, the extension will only work for this session.'}
You can enable storage anytime from <strong>Settings &gt; Integrations</strong>.
? t($ => $.keychain.portableHint)
: t($ => $.keychain.defaultHint)}{' '}
{t($ => $.keychain.enableFromSettings)}
</div>
</div>
<div className="px-5 py-4 border-t border-border-modal flex justify-end gap-3 bg-bg-modal-accent">
<button
onClick={handleLater}
disabled={isGranting}
disabled={isGranting || grantRequestPending}
className="px-4 py-2 rounded-lg text-sm font-medium transition-colors text-text-secondary hover:bg-item-hover hover:text-text-primary disabled:opacity-50"
>
Later
{t($ => $.keychain.later)}
</button>
<button
onClick={handleGrant}
disabled={isGranting}
disabled={isGranting || grantRequestPending}
className="px-4 py-2 rounded-lg text-sm font-medium transition-colors bg-accent text-white hover:bg-accent/90 disabled:opacity-50"
>
{isGranting ? 'Enabling...' : grantLabel}
{isGranting || grantRequestPending ? t($ => $.keychain.enabling) : grantLabel}
</button>
</div>
</div>
+30 -28
View File
@@ -7,6 +7,7 @@ import { FileDown, Trash2, Terminal, Filter, Play, Pause, Info, Copy } from 'luc
import { WindowDragRegion } from './WindowDragRegion';
import { useToast } from '../contexts/ToastContext';
import { useSettingsStore } from '../store/useSettingsStore';
import { useTranslation } from 'react-i18next';
import {
MAX_LOG_LINES,
appendBoundedLogEntries,
@@ -18,6 +19,7 @@ import {
} from '../utils/logEntries';
export default function LogsView() {
const { t } = useTranslation();
const { addToast } = useToast();
const logsEnabled = useSettingsStore(state => state.logsEnabled);
const setLogsEnabled = useSettingsStore(state => state.setLogsEnabled);
@@ -169,10 +171,10 @@ export default function LogsView() {
if (contextMenu?.text) {
try {
await navigator.clipboard.writeText(contextMenu.text);
addToast({ message: 'Copied to clipboard', variant: 'success' });
addToast({ message: t($ => $.logs.copied), variant: 'success' });
} catch (err) {
console.error('Clipboard write error:', err);
addToast({ message: 'Failed to copy to clipboard', variant: 'error' });
addToast({ message: t($ => $.logs.copyFailed), variant: 'error' });
}
}
setContextMenu(null);
@@ -182,14 +184,14 @@ export default function LogsView() {
try {
const path = await save({
defaultPath: 'Firelink-Support-Logs.log',
filters: [{ name: 'Log Files', extensions: ['log'] }],
filters: [{ name: t($ => $.logs.logFiles), extensions: ['log'] }],
});
if (!path) return;
await invoke('export_logs', { destination: path });
addToast({ message: 'Support logs exported', variant: 'success' });
addToast({ message: t($ => $.logs.exported), variant: 'success' });
} catch (e) {
console.error('Export failed:', e);
addToast({ message: `Could not export logs: ${String(e)}`, variant: 'error', isActionable: true });
addToast({ message: t($ => $.logs.exportFailed, { detail: String(e) }), variant: 'error', isActionable: true });
}
};
@@ -208,10 +210,10 @@ export default function LogsView() {
liveFrameRef.current = null;
}
setLogs([]);
addToast({ message: 'Logs cleared', variant: 'info' });
addToast({ message: t($ => $.logs.cleared), variant: 'info' });
} catch (error) {
addToast({
message: `Could not clear logs: ${String(error)}`,
message: t($ => $.logs.clearFailed, { detail: String(error) }),
variant: 'error',
isActionable: true
});
@@ -231,7 +233,7 @@ export default function LogsView() {
await setLogPaused(!nextEnabled);
setLogsEnabled(nextEnabled);
addToast({
message: nextEnabled ? 'Diagnostic logging enabled' : 'Diagnostic logging disabled',
message: nextEnabled ? t($ => $.logs.enabled) : t($ => $.logs.disabled),
variant: 'success'
});
})();
@@ -241,7 +243,7 @@ export default function LogsView() {
await toggleOperation;
} catch (error) {
addToast({
message: `Could not update diagnostic logging: ${String(error)}`,
message: t($ => $.logs.updateFailed, { detail: String(error) }),
variant: 'error',
isActionable: true
});
@@ -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="flex items-center gap-2 text-text-secondary">
<Terminal size={16} strokeWidth={1.8} />
<span className="text-[13px] font-semibold text-text-primary">Logs</span>
<span className="text-[11px] text-text-muted">({logs.length} entries)</span>
<span className="text-[13px] font-semibold text-text-primary">{t($ => $.logs.title)}</span>
<span className="text-[11px] text-text-muted">{t($ => $.logs.entries, { count: logs.length })}</span>
<span className={`rounded-full px-2 py-0.5 text-[10px] font-semibold ${
logsEnabled ? 'bg-accent/15 text-accent' : 'bg-item-hover text-text-muted'
}`}>
{logsEnabled ? 'Collecting' : 'Off'}
{logsEnabled ? t($ => $.logs.collecting) : t($ => $.logs.off)}
</span>
</div>
<div className="flex items-center gap-3">
@@ -286,12 +288,12 @@ export default function LogsView() {
onChange={e => setLevelFilter(e.target.value as LogEntry['level'] | 'All')}
className="bg-bg-input border border-border-modal rounded px-1.5 py-0.5 text-[11px] text-text-primary focus:outline-none focus:border-accent"
>
<option value="All">All Levels</option>
<option value="Error">Error</option>
<option value="Warn">Warn</option>
<option value="Info">Info</option>
<option value="Debug">Debug</option>
<option value="Trace">Trace</option>
<option value="All">{t($ => $.logs.allLevels)}</option>
<option value="Error">{t($ => $.logs.levels.error)}</option>
<option value="Warn">{t($ => $.logs.levels.warn)}</option>
<option value="Info">{t($ => $.logs.levels.info)}</option>
<option value="Debug">{t($ => $.logs.levels.debug)}</option>
<option value="Trace">{t($ => $.logs.levels.trace)}</option>
</select>
</div>
<div className="w-[1px] h-4 bg-border-modal mx-0.5" />
@@ -299,7 +301,7 @@ export default function LogsView() {
onClick={handleToggleLogging}
disabled={isToggling}
className={`app-icon-button disabled:cursor-not-allowed disabled:opacity-50 ${logsEnabled ? 'text-accent' : ''}`}
title={logsEnabled ? "Pause diagnostic logging" : "Enable diagnostic logging"}
title={logsEnabled ? t($ => $.logs.pauseLogging) : t($ => $.logs.enableLogging)}
>
{logsEnabled ? <Pause size={14} /> : <Play size={14} />}
</button>
@@ -307,17 +309,17 @@ export default function LogsView() {
onClick={handleClear}
disabled={isClearing}
className="app-icon-button disabled:cursor-not-allowed disabled:opacity-50"
title="Clear displayed logs"
title={t($ => $.logs.clearDisplayed)}
>
<Trash2 size={14} />
</button>
<button
onClick={handleExport}
className="app-button px-3 text-[11px] gap-1.5"
title="Export logs"
title={t($ => $.logs.export)}
>
<FileDown size={13} />
Export Logs
{t($ => $.logs.exportButton)}
</button>
</div>
</div>
@@ -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">
<Info size={12} className="text-text-muted opacity-80 shrink-0" />
<span className="opacity-90 leading-tight">
<strong className="font-medium text-text-primary mr-1">Local diagnostics:</strong>
Diagnostic collection is opt-in, bounded, and local. Common secrets, URL queries, and home-directory paths are redacted during display and export. Nothing is uploaded automatically.
<strong className="font-medium text-text-primary me-1">{t($ => $.logs.localDiagnostics)}</strong>
{t($ => $.logs.diagnosticsDescription)}
</span>
</div>
@@ -340,7 +342,7 @@ export default function LogsView() {
>
{logs.length === 0 && (
<div className="text-text-muted italic select-none">
{logsEnabled ? 'No persisted log entries are available yet.' : 'Diagnostic logging is off. Existing support logs will appear here when available.'}
{logsEnabled ? t($ => $.logs.noEntries) : t($ => $.logs.disabledDescription)}
</div>
)}
{logs.filter(entry => levelFilter === 'All' || entry.level === levelFilter).map((entry, i) => (
@@ -360,11 +362,11 @@ export default function LogsView() {
onClick={(e) => e.stopPropagation()}
>
<button
className="w-full text-left px-3 py-2 flex items-center hover:bg-item-hover transition-colors"
className="w-full text-start px-3 py-2 flex items-center hover:bg-item-hover transition-colors"
onClick={handleCopy}
>
<Copy size={13} className="mr-2 text-text-secondary" />
Copy
<Copy size={13} className="me-2 text-text-secondary" />
{t($ => $.logs.copy)}
</button>
</div>
)}
+110 -50
View File
@@ -16,6 +16,7 @@ import {
resolveDownloadSizeDisplay
} from '../utils/downloadProgress';
import { resolveDownloadConnections } from '../utils/downloads';
import { useTranslation } from 'react-i18next';
type LoginMode = 'matching' | 'custom' | 'none';
@@ -28,6 +29,18 @@ const formatLastTry = (value?: string): string => {
};
export const PropertiesModal = () => {
const { t } = useTranslation();
const categoryLabel = (category: string) => {
switch (category) {
case 'Musics': return t($ => $.navigation.categories.musics);
case 'Movies': return t($ => $.navigation.categories.movies);
case 'Compressed': return t($ => $.navigation.categories.compressed);
case 'Documents': return t($ => $.navigation.categories.documents);
case 'Pictures': return t($ => $.navigation.categories.pictures);
case 'Applications': return t($ => $.navigation.categories.applications);
default: return t($ => $.navigation.categories.other);
}
};
const selectedPropertiesDownloadId = useDownloadStore(state => state.selectedPropertiesDownloadId);
const setSelectedPropertiesDownloadId = useDownloadStore(state => state.setSelectedPropertiesDownloadId);
const item = useDownloadStore(useShallow(state =>
@@ -159,11 +172,11 @@ export const PropertiesModal = () => {
const handleSave = async () => {
if (!url.trim()) {
setErrorMessage("Enter a valid URL.");
setErrorMessage(t($ => $.properties.enterValidUrl));
return;
}
if (!fileName.trim()) {
setErrorMessage("File name cannot be empty.");
setErrorMessage(t($ => $.properties.fileNameEmpty));
return;
}
@@ -194,6 +207,39 @@ export const PropertiesModal = () => {
const identityLocked = getIdentityLocked(item.status);
const transferLocked = getTransferLocked(item.status);
const configuredConnections = resolveDownloadConnections(item.connections, perServerConnections);
const observedConnectionTotal = Math.max(
1,
liveProgress?.requested_connections ?? configuredConnections
);
const observedActiveConnections = liveProgress?.active_connections;
const connectionTelemetryActive = item.status === 'downloading' ||
item.status === 'processing' ||
item.status === 'retrying';
const connectionStatus = (() => {
if (!connectionTelemetryActive) return String(configuredConnections);
// yt-dlp exposes the configured fragment limit through Firelink, but its
// progress stream does not expose a reliable active-worker count. Keep
// the selected limit visible without presenting it as an active count.
if (item.isMedia) {
return t($ => $.properties.connectionCountUnknown, {
total: configuredConnections,
});
}
if (typeof observedActiveConnections === 'number') {
return t($ => $.properties.connectionCount, {
active: observedActiveConnections,
total: observedConnectionTotal,
});
}
if (item.status === 'downloading') {
return t($ => $.properties.connectionCountUnknown, { total: observedConnectionTotal });
}
return t($ => $.properties.connectionCount, {
active: 0,
total: observedConnectionTotal,
});
})();
const displayedFraction = item.status === 'completed'
? 1
: liveProgress?.fraction ?? item.fraction ?? 0;
@@ -211,9 +257,22 @@ export const PropertiesModal = () => {
});
const hasDownloadedAmount = item.status !== 'completed' &&
Boolean(sizeDisplay.downloaded && sizeDisplay.total);
const completedSizeLabel = item.status === 'completed'
? formatDownloadTotal(sizeDisplay)
: sizeDisplay.fallback;
const completedSizeLabel = (() => {
const value = item.status === 'completed' ? formatDownloadTotal(sizeDisplay) : sizeDisplay.fallback;
return value === 'Unknown' ? t($ => $.addDownloads.unknown) : value;
})();
const statusLabel = t($ => $.downloads.status[item.status]);
const sizeDescription = sizeDisplay.totalIsEstimate
? t($ => $.downloads.size.downloadedOfApproximate, {
downloaded: sizeDisplay.downloaded ?? '',
total: sizeDisplay.total ?? '',
unit: sizeDisplay.unit ?? '',
})
: t($ => $.downloads.size.downloadedOf, {
downloaded: sizeDisplay.downloaded ?? '',
total: sizeDisplay.total ?? '',
unit: sizeDisplay.unit ?? '',
});
let statusColor = 'text-text-secondary';
let StatusIcon = Info;
@@ -240,7 +299,7 @@ export const PropertiesModal = () => {
<h2 className="text-base font-semibold truncate text-text-primary pr-4">{item.fileName}</h2>
<span className={`flex items-center gap-1.5 text-xs font-semibold tracking-wide uppercase ${statusColor}`}>
<StatusIcon size={14} />
{item.status}
{statusLabel}
</span>
</div>
@@ -249,13 +308,13 @@ export const PropertiesModal = () => {
</div>
<div className="grid grid-cols-4 gap-y-2 gap-x-4 text-[11px] leading-tight">
<div className="flex gap-1.5 min-w-0"><span className="text-text-muted font-medium w-[90px] shrink-0">Progress</span><span className="text-text-secondary truncate">{`${(displayedFraction * 100).toFixed(0)}%`}</span></div>
<div className="flex gap-1.5 min-w-0"><span className="text-text-muted font-medium w-[90px] shrink-0">{t($ => $.properties.progress)}</span><span className="text-text-secondary truncate">{`${(displayedFraction * 100).toFixed(0)}%`}</span></div>
<div className="flex gap-1.5 min-w-0">
<span className="text-text-muted font-medium w-[40px] shrink-0">Size</span>
<span className="text-text-muted font-medium w-[40px] shrink-0">{t($ => $.properties.size)}</span>
<span
className="truncate"
title={hasDownloadedAmount
? `${sizeDisplay.downloaded} downloaded of ${sizeDisplay.totalIsEstimate ? 'approximately ' : ''}${sizeDisplay.total} ${sizeDisplay.unit}`
? sizeDescription
: completedSizeLabel}
>
{hasDownloadedAmount ? (
@@ -269,19 +328,19 @@ export const PropertiesModal = () => {
) : completedSizeLabel}
</span>
</div>
<div className="flex gap-1.5 min-w-0"><span className="text-text-muted font-medium w-[40px] shrink-0">Speed</span><span className="text-text-secondary truncate">{displayedSpeed}</span></div>
<div className="flex gap-1.5 min-w-0"><span className="text-text-muted font-medium w-[30px] shrink-0">ETA</span><span className="text-text-secondary truncate">{displayedEta}</span></div>
<div className="flex gap-1.5 min-w-0"><span className="text-text-muted font-medium w-[40px] shrink-0">{t($ => $.properties.speed)}</span><span className="text-text-secondary truncate">{displayedSpeed}</span></div>
<div className="flex gap-1.5 min-w-0"><span className="text-text-muted font-medium w-[30px] shrink-0">{t($ => $.properties.eta)}</span><span className="text-text-secondary truncate">{displayedEta}</span></div>
<div className="flex gap-1.5 min-w-0"><span className="text-text-muted font-medium w-[90px] shrink-0">Connections</span><span className="text-text-secondary truncate" title={item.connections !== undefined ? 'Saved for this download; Settings changes apply to new downloads.' : 'Using the current default for new downloads.'}>{resolveDownloadConnections(item.connections, perServerConnections)}{item.connections !== undefined ? ' (saved)' : ' (default)'}</span></div>
<div className="flex gap-1.5 min-w-0"><span className="text-text-muted font-medium w-[60px] shrink-0">Speed cap</span><span className="text-text-secondary truncate">{item.speedLimit || '-'}</span></div>
<div className="flex gap-1.5 min-w-0"><span className="text-text-muted font-medium w-[55px] shrink-0">Category</span><span className="text-text-secondary truncate">{item.category}</span></div>
<div className="flex gap-1.5"><span className="text-text-muted font-medium w-[50px]">Last try</span><span className="text-text-secondary truncate">{formatLastTry(item.lastTry)}</span></div>
<div className="flex gap-1.5 min-w-0"><span className="text-text-muted font-medium shrink-0 whitespace-nowrap">{t($ => $.properties.connections)}</span><span className="text-text-secondary truncate whitespace-nowrap" title={item.connections !== undefined ? t($ => $.properties.savedTooltip) : t($ => $.properties.defaultTooltip)}><bdi>{connectionStatus}</bdi></span></div>
<div className="flex gap-1.5 min-w-0"><span className="text-text-muted font-medium w-[60px] shrink-0">{t($ => $.properties.speedCap)}</span><span className="text-text-secondary truncate">{item.speedLimit || '-'}</span></div>
<div className="flex gap-1.5 min-w-0"><span className="text-text-muted font-medium w-[55px] shrink-0">{t($ => $.properties.category)}</span><span className="text-text-secondary truncate">{categoryLabel(item.category)}</span></div>
<div className="flex gap-1.5"><span className="text-text-muted font-medium w-[50px]">{t($ => $.properties.lastTry)}</span><span className="text-text-secondary truncate">{formatLastTry(item.lastTry)}</span></div>
<div className="flex gap-1.5 col-span-2"><span className="text-text-muted font-medium w-[90px]">Date added</span><span className="text-text-secondary truncate">{new Date(item.dateAdded).toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short' })}</span></div>
<div className="flex gap-1.5 col-span-2"><span className="text-text-muted font-medium w-[70px]">Destination</span><span className="text-text-secondary truncate" title={saveLocation}>{saveLocation || baseDownloadFolder}</span></div>
<div className="flex gap-1.5 col-span-2"><span className="text-text-muted font-medium w-[90px]">{t($ => $.properties.dateAdded)}</span><span className="text-text-secondary truncate">{new Date(item.dateAdded).toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short' })}</span></div>
<div className="flex gap-1.5 col-span-2"><span className="text-text-muted font-medium w-[70px]">{t($ => $.properties.destination)}</span><span className="text-text-secondary truncate" title={saveLocation}>{saveLocation || baseDownloadFolder}</span></div>
{item.lastError && (item.status === 'failed' || item.status === 'retrying') && (
<div className="flex gap-1.5 col-span-4 min-w-0">
<span className="text-text-muted font-medium w-[90px] shrink-0">Last error</span>
<span className="text-text-muted font-medium w-[90px] shrink-0">{t($ => $.properties.lastError)}</span>
<span className="text-red-400 truncate" title={item.lastError}>{item.lastError}</span>
</div>
)}
@@ -298,53 +357,54 @@ export const PropertiesModal = () => {
{item.status === 'completed' ? <CheckCircle size={16} className="text-green-500" /> : <AlertCircle size={16} className="text-blue-500" />}
<span>
{item.status === 'completed'
? "File identity is read-only. Transfer settings are saved for redownload."
: "Transfer settings can be changed after stopping or pausing. Current transfers keep their existing backend options."}
? t($ => $.properties.identityReadOnly)
: t($ => $.properties.transferSettings)}
</span>
</div>
)}
{/* Download Section */}
<section>
<h3 className="text-sm font-semibold text-text-primary mb-4 pb-1 border-b border-border-modal/50">Download</h3>
<h3 className="text-sm font-semibold text-text-primary mb-4 pb-1 border-b border-border-modal/50">{t($ => $.properties.download)}</h3>
<div className="grid grid-cols-[100px_1fr] gap-y-3.5 gap-x-4 items-center">
<label className="text-xs text-text-muted text-right">URL</label>
<label className="text-xs text-text-muted text-right">{t($ => $.properties.url)}</label>
<input type="text" value={url} onChange={e => setUrl(e.target.value)} disabled={identityLocked} className="bg-bg-input border border-border-modal rounded-lg px-2.5 py-1.5 text-xs text-text-primary font-mono focus:outline-none focus:border-accent disabled:opacity-50" />
<label className="text-xs text-text-muted text-right">File name</label>
<label className="text-xs text-text-muted text-right">{t($ => $.properties.fileName)}</label>
<input type="text" value={fileName} onChange={e => setFileName(e.target.value)} disabled={identityLocked} className="bg-bg-input border border-border-modal rounded-lg px-2.5 py-1.5 text-xs text-text-primary focus:outline-none focus:border-accent disabled:opacity-50" />
<label className="text-xs text-text-muted text-right">Save location</label>
<label className="text-xs text-text-muted text-right">{t($ => $.properties.saveLocation)}</label>
<div className="flex gap-2">
<input type="text" value={saveLocation} readOnly disabled={identityLocked} className="flex-1 bg-bg-input border border-border-modal rounded-lg px-2.5 py-1.5 text-xs text-text-primary font-mono focus:outline-none focus:border-accent disabled:opacity-50" />
<button onClick={handleBrowse} disabled={identityLocked} className="bg-item-hover hover:bg-item-hover/80 text-text-primary border border-border-modal px-3 py-1.5 rounded text-xs transition-colors disabled:opacity-40 flex items-center gap-1.5">
<FolderPlus size={14} /> Select
<FolderPlus size={14} /> {t($ => $.properties.select)}
</button>
</div>
<label className="text-xs text-text-muted text-right">Connections</label>
<label className="text-xs text-text-muted text-right">{t($ => $.properties.connections)}</label>
<div className="flex items-center gap-2">
<input type="number" value={connections} min={1} max={16} onChange={e=>{ setConnections(Number(e.target.value)); 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 && (
<button
type="button"
onClick={() => { setConnections(perServerConnections); setConnectionsDirty(true); }}
className="text-[11px] text-accent hover:underline whitespace-nowrap"
>
Use current default ({perServerConnections})
{t($ => $.properties.useCurrentDefault, { count: perServerConnections })}
</button>
)}
</div>
<div className="col-start-2 text-[11px] text-text-muted">
Saved per download. The Settings default applies to new downloads.
{t($ => $.properties.savedPerDownload)}
</div>
<label className="text-xs text-text-muted text-right">Speed</label>
<label className="text-xs text-text-muted text-right">{t($ => $.properties.speed)}</label>
<div className="flex items-center gap-3">
<label className="flex items-center gap-2 text-xs text-text-primary">
<input type="checkbox" checked={speedLimitEnabled} onChange={e => setSpeedLimitEnabled(e.target.checked)} disabled={transferLocked} className="rounded border-border-modal text-blue-500 focus:ring-blue-500/20 bg-bg-input disabled:opacity-50" />
Limit
{t($ => $.properties.limit)}
</label>
{speedLimitEnabled && (
<div className="flex items-center gap-2">
@@ -359,7 +419,7 @@ export const PropertiesModal = () => {
{/* Site Login Section */}
<section>
<h3 className="text-sm font-semibold text-text-primary mb-4 pb-1 border-b border-border-modal/50">
{item.status === 'completed' ? 'Site Login for Redownload' : 'Site Login'}
{item.status === 'completed' ? t($ => $.properties.siteLoginRedownload) : t($ => $.properties.siteLogin)}
</h3>
<div className="flex gap-1 p-1 bg-border-color rounded-lg mb-4 w-fit mx-auto md:mx-0">
@@ -370,7 +430,7 @@ export const PropertiesModal = () => {
disabled={transferLocked}
className={`px-3 py-1.5 rounded-md text-xs font-medium transition-colors disabled:opacity-50 ${loginMode === mode ? 'bg-bg-modal text-text-primary shadow-sm' : 'text-text-muted hover:text-text-secondary'}`}
>
{mode === 'matching' ? 'Matching site login' : mode === 'custom' ? 'Custom credentials' : 'No login'}
{mode === 'matching' ? t($ => $.properties.matchingSiteLogin) : mode === 'custom' ? t($ => $.properties.customCredentials) : t($ => $.properties.noLogin)}
</button>
))}
</div>
@@ -378,16 +438,16 @@ export const PropertiesModal = () => {
<div className="grid grid-cols-[100px_1fr] gap-y-3.5 gap-x-4 items-center">
{loginMode === 'matching' && (
<div className="col-start-2 text-xs text-text-secondary italic">
Will use saved login if available.
{t($ => $.properties.useSavedLogin)}
</div>
)}
{loginMode === 'custom' && (
<>
<label className="text-xs text-text-muted text-right">Username</label>
<input type="text" value={username} onChange={e=>setUsername(e.target.value)} disabled={transferLocked} placeholder="Username" className="max-w-[250px] bg-bg-input border border-border-modal rounded-lg px-2.5 py-1.5 text-xs text-text-primary focus:outline-none focus:border-accent disabled:opacity-50" />
<label className="text-xs text-text-muted text-right">{t($ => $.properties.username)}</label>
<input type="text" value={username} onChange={e=>setUsername(e.target.value)} disabled={transferLocked} placeholder={t($ => $.properties.username)} className="max-w-[250px] bg-bg-input border border-border-modal rounded-lg px-2.5 py-1.5 text-xs text-text-primary focus:outline-none focus:border-accent disabled:opacity-50" />
<label className="text-xs text-text-muted text-right">Password</label>
<input type="password" value={password} onChange={e=>setPassword(e.target.value)} disabled={transferLocked} placeholder="Password" className="max-w-[250px] bg-bg-input border border-border-modal rounded-lg px-2.5 py-1.5 text-xs text-text-primary focus:outline-none focus:border-accent disabled:opacity-50" />
<label className="text-xs text-text-muted text-right">{t($ => $.properties.password)}</label>
<input type="password" value={password} onChange={e=>setPassword(e.target.value)} disabled={transferLocked} placeholder={t($ => $.properties.password)} className="max-w-[250px] bg-bg-input border border-border-modal rounded-lg px-2.5 py-1.5 text-xs text-text-primary focus:outline-none focus:border-accent disabled:opacity-50" />
</>
)}
</div>
@@ -400,20 +460,20 @@ export const PropertiesModal = () => {
className="flex items-center gap-2 text-sm font-semibold text-text-primary w-full pb-1 border-b border-border-modal/50 hover:text-blue-400 transition-colors"
>
{advancedExpanded ? <ChevronDown size={16} /> : <ChevronRight size={16} />}
{item.status === 'completed' ? 'Advanced Transfer for Redownload' : 'Advanced Transfer'}
{item.status === 'completed' ? t($ => $.properties.advancedTransferRedownload) : t($ => $.properties.advancedTransfer)}
</button>
{advancedExpanded && (
<div className="mt-4 grid grid-cols-[100px_1fr] gap-y-3.5 gap-x-4 items-center pl-6">
<label className="text-xs text-text-muted text-right">Checksum</label>
<label className="text-xs text-text-muted text-right">{t($ => $.properties.checksum)}</label>
<label className="flex items-center gap-2 text-xs text-text-primary">
<input type="checkbox" checked={checksumEnabled} onChange={e => setChecksumEnabled(e.target.checked)} disabled={transferLocked} className="rounded border-border-modal text-blue-500 focus:ring-blue-500/20 bg-bg-input" />
Verify
{t($ => $.properties.verify)}
</label>
{checksumEnabled && (
<>
<label className="text-xs text-text-muted text-right">Algorithm</label>
<label className="text-xs text-text-muted text-right">{t($ => $.properties.algorithm)}</label>
<select value={checksumAlgorithm} onChange={e=>setChecksumAlgorithm(e.target.value)} disabled={transferLocked} className="max-w-[150px] bg-bg-input border border-border-modal rounded-lg px-2.5 py-1.5 text-xs text-text-primary focus:outline-none focus:border-accent disabled:opacity-50">
<option value="MD5">MD5</option>
<option value="SHA-1">SHA-1</option>
@@ -421,21 +481,21 @@ export const PropertiesModal = () => {
<option value="SHA-512">SHA-512</option>
</select>
<label className="text-xs text-text-muted text-right">Digest</label>
<input type="text" value={checksumValue} onChange={e=>setChecksumValue(e.target.value)} disabled={transferLocked} placeholder="Expected digest" className="bg-bg-input border border-border-modal rounded-lg px-2.5 py-1.5 text-xs text-text-primary font-mono focus:outline-none focus:border-accent disabled:opacity-50" />
<label className="text-xs text-text-muted text-right">{t($ => $.properties.digest)}</label>
<input type="text" value={checksumValue} onChange={e=>setChecksumValue(e.target.value)} disabled={transferLocked} placeholder={t($ => $.properties.expectedDigest)} className="bg-bg-input border border-border-modal rounded-lg px-2.5 py-1.5 text-xs text-text-primary font-mono focus:outline-none focus:border-accent disabled:opacity-50" />
</>
)}
<label className="text-xs text-text-muted text-right">Cookies</label>
<input type="text" value={cookies} onChange={e=>setCookies(e.target.value)} disabled={transferLocked} placeholder="Cookies" className="bg-bg-input border border-border-modal rounded-lg px-2.5 py-1.5 text-xs text-text-primary font-mono focus:outline-none focus:border-accent disabled:opacity-50" />
<label className="text-xs text-text-muted text-right">{t($ => $.properties.cookies)}</label>
<input type="password" value={cookies} onChange={e=>setCookies(e.target.value)} disabled={transferLocked} autoComplete="off" placeholder={t($ => $.properties.cookies)} className="bg-bg-input border border-border-modal rounded-lg px-2.5 py-1.5 text-xs text-text-primary font-mono focus:outline-none focus:border-accent disabled:opacity-50" />
<div className="col-span-2 mt-2">
<label className="block text-xs text-text-muted mb-1.5">Headers</label>
<label className="block text-xs text-text-muted mb-1.5">{t($ => $.properties.headers)}</label>
<textarea value={headers} onChange={e=>setHeaders(e.target.value)} disabled={transferLocked} className="w-full h-16 bg-bg-input border border-border-modal rounded-lg px-2.5 py-1.5 text-xs text-text-primary font-mono focus:outline-none focus:border-accent disabled:opacity-50 resize-none"></textarea>
</div>
<div className="col-span-2">
<label className="block text-xs text-text-muted mb-1.5">Mirrors</label>
<label className="block text-xs text-text-muted mb-1.5">{t($ => $.properties.mirrors)}</label>
<textarea value={mirrors} onChange={e=>setMirrors(e.target.value)} disabled={transferLocked} className="w-full h-16 bg-bg-input border border-border-modal rounded-lg px-2.5 py-1.5 text-xs text-text-primary font-mono focus:outline-none focus:border-accent disabled:opacity-50 resize-none"></textarea>
</div>
</div>
@@ -456,7 +516,7 @@ export const PropertiesModal = () => {
onClick={() => setSelectedPropertiesDownloadId(null)}
className="app-button px-4 text-xs"
>
Cancel
{t($ => $.properties.cancel)}
</button>
<button
onClick={handleSave}
@@ -464,7 +524,7 @@ export const PropertiesModal = () => {
className={`app-button app-button-primary px-4 text-xs ${transferLocked ? 'opacity-50 cursor-not-allowed' : ''}`}
>
<CheckCircle size={14} />
Save
{t($ => $.properties.save)}
</button>
</div>
</div>
+84 -64
View File
@@ -10,22 +10,23 @@ import { isActiveDownloadStatus } from '../utils/downloads';
import { WindowDragRegion } from './WindowDragRegion';
import { useToast } from '../contexts/ToastContext';
import { usePlatformInfo } from '../utils/platform';
import { useTranslation } from 'react-i18next';
const days = [
{ value: 0, label: 'Su' },
{ value: 1, label: 'Mo' },
{ value: 2, label: 'Tu' },
{ value: 3, label: 'We' },
{ value: 4, label: 'Th' },
{ value: 5, label: 'Fr' },
{ value: 6, label: 'Sa' },
];
{ value: 0, key: 'su' },
{ value: 1, key: 'mo' },
{ value: 2, key: 'tu' },
{ value: 3, key: 'we' },
{ value: 4, key: 'th' },
{ value: 5, key: 'fr' },
{ value: 6, key: 'sa' },
] as const;
const postActions: { value: PostQueueAction; label: string; icon: typeof Moon }[] = [
{ value: 'none', label: 'Do nothing', icon: CheckCircle2 },
{ value: 'sleep', label: 'Sleep', icon: Moon },
{ value: 'restart', label: 'Restart', icon: RotateCcw },
{ value: 'shutdown', label: 'Shut down', icon: Power },
const postActions: { value: PostQueueAction; icon: typeof Moon }[] = [
{ value: 'none', icon: CheckCircle2 },
{ value: 'sleep', icon: Moon },
{ value: 'restart', icon: RotateCcw },
{ value: 'shutdown', icon: Power },
];
const minuteOfDay = (value: string) => {
@@ -33,8 +34,8 @@ const minuteOfDay = (value: string) => {
return hour * 60 + minute;
};
function nextScheduledRun(settings: SchedulerSettings): string {
if (!settings.enabled) return 'Scheduler is disabled';
function nextScheduledRun(settings: SchedulerSettings): Date | 'disabled' | 'none' {
if (!settings.enabled) return 'disabled';
const [hour, minute] = settings.startTime.split(':').map(Number);
const now = new Date();
@@ -45,20 +46,15 @@ function nextScheduledRun(settings: SchedulerSettings): string {
candidate.setHours(hour, minute, 0, 0);
const allowedDay = settings.everyday || settings.selectedDays.includes(candidate.getDay());
if (allowedDay && candidate > now) {
return candidate.toLocaleString(undefined, {
weekday: 'short',
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: '2-digit'
});
return candidate;
}
}
return 'No scheduled day selected';
return 'none';
}
export default function SchedulerView() {
const { t } = useTranslation();
const savedSettings = useSettingsStore(state => state.scheduler);
const schedulerRunning = useSettingsStore(state => state.schedulerRunning);
const setScheduler = useSettingsStore(state => state.setScheduler);
@@ -75,7 +71,18 @@ export default function SchedulerView() {
}, [savedSettings]);
const nextRun = useMemo(() => nextScheduledRun(draft), [draft]);
const nextRun = useMemo(() => {
const scheduledRun = nextScheduledRun(draft);
if (scheduledRun === 'disabled') return t($ => $.scheduler.disabled);
if (scheduledRun === 'none') return t($ => $.scheduler.noScheduledDay);
return scheduledRun.toLocaleString(undefined, {
weekday: 'short',
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: '2-digit'
});
}, [draft, t]);
const hasUnsavedChanges = useMemo(
() => JSON.stringify(draft) !== JSON.stringify(savedSettings),
[draft, savedSettings]
@@ -116,15 +123,15 @@ export default function SchedulerView() {
const save = () => {
if (draft.enabled && !draft.everyday && draft.selectedDays.length === 0) {
addToast({ message: 'Select at least one day for the scheduler', variant: 'error', isActionable: true });
addToast({ message: t($ => $.scheduler.validationDay), variant: 'error', isActionable: true });
return;
}
if (draft.enabled && effectiveSelectedQueueIds.length === 0) {
addToast({ message: 'Select at least one queue for the scheduler', variant: 'error', isActionable: true });
addToast({ message: t($ => $.scheduler.validationQueue), variant: 'error', isActionable: true });
return;
}
if (draft.enabled && draft.stopTimeEnabled && minuteOfDay(draft.stopTime) <= minuteOfDay(draft.startTime)) {
addToast({ message: 'Stop time must be later than start time', variant: 'error', isActionable: true });
if (draft.enabled && draft.stopTimeEnabled && minuteOfDay(draft.stopTime) === minuteOfDay(draft.startTime)) {
addToast({ message: t($ => $.scheduler.validationStopTime), variant: 'error', isActionable: true });
return;
}
const normalized = {
@@ -134,7 +141,7 @@ export default function SchedulerView() {
};
setScheduler(normalized);
setDraft(normalized);
addToast({ message: 'Scheduler settings saved', variant: 'success' });
addToast({ message: t($ => $.scheduler.saved), variant: 'success' });
};
const runNow = async () => {
@@ -155,9 +162,14 @@ export default function SchedulerView() {
if (activeIds.length > 0) {
useSettingsStore.getState().setSchedulerRunning(true);
useSettingsStore.getState().setSchedulerActiveDownloadIds(activeIds);
addToast({ message: `Tracking ${activeIds.length} scheduled download${activeIds.length === 1 ? '' : 's'}`, variant: 'success' });
addToast({
message: activeIds.length === 1
? t($ => $.scheduler.trackingOne)
: t($ => $.scheduler.trackingMany, { count: activeIds.length }),
variant: 'success'
});
} else {
addToast({ message: 'No downloads in the selected queues can be started', variant: 'info' });
addToast({ message: t($ => $.scheduler.noStartableDownloads), variant: 'info' });
}
};
@@ -168,7 +180,14 @@ export default function SchedulerView() {
const count = counts.reduce((total, queueCount) => total + queueCount, 0);
useSettingsStore.getState().setSchedulerRunning(false);
useSettingsStore.getState().setSchedulerActiveDownloadIds([]);
addToast({ message: count > 0 ? `Paused ${count} active download${count === 1 ? '' : 's'}` : 'No active downloads', variant: 'info' });
addToast({
message: count === 0
? t($ => $.scheduler.noActiveDownloads)
: count === 1
? t($ => $.scheduler.pausedOne)
: t($ => $.scheduler.pausedMany, { count }),
variant: 'info'
});
};
const refreshPermissionStatus = useCallback(async (showMessage = false) => {
@@ -178,12 +197,12 @@ export default function SchedulerView() {
await invoke('check_automation_permission');
setAutomationPermissionGranted(true);
if (showMessage) {
setPermissionMessage('Automation permission is available.');
setPermissionMessage(t($ => $.scheduler.permissionAvailable));
}
} catch {
setAutomationPermissionGranted(false);
if (showMessage) {
setPermissionMessage('Automation permission is missing. Enable Firelink under Automation for System Events in System Settings.');
setPermissionMessage(t($ => $.scheduler.permissionMissingDetails));
}
}
}, [isMac]);
@@ -222,18 +241,18 @@ export default function SchedulerView() {
const handlePermissionAction = async () => {
if (automationPermissionGranted) {
await openAutomationSettings('macOS does not allow Firelink to revoke Automation permission directly. Revoke System Events access in System Settings, then return to Firelink.');
await openAutomationSettings(t($ => $.scheduler.revokePermissionDetails));
return;
}
setPermissionMessage('Requesting Automation permission...');
setPermissionMessage(t($ => $.scheduler.requestingPermission));
try {
await invoke('request_automation_permission');
setAutomationPermissionGranted(true);
setPermissionMessage('Automation permission is available.');
setPermissionMessage(t($ => $.scheduler.permissionAvailable));
} catch {
setAutomationPermissionGranted(false);
await openAutomationSettings('Enable Firelink under Automation for System Events in System Settings, then return to Firelink.');
await openAutomationSettings(t($ => $.scheduler.enablePermissionDetails));
}
};
@@ -253,27 +272,27 @@ export default function SchedulerView() {
className={`inline-block h-4 w-4 transform rounded-full bg-white transition duration-200 ease-in-out ${draft.enabled ? 'translate-x-4' : 'translate-x-1'}`}
/>
</button>
Scheduler
{t($ => $.scheduler.title)}
</div>
<span className={`rounded-full px-2.5 py-1 text-[11px] font-semibold ${
schedulerRunning ? 'bg-green-500/15 text-green-500' : 'bg-item-hover text-text-muted'
}`}>
{schedulerRunning ? 'Running' : nextRun}
{schedulerRunning ? t($ => $.scheduler.running) : nextRun}
</span>
{hasUnsavedChanges && (
<span className="rounded-full bg-orange-500/10 px-2.5 py-1 text-[11px] font-semibold text-orange-300">
Unsaved changes
{t($ => $.scheduler.unsavedChanges)}
</span>
)}
<div className="ml-auto flex gap-2">
<button onClick={runNow} className="app-button px-3 text-[11px]">
<Play size={14} /> Run Now
<Play size={14} /> {t($ => $.scheduler.runNow)}
</button>
<button onClick={pauseNow} className="app-button px-3 text-[11px]">
<Pause size={14} /> Pause
<Pause size={14} /> {t($ => $.scheduler.pause)}
</button>
<button onClick={save} className="app-button app-button-primary px-3 text-[11px]">
<Save size={14} /> Save Settings
<Save size={14} /> {t($ => $.scheduler.saveSettings)}
</button>
</div>
</div>
@@ -282,29 +301,29 @@ export default function SchedulerView() {
<div className={`max-w-[760px] space-y-4 ${draft.enabled ? '' : 'opacity-50'}`}>
<section className="app-card p-5">
<div className="mb-5 flex items-center gap-2 font-semibold text-text-primary">
<Clock3 size={17} className="text-accent" /> Timing
<Clock3 size={17} className="text-accent" /> {t($ => $.scheduler.timing)}
</div>
<div className="flex flex-wrap items-end gap-8">
<label className="space-y-2 text-[12px] text-text-secondary">
<span className="block">Start Time</span>
<span className="block">{t($ => $.scheduler.startTime)}</span>
<input type="time" value={draft.startTime} onChange={event => updateDraft('startTime', event.target.value)} disabled={!draft.enabled} className="app-control px-3 py-2 text-text-primary" />
</label>
<div className="space-y-2">
<label className="flex items-center gap-2 text-[12px] text-text-secondary">
<input type="checkbox" checked={draft.stopTimeEnabled} onChange={event => updateDraft('stopTimeEnabled', event.target.checked)} disabled={!draft.enabled} className="accent-accent" />
Stop Time
{t($ => $.scheduler.stopTime)}
</label>
<input type="time" value={draft.stopTime} onChange={event => updateDraft('stopTime', event.target.value)} disabled={!draft.enabled || !draft.stopTimeEnabled} className="app-control px-3 py-2 text-text-primary disabled:opacity-50" />
</div>
</div>
<p className="mt-4 text-[11px] text-text-muted">
If Firelink is asleep at the start time, it starts the selected queues when it returns later that day, unless the stop time has already passed.
{t($ => $.scheduler.timingDescription)}
</p>
<div className="my-5 border-t border-border-color" />
<label className="flex items-center gap-2 text-[13px] font-medium text-text-primary">
<input type="checkbox" checked={draft.everyday} onChange={event => updateDraft('everyday', event.target.checked)} disabled={!draft.enabled} className="accent-accent" />
Run Every Day
{t($ => $.scheduler.runEveryDay)}
</label>
{!draft.everyday && (
<div className="mt-4 flex gap-2">
@@ -320,7 +339,7 @@ export default function SchedulerView() {
selected ? 'bg-accent text-white' : 'bg-bg-input text-text-primary hover:bg-item-hover'
}`}
>
{day.label}
{t($ => $.scheduler.days[day.key])}
</button>
);
})}
@@ -330,7 +349,7 @@ export default function SchedulerView() {
<section className="app-card p-5">
<div className="mb-4 flex items-center gap-2 font-semibold text-text-primary">
<List size={17} className="text-accent" /> Queues to Schedule
<List size={17} className="text-accent" /> {t($ => $.scheduler.queuesToSchedule)}
</div>
<div className="space-y-3">
{queues.map(queue => {
@@ -346,7 +365,7 @@ export default function SchedulerView() {
/>
{queue.name}
{queue.isMain && (
<span className="text-[11px] text-text-muted">Default queue</span>
<span className="text-[11px] text-text-muted">{t($ => $.scheduler.defaultQueue)}</span>
)}
</label>
);
@@ -356,9 +375,9 @@ export default function SchedulerView() {
<section className="app-card p-5">
<div className="mb-2 flex items-center gap-2 font-semibold text-text-primary">
<Power size={17} className="text-accent" /> After Completion
<Power size={17} className="text-accent" /> {t($ => $.scheduler.afterCompletion)}
</div>
<p className="mb-4 text-[12px] text-text-muted">Choose what happens after downloads started by the scheduler finish.</p>
<p className="mb-4 text-[12px] text-text-muted">{t($ => $.scheduler.afterCompletionDescription)}</p>
<div className="grid grid-cols-2 gap-2">
{postActions.map(action => {
const Icon = action.icon;
@@ -368,13 +387,13 @@ export default function SchedulerView() {
}`}>
<input type="radio" name="post-action" checked={draft.postQueueAction === action.value} onChange={() => updateDraft('postQueueAction', action.value)} disabled={!draft.enabled} className="accent-accent" />
<Icon size={15} />
{action.label}
{t($ => $.scheduler.postActions[action.value])}
</label>
);
})}
</div>
{draft.postQueueAction !== 'none' && (
<p className="mt-3 text-[11px] text-orange-400">This action can interrupt other work on the computer. Firelink invokes it immediately after the scheduled queue finishes.</p>
<p className="mt-3 text-[11px] text-orange-400">{t($ => $.scheduler.actionWarning)}</p>
)}
</section>
</div>
@@ -382,27 +401,27 @@ export default function SchedulerView() {
{isMac ? (
<section className="app-card mt-4 max-w-[760px] p-5">
<div className="mb-2 flex items-center gap-2 font-semibold text-text-primary">
<LockKeyhole size={17} className="text-accent" /> System Permissions
<LockKeyhole size={17} className="text-accent" /> {t($ => $.scheduler.systemPermissions)}
</div>
<p className="mb-4 text-[12px] text-text-muted">Sleep, restart, and shut down require macOS Automation permission for System Events.</p>
<p className="mb-4 text-[12px] text-text-muted">{t($ => $.scheduler.macPermissionDescription)}</p>
<div className="mb-4 flex items-center gap-2 text-[12px]">
{automationPermissionGranted ? (
<>
<CheckCircle2 size={16} className="text-green-500" />
<span className="font-medium text-green-500">Automation permission granted</span>
<span className="font-medium text-green-500">{t($ => $.scheduler.permissionGranted)}</span>
</>
) : (
<>
<AlertCircle size={16} className="text-orange-400" />
<span className="font-medium text-orange-400">
{automationPermissionGranted === null ? 'Checking Automation permission...' : 'Automation permission missing'}
{automationPermissionGranted === null ? t($ => $.scheduler.permissionChecking) : t($ => $.scheduler.permissionMissing)}
</span>
</>
)}
</div>
<div className="flex gap-2">
<button onClick={handlePermissionAction} className="app-button app-button-primary px-3 text-[11px]">
{automationPermissionGranted ? 'Revoke permission' : 'Grant permission'}
{automationPermissionGranted ? t($ => $.scheduler.revokePermission) : t($ => $.scheduler.grantPermission)}
</button>
</div>
{permissionMessage && <p className="mt-3 text-[11px] text-text-muted">{permissionMessage}</p>}
@@ -410,11 +429,12 @@ export default function SchedulerView() {
) : (
<section className="app-card mt-4 max-w-[760px] p-5">
<div className="mb-2 flex items-center gap-2 font-semibold text-text-primary">
<LockKeyhole size={17} className="text-accent" /> System Actions
<LockKeyhole size={17} className="text-accent" /> {t($ => $.scheduler.systemActions)}
</div>
<p className="text-[12px] text-text-muted">
Sleep, restart, and shut down use {platform.os === 'windows' ? 'Windows system privileges' : 'your Linux desktop and system policy'}.
Firelink reports any rejected action when it runs; no permanent permission is claimed in advance.
{platform.os === 'windows'
? t($ => $.scheduler.windowsActionsDescription)
: t($ => $.scheduler.linuxActionsDescription)}
</p>
</section>
)}
File diff suppressed because it is too large Load Diff
+173 -81
View File
@@ -11,6 +11,7 @@ import { ActiveView, useSettingsStore } from '../store/useSettingsStore';
import { WindowDragRegion } from './WindowDragRegion';
import { useToast } from '../contexts/ToastContext';
import { isTransferActiveStatus } from '../utils/downloads';
import { useTranslation } from 'react-i18next';
export type SidebarFilter = 'all' | 'active' | 'completed' | 'unfinished' | DownloadCategory | 'settings' | string;
@@ -24,6 +25,7 @@ export const Sidebar: React.FC<SidebarProps> = (props) => {
const { downloads, queues, addQueue, renameQueue, removeQueue, startQueue, pauseQueue } = useDownloadStore();
const { activeView, setActiveView, toggleSidebar } = useSettingsStore();
const { addToast } = useToast();
const { t } = useTranslation();
const [isAddingQueue, setIsAddingQueue] = useState(false);
const [newQueueName, setNewQueueName] = useState('');
@@ -39,7 +41,13 @@ export const Sidebar: React.FC<SidebarProps> = (props) => {
const addInputRef = useRef<HTMLInputElement>(null);
const renameInputRef = useRef<HTMLInputElement>(null);
const addQueueSubmitRef = useRef(false);
const addQueueCancelRef = useRef(false);
const renameQueueSubmitRef = useRef(false);
const renameQueueCancelRef = useRef<string | null>(null);
const renamingQueueIdRef = useRef<string | null>(null);
const editingQueueNameRef = useRef('');
const rejectedAddQueueNameRef = useRef<string | null>(null);
const rejectedRenameRef = useRef<{ queueId: string; name: string } | null>(null);
useEffect(() => {
const handleCloseMenu = () => setContextMenu(null);
@@ -72,6 +80,13 @@ export const Sidebar: React.FC<SidebarProps> = (props) => {
}
}, [foldersCollapsed]);
const handleFoldersToggle = () => {
if (foldersListRef.current?.contains(document.activeElement)) {
foldersToggleRef.current?.focus();
}
setFoldersCollapsed(collapsed => !collapsed);
};
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (document.querySelector('.app-modal-backdrop') || document.querySelector('.app-modal')) return;
@@ -80,15 +95,19 @@ export const Sidebar: React.FC<SidebarProps> = (props) => {
if (!isInput && (e.key === 'Delete' || e.key === 'Backspace')) {
if (activeEl && activeEl.closest('.sidebar-inner')) {
if (activeView === 'downloads' && selectedFilter.startsWith('queue:')) {
const queueId = selectedFilter.replace('queue:', '');
const focusedQueueId = activeEl
.closest<HTMLElement>('[data-sidebar-queue-id]')
?.dataset.sidebarQueueId;
if (activeView === 'downloads' && focusedQueueId) {
const queueId = focusedQueueId;
const q = queues.find(q => q.id === queueId);
if (q && !q.isMain) {
if (!window.confirm(`Delete queue "${q.name}"? Its unfinished downloads will move to Main Queue.`)) {
e.preventDefault();
if (!window.confirm(t($ => $.sidebar.deleteQueueConfirm, { name: q.name }))) {
return;
}
void removeQueue(queueId).catch(error => {
addToast({ message: `Could not delete queue: ${String(error)}`, variant: 'error', isActionable: true });
addToast({ message: t($ => $.sidebar.deleteQueueFailed, { detail: String(error) }), variant: 'error', isActionable: true });
});
}
}
@@ -97,7 +116,7 @@ export const Sidebar: React.FC<SidebarProps> = (props) => {
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [addToast, activeView, queues, removeQueue, selectedFilter]);
}, [addToast, activeView, queues, removeQueue]);
const getCount = (filter: SidebarFilter) => {
if (filter.startsWith('queue:')) {
@@ -120,13 +139,13 @@ export const Sidebar: React.FC<SidebarProps> = (props) => {
<button
type="button"
data-active={isSelected}
className="sidebar-nav-item group flex w-full items-center text-[13px] text-left cursor-default font-medium"
className="sidebar-nav-item group flex w-full items-center text-[13px] text-start cursor-default font-medium"
onClick={() => onSelectFilter(filter)}
>
<Icon className="w-[18px] h-[18px] mr-3 shrink-0" strokeWidth={isSelected ? 2.5 : 2} />
<span className="truncate">{label}</span>
<Icon className="w-[18px] h-[18px] me-3 shrink-0" strokeWidth={isSelected ? 2.5 : 2} />
<span className="sidebar-nav-label truncate">{label}</span>
{getCount(filter) > 0 && (
<span className="sidebar-count ml-auto min-w-5 px-1.5 py-0.5 rounded-full text-center text-[10px] leading-none font-bold">
<span className="sidebar-count ms-auto min-w-5 px-1.5 py-0.5 rounded-full text-center text-[10px] leading-none font-bold">
{getCount(filter)}
</span>
)}
@@ -140,35 +159,78 @@ export const Sidebar: React.FC<SidebarProps> = (props) => {
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;
const normalizedName = newQueueName.trim();
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;
}
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;
}
rejectedAddQueueNameRef.current = null;
addQueueSubmitRef.current = true;
setNewQueueName('');
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;
const normalizedName = editingQueueName.trim();
if (!renamingQueueId) return;
const normalizedName = editingQueueNameRef.current.trim();
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;
}
if (!renameQueue(renamingQueueId, normalizedName)) {
addToast({ message: 'A queue with this name already exists', variant: 'error', isActionable: true });
if (!renameQueue(queueId, normalizedName)) {
if (
trigger === 'blur'
&& rejectedRenameRef.current?.queueId === queueId
&& rejectedRenameRef.current.name === normalizedName
) {
rejectedRenameRef.current = null;
renamingQueueIdRef.current = null;
editingQueueNameRef.current = '';
setEditingQueueName('');
setRenamingQueueId(null);
return;
}
rejectedRenameRef.current = { queueId, name: normalizedName };
addToast({ message: t($ => $.sidebar.queueNameExists), variant: 'error', isActionable: true });
return;
}
rejectedRenameRef.current = null;
renameQueueSubmitRef.current = true;
renamingQueueIdRef.current = null;
setRenamingQueueId(null);
};
@@ -179,19 +241,30 @@ export const Sidebar: React.FC<SidebarProps> = (props) => {
if (isRenaming) {
return (
<div className="flex items-center px-2.5 py-1 rounded-lg mb-0.5 bg-item-hover">
<List className="w-4 h-4 mr-2 text-text-secondary" strokeWidth={2} />
<div className="sidebar-queue-editor flex items-center px-2.5 py-1 rounded-lg mb-0.5 bg-item-hover">
<List className="w-4 h-4 me-2 text-text-secondary" strokeWidth={2} />
<input
ref={renameInputRef}
type="text"
className="flex-1 bg-transparent border border-accent rounded px-1 text-[13px] text-text-primary outline-none min-w-0"
value={editingQueueName}
onChange={e => setEditingQueueName(e.target.value)}
onKeyDown={e => {
if (e.key === 'Enter') handleRenameQueueSubmit();
if (e.key === 'Escape') setRenamingQueueId(null);
onChange={e => {
editingQueueNameRef.current = e.target.value;
rejectedRenameRef.current = null;
setEditingQueueName(e.target.value);
}}
onBlur={handleRenameQueueSubmit}
onKeyDown={e => {
if (e.key === 'Enter') handleRenameQueueSubmit(queue.id);
if (e.key === 'Escape') {
e.preventDefault();
renameQueueCancelRef.current = queue.id;
renamingQueueIdRef.current = null;
editingQueueNameRef.current = '';
setEditingQueueName('');
setRenamingQueueId(null);
}
}}
onBlur={() => handleRenameQueueSubmit(queue.id, 'blur')}
/>
</div>
);
@@ -201,14 +274,15 @@ export const Sidebar: React.FC<SidebarProps> = (props) => {
<button
type="button"
data-active={isSelected}
data-sidebar-queue-id={queue.id}
onContextMenu={e => handleQueueContextMenu(e, queue.id)}
onClick={() => onSelectFilter(filterId)}
className="sidebar-nav-item group flex w-full items-center text-[13px] text-left cursor-default font-medium"
className="sidebar-nav-item group flex w-full items-center text-[13px] text-start cursor-default font-medium"
>
<List className="w-[18px] h-[18px] mr-3 shrink-0" strokeWidth={isSelected ? 2.5 : 2} />
<span className="truncate">{queue.name}</span>
<List className="w-[18px] h-[18px] me-3 shrink-0" strokeWidth={isSelected ? 2.5 : 2} />
<span className="sidebar-nav-label truncate">{queue.name}</span>
{getCount(filterId) > 0 && (
<span className="sidebar-count ml-auto min-w-5 px-1.5 py-0.5 rounded-full text-center text-[10px] leading-none font-bold shrink-0">
<span className="sidebar-count ms-auto min-w-5 px-1.5 py-0.5 rounded-full text-center text-[10px] leading-none font-bold shrink-0">
{getCount(filterId)}
</span>
)}
@@ -223,10 +297,10 @@ export const Sidebar: React.FC<SidebarProps> = (props) => {
type="button"
data-active={isSelected}
onClick={() => setActiveView(view)}
className="sidebar-nav-item group flex w-full items-center text-[13px] text-left cursor-default font-medium"
className="sidebar-nav-item group flex w-full items-center text-[13px] text-start cursor-default font-medium"
>
<Icon className="w-[18px] h-[18px] mr-3 shrink-0" strokeWidth={isSelected ? 2.5 : 2} />
<span>{label}</span>
<Icon className="w-[18px] h-[18px] me-3 shrink-0" strokeWidth={isSelected ? 2.5 : 2} />
<span className="sidebar-nav-label">{label}</span>
</button>
);
};
@@ -240,18 +314,18 @@ export const Sidebar: React.FC<SidebarProps> = (props) => {
type="button"
onClick={toggleSidebar}
className="sidebar-toggle-button"
title="Hide Sidebar"
title={t($ => $.actions.hideSidebar)}
>
<PanelLeft size={14} strokeWidth={1.9} />
</button>
</div>
<div className="sidebar-scroll">
<section className="sidebar-section">
<div className="sidebar-section-label">Library</div>
<NavItem icon={Inbox} label="All" filter="all" />
<NavItem icon={Zap} label="Active" filter="active" />
<NavItem icon={CheckCircle2} label="Completed" filter="completed" />
<NavItem icon={CircleDashed} label="Unfinished" filter="unfinished" />
<div className="sidebar-section-label">{t($ => $.navigation.library)}</div>
<NavItem icon={Inbox} label={t($ => $.navigation.filters.all)} filter="all" />
<NavItem icon={Zap} label={t($ => $.navigation.filters.active)} filter="active" />
<NavItem icon={CheckCircle2} label={t($ => $.navigation.filters.completed)} filter="completed" />
<NavItem icon={CircleDashed} label={t($ => $.navigation.filters.unfinished)} filter="unfinished" />
</section>
<section className="sidebar-section">
@@ -261,9 +335,9 @@ export const Sidebar: React.FC<SidebarProps> = (props) => {
className="sidebar-section-label sidebar-section-label-toggle"
aria-expanded={!foldersCollapsed}
aria-controls="sidebar-folders-list"
onClick={() => setFoldersCollapsed(collapsed => !collapsed)}
onClick={handleFoldersToggle}
>
<span>Folders</span>
<span>{t($ => $.navigation.folders)}</span>
<ChevronDown
aria-hidden="true"
size={13}
@@ -278,56 +352,70 @@ export const Sidebar: React.FC<SidebarProps> = (props) => {
inert={foldersCollapsed}
>
<div className="sidebar-collapse-content">
<NavItem icon={Music} label="Musics" filter="Musics" />
<NavItem icon={Film} label="Movies" filter="Movies" />
<NavItem icon={Archive} label="Compressed" filter="Compressed" />
<NavItem icon={FileText} label="Documents" filter="Documents" />
<NavItem icon={ImageIcon} label="Pictures" filter="Pictures" />
<NavItem icon={Box} label="Applications" filter="Applications" />
<NavItem icon={FileQuestion} label="Other" filter="Other" />
<NavItem icon={Music} label={t($ => $.navigation.categories.musics)} filter="Musics" />
<NavItem icon={Film} label={t($ => $.navigation.categories.movies)} filter="Movies" />
<NavItem icon={Archive} label={t($ => $.navigation.categories.compressed)} filter="Compressed" />
<NavItem icon={FileText} label={t($ => $.navigation.categories.documents)} filter="Documents" />
<NavItem icon={ImageIcon} label={t($ => $.navigation.categories.pictures)} filter="Pictures" />
<NavItem icon={Box} label={t($ => $.navigation.categories.applications)} filter="Applications" />
<NavItem icon={FileQuestion} label={t($ => $.navigation.categories.other)} filter="Other" />
</div>
</div>
</section>
<section className="sidebar-section">
<div className="sidebar-section-label">Queues</div>
<div className="sidebar-section-label">{t($ => $.navigation.queues)}</div>
{queues.map(queue => (
<QueueItem key={queue.id} queue={queue} />
))}
{isAddingQueue ? (
<div className="flex items-center px-3.5 py-1.5 rounded-lg bg-item-hover mb-1">
<Plus className="w-4 h-4 mr-2 text-text-secondary shrink-0" strokeWidth={2} />
<div className="sidebar-queue-editor flex items-center px-3.5 py-1.5 rounded-lg bg-item-hover mb-1">
<Plus className="w-4 h-4 me-2 text-text-secondary shrink-0" strokeWidth={2} />
<input
ref={addInputRef}
type="text"
placeholder="Queue name"
placeholder={t($ => $.actions.queueName)}
className="flex-1 bg-transparent border border-accent rounded px-1 text-[13px] text-text-primary outline-none min-w-0"
value={newQueueName}
onChange={e => setNewQueueName(e.target.value)}
onChange={e => {
rejectedAddQueueNameRef.current = null;
setNewQueueName(e.target.value);
}}
onKeyDown={e => {
if (e.key === 'Enter') handleAddQueueSubmit();
if (e.key === 'Escape') setIsAddingQueue(false);
if (e.key === 'Escape') {
e.preventDefault();
addQueueCancelRef.current = true;
setNewQueueName('');
setIsAddingQueue(false);
}
}}
onBlur={handleAddQueueSubmit}
onBlur={() => handleAddQueueSubmit('blur')}
/>
</div>
) : (
<button
type="button"
onClick={() => { addQueueSubmitRef.current = false; setIsAddingQueue(true); setNewQueueName(''); }}
className="flex w-full items-center px-3.5 py-1.5 rounded-lg text-[13px] text-text-muted hover:bg-item-hover hover:text-text-secondary cursor-default transition-colors mb-1"
onClick={() => {
addQueueSubmitRef.current = false;
addQueueCancelRef.current = false;
rejectedAddQueueNameRef.current = null;
setIsAddingQueue(true);
setNewQueueName('');
}}
className="sidebar-add-queue-button flex w-full items-center px-3.5 py-1.5 rounded-lg text-[13px] text-text-muted hover:bg-item-hover hover:text-text-secondary cursor-default transition-colors mb-1"
>
<Plus className="w-4 h-4 mr-2 shrink-0" strokeWidth={2} />
<span className="truncate">Add new queue</span>
<Plus className="w-4 h-4 me-2 shrink-0" strokeWidth={2} />
<span className="truncate">{t($ => $.actions.addNewQueue)}</span>
</button>
)}
</section>
<section className="sidebar-section">
<div className="sidebar-section-label">Tools</div>
<ToolItem icon={CalendarClock} label="Scheduler" view="scheduler" />
<ToolItem icon={Gauge} label="Speed Limiter" view="speedLimiter" />
<ToolItem icon={Bug} label="Logs" view="logs" />
<div className="sidebar-section-label">{t($ => $.navigation.tools)}</div>
<ToolItem icon={CalendarClock} label={t($ => $.navigation.scheduler)} view="scheduler" />
<ToolItem icon={Gauge} label={t($ => $.navigation.speedLimiter)} view="speedLimiter" />
<ToolItem icon={Bug} label={t($ => $.navigation.logs)} view="logs" />
</section>
</div>
@@ -336,10 +424,10 @@ export const Sidebar: React.FC<SidebarProps> = (props) => {
type="button"
data-active={activeView === 'settings'}
onClick={() => setActiveView('settings')}
className="sidebar-nav-item sidebar-settings-button group flex w-full items-center text-[13px] text-left cursor-default font-medium transition-colors"
className="sidebar-nav-item sidebar-settings-button group flex w-full items-center text-[13px] text-start cursor-default font-medium transition-colors"
>
<Settings className={`w-[18px] h-[18px] mr-3 shrink-0 ${activeView === 'settings' ? 'text-white' : 'text-text-muted'}`} strokeWidth={activeView === 'settings' ? 2.5 : 2} />
<span>Settings</span>
<Settings className={`w-[18px] h-[18px] me-3 shrink-0 ${activeView === 'settings' ? 'text-white' : 'text-text-muted'}`} strokeWidth={activeView === 'settings' ? 2.5 : 2} />
<span className="sidebar-nav-label">{t($ => $.navigation.settings)}</span>
</button>
</div>
@@ -354,58 +442,62 @@ export const Sidebar: React.FC<SidebarProps> = (props) => {
onClick={e => e.stopPropagation()}
>
<button
className="w-full text-left px-3 py-1.5 flex items-center hover:bg-item-hover"
className="w-full text-start px-3 py-1.5 flex items-center hover:bg-item-hover"
onClick={() => {
const queueId = contextMenu.id;
setContextMenu(null);
void startQueue(queueId).catch(error => {
addToast({
message: `Could not start queue: ${String(error)}`,
message: t($ => $.sidebar.startQueueFailed, { detail: String(error) }),
variant: 'error',
isActionable: true
});
});
}}
>
<Play size={14} className="mr-2 text-text-secondary" />
Start Queue
<Play size={14} className="me-2 text-text-secondary" />
{t($ => $.actions.startQueue)}
</button>
<button
className="w-full text-left px-3 py-1.5 flex items-center hover:bg-item-hover"
className="w-full text-start px-3 py-1.5 flex items-center hover:bg-item-hover"
onClick={() => {
const queueId = contextMenu.id;
setContextMenu(null);
void pauseQueue(queueId).catch(error => {
addToast({
message: `Could not pause queue: ${String(error)}`,
message: t($ => $.sidebar.pauseQueueFailed, { detail: String(error) }),
variant: 'error',
isActionable: true
});
});
}}
>
<Pause size={14} className="mr-2 text-text-secondary" />
Pause Queue
<Pause size={14} className="me-2 text-text-secondary" />
{t($ => $.actions.pauseQueue)}
</button>
<div className="h-px bg-border-color my-1 mx-2" />
<button
className="w-full text-left px-3 py-1.5 flex items-center hover:bg-item-hover"
className="w-full text-start px-3 py-1.5 flex items-center hover:bg-item-hover"
onClick={() => {
const q = queues.find(q => q.id === contextMenu.id);
if (q) {
renameQueueSubmitRef.current = false;
renameQueueCancelRef.current = null;
rejectedRenameRef.current = null;
renamingQueueIdRef.current = q.id;
editingQueueNameRef.current = q.name;
setEditingQueueName(q.name);
setRenamingQueueId(q.id);
}
setContextMenu(null);
}}
>
<Edit2 size={14} className="mr-2 text-text-secondary" />
Rename Queue
<Edit2 size={14} className="me-2 text-text-secondary" />
{t($ => $.actions.renameQueue)}
</button>
{!queues.find(q => q.id === contextMenu.id)?.isMain && (
<button
className="w-full text-left px-3 py-1.5 flex items-center hover:bg-red-500/20 text-red-400"
className="w-full text-start px-3 py-1.5 flex items-center hover:bg-red-500/20 text-red-400"
onClick={() => {
const queueId = contextMenu.id;
const queue = queues.find(q => q.id === queueId);
@@ -413,21 +505,21 @@ export const Sidebar: React.FC<SidebarProps> = (props) => {
setContextMenu(null);
return;
}
if (!window.confirm(`Delete queue "${queue.name}"? Its unfinished downloads will move to Main Queue.`)) {
if (!window.confirm(t($ => $.sidebar.deleteQueueConfirm, { name: queue.name }))) {
return;
}
setContextMenu(null);
void removeQueue(queueId).catch(error => {
addToast({
message: `Could not delete queue: ${String(error)}`,
message: t($ => $.sidebar.deleteQueueFailed, { detail: String(error) }),
variant: 'error',
isActionable: true
});
});
}}
>
<Trash2 size={14} className="mr-2" />
Delete Queue
<Trash2 size={14} className="me-2" />
{t($ => $.actions.deleteQueue)}
</button>
)}
</div>
+24 -20
View File
@@ -3,6 +3,7 @@ import { Gauge, Plus, Save, X, Zap } from 'lucide-react';
import { useSettingsStore } from '../store/useSettingsStore';
import { WindowDragRegion } from './WindowDragRegion';
import { useToast } from '../contexts/ToastContext';
import { useTranslation } from 'react-i18next';
type SpeedUnit = 'KB/s' | 'MB/s';
@@ -89,6 +90,7 @@ export function formatPresetValue(value: number): string {
}
export default function SpeedLimiterView() {
const { t } = useTranslation();
const globalSpeedLimit = useSettingsStore(state => state.globalSpeedLimit);
const lastCustomSpeedLimitKiB = useSettingsStore(state => state.lastCustomSpeedLimitKiB);
const lastCustomSpeedLimitUnit = useSettingsStore(state => state.lastCustomSpeedLimitUnit);
@@ -132,12 +134,14 @@ export default function SpeedLimiterView() {
setLastCustomSpeedLimitKiB(valueKiB);
setLastCustomSpeedLimitUnit(unit);
addToast({
message: enabled ? `Global limit saved at ${numericValue} ${unit}` : 'Global speed limit disabled',
message: enabled
? t($ => $.speedLimiter.globalLimitSaved, { value: numericValue, unit })
: t($ => $.speedLimiter.globalLimitDisabled),
variant: 'success'
});
} catch (error) {
addToast({
message: `Could not save global speed limit: ${String(error)}`,
message: t($ => $.speedLimiter.saveFailed, { detail: String(error) }),
variant: 'error',
isActionable: true
});
@@ -163,8 +167,8 @@ export default function SpeedLimiterView() {
setValue(String(storedPresetDisplayValue));
addToast({
message: alreadyExists
? `${formatPresetValue(storedPresetDisplayValue)} ${unit} is already in quick presets`
: `Added ${formatPresetValue(storedPresetDisplayValue)} ${unit} quick preset`,
? t($ => $.speedLimiter.presetAlreadyExists, { value: formatPresetValue(storedPresetDisplayValue), unit })
: t($ => $.speedLimiter.presetAdded, { value: formatPresetValue(storedPresetDisplayValue), unit }),
variant: alreadyExists ? 'info' : 'success'
});
};
@@ -174,7 +178,7 @@ export default function SpeedLimiterView() {
const nextPresets = presetValues.filter(value => value !== presetValue);
setSpeedLimitPresetValues(nextPresets);
addToast({
message: `Removed ${formatPresetValue(displayValue)} ${unit} quick preset`,
message: t($ => $.speedLimiter.presetRemoved, { value: formatPresetValue(displayValue), unit }),
variant: 'info'
});
};
@@ -207,25 +211,25 @@ export default function SpeedLimiterView() {
className={`inline-block h-4 w-4 transform rounded-full bg-white transition duration-200 ease-in-out ${enabled ? 'translate-x-4' : 'translate-x-1'}`}
/>
</button>
Speed Limiter
{t($ => $.speedLimiter.title)}
</div>
<span className={`rounded-full px-2.5 py-1 text-[11px] font-semibold ${
enabled ? 'bg-accent/15 text-accent' : 'bg-item-hover text-text-muted'
}`}>
{enabled ? `${currentDisplayValue} ${unit}` : 'Unlimited'}
{enabled ? `${currentDisplayValue} ${unit}` : t($ => $.speedLimiter.unlimited)}
</span>
<button onClick={() => void save()} disabled={isSaving} className="app-button app-button-primary ml-auto px-3 text-[11px] disabled:opacity-50">
<Save size={14} /> Save Limit
<button onClick={() => void save()} disabled={isSaving} className="app-button app-button-primary ms-auto px-3 text-[11px] disabled:opacity-50">
<Save size={14} /> {t($ => $.speedLimiter.saveLimit)}
</button>
</div>
<div className="flex-1 overflow-y-auto p-6">
<section className={`app-card max-w-[760px] p-5 ${enabled ? '' : 'opacity-55'}`}>
<div className="mb-2 flex items-center gap-2 font-semibold text-text-primary">
<Gauge size={18} className="text-accent" /> Global Speed Limit
<Gauge size={18} className="text-accent" /> {t($ => $.speedLimiter.globalSpeedLimit)}
</div>
<p className="max-w-2xl text-[12px] leading-relaxed text-text-muted">
Applies to new and active aria2 transfers and new yt-dlp media downloads. Explicit per-download limits remain attached to those transfers.
{t($ => $.speedLimiter.description)}
</p>
<div className="mt-6 flex items-center gap-3">
@@ -257,7 +261,7 @@ export default function SpeedLimiterView() {
<div className="my-6 border-t border-border-color" />
<div className="mb-3 flex items-center gap-2 text-[12px] font-medium text-text-secondary">
<Zap size={14} /> Quick Presets
<Zap size={14} /> {t($ => $.speedLimiter.quickPresets)}
</div>
<div className="flex flex-wrap items-center gap-2">
{presetValues.map(presetValue => {
@@ -271,7 +275,7 @@ export default function SpeedLimiterView() {
type="button"
disabled={!enabled || isSaving}
onClick={() => preset(presetValue)}
className="h-full flex-1 px-3 text-left disabled:opacity-50"
className="h-full flex-1 px-3 text-start disabled:opacity-50"
>
{formatPresetValue(displayValue)} {unit}
</button>
@@ -279,16 +283,16 @@ export default function SpeedLimiterView() {
type="button"
disabled={!enabled || isSaving}
onClick={() => removePreset(presetValue)}
className="flex h-full w-7 items-center justify-center border-l border-border-modal text-text-muted transition-colors hover:bg-red-500/10 hover:text-red-400 focus-visible:bg-red-500/10 focus-visible:text-red-400 disabled:opacity-50"
title={`Remove ${formatPresetValue(displayValue)} ${unit} preset`}
aria-label={`Remove ${formatPresetValue(displayValue)} ${unit} preset`}
className="flex h-full w-7 items-center justify-center border-s border-border-modal text-text-muted transition-colors hover:bg-red-500/10 hover:text-red-400 focus-visible:bg-red-500/10 focus-visible:text-red-400 disabled:opacity-50"
title={t($ => $.speedLimiter.removePreset, { value: formatPresetValue(displayValue), unit })}
aria-label={t($ => $.speedLimiter.removePreset, { value: formatPresetValue(displayValue), unit })}
>
<X size={12} />
</button>
</div>
);
})}
<div className="ml-1 flex h-8 items-center gap-1.5 rounded-md border border-border-modal bg-bg-input px-2">
<div className="ms-1 flex h-8 items-center gap-1.5 rounded-md border border-border-modal bg-bg-input px-2">
<input
type="number"
min={speedValueFromKiB(1, unit)}
@@ -297,7 +301,7 @@ export default function SpeedLimiterView() {
disabled={!enabled || isSaving}
onChange={event => setCustomPresetValue(event.target.value)}
className="w-12 bg-transparent text-right font-mono text-[12px] text-text-primary outline-none disabled:opacity-50"
aria-label={`Custom preset in ${unit}`}
aria-label={t($ => $.speedLimiter.customPresetIn, { unit })}
/>
<span className="text-[11px] text-text-muted">{unit}</span>
<button
@@ -305,8 +309,8 @@ export default function SpeedLimiterView() {
disabled={!enabled || isSaving}
onClick={applyCustomPreset}
className="app-icon-button h-6 w-6 disabled:opacity-50"
title="Add quick preset"
aria-label="Add quick preset"
title={t($ => $.speedLimiter.addQuickPreset)}
aria-label={t($ => $.speedLimiter.addQuickPreset)}
>
<Plus size={14} />
</button>
+20 -10
View File
@@ -1,6 +1,7 @@
import { getCurrentWindow } from '@tauri-apps/api/window';
import { Minus, Square, X } from 'lucide-react';
import { Maximize2, Minus, X } from 'lucide-react';
import type { PointerEvent } from 'react';
import { useTranslation } from 'react-i18next';
const appWindow = getCurrentWindow();
@@ -8,14 +9,23 @@ const stopTitlebarDrag = (event: PointerEvent<HTMLButtonElement>) => {
event.stopPropagation();
};
export function WindowControls() {
interface WindowControlsProps {
side: 'left' | 'right';
}
export function WindowControls({ side }: WindowControlsProps) {
const { t } = useTranslation();
return (
<div className="window-controls" aria-label="Window controls">
<div
className={`window-controls window-controls--${side}`}
aria-label={t($ => $.window.controls)}
>
<button
type="button"
className="window-control close"
title="Close"
aria-label="Close"
title={t($ => $.window.close)}
aria-label={t($ => $.window.close)}
onPointerDown={stopTitlebarDrag}
onClick={(event) => {
event.stopPropagation();
@@ -27,8 +37,8 @@ export function WindowControls() {
<button
type="button"
className="window-control minimize"
title="Minimize"
aria-label="Minimize"
title={t($ => $.window.minimize)}
aria-label={t($ => $.window.minimize)}
onPointerDown={stopTitlebarDrag}
onClick={(event) => {
event.stopPropagation();
@@ -40,15 +50,15 @@ export function WindowControls() {
<button
type="button"
className="window-control maximize"
title="Maximize"
aria-label="Maximize"
title={t($ => $.window.maximize)}
aria-label={t($ => $.window.maximize)}
onPointerDown={stopTitlebarDrag}
onClick={(event) => {
event.stopPropagation();
void appWindow.toggleMaximize();
}}
>
<Square size={8} strokeWidth={3} />
<Maximize2 size={9} strokeWidth={3} />
</button>
</div>
);
+3 -1
View File
@@ -1,5 +1,6 @@
import React, { createContext, useContext, useState, useCallback, ReactNode, useEffect, useRef, useLayoutEffect } from 'react';
import { CheckCircle2, AlertCircle, Info, XCircle, X } from 'lucide-react';
import { useTranslation } from 'react-i18next';
export type ToastVariant = 'success' | 'info' | 'warning' | 'error';
@@ -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 { t } = useTranslation();
const [isMounted, setIsMounted] = useState(false);
const [isHovered, setIsHovered] = useState(false);
const timerStartedAt = useRef<number | null>(null);
@@ -196,7 +198,7 @@ const ToastItem: React.FC<{ toast: ToastState; removeToast: (id: string) => void
<button
onClick={() => removeToast(toast.id)}
className="ml-2 mt-0.5 shrink-0 rounded-md p-1 text-text-secondary opacity-70 transition-all hover:bg-item-hover hover:text-text-primary hover:opacity-100 active:scale-95"
aria-label="Dismiss notification"
aria-label={t($ => $.actions.dismissNotification)}
>
<X className="w-4 h-4" strokeWidth={2.5} />
</button>
+755
View File
@@ -0,0 +1,755 @@
const common = {
navigation: {
library: 'Library',
filters: {
all: 'All',
active: 'Active',
completed: 'Completed',
unfinished: 'Unfinished',
},
categories: {
musics: 'Music',
movies: 'Movies',
compressed: 'Compressed',
documents: 'Documents',
pictures: 'Pictures',
applications: 'Applications',
other: 'Other',
},
folders: 'Folders',
queues: 'Queues',
tools: 'Tools',
scheduler: 'Scheduler',
speedLimiter: 'Speed Limiter',
logs: 'Logs',
settings: 'Settings',
},
actions: {
hideSidebar: 'Hide Sidebar',
showSidebar: 'Show Sidebar',
resizeSidebar: 'Resize Sidebar',
cancel: 'Cancel',
continue: 'Continue',
dismissNotification: 'Dismiss notification',
queueName: 'Queue name',
addNewQueue: 'Add new queue',
startQueue: 'Start Queue',
pauseQueue: 'Pause Queue',
renameQueue: 'Rename Queue',
deleteQueue: 'Delete Queue',
},
dialogs: {
errorBoundary: {
title: 'Something went wrong.',
description: 'A critical error occurred in the React component tree. The error details below can help identify the root cause.',
reload: 'Reload App',
},
duplicateDownloads: {
title: 'Duplicate Downloads Detected',
description: 'Some of the downloads you are adding already exist in the queue or on disk. Please choose how to resolve these conflicts.',
rename: 'Rename',
replace: 'Replace',
skip: 'Skip',
},
removeDownload: {
title: 'Remove Download',
confirmationSingle: 'Are you sure you want to remove this item from the list? You can also choose to delete the underlying file from your hard drive.',
confirmationMultiple: 'Are you sure you want to remove these {{count}} items from the list? You can also choose to delete the underlying files from your hard drive.',
errorSummary: '{{succeeded}} removed, {{failed}} failed: {{detail}}',
remove: 'Remove',
deleteFile: 'Delete file',
},
},
window: {
controls: 'Window controls',
close: 'Close',
minimize: 'Minimize',
maximize: 'Maximize',
},
downloads: {
actions: {
moveUp: 'Move Up',
moveDown: 'Move Down',
pause: 'Pause',
start: 'Start',
resume: 'Resume',
options: 'Options',
},
size: {
downloadedOf: '{{downloaded}} downloaded of {{total}} {{unit}}',
downloadedOfApproximate: '{{downloaded}} downloaded of approximately {{total}} {{unit}}',
},
status: {
ready: 'Ready',
staged: 'In queue',
queued: 'Queued',
downloading: 'Downloading',
processing: 'Processing',
paused: 'Paused',
completed: 'Completed',
failed: 'Failed',
retrying: 'Retrying',
},
values: {
processing: 'Processing...',
muxing: 'Muxing...',
},
},
speedLimiter: {
title: 'Speed Limiter',
unlimited: 'Unlimited',
saveLimit: 'Save Limit',
globalSpeedLimit: 'Global Speed Limit',
description: 'Applies to new and active aria2 transfers and new yt-dlp media downloads. Explicit per-download limits remain attached to those transfers.',
quickPresets: 'Quick Presets',
removePreset: 'Remove {{value}} {{unit}} preset',
customPresetIn: 'Custom preset in {{unit}}',
addQuickPreset: 'Add quick preset',
globalLimitSaved: 'Global limit saved at {{value}} {{unit}}',
globalLimitDisabled: 'Global speed limit disabled',
saveFailed: 'Could not save global speed limit: {{detail}}',
presetAlreadyExists: '{{value}} {{unit}} is already in quick presets',
presetAdded: 'Added {{value}} {{unit}} quick preset',
presetRemoved: 'Removed {{value}} {{unit}} quick preset',
},
logs: {
title: 'Logs',
entries: '({{count}} entries)',
collecting: 'Collecting',
off: 'Off',
allLevels: 'All Levels',
levels: {
error: 'Error',
warn: 'Warn',
info: 'Info',
debug: 'Debug',
trace: 'Trace',
},
pauseLogging: 'Pause diagnostic logging',
enableLogging: 'Enable diagnostic logging',
clearDisplayed: 'Clear displayed logs',
export: 'Export logs',
exportButton: 'Export Logs',
logFiles: 'Log Files',
localDiagnostics: 'Local diagnostics:',
diagnosticsDescription: 'Diagnostic collection is opt-in, bounded, and local. Common secrets, URL queries, and home-directory paths are redacted during display and export. Nothing is uploaded automatically.',
noEntries: 'No persisted log entries are available yet.',
disabledDescription: 'Diagnostic logging is off. Existing support logs will appear here when available.',
copy: 'Copy',
copied: 'Copied to clipboard',
copyFailed: 'Failed to copy to clipboard',
exported: 'Support logs exported',
exportFailed: 'Could not export logs: {{detail}}',
cleared: 'Logs cleared',
clearFailed: 'Could not clear logs: {{detail}}',
enabled: 'Diagnostic logging enabled',
disabled: 'Diagnostic logging disabled',
updateFailed: 'Could not update diagnostic logging: {{detail}}',
},
scheduler: {
title: 'Scheduler',
running: 'Running',
disabled: 'Scheduler is disabled',
noScheduledDay: 'No scheduled day selected',
unsavedChanges: 'Unsaved changes',
runNow: 'Run Now',
pause: 'Pause',
saveSettings: 'Save Settings',
timing: 'Timing',
startTime: 'Start Time',
stopTime: 'Stop Time',
timingDescription: 'If Firelink is asleep at the start time, it starts the selected queues when it returns later that day, unless the stop time has already passed.',
runEveryDay: 'Run Every Day',
queuesToSchedule: 'Queues to Schedule',
defaultQueue: 'Default queue',
afterCompletion: 'After Completion',
afterCompletionDescription: 'Choose what happens after downloads started by the scheduler finish.',
actionWarning: 'This action can interrupt other work on the computer. Firelink invokes it immediately after the scheduled queue finishes.',
systemPermissions: 'System Permissions',
macPermissionDescription: 'Sleep, restart, and shut down require macOS Automation permission for System Events.',
permissionGranted: 'Automation permission granted',
permissionChecking: 'Checking Automation permission...',
permissionMissing: 'Automation permission missing',
revokePermission: 'Revoke permission',
grantPermission: 'Grant permission',
permissionAvailable: 'Automation permission is available.',
permissionMissingDetails: 'Automation permission is missing. Enable Firelink under Automation for System Events in System Settings.',
revokePermissionDetails: 'macOS does not allow Firelink to revoke Automation permission directly. Revoke System Events access in System Settings, then return to Firelink.',
requestingPermission: 'Requesting Automation permission...',
enablePermissionDetails: 'Enable Firelink under Automation for System Events in System Settings, then return to Firelink.',
systemActions: 'System Actions',
windowsActionsDescription: 'Sleep, restart, and shut down use Windows system privileges. Firelink reports any rejected action when it runs; no permanent permission is claimed in advance.',
linuxActionsDescription: 'Sleep, restart, and shut down use your Linux desktop and system policy. Firelink reports any rejected action when it runs; no permanent permission is claimed in advance.',
validationDay: 'Select at least one day for the scheduler',
validationQueue: 'Select at least one queue for the scheduler',
validationStopTime: 'Stop time must be later than start time',
saved: 'Scheduler settings saved',
trackingOne: 'Tracking 1 scheduled download',
trackingMany: 'Tracking {{count}} scheduled downloads',
noStartableDownloads: 'No downloads in the selected queues can be started',
pausedOne: 'Paused 1 active download',
pausedMany: 'Paused {{count}} active downloads',
noActiveDownloads: 'No active downloads',
days: {
su: 'Su',
mo: 'Mo',
tu: 'Tu',
we: 'We',
th: 'Th',
fr: 'Fr',
sa: 'Sa',
},
postActions: {
none: 'Do nothing',
sleep: 'Sleep',
restart: 'Restart',
shutdown: 'Shut down',
},
},
properties: {
progress: 'Progress',
size: 'Size',
speed: 'Speed',
eta: 'ETA',
connections: 'Connections',
connectionCount: '{{active}}/{{total}}',
connectionCountUnknown: '—/{{total}}',
connectionsUnavailable: '—',
speedCap: 'Speed cap',
category: 'Category',
lastTry: 'Last try',
dateAdded: 'Date added',
destination: 'Destination',
lastError: 'Last error',
saved: ' (saved)',
defaultValue: ' (default)',
savedTooltip: 'Saved for this download; Settings changes apply to new downloads.',
defaultTooltip: 'Using the current default for new downloads.',
identityReadOnly: 'File identity is read-only. Transfer settings are saved for redownload.',
transferSettings: 'Transfer settings can be changed after stopping or pausing. Current transfers keep their existing backend options.',
download: 'Download',
url: 'URL',
fileName: 'File name',
saveLocation: 'Save location',
select: 'Select',
perFile: 'per file',
useCurrentDefault: 'Use current default ({{count}})',
savedPerDownload: 'Saved per download. The Settings default applies to new downloads.',
limit: 'Limit',
siteLogin: 'Site Login',
siteLoginRedownload: 'Site Login for Redownload',
matchingSiteLogin: 'Matching site login',
customCredentials: 'Custom credentials',
noLogin: 'No login',
useSavedLogin: 'Will use saved login if available.',
advancedTransfer: 'Advanced Transfer',
advancedTransferRedownload: 'Advanced Transfer for Redownload',
checksum: 'Checksum',
verify: 'Verify',
algorithm: 'Algorithm',
digest: 'Digest',
expectedDigest: 'Expected digest',
cookies: 'Cookies',
headers: 'Headers',
mirrors: 'Mirrors',
username: 'Username',
password: 'Password',
enterValidUrl: 'Enter a valid URL.',
fileNameEmpty: 'File name cannot be empty.',
cancel: 'Cancel',
save: 'Save',
},
keychain: {
title: 'Credential Storage Access Needed',
stores: {
portable: 'the portable Firelink data folder',
windows: 'Windows Credential Manager',
linux: 'your Linux credential store',
macos: 'macOS Keychain',
system: "this system's credential store",
siteCredentials: "the system's credential store",
},
grantLabelPortable: 'Continue',
grantLabelMacos: 'Grant Access',
grantLabelDefault: 'Enable Secure Storage',
description: 'Firelink uses the browser extension to capture downloads. To keep the extension paired after restarts, Firelink stores its pairing token in {{pairingStore}}. Optional site credentials are stored in {{siteCredentialStore}}.',
portableExplanation: 'The pairing token is portable with this folder. Site credentials remain in the system credential store; a system prompt may appear after you grant access.',
macosExplanation: 'macOS may show a Keychain prompt after you grant access.',
defaultExplanation: 'This usually completes silently. If the credential service is unavailable, Firelink will show the error here and the extension will stay paired for this session only.',
note: 'Note:',
portableNote: 'Portable mode stores only the pairing token in this folder. It does not copy site passwords or browser credentials.',
defaultNote: 'Firelink only writes its own dedicated credential entry. It cannot access other saved passwords or credential items on your system.',
hint: 'Hint:',
portableHint: 'The portable pairing token is already stored with this folder; you can enable it here or select Later.',
defaultHint: 'If you select Later, the extension will only work for this session.',
enableFromSettings: 'You can enable storage anytime from Settings > Integrations.',
later: 'Later',
enabling: 'Enabling...',
timeout: 'Credential storage request timed out. You can select Later and try again.',
unavailable: '{{store}} is unavailable.',
accessRequired: 'Grant credential-store access before regenerating the pairing token.',
storeUnavailable: 'Credential store access is unavailable.',
},
app: {
loading: 'Loading…',
settingsSaveFailed: 'Could not save settings. Check storage permissions and try again.',
systemActionCountdown: '{{action}} in 10 seconds.',
systemActionCancelled: 'System action cancelled because another download is active or queued.',
systemActionFailed: 'Scheduled system action failed: {{detail}}',
downloadCompleteTitle: 'Download Complete',
downloadCompleteBody: '{{fileName}} has finished downloading.',
unknownFile: 'A file',
downloadFailedTitle: 'Download Failed',
downloadFailedBody: '{{fileName}} failed to download.',
initializeDownloadsFailed: 'Could not initialize saved downloads: {{detail}}',
extensionDisconnected: 'Browser extension disconnected because its pairing token changed.',
copyToken: 'Copy token',
integrations: 'Integrations',
copyTokenFailed: 'Could not copy pairing token: {{detail}}',
credentialPersistenceFailed: 'Secure credential persistence is unavailable. Browser pairing works for this session only: {{detail}}',
resumeDownloadsFailed: 'Could not resume saved downloads: {{detail}}',
updateAvailable: 'Firelink {{version}} is available.',
viewRelease: 'View release',
sleepPreventionFailed: 'Could not update sleep prevention: {{detail}}',
schedulerNoQueues: 'Scheduler has no valid queues selected. Update Scheduler settings.',
schedulerPauseOneFailed: 'Scheduler could not pause 1 download.',
schedulerPauseManyFailed: 'Scheduler could not pause {{count}} downloads.',
scheduledIncomplete: 'Scheduled downloads did not all complete. The post-queue system action was skipped.',
scheduledActionSkippedActive: 'Scheduled system action skipped because another download is active or queued.',
notificationsDisabled: 'System notifications are disabled for Firelink.',
notificationsFailed: 'Could not configure notifications: {{detail}}',
},
sidebar: {
deleteQueueConfirm: 'Delete queue "{{name}}"? Its unfinished downloads will move to Main Queue.',
deleteQueueFailed: 'Could not delete queue: {{detail}}',
queueNameEmpty: 'Queue name cannot be empty',
queueNameExists: 'A queue with this name already exists',
startQueueFailed: 'Could not start queue: {{detail}}',
pauseQueueFailed: 'Could not pause queue: {{detail}}',
},
downloadTable: {
unknownQueue: 'Unknown Queue',
allDownloads: 'All Downloads',
active: 'Active',
completed: 'Completed',
unfinished: 'Unfinished',
headers: {
fileName: 'File Name',
size: 'Size',
status: 'Status',
speed: 'Speed',
eta: 'ETA',
dateAdded: 'Date Added',
},
columnOptions: {
open: 'Column options for {{column}}',
dragToReorder: 'Drag to reorder columns',
alignContent: 'Align content position',
left: 'Left',
center: 'Center',
right: 'Right',
resetLayout: 'Reset column layout',
},
addDownload: 'Add Download',
resumeAll: 'Resume All',
pauseAll: 'Pause All',
queueEmpty: 'Queue is empty',
noCompletedDownloads: 'No Completed Downloads',
noDownloads: 'No Downloads',
queueEmptyDescription: 'Add downloads to this queue from an item menu or the Add window.',
completedDescription: 'Completed downloads will appear here.',
clickToAdd: 'Click',
addButtonOr: 'button or',
toAddDownloads: 'to add downloads',
nonResumableOne: '1 download does not support resuming. If you pause it, you will have to start over again later. Are you sure you want to pause?',
nonResumableMany: '{{count}} downloads do not support resuming. If you pause them, you will have to start over again later. Are you sure you want to pause?',
interactionError: '{{message}}: {{detail}}',
openFileFailed: 'Could not open downloaded file',
revealFileFailed: 'Could not show download in Finder',
pauseFailed: 'Could not pause download',
resumeFailed: 'Could not resume {{fileName}}',
moveManyFailed: 'Could not move downloads to queue',
moveOneFailed: 'Could not move download to queue',
copyAddressesFailed: 'Could not copy addresses',
copyAddressFailed: 'Could not copy address',
copyPathFailed: 'Could not copy file path',
missingFileName: 'File name is missing',
redownloadFailed: 'Redownload failed',
startResume: 'Start/Resume',
addToQueue: 'Add to Queue',
copyAddress: 'Copy Address',
remove: 'Remove',
open: 'Open',
showInFolder: 'Show in Folder',
pause: 'Pause',
start: 'Start',
resume: 'Resume',
redownload: 'Redownload',
copyFilePath: 'Copy File Path',
properties: 'Properties',
backendRejectedStart: 'The backend rejected the start/resume request.',
transferActive: 'Cannot change properties while transfer is active. Pause it first.',
redownloadNotFound: 'Cannot redownload: download was not found.',
redownloadActive: 'Cannot redownload a {{status}} download. Pause or wait for it to finish first.',
originalUrlMissing: 'Cannot redownload: original URL is missing.',
pauseBeforeMove: 'Pause {{file}} before moving it to another queue.',
},
addDownloads: {
unknownSize: 'Unknown size',
unknown: 'Unknown',
discardSetup: 'Discard this download setup?',
speedInvalid: 'Speed limit must be greater than zero',
duplicateResolveFailed: 'Could not resolve duplicate downloads: {{detail}}',
addedWithFailures: '{{added}} added, {{failed}} failed. {{detail}}',
addedOne: '1 download added',
addedMany: '{{count}} downloads added',
updatedOne: '1 download updated',
updatedMany: '{{count}} downloads updated',
urlAlreadyQueued: 'URL already in queue',
destinationConflict: 'Another selected download uses this destination',
existingDownloadDestination: 'Existing Firelink download uses this destination',
matchingDownloadFilename: 'An unfinished download already has this filename',
fileExistsOnDisk: 'File exists on disk; rename or skip to avoid deleting unrelated data',
backendRejectedStart: 'Backend rejected download start.',
playlistNoEntries: 'Playlist contains no downloadable entries',
invalidMediaMetadata: 'Invalid media metadata or no formats found',
noAvailableName: 'Could not find an available name for {{file}}.',
pauseBeforeReplace: 'Pause {{file}} before replacing it.',
cannotReplace: 'Cannot replace {{file}}: file is not owned by a Firelink download.',
downloadLinks: 'Download Links',
pastePlaceholder: 'Paste HTTP, HTTPS, FTP, or SFTP URLs here...\n\nFor media downloads, paste links from YouTube, X, TikTok, Instagram, Reddit, etc.',
playlistSummary: 'Playlist “{{title}}”: {{loaded}}{{total}} entries loaded{{truncated}}{{skipped}}',
safeEntryLimit: ' (safe entry limit reached)',
selectedSummary: '{{ready}} selected ready, {{fallback}} fallback, {{mediaRetry}} media retry, {{blocked}} blocked',
clearSelection: 'Clear selection',
selectAll: 'Select all',
refreshMetadata: 'Refresh Metadata',
files: 'Files',
required: 'Required',
free: 'Free',
preview: 'Preview',
file: 'File',
size: 'Size',
status: 'Status',
downloadPreview: 'Download preview',
noLinks: 'No links added yet.',
selectItem: 'Select {{file}}',
fetchingPlaylist: 'Fetching playlist...',
fetching: 'Fetching...',
unsafeUrl: 'Unsafe URL',
playlistFailed: 'Playlist failed',
metadataFailed: 'Metadata failed',
fallback: 'Fallback',
invalid: 'Invalid',
ready: 'Ready',
mediaFormat: 'Media Format',
playlistItem: 'Playlist item {{index}}',
fetchingMediaStreams: 'Fetching media streams...',
availableStreams: 'Available Streams',
availableMediaStreams: 'Available media streams',
metadataUnavailable: 'Metadata unavailable. Refresh metadata before adding this media.',
saveLocation: 'Save Location',
browse: 'Browse',
categoryFolders: 'Files will be organized into category folders.',
sharedFolder: 'All selected downloads will use this folder.',
dedicatedFolder: 'Save in a new folder',
dedicatedFolderName: 'Folder name',
dedicatedFolderDescription: 'Creates this folder inside each automatic category folder.',
dedicatedFolderManualDescription: 'Creates this folder inside the selected save location.',
saveFolderName: 'Save folder name',
dedicatedFolderNameRequired: 'Enter a folder name before saving.',
transferSettings: 'Transfer Settings',
connectionsPerFile: 'Connections per File',
connectionsPerFileAria: 'Connections per file',
limitSpeedPerFile: 'Limit speed per file',
speedLimitPerFileAria: 'Speed limit per file',
authorization: 'Authorization',
useAuthorization: 'Use authorization',
username: 'Username',
password: 'Password',
advancedTransfer: 'Advanced Transfer',
verifyChecksum: 'Verify Checksum',
checksumAlgorithm: 'Checksum algorithm',
expectedDigest: 'Expected digest',
headers: 'Headers',
requestHeaders: 'Request headers',
cookies: 'Cookies',
cookiePlaceholder: 'name=value; other=value',
mirrors: 'Mirrors',
cancel: 'Cancel',
startDownloads: 'Start Downloads',
addToQueue: 'Add to queue',
pasteOneOrMore: 'Paste one or more links.',
selectAtLeastOne: 'Select at least one download.',
correctInvalidOne: 'Correct or remove {{count}} invalid URL before continuing.',
correctInvalidFew: 'Correct or remove {{count}} invalid URLs before continuing.',
correctInvalidMany: 'Correct or remove {{count}} invalid URLs before continuing.',
waitingForMetadataOne: 'Waiting for metadata for {{count}} download.',
waitingForMetadataFew: 'Waiting for metadata for {{count}} downloads.',
waitingForMetadataMany: 'Waiting for metadata for {{count}} downloads.',
removeUnsafeOne: 'Remove {{count}} unsafe URL before continuing.',
removeUnsafeFew: 'Remove {{count}} unsafe URLs before continuing.',
removeUnsafeMany: 'Remove {{count}} unsafe URLs before continuing.',
mediaMetadataUnavailableSummaryOne: 'Media metadata is unavailable for {{count}} item. Refresh metadata before adding.',
mediaMetadataUnavailableSummaryFew: 'Media metadata is unavailable for {{count}} items. Refresh metadata before adding.',
mediaMetadataUnavailableSummaryMany: 'Media metadata is unavailable for {{count}} items. Refresh metadata before adding.',
metadataUnavailableFallback: 'Metadata is unavailable. Downloads can still be added using fallback details.',
fallbackReadyOne: '{{ready}} download ready; {{failed}} will use fallback filename and unknown size.',
fallbackReadyFew: '{{ready}} downloads ready; {{failed}} will use fallback filename and unknown size.',
fallbackReadyMany: '{{ready}} downloads ready; {{failed}} will use fallback filename and unknown size.',
readyToAddOne: 'Ready to add {{count}} download.',
readyToAddFew: 'Ready to add {{count}} downloads.',
readyToAddMany: 'Ready to add {{count}} downloads.',
},
settings: {
tabs: {
downloads: 'Downloads',
lookAndFeel: 'Look and feel',
network: 'Network',
locations: 'Locations',
siteLogins: 'Site Logins',
power: 'Power',
engine: 'Engine',
integrations: 'Integrations',
about: 'About',
},
common: {
thisOs: 'this OS',
checking: 'Checking...',
ready: 'Ready',
errorMissing: 'Error / Missing',
hideTechnicalDetails: 'Hide technical details',
showTechnicalDetails: 'Show technical details',
binary: 'Binary',
expected: 'Expected',
error: 'Error',
tip: 'Tip',
stderr: 'stderr',
daemonProcessAlive: 'Daemon process alive',
rpcReady: 'RPC ready',
rpcPort: 'RPC port',
daemonStderr: 'daemon stderr',
packaging: 'Packaging: PyInstaller onedir (_internal required)',
internalDirectoryFound: '_internal directory found',
pythonRuntimeFound: 'Python runtime found',
externalOpenFailed: 'Could not open {{label}}: {{detail}}',
updateUnexpectedResponse: 'The update check returned an unexpected response.',
updateUntrustedReleaseUrl: 'The update check returned an untrusted release URL.',
updateFailed: 'Update check failed: {{detail}}',
},
downloads: {
defaultConnections: 'Default connections:',
defaultConnectionsDescription: 'New downloads; existing items keep their saved value',
parallelDownloads: 'Parallel downloads:',
parallelDownloadsDescription: 'Max simultaneous active files',
automaticRetries: 'Automatic retries:',
automaticRetriesDescription: 'If a connection fails',
systemNotification: 'Show system notification when download completes',
systemNotificationDescription: 'Uses your operating system notification settings',
completionChime: 'Play in-app completion chime',
completionChimeDescription: 'Optional Firelink sound, independent of system notifications',
clipboardLinks: 'Add clipboard links when Firelink becomes active',
clipboardLinksDescription: 'Opens supported copied links in the Add window. Off by default.',
},
lookAndFeel: {
language: 'Language',
languageDescription: 'Choose the Firelink interface language.',
sidebarPosition: 'Sidebar position',
sidebarPositionDescription: 'Choose where the sidebar appears. Automatic follows the interface direction.',
sidebarPositionAutomatic: 'Automatic (recommended)',
sidebarPositionLeft: 'Left',
sidebarPositionRight: 'Right',
languageSystem: 'System default',
languageEnglish: 'English',
languageChinese: 'Simplified Chinese',
languageHebrew: 'Hebrew',
languagePersian: 'Persian',
languageUkrainian: 'Ukrainian',
languageRussian: 'Russian',
appTheme: 'App Theme',
theme: 'Theme',
ariaLabel: 'App theme',
system: 'System',
light: 'Light',
dark: 'Dark',
dracula: 'Dracula',
nord: 'Nord',
systemAppearance: 'System follows the current {{platform}} light or dark appearance.',
display: 'Display',
fontSize: 'Font size',
fontSizeDescription: 'Scales the download list and compact controls.',
small: 'Small',
standard: 'Standard',
large: 'Large',
listDensity: 'List density',
listDensityDescription: 'Changes row height, spacing, and progress bar scale.',
compact: 'Compact',
comfortable: 'Comfortable',
relaxed: 'Relaxed',
osIntegration: 'OS Integration',
dockBadge: 'Show badge on Dock icon',
dockBadgeDescription: 'Displays active download count on Firelink Dock icon.',
menuBarIcon: 'Show menu bar icon',
statusIndicatorIcon: 'Show status indicator icon',
systemTrayIcon: 'Show system tray icon',
macosTrayDescription: 'Provides quick access from the macOS menu bar.',
windowsTrayDescription: 'Provides quick access from the Windows notification area.',
linuxTrayDescription: 'Provides quick access from the desktop tray or status area when available.',
defaultTrayDescription: 'Provides quick access from the OS tray area when available.',
},
network: {
proxy: 'Proxy',
mode: 'Mode',
modeDescription: 'Controls proxy use for new download requests.',
noProxy: 'No Proxy',
systemProxy: 'System Proxy',
customProxy: 'Custom Proxy',
proxyHost: 'Proxy host',
proxyHostDescription: 'Host name, IP address, or HTTP proxy URL.',
proxyHostPlaceholder: '127.0.0.1 or http://127.0.0.1',
proxyPort: 'Proxy port',
proxyPortDescription: 'Valid range is 1 to 65535.',
noProxyDescription: 'Downloads ignore configured proxies.',
systemProxyDescription: 'Downloads use the detected {{platform}} system proxy. Normal file downloads require an HTTP or HTTPS proxy endpoint; media downloads can use SOCKS.',
customProxyDescription: 'Downloads use the configured HTTP proxy endpoint for metadata and download engines.',
invalidCustomProxy: 'Enter a valid HTTP proxy host and port to enable the custom proxy.',
incompleteCustomProxy: 'Enter a proxy host and port to enable the custom proxy.',
checkingSystemProxy: 'Checking system proxy configuration…',
detectedSystemProxy: 'A system proxy was detected. Normal file downloads require an HTTP or HTTPS endpoint; media downloads can use SOCKS.',
noSystemProxy: 'No usable system proxy was detected. Downloads will use no proxy.',
systemProxyReadFailed: 'System proxy configuration could not be read. Choose No Proxy or try again when it is available.',
identity: 'Identity',
customUserAgent: 'Custom User-Agent',
userAgentDescription: 'Applied to metadata fetches and download engines.',
userAgentPlaceholder: 'Leave blank for Firelink default',
userAgentOverrides: 'Overrides the outbound User-Agent header. Leave blank for Firelink defaults.',
chromeWindows: 'Chrome (Windows)',
chromeMacos: 'Chrome (macOS)',
edgeWindows: 'Edge (Windows)',
firefoxWindows: 'Firefox (Windows)',
firefoxMacos: 'Firefox (macOS)',
safariMacos: 'Safari (macOS)',
windowsDesktop: 'Windows desktop',
macosDesktop: 'macOS desktop',
},
locations: {
baseDownloadFolder: 'Base Download Folder',
baseDownloadFolderDescription: 'Automatic category folders are created inside this folder.',
browse: 'Browse',
baseFolderCreateFailed: 'Base folder saved, but category folders could not be created: {{detail}}',
baseFolderUpdated: 'Base download folder updated',
askWhereToSave: 'Ask where to save when adding downloads',
rememberLastUsedDownloadDirectory: 'Remember the last used download directory',
rememberLastUsedDownloadDirectoryDescription: 'Use the last folder selected in the Add window for the next download until the app restarts.',
automaticCategorySubfolders: 'Automatically save files to category subfolders',
automaticCategorySubfoldersDescription: 'When off, automatic downloads use the base folder.',
categorySubfolders: 'Category Subfolders',
relativeToBase: 'Relative to the base folder',
disabled: 'Disabled',
categorySubfolder: '{{category}} subfolder',
customFolder: 'Custom folder',
useAutomatic: 'Use automatic',
resetDefaults: 'Reset Defaults',
resetToast: 'Reset category locations to default',
},
siteLogins: {
title: 'Site Credentials',
noSavedLogins: 'No saved logins.',
user: 'User: {{username}}',
deleteCredential: 'Delete credential',
deletedCredential: 'Deleted credential',
deleteFailed: 'Could not delete credential: {{detail}}',
addTitle: 'Add Site Credentials',
urlPattern: 'URL Pattern:',
urlPatternPlaceholder: 'e.g. *.example.com or example.com/downloads',
username: 'Username:',
password: 'Password:',
usernamePlaceholder: 'Username',
passwordPlaceholder: 'Password',
saving: 'Saving…',
addLogin: 'Add Login',
urlPatternRequired: 'URL pattern is required.',
urlPatternWhitespace: 'URL pattern cannot contain whitespace.',
usernameRequired: 'Username is required.',
passwordRequired: 'Password is required.',
accessBeforeSave: 'Grant credential-store access before saving a site login.',
saveFailed: 'Failed to save site credential securely.',
addedCredential: 'Added site credential',
},
power: {
title: 'Power Management',
preventSleep: 'Prevent system sleep while downloads are active',
preventSleepDescription: 'The display may still turn off. Firelink only keeps the device awake enough to complete active transfers.',
},
engine: {
title: 'Media Downloader & Engines',
description: 'Successful results are reused for this app session. Recheck runs real validation again.',
recheck: 'Recheck engines',
coreDownloader: 'Core Downloader (aria2)',
version: 'Version:',
status: 'Status:',
mediaExtractors: 'Media Extractors',
browserCookiesSource: 'Browser Cookies Source:',
cookieDescription: 'yt-dlp reads browser cookies to bypass video download limits or access restricted media. Firelink does not save browser cookies.',
none: 'None',
},
integrations: {
title: 'Connect Browser Extension',
description: 'Capture downloads directly from your browser in three easy steps.',
portablePairingEnabled: 'Portable Pairing Enabled',
pairingTokenPersisted: 'Pairing Token Persisted',
portablePersistedDescription: 'Your pairing token is stored with this portable Firelink folder and will persist when the folder is moved. Treat the folder as sensitive.',
systemPersistedDescription: 'Your pairing token is stored in the system credential store and will persist across restarts.',
portablePairingAvailable: 'Portable Pairing Available',
credentialStorageNeeded: 'Credential Storage Needed',
portableAvailableDescription: 'Your pairing token is stored with this portable Firelink folder and will persist across restarts. Enable it here to review the portable-storage warning.',
credentialStorageDescription: "Firelink needs access to this system's credential store to securely save your pairing token across app restarts. Currently, your extension will only stay connected for this session.",
reviewPortablePairing: 'Review Portable Pairing',
grantCredentialAccess: 'Grant Credential Access',
copyToken: 'Copy Token',
tokenDescription: 'This secure token authorizes your browser extension.',
tokenCopied: 'Token copied to clipboard!',
tokenCopyFailed: 'Could not copy token: {{detail}}',
pairingTokenRegenerated: 'Pairing token regenerated',
regenerateFailed: 'Could not regenerate pairing token: {{detail}}',
getExtension: 'Get Extension',
extensionDescription: 'Install Firelink Companion for Firefox or Chromium browsers.',
firefoxAddons: 'Firefox Add-ons',
chromiumZip: 'Chromium ZIP',
pasteConnect: 'Paste & Connect',
pasteConnectDescription: "Click the Firelink icon in your browser's toolbar and paste the copied token.",
extensionServerStatus: 'Extension Server Status:',
listening: '● Listening on 127.0.0.1:{{port}}',
unavailable: '● Server unavailable',
},
about: {
firelinkIcon: 'Firelink Icon',
unknown: 'Unknown',
version: 'Version {{version}}',
description: 'Cross-platform download manager for macOS, Windows, and Linux.',
sourceCode: 'Source Code',
source: 'Source',
upToDate: 'Firelink {{version}} is up to date.',
updateAvailable: 'Firelink {{version}} is available.',
viewRelease: 'View release',
updates: 'Updates',
checkForUpdates: 'Check for Updates',
updateDescription: 'Firelink checks GitHub Releases for new versions.',
checking: 'Checking...',
checkNow: 'Check Now',
automaticUpdates: 'Automatically check for updates',
createdBy: 'Created by NimBold',
mitLicense: 'MIT License',
credits: 'Credits: aria2, yt-dlp, FFmpeg, and Deno.',
copyright: 'Copyright © 2026 NimBold. All rights reserved.',
},
},
status: {
ready: 'Ready',
active: '{{count}} active',
queued: '{{count}} queued',
done: '{{count}} done',
},
} as const;
export default common;
+755
View File
@@ -0,0 +1,755 @@
const fa = {
navigation: {
library: 'کتابخانه',
filters: {
all: 'همه',
active: 'فعال',
completed: 'تکمیل‌شده',
unfinished: 'ناتمام',
},
categories: {
musics: 'موسیقی',
movies: 'فیلم‌ها',
compressed: 'فشرده',
documents: 'اسناد',
pictures: 'تصاویر',
applications: 'برنامه‌ها',
other: 'سایر',
},
folders: 'پوشه‌ها',
queues: 'صف‌ها',
tools: 'ابزارها',
scheduler: 'زمان‌بندی',
speedLimiter: 'محدودکنندهٔ سرعت',
logs: 'گزارش‌ها',
settings: 'تنظیمات',
},
actions: {
hideSidebar: 'پنهان کردن نوار کناری',
showSidebar: 'نمایش نوار کناری',
resizeSidebar: 'تغییر اندازه نوار کناری',
cancel: 'لغو',
continue: 'ادامه',
dismissNotification: 'بستن اعلان',
queueName: 'نام صف',
addNewQueue: 'افزودن صف جدید',
startQueue: 'شروع صف',
pauseQueue: 'توقف صف',
renameQueue: 'تغییر نام صف',
deleteQueue: 'حذف صف',
},
dialogs: {
errorBoundary: {
title: 'مشکلی پیش آمد.',
description: 'یک خطای بحرانی در درخت اجزای React رخ داده است. جزئیات خطای زیر می‌تواند به شناسایی علت اصلی کمک کند.',
reload: 'بارگذاری مجدد برنامه',
},
duplicateDownloads: {
title: 'شناسایی دانلودهای تکراری',
description: 'برخی از دانلودهایی که اضافه می‌کنید از قبل در صف یا روی دیسک وجود دارند. لطفاً نحوه حل این تداخل‌ها را انتخاب کنید.',
rename: 'تغییر نام',
replace: 'جایگزینی',
skip: 'رد شدن',
},
removeDownload: {
title: 'حذف دانلود',
confirmationSingle: 'آیا مطمئن هستید که می‌خواهید این مورد را از لیست حذف کنید؟ همچنین می‌توانید فایل اصلی را از هارد دیسک خود حذف کنید.',
confirmationMultiple: 'آیا مطمئن هستید که می‌خواهید این {{count}} مورد را از لیست حذف کنید؟ همچنین می‌توانید فایل‌های اصلی را از هارد دیسک خود حذف کنید.',
errorSummary: '{{succeeded}} مورد حذف شد، {{failed}} مورد ناموفق: {{detail}}',
remove: 'حذف',
deleteFile: 'حذف فایل',
},
},
window: {
controls: 'کنترل‌های پنجره',
close: 'بستن',
minimize: 'کمینه کردن',
maximize: 'بیشینه کردن',
},
downloads: {
actions: {
moveUp: 'انتقال به بالا',
moveDown: 'انتقال به پایین',
pause: 'توقف',
start: 'شروع',
resume: 'ادامه',
options: 'گزینه‌ها',
},
size: {
downloadedOf: '{{downloaded}} دانلودشده از {{total}} {{unit}}',
downloadedOfApproximate: '{{downloaded}} دانلودشده از حدود {{total}} {{unit}}',
},
status: {
ready: 'آماده',
staged: 'در صف',
queued: 'در صف',
downloading: 'در حال دانلود',
processing: 'در حال پردازش',
paused: 'متوقف‌شده',
completed: 'تکمیل‌شده',
failed: 'ناموفق',
retrying: 'در حال تلاش مجدد',
},
values: {
processing: 'در حال پردازش…',
muxing: 'در حال ترکیب…',
},
},
speedLimiter: {
title: 'محدودکنندهٔ سرعت',
unlimited: 'نامحدود',
saveLimit: 'ذخیره محدودیت سرعت',
globalSpeedLimit: 'محدودیت سرعت کلی',
description: 'در مورد انتقال‌های جدید و فعال aria2 و دانلودهای رسانه جدید yt-dlp اعمال می‌شود. محدودیت‌های خاص هر دانلود برای آن انتقال‌ها به قوت خود باقی می‌ماند.',
quickPresets: 'پیش‌تنظیم‌های سریع',
removePreset: 'حذف پیش‌تنظیم {{value}} {{unit}}',
customPresetIn: 'پیش‌تنظیم سفارشی بر حسب {{unit}}',
addQuickPreset: 'افزودن پیش‌تنظیم سریع',
globalLimitSaved: 'محدودیت کلی در {{value}} {{unit}} ذخیره شد',
globalLimitDisabled: 'محدودیت سرعت کلی غیرفعال شد',
saveFailed: 'ذخیره محدودیت سرعت کلی ناموفق بود: {{detail}}',
presetAlreadyExists: '{{value}} {{unit}} از قبل در پیش‌تنظیم‌های سریع وجود دارد',
presetAdded: 'پیش‌تنظیم سریع {{value}} {{unit}} اضافه شد',
presetRemoved: 'پیش‌تنظیم سریع {{value}} {{unit}} حذف شد',
},
logs: {
title: 'گزارش‌ها',
entries: '({{count}} ورودی)',
collecting: 'در حال جمع‌آوری',
off: 'خاموش',
allLevels: 'همه سطوح',
levels: {
error: 'خطا',
warn: 'هشدار',
info: 'اطلاعات',
debug: 'دیباگ',
trace: 'ردیابی',
},
pauseLogging: 'توقف گزارش‌گیری تشخیصی',
enableLogging: 'فعال‌سازی گزارش‌گیری تشخیصی',
clearDisplayed: 'پاک کردن گزارش‌های نمایش‌داده‌شده',
export: 'خروجی گزارش‌ها',
exportButton: 'خروجی گزارش‌ها',
logFiles: 'فایل‌های گزارش',
localDiagnostics: 'تشخیص‌های محلی:',
diagnosticsDescription: 'جمع‌آوری اطلاعات تشخیصی اختیاری، محدود و محلی است. اطلاعات حساسی مانند کلیدها، پارامترهای URL و مسیرهای پوشه شخصی هنگام نمایش و خروجی پنهان می‌شوند. هیچ داده‌ای به طور خودکار بارگذاری نمی‌شود.',
noEntries: 'هنوز هیچ گزارش ذخیره‌شده‌ای وجود ندارد.',
disabledDescription: 'گزارش‌گیری تشخیصی خاموش است. گزارش‌های پشتیبانی موجود در صورت در دسترس بودن در اینجا ظاهر می‌شوند.',
copy: 'کپی',
copied: 'در کلیپ‌بورد کپی شد',
copyFailed: 'کپی در کلیپ‌بورد ناموفق بود',
exported: 'گزارش‌های پشتیبانی صادر شدند',
exportFailed: 'صدور گزارش‌ها ناموفق بود: {{detail}}',
cleared: 'گزارش‌ها پاک شدند',
clearFailed: 'پاک کردن گزارش‌ها ناموفق بود: {{detail}}',
enabled: 'گزارش‌گیری تشخیصی فعال شد',
disabled: 'گزارش‌گیری تشخیصی غیرفعال شد',
updateFailed: 'به‌روزرسانی گزارش‌گیری تشخیصی ناموفق بود: {{detail}}',
},
scheduler: {
title: 'زمان‌بند',
running: 'در حال اجرا',
disabled: 'زمان‌بند غیرفعال است',
noScheduledDay: 'هیچ روز زمان‌بندی‌شده‌ای انتخاب نشده است',
unsavedChanges: 'تغییرات ذخیره‌نشده',
runNow: 'اجرای فوری',
pause: 'توقف',
saveSettings: 'ذخیره تنظیمات',
timing: 'زمان‌بندی',
startTime: 'زمان شروع',
stopTime: 'زمان پایان',
timingDescription: 'اگر Firelink در زمان شروع در حالت خواب باشد، صف‌های انتخاب‌شده را هنگام بیدار شدن در ادامه آن روز شروع می‌کند، مگر اینکه زمان پایان گذشته باشد.',
runEveryDay: 'اجرای هر روز',
queuesToSchedule: 'صف‌ها برای زمان‌بندی',
defaultQueue: 'صف پیش‌فرض',
afterCompletion: 'پس از تکمیل',
afterCompletionDescription: 'انتخاب کنید پس از پایان دانلودهای شروع‌شده توسط زمان‌بند، چه اتفاقی بیفتد.',
actionWarning: 'این اقدام می‌تواند سایر کارهای کامپیوتر را مختل کند. Firelink بلافاصله پس از اتمام صف زمان‌بندی‌شده، آن را اجرا می‌کند.',
systemPermissions: 'مجوزهای سیستم',
macPermissionDescription: 'خواب، راه‌اندازی مجدد و خاموش کردن به مجوز macOS Automation برای System Events نیاز دارد.',
permissionGranted: 'مجوز اتوماسیون اعطا شد',
permissionChecking: 'در حال بررسی مجوز اتوماسیون…',
permissionMissing: 'مجوز اتوماسیون موجود نیست',
revokePermission: 'لغو مجوز',
grantPermission: 'اعطای مجوز',
permissionAvailable: 'مجوز اتوماسیون در دسترس است.',
permissionMissingDetails: 'مجوز اتوماسیون موجود نیست. Firelink را در بخش System Settings > Privacy & Security > Automation برای System Events فعال کنید.',
revokePermissionDetails: 'سیستم‌عامل macOS اجازه نمی‌دهد Firelink مجوز اتوماسیون را مستقیماً لغو کند. دسترسی System Events را در System Settings لغو کنید و سپس به Firelink بازگردید.',
requestingPermission: 'در حال درخواست مجوز اتوماسیون…',
enablePermissionDetails: 'Firelink را در بخش System Settings > Privacy & Security > Automation برای System Events فعال کنید و سپس به Firelink بازگردید.',
systemActions: 'اقدامات سیستم',
windowsActionsDescription: 'خواب، راه‌اندازی مجدد و خاموش کردن از دسترسی‌های سیستم Windows استفاده می‌کنند. Firelink هنگام اجرا هر اقدام ردشده‌ای را گزارش می‌دهد؛ هیچ مجوز دائمی از قبل درخواست نمی‌شود.',
linuxActionsDescription: 'خواب، راه‌اندازی مجدد و خاموش کردن از دسکتاپ و سیاست‌های سیستم Linux استفاده می‌کنند. Firelink هنگام اجرا هر اقدام ردشده‌ای را گزارش می‌دهد؛ هیچ مجوز دائمی از قبل درخواست نمی‌شود.',
validationDay: 'حداقل یک روز برای زمان‌بند انتخاب کنید',
validationQueue: 'حداقل یک صف برای زمان‌بند انتخاب کنید',
validationStopTime: 'زمان پایان باید بعد از زمان شروع باشد',
saved: 'تنظیمات زمان‌بند ذخیره شد',
trackingOne: 'در حال پیگیری ۱ دانلود زمان‌بندی‌شده',
trackingMany: 'در حال پیگیری {{count}} دانلود زمان‌بندی‌شده',
noStartableDownloads: 'هیچ دانلودی در صف‌های انتخاب‌شده قابل شروع نیست',
pausedOne: '۱ دانلود فعال متوقف شد',
pausedMany: '{{count}} دانلود فعال متوقف شد',
noActiveDownloads: 'هیچ دانلود فعالی وجود ندارد',
days: {
su: 'ی',
mo: 'د',
tu: 'س',
we: 'چ',
th: 'پ',
fr: 'ج',
sa: 'ش',
},
postActions: {
none: 'هیچ کاری انجام نده',
sleep: 'خواب',
restart: 'راه‌اندازی مجدد',
shutdown: 'خاموش کردن',
},
},
properties: {
progress: 'پیشرفت',
size: 'اندازه',
speed: 'سرعت',
eta: 'زمان باقیمانده',
connections: 'اتصالات',
connectionCount: '{{active}}/{{total}} فعال',
connectionCountUnknown: '—/{{total}} فعال',
connectionsUnavailable: '—',
speedCap: 'سقف سرعت',
category: 'دسته',
lastTry: 'آخرین تلاش',
dateAdded: 'تاریخ افزودن',
destination: 'مقصد',
lastError: 'آخرین خطا',
saved: ' (ذخیره‌شده)',
defaultValue: ' (پیش‌فرض)',
savedTooltip: 'برای این دانلود ذخیره‌شده است؛ تغییرات تنظیمات روی دانلودهای جدید اعمال می‌شود.',
defaultTooltip: 'استفاده از پیش‌فرض کنونی برای دانلودهای جدید.',
identityReadOnly: 'هویت فایل فقط‌خواندنی است. تنظیمات انتقال برای دانلود مجدد ذخیره می‌شوند.',
transferSettings: 'تنظیمات انتقال را می‌توان پس از توقف تغییر داد. انتقال‌های کنونی گزینه‌های فعلی خود را حفظ می‌کنند.',
download: 'دانلود',
url: 'URL',
fileName: 'نام فایل',
saveLocation: 'محل ذخیره',
select: 'انتخاب',
perFile: 'برای هر فایل',
useCurrentDefault: 'استفاده از پیش‌فرض کنونی ({{count}})',
savedPerDownload: 'برای هر دانلود ذخیره‌شده است. پیش‌فرض تنظیمات روی دانلودهای جدید اعمال می‌شود.',
limit: 'محدودیت',
siteLogin: 'ورود به سایت',
siteLoginRedownload: 'ورود به سایت برای دانلود مجدد',
matchingSiteLogin: 'اطلاعات ورود منطبق با سایت',
customCredentials: 'اعتبارنامه‌های سفارشی',
noLogin: 'بدون ورود',
useSavedLogin: 'در صورت وجود از اطلاعات ورود ذخیره‌شده استفاده خواهد شد.',
advancedTransfer: 'انتقال پیشرفته',
advancedTransferRedownload: 'انتقال پیشرفته برای دانلود مجدد',
checksum: 'چک‌سام',
verify: 'تأیید',
algorithm: 'الگوریتم',
digest: 'هش',
expectedDigest: 'هش مورد انتظار',
cookies: 'کوکی‌ها',
headers: 'هدرها',
mirrors: 'آینه‌ها',
username: 'نام کاربری',
password: 'رمز عبور',
enterValidUrl: 'یک URL معتبر وارد کنید.',
fileNameEmpty: 'نام فایل نمی‌تواند خالی باشد.',
cancel: 'لغو',
save: 'ذخیره',
},
keychain: {
title: 'نیاز به دسترسی ذخیره‌سازی اعتبارنامه',
stores: {
portable: 'پوشه داده‌های قابل‌حمل Firelink',
windows: 'مدیر اعتبارنامه Windows',
linux: 'مخزن اعتبارنامه‌های Linux شما',
macos: 'Keychain در macOS',
system: 'مخزن اعتبارنامه‌های این سیستم',
siteCredentials: 'مخزن اعتبارنامه‌های سیستم',
},
grantLabelPortable: 'ادامه',
grantLabelMacos: 'اعطای دسترسی',
grantLabelDefault: 'فعال‌سازی ذخیره‌سازی امن',
description: 'Firelink از افزونه مرورگر برای ثبت دانلودها استفاده می‌کند. برای جفت ماندن افزونه پس از راه‌اندازی مجدد، Firelink توکن جفت‌سازی خود را در {{pairingStore}} ذخیره می‌کند. اعتبارنامه‌های اختیاری سایت در {{siteCredentialStore}} ذخیره می‌شوند.',
portableExplanation: 'توکن جفت‌سازی همراه با این پوشه قابل حمل است. اعتبارنامه‌های سایت در مخزن اعتبارنامه‌های سیستم باقی می‌مانند؛ ممکن است پس از اعطای دسترسی، یک پیغام سیستم ظاهر شود.',
macosExplanation: 'macOS ممکن است پس از اعطای دسترسی، یک پیغام Keychain را نشان دهد.',
defaultExplanation: 'این کار معمولاً بدون پیغام انجام می‌شود. اگر سرویس اعتبارنامه در دسترس نباشد، Firelink خطا را در اینجا نشان می‌دهد و افزونه فقط برای این جلسه جفت باقی می‌ماند.',
note: 'توجه:',
portableNote: 'حالت قابل‌حمل فقط توکن جفت‌سازی را در این پوشه ذخیره می‌کند. این حالت رمزهای عبور سایت یا اعتبارنامه‌های مرورگر را کپی نمی‌کند.',
defaultNote: 'Firelink فقط ورودی اعتبارنامه اختصاصی خود را می‌نویسد و به سایر رمزهای عبور ذخیره‌شده در سیستم شما دسترسی ندارد.',
hint: 'راهنمایی:',
portableHint: 'توکن جفت‌سازی قابل‌حمل از قبل در این پوشه ذخیره‌شده است؛ می‌توانید آن را در اینجا فعال کنید یا بعداً را انتخاب کنید.',
defaultHint: 'اگر بعداً را انتخاب کنید، افزونه فقط برای این جلسه کار خواهد کرد.',
enableFromSettings: 'در هر زمان می‌توانید ذخیره‌سازی را از تنظیمات > یکپارچه‌سازی فعال کنید.',
later: 'بعداً',
enabling: 'در حال فعال‌سازی…',
timeout: 'مهلت درخواست ذخیره‌سازی اعتبارنامه به پایان رسید. می‌توانید بعداً را انتخاب کرده و دوباره امتحان کنید.',
unavailable: '{{store}} در دسترس نیست.',
accessRequired: 'پیش از ایجاد مجدد توکن جفت‌سازی، دسترسی به مخزن اعتبارنامه‌ها را اعطا کنید.',
storeUnavailable: 'دسترسی به مخزن اعتبارنامه در دسترس نیست.',
},
app: {
loading: 'در حال بارگذاری…',
settingsSaveFailed: 'تنظیمات ذخیره نشدند. دسترسی‌های ذخیره‌سازی را بررسی کرده و دوباره امتحان کنید.',
systemActionCountdown: '{{action}} در ۱۰ ثانیه.',
systemActionCancelled: 'اقدام سیستم لغو شد زیرا دانلود دیگری فعال یا در صف است.',
systemActionFailed: 'اقدام سیستم زمان‌بندی‌شده ناموفق بود: {{detail}}',
downloadCompleteTitle: 'دانلود تکمیل‌شده',
downloadCompleteBody: 'دانلود {{fileName}} به پایان رسید.',
unknownFile: 'یک فایل',
downloadFailedTitle: 'دانلود ناموفق',
downloadFailedBody: 'دانلود {{fileName}} با شکست مواجه شد.',
initializeDownloadsFailed: 'راه‌اندازی دانلودهای ذخیره‌شده ناموفق بود: {{detail}}',
extensionDisconnected: 'اتصال افزونه مرورگر قطع شد زیرا توکن جفت‌سازی آن تغییر کرده است.',
copyToken: 'کپی توکن',
integrations: 'یکپارچه‌سازی',
copyTokenFailed: 'کپی توکن جفت‌سازی ناموفق بود: {{detail}}',
credentialPersistenceFailed: 'ذخیره‌سازی امن اعتبارنامه در دسترس نیست. جفت‌سازی مرورگر فقط برای این جلسه کار می‌کند: {{detail}}',
resumeDownloadsFailed: 'ادامه دانلودهای ذخیره‌شده ناموفق بود: {{detail}}',
updateAvailable: 'نسخه جدید Firelink {{version}} در دسترس است.',
viewRelease: 'مشاهده تغییرات نسخه',
sleepPreventionFailed: 'جلوگیری از خواب سیستم به‌روزرسانی نشد: {{detail}}',
schedulerNoQueues: 'زمان‌بند هیچ صف معتبری انتخاب نکرده است. تنظیمات زمان‌بندی را به‌روزرسانی کنید.',
schedulerPauseOneFailed: 'زمان‌بند نتوانست ۱ دانلود را متوقف کند.',
schedulerPauseManyFailed: 'زمان‌بند نتوانست {{count}} دانلود را متوقف کند.',
scheduledIncomplete: 'همه دانلودهای زمان‌بندی‌شده تکمیل نشدند. اقدام سیستم پس از اتمام صف رد شد.',
scheduledActionSkippedActive: 'اقدام سیستم زمان‌بندی‌شده رد شد زیرا دانلود دیگری فعال یا در صف است.',
notificationsDisabled: 'اعلان‌های سیستم برای Firelink غیرفعال‌اند.',
notificationsFailed: 'پیکربندی اعلان‌ها ناموفق بود: {{detail}}',
},
sidebar: {
deleteQueueConfirm: 'حذف صف "{{name}}"؟ دانلودهای ناتمام آن به صف اصلی منتقل خواهند شد.',
deleteQueueFailed: 'حذف صف ناموفق بود: {{detail}}',
queueNameEmpty: 'نام صف نمی‌تواند خالی باشد',
queueNameExists: 'یک صف با این نام از قبل وجود دارد',
startQueueFailed: 'شروع صف ناموفق بود: {{detail}}',
pauseQueueFailed: 'توقف صف ناموفق بود: {{detail}}',
},
downloadTable: {
unknownQueue: 'صف نامشخص',
allDownloads: 'همه دانلودها',
active: 'فعال',
completed: 'تکمیل‌شده',
unfinished: 'ناتمام',
headers: {
fileName: 'نام فایل',
size: 'اندازه',
status: 'وضعیت',
speed: 'سرعت',
eta: 'زمان باقیمانده',
dateAdded: 'تاریخ افزودن',
},
columnOptions: {
open: 'گزینه‌های ستون {{column}}',
dragToReorder: 'برای جابه‌جایی ستون‌ها بکشید',
alignContent: 'تراز محتوا',
left: 'چپ',
center: 'وسط',
right: 'راست',
resetLayout: 'بازنشانی چیدمان ستون‌ها',
},
addDownload: 'افزودن دانلود',
resumeAll: 'ادامه همه',
pauseAll: 'توقف همه',
queueEmpty: 'صف خالی است',
noCompletedDownloads: 'هیچ دانلود تکمیل‌شده‌ای وجود ندارد',
noDownloads: 'هیچ دانلودی وجود ندارد',
queueEmptyDescription: 'دانلودها را از منوی آیتم‌ها یا پنجره افزودن به این صف اضافه کنید.',
completedDescription: 'دانلودهای تکمیل‌شده در اینجا ظاهر می‌شوند.',
clickToAdd: 'برای افزودن دانلودها، روی',
addButtonOr: 'یا',
toAddDownloads: 'کلیک کنید',
nonResumableOne: '۱ دانلود از ادامه‌دادن پشتیبانی نمی‌کند. اگر آن را متوقف کنید، بعداً باید از ابتدا شروع کنید. آیا از توقف آن مطمئن هستید؟',
nonResumableMany: '{{count}} دانلود از ادامه‌دادن پشتیبانی نمی‌کنند. اگر آن‌ها را متوقف کنید، بعداً باید از ابتدا شروع کنید. آیا از توقف آن‌ها مطمئن هستید؟',
interactionError: '{{message}}: {{detail}}',
openFileFailed: 'باز کردن فایل دانلودشده ناموفق بود',
revealFileFailed: 'نمایش فایل دانلودشده در پوشه ناموفق بود',
pauseFailed: 'توقف دانلود ناموفق بود',
resumeFailed: 'ادامه {{fileName}} ناموفق بود',
moveManyFailed: 'انتقال دانلودها به صف ناموفق بود',
moveOneFailed: 'انتقال دانلود به صف ناموفق بود',
copyAddressesFailed: 'آدرس‌ها کپی نشدند',
copyAddressFailed: 'آدرس کپی نشد',
copyPathFailed: 'مسیر فایل کپی نشد',
missingFileName: 'نام فایل وجود ندارد',
redownloadFailed: 'دانلود مجدد ناموفق بود',
startResume: 'شروع/ادامه',
addToQueue: 'افزودن به صف',
copyAddress: 'کپی آدرس',
remove: 'حذف',
open: 'باز کردن',
showInFolder: 'نمایش در پوشه',
pause: 'توقف',
start: 'شروع',
resume: 'ادامه',
redownload: 'دانلود مجدد',
copyFilePath: 'کپی مسیر فایل',
properties: 'ویژگی‌ها',
backendRejectedStart: 'درخواست شروع/ادامه توسط موتور دانلود رد شد.',
transferActive: 'نمی‌توانید ویژگی‌ها را هنگام فعال بودن دانلود تغییر دهید. ابتدا آن را متوقف کنید.',
redownloadNotFound: 'دانلود مجدد ناموفق: دانلود پیدا نشد.',
redownloadActive: 'نمی‌توانید یک دانلود با وضعیت {{status}} را دوباره دانلود کنید. ابتدا آن را متوقف کنید یا منتظر بمانید تا تمام شود.',
originalUrlMissing: 'دانلود مجدد ناموفق: URL اصلی وجود ندارد.',
pauseBeforeMove: 'قبل از انتقال {{file}} به صف دیگر، آن را متوقف کنید.',
},
addDownloads: {
unknownSize: 'اندازه نامشخص',
unknown: 'نامشخص',
discardSetup: 'صرف‌نظر از این پیکربندی دانلود؟',
speedInvalid: 'محدودیت سرعت باید بیشتر از صفر باشد',
duplicateResolveFailed: 'دانلودهای تکراری برطرف نشدند: {{detail}}',
addedWithFailures: '{{added}} اضافه شد، {{failed}} ناموفق بود. {{detail}}',
addedOne: '۱ دانلود اضافه شد',
addedMany: '{{count}} دانلود اضافه شد',
updatedOne: '۱ دانلود به‌روزرسانی شد',
updatedMany: '{{count}} دانلود به‌روزرسانی شد',
urlAlreadyQueued: 'URL در حال حاضر در صف است',
destinationConflict: 'یک دانلود انتخاب‌شده دیگر از این مقصد استفاده می‌کند',
existingDownloadDestination: 'دانلود Firelink موجود از این مقصد استفاده می‌کند',
matchingDownloadFilename: 'یک دانلود ناتمام از قبل همین نام فایل را دارد',
fileExistsOnDisk: 'فایل روی دیسک وجود دارد؛ برای جلوگیری از حذف داده‌های نامرتبط، نام آن را تغییر دهید یا رد شوید',
backendRejectedStart: 'شروع دانلود توسط موتور دانلود رد شد.',
playlistNoEntries: 'لیست پخش شامل هیچ ورودی قابل دانلودی نیست',
invalidMediaMetadata: 'متادیتای رسانه نامعتبر است یا هیچ فرمتی یافت نشد',
noAvailableName: 'یک نام در دسترس برای {{file}} پیدا نشد.',
pauseBeforeReplace: 'قبل از جایگزینی {{file}}، آن را متوقف کنید.',
cannotReplace: 'نمی‌توان {{file}} را جایگزین کرد: فایل متعلق به یک دانلود Firelink نیست.',
downloadLinks: 'پیوندهای دانلود',
pastePlaceholder: '\u2066URL\u2069های \u2066HTTP\u2069، \u2066HTTPS\u2069، \u2066FTP\u2069 یا \u2066SFTP\u2069 را در اینجا جای‌گذاری کنید…\n\nبرای دانلود رسانه، پیوندهایی از \u2066YouTube\u2069، \u2066X\u2069، \u2066TikTok\u2069، \u2066Instagram\u2069، \u2066Reddit\u2069 و غیره جای‌گذاری کنید.',
playlistSummary: 'لیست پخش "{{title}}": {{loaded}} از {{total}} ورودی بارگیری شد{{truncated}}{{skipped}}',
safeEntryLimit: ' (به حد مجاز ایمن ورودی‌ها رسیدیم)',
selectedSummary: '{{ready}} انتخاب‌شده آماده، {{fallback}} اطلاعات جایگزین، {{mediaRetry}} تلاش مجدد رسانه، {{blocked}} مسدودشده',
clearSelection: 'پاک کردن انتخاب',
selectAll: 'انتخاب همه',
refreshMetadata: 'تازه‌سازی متادیتا',
files: 'فایل‌ها',
required: 'الزامی',
free: 'فضای آزاد',
preview: 'پیش‌نمایش',
file: 'فایل',
size: 'اندازه',
status: 'وضعیت',
downloadPreview: 'پیش‌نمایش دانلود',
noLinks: 'هنوز هیچ پیوندی اضافه نشده است.',
selectItem: 'انتخاب {{file}}',
fetchingPlaylist: 'در حال دریافت لیست پخش…',
fetching: 'در حال دریافت…',
unsafeUrl: 'URL ناامن',
playlistFailed: 'دریافت لیست پخش ناموفق بود',
metadataFailed: 'دریافت متادیتا ناموفق بود',
fallback: 'گزینه جایگزین',
invalid: 'نامعتبر',
ready: 'آماده',
mediaFormat: 'فرمت رسانه',
playlistItem: 'مورد لیست پخش {{index}}',
fetchingMediaStreams: 'در حال دریافت جریان‌های رسانه…',
availableStreams: 'جریان‌های موجود',
availableMediaStreams: 'جریان‌های رسانه موجود',
metadataUnavailable: 'متادیتا در دسترس نیست. پیش از افزودن این رسانه، متادیتا را تازه‌سازی کنید.',
saveLocation: 'محل ذخیره',
browse: 'انتخاب…',
categoryFolders: 'فایل‌ها در پوشه‌های دسته‌بندی سازمان‌دهی خواهند شد.',
sharedFolder: 'همه دانلودهای انتخاب‌شده از این پوشه استفاده خواهند کرد.',
dedicatedFolder: 'ذخیره در پوشه جدید',
dedicatedFolderName: 'نام پوشه',
dedicatedFolderDescription: 'این پوشه را داخل هر پوشه دسته‌بندی خودکار ایجاد می‌کند.',
dedicatedFolderManualDescription: 'این پوشه را داخل محل ذخیره انتخاب‌شده ایجاد می‌کند.',
saveFolderName: 'ذخیره نام پوشه',
dedicatedFolderNameRequired: 'پیش از ذخیره، نام پوشه را وارد کنید.',
transferSettings: 'تنظیمات انتقال',
connectionsPerFile: 'اتصالات در هر فایل',
connectionsPerFileAria: 'اتصالات در هر فایل',
limitSpeedPerFile: 'محدودیت سرعت در هر فایل',
speedLimitPerFileAria: 'محدودیت سرعت در هر فایل',
authorization: 'احراز هویت',
useAuthorization: 'استفاده از احراز هویت',
username: 'نام کاربری',
password: 'رمز عبور',
advancedTransfer: 'انتقال پیشرفته',
verifyChecksum: 'تأیید چک‌سام',
checksumAlgorithm: 'الگوریتم چک‌سام',
expectedDigest: 'هش مورد انتظار',
headers: 'هدرها',
requestHeaders: 'هدرهای درخواست',
cookies: 'کوکی‌ها',
cookiePlaceholder: 'name=value; other=value',
mirrors: 'آینه‌ها',
cancel: 'لغو',
startDownloads: 'شروع دانلودها',
addToQueue: 'افزودن به صف',
pasteOneOrMore: 'یک یا چند پیوند را جای‌گذاری کنید.',
selectAtLeastOne: 'حداقل یک دانلود را انتخاب کنید.',
correctInvalidOne: 'قبل از ادامه، {{count}} URL نامعتبر را اصلاح یا حذف کنید.',
correctInvalidFew: 'قبل از ادامه، {{count}} URL نامعتبر را اصلاح یا حذف کنید.',
correctInvalidMany: 'قبل از ادامه، {{count}} URL نامعتبر را اصلاح یا حذف کنید.',
waitingForMetadataOne: 'در انتظار متادیتای {{count}} دانلود…',
waitingForMetadataFew: 'در انتظار متادیتای {{count}} دانلود…',
waitingForMetadataMany: 'در انتظار متادیتای {{count}} دانلود…',
removeUnsafeOne: 'قبل از ادامه، {{count}} URL ناامن را حذف کنید.',
removeUnsafeFew: 'قبل از ادامه، {{count}} URL ناامن را حذف کنید.',
removeUnsafeMany: 'قبل از ادامه، {{count}} URL ناامن را حذف کنید.',
mediaMetadataUnavailableSummaryOne: 'متادیتای رسانه برای {{count}} مورد در دسترس نیست. پیش از افزودن، متادیتا را تازه‌سازی کنید.',
mediaMetadataUnavailableSummaryFew: 'متادیتای رسانه برای {{count}} مورد در دسترس نیست. پیش از افزودن، متادیتا را تازه‌سازی کنید.',
mediaMetadataUnavailableSummaryMany: 'متادیتای رسانه برای {{count}} مورد در دسترس نیست. پیش از افزودن، متادیتا را تازه‌سازی کنید.',
metadataUnavailableFallback: 'متادیتا در دسترس نیست. دانلودها همچنان می‌توانند با اطلاعات جایگزین اضافه شوند.',
fallbackReadyOne: '{{ready}} دانلود آماده؛ برای {{failed}} از نام فایل جایگزین و اندازه نامشخص استفاده خواهد شد.',
fallbackReadyFew: '{{ready}} دانلود آماده؛ برای {{failed}} از نام فایل جایگزین و اندازه نامشخص استفاده خواهد شد.',
fallbackReadyMany: '{{ready}} دانلود آماده؛ برای {{failed}} از نام فایل جایگزین و اندازه نامشخص استفاده خواهد شد.',
readyToAddOne: 'آماده برای افزودن {{count}} دانلود.',
readyToAddFew: 'آماده برای افزودن {{count}} دانلود.',
readyToAddMany: 'آماده برای افزودن {{count}} دانلود.',
},
settings: {
tabs: {
downloads: 'دانلودها',
lookAndFeel: 'ظاهر',
network: 'شبکه',
locations: 'مکان‌ها',
siteLogins: 'ورود به سایت‌ها',
power: 'انرژی',
engine: 'موتور',
integrations: 'یکپارچه‌سازی‌ها',
about: 'درباره',
},
common: {
thisOs: 'این سیستم‌عامل',
checking: 'در حال بررسی…',
ready: 'آماده',
errorMissing: 'خطا / یافت‌نشده',
hideTechnicalDetails: 'پنهان کردن جزئیات فنی',
showTechnicalDetails: 'نمایش جزئیات فنی',
binary: 'فایل باینری',
expected: 'مقدار مورد انتظار',
error: 'خطا',
tip: 'نکته',
stderr: 'stderr',
daemonProcessAlive: 'فرآیند پس‌زمینه (Daemon) فعال است',
rpcReady: 'سرویس RPC آماده است',
rpcPort: 'پورت RPC',
daemonStderr: 'خروجی stderr سرویس پس‌زمینه',
packaging: 'بسته‌بندی: PyInstaller onedir (نیاز به پوشه _internal)',
internalDirectoryFound: 'پوشه _internal پیداشده',
pythonRuntimeFound: 'محیط اجرای Python پیداشده',
externalOpenFailed: 'باز کردن {{label}} ناموفق بود: {{detail}}',
updateUnexpectedResponse: 'بررسی به‌روزرسانی با یک پاسخ غیرمنتظره مواجه شد.',
updateUntrustedReleaseUrl: 'بررسی به‌روزرسانی با یک URL انتشار نامعتبر مواجه شد.',
updateFailed: 'بررسی به‌روزرسانی ناموفق بود: {{detail}}',
},
downloads: {
defaultConnections: 'اتصالات پیش‌فرض:',
defaultConnectionsDescription: 'دانلودهای جدید؛ موارد موجود مقادیر ذخیره‌شده خود را حفظ می‌کنند',
parallelDownloads: 'دانلودهای موازی:',
parallelDownloadsDescription: 'حداکثر فایل‌های فعال همزمان',
automaticRetries: 'تلاش‌های مجدد خودکار:',
automaticRetriesDescription: 'اگر اتصالی ناموفق باشد',
systemNotification: 'نمایش اعلان سیستم هنگام تکمیل دانلود',
systemNotificationDescription: 'از تنظیمات اعلان سیستم‌عامل شما استفاده می‌کند',
completionChime: 'پخش صدای تکمیل درون برنامه‌ای',
completionChimeDescription: 'صدای اختیاری Firelink، مستقل از اعلان‌های سیستم',
clipboardLinks: 'افزودن پیوندهای کلیپ‌بورد وقتی Firelink فعال می‌شود',
clipboardLinksDescription: 'پیوندهای کپی‌شده پشتیبانی‌شده را در پنجره افزودن باز می‌کند. به‌طور پیش‌فرض خاموش است.',
},
lookAndFeel: {
language: 'زبان',
languageDescription: 'زبان رابط Firelink را انتخاب کنید.',
sidebarPosition: 'جایگاه نوار کناری',
sidebarPositionDescription: 'مشخص کنید نوار کناری کجا نمایش داده شود. حالت خودکار از جهت رابط کاربری پیروی می‌کند.',
sidebarPositionAutomatic: 'خودکار (پیشنهادشده)',
sidebarPositionLeft: 'چپ',
sidebarPositionRight: 'راست',
languageSystem: 'زبان سیستم',
languageEnglish: 'انگلیسی',
languageChinese: 'چینی ساده‌شده',
languageHebrew: 'عبری',
languagePersian: 'فارسی',
languageUkrainian: 'اوکراینی',
languageRussian: 'روسی',
appTheme: 'ظاهر برنامه',
theme: 'ظاهر',
ariaLabel: 'ظاهر برنامه',
system: 'سیستم',
light: 'روشن',
dark: 'تاریک',
dracula: 'Dracula',
nord: 'Nord',
systemAppearance: 'برنامه از حالت روشن یا تاریک فعلی {{platform}} پیروی می‌کند.',
display: 'نمایش',
fontSize: 'اندازه قلم',
fontSizeDescription: 'اندازه فهرست دانلود و کنترل‌های فشرده را تغییر می‌دهد.',
small: 'کوچک',
standard: 'استاندارد',
large: 'بزرگ',
listDensity: 'تراکم فهرست',
listDensityDescription: 'ارتفاع سطر، فاصله‌گذاری و مقیاس نوار پیشرفت را تغییر می‌دهد.',
compact: 'فشرده',
comfortable: 'معمولی',
relaxed: 'بسیار راحت',
osIntegration: 'یکپارچه‌سازی سیستم‌عامل',
dockBadge: 'نمایش نشان روی آیکون Dock',
dockBadgeDescription: 'تعداد دانلودهای فعال را روی آیکون Firelink در Dock نشان می‌دهد.',
menuBarIcon: 'نمایش آیکون نوار منو',
statusIndicatorIcon: 'نمایش آیکون نشانگر وضعیت',
systemTrayIcon: 'نمایش آیکون سینی سیستم',
macosTrayDescription: 'دسترسی سریع از نوار منوی macOS فراهم می‌کند.',
windowsTrayDescription: 'دسترسی سریع از ناحیه اعلان‌های Windows فراهم می‌کند.',
linuxTrayDescription: 'در صورت وجود، دسترسی سریع از سینی دسکتاپ یا ناحیه وضعیت فراهم می‌کند.',
defaultTrayDescription: 'در صورت وجود، دسترسی سریع از ناحیه سینی سیستم‌عامل فراهم می‌کند.',
},
network: {
proxy: 'پروکسی',
mode: 'حالت',
modeDescription: 'استفاده از پروکسی را برای درخواست‌های دانلود جدید کنترل می‌کند.',
noProxy: 'بدون پروکسی',
systemProxy: 'پروکسی سیستم',
customProxy: 'پروکسی سفارشی',
proxyHost: 'میزبان پروکسی',
proxyHostDescription: 'نام میزبان، آدرس IP یا URL پروکسی HTTP.',
proxyHostPlaceholder: '127.0.0.1 یا http://127.0.0.1',
proxyPort: 'پورت پروکسی',
proxyPortDescription: 'محدوده معتبر بین ۱ تا ۶۵۵۳۵ است.',
noProxyDescription: 'دانلودها پروکسی‌های پیکربندی‌شده را نادیده می‌گیرند.',
systemProxyDescription: 'دانلودها از پروکسی سیستم شناسایی‌شده {{platform}} استفاده می‌کنند. دانلودهای فایل عادی به یک نقطه پایانی پروکسی HTTP یا HTTPS نیاز دارند؛ دانلودهای رسانه می‌توانند از SOCKS استفاده کنند.',
customProxyDescription: 'دانلودها از نقطه پایانی پروکسی HTTP پیکربندی‌شده برای متادیتا و موتورهای دانلود استفاده می‌کنند.',
invalidCustomProxy: 'برای فعال کردن پروکسی سفارشی یک میزبان و پورت پروکسی HTTP معتبر وارد کنید.',
incompleteCustomProxy: 'برای فعال کردن پروکسی سفارشی یک میزبان و پورت پروکسی وارد کنید.',
checkingSystemProxy: 'در حال بررسی پیکربندی پروکسی سیستم…',
detectedSystemProxy: 'یک پروکسی سیستم شناسایی شد. دانلودهای فایل عادی به یک نقطه پایانی HTTP یا HTTPS نیاز دارند؛ دانلودهای رسانه می‌توانند از SOCKS استفاده کنند.',
noSystemProxy: 'هیچ پروکسی سیستم قابل استفاده‌ای شناسایی نشد. دانلودها از هیچ پروکسی‌ای استفاده نخواهند کرد.',
systemProxyReadFailed: 'پیکربندی پروکسی سیستم قابل خواندن نیست. بدون پروکسی را انتخاب کنید یا هنگامی که در دسترس است دوباره امتحان کنید.',
identity: 'هویت',
customUserAgent: 'User-Agent سفارشی',
userAgentDescription: 'در دریافت‌های متادیتا و موتورهای دانلود اعمال می‌شود.',
userAgentPlaceholder: 'برای پیش‌فرض Firelink خالی بگذارید',
userAgentOverrides: 'هدر User-Agent خروجی را بازنویسی می‌کند. برای پیش‌فرض‌های Firelink خالی بگذارید.',
chromeWindows: 'Chrome (Windows)',
chromeMacos: 'Chrome (macOS)',
edgeWindows: 'Edge (Windows)',
firefoxWindows: 'Firefox (Windows)',
firefoxMacos: 'Firefox (macOS)',
safariMacos: 'Safari (macOS)',
windowsDesktop: 'دسکتاپ Windows',
macosDesktop: 'دسکتاپ macOS',
},
locations: {
baseDownloadFolder: 'پوشه اصلی دانلود',
baseDownloadFolderDescription: 'پوشه‌های دسته خودکار در داخل این پوشه ایجاد می‌شوند.',
browse: 'انتخاب…',
baseFolderCreateFailed: 'پوشه اصلی ذخیره شد، اما ایجاد پوشه‌های دسته ناموفق بود: {{detail}}',
baseFolderUpdated: 'پوشه اصلی دانلود به‌روزرسانی شد',
askWhereToSave: 'هنگام افزودن دانلود بپرس کجا ذخیره شود',
rememberLastUsedDownloadDirectory: 'پوشه دانلود اخیراً استفاده‌شده را به خاطر بسپار',
rememberLastUsedDownloadDirectoryDescription: 'از آخرین پوشه انتخاب‌شده در پنجره افزودن برای دانلود بعدی تا زمان راه‌اندازی مجدد برنامه استفاده کنید.',
automaticCategorySubfolders: 'فایل‌ها را به‌طور خودکار در زیرپوشه‌های دسته ذخیره کن',
automaticCategorySubfoldersDescription: 'وقتی خاموش است، دانلودهای خودکار از پوشه پایه استفاده می‌کنند.',
categorySubfolders: 'زیرپوشه‌های دسته',
relativeToBase: 'نسبت به پوشه پایه',
disabled: 'غیرفعال',
categorySubfolder: 'زیرپوشه {{category}}',
customFolder: 'پوشه سفارشی',
useAutomatic: 'استفاده از حالت خودکار',
resetDefaults: 'بازنشانی به پیش‌فرض‌ها',
resetToast: 'مکان‌های دسته‌ها به پیش‌فرض بازنشانی شد',
},
siteLogins: {
title: 'اعتبارنامه‌های سایت',
noSavedLogins: 'هیچ ورودی ذخیره‌شده‌ای وجود ندارد.',
user: 'کاربر: {{username}}',
deleteCredential: 'حذف اعتبارنامه',
deletedCredential: 'اعتبارنامه حذف شد',
deleteFailed: 'اعتبارنامه حذف نشد: {{detail}}',
addTitle: 'افزودن اعتبارنامه‌های سایت',
urlPattern: 'الگوی URL:',
urlPatternPlaceholder: 'مثلاً *.example.com یا example.com/downloads',
username: 'نام کاربری:',
password: 'رمز عبور:',
usernamePlaceholder: 'نام کاربری',
passwordPlaceholder: 'رمز عبور',
saving: 'در حال ذخیره…',
addLogin: 'افزودن ورود',
urlPatternRequired: 'الگوی URL الزامی است.',
urlPatternWhitespace: 'الگوی URL نمی‌تواند شامل فاصله باشد.',
usernameRequired: 'نام کاربری الزامی است.',
passwordRequired: 'رمز عبور الزامی است.',
accessBeforeSave: 'قبل از ذخیره ورود به سایت، به مخزن اعتبارنامه دسترسی بدهید.',
saveFailed: 'ذخیره ایمن اعتبارنامه سایت ناموفق بود.',
addedCredential: 'اعتبارنامه سایت اضافه شد',
},
power: {
title: 'مدیریت انرژی',
preventSleep: 'جلوگیری از خواب سیستم در حالی که دانلودها فعال هستند',
preventSleepDescription: 'نمایشگر همچنان می‌تواند خاموش شود. Firelink تنها به اندازه‌ای دستگاه را بیدار نگه می‌دارد تا انتقال‌های فعال را تکمیل کند.',
},
engine: {
title: 'دانلودکننده رسانه و موتورها',
description: 'نتایج موفقیت‌آمیز برای این جلسه برنامه استفاده مجدد می‌شوند. بررسی مجدد باعث اعتبارسنجی واقعی دوباره می‌شود.',
recheck: 'بررسی مجدد موتورها',
coreDownloader: 'دانلودکننده اصلی (aria2)',
version: 'نسخه:',
status: 'وضعیت:',
mediaExtractors: 'استخراج‌کننده‌های رسانه',
browserCookiesSource: 'منبع کوکی‌های مرورگر:',
cookieDescription: 'yt-dlp کوکی‌های مرورگر را می‌خواند تا محدودیت‌های دانلود ویدیو را دور بزند یا به رسانه‌های محدود دسترسی پیدا کند. Firelink کوکی‌های مرورگر را ذخیره نمی‌کند.',
none: 'هیچ‌کدام',
},
integrations: {
title: 'اتصال افزونه مرورگر',
description: 'ثبت مستقیم دانلودها از مرورگر خود در سه مرحله آسان.',
portablePairingEnabled: 'جفت‌سازی قابل‌حمل فعال‌شده',
pairingTokenPersisted: 'توکن جفت‌سازی ذخیره‌شده',
portablePersistedDescription: 'توکن جفت‌سازی شما با این پوشه قابل‌حمل Firelink ذخیره‌شده است و هنگام انتقال پوشه باقی می‌ماند. با پوشه به عنوان داده حساس برخورد کنید.',
systemPersistedDescription: 'توکن جفت‌سازی شما در مخزن اعتبارنامه‌های سیستم ذخیره‌شده است و پس از راه‌اندازی مجدد باقی می‌ماند.',
portablePairingAvailable: 'جفت‌سازی قابل‌حمل در دسترس است',
credentialStorageNeeded: 'نیاز به ذخیره‌سازی اعتبارنامه',
portableAvailableDescription: 'توکن جفت‌سازی شما با این پوشه قابل‌حمل Firelink ذخیره‌شده است و پس از راه‌اندازی مجدد باقی می‌ماند. برای مشاهده هشدار ذخیره‌سازی قابل‌حمل آن را اینجا فعال کنید.',
credentialStorageDescription: 'Firelink به دسترسی به مخزن اعتبارنامه‌های این سیستم نیاز دارد تا توکن جفت‌سازی شما را پس از راه‌اندازی مجدد به‌طور امن حفظ کند. در حال حاضر، افزونه شما فقط برای این جلسه متصل باقی می‌ماند.',
reviewPortablePairing: 'بررسی جفت‌سازی قابل‌حمل',
grantCredentialAccess: 'اعطای دسترسی اعتبارنامه',
copyToken: 'کپی توکن',
tokenDescription: 'این توکن امن افزونه مرورگر شما را مجاز می‌سازد.',
tokenCopied: 'توکن در کلیپ‌بورد کپی شد!',
tokenCopyFailed: 'توکن کپی نشد: {{detail}}',
pairingTokenRegenerated: 'توکن جفت‌سازی دوباره ایجاد شد',
regenerateFailed: 'ایجاد دوباره توکن جفت‌سازی ناموفق بود: {{detail}}',
getExtension: 'دریافت افزونه',
extensionDescription: 'Firelink Companion را برای مرورگرهای Firefox یا Chromium نصب کنید.',
firefoxAddons: 'افزونه‌های Firefox',
chromiumZip: 'Chromium ZIP',
pasteConnect: 'جای‌گذاری و اتصال',
pasteConnectDescription: 'روی آیکون Firelink در نوار ابزار مرورگر خود کلیک کنید و توکن کپی‌شده را جای‌گذاری کنید.',
extensionServerStatus: 'وضعیت سرور افزونه:',
listening: '● در حال گوش دادن به 127.0.0.1:{{port}}',
unavailable: '● سرور در دسترس نیست',
},
about: {
firelinkIcon: 'آیکون Firelink',
unknown: 'نامشخص',
version: 'نسخه {{version}}',
description: 'مدیریت دانلود چند پلتفرمی برای macOS، Windows و Linux.',
sourceCode: 'کد منبع',
source: 'منبع',
upToDate: 'Firelink {{version}} به‌روز است.',
updateAvailable: 'Firelink {{version}} در دسترس است.',
viewRelease: 'مشاهده انتشار',
updates: 'به‌روزرسانی‌ها',
checkForUpdates: 'بررسی به‌روزرسانی‌ها',
updateDescription: 'Firelink انتشار‌های GitHub را برای نسخه‌های جدید بررسی می‌کند.',
checking: 'در حال بررسی…',
checkNow: 'اکنون بررسی کن',
automaticUpdates: 'بررسی خودکار برای به‌روزرسانی‌ها',
createdBy: 'ایجادشده توسط NimBold',
mitLicense: 'مجوز MIT',
credits: 'اعتبارات: aria2، yt-dlp، FFmpeg و Deno.',
copyright: 'حق کپی‌رایت © 2026 NimBold. تمامی حقوق محفوظ است.',
},
},
status: {
ready: 'آماده',
active: '{{count}} فعال',
queued: '{{count}} در صف',
done: '{{count}} تکمیل‌شده',
},
} as const;
export default fa;
+755
View File
@@ -0,0 +1,755 @@
const he = {
navigation: {
library: 'ספרייה',
filters: {
all: 'הכול',
active: 'פעילות',
completed: 'הושלמו',
unfinished: 'לא הושלמו',
},
categories: {
musics: 'מוזיקה',
movies: 'סרטים',
compressed: 'קבצים דחוסים',
documents: 'מסמכים',
pictures: 'תמונות',
applications: 'יישומים',
other: 'אחר',
},
folders: 'תיקיות',
queues: 'תורים',
tools: 'כלים',
scheduler: 'מתזמן',
speedLimiter: 'הגבלת מהירות',
logs: 'יומנים',
settings: 'הגדרות',
},
actions: {
hideSidebar: 'הסתרת סרגל צד',
showSidebar: 'הצגת סרגל צד',
resizeSidebar: 'שינוי גודל סרגל הצד',
cancel: 'ביטול',
continue: 'המשך',
dismissNotification: 'סגירת התראה',
queueName: 'שם התור',
addNewQueue: 'הוספת תור חדש',
startQueue: 'הפעלת תור',
pauseQueue: 'השהיית תור',
renameQueue: 'שינוי שם תור',
deleteQueue: 'מחיקת תור',
},
dialogs: {
errorBoundary: {
title: 'משהו השתבש.',
description: 'אירעה שגיאה קריטית בעץ הרכיבים של React. פרטי השגיאה למטה עשויים לעזור באיתור הגורם לה.',
reload: 'טעינה מחדש של האפליקציה',
},
duplicateDownloads: {
title: 'זוהו הורדות כפולות',
description: 'חלק מההורדות שברצונך להוסיף כבר קיימות בתור או בדיסק. נא לבחור כיצד לפתור התנגשויות אלו.',
rename: 'שינוי שם',
replace: 'החלפה',
skip: 'דילוג',
},
removeDownload: {
title: 'הסרת הורדה',
confirmationSingle: 'האם ברצונך להסיר פריט זה מהרשימה? ניתן לבחור למחוק גם את הקובץ מהכונן הקשיח.',
confirmationMultiple: 'האם ברצונך להסיר {{count}} פריטים אלו מהרשימה? ניתן לבחור למחוק גם את הקבצים מהכונן הקשיח.',
errorSummary: '{{succeeded}} הוסרו, {{failed}} נכשלו: {{detail}}',
remove: 'הסרה',
deleteFile: 'מחיקת קובץ',
},
},
window: {
controls: 'פקדי חלון',
close: 'סגירה',
minimize: 'מזעור',
maximize: 'הגדלה',
},
downloads: {
actions: {
moveUp: 'הזזה למעלה',
moveDown: 'הזזה למטה',
pause: 'השהייה',
start: 'הפעלה',
resume: 'חידוש',
options: 'אפשרויות',
},
size: {
downloadedOf: '{{downloaded}} הורדו מתוך {{total}} {{unit}}',
downloadedOfApproximate: '{{downloaded}} הורדו מתוך כ-{{total}} {{unit}}',
},
status: {
ready: 'מוכן',
staged: 'בתור',
queued: 'בתור',
downloading: 'מוריד',
processing: 'מעבד',
paused: 'מושהה',
completed: 'הושלם',
failed: 'נכשל',
retrying: 'ניסיון חוזר',
},
values: {
processing: 'מעבד…',
muxing: 'ממזג…',
},
},
speedLimiter: {
title: 'הגבלת מהירות',
unlimited: 'ללא הגבלה',
saveLimit: 'שמירת הגבלה',
globalSpeedLimit: 'הגבלת מהירות כוללת',
description: 'חלה על העברות aria2 חדשות ופעילות ועל הורדות מדיה חדשות של yt-dlp. הגבלות פרטניות עבור הורדה מסוימת נשמרות.',
quickPresets: 'פרופילים מהירים',
removePreset: 'הסרת פרופיל {{value}} {{unit}}',
customPresetIn: 'פרופיל מותאם אישית ב-{{unit}}',
addQuickPreset: 'הוספת פרופיל מהיר',
globalLimitSaved: 'הגבלה כוללת נשמרה: {{value}} {{unit}}',
globalLimitDisabled: 'הגבלת מהירות כוללת הושבתה',
saveFailed: 'לא ניתן לשמור את הגבלת המהירות הכוללת: {{detail}}',
presetAlreadyExists: '{{value}} {{unit}} כבר קיים בפרופילים מהירים',
presetAdded: 'נוסף פרופיל מהיר של {{value}} {{unit}}',
presetRemoved: 'הוסר פרופיל מהיר של {{value}} {{unit}}',
},
logs: {
title: 'יומנים',
entries: '({{count}} רשומות)',
collecting: 'אוסף',
off: 'כבוי',
allLevels: 'כל הרמות',
levels: {
error: 'שגיאה',
warn: 'אזהרה',
info: 'מידע',
debug: 'ניפוי באגים',
trace: 'מעקב',
},
pauseLogging: 'השהיית רישום אבחון',
enableLogging: 'הפעלת רישום אבחון',
clearDisplayed: 'ניקוי היומנים המוצגים',
export: 'ייצוא יומנים',
exportButton: 'ייצוא יומנים',
logFiles: 'קבצי יומן',
localDiagnostics: 'אבחון מקומי:',
diagnosticsDescription: 'איסוף אבחון נעשה בהסכמה, מוגבל בהיקפו ומקומי. נתונים רגישים, שאילתות URL ונתיבי תיקיית הבית מוסתרים בעת הצגה וייצוא. שום דבר אינו מועלה באופן אוטומטי.',
noEntries: 'אין עדיין רשומות יומן שמורות.',
disabledDescription: 'רישום אבחון כבוי. יומני תמיכה קיימים יופיעו כאן כאשר יהיו זמינים.',
copy: 'העתקה',
copied: 'הועתק ללוח',
copyFailed: 'העתקה ללוח נכשלה',
exported: 'יומני תמיכה יוצאו',
exportFailed: 'לא ניתן לייצא יומנים: {{detail}}',
cleared: 'היומנים נוקו',
clearFailed: 'לא ניתן לנקות יומנים: {{detail}}',
enabled: 'רישום אבחון הופעל',
disabled: 'רישום אבחון הושבת',
updateFailed: 'לא ניתן לעדכן את רישום האבחון: {{detail}}',
},
scheduler: {
title: 'מתזמן',
running: 'פועל',
disabled: 'המתזמן מושבת',
noScheduledDay: 'לא נבחר יום מתוזמן',
unsavedChanges: 'שינויים שלא נשמרו',
runNow: 'הפעלה כעת',
pause: 'השהייה',
saveSettings: 'שמירת הגדרות',
timing: 'תזמון',
startTime: 'שעת התחלה',
stopTime: 'שעת סיום',
timingDescription: 'אם Firelink נמצא במצב שינה בשעת ההתחלה, הוא יפעיל את התורים שנבחרו כשיחזור לפעולה מאוחר יותר באותו יום, אלא אם כן שעת הסיום כבר עברה.',
runEveryDay: 'הפעלה בכל יום',
queuesToSchedule: 'תורים לתזמון',
defaultQueue: 'תור ברירת מחדל',
afterCompletion: 'לאחר הסיום',
afterCompletionDescription: 'בחר מה יתרחש לאחר סיום ההורדות שהופעלו על ידי המתזמן.',
actionWarning: 'פעולה זו עשויה להפריע לעבודה אחרת במחשב. Firelink יבצע אותה מיד לאחר שהתור המתוזמן מסתיים.',
systemPermissions: 'הרשאות מערכת',
macPermissionDescription: 'שינה, הפעלה מחדש וכיבוי דורשים הרשאת Automation עבור System Events ב-macOS.',
permissionGranted: 'ניתנה הרשאת Automation',
permissionChecking: 'בודק הרשאת Automation…',
permissionMissing: 'חסרה הרשאת Automation',
revokePermission: 'ביטול הרשאה',
grantPermission: 'מתן הרשאה',
permissionAvailable: 'הרשאת Automation זמינה.',
permissionMissingDetails: 'חסרה הרשאת Automation. הפעל את Firelink תחת Automation עבור System Events בהגדרות המערכת.',
revokePermissionDetails: 'macOS אינה מאפשרת ל-Firelink לבטל הרשאת Automation ישירות. בטל גישה ל-System Events בהגדרות המערכת, ולאחר מכן חזור ל-Firelink.',
requestingPermission: 'מבקש הרשאת Automation…',
enablePermissionDetails: 'הפעל את Firelink תחת Automation עבור System Events בהגדרות המערכת, ולאחר מכן חזור ל-Firelink.',
systemActions: 'פעולות מערכת',
windowsActionsDescription: 'שינה, הפעלה מחדש וכיבוי משתמשים בהרשאות מערכת של Windows. Firelink מדווח על כל פעולה שנדחתה בעת ביצועה; לא נדרשת הרשאה קבועה מראש.',
linuxActionsDescription: 'שינה, הפעלה מחדש וכיבוי משתמשים בשולחן העבודה ובמדיניות המערכת של Linux. Firelink מדווח על כל פעולה שנדחתה בעת ביצועה; לא נדרשת הרשאה קבועה מראש.',
validationDay: 'יש לבחור לפחות יום אחד למתזמן',
validationQueue: 'יש לבחור לפחות תור אחד למתזמן',
validationStopTime: 'שעת הסיום חייבת להיות מאוחרת משעת ההתחלה',
saved: 'הגדרות המתזמן נשמרו',
trackingOne: 'עוקב אחר הורדה מתוזמנת אחת',
trackingMany: 'עוקב אחר {{count}} הורדות מתוזמנות',
noStartableDownloads: 'אין הורדות שניתן להפעיל בתורים שנבחרו',
pausedOne: 'הושהתה הורדה פעילה אחת',
pausedMany: 'הושהתו {{count}} הורדות פעילות',
noActiveDownloads: 'אין הורדות פעילות',
days: {
su: 'א\'',
mo: 'ב\'',
tu: 'ג\'',
we: 'ד\'',
th: 'ה\'',
fr: 'ו\'',
sa: 'ש\'',
},
postActions: {
none: 'ללא פעולה',
sleep: 'שינה',
restart: 'הפעלה מחדש',
shutdown: 'כיבוי',
},
},
properties: {
progress: 'התקדמות',
size: 'גודל',
speed: 'מהירות',
eta: 'זמן נותר',
connections: 'חיבורים',
connectionCount: '{{active}}/{{total}} פעילות',
connectionCountUnknown: '—/{{total}} פעילות',
connectionsUnavailable: '—',
speedCap: 'הגבלת מהירות',
category: 'קטגוריה',
lastTry: 'ניסיון אחרון',
dateAdded: 'תאריך הוספה',
destination: 'יעד',
lastError: 'שגיאה אחרונה',
saved: ' (נשמר)',
defaultValue: ' (ברירת מחדל)',
savedTooltip: 'נשמר עבור הורדה זו; שינויים בהגדרות יחולו על הורדות חדשות.',
defaultTooltip: 'שימוש בברירת המחדל הנוכחית להורדות חדשות.',
identityReadOnly: 'זהות הקובץ היא לקריאה בלבד. הגדרות ההעברה נשמרות להורדה מחדש.',
transferSettings: 'ניתן לשנות את הגדרות ההעברה לאחר עצירה או השהייה. העברות נוכחיות שומרות על אפשרויות המנוע הקיימות שלהן.',
download: 'הורדה',
url: 'URL',
fileName: 'שם קובץ',
saveLocation: 'מיקום שמירה',
select: 'בחירה',
perFile: 'לקובץ',
useCurrentDefault: 'שימוש בברירת המחדל הנוכחית ({{count}})',
savedPerDownload: 'נשמר עבור כל הורדה. ברירת המחדל בהגדרות חלה על הורדות חדשות.',
limit: 'הגבלה',
siteLogin: 'התחברות לאתר',
siteLoginRedownload: 'התחברות לאתר להורדה מחדש',
matchingSiteLogin: 'פרטי התחברות תואמים לאתר',
customCredentials: 'אישורים מותאמים אישית',
noLogin: 'ללא התחברות',
useSavedLogin: 'יעשה שימוש בהתחברות שמורה אם היא זמינה.',
advancedTransfer: 'העברה מתקדמת',
advancedTransferRedownload: 'העברה מתקדמת להורדה מחדש',
checksum: 'סכום ביקורת',
verify: 'אימות',
algorithm: 'אלגוריתם',
digest: 'ערך גיבוב',
expectedDigest: 'ערך גיבוב צפוי',
cookies: 'עוגיות',
headers: 'כותרות (Headers)',
mirrors: 'מראות',
username: 'שם משתמש',
password: 'סיסמה',
enterValidUrl: 'נא להזין כתובת URL תקינה.',
fileNameEmpty: 'שם הקובץ אינו יכול להיות ריק.',
cancel: 'ביטול',
save: 'שמירה',
},
keychain: {
title: 'נדרשת גישה לאחסון אישורים',
stores: {
portable: 'תיקיית הנתונים הניידת של Firelink',
windows: 'מנהל האישורים של Windows',
linux: 'אחסון האישורים של Linux',
macos: 'Keychain של macOS',
system: 'אחסון האישורים של מערכת זו',
siteCredentials: 'אחסון האישורים של המערכת',
},
grantLabelPortable: 'המשך',
grantLabelMacos: 'מתן גישה',
grantLabelDefault: 'הפעלת אחסון מאובטח',
description: 'Firelink משתמש בתוסף הדפדפן כדי ללכוד הורדות. כדי לשמור על חיבור התוסף לאחר הפעלה מחדש, Firelink שומר את אסימון הצימוד שלו ב-{{pairingStore}}. אישורי אתר אופציונליים נשמרים ב-{{siteCredentialStore}}.',
portableExplanation: 'אסימון הצימוד נייד יחד עם תיקייה זו. אישורי אתרים נשארים באחסון האישורים של המערכת; ייתכן שתופיע בקשת מערכת לאחר מתן הגישה.',
macosExplanation: 'macOS עשויה להציג בקשת Keychain לאחר מתן הגישה.',
defaultExplanation: 'לרוב זה מתבצע ברקע. אם שירות האישורים אינו זמין, Firelink יציג את השגיאה כאן והתוסף יישאר מחובר עבור הפעלה זו בלבד.',
note: 'הערה:',
portableNote: 'מצב נייד שומר רק את אסימון הצימוד בתיקייה זו. הוא אינו מעתיק סיסמאות אתרים או אישורי דפדפן.',
defaultNote: 'Firelink כותב רק רשומת אישור ייעודית משלו. אין לו גישה לסיסמאות שמורות אחרות במערכת שלך.',
hint: 'רמז:',
portableHint: 'אסימון הצימוד הנייד כבר שמור בתיקייה זו; ניתן להפעיל אותו כאן או לבחור "מאוחר יותר".',
defaultHint: 'אם תבחר "מאוחר יותר", התוסף יפעל עבור הפעלה זו בלבד.',
enableFromSettings: 'ניתן להפעיל את האחסון בכל עת דרך הגדרות > שילובים.',
later: 'מאוחר יותר',
enabling: 'מפעיל…',
timeout: 'בקשת האחסון של האישורים הסתיימה. ניתן לבחור "מאוחר יותר" ולנסות שוב.',
unavailable: '{{store}} אינו זמין.',
accessRequired: 'יש להעניק גישה לאחסון האישורים לפני יצירה מחדש של אסימון הצימוד.',
storeUnavailable: 'הגישה לאחסון האישורים אינה זמינה.',
},
app: {
loading: 'טוען…',
settingsSaveFailed: 'לא ניתן לשמור הגדרות. בדוק הרשאות אחסון ונסה שוב.',
systemActionCountdown: '{{action}} בעוד 10 שניות.',
systemActionCancelled: 'פעולת המערכת בוטלה מכיוון שהורדה אחרת פעילה או בתור.',
systemActionFailed: 'פעולת מערכת מתוזמנת נכשלה: {{detail}}',
downloadCompleteTitle: 'ההורדה הושלמה',
downloadCompleteBody: 'הורדת {{fileName}} הסתיימה.',
unknownFile: 'קובץ',
downloadFailedTitle: 'ההורדה נכשלה',
downloadFailedBody: 'הורדת {{fileName}} נכשלה.',
initializeDownloadsFailed: 'לא ניתן לאתחל הורדות שמורות: {{detail}}',
extensionDisconnected: 'תוסף הדפדפן נותק מכיוון שאסימון הצימוד שלו השתנה.',
copyToken: 'העתקת אסימון',
integrations: 'שילובים',
copyTokenFailed: 'לא ניתן להעתיק את אסימון הצימוד: {{detail}}',
credentialPersistenceFailed: 'שמירת אישורים מאובטחת אינה זמינה. צימוד הדפדפן פועל עבור הפעלה זו בלבד: {{detail}}',
resumeDownloadsFailed: 'לא ניתן לחדש הורדות שמורות: {{detail}}',
updateAvailable: 'Firelink {{version}} זמין.',
viewRelease: 'הצגת פרטי שחרור',
sleepPreventionFailed: 'לא ניתן לעדכן את מניעת השינה: {{detail}}',
schedulerNoQueues: 'למתזמן אין תורים תקינים שנבחרו. עדכן את הגדרות המתזמן.',
schedulerPauseOneFailed: 'המתזמן לא הצליח להשהות הורדה אחת.',
schedulerPauseManyFailed: 'המתזמן לא הצליח להשהות {{count}} הורדות.',
scheduledIncomplete: 'ההורדות המתוזמנות לא הושלמו במלואן. פעולת המערכת לאחר התור דולגה.',
scheduledActionSkippedActive: 'פעולת המערכת המתוזמנת דולגה מכיוון שהורדה אחרת פעילה או בתור.',
notificationsDisabled: 'התראות המערכת מושבתות עבור Firelink.',
notificationsFailed: 'לא ניתן להגדיר התראות: {{detail}}',
},
sidebar: {
deleteQueueConfirm: 'למחוק את התור "{{name}}"? ההורדות שלא הושלמו בו יועברו לתור הראשי.',
deleteQueueFailed: 'לא ניתן למחוק את התור: {{detail}}',
queueNameEmpty: 'שם התור אינו יכול להיות ריק',
queueNameExists: 'כבר קיים תור בשם זה',
startQueueFailed: 'לא ניתן להפעיל את התור: {{detail}}',
pauseQueueFailed: 'לא ניתן להשהות את התור: {{detail}}',
},
downloadTable: {
unknownQueue: 'תור לא ידוע',
allDownloads: 'כל ההורדות',
active: 'פעילות',
completed: 'הושלמו',
unfinished: 'לא הושלמו',
headers: {
fileName: 'שם קובץ',
size: 'גודל',
status: 'סטטוס',
speed: 'מהירות',
eta: 'זמן נותר',
dateAdded: 'תאריך הוספה',
},
columnOptions: {
open: 'אפשרויות עמודה: {{column}}',
dragToReorder: 'גררו כדי לשנות את סדר העמודות',
alignContent: 'יישור תוכן העמודה',
left: 'שמאל',
center: 'מרכז',
right: 'ימין',
resetLayout: 'איפוס פריסת העמודות',
},
addDownload: 'הוספת הורדה',
resumeAll: 'חידוש הכל',
pauseAll: 'השהיית הכל',
queueEmpty: 'התור ריק',
noCompletedDownloads: 'אין הורדות שהושלמו',
noDownloads: 'אין הורדות',
queueEmptyDescription: 'הוסף הורדות לתור זה מתפריט הפריט או מחלון ההוספה.',
completedDescription: 'הורדות שהושלמו יופיעו כאן.',
clickToAdd: 'לחץ על לחצן',
addButtonOr: 'או',
toAddDownloads: 'כדי להוסיף הורדות',
nonResumableOne: 'הורדה אחת אינה תומכת בחידוש. אם תשהה אותה, תצטרך להתחיל מהתחלה מאוחר יותר. האם ברצונך להשהות?',
nonResumableMany: '{{count}} הורדות אינן תומכות בחידוש. אם תשהה אותן, תצטרך להתחיל מהתחלה מאוחר יותר. האם ברצונך להשהות?',
interactionError: '{{message}}: {{detail}}',
openFileFailed: 'לא ניתן לפתוח את הקובץ שהורד',
revealFileFailed: 'לא ניתן להציג את הקובץ ב-Finder',
pauseFailed: 'לא ניתן להשהות את ההורדה',
resumeFailed: 'לא ניתן לחדש את {{fileName}}',
moveManyFailed: 'לא ניתן להעביר הורדות לתור',
moveOneFailed: 'לא ניתן להעביר הורדה לתור',
copyAddressesFailed: 'לא ניתן להעתיק כתובות',
copyAddressFailed: 'לא ניתן להעתיק כתובת',
copyPathFailed: 'לא ניתן להעתיק נתיב קובץ',
missingFileName: 'שם הקובץ חסר',
redownloadFailed: 'הורדה מחדש נכשלה',
startResume: 'הפעלה/חידוש',
addToQueue: 'הוספה לתור',
copyAddress: 'העתקת כתובת',
remove: 'הסרה',
open: 'פתיחה',
showInFolder: 'הצגה בתיקייה',
pause: 'השהייה',
start: 'הפעלה',
resume: 'חידוש',
redownload: 'הורדה מחדש',
copyFilePath: 'העתקת נתיב קובץ',
properties: 'מאפיינים',
backendRejectedStart: 'מנוע הרקע דחה את בקשת ההפעלה/החידוש.',
transferActive: 'לא ניתן לשנות מאפיינים בזמן שההעברה פעילה. השהה אותה תחילה.',
redownloadNotFound: 'לא ניתן להוריד מחדש: ההורדה לא נמצאה.',
redownloadActive: 'לא ניתן להוריד מחדש הורדה בסטטוס "{{status}}". השהה או המתן שתסתיים תחילה.',
originalUrlMissing: 'לא ניתן להוריד מחדש: ה-URL המקורי חסר.',
pauseBeforeMove: 'השהה את {{file}} לפני העברתו לתור אחר.',
},
addDownloads: {
unknownSize: 'גודל לא ידוע',
unknown: 'לא ידוע',
discardSetup: 'לבטל את הגדרת ההורדה הזו?',
speedInvalid: 'הגבלת המהירות חייבת להיות גדולה מאפס',
duplicateResolveFailed: 'לא ניתן לפתור הורדות כפולות: {{detail}}',
addedWithFailures: '{{added}} נוספו, {{failed}} נכשלו. {{detail}}',
addedOne: 'הורדה אחת נוספה',
addedMany: '{{count}} הורדות נוספו',
updatedOne: 'הורדה אחת עודכנה',
updatedMany: '{{count}} הורדות עודכנו',
urlAlreadyQueued: 'URL כבר נמצא בתור',
destinationConflict: 'הורדה אחרת שנבחרה משתמשת ביעד זה',
existingDownloadDestination: 'הורדת Firelink קיימת משתמשת ביעד זה',
matchingDownloadFilename: 'להורדה שלא הושלמה יש כבר שם קובץ זה',
fileExistsOnDisk: 'הקובץ קיים בדיסק; שנה שם או דלג כדי למנוע מחיקת נתונים שאינם קשורים',
backendRejectedStart: 'מנוע הרקע דחה את התחלת ההורדה.',
playlistNoEntries: 'רשימת ההשמעה אינה מכילה פריטים הניתנים להורדה',
invalidMediaMetadata: 'מטא נתונים של המדיה אינם תקינים או שלא נמצאו פורמטים',
noAvailableName: 'לא נמצא שם פנוי עבור {{file}}.',
pauseBeforeReplace: 'השהה את {{file}} לפני החלפתו.',
cannotReplace: 'לא ניתן להחליף את {{file}}: הקובץ אינו שייך להורדת Firelink.',
downloadLinks: 'קישורי הורדה',
pastePlaceholder: 'הדבק כתובות \u2066HTTP\u2069, \u2066HTTPS\u2069, \u2066FTP\u2069 או \u2066SFTP\u2069 כאן…\n\nעבור הורדות מדיה, הדבק קישורים מ-\u2066YouTube\u2069, \u2066X\u2069, \u2066TikTok\u2069, \u2066Instagram\u2069, \u2066Reddit\u2069 וכו\'.',
playlistSummary: 'רשימת השמעה "{{title}}": {{loaded}} מתוך {{total}} פריטים נטענו{{truncated}}{{skipped}}',
safeEntryLimit: ' (הושגה מגבלת הפריטים הבטוחה)',
selectedSummary: '{{ready}} נבחרו ומוכנים, {{fallback}} לגיבוי, {{mediaRetry}} מדיה לניסיון חוזר, {{blocked}} חסומים',
clearSelection: 'ניקוי בחירה',
selectAll: 'בחירת הכל',
refreshMetadata: 'רענון מטא נתונים',
files: 'קבצים',
required: 'נדרש',
free: 'פנוי',
preview: 'תצוגה מקדימה',
file: 'קובץ',
size: 'גודל',
status: 'סטטוס',
downloadPreview: 'תצוגה מקדימה של הורדה',
noLinks: 'טרם נוספו קישורים.',
selectItem: 'בחר {{file}}',
fetchingPlaylist: 'טוען את רשימת ההשמעה…',
fetching: 'טוען…',
unsafeUrl: 'URL לא בטוח',
playlistFailed: 'טעינת רשימת ההשמעה נכשלה',
metadataFailed: 'אחזור המטא-נתונים נכשל',
fallback: 'גיבוי',
invalid: 'לא תקין',
ready: 'מוכן',
mediaFormat: 'פורמט מדיה',
playlistItem: 'פריט רשימת השמעה {{index}}',
fetchingMediaStreams: 'טוען זרמי מדיה…',
availableStreams: 'זרמים זמינים',
availableMediaStreams: 'זרמי מדיה זמינים',
metadataUnavailable: 'מטא נתונים אינם זמינים. רענן מטא נתונים לפני הוספת מדיה זו.',
saveLocation: 'מיקום שמירה',
browse: 'עיון…',
categoryFolders: 'הקבצים יאורגנו בתיקיות קטגוריה.',
sharedFolder: 'כל ההורדות שנבחרו ישתמשו בתיקייה זו.',
dedicatedFolder: 'שמירה בתיקייה חדשה',
dedicatedFolderName: 'שם התיקייה',
dedicatedFolderDescription: 'תיקייה זו תיווצר בתוך כל תיקיית קטגוריה אוטומטית.',
dedicatedFolderManualDescription: 'תיקייה זו תיווצר בתוך מיקום השמירה שנבחר.',
saveFolderName: 'שמירת שם התיקייה',
dedicatedFolderNameRequired: 'יש להזין שם תיקייה לפני השמירה.',
transferSettings: 'הגדרות העברה',
connectionsPerFile: 'חיבורים לקובץ',
connectionsPerFileAria: 'חיבורים לקובץ',
limitSpeedPerFile: 'הגבלת מהירות לקובץ',
speedLimitPerFileAria: 'הגבלת מהירות לקובץ',
authorization: 'הרשאה',
useAuthorization: 'שימוש בהרשאה',
username: 'שם משתמש',
password: 'סיסמה',
advancedTransfer: 'העברה מתקדמת',
verifyChecksum: 'אימות סכום ביקורת',
checksumAlgorithm: 'אלגוריתם סכום ביקורת',
expectedDigest: 'ערך גיבוב צפוי',
headers: 'כותרות (Headers)',
requestHeaders: 'כותרות בקשה',
cookies: 'עוגיות',
cookiePlaceholder: 'name=value; other=value',
mirrors: 'מראות',
cancel: 'ביטול',
startDownloads: 'התחלת הורדות',
addToQueue: 'הוספה לתור',
pasteOneOrMore: 'הדבק קישור אחד או יותר.',
selectAtLeastOne: 'בחר לפחות הורדה אחת.',
correctInvalidOne: 'תקן או הסר {{count}} כתובת URL לא תקינה לפני שתמשיך.',
correctInvalidFew: 'תקן או הסר {{count}} כתובות URL לא תקינות לפני שתמשיך.',
correctInvalidMany: 'תקן או הסר {{count}} כתובות URL לא תקינות לפני שתמשיך.',
waitingForMetadataOne: 'ממתין למטא נתונים עבור {{count}} הורדה.',
waitingForMetadataFew: 'ממתין למטא נתונים עבור {{count}} הורדות.',
waitingForMetadataMany: 'ממתין למטא נתונים עבור {{count}} הורדות.',
removeUnsafeOne: 'הסר {{count}} כתובת URL לא בטוחה לפני שתמשיך.',
removeUnsafeFew: 'הסר {{count}} כתובות URL לא בטוחות לפני שתמשיך.',
removeUnsafeMany: 'הסר {{count}} כתובות URL לא בטוחות לפני שתמשיך.',
mediaMetadataUnavailableSummaryOne: 'מטא נתונים של מדיה אינם זמינים עבור {{count}} פריט. רענן מטא נתונים לפני ההוספה.',
mediaMetadataUnavailableSummaryFew: 'מטא נתונים של מדיה אינם זמינים עבור {{count}} פריטים. רענן מטא נתונים לפני ההוספה.',
mediaMetadataUnavailableSummaryMany: 'מטא נתונים של מדיה אינם זמינים עבור {{count}} פריטים. רענן מטא נתונים לפני ההוספה.',
metadataUnavailableFallback: 'מטא נתונים אינם זמינים. עדיין ניתן להוסיף הורדות באמצעות פרטי הגיבוי.',
fallbackReadyOne: '{{ready}} הורדה מוכנה; עבור {{failed}} ייעשה שימוש בשם קובץ גיבוי ובגודל לא ידוע.',
fallbackReadyFew: '{{ready}} הורדות מוכנות; עבור {{failed}} ייעשה שימוש בשם קובץ גיבוי ובגודל לא ידוע.',
fallbackReadyMany: '{{ready}} הורדות מוכנות; עבור {{failed}} ייעשה שימוש בשם קובץ גיבוי ובגודל לא ידוע.',
readyToAddOne: 'מוכן להוסיף {{count}} הורדה.',
readyToAddFew: 'מוכן להוסיף {{count}} הורדות.',
readyToAddMany: 'מוכן להוסיף {{count}} הורדות.',
},
settings: {
tabs: {
downloads: 'הורדות',
lookAndFeel: 'מראה ותחושה',
network: 'רשת',
locations: 'מיקומים',
siteLogins: 'התחברויות לאתרים',
power: 'צריכת חשמל',
engine: 'מנוע',
integrations: 'שילובים',
about: 'אודות',
},
common: {
thisOs: 'מערכת הפעלה זו',
checking: 'בודק…',
ready: 'מוכן',
errorMissing: 'שגיאה / חסר',
hideTechnicalDetails: 'הסתרת פרטים טכניים',
showTechnicalDetails: 'הצגת פרטים טכניים',
binary: 'קובץ בינארי',
expected: 'צפוי',
error: 'שגיאה',
tip: 'טיפ',
stderr: 'stderr',
daemonProcessAlive: 'תהליך רקע פעיל',
rpcReady: 'RPC מוכן',
rpcPort: 'פורט RPC',
daemonStderr: 'stderr של תהליך רקע',
packaging: 'אריזה: PyInstaller onedir (נדרש _internal)',
internalDirectoryFound: 'תיקיית _internal נמצאה',
pythonRuntimeFound: 'סביבת ריצה של Python נמצאה',
externalOpenFailed: 'לא ניתן לפתוח את {{label}}: {{detail}}',
updateUnexpectedResponse: 'בדיקת העדכון החזירה תגובה בלתי צפויה.',
updateUntrustedReleaseUrl: 'בדיקת העדכון החזירה URL שחרור לא מהימן.',
updateFailed: 'בדיקת עדכון נכשלה: {{detail}}',
},
downloads: {
defaultConnections: 'חיבורי ברירת מחדל:',
defaultConnectionsDescription: 'הורדות חדשות; פריטים קיימים שומרים על הערך השמור שלהם',
parallelDownloads: 'הורדות במקביל:',
parallelDownloadsDescription: 'מקסימום קבצים פעילים בו-זמנית',
automaticRetries: 'ניסיונות חוזרים אוטומטיים:',
automaticRetriesDescription: 'אם חיבור נכשל',
systemNotification: 'הצג התראת מערכת כאשר ההורדה מסתיימת',
systemNotificationDescription: 'משתמש בהגדרות ההתראות של מערכת ההפעלה שלך',
completionChime: 'השמע צליל סיום בתוך האפליקציה',
completionChimeDescription: 'צליל Firelink אופציונלי, ללא קשר להתראות המערכת',
clipboardLinks: 'הוסף קישורים מהלוח כאשר Firelink הופך לפעיל',
clipboardLinksDescription: 'פותח קישורים מועתקים נתמכים בחלון ההוספה. כבוי כברירת מחדל.',
},
lookAndFeel: {
language: 'שפה',
languageDescription: 'בחר את שפת הממשק של Firelink.',
sidebarPosition: 'מיקום סרגל הצד',
sidebarPositionDescription: 'בחר היכן יופיע סרגל הצד. מצב אוטומטי עוקב אחר כיוון הממשק.',
sidebarPositionAutomatic: 'אוטומטי (מומלץ)',
sidebarPositionLeft: 'שמאל',
sidebarPositionRight: 'ימין',
languageSystem: 'שפת המערכת',
languageEnglish: 'אנגלית',
languageChinese: 'סינית מפושטת',
languageHebrew: 'עברית',
languagePersian: 'פרסית',
languageUkrainian: 'אוקראינית',
languageRussian: 'רוסית',
appTheme: 'ערכת נושא לאפליקציה',
theme: 'ערכת נושא',
ariaLabel: 'ערכת נושא לאפליקציה',
system: 'מערכת',
light: 'בהירה',
dark: 'כהה',
dracula: 'Dracula',
nord: 'Nord',
systemAppearance: 'מערכת עוקבת אחר המראה הנוכחי (בהיר או כהה) של {{platform}}.',
display: 'תצוגה',
fontSize: 'גודל גופן',
fontSizeDescription: 'משנה את קנה המידה של רשימת ההורדות והפקדים הקומפקטיים.',
small: 'קטן',
standard: 'רגיל',
large: 'גדול',
listDensity: 'צפיפות הרשימה',
listDensityDescription: 'משנה את גובה השורה, הריווח וקנה המידה של פס ההתקדמות.',
compact: 'קומפקטי',
comfortable: 'נוח',
relaxed: 'מרווח',
osIntegration: 'שילוב במערכת ההפעלה',
dockBadge: 'הצג תג על סמל ה-Dock',
dockBadgeDescription: 'מציג את ספירת ההורדות הפעילות על סמל Firelink ב-Dock.',
menuBarIcon: 'הצג סמל בשורת התפריטים',
statusIndicatorIcon: 'הצג סמל מחוון סטטוס',
systemTrayIcon: 'הצג סמל במגש המערכת',
macosTrayDescription: 'מספק גישה מהירה משורת התפריטים של macOS.',
windowsTrayDescription: 'מספק גישה מהירה מאזור ההתראות של Windows.',
linuxTrayDescription: 'מספק גישה מהירה ממגש שולחן העבודה או מאזור הסטטוס כאשר הם זמינים.',
defaultTrayDescription: 'מספק גישה מהירה מאזור המגש של מערכת ההפעלה כאשר הוא זמין.',
},
network: {
proxy: 'פרוקסי',
mode: 'מצב',
modeDescription: 'שולט בשימוש בפרוקסי עבור בקשות הורדה חדשות.',
noProxy: 'ללא פרוקסי',
systemProxy: 'פרוקסי של המערכת',
customProxy: 'פרוקסי מותאם אישית',
proxyHost: 'מארח פרוקסי',
proxyHostDescription: 'שם מארח, כתובת IP או URL של פרוקסי HTTP.',
proxyHostPlaceholder: '127.0.0.1 או http://127.0.0.1',
proxyPort: 'פורט פרוקסי',
proxyPortDescription: 'טווח חוקי הוא 1 עד 65535.',
noProxyDescription: 'הורדות מתעלמות מפרוקסי שהוגדרו.',
systemProxyDescription: 'הורדות משתמשות בפרוקסי המערכת {{platform}} שזוהה. הורדות קבצים רגילות דורשות נקודת קצה של HTTP או HTTPS; הורדות מדיה יכולות להשתמש ב-SOCKS.',
customProxyDescription: 'הורדות משתמשות בנקודת הקצה המוגדרת של פרוקסי HTTP עבור מטא-נתונים ומנועי הורדה.',
invalidCustomProxy: 'הזן מארח ופורט חוקיים של פרוקסי HTTP כדי להפעיל את הפרוקסי המותאם אישית.',
incompleteCustomProxy: 'הזן מארח ופורט של פרוקסי כדי להפעיל את הפרוקסי המותאם אישית.',
checkingSystemProxy: 'בודק את תצורת פרוקסי המערכת…',
detectedSystemProxy: 'זוהה פרוקסי מערכת. הורדות קבצים רגילות דורשות נקודת קצה של HTTP או HTTPS; הורדות מדיה יכולות להשתמש ב-SOCKS.',
noSystemProxy: 'לא זוהה פרוקסי מערכת שימושי. ההורדות לא ישתמשו בפרוקסי.',
systemProxyReadFailed: 'לא ניתן לקרוא את תצורת פרוקסי המערכת. בחר "ללא פרוקסי" או נסה שוב כשהיא תהיה זמינה.',
identity: 'זהות',
customUserAgent: 'User-Agent מותאם אישית',
userAgentDescription: 'מוחל על משיכות מטא נתונים ומנועי הורדה.',
userAgentPlaceholder: 'השאר ריק עבור ברירת המחדל של Firelink',
userAgentOverrides: 'דורס את כותרת ה-User-Agent היוצאת. השאר ריק עבור ברירות המחדל של Firelink.',
chromeWindows: 'Chrome (Windows)',
chromeMacos: 'Chrome (macOS)',
edgeWindows: 'Edge (Windows)',
firefoxWindows: 'Firefox (Windows)',
firefoxMacos: 'Firefox (macOS)',
safariMacos: 'Safari (macOS)',
windowsDesktop: 'שולחן עבודה Windows',
macosDesktop: 'שולחן עבודה macOS',
},
locations: {
baseDownloadFolder: 'תיקיית הורדות בסיסית',
baseDownloadFolderDescription: 'תיקיות קטגוריה אוטומטיות נוצרות בתוך תיקייה זו.',
browse: 'עיון…',
baseFolderCreateFailed: 'תיקיית הבסיס נשמרה, אך לא ניתן היה ליצור תיקיות קטגוריה: {{detail}}',
baseFolderUpdated: 'תיקיית הורדות בסיסית עודכנה',
askWhereToSave: 'שאל היכן לשמור בעת הוספת הורדות',
rememberLastUsedDownloadDirectory: 'זכור את ספריית ההורדות שהייתה בשימוש אחרון',
rememberLastUsedDownloadDirectoryDescription: 'השתמש בתיקייה האחרונה שנבחרה בחלון ההוספה עבור ההורדה הבאה עד שהאפליקציה תופעל מחדש.',
automaticCategorySubfolders: 'שמור קבצים אוטומטית לתיקיות משנה של קטגוריות',
automaticCategorySubfoldersDescription: 'כאשר כבוי, הורדות אוטומטיות משתמשות בתיקיית הבסיס.',
categorySubfolders: 'תיקיות משנה של קטגוריות',
relativeToBase: 'יחסית לתיקיית הבסיס',
disabled: 'מושבת',
categorySubfolder: 'תיקיית משנה {{category}}',
customFolder: 'תיקייה מותאמת אישית',
useAutomatic: 'השתמש באוטומטי',
resetDefaults: 'שחזור ברירות מחדל',
resetToast: 'מיקומי הקטגוריות אופסו לברירת המחדל',
},
siteLogins: {
title: 'אישורי אתרים',
noSavedLogins: 'אין התחברויות שמורות.',
user: 'משתמש: {{username}}',
deleteCredential: 'מחיקת אישור',
deletedCredential: 'אישור נמחק',
deleteFailed: 'לא ניתן למחוק אישור: {{detail}}',
addTitle: 'הוספת אישורי אתר',
urlPattern: 'תבנית URL:',
urlPatternPlaceholder: 'לדוגמה *.example.com או example.com/downloads',
username: 'שם משתמש:',
password: 'סיסמה:',
usernamePlaceholder: 'שם משתמש',
passwordPlaceholder: 'סיסמה',
saving: 'שומר…',
addLogin: 'הוספת התחברות',
urlPatternRequired: 'נדרשת תבנית URL.',
urlPatternWhitespace: 'תבנית URL אינה יכולה להכיל רווחים.',
usernameRequired: 'נדרש שם משתמש.',
passwordRequired: 'נדרשת סיסמה.',
accessBeforeSave: 'הענק גישה לאחסון האישורים לפני שמירת התחברות לאתר.',
saveFailed: 'שמירת אישור האתר באופן מאובטח נכשלה.',
addedCredential: 'אישור אתר נוסף',
},
power: {
title: 'ניהול צריכת חשמל',
preventSleep: 'מנע שינה של המערכת בזמן שהורדות פעילות',
preventSleepDescription: 'התצוגה עדיין עשויה להכבות. Firelink משאיר את המכשיר ער רק במידה המספקת להשלמת העברות פעילות.',
},
engine: {
title: 'מוריד מדיה ומנועים',
description: 'תוצאות מוצלחות נמצאות בשימוש חוזר עבור הפעלה זו של האפליקציה. בדיקה מחדש מפעילה אימות אמיתי מחדש.',
recheck: 'בדיקה מחדש של מנועים',
coreDownloader: 'מנוע ההורדה הראשי (aria2)',
version: 'גרסה:',
status: 'סטטוס:',
mediaExtractors: 'מחלצי מדיה',
browserCookiesSource: 'מקור עוגיות דפדפן (Browser Cookies):',
cookieDescription: 'yt-dlp קורא עוגיות דפדפן כדי לעקוף הגבלות הורדת וידאו או כדי לגשת למדיה מוגבלת. Firelink אינו שומר עוגיות דפדפן.',
none: 'ללא',
},
integrations: {
title: 'חיבור תוסף דפדפן',
description: 'לכוד הורדות ישירות מהדפדפן שלך בשלושה שלבים פשוטים.',
portablePairingEnabled: 'צימוד נייד מופעל',
pairingTokenPersisted: 'אסימון צימוד נשמר',
portablePersistedDescription: 'אסימון הצימוד שלך נשמר יחד עם תיקיית Firelink ניידת זו ויישאר בעת הזזת התיקייה. התייחס לתיקייה כרגישה.',
systemPersistedDescription: 'אסימון הצימוד שלך נשמר באחסון האישורים של המערכת ויישאר לאחר הפעלות מחדש.',
portablePairingAvailable: 'צימוד נייד זמין',
credentialStorageNeeded: 'נדרש אחסון אישורים',
portableAvailableDescription: 'אסימון הצימוד שלך נשמר יחד עם תיקיית Firelink ניידת זו ויישאר לאחר הפעלות מחדש. הפעל אותו כאן כדי לעיין באזהרת האחסון הנייד.',
credentialStorageDescription: 'Firelink זקוק לגישה לאחסון האישורים של מערכת זו כדי לשמור בבטחה את אסימון הצימוד שלך לאחר הפעלות מחדש של האפליקציה. כרגע, התוסף שלך יישאר מחובר עבור הפעלה זו בלבד.',
reviewPortablePairing: 'עיון בצימוד נייד',
grantCredentialAccess: 'הענקת גישה לאישורים',
copyToken: 'העתקת אסימון',
tokenDescription: 'אסימון מאובטח זה מאשר את תוסף הדפדפן שלך.',
tokenCopied: 'האסימון הועתק ללוח!',
tokenCopyFailed: 'לא ניתן להעתיק את האסימון: {{detail}}',
pairingTokenRegenerated: 'אסימון הצימוד נוצר מחדש',
regenerateFailed: 'לא ניתן ליצור מחדש אסימון צימוד: {{detail}}',
getExtension: 'קבלת התוסף',
extensionDescription: 'התקן את Firelink Companion עבור דפדפני Firefox או Chromium.',
firefoxAddons: 'הרחבות Firefox',
chromiumZip: 'Chromium ZIP',
pasteConnect: 'הדבקה וחיבור',
pasteConnectDescription: 'לחץ על סמל Firelink בסרגל הכלים של הדפדפן שלך והדבק את האסימון שהועתק.',
extensionServerStatus: 'סטטוס שרת התוסף:',
listening: '● מאזין ב-127.0.0.1:{{port}}',
unavailable: '● שרת לא זמין',
},
about: {
firelinkIcon: 'סמל Firelink',
unknown: 'לא ידוע',
version: 'גרסה {{version}}',
description: 'מנהל הורדות חוצה-פלטפורמות עבור macOS, Windows ו-Linux.',
sourceCode: 'קוד מקור',
source: 'מקור',
upToDate: 'Firelink {{version}} מעודכן.',
updateAvailable: 'Firelink {{version}} זמין.',
viewRelease: 'הצגת שחרור',
updates: 'עדכונים',
checkForUpdates: 'בדוק עדכונים',
updateDescription: 'Firelink בודק מהדורות ב-GitHub עבור גרסאות חדשות.',
checking: 'בודק…',
checkNow: 'בדוק כעת',
automaticUpdates: 'בדוק עדכונים אוטומטית',
createdBy: 'נוצר על ידי NimBold',
mitLicense: 'רישיון MIT',
credits: 'קרדיטים: aria2, yt-dlp, FFmpeg ו-Deno.',
copyright: 'כל הזכויות שמורות © 2026 NimBold.',
},
},
status: {
ready: 'מוכן',
active: '{{count}} פעילים',
queued: '{{count}} בתור',
done: '{{count}} הושלמו',
},
} as const;
export default he;
+755
View File
@@ -0,0 +1,755 @@
const ru = {
navigation: {
library: 'Библиотека',
filters: {
all: 'Все',
active: 'Активные',
completed: 'Завершённые',
unfinished: 'Незавершённые',
},
categories: {
musics: 'Музыка',
movies: 'Фильмы',
compressed: 'Архивы',
documents: 'Документы',
pictures: 'Изображения',
applications: 'Программы',
other: 'Другое',
},
folders: 'Папки',
queues: 'Очереди',
tools: 'Инструменты',
scheduler: 'Планировщик',
speedLimiter: 'Ограничение скорости',
logs: 'Журнал',
settings: 'Настройки',
},
actions: {
hideSidebar: 'Скрыть боковую панель',
showSidebar: 'Показать боковую панель',
resizeSidebar: 'Изменить размер боковой панели',
cancel: 'Отмена',
continue: 'Продолжить',
dismissNotification: 'Закрыть уведомление',
queueName: 'Название очереди',
addNewQueue: 'Добавить новую очередь',
startQueue: 'Запустить очередь',
pauseQueue: 'Приостановить очередь',
renameQueue: 'Переименовать очередь',
deleteQueue: 'Удалить очередь',
},
dialogs: {
errorBoundary: {
title: 'Что-то пошло не так.',
description: 'Произошла критическая ошибка в дереве компонентов React. Сведения об ошибке ниже помогут определить её причину.',
reload: 'Перезапустить приложение',
},
duplicateDownloads: {
title: 'Обнаружены дубликаты загрузок',
description: 'Некоторые из добавляемых загрузок уже есть в очереди или на диске. Выберите способ разрешения конфликтов.',
rename: 'Переименовать',
replace: 'Заменить',
skip: 'Пропустить',
},
removeDownload: {
title: 'Удалить загрузку',
confirmationSingle: 'Вы действительно хотите удалить этот элемент из списка? Вы также можете удалить файл с диска.',
confirmationMultiple: 'Вы действительно хотите удалить выбранные элементы ({{count}}) из списка? Вы также можете удалить файлы с диска.',
errorSummary: 'Удалено {{succeeded}}, с ошибкой {{failed}}: {{detail}}',
remove: 'Удалить',
deleteFile: 'Удалить файл',
},
},
window: {
controls: 'Управление окном',
close: 'Закрыть',
minimize: 'Свернуть',
maximize: 'Развернуть',
},
downloads: {
actions: {
moveUp: 'Переместить вверх',
moveDown: 'Переместить вниз',
pause: 'Приостановить',
start: 'Запустить',
resume: 'Возобновить',
options: 'Параметры',
},
size: {
downloadedOf: 'Загружено {{downloaded}} из {{total}} {{unit}}',
downloadedOfApproximate: 'Загружено {{downloaded}} из примерно {{total}} {{unit}}',
},
status: {
ready: 'Готово',
staged: 'В очереди',
queued: 'В очереди',
downloading: 'Загрузка',
processing: 'Обработка',
paused: 'Приостановлено',
completed: 'Завершено',
failed: 'Ошибка',
retrying: 'Повторная попытка',
},
values: {
processing: 'Обработка…',
muxing: 'Мультиплексирование…',
},
},
speedLimiter: {
title: 'Ограничение скорости',
unlimited: 'Без ограничений',
saveLimit: 'Сохранить ограничение',
globalSpeedLimit: 'Общее ограничение скорости',
description: 'Применяется к новым и активным загрузкам aria2 и новым загрузкам медиа yt-dlp. Индивидуальные ограничения для отдельных загрузок сохраняются.',
quickPresets: 'Быстрые пресеты',
removePreset: 'Удалить пресет {{value}} {{unit}}',
customPresetIn: 'Пользовательский пресет ({{unit}})',
addQuickPreset: 'Добавить быстрый пресет',
globalLimitSaved: 'Общее ограничение сохранено: {{value}} {{unit}}',
globalLimitDisabled: 'Общее ограничение скорости отключено',
saveFailed: 'Не удалось сохранить общее ограничение скорости: {{detail}}',
presetAlreadyExists: 'Пресет {{value}} {{unit}} уже есть в быстрых пресетах',
presetAdded: 'Добавлен быстрый пресет {{value}} {{unit}}',
presetRemoved: 'Удалён быстрый пресет {{value}} {{unit}}',
},
logs: {
title: 'Журнал',
entries: '({{count}} записей)',
collecting: 'Сбор',
off: 'Выкл.',
allLevels: 'Все уровни',
levels: {
error: 'Ошибка',
warn: 'Предупреждение',
info: 'Информация',
debug: 'Отладка',
trace: 'Трассировка',
},
pauseLogging: 'Приостановить ведение журнала',
enableLogging: 'Включить ведение журнала',
clearDisplayed: 'Очистить отображаемый журнал',
export: 'Экспорт журнала',
exportButton: 'Экспортировать журнал',
logFiles: 'Файлы журнала',
localDiagnostics: 'Локальная диагностика:',
diagnosticsDescription: 'Сбор диагностики выполняется по желанию, ограничен по объёму и хранится локально. Конфиденциальные данные, параметры URL и пути к домашней папке скрываются при отображении и экспорте. Никакие данные не отправляются автоматически.',
noEntries: 'Сохранённых записей журнала пока нет.',
disabledDescription: 'Ведение журнала диагностики отключено. Существующие логи поддержки появятся здесь при наличии.',
copy: 'Копировать',
copied: 'Скопировано в буфер обмена',
copyFailed: 'Не удалось скопировать в буфер обмена',
exported: 'Журнал диагностики экспортирован',
exportFailed: 'Не удалось экспортировать журнал: {{detail}}',
cleared: 'Журнал очищен',
clearFailed: 'Не удалось очистить журнал: {{detail}}',
enabled: 'Ведение журнала диагностики включено',
disabled: 'Ведение журнала диагностики отключено',
updateFailed: 'Не удалось обновить ведение журнала диагностики: {{detail}}',
},
scheduler: {
title: 'Планировщик',
running: 'Работает',
disabled: 'Планировщик отключён',
noScheduledDay: 'Дни выполнения не выбраны',
unsavedChanges: 'Несохранённые изменения',
runNow: 'Запустить сейчас',
pause: 'Приостановить',
saveSettings: 'Сохранить настройки',
timing: 'Расписание',
startTime: 'Время начала',
stopTime: 'Время окончания',
timingDescription: 'Если Firelink находится в спящем режиме во время запуска, он запустит выбранные очереди после пробуждения в этот же день, если время окончания ещё не наступило.',
runEveryDay: 'Запускать каждый день',
queuesToSchedule: 'Очереди для запуска',
defaultQueue: 'Очередь по умолчанию',
afterCompletion: 'После завершения',
afterCompletionDescription: 'Выберите действие после завершения загрузок, запущенных планировщиком.',
actionWarning: 'Это действие может прервать работу других программ на компьютере. Firelink выполнит его сразу после завершения запланированной очереди.',
systemPermissions: 'Системные разрешения',
macPermissionDescription: 'Для перехода в спящий режим, перезагрузки и выключения требуется разрешение macOS «Автоматизация» для System Events.',
permissionGranted: 'Разрешение «Автоматизация» предоставлено',
permissionChecking: 'Проверка разрешения «Автоматизация»…',
permissionMissing: 'Отсутствует разрешение «Автоматизация»',
revokePermission: 'Отозвать разрешение',
grantPermission: 'Предоставить разрешение',
permissionAvailable: 'Разрешение «Автоматизация» доступно.',
permissionMissingDetails: 'Отсутствует разрешение «Автоматизация». В Системных настройках откройте раздел «Автоматизация» для System Events и включите Firelink.',
revokePermissionDetails: 'macOS не позволяет отзывать разрешение «Автоматизация» напрямую из Firelink. Отключите доступ Firelink к System Events в Системных настройках, затем вернитесь в Firelink.',
requestingPermission: 'Запрос разрешения «Автоматизация»…',
enablePermissionDetails: 'В Системных настройках откройте раздел «Автоматизация» для System Events и включите Firelink, затем вернитесь в Firelink.',
systemActions: 'Системные действия',
windowsActionsDescription: 'Спящий режим, перезагрузка и выключение используют системные привилегии Windows. Firelink сообщит о любых отклонённых действиях при их запуске; постоянное разрешение заранее не запрашивается.',
linuxActionsDescription: 'Спящий режим, перезагрузка и выключение используют системную политику и рабочий стол Linux. Firelink сообщит о любых отклонённых действиях при их запуске; постоянное разрешение заранее не запрашивается.',
validationDay: 'Выберите хотя бы один день для планировщика',
validationQueue: 'Выберите хотя бы одну очередь для планировщика',
validationStopTime: 'Время окончания должно быть позже времени начала',
saved: 'Настройки планировщика сохранены',
trackingOne: 'Отслеживается 1 запланированная загрузка',
trackingMany: 'Отслеживается {{count}} запланированных загрузок',
noStartableDownloads: 'В выбранных очередях нет загрузок, которые можно запустить',
pausedOne: 'Приостановлена 1 активная загрузка',
pausedMany: 'Приостановлено {{count}} активных загрузок',
noActiveDownloads: 'Нет активных загрузок',
days: {
su: 'Вс',
mo: 'Пн',
tu: 'Вт',
we: 'Ср',
th: 'Чт',
fr: 'Пт',
sa: 'Сб',
},
postActions: {
none: 'Ничего не делать',
sleep: 'Спящий режим',
restart: 'Перезагрузка',
shutdown: 'Завершение работы',
},
},
properties: {
progress: 'Прогресс',
size: 'Размер',
speed: 'Скорость',
eta: 'Осталось',
connections: 'Соединения',
connectionCount: '{{active}}/{{total}} активных',
connectionCountUnknown: '—/{{total}} активных',
connectionsUnavailable: '—',
speedCap: 'Ограничение скорости',
category: 'Категория',
lastTry: 'Последняя попытка',
dateAdded: 'Дата добавления',
destination: 'Папка назначения',
lastError: 'Последняя ошибка',
saved: ' (сохранено)',
defaultValue: ' (по умолчанию)',
savedTooltip: 'Сохранено для этой загрузки. Изменения в Настройках будут применяться к новым загрузкам.',
defaultTooltip: 'Используется текущее значение по умолчанию для новых загрузок.',
identityReadOnly: 'Идентификация файла доступна только для чтения. Настройки передачи сохранены для повторного скачивания.',
transferSettings: 'Настройки передачи можно изменить после остановки или приостановки. Текущие загрузки сохраняют свои параметры.',
download: 'Загрузка',
url: 'URL',
fileName: 'Имя файла',
saveLocation: 'Место сохранения',
select: 'Выбрать',
perFile: 'на файл',
useCurrentDefault: 'По умолчанию ({{count}})',
savedPerDownload: 'Сохранено для этой загрузки. Значение из Настроек применяется к новым загрузкам.',
limit: 'Ограничение',
siteLogin: 'Авторизация на сайте',
siteLoginRedownload: 'Авторизация для повторного скачивания',
matchingSiteLogin: 'Учётные данные сайта',
customCredentials: 'Пользовательские учётные данные',
noLogin: 'Без авторизации',
useSavedLogin: 'Будет использован сохранённый логин, если он доступен.',
advancedTransfer: 'Дополнительные параметры',
advancedTransferRedownload: 'Дополнительные параметры повторного скачивания',
checksum: 'Контрольная сумма',
verify: 'Проверить',
algorithm: 'Алгоритм',
digest: 'Хеш',
expectedDigest: 'Ожидаемый хеш',
cookies: 'Файлы cookie',
headers: 'Заголовки',
mirrors: 'Зеркала',
username: 'Имя пользователя',
password: 'Пароль',
enterValidUrl: 'Введите корректный URL.',
fileNameEmpty: 'Имя файла не может быть пустым.',
cancel: 'Отмена',
save: 'Сохранить',
},
keychain: {
title: 'Требуется доступ к хранилищу учётных данных',
stores: {
portable: 'портативная папка Firelink',
windows: 'Диспетчер учётных данных Windows',
linux: 'хранилище учётных данных Linux',
macos: 'Связка ключей macOS',
system: 'системное хранилище учётных данных',
siteCredentials: 'системное хранилище учётных данных',
},
grantLabelPortable: 'Продолжить',
grantLabelMacos: 'Предоставить доступ',
grantLabelDefault: 'Включить защищённое хранилище',
description: 'Firelink использует расширение браузера для перехвата загрузок. Чтобы расширение оставалось привязанным после перезапуска, Firelink сохраняет токен сопряжения в {{pairingStore}}. Дополнительные учётные данные сайтов хранятся в {{siteCredentialStore}}.',
portableExplanation: 'Токен сопряжения переносится вместе с этой папкой. Учётные данные сайтов остаются в системном хранилище; после предоставления доступа может появиться системный запрос.',
macosExplanation: 'macOS может показать запрос Связки ключей после предоставления доступа.',
defaultExplanation: 'Обычно это происходит незаметно. Если служба учётных данных недоступна, Firelink покажет ошибку здесь, и расширение останется привязанным только на текущий сеанс.',
note: 'Примечание:',
portableNote: 'В портативном режиме в этой папке сохраняется только токен сопряжения. Пароли сайтов и учётные данные браузера не копируются.',
defaultNote: 'Firelink создаёт только собственную изолированную запись учётных данных. Приложение не имеет доступа к другим сохранённым паролям вашей системы.',
hint: 'Подсказка:',
portableHint: 'Портативный токен сопряжения уже сохранён в этой папке. Вы можете включить его здесь или выбрать «Позже».',
defaultHint: 'Если вы выберете «Позже», расширение будет работать только во время текущего сеанса.',
enableFromSettings: 'Вы можете включить хранилище в любое время в меню «Настройки» > «Интеграции».',
later: 'Позже',
enabling: 'Включение…',
timeout: 'Время ожидания запроса к хранилищу учётных данных истекло. Вы можете выбрать «Позже» и попробовать снова.',
unavailable: 'Хранилище {{store}} недоступно.',
accessRequired: 'Предоставьте доступ к хранилищу учётных данных перед повторной генерацией токена сопряжения.',
storeUnavailable: 'Доступ к хранилищу учётных данных недоступен.',
},
app: {
loading: 'Загрузка…',
settingsSaveFailed: 'Не удалось сохранить настройки. Проверьте разрешения хранилища и попробуйте снова.',
systemActionCountdown: '{{action}} через 10 секунд.',
systemActionCancelled: 'Системное действие отменено, так как есть активная или запланированная загрузка.',
systemActionFailed: 'Не удалось выполнить запланированное системное действие: {{detail}}',
downloadCompleteTitle: 'Загрузка завершена',
downloadCompleteBody: 'Загрузка {{fileName}} завершена.',
unknownFile: 'Файл',
downloadFailedTitle: 'Ошибка загрузки',
downloadFailedBody: 'Не удалось загрузить {{fileName}}.',
initializeDownloadsFailed: 'Не удалось инициализировать сохранённые загрузки: {{detail}}',
extensionDisconnected: 'Расширение браузера отключено, так как его токен сопряжения изменился.',
copyToken: 'Скопировать токен',
integrations: 'Интеграции',
copyTokenFailed: 'Не удалось скопировать токен сопряжения: {{detail}}',
credentialPersistenceFailed: 'Безопасное сохранение учётных данных недоступно. Сопряжение с браузером работает только в текущем сеансе: {{detail}}',
resumeDownloadsFailed: 'Не удалось возобновить сохранённые загрузки: {{detail}}',
updateAvailable: 'Доступна Firelink {{version}}.',
viewRelease: 'Просмотреть релиз',
sleepPreventionFailed: 'Не удалось обновить предотвращение перехода в спящий режим: {{detail}}',
schedulerNoQueues: 'В планировщике не выбраны действительные очереди. Обновите настройки планировщика.',
schedulerPauseOneFailed: 'Планировщик не смог приостановить 1 загрузку.',
schedulerPauseManyFailed: 'Планировщик не смог приостановить {{count}} загрузок.',
scheduledIncomplete: 'Не все запланированные загрузки были завершены. Системное действие после завершения очереди пропущено.',
scheduledActionSkippedActive: 'Запланированное системное действие пропущено, так как есть активная или находящаяся в очереди загрузка.',
notificationsDisabled: 'Системные уведомления отключены для Firelink.',
notificationsFailed: 'Не удалось настроить уведомления: {{detail}}',
},
sidebar: {
deleteQueueConfirm: 'Удалить очередь «{{name}}»? Её незавершённые загрузки будут перемещены в Главную очередь.',
deleteQueueFailed: 'Не удалось удалить очередь: {{detail}}',
queueNameEmpty: 'Название очереди не может быть пустым',
queueNameExists: 'Очередь с таким названием уже существует',
startQueueFailed: 'Не удалось запустить очередь: {{detail}}',
pauseQueueFailed: 'Не удалось приостановить очередь: {{detail}}',
},
downloadTable: {
unknownQueue: 'Неизвестная очередь',
allDownloads: 'Все загрузки',
active: 'Активные',
completed: 'Завершённые',
unfinished: 'Незавершённые',
headers: {
fileName: 'Имя файла',
size: 'Размер',
status: 'Статус',
speed: 'Скорость',
eta: 'Осталось',
dateAdded: 'Дата добавления',
},
columnOptions: {
open: 'Параметры столбца «{{column}}»',
dragToReorder: 'Перетащите для изменения порядка столбцов',
alignContent: 'Выравнивание содержимого',
left: 'По левому краю',
center: 'По центру',
right: 'По правому краю',
resetLayout: 'Сбросить макет столбцов',
},
addDownload: 'Добавить загрузку',
resumeAll: 'Возобновить все',
pauseAll: 'Приостановить все',
queueEmpty: 'Очередь пуста',
noCompletedDownloads: 'Нет завершённых загрузок',
noDownloads: 'Нет загрузок',
queueEmptyDescription: 'Добавьте загрузки в эту очередь из меню элемента или окна добавления.',
completedDescription: 'Завершённые загрузки будут отображаться здесь.',
clickToAdd: 'Нажмите кнопку',
addButtonOr: 'или',
toAddDownloads: 'чтобы добавить загрузки',
nonResumableOne: '1 загрузка не поддерживает возобновление. Если вы приостановите её, позже придётся начинать скачивание заново. Вы действительно хотите приостановить?',
nonResumableMany: '{{count}} загрузок не поддерживают возобновление. Если вы приостановите их, позже придётся начинать скачивание заново. Вы действительно хотите приостановить?',
interactionError: '{{message}}: {{detail}}',
openFileFailed: 'Не удалось открыть скачанный файл',
revealFileFailed: 'Не удалось показать файл в Finder',
pauseFailed: 'Не удалось приостановить загрузку',
resumeFailed: 'Не удалось возобновить {{fileName}}',
moveManyFailed: 'Не удалось переместить загрузки в очередь',
moveOneFailed: 'Не удалось переместить загрузку в очередь',
copyAddressesFailed: 'Не удалось скопировать адреса',
copyAddressFailed: 'Не удалось скопировать адрес',
copyPathFailed: 'Не удалось скопировать путь к файлу',
missingFileName: 'Отсутствует имя файла',
redownloadFailed: 'Не удалось скачать повторно',
startResume: 'Запустить/Возобновить',
addToQueue: 'Добавить в очередь',
copyAddress: 'Скопировать адрес',
remove: 'Удалить',
open: 'Открыть',
showInFolder: 'Показать в папке',
pause: 'Приостановить',
start: 'Запустить',
resume: 'Возобновить',
redownload: 'Скачать повторно',
copyFilePath: 'Скопировать путь к файлу',
properties: 'Свойства',
backendRejectedStart: 'Фоновый модуль отклонил запрос на запуск/возобновление.',
transferActive: 'Нельзя изменять свойства во время активной передачи. Сначала приостановите её.',
redownloadNotFound: 'Невозможно скачать повторно: загрузка не найдена.',
redownloadActive: 'Невозможно повторно скачать загрузку в статусе «{{status}}». Сначала приостановите её или дождитесь завершения.',
originalUrlMissing: 'Невозможно скачать повторно: исходный URL отсутствует.',
pauseBeforeMove: 'Приостановите {{file}} перед перемещением в другую очередь.',
},
addDownloads: {
unknownSize: 'Неизвестный размер',
unknown: 'Неизвестно',
discardSetup: 'Отменить настройку этой загрузки?',
speedInvalid: 'Ограничение скорости должно быть больше нуля',
duplicateResolveFailed: 'Не удалось разрешить дубликаты загрузок: {{detail}}',
addedWithFailures: 'Добавлено: {{added}}, с ошибкой: {{failed}}. {{detail}}',
addedOne: 'Добавлена 1 загрузка',
addedMany: 'Добавлено {{count}} загрузок',
updatedOne: 'Обновлена 1 загрузка',
updatedMany: 'Обновлено {{count}} загрузок',
urlAlreadyQueued: 'URL уже находится в очереди',
destinationConflict: 'Другая выбранная загрузка использует это место назначения',
existingDownloadDestination: 'Существующая загрузка Firelink использует эту папку назначения',
matchingDownloadFilename: 'Незавершённая загрузка уже использует это имя файла',
fileExistsOnDisk: 'Файл уже существует на диске; переименуйте или пропустите его, чтобы не удалить сторонние данные',
backendRejectedStart: 'Фоновый модуль отклонил запуск загрузки.',
playlistNoEntries: 'Плейлист не содержит доступных элементов',
invalidMediaMetadata: 'Некорректные метаданные медиа или форматы не найдены',
noAvailableName: 'Не удалось найти доступное имя для {{file}}.',
pauseBeforeReplace: 'Приостановите {{file}} перед заменой.',
cannotReplace: 'Невозможно заменить {{file}}: файл не принадлежит загрузке Firelink.',
downloadLinks: 'Ссылки для скачивания',
pastePlaceholder: 'Вставьте сюда URL-адреса HTTP, HTTPS, FTP или SFTP…\n\nДля загрузки медиа вставляйте ссылки с YouTube, X, TikTok, Instagram, Reddit и т. д.',
playlistSummary: 'Плейлист «{{title}}»: загружено {{loaded}} из {{total}} элементов{{truncated}}{{skipped}}',
safeEntryLimit: ' (достигнут безопасный лимит элементов)',
selectedSummary: 'Выбрано: {{ready}} готовых, {{fallback}} с резервными данными, {{mediaRetry}} повторов медиа, {{blocked}} заблокировано',
clearSelection: 'Очистить выбор',
selectAll: 'Выбрать все',
refreshMetadata: 'Обновить метаданные',
files: 'Файлы',
required: 'Требуется',
free: 'Свободно',
preview: 'Предпросмотр',
file: 'Файл',
size: 'Размер',
status: 'Статус',
downloadPreview: 'Предпросмотр загрузки',
noLinks: 'Ссылки ещё не добавлены.',
selectItem: 'Выбрать {{file}}',
fetchingPlaylist: 'Получение плейлиста…',
fetching: 'Получение…',
unsafeUrl: 'Небезопасный URL',
playlistFailed: 'Ошибка плейлиста',
metadataFailed: 'Ошибка метаданных',
fallback: 'Резервный режим',
invalid: 'Некорректный',
ready: 'Готово',
mediaFormat: 'Формат медиа',
playlistItem: 'Элемент плейлиста {{index}}',
fetchingMediaStreams: 'Получение медиапотоков…',
availableStreams: 'Доступные потоки',
availableMediaStreams: 'Доступные медиапотоки',
metadataUnavailable: 'Метаданные недоступны. Обновите метаданные перед добавлением этого медиа.',
saveLocation: 'Папка сохранения',
browse: 'Обзор…',
categoryFolders: 'Файлы будут распределены по папкам категорий.',
sharedFolder: 'Все выбранные загрузки будут использовать эту папку.',
dedicatedFolder: 'Сохранить в новую папку',
dedicatedFolderName: 'Имя папки',
dedicatedFolderDescription: 'Создаёт эту папку внутри каждой автоматической папки категории.',
dedicatedFolderManualDescription: 'Создаёт эту папку внутри выбранного места сохранения.',
saveFolderName: 'Имя папки сохранения',
dedicatedFolderNameRequired: 'Введите имя папки перед сохранением.',
transferSettings: 'Настройки передачи',
connectionsPerFile: 'Соединений на файл',
connectionsPerFileAria: 'Соединений на файл',
limitSpeedPerFile: 'Ограничить скорость на файл',
speedLimitPerFileAria: 'Ограничение скорости на файл',
authorization: 'Авторизация',
useAuthorization: 'Использовать авторизацию',
username: 'Имя пользователя',
password: 'Пароль',
advancedTransfer: 'Дополнительные параметры',
verifyChecksum: 'Проверять контрольную сумму',
checksumAlgorithm: 'Алгоритм контрольной суммы',
expectedDigest: 'Ожидаемый хеш',
headers: 'Заголовки',
requestHeaders: 'Заголовки запроса',
cookies: 'Файлы cookie',
cookiePlaceholder: 'name=value; other=value',
mirrors: 'Зеркала',
cancel: 'Отмена',
startDownloads: 'Начать загрузку',
addToQueue: 'Добавить в очередь',
pasteOneOrMore: 'Вставьте одну или несколько ссылок.',
selectAtLeastOne: 'Выберите хотя бы одну загрузку.',
correctInvalidOne: 'Исправьте или удалите {{count}} некорректный URL-адрес перед продолжением.',
correctInvalidFew: 'Исправьте или удалите {{count}} некорректных URL-адреса перед продолжением.',
correctInvalidMany: 'Исправьте или удалите {{count}} некорректных URL-адресов перед продолжением.',
waitingForMetadataOne: 'Ожидание метаданных для {{count}} загрузки…',
waitingForMetadataFew: 'Ожидание метаданных для {{count}} загрузок…',
waitingForMetadataMany: 'Ожидание метаданных для {{count}} загрузок…',
removeUnsafeOne: 'Удалите {{count}} небезопасный URL-адрес перед продолжением.',
removeUnsafeFew: 'Удалите {{count}} небезопасных URL-адреса перед продолжением.',
removeUnsafeMany: 'Удалите {{count}} небезопасных URL-адресов перед продолжением.',
mediaMetadataUnavailableSummaryOne: 'Метаданные медиа недоступны для {{count}} элемента. Обновите метаданные перед добавлением.',
mediaMetadataUnavailableSummaryFew: 'Метаданные медиа недоступны для {{count}} элементов. Обновите метаданные перед добавлением.',
mediaMetadataUnavailableSummaryMany: 'Метаданные медиа недоступны для {{count}} элементов. Обновите метаданные перед добавлением.',
metadataUnavailableFallback: 'Метаданные недоступны. Загрузки всё равно можно добавить с резервными данными.',
fallbackReadyOne: 'Готова {{ready}} загрузка; для {{failed}} будет использовано резервное имя файла и неизвестный размер.',
fallbackReadyFew: 'Готовы {{ready}} загрузки; для {{failed}} будет использовано резервное имя файла и неизвестный размер.',
fallbackReadyMany: 'Готово {{ready}} загрузок; для {{failed}} будет использовано резервное имя файла и неизвестный размер.',
readyToAddOne: 'Готово к добавлению: {{count}} загрузка.',
readyToAddFew: 'Готово к добавлению: {{count}} загрузки.',
readyToAddMany: 'Готово к добавлению: {{count}} загрузок.',
},
settings: {
tabs: {
downloads: 'Загрузки',
lookAndFeel: 'Внешний вид',
network: 'Сеть',
locations: 'Папки',
siteLogins: 'Учётные данные',
power: 'Питание',
engine: 'Движок',
integrations: 'Интеграции',
about: 'О программе',
},
common: {
thisOs: 'этой ОС',
checking: 'Проверка…',
ready: 'Готово',
errorMissing: 'Ошибка / Отсутствует',
hideTechnicalDetails: 'Скрыть технические детали',
showTechnicalDetails: 'Показать технические детали',
binary: 'Исполняемый файл',
expected: 'Ожидается',
error: 'Ошибка',
tip: 'Совет',
stderr: 'stderr',
daemonProcessAlive: 'Процесс фоновой службы активен',
rpcReady: 'RPC готов',
rpcPort: 'Порт RPC',
daemonStderr: 'stderr фоновой службы',
packaging: 'Упаковка: PyInstaller onedir (требуется _internal)',
internalDirectoryFound: 'Каталог _internal найден',
pythonRuntimeFound: 'Среда выполнения Python найдена',
externalOpenFailed: 'Не удалось открыть {{label}}: {{detail}}',
updateUnexpectedResponse: 'Проверка обновлений вернула неожиданный ответ.',
updateUntrustedReleaseUrl: 'Проверка обновлений вернула ненадёжный URL релиза.',
updateFailed: 'Ошибка проверки обновлений: {{detail}}',
},
downloads: {
defaultConnections: 'Соединения по умолчанию:',
defaultConnectionsDescription: 'Для новых загрузок; существующие элементы сохраняют своё значение',
parallelDownloads: 'Параллельные загрузки:',
parallelDownloadsDescription: 'Максимальное число одновременно активных файлов',
automaticRetries: 'Автоматические повторы:',
automaticRetriesDescription: 'При сбое соединения',
systemNotification: 'Показывать системное уведомление по завершении загрузки',
systemNotificationDescription: 'Использует настройки уведомлений вашей операционной системы',
completionChime: 'Воспроизводить звуковой сигнал завершения в приложении',
completionChimeDescription: 'Дополнительный звук Firelink, независимый от системных уведомлений',
clipboardLinks: 'Добавлять ссылки из буфера обмена при активации Firelink',
clipboardLinksDescription: 'Открывает скопированные поддерживаемые ссылки в окне добавления. По умолчанию отключено.',
},
lookAndFeel: {
language: 'Язык',
languageDescription: 'Выберите язык интерфейса Firelink.',
sidebarPosition: 'Положение боковой панели',
sidebarPositionDescription: 'Выберите, где отображается боковая панель. В автоматическом режиме следует направлению интерфейса.',
sidebarPositionAutomatic: 'Автоматически (рекомендуется)',
sidebarPositionLeft: 'Слева',
sidebarPositionRight: 'Справа',
languageSystem: 'Системный по умолчанию',
languageEnglish: 'Английский',
languageChinese: 'Упрощённый китайский',
languageHebrew: 'Иврит',
languagePersian: 'Персидский',
languageUkrainian: 'Украинский',
languageRussian: 'Русский',
appTheme: 'Тема приложения',
theme: 'Тема',
ariaLabel: 'Тема приложения',
system: 'Системная',
light: 'Светлая',
dark: 'Тёмная',
dracula: 'Dracula',
nord: 'Nord',
systemAppearance: 'Системная тема соответствует текущему светлому или тёмному оформлению {{platform}}.',
display: 'Отображение',
fontSize: 'Размер шрифта',
fontSizeDescription: 'Масштабирует список загрузок и компактные элементы управления.',
small: 'Мелкий',
standard: 'Стандартный',
large: 'Крупный',
listDensity: 'Плотность списка',
listDensityDescription: 'Изменяет высоту строк, отступы и масштаб индикатора прогресса.',
compact: 'Компактный',
comfortable: 'Удобный',
relaxed: 'Просторный',
osIntegration: 'Интеграция с ОС',
dockBadge: 'Показывать счётчик на значке в Dock',
dockBadgeDescription: 'Отображает количество активных загрузок на значке Firelink в Dock.',
menuBarIcon: 'Показывать значок в строке меню',
statusIndicatorIcon: 'Показывать значок состояния',
systemTrayIcon: 'Показывать значок в системном трее',
macosTrayDescription: 'Обеспечивает быстрый доступ из строки меню macOS.',
windowsTrayDescription: 'Обеспечивает быстрый доступ из области уведомлений Windows.',
linuxTrayDescription: 'Обеспечивает быстрый доступ из трея рабочего стола или области состояния Linux (при наличии).',
defaultTrayDescription: 'Обеспечивает быстрый доступ из области системного трея ОС (при наличии).',
},
network: {
proxy: 'Прокси',
mode: 'Режим',
modeDescription: 'Управляет использованием прокси для новых запросов на загрузку.',
noProxy: 'Без прокси',
systemProxy: 'Системный прокси',
customProxy: 'Собственный прокси',
proxyHost: 'Хост прокси',
proxyHostDescription: 'Имя хоста, IP-адрес или URL HTTP-прокси.',
proxyHostPlaceholder: '127.0.0.1 или http://127.0.0.1',
proxyPort: 'Порт прокси',
proxyPortDescription: 'Допустимый диапазон от 1 до 65535.',
noProxyDescription: 'Загрузки игнорируют настроенные прокси.',
systemProxyDescription: 'Загрузки используют обнаруженный системный прокси {{platform}}. Для обычных загрузок файлов требуется HTTP- или HTTPS-прокси; для загрузки медиа можно использовать SOCKS.',
customProxyDescription: 'Загрузки используют настроенный HTTP-прокси для получения метаданных и движков скачивания.',
invalidCustomProxy: 'Введите корректный хост и порт HTTP-прокси, чтобы включить собственный прокси.',
incompleteCustomProxy: 'Введите хост и порт прокси, чтобы включить собственный прокси.',
checkingSystemProxy: 'Проверка конфигурации системного прокси…',
detectedSystemProxy: 'Обнаружен системный прокси. Для обычных загрузок файлов требуется HTTP- или HTTPS-прокси; для загрузки медиа можно использовать SOCKS.',
noSystemProxy: 'Подходящий системный прокси не обнаружен. Загрузки будут выполняться без прокси.',
systemProxyReadFailed: 'Не удалось прочитать конфигурацию системного прокси. Выберите «Без прокси» или повторите попытку, когда она станет доступна.',
identity: 'Идентификация',
customUserAgent: 'Собственный User-Agent',
userAgentDescription: 'Применяется при получении метаданных и работе движков загрузки.',
userAgentPlaceholder: 'Оставьте пустым, чтобы использовать значение Firelink по умолчанию',
userAgentOverrides: 'Переопределяет исходящий заголовок User-Agent. Оставьте пустым, чтобы использовать значения Firelink по умолчанию.',
chromeWindows: 'Chrome (Windows)',
chromeMacos: 'Chrome (macOS)',
edgeWindows: 'Edge (Windows)',
firefoxWindows: 'Firefox (Windows)',
firefoxMacos: 'Firefox (macOS)',
safariMacos: 'Safari (macOS)',
windowsDesktop: 'Windows (ПК)',
macosDesktop: 'macOS (ПК)',
},
locations: {
baseDownloadFolder: 'Основная папка загрузок',
baseDownloadFolderDescription: 'Автоматические папки категорий создаются внутри этой папки.',
browse: 'Обзор…',
baseFolderCreateFailed: 'Основная папка сохранена, но не удалось создать папки категорий: {{detail}}',
baseFolderUpdated: 'Основная папка загрузок обновлена',
askWhereToSave: 'Запрашивать место сохранения при добавлении загрузок',
rememberLastUsedDownloadDirectory: 'Запоминать последнюю использованную папку загрузки',
rememberLastUsedDownloadDirectoryDescription: 'Использовать последнюю папку, выбранную в окне добавления, для следующей загрузки до перезапуска приложения.',
automaticCategorySubfolders: 'Автоматически сохранять файлы в подпапки категорий',
automaticCategorySubfoldersDescription: 'Если отключено, автоматические загрузки используют основную папку.',
categorySubfolders: 'Подпапки категорий',
relativeToBase: 'Относительно основной папки',
disabled: 'Отключено',
categorySubfolder: 'Подпапка «{{category}}»',
customFolder: 'Собственная папка',
useAutomatic: 'Автоматически',
resetDefaults: 'Сбросить по умолчанию',
resetToast: 'Настройки папок категорий сброшены по умолчанию',
},
siteLogins: {
title: 'Учётные данные сайтов',
noSavedLogins: 'Нет сохранённых логинов.',
user: 'Пользователь: {{username}}',
deleteCredential: 'Удалить учётные данные',
deletedCredential: 'Учётные данные удалены',
deleteFailed: 'Не удалось удалить учётные данные: {{detail}}',
addTitle: 'Добавить учётные данные сайта',
urlPattern: 'Шаблон URL:',
urlPatternPlaceholder: 'например *.example.com или example.com/downloads',
username: 'Имя пользователя:',
password: 'Пароль:',
usernamePlaceholder: 'Имя пользователя',
passwordPlaceholder: 'Пароль',
saving: 'Сохранение…',
addLogin: 'Добавить логин',
urlPatternRequired: 'Требуется указать шаблон URL.',
urlPatternWhitespace: 'Шаблон URL не может содержать пробелы.',
usernameRequired: 'Требуется указать имя пользователя.',
passwordRequired: 'Требуется указать пароль.',
accessBeforeSave: 'Предоставьте доступ к хранилищу учётных данных перед сохранением логина сайта.',
saveFailed: 'Не удалось безопасно сохранить учётные данные сайта.',
addedCredential: 'Добавлены учётные данные сайта',
},
power: {
title: 'Управление питанием',
preventSleep: 'Запрещать спящий режим системы во время активных загрузок',
preventSleepDescription: 'Экран всё ещё может выключаться. Firelink предотвращает засыпание устройства только настолько, чтобы завершить активные передачи.',
},
engine: {
title: 'Загрузчик медиа и движки',
description: 'Успешные результаты используются повторно в течение сеанса приложения. Повторная проверка запускает реальную валидацию заново.',
recheck: 'Перепроверить движки',
coreDownloader: 'Основной загрузчик (aria2)',
version: 'Версия:',
status: 'Статус:',
mediaExtractors: 'Экстракторы медиа',
browserCookiesSource: 'Источник файлов cookie браузера:',
cookieDescription: 'yt-dlp читает файлы cookie браузера для обхода ограничений на загрузку видео или доступа к закрытым медиа. Firelink не сохраняет файлы cookie браузера.',
none: 'Не использовать',
},
integrations: {
title: 'Подключение расширения браузера',
description: 'Перехватывайте загрузки прямо из браузера за три простых шага.',
portablePairingEnabled: 'Портативное сопряжение включено',
pairingTokenPersisted: 'Токен сопряжения сохранён',
portablePersistedDescription: 'Ваш токен сопряжения хранится в этой портативной папке Firelink и сохранится при перемещении папки. Относитесь к папке как к конфиденциальным данным.',
systemPersistedDescription: 'Ваш токен сопряжения сохранён в системном хранилище учётных данных и сохранится после перезапуска.',
portablePairingAvailable: 'Доступно портативное сопряжение',
credentialStorageNeeded: 'Требуется доступ к хранилищу учётных данных',
portableAvailableDescription: 'Ваш токен сопряжения хранится в этой портативной папке Firelink и сохранится после перезапуска. Включите его здесь, чтобы ознакомиться с предупреждением о портативном хранилище.',
credentialStorageDescription: 'Firelink требуется доступ к системному хранилищу учётных данных для безопасного сохранения токена сопряжения между перезапусками приложения. В настоящее время ваше расширение будет оставаться подключённым только на время этого сеанса.',
reviewPortablePairing: 'Проверить портативное сопряжение',
grantCredentialAccess: 'Предоставить доступ к хранилищу',
copyToken: 'Скопировать токен',
tokenDescription: 'Этот защищённый токен авторизует ваше расширение браузера.',
tokenCopied: 'Токен скопирован в буфер обмена!',
tokenCopyFailed: 'Не удалось скопировать токен: {{detail}}',
pairingTokenRegenerated: 'Токен сопряжения сгенерирован заново',
regenerateFailed: 'Не удалось сгенерировать токен сопряжения заново: {{detail}}',
getExtension: 'Получить расширение',
extensionDescription: 'Установите Firelink Companion для браузеров Firefox или Chromium.',
firefoxAddons: 'Дополнения Firefox',
chromiumZip: 'Chromium ZIP',
pasteConnect: 'Вставить и подключить',
pasteConnectDescription: 'Нажмите на значок Firelink на панели инструментов браузера и вставьте скопированный токен.',
extensionServerStatus: 'Статус сервера расширения:',
listening: '● Прослушивание 127.0.0.1:{{port}}',
unavailable: '● Сервер недоступен',
},
about: {
firelinkIcon: 'Значок Firelink',
unknown: 'Неизвестно',
version: 'Версия {{version}}',
description: 'Кроссплатформенный менеджер загрузок для macOS, Windows и Linux.',
sourceCode: 'Исходный код',
source: 'Исходный код',
upToDate: 'У вас установлена актуальная версия Firelink ({{version}}).',
updateAvailable: 'Доступна новая версия Firelink {{version}}.',
viewRelease: 'Просмотреть релиз',
updates: 'Обновления',
checkForUpdates: 'Проверить обновления',
updateDescription: 'Firelink проверяет наличие новых версий в GitHub Releases.',
checking: 'Проверка…',
checkNow: 'Проверить сейчас',
automaticUpdates: 'Автоматически проверять наличие обновлений',
createdBy: 'Разработчик: NimBold',
mitLicense: 'Лицензия MIT',
credits: 'Благодарности: aria2, yt-dlp, FFmpeg и Deno.',
copyright: 'Copyright © 2026 NimBold. Все права защищены.',
},
},
status: {
ready: 'Готово',
active: '{{count}} активных',
queued: '{{count}} в очереди',
done: '{{count}} завершено',
},
} as const;
export default ru;
+755
View File
@@ -0,0 +1,755 @@
const uk = {
navigation: {
library: 'Бібліотека',
filters: {
all: 'Усі',
active: 'Активні',
completed: 'Завершені',
unfinished: 'Незавершені',
},
categories: {
musics: 'Музика',
movies: 'Відео',
compressed: 'Архіви',
documents: 'Документи',
pictures: 'Зображення',
applications: 'Програми',
other: 'Інше',
},
folders: 'Папки',
queues: 'Черги',
tools: 'Інструменти',
scheduler: 'Планувальник',
speedLimiter: 'Обмеження швидкості',
logs: 'Журнали',
settings: 'Налаштування',
},
actions: {
hideSidebar: 'Сховати бічну панель',
showSidebar: 'Показати бічну панель',
resizeSidebar: 'Змінити розмір бічної панелі',
cancel: 'Скасувати',
continue: 'Продовжити',
dismissNotification: 'Закрити сповіщення',
queueName: 'Назва черги',
addNewQueue: 'Додати нову чергу',
startQueue: 'Запустити чергу',
pauseQueue: 'Призупинити чергу',
renameQueue: 'Перейменувати чергу',
deleteQueue: 'Видалити чергу',
},
dialogs: {
errorBoundary: {
title: 'Щось пішло не так.',
description: 'Сталася критична помилка в дереві компонентів React. Наведені нижче деталі помилки можуть допомогти виявити першопричину.',
reload: 'Перезавантажити програму',
},
duplicateDownloads: {
title: 'Виявлено дублікати',
description: 'Деякі із завантажень, які ви додаєте, вже існують у черзі або на диску. Будь ласка, виберіть, як вирішити ці конфлікти.',
rename: 'Перейменувати',
replace: 'Замінити',
skip: 'Пропустити',
},
removeDownload: {
title: 'Видалити завантаження',
confirmationSingle: 'Ви впевнені, що хочете видалити цей елемент зі списку? Ви також можете видалити сам файл з вашого диска.',
confirmationMultiple: 'Ви впевнені, що хочете видалити вибрані елементи ({{count}}) зі списку? Ви також можете видалити самі файли з диска.',
errorSummary: '{{succeeded}} видалено, {{failed}} не вдалося: {{detail}}',
remove: 'Видалити',
deleteFile: 'Видалити файл',
},
},
window: {
controls: 'Керування вікном',
close: 'Закрити',
minimize: 'Згорнути',
maximize: 'Розгорнути',
},
downloads: {
actions: {
moveUp: 'Перемістити вгору',
moveDown: 'Перемістити вниз',
pause: 'Призупинити',
start: 'Запустити',
resume: 'Відновити',
options: 'Опції',
},
size: {
downloadedOf: '{{downloaded}} завантажено з {{total}} {{unit}}',
downloadedOfApproximate: '{{downloaded}} завантажено з приблизно {{total}} {{unit}}',
},
status: {
ready: 'Готово',
staged: 'У черзі',
queued: 'У черзі',
downloading: 'Завантаження',
processing: 'Обробка',
paused: 'Призупинено',
completed: 'Завершено',
failed: 'Помилка',
retrying: 'Повторна спроба',
},
values: {
processing: 'Обробка…',
muxing: 'Об\'єднання потоків…',
},
},
speedLimiter: {
title: 'Обмеження швидкості',
unlimited: 'Необмежено',
saveLimit: 'Зберегти обмеження',
globalSpeedLimit: 'Глобальне обмеження швидкості',
description: 'Застосовується до нових і активних завантажень aria2 та нових медіазавантажень yt-dlp. Індивідуальні обмеження для кожного завантаження залишаються прикріпленими до цих завантажень.',
quickPresets: 'Швидкі пресети',
removePreset: 'Видалити пресет {{value}} {{unit}}',
customPresetIn: 'Свій пресет у {{unit}}',
addQuickPreset: 'Додати швидкий пресет',
globalLimitSaved: 'Глобальне обмеження збережено на рівні {{value}} {{unit}}',
globalLimitDisabled: 'Глобальне обмеження швидкості вимкнено',
saveFailed: 'Не вдалося зберегти глобальне обмеження швидкості: {{detail}}',
presetAlreadyExists: '{{value}} {{unit}} вже є у швидких пресетах',
presetAdded: 'Додано швидкий пресет {{value}} {{unit}}',
presetRemoved: 'Видалено швидкий пресет {{value}} {{unit}}',
},
logs: {
title: 'Журнали',
entries: '({{count}} записів)',
collecting: 'Збір даних',
off: 'Вимкнено',
allLevels: 'Усі рівні',
levels: {
error: 'Помилка',
warn: 'Попередження',
info: 'Інфо',
debug: 'Налагодження',
trace: 'Трасування',
},
pauseLogging: 'Призупинити діагностичне логування',
enableLogging: 'Увімкнути діагностичне логування',
clearDisplayed: 'Очистити відображені журнали',
export: 'Експортувати журнали',
exportButton: 'Експортувати журнали',
logFiles: 'Файли журналів',
localDiagnostics: 'Локальна діагностика:',
diagnosticsDescription: 'Збір діагностичних даних є добровільним, обмеженим і локальним. Звичайні секрети, запити URL та шляхи до домашнього каталогу приховуються під час відображення та експорту. Нічого не завантажується автоматично.',
noEntries: 'Збережених записів журналів поки немає.',
disabledDescription: 'Діагностичне логування вимкнено. Існуючі журнали підтримки з\'являться тут, коли будуть доступні.',
copy: 'Копіювати',
copied: 'Скопійовано в буфер обміну',
copyFailed: 'Не вдалося скопіювати в буфер обміну',
exported: 'Журнали підтримки експортовано',
exportFailed: 'Не вдалося експортувати журнали: {{detail}}',
cleared: 'Журнали очищено',
clearFailed: 'Не вдалося очистити журнали: {{detail}}',
enabled: 'Діагностичне логування увімкнено',
disabled: 'Діагностичне логування вимкнено',
updateFailed: 'Не вдалося оновити діагностичне логування: {{detail}}',
},
scheduler: {
title: 'Планувальник',
running: 'Працює',
disabled: 'Планувальник вимкнено',
noScheduledDay: 'Не вибрано запланованого дня',
unsavedChanges: 'Незбережені зміни',
runNow: 'Запустити зараз',
pause: 'Призупинити',
saveSettings: 'Зберегти налаштування',
timing: 'Час',
startTime: 'Час початку',
stopTime: 'Час зупинки',
timingDescription: 'Якщо Firelink знаходиться в режимі сну на момент початку, він запустить вибрані черги після повернення пізніше того ж дня, якщо час зупинки ще не минув.',
runEveryDay: 'Запускати щодня',
queuesToSchedule: 'Черги для планування',
defaultQueue: 'Черга за замовчуванням',
afterCompletion: 'Після завершення',
afterCompletionDescription: 'Виберіть, що станеться після завершення завантажень, розпочатих планувальником.',
actionWarning: 'Ця дія може перервати іншу роботу на комп\'ютері. Firelink викликає її відразу після завершення запланованої черги.',
systemPermissions: 'Системні дозволи',
macPermissionDescription: 'Сон, перезавантаження та вимкнення вимагають дозволу на автоматизацію macOS для системних подій (System Events).',
permissionGranted: 'Дозвіл на автоматизацію надано',
permissionChecking: 'Перевірка дозволу на автоматизацію…',
permissionMissing: 'Відсутній дозвіл на автоматизацію',
revokePermission: 'Відкликати дозвіл',
grantPermission: 'Надати дозвіл',
permissionAvailable: 'Дозвіл на автоматизацію доступний.',
permissionMissingDetails: 'Відсутній дозвіл на автоматизацію. Увімкніть Firelink у розділі Автоматизація для системних подій у Налаштуваннях системи.',
revokePermissionDetails: 'macOS не дозволяє Firelink безпосередньо відкликати дозвіл на автоматизацію. Відкличте доступ до системних подій у Налаштуваннях системи, а потім поверніться до Firelink.',
requestingPermission: 'Запит дозволу на автоматизацію…',
enablePermissionDetails: 'Увімкніть Firelink у розділі Автоматизація для системних подій у Налаштуваннях системи, а потім поверніться до Firelink.',
systemActions: 'Системні дії',
windowsActionsDescription: 'Сон, перезавантаження та вимкнення використовують системні привілеї Windows. Firelink повідомляє про будь-яку відхилену дію під час її виконання; жодні постійні дозволи заздалегідь не вимагаються.',
linuxActionsDescription: 'Сон, перезавантаження та вимкнення використовують ваш робочий стіл Linux та системну політику. Firelink повідомляє про будь-яку відхилену дію під час її виконання; жодні постійні дозволи заздалегідь не вимагаються.',
validationDay: 'Виберіть принаймні один день для планувальника',
validationQueue: 'Виберіть принаймні одну чергу для планувальника',
validationStopTime: 'Час зупинки має бути пізнішим за час початку',
saved: 'Налаштування планувальника збережено',
trackingOne: 'Відстеження 1 запланованого завантаження',
trackingMany: 'Відстеження {{count}} запланованих завантажень',
noStartableDownloads: 'Жодне завантаження у вибраних чергах не може бути запущено',
pausedOne: 'Призупинено 1 активне завантаження',
pausedMany: 'Призупинено {{count}} активних завантажень',
noActiveDownloads: 'Немає активних завантажень',
days: {
su: 'Нд',
mo: 'Пн',
tu: 'Вв',
we: 'Ср',
th: 'Чт',
fr: 'Пт',
sa: 'Сб',
},
postActions: {
none: 'Нічого не робити',
sleep: 'Сон',
restart: 'Перезавантажити',
shutdown: 'Вимкнути',
},
},
properties: {
progress: 'Прогрес',
size: 'Розмір',
speed: 'Швидкість',
eta: 'Залишилось',
connections: 'З\'єднання',
connectionCount: '{{active}}/{{total}} активних',
connectionCountUnknown: '—/{{total}} активних',
connectionsUnavailable: '—',
speedCap: 'Обмеження швидкості',
category: 'Категорія',
lastTry: 'Остання спроба',
dateAdded: 'Дата додавання',
destination: 'Призначення',
lastError: 'Остання помилка',
saved: ' (збережено)',
defaultValue: ' (за замовчуванням)',
savedTooltip: 'Збережено для цього завантаження; зміни в налаштуваннях застосовуються до нових завантажень.',
defaultTooltip: 'Використовується поточне значення за замовчуванням для нових завантажень.',
identityReadOnly: 'Ідентифікатор файлу доступний лише для читання. Налаштування передачі збережено для повторного завантаження.',
transferSettings: 'Налаштування передачі можна змінити після зупинки або призупинення. Поточні передачі зберігають свої існуючі налаштування бекенду.',
download: 'Завантаження',
url: 'URL',
fileName: 'Ім\'я файлу',
saveLocation: 'Місце збереження',
select: 'Вибрати',
perFile: 'на файл',
useCurrentDefault: 'Використовувати поточне за замовчуванням ({{count}})',
savedPerDownload: 'Збережено для кожного завантаження. Значення за замовчуванням з Налаштувань застосовується до нових завантажень.',
limit: 'Обмеження',
siteLogin: 'Вхід на сайт',
siteLoginRedownload: 'Вхід на сайт для повторного завантаження',
matchingSiteLogin: 'Відповідний логін сайту',
customCredentials: 'Власні облікові дані',
noLogin: 'Без входу',
useSavedLogin: 'Буде використано збережений логін, якщо доступний.',
advancedTransfer: 'Розширені параметри передачі',
advancedTransferRedownload: 'Розширені параметри передачі для повторного завантаження',
checksum: 'Контрольна сума',
verify: 'Перевірити',
algorithm: 'Алгоритм',
digest: 'Хеш',
expectedDigest: 'Очікуваний хеш',
cookies: 'Файли cookie',
headers: 'Заголовки',
mirrors: 'Дзеркала',
username: 'Ім\'я користувача',
password: 'Пароль',
enterValidUrl: 'Введіть дійсну URL-адресу.',
fileNameEmpty: 'Ім\'я файлу не може бути порожнім.',
cancel: 'Скасувати',
save: 'Зберегти',
},
keychain: {
title: 'Потрібен доступ до сховища облікових даних',
stores: {
portable: 'портативна папка даних Firelink',
windows: 'Диспетчер облікових даних Windows',
linux: 'ваше сховище облікових даних Linux',
macos: 'В\'язка ключів macOS (Keychain)',
system: 'сховище облікових даних цієї системи',
siteCredentials: 'сховище облікових даних системи',
},
grantLabelPortable: 'Продовжити',
grantLabelMacos: 'Надати доступ',
grantLabelDefault: 'Увімкнути безпечне сховище',
description: 'Firelink використовує розширення браузера для захоплення завантажень. Щоб зберегти розширення підключеним після перезапуску, Firelink зберігає свій токен підключення у {{pairingStore}}. Необов\'язкові облікові дані сайтів зберігаються в {{siteCredentialStore}}.',
portableExplanation: 'Токен підключення є портативним і зберігається в цій папці. Облікові дані сайтів залишаються в системному сховищі; після надання доступу може з\'явитися системний запит.',
macosExplanation: 'macOS може показати запит до В\'язки ключів після того, як ви надасте доступ.',
defaultExplanation: 'Зазвичай це виконується у фоновому режимі. Якщо служба облікових даних недоступна, Firelink покаже помилку тут, а розширення залишиться підключеним лише для цього сеансу.',
note: 'Примітка:',
portableNote: 'У портативному режимі в цій папці зберігається лише токен підключення. Він не копіює паролі сайтів або облікові дані браузера.',
defaultNote: 'Firelink записує лише свій власний запис облікових даних. Він не має доступу до інших збережених паролів або елементів облікових даних у вашій системі.',
hint: 'Підказка:',
portableHint: 'Портативний токен підключення вже збережено в цій папці; ви можете увімкнути його тут або вибрати "Пізніше".',
defaultHint: 'Якщо ви виберете "Пізніше", розширення працюватиме лише для цього сеансу.',
enableFromSettings: 'Ви можете увімкнути сховище будь-коли в Налаштування > Інтеграції.',
later: 'Пізніше',
enabling: 'Увімкнення…',
timeout: 'Час очікування запиту до сховища облікових даних вичерпано. Ви можете вибрати "Пізніше" і спробувати ще раз.',
unavailable: '{{store}} недоступно.',
accessRequired: 'Надайте доступ до сховища облікових даних перед повторною генерацією токена підключення.',
storeUnavailable: 'Доступ до сховища облікових даних недоступний.',
},
app: {
loading: 'Завантаження…',
settingsSaveFailed: 'Не вдалося зберегти налаштування. Перевірте дозволи сховища та спробуйте ще раз.',
systemActionCountdown: '{{action}} через 10 секунд.',
systemActionCancelled: 'Системна дія скасована, оскільки активне або в черзі інше завантаження.',
systemActionFailed: 'Не вдалося виконати заплановану системну дію: {{detail}}',
downloadCompleteTitle: 'Завантаження завершено',
downloadCompleteBody: 'Завантаження {{fileName}} завершено.',
unknownFile: 'Файл',
downloadFailedTitle: 'Помилка завантаження',
downloadFailedBody: 'Не вдалося завантажити {{fileName}}.',
initializeDownloadsFailed: 'Не вдалося ініціалізувати збережені завантаження: {{detail}}',
extensionDisconnected: 'Розширення браузера відключено, оскільки його токен підключення змінився.',
copyToken: 'Скопіювати токен',
integrations: 'Інтеграції',
copyTokenFailed: 'Не вдалося скопіювати токен підключення: {{detail}}',
credentialPersistenceFailed: 'Безпечне збереження облікових даних недоступне. Підключення браузера працює лише для цього сеансу: {{detail}}',
resumeDownloadsFailed: 'Не вдалося відновити збережені завантаження: {{detail}}',
updateAvailable: 'Доступна нова версія Firelink {{version}}.',
viewRelease: 'Переглянути реліз',
sleepPreventionFailed: 'Не вдалося оновити запобігання переходу в сплячий режим: {{detail}}',
schedulerNoQueues: 'Для планувальника не вибрано жодної дійсної черги. Оновіть налаштування Планувальника.',
schedulerPauseOneFailed: 'Планувальник не зміг призупинити 1 завантаження.',
schedulerPauseManyFailed: 'Планувальник не зміг призупинити {{count}} завантажень.',
scheduledIncomplete: 'Заплановані завантаження не були завершені повністю. Системну дію після виконання черги пропущено.',
scheduledActionSkippedActive: 'Заплановану системну дію пропущено, оскільки інше завантаження активне або перебуває в черзі.',
notificationsDisabled: 'Системні сповіщення вимкнено для Firelink.',
notificationsFailed: 'Не вдалося налаштувати сповіщення: {{detail}}',
},
sidebar: {
deleteQueueConfirm: 'Видалити чергу "{{name}}"? Її незавершені завантаження перемістяться до Головної черги.',
deleteQueueFailed: 'Не вдалося видалити чергу: {{detail}}',
queueNameEmpty: 'Назва черги не може бути порожньою',
queueNameExists: 'Черга з такою назвою вже існує',
startQueueFailed: 'Не вдалося запустити чергу: {{detail}}',
pauseQueueFailed: 'Не вдалося призупинити чергу: {{detail}}',
},
downloadTable: {
unknownQueue: 'Невідома черга',
allDownloads: 'Усі завантаження',
active: 'Активні',
completed: 'Завершені',
unfinished: 'Незавершені',
headers: {
fileName: 'Ім\'я файлу',
size: 'Розмір',
status: 'Статус',
speed: 'Швидкість',
eta: 'Залишилось',
dateAdded: 'Дата додавання',
},
columnOptions: {
open: 'Параметри стовпця: {{column}}',
dragToReorder: 'Перетягніть, щоб змінити порядок стовпців',
alignContent: 'Позиція вмісту',
left: 'Ліворуч',
center: 'По центру',
right: 'Праворуч',
resetLayout: 'Скинути макет стовпців',
},
addDownload: 'Додати завантаження',
resumeAll: 'Відновити всі',
pauseAll: 'Призупинити всі',
queueEmpty: 'Черга порожня',
noCompletedDownloads: 'Немає завершених завантажень',
noDownloads: 'Немає завантажень',
queueEmptyDescription: 'Додайте завантаження до цієї черги через меню елемента або у вікні "Додати".',
completedDescription: 'Тут з\'являтимуться завершені завантаження.',
clickToAdd: 'Натисніть',
addButtonOr: 'або',
toAddDownloads: 'щоб додати завантаження',
nonResumableOne: '1 завантаження не підтримує відновлення. Якщо ви призупините його, вам доведеться почати спочатку пізніше. Ви впевнені, що хочете призупинити?',
nonResumableMany: '{{count}} завантажень не підтримують відновлення. Якщо ви призупините їх, вам доведеться почати спочатку пізніше. Ви впевнені, що хочете призупинити?',
interactionError: '{{message}}: {{detail}}',
openFileFailed: 'Не вдалося відкрити завантажений файл',
revealFileFailed: 'Не вдалося показати завантаження в Finder',
pauseFailed: 'Не вдалося призупинити завантаження',
resumeFailed: 'Не вдалося відновити {{fileName}}',
moveManyFailed: 'Не вдалося перемістити завантаження до черги',
moveOneFailed: 'Не вдалося перемістити завантаження до черги',
copyAddressesFailed: 'Не вдалося скопіювати адреси',
copyAddressFailed: 'Не вдалося скопіювати адресу',
copyPathFailed: 'Не вдалося скопіювати шлях до файлу',
missingFileName: 'Ім\'я файлу відсутнє',
redownloadFailed: 'Не вдалося повторно завантажити',
startResume: 'Запустити/Відновити',
addToQueue: 'Додати до черги',
copyAddress: 'Скопіювати адресу',
remove: 'Видалити',
open: 'Відкрити',
showInFolder: 'Показати в папці',
pause: 'Призупинити',
start: 'Запустити',
resume: 'Відновити',
redownload: 'Завантажити повторно',
copyFilePath: 'Скопіювати шлях до файлу',
properties: 'Властивості',
backendRejectedStart: 'Бекенд відхилив запит на запуск/відновлення.',
transferActive: 'Неможливо змінити властивості під час активної передачі. Спочатку призупиніть її.',
redownloadNotFound: 'Неможливо завантажити повторно: завантаження не знайдено.',
redownloadActive: 'Неможливо повторно завантажити файл у статусі {{status}}. Спочатку призупиніть або дочекайтеся завершення.',
originalUrlMissing: 'Неможливо завантажити повторно: оригінальна URL-адреса відсутня.',
pauseBeforeMove: 'Призупиніть {{file}} перед переміщенням його до іншої черги.',
},
addDownloads: {
unknownSize: 'Невідомий розмір',
unknown: 'Невідомо',
discardSetup: 'Відкинути це налаштування завантаження?',
speedInvalid: 'Обмеження швидкості має бути більшим за нуль',
duplicateResolveFailed: 'Не вдалося вирішити конфлікт дублікатів завантажень: {{detail}}',
addedWithFailures: '{{added}} додано, {{failed}} не вдалося. {{detail}}',
addedOne: 'Додано 1 завантаження',
addedMany: 'Додано {{count}} завантажень',
updatedOne: 'Оновлено 1 завантаження',
updatedMany: 'Оновлено {{count}} завантажень',
urlAlreadyQueued: 'URL-адреса вже в черзі',
destinationConflict: 'Інше вибране завантаження використовує це місце призначення',
existingDownloadDestination: 'Існуюче завантаження Firelink використовує це місце призначення',
matchingDownloadFilename: 'Незавершене завантаження вже має це ім’я файлу',
fileExistsOnDisk: 'Файл існує на диску; перейменуйте або пропустіть, щоб уникнути видалення незв\'язаних даних',
backendRejectedStart: 'Бекенд відхилив початок завантаження.',
playlistNoEntries: 'Плейлист не містить елементів для завантаження',
invalidMediaMetadata: 'Недійсні метадані медіа або не знайдено форматів',
noAvailableName: 'Не вдалося знайти доступне ім\'я для {{file}}.',
pauseBeforeReplace: 'Призупиніть {{file}} перед заміною.',
cannotReplace: 'Неможливо замінити {{file}}: файл не належить до завантажень Firelink.',
downloadLinks: 'Посилання для завантаження',
pastePlaceholder: 'Вставте URL-адреси HTTP, HTTPS, FTP або SFTP сюди…\n\nДля медіазавантажень вставте посилання з YouTube, X, TikTok, Instagram, Reddit тощо.',
playlistSummary: 'Плейлист “{{title}}”: {{loaded}} з {{total}} елементів завантажено{{truncated}}{{skipped}}',
safeEntryLimit: ' (досягнуто безпечного ліміту елементів)',
selectedSummary: '{{ready}} вибрано готових, {{fallback}} резервних, {{mediaRetry}} повторних медіа, {{blocked}} заблоковано',
clearSelection: 'Очистити вибір',
selectAll: 'Вибрати всі',
refreshMetadata: 'Оновити метадані',
files: 'Файли',
required: 'Обов\'язково',
free: 'Вільно',
preview: 'Попередній перегляд',
file: 'Файл',
size: 'Розмір',
status: 'Статус',
downloadPreview: 'Попередній перегляд завантаження',
noLinks: 'Поки що немає доданих посилань.',
selectItem: 'Вибрати {{file}}',
fetchingPlaylist: 'Отримання плейлиста…',
fetching: 'Отримання…',
unsafeUrl: 'Небезпечна URL-адреса',
playlistFailed: 'Помилка плейлиста',
metadataFailed: 'Помилка метаданих',
fallback: 'Резервний варіант',
invalid: 'Недійсно',
ready: 'Готово',
mediaFormat: 'Формат медіа',
playlistItem: 'Елемент плейлиста {{index}}',
fetchingMediaStreams: 'Отримання медіапотоків…',
availableStreams: 'Доступні потоки',
availableMediaStreams: 'Доступні медіапотоки',
metadataUnavailable: 'Метадані недоступні. Оновіть метадані перед додаванням цього медіа.',
saveLocation: 'Місце збереження',
browse: 'Огляд…',
categoryFolders: 'Файли будуть організовані в папки категорій.',
sharedFolder: 'Усі вибрані завантаження використовуватимуть цю папку.',
dedicatedFolder: 'Зберегти в новій папці',
dedicatedFolderName: 'Назва папки',
dedicatedFolderDescription: 'Цю папку буде створено всередині кожної автоматичної папки категорії.',
dedicatedFolderManualDescription: 'Цю папку буде створено всередині вибраного місця збереження.',
saveFolderName: 'Зберегти назву папки',
dedicatedFolderNameRequired: 'Введіть назву папки перед збереженням.',
transferSettings: 'Налаштування передачі',
connectionsPerFile: 'З\'єднань на файл',
connectionsPerFileAria: 'З\'єднань на файл',
limitSpeedPerFile: 'Обмеження швидкості на файл',
speedLimitPerFileAria: 'Обмеження швидкості на файл',
authorization: 'Авторизація',
useAuthorization: 'Використовувати авторизацію',
username: 'Ім\'я користувача',
password: 'Пароль',
advancedTransfer: 'Розширені параметри передачі',
verifyChecksum: 'Перевірити контрольну суму',
checksumAlgorithm: 'Алгоритм контрольної суми',
expectedDigest: 'Очікуваний хеш',
headers: 'Заголовки',
requestHeaders: 'Заголовки запиту',
cookies: 'Файли cookie',
cookiePlaceholder: 'name=value; other=value',
mirrors: 'Дзеркала',
cancel: 'Скасувати',
startDownloads: 'Почати завантаження',
addToQueue: 'Додати до черги',
pasteOneOrMore: 'Вставте одне або кілька посилань.',
selectAtLeastOne: 'Виберіть хоча б одне завантаження.',
correctInvalidOne: 'Виправте або видаліть {{count}} недійсну URL-адресу перед продовженням.',
correctInvalidFew: 'Виправте або видаліть {{count}} недійсні URL-адреси перед продовженням.',
correctInvalidMany: 'Виправте або видаліть {{count}} недійсних URL-адрес перед продовженням.',
waitingForMetadataOne: 'Очікування метаданих для {{count}} завантаження…',
waitingForMetadataFew: 'Очікування метаданих для {{count}} завантажень…',
waitingForMetadataMany: 'Очікування метаданих для {{count}} завантажень…',
removeUnsafeOne: 'Видаліть {{count}} небезпечну URL-адресу перед продовженням.',
removeUnsafeFew: 'Видаліть {{count}} небезпечні URL-адреси перед продовженням.',
removeUnsafeMany: 'Видаліть {{count}} небезпечних URL-адрес перед продовженням.',
mediaMetadataUnavailableSummaryOne: 'Метадані медіа недоступні для {{count}} елемента. Оновіть метадані перед додаванням.',
mediaMetadataUnavailableSummaryFew: 'Метадані медіа недоступні для {{count}} елементів. Оновіть метадані перед додаванням.',
mediaMetadataUnavailableSummaryMany: 'Метадані медіа недоступні для {{count}} елементів. Оновіть метадані перед додаванням.',
metadataUnavailableFallback: 'Метадані недоступні. Завантаження все ще можна додати, використовуючи резервні дані.',
fallbackReadyOne: '{{ready}} завантаження готове; для {{failed}} буде використано резервне ім\'я файлу та невідомий розмір.',
fallbackReadyFew: '{{ready}} завантаження готові; для {{failed}} буде використано резервне ім\'я файлу та невідомий розмір.',
fallbackReadyMany: '{{ready}} завантажень готово; для {{failed}} буде використано резервне ім\'я файлу та невідомий розмір.',
readyToAddOne: 'Готово до додавання: {{count}} завантаження.',
readyToAddFew: 'Готово до додавання: {{count}} завантаження.',
readyToAddMany: 'Готово до додавання {{count}} завантажень.',
},
settings: {
tabs: {
downloads: 'Завантаження',
lookAndFeel: 'Вигляд та інтерфейс',
network: 'Мережа',
locations: 'Розташування',
siteLogins: 'Вхід на сайти',
power: 'Живлення',
engine: 'Рушій',
integrations: 'Інтеграції',
about: 'Про програму',
},
common: {
thisOs: 'ця ОС',
checking: 'Перевірка…',
ready: 'Готово',
errorMissing: 'Помилка / Відсутнє',
hideTechnicalDetails: 'Сховати технічні деталі',
showTechnicalDetails: 'Показати технічні деталі',
binary: 'Виконуваний файл',
expected: 'Очікується',
error: 'Помилка',
tip: 'Підказка',
stderr: 'stderr',
daemonProcessAlive: 'Процес демона працює',
rpcReady: 'RPC готовий',
rpcPort: 'Порт RPC',
daemonStderr: 'stderr демона',
packaging: 'Пакування: PyInstaller onedir (потрібен _internal)',
internalDirectoryFound: 'Директорію _internal знайдено',
pythonRuntimeFound: 'Середовище виконання Python знайдено',
externalOpenFailed: 'Не вдалося відкрити {{label}}: {{detail}}',
updateUnexpectedResponse: 'Перевірка оновлень повернула неочікувану відповідь.',
updateUntrustedReleaseUrl: 'Перевірка оновлень повернула ненадійну URL-адресу релізу.',
updateFailed: 'Не вдалося перевірити наявність оновлень: {{detail}}',
},
downloads: {
defaultConnections: 'З\'єднання за замовчуванням:',
defaultConnectionsDescription: 'Нові завантаження; існуючі елементи зберігають своє збережене значення',
parallelDownloads: 'Паралельні завантаження:',
parallelDownloadsDescription: 'Макс. одночасних активних файлів',
automaticRetries: 'Автоматичні повторні спроби:',
automaticRetriesDescription: 'Якщо з\'єднання перерветься',
systemNotification: 'Показувати системне сповіщення по завершенні завантаження',
systemNotificationDescription: 'Використовує налаштування сповіщень вашої операційної системи',
completionChime: 'Відтворювати звуковий сигнал по завершенні в програмі',
completionChimeDescription: 'Додатковий звук Firelink, незалежно від системних сповіщень',
clipboardLinks: 'Додавати посилання з буфера обміну, коли Firelink стає активним',
clipboardLinksDescription: 'Відкриває підтримувані скопійовані посилання у вікні "Додати". Вимкнено за замовчуванням.',
},
lookAndFeel: {
language: 'Мова',
languageDescription: 'Виберіть мову інтерфейсу Firelink.',
sidebarPosition: 'Положення бічної панелі',
sidebarPositionDescription: 'Виберіть, де відображати бічну панель. Автоматичний режим враховує напрямок інтерфейсу.',
sidebarPositionAutomatic: 'Автоматично (рекомендовано)',
sidebarPositionLeft: 'Ліворуч',
sidebarPositionRight: 'Праворуч',
languageSystem: 'Мова системи',
languageEnglish: 'Англійська',
languageChinese: 'Спрощена китайська',
languageHebrew: 'Іврит',
languagePersian: 'Перська',
languageUkrainian: 'Українська',
languageRussian: 'Російська',
appTheme: 'Тема програми',
theme: 'Тема',
ariaLabel: 'Тема програми',
system: 'Системна',
light: 'Світла',
dark: 'Темна',
dracula: 'Dracula',
nord: 'Nord',
systemAppearance: 'Система слідкує за поточним світлим або темним виглядом {{platform}}.',
display: 'Відображення',
fontSize: 'Розмір шрифту',
fontSizeDescription: 'Масштабує список завантажень та компактні елементи керування.',
small: 'Дрібний',
standard: 'Стандартний',
large: 'Великий',
listDensity: 'Щільність списку',
listDensityDescription: 'Змінює висоту рядка, інтервали та масштаб індикатора прогресу.',
compact: 'Компактний',
comfortable: 'Комфортний',
relaxed: 'Вільний',
osIntegration: 'Інтеграція з ОС',
dockBadge: 'Показувати значок на іконці Dock',
dockBadgeDescription: 'Відображає кількість активних завантажень на іконці Firelink у Dock.',
menuBarIcon: 'Показувати іконку в рядку меню',
statusIndicatorIcon: 'Показувати іконку індикатора стану',
systemTrayIcon: 'Показувати іконку в системному треї',
macosTrayDescription: 'Надає швидкий доступ з рядка меню macOS.',
windowsTrayDescription: 'Надає швидкий доступ з області сповіщень Windows.',
linuxTrayDescription: 'Надає швидкий доступ із системного трею або області стану, якщо доступно.',
defaultTrayDescription: 'Надає швидкий доступ із системного трею ОС, якщо доступно.',
},
network: {
proxy: 'Проксі',
mode: 'Режим',
modeDescription: 'Керує використанням проксі для нових запитів на завантаження.',
noProxy: 'Без проксі',
systemProxy: 'Системний проксі',
customProxy: 'Власний проксі',
proxyHost: 'Хост проксі',
proxyHostDescription: 'Ім\'я хоста, IP-адреса або URL-адреса HTTP-проксі.',
proxyHostPlaceholder: '127.0.0.1 або http://127.0.0.1',
proxyPort: 'Порт проксі',
proxyPortDescription: 'Дійсний діапазон від 1 до 65535.',
noProxyDescription: 'Завантаження ігнорують налаштовані проксі-сервери.',
systemProxyDescription: 'Завантаження використовують виявлений системний проксі {{platform}}. Звичайні завантаження файлів вимагають HTTP або HTTPS проксі; медіазавантаження можуть використовувати SOCKS.',
customProxyDescription: 'Завантаження використовують налаштовану кінцеву точку HTTP-проксі для метаданих і рушіїв завантаження.',
invalidCustomProxy: 'Введіть дійсний хост і порт HTTP-проксі, щоб увімкнути власний проксі.',
incompleteCustomProxy: 'Введіть хост і порт проксі, щоб увімкнути власний проксі.',
checkingSystemProxy: 'Перевірка конфігурації системного проксі…',
detectedSystemProxy: 'Виявлено системний проксі. Звичайні завантаження файлів вимагають HTTP або HTTPS проксі; медіазавантаження можуть використовувати SOCKS.',
noSystemProxy: 'Не виявлено придатного системного проксі. Завантаження використовуватимуть пряме підключення (без проксі).',
systemProxyReadFailed: 'Не вдалося прочитати конфігурацію системного проксі. Виберіть "Без проксі" або спробуйте ще раз, коли він стане доступним.',
identity: 'Ідентифікація',
customUserAgent: 'Власний User-Agent',
userAgentDescription: 'Застосовується до запитів метаданих та рушіїв завантаження.',
userAgentPlaceholder: 'Залиште порожнім для Firelink за замовчуванням',
userAgentOverrides: 'Перевизначає вихідний заголовок User-Agent. Залиште порожнім для типових значень Firelink.',
chromeWindows: 'Chrome (Windows)',
chromeMacos: 'Chrome (macOS)',
edgeWindows: 'Edge (Windows)',
firefoxWindows: 'Firefox (Windows)',
firefoxMacos: 'Firefox (macOS)',
safariMacos: 'Safari (macOS)',
windowsDesktop: 'Робочий стіл Windows',
macosDesktop: 'Робочий стіл macOS',
},
locations: {
baseDownloadFolder: 'Базова папка завантажень',
baseDownloadFolderDescription: 'Автоматичні папки категорій створюються всередині цієї папки.',
browse: 'Огляд…',
baseFolderCreateFailed: 'Базова папка збережена, але папки категорій не вдалося створити: {{detail}}',
baseFolderUpdated: 'Базову папку завантажень оновлено',
askWhereToSave: 'Запитувати, де зберігати під час додавання завантажень',
rememberLastUsedDownloadDirectory: 'Запам\'ятати останню використану папку завантажень',
rememberLastUsedDownloadDirectoryDescription: 'Використовувати останню папку, вибрану у вікні "Додати", для наступного завантаження до перезапуску програми.',
automaticCategorySubfolders: 'Автоматично зберігати файли у вкладених папках категорій',
automaticCategorySubfoldersDescription: 'Якщо вимкнено, автоматичні завантаження використовують базову папку.',
categorySubfolders: 'Вкладені папки категорій',
relativeToBase: 'Відносно базової папки',
disabled: 'Вимкнено',
categorySubfolder: 'Вкладена папка {{category}}',
customFolder: 'Власна папка',
useAutomatic: 'Використовувати автоматичну',
resetDefaults: 'Скинути за замовчуванням',
resetToast: 'Розташування категорій скинуто до стандартних',
},
siteLogins: {
title: 'Облікові дані сайтів',
noSavedLogins: 'Немає збережених логінів.',
user: 'Користувач: {{username}}',
deleteCredential: 'Видалити облікові дані',
deletedCredential: 'Видалено облікові дані',
deleteFailed: 'Не вдалося видалити облікові дані: {{detail}}',
addTitle: 'Додати облікові дані сайту',
urlPattern: 'Шаблон URL:',
urlPatternPlaceholder: 'наприклад, *.example.com або example.com/downloads',
username: 'Ім\'я користувача:',
password: 'Пароль:',
usernamePlaceholder: 'Ім\'я користувача',
passwordPlaceholder: 'Пароль',
saving: 'Збереження…',
addLogin: 'Додати вхід',
urlPatternRequired: 'Необхідно вказати шаблон URL.',
urlPatternWhitespace: 'Шаблон URL не може містити пробілів.',
usernameRequired: 'Необхідно вказати ім\'я користувача.',
passwordRequired: 'Необхідно вказати пароль.',
accessBeforeSave: 'Надайте доступ до сховища облікових даних перед збереженням логіна сайту.',
saveFailed: 'Не вдалося безпечно зберегти облікові дані сайту.',
addedCredential: 'Додано облікові дані сайту',
},
power: {
title: 'Керування живленням',
preventSleep: 'Запобігати переходу системи в сплячий режим під час активних завантажень',
preventSleepDescription: 'Дисплей може все одно вимкнутися. Firelink лише не дає пристрою заснути, щоб завершити активні передачі.',
},
engine: {
title: 'Завантажувач медіа та рушії',
description: 'Успішні результати повторно використовуються для цього сеансу програми. Повторна перевірка запускає справжню валідацію знову.',
recheck: 'Перевірити рушії знову',
coreDownloader: 'Ядро завантаження (aria2)',
version: 'Версія:',
status: 'Статус:',
mediaExtractors: 'Екстрактори медіа',
browserCookiesSource: 'Джерело Cookies з браузера:',
cookieDescription: 'yt-dlp читає файли cookie з браузера, щоб обійти обмеження на завантаження відео або отримати доступ до медіа з обмеженим доступом. Firelink не зберігає файли cookie з браузера.',
none: 'Немає',
},
integrations: {
title: 'Підключення розширення браузера',
description: 'Захоплюйте завантаження безпосередньо з вашого браузера у три простих кроки.',
portablePairingEnabled: 'Портативне підключення увімкнено',
pairingTokenPersisted: 'Токен підключення збережено',
portablePersistedDescription: 'Ваш токен підключення збережено разом із цією портативною папкою Firelink, і він залишатиметься дійсним при переміщенні папки. Ставтеся до цієї папки як до конфіденційної.',
systemPersistedDescription: 'Ваш токен підключення зберігається в системному сховищі облікових даних і зберігатиметься після перезапусків.',
portablePairingAvailable: 'Доступно портативне підключення',
credentialStorageNeeded: 'Потрібне сховище облікових даних',
portableAvailableDescription: 'Ваш токен підключення збережено разом із цією портативною папкою Firelink, і він зберігатиметься після перезапусків. Увімкніть його тут, щоб переглянути попередження про портативне сховище.',
credentialStorageDescription: 'Firelink потребує доступу до сховища облікових даних цієї системи, щоб безпечно зберегти ваш токен підключення після перезапусків програми. Наразі ваше розширення залишатиметься підключеним лише для цього сеансу.',
reviewPortablePairing: 'Переглянути портативне підключення',
grantCredentialAccess: 'Надати доступ до облікових даних',
copyToken: 'Скопіювати токен',
tokenDescription: 'Цей безпечний токен авторизує ваше розширення браузера.',
tokenCopied: 'Токен скопійовано в буфер обміну!',
tokenCopyFailed: 'Не вдалося скопіювати токен: {{detail}}',
pairingTokenRegenerated: 'Токен підключення згенеровано наново',
regenerateFailed: 'Не вдалося згенерувати токен підключення наново: {{detail}}',
getExtension: 'Отримати розширення',
extensionDescription: 'Встановіть Firelink Companion для браузерів Firefox або Chromium.',
firefoxAddons: 'Додатки Firefox',
chromiumZip: 'Chromium ZIP',
pasteConnect: 'Вставити та підключити',
pasteConnectDescription: 'Натисніть на іконку Firelink на панелі інструментів вашого браузера та вставте скопійований токен.',
extensionServerStatus: 'Статус сервера розширень:',
listening: '● Прослуховування на 127.0.0.1:{{port}}',
unavailable: '● Сервер недоступний',
},
about: {
firelinkIcon: 'Іконка Firelink',
unknown: 'Невідомо',
version: 'Версія {{version}}',
description: 'Кросплатформний менеджер завантажень для macOS, Windows та Linux.',
sourceCode: 'Вихідний код',
source: 'Джерело',
upToDate: 'У вас остання версія Firelink {{version}}.',
updateAvailable: 'Доступна нова версія Firelink {{version}}.',
viewRelease: 'Переглянути реліз',
updates: 'Оновлення',
checkForUpdates: 'Перевірити наявність оновлень',
updateDescription: 'Firelink перевіряє GitHub Releases на наявність нових версій.',
checking: 'Перевірка…',
checkNow: 'Перевірити зараз',
automaticUpdates: 'Автоматично перевіряти наявність оновлень',
createdBy: 'Створено NimBold',
mitLicense: 'Ліцензія MIT',
credits: 'Подяки: aria2, yt-dlp, FFmpeg та Deno.',
copyright: 'Авторське право © 2026 NimBold. Всі права захищені.',
},
},
status: {
ready: 'Готово',
active: '{{count}} активних',
queued: '{{count}} в черзі',
done: '{{count}} завершено',
},
} as const;
export default uk;
+755
View File
@@ -0,0 +1,755 @@
const zhCN = {
navigation: {
library: '库',
filters: {
all: '全部',
active: '进行中',
completed: '已完成',
unfinished: '未完成',
},
categories: {
musics: '音乐',
movies: '视频',
compressed: '压缩包',
documents: '文档',
pictures: '图片',
applications: '应用程序',
other: '其他',
},
folders: '文件夹',
queues: '队列',
tools: '工具',
scheduler: '计划任务',
speedLimiter: '速度限制',
logs: '日志',
settings: '设置',
},
actions: {
hideSidebar: '隐藏侧边栏',
showSidebar: '显示侧边栏',
resizeSidebar: '调整侧边栏大小',
cancel: '取消',
continue: '继续',
dismissNotification: '关闭通知',
queueName: '队列名称',
addNewQueue: '新建队列',
startQueue: '启动队列',
pauseQueue: '暂停队列',
renameQueue: '重命名队列',
deleteQueue: '删除队列',
},
dialogs: {
errorBoundary: {
title: '出现了一些问题。',
description: 'React 组件树中发生了严重错误。下面的错误详情可以帮助确定根本原因。',
reload: '重新加载应用',
},
duplicateDownloads: {
title: '检测到重复下载',
description: '您要添加的一些下载已存在于队列或磁盘上。请选择如何解决这些冲突。',
rename: '重命名',
replace: '替换',
skip: '跳过',
},
removeDownload: {
title: '移除下载',
confirmationSingle: '您确定要从列表中移除此项目吗?您也可以选择同时从磁盘中删除底层文件。',
confirmationMultiple: '您确定要从列表中移除这 {{count}} 个项目吗?您也可以选择同时从磁盘中删除底层文件。',
errorSummary: '成功移除 {{succeeded}} 个,失败 {{failed}} 个:{{detail}}',
remove: '移除',
deleteFile: '删除文件',
},
},
window: {
controls: '窗口控制',
close: '关闭',
minimize: '最小化',
maximize: '最大化',
},
downloads: {
actions: {
moveUp: '上移',
moveDown: '下移',
pause: '暂停',
start: '启动',
resume: '恢复',
options: '选项',
},
size: {
downloadedOf: '已下载 {{downloaded}} / 共 {{total}} {{unit}}',
downloadedOfApproximate: '已下载 {{downloaded}} / 预估共 {{total}} {{unit}}',
},
status: {
ready: '就绪',
staged: '在队列中',
queued: '已排队',
downloading: '下载中',
processing: '处理中',
paused: '已暂停',
completed: '已完成',
failed: '失败',
retrying: '重试中',
},
values: {
processing: '处理中…',
muxing: '混流中…',
},
},
speedLimiter: {
title: '速度限制',
unlimited: '不限速',
saveLimit: '保存限制',
globalSpeedLimit: '全局速度限制',
description: '适用于新的和活动中的 aria2 传输以及新的 yt-dlp 媒体下载。单个下载的专属限制仍将保留。',
quickPresets: '快捷预设',
removePreset: '移除 {{value}} {{unit}} 预设',
customPresetIn: '自定义预设 (单位:{{unit}})',
addQuickPreset: '添加快捷预设',
globalLimitSaved: '全局限制已保存为 {{value}} {{unit}}',
globalLimitDisabled: '全局速度限制已禁用',
saveFailed: '无法保存全局速度限制:{{detail}}',
presetAlreadyExists: '{{value}} {{unit}} 已在快捷预设中',
presetAdded: '已添加 {{value}} {{unit}} 快捷预设',
presetRemoved: '已移除 {{value}} {{unit}} 快捷预设',
},
logs: {
title: '日志',
entries: '({{count}} 条)',
collecting: '收集中',
off: '关闭',
allLevels: '所有级别',
levels: {
error: '错误',
warn: '警告',
info: '信息',
debug: '调试',
trace: '追踪',
},
pauseLogging: '暂停诊断日志记录',
enableLogging: '启用诊断日志记录',
clearDisplayed: '清除显示的日志',
export: '导出日志',
exportButton: '导出日志',
logFiles: '日志文件',
localDiagnostics: '本地诊断:',
diagnosticsDescription: '诊断信息的收集需要主动开启,范围有限,并且仅在本地进行。常见的密钥、URL 查询参数和主目录路径在显示和导出时都会被隐藏。不会自动上传任何数据。',
noEntries: '尚无持久化的日志条目。',
disabledDescription: '诊断日志记录已关闭。现有的支持日志将在可用时显示在此处。',
copy: '复制',
copied: '已复制到剪贴板',
copyFailed: '复制到剪贴板失败',
exported: '支持日志已导出',
exportFailed: '无法导出日志:{{detail}}',
cleared: '日志已清除',
clearFailed: '无法清除日志:{{detail}}',
enabled: '已启用诊断日志记录',
disabled: '已禁用诊断日志记录',
updateFailed: '无法更新诊断日志记录:{{detail}}',
},
scheduler: {
title: '计划任务',
running: '运行中',
disabled: '计划任务已禁用',
noScheduledDay: '未选择计划日期',
unsavedChanges: '未保存的更改',
runNow: '立即运行',
pause: '暂停',
saveSettings: '保存设置',
timing: '时间',
startTime: '开始时间',
stopTime: '停止时间',
timingDescription: '如果在开始时间 Firelink 处于休眠状态,它会在当天晚些时候恢复运行时启动所选队列,除非停止时间已过。',
runEveryDay: '每天运行',
queuesToSchedule: '要计划运行的队列',
defaultQueue: '默认队列',
afterCompletion: '完成后',
afterCompletionDescription: '选择计划任务启动的下载完成后的操作。',
actionWarning: '此操作可能会中断计算机上的其他工作。Firelink 会在计划的队列完成后立即执行它。',
systemPermissions: '系统权限',
macPermissionDescription: '睡眠、重启和关机需要 macOS 系统事件的自动化权限。',
permissionGranted: '已授予自动化权限',
permissionChecking: '正在检查自动化权限…',
permissionMissing: '缺少自动化权限',
revokePermission: '撤销权限',
grantPermission: '授予权限',
permissionAvailable: '自动化权限可用。',
permissionMissingDetails: '缺少自动化权限。请在“系统设置”的“系统事件”的“自动化”下启用 Firelink。',
revokePermissionDetails: 'macOS 不允许 Firelink 直接撤销自动化权限。请在“系统设置”中撤销“系统事件”访问权限,然后返回 Firelink。',
requestingPermission: '正在请求自动化权限…',
enablePermissionDetails: '请在“系统设置”的“系统事件”的“自动化”下启用 Firelink,然后返回 Firelink。',
systemActions: '系统操作',
windowsActionsDescription: '睡眠、重启和关机使用 Windows 系统权限。Firelink 在运行时会报告任何被拒绝的操作;不会提前声明任何永久权限。',
linuxActionsDescription: '睡眠、重启和关机使用您的 Linux 桌面和系统策略。Firelink 在运行时会报告任何被拒绝的操作;不会提前声明任何永久权限。',
validationDay: '至少为计划任务选择一天',
validationQueue: '至少为计划任务选择一个队列',
validationStopTime: '停止时间必须晚于开始时间',
saved: '计划任务设置已保存',
trackingOne: '正在跟踪 1 个计划的下载',
trackingMany: '正在跟踪 {{count}} 个计划的下载',
noStartableDownloads: '所选队列中没有可开始的下载',
pausedOne: '已暂停 1 个活动的下载',
pausedMany: '已暂停 {{count}} 个活动的下载',
noActiveDownloads: '没有活动的下载',
days: {
su: '日',
mo: '一',
tu: '二',
we: '三',
th: '四',
fr: '五',
sa: '六',
},
postActions: {
none: '不执行任何操作',
sleep: '睡眠',
restart: '重启',
shutdown: '关机',
},
},
properties: {
progress: '进度',
size: '大小',
speed: '速度',
eta: '剩余时间',
connections: '连接数',
connectionCount: '{{active}}/{{total}} 个连接',
connectionCountUnknown: '—/{{total}} 个连接',
connectionsUnavailable: '—',
speedCap: '速度上限',
category: '类别',
lastTry: '上次尝试',
dateAdded: '添加日期',
destination: '保存目录',
lastError: '上次错误',
saved: ' (已保存)',
defaultValue: ' (默认)',
savedTooltip: '已为此下载保存;设置中的更改将应用于新的下载。',
defaultTooltip: '对新的下载使用当前默认值。',
identityReadOnly: '文件标识为只读。传输设置会保存以备重新下载使用。',
transferSettings: '停止或暂停后可以更改传输设置。当前的传输会保留其现有的后端选项。',
download: '下载',
url: 'URL',
fileName: '文件名',
saveLocation: '保存位置',
select: '选择',
perFile: '每个文件',
useCurrentDefault: '使用当前默认值 ({{count}})',
savedPerDownload: '按下载保存。设置中的默认值将应用于新下载。',
limit: '限制',
siteLogin: '站点登录',
siteLoginRedownload: '重新下载的站点登录',
matchingSiteLogin: '匹配的站点登录',
customCredentials: '自定义凭据',
noLogin: '无登录',
useSavedLogin: '如果可用,将使用保存的登录信息。',
advancedTransfer: '高级传输',
advancedTransferRedownload: '重新下载的高级传输',
checksum: '校验和',
verify: '验证',
algorithm: '算法',
digest: '哈希值',
expectedDigest: '预期哈希值',
cookies: 'Cookie',
headers: '请求头',
mirrors: '镜像源',
username: '用户名',
password: '密码',
enterValidUrl: '请输入有效的 URL。',
fileNameEmpty: '文件名不能为空。',
cancel: '取消',
save: '保存',
},
keychain: {
title: '需要凭据存储访问权限',
stores: {
portable: '便携式 Firelink 数据文件夹',
windows: 'Windows 凭据管理器',
linux: '您的 Linux 凭据存储',
macos: 'macOS 钥匙串',
system: '此系统的凭据存储',
siteCredentials: '系统的凭据存储',
},
grantLabelPortable: '继续',
grantLabelMacos: '授予访问权限',
grantLabelDefault: '启用安全存储',
description: 'Firelink 使用浏览器扩展来捕获下载。要在重启后保持扩展配对,Firelink 会将其配对令牌存储在{{pairingStore}}中。可选的站点凭据存储在{{siteCredentialStore}}中。',
portableExplanation: '配对令牌可与此文件夹一起移植。站点凭据保留在系统凭据存储中;授予访问权限后可能会出现系统提示。',
macosExplanation: '授予访问权限后,macOS 可能会显示钥匙串提示。',
defaultExplanation: '这通常会在后台静默完成。如果凭据服务不可用,Firelink 将在此处显示错误,并且扩展仅在本次会话期间保持配对状态。',
note: '注意:',
portableNote: '便携模式仅在此文件夹中存储配对令牌。它不会复制站点密码或浏览器凭据。',
defaultNote: 'Firelink 仅写入其自己的专用凭据条目。它无法访问您系统上保存的其他密码或凭据项。',
hint: '提示:',
portableHint: '便携配对令牌已与此文件夹一起存储;您可以在此处启用它,也可以选择“稍后”。',
defaultHint: '如果您选择“稍后”,扩展仅在本次会话中工作。',
enableFromSettings: '您可以随时从“设置 > 集成”中启用存储。',
later: '稍后',
enabling: '正在启用…',
timeout: '凭据存储请求超时。您可以选择“稍后”并重试。',
unavailable: '{{store}}不可用。',
accessRequired: '在重新生成配对令牌之前,请授予凭据存储访问权限。',
storeUnavailable: '凭据存储访问不可用。',
},
app: {
loading: '加载中…',
settingsSaveFailed: '无法保存设置。请检查存储权限并重试。',
systemActionCountdown: '10 秒后{{action}}。',
systemActionCancelled: '系统操作已取消,因为有其他下载正在进行或已排队。',
systemActionFailed: '计划的系统操作失败:{{detail}}',
downloadCompleteTitle: '下载完成',
downloadCompleteBody: '{{fileName}} 已下载完成。',
unknownFile: '未知文件',
downloadFailedTitle: '下载失败',
downloadFailedBody: '{{fileName}} 下载失败。',
initializeDownloadsFailed: '无法初始化已保存的下载:{{detail}}',
extensionDisconnected: '浏览器扩展已断开连接,因为其配对令牌已更改。',
copyToken: '复制令牌',
integrations: '集成',
copyTokenFailed: '无法复制配对令牌:{{detail}}',
credentialPersistenceFailed: '安全凭据持久化不可用。浏览器配对仅适用于本次会话:{{detail}}',
resumeDownloadsFailed: '无法恢复已保存的下载:{{detail}}',
updateAvailable: 'Firelink {{version}} 可用。',
viewRelease: '查看发布版本',
sleepPreventionFailed: '无法更新睡眠阻止状态:{{detail}}',
schedulerNoQueues: '计划任务未选择有效的队列。请更新计划任务设置。',
schedulerPauseOneFailed: '计划任务无法暂停 1 个下载。',
schedulerPauseManyFailed: '计划任务无法暂停 {{count}} 个下载。',
scheduledIncomplete: '并非所有计划的下载都已完成。已跳过队列后系统操作。',
scheduledActionSkippedActive: '已跳过计划的系统操作,因为有其他下载正在进行或已排队。',
notificationsDisabled: '系统的 Firelink 通知已被禁用。',
notificationsFailed: '无法配置通知:{{detail}}',
},
sidebar: {
deleteQueueConfirm: '删除队列“{{name}}”?其未完成的下载将被移至主队列。',
deleteQueueFailed: '无法删除队列:{{detail}}',
queueNameEmpty: '队列名称不能为空',
queueNameExists: '同名队列已存在',
startQueueFailed: '无法启动队列:{{detail}}',
pauseQueueFailed: '无法暂停队列:{{detail}}',
},
downloadTable: {
unknownQueue: '未知队列',
allDownloads: '所有下载',
active: '进行中',
completed: '已完成',
unfinished: '未完成',
headers: {
fileName: '文件名',
size: '大小',
status: '状态',
speed: '速度',
eta: '剩余时间',
dateAdded: '添加日期',
},
columnOptions: {
open: '{{column}}列选项',
dragToReorder: '拖动以重新排列列',
alignContent: '内容位置',
left: '左',
center: '居中',
right: '右',
resetLayout: '重置列布局',
},
addDownload: '添加下载',
resumeAll: '全部恢复',
pauseAll: '全部暂停',
queueEmpty: '队列为空',
noCompletedDownloads: '没有已完成的下载',
noDownloads: '没有下载',
queueEmptyDescription: '通过项目菜单或“添加”窗口将下载添加到此队列。',
completedDescription: '已完成的下载将显示在此处。',
clickToAdd: '点击',
addButtonOr: '按钮或',
toAddDownloads: '来添加下载',
nonResumableOne: '1 个下载不支持断点续传。如果您暂停它,稍后您将不得不重新开始。确定要暂停吗?',
nonResumableMany: '{{count}} 个下载不支持断点续传。如果您暂停它们,稍后您将不得不重新开始。确定要暂停吗?',
interactionError: '{{message}}{{detail}}',
openFileFailed: '无法打开下载的文件',
revealFileFailed: '无法在文件管理器中显示下载',
pauseFailed: '无法暂停下载',
resumeFailed: '无法恢复 {{fileName}}',
moveManyFailed: '无法将下载移动到队列',
moveOneFailed: '无法将下载移动到队列',
copyAddressesFailed: '无法复制地址',
copyAddressFailed: '无法复制地址',
copyPathFailed: '无法复制文件路径',
missingFileName: '缺少文件名',
redownloadFailed: '重新下载失败',
startResume: '启动/恢复',
addToQueue: '添加到队列',
copyAddress: '复制地址',
remove: '移除',
open: '打开',
showInFolder: '在文件夹中显示',
pause: '暂停',
start: '启动',
resume: '恢复',
redownload: '重新下载',
copyFilePath: '复制文件路径',
properties: '属性',
backendRejectedStart: '后端拒绝了启动/恢复请求。',
transferActive: '传输处于活动状态时无法更改属性。请先暂停它。',
redownloadNotFound: '无法重新下载:未找到下载。',
redownloadActive: '无法重新下载{{status}}的下载。请先暂停或等待其完成。',
originalUrlMissing: '无法重新下载:缺少原始 URL。',
pauseBeforeMove: '请在将 {{file}} 移动到另一个队列前暂停它。',
},
addDownloads: {
unknownSize: '未知大小',
unknown: '未知',
discardSetup: '放弃此下载设置吗?',
speedInvalid: '速度限制必须大于零',
duplicateResolveFailed: '无法解决重复的下载:{{detail}}',
addedWithFailures: '添加了 {{added}} 个,失败了 {{failed}} 个。{{detail}}',
addedOne: '添加了 1 个下载',
addedMany: '添加了 {{count}} 个下载',
updatedOne: '已更新 1 个下载',
updatedMany: '已更新 {{count}} 个下载',
urlAlreadyQueued: 'URL 已在队列中',
destinationConflict: '其他选定的下载已使用此目标路径',
existingDownloadDestination: '现有的 Firelink 下载已使用此目标路径',
matchingDownloadFilename: '已有一个未完成的下载使用此文件名',
fileExistsOnDisk: '磁盘上已存在该文件;请重命名或跳过以避免覆盖无关数据',
backendRejectedStart: '后端拒绝开始下载。',
playlistNoEntries: '播放列表不包含可下载的条目',
invalidMediaMetadata: '媒体元数据无效或未找到格式',
noAvailableName: '无法为 {{file}} 找到可用的名称。',
pauseBeforeReplace: '请在替换 {{file}} 前暂停它。',
cannotReplace: '无法替换 {{file}}:文件不属于 Firelink 下载。',
downloadLinks: '下载链接',
pastePlaceholder: '在此粘贴 HTTP、HTTPS、FTP 或 SFTP URL…\n\n对于媒体下载,请粘贴来自 YouTube、X、TikTok、Instagram、Reddit 等的链接。',
playlistSummary: '播放列表“{{title}}”:已加载 {{loaded}} / {{total}} 个条目{{truncated}}{{skipped}}',
safeEntryLimit: ' (达到安全条目限制)',
selectedSummary: '准备就绪 {{ready}} 个,后备项 {{fallback}} 个,媒体重试 {{mediaRetry}} 个,已屏蔽 {{blocked}} 个',
clearSelection: '清除选择',
selectAll: '全选',
refreshMetadata: '刷新元数据',
files: '文件',
required: '必需',
free: '可用空间',
preview: '预览',
file: '文件',
size: '大小',
status: '状态',
downloadPreview: '下载预览',
noLinks: '尚未添加任何链接。',
selectItem: '选择 {{file}}',
fetchingPlaylist: '正在获取播放列表…',
fetching: '正在获取…',
unsafeUrl: '不安全的 URL',
playlistFailed: '播放列表获取失败',
metadataFailed: '元数据获取失败',
fallback: '备用项',
invalid: '无效',
ready: '就绪',
mediaFormat: '媒体格式',
playlistItem: '播放列表项目 {{index}}',
fetchingMediaStreams: '正在获取媒体流…',
availableStreams: '可用流',
availableMediaStreams: '可用的媒体流',
metadataUnavailable: '元数据不可用。在添加此媒体之前,请刷新元数据。',
saveLocation: '保存位置',
browse: '浏览',
categoryFolders: '文件将被组织到类别文件夹中。',
sharedFolder: '所有选定的下载都将使用此文件夹。',
dedicatedFolder: '保存到新文件夹',
dedicatedFolderName: '文件夹名称',
dedicatedFolderDescription: '将在每个自动类别文件夹中创建此文件夹。',
dedicatedFolderManualDescription: '将在选定的保存位置中创建此文件夹。',
saveFolderName: '保存文件夹名称',
dedicatedFolderNameRequired: '保存前请输入文件夹名称。',
transferSettings: '传输设置',
connectionsPerFile: '每个文件的连接数',
connectionsPerFileAria: '每个文件的连接数',
limitSpeedPerFile: '限制每个文件的速度',
speedLimitPerFileAria: '每个文件的速度限制',
authorization: '授权',
useAuthorization: '使用授权',
username: '用户名',
password: '密码',
advancedTransfer: '高级传输',
verifyChecksum: '验证校验和',
checksumAlgorithm: '校验和算法',
expectedDigest: '预期摘要',
headers: '请求头',
requestHeaders: '请求头',
cookies: 'Cookie',
cookiePlaceholder: 'name=value; other=value',
mirrors: '镜像源',
cancel: '取消',
startDownloads: '开始下载',
addToQueue: '添加到队列',
pasteOneOrMore: '粘贴一个或多个链接。',
selectAtLeastOne: '请至少选择一个下载。',
correctInvalidOne: '在继续之前,请更正或移除 {{count}} 个无效的 URL。',
correctInvalidFew: '在继续之前,请更正或移除 {{count}} 个无效的 URL。',
correctInvalidMany: '在继续之前,请更正或移除 {{count}} 个无效的 URL。',
waitingForMetadataOne: '正在等待 {{count}} 个下载的元数据。',
waitingForMetadataFew: '正在等待 {{count}} 个下载的元数据。',
waitingForMetadataMany: '正在等待 {{count}} 个下载的元数据。',
removeUnsafeOne: '在继续之前,请移除 {{count}} 个不安全的 URL。',
removeUnsafeFew: '在继续之前,请移除 {{count}} 个不安全的 URL。',
removeUnsafeMany: '在继续之前,请移除 {{count}} 个不安全的 URL。',
mediaMetadataUnavailableSummaryOne: '{{count}} 个项目的媒体元数据不可用。在添加之前请刷新元数据。',
mediaMetadataUnavailableSummaryFew: '{{count}} 个项目的媒体元数据不可用。在添加之前请刷新元数据。',
mediaMetadataUnavailableSummaryMany: '{{count}} 个项目的媒体元数据不可用。在添加之前请刷新元数据。',
metadataUnavailableFallback: '元数据不可用。下载仍可以使用后备详细信息添加。',
fallbackReadyOne: '{{ready}} 个下载已就绪;{{failed}} 个将使用后备文件名和未知大小。',
fallbackReadyFew: '{{ready}} 个下载已就绪;{{failed}} 个将使用后备文件名和未知大小。',
fallbackReadyMany: '{{ready}} 个下载已就绪;{{failed}} 个将使用后备文件名和未知大小。',
readyToAddOne: '准备添加 {{count}} 个下载。',
readyToAddFew: '准备添加 {{count}} 个下载。',
readyToAddMany: '准备添加 {{count}} 个下载。',
},
settings: {
tabs: {
downloads: '下载',
lookAndFeel: '外观',
network: '网络',
locations: '位置',
siteLogins: '站点登录',
power: '电源',
engine: '引擎',
integrations: '集成',
about: '关于',
},
common: {
thisOs: '此操作系统',
checking: '正在检查…',
ready: '就绪',
errorMissing: '错误 / 缺失',
hideTechnicalDetails: '隐藏技术详细信息',
showTechnicalDetails: '显示技术详细信息',
binary: '二进制文件',
expected: '预期',
error: '错误',
tip: '提示',
stderr: '标准错误',
daemonProcessAlive: '守护进程处于活动状态',
rpcReady: 'RPC 已就绪',
rpcPort: 'RPC 端口',
daemonStderr: '守护进程标准错误',
packaging: '打包:PyInstaller onedir(需要 _internal',
internalDirectoryFound: '找到 _internal 目录',
pythonRuntimeFound: '找到 Python 运行时',
externalOpenFailed: '无法打开 {{label}}{{detail}}',
updateUnexpectedResponse: '更新检查返回了意外的响应。',
updateUntrustedReleaseUrl: '更新检查返回了不受信任的发布版本 URL。',
updateFailed: '更新检查失败:{{detail}}',
},
downloads: {
defaultConnections: '默认连接数:',
defaultConnectionsDescription: '新下载;现有项目保持其保存的值',
parallelDownloads: '并行下载:',
parallelDownloadsDescription: '最大同时活动文件数',
automaticRetries: '自动重试:',
automaticRetriesDescription: '如果连接失败',
systemNotification: '下载完成时显示系统通知',
systemNotificationDescription: '使用操作系统的通知设置',
completionChime: '播放应用内完成提示音',
completionChimeDescription: '可选的 Firelink 声音,独立于系统通知',
clipboardLinks: '当 Firelink 处于活动状态时添加剪贴板链接',
clipboardLinksDescription: '在“添加”窗口中打开支持的已复制链接。默认关闭。',
},
lookAndFeel: {
language: '语言',
languageDescription: '选择 Firelink 的界面语言。',
sidebarPosition: '侧边栏位置',
sidebarPositionDescription: '选择侧边栏显示的位置。“自动”会跟随界面文字方向。',
sidebarPositionAutomatic: '自动(推荐)',
sidebarPositionLeft: '左侧',
sidebarPositionRight: '右侧',
languageSystem: '系统默认',
languageEnglish: '英语',
languageChinese: '简体中文',
languageHebrew: '希伯来语',
languagePersian: '波斯语',
languageUkrainian: '乌克兰语',
languageRussian: '俄语',
appTheme: '应用主题',
theme: '主题',
ariaLabel: '应用主题',
system: '系统',
light: '浅色',
dark: '深色',
dracula: 'Dracula',
nord: 'Nord',
systemAppearance: '系统跟随当前的 {{platform}} 浅色或深色外观。',
display: '显示',
fontSize: '字体大小',
fontSizeDescription: '缩放下载列表和紧凑型控件。',
small: '小',
standard: '标准',
large: '大',
listDensity: '列表密度',
listDensityDescription: '更改行高、间距和进度条的缩放。',
compact: '紧凑',
comfortable: '舒适',
relaxed: '宽松',
osIntegration: '系统集成',
dockBadge: '在 Dock 图标上显示角标',
dockBadgeDescription: '在 Firelink 的 Dock 图标上显示活动下载计数。',
menuBarIcon: '显示菜单栏图标',
statusIndicatorIcon: '显示状态指示器图标',
systemTrayIcon: '显示系统托盘图标',
macosTrayDescription: '提供从 macOS 菜单栏的快速访问。',
windowsTrayDescription: '提供从 Windows 通知区域的快速访问。',
linuxTrayDescription: '在可用时,提供从桌面托盘或状态区域的快速访问。',
defaultTrayDescription: '在可用时,提供从操作系统托盘区域的快速访问。',
},
network: {
proxy: '代理',
mode: '模式',
modeDescription: '控制新下载请求的代理使用。',
noProxy: '无代理',
systemProxy: '系统代理',
customProxy: '自定义代理',
proxyHost: '代理主机',
proxyHostDescription: '主机名、IP 地址或 HTTP 代理 URL。',
proxyHostPlaceholder: '127.0.0.1 或 http://127.0.0.1',
proxyPort: '代理端口',
proxyPortDescription: '有效范围是 1 到 65535。',
noProxyDescription: '下载忽略已配置的代理。',
systemProxyDescription: '下载使用检测到的 {{platform}} 系统代理。普通文件下载需要 HTTP 或 HTTPS 代理端点;媒体下载可以使用 SOCKS。',
customProxyDescription: '下载使用配置的 HTTP 代理端点来获取元数据和进行引擎下载。',
invalidCustomProxy: '输入有效的 HTTP 代理主机和端口以启用自定义代理。',
incompleteCustomProxy: '输入代理主机和端口以启用自定义代理。',
checkingSystemProxy: '正在检查系统代理配置…',
detectedSystemProxy: '检测到系统代理。普通文件下载需要 HTTP 或 HTTPS 端点;媒体下载可以使用 SOCKS。',
noSystemProxy: '未检测到可用的系统代理。下载将不使用代理。',
systemProxyReadFailed: '无法读取系统代理配置。请选择“无代理”,或者在其可用时重试。',
identity: '身份',
customUserAgent: '自定义 User-Agent',
userAgentDescription: '应用于元数据获取和下载引擎。',
userAgentPlaceholder: '留空以使用 Firelink 默认值',
userAgentOverrides: '覆盖传出的 User-Agent 请求头。留空以使用 Firelink 默认值。',
chromeWindows: 'Chrome (Windows)',
chromeMacos: 'Chrome (macOS)',
edgeWindows: 'Edge (Windows)',
firefoxWindows: 'Firefox (Windows)',
firefoxMacos: 'Firefox (macOS)',
safariMacos: 'Safari (macOS)',
windowsDesktop: 'Windows 桌面',
macosDesktop: 'macOS 桌面',
},
locations: {
baseDownloadFolder: '基本下载文件夹',
baseDownloadFolderDescription: '在此文件夹中创建自动类别文件夹。',
browse: '浏览',
baseFolderCreateFailed: '基本文件夹已保存,但无法创建类别文件夹:{{detail}}',
baseFolderUpdated: '基本下载文件夹已更新',
askWhereToSave: '在添加下载时询问保存位置',
rememberLastUsedDownloadDirectory: '记住上次使用的下载目录',
rememberLastUsedDownloadDirectoryDescription: '在下一次下载时,使用在“添加”窗口中最后选择的文件夹,直到应用程序重启。',
automaticCategorySubfolders: '自动将文件保存到类别子文件夹',
automaticCategorySubfoldersDescription: '关闭时,自动下载使用基本文件夹。',
categorySubfolders: '类别子文件夹',
relativeToBase: '相对于基本文件夹',
disabled: '已禁用',
categorySubfolder: '{{category}}子文件夹',
customFolder: '自定义文件夹',
useAutomatic: '使用自动',
resetDefaults: '重置为默认值',
resetToast: '已将类别位置重置为默认值',
},
siteLogins: {
title: '站点凭据',
noSavedLogins: '没有保存的登录信息。',
user: '用户:{{username}}',
deleteCredential: '删除凭据',
deletedCredential: '已删除凭据',
deleteFailed: '无法删除凭据:{{detail}}',
addTitle: '添加站点凭据',
urlPattern: 'URL 模式:',
urlPatternPlaceholder: '例如:*.example.com 或 example.com/downloads',
username: '用户名:',
password: '密码:',
usernamePlaceholder: '用户名',
passwordPlaceholder: '密码',
saving: '正在保存…',
addLogin: '添加登录',
urlPatternRequired: 'URL 模式是必需的。',
urlPatternWhitespace: 'URL 模式不能包含空格。',
usernameRequired: '用户名是必需的。',
passwordRequired: '密码是必需的。',
accessBeforeSave: '在保存站点登录之前,请授予凭据存储访问权限。',
saveFailed: '无法安全地保存站点凭据。',
addedCredential: '已添加站点凭据',
},
power: {
title: '电源管理',
preventSleep: '下载活动时阻止系统睡眠',
preventSleepDescription: '显示器可能仍会关闭。Firelink 仅保持设备唤醒足以完成活动的传输。',
},
engine: {
title: '媒体下载器和引擎',
description: '成功的结果将在此应用会话中重用。重新检查将再次运行真正的验证。',
recheck: '重新检查引擎',
coreDownloader: '核心下载器 (aria2)',
version: '版本:',
status: '状态:',
mediaExtractors: '媒体提取器',
browserCookiesSource: '浏览器 Cookies 来源:',
cookieDescription: 'yt-dlp 读取浏览器 cookies 以绕过视频下载限制或访问受限媒体。Firelink 不会保存浏览器 cookies。',
none: '无',
},
integrations: {
title: '连接浏览器扩展',
description: '通过三个简单的步骤直接从浏览器捕获下载。',
portablePairingEnabled: '便携配对已启用',
pairingTokenPersisted: '配对令牌已持久化',
portablePersistedDescription: '您的配对令牌与此便携式 Firelink 文件夹一起存储,并在文件夹移动时保留。请将该文件夹视为敏感内容。',
systemPersistedDescription: '您的配对令牌已存储在系统凭据存储中,并在重启后保留。',
portablePairingAvailable: '便携配对可用',
credentialStorageNeeded: '需要凭据存储',
portableAvailableDescription: '您的配对令牌与此便携式 Firelink 文件夹一起存储,并在重启后保留。在此处启用它以查看便携存储警告。',
credentialStorageDescription: 'Firelink 需要访问此系统的凭据存储以在应用重启时安全地保存您的配对令牌。目前,您的扩展仅在此会话中保持连接。',
reviewPortablePairing: '查看便携配对',
grantCredentialAccess: '授予凭据访问权限',
copyToken: '复制令牌',
tokenDescription: '此安全令牌用于授权您的浏览器扩展。',
tokenCopied: '令牌已复制到剪贴板!',
tokenCopyFailed: '无法复制令牌:{{detail}}',
pairingTokenRegenerated: '配对令牌已重新生成',
regenerateFailed: '无法重新生成配对令牌:{{detail}}',
getExtension: '获取扩展',
extensionDescription: '安装适用于 Firefox 或 Chromium 浏览器的 Firelink Companion。',
firefoxAddons: 'Firefox 附加组件',
chromiumZip: 'Chromium ZIP',
pasteConnect: '粘贴并连接',
pasteConnectDescription: '点击浏览器工具栏中的 Firelink 图标,然后粘贴复制的令牌。',
extensionServerStatus: '扩展服务器状态:',
listening: '● 正在 127.0.0.1:{{port}} 监听',
unavailable: '● 服务器不可用',
},
about: {
firelinkIcon: 'Firelink 图标',
unknown: '未知',
version: '版本 {{version}}',
description: '适用于 macOS、Windows 和 Linux 的跨平台下载管理器。',
sourceCode: '源代码',
source: '源码',
upToDate: 'Firelink {{version}} 已是最新版本。',
updateAvailable: 'Firelink {{version}} 可用。',
viewRelease: '查看发布版本',
updates: '更新',
checkForUpdates: '检查更新',
updateDescription: 'Firelink 会在 GitHub Releases 检查新版本。',
checking: '正在检查…',
checkNow: '立即检查',
automaticUpdates: '自动检查更新',
createdBy: '由 NimBold 创建',
mitLicense: 'MIT 许可证',
credits: '致谢:aria2、yt-dlp、FFmpeg 和 Deno。',
copyright: '版权所有 © 2026 NimBold。保留所有权利。',
},
},
status: {
ready: '就绪',
active: '{{count}} 个进行中',
queued: '{{count}} 个排队',
done: '{{count}} 个完成',
},
} as const;
export default zhCN;
+64
View File
@@ -0,0 +1,64 @@
import { describe, expect, it, vi } from 'vitest';
import i18n, { changeAppLocale, i18nReady, syncDocumentLocale } from './index';
import { APP_LOCALES, localeDirection, resolveAppLocale } from './locales';
describe('i18n runtime', () => {
it('selects every bundled catalog instead of falling back to English', async () => {
await i18nReady;
const originalLanguage = i18n.language;
try {
const expectedLibraryLabels = {
en: 'Library',
'zh-CN': '库',
he: 'ספרייה',
fa: 'کتابخانه',
uk: 'Бібліотека',
ru: 'Библиотека',
} as const;
for (const locale of APP_LOCALES) {
await changeAppLocale(locale);
expect(i18n.language).toBe(locale);
expect(i18n.t($ => $.navigation.library)).toBe(expectedLibraryLabels[locale]);
}
} finally {
await changeAppLocale(resolveAppLocale(originalLanguage));
}
});
it('syncs document language and direction for every supported locale', async () => {
const documentElement = { lang: '', dir: '' };
vi.stubGlobal('document', { documentElement });
try {
for (const locale of APP_LOCALES) {
await changeAppLocale(locale);
expect(documentElement).toEqual({ lang: locale, dir: localeDirection(locale) });
}
expect(syncDocumentLocale('de-DE')).toBe('en');
expect(documentElement).toEqual({ lang: 'en', dir: 'ltr' });
} finally {
vi.unstubAllGlobals();
}
});
it('uses English resources for an unknown i18next language', async () => {
await i18nReady;
const originalLanguage = i18n.language;
try {
await i18n.changeLanguage('de-DE');
expect(i18n.t($ => $.navigation.library)).toBe('Library');
expect(resolveAppLocale('de-DE')).toBe('en');
expect(resolveAppLocale('ru-RU')).toBe('ru');
expect(resolveAppLocale('fa-IR')).toBe('fa');
expect(resolveAppLocale('he-IL')).toBe('he');
expect(resolveAppLocale('uk-UA')).toBe('uk');
expect(resolveAppLocale('zh-Hans-CN')).toBe('zh-CN');
} finally {
await changeAppLocale(resolveAppLocale(originalLanguage));
}
});
});
+115
View File
@@ -0,0 +1,115 @@
import i18n from 'i18next';
import { initReactI18next } from 'react-i18next';
import englishCommon from './catalogs/en';
import {
APP_LOCALES,
DEFAULT_LOCALE,
localeDirection,
resolveAppLocale,
type AppLocale,
} from './locales';
export { APP_LOCALES, APP_LOCALE_METADATA, APP_LOCALE_PREFERENCES, DEFAULT_LOCALE, isAppLocale, isAppLocalePreference, localeDirection, localePluralVariant, resolveAppLocale } from './locales';
export type { AppLocale, AppLocaleDirection, AppLocalePreference, AppPluralVariant } from './locales';
const defaultNS = 'common' as const;
const initialLocale = resolveAppLocale(
typeof navigator === 'undefined' ? undefined : navigator.language
);
type CommonCatalog = Parameters<typeof i18n.addResourceBundle>[2];
type LocaleLoader = () => Promise<CommonCatalog>;
const localeLoaders: Record<AppLocale, LocaleLoader> = {
en: async () => englishCommon,
'zh-CN': async () => (await import('./catalogs/zh-CN')).default,
he: async () => (await import('./catalogs/he')).default,
fa: async () => (await import('./catalogs/fa')).default,
uk: async () => (await import('./catalogs/uk')).default,
ru: async () => (await import('./catalogs/ru')).default,
};
const loadedLocales = new Set<AppLocale>([DEFAULT_LOCALE]);
const localeLoadPromises = new Map<AppLocale, Promise<void>>();
let localeChangeRequest = 0;
const loadLocale = (locale: AppLocale): Promise<void> => {
if (loadedLocales.has(locale)) return Promise.resolve();
const existingLoad = localeLoadPromises.get(locale);
if (existingLoad) return existingLoad;
const load = localeLoaders[locale]().then(catalog => {
i18n.addResourceBundle(locale, defaultNS, catalog, true, true);
loadedLocales.add(locale);
localeLoadPromises.delete(locale);
}).catch(error => {
localeLoadPromises.delete(locale);
throw error;
});
localeLoadPromises.set(locale, load);
return load;
};
const fallbackToEnglish = async (requestedLocale: AppLocale, error: unknown): Promise<void> => {
console.error(`Failed to load locale "${requestedLocale}"; falling back to English.`, error);
await i18n.changeLanguage(DEFAULT_LOCALE);
syncDocumentLocale(DEFAULT_LOCALE);
};
export const i18nReady = i18n
.use(initReactI18next)
.init({
defaultNS,
fallbackLng: DEFAULT_LOCALE,
initAsync: false,
lng: DEFAULT_LOCALE,
interpolation: {
escapeValue: false,
},
ns: [defaultNS],
resources: {
[DEFAULT_LOCALE]: { [defaultNS]: englishCommon },
},
returnNull: false,
supportedLngs: APP_LOCALES,
})
.then(async () => {
try {
await loadLocale(initialLocale);
await i18n.changeLanguage(initialLocale);
syncDocumentLocale(initialLocale);
} catch (error) {
await fallbackToEnglish(initialLocale, error);
}
});
export const changeAppLocale = async (locale: AppLocale): Promise<void> => {
const request = ++localeChangeRequest;
await i18nReady;
if (request !== localeChangeRequest) return;
try {
await loadLocale(locale);
if (request !== localeChangeRequest) return;
await i18n.changeLanguage(locale);
if (request === localeChangeRequest) syncDocumentLocale(locale);
} catch (error) {
if (request === localeChangeRequest) {
await fallbackToEnglish(locale, error);
}
}
};
export const syncDocumentLocale = (value: string | null | undefined): AppLocale => {
const locale = resolveAppLocale(value);
if (typeof document !== 'undefined') {
document.documentElement.lang = locale;
document.documentElement.dir = localeDirection(locale);
}
return locale;
};
export default i18n;
+56
View File
@@ -0,0 +1,56 @@
import { describe, expect, it } from 'vitest';
import {
APP_LOCALES,
DEFAULT_LOCALE,
isAppLocalePreference,
isAppLocale,
localeDirection,
localePluralVariant,
resolveAppLocale,
} from './locales';
describe('app locale metadata', () => {
it('keeps the planned locale set stable', () => {
expect(APP_LOCALES).toEqual(['en', 'zh-CN', 'he', 'fa', 'uk', 'ru']);
expect(DEFAULT_LOCALE).toBe('en');
});
it('normalizes common system language tags', () => {
expect(resolveAppLocale('en-US')).toBe('en');
expect(resolveAppLocale('zh-Hans-CN')).toBe('zh-CN');
expect(resolveAppLocale('iw-IL')).toBe('he');
expect(resolveAppLocale('fa-IR')).toBe('fa');
expect(resolveAppLocale('uk-UA')).toBe('uk');
expect(resolveAppLocale('ru-RU')).toBe('ru');
expect(resolveAppLocale('de-DE')).toBe('en');
});
it('marks the planned RTL locales correctly', () => {
expect(localeDirection('en')).toBe('ltr');
expect(localeDirection('zh-CN')).toBe('ltr');
expect(localeDirection('he')).toBe('rtl');
expect(localeDirection('fa')).toBe('rtl');
expect(localeDirection('uk')).toBe('ltr');
expect(localeDirection('ru')).toBe('ltr');
});
it('selects locale-aware plural categories', () => {
expect(localePluralVariant('en', 1)).toBe('one');
expect(localePluralVariant('en', 2)).toBe('many');
expect(localePluralVariant('ru-RU', 1)).toBe('one');
expect(localePluralVariant('ru-RU', 2)).toBe('few');
expect(localePluralVariant('ru-RU', 5)).toBe('many');
expect(localePluralVariant('uk-UA', 2)).toBe('few');
});
it('guards locale values before they reach i18next', () => {
expect(isAppLocale('en')).toBe(true);
expect(isAppLocale('fa')).toBe(true);
expect(isAppLocale('ru')).toBe(true);
expect(isAppLocale('de')).toBe(false);
expect(isAppLocale(null)).toBe(false);
expect(isAppLocalePreference('system')).toBe(true);
expect(isAppLocalePreference('fa')).toBe(true);
expect(isAppLocalePreference('de')).toBe(false);
});
});
+58
View File
@@ -0,0 +1,58 @@
export const APP_LOCALES = ['en', 'zh-CN', 'he', 'fa', 'uk', 'ru'] as const;
export const APP_LOCALE_PREFERENCES = ['system', ...APP_LOCALES] as const;
export type AppLocale = typeof APP_LOCALES[number];
export type AppLocalePreference = typeof APP_LOCALE_PREFERENCES[number];
export type AppLocaleDirection = 'ltr' | 'rtl';
export type AppPluralVariant = 'one' | 'few' | 'many';
export const DEFAULT_LOCALE: AppLocale = 'en';
export const APP_LOCALE_METADATA: Record<AppLocale, {
direction: AppLocaleDirection;
isTranslated: boolean;
}> = {
en: { direction: 'ltr', isTranslated: true },
'zh-CN': { direction: 'ltr', isTranslated: true },
he: { direction: 'rtl', isTranslated: true },
fa: { direction: 'rtl', isTranslated: true },
uk: { direction: 'ltr', isTranslated: true },
ru: { direction: 'ltr', isTranslated: true },
};
export const isAppLocale = (value: unknown): value is AppLocale =>
typeof value === 'string' && (APP_LOCALES as readonly string[]).includes(value);
export const isAppLocalePreference = (value: unknown): value is AppLocalePreference =>
typeof value === 'string' && (APP_LOCALE_PREFERENCES as readonly string[]).includes(value);
/**
* Resolve a system or persisted BCP 47 language tag to a bundled Firelink
* locale. Unknown languages fall back to English.
*/
export const resolveAppLocale = (value: string | null | undefined): AppLocale => {
const normalized = value?.trim().replace(/_/g, '-').toLowerCase();
if (!normalized) return DEFAULT_LOCALE;
if (normalized === 'zh-cn' || normalized.startsWith('zh-')) return 'zh-CN';
if (normalized === 'he' || normalized.startsWith('he-') || normalized === 'iw' || normalized.startsWith('iw-')) return 'he';
if (normalized === 'fa' || normalized.startsWith('fa-')) return 'fa';
if (normalized === 'uk' || normalized.startsWith('uk-')) return 'uk';
if (normalized === 'ru' || normalized.startsWith('ru-')) return 'ru';
if (normalized === 'en' || normalized.startsWith('en-')) return 'en';
return DEFAULT_LOCALE;
};
export const localePluralVariant = (
value: string | null | undefined,
count: number
): AppPluralVariant => {
const category = new Intl.PluralRules(resolveAppLocale(value)).select(Math.abs(count));
if (category === 'one') return 'one';
if (category === 'few') return 'few';
return 'many';
};
export const localeDirection = (locale: AppLocale): AppLocaleDirection =>
APP_LOCALE_METADATA[locale].direction;
+111
View File
@@ -0,0 +1,111 @@
import { describe, expect, it } from 'vitest';
import { APP_LOCALES } from './locales';
import { resources } from './resources';
type CatalogNode = string | { readonly [key: string]: CatalogNode };
const flattenCatalog = (
node: CatalogNode,
prefix = ''
): Map<string, string> => {
const entries = new Map<string, string>();
if (typeof node === 'string') {
entries.set(prefix, node);
return entries;
}
for (const [key, value] of Object.entries(node)) {
const path = prefix ? `${prefix}.${key}` : key;
for (const [leafPath, leafValue] of flattenCatalog(value, path)) {
entries.set(leafPath, leafValue);
}
}
return entries;
};
const interpolationTokens = (value: string): string[] =>
[...value.matchAll(/\{\{([A-Za-z0-9_]+)\}\}/g)]
.map((match) => match[1])
.sort();
const catalogFor = (locale: keyof typeof resources): Map<string, string> =>
flattenCatalog(resources[locale].common);
describe('translation catalogs', () => {
it('registers a catalog for every supported locale', () => {
expect(Object.keys(resources).sort()).toEqual([...APP_LOCALES].sort());
});
it('keeps the English catalog free of translated-script contamination', () => {
const suspiciousValues = [...catalogFor('en').entries()]
.filter(([, value]) => /[\u0590-\u05FF\u0600-\u06FF\u0400-\u04FF\u4E00-\u9FFF]/u.test(value))
.map(([key, value]) => `${key} = ${JSON.stringify(value)}`);
expect(suspiciousValues).toEqual([]);
});
it('keeps every locale structurally identical to English', () => {
const englishKeys = [...catalogFor('en').keys()].sort();
for (const locale of APP_LOCALES) {
const localeKeys = [...catalogFor(locale).keys()].sort();
expect({ locale, missing: englishKeys.filter((key) => !localeKeys.includes(key)), extra: localeKeys.filter((key) => !englishKeys.includes(key)) }).toEqual({
locale,
missing: [],
extra: [],
});
}
});
it('preserves interpolation tokens exactly in every translation', () => {
const english = catalogFor('en');
const allMismatches = APP_LOCALES.flatMap((locale) => {
const catalog = catalogFor(locale);
return [...english.entries()]
.map(([key, englishValue]) => ({
locale,
key,
expected: interpolationTokens(englishValue),
actual: interpolationTokens(catalog.get(key) ?? ''),
}))
.filter(({ expected, actual }) => JSON.stringify(expected) !== JSON.stringify(actual));
});
expect(allMismatches).toEqual([]);
});
it('reports exact English duplicates for translation review', () => {
const english = catalogFor('en');
const duplicates = APP_LOCALES.filter((locale) => locale !== 'en').flatMap((locale) => {
const catalog = catalogFor(locale);
return [...english.entries()]
.filter(([key, englishValue]) => englishValue.length >= 4 && catalog.get(key) === englishValue)
.map(([key, value]) => ({ locale, key, value }));
});
const allowedExactKeys = new Set([
'addDownloads.cookiePlaceholder',
'downloadTable.interactionError',
'keychain.stores.windows',
'keychain.stores.macos',
'settings.common.stderr',
'settings.integrations.chromiumZip',
'settings.lookAndFeel.dracula',
'settings.lookAndFeel.nord',
'settings.network.chromeWindows',
'settings.network.chromeMacos',
'settings.network.edgeWindows',
'settings.network.firefoxWindows',
'settings.network.firefoxMacos',
'settings.network.safariMacos',
]);
const unexpectedDuplicates = duplicates
.filter(({ key }) => !allowedExactKeys.has(key))
.map(({ locale, key, value }) => `${locale}:${key} = ${JSON.stringify(value)}`);
expect(unexpectedDuplicates).toEqual([]);
});
});
+17
View File
@@ -0,0 +1,17 @@
import en from "./catalogs/en";
import fa from "./catalogs/fa";
import he from "./catalogs/he";
import ru from "./catalogs/ru";
import uk from "./catalogs/uk";
import zhCN from "./catalogs/zh-CN";
export const defaultNS = "common" as const;
export const resources = {
en: { common: en },
"zh-CN": { common: zhCN },
fa: { common: fa },
he: { common: he },
uk: { common: uk },
ru: { common: ru },
} as const;
+11
View File
@@ -0,0 +1,11 @@
import 'i18next';
import type { defaultNS, resources } from './i18n/resources';
declare module 'i18next' {
interface CustomTypeOptions {
defaultNS: typeof defaultNS;
enableSelector: 'optimize';
resources: typeof resources['en'];
returnNull: false;
}
}
+556 -77
View File
@@ -235,7 +235,9 @@
}
body.is-resizing,
body.is-resizing * {
body.is-resizing *,
body.is-column-resizing,
body.is-column-resizing * {
cursor: col-resize !important;
user-select: none !important;
}
@@ -548,6 +550,7 @@ html[data-list-density="relaxed"] {
min-width: 0;
min-height: 0;
overflow: hidden;
border-inline-end: 1px solid hsl(var(--border-modal));
background:
radial-gradient(circle at 18% 0%, hsl(var(--accent-color) / 0.055), transparent 34%),
hsl(var(--main-bg) / 0.56);
@@ -619,6 +622,13 @@ html[data-list-density="relaxed"] {
.add-download-links-input {
border-radius: 10px;
line-height: 1.55;
direction: ltr;
text-align: left;
}
.add-download-links-input--rtl:placeholder-shown {
direction: rtl;
text-align: right;
}
.add-download-link-button {
@@ -999,18 +1009,34 @@ html[data-list-density="relaxed"] {
}
.app-shell {
--window-corner-radius: 18px;
direction: ltr;
background: hsl(var(--main-bg));
border: 1px solid hsl(var(--border-color));
border-radius: var(--window-corner-radius);
overflow: hidden;
}
.app-sidebar-shell {
padding: 9px 0 9px 9px;
padding-block: 10px;
padding-inline-start: 10px;
background: transparent;
}
.app-sidebar-shell--left {
order: 0;
}
.app-sidebar-shell--right {
order: 2;
padding-inline-start: 0;
padding-inline-end: 10px;
}
.sidebar-resize-handle {
position: absolute;
top: 18px;
right: -4px;
inset-inline-end: -4px;
bottom: 18px;
z-index: 60;
width: 8px;
@@ -1022,13 +1048,23 @@ html[data-list-density="relaxed"] {
content: '';
position: absolute;
top: 0;
right: 3px;
inset-inline-end: 3px;
bottom: 0;
width: 1px;
background: transparent;
transition: background-color 100ms ease;
}
.app-sidebar-shell--right .sidebar-resize-handle {
inset-inline-start: -4px;
inset-inline-end: auto;
}
.app-sidebar-shell--right .sidebar-resize-handle::after {
inset-inline-start: 3px;
inset-inline-end: auto;
}
.sidebar-resize-handle:hover::after,
body.is-resizing .sidebar-resize-handle::after {
background: hsl(var(--accent-color) / 0.55);
@@ -1043,10 +1079,7 @@ html[data-list-density="relaxed"] {
background: hsl(var(--sidebar-panel-bg));
border: 1px solid hsl(var(--sidebar-border));
border-top-left-radius: 18px;
border-bottom-left-radius: 18px;
border-top-right-radius: 17px;
border-bottom-right-radius: 17px;
border-radius: var(--window-corner-radius);
box-shadow:
inset 0 1px 0 hsl(0 0% 100% / 0.045),
@@ -1055,13 +1088,21 @@ html[data-list-density="relaxed"] {
.app-workspace {
order: 1;
direction: ltr;
background: hsl(var(--workspace-bg));
}
html[dir="rtl"] .app-sidebar-panel,
html[dir="rtl"] .app-workspace {
direction: rtl;
}
.app-sidebar-reveal-button {
position: absolute;
top: 12px;
left: 88px;
right: auto;
z-index: 80;
-webkit-app-region: no-drag;
}
@@ -1070,6 +1111,25 @@ html[data-list-density="relaxed"] {
left: 124px;
}
.app-workspace--sidebar-right .app-sidebar-reveal-button {
left: auto;
right: 88px;
}
.app-workspace--sidebar-right.app-workspace--custom-window-controls .app-sidebar-reveal-button {
right: 124px;
}
.app-sidebar-shell--left .sidebar-nav-item {
direction: ltr;
text-align: left;
}
.app-sidebar-shell--right .sidebar-nav-item {
direction: rtl;
text-align: right;
}
.app-statusbar {
height: 26px;
font-size: 10px;
@@ -1087,6 +1147,11 @@ html[data-list-density="relaxed"] {
transition: background-color 100ms ease;
}
.sidebar-nav-label {
flex: 1 1 auto;
min-width: 0;
}
.sidebar-nav-item[data-active="true"] {
background: hsl(var(--accent-color)) !important;
color: white !important;
@@ -1240,7 +1305,7 @@ html[data-list-density="relaxed"] {
.sidebar-toggle-button {
position: absolute;
top: 11px;
right: 12px;
inset-inline-end: 12px;
z-index: 50;
width: 24px;
@@ -1272,6 +1337,11 @@ html[data-list-density="relaxed"] {
.settings-tab-strip {
padding: 16px 32px;
direction: ltr;
}
.settings-tab-strip--sidebar-right {
flex-direction: row-reverse;
}
.settings-tab-button {
@@ -1445,7 +1515,7 @@ html[data-list-density="relaxed"] {
background: transparent;
color: hsl(var(--text-primary));
font: inherit;
text-align: left;
text-align: start;
}
.settings-combobox-option:last-child {
@@ -1572,14 +1642,20 @@ html[data-list-density="relaxed"] {
.mac-switch:checked::after {
transform: translateX(16px);
}
/* Switch thumbs use physical LTR transforms in every interface direction. */
button[role="switch"] {
direction: ltr;
}
.downloads-view {
background: hsl(var(--main-bg));
}
.window-controls {
position: fixed;
top: 15px;
top: 20px;
left: 22px;
right: auto;
z-index: 60;
display: inline-flex;
align-items: center;
@@ -1588,6 +1664,16 @@ html[data-list-density="relaxed"] {
pointer-events: auto;
}
.app-shell--sidebar-right .window-controls {
left: auto;
right: 22px;
}
.window-controls--right {
left: auto;
right: 22px;
}
.window-control {
width: 14px;
height: 14px;
@@ -1624,6 +1710,10 @@ html[data-list-density="relaxed"] {
filter: saturate(1.14) brightness(1.05);
}
.window-controls:hover .window-control {
color: hsl(0 0% 8% / 0.68);
}
.window-control:active {
transform: scale(0.92);
filter: brightness(0.9);
@@ -1633,19 +1723,35 @@ html[data-list-density="relaxed"] {
height: 52px;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 18px;
direction: ltr;
border-bottom: 1px solid hsl(var(--border-color));
background: hsl(var(--statusbar-bg));
}
.app-workspace--sidebar-right .main-titlebar {
flex-direction: row-reverse;
}
.app-workspace--sidebar-collapsed .main-titlebar {
padding-left: 124px;
padding-right: 18px;
}
.app-workspace--sidebar-collapsed.app-workspace--custom-window-controls .main-titlebar {
padding-left: 160px;
}
.app-workspace--sidebar-right.app-workspace--sidebar-collapsed .main-titlebar {
padding-left: 18px;
padding-right: 124px;
}
.app-workspace--sidebar-right.app-workspace--sidebar-collapsed.app-workspace--custom-window-controls .main-titlebar {
padding-right: 160px;
}
.main-titlebar-title {
font-size: 14px;
font-weight: 700;
@@ -1654,7 +1760,7 @@ html[data-list-density="relaxed"] {
}
.main-control-group {
margin-left: auto;
margin: 0;
height: 28px;
display: inline-flex;
align-items: center;
@@ -1672,7 +1778,7 @@ html[data-list-density="relaxed"] {
align-items: center;
justify-content: center;
color: hsl(var(--text-secondary));
border-right: 1px solid hsl(var(--border-color));
border-inline-end: 1px solid hsl(var(--border-color));
-webkit-app-region: no-drag;
}
@@ -1682,7 +1788,7 @@ html[data-list-density="relaxed"] {
}
.main-control-button:last-child {
border-right: 0;
border-inline-end: 0;
}
.main-control-button:hover:not(:disabled) {
@@ -1779,6 +1885,7 @@ html[data-list-density="relaxed"] {
.download-table-header {
flex-shrink: 0;
position: relative;
height: var(--download-header-height);
display: grid;
align-items: center;
@@ -1796,51 +1903,131 @@ html[data-list-density="relaxed"] {
flex-direction: column;
min-height: 0;
min-width: 0;
overflow-x: hidden;
overflow-x: auto;
overflow-y: hidden;
}
.download-table-header,
.download-row {
direction: ltr;
box-sizing: border-box;
width: 100%;
min-width: 0;
--download-column-padding-x: 14px;
}
.download-table-header > div {
.download-column-header {
position: relative;
height: 100%;
display: flex;
align-items: center;
justify-content: var(--column-justify, flex-start);
min-width: 0;
overflow: hidden;
padding-inline: var(--download-column-padding-x);
padding-right: calc(var(--download-column-padding-x) + 22px);
cursor: grab;
transition: background-color 120ms ease, opacity 120ms ease, box-shadow 120ms ease;
}
.download-table-header > div:first-child {
padding-left: 0;
.download-column-header:active {
cursor: grabbing;
}
.download-table-header > div:last-child {
padding-right: 0;
.download-column-header.is-dragging {
opacity: 0;
}
.download-table-header > div > span {
.download-column-header.is-drop-flashing {
animation: column-drop-flash 260ms ease-out;
}
.download-column-header-content {
display: inline-flex;
align-items: center;
gap: 4px;
min-width: 0;
max-width: 100%;
height: 100%;
overflow: hidden;
padding: 0;
border: 0;
background: transparent;
color: inherit;
font: inherit;
text-align: inherit;
cursor: pointer;
}
.download-column-header-content > span {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.download-table-header > div:not(:first-child) {
padding-left: var(--download-column-padding-x);
.download-column-header-content:focus-visible,
.download-column-options:focus-visible {
outline: 2px solid hsl(var(--accent-color));
outline-offset: -2px;
}
.download-table-header > [data-column-key="Status"] {
padding-inline: 8px;
padding-right: 8px;
}
.download-table-header > [data-column-key="Status"]:hover,
.download-table-header > [data-column-key="Status"]:focus-within {
padding-right: 30px;
}
.download-column-options {
position: absolute;
right: 8px;
left: auto;
top: 50%;
z-index: 4;
display: inline-flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
border: 0;
border-radius: 6px;
background: hsl(var(--surface-overlay) / 0.92);
color: hsl(var(--text-secondary));
opacity: 0;
pointer-events: none;
transform: translateY(-50%);
transition: opacity 120ms ease, color 120ms ease, background-color 120ms ease;
}
.download-column-header:hover .download-column-options,
.download-column-options:focus-visible {
opacity: 1;
pointer-events: auto;
}
.download-column-options:hover {
background: hsl(var(--item-hover));
color: hsl(var(--text-primary));
}
.download-column-header:first-child {
padding-inline-start: 0;
}
.download-column-header[data-column-key="File Name"] {
direction: ltr;
text-align: left;
}
.column-resize-handle {
position: absolute;
top: 0;
right: -4px;
left: auto;
bottom: 0;
z-index: 5;
width: 8px;
@@ -1861,12 +2048,12 @@ html[data-list-density="relaxed"] {
}
.column-resize-handle:hover,
body.is-resizing .column-resize-handle:hover {
body.is-column-resizing .column-resize-handle:hover {
background: hsl(var(--accent-color) / 0.16);
}
.column-resize-handle:hover::after,
body.is-resizing .column-resize-handle:hover::after {
body.is-column-resizing .column-resize-handle:hover::after {
width: 2px;
transform: translateX(-1px);
background: hsl(var(--accent-color));
@@ -1882,7 +2069,8 @@ body.is-resizing .column-resize-handle:hover::after {
.download-table-list {
height: 100%;
min-height: 0;
overflow: auto;
overflow-x: hidden;
overflow-y: auto;
display: flex;
flex-direction: column;
}
@@ -1922,15 +2110,13 @@ html[data-list-density="relaxed"] .download-ghost-row {
}
.download-row > div:first-child {
padding-left: 0;
padding-inline-start: 0;
}
.download-row > div:last-child {
padding-right: 0;
.download-row > .download-status-cell {
padding-inline: 8px;
}
.download-row.is-selected {
background: hsl(var(--accent-color) / 0.26) !important;
box-shadow: inset 0 0 0 1px hsl(var(--accent-color) / 0.28);
@@ -1940,21 +2126,50 @@ html[data-list-density="relaxed"] .download-ghost-row {
background: hsl(var(--accent-color) / 0.32) !important;
}
.download-row:focus-visible {
outline: 2px solid hsl(var(--accent-color) / 0.7);
outline-offset: -1px;
}
.download-row:hover {
background: hsl(var(--item-hover));
}
.download-file-cell {
.download-column-cell {
display: flex;
align-items: center;
gap: var(--download-cell-gap);
justify-content: var(--column-justify, flex-start);
min-width: 0;
overflow: hidden;
}
.download-file-name {
.download-file-cell {
direction: ltr;
text-align: left;
}
.download-cell-content {
display: block;
flex: 1 1 auto;
flex: 0 1 auto;
min-width: 0;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.download-file-cell > .download-cell-content {
display: inline-flex;
align-items: center;
gap: var(--download-cell-gap);
width: max-content;
}
.download-file-name {
direction: ltr;
text-align: left;
display: block;
flex: 0 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
@@ -1964,50 +2179,29 @@ html[data-list-density="relaxed"] .download-ghost-row {
}
.download-cell-truncate {
text-align: start;
}
.download-size-content {
display: flex;
align-items: center;
justify-content: flex-start;
min-width: 0;
overflow: hidden;
width: max-content;
direction: ltr;
text-align: start;
}
.download-size-cell {
justify-content: flex-end;
text-align: right;
.download-size-content > .download-size-progress {
flex: 0 1 auto;
text-align: start;
}
.download-size-cell > .download-size-progress {
flex: 1 1 auto;
text-align: right;
.download-size-content > .download-size-total {
flex: 0 1 auto;
text-align: start;
}
.download-size-cell > .download-size-total {
flex: 0 0 auto;
text-align: left;
white-space: nowrap;
}
.download-size-cell > .download-size-progress,
.download-size-cell > .download-size-total {
max-width: 100%;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.download-size-cell > .download-size-progress {
text-align: right;
}
.download-row > .download-size-cell {
padding-right: calc(var(--download-column-padding-x) + 6px);
}
.download-cell-truncate > span,
.download-cell-right > span {
display: block;
text-align: left;
.download-size-content > .download-size-progress,
.download-size-content > .download-size-total {
max-width: 100%;
min-width: 0;
overflow: hidden;
@@ -2020,12 +2214,147 @@ html[data-list-density="relaxed"] .download-ghost-row {
}
.download-cell-right {
text-align: left;
text-align: start;
}
.download-row-actions {
position: fixed;
right: 0;
display: flex;
justify-content: flex-start;
align-self: stretch;
align-items: center;
justify-content: flex-end;
z-index: 2;
max-width: 100%;
min-width: 0;
padding: 0;
background: transparent;
opacity: 0;
pointer-events: none;
transition: opacity 120ms ease;
}
.download-row.has-visible-actions .download-row-actions {
opacity: 1;
pointer-events: auto;
}
.download-row > .download-row-actions {
overflow: visible;
padding: 0;
}
.download-row-actions .app-icon-button,
.download-row-actions .app-icon-button:hover:not(:disabled),
.download-row-actions .app-icon-button:active:not(:disabled) {
background: transparent;
color: hsl(var(--text-secondary));
transition: background-color 120ms ease, box-shadow 120ms ease, color 120ms ease, transform 100ms ease;
}
@media (prefers-reduced-motion: reduce) {
.download-row-actions,
.download-row-actions .app-icon-button {
transition: none;
}
}
.download-row-actions .app-icon-button:hover:not(:disabled),
.download-row-actions .app-icon-button:focus-visible {
background: hsl(var(--accent-color) / 0.14);
box-shadow: 0 0 0 1px hsl(var(--accent-color) / 0.34);
color: hsl(var(--accent-color));
}
.download-row-actions .app-icon-button:active:not(:disabled) {
background: hsl(var(--accent-color) / 0.22);
box-shadow: 0 0 0 1px hsl(var(--accent-color) / 0.46);
color: hsl(var(--accent-color));
}
.download-column-menu {
width: 188px;
}
.download-column-menu-title {
font-weight: 650;
}
.download-column-menu-label {
letter-spacing: 0.12em;
}
.download-column-menu-item[aria-checked="true"] {
color: hsl(var(--accent-color));
}
.download-column-drag-preview {
position: fixed;
z-index: 120;
display: inline-flex;
align-items: center;
gap: 5px;
height: var(--download-header-height);
padding: 0 12px;
border: 1px solid hsl(var(--accent-color) / 0.75);
border-radius: 8px;
background: hsl(var(--surface-overlay) / 0.96);
box-shadow: 0 12px 28px hsl(0 0% 0% / 0.28), 0 0 0 1px hsl(var(--accent-color) / 0.18);
color: hsl(var(--text-primary));
font-size: var(--download-row-font-size);
font-weight: 650;
pointer-events: none;
transition: top 120ms ease-out, width 120ms ease-out;
animation: column-drag-preview-in 120ms ease-out;
}
.download-column-drag-preview span {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.download-column-drop-marker {
position: fixed;
z-index: 119;
width: 2px;
height: var(--download-header-height);
border-radius: 999px;
background: hsl(var(--accent-color));
box-shadow: 0 0 0 2px hsl(var(--accent-color) / 0.18), 0 0 14px hsl(var(--accent-color) / 0.8);
pointer-events: none;
transform: translateX(-1px);
animation: column-drop-marker-pulse 700ms ease-in-out infinite;
}
.add-download-nested-fields {
padding-inline-start: 1.25rem;
border-inline-start: 2px solid hsl(var(--border-modal) / 0.5);
}
.add-download-advanced-fields {
padding-inline-start: 1.5rem;
}
.add-download-queue-menu {
inset-inline-end: 0;
}
.download-context-submenu {
inset-inline-start: 100%;
margin-inline-start: 0.25rem;
}
.app-workspace--sidebar-right .download-context-submenu {
inset-inline-start: auto;
inset-inline-end: 100%;
margin-inline-start: 0;
margin-inline-end: 0.25rem;
}
html[dir="rtl"] .download-context-menu-chevron {
transform: scaleX(-1);
}
.download-empty-row {
@@ -2052,17 +2381,41 @@ html[data-list-density="relaxed"] .download-ghost-row {
.download-status-cell {
display: flex;
align-items: center;
justify-content: flex-start;
justify-content: var(--column-justify, flex-start);
gap: var(--download-status-gap);
min-width: 0;
overflow: hidden;
}
.download-status-cell > span {
.download-status-content {
display: flex;
align-items: center;
gap: var(--download-status-gap);
width: 100%;
min-width: 0;
max-width: 100%;
flex: 0 0 auto;
text-align: left;
}
.download-status-content-static {
justify-content: var(--column-justify, flex-start);
}
.download-status-cell[data-column-alignment="center"] > .download-status-content,
.download-status-cell[data-column-alignment="right"] > .download-status-content {
width: max-content;
}
.download-status-cell[data-column-alignment="center"] .download-progress-track,
.download-status-cell[data-column-alignment="right"] .download-progress-track {
flex: 1 1 128px;
width: auto;
}
.download-status-cell .download-status {
min-width: 0;
max-width: 100%;
flex: 0 1 auto;
text-align: start;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
@@ -2074,6 +2427,7 @@ html[data-list-density="relaxed"] .download-ghost-row {
.download-progress-track {
flex: 1 1 auto;
width: auto;
max-width: 100%;
min-width: 0;
height: var(--download-progress-height);
border-radius: max(4px, calc(var(--download-progress-height) / 3));
@@ -2268,6 +2622,22 @@ html[data-list-density="relaxed"] .download-ghost-row {
to { opacity: 1; transform: translateY(0) scale(1); }
}
@keyframes column-drag-preview-in {
from { opacity: 0; transform: translateY(4px) scale(0.98); }
to { opacity: 1; transform: translateY(0) scale(1); }
}
@keyframes column-drop-marker-pulse {
0%, 100% { opacity: 0.72; }
50% { opacity: 1; }
}
@keyframes column-drop-flash {
0% { box-shadow: inset 0 0 0 0 hsl(var(--accent-color) / 0); }
40% { box-shadow: inset 0 0 0 1px hsl(var(--accent-color) / 0.65); }
100% { box-shadow: inset 0 0 0 0 hsl(var(--accent-color) / 0); }
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
@@ -2278,3 +2648,112 @@ html[data-list-density="relaxed"] .download-ghost-row {
transition-duration: 0.01ms !important;
}
}
html[dir="rtl"] .text-left {
text-align: right;
}
html[dir="rtl"] .font-mono,
html[dir="rtl"] input[type="number"] {
direction: ltr;
unicode-bidi: isolate;
}
.app-sidebar-shell--right .app-sidebar-panel {
box-shadow:
inset 0 1px 0 hsl(0 0% 100% / 0.045),
-2px 0 10px hsl(0 0% 0% / 0.20);
}
.app-sidebar-shell--left .app-sidebar-panel {
direction: ltr;
text-align: left;
}
.app-sidebar-shell--left .sidebar-section-label,
.app-sidebar-shell--left .sidebar-section-label-toggle {
direction: ltr;
text-align: left;
}
.app-sidebar-shell--left .sidebar-toggle-button {
inset-inline-start: auto;
inset-inline-end: auto;
left: auto;
right: 12px;
}
.app-sidebar-shell--right .sidebar-toggle-button {
inset-inline-start: auto;
inset-inline-end: auto;
left: 12px;
right: auto;
}
.app-sidebar-shell--left .sidebar-nav-item .sidebar-count {
margin-left: auto;
margin-right: 0;
}
.app-sidebar-shell--right .sidebar-nav-item .sidebar-count {
margin-left: 0;
margin-right: auto;
}
.app-sidebar-shell--left .sidebar-nav-item > svg {
margin-left: 0;
margin-right: 0.75rem;
}
.app-sidebar-shell--left .sidebar-nav-label {
direction: ltr;
unicode-bidi: isolate;
}
.app-sidebar-shell--right .sidebar-nav-item > svg {
margin-left: 0.75rem;
margin-right: 0;
}
.app-sidebar-shell--right .sidebar-nav-label {
text-align: right;
}
.app-sidebar-shell--right .sidebar-section-label {
text-align: right;
}
.app-sidebar-shell--right .sidebar-section-label-toggle {
direction: rtl;
text-align: right;
}
.app-sidebar-shell--right .sidebar-add-queue-button,
.app-sidebar-shell--right .sidebar-queue-editor {
direction: rtl;
text-align: right;
}
html[dir="ltr"] .app-sidebar-shell--right .sidebar-queue-editor input {
direction: ltr;
text-align: right;
}
html[dir="ltr"] .app-sidebar-shell--right .sidebar-nav-label {
direction: ltr;
unicode-bidi: isolate;
}
html[dir="rtl"] .app-sidebar-shell--right .sidebar-nav-label {
direction: rtl;
unicode-bidi: isolate;
}
html[dir="rtl"] .mac-switch::after {
left: auto;
right: 2px;
}
html[dir="rtl"] .mac-switch:checked::after {
transform: translateX(-16px);
}
+3 -1
View File
@@ -40,7 +40,8 @@ type CommandMap = {
resume_download: { args: { id: string }; result: boolean };
remove_download: { args: { id: string; deleteAssets: boolean; preserveResumable?: boolean }; result: void };
detach_download_for_reconfigure: { args: { id: string }; result: void };
update_dock_badge: { args: { count: number }; result: void };
begin_dock_badge_session: { args: undefined; result: number };
update_dock_badge: { args: { count: number; generation: number; session: number }; result: void };
get_platform_info: { args: undefined; result: PlatformInfo };
approve_download_root: { args: { path: string }; result: string };
set_prevent_sleep: { args: { prevent: boolean }; result: void };
@@ -66,6 +67,7 @@ type CommandMap = {
get_extension_server_port: { args: undefined; result: number | null };
hydrate_extension_pairing_token: { args: undefined; result: PairingTokenHydration };
get_session_pairing_token: { args: undefined; result: PairingTokenHydration };
authorize_keychain_access: { args: undefined; result: void };
regenerate_pairing_token: { args: undefined; result: PairingTokenHydration };
grant_keychain_access: { args: undefined; result: PairingTokenHydration };
acknowledge_pairing_token_change: { args: undefined; result: void };
+10 -2
View File
@@ -2,6 +2,7 @@ import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import "./index.css";
import App from "./App";
import { i18nReady } from "./i18n";
import { ErrorBoundary } from "./components/ErrorBoundary";
import { ToastProvider } from "./contexts/ToastContext";
import { error as logError, warn as logWarn, initLogger } from "./utils/logger";
@@ -34,7 +35,9 @@ console.warn = (...values: unknown[]) => {
};
const rootElement = document.getElementById("root");
if (rootElement) {
const renderApp = () => {
if (!rootElement) return;
createRoot(rootElement).render(
<StrictMode>
<ErrorBoundary>
@@ -44,7 +47,12 @@ if (rootElement) {
</ErrorBoundary>
</StrictMode>,
);
}
};
void i18nReady.then(renderApp).catch(error => {
console.error('Failed to initialize localization:', error);
renderApp();
});
// Prevent the webview's default context menu ("Reload", etc.) on right-click.
// Individual components that provide custom context menus call preventDefault()
+34
View File
@@ -90,6 +90,40 @@ describe('useDownloadProgressStore', () => {
release();
});
it('keeps Aria2 connection telemetry from the live progress event', async () => {
const handlers: Record<string, (event: any) => void> = {};
vi.mocked(ipc.listenEvent).mockImplementation((event, handler) => {
handlers[event] = handler as (event: any) => void;
return Promise.resolve(vi.fn());
});
useDownloadStore.setState({
downloads: [{
id: 'connection-telemetry',
url: 'https://github.com/example/release.zip',
fileName: 'release.zip',
status: 'downloading',
category: 'Compressed',
dateAdded: ''
}]
});
const release = await initDownloadListener();
handlers['download-progress']({ payload: {
id: 'connection-telemetry',
fraction: 0.2,
speed: '3 MB/s',
eta: '10s',
size: '38 MB',
size_is_final: false,
active_connections: 16,
requested_connections: 16
} });
expect(useDownloadProgressStore.getState().progressMap['connection-telemetry'])
.toMatchObject({ active_connections: 16, requested_connections: 16 });
release();
});
it('clears progress when events arrive after a download row was removed', async () => {
const handlers: Record<string, (event: any) => void> = {};
vi.mocked(ipc.listenEvent).mockImplementation((event, handler) => {
+273 -13
View File
@@ -95,6 +95,8 @@ describe('useDownloadStore', () => {
pendingAddHeaders: '',
pendingAddCookies: '',
pendingAddMediaUrls: [],
pendingAddBatch: false,
pendingAddBatchName: '',
pendingAddRequestContexts: {},
pendingAddRequestVersion: 0,
});
@@ -124,6 +126,132 @@ describe('useDownloadStore', () => {
expect(state.pendingAddRequestContexts['https://example.com/file.bin']?.media).toBe(false);
});
it('replaces a paused download URL in place and preserves its progress', async () => {
useDownloadStore.setState({
downloads: [{
id: 'replace-in-place',
url: 'https://expired.example/file.bin',
fileName: 'file.bin',
status: 'paused',
category: 'Other',
dateAdded: '2026-07-15T00:00:00.000Z',
downloadedBytes: 1024,
totalBytes: 4096,
fraction: 0.25
}] as any[]
});
vi.mocked(ipc.invokeCommand).mockResolvedValue(undefined as never);
const replaced = await useDownloadStore.getState().replaceDownload(
'replace-in-place',
{ url: 'https://fresh.example/file.bin', lastError: undefined },
{ type: 'add-to-queue', queueId: 'main' }
);
expect(replaced).toBe(true);
expect(useDownloadStore.getState().downloads).toEqual([expect.objectContaining({
id: 'replace-in-place',
url: 'https://fresh.example/file.bin',
status: 'paused',
downloadedBytes: 1024,
totalBytes: 4096,
fraction: 0.25
})]);
expect(ipc.invokeCommand).not.toHaveBeenCalledWith('remove_download', expect.anything());
});
it('resumes a replaced paused download without creating a second row', async () => {
useDownloadStore.setState({
downloads: [{
id: 'replace-and-resume',
url: 'https://expired.example/file.bin',
fileName: 'file.bin',
status: 'paused',
category: 'Other',
dateAdded: '',
destination: '/tmp',
downloadedBytes: 2048,
totalBytes: 4096
}] as any[]
});
vi.mocked(ipc.invokeCommand).mockImplementation(async (command: string) => {
if (command === 'resume_download') return false;
if (command === 'enqueue_download') {
return { id: 'replace-and-resume', filename: 'file.bin' };
}
if (command === 'get_pending_order') return [];
return undefined;
});
const replaced = await useDownloadStore.getState().replaceDownload(
'replace-and-resume',
{ url: 'https://fresh.example/file.bin' },
{ type: 'start-now' }
);
expect(replaced).toBe(true);
expect(useDownloadStore.getState().downloads).toHaveLength(1);
expect(useDownloadStore.getState().downloads[0]).toMatchObject({
id: 'replace-and-resume',
url: 'https://fresh.example/file.bin',
downloadedBytes: 2048,
totalBytes: 4096,
hasBeenDispatched: true
});
expect(ipc.invokeCommand).not.toHaveBeenCalledWith('remove_download', expect.anything());
});
it('serializes a replacement and a concurrent pause as one lifecycle operation', async () => {
useDownloadStore.setState({
downloads: [{
id: 'replace-pause-race',
url: 'https://expired.example/file.bin',
fileName: 'file.bin',
status: 'paused',
category: 'Other',
dateAdded: ''
}] as any[]
});
let releaseResume!: () => void;
let signalResumeStarted!: () => void;
const resumeStarted = new Promise<void>(resolve => {
signalResumeStarted = resolve;
});
const resumeGate = new Promise<void>(resolve => {
releaseResume = resolve;
});
vi.mocked(ipc.invokeCommand).mockImplementation(async (command: string) => {
if (command === 'resume_download') {
signalResumeStarted();
await resumeGate;
return true;
}
return undefined;
});
const replacing = useDownloadStore.getState().replaceDownload(
'replace-pause-race',
{ url: 'https://fresh.example/file.bin' },
{ type: 'start-now' }
);
await resumeStarted;
const pausing = useDownloadStore.getState().pauseDownload('replace-pause-race');
await Promise.resolve();
expect(ipc.invokeCommand).not.toHaveBeenCalledWith('pause_download', { id: 'replace-pause-race' });
releaseResume();
await replacing;
await pausing;
expect(ipc.invokeCommand).toHaveBeenCalledWith('pause_download', { id: 'replace-pause-race' });
expect(useDownloadStore.getState().downloads[0]).toMatchObject({
url: 'https://fresh.example/file.bin',
status: 'paused'
});
});
it('rejects empty and duplicate queue names', () => {
useDownloadStore.setState({
queues: [
@@ -608,6 +736,48 @@ describe('useDownloadStore', () => {
expect(useDownloadStore.getState().backendRegisteredIds.has('resume-generation')).toBe(true); // Re-registered by dispatchItem
});
it('coalesces duplicate resume actions while the backend transition is in flight', async () => {
useDownloadStore.setState({
downloads: [{
id: 'resume-double-submit',
url: 'http://test1',
fileName: 'f1',
destination: '/tmp',
status: 'paused',
category: 'Other',
dateAdded: '',
hasBeenDispatched: true
}] as any[],
backendRegisteredIds: new Set(['resume-double-submit'])
});
let releaseResume!: () => void;
const resumeReleased = new Promise<void>(resolve => {
releaseResume = resolve;
});
vi.mocked(ipc.invokeCommand).mockImplementation(async (cmd: string) => {
if (cmd === 'resume_download') {
await resumeReleased;
return true;
}
return undefined;
});
const first = useDownloadStore.getState().resumeDownload('resume-double-submit');
await vi.waitFor(() => {
expect(vi.mocked(ipc.invokeCommand).mock.calls.filter(([command]) => command === 'resume_download'))
.toHaveLength(1);
});
const second = useDownloadStore.getState().resumeDownload('resume-double-submit');
expect(second).toBe(first);
releaseResume();
await Promise.all([first, second]);
expect(
vi.mocked(ipc.invokeCommand).mock.calls.filter(([command]) => command === 'resume_download')
).toHaveLength(1);
});
it('does not re-enqueue when the existing resume RPC fails', async () => {
useDownloadStore.setState({
downloads: [
@@ -1185,6 +1355,48 @@ describe('useDownloadStore', () => {
).toBe(false);
});
it('coalesces duplicate redownloads and serializes a destructive removal behind them', async () => {
useDownloadStore.setState({
downloads: [{
id: 'redownload-double-submit',
url: 'https://example.com/file.bin',
fileName: 'file.bin',
destination: '/tmp',
status: 'paused',
category: 'Other',
dateAdded: ''
}] as any[]
});
let releaseRedownloadRemoval!: () => void;
const redownloadRemovalReleased = new Promise<void>(resolve => {
releaseRedownloadRemoval = resolve;
});
let removeCallCount = 0;
vi.mocked(ipc.invokeCommand).mockImplementation(async (cmd: string) => {
if (cmd === 'remove_download') {
removeCallCount += 1;
if (removeCallCount === 1) await redownloadRemovalReleased;
return undefined;
}
if (cmd === 'get_pending_order') return [];
return undefined;
});
const first = useDownloadStore.getState().redownload('redownload-double-submit');
await vi.waitFor(() => expect(removeCallCount).toBe(1));
const second = useDownloadStore.getState().redownload('redownload-double-submit');
const remove = useDownloadStore.getState().removeDownload('redownload-double-submit');
expect(second).toBe(first);
expect(removeCallCount).toBe(1);
releaseRedownloadRemoval();
await first;
await remove;
expect(removeCallCount).toBe(2);
expect(useDownloadStore.getState().downloads).toEqual([]);
});
it('starts and pauses all items regardless of legacy missing queue ids', async () => {
useDownloadStore.setState({
downloads: [
@@ -1510,7 +1722,9 @@ describe('useDownloadStore', () => {
{ url: 'https://mail.google.com/', cookies: 'SID=mail-session' },
{ url: 'https://accounts.google.com/', cookies: 'SID=account-session' }
],
media: false
media: false,
batch: false,
batch_name: null
});
const state = useDownloadStore.getState();
@@ -1546,7 +1760,9 @@ describe('useDownloadStore', () => {
headers: 'User-Agent: Firefox Test',
cookies: null,
cookie_scopes: null,
media: false
media: false,
batch: false,
batch_name: null
});
const state = useDownloadStore.getState();
@@ -1567,7 +1783,9 @@ describe('useDownloadStore', () => {
headers: 'User-Agent: Test',
cookies: 'session=secret',
cookie_scopes: null,
media: false
media: false,
batch: false,
batch_name: null
});
const state = useDownloadStore.getState();
@@ -1580,6 +1798,30 @@ describe('useDownloadStore', () => {
expect(state.pendingAddMediaUrls).toEqual([]);
});
it('tracks selected-link batch context without changing ordinary multi-link handoffs', async () => {
await useDownloadStore.getState().handleExtensionDownload({
urls: ['https://example.com/one.zip', 'https://example.com/two.zip'],
referer: 'https://example.com/gallery',
silent: false,
filename: null,
headers: null,
cookies: null,
cookie_scopes: null,
media: false,
batch: true,
batch_name: 'Example Gallery'
});
expect(useDownloadStore.getState().pendingAddBatch).toBe(true);
expect(useDownloadStore.getState().pendingAddBatchName).toBe('Example Gallery');
useDownloadStore.getState().toggleAddModal(false);
useDownloadStore.getState().openAddModalWithUrls(
'https://example.com/one.zip\nhttps://example.com/two.zip'
);
expect(useDownloadStore.getState().pendingAddBatch).toBe(false);
});
it('keeps each extension handoff context attached to its own URL while the Add Modal is open', async () => {
await useDownloadStore.getState().handleExtensionDownload({
urls: ['https://first.example/file.zip'],
@@ -1589,7 +1831,9 @@ describe('useDownloadStore', () => {
headers: 'User-Agent: First Browser',
cookies: 'first=session',
cookie_scopes: null,
media: false
media: false,
batch: false,
batch_name: null
});
await useDownloadStore.getState().handleExtensionDownload({
urls: ['https://second.example/file.zip'],
@@ -1599,7 +1843,9 @@ describe('useDownloadStore', () => {
headers: 'User-Agent: Second Browser',
cookies: 'second=session',
cookie_scopes: null,
media: false
media: false,
batch: false,
batch_name: null
});
const state = useDownloadStore.getState();
@@ -1633,10 +1879,12 @@ describe('useDownloadStore', () => {
referer: 'https://adult.example/watch/123',
silent: false,
filename: null,
headers: `Cookie: stale=${'x'.repeat(64 * 1024)}\nUser-Agent: Firefox Test`,
headers: `Cookie: stale=${'x'.repeat(64 * 1024)}\nCookie2: stale=1\nAuthorization: Bearer stale\nProxy-Authorization: Basic stale\nSet-Cookie: stale=1\nSet-Cookie2: stale=1\nUser-Agent: Firefox Test`,
cookies: `oversized=${'x'.repeat(64 * 1024)}`,
cookie_scopes: null,
media: true
media: true,
batch: false,
batch_name: null
});
const state = useDownloadStore.getState();
@@ -1656,7 +1904,9 @@ describe('useDownloadStore', () => {
headers: null,
cookies: 'session=secret',
cookie_scopes: null,
media: false
media: false,
batch: false,
batch_name: null
});
expect(useDownloadStore.getState().pendingAddCookies).toBe('session=secret');
@@ -1673,7 +1923,9 @@ describe('useDownloadStore', () => {
cookie_scopes: [
{ 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)
@@ -1690,7 +1942,9 @@ describe('useDownloadStore', () => {
headers: 'Authorization: secret',
cookies: 'session=secret',
cookie_scopes: null,
media: false
media: false,
batch: false,
batch_name: null
});
await useDownloadStore.getState().handleExtensionDownload({
urls: [url],
@@ -1700,7 +1954,9 @@ describe('useDownloadStore', () => {
headers: null,
cookies: null,
cookie_scopes: null,
media: false
media: false,
batch: false,
batch_name: null
});
expect(useDownloadStore.getState().pendingAddRequestContexts[url]).toEqual({
@@ -1728,7 +1984,9 @@ describe('useDownloadStore', () => {
headers: 'User-Agent: Firefox Test',
cookies: 'session=secret',
cookie_scopes: null,
media: true
media: true,
batch: false,
batch_name: null
});
expect(useDownloadStore.getState().pendingAddMediaUrls).toEqual([
@@ -1748,7 +2006,9 @@ describe('useDownloadStore', () => {
headers: 'User-Agent: Firefox Test',
cookies: null,
cookie_scopes: null,
media: false
media: false,
batch: false,
batch_name: null
});
expect(useDownloadStore.getState().pendingAddMediaUrls).toEqual([]);
+310 -161
View File
@@ -14,6 +14,8 @@ import {
resolveCategoryDestination
} from '../utils/downloadLocations';
import { canPauseDownload, canStartDownload } from '../utils/downloadActions';
import { updateDockBadge } from '../utils/dockBadge';
import i18n from '../i18n';
export type { DownloadCategory } from '../utils/downloads';
@@ -22,6 +24,12 @@ const downloadLifecycleGenerations = new Map<string, bigint>();
const queueReorderPromises = new Map<string, Promise<void>>();
const queueStartPromises = new Map<string, Promise<string[]>>();
const queueControlGenerations = new Map<string, number>();
type DownloadLifecycleOperation = {
kind: string;
promise: Promise<unknown>;
};
const downloadLifecycleOperations = new Map<string, DownloadLifecycleOperation>();
const preemptDispatch = ['dispatch'] as const;
let pendingStartupResume: Promise<void> | null = null;
type DownloadControlIntent = 'pause' | 'resume';
@@ -47,6 +55,49 @@ export const clearDownloadControlIntents = (): void => {
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 pending = pendingStartupResume;
if (pending) await pending.catch(() => undefined);
@@ -158,12 +209,21 @@ export class SystemProxyResolutionError extends Error {
const isSystemProxyConfigurationError = (error: unknown): boolean =>
error instanceof SystemProxyResolutionError;
const stripCookieHeaders = (value: string | null | undefined): string =>
const stripSensitiveMediaHeaders = (value: string | null | undefined): string =>
(value || '')
.split(/\r?\n/)
.filter(line => {
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')
.trim();
@@ -192,7 +252,7 @@ const speedLimitForDispatch = (
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();
if (backendDispatchPromises.has(id)) return backendDispatchPromises.get(id)!;
@@ -204,6 +264,7 @@ export async function dispatchItem(id: string, proxyOverride?: string | null): P
const item = state.downloads.find(d => d.id === id);
if (!item) return false;
if (state.backendRegisteredIds.has(id)) return true;
if (!['ready', 'staged', 'failed', 'queued'].includes(item.status)) return false;
lifecycleGeneration = currentDownloadLifecycle(id);
const settings = useSettingsStore.getState();
@@ -303,6 +364,18 @@ export async function dispatchItem(id: string, proxyOverride?: string | null): P
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 => {
const trimmedHost = host.trim();
const normalizedPort = Number.isFinite(port) ? Math.trunc(port) : NaN;
@@ -424,7 +497,7 @@ export const getSiteLogin = (url: string, settings: ReturnType<typeof useSetting
const syncSystemIntegrations = () => {
const settings = useSettingsStore.getState();
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 (
@@ -594,6 +667,8 @@ interface DownloadState {
pendingAddHeaders: string;
pendingAddCookies: string;
pendingAddMediaUrls: string[];
pendingAddBatch: boolean;
pendingAddBatchName: string;
pendingAddRequestContexts: Record<string, PendingAddRequestContext>;
pendingAddRequestVersion: number;
selectedPropertiesDownloadId: string | null;
@@ -605,7 +680,9 @@ interface DownloadState {
headers?: string | null,
cookies?: string | null,
media?: boolean,
cookieScopes?: ExtensionCookieScope[] | null
cookieScopes?: ExtensionCookieScope[] | null,
batch?: boolean,
batchName?: string | null
) => void;
handleExtensionDownload: (request: ExtensionDownloadRequest) => Promise<void>;
deleteModalState: DeleteModalState;
@@ -613,6 +690,7 @@ interface DownloadState {
closeDeleteModal: () => void;
setSelectedPropertiesDownloadId: (id: string | null) => void;
addDownload: (item: DownloadDraft, action: AddDownloadAction) => Promise<boolean>;
replaceDownload: (id: string, updates: Partial<DownloadItem>, action: AddDownloadAction) => Promise<boolean>;
updateDownload: (id: string, updates: Partial<DownloadItem>) => void;
removeDownload: (id: string, deleteFile?: boolean, preserveResumable?: boolean) => Promise<void>;
pauseDownload: (id: string) => Promise<void>;
@@ -631,7 +709,115 @@ 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 });
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: [],
queues: [{ id: MAIN_QUEUE_ID, name: 'Main Queue', isMain: true }],
pendingOrder: [],
@@ -746,6 +932,8 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
pendingAddHeaders: '',
pendingAddCookies: '',
pendingAddMediaUrls: [],
pendingAddBatch: false,
pendingAddBatchName: '',
pendingAddRequestContexts: {},
pendingAddRequestVersion: 0,
selectedPropertiesDownloadId: null,
@@ -765,12 +953,24 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
pendingAddHeaders: '',
pendingAddCookies: '',
pendingAddMediaUrls: [],
pendingAddBatch: false,
pendingAddBatchName: '',
pendingAddRequestContexts: {},
// Invalidate any in-flight Add-modal handoff even when the modal is
// opened or closed without URLs.
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 existingUrls = isAppending ? state.pendingAddUrls : '';
const mergedUrls = existingUrls ? `${existingUrls}\n${urls}` : urls;
@@ -778,6 +978,13 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
const cleanFilename = filename?.trim() || '';
const cleanHeaders = headers?.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
?.map(scope => ({
url: scope.url.trim(),
@@ -822,6 +1029,8 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
pendingAddHeaders: cleanHeaders,
pendingAddCookies: cleanCookies,
pendingAddMediaUrls,
pendingAddBatch: nextBatch,
pendingAddBatchName: nextBatchName,
pendingAddRequestContexts,
pendingAddRequestVersion: requestVersion
};
@@ -835,7 +1044,7 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
// extension builds; ordinary captured downloads retain their cookies.
const cookies = request.media === true ? null : request.cookies;
const headers = request.media === true
? stripCookieHeaders(request.headers) || null
? stripSensitiveMediaHeaders(request.headers) || null
: request.headers;
get().openAddModalWithUrls(
@@ -845,7 +1054,9 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
headers,
cookies,
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 }),
@@ -888,53 +1099,30 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
}
return false;
},
applyProperties: async (id, updates) => {
await waitForPendingStartupResume();
const wasDispatching = await invalidateAndWaitForDispatch(id);
const state = get();
const item = state.downloads.find(d => d.id === id);
if (!item) return;
if (item.status === 'downloading' || item.status === 'processing' || item.status === 'retrying') {
throw new Error("Cannot change properties while transfer is active. Pause it first.");
}
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) }));
replaceDownload: (id, updates, action) => runDownloadLifecycleOperation(
id,
'replace',
async () => {
if (!get().downloads.some(download => download.id === id)) return false;
await applyPropertiesInternal(id, updates);
if (!get().downloads.some(download => download.id === id)) return false;
if (action.type === 'start-now') {
const resumed = await resumeDownloadInternal(id);
if (resumed) get().updateDownload(id, { hasBeenDispatched: true });
return resumed;
}
state.updateDownload(id, updates);
if (isRegistered || wasDispatching) {
const dispatched = await dispatchItem(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);
}
},
return true;
},
false,
preemptDispatch
),
applyProperties: (id, updates) => runDownloadLifecycleOperation(
id,
'properties',
() => applyPropertiesInternal(id, updates),
false,
preemptDispatch
),
updateDownload: (id, updates) => {
set((state) => ({
downloads: state.downloads.map(d => {
@@ -958,7 +1146,10 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
syncSystemIntegrations();
}
},
removeDownload: async (id, deleteFile = false, preserveResumable = false) => {
removeDownload: (id, deleteFile = false, preserveResumable = false) => runDownloadLifecycleOperation(
id,
`remove:${deleteFile}:${preserveResumable}`,
async () => {
await waitForPendingStartupResume();
clearDownloadControlIntent(id);
const { pendingDispatch } = await invalidateDispatch(id);
@@ -985,8 +1176,11 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
useDownloadProgressStore.getState().clearDownloadProgress(id);
info(`Download ${id} removed`);
syncSystemIntegrations();
},
pauseDownload: async (id) => {
},
true,
preemptDispatch
),
pauseDownload: (id) => runDownloadLifecycleOperation(id, 'pause', async () => {
await waitForPendingStartupResume();
if (!get().downloads.some(download => download.id === id)) return;
setDownloadControlIntent(id, 'pause');
@@ -1006,20 +1200,22 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
} finally {
clearDownloadControlIntent(id, 'pause');
}
},
redownload: async (id) => {
}, true, preemptDispatch),
redownload: (id) => runDownloadLifecycleOperation(id, 'redownload', async () => {
await waitForPendingStartupResume();
const targetItem = get().downloads.find(d => d.id === id);
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)) {
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();
if (!url) throw new Error('Cannot redownload: original URL is missing.');
if (!url) throw new Error(i18n.t($ => $.downloadTable.originalUrlMissing));
setDownloadControlIntent(id, 'resume');
await invalidateAndWaitForDispatch(id);
@@ -1046,7 +1242,7 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
dateAdded: new Date().toISOString()
});
if (!await dispatchItem(id)) {
if (!await dispatchItemInternal(id)) {
console.error("Failed to enqueue redownload");
get().updateDownload(id, { status: 'failed' });
clearDownloadControlIntent(id, 'resume');
@@ -1054,65 +1250,14 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
get().updateDownload(id, { hasBeenDispatched: true });
info(`Download ${id} redownloaded (queued)`);
}
},
resumeDownload: async (id) => {
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 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;
}
},
}, true, preemptDispatch),
resumeDownload: (id) => runDownloadLifecycleOperation(
id,
'resume',
() => resumeDownloadInternal(id),
true,
preemptDispatch
),
startQueue: (queueId) => {
const requestedGeneration = currentQueueControlGeneration(queueId);
const previousOperation = queueStartPromises.get(queueId) ?? Promise.resolve([]);
@@ -1279,46 +1424,49 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
},
assignToQueue: async (ids, queueId) => {
await waitForPendingStartupResume();
const selectedIds = new Set(ids);
const selected = get().downloads.filter(item => selectedIds.has(item.id));
const locked = selected.find(item => isActiveDownloadStatus(item.status) && item.status !== 'queued');
if (locked) {
throw new Error(`Pause ${locked.fileName} before moving it to another queue.`);
}
const lockedIds = ids.filter(id => get().downloads.some(item => item.id === id));
return runDownloadLifecycleOperations(lockedIds, 'assign-to-queue', async () => {
const selectedIds = new Set(ids);
const selected = get().downloads.filter(item => selectedIds.has(item.id));
const locked = selected.find(item => isActiveDownloadStatus(item.status) && item.status !== 'queued');
if (locked) {
throw new Error(i18n.t($ => $.downloadTable.pauseBeforeMove, { file: locked.fileName }));
}
const movableSelected = selected.filter(item => item.status !== 'completed');
const movableIds = new Set(movableSelected.map(item => item.id));
await Promise.all(movableSelected.map(item => invalidateAndWaitForDispatch(item.id)));
const movableSelected = selected.filter(item => item.status !== 'completed');
const movableIds = new Set(movableSelected.map(item => item.id));
await Promise.all(movableSelected.map(item => invalidateAndWaitForDispatch(item.id)));
for (const item of get().downloads.filter(item => movableIds.has(item.id))) {
if (!get().backendRegisteredIds.has(item.id)) continue;
// The UI can still say queued while a dispatch has already reached
// Aria2/media. Detach through the backend lifecycle owner for every
// registered item; remove_from_queue only handles the pending list.
await invoke('detach_download_for_reconfigure', { id: item.id });
get().unregisterBackendIds([item.id]);
set(state => ({ pendingOrder: state.pendingOrder.filter(value => value !== item.id) }));
}
for (const item of get().downloads.filter(item => movableIds.has(item.id))) {
if (!get().backendRegisteredIds.has(item.id)) continue;
// The UI can still say queued while a dispatch has already reached
// Aria2/media. Detach through the backend lifecycle owner for every
// registered item; remove_from_queue only handles the pending list.
await invoke('detach_download_for_reconfigure', { id: item.id });
get().unregisterBackendIds([item.id]);
set(state => ({ pendingOrder: state.pendingOrder.filter(value => value !== item.id) }));
}
const queueItems = get().downloads.filter(item =>
!movableIds.has(item.id) &&
(item.queueId || MAIN_QUEUE_ID) === queueId
);
const maxPos = queueItems.reduce((max, d) => Math.max(max, d.queuePosition ?? 0), -1);
const nextPosition = maxPos + 1;
set(state => ({
downloads: state.downloads.map(item =>
movableIds.has(item.id) && item.status !== 'completed'
? {
...item,
queueId,
queuePosition: nextPosition + movableSelected.findIndex(selectedItem => selectedItem.id === item.id),
status: 'staged' as const,
hasBeenDispatched: false
}
: item
)
}));
const queueItems = get().downloads.filter(item =>
!movableIds.has(item.id) &&
(item.queueId || MAIN_QUEUE_ID) === queueId
);
const maxPos = queueItems.reduce((max, d) => Math.max(max, d.queuePosition ?? 0), -1);
const nextPosition = maxPos + 1;
set(state => ({
downloads: state.downloads.map(item =>
movableIds.has(item.id) && item.status !== 'completed'
? {
...item,
queueId,
queuePosition: nextPosition + movableSelected.findIndex(selectedItem => selectedItem.id === item.id),
status: 'staged' as const,
hasBeenDispatched: false
}
: item
)
}));
});
},
addQueue: (name) => {
const normalizedName = name.trim();
@@ -1580,7 +1728,8 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
throw e;
}
}
}));
};
});
let lastSavedDownloads = '';
let isSavingDownloads = false;
+19
View File
@@ -15,6 +15,12 @@ vi.mock('../utils/logger', () => ({
info: vi.fn()
}));
describe('last used download directory preference', () => {
it('is disabled by default', () => {
expect(useSettingsStore.getState().rememberLastUsedDownloadDirectory).toBe(false);
});
});
describe('useSettingsStore global speed limit persistence', () => {
beforeEach(() => {
vi.clearAllMocks();
@@ -31,6 +37,19 @@ describe('useSettingsStore global speed limit persistence', () => {
});
});
describe('useSettingsStore dock badge synchronization', () => {
it('increments the badge sync version for every toggle without issuing out-of-band clears', () => {
vi.clearAllMocks();
const initialVersion = useSettingsStore.getState().dockBadgeSyncVersion;
useSettingsStore.getState().setShowDockBadge(false);
useSettingsStore.getState().setShowDockBadge(true);
expect(useSettingsStore.getState().dockBadgeSyncVersion).toBe(initialVersion + 2);
expect(ipc.invokeCommand).not.toHaveBeenCalledWith('update_dock_badge', { count: 0 });
});
});
describe('useSettingsStore credential-store startup flow', () => {
beforeEach(() => {
vi.clearAllMocks();
+58 -5
View File
@@ -19,6 +19,8 @@ import {
normalizeDownloadLocationSettings
} from '../utils/downloadLocations';
import { normalizeSpeedLimitForBackend } from '../utils/downloads';
import i18n from '../i18n';
import { isAppLocalePreference, type AppLocalePreference } from '../i18n/locales';
let settingsQueue: Promise<void> = Promise.resolve();
let pairingTokenHydrationRequest: Promise<PairingTokenHydration> | null = null;
@@ -68,6 +70,7 @@ const notifySettingsPersistenceError = () => {
const THEME_VALUES = ['system', 'light', 'dark', 'dracula', 'nord'] as const;
const APP_FONT_SIZE_VALUES = ['small', 'standard', 'large'] as const;
const LIST_ROW_DENSITY_VALUES = ['compact', 'standard', 'relaxed'] as const;
const SIDEBAR_POSITION_VALUES = ['auto', 'left', 'right'] as const;
const PROXY_MODE_VALUES = ['none', 'system', 'custom'] as const;
const MEDIA_COOKIE_SOURCE_VALUES = [
'none', 'safari', 'chrome', 'chromium', 'firefox', 'edge', 'brave', 'opera', 'vivaldi', 'whale'
@@ -77,6 +80,7 @@ const SETTINGS_TAB_VALUES = [
] as const;
type PersistedSettingsSnapshot = PersistedSettings & {
language: AppLocalePreference;
keychainPromptDismissed: boolean;
keychainAccessVersion: string;
};
@@ -161,18 +165,25 @@ export type {
Theme
};
export type SidebarPosition = 'auto' | 'left' | 'right';
export interface SettingsState {
theme: Theme;
language: AppLocalePreference;
baseDownloadFolder: string;
categorySubfoldersEnabled: boolean;
categorySubfolders: Record<string, string>;
categoryDirectoryOverrides: Record<string, string>;
rememberLastUsedDownloadDirectory: boolean;
/** Session-only path selected in the Add window; intentionally not persisted. */
lastUsedDownloadDirectory: string | null;
approvedDownloadRoots: string[];
maxConcurrentDownloads: number;
globalSpeedLimit: string;
speedLimitPresetValues: number[];
logsEnabled: boolean;
isSidebarVisible: boolean;
sidebarPosition: SidebarPosition;
activeView: ActiveView;
activeSettingsTab: SettingsTab;
scheduler: SchedulerSettings;
@@ -192,6 +203,8 @@ export interface SettingsState {
appFontSize: AppFontSize;
listRowDensity: ListRowDensity;
showDockBadge: boolean;
/** Forces the App-level badge effect to run for every toggle request. */
dockBadgeSyncVersion: number;
showMenuBarIcon: boolean;
proxyMode: ProxyMode;
proxyHost: string;
@@ -211,12 +224,14 @@ export interface SettingsState {
showKeychainModal: boolean;
setTheme: (theme: Theme) => void;
setLanguage: (language: AppLocalePreference) => void;
setBaseDownloadFolder: (path: string) => void;
approveDownloadRoot: (path: string) => Promise<string>;
setMaxConcurrentDownloads: (count: number) => void;
setGlobalSpeedLimit: (limit: string) => Promise<void>;
setSpeedLimitPresetValues: (values: number[]) => void;
setLogsEnabled: (enabled: boolean) => void;
setSidebarPosition: (position: SidebarPosition) => void;
setActiveView: (view: ActiveView) => void;
setActiveSettingsTab: (tab: SettingsTab) => void;
setScheduler: (settings: SchedulerSettings) => void;
@@ -244,6 +259,8 @@ export interface SettingsState {
setAskWhereToSaveEachFile: (ask: boolean) => void;
setPreventsSleepWhileDownloading: (prevent: boolean) => void;
setMediaCookieSource: (source: MediaCookieSource) => void;
setRememberLastUsedDownloadDirectory: (enabled: boolean) => void;
setLastUsedDownloadDirectory: (path: string) => void;
setCategorySubfoldersEnabled: (enabled: boolean) => void;
setCategorySubfolder: (category: string, subfolder: string) => void;
setCategoryDirectoryOverride: (category: string, path?: string) => void;
@@ -263,10 +280,13 @@ export const useSettingsStore = create<SettingsState>()(
persist(
(set, get) => ({
theme: 'system',
language: 'system',
baseDownloadFolder: '~/Downloads',
categorySubfoldersEnabled: true,
categorySubfolders: { ...DEFAULT_CATEGORY_SUBFOLDERS },
categoryDirectoryOverrides: {},
rememberLastUsedDownloadDirectory: false,
lastUsedDownloadDirectory: null,
approvedDownloadRoots: [],
maxConcurrentDownloads: 3,
globalSpeedLimit: '',
@@ -275,6 +295,7 @@ export const useSettingsStore = create<SettingsState>()(
activeView: 'downloads',
activeSettingsTab: 'downloads',
isSidebarVisible: true,
sidebarPosition: 'auto',
scheduler: {
enabled: false,
startTime: '00:00',
@@ -301,6 +322,7 @@ export const useSettingsStore = create<SettingsState>()(
appFontSize: 'standard',
listRowDensity: 'standard',
showDockBadge: true,
dockBadgeSyncVersion: 0,
showMenuBarIcon: true,
proxyMode: 'none',
proxyHost: '',
@@ -320,6 +342,7 @@ export const useSettingsStore = create<SettingsState>()(
showKeychainModal: false,
setTheme: (theme) => { info('Settings updated: theme'); set({ theme }); },
setLanguage: (language) => { info('Settings updated: language'); set({ language }); },
setBaseDownloadFolder: (path) => {
info('Settings updated: baseDownloadFolder');
set({ baseDownloadFolder: path });
@@ -348,6 +371,7 @@ export const useSettingsStore = create<SettingsState>()(
},
setSpeedLimitPresetValues: (speedLimitPresetValues) => set({ speedLimitPresetValues }),
setLogsEnabled: (logsEnabled) => set({ logsEnabled }),
setSidebarPosition: (sidebarPosition) => set({ sidebarPosition }),
setActiveView: (view) => set({ activeView: view }),
setActiveSettingsTab: (activeSettingsTab) => set({ activeSettingsTab }),
setScheduler: (scheduler) => set({ scheduler }),
@@ -371,8 +395,10 @@ export const useSettingsStore = create<SettingsState>()(
setAppFontSize: (appFontSize) => set({ appFontSize }),
setListRowDensity: (listRowDensity) => set({ listRowDensity }),
setShowDockBadge: (showDockBadge) => {
set({ showDockBadge });
if (!showDockBadge) invoke('update_dock_badge', { count: 0 }).catch(console.error);
set(state => ({
showDockBadge,
dockBadgeSyncVersion: state.dockBadgeSyncVersion + 1
}));
},
setShowMenuBarIcon: (showMenuBarIcon) => set({ showMenuBarIcon }),
setProxyMode: (proxyMode) => set({ proxyMode }),
@@ -389,6 +415,18 @@ export const useSettingsStore = create<SettingsState>()(
set({ preventsSleepWhileDownloading });
},
setMediaCookieSource: (mediaCookieSource) => { info('Settings updated: mediaCookieSource'); set({ mediaCookieSource }); },
setRememberLastUsedDownloadDirectory: (rememberLastUsedDownloadDirectory) => {
info('Settings updated: rememberLastUsedDownloadDirectory');
set({
rememberLastUsedDownloadDirectory,
...(rememberLastUsedDownloadDirectory ? {} : { lastUsedDownloadDirectory: null })
});
},
setLastUsedDownloadDirectory: (path) => {
const trimmedPath = path.trim();
if (!trimmedPath) return;
set({ lastUsedDownloadDirectory: trimmedPath });
},
setCategorySubfoldersEnabled: (categorySubfoldersEnabled) => {
info('Settings updated: categorySubfoldersEnabled');
set({ categorySubfoldersEnabled });
@@ -425,11 +463,11 @@ export const useSettingsStore = create<SettingsState>()(
const current = get();
if (!current.keychainAccessReady && !current.isPairingTokenPersistent) {
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');
if (!result.persistent) {
throw new Error(result.error || 'Credential store access is unavailable.');
throw new Error(result.error || i18n.t($ => $.keychain.storeUnavailable));
}
set({
extensionPairingToken: result.token,
@@ -474,7 +512,7 @@ export const useSettingsStore = create<SettingsState>()(
{
name: 'firelink-settings',
storage: createJSONStorage(() => tauriStorage),
version: 3,
version: 5,
migrate: (persistedState) => {
if (!persistedState || typeof persistedState !== 'object') {
return persistedState as SettingsState;
@@ -510,16 +548,19 @@ export const useSettingsStore = create<SettingsState>()(
},
partialize: (state): PersistedSettingsSnapshot => ({
theme: state.theme,
language: state.language,
baseDownloadFolder: state.baseDownloadFolder,
categorySubfoldersEnabled: state.categorySubfoldersEnabled,
categorySubfolders: state.categorySubfolders,
categoryDirectoryOverrides: state.categoryDirectoryOverrides,
rememberLastUsedDownloadDirectory: state.rememberLastUsedDownloadDirectory,
approvedDownloadRoots: state.approvedDownloadRoots,
maxConcurrentDownloads: state.maxConcurrentDownloads,
globalSpeedLimit: state.globalSpeedLimit,
speedLimitPresetValues: state.speedLimitPresetValues,
logsEnabled: state.logsEnabled,
isSidebarVisible: state.isSidebarVisible,
sidebarPosition: state.sidebarPosition,
activeSettingsTab: state.activeSettingsTab,
scheduler: state.scheduler,
schedulerRunning: state.schedulerRunning,
@@ -561,16 +602,24 @@ export const useSettingsStore = create<SettingsState>()(
...persisted,
...locations,
extensionPairingToken: currentState.extensionPairingToken,
// Never hydrate the remembered Add-window path from persisted data.
lastUsedDownloadDirectory: currentState.lastUsedDownloadDirectory,
keychainAccessReady: currentState.keychainAccessReady,
theme: isAllowedSetting(THEME_VALUES, persisted.theme)
? persisted.theme
: currentState.theme,
language: isAppLocalePreference(persisted.language)
? persisted.language
: currentState.language,
appFontSize: isAllowedSetting(APP_FONT_SIZE_VALUES, persisted.appFontSize)
? persisted.appFontSize
: currentState.appFontSize,
listRowDensity: isAllowedSetting(LIST_ROW_DENSITY_VALUES, persisted.listRowDensity)
? persisted.listRowDensity
: currentState.listRowDensity,
sidebarPosition: isAllowedSetting(SIDEBAR_POSITION_VALUES, persisted.sidebarPosition)
? persisted.sidebarPosition
: currentState.sidebarPosition,
proxyMode: isAllowedSetting(PROXY_MODE_VALUES, persisted.proxyMode)
? persisted.proxyMode
: currentState.proxyMode,
@@ -586,6 +635,10 @@ export const useSettingsStore = create<SettingsState>()(
persisted.autoAddClipboardLinks,
currentState.autoAddClipboardLinks
),
rememberLastUsedDownloadDirectory: persistedBoolean(
persisted.rememberLastUsedDownloadDirectory,
currentState.rememberLastUsedDownloadDirectory
),
showDockBadge: persistedBoolean(persisted.showDockBadge, currentState.showDockBadge),
showMenuBarIcon: persistedBoolean(persisted.showMenuBarIcon, currentState.showMenuBarIcon),
askWhereToSaveEachFile: persistedBoolean(
+16
View File
@@ -12,6 +12,7 @@ import {
updateRowIfCurrent,
type AddDownloadDraftRow
} from './addDownloadMetadata';
import i18n, { changeAppLocale } from '../i18n';
const row = (
overrides: Partial<AddDownloadDraftRow> = {}
@@ -537,4 +538,19 @@ describe('add download metadata workflow', () => {
row({ status: 'invalid' })
])).toContain('Correct or remove 1 invalid URL');
});
it('uses few forms for Russian and Ukrainian metadata summaries', async () => {
const originalLanguage = i18n.language;
const twoReadyRows = [row(), row({ id: 'row-2' })];
try {
await changeAppLocale('ru');
expect(metadataSummaryMessage(twoReadyRows)).toBe('Готово к добавлению: 2 загрузки.');
await changeAppLocale('uk');
expect(metadataSummaryMessage(twoReadyRows)).toBe('Готово до додавання: 2 завантаження.');
} finally {
await changeAppLocale(originalLanguage === 'uk' || originalLanguage === 'ru' ? originalLanguage : 'en');
}
});
});
+89 -10
View File
@@ -4,6 +4,8 @@ import {
isMediaUrl
} from './downloads';
import type { MediaPlaylistMetadata } from '../bindings/MediaPlaylistMetadata';
import i18n from '../i18n';
import { localePluralVariant } from '../i18n/locales';
export type MetadataStatus = 'loading' | 'ready' | 'metadata-error' | 'invalid';
@@ -361,20 +363,77 @@ export const mediaFileNameForSelectedFormat = (
return canonicalizeDownloadFileName(`${baseName}.${selectedExt}`);
};
export const metadataSummaryMessage = (rows: AddDownloadDraftRow[]): string => {
if (rows.length === 0) return 'Paste one or more links.';
export type MetadataSummaryState =
| { type: 'empty' }
| { type: 'none-selected' }
| { type: 'invalid'; count: number }
| { type: 'loading'; count: number }
| { type: 'unsafe'; count: number }
| { type: 'media-error'; count: number }
| { type: 'all-error' }
| { type: 'fallback'; ready: number; failed: number }
| { type: 'ready'; count: number };
export const metadataSummaryState = (rows: AddDownloadDraftRow[]): MetadataSummaryState => {
if (rows.length === 0) return { type: 'empty' };
const selectedRows = rows.filter(row => row.selected !== false);
if (selectedRows.length === 0) return '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;
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;
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;
@@ -382,16 +441,36 @@ export const metadataSummaryMessage = (rows: AddDownloadDraftRow[]): string => {
const blocked = selectedRows.filter(row => row.metadataBlockedReason === 'unsafe-url').length;
const ready = selectedRows.filter(row => row.status === 'ready').length;
if (blocked > 0) {
return `Remove ${blocked} unsafe URL${blocked === 1 ? '' : 's'} before continuing.`;
return pluralMessage(
blocked,
() => i18n.t($ => $.addDownloads.removeUnsafeOne, { count: blocked }),
() => i18n.t($ => $.addDownloads.removeUnsafeFew, { count: blocked }),
() => i18n.t($ => $.addDownloads.removeUnsafeMany, { count: blocked })
);
}
if (failedMedia > 0) {
return `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) {
return 'Metadata is unavailable. Downloads can still be added using fallback details.';
return i18n.t($ => $.addDownloads.metadataUnavailableFallback);
}
if (failed > 0) {
return `${ready} download${ready === 1 ? '' : 's'} ready; ${failed} will use fallback filename and unknown size.`;
return pluralMessage(
ready,
() => i18n.t($ => $.addDownloads.fallbackReadyOne, { ready, failed }),
() => i18n.t($ => $.addDownloads.fallbackReadyFew, { ready, failed }),
() => i18n.t($ => $.addDownloads.fallbackReadyMany, { ready, failed })
);
}
return `Ready to add ${ready} download${ready === 1 ? '' : 's'}.`;
return pluralMessage(
ready,
() => i18n.t($ => $.addDownloads.readyToAddOne, { count: ready }),
() => i18n.t($ => $.addDownloads.readyToAddFew, { count: ready }),
() => i18n.t($ => $.addDownloads.readyToAddMany, { count: ready })
);
};
+36
View File
@@ -0,0 +1,36 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import * as ipc from '../ipc';
import { updateDockBadge } from './dockBadge';
vi.mock('../ipc', () => ({
invokeCommand: vi.fn()
}));
describe('dock badge synchronization', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(ipc.invokeCommand).mockImplementation(async command => (
command === 'begin_dock_badge_session' ? 1 : undefined
));
});
it('attaches increasing generations to concurrent updates', async () => {
await Promise.all([updateDockBadge(3), updateDockBadge(0)]);
const calls = vi.mocked(ipc.invokeCommand).mock.calls;
expect(calls).toHaveLength(3);
expect(calls[0]).toEqual(['begin_dock_badge_session']);
const badgeCalls = calls.slice(1);
expect(badgeCalls[0]).toEqual([
'update_dock_badge',
{ count: 3, generation: expect.any(Number), session: 1 }
]);
expect(badgeCalls[1]).toEqual([
'update_dock_badge',
{ count: 0, generation: expect.any(Number), session: 1 }
]);
const firstBadgeArgs = badgeCalls[0][1] as { generation: number };
const secondBadgeArgs = badgeCalls[1][1] as { generation: number };
expect(secondBadgeArgs.generation).toBe(firstBadgeArgs.generation + 1);
});
});
+25
View File
@@ -0,0 +1,25 @@
import { invokeCommand } from '../ipc';
let dockBadgeGeneration = 0;
let dockBadgeSessionRequest: Promise<number> | null = null;
const getDockBadgeSession = (): Promise<number> => {
if (!dockBadgeSessionRequest) {
const request = invokeCommand('begin_dock_badge_session');
dockBadgeSessionRequest = request.catch(error => {
dockBadgeSessionRequest = null;
throw error;
});
}
return dockBadgeSessionRequest;
};
/**
* Attach the backend session and a per-session generation to every badge
* update so stale main-thread callbacks cannot overwrite a newer session.
*/
export const updateDockBadge = async (count: number): Promise<void> => {
const session = await getDockBadgeSession();
const generation = ++dockBadgeGeneration;
await invokeCommand('update_dock_badge', { count, generation, session });
};
+65
View File
@@ -11,10 +11,14 @@ vi.mock('@tauri-apps/api/path', () => ({
import {
downloadLocationEquals,
DEFAULT_CATEGORY_SUBFOLDERS,
deriveBatchFolderName,
formatDerivedCategoryPath,
normalizeCategorySubfolder,
normalizeDownloadLocationSettings,
resolveInitialAddWindowLocation,
resolveCategoryDestination,
resolveSubfolderDestination,
sanitizeBatchFolderName,
subfolderFromDerivedCategoryPath
} from './downloadLocations';
@@ -24,6 +28,67 @@ describe('download locations', () => {
expect(downloadLocationEquals('/Users/Test', 'Movie.MP4', '/users/test', 'movie.mp4', 'macos')).toBe(false);
expect(downloadLocationEquals('/home/Test', 'Movie.MP4', '/home/test', 'movie.mp4', 'linux')).toBe(false);
});
it('uses a remembered Add-window directory only when the setting is enabled', () => {
expect(resolveInitialAddWindowLocation(
'D:\\Downloads',
true,
'D:\\Course_Videos'
)).toEqual({ path: 'D:\\Course_Videos', isManual: true });
expect(resolveInitialAddWindowLocation(
'D:\\Downloads',
false,
'D:\\Course_Videos'
)).toEqual({ path: 'D:\\Downloads', isManual: false });
});
it('falls back to the normalized base folder when no directory was remembered', () => {
expect(resolveInitialAddWindowLocation(' ', true, null))
.toEqual({ path: '~/Downloads', isManual: false });
});
it('derives safe batch folder names from title, referer, and timestamp fallback', () => {
expect(deriveBatchFolderName(
'Gallery / Chapter: 1',
'https://example.com/gallery'
)).toBe('Gallery - Chapter- 1');
expect(deriveBatchFolderName(
'New Tab',
'https://example.com/gallery/part-1?token=secret'
)).toBe('example.com-gallery-part-1');
expect(deriveBatchFolderName(
'New Tab',
'https://example.com/gallery',
new Date('2026-07-20T12:34:56.789Z'),
['example.part1.rar', 'example.part2.rar']
)).toBe('example');
expect(deriveBatchFolderName(
'CON',
null,
new Date('2026-07-20T12:34:56.789Z')
)).toBe('batch-CON');
expect(deriveBatchFolderName(
'',
null,
new Date('2026-07-20T12:34:56.789Z')
)).toBe('firelink-batch-2026-07-20-12-34-56-789');
expect(deriveBatchFolderName(
'',
null,
new Date('2026-07-20T12:34:56.789Z'),
['example.part1.rar', 'example.part2.rar']
)).toBe('example');
expect(sanitizeBatchFolderName('../Example: Parts')).toBe('Example- Parts');
expect(sanitizeBatchFolderName('😀'.repeat(100))).toBe('😀'.repeat(96));
});
it('places the optional folder below an existing category destination', async () => {
expect(await resolveSubfolderDestination(
'/Users/test/Downloads/Compressed',
'Example: Parts'
)).toBe('/Users/test/Downloads/Compressed/Example- Parts');
});
beforeEach(() => {
vi.clearAllMocks();
});
+107
View File
@@ -48,6 +48,113 @@ interface LegacyDownloadLocationSettings {
downloadDirectories?: unknown;
}
export interface AddWindowLocationSuggestion {
path: string;
isManual: boolean;
}
const MAX_BATCH_FOLDER_NAME_LENGTH = 96;
const WEAK_BATCH_PAGE_TITLES = new Set(['new tab', 'untitled', 'about:blank']);
const truncateBatchFolderName = (value: string): string => Array.from(value)
.filter(character => {
const codePoint = character.codePointAt(0) || 0;
return codePoint < 0xd800 || codePoint > 0xdfff;
})
.slice(0, MAX_BATCH_FOLDER_NAME_LENGTH)
.join('');
export const sanitizeBatchFolderName = (value: string): string => {
const sanitized = truncateBatchFolderName(
value
.trim()
.replace(/[\u0000-\u001f\u007f]/g, '-')
.replace(/[<>:"/\\|?*]/g, '-')
.replace(/\s+/g, ' ')
.replace(/-+/g, '-')
.replace(/^[ .-]+|[ .-]+$/g, '')
)
.trim()
.replace(/[ .-]+$/g, '');
if (!sanitized || sanitized === '.' || sanitized === '..') return '';
if (/^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\..*)?$/i.test(sanitized)) {
return `batch-${sanitized}`;
}
return sanitized;
};
const batchFolderSlugFromReferer = (referer: string): string => {
try {
const url = new URL(referer);
if (!['http:', 'https:'].includes(url.protocol) || !url.hostname) return '';
const path = url.pathname.replace(/^\/+|\/+$/g, '');
return sanitizeBatchFolderName(`${url.hostname}${path ? `-${path}` : ''}`);
} catch {
return '';
}
};
const batchFolderNameFromFiles = (fileNames: string[]): string => {
const stems = fileNames
.map(fileName => fileName.replace(/\\/g, '/').split('/').pop() || '')
.map(fileName => fileName.replace(/\.[^.]+$/, ''))
.filter(Boolean);
if (stems.length === 0) return '';
const partStems = stems.map(stem => stem.replace(/[._ -]?part\s*\d+$/i, ''));
const candidate = partStems.every(stem => stem && stem === partStems[0])
? partStems[0]
: stems.length === 1 ? stems[0] : '';
return candidate ? sanitizeBatchFolderName(candidate) : '';
};
export const deriveBatchFolderName = (
pageTitle?: string | null,
referer?: string | null,
now = new Date(),
fileNames: string[] = []
): string => {
const title = pageTitle?.trim() || '';
if (title && !WEAK_BATCH_PAGE_TITLES.has(title.toLocaleLowerCase())) {
const safeTitle = sanitizeBatchFolderName(title);
if (safeTitle) return safeTitle;
}
const fileNameSlug = batchFolderNameFromFiles(fileNames);
if (fileNameSlug) return fileNameSlug;
const refererSlug = batchFolderSlugFromReferer(referer?.trim() || '');
if (refererSlug) return refererSlug;
const timestamp = now.toISOString().replace(/[.:]/g, '-').replace('T', '-').replace('Z', '');
return `firelink-batch-${timestamp}`;
};
export const resolveSubfolderDestination = async (
destination: string,
folderName: string
): Promise<string> => {
const root = await expandTilde(destination.trim() || '~/Downloads');
const safeFolderName = sanitizeBatchFolderName(folderName);
return safeFolderName ? join(root, safeFolderName) : root;
};
export const resolveInitialAddWindowLocation = (
baseDownloadFolder: string,
rememberLastUsedDownloadDirectory: boolean,
lastUsedDownloadDirectory: string | null
): AddWindowLocationSuggestion => {
const rememberedPath = rememberLastUsedDownloadDirectory
? lastUsedDownloadDirectory?.trim()
: undefined;
const basePath = baseDownloadFolder.trim() || '~/Downloads';
return {
path: rememberedPath || basePath,
isManual: Boolean(rememberedPath)
};
};
const stringRecord = (value: unknown): Record<string, string> => {
if (!value || typeof value !== 'object') return {};
return Object.fromEntries(
+65
View File
@@ -0,0 +1,65 @@
import { describe, expect, it } from 'vitest';
import {
DEFAULT_COLUMN_ALIGNMENTS,
DEFAULT_COLUMN_ORDER,
DEFAULT_COLUMN_WIDTHS,
buildColumnGridTemplate,
getColumnGridColumn,
normalizeColumnAlignments,
normalizeColumnOrder,
normalizeColumnWidths,
} from './downloadTableColumns';
describe('download table column preferences', () => {
it('normalizes a persisted order without losing or duplicating columns', () => {
expect(normalizeColumnOrder(['Status', 'Status', 'not-a-column', 'File Name'])).toEqual([
'Status',
'File Name',
'Size',
'Speed',
'ETA',
'Date Added',
]);
});
it('clamps persisted widths to column minimums and restores malformed entries', () => {
expect(normalizeColumnWidths([10, 70, Number.POSITIVE_INFINITY, 80, '48', 140])).toEqual([
160,
70,
220,
80,
80,
144,
]);
expect(normalizeColumnWidths([0, 100, 220, 100, 80, 170])[0]).toBe(160);
});
it('keeps only supported positional alignment values', () => {
expect(normalizeColumnAlignments({
'File Name': 'center',
Size: 'right',
Status: 'invalid',
Speed: 'left',
})).toEqual({
...DEFAULT_COLUMN_ALIGNMENTS,
'File Name': 'center',
Size: 'right',
Speed: 'left',
});
});
it('restores the default order for malformed persisted data', () => {
expect(normalizeColumnOrder(null)).toEqual([...DEFAULT_COLUMN_ORDER]);
expect(normalizeColumnAlignments(null)).toEqual(DEFAULT_COLUMN_ALIGNMENTS);
});
it('keeps user widths fixed while reserving a shared spacer before the final column', () => {
expect(buildColumnGridTemplate([...DEFAULT_COLUMN_ORDER], [...DEFAULT_COLUMN_WIDTHS])).toBe(
'340px 100px 220px 100px 80px minmax(0, 1fr) 170px'
);
expect(getColumnGridColumn('Date Added', [...DEFAULT_COLUMN_ORDER])).toBe('7');
expect(getColumnGridColumn('Date Added', ['Date Added', ...DEFAULT_COLUMN_ORDER.slice(0, -1)])).toBeUndefined();
expect(getColumnGridColumn('ETA', ['Date Added', 'File Name', 'Size', 'Status', 'Speed', 'ETA'])).toBe('7');
});
});
+113
View File
@@ -0,0 +1,113 @@
import type { DownloadSortColumn } from './downloadTableSorting';
export type DownloadTableColumnKey = DownloadSortColumn;
export type DownloadColumnAlignment = 'left' | 'center' | 'right';
export const DEFAULT_COLUMN_ORDER = [
'File Name',
'Size',
'Status',
'Speed',
'ETA',
'Date Added',
] as const satisfies readonly DownloadTableColumnKey[];
export const DEFAULT_COLUMN_WIDTHS = [340, 100, 220, 100, 80, 170] as const;
export const COLUMN_MINIMUMS = [160, 58, 92, 58, 48, 144] as const;
// Width of the fixed action rail shown while hovering a row.
export const DOWNLOAD_ACTIONS_COLUMN_WIDTH = 120;
// Keep the fixed rail clear of the viewport edge and horizontal scrollbar.
export const DOWNLOAD_ACTIONS_VIEWPORT_INSET = 8;
export const COLUMN_WIDTHS_STORAGE_KEY = 'firelink-download-column-widths';
export const COLUMN_ORDER_STORAGE_KEY = 'firelink-download-column-order';
export const COLUMN_ALIGNMENTS_STORAGE_KEY = 'firelink-download-column-alignments';
export const DEFAULT_COLUMN_ALIGNMENTS: Record<DownloadTableColumnKey, DownloadColumnAlignment> = {
'File Name': 'left',
Size: 'left',
Status: 'left',
Speed: 'left',
ETA: 'left',
'Date Added': 'left',
};
export const COLUMN_ALIGNMENT_JUSTIFY: Record<DownloadColumnAlignment, 'flex-start' | 'center' | 'flex-end'> = {
left: 'flex-start',
center: 'center',
right: 'flex-end',
};
const isColumnKey = (value: unknown): value is DownloadTableColumnKey =>
typeof value === 'string' && (DEFAULT_COLUMN_ORDER as readonly string[]).includes(value);
const isColumnAlignment = (value: unknown): value is DownloadColumnAlignment =>
value === 'left' || value === 'center' || value === 'right';
export const normalizeColumnOrder = (value: unknown): DownloadTableColumnKey[] => {
const seen = new Set<DownloadTableColumnKey>();
const normalized: DownloadTableColumnKey[] = [];
if (Array.isArray(value)) {
for (const candidate of value) {
if (isColumnKey(candidate) && !seen.has(candidate)) {
seen.add(candidate);
normalized.push(candidate);
}
}
}
for (const key of DEFAULT_COLUMN_ORDER) {
if (!seen.has(key)) normalized.push(key);
}
return normalized;
};
export const normalizeColumnWidths = (value: unknown): number[] => {
if (!Array.isArray(value) || value.length !== DEFAULT_COLUMN_WIDTHS.length) {
return [...DEFAULT_COLUMN_WIDTHS];
}
return value.map((width, index) =>
typeof width === 'number' && Number.isFinite(width)
? Math.max(COLUMN_MINIMUMS[index], width)
: DEFAULT_COLUMN_WIDTHS[index]
);
};
export const normalizeColumnAlignments = (value: unknown): Record<DownloadTableColumnKey, DownloadColumnAlignment> => {
const candidate = value && typeof value === 'object' && !Array.isArray(value)
? value as Partial<Record<DownloadTableColumnKey, unknown>>
: {};
return DEFAULT_COLUMN_ORDER.reduce((alignments, key) => {
alignments[key] = isColumnAlignment(candidate[key])
? candidate[key]
: DEFAULT_COLUMN_ALIGNMENTS[key];
return alignments;
}, {} as Record<DownloadTableColumnKey, DownloadColumnAlignment>);
};
export const columnIndex = (key: DownloadTableColumnKey): number =>
DEFAULT_COLUMN_ORDER.indexOf(key);
export const buildColumnGridTemplate = (
order: DownloadTableColumnKey[],
widths: number[]
): string => {
const normalizedWidths = normalizeColumnWidths(widths);
const orderedWidths = order.map(key => normalizedWidths[columnIndex(key)]);
return [
...orderedWidths.slice(0, -1).map(width => `${width}px`),
'minmax(0, 1fr)',
`${orderedWidths[orderedWidths.length - 1]}px`,
].join(' ');
};
export const getColumnGridColumn = (
key: DownloadTableColumnKey,
order: DownloadTableColumnKey[]
): string | undefined => key === order[order.length - 1]
? `${order.length + 1}`
: undefined;
+31 -1
View File
@@ -1,6 +1,11 @@
import { describe, expect, it } from 'vitest';
import type { DownloadItem } from '../bindings/DownloadItem';
import { redactDownloadForPersistence, resolveDownloadConnections } from './downloads';
import {
downloadFileNamesMatch,
downloadMediaKindsMatch,
redactDownloadForPersistence,
resolveDownloadConnections
} from './downloads';
const item = (status: DownloadItem['status']): DownloadItem => ({
id: 'download-1',
@@ -56,3 +61,28 @@ describe('download connection resolution', () => {
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);
});
});
+23
View File
@@ -132,6 +132,29 @@ export const canonicalizeDownloadFileName = (fileName: string): string => {
return sanitized && sanitized !== '.' && sanitized !== '..' ? sanitized : 'download';
};
/**
* Compare metadata-derived names without allowing path spelling or case to
* turn the same download into a second queue entry. Keep the extension and
* the rest of the name intact: URL query strings are not part of this value.
*/
export const normalizeDownloadFileNameForMatch = (fileName: string): string =>
canonicalizeDownloadFileName(fileName).normalize('NFKC').toLowerCase();
export const downloadMediaKindsMatch = (
left: boolean | undefined,
right: boolean | undefined
): boolean => Boolean(left) === Boolean(right);
const WEAK_DOWNLOAD_FILE_NAMES = new Set(['download', 'identifier', 'view', 'uc']);
export const downloadFileNamesMatch = (left: string, right: string): boolean => {
const normalizedLeft = normalizeDownloadFileNameForMatch(left);
const normalizedRight = normalizeDownloadFileNameForMatch(right);
return !WEAK_DOWNLOAD_FILE_NAMES.has(normalizedLeft)
&& !WEAK_DOWNLOAD_FILE_NAMES.has(normalizedRight)
&& normalizedLeft === normalizedRight;
};
export const isMediaUrl = (rawUrl: string): boolean => {
try {
const url = new URL(rawUrl);
+4 -3
View File
@@ -18,9 +18,10 @@ export type KeychainAccessReadiness = {
};
// The semantic app version can remain unchanged across release-candidate and
// packaging rebuilds. Use the build identity so an updated binary cannot skip
// Firelink's explanation and invoke the OS prompt directly. The policy epoch
// remains only as a safe fallback for builds created outside the Git checkout.
// packaging rebuilds. The Tauri packaging hook appends a fresh artifact nonce
// to the build identity, so replacing the binary cannot skip Firelink's
// explanation and invoke the OS prompt directly. The policy epoch remains
// only as a safe fallback for builds created outside the Git checkout.
const KEYCHAIN_CONSENT_POLICY_VERSION = '2';
const buildId = typeof import.meta.env.VITE_BUILD_ID === 'string'
? import.meta.env.VITE_BUILD_ID.trim()
+22
View File
@@ -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);
});
});
+10
View File
@@ -9,6 +9,16 @@ const fallback: PlatformInfo = {
portable: false
};
export const shouldUseCustomWindowControls = (os: string, userAgent: string): boolean => {
if (os === 'windows' || os === 'linux' || os === 'macos') return true;
if (os !== 'unknown') return false;
// Keep the custom titlebar visible while the native platform query is
// resolving. Mobile user agents are the only unknown targets that must not
// receive desktop window controls.
return !/Android|iPhone|iPad|iPod|Mobile/i.test(userAgent);
};
let cached: PlatformInfo | null = null;
let pending: Promise<PlatformInfo> | null = null;
+3
View File
@@ -26,6 +26,9 @@ const buildId = (() => {
'src-tauri/Cargo.lock',
'src-tauri/build.rs',
'src-tauri/tauri.conf.json',
'src-tauri/tauri.linux.conf.json',
'src-tauri/tauri.macos.conf.json',
'src-tauri/tauri.windows.conf.json',
'index.html',
'package.json',
'package-lock.json',