mirror of
https://github.com/nimbold/Firelink.git
synced 2026-07-26 12:08:27 +00:00
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.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
<a href="https://github.com/nimbold/Firelink-Extension#manual-chromium-installation"><img src="https://img.shields.io/badge/Manual%20install-Chromium-4285F4?style=for-the-badge&logo=googlechrome&logoColor=white" alt="Read manual Chromium install instructions" /></a>
|
||||
</p>
|
||||
|
||||
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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 <Firelink.app> and/or --dmg <Firelink.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.');
|
||||
@@ -22,6 +22,10 @@
|
||||
]
|
||||
},
|
||||
"bundle": {
|
||||
"targets": ["app", "dmg"]
|
||||
"targets": ["app", "dmg"],
|
||||
"macOS": {
|
||||
"signingIdentity": "-",
|
||||
"hardenedRuntime": true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user