Mac packaging: Igneum Devnet.app and DMG for Apple silicon miners

packaging/mac: build-dmg.sh assembles Igneum Devnet.app (shell launcher that opens
igneum-devnet.sh in Terminal, stripped ad-hoc-signed copies of igneumd, igneum-miner
and the Metal worker), README.txt, Stop Igneum.command and an Applications link into
dist/igneum-devnet-mac.dmg (17 MB, lzfse). The script starts the node peered to
SEED_PEERS, waits for sync, runs one miner identity mac-<hostname> on the Metal worker,
prints a status line every 30 s, uploads logs every 60 s, keeps the Mac awake and stops
everything in order on Ctrl+C, window close, --stop or the Stop command.

Tested on this Mac from the mounted DMG against a test node on 27310/27311 peered to the
live node: sync in 22 s, 24 accepted blocks, listed on igneum.network/live, clean stop
with no stray process on SIGINT, SIGTERM and Stop Igneum.command.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
igneum-labs 2026-10-03 21:54:22 +00:00
parent 75a08060f3
commit 543833c50d
8 changed files with 715 additions and 0 deletions

3
packaging/mac/.gitignore vendored Normal file
View file

@ -0,0 +1,3 @@
# built outputs: the app with its binaries, and the DMG
build/
dist/

96
packaging/mac/README.md Normal file
View file

