From a2a3b08d4e2cb4a2bde4077d92cf90b9794cecd4 Mon Sep 17 00:00:00 2001 From: NimBold Date: Mon, 6 Jul 2026 16:34:20 +0330 Subject: [PATCH] chore(release): ad-hoc sign macOS builds Configure Tauri to ad-hoc sign macOS app and DMG builds without Apple Developer credentials. Add a macOS signing verifier that checks the built app, the DMG payload, Mach-O signatures, quarantine xattrs, and expected Gatekeeper behavior. Tighten README copy while keeping the platform install pills and accurate non-notarized macOS guidance. --- .github/workflows/release.yml | 3 + README.md | 58 +++--- package.json | 1 + scripts/verify-macos-signing.js | 306 ++++++++++++++++++++++++++++++++ src-tauri/tauri.macos.conf.json | 6 +- 5 files changed, 342 insertions(+), 32 deletions(-) create mode 100644 scripts/verify-macos-signing.js diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4d476b1..7b3ccab 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -91,6 +91,9 @@ jobs: if: runner.os == 'macOS' run: | APP="src-tauri/target/${{ matrix.target }}/release/bundle/macos/Firelink.app" + DMG="$(find src-tauri/target/${{ matrix.target }}/release/bundle/dmg -name '*.dmg' -print -quit)" + test -n "$DMG" + npm run verify:macos-signing -- --app "$APP" --dmg "$DMG" node scripts/verify-binaries.js --search-root "$APP" --target ${{ matrix.target }} node scripts/smoke-packaged-app.js --executable "$APP/Contents/MacOS/firelink" - name: Verify Windows installer payload and launch diff --git a/README.md b/README.md index 404df0c..73e67a0 100644 --- a/README.md +++ b/README.md @@ -9,9 +9,6 @@ [![macOS](https://img.shields.io/badge/macOS-111111?style=flat-square&logo=apple&logoColor=white)](#platforms) [![Windows](.github/badges/windows.svg)](#platforms) [![Linux](https://img.shields.io/badge/Linux-FCC624?style=flat-square&logo=linux&logoColor=black)](#platforms) - [![Tauri](https://img.shields.io/badge/Tauri-2-24C8DB?style=flat-square&logo=tauri&logoColor=white)](https://tauri.app/) - [![Rust](https://img.shields.io/badge/Rust-backend-000000?style=flat-square&logo=rust)](https://www.rust-lang.org/) - [![React](https://img.shields.io/badge/React-TypeScript-61DAFB?style=flat-square&logo=react&logoColor=111111)](https://react.dev/) [![License](https://img.shields.io/github/license/nimbold/Firelink?style=flat-square)](LICENSE) [![CI](https://img.shields.io/github/actions/workflow/status/nimbold/Firelink/ci.yml?branch=main&style=flat-square&label=CI)](https://github.com/nimbold/Firelink/actions/workflows/ci.yml) @@ -36,33 +33,33 @@ ## Why Firelink -Firelink is built for people who want a real desktop download manager again: fast segmented transfers, browser capture, media extraction, scheduling, recovery, and clear control over where files land. Version 1.0.0 completes the move from the earlier macOS-only Swift app to a modern Rust/Tauri application with a React and TypeScript interface. +Firelink is a desktop download manager for fast transfers, browser capture, media extraction, scheduling, and clear file placement. -The app keeps the heavy work native. Downloads are coordinated by a Rust backend, accelerated with aria2, enriched with yt-dlp and FFmpeg for media workflows, and persisted locally with SQLite so queues survive restarts and app updates. +It is now a cross-platform Rust/Tauri app with a React and TypeScript interface. A native backend coordinates downloads with aria2, yt-dlp, FFmpeg, Deno, and SQLite. ## Features -- **Fast segmented downloads** powered by aria2 with configurable connections, retries, and speed limits. -- **Media extraction** with yt-dlp, FFmpeg, and Deno for video/audio links and richer format selection. -- **A real Add window** for manual, extension-captured, and media downloads, including metadata, duplicate handling, and save-location choices before downloads start. -- **Persistent queue management** with safe concurrency limits, pause/resume, retry, redownload, sorting, multi-select, and bulk controls. -- **Download scheduling** with start/stop windows, speed-limiter tools, and optional post-queue actions. -- **Smart organization** through categories, default folders, per-download overrides, and open/reveal/trash actions. -- **Private browser handoff** through authenticated local pairing with replay protection and desktop-server proof checks. -- **Native desktop integration** including tray controls, notifications, completion sounds, sleep prevention, and OS keychain support where available. -- **Diagnostics built in** with engine health checks, structured logs, and packaged-engine verification. +- **Segmented downloads** with aria2, retries, speed limits, and connection controls. +- **Media downloads** with yt-dlp, FFmpeg, and Deno. +- **Add window** for metadata, duplicates, location choices, and captured links. +- **Persistent queues** with pause, resume, retry, redownload, sorting, multi-select, and bulk actions. +- **Scheduling** with start/stop windows, speed rules, and post-queue actions. +- **File organization** with categories, default folders, per-download overrides, and reveal/trash actions. +- **Browser handoff** through local pairing, signed requests, replay protection, and server checks. +- **Desktop integration** with tray controls, notifications, sounds, sleep prevention, and secure credential storage. +- **Diagnostics** with engine health checks, structured logs, and package verification. ## Installation -Download the latest desktop build from [GitHub Releases](https://github.com/nimbold/Firelink/releases). +Download desktop builds from [GitHub Releases](https://github.com/nimbold/Firelink/releases). | Platform | Package | Notes | | --- | --- | --- | -| **macOS Apple silicon** | `.dmg` | Unsigned and not notarized. Open through Finder or approve once in **System Settings -> Privacy & Security**. | +| **macOS Apple silicon** | `.dmg` | Not notarized. If macOS blocks the first launch, approve Firelink in **System Settings -> Privacy & Security**. | | **Windows x64** | NSIS `.exe` installer | Unsigned. Windows SmartScreen may warn until code signing is added. | | **Linux x64** | `.AppImage` | Make executable before launching if your desktop environment does not do that automatically. | -Production bundles include the required media engines for the target platform. Users do not need to install aria2, yt-dlp, FFmpeg, Deno, Python, Homebrew, or a package manager for everyday app usage. +Bundles include the required engines. Users do not need aria2, yt-dlp, FFmpeg, Deno, Python, Homebrew, or another package manager. ## Browser Extension @@ -72,28 +69,27 @@ Production bundles include the required media engines for the target platform. U Read manual Chromium install instructions

-Firelink Companion connects your browser to the desktop app so links and browser downloads can open in Firelink instead of disappearing into the browser's default download shelf. -The Chrome extension is currently distributed as a manual Chromium package while Chrome Web Store publication is pending. Follow the [manual Chrome/Chromium installation instructions](https://github.com/nimbold/Firelink-Extension#manual-chromium-installation) and download `firelink-chromium.zip` from the [Firelink-Extension releases](https://github.com/nimbold/Firelink-Extension/releases). +Firelink Companion sends browser links and downloads to the desktop app. What it adds: -- **Automatic capture** for ordinary browser downloads, while still routing every captured link through Firelink's Add window. -- **Firefox and Chromium support** for Firefox, Chrome, Edge, Brave, Vivaldi, Opera, and other compatible desktop Chromium browsers. -- **Context-menu actions** for "Download with Firelink" and selected links. -- **Signed local requests** using the pairing token from **Settings -> Integrations**. -- **Server identity checks** so the extension only trusts the real local Firelink app. -- **Offline-safe behavior** that resumes browser downloads when Firelink is closed or rejects a handoff. -- **Protocol-aware compatibility** so older desktop builds are rejected before automatic capture can cancel a browser download. +- Automatic capture for regular browser downloads. +- Context-menu actions for links and selected text. +- Firefox and Chromium support. +- Signed local requests using the token from **Settings -> Integrations**. +- Fallback to the browser download when Firelink is closed or rejects a handoff. -Install the extension, open Firelink, then pair it from **Settings -> Integrations**. Firefox users can install from Mozilla Add-ons. Chrome and Chromium users can use the [manual load-unpacked flow](https://github.com/nimbold/Firelink-Extension#manual-chromium-installation). The extension is maintained in the [Firelink-Extension](https://github.com/nimbold/Firelink-Extension) repository and is also vendored here as the `Extensions/Firefox` submodule. +Install the extension, open Firelink, then pair it from **Settings -> Integrations**. Firefox users can install from Mozilla Add-ons. Chromium users can use the [manual load-unpacked flow](https://github.com/nimbold/Firelink-Extension#manual-chromium-installation) with `firelink-chromium.zip` from the [extension releases](https://github.com/nimbold/Firelink-Extension/releases). + +The extension lives in [Firelink-Extension](https://github.com/nimbold/Firelink-Extension). This repo also vendors it as the `Extensions/Firefox` submodule. ## Platforms | Target | Status | | --- | --- | -| **macOS arm64** | Supported. Automated native build, engine validation, packaged launch smoke test, and unsigned DMG packaging. | -| **Windows x64** | Supported. Native GitHub Actions build, engine validation, silent installer smoke test, and NSIS packaging. | -| **Linux x64** | Supported. Native GitHub Actions build, engine validation, AppImage repackaging, and xvfb launch smoke test. | +| **macOS arm64** | Supported. Native build, engine checks, launch smoke test, ad-hoc-signed DMG workflow. | +| **Windows x64** | Supported. Native build, engine checks, silent installer smoke test, NSIS installer. | +| **Linux x64** | Supported. Native build, engine checks, xvfb launch smoke test, AppImage. | ## Development @@ -134,7 +130,7 @@ Create a production bundle: npm run tauri build ``` -macOS development uses locked payloads in `src-tauri/binaries`. Windows and Linux payloads are provisioned from checksum-pinned archives: +macOS uses locked payloads in `src-tauri/binaries`. Provision Windows and Linux payloads from checksum-pinned archives: ```sh node scripts/provision-engines.js --target x86_64-pc-windows-msvc diff --git a/package.json b/package.json index 4430b4f..424e29f 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,7 @@ "bindings": "cd src-tauri && cargo test export_bindings --lib", "build": "tsc && vite build", "check:updates": "node scripts/check-updates.js", + "verify:macos-signing": "node scripts/verify-macos-signing.js", "preview": "vite preview", "tauri": "tauri", "test": "vitest" diff --git a/scripts/verify-macos-signing.js b/scripts/verify-macos-signing.js new file mode 100644 index 0000000..34b356a --- /dev/null +++ b/scripts/verify-macos-signing.js @@ -0,0 +1,306 @@ +#!/usr/bin/env node +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { execFileSync, spawnSync } from 'node:child_process'; + +function argValue(name) { + const index = process.argv.indexOf(name); + return index >= 0 ? process.argv[index + 1] : undefined; +} + +const appArg = argValue('--app'); +const dmgArg = argValue('--dmg'); + +if (process.platform !== 'darwin') { + console.error('macOS signing verification must run on a macOS host.'); + process.exit(1); +} + +if (!appArg && !dmgArg) { + console.error('Pass --app and/or --dmg .'); + process.exit(1); +} + +let exitCode = 0; + +function fail(message) { + console.error(`[FAIL] ${message}`); + exitCode = 1; +} + +function ok(message) { + console.log(`[OK] ${message}`); +} + +function note(message) { + console.log(`[INFO] ${message}`); +} + +function run(command, args, options = {}) { + return execFileSync(command, args, { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + ...options, + }); +} + +function runResult(command, args) { + return spawnSync(command, args, { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + }); +} + +function assertAppPath(appPath, label) { + if (!fs.existsSync(appPath)) { + fail(`${label} does not exist at ${appPath}`); + return false; + } + + const stat = fs.statSync(appPath); + if (!stat.isDirectory() || path.extname(appPath) !== '.app') { + fail(`${label} is not an .app bundle: ${appPath}`); + return false; + } + + ok(`${label} exists: ${appPath}`); + return true; +} + +function codesignDetails(targetPath) { + const result = runResult('codesign', ['-dv', '--verbose=4', targetPath]); + return { + ok: result.status === 0, + output: `${result.stdout || ''}${result.stderr || ''}`, + }; +} + +function verifyCodeSignature(targetPath, label, options = {}) { + const { + deep = false, + quietOk = false, + requireAdhoc = false, + warnOnVerifyFailure = false, + } = options; + const verifyArgs = ['--verify']; + if (deep) { + verifyArgs.push('--deep'); + } + verifyArgs.push('--strict', '--verbose=2', targetPath); + + const details = codesignDetails(targetPath); + if (!details.ok) { + fail(`${label}: no readable code signature: ${details.output.trim() || 'codesign -dv failed'}`); + return 'failed'; + } + + let status = 'verified'; + try { + run('codesign', verifyArgs); + if (!quietOk) { + ok(`${label}: codesign verification passed`); + } + } catch (error) { + const detail = error.stderr?.trim() || error.message; + if (!warnOnVerifyFailure) { + fail(`${label}: codesign verification failed: ${detail}`); + return 'failed'; + } + note(`${label}: signed, but individual verification warned: ${detail}`); + status = 'warning'; + } + + if (requireAdhoc && !details.output.includes('Signature=adhoc')) { + fail(`${label}: expected ad-hoc signature, but codesign did not report Signature=adhoc`); + return 'failed'; + } + if (requireAdhoc && !quietOk) { + ok(`${label}: ad-hoc signature present`); + } + + return status; +} + +function walkFiles(root, visitor) { + for (const entry of fs.readdirSync(root, { withFileTypes: true })) { + const entryPath = path.join(root, entry.name); + if (entry.isSymbolicLink()) { + continue; + } + if (entry.isDirectory()) { + walkFiles(entryPath, visitor); + continue; + } + if (entry.isFile()) { + visitor(entryPath); + } + } +} + +function fileBrief(filePath) { + try { + return run('file', ['--brief', filePath], { timeout: 5000 }).trim(); + } catch (error) { + fail(`file(1) failed for ${filePath}: ${error.stderr?.trim() || error.message}`); + return ''; + } +} + +function verifyMachOObjects(appPath, label) { + const machOFiles = []; + walkFiles(appPath, filePath => { + const description = fileBrief(filePath); + if (description.includes('Mach-O')) { + machOFiles.push(filePath); + } + }); + + if (machOFiles.length === 0) { + fail(`${label}: no Mach-O files found inside app bundle`); + return; + } + + let warningCount = 0; + let signedCount = 0; + let verifiedCount = 0; + for (const filePath of machOFiles) { + const relative = path.relative(appPath, filePath).split(path.sep).join('/'); + const isPrimaryExecutable = relative === 'Contents/MacOS/firelink'; + const isDirectEngine = /^Contents\/Resources\/engine-dist\/[^/]+\/(?:yt-dlp|aria2c|ffmpeg|deno)-/.test(relative); + const mayWarn = !isPrimaryExecutable && !isDirectEngine; + + const result = verifyCodeSignature(filePath, `${label} ${relative}`, { + quietOk: true, + warnOnVerifyFailure: mayWarn, + }); + if (result !== 'failed') { + signedCount += 1; + if (result === 'verified') { + verifiedCount += 1; + } else { + warningCount += 1; + } + } + } + ok(`${label}: found signatures on ${signedCount}/${machOFiles.length} Mach-O code object(s)`); + ok(`${label}: individually verified ${verifiedCount}/${machOFiles.length} Mach-O code object(s)`); + if (warningCount > 0) { + note(`${label}: ${warningCount} nested signed framework object(s) produced individual verification warnings; the outer bundle signature remains authoritative.`); + } +} + +function assessGatekeeper(appPath, label) { + const result = runResult('spctl', ['--assess', '--type', 'execute', '--verbose=4', appPath]); + const output = `${result.stdout || ''}${result.stderr || ''}`.trim(); + + if (result.status === 0) { + note(`${label}: Gatekeeper accepted this app (${output || 'no spctl detail'}).`); + return; + } + + const normalized = output.toLowerCase(); + if (normalized.includes('not signed at all') || normalized.includes('invalid signature')) { + fail(`${label}: Gatekeeper rejection indicates a broken signature: ${output}`); + return; + } + + note(`${label}: Gatekeeper rejected the app as expected for ad-hoc, unnotarized distribution: ${output || `exit ${result.status}`}`); +} + +function reportQuarantine(targetPath, label) { + const result = runResult('xattr', ['-p', 'com.apple.quarantine', targetPath]); + if (result.status === 0) { + fail(`${label}: build artifact unexpectedly has com.apple.quarantine=${result.stdout.trim()}`); + } else { + ok(`${label}: no quarantine xattr on generated artifact`); + } +} + +function verifyApp(appPath, label) { + const resolved = path.resolve(appPath); + if (!assertAppPath(resolved, label)) { + return; + } + + reportQuarantine(resolved, label); + verifyCodeSignature(resolved, label, { deep: true, requireAdhoc: true }); + verifyMachOObjects(resolved, label); + assessGatekeeper(resolved, label); +} + +function attachDmg(dmgPath) { + const mountPoint = fs.mkdtempSync(path.join(os.tmpdir(), 'firelink-dmg-')); + try { + const plist = run('hdiutil', [ + 'attach', + '-plist', + '-nobrowse', + '-readonly', + '-mountpoint', + mountPoint, + dmgPath, + ], { timeout: 60000 }); + return { mountPoint, plist }; + } catch (error) { + fs.rmSync(mountPoint, { recursive: true, force: true }); + throw error; + } +} + +function detachDmg(mountPoint) { + const result = runResult('hdiutil', ['detach', mountPoint]); + if (result.status !== 0) { + note(`Initial hdiutil detach failed, retrying with -force: ${result.stderr?.trim() || result.stdout?.trim()}`); + const forced = runResult('hdiutil', ['detach', '-force', mountPoint]); + if (forced.status !== 0) { + fail(`Failed to detach DMG mount point ${mountPoint}: ${forced.stderr?.trim() || forced.stdout?.trim()}`); + } + } + fs.rmSync(mountPoint, { recursive: true, force: true }); +} + +function verifyDmg(dmgPath) { + const resolved = path.resolve(dmgPath); + if (!fs.existsSync(resolved)) { + fail(`DMG does not exist at ${resolved}`); + return; + } + + reportQuarantine(resolved, 'DMG'); + let mount; + try { + mount = attachDmg(resolved); + ok(`DMG mounted at ${mount.mountPoint}`); + const apps = fs.readdirSync(mount.mountPoint) + .filter(name => name.endsWith('.app')) + .map(name => path.join(mount.mountPoint, name)); + if (apps.length !== 1) { + fail(`Expected exactly one .app inside DMG, found ${apps.length}`); + return; + } + verifyApp(apps[0], 'DMG app'); + } catch (error) { + fail(`DMG verification failed: ${error.stderr?.trim() || error.message}`); + } finally { + if (mount) { + detachDmg(mount.mountPoint); + } + } +} + +if (appArg) { + verifyApp(appArg, 'Built app'); +} + +if (dmgArg) { + verifyDmg(dmgArg); +} + +console.log(''); +if (exitCode !== 0) { + console.error('[FAIL] macOS signing verification failed.'); + process.exit(1); +} + +console.log('[PASS] macOS ad-hoc signing verification passed.'); diff --git a/src-tauri/tauri.macos.conf.json b/src-tauri/tauri.macos.conf.json index 70412a9..7f38f42 100644 --- a/src-tauri/tauri.macos.conf.json +++ b/src-tauri/tauri.macos.conf.json @@ -22,6 +22,10 @@ ] }, "bundle": { - "targets": ["app", "dmg"] + "targets": ["app", "dmg"], + "macOS": { + "signingIdentity": "-", + "hardenedRuntime": true + } } }