| 1 | import Foundation |
| 2 | import SwiftUI |
| 3 | import Darwin |
| 4 | #if os(macOS) |
| 5 | import AppKit |
| 6 | #else |
| 7 | import UIKit |
| 8 | #endif |
| 9 | |
| 10 | public struct PetConnection: Decodable { |
| 11 | public let version: Int, port: Int, token: String, identity: String |
| 12 | public func validate() throws { |
| 13 | guard version == 1, (1...65535).contains(port), token.range(of: "^[a-f0-9]{64}$", options: .regularExpression) != nil, |
| 14 | UUID(uuidString: identity) != nil else { throw PetCoreError.invalid("Invalid shared pet connection.") } |
| 15 | } |
| 16 | } |
| 17 | public struct PetAppearance: Decodable { |
| 18 | public let background: [Double], backgroundTop: [Double], particle: [Double] |
| 19 | public let eventColors: Bool, brightness: Double, dotScale: Double, glow: Double, environment: Bool |
| 20 | public var valid: Bool { [background,backgroundTop,particle].allSatisfy { $0.count == 3 && $0.allSatisfy { $0.isFinite && (0...255).contains($0) } } && brightness.isFinite && (0.25...2).contains(brightness) && dotScale.isFinite && (0.65...1.8).contains(dotScale) && glow.isFinite && (0...1).contains(glow) } |
| 21 | public func color(_ values: [Double]) -> Color { |
| 22 | guard values.count == 3 else { return Color.black } |
| 23 | return Color(red: values[0]/255, green: values[1]/255, blue: values[2]/255) |
| 24 | } |
| 25 | } |
| 26 | public struct PetProjection: Decodable { |
| 27 | public let points: [[Double]], style: Frame, state: PetState |
| 28 | } |
| 29 | public struct PetActionCue: Decodable { |
| 30 | public let label: String, tool: String?, observed: Bool, parallel: Int |
| 31 | public var caption: String { label + (tool.map { " · " + $0 } ?? "") + (parallel > 0 ? " · \(parallel) parallel agents" : "") } |
| 32 | } |
| 33 | public struct PetSharedFrame: Decodable { |
| 34 | public let version: Int, identity: String, epoch: String, tick: UInt64, cursor: UInt64, source: String, sourceRevision: UInt64 |
| 35 | public let timeMs: Double, behaviour: String, needs: String, digest: String |
| 36 | public let points: [[Double]], style: Frame, state: PetState, still: PetProjection |
| 37 | public let appearance: PetAppearance? |
| 38 | public let activity: PetActionCue? |
| 39 | public let producerConnected: Bool, storageAvailable: Bool, audioOwner: String?, audioUnavailable: Bool |
| 40 | public func validate() throws { |
| 41 | guard version == 1, UUID(uuidString: identity) != nil, UUID(uuidString: epoch) != nil, |
| 42 | timeMs.isFinite, timeMs >= 0, digest.count == 16, appearance?.valid != false, |
| 43 | [points, still.points].allSatisfy({ $0.count == 980 && $0.allSatisfy { $0.count == 2 && $0.allSatisfy { $0.isFinite && abs($0) <= 8 } } }) |
| 44 | else { throw PetCoreError.invalid("Invalid shared pet frame.") } |
| 45 | } |
| 46 | } |
| 47 | |
| 48 | /// A view, never a world. Its task polls immutable owner snapshots. Reopening |
| 49 | /// reads the same private descriptor; closing cannot stop the companion. |
| 50 | @MainActor public final class PetSharedHost: ObservableObject { |
| 51 | @Published public private(set) var frame: PetSharedFrame? |
| 52 | @Published public private(set) var previous: PetSharedFrame? |
| 53 | @Published public private(set) var message = "Connecting to the shared pet…" |
| 54 | @Published public private(set) var sound = false |
| 55 | @Published public private(set) var changedAt = ProcessInfo.processInfo.systemUptime |
| 56 | private var connection: PetConnection? |
| 57 | private var task: Task<Void, Never>? |
| 58 | private var viewCount = 0 |
| 59 | private let client = UUID().uuidString |
| 60 | private var sequence: UInt64 = 0 |
| 61 | private var pending: Data? |
| 62 | private var sending = false |
| 63 | private var lastLease = 0.0 |
| 64 | private var lastStart = 0.0 |
| 65 | private let session: URLSession = { |
| 66 | let config = URLSessionConfiguration.ephemeral |
| 67 | config.timeoutIntervalForRequest = 1.5; config.timeoutIntervalForResource = 2 |
| 68 | config.urlCache = nil |
| 69 | return URLSession(configuration: config) |
| 70 | }() |
| 71 | public init() {} |
| 72 | public var fresh: Bool { ProcessInfo.processInfo.systemUptime - changedAt < 0.8 } |
| 73 | private var descriptorURL: URL { |
| 74 | #if os(macOS) |
| 75 | return URL(fileURLWithPath: ProcessInfo.processInfo.environment["CODEWHALE_PET_HOME"] ?? NSHomeDirectory() + "/.codewhale/pet-shared").appendingPathComponent("connection.json") |
| 76 | #else |
| 77 | return FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0].appendingPathComponent("pet-connection.json") |
| 78 | #endif |
| 79 | } |
| 80 | private func loadConnection() throws { |
| 81 | let fd = open(descriptorURL.path, O_RDONLY | O_NOFOLLOW | O_NONBLOCK | O_CLOEXEC) |
| 82 | guard fd >= 0 else { throw PetCoreError.invalid("Run /pet in Codewhale to start the shared pet.") } |
| 83 | defer { close(fd) }; var info = stat() |
| 84 | guard fstat(fd, &info) == 0, info.st_mode & S_IFMT == S_IFREG, info.st_nlink == 1, info.st_size <= 4096 else { throw PetCoreError.invalid("Invalid shared connection file.") } |
| 85 | var bytes = [UInt8](repeating: 0, count: 4097); let count = Darwin.read(fd, &bytes, bytes.count) |
| 86 | guard count > 0, count <= 4096 else { throw PetCoreError.invalid("Shared connection could not be read.") } |
| 87 | let next = try JSONDecoder().decode(PetConnection.self, from: Data(bytes.prefix(count))); try next.validate(); connection = next |
| 88 | } |
| 89 | private func startOwnerIfAvailable() { |
| 90 | #if os(macOS) |
| 91 | let now = ProcessInfo.processInfo.systemUptime |
| 92 | guard now - lastStart >= 5 else { return }; lastStart = now |
| 93 | let binary = ProcessInfo.processInfo.environment["CODEWHALE_PET_EXECUTABLE"].map { URL(fileURLWithPath: $0) } |
| 94 | ?? Bundle.main.url(forAuxiliaryExecutable: "codewhale-pet-owner") |
| 95 | if let binary { |
| 96 | let process = Process(); process.executableURL = binary; process.arguments = ["pet", "serve"] |
| 97 | process.standardInput = FileHandle.nullDevice; process.standardOutput = FileHandle.nullDevice; process.standardError = FileHandle.nullDevice |
| 98 | try? process.run() |
| 99 | } |
| 100 | #endif |
| 101 | } |
| 102 | public func start() { |
| 103 | guard task == nil else { return } |
| 104 | task = Task { [weak self] in |
| 105 | while !Task.isCancelled { |
| 106 | guard let self else { return } |
| 107 | do { |
| 108 | if self.connection == nil { try self.loadConnection() } |
| 109 | let data = try await self.request("/v1/frame") |
| 110 | let next = try JSONDecoder().decode(PetSharedFrame.self, from: data); try next.validate() |
| 111 | guard next.identity == self.connection?.identity else { throw PetCoreError.invalid("Pet identity changed; reopen its connection.") } |
| 112 | if self.frame?.epoch != next.epoch { self.previous = nil; self.changedAt = ProcessInfo.processInfo.systemUptime } |
| 113 | else if self.frame?.tick != next.tick { self.previous = self.frame; self.changedAt = ProcessInfo.processInfo.systemUptime } |
| 114 | self.frame = next |
| 115 | self.message = !self.fresh ? "Owner paused · unobserved" : next.producerConnected ? "Following \(next.source)" : "\(next.source) · telemetry unobserved" |
| 116 | if !next.storageAvailable { self.message += " · storage unavailable; previous recording kept" } |
| 117 | if next.audioUnavailable { self.sound = false; self.message += " · sound unavailable" } |
| 118 | if self.sound && self.fresh && ProcessInfo.processInfo.systemUptime - self.lastLease > 0.5 { await self.setSound(true) } |
| 119 | if self.pending != nil { await self.sendPending() } |
| 120 | } catch { |
| 121 | self.message = error.localizedDescription; self.connection = nil; self.sound = false; self.startOwnerIfAvailable() |
| 122 | } |
| 123 | try? await Task.sleep(for: .milliseconds(33)) |
| 124 | } |
| 125 | } |
| 126 | } |
| 127 | public func attachView() { viewCount += 1; start() } |
| 128 | public func detachView() { viewCount = max(0, viewCount - 1); if viewCount == 0 { stop() } } |
| 129 | public func stop() { |
| 130 | task?.cancel(); task = nil |
| 131 | if sound { Task { await setSound(false) } } |
| 132 | } |
| 133 | private func request(_ path: String, body: Data? = nil) async throws -> Data { |
| 134 | guard let connection else { throw PetCoreError.invalid("No shared pet connection.") } |
| 135 | var request = URLRequest(url: URL(string: "http://127.0.0.1:\(connection.port)\(path)")!) |
| 136 | request.setValue("Bearer " + connection.token, forHTTPHeaderField: "Authorization") |
| 137 | if let body { request.httpMethod = "POST"; request.httpBody = body; request.setValue("application/json", forHTTPHeaderField: "Content-Type") } |
| 138 | let (data, response) = try await session.data(for: request) |
| 139 | guard data.count <= (path == "/v1/export" ? 64 : 8) * 1024 * 1024, (response as? HTTPURLResponse)?.statusCode == 200 else { |
| 140 | let error = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any] |
| 141 | let message = error?["error"] as? String ?? "Shared pet unavailable." |
| 142 | if path == "/v1/action", (response as? HTTPURLResponse)?.statusCode == 409, !message.contains("storage") { pending = nil } |
| 143 | throw PetCoreError.invalid(message) |
| 144 | } |
| 145 | return data |
| 146 | } |
| 147 | public func interact(food: Bool) { |
| 148 | guard let frame, fresh, pending == nil else { return } |
| 149 | pending = try? JSONSerialization.data(withJSONObject: ["identity":frame.identity,"client":client,"seq":sequence + 1,"source_revision":frame.sourceRevision, |
| 150 | "action":["kind":"interact","food":food,"x":0.2,"y":-0.15] as [String:Any]]) |
| 151 | Task { await sendPending() } |
| 152 | } |
| 153 | private func sendPending() async { |
| 154 | guard let pending, !sending else { return }; sending = true; defer { sending = false } |
| 155 | do { _ = try await request("/v1/action", body: pending); sequence += 1; self.pending = nil } |
| 156 | catch { message = error.localizedDescription } |
| 157 | } |
| 158 | public func setSound(_ enabled: Bool) async { |
| 159 | do { |
| 160 | let data = try await request("/v1/audio", body: JSONSerialization.data(withJSONObject: ["client":client,"enabled":enabled])) |
| 161 | let response = try JSONSerialization.jsonObject(with: data) as? [String:Any] |
| 162 | sound = response?["granted"] as? Bool ?? false; lastLease = ProcessInfo.processInfo.systemUptime |
| 163 | if enabled && !sound { message = "Another view owns sound." } |
| 164 | } catch { sound = false; message = error.localizedDescription } |
| 165 | } |
| 166 | public func openAppearance() { |
| 167 | guard let connection, let url=URL(string:"http://127.0.0.1:\(connection.port)/#\(connection.token)") else { return } |
| 168 | #if os(macOS) |
| 169 | NSWorkspace.shared.open(url) |
| 170 | #else |
| 171 | UIApplication.shared.open(url) |
| 172 | #endif |
| 173 | } |
| 174 | public func export() async throws -> Data { try await request("/v1/export") } |
| 175 | } |
| 176 | |
| 177 | public struct PetSharedHabitat: View { |
| 178 | @ObservedObject public var host: PetSharedHost |
| 179 | public let still: Bool |
| 180 | public init(host: PetSharedHost, still: Bool) { self.host = host; self.still = still } |
| 181 | public var body: some View { |
| 182 | VStack(alignment: .leading, spacing: 12) { |
| 183 | TimelineView(.animation(minimumInterval: 1.0 / 60, paused: still)) { _ in |
| 184 | Canvas { ctx, size in |
| 185 | let bounds = CGRect(origin: .zero, size: size) |
| 186 | let appearance = host.frame?.appearance |
| 187 | let colors = appearance.map { [$0.color($0.backgroundTop), $0.color($0.background)] } ?? [Color(red:0.075,green:0.15,blue:0.19),Color(red:0.027,green:0.05,blue:0.07)] |
| 188 | ctx.fill(Path(bounds), with: .linearGradient(Gradient(colors:colors), startPoint:.zero, endPoint:CGPoint(x:size.width,y:size.height))) |
| 189 | guard let f = host.frame else { return } |
| 190 | let state = still ? f.still.state : f.state, style = still ? f.still.style : f.style, points = still ? f.still.points : f.points |
| 191 | let lay = petLayout(w:size.width,h:size.height,state:state) |
| 192 | let fraction = min(1,max(0,(ProcessInfo.processInfo.systemUptime-host.changedAt)*30)) |
| 193 | let color = Color(red:style.r/255,green:style.g/255,blue:style.b/255) |
| 194 | let t = still ? 0 : f.timeMs / 1000 |
| 195 | for lane in 0..<(appearance?.environment == false ? 0 : 6) { |
| 196 | var path = Path() |
| 197 | for x in stride(from:0.0,through:size.width,by:8) { |
| 198 | let y=size.height*0.85+sin(x/110+Double(lane)+t*0.18)*8+Double(lane)*5 |
| 199 | if x==0 {path.move(to:CGPoint(x:x,y:y))} else {path.addLine(to:CGPoint(x:x,y:y))} |
| 200 | } |
| 201 | ctx.stroke(path,with:.color(.cyan.opacity(0.035)),lineWidth:1) |
| 202 | } |
| 203 | for (i,q) in points.enumerated() { |
| 204 | let before = !still && host.fresh ? host.previous?.points[i] : nil |
| 205 | let x = before.map {$0[0]+(q[0]-$0[0])*fraction} ?? q[0], y = before.map {$0[1]+(q[1]-$0[1])*fraction} ?? q[1] |
| 206 | let depth=0.65+0.35*Double(i*37%101)/100, radius=max(0.7,lay.dot*0.31)*depth*(appearance?.dotScale ?? 1) |
| 207 | let point=CGPoint(x:lay.ox+x*lay.scale*lay.flipX,y:lay.oy+y*lay.scale) |
| 208 | let dot=Path(ellipseIn:CGRect(x:point.x-radius,y:point.y-radius,width:radius*2,height:radius*2)) |
| 209 | if style.hollow || !f.producerConnected || !host.fresh {ctx.stroke(dot,with:.color(color.opacity(style.alpha*depth)),lineWidth:0.7)} |
| 210 | else { |
| 211 | ctx.fill(dot,with:.color(color.opacity(style.alpha*depth))) |
| 212 | let glow=appearance?.glow ?? 0.5 |
| 213 | if glow > 0 && i % 5 == 0 { let r=radius*(1+glow*5); ctx.fill(Path(ellipseIn:CGRect(x:point.x-r,y:point.y-r,width:r*2,height:r*2)),with:.color(color.opacity(0.055*glow))) } |
| 214 | } |
| 215 | } |
| 216 | } |
| 217 | }.clipShape(RoundedRectangle(cornerRadius:5)) |
| 218 | .accessibilityLabel(host.frame.map {"Shared pet, \($0.style.channel), \($0.style.arch), \($0.behaviour)\($0.state.observed < 0.92 || !$0.producerConnected || !host.fresh ? ", unobserved" : "")"} ?? "Connecting to shared pet") |
| 219 | if let f=host.frame { |
| 220 | if let activity=f.activity, activity.observed, host.fresh { Text(activity.caption).font(.callout).accessibilityAddTraits(.updatesFrequently) } |
| 221 | Text("\(f.style.channel) · \(f.style.arch) · \(f.behaviour)").font(.caption.monospaced()) |
| 222 | } |
| 223 | Text(host.message).font(.caption).foregroundStyle(.secondary) |
| 224 | HStack { |
| 225 | Button("Appearance…"){host.openAppearance()}; Button("Focus"){host.interact(food:false)}; Button("Pulse"){host.interact(food:true)} |
| 226 | Toggle("Companion sound",isOn:Binding(get:{host.sound},set:{ value in Task {await host.setSound(value)} })) |
| 227 | } |
| 228 | if let f=host.frame {Text("\(f.identity) · tick \(f.tick) · \(f.digest)").font(.system(size:9,design:.monospaced)).foregroundStyle(.secondary).textSelection(.enabled)} |
| 229 | }.onAppear{host.attachView()}.onDisappear{host.detachView()} |
| 230 | } |
| 231 | } |
| 232 |