mirror of
https://github.com/alphaXiv/OpenResearch.git
synced 2026-10-02 01:34:34 +08:00
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:
co-authored by
Claude Opus 4.8
parent
036f924161
commit
dccc37c4e8
@@ -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
|
||||
@@ -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
|
||||
@@ -1,4 +1,6 @@
|
||||
/target
|
||||
# Built macOS .app bundle (scripts/build-macos-app.sh output).
|
||||
/dist
|
||||
*.log
|
||||
.DS_Store
|
||||
Claude.local.md
|
||||
|
||||
Generated
+38
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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.
|
||||
@@ -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>
|
||||
Executable
+70
@@ -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"
|
||||
@@ -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)`);
|
||||
Executable
+65
@@ -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"
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user