Skip to main content

lingxia_devtool/
lib.rs

1//! Devtool runtime bridge and protocol helpers for LingXia apps.
2//!
3//! Host libraries decide how this service is installed into their own
4//! `HostAddon`; this crate only exposes the service entry points.
5
6use lingxia_log::{AttachedLogStream, LogLevel, LogMessage, LogTag, attach_log_stream_default};
7use std::sync::OnceLock;
8use std::thread;
9use std::time::Duration;
10use tungstenite::protocol::Message;
11use tungstenite::stream::MaybeTlsStream;
12use tungstenite::{Error as WsError, WebSocket};
13
14mod app;
15mod browser;
16mod lxapp;
17mod lxapp_device;
18mod lxapp_nav;
19mod lxapp_page;
20#[cfg(feature = "test-runtime")]
21mod session_test;
22mod util;
23
24pub use lingxia_devtool_protocol::{
25    DevtoolsLogLevel, DevtoolsLogMessage, DevtoolsLogSource, DevtoolsPeerRole, DevtoolsWireMessage,
26    handlers,
27};
28
29const DEV_WS_URL_ENV: &str = "LINGXIA_DEV_WS_URL";
30
31pub fn start_devtool_bridge_from_env() {
32    static STARTED: OnceLock<()> = OnceLock::new();
33    if STARTED.set(()).is_err() {
34        return;
35    }
36
37    let ws_url = match dev_ws_url() {
38        Some(value) => value,
39        None => {
40            log::info!("Devtool bridge disabled because no dev websocket URL is configured");
41            return;
42        }
43    };
44
45    thread::spawn(move || run_dev_bridge(ws_url));
46}
47
48fn dev_ws_url() -> Option<String> {
49    std::env::var(DEV_WS_URL_ENV)
50        .ok()
51        .map(|value| value.trim().to_string())
52        .filter(|value| !value.is_empty())
53        .or_else(|| {
54            lingxia_app_context::app_config()
55                .and_then(|config| config.dev_ws_url.as_deref())
56                .map(str::trim)
57                .filter(|value| !value.is_empty())
58                .map(ToOwned::to_owned)
59        })
60}
61
62/// Connect with explicit TCP + handshake timeouts. A plain `connect` blocks
63/// forever when a stale port-forward listener accepts the socket but the far
64/// side is gone — the bridge then never retries.
65fn connect_with_timeout(
66    ws_url: &str,
67) -> Result<WebSocket<MaybeTlsStream<std::net::TcpStream>>, WsError> {
68    let authority = ws_url
69        .trim_start_matches("ws://")
70        .split(['/', '?'])
71        .next()
72        .unwrap_or_default();
73    let addr = authority.parse::<std::net::SocketAddr>().map_err(|err| {
74        WsError::Url(tungstenite::error::UrlError::UnableToConnect(
75            err.to_string(),
76        ))
77    })?;
78    let stream =
79        std::net::TcpStream::connect_timeout(&addr, Duration::from_secs(5)).map_err(WsError::Io)?;
80    stream
81        .set_read_timeout(Some(Duration::from_secs(5)))
82        .map_err(WsError::Io)?;
83    stream
84        .set_write_timeout(Some(Duration::from_secs(5)))
85        .map_err(WsError::Io)?;
86    let (websocket, _) =
87        tungstenite::client(ws_url, MaybeTlsStream::Plain(stream)).map_err(|err| match err {
88            tungstenite::HandshakeError::Failure(err) => err,
89            // A read timeout during the handshake surfaces as Interrupted —
90            // exactly the stale-tunnel case this timeout exists to catch.
91            tungstenite::HandshakeError::Interrupted(_) => WsError::Io(std::io::Error::new(
92                std::io::ErrorKind::TimedOut,
93                "websocket handshake timed out",
94            )),
95        })?;
96    Ok(websocket)
97}
98
99fn run_dev_bridge(ws_url: String) {
100    let mut connect_failures = 0u32;
101    loop {
102        match connect_with_timeout(ws_url.as_str()) {
103            Ok(mut websocket) => {
104                if connect_failures > 0 {
105                    log::info!(
106                        "Connected devtool websocket after {} failed attempts",
107                        connect_failures
108                    );
109                }
110                connect_failures = 0;
111                if let Err(err) = send_wire_message(
112                    &mut websocket,
113                    &DevtoolsWireMessage::Hello {
114                        role: DevtoolsPeerRole::Devtool,
115                        token: lingxia_devtool_protocol::token_from_ws_url(&ws_url),
116                    },
117                ) {
118                    log::warn!("Failed to send devtool hello: {}", err);
119                    thread::sleep(Duration::from_millis(500));
120                    continue;
121                }
122
123                configure_read_timeout(&mut websocket);
124
125                let attached = match attach_log_stream_default() {
126                    Ok(attached) => attached,
127                    Err(err) => {
128                        log::warn!("Failed to attach devtool log stream: {}", err);
129                        thread::sleep(Duration::from_millis(500));
130                        continue;
131                    }
132                };
133
134                if let Err(err) = bridge_loop(&mut websocket, attached) {
135                    log::warn!("Devtool bridge disconnected: {}", err);
136                }
137            }
138            Err(err) => {
139                connect_failures = connect_failures.saturating_add(1);
140                log_connect_failure(connect_failures, &err);
141            }
142        }
143
144        thread::sleep(reconnect_delay(connect_failures));
145    }
146}
147
148fn reconnect_delay(connect_failures: u32) -> Duration {
149    match connect_failures {
150        0 => Duration::from_millis(500),
151        1 => Duration::from_secs(1),
152        2 => Duration::from_secs(2),
153        _ => Duration::from_secs(5),
154    }
155}
156
157fn log_connect_failure(attempt: u32, err: &WsError) {
158    if attempt == 1 {
159        log::warn!(
160            "Failed to connect devtool websocket; retrying in background: {}",
161            err
162        );
163    } else if attempt.is_multiple_of(12) {
164        log::warn!(
165            "Still unable to connect devtool websocket after {} attempts: {}",
166            attempt,
167            err
168        );
169    } else {
170        log::debug!(
171            "Failed to connect devtool websocket attempt {}: {}",
172            attempt,
173            err
174        );
175    }
176}
177
178fn bridge_loop(
179    websocket: &mut WebSocket<MaybeTlsStream<std::net::TcpStream>>,
180    attached: AttachedLogStream,
181) -> Result<(), String> {
182    let (recent, mut receiver) = attached.into_parts();
183    for chunk in recent.chunks(128) {
184        send_log_batch(websocket, chunk)?;
185    }
186
187    loop {
188        let mut batch = Vec::new();
189        while batch.len() < 64 {
190            match receiver.try_recv() {
191                Ok(message) => batch.push(message),
192                Err(tokio::sync::broadcast::error::TryRecvError::Empty) => break,
193                Err(tokio::sync::broadcast::error::TryRecvError::Lagged(skipped)) => {
194                    log::warn!("Devtool log stream lagged and skipped {} messages", skipped);
195                    break;
196                }
197                Err(tokio::sync::broadcast::error::TryRecvError::Closed) => {
198                    return Err("log stream closed".to_string());
199                }
200            }
201        }
202
203        if !batch.is_empty() {
204            send_log_batch(websocket, &batch)?;
205        }
206
207        match websocket.read() {
208            Ok(message) => {
209                if let Some(wire) = parse_wire_message(message)? {
210                    handle_incoming_message(websocket, wire)?;
211                }
212            }
213            Err(WsError::Io(err))
214                if err.kind() == std::io::ErrorKind::WouldBlock
215                    || err.kind() == std::io::ErrorKind::TimedOut => {}
216            Err(WsError::ConnectionClosed) | Err(WsError::AlreadyClosed) => {
217                return Err("websocket closed".to_string());
218            }
219            Err(err) => return Err(err.to_string()),
220        }
221
222        thread::sleep(Duration::from_millis(50));
223    }
224}
225
226fn handle_incoming_message(
227    websocket: &mut WebSocket<MaybeTlsStream<std::net::TcpStream>>,
228    message: DevtoolsWireMessage,
229) -> Result<(), String> {
230    let DevtoolsWireMessage::Command {
231        command_id,
232        handler,
233        args,
234    } = message
235    else {
236        return Ok(());
237    };
238
239    #[cfg(feature = "test-runtime")]
240    if let Some(result) = session_test::handle_session_test_command(&handler, args.clone()) {
241        return send_wire_message(websocket, &command_result(command_id, result));
242    }
243
244    let result = if let Some(result) = app::handle_app_command(&handler, args.clone()) {
245        command_result(command_id, result)
246    } else if let Some(result) = browser::handle_browser_command(&handler, args.clone()) {
247        command_result(command_id, result)
248    } else if let Some(result) = lxapp_nav::handle_lxapp_nav_command(&handler, args.clone()) {
249        command_result(command_id, result)
250    } else if let Some(result) = lxapp_page::handle_lxapp_page_command(&handler, args.clone()) {
251        command_result(command_id, result)
252    } else if let Some(result) = lxapp_device::handle_lxapp_device_command(&handler, args.clone()) {
253        command_result(command_id, result)
254    } else if let Some(result) = lxapp::handle_lxapp_command(&handler, args.clone()) {
255        command_result(command_id, result)
256    } else {
257        match handler.as_str() {
258            handlers::ECHO => DevtoolsWireMessage::Result {
259                command_id,
260                ok: true,
261                data: args,
262                error: None,
263            },
264            other => DevtoolsWireMessage::Result {
265                command_id,
266                ok: false,
267                data: None,
268                error: Some(format!("unknown handler: {}", other)),
269            },
270        }
271    };
272    send_wire_message(websocket, &result)
273}
274
275fn command_result(
276    command_id: String,
277    result: Result<Option<serde_json::Value>, String>,
278) -> DevtoolsWireMessage {
279    match result {
280        Ok(data) => DevtoolsWireMessage::Result {
281            command_id,
282            ok: true,
283            data,
284            error: None,
285        },
286        Err(error) => DevtoolsWireMessage::Result {
287            command_id,
288            ok: false,
289            data: None,
290            error: Some(error),
291        },
292    }
293}
294
295fn send_log_batch(
296    websocket: &mut WebSocket<MaybeTlsStream<std::net::TcpStream>>,
297    logs: &[LogMessage],
298) -> Result<(), String> {
299    send_wire_message(
300        websocket,
301        &DevtoolsWireMessage::LogBatch {
302            logs: logs.iter().map(devtools_log_message).collect(),
303        },
304    )
305}
306
307fn devtools_log_message(value: &LogMessage) -> DevtoolsLogMessage {
308    DevtoolsLogMessage {
309        timestamp_ms: value.timestamp_ms,
310        source: devtools_log_source(value.tag),
311        level: devtools_log_level(value.level),
312        appid: value.appid.clone(),
313        path: value.path.clone(),
314        message: value.message.clone(),
315    }
316}
317
318fn devtools_log_level(value: LogLevel) -> DevtoolsLogLevel {
319    match value {
320        LogLevel::Verbose => DevtoolsLogLevel::Verbose,
321        LogLevel::Debug => DevtoolsLogLevel::Debug,
322        LogLevel::Info => DevtoolsLogLevel::Info,
323        LogLevel::Warn => DevtoolsLogLevel::Warn,
324        LogLevel::Error => DevtoolsLogLevel::Error,
325    }
326}
327
328fn devtools_log_source(value: LogTag) -> DevtoolsLogSource {
329    match value {
330        LogTag::Native => DevtoolsLogSource::Native,
331        LogTag::WebViewConsole => DevtoolsLogSource::WebViewConsole,
332        LogTag::LxAppServiceConsole => DevtoolsLogSource::LxAppServiceConsole,
333        LogTag::BrowserConsole => DevtoolsLogSource::BrowserConsole,
334        LogTag::Automation => DevtoolsLogSource::Automation,
335    }
336}
337
338fn send_wire_message(
339    websocket: &mut WebSocket<MaybeTlsStream<std::net::TcpStream>>,
340    message: &DevtoolsWireMessage,
341) -> Result<(), String> {
342    let text = serde_json::to_string(message).map_err(|err| err.to_string())?;
343    websocket
344        .send(Message::Text(text.into()))
345        .map_err(|err| err.to_string())
346}
347
348fn parse_wire_message(message: Message) -> Result<Option<DevtoolsWireMessage>, String> {
349    match message {
350        Message::Text(text) => serde_json::from_str(&text)
351            .map(Some)
352            .map_err(|err| err.to_string()),
353        Message::Ping(_) | Message::Pong(_) | Message::Close(_) | Message::Frame(_) => Ok(None),
354        Message::Binary(_) => Err("binary websocket messages are not supported".to_string()),
355    }
356}
357
358fn configure_read_timeout(websocket: &mut WebSocket<MaybeTlsStream<std::net::TcpStream>>) {
359    if let MaybeTlsStream::Plain(stream) = websocket.get_mut() {
360        let _ = stream.set_read_timeout(Some(Duration::from_millis(100)));
361    }
362}