#!/usr/bin/env bash set -euo pipefail CONF=${1:-release} ALLOW_LLDB=${CODEXBAR_ALLOW_LLDB:-0} SIGNING_MODE=${CODEXBAR_SIGNING:-} ROOT=$(cd "$(dirname "$0")/.." && pwd) cd "$ROOT" # Load version info source "$ROOT/version.env" # Clean build only when explicitly requested (slower). if [[ "${CODEXBAR_FORCE_CLEAN:-0}" == "1" ]]; then if [[ -d "$ROOT/.build" ]]; then if command -v trash >/dev/null 2>&1; then if ! trash "$ROOT/.build"; then echo "WARN: trash .build failed; continuing with swift package clean." >&2 fi else rm -rf "$ROOT/.build" || echo "WARN: rm -rf .build failed; continuing with swift package clean." >&2 fi fi swift package clean >/dev/null 2>&1 || true fi # Build for host architecture by default; allow overriding via ARCHES (e.g., "arm64 x86_64" for universal). ARCH_LIST=( ${ARCHES:-} ) if [[ ${#ARCH_LIST[@]} -eq 0 ]]; then HOST_ARCH=$(uname -m) case "$HOST_ARCH" in arm64) ARCH_LIST=(arm64) ;; x86_64) ARCH_LIST=(x86_64) ;; *) ARCH_LIST=("$HOST_ARCH") ;; esac fi patch_keyboard_shortcuts() { local util_path="$ROOT/.build/checkouts/KeyboardShortcuts/Sources/KeyboardShortcuts/Utilities.swift" if [[ ! -f "$util_path" ]]; then return 0 fi if grep -q "keyboardShortcutsSafeBundle" "$util_path"; then return 0 fi chmod +w "$util_path" || true python3 - "$util_path" <<'PY' import sys from pathlib import Path path = Path(sys.argv[1]) text = path.read_text() if ".keyboardShortcutsSafeBundle" in text: sys.exit(0) text = text.replace( 'NSLocalizedString(self, bundle: .module, comment: self)', 'NSLocalizedString(self, bundle: .keyboardShortcutsSafeBundle, comment: self)', ) inject = """ private extension Bundle { /// Safe lookup that avoids the fatal trap in the autogenerated `Bundle.module` /// when the resource bundle is not placed at the bundle root. static let keyboardShortcutsSafeBundle: Bundle = { #if os(macOS) if let url = Bundle.main.url(forResource: "KeyboardShortcuts_KeyboardShortcuts", withExtension: "bundle"), let bundle = Bundle(url: url) { return bundle } let rootURL = Bundle.main.bundleURL.appendingPathComponent("KeyboardShortcuts_KeyboardShortcuts.bundle") if let bundle = Bundle(url: rootURL) { return bundle } #endif let devURL = URL(fileURLWithPath: #file) .deletingLastPathComponent() // Utilities.swift .deletingLastPathComponent() // KeyboardShortcuts .deletingLastPathComponent() // Sources .appendingPathComponent("KeyboardShortcuts_KeyboardShortcuts.bundle") if let bundle = Bundle(url: devURL) { return bundle } return Bundle.main }() } """ marker = "}\n\n\nextension Data {" if marker not in text: raise SystemExit("Marker not found in Utilities.swift; patch failed.") text = text.replace(marker, "}\n\n" + inject + "\n\nextension Data {") path.write_text(text) PY } KEYBOARD_SHORTCUTS_UTIL="$ROOT/.build/checkouts/KeyboardShortcuts/Sources/KeyboardShortcuts/Utilities.swift" if [[ ! -f "$KEYBOARD_SHORTCUTS_UTIL" ]]; then swift build -c "$CONF" --arch "${ARCH_LIST[0]}" fi patch_keyboard_shortcuts for ARCH in "${ARCH_LIST[@]}"; do swift build -c "$CONF" --arch "$ARCH" done APP="$ROOT/CodexBar.app" rm -rf "$APP" mkdir -p "$APP/Contents/MacOS" "$APP/Contents/Resources" "$APP/Contents/Frameworks" mkdir -p "$APP/Contents/Helpers" "$APP/Contents/PlugIns" # Convert new .icon bundle to .icns if present (macOS 14+/IconStudio export) ICON_SOURCE="$ROOT/Icon.icon" ICON_TARGET="$ROOT/Icon.icns" if [[ -f "$ICON_SOURCE" ]]; then iconutil --convert icns --output "$ICON_TARGET" "$ICON_SOURCE" fi BUNDLE_ID="com.steipete.codexbar" FEED_URL="https://raw.githubusercontent.com/steipete/CodexBar/main/appcast.xml" AUTO_CHECKS=true LOWER_CONF=$(printf "%s" "$CONF" | tr '[:upper:]' '[:lower:]') if [[ "$LOWER_CONF" == "debug" ]]; then BUNDLE_ID="com.steipete.codexbar.debug" FEED_URL="" AUTO_CHECKS=false fi if [[ "$SIGNING_MODE" == "adhoc" ]]; then FEED_URL="" AUTO_CHECKS=false fi WIDGET_BUNDLE_ID="${BUNDLE_ID}.widget" APP_GROUP_ID="group.com.steipete.codexbar" if [[ "$BUNDLE_ID" == *".debug"* ]]; then APP_GROUP_ID="group.com.steipete.codexbar.debug" fi ENTITLEMENTS_DIR="$ROOT/.build/entitlements" APP_ENTITLEMENTS="${ENTITLEMENTS_DIR}/CodexBar.entitlements" WIDGET_ENTITLEMENTS="${ENTITLEMENTS_DIR}/CodexBarWidget.entitlements" mkdir -p "$ENTITLEMENTS_DIR" if [[ "$ALLOW_LLDB" == "1" && "$LOWER_CONF" != "debug" ]]; then echo "ERROR: CODEXBAR_ALLOW_LLDB requires debug configuration" >&2 exit 1 fi cat > "$APP_ENTITLEMENTS" < com.apple.security.application-groups ${APP_GROUP_ID} $(if [[ "$ALLOW_LLDB" == "1" ]]; then echo " com.apple.security.get-task-allow"; fi) PLIST cat > "$WIDGET_ENTITLEMENTS" < com.apple.security.app-sandbox com.apple.security.application-groups ${APP_GROUP_ID} PLIST BUILD_TIMESTAMP=$(date -u +"%Y-%m-%dT%H:%M:%SZ") GIT_COMMIT=$(git rev-parse --short HEAD 2>/dev/null || echo "unknown") cat > "$APP/Contents/Info.plist" < CFBundleNameCodexBar CFBundleDisplayNameCodexBar CFBundleIdentifier${BUNDLE_ID} CFBundleExecutableCodexBar CFBundlePackageTypeAPPL CFBundleShortVersionString${MARKETING_VERSION} CFBundleVersion${BUILD_NUMBER} LSMinimumSystemVersion14.0 LSUIElement CFBundleIconFileIcon NSHumanReadableCopyright© 2025 Peter Steinberger. MIT License. SUFeedURL${FEED_URL} SUPublicEDKeyAGCY8w5vHirVfGGDGc8Szc5iuOqupZSh9pMj/Qs67XI= SUEnableAutomaticChecks<${AUTO_CHECKS}/> CodexBuildTimestamp${BUILD_TIMESTAMP} CodexGitCommit${GIT_COMMIT} PLIST build_product_path() { local name="$1" local arch="$2" case "$arch" in arm64|x86_64) echo ".build/${arch}-apple-macosx/$CONF/$name" ;; *) echo ".build/$CONF/$name" ;; esac } # Resolve path to built binary; some SwiftPM versions use .build/$CONF/ when building for host only. resolve_binary_path() { local name="$1" local arch="$2" local candidate candidate=$(build_product_path "$name" "$arch") if [[ -f "$candidate" ]]; then echo "$candidate" return fi if [[ "$arch" == "arm64" || "$arch" == "x86_64" ]] && [[ -f ".build/$CONF/$name" ]]; then echo ".build/$CONF/$name" fi } verify_binary_arches() { local binary="$1"; shift local expected=("$@") local actual actual=$(lipo -archs "$binary") local actual_count expected_count actual_count=$(wc -w <<<"$actual" | tr -d ' ') expected_count=${#expected[@]} if [[ "$actual_count" -ne "$expected_count" ]]; then echo "ERROR: $binary arch mismatch (expected: ${expected[*]}, actual: ${actual})" >&2 exit 1 fi for arch in "${expected[@]}"; do if [[ "$actual" != *"$arch"* ]]; then echo "ERROR: $binary missing arch $arch (have: ${actual})" >&2 exit 1 fi done } install_binary() { local name="$1" local dest="$2" local binaries=() for arch in "${ARCH_LIST[@]}"; do local src src=$(resolve_binary_path "$name" "$arch") if [[ -z "$src" || ! -f "$src" ]]; then echo "ERROR: Missing ${name} build for ${arch} at $(build_product_path "$name" "$arch")" >&2 exit 1 fi binaries+=("$src") done if [[ ${#ARCH_LIST[@]} -gt 1 ]]; then lipo -create "${binaries[@]}" -output "$dest" else cp "${binaries[0]}" "$dest" fi chmod +x "$dest" verify_binary_arches "$dest" "${ARCH_LIST[@]}" } install_binary "CodexBar" "$APP/Contents/MacOS/CodexBar" # Ship CodexBarCLI alongside the app for easy symlinking. if [[ -n "$(resolve_binary_path "CodexBarCLI" "${ARCH_LIST[0]}")" ]]; then install_binary "CodexBarCLI" "$APP/Contents/Helpers/CodexBarCLI" fi # Watchdog helper: ensures `claude` probes die when CodexBar crashes/gets killed. if [[ -n "$(resolve_binary_path "CodexBarClaudeWatchdog" "${ARCH_LIST[0]}")" ]]; then install_binary "CodexBarClaudeWatchdog" "$APP/Contents/Helpers/CodexBarClaudeWatchdog" fi if [[ -n "$(resolve_binary_path "CodexBarWidget" "${ARCH_LIST[0]}")" ]]; then WIDGET_APP="$APP/Contents/PlugIns/CodexBarWidget.appex" mkdir -p "$WIDGET_APP/Contents/MacOS" "$WIDGET_APP/Contents/Resources" cat > "$WIDGET_APP/Contents/Info.plist" < CFBundleNameCodexBarWidget CFBundleDisplayNameCodexBar CFBundleIdentifier${WIDGET_BUNDLE_ID} CFBundleExecutableCodexBarWidget CFBundlePackageTypeXPC! CFBundleShortVersionString${MARKETING_VERSION} CFBundleVersion${BUILD_NUMBER} LSMinimumSystemVersion14.0 NSExtension NSExtensionPointIdentifiercom.apple.widgetkit-extension NSExtensionPrincipalClassCodexBarWidget.CodexBarWidgetBundle PLIST install_binary "CodexBarWidget" "$WIDGET_APP/Contents/MacOS/CodexBarWidget" fi # Embed Sparkle.framework if [[ -d ".build/$CONF/Sparkle.framework" ]]; then cp -R ".build/$CONF/Sparkle.framework" "$APP/Contents/Frameworks/" chmod -R a+rX "$APP/Contents/Frameworks/Sparkle.framework" install_name_tool -add_rpath "@executable_path/../Frameworks" "$APP/Contents/MacOS/CodexBar" # Re-sign Sparkle and all nested components with Developer ID + timestamp SPARKLE="$APP/Contents/Frameworks/Sparkle.framework" if [[ "$SIGNING_MODE" == "adhoc" ]]; then CODESIGN_ID="-" CODESIGN_ARGS=(--force --sign "$CODESIGN_ID") elif [[ "$ALLOW_LLDB" == "1" ]]; then CODESIGN_ID="-" CODESIGN_ARGS=(--force --sign "$CODESIGN_ID") else CODESIGN_ID="${APP_IDENTITY:-Developer ID Application: Peter Steinberger (Y5PE65HELJ)}" CODESIGN_ARGS=(--force --timestamp --options runtime --sign "$CODESIGN_ID") fi function resign() { codesign "${CODESIGN_ARGS[@]}" "$1"; } # Sign innermost binaries first, then the framework root to seal resources resign "$SPARKLE" resign "$SPARKLE/Versions/B/Sparkle" resign "$SPARKLE/Versions/B/Autoupdate" resign "$SPARKLE/Versions/B/Updater.app" resign "$SPARKLE/Versions/B/Updater.app/Contents/MacOS/Updater" resign "$SPARKLE/Versions/B/XPCServices/Downloader.xpc" resign "$SPARKLE/Versions/B/XPCServices/Downloader.xpc/Contents/MacOS/Downloader" resign "$SPARKLE/Versions/B/XPCServices/Installer.xpc" resign "$SPARKLE/Versions/B/XPCServices/Installer.xpc/Contents/MacOS/Installer" resign "$SPARKLE/Versions/B" resign "$SPARKLE" fi if [[ -f "$ICON_TARGET" ]]; then cp "$ICON_TARGET" "$APP/Contents/Resources/Icon.icns" fi # Bundle app resources (provider icons, etc.). APP_RESOURCES_DIR="$ROOT/Sources/CodexBar/Resources" if [[ -d "$APP_RESOURCES_DIR" ]]; then cp -R "$APP_RESOURCES_DIR/." "$APP/Contents/Resources/" fi if [[ ! -f "$APP/Contents/Resources/Icon-classic.icns" ]]; then echo "ERROR: Missing Icon-classic.icns in app bundle resources." >&2 exit 1 fi # SwiftPM resource bundles (e.g. KeyboardShortcuts) are emitted next to the built binary. CODEXBAR_BINARY="$(resolve_binary_path "CodexBar" "${ARCH_LIST[0]}")" PREFERRED_BUILD_DIR="$(dirname "${CODEXBAR_BINARY:-$(build_product_path "CodexBar" "${ARCH_LIST[0]}")}")" shopt -s nullglob SWIFTPM_BUNDLES=("${PREFERRED_BUILD_DIR}/"*.bundle) shopt -u nullglob if [[ ${#SWIFTPM_BUNDLES[@]} -gt 0 ]]; then for bundle in "${SWIFTPM_BUNDLES[@]}"; do bundle_name="$(basename "$bundle")" cp -R "$bundle" "$APP/Contents/Resources/" done fi if [[ ! -d "$APP/Contents/Resources/KeyboardShortcuts_KeyboardShortcuts.bundle" ]]; then echo "ERROR: Missing KeyboardShortcuts SwiftPM resource bundle (Settings → Keyboard shortcut will crash)." >&2 echo "Expected: ${PREFERRED_BUILD_DIR}/KeyboardShortcuts_KeyboardShortcuts.bundle" >&2 exit 1 fi # Ensure contents are writable before stripping attributes and signing. chmod -R u+w "$APP" # Strip extended attributes to prevent AppleDouble (._*) files that break code sealing xattr -cr "$APP" find "$APP" -name '._*' -delete # Sign helper binaries if present if [[ -f "${APP}/Contents/Helpers/CodexBarCLI" ]]; then codesign "${CODESIGN_ARGS[@]}" "${APP}/Contents/Helpers/CodexBarCLI" fi if [[ -f "${APP}/Contents/Helpers/CodexBarClaudeWatchdog" ]]; then codesign "${CODESIGN_ARGS[@]}" "${APP}/Contents/Helpers/CodexBarClaudeWatchdog" fi # Sign widget extension if present if [[ -d "${APP}/Contents/PlugIns/CodexBarWidget.appex" ]]; then codesign "${CODESIGN_ARGS[@]}" \ --entitlements "$WIDGET_ENTITLEMENTS" \ "$APP/Contents/PlugIns/CodexBarWidget.appex/Contents/MacOS/CodexBarWidget" codesign "${CODESIGN_ARGS[@]}" \ --entitlements "$WIDGET_ENTITLEMENTS" \ "$APP/Contents/PlugIns/CodexBarWidget.appex" fi # Finally sign the app bundle itself codesign "${CODESIGN_ARGS[@]}" \ --entitlements "$APP_ENTITLEMENTS" \ "$APP" echo "Created $APP"