mirror of
https://github.com/nimbold/Firelink.git
synced 2026-07-26 12:08:27 +00:00
feat(release): add cross-platform packaging
Add target-aware engine provisioning, platform package configs, and CI/release verification for macOS arm64, Windows x64, and Linux AppImage.
This commit is contained in:
+53
-21
@@ -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 }}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -45,9 +45,13 @@ instructions below. New packaged builds will be published through
|
||||
[GitHub Releases](https://github.com/nimbold/Firelink/releases) once the
|
||||
migration is complete.
|
||||
|
||||
Firelink is currently developed and tested on macOS. Production bundles include
|
||||
the media engines, so packaged releases will not require separate aria2, yt-dlp,
|
||||
FFmpeg, or Deno installations.
|
||||
Production bundles include target-specific media engines, so packaged releases
|
||||
do not require separate aria2, yt-dlp, FFmpeg, Deno, Python, or package-manager
|
||||
installations.
|
||||
|
||||
macOS builds are distributed without Apple code signing or notarization. Users
|
||||
must approve the downloaded app through Finder or **System Settings → Privacy &
|
||||
Security**. Firelink does not claim Gatekeeper trust.
|
||||
|
||||
### Browser Extension
|
||||
|
||||
@@ -63,9 +67,9 @@ root.
|
||||
|
||||
| Target | Status |
|
||||
| --- | --- |
|
||||
| macOS | Active development and automated testing |
|
||||
| Windows | Core architecture prepared; packaging and validation pending |
|
||||
| Linux | Core architecture prepared; packaging and validation pending |
|
||||
| macOS arm64 | Automated build, engine validation, and unsigned DMG packaging |
|
||||
| Windows x64 | Native CI and NSIS packaging configured; first clean-run validation pending |
|
||||
| Linux x64 | Native CI and AppImage packaging configured; desktop-matrix validation pending |
|
||||
|
||||
See the [changelog](CHANGELOG.md) for release history and recent work.
|
||||
|
||||
@@ -106,8 +110,16 @@ Create a production bundle:
|
||||
npm run tauri build
|
||||
```
|
||||
|
||||
Native media executables and runtime files are expected in
|
||||
`src-tauri/binaries` when running or packaging the application locally.
|
||||
macOS development uses locked payloads in `src-tauri/binaries`. Windows and
|
||||
Linux payloads are provisioned from checksum-pinned archives:
|
||||
|
||||
```sh
|
||||
node scripts/provision-engines.js --target x86_64-pc-windows-msvc
|
||||
node scripts/provision-engines.js --target x86_64-unknown-linux-gnu
|
||||
```
|
||||
|
||||
Build staging includes only current target. See `engines.lock.json`,
|
||||
`engine-sources.lock.json`, and [RELEASE.md](RELEASE.md).
|
||||
|
||||
## Repository Structure
|
||||
|
||||
|
||||
+43
-50
@@ -1,74 +1,67 @@
|
||||
# Firelink Release Process
|
||||
|
||||
## Prerequisites
|
||||
Targets:
|
||||
|
||||
- macOS ARM64 (aarch64) build host
|
||||
- Node.js 22+
|
||||
- Rust toolchain
|
||||
- Tauri CLI (`cargo install tauri-cli --version "^2"`)
|
||||
- Apple Developer account with signing certificates (for notarized builds)
|
||||
- macOS arm64 DMG
|
||||
- Windows x64 NSIS installer
|
||||
- Linux x64 AppImage
|
||||
|
||||
## Step-by-step
|
||||
## Distribution policy
|
||||
|
||||
### 1. Update version
|
||||
Firelink does not use an Apple Developer account. macOS releases are unsigned and not notarized. Users must explicitly approve the downloaded app through Finder or macOS Privacy & Security. Release copy must never describe these builds as signed, notarized, or Gatekeeper-approved.
|
||||
|
||||
Bump version in these files so they match:
|
||||
Windows releases are currently unsigned. SmartScreen may warn until code signing is added.
|
||||
|
||||
| File | Field |
|
||||
|------|-------|
|
||||
| `package.json` | `version` |
|
||||
| `src-tauri/Cargo.toml` | `package.version` |
|
||||
| `src-tauri/tauri.conf.json` | `version` |
|
||||
## Engine supply chain
|
||||
|
||||
### 2. Verify engines locally
|
||||
Firelink never falls back to system-installed media tools.
|
||||
|
||||
- `engines.lock.json` pins current committed macOS payload hashes.
|
||||
- `engine-sources.lock.json` pins Windows/Linux source archives and checksums.
|
||||
- `scripts/provision-engines.js` downloads and verifies target archives.
|
||||
- `scripts/stage-engines.js` creates one target-specific bundle payload.
|
||||
- `scripts/verify-binaries.js` runs architecture, packaging, version, and RPC checks.
|
||||
|
||||
yt-dlp must remain its official PyInstaller **onedir** distribution: launcher plus adjacent `_internal` runtime. Onefile builds are rejected because repeated extraction caused roughly 17-second startup latency.
|
||||
|
||||
## Version update
|
||||
|
||||
Keep versions aligned:
|
||||
|
||||
- `package.json`
|
||||
- `src-tauri/Cargo.toml`
|
||||
- `src-tauri/tauri.conf.json`
|
||||
|
||||
## Local macOS build
|
||||
|
||||
```bash
|
||||
node scripts/verify-binaries.js
|
||||
npm ci
|
||||
node scripts/stage-engines.js --target aarch64-apple-darwin
|
||||
node scripts/verify-binaries.js --staged --target aarch64-apple-darwin
|
||||
npm test -- --run
|
||||
npm run build
|
||||
cd src-tauri && cargo test --all-targets
|
||||
cd ..
|
||||
npm run tauri build -- --target aarch64-apple-darwin --bundles dmg
|
||||
```
|
||||
|
||||
This runs all pre-release checks:
|
||||
1. Target-triple sidecars exist
|
||||
2. Binaries are executable
|
||||
3. `file(1)` identifies correct architecture
|
||||
4. `otool -L` shows no local-only dylib paths
|
||||
5. No `/opt/homebrew` or `/usr/local/Cellar` linkage
|
||||
6. yt-dlp packaging is intact (onedir or standalone)
|
||||
7. Every engine runs and reports its version
|
||||
8. aria2 RPC daemon starts and responds to JSON-RPC
|
||||
9. No forbidden stderr patterns (`Library not loaded`, etc.)
|
||||
|
||||
The build is **blocked** if any check fails, enforced via `beforeBuildCommand` in `tauri.conf.json`.
|
||||
|
||||
### 3. Build
|
||||
Verify packaged resources, then launch outside repository working directory:
|
||||
|
||||
```bash
|
||||
npm run tauri build
|
||||
APP="src-tauri/target/aarch64-apple-darwin/release/bundle/macos/Firelink.app"
|
||||
node scripts/verify-binaries.js --search-root "$APP" --target aarch64-apple-darwin
|
||||
node scripts/smoke-packaged-app.js --executable "$APP/Contents/MacOS/firelink"
|
||||
```
|
||||
|
||||
### 4. Build artifacts
|
||||
## Automated release
|
||||
|
||||
The packaged `.app` and `.dmg` appear in:
|
||||
|
||||
```
|
||||
src-tauri/target/release/bundle/macos/
|
||||
```
|
||||
|
||||
### 5. GitHub Release
|
||||
|
||||
Push a tag to trigger the release workflow:
|
||||
Push a version tag:
|
||||
|
||||
```bash
|
||||
git tag v<version>
|
||||
git push origin v<version>
|
||||
```
|
||||
|
||||
The release workflow (`.github/workflows/release.yml`) will:
|
||||
GitHub Actions builds all targets on native runners, verifies engines inside final package contents, performs packaged launch smoke where supported, creates SHA-256 sums, then publishes one GitHub Release.
|
||||
|
||||
| Job | What it does |
|
||||
|-----|-------------|
|
||||
| `engine-verification` | Runs `verify-binaries.js`, builds `.app`, uploads artifacts |
|
||||
| `create-release` | Creates GitHub Release with checksums and release notes |
|
||||
|
||||
## CI verification
|
||||
|
||||
Every PR and push to `main` also runs `node scripts/verify-binaries.js` (see `.github/workflows/ci.yml`), so broken engines are caught before they reach a release tag.
|
||||
No target may silently skip missing engines, failed extraction, checksum mismatch, or missing package output.
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
# Third-Party Notices
|
||||
|
||||
Firelink distributes separate executable tools. Firelink's MIT license does not replace their licenses.
|
||||
|
||||
Exact versions, target hashes, sources, and build descriptions are pinned in `engines.lock.json`.
|
||||
|
||||
## aria2
|
||||
|
||||
- Project: <https://aria2.github.io/>
|
||||
- Source: <https://github.com/aria2/aria2>
|
||||
- License: GNU General Public License version 2 or later
|
||||
|
||||
Corresponding source for the distributed version is available from the source link and release tag listed in `engines.lock.json`. Firelink release notes must retain that source reference.
|
||||
|
||||
Linux x64 uses a checksum-pinned musl static build produced from upstream aria2 by <https://github.com/abcfy2/aria2-static-build>. Builder source and upstream tag are recorded in `engine-sources.lock.json`.
|
||||
|
||||
## FFmpeg
|
||||
|
||||
- Project and source: <https://ffmpeg.org/>
|
||||
- License information: <https://ffmpeg.org/legal.html>
|
||||
|
||||
Current macOS binary reports `--enable-gpl --enable-version3`; distribution therefore follows GNU GPL version 3 requirements. Exact build identity is recorded in `engines.lock.json`.
|
||||
|
||||
Windows and Linux archives come from checksum-pinned BtbN FFmpeg GPL builds. Build project: <https://github.com/BtbN/FFmpeg-Builds>.
|
||||
|
||||
## yt-dlp
|
||||
|
||||
- Project and source: <https://github.com/yt-dlp/yt-dlp>
|
||||
- License: The Unlicense
|
||||
|
||||
Firelink uses a self-contained PyInstaller onedir distribution. Embedded Python packages keep their own license files inside `_internal` where supplied.
|
||||
|
||||
## Deno
|
||||
|
||||
- Project and source: <https://github.com/denoland/deno>
|
||||
- License: MIT
|
||||
|
||||
## OpenSSL and bundled native libraries
|
||||
|
||||
Engine payloads may contain OpenSSL, SQLite, c-ares, libssh2, gettext/libintl, zstd, and other runtime libraries. Their copyright and license notices remain part of their source distributions and embedded package metadata.
|
||||
|
||||
Release engineering must review each newly added target payload before adding its hashes to `engines.lock.json`. Missing provenance or license data blocks release.
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
#!/usr/bin/env node
|
||||
import crypto from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const repoRoot = path.resolve(__dirname, '..');
|
||||
const sourceLock = JSON.parse(
|
||||
fs.readFileSync(path.join(repoRoot, 'engine-sources.lock.json'), 'utf8')
|
||||
);
|
||||
|
||||
function argValue(name) {
|
||||
const index = process.argv.indexOf(name);
|
||||
return index >= 0 ? process.argv[index + 1] : undefined;
|
||||
}
|
||||
|
||||
const target = argValue('--target')
|
||||
|| process.env.FIRELINK_TARGET_TRIPLE
|
||||
|| process.env.TAURI_ENV_TARGET_TRIPLE;
|
||||
if (!target) {
|
||||
console.error('Pass --target <Rust target triple>.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const targetSources = sourceLock.targets?.[target];
|
||||
if (!targetSources) {
|
||||
console.error(`No source lock exists for ${target}.`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const destination = path.join(repoRoot, 'src-tauri', 'provisioned-engines', target);
|
||||
const temporary = fs.mkdtempSync(path.join(os.tmpdir(), `firelink-engines-${target}-`));
|
||||
const isWindows = target.includes('windows');
|
||||
const executableSuffix = isWindows ? '.exe' : '';
|
||||
|
||||
function sha256(file) {
|
||||
return crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex');
|
||||
}
|
||||
|
||||
async function download(name, source) {
|
||||
const sourcePath = new URL(source.url).pathname;
|
||||
const archive = path.join(
|
||||
temporary,
|
||||
`${name}${sourcePath.endsWith('.tar.xz') ? '.tar.xz' : '.zip'}`
|
||||
);
|
||||
const response = await fetch(source.url, { redirect: 'follow' });
|
||||
if (!response.ok || !response.body) {
|
||||
throw new Error(`Failed to download ${name}: HTTP ${response.status}`);
|
||||
}
|
||||
const output = fs.createWriteStream(archive);
|
||||
const reader = response.body.getReader();
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
if (!output.write(Buffer.from(value))) {
|
||||
await new Promise(resolve => output.once('drain', resolve));
|
||||
}
|
||||
}
|
||||
await new Promise(resolve => output.end(resolve));
|
||||
|
||||
const actual = sha256(archive);
|
||||
if (actual !== source.sha256) {
|
||||
throw new Error(`Archive checksum mismatch for ${name}. Expected ${source.sha256}, got ${actual}`);
|
||||
}
|
||||
const extracted = path.join(temporary, `${name}-extracted`);
|
||||
fs.mkdirSync(extracted);
|
||||
if (archive.endsWith('.zip') && process.platform !== 'win32') {
|
||||
execFileSync('unzip', ['-q', archive, '-d', extracted], { stdio: 'inherit' });
|
||||
} else {
|
||||
execFileSync('tar', ['-xf', archive, '-C', extracted], { stdio: 'inherit' });
|
||||
}
|
||||
return extracted;
|
||||
}
|
||||
|
||||
function findFile(root, names) {
|
||||
const wanted = new Set(names.map(name => name.toLowerCase()));
|
||||
const matches = [];
|
||||
const walk = directory => {
|
||||
for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
|
||||
const file = path.join(directory, entry.name);
|
||||
if (entry.isDirectory()) walk(file);
|
||||
else if (entry.isFile() && wanted.has(entry.name.toLowerCase())) matches.push(file);
|
||||
}
|
||||
};
|
||||
walk(root);
|
||||
if (matches.length !== 1) {
|
||||
throw new Error(`Expected one of [${names.join(', ')}] under ${root}, found ${matches.length}`);
|
||||
}
|
||||
return matches[0];
|
||||
}
|
||||
|
||||
function copyExecutable(source, engine) {
|
||||
const output = path.join(destination, `${engine}-${target}${executableSuffix}`);
|
||||
fs.copyFileSync(source, output);
|
||||
if (!isWindows) fs.chmodSync(output, 0o755);
|
||||
}
|
||||
|
||||
function writePayloadManifest() {
|
||||
const files = [];
|
||||
const walk = directory => {
|
||||
for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
|
||||
const file = path.join(directory, entry.name);
|
||||
if (entry.isDirectory()) walk(file);
|
||||
else if (entry.isFile() && entry.name !== 'payload-manifest.json') files.push(file);
|
||||
}
|
||||
};
|
||||
walk(destination);
|
||||
files.sort((left, right) => left.localeCompare(right));
|
||||
const manifest = {
|
||||
schemaVersion: 1,
|
||||
target,
|
||||
generatedFrom: Object.fromEntries(
|
||||
Object.entries(targetSources).map(([name, source]) => [
|
||||
name,
|
||||
{
|
||||
version: source.version,
|
||||
url: source.url || source.sourceUrl,
|
||||
sha256: source.sha256 || source.sourceSha256
|
||||
}
|
||||
])
|
||||
),
|
||||
files: Object.fromEntries(
|
||||
files.map(file => [
|
||||
path.relative(destination, file).split(path.sep).join('/'),
|
||||
sha256(file)
|
||||
])
|
||||
)
|
||||
};
|
||||
fs.writeFileSync(
|
||||
path.join(destination, 'payload-manifest.json'),
|
||||
`${JSON.stringify(manifest, null, 2)}\n`
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
fs.rmSync(destination, { recursive: true, force: true });
|
||||
fs.mkdirSync(destination, { recursive: true });
|
||||
|
||||
const ytdlp = await download('yt-dlp', targetSources['yt-dlp']);
|
||||
copyExecutable(
|
||||
findFile(ytdlp, isWindows ? ['yt-dlp.exe'] : ['yt-dlp_linux']),
|
||||
'yt-dlp'
|
||||
);
|
||||
fs.cpSync(path.join(ytdlp, '_internal'), path.join(destination, '_internal'), {
|
||||
recursive: true,
|
||||
preserveTimestamps: true
|
||||
});
|
||||
|
||||
const deno = await download('deno', targetSources.deno);
|
||||
copyExecutable(findFile(deno, isWindows ? ['deno.exe'] : ['deno']), 'deno');
|
||||
|
||||
const ffmpeg = await download('ffmpeg', targetSources.ffmpeg);
|
||||
copyExecutable(findFile(ffmpeg, isWindows ? ['ffmpeg.exe'] : ['ffmpeg']), 'ffmpeg');
|
||||
|
||||
const aria2 = await download('aria2c', targetSources.aria2c);
|
||||
copyExecutable(findFile(aria2, isWindows ? ['aria2c.exe'] : ['aria2c']), 'aria2c');
|
||||
|
||||
writePayloadManifest();
|
||||
console.log(`Provisioned locked engine payload at ${destination}`);
|
||||
} finally {
|
||||
fs.rmSync(temporary, { recursive: true, force: true });
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
#!/usr/bin/env node
|
||||
import { spawn } from 'node:child_process';
|
||||
import path from 'node:path';
|
||||
|
||||
function argValue(name) {
|
||||
const index = process.argv.indexOf(name);
|
||||
return index >= 0 ? process.argv[index + 1] : undefined;
|
||||
}
|
||||
|
||||
const executableArg = argValue('--executable');
|
||||
if (!executableArg) {
|
||||
console.error('Pass --executable <path>.');
|
||||
process.exit(1);
|
||||
}
|
||||
const executable = path.resolve(executableArg);
|
||||
|
||||
const child = spawn(executable, [], {
|
||||
cwd: process.env.RUNNER_TEMP || process.env.TMPDIR || process.cwd(),
|
||||
detached: process.platform !== 'win32',
|
||||
stdio: ['ignore', 'pipe', 'pipe']
|
||||
});
|
||||
let stderr = '';
|
||||
let spawnError = null;
|
||||
child.on('error', error => {
|
||||
spawnError = error;
|
||||
});
|
||||
child.stderr.on('data', data => {
|
||||
stderr += data.toString();
|
||||
});
|
||||
|
||||
let readyPort = null;
|
||||
for (let attempt = 0; attempt < 40 && readyPort === null; attempt += 1) {
|
||||
for (let port = 6412; port <= 6422; port += 1) {
|
||||
try {
|
||||
const response = await fetch(`http://127.0.0.1:${port}/ping`);
|
||||
if (response.headers.get('x-firelink-server') === '1') {
|
||||
readyPort = port;
|
||||
break;
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
if (readyPort === null) {
|
||||
await new Promise(resolve => setTimeout(resolve, 250));
|
||||
}
|
||||
}
|
||||
|
||||
if (process.platform === 'win32') {
|
||||
spawn('taskkill', ['/pid', String(child.pid), '/t', '/f'], { stdio: 'ignore' });
|
||||
} else {
|
||||
try {
|
||||
process.kill(-child.pid, 'SIGTERM');
|
||||
} catch {
|
||||
child.kill('SIGTERM');
|
||||
}
|
||||
}
|
||||
|
||||
if (readyPort === null) {
|
||||
const detail = spawnError?.message || stderr.slice(-1000);
|
||||
console.error(`Packaged Firelink did not expose extension server. ${detail}`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(`Packaged Firelink smoke passed on 127.0.0.1:${readyPort}`);
|
||||
@@ -0,0 +1,163 @@
|
||||
#!/usr/bin/env node
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import crypto from 'node:crypto';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const repoRoot = path.resolve(__dirname, '..');
|
||||
const binariesRoot = path.join(repoRoot, 'src-tauri', 'binaries');
|
||||
const outputRoot = path.join(repoRoot, 'src-tauri', 'engine-dist');
|
||||
const lock = JSON.parse(fs.readFileSync(path.join(repoRoot, 'engines.lock.json'), 'utf8'));
|
||||
|
||||
const archMap = { x64: 'x86_64', arm64: 'aarch64' };
|
||||
const platformMap = {
|
||||
darwin: 'apple-darwin',
|
||||
win32: 'pc-windows-msvc',
|
||||
linux: 'unknown-linux-gnu',
|
||||
};
|
||||
|
||||
function argValue(name) {
|
||||
const index = process.argv.indexOf(name);
|
||||
return index >= 0 ? process.argv[index + 1] : undefined;
|
||||
}
|
||||
|
||||
const hostTarget = `${archMap[os.arch()]}-${platformMap[os.platform()]}`;
|
||||
const target = argValue('--target')
|
||||
|| process.env.TAURI_ENV_TARGET_TRIPLE
|
||||
|| process.env.FIRELINK_TARGET_TRIPLE
|
||||
|| hostTarget;
|
||||
const isWindowsTarget = target.includes('windows');
|
||||
const suffix = isWindowsTarget ? '.exe' : '';
|
||||
const engines = ['yt-dlp', 'aria2c', 'ffmpeg', 'deno'];
|
||||
const expectedNames = engines.map(engine => `${engine}-${target}${suffix}`);
|
||||
const targetLock = lock.targets?.[target];
|
||||
|
||||
const configuredSource = process.env.FIRELINK_ENGINE_SOURCE_DIR
|
||||
? path.resolve(process.env.FIRELINK_ENGINE_SOURCE_DIR)
|
||||
: null;
|
||||
const canonicalSource = path.join(binariesRoot, target);
|
||||
const provisionedSource = path.join(repoRoot, 'src-tauri', 'provisioned-engines', target);
|
||||
const legacyMacSource = target.endsWith('apple-darwin') ? binariesRoot : null;
|
||||
const source = [configuredSource, canonicalSource, provisionedSource, legacyMacSource]
|
||||
.filter(Boolean)
|
||||
.find(candidate => expectedNames.every(name => fs.existsSync(path.join(candidate, name))));
|
||||
|
||||
if (!source) {
|
||||
console.error(`No complete engine payload found for ${target}.`);
|
||||
console.error(`Expected source directory: ${canonicalSource}`);
|
||||
console.error(`Expected files: ${expectedNames.join(', ')}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
function sha256(file) {
|
||||
return crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex');
|
||||
}
|
||||
|
||||
function treeDigest(root) {
|
||||
const files = [];
|
||||
const walk = directory => {
|
||||
for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
|
||||
const file = path.join(directory, entry.name);
|
||||
if (entry.isDirectory()) walk(file);
|
||||
else if (entry.isFile()) files.push(file);
|
||||
}
|
||||
};
|
||||
walk(root);
|
||||
files.sort((left, right) => left.localeCompare(right));
|
||||
const digest = crypto.createHash('sha256');
|
||||
for (const file of files) {
|
||||
const relative = path.relative(root, file).split(path.sep).join('/');
|
||||
digest.update(`${relative}\0${sha256(file)}\n`);
|
||||
}
|
||||
return { files: files.length, sha256: digest.digest('hex') };
|
||||
}
|
||||
|
||||
if (targetLock) {
|
||||
for (const engine of engines) {
|
||||
const name = `${engine}-${target}${suffix}`;
|
||||
const expected = targetLock.engines?.[engine]?.sha256;
|
||||
const actual = sha256(path.join(source, name));
|
||||
if (!expected || actual !== expected) {
|
||||
console.error(`Checksum mismatch for ${name}. Expected ${expected || 'missing lock'}, got ${actual}.`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
for (const [runtimeDir, expected] of Object.entries(targetLock.runtimeTrees || {})) {
|
||||
const sourceDir = path.join(source, runtimeDir);
|
||||
if (!fs.existsSync(sourceDir)) {
|
||||
console.error(`Missing locked runtime directory ${runtimeDir} for ${target}.`);
|
||||
process.exit(1);
|
||||
}
|
||||
const actual = treeDigest(sourceDir);
|
||||
if (actual.files !== expected.files || actual.sha256 !== expected.sha256) {
|
||||
console.error(`Runtime checksum mismatch for ${runtimeDir}.`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const manifestPath = path.join(source, 'payload-manifest.json');
|
||||
if (!fs.existsSync(manifestPath)) {
|
||||
console.error(`No committed lock or payload manifest exists for ${target}.`);
|
||||
process.exit(1);
|
||||
}
|
||||
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
|
||||
if (manifest.target !== target) {
|
||||
console.error(`Payload manifest target mismatch: ${manifest.target}`);
|
||||
process.exit(1);
|
||||
}
|
||||
for (const [relative, expected] of Object.entries(manifest.files || {})) {
|
||||
const file = path.join(source, relative);
|
||||
if (!fs.existsSync(file) || sha256(file) !== expected) {
|
||||
console.error(`Payload manifest mismatch: ${relative}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
const actualFiles = [];
|
||||
const walk = directory => {
|
||||
for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
|
||||
const file = path.join(directory, entry.name);
|
||||
if (entry.isDirectory()) walk(file);
|
||||
else if (entry.isFile() && entry.name !== 'payload-manifest.json') {
|
||||
actualFiles.push(path.relative(source, file).split(path.sep).join('/'));
|
||||
}
|
||||
}
|
||||
};
|
||||
walk(source);
|
||||
const expectedFiles = Object.keys(manifest.files || {}).sort();
|
||||
actualFiles.sort();
|
||||
if (JSON.stringify(actualFiles) !== JSON.stringify(expectedFiles)) {
|
||||
console.error(`Payload contains files not covered by manifest for ${target}.`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
const destination = path.join(outputRoot, target);
|
||||
fs.rmSync(outputRoot, { recursive: true, force: true });
|
||||
fs.mkdirSync(destination, { recursive: true });
|
||||
|
||||
for (const name of expectedNames) {
|
||||
fs.copyFileSync(path.join(source, name), path.join(destination, name));
|
||||
if (!isWindowsTarget) {
|
||||
fs.chmodSync(path.join(destination, name), 0o755);
|
||||
}
|
||||
}
|
||||
|
||||
for (const runtimeDir of ['_internal', 'aria2-libs']) {
|
||||
const sourceDir = path.join(source, runtimeDir);
|
||||
if (fs.existsSync(sourceDir)) {
|
||||
fs.cpSync(sourceDir, path.join(destination, runtimeDir), {
|
||||
recursive: true,
|
||||
dereference: false,
|
||||
preserveTimestamps: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
const payloadManifest = path.join(source, 'payload-manifest.json');
|
||||
if (fs.existsSync(payloadManifest)) {
|
||||
fs.copyFileSync(payloadManifest, path.join(destination, 'payload-manifest.json'));
|
||||
}
|
||||
|
||||
console.log(`Staged Firelink engines for ${target} from ${source}`);
|
||||
+87
-11
@@ -8,6 +8,11 @@ import { fileURLToPath } from 'node:url';
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
function argValue(name) {
|
||||
const index = process.argv.indexOf(name);
|
||||
return index >= 0 ? process.argv[index + 1] : undefined;
|
||||
}
|
||||
|
||||
const archMap = { x64: 'x86_64', arm64: 'aarch64' };
|
||||
const platformMap = {
|
||||
darwin: 'apple-darwin',
|
||||
@@ -23,14 +28,47 @@ if (!currentArch || !currentPlatform) {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const targetTriple = `${currentArch}-${currentPlatform}`;
|
||||
const isWindows = os.platform() === 'win32';
|
||||
const isMacOS = os.platform() === 'darwin';
|
||||
const targetTriple = argValue('--target')
|
||||
|| process.env.FIRELINK_TARGET_TRIPLE
|
||||
|| `${currentArch}-${currentPlatform}`;
|
||||
const hostTriple = `${currentArch}-${currentPlatform}`;
|
||||
const canExecuteTarget = targetTriple === hostTriple;
|
||||
const isWindows = targetTriple.includes('windows');
|
||||
const isMacOS = targetTriple.includes('apple-darwin');
|
||||
const isLinux = targetTriple.includes('linux');
|
||||
const ext = isWindows ? '.exe' : '';
|
||||
const suffix = `-${targetTriple}${ext}`;
|
||||
|
||||
const scriptsDir = __dirname;
|
||||
const binariesDir = path.join(scriptsDir, '..', 'src-tauri', 'binaries');
|
||||
const searchRoot = argValue('--search-root');
|
||||
function findEngineRoot(root) {
|
||||
const expected = `yt-dlp-${targetTriple}${ext}`;
|
||||
const matches = [];
|
||||
const walk = directory => {
|
||||
for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
|
||||
const candidate = path.join(directory, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
if (fs.existsSync(path.join(candidate, expected))) matches.push(candidate);
|
||||
walk(candidate);
|
||||
}
|
||||
}
|
||||
};
|
||||
walk(path.resolve(root));
|
||||
if (matches.length !== 1) {
|
||||
throw new Error(`Expected exactly one packaged engine root under ${root}, found ${matches.length}`);
|
||||
}
|
||||
return matches[0];
|
||||
}
|
||||
|
||||
const configuredRoot = argValue('--root')
|
||||
|| (process.argv.includes('--staged')
|
||||
? path.join(scriptsDir, '..', 'src-tauri', 'engine-dist', targetTriple)
|
||||
: searchRoot
|
||||
? findEngineRoot(searchRoot)
|
||||
: null);
|
||||
const binariesDir = configuredRoot
|
||||
? path.resolve(configuredRoot)
|
||||
: path.join(scriptsDir, '..', 'src-tauri', 'binaries');
|
||||
const requiredEngines = ['yt-dlp', 'aria2c', 'ffmpeg', 'deno'];
|
||||
|
||||
const FORBIDDEN_OTOOL_PATHS = ['/opt/homebrew', '/usr/local/Cellar'];
|
||||
@@ -79,6 +117,10 @@ if (exitCode !== 0) {
|
||||
// ───── Check 2: Executable permission ─────
|
||||
console.log('\n─── 2. Executable permission ───');
|
||||
for (const eng of requiredEngines) {
|
||||
if (isWindows) {
|
||||
ok(`Executable permission is represented by PE format on Windows: ${binName(eng)}`);
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
fs.accessSync(binPath(eng), fs.constants.X_OK);
|
||||
ok(`Executable ${binName(eng)}`);
|
||||
@@ -90,6 +132,19 @@ for (const eng of requiredEngines) {
|
||||
// ───── Check 3: file(1) identification ─────
|
||||
console.log('\n─── 3. file(1) identification ───');
|
||||
for (const eng of requiredEngines) {
|
||||
if (os.platform() === 'win32') {
|
||||
try {
|
||||
const header = fs.readFileSync(binPath(eng)).subarray(0, 2).toString('ascii');
|
||||
if (header === 'MZ') {
|
||||
ok(`${binName(eng)}: Windows PE executable`);
|
||||
} else {
|
||||
fail(`${binName(eng)}: missing Windows PE MZ header`);
|
||||
}
|
||||
} catch (e) {
|
||||
fail(`identify ${binName(eng)}: ${e.message}`);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const out = execFileSync('file', ['--brief', binPath(eng)], {
|
||||
encoding: 'utf-8',
|
||||
@@ -172,7 +227,6 @@ console.log('\n─── 6. yt-dlp packaging ───');
|
||||
}
|
||||
|
||||
const requiredRuntimeFiles = [
|
||||
path.join(internalDir, 'Python'),
|
||||
path.join(internalDir, 'yt_dlp_ejs', 'yt', 'solver', 'core.min.js'),
|
||||
path.join(internalDir, 'yt_dlp_ejs', 'yt', 'solver', 'lib.min.js'),
|
||||
];
|
||||
@@ -183,6 +237,16 @@ console.log('\n─── 6. yt-dlp packaging ───');
|
||||
fail(`Missing yt-dlp runtime component: ${path.relative(binariesDir, required)}`);
|
||||
}
|
||||
}
|
||||
const runtimeCandidates = isWindows
|
||||
? fs.readdirSync(internalDir).filter(name => /^python.*\.dll$/i.test(name))
|
||||
: isLinux
|
||||
? fs.readdirSync(internalDir).filter(name => /^libpython.*\.so/i.test(name) || name === 'Python')
|
||||
: ['Python', 'Python.framework'].filter(name => fs.existsSync(path.join(internalDir, name)));
|
||||
if (runtimeCandidates.length > 0) {
|
||||
ok(`yt-dlp embedded Python runtime: ${runtimeCandidates[0]}`);
|
||||
} else {
|
||||
fail(`Missing embedded Python runtime in ${internalDir}`);
|
||||
}
|
||||
} else {
|
||||
fail('yt-dlp must use the self-contained onedir distribution; onefile adds ~17 seconds to every launch');
|
||||
}
|
||||
@@ -222,14 +286,26 @@ function runEngine(label, engine, args, timeout = 30000) {
|
||||
}
|
||||
}
|
||||
|
||||
runEngine('yt-dlp cold start', 'yt-dlp', ['--version'], 20000);
|
||||
const coldStartTimeout = isMacOS ? 120000 : 30000;
|
||||
if (canExecuteTarget) {
|
||||
runEngine('yt-dlp cold start', 'yt-dlp', ['--version'], coldStartTimeout);
|
||||
runEngine('yt-dlp warm start', 'yt-dlp', ['--version'], 8000);
|
||||
runEngine('ffmpeg', 'ffmpeg', ['-version']);
|
||||
runEngine('deno', 'deno', ['--version']);
|
||||
runEngine('aria2c', 'aria2c', ['--version']);
|
||||
|
||||
// ───── aria2 RPC smoke test (macOS only) ─────
|
||||
runEngine('ffmpeg cold start', 'ffmpeg', ['-version'], coldStartTimeout);
|
||||
runEngine('deno cold start', 'deno', ['--version'], coldStartTimeout);
|
||||
runEngine('aria2c cold start', 'aria2c', ['--version'], coldStartTimeout);
|
||||
if (isMacOS) {
|
||||
// Unsigned binaries can incur a one-time macOS provenance scan after copying.
|
||||
// Warm checks enforce engine startup performance after that OS validation.
|
||||
runEngine('ffmpeg warm start', 'ffmpeg', ['-version'], 8000);
|
||||
runEngine('deno warm start', 'deno', ['--version'], 8000);
|
||||
runEngine('aria2c warm start', 'aria2c', ['--version'], 8000);
|
||||
}
|
||||
} else {
|
||||
console.log(` Runtime tests skipped on ${hostTriple}; native ${targetTriple} CI runs them.`);
|
||||
}
|
||||
|
||||
// ───── aria2 RPC smoke test (native target only) ─────
|
||||
if (canExecuteTarget) {
|
||||
console.log('\n─── aria2 RPC smoke test ───');
|
||||
await (async function testAria2Rpc() {
|
||||
const p = binPath('aria2c');
|
||||
|
||||
Generated
+56
-1
@@ -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"
|
||||
|
||||
@@ -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"] }
|
||||
|
||||
@@ -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-");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
use std::path::{Path, PathBuf};
|
||||
use tauri::Manager;
|
||||
|
||||
pub fn resolve_bundled_binary_path(
|
||||
app_handle: &tauri::AppHandle,
|
||||
engine: &str,
|
||||
) -> Result<PathBuf, String> {
|
||||
let binary_name = crate::platform::engine_binary_name(engine);
|
||||
let target = crate::platform::target_triple();
|
||||
|
||||
if let Ok(resource_dir) = app_handle.path().resource_dir() {
|
||||
for candidate in packaged_candidates(&resource_dir, &target, &binary_name) {
|
||||
if candidate.is_file() {
|
||||
log::info!("Resolved bundled '{}' at: {:?}", engine, candidate);
|
||||
return Ok(candidate);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Ok(exe_path) = std::env::current_exe() {
|
||||
for candidate in executable_relative_candidates(&exe_path, &target, &binary_name) {
|
||||
if candidate.is_file() {
|
||||
log::info!("Resolved bundled '{}' at: {:?}", engine, candidate);
|
||||
return Ok(candidate);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Ok(cwd) = std::env::current_dir() {
|
||||
for candidate in development_candidates(&cwd, &target, &binary_name) {
|
||||
if candidate.is_file() {
|
||||
let absolute = candidate.canonicalize().map_err(|error| {
|
||||
format!("Failed to canonicalize '{}': {error}", candidate.display())
|
||||
})?;
|
||||
log::info!("Resolved bundled '{}' at: {:?}", engine, absolute);
|
||||
return Ok(absolute);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Err(format!(
|
||||
"Could not find bundled binary '{}' for target '{}' (expected name: {})",
|
||||
engine, target, binary_name
|
||||
))
|
||||
}
|
||||
|
||||
fn packaged_candidates(resource_dir: &Path, target: &str, binary_name: &str) -> Vec<PathBuf> {
|
||||
let mut candidates = vec![
|
||||
resource_dir
|
||||
.join("engine-dist")
|
||||
.join(target)
|
||||
.join(binary_name),
|
||||
resource_dir.join("engines").join(target).join(binary_name),
|
||||
];
|
||||
if cfg!(target_os = "macos") {
|
||||
candidates.push(resource_dir.join("binaries").join(binary_name));
|
||||
candidates.push(resource_dir.join(binary_name));
|
||||
}
|
||||
candidates
|
||||
}
|
||||
|
||||
fn executable_relative_candidates(
|
||||
executable: &Path,
|
||||
target: &str,
|
||||
binary_name: &str,
|
||||
) -> Vec<PathBuf> {
|
||||
let Some(executable_dir) = executable.parent() else {
|
||||
return Vec::new();
|
||||
};
|
||||
let mut candidates = vec![
|
||||
executable_dir
|
||||
.join("engine-dist")
|
||||
.join(target)
|
||||
.join(binary_name),
|
||||
executable_dir.join("engines").join(target).join(binary_name),
|
||||
];
|
||||
|
||||
if cfg!(target_os = "macos") {
|
||||
if let Some(contents_dir) = executable_dir.parent() {
|
||||
candidates.push(
|
||||
contents_dir
|
||||
.join("Resources")
|
||||
.join("engine-dist")
|
||||
.join(target)
|
||||
.join(binary_name),
|
||||
);
|
||||
candidates.push(
|
||||
contents_dir
|
||||
.join("Resources")
|
||||
.join("binaries")
|
||||
.join(binary_name),
|
||||
);
|
||||
}
|
||||
}
|
||||
candidates
|
||||
}
|
||||
|
||||
fn development_candidates(cwd: &Path, target: &str, binary_name: &str) -> Vec<PathBuf> {
|
||||
let roots = [cwd.to_path_buf(), cwd.join("src-tauri")];
|
||||
let mut candidates = Vec::new();
|
||||
for root in roots {
|
||||
candidates.push(
|
||||
root.join("engine-dist")
|
||||
.join(target)
|
||||
.join(binary_name),
|
||||
);
|
||||
candidates.push(root.join("binaries").join(target).join(binary_name));
|
||||
if cfg!(target_os = "macos") {
|
||||
candidates.push(root.join("binaries").join(binary_name));
|
||||
}
|
||||
}
|
||||
candidates
|
||||
}
|
||||
|
||||
pub fn ytdlp_internal_dir(binary_path: &Path) -> Option<PathBuf> {
|
||||
binary_path.parent().map(|parent| parent.join("_internal"))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{development_candidates, packaged_candidates};
|
||||
use std::path::Path;
|
||||
|
||||
#[test]
|
||||
fn canonical_packaged_layout_is_target_scoped() {
|
||||
let candidates = packaged_candidates(
|
||||
Path::new("/resources"),
|
||||
"x86_64-unknown-linux-gnu",
|
||||
"yt-dlp-x86_64-unknown-linux-gnu",
|
||||
);
|
||||
assert_eq!(
|
||||
candidates[0],
|
||||
Path::new("/resources/engine-dist/x86_64-unknown-linux-gnu/yt-dlp-x86_64-unknown-linux-gnu")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn canonical_development_layout_is_target_scoped() {
|
||||
let candidates = development_candidates(
|
||||
Path::new("/repo"),
|
||||
"x86_64-pc-windows-msvc",
|
||||
"aria2c-x86_64-pc-windows-msvc.exe",
|
||||
);
|
||||
assert_eq!(
|
||||
candidates[0],
|
||||
Path::new("/repo/engine-dist/x86_64-pc-windows-msvc/aria2c-x86_64-pc-windows-msvc.exe")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -246,6 +246,7 @@ pub struct PersistedSettings {
|
||||
pub base_download_folder: String,
|
||||
pub category_subfolders: HashMap<String, String>,
|
||||
pub category_directory_overrides: HashMap<String, String>,
|
||||
pub approved_download_roots: Vec<String>,
|
||||
pub max_concurrent_downloads: usize,
|
||||
pub global_speed_limit: String,
|
||||
pub is_sidebar_visible: bool,
|
||||
@@ -279,6 +280,15 @@ pub struct PersistedSettings {
|
||||
pub auto_check_updates: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export, export_to = "../../src/bindings/")]
|
||||
pub struct PlatformInfo {
|
||||
pub os: String,
|
||||
pub arch: String,
|
||||
pub target_triple: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Serialize, Deserialize, TS)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
#[ts(export, export_to = "../../src/bindings/")]
|
||||
|
||||
+212
-141
@@ -984,11 +984,12 @@ async fn fetch_media_metadata_uncached(app_handle: tauri::AppHandle, url: String
|
||||
let deno_path = resolve_bundled_binary_path(&app_handle, "deno").map_err(|e| format!("failed to find bundled deno: {e}"))?;
|
||||
let ffmpeg_path = resolve_bundled_binary_path(&app_handle, "ffmpeg").map_err(|e| format!("failed to find bundled ffmpeg: {e}"))?;
|
||||
let deno_runtime = format!("deno:{}", deno_path.to_string_lossy());
|
||||
let trusted_path = crate::platform::trusted_system_path()?;
|
||||
|
||||
use tauri_plugin_shell::ShellExt;
|
||||
let (ytdlp_path, _) = resolve_metadata_ytdlp_path(&app_handle)?;
|
||||
let mut cmd = app_handle.shell().command(ytdlp_path.to_string_lossy().to_string());
|
||||
cmd = cmd.env("PATH", "/usr/bin:/bin")
|
||||
cmd = cmd.env("PATH", trusted_path)
|
||||
.arg("--ffmpeg-location").arg(&ffmpeg_path)
|
||||
.arg("--js-runtimes").arg(&deno_runtime)
|
||||
.arg("--no-warnings")
|
||||
@@ -1133,45 +1134,109 @@ pub(crate) fn is_safe_path(path: &std::path::Path, app_handle: &tauri::AppHandle
|
||||
return false;
|
||||
}
|
||||
|
||||
let Some(canonical_path) = canonicalize_with_missing_components(path) else {
|
||||
return false;
|
||||
};
|
||||
|
||||
approved_download_roots(app_handle)
|
||||
.into_iter()
|
||||
.filter_map(|root| canonicalize_with_missing_components(&root))
|
||||
.any(|root| crate::platform::path_is_within(&canonical_path, &root))
|
||||
}
|
||||
|
||||
fn canonicalize_with_missing_components(path: &std::path::Path) -> Option<std::path::PathBuf> {
|
||||
let mut existing = path;
|
||||
let mut missing = Vec::new();
|
||||
while !existing.exists() {
|
||||
let Some(name) = existing.file_name() else {
|
||||
return false;
|
||||
};
|
||||
missing.push(name.to_owned());
|
||||
let Some(parent) = existing.parent() else {
|
||||
return false;
|
||||
};
|
||||
existing = parent;
|
||||
missing.push(existing.file_name()?.to_owned());
|
||||
existing = existing.parent()?;
|
||||
}
|
||||
let Ok(mut canonical_path) = std::fs::canonicalize(existing) else {
|
||||
return false;
|
||||
};
|
||||
let mut canonical = std::fs::canonicalize(existing).ok()?;
|
||||
for component in missing.iter().rev() {
|
||||
canonical_path.push(component);
|
||||
canonical.push(component);
|
||||
}
|
||||
Some(canonical)
|
||||
}
|
||||
|
||||
let mut allowed_prefixes = Vec::new();
|
||||
fn approved_download_roots(app_handle: &tauri::AppHandle) -> Vec<std::path::PathBuf> {
|
||||
use tauri::Manager;
|
||||
if let Ok(home) = app_handle.path().home_dir() {
|
||||
allowed_prefixes.push(home.join("Downloads"));
|
||||
allowed_prefixes.push(home.join("Music"));
|
||||
allowed_prefixes.push(home.join("Movies"));
|
||||
allowed_prefixes.push(home.join("Pictures"));
|
||||
allowed_prefixes.push(home.join("Documents"));
|
||||
allowed_prefixes.push(home.join("Desktop"));
|
||||
}
|
||||
allowed_prefixes.push(std::path::PathBuf::from("/Volumes"));
|
||||
|
||||
for prefix in allowed_prefixes {
|
||||
let canonical_prefix = std::fs::canonicalize(&prefix).unwrap_or(prefix);
|
||||
if canonical_path.starts_with(&canonical_prefix) {
|
||||
return true;
|
||||
let mut roots = Vec::new();
|
||||
for root in [
|
||||
app_handle.path().download_dir().ok(),
|
||||
app_handle.path().audio_dir().ok(),
|
||||
app_handle.path().video_dir().ok(),
|
||||
app_handle.path().picture_dir().ok(),
|
||||
app_handle.path().document_dir().ok(),
|
||||
app_handle.path().desktop_dir().ok(),
|
||||
]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
{
|
||||
push_unique_path(&mut roots, root);
|
||||
}
|
||||
|
||||
if let Ok(settings) = crate::settings::load_settings(app_handle) {
|
||||
push_unique_path(
|
||||
&mut roots,
|
||||
resolve_path(&settings.base_download_folder, app_handle),
|
||||
);
|
||||
for root in settings.category_directory_overrides.values() {
|
||||
push_unique_path(&mut roots, resolve_path(root, app_handle));
|
||||
}
|
||||
for root in &settings.approved_download_roots {
|
||||
push_unique_path(&mut roots, resolve_path(root, app_handle));
|
||||
}
|
||||
}
|
||||
|
||||
false
|
||||
roots
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn approve_download_root(
|
||||
app_handle: tauri::AppHandle,
|
||||
path: String,
|
||||
) -> Result<String, String> {
|
||||
let resolved = resolve_path(path.trim(), &app_handle);
|
||||
if !resolved.is_absolute() {
|
||||
return Err("Download root must be an absolute path".to_string());
|
||||
}
|
||||
let canonical = std::fs::canonicalize(&resolved)
|
||||
.map_err(|error| format!("Failed to resolve download root: {error}"))?;
|
||||
if !canonical.is_dir() {
|
||||
return Err("Download root must be an existing directory".to_string());
|
||||
}
|
||||
let canonical_text = canonical.to_string_lossy().to_string();
|
||||
|
||||
crate::settings::update_settings_state(&app_handle, |state| {
|
||||
let roots = state
|
||||
.entry("approvedDownloadRoots".to_string())
|
||||
.or_insert_with(|| serde_json::Value::Array(Vec::new()));
|
||||
if !roots.is_array() {
|
||||
*roots = serde_json::Value::Array(Vec::new());
|
||||
}
|
||||
let values = roots.as_array_mut().expect("approved roots must be an array");
|
||||
if !values.iter().filter_map(serde_json::Value::as_str).any(|root| {
|
||||
crate::platform::paths_equal(
|
||||
std::path::Path::new(root),
|
||||
std::path::Path::new(&canonical_text),
|
||||
)
|
||||
}) {
|
||||
values.push(serde_json::Value::String(canonical_text.clone()));
|
||||
}
|
||||
})?;
|
||||
|
||||
Ok(canonical_text)
|
||||
}
|
||||
|
||||
fn push_unique_path(paths: &mut Vec<std::path::PathBuf>, path: std::path::PathBuf) {
|
||||
if path.is_absolute()
|
||||
&& !paths
|
||||
.iter()
|
||||
.any(|existing| crate::platform::paths_equal(existing, &path))
|
||||
{
|
||||
paths.push(path);
|
||||
}
|
||||
}
|
||||
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
@@ -1215,6 +1280,8 @@ pub mod error;
|
||||
pub mod commands;
|
||||
pub mod download_ownership;
|
||||
pub mod retry;
|
||||
mod engines;
|
||||
mod platform;
|
||||
mod settings;
|
||||
pub use error::AppError;
|
||||
|
||||
@@ -1281,7 +1348,10 @@ pub struct EngineStatusResult {
|
||||
pub(crate) fn resolve_path(path: &str, app_handle: &tauri::AppHandle) -> std::path::PathBuf {
|
||||
use tauri::Manager;
|
||||
let mut resolved = std::path::PathBuf::from(path);
|
||||
if let Some(stripped) = path.strip_prefix("~/") {
|
||||
if let Some(stripped) = path
|
||||
.strip_prefix("~/")
|
||||
.or_else(|| path.strip_prefix("~\\"))
|
||||
{
|
||||
if let Ok(home) = app_handle.path().home_dir() {
|
||||
resolved = home.join(stripped);
|
||||
}
|
||||
@@ -1641,13 +1711,9 @@ fn generate_remediation_hint(error: &str, _kind: &str) -> Option<String> {
|
||||
}
|
||||
}
|
||||
|
||||
fn arch_suffix() -> &'static str {
|
||||
if cfg!(target_arch = "aarch64") { "aarch64" } else { "x86_64" }
|
||||
}
|
||||
|
||||
async fn check_aria2(app_handle: &tauri::AppHandle, port: u16, secret: &str) -> EngineStatusItem {
|
||||
let sidecar_name = "aria2c";
|
||||
let expected_sidecar = format!("{}-{}-apple-darwin", sidecar_name, arch_suffix());
|
||||
let expected_sidecar = crate::platform::engine_binary_name(sidecar_name);
|
||||
|
||||
let resolved = resolve_bundled_binary_path(app_handle, sidecar_name);
|
||||
let resolved_path = resolved.as_ref().ok().map(|p| p.to_string_lossy().to_string());
|
||||
@@ -1698,7 +1764,7 @@ async fn check_aria2(app_handle: &tauri::AppHandle, port: u16, secret: &str) ->
|
||||
|
||||
async fn check_ytdlp(app_handle: &tauri::AppHandle) -> EngineStatusItem {
|
||||
let sidecar_name = "yt-dlp";
|
||||
let expected_sidecar = format!("{}-{}-apple-darwin", sidecar_name, arch_suffix());
|
||||
let expected_sidecar = crate::platform::engine_binary_name(sidecar_name);
|
||||
|
||||
let resolved = resolve_bundled_binary_path(app_handle, sidecar_name);
|
||||
let resolved_path = resolved.as_ref().ok().map(|p| p.to_string_lossy().to_string());
|
||||
@@ -1706,7 +1772,8 @@ async fn check_ytdlp(app_handle: &tauri::AppHandle) -> EngineStatusItem {
|
||||
let (has_internal_dir, has_python_framework) = if let Some(ref path) = resolved_path {
|
||||
let parent = std::path::Path::new(path).parent().map(|p| p.to_path_buf());
|
||||
if let Some(parent) = parent {
|
||||
let internal = parent.join("_internal");
|
||||
let internal = crate::engines::ytdlp_internal_dir(std::path::Path::new(path))
|
||||
.unwrap_or_else(|| parent.join("_internal"));
|
||||
let hi = internal.is_dir();
|
||||
let hp = if hi {
|
||||
internal.join("Python.framework").is_dir() || internal.join("Python").exists()
|
||||
@@ -1758,7 +1825,7 @@ async fn check_ytdlp(app_handle: &tauri::AppHandle) -> EngineStatusItem {
|
||||
|
||||
async fn check_ffmpeg(app_handle: &tauri::AppHandle) -> EngineStatusItem {
|
||||
let sidecar_name = "ffmpeg";
|
||||
let expected_sidecar = format!("{}-{}-apple-darwin", sidecar_name, arch_suffix());
|
||||
let expected_sidecar = crate::platform::engine_binary_name(sidecar_name);
|
||||
|
||||
let resolved = resolve_bundled_binary_path(app_handle, sidecar_name);
|
||||
let resolved_path = resolved.as_ref().ok().map(|p| p.to_string_lossy().to_string());
|
||||
@@ -1801,7 +1868,7 @@ async fn check_ffmpeg(app_handle: &tauri::AppHandle) -> EngineStatusItem {
|
||||
|
||||
async fn check_deno(app_handle: &tauri::AppHandle) -> EngineStatusItem {
|
||||
let sidecar_name = "deno";
|
||||
let expected_sidecar = format!("{}-{}-apple-darwin", sidecar_name, arch_suffix());
|
||||
let expected_sidecar = crate::platform::engine_binary_name(sidecar_name);
|
||||
|
||||
let resolved = resolve_bundled_binary_path(app_handle, sidecar_name);
|
||||
let resolved_path = resolved.as_ref().ok().map(|p| p.to_string_lossy().to_string());
|
||||
@@ -1880,73 +1947,7 @@ async fn get_deno_engine_status(app_handle: tauri::AppHandle) -> Result<EngineSt
|
||||
|
||||
|
||||
fn resolve_bundled_binary_path(app_handle: &tauri::AppHandle, binary_name: &str) -> Result<std::path::PathBuf, String> {
|
||||
let full_name = format!(
|
||||
"{}-{}-apple-darwin",
|
||||
binary_name,
|
||||
if cfg!(target_arch = "aarch64") { "aarch64" } else { "x86_64" },
|
||||
);
|
||||
|
||||
// Production: use the resource copy as the canonical engine location.
|
||||
if let Ok(resource_dir) = app_handle.path().resource_dir() {
|
||||
for candidate in [
|
||||
resource_dir.join("binaries").join(&full_name),
|
||||
resource_dir.join(&full_name),
|
||||
] {
|
||||
if candidate.is_file() {
|
||||
log::info!("Resolved bundled '{}' at: {:?}", binary_name, candidate);
|
||||
return Ok(candidate);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Packaged macOS fallback: resolve directly from Contents/MacOS to
|
||||
// Contents/Resources when Tauri's resource_dir is unavailable.
|
||||
if let Ok(exe_path) = std::env::current_exe() {
|
||||
if let Some(contents_dir) = exe_path.parent().and_then(std::path::Path::parent) {
|
||||
let candidate = contents_dir
|
||||
.join("Resources")
|
||||
.join("binaries")
|
||||
.join(&full_name);
|
||||
if candidate.is_file() {
|
||||
log::info!("Resolved bundled '{}' at: {:?}", binary_name, candidate);
|
||||
return Ok(candidate);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Dev mode: search relative to CWD
|
||||
if let Ok(cwd) = std::env::current_dir() {
|
||||
let search_dirs = [
|
||||
cwd.join("binaries"),
|
||||
cwd.join("src-tauri").join("binaries"),
|
||||
];
|
||||
for dir in &search_dirs {
|
||||
let candidate = dir.join(&full_name);
|
||||
if candidate.is_file() {
|
||||
let abs = candidate.canonicalize().map_err(|e| {
|
||||
format!("Failed to canonicalize '{}': {}", full_name, e)
|
||||
})?;
|
||||
log::info!("Resolved bundled '{}' at: {:?}", binary_name, abs);
|
||||
return Ok(abs);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Compatibility fallback for older bundles produced with externalBin.
|
||||
if let Ok(exe_path) = std::env::current_exe() {
|
||||
if let Some(exe_dir) = exe_path.parent() {
|
||||
let candidate = exe_dir.join(binary_name);
|
||||
if candidate.is_file() {
|
||||
log::info!("Resolved legacy bundled '{}' at: {:?}", binary_name, candidate);
|
||||
return Ok(candidate);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Err(format!(
|
||||
"Could not find bundled binary '{}' (expected name: {})",
|
||||
binary_name, full_name
|
||||
))
|
||||
crate::engines::resolve_bundled_binary_path(app_handle, binary_name)
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
@@ -2037,22 +2038,10 @@ pub(crate) async fn start_media_download_internal(
|
||||
log::info!("Using bundled ffmpeg: {:?}", ffmpeg_path);
|
||||
log::info!("Using bundled deno: {:?}", deno_path);
|
||||
|
||||
// Create a temp directory with bare-name symlinks so yt-dlp finds the
|
||||
// bundled binaries via PATH when told --downloader aria2c (bare name).
|
||||
let bin_dir = tempfile::tempdir().map_err(|e| format!("failed to create bundling temp dir: {e}"))?;
|
||||
{
|
||||
use std::os::unix::fs::symlink;
|
||||
symlink(&aria2c_path, bin_dir.path().join("aria2c"))
|
||||
.map_err(|e| format!("failed to symlink aria2c: {e}"))?;
|
||||
symlink(&ffmpeg_path, bin_dir.path().join("ffmpeg"))
|
||||
.map_err(|e| format!("failed to symlink ffmpeg: {e}"))?;
|
||||
symlink(&deno_path, bin_dir.path().join("deno"))
|
||||
.map_err(|e| format!("failed to symlink deno: {e}"))?;
|
||||
}
|
||||
let bin_dir_str = bin_dir.path().to_string_lossy().to_string();
|
||||
// Minimal PATH: bundled dir first, then only essential system paths.
|
||||
// No user-writable or Homebrew paths that could shadow our binaries.
|
||||
let path_env = format!("{}:/usr/bin:/bin", bin_dir_str);
|
||||
// yt-dlp accepts an absolute path for its external downloader. Keep every
|
||||
// engine explicit so behavior never depends on PATH, symlink privileges,
|
||||
// user-installed tools, or platform-specific executable aliases.
|
||||
let trusted_path = crate::platform::trusted_system_path()?;
|
||||
|
||||
let mut strike = 0_usize;
|
||||
let mut processing_started = false;
|
||||
@@ -2068,16 +2057,16 @@ pub(crate) async fn start_media_download_internal(
|
||||
.arg("--socket-timeout").arg("20")
|
||||
.arg("--retries").arg("3")
|
||||
.arg("--extractor-retries").arg("3")
|
||||
.arg("--downloader").arg("aria2c")
|
||||
.arg("--downloader").arg(&aria2c_path)
|
||||
.arg("--downloader-args").arg("aria2c:-c -x 16 -s 16 -k 1M --summary-interval=1")
|
||||
.arg("--ffmpeg-location").arg(&bin_dir_str)
|
||||
.arg("--ffmpeg-location").arg(&ffmpeg_path)
|
||||
.arg("--js-runtimes").arg(format!("deno:{}", deno_path.to_string_lossy()))
|
||||
.arg("--concurrent-fragments").arg("4")
|
||||
.arg("--no-warnings")
|
||||
.arg("--continue")
|
||||
.arg("--compat-options").arg("no-youtube-unavailable-videos")
|
||||
.arg("-o").arg(out_path.to_string_lossy().to_string())
|
||||
.env("PATH", &path_env);
|
||||
.env("PATH", &trusted_path);
|
||||
|
||||
if let Some(limit) = speed_limit.as_ref() {
|
||||
if !limit.is_empty() {
|
||||
@@ -2726,6 +2715,15 @@ fn update_dock_badge(_app_handle: tauri::AppHandle, count: i32) {
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn get_platform_info() -> crate::ipc::PlatformInfo {
|
||||
crate::ipc::PlatformInfo {
|
||||
os: std::env::consts::OS.to_string(),
|
||||
arch: std::env::consts::ARCH.to_string(),
|
||||
target_triple: crate::platform::target_triple(),
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn set_prevent_sleep(state: tauri::State<'_, AppState>, prevent: bool) -> Result<(), String> {
|
||||
let mut current_preventer = state.sleep_preventer.lock().unwrap_or_else(|e| e.into_inner());
|
||||
@@ -3044,15 +3042,37 @@ fn delete_keychain_password(id: String) -> Result<(), String> {
|
||||
struct PairingTokenHydration {
|
||||
token: String,
|
||||
token_changed: bool,
|
||||
persistent: bool,
|
||||
error: Option<String>,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn hydrate_extension_pairing_token(
|
||||
state: tauri::State<'_, crate::db::DbState>,
|
||||
database: tauri::State<'_, crate::db::DbState>,
|
||||
app_state: tauri::State<'_, AppState>,
|
||||
) -> Result<PairingTokenHydration, String> {
|
||||
let mut connection = state.lock()?;
|
||||
let (token, token_changed) = crate::db::hydrate_pairing_token(&mut connection)?;
|
||||
Ok(PairingTokenHydration { token, token_changed })
|
||||
let mut connection = database.lock()?;
|
||||
match crate::db::hydrate_pairing_token(&mut connection) {
|
||||
Ok((token, token_changed)) => Ok(PairingTokenHydration {
|
||||
token,
|
||||
token_changed,
|
||||
persistent: true,
|
||||
error: None,
|
||||
}),
|
||||
Err(error) => {
|
||||
let token = app_state
|
||||
.extension_pairing_token
|
||||
.read()
|
||||
.map_err(|_| "Extension pairing token lock is unavailable".to_string())?
|
||||
.clone();
|
||||
Ok(PairingTokenHydration {
|
||||
token,
|
||||
token_changed: false,
|
||||
persistent: false,
|
||||
error: Some(error),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -3154,6 +3174,7 @@ async fn log_files(app_handle: &tauri::AppHandle) -> Result<Vec<std::path::PathB
|
||||
fn redact_log_line(line: &str) -> String {
|
||||
use std::sync::OnceLock;
|
||||
static SECRET: OnceLock<regex::Regex> = OnceLock::new();
|
||||
static HEADER: OnceLock<regex::Regex> = OnceLock::new();
|
||||
static QUERY: OnceLock<regex::Regex> = OnceLock::new();
|
||||
let secret = SECRET.get_or_init(|| {
|
||||
regex::Regex::new(
|
||||
@@ -3161,14 +3182,33 @@ fn redact_log_line(line: &str) -> String {
|
||||
)
|
||||
.expect("valid secret redaction regex")
|
||||
});
|
||||
let header = HEADER.get_or_init(|| {
|
||||
regex::Regex::new(r"(?i)(authorization|cookie)\s*:\s*[^\r\n]+")
|
||||
.expect("valid sensitive header redaction regex")
|
||||
});
|
||||
let query = QUERY.get_or_init(|| {
|
||||
regex::Regex::new(r"(https?://[^\s?]+)\?[^\s]+")
|
||||
.expect("valid URL query redaction regex")
|
||||
});
|
||||
let redacted = secret.replace_all(line, "$1=[redacted]");
|
||||
let redacted = header.replace_all(line, "$1: [redacted]");
|
||||
let redacted = secret.replace_all(&redacted, "$1=[redacted]");
|
||||
query.replace_all(&redacted, "$1?[redacted]").into_owned()
|
||||
}
|
||||
|
||||
fn redact_log_line_for_app(line: &str, app_handle: &tauri::AppHandle) -> String {
|
||||
use tauri::Manager;
|
||||
let without_home = app_handle
|
||||
.path()
|
||||
.home_dir()
|
||||
.ok()
|
||||
.map(|home| {
|
||||
let home = home.to_string_lossy();
|
||||
line.replace(home.as_ref(), "~")
|
||||
})
|
||||
.unwrap_or_else(|| line.to_string());
|
||||
redact_log_line(&without_home)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn read_logs(app_handle: tauri::AppHandle, limit: usize) -> Result<Vec<String>, String> {
|
||||
let mut lines = Vec::new();
|
||||
@@ -3176,7 +3216,11 @@ async fn read_logs(app_handle: tauri::AppHandle, limit: usize) -> Result<Vec<Str
|
||||
let content = tokio::fs::read_to_string(&file)
|
||||
.await
|
||||
.map_err(|error| format!("failed to read '{}': {error}", file.display()))?;
|
||||
lines.extend(content.lines().map(redact_log_line));
|
||||
lines.extend(
|
||||
content
|
||||
.lines()
|
||||
.map(|line| redact_log_line_for_app(line, &app_handle)),
|
||||
);
|
||||
}
|
||||
let keep = limit.clamp(1, 10_000);
|
||||
if lines.len() > keep {
|
||||
@@ -3240,12 +3284,16 @@ async fn export_logs(
|
||||
));
|
||||
}
|
||||
for file in log_files(&app_handle).await? {
|
||||
output.push_str(&format!("===== {} =====\n", file.display()));
|
||||
let file_name = file
|
||||
.file_name()
|
||||
.map(|name| name.to_string_lossy())
|
||||
.unwrap_or_else(|| std::borrow::Cow::Borrowed("firelink.log"));
|
||||
output.push_str(&format!("===== {} =====\n", file_name));
|
||||
let content = tokio::fs::read_to_string(&file)
|
||||
.await
|
||||
.map_err(|error| format!("failed to read '{}': {error}", file.display()))?;
|
||||
for line in content.lines() {
|
||||
output.push_str(&redact_log_line(line));
|
||||
output.push_str(&redact_log_line_for_app(line, &app_handle));
|
||||
output.push('\n');
|
||||
}
|
||||
output.push('\n');
|
||||
@@ -3290,11 +3338,13 @@ fn build_main_tray(app_handle: &tauri::AppHandle) -> Result<(), String> {
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let tray_icon = tauri::image::Image::from_bytes(include_bytes!("../icons/trayTemplate.png"))
|
||||
.map_err(|e| e.to_string())?;
|
||||
TrayIconBuilder::with_id("main")
|
||||
#[cfg(target_os = "macos")]
|
||||
let tray_icon_bytes = include_bytes!("../icons/trayTemplate.png").as_slice();
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
let tray_icon_bytes = include_bytes!("../icons/128x128.png").as_slice();
|
||||
let tray_icon = tauri::image::Image::from_bytes(tray_icon_bytes).map_err(|e| e.to_string())?;
|
||||
let mut tray = TrayIconBuilder::with_id("main")
|
||||
.icon(tray_icon)
|
||||
.icon_as_template(true)
|
||||
.menu(&menu)
|
||||
.show_menu_on_left_click(false)
|
||||
.on_menu_event(|app, event| match event.id.as_ref() {
|
||||
@@ -3322,8 +3372,12 @@ fn build_main_tray(app_handle: &tauri::AppHandle) -> Result<(), String> {
|
||||
) {
|
||||
restore_main_window(tray.app_handle());
|
||||
}
|
||||
})
|
||||
.build(app_handle)
|
||||
});
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
tray = tray.icon_as_template(true);
|
||||
}
|
||||
tray.build(app_handle)
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
Ok(())
|
||||
@@ -3730,7 +3784,7 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
static LOG_PAUSED: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(true);
|
||||
static LOG_PAUSED: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
|
||||
|
||||
#[tauri::command]
|
||||
fn toggle_log_pause(pause: bool) {
|
||||
@@ -3786,7 +3840,20 @@ pub fn run() {
|
||||
.map_err(|error| format!("failed to initialize persistence: {error}"))?;
|
||||
let initial_pairing_token = {
|
||||
let mut connection = database.lock()?;
|
||||
crate::db::hydrate_pairing_token(&mut connection)?.0
|
||||
match crate::db::hydrate_pairing_token(&mut connection) {
|
||||
Ok((token, _)) => token,
|
||||
Err(error) => {
|
||||
log::warn!(
|
||||
"Secure credential storage unavailable; using session-only extension token: {}",
|
||||
error
|
||||
);
|
||||
format!(
|
||||
"{}{}",
|
||||
uuid::Uuid::new_v4().simple(),
|
||||
uuid::Uuid::new_v4().simple()
|
||||
)
|
||||
}
|
||||
}
|
||||
};
|
||||
{
|
||||
let mut pairing_token = extension_pairing_token
|
||||
@@ -3862,6 +3929,10 @@ pub fn run() {
|
||||
});
|
||||
|
||||
let deep_link_app = app.handle().clone();
|
||||
#[cfg(target_os = "linux")]
|
||||
if let Err(error) = app.deep_link().register_all() {
|
||||
log::warn!("Could not register firelink:// handler: {error}");
|
||||
}
|
||||
app.deep_link().on_open_url(move |event| {
|
||||
dispatch_deep_links(deep_link_app.clone(), event.urls());
|
||||
});
|
||||
@@ -4123,7 +4194,7 @@ pub fn run() {
|
||||
get_engine_status, get_aria2_engine_status, get_ytdlp_engine_status, get_ffmpeg_engine_status,
|
||||
get_deno_engine_status, test_ytdlp, test_aria2c, test_ffmpeg, test_deno,
|
||||
pause_download, resume_download, fetch_metadata, fetch_media_metadata,
|
||||
update_dock_badge, set_prevent_sleep, get_free_space, perform_system_action,
|
||||
update_dock_badge, get_platform_info, approve_download_root, set_prevent_sleep, get_free_space, perform_system_action,
|
||||
ack_schedule_trigger,
|
||||
check_automation_permission, request_automation_permission, open_automation_settings,
|
||||
set_keychain_password, get_keychain_password, delete_keychain_password,
|
||||
|
||||
@@ -1,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()
|
||||
|
||||
@@ -14,7 +14,7 @@ pub async fn get_system_proxy() -> Result<Option<String>, String> {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
Err(_) => Ok(None),
|
||||
Err(error) => Err(format!("failed to read system proxy settings: {error}")),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
use std::ffi::OsString;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
pub fn target_arch() -> &'static str {
|
||||
if cfg!(target_arch = "aarch64") {
|
||||
"aarch64"
|
||||
} else if cfg!(target_arch = "x86_64") {
|
||||
"x86_64"
|
||||
} else {
|
||||
std::env::consts::ARCH
|
||||
}
|
||||
}
|
||||
|
||||
pub fn target_platform() -> &'static str {
|
||||
if cfg!(target_os = "macos") {
|
||||
"apple-darwin"
|
||||
} else if cfg!(target_os = "windows") {
|
||||
"pc-windows-msvc"
|
||||
} else if cfg!(target_os = "linux") {
|
||||
"unknown-linux-gnu"
|
||||
} else {
|
||||
std::env::consts::OS
|
||||
}
|
||||
}
|
||||
|
||||
pub fn target_triple() -> String {
|
||||
format!("{}-{}", target_arch(), target_platform())
|
||||
}
|
||||
|
||||
pub fn executable_suffix() -> &'static str {
|
||||
if cfg!(target_os = "windows") {
|
||||
".exe"
|
||||
} else {
|
||||
""
|
||||
}
|
||||
}
|
||||
|
||||
pub fn engine_binary_name(engine: &str) -> String {
|
||||
format!("{engine}-{}{}", target_triple(), executable_suffix())
|
||||
}
|
||||
|
||||
pub fn trusted_system_path() -> Result<OsString, String> {
|
||||
let entries = trusted_system_path_entries();
|
||||
std::env::join_paths(entries)
|
||||
.map_err(|error| format!("failed to construct trusted system PATH: {error}"))
|
||||
}
|
||||
|
||||
fn trusted_system_path_entries() -> Vec<PathBuf> {
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
let windows = std::env::var_os("SystemRoot")
|
||||
.or_else(|| std::env::var_os("WINDIR"))
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|| PathBuf::from(r"C:\Windows"));
|
||||
return vec![windows.join("System32"), windows];
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
{
|
||||
vec![PathBuf::from("/usr/bin"), PathBuf::from("/bin")]
|
||||
}
|
||||
}
|
||||
|
||||
pub fn path_is_within(path: &Path, root: &Path) -> bool {
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
let path = path.to_string_lossy().to_lowercase();
|
||||
let root = root.to_string_lossy().to_lowercase();
|
||||
path == root
|
||||
|| path
|
||||
.strip_prefix(&root)
|
||||
.is_some_and(|suffix| suffix.starts_with(['\\', '/']))
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
{
|
||||
path.starts_with(root)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn paths_equal(left: &Path, right: &Path) -> bool {
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
left.to_string_lossy()
|
||||
.eq_ignore_ascii_case(&right.to_string_lossy())
|
||||
}
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
{
|
||||
left == right
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_windows_reserved_filename(filename: &str) -> bool {
|
||||
let stem = filename
|
||||
.split('.')
|
||||
.next()
|
||||
.unwrap_or(filename)
|
||||
.trim_end_matches(['.', ' '])
|
||||
.to_ascii_uppercase();
|
||||
matches!(stem.as_str(), "CON" | "PRN" | "AUX" | "NUL")
|
||||
|| numbered_windows_device(&stem, "COM")
|
||||
|| numbered_windows_device(&stem, "LPT")
|
||||
}
|
||||
|
||||
fn numbered_windows_device(stem: &str, prefix: &str) -> bool {
|
||||
stem.strip_prefix(prefix)
|
||||
.is_some_and(|number| matches!(number, "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9"))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{engine_binary_name, is_windows_reserved_filename, target_triple};
|
||||
|
||||
#[test]
|
||||
fn target_engine_name_uses_current_rust_target() {
|
||||
let name = engine_binary_name("ffmpeg");
|
||||
assert!(name.starts_with("ffmpeg-"));
|
||||
assert!(name.contains(&target_triple()));
|
||||
if cfg!(target_os = "windows") {
|
||||
assert!(name.ends_with(".exe"));
|
||||
} else {
|
||||
assert!(!name.ends_with(".exe"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recognizes_windows_reserved_device_names() {
|
||||
for filename in ["CON", "con.txt", "PRN.", "aux.mp4", "NUL", "COM1.zip", "lpt9"] {
|
||||
assert!(is_windows_reserved_filename(filename), "{filename}");
|
||||
}
|
||||
for filename in ["console.txt", "com0.zip", "com10.zip", "lpt.txt", "movie.mp4"] {
|
||||
assert!(!is_windows_reserved_filename(filename), "{filename}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -252,6 +252,7 @@ fn default_settings() -> PersistedSettings {
|
||||
base_download_folder: "~/Downloads".to_string(),
|
||||
category_subfolders: default_category_subfolders(),
|
||||
category_directory_overrides: HashMap::new(),
|
||||
approved_download_roots: Vec::new(),
|
||||
max_concurrent_downloads: 3,
|
||||
global_speed_limit: String::new(),
|
||||
is_sidebar_visible: true,
|
||||
|
||||
+10
-21
@@ -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": {
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"app": {
|
||||
"windows": [
|
||||
{
|
||||
"title": "Firelink",
|
||||
"width": 1280,
|
||||
"height": 760,
|
||||
"minWidth": 960,
|
||||
"minHeight": 640,
|
||||
"transparent": false,
|
||||
"decorations": true
|
||||
}
|
||||
]
|
||||
},
|
||||
"bundle": {
|
||||
"targets": ["appimage"]
|
||||
}
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"app": {
|
||||
"windows": [
|
||||
{
|
||||
"title": "Firelink",
|
||||
"width": 1280,
|
||||
"height": 760,
|
||||
"minWidth": 960,
|
||||
"minHeight": 640,
|
||||
"transparent": true,
|
||||
"windowEffects": {
|
||||
"effects": ["mica"],
|
||||
"state": "active"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"bundle": {
|
||||
"targets": ["nsis"]
|
||||
}
|
||||
}
|
||||
+85
-6
@@ -17,6 +17,7 @@ import SpeedLimiterView from "./components/SpeedLimiterView";
|
||||
import LogsView from "./components/LogsView";
|
||||
import { useToast } from "./contexts/ToastContext";
|
||||
import { openUrl } from '@tauri-apps/plugin-opener';
|
||||
import { usePlatformInfo } from './utils/platform';
|
||||
|
||||
let automaticUpdateCheckStarted = false;
|
||||
const processingScheduleKeys = new Set<string>();
|
||||
@@ -40,6 +41,8 @@ const getScheduledQueueIds = () => {
|
||||
};
|
||||
|
||||
function App() {
|
||||
const platform = usePlatformInfo();
|
||||
const platformOsRef = useRef(platform.os);
|
||||
const [filter, setFilter] = useState<SidebarFilter>('all');
|
||||
const [coreReady, setCoreReady] = useState(false);
|
||||
|
||||
@@ -110,6 +113,10 @@ function App() {
|
||||
|
||||
const { addToast } = useToast();
|
||||
|
||||
useEffect(() => {
|
||||
platformOsRef.current = platform.os;
|
||||
}, [platform.os]);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
const initialize = async () => {
|
||||
@@ -177,6 +184,11 @@ function App() {
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to hydrate extension pairing token:', error);
|
||||
addToast({
|
||||
message: `Secure credential persistence is unavailable. Browser pairing works for this session only: ${String(error)}`,
|
||||
variant: 'error',
|
||||
isActionable: true
|
||||
});
|
||||
}
|
||||
};
|
||||
void initialize();
|
||||
@@ -238,8 +250,10 @@ function App() {
|
||||
}, [maxConcurrentDownloads]);
|
||||
|
||||
useEffect(() => {
|
||||
if (platform.os === 'macos') {
|
||||
invoke('update_dock_badge', { count: showDockBadge ? activeDownloadCount : 0 }).catch(() => {});
|
||||
}, [showDockBadge, activeDownloadCount]);
|
||||
}
|
||||
}, [platform.os, showDockBadge, activeDownloadCount]);
|
||||
|
||||
useEffect(() => {
|
||||
invoke('set_prevent_sleep', {
|
||||
@@ -360,7 +374,40 @@ function App() {
|
||||
isActionable: true
|
||||
});
|
||||
} else if (settings.scheduler.postQueueAction !== 'none') {
|
||||
invoke('perform_system_action', { action: settings.scheduler.postQueueAction }).catch(error => {
|
||||
const action = settings.scheduler.postQueueAction;
|
||||
let cancelled = false;
|
||||
addToast({
|
||||
variant: 'warning',
|
||||
isActionable: true,
|
||||
message: (
|
||||
<div className="flex items-center gap-3">
|
||||
<span>{action === 'shutdown' ? 'Shut down' : action === 'restart' ? 'Restart' : 'Sleep'} in 10 seconds.</span>
|
||||
<button
|
||||
type="button"
|
||||
className="app-button px-2 py-1"
|
||||
onClick={() => {
|
||||
cancelled = true;
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
});
|
||||
window.setTimeout(() => {
|
||||
if (cancelled) return;
|
||||
const activeTransfers = useDownloadStore.getState().downloads.some(download =>
|
||||
isActiveDownloadStatus(download.status)
|
||||
);
|
||||
if (activeTransfers) {
|
||||
addToast({
|
||||
message: 'System action cancelled because another download is active.',
|
||||
variant: 'warning',
|
||||
isActionable: true
|
||||
});
|
||||
return;
|
||||
}
|
||||
invoke('perform_system_action', { action }).catch(error => {
|
||||
console.error('Scheduled post action failed:', error);
|
||||
addToast({
|
||||
message: `Scheduled system action failed: ${String(error)}`,
|
||||
@@ -368,15 +415,31 @@ function App() {
|
||||
isActionable: true
|
||||
});
|
||||
});
|
||||
}, 10_000);
|
||||
}
|
||||
}, [addToast, downloads, schedulerRunning, schedulerActiveDownloadIds]);
|
||||
|
||||
useEffect(() => {
|
||||
const initNotifications = async () => {
|
||||
if (!useSettingsStore.getState().showNotifications) return;
|
||||
let permissionGranted = await isPermissionGranted();
|
||||
try {
|
||||
const permissionGranted = await isPermissionGranted();
|
||||
if (!permissionGranted) {
|
||||
await requestPermission();
|
||||
const permission = await requestPermission();
|
||||
if (permission !== 'granted') {
|
||||
addToast({
|
||||
message: 'System notifications are disabled for Firelink.',
|
||||
variant: 'warning',
|
||||
isActionable: true
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
addToast({
|
||||
message: `Could not configure notifications: ${String(error)}`,
|
||||
variant: 'error',
|
||||
isActionable: true
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -387,7 +450,7 @@ function App() {
|
||||
return useSettingsStore.persist.onFinishHydration(() => {
|
||||
void initNotifications();
|
||||
});
|
||||
}, [showNotifications]);
|
||||
}, [addToast, showNotifications]);
|
||||
|
||||
useEffect(() => {
|
||||
const handlePaste = (e: ClipboardEvent) => {
|
||||
@@ -450,17 +513,33 @@ function App() {
|
||||
|
||||
const item = useDownloadStore.getState().downloads.find(d => d.id === event.payload.id);
|
||||
const fileName = item?.fileName || 'A file';
|
||||
const platformOs = platformOsRef.current;
|
||||
const sound = settings.playCompletionSound
|
||||
? platformOs === 'macos'
|
||||
? 'Ping'
|
||||
: platformOs === 'linux'
|
||||
? 'message-new-instant'
|
||||
: undefined
|
||||
: undefined;
|
||||
if (event.payload.status === 'completed') {
|
||||
try {
|
||||
sendNotification({
|
||||
title: 'Download Complete',
|
||||
body: `${fileName} has finished downloading.`,
|
||||
sound: settings.playCompletionSound ? 'default' : undefined
|
||||
sound
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Completion notification failed:', error);
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
sendNotification({
|
||||
title: 'Download Failed',
|
||||
body: `${fileName} failed to download.`,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failure notification failed:', error);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -1,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, };
|
||||
|
||||
@@ -8,4 +8,4 @@ import type { SettingsTab } from "./SettingsTab";
|
||||
import type { SiteLogin } from "./SiteLogin";
|
||||
import type { Theme } from "./Theme";
|
||||
|
||||
export type PersistedSettings = { theme: Theme, baseDownloadFolder: string, categorySubfolders: { [key in string]: string }, categoryDirectoryOverrides: { [key in string]: string }, maxConcurrentDownloads: number, globalSpeedLimit: string, isSidebarVisible: boolean, activeSettingsTab: SettingsTab, scheduler: SchedulerSettings, schedulerRunning: boolean, schedulerActiveDownloadIds: Array<string>, schedulerLastStartKey: string, schedulerLastStopKey: string, lastCustomSpeedLimitKiB: number, perServerConnections: number, maxAutomaticRetries: number, showNotifications: boolean, playCompletionSound: boolean, appFontSize: AppFontSize, listRowDensity: ListRowDensity, showDockBadge: boolean, showMenuBarIcon: boolean, proxyMode: ProxyMode, proxyHost: string, proxyPort: number, customUserAgent: string, askWhereToSaveEachFile: boolean, preventsSleepWhileDownloading: boolean, mediaCookieSource: MediaCookieSource, siteLogins: Array<SiteLogin>, autoCheckUpdates: boolean, };
|
||||
export type PersistedSettings = { theme: Theme, baseDownloadFolder: string, categorySubfolders: { [key in string]: string }, categoryDirectoryOverrides: { [key in string]: string }, approvedDownloadRoots: Array<string>, maxConcurrentDownloads: number, globalSpeedLimit: string, isSidebarVisible: boolean, activeSettingsTab: SettingsTab, scheduler: SchedulerSettings, schedulerRunning: boolean, schedulerActiveDownloadIds: Array<string>, schedulerLastStartKey: string, schedulerLastStopKey: string, lastCustomSpeedLimitKiB: number, perServerConnections: number, maxAutomaticRetries: number, showNotifications: boolean, playCompletionSound: boolean, appFontSize: AppFontSize, listRowDensity: ListRowDensity, showDockBadge: boolean, showMenuBarIcon: boolean, proxyMode: ProxyMode, proxyHost: string, proxyPort: number, customUserAgent: string, askWhereToSaveEachFile: boolean, preventsSleepWhileDownloading: boolean, mediaCookieSource: MediaCookieSource, siteLogins: Array<SiteLogin>, autoCheckUpdates: boolean, };
|
||||
|
||||
@@ -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, };
|
||||
@@ -13,8 +13,10 @@ import { canonicalizeDownloadFileName, categoryForFileName } from '../utils/down
|
||||
import { fetchMediaMetadataDeduped } from '../utils/mediaMetadata';
|
||||
import {
|
||||
resolveCategoryDestination,
|
||||
resolveDownloadFilePath
|
||||
resolveDownloadFilePath,
|
||||
downloadLocationEquals
|
||||
} from '../utils/downloadLocations';
|
||||
import { getPlatformInfo } from '../utils/platform';
|
||||
import { isTransferLocked } from '../utils/downloadActions';
|
||||
import { useToast } from '../contexts/ToastContext';
|
||||
import {
|
||||
@@ -321,7 +323,8 @@ export const AddDownloadsModal = () => {
|
||||
defaultPath: saveLocation.startsWith('~') ? undefined : saveLocation
|
||||
});
|
||||
if (selected && typeof selected === 'string') {
|
||||
setSaveLocation(selected);
|
||||
const approvedPath = await useSettingsStore.getState().approveDownloadRoot(selected);
|
||||
setSaveLocation(approvedPath);
|
||||
setIsSaveLocationManual(true);
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -347,6 +350,7 @@ export const AddDownloadsModal = () => {
|
||||
let useSharedDestination = isSaveLocationManual;
|
||||
const destinationOverrides: Record<number, string> = {};
|
||||
const settings = useSettingsStore.getState();
|
||||
const platform = await getPlatformInfo().catch(() => ({ os: 'unknown' }));
|
||||
if (settings.askWhereToSaveEachFile && parsedItems.length > 0) {
|
||||
for (const [index, item] of parsedItems.entries()) {
|
||||
try {
|
||||
@@ -360,7 +364,8 @@ export const AddDownloadsModal = () => {
|
||||
defaultPath: suggestedLocation.startsWith('~') ? undefined : suggestedLocation
|
||||
});
|
||||
if (selected && typeof selected === 'string') {
|
||||
destinationOverrides[index] = selected;
|
||||
const approvedPath = await useSettingsStore.getState().approveDownloadRoot(selected);
|
||||
destinationOverrides[index] = approvedPath;
|
||||
} else {
|
||||
setIsSubmitting(false);
|
||||
return;
|
||||
@@ -398,8 +403,13 @@ export const AddDownloadsModal = () => {
|
||||
const destination = download.destination ||
|
||||
await resolveCategoryDestination(settings, download.category);
|
||||
if (
|
||||
destination === itemLocation &&
|
||||
download.fileName === finalFile &&
|
||||
downloadLocationEquals(
|
||||
destination,
|
||||
download.fileName,
|
||||
itemLocation,
|
||||
finalFile,
|
||||
platform.os
|
||||
) &&
|
||||
download.status !== 'failed'
|
||||
) {
|
||||
fileExistsInStore = true;
|
||||
@@ -456,6 +466,7 @@ export const AddDownloadsModal = () => {
|
||||
destinationOverrides: Record<number, string> = {}
|
||||
) => {
|
||||
let itemsToAdd: Array<AddDownloadDraftRow | null> = [...parsedItems];
|
||||
const platform = await getPlatformInfo().catch(() => ({ os: 'unknown' }));
|
||||
|
||||
if (resolutions) {
|
||||
for (const res of resolutions) {
|
||||
@@ -491,8 +502,13 @@ export const AddDownloadsModal = () => {
|
||||
const destination = download.destination ||
|
||||
await resolveCategoryDestination(currentSettings, download.category);
|
||||
if (
|
||||
destination === itemLocation &&
|
||||
download.fileName === newName &&
|
||||
downloadLocationEquals(
|
||||
destination,
|
||||
download.fileName,
|
||||
itemLocation,
|
||||
newName,
|
||||
platform.os
|
||||
) &&
|
||||
download.status !== 'failed'
|
||||
) {
|
||||
storeHas = true;
|
||||
@@ -534,8 +550,13 @@ export const AddDownloadsModal = () => {
|
||||
const destination = download.destination ||
|
||||
await resolveCategoryDestination(currentSettings, download.category);
|
||||
if (
|
||||
destination === itemLocation &&
|
||||
download.fileName === finalFile &&
|
||||
downloadLocationEquals(
|
||||
destination,
|
||||
download.fileName,
|
||||
itemLocation,
|
||||
finalFile,
|
||||
platform.os
|
||||
) &&
|
||||
download.status !== 'failed'
|
||||
) {
|
||||
existingItem = download;
|
||||
|
||||
@@ -211,8 +211,8 @@ export default function LogsView() {
|
||||
<div className="bg-black/10 border-y border-border-modal px-4 py-2 shrink-0 flex items-center gap-2 text-text-muted text-[10px] select-none">
|
||||
<Info size={12} className="text-text-muted opacity-80 shrink-0" />
|
||||
<span className="opacity-90 leading-tight">
|
||||
<strong className="font-medium text-text-primary mr-1">Privacy Note:</strong>
|
||||
Telemetry securely captures basic hardware capabilities (OS, CPU, RAM) exclusively for troubleshooting. No unique identifiers or sensitive paths are collected. Logs remain entirely offline on your device until manually exported.
|
||||
<strong className="font-medium text-text-primary mr-1">Local diagnostics:</strong>
|
||||
Firelink keeps bounded rotating logs on this device for troubleshooting. Common secrets, URL queries, and home-directory paths are redacted during display and export. Nothing is uploaded automatically.
|
||||
</span>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import { PostQueueAction, SchedulerSettings, useSettingsStore } from '../store/u
|
||||
import { MAIN_QUEUE_ID, useDownloadStore } from '../store/useDownloadStore';
|
||||
import { WindowDragRegion } from './WindowDragRegion';
|
||||
import { useToast } from '../contexts/ToastContext';
|
||||
import { usePlatformInfo } from '../utils/platform';
|
||||
|
||||
const days = [
|
||||
{ value: 0, label: 'Su' },
|
||||
@@ -65,7 +66,8 @@ export default function SchedulerView() {
|
||||
const { addToast } = useToast();
|
||||
const [permissionMessage, setPermissionMessage] = useState('');
|
||||
const [automationPermissionGranted, setAutomationPermissionGranted] = useState<boolean | null>(null);
|
||||
const isMac = navigator.userAgent.includes('Mac');
|
||||
const platform = usePlatformInfo();
|
||||
const isMac = platform.os === 'macos';
|
||||
|
||||
useEffect(() => {
|
||||
setDraft(savedSettings);
|
||||
@@ -365,7 +367,7 @@ export default function SchedulerView() {
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{isMac && (
|
||||
{isMac ? (
|
||||
<section className="app-card mt-4 max-w-[760px] p-5">
|
||||
<div className="mb-2 flex items-center gap-2 font-semibold text-text-primary">
|
||||
<LockKeyhole size={17} className="text-accent" /> System Permissions
|
||||
@@ -393,6 +395,16 @@ export default function SchedulerView() {
|
||||
</div>
|
||||
{permissionMessage && <p className="mt-3 text-[11px] text-text-muted">{permissionMessage}</p>}
|
||||
</section>
|
||||
) : (
|
||||
<section className="app-card mt-4 max-w-[760px] p-5">
|
||||
<div className="mb-2 flex items-center gap-2 font-semibold text-text-primary">
|
||||
<LockKeyhole size={17} className="text-accent" /> System Actions
|
||||
</div>
|
||||
<p className="text-[12px] text-text-muted">
|
||||
Sleep, restart, and shut down use {platform.os === 'windows' ? 'Windows system privileges' : 'your Linux desktop and system policy'}.
|
||||
Firelink reports any rejected action when it runs; no permanent permission is claimed in advance.
|
||||
</p>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
DOWNLOAD_CATEGORIES,
|
||||
normalizeCategorySubfolder
|
||||
} from '../utils/downloadLocations';
|
||||
import { usePlatformInfo } from '../utils/platform';
|
||||
|
||||
const settingsTabs: { type: SettingsTab; label: string; icon: typeof Download }[] = [
|
||||
{ type: 'downloads', label: 'Downloads', icon: Download },
|
||||
@@ -176,6 +177,7 @@ const CategoryFolderInput = ({
|
||||
export default function SettingsView() {
|
||||
const settings = useSettingsStore();
|
||||
const activeTab = settings.activeSettingsTab;
|
||||
const platform = usePlatformInfo();
|
||||
|
||||
// Local state for engine status
|
||||
const [engineStatus, setEngineStatus] = useState<EngineStatusItem[] | null>(null);
|
||||
@@ -362,7 +364,8 @@ runEngineChecks(false);
|
||||
defaultPath: currentPath.startsWith('~') ? undefined : currentPath
|
||||
});
|
||||
if (selected && typeof selected === 'string') {
|
||||
settings.setCategoryDirectoryOverride(category, selected);
|
||||
const approvedPath = await settings.approveDownloadRoot(selected);
|
||||
settings.setCategoryDirectoryOverride(category, approvedPath);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(`Failed to select folder for ${category}:`, e);
|
||||
@@ -379,7 +382,8 @@ runEngineChecks(false);
|
||||
: settings.baseDownloadFolder
|
||||
});
|
||||
if (base && typeof base === 'string') {
|
||||
settings.setBaseDownloadFolder(base);
|
||||
const approvedBase = await settings.approveDownloadRoot(base);
|
||||
settings.setBaseDownloadFolder(approvedBase);
|
||||
try {
|
||||
const safeSubfolders = Object.fromEntries(
|
||||
DOWNLOAD_CATEGORIES.map(category => [
|
||||
@@ -391,7 +395,7 @@ runEngineChecks(false);
|
||||
])
|
||||
);
|
||||
await invoke('create_category_directories', {
|
||||
baseFolder: base,
|
||||
baseFolder: approvedBase,
|
||||
subfolders: safeSubfolders
|
||||
});
|
||||
} catch (e) {
|
||||
@@ -647,12 +651,15 @@ runEngineChecks(false);
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2 className="settings-section-title">macOS Integration</h2>
|
||||
<h2 className="settings-section-title">
|
||||
{platform.os === 'macos' ? 'macOS Integration' : 'Desktop Integration'}
|
||||
</h2>
|
||||
<div className="mac-settings-group">
|
||||
{platform.os === 'macos' && (
|
||||
<label className="mac-settings-row cursor-default">
|
||||
<div className="settings-row-label">
|
||||
<span>Show badge on Dock icon</span>
|
||||
<small>Displays the number of active downloads on the Firelink Dock icon.</small>
|
||||
<small>Displays active download count on Firelink Dock icon.</small>
|
||||
</div>
|
||||
<input
|
||||
type="checkbox"
|
||||
@@ -661,10 +668,11 @@ runEngineChecks(false);
|
||||
className="mac-switch"
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
<label className="mac-settings-row cursor-default">
|
||||
<div className="settings-row-label">
|
||||
<span>Show menu bar icon</span>
|
||||
<small>Provides quick access to downloads and queues from the macOS menu bar.</small>
|
||||
<span>{platform.os === 'macos' ? 'Show menu bar icon' : 'Show system tray icon'}</span>
|
||||
<small>Provides quick access to downloads and queues.</small>
|
||||
</div>
|
||||
<input
|
||||
type="checkbox"
|
||||
@@ -732,7 +740,7 @@ runEngineChecks(false);
|
||||
)}
|
||||
<p className="settings-group-footer">
|
||||
{settings.proxyMode === 'none' && 'Downloads ignore configured proxies.'}
|
||||
{settings.proxyMode === 'system' && 'Downloads use the matching macOS system proxy when one is configured.'}
|
||||
{settings.proxyMode === 'system' && `Downloads use the detected ${platform.os === 'macos' ? 'macOS' : platform.os === 'windows' ? 'Windows' : 'desktop'} system proxy when available.`}
|
||||
{settings.proxyMode === 'custom' && (settings.proxyHost
|
||||
? `Downloads use http://${settings.proxyHost}:${settings.proxyPort}.`
|
||||
: 'Enter a proxy host and port to enable the custom proxy.')}
|
||||
|
||||
@@ -13,6 +13,7 @@ import type { ReleaseCheckOutcome } from './bindings/ReleaseCheckOutcome';
|
||||
import type { PairingTokenHydration } from './bindings/PairingTokenHydration';
|
||||
import type { EnqueueItem } from './bindings/EnqueueItem';
|
||||
import type { EnqueueAccepted } from './bindings/EnqueueAccepted';
|
||||
import type { PlatformInfo } from './bindings/PlatformInfo';
|
||||
|
||||
type CommandMap = {
|
||||
fetch_metadata: {
|
||||
@@ -34,6 +35,8 @@ type CommandMap = {
|
||||
remove_download: { args: { id: string; deleteAssets: boolean }; result: void };
|
||||
detach_download_for_reconfigure: { args: { id: string }; result: void };
|
||||
update_dock_badge: { args: { count: number }; result: void };
|
||||
get_platform_info: { args: undefined; result: PlatformInfo };
|
||||
approve_download_root: { args: { path: string }; result: string };
|
||||
set_prevent_sleep: { args: { prevent: boolean }; result: void };
|
||||
perform_system_action: { args: { action: PostQueueAction }; result: void };
|
||||
ack_schedule_trigger: { args: { action: 'start' | 'stop'; key: string }; result: void };
|
||||
|
||||
@@ -74,6 +74,7 @@ export interface SettingsState {
|
||||
baseDownloadFolder: string;
|
||||
categorySubfolders: Record<string, string>;
|
||||
categoryDirectoryOverrides: Record<string, string>;
|
||||
approvedDownloadRoots: string[];
|
||||
maxConcurrentDownloads: number;
|
||||
globalSpeedLimit: string;
|
||||
isSidebarVisible: boolean;
|
||||
@@ -108,6 +109,7 @@ export interface SettingsState {
|
||||
|
||||
setTheme: (theme: Theme) => void;
|
||||
setBaseDownloadFolder: (path: string) => void;
|
||||
approveDownloadRoot: (path: string) => Promise<string>;
|
||||
setMaxConcurrentDownloads: (count: number) => void;
|
||||
setGlobalSpeedLimit: (limit: string) => void;
|
||||
setActiveView: (view: ActiveView) => void;
|
||||
@@ -178,6 +180,7 @@ export const useSettingsStore = create<SettingsState>()(
|
||||
baseDownloadFolder: '~/Downloads',
|
||||
categorySubfolders: { ...DEFAULT_CATEGORY_SUBFOLDERS },
|
||||
categoryDirectoryOverrides: {},
|
||||
approvedDownloadRoots: [],
|
||||
maxConcurrentDownloads: 3,
|
||||
globalSpeedLimit: '',
|
||||
activeView: 'downloads',
|
||||
@@ -224,6 +227,15 @@ export const useSettingsStore = create<SettingsState>()(
|
||||
info('Settings updated: baseDownloadFolder');
|
||||
set({ baseDownloadFolder: path });
|
||||
},
|
||||
approveDownloadRoot: async (path) => {
|
||||
const approvedPath = await invoke('approve_download_root', { path });
|
||||
set(state => ({
|
||||
approvedDownloadRoots: state.approvedDownloadRoots.includes(approvedPath)
|
||||
? state.approvedDownloadRoots
|
||||
: [...state.approvedDownloadRoots, approvedPath]
|
||||
}));
|
||||
return approvedPath;
|
||||
},
|
||||
setMaxConcurrentDownloads: (max) => {
|
||||
info('Settings updated: maxConcurrentDownloads');
|
||||
set({ maxConcurrentDownloads: max });
|
||||
@@ -299,6 +311,9 @@ export const useSettingsStore = create<SettingsState>()(
|
||||
hydratePairingToken: async () => {
|
||||
const result = await invoke('hydrate_extension_pairing_token');
|
||||
set({ extensionPairingToken: result.token });
|
||||
if (!result.persistent && result.error) {
|
||||
throw new Error(`Session-only browser pairing token: ${result.error}`);
|
||||
}
|
||||
return result.tokenChanged;
|
||||
},
|
||||
setAutoCheckUpdates: (autoCheckUpdates) => set({ autoCheckUpdates }),
|
||||
@@ -330,7 +345,10 @@ export const useSettingsStore = create<SettingsState>()(
|
||||
: [DEFAULT_SCHEDULER_QUEUE_ID]
|
||||
}
|
||||
: persisted.scheduler,
|
||||
siteLogins: Array.isArray(persisted.siteLogins) ? persisted.siteLogins : []
|
||||
siteLogins: Array.isArray(persisted.siteLogins) ? persisted.siteLogins : [],
|
||||
approvedDownloadRoots: Array.isArray(persisted.approvedDownloadRoots)
|
||||
? persisted.approvedDownloadRoots
|
||||
: []
|
||||
} as SettingsState;
|
||||
},
|
||||
partialize: (state): PersistedSettings => ({
|
||||
@@ -338,6 +356,7 @@ export const useSettingsStore = create<SettingsState>()(
|
||||
baseDownloadFolder: state.baseDownloadFolder,
|
||||
categorySubfolders: state.categorySubfolders,
|
||||
categoryDirectoryOverrides: state.categoryDirectoryOverrides,
|
||||
approvedDownloadRoots: state.approvedDownloadRoots,
|
||||
maxConcurrentDownloads: state.maxConcurrentDownloads,
|
||||
globalSpeedLimit: state.globalSpeedLimit,
|
||||
isSidebarVisible: state.isSidebarVisible,
|
||||
@@ -376,6 +395,9 @@ export const useSettingsStore = create<SettingsState>()(
|
||||
...currentState,
|
||||
...persisted,
|
||||
...locations,
|
||||
approvedDownloadRoots: Array.isArray(persisted.approvedDownloadRoots)
|
||||
? persisted.approvedDownloadRoots
|
||||
: currentState.approvedDownloadRoots,
|
||||
scheduler: {
|
||||
...currentState.scheduler,
|
||||
...persisted.scheduler,
|
||||
|
||||
@@ -9,6 +9,7 @@ vi.mock('@tauri-apps/api/path', () => ({
|
||||
}));
|
||||
|
||||
import {
|
||||
downloadLocationEquals,
|
||||
DEFAULT_CATEGORY_SUBFOLDERS,
|
||||
normalizeCategorySubfolder,
|
||||
normalizeDownloadLocationSettings,
|
||||
@@ -16,6 +17,11 @@ import {
|
||||
} from './downloadLocations';
|
||||
|
||||
describe('download locations', () => {
|
||||
it('compares Windows and macOS locations case-insensitively', () => {
|
||||
expect(downloadLocationEquals('D:\\Downloads', 'Movie.MP4', 'd:/downloads', 'movie.mp4', 'windows')).toBe(true);
|
||||
expect(downloadLocationEquals('/Users/Test', 'Movie.MP4', '/users/test', 'movie.mp4', 'macos')).toBe(true);
|
||||
expect(downloadLocationEquals('/home/Test', 'Movie.MP4', '/home/test', 'movie.mp4', 'linux')).toBe(false);
|
||||
});
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
@@ -143,3 +143,20 @@ export const resolveDownloadFilePath = async (
|
||||
const expandedDest = await expandTilde(destination);
|
||||
return join(expandedDest, fileName);
|
||||
};
|
||||
|
||||
export const downloadLocationEquals = (
|
||||
leftDirectory: string,
|
||||
leftFileName: string,
|
||||
rightDirectory: string,
|
||||
rightFileName: string,
|
||||
os: string
|
||||
): boolean => {
|
||||
const normalize = (value: string) => {
|
||||
const normalized = value.replace(/\\/g, '/').replace(/\/+$/, '');
|
||||
return os === 'windows' || os === 'macos'
|
||||
? normalized.toLocaleLowerCase()
|
||||
: normalized;
|
||||
};
|
||||
return normalize(`${leftDirectory}/${leftFileName}`)
|
||||
=== normalize(`${rightDirectory}/${rightFileName}`);
|
||||
};
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@ import { info as tauriInfo, warn as tauriWarn, error as tauriError, debug as tau
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
|
||||
// Default to true to match backend default
|
||||
let isPaused = true;
|
||||
let isPaused = false;
|
||||
|
||||
let initPromise: Promise<void> | null = null;
|
||||
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import type { PlatformInfo } from '../bindings/PlatformInfo';
|
||||
import { invokeCommand as invoke } from '../ipc';
|
||||
|
||||
const fallback: PlatformInfo = {
|
||||
os: 'unknown',
|
||||
arch: 'unknown',
|
||||
targetTriple: 'unknown'
|
||||
};
|
||||
|
||||
let cached: PlatformInfo | null = null;
|
||||
let pending: Promise<PlatformInfo> | null = null;
|
||||
|
||||
export const getPlatformInfo = (): Promise<PlatformInfo> => {
|
||||
if (cached) return Promise.resolve(cached);
|
||||
if (!pending) {
|
||||
pending = invoke('get_platform_info')
|
||||
.then(info => {
|
||||
cached = info;
|
||||
return info;
|
||||
})
|
||||
.finally(() => {
|
||||
pending = null;
|
||||
});
|
||||
}
|
||||
return pending;
|
||||
};
|
||||
|
||||
export const usePlatformInfo = () => {
|
||||
const [platform, setPlatform] = useState<PlatformInfo>(cached ?? fallback);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
void getPlatformInfo()
|
||||
.then(info => {
|
||||
if (active) setPlatform(info);
|
||||
})
|
||||
.catch(() => undefined);
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
return platform;
|
||||
};
|
||||
Reference in New Issue
Block a user