~ knotless Search/
↑↓ move↵ open⇧↵ all results

johnandersen777.bsky.social/atirc-ios-client

0
1
main
465 lines · 22.4 KB Raw History
//
//  KCPHost.swift
//  AT-IRC
//
//  Boots the real kcp control plane from the embedded AtircGo framework and
//  reports its status. kcp runs entirely in-process over a net.Pipe; no
//  sockets are opened.
//

import CryptoKit
import Foundation
import AtircGo
import Observation
import OSLog
import UIKit

/// One in-process kcp control plane.
@Observable
@MainActor
final class KCPHost {
    enum State: Equatable {
        case idle
        case starting
        case ready
        case failed(String)
    }

    private(set) var state: State = .idle
    /// kcp's self-reported git version, e.g. "v1.31.0".
    private(set) var version = ""
    /// Wall time from Start to ready; cold boot vs snapshot resume.
    private(set) var bootSeconds: TimeInterval = 0
    private let log = Logger(subsystem: "com.fedfork.irc.AT-IRC", category: "kcp")
    private let policySandbox = PolicySandboxHost()

    var statusText: String {
        switch state {
        case .idle: "kcp: idle"
        case .starting: "kcp: starting…"
        case .ready: String(format: "kcp: ready · %.1fs", bootSeconds)
        case .failed(let message): "kcp: failed — \(message)"
        }
    }

    /// Boot kcp once, restoring the saved snapshot. Safe to call repeatedly;
    /// later calls are no-ops.
    func start() async {
        guard state == .idle else { return }
        state = .starting
        let root = Self.rootDirectory()
        let began = Date()
        log.notice("kcp start")
        // iOS has no separate process for this: keep the whole boot off the
        // main thread at background QoS and hold a background task so the
        // system does not suspend the app mid-boot.
        let backgroundTask = UIApplication.shared.beginBackgroundTask(withName: "kcp.boot")
        defer { UIApplication.shared.endBackgroundTask(backgroundTask) }
        do {
            // userInitiated keeps boot off the E-cores; the GOMAXPROCS cap in
            // the Go bridge keeps the UI responsive while it runs.
            let bootVersion = try await Task.detached(priority: .userInitiated) {
                try Self.boot(root: root)
            }.value
            version = bootVersion
            bootSeconds = Date().timeIntervalSince(began)
            state = .ready
            log.notice("kcp ready in \(self.bootSeconds, format: .fixed(precision: 1))s")
            Self.persistSnapshot()
        } catch {
            state = .failed(error.localizedDescription)
            log.error("kcp failed: \(error.localizedDescription, privacy: .public)")
        }
    }

    /// The MarketIdentity is composed a couple of seconds after Start; wait
    /// briefly and log only its coarse phase (never the sign-in URL).
    nonisolated private static func logMarketIdentityPhase() async {
        for _ in 0..<15 {
            var error: NSError?
            let response = AtircgoKCPRequest(
                "GET",
                "/apis/socialweb.computer/v1alpha1/marketidentities/requester",
                "{}", "", &error
            )
            if error == nil,
               let data = response.data(using: .utf8),
               let envelope = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
               let body = envelope["bodyJSON"] as? [String: Any],
               let status = body["status"] as? [String: Any] {
                let phase = status["phase"] as? String ?? "unknown"
                let reason = (status["pendingAction"] as? [String: Any])?["reason"] as? String ?? ""
                Logger(subsystem: "com.fedfork.irc.AT-IRC", category: "kcp")
                    .notice("marketidentity phase=\(phase, privacy: .public) pending=\(reason, privacy: .public)")
                return
            }
            try? await Task.sleep(for: .seconds(1))
        }
    }

