Skip to main content

waterui_preview_protocol/
lib.rs

1//! Shared TCP protocol between `water` CLI and the preview support app.
2
3pub mod bench;
4pub mod hydrolysis;
5
6use serde::de::{Error as DeError, Visitor as DeVisitor};
7use serde::{Deserialize, Serialize};
8use std::fmt;
9use std::str::FromStr;
10use std::time::{SystemTime, UNIX_EPOCH};
11
12/// Build commit hash for protocol compatibility checks.
13pub const PREVIEW_PROTOCOL_COMMIT: &str = env!("WATERUI_PREVIEW_PROTOCOL_COMMIT");
14
15#[must_use]
16/// Return protocol metadata for handshake responses.
17pub fn protocol_info(waterui_core_fingerprint: impl Into<String>) -> PreviewProtocolInfo {
18    PreviewProtocolInfo {
19        build_commit: PREVIEW_PROTOCOL_COMMIT.to_string(),
20        waterui_core_fingerprint: waterui_core_fingerprint.into(),
21        platform: PreviewRuntimePlatform::current(),
22    }
23}
24
25/// Protocol metadata exchanged during ping/pong handshake.
26#[derive(Debug, Clone, Serialize, Deserialize)]
27pub struct PreviewProtocolInfo {
28    /// Build commit hash of the preview support app.
29    pub build_commit: String,
30    /// Fingerprint of the `waterui-core` package used by this preview app build.
31    pub waterui_core_fingerprint: String,
32    /// Runtime platform of the preview support app.
33    pub platform: PreviewRuntimePlatform,
34}
35
36/// Runtime platform that owns the preview support app process.
37#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
38pub enum PreviewRuntimePlatform {
39    /// macOS preview support app.
40    Macos,
41    /// iOS Simulator preview support app.
42    IosSimulator,
43    /// Physical iOS preview support app.
44    Ios,
45    /// Android preview support app.
46    Android,
47    /// Other platform.
48    Other,
49}
50
51impl PreviewRuntimePlatform {
52    /// Return the current preview support app runtime platform.
53    #[must_use]
54    pub const fn current() -> Self {
55        if cfg!(target_os = "macos") {
56            Self::Macos
57        } else if cfg!(target_os = "ios") && cfg!(target_abi = "sim") {
58            Self::IosSimulator
59        } else if cfg!(target_os = "ios") {
60            Self::Ios
61        } else if cfg!(target_os = "android") {
62            Self::Android
63        } else {
64            Self::Other
65        }
66    }
67}
68
69pub mod registry {
70    //! Preview support app instance registry shared between the CLI and local preview apps.
71
72    use std::net::IpAddr;
73    use std::path::PathBuf;
74
75    use serde::{Deserialize, Serialize};
76
77    use super::{SystemTime, UNIX_EPOCH};
78
79    /// A registered local preview support app instance.
80    #[derive(Debug, Clone, Serialize, Deserialize)]
81    pub struct PreviewAppInstance {
82        /// Operating-system process identifier of the support app.
83        pub pid: u32,
84        /// Host address the support app listens on.
85        pub host: IpAddr,
86        /// TCP port the support app listens on.
87        pub port: u16,
88        /// Runtime fingerprint of the support app build.
89        pub waterui_core_fingerprint: String,
90        /// Registration timestamp in milliseconds since the Unix epoch.
91        pub registered_at_unix_ms: u64,
92    }
93
94    impl PreviewAppInstance {
95        #[must_use]
96        /// Create a registry entry for a support app instance.
97        ///
98        /// # Panics
99        ///
100        /// Panics if the system clock predates the Unix epoch or its millisecond timestamp does
101        /// not fit into `u64`.
102        pub fn new(
103            pid: u32,
104            host: IpAddr,
105            port: u16,
106            waterui_core_fingerprint: impl Into<String>,
107        ) -> Self {
108            Self {
109                pid,
110                host,
111                port,
112                waterui_core_fingerprint: waterui_core_fingerprint.into(),
113                registered_at_unix_ms: SystemTime::now()
114                    .duration_since(UNIX_EPOCH)
115                    .expect("system clock must not be earlier than the Unix epoch")
116                    .as_millis()
117                    .try_into()
118                    .expect("preview registration timestamp must fit into u64"),
119            }
120        }
121    }
122
123    fn water_cache_dir() -> PathBuf {
124        if let Some(cache_dir) = std::env::var_os("WATER_CACHE_DIR") {
125            return PathBuf::from(cache_dir);
126        }
127
128        if let Some(cache_dir) = dirs::cache_dir() {
129            return cache_dir.join("waterui");
130        }
131
132        std::env::temp_dir().join("waterui-cache")
133    }
134
135    #[must_use]
136    /// Root cache directory for preview support assets.
137    pub fn preview_cache_root_dir() -> PathBuf {
138        water_cache_dir().join("preview")
139    }
140
141    #[must_use]
142    /// Directory containing registered preview support app instances.
143    pub fn preview_instance_registry_dir() -> PathBuf {
144        preview_cache_root_dir().join("instances")
145    }
146
147    #[must_use]
148    /// Path of the JSON registry file for a support app instance.
149    pub fn preview_instance_registry_path(instance: &PreviewAppInstance) -> PathBuf {
150        preview_instance_registry_dir().join(format!("{}-{}.json", instance.pid, instance.port))
151    }
152}
153
154pub mod transport {
155    //! Framed binary transport helpers.
156    //!
157    //! The preview protocol uses length-prefixed frames:
158    //! `u32::to_be_bytes(len)` followed by `len` bytes of binary payload.
159
160    use std::io;
161
162    use futures_lite::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt};
163    use serde::Serialize;
164    use serde::de::DeserializeOwned;
165
166    /// Length prefix size for framed messages (big-endian `u32`).
167    pub const LEN_PREFIX_BYTES: usize = 4;
168
169    /// Hard limit for a single frame to prevent OOM from malformed inputs.
170    ///
171    /// Override via `WATERUI_PREVIEW_MAX_FRAME_BYTES`.
172    ///
173    /// # Panics
174    ///
175    /// Panics if `WATERUI_PREVIEW_MAX_FRAME_BYTES` is not a valid UTF-8 `usize` value.
176    #[must_use]
177    pub fn max_frame_bytes() -> usize {
178        const DEFAULT: usize = 128 * 1024 * 1024;
179        match std::env::var("WATERUI_PREVIEW_MAX_FRAME_BYTES") {
180            Ok(value) => value.parse::<usize>().unwrap_or_else(|error| {
181                panic!("invalid WATERUI_PREVIEW_MAX_FRAME_BYTES value `{value}`: {error}")
182            }),
183            Err(std::env::VarError::NotPresent) => DEFAULT,
184            Err(std::env::VarError::NotUnicode(_)) => {
185                panic!("WATERUI_PREVIEW_MAX_FRAME_BYTES must be valid UTF-8")
186            }
187        }
188    }
189
190    /// Read a single length-prefixed binary frame.
191    ///
192    /// # Errors
193    ///
194    /// Returns an error when reading from the stream fails, the frame exceeds
195    /// [`max_frame_bytes`], or the payload cannot be decoded.
196    pub async fn read_frame<R, T>(reader: &mut R) -> io::Result<T>
197    where
198        R: AsyncRead + Unpin + Send,
199        T: DeserializeOwned,
200    {
201        let mut len_buf = [0u8; LEN_PREFIX_BYTES];
202        reader.read_exact(&mut len_buf).await?;
203        let len = u32::from_be_bytes(len_buf) as usize;
204        let max = max_frame_bytes();
205        if len > max {
206            return Err(io::Error::new(
207                io::ErrorKind::InvalidData,
208                format!("preview frame too large: {len} bytes (max {max})"),
209            ));
210        }
211
212        let mut buf = vec![0u8; len];
213        reader.read_exact(&mut buf).await?;
214
215        let config = bincode::config::standard();
216        let (value, bytes_read): (T, usize) = bincode::serde::decode_from_slice(&buf, config)
217            .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
218        if bytes_read != buf.len() {
219            return Err(io::Error::new(
220                io::ErrorKind::InvalidData,
221                "trailing bytes after preview frame payload",
222            ));
223        }
224        Ok(value)
225    }
226
227    /// Write a single length-prefixed binary frame.
228    ///
229    /// # Errors
230    ///
231    /// Returns an error when encoding the payload fails, the encoded payload
232    /// does not fit in a `u32` length prefix, or writing to the stream fails.
233    pub async fn write_frame<W, T>(writer: &mut W, value: &T) -> io::Result<()>
234    where
235        W: AsyncWrite + Unpin + Send,
236        T: Serialize + Sync,
237    {
238        let config = bincode::config::standard();
239        let data = bincode::serde::encode_to_vec(value, config)
240            .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
241        let len: u32 = data.len().try_into().map_err(|_| {
242            io::Error::new(
243                io::ErrorKind::InvalidData,
244                "preview frame too large for u32 length",
245            )
246        })?;
247
248        writer.write_all(&len.to_be_bytes()).await?;
249        writer.write_all(&data).await?;
250        writer.flush().await?;
251        Ok(())
252    }
253}
254
255pub mod tcp {
256    //! TCP configuration shared by the CLI and the preview support app.
257
258    use std::net::{IpAddr, Ipv4Addr};
259    use std::ops::RangeInclusive;
260
261    use thiserror::Error;
262
263    /// Default host the preview support app binds to.
264    pub const DEFAULT_HOST: IpAddr = IpAddr::V4(Ipv4Addr::LOCALHOST);
265
266    /// Default TCP port range start.
267    pub const DEFAULT_PORT_START: u16 = 2106;
268
269    /// Default number of ports to try.
270    pub const DEFAULT_PORT_RANGE: u16 = 50;
271
272    /// TCP configuration shared by the CLI and preview app.
273    #[derive(Debug, Clone, Copy)]
274    pub struct PreviewTcpConfig {
275        /// IP address to bind/connect to (defaults to localhost).
276        pub host: IpAddr,
277        /// First port to try.
278        pub port_start: u16,
279        /// Number of consecutive ports to try.
280        pub port_range: u16,
281    }
282
283    impl PreviewTcpConfig {
284        #[must_use]
285        /// Default localhost configuration.
286        pub const fn default_localhost() -> Self {
287            Self {
288                host: DEFAULT_HOST,
289                port_start: DEFAULT_PORT_START,
290                port_range: DEFAULT_PORT_RANGE,
291            }
292        }
293
294        /// Build config from environment variables.
295        ///
296        /// - `WATERUI_PREVIEW_HOST` (IPv4/IPv6)
297        /// - `WATERUI_PREVIEW_PORT_START` (u16)
298        /// - `WATERUI_PREVIEW_PORT_RANGE` (u16)
299        ///
300        /// Missing variables use defaults; present-but-invalid values fail fast.
301        ///
302        /// # Errors
303        ///
304        /// Returns an error when any preview TCP environment variable is present
305        /// but cannot be parsed as its declared type.
306        pub fn from_env() -> Result<Self, ConfigError> {
307            let mut cfg = Self::default_localhost();
308
309            if let Ok(host) = std::env::var("WATERUI_PREVIEW_HOST") {
310                cfg.host = host.parse().map_err(|_| ConfigError::InvalidHost)?;
311            }
312            if let Ok(port_start) = std::env::var("WATERUI_PREVIEW_PORT_START") {
313                cfg.port_start = port_start
314                    .parse()
315                    .map_err(|_| ConfigError::InvalidPortStart)?;
316            }
317            if let Ok(port_range) = std::env::var("WATERUI_PREVIEW_PORT_RANGE") {
318                cfg.port_range = port_range
319                    .parse()
320                    .map_err(|_| ConfigError::InvalidPortRange)?;
321            }
322
323            Ok(cfg)
324        }
325
326        #[must_use]
327        /// Inclusive port range to scan/bind.
328        pub const fn ports(&self) -> RangeInclusive<u16> {
329            let end = self
330                .port_start
331                .saturating_add(self.port_range.saturating_sub(1));
332            self.port_start..=end
333        }
334    }
335
336    #[derive(Debug, Error)]
337    /// Errors returned by [`PreviewTcpConfig::from_env`].
338    pub enum ConfigError {
339        #[error("invalid WATERUI_PREVIEW_HOST")]
340        /// Host env var is present but invalid.
341        InvalidHost,
342        #[error("invalid WATERUI_PREVIEW_PORT_START")]
343        /// Port start env var is present but invalid.
344        InvalidPortStart,
345        #[error("invalid WATERUI_PREVIEW_PORT_RANGE")]
346        /// Port range env var is present but invalid.
347        InvalidPortRange,
348    }
349}
350
351/// Frame size for rendering.
352#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
353pub struct Size {
354    /// Width in points.
355    pub width: f32,
356    /// Height in points.
357    pub height: f32,
358}
359
360impl Size {
361    /// Create a new size.
362    #[must_use]
363    pub const fn new(width: f32, height: f32) -> Self {
364        Self { width, height }
365    }
366}
367
368/// Stable identifier for a preview dylib payload.
369#[derive(Clone, Copy, PartialEq, Eq, Hash)]
370pub struct DylibId([u8; 32]);
371
372impl DylibId {
373    #[must_use]
374    /// Create a dylib id from raw identifier bytes.
375    pub const fn from_bytes(bytes: [u8; 32]) -> Self {
376        Self(bytes)
377    }
378
379    #[must_use]
380    /// Borrow the raw identifier bytes.
381    pub const fn as_bytes(&self) -> &[u8; 32] {
382        &self.0
383    }
384}
385
386impl fmt::Debug for DylibId {
387    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
388        write!(f, "DylibId({self})")
389    }
390}
391
392impl fmt::Display for DylibId {
393    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
394        write!(f, "{}", hex::encode(self.0))
395    }
396}
397
398impl FromStr for DylibId {
399    type Err = &'static str;
400
401    fn from_str(s: &str) -> Result<Self, Self::Err> {
402        let bytes = hex::decode(s).map_err(|_| "invalid hex")?;
403        let bytes: [u8; 32] = bytes.try_into().map_err(|_| "expected 32 bytes")?;
404        Ok(Self(bytes))
405    }
406}
407
408impl Serialize for DylibId {
409    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
410    where
411        S: serde::Serializer,
412    {
413        serializer.serialize_str(&hex::encode(self.0))
414    }
415}
416
417impl<'de> Deserialize<'de> for DylibId {
418    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
419    where
420        D: serde::Deserializer<'de>,
421    {
422        struct Visitor;
423
424        impl DeVisitor<'_> for Visitor {
425            type Value = DylibId;
426
427            fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
428                write!(f, "a 64-char hex string")
429            }
430
431            fn visit_str<E>(self, v: &str) -> Result<Self::Value, E>
432            where
433                E: DeError,
434            {
435                let bytes = hex::decode(v).map_err(|_| E::custom("invalid hex"))?;
436                let bytes: [u8; 32] = bytes
437                    .try_into()
438                    .map_err(|_| E::custom("expected 32 bytes"))?;
439                Ok(DylibId(bytes))
440            }
441        }
442
443        deserializer.deserialize_str(Visitor)
444    }
445}
446
447/// How to provide the dylib used for rendering.
448#[derive(Debug, Clone, Serialize, Deserialize)]
449pub enum DylibSource {
450    /// Inline dylib bytes (used when the app doesn't have `id` yet).
451    ///
452    /// The CLI must use a fresh `id` whenever the payload changes.
453    Bytes {
454        /// Dylib payload identifier.
455        id: DylibId,
456        /// Raw dylib bytes.
457        bytes: Vec<u8>,
458    },
459    /// Reuse a previously loaded dylib by id.
460    Cached {
461        /// Dylib payload identifier.
462        id: DylibId,
463    },
464    /// Absolute local dylib path on the same host as the preview support app.
465    ///
466    /// This avoids retransmitting large dylibs over TCP when the CLI and support app share
467    /// a filesystem, such as macOS local preview.
468    LocalPath {
469        /// Dylib payload identifier.
470        id: DylibId,
471        /// Absolute local dylib path.
472        path: std::path::PathBuf,
473    },
474}
475
476/// Request from CLI to preview support app.
477#[derive(Debug, Clone, Serialize, Deserialize)]
478pub enum PreviewRequest {
479    /// Fast liveness probe.
480    ///
481    /// Used by the CLI to confirm the app is responsive (not just accepting TCP).
482    Ping,
483    /// Ask whether a dylib id is present in the app cache.
484    HasDylib {
485        /// Dylib id to query.
486        id: DylibId,
487    },
488    /// Render a view function.
489    Render {
490        /// Dylib source to use for rendering.
491        dylib: DylibSource,
492        /// Symbol name (e.g. `waterui_preview_my_crate_sidebar`).
493        symbol: String,
494        /// Frame size for rendering.
495        frame: Size,
496    },
497    /// Shutdown the preview app.
498    Shutdown,
499}
500
501/// Successful render output.
502#[derive(Debug, Clone, Serialize, Deserialize)]
503pub struct PreviewOutput {
504    /// PNG image bytes.
505    pub png_data: Vec<u8>,
506    /// Support-app timing breakdown for this render request.
507    pub timings: PreviewRenderTimings,
508}
509
510/// Timing breakdown for loading a preview dylib into the support app process.
511#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)]
512pub struct PreviewDylibLoadTimings {
513    /// Time spent materializing the cached dylib file on disk.
514    pub cache_file_ms: u64,
515    /// Total time spent loading the library from disk.
516    pub load_library_ms: u64,
517    /// Time spent in the initial `dlopen`.
518    pub initial_dlopen_ms: u64,
519    /// Time spent verifying an existing code signature, if the initial `dlopen` failed.
520    pub codesign_verify_ms: Option<u64>,
521    /// Time spent codesigning the dylib, if needed.
522    pub codesign_ms: Option<u64>,
523    /// Time spent reloading the dylib after codesigning, if needed.
524    pub reload_after_codesign_ms: Option<u64>,
525}
526
527/// Timing breakdown for a single preview render request inside the support app.
528#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)]
529pub struct PreviewRenderTimings {
530    /// Time spent ensuring the requested dylib is cached and loaded.
531    pub ensure_dylib_cached_ms: u64,
532    /// Detailed dylib-load timings when this request had to load a new dylib.
533    pub dylib_load: Option<PreviewDylibLoadTimings>,
534    /// Time spent resolving the requested preview symbol into a view.
535    pub load_view_ms: u64,
536    /// Time spent rendering the view to an in-memory image.
537    pub render_ms: u64,
538    /// Time spent encoding the rendered image to PNG.
539    pub png_encode_ms: u64,
540    /// Total request time inside the support app.
541    pub total_ms: u64,
542}
543
544/// Errors that can occur during preview rendering.
545#[derive(Debug, Clone, Serialize, Deserialize, thiserror::Error)]
546pub enum PreviewError {
547    /// Requested dylib id is not loaded.
548    #[error("Unknown dylib id: {0}")]
549    UnknownDylibId(DylibId),
550    /// Failed to load dylib.
551    #[error("Failed to load dylib: {0}")]
552    DylibLoad(String),
553    /// Symbol not found in dylib.
554    #[error("Symbol not found: {0}")]
555    SymbolNotFound(String),
556    /// Rendering failed.
557    #[error("Render failed: {0}")]
558    RenderFailed(String),
559}
560
561/// Response from preview support app.
562#[derive(Debug, Clone, Serialize, Deserialize)]
563pub enum PreviewResponse {
564    /// Response to [`PreviewRequest::Ping`].
565    Pong {
566        /// Protocol metadata for compatibility handshake.
567        protocol: PreviewProtocolInfo,
568    },
569    /// Response to [`PreviewRequest::HasDylib`].
570    HasDylib {
571        /// Whether the dylib id is present.
572        present: bool,
573    },
574    /// Response to [`PreviewRequest::Render`].
575    Render {
576        /// Render result or error.
577        result: Result<PreviewOutput, PreviewError>,
578    },
579    /// Response to [`PreviewRequest::Shutdown`].
580    Shutdown,
581}
582
583#[cfg(test)]
584mod tests {
585    use super::*;
586
587    #[test]
588    fn dylib_id_roundtrip_hex() {
589        let id = DylibId::from_bytes([0xAB; 32]);
590        let json = serde_json::to_string(&id).unwrap();
591        let de: DylibId = serde_json::from_str(&json).unwrap();
592        assert_eq!(id, de);
593    }
594}