mirror of
https://github.com/nimbold/Firelink.git
synced 2026-07-27 04:19:19 +00:00
Compare commits
30 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6d9ef68bb0 | |||
| 51258b25bc | |||
| 1baadf6ea6 | |||
| 60dad5703a | |||
| b8ef712981 | |||
| 3b9faa789c | |||
| 161b0028f9 | |||
| f84726a403 | |||
| e52e885193 | |||
| 5fc3c9f965 | |||
| 800b94f9c0 | |||
| 6b5384f261 | |||
| 607d193f8e | |||
| f711c6b7a4 | |||
| e30eff4d2e | |||
| 85ad9d18e8 | |||
| 036ff02dac | |||
| 99f73eaae2 | |||
| 5ea46024e9 | |||
| a2a3b08d4e | |||
| 5ef0b3b5f6 | |||
| 515ba611ce | |||
| 579174c5b5 | |||
| c13f993150 | |||
| 017d9cd879 | |||
| dabd252ab7 | |||
| 3da73c623f | |||
| 757e313f71 | |||
| 3aaf32f9ee | |||
| 58bbf95761 |
@@ -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 |
@@ -6,13 +6,9 @@ on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
publish_release:
|
||||
description: 'Publish the GitHub release after all platform QA is certified'
|
||||
description: 'Publish the GitHub release after all platform build and verification jobs pass'
|
||||
type: boolean
|
||||
default: false
|
||||
certified_cross_platform:
|
||||
description: 'Confirm Windows, Linux, and macOS clean-machine QA passed'
|
||||
type: boolean
|
||||
default: false
|
||||
default: true
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
@@ -91,6 +87,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
|
||||
@@ -133,10 +132,11 @@ jobs:
|
||||
publish:
|
||||
name: Publish GitHub release
|
||||
if: >-
|
||||
github.event_name == 'workflow_dispatch' &&
|
||||
startsWith(github.ref, 'refs/tags/v') &&
|
||||
inputs.publish_release &&
|
||||
inputs.certified_cross_platform
|
||||
(
|
||||
github.event_name == 'push' ||
|
||||
(github.event_name == 'workflow_dispatch' && inputs.publish_release)
|
||||
)
|
||||
needs: build
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
@@ -151,10 +151,29 @@ jobs:
|
||||
VERSION="${GITHUB_REF_NAME#v}"
|
||||
awk '/^## \['"$VERSION"'\]/{flag=1; next} /^## \[/{if(flag) exit} flag' CHANGELOG.md > release_notes.md
|
||||
test -s release_notes.md
|
||||
- name: Normalize release asset names
|
||||
shell: bash
|
||||
run: |
|
||||
VERSION="${GITHUB_REF_NAME#v}"
|
||||
rename_asset() {
|
||||
local pattern="$1"
|
||||
local destination="$2"
|
||||
local source
|
||||
source="$(find release-assets -maxdepth 1 -type f -name "$pattern" -print -quit)"
|
||||
if [[ -z "$source" ]]; then
|
||||
echo "::error::Missing release asset matching $pattern"
|
||||
exit 1
|
||||
fi
|
||||
mv "$source" "release-assets/$destination"
|
||||
}
|
||||
|
||||
rename_asset '*.dmg' "Firelink_${VERSION}_macOS-ARM64.dmg"
|
||||
rename_asset '*.AppImage' "Firelink_${VERSION}_Linux-x64.AppImage"
|
||||
rename_asset '*.exe' "Firelink_${VERSION}_Windows-x64-setup.exe"
|
||||
- name: Generate checksums
|
||||
run: |
|
||||
cd release-assets
|
||||
find . -type f -print0 | sort -z | xargs -0 sha256sum > SHA256SUMS
|
||||
find . -type f ! -name SHA256SUMS -print0 | sort -z | xargs -0 sha256sum > SHA256SUMS
|
||||
- uses: softprops/action-gh-release@v3
|
||||
with:
|
||||
files: release-assets/**
|
||||
|
||||
@@ -5,6 +5,42 @@ 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.3] - 2026-07-09
|
||||
|
||||
### Improved
|
||||
- Refresh bundled Deno to 2.9.2 and update the TypeScript build toolchain used by the desktop app.
|
||||
- Keep release publishing aligned with the changelog so tag builds publish GitHub release notes automatically after the platform builds pass.
|
||||
|
||||
### Fixed
|
||||
- Fix YouTube and other yt-dlp downloads that appeared stuck at 0% even while the transfer was active, addressing the progress problem reported around [#8](https://github.com/nimbold/Firelink/issues/8).
|
||||
- Improve media download speed and ETA updates so short stalls no longer make the main list look frozen or misleading.
|
||||
- Keep media progress sizes from replacing the final file size until the completed file is actually known.
|
||||
|
||||
## [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
|
||||
- Fix custom window controls on Windows and Linux so close, minimize, and maximize work in packaged builds.
|
||||
|
||||
## [1.0.0] - 2026-07-04
|
||||
|
||||
### Highlights
|
||||
|
||||
+1
-1
Submodule Extensions/Firefox updated: 42b28fa4f6...be632f368c
@@ -5,13 +5,10 @@
|
||||
|
||||
**A fast, focused desktop download manager for macOS, Windows, and Linux.**
|
||||
|
||||
[](https://github.com/nimbold/Firelink/releases)
|
||||
[](https://github.com/nimbold/Firelink/releases)
|
||||
[](#platforms)
|
||||
[](#platforms)
|
||||
[](#platforms)
|
||||
[](#platforms)
|
||||
[](https://tauri.app/)
|
||||
[](https://www.rust-lang.org/)
|
||||
[](https://react.dev/)
|
||||
[](LICENSE)
|
||||
[](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, Deno, live progress, speed, and ETA.
|
||||
- **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>
|
||||
|
||||
<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.3.
|
||||
|
||||
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
|
||||
|
||||
+18
-18
@@ -3,19 +3,19 @@
|
||||
"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",
|
||||
"url": "https://github.com/denoland/deno/releases/download/v2.9.1/deno-x86_64-pc-windows-msvc.zip",
|
||||
"sha256": "ab310b4232cca207d40ffa41867e93aaf9f893802bc76756e74f486a6b21b371"
|
||||
"version": "2.9.2",
|
||||
"url": "https://github.com/denoland/deno/releases/download/v2.9.2/deno-x86_64-pc-windows-msvc.zip",
|
||||
"sha256": "5fe194d26ac5ef77fcc5288c2c438c7a0465f3b6180440ebf04092714bf2dcdf"
|
||||
},
|
||||
"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,19 +25,19 @@
|
||||
},
|
||||
"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",
|
||||
"url": "https://github.com/denoland/deno/releases/download/v2.9.1/deno-x86_64-unknown-linux-gnu.zip",
|
||||
"sha256": "710c54d63477d1100844ef4818f19507ce0dbf40510903b1d883f19e394446a2"
|
||||
"version": "2.9.2",
|
||||
"url": "https://github.com/denoland/deno/releases/download/v2.9.2/deno-x86_64-unknown-linux-gnu.zip",
|
||||
"sha256": "934d1bd5cb09eaed7f2e4a4fc58208d04a3c5c0fcde9f319d93d735265c67a4a"
|
||||
},
|
||||
"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",
|
||||
|
||||
+7
-7
@@ -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,22 +16,22 @@
|
||||
"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",
|
||||
"version": "2.9.2",
|
||||
"source": "https://github.com/denoland/deno",
|
||||
"build": "official aarch64-apple-darwin executable",
|
||||
"sha256": "df5e2cca5253ec99b9a630fb059bf01cdbbf586fed1f344017875c5462b2c483"
|
||||
"sha256": "218ab752ae8f64f0a7822af710886488f15169fdae153a3aada4861f9635b266"
|
||||
}
|
||||
},
|
||||
"runtimeTrees": {
|
||||
"_internal": {
|
||||
"files": 142,
|
||||
"sha256": "3fabc08e6367f9393cfee32c32138f057f0e6068e430190c26a22ddfea84242b"
|
||||
"sha256": "769507d9b8d97164ef81ebb449873072697bf82acac8c7d61c7bfd96c551e210"
|
||||
},
|
||||
"aria2-libs": {
|
||||
"files": 7,
|
||||
|
||||
Generated
+415
-54
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "firelink",
|
||||
"version": "1.0.0",
|
||||
"version": "1.0.3",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "firelink",
|
||||
"version": "1.0.0",
|
||||
"version": "1.0.3",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@formkit/auto-animate": "^0.9.0",
|
||||
@@ -31,9 +31,9 @@
|
||||
"autoprefixer": "^10.5.2",
|
||||
"postcss": "^8.5.15",
|
||||
"tailwindcss": "^4.3.1",
|
||||
"typescript": "^6.0.3",
|
||||
"typescript": "^7.0.2",
|
||||
"vite": "^8.1.3",
|
||||
"vitest": "^4.1.9"
|
||||
"vitest": "^4.1.10"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22"
|
||||
@@ -996,6 +996,346 @@
|
||||
"@types/react": "^19.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-aix-ppc64": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz",
|
||||
"integrity": "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"aix"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-darwin-arm64": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-arm64/-/typescript-darwin-arm64-7.0.2.tgz",
|
||||
"integrity": "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-darwin-x64": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-x64/-/typescript-darwin-x64-7.0.2.tgz",
|
||||
"integrity": "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-freebsd-arm64": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-arm64/-/typescript-freebsd-arm64-7.0.2.tgz",
|
||||
"integrity": "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-freebsd-x64": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-x64/-/typescript-freebsd-x64-7.0.2.tgz",
|
||||
"integrity": "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-linux-arm": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm/-/typescript-linux-arm-7.0.2.tgz",
|
||||
"integrity": "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-linux-arm64": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm64/-/typescript-linux-arm64-7.0.2.tgz",
|
||||
"integrity": "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-linux-loong64": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-loong64/-/typescript-linux-loong64-7.0.2.tgz",
|
||||
"integrity": "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==",
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-linux-mips64el": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-mips64el/-/typescript-linux-mips64el-7.0.2.tgz",
|
||||
"integrity": "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==",
|
||||
"cpu": [
|
||||
"mips64el"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-linux-ppc64": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-ppc64/-/typescript-linux-ppc64-7.0.2.tgz",
|
||||
"integrity": "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-linux-riscv64": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-riscv64/-/typescript-linux-riscv64-7.0.2.tgz",
|
||||
"integrity": "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-linux-s390x": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-s390x/-/typescript-linux-s390x-7.0.2.tgz",
|
||||
"integrity": "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-linux-x64": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-x64/-/typescript-linux-x64-7.0.2.tgz",
|
||||
"integrity": "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-netbsd-arm64": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-arm64/-/typescript-netbsd-arm64-7.0.2.tgz",
|
||||
"integrity": "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"netbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-netbsd-x64": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-x64/-/typescript-netbsd-x64-7.0.2.tgz",
|
||||
"integrity": "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"netbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-openbsd-arm64": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-arm64/-/typescript-openbsd-arm64-7.0.2.tgz",
|
||||
"integrity": "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-openbsd-x64": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-x64/-/typescript-openbsd-x64-7.0.2.tgz",
|
||||
"integrity": "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-sunos-x64": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-sunos-x64/-/typescript-sunos-x64-7.0.2.tgz",
|
||||
"integrity": "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"sunos"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-win32-arm64": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-win32-arm64/-/typescript-win32-arm64-7.0.2.tgz",
|
||||
"integrity": "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-win32-x64": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-win32-x64/-/typescript-win32-x64-7.0.2.tgz",
|
||||
"integrity": "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitejs/plugin-react": {
|
||||
"version": "6.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.3.tgz",
|
||||
@@ -1023,16 +1363,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 +1381,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 +1408,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 +1421,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 +1435,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 +1451,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 +1461,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"
|
||||
},
|
||||
@@ -1929,17 +2269,38 @@
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "6.0.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz",
|
||||
"integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==",
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz",
|
||||
"integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
"tsc": "bin/tsc"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.17"
|
||||
"node": ">=16.20.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@typescript/typescript-aix-ppc64": "7.0.2",
|
||||
"@typescript/typescript-darwin-arm64": "7.0.2",
|
||||
"@typescript/typescript-darwin-x64": "7.0.2",
|
||||
"@typescript/typescript-freebsd-arm64": "7.0.2",
|
||||
"@typescript/typescript-freebsd-x64": "7.0.2",
|
||||
"@typescript/typescript-linux-arm": "7.0.2",
|
||||
"@typescript/typescript-linux-arm64": "7.0.2",
|
||||
"@typescript/typescript-linux-loong64": "7.0.2",
|
||||
"@typescript/typescript-linux-mips64el": "7.0.2",
|
||||
"@typescript/typescript-linux-ppc64": "7.0.2",
|
||||
"@typescript/typescript-linux-riscv64": "7.0.2",
|
||||
"@typescript/typescript-linux-s390x": "7.0.2",
|
||||
"@typescript/typescript-linux-x64": "7.0.2",
|
||||
"@typescript/typescript-netbsd-arm64": "7.0.2",
|
||||
"@typescript/typescript-netbsd-x64": "7.0.2",
|
||||
"@typescript/typescript-openbsd-arm64": "7.0.2",
|
||||
"@typescript/typescript-openbsd-x64": "7.0.2",
|
||||
"@typescript/typescript-sunos-x64": "7.0.2",
|
||||
"@typescript/typescript-win32-arm64": "7.0.2",
|
||||
"@typescript/typescript-win32-x64": "7.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/update-browserslist-db": {
|
||||
@@ -2051,19 +2412,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 +2452,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"
|
||||
|
||||
+4
-3
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "firelink",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"version": "1.0.3",
|
||||
"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"
|
||||
@@ -59,8 +60,8 @@
|
||||
"autoprefixer": "^10.5.2",
|
||||
"postcss": "^8.5.15",
|
||||
"tailwindcss": "^4.3.1",
|
||||
"typescript": "^6.0.3",
|
||||
"typescript": "^7.0.2",
|
||||
"vite": "^8.1.3",
|
||||
"vitest": "^4.1.9"
|
||||
"vitest": "^4.1.10"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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.');
|
||||
@@ -0,0 +1,26 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import test from 'node:test';
|
||||
|
||||
const capability = JSON.parse(fs.readFileSync('src-tauri/capabilities/default.json', 'utf8'));
|
||||
const permissions = new Set(capability.permissions);
|
||||
|
||||
test('custom window controls have required Tauri permissions', () => {
|
||||
const windowControls = fs.readFileSync('src/components/WindowControls.tsx', 'utf8');
|
||||
const requiredPermissions = new Map([
|
||||
['.close()', 'core:window:allow-close'],
|
||||
['.minimize()', 'core:window:allow-minimize'],
|
||||
['.startDragging()', 'core:window:allow-start-dragging'],
|
||||
['.toggleMaximize()', 'core:window:allow-toggle-maximize'],
|
||||
]);
|
||||
|
||||
for (const [apiCall, permission] of requiredPermissions) {
|
||||
if (windowControls.includes(apiCall)) {
|
||||
assert.equal(
|
||||
permissions.has(permission),
|
||||
true,
|
||||
`${apiCall} requires ${permission} in src-tauri/capabilities/default.json`
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
Generated
+228
-113
@@ -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.0"
|
||||
version = "1.0.3"
|
||||
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",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "firelink"
|
||||
version = "1.0.0"
|
||||
version = "1.0.3"
|
||||
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>
|
||||
|
||||
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>
|
||||
|
||||
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>
|
||||
|
||||
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.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -5,7 +5,10 @@
|
||||
"windows": ["main"],
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"core:window:allow-close",
|
||||
"core:window:allow-minimize",
|
||||
"core:window:allow-start-dragging",
|
||||
"core:window:allow-toggle-maximize",
|
||||
"opener:default",
|
||||
"dialog:default",
|
||||
"log:default",
|
||||
|
||||
+3
-609
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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>,
|
||||
|
||||
+643
-290
File diff suppressed because it is too large
Load Diff
+446
-11
@@ -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]
|
||||
|
||||
@@ -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
@@ -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, ¶ms).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
@@ -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"
|
||||
));
|
||||
|
||||
@@ -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,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Firelink",
|
||||
"version": "1.0.0",
|
||||
"version": "1.0.3",
|
||||
"identifier": "com.nimbold.firelink",
|
||||
"build": {
|
||||
"beforeDevCommand": "node scripts/stage-engines.js && npm run dev",
|
||||
|
||||
@@ -22,6 +22,10 @@
|
||||
]
|
||||
},
|
||||
"bundle": {
|
||||
"targets": ["app", "dmg"]
|
||||
"targets": ["app", "dmg"],
|
||||
"macOS": {
|
||||
"signingIdentity": "-",
|
||||
"hardenedRuntime": true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,12 @@
|
||||
]
|
||||
},
|
||||
"bundle": {
|
||||
"targets": ["nsis"]
|
||||
"targets": ["nsis"],
|
||||
"windows": {
|
||||
"nsis": {
|
||||
"installerIcon": "icons/icon.ico",
|
||||
"uninstallerIcon": "icons/icon.ico"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+11
-13
@@ -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
|
||||
```
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user