Skip to main content

lingxia_devtool_protocol/
lib.rs

1#[cfg(feature = "broker")]
2pub mod broker;
3pub mod session_test;
4
5use serde::{Deserialize, Serialize};
6
7/// Extract the session auth token from a dev websocket URL's `?token=` query
8/// parameter. The tokened URL is the single credential artifact: the server
9/// prints it, and every peer parses the token back out to present in `Hello`.
10pub fn token_from_ws_url(ws_url: &str) -> Option<String> {
11    let (_, query) = ws_url.split_once('?')?;
12    query.split('&').find_map(|pair| {
13        let (key, value) = pair.split_once('=')?;
14        (key == "token" && !value.is_empty()).then(|| value.to_string())
15    })
16}
17
18/// Append a `?token=` query to a dev websocket URL. Authority-only URLs
19/// (`ws://host:port`) get an explicit `/` first so naive authority parsers
20/// (`split('/')`) never see the query glued to the port.
21pub fn ws_url_with_token(ws_url: &str, token: &str) -> String {
22    if ws_url.contains('?') {
23        return format!("{ws_url}&token={token}");
24    }
25    let has_path = ws_url
26        .split_once("://")
27        .is_some_and(|(_, rest)| rest.contains('/'));
28    let separator = if has_path { "?" } else { "/?" };
29    format!("{ws_url}{separator}token={token}")
30}
31
32pub mod handlers {
33    pub const ECHO: &str = "echo";
34
35    pub mod session {
36        /// Request the owning `lingxia dev` process to stop this session.
37        /// Handled by the dev server, not forwarded to the runtime.
38        pub const SHUTDOWN: &str = "session.shutdown";
39
40        /// Session test runner (`lxdev test`). Runtime-owned: the dev server
41        /// forwards these to the host, which executes the bundled test in an
42        /// isolated `lingxia-automation` runtime.
43        pub mod test {
44            pub const START: &str = "session.test.start";
45            pub const POLL: &str = "session.test.poll";
46            pub const CANCEL: &str = "session.test.cancel";
47        }
48    }
49
50    pub mod browser {
51        pub const OPEN: &str = "browser.open";
52        pub const TABS: &str = "browser.tabs";
53        pub const CURRENT: &str = "browser.current";
54        pub const ACTIVATE: &str = "browser.activate";
55        pub const CLOSE: &str = "browser.close";
56        pub const RELOAD: &str = "browser.reload";
57        pub const BACK: &str = "browser.back";
58        pub const FORWARD: &str = "browser.forward";
59        pub const EVAL: &str = "browser.eval";
60        pub const QUERY: &str = "browser.query";
61        pub const WAIT: &str = "browser.wait";
62        pub const WAIT_URL: &str = "browser.wait_url";
63        pub const WAIT_NAVIGATION: &str = "browser.wait_navigation";
64        pub const CLICK: &str = "browser.click";
65        pub const TYPE: &str = "browser.type";
66        pub const FILL: &str = "browser.fill";
67        pub const PRESS: &str = "browser.press";
68        pub const SCROLL: &str = "browser.scroll";
69        pub const SCROLL_TO: &str = "browser.scroll_to";
70        pub const COOKIES_LIST: &str = "browser.cookies.list";
71        pub const COOKIES_SET: &str = "browser.cookies.set";
72        pub const COOKIES_DELETE: &str = "browser.cookies.delete";
73        pub const COOKIES_CLEAR: &str = "browser.cookies.clear";
74        pub const SCREENSHOT: &str = "browser.screenshot";
75        // Network capture (Windows/WebView2 CDP only).
76        pub const NETWORK_ENABLE: &str = "browser.network.enable";
77        pub const NETWORK_DISABLE: &str = "browser.network.disable";
78        pub const NETWORK_LIST: &str = "browser.network.list";
79        pub const NETWORK_CLEAR: &str = "browser.network.clear";
80    }
81
82    pub mod lxapp {
83        pub const LIST: &str = "lxapp.list";
84        pub const CURRENT: &str = "lxapp.current";
85        /// Report page screenshot/input support and the runtime tier.
86        pub const DOCTOR: &str = "lxapp.doctor";
87        pub const INFO: &str = "lxapp.info";
88        pub const PAGES: &str = "lxapp.pages";
89        pub const EVAL: &str = "lxapp.eval";
90        pub const OPEN: &str = "lxapp.open";
91        pub const CLOSE: &str = "lxapp.close";
92        pub const RESTART: &str = "lxapp.restart";
93        pub const UNINSTALL: &str = "lxapp.uninstall";
94        /// Rebuild the lxapp front-end bundle. Handled by the `lingxia dev`
95        /// orchestrator (which owns the project + build pipeline), not the
96        /// runtime — so it works even with no app attached.
97        pub const BUILD: &str = "lxapp.build";
98    }
99
100    pub mod lxapp_nav {
101        pub const TO: &str = "lxapp.nav.to";
102        pub const REDIRECT: &str = "lxapp.nav.redirect";
103        pub const SWITCH_TAB: &str = "lxapp.nav.switch_tab";
104        pub const RELAUNCH: &str = "lxapp.nav.relaunch";
105        pub const BACK: &str = "lxapp.nav.back";
106    }
107
108    pub mod app {
109        /// Report host-window automation capabilities and coordinate units.
110        pub const DOCTOR: &str = "app.doctor";
111
112        /// Capture a PNG of the host app's window. Accepts an optional
113        /// `window_id` (returned by [`WINDOWS`]) so multi-window desktop
114        /// apps can pick a specific surface; mobile platforms ignore it
115        /// since they have a single foreground window. Returns the unified
116        /// screenshot envelope `{target, kind, coordinate_space, format,
117        /// width, height, size_bytes, image:{mime, encoding, data}}`.
118        pub const SCREENSHOT: &str = "app.screenshot";
119
120        /// Enumerate the host app's top-level windows. Returns a JSON
121        /// array of `{id, title, focused, main, visible, width, height}`.
122        pub const WINDOWS: &str = "app.windows";
123
124        /// Dispatch mouse input to a host app window. Accepts
125        /// `{window_id?, action}` where action is a tagged object such as
126        /// `{kind:"click", x, y, button?}`.
127        pub const MOUSE: &str = "app.mouse";
128
129        /// Dispatch keyboard input to a host app window's focused control.
130        /// Accepts `{window_id?, action}` where action is a tagged object
131        /// such as `{kind:"type", text}` or `{kind:"press", key, modifiers?}`.
132        pub const KEYBOARD: &str = "app.keyboard";
133    }
134
135    pub mod lxapp_device {
136        /// List the device presets the runner offers (id, name, group, size).
137        pub const LIST: &str = "lxapp.device.list";
138        /// Report the currently selected device and orientation.
139        pub const GET: &str = "lxapp.device.get";
140        /// Switch the simulated device (by preset id) and/or orientation.
141        pub const SET: &str = "lxapp.device.set";
142    }
143
144    pub mod lxapp_page {
145        pub const CURRENT: &str = "lxapp.page.current";
146        pub const LIST: &str = "lxapp.page.list";
147        pub const INFO: &str = "lxapp.page.info";
148        pub const WAIT: &str = "lxapp.page.wait";
149        pub const EVAL: &str = "lxapp.page.eval";
150        pub const QUERY: &str = "lxapp.page.query";
151        pub const CLICK: &str = "lxapp.page.click";
152        pub const TYPE: &str = "lxapp.page.type";
153        pub const FILL: &str = "lxapp.page.fill";
154        pub const PRESS: &str = "lxapp.page.press";
155        pub const SCROLL: &str = "lxapp.page.scroll";
156        pub const SCROLL_TO: &str = "lxapp.page.scroll_to";
157        pub const BACK: &str = "lxapp.page.back";
158        pub const SCREENSHOT: &str = "lxapp.page.screenshot";
159    }
160}
161
162#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
163#[serde(rename_all = "snake_case")]
164pub enum DevtoolsPeerRole {
165    Devtool,
166    Client,
167}
168
169#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
170#[serde(rename_all = "snake_case")]
171pub enum DevtoolsLogLevel {
172    Verbose,
173    Debug,
174    Info,
175    Warn,
176    Error,
177}
178
179#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
180#[serde(rename_all = "snake_case")]
181pub enum DevtoolsLogSource {
182    Native,
183    WebViewConsole,
184    LxAppServiceConsole,
185    BrowserConsole,
186    /// Isolated host automation output mirrored into the session log (`path`
187    /// carries the run id). `lxdev test` live output flows through poll.
188    Automation,
189}
190
191#[derive(Debug, Clone, Serialize, Deserialize)]
192pub struct DevtoolsLogMessage {
193    pub timestamp_ms: u64,
194    #[serde(alias = "tag")]
195    pub source: DevtoolsLogSource,
196    pub level: DevtoolsLogLevel,
197    pub appid: Option<String>,
198    pub path: Option<String>,
199    pub message: String,
200}
201
202#[derive(Debug, Clone, Serialize, Deserialize)]
203#[serde(tag = "type", rename_all = "snake_case")]
204pub enum DevtoolsWireMessage {
205    Hello {
206        role: DevtoolsPeerRole,
207        /// Session auth token echoed from the `?token=` query of the websocket
208        /// URL. The server also authenticates that upgrade-query token directly
209        /// so cached peers from before this field remain compatible.
210        #[serde(default, skip_serializing_if = "Option::is_none")]
211        token: Option<String>,
212    },
213    LogBatch {
214        logs: Vec<DevtoolsLogMessage>,
215    },
216    Command {
217        command_id: String,
218        handler: String,
219        #[serde(default, skip_serializing_if = "Option::is_none")]
220        args: Option<serde_json::Value>,
221    },
222    Result {
223        command_id: String,
224        ok: bool,
225        #[serde(default, skip_serializing_if = "Option::is_none")]
226        data: Option<serde_json::Value>,
227        #[serde(default, skip_serializing_if = "Option::is_none")]
228        error: Option<String>,
229    },
230}
231
232#[cfg(test)]
233mod tests {
234    use super::*;
235
236    #[test]
237    fn token_appends_with_path_separator_on_authority_only_urls() {
238        let url = ws_url_with_token("ws://192.168.1.20:39142", "abc");
239        assert_eq!(url, "ws://192.168.1.20:39142/?token=abc");
240        assert_eq!(token_from_ws_url(&url).as_deref(), Some("abc"));
241    }
242
243    #[test]
244    fn token_round_trips_with_existing_path_and_query() {
245        assert_eq!(
246            ws_url_with_token("ws://h:1/x", "t"),
247            "ws://h:1/x?token=t".to_string()
248        );
249        assert_eq!(
250            ws_url_with_token("ws://h:1/x?a=b", "t"),
251            "ws://h:1/x?a=b&token=t".to_string()
252        );
253        assert_eq!(
254            token_from_ws_url("ws://h:1/x?a=b&token=t").as_deref(),
255            Some("t")
256        );
257        assert_eq!(token_from_ws_url("ws://h:1/x?a=b"), None);
258        assert_eq!(token_from_ws_url("ws://h:1"), None);
259    }
260
261    #[test]
262    fn hello_without_token_deserializes_as_none() {
263        let hello: DevtoolsWireMessage =
264            serde_json::from_str(r#"{"type":"hello","role":"client"}"#).unwrap();
265        let DevtoolsWireMessage::Hello { role, token } = hello else {
266            panic!("expected hello");
267        };
268        assert_eq!(role, DevtoolsPeerRole::Client);
269        assert_eq!(token, None);
270    }
271}