    /// Debug: log the policy engine and its workflow runs so a launch test can
    /// tell whether a run reached the sandbox. Status only: no URLs, no secrets.
    nonisolated static func logPolicyRuns() async {
        let log = Logger(subsystem: "com.fedfork.irc.AT-IRC", category: "kcp")
        func list(_ path: String) -> [[String: Any]] {
            var error: NSError?
            let response = AtircgoKCPRequest("GET", path, "{\"X-Atirc-Direct\":\"1\"}", "", &error)
            guard error == nil,
                  let data = response.data(using: .utf8),
                  let envelope = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
                  let body = envelope["bodyJSON"] as? [String: Any],
                  let items = body["items"] as? [[String: Any]] else { return [] }
            return items
        }
        for engine in list("/apis/policy.socialweb.computer/v1alpha1/policyengines") {
            let status = engine["status"] as? [String: Any] ?? [:]
            let conditions = (status["conditions"] as? [[String: Any]] ?? []).map {
                "\($0["type"] as? String ?? "?")=\($0["status"] as? String ?? "?")/\($0["reason"] as? String ?? "?"):\(String(($0["message"] as? String ?? "").prefix(500)))"
            }
            log.notice("policyengine status phase=\(status["phase"] as? String ?? "?", privacy: .public) conditions=\(conditions.joined(separator: " | "), privacy: .public)")
        }
        let runs = list("/apis/policy.socialweb.computer/v1alpha1/policyworkflowruns")
        for run in runs.prefix(10) {
            let meta = run["metadata"] as? [String: Any] ?? [:]
            let status = run["status"] as? [String: Any] ?? [:]
            let conditions = (status["conditions"] as? [[String: Any]] ?? []).map {
                "\($0["type"] as? String ?? "?")=\($0["status"] as? String ?? "?")/\($0["reason"] as? String ?? "?"):\($0["message"] as? String ?? "")"
            }
            let joined = String(conditions.joined(separator: " | ").prefix(700))
            let annotations = status["annotations"] as? [String: Any] ?? [:]
            let errorText = String(String(describing: annotations["error"] ?? "").prefix(700))
            let console = String((status["consoleOutput"] as? String ?? "").prefix(400))
            let outputs = String(String(describing: status["outputs"] ?? "").prefix(300))
            log.notice("policyrun name=\(meta["name"] as? String ?? "?", privacy: .public) phase=\(status["phase"] as? String ?? "?", privacy: .public) start=\(status["startTime"] as? String ?? "-", privacy: .public) done=\(status["completionTime"] as? String ?? "-", privacy: .public) conditions=\(joined, privacy: .public) error=\(errorText, privacy: .public) outputs=\(outputs, privacy: .public) console=\(console, privacy: .public)")
        }
        log.notice("policyrun count=\(runs.count, privacy: .public)")
    }

    /// Debug: log the Workspace app phase/initializers and its LogicalCluster
    /// conditions, so a stalled requester compose says why. No URLs or secrets.
    nonisolated static func logWorkspaceState() async {
        let log = Logger(subsystem: "com.fedfork.irc.AT-IRC", category: "kcp")
        let headers = "{\"X-Atirc-Direct\":\"1\"}"
        func object(_ path: String) -> [String: Any]? {
            var error: NSError?
            let response = AtircgoKCPRequest("GET", path, headers, "", &error)
            guard error == nil,
                  let data = response.data(using: .utf8),
                  let envelope = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
                  let body = envelope["bodyJSON"] as? [String: Any] else { return nil }
            return body
        }
        if let workspace = object("/apis/tenancy.kcp.io/v1alpha1/workspaces/app") {
            let status = workspace["status"] as? [String: Any] ?? [:]
            let initializers = (status["initializers"] as? [Any] ?? []).map { String(describing: $0) }
            let phase = status["phase"] as? String ?? "?"
            log.notice("workspace app phase=\(phase, privacy: .public) initializers=\(initializers.joined(separator: ","), privacy: .public)")
        }
        if let cluster = object("/clusters/root:app/apis/core.kcp.io/v1alpha1/logicalclusters/cluster") {
            let status = cluster["status"] as? [String: Any] ?? [:]
            let conditions = (status["conditions"] as? [[String: Any]] ?? []).map {
                "\($0["type"] as? String ?? "?")=\($0["status"] as? String ?? "?")/\($0["reason"] as? String ?? "?"):\(String(($0["message"] as? String ?? "").prefix(300)))"
            }
            log.notice("logicalcluster root:app conditions=\(conditions.joined(separator: " | "), privacy: .public)")
        }
    }

    func stop() {
        AtircgoKCPStop()
        state = .idle
        version = ""
    }

