2026-05-15 17:05:13 -07:00
|
|
|
import Foundation
|
2026-05-15 18:02:04 -07:00
|
|
|
import SQLite3
|
2026-05-15 17:05:13 -07:00
|
|
|
import Swifter
|
2026-05-15 17:05:39 -07:00
|
|
|
import Contacts
|
|
|
|
|
import Photos
|
|
|
|
|
import EventKit
|
|
|
|
|
import ScriptingBridge
|
2026-05-15 17:05:13 -07:00
|
|
|
|
|
|
|
|
/// 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)
|
|
|
|
|
/// - `<binary-dir>/../../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())
|
|
|
|
|
}
|
2026-05-15 17:05:39 -07:00
|
|
|
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([])
|
2026-05-15 17:05:13 -07:00
|
|
|
}
|
2026-05-15 17:05:39 -07:00
|
|
|
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)
|
2026-05-15 17:05:13 -07:00
|
|
|
}
|
|
|
|
|
|
2026-05-15 18:02:04 -07:00
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-15 17:05:13 -07:00
|
|
|
// 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
|
2026-05-15 17:05:39 -07:00
|
|
|
if let token = ud.string(forKey: "serviceToken") { result["serviceToken"] = token }
|
2026-05-15 17:05:13 -07:00
|
|
|
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: ", "))")
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-15 17:05:39 -07:00
|
|
|
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"
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-15 17:05:13 -07:00
|
|
|
// 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 <repo-root>/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
|
|
|
|
|
|
2026-05-15 17:05:39 -07:00
|
|
|
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)) }
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-15 17:05:13 -07:00
|
|
|
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"
|
|
|
|
|
}
|
|
|
|
|
}
|