macsync/deploy/install.sh

453 lines
18 KiB
Bash
Raw Permalink Normal View History

#!/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 "$@"