433 lines
17 KiB
Bash
Executable file
433 lines
17 KiB
Bash
Executable file
#!/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 valid signing identities are in the dedicated keychain.
|
||
local identity_count
|
||
identity_count=$(security find-identity -p codesigning -v "$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
|
||
# 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.
|
||
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
|
||
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 "$@"
|