返回 CodeWhale
PetShared.swift
根目录 / pet / swift / PetShared.swift
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
232 lines Plain Text