Files
Myles AndersonandClaude Opus 4.8 dccc37c4e8 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>
2026-08-12 15:25:33 -07:00

84 lines
3.2 KiB
JavaScript

// 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)`);