prole/prole-app/build.sh

492 lines
16 KiB
Bash
Executable File

#!/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 <<EOF
Usage: $(basename "$0") [command] [options]
Commands:
build Build ${APP_NAME}.app for the current architecture (default)
build-universal Build a universal (arm64+x86_64) ${APP_NAME}.app
clean Remove build artifacts
run Build (if needed) and run the app
debug Build (if needed) and run in foreground with verbose logs
package Zip the built app into dist/${APP_NAME}.zip
Options (for build):
--arch <arch> 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" <<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>CFBundleDevelopmentRegion</key><string>en</string>
<key>CFBundleExecutable</key><string>${APP_NAME}</string>
<key>CFBundleIdentifier</key><string>${BUNDLE_ID}</string>
<key>CFBundleInfoDictionaryVersion</key><string>6.0</string>
<key>CFBundleName</key><string>${APP_NAME}</string>
<key>CFBundlePackageType</key><string>APPL</string>
<key>CFBundleShortVersionString</key><string>1.0</string>
<key>CFBundleVersion</key><string>1</string>
<key>LSMinimumSystemVersion</key><string>${MIN_MACOS}</string>
<key>NSHighResolutionCapable</key><true/>
<key>NSPrincipalClass</key><string>NSApplication</string>
<key>LSApplicationCategoryType</key><string>public.app-category.developer-tools</string>
<!-- UIElement must be false so the app can present a normal window and app menu when in Application Window mode. -->
<key>LSUIElement</key><false/>
<key>CFBundleIconFile</key><string>${APP_NAME}.icns</string>
</dict>
</plist>
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