Skip to main content

Mountain/Binary/Debug/
WebkitServer.rs

1//! Debug server for inspecting webview content via HTTP API.
2//! Only compiled in debug builds.
3//!
4//! # Layered DebugServer (Mountain layer)
5//!
6//! This is the **Mountain** half of the dual-layer DebugServer. It exposes
7//! an HTTP surface bound to `127.0.0.1` for live inspection and command
8//! invocation against the running renderer (workbench webview).
9//!
10//! Activation is gated by the unified `DebugServer` env var:
11//!
12//! | Value                          | Mountain layer | Cocoon layer |
13//! |--------------------------------|----------------|--------------|
14//! | unset, `0`, `false`, `off`     | off            | off          |
15//! | `1`, `true`, `on`              | on             | off (compat) |
16//! | `mountain`, `m`                | on             | off          |
17//! | `cocoon`, `c`, `eh`            | off            | on           |
18//! | `both`, `all`                  | on             | on           |
19//!
20//! Ports: `DebugServerPort` (legacy alias `DebugServerPortMountain`,
21//! default `9933`) - Mountain. Cocoon uses `DebugServerPortCocoon`
22//! (default `9934`).
23//!
24//! ## Endpoints
25//!
26//! | Method | Path             | Purpose                                            |
27//! |--------|------------------|----------------------------------------------------|
28//! | GET    | `/health`        | Layer identity + capability advertisement          |
29//! | GET    | `/layers`        | Discoverability: lists every reachable layer       |
30//! | GET    | `/eval?js=…`     | Eval JS in the renderer; returns parsed JSON       |
31//! | POST   | `/execute`       | Body `{js,target?}`; target=`renderer\|iframe:<id>` |
32//! | GET    | `/iframes`       | Walk DOM iframes (src/id/name)                     |
33//! | GET    | `/console`       | Drain the renderer console mirror buffer          |
34//! | GET    | `/commands`      | Enumerate registered workbench commands           |
35//! | POST   | `/command`       | Body `{id,args?}` - invoke a workbench command    |
36//! | POST   | `/vscode/diff`   | Body `{left,right,title?}` - open diff editor      |
37//! | GET    | `/extensions`    | Proxies Cocoon `/extensions` if reachable          |
38//!
39//! All responses are JSON. Mountain-layer endpoints execute in the renderer
40//! via `WebviewWindow::eval_with_callback`. Cocoon-targeted requests are
41//! HTTP-forwarded to the Cocoon DebugServer when present, transparently.
42
43use std::{
44	collections::HashMap,
45	io::{self, BufRead, BufReader, Read, Write},
46	net::TcpListener,
47	sync::{Arc, Mutex},
48	time::Duration,
49};
50
51use once_cell::sync::Lazy;
52use serde_json::{Value, json};
53use tauri::{WebviewWindow, Wry};
54use url::Url;
55
56/// Global storage for the webview window used by the debug server.
57static WINDOW:Lazy<Mutex<Option<Arc<WebviewWindow<Wry>>>>> = Lazy::new(|| Mutex::new(None));
58
59/// Parsed Mountain-layer activation mode. See module docs for the matrix.
60#[derive(Copy, Clone, Debug)]
61enum LayerMode {
62	Off,
63
64	Mountain,
65
66	Cocoon,
67
68	Both,
69}
70
71fn parse_mode() -> LayerMode {
72	match std::env::var("DebugServer").ok().as_deref().map(str::trim) {
73		None | Some("") | Some("0") | Some("false") | Some("off") | Some("no") => LayerMode::Off,
74
75		Some(v) => {
76			let v = v.to_ascii_lowercase();
77
78			match v.as_str() {
79				"mountain" | "m" | "native" | "rust" => LayerMode::Mountain,
80
81				"cocoon" | "c" | "eh" | "extension-host" | "node" => LayerMode::Cocoon,
82
83				"both" | "all" | "dual" => LayerMode::Both,
84
85				// Legacy compat: 1/true/on enables Mountain-only.
86				"1" | "true" | "on" | "yes" => LayerMode::Mountain,
87
88				_ => LayerMode::Off,
89			}
90		},
91	}
92}
93
94fn mountain_enabled(m:LayerMode) -> bool { matches!(m, LayerMode::Mountain | LayerMode::Both) }
95
96fn cocoon_enabled(m:LayerMode) -> bool { matches!(m, LayerMode::Cocoon | LayerMode::Both) }
97
98fn mountain_port() -> u16 {
99	std::env::var("DebugServerPortMountain")
100		.or_else(|_| std::env::var("DebugServerPort"))
101		.ok()
102		.and_then(|p| p.parse().ok())
103		.unwrap_or(9933)
104}
105
106fn cocoon_port() -> u16 {
107	std::env::var("DebugServerPortCocoon")
108		.ok()
109		.and_then(|p| p.parse().ok())
110		.unwrap_or(9934)
111}
112
113/// Installs the Mountain-layer debug server and stores a reference to the
114/// renderer webview window. Called once during app setup in debug builds.
115///
116/// Activation is gated by the unified `DebugServer` env var (see module
117/// docs). When `cocoon`/`both` is selected, this function still installs
118/// the window handle (so `/eval` keeps working for proxy requests) but
119/// skips starting the Mountain HTTP listener.
120pub fn install(window:&WebviewWindow<Wry>) {
121	// Always store the window: even in cocoon-only mode the eval pipeline
122	// stays useful for tests that imported this module directly.
123	let mut guard = WINDOW.lock().unwrap();
124
125	*guard = Some(Arc::new(window.clone()));
126	drop(guard);
127
128	let mode = parse_mode();
129
130	if mountain_enabled(mode) {
131		std::thread::spawn(|| start_server());
132	}
133
134	if cocoon_enabled(mode) {
135		eprintln!(
136			"[WebkitDebug] Cocoon layer requested (port {}). Cocoon must start its own listener.",
137			cocoon_port()
138		);
139	}
140}
141
142/// Main server loop listening for TCP connections.
143fn start_server() {
144	let port = mountain_port();
145
146	let listener = match TcpListener::bind(("127.0.0.1", port)) {
147		Ok(l) => l,
148
149		Err(e) => {
150			eprintln!("[WebkitDebug] Failed to bind to 127.0.0.1:{}: {}", port, e);
151
152			return;
153		},
154	};
155
156	eprintln!(
157		"[WebkitDebug] Mountain layer listening on http://127.0.0.1:{} (mode={:?})",
158		port,
159		parse_mode()
160	);
161
162	for stream in listener.incoming() {
163		match stream {
164			Ok(mut stream) => {
165				let window_opt = WINDOW.lock().unwrap().clone();
166
167				std::thread::spawn(move || {
168					if let Err(e) = handle_connection(&window_opt, &mut stream) {
169						eprintln!("[WebkitDebug] Connection error: {}", e);
170					}
171				});
172			},
173
174			Err(e) => eprintln!("[WebkitDebug] Accept error: {}", e),
175		}
176	}
177}
178
179/// Handles a single HTTP connection, dispatches based on method and path.
180fn handle_connection(window_opt:&Option<Arc<WebviewWindow<Wry>>>, stream:&mut std::net::TcpStream) -> io::Result<()> {
181	// Early check for window initialization
182	if window_opt.is_none() {
183		send_json(stream, 503, &json!({"error": "debug server not initialized"}))?;
184
185		return Ok(());
186	}
187
188	// Read request data (method, path_and_query, body)
189	let (method, path_and_query, body) = {
190		let mut reader = BufReader::new(&mut *stream);
191
192		let mut request_line = String::new();
193
194		reader.read_line(&mut request_line)?;
195
196		let request_line = request_line.trim_end();
197
198		let parts:Vec<&str> = request_line.split_whitespace().collect();
199
200		if parts.len() != 3 {
201			return Err(io::Error::new(io::ErrorKind::InvalidInput, "bad request line"));
202		}
203
204		let method = parts[0].to_string();
205
206		let path_and_query = parts[1].to_string();
207
208		// Read headers
209		let mut headers = HashMap::new();
210
211		loop {
212			let mut line = String::new();
213
214			let n = reader.read_line(&mut line)?;
215
216			if n == 0 || line == "\r\n" {
217				break;
218			}
219
220			if let Some(idx) = line.find(':') {
221				let name = line[..idx].trim().to_uppercase();
222
223				let value = line[idx + 1..].trim().to_string();
224
225				headers.insert(name, value);
226			}
227		}
228
229		// Read body if Content-Length present
230		let body = if let Some(len_str) = headers.get("CONTENT-LENGTH") {
231			let len:usize = len_str.parse().unwrap_or(0);
232
233			let mut body_bytes = vec![0; len];
234
235			reader.read_exact(&mut body_bytes)?;
236
237			String::from_utf8_lossy(&body_bytes).to_string()
238		} else {
239			String::new()
240		};
241
242		(method, path_and_query, body)
243	};
244
245	// Parse URL to get path and query
246	let full_url = format!("http://localhost{}", path_and_query);
247
248	let parsed = Url::parse(&full_url).map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "invalid URL"))?;
249
250	let path = parsed.path();
251
252	let mut query_pairs = parsed.query_pairs();
253
254	// Dispatch request
255	let (status, response_json) = match (method.as_str(), path) {
256		// ---------- Layer discovery -------------------------------------
257		("GET", "/health") => {
258			(
259				200,
260				json!({
261					"layer": "mountain",
262					"version": env!("CARGO_PKG_VERSION"),
263					"pid": std::process::id(),
264					"mode": format!("{:?}", parse_mode()),
265					"capabilities": [
266						"eval","execute","iframes","console","commands",
267						"command","vscode/diff","extensions(proxy)","layers"
268					],
269				}),
270			)
271		},
272
273		("GET", "/layers") => {
274			(
275				200,
276				json!({
277					"mountain": { "enabled": mountain_enabled(parse_mode()), "port": mountain_port() },
278					"cocoon":   { "enabled": cocoon_enabled(parse_mode()),   "port": cocoon_port()   },
279					"mode": format!("{:?}", parse_mode()),
280				}),
281			)
282		},
283
284		// ---------- Renderer-side primitives ---------------------------
285		("GET", "/console") => {
286			let js = r#"(function() {
287                const logs = window.__MOUNTAIN_DEBUG_CONSOLE || [];
288
289                window.__MOUNTAIN_DEBUG_CONSOLE = [];
290
291                return JSON.stringify(logs);
292            })()"#;
293			match eval_js(window_opt, js) {
294				Ok(value) => (200, json!({"logs": value})),
295
296				Err(e) => (500, json!({"error": e})),
297			}
298		},
299
300		("GET", "/eval") => {
301			let js = query_pairs
302				.find(|(k, _)| k == "js")
303				.map(|(_, v)| v.into_owned())
304				.ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "missing js parameter"))?;
305
306			match eval_js(window_opt, &js) {
307				Ok(value) => (200, json!({"result": value})),
308
309				Err(e) => (500, json!({"error": e})),
310			}
311		},
312
313		("POST", "/execute") => {
314			let parsed_body:Value =
315				serde_json::from_str(&body).map_err(|e| io::Error::new(io::ErrorKind::InvalidInput, e))?;
316
317			let js = parsed_body["js"]
318				.as_str()
319				.ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "missing js field"))?;
320
321			let target = parsed_body["target"].as_str().unwrap_or("renderer");
322
323			match target {
324				"extension-host" | "eh" | "cocoon" => proxy_to_cocoon("POST", "/execute", &body),
325
326				"iframe" => {
327					let iframe_id = parsed_body["iframeId"].as_str().unwrap_or("");
328
329					let wrapped = format!(
330						r#"(function() {{
331                            const ifr = document.querySelector({0});
332                            if (!ifr) return JSON.stringify({{ error: "iframe not found" }});
333                            try {{
334                                return JSON.stringify(ifr.contentWindow.eval({1}));
335                            }} catch (e) {{ return JSON.stringify({{ error: String(e) }}); }}
336                        }})()"#,
337						json!(format!("iframe#{}", iframe_id)),
338						json!(js)
339					);
340
341					match eval_js(window_opt, &wrapped) {
342						Ok(v) => (200, json!({"result": v})),
343
344						Err(e) => (500, json!({"error": e})),
345					}
346				},
347
348				_ => {
349					if js.is_empty() {
350						(400, json!({"error": "empty js"}))
351					} else {
352						match eval_js(window_opt, js) {
353							Ok(val) => (200, json!({"result": val})),
354
355							Err(e) => (500, json!({"error": e})),
356						}
357					}
358				},
359			}
360		},
361
362		("GET", "/iframes") => {
363			let js = r#"(function() {
364                const frames = document.querySelectorAll(iframe);
365
366                const arr = [];
367
368                frames.forEach(f => {
369                    arr.push({
370                        src: f.src, id: f.id, name: f.name,
371                        contentWindow: !!f.contentWindow
372                    });
373                });
374
375                return JSON.stringify(arr);
376            })()"#;
377			match eval_js(window_opt, js) {
378				Ok(value) => (200, json!({"iframes": value})),
379
380				Err(e) => (500, json!({"error": e})),
381			}
382		},
383
384		// ---------- Workbench command surface ---------------------------
385		("GET", "/commands") => {
386			let js = r#"(async function(){
387                try {
388
389                  const r = require(vs/platform/commands/common/commands);
390
391                  const all = r.CommandsRegistry.getCommands();
392
393                  return JSON.stringify(Array.from(all.keys()).slice(0, 5000));
394                } catch (e) { return JSON.stringify({error:String(e)}); }
395            })()"#;
396			match eval_js(window_opt, js) {
397				Ok(v) => (200, json!({"commands": v})),
398
399				Err(e) => (500, json!({"error": e})),
400			}
401		},
402
403		("POST", "/command") => {
404			let parsed_body:Value =
405				serde_json::from_str(&body).map_err(|e| io::Error::new(io::ErrorKind::InvalidInput, e))?;
406
407			let id = parsed_body["id"]
408				.as_str()
409				.ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "missing id"))?;
410
411			let args = parsed_body.get("args").cloned().unwrap_or_else(|| json!([]));
412
413			let js = format!(
414				r#"(async function(){{
415                    try {{
416                      const cs = require(vs/platform/commands/common/commands).CommandsRegistry;
417                      const svcId = require(vs/platform/instantiation/common/instantiation).IInstantiationService;
418                      const ws = (globalThis.MonacoEnvironment || globalThis).__workbench__;
419                      // Resolve through the workbench command service if available.
420                      const cmdSvc = ws?.commandService
421                        || ws?.services?.get?.(require(vs/platform/commands/common/commands).ICommandService);
422                      if (!cmdSvc) return JSON.stringify({{error:"command service unavailable"}});
423                      const args = {0};
424                      const result = await cmdSvc.executeCommand({1}, ...args);
425                      return JSON.stringify({{ ok: true, result: result ?? null }});
426                    }} catch (e) {{ return JSON.stringify({{ ok:false, error: String(e?.stack||e) }}); }}
427                }})()"#,
428				args,
429				json!(id)
430			);
431
432			match eval_js(window_opt, &js) {
433				Ok(v) => (200, v),
434
435				Err(e) => (500, json!({"error": e})),
436			}
437		},
438
439		("POST", "/vscode/diff") => {
440			let parsed_body:Value =
441				serde_json::from_str(&body).map_err(|e| io::Error::new(io::ErrorKind::InvalidInput, e))?;
442
443			let left = parsed_body["left"].as_str().unwrap_or("");
444
445			let right = parsed_body["right"].as_str().unwrap_or("");
446
447			let title = parsed_body["title"].as_str().unwrap_or("Diff");
448
449			if left.is_empty() || right.is_empty() {
450				(400, json!({"error":"left and right required"}))
451			} else {
452				let js = format!(
453					r#"(async function(){{
454                        try {{
455                          const URI = require(vs/base/common/uri).URI;
456                          const cmdSvc = (globalThis).__workbench__?.commandService;
457                          if (!cmdSvc) return JSON.stringify({{error:"command service unavailable"}});
458                          await cmdSvc.executeCommand(vscode.diff, URI.parse({0}), URI.parse({1}), {2});
459                          return JSON.stringify({{ok:true}});
460                        }} catch (e) {{ return JSON.stringify({{ok:false,error:String(e?.stack||e)}}); }}
461                    }})()"#,
462					json!(left),
463					json!(right),
464					json!(title)
465				);
466
467				match eval_js(window_opt, &js) {
468					Ok(v) => (200, v),
469
470					Err(e) => (500, json!({"error": e})),
471				}
472			}
473		},
474
475		// ---------- Cocoon proxy ---------------------------------------
476		("GET", "/extensions") => proxy_to_cocoon("GET", "/extensions", ""),
477
478		_ => (404, json!({"error": "not found", "method": method, "path": path})),
479	};
480
481	send_json(stream, status, &response_json)
482}
483
484/// Evaluates JavaScript in the webview and returns the result as a
485/// serde_json::Value.
486fn eval_js(window_opt:&Option<Arc<WebviewWindow<Wry>>>, js:&str) -> Result<Value, String> {
487	let window = window_opt.as_ref().ok_or("debug server not initialized")?;
488
489	let (tx, rx) = std::sync::mpsc::sync_channel(1);
490
491	window
492		.eval_with_callback(js.to_string(), move |result| {
493			let _ = tx.send(result);
494		})
495		.map_err(|e| e.to_string())?;
496
497	let result_str = rx
498		.recv_timeout(Duration::from_secs(5))
499		.map_err(|_| "timeout waiting for eval result".to_string())?;
500
501	serde_json::from_str(&result_str).map_err(|e| e.to_string())
502}
503
504/// Best-effort forward to the Cocoon DebugServer over loopback.
505/// Returns (status, json). If Cocoon is unreachable, returns 502.
506fn proxy_to_cocoon(method:&str, path:&str, body:&str) -> (u16, Value) {
507	use std::net::TcpStream;
508
509	let port = cocoon_port();
510
511	let addr = format!("127.0.0.1:{}", port);
512
513	let mut stream = match TcpStream::connect_timeout(&addr.parse().unwrap(), Duration::from_millis(300)) {
514		Ok(s) => s,
515
516		Err(e) => {
517			return (
518				502,
519				json!({"error":"cocoon layer unreachable","detail":e.to_string(),"port":port}),
520			);
521		},
522	};
523
524	let _ = stream.set_read_timeout(Some(Duration::from_secs(5)));
525
526	let req = format!(
527		"{} {} HTTP/1.1\r\nHost: 127.0.0.1\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: \
528		 close\r\n\r\n{}",
529		method,
530		path,
531		body.len(),
532		body
533	);
534
535	if stream.write_all(req.as_bytes()).is_err() {
536		return (502, json!({"error":"cocoon write failed"}));
537	}
538
539	let mut buf = String::new();
540
541	if stream.read_to_string(&mut buf).is_err() {
542		return (502, json!({"error":"cocoon read failed"}));
543	}
544
545	// Split headers/body.
546	let body_idx = buf.find("\r\n\r\n").map(|i| i + 4).unwrap_or(buf.len());
547
548	let body_str = &buf[body_idx..];
549
550	let parsed:Value = serde_json::from_str(body_str).unwrap_or_else(|_| json!({"raw": body_str}));
551
552	(200, parsed)
553}
554
555/// Sends a JSON response with the given status code.
556fn send_json(stream:&mut std::net::TcpStream, status:u16, value:&Value) -> io::Result<()> {
557	let body =
558		serde_json::to_string(value).map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "serialization error"))?;
559
560	let status_text = match status {
561		200 => "OK",
562
563		400 => "Bad Request",
564
565		404 => "Not Found",
566
567		500 => "Internal Server Error",
568
569		502 => "Bad Gateway",
570
571		503 => "Service Unavailable",
572
573		_ => "OK",
574	};
575
576	let headers = format!(
577		"HTTP/1.1 {} {}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
578		status,
579		status_text,
580		body.len()
581	);
582
583	stream.write_all(headers.as_bytes())?;
584
585	stream.write_all(body.as_bytes())?;
586
587	stream.flush()?;
588
589	Ok(())
590}