Compare commits

..

42 Commits

Author SHA1 Message Date
NimBold d895fb1b73 docs(readme): restore project icons 2026-07-26 19:58:15 +03:30
NimBold 5372a76eda chore(deps): update Firelink packages and FFmpeg sources 2026-07-26 19:53:14 +03:30
NimBold 54693456b1 docs(readme): update installation badges for Firefox and Chromium 2026-07-26 19:32:56 +03:30
NimBold edc0c1c6ad docs(readme): restore Firefox install badge
Remove maintainer-focused wording, add the Firefox Add-ons pill, and update the paired extension pointer.
2026-07-26 19:29:14 +03:30
NimBold c10b889a41 docs(readme): refresh project documentation
Clarify Firelink status, installation, development, support, credits, and the paired Companion revision.
2026-07-26 19:05:45 +03:30
NimBold 530324ab9c fix(i18n): refine download action translations
Align Persian verb forms and Chinese/Russian action labels with established IDM and FDM terminology references.
2026-07-26 18:51:59 +03:30
NimBold 240055497d fix(queue): preserve lifecycle ownership during recovery
Require queue-permit ownership before resuming a paused GID and rebuild a job when its GID disappears during recovery.

Advance the Browser companion with the automatic-capture handoff guard for #28.
2026-07-26 18:10:04 +03:30
NimBold 7787f4e6c0 test(queue): synchronize dispatcher integration assertion 2026-07-26 03:22:07 +03:30
NimBold cf09bfa2e4 fix(downloads): harden power management and aria2 recovery 2026-07-26 03:04:50 +03:30
NimBold 931e1f2f1e fix(queue): preserve aria2 connection pool 2026-07-25 17:54:28 +03:30
NimBold 956b70dc10 fix(extension): include capture recovery fix (#28)
Advance the Browser companion to bd3c7e0, which keeps disabled or malformed Firefox captures untouched and recovers pending captures safely during startup.
2026-07-25 16:50:04 +03:30
NimBold dd7c2196fe fix(queue): dispatch admitted downloads concurrently 2026-07-25 16:50:00 +03:30
NimBold d62e0429a4 fix(downloads): animate table column reordering 2026-07-25 16:23:07 +03:30
NimBold f3af5b0584 chore(deps): refresh application dependencies and engines 2026-07-25 15:35:54 +03:30
NimBold 5614022dbd fix(downloads): bound connection recovery churn
Require a healthy multi-connection baseline, cap consecutive automatic recovery attempts, and preserve zero-progress recovery. Align Download Properties speed-cap controls with shared app styles.
2026-07-24 06:57:26 +03:30
NimBold b1659fa3d0 fix(ui): harden download table layout (#32)
Refs #32
2026-07-24 04:29:27 +03:30
NimBold 9a40b30a0f fix(add-modal): harden playlist media selection 2026-07-24 03:34:42 +03:30
NimBold 4f89a6c79d fix(i18n): keep font family names consistent
Keep bundled font names untranslated across every supported locale while preserving localized descriptions.

Refs #31
2026-07-23 19:55:12 +03:30
NimBold f5d918ec1e fix(settings): keep bundled font labels compact
Use canonical font names across locales, align system-mode descriptions, and document the bundled OFL license text.

Refs #31
2026-07-23 17:38:33 +03:30
NimBold b579c35e42 feat(settings): add curated multilingual fonts
Bundle Vazirmatn, Noto Sans Hebrew, Noto Sans SC, and Roboto with locale-aware fallbacks while keeping custom font import out of scope.

Refs #31
2026-07-23 17:29:29 +03:30
NimBold 1f1ec828f9 feat(settings): add locale-aware font preferences
Bundle optional Inter and Outfit families, preserve the platform system default, and provide script-aware fallbacks for every supported locale.

Refs #31
2026-07-23 17:13:00 +03:30
NimBold 228db1eaa2 fix(settings): improve look and feel organization
Prevent localized select labels from being clipped and order appearance settings by scope and frequency.

Refs #31
2026-07-23 16:54:53 +03:30
NimBold 9a790073f5 fix(ui): align all window control styles
Lower macOS and minimal controls so every preset shares the same titlebar centerline.

Refs #31
2026-07-23 16:44:01 +03:30
NimBold f3eff02069 fix(ui): align window control vertical spacing
Lower GNOME and minimal controls to the same titlebar baseline as Windows 11.

Refs #31
2026-07-23 16:42:53 +03:30
NimBold 4374219685 fix(ui): refine Windows window controls
Place the close action at the outer sidebar corner, round the caption hit regions, and align the controls with the titlebar content.

Refs #31
2026-07-23 16:40:09 +03:30
NimBold 4e3ceb21a5 feat(ui): add cross-platform window control styles
Persist automatic and explicit macOS, Windows, GNOME, and minimal control presets with localized settings and platform-aware fallback.

Refs #31
2026-07-23 16:25:39 +03:30
NimBold 6adbccdf43 fix(ui): mirror window controls for right sidebar
Match native macOS traffic-light spacing and reference the related UI enhancement.

Refs #31
2026-07-23 16:10:48 +03:30
NimBold 454fad712b fix(modals): import AddDownloadsModal and adjust modal backdrop animation 2026-07-23 15:25:34 +03:30
NimBold 7f450a802f fix(i18n): update queue reorder hint to include hotkey usage 2026-07-23 14:50:06 +03:30
NimBold f2b1fb9d4a fix(downloads): restore smooth queue reorder animations 2026-07-23 14:40:53 +03:30
NimBold 9302911ac7 fix(downloads): harden queue drag and reorder behavior 2026-07-23 07:40:02 +03:30
NimBold 18e9200b74 fix(downloads): restore queue reorder interactions 2026-07-23 06:45:49 +03:30
NimBold 3559c69968 fix(downloads): harden queue reordering and summary stats 2026-07-23 06:23:04 +03:30
NimBold 3587fb0c0d fix(downloads): harden queue admission and live speed controls 2026-07-23 04:13:07 +03:30
NimBold b60818d3af feat(downloads): add live aria2 speed limits 2026-07-23 03:21:34 +03:30
NimBold 5463d27ed3 feat(settings): add opt-in calendar localization 2026-07-23 03:08:52 +03:30
NimBold 3f298715a6 fix(downloads): avoid quadratic queue drag lookups 2026-07-23 03:02:22 +03:30
NimBold ceab8a5fdf feat(downloads): add media quality and queue reordering 2026-07-23 02:55:33 +03:30
NimBold f4e5e211cc feat(downloads): add details controls and aggregate summaries 2026-07-23 02:31:34 +03:30
NimBold 9d737598c6 fix(macos): handle Cmd+M minimization (#30) 2026-07-22 19:02:17 +03:30
NimBold d3e5e8c208 fix(macos): restore native window lifecycle (#30)
Install the native macOS application menu for Command-M, handle Dock reopen events only when no window is visible, and preserve early secondary-launch restores until the main window exists. Refs #30.
2026-07-22 18:34:56 +03:30
NimBold 17eb8e5d39 docs(release): link reported issues 2026-07-22 17:48:27 +03:30
62 changed files with 7804 additions and 690 deletions
+5 -5
View File
@@ -11,20 +11,20 @@ This release makes everyday download management easier to organize, review, and
### 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.
- **Localized desktop interface** for Persian, Hebrew, Russian, Ukrainian, and Simplified Chinese, responding to the Chinese-language request in [#17](https://github.com/nimbold/Firelink/issues/17). These translations are currently produced with help from LLMs and need user review; please report corrections so they can improve.
- **Customizable download table** with selectable columns, drag-to-reorder controls, sorting, clearer size alignment, and better bulk actions.
- **Optional batch folders** for multi-link downloads, addressing [#27](https://github.com/nimbold/Firelink/issues/27). Firelink can suggest an editable folder name from the page title or common filename while keeping the existing category-based behavior as the default.
- **Remember the last Add-window directory**, with an opt-in setting so users remain in control of where it applies.
- **Per-download connection controls** for normal and media transfers, with the active aria2 connection count shown in the download table.
- **Remember the last Add-window directory**, with an opt-in setting so users remain in control of where it applies, addressing [#23](https://github.com/nimbold/Firelink/issues/23).
- **Per-download connection controls** for normal and media transfers, with the active aria2 connection count shown in the download table. This also follows the connection-setting report in [#20](https://github.com/nimbold/Firelink/issues/20).
- **Latest Firelink Companion 2.0.6** in the `Extensions/Browser` submodule, including selected-link batch context and safer automatic capture recovery.
### Improvements
- Make pause, resume, retry, remove, redownload, queue, scheduler, and settings actions safer when several operations overlap or background events arrive late.
- Reuse unfinished downloads when their filenames match, while reducing the chance of stale state creating duplicate or misleading rows.
- Reuse unfinished downloads when their filenames match, addressing [#26](https://github.com/nimbold/Firelink/issues/26), while reducing the chance of stale state creating duplicate or misleading rows.
- Improve first-open navigation, lazy page loading, table layout, drag interactions, sorting, and window-control placement.
- Improve RTL behavior while keeping the download table's physical file columns readable.
- Handle locked browser cookie databases more gracefully and keep browser metadata and authenticated captures on the correct path.
- Handle locked browser cookie databases more gracefully and keep browser metadata and authenticated captures on the correct path, addressing the media reports in [#22](https://github.com/nimbold/Firelink/issues/22) and [#24](https://github.com/nimbold/Firelink/issues/24).
- Enforce startup consent before accessing saved credentials and make keychain-related startup behavior more predictable.
- Refresh dependencies and bundled engines, and strengthen checks for macOS, Windows portable, Linux packages, release assets, and release version identity.
- Refine localized wording, including Persian status labels.
+77 -79
View File
@@ -1,107 +1,89 @@
<div align="center">
<img src="src/assets/app-icon.png" alt="Firelink" width="112" height="112" />
<p align="center">
<img src="src/assets/app-icon.png" alt="Firelink app icon" width="128" height="128" />
</p>
# Firelink
# Firelink
**A fast, focused desktop download manager for macOS, Windows, and Linux.**
> A fast, focused desktop download manager for macOS, Windows, and Linux.
[![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)
[![Latest release](https://img.shields.io/github/v/release/nimbold/Firelink?style=flat-square)](https://github.com/nimbold/Firelink/releases/latest)
[![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)
[![License](https://img.shields.io/github/license/nimbold/Firelink?style=flat-square)](LICENSE)
[![macOS](https://img.shields.io/badge/macOS-Apple%20silicon-111111?style=flat-square&logo=apple&logoColor=white)](#installation)
[![Windows](.github/badges/windows.svg)](#installation)
[![Linux](https://img.shields.io/badge/Linux-x64-FCC624?style=flat-square&logo=linux&logoColor=black)](#installation)
[Features](#features) · [Install](#installation) · [Browser Extension](#browser-extension) · [Development](#development) · [Changelog](CHANGELOG.md)
</div>
<br/>
<div align="center">
<p align="center">
<img src="Screenshots/Dark%20theme%20-%20main.png" width="24%" alt="Firelink dark theme main window" />
<img src="Screenshots/Dark%20theme%20-%20add%20window.png" width="24%" alt="Firelink dark theme add window" />
<img src="Screenshots/Light%20theme%20-%20main.png" width="24%" alt="Firelink light theme main window" />
<img src="Screenshots/Light%20theme%20-%20add%20window.png" width="24%" alt="Firelink light theme add window" />
</p>
<details>
<summary><b>View more screenshots</b></summary>
<br/>
<img src="Screenshots/Dark%20theme%20-%20settings.png" width="32%" alt="Firelink dark theme settings" />
<img src="Screenshots/Light%20theme%20-%20settings.png" width="32%" alt="Firelink light theme settings" />
</details>
</div>
## What is Firelink?
## Overview
Firelink manages direct downloads, browser captures, media, playlists, queues, and scheduling from one desktop app.
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 uses a Rust and Tauri backend with a React and TypeScript interface. Required download and media engines are bundled with the app.
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.
## Status
Firelink `1.2.0` is the latest desktop release.
It is paired with [Firelink Companion `2.0.6`](https://github.com/nimbold/Firelink-Extension/releases/tag/v2.0.6).
The project is actively maintained. See the [changelog](CHANGELOG.md) for release history and current work.
Translations are available for English, Simplified Chinese, Hebrew, Persian, Ukrainian, and Russian. Translation corrections are welcome.
## Features
- **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 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.
- Segmented HTTP and HTTPS downloads with retries, speed limits, and connection controls.
- Media and playlist downloads through yt-dlp, FFmpeg, and Deno.
- An Add window for metadata, duplicate handling, save locations, and download options.
- Persistent queues with pause, resume, retry, redownload, and scheduling.
- A customizable download table with sorting, column selection, reordering, and bulk actions.
- File organization with categories, default folders, and per-download locations.
- Browser handoff through local pairing and signed requests.
- Tray controls, notifications, sounds, sleep prevention, and secure credential storage.
- RTL support for Hebrew and Persian.
## Installation
Download desktop builds from [GitHub Releases](https://github.com/nimbold/Firelink/releases).
Download the [latest Firelink release](https://github.com/nimbold/Firelink/releases/latest).
| Platform | Package | Notes |
| --- | --- | --- |
| **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 notes below. |
| **Linux x64** | `.deb`, `.rpm`, or `.AppImage` | Choose the package for your distribution, or use AppImage as the self-contained option. |
| macOS Apple silicon | `.dmg` | Ad-hoc signed and not notarized. macOS may require approval in **System Settings -> Privacy & Security**. |
| Windows x64 | NSIS `.exe` | Unsigned. Windows SmartScreen may display a warning. |
| Windows x64 portable | `.zip` | Extract to a writable folder and launch `firelink.exe`. |
| Linux x64 | `.deb`, `.rpm`, or `.AppImage` | Choose the package for your distribution, or use AppImage. |
All packages include aria2, yt-dlp, FFmpeg, Deno, and SQLite support. You do not need to install those engines separately.
All packages include aria2, yt-dlp, FFmpeg, Deno, and SQLite support. No separate engine installation is required.
<details>
<summary><strong>Windows portable ZIP notes</strong></summary>
The portable ZIP is a secondary distribution. Extract it to a writable folder and launch `firelink.exe`:
- Keep it out of `Program Files`, read-only media, and folders that block SQLite or WebView writes.
- Settings, queues, logs, and WebView data stay beside the executable under `data/`.
- Close Firelink before moving or copying the folder, and close the installed app before launching the portable copy.
- Credentials, browser cookies, and URL query or fragment data are not saved in portable queue records.
- Saved site passwords remain in the Windows credential store and are not copied into the archive.
- The folder contains the extension pairing credential. Treat it as sensitive and do not share it.
- Saved absolute download locations may need to be selected again after moving the folder to another drive.
- Keep the folder writable. Avoid `Program Files` and read-only media.
- Settings, queues, logs, and WebView data are stored beside the executable in `data/`.
- Close Firelink before moving or copying the folder.
- Treat the folder as sensitive because it contains the extension pairing credential.
- The installer remains the supported path for `firelink://` browser launch registration.
</details>
## Browser Extension
## Browser integration
[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.
[Firelink Companion](https://github.com/nimbold/Firelink-Extension) connects browser downloads, links, and media pages to Firelink.
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.
Captured links open Firelink's Add window for review before they are started or queued.
<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>
<a href="https://addons.mozilla.org/en-US/firefox/addon/firelink-companion/"><img src="https://img.shields.io/badge/Install%20from-Firefox%20Add--ons-FF7139?style=for-the-badge&logo=firefox-browser&logoColor=white" alt="Install from Firefox Add-ons" /></a>&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="Manual install for Chromium browsers" /></a>
</p>
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).
## Supported Platforms
| Target | Status |
| --- | --- |
| **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. |
See the [Companion README](https://github.com/nimbold/Firelink-Extension#readme) for browser installation, pairing, and privacy details.
## Development
@@ -112,34 +94,50 @@ Install the extension, open Firelink, and pair it from **Settings -> Integration
- Rust and Cargo
- [Tauri 2 platform prerequisites](https://v2.tauri.app/start/prerequisites/)
Clone the repository with its browser-extension submodule:
### Quick start
```sh
git clone --recurse-submodules https://github.com/nimbold/Firelink.git
cd Firelink
npm install
npm run tauri dev
```
Launch the app with `npm run tauri dev`. Run the core checks with:
Run the main checks with:
```sh
node --test scripts/*.node-test.js
npm test -- --run
npm run build
cd src-tauri
cargo test --all-targets
cd src-tauri && cargo test --all-targets
```
See [RELEASE.md](RELEASE.md) for engine provisioning, packaging, and release verification.
## Help and Project Status
## Contributing and support
- Report bugs or request improvements in [GitHub Issues](https://github.com/nimbold/Firelink/issues).
- Report bugs and request features in [GitHub Issues](https://github.com/nimbold/Firelink/issues).
- Open focused pull requests with tests for behavior changes.
- Report translation corrections in an issue or pull request.
- Read [CHANGELOG.md](CHANGELOG.md) for release history.
- The project is actively maintained, but macOS builds are not notarized and Windows installers are not code-signed yet.
## Technology and License
## Credits
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/).
Core technologies:
Firelink is available under the [MIT License](LICENSE).
- [Tauri 2](https://v2.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/)
Bundled engines:
- [aria2](https://aria2.github.io/)
- [yt-dlp](https://github.com/yt-dlp/yt-dlp)
- [FFmpeg](https://ffmpeg.org/)
- [Deno](https://deno.com/)
Font licenses, engine versions, source references, and third-party notices are documented in [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md).
## License
Firelink is available under the [MIT License](LICENSE). Bundled tools and fonts retain their own licenses.
+17
View File
@@ -4,6 +4,23 @@ Firelink distributes separate executable tools. Firelink's MIT license does not
Exact versions, target hashes, sources, and build descriptions are pinned in `engines.lock.json`.
## Bundled fonts
Firelink bundles variable WOFF2 web fonts through Fontsource. Each font is
licensed under the SIL Open Font License, Version 1.1. The corresponding
license and author information are available from the linked project sources.
- Inter: <https://github.com/rsms/inter>
- Outfit: <https://github.com/Outfitio/Outfit-Fonts>
- Roboto: <https://github.com/googlefonts/roboto-classic>
- Vazirmatn: <https://github.com/rastikerdar/vazirmatn>
- Noto Sans Hebrew: <https://github.com/notofonts/hebrew>
- Noto Sans SC: <https://github.com/notofonts/noto-cjk>
Fontsource distribution: <https://github.com/fontsource/font-files>
License text: <https://openfontlicense.org/open-font-license-official-text/>
## aria2
- Project: <https://aria2.github.io/>
+12 -12
View File
@@ -8,14 +8,14 @@
"sha256": "90254845be5282b1f4d843a873abff04f569f857f64250f833fe152b21eec152"
},
"deno": {
"version": "2.9.3",
"url": "https://github.com/denoland/deno/releases/download/v2.9.3/deno-x86_64-pc-windows-msvc.zip",
"sha256": "60343461ac5fe3a31f4ef12667f2946bb852e20655c8610aeb7e751e87f7df3a"
"version": "2.9.4",
"url": "https://github.com/denoland/deno/releases/download/v2.9.4/deno-x86_64-pc-windows-msvc.zip",
"sha256": "68ed08b05c56cf887e9aa509947dc3f468f7e12f47a13e5c1abd51d46d1453ef"
},
"ffmpeg": {
"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"
"version": "8.1.2-31-g8c9502e9b0",
"url": "https://github.com/BtbN/FFmpeg-Builds/releases/download/autobuild-2026-07-26-13-28/ffmpeg-n8.1.2-31-g8c9502e9b0-win64-gpl-8.1.zip",
"sha256": "2b6a26a3943892dc61ea62545253efc1495580f67346285a6b12713d70c1a2ff"
},
"aria2c": {
"version": "1.37.0",
@@ -30,14 +30,14 @@
"sha256": "d7d2d09e900b5ae11821b5784b18cf064984a2bd88b1ca5c798d744bcbe3658b"
},
"deno": {
"version": "2.9.3",
"url": "https://github.com/denoland/deno/releases/download/v2.9.3/deno-x86_64-unknown-linux-gnu.zip",
"sha256": "8101865641cbede56f08ad19c0a67a87df84bce127fee0d3e3e1f7467717ffa6"
"version": "2.9.4",
"url": "https://github.com/denoland/deno/releases/download/v2.9.4/deno-x86_64-unknown-linux-gnu.zip",
"sha256": "c24f955d9fbfe0ea5ae2b501c8e71ae76e31e4c9782390a54a284b3364fda725"
},
"ffmpeg": {
"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"
"version": "8.1.2-31-g8c9502e9b0",
"url": "https://github.com/BtbN/FFmpeg-Builds/releases/download/autobuild-2026-07-26-13-28/ffmpeg-n8.1.2-31-g8c9502e9b0-linux64-gpl-8.1.tar.xz",
"sha256": "00a4cce5118f477da5d6758630ecd70f71ac7601dca377d819b619613cb577a0"
},
"aria2c": {
"version": "1.37.0",
+2 -2
View File
@@ -23,10 +23,10 @@
"sha256": "f65b4ba782b25e9f4374765d421793c6613f692d8e50b2888079657793619034"
},
"deno": {
"version": "2.9.3",
"version": "2.9.4",
"source": "https://github.com/denoland/deno",
"build": "official aarch64-apple-darwin executable",
"sha256": "80c83cdfb20289f8818a71220b570df363f6f0ba93580c29c70f08ab14e93568"
"sha256": "433088c827fa0e39ff162ab0e475f1fd4c7690eaedec500cf678edc3865e9287"
}
},
"runtimeTrees": {
-3
View File
@@ -5,9 +5,6 @@
<link rel="icon" type="image/png" href="/src/assets/app-icon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Firelink Download Manager</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&family=Outfit:wght@300;400;500;600;700&display=swap" rel="stylesheet">
</head>
<body>
+80 -29
View File
@@ -9,6 +9,12 @@
"version": "1.2.0",
"license": "MIT",
"dependencies": {
"@fontsource-variable/inter": "^5.3.0",
"@fontsource-variable/noto-sans-hebrew": "^5.3.0",
"@fontsource-variable/noto-sans-sc": "^5.3.0",
"@fontsource-variable/outfit": "^5.3.0",
"@fontsource-variable/roboto": "^5.3.0",
"@fontsource-variable/vazirmatn": "^5.3.0",
"@formkit/auto-animate": "^0.10.0",
"@tailwindcss/vite": "^4.3.3",
"@tauri-apps/api": "^2.11.1",
@@ -19,19 +25,19 @@
"@tauri-apps/plugin-notification": "^2.3.3",
"@tauri-apps/plugin-opener": "^2",
"i18next": "^26.3.6",
"lucide-react": "^1.25.0",
"lucide-react": "^1.27.0",
"react": "^19.2.8",
"react-dom": "^19.2.8",
"react-i18next": "^17.0.10",
"react-i18next": "^17.0.11",
"zustand": "^5.0.14"
},
"devDependencies": {
"@tauri-apps/cli": "^2.11.4",
"@types/react": "^19.1.8",
"@types/react-dom": "^19.1.6",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.4",
"autoprefixer": "^10.5.4",
"postcss": "^8.5.21",
"postcss": "^8.5.23",
"tailwindcss": "^4.3.3",
"typescript": "^7.0.2",
"vite": "^8.1.5",
@@ -81,6 +87,60 @@
"tslib": "^2.4.0"
}
},
"node_modules/@fontsource-variable/inter": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/@fontsource-variable/inter/-/inter-5.3.0.tgz",
"integrity": "sha512-OupL48va4JNofb97w6NYeF9S7W/kHNKM0Er8Dem5nqi4jeOLrVJDoE8tZEpnMJmtkvNbB1EIPPwHcdkF6b1oUA==",
"license": "OFL-1.1",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@fontsource-variable/noto-sans-hebrew": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/@fontsource-variable/noto-sans-hebrew/-/noto-sans-hebrew-5.3.0.tgz",
"integrity": "sha512-jGIZ2EOCTtT1ZhLthz6nEpPXGMniSPI/0tv5CDFDZ7MPYyoj6FskW7WJ0YTyesFADdzjx7F2Pg5W1PWD6y9UFw==",
"license": "OFL-1.1",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@fontsource-variable/noto-sans-sc": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/@fontsource-variable/noto-sans-sc/-/noto-sans-sc-5.3.0.tgz",
"integrity": "sha512-lNar1dF7Ik/lHNPo/7JWG0TolXY29LtsqYgMvEysooZ5bsO9uH4shJmRrwyJ3PjyTPljhpMJEK0jDuLSU4vJ1w==",
"license": "OFL-1.1",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@fontsource-variable/outfit": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/@fontsource-variable/outfit/-/outfit-5.3.0.tgz",
"integrity": "sha512-fPb5XnYK0k7SbR++HEmm4+ZqDszmLKQ+sPRUr8oQ/KNC0vy6r6hazJFe8iYozv4cqlHbvJ/JECkJWrfDodUMVQ==",
"license": "OFL-1.1",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@fontsource-variable/roboto": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/@fontsource-variable/roboto/-/roboto-5.3.0.tgz",
"integrity": "sha512-BoaaZiQf8fgtxv07KXkTwx1bKz/EoqIlTXq2OOKKXINxp6qXWdnlChqV+hSaDANS49yyUASFl3OVBMMDTCIHFA==",
"license": "OFL-1.1",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@fontsource-variable/vazirmatn": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/@fontsource-variable/vazirmatn/-/vazirmatn-5.3.0.tgz",
"integrity": "sha512-ZA68W8JHbMdNzzwjYHUWKB2PH22QpxNEnk0ukcXM+XiqYMV6ZWJZAKuNXcrMR2JjuIAwsfAU3KknCXPI6wXUvw==",
"license": "OFL-1.1",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@formkit/auto-animate": {
"version": "0.10.0",
"resolved": "https://registry.npmjs.org/@formkit/auto-animate/-/auto-animate-0.10.0.tgz",
@@ -1783,12 +1843,12 @@
"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==",
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-4.0.1.tgz",
"integrity": "sha512-0zHsZJrK7S3K2aucXWL6ycoYJ/iNtIcFHC/nYQgFklPtrv5LpJctIiSCroWZWeuoXvuyFdzp6KzjJQ+OT5MfFw==",
"license": "MIT",
"dependencies": {
"void-elements": "3.1.0"
"funding": {
"url": "https://locize.com"
}
},
"node_modules/i18next": {
@@ -2078,9 +2138,9 @@
}
},
"node_modules/lucide-react": {
"version": "1.25.0",
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.25.0.tgz",
"integrity": "sha512-/mdJTRbiwcLOQ1NZZK1amZF9rIZyvO18D6r9TngE6TG1NmqHgFuT4eE7Xrkm9UsXMbBJD1NlfwHVltCDWHrOTw==",
"version": "1.27.0",
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.27.0.tgz",
"integrity": "sha512-rJicGl/3Fly/E0rOH1YmPZ6e49JCnKknh1ox1vpHnkfjujAkKA6sqUZvH3MTAaXXjgexyUwgNwTJzTtYuAFYJw==",
"license": "ISC",
"peerDependencies": {
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
@@ -2163,9 +2223,9 @@
}
},
"node_modules/postcss": {
"version": "8.5.21",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.21.tgz",
"integrity": "sha512-v4sDNP3fdNiWMfabO7OwOQdOX8TiQSztKyT1Wj0w+j7LDallJThJRBBBmzVGyYj0crMh7jlV4zepPkiNu9UwDQ==",
"version": "8.5.23",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.23.tgz",
"integrity": "sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==",
"funding": [
{
"type": "opencollective",
@@ -2219,13 +2279,13 @@
}
},
"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==",
"version": "17.0.11",
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.0.11.tgz",
"integrity": "sha512-cDtkXgxjuFTWUH6V+aQn1Ve5vDiUztCNPWW5GtSHDccsgRXO1nE6QFWCEmc1KAutrb3OUv87wFShJL5RhUwPXg==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.29.2",
"html-parse-stringify": "^3.0.1",
"html-parse-stringify": "^4.0.1",
"use-sync-external-store": "^1.6.0"
},
"peerDependencies": {
@@ -2625,15 +2685,6 @@
}
}
},
"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",
+11 -5
View File
@@ -39,6 +39,12 @@
"test": "vitest"
},
"dependencies": {
"@fontsource-variable/inter": "^5.3.0",
"@fontsource-variable/noto-sans-hebrew": "^5.3.0",
"@fontsource-variable/noto-sans-sc": "^5.3.0",
"@fontsource-variable/outfit": "^5.3.0",
"@fontsource-variable/roboto": "^5.3.0",
"@fontsource-variable/vazirmatn": "^5.3.0",
"@formkit/auto-animate": "^0.10.0",
"@tailwindcss/vite": "^4.3.3",
"@tauri-apps/api": "^2.11.1",
@@ -49,19 +55,19 @@
"@tauri-apps/plugin-notification": "^2.3.3",
"@tauri-apps/plugin-opener": "^2",
"i18next": "^26.3.6",
"lucide-react": "^1.25.0",
"lucide-react": "^1.27.0",
"react": "^19.2.8",
"react-dom": "^19.2.8",
"react-i18next": "^17.0.10",
"react-i18next": "^17.0.11",
"zustand": "^5.0.14"
},
"devDependencies": {
"@tauri-apps/cli": "^2.11.4",
"@types/react": "^19.1.8",
"@types/react-dom": "^19.1.6",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.4",
"autoprefixer": "^10.5.4",
"postcss": "^8.5.21",
"postcss": "^8.5.23",
"tailwindcss": "^4.3.3",
"typescript": "^7.0.2",
"vite": "^8.1.5",
+15 -24
View File
@@ -566,9 +566,9 @@ dependencies = [
[[package]]
name = "cc"
version = "1.3.0"
version = "1.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c89588d05638b5b4594a3348a2d6c20277e43a7f5c5202b05cc56888475a47b8"
checksum = "5add81bb678e6cb321aff7fa0dc7689ad82b112dbc032cea19f91d6b8e3582b9"
dependencies = [
"find-msvc-tools",
"shlex",
@@ -1457,13 +1457,13 @@ dependencies = [
[[package]]
name = "foreign-types-macros"
version = "0.2.3"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1a5c6c585bc94aaf2c7b51dd4c2ba22680844aba4c687be581871a6f518c5742"
checksum = "ea5190182e6915eb873ddbc16e23b711b6eb1f9c00a0d0a3a91b5f6228475225"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
"syn 3.0.3",
]
[[package]]
@@ -3402,7 +3402,7 @@ checksum = "7da1d65da6dd5d1e44199ac0f58712d241c0f439f80adea8924d832384087f85"
dependencies = [
"base64 0.22.1",
"indexmap 2.14.0",
"quick-xml 0.41.0",
"quick-xml",
"serde",
"time",
]
@@ -3557,15 +3557,6 @@ version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3"
[[package]]
name = "quick-xml"
version = "0.39.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cdcc8dd4e2f670d309a5f0e83fe36dfdc05af317008fea29144da1a2ac858e5e"
dependencies = [
"memchr",
]
[[package]]
name = "quick-xml"
version = "0.41.0"
@@ -3907,9 +3898,9 @@ dependencies = [
[[package]]
name = "rustls-pki-types"
version = "1.15.0"
version = "1.15.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "764899a24af3980067ee14bc143654f297b22eaebfe3c7b6b211920a5a59b046"
checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96"
dependencies = [
"zeroize",
]
@@ -5931,9 +5922,9 @@ dependencies = [
[[package]]
name = "wayland-backend"
version = "0.3.15"
version = "0.3.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2857dd20b54e916ec7253b3d6b4d5c4d7d4ca2c33c2e11c6c76a99bd8744755d"
checksum = "016ccf01d1c58b6f8999612813e17c9b2390f7d70671428869913310f83f54b8"
dependencies = [
"cc",
"downcast-rs",
@@ -5944,9 +5935,9 @@ dependencies = [
[[package]]
name = "wayland-client"
version = "0.31.14"
version = "0.31.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "645c7c96bb74690c3189b5c9cb4ca1627062bb23693a4fad9d8c3de958260144"
checksum = "e3c36a0f861ad76d0901f2800b46321410d9f73f2ea88aac0650d86c32688073"
dependencies = [
"bitflags 2.13.1",
"rustix",
@@ -5981,12 +5972,12 @@ dependencies = [
[[package]]
name = "wayland-scanner"
version = "0.31.10"
version = "0.31.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9c324a910fd86ebdc364a3e61ec1f11737d3b1d6c273c0239ee8ff4bc0d24b4a"
checksum = "338e30461b3a2b67d70eb30a6d89f8e0c93a833e07d2ae89085cd070c4a00ac0"
dependencies = [
"proc-macro2",
"quick-xml 0.39.4",
"quick-xml",
"quote",
]
Binary file not shown.
+75
View File
@@ -70,6 +70,18 @@ pub struct Queue {
pub id: String,
pub name: String,
pub is_main: bool,
#[serde(default)]
#[ts(optional)]
pub max_concurrent: Option<usize>,
}
#[derive(Clone, Debug, Serialize, Deserialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export, export_to = "../../src/bindings/")]
pub struct QueueConcurrencyConfig {
pub id: String,
#[serde(default)]
pub max_concurrent: Option<usize>,
}
#[derive(Clone, Debug, Serialize, Deserialize, TS)]
@@ -121,6 +133,8 @@ pub struct DownloadItem {
#[ts(optional)]
pub media_format_selector: Option<String>,
#[ts(optional)]
pub media_quality: Option<String>,
#[ts(optional)]
pub queue_id: Option<String>,
#[ts(optional)]
pub queue_position: Option<i32>,
@@ -200,6 +214,59 @@ pub enum Theme {
Nord,
}
#[derive(Clone, Debug, Serialize, Deserialize, TS)]
#[serde(rename_all = "kebab-case")]
#[ts(export, export_to = "../../src/bindings/")]
pub enum FontFamily {
System,
Inter,
Outfit,
Vazirmatn,
NotoSansHebrew,
NotoSansSc,
Roboto,
Serif,
Monospace,
}
impl Default for FontFamily {
fn default() -> Self {
Self::System
}
}
#[derive(Clone, Debug, Serialize, Deserialize, TS)]
#[serde(rename_all = "lowercase")]
#[ts(export, export_to = "../../src/bindings/")]
pub enum WindowControlStyle {
Auto,
Macos,
Windows,
Gnome,
Minimal,
}
impl Default for WindowControlStyle {
fn default() -> Self {
Self::Auto
}
}
#[derive(Clone, Debug, Serialize, Deserialize, TS)]
#[serde(rename_all = "lowercase")]
#[ts(export, export_to = "../../src/bindings/")]
pub enum CalendarPreference {
Gregorian,
Persian,
Hebrew,
}
impl Default for CalendarPreference {
fn default() -> Self {
Self::Gregorian
}
}
#[derive(Clone, Debug, Serialize, Deserialize, TS)]
#[serde(rename_all = "lowercase")]
#[ts(export, export_to = "../../src/bindings/")]
@@ -265,6 +332,12 @@ pub struct SchedulerSettings {
#[ts(export, export_to = "../../src/bindings/")]
pub struct PersistedSettings {
pub theme: Theme,
#[serde(default)]
pub font_family: FontFamily,
#[serde(default)]
pub window_control_style: WindowControlStyle,
#[serde(default)]
pub calendar_preference: CalendarPreference,
#[serde(default = "default_language_preference")]
pub language: String,
pub base_download_folder: String,
@@ -305,6 +378,8 @@ pub struct PersistedSettings {
pub ask_where_to_save_each_file: bool,
pub remember_last_used_download_directory: bool,
pub prevents_sleep_while_downloading: bool,
#[serde(default)]
pub prevents_display_sleep_while_downloading: bool,
pub media_cookie_source: MediaCookieSource,
pub site_logins: Vec<SiteLogin>,
pub auto_check_updates: bool,
+734 -170
View File
File diff suppressed because it is too large Load Diff
+419
View File
@@ -0,0 +1,419 @@
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
use std::sync::mpsc::{self, Receiver, Sender};
use std::sync::{Arc, Mutex};
use std::thread::{self, JoinHandle};
const ASSERTION_REASON: &str = "Firelink active download";
/// Owns the OS power assertions for the whole backend transfer lifecycle.
///
/// The frontend may request the policy, but it does not own the assertion:
/// queue permits are the source of truth for whether a transfer is actually
/// active. Keeping the two assertions separate also means display sleep can
/// be opted into without changing the system-sleep policy.
struct PowerState {
runtime_enabled: AtomicBool,
prevent_system_sleep: AtomicBool,
prevent_display_sleep: AtomicBool,
active_transfers: AtomicUsize,
}
enum PowerCommand {
Reconcile(Option<Sender<Result<(), String>>>),
}
struct PowerWorker {
sender: Option<Sender<PowerCommand>>,
join_handle: Option<JoinHandle<()>>,
}
impl PowerWorker {
fn start(state: Arc<PowerState>) -> Result<Self, String> {
let (sender, receiver) = mpsc::channel();
let join_handle = thread::Builder::new()
.name("firelink-power".to_string())
.spawn(move || run_power_worker(state, receiver))
.map_err(|error| format!("failed to start power-management worker: {error}"))?;
Ok(Self {
sender: Some(sender),
join_handle: Some(join_handle),
})
}
fn send(&self, command: PowerCommand) -> Result<(), String> {
self.sender
.as_ref()
.ok_or_else(|| "power-management worker is stopping".to_string())?
.send(command)
.map_err(|_| "power-management worker stopped unexpectedly".to_string())
}
}
impl Drop for PowerWorker {
fn drop(&mut self) {
// Closing the sender lets the worker drop its OS assertions on the
// same thread that created them. This matters on Windows, where
// SetThreadExecutionState is thread-local, and avoids calling the
// Linux keepawake D-Bus destructor from an arbitrary queue thread.
self.sender.take();
if let Some(join_handle) = self.join_handle.take() {
let _ = join_handle.join();
}
}
}
pub struct PowerManager {
state: Arc<PowerState>,
worker: Mutex<Option<PowerWorker>>,
}
impl PowerManager {
pub fn new() -> Arc<Self> {
Arc::new(Self {
state: Arc::new(PowerState {
runtime_enabled: AtomicBool::new(false),
prevent_system_sleep: AtomicBool::new(true),
prevent_display_sleep: AtomicBool::new(false),
active_transfers: AtomicUsize::new(0),
}),
worker: Mutex::new(None),
})
}
/// Enables OS integration after AppState has been registered. This keeps
/// QueueManager tests and pre-window startup work from creating real
/// assertions in the test process or during partial initialization.
pub fn activate(&self) -> Result<(), String> {
self.state.runtime_enabled.store(true, Ordering::Release);
self.reconcile()
}
pub fn set_preferences(
&self,
prevent_system_sleep: bool,
prevent_display_sleep: bool,
) -> Result<(), String> {
self.state
.prevent_system_sleep
.store(prevent_system_sleep, Ordering::Release);
self.state
.prevent_display_sleep
.store(prevent_display_sleep, Ordering::Release);
self.reconcile()
}
pub fn set_system_prevention(&self, enabled: bool) -> Result<(), String> {
self.state
.prevent_system_sleep
.store(enabled, Ordering::Release);
self.reconcile()
}
pub fn set_display_prevention(&self, enabled: bool) -> Result<(), String> {
self.state
.prevent_display_sleep
.store(enabled, Ordering::Release);
self.reconcile()
}
pub fn set_active_transfer_count(self: &Arc<Self>, count: usize) {
self.state.active_transfers.store(count, Ordering::Release);
if self.state.runtime_enabled.load(Ordering::Acquire) {
if let Err(error) = self.enqueue_reconcile() {
log::error!(
"power: failed to schedule assertion reconciliation for {count} active transfer(s): {error}"
);
}
}
}
pub fn active_transfer_count(&self) -> usize {
self.state.active_transfers.load(Ordering::Acquire)
}
fn reconcile(&self) -> Result<(), String> {
if !self.state.runtime_enabled.load(Ordering::Acquire) {
return Ok(());
}
self.ensure_worker()?;
let (response_sender, response_receiver) = mpsc::channel();
if let Err(error) = self.send_command(PowerCommand::Reconcile(Some(response_sender))) {
self.restart_worker();
return Err(error);
}
match response_receiver.recv() {
Ok(result) => result,
Err(_) => {
self.restart_worker();
Err("power-management worker stopped before reporting reconciliation".to_string())
}
}
}
fn enqueue_reconcile(&self) -> Result<(), String> {
self.ensure_worker()?;
if let Err(error) = self.send_command(PowerCommand::Reconcile(None)) {
self.restart_worker();
return Err(error);
}
Ok(())
}
fn ensure_worker(&self) -> Result<(), String> {
let mut worker = self
.worker
.lock()
.unwrap_or_else(|error| error.into_inner());
if worker.is_none() {
*worker = Some(PowerWorker::start(Arc::clone(&self.state))?);
}
Ok(())
}
fn send_command(&self, command: PowerCommand) -> Result<(), String> {
let worker = self
.worker
.lock()
.unwrap_or_else(|error| error.into_inner());
worker
.as_ref()
.ok_or_else(|| "power-management worker is not running".to_string())?
.send(command)
}
fn restart_worker(&self) {
let mut worker = self
.worker
.lock()
.unwrap_or_else(|error| error.into_inner());
worker.take();
}
}
struct OsPowerAssertions {
#[cfg(windows)]
assertion: Option<keepawake::KeepAwake>,
#[cfg(not(windows))]
system: Option<keepawake::KeepAwake>,
#[cfg(not(windows))]
display: Option<keepawake::KeepAwake>,
system_enabled: bool,
display_enabled: bool,
}
impl OsPowerAssertions {
fn reconcile(&mut self, state: &PowerState) -> Result<(), String> {
let active = state.active_transfers.load(Ordering::Acquire);
let desired_system = active > 0 && state.prevent_system_sleep.load(Ordering::Acquire);
let desired_display = active > 0 && state.prevent_display_sleep.load(Ordering::Acquire);
#[cfg(windows)]
{
return self.reconcile_combined(desired_system, desired_display, active);
}
#[cfg(not(windows))]
self.reconcile_independent(desired_system, desired_display, active)
}
#[cfg(windows)]
fn reconcile_combined(
&mut self,
desired_system: bool,
desired_display: bool,
active: usize,
) -> Result<(), String> {
if self.system_enabled == desired_system && self.display_enabled == desired_display {
return Ok(());
}
if let Some(assertion) = self.assertion.take() {
drop_assertion(assertion, "combined");
if self.system_enabled {
log::info!("power: system-sleep prevention released");
}
if self.display_enabled {
log::info!("power: display-sleep prevention released");
}
self.system_enabled = false;
self.display_enabled = false;
}
if desired_system || desired_display {
let assertion = create_assertion(|| {
keepawake::Builder::default()
.display(desired_display)
.idle(desired_system)
.sleep(desired_system)
.reason(ASSERTION_REASON)
.create()
})
.map_err(|error| format!("failed to apply power prevention: {error}"))?;
self.assertion = Some(assertion);
self.system_enabled = desired_system;
self.display_enabled = desired_display;
if desired_system {
log::info!(
"power: system-sleep prevention enabled for {active} active transfer(s)"
);
}
if desired_display {
log::info!(
"power: display-sleep prevention enabled for {active} active transfer(s)"
);
}
}
Ok(())
}
#[cfg(not(windows))]
fn reconcile_independent(
&mut self,
desired_system: bool,
desired_display: bool,
active: usize,
) -> Result<(), String> {
let mut first_error = None;
if desired_system {
if self.system.is_none() {
match create_assertion(|| {
keepawake::Builder::default()
.idle(true)
.sleep(true)
.reason(ASSERTION_REASON)
.create()
}) {
Ok(assertion) => {
self.system = Some(assertion);
self.system_enabled = true;
log::info!(
"power: system-sleep prevention enabled for {active} active transfer(s)"
);
}
Err(error) => {
first_error = Some(format!("failed to prevent system sleep: {error}"))
}
}
}
} else if let Some(assertion) = self.system.take() {
drop_assertion(assertion, "system-sleep");
self.system_enabled = false;
log::info!("power: system-sleep prevention released");
}
if desired_display {
if self.display.is_none() {
match create_assertion(|| {
keepawake::Builder::default()
.display(true)
.reason(ASSERTION_REASON)
.create()
}) {
Ok(assertion) => {
self.display = Some(assertion);
self.display_enabled = true;
log::info!(
"power: display-sleep prevention enabled for {active} active transfer(s)"
);
}
Err(error) => {
let display_error = format!("failed to prevent display sleep: {error}");
if first_error.is_none() {
first_error = Some(display_error);
} else {
log::error!("power: {display_error}");
}
}
}
}
} else if let Some(assertion) = self.display.take() {
drop_assertion(assertion, "display-sleep");
self.display_enabled = false;
log::info!("power: display-sleep prevention released");
}
first_error.map_or(Ok(()), Err)
}
}
fn create_assertion(
create: impl FnOnce() -> keepawake::Result<keepawake::KeepAwake>,
) -> Result<keepawake::KeepAwake, String> {
std::panic::catch_unwind(std::panic::AssertUnwindSafe(create))
.map_err(|_| "keepawake assertion creation panicked".to_string())?
.map_err(|error| error.to_string())
}
fn drop_assertion(assertion: keepawake::KeepAwake, kind: &str) {
if std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| drop(assertion))).is_err() {
log::error!("power: keepawake {kind} assertion cleanup panicked");
}
}
fn run_power_worker(state: Arc<PowerState>, receiver: Receiver<PowerCommand>) {
let mut assertions = OsPowerAssertions {
#[cfg(windows)]
assertion: None,
#[cfg(not(windows))]
system: None,
#[cfg(not(windows))]
display: None,
system_enabled: false,
display_enabled: false,
};
while let Ok(PowerCommand::Reconcile(response)) = receiver.recv() {
let result = assertions.reconcile(&state);
if let Some(response) = response {
let _ = response.send(result);
} else if let Err(error) = result {
let active = state.active_transfers.load(Ordering::Acquire);
log::error!(
"power: failed to reconcile assertions for {active} active transfer(s): {error}"
);
}
}
#[cfg(windows)]
if let Some(assertion) = assertions.assertion.take() {
drop_assertion(assertion, "combined");
}
#[cfg(not(windows))]
{
if let Some(assertion) = assertions.display.take() {
drop_assertion(assertion, "display-sleep");
}
if let Some(assertion) = assertions.system.take() {
drop_assertion(assertion, "system-sleep");
}
}
}
#[cfg(test)]
mod tests {
use super::PowerManager;
#[test]
fn tracks_transfer_activity_before_runtime_activation() {
let manager = PowerManager::new();
manager.set_active_transfer_count(2);
assert_eq!(manager.active_transfer_count(), 2);
manager.set_active_transfer_count(0);
assert_eq!(manager.active_transfer_count(), 0);
}
#[test]
fn preference_changes_are_safe_before_runtime_activation() {
let manager = PowerManager::new();
assert!(manager.set_preferences(false, true).is_ok());
assert!(manager.set_system_prevention(true).is_ok());
assert!(manager.set_display_prevention(false).is_ok());
}
#[test]
fn runtime_worker_can_activate_without_active_transfers() {
let manager = PowerManager::new();
assert!(manager.activate().is_ok());
manager.set_active_transfer_count(0);
assert_eq!(manager.active_transfer_count(), 0);
}
}
+927 -64
View File
File diff suppressed because it is too large Load Diff
+75 -2
View File
@@ -1,6 +1,7 @@
use crate::ipc::{
AppFontSize, ListRowDensity, MediaCookieSource, PersistedSettings, PostQueueAction, ProxyMode,
SchedulerSettings, SettingsTab, Theme,
AppFontSize, CalendarPreference, FontFamily, ListRowDensity, MediaCookieSource,
PersistedSettings, PostQueueAction, ProxyMode, SchedulerSettings, SettingsTab, Theme,
WindowControlStyle,
};
use serde_json::{Map, Value};
use std::collections::HashMap;
@@ -192,11 +193,32 @@ fn sanitize_persisted_setting_values(state: &mut Value) {
"theme",
&["system", "light", "dark", "dracula", "nord"],
);
sanitize_allowed_string(
state,
"fontFamily",
&[
"system",
"inter",
"outfit",
"vazirmatn",
"noto-sans-hebrew",
"noto-sans-sc",
"roboto",
"serif",
"monospace",
],
);
sanitize_allowed_string(
state,
"windowControlStyle",
&["auto", "macos", "windows", "gnome", "minimal"],
);
sanitize_allowed_string(
state,
"language",
&["system", "en", "zh-CN", "he", "fa", "uk", "ru"],
);
sanitize_allowed_string(state, "calendarPreference", &["gregorian", "persian", "hebrew"]);
sanitize_allowed_string(state, "sidebarPosition", &["auto", "left", "right"]);
sanitize_allowed_string(state, "appFontSize", &["small", "standard", "large"]);
sanitize_allowed_string(state, "listRowDensity", &["compact", "standard", "relaxed"]);
@@ -416,6 +438,9 @@ fn derived_location_path(base: &str, subfolder: &str) -> String {
fn default_settings() -> PersistedSettings {
PersistedSettings {
theme: Theme::System,
font_family: FontFamily::System,
window_control_style: WindowControlStyle::Auto,
calendar_preference: CalendarPreference::Gregorian,
language: "system".to_string(),
base_download_folder: "~/Downloads".to_string(),
category_subfolders_enabled: true,
@@ -461,6 +486,7 @@ fn default_settings() -> PersistedSettings {
ask_where_to_save_each_file: false,
remember_last_used_download_directory: false,
prevents_sleep_while_downloading: true,
prevents_display_sleep_while_downloading: false,
media_cookie_source: MediaCookieSource::default(),
site_logins: Vec::new(),
auto_check_updates: true,
@@ -470,6 +496,7 @@ fn default_settings() -> PersistedSettings {
#[cfg(test)]
mod tests {
use crate::ipc::{FontFamily, WindowControlStyle};
use super::{
decode_stored_settings, default_settings, preserve_portable_pairing_token,
preserve_scheduler_runtime_keys,
@@ -676,6 +703,47 @@ mod tests {
assert_eq!(settings.sidebar_position, "auto");
}
#[test]
fn invalid_font_family_uses_system_font() {
let stored = json!({
"state": {"fontFamily": "comic-sans"},
"version": 5
});
let settings = decode_stored_settings(&Value::String(stored.to_string())).unwrap();
assert!(matches!(settings.font_family, FontFamily::System));
}
#[test]
fn curated_font_families_are_preserved() {
for value in ["vazirmatn", "noto-sans-hebrew", "noto-sans-sc", "roboto"] {
let stored = json!({
"state": {"fontFamily": value},
"version": 5
});
let settings = decode_stored_settings(&Value::String(stored.to_string())).unwrap();
assert_eq!(serde_json::to_value(settings.font_family).unwrap(), json!(value));
}
}
#[test]
fn invalid_window_control_style_uses_automatic_layout() {
let stored = json!({
"state": {"windowControlStyle": "neon"},
"version": 5
});
let settings = decode_stored_settings(&Value::String(stored.to_string())).unwrap();
assert!(matches!(
settings.window_control_style,
WindowControlStyle::Auto
));
}
#[test]
fn clamps_out_of_range_download_settings() {
let stored = json!({
@@ -703,6 +771,7 @@ mod tests {
"perServerConnections": 5,
"showNotifications": "yes",
"theme": "not-a-theme",
"calendarPreference": "lunar",
"siteLogins": [{"id": "valid", "urlPattern": "example.com", "username": "user"}, {"id": 3}]
},
"version": 3
@@ -715,6 +784,10 @@ mod tests {
assert_eq!(settings.per_server_connections, 5);
assert!(settings.show_notifications);
assert!(matches!(settings.theme, crate::ipc::Theme::System));
assert!(matches!(
settings.calendar_preference,
crate::ipc::CalendarPreference::Gregorian
));
assert_eq!(settings.site_logins.len(), 1);
assert_eq!(settings.site_logins[0].id, "valid");
}
+1 -1
View File
@@ -23,7 +23,7 @@
}
],
"security": {
"csp": "default-src 'self'; img-src 'self' data: https:; script-src 'self'; style-src 'self' 'unsafe-inline'; connect-src 'self' ws://localhost:* http://localhost:* http://127.0.0.1:* ws://127.0.0.1:*"
"csp": "default-src 'self'; img-src 'self' data: https:; script-src 'self'; style-src 'self' 'unsafe-inline'; font-src 'self'; connect-src 'self' ws://localhost:* http://localhost:* http://127.0.0.1:* ws://127.0.0.1:*"
}
},
"bundle": {
+819 -7
View File
@@ -1,6 +1,6 @@
use firelink_lib::queue::{
Aria2RefreshOutcome, QueueManager, QueuedTask, SidecarSpawner, SpawnPayload, TaskKind,
MEDIA_RUN_CANCELLED,
Aria2RecreateOutcome, Aria2RefreshOutcome, QueueManager, QueuedTask, SidecarSpawner,
SpawnPayload, TaskKind, MEDIA_RUN_CANCELLED,
};
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;
@@ -13,6 +13,12 @@ use tokio::time::timeout;
struct CountingSpawner {
add_uri_calls: AtomicUsize,
media_calls: AtomicUsize,
speed_limit_calls: AtomicUsize,
last_speed_limit: std::sync::Mutex<Option<String>>,
add_speed_limits: std::sync::Mutex<Vec<Option<String>>>,
block_speed_limit: std::sync::atomic::AtomicBool,
speed_limit_started: tokio::sync::Notify,
speed_limit_release: tokio::sync::Notify,
}
struct DelayedAria2Spawner {
@@ -21,6 +27,13 @@ struct DelayedAria2Spawner {
remove_uri_calls: AtomicUsize,
}
struct BlockingAria2Spawner {
first_started: tokio::sync::Notify,
second_started: tokio::sync::Notify,
release_first: tokio::sync::Notify,
add_uri_calls: AtomicUsize,
}
struct FailFirstAria2Spawner {
add_uri_calls: AtomicUsize,
fail_first: std::sync::atomic::AtomicBool,
@@ -31,6 +44,34 @@ struct RefreshOutcomeSpawner {
refresh_calls: AtomicUsize,
}
struct RecreateSpawner {
recreate_calls: AtomicUsize,
remove_calls: AtomicUsize,
recreate_started: tokio::sync::Notify,
release_recreate: tokio::sync::Notify,
block_recreate: std::sync::atomic::AtomicBool,
outcome: std::sync::Mutex<Aria2RecreateOutcome>,
}
impl RecreateSpawner {
fn new(block_recreate: bool) -> Self {
Self {
recreate_calls: AtomicUsize::new(0),
remove_calls: AtomicUsize::new(0),
recreate_started: tokio::sync::Notify::new(),
release_recreate: tokio::sync::Notify::new(),
block_recreate: std::sync::atomic::AtomicBool::new(block_recreate),
outcome: std::sync::Mutex::new(Aria2RecreateOutcome::NewGid(
"gid-recreate-new".to_string(),
)),
}
}
fn set_outcome(&self, outcome: Aria2RecreateOutcome) {
*self.outcome.lock().unwrap() = outcome;
}
}
impl FailFirstAria2Spawner {
fn new() -> Self {
Self {
@@ -50,6 +91,49 @@ impl DelayedAria2Spawner {
}
}
impl BlockingAria2Spawner {
fn new() -> Self {
Self {
first_started: tokio::sync::Notify::new(),
second_started: tokio::sync::Notify::new(),
release_first: tokio::sync::Notify::new(),
add_uri_calls: AtomicUsize::new(0),
}
}
}
#[async_trait::async_trait]
impl SidecarSpawner for BlockingAria2Spawner {
async fn add_uri(&self, _id: &str, _payload: &SpawnPayload) -> Result<String, String> {
let call = self.add_uri_calls.fetch_add(1, Ordering::SeqCst) + 1;
match call {
1 => {
self.first_started.notify_one();
self.release_first.notified().await;
Ok("gid-first".to_string())
}
2 => {
self.second_started.notify_one();
Ok("gid-second".to_string())
}
_ => Ok(format!("gid-{call}")),
}
}
async fn remove_uri(&self, _gid: &str) -> Result<(), String> {
Ok(())
}
async fn run_media(
&self,
_id: &str,
_payload: &SpawnPayload,
_generation: u64,
) -> Result<(), String> {
unreachable!("media is not used by blocking aria2 tests")
}
}
#[async_trait::async_trait]
impl SidecarSpawner for DelayedAria2Spawner {
async fn add_uri(&self, _id: &str, _payload: &SpawnPayload) -> Result<String, String> {
@@ -102,6 +186,12 @@ impl CountingSpawner {
Self {
add_uri_calls: AtomicUsize::new(0),
media_calls: AtomicUsize::new(0),
speed_limit_calls: AtomicUsize::new(0),
last_speed_limit: std::sync::Mutex::new(None),
add_speed_limits: std::sync::Mutex::new(Vec::new()),
block_speed_limit: std::sync::atomic::AtomicBool::new(false),
speed_limit_started: tokio::sync::Notify::new(),
speed_limit_release: tokio::sync::Notify::new(),
}
}
}
@@ -132,14 +222,70 @@ impl SidecarSpawner for RefreshOutcomeSpawner {
}
#[async_trait::async_trait]
impl firelink_lib::queue::SidecarSpawner for CountingSpawner {
impl SidecarSpawner for RecreateSpawner {
async fn add_uri(&self, _id: &str, _payload: &SpawnPayload) -> Result<String, String> {
Ok("gid-recreate-old".to_string())
}
async fn remove_uri(&self, gid: &str) -> Result<(), String> {
assert_eq!(gid, "gid-recreate-new");
self.remove_calls.fetch_add(1, Ordering::SeqCst);
Ok(())
}
async fn recreate_uri(
&self,
_id: &str,
_gid: &str,
_payload: &SpawnPayload,
) -> Result<Aria2RecreateOutcome, String> {
self.recreate_calls.fetch_add(1, Ordering::SeqCst);
self.recreate_started.notify_one();
if self.block_recreate.load(Ordering::SeqCst) {
self.release_recreate.notified().await;
}
Ok(self.outcome.lock().unwrap().clone())
}
async fn run_media(
&self,
_id: &str,
_payload: &SpawnPayload,
_generation: u64,
) -> Result<(), String> {
unreachable!("media is not used by recreation tests")
}
}
#[async_trait::async_trait]
impl firelink_lib::queue::SidecarSpawner for CountingSpawner {
async fn add_uri(&self, _id: &str, payload: &SpawnPayload) -> Result<String, String> {
self.add_uri_calls.fetch_add(1, Ordering::SeqCst);
self.add_speed_limits
.lock()
.unwrap()
.push(payload.speed_limit.clone());
Ok(format!("gid-{}", self.add_uri_calls.load(Ordering::SeqCst)))
}
async fn remove_uri(&self, _gid: &str) -> Result<(), String> {
Ok(())
}
async fn set_download_speed_limit(
&self,
_gid: &str,
limit: Option<&str>,
) -> Result<(), String> {
self.speed_limit_calls.fetch_add(1, Ordering::SeqCst);
*self.last_speed_limit.lock().unwrap() = limit.map(str::to_string);
if self
.block_speed_limit
.load(std::sync::atomic::Ordering::SeqCst)
{
self.speed_limit_started.notify_one();
self.speed_limit_release.notified().await;
}
Ok(())
}
async fn run_media(&self, _id: &str, _payload: &SpawnPayload, _generation: u64) -> Result<(), String> {
self.media_calls.fetch_add(1, Ordering::SeqCst);
Ok(())
@@ -258,6 +404,207 @@ async fn ensure_aria2_permit_does_not_double_acquire() {
assert_eq!(mgr.available_permits(), 2);
}
#[tokio::test]
async fn backend_power_activity_follows_active_permits_not_queue_reservations() {
let (mgr, _spawner) = make_manager(2);
assert_eq!(mgr.power_manager().active_transfer_count(), 0);
let reservation = mgr
.reserve_enqueue_generation("queued", 1)
.await
.expect("queue reservation should succeed");
assert_eq!(mgr.power_manager().active_transfer_count(), 0);
mgr.rollback_enqueue_reservation("queued", 1, reservation)
.await;
assert!(mgr.ensure_aria2_permit("active").await);
assert_eq!(mgr.power_manager().active_transfer_count(), 1);
mgr.release_permit("active").await;
assert_eq!(mgr.power_manager().active_transfer_count(), 0);
}
#[tokio::test]
async fn live_aria2_speed_limit_updates_the_current_gid_and_payload() {
let (manager, spawner) = make_manager(1);
let manager = Arc::new(manager);
let mut task = aria2_task("speed-limit");
task.payload.speed_limit = Some("1M".to_string());
manager.push(task).await.unwrap();
let dispatcher = {
let manager = Arc::clone(&manager);
tokio::spawn(async move { manager.run_dispatcher().await })
};
timeout(Duration::from_secs(1), async {
loop {
if manager.aria2_gid_for_download("speed-limit").is_some() {
break;
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
})
.await
.expect("aria2 dispatch should register a gid");
manager
.set_aria2_download_speed_limit("speed-limit", Some("512K".to_string()))
.await
.unwrap();
assert_eq!(spawner.speed_limit_calls.load(Ordering::SeqCst), 1);
assert_eq!(
spawner.last_speed_limit.lock().unwrap().as_deref(),
Some("512K")
);
assert!(manager.aria2_speed_limited("speed-limit").await);
manager
.set_aria2_download_speed_limit("speed-limit", None)
.await
.unwrap();
assert_eq!(spawner.speed_limit_calls.load(Ordering::SeqCst), 2);
assert!(spawner.last_speed_limit.lock().unwrap().is_none());
assert!(!manager.aria2_speed_limited("speed-limit").await);
manager
.apply_completion(
"speed-limit",
firelink_lib::queue::PendingOutcome::Complete,
)
.await;
dispatcher.abort();
}
#[tokio::test]
async fn live_aria2_speed_limit_rejects_invalid_and_non_active_requests() {
let (manager, spawner) = make_manager(1);
assert!(manager
.set_aria2_download_speed_limit("missing", Some("not-a-rate".to_string()))
.await
.is_err());
assert!(manager
.set_aria2_download_speed_limit("missing", Some("512K".to_string()))
.await
.is_err());
assert_eq!(spawner.speed_limit_calls.load(Ordering::SeqCst), 0);
}
#[tokio::test]
async fn live_aria2_speed_limit_does_not_update_payload_after_gid_replacement() {
let (manager, spawner) = make_manager(1);
let manager = Arc::new(manager);
let mut task = aria2_task("speed-stale");
task.payload.speed_limit = Some("1M".to_string());
manager.push(task).await.unwrap();
let dispatcher = {
let manager = Arc::clone(&manager);
tokio::spawn(async move { manager.run_dispatcher().await })
};
timeout(Duration::from_secs(1), async {
loop {
if manager.aria2_gid_for_download("speed-stale").is_some() {
break;
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
})
.await
.expect("aria2 dispatch should register a gid");
spawner
.block_speed_limit
.store(true, std::sync::atomic::Ordering::SeqCst);
let started = spawner.speed_limit_started.notified();
let setter = {
let manager = Arc::clone(&manager);
tokio::spawn(async move {
manager
.set_aria2_download_speed_limit("speed-stale", Some("512K".to_string()))
.await
})
};
timeout(Duration::from_secs(1), started)
.await
.expect("speed RPC should start");
manager
.remember_gid("speed-stale".to_string(), "gid-replaced".to_string())
.await;
spawner.speed_limit_release.notify_one();
assert!(setter.await.unwrap().is_err());
assert!(manager.aria2_speed_limited("speed-stale").await);
manager
.apply_completion(
"speed-stale",
firelink_lib::queue::PendingOutcome::Complete,
)
.await;
dispatcher.abort();
}
#[tokio::test]
async fn retry_readds_aria2_with_the_latest_live_speed_limit() {
use firelink_lib::queue::PendingOutcome;
let (mgr, spawner) = make_manager(1);
let manager = Arc::new(mgr);
let mut task = aria2_task("speed-retry");
task.payload.max_tries = Some(1);
task.payload.speed_limit = Some("1M".to_string());
manager.push(task).await.unwrap();
let dispatcher = {
let manager = Arc::clone(&manager);
tokio::spawn(async move { manager.run_dispatcher().await })
};
timeout(Duration::from_secs(1), async {
loop {
if spawner.add_uri_calls.load(Ordering::SeqCst) >= 1 {
break;
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
})
.await
.expect("initial aria2 add should run");
manager
.handle_aria2_event(
"gid-1",
PendingOutcome::Error(
"aria2 error code 1: Failed to receive data, cause: protocol error".to_string(),
),
)
.await;
manager
.set_aria2_download_speed_limit("speed-retry", Some("512K".to_string()))
.await
.unwrap();
timeout(Duration::from_secs(4), async {
loop {
if spawner.add_uri_calls.load(Ordering::SeqCst) >= 2 {
break;
}
tokio::time::sleep(Duration::from_millis(25)).await;
}
})
.await
.expect("retry should re-add after backoff");
assert_eq!(
spawner.add_speed_limits.lock().unwrap().as_slice(),
&[Some("1M".to_string()), Some("512K".to_string())]
);
manager
.handle_aria2_event(
"gid-2",
PendingOutcome::Error("permanent failure".to_string()),
)
.await;
dispatcher.abort();
}
#[tokio::test]
async fn stale_aria2_permit_candidate_cannot_replace_current_permit() {
let (mgr, _spawner) = make_manager(2);
@@ -569,6 +916,285 @@ async fn aria2_resume_waits_for_shrunk_capacity() {
.expect("resume task should not panic"));
}
#[tokio::test]
async fn dispatcher_skips_a_full_front_queue_for_later_eligible_work() {
let (manager, _spawner) = make_manager(2);
let manager = Arc::new(manager);
manager
.replace_queue_limits(vec![
("queue-a".to_string(), Some(1)),
("queue-b".to_string(), Some(1)),
])
.await
.unwrap();
manager.push(aria2_task_in_queue("a1", "queue-a")).await.unwrap();
manager.push(aria2_task_in_queue("a2", "queue-a")).await.unwrap();
manager.push(aria2_task_in_queue("b1", "queue-b")).await.unwrap();
let dispatcher = {
let manager = Arc::clone(&manager);
tokio::spawn(async move { manager.run_dispatcher().await })
};
timeout(Duration::from_secs(1), async {
while manager.aria2_gid_for_download("a1").is_none()
|| manager.aria2_gid_for_download("b1").is_none()
{
tokio::time::sleep(Duration::from_millis(10)).await;
}
})
.await
.expect("both eligible queues should finish dispatching");
assert!(manager.aria2_gid_for_download("a1").is_some());
assert!(manager.aria2_gid_for_download("b1").is_some());
assert!(manager.aria2_gid_for_download("a2").is_none());
dispatcher.abort();
}
#[tokio::test]
async fn dispatcher_admits_next_task_while_first_add_uri_is_blocked() {
let app = mock_builder()
.build(mock_context(noop_assets()))
.expect("mock app");
let spawner = Arc::new(BlockingAria2Spawner::new());
let manager = Arc::new(QueueManager::test_new(
app.handle().clone(),
2,
spawner.clone(),
));
manager.push(aria2_task("blocked-first")).await.unwrap();
manager.push(aria2_task("admitted-second")).await.unwrap();
let first_started = spawner.first_started.notified();
let dispatcher = {
let manager = Arc::clone(&manager);
tokio::spawn(async move { manager.run_dispatcher().await })
};
timeout(Duration::from_secs(1), first_started)
.await
.expect("the first addUri should start");
timeout(Duration::from_secs(1), spawner.second_started.notified())
.await
.expect("a blocked addUri must not serialize admission of the next task");
spawner.release_first.notify_one();
timeout(Duration::from_secs(1), async {
while manager.aria2_gid_for_download("blocked-first").is_none()
|| manager.aria2_gid_for_download("admitted-second").is_none()
{
tokio::time::sleep(Duration::from_millis(10)).await;
}
})
.await
.expect("both admitted tasks should finish dispatching");
dispatcher.abort();
}
#[tokio::test]
async fn dispatcher_rotates_eligible_queues_without_starving_later_work() {
let (manager, spawner) = make_manager(1);
let manager = Arc::new(manager);
manager
.replace_queue_limits(vec![
("queue-a".to_string(), Some(1)),
("queue-b".to_string(), Some(1)),
])
.await
.unwrap();
manager.push(aria2_task_in_queue("a1", "queue-a")).await.unwrap();
manager.push(aria2_task_in_queue("a2", "queue-a")).await.unwrap();
manager.push(aria2_task_in_queue("b1", "queue-b")).await.unwrap();
let dispatcher = {
let manager = Arc::clone(&manager);
tokio::spawn(async move { manager.run_dispatcher().await })
};
timeout(Duration::from_secs(1), async {
while spawner.add_uri_calls.load(Ordering::SeqCst) < 1 {
tokio::time::sleep(Duration::from_millis(10)).await;
}
})
.await
.expect("first queue task should dispatch");
manager.release_permit("a1").await;
timeout(Duration::from_secs(1), async {
while manager.aria2_gid_for_download("b1").is_none() {
tokio::time::sleep(Duration::from_millis(10)).await;
}
})
.await
.expect("later queue should be selected before another task from queue-a");
assert!(manager.aria2_gid_for_download("a2").is_none());
dispatcher.abort();
}
#[tokio::test]
async fn queue_limit_increase_wakes_waiting_work_and_decrease_keeps_active_tasks() {
let (manager, spawner) = make_manager(3);
let manager = Arc::new(manager);
manager
.replace_queue_limits(vec![("queue-a".to_string(), Some(2))])
.await
.unwrap();
for id in ["a1", "a2", "a3"] {
manager.push(aria2_task_in_queue(id, "queue-a")).await.unwrap();
}
let dispatcher = {
let manager = Arc::clone(&manager);
tokio::spawn(async move { manager.run_dispatcher().await })
};
timeout(Duration::from_secs(1), async {
while spawner.add_uri_calls.load(Ordering::SeqCst) < 2 {
tokio::time::sleep(Duration::from_millis(10)).await;
}
})
.await
.expect("queue override should allow two active transfers");
manager
.replace_queue_limits(vec![("queue-a".to_string(), Some(1))])
.await
.unwrap();
assert_eq!(spawner.add_uri_calls.load(Ordering::SeqCst), 2);
manager.release_permit("a1").await;
tokio::time::sleep(Duration::from_millis(50)).await;
assert_eq!(
spawner.add_uri_calls.load(Ordering::SeqCst),
2,
"reducing a queue limit must not admit work while one active transfer remains"
);
manager.release_permit("a2").await;
timeout(Duration::from_secs(1), async {
while spawner.add_uri_calls.load(Ordering::SeqCst) < 3 {
tokio::time::sleep(Duration::from_millis(10)).await;
}
})
.await
.expect("queue work should resume after active count falls below the reduced limit");
dispatcher.abort();
}
#[tokio::test]
async fn queue_overrides_never_exceed_the_global_ceiling() {
let (manager, spawner) = make_manager(2);
let manager = Arc::new(manager);
manager
.replace_queue_limits(vec![
("queue-a".to_string(), Some(12)),
("queue-b".to_string(), Some(12)),
])
.await
.unwrap();
for (id, queue_id) in [
("a1", "queue-a"),
("a2", "queue-a"),
("b1", "queue-b"),
("b2", "queue-b"),
] {
manager.push(aria2_task_in_queue(id, queue_id)).await.unwrap();
}
let dispatcher = {
let manager = Arc::clone(&manager);
tokio::spawn(async move { manager.run_dispatcher().await })
};
tokio::time::sleep(Duration::from_millis(100)).await;
assert_eq!(spawner.add_uri_calls.load(Ordering::SeqCst), 2);
assert_eq!(manager.available_permits(), 0);
dispatcher.abort();
}
#[tokio::test]
async fn queue_limit_configuration_rejects_malformed_input() {
let (manager, _spawner) = make_manager(2);
assert!(manager
.replace_queue_limits(vec![(String::new(), Some(1))])
.await
.is_err());
assert!(manager
.replace_queue_limits(vec![("queue-a".to_string(), Some(0))])
.await
.is_err());
assert!(manager
.replace_queue_limits(vec![
("queue-a".to_string(), Some(1)),
("queue-a".to_string(), None),
])
.await
.is_err());
manager
.replace_queue_limits(vec![("queue-a".to_string(), None)])
.await
.unwrap();
}
#[tokio::test]
async fn stale_queue_resume_reservation_is_removed_when_activation_fails() {
let (manager, _spawner) = make_manager(1);
let previous = manager
.reserve_enqueue_generation("candidate", 1)
.await
.unwrap();
assert!(previous.is_none());
let candidate = manager
.acquire_aria2_permit_candidate_for_queue("candidate", "queue-a", 1)
.await
.expect("candidate should reserve the queue slot");
manager.release_registered_id("candidate").await;
assert!(!manager
.park_aria2_permit_if_missing_for_queue("candidate", "queue-a", 1, candidate)
.await);
assert_eq!(manager.available_permits(), 1);
assert!(manager.ensure_aria2_permit_for_queue("replacement", "queue-a").await);
manager.release_permit("replacement").await;
}
#[tokio::test]
async fn duplicate_pending_id_cannot_replace_an_existing_queue_ownership() {
let (manager, spawner) = make_manager(2);
let manager = Arc::new(manager);
manager
.replace_queue_limits(vec![
("queue-a".to_string(), Some(1)),
("queue-b".to_string(), Some(1)),
])
.await
.unwrap();
assert!(manager
.ensure_aria2_permit_for_queue("duplicate", "queue-b")
.await);
manager
.reserve_enqueue_generation("duplicate", 1)
.await
.unwrap();
manager
.commit_reserved_enqueue(aria2_task_in_queue("duplicate", "queue-a"), 1)
.await
.unwrap();
let dispatcher = {
let manager = Arc::clone(&manager);
tokio::spawn(async move { manager.run_dispatcher().await })
};
tokio::time::sleep(Duration::from_millis(100)).await;
assert_eq!(spawner.add_uri_calls.load(Ordering::SeqCst), 0);
assert_eq!(manager.available_permits(), 1);
manager.release_permit("duplicate").await;
dispatcher.abort();
}
fn aria2_task(id: &str) -> QueuedTask {
QueuedTask {
id: id.to_string(),
@@ -579,6 +1205,12 @@ fn aria2_task(id: &str) -> QueuedTask {
}
}
fn aria2_task_in_queue(id: &str, queue_id: &str) -> QueuedTask {
let mut task = aria2_task(id);
task.queue_id = queue_id.to_string();
task
}
fn media_task(id: &str) -> QueuedTask {
QueuedTask {
id: id.to_string(),
@@ -714,11 +1346,11 @@ async fn failed_refresh_that_leaves_gid_paused_releases_permit_but_keeps_resume_
outcome: Aria2RefreshOutcome::Paused,
refresh_calls: AtomicUsize::new(0),
});
let manager = QueueManager::test_new(
let manager = Arc::new(QueueManager::test_new(
app.handle().clone(),
1,
Arc::clone(&spawner) as Arc<dyn SidecarSpawner>,
);
));
manager.push(aria2_task("refresh-paused")).await.unwrap();
assert!(manager.ensure_aria2_permit("refresh-paused").await);
manager
@@ -741,6 +1373,159 @@ async fn failed_refresh_that_leaves_gid_paused_releases_permit_but_keeps_resume_
assert!(manager.is_registered("refresh-paused").await);
}
#[tokio::test]
async fn connection_recovery_recreates_gid_without_releasing_transfer_permit() {
let app = mock_builder()
.build(mock_context(noop_assets()))
.expect("mock app");
let spawner = Arc::new(RecreateSpawner::new(false));
let manager = Arc::new(QueueManager::test_new(
app.handle().clone(),
1,
Arc::clone(&spawner) as Arc<dyn SidecarSpawner>,
));
manager.push(aria2_task("recreate")).await.unwrap();
let dispatcher = {
let manager = Arc::clone(&manager);
tokio::spawn(async move { manager.run_dispatcher().await })
};
timeout(Duration::from_secs(1), async {
loop {
if manager.aria2_gid_for_download("recreate").as_deref() == Some("gid-recreate-old") {
break;
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
})
.await
.expect("initial gid should be mapped");
let epoch = manager.current_aria2_control_epoch("recreate").await;
manager
.refresh_aria2_connections("recreate", "gid-recreate-old", epoch)
.await
.unwrap();
assert_eq!(spawner.recreate_calls.load(Ordering::SeqCst), 1);
assert_eq!(
manager.aria2_gid_for_download("recreate").as_deref(),
Some("gid-recreate-new")
);
assert!(manager.has_active_permit("recreate").await);
assert_eq!(manager.available_permits(), 0);
manager.release_permit("recreate").await;
dispatcher.abort();
}
#[tokio::test]
async fn stale_recreated_gid_is_removed_without_rebinding_or_leaking_permit() {
let app = mock_builder()
.build(mock_context(noop_assets()))
.expect("mock app");
let spawner = Arc::new(RecreateSpawner::new(true));
let manager = Arc::new(QueueManager::test_new(
app.handle().clone(),
1,
Arc::clone(&spawner) as Arc<dyn SidecarSpawner>,
));
manager.push(aria2_task("recreate-stale")).await.unwrap();
let dispatcher = {
let manager = Arc::clone(&manager);
tokio::spawn(async move { manager.run_dispatcher().await })
};
timeout(Duration::from_secs(1), async {
loop {
if manager.aria2_gid_for_download("recreate-stale").as_deref()
== Some("gid-recreate-old")
{
break;
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
})
.await
.expect("initial gid should be mapped");
let epoch = manager.current_aria2_control_epoch("recreate-stale").await;
let refresh = {
let manager = Arc::clone(&manager);
tokio::spawn(async move {
manager
.refresh_aria2_connections("recreate-stale", "gid-recreate-old", epoch)
.await
})
};
timeout(Duration::from_secs(1), spawner.recreate_started.notified())
.await
.expect("recreation should reach its async boundary");
manager.next_aria2_control_epoch("recreate-stale").await;
spawner.release_recreate.notify_one();
refresh.await.unwrap().unwrap();
assert_eq!(spawner.remove_calls.load(Ordering::SeqCst), 1);
assert_eq!(
manager.aria2_gid_for_download("recreate-stale").as_deref(),
Some("gid-recreate-old")
);
assert!(manager.has_active_permit("recreate-stale").await);
assert_eq!(manager.available_permits(), 0);
manager.release_permit("recreate-stale").await;
dispatcher.abort();
}
#[tokio::test]
async fn unavailable_recreated_gid_retires_stale_lifecycle_for_manual_resume() {
let app = mock_builder()
.build(mock_context(noop_assets()))
.expect("mock app");
let spawner = Arc::new(RecreateSpawner::new(false));
spawner.set_outcome(Aria2RecreateOutcome::Unavailable(
"aria2.addUri unavailable".to_string(),
));
let manager = Arc::new(QueueManager::test_new(
app.handle().clone(),
1,
Arc::clone(&spawner) as Arc<dyn SidecarSpawner>,
));
manager.push(aria2_task("recreate-unavailable")).await.unwrap();
let dispatcher = {
let manager = Arc::clone(&manager);
tokio::spawn(async move { manager.run_dispatcher().await })
};
timeout(Duration::from_secs(1), async {
loop {
if manager
.aria2_gid_for_download("recreate-unavailable")
.as_deref()
== Some("gid-recreate-old")
{
break;
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
})
.await
.expect("initial gid should be mapped");
let epoch = manager.current_aria2_control_epoch("recreate-unavailable").await;
manager
.refresh_aria2_connections("recreate-unavailable", "gid-recreate-old", epoch)
.await
.unwrap();
assert_eq!(manager.aria2_gid_for_download("recreate-unavailable"), None);
assert!(!manager.has_active_permit("recreate-unavailable").await);
assert!(!manager.is_registered("recreate-unavailable").await);
assert_eq!(manager.available_permits(), 1);
dispatcher.abort();
}
#[tokio::test]
async fn stale_refresh_observation_cannot_touch_a_newer_control_epoch() {
let app = mock_builder()
@@ -750,11 +1535,11 @@ async fn stale_refresh_observation_cannot_touch_a_newer_control_epoch() {
outcome: Aria2RefreshOutcome::Resumed,
refresh_calls: AtomicUsize::new(0),
});
let manager = QueueManager::test_new(
let manager = Arc::new(QueueManager::test_new(
app.handle().clone(),
1,
Arc::clone(&spawner) as Arc<dyn SidecarSpawner>,
);
));
manager.push(aria2_task("refresh-stale")).await.unwrap();
assert!(manager.ensure_aria2_permit("refresh-stale").await);
manager
@@ -1247,6 +2032,33 @@ async fn multi_move_reorders_selected_items_as_one_atomic_block() {
);
}
#[tokio::test]
async fn target_move_reorders_a_selected_block_and_clamps_the_target() {
use firelink_lib::ipc::QueueDirection;
let (mgr, _spawner) = make_manager(3);
for id in ["a", "b", "c", "d", "e"] {
mgr.push(sample_task(id)).await.unwrap();
}
let selected = vec!["b".to_string(), "d".to_string()];
assert_eq!(
mgr.move_many_in_queue_to(&selected, "main", 1).await,
vec!["a", "b", "d", "c", "e"]
);
assert_eq!(
mgr.move_many_in_queue_to(&selected, "main", usize::MAX).await,
vec!["a", "c", "e", "b", "d"]
);
// The original direction API remains unchanged for keyboard/button moves.
assert_eq!(
mgr.move_many_in_queue(&selected, "main", QueueDirection::Up)
.await,
vec!["a", "c", "b", "d", "e"]
);
}
#[tokio::test]
async fn moving_one_queue_does_not_reorder_another_queue() {
use firelink_lib::ipc::QueueDirection;
+83 -18
View File
@@ -2,7 +2,10 @@ import { initMediaDomains, isActiveDownloadStatus, isTransferActiveStatus } from
import { schedulerCompletionState } from './utils/schedulerCompletion';
import { lazy, Suspense, useCallback, useEffect, useRef, useState } from "react";
import { Sidebar, SidebarFilter } from "./components/Sidebar";
import { DownloadTable } from "./components/DownloadTable";
import { DownloadTable, type DownloadTableStatusSummary } from "./components/DownloadTable";
// Keep the primary Add action eager so the modal cannot disappear behind a
// null Suspense fallback while its development chunk is being transformed.
import { AddDownloadsModal } from './components/AddDownloadsModal';
import { KeychainPermissionModal } from './components/KeychainPermissionModal';
import { extractValidDownloadUrls } from './utils/url';
import { readClipboardDownloadUrls } from './utils/clipboard';
@@ -17,6 +20,7 @@ import { setLogStreamActive } from './utils/logger';
import { updateDockBadge } from './utils/dockBadge';
import { openUrl } from '@tauri-apps/plugin-opener';
import { getPlatformInfo, shouldUseCustomWindowControls, usePlatformInfo } from './utils/platform';
import { resolveWindowControlStyle } from './utils/windowControlStyle';
import {
getKeychainAccessReady,
getKeychainConsentVersion,
@@ -28,6 +32,7 @@ import { PanelLeft } from 'lucide-react';
import { isTrustedFirelinkReleaseUrl } from './utils/releaseUrls';
import { changeAppLocale, localeDirection, resolveAppLocale, syncDocumentLocale } from './i18n';
import { useTranslation } from 'react-i18next';
import { formatDownloadBytes } from './utils/downloadProgress';
const loadSettingsView = () => import('./components/SettingsView');
const loadSchedulerView = () => import('./components/SchedulerView');
@@ -44,9 +49,6 @@ 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,
})));
@@ -99,6 +101,7 @@ const PageLoadingFallback = () => {
let automaticUpdateCheckStarted = false;
const processingScheduleKeys = new Set<string>();
let powerPreferencesSync: Promise<void> = Promise.resolve();
const waitForSettingsHydration = (): Promise<void> => {
if (useSettingsStore.persist.hasHydrated()) return Promise.resolve();
@@ -166,6 +169,7 @@ function App() {
const { i18n, t } = useTranslation();
const platform = usePlatformInfo();
const [filter, setFilter] = useState<SidebarFilter>('all');
const [downloadTableSummary, setDownloadTableSummary] = useState<DownloadTableStatusSummary | null>(null);
const [coreReady, setCoreReady] = useState(false);
const [keychainConsentVersion, setKeychainConsentVersion] = useState('');
@@ -185,11 +189,13 @@ function App() {
});
const theme = useSettingsStore(state => state.theme);
const windowControlStylePreference = useSettingsStore(state => state.windowControlStyle);
const languagePreference = useSettingsStore(state => state.language);
const isSidebarVisible = useSettingsStore(state => state.isSidebarVisible);
const sidebarPosition = useSettingsStore(state => state.sidebarPosition);
const toggleSidebar = useSettingsStore(state => state.toggleSidebar);
const activeView = useSettingsStore(state => state.activeView);
const fontFamily = useSettingsStore(state => state.fontFamily);
const appFontSize = useSettingsStore(state => state.appFontSize);
const listRowDensity = useSettingsStore(state => state.listRowDensity);
@@ -218,6 +224,16 @@ function App() {
download.status === 'queued' || download.status === 'staged'
).length;
const doneCount = downloads.filter(download => download.status === 'completed').length;
const handleDownloadTableSummaryChange = useCallback((summary: DownloadTableStatusSummary | null) => {
setDownloadTableSummary(summary);
}, []);
const formatStatusSummaryBytes = (value: number | null, isEstimated = false): string => {
if (value === null) return t($ => $.downloadTable.summary.unknown);
const formatted = formatDownloadBytes(value);
return isEstimated
? t($ => $.downloadTable.summary.estimated, { value: formatted })
: formatted;
};
const schedulerRunning = useSettingsStore(state => state.schedulerRunning);
const schedulerActiveDownloadIds = useSettingsStore(state => state.schedulerActiveDownloadIds);
const pendingPostActionTimer = useRef<number | null>(null);
@@ -230,10 +246,14 @@ function App() {
>>([]);
const maxConcurrentDownloads = useSettingsStore(state => state.maxConcurrentDownloads);
const preventsSleepWhileDownloading = useSettingsStore(state => state.preventsSleepWhileDownloading);
const preventsDisplaySleepWhileDownloading = useSettingsStore(
state => state.preventsDisplaySleepWhileDownloading
);
const activeTransferCount = downloads.filter(download => isTransferActiveStatus(download.status)).length;
const { addToast, removeToast } = useToast();
const isMacUserAgent = navigator.userAgent.includes('Mac');
const usesCustomWindowControls = shouldUseCustomWindowControls(platform.os, navigator.userAgent);
const windowControlStyle = resolveWindowControlStyle(windowControlStylePreference, platform.os, navigator.userAgent);
const isRtl = localeDirection(resolveAppLocale(i18n.language)) === 'rtl';
const isSidebarOnRight = sidebarPosition === 'right' || (sidebarPosition === 'auto' && isRtl);
// Keep dialogs out of the titlebar area while platform detection is still
@@ -634,6 +654,10 @@ function App() {
});
}, [addToast, coreReady, showKeychainModal]);
useEffect(() => {
window.document.documentElement.setAttribute('data-font-family', fontFamily);
}, [fontFamily]);
useEffect(() => {
window.document.documentElement.setAttribute('data-font-size', appFontSize);
}, [appFontSize]);
@@ -696,17 +720,34 @@ function App() {
}, [platform.os, showDockBadge, dockBadgeSyncVersion, activeDownloadCount]);
useEffect(() => {
invoke('set_prevent_sleep', {
prevent: preventsSleepWhileDownloading && activeTransferCount > 0
}).catch(error => {
console.error('Failed to update sleep prevention:', error);
addToast({
message: t($ => $.app.sleepPreventionFailed, { detail: String(error) }),
variant: 'error',
isActionable: true
});
});
}, [addToast, preventsSleepWhileDownloading, activeTransferCount]);
const sync = () => {
powerPreferencesSync = powerPreferencesSync
.catch(() => undefined)
.then(() => invoke('set_power_preferences', {
preventSystemSleep: preventsSleepWhileDownloading,
preventDisplaySleep: preventsDisplaySleepWhileDownloading
}))
.catch(error => {
console.error('Failed to update power prevention:', error);
addToast({
message: t($ => $.app.sleepPreventionFailed, { detail: String(error) }),
variant: 'error',
isActionable: true
});
});
};
if (useSettingsStore.persist.hasHydrated()) {
sync();
return;
}
return useSettingsStore.persist.onFinishHydration(sync);
}, [
addToast,
preventsDisplaySleepWhileDownloading,
preventsSleepWhileDownloading,
t
]);
useEffect(() => {
invoke('toggle_tray_icon', { show: showMenuBarIcon }).catch(console.error);
@@ -1009,7 +1050,10 @@ function App() {
hasWindowChrome ? 'app-shell--window-chrome' : ''
}`}>
{usesCustomWindowControls && (
<WindowControls side={isSidebarOnRight ? 'right' : 'left'} />
<WindowControls
side={isSidebarOnRight ? 'right' : 'left'}
controlStyle={windowControlStyle}
/>
)}
<div
className={`app-sidebar-shell relative z-20 shrink-0 transition-all duration-300 ease-[cubic-bezier(0.2,0.8,0.2,1)] ${
@@ -1062,7 +1106,12 @@ function App() {
)}
<div className="flex-1 flex flex-col overflow-hidden relative">
<Suspense fallback={<PageLoadingFallback />}>
{activeView === 'downloads' && <DownloadTable filter={filter} />}
{activeView === 'downloads' && (
<DownloadTable
filter={filter}
onSummaryChange={handleDownloadTableSummaryChange}
/>
)}
{activeView === 'settings' && <SettingsView />}
{activeView === 'scheduler' && <SchedulerView />}
{activeView === 'speedLimiter' && <SpeedLimiterView />}
@@ -1073,6 +1122,21 @@ function App() {
{/* Status Bar */}
<div className="app-statusbar px-[14px] flex items-center justify-between text-text-muted shrink-0">
<span>{t($ => $.status.ready)}</span>
{activeView === 'downloads' && downloadTableSummary ? (
<div className="app-statusbar-summary" dir="ltr" aria-live="polite">
<span className="app-statusbar-summary-metric">
<span dir="auto">{t($ => $.downloadTable.summary.downloaded)}</span>
<strong dir="auto">{formatStatusSummaryBytes(downloadTableSummary.summary.downloadedBytes)}</strong>
</span>
<span className="app-statusbar-summary-metric">
<span dir="auto">{t($ => $.downloadTable.summary.remaining)}</span>
<strong dir="auto">{formatStatusSummaryBytes(
downloadTableSummary.summary.remainingBytes,
downloadTableSummary.summary.remainingIsEstimated
)}</strong>
</span>
</div>
) : null}
<div className="flex gap-3 tabular-nums">
<span>{t($ => $.status.active, { count: activeDownloadCount })}</span>
<span>{t($ => $.status.queued, { count: queuedCount })}</span>
@@ -1081,8 +1145,9 @@ function App() {
</div>
</div>
{isAddModalOpen && <AddDownloadsModal />}
<Suspense fallback={null}>
{isAddModalOpen && <AddDownloadsModal />}
{selectedPropertiesDownloadId !== null && <PropertiesModal />}
{isDeleteModalOpen && <DeleteConfirmationModal />}
{showKeychainModal && <KeychainPermissionModal consentVersion={keychainConsentVersion} />}
+3
View File
@@ -0,0 +1,3 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type CalendarPreference = "gregorian" | "persian" | "hebrew";
+1 -1
View File
@@ -2,4 +2,4 @@
import type { DownloadCategory } from "./DownloadCategory";
import type { DownloadStatus } from "./DownloadStatus";
export type DownloadItem = { id: string, url: string, fileName: string, status: DownloadStatus, fraction?: number, speed?: string, eta?: string, size?: string, downloadedBytes?: number, totalBytes?: number, totalIsEstimate?: boolean, category: DownloadCategory, dateAdded: string, resumable?: boolean, connections?: number, speedLimit?: string, username?: string, password?: string, headers?: string, checksum?: string, cookies?: string, mirrors?: string, destination?: string, isMedia?: boolean, mediaFormatSelector?: string, queueId?: string, queuePosition?: number, hasBeenDispatched?: boolean, lastError?: string, lastTry?: string, };
export type DownloadItem = { id: string, url: string, fileName: string, status: DownloadStatus, fraction?: number, speed?: string, eta?: string, size?: string, downloadedBytes?: number, totalBytes?: number, totalIsEstimate?: boolean, category: DownloadCategory, dateAdded: string, resumable?: boolean, connections?: number, speedLimit?: string, username?: string, password?: string, headers?: string, checksum?: string, cookies?: string, mirrors?: string, destination?: string, isMedia?: boolean, mediaFormatSelector?: string, mediaQuality?: string, queueId?: string, queuePosition?: number, hasBeenDispatched?: boolean, lastError?: string, lastTry?: string, };
+3
View File
@@ -0,0 +1,3 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type FontFamily = "system" | "inter" | "outfit" | "vazirmatn" | "noto-sans-hebrew" | "noto-sans-sc" | "roboto" | "serif" | "monospace";
+4 -1
View File
@@ -1,5 +1,7 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { AppFontSize } from "./AppFontSize";
import type { CalendarPreference } from "./CalendarPreference";
import type { FontFamily } from "./FontFamily";
import type { ListRowDensity } from "./ListRowDensity";
import type { MediaCookieSource } from "./MediaCookieSource";
import type { ProxyMode } from "./ProxyMode";
@@ -7,5 +9,6 @@ import type { SchedulerSettings } from "./SchedulerSettings";
import type { SettingsTab } from "./SettingsTab";
import type { SiteLogin } from "./SiteLogin";
import type { Theme } from "./Theme";
import type { WindowControlStyle } from "./WindowControlStyle";
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, };
export type PersistedSettings = { theme: Theme, fontFamily: FontFamily, windowControlStyle: WindowControlStyle, calendarPreference: CalendarPreference, language: string, baseDownloadFolder: string, categorySubfoldersEnabled: boolean, categorySubfolders: { [key in string]: string }, categoryDirectoryOverrides: { [key in string]: string }, approvedDownloadRoots: Array<string>, maxConcurrentDownloads: number, globalSpeedLimit: string, speedLimitPresetValues: Array<number>, logsEnabled: boolean, isSidebarVisible: boolean, sidebarPosition: string, activeSettingsTab: SettingsTab, scheduler: SchedulerSettings, schedulerRunning: boolean, schedulerActiveDownloadIds: Array<string>, schedulerLastStartKey: string, schedulerLastStopKey: string, lastCustomSpeedLimitKiB: number, lastCustomSpeedLimitUnit: string, perServerConnections: number, maxAutomaticRetries: number, showNotifications: boolean, playCompletionSound: boolean, autoAddClipboardLinks: boolean, appFontSize: AppFontSize, listRowDensity: ListRowDensity, showDockBadge: boolean, showMenuBarIcon: boolean, proxyMode: ProxyMode, proxyHost: string, proxyPort: number, customUserAgent: string, askWhereToSaveEachFile: boolean, rememberLastUsedDownloadDirectory: boolean, preventsSleepWhileDownloading: boolean, preventsDisplaySleepWhileDownloading: boolean, mediaCookieSource: MediaCookieSource, siteLogins: Array<SiteLogin>, autoCheckUpdates: boolean, keychainAccessGranted: boolean, };
+1 -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 Queue = { id: string, name: string, isMain: boolean, };
export type Queue = { id: string, name: string, isMain: boolean, maxConcurrent?: number, };
+3
View File
@@ -0,0 +1,3 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type QueueConcurrencyConfig = { id: string, maxConcurrent: number | null, };
+3
View File
@@ -0,0 +1,3 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type WindowControlStyle = "auto" | "macos" | "windows" | "gnome" | "minimal";
+311 -7
View File
@@ -33,14 +33,22 @@ import { localeDirection, localePluralVariant, resolveAppLocale } from '../i18n/
import {
canSubmitMetadataRows,
appendRequestUrlsAfterVersion,
commonMediaFormatsForRows,
commonMediaQualitiesForRows,
mediaFileNameForSelectedFormat,
mediaFormatForFormat,
mediaQualityForFormat,
mediaQualityForRow,
mediaTypeForFormat,
mediaFormatSelectorForRow,
metadataSummaryState,
playlistFilePrefix,
reconcileDownloadRows,
refreshFailedMetadataRows,
selectExactMediaSelection,
updateRowIfCurrent,
type AddDownloadDraftRow
type AddDownloadDraftRow,
type MediaSelection
} from '../utils/addDownloadMetadata';
const formatBytes = (bytes: number) => {
@@ -186,6 +194,9 @@ export const AddDownloadsModal = () => {
const [password, setPassword] = useState('');
const [advancedExpanded, setAdvancedExpanded] = useState(false);
const [playlistQualityExpanded, setPlaylistQualityExpanded] = useState(true);
const playlistMediaSelectionsRef = useRef(new Map<string, MediaSelection>());
const [playlistMediaTypeSelections, setPlaylistMediaTypeSelections] = useState<Record<string, MediaSelection['mediaType']>>({});
const [checksumEnabled, setChecksumEnabled] = useState(false);
const [checksumAlgo, setChecksumAlgo] = useState('SHA-256');
const [checksumValue, setChecksumValue] = useState('');
@@ -245,6 +256,9 @@ export const AddDownloadsModal = () => {
setPlaylistExpansions({});
playlistRequestsRef.current.clear();
latestPlaylistRequestRef.current.clear();
playlistMediaSelectionsRef.current.clear();
setPlaylistMediaTypeSelections({});
setPlaylistQualityExpanded(true);
return;
}
@@ -273,6 +287,9 @@ export const AddDownloadsModal = () => {
metadataRequestsRef.current.clear();
playlistRequestsRef.current.clear();
latestPlaylistRequestRef.current.clear();
playlistMediaSelectionsRef.current.clear();
setPlaylistMediaTypeSelections({});
setPlaylistQualityExpanded(true);
setSelectedItemIndex(null);
setPendingUseSharedDestination(false);
setPendingDestinationOverrides({});
@@ -322,6 +339,8 @@ export const AddDownloadsModal = () => {
// arrives instead of reusing entries extracted under stale cookies.
setPlaylistExpansions({});
latestPlaylistRequestRef.current.clear();
playlistMediaSelectionsRef.current.clear();
setPlaylistMediaTypeSelections({});
setUrls(current => appendRequestUrlsAfterVersion(
current,
pendingAddRequestContexts,
@@ -383,6 +402,17 @@ export const AddDownloadsModal = () => {
latestPlaylistRequestRef.current.delete(sourceUrl);
}
}
for (const sourceUrl of playlistMediaSelectionsRef.current.keys()) {
if (!activeUrls.has(sourceUrl)) {
playlistMediaSelectionsRef.current.delete(sourceUrl);
}
}
setPlaylistMediaTypeSelections(current => {
const retained = Object.fromEntries(
Object.entries(current).filter(([sourceUrl]) => activeUrls.has(sourceUrl))
);
return Object.keys(retained).length === Object.keys(current).length ? current : retained;
});
setPlaylistExpansions(current => {
const retained = Object.fromEntries(
Object.entries(current).filter(([sourceUrl]) => activeUrls.has(sourceUrl))
@@ -519,6 +549,7 @@ export const AddDownloadsModal = () => {
const isApproximate = !exactBytes && approxBytes > 0;
return {
name: `${quality} ${container}`,
quality,
ext: f.ext,
bytes,
isApproximate,
@@ -528,6 +559,26 @@ export const AddDownloadsModal = () => {
type: quality.toLowerCase().includes('audio') ? 'Audio' : 'Video'
};
});
const requestedSelection = row.playlistSourceUrl
? playlistMediaSelectionsRef.current.get(row.playlistSourceUrl)
: undefined;
const requestedFormatIndex = requestedSelection
? mappedFormats.findIndex(format =>
mediaTypeForFormat(format) === requestedSelection.mediaType
&& mediaFormatForFormat(format) === requestedSelection.format
&& mediaQualityForFormat(format) === requestedSelection.quality
)
: -1;
const fallbackFormatIndex = requestedSelection
? mappedFormats.findIndex(format =>
mediaTypeForFormat(format) === requestedSelection.mediaType
&& mediaQualityForFormat(format) === requestedSelection.quality
)
: -1;
const selectedFormatIndex = requestedFormatIndex >= 0
? requestedFormatIndex
: fallbackFormatIndex >= 0 ? fallbackFormatIndex : 0;
const selectedFormat = mappedFormats[selectedFormatIndex];
setParsedItems(current => updateRowIfCurrent(
current,
row.id,
@@ -537,13 +588,13 @@ export const AddDownloadsModal = () => {
...currentRow,
downloadUrl: row.sourceUrl,
file: canonicalizeDownloadFileName(
`${playlistFilePrefix(row.playlistIndex, row.playlistCount)}${mediaData.title}.${mediaData.formats[0].ext}`
`${playlistFilePrefix(row.playlistIndex, row.playlistCount)}${mediaData.title}.${selectedFormat.ext}`
),
size: mappedFormats[0].bytes ? mappedFormats[0].detail : undefined,
sizeBytes: mappedFormats[0].bytes || undefined,
size: selectedFormat.bytes ? selectedFormat.detail : undefined,
sizeBytes: selectedFormat.bytes || undefined,
status: 'ready',
formats: mappedFormats,
selectedFormat: 0,
selectedFormat: selectedFormatIndex,
playlistError: undefined
})
));
@@ -1187,6 +1238,7 @@ export const AddDownloadsModal = () => {
isMedia: item.isMedia,
resumable: item.resumable,
mediaFormatSelector: formatSelector,
mediaQuality: mediaQualityForRow(item),
size: item.size || (item.sizeBytes ? formatBytes(item.sizeBytes) : undefined),
sizeBytes: item.sizeBytes
}, action);
@@ -1262,11 +1314,23 @@ export const AddDownloadsModal = () => {
});
};
const clearPlaylistMediaSelection = (sourceUrl: string | undefined) => {
if (!sourceUrl) return;
playlistMediaSelectionsRef.current.delete(sourceUrl);
setPlaylistMediaTypeSelections(current => {
if (!(sourceUrl in current)) return current;
const next = { ...current };
delete next[sourceUrl];
return next;
});
};
const selectMediaFormat = (index: number) => {
if (selectedItemIndex === null) return;
const selectedItem = parsedItems[selectedItemIndex];
const format = selectedItem?.formats?.[index];
if (!selectedItem || !format) return;
clearPlaylistMediaSelection(selectedItem.playlistSourceUrl);
setParsedItems(items => items.map((item, itemIndex) =>
itemIndex === selectedItemIndex
@@ -1285,6 +1349,120 @@ export const AddDownloadsModal = () => {
};
const selectedItems = parsedItems.filter(item => item.selected !== false);
const selectedItem = selectedItemIndex === null ? undefined : parsedItems[selectedItemIndex];
const selectedPlaylistSourceUrl = selectedItem?.playlistSourceUrl;
const selectedPlaylistRows = selectedPlaylistSourceUrl
? parsedItems.filter(item => item.playlistSourceUrl === selectedPlaylistSourceUrl && item.selected !== false)
: [];
const selectedPlaylistReadyRows = selectedPlaylistRows.filter(item =>
item.isMedia && item.status === 'ready' && Boolean(item.formats?.length)
);
const selectedPlaylistFormatObject = selectedItem?.formats?.[selectedItem.selectedFormat ?? -1]
|| selectedPlaylistReadyRows[0]?.formats?.[selectedPlaylistReadyRows[0].selectedFormat ?? -1];
const detectedPlaylistMediaType = selectedPlaylistFormatObject
? mediaTypeForFormat(selectedPlaylistFormatObject)
: 'Video';
const playlistVideoFormatOptions = commonMediaFormatsForRows(selectedPlaylistRows, 'Video');
const playlistAudioFormatOptions = commonMediaFormatsForRows(selectedPlaylistRows, 'Audio');
const requestedPlaylistMediaType = selectedPlaylistSourceUrl
? playlistMediaTypeSelections[selectedPlaylistSourceUrl]
: undefined;
const selectedPlaylistMediaType = requestedPlaylistMediaType === 'Audio' && playlistAudioFormatOptions.length > 0
? 'Audio'
: requestedPlaylistMediaType === 'Video' && playlistVideoFormatOptions.length > 0
? 'Video'
: detectedPlaylistMediaType === 'Audio' && playlistAudioFormatOptions.length > 0
? 'Audio'
: playlistVideoFormatOptions.length > 0 ? 'Video' : 'Audio';
const playlistFormatOptions = selectedPlaylistMediaType === 'Audio'
? playlistAudioFormatOptions
: playlistVideoFormatOptions;
const hasPlaylistFormatOptions = playlistVideoFormatOptions.length > 0
|| playlistAudioFormatOptions.length > 0;
const currentPlaylistFormat = selectedPlaylistFormatObject
? mediaFormatForFormat(selectedPlaylistFormatObject)
: undefined;
const selectedPlaylistFormat = currentPlaylistFormat && playlistFormatOptions.includes(currentPlaylistFormat)
? currentPlaylistFormat
: playlistFormatOptions[0];
const playlistQualityOptions = selectedPlaylistFormat
? commonMediaQualitiesForRows(
selectedPlaylistRows,
selectedPlaylistMediaType,
selectedPlaylistFormat
)
: [];
const playlistSelectionForRow = (row: AddDownloadDraftRow): MediaSelection | undefined => {
const format = row.formats?.[row.selectedFormat ?? -1];
return format ? {
mediaType: mediaTypeForFormat(format),
format: mediaFormatForFormat(format),
quality: mediaQualityForFormat(format)
} : undefined;
};
const appliedPlaylistSelection = selectedPlaylistReadyRows.length > 0
? (() => {
const firstSelection = playlistSelectionForRow(selectedPlaylistReadyRows[0]);
return firstSelection && selectedPlaylistReadyRows.every(row => {
const selection = playlistSelectionForRow(row);
return selection?.mediaType === firstSelection.mediaType
&& selection.format === firstSelection.format
&& selection.quality === firstSelection.quality;
})
? firstSelection
: undefined;
})()
: undefined;
const appliedPlaylistFormat = selectedPlaylistReadyRows.length > 0
? (() => {
const firstSelection = playlistSelectionForRow(selectedPlaylistReadyRows[0]);
return firstSelection
&& firstSelection.mediaType === selectedPlaylistMediaType
&& selectedPlaylistReadyRows.every(row => {
const selection = playlistSelectionForRow(row);
return selection?.mediaType === selectedPlaylistMediaType
&& selection.format === firstSelection.format;
})
? firstSelection.format
: undefined;
})()
: undefined;
const applyPlaylistMediaSelection = (selection: MediaSelection) => {
if (!selectedPlaylistSourceUrl) return;
playlistMediaSelectionsRef.current.set(selectedPlaylistSourceUrl, selection);
setPlaylistMediaTypeSelections(current => ({
...current,
[selectedPlaylistSourceUrl]: selection.mediaType
}));
const selectedIds = selectedPlaylistReadyRows.map(item => item.id);
setParsedItems(items => selectExactMediaSelection(items, selectedIds, selection));
};
const applyPlaylistMediaQuality = (quality: string) => {
if (!selectedPlaylistFormat) return;
applyPlaylistMediaSelection({
mediaType: selectedPlaylistMediaType,
format: selectedPlaylistFormat,
quality
});
};
const applyPlaylistMediaFormat = (format: string) => {
const qualityOptions = commonMediaQualitiesForRows(
selectedPlaylistRows,
selectedPlaylistMediaType,
format
);
const currentQuality = appliedPlaylistSelection?.quality
|| mediaQualityForRow(selectedPlaylistReadyRows[0]);
const quality = currentQuality && qualityOptions.includes(currentQuality)
? currentQuality
: qualityOptions[0];
if (!quality) return;
applyPlaylistMediaSelection({
mediaType: selectedPlaylistMediaType,
format,
quality
});
};
const allRowsSelected = parsedItems.length > 0 && selectedItems.length === parsedItems.length;
const requiredBytes = selectedItems.reduce((acc, item) => acc + (item.sizeBytes || 0), 0);
const hasApproximateSize = selectedItems.some(item =>
@@ -1524,7 +1702,14 @@ export const AddDownloadsModal = () => {
aria-label={t($ => $.addDownloads.selectItem, { file: item.file })}
className="me-2 shrink-0 accent-purple-500"
/>
<div className="flex-[2] text-text-primary font-medium truncate pe-2" title={item.file}>{item.file}</div>
<div className="flex-[2] min-w-0 flex items-center gap-2 text-text-primary font-medium truncate pe-2" title={item.file}>
<span className="truncate">{item.file}</span>
{item.isMedia && item.status === 'ready' && mediaQualityForRow(item) ? (
<span className="add-download-quality-chip shrink-0" title={t($ => $.addDownloads.quality)}>
{mediaQualityForRow(item)}
</span>
) : null}
</div>
<div className={`flex-1 font-mono ${item.status === 'loading' ? 'text-text-muted/50' : 'text-text-muted'}`}>{item.size || t($ => $.addDownloads.unknown)}</div>
<div className={`flex-[1.5] font-medium ${item.status === 'metadata-error' || item.status === 'invalid' ? 'text-red-500' : item.status === 'loading' ? 'text-orange-400' : 'text-blue-500'}`}>
{item.status === 'loading' ? (
@@ -1612,12 +1797,131 @@ export const AddDownloadsModal = () => {
})}
</div>
</div>
</div>
</div>
) : (
<div className="flex flex-col items-center justify-center py-4 relative z-10">
<span className="text-xs text-red-400 font-medium">{t($ => $.addDownloads.metadataUnavailable)}</span>
</div>
)}
{selectedPlaylistSourceUrl && selectedPlaylistReadyRows.length > 0 ? (
<div className="add-download-playlist-quality relative z-10">
<button
type="button"
className="add-download-playlist-quality-toggle"
aria-expanded={playlistQualityExpanded}
onClick={() => setPlaylistQualityExpanded(expanded => !expanded)}
>
<span className="min-w-0 text-start">
<span className="block text-xs font-semibold text-text-primary">
{t($ => $.addDownloads.playlistQuality)}
</span>
<span className="mt-0.5 block text-[10px] text-text-muted">
{t($ => $.addDownloads.playlistQualitySelected, {
selected: selectedPlaylistRows.length,
total: parsedItems.filter(item => item.playlistSourceUrl === selectedPlaylistSourceUrl).length
})}
</span>
</span>
{playlistQualityExpanded ? <ChevronDown size={15} /> : <ChevronRight size={15} />}
</button>
{playlistQualityExpanded ? (
hasPlaylistFormatOptions ? (
<>
{playlistVideoFormatOptions.length > 0 && playlistAudioFormatOptions.length > 0 ? (
<div className="add-download-playlist-media-type" role="tablist" aria-label={t($ => $.addDownloads.mediaFormat)}>
<button
type="button"
role="tab"
aria-selected={selectedPlaylistMediaType === 'Video'}
onClick={() => selectedPlaylistSourceUrl && setPlaylistMediaTypeSelections(current => ({
...current,
[selectedPlaylistSourceUrl]: 'Video'
}))}
className={`add-download-playlist-media-type-option ${selectedPlaylistMediaType === 'Video' ? 'is-selected' : ''}`}
>
<Film size={13} /> {t($ => $.addDownloads.video)}
</button>
<button
type="button"
role="tab"
aria-selected={selectedPlaylistMediaType === 'Audio'}
onClick={() => selectedPlaylistSourceUrl && setPlaylistMediaTypeSelections(current => ({
...current,
[selectedPlaylistSourceUrl]: 'Audio'
}))}
className={`add-download-playlist-media-type-option ${selectedPlaylistMediaType === 'Audio' ? 'is-selected' : ''}`}
>
<Music size={13} /> {t($ => $.addDownloads.audio)}
</button>
</div>
) : null}
<div className="add-download-playlist-quality-options mt-2">
<div className="add-download-playlist-quality-column">
<div className="add-download-playlist-quality-label">{t($ => $.addDownloads.quality)}</div>
{playlistQualityOptions.length > 0 ? (
<div className="add-download-playlist-quality-list" role="radiogroup" aria-label={t($ => $.addDownloads.quality)}>
{playlistQualityOptions.map(quality => {
const isApplied = appliedPlaylistSelection?.mediaType === selectedPlaylistMediaType
&& appliedPlaylistSelection.format === selectedPlaylistFormat
&& appliedPlaylistSelection.quality === quality;
return (
<button
key={quality}
type="button"
role="radio"
aria-checked={isApplied}
onClick={() => applyPlaylistMediaQuality(quality)}
className={`add-download-quality-option ${isApplied ? 'is-selected' : ''}`}
>
<span className="add-download-quality-option-radio" aria-hidden="true" />
<span className="flex-1 text-start">{quality}</span>
</button>
);
})}
</div>
) : (
<span className="text-[10px] text-text-muted">
{t($ => $.addDownloads.noCommonQuality)}
</span>
)}
</div>
{playlistFormatOptions.length > 0 ? (
<div className="add-download-playlist-quality-column">
<div className="add-download-playlist-quality-label">{t($ => $.addDownloads.format)}</div>
<div className="add-download-playlist-quality-list" role="radiogroup" aria-label={t($ => $.addDownloads.format)}>
{playlistFormatOptions.map(format => {
const isApplied = appliedPlaylistFormat === format;
return (
<button
key={format}
type="button"
role="radio"
aria-checked={isApplied}
onClick={() => applyPlaylistMediaFormat(format)}
className={`add-download-quality-option ${isApplied ? 'is-selected' : ''}`}
>
<span className="add-download-quality-option-radio" aria-hidden="true" />
<span className="flex-1 text-start font-mono">{format}</span>
</button>
);
})}
</div>
</div>
) : null}
</div>
</>
) : (
<div className="px-3 py-2 text-[10px] text-text-muted">
{t($ => $.addDownloads.noCommonFormat)}
</div>
)
) : null}
</div>
) : null}
</section>
)}
+92 -59
View File
@@ -1,9 +1,11 @@
import React from 'react';
import { useDownloadProgressStore } from '../store/downloadProgressStore';
import { Play, Pause, MoreVertical, Clock, ArrowUp, ArrowDown } from 'lucide-react';
import { Play, Pause, MoreVertical, Clock } from 'lucide-react';
import type { DownloadItem as DownloadItemType } from '../bindings/DownloadItem';
import { canPauseDownload, canStartDownload } from '../utils/downloadActions';
import { useTranslation } from 'react-i18next';
import { useSettingsStore } from '../store/useSettingsStore';
import { formatDateTime } from '../utils/dateTime';
import {
downloadProgressColorClass,
formatDownloadTotal,
@@ -12,7 +14,7 @@ import {
import {
COLUMN_ALIGNMENT_JUSTIFY,
DOWNLOAD_ACTIONS_COLUMN_WIDTH,
DOWNLOAD_ACTIONS_VIEWPORT_INSET,
getDownloadActionPosition,
getColumnGridColumn,
type DownloadColumnAlignment,
type DownloadTableColumnKey
@@ -20,9 +22,7 @@ import {
interface DownloadItemProps {
download: DownloadItemType;
index: number;
queueIndex: number;
queueLength: number;
columnOrder: DownloadTableColumnKey[];
columnAlignments: Record<DownloadTableColumnKey, DownloadColumnAlignment>;
tableGridTemplate: string;
@@ -32,15 +32,16 @@ interface DownloadItemProps {
handleResume: (item: DownloadItemType) => void;
getCategoryIcon: (category: string) => React.ReactNode;
isSelected: boolean;
isQueueReorderable: boolean;
isQueueDragSource: boolean;
onMoveInQueue: (id: string, direction: 'up' | 'down') => void;
onQueueDragStart: (id: string, event: React.PointerEvent<HTMLDivElement>) => void;
onClick: (e: React.MouseEvent, item: DownloadItemType) => void;
}
export const DownloadItem = React.memo<DownloadItemProps>(({
download,
index,
queueIndex,
queueLength,
columnOrder,
columnAlignments,
tableGridTemplate,
@@ -50,16 +51,32 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
handleResume,
getCategoryIcon,
isSelected,
isQueueReorderable,
isQueueDragSource,
onMoveInQueue,
onQueueDragStart,
onClick,
}) => {
const { t } = useTranslation();
const { t, i18n } = useTranslation();
const calendarPreference = useSettingsStore(state => state.calendarPreference);
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 hasRowActions = download.status !== 'completed';
const isActionVisible = hasRowActions && (isRowHovered || isRowFocused);
const mediaQualityLabel = (() => {
if (!download.isMedia || typeof download.mediaQuality !== 'string') return undefined;
const normalized = download.mediaQuality.replace(/[\u0000-\u001f\u007f]+/g, ' ').replace(/\s+/g, ' ').trim();
return normalized.length > 0 && normalized.length <= 48 ? normalized : undefined;
})();
const dateAddedLabel = download.dateAdded
? formatDateTime(download.dateAdded, {
locale: i18n.language,
calendar: calendarPreference
})
: '-';
const updateActionPosition = React.useCallback(() => {
const row = rowRef.current;
@@ -71,19 +88,12 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
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',
};
const nextPosition = getDownloadActionPosition(
rowRect,
horizontalViewportRect,
verticalViewportRect,
window.innerWidth
);
setActionPosition(previous => (
previous?.top === nextPosition.top &&
@@ -99,11 +109,11 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
React.useEffect(() => {
if (!isActionVisible) return;
let frame = 0;
let frame: number | null = null;
const schedulePositionUpdate = () => {
if (frame) window.cancelAnimationFrame(frame);
if (frame !== null) window.cancelAnimationFrame(frame);
frame = window.requestAnimationFrame(() => {
frame = 0;
frame = null;
updateActionPosition();
});
};
@@ -127,7 +137,7 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
window.removeEventListener('resize', schedulePositionUpdate);
window.removeEventListener('scroll', schedulePositionUpdate, true);
resizeObserver?.disconnect();
if (frame) window.cancelAnimationFrame(frame);
if (frame !== null) window.cancelAnimationFrame(frame);
};
}, [isActionVisible, updateActionPosition]);
@@ -179,6 +189,7 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
'File Name': (
<div
className="download-column-cell download-file-cell download-column-file-name"
data-column-key="File Name"
style={columnStyle('File Name')}
>
<div className="download-cell-content">
@@ -188,12 +199,18 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
<span className="download-file-name" title={download.fileName}>
{download.fileName}
</span>
{mediaQualityLabel ? (
<span className="download-quality-chip shrink-0" title={t($ => $.addDownloads.quality)}>
{mediaQualityLabel}
</span>
) : null}
</div>
</div>
),
Size: (
<div
className="download-column-cell download-cell-truncate download-size-cell tabular-nums"
data-column-key="Size"
style={columnStyle('Size')}
title={hasDownloadedAmount ? downloadedSizeLabel : completedSizeLabel}
aria-label={hasDownloadedAmount ? downloadedSizeLabel : completedSizeLabel}
@@ -216,6 +233,7 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
Status: (
<div
className="download-column-cell download-status-cell"
data-column-key="Status"
data-column-alignment={columnAlignments.Status}
style={columnStyle('Status')}
>
@@ -279,61 +297,42 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
</div>
),
Speed: (
<div className="download-column-cell download-cell-truncate" style={columnStyle('Speed')}>
<div className="download-column-cell download-cell-truncate" data-column-key="Speed" style={columnStyle('Speed')}>
<span className="download-cell-content tabular-nums" title={displaySpeed}>
{displaySpeed}
</span>
</div>
),
ETA: (
<div className="download-column-cell download-cell-truncate" style={columnStyle('ETA')}>
<div className="download-column-cell download-cell-truncate" data-column-key="ETA" style={columnStyle('ETA')}>
<span className="download-cell-content tabular-nums" title={displayEta}>
{displayEta}
</span>
</div>
),
'Date Added': (
<div className="download-column-cell download-cell-right download-column-date-added" style={columnStyle('Date Added')}>
<div className="download-column-cell download-cell-right download-column-date-added" data-column-key="Date Added" style={columnStyle('Date Added')}>
<span
className="download-cell-content download-date-value tabular-nums"
title={download.dateAdded ? new Date(download.dateAdded).toLocaleDateString() : '-'}
title={dateAddedLabel}
>
{download.dateAdded ? new Date(download.dateAdded).toLocaleDateString() : '-'}
{dateAddedLabel}
</span>
</div>
),
};
const rowActions = (
const rowActions = hasRowActions ? (
<div
className="download-row-actions items-center gap-0.5"
style={{
...actionPosition,
visibility: isActionVisible && actionPosition?.visibility === 'visible' ? 'visible' : 'hidden',
width: `${DOWNLOAD_ACTIONS_COLUMN_WIDTH}px`,
}}
onClick={(e) => e.stopPropagation()}
onDoubleClick={(e) => e.stopPropagation()}
>
{(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" />
@@ -355,17 +354,18 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
<MoreVertical size={14} />
</button>
</div>
);
) : null;
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 }}
data-download-id={download.id}
className={`download-row group cursor-default relative ${isActionVisible ? 'has-visible-actions' : ''} ${isSelected ? 'is-selected' : ''} ${isQueueReorderable ? 'is-queue-reorderable' : ''} ${isQueueDragSource ? 'is-queue-drag-source' : ''}`}
style={{ minWidth: tableMinWidth }}
tabIndex={0}
onMouseEnter={() => {
setIsRowHovered(true);
updateActionPosition();
if (hasRowActions) updateActionPosition();
}}
onMouseLeave={event => {
const nextTarget = event.relatedTarget;
@@ -375,7 +375,7 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
}}
onFocus={() => {
setIsRowFocused(true);
updateActionPosition();
if (hasRowActions) updateActionPosition();
}}
onBlur={event => {
const nextTarget = event.relatedTarget;
@@ -383,15 +383,48 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
setIsRowFocused(false);
}
}}
onPointerDown={event => {
// Modifier clicks belong to selection. Starting a row drag first can
// capture the pointer and suppress the click that applies Cmd/Ctrl or
// Shift selection.
if (
isQueueReorderable &&
!event.shiftKey &&
!event.metaKey &&
!event.ctrlKey &&
!event.altKey
) {
onQueueDragStart(download.id, event);
}
}}
onClick={(e) => onClick(e, download)}
onKeyDown={event => {
if (
isQueueReorderable &&
event.altKey &&
!event.metaKey &&
!event.ctrlKey &&
!event.shiftKey &&
(event.key === 'ArrowUp' || event.key === 'ArrowDown')
) {
event.preventDefault();
event.stopPropagation();
onMoveInQueue(download.id, event.key === 'ArrowUp' ? 'up' : 'down');
}
}}
onContextMenu={(e) => {
e.preventDefault();
setContextMenu({ x: e.clientX, y: e.clientY, id: download.id });
}}
>
{columnOrder.map(columnKey => (
<React.Fragment key={columnKey}>{cells[columnKey]}</React.Fragment>
))}
<div
className="download-row-motion"
style={{ gridTemplateColumns: tableGridTemplate, minWidth: tableMinWidth }}
>
{columnOrder.map(columnKey => (
<React.Fragment key={columnKey}>{cells[columnKey]}</React.Fragment>
))}
</div>
{rowActions}
</div>
);
File diff suppressed because it is too large Load Diff
+164 -14
View File
@@ -7,6 +7,7 @@ import { ChevronDown, ChevronRight, FolderPlus, Info, CheckCircle, AlertCircle,
import { open } from '@tauri-apps/plugin-dialog';
import { resolveCategoryDestination } from '../utils/downloadLocations';
import {
getPauseResumeAction,
isIdentityLocked as getIdentityLocked,
isTransferLocked as getTransferLocked
} from '../utils/downloadActions';
@@ -17,19 +18,25 @@ import {
} from '../utils/downloadProgress';
import { resolveDownloadConnections } from '../utils/downloads';
import { useTranslation } from 'react-i18next';
import { formatDateTime, type CalendarPreference } from '../utils/dateTime';
type LoginMode = 'matching' | 'custom' | 'none';
const formatLastTry = (value?: string): string => {
const formatLastTry = (
value: string | undefined,
locale: string,
calendar: CalendarPreference
): string => {
if (!value) return '-';
const date = new Date(value);
return Number.isNaN(date.getTime())
? '-'
: date.toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short' });
return formatDateTime(value, {
locale,
calendar,
options: { dateStyle: 'medium', timeStyle: 'short' }
});
};
export const PropertiesModal = () => {
const { t } = useTranslation();
const { t, i18n } = useTranslation();
const categoryLabel = (category: string) => {
switch (category) {
case 'Musics': return t($ => $.navigation.categories.musics);
@@ -54,7 +61,7 @@ export const PropertiesModal = () => {
: undefined
));
const { baseDownloadFolder, perServerConnections } = useSettingsStore();
const { baseDownloadFolder, perServerConnections, calendarPreference } = useSettingsStore();
// Form states
const [url, setUrl] = useState('');
@@ -65,6 +72,8 @@ export const PropertiesModal = () => {
const [speedLimitEnabled, setSpeedLimitEnabled] = useState(false);
const [speedLimitValue, setSpeedLimitValue] = useState('1024'); // KiB/s
const [liveSpeedLimitValue, setLiveSpeedLimitValue] = useState('');
const [isLiveSpeedLimitPending, setIsLiveSpeedLimitPending] = useState(false);
const [loginMode, setLoginMode] = useState<LoginMode>('matching');
const [username, setUsername] = useState('');
@@ -79,6 +88,7 @@ export const PropertiesModal = () => {
const [mirrors, setMirrors] = useState('');
const [errorMessage, setErrorMessage] = useState('');
const [isPauseResumePending, setIsPauseResumePending] = useState(false);
useEffect(() => {
if (selectedPropertiesDownloadId) {
@@ -135,6 +145,11 @@ export const PropertiesModal = () => {
}
}, [selectedPropertiesDownloadId, setSelectedPropertiesDownloadId]);
useEffect(() => {
const activeLimit = item?.speedLimit?.trim();
setLiveSpeedLimitValue(activeLimit && activeLimit !== '0' ? activeLimit : '');
}, [item?.speedLimit, selectedPropertiesDownloadId]);
useEffect(() => {
if (!selectedPropertiesDownloadId || connectionsDirty) return;
const activeItem = useDownloadStore.getState().downloads.find(d => d.id === selectedPropertiesDownloadId);
@@ -205,8 +220,59 @@ export const PropertiesModal = () => {
}
};
const handlePauseResume = async () => {
const currentItem = useDownloadStore.getState().downloads.find(download => download.id === item.id);
const action = currentItem ? getPauseResumeAction(currentItem.status) : null;
if (!currentItem || !action || isPauseResumePending) return;
if (action === 'pause' && currentItem.resumable === false) {
const confirmPause = window.confirm(t($ => $.downloadTable.nonResumableOne));
if (!confirmPause) return;
}
setErrorMessage('');
setIsPauseResumePending(true);
try {
if (action === 'pause') {
await useDownloadStore.getState().pauseDownload(currentItem.id);
} else {
const resumed = await useDownloadStore.getState().resumeDownload(currentItem.id);
if (!resumed) {
throw new Error(t($ => $.downloadTable.backendRejectedStart));
}
}
} catch (error) {
const detail = error instanceof Error ? error.message : String(error);
const message = action === 'pause'
? t($ => $.downloadTable.pauseFailed)
: t($ => $.downloadTable.resumeFailed, { fileName: currentItem.fileName });
setErrorMessage(t($ => $.downloadTable.interactionError, { message, detail }));
} finally {
setIsPauseResumePending(false);
}
};
const handleLiveSpeedLimit = async (limit: string | null) => {
if (isLiveSpeedLimitPending || item.isMedia || !['downloading', 'retrying'].includes(item.status)) return;
setErrorMessage('');
setIsLiveSpeedLimitPending(true);
try {
await useDownloadStore.getState().setDownloadSpeedLimit(item.id, limit);
if (limit === null) setLiveSpeedLimitValue('');
} catch (error) {
setErrorMessage(t($ => $.properties.liveSpeedLimitFailed, {
detail: error instanceof Error ? error.message : String(error)
}));
} finally {
setIsLiveSpeedLimitPending(false);
}
};
const identityLocked = getIdentityLocked(item.status);
const transferLocked = getTransferLocked(item.status);
const liveSpeedLimitAvailable = !item.isMedia && ['downloading', 'retrying'].includes(item.status);
const liveSpeedLimitUnavailable = item.isMedia && ['downloading', 'processing', 'retrying'].includes(item.status);
const configuredConnections = resolveDownloadConnections(item.connections, perServerConnections);
const observedConnectionTotal = Math.max(
1,
@@ -262,6 +328,11 @@ export const PropertiesModal = () => {
return value === 'Unknown' ? t($ => $.addDownloads.unknown) : value;
})();
const statusLabel = t($ => $.downloads.status[item.status]);
const pauseResumeAction = getPauseResumeAction(item.status);
const pauseResumeLabel = pauseResumeAction === 'pause'
? t($ => $.downloadTable.pause)
: t($ => $.downloadTable.resume);
const PauseResumeIcon = pauseResumeAction === 'pause' ? Pause : Play;
const sizeDescription = sizeDisplay.totalIsEstimate
? t($ => $.downloads.size.downloadedOfApproximate, {
downloaded: sizeDisplay.downloaded ?? '',
@@ -334,9 +405,9 @@ export const PropertiesModal = () => {
<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"><span className="text-text-muted font-medium w-[50px]">{t($ => $.properties.lastTry)}</span><span className="text-text-secondary truncate">{formatLastTry(item.lastTry, i18n.language, calendarPreference)}</span></div>
<div className="flex gap-1.5 col-span-2"><span className="text-text-muted font-medium w-[90px]">{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-[90px]">{t($ => $.properties.dateAdded)}</span><span className="text-text-secondary truncate">{formatDateTime(item.dateAdded, { locale: i18n.language, calendar: calendarPreference, options: { dateStyle: 'medium', timeStyle: 'short' } })}</span></div>
<div className="flex gap-1.5 col-span-2"><span className="text-text-muted font-medium w-[70px]">{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">
@@ -400,19 +471,83 @@ export const PropertiesModal = () => {
{t($ => $.properties.savedPerDownload)}
</div>
<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" />
<label className="text-xs text-text-muted text-right">{t($ => $.properties.speedCap)}</label>
<div className="flex flex-wrap items-center gap-2">
<label className="inline-flex min-h-7 items-center gap-2 rounded-md border border-border-modal bg-bg-input px-2.5 py-1.5 text-xs text-text-primary">
<input
type="checkbox"
checked={speedLimitEnabled}
onChange={e => setSpeedLimitEnabled(e.target.checked)}
disabled={transferLocked}
className="accent-accent disabled:opacity-50"
/>
{t($ => $.properties.limit)}
</label>
{speedLimitEnabled && (
<div className="flex items-center gap-2">
<input type="number" value={speedLimitValue} min={1} step={128} onChange={e=>setSpeedLimitValue(e.target.value)} disabled={transferLocked} className="w-20 bg-bg-input border border-border-modal rounded-lg px-2.5 py-1.5 text-xs text-text-primary focus:outline-none focus:border-accent disabled:opacity-50" />
<input
type="number"
value={speedLimitValue}
min={1}
step={128}
onChange={e => setSpeedLimitValue(e.target.value)}
disabled={transferLocked}
className="app-control w-24 px-2.5 py-1.5 text-end text-xs font-mono disabled:opacity-50"
/>
<span className="text-xs text-text-muted">KiB/s</span>
</div>
)}
</div>
<div className="col-start-2 text-[11px] text-text-muted">
{t($ => $.properties.savedPerDownload)}
</div>
{(liveSpeedLimitAvailable || liveSpeedLimitUnavailable) && (
<div className="col-start-2 rounded-lg border border-border-modal bg-bg-input/30 p-3 space-y-2">
{liveSpeedLimitAvailable ? (
<>
<label htmlFor="live-speed-limit" className="block text-xs font-semibold text-text-primary">
{t($ => $.properties.liveSpeedLimit)}
</label>
<div className="flex flex-wrap items-center gap-2">
<input
id="live-speed-limit"
type="text"
inputMode="decimal"
value={liveSpeedLimitValue}
onChange={event => setLiveSpeedLimitValue(event.currentTarget.value)}
placeholder={t($ => $.properties.liveSpeedLimitPlaceholder)}
disabled={isLiveSpeedLimitPending}
aria-describedby="live-speed-limit-hint"
className="app-control w-32 px-2.5 py-1.5 text-xs font-mono disabled:opacity-50"
/>
<button
type="button"
onClick={() => void handleLiveSpeedLimit(liveSpeedLimitValue)}
disabled={isLiveSpeedLimitPending}
className="app-button app-button-primary px-3 text-xs disabled:opacity-50"
>
{t($ => $.properties.liveSpeedLimitApply)}
</button>
<button
type="button"
onClick={() => void handleLiveSpeedLimit(null)}
disabled={isLiveSpeedLimitPending || !liveSpeedLimitValue}
className="app-button px-3 text-xs disabled:opacity-50"
>
{t($ => $.properties.liveSpeedLimitClear)}
</button>
</div>
<p id="live-speed-limit-hint" className="text-[11px] text-text-muted">
{t($ => $.properties.liveSpeedLimitHint)}
</p>
</>
) : (
<p className="text-[11px] text-text-muted">
{t($ => $.properties.liveSpeedLimitUnavailable)}
</p>
)}
</div>
)}
</div>
</section>
@@ -513,12 +648,27 @@ export const PropertiesModal = () => {
</div>
<div className="flex gap-2">
<button
type="button"
onClick={() => setSelectedPropertiesDownloadId(null)}
className="app-button px-4 text-xs"
>
{t($ => $.properties.cancel)}
</button>
{pauseResumeAction && (
<button
type="button"
onClick={() => void handlePauseResume()}
disabled={isPauseResumePending}
aria-label={pauseResumeLabel}
title={pauseResumeLabel}
className={`app-button px-4 text-xs ${isPauseResumePending ? 'opacity-50 cursor-not-allowed' : ''}`}
>
<PauseResumeIcon size={14} fill="currentColor" />
{pauseResumeLabel}
</button>
)}
<button
type="button"
onClick={handleSave}
disabled={transferLocked}
className={`app-button app-button-primary px-4 text-xs ${transferLocked ? 'opacity-50 cursor-not-allowed' : ''}`}
+14 -8
View File
@@ -11,6 +11,7 @@ import { WindowDragRegion } from './WindowDragRegion';
import { useToast } from '../contexts/ToastContext';
import { usePlatformInfo } from '../utils/platform';
import { useTranslation } from 'react-i18next';
import { formatDateTime } from '../utils/dateTime';
const days = [
{ value: 0, key: 'su' },
@@ -54,9 +55,10 @@ function nextScheduledRun(settings: SchedulerSettings): Date | 'disabled' | 'non
}
export default function SchedulerView() {
const { t } = useTranslation();
const { t, i18n } = useTranslation();
const savedSettings = useSettingsStore(state => state.scheduler);
const schedulerRunning = useSettingsStore(state => state.schedulerRunning);
const calendarPreference = useSettingsStore(state => state.calendarPreference);
const setScheduler = useSettingsStore(state => state.setScheduler);
const queues = useDownloadStore(state => state.queues);
const [draft, setDraft] = useState<SchedulerSettings>(savedSettings);
@@ -75,14 +77,18 @@ export default function SchedulerView() {
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'
return formatDateTime(scheduledRun, {
locale: i18n.language,
calendar: calendarPreference,
options: {
weekday: 'short',
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: '2-digit'
}
});
}, [draft, t]);
}, [calendarPreference, draft, i18n.language, t]);
const hasUnsavedChanges = useMemo(
() => JSON.stringify(draft) !== JSON.stringify(savedSettings),
[draft, savedSettings]
+125 -34
View File
@@ -1,8 +1,10 @@
import { useCallback, useRef, useState, useEffect } from 'react';
import {
type AppFontSize,
type FontFamily,
type ListRowDensity,
type SidebarPosition,
type WindowControlStyle,
type SettingsState,
SettingsTab,
runSettingsPersistenceTransaction,
@@ -693,6 +695,29 @@ runEngineChecks(false);
{ value: 'uk', label: t($ => $.settings.lookAndFeel.languageUkrainian) },
{ value: 'ru', label: t($ => $.settings.lookAndFeel.languageRussian) },
] as const;
const calendarOptions = [
{ value: 'gregorian', label: t($ => $.settings.lookAndFeel.calendarGregorian) },
{ value: 'persian', label: t($ => $.settings.lookAndFeel.calendarPersian) },
{ value: 'hebrew', label: t($ => $.settings.lookAndFeel.calendarHebrew) },
] as const;
const windowControlStyleOptions: Array<{ value: WindowControlStyle; label: string }> = [
{ value: 'auto', label: t($ => $.settings.lookAndFeel.windowControlStyleAutomatic) },
{ value: 'macos', label: t($ => $.settings.lookAndFeel.windowControlStyleMacos) },
{ value: 'windows', label: t($ => $.settings.lookAndFeel.windowControlStyleWindows) },
{ value: 'gnome', label: t($ => $.settings.lookAndFeel.windowControlStyleGnome) },
{ value: 'minimal', label: t($ => $.settings.lookAndFeel.windowControlStyleMinimal) },
];
const fontFamilyOptions: Array<{ value: FontFamily; label: string }> = [
{ value: 'system', label: t($ => $.settings.lookAndFeel.fontFamilySystem) },
{ value: 'inter', label: t($ => $.settings.lookAndFeel.fontFamilyInter) },
{ value: 'outfit', label: t($ => $.settings.lookAndFeel.fontFamilyOutfit) },
{ value: 'vazirmatn', label: t($ => $.settings.lookAndFeel.fontFamilyVazirmatn) },
{ value: 'noto-sans-hebrew', label: t($ => $.settings.lookAndFeel.fontFamilyNotoSansHebrew) },
{ value: 'noto-sans-sc', label: t($ => $.settings.lookAndFeel.fontFamilyNotoSansSc) },
{ value: 'roboto', label: t($ => $.settings.lookAndFeel.fontFamilyRoboto) },
{ value: 'serif', label: t($ => $.settings.lookAndFeel.fontFamilySerif) },
{ value: 'monospace', label: t($ => $.settings.lookAndFeel.fontFamilyMonospace) },
];
const TabButton = ({ type, icon: Icon, label }: { type: SettingsTab; icon: typeof Download; label: string }) => {
const active = activeTab === type;
@@ -853,40 +878,6 @@ runEngineChecks(false);
{/* Look & Feel Pane */}
{activeTab === 'lookandfeel' && (
<div className="settings-pane max-w-[720px]">
<h2 className="settings-section-title">{t($ => $.settings.lookAndFeel.language)}</h2>
<div className="mac-settings-group">
<div className="mac-settings-row">
<div className="settings-row-label">
<span>{t($ => $.settings.lookAndFeel.language)}</span>
<small>{t($ => $.settings.lookAndFeel.languageDescription)}</small>
</div>
<select
value={settings.language}
onChange={(event) => settings.setLanguage(event.target.value as typeof settings.language)}
className="app-control w-48"
>
{languageOptions.map(option => (
<option key={option.value} value={option.value}>{option.label}</option>
))}
</select>
</div>
<div className="mac-settings-row">
<div className="settings-row-label">
<span>{t($ => $.settings.lookAndFeel.sidebarPosition)}</span>
<small>{t($ => $.settings.lookAndFeel.sidebarPositionDescription)}</small>
</div>
<select
value={settings.sidebarPosition}
onChange={(event) => settings.setSidebarPosition(event.target.value as SidebarPosition)}
className="app-control w-48"
>
<option value="auto">{t($ => $.settings.lookAndFeel.sidebarPositionAutomatic)}</option>
<option value="left">{t($ => $.settings.lookAndFeel.sidebarPositionLeft)}</option>
<option value="right">{t($ => $.settings.lookAndFeel.sidebarPositionRight)}</option>
</select>
</div>
</div>
<h2 className="settings-section-title">{t($ => $.settings.lookAndFeel.appTheme)}</h2>
<div className="mac-settings-group">
<div className="mac-settings-row settings-choice-row">
@@ -924,6 +915,21 @@ runEngineChecks(false);
<h2 className="settings-section-title">{t($ => $.settings.lookAndFeel.display)}</h2>
<div className="mac-settings-group">
<div className="mac-settings-row">
<div className="settings-row-label">
<span>{t($ => $.settings.lookAndFeel.fontFamily)}</span>
<small>{t($ => $.settings.lookAndFeel.fontFamilyDescription)}</small>
</div>
<select
value={settings.fontFamily}
onChange={(event) => settings.setFontFamily(event.target.value as FontFamily)}
className="app-control w-60"
>
{fontFamilyOptions.map(option => (
<option key={option.value} value={option.value}>{option.label}</option>
))}
</select>
</div>
<div className="mac-settings-row">
<div className="settings-row-label">
<span>{t($ => $.settings.lookAndFeel.fontSize)}</span>
@@ -956,6 +962,78 @@ runEngineChecks(false);
</div>
</div>
<h2 className="settings-section-title">{t($ => $.settings.lookAndFeel.languageAndRegion)}</h2>
<div className="mac-settings-group">
<div className="mac-settings-row">
<div className="settings-row-label">
<span>{t($ => $.settings.lookAndFeel.language)}</span>
<small>{t($ => $.settings.lookAndFeel.languageDescription)}</small>
</div>
<select
value={settings.language}
onChange={(event) => settings.setLanguage(event.target.value as typeof settings.language)}
className="app-control w-60"
>
{languageOptions.map(option => (
<option key={option.value} value={option.value}>{option.label}</option>
))}
</select>
</div>
<div className="mac-settings-row">
<div className="settings-row-label">
<span>{t($ => $.settings.lookAndFeel.calendar)}</span>
<small>{t($ => $.settings.lookAndFeel.calendarDescription)}</small>
</div>
<select
value={settings.calendarPreference}
onChange={(event) => settings.setCalendarPreference(event.target.value as typeof settings.calendarPreference)}
className="app-control w-60"
>
{calendarOptions.map(option => (
<option key={option.value} value={option.value}>{option.label}</option>
))}
</select>
</div>
</div>
<h2 className="settings-section-title">{t($ => $.settings.lookAndFeel.windowControls)}</h2>
<div className="mac-settings-group">
<div className="mac-settings-row">
<div className="settings-row-label">
<span>{t($ => $.settings.lookAndFeel.windowControlStyle)}</span>
<small>{t($ => $.settings.lookAndFeel.windowControlsDescription)}</small>
</div>
<select
value={settings.windowControlStyle}
onChange={(event) => settings.setWindowControlStyle(event.target.value as WindowControlStyle)}
className="app-control w-60"
>
{windowControlStyleOptions.map(option => (
<option key={option.value} value={option.value}>{option.label}</option>
))}
</select>
</div>
</div>
<h2 className="settings-section-title">{t($ => $.settings.lookAndFeel.layout)}</h2>
<div className="mac-settings-group">
<div className="mac-settings-row">
<div className="settings-row-label">
<span>{t($ => $.settings.lookAndFeel.sidebarPosition)}</span>
<small>{t($ => $.settings.lookAndFeel.sidebarPositionDescription)}</small>
</div>
<select
value={settings.sidebarPosition}
onChange={(event) => settings.setSidebarPosition(event.target.value as SidebarPosition)}
className="app-control w-60"
>
<option value="auto">{t($ => $.settings.lookAndFeel.sidebarPositionAutomatic)}</option>
<option value="left">{t($ => $.settings.lookAndFeel.sidebarPositionLeft)}</option>
<option value="right">{t($ => $.settings.lookAndFeel.sidebarPositionRight)}</option>
</select>
</div>
</div>
<h2 className="settings-section-title">{t($ => $.settings.lookAndFeel.osIntegration)}</h2>
<div className="mac-settings-group">
{platform.os === 'macos' && (
@@ -1397,6 +1475,19 @@ runEngineChecks(false);
<p className="text-text-muted text-xs mt-0.5">{t($ => $.settings.power.preventSleepDescription)}</p>
</div>
</label>
<label className="flex items-start gap-3 cursor-default select-none text-[13px] text-text-secondary">
<input
type="checkbox"
checked={settings.preventsDisplaySleepWhileDownloading}
onChange={(e) => settings.setPreventsDisplaySleepWhileDownloading(e.target.checked)}
className="mt-0.5 rounded accent-accent"
/>
<div>
<p className="font-semibold text-text-primary">{t($ => $.settings.power.preventDisplaySleep)}</p>
<p className="text-text-muted text-xs mt-0.5">{t($ => $.settings.power.preventDisplaySleepDescription)}</p>
</div>
</label>
</div>
)}
+40 -1
View File
@@ -22,7 +22,7 @@ interface SidebarProps {
export const Sidebar: React.FC<SidebarProps> = (props) => {
const { selectedFilter, onSelectFilter } = props;
const { downloads, queues, addQueue, renameQueue, removeQueue, startQueue, pauseQueue } = useDownloadStore();
const { downloads, queues, addQueue, renameQueue, removeQueue, startQueue, pauseQueue, setQueueConcurrency } = useDownloadStore();
const { activeView, setActiveView, toggleSidebar } = useSettingsStore();
const { addToast } = useToast();
const { t } = useTranslation();
@@ -475,6 +475,45 @@ export const Sidebar: React.FC<SidebarProps> = (props) => {
<Pause size={14} className="me-2 text-text-secondary" />
{t($ => $.actions.pauseQueue)}
</button>
{(() => {
const queue = queues.find(candidate => candidate.id === contextMenu.id);
if (!queue) return null;
return (
<div
role="group"
aria-labelledby="queue-concurrency-label"
className="px-3 py-2 text-[12px] text-text-secondary"
onClick={event => event.stopPropagation()}
>
<label id="queue-concurrency-label" htmlFor="queue-concurrency-select" className="block mb-1">
{t($ => $.sidebar.queueConcurrency)}
</label>
<select
id="queue-concurrency-select"
aria-label={t($ => $.sidebar.queueConcurrency)}
value={queue.maxConcurrent?.toString() ?? ''}
className="w-full rounded border border-border-color bg-bg-context-menu px-1.5 py-1 text-[12px] text-text-primary outline-none focus:border-accent"
onChange={event => {
const value = event.currentTarget.value === ''
? null
: Number(event.currentTarget.value);
void setQueueConcurrency(queue.id, value).catch(error => {
addToast({
message: t($ => $.sidebar.queueConcurrencyFailed, { detail: String(error) }),
variant: 'error',
isActionable: true
});
});
}}
>
<option value="">{t($ => $.sidebar.queueConcurrencyGlobal)}</option>
{Array.from({ length: 12 }, (_, index) => index + 1).map(value => (
<option key={value} value={value}>{value}</option>
))}
</select>
</div>
);
})()}
<div className="h-px bg-border-color my-1 mx-2" />
<button
className="w-full text-start px-3 py-1.5 flex items-center hover:bg-item-hover"
+4 -2
View File
@@ -2,6 +2,7 @@ import { getCurrentWindow } from '@tauri-apps/api/window';
import { Maximize2, Minus, X } from 'lucide-react';
import type { PointerEvent } from 'react';
import { useTranslation } from 'react-i18next';
import type { ResolvedWindowControlStyle } from '../utils/windowControlStyle';
const appWindow = getCurrentWindow();
@@ -11,14 +12,15 @@ const stopTitlebarDrag = (event: PointerEvent<HTMLButtonElement>) => {
interface WindowControlsProps {
side: 'left' | 'right';
controlStyle: ResolvedWindowControlStyle;
}
export function WindowControls({ side }: WindowControlsProps) {
export function WindowControls({ side, controlStyle }: WindowControlsProps) {
const { t } = useTranslation();
return (
<div
className={`window-controls window-controls--${side}`}
className={`window-controls window-controls--${side} window-controls--style-${controlStyle}`}
aria-label={t($ => $.window.controls)}
>
<button
+60
View File
@@ -216,6 +216,13 @@ const common = {
connectionCountUnknown: '—/{{total}}',
connectionsUnavailable: '—',
speedCap: 'Speed cap',
liveSpeedLimit: 'Live speed cap',
liveSpeedLimitHint: 'Applies to active normal downloads only. Media downloads cannot be changed while running.',
liveSpeedLimitPlaceholder: 'e.g. 1024K',
liveSpeedLimitApply: 'Apply',
liveSpeedLimitClear: 'Clear',
liveSpeedLimitFailed: 'Could not update live speed cap: {{detail}}',
liveSpeedLimitUnavailable: 'Live speed control is unavailable for media downloads while running.',
category: 'Category',
lastTry: 'Last try',
dateAdded: 'Date added',
@@ -326,6 +333,9 @@ const common = {
queueNameExists: 'A queue with this name already exists',
startQueueFailed: 'Could not start queue: {{detail}}',
pauseQueueFailed: 'Could not pause queue: {{detail}}',
queueConcurrency: 'Concurrent downloads',
queueConcurrencyGlobal: 'Use global limit',
queueConcurrencyFailed: 'Could not update queue concurrency: {{detail}}',
},
downloadTable: {
unknownQueue: 'Unknown Queue',
@@ -353,6 +363,15 @@ const common = {
addDownload: 'Add Download',
resumeAll: 'Resume All',
pauseAll: 'Pause All',
summary: {
items: '{{count}} items',
selected: '{{count}} selected',
downloaded: 'Downloaded',
remaining: 'Remaining',
active: 'Active',
unknown: 'Unknown',
estimated: 'Estimated {{value}}',
},
queueEmpty: 'Queue is empty',
noCompletedDownloads: 'No Completed Downloads',
noDownloads: 'No Downloads',
@@ -361,6 +380,11 @@ const common = {
clickToAdd: 'Click',
addButtonOr: 'button or',
toAddDownloads: 'to add downloads',
queueReorderHint: 'Drag rows to reorder or use the hotkeys:',
queueReorderShortcut: '{{key}} + Arrow Up/Down moves the focused row',
queuePriorityControls: 'Move selected downloads in the queue',
queueDragHandle: 'Drag {{fileName}} to reorder',
queueReorderFailed: 'Could not reorder downloads',
nonResumableOne: '1 download does not support resuming. If you pause it, you will have to start over again later. Are you sure you want to pause?',
nonResumableMany: '{{count}} downloads do not support resuming. If you pause them, you will have to start over again later. Are you sure you want to pause?',
interactionError: '{{message}}: {{detail}}',
@@ -447,6 +471,14 @@ const common = {
fetchingMediaStreams: 'Fetching media streams...',
availableStreams: 'Available Streams',
availableMediaStreams: 'Available media streams',
quality: 'Quality',
format: 'Format',
video: 'Video',
audio: 'Audio',
noCommonFormat: 'No single format is available for all selected items.',
noCommonQuality: 'No single quality is available for all selected items.',
playlistQuality: 'Playlist quality',
playlistQualitySelected: '{{selected}} of {{total}} selected',
metadataUnavailable: 'Metadata unavailable. Refresh metadata before adding this media.',
saveLocation: 'Save Location',
browse: 'Browse',
@@ -553,12 +585,22 @@ const common = {
},
lookAndFeel: {
language: 'Language',
languageAndRegion: 'Language & region',
layout: 'Layout',
languageDescription: 'Choose the Firelink interface language.',
sidebarPosition: 'Sidebar position',
sidebarPositionDescription: 'Choose where the sidebar appears. Automatic follows the interface direction.',
sidebarPositionAutomatic: 'Automatic (recommended)',
sidebarPositionLeft: 'Left',
sidebarPositionRight: 'Right',
windowControls: 'Window controls',
windowControlStyle: 'Control style',
windowControlsDescription: 'Choose how Firelink draws its custom window buttons. Automatic follows your operating system.',
windowControlStyleAutomatic: 'Automatic (recommended)',
windowControlStyleMacos: 'macOS Traffic Lights',
windowControlStyleWindows: 'Windows 11 Fluent',
windowControlStyleGnome: 'GNOME / Adwaita',
windowControlStyleMinimal: 'Minimal',
languageSystem: 'System default',
languageEnglish: 'English',
languageChinese: 'Simplified Chinese',
@@ -566,6 +608,11 @@ const common = {
languagePersian: 'Persian',
languageUkrainian: 'Ukrainian',
languageRussian: 'Russian',
calendar: 'Calendar',
calendarDescription: 'Choose the calendar used for displayed dates and times. Gregorian is the default; this does not change scheduling or stored timestamps.',
calendarGregorian: 'Gregorian',
calendarPersian: 'Persian',
calendarHebrew: 'Hebrew',
appTheme: 'App Theme',
theme: 'Theme',
ariaLabel: 'App theme',
@@ -576,6 +623,17 @@ const common = {
nord: 'Nord',
systemAppearance: 'System follows the current {{platform}} light or dark appearance.',
display: 'Display',
fontFamily: 'Font',
fontFamilyDescription: 'Choose the interface font. System uses platform and locale-aware fallbacks.',
fontFamilySystem: 'System UI (recommended)',
fontFamilyInter: 'Inter',
fontFamilyOutfit: 'Outfit',
fontFamilyVazirmatn: 'Vazirmatn',
fontFamilyNotoSansHebrew: 'Noto Sans Hebrew',
fontFamilyNotoSansSc: 'Noto Sans SC',
fontFamilyRoboto: 'Roboto',
fontFamilySerif: 'Serif',
fontFamilyMonospace: 'Monospace',
fontSize: 'Font size',
fontSizeDescription: 'Scales the download list and compact controls.',
small: 'Small',
@@ -680,6 +738,8 @@ const common = {
title: 'Power Management',
preventSleep: 'Prevent system sleep while downloads are active',
preventSleepDescription: 'The display may still turn off. Firelink only keeps the device awake enough to complete active transfers.',
preventDisplaySleep: 'Keep the display awake while downloads are active',
preventDisplaySleepDescription: 'Optional and separate from system-sleep prevention. The display will remain on until active transfers finish.',
},
engine: {
title: 'Media Downloader & Engines',
+65 -5
View File
@@ -49,7 +49,7 @@ const fa = {
description: 'برخی از دانلودهایی که اضافه می‌کنید از قبل در صف یا روی دیسک وجود دارند. لطفاً نحوه حل این تداخل‌ها را انتخاب کنید.',
rename: 'تغییر نام',
replace: 'جایگزینی',
skip: 'رد شدن',
skip: 'رد کردن',
},
removeDownload: {
title: 'حذف دانلود',
@@ -166,7 +166,7 @@ const fa = {
afterCompletionDescription: 'انتخاب کنید پس از پایان دانلودهای شروع‌شده توسط زمان‌بند، چه اتفاقی بیفتد.',
actionWarning: 'این اقدام می‌تواند سایر کارهای کامپیوتر را مختل کند. Firelink بلافاصله پس از اتمام صف زمان‌بندی‌شده، آن را اجرا می‌کند.',
systemPermissions: 'مجوزهای سیستم',
macPermissionDescription: 'خواب، راه‌اندازی مجدد و خاموش کردن به مجوز macOS Automation برای System Events نیاز دارد.',
macPermissionDescription: 'خوابیدن، راه‌اندازی مجدد و خاموش کردن به مجوز macOS Automation برای System Events نیاز دارد.',
permissionGranted: 'مجوز اتوماسیون اعطا شد',
permissionChecking: 'در حال بررسی مجوز اتوماسیون…',
permissionMissing: 'مجوز اتوماسیون موجود نیست',
@@ -178,8 +178,8 @@ const fa = {
requestingPermission: 'در حال درخواست مجوز اتوماسیون…',
enablePermissionDetails: 'Firelink را در بخش System Settings > Privacy & Security > Automation برای System Events فعال کنید و سپس به Firelink بازگردید.',
systemActions: 'اقدامات سیستم',
windowsActionsDescription: 'خواب، راه‌اندازی مجدد و خاموش کردن از دسترسی‌های سیستم Windows استفاده می‌کنند. Firelink هنگام اجرا هر اقدام ردشده‌ای را گزارش می‌دهد؛ هیچ مجوز دائمی از قبل درخواست نمی‌شود.',
linuxActionsDescription: 'خواب، راه‌اندازی مجدد و خاموش کردن از دسکتاپ و سیاست‌های سیستم Linux استفاده می‌کنند. Firelink هنگام اجرا هر اقدام ردشده‌ای را گزارش می‌دهد؛ هیچ مجوز دائمی از قبل درخواست نمی‌شود.',
windowsActionsDescription: 'خوابیدن، راه‌اندازی مجدد و خاموش کردن از دسترسی‌های سیستم Windows استفاده می‌کنند. Firelink هنگام اجرا هر اقدام ردشده‌ای را گزارش می‌دهد؛ هیچ مجوز دائمی از قبل درخواست نمی‌شود.',
linuxActionsDescription: 'خوابیدن، راه‌اندازی مجدد و خاموش کردن از دسکتاپ و سیاست‌های سیستم Linux استفاده می‌کنند. Firelink هنگام اجرا هر اقدام ردشده‌ای را گزارش می‌دهد؛ هیچ مجوز دائمی از قبل درخواست نمی‌شود.',
validationDay: 'حداقل یک روز برای زمان‌بند انتخاب کنید',
validationQueue: 'حداقل یک صف برای زمان‌بند انتخاب کنید',
validationStopTime: 'زمان پایان باید بعد از زمان شروع باشد',
@@ -201,7 +201,7 @@ const fa = {
},
postActions: {
none: 'هیچ کاری انجام نده',
sleep: 'خواب',
sleep: 'خوابیدن',
restart: 'راه‌اندازی مجدد',
shutdown: 'خاموش کردن',
},
@@ -216,6 +216,13 @@ const fa = {
connectionCountUnknown: '—/{{total}} فعال',
connectionsUnavailable: '—',
speedCap: 'سقف سرعت',
liveSpeedLimit: 'سقف سرعت زنده',
liveSpeedLimitHint: 'فقط برای دانلودهای عادیِ فعال اعمال می‌شود. سرعت دانلودهای رسانه‌ای هنگام اجرا قابل تغییر نیست.',
liveSpeedLimitPlaceholder: 'مثلاً 1024K',
liveSpeedLimitApply: 'اعمال',
liveSpeedLimitClear: 'پاک کردن',
liveSpeedLimitFailed: 'به‌روزرسانی سقف سرعت زنده ممکن نیست: {{detail}}',
liveSpeedLimitUnavailable: 'تغییر زنده سرعت دانلودهای رسانه‌ای هنگام اجرا در دسترس نیست.',
category: 'دسته',
lastTry: 'آخرین تلاش',
dateAdded: 'تاریخ افزودن',
@@ -326,6 +333,9 @@ const fa = {
queueNameExists: 'یک صف با این نام از قبل وجود دارد',
startQueueFailed: 'شروع صف ناموفق بود: {{detail}}',
pauseQueueFailed: 'توقف صف ناموفق بود: {{detail}}',
queueConcurrency: 'دانلودهای هم‌زمان',
queueConcurrencyGlobal: 'استفاده از محدودیت کلی',
queueConcurrencyFailed: 'به‌روزرسانی هم‌زمانی صف ناموفق بود: {{detail}}',
},
downloadTable: {
unknownQueue: 'صف نامشخص',
@@ -353,6 +363,15 @@ const fa = {
addDownload: 'افزودن دانلود',
resumeAll: 'ادامه همه',
pauseAll: 'توقف همه',
summary: {
items: '{{count}} مورد',
selected: '{{count}} انتخاب‌شده',
downloaded: 'دانلودشده',
remaining: 'باقی‌مانده',
active: 'فعال',
unknown: 'نامشخص',
estimated: '{{value}} (تخمینی)',
},
queueEmpty: 'صف خالی است',
noCompletedDownloads: 'هیچ دانلود تکمیل‌شده‌ای وجود ندارد',
noDownloads: 'هیچ دانلودی وجود ندارد',
@@ -361,6 +380,11 @@ const fa = {
clickToAdd: 'برای افزودن دانلودها، روی',
addButtonOr: 'یا',
toAddDownloads: 'کلیک کنید',
queueReorderHint: 'ردیف‌ها را بکشید تا مرتب شوند یا از کلیدهای میانبر استفاده کنید:',
queueReorderShortcut: '{{key}} + فلش بالا/پایین ردیف انتخاب‌شده را جابه‌جا می‌کند',
queuePriorityControls: 'جابه‌جایی دانلودهای انتخاب‌شده در صف',
queueDragHandle: 'کشیدن {{fileName}} برای جابه‌جایی',
queueReorderFailed: 'جابه‌جایی دانلودها ناموفق بود',
nonResumableOne: '۱ دانلود از ادامه‌دادن پشتیبانی نمی‌کند. اگر آن را متوقف کنید، بعداً باید از ابتدا شروع کنید. آیا از توقف آن مطمئن هستید؟',
nonResumableMany: '{{count}} دانلود از ادامه‌دادن پشتیبانی نمی‌کنند. اگر آن‌ها را متوقف کنید، بعداً باید از ابتدا شروع کنید. آیا از توقف آن‌ها مطمئن هستید؟',
interactionError: '{{message}}: {{detail}}',
@@ -447,6 +471,14 @@ const fa = {
fetchingMediaStreams: 'در حال دریافت جریان‌های رسانه…',
availableStreams: 'جریان‌های موجود',
availableMediaStreams: 'جریان‌های رسانه موجود',
quality: 'کیفیت',
format: 'قالب',
video: 'ویدئو',
audio: 'صوتی',
noCommonFormat: 'هیچ فرمت مشترکی برای همه موارد انتخاب‌شده وجود ندارد.',
noCommonQuality: 'کیفیت یکسانی برای همه موارد انتخاب‌شده وجود ندارد.',
playlistQuality: 'کیفیت فهرست پخش',
playlistQualitySelected: '{{selected}} از {{total}} مورد انتخاب شده',
metadataUnavailable: 'متادیتا در دسترس نیست. پیش از افزودن این رسانه، متادیتا را تازه‌سازی کنید.',
saveLocation: 'محل ذخیره',
browse: 'انتخاب…',
@@ -553,12 +585,22 @@ const fa = {
},
lookAndFeel: {
language: 'زبان',
languageAndRegion: 'زبان و منطقه',
layout: 'چیدمان',
languageDescription: 'زبان رابط Firelink را انتخاب کنید.',
sidebarPosition: 'جایگاه نوار کناری',
sidebarPositionDescription: 'مشخص کنید نوار کناری کجا نمایش داده شود. حالت خودکار از جهت رابط کاربری پیروی می‌کند.',
sidebarPositionAutomatic: 'خودکار (پیشنهادشده)',
sidebarPositionLeft: 'چپ',
sidebarPositionRight: 'راست',
windowControls: 'کنترل‌های پنجره',
windowControlStyle: 'سبک کنترل‌ها',
windowControlsDescription: 'نحوه نمایش دکمه‌های سفارشی پنجره Firelink را انتخاب کنید. حالت خودکار از سیستم‌عامل شما پیروی می‌کند.',
windowControlStyleAutomatic: 'خودکار (پیشنهادشده)',
windowControlStyleMacos: 'چراغ‌های macOS',
windowControlStyleWindows: 'Windows 11 Fluent',
windowControlStyleGnome: 'GNOME / Adwaita',
windowControlStyleMinimal: 'ساده',
languageSystem: 'زبان سیستم',
languageEnglish: 'انگلیسی',
languageChinese: 'چینی ساده‌شده',
@@ -566,6 +608,11 @@ const fa = {
languagePersian: 'فارسی',
languageUkrainian: 'اوکراینی',
languageRussian: 'روسی',
calendar: 'تقویم',
calendarDescription: 'تقویم مورد استفاده برای نمایش تاریخ و زمان را انتخاب کنید. تقویم میلادی پیش‌فرض است و زمان‌بندی یا مُهرهای زمانی ذخیره‌شده را تغییر نمی‌دهد.',
calendarGregorian: 'میلادی',
calendarPersian: 'هجری شمسی',
calendarHebrew: 'عبری',
appTheme: 'ظاهر برنامه',
theme: 'ظاهر',
ariaLabel: 'ظاهر برنامه',
@@ -576,6 +623,17 @@ const fa = {
nord: 'Nord',
systemAppearance: 'برنامه از حالت روشن یا تاریک فعلی {{platform}} پیروی می‌کند.',
display: 'نمایش',
fontFamily: 'فونت',
fontFamilyDescription: 'فونت رابط کاربری را انتخاب کنید. حالت سیستم از فونت‌های سیستمی متناسب با سیستم‌عامل و زبان استفاده می‌کند.',
fontFamilySystem: 'System UI (recommended)',
fontFamilyInter: 'Inter',
fontFamilyOutfit: 'Outfit',
fontFamilyVazirmatn: 'Vazirmatn',
fontFamilyNotoSansHebrew: 'Noto Sans Hebrew',
fontFamilyNotoSansSc: 'Noto Sans SC',
fontFamilyRoboto: 'Roboto',
fontFamilySerif: 'Serif',
fontFamilyMonospace: 'Monospace',
fontSize: 'اندازه قلم',
fontSizeDescription: 'اندازه فهرست دانلود و کنترل‌های فشرده را تغییر می‌دهد.',
small: 'کوچک',
@@ -680,6 +738,8 @@ const fa = {
title: 'مدیریت انرژی',
preventSleep: 'جلوگیری از خواب سیستم در حالی که دانلودها فعال هستند',
preventSleepDescription: 'نمایشگر همچنان می‌تواند خاموش شود. Firelink تنها به اندازه‌ای دستگاه را بیدار نگه می‌دارد تا انتقال‌های فعال را تکمیل کند.',
preventDisplaySleep: 'روشن نگه داشتن نمایشگر هنگام فعال بودن دانلودها',
preventDisplaySleepDescription: 'این گزینه جدا از جلوگیری از خواب سیستم است و اختیاری است. نمایشگر تا پایان انتقال‌های فعال روشن می‌ماند.',
},
engine: {
title: 'دانلودکننده رسانه و موتورها',
+60
View File
@@ -216,6 +216,13 @@ const he = {
connectionCountUnknown: '—/{{total}} פעילות',
connectionsUnavailable: '—',
speedCap: 'הגבלת מהירות',
liveSpeedLimit: 'הגבלת מהירות בזמן אמת',
liveSpeedLimitHint: 'חל על הורדות רגילות פעילות בלבד. אי אפשר לשנות הורדות מדיה בזמן שהן פועלות.',
liveSpeedLimitPlaceholder: 'לדוגמה 1024K',
liveSpeedLimitApply: 'החל',
liveSpeedLimitClear: 'נקה',
liveSpeedLimitFailed: 'לא ניתן לעדכן את הגבלת המהירות בזמן אמת: {{detail}}',
liveSpeedLimitUnavailable: 'שליטה במהירות בזמן אמת אינה זמינה להורדות מדיה בזמן שהן פועלות.',
category: 'קטגוריה',
lastTry: 'ניסיון אחרון',
dateAdded: 'תאריך הוספה',
@@ -326,6 +333,9 @@ const he = {
queueNameExists: 'כבר קיים תור בשם זה',
startQueueFailed: 'לא ניתן להפעיל את התור: {{detail}}',
pauseQueueFailed: 'לא ניתן להשהות את התור: {{detail}}',
queueConcurrency: 'הורדות בו-זמניות',
queueConcurrencyGlobal: 'שימוש במגבלה הכללית',
queueConcurrencyFailed: 'לא ניתן לעדכן את מקביליות התור: {{detail}}',
},
downloadTable: {
unknownQueue: 'תור לא ידוע',
@@ -353,6 +363,15 @@ const he = {
addDownload: 'הוספת הורדה',
resumeAll: 'חידוש הכל',
pauseAll: 'השהיית הכל',
summary: {
items: '{{count}} פריטים',
selected: '{{count}} נבחרו',
downloaded: 'הורדו',
remaining: 'נותרו',
active: 'פעילות',
unknown: 'לא ידוע',
estimated: '{{value}} (הערכה)',
},
queueEmpty: 'התור ריק',
noCompletedDownloads: 'אין הורדות שהושלמו',
noDownloads: 'אין הורדות',
@@ -361,6 +380,11 @@ const he = {
clickToAdd: 'לחץ על לחצן',
addButtonOr: 'או',
toAddDownloads: 'כדי להוסיף הורדות',
queueReorderHint: 'גררו שורות כדי לשנות את הסדר או השתמשו במקשי הקיצור:',
queueReorderShortcut: '{{key}} + חץ למעלה/למטה מזיז את השורה הממוקדת',
queuePriorityControls: 'הזזת ההורדות שנבחרו בתור',
queueDragHandle: 'גררו את {{fileName}} כדי לשנות את הסדר',
queueReorderFailed: 'לא ניתן לשנות את סדר ההורדות',
nonResumableOne: 'הורדה אחת אינה תומכת בחידוש. אם תשהה אותה, תצטרך להתחיל מהתחלה מאוחר יותר. האם ברצונך להשהות?',
nonResumableMany: '{{count}} הורדות אינן תומכות בחידוש. אם תשהה אותן, תצטרך להתחיל מהתחלה מאוחר יותר. האם ברצונך להשהות?',
interactionError: '{{message}}: {{detail}}',
@@ -447,6 +471,14 @@ const he = {
fetchingMediaStreams: 'טוען זרמי מדיה…',
availableStreams: 'זרמים זמינים',
availableMediaStreams: 'זרמי מדיה זמינים',
quality: 'איכות',
format: 'פורמט',
video: 'וידאו',
audio: 'שמע',
noCommonFormat: 'אין פורמט אחד זמין לכל הפריטים שנבחרו.',
noCommonQuality: 'אין איכות אחת זמינה לכל הפריטים שנבחרו.',
playlistQuality: 'איכות רשימת השמעה',
playlistQualitySelected: '{{selected}} מתוך {{total}} נבחרו',
metadataUnavailable: 'מטא נתונים אינם זמינים. רענן מטא נתונים לפני הוספת מדיה זו.',
saveLocation: 'מיקום שמירה',
browse: 'עיון…',
@@ -553,12 +585,22 @@ const he = {
},
lookAndFeel: {
language: 'שפה',
languageAndRegion: 'שפה ואזור',
layout: 'פריסה',
languageDescription: 'בחר את שפת הממשק של Firelink.',
sidebarPosition: 'מיקום סרגל הצד',
sidebarPositionDescription: 'בחר היכן יופיע סרגל הצד. מצב אוטומטי עוקב אחר כיוון הממשק.',
sidebarPositionAutomatic: 'אוטומטי (מומלץ)',
sidebarPositionLeft: 'שמאל',
sidebarPositionRight: 'ימין',
windowControls: 'פקדי חלון',
windowControlStyle: 'סגנון פקדים',
windowControlsDescription: 'בחר כיצד Firelink מצייר את כפתורי החלון המותאמים אישית. מצב אוטומטי עוקב אחר מערכת ההפעלה שלך.',
windowControlStyleAutomatic: 'אוטומטי (מומלץ)',
windowControlStyleMacos: 'רמזורי macOS',
windowControlStyleWindows: 'Windows 11 Fluent',
windowControlStyleGnome: 'GNOME / Adwaita',
windowControlStyleMinimal: 'מינימלי',
languageSystem: 'שפת המערכת',
languageEnglish: 'אנגלית',
languageChinese: 'סינית מפושטת',
@@ -566,6 +608,11 @@ const he = {
languagePersian: 'פרסית',
languageUkrainian: 'אוקראינית',
languageRussian: 'רוסית',
calendar: 'לוח שנה',
calendarDescription: 'בחר את לוח השנה לתצוגת תאריכים ושעות. הלוח הגרגוריאני הוא ברירת המחדל; הבחירה אינה משנה תזמון או חותמות זמן שמורות.',
calendarGregorian: 'גרגוריאני',
calendarPersian: 'פרסי',
calendarHebrew: 'עברי',
appTheme: 'ערכת נושא לאפליקציה',
theme: 'ערכת נושא',
ariaLabel: 'ערכת נושא לאפליקציה',
@@ -576,6 +623,17 @@ const he = {
nord: 'Nord',
systemAppearance: 'מערכת עוקבת אחר המראה הנוכחי (בהיר או כהה) של {{platform}}.',
display: 'תצוגה',
fontFamily: 'גופן',
fontFamilyDescription: 'בחר את גופן הממשק. מצב המערכת משתמש בגופני מערכת המתאימים לפלטפורמה ולשפה.',
fontFamilySystem: 'System UI (recommended)',
fontFamilyInter: 'Inter',
fontFamilyOutfit: 'Outfit',
fontFamilyVazirmatn: 'Vazirmatn',
fontFamilyNotoSansHebrew: 'Noto Sans Hebrew',
fontFamilyNotoSansSc: 'Noto Sans SC',
fontFamilyRoboto: 'Roboto',
fontFamilySerif: 'Serif',
fontFamilyMonospace: 'Monospace',
fontSize: 'גודל גופן',
fontSizeDescription: 'משנה את קנה המידה של רשימת ההורדות והפקדים הקומפקטיים.',
small: 'קטן',
@@ -680,6 +738,8 @@ const he = {
title: 'ניהול צריכת חשמל',
preventSleep: 'מנע שינה של המערכת בזמן שהורדות פעילות',
preventSleepDescription: 'התצוגה עדיין עשויה להכבות. Firelink משאיר את המכשיר ער רק במידה המספקת להשלמת העברות פעילות.',
preventDisplaySleep: 'השאר את התצוגה פעילה בזמן שהורדות פעילות',
preventDisplaySleepDescription: 'אפשרות זו נפרדת ממניעת שינת המערכת. התצוגה תישאר פעילה עד לסיום ההעברות הפעילות.',
},
engine: {
title: 'מוריד מדיה ומנועים',
+61 -1
View File
@@ -203,7 +203,7 @@ const ru = {
none: 'Ничего не делать',
sleep: 'Спящий режим',
restart: 'Перезагрузка',
shutdown: 'Завершение работы',
shutdown: 'Выключение',
},
},
properties: {
@@ -216,6 +216,13 @@ const ru = {
connectionCountUnknown: '—/{{total}} активных',
connectionsUnavailable: '—',
speedCap: 'Ограничение скорости',
liveSpeedLimit: 'Текущее ограничение скорости',
liveSpeedLimitHint: 'Применяется только к активным обычным загрузкам. Скорость медиазагрузок нельзя изменить во время работы.',
liveSpeedLimitPlaceholder: 'например, 1024K',
liveSpeedLimitApply: 'Применить',
liveSpeedLimitClear: 'Очистить',
liveSpeedLimitFailed: 'Не удалось обновить текущее ограничение скорости: {{detail}}',
liveSpeedLimitUnavailable: 'Изменение скорости медиазагрузок во время работы недоступно.',
category: 'Категория',
lastTry: 'Последняя попытка',
dateAdded: 'Дата добавления',
@@ -326,6 +333,9 @@ const ru = {
queueNameExists: 'Очередь с таким названием уже существует',
startQueueFailed: 'Не удалось запустить очередь: {{detail}}',
pauseQueueFailed: 'Не удалось приостановить очередь: {{detail}}',
queueConcurrency: 'Одновременные загрузки',
queueConcurrencyGlobal: 'Использовать общий лимит',
queueConcurrencyFailed: 'Не удалось обновить параллельность очереди: {{detail}}',
},
downloadTable: {
unknownQueue: 'Неизвестная очередь',
@@ -353,6 +363,15 @@ const ru = {
addDownload: 'Добавить загрузку',
resumeAll: 'Возобновить все',
pauseAll: 'Приостановить все',
summary: {
items: '{{count}} элементов',
selected: 'Выбрано: {{count}}',
downloaded: 'Загружено',
remaining: 'Осталось',
active: 'Активные',
unknown: 'Неизвестно',
estimated: 'Около {{value}}',
},
queueEmpty: 'Очередь пуста',
noCompletedDownloads: 'Нет завершённых загрузок',
noDownloads: 'Нет загрузок',
@@ -361,6 +380,11 @@ const ru = {
clickToAdd: 'Нажмите кнопку',
addButtonOr: 'или',
toAddDownloads: 'чтобы добавить загрузки',
queueReorderHint: 'Перетаскивайте строки, чтобы изменить порядок, или используйте горячие клавиши:',
queueReorderShortcut: '{{key}} + стрелка вверх/вниз перемещает сфокусированную строку',
queuePriorityControls: 'Переместить выбранные загрузки в очереди',
queueDragHandle: 'Перетащите {{fileName}} для изменения порядка',
queueReorderFailed: 'Не удалось изменить порядок загрузок',
nonResumableOne: '1 загрузка не поддерживает возобновление. Если вы приостановите её, позже придётся начинать скачивание заново. Вы действительно хотите приостановить?',
nonResumableMany: '{{count}} загрузок не поддерживают возобновление. Если вы приостановите их, позже придётся начинать скачивание заново. Вы действительно хотите приостановить?',
interactionError: '{{message}}: {{detail}}',
@@ -447,6 +471,14 @@ const ru = {
fetchingMediaStreams: 'Получение медиапотоков…',
availableStreams: 'Доступные потоки',
availableMediaStreams: 'Доступные медиапотоки',
quality: 'Качество',
format: 'Формат',
video: 'Видео',
audio: 'Аудио',
noCommonFormat: 'Для всех выбранных элементов нет общего формата.',
noCommonQuality: 'Для всех выбранных элементов нет общего качества.',
playlistQuality: 'Качество плейлиста',
playlistQualitySelected: 'Выбрано: {{selected}} из {{total}}',
metadataUnavailable: 'Метаданные недоступны. Обновите метаданные перед добавлением этого медиа.',
saveLocation: 'Папка сохранения',
browse: 'Обзор…',
@@ -553,12 +585,22 @@ const ru = {
},
lookAndFeel: {
language: 'Язык',
languageAndRegion: 'Язык и регион',
layout: 'Компоновка',
languageDescription: 'Выберите язык интерфейса Firelink.',
sidebarPosition: 'Положение боковой панели',
sidebarPositionDescription: 'Выберите, где отображается боковая панель. В автоматическом режиме следует направлению интерфейса.',
sidebarPositionAutomatic: 'Автоматически (рекомендуется)',
sidebarPositionLeft: 'Слева',
sidebarPositionRight: 'Справа',
windowControls: 'Элементы управления окном',
windowControlStyle: 'Стиль элементов управления',
windowControlsDescription: 'Выберите, как Firelink отображает пользовательские кнопки окна. Автоматический режим учитывает вашу ОС.',
windowControlStyleAutomatic: 'Автоматически (рекомендуется)',
windowControlStyleMacos: 'Светофоры macOS',
windowControlStyleWindows: 'Windows 11 Fluent',
windowControlStyleGnome: 'GNOME / Adwaita',
windowControlStyleMinimal: 'Минималистичный',
languageSystem: 'Системный по умолчанию',
languageEnglish: 'Английский',
languageChinese: 'Упрощённый китайский',
@@ -566,6 +608,11 @@ const ru = {
languagePersian: 'Персидский',
languageUkrainian: 'Украинский',
languageRussian: 'Русский',
calendar: 'Календарь',
calendarDescription: 'Выберите календарь для отображения дат и времени. По умолчанию используется григорианский; расписание и сохранённые метки времени не изменяются.',
calendarGregorian: 'Григорианский',
calendarPersian: 'Персидский',
calendarHebrew: 'Еврейский',
appTheme: 'Тема приложения',
theme: 'Тема',
ariaLabel: 'Тема приложения',
@@ -576,6 +623,17 @@ const ru = {
nord: 'Nord',
systemAppearance: 'Системная тема соответствует текущему светлому или тёмному оформлению {{platform}}.',
display: 'Отображение',
fontFamily: 'Шрифт',
fontFamilyDescription: 'Выберите шрифт интерфейса. Системный режим использует системные шрифты с учётом ОС и языка.',
fontFamilySystem: 'System UI (recommended)',
fontFamilyInter: 'Inter',
fontFamilyOutfit: 'Outfit',
fontFamilyVazirmatn: 'Vazirmatn',
fontFamilyNotoSansHebrew: 'Noto Sans Hebrew',
fontFamilyNotoSansSc: 'Noto Sans SC',
fontFamilyRoboto: 'Roboto',
fontFamilySerif: 'Serif',
fontFamilyMonospace: 'Monospace',
fontSize: 'Размер шрифта',
fontSizeDescription: 'Масштабирует список загрузок и компактные элементы управления.',
small: 'Мелкий',
@@ -680,6 +738,8 @@ const ru = {
title: 'Управление питанием',
preventSleep: 'Запрещать спящий режим системы во время активных загрузок',
preventSleepDescription: 'Экран всё ещё может выключаться. Firelink предотвращает засыпание устройства только настолько, чтобы завершить активные передачи.',
preventDisplaySleep: 'Не выключать экран во время активных загрузок',
preventDisplaySleepDescription: 'Это отдельная необязательная настройка, не связанная с запретом сна системы. Экран останется включённым до завершения передач.',
},
engine: {
title: 'Загрузчик медиа и движки',
+60
View File
@@ -216,6 +216,13 @@ const uk = {
connectionCountUnknown: '—/{{total}} активних',
connectionsUnavailable: '—',
speedCap: 'Обмеження швидкості',
liveSpeedLimit: 'Поточне обмеження швидкості',
liveSpeedLimitHint: 'Застосовується лише до активних звичайних завантажень. Швидкість медіазавантажень не можна змінити під час роботи.',
liveSpeedLimitPlaceholder: 'наприклад, 1024K',
liveSpeedLimitApply: 'Застосувати',
liveSpeedLimitClear: 'Очистити',
liveSpeedLimitFailed: 'Не вдалося оновити поточне обмеження швидкості: {{detail}}',
liveSpeedLimitUnavailable: 'Зміна швидкості медіазавантажень під час роботи недоступна.',
category: 'Категорія',
lastTry: 'Остання спроба',
dateAdded: 'Дата додавання',
@@ -326,6 +333,9 @@ const uk = {
queueNameExists: 'Черга з такою назвою вже існує',
startQueueFailed: 'Не вдалося запустити чергу: {{detail}}',
pauseQueueFailed: 'Не вдалося призупинити чергу: {{detail}}',
queueConcurrency: 'Одночасні завантаження',
queueConcurrencyGlobal: 'Використовувати загальний ліміт',
queueConcurrencyFailed: 'Не вдалося оновити паралельність черги: {{detail}}',
},
downloadTable: {
unknownQueue: 'Невідома черга',
@@ -353,6 +363,15 @@ const uk = {
addDownload: 'Додати завантаження',
resumeAll: 'Відновити всі',
pauseAll: 'Призупинити всі',
summary: {
items: '{{count}} елементів',
selected: 'Вибрано: {{count}}',
downloaded: 'Завантажено',
remaining: 'Залишилось',
active: 'Активні',
unknown: 'Невідомо',
estimated: 'Приблизно {{value}}',
},
queueEmpty: 'Черга порожня',
noCompletedDownloads: 'Немає завершених завантажень',
noDownloads: 'Немає завантажень',
@@ -361,6 +380,11 @@ const uk = {
clickToAdd: 'Натисніть',
addButtonOr: 'або',
toAddDownloads: 'щоб додати завантаження',
queueReorderHint: 'Перетягуйте рядки, щоб змінити порядок, або використовуйте гарячі клавіші:',
queueReorderShortcut: '{{key}} + стрілка вгору/вниз переміщує сфокусований рядок',
queuePriorityControls: 'Перемістити вибрані завантаження в черзі',
queueDragHandle: 'Перетягніть {{fileName}}, щоб змінити порядок',
queueReorderFailed: 'Не вдалося змінити порядок завантажень',
nonResumableOne: '1 завантаження не підтримує відновлення. Якщо ви призупините його, вам доведеться почати спочатку пізніше. Ви впевнені, що хочете призупинити?',
nonResumableMany: '{{count}} завантажень не підтримують відновлення. Якщо ви призупините їх, вам доведеться почати спочатку пізніше. Ви впевнені, що хочете призупинити?',
interactionError: '{{message}}: {{detail}}',
@@ -447,6 +471,14 @@ const uk = {
fetchingMediaStreams: 'Отримання медіапотоків…',
availableStreams: 'Доступні потоки',
availableMediaStreams: 'Доступні медіапотоки',
quality: 'Якість',
format: 'Формат',
video: 'Відео',
audio: 'Аудіо',
noCommonFormat: 'Немає спільного формату для всіх вибраних елементів.',
noCommonQuality: 'Немає спільної якості для всіх вибраних елементів.',
playlistQuality: 'Якість плейлиста',
playlistQualitySelected: 'Вибрано: {{selected}} із {{total}}',
metadataUnavailable: 'Метадані недоступні. Оновіть метадані перед додаванням цього медіа.',
saveLocation: 'Місце збереження',
browse: 'Огляд…',
@@ -553,12 +585,22 @@ const uk = {
},
lookAndFeel: {
language: 'Мова',
languageAndRegion: 'Мова й регіон',
layout: 'Компонування',
languageDescription: 'Виберіть мову інтерфейсу Firelink.',
sidebarPosition: 'Положення бічної панелі',
sidebarPositionDescription: 'Виберіть, де відображати бічну панель. Автоматичний режим враховує напрямок інтерфейсу.',
sidebarPositionAutomatic: 'Автоматично (рекомендовано)',
sidebarPositionLeft: 'Ліворуч',
sidebarPositionRight: 'Праворуч',
windowControls: 'Керування вікном',
windowControlStyle: 'Стиль елементів керування',
windowControlsDescription: 'Виберіть, як Firelink відображає власні кнопки вікна. Автоматичний режим враховує вашу ОС.',
windowControlStyleAutomatic: 'Автоматично (рекомендовано)',
windowControlStyleMacos: 'Світлофори macOS',
windowControlStyleWindows: 'Windows 11 Fluent',
windowControlStyleGnome: 'GNOME / Adwaita',
windowControlStyleMinimal: 'Мінімальний',
languageSystem: 'Мова системи',
languageEnglish: 'Англійська',
languageChinese: 'Спрощена китайська',
@@ -566,6 +608,11 @@ const uk = {
languagePersian: 'Перська',
languageUkrainian: 'Українська',
languageRussian: 'Російська',
calendar: 'Календар',
calendarDescription: 'Виберіть календар для відображення дат і часу. Типово використовується григоріанський; розклад і збережені мітки часу не змінюються.',
calendarGregorian: 'Григоріанський',
calendarPersian: 'Перський',
calendarHebrew: 'Єврейський',
appTheme: 'Тема програми',
theme: 'Тема',
ariaLabel: 'Тема програми',
@@ -576,6 +623,17 @@ const uk = {
nord: 'Nord',
systemAppearance: 'Система слідкує за поточним світлим або темним виглядом {{platform}}.',
display: 'Відображення',
fontFamily: 'Шрифт',
fontFamilyDescription: 'Виберіть шрифт інтерфейсу. Системний режим використовує системні шрифти з урахуванням ОС і мови.',
fontFamilySystem: 'System UI (recommended)',
fontFamilyInter: 'Inter',
fontFamilyOutfit: 'Outfit',
fontFamilyVazirmatn: 'Vazirmatn',
fontFamilyNotoSansHebrew: 'Noto Sans Hebrew',
fontFamilyNotoSansSc: 'Noto Sans SC',
fontFamilyRoboto: 'Roboto',
fontFamilySerif: 'Serif',
fontFamilyMonospace: 'Monospace',
fontSize: 'Розмір шрифту',
fontSizeDescription: 'Масштабує список завантажень та компактні елементи керування.',
small: 'Дрібний',
@@ -680,6 +738,8 @@ const uk = {
title: 'Керування живленням',
preventSleep: 'Запобігати переходу системи в сплячий режим під час активних завантажень',
preventSleepDescription: 'Дисплей може все одно вимкнутися. Firelink лише не дає пристрою заснути, щоб завершити активні передачі.',
preventDisplaySleep: 'Не вимикати дисплей під час активних завантажень',
preventDisplaySleepDescription: 'Це окреме необов’язкове налаштування, незалежне від запобігання сну системи. Дисплей залишатиметься ввімкненим до завершення передач.',
},
engine: {
title: 'Завантажувач медіа та рушії',
+64 -4
View File
@@ -33,7 +33,7 @@ const zhCN = {
dismissNotification: '关闭通知',
queueName: '队列名称',
addNewQueue: '新建队列',
startQueue: '启动队列',
startQueue: '开始队列',
pauseQueue: '暂停队列',
renameQueue: '重命名队列',
deleteQueue: '删除队列',
@@ -71,7 +71,7 @@ const zhCN = {
moveUp: '上移',
moveDown: '下移',
pause: '暂停',
start: '启动',
start: '开始',
resume: '恢复',
options: '选项',
},
@@ -216,6 +216,13 @@ const zhCN = {
connectionCountUnknown: '—/{{total}} 个连接',
connectionsUnavailable: '—',
speedCap: '速度上限',
liveSpeedLimit: '实时速度上限',
liveSpeedLimitHint: '仅适用于正在进行的普通下载。媒体下载运行时无法更改速度。',
liveSpeedLimitPlaceholder: '例如 1024K',
liveSpeedLimitApply: '应用',
liveSpeedLimitClear: '清除',
liveSpeedLimitFailed: '无法更新实时速度上限:{{detail}}',
liveSpeedLimitUnavailable: '媒体下载运行时无法使用实时速度控制。',
category: '类别',
lastTry: '上次尝试',
dateAdded: '添加日期',
@@ -326,6 +333,9 @@ const zhCN = {
queueNameExists: '同名队列已存在',
startQueueFailed: '无法启动队列:{{detail}}',
pauseQueueFailed: '无法暂停队列:{{detail}}',
queueConcurrency: '并行下载数',
queueConcurrencyGlobal: '使用全局限制',
queueConcurrencyFailed: '无法更新队列并发数:{{detail}}',
},
downloadTable: {
unknownQueue: '未知队列',
@@ -353,6 +363,15 @@ const zhCN = {
addDownload: '添加下载',
resumeAll: '全部恢复',
pauseAll: '全部暂停',
summary: {
items: '{{count}} 个项目',
selected: '已选择 {{count}}',
downloaded: '已下载',
remaining: '剩余',
active: '进行中',
unknown: '未知',
estimated: '估计 {{value}}',
},
queueEmpty: '队列为空',
noCompletedDownloads: '没有已完成的下载',
noDownloads: '没有下载',
@@ -361,6 +380,11 @@ const zhCN = {
clickToAdd: '点击',
addButtonOr: '按钮或',
toAddDownloads: '来添加下载',
queueReorderHint: '拖动行调整顺序,或使用快捷键:',
queueReorderShortcut: '{{key}} + 上/下箭头可移动当前行',
queuePriorityControls: '移动队列中选中的下载',
queueDragHandle: '拖动 {{fileName}} 调整顺序',
queueReorderFailed: '无法调整下载顺序',
nonResumableOne: '1 个下载不支持断点续传。如果您暂停它,稍后您将不得不重新开始。确定要暂停吗?',
nonResumableMany: '{{count}} 个下载不支持断点续传。如果您暂停它们,稍后您将不得不重新开始。确定要暂停吗?',
interactionError: '{{message}}{{detail}}',
@@ -375,14 +399,14 @@ const zhCN = {
copyPathFailed: '无法复制文件路径',
missingFileName: '缺少文件名',
redownloadFailed: '重新下载失败',
startResume: '启动/恢复',
startResume: '开始/恢复',
addToQueue: '添加到队列',
copyAddress: '复制地址',
remove: '移除',
open: '打开',
showInFolder: '在文件夹中显示',
pause: '暂停',
start: '启动',
start: '开始',
resume: '恢复',
redownload: '重新下载',
copyFilePath: '复制文件路径',
@@ -447,6 +471,14 @@ const zhCN = {
fetchingMediaStreams: '正在获取媒体流…',
availableStreams: '可用流',
availableMediaStreams: '可用的媒体流',
quality: '画质',
format: '格式',
video: '视频',
audio: '音频',
noCommonFormat: '所选项目没有共同格式。',
noCommonQuality: '所选项目没有共同画质。',
playlistQuality: '播放列表画质',
playlistQualitySelected: '已选择 {{selected}} / {{total}}',
metadataUnavailable: '元数据不可用。在添加此媒体之前,请刷新元数据。',
saveLocation: '保存位置',
browse: '浏览',
@@ -553,12 +585,22 @@ const zhCN = {
},
lookAndFeel: {
language: '语言',
languageAndRegion: '语言和地区',
layout: '布局',
languageDescription: '选择 Firelink 的界面语言。',
sidebarPosition: '侧边栏位置',
sidebarPositionDescription: '选择侧边栏显示的位置。“自动”会跟随界面文字方向。',
sidebarPositionAutomatic: '自动(推荐)',
sidebarPositionLeft: '左侧',
sidebarPositionRight: '右侧',
windowControls: '窗口控件',
windowControlStyle: '控件样式',
windowControlsDescription: '选择 Firelink 绘制自定义窗口按钮的方式。“自动”会跟随操作系统。',
windowControlStyleAutomatic: '自动(推荐)',
windowControlStyleMacos: 'macOS 交通灯',
windowControlStyleWindows: 'Windows 11 Fluent',
windowControlStyleGnome: 'GNOME / Adwaita',
windowControlStyleMinimal: '极简',
languageSystem: '系统默认',
languageEnglish: '英语',
languageChinese: '简体中文',
@@ -566,6 +608,11 @@ const zhCN = {
languagePersian: '波斯语',
languageUkrainian: '乌克兰语',
languageRussian: '俄语',
calendar: '日历',
calendarDescription: '选择显示日期和时间所使用的日历。默认使用公历;此选择不会改变计划或已保存的时间戳。',
calendarGregorian: '公历',
calendarPersian: '波斯历',
calendarHebrew: '希伯来历',
appTheme: '应用主题',
theme: '主题',
ariaLabel: '应用主题',
@@ -576,6 +623,17 @@ const zhCN = {
nord: 'Nord',
systemAppearance: '系统跟随当前的 {{platform}} 浅色或深色外观。',
display: '显示',
fontFamily: '字体',
fontFamilyDescription: '选择界面字体。“系统”会使用适合操作系统和语言的系统字体及备用字体。',
fontFamilySystem: 'System UI (recommended)',
fontFamilyInter: 'Inter',
fontFamilyOutfit: 'Outfit',
fontFamilyVazirmatn: 'Vazirmatn',
fontFamilyNotoSansHebrew: 'Noto Sans Hebrew',
fontFamilyNotoSansSc: 'Noto Sans SC',
fontFamilyRoboto: 'Roboto',
fontFamilySerif: 'Serif',
fontFamilyMonospace: 'Monospace',
fontSize: '字体大小',
fontSizeDescription: '缩放下载列表和紧凑型控件。',
small: '小',
@@ -680,6 +738,8 @@ const zhCN = {
title: '电源管理',
preventSleep: '下载活动时阻止系统睡眠',
preventSleepDescription: '显示器可能仍会关闭。Firelink 仅保持设备唤醒足以完成活动的传输。',
preventDisplaySleep: '下载活动时保持显示器唤醒',
preventDisplaySleepDescription: '这是与阻止系统睡眠分开的可选设置。活动传输完成前,显示器将保持开启。',
},
engine: {
title: '媒体下载器和引擎',
+11
View File
@@ -94,6 +94,17 @@ describe('translation catalogs', () => {
'settings.integrations.chromiumZip',
'settings.lookAndFeel.dracula',
'settings.lookAndFeel.nord',
'settings.lookAndFeel.fontFamilyInter',
'settings.lookAndFeel.fontFamilyOutfit',
'settings.lookAndFeel.fontFamilyVazirmatn',
'settings.lookAndFeel.fontFamilyNotoSansHebrew',
'settings.lookAndFeel.fontFamilyNotoSansSc',
'settings.lookAndFeel.fontFamilyRoboto',
'settings.lookAndFeel.fontFamilySystem',
'settings.lookAndFeel.fontFamilySerif',
'settings.lookAndFeel.fontFamilyMonospace',
'settings.lookAndFeel.windowControlStyleWindows',
'settings.lookAndFeel.windowControlStyleGnome',
'settings.network.chromeWindows',
'settings.network.chromeMacos',
'settings.network.edgeWindows',
+604 -32
View File
@@ -11,7 +11,7 @@
--item-hover: 0 0% 0% / 0.05;
--item-selected: 211 100% 50%;
--accent-color: 211 100% 50%;
--stripe-bg: 0 0% 0% / 0.04;
--stripe-bg: 0 0% 0% / 0.035;
--text-primary: 0 0% 10%;
--text-secondary: 0 0% 40%;
@@ -54,7 +54,7 @@
--item-hover: 0 0% 0% / 0.05;
--item-selected: 211 100% 50%;
--accent-color: 211 100% 50%;
--stripe-bg: 0 0% 0% / 0.04;
--stripe-bg: 0 0% 0% / 0.035;
--text-primary: 0 0% 10%;
--text-secondary: 0 0% 40%;
--text-muted: 0 0% 55%;
@@ -84,7 +84,7 @@
--item-hover: 0 0% 100% / 0.07;
--item-selected: 211 100% 56%;
--accent-color: 211 100% 56%;
--stripe-bg: 0 0% 100% / 0.04;
--stripe-bg: 0 0% 100% / 0.025;
--text-primary: 0 0% 91%;
--text-secondary: 0 0% 75%;
--text-muted: 0 0% 50%;
@@ -128,7 +128,7 @@
--item-hover: 326 100% 74% / 0.10;
--item-selected: 326 100% 74%;
--accent-color: 326 100% 74%;
--stripe-bg: 0 0% 100% / 0.04;
--stripe-bg: 0 0% 100% / 0.025;
--text-primary: 60 30% 96%;
--text-secondary: 228 14% 74%;
--text-muted: 229 12% 58%;
@@ -172,7 +172,7 @@
--item-hover: 193 43% 67% / 0.12;
--item-selected: 193 43% 67%;
--accent-color: 193 43% 67%;
--stripe-bg: 0 0% 100% / 0.04;
--stripe-bg: 0 0% 100% / 0.025;
--text-primary: 218 27% 92%;
--text-secondary: 219 28% 80%;
--text-muted: 218 17% 63%;
@@ -223,8 +223,75 @@
--color-surface-overlay: hsl(var(--surface-overlay));
--color-bg-context-menu: hsl(var(--surface-overlay));
/* Apple System Fonts */
--font-sans: -apple-system, BlinkMacSystemFont, "SF Pro Text", "Segoe UI", Roboto, Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol";
/*
* Keep the default platform UI stack, and let each locale contribute a
* script-aware fallback. Optional custom families are bundled locally so
* the Tauri app remains usable offline.
*/
--font-locale-fallback: sans-serif;
--font-stack-system: -apple-system, BlinkMacSystemFont, "SF Pro Text", "Segoe UI", Roboto, Helvetica, Arial, var(--font-locale-fallback), "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol";
--font-stack-inter: "Inter Variable", -apple-system, BlinkMacSystemFont, "SF Pro Text", "Segoe UI", Roboto, Helvetica, Arial, var(--font-locale-fallback), "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol";
--font-stack-outfit: "Outfit Variable", -apple-system, BlinkMacSystemFont, "SF Pro Text", "Segoe UI", Roboto, Helvetica, Arial, var(--font-locale-fallback), "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol";
--font-stack-vazirmatn: "Vazirmatn Variable", var(--font-stack-system);
--font-stack-noto-sans-hebrew: "Noto Sans Hebrew Variable", var(--font-stack-system);
--font-stack-noto-sans-sc: "Noto Sans SC Variable", var(--font-stack-system);
--font-stack-roboto: "Roboto Variable", var(--font-stack-system);
--font-stack-serif: Georgia, "Times New Roman", var(--font-locale-fallback), serif;
--font-stack-monospace: ui-monospace, "SF Mono", Monaco, "Cascadia Code", "Fira Code", "JetBrains Mono", var(--font-locale-fallback), monospace;
--font-sans: var(--font-stack-system);
}
html[lang="fa"] {
--font-locale-fallback: "Geeza Pro", "Noto Sans Arabic", "Segoe UI", Tahoma, Arial, sans-serif;
}
html[lang="he"] {
--font-locale-fallback: "Arial Hebrew", "Segoe UI", Arial, sans-serif;
}
html[lang="zh-CN"] {
--font-locale-fallback: "PingFang SC", "Microsoft YaHei UI", "Noto Sans CJK SC", sans-serif;
}
html[lang="ru"],
html[lang="uk"] {
--font-locale-fallback: "Segoe UI", "Noto Sans", Roboto, Arial, sans-serif;
}
html[data-font-family="system"] {
--font-sans: var(--font-stack-system);
}
html[data-font-family="inter"] {
--font-sans: var(--font-stack-inter);
}
html[data-font-family="outfit"] {
--font-sans: var(--font-stack-outfit);
}
html[data-font-family="vazirmatn"] {
--font-sans: var(--font-stack-vazirmatn);
}
html[data-font-family="noto-sans-hebrew"] {
--font-sans: var(--font-stack-noto-sans-hebrew);
}
html[data-font-family="noto-sans-sc"] {
--font-sans: var(--font-stack-noto-sans-sc);
}
html[data-font-family="roboto"] {
--font-sans: var(--font-stack-roboto);
}
html[data-font-family="serif"] {
--font-sans: var(--font-stack-serif);
}
html[data-font-family="monospace"] {
--font-sans: var(--font-stack-monospace);
}
@layer base {
@@ -498,8 +565,9 @@ html[data-list-density="relaxed"] {
}
.app-modal-backdrop {
background: hsl(0 0% 0% / 0.2);
animation: fade-in 150ms ease-out;
/* Keep the scrim animation from applying opacity to the modal subtree. */
background-color: hsl(0 0% 0% / 0.2);
animation: modal-backdrop-in 150ms ease-out;
overflow: auto;
padding: 16px;
}
@@ -726,6 +794,163 @@ html[data-list-density="relaxed"] {
box-shadow: inset 3px 0 0 hsl(272 74% 62% / 0.9);
}
.add-download-playlist-quality {
margin-top: 12px;
padding-top: 12px;
border-top: 1px solid hsl(var(--border-modal));
}
.add-download-playlist-quality-toggle {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
gap: 10px;
padding: 7px 8px;
border: 1px solid transparent;
border-radius: 8px;
color: hsl(var(--text-primary));
text-align: start;
transition: background-color 100ms ease, border-color 100ms ease;
}
.add-download-playlist-quality-toggle:hover {
border-color: hsl(var(--border-modal));
background: hsl(var(--item-hover));
}
.add-download-playlist-quality-options {
display: grid;
grid-template-columns: minmax(0, 1.35fr) minmax(0, 0.85fr);
gap: 12px;
}
.add-download-playlist-media-type {
display: inline-flex;
gap: 2px;
margin-top: 8px;
padding: 2px;
border: 1px solid hsl(var(--border-modal));
border-radius: 7px;
background: hsl(var(--bg-input) / 0.52);
}
.add-download-playlist-media-type-option {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 26px;
gap: 5px;
padding: 3px 9px;
border-radius: 5px;
color: hsl(var(--text-muted));
font-size: 11px;
font-weight: 650;
transition: background-color 100ms ease, color 100ms ease, box-shadow 100ms ease;
}
.add-download-playlist-media-type-option:hover {
color: hsl(var(--text-primary));
}
.add-download-playlist-media-type-option:focus-visible {
outline: 2px solid hsl(var(--accent-color) / 0.62);
outline-offset: 1px;
}
.add-download-playlist-media-type-option.is-selected {
background: hsl(var(--surface-raised) / 0.9);
box-shadow: 0 1px 2px hsl(var(--shadow-color) / 0.35);
color: hsl(var(--text-primary));
}
.add-download-playlist-quality-column {
min-width: 0;
}
.add-download-playlist-quality-label {
margin-bottom: 5px;
color: hsl(var(--text-muted));
font-size: 10px;
font-weight: 700;
letter-spacing: 0.08em;
line-height: 1.2;
text-transform: uppercase;
}
.add-download-playlist-quality-list {
display: flex;
flex-direction: column;
max-height: 166px;
gap: 3px;
overflow-y: auto;
padding-inline-end: 2px;
}
.add-download-playlist-quality-toggle:focus-visible,
.add-download-quality-option:focus-visible {
outline: 2px solid hsl(var(--accent-color) / 0.62);
outline-offset: 1px;
}
.add-download-quality-option {
display: flex;
align-items: center;
min-height: 32px;
gap: 9px;
padding: 6px 9px;
border: 1px solid hsl(var(--border-modal));
border-radius: 7px;
background: hsl(var(--bg-input) / 0.68);
color: hsl(var(--text-secondary));
font-size: 12px;
text-align: start;
transition: background-color 100ms ease, border-color 100ms ease, color 100ms ease;
}
.add-download-quality-option:hover {
border-color: hsl(var(--text-muted) / 0.58);
background: hsl(var(--item-hover));
color: hsl(var(--text-primary));
}
.add-download-quality-option.is-selected {
border-color: hsl(var(--accent-color) / 0.48);
background: hsl(var(--accent-color) / 0.1);
color: hsl(var(--text-primary));
}
.add-download-quality-option-radio {
flex: 0 0 auto;
width: 14px;
height: 14px;
border: 1px solid hsl(var(--text-muted) / 0.72);
border-radius: 50%;
background: transparent;
}
.add-download-quality-option.is-selected .add-download-quality-option-radio {
border-color: hsl(var(--accent-color));
background: hsl(var(--accent-color));
box-shadow: inset 0 0 0 3px hsl(var(--bg-input));
}
.add-download-quality-chip,
.download-quality-chip {
display: inline-flex;
align-items: center;
min-height: 18px;
padding: 1px 6px;
border: 1px solid hsl(272 74% 62% / 0.32);
border-radius: 999px;
background: hsl(272 74% 58% / 0.12);
color: hsl(272 74% 62%);
font-size: 10px;
font-weight: 700;
line-height: 1.2;
letter-spacing: 0.01em;
}
.add-download-select {
appearance: auto;
cursor: default;
@@ -1131,6 +1356,7 @@ html[data-list-density="relaxed"] {
}
.app-statusbar {
position: relative;
height: 26px;
font-size: 10px;
background: hsl(var(--statusbar-bg));
@@ -1138,6 +1364,39 @@ html[data-list-density="relaxed"] {
box-shadow: none;
}
.app-statusbar-summary {
position: absolute;
left: 50%;
display: flex;
align-items: center;
gap: 12px;
max-width: min(52%, 560px);
overflow: hidden;
transform: translateX(-50%);
white-space: nowrap;
font-size: 10px;
line-height: 1;
pointer-events: none;
}
.app-statusbar-summary-metric {
display: inline-flex;
align-items: baseline;
gap: 4px;
min-width: 0;
}
.app-statusbar-summary-metric > span {
color: hsl(var(--text-muted));
}
.app-statusbar-summary-metric > strong {
overflow: hidden;
color: hsl(var(--text-secondary));
font-variant-numeric: tabular-nums;
text-overflow: ellipsis;
}
.sidebar-nav-item {
height: 31px;
border-radius: 8px;
@@ -1659,7 +1918,8 @@ html[data-list-density="relaxed"] {
z-index: 60;
display: inline-flex;
align-items: center;
gap: 8px;
direction: ltr;
gap: 9px;
-webkit-app-region: no-drag;
pointer-events: auto;
}
@@ -1672,6 +1932,7 @@ html[data-list-density="relaxed"] {
.window-controls--right {
left: auto;
right: 22px;
flex-direction: row-reverse;
}
.window-control {
@@ -1719,6 +1980,142 @@ html[data-list-density="relaxed"] {
filter: brightness(0.9);
}
/* Windows 11 caption-button treatment: full-width hit targets, neutral
glyphs, side-aware close placement, and rounded outer corners. */
.window-controls--style-windows {
top: 18px;
gap: 0;
flex-direction: row;
border-radius: 10px;
}
.window-controls--style-windows .window-control {
width: 46px;
height: 32px;
border: 0;
border-radius: 0;
background: transparent;
box-shadow: none;
color: hsl(var(--text-primary) / 0.84);
order: initial;
}
.window-controls--style-windows .window-control.minimize {
order: 2;
}
.window-controls--style-windows .window-control.maximize {
order: 3;
}
.window-controls--style-windows .window-control.close {
order: 1;
}
/* Keep the close action at the outer top corner on either sidebar side. */
.window-controls--right.window-controls--style-windows .window-control.minimize {
order: 1;
}
.window-controls--right.window-controls--style-windows .window-control.maximize {
order: 2;
}
.window-controls--right.window-controls--style-windows .window-control.close {
order: 3;
}
.window-controls--left.window-controls--style-windows .window-control.close {
border-radius: 10px 0 0 10px;
}
.window-controls--left.window-controls--style-windows .window-control.maximize {
border-radius: 0 10px 10px 0;
}
.window-controls--right.window-controls--style-windows .window-control.minimize {
border-radius: 10px 0 0 10px;
}
.window-controls--right.window-controls--style-windows .window-control.close {
border-radius: 0 10px 10px 0;
}
.window-controls--style-windows .window-control:hover {
color: hsl(var(--text-primary));
background: hsl(var(--item-hover));
filter: none;
}
.window-controls--style-windows .window-control.close:hover {
color: white;
background: #c42b1c;
}
.window-controls--style-windows .window-control:active {
transform: none;
filter: brightness(0.9);
}
/* Keep the smaller macOS traffic lights centered on the same lower titlebar
line as the larger caption-button presets. */
.window-controls--style-macos {
top: 27px;
}
/* GNOME/Adwaita header-bar treatment: compact, borderless icon buttons. */
.window-controls--style-gnome {
top: 18px;
gap: 4px;
}
.window-controls--style-gnome .window-control,
.window-controls--style-minimal .window-control {
border: 0;
background: transparent;
box-shadow: none;
color: hsl(var(--text-primary) / 0.84);
}
.window-controls--style-gnome .window-control {
width: 32px;
height: 32px;
border-radius: 6px;
}
.window-controls--style-gnome .window-control:hover,
.window-controls--style-minimal .window-control:hover {
color: hsl(var(--text-primary));
background: hsl(var(--item-hover));
filter: none;
}
.window-controls--style-gnome .window-control.close,
.window-controls--style-gnome .window-control.minimize,
.window-controls--style-gnome .window-control.maximize,
.window-controls--style-minimal .window-control.close,
.window-controls--style-minimal .window-control.minimize,
.window-controls--style-minimal .window-control.maximize {
background: transparent;
}
/* Minimal keeps the compact footprint while removing colored circles. */
.window-controls--style-minimal {
top: 23px;
gap: 4px;
}
.window-controls--style-minimal .window-control {
width: 22px;
height: 22px;
border-radius: 6px;
}
.window-controls--style-minimal .window-control:active,
.window-controls--style-gnome .window-control:active {
filter: none;
}
.main-titlebar {
height: 52px;
display: flex;
@@ -1805,9 +2202,11 @@ html[data-list-density="relaxed"] {
}
.downloads-content-header {
height: 58px;
min-height: 58px;
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px 16px;
padding: 0 16px;
background: hsl(var(--main-bg));
}
@@ -1837,6 +2236,21 @@ html[data-list-density="relaxed"] {
background: hsl(var(--item-hover));
}
.downloads-header-actions {
min-width: 0;
display: flex;
align-items: center;
gap: 10px;
margin-inline-start: auto;
}
.downloads-selection-status {
color: hsl(var(--text-primary));
font-size: 11px;
font-weight: 650;
white-space: nowrap;
}
.downloads-table {
flex: 1;
min-height: 0;
@@ -1897,6 +2311,59 @@ html[data-list-density="relaxed"] {
font-weight: 600;
}
.downloads-queue-reorder-hint {
display: inline-flex;
align-items: center;
gap: 7px;
min-width: 0;
max-width: 420px;
min-height: 17px;
overflow: visible;
color: hsl(var(--text-muted));
font-size: 10px;
font-weight: 500;
line-height: 1.2;
letter-spacing: 0;
white-space: nowrap;
text-overflow: ellipsis;
}
.downloads-queue-reorder-hint > span:first-child {
display: block;
min-width: 0;
overflow: hidden;
padding-block: 1px;
text-overflow: ellipsis;
line-height: 1.2;
}
.downloads-queue-reorder-shortcut {
display: inline-flex;
align-items: center;
flex: 0 0 auto;
gap: 3px;
direction: ltr;
color: hsl(var(--text-muted));
}
.downloads-queue-reorder-shortcut kbd {
min-width: 16px;
height: 17px;
padding: 0 4px;
display: inline-flex;
align-items: center;
justify-content: center;
border: 1px solid hsl(var(--border-color));
border-bottom-color: hsl(var(--border-color) / 0.72);
border-radius: 4px;
background: hsl(var(--item-hover));
color: hsl(var(--text-secondary));
font-family: inherit;
font-size: 9px;
font-weight: 650;
line-height: 1;
}
.download-table-scroll {
flex: 1;
display: flex;
@@ -2067,6 +2534,7 @@ body.is-column-resizing .column-resize-handle:hover::after {
}
.download-table-list {
position: relative;
height: 100%;
min-height: 0;
overflow-x: hidden;
@@ -2075,22 +2543,100 @@ body.is-column-resizing .column-resize-handle:hover::after {
flex-direction: column;
}
.download-table-list-content {
display: flex;
flex: 1 0 100%;
min-height: 100%;
flex-direction: column;
}
.download-queue-drop-marker {
position: absolute;
inset-inline: 0;
z-index: 8;
height: 2px;
pointer-events: none;
border-radius: 999px;
background: hsl(var(--accent-color));
box-shadow: 0 0 0 1px hsl(var(--accent-color) / 0.18), 0 0 8px hsl(var(--accent-color) / 0.55);
transform: translateY(-1px);
transition: top 100ms cubic-bezier(0.2, 0.8, 0.2, 1), opacity 100ms ease;
will-change: top;
}
.download-queue-drag-preview {
position: absolute;
inset-inline: 0;
z-index: 10;
display: flex;
align-items: center;
gap: 10px;
height: var(--download-row-height);
margin: var(--download-row-margin) 0;
padding: 0 var(--download-row-padding-x);
overflow: hidden;
border: 1px solid hsl(var(--accent-color) / 0.58);
border-radius: 6px;
background: hsl(var(--surface-overlay) / 0.94);
box-shadow: 0 8px 20px hsl(0 0% 0% / 0.28), 0 0 0 1px hsl(var(--accent-color) / 0.14);
color: hsl(var(--text-primary));
font-size: var(--download-row-font-size);
pointer-events: none;
transform: translateZ(0);
will-change: top;
}
.download-queue-drag-preview-icon {
display: inline-flex;
flex: 0 0 auto;
align-items: center;
}
.download-queue-drag-preview-name {
min-width: 0;
overflow: hidden;
font-weight: 600;
text-overflow: ellipsis;
white-space: nowrap;
}
.download-queue-drag-preview-count {
flex: 0 0 auto;
padding: 2px 6px;
border-radius: 999px;
background: hsl(var(--accent-color) / 0.2);
color: hsl(var(--text-primary));
font-size: 10px;
font-weight: 700;
}
.download-row {
flex: 0 0 var(--download-row-height);
height: var(--download-row-height);
display: grid;
align-items: center;
width: 100%;
padding: 0 var(--download-row-padding-x);
margin: var(--download-row-margin) 0;
border-radius: 6px;
color: hsl(var(--text-primary));
font-size: var(--download-row-font-size);
overflow: hidden;
overflow: visible;
user-select: none;
-webkit-user-select: none;
}
.download-row-motion {
display: grid;
align-items: center;
width: 100%;
height: 100%;
padding: 0 var(--download-row-padding-x);
border-radius: inherit;
color: inherit;
font-size: inherit;
overflow: hidden;
user-select: inherit;
-webkit-user-select: inherit;
}
html[data-list-density="compact"] .download-row,
html[data-list-density="compact"] .download-ghost-row {
height: var(--download-row-height);
@@ -2103,37 +2649,61 @@ html[data-list-density="relaxed"] .download-ghost-row {
margin-block: var(--download-row-margin);
}
.download-row > div {
.download-row-motion > div {
min-width: 0;
overflow: hidden;
padding-inline: var(--download-column-padding-x);
}
.download-row > div:first-child {
.download-row-motion > div:first-child {
padding-inline-start: 0;
}
.download-row > .download-status-cell {
.download-row-motion > .download-status-cell {
padding-inline: 8px;
}
.download-row.is-selected {
.download-row.is-selected .download-row-motion {
background: hsl(var(--accent-color) / 0.26) !important;
box-shadow: inset 0 0 0 1px hsl(var(--accent-color) / 0.28);
}
.download-row.is-selected:hover {
.download-row.is-selected:hover .download-row-motion {
background: hsl(var(--accent-color) / 0.32) !important;
}
.download-row.is-queue-drag-source {
opacity: 0.16;
box-shadow: inset 0 0 0 1px hsl(var(--accent-color) / 0.3);
}
.download-row.is-queue-reorderable {
cursor: grab;
touch-action: none;
transition: background-color 120ms ease, box-shadow 120ms ease, opacity 120ms ease;
}
.download-row.is-queue-reorderable:active {
cursor: grabbing;
}
.download-table-list-content > .download-row:nth-child(even) .download-row-motion {
background: hsl(var(--stripe-bg));
}
body.is-queue-dragging,
body.is-queue-dragging * {
cursor: grabbing !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-row:hover .download-row-motion {
background: hsl(var(--item-hover));
}
.download-column-cell {
display: flex;
@@ -2219,16 +2789,18 @@ html[data-list-density="relaxed"] .download-ghost-row {
.download-row-actions {
position: fixed;
right: 0;
display: flex;
align-self: stretch;
align-items: center;
justify-content: flex-end;
z-index: 2;
max-width: 100%;
min-width: 0;
padding: 0;
background: transparent;
padding: 0 2px;
border: 1px solid hsl(var(--border-color) / 0.88);
border-radius: 6px;
background: hsl(var(--surface-raised));
box-shadow: 0 1px 2px hsl(var(--shadow-color));
visibility: hidden;
opacity: 0;
pointer-events: none;
transition: opacity 120ms ease;
@@ -2239,11 +2811,6 @@ html[data-list-density="relaxed"] .download-ghost-row {
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) {
@@ -2607,6 +3174,11 @@ html[dir="rtl"] .download-context-menu-chevron {
to { opacity: 1; }
}
@keyframes modal-backdrop-in {
from { background-color: hsl(0 0% 0% / 0); }
to { background-color: hsl(0 0% 0% / 0.2); }
}
@keyframes pulse-progress {
0%, 100% { opacity: 1; }
50% { opacity: 0.6; }
+9 -2
View File
@@ -16,6 +16,7 @@ import type { PairingTokenHydration } from './bindings/PairingTokenHydration';
import type { EnqueueItem } from './bindings/EnqueueItem';
import type { EnqueueAccepted } from './bindings/EnqueueAccepted';
import type { PlatformInfo } from './bindings/PlatformInfo';
import type { QueueConcurrencyConfig } from './bindings/QueueConcurrencyConfig';
type CommandMap = {
fetch_metadata: {
@@ -37,7 +38,7 @@ type CommandMap = {
reveal_in_file_manager: { args: { path: string }; result: void };
open_downloaded_file: { args: { path: string }; result: void };
pause_download: { args: { id: string }; result: void };
resume_download: { args: { id: string }; result: boolean };
resume_download: { args: { id: string; queueId: string }; result: boolean };
remove_download: { args: { id: string; deleteAssets: boolean; preserveResumable?: boolean }; result: void };
detach_download_for_reconfigure: { args: { id: string }; result: void };
begin_dock_badge_session: { args: undefined; result: number };
@@ -45,9 +46,15 @@ type CommandMap = {
get_platform_info: { args: undefined; result: PlatformInfo };
approve_download_root: { args: { path: string }; result: string };
set_prevent_sleep: { args: { prevent: boolean }; result: void };
set_power_preferences: {
args: { preventSystemSleep: boolean; preventDisplaySleep: boolean };
result: void;
};
perform_system_action: { args: { action: PostQueueAction }; result: void };
ack_schedule_trigger: { args: { action: 'start' | 'stop'; key: string }; result: void };
set_concurrent_limit: { args: { limit: number }; result: void };
set_queue_concurrency_limits: { args: { limits: QueueConcurrencyConfig[] }; result: void };
set_download_speed_limit: { args: { id: string; limit: string | null }; result: void };
set_global_speed_limit: { args: { limit: string | null }; result: void };
request_automation_permission: { args: undefined; result: void };
check_automation_permission: { args: undefined; result: void };
@@ -98,7 +105,7 @@ type CommandMap = {
cancel_enqueue_generation: { args: { id: string; generation: string }; result: void };
enqueue_many: { args: { items: EnqueueItem[] }; result: import('./bindings/EnqueueResult').EnqueueResult[] };
move_in_queue: { args: { id: string; queueId: string; direction: 'up' | 'down' }; result: string[] };
move_many_in_queue: { args: { ids: string[]; queueId: string; direction: 'up' | 'down' }; result: string[] };
move_many_in_queue: { args: { ids: string[]; queueId: string; direction: 'up' | 'down'; targetIndex?: number }; result: string[] };
remove_from_queue: { args: { id: string }; result: boolean };
};
+6
View File
@@ -1,5 +1,11 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import "@fontsource-variable/inter/wght.css";
import "@fontsource-variable/noto-sans-hebrew/wght.css";
import "@fontsource-variable/noto-sans-sc/wght.css";
import "@fontsource-variable/outfit/wght.css";
import "@fontsource-variable/roboto/wght.css";
import "@fontsource-variable/vazirmatn/wght.css";
import "./index.css";
import App from "./App";
import { i18nReady } from "./i18n";
+301
View File
@@ -267,6 +267,166 @@ describe('useDownloadStore', () => {
expect(useDownloadStore.getState().renameQueue('queue-a', '')).toBe(false);
});
it('persists a queue concurrency override only after backend synchronization', async () => {
useDownloadStore.setState({
queues: [
{ id: 'main', name: 'Main Queue', isMain: true },
{ id: 'queue-a', name: 'Downloads', isMain: false }
]
});
vi.mocked(ipc.invokeCommand).mockResolvedValue(undefined as never);
await useDownloadStore.getState().setQueueConcurrency('queue-a', 2);
expect(useDownloadStore.getState().queues).toEqual([
{ id: 'main', name: 'Main Queue', isMain: true },
{ id: 'queue-a', name: 'Downloads', isMain: false, maxConcurrent: 2 }
]);
expect(vi.mocked(ipc.invokeCommand)).toHaveBeenCalledWith(
'set_queue_concurrency_limits',
{
limits: [
{ id: 'main', maxConcurrent: null },
{ id: 'queue-a', maxConcurrent: 2 }
]
}
);
});
it('retains the previous queue concurrency after backend synchronization fails', async () => {
useDownloadStore.setState({
queues: [
{ id: 'main', name: 'Main Queue', isMain: true },
{ id: 'queue-a', name: 'Downloads', isMain: false, maxConcurrent: 2 }
]
});
vi.mocked(ipc.invokeCommand).mockRejectedValue(new Error('backend unavailable'));
await expect(useDownloadStore.getState().setQueueConcurrency('queue-a', 3))
.rejects.toThrow('backend unavailable');
expect(useDownloadStore.getState().queues[1].maxConcurrent).toBe(2);
});
it('rebases queue concurrency updates when queue state changes during IPC', async () => {
useDownloadStore.setState({
queues: [
{ id: 'main', name: 'Main Queue', isMain: true },
{ id: 'queue-a', name: 'Downloads', isMain: false }
]
});
let releaseFirstSync!: () => void;
const firstSyncReleased = new Promise<void>(resolve => { releaseFirstSync = resolve; });
let syncCalls = 0;
vi.mocked(ipc.invokeCommand).mockImplementation(async command => {
if (command === 'set_queue_concurrency_limits') {
syncCalls += 1;
if (syncCalls === 1) await firstSyncReleased;
}
return undefined;
});
const update = useDownloadStore.getState().setQueueConcurrency('queue-a', 3);
await vi.waitFor(() => expect(syncCalls).toBe(1));
useDownloadStore.setState(state => ({
queues: state.queues.filter(queue => queue.id !== 'queue-a')
}));
releaseFirstSync();
await expect(update).rejects.toThrow('Queue no longer exists.');
expect(useDownloadStore.getState().queues).toEqual([
{ id: 'main', name: 'Main Queue', isMain: true }
]);
expect(syncCalls).toBe(2);
const configCalls = vi.mocked(ipc.invokeCommand).mock.calls
.filter(([command]) => command === 'set_queue_concurrency_limits');
expect(configCalls[1]).toEqual([
'set_queue_concurrency_limits',
{ limits: [{ id: 'main', maxConcurrent: null }] }
]);
});
it('updates an active normal download after applying a live speed limit', async () => {
useDownloadStore.setState({
downloads: [{
id: 'live-speed',
status: 'downloading',
isMedia: false,
speedLimit: '512K'
}] as any[]
});
vi.mocked(ipc.invokeCommand).mockResolvedValue(undefined as never);
await useDownloadStore.getState().setDownloadSpeedLimit('live-speed', '2M');
expect(ipc.invokeCommand).toHaveBeenCalledWith('set_download_speed_limit', {
id: 'live-speed',
limit: '2M'
});
expect(useDownloadStore.getState().downloads[0].speedLimit).toBe('2M');
await useDownloadStore.getState().setDownloadSpeedLimit('live-speed', null);
const speedLimitCalls = vi.mocked(ipc.invokeCommand).mock.calls
.filter(([command]) => command === 'set_download_speed_limit');
expect(speedLimitCalls[speedLimitCalls.length - 1]).toEqual(['set_download_speed_limit', {
id: 'live-speed',
limit: null
}]);
expect(useDownloadStore.getState().downloads[0].speedLimit).toBeUndefined();
});
it('rejects live speed changes for media and inactive downloads', async () => {
useDownloadStore.setState({
downloads: [
{ id: 'media-speed', status: 'downloading', isMedia: true, speedLimit: '1M' },
{ id: 'paused-speed', status: 'paused', isMedia: false, speedLimit: '1M' }
] as any[]
});
await expect(useDownloadStore.getState().setDownloadSpeedLimit('media-speed', '2M'))
.rejects.toThrow('media downloads');
await expect(useDownloadStore.getState().setDownloadSpeedLimit('paused-speed', '2M'))
.rejects.toThrow('active download');
expect(ipc.invokeCommand).not.toHaveBeenCalledWith('set_download_speed_limit', expect.anything());
});
it('keeps the prior live speed limit when the backend rejects the update', async () => {
useDownloadStore.setState({
downloads: [{
id: 'live-speed-failure',
status: 'downloading',
isMedia: false,
speedLimit: '512K'
}] as any[]
});
vi.mocked(ipc.invokeCommand).mockImplementation(async command => {
if (command === 'set_download_speed_limit') throw new Error('aria2 unavailable');
return undefined;
});
await expect(useDownloadStore.getState().setDownloadSpeedLimit('live-speed-failure', '2M'))
.rejects.toThrow('aria2 unavailable');
expect(useDownloadStore.getState().downloads[0].speedLimit).toBe('512K');
});
it('coalesces duplicate live speed updates for one download', async () => {
useDownloadStore.setState({
downloads: [{ id: 'live-speed-duplicate', status: 'downloading', isMedia: false }] as any[]
});
let releaseBackend!: () => void;
const backendFinished = new Promise<void>(resolve => { releaseBackend = resolve; });
vi.mocked(ipc.invokeCommand).mockImplementation(async () => backendFinished);
const first = useDownloadStore.getState().setDownloadSpeedLimit('live-speed-duplicate', '2M');
const second = useDownloadStore.getState().setDownloadSpeedLimit('live-speed-duplicate', '3M');
expect(second).toBe(first);
releaseBackend();
await first;
expect(vi.mocked(ipc.invokeCommand).mock.calls.filter(([command]) => command === 'set_download_speed_limit'))
.toHaveLength(1);
expect(useDownloadStore.getState().downloads[0].speedLimit).toBe('2M');
});
it('normalizes malformed persisted queues around one canonical main queue', () => {
expect(normalizePersistedQueues([
{ id: 'custom-a', name: ' Downloads ', isMain: false },
@@ -286,6 +446,47 @@ describe('useDownloadStore', () => {
]).queueIdRemap.get('legacy-main')).toBe('00000000-0000-0000-0000-000000000001');
});
it('keeps only valid persisted queue concurrency overrides', () => {
expect(normalizePersistedQueues([
{ id: 'main', name: 'Main', isMain: true, maxConcurrent: 4 },
{ id: 'valid', name: 'Valid', isMain: false, maxConcurrent: 12 },
{ id: 'zero', name: 'Zero', isMain: false, maxConcurrent: 0 },
{ id: 'large', name: 'Large', isMain: false, maxConcurrent: 13 },
{ id: 'null', name: 'Null', isMain: false, maxConcurrent: null }
])).toEqual([
{ id: '00000000-0000-0000-0000-000000000001', name: 'Main', isMain: true, maxConcurrent: 4 },
{ id: 'valid', name: 'Valid', isMain: false, maxConcurrent: 12 },
{ id: 'zero', name: 'Zero', isMain: false },
{ id: 'large', name: 'Large', isMain: false },
{ id: 'null', name: 'Null', isMain: false }
]);
});
it('synchronizes normalized queue limits before startup resume can run', async () => {
vi.mocked(ipc.invokeCommand).mockImplementation(async (cmd: string) => {
if (cmd === 'db_get_all_queues') {
return [
JSON.stringify({ id: 'main', name: 'Main', isMain: true, maxConcurrent: 4 }),
JSON.stringify({ id: 'queue-a', name: 'Queue A', isMain: false, maxConcurrent: 0 })
];
}
if (cmd === 'db_get_all_downloads') return [];
return undefined;
});
await useDownloadStore.getState().initDB();
expect(vi.mocked(ipc.invokeCommand)).toHaveBeenCalledWith(
'set_queue_concurrency_limits',
{
limits: [
{ id: '00000000-0000-0000-0000-000000000001', maxConcurrent: 4 },
{ id: 'queue-a', maxConcurrent: null }
]
}
);
});
it('remaps persisted downloads when queue records are malformed or missing', async () => {
vi.mocked(ipc.invokeCommand).mockImplementation(async (cmd: string) => {
if (cmd === 'db_get_all_queues') {
@@ -731,6 +932,10 @@ describe('useDownloadStore', () => {
const calls = vi.mocked(ipc.invokeCommand).mock.calls;
expect(calls.some(c => c[0] === 'resume_download')).toBe(true);
expect(calls.some(c => c[0] === 'enqueue_download')).toBe(true);
expect(calls.find(c => c[0] === 'resume_download')?.[1]).toEqual({
id: 'resume-generation',
queueId: 'MAIN'
});
expect(enqueueGeneration).toBe('1');
expect(useDownloadStore.getState().downloads[0].lastTry).toEqual(expect.any(String));
expect(useDownloadStore.getState().backendRegisteredIds.has('resume-generation')).toBe(true); // Re-registered by dispatchItem
@@ -1575,6 +1780,102 @@ describe('useDownloadStore', () => {
expect(new Set(positions).size).toBe(4);
});
it('rolls back and rejects a failed keyboard or header queue move', async () => {
useDownloadStore.setState({
downloads: [
{ id: 'first', status: 'queued', queueId: 'move-failure-queue', queuePosition: 0 },
{ id: 'second', status: 'queued', queueId: 'move-failure-queue', queuePosition: 1 }
] as any[],
backendRegisteredIds: new Set(['second'])
});
vi.mocked(ipc.invokeCommand).mockRejectedValue(new Error('backend unavailable'));
await expect(
useDownloadStore.getState().moveInQueue('second', 'up')
).rejects.toThrow('backend unavailable');
expect(useDownloadStore.getState().downloads.map(item => item.queuePosition)).toEqual([0, 1]);
});
it('translates a drag target around staged rows before the atomic backend move', async () => {
useDownloadStore.setState({
downloads: [
{ id: 'a', status: 'queued', queueId: 'drag-queue', queuePosition: 0 },
{ id: 'staged', status: 'staged', queueId: 'drag-queue', queuePosition: 1 },
{ id: 'b', status: 'queued', queueId: 'drag-queue', queuePosition: 2 },
{ id: 'c', status: 'queued', queueId: 'drag-queue', queuePosition: 3 }
] as any[],
backendRegisteredIds: new Set(['a', 'b', 'c']),
pendingOrder: ['a', 'b', 'c']
});
vi.mocked(ipc.invokeCommand).mockImplementation(async (command: string) => {
if (command === 'move_many_in_queue') return ['c', 'a', 'b'];
if (command === 'get_pending_order') return ['c', 'a', 'b'];
return undefined;
});
await useDownloadStore.getState().moveManyInQueueToPosition('c', 'drag-queue', 0);
expect(vi.mocked(ipc.invokeCommand)).toHaveBeenCalledWith('move_many_in_queue', {
ids: ['c'],
queueId: 'drag-queue',
direction: 'up',
targetIndex: 0
});
expect(useDownloadStore.getState().downloads.map(item => item.id).sort()).toEqual(['a', 'b', 'c', 'staged']);
expect(useDownloadStore.getState().downloads.find(item => item.id === 'c')?.queuePosition).toBe(0);
expect(useDownloadStore.getState().downloads.find(item => item.id === 'staged')?.queuePosition).toBe(2);
});
it('keeps a drag anchored when the queue changes before its serialized operation runs', async () => {
useDownloadStore.setState({
downloads: [
{ id: 'a', status: 'queued', queueId: 'concurrent-drag-queue', queuePosition: 0 },
{ id: 'b', status: 'queued', queueId: 'concurrent-drag-queue', queuePosition: 1 },
{ id: 'c', status: 'queued', queueId: 'concurrent-drag-queue', queuePosition: 2 }
] as any[],
backendRegisteredIds: new Set()
});
const operation = useDownloadStore.getState().moveManyInQueueToPosition(
'b',
'concurrent-drag-queue',
2,
'c'
);
useDownloadStore.setState({
downloads: [
{ id: 'a', status: 'queued', queueId: 'concurrent-drag-queue', queuePosition: 0 },
{ id: 'b', status: 'queued', queueId: 'concurrent-drag-queue', queuePosition: 1 },
{ id: 'new', status: 'queued', queueId: 'concurrent-drag-queue', queuePosition: 2 },
{ id: 'c', status: 'queued', queueId: 'concurrent-drag-queue', queuePosition: 3 }
] as any[]
});
await operation;
expect(useDownloadStore.getState().downloads
.filter(item => item.queueId === 'concurrent-drag-queue')
.sort((left, right) => (left.queuePosition ?? 0) - (right.queuePosition ?? 0))
.map(item => item.id)
).toEqual(['a', 'new', 'b', 'c']);
});
it('rolls back a failed drag move and rejects so the UI can report the failure', async () => {
useDownloadStore.setState({
downloads: [
{ id: 'first', status: 'queued', queueId: 'rollback-queue', queuePosition: 0 },
{ id: 'second', status: 'queued', queueId: 'rollback-queue', queuePosition: 1 }
] as any[],
backendRegisteredIds: new Set(['first', 'second'])
});
vi.mocked(ipc.invokeCommand).mockRejectedValue(new Error('backend unavailable'));
await expect(
useDownloadStore.getState().moveManyInQueueToPosition('second', 'rollback-queue', 0)
).rejects.toThrow('backend unavailable');
expect(useDownloadStore.getState().downloads.map(item => item.queuePosition)).toEqual([0, 1]);
});
it('detaches a registered queued item through the backend before reassigning it', async () => {
useDownloadStore.setState({
downloads: [{
+230 -7
View File
@@ -15,6 +15,10 @@ import {
} from '../utils/downloadLocations';
import { canPauseDownload, canStartDownload } from '../utils/downloadActions';
import { updateDockBadge } from '../utils/dockBadge';
import {
moveSelectedBlockToIndex,
targetIndexForDesiredOrder
} from '../utils/queueOrdering';
import i18n from '../i18n';
export type { DownloadCategory } from '../utils/downloads';
@@ -24,6 +28,7 @@ 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>();
let queueConfigurationQueue: Promise<void> = Promise.resolve();
type DownloadLifecycleOperation = {
kind: string;
promise: Promise<unknown>;
@@ -581,18 +586,39 @@ export const normalizePersistedDownloadProgress = (download: DownloadItem): Down
export type { DownloadStatus };
export const MAIN_QUEUE_ID = '00000000-0000-0000-0000-000000000001';
const DEFAULT_MAIN_QUEUE_NAME = 'Main Queue';
const MAX_QUEUE_CONCURRENT = 12;
const queueNameKey = (name: string): string => name.trim().toLowerCase();
export const normalizePersistedQueueState = (queues: Queue[]) => {
const normalizeQueueConcurrency = (value: unknown): number | undefined => {
if (typeof value !== 'number' || !Number.isInteger(value)) return undefined;
return value >= 1 && value <= MAX_QUEUE_CONCURRENT ? value : undefined;
};
type PersistedQueue = Omit<Queue, 'maxConcurrent'> & {
maxConcurrent?: number | null;
};
const queueWithNormalizedConcurrency = (queue: PersistedQueue): Queue => {
const maxConcurrent = normalizeQueueConcurrency(queue.maxConcurrent);
return maxConcurrent === undefined
? { id: queue.id, name: queue.name, isMain: queue.isMain }
: { id: queue.id, name: queue.name, isMain: queue.isMain, maxConcurrent };
};
export const normalizePersistedQueueState = (queues: PersistedQueue[]) => {
const validQueues = queues.filter(queue =>
queue && typeof queue.id === 'string' && typeof queue.name === 'string'
);
).map(queueWithNormalizedConcurrency);
const persistedMain = validQueues.find(queue => queue.id === MAIN_QUEUE_ID)
|| validQueues.find(queue => queue.isMain);
const persistedMainId = persistedMain?.id.trim();
const mainName = persistedMain?.name.trim() || DEFAULT_MAIN_QUEUE_NAME;
const normalized: Queue[] = [{ id: MAIN_QUEUE_ID, name: mainName, isMain: true }];
const normalizedMain: Queue = { id: MAIN_QUEUE_ID, name: mainName, isMain: true };
if (persistedMain?.maxConcurrent !== undefined) {
normalizedMain.maxConcurrent = persistedMain.maxConcurrent;
}
const normalized: Queue[] = [normalizedMain];
const seenIds = new Set([MAIN_QUEUE_ID]);
const seenNames = new Set([queueNameKey(mainName)]);
const queueIdRemap = new Map<string, string>();
@@ -616,15 +642,37 @@ export const normalizePersistedQueueState = (queues: Queue[]) => {
}
seenIds.add(id);
seenNames.add(queueNameKey(name));
normalized.push({ id, name, isMain: false });
const normalizedQueue = { id, name, isMain: false } as Queue;
if (queue.maxConcurrent !== undefined) {
const maxConcurrent = normalizeQueueConcurrency(queue.maxConcurrent);
if (maxConcurrent !== undefined) normalizedQueue.maxConcurrent = maxConcurrent;
}
normalized.push(normalizedQueue);
}
return { queues: normalized, queueIdRemap };
};
export const normalizePersistedQueues = (queues: Queue[]): Queue[] =>
export const normalizePersistedQueues = (queues: PersistedQueue[]): Queue[] =>
normalizePersistedQueueState(queues).queues;
const synchronizeQueueConcurrencyLimits = async (queues: Queue[]): Promise<void> => {
await invoke('set_queue_concurrency_limits', {
limits: queues.map(queue => ({
id: queue.id,
maxConcurrent: queue.maxConcurrent ?? null
}))
});
};
const sameQueueConcurrencyConfig = (left: Queue[], right: Queue[]): boolean =>
left.length === right.length && left.every((queue, index) => {
const other = right[index];
return other !== undefined
&& queue.id === other.id
&& (queue.maxConcurrent ?? null) === (other.maxConcurrent ?? null);
});
export type { DownloadItem, Queue };
export type ExtensionDownloadRequest = ExtensionDownload;
export type AddDownloadAction =
@@ -659,6 +707,12 @@ interface DownloadState {
unregisterBackendIds: (ids: string[]) => void;
applyProperties: (id: string, updates: Partial<DownloadItem>) => Promise<void>;
moveInQueue: (ids: string | string[], direction: 'up' | 'down') => Promise<void>;
moveManyInQueueToPosition: (
ids: string | string[],
queueId: string,
targetIndex: number,
beforeId?: string | null
) => Promise<void>;
removeFromQueue: (id: string) => Promise<void>;
isAddModalOpen: boolean;
pendingAddUrls: string;
@@ -701,6 +755,8 @@ interface DownloadState {
startAll: () => Promise<number>;
pauseAll: () => Promise<number>;
assignToQueue: (ids: string[], queueId: string) => Promise<void>;
setDownloadSpeedLimit: (id: string, limit: string | null) => Promise<void>;
setQueueConcurrency: (id: string, maxConcurrent: number | null) => Promise<void>;
addQueue: (name: string) => boolean;
renameQueue: (id: string, name: string) => boolean;
removeQueue: (id: string) => Promise<void>;
@@ -789,7 +845,10 @@ export const useDownloadStore = create<DownloadState>((set, get) => {
lastTry: new Date().toISOString()
});
const resumedExisting = await invoke('resume_download', { id });
const resumedExisting = await invoke('resume_download', {
id,
queueId: targetItem.queueId || MAIN_QUEUE_ID
});
let dispatchSucceeded = resumedExisting;
if (!dispatchSucceeded) {
@@ -894,6 +953,92 @@ export const useDownloadStore = create<DownloadState>((set, get) => {
? { ...download, queuePosition: previousPositions.get(download.id) }
: download)
}));
throw error;
}
});
const trackedOperation = operation.finally(() => {
if (queueReorderPromises.get(queueId) === trackedOperation) {
queueReorderPromises.delete(queueId);
}
});
queueReorderPromises.set(queueId, trackedOperation);
return trackedOperation;
},
moveManyInQueueToPosition: (idOrIds, queueId, targetIndex, beforeId) => {
const ids = Array.isArray(idOrIds) ? idOrIds : [idOrIds];
if (ids.length === 0) return Promise.resolve();
// Dragging must be one serialized, atomic queue operation. This prevents
// a second drag or a keyboard move from calculating against stale order
// while the backend is still applying the first drop.
const previousOperation = queueReorderPromises.get(queueId) ?? Promise.resolve();
const operation = previousOperation.catch(() => undefined).then(async () => {
const allDownloads = get().downloads;
const queueItems = queueItemsForReordering(allDownloads, queueId);
const selectedItems = queueItems.filter(item => ids.includes(item.id));
if (selectedItems.length === 0) return;
const selectedIds = new Set(selectedItems.map(item => item.id));
const previousPositions = new Map([
...activeQueueItems(allDownloads, queueId),
...queueItems
].map(item => [item.id, item.queuePosition]));
const unselectedItems = queueItems.filter(item => !selectedIds.has(item.id));
const anchoredTargetIndex = beforeId
? unselectedItems.findIndex(item => item.id === beforeId)
: -1;
const resolvedTargetIndex = anchoredTargetIndex >= 0
? anchoredTargetIndex
: Math.max(0, Math.min(targetIndex, unselectedItems.length));
const reordered = moveSelectedBlockToIndex(queueItems, selectedIds, resolvedTargetIndex);
set(state => ({ downloads: applyQueueOrder(state.downloads, queueId, reordered) }));
const registeredIdsToMove = selectedItems
.filter(item => get().backendRegisteredIds.has(item.id))
.map(item => item.id);
if (registeredIdsToMove.length === 0) return;
// Staged rows are deliberately not registered with the backend. Convert
// the desired local order to a registered-only target before IPC so a
// staged row never shifts the backend insertion index.
const registeredItems = queueItems.filter(item => get().backendRegisteredIds.has(item.id));
const registeredSelectedIds = new Set(registeredIdsToMove);
const registeredDesiredOrder = reordered.filter(item => get().backendRegisteredIds.has(item.id));
const backendTargetIndex = targetIndexForDesiredOrder(
registeredItems,
registeredSelectedIds,
registeredDesiredOrder
);
try {
const order = await invoke('move_many_in_queue', {
ids: registeredIdsToMove,
queueId,
direction: 'up',
targetIndex: backendTargetIndex
}) as string[];
if (Array.isArray(order)) {
const globalOrder = await invoke('get_pending_order', { queueId: null })
.catch(() => null) as string[] | null;
set(state => ({
pendingOrder: Array.isArray(globalOrder)
? globalOrder
: [
...state.pendingOrder.filter(id => !order.includes(id)),
...order
]
}));
}
} catch (error) {
console.error("Failed to move queue block to position:", error);
// The backend operation is atomic. Restore only queue positions so a
// progress/state event received while the RPC was in flight survives.
set(state => ({
downloads: state.downloads.map(download => previousPositions.has(download.id)
? { ...download, queuePosition: previousPositions.get(download.id) }
: download)
}));
throw error;
}
});
const trackedOperation = operation.finally(() => {
@@ -1468,6 +1613,79 @@ export const useDownloadStore = create<DownloadState>((set, get) => {
}));
});
},
setDownloadSpeedLimit: (id, limit) => runDownloadLifecycleOperation(
id,
'speed-limit',
async () => {
await waitForPendingStartupResume();
const item = get().downloads.find(download => download.id === id);
if (!item) throw new Error('Download no longer exists.');
if (item.isMedia) {
throw new Error('Live speed control is unavailable for media downloads.');
}
if (!['downloading', 'retrying'].includes(item.status)) {
throw new Error('Live speed control requires an active download.');
}
const trimmed = limit?.trim() || '';
const normalizedLimit = trimmed
? normalizeSpeedLimitForBackend(trimmed)
: null;
if (trimmed && normalizedLimit === null) {
throw new Error('Enter a valid speed limit.');
}
await invoke('set_download_speed_limit', {
id,
limit: normalizedLimit
});
if (get().downloads.some(download => download.id === id)) {
get().updateDownload(id, { speedLimit: normalizedLimit ?? undefined });
}
},
true,
preemptDispatch
),
setQueueConcurrency: (id, maxConcurrent) => {
const operation = queueConfigurationQueue.then(async () => {
if (
maxConcurrent !== null
&& (!Number.isInteger(maxConcurrent) || maxConcurrent < 1 || maxConcurrent > MAX_QUEUE_CONCURRENT)
) {
throw new Error('Queue concurrency must be between 1 and 12.');
}
const currentQueues = get().queues;
if (!currentQueues.some(queue => queue.id === id)) {
throw new Error('Queue no longer exists.');
}
const nextQueues = currentQueues.map(queue =>
queue.id === id
? maxConcurrent === null
? { id: queue.id, name: queue.name, isMain: queue.isMain }
: { ...queue, maxConcurrent }
: queue
);
await synchronizeQueueConcurrencyLimits(nextQueues);
const latestQueues = get().queues;
if (!latestQueues.some(queue => queue.id === id)) {
await synchronizeQueueConcurrencyLimits(latestQueues);
throw new Error('Queue no longer exists.');
}
const rebasedQueues = latestQueues.map(queue =>
queue.id === id
? maxConcurrent === null
? { id: queue.id, name: queue.name, isMain: queue.isMain }
: { ...queue, maxConcurrent }
: queue
);
if (!sameQueueConcurrencyConfig(nextQueues, rebasedQueues)) {
await synchronizeQueueConcurrencyLimits(rebasedQueues);
}
set({ queues: rebasedQueues });
});
queueConfigurationQueue = operation.then(() => undefined, () => undefined);
return operation;
},
addQueue: (name) => {
const normalizedName = name.trim();
if (!normalizedName) return false;
@@ -1689,7 +1907,7 @@ export const useDownloadStore = create<DownloadState>((set, get) => {
try {
const persistedQueues = (await invoke('db_get_all_queues')).flatMap(value => {
try {
return [JSON.parse(value) as Queue];
return [JSON.parse(value) as PersistedQueue];
} catch {
console.warn('Skipping malformed persisted queue record during startup');
return [];
@@ -1713,6 +1931,11 @@ export const useDownloadStore = create<DownloadState>((set, get) => {
? normalizeQueuePositions(downloads)
: state.downloads
}));
// The backend dispatcher is live before the frontend finishes startup.
// Synchronize the normalized queue policy before any saved download is
// allowed to claim a permit.
await synchronizeQueueConcurrencyLimits(queues);
// Reset interrupted active downloads to queued.
set((state) => ({
+18
View File
@@ -21,6 +21,24 @@ describe('last used download directory preference', () => {
});
});
describe('calendar preference', () => {
it('keeps Gregorian as the default and persists explicit calendar choices', async () => {
vi.clearAllMocks();
useSettingsStore.setState({ calendarPreference: 'gregorian' });
expect(useSettingsStore.getState().calendarPreference).toBe('gregorian');
useSettingsStore.getState().setCalendarPreference('persian');
await new Promise(resolve => setTimeout(resolve, 0));
expect(useSettingsStore.getState().calendarPreference).toBe('persian');
const save = vi.mocked(ipc.invokeCommand).mock.calls
.filter(([command]) => command === 'db_save_settings')
.slice(-1)[0];
expect(save).toBeDefined();
expect(JSON.parse((save?.[1] as { data: string }).data).state.calendarPreference).toBe('persian');
});
});
describe('useSettingsStore global speed limit persistence', () => {
beforeEach(() => {
vi.clearAllMocks();
+69 -2
View File
@@ -4,6 +4,7 @@ import { invokeCommand as invoke } from '../ipc';
import { info } from '../utils/logger';
import type { ActiveView } from '../bindings/ActiveView';
import type { AppFontSize } from '../bindings/AppFontSize';
import type { FontFamily } from '../bindings/FontFamily';
import type { ListRowDensity } from '../bindings/ListRowDensity';
import type { MediaCookieSource } from '../bindings/MediaCookieSource';
import type { PostQueueAction } from '../bindings/PostQueueAction';
@@ -14,6 +15,7 @@ import type { SchedulerSettings } from '../bindings/SchedulerSettings';
import type { SettingsTab } from '../bindings/SettingsTab';
import type { SiteLogin } from '../bindings/SiteLogin';
import type { Theme } from '../bindings/Theme';
import type { WindowControlStyle } from '../bindings/WindowControlStyle';
import {
DEFAULT_CATEGORY_SUBFOLDERS,
normalizeDownloadLocationSettings
@@ -21,6 +23,11 @@ import {
import { normalizeSpeedLimitForBackend } from '../utils/downloads';
import i18n from '../i18n';
import { isAppLocalePreference, type AppLocalePreference } from '../i18n/locales';
import {
DEFAULT_CALENDAR_PREFERENCE,
isCalendarPreference,
type CalendarPreference
} from '../utils/dateTime';
let settingsQueue: Promise<void> = Promise.resolve();
let pairingTokenHydrationRequest: Promise<PairingTokenHydration> | null = null;
@@ -68,6 +75,18 @@ const notifySettingsPersistenceError = () => {
};
const THEME_VALUES = ['system', 'light', 'dark', 'dracula', 'nord'] as const;
const FONT_FAMILY_VALUES = [
'system',
'inter',
'outfit',
'vazirmatn',
'noto-sans-hebrew',
'noto-sans-sc',
'roboto',
'serif',
'monospace',
] as const;
const WINDOW_CONTROL_STYLE_VALUES = ['auto', 'macos', 'windows', 'gnome', 'minimal'] as const;
const APP_FONT_SIZE_VALUES = ['small', 'standard', 'large'] as const;
const LIST_ROW_DENSITY_VALUES = ['compact', 'standard', 'relaxed'] as const;
const SIDEBAR_POSITION_VALUES = ['auto', 'left', 'right'] as const;
@@ -155,6 +174,8 @@ const tauriStorage: StateStorage = {
export type {
ActiveView,
AppFontSize,
FontFamily,
CalendarPreference,
ListRowDensity,
MediaCookieSource,
PostQueueAction,
@@ -162,13 +183,17 @@ export type {
SchedulerSettings,
SettingsTab,
SiteLogin,
Theme
Theme,
WindowControlStyle
};
export type SidebarPosition = 'auto' | 'left' | 'right';
export interface SettingsState {
theme: Theme;
fontFamily: FontFamily;
windowControlStyle: WindowControlStyle;
calendarPreference: CalendarPreference;
language: AppLocalePreference;
baseDownloadFolder: string;
categorySubfoldersEnabled: boolean;
@@ -212,6 +237,7 @@ export interface SettingsState {
customUserAgent: string;
askWhereToSaveEachFile: boolean;
preventsSleepWhileDownloading: boolean;
preventsDisplaySleepWhileDownloading: boolean;
mediaCookieSource: MediaCookieSource;
siteLogins: SiteLogin[];
extensionPairingToken: string;
@@ -224,6 +250,9 @@ export interface SettingsState {
showKeychainModal: boolean;
setTheme: (theme: Theme) => void;
setFontFamily: (fontFamily: FontFamily) => void;
setWindowControlStyle: (style: WindowControlStyle) => void;
setCalendarPreference: (calendarPreference: CalendarPreference) => void;
setLanguage: (language: AppLocalePreference) => void;
setBaseDownloadFolder: (path: string) => void;
approveDownloadRoot: (path: string) => Promise<string>;
@@ -258,6 +287,7 @@ export interface SettingsState {
setCustomUserAgent: (userAgent: string) => void;
setAskWhereToSaveEachFile: (ask: boolean) => void;
setPreventsSleepWhileDownloading: (prevent: boolean) => void;
setPreventsDisplaySleepWhileDownloading: (prevent: boolean) => void;
setMediaCookieSource: (source: MediaCookieSource) => void;
setRememberLastUsedDownloadDirectory: (enabled: boolean) => void;
setLastUsedDownloadDirectory: (path: string) => void;
@@ -280,6 +310,9 @@ export const useSettingsStore = create<SettingsState>()(
persist(
(set, get) => ({
theme: 'system',
fontFamily: 'system',
windowControlStyle: 'auto',
calendarPreference: DEFAULT_CALENDAR_PREFERENCE,
language: 'system',
baseDownloadFolder: '~/Downloads',
categorySubfoldersEnabled: true,
@@ -330,6 +363,7 @@ export const useSettingsStore = create<SettingsState>()(
customUserAgent: '',
askWhereToSaveEachFile: false,
preventsSleepWhileDownloading: true,
preventsDisplaySleepWhileDownloading: false,
mediaCookieSource: 'none',
siteLogins: [],
extensionPairingToken: '',
@@ -342,6 +376,18 @@ export const useSettingsStore = create<SettingsState>()(
showKeychainModal: false,
setTheme: (theme) => { info('Settings updated: theme'); set({ theme }); },
setFontFamily: (fontFamily) => {
info('Settings updated: fontFamily');
set({ fontFamily });
},
setWindowControlStyle: (windowControlStyle) => {
info('Settings updated: windowControlStyle');
set({ windowControlStyle });
},
setCalendarPreference: (calendarPreference) => {
info('Settings updated: calendarPreference');
set({ calendarPreference });
},
setLanguage: (language) => { info('Settings updated: language'); set({ language }); },
setBaseDownloadFolder: (path) => {
info('Settings updated: baseDownloadFolder');
@@ -414,6 +460,10 @@ export const useSettingsStore = create<SettingsState>()(
info('Settings updated: preventsSleepWhileDownloading');
set({ preventsSleepWhileDownloading });
},
setPreventsDisplaySleepWhileDownloading: (preventsDisplaySleepWhileDownloading) => {
info('Settings updated: preventsDisplaySleepWhileDownloading');
set({ preventsDisplaySleepWhileDownloading });
},
setMediaCookieSource: (mediaCookieSource) => { info('Settings updated: mediaCookieSource'); set({ mediaCookieSource }); },
setRememberLastUsedDownloadDirectory: (rememberLastUsedDownloadDirectory) => {
info('Settings updated: rememberLastUsedDownloadDirectory');
@@ -512,7 +562,7 @@ export const useSettingsStore = create<SettingsState>()(
{
name: 'firelink-settings',
storage: createJSONStorage(() => tauriStorage),
version: 5,
version: 6,
migrate: (persistedState) => {
if (!persistedState || typeof persistedState !== 'object') {
return persistedState as SettingsState;
@@ -548,6 +598,9 @@ export const useSettingsStore = create<SettingsState>()(
},
partialize: (state): PersistedSettingsSnapshot => ({
theme: state.theme,
fontFamily: state.fontFamily,
windowControlStyle: state.windowControlStyle,
calendarPreference: state.calendarPreference,
language: state.language,
baseDownloadFolder: state.baseDownloadFolder,
categorySubfoldersEnabled: state.categorySubfoldersEnabled,
@@ -585,6 +638,7 @@ export const useSettingsStore = create<SettingsState>()(
customUserAgent: state.customUserAgent,
askWhereToSaveEachFile: state.askWhereToSaveEachFile,
preventsSleepWhileDownloading: state.preventsSleepWhileDownloading,
preventsDisplaySleepWhileDownloading: state.preventsDisplaySleepWhileDownloading,
mediaCookieSource: state.mediaCookieSource,
siteLogins: state.siteLogins,
keychainAccessGranted: state.keychainAccessGranted,
@@ -608,6 +662,15 @@ export const useSettingsStore = create<SettingsState>()(
theme: isAllowedSetting(THEME_VALUES, persisted.theme)
? persisted.theme
: currentState.theme,
fontFamily: isAllowedSetting(FONT_FAMILY_VALUES, persisted.fontFamily)
? persisted.fontFamily
: currentState.fontFamily,
windowControlStyle: isAllowedSetting(WINDOW_CONTROL_STYLE_VALUES, persisted.windowControlStyle)
? persisted.windowControlStyle
: currentState.windowControlStyle,
calendarPreference: isCalendarPreference(persisted.calendarPreference)
? persisted.calendarPreference
: currentState.calendarPreference,
language: isAppLocalePreference(persisted.language)
? persisted.language
: currentState.language,
@@ -649,6 +712,10 @@ export const useSettingsStore = create<SettingsState>()(
persisted.preventsSleepWhileDownloading,
currentState.preventsSleepWhileDownloading
),
preventsDisplaySleepWhileDownloading: persistedBoolean(
persisted.preventsDisplaySleepWhileDownloading,
currentState.preventsDisplaySleepWhileDownloading
),
keychainAccessGranted: persistedBoolean(
persisted.keychainAccessGranted,
currentState.keychainAccessGranted
+203
View File
@@ -1,14 +1,21 @@
import { describe, expect, it } from 'vitest';
import {
appendRequestUrlsAfterVersion,
commonMediaFormatsForRows,
canSubmitMetadataRows,
commonMediaQualitiesForRows,
mediaFormatSelectorForRow,
mediaFileNameForSelectedFormat,
mediaFormatForFormat,
mediaQualityForRow,
mediaTypeForFormat,
metadataSummaryMessage,
isYouTubePlaylistUrl,
playlistFilePrefix,
reconcileDownloadRows,
refreshFailedMetadataRows,
selectExactMediaSelection,
selectExactMediaQuality,
updateRowIfCurrent,
type AddDownloadDraftRow
} from './addDownloadMetadata';
@@ -539,6 +546,202 @@ describe('add download metadata workflow', () => {
])).toContain('Correct or remove 1 invalid URL');
});
it('offers only qualities shared by every selected ready media row', () => {
const formats = (qualities: string[]) => qualities.map((quality, index) => ({
name: `${quality} MP4`,
quality,
selector: `${quality}-${index}`,
ext: 'mp4',
formatLabel: quality,
detail: '10 MB',
type: 'Video',
bytes: 10
}));
const rows = [
row({ id: 'one', isMedia: true, formats: formats(['1080p', '720p']), selectedFormat: 0 }),
row({ id: 'two', isMedia: true, formats: formats(['720p', '480p']), selectedFormat: 0 })
];
expect(commonMediaQualitiesForRows(rows)).toEqual(['720p']);
expect(mediaQualityForRow(rows[0])).toBe('1080p');
});
it('keeps playlist choices available when one ready row remains selected', () => {
const formats = [
{
name: '720p MP4',
quality: '720p',
selector: '720',
ext: 'mp4',
formatLabel: 'MP4 • H.264',
detail: '10 MB',
type: 'Video',
bytes: 10
},
{
name: 'Audio only M4A',
quality: 'Audio only',
selector: 'audio',
ext: 'm4a',
formatLabel: 'M4A • AAC',
detail: '4 MB',
type: 'Audio',
bytes: 4
}
];
const selectedRow = row({ isMedia: true, formats, selectedFormat: 0 });
expect(commonMediaFormatsForRows([selectedRow], 'Video')).toEqual(['MP4']);
expect(commonMediaQualitiesForRows([selectedRow], 'Video', 'MP4')).toEqual(['720p']);
expect(commonMediaFormatsForRows([selectedRow], 'Audio')).toEqual(['M4A']);
expect(commonMediaQualitiesForRows([selectedRow], 'Audio', 'M4A')).toEqual(['Audio only']);
});
it('does not offer a shared format when its qualities do not overlap', () => {
const format = (quality: string, id: string) => [{
name: `${quality} MP4`,
quality,
selector: id,
ext: 'mp4',
formatLabel: 'MP4 • H.264',
detail: '10 MB',
type: 'Video',
bytes: 10
}];
const rows = [
row({ id: 'one', isMedia: true, formats: format('1080p', 'one'), selectedFormat: 0 }),
row({ id: 'two', isMedia: true, formats: format('720p', 'two'), selectedFormat: 0 })
];
expect(commonMediaFormatsForRows(rows, 'Video')).toEqual([]);
});
it('filters playlist choices by media type and format', () => {
const formats = (qualities: string[], idPrefix: string) => [
...qualities.flatMap((quality, index) => [
{
name: `${quality} MKV`,
quality,
selector: `${idPrefix}-mkv-${index}`,
ext: 'mkv',
formatLabel: 'MKV • H.264',
detail: '10 MB',
type: 'Video',
bytes: 10
},
{
name: `${quality} MP4`,
quality,
selector: `${idPrefix}-mp4-${index}`,
ext: 'mp4',
formatLabel: 'MP4 • H.264',
detail: '10 MB',
type: 'Video',
bytes: 10
}
]),
{
name: 'Audio only M4A',
quality: 'Audio only',
selector: `${idPrefix}-m4a`,
ext: 'm4a',
formatLabel: 'M4A • AAC',
detail: '4 MB',
type: 'Audio',
bytes: 4
},
{
name: 'Audio only WEBM',
quality: 'Audio only',
selector: `${idPrefix}-webm`,
ext: 'webm',
formatLabel: 'WEBM • Opus',
detail: '4 MB',
type: 'Audio',
bytes: 4
}
];
const rows = [
row({ id: 'one', isMedia: true, formats: formats(['1080p', '720p'], 'one'), selectedFormat: 0 }),
row({ id: 'two', isMedia: true, formats: formats(['720p', '480p'], 'two'), selectedFormat: 0 })
];
expect(commonMediaFormatsForRows(rows, 'Video')).toEqual(['MKV', 'MP4']);
expect(commonMediaQualitiesForRows(rows, 'Video', 'MP4')).toEqual(['720p']);
expect(commonMediaFormatsForRows(rows, 'Audio')).toEqual(['M4A', 'WEBM']);
expect(commonMediaQualitiesForRows(rows, 'Audio', 'M4A')).toEqual(['Audio only']);
expect(mediaTypeForFormat(rows[0].formats![4])).toBe('Audio');
expect(mediaFormatForFormat(rows[0].formats![4])).toBe('M4A');
});
it('applies an exact playlist media selection without falling back to another format', () => {
const formats = (id: string) => [
{
name: '720p MKV',
quality: '720p',
selector: `${id}-mkv`,
ext: 'mkv',
formatLabel: 'MKV • H.264',
detail: '10 MB',
type: 'Video',
bytes: 10
},
{
name: '720p MP4',
quality: '720p',
selector: `${id}-mp4`,
ext: 'mp4',
formatLabel: 'MP4 • H.264',
detail: '9 MB',
type: 'Video',
bytes: 9
}
];
const rows = [
row({ id: 'one', isMedia: true, file: 'one.mkv', formats: formats('one'), selectedFormat: 0 }),
row({ id: 'two', isMedia: true, file: 'two.mkv', formats: formats('two'), selectedFormat: 0 })
];
const selected = selectExactMediaSelection(rows, ['one', 'two'], {
mediaType: 'Video',
format: 'MP4',
quality: '720p'
});
expect(selected[0]).toMatchObject({ selectedFormat: 1, file: 'one.mp4' });
expect(selected[1]).toMatchObject({ selectedFormat: 1, file: 'two.mp4' });
expect(selectExactMediaSelection(rows, ['one', 'two'], {
mediaType: 'Video',
format: 'WEBM',
quality: '720p'
})).toEqual(rows);
});
it('applies an exact bulk quality without falling back to a higher or lower stream', () => {
const mediaRow = row({
id: 'media',
file: 'clip.mp4',
isMedia: true,
formats: [{
name: '1080p MP4',
quality: '1080p',
selector: '1080',
ext: 'mp4',
formatLabel: '1080p',
detail: '10 MB',
type: 'Video',
bytes: 10
}],
selectedFormat: 0
});
const unchanged = selectExactMediaQuality([mediaRow], ['media'], '720p');
expect(unchanged[0]).toBe(mediaRow);
expect(selectExactMediaQuality([mediaRow], ['media'], '1080p')[0]).toMatchObject({
selectedFormat: 0,
file: 'clip.mp4'
});
});
it('uses few forms for Russian and Ukrainian metadata summaries', async () => {
const originalLanguage = i18n.language;
const twoReadyRows = [row(), row({ id: 'row-2' })];
+140
View File
@@ -11,6 +11,7 @@ export type MetadataStatus = 'loading' | 'ready' | 'metadata-error' | 'invalid';
export interface AddMediaFormat {
name: string;
quality?: string;
selector: string;
ext: string;
formatLabel: string;
@@ -20,6 +21,14 @@ export interface AddMediaFormat {
isApproximate?: boolean;
}
export type MediaType = 'Audio' | 'Video';
export interface MediaSelection {
mediaType: MediaType;
format: string;
quality: string;
}
export interface AddDownloadDraftRow {
id: string;
sourceUrl: string;
@@ -331,6 +340,137 @@ export const mediaFormatSelectorForRow = (
return row.formats?.[row.selectedFormat]?.selector;
};
const normalizeMediaQualityLabel = (value: string | undefined): string => {
const normalized = value
?.replace(/[\u0000-\u001f\u007f]+/g, ' ')
.replace(/\s+/g, ' ')
.trim();
return normalized && normalized.length <= 48 ? normalized : '';
};
export const mediaQualityForFormat = (
format: Pick<AddMediaFormat, 'quality' | 'name' | 'type' | 'formatLabel' | 'ext'>
): string => {
const explicitQuality = normalizeMediaQualityLabel(format.quality);
if (explicitQuality) return explicitQuality;
const nameQuality = normalizeMediaQualityLabel(format.name.trim().split(/\s+/)[0]);
if (nameQuality) return nameQuality;
const label = normalizeMediaQualityLabel(format.formatLabel);
return label || normalizeMediaQualityLabel(format.type) || normalizeMediaQualityLabel(format.ext.toUpperCase()) || 'Media';
};
export const mediaTypeForFormat = (
format: Pick<AddMediaFormat, 'type'>
): MediaType => format.type.toLowerCase().includes('audio') ? 'Audio' : 'Video';
export const mediaFormatForFormat = (
format: Pick<AddMediaFormat, 'ext'>
): string => normalizeMediaQualityLabel(format.ext.replace(/^\.+/, '').toUpperCase()) || 'MEDIA';
export const mediaQualityForRow = (
row: Pick<AddDownloadDraftRow, 'isMedia' | 'status' | 'formats' | 'selectedFormat'>
): string | undefined => {
if (!row.isMedia || row.status !== 'ready' || row.selectedFormat === undefined) return undefined;
const format = row.formats?.[row.selectedFormat];
return format ? mediaQualityForFormat(format) : undefined;
};
export const commonMediaQualitiesForRows = (
rows: ReadonlyArray<Pick<AddDownloadDraftRow, 'isMedia' | 'status' | 'formats'>>,
mediaType?: MediaType,
mediaFormat?: string
): string[] => {
const readyMediaRows = rows.filter(row => row.isMedia && row.status === 'ready' && row.formats?.length);
if (readyMediaRows.length < 1) return [];
const matches = (format: AddMediaFormat) =>
(!mediaType || mediaTypeForFormat(format) === mediaType)
&& (!mediaFormat || mediaFormatForFormat(format) === mediaFormat);
const firstQualities = Array.from(new Set(
readyMediaRows[0].formats!.filter(matches).map(mediaQualityForFormat)
));
return firstQualities.filter(quality => readyMediaRows.every(row =>
row.formats!.some(format => matches(format) && mediaQualityForFormat(format) === quality)
));
};
export const commonMediaFormatsForRows = (
rows: ReadonlyArray<Pick<AddDownloadDraftRow, 'isMedia' | 'status' | 'formats'>>,
mediaType: MediaType
): string[] => {
const readyMediaRows = rows.filter(row => row.isMedia && row.status === 'ready' && row.formats?.length);
if (readyMediaRows.length < 1) return [];
const firstFormats = Array.from(new Set(
readyMediaRows[0].formats!
.filter(format => mediaTypeForFormat(format) === mediaType)
.map(mediaFormatForFormat)
));
return firstFormats.filter(mediaFormat =>
readyMediaRows.every(row =>
row.formats!.some(format =>
mediaTypeForFormat(format) === mediaType && mediaFormatForFormat(format) === mediaFormat
)
)
&& commonMediaQualitiesForRows(readyMediaRows, mediaType, mediaFormat).length > 0
);
};
export const selectExactMediaQuality = (
rows: AddDownloadDraftRow[],
selectedIds: ReadonlySet<string> | readonly string[],
quality: string
): AddDownloadDraftRow[] => {
const selected = selectedIds instanceof Set ? selectedIds : new Set(selectedIds);
return rows.map(row => {
if (!selected.has(row.id) || !row.isMedia || row.status !== 'ready' || !row.formats) return row;
const selectedFormat = row.formats.findIndex(format => mediaQualityForFormat(format) === quality);
if (selectedFormat === -1) return row;
const format = row.formats[selectedFormat];
return {
...row,
selectedFormat,
size: format.bytes ? format.detail : undefined,
sizeBytes: format.bytes || undefined,
file: mediaFileNameForSelectedFormat(row.file, {
formats: row.formats,
selectedFormat
})
};
});
};
export const selectExactMediaSelection = (
rows: AddDownloadDraftRow[],
selectedIds: ReadonlySet<string> | readonly string[],
selection: MediaSelection
): AddDownloadDraftRow[] => {
const selected = selectedIds instanceof Set ? selectedIds : new Set(selectedIds);
return rows.map(row => {
if (!selected.has(row.id) || !row.isMedia || row.status !== 'ready' || !row.formats) return row;
const selectedFormat = row.formats.findIndex(format =>
mediaTypeForFormat(format) === selection.mediaType
&& mediaFormatForFormat(format) === selection.format
&& mediaQualityForFormat(format) === selection.quality
);
if (selectedFormat === -1) return row;
const format = row.formats[selectedFormat];
return {
...row,
selectedFormat,
size: format.bytes ? format.detail : undefined,
sizeBytes: format.bytes || undefined,
file: mediaFileNameForSelectedFormat(row.file, {
formats: row.formats,
selectedFormat
})
};
});
};
export const mediaFileNameForSelectedFormat = (
fileName: string,
row: Pick<AddDownloadDraftRow, 'formats' | 'selectedFormat'>
+35
View File
@@ -0,0 +1,35 @@
import { describe, expect, it } from 'vitest';
import {
DEFAULT_CALENDAR_PREFERENCE,
formatDateTime,
isCalendarPreference
} from './dateTime';
describe('date/time formatting', () => {
const instant = new Date('2026-03-21T14:30:00.000Z');
it('uses Gregorian explicitly by default, including in localized UI languages', () => {
const options: Intl.DateTimeFormatOptions = { dateStyle: 'medium', timeStyle: 'short' };
expect(formatDateTime(instant, { locale: 'fa', options })).toBe(
new Intl.DateTimeFormat('fa-u-ca-gregory', options).format(instant)
);
expect(DEFAULT_CALENDAR_PREFERENCE).toBe('gregorian');
});
it('supports the opt-in Persian and Hebrew calendars', () => {
const options: Intl.DateTimeFormatOptions = { dateStyle: 'long' };
expect(formatDateTime(instant, { locale: 'fa', calendar: 'persian', options })).toBe(
new Intl.DateTimeFormat('fa-u-ca-persian', options).format(instant)
);
expect(formatDateTime(instant, { locale: 'he', calendar: 'hebrew', options })).toBe(
new Intl.DateTimeFormat('he-u-ca-hebrew', options).format(instant)
);
});
it('returns a safe placeholder for malformed timestamps and rejects unknown preferences', () => {
expect(formatDateTime('not-a-timestamp', { locale: 'en' })).toBe('-');
expect(isCalendarPreference('gregorian')).toBe(true);
expect(isCalendarPreference('lunar')).toBe(false);
expect(isCalendarPreference(null)).toBe(false);
});
});
+65
View File
@@ -0,0 +1,65 @@
import type { CalendarPreference } from '../bindings/CalendarPreference';
import { resolveAppLocale } from '../i18n/locales';
export type { CalendarPreference } from '../bindings/CalendarPreference';
export const CALENDAR_PREFERENCES = ['gregorian', 'persian', 'hebrew'] as const satisfies readonly CalendarPreference[];
export const DEFAULT_CALENDAR_PREFERENCE: CalendarPreference = 'gregorian';
export const isCalendarPreference = (value: unknown): value is CalendarPreference =>
typeof value === 'string' && (CALENDAR_PREFERENCES as readonly string[]).includes(value);
type DateTimeInput = Date | string | number;
interface DateTimeFormatConfig {
locale?: string | null;
calendar?: CalendarPreference;
options?: Intl.DateTimeFormatOptions;
}
const calendarIdentifier = (calendar: CalendarPreference): string =>
calendar === 'gregorian' ? 'gregory' : calendar;
const localeWithCalendar = (locale: string | null | undefined, calendar: CalendarPreference): string => {
const resolvedLocale = resolveAppLocale(locale);
return `${resolvedLocale}-u-ca-${calendarIdentifier(calendar)}`;
};
const dateFromInput = (value: DateTimeInput): Date =>
value instanceof Date ? new Date(value.getTime()) : new Date(value);
/**
* Format a user-facing timestamp with an explicit calendar. Gregorian is
* passed explicitly because some localized browser defaults use a regional
* calendar even when the user has not opted into one.
*/
export const formatDateTime = (
value: DateTimeInput,
config: DateTimeFormatConfig = {}
): string => {
const date = dateFromInput(value);
if (Number.isNaN(date.getTime())) return '-';
const calendar = config.calendar && isCalendarPreference(config.calendar)
? config.calendar
: DEFAULT_CALENDAR_PREFERENCE;
const options = config.options ?? {};
try {
return new Intl.DateTimeFormat(
localeWithCalendar(config.locale, calendar),
options
).format(date);
} catch {
// WebViews can have incomplete ICU calendar data. Keep the display useful
// and deterministic rather than exposing a RangeError to the UI.
try {
return new Intl.DateTimeFormat(
localeWithCalendar(config.locale, DEFAULT_CALENDAR_PREFERENCE),
options
).format(date);
} catch {
return '-';
}
}
};
+13
View File
@@ -3,6 +3,7 @@ import {
canPauseDownload,
canRedownload,
canStartDownload,
getPauseResumeAction,
isIdentityLocked,
isTransferLocked,
startActionLabel,
@@ -27,6 +28,18 @@ describe('download action policy', () => {
expect(canRedownload('downloading')).toBe(false);
});
it('only exposes pause or resume for the details-view toggle', () => {
expect(getPauseResumeAction('queued')).toBe('pause');
expect(getPauseResumeAction('downloading')).toBe('pause');
expect(getPauseResumeAction('processing')).toBe('pause');
expect(getPauseResumeAction('retrying')).toBe('pause');
expect(getPauseResumeAction('paused')).toBe('resume');
for (const status of ['ready', 'staged', 'completed', 'failed'] as const) {
expect(getPauseResumeAction(status)).toBeNull();
}
});
it('provides consistent labels and edit locks', () => {
expect(startActionLabel('ready')).toBe('Start');
expect(startActionLabel('failed')).toBe('Start');
+8
View File
@@ -26,6 +26,14 @@ export const canStartDownload = (status: DownloadStatus): boolean =>
export const canPauseDownload = (status: DownloadStatus): boolean =>
PAUSABLE_STATUSES.has(status);
export type PauseResumeAction = 'pause' | 'resume';
export const getPauseResumeAction = (status: DownloadStatus): PauseResumeAction | null => {
if (canPauseDownload(status)) return 'pause';
if (status === 'paused') return 'resume';
return null;
};
export const canRedownload = (status: DownloadStatus): boolean =>
REDOWNLOADABLE_STATUSES.has(status);
+180
View File
@@ -0,0 +1,180 @@
import { describe, expect, it } from 'vitest';
import type { DownloadItem } from '../bindings/DownloadItem';
import type { DownloadProgressEvent } from '../bindings/DownloadProgressEvent';
import { summarizeDownloads } from './downloadSummary';
const item = (id: string, overrides: Partial<DownloadItem> = {}): DownloadItem => ({
id,
url: `https://example.com/${id}`,
fileName: `${id}.bin`,
status: 'ready',
category: 'Other',
dateAdded: '2026-01-01T00:00:00.000Z',
...overrides,
});
const progress = (id: string, overrides: Partial<DownloadProgressEvent> = {}): DownloadProgressEvent => ({
id,
fraction: 0.5,
speed: '1 MiB/s',
eta: '1m',
size: null,
size_is_final: false,
...overrides,
});
describe('download summaries', () => {
it('aggregates exact bytes and transfer-active counts', () => {
expect(summarizeDownloads([
item('active', { status: 'downloading', downloadedBytes: 1024, totalBytes: 4096 }),
item('done', { status: 'completed', totalBytes: 2048 }),
])).toEqual({
itemCount: 2,
activeCount: 1,
downloadedBytes: 3072,
remainingBytes: 3072,
remainingIsEstimated: false,
});
});
it('treats completed downloads as having no remaining bytes despite stale progress', () => {
expect(summarizeDownloads([
item('done', {
status: 'completed',
totalBytes: 40 * 1024 ** 2,
downloadedBytes: 40 * 1024 ** 2,
}),
], {
done: progress('done', {
downloaded_bytes: 40 * 1024 ** 2 - 555 * 1024,
total_bytes: 555 * 1024,
}),
})).toEqual({
itemCount: 1,
activeCount: 0,
downloadedBytes: 40 * 1024 ** 2,
remainingBytes: 0,
remainingIsEstimated: false,
});
});
it('does not present partial byte totals as complete aggregates', () => {
expect(summarizeDownloads([
item('known', { totalBytes: 100, downloadedBytes: 20 }),
item('unknown', { status: 'failed' }),
])).toMatchObject({
itemCount: 2,
downloadedBytes: null,
remainingBytes: null,
});
});
it('infers zero bytes for an unstarted paused item but preserves partial unknowns', () => {
expect(summarizeDownloads([
item('paused', { status: 'paused', totalBytes: 1000 }),
])).toMatchObject({
downloadedBytes: 0,
remainingBytes: 1000,
remainingIsEstimated: false,
});
expect(summarizeDownloads([
item('partial', { status: 'paused', fraction: 0.4, totalBytes: 1000 }),
]).downloadedBytes).toBeNull();
});
it('treats a fresh media item as having no downloaded bytes before its first progress event', () => {
expect(summarizeDownloads([
item('media', {
status: 'ready',
isMedia: true,
totalBytes: 1024,
totalIsEstimate: true,
}),
])).toMatchObject({
downloadedBytes: 0,
remainingBytes: 1024,
remainingIsEstimated: true,
});
});
it('keeps non-final media progress on the stored estimated denominator', () => {
expect(summarizeDownloads([
item('media', {
status: 'downloading',
isMedia: true,
totalBytes: 2 * 1024 ** 2,
size: '~2 MB',
}),
], {
media: progress('media', {
downloaded_bytes: 512 * 1024,
total_bytes: 1024,
total_is_estimate: true,
}),
})).toEqual({
itemCount: 1,
activeCount: 1,
downloadedBytes: 512 * 1024,
remainingBytes: 2 * 1024 ** 2 - 512 * 1024,
remainingIsEstimated: true,
});
});
it('does not claim zero remaining when an estimate is already below observed bytes', () => {
expect(summarizeDownloads([
item('media', {
status: 'downloading',
isMedia: true,
downloadedBytes: 150,
totalBytes: 100,
totalIsEstimate: true,
}),
])).toMatchObject({
downloadedBytes: 150,
remainingBytes: null,
remainingIsEstimated: false,
});
});
it('uses a final media progress total when one is available', () => {
expect(summarizeDownloads([
item('media', {
status: 'processing',
isMedia: true,
totalBytes: 2 * 1024 ** 2,
totalIsEstimate: true,
}),
], {
media: progress('media', {
downloaded_bytes: 3 * 1024 ** 2,
total_bytes: 3 * 1024 ** 2,
size_is_final: true,
total_is_estimate: false,
}),
})).toMatchObject({
downloadedBytes: 3 * 1024 ** 2,
remainingBytes: 0,
remainingIsEstimated: false,
});
});
it('does not overflow aggregate byte counters', () => {
const huge = Number.MAX_VALUE;
expect(summarizeDownloads([
item('one', { totalBytes: huge, downloadedBytes: huge }),
item('two', { totalBytes: huge, downloadedBytes: huge }),
])).toMatchObject({
downloadedBytes: null,
remainingBytes: 0,
});
expect(summarizeDownloads([
item('three', { totalBytes: huge, downloadedBytes: 0 }),
item('four', { totalBytes: huge, downloadedBytes: 0 }),
])).toMatchObject({
downloadedBytes: 0,
remainingBytes: null,
});
});
});
+125
View File
@@ -0,0 +1,125 @@
import type { DownloadItem } from '../bindings/DownloadItem';
import type { DownloadProgressEvent } from '../bindings/DownloadProgressEvent';
import { isTransferActiveStatus } from './downloads';
export interface DownloadSummary {
itemCount: number;
activeCount: number;
downloadedBytes: number | null;
remainingBytes: number | null;
remainingIsEstimated: boolean;
}
type ProgressMap = Readonly<Record<string, DownloadProgressEvent | undefined>>;
const usableBytes = (value: number | null | undefined): number | undefined =>
typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : undefined;
const isFreshDownloadStatus = (status: DownloadItem['status']): boolean =>
status === 'ready' ||
status === 'staged' ||
status === 'queued' ||
status === 'downloading' ||
status === 'processing' ||
status === 'retrying';
const hasPositiveProgress = (download: DownloadItem): boolean =>
typeof download.fraction === 'number' &&
Number.isFinite(download.fraction) &&
download.fraction > 0;
const canInferNoDownloadedBytes = (download: DownloadItem): boolean =>
!hasPositiveProgress(download) &&
(isFreshDownloadStatus(download.status) || download.status === 'paused');
const effectiveByteState = (
download: DownloadItem,
progress: DownloadProgressEvent | undefined
): { downloadedBytes?: number; totalBytes?: number; totalIsEstimate: boolean } => {
const usesStoredMediaTotal = download.isMedia === true && progress && !progress.size_is_final;
const storedTotalIsEstimate = download.totalIsEstimate === true ||
download.size?.trim().startsWith('~') === true;
const totalBytes = usesStoredMediaTotal
? usableBytes(download.totalBytes)
: usableBytes(progress?.total_bytes) ?? usableBytes(download.totalBytes);
const downloadedBytes = download.status === 'completed'
? usableBytes(download.downloadedBytes) ??
usableBytes(progress?.downloaded_bytes) ??
totalBytes
: usableBytes(progress?.downloaded_bytes) ??
usableBytes(download.downloadedBytes) ??
(canInferNoDownloadedBytes(download) ? 0 : undefined);
const totalIsEstimate = usesStoredMediaTotal
? storedTotalIsEstimate
: (progress?.total_is_estimate ?? storedTotalIsEstimate) === true;
return { downloadedBytes, totalBytes, totalIsEstimate };
};
export const summarizeDownloads = (
downloads: readonly DownloadItem[],
progressMap: ProgressMap = {}
): DownloadSummary => {
if (downloads.length === 0) {
return {
itemCount: 0,
activeCount: 0,
downloadedBytes: null,
remainingBytes: null,
remainingIsEstimated: false,
};
}
let downloadedBytes = 0;
let remainingBytes = 0;
let downloadedKnown = true;
let remainingKnown = true;
let remainingIsEstimated = false;
let activeCount = 0;
for (const download of downloads) {
const state = effectiveByteState(download, progressMap[download.id]);
if (isTransferActiveStatus(download.status)) activeCount += 1;
if (state.downloadedBytes === undefined) {
downloadedKnown = false;
} else {
const nextDownloadedBytes = downloadedBytes + state.downloadedBytes;
if (Number.isFinite(nextDownloadedBytes)) {
downloadedBytes = nextDownloadedBytes;
} else {
downloadedKnown = false;
}
}
if (download.status === 'completed') {
// A completed row is terminal even when a delayed progress event still
// carries an old partial denominator. Never expose that stale value as
// remaining work in the aggregate status bar.
continue;
}
if (
state.totalBytes === undefined ||
state.downloadedBytes === undefined ||
state.downloadedBytes > state.totalBytes
) {
remainingKnown = false;
} else {
const nextRemainingBytes = remainingBytes + Math.max(0, state.totalBytes - state.downloadedBytes);
if (Number.isFinite(nextRemainingBytes)) {
remainingBytes = nextRemainingBytes;
remainingIsEstimated ||= state.totalIsEstimate;
} else {
remainingKnown = false;
}
}
}
return {
itemCount: downloads.length,
activeCount,
downloadedBytes: downloadedKnown ? downloadedBytes : null,
remainingBytes: remainingKnown ? remainingBytes : null,
remainingIsEstimated,
};
};
+36 -4
View File
@@ -4,6 +4,7 @@ import {
DEFAULT_COLUMN_ORDER,
DEFAULT_COLUMN_WIDTHS,
buildColumnGridTemplate,
getDownloadActionPosition,
getColumnGridColumn,
normalizeColumnAlignments,
normalizeColumnOrder,
@@ -54,12 +55,43 @@ describe('download table column preferences', () => {
expect(normalizeColumnAlignments(null)).toEqual(DEFAULT_COLUMN_ALIGNMENTS);
});
it('keeps user widths fixed while reserving a shared spacer before the final column', () => {
it('keeps every data column fixed while reserving a flexible fill track after them', () => {
expect(buildColumnGridTemplate([...DEFAULT_COLUMN_ORDER], [...DEFAULT_COLUMN_WIDTHS])).toBe(
'340px 100px 220px 100px 80px minmax(0, 1fr) 170px'
'340px 100px 220px 100px 80px 170px minmax(0, 1fr)'
);
expect(getColumnGridColumn('Date Added', [...DEFAULT_COLUMN_ORDER])).toBe('7');
expect(getColumnGridColumn('Date Added', [...DEFAULT_COLUMN_ORDER])).toBe('6');
expect(getColumnGridColumn('Date Added', ['Date Added', ...DEFAULT_COLUMN_ORDER.slice(0, -1)])).toBeUndefined();
expect(getColumnGridColumn('ETA', ['Date Added', 'File Name', 'Size', 'Status', 'Speed', 'ETA'])).toBe('7');
expect(getColumnGridColumn('ETA', ['Date Added', 'File Name', 'Size', 'Status', 'Speed', 'ETA'])).toBe('6');
});
it('keeps row actions inside the visible row edge while preserving viewport anchoring for clipped rows', () => {
const viewport = { top: 0, right: 800, bottom: 600, left: 0 };
expect(getDownloadActionPosition(
{ top: 40, right: 1000, bottom: 72, left: -200 },
viewport,
viewport,
800
)).toMatchObject({
right: 8,
height: 30,
visibility: 'visible',
});
expect(getDownloadActionPosition(
{ top: 40, right: 400, bottom: 72, left: 100 },
viewport,
viewport,
800
)).toMatchObject({
right: 408,
visibility: 'visible',
});
expect(getDownloadActionPosition(
{ top: 40, right: -20, bottom: 72, left: -300 },
viewport,
viewport,
800
).visibility).toBe('hidden');
});
});
+48 -6
View File
@@ -14,10 +14,53 @@ export const DEFAULT_COLUMN_ORDER = [
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.
// Width of the compact action rail shown while hovering or focusing a row.
export const DOWNLOAD_ACTIONS_COLUMN_WIDTH = 64;
// Keep the viewport-anchored rail clear of the window edge and scrollbar.
export const DOWNLOAD_ACTIONS_VIEWPORT_INSET = 8;
export const DOWNLOAD_ACTIONS_MAX_HEIGHT = 30;
export const DOWNLOAD_ACTIONS_VIEWPORT_TOLERANCE = 1;
export interface DownloadActionViewportRect {
top: number;
right: number;
bottom: number;
left: number;
}
export interface DownloadActionPosition {
top: number;
right: number;
height: number;
overflow: 'hidden' | 'visible';
visibility: 'hidden' | 'visible';
}
export const getDownloadActionPosition = (
rowRect: DownloadActionViewportRect,
horizontalViewportRect: DownloadActionViewportRect,
verticalViewportRect: DownloadActionViewportRect,
windowWidth: number
): DownloadActionPosition => {
const visibleTop = Math.max(rowRect.top, verticalViewportRect.top);
const visibleBottom = Math.min(rowRect.bottom, verticalViewportRect.bottom);
const visibleHeight = Math.max(0, visibleBottom - visibleTop);
const visibleLeft = Math.max(rowRect.left, horizontalViewportRect.left);
const visibleRight = Math.min(rowRect.right, horizontalViewportRect.right);
const visibleWidth = Math.max(0, visibleRight - visibleLeft);
const isVisible = visibleHeight > DOWNLOAD_ACTIONS_VIEWPORT_TOLERANCE &&
visibleWidth > DOWNLOAD_ACTIONS_VIEWPORT_TOLERANCE;
const actionHeight = Math.min(DOWNLOAD_ACTIONS_MAX_HEIGHT, rowRect.bottom - rowRect.top, visibleHeight);
const actionRightEdge = Math.min(rowRect.right, horizontalViewportRect.right);
return {
top: visibleTop + Math.max(0, (visibleHeight - actionHeight) / 2),
right: Math.max(0, windowWidth - actionRightEdge) + DOWNLOAD_ACTIONS_VIEWPORT_INSET,
height: actionHeight,
overflow: actionHeight < rowRect.bottom - rowRect.top ? 'hidden' : 'visible',
visibility: isVisible ? 'visible' : 'hidden',
};
};
export const COLUMN_WIDTHS_STORAGE_KEY = 'firelink-download-column-widths';
export const COLUMN_ORDER_STORAGE_KEY = 'firelink-download-column-order';
@@ -99,9 +142,8 @@ export const buildColumnGridTemplate = (
const normalizedWidths = normalizeColumnWidths(widths);
const orderedWidths = order.map(key => normalizedWidths[columnIndex(key)]);
return [
...orderedWidths.slice(0, -1).map(width => `${width}px`),
...orderedWidths.map(width => `${width}px`),
'minmax(0, 1fr)',
`${orderedWidths[orderedWidths.length - 1]}px`,
].join(' ');
};
@@ -109,5 +151,5 @@ export const getColumnGridColumn = (
key: DownloadTableColumnKey,
order: DownloadTableColumnKey[]
): string | undefined => key === order[order.length - 1]
? `${order.length + 1}`
? `${order.length}`
: undefined;
+37
View File
@@ -0,0 +1,37 @@
import { describe, expect, it } from 'vitest';
import {
moveSelectedBlockToIndex,
targetIndexForBoundary,
targetIndexForDesiredOrder
} from './queueOrdering';
const items = ['a', 'b', 'c', 'd'].map(id => ({ id }));
describe('queue ordering', () => {
it('moves discontiguous selection as one block', () => {
expect(moveSelectedBlockToIndex(items, ['b', 'd'], 1).map(item => item.id))
.toEqual(['a', 'b', 'd', 'c']);
});
it('moves a selected block to the end for a downward drop', () => {
expect(moveSelectedBlockToIndex(items, ['b', 'c'], 2).map(item => item.id))
.toEqual(['a', 'd', 'b', 'c']);
});
it('translates pointer boundaries after selected rows are removed', () => {
expect(targetIndexForBoundary(items, ['b', 'd'], 2)).toBe(1);
expect(targetIndexForBoundary(items, ['b', 'd'], 3)).toBe(2);
});
it('computes a registered-only backend target from the desired local order', () => {
const current = [{ id: 'a' }, { id: 'staged' }, { id: 'b' }, { id: 'c' }];
const desired = [{ id: 'a' }, { id: 'b' }, { id: 'c' }, { id: 'staged' }];
expect(targetIndexForDesiredOrder(current, ['c'], desired)).toBe(2);
expect(targetIndexForDesiredOrder(
[{ id: 'a' }, { id: 'c' }],
['c'],
[{ id: 'a' }, { id: 'staged' }, { id: 'c' }]
)).toBe(1);
});
});
+61
View File
@@ -0,0 +1,61 @@
export interface QueueOrderItem {
id: string;
}
/**
* Move the selected rows as one block into an insertion position among the
* unselected rows. The position is intentionally defined after selection is
* removed so callers can use the same semantics for UI and backend queues.
*/
export const moveSelectedBlockToIndex = <T extends QueueOrderItem>(
items: T[],
selectedIds: ReadonlySet<string> | readonly string[],
targetIndex: number
): T[] => {
const selected = selectedIds instanceof Set ? selectedIds : new Set(selectedIds);
const selectedItems = items.filter(item => selected.has(item.id));
const unselectedItems = items.filter(item => !selected.has(item.id));
const insertionIndex = Math.max(0, Math.min(targetIndex, unselectedItems.length));
return [
...unselectedItems.slice(0, insertionIndex),
...selectedItems,
...unselectedItems.slice(insertionIndex)
];
};
/**
* Convert a pointer boundary in the original list to the insertion index
* used by moveSelectedBlockToIndex. A boundary is between rows and ranges
* from 0 (before the first row) through items.length (after the last row).
*/
export const targetIndexForBoundary = <T extends QueueOrderItem>(
items: T[],
selectedIds: ReadonlySet<string> | readonly string[],
boundaryIndex: number
): number => {
const selected = selectedIds instanceof Set ? selectedIds : new Set(selectedIds);
const boundary = Math.max(0, Math.min(boundaryIndex, items.length));
return items
.slice(0, boundary)
.reduce((count, item) => count + (selected.has(item.id) ? 0 : 1), 0);
};
/**
* Translate a desired local order to the backend's registered-only queue.
* Staged rows are not registered with the backend, so they must not affect
* the target index sent over IPC.
*/
export const targetIndexForDesiredOrder = <T extends QueueOrderItem>(
currentItems: T[],
selectedIds: ReadonlySet<string> | readonly string[],
desiredItems: T[]
): number => {
const selected = selectedIds instanceof Set ? selectedIds : new Set(selectedIds);
const currentIds = new Set(currentItems.map(item => item.id));
const firstSelectedIndex = desiredItems.findIndex(item => selected.has(item.id));
if (firstSelectedIndex === -1) return currentItems.filter(item => !selected.has(item.id)).length;
return desiredItems
.slice(0, firstSelectedIndex)
.reduce((count, item) => count + (selected.has(item.id) || !currentIds.has(item.id) ? 0 : 1), 0);
};
+28
View File
@@ -0,0 +1,28 @@
import { describe, expect, it } from 'vitest';
import { resolveWindowControlStyle } from './windowControlStyle';
describe('resolveWindowControlStyle', () => {
it('uses the platform convention for automatic style', () => {
expect(resolveWindowControlStyle('auto', 'macos')).toBe('macos');
expect(resolveWindowControlStyle('auto', 'windows')).toBe('windows');
expect(resolveWindowControlStyle('auto', 'linux')).toBe('gnome');
});
it('falls back to macOS styling while an unsupported platform is unresolved', () => {
expect(resolveWindowControlStyle('auto', 'unknown')).toBe('macos');
expect(resolveWindowControlStyle('auto', 'android')).toBe('macos');
});
it('uses the desktop user agent while native platform detection is unresolved', () => {
expect(resolveWindowControlStyle('auto', 'unknown', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)')).toBe('windows');
expect(resolveWindowControlStyle('auto', 'unknown', 'Mozilla/5.0 (X11; Linux x86_64)')).toBe('gnome');
expect(resolveWindowControlStyle('auto', 'unknown', 'Mozilla/5.0 (Linux; Android 14)')).toBe('macos');
});
it('preserves an explicit style across platforms', () => {
expect(resolveWindowControlStyle('macos', 'windows')).toBe('macos');
expect(resolveWindowControlStyle('windows', 'linux')).toBe('windows');
expect(resolveWindowControlStyle('gnome', 'macos')).toBe('gnome');
expect(resolveWindowControlStyle('minimal', 'windows')).toBe('minimal');
});
});
+16
View File
@@ -0,0 +1,16 @@
import type { WindowControlStyle } from '../bindings/WindowControlStyle';
export type ResolvedWindowControlStyle = Exclude<WindowControlStyle, 'auto'>;
export const resolveWindowControlStyle = (
style: WindowControlStyle,
os: string,
userAgent = ''
): ResolvedWindowControlStyle => {
if (style !== 'auto') return style;
if (os === 'windows') return 'windows';
if (os === 'linux') return 'gnome';
if (os === 'unknown' && /Windows/i.test(userAgent)) return 'windows';
if (os === 'unknown' && /Linux/i.test(userAgent) && !/Android/i.test(userAgent)) return 'gnome';
return 'macos';
};