feat: downloadable macOS app (OpenResearch.app) + signed release hosting (#180)

* feat: scaffold downloadable macOS app (OpenResearch.app)

Adds a native macOS `.app` bundle whose executable IS the `orx` binary.
Launched from the bundle (double-click, no args) it enters GUI "app mode":
an AppKit NSApplication + delegate run loop on the main thread — Dock icon
and "OpenResearch" name from the bundle's Info.plist + .icns, and a
Dock-icon click that reopens the dashboard in the browser — while the
`orx up` server runs on background tokio worker threads.

App mode is entered only when the executable lives in `.app/Contents/MacOS`
AND argv is empty, so the bundled binary is still usable as a CLI.

- src/commands/app.rs: bundle detection + AppKit delegate + background server
- macos/Info.plist: bundle metadata (name, icon, identifier, version)
- scripts/generate-icon.mjs: transparent-PNG rasterizer (from favicon.svg)
- scripts/build-macos-app.sh: builds release orx, generates .icns, assembles
  OpenResearch.app into dist/
- objc2/objc2-app-kit gated under cfg(target_os = "macos") so musl Linux is
  untouched

The bundle is unsigned; code-signing + notarization is a follow-up.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat: sign, notarize, and host OpenResearch.app via GitHub Releases

Automates distribution of the macOS app as a signed, notarized DMG attached
to each GitHub Release:

- scripts/build-macos-app.sh: ORX_APP_UNIVERSAL=1 builds a universal
  (arm64 + x86_64) binary via lipo for distribution.
- scripts/package-macos-app.sh: codesigns (hardened runtime, inside-out),
  notarizes + staples the .app (so it launches offline once dragged out of
  the DMG), packages a DMG, then notarizes + staples the DMG. Env-gated:
  runs unsigned locally, fully signed in CI.
- .github/workflows/release-macos-app.yml: on the "Release" workflow
  completing (workflow_run — a GITHUB_TOKEN-created release: published event
  can't trigger workflows), a cheap ubuntu job gates on a real dispatched
  release + all signing secrets + the release existing, then a macOS job
  builds/signs/notarizes and uploads OpenResearch.dmg to that release.
- macos/DISTRIBUTION.md: the one-time Apple setup + the six repo secrets.

Inert until the signing secrets are configured, so it is safe to merge
before the Apple Developer account exists.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* chore: gate macOS signing behind CODEOWNERS + a required-reviewer environment

Hardens the release-signing pipeline for a public repo:

- .github/CODEOWNERS: marks the release workflows and macOS signing scripts
  as owned so they can't change unreviewed (with branch protection's
  "Require review from Code Owners").
- release-macos-app.yml: the cert-using job now runs in the `release-signing`
  environment, so adding required reviewers to it pauses signing for human
  approval — the Developer ID cert is never used by an unreviewed change.
- DISTRIBUTION.md: documents creating the environment + branch protection.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* chore: add @sox8502 to CODEOWNERS

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* security: environment-scoped signing secrets + SHA-pinned actions

Hardens the release-signing pipeline:

- The 6 signing secrets move from repo secrets to the `release-signing`
  environment, so only the reviewed, environment-gated macos-app job can read
  them — the cert secrets never exist in the cheap ubuntu gate.
- The gate now keys on a non-secret repo variable MACOS_SIGNING_ENABLED
  instead of reading the secrets to detect configuration.
- actions/checkout and actions/setup-node are pinned to commit SHAs so a moved
  tag can't inject code into the job that holds the Developer ID cert.
- DISTRIBUTION.md updated: create the environment (required reviewers +
  main-only deployment branches), add environment secrets, set the variable.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* docs: trim DISTRIBUTION.md to repo-specific config

Drop the generic Apple-portal walkthrough (enrolment, cert creation,
export click-by-click) — Apple documents that. Keep only what's specific
to this repo: the environment/secrets/variable, the local build+sign
commands, and the download URL.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Myles Anderson
2026-08-12 15:25:33 -07:00
committed by GitHub
co-authored by Claude Opus 4.8
parent 036f924161
commit dccc37c4e8
13 changed files with 620 additions and 0 deletions
+13
View File
@@ -0,0 +1,13 @@
# Code owners. Requires "Require review from Code Owners" on the main branch
# protection rule to be enforced. Owners must have write access.
# Swap these handles for a team (e.g. @alphaXiv/maintainers) when one exists.
# Fallback owner for everything.
* @myles332 @sox8502
# Security-sensitive: release CI and the macOS signing pipeline handle (or can
# reach) the Developer ID certificate. Changes here must be reviewed.
/.github/workflows/ @myles332 @sox8502
/scripts/build-macos-app.sh @myles332 @sox8502
/scripts/package-macos-app.sh @myles332 @sox8502
/macos/ @myles332 @sox8502
+126
View File
@@ -0,0 +1,126 @@
# Attaches the signed, notarized OpenResearch.app (as a DMG) to each GitHub
# Release. cargo-dist's "Release" workflow (release.yml) creates the Release with
# the default GITHUB_TOKEN, and GitHub's anti-recursion rule blocks a
# `release: published` event from a GITHUB_TOKEN action from triggering other
# workflows (the same constraint release-on-bump.yml documents). So we trigger on
# the Release workflow *completing* via `workflow_run`, which fires regardless of
# what authored the upstream run.
#
# release.yml also runs on pull_request (a dry-run that publishes nothing), so we
# only proceed for a successful `workflow_dispatch` run (a real release) and then
# confirm the tag's release actually exists before uploading.
#
# Signing secrets live in the `release-signing` environment (not repo secrets),
# so only the reviewed, environment-gated `macos-app` job can read them. The
# cheap gate keys on the non-secret repo variable MACOS_SIGNING_ENABLED and never
# touches the certificate. Third-party actions are pinned to commit SHAs so a
# moved tag can't inject code into the job that holds the cert. See
# macos/DISTRIBUTION.md for setup.
name: Attach macOS app to release
on:
workflow_run:
workflows: ["Release"]
types: [completed]
permissions:
contents: write # upload release assets
concurrency:
group: release-macos-app
cancel-in-progress: false
jobs:
# Cheap ubuntu gate: only a real, successful release with signing enabled
# (the non-secret MACOS_SIGNING_ENABLED variable) and a Release that actually
# exists — so a macOS runner (billed 10x) never boots otherwise, and no secret
# is ever read here. Resolves the tag from the released commit.
check:
if: ${{ github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.event == 'workflow_dispatch' && vars.MACOS_SIGNING_ENABLED == 'true' }}
runs-on: ubuntu-latest
outputs:
ok: ${{ steps.gate.outputs.ok }}
tag: ${{ steps.gate.outputs.tag }}
env:
GH_TOKEN: ${{ github.token }}
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with:
ref: ${{ github.event.workflow_run.head_sha }}
- id: gate
run: |
set -euo pipefail
version="$(grep -m1 '^version = ' Cargo.toml | sed -E 's/version = "(.*)"/\1/')"
tag="v${version}"
if ! gh release view "$tag" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then
echo "::notice::No release $tag found (dry-run dispatch?) — nothing to attach."
echo "ok=false" >> "$GITHUB_OUTPUT"; exit 0
fi
echo "ok=true" >> "$GITHUB_OUTPUT"
echo "tag=$tag" >> "$GITHUB_OUTPUT"
macos-app:
needs: check
if: ${{ needs.check.outputs.ok == 'true' }}
runs-on: macos-14 # Apple Silicon runner
# The cert-using job. Secrets come from this environment; add required
# reviewers to it (Settings → Environments) so the Developer ID certificate
# is never used without a human approving the run — even if an unwanted change
# reaches main. Restrict its deployment branches to main. See DISTRIBUTION.md.
environment: release-signing
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with:
ref: ${{ github.event.workflow_run.head_sha }}
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: "22"
- name: Add Rust targets
run: rustup target add aarch64-apple-darwin x86_64-apple-darwin
- name: Import signing certificate into a temp keychain
env:
CERT_P12_BASE64: ${{ secrets.MACOS_CERT_P12_BASE64 }}
CERT_PASSWORD: ${{ secrets.MACOS_CERT_PASSWORD }}
run: |
set -euo pipefail
KEYCHAIN="$RUNNER_TEMP/signing.keychain-db"
KEYCHAIN_PW="$(openssl rand -base64 24)"
security create-keychain -p "$KEYCHAIN_PW" "$KEYCHAIN"
security set-keychain-settings -lut 21600 "$KEYCHAIN"
security unlock-keychain -p "$KEYCHAIN_PW" "$KEYCHAIN"
echo "$CERT_P12_BASE64" | base64 --decode > "$RUNNER_TEMP/cert.p12"
security import "$RUNNER_TEMP/cert.p12" -k "$KEYCHAIN" -P "$CERT_PASSWORD" -T /usr/bin/codesign
security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$KEYCHAIN_PW" "$KEYCHAIN"
# Prepend the temp keychain to the search list, keeping the login keychain.
security list-keychains -d user -s "$KEYCHAIN" $(security list-keychains -d user | sed 's/"//g')
rm -f "$RUNNER_TEMP/cert.p12"
- name: Store notary credentials
env:
NOTARY_APPLE_ID: ${{ secrets.MACOS_NOTARY_APPLE_ID }}
NOTARY_TEAM_ID: ${{ secrets.MACOS_NOTARY_TEAM_ID }}
NOTARY_PASSWORD: ${{ secrets.MACOS_NOTARY_PASSWORD }}
run: |
xcrun notarytool store-credentials orx-notary \
--apple-id "$NOTARY_APPLE_ID" \
--team-id "$NOTARY_TEAM_ID" \
--password "$NOTARY_PASSWORD"
- name: Build universal app
run: ORX_APP_UNIVERSAL=1 bash scripts/build-macos-app.sh
- name: Sign, notarize, and package the DMG
env:
MACOS_SIGN_IDENTITY: ${{ secrets.MACOS_SIGN_IDENTITY }}
MACOS_NOTARY_PROFILE: orx-notary
run: bash scripts/package-macos-app.sh
- name: Upload the DMG to the release
env:
GH_TOKEN: ${{ github.token }}
TAG: ${{ needs.check.outputs.tag }}
run: gh release upload "$TAG" dist/OpenResearch.dmg --clobber
+2
View File
@@ -1,4 +1,6 @@
/target
# Built macOS .app bundle (scripts/build-macos-app.sh output).
/dist
*.log
.DS_Store
Claude.local.md
Generated
+38
View File
@@ -975,6 +975,42 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "objc2"
version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f"
dependencies = [
"objc2-encode",
]
[[package]]
name = "objc2-app-kit"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c"
dependencies = [
"bitflags",
"objc2",
"objc2-foundation",
]
[[package]]
name = "objc2-encode"
version = "4.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33"
[[package]]
name = "objc2-foundation"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272"
dependencies = [
"bitflags",
"objc2",
]
[[package]]
name = "once_cell"
version = "1.21.4"
@@ -1000,6 +1036,8 @@ dependencies = [
"fd-lock",
"futures",
"libc",
"objc2",
"objc2-app-kit",
"reqwest",
"rusqlite",
"rust-embed",
+8
View File
@@ -47,6 +47,14 @@ sha2 = "0.10"
# transitively; taken as a direct dep only on unix, where we call it.
libc = "0.2"
[target.'cfg(target_os = "macos")'.dependencies]
# macOS `.app` mode (src/commands/app.rs): an NSApplication + delegate run loop
# on the main thread so the downloadable app gets a Dock icon and click handling
# while the dashboard server runs on background threads. macOS-only so the
# static musl Linux builds are untouched.
objc2 = "0.6"
objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "NSApplication", "NSResponder", "NSRunningApplication"] }
# The profile that 'dist' will build with
[profile.dist]
inherits = "release"
+56
View File
@@ -0,0 +1,56 @@
# Distributing OpenResearch.app
`scripts/build-macos-app.sh` builds the app; `scripts/package-macos-app.sh`
signs, notarizes, and packages it into a DMG. CI
(`.github/workflows/release-macos-app.yml`) runs both after each release and
attaches `OpenResearch.dmg`:
```
https://github.com/alphaXiv/openresearch-cli/releases/latest/download/OpenResearch.dmg
```
The release job is a no-op until the `MACOS_SIGNING_ENABLED` variable is `true`.
## Configure signing (CI)
Needs an Apple Developer Program account with a **Developer ID Application**
certificate (see Apple's [notarizing docs](https://developer.apple.com/documentation/security/notarizing-macos-software-before-distribution)).
From it you produce the six values below.
1. Create the **`release-signing` environment** (Settings → Environments): add
**required reviewers** and set **Deployment branches → `main`**. Add these as
**environment** secrets (not repo-wide):
| Secret | Value |
| --- | --- |
| `MACOS_CERT_P12_BASE64` | `base64 -i cert.p12` of the exported Developer ID cert |
| `MACOS_CERT_PASSWORD` | the `.p12` export password |
| `MACOS_SIGN_IDENTITY` | `Developer ID Application: <name> (TEAMID)` — `security find-identity -v -p codesigning` |
| `MACOS_NOTARY_APPLE_ID` | your Apple ID email |
| `MACOS_NOTARY_TEAM_ID` | your Team ID |
| `MACOS_NOTARY_PASSWORD` | an app-specific password (account.apple.com) |
2. Set repo **variable** `MACOS_SIGNING_ENABLED = true` to switch the pipeline on.
Also enable **Require a pull request** + **Require review from Code Owners** on
`main` (see `.github/CODEOWNERS`) so the signing scripts can't change unreviewed.
Never commit the `.p12`.
## Build / sign locally
```bash
rustup target add aarch64-apple-darwin x86_64-apple-darwin # once, for universal
ORX_APP_UNIVERSAL=1 bash scripts/build-macos-app.sh
xcrun notarytool store-credentials orx-notary \
--apple-id you@example.com --team-id TEAMID --password <app-specific-password>
MACOS_SIGN_IDENTITY="Developer ID Application: <name> (TEAMID)" \
MACOS_NOTARY_PROFILE=orx-notary bash scripts/package-macos-app.sh
```
Without the two `MACOS_*` vars, `package-macos-app.sh` still makes an **unsigned**
`dist/OpenResearch.dmg` for quick local testing.
## Not yet automated
- A nicer DMG layout (background + drag-to-Applications alias); `create-dmg` is
the usual tool.
+30
View File
@@ -0,0 +1,30 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleName</key>
<string>OpenResearch</string>
<key>CFBundleDisplayName</key>
<string>OpenResearch</string>
<key>CFBundleIdentifier</key>
<string>org.alphaxiv.openresearch</string>
<key>CFBundleExecutable</key>
<string>OpenResearch</string>
<key>CFBundleIconFile</key>
<string>AppIcon</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<!-- __VERSION__ is replaced by scripts/build-macos-app.sh from Cargo.toml. -->
<key>CFBundleShortVersionString</key>
<string>__VERSION__</string>
<key>CFBundleVersion</key>
<string>__VERSION__</string>
<key>LSMinimumSystemVersion</key>
<string>11.0</string>
<key>NSHighResolutionCapable</key>
<true/>
<!-- Regular Dock app (not an agent/menubar-only app). -->
<key>LSUIElement</key>
<false/>
</dict>
</plist>
+70
View File
@@ -0,0 +1,70 @@
#!/bin/bash
# Assemble the downloadable macOS app bundle: OpenResearch.app.
#
# Builds a release `orx`, generates AppIcon.icns from the brand mark, and lays
# out dist/OpenResearch.app. The bundle's executable IS `orx`; launched from the
# bundle it enters GUI app mode (see src/commands/app.rs). macOS only.
#
# The result is UNSIGNED — Gatekeeper will warn on first open (right-click →
# Open, or `xattr -dr com.apple.quarantine`). Signing + notarization is separate.
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
APP="$ROOT/dist/OpenResearch.app"
CONTENTS="$APP/Contents"
if [[ "$(uname)" != "Darwin" ]]; then
echo "build-macos-app.sh: macOS only (uname=$(uname))" >&2
exit 1
fi
VERSION="$(sed -n 's/^version *= *"\(.*\)"/\1/p' "$ROOT/Cargo.toml" | head -1)"
# ORX_APP_UNIVERSAL=1 builds a universal (arm64 + x86_64) binary for
# distribution; the default single-arch build is faster for local iteration.
if [[ "${ORX_APP_UNIVERSAL:-0}" == "1" ]]; then
echo "==> Building universal release orx (v$VERSION: arm64 + x86_64)"
cargo build --release --bin orx --target aarch64-apple-darwin --manifest-path "$ROOT/Cargo.toml"
cargo build --release --bin orx --target x86_64-apple-darwin --manifest-path "$ROOT/Cargo.toml"
BIN="$ROOT/target/universal-apple-darwin/release/orx"
mkdir -p "$(dirname "$BIN")"
lipo -create -output "$BIN" \
"$ROOT/target/aarch64-apple-darwin/release/orx" \
"$ROOT/target/x86_64-apple-darwin/release/orx"
else
echo "==> Building release orx (v$VERSION, native arch — set ORX_APP_UNIVERSAL=1 for universal)"
cargo build --release --bin orx --manifest-path "$ROOT/Cargo.toml"
BIN="$ROOT/target/release/orx"
fi
echo "==> Generating AppIcon.icns"
TMP="$(mktemp -d)"
trap 'rm -rf "$TMP"' EXIT
ICONSET="$TMP/AppIcon.iconset"
mkdir -p "$ICONSET"
gen() { node "$ROOT/scripts/generate-icon.mjs" "$ICONSET/$1" "$2" >/dev/null; }
gen icon_16x16.png 16
gen icon_16x16@2x.png 32
gen icon_32x32.png 32
gen icon_32x32@2x.png 64
gen icon_128x128.png 128
gen icon_128x128@2x.png 256
gen icon_256x256.png 256
gen icon_256x256@2x.png 512
gen icon_512x512.png 512
gen icon_512x512@2x.png 1024
echo "==> Assembling $APP"
rm -rf "$APP"
mkdir -p "$CONTENTS/MacOS" "$CONTENTS/Resources"
cp "$BIN" "$CONTENTS/MacOS/OpenResearch"
chmod +x "$CONTENTS/MacOS/OpenResearch"
iconutil -c icns "$ICONSET" -o "$CONTENTS/Resources/AppIcon.icns"
sed "s/__VERSION__/$VERSION/g" "$ROOT/macos/Info.plist" > "$CONTENTS/Info.plist"
printf 'APPL????' > "$CONTENTS/PkgInfo"
# Refresh Launch Services so Finder/Dock pick up the new icon immediately.
/System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister \
-f "$APP" 2>/dev/null || true
echo "==> Done: $APP"
+83
View File
@@ -0,0 +1,83 @@
// Rasterize the OpenResearch brand mark to a transparent-corner RGBA PNG.
//
// Source of truth for the shapes is ui/public/favicon.svg (a red squircle with
// a white right-triangle). QuickLook/`sips` flatten SVG transparency onto white,
// so we draw the geometry directly and emit straight-alpha RGBA — giving clean
// transparent corners for both the CLI Dock icon and the macOS .app iconset.
//
// Usage: node scripts/generate-icon.mjs <out.png> [size] (default size 1024)
// Requires Node >= 22.2 (uses the built-in zlib.crc32).
import zlib from 'node:zlib';
import { writeFileSync } from 'node:fs';
const out = process.argv[2];
if (!out) {
console.error('usage: node scripts/generate-icon.mjs <out.png> [size]');
process.exit(1);
}
const N = Number(process.argv[3] ?? 1024);
const SS = 4; // supersample factor per axis (anti-aliasing)
// squircle inset within the canvas (macOS-icon-style padding), scaled to N
const s = N / 1024;
const X = 88 * s, Y = 88 * s, W = 848 * s, H = 848 * s, R = 188 * s;
// brand triangle (favicon path, translate 88 + scale 8.48), right angle at B
const A = [218.38 * s, 230.31 * s], B = [218.38 * s, 805.62 * s], C = [793.69 * s, 805.62 * s];
const RED = [0x9a, 0x20, 0x36];
const clamp = (v, lo, hi) => (v < lo ? lo : v > hi ? hi : v);
function inSquircle(px, py) {
const cx = clamp(px, X + R, X + W - R), cy = clamp(py, Y + R, Y + H - R);
const dx = px - cx, dy = py - cy;
return dx * dx + dy * dy <= R * R;
}
function edge(p, a, b) {
return (p[0] - b[0]) * (a[1] - b[1]) - (a[0] - b[0]) * (p[1] - b[1]);
}
function inTriangle(px, py) {
const p = [px, py];
const d1 = edge(p, A, B), d2 = edge(p, B, C), d3 = edge(p, C, A);
const neg = d1 < 0 || d2 < 0 || d3 < 0, pos = d1 > 0 || d2 > 0 || d3 > 0;
return !(neg && pos);
}
const raw = Buffer.alloc(N * (N * 4 + 1)); // +1 filter byte per row
let o = 0;
for (let y = 0; y < N; y++) {
raw[o++] = 0; // PNG filter: none
for (let x = 0; x < N; x++) {
let r = 0, g = 0, b = 0, cov = 0;
for (let sy = 0; sy < SS; sy++) {
for (let sx = 0; sx < SS; sx++) {
const px = x + (sx + 0.5) / SS, py = y + (sy + 0.5) / SS;
if (inTriangle(px, py)) { r += 255; g += 255; b += 255; cov++; }
else if (inSquircle(px, py)) { r += RED[0]; g += RED[1]; b += RED[2]; cov++; }
}
}
const S = SS * SS;
raw[o++] = cov ? Math.round(r / cov) : 0;
raw[o++] = cov ? Math.round(g / cov) : 0;
raw[o++] = cov ? Math.round(b / cov) : 0;
raw[o++] = Math.round((255 * cov) / S);
}
}
function chunk(type, data) {
const len = Buffer.alloc(4); len.writeUInt32BE(data.length);
const td = Buffer.concat([Buffer.from(type, 'ascii'), data]);
const crc = Buffer.alloc(4); crc.writeUInt32BE(zlib.crc32(td) >>> 0);
return Buffer.concat([len, td, crc]);
}
const sig = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
const ihdr = Buffer.alloc(13);
ihdr.writeUInt32BE(N, 0); ihdr.writeUInt32BE(N, 4);
ihdr[8] = 8; ihdr[9] = 6; // 8-bit, RGBA
const png = Buffer.concat([
sig,
chunk('IHDR', ihdr),
chunk('IDAT', zlib.deflateSync(raw, { level: 9 })),
chunk('IEND', Buffer.alloc(0)),
]);
writeFileSync(out, png);
console.log(`wrote ${out} (${N}x${N}, ${png.length} bytes)`);
+65
View File
@@ -0,0 +1,65 @@
#!/bin/bash
# Sign, notarize, and package dist/OpenResearch.app into a distributable DMG.
#
# Run scripts/build-macos-app.sh first. This script is env-driven so it works
# both locally (unsigned, for a quick DMG) and in CI (fully signed + notarized):
#
# MACOS_SIGN_IDENTITY "Developer ID Application: NAME (TEAMID)".
# Unset → skip signing (UNSIGNED dmg; Gatekeeper warns).
# MACOS_NOTARY_PROFILE notarytool keychain profile (see `notarytool
# store-credentials`). Unset → skip notarization.
#
# See macos/DISTRIBUTION.md for the full setup and the CI wiring.
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
APP="$ROOT/dist/OpenResearch.app"
DMG="$ROOT/dist/OpenResearch.dmg"
EXE="$APP/Contents/MacOS/OpenResearch"
if [[ "$(uname)" != "Darwin" ]]; then
echo "package-macos-app.sh: macOS only" >&2
exit 1
fi
if [[ ! -d "$APP" ]]; then
echo "package-macos-app.sh: $APP not found — run scripts/build-macos-app.sh first" >&2
exit 1
fi
if [[ -n "${MACOS_SIGN_IDENTITY:-}" ]]; then
echo "==> Codesigning with hardened runtime"
# Sign inside-out: the nested executable first, then the bundle.
codesign --force --options runtime --timestamp --sign "$MACOS_SIGN_IDENTITY" "$EXE"
codesign --force --options runtime --timestamp --sign "$MACOS_SIGN_IDENTITY" "$APP"
codesign --verify --strict --verbose=2 "$APP"
else
echo "==> MACOS_SIGN_IDENTITY unset — building an UNSIGNED bundle (local test only)."
fi
# Notarize + staple the .app itself first, so it still launches when a user drags
# it out of the DMG and first opens it offline (a DMG-only staple wouldn't cover
# the extracted app).
if [[ -n "${MACOS_NOTARY_PROFILE:-}" ]]; then
echo "==> Notarizing the app (submitting to Apple; can take a few minutes)"
APP_ZIP="$ROOT/dist/OpenResearch-app.zip"
ditto -c -k --keepParent "$APP" "$APP_ZIP"
xcrun notarytool submit "$APP_ZIP" --keychain-profile "$MACOS_NOTARY_PROFILE" --wait
xcrun stapler staple "$APP"
rm -f "$APP_ZIP"
fi
echo "==> Creating DMG"
rm -f "$DMG"
hdiutil create -volname "OpenResearch" -srcfolder "$APP" -ov -format UDZO "$DMG" >/dev/null
[[ -n "${MACOS_SIGN_IDENTITY:-}" ]] && codesign --force --timestamp --sign "$MACOS_SIGN_IDENTITY" "$DMG"
if [[ -n "${MACOS_NOTARY_PROFILE:-}" ]]; then
echo "==> Notarizing and stapling the DMG"
xcrun notarytool submit "$DMG" --keychain-profile "$MACOS_NOTARY_PROFILE" --wait
xcrun stapler staple "$DMG"
xcrun stapler validate "$DMG"
else
echo "==> MACOS_NOTARY_PROFILE unset — skipping notarization (downloads would warn)."
fi
echo "==> Done: $DMG"
+115
View File
@@ -0,0 +1,115 @@
//! macOS `.app` mode — the GUI entry point for the downloadable OpenResearch app.
//!
//! The bundle's executable IS the `orx` binary. When launched from a `.app`
//! (double-click), macOS starts it with no arguments, so `main` routes here
//! instead of parsing CLI args. App mode owns the main thread with the AppKit
//! run loop — giving a proper Dock icon (from the bundle's `.icns`), the
//! "OpenResearch" menu-bar name, and interactive Dock-icon clicks — while the
//! `orx up` dashboard server runs on background tokio worker threads.
//!
//! This is distinct from `orx up` launched in a terminal, which stays a plain
//! CLI. The whole module is macOS-only; other targets compile it away.
/// True when this process is the executable inside a `<name>.app/Contents/MacOS`
/// bundle — the signal to enter GUI app mode instead of parsing CLI args.
#[cfg(target_os = "macos")]
pub fn launched_as_app_bundle() -> bool {
std::env::current_exe()
.ok()
.as_deref()
.and_then(std::path::Path::parent)
.is_some_and(|dir| dir.ends_with("Contents/MacOS"))
}
/// Enter GUI app mode: pick a free port, start the dashboard server on
/// background threads, and hand the main thread to the AppKit run loop. Returns
/// only when the user quits the app (usually the process just exits).
#[cfg(target_os = "macos")]
pub fn run() {
// Ephemeral loopback port so the app never collides with a terminal
// `orx up`. Bind-then-drop to reserve it; the tiny race is harmless locally.
let port = std::net::TcpListener::bind(("127.0.0.1", 0))
.and_then(|l| l.local_addr())
.map(|a| a.port())
.unwrap_or(4791);
imp::run_event_loop(format!("http://127.0.0.1:{port}/"), port);
}
#[cfg(target_os = "macos")]
mod imp {
use objc2::rc::Retained;
use objc2::runtime::{NSObject, NSObjectProtocol, ProtocolObject};
use objc2::{define_class, msg_send, DefinedClass, MainThreadMarker, MainThreadOnly};
use objc2_app_kit::{NSApplication, NSApplicationActivationPolicy, NSApplicationDelegate};
struct DelegateIvars {
url: String,
}
define_class!(
// SAFETY: NSObject has no subclassing requirements; no `Drop` impl.
#[unsafe(super(NSObject))]
#[thread_kind = MainThreadOnly]
#[name = "OrxAppDelegate"]
#[ivars = DelegateIvars]
struct Delegate;
unsafe impl NSObjectProtocol for Delegate {}
unsafe impl NSApplicationDelegate for Delegate {
// Dock-icon click with no open windows → reopen the dashboard.
#[unsafe(method(applicationShouldHandleReopen:hasVisibleWindows:))]
fn should_handle_reopen(&self, _app: &NSApplication, _has_windows: bool) -> bool {
crate::browser::open_browser(&self.ivars().url);
true
}
}
);
impl Delegate {
fn new(mtm: MainThreadMarker, url: String) -> Retained<Self> {
let this = Self::alloc(mtm).set_ivars(DelegateIvars { url });
unsafe { msg_send![super(this), init] }
}
}
pub(super) fn run_event_loop(url: String, port: u16) {
let mtm = MainThreadMarker::new().expect("app mode runs on the main thread");
// Dashboard server on background workers (we're inside main's runtime).
tokio::spawn(async move {
let args = crate::UpArgs {
port,
remote: None,
no_browser: true,
no_agent: false,
model: None,
};
if let Err(err) = crate::commands::up::run(args).await {
eprintln!("openresearch app: dashboard server exited: {err}");
}
});
// Open the browser once the server accepts connections.
let ready_url = url.clone();
tokio::spawn(async move {
for _ in 0..100 {
if tokio::net::TcpStream::connect(("127.0.0.1", port))
.await
.is_ok()
{
crate::browser::open_browser(&ready_url);
return;
}
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
}
});
let app = NSApplication::sharedApplication(mtm);
app.setActivationPolicy(NSApplicationActivationPolicy::Regular);
// Delegate must outlive `run()` — AppKit holds it weakly.
let delegate = Delegate::new(mtm, url);
app.setDelegate(Some(ProtocolObject::from_ref(&*delegate)));
app.run();
}
}
+1
View File
@@ -16,6 +16,7 @@
//! return `Err(anyhow!(...))` (clap already enforces required positionals, so
//! most of those usage guards are unnecessary in the Rust port).
pub mod app;
pub mod artifact;
pub mod artifacts;
pub mod chart;
+13
View File
@@ -871,8 +871,21 @@ pub struct PaperArgs {
pub full: bool,
}
// The default multi-thread runtime is load-bearing for macOS app mode: it blocks
// the main thread in the AppKit run loop while the dashboard server runs on
// worker threads. A `current_thread` flavor would deadlock. See commands::app.
#[tokio::main]
async fn main() {
// Double-clicked as the macOS .app? Enter GUI app mode (Dock icon, dashboard
// server, browser) instead of parsing CLI args. Also require an empty argv so
// the bundled binary stays usable as a CLI (`…/MacOS/OpenResearch up`), since
// the bundle itself launches it with no arguments. See commands::app.
#[cfg(target_os = "macos")]
if commands::app::launched_as_app_bundle() && std::env::args_os().len() == 1 {
commands::app::run();
return;
}
let cli = Cli::parse();
let Some(command) = cli.command else {
// Bare `orx`: print the command overview to stdout and exit 0.