返回 CodeWhale
live.rs
根目录 / crates / tui / src / tui / pet_watch / live.rs
1 //! Live view transport replacing the app-local QuickJS Worker. Only immutable
2 //! projections cross back to Ratatui; network, raster and encoding stay here.
3 use super::{graphics, owner};
4 use serde::{Deserialize, Serialize};
5 use serde_json::{Value, json};
6 use std::{
7 io::{self, Read},
8 path::PathBuf,
9 sync::{Arc, Mutex, mpsc},
10 time::{Duration, Instant},
11 };
12
13 #[derive(Clone, Deserialize, Serialize)]
14 pub struct Style {
15 pub r: f64,
16 pub g: f64,
17 pub b: f64,
18 pub alpha: f64,
19 pub hollow: bool,
20 pub channel: String,
21 pub arch: String,
22 }
23 #[derive(Clone, Deserialize, Serialize)]
24 pub struct Pose {
25 pub points: Vec<[f64; 2]>,
26 pub style: Style,
27 pub state: Value,
28 }
29 #[derive(Clone, Deserialize, Serialize)]
30 pub struct Activity {
31 pub label: String,
32 pub tool: Option<String>,
33 pub observed: bool,
34 pub parallel: usize,
35 }
36 #[derive(Clone, Deserialize, Serialize)]
37 #[serde(rename_all = "camelCase")]
38 pub struct Scene {
39 pub version: u8,
40 pub identity: String,
41 pub epoch: String,
42 pub tick: u64,
43 pub cursor: u64,
44 pub source: String,
45 pub source_revision: u64,
46 pub time_ms: f64,
47 pub digest: String,
48 pub behaviour: String,
49 pub producer_connected: bool,
50 pub storage_available: bool,
51 pub audio_owner: Option<String>,
52 pub audio_unavailable: bool,
53 pub points: Vec<[f64; 2]>,
54 pub style: Style,
55 pub state: Value,
56 pub still: Pose,
57 #[serde(default)]
58 pub appearance: super::appearance::Appearance,
59 #[serde(default)]
60 pub activity: Option<Activity>,
61 }
62 impl Scene {
63 fn valid(&self) -> bool {
64 self.version == 1
65 && self.time_ms.is_finite()
66 && self.points.len() == 980
67 && self.still.points.len() == 980
68 && self
69 .points
70 .iter()
71 .chain(&self.still.points)
72 .flatten()
73 .all(|p| p.is_finite() && p.abs() <= 8.0)
74 }
75 }
76 #[derive(Clone)]
77 pub struct Presentation {
78 pub client: String,
79 pub scene: Scene,
80 pub cells: Vec<u8>,
81 pub image: Option<Vec<u8>>,
82 pub width: u16,
83 pub height: u16,
84 pub created: Instant,
85 pub frame_changed: Instant,
86 pub render_ms: f64,
87 pub bytes: usize,
88 }
89 #[derive(Clone, Default)]
90 pub struct View {
91 pub width: u16,
92 pub height: u16,
93 pub cell_width: f64,
94 pub cell_height: f64,
95 pub motion: bool,
96 pub pixels: bool,
97 pub visible: bool,
98 pub waiting: bool,
99 pub sound: bool,
100 }
101 #[derive(Clone)]
102 pub enum Command {
103 Observe(String),
104 Select,
105 Browser,
106 Window,
107 Export,
108 }
109 pub enum Notice {
110 Exported(PathBuf),
111 Message(String),
112 }
113 pub struct Worker {
114 pub tx: mpsc::SyncSender<Command>,
115 pub latest: Arc<Mutex<Option<Presentation>>>,
116 pub view: Arc<Mutex<View>>,
117 pub notices: mpsc::Receiver<Notice>,
118 }
119
120 pub struct Client {
121 pub descriptor: owner::Descriptor,
122 http: reqwest::blocking::Client,
123 }
124 impl Client {
125 pub fn connect() -> io::Result<Self> {
126 let root = owner::directory()?;
127 let http = crate::tls::reqwest_blocking_client_builder()
128 .no_proxy()
129 .connect_timeout(Duration::from_millis(500))
130 .timeout(Duration::from_secs(2))
131 .build()
132 .map_err(io::Error::other)?;
133 let attempt = || -> io::Result<Self> {
134 Ok(Self {
135 descriptor: owner::descriptor(&root)?,
136 http: http.clone(),
137 })
138 };
139 if let Ok(client) = attempt()
140 && client.get("/v1/frame").is_ok()
141 {
142 return Ok(client);
143 }
144 #[cfg(not(test))]
145 {
146 use std::process::{Command as Process, Stdio};
147 let mut process = Process::new(std::env::current_exe()?);
148 process
149 .args(["pet", "serve"])
150 .stdin(Stdio::null())
151 .stdout(Stdio::null())
152 .stderr(Stdio::null());
153 #[cfg(unix)]
154 {
155 use std::os::unix::process::CommandExt;
156 unsafe {
157 process.pre_exec(|| {
158 if libc::setsid() < 0 {
159 return Err(io::Error::last_os_error());
160 }
161 Ok(())
162 });
163 }
164 }
165 #[cfg(windows)]
166 {
167 use std::os::windows::process::CommandExt;
168 process.creation_flags(0x08000000 | 0x00000008);
169 }
170 let mut child = process.spawn()?;
171 std::thread::spawn(move || {
172 let _ = child.wait();
173 });
174 }
175 let began = Instant::now();
176 while began.elapsed() < Duration::from_secs(5) {
177 if let Ok(client) = attempt()
178 && client.get("/v1/frame").is_ok()
179 {
180 return Ok(client);
181 }
182 std::thread::sleep(Duration::from_millis(50));
183 }
184 Err(io::Error::other(
185 "Shared pet unavailable. Run codewhale pet serve; existing recordings are preserved.",
186 ))
187 }
188 pub fn get(&self, path: &str) -> io::Result<Value> {
189 self.request(path, None)
190 }
191 pub fn post(&self, path: &str, body: &Value) -> io::Result<Value> {
192 self.request(path, Some(body))
193 }
194 fn request(&self, path: &str, body: Option<&Value>) -> io::Result<Value> {
195 let url = format!("http://127.0.0.1:{}{path}", self.descriptor.port);
196 let request = if let Some(body) = body {
197 self.http.post(url).json(body)
198 } else {
199 self.http.get(url)
200 };
201 let response = request
202 .bearer_auth(&self.descriptor.token)
203 .send()
204 .map_err(io::Error::other)?;
205 let status = response.status();
206 let success = status.is_success();
207 let bound = if path == "/v1/export" {
208 super::persistence::MAX_EXPORT_BYTES
209 } else {
210 8 * 1024 * 1024
211 };
212 let mut bytes = Vec::new();
213 response.take(bound as u64 + 1).read_to_end(&mut bytes)?;
214 if bytes.len() > bound {
215 return Err(io::Error::other("Pet response exceeds its bound"));
216 }
217 let value: Value = serde_json::from_slice(&bytes)?;
218 if !success {
219 let message = value["error"]
220 .as_str()
221 .unwrap_or("Shared pet connection failed");
222 return Err(io::Error::new(
223 if status == reqwest::StatusCode::CONFLICT && !message.contains("storage") {
224 io::ErrorKind::InvalidInput
225 } else {
226 io::ErrorKind::Other
227 },
228 message,
229 ));
230 }
231 Ok(value)
232 }
233 pub fn open_browser(&self) -> io::Result<()> {
234 let url = format!(
235 "http://127.0.0.1:{}/#{}",
236 self.descriptor.port, self.descriptor.token
237 );
238 webbrowser::open(&url).map_err(io::Error::other)
239 }
240 pub fn open_window(&self) -> io::Result<()> {
241 #[cfg(target_os = "macos")]
242 {
243 use std::process::{Command as Process, Stdio};
244 let mut process = Process::new("open");
245 if let Some(path) = std::env::var_os("CODEWHALE_PET_APP") {
246 process.arg(path);
247 } else {
248 process.args(["-a", "Codewhale Pet"]);
249 }
250 process
251 .args(["--args", "--companion"])
252 .stdin(Stdio::null())
253 .stdout(Stdio::null())
254 .stderr(Stdio::null());
255 if process.status()?.success() {
256 return Ok(());
257 }
258 Err(io::Error::other(
259 "Build or install the Codewhale Pet app to open its companion window",
260 ))
261 }
262 #[cfg(not(target_os = "macos"))]
263 {
264 Err(io::Error::other(
265 "The native companion window is currently available on macOS",
266 ))
267 }
268 }
269 }
270 impl Worker {
271 pub fn start(session: Option<String>) -> io::Result<Self> {
272 let (tx, rx) = mpsc::sync_channel(128);
273 let (latest, (notices_tx, notices)) = (Arc::new(Mutex::new(None)), mpsc::sync_channel(16));
274 let output = latest.clone();
275 let view = Arc::new(Mutex::new(View {
276 width: 40,
277 height: 8,
278 ..View::default()
279 }));
280 let settings = view.clone();
281 std::thread::Builder::new()
282 .name("pet-view".into())
283 .spawn(move || {
284 if let Err(e) = run(rx, &output, &notices_tx, &settings, session) {
285 let _ = notices_tx.try_send(Notice::Message(e.to_string()));
286 }
287 })?;
288 Ok(Self {
289 tx,
290 latest,
291 notices,
292 view,
293 })
294 }
295 }
296 fn run(
297 rx: mpsc::Receiver<Command>,
298 output: &Mutex<Option<Presentation>>,
299 notices: &mpsc::SyncSender<Notice>,
300 settings: &Mutex<View>,
301 session: Option<String>,
302 ) -> io::Result<()> {
303 use sha2::{Digest, Sha256};
304 let source = session.as_ref().map(|s| {
305 format!(
306 "session:{}",
307 Sha256::digest(s.as_bytes())
308 .iter()
309 .take(10)
310 .map(|b| format!("{b:02x}"))
311 .collect::<String>()
312 )
313 });
314 let mut renderer = graphics::Renderer::default();
315 let id = uuid::Uuid::new_v4().to_string();
316 let mut client = Client::connect()?;
317
318 let mut scene: Option<Scene> = None;
319 let mut previous = None;
320 let mut changed = Instant::now();
321 let mut fetched = Instant::now() - Duration::from_secs(1);
322 let mut encoded = Instant::now() - Duration::from_secs(1);
323 let mut events = Vec::new();
324 let mut producer_seq = None;
325 let mut sequence = 0u64;
326 let mut action: Option<Value> = None;
327 let mut last_action = Instant::now() - Duration::from_secs(1);
328 let mut last_produce = Instant::now();
329 let mut last_audio = Instant::now();
330 let mut last_failure = Instant::now() - Duration::from_secs(10);
331 loop {
332 let view = settings
333 .lock()
334 .map_err(|_| io::Error::other("Pet view lock failed"))?
335 .clone();
336 match rx.recv_timeout(Duration::from_millis(2)) {
337 Ok(command) => match command {
338 Command::Observe(text) => {
339 if events.len() < 64 {
340 events.push(serde_json::from_str::<Value>(&text)?)
341 } else {
342 events.clear();
343 producer_seq = None;
344 }
345 }
346 Command::Select => {
347 if let (Some(s), Some(source)) = (&scene, &source)
348 && action.is_none()
349 {
350 action = Some(
351 json!({"identity":s.identity,"client":id,"seq":sequence+1,"source_revision":s.source_revision,"action":{"kind":"select","source":source}}),
352 );
353 } else {
354 let _ = notices.try_send(Notice::Message(
355 "Save this session and wait for the pet connection before selecting its source.".into(),
356 ));
357 }
358 }
359 Command::Browser => {
360 if let Err(e) = client.open_browser() {
361 let _ = notices.try_send(Notice::Message(e.to_string()));
362 }
363 }
364 Command::Window => {
365 if let Err(e) = client.open_window() {
366 let _ = notices.try_send(Notice::Message(e.to_string()));
367 }
368 }
369 Command::Export => {
370 let result = client.get("/v1/export").and_then(|r| {
371 super::persistence::export(
372 session.as_deref().ok_or_else(|| {
373 io::Error::other("Save the terminal session before exporting")
374 })?,
375 &serde_json::to_vec(&r)?,
376 )
377 });
378 let _ = notices.try_send(match result {
379 Ok(path) => Notice::Exported(path),
380 Err(e) => Notice::Message(e.to_string()),
381 });
382 }
383 },
384 Err(mpsc::RecvTimeoutError::Disconnected) => {
385 let _ = client.post("/v1/audio", &json!({"client":id,"enabled":false}));
386 return Ok(());
387 }
388 Err(mpsc::RecvTimeoutError::Timeout) => {}
389 }
390 if fetched.elapsed() >= Duration::from_millis(if view.visible { 30 } else { 400 }) {
391 match client
392 .get("/v1/frame")
393 .and_then(|value| serde_json::from_value::<Scene>(value).map_err(io::Error::other))
394 {
395 Ok(next) if next.valid() => {
396 if scene
397 .as_ref()
398 .is_none_or(|s| s.epoch != next.epoch || s.identity != next.identity)
399 {
400 previous = None;
401 changed = Instant::now();
402 producer_seq = None;
403 events.clear();
404 } else if scene.as_ref().is_some_and(|s| s.tick != next.tick) {
405 previous = scene.clone();
406 changed = Instant::now();
407 }
408 if next.source == "unattached"
409 && let Some(source) = &source
410 && action.is_none()
411 {
412 action = Some(
413 json!({"identity":next.identity,"client":id,"seq":sequence+1,"source_revision":next.source_revision,"action":{"kind":"select","source":source}}),
414 );
415 }
416 scene = Some(next);
417 fetched = Instant::now();
418 }
419 _ => {
420 fetched = Instant::now();
421 events.clear();
422 producer_seq = None;
423 if last_failure.elapsed() > Duration::from_secs(3) {
424 last_failure = Instant::now();
425 let _ = notices.try_send(Notice::Message(
426 "Shared pet reconnecting · unobserved".into(),
427 ));
428 if let Ok(next) = Client::connect() {
429 client = next;
430 }
431 }
432 continue;
433 }
434 }
435 }
436 let Some(s) = &scene else { continue };
437 if last_action.elapsed() >= Duration::from_millis(250)
438 && let Some(pending) = &action
439 {
440 last_action = Instant::now();
441 match client.post("/v1/action", pending) {
442 Ok(_) => {
443 let selected = pending["action"]["kind"] == "select";
444 sequence += 1;
445 action = None;
446 if selected {
447 producer_seq = None;
448 events.clear();
449 }
450 }
451 Err(e) => {
452 if e.kind() == io::ErrorKind::InvalidInput {
453 action = None;
454 }
455 if last_failure.elapsed() > Duration::from_secs(3) {
456 last_failure = Instant::now();
457 let _ = notices.try_send(Notice::Message(e.to_string()));
458 }
459 }
460 }
461 }
462 if source.as_deref() == Some(s.source.as_str())
463 && last_produce.elapsed() >= Duration::from_millis(100)
464 {
465 let seq = producer_seq.map_or(0, |seq| seq + 1);
466 if seq == 0 {
467 events.clear();
468 }
469 let body = json!({"identity":s.identity,"epoch":s.epoch,"client":id,"source":s.source,"source_revision":s.source_revision,"seq":seq,"waiting":view.waiting,"events":events});
470 producer_seq = client.post("/v1/producer", &body).ok().map(|_| seq);
471 events.clear();
472 last_produce = Instant::now();
473 } else if source.as_deref() != Some(s.source.as_str()) {
474 events.clear();
475 producer_seq = None;
476 }
477 if last_audio.elapsed() >= Duration::from_millis(500) {
478 let _=client.post("/v1/audio",&json!({"client":id,"enabled":view.sound&&view.visible&&changed.elapsed()<Duration::from_millis(500)}));
479 last_audio = Instant::now();
480 }
481 if view.visible
482 && encoded.elapsed()
483 >= Duration::from_millis(if view.motion && view.pixels {
484 16
485 } else if view.motion {
486 33
487 } else {
488 200
489 })
490 {
491 let began = Instant::now();
492 let mut pose = if view.motion {
493 Pose {
494 points: s.points.clone(),
495 style: s.style.clone(),
496 state: s.state.clone(),
497 }
498 } else {
499 s.still.clone()
500 };
501 if !s.producer_connected {
502 pose.style.hollow = true;
503 }
504 let fraction = if view.motion {
505 (changed.elapsed().as_secs_f64() * 30.0).clamp(0.0, 1.0)
506 } else {
507 1.0
508 };
509 renderer.set_appearance(&s.appearance);
510 let (cells, image) = renderer.render(
511 &pose,
512 previous.as_ref().filter(|_| view.motion),
513 fraction,
514 &view,
515 if view.motion { s.time_ms / 1000.0 } else { 0.0 },
516 )?;
517 let bytes = image.as_ref().map_or(0, Vec::len);
518 if let Ok(mut slot) = output.lock() {
519 *slot = Some(Presentation {
520 client: id.clone(),
521 scene: s.clone(),
522 cells,
523 image,
524 width: view.width,
525 height: view.height,
526 created: Instant::now(),
527 frame_changed: changed,
528 render_ms: began.elapsed().as_secs_f64() * 1000.0,
529 bytes,
530 });
531 }
532 encoded = began;
533 }
534 }
535 }
536
536 lines RUST