183 lines
7.7 KiB
Swift
183 lines
7.7 KiB
Swift
|
|
import Foundation
|
||
|
|
import SQLite3
|
||
|
|
import Swifter
|
||
|
|
|
||
|
|
/// 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/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 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: ", "))")
|
||
|
|
}
|
||
|
|
|
||
|
|
// 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 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"
|
||
|
|
}
|
||
|
|
}
|