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