Compare commits

..

65 Commits

Author SHA1 Message Date
NimBold 4df6315be1 chore(release): prepare Firelink 1.3.0 2026-07-29 20:44:58 +03:30
NimBold 29bbb009b5 fix(downloads): harden duplicate resolution and row controls 2026-07-29 18:28:46 +03:30
NimBold 546be23c91 fix(queue): harden resume failure and legacy controls 2026-07-29 16:14:44 +03:30
NimBold ce8a5499c3 fix(queue): resume paused items through lifecycle 2026-07-29 16:06:45 +03:30
NimBold 0e50bdfe9e fix(ui): ignore duplicate stale pause events during resume 2026-07-29 15:49:53 +03:30
NimBold f4ef1a4b28 fix(queue): prevent stale resume waiters from hanging 2026-07-29 15:30:14 +03:30
NimBold 1044b6c619 fix(ui): harden keychain grants and window controls
Keep native credential-store completion pending until the frontend accepts it, prevent stale grant races, and isolate blocking keyring work from the UI. Derive sidebar reveal spacing from each custom control style and restore neutral GNOME/minimal hover feedback.
2026-07-29 15:07:38 +03:30
NimBold 07f45959d0 chore(extension): advance Companion submodule 2026-07-29 12:55:23 +03:30
NimBold 26e0096215 chore(deps): refresh package and engine dependencies 2026-07-29 11:52:18 +03:30
NimBold 197918eab5 fix(queue): keep paused items behind pending work 2026-07-29 00:32:05 +03:30
NimBold 23288ca4e8 fix(ui): harden queue controls and floating menus 2026-07-28 23:49:46 +03:30
NimBold 807663cf03 fix(ui): refine page transitions and bulk actions 2026-07-28 22:39:00 +03:30
NimBold 62f8c83c57 fix(ui): harden navigation and queue interactions 2026-07-28 22:11:38 +03:30
NimBold 7e8f1c7d7b fix(release): enforce tagged companion identity 2026-07-28 19:33:05 +03:30
NimBold 5633896d14 chore(deps): refresh package locks and Rust warnings 2026-07-28 18:29:14 +03:30
NimBold 7795b3bcf8 test(queue): make fake GID allocation atomic 2026-07-28 16:15:10 +03:30
NimBold edc76a7fab fix(ui): avoid default modal action focus 2026-07-27 19:37:47 +03:30
NimBold eb73dde911 fix(ui): harden modal lifecycle and keychain access 2026-07-27 19:23:29 +03:30
NimBold bff7d2782c chore(deps): refresh transitive Rust crates
Update event-listener to 5.4.2 and tray-icon to 0.24.2 for upstream synchronization and cross-platform tray fixes.
2026-07-27 16:39:53 +03:30
NimBold 8cf1bf7897 fix(ui): restore rounded sidebar depth 2026-07-27 16:17:55 +03:30
NimBold 5db9388d07 fix: harden keychain consent startup and modal feedback 2026-07-27 09:58:32 +03:30
NimBold 61dfcf5d8a fix(ui): refine settings and surface styling 2026-07-27 08:37:49 +03:30
NimBold d5c3fc8b27 fix(ui): unify settings top panel surface 2026-07-27 08:15:04 +03:30
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
85 changed files with 11212 additions and 1406 deletions
+3
View File
@@ -40,12 +40,15 @@ jobs:
- uses: actions/checkout@v7
with:
submodules: recursive
fetch-depth: 0
- uses: actions/setup-node@v6
with:
node-version: 22
cache: npm
- name: Verify release version
run: node scripts/verify-release-version.js
- name: Verify Companion release identity
run: node scripts/verify-companion-release.js
- uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.target }}
+36 -5
View File
@@ -5,26 +5,57 @@ All notable changes to Firelink will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [1.3.0] - 2026-07-29
This release focuses on smoother queue control, a more comfortable interface, and safer recovery when downloads or browser handoffs are interrupted.
### New features
- **Live speed limits and connection controls** can be adjusted while a download is active.
- **Queue reordering and bulk actions** are easier to use, with paused downloads kept behind work that is ready to run.
- **Appearance preferences** now include a Windows 11-style option, sidebar-aware window controls, curated fonts for supported languages, and optional localized calendar dates. These improvements respond to [#31](https://github.com/nimbold/Firelink/issues/31).
- **Firelink Companion `2.1.0`** is included in the `Extensions/Browser` submodule. Get the latest extension from the [Firelink-Extension releases](https://github.com/nimbold/Firelink-Extension/releases/latest).
### Improvements
- Resume, retry, pause, and redownload actions now recover more reliably when an earlier attempt has disappeared or a background event arrives late, addressing the failed-resume report in [#28](https://github.com/nimbold/Firelink/issues/28).
- Browser captures recover more safely when Firelink is starting, the extension restarts, or capture settings change during handoff. Captures remain in the Add window for review.
- macOS minimize and Dock, Spotlight, and Raycast reopen behavior are more consistent, addressing [#30](https://github.com/nimbold/Firelink/issues/30).
- The downloads table stays readable at narrow widths: row actions remain contained, columns resize consistently, and controls no longer cover Date Added text, addressing [#32](https://github.com/nimbold/Firelink/issues/32).
- Add-window metadata, duplicate resolution, playlist selection, floating menus, page navigation, and table interactions provide clearer feedback and better keyboard behavior.
- Startup consent and keychain access are more predictable, with clearer permission feedback and safer modal focus.
### Fixes
- Prevent stale pause, resume, retry, completion, and remove actions from reviving a download, reporting the wrong result, or leaving a row stuck in a misleading state.
- Keep queue limits, connection recovery, missing completion events, and retry cleanup from leaving downloads stranded or exceeding the users settings.
- Keep paused work behind pending downloads and make queue controls behave correctly when several actions happen close together.
- Refresh dependencies and bundled engines, and strengthen release, package, portable-mode, and cross-platform verification.
### Known limitation
- Very long media-generated filenames can still exceed an operating systems filename limit. For [#29](https://github.com/nimbold/Firelink/issues/29), the current workaround is to shorten the filename in **Properties** before starting the download.
## [1.2.0] - 2026-07-22
This release makes everyday download management easier to organize, review, and trust across the desktop app and browser extension.
### New features
- **Localized desktop interface** for Persian, Hebrew, Russian, Ukrainian, and Simplified Chinese, including RTL layouts. These translations are currently produced with help from LLMs and need user review; please report corrections so they can improve.
- **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.
+79 -79
View File
@@ -1,107 +1,91 @@
<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.3.0` is the latest desktop release.
It is paired with [the latest Firelink Companion release, `2.1.0`](https://github.com/nimbold/Firelink-Extension/releases/tag/v2.1.0).
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.
- Live speed and connection controls for active downloads.
- A customizable download table with sorting, column selection, reordering, and bulk actions.
- File organization with categories, default folders, and per-download locations.
- Appearance preferences with window styles, multilingual fonts, and localized calendar dates.
- 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 `2.1.0`](https://github.com/nimbold/Firelink-Extension/releases/tag/v2.1.0) connects browser downloads, links, and media pages to Firelink. Use the latest Companion release with the latest Firelink release.
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 +96,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.
+5
View File
@@ -65,6 +65,11 @@ pass. A `workflow_dispatch` on a `v*` tag also publishes when its
release-certification inputs; clean-machine QA remains a release-owner gate
before pushing the tag.
For paired releases, publish and verify the Companion release first. The
desktop release workflow requires `Extensions/Browser` to be at a clean commit
whose exact tag matches both the Companion `package.json` and `manifest.json`
versions before building desktop packages.
## Automated release builds
Push a version tag to build and verify native artifacts:
+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-28-13-32/ffmpeg-n8.1.2-31-g8c9502e9b0-win64-gpl-8.1.zip",
"sha256": "e88033629ebecd56d01e6b5b2e693f07d3ec3ed0d14188fc18d7d0bc9c3e6709"
},
"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-28-13-32/ffmpeg-n8.1.2-31-g8c9502e9b0-linux64-gpl-8.1.tar.xz",
"sha256": "8f61aa8abfc2174bbb60fe0bcf7a23c7940a96d4e34c6694bf0ca48b23ce67f0"
},
"aria2c": {
"version": "1.37.0",
+5 -5
View File
@@ -16,17 +16,17 @@
"sha256": "111b2f5ed760f1e1a2ec06117c4e8094fcde336ba16122dda1c5e7209bf1862d"
},
"ffmpeg": {
"version": "N-125610-g312c830916",
"version": "N-125840-gf6fa0d3fda",
"source": "https://ffmpeg.org/",
"build": "GPLv3 build identified by binary as https://www.martin-riedl.de",
"url": "https://ffmpeg.martin-riedl.de/download/macos/arm64/1784052810_N-125610-g312c830916/ffmpeg.zip",
"sha256": "f65b4ba782b25e9f4374765d421793c6613f692d8e50b2888079657793619034"
"url": "https://ffmpeg.martin-riedl.de/download/macos/arm64/1785265034_N-125840-gf6fa0d3fda/ffmpeg.zip",
"sha256": "e4fa88b19f85afa620dccc35f2bec5b41746e1bb13f1c7e6fe4c7db88391027d"
},
"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>
+206 -124
View File
@@ -1,14 +1,20 @@
{
"name": "firelink",
"version": "1.2.0",
"version": "1.3.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "firelink",
"version": "1.2.0",
"version": "1.3.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.24",
"tailwindcss": "^4.3.3",
"typescript": "^7.0.2",
"vite": "^8.1.5",
@@ -51,20 +57,30 @@
}
},
"node_modules/@emnapi/core": {
"version": "1.11.1",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz",
"integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==",
"version": "2.0.0-alpha.3",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-2.0.0-alpha.3.tgz",
"integrity": "sha512-AZypUeJ/yByuxyS7BlSNRDOMLMlROYtjYdIAuBmJssVz1UJDSeYxLrdizhXCFYhedC5bqd/ASy8EuNXbVVXp9g==",
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/wasi-threads": "2.0.1",
"tslib": "^2.4.0"
}
},
"node_modules/@emnapi/core/node_modules/@emnapi/wasi-threads": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-2.0.1.tgz",
"integrity": "sha512-9DsSk+o5NBX0CCJT8s0EROGSGxjR/tKu6aBTaVyq+SjAEQH4XcdcRxPBRzsBLizTTJ49MJjF+jgu3qnO9GLQcQ==",
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/wasi-threads": "1.2.2",
"tslib": "^2.4.0"
}
},
"node_modules/@emnapi/runtime": {
"version": "1.11.1",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz",
"integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==",
"version": "2.0.0-alpha.3",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-2.0.0-alpha.3.tgz",
"integrity": "sha512-hFPAhMUjJD9BSyCANEISPOogeXC9Zo9ZQl7L6vKnaVsMkCtzznaW/naYypeyl0Gv5rYfWYsZbpixTMpjDJzQeA==",
"license": "MIT",
"optional": true,
"dependencies": {
@@ -81,6 +97,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",
@@ -133,21 +203,24 @@
}
},
"node_modules/@napi-rs/wasm-runtime": {
"version": "1.1.6",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz",
"integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==",
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.2.0.tgz",
"integrity": "sha512-kDoONqMa+VnZ4vvvu/ZUurpJ4gkZU57e7g69qpNgWhYcZFPUHZM2CEMKm+cG6ufDVALbjMvfmMjFVqaK7uEMnA==",
"license": "MIT",
"optional": true,
"dependencies": {
"@tybys/wasm-util": "^0.10.3"
},
"engines": {
"node": "^20.19.0 || ^22.13.0 || >=23.5.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
},
"peerDependencies": {
"@emnapi/core": "^1.7.1",
"@emnapi/runtime": "^1.7.1"
"@emnapi/core": "^2.0.0-alpha.3",
"@emnapi/runtime": "^2.0.0-alpha.3"
}
},
"node_modules/@oxc-project/types": {
@@ -246,6 +319,9 @@
"cpu": [
"arm64"
],
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -262,6 +338,9 @@
"cpu": [
"arm64"
],
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -278,6 +357,9 @@
"cpu": [
"ppc64"
],
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -294,6 +376,9 @@
"cpu": [
"s390x"
],
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -310,6 +395,9 @@
"cpu": [
"x64"
],
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -326,6 +414,9 @@
"cpu": [
"x64"
],
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -369,6 +460,27 @@
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/core": {
"version": "1.11.1",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz",
"integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==",
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/wasi-threads": "1.2.2",
"tslib": "^2.4.0"
}
},
"node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/runtime": {
"version": "1.11.1",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz",
"integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==",
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@rolldown/binding-win32-arm64-msvc": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.5.tgz",
@@ -539,6 +651,9 @@
"cpu": [
"arm64"
],
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -555,6 +670,9 @@
"cpu": [
"arm64"
],
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -571,6 +689,9 @@
"cpu": [
"x64"
],
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -587,6 +708,9 @@
"cpu": [
"x64"
],
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -625,66 +749,6 @@
"node": ">=14.0.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": {
"version": "1.11.1",
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/wasi-threads": "1.2.2",
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": {
"version": "1.11.1",
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": {
"version": "1.2.2",
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": {
"version": "1.1.4",
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@tybys/wasm-util": "^0.10.1"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
},
"peerDependencies": {
"@emnapi/core": "^1.7.1",
"@emnapi/runtime": "^1.7.1"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": {
"version": "0.10.2",
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": {
"version": "2.8.1",
"inBundle": true,
"license": "0BSD",
"optional": true
},
"node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
"version": "4.3.3",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.3.tgz",
@@ -830,6 +894,9 @@
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
@@ -847,6 +914,9 @@
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
@@ -864,6 +934,9 @@
"riscv64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
@@ -881,6 +954,9 @@
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
@@ -898,6 +974,9 @@
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
@@ -1574,9 +1653,9 @@
}
},
"node_modules/baseline-browser-mapping": {
"version": "2.10.43",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.43.tgz",
"integrity": "sha512-AjYpR78kDWAY3Efj+cDTFH9t9SCoL7OoTp1BOb0mQV7S+6CiLwnWM3FyxhJtdPufDFKzmCSFoUncKjWgJEZTCQ==",
"version": "2.11.6",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.6.tgz",
"integrity": "sha512-69D/imtToCsIcAl8WBS2YaRwA4jO/j0HhU+hELqMEu9f54MoUtI6+XH5mrKU8rEFNEk/Ui1I2MK4/JkWacclGw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
@@ -1587,9 +1666,9 @@
}
},
"node_modules/browserslist": {
"version": "4.28.6",
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.6.tgz",
"integrity": "sha512-FQBYNK15VMslhLHpA7+n+n1GOlF1kId2xcCg7/j95f24AOF6VDYMNH4mFxF7KuaTdv627faazpOAjFzMrfJOUw==",
"version": "4.28.7",
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.7.tgz",
"integrity": "sha512-JxV13hNrFxqjOc8alRbq9dK1MM79NEXYpma2B2J4wAtpWS5zIEIKqWPGCl7N4o7Uc7B7itylh7SuDujATRyyTw==",
"dev": true,
"funding": [
{
@@ -1607,9 +1686,9 @@
],
"license": "MIT",
"dependencies": {
"baseline-browser-mapping": "^2.10.42",
"caniuse-lite": "^1.0.30001803",
"electron-to-chromium": "^1.5.389",
"baseline-browser-mapping": "^2.10.44",
"caniuse-lite": "^1.0.30001806",
"electron-to-chromium": "^1.5.393",
"node-releases": "^2.0.51",
"update-browserslist-db": "^1.2.3"
},
@@ -1675,16 +1754,16 @@
}
},
"node_modules/electron-to-chromium": {
"version": "1.5.389",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.389.tgz",
"integrity": "sha512-cEto7aeOqBfU1D+c5py5pE+ooscKE75JifxLBdFUZsqAxRS6y7kebtxAZvICszSl05gPjYHDTjY+lXpyGvpJbg==",
"version": "1.5.398",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.398.tgz",
"integrity": "sha512-AsvhAxopJGh6museTDMIjn6JpDYOfgu4RLlygomt87MUwBUqTfd/1EiPtx10/LZE8xpTvkP2E9Gafq7lkLtodQ==",
"dev": true,
"license": "ISC"
},
"node_modules/enhanced-resolve": {
"version": "5.24.2",
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.24.2.tgz",
"integrity": "sha512-rpsZEGT1jFuve6QlpyRp9ckQ+kN61hvF9BzCPyMdaKTm8UJce96KBn3sorXOFXlzjPrs3Vc4T1NsSroZ3PxlFw==",
"version": "5.24.4",
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.24.4.tgz",
"integrity": "sha512-GVoi+ICHocoOIU7qVVM48wOJziRsqrsyqlI0Ce0LdowRn6v3bcH2zUa9kp85ncx0nwIb9/HOCOLS3fdThDG/XQ==",
"license": "MIT",
"dependencies": {
"graceful-fs": "^4.2.4",
@@ -1783,12 +1862,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": {
@@ -1964,6 +2043,9 @@
"cpu": [
"arm64"
],
"libc": [
"glibc"
],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -1984,6 +2066,9 @@
"cpu": [
"arm64"
],
"libc": [
"musl"
],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -2004,6 +2089,9 @@
"cpu": [
"x64"
],
"libc": [
"glibc"
],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -2024,6 +2112,9 @@
"cpu": [
"x64"
],
"libc": [
"musl"
],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -2078,9 +2169,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"
@@ -2124,9 +2215,9 @@
}
},
"node_modules/obug": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/obug/-/obug-2.1.3.tgz",
"integrity": "sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==",
"version": "2.1.4",
"resolved": "https://registry.npmjs.org/obug/-/obug-2.1.4.tgz",
"integrity": "sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==",
"dev": true,
"funding": [
"https://github.com/sponsors/sxzz",
@@ -2163,9 +2254,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.24",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.24.tgz",
"integrity": "sha512-8RyVklq0owXUTa4xlpzu4l9AaVKIdQvAcOHZWaMh98HgySsUtxRVf/chRe3dsSLqb6i40BzGRzEUddRaI+9TSw==",
"funding": [
{
"type": "opencollective",
@@ -2219,13 +2310,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": {
@@ -2367,9 +2458,9 @@
}
},
"node_modules/tinyrainbow": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz",
"integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==",
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.1.tgz",
"integrity": "sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==",
"dev": true,
"license": "MIT",
"engines": {
@@ -2625,15 +2716,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",
+12 -6
View File
@@ -1,7 +1,7 @@
{
"name": "firelink",
"private": true,
"version": "1.2.0",
"version": "1.3.0",
"description": "A fast cross-platform desktop download manager powered by Rust, Tauri, React, aria2, and yt-dlp.",
"license": "MIT",
"homepage": "https://github.com/nimbold/Firelink",
@@ -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.24",
"tailwindcss": "^4.3.3",
"typescript": "^7.0.2",
"vite": "^8.1.5",
+81
View File
@@ -0,0 +1,81 @@
#!/usr/bin/env node
import fs from 'node:fs';
import path from 'node:path';
import { execFileSync } from 'node:child_process';
import { fileURLToPath } from 'node:url';
const scriptDirectory = path.dirname(fileURLToPath(import.meta.url));
const defaultRepositoryRoot = path.resolve(scriptDirectory, '..');
const SEMVER = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/;
function readJson(file) {
return JSON.parse(fs.readFileSync(file, 'utf8'));
}
function exactVersionTag(extensionRoot, expectedTag) {
try {
const tags = execFileSync(
'git',
['-C', extensionRoot, 'tag', '--points-at', 'HEAD', '--list', '--', expectedTag],
{ encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }
)
.split(/\r?\n/)
.map(tag => tag.trim())
.find(tag => tag === expectedTag);
return tags || null;
} catch {
return null;
}
}
export function verifyCompanionRelease({
repositoryRoot = defaultRepositoryRoot,
resolveExactTag = exactVersionTag,
} = {}) {
const extensionRoot = path.join(repositoryRoot, 'Extensions', 'Browser');
const packagePath = path.join(extensionRoot, 'package.json');
const manifestPath = path.join(extensionRoot, 'manifest.json');
if (!fs.existsSync(packagePath) || !fs.existsSync(manifestPath)) {
throw new Error('Companion package.json and manifest.json must both exist.');
}
const packageVersion = readJson(packagePath).version;
const manifestVersion = readJson(manifestPath).version;
if (!packageVersion || packageVersion !== manifestVersion) {
throw new Error(
`Companion versions do not agree: package.json=${packageVersion || 'missing'}, ` +
`manifest.json=${manifestVersion || 'missing'}.`
);
}
if (typeof packageVersion !== 'string' || !SEMVER.test(packageVersion)) {
throw new Error(`Companion version ${String(packageVersion)} is not a valid semantic version.`);
}
const expectedTag = `v${packageVersion}`;
const tag = resolveExactTag(extensionRoot, expectedTag);
if (!tag) {
throw new Error(
`Companion HEAD is not exactly tagged ${expectedTag}; publish the Companion release first.`
);
}
if (tag !== expectedTag) {
throw new Error(`Companion HEAD tag ${tag} does not match ${expectedTag}.`);
}
return { tag, version: packageVersion };
}
function main() {
try {
const { tag, version } = verifyCompanionRelease();
console.log(`Companion release ${version} matches exact tag ${tag}.`);
} catch (error) {
console.error(error instanceof Error ? error.message : String(error));
process.exitCode = 1;
}
}
if (path.resolve(process.argv[1] || '') === fileURLToPath(import.meta.url)) {
main();
}
@@ -0,0 +1,97 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import test from 'node:test';
import { verifyCompanionRelease } from './verify-companion-release.js';
function createFixture(packageVersion, manifestVersion) {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'firelink-companion-release-'));
const extensionRoot = path.join(root, 'Extensions', 'Browser');
fs.mkdirSync(extensionRoot, { recursive: true });
fs.writeFileSync(
path.join(extensionRoot, 'package.json'),
`${JSON.stringify({ version: packageVersion }, null, 2)}\n`
);
fs.writeFileSync(
path.join(extensionRoot, 'manifest.json'),
`${JSON.stringify({ version: manifestVersion }, null, 2)}\n`
);
return root;
}
test('accepts matching Companion metadata and exact release tag', () => {
const root = createFixture('2.0.7', '2.0.7');
try {
let resolvedExpectedTag;
assert.deepEqual(
verifyCompanionRelease({
repositoryRoot: root,
resolveExactTag: (_extensionRoot, expectedTag) => {
resolvedExpectedTag = expectedTag;
return 'v2.0.7';
},
}),
{ tag: 'v2.0.7', version: '2.0.7' }
);
assert.equal(resolvedExpectedTag, 'v2.0.7');
} finally {
fs.rmSync(root, { recursive: true, force: true });
}
});
test('rejects mismatched Companion package and manifest versions', () => {
const root = createFixture('2.0.7', '2.0.6');
try {
assert.throws(
() => verifyCompanionRelease({ repositoryRoot: root, resolveExactTag: () => 'v2.0.7' }),
/versions do not agree/
);
} finally {
fs.rmSync(root, { recursive: true, force: true });
}
});
test('rejects a malformed Companion semantic version before tag lookup', () => {
const root = createFixture('--contains=HEAD', '--contains=HEAD');
let tagLookupCalled = false;
try {
assert.throws(
() => verifyCompanionRelease({
repositoryRoot: root,
resolveExactTag: () => {
tagLookupCalled = true;
return 'v--contains=HEAD';
},
}),
/not a valid semantic version/
);
assert.equal(tagLookupCalled, false);
} finally {
fs.rmSync(root, { recursive: true, force: true });
}
});
test('rejects an untagged Companion commit', () => {
const root = createFixture('2.0.7', '2.0.7');
try {
assert.throws(
() => verifyCompanionRelease({ repositoryRoot: root, resolveExactTag: () => null }),
/not exactly tagged v2.0.7/
);
} finally {
fs.rmSync(root, { recursive: true, force: true });
}
});
test('rejects a Companion tag for another version', () => {
const root = createFixture('2.0.7', '2.0.7');
try {
assert.throws(
() => verifyCompanionRelease({ repositoryRoot: root, resolveExactTag: () => 'v2.0.6' }),
/does not match v2.0.7/
);
} finally {
fs.rmSync(root, { recursive: true, force: true });
}
});
+38 -48
View File
@@ -515,9 +515,9 @@ dependencies = [
[[package]]
name = "camino"
version = "1.2.4"
version = "1.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5f2d30e4173c4026932d51d31d6b0613b1fd3014bf3f9f8943d4ba139c437ba0"
checksum = "bb1307f12aa967b5a58416e87b3653360e0fd614a016b6e970db08fecbb1b80d"
dependencies = [
"serde_core",
]
@@ -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",
@@ -1072,13 +1072,13 @@ dependencies = [
[[package]]
name = "displaydoc"
version = "0.2.6"
version = "0.2.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f"
checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
"syn 3.0.3",
]
[[package]]
@@ -1194,7 +1194,7 @@ dependencies = [
"cc",
"memchr",
"rustc_version",
"toml 1.1.3+spec-1.1.0",
"toml 1.1.4+spec-1.1.0",
"vswhom",
"winreg 0.55.0",
]
@@ -1286,11 +1286,10 @@ checksum = "dea2df4cf52843e0452895c455a1a2cfbb842a1e7329671acf418fdc53ed4c59"
[[package]]
name = "event-listener"
version = "5.4.1"
version = "5.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab"
checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2"
dependencies = [
"concurrent-queue",
"parking",
"pin-project-lite",
]
@@ -1365,7 +1364,7 @@ checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
[[package]]
name = "firelink"
version = "1.2.0"
version = "1.3.0"
dependencies = [
"apple-native-keyring-store",
"async-trait",
@@ -1457,13 +1456,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 +3401,7 @@ checksum = "7da1d65da6dd5d1e44199ac0f58712d241c0f439f80adea8924d832384087f85"
dependencies = [
"base64 0.22.1",
"indexmap 2.14.0",
"quick-xml 0.41.0",
"quick-xml",
"serde",
"time",
]
@@ -3557,15 +3556,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 +3897,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",
]
@@ -4011,9 +4001,9 @@ dependencies = [
[[package]]
name = "schemars"
version = "1.2.1"
version = "1.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc"
checksum = "687274d293b6cdc6e73e0fee520bf2049650090d7164f87672d212a3c530cf4a"
dependencies = [
"dyn-clone",
"ref-cast",
@@ -4241,7 +4231,7 @@ dependencies = [
"indexmap 1.9.3",
"indexmap 2.14.0",
"schemars 0.9.0",
"schemars 1.2.1",
"schemars 1.2.2",
"serde_core",
"serde_json",
"serde_with_macros",
@@ -4679,9 +4669,9 @@ dependencies = [
[[package]]
name = "tao-macros"
version = "0.1.3"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f4e16beb8b2ac17db28eab8bca40e62dbfbb34c0fcdc6d9826b11b7b5d047dfd"
checksum = "5f7eeb6d99155545da6150a1795945f16ac9c178deb2a5f2e74d776107bd5849"
dependencies = [
"proc-macro2",
"quote",
@@ -4898,7 +4888,7 @@ dependencies = [
"tauri-plugin",
"tauri-utils",
"thiserror 2.0.19",
"toml 1.1.3+spec-1.1.0",
"toml 1.1.4+spec-1.1.0",
"url",
]
@@ -5084,7 +5074,7 @@ dependencies = [
"serde_with",
"swift-rs",
"thiserror 2.0.19",
"toml 1.1.3+spec-1.1.0",
"toml 1.1.4+spec-1.1.0",
"url",
"urlpattern",
"uuid",
@@ -5099,7 +5089,7 @@ checksum = "cc65d45c68858bfe420dd29e834b5d15dbecf8a07a8a16cf4d532c7b1f69d4b6"
dependencies = [
"dunce",
"embed-resource",
"toml 1.1.3+spec-1.1.0",
"toml 1.1.4+spec-1.1.0",
]
[[package]]
@@ -5364,9 +5354,9 @@ dependencies = [
[[package]]
name = "toml"
version = "1.1.3+spec-1.1.0"
version = "1.1.4+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "53c96ecdfa941c8fc4fcaed14f99ada8ebed502eef533015095a07e3301d4c3c"
checksum = "3aace63f4bbcdfc2c965b059de67119c89c4017a70d633be6c104910f67056f5"
dependencies = [
"indexmap 2.14.0",
"serde_core",
@@ -5442,9 +5432,9 @@ dependencies = [
[[package]]
name = "toml_parser"
version = "1.1.2+spec-1.1.0"
version = "1.1.3+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526"
checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56"
dependencies = [
"winnow 1.0.4",
]
@@ -5570,9 +5560,9 @@ dependencies = [
[[package]]
name = "tray-icon"
version = "0.24.1"
version = "0.24.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "65ba1e5f6b9ef9fd87e21b9c6f351554dbd717960089168fcfdef854686961dc"
checksum = "045979e3f037cd18ad1cb2a419dfda133c5c29c9f3453370079f2255d46c257e"
dependencies = [
"crossbeam-channel",
"dirs",
@@ -5931,9 +5921,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 +5934,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 +5971,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",
]
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "firelink"
version = "1.2.0"
version = "1.3.0"
description = "A fast cross-platform desktop download manager powered by Rust and Tauri"
authors = ["NimBold"]
edition = "2021"
Binary file not shown.
Binary file not shown.
+6
View File
@@ -10,6 +10,12 @@ const LEGACY_BUNDLE_IDENTIFIER: &str = "com.nima.tauri-app";
const CURRENT_SCHEMA_VERSION: i64 = 1;
pub(crate) const TOKEN_CHANGED_NOTICE: &str = "pairing-token-changed";
pub const PAIRING_TOKEN_KEYCHAIN_ID: &str = "extension-pairing-token";
// Development builds are a different executable identity from the packaged
// app. Keep their credentials separate so a debug binary cannot trigger an
// access prompt for, or reuse, the release app's Keychain item.
#[cfg(debug_assertions)]
const KEYCHAIN_SERVICE: &str = "com.firelink.app.dev";
#[cfg(not(debug_assertions))]
const KEYCHAIN_SERVICE: &str = "com.firelink.app";
static KEYRING_OPERATION_LOCK: Mutex<()> = Mutex::new(());
+1 -1
View File
@@ -436,7 +436,7 @@ fn normalize_download(mut payload: ExtensionRequest) -> Option<ExtensionDownload
let filename = payload.filename.and_then(|value| sanitize_filename(&value));
let batch = payload.batch && urls.len() >= 2;
let batch_name = batch
.then(|| payload.batch_name)
.then_some(payload.batch_name)
.flatten()
.and_then(|value| {
let value = value.trim().to_string();
+69
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,44 @@ pub enum Theme {
Nord,
}
#[derive(Clone, Debug, Default, Serialize, Deserialize, TS)]
#[serde(rename_all = "kebab-case")]
#[ts(export, export_to = "../../src/bindings/")]
pub enum FontFamily {
#[default]
System,
Inter,
Outfit,
Vazirmatn,
NotoSansHebrew,
NotoSansSc,
Roboto,
Serif,
Monospace,
}
#[derive(Clone, Debug, Default, Serialize, Deserialize, TS)]
#[serde(rename_all = "lowercase")]
#[ts(export, export_to = "../../src/bindings/")]
pub enum WindowControlStyle {
#[default]
Auto,
Macos,
Windows,
Gnome,
Minimal,
}
#[derive(Clone, Debug, Default, Serialize, Deserialize, TS)]
#[serde(rename_all = "lowercase")]
#[ts(export, export_to = "../../src/bindings/")]
pub enum CalendarPreference {
#[default]
Gregorian,
Persian,
Hebrew,
}
#[derive(Clone, Debug, Serialize, Deserialize, TS)]
#[serde(rename_all = "lowercase")]
#[ts(export, export_to = "../../src/bindings/")]
@@ -265,6 +317,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 +363,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,
@@ -360,6 +420,15 @@ impl DownloadStateEvent {
}
}
pub fn paused_with_error(id: impl Into<String>, error: impl Into<String>) -> Self {
Self {
id: id.into(),
status: DownloadStatus::Paused.as_str().to_string(),
error: Some(error.into()),
file_name: None,
}
}
pub fn completed_with_file(id: impl Into<String>, file_name: impl Into<String>) -> Self {
Self {
id: id.into(),
+956 -203
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);
}
}
+975 -64
View File
File diff suppressed because it is too large Load Diff
+60 -47
View File
@@ -26,17 +26,30 @@ fn stop_is_due(
&& last_stop_key != stop_key
}
fn overnight_stop_is_due(
struct OvernightStopCheck<'a> {
stop_time_enabled: bool,
start_minute: Option<u32>,
stop_minute: Option<u32>,
current_minute: u32,
previous_day_allowed: bool,
last_start_key: &str,
previous_start_key: &str,
last_stop_key: &str,
stop_key: &str,
) -> bool {
last_start_key: &'a str,
previous_start_key: &'a str,
last_stop_key: &'a str,
stop_key: &'a str,
}
fn overnight_stop_is_due(check: OvernightStopCheck<'_>) -> bool {
let OvernightStopCheck {
stop_time_enabled,
start_minute,
stop_minute,
current_minute,
previous_day_allowed,
last_start_key,
previous_start_key,
last_stop_key,
stop_key,
} = check;
stop_time_enabled
&& previous_day_allowed
&& start_minute.zip(stop_minute).is_some_and(|(start, stop)| {
@@ -126,17 +139,17 @@ pub fn spawn_scheduler(
let previous_start_key = previous_day
.map(|day| format!("{}-start", day.format("%Y-%m-%d")))
.unwrap_or_default();
let overnight_stop_due = overnight_stop_is_due(
scheduler.stop_time_enabled,
let overnight_stop_due = overnight_stop_is_due(OvernightStopCheck {
stop_time_enabled: scheduler.stop_time_enabled,
start_minute,
stop_minute,
current_minute,
previous_day_allowed,
&scheduler_last_start_key,
&previous_start_key,
&scheduler_last_stop_key,
&stop_key,
);
last_start_key: &scheduler_last_start_key,
previous_start_key: &previous_start_key,
last_stop_key: &scheduler_last_stop_key,
stop_key: &stop_key,
});
if (same_day_stop_due || overnight_stop_due)
&& last_emit
@@ -159,7 +172,7 @@ pub fn spawn_scheduler(
#[cfg(test)]
mod tests {
use super::{minute_of_day, overnight_stop_is_due, stop_is_due};
use super::{minute_of_day, overnight_stop_is_due, stop_is_due, OvernightStopCheck};
#[test]
fn parses_valid_scheduler_times() {
@@ -199,38 +212,38 @@ mod tests {
#[test]
fn overnight_stop_uses_the_previous_day_start() {
assert!(overnight_stop_is_due(
true,
Some(1320),
Some(360),
420,
true,
"2026-06-22-start",
"2026-06-22-start",
"",
"2026-06-23-stop",
));
assert!(!overnight_stop_is_due(
true,
Some(1320),
Some(360),
1380,
true,
"2026-06-22-start",
"2026-06-22-start",
"",
"2026-06-22-stop",
));
assert!(!overnight_stop_is_due(
true,
Some(1320),
Some(360),
420,
false,
"2026-06-22-start",
"2026-06-22-start",
"",
"2026-06-23-stop",
));
assert!(overnight_stop_is_due(OvernightStopCheck {
stop_time_enabled: true,
start_minute: Some(1320),
stop_minute: Some(360),
current_minute: 420,
previous_day_allowed: true,
last_start_key: "2026-06-22-start",
previous_start_key: "2026-06-22-start",
last_stop_key: "",
stop_key: "2026-06-23-stop",
}));
assert!(!overnight_stop_is_due(OvernightStopCheck {
stop_time_enabled: true,
start_minute: Some(1320),
stop_minute: Some(360),
current_minute: 1380,
previous_day_allowed: true,
last_start_key: "2026-06-22-start",
previous_start_key: "2026-06-22-start",
last_stop_key: "",
stop_key: "2026-06-22-stop",
}));
assert!(!overnight_stop_is_due(OvernightStopCheck {
stop_time_enabled: true,
start_minute: Some(1320),
stop_minute: Some(360),
current_minute: 420,
previous_day_allowed: false,
last_start_key: "2026-06-22-start",
previous_start_key: "2026-06-22-start",
last_stop_key: "",
stop_key: "2026-06-23-stop",
}));
}
}
+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");
}
+2 -2
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Firelink",
"version": "1.2.0",
"version": "1.3.0",
"identifier": "com.nimbold.firelink",
"build": {
"beforeDevCommand": "node scripts/stage-engines.js && npm run dev",
@@ -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": {
File diff suppressed because it is too large Load Diff
+224 -134
View File
@@ -1,8 +1,11 @@
import { initMediaDomains, isActiveDownloadStatus, isTransferActiveStatus } from './utils/downloads';
import { schedulerCompletionState } from './utils/schedulerCompletion';
import { lazy, Suspense, useCallback, useEffect, useRef, useState } from "react";
import { lazy, Suspense, useCallback, useEffect, useLayoutEffect, useRef, useState, type CSSProperties } 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 { getWindowControlRevealOffset, 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,13 +189,25 @@ 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);
const pageTransitionKey = activeView === 'downloads'
? `${activeView}:${filter}`
: activeView;
const [isPageTransitionActive, setIsPageTransitionActive] = useState(false);
useLayoutEffect(() => {
setIsPageTransitionActive(false);
const frame = window.requestAnimationFrame(() => setIsPageTransitionActive(true));
return () => window.cancelAnimationFrame(frame);
}, [pageTransitionKey]);
useEffect(() => {
const locale = languagePreference === 'system'
@@ -218,6 +234,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 +256,15 @@ 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 windowControlRevealOffset = getWindowControlRevealOffset(windowControlStyle);
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
@@ -397,6 +428,101 @@ function App() {
unlistenDownload = null;
};
// Establish the consent barrier before registering listeners or loading
// persisted downloads. Those later startup steps can cause user-facing
// work to begin; the explanation must already be committed before any
// path is allowed to reach the credential store.
await waitForSettingsHydration();
if (!active) return;
const [currentAppVersion, currentPlatform] = await Promise.all([
getVersion().catch(() => ''),
getPlatformInfo().catch(() => null)
]);
if (!active) return;
const currentKeychainConsentVersion = getKeychainConsentVersion(currentAppVersion);
setKeychainConsentVersion(currentKeychainConsentVersion);
try {
const settings = useSettingsStore.getState();
const isStartupActive = () => active;
const { deferKeychainHydration, showKeychainPrompt } = getKeychainStartupDecision({
portable: currentPlatform?.portable === true,
appVersion: currentKeychainConsentVersion,
approvedVersion: settings.keychainAccessVersion,
accessGranted: settings.keychainAccessGranted,
promptDismissed: settings.keychainPromptDismissed
});
let changed = false;
if (deferKeychainHydration) {
settings.setKeychainAccessReady(false);
if (showKeychainPrompt) settings.setShowKeychainModal(true);
await settings.hydrateSessionPairingToken(isStartupActive);
if (!active) return;
} else {
await invoke('authorize_keychain_access');
if (!active) return;
changed = await settings.hydratePairingToken(isStartupActive);
if (!active) return;
const currentSettings = useSettingsStore.getState();
settings.setKeychainAccessReady(getKeychainAccessReady({
portable: currentPlatform?.portable === true,
accessGranted: currentSettings.keychainAccessGranted,
persistent: currentSettings.isPairingTokenPersistent
}));
}
if (changed) {
addToast({
variant: 'warning',
isActionable: true,
message: (
<div className="flex flex-col gap-2">
<p>{t($ => $.app.extensionDisconnected)}</p>
<div className="flex gap-2 justify-end">
<button
type="button"
className="app-button px-2 py-1 bg-surface-raised border border-border-color rounded"
onClick={async () => {
const token = useSettingsStore.getState().extensionPairingToken;
try {
if (token) await navigator.clipboard.writeText(token);
acknowledgePairingTokenChange();
} catch (error) {
addToast({
message: t($ => $.app.copyTokenFailed, { detail: String(error) }),
variant: 'error',
isActionable: true
});
}
}}
>
{t($ => $.app.copyToken)}
</button>
<button
type="button"
className="app-button px-2 py-1 bg-surface-raised border border-border-color rounded"
onClick={() => {
const settings = useSettingsStore.getState();
settings.setActiveSettingsTab('integrations');
settings.setActiveView('settings');
acknowledgePairingTokenChange();
}}
>
{t($ => $.app.integrations)}
</button>
</div>
</div>
)
});
}
} catch (error) {
console.error('Failed to hydrate extension pairing token:', error);
addToast({
message: t($ => $.app.credentialPersistenceFailed, { detail: String(error) }),
variant: 'error',
isActionable: true
});
}
try {
unlistenDownload = await initDownloadListener();
unlistenTerminalState = await listen('download-state', (event) => {
@@ -459,7 +585,20 @@ function App() {
cleanupListeners = null;
return;
}
} catch (error) {
disposeListeners();
cleanupListeners = null;
if (!active) return;
console.error('Failed to initialize Firelink listeners:', error);
addToast({
message: t($ => $.app.initializeDownloadsFailed, { detail: String(error) }),
variant: 'error',
isActionable: true
});
return;
}
try {
await initializeDownloadState();
if (!active) return;
} catch (error) {
@@ -475,110 +614,6 @@ function App() {
return;
}
const [currentAppVersion, currentPlatform] = await Promise.all([
getVersion().catch(() => ''),
getPlatformInfo().catch(() => null)
]);
if (!active) return;
const currentKeychainConsentVersion = getKeychainConsentVersion(currentAppVersion);
setKeychainConsentVersion(currentKeychainConsentVersion);
try {
const settings = useSettingsStore.getState();
const isStartupActive = () => active;
const { deferKeychainHydration, showKeychainPrompt } = getKeychainStartupDecision({
portable: currentPlatform?.portable === true,
appVersion: currentKeychainConsentVersion,
approvedVersion: settings.keychainAccessVersion,
accessGranted: settings.keychainAccessGranted,
promptDismissed: settings.keychainPromptDismissed
});
let changed = false;
if (deferKeychainHydration) {
settings.setKeychainAccessReady(false);
if (showKeychainPrompt) {
// Commit the explanation before the harmless session-token IPC so
// a slow startup cannot leave the user facing an unexplained
// credential-store request.
settings.setShowKeychainModal(true);
}
// This token is already owned by the backend and does not access
// the OS credential store. Render our explanation before any native
// Keychain/Credential Manager prompt can be user-triggered.
await settings.hydrateSessionPairingToken(isStartupActive);
if (!active) return;
} else {
// The backend keeps credential-store access disabled for every new
// process. Arm it only after the persisted startup decision has
// confirmed that this build was already approved; the hydrate call
// below is then the first operation allowed to touch the OS store.
await invoke('authorize_keychain_access');
if (!active) return;
changed = await settings.hydratePairingToken(isStartupActive);
if (!active) return;
const currentSettings = useSettingsStore.getState();
settings.setKeychainAccessReady(getKeychainAccessReady({
portable: currentPlatform?.portable === true,
accessGranted: currentSettings.keychainAccessGranted,
persistent: currentSettings.isPairingTokenPersistent
}));
}
if (changed) {
addToast({
variant: 'warning',
isActionable: true,
message: (
<div className="flex flex-col gap-2">
<p>{t($ => $.app.extensionDisconnected)}</p>
<div className="flex gap-2 justify-end">
<button
type="button"
className="app-button px-2 py-1 bg-surface-raised border border-border-color rounded"
onClick={async () => {
const token = useSettingsStore.getState().extensionPairingToken;
try {
if (token) {
await navigator.clipboard.writeText(token);
}
acknowledgePairingTokenChange();
} catch (error) {
addToast({
message: t($ => $.app.copyTokenFailed, { detail: String(error) }),
variant: 'error',
isActionable: true
});
}
}}
>
{t($ => $.app.copyToken)}
</button>
<button
type="button"
className="app-button px-2 py-1 bg-surface-raised border border-border-color rounded"
onClick={() => {
const settings = useSettingsStore.getState();
settings.setActiveSettingsTab('integrations');
settings.setActiveView('settings');
acknowledgePairingTokenChange();
}}
>
{t($ => $.app.integrations)}
</button>
</div>
</div>
)
});
}
} catch (error) {
console.error('Failed to hydrate extension pairing token:', error);
addToast({
message: t($ => $.app.credentialPersistenceFailed, { detail: String(error) }),
variant: 'error',
isActionable: true
});
}
if (!active) return;
setCoreReady(true);
};
@@ -634,6 +669,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 +735,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);
@@ -1007,12 +1063,17 @@ function App() {
isSidebarOnRight ? 'app-shell--sidebar-right' : 'app-shell--sidebar-left'
} ${
hasWindowChrome ? 'app-shell--window-chrome' : ''
}`}>
}`}
style={{ '--window-control-reveal-offset': `${windowControlRevealOffset}px` } as CSSProperties}
>
{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)] ${
className={`app-sidebar-shell relative z-20 shrink-0 transition-[width,margin-inline-start,margin-inline-end,opacity] duration-300 ease-[cubic-bezier(0.2,0.8,0.2,1)] ${
isSidebarOnRight ? 'app-sidebar-shell--right' : 'app-sidebar-shell--left'
} ${
isSidebarVisible ? 'opacity-100' : 'opacity-0 pointer-events-none'
@@ -1062,17 +1123,39 @@ function App() {
)}
<div className="flex-1 flex flex-col overflow-hidden relative">
<Suspense fallback={<PageLoadingFallback />}>
{activeView === 'downloads' && <DownloadTable filter={filter} />}
{activeView === 'settings' && <SettingsView />}
{activeView === 'scheduler' && <SchedulerView />}
{activeView === 'speedLimiter' && <SpeedLimiterView />}
{activeView === 'logs' && <LogsView />}
<div className={`${isPageTransitionActive ? 'app-page-transition' : ''} flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden`}>
{activeView === 'downloads' && (
<DownloadTable
filter={filter}
onSummaryChange={handleDownloadTableSummaryChange}
/>
)}
{activeView === 'settings' && <SettingsView />}
{activeView === 'scheduler' && <SchedulerView />}
{activeView === 'speedLimiter' && <SpeedLimiterView />}
{activeView === 'logs' && <LogsView />}
</div>
</Suspense>
</div>
{/* 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,12 +1164,19 @@ function App() {
</div>
</div>
<Suspense fallback={null}>
{isAddModalOpen && <AddDownloadsModal />}
{selectedPropertiesDownloadId !== null && <PropertiesModal />}
{isDeleteModalOpen && <DeleteConfirmationModal />}
{showKeychainModal && <KeychainPermissionModal consentVersion={keychainConsentVersion} />}
</Suspense>
{isAddModalOpen && <AddDownloadsModal />}
{selectedPropertiesDownloadId !== null && (
<Suspense fallback={null}>
<PropertiesModal />
</Suspense>
)}
{isDeleteModalOpen && (
<Suspense fallback={null}>
<DeleteConfirmationModal />
</Suspense>
)}
{showKeychainModal && <KeychainPermissionModal consentVersion={keychainConsentVersion} />}
</div>
);
+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
View File
@@ -0,0 +1,4 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { PairingTokenHydration } from "./PairingTokenHydration";
export type KeychainGrantStatus = { started: boolean, inProgress: boolean, result: PairingTokenHydration | null, };
+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";
+349 -16
View File
@@ -33,15 +33,25 @@ import { localeDirection, localePluralVariant, resolveAppLocale } from '../i18n/
import {
canSubmitMetadataRows,
appendRequestUrlsAfterVersion,
commonMediaFormatsForRows,
commonMediaQualitiesForRows,
durableDownloadUrl,
mediaFileNameForSelectedFormat,
mediaFormatForFormat,
mediaQualityForFormat,
mediaQualityForRow,
mediaTypeForFormat,
mediaFormatSelectorForRow,
metadataSummaryState,
playlistFilePrefix,
reconcileDownloadRows,
refreshFailedMetadataRows,
selectExactMediaSelection,
updateRowIfCurrent,
type AddDownloadDraftRow
type AddDownloadDraftRow,
type MediaSelection
} from '../utils/addDownloadMetadata';
import { isTopmostModal, useModalFocus } from '../hooks/useModalFocus';
const formatBytes = (bytes: number) => {
const k = 1024;
@@ -157,6 +167,7 @@ export const AddDownloadsModal = () => {
const [conflicts, setConflicts] = useState<DuplicateConflict[]>([]);
const [showingDuplicates, setShowingDuplicates] = useState(false);
const modalRef = useModalFocus(isAddModalOpen);
const [pendingAction, setPendingAction] = useState<AddDownloadAction>({ type: 'start-now' });
const [pendingUseSharedDestination, setPendingUseSharedDestination] = useState(false);
const [pendingDestinationOverrides, setPendingDestinationOverrides] = useState<Record<number, string>>({});
@@ -186,6 +197,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 +259,9 @@ export const AddDownloadsModal = () => {
setPlaylistExpansions({});
playlistRequestsRef.current.clear();
latestPlaylistRequestRef.current.clear();
playlistMediaSelectionsRef.current.clear();
setPlaylistMediaTypeSelections({});
setPlaylistQualityExpanded(true);
return;
}
@@ -273,6 +290,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 +342,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,
@@ -344,7 +366,9 @@ export const AddDownloadsModal = () => {
if (!isAddModalOpen) return;
const closeOnEscape = (event: KeyboardEvent) => {
if (event.key !== 'Escape') return;
if (!isTopmostModal(modalRef.current)) return;
if (showKeychainModal) return;
event.preventDefault();
if (showingDuplicates) {
setShowingDuplicates(false);
} else if (isQueueMenuOpen) {
@@ -383,6 +407,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 +554,7 @@ export const AddDownloadsModal = () => {
const isApproximate = !exactBytes && approxBytes > 0;
return {
name: `${quality} ${container}`,
quality,
ext: f.ext,
bytes,
isApproximate,
@@ -528,6 +564,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 +593,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
})
));
@@ -570,7 +626,12 @@ export const AddDownloadsModal = () => {
proxy,
deferCookies: shouldDeferCookiesForRow(row.sourceUrl)
});
const nextDownloadUrl = meta.url || row.sourceUrl;
// Persist the stable source URL, not the resolved redirect. A
// redirect target may be a short-lived signed URL (for example,
// GitHub release assets) and would make later resumes fail after
// its expiry. The metadata response remains useful for filename,
// size, and resumability.
const nextDownloadUrl = durableDownloadUrl(row.sourceUrl);
setParsedItems(current => updateRowIfCurrent(
current,
row.id,
@@ -834,7 +895,11 @@ export const AddDownloadsModal = () => {
destinationOverrides[i]
);
const isUrlDupe = store.downloads.some(d => d.url === item.downloadUrl && d.status !== 'failed' && d.status !== 'completed');
const urlMatch = store.downloads.find(d =>
normalizeComparableUrl(d.url) === normalizeComparableUrl(item.downloadUrl)
&& d.status !== 'failed'
&& d.status !== 'completed'
);
const hasBatchConflict = plannedTargets.some(target =>
downloadLocationEquals(
target.location,
@@ -844,8 +909,15 @@ export const AddDownloadsModal = () => {
platform.os
)
);
if (isUrlDupe) {
newConflicts.push({ id: i.toString(), fileName: finalFile, reason: { type: 'url', msg: t($ => $.addDownloads.urlAlreadyQueued) }, resolution: 'rename' });
if (urlMatch) {
newConflicts.push({
id: i.toString(),
fileName: finalFile,
reason: { type: 'url', msg: t($ => $.addDownloads.urlAlreadyQueued) },
resolution: 'rename',
replaceAllowed: !isTransferLocked(urlMatch.status),
existingDownloadId: urlMatch.id
});
} else if (hasBatchConflict) {
newConflicts.push({
id: i.toString(),
@@ -1068,9 +1140,11 @@ export const AddDownloadsModal = () => {
itemsToAdd[idx] = { ...item, file: newName };
} else if (res.resolution === 'replace') {
if (conflict?.reason.type !== 'file' || !conflict.replaceAllowed) {
itemsToAdd[idx] = null;
continue;
if (!conflict?.replaceAllowed) {
const finalFile = item.isMedia
? mediaFileNameForSelectedFormat(item.file, item)
: canonicalizeDownloadFileName(item.file);
throw new Error(t($ => $.addDownloads.cannotReplace, { file: finalFile }));
}
const finalFile = item.isMedia
? mediaFileNameForSelectedFormat(item.file, item)
@@ -1187,6 +1261,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 +1337,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 +1372,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 =>
@@ -1405,8 +1606,14 @@ export const AddDownloadsModal = () => {
}}
role="dialog"
aria-modal="true"
aria-labelledby="add-downloads-modal-title"
>
<div className="app-modal add-download-modal flex flex-col overflow-hidden text-sm">
<div
ref={modalRef}
tabIndex={-1}
data-modal-surface="true"
className="app-modal add-download-modal flex flex-col overflow-hidden text-sm"
>
{/* Main Content Split */}
<div className="flex flex-1 min-h-0 overflow-hidden">
@@ -1419,7 +1626,7 @@ export const AddDownloadsModal = () => {
<div className="flex items-center justify-between">
<div className="add-download-section-title flex items-center gap-2">
<Link size={16} className="text-blue-500" />
{t($ => $.addDownloads.downloadLinks)}
<span id="add-downloads-modal-title">{t($ => $.addDownloads.downloadLinks)}</span>
</div>
</div>
<textarea
@@ -1524,7 +1731,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 +1826,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>
)}
+13 -4
View File
@@ -2,12 +2,14 @@ import React, { useState, useEffect } from 'react';
import { useDownloadStore } from '../store/useDownloadStore';
import { AlertTriangle } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { isTopmostModal, useModalFocus } from '../hooks/useModalFocus';
export const DeleteConfirmationModal: React.FC = () => {
const { t } = useTranslation();
const { deleteModalState, closeDeleteModal, removeDownload } = useDownloadStore();
const [errorMessage, setErrorMessage] = useState('');
const [isRemoving, setIsRemoving] = useState(false);
const modalRef = useModalFocus(deleteModalState.isOpen);
useEffect(() => {
if (deleteModalState.isOpen) {
@@ -19,7 +21,10 @@ export const DeleteConfirmationModal: React.FC = () => {
useEffect(() => {
if (!deleteModalState.isOpen || isRemoving) return;
const handleEscape = (event: KeyboardEvent) => {
if (event.key === 'Escape') closeDeleteModal();
if (event.key === 'Escape' && isTopmostModal(modalRef.current)) {
event.preventDefault();
closeDeleteModal();
}
};
window.addEventListener('keydown', handleEscape);
return () => window.removeEventListener('keydown', handleEscape);
@@ -70,22 +75,26 @@ export const DeleteConfirmationModal: React.FC = () => {
return (
<div
className="app-modal-backdrop fixed inset-0 z-50 flex items-center justify-center bg-black/40"
className="app-modal-backdrop fixed inset-0 z-50 flex items-center justify-center"
onClick={(event) => {
if (event.target === event.currentTarget && !isRemoving) handleCancel();
}}
role="dialog"
aria-modal="true"
aria-labelledby="remove-download-title"
>
<div
className="window-safe-modal bg-bg-modal rounded-xl w-full max-w-md overflow-hidden flex flex-col shadow-2xl border border-border-modal scale-in"
ref={modalRef}
tabIndex={-1}
data-modal-surface="true"
className="app-modal keychain-modal flex w-full max-w-md flex-col overflow-hidden text-sm"
onClick={(e) => e.stopPropagation()}
>
<div className="px-5 py-4 border-b border-border-modal flex items-center gap-3">
<div className="p-2 bg-red-500/10 rounded-full flex items-center justify-center">
<AlertTriangle size={20} className="text-red-400" />
</div>
<h2 className="text-lg font-semibold text-text-primary m-0">{t($ => $.dialogs.removeDownload.title)}</h2>
<h2 id="remove-download-title" className="text-lg font-semibold text-text-primary m-0">{t($ => $.dialogs.removeDownload.title)}</h2>
</div>
<div className="px-5 py-6 flex-1 text-sm text-text-secondary leading-relaxed">
+197 -79
View File
@@ -1,9 +1,16 @@
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 {
canPauseDownload,
canStartDownload,
formatDownloadActionCount,
type DownloadActionCounts,
} from '../utils/downloadActions';
import { useTranslation } from 'react-i18next';
import { useSettingsStore } from '../store/useSettingsStore';
import { formatDateTime } from '../utils/dateTime';
import {
downloadProgressColorClass,
formatDownloadTotal,
@@ -11,8 +18,7 @@ import {
} from '../utils/downloadProgress';
import {
COLUMN_ALIGNMENT_JUSTIFY,
DOWNLOAD_ACTIONS_COLUMN_WIDTH,
DOWNLOAD_ACTIONS_VIEWPORT_INSET,
getDownloadActionPosition,
getColumnGridColumn,
type DownloadColumnAlignment,
type DownloadTableColumnKey
@@ -20,9 +26,7 @@ import {
interface DownloadItemProps {
download: DownloadItemType;
index: number;
queueIndex: number;
queueLength: number;
columnOrder: DownloadTableColumnKey[];
columnAlignments: Record<DownloadTableColumnKey, DownloadColumnAlignment>;
tableGridTemplate: string;
@@ -30,17 +34,22 @@ interface DownloadItemProps {
setContextMenu: (menu: { x: number; y: number; id: string }) => void;
handlePause: (id: string, skipConfirm?: boolean) => void;
handleResume: (item: DownloadItemType) => void;
handlePauseSelected: () => void;
handleResumeSelected: () => void;
getCategoryIcon: (category: string) => React.ReactNode;
isSelected: boolean;
selectedDownloadCount: number;
selectedActionCounts: DownloadActionCounts;
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,
@@ -48,18 +57,61 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
setContextMenu,
handlePause,
handleResume,
handlePauseSelected,
handleResumeSelected,
getCategoryIcon,
isSelected,
selectedDownloadCount,
selectedActionCounts,
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 [isRowKeyboardFocused, setIsRowKeyboardFocused] = React.useState(false);
const [isActionHovered, setIsActionHovered] = React.useState(false);
const [isActionFocused, setIsActionFocused] = React.useState(false);
const [actionPosition, setActionPosition] = React.useState<React.CSSProperties | undefined>();
const isActionVisible = isRowHovered || isRowFocused;
const hasRowActions = download.status !== 'completed';
const isBulkSelection = isSelected && selectedDownloadCount > 1;
const pauseSelectionCount = isBulkSelection && selectedActionCounts.pause > 0
? selectedActionCounts.pause
: null;
const resumeSelectionCount = isBulkSelection && selectedActionCounts.resume > 0
? selectedActionCounts.resume
: null;
const canResumeAction = isBulkSelection
? selectedActionCounts.resume > 0
: canStartDownload(download.status);
const canPauseAction = isBulkSelection
? selectedActionCounts.pause > 0
: canPauseDownload(download.status);
const selectedCountLabel = (count: number | null) => count === null
? null
: t($ => $.downloadTable.summary.selected, { count });
const isActionVisible = hasRowActions && (
isRowHovered ||
isActionHovered ||
isRowKeyboardFocused ||
isActionFocused
);
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 +123,14 @@ 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 rowPadding = Number.parseFloat(getComputedStyle(row).getPropertyValue('--download-row-padding-x'));
const nextPosition = getDownloadActionPosition(
rowRect,
horizontalViewportRect,
verticalViewportRect,
window.innerWidth,
Number.isFinite(rowPadding) ? rowPadding : undefined
);
setActionPosition(previous => (
previous?.top === nextPosition.top &&
@@ -96,14 +143,14 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
));
}, []);
React.useEffect(() => {
React.useLayoutEffect(() => {
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 +174,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 +226,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 +236,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 +270,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')}
>
@@ -261,7 +316,7 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
>
{(download.status === 'queued' || download.status === 'staged') && queueIndex !== -1 ? (
<>
<Clock size={12} className={download.status === 'queued' ? 'animate-pulse shrink-0' : 'shrink-0'} />
<Clock size={12} className={download.status === 'queued' ? 'animate-pulse motion-reduce:animate-none shrink-0' : 'shrink-0'} />
<span className="truncate">
{downloadStatusLabel} #{queueIndex + 1}
</span>
@@ -279,93 +334,121 @@ 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"
className={`download-row-actions main-control-group ${actionPosition?.visibility === 'visible' ? 'is-positioned' : ''}`}
style={{
...actionPosition,
width: `${DOWNLOAD_ACTIONS_COLUMN_WIDTH}px`,
// Preserve the geometry helper's hidden result when the row is
// outside the scroll viewport. A fixed rail is not clipped by the
// list, while visible state remains CSS-owned so pointer handoff
// cannot leave two rails visible.
visibility: actionPosition?.visibility === 'hidden' ? 'hidden' : undefined,
}}
onClick={(e) => e.stopPropagation()}
onDoubleClick={(e) => e.stopPropagation()}
onMouseEnter={() => {
setIsActionHovered(true);
updateActionPosition();
}}
onMouseLeave={() => setIsActionHovered(false)}
onFocusCapture={event => {
const target = event.target;
const keyboardFocused = target instanceof HTMLElement && target.matches(':focus-visible');
setIsActionFocused(keyboardFocused);
if (keyboardFocused) updateActionPosition();
}}
onBlurCapture={event => {
const nextTarget = event.relatedTarget;
if (!(nextTarget instanceof Node) || !event.currentTarget.contains(nextTarget)) {
setIsActionFocused(false);
}
}}
>
{(download.status === 'queued' || download.status === 'staged') && queueIndex !== -1 && (
<>
<button
onClick={() => onMoveInQueue(download.id, 'up')}
disabled={queueIndex === 0}
className="app-icon-button h-7 w-7 disabled:opacity-40"
title={t($ => $.downloads.actions.moveUp)}
>
<ArrowUp size={14} />
</button>
<button
onClick={() => onMoveInQueue(download.id, 'down')}
disabled={queueIndex === queueLength - 1}
className="app-icon-button h-7 w-7 disabled:opacity-40"
title={t($ => $.downloads.actions.moveDown)}
>
<ArrowDown size={14} />
</button>
</>
)}
{canPauseDownload(download.status) && (
<button onClick={() => handlePause(download.id)} className="app-icon-button h-7 w-7" title={t($ => $.downloads.actions.pause)}>
<Pause size={14} fill="currentColor" />
</button>
)}
{canStartDownload(download.status) && (
<button onClick={() => handleResume(download)} className="app-icon-button h-7 w-7" title={download.status === 'paused' ? t($ => $.downloads.actions.resume) : t($ => $.downloads.actions.start)}>
<Play size={14} fill="currentColor" />
</button>
)}
<button
disabled={!canResumeAction}
onClick={() => isBulkSelection ? handleResumeSelected() : handleResume(download)}
className="app-icon-button main-control-button"
title={resumeSelectionCount === null
? download.status === 'paused' ? t($ => $.downloads.actions.resume) : t($ => $.downloads.actions.start)
: `${t($ => $.downloadTable.startResume)} (${selectedCountLabel(resumeSelectionCount)})`}
aria-label={resumeSelectionCount === null
? download.status === 'paused' ? t($ => $.downloads.actions.resume) : t($ => $.downloads.actions.start)
: `${t($ => $.downloadTable.startResume)} (${selectedCountLabel(resumeSelectionCount)})`}
>
<Play size={14} fill="currentColor" />
{resumeSelectionCount !== null ? (
<span className="download-row-action-badge" aria-hidden="true">
{formatDownloadActionCount(resumeSelectionCount)}
</span>
) : null}
</button>
<button
disabled={!canPauseAction}
onClick={() => isBulkSelection ? handlePauseSelected() : handlePause(download.id)}
className="app-icon-button main-control-button"
title={pauseSelectionCount === null
? t($ => $.downloads.actions.pause)
: `${t($ => $.downloads.actions.pause)} (${selectedCountLabel(pauseSelectionCount)})`}
aria-label={pauseSelectionCount === null
? t($ => $.downloads.actions.pause)
: `${t($ => $.downloads.actions.pause)} (${selectedCountLabel(pauseSelectionCount)})`}
>
<Pause size={14} fill="currentColor" />
{pauseSelectionCount !== null ? (
<span className="download-row-action-badge" aria-hidden="true">
{formatDownloadActionCount(pauseSelectionCount)}
</span>
) : null}
</button>
<button
onClick={(e) => {
e.stopPropagation();
setContextMenu({ x: e.clientX, y: e.clientY, id: download.id });
}}
className="app-icon-button h-7 w-7"
className="app-icon-button main-control-button"
title={t($ => $.downloads.actions.options)}
>
<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' : ''} ${isRowKeyboardFocused || isActionFocused ? 'has-keyboard-action-focus' : ''} ${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;
@@ -374,24 +457,59 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
}
}}
onFocus={() => {
setIsRowFocused(true);
updateActionPosition();
const target = document.activeElement;
const keyboardFocused = target instanceof HTMLElement && target.matches(':focus-visible');
setIsRowKeyboardFocused(keyboardFocused);
if (hasRowActions && keyboardFocused) updateActionPosition();
}}
onBlur={event => {
const nextTarget = event.relatedTarget;
if (!(nextTarget instanceof Node) || !event.currentTarget.contains(nextTarget)) {
setIsRowFocused(false);
setIsRowKeyboardFocused(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
+56 -6
View File
@@ -1,5 +1,6 @@
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { isTopmostModal, useModalFocus } from '../hooks/useModalFocus';
export type DuplicateReason = { type: 'url', msg: string } | { type: 'file', msg: string };
type DuplicateResolution = 'rename' | 'replace' | 'skip';
@@ -22,17 +23,30 @@ interface Props {
export const DuplicateResolutionModal = ({ conflicts: initialConflicts, onConfirm, onCancel }: Props) => {
const { t } = useTranslation();
const [conflicts, setConflicts] = useState<DuplicateConflict[]>(initialConflicts);
const modalRef = useModalFocus();
useEffect(() => {
const handleEscape = (event: KeyboardEvent) => {
if (event.key === 'Escape') onCancel();
if (event.key === 'Escape' && isTopmostModal(modalRef.current)) {
event.preventDefault();
onCancel();
}
};
window.addEventListener('keydown', handleEscape);
return () => window.removeEventListener('keydown', handleEscape);
}, [onCancel]);
const updateResolution = (id: string, resolution: DuplicateResolution) => {
setConflicts(conflicts.map(c => c.id === id ? { ...c, resolution } : c));
setConflicts(current => current.map(c => c.id === id ? { ...c, resolution } : c));
};
const canReplaceAll = conflicts.length > 0 && conflicts.every(conflict =>
conflict.replaceAllowed === true
);
const applyResolutionToAll = (resolution: DuplicateResolution) => {
if (resolution === 'replace' && !canReplaceAll) return;
setConflicts(current => current.map(conflict => ({ ...conflict, resolution })));
};
return (
@@ -43,13 +57,49 @@ export const DuplicateResolutionModal = ({ conflicts: initialConflicts, onConfir
}}
role="dialog"
aria-modal="true"
aria-labelledby="duplicate-downloads-title"
>
<div className="app-modal w-[500px] flex flex-col overflow-hidden text-sm">
<div
ref={modalRef}
tabIndex={-1}
data-modal-surface="true"
className="app-modal w-[500px] flex flex-col overflow-hidden text-sm"
>
<div className="p-4 border-b border-border-modal flex flex-col gap-2">
<h2 className="text-lg font-semibold text-text-primary">{t($ => $.dialogs.duplicateDownloads.title)}</h2>
<h2 id="duplicate-downloads-title" className="text-lg font-semibold text-text-primary">{t($ => $.dialogs.duplicateDownloads.title)}</h2>
<p className="text-xs text-text-muted">{t($ => $.dialogs.duplicateDownloads.description)}</p>
</div>
<div className="flex items-center justify-between gap-3 border-b border-border-modal/60 bg-sidebar-bg/30 px-4 py-2.5">
<span className="shrink-0 text-xs font-medium text-text-secondary">
{t($ => $.dialogs.duplicateDownloads.applyToAll)}
</span>
<div className="flex min-w-0 items-center justify-end gap-1.5" role="group" aria-label={t($ => $.dialogs.duplicateDownloads.applyToAll)}>
<button
type="button"
onClick={() => applyResolutionToAll('rename')}
className="app-button px-2.5 py-1 text-[11px]"
>
{t($ => $.dialogs.duplicateDownloads.renameAll)}
</button>
<button
type="button"
onClick={() => applyResolutionToAll('replace')}
disabled={!canReplaceAll}
className="app-button px-2.5 py-1 text-[11px] disabled:cursor-not-allowed disabled:opacity-40"
>
{t($ => $.dialogs.duplicateDownloads.replaceAll)}
</button>
<button
type="button"
onClick={() => applyResolutionToAll('skip')}
className="app-button px-2.5 py-1 text-[11px]"
>
{t($ => $.dialogs.duplicateDownloads.skipAll)}
</button>
</div>
</div>
<div className="max-h-[300px] overflow-y-auto p-4 space-y-3">
{conflicts.map(conflict => (
<div key={conflict.id} className="flex items-center justify-between bg-bg-input/50 p-2.5 rounded-lg border border-border-modal/50 gap-4">
@@ -63,7 +113,7 @@ export const DuplicateResolutionModal = ({ conflicts: initialConflicts, onConfir
className="app-control w-24 shrink-0 px-2 py-1 text-xs"
>
<option value="rename">{t($ => $.dialogs.duplicateDownloads.rename)}</option>
{conflict.reason.type === 'file' && conflict.replaceAllowed && <option value="replace">{t($ => $.dialogs.duplicateDownloads.replace)}</option>}
{conflict.replaceAllowed && <option value="replace">{t($ => $.dialogs.duplicateDownloads.replace)}</option>}
<option value="skip">{t($ => $.dialogs.duplicateDownloads.skip)}</option>
</select>
</div>
+195
View File
@@ -0,0 +1,195 @@
import React, { useCallback, useLayoutEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { ChevronRight } from 'lucide-react';
import type { Queue } from '../store/useDownloadStore';
import { positionFloatingSubmenu, type FloatingSubmenuPosition } from '../utils/floatingPosition';
interface FloatingQueueSubmenuProps {
label: React.ReactNode;
queues: Queue[];
onSelect: (queue: Queue) => void;
}
const CLOSE_DELAY = 140;
export const FloatingQueueSubmenu: React.FC<FloatingQueueSubmenuProps> = ({ label, queues, onSelect }) => {
const triggerRef = useRef<HTMLDivElement>(null);
const menuRef = useRef<HTMLDivElement>(null);
const closeTimerRef = useRef<number | null>(null);
const [isOpen, setIsOpen] = useState(false);
const [position, setPosition] = useState<FloatingSubmenuPosition | null>(null);
const isRtl = typeof document !== 'undefined' && document.documentElement.dir === 'rtl';
const openKey = isRtl ? 'ArrowLeft' : 'ArrowRight';
const closeKey = isRtl ? 'ArrowRight' : 'ArrowLeft';
const clearCloseTimer = useCallback(() => {
if (closeTimerRef.current !== null) {
window.clearTimeout(closeTimerRef.current);
closeTimerRef.current = null;
}
}, []);
const closeMenu = useCallback(() => {
clearCloseTimer();
setIsOpen(false);
setPosition(null);
}, [clearCloseTimer]);
const scheduleClose = useCallback(() => {
clearCloseTimer();
closeTimerRef.current = window.setTimeout(() => {
const activeElement = document.activeElement;
if (
triggerRef.current?.contains(activeElement) ||
menuRef.current?.contains(activeElement)
) {
closeTimerRef.current = null;
return;
}
closeTimerRef.current = null;
setIsOpen(false);
setPosition(null);
}, CLOSE_DELAY);
}, [clearCloseTimer]);
const openMenu = useCallback(() => {
clearCloseTimer();
setIsOpen(true);
}, [clearCloseTimer]);
useLayoutEffect(() => {
if (!isOpen) return;
const updatePosition = () => {
const trigger = triggerRef.current;
const menu = menuRef.current;
if (!trigger || !menu) return;
const triggerRect = trigger.getBoundingClientRect();
const menuRect = menu.getBoundingClientRect();
const nextPosition = positionFloatingSubmenu(
triggerRect,
menu.offsetWidth || menuRect.width,
menu.offsetHeight || menuRect.height,
window.innerWidth,
window.innerHeight,
8,
4,
isRtl ? 'left' : 'right'
);
setPosition(current => (
current?.x === nextPosition.x &&
current.y === nextPosition.y &&
current.side === nextPosition.side
? current
: nextPosition
));
};
updatePosition();
const resizeObserver = typeof ResizeObserver === 'undefined'
? null
: new ResizeObserver(updatePosition);
if (menuRef.current) resizeObserver?.observe(menuRef.current);
window.addEventListener('resize', updatePosition);
window.addEventListener('scroll', updatePosition, true);
return () => {
resizeObserver?.disconnect();
window.removeEventListener('resize', updatePosition);
window.removeEventListener('scroll', updatePosition, true);
};
}, [isOpen, isRtl]);
useLayoutEffect(() => () => clearCloseTimer(), [clearCloseTimer]);
return (
<div
ref={triggerRef}
className="relative"
onMouseEnter={openMenu}
onMouseLeave={scheduleClose}
onFocus={openMenu}
onBlur={event => {
if (!event.currentTarget.contains(event.relatedTarget as Node | null)) {
scheduleClose();
}
}}
>
<button
type="button"
aria-haspopup="menu"
aria-expanded={isOpen}
className="w-full text-start px-3 py-2 hover:bg-item-hover transition-colors flex justify-between items-center"
onClick={event => {
event.stopPropagation();
if (isOpen) closeMenu();
else openMenu();
}}
onKeyDown={event => {
if (event.key === 'Escape') {
event.preventDefault();
event.stopPropagation();
closeMenu();
event.currentTarget.focus();
}
if (event.key === openKey) {
event.preventDefault();
openMenu();
window.requestAnimationFrame(() => menuRef.current?.querySelector<HTMLElement>('[role="menuitem"]')?.focus());
}
if (event.key === closeKey && isOpen) {
event.preventDefault();
event.stopPropagation();
closeMenu();
event.currentTarget.focus();
}
}}
>
{label}
<ChevronRight size={14} aria-hidden="true" className="download-context-menu-chevron" />
</button>
{isOpen && createPortal(
<div
ref={menuRef}
role="menu"
data-side={position?.side}
className={`download-context-submenu fixed min-w-[150px] max-w-[min(280px,calc(100vw-16px))] max-h-[calc(100vh-16px)] overflow-y-auto overflow-x-hidden bg-bg-modal border border-border-modal rounded-lg shadow-lg py-1.5 z-[80] ${position ? 'is-open' : ''}`}
style={{
top: position?.y ?? 8,
left: position?.x ?? 8,
}}
onMouseEnter={clearCloseTimer}
onMouseLeave={scheduleClose}
onFocus={clearCloseTimer}
onBlur={event => {
if (!event.currentTarget.contains(event.relatedTarget as Node | null)) {
scheduleClose();
}
}}
onKeyDown={event => {
if (event.key !== 'Escape' && event.key !== 'ArrowLeft') return;
event.preventDefault();
event.stopPropagation();
closeMenu();
triggerRef.current?.querySelector<HTMLElement>('button')?.focus();
}}
onClick={event => event.stopPropagation()}
>
{queues.map(queue => (
<button
key={queue.id}
type="button"
role="menuitem"
title={queue.name}
className="w-full min-w-0 text-start px-3 py-2 hover:bg-item-hover transition-colors text-[12px] truncate"
onClick={() => onSelect(queue)}
>
{queue.name}
</button>
))}
</div>,
document.body
)}
</div>
);
};
+184 -40
View File
@@ -3,12 +3,12 @@ import { useSettingsStore } from '../store/useSettingsStore';
import { invokeCommand as invoke } from '../ipc';
import { KeyRound, ShieldAlert } from 'lucide-react';
import { usePlatformInfo } from '../utils/platform';
import { getKeychainConsentVersion } from '../utils/keychainStartup';
import { getVersion } from '@tauri-apps/api/app';
import type { PairingTokenHydration } from '../bindings/PairingTokenHydration';
import { useTranslation } from 'react-i18next';
import { isTopmostModal, useModalFocus } from '../hooks/useModalFocus';
const KEYCHAIN_GRANT_TIMEOUT_MS = 30_000;
const KEYCHAIN_GRANT_STATUS_POLL_MS = 100;
type KeychainPermissionModalProps = {
consentVersion: string;
@@ -21,22 +21,62 @@ export const KeychainPermissionModal: React.FC<KeychainPermissionModalProps> = (
const platform = usePlatformInfo();
const [isGranting, setIsGranting] = useState(false);
const [grantRequestPending, setGrantRequestPending] = useState(false);
const [grantRequestWaiting, setGrantRequestWaiting] = useState(false);
const [error, setError] = useState<string | null>(null);
const isMountedRef = useRef(true);
const grantRequestRef = useRef<Promise<PairingTokenHydration> | null>(null);
const grantRequestIdRef = useRef<string | null>(null);
const grantAttemptRef = useRef(0);
const consentVersionRef = useRef(consentVersion);
consentVersionRef.current = consentVersion;
const modalRef = useModalFocus(showKeychainModal);
const applyPersistentGrantToStore = (result: PairingTokenHydration) => {
if (!result.persistent) return;
const grantedVersion = consentVersionRef.current.trim() || useSettingsStore.getState().keychainAccessVersion;
useSettingsStore.setState({
keychainAccessGranted: true,
keychainAccessVersion: grantedVersion,
keychainAccessReady: true,
extensionPairingToken: result.token,
isPairingTokenPersistent: true,
keychainPromptDismissed: false,
showKeychainModal: false
});
};
useEffect(() => {
if (!showKeychainModal || isGranting || grantRequestPending) return;
// React Strict Mode replays effects in development. Reset the guard in
// setup so the replay cleanup cannot leave a live modal permanently
// unable to receive the native grant completion.
isMountedRef.current = true;
return () => {
isMountedRef.current = false;
grantAttemptRef.current += 1;
const requestId = grantRequestIdRef.current;
if (requestId) {
void invoke('abandon_keychain_grant', { requestId })
.then(result => {
// If native acceptance won the same-lock race during unmount,
// keep the global store aligned even though this component is
// no longer mounted.
if (result?.persistent) applyPersistentGrantToStore(result);
})
.catch(() => undefined);
}
};
}, []);
useEffect(() => {
if (!showKeychainModal) return;
const handleEscape = (event: KeyboardEvent) => {
if (event.key !== 'Escape') return;
if (consentVersion.trim()) {
dismissKeychainPrompt(consentVersion);
} else {
useSettingsStore.getState().setShowKeychainModal(false);
}
if (event.key !== 'Escape' || !isTopmostModal(modalRef.current)) return;
event.preventDefault();
void handleLater();
};
window.addEventListener('keydown', handleEscape);
return () => window.removeEventListener('keydown', handleEscape);
}, [consentVersion, dismissKeychainPrompt, grantRequestPending, isGranting, showKeychainModal]);
}, [consentVersion, dismissKeychainPrompt, showKeychainModal]);
if (!showKeychainModal) {
return null;
@@ -66,40 +106,113 @@ export const KeychainPermissionModal: React.FC<KeychainPermissionModalProps> = (
// A native credential-store call cannot be cancelled from the webview.
// Keep the request identity until it settles so a UI timeout cannot
// launch a second OS prompt while the first one is still outstanding.
// The native command owns the process-wide guard, so a remounted dialog
// receives an explicit in-progress error instead of silently doing
// nothing while an earlier native request is still outstanding.
if (grantRequestRef.current) return;
const grantAttempt = ++grantAttemptRef.current;
const grantRequestId = crypto.randomUUID();
setIsGranting(true);
setGrantRequestWaiting(false);
setError(null);
let timeoutId: number | undefined;
let persistentGrantApplied = false;
const applyPersistentGrant = async (result: PairingTokenHydration): Promise<boolean> => {
if (!result.persistent || persistentGrantApplied) return result.persistent;
// A native prompt cannot be cancelled by the webview. If the user
// dismisses this dialog after a timeout, a late result must not turn
// that explicit choice into a silent authorization.
if (!isMountedRef.current || grantAttemptRef.current !== grantAttempt) return false;
persistentGrantApplied = true;
const grantedVersion = consentVersion || getKeychainConsentVersion(await getVersion().catch(() => ''));
// Keep state in sync with the grant result instead of rehydrating
// before Zustand has persisted keychainAccessGranted.
useSettingsStore.setState({
keychainAccessGranted: true,
keychainAccessVersion: grantedVersion,
keychainAccessReady: true,
extensionPairingToken: result.token,
isPairingTokenPersistent: true,
keychainPromptDismissed: false,
showKeychainModal: false
});
applyPersistentGrantToStore(result);
return true;
};
const grantRequest = invoke('grant_keychain_access');
let grantRequest: Promise<PairingTokenHydration>;
try {
grantRequest = (async () => {
// Do not await this IPC request. The native command starts a worker
// and the worker can complete while the original WebView response
// remains unsettled around the OS credential dialog. Completion is
// observed only through the status command below.
let grantStartError: unknown = null;
let grantStartSettled = false;
const grantStart = invoke('grant_keychain_access', { requestId: grantRequestId });
void grantStart.then(
() => {
grantStartSettled = true;
},
(error: unknown) => {
grantStartError = error;
grantStartSettled = true;
},
);
if (isMountedRef.current) {
// The native request has been launched. Do not keep presenting the
// button as if the click itself were still running; the request
// remains pending until the worker reports completion.
setIsGranting(false);
setGrantRequestWaiting(true);
}
while (true) {
if (grantStartError) throw grantStartError;
const status = await invoke('get_keychain_grant_status', { requestId: grantRequestId });
const result = status.result;
if (result?.persistent) {
if (!isMountedRef.current || grantAttemptRef.current !== grantAttempt) {
return (await invoke('abandon_keychain_grant', { requestId: grantRequestId }).catch(() => null)) || {
token: '',
tokenChanged: false,
persistent: false,
error: null
};
}
return await invoke('accept_keychain_grant', { requestId: grantRequestId });
}
if (result?.error) return result;
if (grantStartSettled && !status.started && !status.inProgress) {
return {
token: '',
tokenChanged: false,
persistent: false,
error: t($ => $.keychain.unavailable, { store: siteCredentialStoreName })
};
}
await new Promise<void>(resolve => {
window.setTimeout(resolve, KEYCHAIN_GRANT_STATUS_POLL_MS);
});
}
})();
} catch (error) {
if (isMountedRef.current) {
setIsGranting(false);
setError(error instanceof Error ? error.message : String(error));
}
return;
}
grantRequestRef.current = grantRequest;
grantRequestIdRef.current = grantRequestId;
setGrantRequestPending(true);
void grantRequest.then(
() => {
if (grantRequestRef.current === grantRequest) grantRequestRef.current = null;
setGrantRequestPending(false);
if (grantRequestRef.current === grantRequest) {
grantRequestRef.current = null;
grantRequestIdRef.current = null;
}
if (isMountedRef.current) {
setGrantRequestPending(false);
setGrantRequestWaiting(false);
}
},
() => {
if (grantRequestRef.current === grantRequest) grantRequestRef.current = null;
setGrantRequestPending(false);
if (grantRequestRef.current === grantRequest) {
grantRequestRef.current = null;
grantRequestIdRef.current = null;
}
if (isMountedRef.current) {
setGrantRequestPending(false);
setGrantRequestWaiting(false);
}
}
);
// A native credential-store call cannot be cancelled by the webview. Keep
@@ -112,23 +225,45 @@ export const KeychainPermissionModal: React.FC<KeychainPermissionModalProps> = (
grantRequest,
new Promise<never>((_, reject) => {
timeoutId = window.setTimeout(
() => reject(new Error(t($ => $.keychain.timeout))),
() => {
if (isMountedRef.current) {
// The native request remains in flight and cannot be
// cancelled, but the webview must stop presenting the
// operation as an endlessly active click. Keep the request
// identity so a second OS prompt cannot be started, while
// allowing the user to dismiss this explanation.
setGrantRequestWaiting(true);
setIsGranting(false);
}
reject(new Error(t($ => $.keychain.timeout)));
},
KEYCHAIN_GRANT_TIMEOUT_MS
);
})
]);
if (!(await applyPersistentGrant(result))) {
setError(result.error || t($ => $.keychain.unavailable, { store: siteCredentialStoreName }));
if (isMountedRef.current) {
setError(result.error || t($ => $.keychain.unavailable, { store: siteCredentialStoreName }));
}
}
} catch (e: any) {
setError(e.toString());
if (isMountedRef.current) setError(e.toString());
} finally {
if (timeoutId !== undefined) window.clearTimeout(timeoutId);
setIsGranting(false);
if (isMountedRef.current) setIsGranting(false);
}
};
const handleLater = () => {
const handleLater = async () => {
const requestId = grantRequestIdRef.current;
grantAttemptRef.current += 1;
if (requestId) {
const accepted = await invoke('abandon_keychain_grant', { requestId }).catch(() => null);
if (accepted?.persistent) {
applyPersistentGrantToStore(accepted);
return;
}
}
if (consentVersion.trim()) {
dismissKeychainPrompt(consentVersion);
} else {
@@ -142,22 +277,26 @@ export const KeychainPermissionModal: React.FC<KeychainPermissionModalProps> = (
return (
<div
className="app-modal-backdrop fixed inset-0 z-50 flex items-center justify-center bg-black/40"
className="app-modal-backdrop fixed inset-0 z-[80] flex items-center justify-center"
onClick={(event) => {
if (event.target === event.currentTarget && !isGranting && !grantRequestPending) handleLater();
if (event.target === event.currentTarget && isTopmostModal(modalRef.current)) void handleLater();
}}
role="dialog"
aria-modal="true"
aria-labelledby="keychain-permission-title"
>
<div
className="window-safe-modal bg-bg-modal rounded-xl w-full max-w-md overflow-hidden flex flex-col shadow-2xl border border-border-modal scale-in"
ref={modalRef}
tabIndex={-1}
data-modal-surface="true"
className="app-modal keychain-modal flex w-full max-w-md flex-col overflow-hidden text-sm"
onClick={(e) => e.stopPropagation()}
>
<div className="px-5 py-4 border-b border-border-modal flex items-center gap-3">
<div className="p-2 bg-blue-500/10 rounded-full items-center justify-center">
<KeyRound size={20} className="text-blue-500" />
</div>
<h2 className="text-lg font-semibold text-text-primary m-0">{t($ => $.keychain.title)}</h2>
<h2 id="keychain-permission-title" className="text-lg font-semibold text-text-primary m-0">{t($ => $.keychain.title)}</h2>
</div>
<div className="px-5 py-6 flex-1 min-h-0 overflow-y-auto text-sm text-text-secondary leading-relaxed space-y-4">
@@ -198,18 +337,23 @@ export const KeychainPermissionModal: React.FC<KeychainPermissionModalProps> = (
<div className="px-5 py-4 border-t border-border-modal flex justify-end gap-3 bg-bg-modal-accent">
<button
type="button"
onClick={handleLater}
disabled={isGranting || grantRequestPending}
className="px-4 py-2 rounded-lg text-sm font-medium transition-colors text-text-secondary hover:bg-item-hover hover:text-text-primary disabled:opacity-50"
className="keychain-modal-action px-4 py-2 rounded-lg text-sm font-medium text-text-secondary hover:bg-item-hover hover:text-text-primary disabled:opacity-50"
>
{t($ => $.keychain.later)}
</button>
<button
type="button"
onClick={handleGrant}
disabled={isGranting || grantRequestPending}
className="px-4 py-2 rounded-lg text-sm font-medium transition-colors bg-accent text-white hover:bg-accent/90 disabled:opacity-50"
className="keychain-modal-action px-4 py-2 rounded-lg text-sm font-medium bg-accent text-accent-foreground hover:bg-accent/90 disabled:opacity-50"
>
{isGranting || grantRequestPending ? t($ => $.keychain.enabling) : grantLabel}
{isGranting
? t($ => $.keychain.enabling)
: grantRequestWaiting && grantRequestPending
? t($ => $.keychain.waitingForPrompt)
: grantLabel}
</button>
</div>
</div>
+48 -6
View File
@@ -1,4 +1,5 @@
import { useEffect, useRef, useState } from 'react';
import { useEffect, useLayoutEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { invokeCommand as invoke } from '../ipc';
import { save } from '@tauri-apps/plugin-dialog';
import { homeDir } from '@tauri-apps/api/path';
@@ -8,6 +9,7 @@ import { WindowDragRegion } from './WindowDragRegion';
import { useToast } from '../contexts/ToastContext';
import { useSettingsStore } from '../store/useSettingsStore';
import { useTranslation } from 'react-i18next';
import { clampFloatingPosition } from '../utils/floatingPosition';
import {
MAX_LOG_LINES,
appendBoundedLogEntries,
@@ -26,6 +28,8 @@ export default function LogsView() {
const [logs, setLogs] = useState<LogEntry[]>([]);
const [levelFilter, setLevelFilter] = useState<LogEntry['level'] | 'All'>('All');
const [contextMenu, setContextMenu] = useState<{ x: number; y: number; text: string } | null>(null);
const [contextMenuPosition, setContextMenuPosition] = useState<{ x: number; y: number } | null>(null);
const contextMenuRef = useRef<HTMLDivElement>(null);
const [pageVisible, setPageVisible] = useState(() => document.visibilityState !== 'hidden');
const homeDirectoryRef = useRef('');
const scrollRef = useRef<HTMLDivElement>(null);
@@ -161,12 +165,48 @@ export default function LogsView() {
e.preventDefault();
const selection = window.getSelection()?.toString();
if (selection && selection.trim().length > 0) {
const position = clampFloatingPosition(e.clientX, e.clientY, 150, 50, window.innerWidth, window.innerHeight);
setContextMenuPosition(position);
setContextMenu({ x: e.clientX, y: e.clientY, text: selection });
} else {
setContextMenu(null);
}
};
useLayoutEffect(() => {
if (!contextMenu || !contextMenuRef.current) return;
const updateContextMenuPosition = () => {
const menu = contextMenuRef.current;
if (!menu) return;
const rect = menu.getBoundingClientRect();
const nextPosition = clampFloatingPosition(
contextMenu.x,
contextMenu.y,
menu.offsetWidth || rect.width,
menu.offsetHeight || rect.height,
window.innerWidth,
window.innerHeight
);
setContextMenuPosition(current => (
current?.x === nextPosition.x && current.y === nextPosition.y
? current
: nextPosition
));
};
updateContextMenuPosition();
const resizeObserver = typeof ResizeObserver === 'undefined'
? null
: new ResizeObserver(updateContextMenuPosition);
resizeObserver?.observe(contextMenuRef.current);
window.addEventListener('resize', updateContextMenuPosition);
return () => {
resizeObserver?.disconnect();
window.removeEventListener('resize', updateContextMenuPosition);
};
}, [contextMenu]);
const handleCopy = async () => {
if (contextMenu?.text) {
try {
@@ -337,7 +377,7 @@ export default function LogsView() {
<div
ref={scrollRef}
onContextMenu={handleContextMenu}
className="logs-console flex-1 overflow-y-auto p-3 font-mono text-[11px] leading-[1.5] select-text"
className="app-page-transition-content logs-console flex-1 overflow-y-auto p-3 font-mono text-[11px] leading-[1.5] select-text"
style={{ userSelect: 'text', WebkitUserSelect: 'text' }}
>
{logs.length === 0 && (
@@ -354,11 +394,12 @@ export default function LogsView() {
</div>
{/* Context Menu */}
{contextMenu && (
{contextMenu && createPortal(
<div
role="menu"
className="app-modal fixed z-50 min-w-[150px] overflow-visible py-1.5 text-[12px] font-medium text-text-primary"
style={{ left: Math.min(contextMenu.x, window.innerWidth - 150), top: Math.min(contextMenu.y, window.innerHeight - 50) }}
ref={contextMenuRef}
className="app-modal fixed z-[70] min-w-[150px] max-h-[calc(100vh-16px)] overflow-y-auto overflow-x-hidden py-1.5 text-[12px] font-medium text-text-primary"
style={{ left: contextMenuPosition?.x, top: contextMenuPosition?.y }}
onClick={(e) => e.stopPropagation()}
>
<button
@@ -368,7 +409,8 @@ export default function LogsView() {
<Copy size={13} className="me-2 text-text-secondary" />
{t($ => $.logs.copy)}
</button>
</div>
</div>,
document.body
)}
</div>
);
+230 -22
View File
@@ -1,4 +1,4 @@
import { useState, useEffect } from 'react';
import { useState, useEffect, useRef } from 'react';
import { useDownloadStore, DownloadItem } from '../store/useDownloadStore';
import { useDownloadProgressStore } from '../store/downloadProgressStore';
import { useShallow } from 'zustand/react/shallow';
@@ -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,26 @@ import {
} from '../utils/downloadProgress';
import { resolveDownloadConnections } from '../utils/downloads';
import { useTranslation } from 'react-i18next';
import { formatDateTime, type CalendarPreference } from '../utils/dateTime';
import { isTopmostModal, useModalFocus } from '../hooks/useModalFocus';
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 +62,7 @@ export const PropertiesModal = () => {
: undefined
));
const { baseDownloadFolder, perServerConnections } = useSettingsStore();
const { baseDownloadFolder, perServerConnections, calendarPreference } = useSettingsStore();
// Form states
const [url, setUrl] = useState('');
@@ -65,6 +73,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 +89,15 @@ export const PropertiesModal = () => {
const [mirrors, setMirrors] = useState('');
const [errorMessage, setErrorMessage] = useState('');
const [isPauseResumePending, setIsPauseResumePending] = useState(false);
const actionRequestRef = useRef(0);
const modalRef = useModalFocus(Boolean(selectedPropertiesDownloadId && item));
useEffect(() => {
// Invalidate native pickers and transfer-control results when the modal
// switches items, closes, or reopens for the same download.
actionRequestRef.current += 1;
}, [selectedPropertiesDownloadId]);
useEffect(() => {
if (selectedPropertiesDownloadId) {
@@ -89,10 +108,19 @@ export const PropertiesModal = () => {
if (activeItem.destination) {
setSaveLocation(activeItem.destination);
} else {
const propertiesDownloadId = selectedPropertiesDownloadId;
const requestId = actionRequestRef.current;
void resolveCategoryDestination(
useSettingsStore.getState(),
activeItem.category
).then(setSaveLocation);
).then(location => {
if (
requestId === actionRequestRef.current
&& useDownloadStore.getState().selectedPropertiesDownloadId === propertiesDownloadId
) {
setSaveLocation(location);
}
});
}
setConnections(resolveDownloadConnections(activeItem.connections, perServerConnections));
setConnectionsDirty(false);
@@ -135,6 +163,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);
@@ -146,7 +179,10 @@ export const PropertiesModal = () => {
useEffect(() => {
if (!selectedPropertiesDownloadId) return;
const handleEscape = (event: KeyboardEvent) => {
if (event.key === 'Escape') setSelectedPropertiesDownloadId(null);
if (event.key === 'Escape' && isTopmostModal(modalRef.current)) {
event.preventDefault();
setSelectedPropertiesDownloadId(null);
}
};
window.addEventListener('keydown', handleEscape);
return () => window.removeEventListener('keydown', handleEscape);
@@ -156,13 +192,20 @@ export const PropertiesModal = () => {
const handleBrowse = async () => {
if (identityLocked) return;
const requestId = ++actionRequestRef.current;
const propertiesDownloadId = item.id;
try {
const selected = await open({
directory: true,
multiple: false,
defaultPath: saveLocation.startsWith('~') ? undefined : saveLocation
});
if (selected && typeof selected === 'string') {
if (
selected
&& typeof selected === 'string'
&& requestId === actionRequestRef.current
&& useDownloadStore.getState().selectedPropertiesDownloadId === propertiesDownloadId
) {
setSaveLocation(selected);
}
} catch (e) {
@@ -196,17 +239,92 @@ export const PropertiesModal = () => {
: {}),
};
const requestId = ++actionRequestRef.current;
try {
setErrorMessage('');
await useDownloadStore.getState().applyProperties(item.id, updates);
setSelectedPropertiesDownloadId(null);
if (
requestId === actionRequestRef.current
&& useDownloadStore.getState().selectedPropertiesDownloadId === item.id
) {
setSelectedPropertiesDownloadId(null);
}
} catch (e) {
setErrorMessage(e instanceof Error ? e.message : String(e));
if (requestId === actionRequestRef.current && useDownloadStore.getState().selectedPropertiesDownloadId === item.id) {
setErrorMessage(e instanceof Error ? e.message : String(e));
}
}
};
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('');
const requestId = ++actionRequestRef.current;
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 });
if (requestId === actionRequestRef.current && useDownloadStore.getState().selectedPropertiesDownloadId === item.id) {
setErrorMessage(t($ => $.downloadTable.interactionError, { message, detail }));
}
} finally {
if (requestId === actionRequestRef.current && useDownloadStore.getState().selectedPropertiesDownloadId === item.id) {
setIsPauseResumePending(false);
}
}
};
const handleLiveSpeedLimit = async (limit: string | null) => {
if (isLiveSpeedLimitPending || item.isMedia || !['downloading', 'retrying'].includes(item.status)) return;
setErrorMessage('');
const requestId = ++actionRequestRef.current;
setIsLiveSpeedLimitPending(true);
try {
await useDownloadStore.getState().setDownloadSpeedLimit(item.id, limit);
if (
limit === null
&& requestId === actionRequestRef.current
&& useDownloadStore.getState().selectedPropertiesDownloadId === item.id
) {
setLiveSpeedLimitValue('');
}
} catch (error) {
if (requestId === actionRequestRef.current && useDownloadStore.getState().selectedPropertiesDownloadId === item.id) {
setErrorMessage(t($ => $.properties.liveSpeedLimitFailed, {
detail: error instanceof Error ? error.message : String(error)
}));
}
} finally {
if (requestId === actionRequestRef.current && useDownloadStore.getState().selectedPropertiesDownloadId === item.id) {
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 +380,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 ?? '',
@@ -290,13 +413,19 @@ export const PropertiesModal = () => {
}}
role="dialog"
aria-modal="true"
aria-labelledby="properties-modal-title"
>
<div className="app-modal w-[720px] h-[580px] flex flex-col overflow-hidden text-sm">
<div
ref={modalRef}
tabIndex={-1}
data-modal-surface="true"
className="app-modal properties-modal w-[720px] h-[580px] flex flex-col overflow-hidden text-sm"
>
{/* Header Summary */}
<div className="p-4 px-5 bg-sidebar-bg/50">
<div className="flex items-center justify-between mb-3">
<h2 className="text-base font-semibold truncate text-text-primary pr-4">{item.fileName}</h2>
<h2 id="properties-modal-title" className="text-base font-semibold truncate text-text-primary pr-4">{item.fileName}</h2>
<span className={`flex items-center gap-1.5 text-xs font-semibold tracking-wide uppercase ${statusColor}`}>
<StatusIcon size={14} />
{statusLabel}
@@ -334,9 +463,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 +529,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 +706,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' : ''}`}
+16 -10
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]
@@ -297,7 +303,7 @@ export default function SchedulerView() {
</div>
</div>
<div className="flex-1 overflow-y-auto p-6">
<div className="app-page-transition-content flex-1 overflow-y-auto p-6">
<div className={`max-w-[760px] space-y-4 ${draft.enabled ? '' : 'opacity-50'}`}>
<section className="app-card p-5">
<div className="mb-5 flex items-center gap-2 font-semibold text-text-primary">
@@ -336,7 +342,7 @@ export default function SchedulerView() {
disabled={!draft.enabled}
onClick={() => toggleDay(day.value)}
className={`h-8 w-8 rounded-full text-[12px] font-semibold ${
selected ? 'bg-accent text-white' : 'bg-bg-input text-text-primary hover:bg-item-hover'
selected ? 'bg-accent text-accent-foreground' : 'bg-bg-input text-text-primary hover:bg-item-hover'
}`}
>
{t($ => $.scheduler.days[day.key])}
+135 -42
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;
@@ -700,10 +725,11 @@ runEngineChecks(false);
<button
type="button"
data-active={active}
aria-current={active ? 'page' : undefined}
onClick={() => settings.setActiveSettingsTab(type)}
className={`settings-tab-button flex min-w-0 flex-1 flex-col items-center justify-center px-1 text-center cursor-default ${
className={`settings-tab-button flex min-w-0 flex-1 flex-col items-center justify-center px-1 text-center cursor-pointer ${
active
? 'text-white'
? 'text-accent-foreground'
: 'text-text-primary hover:bg-item-hover'
}`}
>
@@ -715,10 +741,9 @@ runEngineChecks(false);
return (
<div className="settings-view flex-1 flex flex-col relative h-full overflow-hidden">
<WindowDragRegion />
{/* SwiftUI SettingsPaneContainer-style horizontal tab strip */}
<div className="settings-toolbar">
<WindowDragRegion />
<div className={`settings-tab-strip flex items-stretch gap-1 ${
isSidebarOnRight ? 'settings-tab-strip--sidebar-right' : ''
}`}>
@@ -731,6 +756,7 @@ runEngineChecks(false);
{/* Content Area */}
<div className="settings-scroll flex-1 overflow-y-auto">
<div className="settings-content-shell w-full">
<div key={activeTab} className="settings-page-transition">
<h1 className="settings-title text-text-primary">{activeTabLabel}</h1>
<div className="settings-content max-w-[720px]">
@@ -853,40 +879,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 +916,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 +963,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' && (
@@ -1371,7 +1450,7 @@ runEngineChecks(false);
type="button"
onClick={handleAddLogin}
disabled={isSavingLogin}
className="bg-accent hover:bg-accent text-white px-4 py-1.5 rounded-lg text-xs font-semibold shadow flex items-center gap-1.5"
className="bg-accent hover:bg-accent text-accent-foreground px-4 py-1.5 rounded-lg text-xs font-semibold shadow flex items-center gap-1.5"
>
<Plus size={14} /> {isSavingLogin ? t($ => $.settings.siteLogins.saving) : t($ => $.settings.siteLogins.addLogin)}
</button>
@@ -1397,6 +1476,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>
)}
@@ -1521,7 +1613,7 @@ className="app-button px-3 py-1.5 text-[12px] flex items-center gap-1.5 disabled
</p>
<button
onClick={() => settings.setShowKeychainModal(true)}
className="px-4 py-1.5 rounded-md text-xs font-medium transition-colors bg-accent text-white hover:bg-accent/90 shadow-sm"
className="px-4 py-1.5 rounded-md text-xs font-medium transition-colors bg-accent text-accent-foreground hover:bg-accent/90 shadow-sm"
>
{platform.portable ? t($ => $.settings.integrations.reviewPortablePairing) : t($ => $.settings.integrations.grantCredentialAccess)}
</button>
@@ -1545,7 +1637,7 @@ className="app-button px-3 py-1.5 text-[12px] flex items-center gap-1.5 disabled
<div className="space-y-2">
<button
onClick={() => void copyToken()}
className="w-full bg-accent hover:bg-accent text-white font-medium py-1 px-2 rounded text-[11px] flex items-center justify-center gap-1 shadow transition-colors"
className="w-full bg-accent hover:bg-accent text-accent-foreground font-medium py-1 px-2 rounded text-[11px] flex items-center justify-center gap-1 shadow transition-colors"
>
<Copy size={11} /> {t($ => $.settings.integrations.copyToken)}
</button>
@@ -1708,7 +1800,7 @@ className="app-button px-3 py-1.5 text-[12px] flex items-center gap-1.5 disabled
role="switch"
aria-checked={settings.autoCheckUpdates}
onClick={() => settings.setAutoCheckUpdates(!settings.autoCheckUpdates)}
className={`relative inline-flex h-5 w-9 shrink-0 cursor-default items-center rounded-full transition-colors duration-200 ease-in-out border border-transparent ${settings.autoCheckUpdates ? 'bg-accent' : 'bg-border-color'}`}
className={`relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full transition-colors duration-200 ease-in-out border border-transparent ${settings.autoCheckUpdates ? 'bg-accent' : 'bg-border-color'}`}
>
<span className={`pointer-events-none inline-block h-4 w-4 transform rounded-full bg-white shadow transition duration-200 ease-in-out ${settings.autoCheckUpdates ? 'translate-x-4' : 'translate-x-0'}`} />
</button>
@@ -1739,6 +1831,7 @@ className="app-button px-3 py-1.5 text-[12px] flex items-center gap-1.5 disabled
)}
</div>
</div>
</div>
</div>
+96 -10
View File
@@ -1,4 +1,5 @@
import React, { useState, useEffect, useRef } from 'react';
import React, { useState, useEffect, useLayoutEffect, useRef } from 'react';
import { createPortal } from 'react-dom';
import {
Inbox, Zap, CheckCircle2, CircleDashed,
Film, Music, FileText, Box, Image as ImageIcon, Archive, FileQuestion,
@@ -11,6 +12,7 @@ import { ActiveView, useSettingsStore } from '../store/useSettingsStore';
import { WindowDragRegion } from './WindowDragRegion';
import { useToast } from '../contexts/ToastContext';
import { isTransferActiveStatus } from '../utils/downloads';
import { clampFloatingPosition } from '../utils/floatingPosition';
import { useTranslation } from 'react-i18next';
export type SidebarFilter = 'all' | 'active' | 'completed' | 'unfinished' | DownloadCategory | 'settings' | string;
@@ -22,16 +24,18 @@ 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();
const { t, i18n } = useTranslation();
const [isAddingQueue, setIsAddingQueue] = useState(false);
const [newQueueName, setNewQueueName] = useState('');
const [renamingQueueId, setRenamingQueueId] = useState<string | null>(null);
const [editingQueueName, setEditingQueueName] = useState('');
const [contextMenu, setContextMenu] = useState<{ x: number; y: number; id: string } | null>(null);
const contextMenuRef = useRef<HTMLDivElement>(null);
const [contextMenuPosition, setContextMenuPosition] = useState<{ x: number; y: number } | null>(null);
const [foldersCollapsed, setFoldersCollapsed] = useState(() =>
window.localStorage.getItem('firelink-folders-collapsed') === 'true'
);
@@ -49,6 +53,46 @@ export const Sidebar: React.FC<SidebarProps> = (props) => {
const rejectedAddQueueNameRef = useRef<string | null>(null);
const rejectedRenameRef = useRef<{ queueId: string; name: string } | null>(null);
useLayoutEffect(() => {
if (!contextMenu) return;
if (!queues.some(queue => queue.id === contextMenu.id)) {
setContextMenu(null);
setContextMenuPosition(null);
return;
}
const updateContextMenuPosition = () => {
const menu = contextMenuRef.current;
if (!menu) return;
const rect = menu.getBoundingClientRect();
const nextPosition = clampFloatingPosition(
contextMenu.x,
contextMenu.y,
rect.width,
rect.height,
window.innerWidth,
window.innerHeight
);
setContextMenuPosition(current => (
current?.x === nextPosition.x && current.y === nextPosition.y
? current
: nextPosition
));
};
updateContextMenuPosition();
const resizeObserver = typeof ResizeObserver === 'undefined'
? null
: new ResizeObserver(updateContextMenuPosition);
const menu = contextMenuRef.current;
if (menu) resizeObserver?.observe(menu);
window.addEventListener('resize', updateContextMenuPosition);
return () => {
resizeObserver?.disconnect();
window.removeEventListener('resize', updateContextMenuPosition);
};
}, [contextMenu, queues, i18n.language]);
useEffect(() => {
const handleCloseMenu = () => setContextMenu(null);
const handleEscape = (event: KeyboardEvent) => {
@@ -156,6 +200,7 @@ export const Sidebar: React.FC<SidebarProps> = (props) => {
const handleQueueContextMenu = (e: React.MouseEvent, id: string) => {
e.preventDefault();
e.stopPropagation();
setContextMenuPosition(null);
setContextMenu({ x: e.clientX, y: e.clientY, id });
};
@@ -426,18 +471,19 @@ export const Sidebar: React.FC<SidebarProps> = (props) => {
onClick={() => setActiveView('settings')}
className="sidebar-nav-item sidebar-settings-button group flex w-full items-center text-[13px] text-start cursor-default font-medium transition-colors"
>
<Settings className={`w-[18px] h-[18px] me-3 shrink-0 ${activeView === 'settings' ? 'text-white' : 'text-text-muted'}`} strokeWidth={activeView === 'settings' ? 2.5 : 2} />
<Settings className={`w-[18px] h-[18px] me-3 shrink-0 ${activeView === 'settings' ? 'text-accent-foreground' : 'text-text-muted'}`} strokeWidth={activeView === 'settings' ? 2.5 : 2} />
<span className="sidebar-nav-label">{t($ => $.navigation.settings)}</span>
</button>
</div>
{contextMenu && (
{contextMenu && createPortal(
<div
role="menu"
className="fixed z-50 w-48 py-1 rounded-xl shadow-lg border border-border-modal bg-bg-context-menu backdrop-blur-xl animate-fade-in text-[13px] text-text-primary overflow-hidden"
style={{
top: Math.min(contextMenu.y, window.innerHeight - 200),
left: Math.min(contextMenu.x, window.innerWidth - 200)
ref={contextMenuRef}
className="fixed z-[70] w-48 py-1 rounded-xl shadow-lg border border-border-modal bg-bg-context-menu backdrop-blur-xl animate-fade-in text-[13px] text-text-primary overflow-hidden"
style={{
top: contextMenuPosition?.y ?? contextMenu.y,
left: contextMenuPosition?.x ?? contextMenu.x,
}}
onClick={e => e.stopPropagation()}
>
@@ -475,6 +521,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"
@@ -522,7 +607,8 @@ export const Sidebar: React.FC<SidebarProps> = (props) => {
{t($ => $.actions.deleteQueue)}
</button>
)}
</div>
</div>,
document.body
)}
</aside>
);
+2 -2
View File
@@ -223,7 +223,7 @@ export default function SpeedLimiterView() {
</button>
</div>
<div className="flex-1 overflow-y-auto p-6">
<div className="app-page-transition-content flex-1 overflow-y-auto p-6">
<section className={`app-card max-w-[760px] p-5 ${enabled ? '' : 'opacity-55'}`}>
<div className="mb-2 flex items-center gap-2 font-semibold text-text-primary">
<Gauge size={18} className="text-accent" /> {t($ => $.speedLimiter.globalSpeedLimit)}
@@ -250,7 +250,7 @@ export default function SpeedLimiterView() {
disabled={!enabled || isSaving}
onClick={() => changeUnit(option)}
className={`rounded px-3 py-1.5 text-[12px] font-medium ${
unit === option ? 'bg-accent text-white' : 'text-text-secondary hover:bg-item-hover'
unit === option ? 'bg-accent text-accent-foreground' : 'text-text-secondary hover:bg-item-hover'
}`}
>
{option}
+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
+124
View File
@@ -0,0 +1,124 @@
import { useEffect, useRef } from 'react';
const FOCUSABLE_SELECTOR = [
'a[href]',
'area[href]',
'button:not([disabled])',
'input:not([disabled]):not([type="hidden"])',
'select:not([disabled])',
'textarea:not([disabled])',
'iframe',
'object',
'embed',
'[contenteditable="true"]',
'[tabindex]:not([tabindex="-1"])'
].join(',');
const getFocusableElements = (surface: HTMLElement): HTMLElement[] => {
return Array.from(surface.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR)).filter(element => {
const style = window.getComputedStyle(element);
return style.display !== 'none'
&& style.visibility !== 'hidden'
&& !element.closest('[aria-hidden="true"], [inert]');
});
};
const getTopmostModal = (): HTMLElement | null => {
const surfaces = Array.from(document.querySelectorAll<HTMLElement>('[data-modal-surface="true"]'));
let topmost: HTMLElement | null = null;
let topmostZIndex = Number.NEGATIVE_INFINITY;
for (const surface of surfaces) {
// Modal stacking is applied to the backdrop, not the panel itself. This
// matters when a child modal (for example duplicate resolution) is
// rendered before its parent panel in the DOM.
const stackingElement = surface.closest<HTMLElement>('.app-modal-backdrop') || surface;
const parsedZIndex = Number.parseInt(window.getComputedStyle(stackingElement).zIndex, 10);
const zIndex = Number.isFinite(parsedZIndex) ? parsedZIndex : 0;
// Later DOM order wins ties, matching browser painting order for equal
// z-index surfaces and keeping the stack deterministic.
if (zIndex >= topmostZIndex) {
topmost = surface;
topmostZIndex = zIndex;
}
}
return topmost;
};
export const isTopmostModal = (surface: HTMLElement | null): boolean =>
surface !== null && getTopmostModal() === surface;
export const useModalFocus = (enabled = true) => {
const surfaceRef = useRef<HTMLDivElement>(null);
const previousFocusRef = useRef<HTMLElement | null>(null);
useEffect(() => {
if (!enabled) return;
const surface = surfaceRef.current;
if (!surface) return;
const activeElement = document.activeElement;
previousFocusRef.current = activeElement instanceof HTMLElement && !surface.contains(activeElement)
? activeElement
: null;
const focusInitialElement = () => {
if (!isTopmostModal(surface)) return;
// Keep initial focus on the dialog itself. This avoids visually selecting
// an action when a modal opens from a pointer interaction; Tab still
// enters the first available control through the trap below.
const initialElement = surface.querySelector<HTMLElement>('[data-modal-autofocus="true"]')
|| surface;
initialElement.focus({ preventScroll: true });
};
const initialFocusFrame = window.requestAnimationFrame(focusInitialElement);
const handleFocusIn = (event: FocusEvent) => {
if (!isTopmostModal(surface) || surface.contains(event.target as Node)) return;
focusInitialElement();
};
const handleKeyDown = (event: KeyboardEvent) => {
if (!isTopmostModal(surface) || event.key !== 'Tab') return;
const focusableElements = getFocusableElements(surface);
if (focusableElements.length === 0) {
event.preventDefault();
surface.focus({ preventScroll: true });
return;
}
const currentIndex = focusableElements.indexOf(document.activeElement as HTMLElement);
const isLeavingForward = !event.shiftKey && (currentIndex < 0 || currentIndex === focusableElements.length - 1);
const isLeavingBackward = event.shiftKey && currentIndex <= 0;
if (!isLeavingForward && !isLeavingBackward) return;
event.preventDefault();
const nextElement = event.shiftKey
? focusableElements[focusableElements.length - 1]
: focusableElements[0];
nextElement.focus({ preventScroll: true });
};
document.addEventListener('focusin', handleFocusIn);
document.addEventListener('keydown', handleKeyDown);
return () => {
window.cancelAnimationFrame(initialFocusFrame);
document.removeEventListener('focusin', handleFocusIn);
document.removeEventListener('keydown', handleKeyDown);
const previousFocus = previousFocusRef.current;
if (!previousFocus?.isConnected) return;
window.requestAnimationFrame(() => {
const topmostModal = getTopmostModal();
if (!topmostModal || topmostModal.contains(previousFocus)) {
previousFocus.focus({ preventScroll: true });
}
});
};
}, [enabled]);
return surfaceRef;
};
+65
View File
@@ -50,6 +50,10 @@ const common = {
rename: 'Rename',
replace: 'Replace',
skip: 'Skip',
applyToAll: 'Apply to all',
renameAll: 'Rename all',
replaceAll: 'Replace all',
skipAll: 'Skip all',
},
removeDownload: {
title: 'Remove Download',
@@ -216,6 +220,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',
@@ -285,6 +296,7 @@ const common = {
enableFromSettings: 'You can enable storage anytime from Settings > Integrations.',
later: 'Later',
enabling: 'Enabling...',
waitingForPrompt: 'Waiting for system prompt…',
timeout: 'Credential storage request timed out. You can select Later and try again.',
unavailable: '{{store}} is unavailable.',
accessRequired: 'Grant credential-store access before regenerating the pairing token.',
@@ -326,6 +338,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 +368,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 +385,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 +476,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 +590,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 +613,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 +628,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 +743,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',
+70 -5
View File
@@ -49,7 +49,11 @@ const fa = {
description: 'برخی از دانلودهایی که اضافه می‌کنید از قبل در صف یا روی دیسک وجود دارند. لطفاً نحوه حل این تداخل‌ها را انتخاب کنید.',
rename: 'تغییر نام',
replace: 'جایگزینی',
skip: 'رد شدن',
skip: 'رد کردن',
applyToAll: 'اعمال برای همه',
renameAll: 'تغییر نام همه',
replaceAll: 'جایگزینی همه',
skipAll: 'رد کردن همه',
},
removeDownload: {
title: 'حذف دانلود',
@@ -166,7 +170,7 @@ const fa = {
afterCompletionDescription: 'انتخاب کنید پس از پایان دانلودهای شروع‌شده توسط زمان‌بند، چه اتفاقی بیفتد.',
actionWarning: 'این اقدام می‌تواند سایر کارهای کامپیوتر را مختل کند. Firelink بلافاصله پس از اتمام صف زمان‌بندی‌شده، آن را اجرا می‌کند.',
systemPermissions: 'مجوزهای سیستم',
macPermissionDescription: 'خواب، راه‌اندازی مجدد و خاموش کردن به مجوز macOS Automation برای System Events نیاز دارد.',
macPermissionDescription: 'خوابیدن، راه‌اندازی مجدد و خاموش کردن به مجوز macOS Automation برای System Events نیاز دارد.',
permissionGranted: 'مجوز اتوماسیون اعطا شد',
permissionChecking: 'در حال بررسی مجوز اتوماسیون…',
permissionMissing: 'مجوز اتوماسیون موجود نیست',
@@ -178,8 +182,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 +205,7 @@ const fa = {
},
postActions: {
none: 'هیچ کاری انجام نده',
sleep: 'خواب',
sleep: 'خوابیدن',
restart: 'راه‌اندازی مجدد',
shutdown: 'خاموش کردن',
},
@@ -216,6 +220,13 @@ const fa = {
connectionCountUnknown: '—/{{total}} فعال',
connectionsUnavailable: '—',
speedCap: 'سقف سرعت',
liveSpeedLimit: 'سقف سرعت زنده',
liveSpeedLimitHint: 'فقط برای دانلودهای عادیِ فعال اعمال می‌شود. سرعت دانلودهای رسانه‌ای هنگام اجرا قابل تغییر نیست.',
liveSpeedLimitPlaceholder: 'مثلاً 1024K',
liveSpeedLimitApply: 'اعمال',
liveSpeedLimitClear: 'پاک کردن',
liveSpeedLimitFailed: 'به‌روزرسانی سقف سرعت زنده ممکن نیست: {{detail}}',
liveSpeedLimitUnavailable: 'تغییر زنده سرعت دانلودهای رسانه‌ای هنگام اجرا در دسترس نیست.',
category: 'دسته',
lastTry: 'آخرین تلاش',
dateAdded: 'تاریخ افزودن',
@@ -285,6 +296,7 @@ const fa = {
enableFromSettings: 'در هر زمان می‌توانید ذخیره‌سازی را از تنظیمات > یکپارچه‌سازی فعال کنید.',
later: 'بعداً',
enabling: 'در حال فعال‌سازی…',
waitingForPrompt: 'در انتظار پیام سیستم…',
timeout: 'مهلت درخواست ذخیره‌سازی اعتبارنامه به پایان رسید. می‌توانید بعداً را انتخاب کرده و دوباره امتحان کنید.',
unavailable: '{{store}} در دسترس نیست.',
accessRequired: 'پیش از ایجاد مجدد توکن جفت‌سازی، دسترسی به مخزن اعتبارنامه‌ها را اعطا کنید.',
@@ -326,6 +338,9 @@ const fa = {
queueNameExists: 'یک صف با این نام از قبل وجود دارد',
startQueueFailed: 'شروع صف ناموفق بود: {{detail}}',
pauseQueueFailed: 'توقف صف ناموفق بود: {{detail}}',
queueConcurrency: 'دانلودهای هم‌زمان',
queueConcurrencyGlobal: 'استفاده از محدودیت کلی',
queueConcurrencyFailed: 'به‌روزرسانی هم‌زمانی صف ناموفق بود: {{detail}}',
},
downloadTable: {
unknownQueue: 'صف نامشخص',
@@ -353,6 +368,15 @@ const fa = {
addDownload: 'افزودن دانلود',
resumeAll: 'ادامه همه',
pauseAll: 'توقف همه',
summary: {
items: '{{count}} مورد',
selected: '{{count}} انتخاب‌شده',
downloaded: 'دانلودشده',
remaining: 'باقی‌مانده',
active: 'فعال',
unknown: 'نامشخص',
estimated: '{{value}} (تخمینی)',
},
queueEmpty: 'صف خالی است',
noCompletedDownloads: 'هیچ دانلود تکمیل‌شده‌ای وجود ندارد',
noDownloads: 'هیچ دانلودی وجود ندارد',
@@ -361,6 +385,11 @@ const fa = {
clickToAdd: 'برای افزودن دانلودها، روی',
addButtonOr: 'یا',
toAddDownloads: 'کلیک کنید',
queueReorderHint: 'ردیف‌ها را بکشید تا مرتب شوند یا از کلیدهای میانبر استفاده کنید:',
queueReorderShortcut: '{{key}} + فلش بالا/پایین ردیف انتخاب‌شده را جابه‌جا می‌کند',
queuePriorityControls: 'جابه‌جایی دانلودهای انتخاب‌شده در صف',
queueDragHandle: 'کشیدن {{fileName}} برای جابه‌جایی',
queueReorderFailed: 'جابه‌جایی دانلودها ناموفق بود',
nonResumableOne: '۱ دانلود از ادامه‌دادن پشتیبانی نمی‌کند. اگر آن را متوقف کنید، بعداً باید از ابتدا شروع کنید. آیا از توقف آن مطمئن هستید؟',
nonResumableMany: '{{count}} دانلود از ادامه‌دادن پشتیبانی نمی‌کنند. اگر آن‌ها را متوقف کنید، بعداً باید از ابتدا شروع کنید. آیا از توقف آن‌ها مطمئن هستید؟',
interactionError: '{{message}}: {{detail}}',
@@ -447,6 +476,14 @@ const fa = {
fetchingMediaStreams: 'در حال دریافت جریان‌های رسانه…',
availableStreams: 'جریان‌های موجود',
availableMediaStreams: 'جریان‌های رسانه موجود',
quality: 'کیفیت',
format: 'قالب',
video: 'ویدئو',
audio: 'صوتی',
noCommonFormat: 'هیچ فرمت مشترکی برای همه موارد انتخاب‌شده وجود ندارد.',
noCommonQuality: 'کیفیت یکسانی برای همه موارد انتخاب‌شده وجود ندارد.',
playlistQuality: 'کیفیت فهرست پخش',
playlistQualitySelected: '{{selected}} از {{total}} مورد انتخاب شده',
metadataUnavailable: 'متادیتا در دسترس نیست. پیش از افزودن این رسانه، متادیتا را تازه‌سازی کنید.',
saveLocation: 'محل ذخیره',
browse: 'انتخاب…',
@@ -553,12 +590,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 +613,11 @@ const fa = {
languagePersian: 'فارسی',
languageUkrainian: 'اوکراینی',
languageRussian: 'روسی',
calendar: 'تقویم',
calendarDescription: 'تقویم مورد استفاده برای نمایش تاریخ و زمان را انتخاب کنید. تقویم میلادی پیش‌فرض است و زمان‌بندی یا مُهرهای زمانی ذخیره‌شده را تغییر نمی‌دهد.',
calendarGregorian: 'میلادی',
calendarPersian: 'هجری شمسی',
calendarHebrew: 'عبری',
appTheme: 'ظاهر برنامه',
theme: 'ظاهر',
ariaLabel: 'ظاهر برنامه',
@@ -576,6 +628,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 +743,8 @@ const fa = {
title: 'مدیریت انرژی',
preventSleep: 'جلوگیری از خواب سیستم در حالی که دانلودها فعال هستند',
preventSleepDescription: 'نمایشگر همچنان می‌تواند خاموش شود. Firelink تنها به اندازه‌ای دستگاه را بیدار نگه می‌دارد تا انتقال‌های فعال را تکمیل کند.',
preventDisplaySleep: 'روشن نگه داشتن نمایشگر هنگام فعال بودن دانلودها',
preventDisplaySleepDescription: 'این گزینه جدا از جلوگیری از خواب سیستم است و اختیاری است. نمایشگر تا پایان انتقال‌های فعال روشن می‌ماند.',
},
engine: {
title: 'دانلودکننده رسانه و موتورها',
+65
View File
@@ -50,6 +50,10 @@ const he = {
rename: 'שינוי שם',
replace: 'החלפה',
skip: 'דילוג',
applyToAll: 'החל על הכול',
renameAll: 'שינוי שם לכולם',
replaceAll: 'החלפה לכולם',
skipAll: 'דילוג על כולם',
},
removeDownload: {
title: 'הסרת הורדה',
@@ -216,6 +220,13 @@ const he = {
connectionCountUnknown: '—/{{total}} פעילות',
connectionsUnavailable: '—',
speedCap: 'הגבלת מהירות',
liveSpeedLimit: 'הגבלת מהירות בזמן אמת',
liveSpeedLimitHint: 'חל על הורדות רגילות פעילות בלבד. אי אפשר לשנות הורדות מדיה בזמן שהן פועלות.',
liveSpeedLimitPlaceholder: 'לדוגמה 1024K',
liveSpeedLimitApply: 'החל',
liveSpeedLimitClear: 'נקה',
liveSpeedLimitFailed: 'לא ניתן לעדכן את הגבלת המהירות בזמן אמת: {{detail}}',
liveSpeedLimitUnavailable: 'שליטה במהירות בזמן אמת אינה זמינה להורדות מדיה בזמן שהן פועלות.',
category: 'קטגוריה',
lastTry: 'ניסיון אחרון',
dateAdded: 'תאריך הוספה',
@@ -285,6 +296,7 @@ const he = {
enableFromSettings: 'ניתן להפעיל את האחסון בכל עת דרך הגדרות > שילובים.',
later: 'מאוחר יותר',
enabling: 'מפעיל…',
waitingForPrompt: 'ממתין להנחיית המערכת…',
timeout: 'בקשת האחסון של האישורים הסתיימה. ניתן לבחור "מאוחר יותר" ולנסות שוב.',
unavailable: '{{store}} אינו זמין.',
accessRequired: 'יש להעניק גישה לאחסון האישורים לפני יצירה מחדש של אסימון הצימוד.',
@@ -326,6 +338,9 @@ const he = {
queueNameExists: 'כבר קיים תור בשם זה',
startQueueFailed: 'לא ניתן להפעיל את התור: {{detail}}',
pauseQueueFailed: 'לא ניתן להשהות את התור: {{detail}}',
queueConcurrency: 'הורדות בו-זמניות',
queueConcurrencyGlobal: 'שימוש במגבלה הכללית',
queueConcurrencyFailed: 'לא ניתן לעדכן את מקביליות התור: {{detail}}',
},
downloadTable: {
unknownQueue: 'תור לא ידוע',
@@ -353,6 +368,15 @@ const he = {
addDownload: 'הוספת הורדה',
resumeAll: 'חידוש הכל',
pauseAll: 'השהיית הכל',
summary: {
items: '{{count}} פריטים',
selected: '{{count}} נבחרו',
downloaded: 'הורדו',
remaining: 'נותרו',
active: 'פעילות',
unknown: 'לא ידוע',
estimated: '{{value}} (הערכה)',
},
queueEmpty: 'התור ריק',
noCompletedDownloads: 'אין הורדות שהושלמו',
noDownloads: 'אין הורדות',
@@ -361,6 +385,11 @@ const he = {
clickToAdd: 'לחץ על לחצן',
addButtonOr: 'או',
toAddDownloads: 'כדי להוסיף הורדות',
queueReorderHint: 'גררו שורות כדי לשנות את הסדר או השתמשו במקשי הקיצור:',
queueReorderShortcut: '{{key}} + חץ למעלה/למטה מזיז את השורה הממוקדת',
queuePriorityControls: 'הזזת ההורדות שנבחרו בתור',
queueDragHandle: 'גררו את {{fileName}} כדי לשנות את הסדר',
queueReorderFailed: 'לא ניתן לשנות את סדר ההורדות',
nonResumableOne: 'הורדה אחת אינה תומכת בחידוש. אם תשהה אותה, תצטרך להתחיל מהתחלה מאוחר יותר. האם ברצונך להשהות?',
nonResumableMany: '{{count}} הורדות אינן תומכות בחידוש. אם תשהה אותן, תצטרך להתחיל מהתחלה מאוחר יותר. האם ברצונך להשהות?',
interactionError: '{{message}}: {{detail}}',
@@ -447,6 +476,14 @@ const he = {
fetchingMediaStreams: 'טוען זרמי מדיה…',
availableStreams: 'זרמים זמינים',
availableMediaStreams: 'זרמי מדיה זמינים',
quality: 'איכות',
format: 'פורמט',
video: 'וידאו',
audio: 'שמע',
noCommonFormat: 'אין פורמט אחד זמין לכל הפריטים שנבחרו.',
noCommonQuality: 'אין איכות אחת זמינה לכל הפריטים שנבחרו.',
playlistQuality: 'איכות רשימת השמעה',
playlistQualitySelected: '{{selected}} מתוך {{total}} נבחרו',
metadataUnavailable: 'מטא נתונים אינם זמינים. רענן מטא נתונים לפני הוספת מדיה זו.',
saveLocation: 'מיקום שמירה',
browse: 'עיון…',
@@ -553,12 +590,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 +613,11 @@ const he = {
languagePersian: 'פרסית',
languageUkrainian: 'אוקראינית',
languageRussian: 'רוסית',
calendar: 'לוח שנה',
calendarDescription: 'בחר את לוח השנה לתצוגת תאריכים ושעות. הלוח הגרגוריאני הוא ברירת המחדל; הבחירה אינה משנה תזמון או חותמות זמן שמורות.',
calendarGregorian: 'גרגוריאני',
calendarPersian: 'פרסי',
calendarHebrew: 'עברי',
appTheme: 'ערכת נושא לאפליקציה',
theme: 'ערכת נושא',
ariaLabel: 'ערכת נושא לאפליקציה',
@@ -576,6 +628,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 +743,8 @@ const he = {
title: 'ניהול צריכת חשמל',
preventSleep: 'מנע שינה של המערכת בזמן שהורדות פעילות',
preventSleepDescription: 'התצוגה עדיין עשויה להכבות. Firelink משאיר את המכשיר ער רק במידה המספקת להשלמת העברות פעילות.',
preventDisplaySleep: 'השאר את התצוגה פעילה בזמן שהורדות פעילות',
preventDisplaySleepDescription: 'אפשרות זו נפרדת ממניעת שינת המערכת. התצוגה תישאר פעילה עד לסיום ההעברות הפעילות.',
},
engine: {
title: 'מוריד מדיה ומנועים',
+66 -1
View File
@@ -50,6 +50,10 @@ const ru = {
rename: 'Переименовать',
replace: 'Заменить',
skip: 'Пропустить',
applyToAll: 'Применить ко всем',
renameAll: 'Переименовать все',
replaceAll: 'Заменить все',
skipAll: 'Пропустить все',
},
removeDownload: {
title: 'Удалить загрузку',
@@ -203,7 +207,7 @@ const ru = {
none: 'Ничего не делать',
sleep: 'Спящий режим',
restart: 'Перезагрузка',
shutdown: 'Завершение работы',
shutdown: 'Выключение',
},
},
properties: {
@@ -216,6 +220,13 @@ const ru = {
connectionCountUnknown: '—/{{total}} активных',
connectionsUnavailable: '—',
speedCap: 'Ограничение скорости',
liveSpeedLimit: 'Текущее ограничение скорости',
liveSpeedLimitHint: 'Применяется только к активным обычным загрузкам. Скорость медиазагрузок нельзя изменить во время работы.',
liveSpeedLimitPlaceholder: 'например, 1024K',
liveSpeedLimitApply: 'Применить',
liveSpeedLimitClear: 'Очистить',
liveSpeedLimitFailed: 'Не удалось обновить текущее ограничение скорости: {{detail}}',
liveSpeedLimitUnavailable: 'Изменение скорости медиазагрузок во время работы недоступно.',
category: 'Категория',
lastTry: 'Последняя попытка',
dateAdded: 'Дата добавления',
@@ -285,6 +296,7 @@ const ru = {
enableFromSettings: 'Вы можете включить хранилище в любое время в меню «Настройки» > «Интеграции».',
later: 'Позже',
enabling: 'Включение…',
waitingForPrompt: 'Ожидание системного запроса…',
timeout: 'Время ожидания запроса к хранилищу учётных данных истекло. Вы можете выбрать «Позже» и попробовать снова.',
unavailable: 'Хранилище {{store}} недоступно.',
accessRequired: 'Предоставьте доступ к хранилищу учётных данных перед повторной генерацией токена сопряжения.',
@@ -326,6 +338,9 @@ const ru = {
queueNameExists: 'Очередь с таким названием уже существует',
startQueueFailed: 'Не удалось запустить очередь: {{detail}}',
pauseQueueFailed: 'Не удалось приостановить очередь: {{detail}}',
queueConcurrency: 'Одновременные загрузки',
queueConcurrencyGlobal: 'Использовать общий лимит',
queueConcurrencyFailed: 'Не удалось обновить параллельность очереди: {{detail}}',
},
downloadTable: {
unknownQueue: 'Неизвестная очередь',
@@ -353,6 +368,15 @@ const ru = {
addDownload: 'Добавить загрузку',
resumeAll: 'Возобновить все',
pauseAll: 'Приостановить все',
summary: {
items: '{{count}} элементов',
selected: 'Выбрано: {{count}}',
downloaded: 'Загружено',
remaining: 'Осталось',
active: 'Активные',
unknown: 'Неизвестно',
estimated: 'Около {{value}}',
},
queueEmpty: 'Очередь пуста',
noCompletedDownloads: 'Нет завершённых загрузок',
noDownloads: 'Нет загрузок',
@@ -361,6 +385,11 @@ const ru = {
clickToAdd: 'Нажмите кнопку',
addButtonOr: 'или',
toAddDownloads: 'чтобы добавить загрузки',
queueReorderHint: 'Перетаскивайте строки, чтобы изменить порядок, или используйте горячие клавиши:',
queueReorderShortcut: '{{key}} + стрелка вверх/вниз перемещает сфокусированную строку',
queuePriorityControls: 'Переместить выбранные загрузки в очереди',
queueDragHandle: 'Перетащите {{fileName}} для изменения порядка',
queueReorderFailed: 'Не удалось изменить порядок загрузок',
nonResumableOne: '1 загрузка не поддерживает возобновление. Если вы приостановите её, позже придётся начинать скачивание заново. Вы действительно хотите приостановить?',
nonResumableMany: '{{count}} загрузок не поддерживают возобновление. Если вы приостановите их, позже придётся начинать скачивание заново. Вы действительно хотите приостановить?',
interactionError: '{{message}}: {{detail}}',
@@ -447,6 +476,14 @@ const ru = {
fetchingMediaStreams: 'Получение медиапотоков…',
availableStreams: 'Доступные потоки',
availableMediaStreams: 'Доступные медиапотоки',
quality: 'Качество',
format: 'Формат',
video: 'Видео',
audio: 'Аудио',
noCommonFormat: 'Для всех выбранных элементов нет общего формата.',
noCommonQuality: 'Для всех выбранных элементов нет общего качества.',
playlistQuality: 'Качество плейлиста',
playlistQualitySelected: 'Выбрано: {{selected}} из {{total}}',
metadataUnavailable: 'Метаданные недоступны. Обновите метаданные перед добавлением этого медиа.',
saveLocation: 'Папка сохранения',
browse: 'Обзор…',
@@ -553,12 +590,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 +613,11 @@ const ru = {
languagePersian: 'Персидский',
languageUkrainian: 'Украинский',
languageRussian: 'Русский',
calendar: 'Календарь',
calendarDescription: 'Выберите календарь для отображения дат и времени. По умолчанию используется григорианский; расписание и сохранённые метки времени не изменяются.',
calendarGregorian: 'Григорианский',
calendarPersian: 'Персидский',
calendarHebrew: 'Еврейский',
appTheme: 'Тема приложения',
theme: 'Тема',
ariaLabel: 'Тема приложения',
@@ -576,6 +628,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 +743,8 @@ const ru = {
title: 'Управление питанием',
preventSleep: 'Запрещать спящий режим системы во время активных загрузок',
preventSleepDescription: 'Экран всё ещё может выключаться. Firelink предотвращает засыпание устройства только настолько, чтобы завершить активные передачи.',
preventDisplaySleep: 'Не выключать экран во время активных загрузок',
preventDisplaySleepDescription: 'Это отдельная необязательная настройка, не связанная с запретом сна системы. Экран останется включённым до завершения передач.',
},
engine: {
title: 'Загрузчик медиа и движки',
+65
View File
@@ -50,6 +50,10 @@ const uk = {
rename: 'Перейменувати',
replace: 'Замінити',
skip: 'Пропустити',
applyToAll: 'Застосувати до всіх',
renameAll: 'Перейменувати всі',
replaceAll: 'Замінити всі',
skipAll: 'Пропустити всі',
},
removeDownload: {
title: 'Видалити завантаження',
@@ -216,6 +220,13 @@ const uk = {
connectionCountUnknown: '—/{{total}} активних',
connectionsUnavailable: '—',
speedCap: 'Обмеження швидкості',
liveSpeedLimit: 'Поточне обмеження швидкості',
liveSpeedLimitHint: 'Застосовується лише до активних звичайних завантажень. Швидкість медіазавантажень не можна змінити під час роботи.',
liveSpeedLimitPlaceholder: 'наприклад, 1024K',
liveSpeedLimitApply: 'Застосувати',
liveSpeedLimitClear: 'Очистити',
liveSpeedLimitFailed: 'Не вдалося оновити поточне обмеження швидкості: {{detail}}',
liveSpeedLimitUnavailable: 'Зміна швидкості медіазавантажень під час роботи недоступна.',
category: 'Категорія',
lastTry: 'Остання спроба',
dateAdded: 'Дата додавання',
@@ -285,6 +296,7 @@ const uk = {
enableFromSettings: 'Ви можете увімкнути сховище будь-коли в Налаштування > Інтеграції.',
later: 'Пізніше',
enabling: 'Увімкнення…',
waitingForPrompt: 'Очікування системного запиту…',
timeout: 'Час очікування запиту до сховища облікових даних вичерпано. Ви можете вибрати "Пізніше" і спробувати ще раз.',
unavailable: '{{store}} недоступно.',
accessRequired: 'Надайте доступ до сховища облікових даних перед повторною генерацією токена підключення.',
@@ -326,6 +338,9 @@ const uk = {
queueNameExists: 'Черга з такою назвою вже існує',
startQueueFailed: 'Не вдалося запустити чергу: {{detail}}',
pauseQueueFailed: 'Не вдалося призупинити чергу: {{detail}}',
queueConcurrency: 'Одночасні завантаження',
queueConcurrencyGlobal: 'Використовувати загальний ліміт',
queueConcurrencyFailed: 'Не вдалося оновити паралельність черги: {{detail}}',
},
downloadTable: {
unknownQueue: 'Невідома черга',
@@ -353,6 +368,15 @@ const uk = {
addDownload: 'Додати завантаження',
resumeAll: 'Відновити всі',
pauseAll: 'Призупинити всі',
summary: {
items: '{{count}} елементів',
selected: 'Вибрано: {{count}}',
downloaded: 'Завантажено',
remaining: 'Залишилось',
active: 'Активні',
unknown: 'Невідомо',
estimated: 'Приблизно {{value}}',
},
queueEmpty: 'Черга порожня',
noCompletedDownloads: 'Немає завершених завантажень',
noDownloads: 'Немає завантажень',
@@ -361,6 +385,11 @@ const uk = {
clickToAdd: 'Натисніть',
addButtonOr: 'або',
toAddDownloads: 'щоб додати завантаження',
queueReorderHint: 'Перетягуйте рядки, щоб змінити порядок, або використовуйте гарячі клавіші:',
queueReorderShortcut: '{{key}} + стрілка вгору/вниз переміщує сфокусований рядок',
queuePriorityControls: 'Перемістити вибрані завантаження в черзі',
queueDragHandle: 'Перетягніть {{fileName}}, щоб змінити порядок',
queueReorderFailed: 'Не вдалося змінити порядок завантажень',
nonResumableOne: '1 завантаження не підтримує відновлення. Якщо ви призупините його, вам доведеться почати спочатку пізніше. Ви впевнені, що хочете призупинити?',
nonResumableMany: '{{count}} завантажень не підтримують відновлення. Якщо ви призупините їх, вам доведеться почати спочатку пізніше. Ви впевнені, що хочете призупинити?',
interactionError: '{{message}}: {{detail}}',
@@ -447,6 +476,14 @@ const uk = {
fetchingMediaStreams: 'Отримання медіапотоків…',
availableStreams: 'Доступні потоки',
availableMediaStreams: 'Доступні медіапотоки',
quality: 'Якість',
format: 'Формат',
video: 'Відео',
audio: 'Аудіо',
noCommonFormat: 'Немає спільного формату для всіх вибраних елементів.',
noCommonQuality: 'Немає спільної якості для всіх вибраних елементів.',
playlistQuality: 'Якість плейлиста',
playlistQualitySelected: 'Вибрано: {{selected}} із {{total}}',
metadataUnavailable: 'Метадані недоступні. Оновіть метадані перед додаванням цього медіа.',
saveLocation: 'Місце збереження',
browse: 'Огляд…',
@@ -553,12 +590,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 +613,11 @@ const uk = {
languagePersian: 'Перська',
languageUkrainian: 'Українська',
languageRussian: 'Російська',
calendar: 'Календар',
calendarDescription: 'Виберіть календар для відображення дат і часу. Типово використовується григоріанський; розклад і збережені мітки часу не змінюються.',
calendarGregorian: 'Григоріанський',
calendarPersian: 'Перський',
calendarHebrew: 'Єврейський',
appTheme: 'Тема програми',
theme: 'Тема',
ariaLabel: 'Тема програми',
@@ -576,6 +628,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 +743,8 @@ const uk = {
title: 'Керування живленням',
preventSleep: 'Запобігати переходу системи в сплячий режим під час активних завантажень',
preventSleepDescription: 'Дисплей може все одно вимкнутися. Firelink лише не дає пристрою заснути, щоб завершити активні передачі.',
preventDisplaySleep: 'Не вимикати дисплей під час активних завантажень',
preventDisplaySleepDescription: 'Це окреме необов’язкове налаштування, незалежне від запобігання сну системи. Дисплей залишатиметься ввімкненим до завершення передач.',
},
engine: {
title: 'Завантажувач медіа та рушії',
+69 -4
View File
@@ -33,7 +33,7 @@ const zhCN = {
dismissNotification: '关闭通知',
queueName: '队列名称',
addNewQueue: '新建队列',
startQueue: '启动队列',
startQueue: '开始队列',
pauseQueue: '暂停队列',
renameQueue: '重命名队列',
deleteQueue: '删除队列',
@@ -50,6 +50,10 @@ const zhCN = {
rename: '重命名',
replace: '替换',
skip: '跳过',
applyToAll: '应用于全部',
renameAll: '全部重命名',
replaceAll: '全部替换',
skipAll: '全部跳过',
},
removeDownload: {
title: '移除下载',
@@ -71,7 +75,7 @@ const zhCN = {
moveUp: '上移',
moveDown: '下移',
pause: '暂停',
start: '启动',
start: '开始',
resume: '恢复',
options: '选项',
},
@@ -216,6 +220,13 @@ const zhCN = {
connectionCountUnknown: '—/{{total}} 个连接',
connectionsUnavailable: '—',
speedCap: '速度上限',
liveSpeedLimit: '实时速度上限',
liveSpeedLimitHint: '仅适用于正在进行的普通下载。媒体下载运行时无法更改速度。',
liveSpeedLimitPlaceholder: '例如 1024K',
liveSpeedLimitApply: '应用',
liveSpeedLimitClear: '清除',
liveSpeedLimitFailed: '无法更新实时速度上限:{{detail}}',
liveSpeedLimitUnavailable: '媒体下载运行时无法使用实时速度控制。',
category: '类别',
lastTry: '上次尝试',
dateAdded: '添加日期',
@@ -285,6 +296,7 @@ const zhCN = {
enableFromSettings: '您可以随时从“设置 > 集成”中启用存储。',
later: '稍后',
enabling: '正在启用…',
waitingForPrompt: '正在等待系统提示…',
timeout: '凭据存储请求超时。您可以选择“稍后”并重试。',
unavailable: '{{store}}不可用。',
accessRequired: '在重新生成配对令牌之前,请授予凭据存储访问权限。',
@@ -326,6 +338,9 @@ const zhCN = {
queueNameExists: '同名队列已存在',
startQueueFailed: '无法启动队列:{{detail}}',
pauseQueueFailed: '无法暂停队列:{{detail}}',
queueConcurrency: '并行下载数',
queueConcurrencyGlobal: '使用全局限制',
queueConcurrencyFailed: '无法更新队列并发数:{{detail}}',
},
downloadTable: {
unknownQueue: '未知队列',
@@ -353,6 +368,15 @@ const zhCN = {
addDownload: '添加下载',
resumeAll: '全部恢复',
pauseAll: '全部暂停',
summary: {
items: '{{count}} 个项目',
selected: '已选择 {{count}}',
downloaded: '已下载',
remaining: '剩余',
active: '进行中',
unknown: '未知',
estimated: '估计 {{value}}',
},
queueEmpty: '队列为空',
noCompletedDownloads: '没有已完成的下载',
noDownloads: '没有下载',
@@ -361,6 +385,11 @@ const zhCN = {
clickToAdd: '点击',
addButtonOr: '按钮或',
toAddDownloads: '来添加下载',
queueReorderHint: '拖动行调整顺序,或使用快捷键:',
queueReorderShortcut: '{{key}} + 上/下箭头可移动当前行',
queuePriorityControls: '移动队列中选中的下载',
queueDragHandle: '拖动 {{fileName}} 调整顺序',
queueReorderFailed: '无法调整下载顺序',
nonResumableOne: '1 个下载不支持断点续传。如果您暂停它,稍后您将不得不重新开始。确定要暂停吗?',
nonResumableMany: '{{count}} 个下载不支持断点续传。如果您暂停它们,稍后您将不得不重新开始。确定要暂停吗?',
interactionError: '{{message}}{{detail}}',
@@ -375,14 +404,14 @@ const zhCN = {
copyPathFailed: '无法复制文件路径',
missingFileName: '缺少文件名',
redownloadFailed: '重新下载失败',
startResume: '启动/恢复',
startResume: '开始/恢复',
addToQueue: '添加到队列',
copyAddress: '复制地址',
remove: '移除',
open: '打开',
showInFolder: '在文件夹中显示',
pause: '暂停',
start: '启动',
start: '开始',
resume: '恢复',
redownload: '重新下载',
copyFilePath: '复制文件路径',
@@ -447,6 +476,14 @@ const zhCN = {
fetchingMediaStreams: '正在获取媒体流…',
availableStreams: '可用流',
availableMediaStreams: '可用的媒体流',
quality: '画质',
format: '格式',
video: '视频',
audio: '音频',
noCommonFormat: '所选项目没有共同格式。',
noCommonQuality: '所选项目没有共同画质。',
playlistQuality: '播放列表画质',
playlistQualitySelected: '已选择 {{selected}} / {{total}}',
metadataUnavailable: '元数据不可用。在添加此媒体之前,请刷新元数据。',
saveLocation: '保存位置',
browse: '浏览',
@@ -553,12 +590,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 +613,11 @@ const zhCN = {
languagePersian: '波斯语',
languageUkrainian: '乌克兰语',
languageRussian: '俄语',
calendar: '日历',
calendarDescription: '选择显示日期和时间所使用的日历。默认使用公历;此选择不会改变计划或已保存的时间戳。',
calendarGregorian: '公历',
calendarPersian: '波斯历',
calendarHebrew: '希伯来历',
appTheme: '应用主题',
theme: '主题',
ariaLabel: '应用主题',
@@ -576,6 +628,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 +743,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',
+913 -97
View File
File diff suppressed because it is too large Load Diff
+14 -3
View File
@@ -13,9 +13,11 @@ import type { EngineStatusItem } from './bindings/EngineStatusItem';
import type { PostQueueAction } from './bindings/PostQueueAction';
import type { ReleaseCheckOutcome } from './bindings/ReleaseCheckOutcome';
import type { PairingTokenHydration } from './bindings/PairingTokenHydration';
import type { KeychainGrantStatus } from './bindings/KeychainGrantStatus';
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 +39,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 +47,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 };
@@ -69,7 +77,10 @@ type CommandMap = {
get_session_pairing_token: { args: undefined; result: PairingTokenHydration };
authorize_keychain_access: { args: undefined; result: void };
regenerate_pairing_token: { args: undefined; result: PairingTokenHydration };
grant_keychain_access: { args: undefined; result: PairingTokenHydration };
grant_keychain_access: { args: { requestId: string }; result: void };
get_keychain_grant_status: { args: { requestId: string }; result: KeychainGrantStatus };
accept_keychain_grant: { args: { requestId: string }; result: PairingTokenHydration };
abandon_keychain_grant: { args: { requestId: string }; result: PairingTokenHydration | null };
acknowledge_pairing_token_change: { args: undefined; result: void };
set_extension_frontend_ready: { args: { ready: boolean }; result: void };
ack_extension_download: { args: { requestId: string }; result: void };
@@ -98,7 +109,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";
+45 -2
View File
@@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
import { initDownloadListener, useDownloadProgressStore } from './downloadStore';
import {
clearDownloadControlIntents,
downloadControlIntentFor,
setDownloadControlIntent,
useDownloadStore
} from './useDownloadStore';
@@ -353,7 +354,7 @@ describe('useDownloadProgressStore', () => {
release();
});
it('ignores a stale paused event during resume and accepts the new active state', async () => {
it('ignores duplicate stale paused events during resume and accepts the new active state', async () => {
const handlers: Record<string, (event: any) => void> = {};
vi.mocked(ipc.listenEvent).mockImplementation((event, handler) => {
handlers[event] = handler as (event: any) => void;
@@ -378,6 +379,12 @@ describe('useDownloadProgressStore', () => {
} });
expect(useDownloadStore.getState().downloads[0].status).toBe('queued');
handlers['download-state']({ payload: {
id: 'resume-race',
status: 'paused'
} });
expect(useDownloadStore.getState().downloads[0].status).toBe('queued');
handlers['download-state']({ payload: {
id: 'resume-race',
status: 'downloading'
@@ -386,7 +393,7 @@ describe('useDownloadProgressStore', () => {
release();
});
it('allows a later genuine pause after consuming the stale resume event', async () => {
it('accepts an explicit pause while a resume transition is in flight', async () => {
const handlers: Record<string, (event: any) => void> = {};
vi.mocked(ipc.listenEvent).mockImplementation((event, handler) => {
handlers[event] = handler as (event: any) => void;
@@ -411,6 +418,9 @@ describe('useDownloadProgressStore', () => {
} });
expect(useDownloadStore.getState().downloads[0].status).toBe('queued');
// pauseDownload replaces the resume intent before it asks the backend to
// pause, so the next paused event is authoritative.
setDownloadControlIntent('resume-pause-race', 'pause');
handlers['download-state']({ payload: {
id: 'resume-pause-race',
status: 'paused'
@@ -418,4 +428,37 @@ describe('useDownloadProgressStore', () => {
expect(useDownloadStore.getState().downloads[0].status).toBe('paused');
release();
});
it('accepts an authoritative resume failure while ignoring stale paused events', async () => {
const handlers: Record<string, (event: any) => void> = {};
vi.mocked(ipc.listenEvent).mockImplementation((event, handler) => {
handlers[event] = handler as (event: any) => void;
return Promise.resolve(vi.fn());
});
useDownloadStore.setState({
downloads: [{
id: 'resume-failure',
url: 'https://example.com/file',
fileName: 'file.bin',
status: 'queued',
category: 'Other',
dateAdded: ''
}]
});
setDownloadControlIntent('resume-failure', 'resume');
const release = await initDownloadListener();
handlers['download-state']({ payload: {
id: 'resume-failure',
status: 'paused',
error: 'aria2 resume failed'
} });
expect(useDownloadStore.getState().downloads[0]).toMatchObject({
status: 'paused',
lastError: 'aria2 resume failed'
});
expect(downloadControlIntentFor('resume-failure')).toBeUndefined();
release();
});
});
+18 -13
View File
@@ -103,17 +103,19 @@ const startDownloadListeners = async () => {
const status = payload.status as DownloadStatus;
// resume_download queues the row before the backend can emit its new
// active state. A paused event already emitted by the old lifecycle may
// arrive in that gap. Do not let it overwrite the queued transition;
// otherwise the guard below would reject the legitimate downloading
// event and leave the row visibly paused forever.
// active state. Paused events already emitted by the old lifecycle may
// arrive in that gap, and more than one can be queued before the new
// lifecycle reports its state. Do not let any of them overwrite the
// queued transition; otherwise a duplicate stale event can leave the
// row visibly paused and make dispatch reject it before IPC.
if (status === 'paused' &&
current.status === 'queued' &&
downloadControlIntentFor(payload.id) === 'resume') {
// Consume only the stale pause event that caused the resume race.
// A later real pause must be allowed through even if the backend has
// not emitted a new active state yet.
clearDownloadControlIntent(payload.id, 'resume');
downloadControlIntentFor(payload.id) === 'resume' &&
!payload.error) {
// Keep the resume intent until an active or terminal event proves
// that the new lifecycle has taken over. An explicit pause replaces
// this intent in pauseDownload, so a genuine user pause is still
// applied while the transition is in flight.
return;
}
if (status === 'downloading' || status === 'processing' ||
@@ -122,6 +124,7 @@ const startDownloadListeners = async () => {
}
if (status === 'paused') {
clearDownloadControlIntent(payload.id, 'pause');
if (payload.error) clearDownloadControlIntent(payload.id, 'resume');
}
// Prevent stale lifecycle events from moving a paused row back into an
@@ -177,11 +180,13 @@ const startDownloadListeners = async () => {
mainStore.updateDownload(payload.id, updates);
if (status === 'completed' || status === 'failed' || status === 'paused') {
mainStore.setPendingOrder(mainStore.pendingOrder.filter(id => id !== payload.id));
useDownloadStore.setState(state => ({
pendingOrder: state.pendingOrder.filter(id => id !== payload.id)
}));
} else if (status === 'queued') {
if (!mainStore.pendingOrder.includes(payload.id)) {
mainStore.setPendingOrder([...mainStore.pendingOrder, payload.id]);
}
useDownloadStore.setState(state => state.pendingOrder.includes(payload.id)
? {}
: { pendingOrder: [...state.pendingOrder, payload.id] });
}
if (status === 'queued' || status === 'downloading' || status === 'processing' || status === 'retrying') {
+620 -1
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') {
@@ -322,6 +523,37 @@ describe('useDownloadStore', () => {
.toEqual(['00000000-0000-0000-0000-000000000001', '00000000-0000-0000-0000-000000000001']);
});
it('moves persisted paused rows behind runnable rows and assigns contiguous positions', async () => {
vi.mocked(ipc.invokeCommand).mockImplementation(async (cmd: string) => {
if (cmd === 'db_get_all_queues') {
return [JSON.stringify({ id: 'queue-a', name: 'Queue A', isMain: false })];
}
if (cmd === 'db_get_all_downloads') {
return [
JSON.stringify({ id: 'active', status: 'downloading', queueId: 'queue-a', queuePosition: 0 }),
JSON.stringify({ id: 'queued-one', status: 'queued', queueId: 'queue-a', queuePosition: 1 }),
JSON.stringify({ id: 'paused-one', status: 'paused', queueId: 'queue-a', queuePosition: 2 }),
JSON.stringify({ id: 'queued-two', status: 'queued', queueId: 'queue-a', queuePosition: 3 }),
JSON.stringify({ id: 'legacy-invalid', status: 'queued', queueId: 'queue-a', queuePosition: 'not-a-number' })
];
}
return undefined;
});
await useDownloadStore.getState().initDB();
expect(useDownloadStore.getState().downloads
.filter(download => download.queueId === 'queue-a')
.sort((left, right) => (left.queuePosition ?? 0) - (right.queuePosition ?? 0))
.map(download => download.id)
).toEqual(['active', 'queued-one', 'queued-two', 'legacy-invalid', 'paused-one']);
expect(useDownloadStore.getState().downloads
.filter(download => download.queueId === 'queue-a')
.map(download => download.queuePosition)
.sort((left, right) => (left ?? 0) - (right ?? 0))
).toEqual([0, 1, 2, 3, 4]);
});
it('removes persisted temporary media estimates that are smaller than downloaded bytes', async () => {
vi.mocked(ipc.invokeCommand).mockImplementation(async (cmd: string) => {
if (cmd === 'db_get_all_queues') return [];
@@ -731,6 +963,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
@@ -801,6 +1037,159 @@ describe('useDownloadStore', () => {
});
});
it('starts a selected queue block in selection order and moves pending rows to the front', async () => {
useDownloadStore.setState({
downloads: [
{ id: 'other', url: 'http://other', fileName: 'other', destination: '/tmp', status: 'staged', category: 'Other', dateAdded: '', queueId: 'selection-queue', queuePosition: 0 },
{ id: 'selected-a', url: 'http://a', fileName: 'a', destination: '/tmp', status: 'paused', category: 'Other', dateAdded: '', queueId: 'selection-queue', queuePosition: 1, hasBeenDispatched: true },
{ id: 'selected-b', url: 'http://b', fileName: 'b', destination: '/tmp', status: 'paused', category: 'Other', dateAdded: '', queueId: 'selection-queue', queuePosition: 2, hasBeenDispatched: true },
] as any[],
backendRegisteredIds: new Set(['selected-a', 'selected-b'])
});
vi.mocked(ipc.invokeCommand).mockImplementation(async (command: string, args?: unknown) => {
if (command === 'enqueue_download') {
const id = (args as { item: { id: string } }).item.id;
return { id, filename: id };
}
if (command === 'get_pending_order') return ['selected-b', 'selected-a', 'other'];
if (command === 'move_many_in_queue') return ['selected-b', 'selected-a', 'other'];
return undefined;
});
await expect(useDownloadStore.getState().startSelected(['selected-b', 'selected-a'])).resolves.toBe(2);
const enqueueIds = vi.mocked(ipc.invokeCommand).mock.calls
.filter(([command]) => command === 'enqueue_download')
.map(([, args]) => (args as { item: { id: string } }).item.id);
expect(enqueueIds).toEqual(['selected-b', 'selected-a']);
expect(ipc.invokeCommand).toHaveBeenCalledWith('move_many_in_queue', {
ids: ['selected-b', 'selected-a'],
queueId: 'selection-queue',
direction: 'up',
targetIndex: 0,
});
expect(useDownloadStore.getState().downloads.map(item => item.id)).toEqual([
'other',
'selected-a',
'selected-b',
]);
expect(useDownloadStore.getState().downloads.find(item => item.id === 'selected-b')?.queuePosition).toBe(0);
expect(useDownloadStore.getState().downloads.find(item => item.id === 'selected-a')?.queuePosition).toBe(1);
});
it('resumes a selected block whose paused rows were never dispatched', async () => {
useDownloadStore.setState({
downloads: [
{ id: 'selected-undispatched-a', url: 'http://a', fileName: 'a', destination: '/tmp', status: 'paused', category: 'Other', dateAdded: '', queueId: 'selection-undispatched', queuePosition: 0, hasBeenDispatched: false },
{ id: 'selected-undispatched-b', url: 'http://b', fileName: 'b', destination: '/tmp', status: 'paused', category: 'Other', dateAdded: '', queueId: 'selection-undispatched', queuePosition: 1, hasBeenDispatched: false },
] as any[],
});
vi.mocked(ipc.invokeCommand).mockImplementation(async (command: string, args?: unknown) => {
if (command === 'enqueue_download') {
const id = (args as { item: { id: string } }).item.id;
return { id, filename: id };
}
if (command === 'get_pending_order') return ['selected-undispatched-a', 'selected-undispatched-b'];
if (command === 'move_many_in_queue') return ['selected-undispatched-a', 'selected-undispatched-b'];
return undefined;
});
await expect(useDownloadStore.getState().startSelected([
'selected-undispatched-a',
'selected-undispatched-b'
])).resolves.toBe(2);
const enqueueIds = vi.mocked(ipc.invokeCommand).mock.calls
.filter(([command]) => command === 'enqueue_download')
.map(([, args]) => (args as { item: { id: string } }).item.id);
expect(enqueueIds).toEqual(['selected-undispatched-a', 'selected-undispatched-b']);
});
it('pauses queued items through the global pause action', async () => {
useDownloadStore.setState({
downloads: [
{ id: 'queued-one', url: 'http://one', fileName: 'one', status: 'queued', category: 'Other', dateAdded: '', queueId: 'pause-queue' },
{ id: 'queued-two', url: 'http://two', fileName: 'two', status: 'queued', category: 'Other', dateAdded: '', queueId: 'pause-queue' },
{ id: 'staged-one', url: 'http://staged', fileName: 'staged', status: 'staged', category: 'Other', dateAdded: '', queueId: 'pause-queue' },
] as any[],
});
vi.mocked(ipc.invokeCommand).mockResolvedValue(undefined as never);
await expect(useDownloadStore.getState().pauseAll()).resolves.toBe(3);
expect(
vi.mocked(ipc.invokeCommand).mock.calls.filter(([command]) => command === 'pause_download')
).toHaveLength(3);
expect(useDownloadStore.getState().downloads.every(item => item.status === 'paused')).toBe(true);
});
it('moves a paused row behind the remaining runnable queue rows', async () => {
useDownloadStore.setState({
downloads: [
{ id: 'active', status: 'downloading', queueId: 'ordered-pause', queuePosition: 0 },
{ id: 'queued-one', status: 'queued', queueId: 'ordered-pause', queuePosition: 1 },
{ id: 'pause-target', status: 'queued', queueId: 'ordered-pause', queuePosition: 2 },
{ id: 'queued-two', status: 'queued', queueId: 'ordered-pause', queuePosition: 3 }
] as any[],
pendingOrder: ['queued-one', 'pause-target', 'queued-two']
});
vi.mocked(ipc.invokeCommand).mockResolvedValue(undefined as never);
await useDownloadStore.getState().pauseDownload('pause-target');
const ordered = useDownloadStore.getState().downloads
.filter(download => download.queueId === 'ordered-pause')
.sort((left, right) => (left.queuePosition ?? 0) - (right.queuePosition ?? 0));
expect(ordered.map(download => download.id)).toEqual([
'active', 'queued-one', 'queued-two', 'pause-target'
]);
expect(ordered.map(download => download.queuePosition)).toEqual([0, 1, 2, 3]);
expect(useDownloadStore.getState().pendingOrder).toEqual(['queued-one', 'queued-two']);
expect(useDownloadStore.getState().downloads.find(download => download.id === 'pause-target')?.status)
.toBe('paused');
});
it('does not let a queue pause lose a selected start in flight', async () => {
useDownloadStore.setState({
downloads: [
{ id: 'race-first', url: 'http://first', fileName: 'first', destination: '/tmp', status: 'paused', category: 'Other', dateAdded: '', queueId: 'race-selected', queuePosition: 0 },
{ id: 'race-second', url: 'http://second', fileName: 'second', destination: '/tmp', status: 'paused', category: 'Other', dateAdded: '', queueId: 'race-selected', queuePosition: 1 },
] as any[],
});
let releaseEnqueue!: (value: { id: string; filename: string }) => void;
const enqueue = new Promise<{ id: string; filename: string }>(resolve => {
releaseEnqueue = resolve;
});
vi.mocked(ipc.invokeCommand).mockImplementation(async (command: string) => {
if (command === 'enqueue_download') return enqueue;
if (command === 'get_pending_order') return [];
return undefined;
});
const start = useDownloadStore.getState().startSelected(['race-first', 'race-second']);
await vi.waitFor(() => {
expect(vi.mocked(ipc.invokeCommand)).toHaveBeenCalledWith(
'enqueue_download',
expect.objectContaining({ item: expect.objectContaining({ id: 'race-first' }) })
);
});
const pause = useDownloadStore.getState().pauseQueue('race-selected');
await vi.waitFor(() => {
expect(vi.mocked(ipc.invokeCommand).mock.calls.filter(([command]) => command === 'cancel_enqueue_generation'))
.toHaveLength(2);
});
releaseEnqueue({ id: 'race-first', filename: 'first' });
await expect(start).resolves.toBe(0);
await expect(pause).resolves.toBe(1);
expect(useDownloadStore.getState().downloads.map(item => item.status)).toEqual(['paused', 'paused']);
expect(vi.mocked(ipc.invokeCommand).mock.calls.filter(([command]) => command === 'pause_download'))
.toHaveLength(1);
});
it('cleans an accepted backend enqueue when queue reconciliation fails', async () => {
useDownloadStore.setState({
downloads: [
@@ -841,6 +1230,35 @@ describe('useDownloadStore', () => {
expect(ipc.invokeCommand).not.toHaveBeenCalledWith('enqueue_download', expect.anything());
});
it('inserts a newly staged queue item before paused rows', async () => {
useDownloadStore.setState({
downloads: [{
id: 'already-paused',
url: 'https://example.com/paused.bin',
fileName: 'paused.bin',
category: 'Other',
dateAdded: '',
status: 'paused',
queueId: 'queue-b',
queuePosition: 0
}] as any[]
});
await useDownloadStore.getState().addDownload({
id: 'new-staged',
url: 'https://example.com/new.bin',
fileName: 'new.bin',
category: 'Other',
dateAdded: ''
}, { type: 'add-to-queue', queueId: 'queue-b' });
const ordered = useDownloadStore.getState().downloads
.filter(item => item.queueId === 'queue-b')
.sort((left, right) => (left.queuePosition ?? 0) - (right.queuePosition ?? 0));
expect(ordered.map(item => item.id)).toEqual(['new-staged', 'already-paused']);
expect(ordered.map(item => item.queuePosition)).toEqual([0, 1]);
});
it('carries a media format estimate into numeric progress state', async () => {
await useDownloadStore.getState().addDownload({
id: 'media-estimate',
@@ -1418,6 +1836,105 @@ describe('useDownloadStore', () => {
expect(calls.some(call => call[0] === 'pause_download' && (call[1] as any).id === 'active')).toBe(true);
});
it('direct queue controls treat missing queue ids as the main queue', async () => {
useDownloadStore.setState({
downloads: [
{ id: 'legacy-ready', url: 'http://ready', fileName: 'ready', status: 'ready', category: 'Other', dateAdded: '' },
{ id: 'legacy-active', url: 'http://active', fileName: 'active', status: 'processing', category: 'Other', dateAdded: '' },
] as any[],
});
vi.mocked(ipc.invokeCommand).mockImplementation(async (cmd: string) => {
if (cmd === 'get_pending_order') return ['legacy-ready'];
return undefined;
});
await expect(useDownloadStore.getState().startQueue('00000000-0000-0000-0000-000000000001'))
.resolves.toEqual(['legacy-ready']);
await expect(useDownloadStore.getState().pauseQueue('00000000-0000-0000-0000-000000000001'))
.resolves.toBe(2);
expect(vi.mocked(ipc.invokeCommand).mock.calls.filter(([command]) => command === 'pause_download'))
.toHaveLength(2);
});
it('resumes paused items that were never dispatched through the master start action', async () => {
useDownloadStore.setState({
downloads: [
{
id: 'paused-before-dispatch',
url: 'http://paused-before-dispatch',
fileName: 'paused-before-dispatch',
destination: '/tmp',
status: 'paused',
category: 'Other',
dateAdded: '',
queueId: 'MAIN',
hasBeenDispatched: false
}
] as any[]
});
vi.mocked(ipc.invokeCommand).mockImplementation(async (cmd: string, args?: unknown) => {
if (cmd === 'resume_download') return false;
if (cmd === 'enqueue_download') {
const id = (args as { item: { id: string } }).item.id;
return { id, filename: id };
}
if (cmd === 'get_pending_order') return ['paused-before-dispatch'];
return undefined;
});
await expect(useDownloadStore.getState().startAll()).resolves.toBe(1);
expect(ipc.invokeCommand).toHaveBeenCalledWith('resume_download', {
id: 'paused-before-dispatch',
queueId: 'MAIN'
});
expect(ipc.invokeCommand).toHaveBeenCalledWith(
'enqueue_download',
expect.objectContaining({ item: expect.objectContaining({ id: 'paused-before-dispatch' }) })
);
expect(useDownloadStore.getState().downloads[0].hasBeenDispatched).toBe(true);
});
it('resumes an individually paused item that has no backend registration', async () => {
useDownloadStore.setState({
downloads: [{
id: 'individual-paused-before-dispatch',
url: 'http://individual-paused-before-dispatch',
fileName: 'individual-paused-before-dispatch',
destination: '/tmp',
status: 'paused',
category: 'Other',
dateAdded: '',
queueId: 'MAIN',
hasBeenDispatched: false
}] as any[]
});
vi.mocked(ipc.invokeCommand).mockImplementation(async (cmd: string, args?: unknown) => {
if (cmd === 'resume_download') return false;
if (cmd === 'enqueue_download') {
const id = (args as { item: { id: string } }).item.id;
return { id, filename: id };
}
if (cmd === 'get_pending_order') return ['individual-paused-before-dispatch'];
return undefined;
});
await expect(useDownloadStore.getState().resumeDownload('individual-paused-before-dispatch'))
.resolves.toBe(true);
expect(ipc.invokeCommand).toHaveBeenCalledWith('resume_download', {
id: 'individual-paused-before-dispatch',
queueId: 'MAIN'
});
expect(ipc.invokeCommand).toHaveBeenCalledWith(
'enqueue_download',
expect.objectContaining({ item: expect.objectContaining({ id: 'individual-paused-before-dispatch' }) })
);
expect(useDownloadStore.getState().downloads[0].hasBeenDispatched).toBe(true);
});
it('migrates legacy downloads without queue ids into the main queue', async () => {
vi.mocked(ipc.invokeCommand).mockImplementation(async (cmd: string) => {
if (cmd === 'db_get_all_queues') return [];
@@ -1464,7 +1981,8 @@ describe('useDownloadStore', () => {
useDownloadStore.setState({
downloads: [
{ id: 'ready', status: 'ready', queueId: 'old' },
{ id: 'done', status: 'completed', queueId: 'old' }
{ id: 'done', status: 'completed', queueId: 'old' },
{ id: 'paused', status: 'paused', queueId: 'new', queuePosition: 0 }
] as any[]
});
@@ -1472,6 +1990,11 @@ describe('useDownloadStore', () => {
expect(useDownloadStore.getState().downloads.find(item => item.id === 'ready')?.queueId).toBe('new');
expect(useDownloadStore.getState().downloads.find(item => item.id === 'done')?.queueId).toBe('old');
expect(useDownloadStore.getState().downloads
.filter(item => item.queueId === 'new')
.sort((left, right) => (left.queuePosition ?? 0) - (right.queuePosition ?? 0))
.map(item => item.id)
).toEqual(['ready', 'paused']);
});
it('does not reassign an item that completes while queue assignment is awaiting cancellation', async () => {
@@ -1575,6 +2098,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: [{
+499 -50
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,17 +28,24 @@ 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>;
};
const downloadLifecycleOperations = new Map<string, DownloadLifecycleOperation>();
const preemptDispatch = ['dispatch'] as const;
const preemptStartSelected = ['dispatch', 'start-selected'] as const;
let pendingStartupResume: Promise<void> | null = null;
type DownloadControlIntent = 'pause' | 'resume';
const downloadControlIntents = new Map<string, DownloadControlIntent>();
export interface ResumeDownloadOptions {
preserveQueuePosition?: boolean;
forceRequeue?: boolean;
}
// State events do not carry a lifecycle generation. Keep the intent that
// initiated a control transition long enough for the listener to discard an
// already-emitted event from the previous transition.
@@ -115,8 +126,15 @@ const advanceQueueControlGeneration = (queueId: string): number => {
const isCurrentQueueControlGeneration = (queueId: string, generation: number): boolean =>
currentQueueControlGeneration(queueId) === generation;
const comparableQueuePosition = (download: DownloadItem): number => {
const position = download.queuePosition;
return typeof position === 'number' && Number.isFinite(position) && position >= 0
? position
: Number.MAX_SAFE_INTEGER;
};
const queuePositionComparator = (left: DownloadItem, right: DownloadItem): number =>
(left.queuePosition ?? Number.MAX_SAFE_INTEGER) - (right.queuePosition ?? Number.MAX_SAFE_INTEGER) ||
comparableQueuePosition(left) - comparableQueuePosition(right) ||
left.id.localeCompare(right.id);
const queueItemsForReordering = (downloads: DownloadItem[], queueId: string): DownloadItem[] =>
@@ -150,6 +168,17 @@ const applyQueueOrder = (
: download);
};
// Paused downloads remain part of the queue, but they must not be interleaved
// with the queue's non-paused rows. Keep the partition in the store so
// the table, persistence, and later queue operations all observe the same
// order instead of each deriving a different one.
const reorderQueueWithPausedAtEnd = (downloads: DownloadItem[], queueId: string): DownloadItem[] => {
const queueItems = queueItemsForReordering(downloads, queueId);
const nonPausedItems = queueItems.filter(download => download.status !== 'paused');
const pausedItems = queueItems.filter(download => download.status === 'paused');
return applyQueueOrder(downloads, queueId, [...nonPausedItems, ...pausedItems]);
};
const advanceDownloadLifecycle = (id: string): bigint => {
const nextGeneration = (downloadLifecycleGenerations.get(id) ?? 0n) + 1n;
downloadLifecycleGenerations.set(id, nextGeneration);
@@ -508,16 +537,27 @@ const effectiveDestinationForItem = async (
const normalizeQueuePositions = (downloads: DownloadItem[]): DownloadItem[] => {
const nextPosition = new Map<string, number>();
return downloads.map(download => {
const normalized: DownloadItem[] = downloads.map(download => {
const queueId = download.queueId || MAIN_QUEUE_ID;
const position = nextPosition.get(queueId) || 0;
nextPosition.set(queueId, position + 1);
const persistedPosition = download.queuePosition;
const queuePosition = typeof persistedPosition === 'number' &&
Number.isFinite(persistedPosition) && persistedPosition >= 0
? Math.trunc(persistedPosition)
: position;
return {
...download,
queueId,
queuePosition: download.queuePosition ?? position
queuePosition
};
});
let ordered = normalized;
for (const queueId of new Set(normalized.map(download => download.queueId || MAIN_QUEUE_ID))) {
ordered = reorderQueueWithPausedAtEnd(ordered, queueId);
}
return ordered;
};
const TEMPORARY_MEDIA_ESTIMATE_MAX_BYTES = 1024;
@@ -581,18 +621,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 +677,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 +742,13 @@ 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,
selectedOrder?: readonly string[]
) => Promise<void>;
removeFromQueue: (id: string) => Promise<void>;
isAddModalOpen: boolean;
pendingAddUrls: string;
@@ -695,12 +785,15 @@ interface DownloadState {
removeDownload: (id: string, deleteFile?: boolean, preserveResumable?: boolean) => Promise<void>;
pauseDownload: (id: string) => Promise<void>;
redownload: (id: string) => Promise<void>;
resumeDownload: (id: string) => Promise<boolean>;
resumeDownload: (id: string, options?: ResumeDownloadOptions) => Promise<boolean>;
startSelected: (ids: string[]) => Promise<number>;
startQueue: (queueId: string) => Promise<string[]>;
pauseQueue: (queueId: string) => Promise<number>;
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>;
@@ -758,38 +851,81 @@ export const useDownloadStore = create<DownloadState>((set, get) => {
}
};
const resumeDownloadInternal = async (id: string): Promise<boolean> => {
const resumeDownloadInternal = async (
id: string,
options: ResumeDownloadOptions = {}
): Promise<boolean> => {
await waitForPendingStartupResume();
const targetItem = get().downloads.find(d => d.id === id);
let targetItem = get().downloads.find(d => d.id === id);
if (!targetItem) return false;
setDownloadControlIntent(id, 'resume');
let previousStatus = targetItem.status;
try {
if (targetItem.status === 'ready' || targetItem.status === 'staged') {
if (options.forceRequeue) {
// Fence any older enqueue before replacing a paused backend lifecycle.
// Otherwise a late addUri result can win the race and make this
// selection start outside the requested order.
const { pendingDispatch } = await invalidateDispatch(id);
if (pendingDispatch) await pendingDispatch;
targetItem = get().downloads.find(download => download.id === id);
if (!targetItem || !canStartDownload(targetItem.status)) {
clearDownloadControlIntent(id, 'resume');
return false;
}
previousStatus = targetItem.status;
}
const currentTargetItem = targetItem;
if (
options.forceRequeue &&
currentTargetItem.status === 'paused' &&
get().backendRegisteredIds.has(id)
) {
await invoke('detach_download_for_reconfigure', { id });
get().unregisterBackendIds([id]);
}
if (options.forceRequeue) {
set(state => ({
pendingOrder: state.pendingOrder.filter(value => value !== id)
}));
}
if (currentTargetItem.status === 'ready' || currentTargetItem.status === 'staged') {
get().updateDownload(id, { status: 'queued', hasBeenDispatched: true });
if (await dispatchItemInternal(id)) {
return true;
}
get().updateDownload(id, { status: targetItem.status });
get().updateDownload(id, { status: currentTargetItem.status });
clearDownloadControlIntent(id, 'resume');
return false;
}
const prevStatus = targetItem.status;
const prevStatus = currentTargetItem.status;
const queueItems = get().downloads.filter(d =>
(d.queueId || MAIN_QUEUE_ID) === (targetItem.queueId || MAIN_QUEUE_ID)
(d.queueId || MAIN_QUEUE_ID) === (currentTargetItem.queueId || MAIN_QUEUE_ID)
);
const maxPos = queueItems.reduce((max, d) => Math.max(max, d.queuePosition ?? 0), -1);
const queuePosition = options.preserveQueuePosition
? currentTargetItem.queuePosition
: maxPos + 1;
get().updateDownload(id, {
status: 'queued',
speed: '-',
eta: '-',
queuePosition: maxPos + 1,
...(queuePosition === undefined ? {} : { queuePosition }),
lastTry: new Date().toISOString()
});
const resumedExisting = await invoke('resume_download', { id });
const resumedExisting = options.forceRequeue
? false
: await invoke('resume_download', {
id,
queueId: currentTargetItem.queueId || MAIN_QUEUE_ID
});
let dispatchSucceeded = resumedExisting;
if (!dispatchSucceeded) {
@@ -802,16 +938,24 @@ export const useDownloadStore = create<DownloadState>((set, get) => {
}
if (dispatchSucceeded) {
get().updateDownload(id, { hasBeenDispatched: true });
return true;
} else {
console.error("Failed to re-enqueue for resume");
get().updateDownload(id, { status: prevStatus });
const dispatchError = get().downloads.find(download => download.id === id)?.lastError;
console.error("Failed to re-enqueue for resume:", dispatchError || "backend rejected the enqueue request");
get().updateDownload(id, {
status: prevStatus,
...(dispatchError ? { lastError: dispatchError } : {})
});
clearDownloadControlIntent(id, 'resume');
return false;
}
} catch (e) {
console.error("Failed to resume download:", e);
get().updateDownload(id, { status: targetItem.status });
const current = get().downloads.find(download => download.id === id);
if (current?.status === 'queued') {
get().updateDownload(id, { status: previousStatus });
}
clearDownloadControlIntent(id, 'resume');
return false;
}
@@ -894,6 +1038,106 @@ 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, selectedOrder) => {
const requestedIds = Array.isArray(idOrIds) ? idOrIds : [idOrIds];
const ids = [...new Set(requestedIds)];
const normalizedSelectedOrder = selectedOrder
? [...new Set(selectedOrder)].filter(id => ids.includes(id))
: undefined;
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 = normalizedSelectedOrder
? normalizedSelectedOrder
.map(id => queueItems.find(item => item.id === id))
.filter((item): item is DownloadItem => Boolean(item))
: 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 = normalizedSelectedOrder
? [
...unselectedItems.slice(0, resolvedTargetIndex),
...selectedItems,
...unselectedItems.slice(resolvedTargetIndex)
]
: 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(() => {
@@ -1084,7 +1328,9 @@ export const useDownloadStore = create<DownloadState>((set, get) => {
hasBeenDispatched: false
};
advanceDownloadLifecycle(item.id);
set((state) => ({ downloads: [...state.downloads, ownedItem] }));
set((state) => ({
downloads: reorderQueueWithPausedAtEnd([...state.downloads, ownedItem], queueId)
}));
if (action.type === 'add-to-queue') {
info(`Download ${item.id} added to queue ${action.queueId}`);
@@ -1124,19 +1370,32 @@ export const useDownloadStore = create<DownloadState>((set, get) => {
preemptDispatch
),
updateDownload: (id, updates) => {
set((state) => ({
downloads: state.downloads.map(d => {
if (d.id === id) {
const updated = {
...d,
set((state) => {
const current = state.downloads.find(download => download.id === id);
if (!current) return { downloads: state.downloads };
const downloads = state.downloads.map(d => d.id === id
? {
...d,
...updates,
fraction: updates.fraction !== undefined ? updates.fraction : d.fraction
};
return updated;
}
return d;
})
}));
}
: d
);
const shouldNormalizeQueue = updates.status === 'paused' ||
updates.status === 'queued' ||
updates.status === 'staged';
const updated = downloads.find(download => download.id === id) ?? current;
return {
downloads: shouldNormalizeQueue
? reorderQueueWithPausedAtEnd(downloads, updated.queueId || MAIN_QUEUE_ID)
: downloads,
...(updates.status === 'paused'
? { pendingOrder: state.pendingOrder.filter(value => value !== id) }
: {})
};
});
if (updates.status && ['completed', 'failed', 'paused'].includes(updates.status)) {
info(`Download ${id} status changed to ${updates.status}`);
@@ -1200,7 +1459,7 @@ export const useDownloadStore = create<DownloadState>((set, get) => {
} finally {
clearDownloadControlIntent(id, 'pause');
}
}, true, preemptDispatch),
}, true, preemptStartSelected),
redownload: (id) => runDownloadLifecycleOperation(id, 'redownload', async () => {
await waitForPendingStartupResume();
const targetItem = get().downloads.find(d => d.id === id);
@@ -1251,20 +1510,113 @@ export const useDownloadStore = create<DownloadState>((set, get) => {
info(`Download ${id} redownloaded (queued)`);
}
}, true, preemptDispatch),
resumeDownload: (id) => runDownloadLifecycleOperation(
resumeDownload: (id, options) => runDownloadLifecycleOperation(
id,
'resume',
() => resumeDownloadInternal(id),
() => resumeDownloadInternal(id, options),
true,
preemptDispatch
),
startSelected: (ids) => {
const orderedIds = [...new Set(ids)];
if (orderedIds.length === 0) return Promise.resolve(0);
return runDownloadLifecycleOperations(orderedIds, 'start-selected', async () => {
await waitForPendingStartupResume();
const selectedByQueue = new Map<string, string[]>();
for (const id of orderedIds) {
const item = get().downloads.find(download => download.id === id);
if (!item || !canStartDownload(item.status)) continue;
const queueId = item.queueId || MAIN_QUEUE_ID;
const queueIds = selectedByQueue.get(queueId) || [];
queueIds.push(id);
selectedByQueue.set(queueId, queueIds);
}
const selectedQueueGenerations = new Map(
Array.from(selectedByQueue.keys(), queueId => [
queueId,
advanceQueueControlGeneration(queueId)
] as const)
);
// Make the selection's order explicit before any capacity becomes
// available. This keeps the frontend projection and backend pending
// queue aligned even when the selected rows were not adjacent.
for (const [queueId, queueIds] of selectedByQueue) {
const generation = selectedQueueGenerations.get(queueId);
if (generation === undefined || !isCurrentQueueControlGeneration(queueId, generation)) {
continue;
}
await get().moveManyInQueueToPosition(queueIds, queueId, 0, null, queueIds);
}
let startedCount = 0;
const startedByQueue = new Map<string, string[]>();
for (const id of orderedIds) {
const current = get().downloads.find(download => download.id === id);
if (!current || !canStartDownload(current.status)) continue;
const queueId = current.queueId || MAIN_QUEUE_ID;
const generation = selectedQueueGenerations.get(queueId);
if (generation === undefined || !isCurrentQueueControlGeneration(queueId, generation)) {
continue;
}
const resumed = await resumeDownloadInternal(id, {
preserveQueuePosition: true,
forceRequeue: true
});
if (resumed && !isCurrentQueueControlGeneration(queueId, generation)) {
// A queue pause can win while this item's requeue is in flight. The
// pause action is allowed to preempt this bulk-start operation so
// this item cannot remain running after the user's pause request.
await get().pauseDownload(id);
continue;
}
if (resumed) {
startedCount += 1;
const startedIds = startedByQueue.get(queueId) || [];
startedIds.push(id);
startedByQueue.set(queueId, startedIds);
// A requeued paused item is appended by the backend. Move the
// started prefix immediately so a newly available slot cannot let
// older pending work overtake the remaining selection while it is
// still being requeued.
if (isCurrentQueueControlGeneration(queueId, generation)) {
await get().moveManyInQueueToPosition(
startedIds,
queueId,
0,
null,
startedIds
);
}
}
}
// Newly dispatched rows are now visible to the backend pending list.
// Reapply the same ordered block so existing pending work cannot remain
// ahead of a user-selected start request.
for (const [queueId, queueIds] of selectedByQueue) {
const generation = selectedQueueGenerations.get(queueId);
if (generation === undefined || !isCurrentQueueControlGeneration(queueId, generation)) {
continue;
}
await get().moveManyInQueueToPosition(queueIds, queueId, 0, null, queueIds);
}
return startedCount;
});
},
startQueue: (queueId) => {
const requestedGeneration = currentQueueControlGeneration(queueId);
const previousOperation = queueStartPromises.get(queueId) ?? Promise.resolve([]);
const operation = previousOperation.catch(() => []).then(async () => {
await waitForPendingStartupResume();
const runnable = get().downloads
.filter(item => item.queueId === queueId && (item.status === 'queued' || canStartDownload(item.status)))
.filter(item =>
(item.queueId || MAIN_QUEUE_ID) === queueId &&
(item.status === 'queued' || canStartDownload(item.status))
)
.sort(queuePositionComparator);
if (runnable.length === 0 || !isCurrentQueueControlGeneration(queueId, requestedGeneration)) return [];
@@ -1272,6 +1624,10 @@ export const useDownloadStore = create<DownloadState>((set, get) => {
const needsNewDispatch = runnable.some(item => {
const currentItem = get().downloads.find(download => download.id === item.id);
if (!currentItem) return false;
// Paused rows must go through resumeDownload. This includes rows that
// were paused before their first dispatch, for which dispatchItem
// would reject the paused status before reaching the backend.
if (currentItem.status === 'paused') return false;
const backendRegistered = get().backendRegisteredIds.has(item.id);
const backendPending = get().pendingOrder.includes(item.id);
if (currentItem.status === 'queued' && backendRegistered && !backendPending) {
@@ -1311,8 +1667,26 @@ export const useDownloadStore = create<DownloadState>((set, get) => {
const backendRegistered = get().backendRegisteredIds.has(item.id);
const backendPending = get().pendingOrder.includes(item.id);
if (currentItem.status === 'paused') {
const resumed = await get().resumeDownload(item.id, { preserveQueuePosition: true });
if (!resumed) continue;
if (!isCurrentQueueControlGeneration(queueId, requestedGeneration)) {
const afterResume = get().downloads.find(download => download.id === item.id);
if (
backendDispatchPromises.has(item.id) ||
get().backendRegisteredIds.has(item.id) ||
(afterResume && canPauseDownload(afterResume.status))
) {
await get().pauseDownload(item.id);
}
continue;
}
acceptedIds.push(item.id);
continue;
}
if (currentItem.status === 'queued' && backendRegistered && !backendPending) {
if (await get().resumeDownload(item.id)) {
if (await get().resumeDownload(item.id, { preserveQueuePosition: true })) {
acceptedIds.push(item.id);
}
continue;
@@ -1344,12 +1718,8 @@ export const useDownloadStore = create<DownloadState>((set, get) => {
});
acceptedIds.push(item.id);
}
} else if (currentItem.status === 'paused' || currentItem.status === 'queued') {
} else if (currentItem.status === 'queued') {
// If it's queued but already dispatched, it might be waiting.
// If it's paused, we resume it.
if (currentItem.status === 'paused') {
if (!await get().resumeDownload(item.id)) continue;
}
acceptedIds.push(item.id);
}
}
@@ -1373,7 +1743,7 @@ export const useDownloadStore = create<DownloadState>((set, get) => {
advanceQueueControlGeneration(queueId);
const activeIds = get().downloads
.filter(item =>
item.queueId === queueId &&
(item.queueId || MAIN_QUEUE_ID) === queueId &&
(canPauseDownload(item.status) || backendDispatchPromises.has(item.id))
)
.map(item => item.id);
@@ -1453,8 +1823,8 @@ export const useDownloadStore = create<DownloadState>((set, get) => {
);
const maxPos = queueItems.reduce((max, d) => Math.max(max, d.queuePosition ?? 0), -1);
const nextPosition = maxPos + 1;
set(state => ({
downloads: state.downloads.map(item =>
set(state => {
const downloads = state.downloads.map(item =>
movableIds.has(item.id) && item.status !== 'completed'
? {
...item,
@@ -1464,10 +1834,84 @@ export const useDownloadStore = create<DownloadState>((set, get) => {
hasBeenDispatched: false
}
: item
)
}));
);
return { downloads: reorderQueueWithPausedAtEnd(downloads, queueId) };
});
});
},
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 +2133,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,14 +2157,19 @@ 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) => ({
downloads: state.downloads.map(d =>
downloads: normalizeQueuePositions(state.downloads.map(d =>
isActiveDownloadStatus(d.status) && d.status !== 'queued'
? { ...d, status: 'queued' as const }
: d
)
))
}));
} catch (e) {
+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
+212
View File
@@ -1,14 +1,22 @@
import { describe, expect, it } from 'vitest';
import {
appendRequestUrlsAfterVersion,
commonMediaFormatsForRows,
canSubmitMetadataRows,
commonMediaQualitiesForRows,
durableDownloadUrl,
mediaFormatSelectorForRow,
mediaFileNameForSelectedFormat,
mediaFormatForFormat,
mediaQualityForRow,
mediaTypeForFormat,
metadataSummaryMessage,
isYouTubePlaylistUrl,
playlistFilePrefix,
reconcileDownloadRows,
refreshFailedMetadataRows,
selectExactMediaSelection,
selectExactMediaQuality,
updateRowIfCurrent,
type AddDownloadDraftRow
} from './addDownloadMetadata';
@@ -28,6 +36,14 @@ const row = (
});
describe('add download metadata workflow', () => {
it('keeps the stable source URL instead of persisting a resolved redirect', () => {
const sourceUrl = 'https://github.com/example/project/releases/download/v1/file.zip';
const signedRedirect = 'https://release-assets.githubusercontent.com/github-production-release-asset/asset?se=2099-01-01T00%3A00%3A00Z&sig=redacted';
expect(durableDownloadUrl(sourceUrl)).toBe(sourceUrl);
expect(durableDownloadUrl(sourceUrl)).not.toBe(signedRedirect);
});
it('preserves rows by normalized source URL and creates only new rows', () => {
const existing = row({ file: 'server-name.zip' });
let nextId = 0;
@@ -539,6 +555,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' })];
+147
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;
@@ -45,6 +54,13 @@ export interface AddDownloadDraftRow {
selected?: boolean;
}
/**
* Redirect targets are request-time details, not durable download identity.
* Providers such as GitHub sign those targets with short-lived credentials,
* so retries must start from the source URL and resolve a fresh target.
*/
export const durableDownloadUrl = (sourceUrl: string): string => sourceUrl.trim();
const ALLOWED_SCHEMES = new Set(['http:', 'https:', 'ftp:', 'sftp:']);
type ParsedInput = {
@@ -331,6 +347,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 '-';
}
}
};
+43 -2
View File
@@ -3,19 +3,27 @@ import {
canPauseDownload,
canRedownload,
canStartDownload,
countDownloadActions,
formatDownloadActionCount,
getPauseResumeAction,
isIdentityLocked,
isTransferLocked,
startActionLabel,
} from './downloadActions';
describe('download action policy', () => {
it('keeps start and pause actions mutually exclusive', () => {
it('allows queued items to be paused before their first dispatch', () => {
expect(canStartDownload('staged')).toBe(true);
expect(canPauseDownload('staged')).toBe(true);
for (const status of ['ready', 'paused', 'failed'] as const) {
expect(canStartDownload(status)).toBe(true);
expect(canPauseDownload(status)).toBe(false);
}
for (const status of ['queued', 'downloading', 'processing', 'retrying'] as const) {
for (const status of ['staged', 'queued', 'downloading', 'processing', 'retrying'] as const) {
expect(canPauseDownload(status)).toBe(true);
}
for (const status of ['queued', 'downloading', 'processing', 'retrying'] as const) {
expect(canStartDownload(status)).toBe(false);
}
});
@@ -27,6 +35,19 @@ 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', 'completed', 'failed'] as const) {
expect(getPauseResumeAction(status)).toBeNull();
}
expect(getPauseResumeAction('staged')).toBe('pause');
});
it('provides consistent labels and edit locks', () => {
expect(startActionLabel('ready')).toBe('Start');
expect(startActionLabel('failed')).toBe('Start');
@@ -35,4 +56,24 @@ describe('download action policy', () => {
expect(isIdentityLocked('completed')).toBe(true);
expect(isTransferLocked('completed')).toBe(false);
});
it('counts only eligible actions for a multi-selection', () => {
const counts = countDownloadActions([
{ status: 'queued' },
{ status: 'downloading' },
{ status: 'paused' },
{ status: 'ready' },
{ status: 'staged' },
{ status: 'failed' },
{ status: 'completed' },
]);
expect(counts).toEqual({ pause: 3, resume: 4 });
});
it('keeps large action badges compact without changing the accessible count', () => {
expect(formatDownloadActionCount(2)).toBe('2');
expect(formatDownloadActionCount(99)).toBe('99');
expect(formatDownloadActionCount(100)).toBe('99+');
});
});
+30
View File
@@ -8,6 +8,7 @@ const STARTABLE_STATUSES: ReadonlySet<DownloadStatus> = new Set([
]);
const PAUSABLE_STATUSES: ReadonlySet<DownloadStatus> = new Set([
'staged',
'queued',
'downloading',
'processing',
@@ -26,6 +27,35 @@ export const canStartDownload = (status: DownloadStatus): boolean =>
export const canPauseDownload = (status: DownloadStatus): boolean =>
PAUSABLE_STATUSES.has(status);
export interface DownloadActionCounts {
pause: number;
resume: number;
}
/**
* Count the actions that a bulk pause/resume control can actually apply.
* Keep this derived from the same predicates used by the individual row
* buttons so a badge never promises to affect an ineligible item.
*/
export const countDownloadActions = (
downloads: ReadonlyArray<{ status: DownloadStatus }>
): DownloadActionCounts => downloads.reduce<DownloadActionCounts>((counts, download) => {
if (canPauseDownload(download.status)) counts.pause += 1;
if (canStartDownload(download.status)) counts.resume += 1;
return counts;
}, { pause: 0, resume: 0 });
export const formatDownloadActionCount = (count: number): string =>
count > 99 ? '99+' : String(count);
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);
+47
View File
@@ -0,0 +1,47 @@
import { describe, expect, it } from 'vitest';
import { updateDownloadSelection } from './downloadSelection';
const orderedIds = ['a', 'b', 'c', 'd'];
describe('download selection', () => {
it('collapses select-all to the clicked row', () => {
const result = updateDownloadSelection({
orderedIds,
selectedIds: new Set(orderedIds),
lastSelectedId: 'd',
targetId: 'b',
extendRange: false,
toggle: false,
});
expect([...result.selectedIds]).toEqual(['b']);
expect(result.lastSelectedId).toBe('b');
});
it('toggles a row without losing the rest of the selection', () => {
const result = updateDownloadSelection({
orderedIds,
selectedIds: new Set(['a', 'c']),
lastSelectedId: 'a',
targetId: 'c',
extendRange: false,
toggle: true,
});
expect([...result.selectedIds]).toEqual(['a']);
});
it('extends from the existing anchor for a range selection', () => {
const result = updateDownloadSelection({
orderedIds,
selectedIds: new Set(['a']),
lastSelectedId: 'a',
targetId: 'c',
extendRange: true,
toggle: false,
});
expect([...result.selectedIds]).toEqual(['a', 'b', 'c']);
expect(result.lastSelectedId).toBe('a');
});
});
+53
View File
@@ -0,0 +1,53 @@
export interface DownloadSelectionResult {
selectedIds: Set<string>;
lastSelectedId: string | null;
}
interface DownloadSelectionOptions {
orderedIds: readonly string[];
selectedIds: ReadonlySet<string>;
lastSelectedId: string | null;
targetId: string;
extendRange: boolean;
toggle: boolean;
}
/** Apply the table's click-selection rules without depending on React state timing. */
export const updateDownloadSelection = ({
orderedIds,
selectedIds,
lastSelectedId,
targetId,
extendRange,
toggle,
}: DownloadSelectionOptions): DownloadSelectionResult => {
const targetIndex = orderedIds.indexOf(targetId);
if (targetIndex === -1) {
return { selectedIds: new Set(selectedIds), lastSelectedId };
}
if (extendRange && lastSelectedId) {
const anchorIndex = orderedIds.indexOf(lastSelectedId);
if (anchorIndex !== -1) {
const nextSelectedIds = toggle ? new Set(selectedIds) : new Set<string>();
const start = Math.min(anchorIndex, targetIndex);
const end = Math.max(anchorIndex, targetIndex);
for (let index = start; index <= end; index += 1) {
nextSelectedIds.add(orderedIds[index]);
}
return { selectedIds: nextSelectedIds, lastSelectedId };
}
}
if (toggle) {
const nextSelectedIds = new Set(selectedIds);
if (nextSelectedIds.has(targetId)) {
nextSelectedIds.delete(targetId);
} else {
nextSelectedIds.add(targetId);
}
return { selectedIds: nextSelectedIds, lastSelectedId: targetId };
}
return { selectedIds: new Set([targetId]), lastSelectedId: targetId };
};
+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,
};
};
+47 -4
View File
@@ -4,6 +4,7 @@ import {
DEFAULT_COLUMN_ORDER,
DEFAULT_COLUMN_WIDTHS,
buildColumnGridTemplate,
getDownloadActionPosition,
getColumnGridColumn,
normalizeColumnAlignments,
normalizeColumnOrder,
@@ -54,12 +55,54 @@ 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: 28,
visibility: 'visible',
});
expect(getDownloadActionPosition(
{ top: 40, right: 400, bottom: 72, left: 100 },
viewport,
viewport,
800,
16
)).toMatchObject({
right: 416,
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');
});
});
+51 -6
View File
@@ -14,10 +14,56 @@ 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.
// Keep the viewport-anchored rail clear of the window edge and scrollbar.
export const DOWNLOAD_ACTIONS_VIEWPORT_INSET = 8;
// Match the 28px master-control group height.
export const DOWNLOAD_ACTIONS_MAX_HEIGHT = 28;
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,
actionInset = DOWNLOAD_ACTIONS_VIEWPORT_INSET
): 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);
const safeActionInset = Number.isFinite(actionInset)
? Math.max(0, actionInset)
: DOWNLOAD_ACTIONS_VIEWPORT_INSET;
return {
top: visibleTop + Math.max(0, (visibleHeight - actionHeight) / 2),
right: Math.max(0, windowWidth - actionRightEdge) + safeActionInset,
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 +145,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 +154,5 @@ export const getColumnGridColumn = (
key: DownloadTableColumnKey,
order: DownloadTableColumnKey[]
): string | undefined => key === order[order.length - 1]
? `${order.length + 1}`
? `${order.length}`
: undefined;
+50
View File
@@ -0,0 +1,50 @@
import { describe, expect, it } from 'vitest';
import { clampFloatingPosition, positionFloatingSubmenu } from './floatingPosition';
describe('floating surface positioning', () => {
it('keeps a variable-height menu inside the viewport', () => {
expect(clampFloatingPosition(350, 580, 192, 220, 400, 640)).toEqual({
x: 200,
y: 412,
});
});
it('keeps oversized surfaces anchored to the safe gutter', () => {
expect(clampFloatingPosition(-20, -10, 700, 900, 400, 640)).toEqual({
x: 8,
y: 8,
});
});
it('opens a submenu to the right when there is room', () => {
expect(positionFloatingSubmenu({ left: 200, right: 280, top: 120 }, 160, 180, 800, 600)).toEqual({
x: 284,
y: 120,
side: 'right',
});
});
it('flips a submenu to the left at the viewport edge', () => {
expect(positionFloatingSubmenu({ left: 700, right: 780, top: 120 }, 160, 180, 800, 600)).toEqual({
x: 536,
y: 120,
side: 'left',
});
});
it('clamps a submenu vertically when its trigger is near the bottom', () => {
expect(positionFloatingSubmenu({ left: 200, right: 280, top: 580 }, 160, 180, 800, 600)).toEqual({
x: 284,
y: 412,
side: 'right',
});
});
it('prefers the inline-start side in RTL when both sides fit', () => {
expect(positionFloatingSubmenu({ left: 400, right: 480, top: 120 }, 160, 180, 800, 600, 8, 4, 'left')).toEqual({
x: 236,
y: 120,
side: 'left',
});
});
});
+63
View File
@@ -0,0 +1,63 @@
export interface FloatingPosition {
x: number;
y: number;
}
export type FloatingSubmenuSide = 'left' | 'right';
export interface FloatingSubmenuPosition extends FloatingPosition {
side: FloatingSubmenuSide;
}
/** Keep a fixed-position surface inside the viewport with a small safe gutter. */
export const clampFloatingPosition = (
x: number,
y: number,
width: number,
height: number,
viewportWidth: number,
viewportHeight: number,
gutter = 8
): FloatingPosition => {
const maxX = Math.max(gutter, viewportWidth - width - gutter);
const maxY = Math.max(gutter, viewportHeight - height - gutter);
return {
x: Math.min(Math.max(gutter, x), maxX),
y: Math.min(Math.max(gutter, y), maxY),
};
};
/** Place a submenu beside its trigger, flipping sides before clamping to the viewport. */
export const positionFloatingSubmenu = (
trigger: Pick<DOMRect, 'left' | 'right' | 'top'>,
width: number,
height: number,
viewportWidth: number,
viewportHeight: number,
gutter = 8,
gap = 4,
preferredSide: FloatingSubmenuSide = 'right'
): FloatingSubmenuPosition => {
const rightX = trigger.right + gap;
const leftX = trigger.left - width - gap;
const rightFits = rightX + width <= viewportWidth - gutter;
const leftFits = leftX >= gutter;
const preferredFits = preferredSide === 'right' ? rightFits : leftFits;
const alternateFits = preferredSide === 'right' ? leftFits : rightFits;
const side = preferredFits || !alternateFits
? preferredSide
: preferredSide === 'right' ? 'left' : 'right';
const preferredX = side === 'right' ? rightX : leftX;
const position = clampFloatingPosition(
preferredX,
trigger.top,
width,
height,
viewportWidth,
viewportHeight,
gutter
);
return { ...position, side };
};
+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);
};
+35
View File
@@ -0,0 +1,35 @@
import { describe, expect, it } from 'vitest';
import { getWindowControlRevealOffset, 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');
});
it('reserves space after the complete custom-control footprint', () => {
expect(getWindowControlRevealOffset('macos')).toBe(88);
expect(getWindowControlRevealOffset('windows')).toBe(168);
expect(getWindowControlRevealOffset('gnome')).toBe(134);
expect(getWindowControlRevealOffset('minimal')).toBe(104);
});
});
+29
View File
@@ -0,0 +1,29 @@
import type { WindowControlStyle } from '../bindings/WindowControlStyle';
export type ResolvedWindowControlStyle = Exclude<WindowControlStyle, 'auto'>;
// The reveal button sits after the complete custom-control hit area. Keep this
// derived from the resolved style so a sidebar toggle can never overlap a
// platform-specific control footprint.
const WINDOW_CONTROL_REVEAL_OFFSETS: Record<ResolvedWindowControlStyle, number> = {
macos: 88,
windows: 168,
gnome: 134,
minimal: 104,
};
export const getWindowControlRevealOffset = (style: ResolvedWindowControlStyle): number =>
WINDOW_CONTROL_REVEAL_OFFSETS[style];
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';
};