1use 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
62fn 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 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}