Skip to main content

muffintui/
remote.rs

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}