    /// Start the socialweb market requester on the running kcp, with the
    /// signed-in identity. The requester is a second Go component; keep it off
    /// the main thread like the boot.
    func startRequester(configJSON: String) async {
        guard state == .ready else { return }
        log.notice("requester start")
        let root = Self.rootDirectory()
        // Resume the requester's vault (OAuth session, MarketIdentity state).
        var config = (try? JSONSerialization.jsonObject(with: Data(configJSON.utf8)) as? [String: Any]) ?? [:]
        let restore = Self.savedRequesterSnapshot(root: root)
        if !restore.isEmpty {
            config["restore"] = restore
        }
        // EphemeralInfra + ComputeContracts are merged inside the embedded
        // BeforeRequester hook, once the socialweb schemas exist and before
        // the requester controllers start (no orphan race).
        let infraRestore = Self.savedInfraSnapshot(root: root)
        if !infraRestore.isEmpty {
            config["infraRestoreB64"] = infraRestore
        }
        // The provider APIExport identities keep the bound-resource key hash
        // stable across boots, so the restored contracts stay visible. Key
        // material: it only travels from the sealed file back into Start.
        let identities = Self.savedIdentities(root: root)
        if !identities.isEmpty {
            config["providerIdentitiesB64"] = identities
        }
        let resolved: String
        if !config.isEmpty, let data = try? JSONSerialization.data(withJSONObject: config) {
            resolved = String(decoding: data, as: UTF8.self)
        } else {
            resolved = configJSON
        }
        do {
            try await Task.detached(priority: .background) {
                var error: NSError?
                _ = AtircgoKCPRequesterStart(resolved, self.policySandbox, &error)
                if let error { throw error }
            }.value
            log.notice("requester ready")
            // A contract's receipt and state change after the launch; persist
            // them as they change, so a killed app can still release its VMs
            // on the next launch (an unsaved receipt orphans the VM: no bidder
            // timer ends a plain requester's VM). Unchanged state is not rewritten.
            Task.detached(priority: .utility) {
                while AtircgoKCPRequesterReady() {
                    Self.persistSnapshot()
                    try? await Task.sleep(for: .seconds(5))
                }
            }
            var infraError: NSError?
            let infra = AtircgoKCPRequest(
                "GET", "/apis/socialweb.computer/v1alpha1/ephemeralinfras/atirc-ios",
                "{\"X-Atirc-Direct\":\"1\"}", "", &infraError
            )
            log.notice("infra restore visible=\(infraError == nil, privacy: .public) bytes=\(infra.utf8.count, privacy: .public)")
            await Self.logPolicyRuns()
            // The PolicyEngine is created a beat after the requester's
            // components; ask once more so the fence verdict is in the log.
            try? await Task.sleep(for: .seconds(2))
            await Self.logPolicyRuns()
            await Self.logMarketIdentityPhase()
            var engineError: NSError?
            let engines = AtircgoKCPRequest(
                "GET", "/apis/policy.socialweb.computer/v1alpha1/policyengines",
                "{\"X-Atirc-Direct\":\"1\"}", "", &engineError
            )
            if engineError == nil,
               let data = engines.data(using: .utf8),
               let envelope = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
               let list = envelope["bodyJSON"] as? [String: Any],
               let items = list["items"] as? [[String: Any]],
               let first = items.first,
               let state = first["status"] as? [String: Any] {
                let phase = state["phase"] as? String ?? "?"
                let message = state["message"] as? String ?? ""
                log.notice("policyengine phase=\(phase, privacy: .public) message=\(message.prefix(600), privacy: .public)")
            }
        } catch {
            log.error("requester failed: \(error.localizedDescription, privacy: .public)")
        }
    }

