Ship ProdDash as one app: its own Node, its own copy, a .dmg
Installing is dragging ProdDash to Applications. The app carries the
dashboard and the Node that runs it, so nothing has to be installed first and
nothing can go missing underneath it.
ProdDash.app/Contents/
MacOS/ProdDash the launcher
Resources/node/bin/node Node 24.21.0 LTS, fetched and checksum-verified
Resources/app/ server.js, public/, modules/
Everything mutable stays where it already was, in ~/Library/Application
Support/ProdDash — and that now includes app/, the copy of ProdDash that
actually runs. The copy is not ceremony: ProdDash updates itself in place,
and nothing may write inside a signed .app. So the bundle holds a read-only
baseline and the data directory holds the running copy, which means both
routes to a new version keep working and don't fight — installing a newer
.app replaces the copy, while a copy that has already updated itself past the
bundle is left alone.
build.sh now assembles all of it: fetches Node against nodejs.org's published
checksums (cached in .cache/), embeds the server, compiles each module's
native helper so the Mac running ProdDash needs no compiler, signs nested
code before the bundle, and builds the disk image. --dev skips the payload
for launcher work, which still runs against a checkout.
Signing is set up for a Developer ID with the hardened runtime and
notarisation (--notarize), and falls back to ad-hoc when no such certificate
is installed. Entitlements are split by what each binary actually does: JIT
for node, audio input for the launcher and for module helpers that read one.
Built and run end to end: first-run install, bundled node, dashboard and
admin serving, re-seed in both directions (newer app replaces an older copy;
an updated copy survives), disk image mounts and its app verifies.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
e1bceb3e60
commit
adac3a4785
+161
-53
@@ -1,72 +1,92 @@
|
||||
#!/bin/zsh
|
||||
# Build ProdDash.app — the menu-bar launcher.
|
||||
# Build ProdDash.app — the whole application: launcher, server and its own
|
||||
# Node runtime, with nothing to install first.
|
||||
#
|
||||
# ./build.sh build into launcher/macos/build/ProdDash.app
|
||||
# ./build.sh build/ProdDash.app, self-contained
|
||||
# ./build.sh --dmg …and build/ProdDash-<version>-arm64.dmg
|
||||
# ./build.sh --install …and copy it to /Applications
|
||||
# ./build.sh --run …and launch it when it's built
|
||||
# ./build.sh --arch native build for this Mac only (faster; default is universal)
|
||||
# ./build.sh --run …and launch it
|
||||
# ./build.sh --notarize with --dmg: notarise and staple (needs a
|
||||
# Developer ID and a notarytool keychain profile)
|
||||
# ./build.sh --dev launcher only — no Node, no server payload, so
|
||||
# it runs against the checkout. Seconds, not
|
||||
# minutes, while working on the launcher itself.
|
||||
#
|
||||
# Needs the Xcode command line tools (swiftc). No other dependencies — the app
|
||||
# is Swift and system frameworks, nothing to install and nothing to vendor.
|
||||
# Signing: a "Developer ID Application" certificate is used when one is
|
||||
# installed (override with CODESIGN_IDENTITY). Without one the app is signed
|
||||
# ad-hoc, which is fine on your own Macs but makes another Mac ask before it
|
||||
# will open the app.
|
||||
#
|
||||
# Needs the Xcode command line tools. Everything else it fetches or builds.
|
||||
set -e -u
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
APP_NAME="ProdDash"
|
||||
BUNDLE_ID="org.waterschurch.proddash"
|
||||
LAUNCHER_VERSION="1.0.0"
|
||||
# The Node the app ships. Bump it here; the download is checksum-verified
|
||||
# against nodejs.org's own SHASUMS256.txt and cached in .cache/.
|
||||
NODE_VERSION="v24.21.0"
|
||||
ARCH="arm64"
|
||||
DEPLOYMENT_TARGET="13.0"
|
||||
|
||||
REPO_ROOT="../.."
|
||||
OUT_DIR="build"
|
||||
CACHE_DIR=".cache"
|
||||
APP="$OUT_DIR/$APP_NAME.app"
|
||||
ARCHS=(arm64 x86_64)
|
||||
CONTENTS="$APP/Contents"
|
||||
PAYLOAD="$CONTENTS/Resources/app"
|
||||
|
||||
DEV=0
|
||||
INSTALL=0
|
||||
RUN=0
|
||||
MAKE_DMG=0
|
||||
NOTARIZE=0
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--dev) DEV=1 ;;
|
||||
--install) INSTALL=1 ;;
|
||||
--run) RUN=1 ;;
|
||||
--arch) shift; [[ "${1:-}" == "native" ]] && ARCHS=($(uname -m)) || ARCHS=("${1:-arm64}") ;;
|
||||
-h|--help) sed -n '2,10p' "$0"; exit 0 ;;
|
||||
--dmg) MAKE_DMG=1 ;;
|
||||
--notarize) NOTARIZE=1; MAKE_DMG=1 ;;
|
||||
-h|--help) sed -n '2,22p' "$0"; exit 0 ;;
|
||||
*) echo "build.sh: unknown option $1" >&2; exit 2 ;;
|
||||
esac
|
||||
shift
|
||||
done
|
||||
|
||||
if ! command -v swiftc >/dev/null; then
|
||||
command -v swiftc >/dev/null || {
|
||||
echo "build.sh: swiftc not found — install the Xcode command line tools:" >&2
|
||||
echo " xcode-select --install" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
VERSION=$(/usr/libexec/PlistBuddy -c 'Print :CFBundleShortVersionString' Resources/Info.plist)
|
||||
echo "ProdDash Launcher $VERSION — building for ${ARCHS[*]}"
|
||||
# The product's version is ProdDash's own; the launcher keeps its own number
|
||||
# for when something is wrong with the launcher rather than the dashboard.
|
||||
VERSION=$(sed -n 's/.*"version" *: *"\([^"]*\)".*/\1/p' "$REPO_ROOT/package.json" | head -1)
|
||||
[[ -n "$VERSION" ]] || { echo "build.sh: can't read the version from package.json" >&2; exit 1; }
|
||||
|
||||
echo "ProdDash $VERSION (launcher $LAUNCHER_VERSION) — building for $ARCH"
|
||||
|
||||
rm -rf "$OUT_DIR"
|
||||
mkdir -p "$APP/Contents/MacOS" "$APP/Contents/Resources" "$OUT_DIR/obj"
|
||||
mkdir -p "$CONTENTS/MacOS" "$CONTENTS/Resources"
|
||||
|
||||
# One binary per architecture, then lipo them together, so the same .app runs
|
||||
# on the booth's Apple Silicon and on any Intel Mac still in service.
|
||||
SLICES=()
|
||||
for arch in "${ARCHS[@]}"; do
|
||||
echo " compiling $arch"
|
||||
swiftc -O -whole-module-optimization \
|
||||
-target "$arch-apple-macos13.0" \
|
||||
-module-name ProdDashLauncher \
|
||||
-o "$OUT_DIR/obj/$APP_NAME-$arch" \
|
||||
Sources/*.swift
|
||||
SLICES+=("$OUT_DIR/obj/$APP_NAME-$arch")
|
||||
done
|
||||
# ── the launcher ─────────────────────────────────────────────────────────
|
||||
echo " compiling the launcher"
|
||||
swiftc -O -whole-module-optimization \
|
||||
-target "$ARCH-apple-macos$DEPLOYMENT_TARGET" \
|
||||
-module-name ProdDashLauncher \
|
||||
-o "$CONTENTS/MacOS/$APP_NAME" \
|
||||
Sources/*.swift
|
||||
|
||||
if [[ ${#SLICES[@]} -gt 1 ]]; then
|
||||
lipo -create "${SLICES[@]}" -output "$APP/Contents/MacOS/$APP_NAME"
|
||||
else
|
||||
cp "${SLICES[1]}" "$APP/Contents/MacOS/$APP_NAME"
|
||||
fi
|
||||
chmod +x "$APP/Contents/MacOS/$APP_NAME"
|
||||
cp Resources/Info.plist "$CONTENTS/Info.plist"
|
||||
/usr/libexec/PlistBuddy -c "Set :CFBundleShortVersionString $VERSION" "$CONTENTS/Info.plist" >/dev/null
|
||||
/usr/libexec/PlistBuddy -c "Set :CFBundleVersion $VERSION" "$CONTENTS/Info.plist" >/dev/null
|
||||
/usr/libexec/PlistBuddy -c "Set :ProdDashLauncherVersion $LAUNCHER_VERSION" "$CONTENTS/Info.plist" >/dev/null
|
||||
printf 'APPL????' > "$CONTENTS/PkgInfo"
|
||||
|
||||
cp Resources/Info.plist "$APP/Contents/Info.plist"
|
||||
printf 'APPL????' > "$APP/Contents/PkgInfo"
|
||||
|
||||
# App icon: ProdDash's own, straight from the web app's icons.
|
||||
SOURCE_ICON="../../public/icons/icon-512.png"
|
||||
SOURCE_ICON="$REPO_ROOT/public/icons/icon-512.png"
|
||||
if [[ -f "$SOURCE_ICON" ]]; then
|
||||
echo " building the icon"
|
||||
ICONSET="$OUT_DIR/AppIcon.iconset"
|
||||
@@ -74,33 +94,122 @@ if [[ -f "$SOURCE_ICON" ]]; then
|
||||
for size in 16 32 64 128 256 512 1024; do
|
||||
sips -z $size $size "$SOURCE_ICON" --out "$ICONSET/icon_${size}x${size}.png" >/dev/null 2>&1
|
||||
done
|
||||
# iconutil wants Apple's names, including the @2x pairs.
|
||||
mv "$ICONSET/icon_32x32.png" "$ICONSET/icon_16x16@2x.png"
|
||||
cp "$ICONSET/icon_64x64.png" "$ICONSET/icon_32x32@2x.png"
|
||||
mv "$ICONSET/icon_64x64.png" "$ICONSET/icon_32x32.png"
|
||||
cp "$ICONSET/icon_256x256.png" "$ICONSET/icon_128x128@2x.png"
|
||||
cp "$ICONSET/icon_512x512.png" "$ICONSET/icon_256x256@2x.png"
|
||||
mv "$ICONSET/icon_1024x1024.png" "$ICONSET/icon_512x512@2x.png"
|
||||
iconutil -c icns "$ICONSET" -o "$APP/Contents/Resources/AppIcon.icns"
|
||||
iconutil -c icns "$ICONSET" -o "$CONTENTS/Resources/AppIcon.icns"
|
||||
rm -rf "$ICONSET"
|
||||
else
|
||||
echo " (no $SOURCE_ICON — the app will use the generic icon)"
|
||||
fi
|
||||
|
||||
rm -rf "$OUT_DIR/obj"
|
||||
# ── the server, and the Node that runs it ────────────────────────────────
|
||||
if [[ $DEV -eq 0 ]]; then
|
||||
TARBALL="node-$NODE_VERSION-darwin-$ARCH.tar.gz"
|
||||
mkdir -p "$CACHE_DIR"
|
||||
if [[ ! -f "$CACHE_DIR/$TARBALL" ]]; then
|
||||
echo " fetching Node $NODE_VERSION (~50 MB, cached for next time)"
|
||||
curl -fsSL --retry 3 -o "$CACHE_DIR/$TARBALL.part" "https://nodejs.org/dist/$NODE_VERSION/$TARBALL"
|
||||
mv "$CACHE_DIR/$TARBALL.part" "$CACHE_DIR/$TARBALL"
|
||||
fi
|
||||
SUMS="$CACHE_DIR/SHASUMS256-$NODE_VERSION.txt"
|
||||
[[ -f "$SUMS" ]] || curl -fsSL --retry 3 -o "$SUMS" "https://nodejs.org/dist/$NODE_VERSION/SHASUMS256.txt"
|
||||
# This binary is about to be signed with your name on it — check it every build.
|
||||
WANT=$(awk -v f="$TARBALL" '$2 == f {print $1}' "$SUMS")
|
||||
GOT=$(shasum -a 256 "$CACHE_DIR/$TARBALL" | awk '{print $1}')
|
||||
if [[ -z "$WANT" || "$WANT" != "$GOT" ]]; then
|
||||
echo "build.sh: $TARBALL does not match nodejs.org's checksum — refusing to ship it" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo " embedding Node $NODE_VERSION"
|
||||
mkdir -p "$CONTENTS/Resources/node/bin"
|
||||
tar -xzf "$CACHE_DIR/$TARBALL" -C "$CONTENTS/Resources/node/bin" --strip-components 2 \
|
||||
"node-$NODE_VERSION-darwin-$ARCH/bin/node"
|
||||
|
||||
# Ad-hoc signature: enough for macOS to run it and to hang privacy permissions
|
||||
# off it. Note that the signature changes on every rebuild, so macOS may treat
|
||||
# a rebuilt app as a new one and ask for the microphone again.
|
||||
codesign --force --sign - --identifier "$BUNDLE_ID" "$APP" >/dev/null 2>&1 \
|
||||
&& echo " signed (ad-hoc)" \
|
||||
|| echo " warning: could not sign the app — permissions may not stick"
|
||||
echo " embedding ProdDash $VERSION"
|
||||
mkdir -p "$PAYLOAD/config"
|
||||
cp "$REPO_ROOT/server.js" "$REPO_ROOT/package.json" "$PAYLOAD/"
|
||||
cp -R "$REPO_ROOT/public" "$REPO_ROOT/modules" "$PAYLOAD/"
|
||||
# Only the checked-in defaults: config/ also holds this machine's own
|
||||
# modules.json and layouts when ProdDash has ever run from the checkout.
|
||||
cp "$REPO_ROOT/config/proddash.json" "$PAYLOAD/config/"
|
||||
find "$PAYLOAD" -name '.DS_Store' -delete
|
||||
|
||||
echo "built $APP"
|
||||
# A module's native helper is built here, so the Mac running ProdDash needs
|
||||
# no compiler: <name>.swift beside a module's files becomes <name>.
|
||||
for source in "$PAYLOAD"/modules/*/*.swift; do
|
||||
[[ -e "$source" ]] || continue
|
||||
helper="${source%.swift}"
|
||||
echo " compiling $(basename "$(dirname "$source")")/$(basename "$helper")"
|
||||
swiftc -O -target "$ARCH-apple-macos$DEPLOYMENT_TARGET" -o "$helper" "$source"
|
||||
done
|
||||
fi
|
||||
|
||||
# ── signing ──────────────────────────────────────────────────────────────
|
||||
IDENTITY="${CODESIGN_IDENTITY:-}"
|
||||
if [[ -z "$IDENTITY" ]]; then
|
||||
IDENTITY=$(security find-identity -v -p codesigning 2>/dev/null \
|
||||
| awk -F'"' '/Developer ID Application/ {print $2; exit}')
|
||||
fi
|
||||
if [[ -n "$IDENTITY" ]]; then
|
||||
echo " signing as $IDENTITY"
|
||||
SIGN=(--force --sign "$IDENTITY" --options runtime --timestamp)
|
||||
else
|
||||
echo " signing ad-hoc (no Developer ID installed — other Macs will ask before opening it)"
|
||||
IDENTITY="-"
|
||||
SIGN=(--force --sign -)
|
||||
fi
|
||||
|
||||
# Nested code first, outermost last, or the outer signature seals a lie.
|
||||
if [[ -f "$CONTENTS/Resources/node/bin/node" ]]; then
|
||||
codesign "${SIGN[@]}" --entitlements Resources/node.entitlements "$CONTENTS/Resources/node/bin/node"
|
||||
fi
|
||||
if [[ $DEV -eq 0 ]]; then
|
||||
for helper in "$PAYLOAD"/modules/*/*; do
|
||||
# A compiled helper is the extensionless executable beside a module's
|
||||
# sources — test the name, not the path, which has dots all over it.
|
||||
name=$(basename "$helper")
|
||||
[[ -f "$helper" && -x "$helper" && "$name" != *.* ]] || continue
|
||||
echo " signing $name"
|
||||
codesign "${SIGN[@]}" --entitlements Resources/helper.entitlements "$helper"
|
||||
done
|
||||
fi
|
||||
codesign "${SIGN[@]}" --entitlements Resources/ProdDash.entitlements --identifier "$BUNDLE_ID" "$APP"
|
||||
codesign --verify --deep --strict "$APP" && echo " signature verifies"
|
||||
|
||||
SIZE=$(du -sh "$APP" | awk '{print $1}')
|
||||
echo "built $APP ($SIZE)"
|
||||
|
||||
# ── disk image ───────────────────────────────────────────────────────────
|
||||
if [[ $MAKE_DMG -eq 1 ]]; then
|
||||
DMG="$OUT_DIR/$APP_NAME-$VERSION-$ARCH.dmg"
|
||||
STAGE="$OUT_DIR/dmg"
|
||||
rm -rf "$STAGE"
|
||||
mkdir -p "$STAGE"
|
||||
cp -R "$APP" "$STAGE/"
|
||||
ln -s /Applications "$STAGE/Applications" # drag-to-install, the usual way
|
||||
hdiutil create -volname "$APP_NAME $VERSION" -srcfolder "$STAGE" \
|
||||
-ov -format UDZO -quiet "$DMG"
|
||||
rm -rf "$STAGE"
|
||||
[[ "$IDENTITY" == "-" ]] || codesign --force --sign "$IDENTITY" --timestamp "$DMG"
|
||||
echo "built $DMG ($(du -sh "$DMG" | awk '{print $1}'))"
|
||||
|
||||
if [[ $NOTARIZE -eq 1 ]]; then
|
||||
if [[ "$IDENTITY" == "-" ]]; then
|
||||
echo "build.sh: can't notarise an ad-hoc signed app — a Developer ID is required" >&2
|
||||
exit 1
|
||||
fi
|
||||
PROFILE="${NOTARY_PROFILE:-proddash}"
|
||||
echo " notarising as keychain profile \"$PROFILE\" (a few minutes)"
|
||||
xcrun notarytool submit "$DMG" --keychain-profile "$PROFILE" --wait
|
||||
xcrun stapler staple "$DMG"
|
||||
echo " notarised and stapled — this opens anywhere with no warning"
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ $INSTALL -eq 1 ]]; then
|
||||
DEST="/Applications/$APP_NAME.app"
|
||||
# A running copy holds its own bundle open; stop it before replacing it.
|
||||
osascript -e "tell application \"$APP_NAME\" to quit" >/dev/null 2>&1 || true
|
||||
sleep 1
|
||||
rm -rf "$DEST"
|
||||
@@ -109,6 +218,5 @@ if [[ $INSTALL -eq 1 ]]; then
|
||||
APP="$DEST"
|
||||
fi
|
||||
|
||||
if [[ $RUN -eq 1 ]]; then
|
||||
open "$APP"
|
||||
fi
|
||||
[[ $RUN -eq 1 ]] && open "$APP"
|
||||
exit 0
|
||||
|
||||
Reference in New Issue
Block a user