#!/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, xcodebuild) # Human-friendly app bundle name (can contain spaces) APP_NAME="Prole Tools" # SwiftPM product (executable) name as defined in Package.swift -> products/executable(name: ...) # This must match the actual built binary filename produced by SwiftPM. EXECUTABLE_NAME="Prole" # Bundle identifiers cannot contain spaces — keep a stable reverse-DNS id BUNDLE_ID="org.prole.ProleTools" 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)" LOG_DIR="$BUILD_DIR/logs" ARCH_CURRENT="$(uname -m)" # arm64 or x86_64 # Deps staging directories DEPS_DIR="$BUILD_DIR/deps" DEPS_SRC_DIR="$DEPS_DIR/src" DEPS_OUT_DIR="$DEPS_DIR/out" DEPS_LOGS_DIR="$DEPS_OUT_DIR/logs" usage() { cat < Build for a specific arch (arm64 or x86_64). Defaults to host arch. --verbose Print verbose build commands (same as PROLE_VERBOSE=1) Examples: ./build.sh build ./build.sh build --arch arm64 ./build.sh build-universal ./build.sh run ./build.sh package Dependencies: IRC is built via Swift Package Manager. Environment overrides (optional): KEEP_DEPS=1 Keep the deps staging directory after the build (for debugging) PROLE_VERBOSE=1 Stream verbose output from SwiftPM/xcodebuild and echo commands EOF } ensure_dirs() { mkdir -p "$BUILD_DIR" "$DIST_DIR" "$MACOS_DIR" "$RESOURCES_DIR" "$CONTENTS_DIR/Frameworks" "$DEPS_SRC_DIR" "$DEPS_OUT_DIR" "$LOG_DIR" "$DEPS_LOGS_DIR" } # Determine verbosity from env/flags VERBOSE="${PROLE_VERBOSE:-0}" if [[ "$VERBOSE" = "1" ]]; then # Enable shell tracing for more insight set -x fi # Resolve a SwiftPM-capable command (prefer xcrun swift on macOS) resolve_swiftpm() { if command -v xcrun >/dev/null 2>&1 && xcrun swift --version >/dev/null 2>&1; then echo "xcrun swift" return 0 fi if command -v swift >/dev/null 2>&1; then if swift build --help >/dev/null 2>&1; then echo "swift" return 0 fi fi # Fallback to package form via xcrun if available if command -v xcrun >/dev/null 2>&1 && xcrun swift package --help >/dev/null 2>&1; then echo "xcrun swift" return 0 fi return 1 } SWIFTPM_CMD=$(resolve_swiftpm || true) if [[ -z "${SWIFTPM_CMD:-}" ]]; then echo "[spm] error: Swift Package Manager not found. Install Xcode Command Line Tools: xcode-select --install" >&2 xcodebuild -version 2>/dev/null || true swift --version 2>/dev/null || true exit 1 fi echo "[spm] Using SwiftPM: ${SWIFTPM_CMD}" # 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 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 } # --- Dependency preparation --- prepare_dependencies() { ensure_dirs } cleanup_dependencies() { if [[ "${KEEP_DEPS:-0}" = "1" ]]; then echo "[deps] KEEP_DEPS=1 set; preserving $DEPS_DIR" else rm -rf "$DEPS_DIR" echo "[deps] Cleaned deps staging directory" fi } # Build via Swift Package Manager and return path to built binary on stdout spm_build_binary() { local arch="$1" echo "[spm] Building (Release) for arch=${arch}" >&2 # ROOT_DIR already points to prole-app; build from there so Package.swift is visible. pushd "$ROOT_DIR" >/dev/null || return 1 mkdir -p "$LOG_DIR" # Build via SwiftPM (Package.swift in prole-app). All dependencies managed by SPM. # Prepare argument arrays to avoid word-splitting issues local spm_verbose_flag=() if [[ "$VERBOSE" = "1" ]]; then spm_verbose_flag=( -v ) fi local build_flags=( -c release --arch "$arch" \ -Xlinker -rpath -Xlinker "@executable_path/../Frameworks" ) # Turn SWIFTPM_CMD (e.g., "xcrun swift") into an array local -a SWIFTPM_ARR # shellcheck disable=SC2206 SWIFTPM_ARR=( $SWIFTPM_CMD ) # If verbose, stream output to stdout and tee to log; otherwise, write only to log if [[ "$VERBOSE" = "1" ]]; then echo "[spm] Executing: ${SWIFTPM_CMD} build ${spm_verbose_flag[*]} ${build_flags[*]}" "${SWIFTPM_ARR[@]}" build "${spm_verbose_flag[@]}" "${build_flags[@]}" 2>&1 | tee "$LOG_DIR/spm-build-${arch}.log" local rc=${PIPESTATUS[0]} if [[ $rc -ne 0 ]]; then popd >/dev/null; return $rc; fi else "${SWIFTPM_ARR[@]}" build "${build_flags[@]}" >"$LOG_DIR/spm-build-${arch}.log" 2>&1 || { popd >/dev/null; return 1; } fi popd >/dev/null # Note: SwiftPM output binary name equals EXECUTABLE_NAME (not APP_NAME) local candidate1="$ROOT_DIR/.build/${arch}-apple-macosx/release/${EXECUTABLE_NAME}" local candidate2="$ROOT_DIR/.build/release/${EXECUTABLE_NAME}" if [[ -x "$candidate1" ]]; then echo "$candidate1"; return 0 fi if [[ -x "$candidate2" ]]; then echo "$candidate2"; return 0 fi return 1 } codesign_app() { echo "[codesign] Ad-hoc signing ${APP_DIR}" xcrun codesign --force --deep -s - "$APP_DIR" } # Copy SwiftPM-produced dynamic libraries (e.g., libRoyalVNCKit.dylib) into the app bundle embed_spm_dylibs() { local arch="$1" local spm_lib_dir="$ROOT_DIR/.build/${arch}-apple-macosx/release" if [[ ! -d "$spm_lib_dir" ]]; then spm_lib_dir="$ROOT_DIR/.build/release" fi mkdir -p "$CONTENTS_DIR/Frameworks" local found=0 if compgen -G "$spm_lib_dir/*.dylib" > /dev/null; then for dyl in "$spm_lib_dir"/*.dylib; do found=1 echo "[embed] Copying $(basename "$dyl") into Frameworks" cp -f "$dyl" "$CONTENTS_DIR/Frameworks/" done fi if [[ $found -eq 1 ]]; then echo "[embed] Codesigning embedded dylibs" # Sign all copied dylibs find "$CONTENTS_DIR/Frameworks" -name "*.dylib" -print0 | while IFS= read -r -d '' f; do xcrun codesign --force -s - "$f" done fi } build_one_arch() { local arch="$1" # Default clean at the start of every build clean || true # Constrain dependency builds to the requested architecture to avoid toolchain incompatibilities export PROLE_BUILD_ARCH="$arch" prepare_dependencies ensure_dirs gen_plist gen_app_icns gen_statusbar_icon_png copy_extra_resources local built_bin built_bin=$(spm_build_binary "$arch" | tail -n1) || { echo "[spm] build failed" >&2; exit 1; } mkdir -p "$MACOS_DIR" cp "$built_bin" "$MACOS_DIR/${APP_NAME}" # Embed any SwiftPM dynamic libs into the app bundle embed_spm_dylibs "$arch" # Ensure the app binary can locate embedded dylibs in Contents/Frameworks at runtime echo "[rpath] Adding @executable_path/../Frameworks to app binary rpaths" xcrun install_name_tool -add_rpath "@executable_path/../Frameworks" "$MACOS_DIR/${APP_NAME}" 2>/dev/null || true codesign_app echo "Built: $APP_DIR" echo "[summary] App binary: $(lipo -info "$MACOS_DIR/${APP_NAME}" 2>/dev/null || echo unknown)" echo "[summary] Embedded frameworks (.framework):" /usr/bin/find "$CONTENTS_DIR/Frameworks" -maxdepth 1 -type d -name "*.framework" -exec basename {} \; | sed 's/^/ - /' echo "[summary] Embedded dynamic libraries (.dylib):" /usr/bin/find "$CONTENTS_DIR/Frameworks" -maxdepth 1 -type f -name "*.dylib" -exec basename {} \; | sed 's/^/ - /' cleanup_dependencies } build_universal() { # Default clean at the start of every universal build clean || true # Build dependencies for both arches in universal build unset PROLE_BUILD_ARCH || true prepare_dependencies ensure_dirs gen_plist local built_arm64; built_arm64=$(spm_build_binary arm64 | tail -n1) || { echo "[spm] arm64 build failed" >&2; exit 1; } local built_x86; built_x86=$(spm_build_binary x86_64 | tail -n1) || { echo "[spm] x86_64 build failed" >&2; exit 1; } mkdir -p "$MACOS_DIR" xcrun lipo -create -output "$MACOS_DIR/${APP_NAME}" "$built_arm64" "$built_x86" codesign_app echo "Built universal: $APP_DIR" echo "[summary] App binary: $(lipo -info "$MACOS_DIR/${APP_NAME}" 2>/dev/null || echo unknown)" echo "[summary] Embedded frameworks (.framework):" /usr/bin/find "$CONTENTS_DIR/Frameworks" -maxdepth 1 -type d -name "*.framework" -exec basename {} \; | sed 's/^/ - /' echo "[summary] Embedded dynamic libraries (.dylib):" /usr/bin/find "$CONTENTS_DIR/Frameworks" -maxdepth 1 -type f -name "*.dylib" -exec basename {} \; | sed 's/^/ - /' cleanup_dependencies } 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}" # Allow per-invocation verbose flag while [ $# -gt 0 ]; do case "$1" in --arch) arch="$2"; shift 2 ;; --verbose) VERBOSE="1"; shift ;; *) 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