Skip to main content

mj_client/
web.rs

1//! Web-viewer data shared by Mjolnir's daemon and control surfaces.
2
3use std::net::SocketAddr;
4use std::path::PathBuf;
5
6use serde::{Deserialize, Serialize};
7
8/// The conversation shape the phone reads. The chat layer projects its
9/// entries into this; the browser API owns the wire form.
10#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
11pub struct BrowserTranscript {
12    pub latest_seq: u64,
13    /// Opaque Rich-presentation topology key. A browser sends the key it last
14    /// rendered with its next delta request; a mismatch means an append-only
15    /// feed may contain rows the current presentation has removed.
16    pub presentation_key: String,
17    /// Cursor boundary below which a client must replace its feed. Usually
18    /// this is the oldest retained entry, but presentation coalescing may
19    /// advance it so an append-only client drops a marker hidden by a newer
20    /// entry.
21    pub window_start_seq: u64,
22    pub reset: bool,
23    pub entries: Vec<BrowserTranscriptEntry>,
24}
25
26#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
27pub struct BrowserTranscriptEntry {
28    /// Submission identity for replacing a client-local pending row.
29    #[serde(skip_serializing_if = "Option::is_none")]
30    pub command_id: Option<String>,
31    pub id: u64,
32    pub updated_seq: u64,
33    pub role: &'static str,
34    pub label: String,
35    pub recorded_at_ms: Option<i64>,
36    pub lines: Vec<String>,
37    /// The glyph the terminal draws for this role, so both surfaces read alike
38    /// without the browser keeping a second copy of the mapping. Taken from
39    /// the same `entry_visual` the terminal renders from.
40    pub glyph: &'static str,
41    /// The semantic colour name, not a colour. The stylesheet decides what
42    /// `agent` or `failed` looks like; this says which one applies.
43    pub tone: &'static str,
44    /// A tool call's state, for a tool entry. `None` for every other role.
45    #[serde(default, skip_serializing_if = "Option::is_none")]
46    pub tool_status: Option<&'static str>,
47    /// The changed files a tool reported, as data rather than as extra lines
48    /// appended to `lines`. The terminal formats these for a terminal; a
49    /// browser re-parsing that formatting is how the phone came to render
50    /// every diffstat as one unsplit path.
51    #[serde(default, skip_serializing_if = "Vec::is_empty")]
52    pub diffstats: Vec<BrowserDiffStat>,
53}
54
55/// One file a tool changed, and by how much.
56#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
57pub struct BrowserDiffStat {
58    pub path: String,
59    pub insertions: u32,
60    pub deletions: u32,
61}
62
63#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
64pub enum WebViewerAccess {
65    Starting,
66    Ready {
67        viewer_url: String,
68        viewer_code: String,
69        qr_login_url: Option<String>,
70        fallback_reason: Option<String>,
71        /// Lowercase hex SHA-256 of the DER certificate the viewer serves when
72        /// that certificate is one the operator configured (often self-signed).
73        /// Local clients trust exactly this certificate instead of a CA chain.
74        /// `None` over plain HTTP and for publicly trusted Tailscale
75        /// certificates, which renew in place.
76        #[serde(default)]
77        certificate_sha256: Option<String>,
78    },
79    Failed {
80        address: SocketAddr,
81        message: String,
82        port_conflict: bool,
83    },
84    Unavailable(String),
85}
86
87impl std::fmt::Debug for WebViewerAccess {
88    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
89        match self {
90            Self::Starting => formatter.write_str("Starting"),
91            Self::Ready {
92                viewer_url,
93                fallback_reason,
94                ..
95            } => formatter
96                .debug_struct("Ready")
97                .field("viewer_url", viewer_url)
98                .field("credentials", &"[redacted]")
99                .field("fallback_reason", fallback_reason)
100                .finish(),
101            Self::Failed {
102                address,
103                message,
104                port_conflict,
105            } => formatter
106                .debug_struct("Failed")
107                .field("address", address)
108                .field("message", message)
109                .field("port_conflict", port_conflict)
110                .finish(),
111            Self::Unavailable(message) => {
112                formatter.debug_tuple("Unavailable").field(message).finish()
113            }
114        }
115    }
116}
117
118/// Identity shown before an explicit stop request and checked again before signalling.
119#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
120pub struct WebListenerProcess {
121    pub pid: u32,
122    pub name: String,
123    pub executable: PathBuf,
124    pub started_at: u64,
125    pub stop_disabled_reason: Option<String>,
126}
127
128#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
129pub enum WebViewerRecovery {
130    Retry,
131    AnotherPort,
132    StopAndRetry(WebListenerProcess),
133}