#!/usr/bin/env bash set -euo pipefail resolve_package_signing_mode() { local requested="${CODEXBAR_SIGNING:-adhoc}" case "$requested" in adhoc|identity) ;; *) echo "ERROR: Unsupported CODEXBAR_SIGNING: $requested (expected adhoc or identity)" >&2 return 1 ;; esac SIGNING_MODE="$requested" } CONF=${1:-release} ALLOW_LLDB=${CODEXBAR_ALLOW_LLDB:-0} SIGNING_MODE= resolve_package_signing_mode ROOT=$(cd "$(dirname "$0")/.." && pwd) cd "$ROOT" LOWER_CONF=$(printf "%s" "$CONF" | tr '[:upper:]' '[:lower:]') case "$LOWER_CONF" in debug|release) ;; *) echo "ERROR: Unsupported build configuration: $CONF (expected debug or release)" >&2 exit 1 ;; esac # Load version info source "$ROOT/version.env" source "$ROOT/Scripts/package_product_paths.sh" source "$ROOT/Scripts/sparkle_signing_paths.sh" # 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 # Resolve SwiftPM's current output path without relying on a fixed build-system layout. # The output variable keeps the per-arch cache in this shell instead of losing it to # command substitution. swiftpm_bin_path() { local arch="$1" local output_var="$2" local cache_var="SWIFTPM_BIN_PATH_${arch//[^A-Za-z0-9]/_}" if [[ -z "${!cache_var+set}" ]]; then local resolved if ! resolved=$(codexbar_swiftpm_bin_path "$CONF" "$arch"); then return 1 fi printf -v "$cache_var" '%s' "$resolved" fi printf -v "$output_var" '%s' "${!cache_var}" } binary_has_arch() { local binary="$1" local arch="$2" [[ -f "$binary" ]] && lipo -archs "$binary" 2>/dev/null | tr ' ' '\n' | grep -qx "$arch" } # SwiftBuild can reuse one output directory for sequential per-arch builds. Snapshot # each fresh slice before the next build can replace it. PRODUCT_STAGE_ROOT="$ROOT/.build/package-products/$LOWER_CONF" rm -rf "$PRODUCT_STAGE_ROOT" stage_build_products() { local arch="$1" local bin_dir stage_dir name product swiftpm_bin_path "$arch" bin_dir stage_dir="$PRODUCT_STAGE_ROOT/$arch" mkdir -p "$stage_dir" for name in CodexBar CodexBarCLI CodexBarClaudeWatchdog; do if ! product=$(codexbar_require_product_file "$bin_dir" "$name" "$arch"); then return 1 fi if ! binary_has_arch "$product" "$arch"; then echo "ERROR: ${product} does not contain required architecture: ${arch}" >&2 return 1 fi cp "$product" "$stage_dir/$name" done if [[ -d "$bin_dir/CodexBar.dSYM" ]]; then cp -R "$bin_dir/CodexBar.dSYM" "$stage_dir/" fi } for ARCH in "${ARCH_LIST[@]}"; do swift build -c "$CONF" --arch "$ARCH" stage_build_products "$ARCH" done APP_FINAL="$ROOT/CodexBar.app" APP_STAGE="$ROOT/.build/package/CodexBar.app" rm -rf "$APP_STAGE" APP="$APP_STAGE" 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 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_TEAM_ID="${APP_TEAM_ID:-Y5PE65HELJ}" APP_GROUP_ID="${APP_TEAM_ID}.com.steipete.codexbar" if [[ "$BUNDLE_ID" == *".debug"* ]]; then APP_GROUP_ID="${APP_TEAM_ID}.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© 2026 Peter Steinberger. MIT License. SUFeedURL${FEED_URL} SUPublicEDKeyAGCY8w5vHirVfGGDGc8Szc5iuOqupZSh9pMj/Qs67XI= SUEnableAutomaticChecks<${AUTO_CHECKS}/> CodexBuildTimestamp${BUILD_TIMESTAMP} CodexGitCommit${GIT_COMMIT} CodexBarTeamID${APP_TEAM_ID} PLIST # Resolve a built binary from the fresh per-arch snapshot or SwiftPM's reported directory. resolve_binary_path() { local name="$1" local arch="$2" local bin_dir candidate swiftpm_bin_path "$arch" bin_dir if ! candidate=$(codexbar_resolve_staged_or_reported_file \ "$PRODUCT_STAGE_ROOT" "$bin_dir" "$name" "$arch"); then return 1 fi if ! binary_has_arch "$candidate" "$arch"; then echo "ERROR: ${candidate} does not contain required architecture: ${arch}" >&2 return 1 fi echo "$candidate" } 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 if ! src=$(resolve_binary_path "$name" "$arch"); then 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[@]}" } strip_release_binary() { local binary="$1" if [[ "$LOWER_CONF" != "release" ]]; then return 0 fi if [[ ! -f "$binary" ]]; then return 0 fi xcrun strip -x "$binary" } ensure_widget_extension_project() { local spec="$ROOT/WidgetExtension/project.yml" local project_dir="$ROOT/WidgetExtension/CodexBarWidgetExtension.xcodeproj" if [[ -f "$project_dir/project.pbxproj" ]]; then return fi if ! command -v xcodegen >/dev/null 2>&1; then echo "ERROR: Missing ${project_dir}; install xcodegen or restore the generated project." >&2 exit 1 fi # The tracked project is authoritative. Regenerating it during packaging records the checkout # directory's spelling in a package file reference and leaves release worktrees dirty. xcodegen generate --spec "$spec" --project "$ROOT/WidgetExtension" --quiet } build_widget_extension() { local xcode_conf="Release" if [[ "$LOWER_CONF" == "debug" ]]; then xcode_conf="Debug" fi ensure_widget_extension_project local derived_dir="$ROOT/.build/xcode-widget-extension-${LOWER_CONF}" local project_dir="$ROOT/WidgetExtension/CodexBarWidgetExtension.xcodeproj" local build_log="$derived_dir/xcodebuild.log" local timeout_seconds="${CODEXBAR_WIDGET_EXTENSION_TIMEOUT_SECONDS:-900}" local archs="${ARCH_LIST[*]}" mkdir -p "$derived_dir" echo "Building CodexBarWidget Xcode extension (${xcode_conf}, ${archs})." >&2 xcodebuild \ -project "$project_dir" \ -scheme CodexBarWidgetExtension \ -configuration "$xcode_conf" \ -destination "generic/platform=macOS" \ -derivedDataPath "$derived_dir" \ -skipPackageUpdates \ -disableAutomaticPackageResolution \ -skipMacroValidation \ -skipPackagePluginValidation \ CODEXBAR_WIDGET_BUNDLE_ID="$WIDGET_BUNDLE_ID" \ CODEXBAR_TEAM_ID="$APP_TEAM_ID" \ MARKETING_VERSION="$MARKETING_VERSION" \ CURRENT_PROJECT_VERSION="$BUILD_NUMBER" \ CODE_SIGNING_ALLOWED=NO \ ARCHS="$archs" \ ONLY_ACTIVE_ARCH=NO \ build >"$build_log" 2>&1 & local xcodebuild_pid=$! local elapsed=0 while kill -0 "$xcodebuild_pid" 2>/dev/null; do if [[ "$elapsed" -ge "$timeout_seconds" ]]; then kill "$xcodebuild_pid" 2>/dev/null || true wait "$xcodebuild_pid" 2>/dev/null || true tail -80 "$build_log" >&2 || true echo "ERROR: Timed out building CodexBarWidget extension after ${timeout_seconds}s" >&2 exit 1 fi sleep 5 elapsed=$((elapsed + 5)) if (( elapsed > 0 && elapsed % 60 == 0 )); then echo "Still building CodexBarWidget extension (${elapsed}s)..." >&2 fi done if ! wait "$xcodebuild_pid"; then tail -120 "$build_log" >&2 || true echo "ERROR: Failed to build CodexBarWidget extension" >&2 exit 1 fi local appex="$derived_dir/Build/Products/${xcode_conf}/CodexBarWidget.appex" if [[ ! -f "$appex/Contents/MacOS/CodexBarWidget" ]]; then echo "ERROR: Missing Xcode-built CodexBarWidget.appex at ${appex}" >&2 exit 1 fi echo "$appex" } install_widget_extension() { local src_appex src_appex="$(build_widget_extension)" local widget_app="$APP/Contents/PlugIns/CodexBarWidget.appex" rm -rf "$widget_app" mkdir -p "$APP/Contents/PlugIns" cp -R "$src_appex" "$widget_app" verify_binary_arches "$widget_app/Contents/MacOS/CodexBarWidget" "${ARCH_LIST[@]}" } install_binary "CodexBar" "$APP/Contents/MacOS/CodexBar" strip_release_binary "$APP/Contents/MacOS/CodexBar" # Ship CodexBarCLI alongside the app for easy symlinking. install_binary "CodexBarCLI" "$APP/Contents/Helpers/CodexBarCLI" strip_release_binary "$APP/Contents/Helpers/CodexBarCLI" # Watchdog helper: ensures `claude` probes die when CodexBar crashes/gets killed. install_binary "CodexBarClaudeWatchdog" "$APP/Contents/Helpers/CodexBarClaudeWatchdog" strip_release_binary "$APP/Contents/Helpers/CodexBarClaudeWatchdog" install_widget_extension strip_release_binary "$APP/Contents/PlugIns/CodexBarWidget.appex/Contents/MacOS/CodexBarWidget" swiftpm_bin_path "${ARCH_LIST[0]}" PREFERRED_BUILD_DIR # Embed Sparkle.framework SPARKLE_SOURCE=$(codexbar_require_product_directory "$PREFERRED_BUILD_DIR" Sparkle.framework packaging) cp -R "$SPARKLE_SOURCE" "$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 the selected package identity. 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"; } # Validate Sparkle's nested layout before signing so framework layout drift fails clearly. SPARKLE_SIGNING_TARGETS=$(codexbar_sparkle_signing_targets "$SPARKLE") while IFS= read -r SPARKLE_TARGET; do resign "$SPARKLE_TARGET" done <<<"$SPARKLE_SIGNING_TARGETS" 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. 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" rm -rf "$APP_FINAL" mv "$APP" "$APP_FINAL" APP="$APP_FINAL" echo "Created $APP"