@ -0,0 +1,96 @@
# Igneum Devnet for Mac (packaging/mac)
A DMG a friend can open on an Apple silicon Mac to run a devnet node and the Metal miner, and show up on
igneum.network/live. Built 3 October 2026 from the binaries already compiled in this repo; nothing is compiled here.
## Build
packaging/mac/build-dmg.sh
Output: `packaging/mac/dist/igneum-devnet-mac.dmg` (about 17 MB, lzfse). The script takes
`vendor/igneum-node/target-rename/release/igneumd` (falls back to `target/release/kaspad` renamed, and says so),
`vendor/igneum-node/target/release/igneum-miner` and `proto-metal/igneum-bench` (the plain build, the one whose
`--serve` protocol matches that miner), copies them into the bundle, strips the copies and signs them ad hoc again
(strip invalidates the linker signature and arm64 macOS refuses an unsigned binary), and checks that each copy still
runs. The originals are untouched. The icon comes from `site/icon-512.png`. `build/` and `dist/` are ignored by git.
## What is on the DMG
| Item | What it is |
|---|---|
| `Igneum Devnet.app` | the launcher bundle (below) |
| `README.txt` | 9 lines for the friend (`dmg/README.txt`) |
| `Stop Igneum.command` | stops a copy whose window was closed without Ctrl+C (`app/Stop Igneum.command`) |
| `Applications` | symlink, for the drag |
## The app bundle
Igneum Devnet.app/Contents/MacOS/Igneum Devnet app/launcher.sh: removes quarantine from Resources, opens the script in Terminal
Igneum Devnet.app/Contents/Resources/igneum-devnet.sh the run script (app/igneum-devnet.sh)
Igneum Devnet.app/Contents/Resources/bin/igneumd, igneum-miner, igneum-bench
Igneum Devnet.app/Contents/Resources/AppIcon.icns
Igneum Devnet.app/Contents/Info.plist app/Info.plist (CFBundleVersion = build stamp)
Why a shell launcher and not an AppleScript applet: `open -a Terminal <script>` needs no Automation consent;
`tell application "Terminal" to do script` would prompt "Igneum Devnet wants to control Terminal" on first run.
The launcher strips `com.apple.quarantine` from its own Resources first, because after Gatekeeper lets the app
through, every binary inside would still be checked on its first exec and refused as unidentified. That only works on
a writable volume, so the app refuses (with a dialog) to run straight from the DMG.
## The run script (settings at the top)
| Variable | Default | Meaning |
|---|---|---|
| `SEED_PEERS` | `192.168.68.64:26611` | the Mac node on the project lead's LAN; a public seed node replaces it; comma list allowed |
| `MINERS` | `1` | miner identities; 0 = node only. One is right: one worker owns the whole GPU |
| `STATUS_SECS`, `UPLOAD_SECS` | 30, 60 | status line and log upload periods |
| `IGNEUM_RPC_PORT`, `IGNEUM_P2P_PORT` | 26610, 26611 | environment overrides (tests) |
| `IGNEUM_DATA`, `IGNEUM_LOGS` | `~/Library/Application Support/Igneum/devnet`, `~/Library/Logs/Igneum` | the only places written outside the bundle |
Order: checks (arm64, binaries, ports, no second copy via the pid file) > `caffeinate -dims -w <launcher>` >
`igneumd --devnet --appdir ... --rpclisten 127.0.0.1:26610 --listen 0.0.0.0:26611 --addpeer <each seed> --nodnsseed
--disable-upnp --nologfiles` > every 5 s `igneum-miner watch 1` until synced (peers > 0, blocks >= headers > 1 and
the count moving between readings, or stable for 60 s; this build's watch line has no synced= field) > `igneum-miner
mine grpc://127.0.0.1:26610 1 100000000 mac-<hostname> --worker igneum-bench --status-secs 30 --exit-on-seed-change
--payout-label mac-<hostname>` > loop: status every 30 s, uploads every 60 s (labels `mac-<host>`, `nodelog-mac-<host>`,
`miner-mac-<host>`, run id `mac-<host>-<stamp>`), node crash restarted after 5 to 60 s with the miners stopped until it
is synced again, miner exit 42 (hourly program change) restarted at once, other miner exits after 5 to 60 s.
Stopping: Ctrl+C (SIGINT), the window closing (SIGHUP) or SIGTERM set a flag; the loop then stops the miners and their
workers (TERM, 8 s, KILL), the node (TERM, 30 s, KILL), caffeinate, uploads the logs once more and prints a summary.
Children are started with SIGHUP ignored (bash already makes background children of a script ignore SIGINT), so the
launcher controls the order. `igneum-devnet.sh --stop` and `Stop Igneum.command` signal the launcher from the pid file
and fall back to `pkill -f 'Igneum Devnet.app/Contents/Resources/bin/'`.
The miner identity shows on igneum.network/live as the first 8 hex of its vote key hash; the window prints it.
## Gatekeeper (expected on macOS 15 and 26)
The app is unsigned and not notarized. Double-click gives "Apple could not verify ... is free of malware" with no
Open button. Right-click > Open offers Open on older systems; on macOS 15 and later the route is System Settings >
Privacy & Security > "Open Anyway" (the button appears after the first refusal), then open the app again. Both are in
README.txt. Removing the flag by hand also works: `xattr -dr com.apple.quarantine "/Applications/Igneum Devnet.app"`.
The first time igneumd listens on 0.0.0.0:26611 the macOS firewall (if on) asks to allow incoming connections; Allow.
## Test (3 October 2026, this Mac, macOS 26.6.2, M5 Max)
Mounted the DMG and ran the script from the mounted bundle with `SEED_PEERS=127.0.0.1:26611 MINERS=1
IGNEUM_RPC_PORT=27310 IGNEUM_P2P_PORT=27311 IGNEUM_DATA=/tmp/igneum-mac-test/data IGNEUM_LOGS=/tmp/igneum-mac-test/logs`
(the live node as the seed, own node on 27300+, the Metal miner against that node).
| Check | Result |
|---|---|
| fresh database to synced | 22 s (0 blocks, 5,017 headers at 5 s; 9,988 blocks at 22 s); existing database 12 to 13 s |
| miner | started at sync, id 0aa660fe printed, first block 6 to 11 s later, 24 accepted blocks in 3 min 17 s, 0 rejected, 0 mismatched |
| rate | 16 to 18 MH/s wall, 40 MH/s inside jobs (the Mac was running two cargo builds and a census) |
| status line | every 30 s: `status: accepted 22 blocks, 17.76 MH/s, template 0.89 s old \| node 10205 blocks, 1 peers, synced \| up 00:03:02` |
| uploads | 12 uploads in the intake under run id mac-MacBook-Pro-20261003-224420 (`node tools/logs.mjs`) |
| live page | 0aa660fe listed in igneum.network/api/live miners within a minute |
| Ctrl+C (SIGINT) | miners, worker, node gone in 5 s, summary printed, no stray process, pid and run files removed, ports free |
| SIGTERM | same, 4 s |
| Stop Igneum.command | node-only run stopped in 2 s; a second run says "Igneum is not running" |
Two things the test caught and fixed: an unquoted `$NODE_EXE` in the version line (the bundle path has a space), and
`uname -m` as the Apple silicon check (an x86_64 process, Rosetta, reports x86_64; now `sysctl hw.optional.arm64`).
Note for harness tests: a script started as a background job of a non-interactive shell has SIGINT ignored at entry
and cannot trap it; run it in the foreground or through `perl -e '$SIG{INT}="DEFAULT"; exec ...'`.

View file

@ -0,0 +1,32 @@
<?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>Igneum Devnet</string>
<key>CFBundleDisplayName</key>
<string>Igneum Devnet</string>
<key>CFBundleIdentifier</key>
<string>network.igneum.devnet</string>
<key>CFBundleVersion</key>
<string>VERSION_STAMP</string>
<key>CFBundleShortVersionString</key>
<string>0.1</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleExecutable</key>
<string>Igneum Devnet</string>
<key>CFBundleIconFile</key>
<string>AppIcon</string>
<key>LSMinimumSystemVersion</key>
<string>13.3</string>
<key>LSArchitecturePriority</key>
<array>
<string>arm64</string>
</array>
<key>LSRequiresNativeExecution</key>
<true/>
<key>NSHumanReadableCopyright</key>
<string>Igneum devnet. Nothing is bought or sold.</string>
</dict>
</plist>

View file

@ -0,0 +1,20 @@
#!/bin/bash
# Stops a running Igneum Devnet (miners, Metal worker, then the node) when its window was closed without Ctrl+C.
# Finds the running copy through the run file the launcher writes, and falls back to the process list.
DATA="${IGNEUM_DATA:-$HOME/Library/Application Support/Igneum/devnet}"
RUNFILE="$DATA/igneum-devnet.run"
script="$(sed -n 's/^script=//p' "$RUNFILE" 2>/dev/null)"
if [ -n "$script" ] && [ -x "$script" ]; then
exec "$script" --stop
fi
left="$(pgrep -f 'Igneum Devnet.app/Contents/Resources/bin/' 2>/dev/null)"
if [ -n "$left" ]; then
echo "stopping Igneum processes: $(echo $left | tr '\n' ' ')"
pkill -TERM -f 'Igneum Devnet.app/Contents/Resources/bin/' 2>/dev/null
sleep 5
pkill -KILL -f 'Igneum Devnet.app/Contents/Resources/bin/' 2>/dev/null
rm -f "$DATA/igneum-devnet.pid" "$RUNFILE"
echo "Igneum is stopped."
else
echo "Igneum is not running."
fi

View file

@ -0,0 +1,454 @@
#!/bin/bash
# Igneum devnet for Apple silicon Macs: the node and the Metal miner in one Terminal window.
# Lives in "Igneum Devnet.app/Contents/Resources" next to bin/ (igneumd, igneum-miner, igneum-bench).
# The app's launcher opens this script in Terminal. 3 October 2026.
#
# What it does, in order:
# 1. Checks the Mac (Apple silicon), the binaries, the ports, and that no other copy is running.
# 2. Starts igneumd --devnet with the database under ~/Library/Application Support/Igneum/devnet,
# RPC on 127.0.0.1:26610, p2p on 0.0.0.0:26611, peered to SEED_PEERS (--addpeer, never --connect,
# so the seed node can dial back in). The node's output goes to ~/Library/Logs/Igneum/node-<stamp>.log.
# 3. Waits until the node is synced ("syncing, N blocks" every 5 s meanwhile). The reading is
# `igneum-miner watch`: peers > 0, blocks caught up with headers, and the block count moving.
# 4. Starts MINERS identities of igneum-miner with the Metal worker (igneum-bench --serve), identity and
# payout label mac-<hostname> (mac-<hostname>-2, -3 ... for more). One identity is plenty: one worker
# owns the whole GPU.
# 5. Prints a plain status line every 30 s, uploads the logs to the intake every 60 s, keeps the Mac
# awake with caffeinate, restarts the node (5 to 60 s later) or a miner after a crash, restarts a
# miner at once when it exits with code 42 (the hourly program change).
# 6. Ctrl+C, closing the window (SIGHUP) or `igneum-devnet.sh --stop` stops the miners, their workers,
# then the node, then uploads the logs one last time.
#
# Nothing is written outside the app except the data directory and the log directory below.
# ---- settings ---------------------------------------------------------------------------------------------
# The seed node(s) to peer with. For now the project lead's Mac on the house LAN; a public seed node address
# (host:port) replaces it when one exists. A comma list is allowed: "1.2.3.4:26611,5.6.7.8:26611".
SEED_PEERS="${SEED_PEERS:-192.168.68.64:26611}"
# Miner identities on this Mac. 1 is right for one GPU; 0 runs the node alone.
MINERS="${MINERS:-1}"
STATUS_SECS="${STATUS_SECS:-30}"
UPLOAD_SECS="${UPLOAD_SECS:-60}"
RPC_PORT="${IGNEUM_RPC_PORT:-26610}"
P2P_PORT="${IGNEUM_P2P_PORT:-26611}"
DATA="${IGNEUM_DATA:-$HOME/Library/Application Support/Igneum/devnet}"
LOGS="${IGNEUM_LOGS:-$HOME/Library/Logs/Igneum}"
# The log intake (site/api/log.mjs). The key only authorises log uploads and is meant to ship here.
LOG_URL="https://igneum-six.vercel.app/api/log"
LOG_KEY="***INTAKE-KEY-REMOVED***"
LIVE_PAGE="igneum.network/live"
# ---- paths and names --------------------------------------------------------------------------------------
RES="$(cd "$(dirname "$0")" && pwd)"
BIN="$RES/bin"
NODE_EXE="$BIN/igneumd"
MINER_EXE="$BIN/igneum-miner"
WORKER_EXE="$BIN/igneum-bench"
RPC_URL="grpc://127.0.0.1:$RPC_PORT"
PIDFILE="$DATA/igneum-devnet.pid"
RUNFILE="$DATA/igneum-devnet.run"
STAMP="$(date +%Y%m%d-%H%M%S)"
HOST="$(scutil --get LocalHostName 2>/dev/null || hostname -s)"
HOST="$(printf '%s' "$HOST" | tr -c 'A-Za-z0-9-' '-' | sed -e 's/^-*//' -e 's/-*$//')"
[ -n "$HOST" ] || HOST="mac"
LABEL_BASE="mac-$HOST"
RUN_ID="$LABEL_BASE-$STAMP"
LAUNCHER_LOG="$LOGS/igneum-$STAMP.log"
NODE_LOG=""
START_AT=$SECONDS
log() {
local line
line="$(date '+%Y-%m-%d %H:%M:%S') $*"
printf '%s\n' "$line" 2>/dev/null || true
[ -d "$LOGS" ] && printf '%s\n' "$line" >> "$LAUNCHER_LOG" 2>/dev/null
}
alive() { [ -n "$1" ] && kill -0 "$1" 2>/dev/null; }
uptime_txt() {
local s=$(( SECONDS - START_AT ))
printf '%02d:%02d:%02d' $(( s / 3600 )) $(( s % 3600 / 60 )) $(( s % 60 ))
}
# ---- --stop: stop a running instance (also used by "Stop Igneum.command") ----------------------------------
stop_mode() {
local pid="" waited=0 left
[ -f "$PIDFILE" ] && pid="$(cat "$PIDFILE" 2>/dev/null)"
if alive "$pid"; then
echo "stopping Igneum (launcher pid $pid): miners first, then the node"
kill -TERM "$pid" 2>/dev/null
while alive "$pid" && [ $waited -lt 60 ]; do sleep 1; waited=$(( waited + 1 )); done
if alive "$pid"; then echo "the launcher did not stop in 60 s; forcing"; kill -KILL "$pid" 2>/dev/null; fi
else
echo "no running launcher found (no live pid in $PIDFILE)"
fi
# Anything left from this app (a launcher killed with SIGKILL leaves its children behind)
left="$(pgrep -f 'Igneum Devnet.app/Contents/Resources/bin/' 2>/dev/null)"
if [ -n "$left" ]; then
echo "stopping leftover processes: $(echo $left | tr '\n' ' ')"
pkill -TERM -f 'Igneum Devnet.app/Contents/Resources/bin/' 2>/dev/null
sleep 5
pkill -KILL -f 'Igneum Devnet.app/Contents/Resources/bin/' 2>/dev/null
fi
rm -f "$PIDFILE" "$RUNFILE"
echo "Igneum is stopped."
exit 0
}
case "$1" in
--stop) stop_mode ;;
--help|-h) sed -n '2,20p' "$0"; echo; echo "usage: igneum-devnet.sh [--stop] (settings: SEED_PEERS, MINERS, STATUS_SECS, UPLOAD_SECS at the top)"; exit 0 ;;
esac
# ---- 1. checks --------------------------------------------------------------------------------------------
# sysctl rather than uname -m: a Terminal running under Rosetta reports x86_64 from uname on an Apple silicon Mac.
if [ "$(sysctl -n hw.optional.arm64 2>/dev/null)" != "1" ]; then
echo "Igneum devnet needs an Apple silicon Mac (M1 or later). This Mac reports $(uname -m) and no arm64 support. Nothing was started."
exit 1
fi
for f in "$NODE_EXE" "$MINER_EXE" "$WORKER_EXE"; do
if [ ! -x "$f" ]; then echo "missing or not executable: $f. The app is incomplete; copy it again from the DMG."; exit 1; fi
done
mkdir -p "$DATA" "$LOGS" || { echo "cannot create $DATA or $LOGS"; exit 1; }
if [ -f "$PIDFILE" ] && alive "$(cat "$PIDFILE" 2>/dev/null)"; then
echo "Igneum is already running in another window (launcher pid $(cat "$PIDFILE")). Press Ctrl+C there, or run Stop Igneum."
exit 1
fi
for port in "$RPC_PORT" "$P2P_PORT"; do
busy="$(lsof -nP -iTCP:"$port" -sTCP:LISTEN -t 2>/dev/null | head -1)"
if [ -n "$busy" ]; then
echo "port $port is already in use by pid $busy ($(ps -p "$busy" -o comm= 2>/dev/null)). Is a node already running? Run Stop Igneum first."
exit 1
fi
done
case "$MINERS" in ''|*[!0-9]*) echo "MINERS must be a number (got '$MINERS')"; exit 1 ;; esac
echo "$$" > "$PIDFILE"
printf 'pid=%s\nscript=%s\nrpc=%s\nrun=%s\n' "$$" "$RES/igneum-devnet.sh" "$RPC_URL" "$RUN_ID" > "$RUNFILE"
log "Igneum devnet on $HOST (run $RUN_ID)"
log "node: RPC 127.0.0.1:$RPC_PORT, p2p 0.0.0.0:$P2P_PORT, data $DATA"
log "seed peers: $SEED_PEERS; miners: $MINERS; logs: $LOGS"
log "node $("$NODE_EXE" --version 2>&1 | head -1); worker $(basename "$WORKER_EXE") (Metal)"
# ---- keep awake -------------------------------------------------------------------------------------------
caffeinate -dims -w $$ >/dev/null 2>&1 &
CAFFEINATE_PID=$!
log "keeping the Mac awake while this window runs (caffeinate)"
# ---- the node ---------------------------------------------------------------------------------------------
NODE_PID=""; NODE_STARTS=0; NODE_RESTARTS=0; NODE_RESTART_AT=""; NODE_STARTED_AT=0
NODE_SYNCED=0; NODE_SYNCED_AT=""; SYNC_PREV=""; SYNC_STABLE_SINCE=""; NOPEER_WARNED=0
NODE_BLOCKS=0; NODE_HEADERS=0; NODE_PEERS=0; NODE_DAA=0; NODE_READ_OK=0
start_node() {
local args p seg
NODE_STARTS=$(( NODE_STARTS + 1 ))
seg=""; [ $NODE_STARTS -gt 1 ] && seg="-r$NODE_STARTS"
NODE_LOG="$LOGS/node-$STAMP$seg.log"
args=(--devnet "--appdir=$DATA" "--rpclisten=127.0.0.1:$RPC_PORT" "--listen=0.0.0.0:$P2P_PORT" --nodnsseed --disable-upnp --nologfiles)
for p in $(printf '%s' "$SEED_PEERS" | tr ',' ' '); do args+=("--addpeer=$p"); done
# Children ignore SIGHUP (the window closing) so the launcher can stop them in order; bash already makes
# background children ignore SIGINT (Ctrl+C) in a script.
( trap '' HUP; exec "$NODE_EXE" "${args[@]}" ) > "$NODE_LOG" 2>&1 &
NODE_PID=$!
NODE_STARTED_AT=$SECONDS
NODE_RESTART_AT=""
NODE_SYNCED=0; SYNC_PREV=""; SYNC_STABLE_SINCE=""; NODE_READ_OK=0
log "igneumd started (pid $NODE_PID): ${args[*]}"
log "node output: $NODE_LOG"
}
stop_node() {
local waited=0
alive "$NODE_PID" || return 0
kill -TERM "$NODE_PID" 2>/dev/null
while alive "$NODE_PID" && [ $waited -lt 30 ]; do sleep 1; waited=$(( waited + 1 )); done
if alive "$NODE_PID"; then log "the node did not stop in 30 s; forcing"; kill -KILL "$NODE_PID" 2>/dev/null; fi
wait "$NODE_PID" 2>/dev/null
}
# One reading from `igneum-miner watch 1 <rpc>`: the first "node1 blocks=..." line. The watch command sleeps
# 10 s after its sample, so it runs in the background and is stopped once the line is there.
read_node() {
local tmp="$LOGS/.watch-$$.tmp" pid n=0 line
NODE_READ_OK=0
alive "$NODE_PID" || return 1
"$MINER_EXE" watch 1 "$RPC_URL" > "$tmp" 2>/dev/null &
pid=$!
while [ $n -lt 32 ]; do
if grep -q ' node1 blocks=' "$tmp" 2>/dev/null; then break; fi
alive "$pid" || break
sleep 0.25; n=$(( n + 1 ))
done
kill "$pid" 2>/dev/null; wait "$pid" 2>/dev/null
line="$(grep ' node1 blocks=' "$tmp" 2>/dev/null | head -1)"
rm -f "$tmp"
[ -n "$line" ] || return 1
set -- $(printf '%s' "$line" | sed -E 's/.* blocks=([0-9]+) headers=([0-9]+) daa=([0-9]+) tips=([0-9]+) peers=([0-9]+).*/\1 \2 \3 \4 \5/')
[ "$#" -eq 5 ] || return 1
NODE_BLOCKS=$1; NODE_HEADERS=$2; NODE_DAA=$3; NODE_PEERS=$5; NODE_READ_OK=1
return 0
}
# Synced: a peer, blocks caught up with headers, more than genesis, and the count moving between two readings
# (or stable for 60 s with a peer, for a devnet that is not producing blocks right now).
check_sync() {
read_node || return 1
if [ "$NODE_PEERS" -gt 0 ] && [ "$NODE_BLOCKS" -ge "$NODE_HEADERS" ] && [ "$NODE_BLOCKS" -gt 1 ]; then
[ -n "$SYNC_STABLE_SINCE" ] || SYNC_STABLE_SINCE=$SECONDS
if { [ -n "$SYNC_PREV" ] && [ "$NODE_BLOCKS" -gt "$SYNC_PREV" ]; } || [ $(( SECONDS - SYNC_STABLE_SINCE )) -ge 60 ]; then
SYNC_PREV=$NODE_BLOCKS
return 0
fi
else
SYNC_STABLE_SINCE=""
fi
SYNC_PREV=$NODE_BLOCKS
return 1
}
# ---- the miners -------------------------------------------------------------------------------------------
# Per identity i (1-based): label, pid, worker pid, log, restart bookkeeping, accepted blocks of earlier runs.
MINER_LABEL=(); MINER_PID=(); MINER_WORKER=(); MINER_LOG=(); MINER_STARTS=(); MINER_RESTARTS=(); MINER_RESTART_AT=(); MINER_ACC_BASE=(); MINER_ID=()
i=1
while [ $i -le "$MINERS" ]; do
if [ $i -eq 1 ]; then MINER_LABEL[$i]="$LABEL_BASE"; else MINER_LABEL[$i]="$LABEL_BASE-$i"; fi
MINER_PID[$i]=""; MINER_WORKER[$i]=""; MINER_LOG[$i]=""; MINER_STARTS[$i]=0; MINER_RESTARTS[$i]=0; MINER_RESTART_AT[$i]=""; MINER_ACC_BASE[$i]=0; MINER_ID[$i]=""
i=$(( i + 1 ))
done
MINERS_UP=0
start_miner() {
local i=$1 label seg
label="${MINER_LABEL[$i]}"
MINER_STARTS[$i]=$(( ${MINER_STARTS[$i]} + 1 ))
seg=""; [ "${MINER_STARTS[$i]}" -gt 1 ] && seg="-r${MINER_STARTS[$i]}"
MINER_LOG[$i]="$LOGS/miner-$label-$STAMP$seg.log"
MINER_RESTART_AT[$i]=""
( trap '' HUP; exec "$MINER_EXE" mine "$RPC_URL" 1 100000000 "$label" --worker "$WORKER_EXE" --status-secs "$STATUS_SECS" --exit-on-seed-change --payout-label "$label" ) > "${MINER_LOG[$i]}" 2>&1 &
MINER_PID[$i]=$!
MINER_WORKER[$i]=""
log "miner $label started (pid ${MINER_PID[$i]}): igneum-miner mine $RPC_URL 1 100000000 $label --worker igneum-bench --status-secs $STATUS_SECS --exit-on-seed-change --payout-label $label"
log "miner output: ${MINER_LOG[$i]}"
}
# The worker is the miner's child; remembered so it can be stopped if the miner dies without closing it.
note_worker() {
local i=$1
[ -z "${MINER_WORKER[$i]}" ] && alive "${MINER_PID[$i]}" && MINER_WORKER[$i]="$(pgrep -P "${MINER_PID[$i]}" 2>/dev/null | head -1)"
}
accepted_in() { [ -f "$1" ] && grep -c ' ACCEPTED block' "$1" 2>/dev/null || echo 0; }
stop_miner() {
local i=$1 waited=0
note_worker "$i"
alive "${MINER_PID[$i]}" && kill -TERM "${MINER_PID[$i]}" 2>/dev/null
alive "${MINER_WORKER[$i]}" && kill -TERM "${MINER_WORKER[$i]}" 2>/dev/null
while { alive "${MINER_PID[$i]}" || alive "${MINER_WORKER[$i]}"; } && [ $waited -lt 8 ]; do sleep 1; waited=$(( waited + 1 )); done
alive "${MINER_PID[$i]}" && kill -KILL "${MINER_PID[$i]}" 2>/dev/null
alive "${MINER_WORKER[$i]}" && kill -KILL "${MINER_WORKER[$i]}" 2>/dev/null
wait "${MINER_PID[$i]}" 2>/dev/null
MINER_ACC_BASE[$i]=$(( ${MINER_ACC_BASE[$i]} + $(accepted_in "${MINER_LOG[$i]}") ))
MINER_PID[$i]=""; MINER_WORKER[$i]=""
}
start_all_miners() {
local i=1
while [ $i -le "$MINERS" ]; do start_miner $i; i=$(( i + 1 )); done
MINERS_UP=1
}
stop_all_miners() {
local i=1
while [ $i -le "$MINERS" ]; do stop_miner $i; i=$(( i + 1 )); done
MINERS_UP=0
}
# Miner bookkeeping every second: the live id once it is known, exits and restarts.
tend_miners() {
local i=1 rc delay
while [ $i -le "$MINERS" ]; do
if alive "${MINER_PID[$i]}"; then
note_worker "$i"
if [ -z "${MINER_ID[$i]}" ]; then
MINER_ID[$i]="$(sed -n 's/.*vote_key_hash=\([0-9a-f]\{8\}\).*/\1/p' "${MINER_LOG[$i]}" 2>/dev/null | head -1)"
[ -n "${MINER_ID[$i]}" ] && log "miner ${MINER_LABEL[$i]} shows on $LIVE_PAGE as ${MINER_ID[$i]} once it finds a block"
fi
elif [ -n "${MINER_PID[$i]}" ]; then
wait "${MINER_PID[$i]}" 2>/dev/null; rc=$?
alive "${MINER_WORKER[$i]}" && kill -TERM "${MINER_WORKER[$i]}" 2>/dev/null
MINER_ACC_BASE[$i]=$(( ${MINER_ACC_BASE[$i]} + $(accepted_in "${MINER_LOG[$i]}") ))
MINER_PID[$i]=""; MINER_WORKER[$i]=""
if [ "$rc" -eq 42 ]; then
log "miner ${MINER_LABEL[$i]}: the hourly program changed (exit 42); restarting it now"
MINER_RESTART_AT[$i]=$SECONDS
else
delay=$(( RANDOM % 56 + 5 ))
MINER_RESTARTS[$i]=$(( ${MINER_RESTARTS[$i]} + 1 ))
MINER_RESTART_AT[$i]=$(( SECONDS + delay ))
log "miner ${MINER_LABEL[$i]} exited with code $rc; restarting in $delay s (restart ${MINER_RESTARTS[$i]}). Last lines:"
tail -3 "${MINER_LOG[$i]}" 2>/dev/null | while IFS= read -r l; do log " miner: $l"; done
fi
elif [ -n "${MINER_RESTART_AT[$i]}" ] && [ "$SECONDS" -ge "${MINER_RESTART_AT[$i]}" ] && [ "$NODE_SYNCED" -eq 1 ]; then
start_miner $i
fi
i=$(( i + 1 ))
done
}
# ---- status -----------------------------------------------------------------------------------------------
STATUS_COUNT=0
print_status() {
local i=1 acc=0 a st mh age node_txt miner_txt sync_txt
STATUS_COUNT=$(( STATUS_COUNT + 1 ))
if alive "$NODE_PID"; then
if read_node; then
if [ "$NODE_SYNCED" -eq 1 ]; then sync_txt="synced"; else sync_txt="syncing"; fi
node_txt="node $NODE_BLOCKS blocks, $NODE_PEERS peers, $sync_txt"
else
node_txt="node running (pid $NODE_PID), no reading from the RPC yet"
fi
elif [ -n "$NODE_RESTART_AT" ]; then
node_txt="node restarting in $(( NODE_RESTART_AT - SECONDS )) s"
else
node_txt="node not running"
fi
if [ "$MINERS" -eq 0 ]; then
miner_txt="no miner (MINERS=0)"
else
mh="n/a"; age="n/a"
while [ $i -le "$MINERS" ]; do
acc=$(( acc + ${MINER_ACC_BASE[$i]} + $(accepted_in "${MINER_LOG[$i]}") ))
if alive "${MINER_PID[$i]}"; then
st="$(grep ' STATUS ' "${MINER_LOG[$i]}" 2>/dev/null | tail -1)"
if [ -n "$st" ]; then
a="$(printf '%s' "$st" | sed -E 's/.* hash=([0-9.]+) MH\/s wall.* template_age=([0-9.]+)s.*/\1 \2/')"
set -- $a; mh="${1:-n/a}"; age="${2:-n/a}"
fi
fi
i=$(( i + 1 ))
done
if [ "$MINERS_UP" -eq 1 ]; then
miner_txt="accepted $acc blocks, $mh MH/s, template $age s old"
else
miner_txt="accepted $acc blocks, miner waiting for the node"
fi
fi
log "status: $miner_txt | $node_txt | up $(uptime_txt)"
}
# ---- uploads ----------------------------------------------------------------------------------------------
# JSON {label, machine, run_id, lines} with the last 256 KB of the file, as site/api/log.mjs expects.
upload_one() {
local file=$1 label=$2 json="$LOGS/.upload-$$-$RANDOM.json" code
[ -s "$file" ] || return 0
{
printf '{"label":"%s","machine":"%s","run_id":"%s","lines":"' "$label" "$HOST" "$RUN_ID"
tail -c 262144 "$file" | tr -d '\000-\010\013\014\016-\037' | awk 'BEGIN{ORS=""} { gsub(/\\/,"\\\\"); gsub(/"/,"\\\""); gsub(/\t/,"\\t"); printf "%s\\n", $0 }'
printf '"}'
} > "$json"
code="$(curl -sS --max-time 60 -X POST "$LOG_URL" -H "Content-Type: application/json" -H "x-igneum-key: $LOG_KEY" --data-binary "@$json" -o /dev/null -w '%{http_code}' 2>&1)"
rm -f "$json"
printf '%s' "$code"
}
upload_logs() {
local i=1 r
r="$(upload_one "$LAUNCHER_LOG" "$LABEL_BASE")"; [ "$r" = "200" ] || log "upload of the launcher log failed: $r"
r="$(upload_one "$NODE_LOG" "nodelog-$LABEL_BASE")"; [ "$r" = "200" ] || [ -z "$r" ] || log "upload of the node log failed: $r"
while [ $i -le "$MINERS" ]; do
r="$(upload_one "${MINER_LOG[$i]}" "miner-${MINER_LABEL[$i]}")"; [ "$r" = "200" ] || [ -z "$r" ] || log "upload of the miner log failed: $r"
i=$(( i + 1 ))
done
}
UPLOAD_PID=""
upload_logs_bg() {
alive "$UPLOAD_PID" && return 0 # the previous upload is still running
upload_logs &
UPLOAD_PID=$!
}
# ---- stop -------------------------------------------------------------------------------------------------
STOPPING=0; STOP_REASON=""
on_signal() { STOPPING=1; STOP_REASON="$1"; }
trap 'on_signal "Ctrl+C"' INT
trap 'on_signal "stop requested"' TERM
trap 'on_signal "window closed"' HUP
cleanup() {
trap '' INT TERM HUP
local acc=0 i=1
log "$STOP_REASON: stopping the miners, then the node"
[ "$MINERS" -gt 0 ] && stop_all_miners
stop_node
alive "$CAFFEINATE_PID" && kill "$CAFFEINATE_PID" 2>/dev/null
alive "$UPLOAD_PID" && wait "$UPLOAD_PID" 2>/dev/null
while [ $i -le "$MINERS" ]; do acc=$(( acc + ${MINER_ACC_BASE[$i]} )); i=$(( i + 1 )); done
log "SUMMARY after $(uptime_txt): node started $NODE_STARTS time(s), restarts $NODE_RESTARTS, $STATUS_COUNT status lines, $acc accepted blocks; data stays in $DATA"
upload_logs
rm -f "$PIDFILE" "$RUNFILE"
log "stopped. This window can be closed."
}
# ---- run --------------------------------------------------------------------------------------------------
start_node
sleep 2
if ! alive "$NODE_PID"; then
log "igneumd exited at once. Last lines of $NODE_LOG:"
tail -8 "$NODE_LOG" 2>/dev/null | while IFS= read -r l; do log " node: $l"; done
log "Nothing to restart. If it mentions the database, delete \"$DATA\" and start again (the chain resyncs from the seed in seconds)."
alive "$CAFFEINATE_PID" && kill "$CAFFEINATE_PID" 2>/dev/null
rm -f "$PIDFILE" "$RUNFILE"
exit 1
fi
log "running. Press Ctrl+C to stop. Status every $STATUS_SECS s, logs uploaded every $UPLOAD_SECS s."
log "node: syncing, waiting for the first peer ($SEED_PEERS)"
LAST_SYNC_CHECK=$SECONDS
LAST_STATUS=$SECONDS
LAST_UPLOAD=$SECONDS
while [ "$STOPPING" -eq 0 ]; do
sleep 1
[ "$STOPPING" -eq 0 ] || break
# the node: crash, restart
if [ -n "$NODE_PID" ] && ! alive "$NODE_PID" && [ -z "$NODE_RESTART_AT" ]; then
wait "$NODE_PID" 2>/dev/null; rc=$?
NODE_RESTARTS=$(( NODE_RESTARTS + 1 ))
delay=$(( RANDOM % 56 + 5 ))
NODE_RESTART_AT=$(( SECONDS + delay ))
NODE_SYNCED=0
log "igneumd exited with code $rc after $(( SECONDS - NODE_STARTED_AT )) s; restarting in $delay s (restart $NODE_RESTARTS). Last lines:"
tail -5 "$NODE_LOG" 2>/dev/null | while IFS= read -r l; do log " node: $l"; done
if [ "$MINERS_UP" -eq 1 ]; then log "stopping the miners until the node is back and synced (their connection died with it)"; stop_all_miners; fi
fi
if [ -n "$NODE_RESTART_AT" ] && [ "$SECONDS" -ge "$NODE_RESTART_AT" ]; then start_node; fi
# sync, then the miners
if alive "$NODE_PID" && [ "$NODE_SYNCED" -eq 0 ] && [ $(( SECONDS - LAST_SYNC_CHECK )) -ge 5 ]; then
LAST_SYNC_CHECK=$SECONDS
if check_sync; then
NODE_SYNCED=1; NODE_SYNCED_AT=$SECONDS
log "node synced: $NODE_BLOCKS blocks, $NODE_PEERS peer(s), $(( SECONDS - NODE_STARTED_AT )) s after the start"
if [ "$MINERS" -gt 0 ] && [ "$MINERS_UP" -eq 0 ]; then start_all_miners; fi
elif [ "$NODE_READ_OK" -eq 1 ]; then
log "node: syncing, $NODE_BLOCKS blocks ($NODE_HEADERS headers, $NODE_PEERS peers)"
if [ "$NOPEER_WARNED" -eq 0 ] && [ "$NODE_PEERS" -eq 0 ] && [ $(( SECONDS - NODE_STARTED_AT )) -ge 90 ]; then
NOPEER_WARNED=1
log "no peer after 90 s: is the seed node reachable at $SEED_PEERS? The node keeps retrying (backoff up to 8 minutes)."
fi
elif [ $(( SECONDS - NODE_STARTED_AT )) -ge 60 ]; then
log "node: no answer from the RPC at $RPC_URL yet (still opening its database?); see $NODE_LOG"
fi
fi
[ "$MINERS" -gt 0 ] && tend_miners
if [ $(( SECONDS - LAST_STATUS )) -ge "$STATUS_SECS" ]; then LAST_STATUS=$SECONDS; print_status; fi
if [ $(( SECONDS - LAST_UPLOAD )) -ge "$UPLOAD_SECS" ]; then LAST_UPLOAD=$SECONDS; upload_logs_bg; fi
done
cleanup
exit 0

19
packaging/mac/app/launcher.sh Executable file
View file

@ -0,0 +1,19 @@
#!/bin/bash
# Bundle executable of "Igneum Devnet.app" (Contents/MacOS/Igneum Devnet). Finder runs this with no terminal, so
# it opens Resources/igneum-devnet.sh in Terminal and exits. A shell script, not an AppleScript applet: Terminal
# opening a file it was handed by `open -a Terminal` needs no Automation consent, an AppleScript `tell Terminal
# to do script` would prompt "Igneum Devnet wants to control Terminal" on the first run.
# Before that it removes the quarantine flag from the bundle's own files: once the user has let the app through
# Gatekeeper, the binaries inside would still be checked one by one on their first exec and refused as
# unidentified. The flag can only be removed on a writable volume, so the app must be run from Applications
# (or any folder), not straight from the DMG.
CONTENTS="$(cd "$(dirname "$0")/.." && pwd)"
RES="$CONTENTS/Resources"
if ! xattr -dr com.apple.quarantine "$RES" 2>/dev/null; then
if [ -n "$(xattr -p com.apple.quarantine "$RES/igneum-devnet.sh" 2>/dev/null)" ]; then
osascript -e 'display dialog "Drag Igneum Devnet into Applications first, then open it from there. It cannot start from the disk image." buttons {"OK"} default button 1 with title "Igneum Devnet"' >/dev/null 2>&1
exit 1
fi
fi
chmod +x "$RES/igneum-devnet.sh" "$RES/bin/"* 2>/dev/null
exec /usr/bin/open -a Terminal "$RES/igneum-devnet.sh"

82
packaging/mac/build-dmg.sh Executable file
View file

@ -0,0 +1,82 @@
#!/bin/bash
# Builds packaging/mac/dist/igneum-devnet-mac.dmg: "Igneum Devnet.app" + README.txt + "Stop Igneum.command" + a link
# to /Applications. Reuses the binaries already built in the repo (nothing is compiled here); copies of the
# binaries are stripped and re-signed ad hoc (strip invalidates the linker signature, and arm64 macOS refuses to
# run an unsigned binary). The originals are not touched. 3 October 2026.
#
# packaging/mac/build-dmg.sh build
# NODE=<path> packaging/mac/build-dmg.sh use another node binary
set -euo pipefail
HERE="$(cd "$(dirname "$0")" && pwd)"
ROOT="$(cd "$HERE/../.." && pwd)"
NODE="${NODE:-$ROOT/vendor/igneum-node/target-rename/release/igneumd}"
MINER="${MINER:-$ROOT/vendor/igneum-node/target/release/igneum-miner}"
WORKER="${WORKER:-$ROOT/proto-metal/igneum-bench}"
ICON_PNG="$ROOT/site/icon-512.png"
BUILD="$HERE/build"
DIST="$HERE/dist"
DMG="$DIST/igneum-devnet-mac.dmg"
APP="$BUILD/dmg/Igneum Devnet.app"
STAMP="$(date -u +%Y%m%d%H%M)"
if [ ! -x "$NODE" ]; then
if [ -x "$ROOT/vendor/igneum-node/target/release/kaspad" ]; then
echo "note: $NODE is missing; using target/release/kaspad renamed to igneumd"
NODE="$ROOT/vendor/igneum-node/target/release/kaspad"
else
echo "no node binary at $NODE"; exit 1
fi
fi
for f in "$MINER" "$WORKER"; do [ -x "$f" ] || { echo "missing: $f"; exit 1; }; done
for f in "$NODE" "$MINER" "$WORKER"; do
file "$f" | grep -q 'arm64' || { echo "$f is not an arm64 binary"; exit 1; }
done
rm -rf "$BUILD"
mkdir -p "$APP/Contents/MacOS" "$APP/Contents/Resources/bin" "$DIST"
# the bundle
sed "s/VERSION_STAMP/$STAMP/" "$HERE/app/Info.plist" > "$APP/Contents/Info.plist"
printf 'APPL????' > "$APP/Contents/PkgInfo"
cp "$HERE/app/launcher.sh" "$APP/Contents/MacOS/Igneum Devnet"
cp "$HERE/app/igneum-devnet.sh" "$APP/Contents/Resources/igneum-devnet.sh"
cp "$NODE" "$APP/Contents/Resources/bin/igneumd"
cp "$MINER" "$APP/Contents/Resources/bin/igneum-miner"
cp "$WORKER" "$APP/Contents/Resources/bin/igneum-bench"
chmod 755 "$APP/Contents/MacOS/Igneum Devnet" "$APP/Contents/Resources/igneum-devnet.sh" "$APP/Contents/Resources/bin/"*
# strip the copies, then sign them ad hoc again
for b in "$APP/Contents/Resources/bin/"*; do
before=$(stat -f %z "$b")
strip "$b" 2>/dev/null || strip -x "$b"
codesign -s - -f "$b" 2>/dev/null
codesign -v "$b"
echo "$(basename "$b"): $before -> $(stat -f %z "$b") bytes, signed ad hoc"
done
# each copy must still run
v="$("$APP/Contents/Resources/bin/igneumd" --version 2>&1 || true)"; echo "node: ${v%%$'\n'*}"
v="$("$APP/Contents/Resources/bin/igneum-miner" 2>&1 || true)"; case "$v" in usage*) ;; *) echo "igneum-miner copy does not run: $v"; exit 1 ;; esac
v="$(echo quit | "$APP/Contents/Resources/bin/igneum-bench" --serve 2>&1)"; case "$v" in "ready metal"*) echo "worker: $v" ;; *) echo "igneum-bench copy does not serve: $v"; exit 1 ;; esac
# icon from the site's 512 px icon
if [ -f "$ICON_PNG" ]; then
ICONSET="$BUILD/AppIcon.iconset"
mkdir -p "$ICONSET"
for s in 16 32 128 256 512; do
sips -z $s $s "$ICON_PNG" --out "$ICONSET/icon_${s}x${s}.png" >/dev/null
d=$(( s * 2 ))
if [ $d -le 1024 ]; then sips -z $d $d "$ICON_PNG" --out "$ICONSET/icon_${s}x${s}@2x.png" >/dev/null; fi
done
iconutil -c icns "$ICONSET" -o "$APP/Contents/Resources/AppIcon.icns"
fi
# the rest of the image
cp "$HERE/dmg/README.txt" "$BUILD/dmg/README.txt"
cp "$HERE/app/Stop Igneum.command" "$BUILD/dmg/Stop Igneum.command"
chmod 755 "$BUILD/dmg/Stop Igneum.command"
ln -s /Applications "$BUILD/dmg/Applications"
rm -f "$DMG"
hdiutil create -volname "Igneum Devnet" -srcfolder "$BUILD/dmg" -ov -format ULFO -fs HFS+ "$DMG" >/dev/null
hdiutil verify "$DMG" >/dev/null
echo "built $DMG ($(du -h "$DMG" | cut -f1), $(stat -f %z "$DMG") bytes, version $STAMP)"

View file

@ -0,0 +1,9 @@
Igneum Devnet for Mac (Apple silicon only: M1 or later). Nothing is bought or sold; this is a test network.
1. Drag "Igneum Devnet" into Applications. It will not start from this disk image.
2. First time: in Applications, right-click "Igneum Devnet" and choose Open, then Open again. The app is unsigned.
If macOS still refuses, open System Settings > Privacy & Security, scroll down, click "Open Anyway", open the app again.
3. A Terminal window opens. It starts the node, shows "syncing, N blocks" until it has the chain (usually under a minute),
then starts the Metal miner. Every 30 s one status line: accepted blocks, MH/s, template age, node blocks and peers.
4. Your miner appears on igneum.network/live within about a minute of its first block, under the id the window prints.
5. To stop: press Ctrl+C in that window (miners stop first, then the node). If the window was closed, open "Stop Igneum".
The Mac stays awake while the window is open. Data: ~/Library/Application Support/Igneum, logs: ~/Library/Logs/Igneum.