Files
steipete--codexbar/Scripts/package_app.sh
T
2026-07-13 12:22:33 +08:00

542 lines
18 KiB
Bash
Executable File

#!/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" <<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>com.apple.security.application-groups</key>
<array>
<string>${APP_GROUP_ID}</string>
</array>
$(if [[ "$ALLOW_LLDB" == "1" ]]; then echo " <key>com.apple.security.get-task-allow</key><true/>"; fi)
</dict>
</plist>
PLIST
cat > "$WIDGET_ENTITLEMENTS" <<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>com.apple.security.app-sandbox</key>
<true/>
<key>com.apple.security.application-groups</key>
<array>
<string>${APP_GROUP_ID}</string>
</array>
</dict>
</plist>
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" <<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>CFBundleName</key><string>CodexBar</string>
<key>CFBundleDisplayName</key><string>CodexBar</string>
<key>CFBundleIdentifier</key><string>${BUNDLE_ID}</string>
<key>CFBundleExecutable</key><string>CodexBar</string>
<key>CFBundlePackageType</key><string>APPL</string>
<key>CFBundleShortVersionString</key><string>${MARKETING_VERSION}</string>
<key>CFBundleVersion</key><string>${BUILD_NUMBER}</string>
<key>LSMinimumSystemVersion</key><string>14.0</string>
<key>LSUIElement</key><true/>
<key>CFBundleIconFile</key><string>Icon</string>
<key>NSHumanReadableCopyright</key><string>© 2026 Peter Steinberger. MIT License.</string>
<key>SUFeedURL</key><string>${FEED_URL}</string>
<key>SUPublicEDKey</key><string>AGCY8w5vHirVfGGDGc8Szc5iuOqupZSh9pMj/Qs67XI=</string>
<key>SUEnableAutomaticChecks</key><${AUTO_CHECKS}/>
<key>CodexBuildTimestamp</key><string>${BUILD_TIMESTAMP}</string>
<key>CodexGitCommit</key><string>${GIT_COMMIT}</string>
<key>CodexBarTeamID</key><string>${APP_TEAM_ID}</string>
</dict>
</plist>
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"