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 @@
[](#platforms)
[](#platforms)
[](#platforms)
- [](https://tauri.app/)
- [](https://www.rust-lang.org/)
- [](https://react.dev/)
[](LICENSE)
[](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
-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
+ }
}
}