Build the irrlicht daemon and/or Swift app, then run them, governed by two
independent axes:
-
Set the run config. Default to MODE=replace and TARGET=full —
don't ask. Only change MODE to separate when the user explicitly
requested it (e.g. /ir:test-mac separate, or "alongside production" /
"don't touch production"). Only change TARGET when the user asked to
restart just one component (e.g. "just restart the daemon", "rebuild the
mac app only"). Note that replace is destructive (it kills production and
lets a dev binary read/write production state, and — for TARGET=macos
or full — overwrites /Applications/Irrlicht.app's executable in
place); that's the intended default, but it's why the final teardown step
is required to get production back.
Every later step branches on $MODE/$TARGET/$PORT/$DEV_HOME/$SOCK.
These are shell variables, and each fenced block runs in a fresh
shell, so when you execute a later step you must carry the step-0
values forward (re-declare them, or inline the literal port/path) — an
empty $PORT makes the daemon bind 127.0.0.1: (invalid) and an empty
$SOCK turns the socket cleanup into a no-op.
REPO_ROOT="$(git rev-parse --show-toplevel)"
IRRLICHTD_BIN="/Users/ingo/projects/irrlicht/core/bin/irrlichd"
PROD_APP="/Applications/Irrlicht.app"
PROD_BACKUP="/Users/ingo/projects/irrlicht/.build/irrlicht-prod-backup/Irrlicht.app"
DEV_APP="/tmp/IrrlichtDev.app"
MODE="replace"
TARGET="full"
case "$MODE" in
replace|separate) ;;
*) echo "ERROR: MODE must be 'replace' or 'separate' (got '$MODE')" >&2; exit 1 ;;
esac
case "$TARGET" in
daemon|macos|full) ;;
*) echo "ERROR: TARGET must be 'daemon', 'macos', or 'full' (got '$TARGET')" >&2; exit 1 ;;
esac
if [ "$MODE" = "replace" ]; then
PORT=7837
DEV_HOME=""
SOCK="$HOME/.local/share/irrlicht/irrlichd.sock"
APP_TARGET="$PROD_APP"
else
PORT=7838
DEV_HOME="$REPO_ROOT/.build/irrlicht-home"
SOCK="$DEV_HOME/irrlichd.sock"
APP_TARGET="$DEV_APP"
mkdir -p "$DEV_HOME"
fi
echo "MODE=$MODE TARGET=$TARGET PORT=$PORT DEV_HOME=${DEV_HOME:-<production>}"
$IRRLICHTD_BIN is the main repo's bin (a stable absolute path) so the
build and launch steps always agree even when $REPO_ROOT is a worktree
— the source compiled is still the worktree's (go build runs in
$REPO_ROOT/core). $PROD_BACKUP is likewise a stable main-repo path
(not worktree-relative) so it survives across worktree runs and removals.
-
Build the Go daemon — the daemon resolves the dashboard from
platforms/web/index.html at runtime via a walk-up search from its own
executable; no embed, no codegen. Skipped when TARGET=macos (no daemon
change requested).
if [ "$TARGET" = "daemon" ] || [ "$TARGET" = "full" ]; then
cd "$REPO_ROOT/core" && go build -o "$IRRLICHTD_BIN" ./cmd/irrlichd
fi
Note: the binary is built from the worktree's source but placed at the
stable $IRRLICHTD_BIN path that the start-daemon step launches.
-
Build the Swift app (compile only — no bundle mutation yet). Skipped
when TARGET=daemon (no app change requested). Building is safe to do
before killing anything since it only writes into .build/; the actual
install into a live bundle happens after the kill step below.
if [ "$TARGET" = "macos" ] || [ "$TARGET" = "full" ]; then
cd "$REPO_ROOT/platforms/macos" && swift build 2>&1 | tail -5
fi
-
Kill the instances this mode+target replaces. Daemon and app are
killed independently so a TARGET=daemon/macos-only run leaves the
other component alone. The app is killed before the daemon (matching
restore-prod.sh's order) so a still-alive app never observes a
momentary daemon-less gap and reacts by spawning its own replacement,
which could win the port race against the daemon step 6 starts next.
if [ "$TARGET" = "macos" ] || [ "$TARGET" = "full" ]; then
if [ "$MODE" = "replace" ]; then
APP_KILL_PATTERN="Irrlicht\.app/Contents/MacOS/Irrlicht"
pkill -f "$APP_KILL_PATTERN" 2>/dev/null
pkill -f "IrrlichtDev" 2>/dev/null
else
APP_KILL_PATTERN="IrrlichtDev"
pkill -f "$APP_KILL_PATTERN" 2>/dev/null
fi
for _ in 1 2 3 4 5; do
pgrep -f "$APP_KILL_PATTERN" >/dev/null 2>&1 || break
sleep 1
done
if [ "$MODE" = "replace" ] && pgrep -f "$APP_KILL_PATTERN" >/dev/null 2>&1; then
echo "ABORT: the app process is still running — refusing to overwrite its bundle in step 5." >&2
return 1 2>/dev/null || exit 1
fi
fi
if [ "$TARGET" = "daemon" ] || [ "$TARGET" = "full" ]; then
if [ "$MODE" = "replace" ]; then
pkill -x "irrlichd" 2>/dev/null
else
pkill -f "core/bin/irrlichd" 2>/dev/null
fi
PORT_PIDS="$(lsof -ti tcp:$PORT 2>/dev/null)"
[ -n "$PORT_PIDS" ] && kill $PORT_PIDS 2>/dev/null
sleep 1
fi
-
Clean up the stale socket for the target instance. Only meaningful
when the daemon is (re)starting.
if [ "$TARGET" = "daemon" ] || [ "$TARGET" = "full" ]; then
rm -f "$SOCK"
fi
-
Install the app bundle. Skipped when TARGET=daemon.
-
replace — install straight into $PROD_APP (no more parallel
/tmp/IrrlichtDev.app, since it shares production's bundle identifier
and a human only reviews one running app at a time). Back up the
untouched original bundle as a full directory copy — a full-bundle
backup sidesteps any code-signature mismatch between the outer bundle
and its nested binaries that a partial-file restore could otherwise
leave behind. The teardown step restores from this copy.
The backup is refreshed (not just made once-ever) whenever the
currently-installed app is still genuinely Developer-ID-signed — never
trust an existing backup blindly, since it can predate a newer
production release installed since (e.g. via /ir:release), which
would otherwise make the teardown step silently reinstall a stale
build. If the app is not currently Developer-ID-signed (a prior
replace-mode run's dev build, still installed) and no backup exists
either, refuse to proceed rather than overwriting the only remaining
copy without a safety net. The build outputs are also checked for
existence before anything in the live bundle is touched, so a failed
swift build aborts here instead of leaving Sparkle.framework
deleted with nothing to replace it.
if [ "$TARGET" = "macos" ] || [ "$TARGET" = "full" ]; then
if [ "$MODE" = "replace" ]; then
DEBUG_BIN="$REPO_ROOT/platforms/macos/.build/arm64-apple-macosx/debug/Irrlicht"
DEBUG_SPARKLE="$REPO_ROOT/platforms/macos/.build/arm64-apple-macosx/debug/Sparkle.framework"
if [ ! -x "$DEBUG_BIN" ] || [ ! -d "$DEBUG_SPARKLE" ]; then
echo "ERROR: swift build did not produce $DEBUG_BIN / $DEBUG_SPARKLE — not touching $PROD_APP." >&2
exit 1
fi
if [ ! -d "$PROD_APP" ]; then
echo "ERROR: $PROD_APP is not installed — run the DMG/PKG installer first." >&2
exit 1
fi
if codesign -dv --verbose=4 "$PROD_APP" 2>&1 | grep -q "^Authority=Developer ID Application"; then
rm -rf "$PROD_BACKUP"
mkdir -p "$(dirname "$PROD_BACKUP")"
if ! cp -R "$PROD_APP" "$PROD_BACKUP"; then
echo "ERROR: backup of $PROD_APP to $PROD_BACKUP failed — not touching $PROD_APP." >&2
rm -rf "$PROD_BACKUP"
exit 1
fi
echo "Backed up the untouched production bundle to $PROD_BACKUP (restore-prod.sh restores from this)."
elif [ ! -d "$PROD_BACKUP" ]; then
echo "ERROR: $PROD_APP isn't Developer-ID-signed (looks like a leftover dev build) and no backup exists — refusing to overwrite it with no safety net. Run the DMG/PKG installer first." >&2
exit 1
fi
cp "$DEBUG_BIN" "$APP_TARGET/Contents/MacOS/Irrlicht"
rm -rf "$APP_TARGET/Contents/Frameworks/Sparkle.framework"
cp -R "$DEBUG_SPARKLE" "$APP_TARGET/Contents/Frameworks/Sparkle.framework"
cp "$REPO_ROOT/platforms/macos/Irrlicht/Resources/AppIcon.icns" "$APP_TARGET/Contents/Resources/AppIcon.icns"
/usr/libexec/PlistBuddy -c "Set :CFBundleShortVersionString dev" "$APP_TARGET/Contents/Info.plist"
/usr/libexec/PlistBuddy -c "Set :CFBundleVersion dev" "$APP_TARGET/Contents/Info.plist"
install_name_tool -add_rpath @executable_path/../Frameworks "$APP_TARGET/Contents/MacOS/Irrlicht"
fi
fi
-
separate — unchanged: assemble a fresh /tmp/IrrlichtDev.app bundle
from scratch so UNUserNotificationCenter (desktop notifications) works.
if [ "$TARGET" = "macos" ] || [ "$TARGET" = "full" ]; then
if [ "$MODE" = "separate" ]; then
rm -rf "$APP_TARGET"
mkdir -p "$APP_TARGET/Contents/MacOS" "$APP_TARGET/Contents/Resources"
cp "$REPO_ROOT/platforms/macos/.build/arm64-apple-macosx/debug/Irrlicht" "$APP_TARGET/Contents/MacOS/Irrlicht"
cp "$REPO_ROOT/platforms/macos/Irrlicht/Resources/AppIcon.icns" "$APP_TARGET/Contents/Resources/AppIcon.icns"
mkdir -p "$APP_TARGET/Contents/Frameworks"
cp -R "$REPO_ROOT/platforms/macos/.build/arm64-apple-macosx/debug/Sparkle.framework" "$APP_TARGET/Contents/Frameworks/"
install_name_tool -add_rpath @executable_path/../Frameworks "$APP_TARGET/Contents/MacOS/Irrlicht"
cat > "$APP_TARGET/Contents/Info.plist" << 'PLIST'
<?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>CFBundleExecutable</key>
<string>Irrlicht</string>
<key>CFBundleIdentifier</key>
<string>io.irrlicht.app</string>
<key>CFBundleIconFile</key>
<string>AppIcon</string>
<key>CFBundleName</key>
<string>Irrlicht Dev</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>dev</string>
<key>LSUIElement</key>
<true/>
<key>NSAppleEventsUsageDescription</key>
<string>Irrlicht uses AppleScript to bring the correct iTerm2 or Terminal.app window and tab to the front when you click a session row.</string>
<key>NSFocusStatusUsageDescription</key>
<string>Irrlicht uses macOS Focus status to silence notification sounds and spoken alerts while you're in Do Not Disturb, Sleep, or any other Focus mode.</string>
</dict>
</plist>
PLIST
fi
fi
-
codesign (both modes, identical). Sign with the persistent
"Irrlicht Dev" identity if it exists; otherwise fall back to ad-hoc (TCC
permissions will need to be re-granted each rebuild). Run
tools/dev-sign-setup.sh once to install the identity. Uses the
dev-only entitlements file (no com.apple.developer.* entries — Apple
gates those to its own certificates and launchd will refuse to spawn a
self-signed/ad-hoc binary that claims them).
if [ "$TARGET" = "macos" ] || [ "$TARGET" = "full" ]; then
ENTITLEMENTS="$REPO_ROOT/platforms/macos/Irrlicht/Resources/Irrlicht-dev.entitlements"
if security find-identity -v -p codesigning 2>/dev/null | grep -q "Irrlicht Dev"; then
codesign --force --deep --sign "Irrlicht Dev" --entitlements "$ENTITLEMENTS" "$APP_TARGET" 2>&1
else
codesign --force --deep --sign - --entitlements "$ENTITLEMENTS" "$APP_TARGET" 2>&1
fi
fi
Heads up for replace mode specifically: since the dev build is now
signed and launched at production's own path, expect one extra
Accessibility/Automation re-grant prompt the first time this runs after
picking up this change (TCC keys grants off path + code identity, and
the dev identity is new at this path) — the stable "Irrlicht Dev"
identity should keep that grant across subsequent dev rebuilds, and
production's original grant (tied to its own Developer ID signature) is
unaffected once restored.
-
Start the daemon with --record for lifecycle event capture. Skipped
when TARGET=macos. In separate mode it gets IRRLICHT_HOME
(isolated state) plus IRRLICHT_PERMISSION_MODE=grant-all — a fresh
isolated state dir has no consent answers (#570), so without it the
daemon monitors nothing until the permission wizard is answered. Drop
that variable when the point of the session is to test the wizard
itself. In replace mode IRRLICHT_HOME is omitted so it reads/writes
the production state dir — including the user's real permission answers
(no grant-all there).
if [ "$TARGET" = "daemon" ] || [ "$TARGET" = "full" ]; then
if [ "$MODE" = "replace" ]; then
IRRLICHT_BIND_ADDR=127.0.0.1:$PORT \
nohup "$IRRLICHTD_BIN" --record > /tmp/irrlichd-dev.log 2>&1 & disown
else
IRRLICHT_HOME="$DEV_HOME" IRRLICHT_BIND_ADDR=127.0.0.1:$PORT \
IRRLICHT_PERMISSION_MODE=grant-all \
nohup "$IRRLICHTD_BIN" --record > /tmp/irrlichd-dev.log 2>&1 & disown
fi
fi
-
Wait for a reachable daemon — and HARD-ABORT if there isn't one. This
is a gate, not a courtesy sleep. In replace mode the app adopts an
already-reachable daemon on 7837 and skips its own spawn/pkill; if no
--record daemon is up when the app launches, the app (port 7837 ⇒
isCustomPort false) runs pkill -x irrlichd — killing whatever daemon
is there — and respawns one without --record, silently defeating
the whole point. So if /state never answers, stop here and do not
launch the app.
if [ "$TARGET" = "daemon" ] || [ "$TARGET" = "full" ]; then
READY=""
for i in 1 2 3 4 5 6 7 8; do
curl -fsS --max-time 1 "http://127.0.0.1:$PORT/state" >/dev/null 2>&1 && { READY=1; break; }
sleep 1
done
if [ -z "$READY" ]; then
echo "ABORT: daemon never became reachable on $PORT — not launching the app." >&2
echo " (Launching now would let the app pkill our daemon and respawn one without --record.)" >&2
echo " Check /tmp/irrlichd-dev.log." >&2
return 1 2>/dev/null || exit 1
fi
else
if ! curl -fsS --max-time 1 "http://127.0.0.1:$PORT/state" >/dev/null 2>&1; then
echo "ABORT: no daemon reachable on $PORT and TARGET=macos doesn't start one." >&2
echo " Run with TARGET=daemon or TARGET=full first." >&2
return 1 2>/dev/null || exit 1
fi
fi
lsof -iTCP:$PORT -sTCP:LISTEN -P -n 2>/dev/null
-
Start the app — launched via LaunchServices so Bundle.main resolves
correctly. Skipped when TARGET=daemon. In separate mode,
IRRLICHT_DAEMON_PORT/IRRLICHT_HOME point it at the isolated dev
daemon. In replace mode, no env overrides are passed: the app uses
the default port 7837 + production state and, finding the daemon already
reachable, adopts it instead of spawning its own.
if [ "$TARGET" = "macos" ] || [ "$TARGET" = "full" ]; then
if [ "$MODE" = "replace" ]; then
open --stdout /tmp/irrlicht-app-dev.log --stderr /tmp/irrlicht-app-dev.log "$APP_TARGET"
else
open --env IRRLICHT_DAEMON_PORT=$PORT --env IRRLICHT_HOME="$DEV_HOME" \
--stdout /tmp/irrlicht-app-dev.log --stderr /tmp/irrlicht-app-dev.log "$APP_TARGET"
fi
fi
-
Verify — whichever components this run touched are up, and the
daemon is serving sessions. In replace mode, also confirm quota data is
present (the whole point of 7837).
if [ "$TARGET" = "daemon" ] || [ "$TARGET" = "full" ]; then
pgrep -f "bin/irrlichd" && curl -s http://127.0.0.1:$PORT/api/v1/sessions | head -c 200
fi
if [ "$TARGET" = "macos" ] || [ "$TARGET" = "full" ]; then
if [ "$MODE" = "replace" ]; then
pgrep -f "Irrlicht\.app/Contents/MacOS/Irrlicht"
else
pgrep -f "IrrlichtDev"
fi
fi
if [ "$MODE" = "replace" ]; then
echo; echo "rate-limit mentions (0 now is fine; should climb after the next statusline tick):" \
"$(curl -s http://127.0.0.1:$PORT/api/v1/sessions | grep -o 'rate_limit\|used_percent' | wc -l | tr -d ' ')"
fi
-
Tearing down (replace mode) — REQUIRED to get production back.
Quitting the app is NOT enough, for two independent reasons:
- The daemon: in replace mode the app only adopted the daemon
started in step 6 (it never owns the process —
DaemonManager.start() returns early on a reachable daemon without
recording it, so its terminateProcess() is a no-op), and that
daemon was nohup/disown'd, so it keeps running on 7837 after the
app exits. A relaunched production app would find it still reachable
and adopt it — you'd be running the production UI against the dev
--record daemon without realizing it.
- The app itself (only if a
TARGET=macos/full run happened): its
executable + Sparkle.framework inside /Applications/Irrlicht.app
were overwritten with the dev build. Relaunching that bundle launches
the dev binary, not production, until it's restored.
Use the bundled restore-prod.sh — it does the whole sequence (kill
the app + daemon → restore the backed-up production bundle if one was
ever overwritten → GATE on 7837 actually freeing → launch
/Applications/Irrlicht.app → confirm prod's own daemon comes up). Note:
the installer does NOT substitute for this teardown by itself — it
replaces the app bundle, but leaves the dev daemon running on 7837 (which
the freshly-installed app would still adopt), so run this script (or at
least the pkill -x irrlichd) regardless.
"$(git rev-parse --show-toplevel)/.claude/skills/ir:test-mac/restore-prod.sh"