Compare commits

..

23 Commits

Author SHA1 Message Date
NimBold 3b9faa789c chore(release): prepare 1.0.2
Remove reduced yt-dlp info-json reuse so metadata fallback downloads re-extract from the original URL instead of crashing on incomplete extractor metadata.

Update README and changelog for the 1.0.2 desktop release and point the bundled extension submodule at Firelink Companion 2.0.2.
2026-07-08 22:19:15 +03:30
NimBold 161b0028f9 fix(downloads): harden add modal and retry state
Route explicit no-limit speed overrides without inheriting the global cap.

Preserve dotted media titles when switching formats and drain retry gid completions through remember_gid.
2026-07-08 21:57:12 +03:30
NimBold f84726a403 chore: refresh release dependencies 2026-07-08 17:52:38 +03:30
NimBold e52e885193 fix(ui): keep sidebar toggle accessible across views
Move the collapsed sidebar reveal control into the app shell so Settings, Scheduler, Speed Limiter, Logs, and Downloads all have a recovery path. Keep separate collapsed spacing for macOS and Windows/Linux window controls to avoid overlap.\n\nFixes #9
2026-07-08 16:50:06 +03:30
NimBold 5fc3c9f965 fix: harden metadata fetch release paths 2026-07-08 16:36:03 +03:30
NimBold 800b94f9c0 fix: harden media cleanup retries
Retry best-effort yt-dlp media artifact cleanup before giving up so Windows file-handle release timing does not leave stale partials behind.

Also adjusts the app sidebar panel contrast and border tokens.
2026-07-08 02:52:23 +03:30
NimBold 6b5384f261 fix(media): speed up extension metadata fetch 2026-07-08 01:32:18 +03:30
NimBold 607d193f8e fix(proxy): preserve system proxy schemes
Detect platform system proxy schemes before normalizing proxy values.

Fail normal aria2 downloads clearly when the selected proxy is SOCKS, since aria2 only supports HTTP-style all-proxy URLs.
2026-07-07 22:25:58 +03:30
NimBold f711c6b7a4 feat(media): support explicit fetch media intent 2026-07-07 12:44:19 +03:30
NimBold e30eff4d2e feat(media): support extension media fetch
Thread extension-provided headers and cookies into Firelink media metadata loading so authenticated page fetches show the correct formats.

Include those auth inputs in both metadata and yt-dlp info-json cache keys to prevent cross-session collisions, and advance the Firefox extension submodule to the published media-fetch commit.
2026-07-07 00:17:35 +03:30
NimBold 85ad9d18e8 fix(settings): add category subfolders toggle
Add a Settings > Locations toggle that disables automatic category subfolders while keeping the selected base download folder as the automatic destination.

Persist the setting across frontend and backend startup paths and disable the category subfolder controls when the feature is off.

Fixes #6
2026-07-06 23:37:03 +03:30
NimBold 036ff02dac chore(engines): refresh ffmpeg provider builds
Update Windows and Linux FFmpeg source locks to BtbN 8.1.2-22-g94138f6973 from autobuild-2026-07-06-14-19.

Replace the bundled macOS Apple Silicon FFmpeg binary with Martin Riedl snapshot N-125450-gfad2e0bc50 and record its verified checksum.

Teach the update checker to compare FFmpeg provider builds separately from upstream stable releases so future refreshes are not missed.

Keep yt-dlp pinned at the current official stable 2026.07.04 and update Vitest to 4.1.10 so the release update gate stays clean.
2026-07-06 20:43:21 +03:30
NimBold 99f73eaae2 fix(downloads): handle root category folders and cookie fallback
Allow explicit empty category subfolders to resolve to the base download folder across settings, UI, and backend ownership paths.

Retry yt-dlp metadata and media downloads without browser cookies when the selected browser cookie database cannot be copied, while preserving real auth failures.

Fixes #6

Fixes #7
2026-07-06 20:19:33 +03:30
NimBold 5ea46024e9 chore(windows): use Firelink icon for NSIS installer
Set the NSIS installer and uninstaller icon to the existing Firelink .ico asset so Windows setup files do not fall back to the default NSIS icon.
2026-07-06 16:44:08 +03:30
NimBold a2a3b08d4e chore(release): ad-hoc sign macOS builds
Configure Tauri to ad-hoc sign macOS app and DMG builds without Apple Developer credentials.

Add a macOS signing verifier that checks the built app, the DMG payload, Mach-O signatures, quarantine xattrs, and expected Gatekeeper behavior.

Tighten README copy while keeping the platform install pills and accurate non-notarized macOS guidance.
2026-07-06 16:34:20 +03:30
NimBold 5ef0b3b5f6 docs(readme): render windows badge locally
Use a checked-in Windows SVG badge because Shields does not render the Windows logo slug.

Keep the browser extension badges compact and inline.
2026-07-06 01:55:30 +03:30
NimBold 515ba611ce feat(integrations): surface chromium extension install
Link Chrome and Chromium users to the manual load-unpacked instructions from the README and Settings > Integrations.

Advance the bundled extension submodule to the Chromium package build commit.
2026-07-06 01:22:20 +03:30
NimBold 579174c5b5 fix(settings): restrict custom proxy to http
Remove the SOCKS5 example from the Network settings custom proxy field until normal file downloads support it end to end.

Reject non-HTTP custom proxy URLs in frontend normalization so Settings cannot emit unsupported proxy schemes to aria2-backed downloads.

Update proxy dispatch tests to preserve the HTTP path and cover rejected SOCKS5 and HTTPS custom inputs.
2026-07-06 00:55:03 +03:30
NimBold c13f993150 fix(proxy): harden system proxy metadata flow
Normalize system proxy values from sysproxy, Windows registry, and environment fallbacks before handing them to download engines.

Apply the resolved proxy to direct metadata probes and yt-dlp media metadata requests so preview and final downloads use the same network path.

Enable reqwest SOCKS support and key media metadata caches by proxy/cookie inputs to avoid stale cross-proxy reuse.
2026-07-06 00:41:34 +03:30
NimBold 017d9cd879 fix(engines): update lockfile checksum for aarch64 internal runtime 2026-07-05 21:17:28 +03:30
NimBold dabd252ab7 fix(ci): resolve clippy warnings 2026-07-05 20:42:09 +03:30
NimBold 3da73c623f fix(proxy): add robust system proxy fallback and log redaction
- Fallback to reading Windows Registry for ProxyServer if sysproxy crate fails parsing.
- Fallback to reading HTTP_PROXY and related environment variables.
- Redact user home directory in tauri_plugin_log to protect privacy.
- Improves yt-dlp compatibility for domains blocked by local ISPs.

Resolves #5
2026-07-05 20:31:34 +03:30
NimBold 757e313f71 fix(download): require aria2 for file transfers
Update yt-dlp to 2026.07.04 and refresh the bundled macOS runtime plus engine locks.

Route every non-media download through aria2 by removing the native HTTP fallback path, native GID handling, and the old direct-download harness.

Retry transient aria2 startup/RPC failures before failing, then show the real last error in the download row and Properties modal so Windows failures are diagnosable instead of silent.

Refresh docs and tests around the aria2-only file-download contract, and advance the Firefox extension submodule to its published wording cleanup.
2026-07-05 20:00:53 +03:30
121 changed files with 2484 additions and 1940 deletions
+6
View File
@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" width="86" height="20" role="img" aria-label="Windows">
<title>Windows</title>
<rect width="86" height="20" fill="#0078D6"/>
<path fill="#fff" d="M7 5.3 12.8 4.5v5H7V5.3Zm6.6-1 7.4-1.1v6.3h-7.4V4.3ZM7 10.5h5.8v5L7 14.7v-4.2Zm6.6 0H21v6.3l-7.4-1.1v-5.2Z"/>
<text x="28" y="14" fill="#fff" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" font-size="11">Windows</text>
</svg>

After

Width:  |  Height:  |  Size: 425 B

