aven 0.1.13

Local-first task manager CLI and sync server
Documentation
import AvenLocalProofCore
import Foundation

final class ProofServer: @unchecked Sendable {
    private final class StartupState: @unchecked Sendable {
        private let lock = NSLock()
        private let signal = DispatchSemaphore(value: 0)
        private var buffer = Data()
        private var listeningURL: String?

        func consume(_ data: Data) {
            guard !data.isEmpty else { return }
            lock.lock()
            defer { lock.unlock() }
            buffer.append(data)
            while let newline = buffer.firstIndex(of: 0x0A) {
                let lineData = buffer[..<newline]
                buffer.removeSubrange(...newline)
                let line = String(decoding: lineData, as: UTF8.self)
                if let value = line.split(separator: " ").first(where: {
                    $0.hasPrefix("url=")
                }), line.hasPrefix("listening ") {
                    listeningURL = String(value.dropFirst("url=".count))
                    signal.signal()
                }
            }
        }

        func processExited() {
            signal.signal()
        }

        func waitForURL(timeout: TimeInterval) -> String? {
            _ = signal.wait(timeout: .now() + timeout)
            lock.lock()
            defer { lock.unlock() }
            return listeningURL
        }
    }

    let url: String

    private let process: Process
    private let stdoutPipe: Pipe
    private let stderrPipe: Pipe

    private init(
        url: String,
        process: Process,
        stdoutPipe: Pipe,
        stderrPipe: Pipe
    ) {
        self.url = url
        self.process = process
        self.stdoutPipe = stdoutPipe
        self.stderrPipe = stderrPipe
    }

    static func start(
        binaryPath: String,
        directory: URL,
        authToken: String
    ) throws -> ProofServer {
        let configDirectory = directory.appendingPathComponent("server-config")
        try FileManager.default.createDirectory(
            at: configDirectory,
            withIntermediateDirectories: true
        )
        let quotedToken = "'" + authToken.replacingOccurrences(of: "'", with: "''") + "'"
        try "sync:\n  auth_token: \(quotedToken)\n".write(
            to: configDirectory.appendingPathComponent("config.yaml"),
            atomically: true,
            encoding: .utf8
        )

        let stdoutPipe = Pipe()
        let stderrPipe = Pipe()
        let state = StartupState()
        stdoutPipe.fileHandleForReading.readabilityHandler = { handle in
            state.consume(handle.availableData)
        }
        stderrPipe.fileHandleForReading.readabilityHandler = { handle in
            _ = handle.availableData
        }

        let process = Process()
        process.executableURL = URL(fileURLWithPath: binaryPath)
        process.arguments = [
            "server",
            "--bind", "127.0.0.1:0",
            "--data", directory.appendingPathComponent("server.sqlite").path,
        ]
        var environment = ProcessInfo.processInfo.environment
        environment["AVEN_CONFIG_DIR"] = configDirectory.path
        environment.removeValue(forKey: "AVEN_DB")
        environment.removeValue(forKey: "AVEN_SYNC_SERVER")
        process.environment = environment
        process.standardOutput = stdoutPipe
        process.standardError = stderrPipe
        process.terminationHandler = { _ in state.processExited() }
        try process.run()

        guard let url = state.waitForURL(timeout: 10), process.isRunning else {
            if process.isRunning {
                process.terminate()
            }
            process.waitUntilExit()
            stdoutPipe.fileHandleForReading.readabilityHandler = nil
            stderrPipe.fileHandleForReading.readabilityHandler = nil
            throw ProofFailure.invariant("sync server did not become ready")
        }
        return ProofServer(
            url: url,
            process: process,
            stdoutPipe: stdoutPipe,
            stderrPipe: stderrPipe
        )
    }

    func stop() {
        if process.isRunning {
            process.terminate()
        }
        process.waitUntilExit()
        stdoutPipe.fileHandleForReading.readabilityHandler = nil
        stderrPipe.fileHandleForReading.readabilityHandler = nil
    }
}