macsync/deploy/install.sh
Natalie 9373b14ab4 fix(@mac-sync): 🐛 add keychain search list cleanup on sign failure
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
2026-05-21 22:03:45 -07:00

452 lines
18 KiB
Bash
Executable file
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/bin/bash
set -euo pipefail
# MacSync — macOS client installer.
# Builds Swift app, creates app bundle, installs LaunchAgent com.lilith.mac-sync.
#
# Usage (run on plum):
# ./install.sh [SERVER_URL]
# SERVER_URL defaults to http://10.0.0.11:3201
APP_NAME="MacSyncApp"
BUNDLE_ID="com.lilith.mac-sync"
DISPLAY_NAME="MacSync"
INSTALL_DIR="$HOME/Applications"
APP_BUNDLE="$INSTALL_DIR/$DISPLAY_NAME.app"
APP_SUPPORT_DIR="$HOME/Library/Application Support/$DISPLAY_NAME"
LAUNCH_AGENTS_DIR="$HOME/Library/LaunchAgents"
PLIST_FILE="$LAUNCH_AGENTS_DIR/$BUNDLE_ID.plist"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
MAC_SYNC_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m'
print_step() { echo -e "${GREEN}${NC} $1"; }
print_info() { echo -e "${BLUE}${NC} $1"; }
print_warning() { echo -e "${YELLOW}${NC} $1"; }
print_error() { echo -e "${RED}${NC} $1"; }
print_success() { echo -e "${GREEN}${NC} $1"; }
echo -e "${BLUE}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
echo -e "${BLUE} MacSync — Unified macOS Sync Agent Installer${NC}"
echo -e "${BLUE}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
echo ""
SERVER_URL="${1:-http://10.0.0.11:3201}"
check_macos() {
if [[ "$OSTYPE" != "darwin"* ]]; then
print_error "This script is macOS only"
exit 1
fi
}
check_swift() {
if ! command -v swift &>/dev/null; then
print_error "Swift not found — install Xcode or Command Line Tools: xcode-select --install"
exit 1
fi
print_info "Swift: $(swift --version | head -n1)"
}
stop_existing_agent() {
print_step "Stopping existing agent (if running)..."
if [[ -f "$PLIST_FILE" ]]; then
launchctl unload "$PLIST_FILE" 2>/dev/null || true
fi
pkill -x "$APP_NAME" 2>/dev/null || true
sleep 1
}
build_application() {
print_step "Building Swift application (release)..."
cd "$MAC_SYNC_ROOT"
rm -rf .build
if swift build -c release; then
print_success "Build successful"
else
print_error "Swift build failed"
exit 1
fi
}
install_binary() {
print_step "Installing app bundle..."
local binary_src="$MAC_SYNC_ROOT/.build/release/$APP_NAME"
if [[ ! -f "$binary_src" ]]; then
print_error "Binary not found: $binary_src"
exit 1
fi
local macos_dir="$APP_BUNDLE/Contents/MacOS"
local resources_dir="$APP_BUNDLE/Contents/Resources"
local webapp_dir="$resources_dir/webapp"
mkdir -p "$macos_dir" "$resources_dir" "$webapp_dir"
# Clean stale resource bundles from previous installs
rm -rf "$macos_dir"/*.bundle "$resources_dir"/*.bundle
cp "$binary_src" "$macos_dir/$APP_NAME"
chmod +x "$macos_dir/$APP_NAME"
# Copy SPM resource bundles (e.g. tray-resources_LilithTrayResources.bundle)
local build_dir="$MAC_SYNC_ROOT/.build/release"
for bundle in "$build_dir"/*.bundle; do
[[ -d "$bundle" ]] || continue
cp -R "$bundle" "$resources_dir/$(basename "$bundle")"
print_info "Bundled: $(basename "$bundle")"
done
# Bundle Vite webapp dist — pre-built by deploy-remote.sh, or build locally
local web_dist="$MAC_SYNC_ROOT/web/dist"
if [[ -d "$web_dist" ]]; then
cp -R "$web_dist/"* "$webapp_dir/"
print_success "Webapp bundled"
else
print_warning "web/dist not found — webapp not bundled (run: cd web && bun run build)"
fi
# Write Info.plist from template (substitute BUILD + VERSION from VERSION.json)
local version="1.0.0"
local build_num="1"
local version_file="$MAC_SYNC_ROOT/VERSION.json"
if [[ -f "$version_file" ]] && command -v python3 &>/dev/null; then
version=$(python3 -c "import json,sys; d=json.load(open('$version_file')); print(d.get('version','1.0.0'))" 2>/dev/null || echo "1.0.0")
build_num=$(python3 -c "import json,sys; d=json.load(open('$version_file')); print(d.get('builds',1))" 2>/dev/null || echo "1")
fi
local template="$MAC_SYNC_ROOT/src/client/Resources/Info.plist.template"
if [[ -f "$template" ]]; then
sed -e "s/{{VERSION}}/$version/g" -e "s/{{BUILD}}/$build_num/g" \
"$template" > "$APP_BUNDLE/Contents/Info.plist"
else
cat > "$APP_BUNDLE/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>CFBundleExecutable</key>
<string>$APP_NAME</string>
<key>CFBundleIdentifier</key>
<string>$BUNDLE_ID</string>
<key>CFBundleName</key>
<string>$DISPLAY_NAME</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleVersion</key>
<string>$build_num</string>
<key>CFBundleShortVersionString</key>
<string>$version</string>
<key>LSUIElement</key>
<true/>
<key>NSHighResolutionCapable</key>
<true/>
<key>NSContactsUsageDescription</key>
<string>MacSync syncs your contacts to your personal server.</string>
<key>NSPhotoLibraryUsageDescription</key>
<string>MacSync reads your photo library to sync metadata and originals to your personal server.</string>
<key>NSAppleEventsUsageDescription</key>
<string>MacSync reads Mail.app messages and sends iMessages to sync to your personal server.</string>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsLocalNetworking</key>
<true/>
</dict>
</dict>
</plist>
PLIST
fi
print_success "Installed to $APP_BUNDLE"
}
# Stable code-signing identity for TCC-grant persistence across rebuilds.
# Preference order:
# 1. $MAC_SYNC_SIGNING_IDENTITY if set
# 2. Self-signed identity with CN="Quinn Norton" if present in keychain
# 3. Auto-create the "Quinn Norton" self-signed identity (requires interactive session)
# Never pick up Apple Development certs automatically — those may carry a
# legacy Apple ID / email we don't want showing up in `codesign -dv`.
SIGNING_IDENTITY_PREFERRED="${MAC_SYNC_SIGNING_IDENTITY:-}"
SIGNING_IDENTITY_FALLBACK="Quinn Norton"
SIGNING_IDENTITY=""
# Dedicated keychain so deployments over SSH don't need the user's login
# password. The password below is embedded intentionally — this keychain only
# ever holds the self-signed code-signing cert and nothing secret.
MAC_SYNC_KEYCHAIN_PW="quinn-unlock"
MAC_SYNC_KEYCHAIN_PATH="$HOME/Library/Keychains/macsync-signing.keychain-db"
ensure_signing_identity() {
# 1. Prefer an explicit identity passed via $MAC_SYNC_SIGNING_IDENTITY env var.
if [[ -n "$SIGNING_IDENTITY_PREFERRED" ]] && \
security find-identity -p codesigning -v 2>/dev/null | grep -q "$SIGNING_IDENTITY_PREFERRED"; then
SIGNING_IDENTITY="$SIGNING_IDENTITY_PREFERRED"
print_success "Using preferred signing identity: $SIGNING_IDENTITY"
return 0
fi
# 2. Prefer the self-signed "Quinn Norton" identity.
SIGNING_IDENTITY="$SIGNING_IDENTITY_FALLBACK"
# Count how many signing identities are in the dedicated keychain.
# NOTE: we deliberately omit -v (valid-only filter). The self-signed
# cert is not in System trust settings — find-identity -v returns 0
# for it, which would loop-regenerate the cert every install and
# invalidate TCC grants (FDA, AppleEvents, etc.) bound to the old
# cert hash. Without -v we still match the cert by label and identifier.
local identity_count
identity_count=$(security find-identity -p codesigning "$MAC_SYNC_KEYCHAIN_PATH" 2>/dev/null | grep -c "\"$SIGNING_IDENTITY\"" || true)
if [[ "$identity_count" -eq 1 ]]; then
print_success "Code-signing identity '$SIGNING_IDENTITY' already in keychain"
return 0
fi
print_step "Creating self-signed code-signing identity '$SIGNING_IDENTITY'..."
# Recreate the keychain to remove duplicates or start fresh (identity count was 0 or >1).
if [[ -f "$MAC_SYNC_KEYCHAIN_PATH" ]]; then
security delete-keychain "$MAC_SYNC_KEYCHAIN_PATH" 2>/dev/null || true
rm -f "$MAC_SYNC_KEYCHAIN_PATH"
fi
security create-keychain -p "$MAC_SYNC_KEYCHAIN_PW" "macsync-signing.keychain"
security unlock-keychain -p "$MAC_SYNC_KEYCHAIN_PW" "$MAC_SYNC_KEYCHAIN_PATH"
# No auto-lock timeout for this keychain (holds only a public self-signed cert).
# Requires interactive context on some macOS versions — best-effort.
security set-keychain-settings -t 0 -l "$MAC_SYNC_KEYCHAIN_PATH" 2>/dev/null || \
print_info "(skipped set-keychain-settings — keychain will auto-lock on timeout)"
# Add to the user search list so codesign can find the identity.
local existing_keychains
existing_keychains=$(security list-keychains -d user | tr -d '"' | xargs)
if ! echo "$existing_keychains" | grep -q "macsync-signing.keychain"; then
security list-keychains -d user -s $existing_keychains "$MAC_SYNC_KEYCHAIN_PATH"
fi
local tmp_dir
tmp_dir=$(mktemp -d)
trap "rm -rf '$tmp_dir'" RETURN
cat > "$tmp_dir/cert.conf" <<EOF
[req]
distinguished_name = req_dn
prompt = no
x509_extensions = v3_req
[req_dn]
CN = $SIGNING_IDENTITY
O = $SIGNING_IDENTITY
emailAddress = quinn90210@pm.me
[v3_req]
basicConstraints = CA:false
keyUsage = digitalSignature
extendedKeyUsage = codeSigning
EOF
openssl req -x509 -newkey rsa:2048 \
-keyout "$tmp_dir/key.pem" -out "$tmp_dir/cert.pem" \
-days 3650 -nodes -config "$tmp_dir/cert.conf" 2>&1 | grep -v '\-\-\-\-\-' || true
# Separate imports — LibreSSL's p12 output fails MAC verification in
# macOS's `security import`, so we skip pkcs12.
# -A allows all apps to access without ACL prompts.
# Order matters: import the PRIVATE KEY first, then the cert. macOS pairs
# them at cert-import time by matching the cert's public key against
# already-imported keys. Reversed order leaves "Imported Private Key" with
# no cert attached → `security find-identity` won't see it as an identity,
# and codesign fails with errSecInternalComponent.
# Keep unlocked with long timeout so imports + partition-list run without prompts.
security unlock-keychain -p "$MAC_SYNC_KEYCHAIN_PW" "$MAC_SYNC_KEYCHAIN_PATH"
security set-keychain-settings -t 3600 "$MAC_SYNC_KEYCHAIN_PATH" 2>/dev/null || true
security import "$tmp_dir/key.pem" -k "$MAC_SYNC_KEYCHAIN_PATH" -A 2>/dev/null || true
security import "$tmp_dir/cert.pem" -k "$MAC_SYNC_KEYCHAIN_PATH" -A
# Grant codesign access to the private key — must happen while keychain is unlocked.
security unlock-keychain -p "$MAC_SYNC_KEYCHAIN_PW" "$MAC_SYNC_KEYCHAIN_PATH"
security set-key-partition-list \
-S "apple-tool:,apple:,codesign:" \
-s -k "$MAC_SYNC_KEYCHAIN_PW" \
"$MAC_SYNC_KEYCHAIN_PATH" >/dev/null 2>&1 || true
print_success "Identity '$SIGNING_IDENTITY' created in dedicated keychain"
}
code_sign() {
ensure_signing_identity
# Add the signing keychain to the search list so `codesign` can resolve
# the identity by name (the --keychain flag alone is not enough). It is
# removed again after signing — left in the search list it hangs the
# agent's own startup keychain reads, since it auto-locks.
security list-keychains -d user -s "$HOME/Library/Keychains/login.keychain-db" "$MAC_SYNC_KEYCHAIN_PATH"
# Keep the signing keychain unlocked through the codesign call.
security unlock-keychain -p "$MAC_SYNC_KEYCHAIN_PW" "$MAC_SYNC_KEYCHAIN_PATH" 2>/dev/null || true
security set-keychain-settings -t 300 "$MAC_SYNC_KEYCHAIN_PATH" 2>/dev/null || true
print_step "Code signing with entitlements..."
local entitlements="$MAC_SYNC_ROOT/src/client/Resources/LilithMacSync.entitlements"
if [[ ! -f "$entitlements" ]]; then
print_error "Entitlements file not found: $entitlements"
exit 1
fi
# Sign directly using the dedicated keychain (explicitly unlocked above).
# No launchctl asuser needed — codesign --keychain works over SSH as long
# as the keychain is unlocked and the partition list grants codesign access.
local sign_ok=true
if codesign --force --deep --sign "$SIGNING_IDENTITY" \
--keychain "$MAC_SYNC_KEYCHAIN_PATH" \
--entitlements "$entitlements" \
"$APP_BUNDLE" 2>&1; then
print_success "Signed with '$SIGNING_IDENTITY' (stable identity — TCC grants persist)"
else
sign_ok=false
fi
# Restore the search list to just the login keychain (whether or not
# signing succeeded) — leaving the signing keychain in it hangs the
# agent's startup keychain reads.
security list-keychains -d user -s "$HOME/Library/Keychains/login.keychain-db"
print_info "Signing keychain removed from search list"
if [[ "$sign_ok" != "true" ]]; then
print_error "Code signing failed — TCC grants will be lost. Check keychain."
exit 1
fi
}
configure_app() {
print_step "Configuring app defaults..."
mkdir -p "$APP_SUPPORT_DIR"
if [[ ! "$SERVER_URL" =~ ^https?:// ]]; then
print_error "SERVER_URL must start with http:// or https://"
exit 1
fi
defaults write "$BUNDLE_ID" serverURL "$SERVER_URL"
defaults write "$BUNDLE_ID" webServerPort -int 8765
print_success "serverURL: $SERVER_URL"
print_success "webServerPort: 8765"
# Drop a discoverable config file at ~/.config/com.lilith.mac-sync/config.json
# so users can see + edit settings in plain text. App reads this at launch
# and merges into UserDefaults (ConfigFile.load()), so this file wins over
# prior `defaults write` values.
local config_dir="$HOME/.config/com.lilith.mac-sync"
local config_file="$config_dir/config.json"
if [[ ! -f "$config_file" ]]; then
mkdir -p "$config_dir"
cat > "$config_file" <<JSON
{
"serverURL": "$SERVER_URL",
"webServerPort": 8765
}
JSON
print_success "config: $config_file"
else
print_info "config: $config_file (preserved — edit to change settings)"
fi
}
create_launch_agent() {
print_step "Creating LaunchAgent $BUNDLE_ID..."
mkdir -p "$LAUNCH_AGENTS_DIR"
cat > "$PLIST_FILE" <<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>Label</key>
<string>$BUNDLE_ID</string>
<key>ProgramArguments</key>
<array>
<string>$APP_BUNDLE/Contents/MacOS/$APP_NAME</string>
</array>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<dict>
<key>SuccessfulExit</key>
<false/>
<key>Crashed</key>
<true/>
</dict>
<key>StandardOutPath</key>
<string>$APP_SUPPORT_DIR/stdout.log</string>
<key>StandardErrorPath</key>
<string>$APP_SUPPORT_DIR/stderr.log</string>
<key>EnvironmentVariables</key>
<dict>
<key>PATH</key>
<string>/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin</string>
</dict>
</dict>
</plist>
PLIST
launchctl load "$PLIST_FILE"
print_success "LaunchAgent loaded — auto-starts on login"
}
verify_install() {
print_step "Verifying installation..."
local errors=0
[[ -f "$APP_BUNDLE/Contents/MacOS/$APP_NAME" ]] && print_success "Binary present" || { print_error "Binary missing"; errors=$((errors+1)); }
[[ -f "$PLIST_FILE" ]] && print_success "LaunchAgent present" || { print_error "LaunchAgent missing"; errors=$((errors+1)); }
pgrep -x "$APP_NAME" >/dev/null && print_success "Agent running" || print_warning "Agent not yet running (may need TCC permissions)"
return $errors
}
show_permissions_guide() {
echo ""
echo -e "${YELLOW}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
echo -e "${YELLOW} Grant macOS Permissions${NC}"
echo -e "${YELLOW}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
echo ""
echo "Open System Settings → Privacy & Security and grant $DISPLAY_NAME:"
echo " • Full Disk Access (required for iMessage chat.db)"
echo " • Contacts"
echo " • Photos"
echo " • Automation → Messages + Mail"
echo ""
echo "App bundle: $APP_BUNDLE"
echo ""
if [[ -t 0 ]]; then
read -rp "Press Enter to open System Settings → Full Disk Access (or 'n' to skip): " resp
if [[ "${resp:-}" != "n" && "${resp:-}" != "N" ]]; then
open "x-apple.systempreferences:com.apple.preference.security?Privacy_AllFiles" 2>/dev/null || \
open "/System/Applications/System Settings.app"
fi
fi
}
print_completion() {
echo ""
echo -e "${GREEN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
echo -e "${GREEN} Installation complete${NC}"
echo -e "${GREEN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
echo ""
echo " Logs: tail -f $APP_SUPPORT_DIR/stderr.log"
echo " Restart: launchctl unload $PLIST_FILE && launchctl load $PLIST_FILE"
echo " Status: http://localhost:8765/"
echo ""
}
main() {
check_macos
check_swift
local IS_FRESH_INSTALL="true"
[[ -f "$PLIST_FILE" ]] && IS_FRESH_INSTALL="false" && print_info "Updating existing install"
stop_existing_agent
build_application
install_binary
code_sign
configure_app
create_launch_agent
if verify_install; then
[[ "$IS_FRESH_INSTALL" == "true" ]] && show_permissions_guide
print_completion
else
echo ""
print_error "Installation completed with errors — review output above"
exit 1
fi
}
main "$@"