feat(release): add cross-platform packaging

Add target-aware engine provisioning, platform package configs, and CI/release verification for macOS arm64, Windows x64, and Linux AppImage.
This commit is contained in:
NimBold
2026-06-23 21:26:51 +03:30
parent 0e3118ec2c
commit f603b74a99
41 changed files with 1889 additions and 382 deletions
+53 -21
View File
@@ -3,38 +3,70 @@ name: CI
on:
pull_request:
push:
branches:
- main
branches: [main]
permissions:
contents: read
jobs:
desktop:
name: Desktop checks
runs-on: macos-latest
frontend:
name: Frontend checks
runs-on: ubuntu-22.04
steps:
- name: Check out repository
uses: actions/checkout@v6
- uses: actions/checkout@v6
with:
submodules: recursive
- name: Set up Node.js
uses: actions/setup-node@v6
- uses: actions/setup-node@v6
with:
node-version: 22
cache: npm
- run: npm ci
- run: npm test -- --run
- run: npm run build
- name: Install frontend dependencies
run: npm ci
- name: Build frontend
run: npm run build
- name: Verify bundled engines
run: node scripts/verify-binaries.js
desktop:
name: Desktop checks (${{ matrix.target }})
strategy:
fail-fast: false
matrix:
include:
- os: macos-latest
target: aarch64-apple-darwin
- os: windows-latest
target: x86_64-pc-windows-msvc
- os: ubuntu-22.04
target: x86_64-unknown-linux-gnu
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v6
with:
submodules: recursive
- uses: actions/setup-node@v6
with:
node-version: 22
cache: npm
- uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.target }}
- name: Install Linux dependencies
if: runner.os == 'Linux'
run: |
sudo apt-get update
sudo apt-get install -y \
libwebkit2gtk-4.1-dev \
libappindicator3-dev \
librsvg2-dev \
patchelf \
libdbus-1-dev \
pkg-config
- run: npm ci
- name: Provision locked engines
if: runner.os != 'macOS'
run: node scripts/provision-engines.js --target ${{ matrix.target }}
- name: Stage and verify engines
run: |
node scripts/stage-engines.js --target ${{ matrix.target }}
node scripts/verify-binaries.js --staged --target ${{ matrix.target }}
- name: Test Rust backend
working-directory: src-tauri
run: cargo test --all-targets
run: cargo test --all-targets --target ${{ matrix.target }}
+83 -66
View File
@@ -2,89 +2,106 @@ name: Release
on:
push:
tags:
- 'v*'
tags: ['v*']
workflow_dispatch:
permissions:
contents: write
jobs:
engine-verification:
name: macOS pre-release gate
runs-on: macos-latest
build:
name: Build ${{ matrix.label }}
strategy:
fail-fast: false
matrix:
include:
- label: macOS-arm64
os: macos-latest
target: aarch64-apple-darwin
bundles: dmg
artifact: src-tauri/target/aarch64-apple-darwin/release/bundle/dmg/*.dmg
- label: Windows-x64
os: windows-latest
target: x86_64-pc-windows-msvc
bundles: nsis
artifact: src-tauri/target/x86_64-pc-windows-msvc/release/bundle/nsis/*.exe
- label: Linux-x64-AppImage
os: ubuntu-22.04
target: x86_64-unknown-linux-gnu
bundles: appimage
artifact: src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/appimage/*.AppImage
runs-on: ${{ matrix.os }}
steps:
- name: Check out repository
uses: actions/checkout@v6
- uses: actions/checkout@v6
with:
submodules: recursive
- name: Set up Node.js
uses: actions/setup-node@v6
- uses: actions/setup-node@v6
with:
node-version: 22
cache: npm
- name: Install frontend dependencies
run: npm ci
- name: Verify bundled engines (pre-build)
run: node scripts/verify-binaries.js
- name: Build macOS application bundle
run: npm run tauri build
env:
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
- name: Run engine self-test against packaged app
run: |
APP_PATH="src-tauri/target/release/bundle/macos/Firelink.app"
if [ -d "$APP_PATH" ]; then
echo "Testing engines from built bundle: $APP_PATH"
node scripts/verify-binaries.js
else
echo "Warning: .app bundle not found at $APP_PATH"
node scripts/verify-binaries.js
fi
- name: Upload macOS bundle artifact
uses: actions/upload-artifact@v4
- uses: dtolnay/rust-toolchain@stable
with:
name: firelink-macos-${{ github.ref_name }}
path: src-tauri/target/release/bundle/macos/
targets: ${{ matrix.target }}
- name: Install Linux dependencies
if: runner.os == 'Linux'
run: |
sudo apt-get update
sudo apt-get install -y \
libwebkit2gtk-4.1-dev \
libappindicator3-dev \
librsvg2-dev \
patchelf \
libdbus-1-dev \
pkg-config \
xvfb
- run: npm ci
- name: Provision locked engines
if: runner.os != 'macOS'
run: node scripts/provision-engines.js --target ${{ matrix.target }}
- name: Build package
run: npm run tauri build -- --target ${{ matrix.target }} --bundles ${{ matrix.bundles }}
- name: Verify macOS packaged engines and launch
if: runner.os == 'macOS'
run: |
APP="src-tauri/target/${{ matrix.target }}/release/bundle/macos/Firelink.app"
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
if: runner.os == 'Windows'
shell: pwsh
run: |
$installer = Get-ChildItem "src-tauri/target/${{ matrix.target }}/release/bundle/nsis/*.exe" | Select-Object -First 1
7z x $installer.FullName "-o$env:RUNNER_TEMP/firelink-installer" -y
node scripts/verify-binaries.js --search-root "$env:RUNNER_TEMP/firelink-installer" --target ${{ matrix.target }}
- name: Verify Linux AppImage payload and launch
if: runner.os == 'Linux'
run: |
APPIMAGE="$(find src-tauri/target/${{ matrix.target }}/release/bundle/appimage -name '*.AppImage' -print -quit)"
chmod +x "$APPIMAGE"
(cd "$RUNNER_TEMP" && "$GITHUB_WORKSPACE/$APPIMAGE" --appimage-extract >/dev/null)
node scripts/verify-binaries.js --search-root "$RUNNER_TEMP/squashfs-root" --target ${{ matrix.target }}
xvfb-run -a node scripts/smoke-packaged-app.js --executable "$RUNNER_TEMP/squashfs-root/AppRun"
- uses: actions/upload-artifact@v4
with:
name: Firelink-${{ matrix.label }}-${{ github.ref_name }}
path: ${{ matrix.artifact }}
if-no-files-found: error
create-release:
name: Create release
needs: engine-verification
runs-on: ubuntu-latest
publish:
name: Publish GitHub release
if: github.ref_type == 'tag'
needs: build
runs-on: ubuntu-22.04
steps:
- name: Download macOS bundle artifact
uses: actions/download-artifact@v4
- uses: actions/download-artifact@v4
with:
name: firelink-macos-${{ github.ref_name }}
path: release-assets/
- name: Generate release notes
path: release-assets
merge-multiple: true
- name: Generate checksums
run: |
echo "## Firelink ${{ github.ref_name }}" > RELEASE_NOTES.md
echo "" >> RELEASE_NOTES.md
echo "### Checksums" >> RELEASE_NOTES.md
echo '```' >> RELEASE_NOTES.md
shasum -a 256 release-assets/* 2>/dev/null || true
echo '```' >> RELEASE_NOTES.md
- name: Publish release
uses: softprops/action-gh-release@v2
cd release-assets
find . -type f -print0 | sort -z | xargs -0 sha256sum > SHA256SUMS
- uses: softprops/action-gh-release@v2
with:
files: release-assets/**
body_path: RELEASE_NOTES.md
generate_release_notes: true
+3
View File
@@ -1,5 +1,6 @@
# OS and editor files
.DS_Store
skills-lock.json
.idea/
.vscode/
*.suo
@@ -24,6 +25,8 @@ lerna-debug.log*
target/
src-tauri/target/
src-tauri/gen/
src-tauri/engine-dist/
src-tauri/provisioned-engines/
# Locally provisioned native engines
src-tauri/binaries/aria2c
+140
View File
@@ -0,0 +1,140 @@
# Firelink Cross-Platform Release Checklist
Audit date: 2026-06-23
Targets:
- macOS arm64
- Windows x64
- Linux x64 AppImage
## Current status
| Target | Implementation | Validation |
|---|---|---|
| macOS arm64 | Complete | Native build, packaged-engine verification, and packaged launch smoke passed |
| Windows x64 | Complete | Payload provision/static verification passed; native CI and clean-machine QA pending |
| Linux x64 AppImage | Complete | Payload provision/static verification passed; native CI and desktop-matrix QA pending |
Windows/Linux publication remains blocked until native GitHub Actions and clean-machine QA pass. macOS distribution is intentionally unsigned and unnotarized because no Apple Developer account is planned.
## Implemented foundations
- Central target triple, executable suffix, trusted system `PATH`, engine naming, and path-comparison helpers.
- Target-aware engine resolver for development and packaged resources.
- Absolute yt-dlp downloader/tool paths. Unix symlink staging removed.
- Official PyInstaller **onedir** yt-dlp payloads retained for every target.
- Checksum-pinned source archives, payload manifests, target-only staging, and packaged-resource verification.
- macOS Keychain, Windows Credential Manager, and Linux Secret Service keyring backends.
- Session-only browser pairing fallback when native credential storage is unavailable.
- OS standard directories plus synchronously persisted user-approved download roots.
- Windows reserved filename sanitization and platform-correct duplicate path comparison.
- Platform-specific Tauri window and bundle configuration.
- Native CI matrix and release jobs for unsigned macOS DMG, unsigned Windows NSIS, and Linux AppImage.
- Linux deep-link registration without native messaging.
- Platform-aware scheduler permissions, tray/menu labels, dock badge, notifications, proxy behavior, and sleep prevention.
- Cancellable delayed post-queue system actions with active-transfer recheck.
- Bounded local logging enabled by default, secret/home-path redaction, and safe export naming.
- Third-party notices and engine provenance locks included in packages.
## Engine payloads
Required names:
```text
macOS arm64
aria2c-aarch64-apple-darwin
yt-dlp-aarch64-apple-darwin
ffmpeg-aarch64-apple-darwin
deno-aarch64-apple-darwin
Windows x64
aria2c-x86_64-pc-windows-msvc.exe
yt-dlp-x86_64-pc-windows-msvc.exe
ffmpeg-x86_64-pc-windows-msvc.exe
deno-x86_64-pc-windows-msvc.exe
Linux x64
aria2c-x86_64-unknown-linux-gnu
yt-dlp-x86_64-unknown-linux-gnu
ffmpeg-x86_64-unknown-linux-gnu
deno-x86_64-unknown-linux-gnu
```
Supply-chain files:
- `engines.lock.json`: committed macOS payload hashes.
- `engine-sources.lock.json`: Windows/Linux archive URLs and hashes.
- `scripts/provision-engines.js`: download, checksum, extract, normalize, and manifest.
- `scripts/stage-engines.js`: verify and stage one target.
- `scripts/verify-binaries.js`: architecture, runtime layout, linkage, version, startup, and aria2 RPC checks.
yt-dlp must remain launcher plus adjacent `_internal`. Onefile builds are rejected. Warm startup target remains below eight seconds; current macOS warm `--version` measured about 0.23 seconds.
## Filesystem and permissions
- Download authorization uses canonical paths and approved roots; no hardcoded `/Volumes`.
- Folder-dialog selections are approved synchronously in backend before enqueue, avoiding settings-persistence races.
- Open, reveal, replace, and delete operations remain constrained to Firelink-owned paths.
- `~`, Windows separators, missing leaf components, symlinks, and case rules are handled per platform.
- Scheduler automation permission controls appear only on macOS. Windows/Linux show honest system-policy behavior.
- Sleep prevention uses platform backend behavior and surfaces errors.
- Browser pairing survives credential-store failure only for current session and reports that state.
## Desktop integration
- macOS: dock badge, menu-bar wording, transparent sidebar window, unsigned/unnotarized release.
- Windows: system tray wording, Mica window config, NSIS installer, SmartScreen warning expected while unsigned.
- Linux: system tray wording, opaque decorated window, AppImage, runtime deep-link registration.
- Notifications request permission and surface denial/errors. Sound names are platform-specific where verified.
- Post-queue sleep/shutdown/restart waits ten seconds, can be cancelled, and aborts if transfers resume.
## Logging and privacy
- Logging starts enabled and rotates at 10 MB with three retained files.
- Authorization, cookies, signed URL queries, tokens, and home paths are redacted.
- Export avoids exposing source log directory paths.
- Logs remain local unless user explicitly exports them.
## Browser integration
- Existing authenticated loopback HTTP integration remains.
- Responses identify Firelink through `X-Firelink-Server`.
- Pairing tokens use native credential storage where available.
- No native-messaging dependency is introduced.
## Validation completed
- Frontend: 31 tests passed.
- Rust: 82 unit tests passed, 1 network-dependent test ignored.
- Download engine: 5 integration tests passed.
- Queue manager: 17 integration tests passed.
- TypeScript/Vite production build passed.
- Rust/TypeScript binding generation passed.
- Windows and Linux payload provisioning plus static architecture/runtime-layout verification passed.
- macOS target staging, engine runtime/RPC verification, release `.app` build, packaged-resource verification, notice layout, and outside-repository launch smoke passed.
- Workflow YAML parsing and `git diff --check` passed.
## Native QA still required
### Windows x64
- Run CI/release jobs on `windows-latest`.
- Install NSIS output on clean Windows 11 x64.
- Verify SmartScreen flow, tray, notifications, sleep prevention, file dialogs, path case behavior, Credential Manager persistence, browser handoff, media download, pause/resume, replace/delete, scheduler, and uninstall.
### Linux x64
- Run CI/release jobs on Ubuntu 22.04.
- Launch extracted AppImage under X11 and Wayland desktops.
- Verify Secret Service present and absent behavior, tray support variance, notifications, sleep inhibition, file dialogs, deep links, browser handoff, media download, pause/resume, replace/delete, scheduler, and AppImage portability.
### macOS arm64
- Test downloaded unsigned artifact on a clean machine.
- Confirm documented Finder/Privacy & Security approval flow.
- Verify first-launch unsigned-engine delay, notifications, menu bar, sleep prevention, scheduler automation permission, browser handoff, and media download.
## Release decision
Implementation phase is complete. Release certification is not complete until native Windows/Linux workflows and clean-machine QA pass. Failures found there must be fixed at root before publication.
+20 -8
View File
@@ -45,9 +45,13 @@ instructions below. New packaged builds will be published through
[GitHub Releases](https://github.com/nimbold/Firelink/releases) once the
migration is complete.
Firelink is currently developed and tested on macOS. Production bundles include
the media engines, so packaged releases will not require separate aria2, yt-dlp,
FFmpeg, or Deno installations.
Production bundles include target-specific media engines, so packaged releases
do not require separate aria2, yt-dlp, FFmpeg, Deno, Python, or package-manager
installations.
macOS builds are distributed without Apple code signing or notarization. Users
must approve the downloaded app through Finder or **System Settings → Privacy &
Security**. Firelink does not claim Gatekeeper trust.
### Browser Extension
@@ -63,9 +67,9 @@ root.
| Target | Status |
| --- | --- |
| macOS | Active development and automated testing |
| Windows | Core architecture prepared; packaging and validation pending |
| Linux | Core architecture prepared; packaging and validation pending |
| macOS arm64 | Automated build, engine validation, and unsigned DMG packaging |
| Windows x64 | Native CI and NSIS packaging configured; first clean-run validation pending |
| Linux x64 | Native CI and AppImage packaging configured; desktop-matrix validation pending |
See the [changelog](CHANGELOG.md) for release history and recent work.
@@ -106,8 +110,16 @@ Create a production bundle:
npm run tauri build
```
Native media executables and runtime files are expected in
`src-tauri/binaries` when running or packaging the application locally.
macOS development uses locked payloads in `src-tauri/binaries`. Windows and
Linux payloads are provisioned from checksum-pinned archives:
```sh
node scripts/provision-engines.js --target x86_64-pc-windows-msvc
node scripts/provision-engines.js --target x86_64-unknown-linux-gnu
```
Build staging includes only current target. See `engines.lock.json`,
`engine-sources.lock.json`, and [RELEASE.md](RELEASE.md).
## Repository Structure
+43 -50
View File
@@ -1,74 +1,67 @@
# Firelink Release Process
## Prerequisites
Targets:
- macOS ARM64 (aarch64) build host
- Node.js 22+
- Rust toolchain
- Tauri CLI (`cargo install tauri-cli --version "^2"`)
- Apple Developer account with signing certificates (for notarized builds)
- macOS arm64 DMG
- Windows x64 NSIS installer
- Linux x64 AppImage
## Step-by-step
## Distribution policy
### 1. Update version
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.
Bump version in these files so they match:
Windows releases are currently unsigned. SmartScreen may warn until code signing is added.
| File | Field |
|------|-------|
| `package.json` | `version` |
| `src-tauri/Cargo.toml` | `package.version` |
| `src-tauri/tauri.conf.json` | `version` |
## Engine supply chain
### 2. Verify engines locally
Firelink never falls back to system-installed media tools.
- `engines.lock.json` pins current committed macOS payload hashes.
- `engine-sources.lock.json` pins Windows/Linux source archives and checksums.
- `scripts/provision-engines.js` downloads and verifies target archives.
- `scripts/stage-engines.js` creates one target-specific bundle payload.
- `scripts/verify-binaries.js` runs architecture, packaging, version, and RPC checks.
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
Keep versions aligned:
- `package.json`
- `src-tauri/Cargo.toml`
- `src-tauri/tauri.conf.json`
## Local macOS build
```bash
node scripts/verify-binaries.js
npm ci
node scripts/stage-engines.js --target aarch64-apple-darwin
node scripts/verify-binaries.js --staged --target aarch64-apple-darwin
npm test -- --run
npm run build
cd src-tauri && cargo test --all-targets
cd ..
npm run tauri build -- --target aarch64-apple-darwin --bundles dmg
```
This runs all pre-release checks:
1. Target-triple sidecars exist
2. Binaries are executable
3. `file(1)` identifies correct architecture
4. `otool -L` shows no local-only dylib paths
5. No `/opt/homebrew` or `/usr/local/Cellar` linkage
6. yt-dlp packaging is intact (onedir or standalone)
7. Every engine runs and reports its version
8. aria2 RPC daemon starts and responds to JSON-RPC
9. No forbidden stderr patterns (`Library not loaded`, etc.)
The build is **blocked** if any check fails, enforced via `beforeBuildCommand` in `tauri.conf.json`.
### 3. Build
Verify packaged resources, then launch outside repository working directory:
```bash
npm run tauri build
APP="src-tauri/target/aarch64-apple-darwin/release/bundle/macos/Firelink.app"
node scripts/verify-binaries.js --search-root "$APP" --target aarch64-apple-darwin
node scripts/smoke-packaged-app.js --executable "$APP/Contents/MacOS/firelink"
```
### 4. Build artifacts
## Automated release
The packaged `.app` and `.dmg` appear in:
```
src-tauri/target/release/bundle/macos/
```
### 5. GitHub Release
Push a tag to trigger the release workflow:
Push a version tag:
```bash
git tag v<version>
git push origin v<version>
```
The release workflow (`.github/workflows/release.yml`) will:
GitHub Actions builds all targets on native runners, verifies engines inside final package contents, performs packaged launch smoke where supported, creates SHA-256 sums, then publishes one GitHub Release.
| Job | What it does |
|-----|-------------|
| `engine-verification` | Runs `verify-binaries.js`, builds `.app`, uploads artifacts |
| `create-release` | Creates GitHub Release with checksums and release notes |
## CI verification
Every PR and push to `main` also runs `node scripts/verify-binaries.js` (see `.github/workflows/ci.yml`), so broken engines are caught before they reach a release tag.
No target may silently skip missing engines, failed extraction, checksum mismatch, or missing package output.
+42
View File
@@ -0,0 +1,42 @@
# Third-Party Notices
Firelink distributes separate executable tools. Firelink's MIT license does not replace their licenses.
Exact versions, target hashes, sources, and build descriptions are pinned in `engines.lock.json`.
## aria2
- Project: <https://aria2.github.io/>
- Source: <https://github.com/aria2/aria2>
- License: GNU General Public License version 2 or later
Corresponding source for the distributed version is available from the source link and release tag listed in `engines.lock.json`. Firelink release notes must retain that source reference.
Linux x64 uses a checksum-pinned musl static build produced from upstream aria2 by <https://github.com/abcfy2/aria2-static-build>. Builder source and upstream tag are recorded in `engine-sources.lock.json`.
## FFmpeg
- Project and source: <https://ffmpeg.org/>
- License information: <https://ffmpeg.org/legal.html>
Current macOS binary reports `--enable-gpl --enable-version3`; distribution therefore follows GNU GPL version 3 requirements. Exact build identity is recorded in `engines.lock.json`.
Windows and Linux archives come from checksum-pinned BtbN FFmpeg GPL builds. Build project: <https://github.com/BtbN/FFmpeg-Builds>.
## yt-dlp
- Project and source: <https://github.com/yt-dlp/yt-dlp>
- License: The Unlicense
Firelink uses a self-contained PyInstaller onedir distribution. Embedded Python packages keep their own license files inside `_internal` where supplied.
## Deno
- Project and source: <https://github.com/denoland/deno>
- License: MIT
## OpenSSL and bundled native libraries
Engine payloads may contain OpenSSL, SQLite, c-ares, libssh2, gettext/libintl, zstd, and other runtime libraries. Their copyright and license notices remain part of their source distributions and embedded package metadata.
Release engineering must review each newly added target payload before adding its hashes to `engines.lock.json`. Missing provenance or license data blocks release.
+51
View File
@@ -0,0 +1,51 @@
{
"schemaVersion": 1,
"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"
},
"deno": {
"version": "2.8.2",
"url": "https://github.com/denoland/deno/releases/download/v2.8.2/deno-x86_64-pc-windows-msvc.zip",
"sha256": "6fe073b11cabeba2f2726d8a3d1592b198aec5f23dab3473d0dc8d5ec7aee1c9"
},
"ffmpeg": {
"version": "8.1",
"url": "https://github.com/BtbN/FFmpeg-Builds/releases/download/latest/ffmpeg-n8.1-latest-win64-gpl-8.1.zip",
"sha256": "6453fae49eeaaae4c885753d4a4d51e30e9712b44fe396a7c4aea202aa16a099"
},
"aria2c": {
"version": "1.37.0",
"url": "https://github.com/aria2/aria2/releases/download/release-1.37.0/aria2-1.37.0-win-64bit-build1.zip",
"sha256": "67d015301eef0b612191212d564c5bb0a14b5b9c4796b76454276a4d28d9b288"
}
},
"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"
},
"deno": {
"version": "2.8.2",
"url": "https://github.com/denoland/deno/releases/download/v2.8.2/deno-x86_64-unknown-linux-gnu.zip",
"sha256": "184da7a5267ab649bc08821b3bc3ce6805d8e6985fb82707cb8d5e9fd6535362"
},
"ffmpeg": {
"version": "8.1",
"url": "https://github.com/BtbN/FFmpeg-Builds/releases/download/latest/ffmpeg-n8.1-latest-linux64-gpl-8.1.tar.xz",
"sha256": "f6ade3d8203a8471cf2e4d90857f1aa5c4a9e5ead1ec45548b4ed6015878dd56"
},
"aria2c": {
"version": "1.37.0",
"url": "https://github.com/abcfy2/aria2-static-build/releases/download/1.37.0/aria2-x86_64-linux-musl_static.zip",
"sha256": "e0a09b12ef67f35f8a8e4fdddbec851d235b7c31da549d0578bff459032b499a",
"upstreamSource": "https://github.com/aria2/aria2/tree/release-1.37.0",
"builderSource": "https://github.com/abcfy2/aria2-static-build/tree/1.37.0"
}
}
}
}
+43
View File
@@ -0,0 +1,43 @@
{
"schemaVersion": 1,
"targets": {
"aarch64-apple-darwin": {
"engines": {
"yt-dlp": {
"version": "2026.06.09",
"source": "https://github.com/yt-dlp/yt-dlp",
"build": "PyInstaller onedir distribution with embedded Python and yt_dlp_ejs",
"sha256": "4eefb498e76f8a425bec30ba3ee2079b01542ca39ca1fb61b79966450794cc13"
},
"aria2c": {
"version": "1.37.0",
"source": "https://github.com/aria2/aria2",
"build": "arm64 executable with adjacent aria2-libs",
"sha256": "111b2f5ed760f1e1a2ec06117c4e8094fcde336ba16122dda1c5e7209bf1862d"
},
"ffmpeg": {
"version": "8.1.1",
"source": "https://ffmpeg.org/",
"build": "GPLv3 build identified by binary as https://www.martin-riedl.de",
"sha256": "ef4fe121377039053b0d7bed4a9aa46e7912918f5ba6424a1dd155f4eed625b0"
},
"deno": {
"version": "2.8.2",
"source": "https://github.com/denoland/deno",
"build": "official aarch64-apple-darwin executable",
"sha256": "9d25a1a5a67579eb607ed27a73141548b163e29df38735bc5556b7d887992435"
}
},
"runtimeTrees": {
"_internal": {
"files": 142,
"sha256": "3fabc08e6367f9393cfee32c32138f057f0e6068e430190c26a22ddfea84242b"
},
"aria2-libs": {
"files": 6,
"sha256": "e18a60538f7176373296f55af86f2c7780c2d6e8e32fdbd75f630e17ce79cb6c"
}
}
}
}
}
+165
View File
@@ -0,0 +1,165 @@
#!/usr/bin/env node
import crypto from 'node:crypto';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { execFileSync } from 'node:child_process';
import { fileURLToPath } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const repoRoot = path.resolve(__dirname, '..');
const sourceLock = JSON.parse(
fs.readFileSync(path.join(repoRoot, 'engine-sources.lock.json'), 'utf8')
);
function argValue(name) {
const index = process.argv.indexOf(name);
return index >= 0 ? process.argv[index + 1] : undefined;
}
const target = argValue('--target')
|| process.env.FIRELINK_TARGET_TRIPLE
|| process.env.TAURI_ENV_TARGET_TRIPLE;
if (!target) {
console.error('Pass --target <Rust target triple>.');
process.exit(1);
}
const targetSources = sourceLock.targets?.[target];
if (!targetSources) {
console.error(`No source lock exists for ${target}.`);
process.exit(1);
}
const destination = path.join(repoRoot, 'src-tauri', 'provisioned-engines', target);
const temporary = fs.mkdtempSync(path.join(os.tmpdir(), `firelink-engines-${target}-`));
const isWindows = target.includes('windows');
const executableSuffix = isWindows ? '.exe' : '';
function sha256(file) {
return crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex');
}
async function download(name, source) {
const sourcePath = new URL(source.url).pathname;
const archive = path.join(
temporary,
`${name}${sourcePath.endsWith('.tar.xz') ? '.tar.xz' : '.zip'}`
);
const response = await fetch(source.url, { redirect: 'follow' });
if (!response.ok || !response.body) {
throw new Error(`Failed to download ${name}: HTTP ${response.status}`);
}
const output = fs.createWriteStream(archive);
const reader = response.body.getReader();
while (true) {
const { done, value } = await reader.read();
if (done) break;
if (!output.write(Buffer.from(value))) {
await new Promise(resolve => output.once('drain', resolve));
}
}
await new Promise(resolve => output.end(resolve));
const actual = sha256(archive);
if (actual !== source.sha256) {
throw new Error(`Archive checksum mismatch for ${name}. Expected ${source.sha256}, got ${actual}`);
}
const extracted = path.join(temporary, `${name}-extracted`);
fs.mkdirSync(extracted);
if (archive.endsWith('.zip') && process.platform !== 'win32') {
execFileSync('unzip', ['-q', archive, '-d', extracted], { stdio: 'inherit' });
} else {
execFileSync('tar', ['-xf', archive, '-C', extracted], { stdio: 'inherit' });
}
return extracted;
}
function findFile(root, names) {
const wanted = new Set(names.map(name => name.toLowerCase()));
const matches = [];
const walk = directory => {
for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
const file = path.join(directory, entry.name);
if (entry.isDirectory()) walk(file);
else if (entry.isFile() && wanted.has(entry.name.toLowerCase())) matches.push(file);
}
};
walk(root);
if (matches.length !== 1) {
throw new Error(`Expected one of [${names.join(', ')}] under ${root}, found ${matches.length}`);
}
return matches[0];
}
function copyExecutable(source, engine) {
const output = path.join(destination, `${engine}-${target}${executableSuffix}`);
fs.copyFileSync(source, output);
if (!isWindows) fs.chmodSync(output, 0o755);
}
function writePayloadManifest() {
const files = [];
const walk = directory => {
for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
const file = path.join(directory, entry.name);
if (entry.isDirectory()) walk(file);
else if (entry.isFile() && entry.name !== 'payload-manifest.json') files.push(file);
}
};
walk(destination);
files.sort((left, right) => left.localeCompare(right));
const manifest = {
schemaVersion: 1,
target,
generatedFrom: Object.fromEntries(
Object.entries(targetSources).map(([name, source]) => [
name,
{
version: source.version,
url: source.url || source.sourceUrl,
sha256: source.sha256 || source.sourceSha256
}
])
),
files: Object.fromEntries(
files.map(file => [
path.relative(destination, file).split(path.sep).join('/'),
sha256(file)
])
)
};
fs.writeFileSync(
path.join(destination, 'payload-manifest.json'),
`${JSON.stringify(manifest, null, 2)}\n`
);
}
try {
fs.rmSync(destination, { recursive: true, force: true });
fs.mkdirSync(destination, { recursive: true });
const ytdlp = await download('yt-dlp', targetSources['yt-dlp']);
copyExecutable(
findFile(ytdlp, isWindows ? ['yt-dlp.exe'] : ['yt-dlp_linux']),
'yt-dlp'
);
fs.cpSync(path.join(ytdlp, '_internal'), path.join(destination, '_internal'), {
recursive: true,
preserveTimestamps: true
});
const deno = await download('deno', targetSources.deno);
copyExecutable(findFile(deno, isWindows ? ['deno.exe'] : ['deno']), 'deno');
const ffmpeg = await download('ffmpeg', targetSources.ffmpeg);
copyExecutable(findFile(ffmpeg, isWindows ? ['ffmpeg.exe'] : ['ffmpeg']), 'ffmpeg');
const aria2 = await download('aria2c', targetSources.aria2c);
copyExecutable(findFile(aria2, isWindows ? ['aria2c.exe'] : ['aria2c']), 'aria2c');
writePayloadManifest();
console.log(`Provisioned locked engine payload at ${destination}`);
} finally {
fs.rmSync(temporary, { recursive: true, force: true });
}
+62
View File
@@ -0,0 +1,62 @@
#!/usr/bin/env node
import { spawn } from 'node:child_process';
import path from 'node:path';
function argValue(name) {
const index = process.argv.indexOf(name);
return index >= 0 ? process.argv[index + 1] : undefined;
}
const executableArg = argValue('--executable');
if (!executableArg) {
console.error('Pass --executable <path>.');
process.exit(1);
}
const executable = path.resolve(executableArg);
const child = spawn(executable, [], {
cwd: process.env.RUNNER_TEMP || process.env.TMPDIR || process.cwd(),
detached: process.platform !== 'win32',
stdio: ['ignore', 'pipe', 'pipe']
});
let stderr = '';
let spawnError = null;
child.on('error', error => {
spawnError = error;
});
child.stderr.on('data', data => {
stderr += data.toString();
});
let readyPort = null;
for (let attempt = 0; attempt < 40 && readyPort === null; attempt += 1) {
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') {
readyPort = port;
break;
}
} catch {}
}
if (readyPort === null) {
await new Promise(resolve => setTimeout(resolve, 250));
}
}
if (process.platform === 'win32') {
spawn('taskkill', ['/pid', String(child.pid), '/t', '/f'], { stdio: 'ignore' });
} else {
try {
process.kill(-child.pid, 'SIGTERM');
} catch {
child.kill('SIGTERM');
}
}
if (readyPort === null) {
const detail = spawnError?.message || stderr.slice(-1000);
console.error(`Packaged Firelink did not expose extension server. ${detail}`);
process.exit(1);
}
console.log(`Packaged Firelink smoke passed on 127.0.0.1:${readyPort}`);
+163
View File
@@ -0,0 +1,163 @@
#!/usr/bin/env node
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import crypto from 'node:crypto';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const repoRoot = path.resolve(__dirname, '..');
const binariesRoot = path.join(repoRoot, 'src-tauri', 'binaries');
const outputRoot = path.join(repoRoot, 'src-tauri', 'engine-dist');
const lock = JSON.parse(fs.readFileSync(path.join(repoRoot, 'engines.lock.json'), 'utf8'));
const archMap = { x64: 'x86_64', arm64: 'aarch64' };
const platformMap = {
darwin: 'apple-darwin',
win32: 'pc-windows-msvc',
linux: 'unknown-linux-gnu',
};
function argValue(name) {
const index = process.argv.indexOf(name);
return index >= 0 ? process.argv[index + 1] : undefined;
}
const hostTarget = `${archMap[os.arch()]}-${platformMap[os.platform()]}`;
const target = argValue('--target')
|| process.env.TAURI_ENV_TARGET_TRIPLE
|| process.env.FIRELINK_TARGET_TRIPLE
|| hostTarget;
const isWindowsTarget = target.includes('windows');
const suffix = isWindowsTarget ? '.exe' : '';
const engines = ['yt-dlp', 'aria2c', 'ffmpeg', 'deno'];
const expectedNames = engines.map(engine => `${engine}-${target}${suffix}`);
const targetLock = lock.targets?.[target];
const configuredSource = process.env.FIRELINK_ENGINE_SOURCE_DIR
? path.resolve(process.env.FIRELINK_ENGINE_SOURCE_DIR)
: null;
const canonicalSource = path.join(binariesRoot, target);
const provisionedSource = path.join(repoRoot, 'src-tauri', 'provisioned-engines', target);
const legacyMacSource = target.endsWith('apple-darwin') ? binariesRoot : null;
const source = [configuredSource, canonicalSource, provisionedSource, legacyMacSource]
.filter(Boolean)
.find(candidate => expectedNames.every(name => fs.existsSync(path.join(candidate, name))));
if (!source) {
console.error(`No complete engine payload found for ${target}.`);
console.error(`Expected source directory: ${canonicalSource}`);
console.error(`Expected files: ${expectedNames.join(', ')}`);
process.exit(1);
}
function sha256(file) {
return crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex');
}
function treeDigest(root) {
const files = [];
const walk = directory => {
for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
const file = path.join(directory, entry.name);
if (entry.isDirectory()) walk(file);
else if (entry.isFile()) files.push(file);
}
};
walk(root);
files.sort((left, right) => left.localeCompare(right));
const digest = crypto.createHash('sha256');
for (const file of files) {
const relative = path.relative(root, file).split(path.sep).join('/');
digest.update(`${relative}\0${sha256(file)}\n`);
}
return { files: files.length, sha256: digest.digest('hex') };
}
if (targetLock) {
for (const engine of engines) {
const name = `${engine}-${target}${suffix}`;
const expected = targetLock.engines?.[engine]?.sha256;
const actual = sha256(path.join(source, name));
if (!expected || actual !== expected) {
console.error(`Checksum mismatch for ${name}. Expected ${expected || 'missing lock'}, got ${actual}.`);
process.exit(1);
}
}
for (const [runtimeDir, expected] of Object.entries(targetLock.runtimeTrees || {})) {
const sourceDir = path.join(source, runtimeDir);
if (!fs.existsSync(sourceDir)) {
console.error(`Missing locked runtime directory ${runtimeDir} for ${target}.`);
process.exit(1);
}
const actual = treeDigest(sourceDir);
if (actual.files !== expected.files || actual.sha256 !== expected.sha256) {
console.error(`Runtime checksum mismatch for ${runtimeDir}.`);
process.exit(1);
}
}
} else {
const manifestPath = path.join(source, 'payload-manifest.json');
if (!fs.existsSync(manifestPath)) {
console.error(`No committed lock or payload manifest exists for ${target}.`);
process.exit(1);
}
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
if (manifest.target !== target) {
console.error(`Payload manifest target mismatch: ${manifest.target}`);
process.exit(1);
}
for (const [relative, expected] of Object.entries(manifest.files || {})) {
const file = path.join(source, relative);
if (!fs.existsSync(file) || sha256(file) !== expected) {
console.error(`Payload manifest mismatch: ${relative}`);
process.exit(1);
}
}
const actualFiles = [];
const walk = directory => {
for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
const file = path.join(directory, entry.name);
if (entry.isDirectory()) walk(file);
else if (entry.isFile() && entry.name !== 'payload-manifest.json') {
actualFiles.push(path.relative(source, file).split(path.sep).join('/'));
}
}
};
walk(source);
const expectedFiles = Object.keys(manifest.files || {}).sort();
actualFiles.sort();
if (JSON.stringify(actualFiles) !== JSON.stringify(expectedFiles)) {
console.error(`Payload contains files not covered by manifest for ${target}.`);
process.exit(1);
}
}
const destination = path.join(outputRoot, target);
fs.rmSync(outputRoot, { recursive: true, force: true });
fs.mkdirSync(destination, { recursive: true });
for (const name of expectedNames) {
fs.copyFileSync(path.join(source, name), path.join(destination, name));
if (!isWindowsTarget) {
fs.chmodSync(path.join(destination, name), 0o755);
}
}
for (const runtimeDir of ['_internal', 'aria2-libs']) {
const sourceDir = path.join(source, runtimeDir);
if (fs.existsSync(sourceDir)) {
fs.cpSync(sourceDir, path.join(destination, runtimeDir), {
recursive: true,
dereference: false,
preserveTimestamps: true,
});
}
}
const payloadManifest = path.join(source, 'payload-manifest.json');
if (fs.existsSync(payloadManifest)) {
fs.copyFileSync(payloadManifest, path.join(destination, 'payload-manifest.json'));
}
console.log(`Staged Firelink engines for ${target} from ${source}`);
+87 -11
View File
@@ -8,6 +8,11 @@ import { fileURLToPath } from 'node:url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
function argValue(name) {
const index = process.argv.indexOf(name);
return index >= 0 ? process.argv[index + 1] : undefined;
}
const archMap = { x64: 'x86_64', arm64: 'aarch64' };
const platformMap = {
darwin: 'apple-darwin',
@@ -23,14 +28,47 @@ if (!currentArch || !currentPlatform) {
process.exit(1);
}
const targetTriple = `${currentArch}-${currentPlatform}`;
const isWindows = os.platform() === 'win32';
const isMacOS = os.platform() === 'darwin';
const targetTriple = argValue('--target')
|| process.env.FIRELINK_TARGET_TRIPLE
|| `${currentArch}-${currentPlatform}`;
const hostTriple = `${currentArch}-${currentPlatform}`;
const canExecuteTarget = targetTriple === hostTriple;
const isWindows = targetTriple.includes('windows');
const isMacOS = targetTriple.includes('apple-darwin');
const isLinux = targetTriple.includes('linux');
const ext = isWindows ? '.exe' : '';
const suffix = `-${targetTriple}${ext}`;
const scriptsDir = __dirname;
const binariesDir = path.join(scriptsDir, '..', 'src-tauri', 'binaries');
const searchRoot = argValue('--search-root');
function findEngineRoot(root) {
const expected = `yt-dlp-${targetTriple}${ext}`;
const matches = [];
const walk = directory => {
for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
const candidate = path.join(directory, entry.name);
if (entry.isDirectory()) {
if (fs.existsSync(path.join(candidate, expected))) matches.push(candidate);
walk(candidate);
}
}
};
walk(path.resolve(root));
if (matches.length !== 1) {
throw new Error(`Expected exactly one packaged engine root under ${root}, found ${matches.length}`);
}
return matches[0];
}
const configuredRoot = argValue('--root')
|| (process.argv.includes('--staged')
? path.join(scriptsDir, '..', 'src-tauri', 'engine-dist', targetTriple)
: searchRoot
? findEngineRoot(searchRoot)
: null);
const binariesDir = configuredRoot
? path.resolve(configuredRoot)
: path.join(scriptsDir, '..', 'src-tauri', 'binaries');
const requiredEngines = ['yt-dlp', 'aria2c', 'ffmpeg', 'deno'];
const FORBIDDEN_OTOOL_PATHS = ['/opt/homebrew', '/usr/local/Cellar'];
@@ -79,6 +117,10 @@ if (exitCode !== 0) {
// ───── Check 2: Executable permission ─────
console.log('\n─── 2. Executable permission ───');
for (const eng of requiredEngines) {
if (isWindows) {
ok(`Executable permission is represented by PE format on Windows: ${binName(eng)}`);
continue;
}
try {
fs.accessSync(binPath(eng), fs.constants.X_OK);
ok(`Executable ${binName(eng)}`);
@@ -90,6 +132,19 @@ for (const eng of requiredEngines) {
// ───── Check 3: file(1) identification ─────
console.log('\n─── 3. file(1) identification ───');
for (const eng of requiredEngines) {
if (os.platform() === 'win32') {
try {
const header = fs.readFileSync(binPath(eng)).subarray(0, 2).toString('ascii');
if (header === 'MZ') {
ok(`${binName(eng)}: Windows PE executable`);
} else {
fail(`${binName(eng)}: missing Windows PE MZ header`);
}
} catch (e) {
fail(`identify ${binName(eng)}: ${e.message}`);
}
continue;
}
try {
const out = execFileSync('file', ['--brief', binPath(eng)], {
encoding: 'utf-8',
@@ -172,7 +227,6 @@ console.log('\n─── 6. yt-dlp packaging ───');
}
const requiredRuntimeFiles = [
path.join(internalDir, 'Python'),
path.join(internalDir, 'yt_dlp_ejs', 'yt', 'solver', 'core.min.js'),
path.join(internalDir, 'yt_dlp_ejs', 'yt', 'solver', 'lib.min.js'),
];
@@ -183,6 +237,16 @@ console.log('\n─── 6. yt-dlp packaging ───');
fail(`Missing yt-dlp runtime component: ${path.relative(binariesDir, required)}`);
}
}
const runtimeCandidates = isWindows
? fs.readdirSync(internalDir).filter(name => /^python.*\.dll$/i.test(name))
: isLinux
? fs.readdirSync(internalDir).filter(name => /^libpython.*\.so/i.test(name) || name === 'Python')
: ['Python', 'Python.framework'].filter(name => fs.existsSync(path.join(internalDir, name)));
if (runtimeCandidates.length > 0) {
ok(`yt-dlp embedded Python runtime: ${runtimeCandidates[0]}`);
} else {
fail(`Missing embedded Python runtime in ${internalDir}`);
}
} else {
fail('yt-dlp must use the self-contained onedir distribution; onefile adds ~17 seconds to every launch');
}
@@ -222,14 +286,26 @@ function runEngine(label, engine, args, timeout = 30000) {
}
}
runEngine('yt-dlp cold start', 'yt-dlp', ['--version'], 20000);
const coldStartTimeout = isMacOS ? 120000 : 30000;
if (canExecuteTarget) {
runEngine('yt-dlp cold start', 'yt-dlp', ['--version'], coldStartTimeout);
runEngine('yt-dlp warm start', 'yt-dlp', ['--version'], 8000);
runEngine('ffmpeg', 'ffmpeg', ['-version']);
runEngine('deno', 'deno', ['--version']);
runEngine('aria2c', 'aria2c', ['--version']);
// ───── aria2 RPC smoke test (macOS only) ─────
runEngine('ffmpeg cold start', 'ffmpeg', ['-version'], coldStartTimeout);
runEngine('deno cold start', 'deno', ['--version'], coldStartTimeout);
runEngine('aria2c cold start', 'aria2c', ['--version'], coldStartTimeout);
if (isMacOS) {
// Unsigned binaries can incur a one-time macOS provenance scan after copying.
// Warm checks enforce engine startup performance after that OS validation.
runEngine('ffmpeg warm start', 'ffmpeg', ['-version'], 8000);
runEngine('deno warm start', 'deno', ['--version'], 8000);
runEngine('aria2c warm start', 'aria2c', ['--version'], 8000);
}
} else {
console.log(` Runtime tests skipped on ${hostTriple}; native ${targetTriple} CI runs them.`);
}
// ───── aria2 RPC smoke test (native target only) ─────
if (canExecuteTarget) {
console.log('\n─── aria2 RPC smoke test ───');
await (async function testAria2Rpc() {
const p = binPath('aria2c');
+56 -1
View File
@@ -977,6 +977,17 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "dbus-secret-service"
version = "4.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "708b509edf7889e53d7efb0ffadd994cc6c2345ccb62f55cfd6b0682165e4fa6"
dependencies = [
"dbus",
"openssl",
"zeroize",
]
[[package]]
name = "deranged"
version = "0.5.8"
@@ -2487,7 +2498,13 @@ version = "3.6.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eebcc3aff044e5944a8fbaf69eb277d11986064cba30c468730e8b9909fb551c"
dependencies = [
"byteorder",
"dbus-secret-service",
"log 0.4.32",
"openssl",
"security-framework 2.11.1",
"security-framework 3.7.0",
"windows-sys 0.60.2",
"zeroize",
]
@@ -2545,6 +2562,7 @@ version = "0.2.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "328c4789d42200f1eeec05bd86c9c13c7f091d2ba9a6ea35acdf51f31bc0f043"
dependencies = [
"cc",
"pkg-config",
]
@@ -2751,7 +2769,7 @@ dependencies = [
"openssl-probe",
"openssl-sys",
"schannel",
"security-framework",
"security-framework 3.7.0",
"security-framework-sys",
"tempfile",
]
@@ -3149,6 +3167,15 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe"
[[package]]
name = "openssl-src"
version = "300.6.1+3.6.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "46eb8fb9fb3b61ce1c0f8a026c4c1a0714d3a9e138e7fbde78753ce2babc3846"
dependencies = [
"cc",
]
[[package]]
name = "openssl-sys"
version = "0.9.117"
@@ -3157,6 +3184,7 @@ checksum = "b47e7e6bb2c38cd930d25a23b40fa52e068c10e85f3e03a7f5ba5aaca5713695"
dependencies = [
"cc",
"libc",
"openssl-src",
"pkg-config",
"vcpkg",
]
@@ -4092,6 +4120,19 @@ version = "4.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1c107b6f4780854c8b126e228ea8869f4d7b71260f962fefb57b996b8959ba6b"
[[package]]
name = "security-framework"
version = "2.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02"
dependencies = [
"bitflags 2.13.0",
"core-foundation 0.9.4",
"core-foundation-sys",
"libc",
"security-framework-sys",
]
[[package]]
name = "security-framework"
version = "3.7.0"
@@ -7003,6 +7044,20 @@ name = "zeroize"
version = "1.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e"
dependencies = [
"zeroize_derive",
]
[[package]]
name = "zeroize_derive"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]]
name = "zerotrie"
+7 -1
View File
@@ -36,7 +36,6 @@ uuid = { version = "1", features = ["v4"] }
ts-rs = { version = "12", features = ["serde-compat", "uuid-impl"] }
tauri-plugin-notification = "2.3.3"
sysinfo = "0.39.3"
keyring = "3"
hmac = "0.12"
sha2 = "0.10"
tauri-plugin-deep-link = "2"
@@ -61,3 +60,10 @@ async-trait = "0.1"
[target.'cfg(target_os = "macos")'.dependencies]
cocoa = "0.25"
objc = "0.2.7"
keyring = { version = "3", features = ["apple-native"] }
[target.'cfg(target_os = "windows")'.dependencies]
keyring = { version = "3", features = ["windows-native"] }
[target.'cfg(target_os = "linux")'.dependencies]
keyring = { version = "3", features = ["sync-secret-service", "vendored"] }
+9
View File
@@ -28,6 +28,13 @@ pub fn canonical_download_filename(filename: &str) -> String {
let sanitized = sanitized.trim().trim_end_matches(['.', ' ']);
if sanitized.is_empty() || matches!(sanitized, "." | "..") {
"download".to_string()
} else if crate::platform::is_windows_reserved_filename(sanitized) {
let path = Path::new(sanitized);
let stem = path.file_stem().and_then(|value| value.to_str()).unwrap_or("download");
match path.extension().and_then(|value| value.to_str()) {
Some(extension) => format!("{stem}-.{extension}"),
None => format!("{stem}-"),
}
} else {
sanitized.to_string()
}
@@ -209,5 +216,7 @@ mod tests {
assert_eq!(canonical_download_filename("../folder/video?.mp4"), "video-.mp4");
assert_eq!(canonical_download_filename(" report. "), "report");
assert_eq!(canonical_download_filename(".."), "download");
assert_eq!(canonical_download_filename("CON.txt"), "CON-.txt");
assert_eq!(canonical_download_filename("lpt9"), "lpt9-");
}
}
+149
View File
@@ -0,0 +1,149 @@
use std::path::{Path, PathBuf};
use tauri::Manager;
pub fn resolve_bundled_binary_path(
app_handle: &tauri::AppHandle,
engine: &str,
) -> Result<PathBuf, String> {
let binary_name = crate::platform::engine_binary_name(engine);
let target = crate::platform::target_triple();
if let Ok(resource_dir) = app_handle.path().resource_dir() {
for candidate in packaged_candidates(&resource_dir, &target, &binary_name) {
if candidate.is_file() {
log::info!("Resolved bundled '{}' at: {:?}", engine, candidate);
return Ok(candidate);
}
}
}
if let Ok(exe_path) = std::env::current_exe() {
for candidate in executable_relative_candidates(&exe_path, &target, &binary_name) {
if candidate.is_file() {
log::info!("Resolved bundled '{}' at: {:?}", engine, candidate);
return Ok(candidate);
}
}
}
if let Ok(cwd) = std::env::current_dir() {
for candidate in development_candidates(&cwd, &target, &binary_name) {
if candidate.is_file() {
let absolute = candidate.canonicalize().map_err(|error| {
format!("Failed to canonicalize '{}': {error}", candidate.display())
})?;
log::info!("Resolved bundled '{}' at: {:?}", engine, absolute);
return Ok(absolute);
}
}
}
Err(format!(
"Could not find bundled binary '{}' for target '{}' (expected name: {})",
engine, target, binary_name
))
}
fn packaged_candidates(resource_dir: &Path, target: &str, binary_name: &str) -> Vec<PathBuf> {
let mut candidates = vec![
resource_dir
.join("engine-dist")
.join(target)
.join(binary_name),
resource_dir.join("engines").join(target).join(binary_name),
];
if cfg!(target_os = "macos") {
candidates.push(resource_dir.join("binaries").join(binary_name));
candidates.push(resource_dir.join(binary_name));
}
candidates
}
fn executable_relative_candidates(
executable: &Path,
target: &str,
binary_name: &str,
) -> Vec<PathBuf> {
let Some(executable_dir) = executable.parent() else {
return Vec::new();
};
let mut candidates = vec![
executable_dir
.join("engine-dist")
.join(target)
.join(binary_name),
executable_dir.join("engines").join(target).join(binary_name),
];
if cfg!(target_os = "macos") {
if let Some(contents_dir) = executable_dir.parent() {
candidates.push(
contents_dir
.join("Resources")
.join("engine-dist")
.join(target)
.join(binary_name),
);
candidates.push(
contents_dir
.join("Resources")
.join("binaries")
.join(binary_name),
);
}
}
candidates
}
fn development_candidates(cwd: &Path, target: &str, binary_name: &str) -> Vec<PathBuf> {
let roots = [cwd.to_path_buf(), cwd.join("src-tauri")];
let mut candidates = Vec::new();
for root in roots {
candidates.push(
root.join("engine-dist")
.join(target)
.join(binary_name),
);
candidates.push(root.join("binaries").join(target).join(binary_name));
if cfg!(target_os = "macos") {
candidates.push(root.join("binaries").join(binary_name));
}
}
candidates
}
pub fn ytdlp_internal_dir(binary_path: &Path) -> Option<PathBuf> {
binary_path.parent().map(|parent| parent.join("_internal"))
}
#[cfg(test)]
mod tests {
use super::{development_candidates, packaged_candidates};
use std::path::Path;
#[test]
fn canonical_packaged_layout_is_target_scoped() {
let candidates = packaged_candidates(
Path::new("/resources"),
"x86_64-unknown-linux-gnu",
"yt-dlp-x86_64-unknown-linux-gnu",
);
assert_eq!(
candidates[0],
Path::new("/resources/engine-dist/x86_64-unknown-linux-gnu/yt-dlp-x86_64-unknown-linux-gnu")
);
}
#[test]
fn canonical_development_layout_is_target_scoped() {
let candidates = development_candidates(
Path::new("/repo"),
"x86_64-pc-windows-msvc",
"aria2c-x86_64-pc-windows-msvc.exe",
);
assert_eq!(
candidates[0],
Path::new("/repo/engine-dist/x86_64-pc-windows-msvc/aria2c-x86_64-pc-windows-msvc.exe")
);
}
}
+10
View File
@@ -246,6 +246,7 @@ pub struct PersistedSettings {
pub base_download_folder: String,
pub category_subfolders: HashMap<String, String>,
pub category_directory_overrides: HashMap<String, String>,
pub approved_download_roots: Vec<String>,
pub max_concurrent_downloads: usize,
pub global_speed_limit: String,
pub is_sidebar_visible: bool,
@@ -279,6 +280,15 @@ pub struct PersistedSettings {
pub auto_check_updates: bool,
}
#[derive(Clone, Debug, Serialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export, export_to = "../../src/bindings/")]
pub struct PlatformInfo {
pub os: String,
pub arch: String,
pub target_triple: String,
}
#[derive(Clone, Copy, Debug, Serialize, Deserialize, TS)]
#[serde(rename_all = "lowercase")]
#[ts(export, export_to = "../../src/bindings/")]
+212 -141
View File
@@ -984,11 +984,12 @@ async fn fetch_media_metadata_uncached(app_handle: tauri::AppHandle, url: String
let deno_path = resolve_bundled_binary_path(&app_handle, "deno").map_err(|e| format!("failed to find bundled deno: {e}"))?;
let ffmpeg_path = resolve_bundled_binary_path(&app_handle, "ffmpeg").map_err(|e| format!("failed to find bundled ffmpeg: {e}"))?;
let deno_runtime = format!("deno:{}", deno_path.to_string_lossy());
let trusted_path = crate::platform::trusted_system_path()?;
use tauri_plugin_shell::ShellExt;
let (ytdlp_path, _) = resolve_metadata_ytdlp_path(&app_handle)?;
let mut cmd = app_handle.shell().command(ytdlp_path.to_string_lossy().to_string());
cmd = cmd.env("PATH", "/usr/bin:/bin")
cmd = cmd.env("PATH", trusted_path)
.arg("--ffmpeg-location").arg(&ffmpeg_path)
.arg("--js-runtimes").arg(&deno_runtime)
.arg("--no-warnings")
@@ -1133,45 +1134,109 @@ pub(crate) fn is_safe_path(path: &std::path::Path, app_handle: &tauri::AppHandle
return false;
}
let Some(canonical_path) = canonicalize_with_missing_components(path) else {
return false;
};
approved_download_roots(app_handle)
.into_iter()
.filter_map(|root| canonicalize_with_missing_components(&root))
.any(|root| crate::platform::path_is_within(&canonical_path, &root))
}
fn canonicalize_with_missing_components(path: &std::path::Path) -> Option<std::path::PathBuf> {
let mut existing = path;
let mut missing = Vec::new();
while !existing.exists() {
let Some(name) = existing.file_name() else {
return false;
};
missing.push(name.to_owned());
let Some(parent) = existing.parent() else {
return false;
};
existing = parent;
missing.push(existing.file_name()?.to_owned());
existing = existing.parent()?;
}
let Ok(mut canonical_path) = std::fs::canonicalize(existing) else {
return false;
};
let mut canonical = std::fs::canonicalize(existing).ok()?;
for component in missing.iter().rev() {
canonical_path.push(component);
canonical.push(component);
}
Some(canonical)
}
let mut allowed_prefixes = Vec::new();
fn approved_download_roots(app_handle: &tauri::AppHandle) -> Vec<std::path::PathBuf> {
use tauri::Manager;
if let Ok(home) = app_handle.path().home_dir() {
allowed_prefixes.push(home.join("Downloads"));
allowed_prefixes.push(home.join("Music"));
allowed_prefixes.push(home.join("Movies"));
allowed_prefixes.push(home.join("Pictures"));
allowed_prefixes.push(home.join("Documents"));
allowed_prefixes.push(home.join("Desktop"));
}
allowed_prefixes.push(std::path::PathBuf::from("/Volumes"));
for prefix in allowed_prefixes {
let canonical_prefix = std::fs::canonicalize(&prefix).unwrap_or(prefix);
if canonical_path.starts_with(&canonical_prefix) {
return true;
let mut roots = Vec::new();
for root in [
app_handle.path().download_dir().ok(),
app_handle.path().audio_dir().ok(),
app_handle.path().video_dir().ok(),
app_handle.path().picture_dir().ok(),
app_handle.path().document_dir().ok(),
app_handle.path().desktop_dir().ok(),
]
.into_iter()
.flatten()
{
push_unique_path(&mut roots, root);
}
if let Ok(settings) = crate::settings::load_settings(app_handle) {
push_unique_path(
&mut roots,
resolve_path(&settings.base_download_folder, app_handle),
);
for root in settings.category_directory_overrides.values() {
push_unique_path(&mut roots, resolve_path(root, app_handle));
}
for root in &settings.approved_download_roots {
push_unique_path(&mut roots, resolve_path(root, app_handle));
}
}
false
roots
}
#[tauri::command]
fn approve_download_root(
app_handle: tauri::AppHandle,
path: String,
) -> Result<String, String> {
let resolved = resolve_path(path.trim(), &app_handle);
if !resolved.is_absolute() {
return Err("Download root must be an absolute path".to_string());
}
let canonical = std::fs::canonicalize(&resolved)
.map_err(|error| format!("Failed to resolve download root: {error}"))?;
if !canonical.is_dir() {
return Err("Download root must be an existing directory".to_string());
}
let canonical_text = canonical.to_string_lossy().to_string();
crate::settings::update_settings_state(&app_handle, |state| {
let roots = state
.entry("approvedDownloadRoots".to_string())
.or_insert_with(|| serde_json::Value::Array(Vec::new()));
if !roots.is_array() {
*roots = serde_json::Value::Array(Vec::new());
}
let values = roots.as_array_mut().expect("approved roots must be an array");
if !values.iter().filter_map(serde_json::Value::as_str).any(|root| {
crate::platform::paths_equal(
std::path::Path::new(root),
std::path::Path::new(&canonical_text),
)
}) {
values.push(serde_json::Value::String(canonical_text.clone()));
}
})?;
Ok(canonical_text)
}
fn push_unique_path(paths: &mut Vec<std::path::PathBuf>, path: std::path::PathBuf) {
if path.is_absolute()
&& !paths
.iter()
.any(|existing| crate::platform::paths_equal(existing, &path))
{
paths.push(path);
}
}
use std::sync::atomic::{AtomicBool, Ordering};
@@ -1215,6 +1280,8 @@ pub mod error;
pub mod commands;
pub mod download_ownership;
pub mod retry;
mod engines;
mod platform;
mod settings;
pub use error::AppError;
@@ -1281,7 +1348,10 @@ pub struct EngineStatusResult {
pub(crate) fn resolve_path(path: &str, app_handle: &tauri::AppHandle) -> std::path::PathBuf {
use tauri::Manager;
let mut resolved = std::path::PathBuf::from(path);
if let Some(stripped) = path.strip_prefix("~/") {
if let Some(stripped) = path
.strip_prefix("~/")
.or_else(|| path.strip_prefix("~\\"))
{
if let Ok(home) = app_handle.path().home_dir() {
resolved = home.join(stripped);
}
@@ -1641,13 +1711,9 @@ fn generate_remediation_hint(error: &str, _kind: &str) -> Option<String> {
}
}
fn arch_suffix() -> &'static str {
if cfg!(target_arch = "aarch64") { "aarch64" } else { "x86_64" }
}
async fn check_aria2(app_handle: &tauri::AppHandle, port: u16, secret: &str) -> EngineStatusItem {
let sidecar_name = "aria2c";
let expected_sidecar = format!("{}-{}-apple-darwin", sidecar_name, arch_suffix());
let expected_sidecar = crate::platform::engine_binary_name(sidecar_name);
let resolved = resolve_bundled_binary_path(app_handle, sidecar_name);
let resolved_path = resolved.as_ref().ok().map(|p| p.to_string_lossy().to_string());
@@ -1698,7 +1764,7 @@ async fn check_aria2(app_handle: &tauri::AppHandle, port: u16, secret: &str) ->
async fn check_ytdlp(app_handle: &tauri::AppHandle) -> EngineStatusItem {
let sidecar_name = "yt-dlp";
let expected_sidecar = format!("{}-{}-apple-darwin", sidecar_name, arch_suffix());
let expected_sidecar = crate::platform::engine_binary_name(sidecar_name);
let resolved = resolve_bundled_binary_path(app_handle, sidecar_name);
let resolved_path = resolved.as_ref().ok().map(|p| p.to_string_lossy().to_string());
@@ -1706,7 +1772,8 @@ async fn check_ytdlp(app_handle: &tauri::AppHandle) -> EngineStatusItem {
let (has_internal_dir, has_python_framework) = if let Some(ref path) = resolved_path {
let parent = std::path::Path::new(path).parent().map(|p| p.to_path_buf());
if let Some(parent) = parent {
let internal = parent.join("_internal");
let internal = crate::engines::ytdlp_internal_dir(std::path::Path::new(path))
.unwrap_or_else(|| parent.join("_internal"));
let hi = internal.is_dir();
let hp = if hi {
internal.join("Python.framework").is_dir() || internal.join("Python").exists()
@@ -1758,7 +1825,7 @@ async fn check_ytdlp(app_handle: &tauri::AppHandle) -> EngineStatusItem {
async fn check_ffmpeg(app_handle: &tauri::AppHandle) -> EngineStatusItem {
let sidecar_name = "ffmpeg";
let expected_sidecar = format!("{}-{}-apple-darwin", sidecar_name, arch_suffix());
let expected_sidecar = crate::platform::engine_binary_name(sidecar_name);
let resolved = resolve_bundled_binary_path(app_handle, sidecar_name);
let resolved_path = resolved.as_ref().ok().map(|p| p.to_string_lossy().to_string());
@@ -1801,7 +1868,7 @@ async fn check_ffmpeg(app_handle: &tauri::AppHandle) -> EngineStatusItem {
async fn check_deno(app_handle: &tauri::AppHandle) -> EngineStatusItem {
let sidecar_name = "deno";
let expected_sidecar = format!("{}-{}-apple-darwin", sidecar_name, arch_suffix());
let expected_sidecar = crate::platform::engine_binary_name(sidecar_name);
let resolved = resolve_bundled_binary_path(app_handle, sidecar_name);
let resolved_path = resolved.as_ref().ok().map(|p| p.to_string_lossy().to_string());
@@ -1880,73 +1947,7 @@ async fn get_deno_engine_status(app_handle: tauri::AppHandle) -> Result<EngineSt
fn resolve_bundled_binary_path(app_handle: &tauri::AppHandle, binary_name: &str) -> Result<std::path::PathBuf, String> {
let full_name = format!(
"{}-{}-apple-darwin",
binary_name,
if cfg!(target_arch = "aarch64") { "aarch64" } else { "x86_64" },
);
// Production: use the resource copy as the canonical engine location.
if let Ok(resource_dir) = app_handle.path().resource_dir() {
for candidate in [
resource_dir.join("binaries").join(&full_name),
resource_dir.join(&full_name),
] {
if candidate.is_file() {
log::info!("Resolved bundled '{}' at: {:?}", binary_name, candidate);
return Ok(candidate);
}
}
}
// Packaged macOS fallback: resolve directly from Contents/MacOS to
// Contents/Resources when Tauri's resource_dir is unavailable.
if let Ok(exe_path) = std::env::current_exe() {
if let Some(contents_dir) = exe_path.parent().and_then(std::path::Path::parent) {
let candidate = contents_dir
.join("Resources")
.join("binaries")
.join(&full_name);
if candidate.is_file() {
log::info!("Resolved bundled '{}' at: {:?}", binary_name, candidate);
return Ok(candidate);
}
}
}
// Dev mode: search relative to CWD
if let Ok(cwd) = std::env::current_dir() {
let search_dirs = [
cwd.join("binaries"),
cwd.join("src-tauri").join("binaries"),
];
for dir in &search_dirs {
let candidate = dir.join(&full_name);
if candidate.is_file() {
let abs = candidate.canonicalize().map_err(|e| {
format!("Failed to canonicalize '{}': {}", full_name, e)
})?;
log::info!("Resolved bundled '{}' at: {:?}", binary_name, abs);
return Ok(abs);
}
}
}
// Compatibility fallback for older bundles produced with externalBin.
if let Ok(exe_path) = std::env::current_exe() {
if let Some(exe_dir) = exe_path.parent() {
let candidate = exe_dir.join(binary_name);
if candidate.is_file() {
log::info!("Resolved legacy bundled '{}' at: {:?}", binary_name, candidate);
return Ok(candidate);
}
}
}
Err(format!(
"Could not find bundled binary '{}' (expected name: {})",
binary_name, full_name
))
crate::engines::resolve_bundled_binary_path(app_handle, binary_name)
}
#[allow(clippy::too_many_arguments)]
@@ -2037,22 +2038,10 @@ pub(crate) async fn start_media_download_internal(
log::info!("Using bundled ffmpeg: {:?}", ffmpeg_path);
log::info!("Using bundled deno: {:?}", deno_path);
// Create a temp directory with bare-name symlinks so yt-dlp finds the
// bundled binaries via PATH when told --downloader aria2c (bare name).
let bin_dir = tempfile::tempdir().map_err(|e| format!("failed to create bundling temp dir: {e}"))?;
{
use std::os::unix::fs::symlink;
symlink(&aria2c_path, bin_dir.path().join("aria2c"))
.map_err(|e| format!("failed to symlink aria2c: {e}"))?;
symlink(&ffmpeg_path, bin_dir.path().join("ffmpeg"))
.map_err(|e| format!("failed to symlink ffmpeg: {e}"))?;
symlink(&deno_path, bin_dir.path().join("deno"))
.map_err(|e| format!("failed to symlink deno: {e}"))?;
}
let bin_dir_str = bin_dir.path().to_string_lossy().to_string();
// Minimal PATH: bundled dir first, then only essential system paths.
// No user-writable or Homebrew paths that could shadow our binaries.
let path_env = format!("{}:/usr/bin:/bin", bin_dir_str);
// yt-dlp accepts an absolute path for its external downloader. Keep every
// engine explicit so behavior never depends on PATH, symlink privileges,
// user-installed tools, or platform-specific executable aliases.
let trusted_path = crate::platform::trusted_system_path()?;
let mut strike = 0_usize;
let mut processing_started = false;
@@ -2068,16 +2057,16 @@ pub(crate) async fn start_media_download_internal(
.arg("--socket-timeout").arg("20")
.arg("--retries").arg("3")
.arg("--extractor-retries").arg("3")
.arg("--downloader").arg("aria2c")
.arg("--downloader").arg(&aria2c_path)
.arg("--downloader-args").arg("aria2c:-c -x 16 -s 16 -k 1M --summary-interval=1")
.arg("--ffmpeg-location").arg(&bin_dir_str)
.arg("--ffmpeg-location").arg(&ffmpeg_path)
.arg("--js-runtimes").arg(format!("deno:{}", deno_path.to_string_lossy()))
.arg("--concurrent-fragments").arg("4")
.arg("--no-warnings")
.arg("--continue")
.arg("--compat-options").arg("no-youtube-unavailable-videos")
.arg("-o").arg(out_path.to_string_lossy().to_string())
.env("PATH", &path_env);
.env("PATH", &trusted_path);
if let Some(limit) = speed_limit.as_ref() {
if !limit.is_empty() {
@@ -2726,6 +2715,15 @@ fn update_dock_badge(_app_handle: tauri::AppHandle, count: i32) {
}
}
#[tauri::command]
fn get_platform_info() -> crate::ipc::PlatformInfo {
crate::ipc::PlatformInfo {
os: std::env::consts::OS.to_string(),
arch: std::env::consts::ARCH.to_string(),
target_triple: crate::platform::target_triple(),
}
}
#[tauri::command]
fn set_prevent_sleep(state: tauri::State<'_, AppState>, prevent: bool) -> Result<(), String> {
let mut current_preventer = state.sleep_preventer.lock().unwrap_or_else(|e| e.into_inner());
@@ -3044,15 +3042,37 @@ fn delete_keychain_password(id: String) -> Result<(), String> {
struct PairingTokenHydration {
token: String,
token_changed: bool,
persistent: bool,
error: Option<String>,
}
#[tauri::command]
fn hydrate_extension_pairing_token(
state: tauri::State<'_, crate::db::DbState>,
database: tauri::State<'_, crate::db::DbState>,
app_state: tauri::State<'_, AppState>,
) -> Result<PairingTokenHydration, String> {
let mut connection = state.lock()?;
let (token, token_changed) = crate::db::hydrate_pairing_token(&mut connection)?;
Ok(PairingTokenHydration { token, token_changed })
let mut connection = database.lock()?;
match crate::db::hydrate_pairing_token(&mut connection) {
Ok((token, token_changed)) => Ok(PairingTokenHydration {
token,
token_changed,
persistent: true,
error: None,
}),
Err(error) => {
let token = app_state
.extension_pairing_token
.read()
.map_err(|_| "Extension pairing token lock is unavailable".to_string())?
.clone();
Ok(PairingTokenHydration {
token,
token_changed: false,
persistent: false,
error: Some(error),
})
}
}
}
#[tauri::command]
@@ -3154,6 +3174,7 @@ async fn log_files(app_handle: &tauri::AppHandle) -> Result<Vec<std::path::PathB
fn redact_log_line(line: &str) -> String {
use std::sync::OnceLock;
static SECRET: OnceLock<regex::Regex> = OnceLock::new();
static HEADER: OnceLock<regex::Regex> = OnceLock::new();
static QUERY: OnceLock<regex::Regex> = OnceLock::new();
let secret = SECRET.get_or_init(|| {
regex::Regex::new(
@@ -3161,14 +3182,33 @@ fn redact_log_line(line: &str) -> String {
)
.expect("valid secret redaction regex")
});
let header = HEADER.get_or_init(|| {
regex::Regex::new(r"(?i)(authorization|cookie)\s*:\s*[^\r\n]+")
.expect("valid sensitive header redaction regex")
});
let query = QUERY.get_or_init(|| {
regex::Regex::new(r"(https?://[^\s?]+)\?[^\s]+")
.expect("valid URL query redaction regex")
});
let redacted = secret.replace_all(line, "$1=[redacted]");
let redacted = header.replace_all(line, "$1: [redacted]");
let redacted = secret.replace_all(&redacted, "$1=[redacted]");
query.replace_all(&redacted, "$1?[redacted]").into_owned()
}
fn redact_log_line_for_app(line: &str, app_handle: &tauri::AppHandle) -> String {
use tauri::Manager;
let without_home = app_handle
.path()
.home_dir()
.ok()
.map(|home| {
let home = home.to_string_lossy();
line.replace(home.as_ref(), "~")
})
.unwrap_or_else(|| line.to_string());
redact_log_line(&without_home)
}
#[tauri::command]
async fn read_logs(app_handle: tauri::AppHandle, limit: usize) -> Result<Vec<String>, String> {
let mut lines = Vec::new();
@@ -3176,7 +3216,11 @@ async fn read_logs(app_handle: tauri::AppHandle, limit: usize) -> Result<Vec<Str
let content = tokio::fs::read_to_string(&file)
.await
.map_err(|error| format!("failed to read '{}': {error}", file.display()))?;
lines.extend(content.lines().map(redact_log_line));
lines.extend(
content
.lines()
.map(|line| redact_log_line_for_app(line, &app_handle)),
);
}
let keep = limit.clamp(1, 10_000);
if lines.len() > keep {
@@ -3240,12 +3284,16 @@ async fn export_logs(
));
}
for file in log_files(&app_handle).await? {
output.push_str(&format!("===== {} =====\n", file.display()));
let file_name = file
.file_name()
.map(|name| name.to_string_lossy())
.unwrap_or_else(|| std::borrow::Cow::Borrowed("firelink.log"));
output.push_str(&format!("===== {} =====\n", file_name));
let content = tokio::fs::read_to_string(&file)
.await
.map_err(|error| format!("failed to read '{}': {error}", file.display()))?;
for line in content.lines() {
output.push_str(&redact_log_line(line));
output.push_str(&redact_log_line_for_app(line, &app_handle));
output.push('\n');
}
output.push('\n');
@@ -3290,11 +3338,13 @@ fn build_main_tray(app_handle: &tauri::AppHandle) -> Result<(), String> {
)
.map_err(|e| e.to_string())?;
let tray_icon = tauri::image::Image::from_bytes(include_bytes!("../icons/trayTemplate.png"))
.map_err(|e| e.to_string())?;
TrayIconBuilder::with_id("main")
#[cfg(target_os = "macos")]
let tray_icon_bytes = include_bytes!("../icons/trayTemplate.png").as_slice();
#[cfg(not(target_os = "macos"))]
let tray_icon_bytes = include_bytes!("../icons/128x128.png").as_slice();
let tray_icon = tauri::image::Image::from_bytes(tray_icon_bytes).map_err(|e| e.to_string())?;
let mut tray = TrayIconBuilder::with_id("main")
.icon(tray_icon)
.icon_as_template(true)
.menu(&menu)
.show_menu_on_left_click(false)
.on_menu_event(|app, event| match event.id.as_ref() {
@@ -3322,8 +3372,12 @@ fn build_main_tray(app_handle: &tauri::AppHandle) -> Result<(), String> {
) {
restore_main_window(tray.app_handle());
}
})
.build(app_handle)
});
#[cfg(target_os = "macos")]
{
tray = tray.icon_as_template(true);
}
tray.build(app_handle)
.map_err(|e| e.to_string())?;
Ok(())
@@ -3730,7 +3784,7 @@ mod tests {
}
}
static LOG_PAUSED: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(true);
static LOG_PAUSED: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
#[tauri::command]
fn toggle_log_pause(pause: bool) {
@@ -3786,7 +3840,20 @@ pub fn run() {
.map_err(|error| format!("failed to initialize persistence: {error}"))?;
let initial_pairing_token = {
let mut connection = database.lock()?;
crate::db::hydrate_pairing_token(&mut connection)?.0
match crate::db::hydrate_pairing_token(&mut connection) {
Ok((token, _)) => token,
Err(error) => {
log::warn!(
"Secure credential storage unavailable; using session-only extension token: {}",
error
);
format!(
"{}{}",
uuid::Uuid::new_v4().simple(),
uuid::Uuid::new_v4().simple()
)
}
}
};
{
let mut pairing_token = extension_pairing_token
@@ -3862,6 +3929,10 @@ pub fn run() {
});
let deep_link_app = app.handle().clone();
#[cfg(target_os = "linux")]
if let Err(error) = app.deep_link().register_all() {
log::warn!("Could not register firelink:// handler: {error}");
}
app.deep_link().on_open_url(move |event| {
dispatch_deep_links(deep_link_app.clone(), event.urls());
});
@@ -4123,7 +4194,7 @@ pub fn run() {
get_engine_status, get_aria2_engine_status, get_ytdlp_engine_status, get_ffmpeg_engine_status,
get_deno_engine_status, test_ytdlp, test_aria2c, test_ffmpeg, test_deno,
pause_download, resume_download, fetch_metadata, fetch_media_metadata,
update_dock_badge, set_prevent_sleep, get_free_space, perform_system_action,
update_dock_badge, get_platform_info, approve_download_root, set_prevent_sleep, get_free_space, perform_system_action,
ack_schedule_trigger,
check_automation_permission, request_automation_permission, open_automation_settings,
set_keychain_password, get_keychain_password, delete_keychain_password,
+1 -1
View File
@@ -1,5 +1,5 @@
// Prevents additional console window on Windows in release, DO NOT REMOVE!!
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
#![cfg_attr(all(not(debug_assertions), target_os = "windows"), windows_subsystem = "windows")]
fn main() {
firelink_lib::run()
+1 -1
View File
@@ -14,7 +14,7 @@ pub async fn get_system_proxy() -> Result<Option<String>, String> {
Ok(None)
}
}
Err(_) => Ok(None),
Err(error) => Err(format!("failed to read system proxy settings: {error}")),
}
}
+135
View File
@@ -0,0 +1,135 @@
use std::ffi::OsString;
use std::path::{Path, PathBuf};
pub fn target_arch() -> &'static str {
if cfg!(target_arch = "aarch64") {
"aarch64"
} else if cfg!(target_arch = "x86_64") {
"x86_64"
} else {
std::env::consts::ARCH
}
}
pub fn target_platform() -> &'static str {
if cfg!(target_os = "macos") {
"apple-darwin"
} else if cfg!(target_os = "windows") {
"pc-windows-msvc"
} else if cfg!(target_os = "linux") {
"unknown-linux-gnu"
} else {
std::env::consts::OS
}
}
pub fn target_triple() -> String {
format!("{}-{}", target_arch(), target_platform())
}
pub fn executable_suffix() -> &'static str {
if cfg!(target_os = "windows") {
".exe"
} else {
""
}
}
pub fn engine_binary_name(engine: &str) -> String {
format!("{engine}-{}{}", target_triple(), executable_suffix())
}
pub fn trusted_system_path() -> Result<OsString, String> {
let entries = trusted_system_path_entries();
std::env::join_paths(entries)
.map_err(|error| format!("failed to construct trusted system PATH: {error}"))
}
fn trusted_system_path_entries() -> Vec<PathBuf> {
#[cfg(target_os = "windows")]
{
let windows = std::env::var_os("SystemRoot")
.or_else(|| std::env::var_os("WINDIR"))
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from(r"C:\Windows"));
return vec![windows.join("System32"), windows];
}
#[cfg(not(target_os = "windows"))]
{
vec![PathBuf::from("/usr/bin"), PathBuf::from("/bin")]
}
}
pub fn path_is_within(path: &Path, root: &Path) -> bool {
#[cfg(target_os = "windows")]
{
let path = path.to_string_lossy().to_lowercase();
let root = root.to_string_lossy().to_lowercase();
path == root
|| path
.strip_prefix(&root)
.is_some_and(|suffix| suffix.starts_with(['\\', '/']))
}
#[cfg(not(target_os = "windows"))]
{
path.starts_with(root)
}
}
pub fn paths_equal(left: &Path, right: &Path) -> bool {
#[cfg(target_os = "windows")]
{
left.to_string_lossy()
.eq_ignore_ascii_case(&right.to_string_lossy())
}
#[cfg(not(target_os = "windows"))]
{
left == right
}
}
pub fn is_windows_reserved_filename(filename: &str) -> bool {
let stem = filename
.split('.')
.next()
.unwrap_or(filename)
.trim_end_matches(['.', ' '])
.to_ascii_uppercase();
matches!(stem.as_str(), "CON" | "PRN" | "AUX" | "NUL")
|| numbered_windows_device(&stem, "COM")
|| numbered_windows_device(&stem, "LPT")
}
fn numbered_windows_device(stem: &str, prefix: &str) -> bool {
stem.strip_prefix(prefix)
.is_some_and(|number| matches!(number, "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9"))
}
#[cfg(test)]
mod tests {
use super::{engine_binary_name, is_windows_reserved_filename, target_triple};
#[test]
fn target_engine_name_uses_current_rust_target() {
let name = engine_binary_name("ffmpeg");
assert!(name.starts_with("ffmpeg-"));
assert!(name.contains(&target_triple()));
if cfg!(target_os = "windows") {
assert!(name.ends_with(".exe"));
} else {
assert!(!name.ends_with(".exe"));
}
}
#[test]
fn recognizes_windows_reserved_device_names() {
for filename in ["CON", "con.txt", "PRN.", "aux.mp4", "NUL", "COM1.zip", "lpt9"] {
assert!(is_windows_reserved_filename(filename), "{filename}");
}
for filename in ["console.txt", "com0.zip", "com10.zip", "lpt.txt", "movie.mp4"] {
assert!(!is_windows_reserved_filename(filename), "{filename}");
}
}
}
+1
View File
@@ -252,6 +252,7 @@ fn default_settings() -> PersistedSettings {
base_download_folder: "~/Downloads".to_string(),
category_subfolders: default_category_subfolders(),
category_directory_overrides: HashMap::new(),
approved_download_roots: Vec::new(),
max_concurrent_downloads: 3,
global_speed_limit: String::new(),
is_sidebar_visible: true,
+10 -21
View File
@@ -4,9 +4,9 @@
"version": "0.7.3",
"identifier": "com.nimbold.firelink",
"build": {
"beforeDevCommand": "npm run dev",
"beforeDevCommand": "node scripts/stage-engines.js && npm run dev",
"devUrl": "http://localhost:1420",
"beforeBuildCommand": "node scripts/verify-binaries.js && npm run build",
"beforeBuildCommand": "node scripts/stage-engines.js && node scripts/verify-binaries.js --staged && npm run build",
"frontendDist": "../dist"
},
"app": {
@@ -15,29 +15,17 @@
"title": "Firelink",
"width": 1280,
"height": 760,
"minWidth": 1180,
"minHeight": 720,
"titleBarStyle": "Overlay",
"trafficLightPosition": {
"x": 17,
"y": 28
},
"hiddenTitle": true,
"transparent": true,
"windowEffects": {
"effects": ["sidebar", "mica"],
"state": "active"
}
"minWidth": 960,
"minHeight": 640,
"transparent": false
}
],
"security": {
"csp": "default-src 'self'; img-src 'self' data: https:; script-src 'self'; style-src 'self' 'unsafe-inline'; connect-src 'self' ws://localhost:* http://localhost:* http://127.0.0.1:* ws://127.0.0.1:*"
},
"macOSPrivateApi": true
}
},
"bundle": {
"active": true,
"targets": "all",
"icon": [
"icons/32x32.png",
"icons/128x128.png",
@@ -45,9 +33,10 @@
"icons/icon.icns",
"icons/icon.ico"
],
"resources": [
"binaries/**/*"
]
"resources": {
"engine-dist/": "engine-dist/",
"../THIRD_PARTY_NOTICES.md": "THIRD_PARTY_NOTICES.md"
}
},
"plugins": {
"deep-link": {
+18
View File
@@ -0,0 +1,18 @@
{
"app": {
"windows": [
{
"title": "Firelink",
"width": 1280,
"height": 760,
"minWidth": 960,
"minHeight": 640,
"transparent": false,
"decorations": true
}
]
},
"bundle": {
"targets": ["appimage"]
}
}
+28
View File
@@ -0,0 +1,28 @@
{
"app": {
"windows": [
{
"title": "Firelink",
"width": 1280,
"height": 760,
"minWidth": 960,
"minHeight": 640,
"titleBarStyle": "Overlay",
"trafficLightPosition": {
"x": 17,
"y": 28
},
"hiddenTitle": true,
"transparent": true,
"windowEffects": {
"effects": ["sidebar"],
"state": "active"
}
}
],
"macOSPrivateApi": true
},
"bundle": {
"targets": ["app", "dmg"]
}
}
+21
View File
@@ -0,0 +1,21 @@
{
"app": {
"windows": [
{
"title": "Firelink",
"width": 1280,
"height": 760,
"minWidth": 960,
"minHeight": 640,
"transparent": true,
"windowEffects": {
"effects": ["mica"],
"state": "active"
}
}
]
},
"bundle": {
"targets": ["nsis"]
}
}
+85 -6
View File
@@ -17,6 +17,7 @@ import SpeedLimiterView from "./components/SpeedLimiterView";
import LogsView from "./components/LogsView";
import { useToast } from "./contexts/ToastContext";
import { openUrl } from '@tauri-apps/plugin-opener';
import { usePlatformInfo } from './utils/platform';
let automaticUpdateCheckStarted = false;
const processingScheduleKeys = new Set<string>();
@@ -40,6 +41,8 @@ const getScheduledQueueIds = () => {
};
function App() {
const platform = usePlatformInfo();
const platformOsRef = useRef(platform.os);
const [filter, setFilter] = useState<SidebarFilter>('all');
const [coreReady, setCoreReady] = useState(false);
@@ -110,6 +113,10 @@ function App() {
const { addToast } = useToast();
useEffect(() => {
platformOsRef.current = platform.os;
}, [platform.os]);
useEffect(() => {
let active = true;
const initialize = async () => {
@@ -177,6 +184,11 @@ function App() {
}
} catch (error) {
console.error('Failed to hydrate extension pairing token:', error);
addToast({
message: `Secure credential persistence is unavailable. Browser pairing works for this session only: ${String(error)}`,
variant: 'error',
isActionable: true
});
}
};
void initialize();
@@ -238,8 +250,10 @@ function App() {
}, [maxConcurrentDownloads]);
useEffect(() => {
if (platform.os === 'macos') {
invoke('update_dock_badge', { count: showDockBadge ? activeDownloadCount : 0 }).catch(() => {});
}, [showDockBadge, activeDownloadCount]);
}
}, [platform.os, showDockBadge, activeDownloadCount]);
useEffect(() => {
invoke('set_prevent_sleep', {
@@ -360,7 +374,40 @@ function App() {
isActionable: true
});
} else if (settings.scheduler.postQueueAction !== 'none') {
invoke('perform_system_action', { action: settings.scheduler.postQueueAction }).catch(error => {
const action = settings.scheduler.postQueueAction;
let cancelled = false;
addToast({
variant: 'warning',
isActionable: true,
message: (
<div className="flex items-center gap-3">
<span>{action === 'shutdown' ? 'Shut down' : action === 'restart' ? 'Restart' : 'Sleep'} in 10 seconds.</span>
<button
type="button"
className="app-button px-2 py-1"
onClick={() => {
cancelled = true;
}}
>
Cancel
</button>
</div>
)
});
window.setTimeout(() => {
if (cancelled) return;
const activeTransfers = useDownloadStore.getState().downloads.some(download =>
isActiveDownloadStatus(download.status)
);
if (activeTransfers) {
addToast({
message: 'System action cancelled because another download is active.',
variant: 'warning',
isActionable: true
});
return;
}
invoke('perform_system_action', { action }).catch(error => {
console.error('Scheduled post action failed:', error);
addToast({
message: `Scheduled system action failed: ${String(error)}`,
@@ -368,15 +415,31 @@ function App() {
isActionable: true
});
});
}, 10_000);
}
}, [addToast, downloads, schedulerRunning, schedulerActiveDownloadIds]);
useEffect(() => {
const initNotifications = async () => {
if (!useSettingsStore.getState().showNotifications) return;
let permissionGranted = await isPermissionGranted();
try {
const permissionGranted = await isPermissionGranted();
if (!permissionGranted) {
await requestPermission();
const permission = await requestPermission();
if (permission !== 'granted') {
addToast({
message: 'System notifications are disabled for Firelink.',
variant: 'warning',
isActionable: true
});
}
}
} catch (error) {
addToast({
message: `Could not configure notifications: ${String(error)}`,
variant: 'error',
isActionable: true
});
}
};
@@ -387,7 +450,7 @@ function App() {
return useSettingsStore.persist.onFinishHydration(() => {
void initNotifications();
});
}, [showNotifications]);
}, [addToast, showNotifications]);
useEffect(() => {
const handlePaste = (e: ClipboardEvent) => {
@@ -450,17 +513,33 @@ function App() {
const item = useDownloadStore.getState().downloads.find(d => d.id === event.payload.id);
const fileName = item?.fileName || 'A file';
const platformOs = platformOsRef.current;
const sound = settings.playCompletionSound
? platformOs === 'macos'
? 'Ping'
: platformOs === 'linux'
? 'message-new-instant'
: undefined
: undefined;
if (event.payload.status === 'completed') {
try {
sendNotification({
title: 'Download Complete',
body: `${fileName} has finished downloading.`,
sound: settings.playCompletionSound ? 'default' : undefined
sound
});
} catch (error) {
console.error('Completion notification failed:', error);
}
} else {
try {
sendNotification({
title: 'Download Failed',
body: `${fileName} failed to download.`,
});
} catch (error) {
console.error('Failure notification failed:', error);
}
}
});
+1 -1
View File
@@ -1,3 +1,3 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type PairingTokenHydration = { token: string, tokenChanged: boolean, };
export type PairingTokenHydration = { token: string, tokenChanged: boolean, persistent: boolean, error: string | null, };
+1 -1
View File
@@ -8,4 +8,4 @@ import type { SettingsTab } from "./SettingsTab";
import type { SiteLogin } from "./SiteLogin";
import type { Theme } from "./Theme";
export type PersistedSettings = { theme: Theme, baseDownloadFolder: string, categorySubfolders: { [key in string]: string }, categoryDirectoryOverrides: { [key in string]: string }, maxConcurrentDownloads: number, globalSpeedLimit: string, isSidebarVisible: boolean, activeSettingsTab: SettingsTab, scheduler: SchedulerSettings, schedulerRunning: boolean, schedulerActiveDownloadIds: Array<string>, schedulerLastStartKey: string, schedulerLastStopKey: string, lastCustomSpeedLimitKiB: number, perServerConnections: number, maxAutomaticRetries: number, showNotifications: boolean, playCompletionSound: boolean, appFontSize: AppFontSize, listRowDensity: ListRowDensity, showDockBadge: boolean, showMenuBarIcon: boolean, proxyMode: ProxyMode, proxyHost: string, proxyPort: number, customUserAgent: string, askWhereToSaveEachFile: boolean, preventsSleepWhileDownloading: boolean, mediaCookieSource: MediaCookieSource, siteLogins: Array<SiteLogin>, autoCheckUpdates: boolean, };
export type PersistedSettings = { theme: Theme, baseDownloadFolder: string, categorySubfolders: { [key in string]: string }, categoryDirectoryOverrides: { [key in string]: string }, approvedDownloadRoots: Array<string>, maxConcurrentDownloads: number, globalSpeedLimit: string, isSidebarVisible: boolean, activeSettingsTab: SettingsTab, scheduler: SchedulerSettings, schedulerRunning: boolean, schedulerActiveDownloadIds: Array<string>, schedulerLastStartKey: string, schedulerLastStopKey: string, lastCustomSpeedLimitKiB: number, perServerConnections: number, maxAutomaticRetries: number, showNotifications: boolean, playCompletionSound: boolean, appFontSize: AppFontSize, listRowDensity: ListRowDensity, showDockBadge: boolean, showMenuBarIcon: boolean, proxyMode: ProxyMode, proxyHost: string, proxyPort: number, customUserAgent: string, askWhereToSaveEachFile: boolean, preventsSleepWhileDownloading: boolean, mediaCookieSource: MediaCookieSource, siteLogins: Array<SiteLogin>, autoCheckUpdates: boolean, };
+3
View File
@@ -0,0 +1,3 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type PlatformInfo = { os: string, arch: string, targetTriple: string, };
+30 -9
View File
@@ -13,8 +13,10 @@ import { canonicalizeDownloadFileName, categoryForFileName } from '../utils/down
import { fetchMediaMetadataDeduped } from '../utils/mediaMetadata';
import {
resolveCategoryDestination,
resolveDownloadFilePath
resolveDownloadFilePath,
downloadLocationEquals
} from '../utils/downloadLocations';
import { getPlatformInfo } from '../utils/platform';
import { isTransferLocked } from '../utils/downloadActions';
import { useToast } from '../contexts/ToastContext';
import {
@@ -321,7 +323,8 @@ export const AddDownloadsModal = () => {
defaultPath: saveLocation.startsWith('~') ? undefined : saveLocation
});
if (selected && typeof selected === 'string') {
setSaveLocation(selected);
const approvedPath = await useSettingsStore.getState().approveDownloadRoot(selected);
setSaveLocation(approvedPath);
setIsSaveLocationManual(true);
}
} catch (e) {
@@ -347,6 +350,7 @@ export const AddDownloadsModal = () => {
let useSharedDestination = isSaveLocationManual;
const destinationOverrides: Record<number, string> = {};
const settings = useSettingsStore.getState();
const platform = await getPlatformInfo().catch(() => ({ os: 'unknown' }));
if (settings.askWhereToSaveEachFile && parsedItems.length > 0) {
for (const [index, item] of parsedItems.entries()) {
try {
@@ -360,7 +364,8 @@ export const AddDownloadsModal = () => {
defaultPath: suggestedLocation.startsWith('~') ? undefined : suggestedLocation
});
if (selected && typeof selected === 'string') {
destinationOverrides[index] = selected;
const approvedPath = await useSettingsStore.getState().approveDownloadRoot(selected);
destinationOverrides[index] = approvedPath;
} else {
setIsSubmitting(false);
return;
@@ -398,8 +403,13 @@ export const AddDownloadsModal = () => {
const destination = download.destination ||
await resolveCategoryDestination(settings, download.category);
if (
destination === itemLocation &&
download.fileName === finalFile &&
downloadLocationEquals(
destination,
download.fileName,
itemLocation,
finalFile,
platform.os
) &&
download.status !== 'failed'
) {
fileExistsInStore = true;
@@ -456,6 +466,7 @@ export const AddDownloadsModal = () => {
destinationOverrides: Record<number, string> = {}
) => {
let itemsToAdd: Array<AddDownloadDraftRow | null> = [...parsedItems];
const platform = await getPlatformInfo().catch(() => ({ os: 'unknown' }));
if (resolutions) {
for (const res of resolutions) {
@@ -491,8 +502,13 @@ export const AddDownloadsModal = () => {
const destination = download.destination ||
await resolveCategoryDestination(currentSettings, download.category);
if (
destination === itemLocation &&
download.fileName === newName &&
downloadLocationEquals(
destination,
download.fileName,
itemLocation,
newName,
platform.os
) &&
download.status !== 'failed'
) {
storeHas = true;
@@ -534,8 +550,13 @@ export const AddDownloadsModal = () => {
const destination = download.destination ||
await resolveCategoryDestination(currentSettings, download.category);
if (
destination === itemLocation &&
download.fileName === finalFile &&
downloadLocationEquals(
destination,
download.fileName,
itemLocation,
finalFile,
platform.os
) &&
download.status !== 'failed'
) {
existingItem = download;
+2 -2
View File
@@ -211,8 +211,8 @@ export default function LogsView() {
<div className="bg-black/10 border-y border-border-modal px-4 py-2 shrink-0 flex items-center gap-2 text-text-muted text-[10px] select-none">
<Info size={12} className="text-text-muted opacity-80 shrink-0" />
<span className="opacity-90 leading-tight">
<strong className="font-medium text-text-primary mr-1">Privacy Note:</strong>
Telemetry securely captures basic hardware capabilities (OS, CPU, RAM) exclusively for troubleshooting. No unique identifiers or sensitive paths are collected. Logs remain entirely offline on your device until manually exported.
<strong className="font-medium text-text-primary mr-1">Local diagnostics:</strong>
Firelink keeps bounded rotating logs on this device for troubleshooting. Common secrets, URL queries, and home-directory paths are redacted during display and export. Nothing is uploaded automatically.
</span>
</div>
+14 -2
View File
@@ -8,6 +8,7 @@ import { PostQueueAction, SchedulerSettings, useSettingsStore } from '../store/u
import { MAIN_QUEUE_ID, useDownloadStore } from '../store/useDownloadStore';
import { WindowDragRegion } from './WindowDragRegion';
import { useToast } from '../contexts/ToastContext';
import { usePlatformInfo } from '../utils/platform';
const days = [
{ value: 0, label: 'Su' },
@@ -65,7 +66,8 @@ export default function SchedulerView() {
const { addToast } = useToast();
const [permissionMessage, setPermissionMessage] = useState('');
const [automationPermissionGranted, setAutomationPermissionGranted] = useState<boolean | null>(null);
const isMac = navigator.userAgent.includes('Mac');
const platform = usePlatformInfo();
const isMac = platform.os === 'macos';
useEffect(() => {
setDraft(savedSettings);
@@ -365,7 +367,7 @@ export default function SchedulerView() {
</section>
</div>
{isMac && (
{isMac ? (
<section className="app-card mt-4 max-w-[760px] p-5">
<div className="mb-2 flex items-center gap-2 font-semibold text-text-primary">
<LockKeyhole size={17} className="text-accent" /> System Permissions
@@ -393,6 +395,16 @@ export default function SchedulerView() {
</div>
{permissionMessage && <p className="mt-3 text-[11px] text-text-muted">{permissionMessage}</p>}
</section>
) : (
<section className="app-card mt-4 max-w-[760px] p-5">
<div className="mb-2 flex items-center gap-2 font-semibold text-text-primary">
<LockKeyhole size={17} className="text-accent" /> System Actions
</div>
<p className="text-[12px] text-text-muted">
Sleep, restart, and shut down use {platform.os === 'windows' ? 'Windows system privileges' : 'your Linux desktop and system policy'}.
Firelink reports any rejected action when it runs; no permanent permission is claimed in advance.
</p>
</section>
)}
</div>
</div>
+16 -8
View File
@@ -24,6 +24,7 @@ import {
DOWNLOAD_CATEGORIES,
normalizeCategorySubfolder
} from '../utils/downloadLocations';
import { usePlatformInfo } from '../utils/platform';
const settingsTabs: { type: SettingsTab; label: string; icon: typeof Download }[] = [
{ type: 'downloads', label: 'Downloads', icon: Download },
@@ -176,6 +177,7 @@ const CategoryFolderInput = ({
export default function SettingsView() {
const settings = useSettingsStore();
const activeTab = settings.activeSettingsTab;
const platform = usePlatformInfo();
// Local state for engine status
const [engineStatus, setEngineStatus] = useState<EngineStatusItem[] | null>(null);
@@ -362,7 +364,8 @@ runEngineChecks(false);
defaultPath: currentPath.startsWith('~') ? undefined : currentPath
});
if (selected && typeof selected === 'string') {
settings.setCategoryDirectoryOverride(category, selected);
const approvedPath = await settings.approveDownloadRoot(selected);
settings.setCategoryDirectoryOverride(category, approvedPath);
}
} catch (e) {
console.error(`Failed to select folder for ${category}:`, e);
@@ -379,7 +382,8 @@ runEngineChecks(false);
: settings.baseDownloadFolder
});
if (base && typeof base === 'string') {
settings.setBaseDownloadFolder(base);
const approvedBase = await settings.approveDownloadRoot(base);
settings.setBaseDownloadFolder(approvedBase);
try {
const safeSubfolders = Object.fromEntries(
DOWNLOAD_CATEGORIES.map(category => [
@@ -391,7 +395,7 @@ runEngineChecks(false);
])
);
await invoke('create_category_directories', {
baseFolder: base,
baseFolder: approvedBase,
subfolders: safeSubfolders
});
} catch (e) {
@@ -647,12 +651,15 @@ runEngineChecks(false);
</div>
</div>
<h2 className="settings-section-title">macOS Integration</h2>
<h2 className="settings-section-title">
{platform.os === 'macos' ? 'macOS Integration' : 'Desktop Integration'}
</h2>
<div className="mac-settings-group">
{platform.os === 'macos' && (
<label className="mac-settings-row cursor-default">
<div className="settings-row-label">
<span>Show badge on Dock icon</span>
<small>Displays the number of active downloads on the Firelink Dock icon.</small>
<small>Displays active download count on Firelink Dock icon.</small>
</div>
<input
type="checkbox"
@@ -661,10 +668,11 @@ runEngineChecks(false);
className="mac-switch"
/>
</label>
)}
<label className="mac-settings-row cursor-default">
<div className="settings-row-label">
<span>Show menu bar icon</span>
<small>Provides quick access to downloads and queues from the macOS menu bar.</small>
<span>{platform.os === 'macos' ? 'Show menu bar icon' : 'Show system tray icon'}</span>
<small>Provides quick access to downloads and queues.</small>
</div>
<input
type="checkbox"
@@ -732,7 +740,7 @@ runEngineChecks(false);
)}
<p className="settings-group-footer">
{settings.proxyMode === 'none' && 'Downloads ignore configured proxies.'}
{settings.proxyMode === 'system' && 'Downloads use the matching macOS system proxy when one is configured.'}
{settings.proxyMode === 'system' && `Downloads use the detected ${platform.os === 'macos' ? 'macOS' : platform.os === 'windows' ? 'Windows' : 'desktop'} system proxy when available.`}
{settings.proxyMode === 'custom' && (settings.proxyHost
? `Downloads use http://${settings.proxyHost}:${settings.proxyPort}.`
: 'Enter a proxy host and port to enable the custom proxy.')}
+3
View File
@@ -13,6 +13,7 @@ import type { ReleaseCheckOutcome } from './bindings/ReleaseCheckOutcome';
import type { PairingTokenHydration } from './bindings/PairingTokenHydration';
import type { EnqueueItem } from './bindings/EnqueueItem';
import type { EnqueueAccepted } from './bindings/EnqueueAccepted';
import type { PlatformInfo } from './bindings/PlatformInfo';
type CommandMap = {
fetch_metadata: {
@@ -34,6 +35,8 @@ type CommandMap = {
remove_download: { args: { id: string; deleteAssets: boolean }; result: void };
detach_download_for_reconfigure: { args: { id: string }; result: void };
update_dock_badge: { args: { count: number }; result: void };
get_platform_info: { args: undefined; result: PlatformInfo };
approve_download_root: { args: { path: string }; result: string };
set_prevent_sleep: { args: { prevent: boolean }; result: void };
perform_system_action: { args: { action: PostQueueAction }; result: void };
ack_schedule_trigger: { args: { action: 'start' | 'stop'; key: string }; result: void };
+23 -1
View File
@@ -74,6 +74,7 @@ export interface SettingsState {
baseDownloadFolder: string;
categorySubfolders: Record<string, string>;
categoryDirectoryOverrides: Record<string, string>;
approvedDownloadRoots: string[];
maxConcurrentDownloads: number;
globalSpeedLimit: string;
isSidebarVisible: boolean;
@@ -108,6 +109,7 @@ export interface SettingsState {
setTheme: (theme: Theme) => void;
setBaseDownloadFolder: (path: string) => void;
approveDownloadRoot: (path: string) => Promise<string>;
setMaxConcurrentDownloads: (count: number) => void;
setGlobalSpeedLimit: (limit: string) => void;
setActiveView: (view: ActiveView) => void;
@@ -178,6 +180,7 @@ export const useSettingsStore = create<SettingsState>()(
baseDownloadFolder: '~/Downloads',
categorySubfolders: { ...DEFAULT_CATEGORY_SUBFOLDERS },
categoryDirectoryOverrides: {},
approvedDownloadRoots: [],
maxConcurrentDownloads: 3,
globalSpeedLimit: '',
activeView: 'downloads',
@@ -224,6 +227,15 @@ export const useSettingsStore = create<SettingsState>()(
info('Settings updated: baseDownloadFolder');
set({ baseDownloadFolder: path });
},
approveDownloadRoot: async (path) => {
const approvedPath = await invoke('approve_download_root', { path });
set(state => ({
approvedDownloadRoots: state.approvedDownloadRoots.includes(approvedPath)
? state.approvedDownloadRoots
: [...state.approvedDownloadRoots, approvedPath]
}));
return approvedPath;
},
setMaxConcurrentDownloads: (max) => {
info('Settings updated: maxConcurrentDownloads');
set({ maxConcurrentDownloads: max });
@@ -299,6 +311,9 @@ export const useSettingsStore = create<SettingsState>()(
hydratePairingToken: async () => {
const result = await invoke('hydrate_extension_pairing_token');
set({ extensionPairingToken: result.token });
if (!result.persistent && result.error) {
throw new Error(`Session-only browser pairing token: ${result.error}`);
}
return result.tokenChanged;
},
setAutoCheckUpdates: (autoCheckUpdates) => set({ autoCheckUpdates }),
@@ -330,7 +345,10 @@ export const useSettingsStore = create<SettingsState>()(
: [DEFAULT_SCHEDULER_QUEUE_ID]
}
: persisted.scheduler,
siteLogins: Array.isArray(persisted.siteLogins) ? persisted.siteLogins : []
siteLogins: Array.isArray(persisted.siteLogins) ? persisted.siteLogins : [],
approvedDownloadRoots: Array.isArray(persisted.approvedDownloadRoots)
? persisted.approvedDownloadRoots
: []
} as SettingsState;
},
partialize: (state): PersistedSettings => ({
@@ -338,6 +356,7 @@ export const useSettingsStore = create<SettingsState>()(
baseDownloadFolder: state.baseDownloadFolder,
categorySubfolders: state.categorySubfolders,
categoryDirectoryOverrides: state.categoryDirectoryOverrides,
approvedDownloadRoots: state.approvedDownloadRoots,
maxConcurrentDownloads: state.maxConcurrentDownloads,
globalSpeedLimit: state.globalSpeedLimit,
isSidebarVisible: state.isSidebarVisible,
@@ -376,6 +395,9 @@ export const useSettingsStore = create<SettingsState>()(
...currentState,
...persisted,
...locations,
approvedDownloadRoots: Array.isArray(persisted.approvedDownloadRoots)
? persisted.approvedDownloadRoots
: currentState.approvedDownloadRoots,
scheduler: {
...currentState.scheduler,
...persisted.scheduler,
+6
View File
@@ -9,6 +9,7 @@ vi.mock('@tauri-apps/api/path', () => ({
}));
import {
downloadLocationEquals,
DEFAULT_CATEGORY_SUBFOLDERS,
normalizeCategorySubfolder,
normalizeDownloadLocationSettings,
@@ -16,6 +17,11 @@ import {
} from './downloadLocations';
describe('download locations', () => {
it('compares Windows and macOS locations case-insensitively', () => {
expect(downloadLocationEquals('D:\\Downloads', 'Movie.MP4', 'd:/downloads', 'movie.mp4', 'windows')).toBe(true);
expect(downloadLocationEquals('/Users/Test', 'Movie.MP4', '/users/test', 'movie.mp4', 'macos')).toBe(true);
expect(downloadLocationEquals('/home/Test', 'Movie.MP4', '/home/test', 'movie.mp4', 'linux')).toBe(false);
});
beforeEach(() => {
vi.clearAllMocks();
});
+17
View File
@@ -143,3 +143,20 @@ export const resolveDownloadFilePath = async (
const expandedDest = await expandTilde(destination);
return join(expandedDest, fileName);
};
export const downloadLocationEquals = (
leftDirectory: string,
leftFileName: string,
rightDirectory: string,
rightFileName: string,
os: string
): boolean => {
const normalize = (value: string) => {
const normalized = value.replace(/\\/g, '/').replace(/\/+$/, '');
return os === 'windows' || os === 'macos'
? normalized.toLocaleLowerCase()
: normalized;
};
return normalize(`${leftDirectory}/${leftFileName}`)
=== normalize(`${rightDirectory}/${rightFileName}`);
};
+1 -1
View File
@@ -2,7 +2,7 @@ import { info as tauriInfo, warn as tauriWarn, error as tauriError, debug as tau
import { invoke } from '@tauri-apps/api/core';
// Default to true to match backend default
let isPaused = true;
let isPaused = false;
let initPromise: Promise<void> | null = null;
+45
View File
@@ -0,0 +1,45 @@
import { useEffect, useState } from 'react';
import type { PlatformInfo } from '../bindings/PlatformInfo';
import { invokeCommand as invoke } from '../ipc';
const fallback: PlatformInfo = {
os: 'unknown',
arch: 'unknown',
targetTriple: 'unknown'
};
let cached: PlatformInfo | null = null;
let pending: Promise<PlatformInfo> | null = null;
export const getPlatformInfo = (): Promise<PlatformInfo> => {
if (cached) return Promise.resolve(cached);
if (!pending) {
pending = invoke('get_platform_info')
.then(info => {
cached = info;
return info;
})
.finally(() => {
pending = null;
});
}
return pending;
};
export const usePlatformInfo = () => {
const [platform, setPlatform] = useState<PlatformInfo>(cached ?? fallback);
useEffect(() => {
let active = true;
void getPlatformInfo()
.then(info => {
if (active) setPlatform(info);
})
.catch(() => undefined);
return () => {
active = false;
};
}, []);
return platform;
};