#!/usr/bin/env bash set -euo pipefail # Prole build script — builds a macOS .app bundle without launching Xcode # Requirements: Xcode Command Line Tools (swiftc, codesign, plutil, lipo) APP_NAME="Prole" BUNDLE_ID="org.prole.${APP_NAME}" MIN_MACOS="12.0" SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" ROOT_DIR="$SCRIPT_DIR" SRC_DIR="$ROOT_DIR/Sources" BUILD_DIR="$ROOT_DIR/.build-cli" DIST_DIR="$ROOT_DIR/dist" APP_DIR="$DIST_DIR/${APP_NAME}.app" CONTENTS_DIR="$APP_DIR/Contents" MACOS_DIR="$CONTENTS_DIR/MacOS" RESOURCES_DIR="$CONTENTS_DIR/Resources" INFO_PLIST="$CONTENTS_DIR/Info.plist" PARENT_DIR="$(cd "$ROOT_DIR/.." && pwd)" ARCH_CURRENT="$(uname -m)" # arm64 or x86_64 usage() { cat < Build for a specific arch (arm64 or x86_64). Defaults to host arch. Examples: ./build.sh build ./build.sh build --arch arm64 ./build.sh build-universal ./build.sh run ./build.sh package EOF } ensure_dirs() { mkdir -p "$BUILD_DIR" "$DIST_DIR" "$MACOS_DIR" "$RESOURCES_DIR" } # Read a key from prole.properties (very simple parser) prop_get() { local key="$1" local file="$ROOT_DIR/prole.properties" if [[ -f "$file" ]]; then local line line=$(grep -E "^${key}=" "$file" | tail -n1 || true) if [[ -n "$line" ]]; then echo "${line#*=}" return 0 fi fi return 1 } gen_statusbar_icon_png() { # Generates a small monochrome template PNG for the status bar (18x18) local out_png="$RESOURCES_DIR/statusIcon.png" # Render a simple 'P' using AppKit to avoid extra deps /usr/bin/env xcrun swift -F /System/Library/PrivateFrameworks - <<'SWIFT' "$out_png" import AppKit import Foundation let args = CommandLine.arguments guard args.count > 1 else { exit(2) } let path = args[1] let size = NSSize(width: 18, height: 18) let img = NSImage(size: size) img.lockFocus() NSColor.clear.setFill() NSBezierPath(rect: NSRect(origin: .zero, size: size)).fill() let paragraph = NSMutableParagraphStyle() paragraph.alignment = .center let attrs: [NSAttributedString.Key: Any] = [ .font: NSFont.monospacedSystemFont(ofSize: 14, weight: .bold), .foregroundColor: NSColor.labelColor, .paragraphStyle: paragraph ] let s = NSString(string: "P") let rect = NSRect(x: 0, y: -2, width: size.width, height: size.height) s.draw(in: rect, withAttributes: attrs) img.unlockFocus() guard let tiff = img.tiffRepresentation, let rep = NSBitmapImageRep(data: tiff), let png = rep.representation(using: .png, properties: [:]) else { exit(3) } try! png.write(to: URL(fileURLWithPath: path)) SWIFT # Mark as template via extended attribute for system tinting (optional) } gen_app_icns() { # Create an .icns for the app. Prefer a configured source image (ui.icon), # otherwise fall back to generating a bold 'P'. local icon_name="${APP_NAME}" local iconset_dir="$BUILD_DIR/${icon_name}.iconset" rm -rf "$iconset_dir" mkdir -p "$iconset_dir" local base_png="$BUILD_DIR/${icon_name}_1024.png" # Try configured ui.icon relative to repo root local ui_icon ui_icon="$(prop_get ui.icon || true)" if [[ -n "$ui_icon" && -f "$PARENT_DIR/$ui_icon" ]]; then # Use the provided icon image as base; if not already 1024x1024, sips will resize for each size cp "$PARENT_DIR/$ui_icon" "$base_png" else # Generate a 1024 base PNG using AppKit so we don't depend on ImageMagick/Pillow /usr/bin/env xcrun swift -F /System/Library/PrivateFrameworks - <<'SWIFT' "$base_png" import AppKit import Foundation let args = CommandLine.arguments guard args.count > 1 else { exit(2) } let outPath = args[1] let size = NSSize(width: 1024, height: 1024) let img = NSImage(size: size) img.lockFocus() NSColor.clear.setFill() NSBezierPath(rect: NSRect(origin: .zero, size: size)).fill() // Draw a rounded rect background to make it look like an app icon let bgRect = NSRect(x: 0, y: 0, width: 1024, height: 1024) let radius: CGFloat = 220 let roundRectPath = NSBezierPath(roundedRect: bgRect, xRadius: radius, yRadius: radius) NSColor.windowBackgroundColor.setFill() roundRectPath.fill() // Draw the letter 'P' centered let paragraph = NSMutableParagraphStyle() paragraph.alignment = .center let attrs: [NSAttributedString.Key: Any] = [ .font: NSFont.monospacedSystemFont(ofSize: 720, weight: .black), .foregroundColor: NSColor.labelColor, .paragraphStyle: paragraph ] let s = NSString(string: "P") let rect = NSRect(x: 0, y: 70, width: 1024, height: 820) s.draw(in: rect, withAttributes: attrs) img.unlockFocus() guard let tiff = img.tiffRepresentation, let rep = NSBitmapImageRep(data: tiff), let png = rep.representation(using: .png, properties: [:]) else { exit(3) } try! png.write(to: URL(fileURLWithPath: outPath)) SWIFT fi # Create all iconset sizes from the base image for s in 16 32 128 256 512; do /usr/bin/sips -z "$s" "$s" "$base_png" --out "$iconset_dir/icon_${s}x${s}.png" >/dev/null /usr/bin/sips -z "$((s*2))" "$((s*2))" "$base_png" --out "$iconset_dir/icon_${s}x${s}@2x.png" >/dev/null done # Build icns /usr/bin/iconutil -c icns "$iconset_dir" -o "$RESOURCES_DIR/${icon_name}.icns" } gen_plist() { cat > "$INFO_PLIST" < CFBundleDevelopmentRegionen CFBundleExecutable${APP_NAME} CFBundleIdentifier${BUNDLE_ID} CFBundleInfoDictionaryVersion6.0 CFBundleName${APP_NAME} CFBundlePackageTypeAPPL CFBundleShortVersionString1.0 CFBundleVersion1 LSMinimumSystemVersion${MIN_MACOS} NSHighResolutionCapable NSPrincipalClassNSApplication LSApplicationCategoryTypepublic.app-category.developer-tools LSUIElement CFBundleIconFile${APP_NAME}.icns PLIST } copy_extra_resources() { # Copy animated tip GIF into app resources if it exists in repo local gif_src="$PARENT_DIR/www/images/prole-type.gif" if [[ -f "$gif_src" ]]; then mkdir -p "$RESOURCES_DIR" cp "$gif_src" "$RESOURCES_DIR/prole-type.gif" echo "[resources] Copied prole-type.gif into Resources" else echo "[resources] prole-type.gif not found at $gif_src (skipping)" fi # Copy configured background image (ui.background) into Resources, keeping basename local ui_bg ui_bg="$(prop_get ui.background || true)" if [[ -n "$ui_bg" && -f "$PARENT_DIR/$ui_bg" ]]; then mkdir -p "$RESOURCES_DIR" local base base="$(basename "$ui_bg")" cp "$PARENT_DIR/$ui_bg" "$RESOURCES_DIR/$base" echo "[resources] Copied background image ($base) into Resources" else echo "[resources] ui.background not set or file missing (skipping)" fi # Copy default properties file if present local props_src="$ROOT_DIR/prole.properties" if [[ -f "$props_src" ]]; then cp "$props_src" "$RESOURCES_DIR/prole.properties" echo "[resources] Copied prole.properties into Resources" else echo "[resources] prole.properties not found at $props_src (skipping)" fi } swiftc_compile() { local arch="$1"; shift local out="$1"; shift # Map arch to -target local target="${arch}-apple-macosx${MIN_MACOS}" echo "[swiftc] Building for ${arch} -> ${out}" # shellcheck disable=SC2046 xcrun swiftc \ -target "$target" \ -O \ -sdk "$(xcrun --show-sdk-path --sdk macosx)" \ -framework AppKit \ -framework Carbon \ -framework Network \ $(/usr/bin/find "$SRC_DIR" -name "*.swift" | sort) \ -o "$out" } codesign_app() { echo "[codesign] Ad-hoc signing ${APP_DIR}" xcrun codesign --force --deep -s - "$APP_DIR" } build_one_arch() { local arch="$1" ensure_dirs gen_plist gen_app_icns gen_statusbar_icon_png copy_extra_resources local bin="$BUILD_DIR/${APP_NAME}-${arch}" swiftc_compile "$arch" "$bin" mkdir -p "$MACOS_DIR" cp "$bin" "$MACOS_DIR/${APP_NAME}" codesign_app echo "Built: $APP_DIR" } build_universal() { ensure_dirs gen_plist local bin_arm64="$BUILD_DIR/${APP_NAME}-arm64" local bin_x86="$BUILD_DIR/${APP_NAME}-x86_64" swiftc_compile arm64 "$bin_arm64" swiftc_compile x86_64 "$bin_x86" mkdir -p "$MACOS_DIR" xcrun lipo -create -output "$MACOS_DIR/${APP_NAME}" "$bin_arm64" "$bin_x86" codesign_app echo "Built universal: $APP_DIR" } clean() { rm -rf "$BUILD_DIR" "$DIST_DIR" echo "Cleaned build artifacts." } run_app() { if [ ! -d "$APP_DIR" ]; then "$0" build fi echo "[run] Launching ${APP_NAME}.app" # Launch app in background without activating it; return immediately. # Then confirm it is running and print its PID. open -gn "$APP_DIR" # Wait briefly for the app to start and obtain PID for i in {1..20}; do PID=$(pgrep -x "$APP_NAME" || true) if [ -n "${PID:-}" ]; then echo "[run] ${APP_NAME} is running (pid: $PID)." return 0 fi sleep 0.1 done echo "[run] Warning: could not confirm ${APP_NAME} is running. Check Console/Activity Monitor." >&2 } package_app() { if [ ! -d "$APP_DIR" ]; then "$0" build fi local zip="$DIST_DIR/${APP_NAME}.zip" (cd "$DIST_DIR" && /usr/bin/zip -qry "$(basename "$zip")" "$(basename "$APP_DIR")") echo "Packaged: $zip" } cmd="${1:-build}" shift || true case "$cmd" in -h|--help|help) usage ;; clean) clean ;; build) arch="${ARCH_CURRENT}" while [ $# -gt 0 ]; do case "$1" in --arch) arch="$2"; shift 2 ;; *) echo "Unknown option: $1"; usage; exit 1 ;; esac done build_one_arch "$arch" ;; build-universal) build_universal ;; run) run_app ;; debug) # Build if needed, then run the binary directly in the foreground if [ ! -d "$APP_DIR" ]; then "$0" build fi BIN="$MACOS_DIR/${APP_NAME}" if [ ! -x "$BIN" ]; then echo "[debug] error: binary not found at $BIN" >&2 exit 1 fi echo "[debug] Running ${APP_NAME} in foreground with verbose logs" echo "[debug] Press Ctrl+C to terminate. To quit from UI, use the context menu or Cmd+Q." export PROLESTATUS_DEBUG=1 "$BIN" status=$? echo "[debug] ${APP_NAME} exited with status ${status}" exit $status ;; package) package_app ;; *) echo "Unknown command: $cmd"; usage; exit 1 ;; esac