import Foundation import SQLite3 import Swifter import Contacts import Photos import EventKit import ScriptingBridge /// HTTP server running on localhost:8765. Serves two surfaces: /// /// 1. **Settings API** — `GET /api/settings` and `PUT /api/settings` let the /// web dashboard read and write `~/.config/com.lilith.mac-sync/config.json`. /// /// 2. **Static SPA** — everything else is served from the bundled `webapp/` /// directory with SPA fallback to `index.html` for client-side routing. /// /// The server finds the webapp root by checking: /// - `Bundle.main.resourcePath/webapp/` (installed .app bundle) /// - `/../../web/dist/` (dev: `make run` or `swift run`) public final class LocalWebServer { private let server: HttpServer public let port: UInt16 public init() { let configured = UserDefaults.standard.integer(forKey: "webServerPort") port = UInt16(configured > 0 ? configured : 8765) server = HttpServer() } public func start() { registerRoutes() do { try server.start(port, forceIPv4: true) NSLog("LocalWebServer: listening on http://localhost:\(port)") } catch { NSLog("LocalWebServer: start failed — \(error)") } } public func stop() { server.stop() } // MARK: - Routes private func registerRoutes() { server.GET["/api/settings"] = { [weak self] _ in guard let self else { return .internalServerError } return jsonResponse(readSettings()) } server.PUT["/api/settings"] = { [weak self] req in guard let self else { return .internalServerError } let bodyData = Data(req.body) guard !bodyData.isEmpty, let raw = try? JSONSerialization.jsonObject(with: bodyData), let updates = raw as? [String: Any] else { return .badRequest(.text("invalid JSON")) } applySettings(updates) return jsonResponse(readSettings()) } server.GET["/api/diagnostics"] = { [weak self] _ in guard let self else { return .internalServerError } return jsonResponse(diagnosticsPayload()) } server.GET["/api/activity"] = { _ in let logPath = FileManager.default.homeDirectoryForCurrentUser .appendingPathComponent("Library/Application Support/MacSync/stderr.log").path guard let content = try? String(contentsOfFile: logPath, encoding: .utf8) else { return jsonArrayResponse([]) } let lines = content.components(separatedBy: "\n") let activityPrefix = "] ActivityLog: [" let entries: [[String: String]] = lines .filter { $0.contains(activityPrefix) } .suffix(100) .reversed() .compactMap { line -> [String: String]? in guard let prefixRange = line.range(of: activityPrefix) else { return nil } let rest = String(line[prefixRange.upperBound...]) let level: String let message: String if rest.hasPrefix("info] ") { level = "info"; message = String(rest.dropFirst(6)) } else if rest.hasPrefix("success] ") { level = "success"; message = String(rest.dropFirst(9)) } else if rest.hasPrefix("error] ") { level = "error"; message = String(rest.dropFirst(7)) } else if rest.hasPrefix("warning] ") { level = "warning"; message = String(rest.dropFirst(9)) } else { level = "info"; message = rest } // Extract ISO-ish timestamp from line prefix "2026-04-23 19:53:37.578 ..." let parts = line.components(separatedBy: " ") let ts = parts.count >= 2 ? "\(parts[0])T\(parts[1])Z" : "" return ["timestamp": ts, "level": level, "message": message] } return jsonArrayResponse(entries) } server.GET["/api/imessage/service-for-handle"] = { req in guard let rawHandle = req.queryParams.first(where: { $0.0 == "handle" })?.1, !rawHandle.isEmpty else { return jsonResponse(["error": "missing_handle"]) } let handle = rawHandle.removingPercentEncoding ?? rawHandle let chatDbPath = (NSHomeDirectory() as NSString).appendingPathComponent("Library/Messages/chat.db") var db: OpaquePointer? guard sqlite3_open_v2(chatDbPath, &db, SQLITE_OPEN_READONLY | SQLITE_OPEN_NOMUTEX, nil) == SQLITE_OK else { return jsonResponse(["error": "chat_db_unavailable"]) } defer { sqlite3_close(db) } let sql = "SELECT service FROM message WHERE handle_id IN (SELECT ROWID FROM handle WHERE id=?) AND is_from_me=0 ORDER BY date DESC LIMIT 1" var stmt: OpaquePointer? guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else { return jsonResponse(["error": "prepare_failed"]) } defer { sqlite3_finalize(stmt) } sqlite3_bind_text(stmt, 1, (handle as NSString).utf8String, -1, unsafeBitCast(-1, to: sqlite3_destructor_type.self)) var rawSvc: String? = nil if sqlite3_step(stmt) == SQLITE_ROW, let col = sqlite3_column_text(stmt, 0) { rawSvc = String(cString: col) } let service: String if rawSvc == "iMessage" || rawSvc == "SMS" { service = rawSvc! } else { service = "unknown" } var result: [String: Any] = ["service": service] if let raw = rawSvc { result["raw_service"] = raw } return jsonResponse(result) } // Static files + SPA fallback server.notFoundHandler = { [weak self] req in guard let webRoot = self?.webappDirectory() else { return .notFound } if req.path.hasPrefix("/api/") { return .notFound } let filePath = (webRoot as NSString).appendingPathComponent(req.path) if let data = FileManager.default.contents(atPath: filePath), !filePath.hasSuffix("/") { return .ok(.data(data, contentType: mimeType(for: filePath))) } let index = (webRoot as NSString).appendingPathComponent("index.html") guard let indexData = FileManager.default.contents(atPath: index) else { return .notFound } return .ok(.data(indexData, contentType: "text/html")) } } // MARK: - Settings private func readSettings() -> [String: Any] { let ud = UserDefaults.standard var result: [String: Any] = [:] result["serverURL"] = ud.string(forKey: "serverURL") ?? "http://10.0.0.11:3201" let p = ud.integer(forKey: "webServerPort") result["webServerPort"] = p > 0 ? p : 8765 if let token = ud.string(forKey: "serviceToken") { result["serviceToken"] = token } if let name = ud.string(forKey: "deviceName") { result["deviceName"] = name } if let addrs = ud.array(forKey: "imessageOwnAddresses") { result["imessageOwnAddresses"] = addrs } if let addrs = ud.array(forKey: "imailOwnAddresses") { result["imailOwnAddresses"] = addrs } return result } private func applySettings(_ updates: [String: Any]) { let ud = UserDefaults.standard for (key, value) in updates { ud.set(value, forKey: key) } ConfigFile.merge(updates) NSLog("LocalWebServer: settings updated — \(updates.keys.sorted().joined(separator: ", "))") } private func diagnosticsPayload() -> [String: Any] { let chatDbPath = FileManager.default.homeDirectoryForCurrentUser .appendingPathComponent("Library/Messages/chat.db").path let fullDiskAccess = Darwin.access(chatDbPath, R_OK) == 0 var iMessageDb: [String: Any] = [:] if fullDiskAccess && FileManager.default.fileExists(atPath: chatDbPath) { iMessageDb["available"] = true iMessageDb["error"] = NSNull() } else { iMessageDb["available"] = false if !fullDiskAccess { iMessageDb["error"] = "Full Disk Access not granted" } else { iMessageDb["error"] = "chat.db not found" } } var mailScriptingBridge: [String: Any] = [:] if SBApplication(bundleIdentifier: "com.apple.mail") != nil { mailScriptingBridge["available"] = true mailScriptingBridge["error"] = NSNull() } else { mailScriptingBridge["available"] = false mailScriptingBridge["error"] = "Mail.app not accessible via ScriptingBridge" } let calendarAuth = authStatusString(for: EKEventStore.authorizationStatus(for: .event)) let photosAuth = photosAuthStatusString(for: PHPhotoLibrary.authorizationStatus(for: .readWrite)) let contactsAuth = contactsAuthStatusString(for: CNContactStore.authorizationStatus(for: .contacts)) return [ "fullDiskAccess": fullDiskAccess, "iMessageDb": iMessageDb, "mailScriptingBridge": mailScriptingBridge, "calendarAuth": calendarAuth, "photosAuth": photosAuth, "contactsAuth": contactsAuth ] } private func authStatusString(for status: EKAuthorizationStatus) -> String { if #available(macOS 14.0, *) { switch status { case .fullAccess: return "authorized" case .writeOnly: return "authorized" case .denied: return "denied" case .notDetermined: return "notDetermined" case .restricted: return "restricted" @unknown default: return "notDetermined" } } else { switch status { case .authorized: return "authorized" case .denied: return "denied" case .notDetermined: return "notDetermined" case .restricted: return "restricted" @unknown default: return "notDetermined" } } } private func photosAuthStatusString(for status: PHAuthorizationStatus) -> String { switch status { case .authorized: return "authorized" case .limited: return "limited" case .denied: return "denied" case .notDetermined: return "notDetermined" case .restricted: return "restricted" @unknown default: return "notDetermined" } } private func contactsAuthStatusString(for status: CNAuthorizationStatus) -> String { switch status { case .authorized: return "authorized" case .denied: return "denied" case .notDetermined: return "notDetermined" case .restricted: return "restricted" @unknown default: return "notDetermined" } } // MARK: - Webapp root discovery private func webappDirectory() -> String? { if let res = Bundle.main.resourcePath { let candidate = (res as NSString).appendingPathComponent("webapp") if FileManager.default.fileExists(atPath: candidate) { return candidate } } // Dev path: binary is at .build/debug/MacSyncApp // web/dist is at /web/dist let binaryPath = ProcessInfo.processInfo.arguments[0] let buildDebug = (binaryPath as NSString).deletingLastPathComponent // .build/debug let buildDir = (buildDebug as NSString).deletingLastPathComponent // .build let repoRoot = (buildDir as NSString).deletingLastPathComponent // repo root let devDist = (repoRoot as NSString).appendingPathComponent("web/dist") let resolved = (devDist as NSString).standardizingPath if FileManager.default.fileExists(atPath: resolved) { return resolved } NSLog("LocalWebServer: webapp directory not found") return nil } } // MARK: - HTTP helpers private func jsonArrayResponse(_ arr: [[String: String]]) -> HttpResponse { guard let data = try? JSONSerialization.data(withJSONObject: arr, options: []), let str = String(data: data, encoding: .utf8) else { return .internalServerError } return .raw(200, "OK", ["Content-Type": "application/json", "Access-Control-Allow-Origin": "*"] ) { writer in try writer.write([UInt8](str.utf8)) } } private func jsonResponse(_ dict: [String: Any]) -> HttpResponse { guard let data = try? JSONSerialization.data(withJSONObject: dict, options: [.sortedKeys]), let str = String(data: data, encoding: .utf8) else { return .internalServerError } return .raw(200, "OK", ["Content-Type": "application/json", "Access-Control-Allow-Origin": "*"] ) { writer in try writer.write([UInt8](str.utf8)) } } private func mimeType(for path: String) -> String { switch (path as NSString).pathExtension.lowercased() { case "html": return "text/html; charset=utf-8" case "js", "mjs": return "application/javascript" case "css": return "text/css" case "json": return "application/json" case "png": return "image/png" case "jpg", "jpeg": return "image/jpeg" case "svg": return "image/svg+xml" case "ico": return "image/x-icon" case "woff": return "font/woff" case "woff2": return "font/woff2" default: return "application/octet-stream" } }