// ServerManager.swift — mirrors example_swift_app.
#if os(macOS)
import Foundation
public actor ServerManager {
public private(set) var baseURL: String
private let binaryPath: String
private let preferredPort: Int
private let portSearchRange: Int
private(set) var port: Int
private let cwd: URL?
private let onLog: (@Sendable (String) -> Void)?
private let readyTimeoutSec: Double
private var process: Process?
public init(
binaryPath: String,
port: Int = 8080,
portSearchRange: Int = 10,
cwd: URL? = nil,
readyTimeoutSec: Double = 15.0,
onLog: (@Sendable (String) -> Void)? = nil
) {
self.binaryPath = binaryPath
self.preferredPort = port
self.port = port
self.portSearchRange = portSearchRange
self.cwd = cwd
self.onLog = onLog
self.readyTimeoutSec = readyTimeoutSec
self.baseURL = "http://localhost:\(port)"
}
public func start() async throws {
if process != nil {
throw ServerManagerError.alreadyStarted
}
// Reuse an already-healthy server on the preferred port.
if await isHealthy() {
onLog?("[server] already running on port \(port); reusing")
return
}
// If the preferred port is occupied by an orphan CameraWebApp, reap it.
// Otherwise, walk forward to the next free port instead of killing
// unrelated processes.
if let orphanPids = pidsBoundToPort(port),
!orphanPids.isEmpty,
orphanPids.allSatisfy({ processName(forPid: $0) == "CameraWebApp" }) {
onLog?("[server] reaping stale CameraWebApp(s) on port \(port): \(orphanPids)")
for pid in orphanPids { kill(pid, SIGKILL) }
try? await Task.sleep(nanoseconds: 500_000_000)
} else if isPortInUse(port) {
var found: Int? = nil
for candidate in port..<(port + portSearchRange) {
if !isPortInUse(candidate) {
found = candidate
break
}
}
guard let chosen = found else {
throw ServerManagerError.noFreePort(start: port, range: portSearchRange)
}
if chosen != port {
onLog?("[server] port \(port) in use; falling back to \(chosen)")
self.port = chosen
self.baseURL = "http://localhost:\(chosen)"
}
}
let p = Process()
p.executableURL = URL(fileURLWithPath: binaryPath)
p.arguments = []
if let cwd = cwd { p.currentDirectoryURL = cwd }
p.environment = ProcessInfo.processInfo.environment.merging(
["PORT": String(port)],
uniquingKeysWith: { _, new in new }
)
let pipe = Pipe()
p.standardOutput = pipe
p.standardError = pipe
if let onLog = onLog {
pipe.fileHandleForReading.readabilityHandler = { handle in
let data = handle.availableData
if !data.isEmpty, let text = String(data: data, encoding: .utf8) {
for line in text.split(separator: "\n") {
onLog(String(line))
}
}
}
}
try p.run()
process = p
let deadline = Date().addingTimeInterval(readyTimeoutSec)
while Date() < deadline {
if !p.isRunning {
process = nil
throw ServerManagerError.processExitedEarly
}
if await isHealthy() { return }
try? await Task.sleep(nanoseconds: 250_000_000)
}
p.terminate()
process = nil
throw ServerManagerError.startTimeout
}
public func stop() async {
guard let p = process else { return }
if let url = URL(string: "\(baseURL)/api/server/shutdown") {
var req = URLRequest(url: url)
req.httpMethod = "POST"
req.timeoutInterval = 2.0
_ = try? await URLSession.shared.data(for: req)
}
let waitStart = Date()
while p.isRunning && Date().timeIntervalSince(waitStart) < 3.0 {
try? await Task.sleep(nanoseconds: 100_000_000)
}
if p.isRunning {
p.terminate()
try? await Task.sleep(nanoseconds: 1_000_000_000)
if p.isRunning { p.interrupt() }
}
process = nil
}
private func isHealthy() async -> Bool {
guard let url = URL(string: "\(baseURL)/api/server/status") else { return false }
var req = URLRequest(url: url)
// Fresh server boot can block on camera discovery for several seconds.
req.timeoutInterval = 30.0
do {
let (_, response) = try await URLSession.shared.data(for: req)
if let http = response as? HTTPURLResponse, http.statusCode == 200 {
return true
}
} catch {}
return false
}
private func pidsBoundToPort(_ port: Int) -> [pid_t]? {
let task = Process()
task.executableURL = URL(fileURLWithPath: "/usr/sbin/lsof")
task.arguments = ["-ti", ":\(port)"]
let pipe = Pipe()
task.standardOutput = pipe
task.standardError = Pipe()
do {
try task.run()
task.waitUntilExit()
} catch {
return nil
}
let data = pipe.fileHandleForReading.readDataToEndOfFile()
guard let out = String(data: data, encoding: .utf8) else { return nil }
return out
.split(separator: "\n")
.compactMap { pid_t($0.trimmingCharacters(in: .whitespaces)) }
}
private func isPortInUse(_ port: Int) -> Bool {
let sock = socket(AF_INET, SOCK_STREAM, 0)
guard sock >= 0 else { return true }
defer { close(sock) }
var noReuse: Int32 = 0
setsockopt(sock, SOL_SOCKET, SO_REUSEADDR, &noReuse, socklen_t(MemoryLayout<Int32>.size))
var addr = sockaddr_in()
addr.sin_family = sa_family_t(AF_INET)
addr.sin_port = UInt16(port).bigEndian
addr.sin_addr.s_addr = INADDR_ANY.bigEndian
let result = withUnsafePointer(to: &addr) { ptr -> Int32 in
ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { sa in
bind(sock, sa, socklen_t(MemoryLayout<sockaddr_in>.size))
}
}
return result != 0
}
private func processName(forPid pid: pid_t) -> String? {
let task = Process()
task.executableURL = URL(fileURLWithPath: "/bin/ps")
task.arguments = ["-p", String(pid), "-o", "comm="]
let pipe = Pipe()
task.standardOutput = pipe
task.standardError = Pipe()
do {
try task.run()
task.waitUntilExit()
} catch {
return nil
}
let data = pipe.fileHandleForReading.readDataToEndOfFile()
guard let out = String(data: data, encoding: .utf8) else { return nil }
return (out.trimmingCharacters(in: .whitespacesAndNewlines) as NSString).lastPathComponent
}
}
public enum ServerManagerError: Error {
case alreadyStarted
case processExitedEarly
case startTimeout
case noFreePort(start: Int, range: Int)
}
#endif