mirror of
https://github.com/nimbold/Firelink.git
synced 2026-08-01 07:02:41 +00:00
Compare commits
65 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4df6315be1 | |||
| 29bbb009b5 | |||
| 546be23c91 | |||
| ce8a5499c3 | |||
| 0e50bdfe9e | |||
| f4ef1a4b28 | |||
| 1044b6c619 | |||
| 07f45959d0 | |||
| 26e0096215 | |||
| 197918eab5 | |||
| 23288ca4e8 | |||
| 807663cf03 | |||
| 62f8c83c57 | |||
| 7e8f1c7d7b | |||
| 5633896d14 | |||
| 7795b3bcf8 | |||
| edc76a7fab | |||
| eb73dde911 | |||
| bff7d2782c | |||
| 8cf1bf7897 | |||
| 5db9388d07 | |||
| 61dfcf5d8a | |||
| d5c3fc8b27 | |||
| d895fb1b73 | |||
| 5372a76eda | |||
| 54693456b1 | |||
| edc0c1c6ad | |||
| c10b889a41 | |||
| 530324ab9c | |||
| 240055497d | |||
| 7787f4e6c0 | |||
| cf09bfa2e4 | |||
| 931e1f2f1e | |||
| 956b70dc10 | |||
| dd7c2196fe | |||
| d62e0429a4 | |||
| f3af5b0584 | |||
| 5614022dbd | |||
| b1659fa3d0 | |||
| 9a40b30a0f | |||
| 4f89a6c79d | |||
| f5d918ec1e | |||
| b579c35e42 | |||
| 1f1ec828f9 | |||
| 228db1eaa2 | |||
| 9a790073f5 | |||
| f3eff02069 | |||
| 4374219685 | |||
| 4e3ceb21a5 | |||
| 6adbccdf43 | |||
| 454fad712b | |||
| 7f450a802f | |||
| f2b1fb9d4a | |||
| 9302911ac7 | |||
| 18e9200b74 | |||
| 3559c69968 | |||
| 3587fb0c0d | |||
| b60818d3af | |||
| 5463d27ed3 | |||
| 3f298715a6 | |||
| ceab8a5fdf | |||
| f4e5e211cc | |||
| 9d737598c6 | |||
| d3e5e8c208 | |||
| 17eb8e5d39 |
@@ -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
@@ -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 user’s 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 system’s 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.
|
||||
|
||||
+1
-1
Submodule Extensions/Browser updated: 8a9a4c4a63...6f1b50bee4
@@ -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.
|
||||
|
||||
[](https://github.com/nimbold/Firelink/releases)
|
||||
[](#supported-platforms)
|
||||
[](#supported-platforms)
|
||||
[](#supported-platforms)
|
||||
[](LICENSE)
|
||||
[](https://github.com/nimbold/Firelink/actions/workflows/ci.yml)
|
||||
[](https://github.com/nimbold/Firelink/releases/latest)
|
||||
[](https://github.com/nimbold/Firelink/actions/workflows/ci.yml)
|
||||
[](LICENSE)
|
||||
[](#installation)
|
||||
[](#installation)
|
||||
[](#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>
|
||||
|
||||
<a href="https://github.com/nimbold/Firelink-Extension#manual-chromium-installation"><img src="https://img.shields.io/badge/Manual%20install-Chromium-4285F4?style=for-the-badge&logo=googlechrome&logoColor=white" alt="Read manual Chromium install instructions" /></a>
|
||||
<p>
|
||||
<a href="https://addons.mozilla.org/en-US/firefox/addon/firelink-companion/"><img src="https://img.shields.io/badge/Install%20from-Firefox%20Add--ons-FF7139?style=for-the-badge&logo=firefox-browser&logoColor=white" alt="Install from Firefox Add-ons" /></a>
|
||||
<a href="https://github.com/nimbold/Firelink-Extension#manual-chromium-installation"><img src="https://img.shields.io/badge/Manual%20install-Chromium-4285F4?style=for-the-badge&logo=googlechrome&logoColor=white" alt="Manual install for Chromium browsers" /></a>
|
||||
</p>
|
||||
|
||||
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.
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -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
@@ -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": {
|
||||
|
||||
@@ -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>
|
||||
|
||||
Generated
+206
-124
@@ -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
@@ -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",
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
Generated
+38
-48
@@ -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,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.
@@ -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(());
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,419 @@
|
||||
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
|
||||
use std::sync::mpsc::{self, Receiver, Sender};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::thread::{self, JoinHandle};
|
||||
|
||||
const ASSERTION_REASON: &str = "Firelink active download";
|
||||
|
||||
/// Owns the OS power assertions for the whole backend transfer lifecycle.
|
||||
///
|
||||
/// The frontend may request the policy, but it does not own the assertion:
|
||||
/// queue permits are the source of truth for whether a transfer is actually
|
||||
/// active. Keeping the two assertions separate also means display sleep can
|
||||
/// be opted into without changing the system-sleep policy.
|
||||
struct PowerState {
|
||||
runtime_enabled: AtomicBool,
|
||||
prevent_system_sleep: AtomicBool,
|
||||
prevent_display_sleep: AtomicBool,
|
||||
active_transfers: AtomicUsize,
|
||||
}
|
||||
|
||||
enum PowerCommand {
|
||||
Reconcile(Option<Sender<Result<(), String>>>),
|
||||
}
|
||||
|
||||
struct PowerWorker {
|
||||
sender: Option<Sender<PowerCommand>>,
|
||||
join_handle: Option<JoinHandle<()>>,
|
||||
}
|
||||
|
||||
impl PowerWorker {
|
||||
fn start(state: Arc<PowerState>) -> Result<Self, String> {
|
||||
let (sender, receiver) = mpsc::channel();
|
||||
let join_handle = thread::Builder::new()
|
||||
.name("firelink-power".to_string())
|
||||
.spawn(move || run_power_worker(state, receiver))
|
||||
.map_err(|error| format!("failed to start power-management worker: {error}"))?;
|
||||
Ok(Self {
|
||||
sender: Some(sender),
|
||||
join_handle: Some(join_handle),
|
||||
})
|
||||
}
|
||||
|
||||
fn send(&self, command: PowerCommand) -> Result<(), String> {
|
||||
self.sender
|
||||
.as_ref()
|
||||
.ok_or_else(|| "power-management worker is stopping".to_string())?
|
||||
.send(command)
|
||||
.map_err(|_| "power-management worker stopped unexpectedly".to_string())
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for PowerWorker {
|
||||
fn drop(&mut self) {
|
||||
// Closing the sender lets the worker drop its OS assertions on the
|
||||
// same thread that created them. This matters on Windows, where
|
||||
// SetThreadExecutionState is thread-local, and avoids calling the
|
||||
// Linux keepawake D-Bus destructor from an arbitrary queue thread.
|
||||
self.sender.take();
|
||||
if let Some(join_handle) = self.join_handle.take() {
|
||||
let _ = join_handle.join();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct PowerManager {
|
||||
state: Arc<PowerState>,
|
||||
worker: Mutex<Option<PowerWorker>>,
|
||||
}
|
||||
|
||||
impl PowerManager {
|
||||
pub fn new() -> Arc<Self> {
|
||||
Arc::new(Self {
|
||||
state: Arc::new(PowerState {
|
||||
runtime_enabled: AtomicBool::new(false),
|
||||
prevent_system_sleep: AtomicBool::new(true),
|
||||
prevent_display_sleep: AtomicBool::new(false),
|
||||
active_transfers: AtomicUsize::new(0),
|
||||
}),
|
||||
worker: Mutex::new(None),
|
||||
})
|
||||
}
|
||||
|
||||
/// Enables OS integration after AppState has been registered. This keeps
|
||||
/// QueueManager tests and pre-window startup work from creating real
|
||||
/// assertions in the test process or during partial initialization.
|
||||
pub fn activate(&self) -> Result<(), String> {
|
||||
self.state.runtime_enabled.store(true, Ordering::Release);
|
||||
self.reconcile()
|
||||
}
|
||||
|
||||
pub fn set_preferences(
|
||||
&self,
|
||||
prevent_system_sleep: bool,
|
||||
prevent_display_sleep: bool,
|
||||
) -> Result<(), String> {
|
||||
self.state
|
||||
.prevent_system_sleep
|
||||
.store(prevent_system_sleep, Ordering::Release);
|
||||
self.state
|
||||
.prevent_display_sleep
|
||||
.store(prevent_display_sleep, Ordering::Release);
|
||||
self.reconcile()
|
||||
}
|
||||
|
||||
pub fn set_system_prevention(&self, enabled: bool) -> Result<(), String> {
|
||||
self.state
|
||||
.prevent_system_sleep
|
||||
.store(enabled, Ordering::Release);
|
||||
self.reconcile()
|
||||
}
|
||||
|
||||
pub fn set_display_prevention(&self, enabled: bool) -> Result<(), String> {
|
||||
self.state
|
||||
.prevent_display_sleep
|
||||
.store(enabled, Ordering::Release);
|
||||
self.reconcile()
|
||||
}
|
||||
|
||||
pub fn set_active_transfer_count(self: &Arc<Self>, count: usize) {
|
||||
self.state.active_transfers.store(count, Ordering::Release);
|
||||
if self.state.runtime_enabled.load(Ordering::Acquire) {
|
||||
if let Err(error) = self.enqueue_reconcile() {
|
||||
log::error!(
|
||||
"power: failed to schedule assertion reconciliation for {count} active transfer(s): {error}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn active_transfer_count(&self) -> usize {
|
||||
self.state.active_transfers.load(Ordering::Acquire)
|
||||
}
|
||||
|
||||
fn reconcile(&self) -> Result<(), String> {
|
||||
if !self.state.runtime_enabled.load(Ordering::Acquire) {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
self.ensure_worker()?;
|
||||
let (response_sender, response_receiver) = mpsc::channel();
|
||||
if let Err(error) = self.send_command(PowerCommand::Reconcile(Some(response_sender))) {
|
||||
self.restart_worker();
|
||||
return Err(error);
|
||||
}
|
||||
match response_receiver.recv() {
|
||||
Ok(result) => result,
|
||||
Err(_) => {
|
||||
self.restart_worker();
|
||||
Err("power-management worker stopped before reporting reconciliation".to_string())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn enqueue_reconcile(&self) -> Result<(), String> {
|
||||
self.ensure_worker()?;
|
||||
if let Err(error) = self.send_command(PowerCommand::Reconcile(None)) {
|
||||
self.restart_worker();
|
||||
return Err(error);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn ensure_worker(&self) -> Result<(), String> {
|
||||
let mut worker = self
|
||||
.worker
|
||||
.lock()
|
||||
.unwrap_or_else(|error| error.into_inner());
|
||||
if worker.is_none() {
|
||||
*worker = Some(PowerWorker::start(Arc::clone(&self.state))?);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn send_command(&self, command: PowerCommand) -> Result<(), String> {
|
||||
let worker = self
|
||||
.worker
|
||||
.lock()
|
||||
.unwrap_or_else(|error| error.into_inner());
|
||||
worker
|
||||
.as_ref()
|
||||
.ok_or_else(|| "power-management worker is not running".to_string())?
|
||||
.send(command)
|
||||
}
|
||||
|
||||
fn restart_worker(&self) {
|
||||
let mut worker = self
|
||||
.worker
|
||||
.lock()
|
||||
.unwrap_or_else(|error| error.into_inner());
|
||||
worker.take();
|
||||
}
|
||||
}
|
||||
|
||||
struct OsPowerAssertions {
|
||||
#[cfg(windows)]
|
||||
assertion: Option<keepawake::KeepAwake>,
|
||||
#[cfg(not(windows))]
|
||||
system: Option<keepawake::KeepAwake>,
|
||||
#[cfg(not(windows))]
|
||||
display: Option<keepawake::KeepAwake>,
|
||||
system_enabled: bool,
|
||||
display_enabled: bool,
|
||||
}
|
||||
|
||||
impl OsPowerAssertions {
|
||||
fn reconcile(&mut self, state: &PowerState) -> Result<(), String> {
|
||||
let active = state.active_transfers.load(Ordering::Acquire);
|
||||
let desired_system = active > 0 && state.prevent_system_sleep.load(Ordering::Acquire);
|
||||
let desired_display = active > 0 && state.prevent_display_sleep.load(Ordering::Acquire);
|
||||
|
||||
#[cfg(windows)]
|
||||
{
|
||||
return self.reconcile_combined(desired_system, desired_display, active);
|
||||
}
|
||||
#[cfg(not(windows))]
|
||||
self.reconcile_independent(desired_system, desired_display, active)
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn reconcile_combined(
|
||||
&mut self,
|
||||
desired_system: bool,
|
||||
desired_display: bool,
|
||||
active: usize,
|
||||
) -> Result<(), String> {
|
||||
if self.system_enabled == desired_system && self.display_enabled == desired_display {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if let Some(assertion) = self.assertion.take() {
|
||||
drop_assertion(assertion, "combined");
|
||||
if self.system_enabled {
|
||||
log::info!("power: system-sleep prevention released");
|
||||
}
|
||||
if self.display_enabled {
|
||||
log::info!("power: display-sleep prevention released");
|
||||
}
|
||||
self.system_enabled = false;
|
||||
self.display_enabled = false;
|
||||
}
|
||||
|
||||
if desired_system || desired_display {
|
||||
let assertion = create_assertion(|| {
|
||||
keepawake::Builder::default()
|
||||
.display(desired_display)
|
||||
.idle(desired_system)
|
||||
.sleep(desired_system)
|
||||
.reason(ASSERTION_REASON)
|
||||
.create()
|
||||
})
|
||||
.map_err(|error| format!("failed to apply power prevention: {error}"))?;
|
||||
self.assertion = Some(assertion);
|
||||
self.system_enabled = desired_system;
|
||||
self.display_enabled = desired_display;
|
||||
if desired_system {
|
||||
log::info!(
|
||||
"power: system-sleep prevention enabled for {active} active transfer(s)"
|
||||
);
|
||||
}
|
||||
if desired_display {
|
||||
log::info!(
|
||||
"power: display-sleep prevention enabled for {active} active transfer(s)"
|
||||
);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
fn reconcile_independent(
|
||||
&mut self,
|
||||
desired_system: bool,
|
||||
desired_display: bool,
|
||||
active: usize,
|
||||
) -> Result<(), String> {
|
||||
let mut first_error = None;
|
||||
if desired_system {
|
||||
if self.system.is_none() {
|
||||
match create_assertion(|| {
|
||||
keepawake::Builder::default()
|
||||
.idle(true)
|
||||
.sleep(true)
|
||||
.reason(ASSERTION_REASON)
|
||||
.create()
|
||||
}) {
|
||||
Ok(assertion) => {
|
||||
self.system = Some(assertion);
|
||||
self.system_enabled = true;
|
||||
log::info!(
|
||||
"power: system-sleep prevention enabled for {active} active transfer(s)"
|
||||
);
|
||||
}
|
||||
Err(error) => {
|
||||
first_error = Some(format!("failed to prevent system sleep: {error}"))
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if let Some(assertion) = self.system.take() {
|
||||
drop_assertion(assertion, "system-sleep");
|
||||
self.system_enabled = false;
|
||||
log::info!("power: system-sleep prevention released");
|
||||
}
|
||||
|
||||
if desired_display {
|
||||
if self.display.is_none() {
|
||||
match create_assertion(|| {
|
||||
keepawake::Builder::default()
|
||||
.display(true)
|
||||
.reason(ASSERTION_REASON)
|
||||
.create()
|
||||
}) {
|
||||
Ok(assertion) => {
|
||||
self.display = Some(assertion);
|
||||
self.display_enabled = true;
|
||||
log::info!(
|
||||
"power: display-sleep prevention enabled for {active} active transfer(s)"
|
||||
);
|
||||
}
|
||||
Err(error) => {
|
||||
let display_error = format!("failed to prevent display sleep: {error}");
|
||||
if first_error.is_none() {
|
||||
first_error = Some(display_error);
|
||||
} else {
|
||||
log::error!("power: {display_error}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if let Some(assertion) = self.display.take() {
|
||||
drop_assertion(assertion, "display-sleep");
|
||||
self.display_enabled = false;
|
||||
log::info!("power: display-sleep prevention released");
|
||||
}
|
||||
|
||||
first_error.map_or(Ok(()), Err)
|
||||
}
|
||||
}
|
||||
|
||||
fn create_assertion(
|
||||
create: impl FnOnce() -> keepawake::Result<keepawake::KeepAwake>,
|
||||
) -> Result<keepawake::KeepAwake, String> {
|
||||
std::panic::catch_unwind(std::panic::AssertUnwindSafe(create))
|
||||
.map_err(|_| "keepawake assertion creation panicked".to_string())?
|
||||
.map_err(|error| error.to_string())
|
||||
}
|
||||
|
||||
fn drop_assertion(assertion: keepawake::KeepAwake, kind: &str) {
|
||||
if std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| drop(assertion))).is_err() {
|
||||
log::error!("power: keepawake {kind} assertion cleanup panicked");
|
||||
}
|
||||
}
|
||||
|
||||
fn run_power_worker(state: Arc<PowerState>, receiver: Receiver<PowerCommand>) {
|
||||
let mut assertions = OsPowerAssertions {
|
||||
#[cfg(windows)]
|
||||
assertion: None,
|
||||
#[cfg(not(windows))]
|
||||
system: None,
|
||||
#[cfg(not(windows))]
|
||||
display: None,
|
||||
system_enabled: false,
|
||||
display_enabled: false,
|
||||
};
|
||||
while let Ok(PowerCommand::Reconcile(response)) = receiver.recv() {
|
||||
let result = assertions.reconcile(&state);
|
||||
if let Some(response) = response {
|
||||
let _ = response.send(result);
|
||||
} else if let Err(error) = result {
|
||||
let active = state.active_transfers.load(Ordering::Acquire);
|
||||
log::error!(
|
||||
"power: failed to reconcile assertions for {active} active transfer(s): {error}"
|
||||
);
|
||||
}
|
||||
}
|
||||
#[cfg(windows)]
|
||||
if let Some(assertion) = assertions.assertion.take() {
|
||||
drop_assertion(assertion, "combined");
|
||||
}
|
||||
#[cfg(not(windows))]
|
||||
{
|
||||
if let Some(assertion) = assertions.display.take() {
|
||||
drop_assertion(assertion, "display-sleep");
|
||||
}
|
||||
if let Some(assertion) = assertions.system.take() {
|
||||
drop_assertion(assertion, "system-sleep");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::PowerManager;
|
||||
|
||||
#[test]
|
||||
fn tracks_transfer_activity_before_runtime_activation() {
|
||||
let manager = PowerManager::new();
|
||||
manager.set_active_transfer_count(2);
|
||||
assert_eq!(manager.active_transfer_count(), 2);
|
||||
|
||||
manager.set_active_transfer_count(0);
|
||||
assert_eq!(manager.active_transfer_count(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preference_changes_are_safe_before_runtime_activation() {
|
||||
let manager = PowerManager::new();
|
||||
assert!(manager.set_preferences(false, true).is_ok());
|
||||
assert!(manager.set_system_prevention(true).is_ok());
|
||||
assert!(manager.set_display_prevention(false).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runtime_worker_can_activate_without_active_transfers() {
|
||||
let manager = PowerManager::new();
|
||||
assert!(manager.activate().is_ok());
|
||||
manager.set_active_transfer_count(0);
|
||||
assert_eq!(manager.active_transfer_count(), 0);
|
||||
}
|
||||
}
|
||||
+975
-64
File diff suppressed because it is too large
Load Diff
+60
-47
@@ -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",
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
use crate::ipc::{
|
||||
AppFontSize, ListRowDensity, MediaCookieSource, PersistedSettings, PostQueueAction, ProxyMode,
|
||||
SchedulerSettings, SettingsTab, Theme,
|
||||
AppFontSize, CalendarPreference, FontFamily, ListRowDensity, MediaCookieSource,
|
||||
PersistedSettings, PostQueueAction, ProxyMode, SchedulerSettings, SettingsTab, Theme,
|
||||
WindowControlStyle,
|
||||
};
|
||||
use serde_json::{Map, Value};
|
||||
use std::collections::HashMap;
|
||||
@@ -192,11 +193,32 @@ fn sanitize_persisted_setting_values(state: &mut Value) {
|
||||
"theme",
|
||||
&["system", "light", "dark", "dracula", "nord"],
|
||||
);
|
||||
sanitize_allowed_string(
|
||||
state,
|
||||
"fontFamily",
|
||||
&[
|
||||
"system",
|
||||
"inter",
|
||||
"outfit",
|
||||
"vazirmatn",
|
||||
"noto-sans-hebrew",
|
||||
"noto-sans-sc",
|
||||
"roboto",
|
||||
"serif",
|
||||
"monospace",
|
||||
],
|
||||
);
|
||||
sanitize_allowed_string(
|
||||
state,
|
||||
"windowControlStyle",
|
||||
&["auto", "macos", "windows", "gnome", "minimal"],
|
||||
);
|
||||
sanitize_allowed_string(
|
||||
state,
|
||||
"language",
|
||||
&["system", "en", "zh-CN", "he", "fa", "uk", "ru"],
|
||||
);
|
||||
sanitize_allowed_string(state, "calendarPreference", &["gregorian", "persian", "hebrew"]);
|
||||
sanitize_allowed_string(state, "sidebarPosition", &["auto", "left", "right"]);
|
||||
sanitize_allowed_string(state, "appFontSize", &["small", "standard", "large"]);
|
||||
sanitize_allowed_string(state, "listRowDensity", &["compact", "standard", "relaxed"]);
|
||||
@@ -416,6 +438,9 @@ fn derived_location_path(base: &str, subfolder: &str) -> String {
|
||||
fn default_settings() -> PersistedSettings {
|
||||
PersistedSettings {
|
||||
theme: Theme::System,
|
||||
font_family: FontFamily::System,
|
||||
window_control_style: WindowControlStyle::Auto,
|
||||
calendar_preference: CalendarPreference::Gregorian,
|
||||
language: "system".to_string(),
|
||||
base_download_folder: "~/Downloads".to_string(),
|
||||
category_subfolders_enabled: true,
|
||||
@@ -461,6 +486,7 @@ fn default_settings() -> PersistedSettings {
|
||||
ask_where_to_save_each_file: false,
|
||||
remember_last_used_download_directory: false,
|
||||
prevents_sleep_while_downloading: true,
|
||||
prevents_display_sleep_while_downloading: false,
|
||||
media_cookie_source: MediaCookieSource::default(),
|
||||
site_logins: Vec::new(),
|
||||
auto_check_updates: true,
|
||||
@@ -470,6 +496,7 @@ fn default_settings() -> PersistedSettings {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::ipc::{FontFamily, WindowControlStyle};
|
||||
use super::{
|
||||
decode_stored_settings, default_settings, preserve_portable_pairing_token,
|
||||
preserve_scheduler_runtime_keys,
|
||||
@@ -676,6 +703,47 @@ mod tests {
|
||||
assert_eq!(settings.sidebar_position, "auto");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_font_family_uses_system_font() {
|
||||
let stored = json!({
|
||||
"state": {"fontFamily": "comic-sans"},
|
||||
"version": 5
|
||||
});
|
||||
|
||||
let settings = decode_stored_settings(&Value::String(stored.to_string())).unwrap();
|
||||
|
||||
assert!(matches!(settings.font_family, FontFamily::System));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn curated_font_families_are_preserved() {
|
||||
for value in ["vazirmatn", "noto-sans-hebrew", "noto-sans-sc", "roboto"] {
|
||||
let stored = json!({
|
||||
"state": {"fontFamily": value},
|
||||
"version": 5
|
||||
});
|
||||
|
||||
let settings = decode_stored_settings(&Value::String(stored.to_string())).unwrap();
|
||||
|
||||
assert_eq!(serde_json::to_value(settings.font_family).unwrap(), json!(value));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_window_control_style_uses_automatic_layout() {
|
||||
let stored = json!({
|
||||
"state": {"windowControlStyle": "neon"},
|
||||
"version": 5
|
||||
});
|
||||
|
||||
let settings = decode_stored_settings(&Value::String(stored.to_string())).unwrap();
|
||||
|
||||
assert!(matches!(
|
||||
settings.window_control_style,
|
||||
WindowControlStyle::Auto
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clamps_out_of_range_download_settings() {
|
||||
let stored = json!({
|
||||
@@ -703,6 +771,7 @@ mod tests {
|
||||
"perServerConnections": 5,
|
||||
"showNotifications": "yes",
|
||||
"theme": "not-a-theme",
|
||||
"calendarPreference": "lunar",
|
||||
"siteLogins": [{"id": "valid", "urlPattern": "example.com", "username": "user"}, {"id": 3}]
|
||||
},
|
||||
"version": 3
|
||||
@@ -715,6 +784,10 @@ mod tests {
|
||||
assert_eq!(settings.per_server_connections, 5);
|
||||
assert!(settings.show_notifications);
|
||||
assert!(matches!(settings.theme, crate::ipc::Theme::System));
|
||||
assert!(matches!(
|
||||
settings.calendar_preference,
|
||||
crate::ipc::CalendarPreference::Gregorian
|
||||
));
|
||||
assert_eq!(settings.site_logins.len(), 1);
|
||||
assert_eq!(settings.site_logins[0].id, "valid");
|
||||
}
|
||||
|
||||
@@ -1,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
@@ -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>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
export type CalendarPreference = "gregorian" | "persian" | "hebrew";
|
||||
@@ -2,4 +2,4 @@
|
||||
import type { DownloadCategory } from "./DownloadCategory";
|
||||
import type { 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, };
|
||||
|
||||
@@ -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";
|
||||
@@ -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, };
|
||||
@@ -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,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, };
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
export type QueueConcurrencyConfig = { id: string, maxConcurrent: number | null, };
|
||||
@@ -0,0 +1,3 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
export type WindowControlStyle = "auto" | "macos" | "windows" | "gnome" | "minimal";
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
|
||||
+1302
-228
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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' : ''}`}
|
||||
|
||||
@@ -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
@@ -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
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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
@@ -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: 'دانلودکننده رسانه و موتورها',
|
||||
|
||||
@@ -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
@@ -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: 'Загрузчик медиа и движки',
|
||||
|
||||
@@ -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: 'Завантажувач медіа та рушії',
|
||||
|
||||
@@ -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: '媒体下载器和引擎',
|
||||
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
+14
-3
@@ -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 };
|
||||
};
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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
@@ -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') {
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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' })];
|
||||
|
||||
@@ -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'>
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
DEFAULT_CALENDAR_PREFERENCE,
|
||||
formatDateTime,
|
||||
isCalendarPreference
|
||||
} from './dateTime';
|
||||
|
||||
describe('date/time formatting', () => {
|
||||
const instant = new Date('2026-03-21T14:30:00.000Z');
|
||||
|
||||
it('uses Gregorian explicitly by default, including in localized UI languages', () => {
|
||||
const options: Intl.DateTimeFormatOptions = { dateStyle: 'medium', timeStyle: 'short' };
|
||||
expect(formatDateTime(instant, { locale: 'fa', options })).toBe(
|
||||
new Intl.DateTimeFormat('fa-u-ca-gregory', options).format(instant)
|
||||
);
|
||||
expect(DEFAULT_CALENDAR_PREFERENCE).toBe('gregorian');
|
||||
});
|
||||
|
||||
it('supports the opt-in Persian and Hebrew calendars', () => {
|
||||
const options: Intl.DateTimeFormatOptions = { dateStyle: 'long' };
|
||||
expect(formatDateTime(instant, { locale: 'fa', calendar: 'persian', options })).toBe(
|
||||
new Intl.DateTimeFormat('fa-u-ca-persian', options).format(instant)
|
||||
);
|
||||
expect(formatDateTime(instant, { locale: 'he', calendar: 'hebrew', options })).toBe(
|
||||
new Intl.DateTimeFormat('he-u-ca-hebrew', options).format(instant)
|
||||
);
|
||||
});
|
||||
|
||||
it('returns a safe placeholder for malformed timestamps and rejects unknown preferences', () => {
|
||||
expect(formatDateTime('not-a-timestamp', { locale: 'en' })).toBe('-');
|
||||
expect(isCalendarPreference('gregorian')).toBe(true);
|
||||
expect(isCalendarPreference('lunar')).toBe(false);
|
||||
expect(isCalendarPreference(null)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
import type { CalendarPreference } from '../bindings/CalendarPreference';
|
||||
import { resolveAppLocale } from '../i18n/locales';
|
||||
|
||||
export type { CalendarPreference } from '../bindings/CalendarPreference';
|
||||
|
||||
export const CALENDAR_PREFERENCES = ['gregorian', 'persian', 'hebrew'] as const satisfies readonly CalendarPreference[];
|
||||
export const DEFAULT_CALENDAR_PREFERENCE: CalendarPreference = 'gregorian';
|
||||
|
||||
export const isCalendarPreference = (value: unknown): value is CalendarPreference =>
|
||||
typeof value === 'string' && (CALENDAR_PREFERENCES as readonly string[]).includes(value);
|
||||
|
||||
type DateTimeInput = Date | string | number;
|
||||
|
||||
interface DateTimeFormatConfig {
|
||||
locale?: string | null;
|
||||
calendar?: CalendarPreference;
|
||||
options?: Intl.DateTimeFormatOptions;
|
||||
}
|
||||
|
||||
const calendarIdentifier = (calendar: CalendarPreference): string =>
|
||||
calendar === 'gregorian' ? 'gregory' : calendar;
|
||||
|
||||
const localeWithCalendar = (locale: string | null | undefined, calendar: CalendarPreference): string => {
|
||||
const resolvedLocale = resolveAppLocale(locale);
|
||||
return `${resolvedLocale}-u-ca-${calendarIdentifier(calendar)}`;
|
||||
};
|
||||
|
||||
const dateFromInput = (value: DateTimeInput): Date =>
|
||||
value instanceof Date ? new Date(value.getTime()) : new Date(value);
|
||||
|
||||
/**
|
||||
* Format a user-facing timestamp with an explicit calendar. Gregorian is
|
||||
* passed explicitly because some localized browser defaults use a regional
|
||||
* calendar even when the user has not opted into one.
|
||||
*/
|
||||
export const formatDateTime = (
|
||||
value: DateTimeInput,
|
||||
config: DateTimeFormatConfig = {}
|
||||
): string => {
|
||||
const date = dateFromInput(value);
|
||||
if (Number.isNaN(date.getTime())) return '-';
|
||||
|
||||
const calendar = config.calendar && isCalendarPreference(config.calendar)
|
||||
? config.calendar
|
||||
: DEFAULT_CALENDAR_PREFERENCE;
|
||||
const options = config.options ?? {};
|
||||
|
||||
try {
|
||||
return new Intl.DateTimeFormat(
|
||||
localeWithCalendar(config.locale, calendar),
|
||||
options
|
||||
).format(date);
|
||||
} catch {
|
||||
// WebViews can have incomplete ICU calendar data. Keep the display useful
|
||||
// and deterministic rather than exposing a RangeError to the UI.
|
||||
try {
|
||||
return new Intl.DateTimeFormat(
|
||||
localeWithCalendar(config.locale, DEFAULT_CALENDAR_PREFERENCE),
|
||||
options
|
||||
).format(date);
|
||||
} catch {
|
||||
return '-';
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -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+');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
};
|
||||
@@ -0,0 +1,180 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { DownloadItem } from '../bindings/DownloadItem';
|
||||
import type { DownloadProgressEvent } from '../bindings/DownloadProgressEvent';
|
||||
import { summarizeDownloads } from './downloadSummary';
|
||||
|
||||
const item = (id: string, overrides: Partial<DownloadItem> = {}): DownloadItem => ({
|
||||
id,
|
||||
url: `https://example.com/${id}`,
|
||||
fileName: `${id}.bin`,
|
||||
status: 'ready',
|
||||
category: 'Other',
|
||||
dateAdded: '2026-01-01T00:00:00.000Z',
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const progress = (id: string, overrides: Partial<DownloadProgressEvent> = {}): DownloadProgressEvent => ({
|
||||
id,
|
||||
fraction: 0.5,
|
||||
speed: '1 MiB/s',
|
||||
eta: '1m',
|
||||
size: null,
|
||||
size_is_final: false,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe('download summaries', () => {
|
||||
it('aggregates exact bytes and transfer-active counts', () => {
|
||||
expect(summarizeDownloads([
|
||||
item('active', { status: 'downloading', downloadedBytes: 1024, totalBytes: 4096 }),
|
||||
item('done', { status: 'completed', totalBytes: 2048 }),
|
||||
])).toEqual({
|
||||
itemCount: 2,
|
||||
activeCount: 1,
|
||||
downloadedBytes: 3072,
|
||||
remainingBytes: 3072,
|
||||
remainingIsEstimated: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('treats completed downloads as having no remaining bytes despite stale progress', () => {
|
||||
expect(summarizeDownloads([
|
||||
item('done', {
|
||||
status: 'completed',
|
||||
totalBytes: 40 * 1024 ** 2,
|
||||
downloadedBytes: 40 * 1024 ** 2,
|
||||
}),
|
||||
], {
|
||||
done: progress('done', {
|
||||
downloaded_bytes: 40 * 1024 ** 2 - 555 * 1024,
|
||||
total_bytes: 555 * 1024,
|
||||
}),
|
||||
})).toEqual({
|
||||
itemCount: 1,
|
||||
activeCount: 0,
|
||||
downloadedBytes: 40 * 1024 ** 2,
|
||||
remainingBytes: 0,
|
||||
remainingIsEstimated: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('does not present partial byte totals as complete aggregates', () => {
|
||||
expect(summarizeDownloads([
|
||||
item('known', { totalBytes: 100, downloadedBytes: 20 }),
|
||||
item('unknown', { status: 'failed' }),
|
||||
])).toMatchObject({
|
||||
itemCount: 2,
|
||||
downloadedBytes: null,
|
||||
remainingBytes: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('infers zero bytes for an unstarted paused item but preserves partial unknowns', () => {
|
||||
expect(summarizeDownloads([
|
||||
item('paused', { status: 'paused', totalBytes: 1000 }),
|
||||
])).toMatchObject({
|
||||
downloadedBytes: 0,
|
||||
remainingBytes: 1000,
|
||||
remainingIsEstimated: false,
|
||||
});
|
||||
|
||||
expect(summarizeDownloads([
|
||||
item('partial', { status: 'paused', fraction: 0.4, totalBytes: 1000 }),
|
||||
]).downloadedBytes).toBeNull();
|
||||
});
|
||||
|
||||
it('treats a fresh media item as having no downloaded bytes before its first progress event', () => {
|
||||
expect(summarizeDownloads([
|
||||
item('media', {
|
||||
status: 'ready',
|
||||
isMedia: true,
|
||||
totalBytes: 1024,
|
||||
totalIsEstimate: true,
|
||||
}),
|
||||
])).toMatchObject({
|
||||
downloadedBytes: 0,
|
||||
remainingBytes: 1024,
|
||||
remainingIsEstimated: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps non-final media progress on the stored estimated denominator', () => {
|
||||
expect(summarizeDownloads([
|
||||
item('media', {
|
||||
status: 'downloading',
|
||||
isMedia: true,
|
||||
totalBytes: 2 * 1024 ** 2,
|
||||
size: '~2 MB',
|
||||
}),
|
||||
], {
|
||||
media: progress('media', {
|
||||
downloaded_bytes: 512 * 1024,
|
||||
total_bytes: 1024,
|
||||
total_is_estimate: true,
|
||||
}),
|
||||
})).toEqual({
|
||||
itemCount: 1,
|
||||
activeCount: 1,
|
||||
downloadedBytes: 512 * 1024,
|
||||
remainingBytes: 2 * 1024 ** 2 - 512 * 1024,
|
||||
remainingIsEstimated: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('does not claim zero remaining when an estimate is already below observed bytes', () => {
|
||||
expect(summarizeDownloads([
|
||||
item('media', {
|
||||
status: 'downloading',
|
||||
isMedia: true,
|
||||
downloadedBytes: 150,
|
||||
totalBytes: 100,
|
||||
totalIsEstimate: true,
|
||||
}),
|
||||
])).toMatchObject({
|
||||
downloadedBytes: 150,
|
||||
remainingBytes: null,
|
||||
remainingIsEstimated: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('uses a final media progress total when one is available', () => {
|
||||
expect(summarizeDownloads([
|
||||
item('media', {
|
||||
status: 'processing',
|
||||
isMedia: true,
|
||||
totalBytes: 2 * 1024 ** 2,
|
||||
totalIsEstimate: true,
|
||||
}),
|
||||
], {
|
||||
media: progress('media', {
|
||||
downloaded_bytes: 3 * 1024 ** 2,
|
||||
total_bytes: 3 * 1024 ** 2,
|
||||
size_is_final: true,
|
||||
total_is_estimate: false,
|
||||
}),
|
||||
})).toMatchObject({
|
||||
downloadedBytes: 3 * 1024 ** 2,
|
||||
remainingBytes: 0,
|
||||
remainingIsEstimated: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('does not overflow aggregate byte counters', () => {
|
||||
const huge = Number.MAX_VALUE;
|
||||
expect(summarizeDownloads([
|
||||
item('one', { totalBytes: huge, downloadedBytes: huge }),
|
||||
item('two', { totalBytes: huge, downloadedBytes: huge }),
|
||||
])).toMatchObject({
|
||||
downloadedBytes: null,
|
||||
remainingBytes: 0,
|
||||
});
|
||||
|
||||
expect(summarizeDownloads([
|
||||
item('three', { totalBytes: huge, downloadedBytes: 0 }),
|
||||
item('four', { totalBytes: huge, downloadedBytes: 0 }),
|
||||
])).toMatchObject({
|
||||
downloadedBytes: 0,
|
||||
remainingBytes: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,125 @@
|
||||
import type { DownloadItem } from '../bindings/DownloadItem';
|
||||
import type { DownloadProgressEvent } from '../bindings/DownloadProgressEvent';
|
||||
import { isTransferActiveStatus } from './downloads';
|
||||
|
||||
export interface DownloadSummary {
|
||||
itemCount: number;
|
||||
activeCount: number;
|
||||
downloadedBytes: number | null;
|
||||
remainingBytes: number | null;
|
||||
remainingIsEstimated: boolean;
|
||||
}
|
||||
|
||||
type ProgressMap = Readonly<Record<string, DownloadProgressEvent | undefined>>;
|
||||
|
||||
const usableBytes = (value: number | null | undefined): number | undefined =>
|
||||
typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : undefined;
|
||||
|
||||
const isFreshDownloadStatus = (status: DownloadItem['status']): boolean =>
|
||||
status === 'ready' ||
|
||||
status === 'staged' ||
|
||||
status === 'queued' ||
|
||||
status === 'downloading' ||
|
||||
status === 'processing' ||
|
||||
status === 'retrying';
|
||||
|
||||
const hasPositiveProgress = (download: DownloadItem): boolean =>
|
||||
typeof download.fraction === 'number' &&
|
||||
Number.isFinite(download.fraction) &&
|
||||
download.fraction > 0;
|
||||
|
||||
const canInferNoDownloadedBytes = (download: DownloadItem): boolean =>
|
||||
!hasPositiveProgress(download) &&
|
||||
(isFreshDownloadStatus(download.status) || download.status === 'paused');
|
||||
|
||||
const effectiveByteState = (
|
||||
download: DownloadItem,
|
||||
progress: DownloadProgressEvent | undefined
|
||||
): { downloadedBytes?: number; totalBytes?: number; totalIsEstimate: boolean } => {
|
||||
const usesStoredMediaTotal = download.isMedia === true && progress && !progress.size_is_final;
|
||||
const storedTotalIsEstimate = download.totalIsEstimate === true ||
|
||||
download.size?.trim().startsWith('~') === true;
|
||||
const totalBytes = usesStoredMediaTotal
|
||||
? usableBytes(download.totalBytes)
|
||||
: usableBytes(progress?.total_bytes) ?? usableBytes(download.totalBytes);
|
||||
const downloadedBytes = download.status === 'completed'
|
||||
? usableBytes(download.downloadedBytes) ??
|
||||
usableBytes(progress?.downloaded_bytes) ??
|
||||
totalBytes
|
||||
: usableBytes(progress?.downloaded_bytes) ??
|
||||
usableBytes(download.downloadedBytes) ??
|
||||
(canInferNoDownloadedBytes(download) ? 0 : undefined);
|
||||
const totalIsEstimate = usesStoredMediaTotal
|
||||
? storedTotalIsEstimate
|
||||
: (progress?.total_is_estimate ?? storedTotalIsEstimate) === true;
|
||||
|
||||
return { downloadedBytes, totalBytes, totalIsEstimate };
|
||||
};
|
||||
|
||||
export const summarizeDownloads = (
|
||||
downloads: readonly DownloadItem[],
|
||||
progressMap: ProgressMap = {}
|
||||
): DownloadSummary => {
|
||||
if (downloads.length === 0) {
|
||||
return {
|
||||
itemCount: 0,
|
||||
activeCount: 0,
|
||||
downloadedBytes: null,
|
||||
remainingBytes: null,
|
||||
remainingIsEstimated: false,
|
||||
};
|
||||
}
|
||||
|
||||
let downloadedBytes = 0;
|
||||
let remainingBytes = 0;
|
||||
let downloadedKnown = true;
|
||||
let remainingKnown = true;
|
||||
let remainingIsEstimated = false;
|
||||
let activeCount = 0;
|
||||
|
||||
for (const download of downloads) {
|
||||
const state = effectiveByteState(download, progressMap[download.id]);
|
||||
if (isTransferActiveStatus(download.status)) activeCount += 1;
|
||||
if (state.downloadedBytes === undefined) {
|
||||
downloadedKnown = false;
|
||||
} else {
|
||||
const nextDownloadedBytes = downloadedBytes + state.downloadedBytes;
|
||||
if (Number.isFinite(nextDownloadedBytes)) {
|
||||
downloadedBytes = nextDownloadedBytes;
|
||||
} else {
|
||||
downloadedKnown = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (download.status === 'completed') {
|
||||
// A completed row is terminal even when a delayed progress event still
|
||||
// carries an old partial denominator. Never expose that stale value as
|
||||
// remaining work in the aggregate status bar.
|
||||
continue;
|
||||
}
|
||||
|
||||
if (
|
||||
state.totalBytes === undefined ||
|
||||
state.downloadedBytes === undefined ||
|
||||
state.downloadedBytes > state.totalBytes
|
||||
) {
|
||||
remainingKnown = false;
|
||||
} else {
|
||||
const nextRemainingBytes = remainingBytes + Math.max(0, state.totalBytes - state.downloadedBytes);
|
||||
if (Number.isFinite(nextRemainingBytes)) {
|
||||
remainingBytes = nextRemainingBytes;
|
||||
remainingIsEstimated ||= state.totalIsEstimate;
|
||||
} else {
|
||||
remainingKnown = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
itemCount: downloads.length,
|
||||
activeCount,
|
||||
downloadedBytes: downloadedKnown ? downloadedBytes : null,
|
||||
remainingBytes: remainingKnown ? remainingBytes : null,
|
||||
remainingIsEstimated,
|
||||
};
|
||||
};
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
};
|
||||
@@ -0,0 +1,37 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
moveSelectedBlockToIndex,
|
||||
targetIndexForBoundary,
|
||||
targetIndexForDesiredOrder
|
||||
} from './queueOrdering';
|
||||
|
||||
const items = ['a', 'b', 'c', 'd'].map(id => ({ id }));
|
||||
|
||||
describe('queue ordering', () => {
|
||||
it('moves discontiguous selection as one block', () => {
|
||||
expect(moveSelectedBlockToIndex(items, ['b', 'd'], 1).map(item => item.id))
|
||||
.toEqual(['a', 'b', 'd', 'c']);
|
||||
});
|
||||
|
||||
it('moves a selected block to the end for a downward drop', () => {
|
||||
expect(moveSelectedBlockToIndex(items, ['b', 'c'], 2).map(item => item.id))
|
||||
.toEqual(['a', 'd', 'b', 'c']);
|
||||
});
|
||||
|
||||
it('translates pointer boundaries after selected rows are removed', () => {
|
||||
expect(targetIndexForBoundary(items, ['b', 'd'], 2)).toBe(1);
|
||||
expect(targetIndexForBoundary(items, ['b', 'd'], 3)).toBe(2);
|
||||
});
|
||||
|
||||
it('computes a registered-only backend target from the desired local order', () => {
|
||||
const current = [{ id: 'a' }, { id: 'staged' }, { id: 'b' }, { id: 'c' }];
|
||||
const desired = [{ id: 'a' }, { id: 'b' }, { id: 'c' }, { id: 'staged' }];
|
||||
expect(targetIndexForDesiredOrder(current, ['c'], desired)).toBe(2);
|
||||
|
||||
expect(targetIndexForDesiredOrder(
|
||||
[{ id: 'a' }, { id: 'c' }],
|
||||
['c'],
|
||||
[{ id: 'a' }, { id: 'staged' }, { id: 'c' }]
|
||||
)).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
export interface QueueOrderItem {
|
||||
id: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Move the selected rows as one block into an insertion position among the
|
||||
* unselected rows. The position is intentionally defined after selection is
|
||||
* removed so callers can use the same semantics for UI and backend queues.
|
||||
*/
|
||||
export const moveSelectedBlockToIndex = <T extends QueueOrderItem>(
|
||||
items: T[],
|
||||
selectedIds: ReadonlySet<string> | readonly string[],
|
||||
targetIndex: number
|
||||
): T[] => {
|
||||
const selected = selectedIds instanceof Set ? selectedIds : new Set(selectedIds);
|
||||
const selectedItems = items.filter(item => selected.has(item.id));
|
||||
const unselectedItems = items.filter(item => !selected.has(item.id));
|
||||
const insertionIndex = Math.max(0, Math.min(targetIndex, unselectedItems.length));
|
||||
|
||||
return [
|
||||
...unselectedItems.slice(0, insertionIndex),
|
||||
...selectedItems,
|
||||
...unselectedItems.slice(insertionIndex)
|
||||
];
|
||||
};
|
||||
|
||||
/**
|
||||
* Convert a pointer boundary in the original list to the insertion index
|
||||
* used by moveSelectedBlockToIndex. A boundary is between rows and ranges
|
||||
* from 0 (before the first row) through items.length (after the last row).
|
||||
*/
|
||||
export const targetIndexForBoundary = <T extends QueueOrderItem>(
|
||||
items: T[],
|
||||
selectedIds: ReadonlySet<string> | readonly string[],
|
||||
boundaryIndex: number
|
||||
): number => {
|
||||
const selected = selectedIds instanceof Set ? selectedIds : new Set(selectedIds);
|
||||
const boundary = Math.max(0, Math.min(boundaryIndex, items.length));
|
||||
return items
|
||||
.slice(0, boundary)
|
||||
.reduce((count, item) => count + (selected.has(item.id) ? 0 : 1), 0);
|
||||
};
|
||||
|
||||
/**
|
||||
* Translate a desired local order to the backend's registered-only queue.
|
||||
* Staged rows are not registered with the backend, so they must not affect
|
||||
* the target index sent over IPC.
|
||||
*/
|
||||
export const targetIndexForDesiredOrder = <T extends QueueOrderItem>(
|
||||
currentItems: T[],
|
||||
selectedIds: ReadonlySet<string> | readonly string[],
|
||||
desiredItems: T[]
|
||||
): number => {
|
||||
const selected = selectedIds instanceof Set ? selectedIds : new Set(selectedIds);
|
||||
const currentIds = new Set(currentItems.map(item => item.id));
|
||||
const firstSelectedIndex = desiredItems.findIndex(item => selected.has(item.id));
|
||||
if (firstSelectedIndex === -1) return currentItems.filter(item => !selected.has(item.id)).length;
|
||||
return desiredItems
|
||||
.slice(0, firstSelectedIndex)
|
||||
.reduce((count, item) => count + (selected.has(item.id) || !currentIds.has(item.id) ? 0 : 1), 0);
|
||||
};
|
||||
@@ -0,0 +1,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);
|
||||
});
|
||||
});
|
||||
@@ -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';
|
||||
};
|
||||
Reference in New Issue
Block a user