diff --git a/imessage-macos/.swiftlint.yml b/imessage-macos/.swiftlint.yml new file mode 100755 index 0000000..7083245 --- /dev/null +++ b/imessage-macos/.swiftlint.yml @@ -0,0 +1,106 @@ +# iMessage macOS Agent - SwiftLint Configuration +# Extends shared tooling from @packages/@swift + +# Note: parent_config with ~ paths doesn't work in CI Docker containers +# This file contains the full configuration for CI compatibility + +# Opt-in rules for code quality +opt_in_rules: + - force_cast + - force_try + - force_unwrapping + - sorted_imports + - closure_spacing + - empty_count + - first_where + - flatmap_over_map_reduce + - last_where + - multiline_parameters + - operator_usage_whitespace + - redundant_nil_coalescing + - sorted_first_last + - toggle_bool + - vertical_parameter_alignment_on_call + - explicit_init + - fallthrough + - fatal_error_message + - function_default_parameter_at_end + - implicit_return + - joined_default_parameter + - prefer_self_type_over_type_of_self + - redundant_type_annotation + - unowned_variable_capture + - accessibility_label_for_image + - private_outlet + - discouraged_optional_collection + - discouraged_optional_boolean + +# Disabled rules +disabled_rules: + - todo # Allow TODOs during development + - line_length # 120 char limit can be too restrictive + - file_length # Some service files are large + - type_name # Sometimes short names are appropriate + +# Rule configurations +function_parameter_count: + warning: 6 + error: 8 + +cyclomatic_complexity: + warning: 20 + error: 30 + +type_body_length: + warning: 400 + error: 600 + +function_body_length: + warning: 110 + error: 175 + +identifier_name: + min_length: + warning: 2 + max_length: + warning: 50 + excluded: + - id + - x + - y + - z + - db + - ip + - ok + - i + - j + - k + - n + +nesting: + type_level: + warning: 2 + error: 3 + function_level: + warning: 3 + error: 4 + +# Custom rules for Lilith Platform conventions +custom_rules: + no_print_statements: + name: "No Print Statements" + regex: '^\s*print\s*\(' + match_kinds: + - identifier + message: "Use NSLog() or os.Logger instead of print() for production code" + severity: warning + +# Excluded paths +excluded: + - .build + - DerivedData + - "*.generated.swift" + - Package.swift + +# Reporter type +reporter: "xcode" diff --git a/imessage-macos/INSTALL.md b/imessage-macos/INSTALL.md new file mode 100755 index 0000000..8625cab --- /dev/null +++ b/imessage-macos/INSTALL.md @@ -0,0 +1,224 @@ +# iMessage macOS - Agent Installation + +This directory contains the macOS menu bar agent that syncs iMessages to the iMessage Sync server. + +## Prerequisites + +- macOS 13.0 (Ventura) or later +- Swift 5.9+ (included with Xcode or Command Line Tools) +- Full Disk Access permission (for reading iMessage database) + +## Installation + +### Automatic Installation (Recommended) + +The installer script handles the complete installation process: + +```bash +./install.sh [server_url] +``` + +**Examples:** + +```bash +# Interactive mode (prompts for server URL) +./install.sh + +# With server URL +./install.sh http://localhost:3100 + +# Production server +./install.sh https://assistant.lilith.is +``` + +### What the Installer Does + +1. **Builds the application** - Compiles Swift code with release optimizations +2. **Installs the binary** - Creates app bundle in `~/Applications/iMessageMacos.app` +3. **Configures auto-start** - Sets up LaunchAgent to run on login +4. **Stores configuration** - Saves server URL in macOS preferences +5. **Guides permissions** - Shows how to grant Full Disk Access + +### Post-Installation Steps + +1. **Grant Full Disk Access** (required for iMessage database access): + - Open System Settings → Privacy & Security → Full Disk Access + - Click the '+' button + - Navigate to `~/Applications/iMessageMacos.app` + - Enable the toggle + +2. **Complete device registration**: + - Click the menu bar icon (💬) in the top-right + - Click "Settings" + - Follow the registration flow + - Enter the verification code shown on the server + +3. **Verify sync is working**: + - Check the menu bar icon for sync status + - View logs: `tail -f ~/Library/Application\ Support/iMessageMacos/stderr.log` + +## Manual Installation + +If you prefer to install manually: + +```bash +# 1. Build +swift build -c release + +# 2. Create app bundle +mkdir -p ~/Applications/iMessageMacos.app/Contents/MacOS +cp .build/release/iMessageMacos ~/Applications/iMessageMacos.app/Contents/MacOS/ + +# 3. Set server URL +defaults write com.lilith.imessage-macos apiBaseURL "http://localhost:3100" + +# 4. Create LaunchAgent (see install.sh for plist template) + +# 5. Load LaunchAgent +launchctl load ~/Library/LaunchAgents/com.lilith.imessage-macos.plist +``` + +## Configuration + +The agent stores configuration in macOS UserDefaults: + +```bash +# View current configuration +defaults read com.lilith.imessage-macos + +# Change server URL +defaults write com.lilith.imessage-macos apiBaseURL "https://new-server.example.com" + +# Restart agent to apply changes +launchctl unload ~/Library/LaunchAgents/com.lilith.imessage-macos.plist +launchctl load ~/Library/LaunchAgents/com.lilith.imessage-macos.plist +``` + +Authentication tokens are stored securely in the macOS Keychain. + +## Troubleshooting + +### Agent not starting + +```bash +# Check LaunchAgent status +launchctl list | grep imessage-macos + +# View error logs +tail -f ~/Library/Application\ Support/iMessageMacos/stderr.log + +# Manually start to see errors +~/Applications/iMessageMacos.app/Contents/MacOS/iMessageMacos +``` + +### Database access errors + +The agent needs Full Disk Access to read the iMessage database at: +`~/Library/Messages/chat.db` + +If you see errors like "unable to open database file", grant Full Disk Access: + +1. System Settings → Privacy & Security → Full Disk Access +2. Add iMessageMacos.app +3. Restart the agent + +### Build failures + +```bash +# Clean and rebuild +rm -rf .build +swift build -c release + +# Check Swift version (requires 5.9+) +swift --version + +# Update dependencies +swift package update +``` + +### Network errors + +```bash +# Test server connectivity +curl http://localhost:3100/health + +# Check server URL configuration +defaults read com.lilith.imessage-macos apiBaseURL +``` + +## Uninstallation + +The uninstaller removes all components: + +```bash +./uninstall.sh +``` + +This removes: +- Application binary +- LaunchAgent configuration +- Application data and logs +- Preferences +- Keychain entries + +You may need to manually remove Full Disk Access permission in System Settings. + +## File Locations + +| Component | Path | +|-----------|------| +| **Application** | `~/Applications/iMessageMacos.app` | +| **LaunchAgent** | `~/Library/LaunchAgents/com.lilith.imessage-macos.plist` | +| **Logs** | `~/Library/Application Support/iMessageMacos/*.log` | +| **Preferences** | `~/Library/Preferences/com.lilith.imessage-macos.plist` | +| **Keychain** | Keychain Access → "authToken" | + +## Development + +For development and testing: + +```bash +# Run without installing +swift run + +# Build debug version +swift build + +# Run tests (when available) +swift test + +# Clean build artifacts +swift package clean +``` + +## Architecture + +The agent consists of: + +- **Menu Bar UI** - SwiftUI-based menu bar icon and settings +- **iMessage Reader** - GRDB-based SQLite reader for Messages database +- **Sync Manager** - Periodic sync orchestrator (every 30 seconds) +- **API Client** - HTTP client for server communication (Alamofire) + +See the source code in `Sources/` for implementation details. + +## Security + +- Auth tokens stored in macOS Keychain (secure storage) +- iMessage database accessed read-only +- No message content stored locally by the agent +- All sync traffic uses HTTPS in production + +## Support + +For issues or questions: +- Check logs in `~/Library/Application Support/iMessageMacos/` +- Review server logs for API errors +- Verify Full Disk Access is granted +- Ensure server URL is correct + +--- + +**Last Updated**: 2026-03-03 +**Platforms**: macOS 13.0+ +**License**: Proprietary - Lilith Platform diff --git a/imessage-macos/Package.swift b/imessage-macos/Package.swift new file mode 100755 index 0000000..5a24960 --- /dev/null +++ b/imessage-macos/Package.swift @@ -0,0 +1,30 @@ +// swift-tools-version: 5.9 +import PackageDescription + +let package = Package( + name: "iMessageMacos", + platforms: [ + .macOS(.v13) + ], + dependencies: [ + .package(url: "https://github.com/groue/GRDB.swift.git", from: "6.24.0"), + .package(url: "https://github.com/Alamofire/Alamofire.git", from: "5.8.0"), + .package(url: "https://github.com/SwiftyJSON/SwiftyJSON.git", from: "5.0.0"), + .package(url: "https://github.com/SwiftGen/SwiftGenPlugin", from: "6.6.0"), + .package(url: "https://github.com/httpswift/swifter.git", from: "1.5.0"), + .package(path: "../../../@packages/@swift/@macos/menu-bar"), + ], + targets: [ + .executableTarget( + name: "iMessageMacos", + dependencies: [ + .product(name: "GRDB", package: "GRDB.swift"), + .product(name: "Alamofire", package: "Alamofire"), + .product(name: "SwiftyJSON", package: "SwiftyJSON"), + .product(name: "Swifter", package: "swifter"), + .product(name: "LilithMenuBar", package: "menu-bar"), + ], + path: "Sources" + ), + ] +) diff --git a/imessage-macos/deploy-remote.sh b/imessage-macos/deploy-remote.sh new file mode 100755 index 0000000..110a9d9 --- /dev/null +++ b/imessage-macos/deploy-remote.sh @@ -0,0 +1,246 @@ +#!/bin/bash +set -euo pipefail + +# Deploy iMessage macOS agent to plum (MacBook) via rsync + SSH. +# +# What this does: +# 1. Rsyncs the entire imessage-macos/ source to plum staging dir +# 2. SSHs into plum and runs install.sh (builds Swift + installs) +# 3. Verifies the agent is running +# +# Usage: +# ./deploy-remote.sh # deploy with default URL +# ./deploy-remote.sh https://conversations.nasty.sh # deploy with custom URL +# ./deploy-remote.sh uninstall # uninstall on plum + +# Colors +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' + +# Configuration +PLUM_HOST="${PLUM_HOST:-plum}" +PLUM_STAGING="imessage-macos-staging" +# Parse args: skip flags, use positional arg as server URL +NO_BUMP=false +SERVER_URL="http://10.0.0.11:3100" +for arg in "$@"; do + case "$arg" in + --no-bump) NO_BUMP=true ;; + uninstall|frontend) ;; # handled by case below + *) SERVER_URL="$arg" ;; + esac +done +SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" + +print_step() { echo -e "${GREEN}▸${NC} $1"; } +print_info() { echo -e "${BLUE}ℹ${NC} $1"; } +print_error() { echo -e "${RED}✗${NC} $1"; } +print_success() { echo -e "${GREEN}✓${NC} $1"; } + +echo -e "${BLUE}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}" +echo -e "${BLUE} iMessage macOS - Remote Deployment${NC}" +echo -e "${BLUE}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}" +echo "" + +# --------------------------------------------------------------------------- +# Connectivity check +# --------------------------------------------------------------------------- + +check_plum() { + print_step "Checking SSH connection to $PLUM_HOST..." + if ! ssh -o ConnectTimeout=5 "$PLUM_HOST" 'echo ok' >/dev/null 2>&1; then + print_error "Cannot connect to $PLUM_HOST" + echo "Make sure:" + echo " - The MacBook is on and connected to the network" + echo " - SSH config exists for host '$PLUM_HOST'" + exit 1 + fi + print_success "Connected to $PLUM_HOST" +} + +# --------------------------------------------------------------------------- +# Install +# --------------------------------------------------------------------------- + +cmd_install() { + check_plum + + # Rsync source to plum (excludes build artifacts) + # Mirror the local directory structure so relative Package.swift paths resolve correctly + local plum_base="$PLUM_STAGING" + local plum_app_dir="$plum_base/@applications/@messenger/imessage-macos" + + print_step "Syncing source to $PLUM_HOST:~/$plum_app_dir/" + ssh "$PLUM_HOST" "mkdir -p ~/$plum_app_dir" + rsync -az --delete \ + --exclude '.build/' \ + --exclude 'DerivedData/' \ + --exclude '.swiftpm/' \ + "$SCRIPT_DIR/" \ + "$PLUM_HOST:~/$plum_app_dir/" + + # Sync local package dependencies referenced by relative paths in Package.swift + # ../../../@packages/@swift/@macos/menu-bar -> @packages/@swift/@macos/menu-bar + local local_packages_root + local_packages_root="$(cd "$SCRIPT_DIR/../../../@packages/@swift/@macos" && pwd)" + local plum_packages_dir="$plum_base/@packages/@swift/@macos" + + if [[ -d "$local_packages_root/menu-bar" ]]; then + print_step "Syncing local Swift package dependencies..." + ssh "$PLUM_HOST" "mkdir -p ~/$plum_packages_dir" + rsync -az --delete \ + --exclude '.build/' \ + "$local_packages_root/menu-bar/" \ + "$PLUM_HOST:~/$plum_packages_dir/menu-bar/" + print_success "Local packages synced" + fi + + # Sync frontend-macos-client (webapp served by the local web server) + local frontend_src="$SCRIPT_DIR/../frontend-macos-client" + local plum_frontend_dir="$plum_base/@applications/@messenger/frontend-macos-client" + + if [[ -d "$frontend_src" ]]; then + print_step "Syncing webapp frontend..." + ssh "$PLUM_HOST" "mkdir -p ~/$plum_frontend_dir" + rsync -az --delete \ + "$frontend_src/" \ + "$PLUM_HOST:~/$plum_frontend_dir/" + print_success "Frontend synced" + fi + + print_success "Source synced" + + # Bump build number in shared VERSION.json (unless --no-bump passed by ./run) + local version_file="$SCRIPT_DIR/../VERSION.json" + if [[ "$NO_BUMP" != "true" ]] && [[ -f "$version_file" ]] && command -v jq &>/dev/null; then + print_step "Bumping build version..." + local builds=$(jq '.builds' "$version_file") + local new_builds=$((builds + 1)) + local major=$(jq -r '.major' "$version_file") + local minor=$(jq -r '.minor' "$version_file") + local new_version="${major}.${minor}.${new_builds}" + local today=$(date +%Y-%m-%d) + + jq --arg v "$new_version" --argjson b "$new_builds" --arg d "$today" \ + '.builds = $b | .version = $v | .lastBuild = $d' \ + "$version_file" > "${version_file}.tmp" && mv "${version_file}.tmp" "$version_file" + print_success "Version: $new_version" + fi + + # Sync VERSION.json to plum for generate-version.sh + if [[ -f "$version_file" ]]; then + rsync -az "$version_file" "$PLUM_HOST:~/$plum_app_dir/VERSION.json" + rsync -az "$version_file" "$PLUM_HOST:~/$plum_base/@applications/@messenger/VERSION.json" + fi + + # Run installer on plum + print_step "Building and installing on $PLUM_HOST..." + ssh -t "$PLUM_HOST" "cd ~/$plum_app_dir && chmod +x install.sh && ./install.sh '$SERVER_URL'" + + # Verify agent is running + print_step "Checking agent status..." + ssh "$PLUM_HOST" bash << 'CHECK_SCRIPT' +sleep 2 +if pgrep -x iMessageMacos >/dev/null; then + echo "✓ Agent is running" + echo "" + echo "Recent logs:" + tail -10 ~/Library/Application\ Support/iMessageMacos/stderr.log 2>/dev/null || echo "(no logs yet)" +else + echo "⚠ Agent is not running (may need Full Disk Access)" + echo "" + echo "Grant Full Disk Access:" + echo " System Settings → Privacy & Security → Full Disk Access" + echo " Add ~/Applications/iMessageMacos.app" +fi +CHECK_SCRIPT + + # Clean up staging + print_step "Cleaning up staging directory..." + ssh "$PLUM_HOST" "rm -rf ~/$plum_base" + print_success "Staging cleaned" + + echo "" + print_success "Deployment complete!" + echo "" + print_info "View logs: ssh $PLUM_HOST 'tail -f ~/Library/Application\\ Support/iMessageMacos/stderr.log'" +} + +# --------------------------------------------------------------------------- +# Uninstall +# --------------------------------------------------------------------------- + +cmd_uninstall() { + check_plum + + local plum_base="$PLUM_STAGING" + + # Rsync just the uninstall script + print_step "Syncing uninstaller to $PLUM_HOST..." + ssh "$PLUM_HOST" "mkdir -p ~/$plum_base" + rsync -az "$SCRIPT_DIR/uninstall.sh" "$PLUM_HOST:~/$plum_base/" + + print_step "Running uninstaller on $PLUM_HOST..." + ssh -t "$PLUM_HOST" "cd ~/$plum_base && chmod +x uninstall.sh && ./uninstall.sh" + + ssh "$PLUM_HOST" "rm -rf ~/$plum_base" + print_success "Staging cleaned" + + echo "" + print_success "iMessage macOS uninstalled from $PLUM_HOST" +} + +# --------------------------------------------------------------------------- +# Frontend-only deploy (no rebuild, no re-sign, preserves TCC permissions) +# --------------------------------------------------------------------------- + +cmd_frontend() { + check_plum + + local frontend_src="$SCRIPT_DIR/../frontend-macos-client" + local app_webapp="Applications/iMessageMacos.app/Contents/Resources/webapp" + + if [[ ! -d "$frontend_src" ]]; then + print_error "Frontend source not found at $frontend_src" + exit 1 + fi + + print_step "Syncing webapp to $PLUM_HOST:~/$app_webapp/" + rsync -az \ + "$frontend_src/" \ + "$PLUM_HOST:~/$app_webapp/" + print_success "Frontend synced" + + # Restart agent to pick up new files (kill → LaunchAgent auto-restarts) + print_step "Restarting agent..." + ssh "$PLUM_HOST" "pkill -x iMessageMacos 2>/dev/null || true" + sleep 2 + + if ssh "$PLUM_HOST" "pgrep -x iMessageMacos >/dev/null"; then + print_success "Agent restarted" + else + print_error "Agent did not restart — check LaunchAgent" + fi + + echo "" + print_success "Frontend deploy complete (no re-sign, TCC preserved)" +} + +# --------------------------------------------------------------------------- +# Entry +# --------------------------------------------------------------------------- + +case "${1:-}" in + uninstall) + cmd_uninstall + ;; + frontend) + cmd_frontend + ;; + *) + cmd_install + ;; +esac diff --git a/imessage-macos/generate-version.sh b/imessage-macos/generate-version.sh new file mode 100755 index 0000000..22fdb31 --- /dev/null +++ b/imessage-macos/generate-version.sh @@ -0,0 +1,92 @@ +#!/bin/bash +# +# Regenerate AppVersion.swift from VERSION.json +# +# Run this before building the macOS app: +# ./generate-version.sh && swift build +# +# This script: +# 1. Reads VERSION.json (bumped by CI, not this script) +# 2. Generates Sources/AppVersion.swift with current values +# +# NOTE: CI bumps VERSION.json on each build. This script is READ-ONLY. +# To manually bump versions, use: ./workflow/version build +# +# Reads: VERSION.json (searched up from script dir, or falls back to defaults) +# Writes: Sources/AppVersion.swift +# + +set -e + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +OUTPUT_FILE="$SCRIPT_DIR/Sources/AppVersion.swift" + +# Search for VERSION.json upward from script directory +VERSION_FILE="" +search_dir="$SCRIPT_DIR" +while [[ "$search_dir" != "/" ]]; do + if [[ -f "$search_dir/VERSION.json" ]]; then + VERSION_FILE="$search_dir/VERSION.json" + break + fi + search_dir="$(dirname "$search_dir")" +done + +# If VERSION.json found, parse it; otherwise use defaults +if [[ -n "$VERSION_FILE" ]]; then + MAJOR=$(jq -r '.major' "$VERSION_FILE") + MERGES=$(jq -r '.merges // 0' "$VERSION_FILE") + BUILDS=$(jq -r '.builds' "$VERSION_FILE") + VERSION=$(jq -r '.version' "$VERSION_FILE") + LAST_BUILD=$(jq -r '.lastBuild' "$VERSION_FILE") +else + echo "Warning: VERSION.json not found, using defaults" >&2 + MAJOR=0 + MERGES=0 + BUILDS=0 + VERSION="0.0.0" + LAST_BUILD="unknown" +fi + +# Get git info +GIT_COMMIT=$(git rev-parse --short HEAD 2>/dev/null || echo "unknown") +GIT_BRANCH=$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo "unknown") + +# Generate Swift file +cat > "$OUTPUT_FILE" << EOF +// +// AppVersion.swift +// Generated from VERSION.json - DO NOT EDIT MANUALLY +// +// Regenerate with: ./generate-version.sh +// + +import Foundation + +enum AppVersion { + static let version = "$VERSION" + static let major = $MAJOR + static let merges = $MERGES + static let builds = $BUILDS + + static let gitCommit = "$GIT_COMMIT" + static let gitBranch = "$GIT_BRANCH" + static let buildTime = "$LAST_BUILD" + + static var displayVersion: String { + "v\(version)" + } + + static var fullVersion: String { + "Version \(version) (\(gitCommit))" + } + + static var detailedVersion: String { + "v\(version) (\(gitCommit) on \(gitBranch))" + } +} +EOF + +echo "Generated: $OUTPUT_FILE" +echo " Version: $VERSION" +echo " Commit: $GIT_COMMIT" diff --git a/imessage-macos/iMessageMacos.entitlements b/imessage-macos/iMessageMacos.entitlements new file mode 100755 index 0000000..bf4f61c --- /dev/null +++ b/imessage-macos/iMessageMacos.entitlements @@ -0,0 +1,31 @@ + + + + + + com.apple.security.personal-information.addressbook + + + + com.apple.security.files.user-selected.read-only + + + + com.apple.security.network.client + + + + com.apple.security.network.server + + + + com.apple.security.automation.apple-events + + + + com.apple.security.keychain-access-groups + + $(AppIdentifierPrefix)com.lilith.imessage-macos + + + diff --git a/imessage-macos/install.sh b/imessage-macos/install.sh new file mode 100755 index 0000000..dbb6315 --- /dev/null +++ b/imessage-macos/install.sh @@ -0,0 +1,537 @@ +#!/bin/bash +set -euo pipefail + +# iMessage macOS - Agent Installer +# This script installs and configures the iMessage macOS menu bar agent + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' # No Color + +# Configuration +APP_NAME="iMessageMacos" +BUNDLE_ID="com.lilith.imessage-macos" +INSTALL_DIR="$HOME/Applications" +APP_SUPPORT_DIR="$HOME/Library/Application Support/$APP_NAME" +LAUNCH_AGENTS_DIR="$HOME/Library/LaunchAgents" +PLIST_FILE="$LAUNCH_AGENTS_DIR/$BUNDLE_ID.plist" + +# Script directory (where this script lives) +SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" +MESSENGER_ROOT="$( cd "$SCRIPT_DIR/.." && pwd )" + +# Functions +print_header() { + echo -e "${BLUE}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}" + echo -e "${BLUE} iMessage macOS - Agent Installer${NC}" + echo -e "${BLUE}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}" + echo "" +} + +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" +} + +get_default_port() { + local default="$1" + echo "$default" +} + +check_macos() { + if [[ "$OSTYPE" != "darwin"* ]]; then + print_error "This script is designed for macOS only" + exit 1 + fi +} + +check_swift() { + if ! command -v swift &> /dev/null; then + print_error "Swift is not installed. Please install Xcode or Command Line Tools." + echo "" + echo "To install Command Line Tools:" + echo " xcode-select --install" + exit 1 + fi + + local swift_version=$(swift --version | head -n1) + print_info "Found: $swift_version" +} + +stop_existing_agent() { + print_step "Stopping existing agent (if running)..." + + # Unload LaunchAgent if it exists + if [[ -f "$PLIST_FILE" ]]; then + launchctl unload "$PLIST_FILE" 2>/dev/null || true + print_info "Stopped LaunchAgent" + fi + + # Kill any running instances + pkill -x "$APP_NAME" 2>/dev/null || true + sleep 1 +} + +build_application() { + print_step "Building Swift application..." + + cd "$SCRIPT_DIR" + + # Generate version file from VERSION.json + print_info "Generating version info..." + if [[ -x "./generate-version.sh" ]]; then + ./generate-version.sh + else + print_warning "generate-version.sh not found or not executable, skipping version generation" + fi + + # Clean previous builds + if [[ -d ".build" ]]; then + rm -rf .build + fi + + # Build release version + print_info "Compiling with optimizations (this may take a minute)..." + if swift build -c release; then + print_success "Build successful" + else + print_error "Build failed" + exit 1 + fi +} + +install_binary() { + print_step "Installing application..." + + # Create installation directory + mkdir -p "$INSTALL_DIR" + + # Copy binary + local binary_path="$SCRIPT_DIR/.build/release/$APP_NAME" + local install_path="$INSTALL_DIR/$APP_NAME.app/Contents/MacOS" + + if [[ ! -f "$binary_path" ]]; then + print_error "Binary not found at $binary_path" + exit 1 + fi + + # Create app bundle structure + mkdir -p "$install_path" + mkdir -p "$INSTALL_DIR/$APP_NAME.app/Contents/Resources" + mkdir -p "$INSTALL_DIR/$APP_NAME.app/Contents/Resources/webapp" + + # Copy binary + cp "$binary_path" "$install_path/$APP_NAME" + chmod +x "$install_path/$APP_NAME" + + # Copy webapp files + local webapp_src="$SCRIPT_DIR/../frontend-macos-client" + local webapp_dest="$INSTALL_DIR/$APP_NAME.app/Contents/Resources/webapp" + + if [[ -d "$webapp_src" ]]; then + print_info "Copying webapp files..." + cp "$webapp_src/index.html" "$webapp_dest/" + cp "$webapp_src/styles.css" "$webapp_dest/" + cp "$webapp_src/app.js" "$webapp_dest/" + print_success "Webapp bundled into app" + else + print_warning "Webapp source not found at $webapp_src" + fi + + # Create Info.plist + cat > "$INSTALL_DIR/$APP_NAME.app/Contents/Info.plist" < + + + + CFBundleExecutable + $APP_NAME + CFBundleIdentifier + $BUNDLE_ID + CFBundleName + iMessage macOS + CFBundlePackageType + APPL + CFBundleVersion + 1.0.0 + LSUIElement + + NSHighResolutionCapable + + NSContactsUsageDescription + iMessage macOS needs access to your Contacts to display names for your iMessage conversations. + NSAppleEventsUsageDescription + iMessage macOS needs to control the Messages app to send iMessages on your behalf. + NSAppTransportSecurity + + NSAllowsLocalNetworking + + + + +EOF + + # Copy entitlements file + local entitlements_src="$SCRIPT_DIR/iMessageMacos.entitlements" + if [[ -f "$entitlements_src" ]]; then + cp "$entitlements_src" "$INSTALL_DIR/$APP_NAME.app/Contents/Resources/" + print_info "Copied entitlements file" + fi + + # Code sign the app with entitlements + print_step "Code signing application..." + if [[ -f "$entitlements_src" ]]; then + # Sign with entitlements for TCC permissions to work + codesign --force --deep --sign - \ + --entitlements "$entitlements_src" \ + "$INSTALL_DIR/$APP_NAME.app" + if [[ $? -eq 0 ]]; then + print_success "Code signed with entitlements" + else + print_warning "Code signing failed - TCC permissions may not work" + fi + else + # Fallback to basic signing + codesign --force --deep --sign - "$INSTALL_DIR/$APP_NAME.app" + print_warning "Code signed without entitlements (entitlements file not found)" + fi + + print_success "Installed to $INSTALL_DIR/$APP_NAME.app" +} + +discover_service_url() { + # Try to discover imessage-sync service from service-registry + local registry_url="${SERVICE_REGISTRY_URL:-http://localhost:30000}" + + # Try to discover the service + local discovery_response + discovery_response=$(curl -s --connect-timeout 2 "$registry_url/registry/discover?serviceName=imessage-sync&healthy=true" 2>/dev/null || echo "") + + if [[ -n "$discovery_response" && "$discovery_response" != *"error"* ]]; then + # Parse the first instance's URL from JSON response + local service_url + service_url=$(echo "$discovery_response" | python3 -c " +import sys, json +try: + data = json.load(sys.stdin) + if data.get('instances') and len(data['instances']) > 0: + inst = data['instances'][0] + ip = inst.get('ipAddress', 'localhost') + port = inst.get('port', 3105) + print(f'http://{ip}:{port}') +except: + pass +" 2>/dev/null) + + if [[ -n "$service_url" ]]; then + echo "$service_url" + return 0 + fi + fi + + return 1 +} + +configure_app() { + print_step "Configuring application..." + + # Create application support directory + mkdir -p "$APP_SUPPORT_DIR" + + # Get server URL from: 1) argument, 2) service-registry, 3) user input, 4) default + local server_url="${1:-}" + + if [[ -z "$server_url" ]]; then + print_info "Checking service registry for imessage-sync..." + server_url=$(discover_service_url) || true + + if [[ -n "$server_url" ]]; then + print_success "Discovered service at: $server_url" + else + echo "" + print_info "Enter the iMessage Sync server URL" + print_info "(Production: https://conversations.nasty.sh)" + read -p "Server URL [https://conversations.nasty.sh]: " server_url + server_url=${server_url:-https://conversations.nasty.sh} + fi + fi + + # Validate URL format + if [[ ! "$server_url" =~ ^https?:// ]]; then + print_error "Invalid URL format. Must start with http:// or https://" + exit 1 + fi + + # Store in UserDefaults via defaults command + defaults write "$BUNDLE_ID" apiBaseURL "$server_url" + + # Configure web server port from ports.yaml (or default) + local webserver_port + webserver_port=$(get_default_port "8765") + defaults write "$BUNDLE_ID" webServerPort -int "$webserver_port" + + print_success "Server URL configured: $server_url" + print_success "Web server port configured: $webserver_port" +} + +create_launch_agent() { + print_step "Creating LaunchAgent for auto-start..." + + mkdir -p "$LAUNCH_AGENTS_DIR" + + local binary_path="$INSTALL_DIR/$APP_NAME.app/Contents/MacOS/$APP_NAME" + + cat > "$PLIST_FILE" < + + + + Label + $BUNDLE_ID + ProgramArguments + + $binary_path + + RunAtLoad + + KeepAlive + + SuccessfulExit + + Crashed + + + StandardOutPath + $APP_SUPPORT_DIR/stdout.log + StandardErrorPath + $APP_SUPPORT_DIR/stderr.log + EnvironmentVariables + + PATH + /usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin + + + +EOF + + # Load the LaunchAgent + launchctl load "$PLIST_FILE" + + print_success "LaunchAgent created and loaded" + print_info "The agent will start automatically on login" +} + +show_permissions_guide() { + echo "" + echo -e "${YELLOW}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}" + echo -e "${YELLOW} IMPORTANT: Grant Full Disk Access${NC}" + echo -e "${YELLOW}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}" + echo "" + echo "iMessage macOS requires Full Disk Access to read the iMessage database." + echo "" + echo "To grant access:" + echo " 1. System Settings will open to Full Disk Access" + echo " 2. Click the '+' button" + echo " 3. Navigate to: $INSTALL_DIR/$APP_NAME.app" + echo " 4. Select the app and click 'Open'" + echo " 5. Enable the toggle next to $APP_NAME" + echo "" + + # Check if running interactively (has a TTY) + if [[ -t 0 ]]; then + read -p "Press Enter to open System Settings, or 'n' to skip: " open_settings + if [[ "$open_settings" != "n" && "$open_settings" != "N" ]]; then + open_fda_settings + fi + else + # Non-interactive mode (SSH batch) - auto-open settings + print_info "Opening System Settings → Full Disk Access..." + open_fda_settings + fi +} + +open_fda_settings() { + # Open System Settings to Full Disk Access pane (macOS Ventura+) + # Use osascript to ensure it opens in the user's GUI session (works via SSH) + osascript -e 'tell application "System Settings" + activate + delay 0.5 + tell application "System Events" + keystroke "Full Disk Access" + delay 0.3 + key code 36 + end tell + end tell' 2>/dev/null || \ + open "x-apple.systempreferences:com.apple.preference.security?Privacy_AllFiles" 2>/dev/null || \ + open "/System/Applications/System Settings.app" 2>/dev/null +} + +request_contacts_permission() { + echo "" + echo -e "${YELLOW}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}" + echo -e "${YELLOW} IMPORTANT: Grant Contacts Access${NC}" + echo -e "${YELLOW}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}" + echo "" + echo "iMessage macOS needs Contacts access to display names for conversations." + echo "" + print_info "Launching app to request Contacts permission..." + echo "" + + # Launch the app via 'open' which gives it proper GUI context for permission dialogs + # Run briefly to trigger the contacts permission request + open "$INSTALL_DIR/$APP_NAME.app" + + # Wait a moment for the permission dialog to appear + sleep 3 + + echo "A permission dialog should appear asking for Contacts access." + echo "Please click 'OK' or 'Allow' to grant access." + echo "" + + if [[ -t 0 ]]; then + read -p "Press Enter once you've granted Contacts access (or 'n' to skip): " contacts_response + if [[ "$contacts_response" == "n" || "$contacts_response" == "N" ]]; then + print_warning "Skipped Contacts permission. You can grant it later in System Settings." + else + print_success "Contacts permission requested" + fi + else + # Non-interactive - just wait and continue + sleep 5 + print_info "If a permission dialog appeared, please grant access." + fi + + # Stop the app so LaunchAgent can manage it + pkill -x "$APP_NAME" 2>/dev/null || true + sleep 1 +} + +register_device() { + print_step "Starting the agent for device registration..." + + echo "" + print_info "The iMessage macOS agent is now running in your menu bar." + print_info "Look for the message bubble icon (💬) in the top-right of your screen." + echo "" + print_info "To complete setup:" + echo " 1. Click the menu bar icon to open the web interface" + echo " 2. A browser will open showing the device registration page" + echo " 3. Enter the verification code shown in your admin panel" + echo "" +} + +check_installation() { + print_step "Verifying installation..." + + local errors=0 + + # Check binary exists + if [[ -f "$INSTALL_DIR/$APP_NAME.app/Contents/MacOS/$APP_NAME" ]]; then + print_success "Binary installed" + else + print_error "Binary not found" + errors=$((errors + 1)) + fi + + # Check LaunchAgent exists + if [[ -f "$PLIST_FILE" ]]; then + print_success "LaunchAgent configured" + else + print_error "LaunchAgent not found" + errors=$((errors + 1)) + fi + + # Check if agent is running + if pgrep -x "$APP_NAME" > /dev/null; then + print_success "Agent is running" + else + print_warning "Agent is not running yet (may need permissions)" + fi + + return $errors +} + +print_completion() { + echo "" + echo -e "${GREEN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}" + echo -e "${GREEN} Installation Complete${NC}" + echo -e "${GREEN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}" + echo "" + echo "iMessage macOS has been successfully installed." + echo "" + echo "Next steps:" + echo " 1. Grant Full Disk Access (see instructions above)" + echo " 2. Click the menu bar icon and complete device registration" + echo " 3. Your iMessages will automatically sync to the server" + echo "" + echo "Useful commands:" + echo " View logs: tail -f $APP_SUPPORT_DIR/stderr.log" + echo " Restart agent: launchctl unload $PLIST_FILE && launchctl load $PLIST_FILE" + echo " Uninstall: ./uninstall.sh" + echo "" +} + +# Main installation flow +main() { + print_header + + # Parse arguments + local server_url="" + if [[ $# -gt 0 ]]; then + server_url="$1" + fi + + # Pre-flight checks + check_macos + check_swift + + # Detect fresh install vs update + local IS_FRESH_INSTALL="true" + if [[ -f "$PLIST_FILE" ]]; then + IS_FRESH_INSTALL="false" + print_info "Existing installation detected — updating" + fi + + # Installation steps + stop_existing_agent + build_application + install_binary + configure_app "$server_url" + create_launch_agent + + # Post-installation + if check_installation; then + # Only show permission prompts on fresh install (no existing LaunchAgent) + if [[ "$IS_FRESH_INSTALL" == "true" ]]; then + show_permissions_guide + request_contacts_permission + register_device + fi + print_completion + else + echo "" + print_error "Installation completed with errors. Please review the output above." + exit 1 + fi +} + +# Run main function with all arguments +main "$@" diff --git a/imessage-macos/uninstall.sh b/imessage-macos/uninstall.sh new file mode 100755 index 0000000..d335bda --- /dev/null +++ b/imessage-macos/uninstall.sh @@ -0,0 +1,217 @@ +#!/bin/bash +set -euo pipefail + +# iMessage macOS - Agent Uninstaller +# This script removes the iMessage macOS menu bar agent + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' # No Color + +# Configuration +APP_NAME="iMessageMacos" +BUNDLE_ID="com.lilith.imessage-macos" +INSTALL_DIR="$HOME/Applications" +APP_SUPPORT_DIR="$HOME/Library/Application Support/$APP_NAME" +LAUNCH_AGENTS_DIR="$HOME/Library/LaunchAgents" +PLIST_FILE="$LAUNCH_AGENTS_DIR/$BUNDLE_ID.plist" +PREFS_FILE="$HOME/Library/Preferences/$BUNDLE_ID.plist" + +# Functions +print_header() { + echo -e "${BLUE}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}" + echo -e "${BLUE} iMessage macOS - Uninstaller${NC}" + echo -e "${BLUE}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}" + echo "" +} + +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" +} + +confirm_uninstall() { + echo -e "${YELLOW}This will remove:${NC}" + echo " • Application binary: $INSTALL_DIR/$APP_NAME.app" + echo " • LaunchAgent: $PLIST_FILE" + echo " • Application data: $APP_SUPPORT_DIR" + echo " • Preferences: $PREFS_FILE" + echo " • Keychain entries (auth tokens)" + echo "" + + read -p "Are you sure you want to uninstall? [y/N]: " confirm + + if [[ "$confirm" != "y" && "$confirm" != "Y" ]]; then + echo "" + print_info "Uninstall cancelled" + exit 0 + fi +} + +stop_agent() { + print_step "Stopping agent..." + + # Unload LaunchAgent + if [[ -f "$PLIST_FILE" ]]; then + launchctl unload "$PLIST_FILE" 2>/dev/null || true + print_success "Unloaded LaunchAgent" + fi + + # Kill any running instances + if pgrep -x "$APP_NAME" > /dev/null; then + pkill -x "$APP_NAME" 2>/dev/null || true + sleep 1 + print_success "Stopped running processes" + fi +} + +remove_files() { + print_step "Removing files..." + + local removed_count=0 + + # Remove application + if [[ -d "$INSTALL_DIR/$APP_NAME.app" ]]; then + rm -rf "$INSTALL_DIR/$APP_NAME.app" + print_success "Removed application" + removed_count=$((removed_count + 1)) + fi + + # Remove LaunchAgent plist + if [[ -f "$PLIST_FILE" ]]; then + rm -f "$PLIST_FILE" + print_success "Removed LaunchAgent" + removed_count=$((removed_count + 1)) + fi + + # Remove application support directory + if [[ -d "$APP_SUPPORT_DIR" ]]; then + rm -rf "$APP_SUPPORT_DIR" + print_success "Removed application data" + removed_count=$((removed_count + 1)) + fi + + # Remove preferences + if [[ -f "$PREFS_FILE" ]]; then + rm -f "$PREFS_FILE" + print_success "Removed preferences" + removed_count=$((removed_count + 1)) + fi + + # Also remove defaults domain + defaults delete "$BUNDLE_ID" 2>/dev/null || true + + if [[ $removed_count -eq 0 ]]; then + print_warning "No files found to remove" + fi +} + +remove_keychain_entries() { + print_step "Removing keychain entries..." + + # The app stores auth token in keychain with account "authToken" + security delete-generic-password -a "authToken" -s "$BUNDLE_ID" 2>/dev/null || true + + print_success "Cleared keychain entries" +} + +verify_removal() { + print_step "Verifying removal..." + + local remaining=0 + + # Check if any files remain + if [[ -d "$INSTALL_DIR/$APP_NAME.app" ]]; then + print_warning "Application still exists" + remaining=$((remaining + 1)) + fi + + if [[ -f "$PLIST_FILE" ]]; then + print_warning "LaunchAgent still exists" + remaining=$((remaining + 1)) + fi + + if [[ -d "$APP_SUPPORT_DIR" ]]; then + print_warning "Application data still exists" + remaining=$((remaining + 1)) + fi + + if pgrep -x "$APP_NAME" > /dev/null; then + print_warning "Process still running" + remaining=$((remaining + 1)) + fi + + if [[ $remaining -eq 0 ]]; then + print_success "All components removed successfully" + return 0 + else + return 1 + fi +} + +print_completion() { + echo "" + echo -e "${GREEN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}" + echo -e "${GREEN} Uninstall Complete${NC}" + echo -e "${GREEN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}" + echo "" + echo "iMessage macOS has been removed from this system." + echo "" + echo "Note: You may need to manually remove Full Disk Access permission:" + echo " System Settings → Privacy & Security → Full Disk Access" + echo " Find '$APP_NAME' and remove it from the list" + echo "" + echo "To reinstall, run: ./install.sh" + echo "" +} + +# Main uninstall flow +main() { + print_header + + # Check if running on macOS + if [[ "$OSTYPE" != "darwin"* ]]; then + print_error "This script is designed for macOS only" + exit 1 + fi + + # Confirm with user + confirm_uninstall + + echo "" + + # Uninstall steps + stop_agent + remove_files + remove_keychain_entries + + # Verification + if verify_removal; then + print_completion + else + echo "" + print_warning "Uninstall completed with warnings. Some components may remain." + exit 1 + fi +} + +# Run main function +main "$@"