macsync/@packages/shared/Sources/MacSyncShared/WebServer/LocalWebServer.swift

319 lines
14 KiB
Swift

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)
/// - `<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())
}
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 <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
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"
}
}