mirror of
https://github.com/nimbold/Firelink.git
synced 2026-07-27 04:19:19 +00:00
Compare commits
79 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| df3bc359c4 | |||
| 57f132f53e | |||
| 52b00e5cb4 | |||
| 8e02a61c3f | |||
| 82f2914077 | |||
| 9224117d77 | |||
| 3c84b5c1a4 | |||
| c34c489aef | |||
| b81e8391e1 | |||
| 6ff0047d6c | |||
| 4a3fece22b | |||
| 1da0fa7223 | |||
| d6af4ee2b5 | |||
| 2479ead4ed | |||
| 80a29356e0 | |||
| 45bbca0515 | |||
| e35b1af731 | |||
| 19953a210e | |||
| 76850f2433 | |||
| 0eee47b97f | |||
| e8487ee71b | |||
| e07182fbf2 | |||
| 2d9eed99d5 | |||
| 80cf835060 | |||
| c78a72a8d7 | |||
| df85a77987 | |||
| be2a98fbcd | |||
| fcfdffa6e0 | |||
| 9805c9288a | |||
| dad5b7bc5e | |||
| f441c687f0 | |||
| a0f44b79ad | |||
| 56b4c9f511 | |||
| c1fa87b953 | |||
| c7ec8cd666 | |||
| 9133e3b05b | |||
| 5bbee12602 | |||
| 7894c05bba | |||
| 33375df2ff | |||
| 1922db8ea0 | |||
| ba70662165 | |||
| 629a34d1e8 | |||
| 248f3869ad | |||
| 4f4c655de6 | |||
| 3fbd0742be | |||
| c5025fd5a0 | |||
| b1c84a0fb9 | |||
| fbb89cde8e | |||
| cd8ab5c12b | |||
| ed7c47cb49 | |||
| 8c035167c8 | |||
| 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 |
@@ -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 |
+104
-11
@@ -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
|
||||
@@ -65,16 +61,39 @@ jobs:
|
||||
libdbus-1-dev \
|
||||
pkg-config \
|
||||
xvfb \
|
||||
libtinfo5
|
||||
libtinfo5 \
|
||||
rpm \
|
||||
cpio \
|
||||
libarchive-tools \
|
||||
desktop-file-utils \
|
||||
xdg-utils
|
||||
- run: npm ci
|
||||
- name: Provision locked engines
|
||||
if: runner.os != 'macOS'
|
||||
run: node scripts/provision-engines.js --target ${{ matrix.target }}
|
||||
- name: Build package
|
||||
if: runner.os != 'Linux'
|
||||
run: npm run tauri build -- -vv --target ${{ matrix.target }} --bundles ${{ matrix.bundles }}
|
||||
env:
|
||||
APPIMAGE_EXTRACT_AND_RUN: 1
|
||||
FIRELINK_OMIT_ENGINE_DIST_FOR_TAURI_BUNDLE: ${{ runner.os == 'Linux' && '1' || '' }}
|
||||
- name: Build Linux native packages
|
||||
if: runner.os == 'Linux'
|
||||
run: npm run tauri build -- -vv --target ${{ matrix.target }} --bundles deb,rpm
|
||||
env:
|
||||
APPIMAGE_EXTRACT_AND_RUN: 1
|
||||
- name: Verify and preserve Linux native packages
|
||||
if: runner.os == 'Linux'
|
||||
run: |
|
||||
node scripts/verify-linux-packages.js --target ${{ matrix.target }}
|
||||
mkdir -p "$RUNNER_TEMP/firelink-native-packages/deb" "$RUNNER_TEMP/firelink-native-packages/rpm"
|
||||
cp src-tauri/target/${{ matrix.target }}/release/bundle/deb/*.deb "$RUNNER_TEMP/firelink-native-packages/deb/"
|
||||
cp src-tauri/target/${{ matrix.target }}/release/bundle/rpm/*.rpm "$RUNNER_TEMP/firelink-native-packages/rpm/"
|
||||
- name: Build Linux AppImage
|
||||
if: runner.os == 'Linux'
|
||||
run: npm run tauri build -- -vv --target ${{ matrix.target }} --bundles appimage
|
||||
env:
|
||||
APPIMAGE_EXTRACT_AND_RUN: 1
|
||||
FIRELINK_OMIT_ENGINE_DIST_FOR_TAURI_BUNDLE: '1'
|
||||
- name: Install pinned appimagetool (Linux only)
|
||||
if: runner.os == 'Linux'
|
||||
env:
|
||||
@@ -91,6 +110,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
|
||||
@@ -103,6 +125,52 @@ jobs:
|
||||
Remove-Item -Recurse -Force $extractRoot -ErrorAction SilentlyContinue
|
||||
7z x $installer.FullName "-o$extractRoot" -y
|
||||
node scripts/verify-binaries.js --search-root "$extractRoot" --target ${{ matrix.target }}
|
||||
|
||||
$portableRoot = "$env:RUNNER_TEMP/firelink-portable-payload"
|
||||
$portableArtifactDir = "$env:RUNNER_TEMP/firelink-portable"
|
||||
Remove-Item -Recurse -Force $portableRoot -ErrorAction SilentlyContinue
|
||||
Remove-Item -Recurse -Force $portableArtifactDir -ErrorAction SilentlyContinue
|
||||
New-Item -ItemType Directory -Path $portableRoot -Force | Out-Null
|
||||
New-Item -ItemType Directory -Path $portableArtifactDir -Force | Out-Null
|
||||
$payloadExe = Get-ChildItem $extractRoot -Recurse -File -ErrorAction SilentlyContinue |
|
||||
Where-Object { $_.Name -ieq "firelink.exe" } |
|
||||
Sort-Object FullName |
|
||||
Select-Object -First 1
|
||||
if (-not $payloadExe) { throw "firelink.exe was not found in the extracted installer payload." }
|
||||
Copy-Item (Join-Path $payloadExe.Directory.FullName '*') $portableRoot -Recurse -Force
|
||||
Set-Content -Path (Join-Path $portableRoot 'portable.flag') -Value 'portable' -NoNewline
|
||||
@"
|
||||
Firelink portable
|
||||
|
||||
Extract this folder to a writable location and launch firelink.exe.
|
||||
Close Firelink before copying or moving this folder.
|
||||
Settings, queues, logs, and WebView data are stored under data\.
|
||||
Only one Firelink instance can run at a time; close the installed app before launching this copy.
|
||||
Credentials, browser cookies, and URL query/fragment data are not persisted in portable queue records.
|
||||
Saved site passwords remain in the Windows credential store and are not portable.
|
||||
The portable folder contains the extension pairing credential; treat it as sensitive and do not share it.
|
||||
Saved absolute download locations may need to be selected again after moving to another drive.
|
||||
The portable archive does not register the firelink:// protocol; use the installer for browser launch integration.
|
||||
"@ | Set-Content -Path (Join-Path $portableRoot 'PORTABLE_README.txt')
|
||||
New-Item -ItemType Directory -Path (Join-Path $portableRoot 'data') -Force | Out-Null
|
||||
$portableExe = Join-Path $portableRoot 'firelink.exe'
|
||||
node scripts/verify-binaries.js --search-root "$portableRoot" --target ${{ matrix.target }}
|
||||
node scripts/smoke-packaged-app.js --executable $portableExe --assert-no-visible-child-windows --assert-portable-data
|
||||
Get-ChildItem $portableRoot -Recurse -File -ErrorAction SilentlyContinue |
|
||||
Where-Object { $_.Name -match '^(unins|Uninstall).*\.exe$' } |
|
||||
Remove-Item -Force -ErrorAction SilentlyContinue
|
||||
$portableDataDir = Join-Path $portableRoot 'data'
|
||||
for ($attempt = 1; $attempt -le 10; $attempt++) {
|
||||
Remove-Item -Recurse -Force $portableDataDir -ErrorAction SilentlyContinue
|
||||
if (-not (Test-Path $portableDataDir)) { break }
|
||||
Start-Sleep -Milliseconds (200 * $attempt)
|
||||
}
|
||||
if (Test-Path $portableDataDir) {
|
||||
throw "Portable test data could not be removed after smoke; refusing to package a ZIP containing runtime data."
|
||||
}
|
||||
$portableZip = "$portableArtifactDir/Firelink_${{ github.ref_name }}_Windows-x64-portable.zip"
|
||||
7z a -tzip $portableZip "$portableRoot\*" -y
|
||||
|
||||
$installRoot = "$env:RUNNER_TEMP\FirelinkSmoke"
|
||||
Remove-Item -Recurse -Force $installRoot -ErrorAction SilentlyContinue
|
||||
$install = Start-Process -FilePath $installer.FullName -ArgumentList @("/S", "/D=$installRoot") -Wait -PassThru
|
||||
@@ -129,14 +197,36 @@ jobs:
|
||||
path: ${{ matrix.artifact }}
|
||||
if-no-files-found: error
|
||||
retention-days: 3
|
||||
- uses: actions/upload-artifact@v7
|
||||
if: runner.os == 'Windows'
|
||||
with:
|
||||
name: Firelink-Windows-x64-portable-${{ github.ref_name }}
|
||||
path: ${{ runner.temp }}/firelink-portable/*.zip
|
||||
if-no-files-found: error
|
||||
retention-days: 3
|
||||
- uses: actions/upload-artifact@v7
|
||||
if: runner.os == 'Linux'
|
||||
with:
|
||||
name: Firelink-Linux-x64-Deb-${{ github.ref_name }}
|
||||
path: ${{ runner.temp }}/firelink-native-packages/deb/*.deb
|
||||
if-no-files-found: error
|
||||
retention-days: 3
|
||||
- uses: actions/upload-artifact@v7
|
||||
if: runner.os == 'Linux'
|
||||
with:
|
||||
name: Firelink-Linux-x64-RPM-${{ github.ref_name }}
|
||||
path: ${{ runner.temp }}/firelink-native-packages/rpm/*.rpm
|
||||
if-no-files-found: error
|
||||
retention-days: 3
|
||||
|
||||
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:
|
||||
@@ -169,7 +259,10 @@ jobs:
|
||||
|
||||
rename_asset '*.dmg' "Firelink_${VERSION}_macOS-ARM64.dmg"
|
||||
rename_asset '*.AppImage' "Firelink_${VERSION}_Linux-x64.AppImage"
|
||||
rename_asset '*.deb' "Firelink_${VERSION}_Linux-x64.deb"
|
||||
rename_asset '*.rpm' "Firelink_${VERSION}_Linux-x64.rpm"
|
||||
rename_asset '*.exe' "Firelink_${VERSION}_Windows-x64-setup.exe"
|
||||
rename_asset '*.zip' "Firelink_${VERSION}_Windows-x64-portable.zip"
|
||||
- name: Generate checksums
|
||||
run: |
|
||||
cd release-assets
|
||||
|
||||
@@ -17,6 +17,7 @@ implementation_plan.md
|
||||
CROSS_PLATFORM_CHECKLIST.md
|
||||
Cross-platform-checklist-gemini.MD
|
||||
YouTube_media_download_handoff.md
|
||||
Release_checklist.md
|
||||
|
||||
# Frontend output and logs
|
||||
node_modules/
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
[submodule "Extensions/Firefox"]
|
||||
path = Extensions/Firefox
|
||||
[submodule "Extensions/Browser"]
|
||||
path = Extensions/Browser
|
||||
url = https://github.com/nimbold/Firelink-Extension.git
|
||||
|
||||
@@ -5,6 +5,77 @@ 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.1.0] - 2026-07-15
|
||||
|
||||
This is a stability-focused release with safer downloads, browser handoffs, settings, and cross-platform packages.
|
||||
|
||||
### New
|
||||
- Add a secure Windows portable ZIP, addressing the portable-version request in [#15](https://github.com/nimbold/Firelink/issues/15). Settings, queues, logs, and WebView data stay with the portable folder, while saved site passwords remain protected in Windows Credential Manager.
|
||||
|
||||
### Improved
|
||||
- Make pause, resume, retry, remove, redownload, and queue actions reliable when several operations happen close together. Downloads now recover their state more consistently instead of restarting unexpectedly or appearing stuck after completion.
|
||||
- Make browser captures and the Add window safer and more dependable. Captured cookies are no longer sent to metadata checks unless authentication is actually needed, while ordinary downloads still keep the browser session they need. This resolves the fallback error reported in [#16](https://github.com/nimbold/Firelink/issues/16).
|
||||
- Improve settings and startup behavior: speed-limit units stay consistent, saved settings are handled more safely, and credential-store access waits until it is needed.
|
||||
- Improve browser handoff safety with stronger local request, replay, path, and credential checks.
|
||||
- Strengthen macOS, Windows, and Linux release verification, refresh the bundled FFmpeg payload, and make package and diagnostic checks more reliable.
|
||||
|
||||
### Fixed
|
||||
- Prevent late or duplicate background events from bringing back downloads after a newer pause, remove, or edit action has already won.
|
||||
- Reconcile completed downloads even when a background completion event is missed, so finished files do not remain visually stuck at the end of the transfer.
|
||||
- Decode URL-derived filenames so links containing encoded characters such as `%20` produce readable names, addressing [#18](https://github.com/nimbold/Firelink/issues/18).
|
||||
- Keep local paths, usernames, credentials, and other sensitive values out of errors and diagnostic output where they are not needed.
|
||||
|
||||
## [1.0.4] - 2026-07-12
|
||||
|
||||
### New
|
||||
- Automatically fill the Add window with valid links from the clipboard when you choose **Add link**, addressing [#10](https://github.com/nimbold/Firelink/issues/10).
|
||||
- Add a persistent, accessible collapse control for the **Folders** section so the sidebar can stay tidy, addressing [#13](https://github.com/nimbold/Firelink/issues/13).
|
||||
- Add verified Linux `.deb` and `.rpm` packages alongside the portable AppImage, completing the Linux packaging request in [#3](https://github.com/nimbold/Firelink/issues/3).
|
||||
|
||||
### Improved
|
||||
- Make queue actions safer: rapid clicks no longer open item properties by accident, and replacing an existing download preserves resumable progress instead of starting from zero, addressing [#11](https://github.com/nimbold/Firelink/issues/11) and [#12](https://github.com/nimbold/Firelink/issues/12).
|
||||
- Improve browser-captured batches so each link keeps its own metadata, headers, cookies, and destination instead of sharing stale request details.
|
||||
- Make media downloads more reliable with custom or system proxies, clearer metadata errors, more accurate quality choices, and steadier retry, speed, ETA, and progress updates, continuing the work reported in [#5](https://github.com/nimbold/Firelink/issues/5) and [#8](https://github.com/nimbold/Firelink/issues/8).
|
||||
- Keep the Logs view responsive while it is open and redact local paths and usernames from diagnostic output before it is shown or exported.
|
||||
- Keep dialogs and controls clear of macOS, Windows, and Linux window controls, and strengthen pause, resume, retry, and removal behavior during rapid actions.
|
||||
- Clarify incomplete-download handling: aria2 sidecar files show when a download is unfinished, preserve resume information, and are removed after completion, addressing [#14](https://github.com/nimbold/Firelink/issues/14).
|
||||
|
||||
### Fixed
|
||||
- Prevent stale background queue work from resurrecting, duplicating, or restarting downloads after a newer pause, remove, or edit action wins.
|
||||
- Keep explicit media requests on Firelink's configured browser-cookie source instead of forwarding raw browser cookies, while preserving the browser session for ordinary captured downloads.
|
||||
- Make final HTTP errors visible during metadata requests and prevent internal retry limits from multiplying unexpectedly.
|
||||
|
||||
## [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
|
||||
|
||||
Submodule
+1
Submodule Extensions/Browser added at 7e09b3f3a7
Submodule Extensions/Firefox deleted from 42b28fa4f6
@@ -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,85 @@
|
||||
|
||||
## 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.
|
||||
|
||||
The current desktop release is **1.1.0**, paired with Firelink Companion **2.0.4**.
|
||||
|
||||
This stability-focused release adds a secure Windows portable build and strengthens queues, browser handoffs, media metadata, persistence, and packaged release checks.
|
||||
|
||||
## 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, captured links, and clipboard-prefilled URLs.
|
||||
- **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, a collapsible Folders section, 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. |
|
||||
| **Windows x64 portable** | `.zip` archive | Extract to a writable folder and launch `firelink.exe`. App data stays under the archive's `data/` directory. |
|
||||
| **Linux x64** | `.deb`, `.rpm`, or `.AppImage` | Use `.deb` for Debian-family systems, `.rpm` for Fedora/RPM-family systems, or AppImage as the self-contained package. AppImage may need executable permission. |
|
||||
|
||||
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.
|
||||
|
||||
The native packages use the distribution's normal desktop runtime dependencies. AppImage is self-contained but uses the normal per-user application-data locations.
|
||||
|
||||
### Windows portable
|
||||
|
||||
The portable ZIP is an opt-in secondary distribution:
|
||||
|
||||
- Keep the extracted folder writable; avoid `Program Files`, read-only media, and folders that block SQLite or WebView writes.
|
||||
- Settings, queues, logs, and WebView data stay beside `firelink.exe` under `data/`.
|
||||
- Close Firelink before moving or copying the folder, and close the installed app before launching the portable copy.
|
||||
- Credentials, browser cookies, and URL query/fragment data are not saved in portable queue records. Active downloads that depend on them must be added again after restart.
|
||||
- Saved site passwords remain in the Windows credential store and are not copied into the archive.
|
||||
- The folder contains the extension pairing credential, so treat it as sensitive and do not share it.
|
||||
- Saved absolute download locations may need to be selected again after moving the folder to another drive.
|
||||
- The installer remains the supported path for `firelink://` browser launch registration.
|
||||
|
||||
## 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.4 is the matching extension release for Firelink 1.1.0.
|
||||
|
||||
The extension lives in [Firelink-Extension](https://github.com/nimbold/Firelink-Extension). This repo also vendors it as the `Extensions/Browser` 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, bundled-engine checks, package/AppImage launch smoke tests, `.deb`, `.rpm`, and AppImage. |
|
||||
|
||||
## Development
|
||||
|
||||
@@ -130,7 +152,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
|
||||
@@ -146,7 +168,7 @@ Build staging includes only the current target. See `engines.lock.json`, `engine
|
||||
├── src/ React and TypeScript interface
|
||||
├── src-tauri/ Rust backend, Tauri config, and native tests
|
||||
├── scripts/ Engine provisioning, release, and smoke-test tooling
|
||||
└── Extensions/Firefox/ Firelink Companion submodule
|
||||
└── Extensions/Browser/ Firelink Companion submodule
|
||||
```
|
||||
|
||||
## Help and Project Status
|
||||
|
||||
+13
-7
@@ -4,11 +4,14 @@ Targets:
|
||||
|
||||
- macOS arm64 DMG
|
||||
- Windows x64 NSIS installer
|
||||
- Windows x64 portable ZIP
|
||||
- Linux x64 AppImage
|
||||
- Linux x64 Debian package
|
||||
- Linux x64 RPM package
|
||||
|
||||
## Distribution policy
|
||||
|
||||
Firelink does not use an Apple Developer account. macOS releases are unsigned and not notarized. Users must explicitly approve the downloaded app through Finder or macOS Privacy & Security. Release copy must never describe these builds as signed, notarized, or Gatekeeper-approved.
|
||||
Firelink does not use an Apple Developer account. macOS releases are ad-hoc signed but not notarized or Gatekeeper-approved. Users may still need to explicitly approve the downloaded app through Finder or macOS Privacy & Security. Release copy must not describe these builds as Developer ID signed, notarized, or Gatekeeper-approved.
|
||||
|
||||
Windows releases are currently unsigned. SmartScreen may warn until code signing is added.
|
||||
|
||||
@@ -22,6 +25,8 @@ Firelink never falls back to system-installed media tools.
|
||||
- `scripts/stage-engines.js` creates one target-specific bundle payload.
|
||||
- `scripts/verify-binaries.js` runs architecture, packaging, version, and RPC checks.
|
||||
|
||||
Linux `.deb` and `.rpm` packages are built with the complete verified engine payload. The AppImage is built separately with the engine payload temporarily omitted, then repacked from the verified payload because the AppImage tooling can rewrite bundled native binaries.
|
||||
|
||||
yt-dlp must remain its official PyInstaller **onedir** distribution: launcher plus adjacent `_internal` runtime. Onefile builds are rejected because repeated extraction caused roughly 17-second startup latency.
|
||||
|
||||
## Version update
|
||||
@@ -53,10 +58,12 @@ node scripts/verify-binaries.js --search-root "$APP" --target aarch64-apple-darw
|
||||
node scripts/smoke-packaged-app.js --executable "$APP/Contents/MacOS/firelink"
|
||||
```
|
||||
|
||||
GitHub release publication is intentionally manual. Tag pushes build and upload
|
||||
artifacts, but the `publish` job only runs from a `workflow_dispatch` on a `v*`
|
||||
tag when both release-certification inputs are checked after Windows, Linux,
|
||||
and macOS clean-machine QA.
|
||||
GitHub release publication follows `.github/workflows/release.yml`. A `v*` tag
|
||||
push builds, verifies, and publishes the GitHub release after the platform jobs
|
||||
pass. A `workflow_dispatch` on a `v*` tag also publishes when its
|
||||
`publish_release` input is enabled. The current workflow has no separate
|
||||
release-certification inputs; clean-machine QA remains a release-owner gate
|
||||
before pushing the tag.
|
||||
|
||||
## Automated release builds
|
||||
|
||||
@@ -69,7 +76,6 @@ git push origin v<version>
|
||||
|
||||
GitHub Actions builds all targets on native runners, verifies engines inside
|
||||
final package contents, performs packaged launch smoke where supported, and
|
||||
uploads artifacts. Publish the GitHub Release with a manual `workflow_dispatch`
|
||||
run on the same tag after clean-machine QA is complete.
|
||||
publishes the GitHub Release after the build matrix passes.
|
||||
|
||||
No target may silently skip missing engines, failed extraction, checksum mismatch, or missing package output.
|
||||
|
||||
+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-14-13-19/ffmpeg-n8.1.2-22-g94138f6973-win64-gpl-8.1.zip",
|
||||
"sha256": "fb627ebbf9b16a3142fa48b8acd27cd9196d57aee1f719e1233416e95da7e877"
|
||||
},
|
||||
"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-14-13-19/ffmpeg-n8.1.2-22-g94138f6973-linux64-gpl-8.1.tar.xz",
|
||||
"sha256": "96cf2585a4b2874044320cbc5d4078af57e266f0c86cca9888507c5faab3aee8"
|
||||
},
|
||||
"aria2c": {
|
||||
"version": "1.37.0",
|
||||
|
||||
+8
-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,23 @@
|
||||
"sha256": "111b2f5ed760f1e1a2ec06117c4e8094fcde336ba16122dda1c5e7209bf1862d"
|
||||
},
|
||||
"ffmpeg": {
|
||||
"version": "N-125385-ge2e889d9da",
|
||||
"version": "N-125610-g312c830916",
|
||||
"source": "https://ffmpeg.org/",
|
||||
"build": "GPLv3 build identified by binary as https://www.martin-riedl.de",
|
||||
"sha256": "3027bd75166a596c31a271ce9ae14b5441492b9557011993c0bf873b5a7c6a09"
|
||||
"url": "https://ffmpeg.martin-riedl.de/download/macos/arm64/1784052810_N-125610-g312c830916/ffmpeg.zip",
|
||||
"sha256": "f65b4ba782b25e9f4374765d421793c6613f692d8e50b2888079657793619034"
|
||||
},
|
||||
"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
+602
-181
File diff suppressed because it is too large
Load Diff
+10
-9
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "firelink",
|
||||
"private": true,
|
||||
"version": "1.0.1",
|
||||
"version": "1.1.0",
|
||||
"description": "A fast cross-platform desktop download manager powered by Rust, Tauri, React, aria2, and yt-dlp.",
|
||||
"license": "MIT",
|
||||
"homepage": "https://github.com/nimbold/Firelink",
|
||||
@@ -32,21 +32,22 @@
|
||||
"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"
|
||||
},
|
||||
"dependencies": {
|
||||
"@formkit/auto-animate": "^0.9.0",
|
||||
"@formkit/auto-animate": "^0.10.0",
|
||||
"@tailwindcss/vite": "^4.3.1",
|
||||
"@tauri-apps/api": "^2.11.1",
|
||||
"@tauri-apps/plugin-clipboard-manager": "^2.3.2",
|
||||
"@tauri-apps/plugin-dialog": "^2.7.1",
|
||||
"@tauri-apps/plugin-fs": "^2.5.1",
|
||||
"@tauri-apps/plugin-log": "^2.8.0",
|
||||
"@tauri-apps/plugin-log": "^2.9.0",
|
||||
"@tauri-apps/plugin-notification": "^2.3.3",
|
||||
"@tauri-apps/plugin-opener": "^2",
|
||||
"lucide-react": "^1.23.0",
|
||||
"lucide-react": "^1.24.0",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"zustand": "^5.0.14"
|
||||
@@ -56,11 +57,11 @@
|
||||
"@types/react": "^19.1.8",
|
||||
"@types/react-dom": "^19.1.6",
|
||||
"@vitejs/plugin-react": "^6.0.3",
|
||||
"autoprefixer": "^10.5.2",
|
||||
"postcss": "^8.5.15",
|
||||
"autoprefixer": "^10.5.3",
|
||||
"postcss": "^8.5.19",
|
||||
"tailwindcss": "^4.3.1",
|
||||
"typescript": "^6.0.3",
|
||||
"vite": "^8.1.3",
|
||||
"vitest": "^4.1.9"
|
||||
"typescript": "^7.0.2",
|
||||
"vite": "^8.1.4",
|
||||
"vitest": "^4.1.10"
|
||||
}
|
||||
}
|
||||
|
||||
+83
-10
@@ -33,10 +33,17 @@ function compareVersions(left, right) {
|
||||
}
|
||||
|
||||
function npmOutdated(cwd) {
|
||||
if (!fs.existsSync(path.join(cwd, 'package.json'))) {
|
||||
throw new Error(`npm workspace is missing package.json: ${cwd}`);
|
||||
}
|
||||
try {
|
||||
execFileSync('npm', ['outdated', '--json'], { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
|
||||
return {};
|
||||
} catch (error) {
|
||||
if (error.status !== 1) {
|
||||
const details = error.stderr?.toString().trim();
|
||||
throw new Error(details || `npm outdated failed in ${cwd}`);
|
||||
}
|
||||
const output = error.stdout?.toString() || '{}';
|
||||
return JSON.parse(output || '{}');
|
||||
}
|
||||
@@ -73,6 +80,46 @@ 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 card = snapshotSection.match(/<h3>macOS \(Apple Silicon\/arm64\)<\/h3>[\s\S]*?<\/div>/)?.[0] || '';
|
||||
const match =
|
||||
card.match(/<b>Release:\s*<\/b>\s*([A-Za-z0-9.-]+)/) ||
|
||||
card.match(/Release:\s*([A-Za-z0-9.-]+)/);
|
||||
const url = card.match(/href="([^"]+\/ffmpeg\.zip)"/)?.[1];
|
||||
return match?.[1]
|
||||
? { version: match[1], url: url ? new URL(url, 'https://ffmpeg.martin-riedl.de').href : undefined }
|
||||
: undefined;
|
||||
}
|
||||
|
||||
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 assets = (release.assets || [])
|
||||
.map(asset => {
|
||||
const match = asset.name.match(/^ffmpeg-n(8\.1\.\d+-\d+-g[0-9a-f]+)-(win64|linux64)-gpl-8\.1\.(?:zip|tar\.xz)$/);
|
||||
if (!match) return undefined;
|
||||
return {
|
||||
target: match[2] === 'win64' ? 'windows' : 'linux',
|
||||
version: match[1],
|
||||
url: asset.browser_download_url,
|
||||
};
|
||||
})
|
||||
.filter(Boolean);
|
||||
const unique = [...new Set(assets.map(asset => asset.version))];
|
||||
const byTarget = Object.fromEntries(assets.map(asset => [asset.target, asset]));
|
||||
if (unique.length === 1 && byTarget.windows && byTarget.linux) {
|
||||
return {
|
||||
version: unique[0],
|
||||
urls: { windows: byTarget.windows.url, linux: byTarget.linux.url },
|
||||
};
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function printNpmReport(label, outdated) {
|
||||
const entries = Object.entries(outdated);
|
||||
if (!entries.length) {
|
||||
@@ -100,22 +147,26 @@ function packagedEngineVersions(engineLock) {
|
||||
const rows = [];
|
||||
for (const [target, targetLock] of Object.entries(engineLock.targets || {})) {
|
||||
for (const [engine, meta] of Object.entries(targetLock.engines || {})) {
|
||||
rows.push({ target, engine, version: meta.version });
|
||||
rows.push({ target, engine, version: meta.version, url: meta.url });
|
||||
}
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
function checkRows(rows, latestByEngine, latestByTargetEngine = {}) {
|
||||
function checkRows(rows, latestByEngine, latestByTargetEngine = {}, latestUrlsByTargetEngine = {}) {
|
||||
let outdated = 0;
|
||||
for (const row of rows) {
|
||||
const latest = latestByTargetEngine[`${row.target}:${row.engine}`] || latestByEngine[row.engine];
|
||||
if (!latest) continue;
|
||||
const current = normalizeVersion(row.version);
|
||||
const wanted = normalizeVersion(latest);
|
||||
const status = compareVersions(current, wanted) < 0 ? 'outdated' : 'current';
|
||||
if (status === 'outdated') outdated += 1;
|
||||
const latestUrl = latestUrlsByTargetEngine[`${row.target}:${row.engine}`];
|
||||
const versionOutdated = compareVersions(current, wanted) < 0;
|
||||
const sourceOutdated = Boolean(latestUrl && row.url && row.url !== latestUrl);
|
||||
const status = versionOutdated ? 'outdated' : sourceOutdated ? 'source-outdated' : 'current';
|
||||
if (status !== 'current') outdated += 1;
|
||||
console.log(` ${row.target} ${row.engine}: ${current} -> ${wanted} ${status}`);
|
||||
if (sourceOutdated) console.log(` source: ${row.url} -> ${latestUrl}`);
|
||||
}
|
||||
return outdated;
|
||||
}
|
||||
@@ -125,16 +176,26 @@ async function main() {
|
||||
|
||||
outdatedCount += printNpmReport('root npm', npmOutdated(repoRoot));
|
||||
outdatedCount += printNpmReport(
|
||||
'Firefox extension npm',
|
||||
npmOutdated(path.join(repoRoot, 'Extensions', 'Firefox'))
|
||||
'Browser extension npm',
|
||||
npmOutdated(path.join(repoRoot, 'Extensions', 'Browser'))
|
||||
);
|
||||
|
||||
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,26 +204,38 @@ async function main() {
|
||||
ffmpeg,
|
||||
};
|
||||
const latestByTargetEngine = {
|
||||
'aarch64-apple-darwin:ffmpeg': martinRiedlMacArm64Ffmpeg,
|
||||
'x86_64-pc-windows-msvc:ffmpeg': btbnFfmpegN81Build?.version || ffmpeg,
|
||||
'x86_64-unknown-linux-gnu:ffmpeg': btbnFfmpegN81Build?.version || ffmpeg,
|
||||
'aarch64-apple-darwin:ffmpeg': martinRiedlMacArm64Snapshot?.version || martinRiedlMacArm64Ffmpeg,
|
||||
};
|
||||
const latestUrlsByTargetEngine = {
|
||||
'x86_64-pc-windows-msvc:ffmpeg': btbnFfmpegN81Build?.urls.windows,
|
||||
'x86_64-unknown-linux-gnu:ffmpeg': btbnFfmpegN81Build?.urls.linux,
|
||||
'aarch64-apple-darwin:ffmpeg': martinRiedlMacArm64Snapshot?.url,
|
||||
};
|
||||
|
||||
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?.version || ffmpeg)}`);
|
||||
console.log(` Martin Riedl FFmpeg macOS arm64 snapshot: ${normalizeVersion(martinRiedlMacArm64Snapshot?.version || martinRiedlMacArm64Ffmpeg)}`);
|
||||
|
||||
console.log('\nengine source lock:');
|
||||
outdatedCount += checkRows(
|
||||
sourceEngineVersions(parseJsonFile('engine-sources.lock.json')),
|
||||
latestByEngine,
|
||||
latestByTargetEngine
|
||||
latestByTargetEngine,
|
||||
latestUrlsByTargetEngine
|
||||
);
|
||||
|
||||
console.log('\npackaged engine lock:');
|
||||
outdatedCount += checkRows(
|
||||
packagedEngineVersions(parseJsonFile('engines.lock.json')),
|
||||
latestByEngine,
|
||||
latestByTargetEngine
|
||||
latestByTargetEngine,
|
||||
latestUrlsByTargetEngine
|
||||
);
|
||||
|
||||
if (outdatedCount > 0) {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
#!/usr/bin/env node
|
||||
import { execFileSync, spawn } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
function argValue(name) {
|
||||
@@ -15,6 +16,7 @@ if (!executableArg) {
|
||||
|
||||
const executable = path.resolve(executableArg);
|
||||
const assertNoVisibleChildWindows = process.argv.includes('--assert-no-visible-child-windows');
|
||||
const assertPortableData = process.argv.includes('--assert-portable-data');
|
||||
const child = spawn(executable, [], {
|
||||
cwd: process.env.RUNNER_TEMP || process.env.TMPDIR || process.cwd(),
|
||||
detached: process.platform !== 'win32',
|
||||
@@ -30,12 +32,14 @@ const child = spawn(executable, [], {
|
||||
let stderr = '';
|
||||
let spawnError = null;
|
||||
let readyPort = null;
|
||||
let childExit = null;
|
||||
|
||||
child.on('error', error => {
|
||||
spawnError = error;
|
||||
});
|
||||
|
||||
child.on('exit', (code, signal) => {
|
||||
childExit = { code, signal };
|
||||
if (readyPort === null) {
|
||||
console.error(`Child exited prematurely with code ${code} signal ${signal}`);
|
||||
}
|
||||
@@ -44,6 +48,7 @@ child.on('exit', (code, signal) => {
|
||||
child.stderr.on('data', data => {
|
||||
stderr += data.toString();
|
||||
});
|
||||
child.stdout.on('data', () => {});
|
||||
|
||||
function sleep(ms) {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
@@ -51,14 +56,17 @@ function sleep(ms) {
|
||||
|
||||
async function findReadyPort() {
|
||||
for (let attempt = 0; attempt < 200 && readyPort === null; attempt += 1) {
|
||||
if (spawnError) {
|
||||
if (spawnError || childExit) {
|
||||
break;
|
||||
}
|
||||
|
||||
for (let port = 6412; port <= 6422; port += 1) {
|
||||
try {
|
||||
const response = await fetch(`http://127.0.0.1:${port}/ping`);
|
||||
if (response.headers.get('x-firelink-server') === '1') {
|
||||
if (
|
||||
response.headers.get('x-firelink-server') === '1'
|
||||
&& response.headers.get('x-firelink-smoke-process-id') === String(child.pid)
|
||||
) {
|
||||
readyPort = port;
|
||||
break;
|
||||
}
|
||||
@@ -120,17 +128,35 @@ if ($visible.Count -gt 0) {
|
||||
}
|
||||
}
|
||||
|
||||
function terminateChild() {
|
||||
if (!child.pid) {
|
||||
return;
|
||||
function waitForChildExit(timeoutMs) {
|
||||
if (childExit) {
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
|
||||
return new Promise(resolve => {
|
||||
const timer = setTimeout(() => {
|
||||
resolve(false);
|
||||
}, timeoutMs);
|
||||
child.once('exit', () => {
|
||||
clearTimeout(timer);
|
||||
resolve(true);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function terminateChild() {
|
||||
if (!child.pid || childExit) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (process.platform === 'win32') {
|
||||
spawn('taskkill', ['/pid', String(child.pid), '/t', '/f'], {
|
||||
stdio: 'ignore',
|
||||
windowsHide: true,
|
||||
});
|
||||
return;
|
||||
try {
|
||||
execFileSync('taskkill', ['/pid', String(child.pid), '/t', '/f'], {
|
||||
stdio: 'ignore',
|
||||
windowsHide: true,
|
||||
});
|
||||
} catch {}
|
||||
return waitForChildExit(10000);
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -138,25 +164,72 @@ function terminateChild() {
|
||||
} catch {
|
||||
child.kill('SIGTERM');
|
||||
}
|
||||
if (await waitForChildExit(5000)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
process.kill(-child.pid, 'SIGKILL');
|
||||
} catch {
|
||||
child.kill('SIGKILL');
|
||||
}
|
||||
return waitForChildExit(5000);
|
||||
}
|
||||
|
||||
await findReadyPort();
|
||||
async function assertPortableStorage() {
|
||||
const portableRoot = path.dirname(executable);
|
||||
const marker = path.join(portableRoot, 'portable.flag');
|
||||
const database = path.join(portableRoot, 'data', 'firelink.sqlite');
|
||||
const webviewData = path.join(portableRoot, 'data', 'webview');
|
||||
|
||||
for (let attempt = 0; attempt < 40; attempt += 1) {
|
||||
const markerReady = fs.statSync(marker, { throwIfNoEntry: false })?.isFile();
|
||||
const databaseReady = fs.statSync(database, { throwIfNoEntry: false })?.isFile();
|
||||
const webviewReady = fs.statSync(webviewData, { throwIfNoEntry: false })?.isDirectory();
|
||||
if (markerReady && databaseReady && webviewReady) {
|
||||
return;
|
||||
}
|
||||
await sleep(250);
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`Portable storage was not ready: marker=${marker}, database=${database}, webview=${webviewData}`,
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
await findReadyPort();
|
||||
|
||||
if (readyPort === null) {
|
||||
if (spawnError) {
|
||||
console.error(`Packaged Firelink failed to start: ${spawnError.message}`);
|
||||
throw new Error(`Packaged Firelink failed to start: ${spawnError.message}`);
|
||||
} else if (childExit) {
|
||||
throw new Error(
|
||||
`Packaged Firelink exited before exposing extension ping endpoint with code ${childExit.code} signal ${childExit.signal}.`,
|
||||
);
|
||||
} else {
|
||||
console.error(`Packaged Firelink did not expose extension ping endpoint. Stderr:\n${stderr.slice(-1000)}`);
|
||||
throw new Error(`Packaged Firelink did not expose extension ping endpoint. Stderr:\n${stderr.slice(-1000)}`);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (assertNoVisibleChildWindows) {
|
||||
assertNoVisibleWindows(child.pid);
|
||||
}
|
||||
if (assertPortableData) {
|
||||
await assertPortableStorage();
|
||||
}
|
||||
|
||||
if (childExit) {
|
||||
throw new Error(`Packaged Firelink exited after exposing extension ping endpoint with code ${childExit.code} signal ${childExit.signal}.`);
|
||||
}
|
||||
|
||||
console.log(`Packaged Firelink smoke passed on 127.0.0.1:${readyPort}`);
|
||||
} catch (error) {
|
||||
console.error(error instanceof Error ? error.message : String(error));
|
||||
process.exitCode = 1;
|
||||
} finally {
|
||||
terminateChild();
|
||||
if (!await terminateChild()) {
|
||||
console.error('Packaged Firelink could not be terminated cleanly; refusing to report smoke success.');
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
|
||||
+182
-49
@@ -2,6 +2,7 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import os from 'node:os';
|
||||
import net from 'node:net';
|
||||
import { execFileSync, spawn } from 'node:child_process';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
@@ -317,6 +318,82 @@ function runEngine(label, engine, args, timeout = 30000) {
|
||||
}
|
||||
}
|
||||
|
||||
function waitForProcessExit(proc, timeoutMs) {
|
||||
if (proc.exitCode !== null || proc.signalCode !== null) {
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
|
||||
return new Promise(resolve => {
|
||||
let settled = false;
|
||||
let timer;
|
||||
const finish = value => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
proc.removeListener('exit', onExit);
|
||||
resolve(value);
|
||||
};
|
||||
const onExit = () => finish(true);
|
||||
|
||||
timer = setTimeout(() => finish(false), timeoutMs);
|
||||
proc.once('exit', onExit);
|
||||
if (proc.exitCode !== null || proc.signalCode !== null) {
|
||||
finish(true);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function terminateProcess(proc, label) {
|
||||
if (proc.exitCode === null && proc.signalCode === null) {
|
||||
try {
|
||||
proc.kill('SIGTERM');
|
||||
} catch {}
|
||||
if (await waitForProcessExit(proc, 2000)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
proc.kill('SIGKILL');
|
||||
} catch {}
|
||||
if (await waitForProcessExit(proc, 2000)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
fail(`${label} did not terminate after SIGTERM and SIGKILL.`);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function findAvailablePort() {
|
||||
return new Promise((resolve, reject) => {
|
||||
const server = net.createServer();
|
||||
server.once('error', reject);
|
||||
server.listen({ host: '127.0.0.1', port: 0 }, () => {
|
||||
const address = server.address();
|
||||
if (!address || typeof address === 'string') {
|
||||
server.close();
|
||||
reject(new Error('Could not determine the verifier RPC port.'));
|
||||
return;
|
||||
}
|
||||
|
||||
const port = address.port;
|
||||
server.close(error => {
|
||||
if (error) {
|
||||
reject(error);
|
||||
} else {
|
||||
resolve(port);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function isPortBindingFailure(message) {
|
||||
return /address already in use|failed to bind|could not bind|listen failed/i.test(message);
|
||||
}
|
||||
|
||||
const coldStartTimeout = isMacOS ? 120000 : 30000;
|
||||
if (canExecuteTarget) {
|
||||
runEngine('yt-dlp cold start', 'yt-dlp', ['--version'], coldStartTimeout);
|
||||
@@ -345,25 +422,6 @@ if (canExecuteTarget) {
|
||||
return;
|
||||
}
|
||||
|
||||
const port = 16801 + (process.pid % 1000);
|
||||
const proc = spawn(p, [
|
||||
'--enable-rpc',
|
||||
`--rpc-listen-port=${port}`,
|
||||
'--rpc-max-request-size=1K',
|
||||
'--quiet',
|
||||
'--console-log-level=error',
|
||||
'--rpc-listen-all=false',
|
||||
], {
|
||||
env: engineEnv('aria2c'),
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
timeout: 15000,
|
||||
});
|
||||
|
||||
let rpcStderr = '';
|
||||
proc.stderr.on('data', (d) => {
|
||||
rpcStderr += d.toString();
|
||||
});
|
||||
|
||||
const body = JSON.stringify({
|
||||
jsonrpc: '2.0',
|
||||
id: 'firelink-verify',
|
||||
@@ -371,39 +429,114 @@ if (canExecuteTarget) {
|
||||
params: [],
|
||||
});
|
||||
|
||||
const result = await new Promise((resolve) => {
|
||||
const maxAttempts = 20;
|
||||
let attempts = 0;
|
||||
|
||||
function tryFetch() {
|
||||
attempts++;
|
||||
fetch(`http://127.0.0.1:${port}/jsonrpc`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body,
|
||||
})
|
||||
.then(async (res) => {
|
||||
resolve({ ok: true, data: await res.text() });
|
||||
})
|
||||
.catch(() => {
|
||||
if (attempts >= maxAttempts) {
|
||||
resolve({ ok: false, error: `RPC not ready after ${maxAttempts} attempts` });
|
||||
return;
|
||||
}
|
||||
setTimeout(tryFetch, 300);
|
||||
});
|
||||
let result = { ok: false, error: 'RPC test did not run.' };
|
||||
let rpcStderr = '';
|
||||
const maxPortAttempts = 3;
|
||||
for (let portAttempt = 0; portAttempt < maxPortAttempts; portAttempt += 1) {
|
||||
let port;
|
||||
try {
|
||||
port = await findAvailablePort();
|
||||
} catch (error) {
|
||||
result = { ok: false, error: `Could not reserve an RPC port: ${error.message}` };
|
||||
break;
|
||||
}
|
||||
|
||||
tryFetch();
|
||||
});
|
||||
const proc = spawn(p, [
|
||||
'--enable-rpc',
|
||||
`--rpc-listen-port=${port}`,
|
||||
'--rpc-max-request-size=1K',
|
||||
'--quiet',
|
||||
'--console-log-level=error',
|
||||
'--rpc-listen-all=false',
|
||||
], {
|
||||
env: engineEnv('aria2c'),
|
||||
stdio: ['ignore', 'ignore', 'pipe'],
|
||||
timeout: 15000,
|
||||
});
|
||||
|
||||
// Clean up
|
||||
proc.kill('SIGTERM');
|
||||
setTimeout(() => {
|
||||
try {
|
||||
proc.kill('SIGKILL');
|
||||
} catch {}
|
||||
}, 2000);
|
||||
let spawnError = null;
|
||||
let childExit = null;
|
||||
let attemptStderr = '';
|
||||
proc.once('error', error => {
|
||||
spawnError = error;
|
||||
});
|
||||
proc.once('exit', (code, signal) => {
|
||||
childExit = { code, signal };
|
||||
});
|
||||
proc.stderr.on('data', data => {
|
||||
attemptStderr += data.toString();
|
||||
});
|
||||
|
||||
const attemptResult = await new Promise(resolve => {
|
||||
const maxAttempts = 20;
|
||||
let attempts = 0;
|
||||
|
||||
function resolveProcessFailure() {
|
||||
if (spawnError) {
|
||||
resolve({ ok: false, error: `aria2c failed to spawn: ${spawnError.message}` });
|
||||
} else if (childExit) {
|
||||
resolve({
|
||||
ok: false,
|
||||
error: `aria2c exited before RPC became ready with code ${childExit.code} signal ${childExit.signal}.`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function tryFetch() {
|
||||
if (spawnError || childExit) {
|
||||
resolveProcessFailure();
|
||||
return;
|
||||
}
|
||||
|
||||
attempts += 1;
|
||||
fetch(`http://127.0.0.1:${port}/jsonrpc`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body,
|
||||
})
|
||||
.then(async response => {
|
||||
resolve({ ok: true, data: await response.text() });
|
||||
})
|
||||
.catch(() => {
|
||||
if (spawnError || childExit) {
|
||||
resolveProcessFailure();
|
||||
} else if (attempts >= maxAttempts) {
|
||||
resolve({ ok: false, error: `RPC not ready after ${maxAttempts} attempts` });
|
||||
} else {
|
||||
setTimeout(tryFetch, 300);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
tryFetch();
|
||||
});
|
||||
|
||||
const exitedBeforeCleanup = childExit;
|
||||
const terminated = proc.pid
|
||||
? await terminateProcess(proc, 'aria2 RPC verifier process')
|
||||
: true;
|
||||
rpcStderr = attemptStderr;
|
||||
result = attemptResult;
|
||||
|
||||
if (
|
||||
result.ok
|
||||
&& exitedBeforeCleanup
|
||||
&& (exitedBeforeCleanup.code !== 0 || exitedBeforeCleanup.signal !== null)
|
||||
) {
|
||||
result = {
|
||||
ok: false,
|
||||
error: `aria2c exited after responding with code ${exitedBeforeCleanup.code} signal ${exitedBeforeCleanup.signal}.`,
|
||||
};
|
||||
}
|
||||
|
||||
if (!terminated || result.ok || !isPortBindingFailure(`${result.error || ''}\n${rpcStderr}`)) {
|
||||
break;
|
||||
}
|
||||
|
||||
if (portAttempt + 1 < maxPortAttempts) {
|
||||
console.log(`[INFO] RPC port ${port} became unavailable; retrying with a new port.`);
|
||||
}
|
||||
}
|
||||
|
||||
if (result.ok) {
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,272 @@
|
||||
#!/usr/bin/env node
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { collectRegularFiles, sha256 } from './engine-payload-integrity.js';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const repoRoot = path.resolve(__dirname, '..');
|
||||
|
||||
function argValue(name) {
|
||||
const index = process.argv.indexOf(name);
|
||||
return index >= 0 ? process.argv[index + 1] : undefined;
|
||||
}
|
||||
|
||||
function fail(message) {
|
||||
console.error(`[FAIL] ${message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
function run(command, args, options = {}) {
|
||||
const result = spawnSync(command, args, {
|
||||
cwd: options.cwd ?? repoRoot,
|
||||
env: { ...process.env, ...options.env },
|
||||
stdio: options.stdio ?? 'inherit',
|
||||
encoding: options.stdio === 'pipe' ? 'utf8' : undefined,
|
||||
});
|
||||
|
||||
if (result.error) {
|
||||
fail(`Failed to run ${command}: ${result.error.message}`);
|
||||
}
|
||||
if (result.status !== 0) {
|
||||
if (options.stdio === 'pipe') {
|
||||
if (result.stdout) process.stdout.write(result.stdout);
|
||||
if (result.stderr) process.stderr.write(result.stderr);
|
||||
}
|
||||
fail(`${command} exited with status ${result.status}`);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function findSingle(directory, extension, label) {
|
||||
if (!fs.existsSync(directory) || !fs.statSync(directory).isDirectory()) {
|
||||
fail(`${label} directory does not exist: ${directory}`);
|
||||
}
|
||||
|
||||
const matches = fs.readdirSync(directory)
|
||||
.filter(name => name.endsWith(extension))
|
||||
.map(name => path.join(directory, name));
|
||||
if (matches.length !== 1) {
|
||||
fail(`Expected exactly one ${label}, found ${matches.length} in ${directory}`);
|
||||
}
|
||||
return matches[0];
|
||||
}
|
||||
|
||||
function assertPackageListing(packageFile, packageType, expectedPath) {
|
||||
const result = packageType === 'deb'
|
||||
? run('dpkg-deb', ['--contents', packageFile], { stdio: 'pipe' })
|
||||
: run('rpm', ['-qpl', packageFile], { stdio: 'pipe' });
|
||||
const listing = result.stdout ?? '';
|
||||
assertSafePackageListing(listing, packageType);
|
||||
if (!listing.includes(expectedPath)) {
|
||||
fail(`${packageType} package is missing ${expectedPath}`);
|
||||
}
|
||||
if (!/usr\/share\/applications\/[^/]+\.desktop/.test(listing)) {
|
||||
fail(`${packageType} package is missing its desktop entry`);
|
||||
}
|
||||
}
|
||||
|
||||
function assertPackageRecommendations(packageFile, packageType) {
|
||||
const result = packageType === 'deb'
|
||||
? run('dpkg-deb', ['--field', packageFile, 'Recommends'], { stdio: 'pipe' })
|
||||
: run('rpm', ['-qp', '--recommends', packageFile], { stdio: 'pipe' });
|
||||
const recommendations = result.stdout ?? '';
|
||||
const dependencyNames = packageType === 'deb'
|
||||
? recommendations
|
||||
.split(/[,|]/)
|
||||
.map(value => value.trim().split(/\s+/, 1)[0]?.split(':', 1)[0])
|
||||
: recommendations
|
||||
.split('\n')
|
||||
.map(value => value.trim().split(/\s+/, 1)[0]);
|
||||
for (const dependency of ['desktop-file-utils', 'xdg-utils']) {
|
||||
if (!dependencyNames.includes(dependency)) {
|
||||
fail(`${packageType} package is missing its ${dependency} recommendation`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function parseDebianPackagePath(line) {
|
||||
const match = line.match(/^\S+\s+\S+\s+\S+\s+\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}\s+(.*)$/);
|
||||
if (!match) {
|
||||
throw new Error(`Could not parse a Debian package path: ${line}`);
|
||||
}
|
||||
return match[1].replace(/^\.\//, '');
|
||||
}
|
||||
|
||||
export function isSafePackagePath(packagePath) {
|
||||
if (packagePath === '') {
|
||||
return true;
|
||||
}
|
||||
|
||||
const parts = packagePath.split('/');
|
||||
return !parts.includes('..') && (parts[0] === 'usr' || packagePath === 'usr');
|
||||
}
|
||||
|
||||
function assertSafePackageListing(listing, packageType) {
|
||||
const lines = listing.split('\n').filter(Boolean);
|
||||
const paths = packageType === 'deb'
|
||||
? lines.map(line => {
|
||||
try {
|
||||
return parseDebianPackagePath(line);
|
||||
} catch (error) {
|
||||
fail(error.message);
|
||||
}
|
||||
})
|
||||
: lines.map(line => line.replace(/^\/+/, ''));
|
||||
|
||||
for (const packagePath of paths) {
|
||||
if (!isSafePackagePath(packagePath)) {
|
||||
fail(`${packageType} package contains an unsafe path: ${packagePath}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function extractDeb(packageFile, destination) {
|
||||
fs.mkdirSync(destination, { recursive: true });
|
||||
run('dpkg-deb', ['--extract', packageFile, destination]);
|
||||
}
|
||||
|
||||
function extractRpm(packageFile, destination) {
|
||||
fs.mkdirSync(destination, { recursive: true });
|
||||
run('bsdtar', [
|
||||
'--extract',
|
||||
'--file', packageFile,
|
||||
'--directory', destination,
|
||||
'--no-same-owner',
|
||||
'--no-same-permissions',
|
||||
]);
|
||||
}
|
||||
|
||||
function readPayloadManifest(root, label) {
|
||||
const manifestPath = path.join(root, 'payload-manifest.json');
|
||||
if (!fs.existsSync(manifestPath)) {
|
||||
fail(`${label} payload manifest is missing`);
|
||||
}
|
||||
let manifest;
|
||||
try {
|
||||
manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
|
||||
} catch (error) {
|
||||
fail(`${label} payload manifest is invalid: ${error.message}`);
|
||||
}
|
||||
return manifest;
|
||||
}
|
||||
|
||||
function findPayloadRoot(root, target, label) {
|
||||
const expectedBinary = `yt-dlp-${target}`;
|
||||
const matches = [];
|
||||
const walk = directory => {
|
||||
for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
|
||||
const candidate = path.join(directory, entry.name);
|
||||
if (!entry.isDirectory()) continue;
|
||||
if (fs.existsSync(path.join(candidate, expectedBinary)) && fs.existsSync(path.join(candidate, 'payload-manifest.json'))) {
|
||||
matches.push(candidate);
|
||||
}
|
||||
walk(candidate);
|
||||
}
|
||||
};
|
||||
walk(root);
|
||||
if (matches.length !== 1) {
|
||||
fail(`Expected exactly one ${label} engine payload root, found ${matches.length}`);
|
||||
}
|
||||
return matches[0];
|
||||
}
|
||||
|
||||
function assertPayloadMatchesSource(sourceRoot, packagedRoot, target, label) {
|
||||
const sourceManifest = readPayloadManifest(sourceRoot, 'Provisioned engine');
|
||||
if (sourceManifest.target !== target) {
|
||||
fail(`Provisioned engine payload target mismatch: expected ${target}, got ${sourceManifest.target}`);
|
||||
}
|
||||
|
||||
const packagedManifest = readPayloadManifest(packagedRoot, label);
|
||||
if (packagedManifest.target !== target) {
|
||||
fail(`${label} payload target mismatch: expected ${target}, got ${packagedManifest.target}`);
|
||||
}
|
||||
|
||||
const expectedFiles = Object.keys(sourceManifest.files || {}).sort();
|
||||
const packagedFiles = collectRegularFiles(packagedRoot, { ignoredNames: ['payload-manifest.json'] })
|
||||
.map(file => path.relative(packagedRoot, file).split(path.sep).join('/'))
|
||||
.sort();
|
||||
if (JSON.stringify(packagedFiles) !== JSON.stringify(expectedFiles)) {
|
||||
fail(`${label} payload files differ from the provisioned engine manifest`);
|
||||
}
|
||||
|
||||
for (const relative of expectedFiles) {
|
||||
const packagedFile = path.join(packagedRoot, relative);
|
||||
if (sha256(packagedFile) !== sourceManifest.files[relative]) {
|
||||
fail(`${label} payload checksum mismatch: ${relative}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function findExecutable(root) {
|
||||
const candidates = [
|
||||
path.join(root, 'usr', 'bin', 'firelink'),
|
||||
path.join(root, 'usr', 'bin', 'Firelink'),
|
||||
];
|
||||
const executable = candidates.find(candidate => {
|
||||
if (!fs.existsSync(candidate)) return false;
|
||||
const stat = fs.lstatSync(candidate);
|
||||
return stat.isFile() && !stat.isSymbolicLink();
|
||||
});
|
||||
if (!executable) {
|
||||
fail(`Packaged Firelink executable was not found under ${root}`);
|
||||
}
|
||||
return executable;
|
||||
}
|
||||
|
||||
function verifyExtractedPackage(packageType, packageFile, target, root) {
|
||||
const sourceRoot = path.join(repoRoot, 'src-tauri', 'provisioned-engines', target);
|
||||
const packagedRoot = findPayloadRoot(root, target, packageType);
|
||||
assertPayloadMatchesSource(sourceRoot, packagedRoot, target, packageType);
|
||||
run(process.execPath, [
|
||||
path.join(repoRoot, 'scripts', 'verify-binaries.js'),
|
||||
'--search-root',
|
||||
root,
|
||||
'--target',
|
||||
target,
|
||||
]);
|
||||
|
||||
const executable = findExecutable(root);
|
||||
run('xvfb-run', [
|
||||
'-a',
|
||||
process.execPath,
|
||||
path.join(repoRoot, 'scripts', 'smoke-packaged-app.js'),
|
||||
'--executable',
|
||||
executable,
|
||||
], { env: { APPDIR: root } });
|
||||
}
|
||||
|
||||
function main() {
|
||||
const target = argValue('--target');
|
||||
if (!target) fail('Pass --target <Rust target triple>.');
|
||||
if (os.platform() !== 'linux') fail('Linux package verification must run on Linux.');
|
||||
|
||||
const bundleRoot = path.join(repoRoot, 'src-tauri', 'target', target, 'release', 'bundle');
|
||||
const deb = findSingle(path.join(bundleRoot, 'deb'), '.deb', 'Debian package');
|
||||
const rpm = findSingle(path.join(bundleRoot, 'rpm'), '.rpm', 'RPM package');
|
||||
const extractionRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'firelink-linux-packages-'));
|
||||
const debRoot = path.join(extractionRoot, 'deb');
|
||||
const rpmRoot = path.join(extractionRoot, 'rpm');
|
||||
|
||||
try {
|
||||
assertPackageListing(deb, 'deb', 'usr/share/metainfo/com.nimbold.firelink.metainfo.xml');
|
||||
assertPackageRecommendations(deb, 'deb');
|
||||
extractDeb(deb, debRoot);
|
||||
verifyExtractedPackage('deb', deb, target, debRoot);
|
||||
|
||||
assertPackageListing(rpm, 'rpm', 'usr/share/metainfo/com.nimbold.firelink.metainfo.xml');
|
||||
assertPackageRecommendations(rpm, 'rpm');
|
||||
extractRpm(rpm, rpmRoot);
|
||||
verifyExtractedPackage('rpm', rpm, target, rpmRoot);
|
||||
console.log('Linux .deb and .rpm payload and launch verification passed.');
|
||||
} finally {
|
||||
fs.rmSync(extractionRoot, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
if (process.argv[1] && path.resolve(process.argv[1]) === __filename) {
|
||||
main();
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { isSafePackagePath, parseDebianPackagePath } from './verify-linux-packages.js';
|
||||
|
||||
test('parses current dpkg-deb listings without a ./ prefix', () => {
|
||||
assert.equal(
|
||||
parseDebianPackagePath('drwxr-xr-x 0/0 0 2026-07-12 07:24 usr/share/'),
|
||||
'usr/share/'
|
||||
);
|
||||
});
|
||||
|
||||
test('parses legacy dpkg-deb listings with a ./ prefix', () => {
|
||||
assert.equal(
|
||||
parseDebianPackagePath('-rwxr-xr-x root/root 123 2026-07-12 07:24 ./usr/bin/firelink'),
|
||||
'usr/bin/firelink'
|
||||
);
|
||||
});
|
||||
|
||||
test('accepts the package root in legacy dpkg-deb listings', () => {
|
||||
assert.equal(
|
||||
parseDebianPackagePath('drwxr-xr-x root/root 0 2026-07-12 07:24 ./'),
|
||||
''
|
||||
);
|
||||
assert.equal(isSafePackagePath(''), true);
|
||||
});
|
||||
|
||||
test('rejects paths outside the package usr tree', () => {
|
||||
assert.equal(isSafePackagePath('../tmp/firelink'), false);
|
||||
assert.equal(isSafePackagePath('etc/firelink'), false);
|
||||
});
|
||||
|
||||
test('rejects malformed dpkg-deb listing lines', () => {
|
||||
assert.throws(
|
||||
() => parseDebianPackagePath('not a dpkg-deb listing'),
|
||||
/Could not parse a Debian package path/
|
||||
);
|
||||
});
|
||||
@@ -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.');
|
||||
Generated
+832
-644
File diff suppressed because it is too large
Load Diff
+14
-11
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "firelink"
|
||||
version = "1.0.1"
|
||||
version = "1.1.0"
|
||||
description = "A fast cross-platform desktop download manager powered by Rust and Tauri"
|
||||
authors = ["NimBold"]
|
||||
edition = "2021"
|
||||
@@ -31,38 +31,41 @@ 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.13", default-features = false, features = ["rustls-no-provider", "json", "stream", "socks"] }
|
||||
rustls = { version = "0.23.42", default-features = false, features = ["ring", "std", "tls12"] }
|
||||
uuid = { version = "1", features = ["v4"] }
|
||||
ts-rs = { version = "12", features = ["serde-compat", "uuid-impl"] }
|
||||
tauri-plugin-notification = "2.3.3"
|
||||
tauri-plugin-clipboard-manager = "2.3.2"
|
||||
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"] }
|
||||
tauri-plugin-single-instance = { version = "2.4.3", 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"
|
||||
system_shutdown = "4.1.0"
|
||||
tokio-tungstenite = "0.29.0"
|
||||
tokio-tungstenite = "0.30.0"
|
||||
futures-util = { version = "0.3.32", features = ["sink"] }
|
||||
chrono = "0.4.38"
|
||||
url = "2"
|
||||
rusqlite = { version = "0.40.1", features = ["bundled"] }
|
||||
log = "0.4.32"
|
||||
tauri-plugin-log = "2"
|
||||
tauri-plugin-log = "2.9.0"
|
||||
trash = "5"
|
||||
async-trait = "0.1"
|
||||
keyring-core = "1.0.0"
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
apple-native-keyring-store = { version = "1.0.1", features = ["keychain"] }
|
||||
objc = "0.2.7"
|
||||
keyring = { version = "3", features = ["apple-native"] }
|
||||
|
||||
[target.'cfg(target_os = "windows")'.dependencies]
|
||||
keyring = { version = "3", features = ["windows-native"] }
|
||||
windows-native-keyring-store = "1.1.0"
|
||||
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
keyring = { version = "3", features = ["sync-secret-service", "vendored"] }
|
||||
zbus-secret-service-keyring-store = { version = "1.0.0", features = ["crypto-rust"] }
|
||||
|
||||
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.
@@ -13,6 +13,7 @@
|
||||
"dialog:default",
|
||||
"log:default",
|
||||
"notification:default",
|
||||
"notification:allow-is-permission-granted"
|
||||
"notification:allow-is-permission-granted",
|
||||
"clipboard-manager:allow-read-text"
|
||||
]
|
||||
}
|
||||
|
||||
+49
-14
@@ -61,9 +61,8 @@ fn known_download_paths(app_handle: &tauri::AppHandle) -> Result<Vec<PathBuf>, S
|
||||
}
|
||||
|
||||
fn authorize_exact_path(requested: &Path, allowed_paths: &[PathBuf]) -> Result<PathBuf, String> {
|
||||
if std::fs::symlink_metadata(requested).is_ok_and(|metadata| metadata.file_type().is_symlink())
|
||||
{
|
||||
return Err("Download path may not be a symlink".to_string());
|
||||
if crate::path_has_symlink_component(requested) {
|
||||
return Err("Download path may not contain symlink components".to_string());
|
||||
}
|
||||
|
||||
let requested = canonicalize_with_missing_leaf(requested)?;
|
||||
@@ -74,8 +73,9 @@ fn authorize_exact_path(requested: &Path, allowed_paths: &[PathBuf]) -> Result<P
|
||||
}
|
||||
|
||||
let authorized = allowed_paths.iter().any(|allowed| {
|
||||
canonicalize_with_missing_leaf(allowed)
|
||||
.is_ok_and(|canonical_allowed| canonical_allowed == requested)
|
||||
!crate::path_has_symlink_component(allowed)
|
||||
&& canonicalize_with_missing_leaf(allowed)
|
||||
.is_ok_and(|canonical_allowed| canonical_allowed == requested)
|
||||
});
|
||||
|
||||
if authorized {
|
||||
@@ -98,14 +98,30 @@ fn canonicalize_with_missing_leaf(path: &Path) -> Result<PathBuf, String> {
|
||||
|
||||
let mut existing = path;
|
||||
let mut missing = Vec::new();
|
||||
while !existing.exists() {
|
||||
let name = existing
|
||||
.file_name()
|
||||
.ok_or_else(|| "Download path has no existing ancestor".to_string())?;
|
||||
missing.push(name.to_owned());
|
||||
existing = existing
|
||||
.parent()
|
||||
.ok_or_else(|| "Download path has no existing ancestor".to_string())?;
|
||||
loop {
|
||||
match std::fs::symlink_metadata(existing) {
|
||||
Ok(metadata) => {
|
||||
if metadata.file_type().is_symlink() {
|
||||
return Err("Download path may not contain symlink components".to_string());
|
||||
}
|
||||
break;
|
||||
}
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
|
||||
let name = existing
|
||||
.file_name()
|
||||
.ok_or_else(|| "Download path has no existing ancestor".to_string())?;
|
||||
missing.push(name.to_owned());
|
||||
existing = existing
|
||||
.parent()
|
||||
.ok_or_else(|| "Download path has no existing ancestor".to_string())?;
|
||||
}
|
||||
Err(error) => {
|
||||
return Err(format!(
|
||||
"Failed to inspect download path '{}': {error}",
|
||||
path.display()
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut canonical = std::fs::canonicalize(existing)
|
||||
@@ -144,7 +160,8 @@ mod tests {
|
||||
#[test]
|
||||
fn authorizes_only_known_download_files_and_partials() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let owned = root.path().join("owned.bin");
|
||||
let root = fs::canonicalize(root.path()).unwrap();
|
||||
let owned = root.join("owned.bin");
|
||||
let outside = tempfile::NamedTempFile::new().unwrap();
|
||||
fs::write(&owned, b"download").unwrap();
|
||||
|
||||
@@ -197,4 +214,22 @@ mod tests {
|
||||
|
||||
assert!(authorize_exact_path(&escaped, std::slice::from_ref(&escaped)).is_err());
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn rejects_parent_directory_symlink_escape_from_download_location() {
|
||||
use std::os::unix::fs::symlink;
|
||||
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let root_path = fs::canonicalize(root.path()).unwrap();
|
||||
let outside = tempfile::tempdir().unwrap();
|
||||
let outside_file = outside.path().join("owned.bin");
|
||||
fs::write(&outside_file, b"outside").unwrap();
|
||||
|
||||
let redirected_parent = root_path.join("downloads");
|
||||
symlink(outside.path(), &redirected_parent).unwrap();
|
||||
let escaped = redirected_parent.join("owned.bin");
|
||||
|
||||
assert!(authorize_exact_path(&escaped, std::slice::from_ref(&escaped)).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
+1033
-189
File diff suppressed because it is too large
Load Diff
+268
-656
File diff suppressed because it is too large
Load Diff
@@ -59,7 +59,12 @@ pub fn expected_primary_path(
|
||||
}
|
||||
|
||||
let safe_filename = canonical_download_filename(filename);
|
||||
Ok(resolved_dest.join(safe_filename))
|
||||
let path = resolved_dest.join(safe_filename);
|
||||
if crate::path_has_symlink_component(&path) {
|
||||
return Err("Download path may not contain symlink components".to_string());
|
||||
}
|
||||
crate::canonicalize_with_missing_components(&path)
|
||||
.ok_or_else(|| "Download path could not be canonicalized".to_string())
|
||||
}
|
||||
|
||||
pub fn register_expected(
|
||||
@@ -88,13 +93,15 @@ pub fn set_primary_path(
|
||||
}) {
|
||||
return Err("Download ownership path traversal is not allowed".to_string());
|
||||
}
|
||||
if std::fs::symlink_metadata(path).is_ok_and(|metadata| metadata.file_type().is_symlink()) {
|
||||
return Err("Download ownership path may not be a symlink".to_string());
|
||||
if crate::path_has_symlink_component(path) {
|
||||
return Err("Download ownership path may not contain symlink components".to_string());
|
||||
}
|
||||
let canonical_path = crate::canonicalize_with_missing_components(path)
|
||||
.ok_or_else(|| "Download ownership path could not be canonicalized".to_string())?;
|
||||
|
||||
let database = app_handle.state::<crate::db::DbState>();
|
||||
let connection = database.lock()?;
|
||||
crate::db::set_ownership(&connection, id, &path.to_string_lossy())
|
||||
crate::db::set_ownership(&connection, id, &canonical_path.to_string_lossy())
|
||||
}
|
||||
|
||||
pub fn remove(app_handle: &tauri::AppHandle, id: &str) -> Result<(), String> {
|
||||
@@ -147,11 +154,7 @@ fn legacy_download_queue_paths(app_handle: &tauri::AppHandle) -> Result<Vec<Path
|
||||
let downloads = {
|
||||
let database = app_handle.state::<crate::db::DbState>();
|
||||
let connection = database.lock()?;
|
||||
crate::db::load_downloads(&connection)?
|
||||
.into_iter()
|
||||
.map(|value| serde_json::from_str::<crate::ipc::DownloadItem>(&value))
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
.map_err(|error| format!("Invalid download queue ownership data: {error}"))?
|
||||
parse_legacy_download_items(crate::db::load_downloads(&connection)?)
|
||||
};
|
||||
|
||||
let mut paths = Vec::new();
|
||||
@@ -168,6 +171,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 +184,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
|
||||
@@ -217,9 +226,23 @@ fn legacy_download_queue_paths(app_handle: &tauri::AppHandle) -> Result<Vec<Path
|
||||
Ok(paths)
|
||||
}
|
||||
|
||||
fn parse_legacy_download_items(values: Vec<String>) -> Vec<crate::ipc::DownloadItem> {
|
||||
values
|
||||
.into_iter()
|
||||
.filter_map(|value| match serde_json::from_str::<crate::ipc::DownloadItem>(&value) {
|
||||
Ok(download) => Some(download),
|
||||
Err(error) => {
|
||||
log::warn!("Skipping malformed download ownership record: {error}");
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::canonical_download_filename;
|
||||
use super::{canonical_download_filename, parse_legacy_download_items};
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn canonicalizes_untrusted_download_filenames() {
|
||||
@@ -232,4 +255,22 @@ mod tests {
|
||||
assert_eq!(canonical_download_filename("CON.txt"), "CON-.txt");
|
||||
assert_eq!(canonical_download_filename("lpt9"), "lpt9-");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_legacy_download_does_not_block_valid_ownership_records() {
|
||||
let valid = json!({
|
||||
"id": "download-1",
|
||||
"url": "https://example.com/file",
|
||||
"fileName": "file",
|
||||
"status": "completed",
|
||||
"category": "Other",
|
||||
"dateAdded": ""
|
||||
})
|
||||
.to_string();
|
||||
|
||||
let downloads = parse_legacy_download_items(vec!["not-json".to_string(), valid]);
|
||||
|
||||
assert_eq!(downloads.len(), 1);
|
||||
assert_eq!(downloads[0].id, "download-1");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -30,8 +30,9 @@ const PROTOCOL_VERSION_HEADER: &str = "x-firelink-protocol-version";
|
||||
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 SMOKE_PROCESS_ID_HEADER: &str = "x-firelink-smoke-process-id";
|
||||
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 +62,8 @@ struct ExtensionRequest {
|
||||
headers: Option<String>,
|
||||
#[serde(default)]
|
||||
cookies: Option<String>,
|
||||
#[serde(default)]
|
||||
media: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize, TS)]
|
||||
@@ -72,6 +75,7 @@ pub struct ExtensionDownload {
|
||||
filename: Option<String>,
|
||||
headers: Option<String>,
|
||||
cookies: Option<String>,
|
||||
media: bool,
|
||||
}
|
||||
|
||||
pub async fn start_server(
|
||||
@@ -137,6 +141,13 @@ async fn add_server_identity(request: Request<Body>, next: Next) -> Response {
|
||||
PROTOCOL_VERSION_HEADER,
|
||||
HeaderValue::from_static(PROTOCOL_VERSION),
|
||||
);
|
||||
if std::env::var_os("FIRELINK_SMOKE_TEST").is_some() {
|
||||
if let Ok(process_id) = HeaderValue::from_str(&std::process::id().to_string()) {
|
||||
response
|
||||
.headers_mut()
|
||||
.insert(SMOKE_PROCESS_ID_HEADER, process_id);
|
||||
}
|
||||
}
|
||||
response
|
||||
}
|
||||
|
||||
@@ -163,6 +174,10 @@ async fn ping_handler(
|
||||
headers: HeaderMap,
|
||||
body: Bytes,
|
||||
) -> Result<Response, StatusCode> {
|
||||
if !has_allowed_request_origin(&headers) {
|
||||
return Err(StatusCode::FORBIDDEN);
|
||||
}
|
||||
|
||||
let signature = match headers
|
||||
.get("x-firelink-signature")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
@@ -188,17 +203,20 @@ async fn ping_handler(
|
||||
None => return Err(StatusCode::FORBIDDEN),
|
||||
};
|
||||
|
||||
if verify_signature(signature, timestamp_str, &body, &state.pairing_token).is_err() {
|
||||
let timestamp = match verify_signature(signature, timestamp_str, &body, &state.pairing_token) {
|
||||
Ok(timestamp) => timestamp,
|
||||
Err(_) => return Err(StatusCode::FORBIDDEN),
|
||||
};
|
||||
|
||||
// Discovery probes are authenticated requests too. Claim the verified
|
||||
// signature before signing a proof so a captured /ping signature cannot
|
||||
// be replayed with arbitrary client nonces during its validity window.
|
||||
if !claim_request(signature, timestamp, &state.replay_cache) {
|
||||
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(
|
||||
@@ -218,6 +236,10 @@ async fn download_handler(
|
||||
headers: HeaderMap,
|
||||
body: Bytes,
|
||||
) -> Result<StatusCode, StatusCode> {
|
||||
if !has_allowed_request_origin(&headers) || !has_valid_optional_nonce(&headers) {
|
||||
return Err(StatusCode::FORBIDDEN);
|
||||
}
|
||||
|
||||
let signature = match headers
|
||||
.get("x-firelink-signature")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
@@ -304,23 +326,67 @@ fn normalize_download(payload: ExtensionRequest) -> Option<ExtensionDownload> {
|
||||
if urls.is_empty() {
|
||||
return None;
|
||||
}
|
||||
if payload.media
|
||||
&& urls.iter().any(|url| {
|
||||
Url::parse(url)
|
||||
.ok()
|
||||
.is_none_or(|url| !matches!(url.scheme(), "http" | "https"))
|
||||
})
|
||||
{
|
||||
return None;
|
||||
}
|
||||
|
||||
let referer = payload.referer.and_then(|value| {
|
||||
let url = Url::parse(value.trim()).ok()?;
|
||||
matches!(url.scheme(), "http" | "https").then(|| url.to_string())
|
||||
});
|
||||
let filename = payload.filename.and_then(|value| sanitize_filename(&value));
|
||||
// A multi-URL handoff has no per-URL cookie scope. Keep ordinary
|
||||
// request headers, but drop Cookie headers and the dedicated cookie field
|
||||
// so a legacy or untrusted caller cannot reuse one session across hosts.
|
||||
let headers = normalize_headers(payload.headers, payload.media || urls.len() > 1);
|
||||
let cookies = if !payload.media && urls.len() == 1 {
|
||||
payload
|
||||
.cookies
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
Some(ExtensionDownload {
|
||||
urls,
|
||||
referer,
|
||||
silent: payload.silent,
|
||||
filename,
|
||||
headers: payload.headers.filter(|value| !value.trim().is_empty()),
|
||||
cookies: payload.cookies.filter(|value| !value.trim().is_empty()),
|
||||
headers,
|
||||
// Explicit media is resolved by yt-dlp, which must use Firelink's
|
||||
// configured browser-cookie source. Forwarding a browser's complete
|
||||
// Cookie header can exceed upstream limits and makes old extension
|
||||
// builds pay for a doomed metadata request before retrying. Ordinary
|
||||
// captured downloads still need their exact request cookies.
|
||||
cookies,
|
||||
media: payload.media,
|
||||
})
|
||||
}
|
||||
|
||||
fn normalize_headers(headers: Option<String>, media: bool) -> Option<String> {
|
||||
let headers = headers?;
|
||||
if !media {
|
||||
return (!headers.trim().is_empty()).then_some(headers);
|
||||
}
|
||||
|
||||
let filtered = headers
|
||||
.lines()
|
||||
.filter(|line| {
|
||||
line.split_once(':')
|
||||
.map(|(name, _)| !name.trim().eq_ignore_ascii_case("cookie"))
|
||||
.unwrap_or(true)
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
(!filtered.trim().is_empty()).then_some(filtered)
|
||||
}
|
||||
|
||||
fn normalize_url(raw_url: &str) -> Option<String> {
|
||||
let url = Url::parse(raw_url.trim()).ok()?;
|
||||
matches!(url.scheme(), "http" | "https" | "ftp" | "sftp").then(|| url.to_string())
|
||||
@@ -364,6 +430,20 @@ fn is_valid_client_nonce(value: &str) -> bool {
|
||||
value.len() == 32 && value.bytes().all(|byte| byte.is_ascii_hexdigit())
|
||||
}
|
||||
|
||||
fn has_allowed_request_origin(headers: &HeaderMap) -> bool {
|
||||
match headers.get("origin") {
|
||||
None => true,
|
||||
Some(origin) => origin.to_str().ok().is_some_and(is_allowed_origin),
|
||||
}
|
||||
}
|
||||
|
||||
fn has_valid_optional_nonce(headers: &HeaderMap) -> bool {
|
||||
match headers.get(CLIENT_NONCE_HEADER) {
|
||||
None => true,
|
||||
Some(nonce) => nonce.to_str().ok().is_some_and(is_valid_client_nonce),
|
||||
}
|
||||
}
|
||||
|
||||
fn sign_server_proof(
|
||||
timestamp_text: &str,
|
||||
nonce: &str,
|
||||
@@ -450,13 +530,20 @@ fn is_allowed_origin(origin: &str) -> bool {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
add_server_identity, is_valid_client_nonce, sign_server_proof, PROTOCOL_VERSION_HEADER,
|
||||
SERVER_HEADER,
|
||||
add_server_identity, claim_request, has_allowed_request_origin, has_valid_optional_nonce,
|
||||
is_valid_client_nonce, normalize_download, sign_server_proof, ExtensionRequest,
|
||||
PROTOCOL_VERSION_HEADER, SERVER_HEADER,
|
||||
};
|
||||
use axum::{http::StatusCode, middleware, routing::get, Router};
|
||||
use hmac::{Hmac, Mac};
|
||||
use axum::{
|
||||
http::{HeaderMap, HeaderValue, StatusCode},
|
||||
middleware,
|
||||
routing::get,
|
||||
Router,
|
||||
};
|
||||
use hmac::{Hmac, KeyInit, Mac};
|
||||
use sha2::Sha256;
|
||||
use std::sync::{Arc, RwLock};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, Mutex, RwLock};
|
||||
|
||||
#[tokio::test]
|
||||
async fn identifies_every_extension_server_response() {
|
||||
@@ -471,6 +558,7 @@ mod tests {
|
||||
axum::serve(listener, app).await.unwrap();
|
||||
});
|
||||
|
||||
crate::ensure_reqwest_crypto_provider();
|
||||
let response = reqwest::get(format!("http://{address}/ping"))
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -478,7 +566,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();
|
||||
@@ -492,6 +580,115 @@ mod tests {
|
||||
assert!(!is_valid_client_nonce("0123456789abcdef0123456789abcdeg"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_invalid_origins_and_malformed_optional_nonces() {
|
||||
let mut headers = HeaderMap::new();
|
||||
assert!(has_allowed_request_origin(&headers));
|
||||
assert!(has_valid_optional_nonce(&headers));
|
||||
|
||||
headers.insert(
|
||||
"origin",
|
||||
HeaderValue::from_static("https://not-firelink.example"),
|
||||
);
|
||||
assert!(!has_allowed_request_origin(&headers));
|
||||
|
||||
headers.insert(
|
||||
"origin",
|
||||
HeaderValue::from_static("moz-extension://firelink"),
|
||||
);
|
||||
headers.insert(
|
||||
"x-firelink-client-nonce",
|
||||
HeaderValue::from_static("not-a-valid-nonce"),
|
||||
);
|
||||
assert!(has_allowed_request_origin(&headers));
|
||||
assert!(!has_valid_optional_nonce(&headers));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn media_handoffs_reject_non_http_page_urls() {
|
||||
let download = normalize_download(ExtensionRequest {
|
||||
urls: vec!["ftp://example.com/audio.mp3".to_string()],
|
||||
referer: None,
|
||||
silent: false,
|
||||
filename: None,
|
||||
headers: None,
|
||||
cookies: None,
|
||||
media: true,
|
||||
});
|
||||
|
||||
assert!(download.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_replayed_download_signature() {
|
||||
let cache = Arc::new(Mutex::new(HashMap::new()));
|
||||
let signature = "a".repeat(64);
|
||||
|
||||
assert!(claim_request(&signature, 42, &cache));
|
||||
assert!(!claim_request(&signature, 42, &cache));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_media_drops_the_extension_cookie_header() {
|
||||
let download = normalize_download(ExtensionRequest {
|
||||
urls: vec!["https://www.youtube.com/watch?v=example".to_string()],
|
||||
referer: None,
|
||||
silent: false,
|
||||
filename: None,
|
||||
headers: Some(format!(
|
||||
"Cookie: stale={};\nUser-Agent: Firefox",
|
||||
"x".repeat(64 * 1024)
|
||||
)),
|
||||
cookies: Some(format!("large={}", "x".repeat(64 * 1024))),
|
||||
media: true,
|
||||
})
|
||||
.expect("valid media handoff");
|
||||
|
||||
assert!(download.media);
|
||||
assert!(download.cookies.is_none());
|
||||
assert_eq!(download.headers.as_deref(), Some("User-Agent: Firefox"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn regular_capture_preserves_the_extension_cookie_header() {
|
||||
let download = normalize_download(ExtensionRequest {
|
||||
urls: vec!["https://example.com/private.zip".to_string()],
|
||||
referer: None,
|
||||
silent: true,
|
||||
filename: None,
|
||||
headers: None,
|
||||
cookies: Some("session=browser-cookie-header".to_string()),
|
||||
media: false,
|
||||
})
|
||||
.expect("valid download handoff");
|
||||
|
||||
assert!(!download.media);
|
||||
assert_eq!(
|
||||
download.cookies.as_deref(),
|
||||
Some("session=browser-cookie-header")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multi_url_capture_drops_cookie_scope_but_preserves_safe_headers() {
|
||||
let download = normalize_download(ExtensionRequest {
|
||||
urls: vec![
|
||||
"https://one.example/private.zip".to_string(),
|
||||
"https://two.example/file.zip".to_string(),
|
||||
],
|
||||
referer: None,
|
||||
silent: true,
|
||||
filename: None,
|
||||
headers: Some("Cookie: session=secret\nUser-Agent: Firefox".to_string()),
|
||||
cookies: Some("session=secret".to_string()),
|
||||
media: false,
|
||||
})
|
||||
.expect("valid multi-url handoff");
|
||||
|
||||
assert_eq!(download.cookies, None);
|
||||
assert_eq!(download.headers.as_deref(), Some("User-Agent: Firefox"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn signs_server_proof_with_timestamp_nonce_and_bound_port() {
|
||||
let token = Arc::new(RwLock::new("pairing-token".to_string()));
|
||||
|
||||
+12
-8
@@ -2,6 +2,10 @@ use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use ts_rs::TS;
|
||||
|
||||
fn default_speed_limit_unit() -> String {
|
||||
"MB/s".to_string()
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Serialize, Deserialize, TS)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
#[ts(export, export_to = "../../src/bindings/")]
|
||||
@@ -108,6 +112,10 @@ pub struct DownloadItem {
|
||||
pub queue_position: Option<i32>,
|
||||
#[ts(optional)]
|
||||
pub has_been_dispatched: Option<bool>,
|
||||
#[ts(optional)]
|
||||
pub last_error: Option<String>,
|
||||
#[ts(optional)]
|
||||
pub last_try: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, TS)]
|
||||
@@ -244,6 +252,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>,
|
||||
@@ -259,6 +268,8 @@ pub struct PersistedSettings {
|
||||
pub scheduler_last_start_key: String,
|
||||
pub scheduler_last_stop_key: String,
|
||||
pub last_custom_speed_limit_ki_b: u32,
|
||||
#[serde(default = "default_speed_limit_unit")]
|
||||
pub last_custom_speed_limit_unit: String,
|
||||
pub per_server_connections: i32,
|
||||
pub max_automatic_retries: i32,
|
||||
pub show_notifications: bool,
|
||||
@@ -275,14 +286,6 @@ pub struct PersistedSettings {
|
||||
pub prevents_sleep_while_downloading: bool,
|
||||
pub media_cookie_source: MediaCookieSource,
|
||||
pub site_logins: Vec<SiteLogin>,
|
||||
/// HMAC shared secret for the browser extension. It is persisted in the
|
||||
/// settings database so that startup never needs to touch the OS keychain.
|
||||
/// The keychain is still used as defence-in-depth — grant_keychain_access
|
||||
/// writes the token there — but the DB copy is the primary read path,
|
||||
/// eliminating the OS credential prompt that macOS shows when the binary
|
||||
/// signature changes after an update.
|
||||
#[serde(default)]
|
||||
pub extension_pairing_token: String,
|
||||
pub auto_check_updates: bool,
|
||||
#[serde(default)]
|
||||
pub keychain_access_granted: bool,
|
||||
@@ -295,6 +298,7 @@ pub struct PlatformInfo {
|
||||
pub os: String,
|
||||
pub arch: String,
|
||||
pub target_triple: String,
|
||||
pub portable: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Serialize, Deserialize, TS)]
|
||||
|
||||
+2380
-558
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user