+3
View File
@@ -91,6 +91,9 @@ jobs:
if: runner.os == 'macOS'
run: |
APP="src-tauri/target/${{ matrix.target }}/release/bundle/macos/Firelink.app"
DMG="$(find src-tauri/target/${{ matrix.target }}/release/bundle/dmg -name '*.dmg' -print -quit)"
test -n "$DMG"
npm run verify:macos-signing -- --app "$APP" --dmg "$DMG"
node scripts/verify-binaries.js --search-root "$APP" --target ${{ matrix.target }}
node scripts/smoke-packaged-app.js --executable "$APP/Contents/MacOS/firelink"
- name: Verify Windows installer payload and launch
+20
View File
@@ -5,6 +5,26 @@ 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.0.2] - 2026-07-08
### New
- Add explicit **Fetch media** actions for Firelink Companion so video and audio pages can be sent to Firelink from the extension popup or page context menu.
- Add a Chromium extension install path in Firelink's Integrations settings and release documentation, addressing the Chromium support request in [#4](https://github.com/nimbold/Firelink/issues/4).
### Improved
- Improve YouTube and social-media download handling with better proxy support, cleaner diagnostics, safer log redaction, and more reliable metadata fetching based on reports in [#5](https://github.com/nimbold/Firelink/issues/5) and [#8](https://github.com/nimbold/Firelink/issues/8).
- Let category subfolders be disabled so files can save directly into a base download folder, addressing [#6](https://github.com/nimbold/Firelink/issues/6).
- Refresh bundled media engines and release tooling for the current cross-platform desktop builds.
- Strengthen release packaging checks for macOS, Windows, and Linux, including macOS ad-hoc signing verification and Windows installer icon handling.
### Fixed
- Fix YouTube downloads that failed when Chrome's cookie database could not be copied by retrying public media without browser cookies, addressing [#7](https://github.com/nimbold/Firelink/issues/7).
- Fix the hidden-sidebar trap on Settings and other pages so the sidebar can always be shown again, addressing [#9](https://github.com/nimbold/Firelink/issues/9).
- Fix Windows proxy detection so system proxy schemes are preserved for metadata and media requests, addressing [#5](https://github.com/nimbold/Firelink/issues/5).
- Fix captured and auto-captured browser links so they always route through Firelink's Add window before reaching the download list.
- Fix media downloads after a metadata fallback so pages without selectable preview formats can still let yt-dlp choose the best downloadable file instead of crashing.
- Fix media cleanup and retry paths so failed, canceled, or retried media downloads leave fewer stale temporary files behind.
## [1.0.1] - 2026-07-04
### Fixed
+36 -34
View File
@@ -5,13 +5,10 @@
**A fast, focused desktop download manager for macOS, Windows, and Linux.**
[![Version](https://img.shields.io/badge/version-1.0.1-6f42c1?style=flat-square)](https://github.com/nimbold/Firelink/releases)
[![Version](https://img.shields.io/badge/version-1.0.2-6f42c1?style=flat-square)](https://github.com/nimbold/Firelink/releases)
[![macOS](https://img.shields.io/badge/macOS-111111?style=flat-square&logo=apple&logoColor=white)](#platforms)
[![Windows](https://img.shields.io/badge/Windows-0078D6?style=flat-square&logo=windows11&logoColor=white)](#platforms)
[![Windows](.github/badges/windows.svg)](#platforms)
[![Linux](https://img.shields.io/badge/Linux-FCC624?style=flat-square&logo=linux&logoColor=black)](#platforms)
[![Tauri](https://img.shields.io/badge/Tauri-2-24C8DB?style=flat-square&logo=tauri&logoColor=white)](https://tauri.app/)
[![Rust](https://img.shields.io/badge/Rust-backend-000000?style=flat-square&logo=rust)](https://www.rust-lang.org/)
[![React](https://img.shields.io/badge/React-TypeScript-61DAFB?style=flat-square&logo=react&logoColor=111111)](https://react.dev/)
[![License](https://img.shields.io/github/license/nimbold/Firelink?style=flat-square)](LICENSE)
[![CI](https://img.shields.io/github/actions/workflow/status/nimbold/Firelink/ci.yml?branch=main&style=flat-square&label=CI)](https://github.com/nimbold/Firelink/actions/workflows/ci.yml)
@@ -36,60 +33,65 @@
## Why Firelink
Firelink is built for people who want a real desktop download manager again: fast segmented transfers, browser capture, media extraction, scheduling, recovery, and clear control over where files land. Version 1.0.0 completes the move from the earlier macOS-only Swift app to a modern Rust/Tauri application with a React and TypeScript interface.
Firelink is a desktop download manager for fast transfers, browser capture, media extraction, scheduling, and clear file placement.
The app keeps the heavy work native. Downloads are coordinated by a Rust backend, accelerated with aria2, enriched with yt-dlp and FFmpeg for media workflows, and persisted locally with SQLite so queues survive restarts and app updates.
It is now a cross-platform Rust/Tauri app with a React and TypeScript interface. A native backend coordinates downloads with aria2, yt-dlp, FFmpeg, Deno, and SQLite.
## Features
- **Fast segmented downloads** powered by aria2 with configurable connections, retries, and speed limits.
- **Media extraction** with yt-dlp, FFmpeg, and Deno for video/audio links and richer format selection.
- **A real Add window** for manual, extension-captured, and media downloads, including metadata, duplicate handling, and save-location choices before downloads start.
- **Persistent queue management** with safe concurrency limits, pause/resume, retry, redownload, sorting, multi-select, and bulk controls.
- **Download scheduling** with start/stop windows, speed-limiter tools, and optional post-queue actions.
- **Smart organization** through categories, default folders, per-download overrides, and open/reveal/trash actions.
- **Private browser handoff** through authenticated local pairing with replay protection and desktop-server proof checks.
- **Native desktop integration** including tray controls, notifications, completion sounds, sleep prevention, and OS keychain support where available.
- **Diagnostics built in** with engine health checks, structured logs, and packaged-engine verification.
- **Segmented downloads** with aria2, retries, speed limits, and connection controls.
- **Media downloads** with yt-dlp, FFmpeg, and Deno.
- **Add window** for metadata, duplicates, location choices, and captured links.
- **Persistent queues** with pause, resume, retry, redownload, sorting, multi-select, and bulk actions.
- **Scheduling** with start/stop windows, speed rules, and post-queue actions.
- **File organization** with categories, default folders, 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.
- **Diagnostics** with engine health checks, structured logs, and package verification.
## Installation
Download the latest desktop build from [GitHub Releases](https://github.com/nimbold/Firelink/releases).
Download desktop builds from [GitHub Releases](https://github.com/nimbold/Firelink/releases).
| Platform | Package | Notes |
| --- | --- | --- |
| **macOS Apple silicon** | `.dmg` | Unsigned and not notarized. Open through Finder or approve once in **System Settings -> Privacy & Security**. |
| **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. |
| **Linux x64** | `.AppImage` | Make executable before launching if your desktop environment does not do that automatically. |
Production bundles include the required media engines for the target platform. Users do not need to install aria2, yt-dlp, FFmpeg, Deno, Python, Homebrew, or a package manager for normal app usage.
Bundles include the required engines. Users do not need aria2, yt-dlp, FFmpeg, Deno, Python, Homebrew, or another package manager.
## Browser Extension
<div align="center">
<a href="https://addons.mozilla.org/en-US/firefox/addon/firelink-companion/"><img src="https://img.shields.io/badge/Install%20Firelink%20Companion-Firefox-FF7139?style=for-the-badge&logo=firefox-browser&logoColor=white" alt="Install Firelink Companion on Firefox" /></a>
</div>
<p align="center">
<a href="https://addons.mozilla.org/en-US/firefox/addon/firelink-companion/"><img src="https://img.shields.io/badge/Install%20from-Firefox%20Add--ons-FF7139?style=for-the-badge&logo=firefox-browser&logoColor=white" alt="Install Firelink Companion from Firefox Add-ons" /></a>
&nbsp;&nbsp;
<a href="https://github.com/nimbold/Firelink-Extension#manual-chromium-installation"><img src="https://img.shields.io/badge/Manual%20install-Chromium-4285F4?style=for-the-badge&logo=googlechrome&logoColor=white" alt="Read manual Chromium install instructions" /></a>
</p>
Firelink Companion connects your browser to the desktop app so links and browser downloads can open in Firelink instead of disappearing into the browser's default download shelf.
Firelink Companion sends browser links and downloads to the desktop app.
What it adds:
- **Automatic capture** for normal browser downloads, while still routing every captured link through Firelink's Add window.
- **Context-menu actions** for "Download with Firelink" and selected links.
- **Signed local requests** using the pairing token from **Settings -> Integrations**.
- **Server identity checks** so the extension only trusts the real local Firelink app.
- **Offline-safe behavior** that resumes browser downloads when Firelink is closed or rejects a handoff.
- **Protocol-aware compatibility** so older desktop builds are rejected before automatic capture can cancel a browser download.
- Automatic capture for regular browser downloads.
- Explicit Fetch media actions from the popup and page context menu.
- Context-menu actions for links and selected text.
- Firefox and Chromium support.
- Signed local requests using the token from **Settings -> Integrations**.
- Fallback to the browser download when Firelink is closed or rejects a handoff.
- Captured links always open Firelink's Add window before anything is added to the download list.
Install the extension, open Firelink, then pair it from **Settings -> Integrations**. The Firefox add-on is maintained in the [Firelink-Extension](https://github.com/nimbold/Firelink-Extension) repository and is also vendored here as the `Extensions/Firefox` submodule.
Install the extension, open Firelink, then pair it from **Settings -> Integrations**. Firefox users can install from Mozilla Add-ons. Chromium users can use the [manual load-unpacked flow](https://github.com/nimbold/Firelink-Extension#manual-chromium-installation) with `firelink-chromium.zip` from the [extension releases](https://github.com/nimbold/Firelink-Extension/releases). Firelink Companion 2.0.2 is the matching extension release for Firelink 1.0.2.
The extension lives in [Firelink-Extension](https://github.com/nimbold/Firelink-Extension). This repo also vendors it as the `Extensions/Firefox` submodule.
## Platforms
| Target | Status |
| --- | --- |
| **macOS arm64** | Supported. Automated native build, engine validation, packaged launch smoke test, and unsigned DMG packaging. |
| **Windows x64** | Supported. Native GitHub Actions build, engine validation, silent installer smoke test, and NSIS packaging. |
| **Linux x64** | Supported. Native GitHub Actions build, engine validation, AppImage repackaging, and xvfb launch smoke test. |
| **macOS arm64** | Supported. Native build, engine checks, launch smoke test, ad-hoc-signed DMG workflow. |
| **Windows x64** | Supported. Native build, engine checks, silent installer smoke test, NSIS installer. |
| **Linux x64** | Supported. Native build, engine checks, xvfb launch smoke test, AppImage. |
## Development
@@ -130,7 +132,7 @@ Create a production bundle:
npm run tauri build
```
macOS development uses locked payloads in `src-tauri/binaries`. Windows and Linux payloads are provisioned from checksum-pinned archives:
macOS uses locked payloads in `src-tauri/binaries`. Provision Windows and Linux payloads from checksum-pinned archives:
```sh
node scripts/provision-engines.js --target x86_64-pc-windows-msvc
+12 -12
View File
@@ -3,9 +3,9 @@
"targets": {
"x86_64-pc-windows-msvc": {
"yt-dlp": {
"version": "2026.06.09",
"url": "https://github.com/yt-dlp/yt-dlp/releases/download/2026.06.09/yt-dlp_win.zip",
"sha256": "9bb27530494092870b5330deacfc65a40d3e980c7e2c67e5f09b902c37a6903d"
"version": "2026.07.04",
"url": "https://github.com/yt-dlp/yt-dlp/releases/download/2026.07.04/yt-dlp_win.zip",
"sha256": "90254845be5282b1f4d843a873abff04f569f857f64250f833fe152b21eec152"
},
"deno": {
"version": "2.9.1",
@@ -13,9 +13,9 @@
"sha256": "ab310b4232cca207d40ffa41867e93aaf9f893802bc76756e74f486a6b21b371"
},
"ffmpeg": {
"version": "8.1.2",
"url": "https://github.com/BtbN/FFmpeg-Builds/releases/download/autobuild-2026-06-23-13-52/ffmpeg-n8.1.2-win64-gpl-8.1.zip",
"sha256": "ddbe0deec00b9cfd3ee74298a46a22ac4061913ed47d92297cd168cc5a5cc625"
"version": "8.1.2-22-g94138f6973",
"url": "https://github.com/BtbN/FFmpeg-Builds/releases/download/autobuild-2026-07-06-14-19/ffmpeg-n8.1.2-22-g94138f6973-win64-gpl-8.1.zip",
"sha256": "a758e2836a8f33c5f21fc44270cb00392acc6d0085dd0ba14fe14ae75935813d"
},
"aria2c": {
"version": "1.37.0",
@@ -25,9 +25,9 @@
},
"x86_64-unknown-linux-gnu": {
"yt-dlp": {
"version": "2026.06.09",
"url": "https://github.com/yt-dlp/yt-dlp/releases/download/2026.06.09/yt-dlp_linux.zip",
"sha256": "217bbc9c3ed19ea75a7f151a3e48dbfeac7f459a7dce2deeeecc2d6e2871bd5b"
"version": "2026.07.04",
"url": "https://github.com/yt-dlp/yt-dlp/releases/download/2026.07.04/yt-dlp_linux.zip",
"sha256": "d7d2d09e900b5ae11821b5784b18cf064984a2bd88b1ca5c798d744bcbe3658b"
},
"deno": {
"version": "2.9.1",
@@ -35,9 +35,9 @@
"sha256": "710c54d63477d1100844ef4818f19507ce0dbf40510903b1d883f19e394446a2"
},
"ffmpeg": {
"version": "8.1.2",
"url": "https://github.com/BtbN/FFmpeg-Builds/releases/download/autobuild-2026-06-23-13-52/ffmpeg-n8.1.2-linux64-gpl-8.1.tar.xz",
"sha256": "0c6772b77fdbf127cc1498eca39a40e20b88817f36b66d553cebcfcca32b6d78"
"version": "8.1.2-22-g94138f6973",
"url": "https://github.com/BtbN/FFmpeg-Builds/releases/download/autobuild-2026-07-06-14-19/ffmpeg-n8.1.2-22-g94138f6973-linux64-gpl-8.1.tar.xz",
"sha256": "df99ffb3803ee56dc68954f43f950ea9f33685a3595a5da8a3e73ef4bef37e3c"
},
"aria2c": {
"version": "1.37.0",
+5 -5
View File
@@ -4,10 +4,10 @@
"aarch64-apple-darwin": {
"engines": {
"yt-dlp": {
"version": "2026.06.09",
"version": "2026.07.04",
"source": "https://github.com/yt-dlp/yt-dlp",
"build": "PyInstaller onedir distribution with embedded Python and yt_dlp_ejs",
"sha256": "4eefb498e76f8a425bec30ba3ee2079b01542ca39ca1fb61b79966450794cc13"
"sha256": "ff7d4fc44b8fbf42da021c1bca950da0326cdb0cdb84992fdc7fb7ec215df435"
},
"aria2c": {
"version": "1.37.0",
@@ -16,10 +16,10 @@
"sha256": "111b2f5ed760f1e1a2ec06117c4e8094fcde336ba16122dda1c5e7209bf1862d"
},
"ffmpeg": {
"version": "N-125385-ge2e889d9da",
"version": "N-125450-gfad2e0bc50",
"source": "https://ffmpeg.org/",
"build": "GPLv3 build identified by binary as https://www.martin-riedl.de",
"sha256": "3027bd75166a596c31a271ce9ae14b5441492b9557011993c0bf873b5a7c6a09"
"sha256": "be2c39e5c9ef923f60da6cb62f5a209ed98b4da8a732d9f06de4355d5ea99e58"
},
"deno": {
"version": "2.9.1",
@@ -31,7 +31,7 @@
"runtimeTrees": {
"_internal": {
"files": 142,
"sha256": "3fabc08e6367f9393cfee32c32138f057f0e6068e430190c26a22ddfea84242b"
"sha256": "769507d9b8d97164ef81ebb449873072697bf82acac8c7d61c7bfd96c551e210"
},
"aria2-libs": {
"files": 7,
+47 -47
View File
@@ -1,12 +1,12 @@
{
"name": "firelink",
"version": "1.0.1",
"version": "1.0.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "firelink",
"version": "1.0.1",
"version": "1.0.2",
"license": "MIT",
"dependencies": {
"@formkit/auto-animate": "^0.9.0",
@@ -33,7 +33,7 @@
"tailwindcss": "^4.3.1",
"typescript": "^6.0.3",
"vite": "^8.1.3",
"vitest": "^4.1.9"
"vitest": "^4.1.10"
},
"engines": {
"node": ">=22"
@@ -1023,16 +1023,16 @@
}
},
"node_modules/@vitest/expect": {
"version": "4.1.9",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.9.tgz",
"integrity": "sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA==",
"version": "4.1.10",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz",
"integrity": "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@standard-schema/spec": "^1.1.0",
"@types/chai": "^5.2.2",
"@vitest/spy": "4.1.9",
"@vitest/utils": "4.1.9",
"@vitest/spy": "4.1.10",
"@vitest/utils": "4.1.10",
"chai": "^6.2.2",
"tinyrainbow": "^3.1.0"
},
@@ -1041,13 +1041,13 @@
}
},
"node_modules/@vitest/mocker": {
"version": "4.1.9",
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.9.tgz",
"integrity": "sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw==",
"version": "4.1.10",
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.10.tgz",
"integrity": "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/spy": "4.1.9",
"@vitest/spy": "4.1.10",
"estree-walker": "^3.0.3",
"magic-string": "^0.30.21"
},
@@ -1068,9 +1068,9 @@
}
},
"node_modules/@vitest/pretty-format": {
"version": "4.1.9",
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.9.tgz",
"integrity": "sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A==",
"version": "4.1.10",
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.10.tgz",
"integrity": "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -1081,13 +1081,13 @@
}
},
"node_modules/@vitest/runner": {
"version": "4.1.9",
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.9.tgz",
"integrity": "sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg==",
"version": "4.1.10",
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.10.tgz",
"integrity": "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/utils": "4.1.9",
"@vitest/utils": "4.1.10",
"pathe": "^2.0.3"
},
"funding": {
@@ -1095,14 +1095,14 @@
}
},
"node_modules/@vitest/snapshot": {
"version": "4.1.9",
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.9.tgz",
"integrity": "sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA==",
"version": "4.1.10",
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.10.tgz",
"integrity": "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/pretty-format": "4.1.9",
"@vitest/utils": "4.1.9",
"@vitest/pretty-format": "4.1.10",
"@vitest/utils": "4.1.10",
"magic-string": "^0.30.21",
"pathe": "^2.0.3"
},
@@ -1111,9 +1111,9 @@
}
},
"node_modules/@vitest/spy": {
"version": "4.1.9",
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.9.tgz",
"integrity": "sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA==",
"version": "4.1.10",
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.10.tgz",
"integrity": "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==",
"dev": true,
"license": "MIT",
"funding": {
@@ -1121,13 +1121,13 @@
}
},
"node_modules/@vitest/utils": {
"version": "4.1.9",
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.9.tgz",
"integrity": "sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA==",
"version": "4.1.10",
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.10.tgz",
"integrity": "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/pretty-format": "4.1.9",
"@vitest/pretty-format": "4.1.10",
"convert-source-map": "^2.0.0",
"tinyrainbow": "^3.1.0"
},
@@ -2051,19 +2051,19 @@
}
},
"node_modules/vitest": {
"version": "4.1.9",
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.9.tgz",
"integrity": "sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ==",
"version": "4.1.10",
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.10.tgz",
"integrity": "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/expect": "4.1.9",
"@vitest/mocker": "4.1.9",
"@vitest/pretty-format": "4.1.9",
"@vitest/runner": "4.1.9",
"@vitest/snapshot": "4.1.9",
"@vitest/spy": "4.1.9",
"@vitest/utils": "4.1.9",
"@vitest/expect": "4.1.10",
"@vitest/mocker": "4.1.10",
"@vitest/pretty-format": "4.1.10",
"@vitest/runner": "4.1.10",
"@vitest/snapshot": "4.1.10",
"@vitest/spy": "4.1.10",
"@vitest/utils": "4.1.10",
"es-module-lexer": "^2.0.0",
"expect-type": "^1.3.0",
"magic-string": "^0.30.21",
@@ -2091,12 +2091,12 @@
"@edge-runtime/vm": "*",
"@opentelemetry/api": "^1.9.0",
"@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0",
"@vitest/browser-playwright": "4.1.9",
"@vitest/browser-preview": "4.1.9",
"@vitest/browser-webdriverio": "4.1.9",
"@vitest/coverage-istanbul": "4.1.9",
"@vitest/coverage-v8": "4.1.9",
"@vitest/ui": "4.1.9",
"@vitest/browser-playwright": "4.1.10",
"@vitest/browser-preview": "4.1.10",
"@vitest/browser-webdriverio": "4.1.10",
"@vitest/coverage-istanbul": "4.1.10",
"@vitest/coverage-v8": "4.1.10",
"@vitest/ui": "4.1.10",
"happy-dom": "*",
"jsdom": "*",
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
+3 -2
View File
@@ -1,7 +1,7 @@
{
"name": "firelink",
"private": true,
"version": "1.0.1",
"version": "1.0.2",
"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",
@@ -32,6 +32,7 @@
"bindings": "cd src-tauri && cargo test export_bindings --lib",
"build": "tsc && vite build",
"check:updates": "node scripts/check-updates.js",
"verify:macos-signing": "node scripts/verify-macos-signing.js",
"preview": "vite preview",
"tauri": "tauri",
"test": "vitest"
@@ -61,6 +62,6 @@
"tailwindcss": "^4.3.1",
"typescript": "^6.0.3",
"vite": "^8.1.3",
"vitest": "^4.1.9"
"vitest": "^4.1.10"
}
}
+41 -2
View File
@@ -73,6 +73,30 @@ async function latestMartinRiedlMacArm64Release() {
return match?.[1];
}
async function latestMartinRiedlMacArm64Snapshot() {
const html = await fetchText('https://ffmpeg.martin-riedl.de/');
const snapshotSection = html.split('Download Snapshot Build')[1]?.split('Download Release Build')[0] || '';
const match =
snapshotSection.match(/macOS \(Apple Silicon\/arm64\)[\s\S]*?<b>Release:\s*<\/b>\s*([A-Za-z0-9.-]+)/) ||
snapshotSection.match(/macOS \(Apple Silicon\/arm64\)[\s\S]*?Release:\s*([A-Za-z0-9.-]+)/);
return match?.[1];
}
async function latestBtbnFfmpegN81Build() {
const releases = await fetchJson('https://api.github.com/repos/BtbN/FFmpeg-Builds/releases?per_page=10');
for (const release of releases) {
if (release.tag_name === 'latest') continue;
const versions = (release.assets || [])
.map(asset => asset.name.match(/^ffmpeg-n(8\.1\.\d+-\d+-g[0-9a-f]+)-(?:win64-gpl-8\.1\.zip|linux64-gpl-8\.1\.tar\.xz)$/)?.[1])
.filter(Boolean);
const unique = [...new Set(versions)];
if (unique.length === 1 && versions.length >= 2) {
return unique[0];
}
}
return undefined;
}
function printNpmReport(label, outdated) {
const entries = Object.entries(outdated);
if (!entries.length) {
@@ -129,12 +153,22 @@ async function main() {
npmOutdated(path.join(repoRoot, 'Extensions', 'Firefox'))
);
const [ytDlp, deno, aria2, ffmpeg, martinRiedlMacArm64Ffmpeg] = await Promise.all([
const [
ytDlp,
deno,
aria2,
ffmpeg,
martinRiedlMacArm64Ffmpeg,
martinRiedlMacArm64Snapshot,
btbnFfmpegN81Build,
] = await Promise.all([
githubLatest('yt-dlp/yt-dlp'),
githubLatest('denoland/deno'),
githubLatest('aria2/aria2'),
latestFfmpegStable(),
latestMartinRiedlMacArm64Release(),
latestMartinRiedlMacArm64Snapshot(),
latestBtbnFfmpegN81Build(),
]);
const latestByEngine = {
'yt-dlp': ytDlp.tag_name,
@@ -143,13 +177,18 @@ async function main() {
ffmpeg,
};
const latestByTargetEngine = {
'aarch64-apple-darwin:ffmpeg': martinRiedlMacArm64Ffmpeg,
'x86_64-pc-windows-msvc:ffmpeg': btbnFfmpegN81Build || ffmpeg,
'x86_64-unknown-linux-gnu:ffmpeg': btbnFfmpegN81Build || ffmpeg,
'aarch64-apple-darwin:ffmpeg': martinRiedlMacArm64Snapshot || martinRiedlMacArm64Ffmpeg,
};
console.log('\nlatest engines:');
for (const [engine, version] of Object.entries(latestByEngine)) {
console.log(` ${engine}: ${normalizeVersion(version)}`);
}
console.log('\nlatest engine provider builds:');
console.log(` BtbN FFmpeg n8.1 Windows/Linux: ${normalizeVersion(btbnFfmpegN81Build || ffmpeg)}`);
console.log(` Martin Riedl FFmpeg macOS arm64 snapshot: ${normalizeVersion(martinRiedlMacArm64Snapshot || martinRiedlMacArm64Ffmpeg)}`);
console.log('\nengine source lock:');
outdatedCount += checkRows(
+306
View File
@@ -0,0 +1,306 @@
#!/usr/bin/env node
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { execFileSync, spawnSync } from 'node:child_process';
function argValue(name) {
const index = process.argv.indexOf(name);
return index >= 0 ? process.argv[index + 1] : undefined;
}
const appArg = argValue('--app');
const dmgArg = argValue('--dmg');
if (process.platform !== 'darwin') {
console.error('macOS signing verification must run on a macOS host.');
process.exit(1);
}
if (!appArg && !dmgArg) {
console.error('Pass --app <Firelink.app> and/or --dmg <Firelink.dmg>.');
process.exit(1);
}
let exitCode = 0;
function fail(message) {
console.error(`[FAIL] ${message}`);
exitCode = 1;
}
function ok(message) {
console.log(`[OK] ${message}`);
}
function note(message) {
console.log(`[INFO] ${message}`);
}
function run(command, args, options = {}) {
return execFileSync(command, args, {
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'pipe'],
...options,
});
}
function runResult(command, args) {
return spawnSync(command, args, {
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'pipe'],
});
}
function assertAppPath(appPath, label) {
if (!fs.existsSync(appPath)) {
fail(`${label} does not exist at ${appPath}`);
return false;
}
const stat = fs.statSync(appPath);
if (!stat.isDirectory() || path.extname(appPath) !== '.app') {
fail(`${label} is not an .app bundle: ${appPath}`);
return false;
}
ok(`${label} exists: ${appPath}`);
return true;
}
function codesignDetails(targetPath) {
const result = runResult('codesign', ['-dv', '--verbose=4', targetPath]);
return {
ok: result.status === 0,
output: `${result.stdout || ''}${result.stderr || ''}`,
};
}
function verifyCodeSignature(targetPath, label, options = {}) {
const {
deep = false,
quietOk = false,
requireAdhoc = false,
warnOnVerifyFailure = false,
} = options;
const verifyArgs = ['--verify'];
if (deep) {
verifyArgs.push('--deep');
}
verifyArgs.push('--strict', '--verbose=2', targetPath);
const details = codesignDetails(targetPath);
if (!details.ok) {
fail(`${label}: no readable code signature: ${details.output.trim() || 'codesign -dv failed'}`);
return 'failed';
}
let status = 'verified';
try {
run('codesign', verifyArgs);
if (!quietOk) {
ok(`${label}: codesign verification passed`);
}
} catch (error) {
const detail = error.stderr?.trim() || error.message;
if (!warnOnVerifyFailure) {
fail(`${label}: codesign verification failed: ${detail}`);
return 'failed';
}
note(`${label}: signed, but individual verification warned: ${detail}`);
status = 'warning';
}
if (requireAdhoc && !details.output.includes('Signature=adhoc')) {
fail(`${label}: expected ad-hoc signature, but codesign did not report Signature=adhoc`);
return 'failed';
}
if (requireAdhoc && !quietOk) {
ok(`${label}: ad-hoc signature present`);
}
return status;
}
function walkFiles(root, visitor) {
for (const entry of fs.readdirSync(root, { withFileTypes: true })) {
const entryPath = path.join(root, entry.name);
if (entry.isSymbolicLink()) {
continue;
}
if (entry.isDirectory()) {
walkFiles(entryPath, visitor);
continue;
}
if (entry.isFile()) {
visitor(entryPath);
}
}
}
function fileBrief(filePath) {
try {
return run('file', ['--brief', filePath], { timeout: 5000 }).trim();
} catch (error) {
fail(`file(1) failed for ${filePath}: ${error.stderr?.trim() || error.message}`);
return '';
}
}
function verifyMachOObjects(appPath, label) {
const machOFiles = [];
walkFiles(appPath, filePath => {
const description = fileBrief(filePath);
if (description.includes('Mach-O')) {
machOFiles.push(filePath);
}
});
if (machOFiles.length === 0) {
fail(`${label}: no Mach-O files found inside app bundle`);
return;
}
let warningCount = 0;
let signedCount = 0;
let verifiedCount = 0;
for (const filePath of machOFiles) {
const relative = path.relative(appPath, filePath).split(path.sep).join('/');
const isPrimaryExecutable = relative === 'Contents/MacOS/firelink';
const isDirectEngine = /^Contents\/Resources\/engine-dist\/[^/]+\/(?:yt-dlp|aria2c|ffmpeg|deno)-/.test(relative);
const mayWarn = !isPrimaryExecutable && !isDirectEngine;
const result = verifyCodeSignature(filePath, `${label} ${relative}`, {
quietOk: true,
warnOnVerifyFailure: mayWarn,
});
if (result !== 'failed') {
signedCount += 1;
if (result === 'verified') {
verifiedCount += 1;
} else {
warningCount += 1;
}
}
}
ok(`${label}: found signatures on ${signedCount}/${machOFiles.length} Mach-O code object(s)`);
ok(`${label}: individually verified ${verifiedCount}/${machOFiles.length} Mach-O code object(s)`);
if (warningCount > 0) {
note(`${label}: ${warningCount} nested signed framework object(s) produced individual verification warnings; the outer bundle signature remains authoritative.`);
}
}
function assessGatekeeper(appPath, label) {
const result = runResult('spctl', ['--assess', '--type', 'execute', '--verbose=4', appPath]);
const output = `${result.stdout || ''}${result.stderr || ''}`.trim();
if (result.status === 0) {
note(`${label}: Gatekeeper accepted this app (${output || 'no spctl detail'}).`);
return;
}
const normalized = output.toLowerCase();
if (normalized.includes('not signed at all') || normalized.includes('invalid signature')) {
fail(`${label}: Gatekeeper rejection indicates a broken signature: ${output}`);
return;
}
note(`${label}: Gatekeeper rejected the app as expected for ad-hoc, unnotarized distribution: ${output || `exit ${result.status}`}`);
}
function reportQuarantine(targetPath, label) {
const result = runResult('xattr', ['-p', 'com.apple.quarantine', targetPath]);
if (result.status === 0) {
fail(`${label}: build artifact unexpectedly has com.apple.quarantine=${result.stdout.trim()}`);
} else {
ok(`${label}: no quarantine xattr on generated artifact`);
}
}
function verifyApp(appPath, label) {
const resolved = path.resolve(appPath);
if (!assertAppPath(resolved, label)) {
return;
}
reportQuarantine(resolved, label);
verifyCodeSignature(resolved, label, { deep: true, requireAdhoc: true });
verifyMachOObjects(resolved, label);
assessGatekeeper(resolved, label);
}
function attachDmg(dmgPath) {
const mountPoint = fs.mkdtempSync(path.join(os.tmpdir(), 'firelink-dmg-'));
try {
const plist = run('hdiutil', [
'attach',
'-plist',
'-nobrowse',
'-readonly',
'-mountpoint',
mountPoint,
dmgPath,
], { timeout: 60000 });
return { mountPoint, plist };
} catch (error) {
fs.rmSync(mountPoint, { recursive: true, force: true });
throw error;
}
}
function detachDmg(mountPoint) {
const result = runResult('hdiutil', ['detach', mountPoint]);
if (result.status !== 0) {
note(`Initial hdiutil detach failed, retrying with -force: ${result.stderr?.trim() || result.stdout?.trim()}`);
const forced = runResult('hdiutil', ['detach', '-force', mountPoint]);
if (forced.status !== 0) {
fail(`Failed to detach DMG mount point ${mountPoint}: ${forced.stderr?.trim() || forced.stdout?.trim()}`);
}
}
fs.rmSync(mountPoint, { recursive: true, force: true });
}
function verifyDmg(dmgPath) {
const resolved = path.resolve(dmgPath);
if (!fs.existsSync(resolved)) {
fail(`DMG does not exist at ${resolved}`);
return;
}
reportQuarantine(resolved, 'DMG');
let mount;
try {
mount = attachDmg(resolved);
ok(`DMG mounted at ${mount.mountPoint}`);
const apps = fs.readdirSync(mount.mountPoint)
.filter(name => name.endsWith('.app'))
.map(name => path.join(mount.mountPoint, name));
if (apps.length !== 1) {
fail(`Expected exactly one .app inside DMG, found ${apps.length}`);
return;
}
verifyApp(apps[0], 'DMG app');
} catch (error) {
fail(`DMG verification failed: ${error.stderr?.trim() || error.message}`);
} finally {
if (mount) {
detachDmg(mount.mountPoint);
}
}
}
if (appArg) {
verifyApp(appArg, 'Built app');
}
if (dmgArg) {
verifyDmg(dmgArg);
}
console.log('');
if (exitCode !== 0) {
console.error('[FAIL] macOS signing verification failed.');
process.exit(1);
}
console.log('[PASS] macOS ad-hoc signing verification passed.');
+228 -113
View File
@@ -86,9 +86,9 @@ checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3"
[[package]]
name = "arrayvec"
version = "0.7.7"
version = "0.7.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f02882884d3e1bc524fb12c79f107f6ad0e1cfd498c536ffb494301740995dfe"
checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56"
[[package]]
name = "async-broadcast"
@@ -371,6 +371,15 @@ dependencies = [
"generic-array",
]
[[package]]
name = "block-buffer"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa"
dependencies = [
"hybrid-array",
]
[[package]]
name = "block2"
version = "0.6.2"
@@ -507,9 +516,9 @@ checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495"
[[package]]
name = "bytes"
version = "1.12.0"
version = "1.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ae3f5d315924270530207e2a68396c3cc547f6dca3fbdca317cfb1a51edb593"
checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
dependencies = [
"serde",
]
@@ -583,9 +592,9 @@ dependencies = [
[[package]]
name = "cc"
version = "1.2.65"
version = "1.2.66"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e228eec9be7c17ccb640b59b36a5cd805ea2a564a4c5e162c2f659fea30d3b96"
checksum = "f5d6cac793997bd970000024b2934968efe83b382de4fdcf4fcb46b6ee4ad996"
dependencies = [
"find-msvc-tools",
"shlex",
@@ -630,6 +639,17 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
[[package]]
name = "chacha20"
version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81"
dependencies = [
"cfg-if",
"cpufeatures 0.3.0",
"rand_core 0.10.1",
]
[[package]]
name = "chrono"
version = "0.4.45"
@@ -644,6 +664,12 @@ dependencies = [
"windows-link 0.2.1",
]
[[package]]
name = "cmov"
version = "0.5.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a"
[[package]]
name = "combine"
version = "4.6.7"
@@ -663,6 +689,12 @@ dependencies = [
"crossbeam-utils",
]
[[package]]
name = "const-oid"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c"
[[package]]
name = "const-random"
version = "0.1.18"
@@ -752,6 +784,15 @@ dependencies = [
"libc",
]
[[package]]
name = "cpufeatures"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201"
dependencies = [
"libc",
]
[[package]]
name = "crc32fast"
version = "1.5.0"
@@ -763,18 +804,18 @@ dependencies = [
[[package]]
name = "crossbeam-channel"
version = "0.5.15"
version = "0.5.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2"
checksum = "d85363c37faeca707aef026efa9f3b34d077bce547e48f770770625c6013679e"
dependencies = [
"crossbeam-utils",
]
[[package]]
name = "crossbeam-utils"
version = "0.8.21"
version = "0.8.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17"
[[package]]
name = "crunchy"
@@ -792,6 +833,15 @@ dependencies = [
"typenum",
]
[[package]]
name = "crypto-common"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453"
dependencies = [
"hybrid-array",
]
[[package]]
name = "cssparser"
version = "0.36.0"
@@ -831,6 +881,15 @@ version = "0.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "52560adf09603e58c9a7ee1fe1dcb95a16927b17c127f0ac02d6e768a0e25bc1"
[[package]]
name = "ctutils"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e"
dependencies = [
"cmov",
]
[[package]]
name = "darling"
version = "0.20.11"
@@ -908,9 +967,9 @@ checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8"
[[package]]
name = "dbus"
version = "0.9.11"
version = "0.9.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b942602992bb7acfd1f51c49811c58a610ef9181b6e66f3e519d79b540a3bf73"
checksum = "3ab69f03cc8c4340c9c8e315114e1658e6775a9b16a04357973aa21cec22b32e"
dependencies = [
"libc",
"libdbus-sys",
@@ -995,9 +1054,20 @@ version = "0.10.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
dependencies = [
"block-buffer",
"crypto-common",
"subtle",
"block-buffer 0.10.4",
"crypto-common 0.1.7",
]
[[package]]
name = "digest"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2"
dependencies = [
"block-buffer 0.12.1",
"const-oid",
"crypto-common 0.2.2",
"ctutils",
]
[[package]]
@@ -1144,9 +1214,9 @@ checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555"
[[package]]
name = "embed-resource"
version = "3.0.9"
version = "3.0.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c31a88c8d26de40ed18fe748c547845aa39de1db3afd958f8cb91579f3644bcb"
checksum = "fbfdaacccebec3b28e4866b8973543c7647797db5ada1bdab552e48fe665fbbd"
dependencies = [
"cc",
"memchr",
@@ -1232,7 +1302,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys 0.52.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -1310,7 +1380,7 @@ checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
[[package]]
name = "firelink"
version = "1.0.1"
version = "1.0.2"
dependencies = [
"async-trait",
"axum",
@@ -1327,7 +1397,7 @@ dependencies = [
"semver",
"serde",
"serde_json",
"sha2",
"sha2 0.11.0",
"sysinfo",
"sysproxy",
"system_shutdown",
@@ -1344,7 +1414,7 @@ dependencies = [
"thiserror 2.0.18",
"tokio",
"tokio-tungstenite",
"tower-http",
"tower-http 0.7.0",
"trash",
"ts-rs",
"url",
@@ -1643,11 +1713,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
dependencies = [
"cfg-if",
"js-sys",
"libc",
"r-efi 5.3.0",
"wasip2",
"wasm-bindgen",
]
[[package]]
@@ -1657,8 +1725,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
dependencies = [
"cfg-if",
"js-sys",
"libc",
"r-efi 6.0.0",
"rand_core 0.10.1",
"wasm-bindgen",
]
[[package]]
@@ -1859,9 +1930,9 @@ dependencies = [
[[package]]
name = "hashlink"
version = "0.12.0"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a5081f264ed7adee96ea4b4778b6bb9da0a7228b084587aa3bd3ff05da7c5a3b"
checksum = "32069d97bb81e38fa67eab65e3393bf804bb85969f2bc06bf13f64aef5aba248"
dependencies = [
"hashbrown 0.17.1",
]
@@ -1892,11 +1963,11 @@ checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
[[package]]
name = "hmac"
version = "0.12.1"
version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e"
checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f"
dependencies = [
"digest",
"digest 0.11.3",
]
[[package]]
@@ -1954,6 +2025,15 @@ version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
[[package]]
name = "hybrid-array"
version = "0.4.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "818356c5132c1fede50f837ca96afbe78ff42413047f4abb886217845e1b6c8c"
dependencies = [
"typenum",
]
[[package]]
name = "hyper"
version = "1.10.1"
@@ -2026,7 +2106,7 @@ dependencies = [
"js-sys",
"log 0.4.33",
"wasm-bindgen",
"windows-core 0.56.0",
"windows-core 0.62.2",
]
[[package]]
@@ -2455,9 +2535,9 @@ dependencies = [
[[package]]
name = "libredox"
version = "0.1.17"
version = "0.1.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f02ab6bace2054fb888a3c16f990117b579d14a3088e472d63c6011fa185c9d3"
checksum = "c943259e342f1e06ff2da7a83eabdfe7f92ce10262688dbf1895ff0b3e6e4652"
dependencies = [
"libc",
]
@@ -2560,9 +2640,9 @@ checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3"
[[package]]
name = "memchr"
version = "2.8.2"
version = "2.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4"
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
[[package]]
name = "memoffset"
@@ -3081,7 +3161,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967"
dependencies = [
"libc",
"windows-sys 0.52.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -3228,13 +3308,13 @@ checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e"
[[package]]
name = "plist"
version = "1.9.0"
version = "1.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "092791278e026273c1b65bbdcfbba3a300f2994c896bd01ab01da613c29c46f1"
checksum = "7da1d65da6dd5d1e44199ac0f58712d241c0f439f80adea8924d832384087f85"
dependencies = [
"base64 0.22.1",
"indexmap 2.14.0",
"quick-xml 0.39.4",
"quick-xml",
"serde",
"time",
]
@@ -3393,9 +3473,9 @@ dependencies = [
[[package]]
name = "pxfm"
version = "0.1.29"
version = "0.1.30"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e0c5ccf5294c6ccd63a74f1565028353830a9c2f5eb0c682c355c471726a6e3f"
checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea"
[[package]]
name = "quick-error"
@@ -3405,18 +3485,9 @@ checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0"
[[package]]
name = "quick-xml"
version = "0.37.5"
version = "0.41.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "331e97a1af0bf59823e6eadffe373d7b27f485be8748f71471c662c1f269b7fb"
dependencies = [
"memchr",
]
[[package]]
name = "quick-xml"
version = "0.39.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cdcc8dd4e2f670d309a5f0e83fe36dfdc05af317008fea29144da1a2ac858e5e"
checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1"
dependencies = [
"memchr",
]
@@ -3443,14 +3514,15 @@ dependencies = [
[[package]]
name = "quinn-proto"
version = "0.11.15"
version = "0.11.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4fcb935c5bec503c2f0e306bdd3e58bb9029dcb14fa8d9ac76e3a5256ac0763e"
checksum = "2f4bfc015262b9df63c8845072ce59068853ff5872180c2ce2f13038b970e560"
dependencies = [
"bytes",
"getrandom 0.3.4",
"getrandom 0.4.3",
"lru-slab",
"rand 0.9.4",
"rand 0.10.2",
"rand_pcg",
"ring",
"rustc-hash",
"rustls",
@@ -3464,16 +3536,16 @@ dependencies = [
[[package]]
name = "quinn-udp"
version = "0.5.14"
version = "0.5.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd"
checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694"
dependencies = [
"cfg_aliases",
"libc",
"once_cell",
"socket2",
"tracing",
"windows-sys 0.52.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -3524,6 +3596,17 @@ dependencies = [
"rand_core 0.9.5",
]
[[package]]
name = "rand"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80"
dependencies = [
"chacha20",
"getrandom 0.4.3",
"rand_core 0.10.1",
]
[[package]]
name = "rand_chacha"
version = "0.3.1"
@@ -3562,6 +3645,21 @@ dependencies = [
"getrandom 0.3.4",
]
[[package]]
name = "rand_core"
version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69"
[[package]]
name = "rand_pcg"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a"
dependencies = [
"rand_core 0.10.1",
]
[[package]]
name = "raw-window-handle"
version = "0.6.2"
@@ -3699,7 +3797,7 @@ dependencies = [
"tokio-rustls",
"tokio-util",
"tower",
"tower-http",
"tower-http 0.6.11",
"tower-service",
"url",
"wasm-bindgen",
@@ -3734,7 +3832,7 @@ dependencies = [
"tokio",
"tokio-util",
"tower",
"tower-http",
"tower-http 0.6.11",
"tower-service",
"url",
"wasm-bindgen",
@@ -3864,9 +3962,9 @@ dependencies = [
[[package]]
name = "rustc-hash"
version = "2.1.2"
version = "2.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe"
checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d"
[[package]]
name = "rustc_version"
@@ -3887,7 +3985,7 @@ dependencies = [
"errno",
"libc",
"linux-raw-sys",
"windows-sys 0.52.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -3906,9 +4004,9 @@ dependencies = [
[[package]]
name = "rustls-pki-types"
version = "1.14.1"
version = "1.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9"
checksum = "764899a24af3980067ee14bc143654f297b22eaebfe3c7b6b211920a5a59b046"
dependencies = [
"web-time",
"zeroize",
@@ -3927,9 +4025,9 @@ dependencies = [
[[package]]
name = "rustversion"
version = "1.0.22"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
[[package]]
name = "ryu"
@@ -4262,8 +4360,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba"
dependencies = [
"cfg-if",
"cpufeatures",
"digest",
"cpufeatures 0.2.17",
"digest 0.10.7",
]
[[package]]
@@ -4273,8 +4371,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
dependencies = [
"cfg-if",
"cpufeatures",
"digest",
"cpufeatures 0.2.17",
"digest 0.10.7",
]
[[package]]
name = "sha2"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4"
dependencies = [
"cfg-if",
"cpufeatures 0.3.0",
"digest 0.11.3",
]
[[package]]
@@ -4635,9 +4744,9 @@ checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1"
[[package]]
name = "tauri"
version = "2.11.4"
version = "2.11.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "766b7bd007abce1210b89d3b9cb36c09d0e4b258f52a77e1ca0874e86efae338"
checksum = "667b20e2726d572dea2de7370da16e188eb06008faf9a92fab7cdc46791190b5"
dependencies = [
"anyhow",
"bytes",
@@ -4676,7 +4785,6 @@ dependencies = [
"tauri-runtime-wry",
"tauri-utils",
"thiserror 2.0.18",
"time",
"tokio",
"tray-icon",
"url",
@@ -4724,7 +4832,7 @@ dependencies = [
"semver",
"serde",
"serde_json",
"sha2",
"sha2 0.10.9",
"syn 2.0.118",
"tauri-utils",
"thiserror 2.0.18",
@@ -4750,9 +4858,9 @@ dependencies = [
[[package]]
name = "tauri-plugin"
version = "2.6.2"
version = "2.6.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e126abc9e84e35cdfd01596140a73a1850cdb0df0a23acf0185776c30b469a6e"
checksum = "74be5dd4bed9afbd145e5716b5fa2ec28cbc29c34ffa61c258c9273d896c8020"
dependencies = [
"anyhow",
"glob",
@@ -5029,11 +5137,10 @@ dependencies = [
[[package]]
name = "tauri-winrt-notification"
version = "0.7.2"
version = "0.7.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b1e66e07de489fe43a46678dd0b8df65e0c973909df1b60ba33874e297ba9b9"
checksum = "9ed071c670382e85fc2f48ae706492d8c338f4f89bf72520d32f8abfe880aade"
dependencies = [
"quick-xml 0.37.5",
"thiserror 2.0.18",
"windows 0.61.3",
"windows-version",
@@ -5049,17 +5156,16 @@ dependencies = [
"getrandom 0.4.3",
"once_cell",
"rustix",
"windows-sys 0.52.0",
"windows-sys 0.61.2",
]
[[package]]
name = "tendril"
version = "0.5.0"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c4790fc369d5a530f4b544b094e31388b9b3a37c0f4652ade4505945f5660d24"
checksum = "5fed54709c5b3a53d09bb1c113ea4f5ceafd1e772ddcb0030a82e1d56c087b08"
dependencies = [
"new_debug_unreachable",
"utf-8",
]
[[package]]
@@ -5122,9 +5228,9 @@ dependencies = [
[[package]]
name = "time"
version = "0.3.49"
version = "0.3.53"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "711a53c2d47bbd818258c498c8dbfe186a2526c631495cfe7e078567f86b8469"
checksum = "18dfaaeddcb932337b5e7866ee7d0ce9b76d2fd092997146f187ec09b4558a50"
dependencies = [
"deranged",
"libc",
@@ -5144,9 +5250,9 @@ checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109"
[[package]]
name = "time-macros"
version = "0.2.29"
version = "0.2.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "71c652a3727a9cbb9a02f707f530b618ce00d0ccd762009c8c23bd191df3c17d"
checksum = "c431b87111666e491a90baa837f914fb45cd5dc3c268591b0220ff5057f2085f"
dependencies = [
"num-conv",
"time-core",
@@ -5402,6 +5508,21 @@ dependencies = [
"url",
]
[[package]]
name = "tower-http"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b11f75e912b0c2be01b63d8cf8057b8c3f97cf34abb3d431a3a4c8675498e233"
dependencies = [
"bitflags 2.13.0",
"bytes",
"http",
"percent-encoding",
"pin-project-lite",
"tower-layer",
"tower-service",
]
[[package]]
name = "tower-layer"
version = "0.3.3"
@@ -5650,12 +5771,6 @@ dependencies = [
"url",
]
[[package]]
name = "utf-8"
version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9"
[[package]]
name = "utf8-ranges"
version = "1.0.5"
@@ -5664,9 +5779,9 @@ checksum = "7fcfc827f90e53a02eaef5e535ee14266c1d569214c6aa70133a624d8a3164ba"
[[package]]
name = "utf8-width"
version = "0.1.8"
version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1292c0d970b54115d14f2492fe0170adf21d68a1de108eebc51c1df4f346a091"
checksum = "159a7cadce548703edd50d24069bc294c5415ecab0a480e0cd1ca06d112dc94a"
[[package]]
name = "utf8_iter"
@@ -5988,7 +6103,7 @@ version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [
"windows-sys 0.52.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -6600,7 +6715,7 @@ dependencies = [
"once_cell",
"percent-encoding",
"raw-window-handle",
"sha2",
"sha2 0.10.9",
"soup3",
"tao-macros",
"thiserror 2.0.18",
@@ -6669,9 +6784,9 @@ dependencies = [
[[package]]
name = "zbus"
version = "5.16.0"
version = "5.17.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eee682d202a77e4a9f3b2c2bdf48a7b28af5c08c34ddf66f98c93e5e39464285"
checksum = "a28b97f866896a4be7aefd2b5a8e01bb6773d19a775d54ab28b4d094b9a4480e"
dependencies = [
"async-broadcast",
"async-executor",
@@ -6704,9 +6819,9 @@ dependencies = [
[[package]]
name = "zbus_macros"
version = "5.16.0"
version = "5.17.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "adf1bd45a81a103745b1757754762a26e8cd01e4532e4d6c8ec431624b80d1d6"
checksum = "5e05ad887425eecf5e8384dc2406a4a9313eb73468712fc1cdea362eb4fe0469"
dependencies = [
"proc-macro-crate 3.5.0",
"proc-macro2",
@@ -6719,9 +6834,9 @@ dependencies = [
[[package]]
name = "zbus_names"
version = "4.3.2"
version = "4.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7074f3e50b894eac91750142016d30d0a89be8e67dbfd9704fb875825760e52d"
checksum = "1039ca249fee9559680f3a9f05b55e0761fee51af4f6c1e7d8c1f31e549721d2"
dependencies = [
"serde",
"winnow 1.0.3",
@@ -6730,18 +6845,18 @@ dependencies = [
[[package]]
name = "zerocopy"
version = "0.8.52"
version = "0.8.53"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ce1022995ff5ff5d841ad7d994facc23098cd40152f2c1d11cd607c6f530653f"
checksum = "75726053136156d419e285b9b7eddaaea9e3fea6ce32eed44a89901f0bd98de1"
dependencies = [
"zerocopy-derive",
]
[[package]]
name = "zerocopy-derive"
version = "0.8.52"
version = "0.8.53"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ae7f38b72ec2a254e2b87ef277cf2cd4fb97cbebf944faa6f33354da0867930"
checksum = "4714fd92cf900833d49538023a9b3915155210801d1c1169eba513b2addefd71"
dependencies = [
"proc-macro2",
"quote",
@@ -6830,9 +6945,9 @@ checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
[[package]]
name = "zvariant"
version = "5.12.0"
version = "5.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a192a0bde63360d77a7523c833d4b4ce6070a927e2c53246e4c540b1a3e27be0"
checksum = "7cf057bb00bf5c9ad77abb6147b0ca4818236a1858416e9d988e40d6322fefa7"
dependencies = [
"endi",
"enumflags2",
@@ -6844,9 +6959,9 @@ dependencies = [
[[package]]
name = "zvariant_derive"
version = "5.12.0"
version = "5.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "90bc6cde9c01c511074be97f7ccb6c19d0da89e3f8662e812e999dcfd4638737"
checksum = "8118ca6bda77bfc0ab51d660db0c955f2505eef854c9a449435bccb616933b31"
dependencies = [
"proc-macro-crate 3.5.0",
"proc-macro2",
@@ -6857,9 +6972,9 @@ dependencies = [
[[package]]
name = "zvariant_utils"
version = "3.4.0"
version = "3.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e8535915cfa75547e559d8c68e8139909a4aeee076831e4ef7fc59d8172c4d6"
checksum = "90cb9383f9b45290407a1258b202d3f8f01db719eb60b4e4055c6375af4fc7c7"
dependencies = [
"proc-macro2",
"quote",
+5 -5
View File
@@ -1,6 +1,6 @@
[package]
name = "firelink"
version = "1.0.1"
version = "1.0.2"
description = "A fast cross-platform desktop download manager powered by Rust and Tauri"
authors = ["NimBold"]
edition = "2021"
@@ -31,19 +31,19 @@ serde = { version = "1", features = ["derive"] }
serde_json = "1"
tokio = { version = "1", features = ["fs", "process", "io-util", "rt", "rt-multi-thread", "macros", "sync", "time"] }
regex = "1.10"
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json", "stream"] }
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json", "stream", "socks"] }
uuid = { version = "1", features = ["v4"] }
ts-rs = { version = "12", features = ["serde-compat", "uuid-impl"] }
tauri-plugin-notification = "2.3.3"
sysinfo = "0.39.3"
hmac = "0.12"
sha2 = "0.10"
hmac = "0.13"
sha2 = "0.11"
tauri-plugin-deep-link = "2"
tauri-plugin-single-instance = { version = "2", features = ["deep-link"] }
tempfile = "3"
thiserror = "2.0.18"
axum = "0.8.9"
tower-http = { version = "0.6.11", features = ["cors"] }
tower-http = { version = "0.7", features = ["cors"] }
sysproxy = "0.3.0"
semver = "1.0.28"
keepawake = "0.6.0"
Binary file not shown.
Binary file not shown.
@@ -7,7 +7,7 @@
<key>CFBundleIdentifier</key>
<string>org.python.python</string>
<key>CFBundleVersion</key>
<string>3.14.5</string>
<string>3.14.6</string>
<key>CFBundlePackageType</key>
<string>FMWK</string>
<key>CFBundleSignature</key>
@@ -17,7 +17,7 @@
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleShortVersionString</key>
<string>3.14.5</string>
<string>3.14.6</string>
<key>CFBundleSupportedPlatforms</key>
<array>
<string>MacOSX</string>
@@ -7,7 +7,7 @@
<key>CFBundleIdentifier</key>
<string>org.python.python</string>
<key>CFBundleVersion</key>
<string>3.14.5</string>
<string>3.14.6</string>
<key>CFBundlePackageType</key>
<string>FMWK</string>
<key>CFBundleSignature</key>
@@ -17,7 +17,7 @@
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleShortVersionString</key>
<string>3.14.5</string>
<string>3.14.6</string>
<key>CFBundleSupportedPlatforms</key>
<array>
<string>MacOSX</string>
@@ -7,7 +7,7 @@
<key>CFBundleIdentifier</key>
<string>org.python.python</string>
<key>CFBundleVersion</key>
<string>3.14.5</string>
<string>3.14.6</string>
<key>CFBundlePackageType</key>
<string>FMWK</string>
<key>CFBundleSignature</key>
@@ -17,7 +17,7 @@
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleShortVersionString</key>
<string>3.14.5</string>
<string>3.14.6</string>
<key>CFBundleSupportedPlatforms</key>
<array>
<string>MacOSX</string>
Binary file not shown.
@@ -3528,35 +3528,6 @@ xeSDwWrruoBa3lwtcHb4yOWHh8qgnaHlIhInD0Q9HWzq1MKLL295q39QpsQZp6F6
t5b5wR9iWqJDB0BeJsas7a5wFsWqynKKTbDPAYsDP27X
-----END CERTIFICATE-----
# Issuer: CN=SecureSign Root CA12 O=Cybertrust Japan Co., Ltd.
# Subject: CN=SecureSign Root CA12 O=Cybertrust Japan Co., Ltd.
# Label: "SecureSign Root CA12"
# Serial: 587887345431707215246142177076162061960426065942
# MD5 Fingerprint: c6:89:ca:64:42:9b:62:08:49:0b:1e:7f:e9:07:3d:e8
# SHA1 Fingerprint: 7a:22:1e:3d:de:1b:06:ac:9e:c8:47:70:16:8e:3c:e5:f7:6b:06:f4
# SHA256 Fingerprint: 3f:03:4b:b5:70:4d:44:b2:d0:85:45:a0:20:57:de:93:eb:f3:90:5f:ce:72:1a:cb:c7:30:c0:6d:da:ee:90:4e
-----BEGIN CERTIFICATE-----
MIIDcjCCAlqgAwIBAgIUZvnHwa/swlG07VOX5uaCwysckBYwDQYJKoZIhvcNAQEL
BQAwUTELMAkGA1UEBhMCSlAxIzAhBgNVBAoTGkN5YmVydHJ1c3QgSmFwYW4gQ28u
LCBMdGQuMR0wGwYDVQQDExRTZWN1cmVTaWduIFJvb3QgQ0ExMjAeFw0yMDA0MDgw
NTM2NDZaFw00MDA0MDgwNTM2NDZaMFExCzAJBgNVBAYTAkpQMSMwIQYDVQQKExpD
eWJlcnRydXN0IEphcGFuIENvLiwgTHRkLjEdMBsGA1UEAxMUU2VjdXJlU2lnbiBS
b290IENBMTIwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQC6OcE3emhF
KxS06+QT61d1I02PJC0W6K6OyX2kVzsqdiUzg2zqMoqUm048luT9Ub+ZyZN+v/mt
p7JIKwccJ/VMvHASd6SFVLX9kHrko+RRWAPNEHl57muTH2SOa2SroxPjcf59q5zd
J1M3s6oYwlkm7Fsf0uZlfO+TvdhYXAvA42VvPMfKWeP+bl+sg779XSVOKik71gur
FzJ4pOE+lEa+Ym6b3kaosRbnhW70CEBFEaCeVESE99g2zvVQR9wsMJvuwPWW0v4J
hscGWa5Pro4RmHvzC1KqYiaqId+OJTN5lxZJjfU+1UefNzFJM3IFTQy2VYzxV4+K
h9GtxRESOaCtAgMBAAGjQjBAMA8GA1UdEwEB/wQFMAMBAf8wDgYDVR0PAQH/BAQD
AgEGMB0GA1UdDgQWBBRXNPN0zwRL1SXm8UC2LEzZLemgrTANBgkqhkiG9w0BAQsF
AAOCAQEAPrvbFxbS8hQBICw4g0utvsqFepq2m2um4fylOqyttCg6r9cBg0krY6Ld
mmQOmFxv3Y67ilQiLUoT865AQ9tPkbeGGuwAtEGBpE/6aouIs3YIcipJQMPTw4WJ
mBClnW8Zt7vPemVV2zfrPIpyMpcemik+rY3moxtt9XUa5rBouVui7mlHJzWhhpmA
8zNL4WukJsPvdFlseqJkth5Ew1DgDzk9qTPxpfPSvWKErI4cqc1avTc7bgoitPQV
55FYxTpE05Uo2cBl6XLK0A+9H7MV2anjpEcJnuDLN/v9vZfVvhgaaaI5gdka9at/
yOPiZwud9AzqVN/Ssq+xIvEg37xEHA==
-----END CERTIFICATE-----
# Issuer: CN=SecureSign Root CA14 O=Cybertrust Japan Co., Ltd.
# Subject: CN=SecureSign Root CA14 O=Cybertrust Japan Co., Ltd.
# Label: "SecureSign Root CA14"
@@ -1,60 +1,60 @@
../../../bin/curl-cffi,sha256=1WNmf_w0emP0-PeIpDGlHiBy4Gdet6ASfQxBOR-BNMA,187
curl_cffi-0.15.0.dist-info/INSTALLER,sha256=zuuue4knoyJ-UwPPXg8fezS7VCrXJQrAP7zeNuwvFQg,4
curl_cffi-0.15.0.dist-info/METADATA,sha256=ryYvNCZOl96BIMI2134ZhyRWy7sr-0-xOc0Q_TfaU9A,18405
curl_cffi-0.15.0.dist-info/RECORD,,
curl_cffi-0.15.0.dist-info/REQUESTED,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
curl_cffi-0.15.0.dist-info/WHEEL,sha256=BX73HzL_Z8VmcUg66xwFel1CJ1goP9ZPbeeVSSFaesY,140
curl_cffi-0.15.0.dist-info/direct_url.json,sha256=lpNNgQ8844-2Q1nCwxiwtLw07Je0dsNXHPwhnLfnit4,313
curl_cffi-0.15.0.dist-info/entry_points.txt,sha256=HzTwoUpbiKIo5uJrqnxPk5OdxZ0PhT0dPTSqUP8Js70,49
curl_cffi-0.15.0.dist-info/licenses/LICENSE,sha256=PoiwKbULav021rGGQs5Mi27uTJA_HPq-9bgR9h4HBQs,1106
curl_cffi-0.15.0.dist-info/top_level.txt,sha256=b51YB50I_vu6XAbSERmqtgaYciYADCA_baVoZ_T5Lzs,10
curl_cffi/__init__.py,sha256=fPBkVIThnbk_a7U41V5S3Qwhh1_WxZkMerXQNv1iOpA,1781
curl_cffi/__main__.py,sha256=L0AH-xyDlgmsPM-HTecPVb1_hV9X35Qf1Mw7h7JVnx0,39
curl_cffi/__pycache__/__init__.cpython-314.pyc,,
curl_cffi/__pycache__/__main__.cpython-314.pyc,,
curl_cffi/__pycache__/__version__.cpython-314.pyc,,
curl_cffi/__pycache__/_asyncio_selector.cpython-314.pyc,,
curl_cffi/__pycache__/aio.cpython-314.pyc,,
curl_cffi/__pycache__/const.cpython-314.pyc,,
curl_cffi/__pycache__/curl.cpython-314.pyc,,
curl_cffi/__pycache__/utils.cpython-314.pyc,,
curl_cffi/__version__.py,sha256=FdOv9cpHIUGkFkcWA4O9gte0sun1QgJI69rIgzUkswo,413
curl_cffi/_asyncio_selector.py,sha256=dxKOC6B8mlRu3WYi0trSq0L-FsWV6l_pg9TP35OpDI4,13043
curl_cffi/_wrapper.abi3.so,sha256=4sMTX_Q2WMouO96xfxc3BZjZxnMZnLjZV8nF_FCU_zE,12691712
curl_cffi/aio.py,sha256=oTiffUFDNfxxSHmCVAsjX0CIOs4IuBQ6mhm4WuY5KZs,11925
curl_cffi/cli/__init__.py,sha256=y-XQAfYkBDN3ejfVex1aKy5bcqsPUglAvm-i177wM5I,7290
curl_cffi/cli/__pycache__/__init__.cpython-314.pyc,,
curl_cffi/cli/__pycache__/doctor.cpython-314.pyc,,
curl_cffi/cli/__pycache__/output.cpython-314.pyc,,
curl_cffi/cli/__pycache__/parse.cpython-314.pyc,,
curl_cffi/cli/__pycache__/request.cpython-314.pyc,,
curl_cffi/cli/__pycache__/run.cpython-314.pyc,,
curl_cffi/cli/doctor.py,sha256=4wfyWF44JLxDtoH9YCDdmxrJ-1cWbNwSXcMc9Aopd1c,416
curl_cffi/cli/output.py,sha256=YzWbYLJ2zowR6G9mJxeTytcPrnSk2WQVwfmv8V404X4,6709
curl_cffi/cli/parse.py,sha256=OzqGEZPqOrH2nq14UKnaXwKAblAw6V867tDEp9XSLdo,2549
curl_cffi/cli/request.py,sha256=MJskNMJBIR8oq_fvrPvsi61Wy-bR4-aC8QQJb1YcqRg,3522
curl_cffi/cli/run.py,sha256=WYaPbX9h85DZQNkDbUUyvp3bryaAuNOxnjU6GpenigQ,7554
curl_cffi/const.py,sha256=mF3H1kHsqj-dfd7GWHMHyuB2bZ3QZd61tC1iCZgEjyo,18523
curl_cffi/curl.py,sha256=woBPw1OQk4FH7neNETuj_GwqcQa1jCjd1tA0ZxPSTA8,26928
curl_cffi/py.typed,sha256=dcrsqJrcYfTX-ckLFJMTaj6mD8aDe2u0tkQG-ZYxnEg,26
curl_cffi/requests/__init__.py,sha256=3ZO7mC7gNJZydze0VQLEYpEc9dMlf1uRbFS-fxKSQPs,6148
curl_cffi/requests/__pycache__/__init__.cpython-314.pyc,,
curl_cffi/requests/__pycache__/cookies.cpython-314.pyc,,
curl_cffi/requests/__pycache__/errors.cpython-314.pyc,,
curl_cffi/requests/__pycache__/exceptions.cpython-314.pyc,,
curl_cffi/requests/__pycache__/headers.cpython-314.pyc,,
curl_cffi/requests/__pycache__/impersonate.cpython-314.pyc,,
curl_cffi/requests/__pycache__/models.cpython-314.pyc,,
curl_cffi/requests/__pycache__/session.cpython-314.pyc,,
curl_cffi/requests/__pycache__/utils.cpython-314.pyc,,
curl_cffi/requests/__pycache__/websockets.cpython-314.pyc,,
curl_cffi/requests/cookies.py,sha256=QDEuhtsSjh6iNAKmp5TnGyyAe8wdDJCADCHY_GWCeCc,11867
curl_cffi/requests/errors.py,sha256=R6N5lmOTdRukThkNGUihDAQRu8HSh27M8E3zfUJJX74,250
curl_cffi/requests/exceptions.py,sha256=ViyLx3XHii_s7kjrO3GhVOVXhq2_UsYfAQl8MPwDnEM,6187
curl_cffi/requests/headers.py,sha256=Q1jrRdJ2YMLOcl5W3WBaKZAdLbPGPPERJ6MYzos3YXk,11535
curl_cffi/requests/impersonate.py,sha256=dKRpbPTqLCABrCmSKmgXyhA1zmrv0DMuSuqQyhn5-FU,13389
curl_cffi/requests/models.py,sha256=SaN9QUYyljUDFDQnUrYZRl9KiUe25nb2c2ca-dfq3Nw,11487
curl_cffi/requests/session.py,sha256=LkfPvAr8BIVzB_K91xwh0wIPRW26mi2VJd1cEYorIho,58871
curl_cffi/requests/utils.py,sha256=zMKaWoXcAPFNBv6FglE3FSAPNuxYr_leUcWKnhfYecw,26618
curl_cffi/requests/websockets.py,sha256=RJVNqCDrlLWzQ12Ym9M5_T0SW4EmoqKUYsXXTaQmBRQ,71685
curl_cffi/utils.py,sha256=gRVzO-vhjf596V6kr_SjwHlwJfDIwTrPbRLJvvNlUNE,307
../../../bin/curl-cffi,sha256=1WNmf_w0emP0-PeIpDGlHiBy4Gdet6ASfQxBOR-BNMA,187
curl_cffi-0.15.0.dist-info/INSTALLER,sha256=zuuue4knoyJ-UwPPXg8fezS7VCrXJQrAP7zeNuwvFQg,4
curl_cffi-0.15.0.dist-info/METADATA,sha256=ryYvNCZOl96BIMI2134ZhyRWy7sr-0-xOc0Q_TfaU9A,18405
curl_cffi-0.15.0.dist-info/RECORD,,
curl_cffi-0.15.0.dist-info/REQUESTED,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
curl_cffi-0.15.0.dist-info/WHEEL,sha256=BX73HzL_Z8VmcUg66xwFel1CJ1goP9ZPbeeVSSFaesY,140
curl_cffi-0.15.0.dist-info/direct_url.json,sha256=ORhsskaIg_jwHIDYD7oRH5nzN3fKYa7xVFg-8WceJ5E,313
curl_cffi-0.15.0.dist-info/entry_points.txt,sha256=HzTwoUpbiKIo5uJrqnxPk5OdxZ0PhT0dPTSqUP8Js70,49
curl_cffi-0.15.0.dist-info/licenses/LICENSE,sha256=PoiwKbULav021rGGQs5Mi27uTJA_HPq-9bgR9h4HBQs,1106
curl_cffi-0.15.0.dist-info/top_level.txt,sha256=b51YB50I_vu6XAbSERmqtgaYciYADCA_baVoZ_T5Lzs,10
curl_cffi/__init__.py,sha256=fPBkVIThnbk_a7U41V5S3Qwhh1_WxZkMerXQNv1iOpA,1781
curl_cffi/__main__.py,sha256=L0AH-xyDlgmsPM-HTecPVb1_hV9X35Qf1Mw7h7JVnx0,39
curl_cffi/__pycache__/__init__.cpython-314.pyc,,
curl_cffi/__pycache__/__main__.cpython-314.pyc,,
curl_cffi/__pycache__/__version__.cpython-314.pyc,,
curl_cffi/__pycache__/_asyncio_selector.cpython-314.pyc,,
curl_cffi/__pycache__/aio.cpython-314.pyc,,
curl_cffi/__pycache__/const.cpython-314.pyc,,
curl_cffi/__pycache__/curl.cpython-314.pyc,,
curl_cffi/__pycache__/utils.cpython-314.pyc,,
curl_cffi/__version__.py,sha256=FdOv9cpHIUGkFkcWA4O9gte0sun1QgJI69rIgzUkswo,413
curl_cffi/_asyncio_selector.py,sha256=dxKOC6B8mlRu3WYi0trSq0L-FsWV6l_pg9TP35OpDI4,13043
curl_cffi/_wrapper.abi3.so,sha256=4sMTX_Q2WMouO96xfxc3BZjZxnMZnLjZV8nF_FCU_zE,12691712
curl_cffi/aio.py,sha256=oTiffUFDNfxxSHmCVAsjX0CIOs4IuBQ6mhm4WuY5KZs,11925
curl_cffi/cli/__init__.py,sha256=y-XQAfYkBDN3ejfVex1aKy5bcqsPUglAvm-i177wM5I,7290
curl_cffi/cli/__pycache__/__init__.cpython-314.pyc,,
curl_cffi/cli/__pycache__/doctor.cpython-314.pyc,,
curl_cffi/cli/__pycache__/output.cpython-314.pyc,,
curl_cffi/cli/__pycache__/parse.cpython-314.pyc,,
curl_cffi/cli/__pycache__/request.cpython-314.pyc,,
curl_cffi/cli/__pycache__/run.cpython-314.pyc,,
curl_cffi/cli/doctor.py,sha256=4wfyWF44JLxDtoH9YCDdmxrJ-1cWbNwSXcMc9Aopd1c,416
curl_cffi/cli/output.py,sha256=YzWbYLJ2zowR6G9mJxeTytcPrnSk2WQVwfmv8V404X4,6709
curl_cffi/cli/parse.py,sha256=OzqGEZPqOrH2nq14UKnaXwKAblAw6V867tDEp9XSLdo,2549
curl_cffi/cli/request.py,sha256=MJskNMJBIR8oq_fvrPvsi61Wy-bR4-aC8QQJb1YcqRg,3522
curl_cffi/cli/run.py,sha256=WYaPbX9h85DZQNkDbUUyvp3bryaAuNOxnjU6GpenigQ,7554
curl_cffi/const.py,sha256=mF3H1kHsqj-dfd7GWHMHyuB2bZ3QZd61tC1iCZgEjyo,18523
curl_cffi/curl.py,sha256=woBPw1OQk4FH7neNETuj_GwqcQa1jCjd1tA0ZxPSTA8,26928
curl_cffi/py.typed,sha256=dcrsqJrcYfTX-ckLFJMTaj6mD8aDe2u0tkQG-ZYxnEg,26
curl_cffi/requests/__init__.py,sha256=3ZO7mC7gNJZydze0VQLEYpEc9dMlf1uRbFS-fxKSQPs,6148
curl_cffi/requests/__pycache__/__init__.cpython-314.pyc,,
curl_cffi/requests/__pycache__/cookies.cpython-314.pyc,,
curl_cffi/requests/__pycache__/errors.cpython-314.pyc,,
curl_cffi/requests/__pycache__/exceptions.cpython-314.pyc,,
curl_cffi/requests/__pycache__/headers.cpython-314.pyc,,
curl_cffi/requests/__pycache__/impersonate.cpython-314.pyc,,
curl_cffi/requests/__pycache__/models.cpython-314.pyc,,
curl_cffi/requests/__pycache__/session.cpython-314.pyc,,
curl_cffi/requests/__pycache__/utils.cpython-314.pyc,,
curl_cffi/requests/__pycache__/websockets.cpython-314.pyc,,
curl_cffi/requests/cookies.py,sha256=QDEuhtsSjh6iNAKmp5TnGyyAe8wdDJCADCHY_GWCeCc,11867
curl_cffi/requests/errors.py,sha256=R6N5lmOTdRukThkNGUihDAQRu8HSh27M8E3zfUJJX74,250
curl_cffi/requests/exceptions.py,sha256=ViyLx3XHii_s7kjrO3GhVOVXhq2_UsYfAQl8MPwDnEM,6187
curl_cffi/requests/headers.py,sha256=Q1jrRdJ2YMLOcl5W3WBaKZAdLbPGPPERJ6MYzos3YXk,11535
curl_cffi/requests/impersonate.py,sha256=dKRpbPTqLCABrCmSKmgXyhA1zmrv0DMuSuqQyhn5-FU,13389
curl_cffi/requests/models.py,sha256=SaN9QUYyljUDFDQnUrYZRl9KiUe25nb2c2ca-dfq3Nw,11487
curl_cffi/requests/session.py,sha256=LkfPvAr8BIVzB_K91xwh0wIPRW26mi2VJd1cEYorIho,58871
curl_cffi/requests/utils.py,sha256=zMKaWoXcAPFNBv6FglE3FSAPNuxYr_leUcWKnhfYecw,26618
curl_cffi/requests/websockets.py,sha256=RJVNqCDrlLWzQ12Ym9M5_T0SW4EmoqKUYsXXTaQmBRQ,71685
curl_cffi/utils.py,sha256=gRVzO-vhjf596V6kr_SjwHlwJfDIwTrPbRLJvvNlUNE,307
@@ -1 +1 @@
{"archive_info": {"hash": "sha256=bf985a468ffb52bdcdfa1dac713555144c7711521428cd9267578a208c8f45ab", "hashes": {"sha256": "bf985a468ffb52bdcdfa1dac713555144c7711521428cd9267578a208c8f45ab"}}, "url": "file:///Users/runner/work/yt-dlp/yt-dlp/build/universal2/curl_cffi-0.15.0-cp310-abi3-macosx_10_9_universal2.whl"}
{"archive_info": {"hash": "sha256=630653d283070b21deca8ee408dcdc98ac4202d80cc9d038db805d45e0b40550", "hashes": {"sha256": "630653d283070b21deca8ee408dcdc98ac4202d80cc9d038db805d45e0b40550"}}, "url": "file:///Users/runner/work/yt-dlp/yt-dlp/build/universal2/curl_cffi-0.15.0-cp310-abi3-macosx_10_9_universal2.whl"}
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+3 -609
View File
@@ -1,74 +1,18 @@
use crate::DownloadProgressEvent;
use futures_util::StreamExt;
use reqwest::{
header::{self, HeaderMap, HeaderName, HeaderValue},
Client, StatusCode,
};
use std::{
collections::{HashMap, HashSet},
path::PathBuf,
str::FromStr,
time::{Duration, Instant},
};
use std::collections::{HashMap, HashSet};
use tauri::{AppHandle, Emitter};
use tokio::{
fs::{self, OpenOptions},
io::{AsyncWriteExt, BufWriter},
sync::{mpsc, watch},
};
use uuid::Uuid;
const PROGRESS_INTERVAL: Duration = Duration::from_millis(1000);
const WRITE_BUFFER_CAPACITY: usize = 256 * 1024;
use tokio::sync::{mpsc, watch};
#[derive(Debug)]
pub enum DownloadCmd {
Start(Box<DownloadPayload>),
Pause(Uuid),
PauseWithAck(Uuid, tokio::sync::oneshot::Sender<()>),
CancelWithAck(Uuid, tokio::sync::oneshot::Sender<()>),
CaptureUrls(Vec<String>),
FrontendReady(bool),
}
#[derive(Clone, Debug, PartialEq)]
pub enum DownloadEvent {
Progress {
id: Uuid,
fraction: f64,
completed: u64,
total: Option<u64>,
},
Completed(Uuid),
Failed {
id: Uuid,
error: String,
},
/// Transient network drop: a backoff retry is scheduled and the slot is
/// still held. Carries the 0-based strike number and the classified reason.
Retrying {
id: Uuid,
strike: usize,
reason: String,
},
CapturedUrls(String),
}
#[derive(Debug)]
pub struct DownloadPayload {
pub id: Uuid,
pub urls: Vec<String>,
pub output_path: PathBuf,
pub speed_limit: Option<String>,
pub username: Option<String>,
pub password: Option<String>,
pub headers: Option<String>,
pub cookies: Option<String>,
pub user_agent: Option<String>,
pub max_tries: u32,
pub proxy: Option<String>,
}
#[derive(Clone)]
pub struct DownloadCoordinator {
tx: mpsc::Sender<DownloadCmd>,
@@ -141,107 +85,6 @@ enum CoordinatorEventSink {
}
impl CoordinatorEventSink {
fn emit_progress(
&self,
id: Uuid,
completed: u64,
total: Option<u64>,
interval_bytes: u64,
interval: Duration,
) {
let speed_bytes = if interval.is_zero() {
0.0
} else {
interval_bytes as f64 / interval.as_secs_f64()
};
let fraction = total
.filter(|total| *total > 0)
.map(|total| completed as f64 / total as f64)
.unwrap_or(0.0)
.clamp(0.0, 1.0);
match self {
Self::Tauri(app_handle) => {
let eta = total
.filter(|total| speed_bytes > 0.0 && *total > completed)
.map(|total| format_duration((total - completed) as f64 / speed_bytes))
.unwrap_or_else(|| "-".to_string());
let _ = app_handle.emit(
"download-progress",
DownloadProgressEvent {
id: id.to_string(),
fraction,
speed: format_speed(speed_bytes),
eta,
size: total.map(|t| format_size(t as f64)),
size_is_final: false,
},
);
}
Self::Headless(event_tx) => {
let _ = event_tx.send(DownloadEvent::Progress {
id,
fraction,
completed,
total,
});
}
}
}
fn emit_completed(&self, id: Uuid) {
match self {
Self::Tauri(app_handle) => {
let _ = app_handle.emit("download-complete", id.to_string());
}
Self::Headless(event_tx) => {
let _ = event_tx.send(DownloadEvent::Completed(id));
}
}
}
fn emit_failed(&self, id: Uuid, error: String) {
match self {
Self::Tauri(app_handle) => {
log::error!("native download {} failed: {}", id, error);
let _ = app_handle.emit("download-failed", id.to_string());
}
Self::Headless(event_tx) => {
let _ = event_tx.send(DownloadEvent::Failed { id, error });
}
}
}
/// Emit a transient `Retrying` state. In production this drives the
/// `download-state` event with status `retrying` (consumed by the queue's
/// completion listener and the frontend store); in headless tests it flows
/// through the `DownloadEvent` channel. The strike is 0-based and becomes
/// the human-facing attempt number (strike + 1).
fn emit_retrying(&self, id: Uuid, strike: usize, reason: String) {
match self {
Self::Tauri(app_handle) => {
use crate::ipc::{DownloadStateEvent, DownloadStatus};
let attempt = strike + 1;
let payload = DownloadStateEvent::retrying(
id.to_string(),
format!("Network drop — retry #{attempt}: {reason}"),
);
// Drive the same `download-state` channel the queue emits on
// so the frontend status flips to `retrying` uniformly.
let _ = app_handle.emit("download-state", payload);
log::warn!(
"download {id} transient error, backing off before retry #{attempt}: {reason}"
);
// Keep the compiler honest about DownloadStatus being used if a
// future refactor drops the `retrying` constructor path.
let _ = DownloadStatus::Retrying.as_str();
}
Self::Headless(event_tx) => {
let _ = event_tx.send(DownloadEvent::Retrying { id, strike, reason });
}
}
}
fn emit_captured_urls(&self, payload: String) -> bool {
match self {
Self::Tauri(app_handle) => app_handle.emit("deep-link-add-download", payload).is_ok(),
@@ -260,46 +103,15 @@ enum MediaCmd {
Finished(String),
}
#[derive(Debug, Clone, Copy)]
enum DownloadControl {
Pause,
Cancel,
Replace,
}
struct ActiveDownload {
generation: u64,
control_tx: mpsc::Sender<DownloadControl>,
}
enum WorkerEvent {
Finished {
id: Uuid,
generation: u64,
outcome: DownloadOutcome,
},
}
enum DownloadOutcome {
Completed,
Paused,
Cancelled,
Failed(String),
}
async fn run_coordinator(
events: CoordinatorEventSink,
mut command_rx: mpsc::Receiver<DownloadCmd>,
mut media_rx: mpsc::Receiver<MediaCmd>,
) {
let (worker_tx, mut worker_rx) = mpsc::channel(128);
let mut active = HashMap::<Uuid, ActiveDownload>::new();
let mut active_media = HashMap::<String, watch::Sender<bool>>::new();
let mut pending_acks = HashMap::<Uuid, tokio::sync::oneshot::Sender<()>>::new();
let mut pending_media_acks = HashMap::<String, tokio::sync::oneshot::Sender<()>>::new();
let mut pending_captured_urls = Vec::<String>::new();
let mut frontend_ready = false;
let mut next_generation = 0_u64;
loop {
tokio::select! {
@@ -309,48 +121,6 @@ async fn run_coordinator(
};
match command {
DownloadCmd::Start(payload_box) => {
let payload = *payload_box;
if let Some(previous) = active.remove(&payload.id) {
let _ = previous.control_tx.send(DownloadControl::Replace).await;
}
next_generation = next_generation.wrapping_add(1);
let generation = next_generation;
let id = payload.id;
let (control_tx, control_rx) = mpsc::channel(1);
active.insert(id, ActiveDownload { generation, control_tx });
let events = events.clone();
let worker_tx = worker_tx.clone();
tauri::async_runtime::spawn(async move {
let outcome = download_file(events, payload, control_rx).await;
let _ = worker_tx
.send(WorkerEvent::Finished { id, generation, outcome })
.await;
});
}
DownloadCmd::Pause(id) => {
if let Some(download) = active.remove(&id) {
let _ = download.control_tx.send(DownloadControl::Pause).await;
}
}
DownloadCmd::PauseWithAck(id, ack) => {
if let Some(download) = active.remove(&id) {
let _ = download.control_tx.send(DownloadControl::Pause).await;
pending_acks.insert(id, ack);
} else {
let _ = ack.send(());
}
}
DownloadCmd::CancelWithAck(id, ack) => {
if let Some(download) = active.remove(&id) {
let _ = download.control_tx.send(DownloadControl::Cancel).await;
pending_acks.insert(id, ack);
} else {
let _ = ack.send(());
}
}
DownloadCmd::CaptureUrls(urls) => {
append_unique_urls(&mut pending_captured_urls, urls);
if frontend_ready && !pending_captured_urls.is_empty() {
@@ -371,32 +141,6 @@ async fn run_coordinator(
}
}
}
event = worker_rx.recv() => {
let Some(WorkerEvent::Finished { id, generation, outcome }) = event else {
continue;
};
let is_current = active
.get(&id)
.is_some_and(|download| download.generation == generation);
if is_current {
active.remove(&id);
}
if let Some(ack) = pending_acks.remove(&id) {
let _ = ack.send(());
}
match (is_current, outcome) {
(true, DownloadOutcome::Completed) => {
events.emit_completed(id);
}
(true, DownloadOutcome::Failed(error)) => {
events.emit_failed(id, error);
}
_ => {}
}
}
command = media_rx.recv() => {
let Some(command) = command else {
continue;
@@ -431,9 +175,6 @@ async fn run_coordinator(
}
}
for (_, download) in active {
let _ = download.control_tx.send(DownloadControl::Cancel).await;
}
for (_, cancel_tx) in active_media {
let _ = cancel_tx.send(true);
}
@@ -444,315 +185,6 @@ fn append_unique_urls(target: &mut Vec<String>, urls: Vec<String>) {
target.extend(urls.into_iter().filter(|url| seen.insert(url.clone())));
}
async fn download_file(
events: CoordinatorEventSink,
payload: DownloadPayload,
mut control_rx: mpsc::Receiver<DownloadControl>,
) -> DownloadOutcome {
if let Some(parent) = payload.output_path.parent() {
if let Err(error) = fs::create_dir_all(parent).await {
return DownloadOutcome::Failed(error.to_string());
}
}
let (client, default_headers) = match build_client(&payload) {
Ok(client) => client,
Err(error) => return DownloadOutcome::Failed(error),
};
let mut last_error = "no download URL was provided".to_string();
// Connection-aware retry policy. A transient network drop never transitions
// the download straight to `Failed`: it is classified, the UI is told the
// item is `Retrying`, and a 3-strike exponential backoff (2s/5s/10s from
// `retry::BACKOFF_SCHEDULE`) runs before the next attempt — all while the
// worker slot stays held (the coordinator does not drop the active entry
// until this future resolves). `download_attempt` re-issues a Range header
// from the existing partial file on every retry, so no bytes are discarded.
//
// `max_tries` is the user-facing retry count. Attempts include the first
// try plus those configured retries.
let max_retries = payload.max_tries as usize;
let max_attempts = max_retries + 1;
'url: for url in &payload.urls {
let mut strike = 0_usize;
let mut attempts = 0_usize;
loop {
attempts += 1;
match download_attempt(
&events,
&client,
&default_headers,
&payload,
url,
&mut control_rx,
)
.await
{
Ok(()) => return DownloadOutcome::Completed,
Err(AttemptError::Controlled(DownloadControl::Pause)) => {
return DownloadOutcome::Paused;
}
Err(AttemptError::Controlled(DownloadControl::Cancel)) => {
if let Err(e) = fs::remove_file(&payload.output_path).await {
log::warn!(
"Failed to remove cancelled file '{}': {}",
payload.output_path.display(),
e
);
}
return DownloadOutcome::Cancelled;
}
Err(AttemptError::Controlled(DownloadControl::Replace)) => {
return DownloadOutcome::Cancelled;
}
Err(AttemptError::Failed(error)) => {
last_error = error.clone();
if attempts >= max_attempts {
continue 'url;
}
let transient = crate::retry::is_transient_network_error(&error);
let strikes_left = strike < max_retries;
if transient && strikes_left {
// Transient: announce `Retrying`, back off, then retry.
// The backoff sleep is itself cancelable so a user
// pause/cancel during the wait is honored immediately.
events.emit_retrying(payload.id, strike, error);
let delay = crate::retry::backoff_for(strike);
tokio::select! {
_ = tokio::time::sleep(delay) => {}
control = control_rx.recv() => {
return match control.unwrap_or(DownloadControl::Cancel) {
DownloadControl::Pause => DownloadOutcome::Paused,
DownloadControl::Cancel => {
if let Err(e) = fs::remove_file(&payload.output_path).await {
log::warn!("Failed to remove cancelled file '{}': {}", payload.output_path.display(), e);
}
DownloadOutcome::Cancelled
}
DownloadControl::Replace => DownloadOutcome::Cancelled,
};
}
}
strike += 1;
continue;
}
if !transient && !crate::retry::is_permanent_network_error(&error) {
// Legacy `max_tries` cap for ambiguous HTTP statuses (e.g.
// 500) that are neither clearly transient nor permanent.
tokio::time::sleep(Duration::from_millis(500)).await;
continue;
}
// Permanent error or transient strike budget exhausted.
continue 'url;
}
}
}
}
DownloadOutcome::Failed(last_error)
}
enum AttemptError {
Controlled(DownloadControl),
Failed(String),
}
async fn download_attempt(
events: &CoordinatorEventSink,
client: &Client,
default_headers: &reqwest::header::HeaderMap,
payload: &DownloadPayload,
url: &str,
control_rx: &mut mpsc::Receiver<DownloadControl>,
) -> Result<(), AttemptError> {
let existing_len = fs::metadata(&payload.output_path)
.await
.map(|metadata| metadata.len())
.unwrap_or(0);
let mut request = client.get(url).headers(default_headers.clone());
if existing_len > 0 {
request = request.header(header::RANGE, format!("bytes={existing_len}-"));
}
if let Some(username) = payload
.username
.as_deref()
.filter(|value| !value.is_empty())
{
request = request.basic_auth(username, payload.password.as_deref());
}
let response = tokio::select! {
control = control_rx.recv() => {
return Err(AttemptError::Controlled(control.unwrap_or(DownloadControl::Cancel)));
}
response = request.send() => {
response.map_err(|error| AttemptError::Failed(error.to_string()))?
}
};
if !(response.status().is_success() || response.status() == StatusCode::PARTIAL_CONTENT) {
return Err(AttemptError::Failed(format!(
"{url} returned HTTP {}",
response.status()
)));
}
let resumed = existing_len > 0 && response.status() == StatusCode::PARTIAL_CONTENT;
if resumed {
let content_range = response
.headers()
.get(reqwest::header::CONTENT_RANGE)
.and_then(|h| h.to_str().ok());
if !content_range.is_some_and(|r| r.starts_with(&format!("bytes {}-", existing_len))) {
return Err(AttemptError::Failed(
"Server returned invalid Content-Range for resume".to_string(),
));
}
}
let completed_at_start = if resumed { existing_len } else { 0 };
let total_len = response
.content_length()
.map(|remaining| remaining.saturating_add(completed_at_start));
let file = OpenOptions::new()
.create(true)
.write(true)
.append(resumed)
.truncate(!resumed)
.open(&payload.output_path)
.await
.map_err(|error| AttemptError::Failed(error.to_string()))?;
let mut writer = BufWriter::with_capacity(WRITE_BUFFER_CAPACITY, file);
let mut stream = response.bytes_stream();
let mut last_emitted_at = Instant::now();
let mut last_emitted_bytes = completed_at_start;
let mut completed = completed_at_start;
let speed_limit = payload.speed_limit.as_deref().and_then(parse_speed_limit);
let transfer_started_at = Instant::now();
let mut transferred_this_attempt = 0_u64;
loop {
tokio::select! {
control = control_rx.recv() => {
writer.flush().await.map_err(|error| AttemptError::Failed(error.to_string()))?;
return Err(AttemptError::Controlled(control.unwrap_or(DownloadControl::Cancel)));
}
chunk = stream.next() => {
match chunk {
Some(Ok(bytes)) => {
writer
.write_all(&bytes)
.await
.map_err(|error| AttemptError::Failed(error.to_string()))?;
completed = completed.saturating_add(bytes.len() as u64);
transferred_this_attempt =
transferred_this_attempt.saturating_add(bytes.len() as u64);
if let Some(bytes_per_second) = speed_limit {
let expected_elapsed =
Duration::from_secs_f64(transferred_this_attempt as f64 / bytes_per_second as f64);
let actual_elapsed = transfer_started_at.elapsed();
if expected_elapsed > actual_elapsed {
tokio::select! {
control = control_rx.recv() => {
writer.flush().await.map_err(|error| AttemptError::Failed(error.to_string()))?;
return Err(AttemptError::Controlled(control.unwrap_or(DownloadControl::Cancel)));
}
_ = tokio::time::sleep(expected_elapsed - actual_elapsed) => {}
}
}
}
let now = Instant::now();
let interval = now.duration_since(last_emitted_at);
if interval >= PROGRESS_INTERVAL {
events.emit_progress(
payload.id,
completed,
total_len,
completed.saturating_sub(last_emitted_bytes),
interval,
);
last_emitted_at = now;
last_emitted_bytes = completed;
}
}
Some(Err(error)) => {
writer.flush().await.map_err(|flush_error| AttemptError::Failed(flush_error.to_string()))?;
return Err(AttemptError::Failed(error.to_string()));
}
None => break,
}
}
}
}
writer
.flush()
.await
.map_err(|error| AttemptError::Failed(error.to_string()))?;
events.emit_progress(
payload.id,
completed,
total_len,
completed.saturating_sub(last_emitted_bytes),
last_emitted_at.elapsed(),
);
Ok(())
}
fn build_client(payload: &DownloadPayload) -> Result<(Client, HeaderMap), String> {
let mut headers = HeaderMap::new();
if let Some(raw_headers) = payload.headers.as_deref() {
for line in raw_headers
.lines()
.map(str::trim)
.filter(|line| !line.is_empty())
{
let (name, value) = line
.split_once(':')
.ok_or_else(|| format!("invalid HTTP header: {line}"))?;
headers.insert(
HeaderName::from_str(name.trim()).map_err(|error| error.to_string())?,
HeaderValue::from_str(value.trim()).map_err(|error| error.to_string())?,
);
}
}
if let Some(cookies) = payload.cookies.as_deref().filter(|value| !value.is_empty()) {
headers.insert(
header::COOKIE,
HeaderValue::from_str(cookies).map_err(|error| error.to_string())?,
);
}
let mut builder = Client::builder();
if let Some(user_agent) = payload
.user_agent
.as_deref()
.filter(|value| !value.is_empty())
{
builder = builder.user_agent(user_agent);
}
if let Some(proxy) = payload.proxy.as_deref().map(str::trim).filter(|value| !value.is_empty()) {
if proxy.eq_ignore_ascii_case("none") {
builder = builder.no_proxy();
} else {
builder = builder.proxy(
reqwest::Proxy::all(proxy)
.map_err(|_| "Invalid proxy URL configured".to_string())?,
);
}
}
builder
.build()
.map_err(|error| error.to_string())
.map(|c| (c, headers))
}
pub(crate) fn format_speed(bytes_per_second: f64) -> String {
if bytes_per_second >= 1024.0 * 1024.0 {
format!("{:.1} MB/s", bytes_per_second / (1024.0 * 1024.0))
@@ -785,49 +217,11 @@ pub(crate) fn format_duration(seconds: f64) -> String {
}
}
fn parse_speed_limit(value: &str) -> Option<u64> {
let normalized = value.trim().to_ascii_lowercase();
if normalized.is_empty() || normalized == "0" {
return None;
}
let (number, multiplier) = if let Some(number) = normalized.strip_suffix("kb/s") {
(number, 1024.0)
} else if let Some(number) = normalized.strip_suffix("mb/s") {
(number, 1024.0 * 1024.0)
} else if let Some(number) = normalized.strip_suffix("gb/s") {
(number, 1024.0 * 1024.0 * 1024.0)
} else if let Some(number) = normalized.strip_suffix('k') {
(number, 1024.0)
} else if let Some(number) = normalized.strip_suffix('m') {
(number, 1024.0 * 1024.0)
} else if let Some(number) = normalized.strip_suffix('g') {
(number, 1024.0 * 1024.0 * 1024.0)
} else {
(normalized.as_str(), 1.0)
};
number
.trim()
.parse::<f64>()
.ok()
.filter(|number| *number > 0.0)
.map(|number| (number * multiplier) as u64)
}
#[cfg(test)]
mod tests {
use super::{parse_speed_limit, DownloadCmd, DownloadCoordinator, DownloadEvent};
use super::{DownloadCmd, DownloadCoordinator, DownloadEvent};
use std::time::Duration;
#[test]
fn parses_aria_style_speed_limits() {
assert_eq!(parse_speed_limit("512K"), Some(512 * 1024));
assert_eq!(parse_speed_limit("1.5M"), Some(1_572_864));
assert_eq!(parse_speed_limit("2 MB/s"), Some(2 * 1024 * 1024));
assert_eq!(parse_speed_limit("0"), None);
}
#[tokio::test]
async fn buffers_captured_urls_until_frontend_is_ready() {
let (coordinator, mut events) = DownloadCoordinator::spawn_headless();
+10 -4
View File
@@ -168,6 +168,9 @@ fn legacy_download_queue_paths(app_handle: &tauri::AppHandle) -> Result<Vec<Path
}
let category_destination = settings.as_ref().map(|settings| {
if !settings.category_subfolders_enabled {
return settings.base_download_folder.clone();
}
settings
.category_directory_overrides
.get(&category)
@@ -178,10 +181,13 @@ fn legacy_download_queue_paths(app_handle: &tauri::AppHandle) -> Result<Vec<Path
.get(&category)
.cloned()
.unwrap_or_else(|| category.clone());
std::path::PathBuf::from(&settings.base_download_folder)
.join(subfolder)
.to_string_lossy()
.to_string()
let base = std::path::PathBuf::from(&settings.base_download_folder);
let destination = if subfolder.is_empty() {
base
} else {
base.join(subfolder)
};
destination.to_string_lossy().to_string()
})
});
let default_destination = settings
+10 -11
View File
@@ -7,7 +7,7 @@ use axum::{
routing::{get, post},
Router,
};
use hmac::{Hmac, Mac};
use hmac::{Hmac, KeyInit, Mac};
use reqwest::Url;
use serde::{Deserialize, Serialize};
use sha2::Sha256;
@@ -31,7 +31,7 @@ const CLIENT_NONCE_HEADER: &str = "x-firelink-client-nonce";
const SERVER_PROOF_HEADER: &str = "x-firelink-server-proof";
const SERVER_PORT_HEADER: &str = "x-firelink-server-port";
const SERVER_PROOF_PREFIX: &[u8] = b"firelink-server-proof\n";
const PROTOCOL_VERSION: &str = "3";
const PROTOCOL_VERSION: &str = "4";
type HmacSha256 = Hmac<Sha256>;
pub type SharedExtensionToken = Arc<RwLock<String>>;
@@ -61,6 +61,8 @@ struct ExtensionRequest {
headers: Option<String>,
#[serde(default)]
cookies: Option<String>,
#[serde(default)]
media: bool,
}
#[derive(Clone, Serialize, TS)]
@@ -72,6 +74,7 @@ pub struct ExtensionDownload {
filename: Option<String>,
headers: Option<String>,
cookies: Option<String>,
media: bool,
}
pub async fn start_server(
@@ -192,13 +195,8 @@ async fn ping_handler(
return Err(StatusCode::FORBIDDEN);
}
let proof = sign_server_proof(
timestamp_str,
nonce,
state.bound_port,
&state.pairing_token,
)
.map_err(|_| StatusCode::FORBIDDEN)?;
let proof = sign_server_proof(timestamp_str, nonce, state.bound_port, &state.pairing_token)
.map_err(|_| StatusCode::FORBIDDEN)?;
let mut response = Response::new(Body::empty());
response.headers_mut().insert(
@@ -318,6 +316,7 @@ fn normalize_download(payload: ExtensionRequest) -> Option<ExtensionDownload> {
filename,
headers: payload.headers.filter(|value| !value.trim().is_empty()),
cookies: payload.cookies.filter(|value| !value.trim().is_empty()),
media: payload.media,
})
}
@@ -454,7 +453,7 @@ mod tests {
SERVER_HEADER,
};
use axum::{http::StatusCode, middleware, routing::get, Router};
use hmac::{Hmac, Mac};
use hmac::{Hmac, KeyInit, Mac};
use sha2::Sha256;
use std::sync::{Arc, RwLock};
@@ -478,7 +477,7 @@ mod tests {
assert_eq!(response.headers().get(SERVER_HEADER).unwrap(), "1");
assert_eq!(
response.headers().get(PROTOCOL_VERSION_HEADER).unwrap(),
"3"
"4"
);
server.abort();
+3
View File
@@ -108,6 +108,8 @@ pub struct DownloadItem {
pub queue_position: Option<i32>,
#[ts(optional)]
pub has_been_dispatched: Option<bool>,
#[ts(optional)]
pub last_error: Option<String>,
}
#[derive(Clone, Debug, Serialize, Deserialize, TS)]
@@ -244,6 +246,7 @@ pub struct SchedulerSettings {
pub struct PersistedSettings {
pub theme: Theme,
pub base_download_folder: String,
pub category_subfolders_enabled: bool,
pub category_subfolders: HashMap<String, String>,
pub category_directory_overrides: HashMap<String, String>,
pub approved_download_roots: Vec<String>,
+289 -147
View File
@@ -11,12 +11,6 @@ use std::time::{Duration, Instant};
use tauri::{Emitter, Manager};
use tauri_plugin_deep_link::DeepLinkExt;
use ts_rs::TS;
use uuid::Uuid;
fn get_metadata_cache() -> &'static std::sync::Mutex<HashMap<String, String>> {
static CACHE: OnceLock<std::sync::Mutex<HashMap<String, String>>> = OnceLock::new();
CACHE.get_or_init(|| std::sync::Mutex::new(HashMap::new()))
}
fn sanitize_metadata_filename(filename: &str) -> Option<String> {
let normalized = filename.trim().replace('\\', "/");
@@ -24,7 +18,7 @@ fn sanitize_metadata_filename(filename: &str) -> Option<String> {
.file_name()?
.to_str()?
.trim()
.trim_end_matches(|c| c == '.' || c == ' ');
.trim_end_matches(['.', ' ']);
if basename.is_empty() || basename == "." || basename == ".." || basename.len() > 255 {
return None;
@@ -905,6 +899,25 @@ async fn cleanup_media_processing_artifacts(out_path: &std::path::Path) {
cleanup_media_artifacts(out_path, true).await;
}
async fn remove_file_best_effort_with_retry(path: &std::path::Path) {
for attempt in 0..=5 {
match tokio::fs::remove_file(path).await {
Ok(()) => return,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return,
Err(error) if attempt == 5 => {
log::warn!(
"failed to remove media artifact '{}' after retries: {}",
path.display(),
error
);
}
Err(_) => {
tokio::time::sleep(std::time::Duration::from_millis(200)).await;
}
}
}
}
async fn cleanup_media_artifacts(out_path: &std::path::Path, remove_primary: bool) {
let Some(parent) = out_path.parent() else {
return;
@@ -918,7 +931,7 @@ async fn cleanup_media_artifacts(out_path: &std::path::Path, remove_primary: boo
.unwrap_or(base_name);
if remove_primary {
let _ = tokio::fs::remove_file(out_path).await;
remove_file_best_effort_with_retry(out_path).await;
}
let Ok(mut entries) = tokio::fs::read_dir(parent).await else {
@@ -946,7 +959,7 @@ async fn cleanup_media_artifacts(out_path: &std::path::Path, remove_primary: boo
|| name.contains(".tmp")
|| yt_dlp_format_fragment;
if looks_like_media_temp {
let _ = tokio::fs::remove_file(path).await;
remove_file_best_effort_with_retry(&path).await;
}
}
}
@@ -1004,6 +1017,14 @@ fn append_ytdlp_http_headers(
Ok(())
}
fn is_browser_cookie_extraction_error(message: &str) -> bool {
let lower = message.to_ascii_lowercase();
lower.contains("could not copy") && lower.contains("cookie database")
|| lower.contains("could not access browser cookie database")
|| lower.contains("failed to read browser cookie")
|| lower.contains("failed to decrypt with dpapi")
}
fn should_cleanup_media_artifacts_after_failure(
failure_reason: &str,
strike: usize,
@@ -1058,6 +1079,7 @@ async fn fetch_metadata(
password: Option<String>,
headers: Option<String>,
cookies: Option<String>,
proxy: Option<String>,
) -> Result<MetadataResponse, String> {
let mut current_url = url.clone();
let original_host = reqwest::Url::parse(&url).ok().and_then(|u| u.host_str().map(|s| s.to_string()));
@@ -1069,7 +1091,16 @@ async fn fetch_metadata(
return Err("Too many redirects".to_string());
}
let mut builder = reqwest::Client::builder().redirect(reqwest::redirect::Policy::none());
let mut builder = reqwest::Client::builder()
.redirect(reqwest::redirect::Policy::none())
.timeout(FILE_METADATA_TIMEOUT);
if let Some(proxy) = proxy.as_deref().map(str::trim).filter(|s| !s.is_empty()) {
if proxy.eq_ignore_ascii_case("none") {
builder = builder.no_proxy();
} else {
builder = builder.proxy(reqwest::Proxy::all(proxy).map_err(|e| e.to_string())?);
}
}
if let Some(ref ua) = user_agent {
let ua = ua.trim();
@@ -1123,6 +1154,20 @@ async fn fetch_metadata(
let client = builder.build().map_err(|e| e.to_string())?;
let build_get_range = || {
let mut get_req = client
.get(&current_url)
.header(reqwest::header::RANGE, "bytes=0-0");
if should_send_auth {
if let Some(ref user) = username {
if !user.is_empty() {
get_req = get_req.basic_auth(user, password.as_deref());
}
}
}
get_req
};
let mut head_req = client.head(&current_url);
if should_send_auth {
if let Some(ref user) = username {
@@ -1131,25 +1176,24 @@ async fn fetch_metadata(
}
}
}
let mut current_res = head_req.send().await.map_err(|e| e.to_string())?;
let mut current_res = match head_req.send().await {
Ok(response) => response,
Err(head_error) => build_get_range().send().await.map_err(|get_error| {
format!(
"HEAD metadata request failed ({head_error}); ranged GET fallback failed ({get_error})"
)
})?,
};
let mut needs_fallback = false;
if !current_res.status().is_success() && !current_res.status().is_redirection() {
needs_fallback = true;
} else if current_res.status().is_success() && current_res.headers().get(reqwest::header::CONTENT_LENGTH).is_none() {
if (!current_res.status().is_success() && !current_res.status().is_redirection())
|| (current_res.status().is_success() && current_res.headers().get(reqwest::header::CONTENT_LENGTH).is_none())
{
needs_fallback = true;
}
if needs_fallback {
let mut get_req = client.get(&current_url).header(reqwest::header::RANGE, "bytes=0-0");
if should_send_auth {
if let Some(ref user) = username {
if !user.is_empty() {
get_req = get_req.basic_auth(user, password.as_deref());
}
}
}
current_res = get_req.send().await.map_err(|e| e.to_string())?;
current_res = build_get_range().send().await.map_err(|e| e.to_string())?;
}
if current_res.status().is_redirection() {
@@ -1233,6 +1277,7 @@ async fn fetch_metadata(
const MEDIA_METADATA_CACHE_TTL: Duration = Duration::from_secs(60);
const MEDIA_METADATA_TIMEOUT: Duration = Duration::from_secs(55);
const MEDIA_METADATA_CACHE_MAX_ENTRIES: usize = 128;
const FILE_METADATA_TIMEOUT: Duration = Duration::from_secs(20);
static MEDIA_METADATA_CACHE: OnceLock<tokio::sync::Mutex<HashMap<u64, (Instant, MediaMetadata)>>> =
OnceLock::new();
@@ -1243,14 +1288,22 @@ static MEDIA_METADATA_LOCKS: OnceLock<
fn media_metadata_cache_key(
url: &str,
cookie_browser: &Option<String>,
user_agent: &Option<String>,
username: &Option<String>,
password: &Option<String>,
headers: &Option<String>,
cookies: &Option<String>,
proxy: &Option<String>,
) -> u64 {
let mut hasher = std::collections::hash_map::DefaultHasher::new();
url.hash(&mut hasher);
cookie_browser.hash(&mut hasher);
user_agent.hash(&mut hasher);
username.hash(&mut hasher);
password.hash(&mut hasher);
headers.hash(&mut hasher);
cookies.hash(&mut hasher);
proxy.hash(&mut hasher);
hasher.finish()
}
@@ -1282,11 +1335,24 @@ async fn fetch_media_metadata(
app_handle: tauri::AppHandle,
url: String,
cookie_browser: Option<String>,
user_agent: Option<String>,
username: Option<String>,
password: Option<String>,
headers: Option<String>,
cookies: Option<String>,
proxy: Option<String>,
) -> Result<MediaMetadata, String> {
validate_url_ssrf(&url).await?;
let cache_key = media_metadata_cache_key(&url, &cookie_browser, &username, &password);
let cache_key = media_metadata_cache_key(
&url,
&cookie_browser,
&user_agent,
&username,
&password,
&headers,
&cookies,
&proxy,
);
let cache = MEDIA_METADATA_CACHE.get_or_init(|| tokio::sync::Mutex::new(HashMap::new()));
let mut cache_guard = cache.lock().await;
@@ -1317,8 +1383,34 @@ async fn fetch_media_metadata(
}
drop(cache_guard);
let result =
fetch_media_metadata_uncached(app_handle, url, cookie_browser, username, password).await;
let result = fetch_media_metadata_uncached(
app_handle.clone(),
url.clone(),
cookie_browser.clone(),
user_agent.clone(),
username.clone(),
password.clone(),
headers.clone(),
cookies.clone(),
proxy.clone(),
)
.await;
let result = match (result, cookie_browser.as_deref()) {
(Err(error), Some(browser))
if !browser.trim().is_empty() && is_browser_cookie_extraction_error(&error) =>
{
log::warn!(
"yt-dlp could not read browser cookies from {}; retrying media metadata without browser cookies",
browser
);
fetch_media_metadata_uncached(
app_handle, url, None, user_agent, username, password, headers, cookies, proxy,
)
.await
}
(result, _) => result,
};
let result = match result {
Ok(metadata) if metadata.formats.is_empty() => {
@@ -1353,8 +1445,12 @@ async fn fetch_media_metadata_uncached(
app_handle: tauri::AppHandle,
url: String,
cookie_browser: Option<String>,
user_agent: Option<String>,
username: Option<String>,
password: Option<String>,
headers: Option<String>,
cookies: Option<String>,
proxy: Option<String>,
) -> Result<MediaMetadata, String> {
// Pass bundled tools by absolute path so extraction never depends on
// system Python, a user-managed PATH, or auto-detection heuristics.
@@ -1390,28 +1486,41 @@ async fn fetch_media_metadata_uncached(
.arg("--print")
.arg("%(.{title,duration,thumbnail,formats})j");
if let Some(browser) = cookie_browser {
if let Some(browser) = cookie_browser.as_deref() {
if !browser.is_empty() {
cmd = cmd.arg("--cookies-from-browser").arg(&browser);
cmd = cmd.arg("--cookies-from-browser").arg(browser);
}
}
if let Some(proxy) = proxy.as_deref().map(str::trim).filter(|s| !s.is_empty()) {
if proxy.eq_ignore_ascii_case("none") {
cmd = cmd.arg("--proxy").arg("");
} else {
cmd = cmd.arg("--proxy").arg(proxy);
}
}
if let Some(ua) = user_agent.as_deref().map(str::trim).filter(|s| !s.is_empty()) {
cmd = cmd.arg("--user-agent").arg(ua);
}
let mut config_file = tempfile::Builder::new()
.prefix("ytdlp-")
.suffix(".conf")
.tempfile()
.map_err(|e| e.to_string())?;
let mut config_content = String::new();
if let Some(user) = username {
if let Some(user) = username.as_deref() {
if !user.is_empty() {
append_ytdlp_config_option(&mut config_content, "--username", &user);
append_ytdlp_config_option(&mut config_content, "--username", user);
}
}
if let Some(pass) = password {
if let Some(pass) = password.as_deref() {
if !pass.is_empty() {
append_ytdlp_config_option(&mut config_content, "--password", &pass);
append_ytdlp_config_option(&mut config_content, "--password", pass);
}
}
append_ytdlp_http_headers(&mut config_content, headers.as_deref(), cookies.as_deref())?;
use std::io::Write;
config_file
.write_all(config_content.as_bytes())
@@ -1436,12 +1545,6 @@ async fn fetch_media_metadata_uncached(
let value: serde_json::Value = serde_json::from_slice(&output.stdout)
.map_err(|e| format!("Failed to parse JSON: {}", e))?;
if let Ok(mut cache) = get_metadata_cache().lock() {
if let Ok(json_str) = String::from_utf8(output.stdout.clone()) {
cache.insert(url.to_string(), json_str);
}
}
let title = value
.get("title")
.and_then(|v| v.as_str())
@@ -2584,14 +2687,14 @@ pub(crate) async fn start_media_download_internal(
.tempfile()
.map_err(|e| e.to_string())?;
let mut config_content = String::new();
if let Some(user) = username {
if let Some(user) = username.as_deref() {
if !user.is_empty() {
append_ytdlp_config_option(&mut config_content, "--username", &user);
append_ytdlp_config_option(&mut config_content, "--username", user);
}
}
if let Some(pass) = password {
if let Some(pass) = password.as_deref() {
if !pass.is_empty() {
append_ytdlp_config_option(&mut config_content, "--password", &pass);
append_ytdlp_config_option(&mut config_content, "--password", pass);
}
}
append_ytdlp_http_headers(&mut config_content, headers.as_deref(), cookies.as_deref())?;
@@ -2635,6 +2738,8 @@ pub(crate) async fn start_media_download_internal(
let max_retries = max_tries.unwrap_or(0).max(0) as usize;
let mut strike = 0_usize;
let mut processing_started = false;
let mut effective_cookie_source = cookie_source.clone();
let mut browser_cookie_fallback_used = false;
while strike <= max_retries {
let ytdlp_path = resolve_bundled_binary_path(&app_handle, "yt-dlp")?;
@@ -2668,10 +2773,11 @@ pub(crate) async fn start_media_download_internal(
.arg(output_template.to_string_lossy().to_string())
.env("PATH", &trusted_path);
if let Some(limit) = speed_limit.as_ref() {
if !limit.is_empty() {
cmd = cmd.arg("--limit-rate").arg(limit);
}
if let Some(limit) = speed_limit
.as_deref()
.and_then(normalize_speed_limit_for_aria2)
{
cmd = cmd.arg("--limit-rate").arg(limit);
}
if let Some(p) = proxy.as_deref().map(str::trim).filter(|s| !s.is_empty()) {
@@ -2682,7 +2788,7 @@ pub(crate) async fn start_media_download_internal(
}
}
if let Some(cs) = cookie_source.as_ref() {
if let Some(cs) = effective_cookie_source.as_ref() {
let mut cs = cs.clone();
if !cs.is_empty() && cs != "none" {
if cs == "safari" {
@@ -2719,32 +2825,7 @@ pub(crate) async fn start_media_download_internal(
}
}
let mut temp_info_path = None;
if let Ok(mut cache) = get_metadata_cache().lock() {
if let Some(json_str) = cache.remove(&url) {
let temp_dir = std::env::temp_dir();
let path = temp_dir.join(format!("firelink_ytdlp_{}.info.json", id));
if std::fs::write(&path, json_str).is_ok() {
temp_info_path = Some(path);
}
}
}
struct CleanupPath(Option<std::path::PathBuf>);
impl Drop for CleanupPath {
fn drop(&mut self) {
if let Some(path) = self.0.take() {
let _ = std::fs::remove_file(path);
}
}
}
let _cleanup = CleanupPath(temp_info_path.clone());
if let Some(path) = temp_info_path.as_ref() {
cmd = cmd.arg("--load-info-json").arg(path);
} else {
cmd = cmd.arg("--").arg(&url);
}
cmd = cmd.arg("--").arg(&url);
let (mut rx, child) = cmd
.spawn()
@@ -2907,6 +2988,21 @@ pub(crate) async fn start_media_download_internal(
if should_cleanup_media_artifacts_after_failure(&failure_reason, strike, max_retries) {
cleanup_media_artifacts(&out_path, false).await;
}
if !browser_cookie_fallback_used
&& effective_cookie_source
.as_deref()
.is_some_and(|source| !source.trim().is_empty() && source != "none")
&& is_browser_cookie_extraction_error(&failure_reason)
{
let source = effective_cookie_source.clone().unwrap_or_default();
log::warn!(
"yt-dlp could not read browser cookies from {}; retrying media download without browser cookies",
source
);
effective_cookie_source = None;
browser_cookie_fallback_used = true;
continue;
}
if !(transient && strikes_left) {
return Err(failure_reason);
}
@@ -2942,7 +3038,7 @@ async fn pause_download(
let removed_pending = state.queue_manager.remove_from_pending(&id).await;
let gid = state.queue_manager.aria2_gid_for_download(&id);
if let Some(gid) = gid.as_deref().filter(|gid| !gid.starts_with("native:")) {
if let Some(gid) = gid.as_deref() {
let status = aria2_download_status(
state.aria2_port.load(std::sync::atomic::Ordering::Relaxed),
&state.aria2_secret,
@@ -3013,11 +3109,6 @@ async fn pause_download(
.download_coordinator
.pause_media_with_ack(id.clone(), tx)
.await?;
} else if let Ok(download_id) = Uuid::parse_str(&id) {
state
.download_coordinator
.send(download::DownloadCmd::PauseWithAck(download_id, tx))
.await?;
} else {
let _ = tx.send(());
}
@@ -3052,13 +3143,6 @@ async fn resume_download(
state.queue_manager.release_registered_id(&id).await;
return Ok(false);
};
if gid.starts_with("native:") {
state.queue_manager.forget_aria2_gid(&id).await;
log::info!("aria2 resume [{}]: native fallback has no aria2 gid", id);
state.queue_manager.release_registered_id(&id).await;
return Ok(false);
}
let status = aria2_download_status(
state.aria2_port.load(std::sync::atomic::Ordering::Relaxed),
&state.aria2_secret,
@@ -3098,6 +3182,13 @@ async fn resume_download(
queue_manager.release_permit(&id_clone).await;
return;
}
let _ = app_handle_clone.emit(
"download-state",
crate::ipc::DownloadStateEvent::new(
&id_clone,
crate::ipc::DownloadStatus::Downloading,
),
);
let result = match rpc_call(
aria2_port,
&aria2_secret,
@@ -3150,13 +3241,6 @@ async fn resume_download(
return;
}
log::info!("aria2 resume [{}]: unpaused gid {}", id_clone, gid_clone);
let _ = app_handle_clone.emit(
"download-state",
crate::ipc::DownloadStateEvent::new(
&id_clone,
crate::ipc::DownloadStatus::Downloading,
),
);
});
return Ok(true);
}
@@ -3212,7 +3296,7 @@ async fn remove_download(
state.queue_manager.cancel_aria2_retries(&id).await;
let gid = state.queue_manager.aria2_gid_for_download(&id);
if let Some(gid) = gid.as_deref().filter(|gid| !gid.starts_with("native:")) {
if let Some(gid) = gid.as_deref() {
let removal_result = async {
force_remove_aria2_gid(
state.aria2_port.load(std::sync::atomic::Ordering::Relaxed),
@@ -3243,13 +3327,6 @@ async fn remove_download(
.download_coordinator
.pause_media_with_ack(id.clone(), tx)
.await?;
} else if let Ok(download_id) = Uuid::parse_str(&id) {
let command = if delete_assets {
download::DownloadCmd::CancelWithAck(download_id, tx)
} else {
download::DownloadCmd::PauseWithAck(download_id, tx)
};
state.download_coordinator.send(command).await?;
} else {
let _ = tx.send(());
}
@@ -3268,7 +3345,7 @@ async fn remove_download(
crate::ipc::DownloadStateEvent::new(id.clone(), crate::ipc::DownloadStatus::Paused),
);
let cleanup_result = (|| async {
let cleanup_result = async {
if delete_assets {
if let Some(path) = primary_path.as_deref() {
remove_download_assets(path, &app_handle).await?;
@@ -3276,7 +3353,7 @@ async fn remove_download(
}
crate::download_ownership::remove(&app_handle, &id)?;
Ok::<(), String>(())
})()
}
.await;
state.queue_manager.release_registered_id(&id).await;
@@ -3356,7 +3433,7 @@ async fn detach_download_for_reconfigure(
let gid = state.queue_manager.aria2_gid_for_download(&id);
if let Some(gid) = gid.as_deref().filter(|gid| !gid.starts_with("native:")) {
if let Some(gid) = gid.as_deref() {
let removal_result = async {
let pause_res = rpc_call(
state.aria2_port.load(std::sync::atomic::Ordering::Relaxed),
@@ -3396,11 +3473,6 @@ async fn detach_download_for_reconfigure(
.download_coordinator
.pause_media_with_ack(id.clone(), tx)
.await?;
} else if let Ok(download_id) = Uuid::parse_str(&id) {
state
.download_coordinator
.send(crate::download::DownloadCmd::PauseWithAck(download_id, tx))
.await?;
} else {
let _ = tx.send(()); // Fallback if no task exists
}
@@ -3538,6 +3610,7 @@ fn get_platform_info() -> crate::ipc::PlatformInfo {
#[cfg(target_os = "macos")]
mod macos_sleep {
use std::ffi::c_void;
#[allow(clippy::duplicated_attributes)]
#[link(name = "IOKit", kind = "framework")]
#[link(name = "CoreFoundation", kind = "framework")]
extern "C" {
@@ -3799,7 +3872,7 @@ async fn set_concurrent_limit(
Ok(())
}
fn normalize_speed_limit_for_aria2(limit: &str) -> Option<String> {
pub(crate) fn normalize_speed_limit_for_aria2(limit: &str) -> Option<String> {
let trimmed = limit.trim();
if trimmed.is_empty() {
return None;
@@ -4412,9 +4485,10 @@ mod tests {
aggregate_media_fraction, append_ytdlp_http_headers, build_media_format_options,
collect_download_uris, filename_from_content_disposition,
filename_from_url_disposition_query, filename_from_url_path, is_excluded_yt_dlp_format,
json_lower, media_output_template, media_progress_speed, normalize_speed_limit_for_aria2,
parse_firelink_deep_link, parse_ffmpeg_version, parse_media_progress_line, redact_log_line,
sanitize_ytdlp_config_value, should_cleanup_media_artifacts_after_failure,
is_browser_cookie_extraction_error, json_lower, media_metadata_cache_key,
media_output_template, media_progress_speed, normalize_speed_limit_for_aria2,
parse_firelink_deep_link, parse_ffmpeg_version, parse_media_progress_line,
redact_log_line, sanitize_ytdlp_config_value, should_cleanup_media_artifacts_after_failure,
FirelinkDeepLink, MediaProgress, MEDIA_PROGRESS_PREFIX,
};
use serde_json::json;
@@ -4468,6 +4542,54 @@ mod tests {
assert!(error.contains("invalid HTTP header"));
}
#[test]
fn media_metadata_cache_key_includes_request_headers_and_cookies() {
let base = media_metadata_cache_key(
"https://example.com/watch?v=1",
&Some("firefox".to_string()),
&Some("Custom UA A".to_string()),
&None,
&None,
&Some("User-Agent: Browser A".to_string()),
&Some("session=one".to_string()),
&None,
);
let changed_headers = media_metadata_cache_key(
"https://example.com/watch?v=1",
&Some("firefox".to_string()),
&Some("Custom UA A".to_string()),
&None,
&None,
&Some("User-Agent: Browser B".to_string()),
&Some("session=one".to_string()),
&None,
);
let changed_cookies = media_metadata_cache_key(
"https://example.com/watch?v=1",
&Some("firefox".to_string()),
&Some("Custom UA A".to_string()),
&None,
&None,
&Some("User-Agent: Browser A".to_string()),
&Some("session=two".to_string()),
&None,
);
let changed_user_agent = media_metadata_cache_key(
"https://example.com/watch?v=1",
&Some("firefox".to_string()),
&Some("Custom UA B".to_string()),
&None,
&None,
&Some("User-Agent: Browser A".to_string()),
&Some("session=one".to_string()),
&None,
);
assert_ne!(base, changed_headers);
assert_ne!(base, changed_cookies);
assert_ne!(base, changed_user_agent);
}
#[test]
fn retryable_media_failures_preserve_resumable_artifacts() {
assert!(!should_cleanup_media_artifacts_after_failure(
@@ -4703,6 +4825,36 @@ mod tests {
assert_eq!(option.filesize_approx, Some(1_468_000_000));
}
#[test]
fn returns_no_media_options_for_webpage_only_metadata() {
let formats = vec![json!({
"format_id": "0",
"ext": "html",
"protocol": "https",
"format": "default webpage",
"vcodec": "none",
"acodec": "none"
})];
assert!(build_media_format_options(&formats, Some(30.0)).is_empty());
}
#[test]
fn classifies_browser_cookie_database_errors_for_fallback() {
assert!(is_browser_cookie_extraction_error(
"ERROR: Could not copy Chrome cookie database. See https://github.com/yt-dlp/yt-dlp/issues/7271"
));
assert!(is_browser_cookie_extraction_error(
"failed to read browser cookie data"
));
assert!(!is_browser_cookie_extraction_error(
"ERROR: Sign in to confirm you are not a bot"
));
assert!(!is_browser_cookie_extraction_error(
"ERROR: requested format is not available"
));
}
#[test]
#[ignore = "requires network and a local yt-dlp executable"]
fn filters_live_youtube_metadata_from_env() {
@@ -5007,7 +5159,6 @@ pub fn run() {
dispatcher_mgr.run_dispatcher().await;
});
let queue_manager_clone = Arc::clone(&queue_manager);
let queue_manager_poll = Arc::clone(&queue_manager);
app.manage(AppState {
@@ -5024,39 +5175,6 @@ pub fn run() {
queue_manager,
});
// Backend listener: release permits + emit terminal state for
// native (and aria2-fallback) downloads. Idempotent for Media/aria2
// which already release via finish_runner/handle_aria2_event.
let completion_app = app.handle().clone();
let completion_mgr = Arc::clone(&queue_manager_clone);
tauri::async_runtime::spawn(async move {
use tauri::Listener;
let rx_complete = completion_app.listen("download-complete", move |event| {
let raw_id = event.payload();
let id: String = serde_json::from_str(raw_id)
.unwrap_or_else(|_| raw_id.trim_matches('"').to_string());
let mgr = Arc::clone(&completion_mgr);
tauri::async_runtime::spawn(async move {
mgr.apply_completion(&id, crate::queue::PendingOutcome::Complete).await;
});
});
let completion_app2 = completion_app.clone();
let completion_mgr2 = Arc::clone(&queue_manager_clone);
let rx_failed = completion_app2.listen("download-failed", move |event| {
let raw_id = event.payload();
let id: String = serde_json::from_str(raw_id)
.unwrap_or_else(|_| raw_id.trim_matches('"').to_string());
let mgr = Arc::clone(&completion_mgr2);
tauri::async_runtime::spawn(async move {
mgr.apply_completion(&id, crate::queue::PendingOutcome::Error("download failed".to_string())).await;
});
});
// Keep the task alive; the listeners are unregistered on drop.
std::future::pending::<()>().await;
let _ = rx_complete;
let _ = rx_failed;
});
let deep_link_app = app.handle().clone();
#[cfg(target_os = "linux")]
if let Err(error) = app.deep_link().register_all() {
@@ -5362,6 +5480,30 @@ pub fn run() {
.max_file_size(10_000_000)
.rotation_strategy(tauri_plugin_log::RotationStrategy::KeepSome(3))
.timezone_strategy(tauri_plugin_log::TimezoneStrategy::UseLocal)
.format({
let home_dir = std::env::var("USERPROFILE").or_else(|_| std::env::var("HOME")).unwrap_or_default();
let home_dir = if home_dir.len() > 3 { home_dir } else { String::new() };
let escaped_home_dir = home_dir.replace("\\", "\\\\");
move |out, message, record| {
let msg = message.to_string();
let redacted = if !home_dir.is_empty() {
let mut s = msg.replace(&home_dir, "<HOME>");
if !escaped_home_dir.is_empty() && escaped_home_dir != home_dir {
s = s.replace(&escaped_home_dir, "<HOME>");
}
s
} else {
msg
};
out.finish(format_args!(
"[{}][{}][{}] {}",
chrono::Local::now().format("%Y-%m-%d][%H:%M:%S"),
record.level(),
record.target(),
redacted
))
}
})
.build(),
)
.plugin(tauri_plugin_dialog::init())
+446 -11
View File
@@ -1,24 +1,454 @@
use serde::{Deserialize, Serialize};
use std::process::Command;
use ts_rs::TS;
use crate::ipc::DownloadCategory;
#[tauri::command]
pub async fn get_system_proxy() -> Result<Option<String>, String> {
match sysproxy::Sysproxy::get_system_proxy() {
Ok(proxy) => {
if proxy.enable {
let protocol = if proxy.host.contains("://") {
""
match native_system_proxy() {
Ok(Some(proxy)) => Ok(Some(proxy)),
Ok(None) => Ok(proxy_from_environment()),
Err(native_error) => match sysproxy::Sysproxy::get_system_proxy() {
Ok(proxy) if proxy.enable => {
if proxy.host.contains('=') {
Ok(parse_windows_proxy_server(&proxy.host).or_else(proxy_from_environment))
} else {
"http://"
};
Ok(Some(format!("{}{}:{}", protocol, proxy.host, proxy.port)))
} else {
Ok(None)
Ok(normalize_sysproxy_address(&proxy.host, proxy.port)
.or_else(proxy_from_environment))
}
}
Ok(_) => Ok(proxy_from_environment()),
Err(error) => proxy_from_environment().map(Some).ok_or_else(|| {
format!(
"failed to read system proxy settings: {native_error}; sysproxy fallback: {error}"
)
}),
},
}
}
#[cfg(target_os = "windows")]
fn native_system_proxy() -> Result<Option<String>, String> {
fallback_windows_proxy().map_err(|_| "failed to read Windows proxy registry".to_string())
}
#[cfg(target_os = "macos")]
fn native_system_proxy() -> Result<Option<String>, String> {
let proxy = sysproxy::Sysproxy::get_system_proxy().map_err(|error| error.to_string())?;
if !proxy.enable {
return Ok(None);
}
Ok(macos_proxy_for_host_port(&proxy.host, proxy.port)
.unwrap_or_else(|| {
normalize_sysproxy_address(&proxy.host, proxy.port)
.unwrap_or_else(|| format!("http://{}:{}", proxy.host, proxy.port))
})
.into())
}
#[cfg(target_os = "linux")]
fn native_system_proxy() -> Result<Option<String>, String> {
let mode =
command_stdout(Command::new("gsettings").args(["get", "org.gnome.system.proxy", "mode"]))
.map_err(|error| error.to_string())?;
if strip_gsettings_string(&mode) != "manual" {
return Ok(None);
}
Ok(linux_gsettings_proxy("https", "http")
.or_else(|| linux_gsettings_proxy("http", "http"))
.or_else(|| linux_gsettings_proxy("socks", "socks5")))
}
#[cfg(not(any(target_os = "windows", target_os = "macos", target_os = "linux")))]
fn native_system_proxy() -> Result<Option<String>, String> {
Ok(None)
}
fn proxy_from_environment() -> Option<String> {
[
"HTTPS_PROXY",
"https_proxy",
"HTTP_PROXY",
"http_proxy",
"ALL_PROXY",
"all_proxy",
]
.into_iter()
.find_map(|name| {
std::env::var(name)
.ok()
.and_then(|value| normalize_proxy_address(&value, "http"))
})
}
fn command_stdout(command: &mut Command) -> std::io::Result<String> {
let output = command.output()?;
if !output.status.success() {
return Err(std::io::Error::new(
std::io::ErrorKind::Other,
format!("command exited with {}", output.status),
));
}
Ok(String::from_utf8_lossy(&output.stdout).to_string())
}
fn normalize_proxy_address(raw: &str, default_scheme: &str) -> Option<String> {
let trimmed = raw.trim().trim_matches('"').trim_end_matches('/');
if trimmed.is_empty() {
return None;
}
let candidate = if trimmed.contains("://") {
trimmed.to_string()
} else {
format!("{default_scheme}://{trimmed}")
};
let parsed = url::Url::parse(&candidate).ok()?;
match parsed.scheme() {
"http" | "https" | "socks4" | "socks4a" | "socks5" | "socks5h" => {}
_ => return None,
}
parsed.host_str()?;
Some(candidate)
}
fn normalize_sysproxy_address(host: &str, port: u16) -> Option<String> {
let host = host.trim();
if host.is_empty() {
return None;
}
if host.contains("://") {
let mut parsed = url::Url::parse(host).ok()?;
if parsed.port().is_none() && port != 0 {
parsed.set_port(Some(port)).ok()?;
}
return normalize_proxy_address(parsed.as_str(), "http");
}
if port == 0 {
normalize_proxy_address(host, "http")
} else {
normalize_proxy_address(&format!("{host}:{port}"), "http")
}
}
fn parse_windows_proxy_server(value: &str) -> Option<String> {
let value = value.trim().trim_matches('"');
if value.is_empty() {
return None;
}
if !value.contains('=') {
return normalize_proxy_address(value, "http");
}
let mut http = None;
let mut https = None;
let mut socks = None;
for entry in value.split(';') {
let Some((kind, address)) = entry.split_once('=') else {
continue;
};
let kind = kind.trim().to_ascii_lowercase();
let address = address.trim();
match kind.as_str() {
"http" => http = normalize_proxy_address(address, "http"),
"https" => https = normalize_proxy_address(address, "http"),
"socks" => socks = normalize_proxy_address(address, "socks5"),
_ => {}
}
}
https.or(http).or(socks)
}
#[cfg(target_os = "macos")]
fn macos_proxy_for_host_port(host: &str, port: u16) -> Option<String> {
let services_output =
command_stdout(Command::new("networksetup").arg("-listallnetworkservices")).ok()?;
for service in parse_macos_network_services(&services_output) {
for (target, scheme) in [
("securewebproxy", "http"),
("webproxy", "http"),
("socksfirewallproxy", "socks5"),
] {
let output = command_stdout(
Command::new("networksetup").args([format!("-get{target}"), service.clone()]),
)
.ok()?;
if let Some(proxy) = parse_macos_networksetup_proxy(&output, scheme)
.filter(|proxy| proxy_matches_host_port(proxy, host, port))
{
return Some(proxy);
}
}
Err(error) => Err(format!("failed to read system proxy settings: {error}")),
}
None
}
#[cfg_attr(not(target_os = "macos"), allow(dead_code))]
fn parse_macos_network_services(output: &str) -> Vec<String> {
output
.lines()
.map(str::trim)
.filter(|line| !line.is_empty())
.filter(|line| !line.starts_with("An asterisk"))
.filter(|line| !line.starts_with('*'))
.map(str::to_string)
.collect()
}
#[cfg_attr(not(target_os = "macos"), allow(dead_code))]
fn parse_macos_networksetup_proxy(output: &str, scheme: &str) -> Option<String> {
let enabled = macos_networksetup_value(output, "Enabled:")
.is_some_and(|value| value.eq_ignore_ascii_case("yes"));
if !enabled {
return None;
}
let server = macos_networksetup_value(output, "Server:")?;
let port = macos_networksetup_value(output, "Port:")?;
normalize_proxy_address(&format!("{scheme}://{server}:{port}"), scheme)
}
#[cfg_attr(not(target_os = "macos"), allow(dead_code))]
fn proxy_matches_host_port(proxy: &str, host: &str, port: u16) -> bool {
let Ok(parsed) = url::Url::parse(proxy) else {
return false;
};
parsed.host_str() == Some(host) && parsed.port() == Some(port)
}
#[cfg_attr(not(target_os = "macos"), allow(dead_code))]
fn macos_networksetup_value<'a>(output: &'a str, key: &str) -> Option<&'a str> {
output
.lines()
.map(str::trim)
.find_map(|line| line.strip_prefix(key).map(str::trim))
.filter(|value| !value.is_empty())
}
#[cfg(target_os = "linux")]
fn linux_gsettings_proxy(service: &str, scheme: &str) -> Option<String> {
let schema = format!("org.gnome.system.proxy.{service}");
let host = command_stdout(Command::new("gsettings").args(["get", &schema, "host"])).ok()?;
let host = strip_gsettings_string(&host);
if host.is_empty() {
return None;
}
let port = command_stdout(Command::new("gsettings").args(["get", &schema, "port"])).ok()?;
let port = port.trim();
normalize_proxy_address(&format!("{scheme}://{host}:{port}"), scheme)
}
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
fn strip_gsettings_string(value: &str) -> String {
value
.trim()
.trim_matches('\'')
.trim_matches('"')
.to_string()
}
#[cfg(target_os = "windows")]
fn fallback_windows_proxy() -> Result<Option<String>, ()> {
use std::os::windows::process::CommandExt;
use std::process::Command;
const CREATE_NO_WINDOW: u32 = 0x08000000;
let output = Command::new("reg")
.args(&[
"query",
"HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Internet Settings",
"/v",
"ProxyEnable",
])
.creation_flags(CREATE_NO_WINDOW)
.output()
.map_err(|_| ())?;
if !output.status.success() {
return Err(());
}
let stdout = String::from_utf8_lossy(&output.stdout);
let enabled = registry_value(&stdout, "ProxyEnable")
.as_deref()
.is_some_and(windows_proxy_enabled);
if !enabled {
return Ok(None);
}
let output = Command::new("reg")
.args(&[
"query",
"HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Internet Settings",
"/v",
"ProxyServer",
])
.creation_flags(CREATE_NO_WINDOW)
.output()
.map_err(|_| ())?;
if !output.status.success() {
return Err(());
}
let stdout = String::from_utf8_lossy(&output.stdout);
Ok(registry_value(&stdout, "ProxyServer").and_then(|value| parse_windows_proxy_server(&value)))
}
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
fn windows_proxy_enabled(value: &str) -> bool {
let value = value.trim();
if value == "1" {
return true;
}
value
.strip_prefix("0x")
.or_else(|| value.strip_prefix("0X"))
.and_then(|hex| u32::from_str_radix(hex, 16).ok())
.is_some_and(|enabled| enabled == 1)
}
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
fn registry_value(output: &str, name: &str) -> Option<String> {
for line in output.lines() {
let trimmed = line.trim();
let mut parts = trimmed.split_whitespace();
let Some(key) = parts.next() else {
continue;
};
if !key.eq_ignore_ascii_case(name) {
continue;
}
if parts.next().is_none() {
continue;
}
let data = parts.collect::<Vec<_>>().join(" ");
if !data.is_empty() {
return Some(data);
}
}
None
}
#[cfg(test)]
mod proxy_tests {
use super::{
normalize_proxy_address, normalize_sysproxy_address, parse_macos_network_services,
parse_macos_networksetup_proxy, parse_windows_proxy_server, proxy_matches_host_port,
registry_value, strip_gsettings_string, windows_proxy_enabled,
};
#[test]
fn normalizes_bare_proxy_addresses() {
assert_eq!(
normalize_proxy_address("127.0.0.1:8080", "http").as_deref(),
Some("http://127.0.0.1:8080")
);
assert_eq!(
normalize_proxy_address(" socks5://127.0.0.1:1080/ ", "http").as_deref(),
Some("socks5://127.0.0.1:1080")
);
assert_eq!(normalize_proxy_address("file:///tmp/proxy", "http"), None);
}
#[test]
fn parses_windows_protocol_proxy_server_values() {
assert_eq!(
parse_windows_proxy_server("http=127.0.0.1:8080;https=127.0.0.1:8081").as_deref(),
Some("http://127.0.0.1:8081")
);
assert_eq!(
parse_windows_proxy_server("socks=127.0.0.1:1080").as_deref(),
Some("socks5://127.0.0.1:1080")
);
assert_eq!(
parse_windows_proxy_server("proxy.local:9000").as_deref(),
Some("http://proxy.local:9000")
);
}
#[test]
fn normalizes_sysproxy_host_without_duplicating_ports() {
assert_eq!(
normalize_sysproxy_address("http://proxy.local", 8080).as_deref(),
Some("http://proxy.local:8080")
);
assert_eq!(
normalize_sysproxy_address("http://proxy.local:9000", 8080).as_deref(),
Some("http://proxy.local:9000")
);
assert_eq!(
normalize_sysproxy_address("proxy.local", 8080).as_deref(),
Some("http://proxy.local:8080")
);
}
#[test]
fn parses_macos_proxy_outputs_with_scheme() {
let services = r#"
An asterisk (*) denotes that a network service is disabled.
Wi-Fi
*USB 10/100/1000 LAN
Thunderbolt Bridge
"#;
assert_eq!(
parse_macos_network_services(services),
vec!["Wi-Fi".to_string(), "Thunderbolt Bridge".to_string()]
);
let proxy = r#"
Enabled: Yes
Server: 127.0.0.1
Port: 1080
Authenticated Proxy Enabled: 0
"#;
assert_eq!(
parse_macos_networksetup_proxy(proxy, "socks5").as_deref(),
Some("socks5://127.0.0.1:1080")
);
assert!(proxy_matches_host_port(
"socks5://127.0.0.1:1080",
"127.0.0.1",
1080
));
assert!(!proxy_matches_host_port(
"socks5://127.0.0.1:1080",
"127.0.0.1",
1081
));
let disabled = proxy.replace("Enabled: Yes", "Enabled: No");
assert_eq!(parse_macos_networksetup_proxy(&disabled, "socks5"), None);
}
#[test]
fn strips_gsettings_string_quotes() {
assert_eq!(strip_gsettings_string("'manual'\n"), "manual");
assert_eq!(strip_gsettings_string("\"127.0.0.1\"\n"), "127.0.0.1");
}
#[test]
fn parses_reg_query_output_values() {
let output = r#"
HKEY_CURRENT_USER\Software\Microsoft\Windows\CurrentVersion\Internet Settings
ProxyEnable REG_DWORD 0x1
ProxyServer REG_SZ http=127.0.0.1:8080;https=127.0.0.1:8081
"#;
assert!(registry_value(output, "ProxyEnable")
.as_deref()
.is_some_and(windows_proxy_enabled));
assert_eq!(
registry_value(output, "ProxyServer").as_deref(),
Some("http=127.0.0.1:8080;https=127.0.0.1:8081")
);
assert!(!windows_proxy_enabled("0X0"));
assert!(windows_proxy_enabled("0X1"));
}
}
@@ -225,6 +655,11 @@ pub static SUPPORTED_DOMAINS: &[&str] = &[
"reddit.com",
"v.redd.it",
"soundcloud.com",
"pornhub.com",
"redtube.com",
"xhamster.com",
"xnxx.com",
"xvideos.com",
];
#[tauri::command]
+2 -4
View File
@@ -9,10 +9,8 @@ pub fn kill_process_tree(pid: u32) {
while i < to_kill.len() {
let current_pid = to_kill[i];
for (p, process) in sys.processes() {
if process.parent() == Some(current_pid) {
if !to_kill.contains(p) {
to_kill.push(*p);
}
if process.parent() == Some(current_pid) && !to_kill.contains(p) {
to_kill.push(*p);
}
}
i += 1;
+116 -129
View File
@@ -28,7 +28,6 @@ pub enum PendingOutcome {
pub enum TaskKind {
Aria2,
Media,
Native,
}
/// Everything needed to start a sidecar, captured at enqueue time so the
@@ -64,7 +63,7 @@ pub struct SpawnPayload {
pub is_media: bool,
}
/// A sidecar spawner. In production this calls the real aria2/yt-dlp/native
/// A sidecar spawner. In production this calls the real aria2/yt-dlp
/// runners; in tests it is replaced with a fake that records calls and
/// optionally hangs to simulate a long-running download.
#[async_trait::async_trait]
@@ -80,9 +79,6 @@ pub trait SidecarSpawner: Send + Sync + 'static {
/// Run a media download to completion. The permit is parked for the full
/// duration; release is handled by QueueManager on the runner's exit.
async fn run_media(&self, id: &str, payload: &SpawnPayload) -> Result<(), String>;
/// Run a native HTTP download to completion.
async fn run_native(&self, id: &str, payload: &SpawnPayload) -> Result<(), String>;
}
/// The centralized concurrency gatekeeper. One instance lives in AppState.
@@ -407,7 +403,7 @@ impl<R: tauri::Runtime> QueueManager<R> {
let id = task.id.clone();
// Park the permit BEFORE spawning. Uniform parking:
// aria2's RPC returns instantly, so the permit must outlive the
// dispatch_one call. Media/Native runners release on exit.
// dispatch_one call. Media runners release on exit.
self.park_permit(&id, permit).await;
self.active_kinds
.lock()
@@ -432,15 +428,13 @@ impl<R: tauri::Runtime> QueueManager<R> {
id,
gid
);
if !gid.starts_with("native:") {
if let Err(error) = self.spawner.remove_uri(&gid).await {
log::warn!(
"aria2 dispatch cancellation [{}]: failed to remove late gid {}: {}",
id,
gid,
error
);
}
if let Err(error) = self.spawner.remove_uri(&gid).await {
log::warn!(
"aria2 dispatch cancellation [{}]: failed to remove late gid {}: {}",
id,
gid,
error
);
}
self.clear_aria2_retry_state(&id).await;
self.release_permit(&id).await;
@@ -464,21 +458,6 @@ impl<R: tauri::Runtime> QueueManager<R> {
this.finish_runner(&id_for_task, outcome).await;
});
}
TaskKind::Native => {
// Native coordinator is event-driven (fire-and-observe). Send
// Start; completion is handled by the download-complete/
// download-failed listener in lib.rs setup() which calls
// release_permit + apply_completion.
let this = Arc::clone(&self);
let payload = task.payload.clone();
let id_for_task = id.clone();
tauri::async_runtime::spawn(async move {
if let Err(error) = this.spawner.run_native(&id_for_task, &payload).await {
this.emit_failed(&id_for_task, error);
this.release_permit(&id_for_task).await;
}
});
}
}
}
@@ -617,20 +596,6 @@ impl<R: tauri::Runtime> QueueManager<R> {
removed.last().cloned()
}
/// Overwrite a stale aria2 gid with the fresh gid minted by a retry
/// `addUri`. Failing to call this after re-add leaks the semaphore permit.
pub fn rotate_aria2_gid(&self, id: &str, stale_gid: &str, new_gid: &str) {
let mut gids = self.aria2_gids.write().unwrap();
gids.remove(stale_gid);
gids.insert(new_gid.to_string(), id.to_string());
log::info!(
"aria2 gid transition [{}]: rotated {} -> {}",
id,
stale_gid,
new_gid
);
}
async fn wait_permit_released(self: &Arc<Self>, id: &str) {
loop {
if !self.active_permits.lock().await.contains_key(id) {
@@ -716,7 +681,6 @@ impl<R: tauri::Runtime> QueueManager<R> {
}
let this = Arc::clone(self);
let stale_gid = gid.to_string();
let id_for_task = id.clone();
let error_for_emit = error.clone();
tauri::async_runtime::spawn(async move {
@@ -773,11 +737,12 @@ impl<R: tauri::Runtime> QueueManager<R> {
);
return;
}
this.rotate_aria2_gid(&id_for_task, &stale_gid, &new_gid);
let retained_gid = new_gid.clone();
this.remember_gid(id_for_task.clone(), new_gid).await;
log::warn!(
"aria2 retry cancellation [{}]: retained late gid {} mapping for remove retry",
id_for_task,
new_gid
retained_gid
);
return;
}
@@ -785,8 +750,8 @@ impl<R: tauri::Runtime> QueueManager<R> {
.lock()
.await
.insert(id_for_task.clone(), strike + 1);
this.rotate_aria2_gid(&id_for_task, &stale_gid, &new_gid);
this.emit_state(&id_for_task, DownloadStatus::Downloading);
this.remember_gid(id_for_task.clone(), new_gid).await;
}
Err(retry_error) => {
this.apply_completion(&id_for_task, PendingOutcome::Error(retry_error))
@@ -924,6 +889,17 @@ fn is_retryable_aria2_error(error: &str) -> bool {
is_transient_network_error(error) || is_aria2_range_mode_error(error)
}
fn is_aria2_rpc_unavailable(error: &str) -> bool {
let lower = error.to_ascii_lowercase();
is_transient_network_error(error)
|| lower.contains("aria2 did not become ready")
|| lower.contains("connection refused")
|| lower.contains("failed to connect")
|| lower.contains("error trying to connect")
|| lower.contains("connection closed")
|| lower.contains("connection reset")
}
fn is_aria2_range_mode_error(error: &str) -> bool {
let lower = error.to_ascii_lowercase();
lower.contains("invalid range header")
@@ -1008,6 +984,28 @@ fn uri_host_for_log(uri: &str) -> String {
.unwrap_or_else(|| "<unknown host>".to_string())
}
fn proxy_scheme(proxy: &str) -> Option<String> {
proxy
.split_once("://")
.map(|(scheme, _)| scheme.trim().to_ascii_lowercase())
}
fn aria2_all_proxy_value(proxy: &str) -> Result<Option<String>, String> {
let proxy = proxy.trim();
if proxy.is_empty() {
return Ok(None);
}
if proxy.eq_ignore_ascii_case("none") {
return Ok(Some(String::new()));
}
if proxy_scheme(proxy).is_some_and(|scheme| scheme.starts_with("socks")) {
return Err(
"SOCKS system proxies are not supported for normal file downloads because aria2 only accepts HTTP/HTTPS/FTP proxy URLs. Use an HTTP proxy endpoint for normal downloads, or use media downloads where yt-dlp supports SOCKS.".to_string(),
);
}
Ok(Some(proxy.to_string()))
}
async fn probe_bounded_range_support(
uri: &str,
payload: &SpawnPayload,
@@ -1123,8 +1121,7 @@ fn parse_content_range_bounds(value: &str) -> Option<(u64, u64)> {
Some((start.trim().parse().ok()?, end.trim().parse().ok()?))
}
/// Production spawner that delegates to the real aria2 RPC, yt-dlp, and
/// native coordinator runners.
/// Production spawner that delegates to the real aria2 RPC and yt-dlp runners.
pub struct ProductionSpawner {
app_handle: AppHandle<tauri::Wry>,
}
@@ -1133,6 +1130,32 @@ impl ProductionSpawner {
pub fn new(app_handle: AppHandle<tauri::Wry>) -> Self {
Self { app_handle }
}
async fn add_uri_rpc(
&self,
state: &crate::AppState,
params: &serde_json::Value,
) -> Result<serde_json::Value, String> {
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(15);
loop {
match crate::rpc_call(
state.aria2_port.load(std::sync::atomic::Ordering::Relaxed),
&state.aria2_secret,
"aria2.addUri",
params.clone(),
)
.await
{
Ok(result) => return Ok(result),
Err(error) => {
if !is_aria2_rpc_unavailable(&error) || std::time::Instant::now() >= deadline {
return Err(error);
}
tokio::time::sleep(std::time::Duration::from_millis(250)).await;
}
}
}
}
}
#[async_trait::async_trait]
@@ -1144,6 +1167,12 @@ impl SidecarSpawner for ProductionSpawner {
if !crate::is_safe_path(&resolved_dest, &self.app_handle) {
return Err("Path traversal blocked".to_string());
}
let proxy_value = payload
.proxy
.as_deref()
.map(aria2_all_proxy_value)
.transpose()?
.flatten();
options.insert(
"dir".to_string(),
serde_json::json!(resolved_dest.to_string_lossy().to_string()),
@@ -1161,7 +1190,11 @@ impl SidecarSpawner for ProductionSpawner {
options.insert("max-tries".to_string(), serde_json::json!(mt.to_string()));
options.insert("retry-wait".to_string(), serde_json::json!("2"));
options.insert("continue".to_string(), serde_json::json!("true"));
if let Some(speed) = &payload.speed_limit {
if let Some(speed) = payload
.speed_limit
.as_deref()
.and_then(crate::normalize_speed_limit_for_aria2)
{
options.insert("max-download-limit".to_string(), serde_json::json!(speed));
}
if let Some(user) = &payload.username {
@@ -1195,29 +1228,13 @@ impl SidecarSpawner for ProductionSpawner {
if !header_list.is_empty() {
options.insert("header".to_string(), serde_json::json!(header_list));
}
if let Some(prox) = payload
.proxy
.as_deref()
.map(str::trim)
.filter(|s| !s.is_empty())
{
if prox.eq_ignore_ascii_case("none") {
options.insert("all-proxy".to_string(), serde_json::json!(""));
} else {
options.insert("all-proxy".to_string(), serde_json::json!(prox));
}
if let Some(prox) = proxy_value {
options.insert("all-proxy".to_string(), serde_json::json!(prox));
}
let uris = crate::collect_download_uris(&payload.url, payload.mirrors.as_deref());
let params = serde_json::json!([uris, options]);
match crate::rpc_call(
state.aria2_port.load(std::sync::atomic::Ordering::Relaxed),
&state.aria2_secret,
"aria2.addUri",
params,
)
.await
{
match self.add_uri_rpc(&state, &params).await {
Ok(result) => {
let gid = result.as_str().unwrap_or("").to_string();
if gid.is_empty() {
@@ -1228,35 +1245,8 @@ impl SidecarSpawner for ProductionSpawner {
}
}
Err(e) => {
// aria2 unavailable — fall back to native coordinator.
log::warn!("aria2 addUri failed, falling back to native: {}", e);
let download_id = uuid::Uuid::parse_str(id).map_err(|e| e.to_string())?;
let mt = automatic_retry_limit(payload.max_tries) as u32;
let safe_filename =
crate::download_ownership::canonical_download_filename(&payload.filename);
state
.download_coordinator
.send(crate::download::DownloadCmd::Start(Box::new(
crate::download::DownloadPayload {
id: download_id,
urls: crate::collect_download_uris(
&payload.url,
payload.mirrors.as_deref(),
),
output_path: resolved_dest.join(safe_filename),
speed_limit: payload.speed_limit.clone(),
username: payload.username.clone(),
password: payload.password.clone(),
headers: payload.headers.clone(),
cookies: payload.cookies.clone(),
user_agent: payload.user_agent.clone(),
max_tries: mt,
proxy: payload.proxy.clone(),
},
)))
.await
.map_err(|e| e.to_string())?;
Ok(format!("native:{id}"))
log::error!("aria2 addUri [{}] failed: {}", id, e);
Err(format!("aria2 addUri failed: {e}"))
}
}
}
@@ -1320,36 +1310,6 @@ impl SidecarSpawner for ProductionSpawner {
.await;
outcome.map(|_| ())
}
async fn run_native(&self, id: &str, payload: &SpawnPayload) -> Result<(), String> {
let state = self.app_handle.state::<crate::AppState>();
let download_id = uuid::Uuid::parse_str(id).map_err(|e| e.to_string())?;
let mt = automatic_retry_limit(payload.max_tries) as u32;
let resolved_dest = crate::resolve_path(&payload.destination, &self.app_handle);
let safe_filename =
crate::download_ownership::canonical_download_filename(&payload.filename);
let output_path = resolved_dest.join(safe_filename);
let _ = crate::download_ownership::set_primary_path(&self.app_handle, id, &output_path);
state
.download_coordinator
.send(crate::download::DownloadCmd::Start(Box::new(
crate::download::DownloadPayload {
id: download_id,
urls: crate::collect_download_uris(&payload.url, payload.mirrors.as_deref()),
output_path,
speed_limit: payload.speed_limit.clone(),
username: payload.username.clone(),
password: payload.password.clone(),
headers: payload.headers.clone(),
cookies: payload.cookies.clone(),
user_agent: payload.user_agent.clone(),
max_tries: mt,
proxy: payload.proxy.clone(),
},
)))
.await?;
Ok(())
}
}
#[derive(Debug, Clone, Deserialize, TS)]
@@ -1438,6 +1398,20 @@ mod tests {
);
}
#[test]
fn aria2_proxy_value_rejects_socks_proxies() {
assert_eq!(aria2_all_proxy_value("none").unwrap().as_deref(), Some(""));
assert_eq!(
aria2_all_proxy_value("http://127.0.0.1:8080")
.unwrap()
.as_deref(),
Some("http://127.0.0.1:8080")
);
assert!(aria2_all_proxy_value("socks5://127.0.0.1:1080")
.unwrap_err()
.contains("SOCKS system proxies are not supported"));
}
#[test]
fn bounded_range_probe_rejects_server_that_expands_to_end() {
assert_eq!(
@@ -1464,4 +1438,17 @@ mod tests {
));
assert!(!is_retryable_aria2_error("No URI available."));
}
#[test]
fn aria2_startup_rpc_errors_are_retryable() {
assert!(is_aria2_rpc_unavailable(
"error trying to connect: tcp connect error: Connection refused"
));
assert!(is_aria2_rpc_unavailable(
"aria2 did not become ready: connection refused"
));
assert!(!is_aria2_rpc_unavailable(
"aria2 error code 3: Resource not found"
));
}
}
+15 -21
View File
@@ -1,5 +1,5 @@
//! Connection-aware retry engine, shared by all three download backends
//! (native `reqwest`, `yt-dlp` media, and `aria2c`).
//! Connection-aware retry engine, shared by aria2c file downloads and yt-dlp
//! media downloads.
//!
//! ## Design contract
//!
@@ -9,26 +9,23 @@
//! allocation (semaphore permit / worker slot) is preserved.
//!
//! This module is deliberately runtime-agnostic and free of Tauri types so it
//! can be unit-tested headlessly. Each backend translates the schedule into its
//! own state-emission + cancellation vocabulary:
//! can be unit-tested headlessly. Each path translates the schedule into its
//! own state-emission and cancellation vocabulary:
//!
//! - **Native** (`download.rs`): calls [`BACKOFF_SCHEDULE`] inside the existing
//! `control_rx` `tokio::select!`, so pause/cancel still interrupt backoff.
//! - **yt-dlp** (`lib.rs`): sleeps between child re-spawns; `--continue` resumes.
//! - **aria2** (`queue.rs` / WS poller): sleeps before re-issuing `aria2.addUri`.
//!
//! ### aria2 GID-rotation contract (CRITICAL)
//!
//! When aria2 retries via a fresh `aria2.addUri`, it mints a **brand-new GID**.
//! The caller MUST overwrite the stale GID → download-id mapping in
//! `QueueManager::aria2_gids` with the new GID on every successful re-add.
//! Failing to do so detaches subsequent `onDownloadComplete` /
//! `onDownloadError` WebSocket events from the original id, which leaks the
//! semaphore permit permanently. Concretely, after every retry-driven
//! `addUri` that returns `new_gid`:
//! The caller MUST store the new GID through `QueueManager::remember_gid` on
//! every successful re-add. Failing to do so detaches subsequent
//! `onDownloadComplete` / `onDownloadError` WebSocket events from the original
//! id, or strands a terminal event that arrived before the fresh GID was stored.
//! Concretely, after every retry-driven `addUri` that returns `new_gid`:
//!
//! ```ignore
//! queue_manager.rotate_aria2_gid(&id, &stale_gid, &new_gid);
//! queue_manager.remember_gid(id.clone(), new_gid).await;
//! ```
use std::time::Duration;
@@ -67,10 +64,8 @@ pub fn backoff_for(strike: usize) -> Duration {
/// Classify an error string as a transient network condition worth retrying.
///
/// Returns `true` for socket drops, connect/read timeouts, connection resets,
/// and HTTP 408 / request-timeout conditions across all three backends:
/// and HTTP 408 / request-timeout conditions across both download paths:
///
/// - **reqwest**: `error.is_timeout()`, `error.is_connect()` surface as
/// "operation timed out", "error sending request", "connection reset".
/// - **yt-dlp**: stderr lines like `ERROR: unable to ... Connection timed out`,
/// `HTTP Error 408`.
/// - **aria2c**: `Timeout.`, `Connection was closed by server`.
@@ -103,7 +98,7 @@ pub fn is_transient_network_error(message: &str) -> bool {
let m = message.to_ascii_lowercase();
const TRANSIENT: [&str; 35] = [
// reqwest / hyper / OS socket-layer
// socket-layer / HTTP-client phrasing surfaced by aria2 and yt-dlp
"timed out",
"timeout",
"connection reset",
@@ -231,7 +226,7 @@ mod tests {
// --- transient classification: positive cases -------------------------
#[test]
fn classifies_reqwest_timeouts_as_transient() {
fn classifies_socket_timeouts_as_transient() {
assert!(is_transient_network_error("operation timed out"));
assert!(is_transient_network_error(
"error sending request: operation timed out"
@@ -305,9 +300,8 @@ mod tests {
#[test]
fn permanent_keyword_wins_over_transient_in_composite_message() {
// The native backend formats HTTP statuses as "{url} returned HTTP {status}"
// (download.rs). A 404 whose URL happens to contain "timeout" must still
// fail fast because the explicit "http 404" token wins.
// A 404 whose URL happens to contain "timeout" must still fail fast
// because the explicit "http 404" token wins.
assert!(!is_transient_network_error(
"https://site/timeout-page returned HTTP 404 Not Found"
));
+54 -6
View File
@@ -150,6 +150,10 @@ fn default_category_subfolders() -> HashMap<String, String> {
}
fn normalize_category_subfolder(value: &str, fallback: &str) -> String {
if value.trim().is_empty() {
return String::new();
}
let parts = value
.split(['/', '\\'])
.filter(|part| !part.is_empty() && *part != "." && *part != ".." && !part.ends_with(':'))
@@ -181,7 +185,7 @@ fn migrate_location_settings(state: &mut Value) -> Result<(), String> {
let mut subfolders = default_category_subfolders();
if let Some(persisted) = state.get("categorySubfolders").and_then(Value::as_object) {
for (category, value) in persisted {
if let Some(folder) = value.as_str().filter(|folder| !folder.trim().is_empty()) {
if let Some(folder) = value.as_str() {
let fallback = subfolders
.get(category)
.cloned()
@@ -232,6 +236,9 @@ fn migrate_location_settings(state: &mut Value) -> Result<(), String> {
}
state.insert("baseDownloadFolder".to_string(), Value::String(base));
state
.entry("categorySubfoldersEnabled".to_string())
.or_insert(Value::Bool(true));
state.insert(
"categorySubfolders".to_string(),
serde_json::to_value(subfolders)
@@ -251,17 +258,20 @@ fn normalize_location_path(path: &str) -> String {
}
fn derived_location_path(base: &str, subfolder: &str) -> String {
format!(
"{}/{}",
normalize_location_path(base),
subfolder.trim_matches(|character| character == '/' || character == '\\')
)
let base = normalize_location_path(base);
let subfolder = subfolder.trim_matches(|character| character == '/' || character == '\\');
if subfolder.is_empty() {
base
} else {
format!("{base}/{subfolder}")
}
}
fn default_settings() -> PersistedSettings {
PersistedSettings {
theme: Theme::System,
base_download_folder: "~/Downloads".to_string(),
category_subfolders_enabled: true,
category_subfolders: default_category_subfolders(),
category_directory_overrides: HashMap::new(),
approved_download_roots: Vec::new(),
@@ -372,6 +382,7 @@ mod tests {
vec!["00000000-0000-0000-0000-000000000001"]
);
assert_eq!(settings.base_download_folder, "~/Downloads");
assert!(settings.category_subfolders_enabled);
}
#[test]
@@ -451,6 +462,43 @@ mod tests {
assert_eq!(settings.category_subfolders["Documents"], "Documents");
}
#[test]
fn preserves_empty_category_subfolder_as_base_folder() {
let stored = json!({
"state": {
"baseDownloadFolder": "/Users/test/Downloads",
"categorySubfolders": {
"Movies": ""
}
},
"version": 3
});
let settings = decode_stored_settings(&Value::String(stored.to_string())).unwrap();
assert_eq!(settings.category_subfolders["Movies"], "");
assert_eq!(settings.category_subfolders["Documents"], "Documents");
}
#[test]
fn decodes_disabled_category_subfolders() {
let stored = json!({
"state": {
"baseDownloadFolder": "/Users/test/Downloads",
"categorySubfoldersEnabled": false,
"categorySubfolders": {
"Movies": "Movies"
}
},
"version": 3
});
let settings = decode_stored_settings(&Value::String(stored.to_string())).unwrap();
assert!(!settings.category_subfolders_enabled);
assert_eq!(settings.category_subfolders["Movies"], "Movies");
}
#[test]
fn replaces_zero_concurrency_with_the_safe_default() {
let stored = json!({"state": {"maxConcurrentDownloads": 0}, "version": 0});
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Firelink",
"version": "1.0.1",
"version": "1.0.2",
"identifier": "com.nimbold.firelink",
"build": {
"beforeDevCommand": "node scripts/stage-engines.js && npm run dev",
+5 -1
View File
@@ -22,6 +22,10 @@
]
},
"bundle": {
"targets": ["app", "dmg"]
"targets": ["app", "dmg"],
"macOS": {
"signingIdentity": "-",
"hardenedRuntime": true
}
}
}
+7 -1
View File
@@ -16,6 +16,12 @@
]
},
"bundle": {
"targets": ["nsis"]
"targets": ["nsis"],
"windows": {
"nsis": {
"installerIcon": "icons/icon.ico",
"uninstallerIcon": "icons/icon.ico"
}
}
}
}
+11 -13
View File
@@ -1,4 +1,4 @@
# Headless Download Engine Tests
# Headless Download Tests
Run the full Rust suite:
@@ -7,21 +7,19 @@ cd src-tauri
cargo test --all-targets
```
Run only the async download integration harness with deterministic serial
performance measurements and visible test output:
Run the queue-manager harness when changing aria2 scheduling, concurrency, and
retry behavior:
```sh
cd src-tauri
RUST_BACKTRACE=1 cargo test --test download_engine -- --test-threads=1 --nocapture
cargo test --test queue_manager -- --nocapture
```
The harness binds an ephemeral loopback port and requires no GUI, external
network access, or bundled media binaries. It validates:
Run the media metadata smoke test with an explicit URL when changing yt-dlp
integration:
- aggregation of many streamed HTTP body chunks;
- pause and ranged resume through `DownloadCoordinator`;
- cancellation and partial-file cleanup;
- SHA-256 integrity after resume;
- retry recovery from transient HTTP failures;
- terminal error reporting after the retry budget is exhausted;
- a five-second local transfer performance budget for a 3 MiB fixture.
```sh
cd src-tauri
FIRELINK_LIVE_YOUTUBE_URL='https://www.youtube.com/watch?v=dQw4w9WgXcQ' \
cargo test filters_live_youtube_metadata_from_env --lib -- --ignored --nocapture
```
-485
View File
@@ -1,485 +0,0 @@
use axum::{
body::{Body, Bytes},
extract::State,
http::{
header::{ACCEPT_RANGES, CONTENT_LENGTH, CONTENT_RANGE, RANGE},
HeaderMap, HeaderValue, StatusCode,
},
response::Response,
routing::get,
Router,
};
use firelink_lib::download::{DownloadCmd, DownloadCoordinator, DownloadEvent, DownloadPayload};
use futures_util::stream;
use sha2::{Digest, Sha256};
use std::{
convert::Infallible,
sync::{
atomic::{AtomicUsize, Ordering},
Arc, Mutex,
},
time::{Duration, Instant},
};
use tempfile::TempDir;
use tokio::{
net::TcpListener,
sync::{mpsc, oneshot},
task::JoinHandle,
};
use uuid::Uuid;
const TEST_TIMEOUT: Duration = Duration::from_secs(10);
#[derive(Clone)]
struct ServerState {
data: Arc<Vec<u8>>,
chunk_size: usize,
chunk_delay: Duration,
failures_remaining: Arc<AtomicUsize>,
requests: Arc<AtomicUsize>,
chunks_served: Arc<AtomicUsize>,
range_starts: Arc<Mutex<Vec<Option<usize>>>>,
}
struct TestServer {
base_url: String,
state: ServerState,
task: JoinHandle<()>,
}
impl TestServer {
async fn spawn(
data: Vec<u8>,
chunk_size: usize,
chunk_delay: Duration,
failures_before_success: usize,
) -> Self {
let state = ServerState {
data: Arc::new(data),
chunk_size,
chunk_delay,
failures_remaining: Arc::new(AtomicUsize::new(failures_before_success)),
requests: Arc::new(AtomicUsize::new(0)),
chunks_served: Arc::new(AtomicUsize::new(0)),
range_starts: Arc::new(Mutex::new(Vec::new())),
};
let app = Router::new()
.route("/file", get(serve_file))
.route("/always-fail", get(always_fail))
.with_state(state.clone());
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let address = listener.local_addr().unwrap();
let task = tokio::spawn(async move {
axum::serve(listener, app).await.unwrap();
});
Self {
base_url: format!("http://{address}"),
state,
task,
}
}
fn file_url(&self) -> String {
format!("{}/file", self.base_url)
}
fn failure_url(&self) -> String {
format!("{}/always-fail", self.base_url)
}
}
impl Drop for TestServer {
fn drop(&mut self) {
self.task.abort();
}
}
async fn serve_file(State(state): State<ServerState>, headers: HeaderMap) -> Response<Body> {
state.requests.fetch_add(1, Ordering::SeqCst);
if state
.failures_remaining
.fetch_update(Ordering::SeqCst, Ordering::SeqCst, |remaining| {
remaining.checked_sub(1)
})
.is_ok()
{
return response(StatusCode::SERVICE_UNAVAILABLE, Body::empty(), 0, None);
}
let requested_start = headers
.get(RANGE)
.and_then(|value| value.to_str().ok())
.and_then(parse_range_start);
state.range_starts.lock().unwrap().push(requested_start);
let start = requested_start.unwrap_or(0);
if start >= state.data.len() {
return response(
StatusCode::RANGE_NOT_SATISFIABLE,
Body::empty(),
0,
Some(format!("bytes */{}", state.data.len())),
);
}
let status = if requested_start.is_some() {
StatusCode::PARTIAL_CONTENT
} else {
StatusCode::OK
};
let content_range = requested_start.map(|_| {
format!(
"bytes {start}-{}/{}",
state.data.len() - 1,
state.data.len()
)
});
let content_length = state.data.len() - start;
let data = state.data.clone();
let chunk_size = state.chunk_size;
let chunk_delay = state.chunk_delay;
let chunks_served = state.chunks_served.clone();
let body_stream = stream::unfold(start, move |offset| {
let data = data.clone();
let chunks_served = chunks_served.clone();
async move {
if offset >= data.len() {
return None;
}
if !chunk_delay.is_zero() {
tokio::time::sleep(chunk_delay).await;
}
let end = (offset + chunk_size).min(data.len());
chunks_served.fetch_add(1, Ordering::SeqCst);
let chunk = Bytes::copy_from_slice(&data[offset..end]);
Some((Ok::<_, Infallible>(chunk), end))
}
});
response(
status,
Body::from_stream(body_stream),
content_length,
content_range,
)
}
async fn always_fail(State(state): State<ServerState>) -> Response<Body> {
state.requests.fetch_add(1, Ordering::SeqCst);
response(StatusCode::INTERNAL_SERVER_ERROR, Body::empty(), 0, None)
}
fn response(
status: StatusCode,
body: Body,
content_length: usize,
content_range: Option<String>,
) -> Response<Body> {
let mut response = Response::new(body);
*response.status_mut() = status;
response
.headers_mut()
.insert(ACCEPT_RANGES, HeaderValue::from_static("bytes"));
response.headers_mut().insert(
CONTENT_LENGTH,
HeaderValue::from_str(&content_length.to_string()).unwrap(),
);
if let Some(content_range) = content_range {
response.headers_mut().insert(
CONTENT_RANGE,
HeaderValue::from_str(&content_range).unwrap(),
);
}
response
}
fn parse_range_start(value: &str) -> Option<usize> {
value
.strip_prefix("bytes=")?
.strip_suffix('-')?
.parse()
.ok()
}
fn fixture_data(size: usize) -> Vec<u8> {
(0..size)
.map(|index| ((index.wrapping_mul(31) + index / 7) % 251) as u8)
.collect()
}
fn payload(id: Uuid, url: String, output_path: std::path::PathBuf) -> DownloadPayload {
DownloadPayload {
id,
urls: vec![url],
output_path,
speed_limit: None,
username: None,
password: None,
headers: None,
cookies: None,
user_agent: Some("Firelink integration test".to_string()),
max_tries: 1,
proxy: None,
}
}
async fn next_event(events: &mut mpsc::UnboundedReceiver<DownloadEvent>) -> DownloadEvent {
tokio::time::timeout(TEST_TIMEOUT, events.recv())
.await
.expect("timed out waiting for download event")
.expect("download event channel closed")
}
async fn wait_for_progress(
events: &mut mpsc::UnboundedReceiver<DownloadEvent>,
id: Uuid,
minimum_completed: u64,
) {
loop {
match next_event(events).await {
DownloadEvent::Progress {
id: event_id,
completed,
..
} if event_id == id && completed >= minimum_completed => return,
DownloadEvent::Failed { error, .. } => panic!("download failed: {error}"),
DownloadEvent::Completed(event_id) if event_id == id => {
panic!("download completed before it could be paused")
}
_ => {}
}
}
}
async fn wait_for_completion(
events: &mut mpsc::UnboundedReceiver<DownloadEvent>,
id: Uuid,
) -> Vec<DownloadEvent> {
let mut observed = Vec::new();
loop {
let event = next_event(events).await;
match &event {
DownloadEvent::Completed(event_id) if *event_id == id => {
observed.push(event);
return observed;
}
DownloadEvent::Failed {
id: event_id,
error,
} if *event_id == id => panic!("download failed: {error}"),
_ => observed.push(event),
}
}
}
fn sha256(data: &[u8]) -> Vec<u8> {
Sha256::digest(data).to_vec()
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn pause_then_resume_uses_range_and_preserves_integrity() {
let expected = fixture_data(4 * 1024 * 1024);
let server = TestServer::spawn(expected.clone(), 16 * 1024, Duration::from_millis(4), 0).await;
let temp = TempDir::new().unwrap();
let output_path = temp.path().join("paused.bin");
let id = Uuid::from_u128(1);
let download = payload(id, server.file_url(), output_path.clone());
let (coordinator, mut events) = DownloadCoordinator::spawn_headless();
coordinator
.send(DownloadCmd::Start(Box::new(download)))
.await
.unwrap();
wait_for_progress(&mut events, id, 256 * 1024).await;
let (pause_tx, pause_rx) = oneshot::channel();
coordinator
.send(DownloadCmd::PauseWithAck(id, pause_tx))
.await
.unwrap();
pause_rx.await.unwrap();
let paused_len = tokio::fs::metadata(&output_path).await.unwrap().len();
tokio::time::sleep(Duration::from_millis(100)).await;
let stable_paused_len = tokio::fs::metadata(&output_path).await.unwrap().len();
assert!(paused_len >= 256 * 1024);
assert!(paused_len < expected.len() as u64);
assert_eq!(stable_paused_len, paused_len);
coordinator
.send(DownloadCmd::Start(Box::new(payload(
id,
server.file_url(),
output_path.clone(),
))))
.await
.unwrap();
wait_for_completion(&mut events, id).await;
let downloaded = tokio::fs::read(&output_path).await.unwrap();
assert_eq!(downloaded.len(), expected.len());
assert_eq!(sha256(&downloaded), sha256(&expected));
let range_starts = server.state.range_starts.lock().unwrap().clone();
assert_eq!(range_starts.first(), Some(&None));
assert!(
range_starts
.iter()
.skip(1)
.flatten()
.any(|start| *start == paused_len as usize),
"resume request did not start at the paused file length: {range_starts:?}"
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn aggregates_many_http_chunks_with_complete_progress() {
let expected = fixture_data(3 * 1024 * 1024);
let server = TestServer::spawn(expected.clone(), 8 * 1024, Duration::ZERO, 0).await;
let temp = TempDir::new().unwrap();
let output_path = temp.path().join("chunked.bin");
let id = Uuid::from_u128(2);
let (coordinator, mut events) = DownloadCoordinator::spawn_headless();
let started = Instant::now();
coordinator
.send(DownloadCmd::Start(Box::new(payload(
id,
server.file_url(),
output_path.clone(),
))))
.await
.unwrap();
let observed = wait_for_completion(&mut events, id).await;
assert!(
started.elapsed() < Duration::from_secs(5),
"local 3 MiB transfer exceeded the performance budget"
);
assert!(server.state.chunks_served.load(Ordering::SeqCst) > 100);
let final_progress = observed.iter().rev().find_map(|event| match event {
DownloadEvent::Progress {
id: event_id,
fraction,
completed,
total,
} if *event_id == id => Some((*fraction, *completed, *total)),
_ => None,
});
assert_eq!(
final_progress,
Some((1.0, expected.len() as u64, Some(expected.len() as u64)))
);
assert_eq!(
sha256(&tokio::fs::read(output_path).await.unwrap()),
sha256(&expected)
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn retries_transient_http_failures_then_completes() {
let expected = fixture_data(512 * 1024);
let server = TestServer::spawn(expected.clone(), 32 * 1024, Duration::ZERO, 2).await;
let temp = TempDir::new().unwrap();
let output_path = temp.path().join("retry.bin");
let id = Uuid::from_u128(3);
let (coordinator, mut events) = DownloadCoordinator::spawn_headless();
let mut download = payload(id, server.file_url(), output_path.clone());
download.max_tries = 3;
coordinator
.send(DownloadCmd::Start(Box::new(download)))
.await
.unwrap();
wait_for_completion(&mut events, id).await;
assert_eq!(server.state.requests.load(Ordering::SeqCst), 3);
assert_eq!(
sha256(&tokio::fs::read(output_path).await.unwrap()),
sha256(&expected)
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn reports_terminal_http_errors_after_retry_budget() {
let server = TestServer::spawn(Vec::new(), 8 * 1024, Duration::ZERO, 0).await;
let temp = TempDir::new().unwrap();
let output_path = temp.path().join("failed.bin");
let id = Uuid::from_u128(4);
let (coordinator, mut events) = DownloadCoordinator::spawn_headless();
let mut download = payload(id, server.failure_url(), output_path.clone());
download.max_tries = 2;
coordinator
.send(DownloadCmd::Start(Box::new(download)))
.await
.unwrap();
let error = loop {
match next_event(&mut events).await {
DownloadEvent::Failed {
id: event_id,
error,
} if event_id == id => break error,
DownloadEvent::Completed(event_id) if event_id == id => {
panic!("failed download was reported as complete")
}
_ => {}
}
};
assert!(error.contains("500 Internal Server Error"));
assert_eq!(server.state.requests.load(Ordering::SeqCst), 3);
assert!(!output_path.exists());
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn cancel_removes_partial_file_without_terminal_success_event() {
let expected = fixture_data(4 * 1024 * 1024);
let server = TestServer::spawn(expected, 16 * 1024, Duration::from_millis(4), 0).await;
let temp = TempDir::new().unwrap();
let output_path = temp.path().join("cancelled.bin");
let id = Uuid::from_u128(5);
let (coordinator, mut events) = DownloadCoordinator::spawn_headless();
coordinator
.send(DownloadCmd::Start(Box::new(payload(
id,
server.file_url(),
output_path.clone(),
))))
.await
.unwrap();
wait_for_progress(&mut events, id, 256 * 1024).await;
let (cancelled_tx, cancelled_rx) = tokio::sync::oneshot::channel();
coordinator
.send(DownloadCmd::CancelWithAck(id, cancelled_tx))
.await
.unwrap();
cancelled_rx.await.unwrap();
tokio::time::timeout(TEST_TIMEOUT, async {
while output_path.exists() {
tokio::time::sleep(Duration::from_millis(10)).await;
}
})
.await
.expect("cancelled partial file was not removed");
while let Ok(Some(event)) =
tokio::time::timeout(Duration::from_millis(100), events.recv()).await
{
assert!(
!matches!(
event,
DownloadEvent::Completed(event_id) if event_id == id
),
"cancelled download emitted a completion event"
);
assert!(
!matches!(
event,
DownloadEvent::Failed { id: event_id, .. } if event_id == id
),
"cancelled download emitted a failure event"
);
}
}
+8 -22
View File
@@ -12,7 +12,6 @@ use tokio::time::timeout;
struct CountingSpawner {
add_uri_calls: AtomicUsize,
media_calls: AtomicUsize,
native_calls: AtomicUsize,
}
struct DelayedAria2Spawner {
@@ -47,10 +46,6 @@ impl SidecarSpawner for DelayedAria2Spawner {
async fn run_media(&self, _id: &str, _payload: &SpawnPayload) -> Result<(), String> {
unreachable!("media is not used by delayed aria2 tests")
}
async fn run_native(&self, _id: &str, _payload: &SpawnPayload) -> Result<(), String> {
unreachable!("native is not used by delayed aria2 tests")
}
}
impl CountingSpawner {
@@ -58,7 +53,6 @@ impl CountingSpawner {
Self {
add_uri_calls: AtomicUsize::new(0),
media_calls: AtomicUsize::new(0),
native_calls: AtomicUsize::new(0),
}
}
}
@@ -76,10 +70,6 @@ impl firelink_lib::queue::SidecarSpawner for CountingSpawner {
self.media_calls.fetch_add(1, Ordering::SeqCst);
Ok(())
}
async fn run_native(&self, _id: &str, _payload: &SpawnPayload) -> Result<(), String> {
self.native_calls.fetch_add(1, Ordering::SeqCst);
Ok(())
}
}
/// Build a QueueManager with a fake spawner. Tauri's mock AppHandle is needed
@@ -97,7 +87,7 @@ fn sample_task(id: &str) -> QueuedTask {
QueuedTask {
id: id.to_string(),
queue_id: "main".to_string(),
kind: TaskKind::Native,
kind: TaskKind::Aria2,
payload: SpawnPayload::default(),
}
}
@@ -243,18 +233,18 @@ async fn grow_releases_immediately_and_dispatches_waiting_tasks() {
// Give dispatcher time to dispatch 2 (capacity) of the 4.
tokio::time::sleep(Duration::from_millis(100)).await;
let native_after_initial = spawner.native_calls.load(Ordering::SeqCst);
let aria2_after_initial = spawner.add_uri_calls.load(Ordering::SeqCst);
assert_eq!(
native_after_initial, 2,
aria2_after_initial, 2,
"only capacity-many tasks dispatch initially"
);
// Grow to 4; the remaining 2 should dispatch.
mgr_arc.set_capacity(4);
tokio::time::sleep(Duration::from_millis(100)).await;
let native_after_grow = spawner.native_calls.load(Ordering::SeqCst);
let aria2_after_grow = spawner.add_uri_calls.load(Ordering::SeqCst);
assert_eq!(
native_after_grow, 4,
aria2_after_grow, 4,
"grow must allow the waiting tasks to dispatch"
);
@@ -276,7 +266,7 @@ async fn shrink_converges_to_target_without_killing_active() {
// Let 4 dispatch (capacity).
tokio::time::sleep(Duration::from_millis(100)).await;
assert_eq!(spawner.native_calls.load(Ordering::SeqCst), 4);
assert_eq!(spawner.add_uri_calls.load(Ordering::SeqCst), 4);
// Shrink to 2 while 4 are "active" (permits parked).
mgr_arc.set_capacity(2);
@@ -288,7 +278,7 @@ async fn shrink_converges_to_target_without_killing_active() {
tokio::time::sleep(Duration::from_millis(100)).await;
assert_eq!(
spawner.native_calls.load(Ordering::SeqCst),
spawner.add_uri_calls.load(Ordering::SeqCst),
4,
"pending tasks must not dispatch while active count already meets the shrunken target"
);
@@ -298,7 +288,7 @@ async fn shrink_converges_to_target_without_killing_active() {
tokio::time::sleep(Duration::from_millis(100)).await;
assert_eq!(
spawner.native_calls.load(Ordering::SeqCst),
spawner.add_uri_calls.load(Ordering::SeqCst),
6,
"pending tasks dispatch after active count falls below the shrunken target"
);
@@ -372,10 +362,6 @@ impl SidecarSpawner for FixedMediaSpawner {
async fn run_media(&self, _id: &str, _payload: &SpawnPayload) -> Result<(), String> {
self.outcome.clone()
}
async fn run_native(&self, _id: &str, _payload: &SpawnPayload) -> Result<(), String> {
unreachable!("native downloads are not used by media terminal-state tests")
}
}
fn make_media_manager(
+20 -1
View File
@@ -22,6 +22,7 @@ import { useToast } from "./contexts/ToastContext";
import { openUrl } from '@tauri-apps/plugin-opener';
import { usePlatformInfo } from './utils/platform';
import type { PostQueueAction } from './bindings/PostQueueAction';
import { PanelLeft } from 'lucide-react';
let automaticUpdateCheckStarted = false;
const processingScheduleKeys = new Set<string>();
@@ -86,6 +87,7 @@ function App() {
const theme = useSettingsStore(state => state.theme);
const isSidebarVisible = useSettingsStore(state => state.isSidebarVisible);
const toggleSidebar = useSettingsStore(state => state.toggleSidebar);
const activeView = useSettingsStore(state => state.activeView);
const appFontSize = useSettingsStore(state => state.appFontSize);
const listRowDensity = useSettingsStore(state => state.listRowDensity);
@@ -114,6 +116,8 @@ function App() {
download.status === 'retrying'
).length;
const { addToast } = useToast();
const isMacUserAgent = navigator.userAgent.includes('Mac');
const usesCustomWindowControls = !isMacUserAgent && platform.os !== 'macos';
const acknowledgePairingTokenChange = () => {
invoke('acknowledge_pairing_token_change').catch(error => {
@@ -666,7 +670,22 @@ function App() {
/>
</div>
<div className="app-workspace relative z-0 flex-1 flex flex-col h-full overflow-hidden">
<div
className={`app-workspace relative z-0 flex-1 flex flex-col h-full overflow-hidden ${
!isSidebarVisible ? 'app-workspace--sidebar-collapsed' : ''
} ${usesCustomWindowControls ? 'app-workspace--custom-window-controls' : ''}`}
>
{!isSidebarVisible && (
<button
type="button"
onClick={toggleSidebar}
className="app-icon-button app-sidebar-reveal-button h-7 w-7"
title="Show Sidebar"
aria-label="Show Sidebar"
>
<PanelLeft size={16} strokeWidth={2} />
</button>
)}
<div className="flex-1 flex flex-col overflow-hidden relative">
{activeView === 'downloads' && <DownloadTable filter={filter} />}
{activeView === 'settings' && <SettingsView />}

Some files were not shown because too many files have changed in this diff Show More