diff --git a/mobile/index.html b/mobile/index.html index 361e51f..74a12e5 100644 --- a/mobile/index.html +++ b/mobile/index.html @@ -740,24 +740,35 @@ function ownComputer() { const n = botName(S.open); return n ? n + " 的電腦" $("b-screen").onclick = $("b-screen2").onclick = () => { S.back = S.view; show("screen"); $("seg").children[0].textContent = ownComputer(); $("seg").children[1].style.display = S.mac ? "" : "none"; startFrames(); }; $("b-back2").onclick = () => show(S.back === "chat" ? "chat" : "list"); document.querySelectorAll("#seg button").forEach((b) => b.onclick = () => { document.querySelectorAll("#seg button").forEach((x) => x.classList.toggle("on", x === b)); S.screen = b.dataset.w; $("k-wake").style.display = S.screen === "box" ? "" : "none"; startFrames(); }); -let busyFrame = false, lastUrl = null; -function startFrames() { stopFrames(); frame(); S.frameTimer = setInterval(frame, 1000); } -function stopFrames() { clearInterval(S.frameTimer); S.frameTimer = null; } -async function frame() { - if (busyFrame || document.hidden) return; busyFrame = true; - try { - const r = await api(base() + "api/screen/" + S.screen, {raw: true}); - if (!r.ok) { const v = await r.json().catch(() => ({})); $("stage").innerHTML = `
${esc(v.error || "看不到畫面")}
`; $("screen-live").textContent = ""; } - else { - const sz = (r.headers.get("X-Size") || "0x0").split("x").map(Number); S.size = sz; +// The bots' computer sends a picture the moment its screen changes: ask for the one after the +// picture shown (X-Seq), again as soon as it arrives. (It was one picture a second, so a tap +// showed its result a second or two later.) This computer's own screen: a picture at a time. +let lastUrl = null; +const nap = (ms) => new Promise((r) => setTimeout(r, ms)); +function startFrames() { stopFrames(); const run = S.frameRun = {}; frames(run); } +function stopFrames() { S.frameRun = null; } +async function frames(run) { + let seq = 0; + while (S.frameRun === run) { + if (document.hidden) { await nap(800); continue; } + try { + const live = S.screen === "box"; + const r = await api(base() + "api/screen/" + S.screen + (live ? "?after=" + seq : ""), {raw: true}); + if (S.frameRun !== run) return; + if (!r.ok) { const v = await r.json().catch(() => ({})); $("stage").innerHTML = `
${esc(v.error || "看不到畫面")}
`; $("screen-live").textContent = ""; await nap(1500); continue; } + const n = +(r.headers.get("X-Seq") || 0); + if (live && n && n === seq) continue; + seq = n; + S.size = (r.headers.get("X-Size") || "0x0").split("x").map(Number); const url = URL.createObjectURL(await r.blob()); let img = $("stage").querySelector("img"); if (!img) { $("stage").innerHTML = ""; img = document.createElement("img"); img.draggable = false; $("stage").appendChild(img); bindStage(img); } img.src = url; if (lastUrl) URL.revokeObjectURL(lastUrl); lastUrl = url; $("screen-live").innerHTML = `即時`; - } - } catch (e) { $("screen-live").textContent = ""; } - busyFrame = false; + // At most about 8 pictures a second (an animated page would send all it can). + await nap(!live || !n ? 700 : 120); + } catch (e) { $("screen-live").textContent = ""; await nap(1500); } + } } function point(img, e) { const r = img.getBoundingClientRect(); @@ -777,7 +788,7 @@ function act(img, e, button) { input2({kind: "click", x: p.x, y: p.y, button}); } async function input2(body) { - try { await api(base() + "api/screen/" + S.screen, {json: body}); setTimeout(frame, 250); } catch (e) { toast(e.message); } + try { await api(base() + "api/screen/" + S.screen, {json: body}); } catch (e) { toast(e.message); } } document.querySelectorAll("[data-key]").forEach((b) => b.onclick = () => input2({kind: "key", key: b.dataset.key})); document.querySelectorAll("[data-scroll]").forEach((b) => b.onclick = () => input2({kind: "scroll", x: Math.round(S.size[0] / 2), y: Math.round(S.size[1] / 2), direction: b.dataset.scroll, amount: 5})); @@ -861,7 +872,7 @@ if ("serviceWorker" in navigator) navigator.serviceWorker.addEventListener("mess if (d.host && d.host !== S.host && S.relay) { S.host = d.host; localStorage.setItem("anybot.host", S.host); S.version = 0; } if (d.bot) openChat(+d.bot); }); -document.addEventListener("visibilitychange", () => { if (!document.hidden && S.view === "screen") frame(); }); +document.addEventListener("visibilitychange", () => { if (!document.hidden && S.view === "screen") startFrames(); }); hello(); diff --git a/relay/src/main.rs b/relay/src/main.rs index ff8f8a3..5ca309e 100644 --- a/relay/src/main.rs +++ b/relay/src/main.rs @@ -469,7 +469,9 @@ impl Relay { let (me, tx, user, method, z, from) = (self.clone(), tx.clone(), h.user.clone(), s("method"), head["z"].as_bool().unwrap_or(false), h.id.clone()); std::thread::spawn(move || { let a = me.forward_raw(&to, &method, &path, &query, &body, z, (&user, role), true, &from); - let _ = tx.send(wire::pack(&json!({"t": "ret", "id": id, "status": a.status, "ctype": a.ctype, "z": a.z}), &a.body)); + // The answer's headers too (a live picture's number, a screen's size). + let headers: Vec<[&str; 2]> = a.headers.iter().map(|(k, v)| [k.as_str(), v.as_str()]).collect(); + let _ = tx.send(wire::pack(&json!({"t": "ret", "id": id, "status": a.status, "ctype": a.ctype, "z": a.z, "headers": headers}), &a.body)); }); } "hello" => { diff --git a/src/app/computers_ui.rs b/src/app/computers_ui.rs index 8ef0f23..794466b 100644 --- a/src/app/computers_ui.rs +++ b/src/app/computers_ui.rs @@ -7,10 +7,9 @@ use super::*; use crate::cloudbox::{self, Rebuild, Run}; -/// How often the viewer asks the box for a new picture (estimated). +/// While the viewer is open it redraws at least this often (new pictures ask for a redraw +/// themselves as they arrive). const VIEWER_FRAME_MS: u64 = 450; -/// The brain's computer comes over the account server: a picture about every 0.7 s. -const REMOTE_FRAME_MS: u64 = 700; /// The floating bar stays this long after the bot's last mouse/keyboard action on this Mac. const LOCAL_BAR_SECS: u64 = 8; @@ -1044,18 +1043,25 @@ impl App { let s_frames = s.clone(); std::thread::spawn(move || { let s = s_frames; + // Ask for the picture after the one shown: the brain answers when the screen + // changes (no fixed pause between pictures). let mut seq = 0u64; while !s2.load(Ordering::SeqCst) { - match crate::remote::sync_screen(&s, false).ok().and_then(|b| image::load_from_memory(&b).ok()) { - Some(img) => { - let img = img.to_rgba8(); - seq += 1; - let size = [img.width() as usize, img.height() as usize]; - *slot.lock().unwrap() = Some((seq, egui::ColorImage::from_rgba_unmultiplied(size, img.as_raw()))); - c.request_repaint(); - std::thread::sleep(std::time::Duration::from_millis(REMOTE_FRAME_MS)); + match crate::remote::sync_live(&s, seq) { + Ok((n, jpeg)) if n != seq || n == 0 => { + if let Ok(img) = image::load_from_memory(&jpeg) { + let img = img.to_rgba8(); + let size = [img.width() as usize, img.height() as usize]; + *slot.lock().unwrap() = Some((n.max(seq + 1), egui::ColorImage::from_rgba_unmultiplied(size, img.as_raw()))); + c.request_repaint(); + } + // At most about 8 a second over the network (an animated page + // sends all it can); a brain too old to number them: one a second. + std::thread::sleep(std::time::Duration::from_millis(if n == 0 { 1000 } else { 120 })); + seq = n; } - None => { + Ok(_) => {} + Err(_) => { std::thread::sleep(std::time::Duration::from_millis(1500)); c.request_repaint(); } @@ -1089,17 +1095,21 @@ impl App { std::thread::spawn(move || { let mut seq = 0u64; while !s2.load(Ordering::SeqCst) { - let got = cloudbox::frame(); - if let Some(img) = got { - seq += 1; - let size = [img.width() as usize, img.height() as usize]; - let ci = egui::ColorImage::from_rgba_unmultiplied(size, img.as_raw()); - *slot.lock().unwrap() = Some((seq, ci)); - c.request_repaint(); - std::thread::sleep(std::time::Duration::from_millis(VIEWER_FRAME_MS)); - } else { - std::thread::sleep(std::time::Duration::from_millis(1000)); - c.request_repaint(); + match cloudbox::live_frame(seq, std::time::Duration::from_secs(5)) { + Some((n, jpeg)) if n != seq => { + if let Ok(img) = image::load_from_memory(&jpeg) { + let img = img.to_rgba8(); + let size = [img.width() as usize, img.height() as usize]; + *slot.lock().unwrap() = Some((n, egui::ColorImage::from_rgba_unmultiplied(size, img.as_raw()))); + c.request_repaint(); + } + seq = n; + } + Some(_) => {} + None => { + std::thread::sleep(std::time::Duration::from_millis(1000)); + c.request_repaint(); + } } } }); diff --git a/src/cloudbox.rs b/src/cloudbox.rs index 595683c..8d75f28 100644 --- a/src/cloudbox.rs +++ b/src/cloudbox.rs @@ -1188,13 +1188,162 @@ pub fn thumbnail(width: u32) -> Option { image::open(&path).ok().map(|i| i.to_rgba8()) } -/// A frame for the viewer (no waking: returns None when the box isn't running). +/// The screen now, for a small picture (no waking: None when the box isn't running): the live +/// picture when someone is watching, else one taken now. pub fn frame() -> Option { if !run_state().is_running() { return None; } - let png = dk_timeout(&["exec", container(), "import", "-window", "root", "-display", DISPLAY, "-quality", "1", "png:-"], 5_000).ok()?; - image::load_from_memory(&png).ok().map(|i| i.to_rgba8()) + let jpeg = match live_latest() { + Some(j) => j, + None => std::sync::Arc::new(dk_timeout(&["exec", container(), "import", "-window", "root", "-display", DISPLAY, "-quality", "80", "jpeg:-"], 5_000).ok()?), + }; + image::load_from_memory(&jpeg).ok().map(|i| i.to_rgba8()) +} + +// ---------- the live picture (people watching) ---------- +// One lasting `docker exec` takes the desktop as a JPEG inside the box (ImageMagick, in C) a few +// times a second and sends a picture only when it changed; a viewer asks for "a picture newer +// than mine" and gets it the moment there is one. It used to be one exec per picture (a 1 MB +// PNG, decoded and encoded again as a JPEG here) and a fixed pause after each: about one +// picture a second, so a click showed its result a second or two later. It stops when nobody +// has asked for a while. + +/// JPEG quality of the live picture (text stays readable; 60–90 KB for 1280×800). +const LIVE_QUALITY: u8 = 70; +/// Pause between looks at the screen inside the box: short while it is changing (a click's +/// result, scrolling), longer while it stays still. +const LIVE_GAP_BUSY: &str = "0.04"; +const LIVE_GAP_STILL: &str = "0.12"; +/// Stop taking pictures when no viewer asked for this long. +const LIVE_IDLE: Duration = Duration::from_secs(10); + +struct Live { + seq: u64, + jpeg: std::sync::Arc>, + at: Instant, + asked: Instant, + running: bool, +} + +fn live() -> &'static (Mutex, std::sync::Condvar) { + static L: OnceLock<(Mutex, std::sync::Condvar)> = OnceLock::new(); + L.get_or_init(|| (Mutex::new(Live { seq: 0, jpeg: Default::default(), at: Instant::now(), asked: Instant::now(), running: false }), std::sync::Condvar::new())) +} + +/// The latest live picture, when one was taken in the last few seconds. +fn live_latest() -> Option>> { + let l = live().0.lock().unwrap_or_else(|e| e.into_inner()); + (l.running && l.seq > 0 && l.at.elapsed() < Duration::from_secs(3)).then(|| l.jpeg.clone()) +} + +/// A picture of the desktop newer than `after` (0: any), waiting up to `wait` for the screen to +/// change; the same one again when it didn't. (number, JPEG); None when the box isn't running. +pub fn live_frame(after: u64, wait: Duration) -> Option<(u64, std::sync::Arc>)> { + if !run_state().is_running() { + return None; + } + let (m, cv) = live(); + let mut l = m.lock().unwrap_or_else(|e| e.into_inner()); + l.asked = Instant::now(); + if !l.running { + l.running = true; + std::thread::spawn(live_loop); + } + let until = Instant::now() + wait; + while l.seq == 0 || l.seq <= after { + let left = until.saturating_duration_since(Instant::now()); + if left.is_zero() { + break; + } + l = cv.wait_timeout(l, left).map(|r| r.0).unwrap_or_else(|e| e.into_inner().0); + } + (l.seq > 0).then(|| (l.seq, l.jpeg.clone())) +} + +/// The box's side: a picture whenever the screen changed, as "\n", and "0\n" now +/// and then while it stays still. Ending the docker exec on this side doesn't end the loop in +/// the box (a still screen never writes, so it never noticed): it ends the one before it when +/// it starts, and itself when a write fails (the "0" lines make sure one does). +fn live_script() -> String { + format!( + "[ -f /tmp/anybot-live.pid ] && kill $(cat /tmp/anybot-live.pid) 2>/dev/null; echo $$ > /tmp/anybot-live.pid; f=/tmp/anybot-live-$$.jpg; trap 'rm -f $f' EXIT; prev=; still=0; \ + while :; do import -window root -display {DISPLAY} -quality {LIVE_QUALITY} jpeg:$f 2>/dev/null || exit 3; h=$(cksum < $f); \ + if [ \"$h\" != \"$prev\" ]; then prev=$h; still=0; {{ wc -c < $f && cat $f; }} || exit 0; sleep {LIVE_GAP_BUSY}; \ + else still=$((still+1)); if [ $still -ge 15 ]; then still=0; echo 0 || exit 0; fi; sleep {LIVE_GAP_STILL}; fi; done" + ) +} + +/// End the picture loop in the box (see live_script). +fn live_stop_in_box() { + let _ = dk_timeout(&["exec", container(), "sh", "-c", "[ -f /tmp/anybot-live.pid ] && kill $(cat /tmp/anybot-live.pid) 2>/dev/null; rm -f /tmp/anybot-live.pid; true"], 5_000); +} + +fn live_loop() { + use std::io::{BufRead, Read}; + let (m, cv) = live(); + let stop = |m: &Mutex| m.lock().unwrap_or_else(|e| e.into_inner()).running = false; + let Ok(mut child) = docker().args(["exec", "-u", "bot", container(), "sh", "-c", &live_script()]).stdout(Stdio::piped()).stderr(Stdio::null()).spawn() else { + return stop(m); + }; + let Some(out) = child.stdout.take() else { + let _ = child.kill(); + return stop(m); + }; + // Reading blocks while the screen stays the same; the watcher ends it when nobody asks. + let done = std::sync::Arc::new(AtomicBool::new(false)); + let d2 = done.clone(); + let reader = std::thread::spawn(move || { + let mut r = std::io::BufReader::new(out); + let mut line = String::new(); + while !d2.load(Ordering::SeqCst) { + line.clear(); + if r.read_line(&mut line).unwrap_or(0) == 0 { + break; + } + let Ok(n) = line.trim().parse::() else { break }; + // "0": still there, nothing new. + if n == 0 { + continue; + } + let mut jpeg = vec![0u8; n]; + if r.read_exact(&mut jpeg).is_err() { + break; + } + if !jpeg.starts_with(&[0xff, 0xd8]) { + continue; + } + let mut l = m.lock().unwrap_or_else(|e| e.into_inner()); + l.seq += 1; + l.jpeg = std::sync::Arc::new(jpeg); + l.at = Instant::now(); + cv.notify_all(); + } + d2.store(true, Ordering::SeqCst); + }); + loop { + std::thread::sleep(Duration::from_millis(500)); + let idle = m.lock().unwrap_or_else(|e| e.into_inner()).asked.elapsed() > LIVE_IDLE; + if idle || done.load(Ordering::SeqCst) || !run_state().is_running() { + break; + } + } + done.store(true, Ordering::SeqCst); + let _ = child.kill(); + let _ = child.wait(); + if run_state().is_running() { + live_stop_in_box(); + } + let _ = reader.join(); + let mut l = m.lock().unwrap_or_else(|e| e.into_inner()); + l.running = false; + // Viewers waiting on a picture that won't come get the last one now. + cv.notify_all(); +} + +/// The desktop's size (for a viewer mapping its taps), when known. +pub fn screen_size() -> Option<(u32, u32)> { + box_size().ok() } /// The desktop's size in pixels (asked once per box). @@ -1475,11 +1624,53 @@ pub fn computer_run(name: &str, v: &Value) -> Result<(String, Option), S } // ---------- the viewer's own input (when the person takes control) ---------- -// Same scripts, at the desktop's own pixels (the viewer shows it full size), no picture back. +// Same scripts, at the desktop's own pixels (the viewer shows it full size), no picture back, +// through one lasting shell in the box (a click or a key no longer starts a docker exec of its +// own: 50–200 ms each, more with a Docker host elsewhere). Typed text rides along as base64. + +fn input_shell() -> &'static Mutex> { + static S: OnceLock>> = OnceLock::new(); + S.get_or_init(|| Mutex::new(None)) +} + +/// One line for the lasting shell: the step's script, its text (if any) fed on its stdin. +pub fn input_line(script: &str, stdin: Option<&str>) -> String { + let script = script.replace('\n', " "); + match stdin { + Some(t) => format!("printf %s {} | base64 -d | {{ {script}; }}\n", base64::engine::general_purpose::STANDARD.encode(t)), + None => format!("{{ {script}; }} ) { + use std::io::Write; + let line = input_line(script, stdin); + let mut sh = input_shell().lock().unwrap_or_else(|e| e.into_inner()); + for _ in 0..2 { + let alive = sh.as_mut().map(|c| matches!(c.try_wait(), Ok(None))).unwrap_or(false); + if !alive { + *sh = docker() + .args(["exec", "-i", "-u", "bot", "-e", "DISPLAY=:1", container(), "sh", "-c", "while IFS= read -r l; do eval \"$l\" >/dev/null 2>&1; done"]) + .stdin(Stdio::piped()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .ok(); + } + let Some(c) = sh.as_mut() else { return }; + if c.stdin.as_mut().map(|i| i.write_all(line.as_bytes()).and_then(|_| i.flush()).is_ok()).unwrap_or(false) { + return; + } + // The box restarted under it: start a new one and try once more. + let _ = c.kill(); + let _ = c.wait(); + *sh = None; + } +} fn viewer(name: &str, v: Value) { if let Ok((script, stdin, _)) = box_script(name, &v, (1.0, 1.0)) { - let _ = box_exec(&script, stdin.as_deref(), false); + send_input(&script, stdin.as_deref()); } touch(); } @@ -1504,13 +1695,13 @@ pub fn viewer_key(combo: &str) { } // X key names pass straight through (they are what the viewers send). let x = combo_to_x(c).unwrap_or_else(|_| c.to_string()); - let _ = box_exec(&format!("xdotool key --clearmodifiers {x}"), None, false); + send_input(&format!("xdotool key --clearmodifiers {x}"), None); touch(); } pub fn viewer_text(text: &str) { if !text.is_empty() { - let _ = box_exec(&type_script(text), Some(text), false); + send_input(&type_script(text), Some(text)); } touch(); } @@ -1685,6 +1876,62 @@ mod tests { assert!(box_script("computer_use_scroll", &serde_json::json!({"direction": "sideways"}), s).is_err()); } + #[test] + fn viewer_input_is_one_line() { + // A click: its script on one line, no stdin. + assert_eq!(input_line("xdotool mousemove 1 2 sleep 0.03 click --repeat 1 --delay 80 1", None), "{ xdotool mousemove 1 2 sleep 0.03 click --repeat 1 --delay 80 1; } seq && react < Duration::from_millis(600), "{react:?}"); + // A screen that keeps changing: pictures a second. + let _ = box_exec("(setsid xterm -geometry 60x20+40+40 -e sh -c 'while :; do date +%s.%N; done' >/dev/null 2>&1 &)", None, false); + std::thread::sleep(Duration::from_millis(1200)); + let (mut n, _) = live_frame(0, Duration::from_secs(2)).unwrap(); + let (start, t) = (n, Instant::now()); + while t.elapsed() < Duration::from_secs(3) { + n = live_frame(n, Duration::from_secs(2)).unwrap().0; + } + let fps = (n - start) as f64 / t.elapsed().as_secs_f64(); + println!("changing screen: {fps:.1} pictures a second"); + // (pkill straight from docker exec: inside `sh -c` it matched its own shell first.) + let _ = dk(&["exec", container(), "pkill", "-f", "date +%s.%N"]); + assert!(fps >= 4.0, "{fps}"); + // Nobody asks: it stops, in the box too. + std::thread::sleep(LIVE_IDLE + Duration::from_secs(2)); + assert!(!live().0.lock().unwrap().running); + let left = dk(&["exec", container(), "sh", "-c", "ps -eo args | grep -c '[a]nybot-live' || true"]).unwrap_or_default(); + assert_eq!(left.trim(), "0", "picture loops left in the box"); + } + /// Uses the real Docker: `cargo test --release -- --ignored box_screen_is_exact --nocapture`. /// The bot's clicks land where its picture says, a phone's tap where the phone's picture says, /// the same spot twice doesn't hang, typing (ASCII, Chinese, a key by name) reaches the app, diff --git a/src/remote.rs b/src/remote.rs index e88c90d..2e40d56 100644 --- a/src/remote.rs +++ b/src/remote.rs @@ -415,7 +415,7 @@ pub fn handle(req: Req, via_relay: bool) -> Resp { hub().push(cmd); Resp::json(&json!({"ok": true})) } - ("GET", ["api", "screen", which]) => screen(which, &s), + ("GET", ["api", "screen", which]) => screen(which, &s, req.query.get("after").and_then(|a| a.parse().ok())), ("POST", ["api", "screen", which]) => screen_input(which, &req.json(), &s), ("POST", ["api", "device", "run"]) => device_run(&req.json(), &s, via_relay, req.header("x-anybot-from")), ("GET", ["api", "sync"]) => { @@ -450,7 +450,7 @@ pub fn handle(req: Req, via_relay: bool) -> Resp { None => Resp::error(404, "還沒有畫面"), } } else { - screen("box", &s) + screen("box", &s, req.query.get("after").and_then(|a| a.parse().ok())) } } ("POST", ["api", "sync", "screen"]) => { @@ -606,17 +606,23 @@ fn upload(bot: u64, req: &Req) -> Resp { } /// A frame of the bot's computer ("box") or this Mac ("mac") as JPEG. -fn screen(which: &str, s: &Settings) -> Resp { +/// How long a viewer's request for a newer picture waits for the screen to change (well under +/// the account server's forward timeout). +const LIVE_WAIT_MS: u64 = 8_000; + +/// A screen's picture for a viewer. The box: its live picture, as it was taken (no decoding and +/// encoding again here); with `after` (the number of the picture the viewer has) it answers the +/// moment the screen changes, or with the same picture after a few seconds. X-Seq is its number, +/// X-Size the desktop's size (what the viewer's taps are in). +fn screen(which: &str, s: &Settings, after: Option) -> Resp { match which { "box" => { crate::cloudbox::touch(); - match crate::cloudbox::frame() { - Some(img) => { - let rgb = image::DynamicImage::ImageRgba8(img).to_rgb8(); - match crate::computer::encode_jpeg(&rgb) { - Some(j) => Resp::bytes("image/jpeg", j).with_header("X-Size", &format!("{}x{}", rgb.width(), rgb.height())), - None => Resp::error(500, "畫面編碼失敗"), - } + let wait = Duration::from_millis(if after.is_some() { LIVE_WAIT_MS } else { 3_000 }); + match crate::cloudbox::live_frame(after.unwrap_or(0), wait) { + Some((seq, jpeg)) => { + let (w, h) = crate::cloudbox::screen_size().unwrap_or_default(); + Resp::bytes("image/jpeg", jpeg.to_vec()).with_header("X-Size", &format!("{w}x{h}")).with_header("X-Seq", &seq.to_string()) } None => Resp::error(503, "Bot 的電腦在休息,按「叫醒」"), } @@ -1206,11 +1212,16 @@ fn to_brain(s: &Settings, method: &str, rest: &str, query: &[(&str, String)], bo } fn to_brain_raw(s: &Settings, method: &str, rest: &str, query: &[(&str, String)], body: &[u8], secs: u64) -> Result<(u16, Vec), String> { + to_brain_full(s, method, rest, query, body, secs).map(|(st, b, _)| (st, b)) +} + +/// Same, with the answer's headers. +fn to_brain_full(s: &Settings, method: &str, rest: &str, query: &[(&str, String)], body: &[u8], secs: u64) -> Result<(u16, Vec, Vec<(String, String)>), String> { if s.brain_id.is_empty() { return Err("沒有選大腦".into()); } if let Some(r) = crate::tunnel::call(&s.brain_id, method, &format!("/api/sync{rest}"), query, body, secs) { - return r.map(|a| (a.status, a.body)); + return r.map(|a| (a.status, a.body, a.headers)); } let q: Vec = query.iter().map(|(k, v)| format!("{k}={}", url_escape(v))).collect(); let url = brain_url(s, &format!("{rest}{}{}", if q.is_empty() { "" } else { "?" }, q.join("&")))?; @@ -1218,8 +1229,9 @@ fn to_brain_raw(s: &Settings, method: &str, rest: &str, query: &[(&str, String)] let r = if method == "GET" { agent(secs).get(&url).header("Authorization", &auth).call() } else { agent(secs).post(&url).header("Authorization", &auth).header("Content-Type", "application/octet-stream").send(body) }; let mut resp = r.map_err(|e| format!("連不到帳號伺服器:{}", why_unreachable(&e)))?; let st = resp.status().as_u16(); + let headers = resp.headers().iter().filter_map(|(k, v)| Some((k.as_str().to_string(), v.to_str().ok()?.to_string()))).collect(); let bytes = resp.body_mut().with_config().limit(200 * 1024 * 1024).read_to_vec().map_err(|_| "和大腦的連線到一半斷掉了".to_string())?; - Ok((st, bytes)) + Ok((st, bytes, headers)) } /// What changed at the brain since `since` (waits up to LONG_POLL_SECS when nothing has). @@ -1247,6 +1259,17 @@ pub fn sync_screen(s: &Settings, thumb: bool) -> Result, String> { Ok(bytes) } +/// The brain's bots' computer, live: a picture newer than number `after` (or the same one when +/// the screen didn't change for a few seconds), with its number. +pub fn sync_live(s: &Settings, after: u64) -> Result<(u64, Vec), String> { + let (st, bytes, headers) = to_brain_full(s, "GET", "/screen", &[("after", after.to_string())], &[], LIVE_WAIT_MS / 1000 + 15)?; + if !(200..300).contains(&st) { + return Err(why_refused(st, &serde_json::from_slice(&bytes).unwrap_or(Value::Null), "大腦")); + } + let seq = headers.iter().find(|(k, _)| k.eq_ignore_ascii_case("x-seq")).and_then(|(_, v)| v.parse().ok()).unwrap_or(0); + Ok((seq, bytes)) +} + /// A file the brain keeps (an attachment shown in a chat), as bytes. pub fn sync_file(s: &Settings, path: &str) -> Result, String> { let (st, bytes) = to_brain_raw(s, "GET", "/file", &[("path", path.to_string())], &[], 120)?; diff --git a/src/tunnel.rs b/src/tunnel.rs index 633e1e7..0aafda1 100644 --- a/src/tunnel.rs +++ b/src/tunnel.rs @@ -26,6 +26,7 @@ const CUT: &str = "和帳號伺服器的連線斷了,正在重新連線"; pub struct Answer { pub status: u16, pub body: Vec, + pub headers: Vec<(String, String)>, } type Calls = Arc>>>>; @@ -51,7 +52,10 @@ pub fn zip(b: &[u8]) -> Vec { /// The body to send and whether it is compressed: only when that saves a good part of it /// (pictures and PDFs are compressed already). fn maybe_zip(b: Vec) -> (Vec, bool) { - if b.len() <= ZIP_OVER { + // JPEG, PNG, GIF, WebP, PDF, zip/docx, gzip: compressed already, not worth a try (the live + // picture is a JPEG several times a second). + const PACKED: [&[u8]; 7] = [b"\xff\xd8\xff", b"\x89PNG", b"GIF8", b"RIFF", b"%PDF", b"PK\x03\x04", b"\x1f\x8b"]; + if b.len() <= ZIP_OVER || PACKED.iter().any(|m| b.starts_with(m)) { return (b, false); } let z = zip(&b); @@ -306,7 +310,8 @@ fn on_frame(head: Value, body: Vec, tx: &Sender>, calls: &Calls) { "ret" => { if let Some(w) = calls.lock().unwrap().remove(head["id"].as_str().unwrap_or("")) { let body = if head["z"].as_bool().unwrap_or(false) { unzip(&body) } else { Ok(body) }; - let _ = w.send(body.map(|body| Answer { status: head["status"].as_u64().unwrap_or(502) as u16, body })); + let headers = head["headers"].as_array().into_iter().flatten().filter_map(|x| Some((x[0].as_str()?.to_string(), x[1].as_str()?.to_string()))).collect(); + let _ = w.send(body.map(|body| Answer { status: head["status"].as_u64().unwrap_or(502) as u16, body, headers })); } } _ => {}