    /// Save the full memory:// state so the next launch can resume. Called on
    /// ready and when the app goes to the background. The snapshot carries
    /// OAuth sessions, DPoP keys and atirc tokens in clear JSON, so it is
    /// sealed with AES-GCM under a Keychain-only key, written with complete
    /// file protection and excluded from backups.
    nonisolated static func persistSnapshot() {
        guard AtircgoKCPReady() else { return }
        let root = rootDirectory()
        let requesterRunning = AtircgoKCPRequesterReady()
        Task.detached(priority: .utility) {
            guard let key = snapshotKey() else { return }
            // Once the requester composed its CRDs, the kcp object snapshot
            // grows to ~10 MB and restoring it makes the apiserver rebuild
            // every informer (40s+). Keep the small pre-requester snapshot
            // instead; the requester's own vault snapshot carries identity.
            if !requesterRunning {
                var error: NSError?
                let snapshot = AtircgoKCPSnapshot(&error)
                if error == nil, !snapshot.isEmpty {
                    writeSealed(snapshot, to: snapshotPath(root: root), key: key)
                }
            }
            if requesterRunning {
                var requesterError: NSError?
                let requesterSnapshot = AtircgoKCPRequesterSnapshot(&requesterError)
                if requesterError == nil, !requesterSnapshot.isEmpty {
                    writeSealed(requesterSnapshot, to: requesterSnapshotPath(root: root), key: key)
                }
                // The session EphemeralInfra and its contracts are a few KB;
                // keep them so a Release can finish after a restart.
                var infraError: NSError?
                let infraSnapshot = AtircgoKCPInfraSnapshot(&infraError)
                if infraError == nil, !infraSnapshot.isEmpty {
                    writeSealed(infraSnapshot, to: infraSnapshotPath(root: root), key: key)
                }
                // Provider APIExport identity secrets: key material that keeps
                // bound-resource keys stable across boots. Sealed like the rest.
                var identitiesError: NSError?
                let identities = AtircgoKCPProviderIdentities(&identitiesError)
                if identitiesError == nil, !identities.isEmpty {
                    writeSealed(identities, to: identitiesPath(root: root), key: key)
                }
            }
        }
    }

    /// Seal UTF-8 text with AES-GCM, then write it with complete file
    /// protection and excluded from backups.
    nonisolated(unsafe) private static var lastWritten: [String: Int] = [:]
    private static let lastWrittenLock = NSLock()

    nonisolated private static func writeSealed(_ text: String, to path: String, key: SymmetricKey) {
        let unchanged = lastWrittenLock.withLock {
            if lastWritten[path] == text.hashValue { return true }
            lastWritten[path] = text.hashValue
            return false
        }
        if unchanged { return }
        guard let plaintext = text.data(using: .utf8),
              let sealed = try? AES.GCM.seal(plaintext, using: key),
              let combined = sealed.combined else { return }
        let url = URL(fileURLWithPath: path)
        do {
            try combined.write(to: url, options: [.atomic, .completeFileProtection])
            var values = URLResourceValues()
            values.isExcludedFromBackup = true
            var mutable = url
            try? mutable.setResourceValues(values)
        } catch {
            // Persistence is best effort; kcp keeps running. Retry next time.
            lastWrittenLock.withLock { lastWritten[path] = nil }
        }
    }

    nonisolated private static func boot(root: String) throws -> String {
        let saved = Self.savedSnapshot(root: root)
        var startError: NSError?
        var status = AtircgoKCPStart(root, saved, &startError)
        if startError != nil, !saved.isEmpty {
            // Fail closed on a saved state this build cannot read: drop it and
            // boot a fresh control plane instead of refusing to start.
            try? FileManager.default.removeItem(atPath: Self.snapshotPath(root: root))
            startError = nil
            status = AtircgoKCPStart(root, "", &startError)
        }
        if let startError { throw startError }
        _ = status

        var requestError: NSError?
        let response = AtircgoKCPRequest("GET", "/version", "", "", &requestError)
        if let requestError { throw requestError }
        return version(from: response)
    }

    nonisolated private static func rootDirectory() -> String {
        let base = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
        let root = base.appendingPathComponent("kcp", isDirectory: true)
        try? FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
        return root.path
    }

    nonisolated private static func snapshotPath(root: String) -> String {
        URL(fileURLWithPath: root, isDirectory: true)
            .appendingPathComponent("snapshot.b64")
            .path
    }

    nonisolated private static func requesterSnapshotPath(root: String) -> String {
        URL(fileURLWithPath: root, isDirectory: true)
            .appendingPathComponent("socialweb.b64")
            .path
    }

