Compare commits

..

81 Commits

Author SHA1 Message Date
NimBold df3bc359c4 chore(release): prepare Firelink 1.1.0 2026-07-15 11:55:54 +03:30
NimBold 57f132f53e fix(metadata): decode URL-derived filenames
Decode URL path segments before sanitizing metadata filenames and preserve malformed escapes safely.

Fixes #18
2026-07-15 11:29:17 +03:30
NimBold 52b00e5cb4 fix(keychain): gate credential-store startup access 2026-07-15 11:07:48 +03:30
NimBold 8e02a61c3f fix(downloads): preserve terminal startup state 2026-07-15 10:23:06 +03:30
NimBold 82f2914077 fix: close post-release lifecycle state gaps 2026-07-15 10:13:53 +03:30
NimBold 9224117d77 chore(deps): scope release-gate update to npm (#16)
Remove the unrelated Rust lockfile refresh from the prior dependency commit. Keep the Autoprefixer release-gate update scoped to package.json and package-lock.json. Refs #16.
2026-07-15 09:43:19 +03:30
NimBold 3c84b5c1a4 chore(deps): refresh compatible npm and Rust packages (#16)
Refresh Autoprefixer and compatible Rust transitive dependencies after the release hardening work. Refs #16.
2026-07-15 09:40:23 +03:30
NimBold c34c489aef fix(settings): preserve speed limiter units
Persist the selected display unit for disabled limits, preserve in-progress fractional input, and keep exact KiB-backed preset values visible.\n\nNo linked issue.
2026-07-15 09:19:19 +03:30
NimBold b81e8391e1 fix(release): harden speed limits and extension replay checks 2026-07-15 09:02:40 +03:30
NimBold 6ff0047d6c fix(release): make verifier RPC smoke resilient
Use OS-selected localhost ports with bounded bind retries and handle aria2c spawn failures without unhandled child-process errors.

Refs #3
2026-07-15 08:50:34 +03:30
NimBold 4a3fece22b fix(release): harden package and engine verifier cleanup 2026-07-15 08:40:12 +03:30
NimBold 1da0fa7223 fix(security): harden credential and path boundaries
Preserve pending pairing tokens until credential-store migration succeeds, defer keychain access until frontend hydration, reject symlink and malformed ownership paths, and restrict metadata credentials to exact origins.

Refs #15

Refs #16
2026-07-15 08:23:43 +03:30
NimBold d6af4ee2b5 fix(extension): harden local handoff boundaries 2026-07-15 06:06:16 +03:30
NimBold 2479ead4ed fix(downloads): harden lifecycle race handling
Audit Assignment 06 lifecycle paths and preserve truthful state across ambiguous Aria2 resume failures. Clean up partial startup listener registration and prevent stale resume workers from leaking or releasing current permits.
2026-07-15 05:34:32 +03:30
NimBold 80a29356e0 fix(tools): harden scheduler limits and logs
Guard scheduler system actions against pending work, serialize diagnostic log transitions, and keep speed-limit saves truthful after backend failures.\n\nNo linked issue was found for this audit.
2026-07-15 03:00:46 +03:30
NimBold 45bbca0515 fix(settings): harden persisted settings and update checks 2026-07-15 02:25:53 +03:30
NimBold e35b1af731 fix(downloads): close audit lifecycle gaps
Prevent duplicate Add Window submissions and preserve discard confirmation.

Reject duplicate backend primary-path ownership and clear stale lifecycle progress.
2026-07-15 01:51:04 +03:30
NimBold 19953a210e fix(downloads): harden Add Window handoffs 2026-07-15 01:38:16 +03:30
NimBold 76850f2433 fix(ui): harden main window interactions 2026-07-15 01:25:59 +03:30
NimBold 0eee47b97f chore(release): refresh cross-platform FFmpeg payloads 2026-07-15 00:11:04 +03:30
NimBold e8487ee71b fix(downloads): harden lifecycle cancellation races 2026-07-15 00:10:56 +03:30
NimBold e07182fbf2 fix(downloads): harden queue and lifecycle synchronization
Serialize queue controls, make multi-item moves atomic, await stale enqueue cleanup, and guard late media and progress events. Add deterministic table sorting and regression coverage for worst-case lifecycle races.
2026-07-14 18:29:14 +03:30
NimBold 2d9eed99d5 chore(deps): refresh packages after fallback hardening (#16)
Refresh the compatible npm and Rust dependency set after the metadata fallback hardening. Refs #16.
2026-07-14 15:34:35 +03:30
NimBold 80cf835060 fix(downloads): defer captured cookies for metadata probes (#16)
Fixes #16
2026-07-14 01:19:00 +03:30
NimBold c78a72a8d7 fix(downloads): harden aria2 resume lifecycle 2026-07-13 04:40:39 +03:30
NimBold df85a77987 fix(downloads): reconcile lost aria2 completion events 2026-07-13 04:21:38 +03:30
NimBold be2a98fbcd fix(downloads): reconcile completed aria2 pause requests 2026-07-13 04:08:18 +03:30
NimBold fcfdffa6e0 fix(downloads): harden aria2 lifecycle synchronization 2026-07-13 03:31:41 +03:30
NimBold 9805c9288a fix(downloads): harden aria2 recovery and resume lifecycle 2026-07-13 00:35:09 +03:30
NimBold dad5b7bc5e fix(portable): harden persistence and release paths (#15)
Preserve legacy source data when portable sanitization cannot replace it, reject malformed settings without panicking, and make portable pairing regeneration durable before UI state changes.\n\nMake the packaged smoke assertion tolerate slow WebView startup and clarify AppImage storage behavior.\n\nRefs #15
2026-07-13 00:09:09 +03:30
NimBold f441c687f0 fix(security): redact secrets from portable errors (#15)
Sanitize lastError at the portable SQLite boundary using the shared redactor.\nCover spaced credentials and non-HTTP URL queries while preserving standard-mode diagnostics.\n\nRefs #15
2026-07-12 23:50:13 +03:30
NimBold a0f44b79ad feat(portable): add secure Windows portable release (#15)
Implement marker-based portable storage, portable WebView and log paths, secure queue and migration sanitization, and Windows portable ZIP validation while preserving the NSIS installer path.

Refs #15
2026-07-12 23:08:56 +03:30
NimBold 56b4c9f511 fix(release): extract RPM packages with bsdtar 2026-07-12 11:31:52 +03:30
NimBold c1fa87b953 fix(release): parse modern Debian package listings 2026-07-12 11:09:15 +03:30
NimBold c7ec8cd666 chore(release): prepare Firelink 1.0.4 2026-07-12 10:48:00 +03:30
NimBold 9133e3b05b fix(downloads): harden release-critical transfer paths 2026-07-12 07:55:47 +03:30
NimBold 5bbee12602 feat(release): add verified Linux packages 2026-07-12 06:03:54 +03:30
NimBold 7894c05bba fix(ui): keep dialogs clear of window controls 2026-07-11 21:52:01 +03:30
NimBold 33375df2ff fix(downloads): harden queue controls and resumable replacement
Prevent queue action clicks from triggering row double-clicks, preserve aria2/yt-dlp resumable sidecars during duplicate replacement, and make aria2 require resume instead of silently restarting.

Add a persisted, accessible Folders collapse control with reduced-motion animation and regression coverage for replacement sidecar handling.

Fixes #11

Fixes #12

Closes #13

Refs #14
2026-07-11 20:07:33 +03:30
NimBold 1922db8ea0 chore(deps): refresh application dependencies 2026-07-11 19:16:51 +03:30
NimBold ba70662165 feat(downloads): prefill Add modal from clipboard (#10) 2026-07-11 09:01:44 +03:30
NimBold 629a34d1e8 chore: clarify browser extension submodule 2026-07-10 19:23:45 +03:30
NimBold 248f3869ad fix: harden media handoff and live logs
Reject stale extension media cookie headers before yt-dlp metadata work, preserve ordinary capture cookies, and advance the companion extension.

Stream redacted diagnostic logs only while the visible Logs view is active, with bounded batched updates and race-safe snapshot handoff.
2026-07-10 19:02:39 +03:30
NimBold 4f4c655de6 fix: address post-audit regressions across queue, db, and ui
- Preserved extension-captured cookies through the Add modal, with a clean fallback when captured cookies break metadata fetching.
- Prevented batched extension captures from losing URLs or reusing stale cookie/header contexts.
- Fixed pause/resume and enqueue generation races, including cancellation during queue reservation and replay after task removal.
- Made startup database initialization safe under React StrictMode.
- Serialized keyring operations and corrected Linux legacy migration/deletion behavior.
- Restored `Downloading` state after yt-dlp retries.
- Replaced hardcoded media heights with dynamically detected formats, including nonstandard qualities such as 576p and 2880p.
2026-07-10 12:07:25 +03:30
NimBold 3fbd0742be fix(media): reject extension cookie headers for media
Keep explicit media requests on yt-dlp's configured browser-cookie path and preserve cookies for normal captures.
2026-07-10 00:46:00 +03:30
NimBold c5025fd5a0 fix(media): quote forwarded cookie headers
Keep yt-dlp config values intact and require explicit media metadata before downloads can start.
2026-07-10 00:27:44 +03:30
NimBold b1c84a0fb9 fix(downloads): harden enqueue lifecycle races
Reject superseded enqueue generations in the queue manager and coordinate frontend dispatch, pause, removal, and property mutations.
2026-07-10 00:04:21 +03:30
NimBold fbb89cde8e fix(downloads): harden capture and media flows
Scope extension request context to each Add modal row, refresh stale metadata handoffs, and align yt-dlp format and retry behavior with Firelink's transfer contract.
2026-07-09 23:21:16 +03:30
NimBold cd8ab5c12b fix(deps): scope linux keyring entries 2026-07-09 18:15:29 +03:30
NimBold ed7c47cb49 chore(deps): migrate keyring stores 2026-07-09 18:11:26 +03:30
NimBold 8c035167c8 chore(deps): refresh package versions 2026-07-09 18:05:36 +03:30
NimBold 6d9ef68bb0 chore(release): prepare firelink 1.0.3 2026-07-09 04:07:34 +03:30
NimBold 51258b25bc fix(media): smooth transfer speed and eta 2026-07-09 03:40:14 +03:30
NimBold 1baadf6ea6 fix(media): force yt-dlp progress output (#8) 2026-07-09 03:16:44 +03:30
NimBold 60dad5703a fix(media): show live yt-dlp progress (#8)
Buffer yt-dlp output chunks before parsing progress events and update the download row from live progress state instead of imperative DOM writes.
2026-07-09 02:59:37 +03:30
NimBold b8ef712981 ci(release): publish tag builds by default 2026-07-08 22:43:23 +03:30
NimBold 3b9faa789c chore(release): prepare 1.0.2
Remove reduced yt-dlp info-json reuse so metadata fallback downloads re-extract from the original URL instead of crashing on incomplete extractor metadata.

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

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

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

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

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

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

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

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

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

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

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

Fixes #6

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

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

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

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

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

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

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

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

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

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

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

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

Refresh docs and tests around the aria2-only file-download contract, and advance the Firefox extension submodule to its published wording cleanup.
2026-07-05 20:00:53 +03:30
NimBold 3aaf32f9ee fix(window): enable custom titlebar controls 2026-07-05 00:02:31 +03:30
NimBold 58bbf95761 fix(release): use platform names for assets 2026-07-04 23:36:59 +03:30
163 changed files with 14133 additions and 4596 deletions
+6
View File
@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" width="86" height="20" role="img" aria-label="Windows">
<title>Windows</title>
<rect width="86" height="20" fill="#0078D6"/>
<path fill="#fff" d="M7 5.3 12.8 4.5v5H7V5.3Zm6.6-1 7.4-1.1v6.3h-7.4V4.3ZM7 10.5h5.8v5L7 14.7v-4.2Zm6.6 0H21v6.3l-7.4-1.1v-5.2Z"/>
<text x="28" y="14" fill="#fff" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" font-size="11">Windows</text>
</svg>

After

Width:  |  Height:  |  Size: 425 B

+124 -12
View File
@@ -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:
@@ -151,10 +241,32 @@ jobs:
VERSION="${GITHUB_REF_NAME#v}"
awk '/^## \['"$VERSION"'\]/{flag=1; next} /^## \[/{if(flag) exit} flag' CHANGELOG.md > release_notes.md
test -s release_notes.md
- name: Normalize release asset names
shell: bash
run: |
VERSION="${GITHUB_REF_NAME#v}"
rename_asset() {
local pattern="$1"
local destination="$2"
local source
source="$(find release-assets -maxdepth 1 -type f -name "$pattern" -print -quit)"
if [[ -z "$source" ]]; then
echo "::error::Missing release asset matching $pattern"
exit 1
fi
mv "$source" "release-assets/$destination"
}
rename_asset '*.dmg' "Firelink_${VERSION}_macOS-ARM64.dmg"
rename_asset '*.AppImage' "Firelink_${VERSION}_Linux-x64.AppImage"
rename_asset '*.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
find . -type f -print0 | sort -z | xargs -0 sha256sum > SHA256SUMS
find . -type f ! -name SHA256SUMS -print0 | sort -z | xargs -0 sha256sum > SHA256SUMS
- uses: softprops/action-gh-release@v3
with:
files: release-assets/**
+1
View File
@@ -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
View File
@@ -1,3 +1,3 @@
[submodule "Extensions/Firefox"]
path = Extensions/Firefox
[submodule "Extensions/Browser"]
path = Extensions/Browser
url = https://github.com/nimbold/Firelink-Extension.git
+76
View File
@@ -5,6 +5,82 @@ 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
- Fix custom window controls on Windows and Linux so close, minimize, and maximize work in packaged builds.
## [1.0.0] - 2026-07-04
### Highlights
Submodule Extensions/Browser added at 7e09b3f3a7
+58 -36
View File
@@ -5,13 +5,10 @@
**A fast, focused desktop download manager for macOS, Windows, and Linux.**
[![Version](https://img.shields.io/badge/version-1.0.0-6f42c1?style=flat-square)](https://github.com/nimbold/Firelink/releases)
[![Version](https://img.shields.io/badge/version-1.1.0-6f42c1?style=flat-square)](https://github.com/nimbold/Firelink/releases)
[![macOS](https://img.shields.io/badge/macOS-111111?style=flat-square&logo=apple&logoColor=white)](#platforms)
[![Windows](https://img.shields.io/badge/Windows-0078D6?style=flat-square&logo=windows11&logoColor=white)](#platforms)
[![Windows](.github/badges/windows.svg)](#platforms)
[![Linux](https://img.shields.io/badge/Linux-FCC624?style=flat-square&logo=linux&logoColor=black)](#platforms)
[![Tauri](https://img.shields.io/badge/Tauri-2-24C8DB?style=flat-square&logo=tauri&logoColor=white)](https://tauri.app/)
[![Rust](https://img.shields.io/badge/Rust-backend-000000?style=flat-square&logo=rust)](https://www.rust-lang.org/)
[![React](https://img.shields.io/badge/React-TypeScript-61DAFB?style=flat-square&logo=react&logoColor=111111)](https://react.dev/)
[![License](https://img.shields.io/github/license/nimbold/Firelink?style=flat-square)](LICENSE)
[![CI](https://img.shields.io/github/actions/workflow/status/nimbold/Firelink/ci.yml?branch=main&style=flat-square&label=CI)](https://github.com/nimbold/Firelink/actions/workflows/ci.yml)
@@ -36,60 +33,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>
&nbsp;&nbsp;
<a href="https://github.com/nimbold/Firelink-Extension#manual-chromium-installation"><img src="https://img.shields.io/badge/Manual%20install-Chromium-4285F4?style=for-the-badge&logo=googlechrome&logoColor=white" alt="Read manual Chromium install instructions" /></a>
</p>
Firelink Companion connects your browser to the desktop app so links and browser downloads can open in Firelink instead of disappearing into the browser's default download shelf.
Firelink Companion sends browser links and downloads to the desktop app.
What it adds:
- **Automatic capture** for normal browser downloads, while still routing every captured link through Firelink's Add window.
- **Context-menu actions** for "Download with Firelink" and selected links.
- **Signed local requests** using the pairing token from **Settings -> Integrations**.
- **Server identity checks** so the extension only trusts the real local Firelink app.
- **Offline-safe behavior** that resumes browser downloads when Firelink is closed or rejects a handoff.
- **Protocol-aware compatibility** so older desktop builds are rejected before automatic capture can cancel a browser download.
- Automatic capture for regular browser downloads.
- Explicit Fetch media actions from the popup and page context menu.
- Context-menu actions for links and selected text.
- Firefox and Chromium support.
- Signed local requests using the token from **Settings -> Integrations**.
- Fallback to the browser download when Firelink is closed or rejects a handoff.
- Captured links always open Firelink's Add window before anything is added to the download list.
Install the extension, open Firelink, then pair it from **Settings -> Integrations**. The Firefox add-on is maintained in the [Firelink-Extension](https://github.com/nimbold/Firelink-Extension) repository and is also vendored here as the `Extensions/Firefox` submodule.
Install the extension, open Firelink, then pair it from **Settings -> Integrations**. Firefox users can install from Mozilla Add-ons. Chromium users can use the [manual load-unpacked flow](https://github.com/nimbold/Firelink-Extension#manual-chromium-installation) with `firelink-chromium.zip` from the [extension releases](https://github.com/nimbold/Firelink-Extension/releases). Firelink Companion 2.0.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
View File
@@ -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
View File
@@ -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
View File
@@ -4,10 +4,10 @@
"aarch64-apple-darwin": {
"engines": {
"yt-dlp": {
"version": "2026.06.09",
"version": "2026.07.04",
"source": "https://github.com/yt-dlp/yt-dlp",
"build": "PyInstaller onedir distribution with embedded Python and yt_dlp_ejs",
"sha256": "4eefb498e76f8a425bec30ba3ee2079b01542ca39ca1fb61b79966450794cc13"
"sha256": "ff7d4fc44b8fbf42da021c1bca950da0326cdb0cdb84992fdc7fb7ec215df435"
},
"aria2c": {
"version": "1.37.0",
@@ -16,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,
+602 -181
View File
File diff suppressed because it is too large Load Diff
+10 -9
View File
@@ -1,7 +1,7 @@
{
"name": "firelink",
"private": true,
"version": "1.0.0",
"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
View File
@@ -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) {
+88 -15
View File
@@ -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
View File
@@ -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 {
+272
View File
@@ -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/
);
});
+306
View File
@@ -0,0 +1,306 @@
#!/usr/bin/env node
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { execFileSync, spawnSync } from 'node:child_process';
function argValue(name) {
const index = process.argv.indexOf(name);
return index >= 0 ? process.argv[index + 1] : undefined;
}
const appArg = argValue('--app');
const dmgArg = argValue('--dmg');
if (process.platform !== 'darwin') {
console.error('macOS signing verification must run on a macOS host.');
process.exit(1);
}
if (!appArg && !dmgArg) {
console.error('Pass --app <Firelink.app> and/or --dmg <Firelink.dmg>.');
process.exit(1);
}
let exitCode = 0;
function fail(message) {
console.error(`[FAIL] ${message}`);
exitCode = 1;
}
function ok(message) {
console.log(`[OK] ${message}`);
}
function note(message) {
console.log(`[INFO] ${message}`);
}
function run(command, args, options = {}) {
return execFileSync(command, args, {
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'pipe'],
...options,
});
}
function runResult(command, args) {
return spawnSync(command, args, {
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'pipe'],
});
}
function assertAppPath(appPath, label) {
if (!fs.existsSync(appPath)) {
fail(`${label} does not exist at ${appPath}`);
return false;
}
const stat = fs.statSync(appPath);
if (!stat.isDirectory() || path.extname(appPath) !== '.app') {
fail(`${label} is not an .app bundle: ${appPath}`);
return false;
}
ok(`${label} exists: ${appPath}`);
return true;
}
function codesignDetails(targetPath) {
const result = runResult('codesign', ['-dv', '--verbose=4', targetPath]);
return {
ok: result.status === 0,
output: `${result.stdout || ''}${result.stderr || ''}`,
};
}
function verifyCodeSignature(targetPath, label, options = {}) {
const {
deep = false,
quietOk = false,
requireAdhoc = false,
warnOnVerifyFailure = false,
} = options;
const verifyArgs = ['--verify'];
if (deep) {
verifyArgs.push('--deep');
}
verifyArgs.push('--strict', '--verbose=2', targetPath);
const details = codesignDetails(targetPath);
if (!details.ok) {
fail(`${label}: no readable code signature: ${details.output.trim() || 'codesign -dv failed'}`);
return 'failed';
}
let status = 'verified';
try {
run('codesign', verifyArgs);
if (!quietOk) {
ok(`${label}: codesign verification passed`);
}
} catch (error) {
const detail = error.stderr?.trim() || error.message;
if (!warnOnVerifyFailure) {
fail(`${label}: codesign verification failed: ${detail}`);
return 'failed';
}
note(`${label}: signed, but individual verification warned: ${detail}`);
status = 'warning';
}
if (requireAdhoc && !details.output.includes('Signature=adhoc')) {
fail(`${label}: expected ad-hoc signature, but codesign did not report Signature=adhoc`);
return 'failed';
}
if (requireAdhoc && !quietOk) {
ok(`${label}: ad-hoc signature present`);
}
return status;
}
function walkFiles(root, visitor) {
for (const entry of fs.readdirSync(root, { withFileTypes: true })) {
const entryPath = path.join(root, entry.name);
if (entry.isSymbolicLink()) {
continue;
}
if (entry.isDirectory()) {
walkFiles(entryPath, visitor);
continue;
}
if (entry.isFile()) {
visitor(entryPath);
}
}
}
function fileBrief(filePath) {
try {
return run('file', ['--brief', filePath], { timeout: 5000 }).trim();
} catch (error) {
fail(`file(1) failed for ${filePath}: ${error.stderr?.trim() || error.message}`);
return '';
}
}
function verifyMachOObjects(appPath, label) {
const machOFiles = [];
walkFiles(appPath, filePath => {
const description = fileBrief(filePath);
if (description.includes('Mach-O')) {
machOFiles.push(filePath);
}
});
if (machOFiles.length === 0) {
fail(`${label}: no Mach-O files found inside app bundle`);
return;
}
let warningCount = 0;
let signedCount = 0;
let verifiedCount = 0;
for (const filePath of machOFiles) {
const relative = path.relative(appPath, filePath).split(path.sep).join('/');
const isPrimaryExecutable = relative === 'Contents/MacOS/firelink';
const isDirectEngine = /^Contents\/Resources\/engine-dist\/[^/]+\/(?:yt-dlp|aria2c|ffmpeg|deno)-/.test(relative);
const mayWarn = !isPrimaryExecutable && !isDirectEngine;
const result = verifyCodeSignature(filePath, `${label} ${relative}`, {
quietOk: true,
warnOnVerifyFailure: mayWarn,
});
if (result !== 'failed') {
signedCount += 1;
if (result === 'verified') {
verifiedCount += 1;
} else {
warningCount += 1;
}
}
}
ok(`${label}: found signatures on ${signedCount}/${machOFiles.length} Mach-O code object(s)`);
ok(`${label}: individually verified ${verifiedCount}/${machOFiles.length} Mach-O code object(s)`);
if (warningCount > 0) {
note(`${label}: ${warningCount} nested signed framework object(s) produced individual verification warnings; the outer bundle signature remains authoritative.`);
}
}
function assessGatekeeper(appPath, label) {
const result = runResult('spctl', ['--assess', '--type', 'execute', '--verbose=4', appPath]);
const output = `${result.stdout || ''}${result.stderr || ''}`.trim();
if (result.status === 0) {
note(`${label}: Gatekeeper accepted this app (${output || 'no spctl detail'}).`);
return;
}
const normalized = output.toLowerCase();
if (normalized.includes('not signed at all') || normalized.includes('invalid signature')) {
fail(`${label}: Gatekeeper rejection indicates a broken signature: ${output}`);
return;
}
note(`${label}: Gatekeeper rejected the app as expected for ad-hoc, unnotarized distribution: ${output || `exit ${result.status}`}`);
}
function reportQuarantine(targetPath, label) {
const result = runResult('xattr', ['-p', 'com.apple.quarantine', targetPath]);
if (result.status === 0) {
fail(`${label}: build artifact unexpectedly has com.apple.quarantine=${result.stdout.trim()}`);
} else {
ok(`${label}: no quarantine xattr on generated artifact`);
}
}
function verifyApp(appPath, label) {
const resolved = path.resolve(appPath);
if (!assertAppPath(resolved, label)) {
return;
}
reportQuarantine(resolved, label);
verifyCodeSignature(resolved, label, { deep: true, requireAdhoc: true });
verifyMachOObjects(resolved, label);
assessGatekeeper(resolved, label);
}
function attachDmg(dmgPath) {
const mountPoint = fs.mkdtempSync(path.join(os.tmpdir(), 'firelink-dmg-'));
try {
const plist = run('hdiutil', [
'attach',
'-plist',
'-nobrowse',
'-readonly',
'-mountpoint',
mountPoint,
dmgPath,
], { timeout: 60000 });
return { mountPoint, plist };
} catch (error) {
fs.rmSync(mountPoint, { recursive: true, force: true });
throw error;
}
}
function detachDmg(mountPoint) {
const result = runResult('hdiutil', ['detach', mountPoint]);
if (result.status !== 0) {
note(`Initial hdiutil detach failed, retrying with -force: ${result.stderr?.trim() || result.stdout?.trim()}`);
const forced = runResult('hdiutil', ['detach', '-force', mountPoint]);
if (forced.status !== 0) {
fail(`Failed to detach DMG mount point ${mountPoint}: ${forced.stderr?.trim() || forced.stdout?.trim()}`);
}
}
fs.rmSync(mountPoint, { recursive: true, force: true });
}
function verifyDmg(dmgPath) {
const resolved = path.resolve(dmgPath);
if (!fs.existsSync(resolved)) {
fail(`DMG does not exist at ${resolved}`);
return;
}
reportQuarantine(resolved, 'DMG');
let mount;
try {
mount = attachDmg(resolved);
ok(`DMG mounted at ${mount.mountPoint}`);
const apps = fs.readdirSync(mount.mountPoint)
.filter(name => name.endsWith('.app'))
.map(name => path.join(mount.mountPoint, name));
if (apps.length !== 1) {
fail(`Expected exactly one .app inside DMG, found ${apps.length}`);
return;
}
verifyApp(apps[0], 'DMG app');
} catch (error) {
fail(`DMG verification failed: ${error.stderr?.trim() || error.message}`);
} finally {
if (mount) {
detachDmg(mount.mountPoint);
}
}
}
if (appArg) {
verifyApp(appArg, 'Built app');
}
if (dmgArg) {
verifyDmg(dmgArg);
}
console.log('');
if (exitCode !== 0) {
console.error('[FAIL] macOS signing verification failed.');
process.exit(1);
}
console.log('[PASS] macOS ad-hoc signing verification passed.');
+26
View File
@@ -0,0 +1,26 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import test from 'node:test';
const capability = JSON.parse(fs.readFileSync('src-tauri/capabilities/default.json', 'utf8'));
const permissions = new Set(capability.permissions);
test('custom window controls have required Tauri permissions', () => {
const windowControls = fs.readFileSync('src/components/WindowControls.tsx', 'utf8');
const requiredPermissions = new Map([
['.close()', 'core:window:allow-close'],
['.minimize()', 'core:window:allow-minimize'],
['.startDragging()', 'core:window:allow-start-dragging'],
['.toggleMaximize()', 'core:window:allow-toggle-maximize'],
]);
for (const [apiCall, permission] of requiredPermissions) {
if (windowControls.includes(apiCall)) {
assert.equal(
permissions.has(permission),
true,
`${apiCall} requires ${permission} in src-tauri/capabilities/default.json`
);
}
}
});
+832 -644
View File
File diff suppressed because it is too large Load Diff
+14 -11
View File
@@ -1,6 +1,6 @@
[package]
name = "firelink"
version = "1.0.0"
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>
@@ -7,7 +7,7 @@
<key>CFBundleIdentifier</key>
<string>org.python.python</string>
<key>CFBundleVersion</key>
<string>3.14.5</string>
<string>3.14.6</string>
<key>CFBundlePackageType</key>
<string>FMWK</string>
<key>CFBundleSignature</key>
@@ -17,7 +17,7 @@
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleShortVersionString</key>
<string>3.14.5</string>
<string>3.14.6</string>
<key>CFBundleSupportedPlatforms</key>
<array>
<string>MacOSX</string>
@@ -7,7 +7,7 @@
<key>CFBundleIdentifier</key>
<string>org.python.python</string>
<key>CFBundleVersion</key>
<string>3.14.5</string>
<string>3.14.6</string>
<key>CFBundlePackageType</key>
<string>FMWK</string>
<key>CFBundleSignature</key>
@@ -17,7 +17,7 @@
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleShortVersionString</key>
<string>3.14.5</string>
<string>3.14.6</string>
<key>CFBundleSupportedPlatforms</key>
<array>
<string>MacOSX</string>
Binary file not shown.
@@ -3528,35 +3528,6 @@ xeSDwWrruoBa3lwtcHb4yOWHh8qgnaHlIhInD0Q9HWzq1MKLL295q39QpsQZp6F6
t5b5wR9iWqJDB0BeJsas7a5wFsWqynKKTbDPAYsDP27X
-----END CERTIFICATE-----
# Issuer: CN=SecureSign Root CA12 O=Cybertrust Japan Co., Ltd.
# Subject: CN=SecureSign Root CA12 O=Cybertrust Japan Co., Ltd.
# Label: "SecureSign Root CA12"
# Serial: 587887345431707215246142177076162061960426065942
# MD5 Fingerprint: c6:89:ca:64:42:9b:62:08:49:0b:1e:7f:e9:07:3d:e8
# SHA1 Fingerprint: 7a:22:1e:3d:de:1b:06:ac:9e:c8:47:70:16:8e:3c:e5:f7:6b:06:f4
# SHA256 Fingerprint: 3f:03:4b:b5:70:4d:44:b2:d0:85:45:a0:20:57:de:93:eb:f3:90:5f:ce:72:1a:cb:c7:30:c0:6d:da:ee:90:4e
-----BEGIN CERTIFICATE-----
MIIDcjCCAlqgAwIBAgIUZvnHwa/swlG07VOX5uaCwysckBYwDQYJKoZIhvcNAQEL
BQAwUTELMAkGA1UEBhMCSlAxIzAhBgNVBAoTGkN5YmVydHJ1c3QgSmFwYW4gQ28u
LCBMdGQuMR0wGwYDVQQDExRTZWN1cmVTaWduIFJvb3QgQ0ExMjAeFw0yMDA0MDgw
NTM2NDZaFw00MDA0MDgwNTM2NDZaMFExCzAJBgNVBAYTAkpQMSMwIQYDVQQKExpD
eWJlcnRydXN0IEphcGFuIENvLiwgTHRkLjEdMBsGA1UEAxMUU2VjdXJlU2lnbiBS
b290IENBMTIwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQC6OcE3emhF
KxS06+QT61d1I02PJC0W6K6OyX2kVzsqdiUzg2zqMoqUm048luT9Ub+ZyZN+v/mt
p7JIKwccJ/VMvHASd6SFVLX9kHrko+RRWAPNEHl57muTH2SOa2SroxPjcf59q5zd
J1M3s6oYwlkm7Fsf0uZlfO+TvdhYXAvA42VvPMfKWeP+bl+sg779XSVOKik71gur
FzJ4pOE+lEa+Ym6b3kaosRbnhW70CEBFEaCeVESE99g2zvVQR9wsMJvuwPWW0v4J
hscGWa5Pro4RmHvzC1KqYiaqId+OJTN5lxZJjfU+1UefNzFJM3IFTQy2VYzxV4+K
h9GtxRESOaCtAgMBAAGjQjBAMA8GA1UdEwEB/wQFMAMBAf8wDgYDVR0PAQH/BAQD
AgEGMB0GA1UdDgQWBBRXNPN0zwRL1SXm8UC2LEzZLemgrTANBgkqhkiG9w0BAQsF
AAOCAQEAPrvbFxbS8hQBICw4g0utvsqFepq2m2um4fylOqyttCg6r9cBg0krY6Ld
mmQOmFxv3Y67ilQiLUoT865AQ9tPkbeGGuwAtEGBpE/6aouIs3YIcipJQMPTw4WJ
mBClnW8Zt7vPemVV2zfrPIpyMpcemik+rY3moxtt9XUa5rBouVui7mlHJzWhhpmA
8zNL4WukJsPvdFlseqJkth5Ew1DgDzk9qTPxpfPSvWKErI4cqc1avTc7bgoitPQV
55FYxTpE05Uo2cBl6XLK0A+9H7MV2anjpEcJnuDLN/v9vZfVvhgaaaI5gdka9at/
yOPiZwud9AzqVN/Ssq+xIvEg37xEHA==
-----END CERTIFICATE-----
# Issuer: CN=SecureSign Root CA14 O=Cybertrust Japan Co., Ltd.
# Subject: CN=SecureSign Root CA14 O=Cybertrust Japan Co., Ltd.
# Label: "SecureSign Root CA14"
@@ -1,60 +1,60 @@
../../../bin/curl-cffi,sha256=1WNmf_w0emP0-PeIpDGlHiBy4Gdet6ASfQxBOR-BNMA,187
curl_cffi-0.15.0.dist-info/INSTALLER,sha256=zuuue4knoyJ-UwPPXg8fezS7VCrXJQrAP7zeNuwvFQg,4
curl_cffi-0.15.0.dist-info/METADATA,sha256=ryYvNCZOl96BIMI2134ZhyRWy7sr-0-xOc0Q_TfaU9A,18405
curl_cffi-0.15.0.dist-info/RECORD,,
curl_cffi-0.15.0.dist-info/REQUESTED,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
curl_cffi-0.15.0.dist-info/WHEEL,sha256=BX73HzL_Z8VmcUg66xwFel1CJ1goP9ZPbeeVSSFaesY,140
curl_cffi-0.15.0.dist-info/direct_url.json,sha256=lpNNgQ8844-2Q1nCwxiwtLw07Je0dsNXHPwhnLfnit4,313
curl_cffi-0.15.0.dist-info/entry_points.txt,sha256=HzTwoUpbiKIo5uJrqnxPk5OdxZ0PhT0dPTSqUP8Js70,49
curl_cffi-0.15.0.dist-info/licenses/LICENSE,sha256=PoiwKbULav021rGGQs5Mi27uTJA_HPq-9bgR9h4HBQs,1106
curl_cffi-0.15.0.dist-info/top_level.txt,sha256=b51YB50I_vu6XAbSERmqtgaYciYADCA_baVoZ_T5Lzs,10
curl_cffi/__init__.py,sha256=fPBkVIThnbk_a7U41V5S3Qwhh1_WxZkMerXQNv1iOpA,1781
curl_cffi/__main__.py,sha256=L0AH-xyDlgmsPM-HTecPVb1_hV9X35Qf1Mw7h7JVnx0,39
curl_cffi/__pycache__/__init__.cpython-314.pyc,,
curl_cffi/__pycache__/__main__.cpython-314.pyc,,
curl_cffi/__pycache__/__version__.cpython-314.pyc,,
curl_cffi/__pycache__/_asyncio_selector.cpython-314.pyc,,
curl_cffi/__pycache__/aio.cpython-314.pyc,,
curl_cffi/__pycache__/const.cpython-314.pyc,,
curl_cffi/__pycache__/curl.cpython-314.pyc,,
curl_cffi/__pycache__/utils.cpython-314.pyc,,
curl_cffi/__version__.py,sha256=FdOv9cpHIUGkFkcWA4O9gte0sun1QgJI69rIgzUkswo,413
curl_cffi/_asyncio_selector.py,sha256=dxKOC6B8mlRu3WYi0trSq0L-FsWV6l_pg9TP35OpDI4,13043
curl_cffi/_wrapper.abi3.so,sha256=4sMTX_Q2WMouO96xfxc3BZjZxnMZnLjZV8nF_FCU_zE,12691712
curl_cffi/aio.py,sha256=oTiffUFDNfxxSHmCVAsjX0CIOs4IuBQ6mhm4WuY5KZs,11925
curl_cffi/cli/__init__.py,sha256=y-XQAfYkBDN3ejfVex1aKy5bcqsPUglAvm-i177wM5I,7290
curl_cffi/cli/__pycache__/__init__.cpython-314.pyc,,
curl_cffi/cli/__pycache__/doctor.cpython-314.pyc,,
curl_cffi/cli/__pycache__/output.cpython-314.pyc,,
curl_cffi/cli/__pycache__/parse.cpython-314.pyc,,
curl_cffi/cli/__pycache__/request.cpython-314.pyc,,
curl_cffi/cli/__pycache__/run.cpython-314.pyc,,
curl_cffi/cli/doctor.py,sha256=4wfyWF44JLxDtoH9YCDdmxrJ-1cWbNwSXcMc9Aopd1c,416
curl_cffi/cli/output.py,sha256=YzWbYLJ2zowR6G9mJxeTytcPrnSk2WQVwfmv8V404X4,6709
curl_cffi/cli/parse.py,sha256=OzqGEZPqOrH2nq14UKnaXwKAblAw6V867tDEp9XSLdo,2549
curl_cffi/cli/request.py,sha256=MJskNMJBIR8oq_fvrPvsi61Wy-bR4-aC8QQJb1YcqRg,3522
curl_cffi/cli/run.py,sha256=WYaPbX9h85DZQNkDbUUyvp3bryaAuNOxnjU6GpenigQ,7554
curl_cffi/const.py,sha256=mF3H1kHsqj-dfd7GWHMHyuB2bZ3QZd61tC1iCZgEjyo,18523
curl_cffi/curl.py,sha256=woBPw1OQk4FH7neNETuj_GwqcQa1jCjd1tA0ZxPSTA8,26928
curl_cffi/py.typed,sha256=dcrsqJrcYfTX-ckLFJMTaj6mD8aDe2u0tkQG-ZYxnEg,26
curl_cffi/requests/__init__.py,sha256=3ZO7mC7gNJZydze0VQLEYpEc9dMlf1uRbFS-fxKSQPs,6148
curl_cffi/requests/__pycache__/__init__.cpython-314.pyc,,
curl_cffi/requests/__pycache__/cookies.cpython-314.pyc,,
curl_cffi/requests/__pycache__/errors.cpython-314.pyc,,
curl_cffi/requests/__pycache__/exceptions.cpython-314.pyc,,
curl_cffi/requests/__pycache__/headers.cpython-314.pyc,,
curl_cffi/requests/__pycache__/impersonate.cpython-314.pyc,,
curl_cffi/requests/__pycache__/models.cpython-314.pyc,,
curl_cffi/requests/__pycache__/session.cpython-314.pyc,,
curl_cffi/requests/__pycache__/utils.cpython-314.pyc,,
curl_cffi/requests/__pycache__/websockets.cpython-314.pyc,,
curl_cffi/requests/cookies.py,sha256=QDEuhtsSjh6iNAKmp5TnGyyAe8wdDJCADCHY_GWCeCc,11867
curl_cffi/requests/errors.py,sha256=R6N5lmOTdRukThkNGUihDAQRu8HSh27M8E3zfUJJX74,250
curl_cffi/requests/exceptions.py,sha256=ViyLx3XHii_s7kjrO3GhVOVXhq2_UsYfAQl8MPwDnEM,6187
curl_cffi/requests/headers.py,sha256=Q1jrRdJ2YMLOcl5W3WBaKZAdLbPGPPERJ6MYzos3YXk,11535
curl_cffi/requests/impersonate.py,sha256=dKRpbPTqLCABrCmSKmgXyhA1zmrv0DMuSuqQyhn5-FU,13389
curl_cffi/requests/models.py,sha256=SaN9QUYyljUDFDQnUrYZRl9KiUe25nb2c2ca-dfq3Nw,11487
curl_cffi/requests/session.py,sha256=LkfPvAr8BIVzB_K91xwh0wIPRW26mi2VJd1cEYorIho,58871
curl_cffi/requests/utils.py,sha256=zMKaWoXcAPFNBv6FglE3FSAPNuxYr_leUcWKnhfYecw,26618
curl_cffi/requests/websockets.py,sha256=RJVNqCDrlLWzQ12Ym9M5_T0SW4EmoqKUYsXXTaQmBRQ,71685
curl_cffi/utils.py,sha256=gRVzO-vhjf596V6kr_SjwHlwJfDIwTrPbRLJvvNlUNE,307
../../../bin/curl-cffi,sha256=1WNmf_w0emP0-PeIpDGlHiBy4Gdet6ASfQxBOR-BNMA,187
curl_cffi-0.15.0.dist-info/INSTALLER,sha256=zuuue4knoyJ-UwPPXg8fezS7VCrXJQrAP7zeNuwvFQg,4
curl_cffi-0.15.0.dist-info/METADATA,sha256=ryYvNCZOl96BIMI2134ZhyRWy7sr-0-xOc0Q_TfaU9A,18405
curl_cffi-0.15.0.dist-info/RECORD,,
curl_cffi-0.15.0.dist-info/REQUESTED,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
curl_cffi-0.15.0.dist-info/WHEEL,sha256=BX73HzL_Z8VmcUg66xwFel1CJ1goP9ZPbeeVSSFaesY,140
curl_cffi-0.15.0.dist-info/direct_url.json,sha256=ORhsskaIg_jwHIDYD7oRH5nzN3fKYa7xVFg-8WceJ5E,313
curl_cffi-0.15.0.dist-info/entry_points.txt,sha256=HzTwoUpbiKIo5uJrqnxPk5OdxZ0PhT0dPTSqUP8Js70,49
curl_cffi-0.15.0.dist-info/licenses/LICENSE,sha256=PoiwKbULav021rGGQs5Mi27uTJA_HPq-9bgR9h4HBQs,1106
curl_cffi-0.15.0.dist-info/top_level.txt,sha256=b51YB50I_vu6XAbSERmqtgaYciYADCA_baVoZ_T5Lzs,10
curl_cffi/__init__.py,sha256=fPBkVIThnbk_a7U41V5S3Qwhh1_WxZkMerXQNv1iOpA,1781
curl_cffi/__main__.py,sha256=L0AH-xyDlgmsPM-HTecPVb1_hV9X35Qf1Mw7h7JVnx0,39
curl_cffi/__pycache__/__init__.cpython-314.pyc,,
curl_cffi/__pycache__/__main__.cpython-314.pyc,,
curl_cffi/__pycache__/__version__.cpython-314.pyc,,
curl_cffi/__pycache__/_asyncio_selector.cpython-314.pyc,,
curl_cffi/__pycache__/aio.cpython-314.pyc,,
curl_cffi/__pycache__/const.cpython-314.pyc,,
curl_cffi/__pycache__/curl.cpython-314.pyc,,
curl_cffi/__pycache__/utils.cpython-314.pyc,,
curl_cffi/__version__.py,sha256=FdOv9cpHIUGkFkcWA4O9gte0sun1QgJI69rIgzUkswo,413
curl_cffi/_asyncio_selector.py,sha256=dxKOC6B8mlRu3WYi0trSq0L-FsWV6l_pg9TP35OpDI4,13043
curl_cffi/_wrapper.abi3.so,sha256=4sMTX_Q2WMouO96xfxc3BZjZxnMZnLjZV8nF_FCU_zE,12691712
curl_cffi/aio.py,sha256=oTiffUFDNfxxSHmCVAsjX0CIOs4IuBQ6mhm4WuY5KZs,11925
curl_cffi/cli/__init__.py,sha256=y-XQAfYkBDN3ejfVex1aKy5bcqsPUglAvm-i177wM5I,7290
curl_cffi/cli/__pycache__/__init__.cpython-314.pyc,,
curl_cffi/cli/__pycache__/doctor.cpython-314.pyc,,
curl_cffi/cli/__pycache__/output.cpython-314.pyc,,
curl_cffi/cli/__pycache__/parse.cpython-314.pyc,,
curl_cffi/cli/__pycache__/request.cpython-314.pyc,,
curl_cffi/cli/__pycache__/run.cpython-314.pyc,,
curl_cffi/cli/doctor.py,sha256=4wfyWF44JLxDtoH9YCDdmxrJ-1cWbNwSXcMc9Aopd1c,416
curl_cffi/cli/output.py,sha256=YzWbYLJ2zowR6G9mJxeTytcPrnSk2WQVwfmv8V404X4,6709
curl_cffi/cli/parse.py,sha256=OzqGEZPqOrH2nq14UKnaXwKAblAw6V867tDEp9XSLdo,2549
curl_cffi/cli/request.py,sha256=MJskNMJBIR8oq_fvrPvsi61Wy-bR4-aC8QQJb1YcqRg,3522
curl_cffi/cli/run.py,sha256=WYaPbX9h85DZQNkDbUUyvp3bryaAuNOxnjU6GpenigQ,7554
curl_cffi/const.py,sha256=mF3H1kHsqj-dfd7GWHMHyuB2bZ3QZd61tC1iCZgEjyo,18523
curl_cffi/curl.py,sha256=woBPw1OQk4FH7neNETuj_GwqcQa1jCjd1tA0ZxPSTA8,26928
curl_cffi/py.typed,sha256=dcrsqJrcYfTX-ckLFJMTaj6mD8aDe2u0tkQG-ZYxnEg,26
curl_cffi/requests/__init__.py,sha256=3ZO7mC7gNJZydze0VQLEYpEc9dMlf1uRbFS-fxKSQPs,6148
curl_cffi/requests/__pycache__/__init__.cpython-314.pyc,,
curl_cffi/requests/__pycache__/cookies.cpython-314.pyc,,
curl_cffi/requests/__pycache__/errors.cpython-314.pyc,,
curl_cffi/requests/__pycache__/exceptions.cpython-314.pyc,,
curl_cffi/requests/__pycache__/headers.cpython-314.pyc,,
curl_cffi/requests/__pycache__/impersonate.cpython-314.pyc,,
curl_cffi/requests/__pycache__/models.cpython-314.pyc,,
curl_cffi/requests/__pycache__/session.cpython-314.pyc,,
curl_cffi/requests/__pycache__/utils.cpython-314.pyc,,
curl_cffi/requests/__pycache__/websockets.cpython-314.pyc,,
curl_cffi/requests/cookies.py,sha256=QDEuhtsSjh6iNAKmp5TnGyyAe8wdDJCADCHY_GWCeCc,11867
curl_cffi/requests/errors.py,sha256=R6N5lmOTdRukThkNGUihDAQRu8HSh27M8E3zfUJJX74,250
curl_cffi/requests/exceptions.py,sha256=ViyLx3XHii_s7kjrO3GhVOVXhq2_UsYfAQl8MPwDnEM,6187
curl_cffi/requests/headers.py,sha256=Q1jrRdJ2YMLOcl5W3WBaKZAdLbPGPPERJ6MYzos3YXk,11535
curl_cffi/requests/impersonate.py,sha256=dKRpbPTqLCABrCmSKmgXyhA1zmrv0DMuSuqQyhn5-FU,13389
curl_cffi/requests/models.py,sha256=SaN9QUYyljUDFDQnUrYZRl9KiUe25nb2c2ca-dfq3Nw,11487
curl_cffi/requests/session.py,sha256=LkfPvAr8BIVzB_K91xwh0wIPRW26mi2VJd1cEYorIho,58871
curl_cffi/requests/utils.py,sha256=zMKaWoXcAPFNBv6FglE3FSAPNuxYr_leUcWKnhfYecw,26618
curl_cffi/requests/websockets.py,sha256=RJVNqCDrlLWzQ12Ym9M5_T0SW4EmoqKUYsXXTaQmBRQ,71685
curl_cffi/utils.py,sha256=gRVzO-vhjf596V6kr_SjwHlwJfDIwTrPbRLJvvNlUNE,307
@@ -1 +1 @@
{"archive_info": {"hash": "sha256=bf985a468ffb52bdcdfa1dac713555144c7711521428cd9267578a208c8f45ab", "hashes": {"sha256": "bf985a468ffb52bdcdfa1dac713555144c7711521428cd9267578a208c8f45ab"}}, "url": "file:///Users/runner/work/yt-dlp/yt-dlp/build/universal2/curl_cffi-0.15.0-cp310-abi3-macosx_10_9_universal2.whl"}
{"archive_info": {"hash": "sha256=630653d283070b21deca8ee408dcdc98ac4202d80cc9d038db805d45e0b40550", "hashes": {"sha256": "630653d283070b21deca8ee408dcdc98ac4202d80cc9d038db805d45e0b40550"}}, "url": "file:///Users/runner/work/yt-dlp/yt-dlp/build/universal2/curl_cffi-0.15.0-cp310-abi3-macosx_10_9_universal2.whl"}
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+5 -1
View File
@@ -5,11 +5,15 @@
"windows": ["main"],
"permissions": [
"core:default",
"core:window:allow-close",
"core:window:allow-minimize",
"core:window:allow-start-dragging",
"core:window:allow-toggle-maximize",
"opener:default",
"dialog:default",
"log:default",
"notification:default",
"notification:allow-is-permission-granted"
"notification:allow-is-permission-granted",
"clipboard-manager:allow-read-text"
]
}
+49 -14
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+268 -656
View File
File diff suppressed because it is too large Load Diff
+55 -14
View File
@@ -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");
}
}
+215 -18
View File
@@ -7,7 +7,7 @@ use axum::{
routing::{get, post},
Router,
};
use hmac::{Hmac, Mac};
use hmac::{Hmac, KeyInit, Mac};
use reqwest::Url;
use serde::{Deserialize, Serialize};
use sha2::Sha256;
@@ -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
View File
@@ -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)]

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