1use std::collections::HashSet;
4use std::fs;
5use std::io;
6use std::net::SocketAddr;
7use std::path::Path;
8use std::time::{Duration, Instant};
9
10use eyre::WrapErr as _;
11use eyre::{Result, bail};
12use futures_util::{FutureExt as _, pin_mut, select};
13use smol::Timer;
14use smol::net::TcpStream;
15use sysinfo::{Pid, ProcessRefreshKind, ProcessesToUpdate, System};
16
17use super::protocol::{
18 AppError, AppRequest, AppResponse, DylibId, DylibSource, PREVIEW_PROTOCOL_COMMIT,
19 PreviewProtocolInfo, PreviewRuntimePlatform, PreviewTcpConfig, Size,
20};
21
22use waterui_preview_protocol::registry::{PreviewAppInstance, preview_instance_registry_dir};
23use waterui_preview_protocol::transport::{read_frame, write_frame};
24
25#[derive(Debug)]
27pub struct PreviewAppClient {
28 stream: TcpStream,
29 present_dylibs: HashSet<DylibId>,
31}
32
33#[derive(Debug)]
40pub enum PreviewProbe {
41 Connected(PreviewAppClient),
43 Rejected(String),
46 Silent,
48}
49
50fn describe_incompatible_app(
57 addr: SocketAddr,
58 app: &PreviewProtocolInfo,
59 expected_core: &str,
60) -> String {
61 let mut reasons = Vec::new();
62 if app.build_commit != PREVIEW_PROTOCOL_COMMIT {
63 reasons.push(format!(
64 " preview protocol: app {}, this CLI {PREVIEW_PROTOCOL_COMMIT}",
65 app.build_commit
66 ));
67 }
68 if app.waterui_core_fingerprint != expected_core {
69 reasons.push(format!(
70 " runtime: app {}, expected {expected_core}",
71 app.waterui_core_fingerprint
72 ));
73 }
74 format!(
75 "A preview app is listening on {addr} and answering, but it is not a build this `water` \
76 can drive:\n{}\nRebuild whichever of the two is older, so the CLI and the support app \
77 come from one checkout.",
78 reasons.join("\n")
79 )
80}
81
82impl PreviewAppClient {
83 pub async fn probe_addr(
85 addr: SocketAddr,
86 expected_waterui_core_fingerprint: &str,
87 expected_platform: PreviewRuntimePlatform,
88 ) -> PreviewProbe {
89 let stream = match connect_with_timeout(addr, connect_timeout()).await {
90 Ok(stream) => stream,
91 Err(error) => {
92 tracing::warn!("Preview TCP connect failed on {addr}: {error}");
93 return PreviewProbe::Silent;
94 }
95 };
96
97 tracing::info!("Connected to preview app on {addr}");
98 let _ = stream.set_nodelay(true);
99
100 let mut client = Self {
101 stream,
102 present_dylibs: HashSet::new(),
103 };
104
105 let handshake = AppRequest::Ping;
110 match client
111 .request_with_timeout(handshake, handshake_timeout())
112 .await
113 {
114 Ok(AppResponse::Pong { protocol }) => {
115 if protocol_is_compatible(
116 &protocol,
117 expected_waterui_core_fingerprint,
118 expected_platform,
119 ) {
120 return PreviewProbe::Connected(client);
121 }
122
123 tracing::warn!(
124 "Preview runtime mismatch on {addr}: app waterui_core='{}' platform={:?} protocol={}, expected waterui_core='{}' platform={:?} protocol={}",
125 protocol.waterui_core_fingerprint,
126 protocol.platform,
127 protocol.build_commit,
128 expected_waterui_core_fingerprint,
129 expected_platform,
130 PREVIEW_PROTOCOL_COMMIT,
131 );
132 return PreviewProbe::Rejected(describe_incompatible_app(
133 addr,
134 &protocol,
135 expected_waterui_core_fingerprint,
136 ));
137 }
138 Ok(other) => {
139 tracing::warn!("Preview handshake got unexpected response from {addr}: {other:?}");
140 }
141 Err(err) => {
142 tracing::warn!("Preview handshake failed on {addr}: {err}");
143 }
144 }
145
146 PreviewProbe::Silent
147 }
148
149 pub async fn probe_registered(
154 expected_waterui_core_fingerprint: &str,
155 expected_platform: PreviewRuntimePlatform,
156 ) -> Result<PreviewProbe> {
157 let expected = expected_waterui_core_fingerprint.to_string();
158 let instances = smol::unblock(move || load_registered_instances_sync(&expected)).await?;
159 tracing::info!(
160 instance_count = instances.len(),
161 "Preview loaded matching registered app instances"
162 );
163
164 let mut rejection = None;
168 for instance in instances {
169 tracing::info!(pid = instance.pid, host = %instance.host, port = instance.port, "Preview trying registered app instance");
170 let addr = SocketAddr::new(instance.host, instance.port);
171 match Self::probe_addr(addr, expected_waterui_core_fingerprint, expected_platform).await
172 {
173 PreviewProbe::Connected(client) => return Ok(PreviewProbe::Connected(client)),
174 PreviewProbe::Rejected(reason) => {
175 rejection.get_or_insert(reason);
176 }
177 PreviewProbe::Silent => {}
178 }
179 }
180
181 Ok(rejection.map_or(PreviewProbe::Silent, PreviewProbe::Rejected))
182 }
183
184 pub async fn probe_ports(
186 config: PreviewTcpConfig,
187 expected_waterui_core_fingerprint: &str,
188 expected_platform: PreviewRuntimePlatform,
189 ) -> PreviewProbe {
190 let mut rejection = None;
194 for port in config.ports() {
195 let addr = SocketAddr::new(config.host, port);
196 match Self::probe_addr(addr, expected_waterui_core_fingerprint, expected_platform).await
197 {
198 PreviewProbe::Connected(client) => return PreviewProbe::Connected(client),
199 PreviewProbe::Rejected(reason) => {
200 rejection.get_or_insert(reason);
201 }
202 PreviewProbe::Silent => {}
203 }
204 }
205
206 rejection.map_or(PreviewProbe::Silent, PreviewProbe::Rejected)
207 }
208
209 pub async fn render(
214 &mut self,
215 dylib_id: DylibId,
216 dylib_bytes: &[u8],
217 symbol: &str,
218 width: f32,
219 height: f32,
220 ) -> Result<Vec<u8>> {
221 self.render_with_dylib_source(dylib_id, dylib_bytes, symbol, width, height)
222 .await
223 .map_err(|e| eyre::eyre!("Preview app error: {e}"))
224 }
225
226 pub async fn render_with_dylib_file(
231 &mut self,
232 dylib_id: DylibId,
233 dylib_path: &Path,
234 symbol: &str,
235 width: f32,
236 height: f32,
237 prefer_local_path: bool,
238 ) -> Result<Vec<u8>, AppError> {
239 let total_start = Instant::now();
240 if let Some(png) = self
241 .render_cached_if_present(dylib_id, symbol, width, height)
242 .await?
243 {
244 tracing::info!(
245 dylib_id = %dylib_id,
246 elapsed_ms = total_start.elapsed().as_millis(),
247 "Preview rendered with cached dylib"
248 );
249 return Ok(png);
250 }
251
252 if prefer_local_path {
253 if !dylib_path.is_absolute() {
254 return Err(AppError::RenderFailed(format!(
255 "local preview dylib path must be absolute: {}",
256 dylib_path.display()
257 )));
258 }
259
260 let render_start = Instant::now();
261 let result = self
262 .render_with_source(
263 DylibSource::LocalPath {
264 id: dylib_id,
265 path: dylib_path.to_path_buf(),
266 },
267 symbol,
268 width,
269 height,
270 )
271 .await;
272 tracing::info!(
273 dylib_id = %dylib_id,
274 path = %dylib_path.display(),
275 elapsed_ms = render_start.elapsed().as_millis(),
276 total_elapsed_ms = total_start.elapsed().as_millis(),
277 "Preview rendered after transferring dylib path"
278 );
279
280 self.record_rendered_dylib(dylib_id, &result);
281
282 return result;
283 }
284
285 let read_start = Instant::now();
286 let dylib_bytes = smol::fs::read(dylib_path)
287 .await
288 .map_err(|e| AppError::RenderFailed(format!("failed to read dylib: {e}")))?;
289 tracing::info!(
290 dylib_id = %dylib_id,
291 bytes = dylib_bytes.len(),
292 elapsed_ms = read_start.elapsed().as_millis(),
293 "Preview loaded dylib bytes from disk"
294 );
295
296 let render_start = Instant::now();
297 let result = self
298 .render_with_source(
299 DylibSource::Bytes {
300 id: dylib_id,
301 bytes: dylib_bytes,
302 },
303 symbol,
304 width,
305 height,
306 )
307 .await;
308 tracing::info!(
309 dylib_id = %dylib_id,
310 elapsed_ms = render_start.elapsed().as_millis(),
311 total_elapsed_ms = total_start.elapsed().as_millis(),
312 "Preview rendered after transferring dylib bytes"
313 );
314
315 self.record_rendered_dylib(dylib_id, &result);
316
317 result
318 }
319
320 pub async fn render_with_dylib_source(
325 &mut self,
326 dylib_id: DylibId,
327 dylib_bytes: &[u8],
328 symbol: &str,
329 width: f32,
330 height: f32,
331 ) -> Result<Vec<u8>, AppError> {
332 if let Some(png) = self
333 .render_cached_if_present(dylib_id, symbol, width, height)
334 .await?
335 {
336 return Ok(png);
337 }
338
339 let result = self
340 .render_with_source(
341 DylibSource::Bytes {
342 id: dylib_id,
343 bytes: dylib_bytes.to_vec(),
344 },
345 symbol,
346 width,
347 height,
348 )
349 .await;
350 self.record_rendered_dylib(dylib_id, &result);
351 result
352 }
353
354 async fn render_cached_if_present(
355 &mut self,
356 dylib_id: DylibId,
357 symbol: &str,
358 width: f32,
359 height: f32,
360 ) -> Result<Option<Vec<u8>>, AppError> {
361 if self.present_dylibs.insert(dylib_id) {
362 let query_start = Instant::now();
363 let present = match self.has_dylib(dylib_id).await {
364 Ok(present) => present,
365 Err(error) => {
366 self.present_dylibs.remove(&dylib_id);
367 return Err(AppError::RenderFailed(format!("transport error: {error}")));
368 }
369 };
370 tracing::info!(
371 dylib_id = %dylib_id,
372 present,
373 elapsed_ms = query_start.elapsed().as_millis(),
374 "Preview queried support-app dylib cache"
375 );
376 if !present {
377 self.present_dylibs.remove(&dylib_id);
378 return Ok(None);
379 }
380 }
381
382 match self
383 .render_with_source(DylibSource::Cached { id: dylib_id }, symbol, width, height)
384 .await
385 {
386 Ok(png) => Ok(Some(png)),
387 Err(AppError::UnknownDylibId(_)) => {
388 self.present_dylibs.remove(&dylib_id);
389 Ok(None)
390 }
391 Err(error) => Err(error),
392 }
393 }
394
395 fn record_rendered_dylib(&mut self, dylib_id: DylibId, result: &Result<Vec<u8>, AppError>) {
396 match result {
397 Ok(_) | Err(AppError::SymbolNotFound(_)) => {
398 self.present_dylibs.insert(dylib_id);
399 }
400 Err(AppError::UnknownDylibId(_)) => {
401 self.present_dylibs.remove(&dylib_id);
402 }
403 Err(AppError::DylibLoad(_) | AppError::RenderFailed(_)) => {}
404 }
405 }
406
407 async fn render_with_source(
408 &mut self,
409 dylib: DylibSource,
410 symbol: &str,
411 width: f32,
412 height: f32,
413 ) -> Result<Vec<u8>, AppError> {
414 let request = AppRequest::Render {
415 dylib,
416 symbol: symbol.to_string(),
417 frame: Size::new(width, height),
418 };
419
420 let response = self
421 .request(request)
422 .await
423 .map_err(|e| AppError::RenderFailed(format!("transport error: {e}")))?;
424
425 match response {
426 waterui_preview_protocol::PreviewResponse::Render { result } => result.map(|output| {
427 tracing::info!(timings = ?output.timings, "Preview support app timing breakdown");
428 output.png_data
429 }),
430 other => Err(AppError::RenderFailed(format!(
431 "protocol error: unexpected response to Render: {other:?}"
432 ))),
433 }
434 }
435
436 pub async fn shutdown(&mut self) -> Result<()> {
441 let response = self.request(AppRequest::Shutdown).await?;
442 match response {
443 waterui_preview_protocol::PreviewResponse::Shutdown => Ok(()),
444 other => {
445 bail!("Protocol error: unexpected response to Shutdown: {other:?}");
446 }
447 }
448 }
449
450 async fn has_dylib(&mut self, id: DylibId) -> Result<bool> {
451 let response = self.request(AppRequest::HasDylib { id }).await?;
452 match response {
453 waterui_preview_protocol::PreviewResponse::HasDylib { present } => Ok(present),
454 other => {
455 bail!("Protocol error: unexpected response to HasDylib: {other:?}");
456 }
457 }
458 }
459
460 async fn request(&mut self, request: AppRequest) -> Result<AppResponse> {
461 let timeout = request_timeout_for(&request);
462 self.request_with_timeout(request, timeout).await
463 }
464
465 async fn request_with_timeout(
466 &mut self,
467 request: AppRequest,
468 timeout: Duration,
469 ) -> Result<AppResponse> {
470 let kind = request_kind(&request);
471 let start = Instant::now();
472 write_frame(&mut self.stream, &request)
473 .await
474 .wrap_err("Failed to send request")?;
475
476 let recv = async {
477 match read_frame::<_, AppResponse>(&mut self.stream).await {
478 Ok(response) => Ok(response),
479 Err(err) if err.kind() == io::ErrorKind::UnexpectedEof => {
480 bail!(
481 "Preview app connection closed unexpectedly (the preview process likely crashed). Check crash logs in ~/Library/Logs/DiagnosticReports/, filed under the preview application's own name"
482 );
483 }
484 Err(err) => Err(err).wrap_err("Failed to receive response"),
485 }
486 }
487 .fuse();
488 let timeout_fut = Timer::after(timeout).fuse();
489
490 pin_mut!(recv);
491 pin_mut!(timeout_fut);
492
493 select! {
494 result = recv => {
495 if result.is_ok() {
496 tracing::info!(
497 request = kind,
498 elapsed_ms = start.elapsed().as_millis(),
499 "Preview app request completed"
500 );
501 }
502 result
503 },
504 _ = timeout_fut => {
505 bail!("Preview app request timed out after {timeout:?} ({kind})");
506 }
507 }
508 }
509}
510
511fn protocol_is_compatible(
512 protocol: &PreviewProtocolInfo,
513 expected_waterui_core_fingerprint: &str,
514 expected_platform: PreviewRuntimePlatform,
515) -> bool {
516 protocol.waterui_core_fingerprint == expected_waterui_core_fingerprint
517 && protocol.platform == expected_platform
518 && protocol.build_commit == PREVIEW_PROTOCOL_COMMIT
519}
520
521fn load_registered_instances_sync(
522 expected_waterui_core_fingerprint: &str,
523) -> io::Result<Vec<PreviewAppInstance>> {
524 let dir = preview_instance_registry_dir();
525 fs::create_dir_all(&dir)?;
526
527 let mut candidates = Vec::new();
528 let mut stale_paths = Vec::new();
529
530 for entry in fs::read_dir(&dir)? {
531 let entry = entry?;
532 let path = entry.path();
533 if path.extension().and_then(|ext| ext.to_str()) != Some("json") {
534 continue;
535 }
536
537 let bytes = match fs::read(&path) {
538 Ok(bytes) => bytes,
539 Err(error) if error.kind() == io::ErrorKind::NotFound => continue,
540 Err(error) => return Err(error),
541 };
542
543 let Ok(instance) = serde_json::from_slice::<PreviewAppInstance>(&bytes) else {
544 stale_paths.push(path);
545 continue;
546 };
547
548 if instance.waterui_core_fingerprint == expected_waterui_core_fingerprint {
549 candidates.push((instance, path));
550 }
551 }
552
553 let mut matching = Vec::with_capacity(candidates.len());
554 if !candidates.is_empty() {
555 let mut processes = System::new();
556 processes.refresh_processes_specifics(
557 ProcessesToUpdate::All,
558 true,
559 ProcessRefreshKind::nothing(),
560 );
561 for (instance, path) in candidates {
562 if processes.process(Pid::from_u32(instance.pid)).is_some() {
563 matching.push(instance);
564 } else {
565 stale_paths.push(path);
566 }
567 }
568 }
569
570 matching.sort_by_key(|registration| std::cmp::Reverse(registration.registered_at_unix_ms));
571
572 for path in stale_paths {
573 match fs::remove_file(path) {
574 Ok(()) => {}
575 Err(error) if error.kind() == io::ErrorKind::NotFound => {}
576 Err(error) => return Err(error),
577 }
578 }
579
580 Ok(matching)
581}
582
583fn connect_timeout() -> Duration {
584 const DEFAULT_MS: u64 = 100;
585 timeout_from_env("WATERUI_PREVIEW_CONNECT_TIMEOUT_MS", DEFAULT_MS)
586}
587
588fn handshake_timeout() -> Duration {
589 const DEFAULT_MS: u64 = 5000;
594 timeout_from_env("WATERUI_PREVIEW_HANDSHAKE_TIMEOUT_MS", DEFAULT_MS)
595}
596
597fn request_timeout() -> Duration {
598 const DEFAULT_MS: u64 = 20_000;
599 timeout_from_env("WATERUI_PREVIEW_REQUEST_TIMEOUT_MS", DEFAULT_MS)
600}
601
602fn render_request_timeout() -> Duration {
603 const DEFAULT_MS: u64 = 120_000;
604 timeout_from_env("WATERUI_PREVIEW_RENDER_TIMEOUT_MS", DEFAULT_MS)
605}
606
607fn timeout_from_env(name: &str, default_ms: u64) -> Duration {
608 match std::env::var(name) {
609 Ok(value) => Duration::from_millis(
610 value
611 .parse::<u64>()
612 .unwrap_or_else(|error| panic!("invalid {name} value `{value}`: {error}")),
613 ),
614 Err(std::env::VarError::NotPresent) => Duration::from_millis(default_ms),
615 Err(std::env::VarError::NotUnicode(_)) => panic!("{name} must be valid UTF-8"),
616 }
617}
618
619fn request_timeout_for(request: &AppRequest) -> Duration {
620 match request {
621 AppRequest::Render { .. } => render_request_timeout(),
622 _ => request_timeout(),
623 }
624}
625
626const fn request_kind(request: &AppRequest) -> &'static str {
627 match request {
628 AppRequest::Ping => "Ping",
629 AppRequest::HasDylib { .. } => "HasDylib",
630 AppRequest::Render { .. } => "Render",
631 AppRequest::Shutdown => "Shutdown",
632 }
633}
634
635async fn connect_with_timeout(addr: SocketAddr, timeout: Duration) -> io::Result<TcpStream> {
636 let connect = TcpStream::connect(addr).fuse();
637 let timeout_fut = Timer::after(timeout).fuse();
638
639 pin_mut!(connect);
640 pin_mut!(timeout_fut);
641
642 select! {
643 result = connect => result,
644 _ = timeout_fut => Err(io::Error::new(io::ErrorKind::TimedOut, "preview TCP connect timed out")),
645 }
646}
647
648#[cfg(test)]
649mod tests {
650 use super::*;
651
652 #[test]
653 fn protocol_match_requires_exact_preview_build() {
654 let protocol = PreviewProtocolInfo {
655 build_commit: PREVIEW_PROTOCOL_COMMIT.to_string(),
656 waterui_core_fingerprint: "runtime-fingerprint".to_string(),
657 platform: PreviewRuntimePlatform::Macos,
658 };
659 assert!(protocol_is_compatible(
660 &protocol,
661 "runtime-fingerprint",
662 PreviewRuntimePlatform::Macos
663 ));
664
665 let stale = PreviewProtocolInfo {
666 build_commit: "stale-preview-build".to_string(),
667 ..protocol
668 };
669 assert!(!protocol_is_compatible(
670 &stale,
671 "runtime-fingerprint",
672 PreviewRuntimePlatform::Macos
673 ));
674 }
675
676 #[test]
677 fn rejection_names_both_halves_of_the_mismatch() {
678 let addr: SocketAddr = "127.0.0.1:9123".parse().unwrap();
679 let app = PreviewProtocolInfo {
680 build_commit: "app-protocol-build".to_string(),
681 waterui_core_fingerprint: "app-runtime".to_string(),
682 platform: PreviewRuntimePlatform::Macos,
683 };
684
685 let explanation = describe_incompatible_app(addr, &app, "cli-runtime");
686
687 assert!(explanation.contains("127.0.0.1:9123"), "{explanation}");
688 assert!(explanation.contains("app-protocol-build"), "{explanation}");
689 assert!(
690 explanation.contains(PREVIEW_PROTOCOL_COMMIT),
691 "{explanation}"
692 );
693 assert!(explanation.contains("app-runtime"), "{explanation}");
694 assert!(explanation.contains("cli-runtime"), "{explanation}");
695 }
696
697 #[test]
698 fn rejection_reports_only_the_half_that_differs() {
699 let addr: SocketAddr = "127.0.0.1:9123".parse().unwrap();
700 let app = PreviewProtocolInfo {
701 build_commit: PREVIEW_PROTOCOL_COMMIT.to_string(),
702 waterui_core_fingerprint: "app-runtime".to_string(),
703 platform: PreviewRuntimePlatform::Macos,
704 };
705
706 let explanation = describe_incompatible_app(addr, &app, "cli-runtime");
707
708 assert!(!explanation.contains("preview protocol:"), "{explanation}");
709 assert!(
710 explanation.contains("runtime: app app-runtime"),
711 "{explanation}"
712 );
713 }
714}