    nonisolated private static func infraSnapshotPath(root: String) -> String {
        URL(fileURLWithPath: root, isDirectory: true)
            .appendingPathComponent("infra.b64")
            .path
    }

    nonisolated private static func identitiesPath(root: String) -> String {
        URL(fileURLWithPath: root, isDirectory: true)
            .appendingPathComponent("identities.b64")
            .path
    }

    nonisolated private static func savedIdentities(root: String) -> String {
        guard let key = snapshotKey(),
              let sealed = try? Data(contentsOf: URL(fileURLWithPath: identitiesPath(root: root))),
              let box = try? AES.GCM.SealedBox(combined: sealed),
              let plaintext = try? AES.GCM.open(box, using: key),
              let text = String(data: plaintext, encoding: .utf8) else {
            return ""
        }
        return text
    }

    nonisolated private static func savedInfraSnapshot(root: String) -> String {
        guard let key = snapshotKey(),
              let sealed = try? Data(contentsOf: URL(fileURLWithPath: infraSnapshotPath(root: root))),
              let box = try? AES.GCM.SealedBox(combined: sealed),
              let plaintext = try? AES.GCM.open(box, using: key),
              let text = String(data: plaintext, encoding: .utf8) else {
            return ""
        }
        return text
    }

    nonisolated private static func savedRequesterSnapshot(root: String) -> String {
        guard let key = snapshotKey(),
              let sealed = try? Data(contentsOf: URL(fileURLWithPath: requesterSnapshotPath(root: root))),
              let box = try? AES.GCM.SealedBox(combined: sealed),
              let plaintext = try? AES.GCM.open(box, using: key),
              let text = String(data: plaintext, encoding: .utf8) else {
            return ""
        }
        return text
    }

    /// The snapshot sealing key lives only in the Keychain, device-bound and
    /// unavailable while the device is locked.
    nonisolated private static func snapshotKey() -> SymmetricKey? {
        let account = "kcp.snapshot.key.v1"
        if let data = Keychain.get(account), data.count == 32 {
            return SymmetricKey(data: data)
        }
        let key = SymmetricKey(size: .bits256)
        let data = key.withUnsafeBytes { Data($0) }
        Keychain.set(data, for: account, accessible: kSecAttrAccessibleWhenUnlockedThisDeviceOnly)
        guard let stored = Keychain.get(account), stored == data else { return nil }
        return key
    }

    nonisolated private static func savedSnapshot(root: String) -> String {
        let path = snapshotPath(root: root)
        // A snapshot that grew past ~3 MB holds the requester's CRDs; restoring
        // it is slower than a cold boot that lets the requester re-apply its
        // provider sets. Drop it once (the requester vault snapshot remains).
        if let size = (try? FileManager.default.attributesOfItem(atPath: path)[.size] as? NSNumber)?.intValue,
           size > 3_000_000 {
            try? FileManager.default.removeItem(atPath: path)
            return ""
        }
        guard let key = snapshotKey(),
              let sealed = try? Data(contentsOf: URL(fileURLWithPath: path)),
              let box = try? AES.GCM.SealedBox(combined: sealed),
              let plaintext = try? AES.GCM.open(box, using: key),
              let text = String(data: plaintext, encoding: .utf8) else {
            // Fail closed: unreadable/plaintext-from-an-older-build state is
            // dropped rather than booted over.
            try? FileManager.default.removeItem(atPath: path)
            return ""
        }
        return text
    }

    /// Pull gitVersion out of the JSON response KCPRequest returns. Unstamped
    /// builds report the placeholder "v0.0.0-master+$Format:%H$"; hide it.
    nonisolated private static func version(from response: String) -> String {
        struct Envelope: Decodable { let bodyJSON: Body? }
        struct Body: Decodable { let gitVersion: String? }
        guard let data = response.data(using: .utf8),
              let envelope = try? JSONDecoder().decode(Envelope.self, from: data),
              let gitVersion = envelope.bodyJSON?.gitVersion,
              !gitVersion.isEmpty,
              !gitVersion.contains("$Format") else {
            return ""
        }
        return gitVersion
    }
}