1use std::{
2 fs, io,
3 net::{TcpListener, TcpStream},
4 path::PathBuf,
5 process::{Child, Command, Stdio},
6 sync::{
7 Arc, Mutex,
8 atomic::{AtomicBool, Ordering},
9 mpsc,
10 },
11 thread,
12 time::{Duration, Instant},
13};
14
15use qrcodegen::{QrCode, QrCodeEcc};
16use rand::{Rng, distr::Alphanumeric};
17use serde::{Deserialize, Serialize};
18use tiny_http::{Header, Method, Response, Server, StatusCode};
19
20use crate::codex::io_other;
21
22const REMOTE_POLL_MS: u64 = 750;
23const TUNNEL_START_TIMEOUT: Duration = Duration::from_secs(15);
24
25#[derive(Clone, Debug, Eq, PartialEq)]
26pub enum RemoteAction {
27 Enter,
28 ApproveYes,
29 RejectNo,
30 Interrupt,
31}
32
33#[derive(Clone, Debug, Serialize)]
34struct RemoteSnapshot {
35 mode_title: String,
36 status: String,
37 lines: Vec<String>,
38}
39
40impl Default for RemoteSnapshot {
41 fn default() -> Self {
42 Self {
43 mode_title: "Shell".to_string(),
44 status: "Remote stream waiting for a live session.".to_string(),
45 lines: vec!["Waiting for session output...".to_string()],
46 }
47 }
48}
49
50struct SharedState {
51 snapshot: RemoteSnapshot,
52 actions: Vec<RemoteAction>,
53}
54
55pub struct RemoteShare {
56 shared: Arc<Mutex<SharedState>>,
57 running: Arc<AtomicBool>,
58 tunnel: Child,
59 url: String,
60 qr_lines: Vec<String>,
61 qr_svg_path: PathBuf,
62}
63
64impl RemoteShare {
65 pub fn start() -> io::Result<Self> {
66 let token = random_token(24);
67 let qr_token = token.clone();
68
69 let listener = TcpListener::bind(("0.0.0.0", 0))?;
70 let port = listener.local_addr()?.port();
71 let server = Server::from_listener(listener, None).map_err(io_other)?;
72 let shared = Arc::new(Mutex::new(SharedState {
73 snapshot: RemoteSnapshot::default(),
74 actions: Vec::new(),
75 }));
76 let running = Arc::new(AtomicBool::new(true));
77
78 spawn_server_thread(server, Arc::clone(&shared), Arc::clone(&running), token);
79 wait_for_local_server(port, TUNNEL_START_TIMEOUT)?;
80
81 let (tunnel, url) = start_tunnel(port)?;
82 let qr_lines = render_qr_lines(&url)?;
83 let qr_svg_path = write_qr_svg(&url, &qr_token)?;
84
85 Ok(Self {
86 shared,
87 running,
88 tunnel,
89 url,
90 qr_lines,
91 qr_svg_path,
92 })
93 }
94
95 pub fn url(&self) -> &str {
96 &self.url
97 }
98
99 pub fn qr_lines(&self) -> &[String] {
100 &self.qr_lines
101 }
102
103 pub fn qr_svg_path(&self) -> &PathBuf {
104 &self.qr_svg_path
105 }
106 pub fn update_snapshot(
107 &self,
108 mode_title: impl Into<String>,
109 status: impl Into<String>,
110 lines: Vec<String>,
111 ) {
112 if let Ok(mut shared) = self.shared.lock() {
113 shared.snapshot = RemoteSnapshot {
114 mode_title: mode_title.into(),
115 status: status.into(),
116 lines,
117 };
118 }
119 }
120
121 pub fn drain_actions(&self) -> Vec<RemoteAction> {
122 if let Ok(mut shared) = self.shared.lock() {
123 return shared.actions.drain(..).collect();
124 }
125 Vec::new()
126 }
127}
128
129impl Drop for RemoteShare {
130 fn drop(&mut self) {
131 self.running.store(false, Ordering::SeqCst);
132 let _ = self.tunnel.kill();
133 let _ = self.tunnel.wait();
134 }
135}
136
137fn spawn_server_thread(
138 server: Server,
139 shared: Arc<Mutex<SharedState>>,
140 running: Arc<AtomicBool>,
141 token: String,
142) {
143 thread::spawn(move || {
144 while running.load(Ordering::SeqCst) {
145 let Ok(Some(request)) = server.recv_timeout(Duration::from_millis(200)) else {
146 continue;
147 };
148
149 let method = request.method().clone();
150 let url = request.url().to_string();
151
152 let response =
153 if method == Method::Get && (url == "/" || url == format!("/pair/{token}")) {
154 html_response(render_mobile_page(&token))
155 } else if method == Method::Get && url == format!("/snapshot/{token}") {
156 let body = if let Ok(shared) = shared.lock() {
157 serde_json::to_string(&shared.snapshot)
158 .unwrap_or_else(|_| "{\"status\":\"snapshot unavailable\"}".to_string())
159 } else {
160 "{\"status\":\"snapshot unavailable\"}".to_string()
161 };
162 json_response(body)
163 } else if method == Method::Post && url.starts_with(&format!("/action/{token}/")) {
164 let action_name = url
165 .trim_start_matches(&format!("/action/{token}/"))
166 .trim_matches('/');
167 if let Some(action) = parse_action(action_name) {
168 if let Ok(mut shared) = shared.lock() {
169 shared.actions.push(action);
170 }
171 text_response("ok")
172 } else {
173 status_response(StatusCode(404), "unknown action")
174 }
175 } else {
176 status_response(StatusCode(404), "not found")
177 };
178
179 let _ = request.respond(response);
180 }
181 });
182}
183
184fn parse_action(value: &str) -> Option<RemoteAction> {
185 match value {
186 "enter" => Some(RemoteAction::Enter),
187 "yes" => Some(RemoteAction::ApproveYes),
188 "no" => Some(RemoteAction::RejectNo),
189 "interrupt" => Some(RemoteAction::Interrupt),
190 _ => None,
191 }
192}
193
194fn start_tunnel(port: u16) -> io::Result<(Child, String)> {
195 let mut tunnel = spawn_ngrok_tunnel(port)?;
196 match wait_for_ngrok_url(&mut tunnel, port, TUNNEL_START_TIMEOUT) {
197 Ok(url) => Ok((tunnel, url)),
198 Err(err) => {
199 let _ = tunnel.kill();
200 let _ = tunnel.wait();
201 Err(err)
202 }
203 }
204}
205
206fn wait_for_local_server(port: u16, timeout: Duration) -> io::Result<()> {
207 use std::io::{Read, Write};
208
209 let deadline = Instant::now() + timeout;
210 while Instant::now() < deadline {
211 match TcpStream::connect(("127.0.0.1", port)) {
212 Ok(mut stream) => {
213 stream.write_all(
214 concat!(
215 "GET / HTTP/1.1\r\n",
216 "Host: 127.0.0.1\r\n",
217 "Connection: close\r\n",
218 "\r\n"
219 )
220 .as_bytes(),
221 )?;
222 stream.flush()?;
223
224 let mut response = String::new();
225 stream.read_to_string(&mut response)?;
226 if response.starts_with("HTTP/1.1 200") || response.starts_with("HTTP/1.0 200") {
227 return Ok(());
228 }
229 }
230 Err(_) => {
231 thread::sleep(Duration::from_millis(100));
232 }
233 }
234 }
235
236 Err(io::Error::other(
237 "local remote-share server did not start listening in time",
238 ))
239}
240
241fn spawn_ngrok_tunnel(port: u16) -> io::Result<Child> {
242 Command::new("ngrok")
243 .args([
244 "http",
245 &format!("127.0.0.1:{port}"),
246 "--log",
247 "stdout",
248 "--log-format",
249 "logfmt",
250 ])
251 .stdout(Stdio::piped())
252 .stderr(Stdio::piped())
253 .spawn()
254}
255
256fn wait_for_ngrok_url(child: &mut Child, port: u16, timeout: Duration) -> io::Result<String> {
257 let (receiver, _threads) = spawn_tunnel_log_threads(child);
258 let deadline = Instant::now() + timeout;
259 let mut recent_logs = Vec::new();
260
261 while Instant::now() < deadline {
262 if let Ok(url) = fetch_ngrok_public_url(port) {
263 return Ok(url);
264 }
265
266 if let Some(status) = child.try_wait()? {
267 return Err(io::Error::other(format!(
268 "ngrok exited before publishing a URL (status {status}); {}",
269 summarize_recent_logs(&recent_logs)
270 )));
271 }
272
273 while let Ok(line) = receiver.try_recv() {
274 push_recent_log_line(&mut recent_logs, line);
275 }
276
277 thread::sleep(Duration::from_millis(250));
278 }
279
280 while let Ok(line) = receiver.try_recv() {
281 push_recent_log_line(&mut recent_logs, line);
282 }
283
284 Err(io::Error::other(format!(
285 "ngrok did not report a public URL; make sure ngrok is installed, authenticated, and able to start a tunnel ({})",
286 summarize_recent_logs(&recent_logs)
287 )))
288}
289
290fn spawn_tunnel_log_threads(
291 child: &mut Child,
292) -> (mpsc::Receiver<String>, Vec<thread::JoinHandle<()>>) {
293 let (sender, receiver) = mpsc::channel();
294 let mut threads = Vec::new();
295
296 if let Some(stdout) = child.stdout.take() {
297 let sender = sender.clone();
298 threads.push(thread::spawn(move || read_tunnel_stream(stdout, sender)));
299 }
300
301 if let Some(stderr) = child.stderr.take() {
302 let sender = sender.clone();
303 threads.push(thread::spawn(move || read_tunnel_stream(stderr, sender)));
304 }
305
306 drop(sender);
307 (receiver, threads)
308}
309
310fn read_tunnel_stream<T: io::Read + Send + 'static>(mut stream: T, sender: mpsc::Sender<String>) {
311 let mut buf = [0u8; 2048];
312 let mut pending = String::new();
313
314 loop {
315 match stream.read(&mut buf) {
316 Ok(0) => break,
317 Ok(n) => {
318 pending.push_str(&String::from_utf8_lossy(&buf[..n]));
319 while let Some(idx) = pending.find('\n') {
320 let line = pending.drain(..=idx).collect::<String>();
321 let _ = sender.send(line);
322 }
323 }
324 Err(_) => break,
325 }
326 }
327
328 if !pending.trim().is_empty() {
329 let _ = sender.send(pending);
330 }
331}
332
333fn fetch_ngrok_public_url(port: u16) -> io::Result<String> {
334 use std::io::{Read, Write};
335
336 let mut stream = TcpStream::connect(("127.0.0.1", 4040))?;
337 let request = concat!(
338 "GET /api/tunnels HTTP/1.1\r\n",
339 "Host: 127.0.0.1:4040\r\n",
340 "Connection: close\r\n",
341 "\r\n"
342 );
343 stream.write_all(request.as_bytes())?;
344 stream.flush()?;
345
346 let mut response = String::new();
347 stream.read_to_string(&mut response)?;
348 let body = response
349 .split_once("\r\n\r\n")
350 .map(|(_, body)| body)
351 .ok_or_else(|| io::Error::other("ngrok API returned an invalid HTTP response"))?;
352
353 extract_ngrok_url_from_api(body, port)
354 .ok_or_else(|| io::Error::other("ngrok API has no active tunnel for this local port"))
355}
356
357fn extract_ngrok_url_from_api(body: &str, port: u16) -> Option<String> {
358 let response: NgrokTunnelList = serde_json::from_str(body).ok()?;
359 let expected_addrs = [
360 format!("127.0.0.1:{port}"),
361 format!("localhost:{port}"),
362 format!("http://127.0.0.1:{port}"),
363 format!("http://localhost:{port}"),
364 ];
365
366 response
367 .tunnels
368 .into_iter()
369 .filter(|tunnel| tunnel.public_url.starts_with("https://"))
370 .find(|tunnel| {
371 tunnel
372 .config
373 .as_ref()
374 .map(|config| {
375 expected_addrs
376 .iter()
377 .any(|expected| config.addr == *expected)
378 })
379 .unwrap_or(false)
380 || tunnel
381 .forwards_to
382 .as_ref()
383 .map(|addr| expected_addrs.iter().any(|expected| addr == expected))
384 .unwrap_or(false)
385 })
386 .map(|tunnel| tunnel.public_url.trim_end_matches('/').to_string())
387}
388
389fn push_recent_log_line(lines: &mut Vec<String>, line: String) {
390 if lines.len() >= 6 {
391 lines.remove(0);
392 }
393 lines.push(line.trim().to_string());
394}
395
396fn summarize_recent_logs(lines: &[String]) -> String {
397 if lines.is_empty() {
398 "no tunnel logs captured".to_string()
399 } else {
400 lines.join(" | ")
401 }
402}
403
404#[derive(Debug, Deserialize)]
405struct NgrokTunnelList {
406 tunnels: Vec<NgrokTunnel>,
407}
408
409#[derive(Debug, Deserialize)]
410struct NgrokTunnel {
411 public_url: String,
412 #[serde(default)]
413 config: Option<NgrokTunnelConfig>,
414 #[serde(default)]
415 forwards_to: Option<String>,
416}
417
418#[derive(Debug, Deserialize)]
419struct NgrokTunnelConfig {
420 addr: String,
421}
422
423fn random_token(len: usize) -> String {
424 rand::rng()
425 .sample_iter(Alphanumeric)
426 .map(char::from)
427 .take(len)
428 .collect()
429}
430
431fn render_qr_lines(url: &str) -> io::Result<Vec<String>> {
432 let qr = QrCode::encode_text(url, QrCodeEcc::Medium)
433 .map_err(|err| io::Error::other(format!("failed to encode QR: {err:?}")))?;
434 let border = 2;
435 let size = qr.size();
436 let mut lines = Vec::new();
437
438 for y in ((-border)..(size + border)).step_by(2) {
439 let mut line = String::new();
440 for x in (-border)..(size + border) {
441 let top = qr.get_module(x, y);
442 let bottom = qr.get_module(x, y + 1);
443 line.push(match (top, bottom) {
444 (true, true) => '█',
445 (true, false) => '▀',
446 (false, true) => '▄',
447 (false, false) => ' ',
448 });
449 }
450 lines.push(line);
451 }
452
453 Ok(lines)
454}
455
456fn write_qr_svg(url: &str, token: &str) -> io::Result<PathBuf> {
457 let qr = QrCode::encode_text(url, QrCodeEcc::Medium)
458 .map_err(|err| io::Error::other(format!("failed to encode QR: {err:?}")))?;
459 let border = 4;
460 let size = qr.size() + (border * 2);
461 let mut svg = format!(
462 r#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 {size} {size}" shape-rendering="crispEdges">"#
463 );
464 svg.push_str(r##"<rect width="100%" height="100%" fill="#ffffff"/>"##);
465 svg.push_str(r#"<path d=""#);
466
467 for y in 0..qr.size() {
468 for x in 0..qr.size() {
469 if qr.get_module(x, y) {
470 let x = x + border;
471 let y = y + border;
472 svg.push_str(&format!("M{x},{y}h1v1h-1z"));
473 }
474 }
475 }
476
477 svg.push_str(r##"" fill="#000000"/></svg>"##);
478
479 let path = std::env::temp_dir().join(format!("muffin-remote-{token}.svg"));
480 fs::write(&path, svg)?;
481 Ok(path)
482}
483
484fn render_mobile_page(token: &str) -> String {
485 format!(
486 r#"<!doctype html>
487<html lang="en">
488<head>
489 <meta charset="utf-8" />
490 <meta name="viewport" content="width=device-width, initial-scale=1" />
491 <title>Muffin Remote</title>
492 <style>
493 :root {{
494 color-scheme: dark;
495 --bg: #0b0f14;
496 --panel: #111823;
497 --panel-border: #243244;
498 --text: #e6eef7;
499 --muted: #8ea2b8;
500 --accent: #64d7c8;
501 --danger: #f85149;
502 --success: #2ea043;
503 }}
504 body {{
505 margin: 0;
506 background: radial-gradient(circle at top, #132235, var(--bg) 60%);
507 color: var(--text);
508 font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
509 }}
510 main {{
511 max-width: 900px;
512 margin: 0 auto;
513 padding: 20px 16px 28px;
514 }}
515 .status {{
516 color: var(--muted);
517 margin-bottom: 12px;
518 font-size: 14px;
519 }}
520 .controls {{
521 display: grid;
522 grid-template-columns: repeat(2, minmax(0, 1fr));
523 gap: 10px;
524 margin: 16px 0;
525 }}
526 button {{
527 border: 1px solid var(--panel-border);
528 background: var(--panel);
529 color: var(--text);
530 padding: 14px 10px;
531 border-radius: 12px;
532 font-size: 16px;
533 font-weight: 600;
534 }}
535 button.primary {{ border-color: var(--accent); }}
536 button.success {{ border-color: var(--success); }}
537 button.danger {{ border-color: var(--danger); }}
538 pre {{
539 white-space: pre-wrap;
540 word-break: break-word;
541 background: rgba(8, 12, 18, 0.95);
542 border: 1px solid var(--panel-border);
543 border-radius: 16px;
544 padding: 14px;
545 min-height: 55vh;
546 overflow: auto;
547 line-height: 1.35;
548 font-size: 13px;
549 }}
550 h1 {{
551 margin: 0 0 8px;
552 font-size: 20px;
553 }}
554 </style>
555</head>
556<body>
557 <main>
558 <h1 id="title">Muffin Remote</h1>
559 <div id="status" class="status">Connecting...</div>
560 <div class="controls">
561 <button class="primary" onclick="sendAction('enter')">Approve / Enter</button>
562 <button class="success" onclick="sendAction('yes')">Send y</button>
563 <button onclick="sendAction('no')">Send n</button>
564 <button class="danger" onclick="sendAction('interrupt')">Ctrl+C</button>
565 </div>
566 <pre id="screen">Waiting for session output...</pre>
567 </main>
568 <script>
569 const token = {token:?};
570 const screen = document.getElementById('screen');
571 const statusEl = document.getElementById('status');
572 const titleEl = document.getElementById('title');
573
574 async function refresh() {{
575 try {{
576 const response = await fetch(`/snapshot/${{token}}`, {{ cache: 'no-store' }});
577 const snapshot = await response.json();
578 titleEl.textContent = `${{snapshot.mode_title}} Remote`;
579 statusEl.textContent = snapshot.status;
580 screen.textContent = (snapshot.lines || []).join('\n');
581 }} catch (error) {{
582 statusEl.textContent = `Remote stream unavailable: ${{error}}`;
583 }}
584 }}
585
586 async function sendAction(action) {{
587 await fetch(`/action/${{token}}/${{action}}`, {{
588 method: 'POST',
589 cache: 'no-store',
590 }});
591 refresh();
592 }}
593
594 refresh();
595 setInterval(refresh, {REMOTE_POLL_MS});
596 </script>
597</body>
598</html>"#
599 )
600}
601
602fn html_response(body: String) -> Response<std::io::Cursor<Vec<u8>>> {
603 response_with_type(body, "text/html; charset=utf-8")
604}
605
606fn json_response(body: String) -> Response<std::io::Cursor<Vec<u8>>> {
607 response_with_type(body, "application/json; charset=utf-8")
608}
609
610fn text_response(body: &str) -> Response<std::io::Cursor<Vec<u8>>> {
611 response_with_type(body.to_string(), "text/plain; charset=utf-8")
612}
613
614fn status_response(status: StatusCode, body: &str) -> Response<std::io::Cursor<Vec<u8>>> {
615 response_with_type(body.to_string(), "text/plain; charset=utf-8").with_status_code(status)
616}
617
618fn response_with_type(body: String, content_type: &str) -> Response<std::io::Cursor<Vec<u8>>> {
619 Response::from_string(body)
620 .with_header(Header::from_bytes(&b"Content-Type"[..], content_type.as_bytes()).unwrap())
621}
622
623#[cfg(test)]
624mod tests {
625 use super::{
626 RemoteAction, extract_ngrok_url_from_api, parse_action, render_mobile_page,
627 render_qr_lines, summarize_recent_logs, write_qr_svg,
628 };
629
630 #[test]
631 fn action_routes_map_to_expected_commands() {
632 assert_eq!(parse_action("enter"), Some(RemoteAction::Enter));
633 assert_eq!(parse_action("yes"), Some(RemoteAction::ApproveYes));
634 assert_eq!(parse_action("no"), Some(RemoteAction::RejectNo));
635 assert_eq!(parse_action("interrupt"), Some(RemoteAction::Interrupt));
636 assert_eq!(parse_action("nope"), None);
637 }
638
639 #[test]
640 fn qr_renderer_returns_multiple_rows() {
641 let qr = render_qr_lines("http://100.64.0.1:9999/pair/token").unwrap();
642 assert!(qr.len() > 5);
643 assert!(qr.iter().all(|line| !line.is_empty()));
644 }
645
646 #[test]
647 fn mobile_page_embeds_snapshot_and_action_routes() {
648 let html = render_mobile_page("abc123");
649 assert!(html.contains("/snapshot/${token}"));
650 assert!(html.contains("sendAction('enter')"));
651 assert!(html.contains("Approve / Enter"));
652 }
653
654 #[test]
655 fn extracts_ngrok_tunnel_url_for_matching_port() {
656 let body = r#"{
657 "tunnels": [
658 {
659 "public_url": "http://abc.ngrok-free.app",
660 "config": {
661 "addr": "127.0.0.1:1234"
662 }
663 },
664 {
665 "public_url": "https://abc.ngrok-free.app",
666 "config": {
667 "addr": "127.0.0.1:1234"
668 }
669 }
670 ]
671 }"#;
672
673 assert_eq!(
674 extract_ngrok_url_from_api(body, 1234).as_deref(),
675 Some("https://abc.ngrok-free.app")
676 );
677 }
678
679 #[test]
680 fn summarizes_recent_logs_when_present() {
681 let logs = vec!["one".to_string(), "two".to_string()];
682 assert_eq!(summarize_recent_logs(&logs), "one | two");
683 }
684
685 #[test]
686 fn qr_svg_writer_persists_a_real_image_file() {
687 let path = write_qr_svg("https://abc.ngrok-free.app", "test-token").unwrap();
688 let svg = std::fs::read_to_string(&path).unwrap();
689 assert!(svg.contains("<svg"));
690 assert!(svg.contains("fill=\"#000000\""));
691 let _ = std::fs::remove_file(path);
692 }
693}