diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9d196c1..4662f38 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ebd2863..7a67772 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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 diff --git a/.gitignore b/.gitignore index b5c8df2..6f46a1d 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/CROSS_PLATFORM_CHECKLIST.md b/CROSS_PLATFORM_CHECKLIST.md new file mode 100644 index 0000000..1a86275 --- /dev/null +++ b/CROSS_PLATFORM_CHECKLIST.md @@ -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. diff --git a/README.md b/README.md index 9c36f1b..4cef09c 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/RELEASE.md b/RELEASE.md index 15340b8..cba45c1 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -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 git push origin v ``` -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. diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md new file mode 100644 index 0000000..9a9d453 --- /dev/null +++ b/THIRD_PARTY_NOTICES.md @@ -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: +- Source: +- 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 . Builder source and upstream tag are recorded in `engine-sources.lock.json`. + +## FFmpeg + +- Project and source: +- License information: + +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: . + +## yt-dlp + +- Project and source: +- 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: +- 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. diff --git a/engine-sources.lock.json b/engine-sources.lock.json new file mode 100644 index 0000000..85b7cc7 --- /dev/null +++ b/engine-sources.lock.json @@ -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" + } + } + } +} diff --git a/engines.lock.json b/engines.lock.json new file mode 100644 index 0000000..f86b947 --- /dev/null +++ b/engines.lock.json @@ -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" + } + } + } + } +} diff --git a/scripts/provision-engines.js b/scripts/provision-engines.js new file mode 100644 index 0000000..82eb0bb --- /dev/null +++ b/scripts/provision-engines.js @@ -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 .'); + 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 }); +} diff --git a/scripts/smoke-packaged-app.js b/scripts/smoke-packaged-app.js new file mode 100644 index 0000000..55b9077 --- /dev/null +++ b/scripts/smoke-packaged-app.js @@ -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 .'); + 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}`); diff --git a/scripts/stage-engines.js b/scripts/stage-engines.js new file mode 100644 index 0000000..95eed7f --- /dev/null +++ b/scripts/stage-engines.js @@ -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}`); diff --git a/scripts/verify-binaries.js b/scripts/verify-binaries.js index 8448446..f4703a9 100644 --- a/scripts/verify-binaries.js +++ b/scripts/verify-binaries.js @@ -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); -runEngine('yt-dlp warm start', 'yt-dlp', ['--version'], 8000); -runEngine('ffmpeg', 'ffmpeg', ['-version']); -runEngine('deno', 'deno', ['--version']); -runEngine('aria2c', 'aria2c', ['--version']); +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 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 (macOS only) ───── -if (isMacOS) { +// ───── aria2 RPC smoke test (native target only) ───── +if (canExecuteTarget) { console.log('\n─── aria2 RPC smoke test ───'); await (async function testAria2Rpc() { const p = binPath('aria2c'); diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 22b07bf..1314a09 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -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" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index bad2265..b3ea221 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -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"] } diff --git a/src-tauri/src/download_ownership.rs b/src-tauri/src/download_ownership.rs index 2f78051..c7905ee 100644 --- a/src-tauri/src/download_ownership.rs +++ b/src-tauri/src/download_ownership.rs @@ -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-"); } } diff --git a/src-tauri/src/engines.rs b/src-tauri/src/engines.rs new file mode 100644 index 0000000..9e98f83 --- /dev/null +++ b/src-tauri/src/engines.rs @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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") + ); + } +} diff --git a/src-tauri/src/ipc.rs b/src-tauri/src/ipc.rs index 1899eee..6f3edee 100644 --- a/src-tauri/src/ipc.rs +++ b/src-tauri/src/ipc.rs @@ -246,6 +246,7 @@ pub struct PersistedSettings { pub base_download_folder: String, pub category_subfolders: HashMap, pub category_directory_overrides: HashMap, + pub approved_download_roots: Vec, 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/")] diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 9a311d6..6fc4623 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -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 { 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 { 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 { + 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, 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 { } } -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 Result { - 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, } #[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 { - 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 String { use std::sync::OnceLock; static SECRET: OnceLock = OnceLock::new(); + static HEADER: OnceLock = OnceLock::new(); static QUERY: OnceLock = 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, String> { let mut lines = Vec::new(); @@ -3176,7 +3216,11 @@ async fn read_logs(app_handle: tauri::AppHandle, limit: usize) -> Result 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, diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 85e9488..532c4a1 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -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() diff --git a/src-tauri/src/parity.rs b/src-tauri/src/parity.rs index f1b945c..261df91 100644 --- a/src-tauri/src/parity.rs +++ b/src-tauri/src/parity.rs @@ -14,7 +14,7 @@ pub async fn get_system_proxy() -> Result, String> { Ok(None) } } - Err(_) => Ok(None), + Err(error) => Err(format!("failed to read system proxy settings: {error}")), } } diff --git a/src-tauri/src/platform.rs b/src-tauri/src/platform.rs new file mode 100644 index 0000000..dbb90ad --- /dev/null +++ b/src-tauri/src/platform.rs @@ -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 { + 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 { + #[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}"); + } + } +} diff --git a/src-tauri/src/settings.rs b/src-tauri/src/settings.rs index 8c4b13f..6e51be5 100644 --- a/src-tauri/src/settings.rs +++ b/src-tauri/src/settings.rs @@ -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, diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 99c88b9..b285b90 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -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": { diff --git a/src-tauri/tauri.linux.conf.json b/src-tauri/tauri.linux.conf.json new file mode 100644 index 0000000..b5344f3 --- /dev/null +++ b/src-tauri/tauri.linux.conf.json @@ -0,0 +1,18 @@ +{ + "app": { + "windows": [ + { + "title": "Firelink", + "width": 1280, + "height": 760, + "minWidth": 960, + "minHeight": 640, + "transparent": false, + "decorations": true + } + ] + }, + "bundle": { + "targets": ["appimage"] + } +} diff --git a/src-tauri/tauri.macos.conf.json b/src-tauri/tauri.macos.conf.json new file mode 100644 index 0000000..6e31633 --- /dev/null +++ b/src-tauri/tauri.macos.conf.json @@ -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"] + } +} diff --git a/src-tauri/tauri.windows.conf.json b/src-tauri/tauri.windows.conf.json new file mode 100644 index 0000000..534aa55 --- /dev/null +++ b/src-tauri/tauri.windows.conf.json @@ -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"] + } +} diff --git a/src/App.tsx b/src/App.tsx index f16d810..5e5a1bf 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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(); @@ -40,6 +41,8 @@ const getScheduledQueueIds = () => { }; function App() { + const platform = usePlatformInfo(); + const platformOsRef = useRef(platform.os); const [filter, setFilter] = useState('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(() => { - invoke('update_dock_badge', { count: showDockBadge ? activeDownloadCount : 0 }).catch(() => {}); - }, [showDockBadge, activeDownloadCount]); + if (platform.os === 'macos') { + invoke('update_dock_badge', { count: showDockBadge ? activeDownloadCount : 0 }).catch(() => {}); + } + }, [platform.os, showDockBadge, activeDownloadCount]); useEffect(() => { invoke('set_prevent_sleep', { @@ -360,23 +374,72 @@ function App() { isActionable: true }); } else if (settings.scheduler.postQueueAction !== 'none') { - invoke('perform_system_action', { action: settings.scheduler.postQueueAction }).catch(error => { - console.error('Scheduled post action failed:', error); - addToast({ - message: `Scheduled system action failed: ${String(error)}`, - variant: 'error', - isActionable: true - }); + const action = settings.scheduler.postQueueAction; + let cancelled = false; + addToast({ + variant: 'warning', + isActionable: true, + message: ( +
+ {action === 'shutdown' ? 'Shut down' : action === 'restart' ? 'Restart' : 'Sleep'} in 10 seconds. + +
+ ) }); + 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)}`, + variant: 'error', + isActionable: true + }); + }); + }, 10_000); } }, [addToast, downloads, schedulerRunning, schedulerActiveDownloadIds]); useEffect(() => { const initNotifications = async () => { if (!useSettingsStore.getState().showNotifications) return; - let permissionGranted = await isPermissionGranted(); - if (!permissionGranted) { - await requestPermission(); + try { + const permissionGranted = await isPermissionGranted(); + if (!permissionGranted) { + 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') { - sendNotification({ - title: 'Download Complete', - body: `${fileName} has finished downloading.`, - sound: settings.playCompletionSound ? 'default' : undefined - }); + try { + sendNotification({ + title: 'Download Complete', + body: `${fileName} has finished downloading.`, + sound + }); + } catch (error) { + console.error('Completion notification failed:', error); + } } else { - sendNotification({ - title: 'Download Failed', - body: `${fileName} failed to download.`, - }); + try { + sendNotification({ + title: 'Download Failed', + body: `${fileName} failed to download.`, + }); + } catch (error) { + console.error('Failure notification failed:', error); + } } }); diff --git a/src/bindings/PairingTokenHydration.ts b/src/bindings/PairingTokenHydration.ts index 8e47a8a..d890e96 100644 --- a/src/bindings/PairingTokenHydration.ts +++ b/src/bindings/PairingTokenHydration.ts @@ -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, }; diff --git a/src/bindings/PersistedSettings.ts b/src/bindings/PersistedSettings.ts index 85439e3..c2c90ad 100644 --- a/src/bindings/PersistedSettings.ts +++ b/src/bindings/PersistedSettings.ts @@ -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, 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, autoCheckUpdates: boolean, }; +export type PersistedSettings = { theme: Theme, baseDownloadFolder: string, categorySubfolders: { [key in string]: string }, categoryDirectoryOverrides: { [key in string]: string }, approvedDownloadRoots: Array, maxConcurrentDownloads: number, globalSpeedLimit: string, isSidebarVisible: boolean, activeSettingsTab: SettingsTab, scheduler: SchedulerSettings, schedulerRunning: boolean, schedulerActiveDownloadIds: Array, 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, autoCheckUpdates: boolean, }; diff --git a/src/bindings/PlatformInfo.ts b/src/bindings/PlatformInfo.ts new file mode 100644 index 0000000..bcfe96f --- /dev/null +++ b/src/bindings/PlatformInfo.ts @@ -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, }; diff --git a/src/components/AddDownloadsModal.tsx b/src/components/AddDownloadsModal.tsx index 8b23dfd..fff9518 100644 --- a/src/components/AddDownloadsModal.tsx +++ b/src/components/AddDownloadsModal.tsx @@ -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 { @@ -319,9 +321,10 @@ export const AddDownloadsModal = () => { directory: true, multiple: false, 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 = {}; 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 = {} ) => { let itemsToAdd: Array = [...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; diff --git a/src/components/LogsView.tsx b/src/components/LogsView.tsx index be531e5..d3a4cc5 100644 --- a/src/components/LogsView.tsx +++ b/src/components/LogsView.tsx @@ -211,8 +211,8 @@ export default function LogsView() {
- Privacy Note: - 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. + Local diagnostics: + 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.
diff --git a/src/components/SchedulerView.tsx b/src/components/SchedulerView.tsx index 0be6d48..e159a76 100644 --- a/src/components/SchedulerView.tsx +++ b/src/components/SchedulerView.tsx @@ -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(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() { - {isMac && ( + {isMac ? (
System Permissions @@ -393,6 +395,16 @@ export default function SchedulerView() {
{permissionMessage &&

{permissionMessage}

}
+ ) : ( +
+
+ System Actions +
+

+ 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. +

+
)} diff --git a/src/components/SettingsView.tsx b/src/components/SettingsView.tsx index 3841743..5de33b8 100644 --- a/src/components/SettingsView.tsx +++ b/src/components/SettingsView.tsx @@ -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(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,24 +651,28 @@ runEngineChecks(false); -

macOS Integration

+

+ {platform.os === 'macos' ? 'macOS Integration' : 'Desktop Integration'} +

+ {platform.os === 'macos' && ( + + )} -