From 6c0c4d7ecd08be668599947e9ffafabac60c2d0b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E6=80=A7=E9=A9=8A?= Date: Thu, 5 Nov 2026 22:34:36 +0800 Subject: [PATCH 1/3] Watching and using the bots' computer from afar: pictures as the screen changes, input through a lasting shell MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - One lasting docker exec takes the desktop as a JPEG inside the box and sends a picture only when it changed (quicker while it changes, slower while still). Viewers ask for the picture after the one they have and get it at once. It was one exec per picture (a 1 MB PNG decoded and encoded again on the brain) and a fixed 0.7 s / 1 s pause: about one picture a second. Measured on the Mac's box: first picture 45 ms, ~90 KB (was ~1.1 MB), 9–19 pictures a second while it changes; viewers take at most ~8 a second over the network. - The loop in the box ends the one before it, ends itself when a write fails ("0" lines while still) and is ended when nobody watched for 10 s (ending docker exec on this side left it running in the box). - The person's clicks and keys go through one lasting shell in the box (text as base64), not a docker exec each. - Answers over the lasting connection keep their headers (the relay dropped them): a picture's number reaches the app. JPEG/PNG/PDF/zip bodies are no longer deflated again. Co-Authored-By: Claude Opus 5.5 --- mobile/index.html | 41 ++++--- relay/src/main.rs | 4 +- src/app/computers_ui.rs | 56 +++++---- src/cloudbox.rs | 261 ++++++++++++++++++++++++++++++++++++++-- src/remote.rs | 47 ++++++-- src/tunnel.rs | 9 +- 6 files changed, 358 insertions(+), 60 deletions(-) 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 })); } } _ => {} From 02bca39b98db68fa3a88ff5db913bee2cae3c71a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E6=80=A7=E9=A9=8A?= Date: Thu, 5 Nov 2026 23:05:44 +0800 Subject: [PATCH 2/3] People watch and help on the bots' computer through VNC (noVNC), carried by the account server MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The core: the bot works the computer; a person watches it like a colleague, and takes over when it needs them. VNC is made for that (only what changed, every mouse move and key, touch on phones), so the pictures sent one by one go, and with them their own input path. - vnc.rs: a VNC proxy to the box's x11vnc through one docker exec (a Python bridge that ends with either side). It signs in to the box with the box's own password (its VNC_PASSWORD) and offers the viewer no password, so the password never leaves the brain. The person's clicks and keys make the bot's steps wait until 8 s after they stop; text typed in the viewer's box (any language) is pasted in the box. - Account server: /screen (the viewer page) and /novnc.js (noVNC 1.6.0, MPL-2.0, bundled); /relay/vnc/ takes the browser's WebSocket (account session, or a 10-minute ticket the app asks for with POST /relay/screen-ticket; owners and admins) and carries it over that AnyBot's lasting connection (vopen / vdata / vtext / vclose). Both ends of that connection wait 2 ms instead of 40 ms to read while things move: a request and its answer took up to 160 ms more than needed. - Phone page: 看 Bot 的電腦 and 接管 open the viewer page (also on the local network: /api/vnc on the AnyBot's own phone server). This Mac's own screen moved to the ⋯ menu. - Mac app: 看畫面 opens the viewer in the browser (a screen of a brain: the brain's computer by ticket; else this computer's box, noVNC straight from it). The in-app picture viewer, the live JPEG stream, the person's click/move/scroll/key endpoints and the input shell are gone. - Opening a Mac app uses LaunchServices (`open -a`): agent-desktop's launch waited on the window through accessibility and failed without 輔助使用 now and then. Measured on this Mac (made-up account, local relay + headless brain + the real box): viewer signed in in 0.4–0.7 s, a request and its answer through relay + brain + box 25–58 ms median; headless Chrome shows the desktop live in /screen. remote_e2e 28/28 ×2, 149 unit tests, relay 11. Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 10 + Cargo.toml | 2 + mobile/index.html | 34 ++- mobile/novnc.js | 8 + mobile/screen.html | 109 +++++++++ relay/src/main.rs | 168 +++++++++++++- src/app.rs | 14 -- src/app/computers_ui.rs | 485 +++------------------------------------- src/cloudbox.rs | 317 +++----------------------- src/computer.rs | 22 +- src/desktop.rs | 3 +- src/main.rs | 1 + src/remote.rs | 210 ++++++++--------- src/tunnel.rs | 80 ++++++- src/vnc.rs | 263 ++++++++++++++++++++++ 15 files changed, 835 insertions(+), 891 deletions(-) create mode 100644 mobile/novnc.js create mode 100644 mobile/screen.html create mode 100644 src/vnc.rs diff --git a/Cargo.lock b/Cargo.lock index 1dee383..2230f35 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -345,6 +345,7 @@ dependencies = [ "chrono-tz", "cpal", "crossterm", + "des", "dirs", "eframe", "flate2", @@ -1356,6 +1357,15 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "des" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ffdd80ce8ce993de27e9f063a444a4d53ce8e8db4c1f00cc03af5ad5a9867a1e" +dependencies = [ + "cipher", +] + [[package]] name = "digest" version = "0.10.7" diff --git a/Cargo.toml b/Cargo.toml index 7e0529a..a126c34 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -25,6 +25,8 @@ serde_json = "1" dirs = "7" image = { version = "0.25", default-features = false, features = ["png", "jpeg", "gif", "webp", "bmp", "tiff", "ico"] } base64 = "0.22" +# VNC sign-in (the 16-byte challenge) for people watching the bots' computer. +des = "0.8" chrono = "0.4" chrono-tz = "0.10" iana-time-zone = "0.1" diff --git a/mobile/index.html b/mobile/index.html index 74a12e5..f5027ad 100644 --- a/mobile/index.html +++ b/mobile/index.html @@ -272,7 +272,7 @@ a.plink{color:var(--accent);text-decoration:underline;text-underline-offset:3px;
-
+
這台 Mac
@@ -287,7 +287,6 @@ a.plink{color:var(--accent);text-decoration:underline;text-underline-offset:3px; -
@@ -295,6 +294,7 @@ a.plink{color:var(--accent);text-decoration:underline;text-underline-offset:3px;
Bot 需要你允許、或在你不在時做完事,手機會跳通知。
+ @@ -692,7 +692,7 @@ function renderAsk() { // 需要處理 (a step only you can do on a computer): its screen is one tap away, then 我完成了 / 跳過. if (a.kind === "handoff") { $("ask").innerHTML = `
需要處理 · ${esc(a.computer || "")}
${esc(a.reason)}
`; - $("ask").querySelector("[data-screen]").onclick = () => { S.back = S.view; show("screen"); $("seg").children[0].textContent = ownComputer(); $("seg").children[1].style.display = S.mac ? "" : "none"; startFrames(); }; + $("ask").querySelector("[data-screen]").onclick = () => openBox(true); } else { $("ask").innerHTML = `
${esc(a.title)}
${esc(a.reason)}
${a.can_always ? `` : ""}
`; } @@ -737,36 +737,33 @@ function renderChips() { // ---------- screens ---------- // Each bot's own computer goes by the bot's name (「Master 的電腦」). function ownComputer() { const n = botName(S.open); return n ? n + " 的電腦" : "Bot 的電腦"; } -$("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(); }; +// The bots' computer: live in the viewer page (VNC), where you can also take over. +function openBox(control) { + const at = S.relay ? "host=" + encodeURIComponent(S.host) : "lan=1"; + location.href = "screen#" + at + "&name=" + encodeURIComponent(ownComputer()) + (control ? "&control=1" : ""); +} +$("b-screen").onclick = $("b-screen2").onclick = () => openBox(false); +$("m-mac").onclick = () => { $("sheet").classList.remove("on"); S.back = S.view; S.screen = "mac"; show("screen"); 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(); }); -// 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. +// This Mac's 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}); + const r = await api(base() + "api/screen/mac", {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 = `即時`; - // At most about 8 pictures a second (an animated page would send all it can). - await nap(!live || !n ? 700 : 120); + await nap(700); } catch (e) { $("screen-live").textContent = ""; await nap(1500); } } } @@ -788,7 +785,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}); } catch (e) { toast(e.message); } + try { await api(base() + "api/screen/mac", {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})); @@ -796,9 +793,8 @@ $("k-type").onclick = () => { $("typebar").classList.toggle("on"); if ($("typeba $("b-type").onclick = () => { const t = $("typein").value; if (!t) return; $("typein").value = ""; input2({kind: "type", text: t}); }; $("typein").addEventListener("keydown", (e) => { if (e.key === "Enter" && !e.isComposing) { e.preventDefault(); $("b-type").click(); } }); $("k-right").onclick = () => { S.right = !S.right; $("k-right").textContent = "右鍵:" + (S.right ? "開" : "關"); }; -$("k-wake").onclick = () => { toast("正在叫醒" + ownComputer() + "…"); input2({kind: "wake"}); }; // ---------- menu, notifications ---------- -$("b-more").onclick = () => { $("m-host").style.display = S.relay ? "" : "none"; $("m-pair").style.display = S.relay ? "" : "none"; notifyLabel(); $("sheet").classList.add("on"); }; +$("b-more").onclick = () => { $("m-mac").style.display = S.mac ? "" : "none"; $("m-host").style.display = S.relay ? "" : "none"; $("m-pair").style.display = S.relay ? "" : "none"; notifyLabel(); $("sheet").classList.add("on"); }; $("sheet").onclick = (e) => { if (e.target.id === "sheet") $("sheet").classList.remove("on"); }; $("m-cancel").onclick = () => $("sheet").classList.remove("on"); $("m-out").onclick = () => { $("sheet").classList.remove("on"); if (confirm("要登出這支手機嗎?")) signOut(); }; diff --git a/mobile/novnc.js b/mobile/novnc.js new file mode 100644 index 0000000..5c01ecd --- /dev/null +++ b/mobile/novnc.js @@ -0,0 +1,8 @@ +/* noVNC 1.6.0 (https://github.com/novnc/noVNC), core library under the Mozilla Public License 2.0 + (https://mozilla.org/MPL/2.0/; source: the tag v1.6.0, core/rfb.js and what it imports). + Bundled for AnyBot with: esbuild core/rfb.js --bundle --minify --format=esm --target=es2022 */ +function ji(i){return i>>>0}function it(i){return i|0}var qi="warn",L=()=>{},V=()=>{},W=()=>{},U=()=>{};function Zr(i){if(typeof i>"u"?i=qi:qi=i,L=V=W=U=()=>{},typeof window.console<"u")switch(i){case"debug":L=console.debug.bind(window.console);case"info":V=console.info.bind(window.console);case"warn":W=console.warn.bind(window.console);case"error":U=console.error.bind(window.console);case"none":break;default:throw new window.Error("invalid logging type '"+i+"'")}}Zr();function Bt(i,t=!1){try{return decodeURIComponent(escape(i))}catch(e){if(e instanceof URIError&&t)return i;throw e}}function he(i){return unescape(encodeURIComponent(i))}var Pe={toBase64Table:"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=".split(""),base64Pad:"=",encode(i){"use strict";let t="",e=i.length,s=e%3;for(let n=0;n>2],t+=this.toBase64Table[((i[n]&3)<<4)+(i[n+1]>>4)],t+=this.toBase64Table[((i[n+1]&15)<<2)+(i[n+2]>>6)],t+=this.toBase64Table[i[n+2]&63];let r=e-s;return s===2?(t+=this.toBase64Table[i[r]>>2],t+=this.toBase64Table[((i[r]&3)<<4)+(i[r+1]>>4)],t+=this.toBase64Table[(i[r+1]&15)<<2],t+=this.toBase64Table[64]):s===1&&(t+=this.toBase64Table[i[r]>>2],t+=this.toBase64Table[(i[r]&3)<<4],t+=this.toBase64Table[64],t+=this.toBase64Table[64]),t},toBinaryTable:[-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,62,-1,-1,-1,63,52,53,54,55,56,57,58,59,60,61,-1,-1,-1,0,-1,-1,-1,0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,-1,-1,-1,-1,-1,-1,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,-1,-1,-1,-1,-1],decode(i,t=0){let e=i.indexOf("=")-t;e<0&&(e=i.length-t);let s=(e>>2)*3+Math.floor(e%4/1.5),r=new Array(s),n=0,a=0;for(let h=0,_=t;_=8&&(n-=8,l||(r[h++]=a>>n&255),a&=(1<0||navigator.msMaxTouchPoints>0;window.addEventListener("touchstart",function i(){_i=!0,window.removeEventListener("touchstart",i,!1)},!1);var st=10*(window.devicePixelRatio||1),Ji=!1;try{let i=document.createElement("canvas");i.style.cursor='url("data:image/x-icon;base64,AAACAAEACAgAAAIAAgA4AQAAFgAAACgAAAAIAAAAEAAAAAEAIAAAAAAAEAAAAAAAAAAAAAAAAAAAAAAAAAD/////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////AAAAAAAAAAAAAAAAAAAAAA==") 2 2, default',i.style.cursor.indexOf("url")===0?(V("Data URI scheme cursor supported"),Ji=!0):W("Data URI scheme cursor not supported")}catch(i){U("Data URI scheme cursor test exception: "+i)}var $i=Ji,Yr=!0;try{let i=document.createElement("div");i.style.visibility="hidden",i.style.overflow="scroll",document.body.appendChild(i);let t=document.createElement("div");i.appendChild(t);let e=i.offsetWidth-t.offsetWidth;i.parentNode.removeChild(i),Yr=e!=0}catch(i){U("Scrollbar test exception: "+i)}var fi=!1;async function jr(){if(!("VideoDecoder"in window))return!1;let i={codec:"avc1.42401f",codedWidth:1920,codedHeight:1080,optimizeForLatency:!0};if(!(await VideoDecoder.isConfigSupported(i)).supported)return!1;let e=new Uint8Array(Pe.decode("AAAAAWdCwBTZnpuAgICgAAADACAAAAZB4oVNAAAAAWjJYyyAAAABBgX//4HcRem95tlIt5Ys2CDZI+7veDI2NCAtIGNvcmUgMTY0IHIzMTA4IDMxZTE5ZjkgLSBILjI2NC9NUEVHLTQgQVZDIGNvZGVjIC0gQ29weWxlZnQgMjAwMy0yMDIzIC0gaHR0cDovL3d3dy52aWRlb2xhbi5vcmcveDI2NC5odG1sIC0gb3B0aW9uczogY2FiYWM9MCByZWY9NSBkZWJsb2NrPTE6MDowIGFuYWx5c2U9MHgxOjB4MTExIG1lPWhleCBzdWJtZT04IHBzeT0xIHBzeV9yZD0xLjAwOjAuMDAgbWl4ZWRfcmVmPTEgbWVfcmFuZ2U9MTYgY2hyb21hX21lPTEgdHJlbGxpcz0yIDh4OGRjdD0wIGNxbT0wIGRlYWR6b25lPTIxLDExIGZhc3RfcHNraXA9MSBjaHJvbWFfcXBfb2Zmc2V0PS0yIHRocmVhZHM9MSBsb29rYWhlYWRfdGhyZWFkcz0xIHNsaWNlZF90aHJlYWRzPTAgbnI9MCBkZWNpbWF0ZT0xIGludGVybGFjZWQ9MCBibHVyYXlfY29tcGF0PTAgY29uc3RyYWluZWRfaW50cmE9MCBiZnJhbWVzPTAgd2VpZ2h0cD0wIGtleWludD1pbmZpbml0ZSBrZXlpbnRfbWluPTI1IHNjZW5lY3V0PTQwIGludHJhX3JlZnJlc2g9MCByY19sb29rYWhlYWQ9NTAgcmM9YWJyIG1idHJlZT0xIGJpdHJhdGU9NDAwIHJhdGV0b2w9MS4wIHFjb21wPTAuNjAgcXBtaW49MCBxcG1heD02OSBxcHN0ZXA9NCBpcF9yYXRpbz0xLjQwIGFxPTE6MS4wMACAAAABZYiEBrxmKAAPVccAAS044AA5DRJMnkycJk4TPw==")),s=!1,r=null,n=new VideoDecoder({output:h=>{s=!0},error:h=>{r=h}}),a=new EncodedVideoChunk({timestamp:0,type:"key",data:e});n.configure(i),n.decode(a);try{await n.flush()}catch(h){r=h}return!(!s||r!==null)}fi=await jr();function me(){return!!/mac/i.exec(navigator.platform)}function He(){return!!/win/i.exec(navigator.platform)}function Ue(){return!!/ipad/i.exec(navigator.platform)||!!/iphone/i.exec(navigator.platform)||!!/ipod/i.exec(navigator.platform)}function Ne(i,t,e){let s=e.getBoundingClientRect(),r={x:0,y:0};return i=s.right?r.x=s.width-1:r.x=i-s.left,t=s.bottom?r.y=s.height-1:r.y=t-s.top,r}function Se(i){i.stopPropagation(),i.preventDefault()}var ui=!1,ts=null;document.captureElement=null;function ke(i){if(ui)return;let t=new i.constructor(i.type,i);ui=!0,document.captureElement?document.captureElement.dispatchEvent(t):ts.dispatchEvent(t),ui=!1,i.stopPropagation(),t.defaultPrevented&&i.preventDefault(),i.type==="mouseup"&&ns()}function is(){let i=document.getElementById("noVNC_mouse_capture_elem");i.style.cursor=window.getComputedStyle(document.captureElement).cursor}var ss=new MutationObserver(is);function rs(i){if(i.setCapture)i.setCapture(),document.captureElement=i;else{ns();let t=document.getElementById("noVNC_mouse_capture_elem");t===null&&(t=document.createElement("div"),t.id="noVNC_mouse_capture_elem",t.style.position="fixed",t.style.top="0px",t.style.left="0px",t.style.width="100%",t.style.height="100%",t.style.zIndex=1e4,t.style.display="none",document.body.appendChild(t),t.addEventListener("contextmenu",ke),t.addEventListener("mousemove",ke),t.addEventListener("mouseup",ke)),document.captureElement=i,ss.observe(i,{attributes:!0}),is(),t.style.display="",window.addEventListener("mousemove",ke),window.addEventListener("mouseup",ke)}}function ns(){if(document.releaseCapture)document.releaseCapture(),document.captureElement=null;else{if(!document.captureElement)return;ts=document.captureElement,document.captureElement=null,ss.disconnect();let i=document.getElementById("noVNC_mouse_capture_elem");i.style.display="none",window.removeEventListener("mousemove",ke),window.removeEventListener("mouseup",ke)}}var Ee=class{constructor(){this._listeners=new Map}addEventListener(t,e){this._listeners.has(t)||this._listeners.set(t,new Set),this._listeners.get(t).add(e)}removeEventListener(t,e){this._listeners.has(t)&&this._listeners.get(t).delete(e)}dispatchEvent(t){return this._listeners.has(t.type)?(this._listeners.get(t.type).forEach(e=>e.call(this,t)),!t.defaultPrevented):!0}};var rt=class{constructor(t){if(this._drawCtx=null,this._renderQ=[],this._flushPromise=null,this._fbWidth=0,this._fbHeight=0,this._prevDrawStyle="",L(">> Display.constructor"),this._target=t,!this._target)throw new Error("Target must be set");if(typeof this._target=="string")throw new Error("target must be a DOM element");if(!this._target.getContext)throw new Error("no getContext method");this._targetCtx=this._target.getContext("2d"),this._viewportLoc={x:0,y:0,w:this._target.width,h:this._target.height},this._backbuffer=document.createElement("canvas"),this._drawCtx=this._backbuffer.getContext("2d"),this._damageBounds={left:0,top:0,right:this._backbuffer.width,bottom:this._backbuffer.height},L("User Agent: "+navigator.userAgent),L("<< Display.constructor"),this._scale=1,this._clipViewport=!1}get scale(){return this._scale}set scale(t){this._rescale(t)}get clipViewport(){return this._clipViewport}set clipViewport(t){this._clipViewport=t;let e=this._viewportLoc;this.viewportChangeSize(e.w,e.h),this.viewportChangePos(0,0)}get width(){return this._fbWidth}get height(){return this._fbHeight}viewportChangePos(t,e){let s=this._viewportLoc;t=Math.floor(t),e=Math.floor(e),this._clipViewport||(t=-s.w,e=-s.h);let r=s.x+s.w-1,n=s.y+s.h-1;t<0&&s.x+t<0&&(t=-s.x),r+t>=this._fbWidth&&(t-=r+t-this._fbWidth+1),s.y+e<0&&(e=-s.y),n+e>=this._fbHeight&&(e-=n+e-this._fbHeight+1),!(t===0&&e===0)&&(L("viewportChange deltaX: "+t+", deltaY: "+e),s.x+=t,s.y+=e,this._damage(s.x,s.y,s.w,s.h),this.flip())}viewportChangeSize(t,e){(!this._clipViewport||typeof t>"u"||typeof e>"u")&&(L("Setting viewport to full display region"),t=this._fbWidth,e=this._fbHeight),t=Math.floor(t),e=Math.floor(e),t>this._fbWidth&&(t=this._fbWidth),e>this._fbHeight&&(e=this._fbHeight);let s=this._viewportLoc;if(s.w!==t||s.h!==e){s.w=t,s.h=e;let r=this._target;r.width=t,r.height=e,this.viewportChangePos(0,0),this._damage(s.x,s.y,s.w,s.h),this.flip(),this._rescale(this._scale)}}absX(t){return this._scale===0?0:it(t/this._scale+this._viewportLoc.x)}absY(t){return this._scale===0?0:it(t/this._scale+this._viewportLoc.y)}resize(t,e){this._prevDrawStyle="",this._fbWidth=t,this._fbHeight=e;let s=this._backbuffer;if(s.width!==t||s.height!==e){let n=null;s.width>0&&s.height>0&&(n=this._drawCtx.getImageData(0,0,s.width,s.height)),s.width!==t&&(s.width=t),s.height!==e&&(s.height=e),n&&this._drawCtx.putImageData(n,0,0)}let r=this._viewportLoc;this.viewportChangeSize(r.w,r.h),this.viewportChangePos(0,0)}getImageData(){return this._drawCtx.getImageData(0,0,this.width,this.height)}toDataURL(t,e){return this._backbuffer.toDataURL(t,e)}toBlob(t,e,s){return this._backbuffer.toBlob(t,e,s)}_damage(t,e,s,r){tthis._damageBounds.right&&(this._damageBounds.right=t+s),e+r>this._damageBounds.bottom&&(this._damageBounds.bottom=e+r)}flip(t){if(this._renderQ.length!==0&&!t)this._renderQPush({type:"flip"});else{let e=this._damageBounds.left,s=this._damageBounds.top,r=this._damageBounds.right-e,n=this._damageBounds.bottom-s,a=e-this._viewportLoc.x,h=s-this._viewportLoc.y;a<0&&(r+=a,e-=a,a=0),h<0&&(n+=h,s-=h,h=0),a+r>this._viewportLoc.w&&(r=this._viewportLoc.w-a),h+n>this._viewportLoc.h&&(n=this._viewportLoc.h-h),r>0&&n>0&&this._targetCtx.drawImage(this._backbuffer,e,s,r,n,a,h,r,n),this._damageBounds.left=this._damageBounds.top=65535,this._damageBounds.right=this._damageBounds.bottom=0}}pending(){return this._renderQ.length>0}flush(){return this._renderQ.length===0?Promise.resolve():(this._flushPromise===null&&(this._flushPromise=new Promise(t=>{this._flushResolve=t})),this._flushPromise)}fillRect(t,e,s,r,n,a){this._renderQ.length!==0&&!a?this._renderQPush({type:"fill",x:t,y:e,width:s,height:r,color:n}):(this._setFillColor(n),this._drawCtx.fillRect(t,e,s,r),this._damage(t,e,s,r))}copyImage(t,e,s,r,n,a,h){this._renderQ.length!==0&&!h?this._renderQPush({type:"copy",oldX:t,oldY:e,x:s,y:r,width:n,height:a}):(this._drawCtx.mozImageSmoothingEnabled=!1,this._drawCtx.webkitImageSmoothingEnabled=!1,this._drawCtx.msImageSmoothingEnabled=!1,this._drawCtx.imageSmoothingEnabled=!1,this._drawCtx.drawImage(this._backbuffer,t,e,n,a,s,r,n,a),this._damage(s,r,n,a))}imageRect(t,e,s,r,n,a){if(s===0||r===0)return;let h=new Image;h.src="data: "+n+";base64,"+Pe.encode(a),this._renderQPush({type:"img",img:h,x:t,y:e,width:s,height:r})}videoFrame(t,e,s,r,n){this._renderQPush({type:"frame",frame:n,x:t,y:e,width:s,height:r})}blitImage(t,e,s,r,n,a,h){if(this._renderQ.length!==0&&!h){let _=new Uint8Array(s*r*4);_.set(new Uint8Array(n.buffer,0,_.length)),this._renderQPush({type:"blit",data:_,x:t,y:e,width:s,height:r})}else{let _=new Uint8ClampedArray(n.buffer,n.byteOffset+a,s*r*4),o=new ImageData(_,s,r);this._drawCtx.putImageData(o,t,e),this._damage(t,e,s,r)}}drawImage(t,...e){if(this._drawCtx.drawImage(t,...e),e.length<=4){let[s,r]=e;this._damage(s,r,t.width,t.height)}else{let[,,s,r,n,a]=e;this._damage(n,a,s,r)}}autoscale(t,e){let s;if(t===0||e===0)s=0;else{let r=this._viewportLoc,n=t/e;r.w/r.h>=n?s=t/r.w:s=e/r.h}this._rescale(s)}_rescale(t){this._scale=t;let e=this._viewportLoc,s=t*e.w+"px",r=t*e.h+"px";(this._target.style.width!==s||this._target.style.height!==r)&&(this._target.style.width=s,this._target.style.height=r)}_setFillColor(t){let e="rgb("+t[0]+","+t[1]+","+t[2]+")";e!==this._prevDrawStyle&&(this._drawCtx.fillStyle=e,this._prevDrawStyle=e)}_renderQPush(t){this._renderQ.push(t),this._renderQ.length===1&&this._scanRenderQ()}_resumeRenderQ(){this.removeEventListener("load",this._noVNCDisplay._resumeRenderQ),this._noVNCDisplay._scanRenderQ()}_scanRenderQ(){let t=!0;for(;t&&this._renderQ.length>0;){let e=this._renderQ[0];switch(e.type){case"flip":this.flip(!0);break;case"copy":this.copyImage(e.oldX,e.oldY,e.x,e.y,e.width,e.height,!0);break;case"fill":this.fillRect(e.x,e.y,e.width,e.height,e.color,!0);break;case"blit":this.blitImage(e.x,e.y,e.width,e.height,e.data,0,!0);break;case"img":if(e.img.complete){if(e.img.width!==e.width||e.img.height!==e.height){U("Decoded image has incorrect dimensions. Got "+e.img.width+"x"+e.img.height+". Expected "+e.width+"x"+e.height+".");return}this.drawImage(e.img,e.x,e.y)}else e.img._noVNCDisplay=this,e.img.addEventListener("load",this._resumeRenderQ),t=!1;break;case"frame":if(e.frame.ready){let s=e.frame.frame;(s.codedWidth{s._scanRenderQ()}),t=!1}break}t&&this._renderQ.shift()}this._renderQ.length===0&&this._flushPromise!==null&&(this._flushResolve(),this._flushPromise=null,this._flushResolve=null)}};function ae(i,t,e,s,r){if(t.subarray&&i.subarray){i.set(t.subarray(e,e+s),r);return}for(var n=0;n>>16&65535|0,a=0;e!==0;){a=e>2e3?2e3:e,e-=a;do r=r+t[s++]|0,n=n+r|0;while(--a);r%=65521,n%=65521}return r|n<<16|0}function G(){for(var i,t=[],e=0;e<256;e++){i=e;for(var s=0;s<8;s++)i=i&1?3988292384^i>>>1:i>>>1;t[e]=i}return t}var Ma=G();var Qt=30,qr=12;function di(i,t){var e,s,r,n,a,h,_,o,l,x,u,d,X,F,A,k,T,E,K,C,I,S,D,B,M;e=i.state,s=i.next_in,B=i.input,r=s+(i.avail_in-5),n=i.next_out,M=i.output,a=n-(t-i.avail_out),h=n+(i.avail_out-257),_=e.dmax,o=e.wsize,l=e.whave,x=e.wnext,u=e.window,d=e.hold,X=e.bits,F=e.lencode,A=e.distcode,k=(1<>>24,d>>>=K,X-=K,K=E>>>16&255,K===0)M[n++]=E&65535;else if(K&16){C=E&65535,K&=15,K&&(X>>=K,X-=K),X<15&&(d+=B[s++]<>>24,d>>>=K,X-=K,K=E>>>16&255,K&16){if(I=E&65535,K&=15,X_){i.msg="invalid distance too far back",e.mode=Qt;break e}if(d>>>=K,X-=K,K=n-a,I>K){if(K=I-K,K>l&&e.sane){i.msg="invalid distance too far back",e.mode=Qt;break e}if(S=0,D=u,x===0){if(S+=o-K,K2;)M[n++]=D[S++],M[n++]=D[S++],M[n++]=D[S++],C-=3;C&&(M[n++]=D[S++],C>1&&(M[n++]=D[S++]))}else{S=n-I;do M[n++]=M[S++],M[n++]=M[S++],M[n++]=M[S++],C-=3;while(C>2);C&&(M[n++]=M[S++],C>1&&(M[n++]=M[S++]))}}else if(K&64){i.msg="invalid distance code",e.mode=Qt;break e}else{E=A[(E&65535)+(d&(1<>3,s-=C,X-=C<<3,d&=(1<=1&&z[u]===0;u--);if(d>u&&(d=u),u===0)return r[n++]=1<<24|64<<16|0,r[n++]=1<<24|64<<16|0,h.bits=1,0;for(x=1;x0&&(i===hs||u!==1))return-1;for(te[1]=0,o=1;oas||i===ls&&k>os)return 1;for(;;){Fe=o-F,a[l]M?(_e=Ie[oe+a[l]],Z=D[B+a[l]]):(_e=96,Z=0),E=1<>F)+K]=Fe<<24|_e<<16|Z|0;while(K!==0);for(E=1<>=1;if(E!==0?(T&=E-1,T+=E):T=0,l++,--z[o]===0){if(o===u)break;o=t[e+a[l]]}if(o>d&&(T&I)!==C){for(F===0&&(F=d),S+=x,X=o-F,A=1<as||i===ls&&k>os)return 1;C=T&I,r[C]=d<<24|X<<16|S-n|0}}return T!==0&&(r[S+T]=o-F<<24|64<<16|0),h.bits=d,0}var sn=0,Is=1,Ps=2,cs=4,rn=5,It=6,Ut=0,nn=1,an=2,Ce=-2,on=-3,_s=-4,hn=-5,fs=8,Hs=1,us=2,ds=3,xs=4,ps=5,gs=6,bs=7,ms=8,ws=9,vs=10,ys=11,pe=12,pi=13,Xs=14,gi=15,Ks=16,Fs=17,ks=18,Ss=19,Pt=20,Ht=21,Es=22,As=23,Cs=24,Ls=25,Ts=26,bi=27,Ms=28,Rs=29,N=30,Bs=31,ln=32,cn=852,_n=592,fn=15,un=fn;function Ds(i){return(i>>>24&255)+(i>>>8&65280)+((i&65280)<<8)+((i&255)<<24)}function dn(){this.mode=0,this.last=!1,this.wrap=0,this.havedict=!1,this.flags=0,this.dmax=0,this.check=0,this.total=0,this.head=null,this.wbits=0,this.wsize=0,this.whave=0,this.wnext=0,this.window=null,this.hold=0,this.bits=0,this.length=0,this.offset=0,this.extra=0,this.lencode=null,this.distcode=null,this.lenbits=0,this.distbits=0,this.ncode=0,this.nlen=0,this.ndist=0,this.have=0,this.next=null,this.lens=new ie(320),this.work=new ie(288),this.lendyn=null,this.distdyn=null,this.sane=0,this.back=0,this.was=0}function xn(i){var t;return!i||!i.state?Ce:(t=i.state,i.total_in=i.total_out=t.total=0,i.msg="",t.wrap&&(i.adler=t.wrap&1),t.mode=Hs,t.last=0,t.havedict=0,t.dmax=32768,t.head=null,t.hold=0,t.bits=0,t.lencode=t.lendyn=new nt(cn),t.distcode=t.distdyn=new nt(_n),t.sane=1,t.back=-1,Ut)}function vi(i){var t;return!i||!i.state?Ce:(t=i.state,t.wsize=0,t.whave=0,t.wnext=0,xn(i))}function pn(i,t){var e,s;return!i||!i.state||(s=i.state,t<0?(e=0,t=-t):(e=(t>>4)+1,t<48&&(t&=15)),t&&(t<8||t>15))?Ce:(s.window!==null&&s.wbits!==t&&(s.window=null),s.wrap=e,s.wbits=t,vi(i))}function gn(i,t){var e,s;return i?(s=new dn,i.state=s,s.window=null,e=pn(i,t),e!==Ut&&(i.state=null),e):Ce}function Us(i){return gn(i,un)}var Qs=!0,mi,wi;function bn(i){if(Qs){var t;for(mi=new nt(512),wi=new nt(32),t=0;t<144;)i.lens[t++]=8;for(;t<256;)i.lens[t++]=9;for(;t<280;)i.lens[t++]=7;for(;t<288;)i.lens[t++]=8;for(Ae(Is,i.lens,0,288,mi,0,i.work,{bits:9}),t=0;t<32;)i.lens[t++]=5;Ae(Ps,i.lens,0,32,wi,0,i.work,{bits:5}),Qs=!1}i.lencode=mi,i.lenbits=9,i.distcode=wi,i.distbits=5}function mn(i,t,e,s){var r,n=i.state;return n.window===null&&(n.wsize=1<=n.wsize?(ae(n.window,t,e-n.wsize,n.wsize,0),n.wnext=0,n.whave=n.wsize):(r=n.wsize-n.wnext,r>s&&(r=s),ae(n.window,t,e-s,r,n.wnext),s-=r,s?(ae(n.window,t,e-s,s,0),n.wnext=s,n.whave=n.wsize):(n.wnext+=r,n.wnext===n.wsize&&(n.wnext=0),n.whave>>8&255,e.check=G(e.check,B,2,0),o=0,l=0,e.mode=us;break}if(e.flags=0,e.head&&(e.head.done=!1),!(e.wrap&1)||(((o&255)<<8)+(o>>8))%31){i.msg="incorrect header check",e.mode=N;break}if((o&15)!==fs){i.msg="unknown compression method",e.mode=N;break}if(o>>>=4,l-=4,S=(o&15)+8,e.wbits===0)e.wbits=S;else if(S>e.wbits){i.msg="invalid window size",e.mode=N;break}e.dmax=1<>8&1),e.flags&512&&(B[0]=o&255,B[1]=o>>>8&255,e.check=G(e.check,B,2,0)),o=0,l=0,e.mode=ds;case ds:for(;l<32;){if(h===0)break e;h--,o+=s[n++]<>>8&255,B[2]=o>>>16&255,B[3]=o>>>24&255,e.check=G(e.check,B,4,0)),o=0,l=0,e.mode=xs;case xs:for(;l<16;){if(h===0)break e;h--,o+=s[n++]<>8),e.flags&512&&(B[0]=o&255,B[1]=o>>>8&255,e.check=G(e.check,B,2,0)),o=0,l=0,e.mode=ps;case ps:if(e.flags&1024){for(;l<16;){if(h===0)break e;h--,o+=s[n++]<>>8&255,e.check=G(e.check,B,2,0)),o=0,l=0}else e.head&&(e.head.extra=null);e.mode=gs;case gs:if(e.flags&1024&&(d=e.length,d>h&&(d=h),d&&(e.head&&(S=e.head.extra_len-e.length,e.head.extra||(e.head.extra=new Array(e.head.extra_len)),ae(e.head.extra,s,n,d,S)),e.flags&512&&(e.check=G(e.check,s,d,n)),h-=d,n+=d,e.length-=d),e.length))break e;e.length=0,e.mode=bs;case bs:if(e.flags&2048){if(h===0)break e;d=0;do S=s[n+d++],e.head&&S&&e.length<65536&&(e.head.name+=String.fromCharCode(S));while(S&&d>9&1,e.head.done=!0),i.adler=e.check=0,e.mode=pe;break;case vs:for(;l<32;){if(h===0)break e;h--,o+=s[n++]<>>=l&7,l-=l&7,e.mode=bi;break}for(;l<3;){if(h===0)break e;h--,o+=s[n++]<>>=1,l-=1,o&3){case 0:e.mode=Xs;break;case 1:if(bn(e),e.mode=Pt,t===It){o>>>=2,l-=2;break e}break;case 2:e.mode=Fs;break;case 3:i.msg="invalid block type",e.mode=N}o>>>=2,l-=2;break;case Xs:for(o>>>=l&7,l-=l&7;l<32;){if(h===0)break e;h--,o+=s[n++]<>>16^65535)){i.msg="invalid stored block lengths",e.mode=N;break}if(e.length=o&65535,o=0,l=0,e.mode=gi,t===It)break e;case gi:e.mode=Ks;case Ks:if(d=e.length,d){if(d>h&&(d=h),d>_&&(d=_),d===0)break e;ae(r,s,n,d,a),h-=d,n+=d,_-=d,a+=d,e.length-=d;break}e.mode=pe;break;case Fs:for(;l<14;){if(h===0)break e;h--,o+=s[n++]<>>=5,l-=5,e.ndist=(o&31)+1,o>>>=5,l-=5,e.ncode=(o&15)+4,o>>>=4,l-=4,e.nlen>286||e.ndist>30){i.msg="too many length or distance symbols",e.mode=N;break}e.have=0,e.mode=ks;case ks:for(;e.have>>=3,l-=3}for(;e.have<19;)e.lens[te[e.have++]]=0;if(e.lencode=e.lendyn,e.lenbits=7,M={bits:e.lenbits},D=Ae(sn,e.lens,0,19,e.lencode,0,e.work,M),e.lenbits=M.bits,D){i.msg="invalid code lengths set",e.mode=N;break}e.have=0,e.mode=Ss;case Ss:for(;e.have>>24,T=A>>>16&255,E=A&65535,!(k<=l);){if(h===0)break e;h--,o+=s[n++]<>>=k,l-=k,e.lens[e.have++]=E;else{if(E===16){for(z=k+2;l>>=k,l-=k,e.have===0){i.msg="invalid bit length repeat",e.mode=N;break}S=e.lens[e.have-1],d=3+(o&3),o>>>=2,l-=2}else if(E===17){for(z=k+3;l>>=k,l-=k,S=0,d=3+(o&7),o>>>=3,l-=3}else{for(z=k+7;l>>=k,l-=k,S=0,d=11+(o&127),o>>>=7,l-=7}if(e.have+d>e.nlen+e.ndist){i.msg="invalid bit length repeat",e.mode=N;break}for(;d--;)e.lens[e.have++]=S}}if(e.mode===N)break;if(e.lens[256]===0){i.msg="invalid code -- missing end-of-block",e.mode=N;break}if(e.lenbits=9,M={bits:e.lenbits},D=Ae(Is,e.lens,0,e.nlen,e.lencode,0,e.work,M),e.lenbits=M.bits,D){i.msg="invalid literal/lengths set",e.mode=N;break}if(e.distbits=6,e.distcode=e.distdyn,M={bits:e.distbits},D=Ae(Ps,e.lens,e.nlen,e.ndist,e.distcode,0,e.work,M),e.distbits=M.bits,D){i.msg="invalid distances set",e.mode=N;break}if(e.mode=Pt,t===It)break e;case Pt:e.mode=Ht;case Ht:if(h>=6&&_>=258){i.next_out=a,i.avail_out=_,i.next_in=n,i.avail_in=h,e.hold=o,e.bits=l,di(i,u),a=i.next_out,r=i.output,_=i.avail_out,n=i.next_in,s=i.input,h=i.avail_in,o=e.hold,l=e.bits,e.mode===pe&&(e.back=-1);break}for(e.back=0;A=e.lencode[o&(1<>>24,T=A>>>16&255,E=A&65535,!(k<=l);){if(h===0)break e;h--,o+=s[n++]<>K)],k=A>>>24,T=A>>>16&255,E=A&65535,!(K+k<=l);){if(h===0)break e;h--,o+=s[n++]<>>=K,l-=K,e.back+=K}if(o>>>=k,l-=k,e.back+=k,e.length=E,T===0){e.mode=Ts;break}if(T&32){e.back=-1,e.mode=pe;break}if(T&64){i.msg="invalid literal/length code",e.mode=N;break}e.extra=T&15,e.mode=Es;case Es:if(e.extra){for(z=e.extra;l>>=e.extra,l-=e.extra,e.back+=e.extra}e.was=e.length,e.mode=As;case As:for(;A=e.distcode[o&(1<>>24,T=A>>>16&255,E=A&65535,!(k<=l);){if(h===0)break e;h--,o+=s[n++]<>K)],k=A>>>24,T=A>>>16&255,E=A&65535,!(K+k<=l);){if(h===0)break e;h--,o+=s[n++]<>>=K,l-=K,e.back+=K}if(o>>>=k,l-=k,e.back+=k,T&64){i.msg="invalid distance code",e.mode=N;break}e.offset=E,e.extra=T&15,e.mode=Cs;case Cs:if(e.extra){for(z=e.extra;l>>=e.extra,l-=e.extra,e.back+=e.extra}if(e.offset>e.dmax){i.msg="invalid distance too far back",e.mode=N;break}e.mode=Ls;case Ls:if(_===0)break e;if(d=u-_,e.offset>d){if(d=e.offset-d,d>e.whave&&e.sane){i.msg="invalid distance too far back",e.mode=N;break}d>e.wnext?(d-=e.wnext,X=e.wsize-d):X=e.wnext-d,d>e.length&&(d=e.length),F=e.window}else F=r,X=a-e.offset,d=e.length;d>_&&(d=_),_-=d,e.length-=d;do r[a++]=F[X++];while(--d);e.length===0&&(e.mode=Ht);break;case Ts:if(_===0)break e;r[a++]=e.length,_--,e.mode=Ht;break;case bi:if(e.wrap){for(;l<32;){if(h===0)break e;h--,o|=s[n++]<this.chunkSize&&(this.chunkSize=t,this.strm.output=new Uint8Array(this.chunkSize)),this.strm.next_out=0,this.strm.avail_out=t,Ns(this.strm,0)<0)throw new Error("zlib inflate failed");if(this.strm.next_out!=t)throw new Error("Incomplete zlib block");return new Uint8Array(this.strm.output.buffer,0,this.strm.next_out)}reset(){vi(this.strm)}};var wn=4,Os=0,zs=1,vn=2;function We(i){for(var t=i.length;--t>=0;)i[t]=0}var yn=0,js=1,Xn=2,Kn=3,Fn=258,Ei=29,ft=256,ht=ft+1+Ei,Ve=30,Ai=19,qs=2*ht+1,Le=15,yi=16,kn=7,Ci=256,Js=16,$s=17,er=18,ki=[0,0,0,0,0,0,0,0,1,1,1,1,2,2,2,2,3,3,3,3,4,4,4,4,5,5,5,5,0],Nt=[0,0,0,0,1,1,2,2,3,3,4,4,5,5,6,6,7,7,8,8,9,9,10,10,11,11,12,12,13,13],Sn=[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,2,3,7],tr=[16,17,18,0,8,7,9,6,10,5,11,4,12,3,13,2,14,1,15],En=512,ge=new Array((ht+2)*2);We(ge);var ot=new Array(Ve*2);We(ot);var lt=new Array(En);We(lt);var ct=new Array(Fn-Kn+1);We(ct);var Li=new Array(Ei);We(Li);var Ot=new Array(Ve);We(Ot);function Xi(i,t,e,s,r){this.static_tree=i,this.extra_bits=t,this.extra_base=e,this.elems=s,this.max_length=r,this.has_stree=i&&i.length}var ir,sr,rr;function Ki(i,t){this.dyn_tree=i,this.max_code=0,this.stat_desc=t}function nr(i){return i<256?lt[i]:lt[256+(i>>>7)]}function _t(i,t){i.pending_buf[i.pending++]=t&255,i.pending_buf[i.pending++]=t>>>8&255}function re(i,t,e){i.bi_valid>yi-e?(i.bi_buf|=t<>yi-i.bi_valid,i.bi_valid+=e-yi):(i.bi_buf|=t<>>=1,e<<=1;while(--t>0);return e>>>1}function An(i){i.bi_valid===16?(_t(i,i.bi_buf),i.bi_buf=0,i.bi_valid=0):i.bi_valid>=8&&(i.pending_buf[i.pending++]=i.bi_buf&255,i.bi_buf>>=8,i.bi_valid-=8)}function Cn(i,t){var e=t.dyn_tree,s=t.max_code,r=t.stat_desc.static_tree,n=t.stat_desc.has_stree,a=t.stat_desc.extra_bits,h=t.stat_desc.extra_base,_=t.stat_desc.max_length,o,l,x,u,d,X,F=0;for(u=0;u<=Le;u++)i.bl_count[u]=0;for(e[i.heap[i.heap_max]*2+1]=0,o=i.heap_max+1;o_&&(u=_,F++),e[l*2+1]=u,!(l>s)&&(i.bl_count[u]++,d=0,l>=h&&(d=a[l-h]),X=e[l*2],i.opt_len+=X*(u+d),n&&(i.static_len+=X*(r[l*2+1]+d)));if(F!==0){do{for(u=_-1;i.bl_count[u]===0;)u--;i.bl_count[u]--,i.bl_count[u+1]+=2,i.bl_count[_]--,F-=2}while(F>0);for(u=_;u!==0;u--)for(l=i.bl_count[u];l!==0;)x=i.heap[--o],!(x>s)&&(e[x*2+1]!==u&&(i.opt_len+=(u-e[x*2+1])*e[x*2],e[x*2+1]=u),l--)}}function or(i,t,e){var s=new Array(Le+1),r=0,n,a;for(n=1;n<=Le;n++)s[n]=r=r+e[n-1]<<1;for(a=0;a<=t;a++){var h=i[a*2+1];h!==0&&(i[a*2]=ar(s[h]++,h))}}function Ln(){var i,t,e,s,r,n=new Array(Le+1);for(e=0,s=0;s>=7;s8?_t(i,i.bi_buf):i.bi_valid>0&&(i.pending_buf[i.pending++]=i.bi_buf),i.bi_buf=0,i.bi_valid=0}function Tn(i,t,e,s){lr(i),s&&(_t(i,e),_t(i,~e)),ae(i.pending_buf,i.window,t,e,i.pending),i.pending+=e}function Gs(i,t,e,s){var r=t*2,n=e*2;return i[r]>1;a>=1;a--)Fi(i,e,a);o=n;do a=i.heap[1],i.heap[1]=i.heap[i.heap_len--],Fi(i,e,1),h=i.heap[1],i.heap[--i.heap_max]=a,i.heap[--i.heap_max]=h,e[o*2]=e[a*2]+e[h*2],i.depth[o]=(i.depth[a]>=i.depth[h]?i.depth[a]:i.depth[h])+1,e[a*2+1]=e[h*2+1]=o,i.heap[1]=o++,Fi(i,e,1);while(i.heap_len>=2);i.heap[--i.heap_max]=i.heap[1],Cn(i,t),or(e,_,i.bl_count)}function Ws(i,t,e){var s,r=-1,n,a=t[0*2+1],h=0,_=7,o=4;for(a===0&&(_=138,o=3),t[(e+1)*2+1]=65535,s=0;s<=e;s++)n=a,a=t[(s+1)*2+1],!(++h<_&&n===a)&&(h=3&&i.bl_tree[tr[t]*2+1]===0;t--);return i.opt_len+=3*(t+1)+5+5+4,t}function Rn(i,t,e,s){var r;for(re(i,t-257,5),re(i,e-1,5),re(i,s-4,4),r=0;r>>=1)if(t&1&&i.dyn_ltree[e*2]!==0)return Os;if(i.dyn_ltree[9*2]!==0||i.dyn_ltree[10*2]!==0||i.dyn_ltree[13*2]!==0)return zs;for(e=32;e0?(i.strm.data_type===vn&&(i.strm.data_type=Bn(i)),Si(i,i.l_desc),Si(i,i.d_desc),a=Mn(i),r=i.opt_len+3+7>>>3,n=i.static_len+3+7>>>3,n<=r&&(r=n)):r=n=e+5,e+4<=r&&t!==-1?Ti(i,t,e,s):i.strategy===wn||n===r?(re(i,(js<<1)+(s?1:0),3),Vs(i,ge,ot)):(re(i,(Xn<<1)+(s?1:0),3),Rn(i,i.l_desc.max_code+1,i.d_desc.max_code+1,a+1),Vs(i,i.dyn_ltree,i.dyn_dtree)),hr(i),s&&lr(i)}function be(i,t,e){return i.pending_buf[i.d_buf+i.last_lit*2]=t>>>8&255,i.pending_buf[i.d_buf+i.last_lit*2+1]=t&255,i.pending_buf[i.l_buf+i.last_lit]=e&255,i.last_lit++,t===0?i.dyn_ltree[e*2]++:(i.matches++,t--,i.dyn_ltree[(ct[e]+ft+1)*2]++,i.dyn_dtree[nr(t)*2]++),i.last_lit===i.lit_bufsize-1}var ur={2:"need dictionary",1:"stream end",0:"","-1":"file error","-2":"stream error","-3":"data error","-4":"insufficient memory","-5":"buffer error","-6":"incompatible version"};var Be=0,Qn=1,Vt=3,ye=4,dr=5,Te=0,xr=1,qe=-2;var Mi=-5,Pi=-1,In=1,zt=2,Pn=3,Hn=4,Un=0,Nn=2,Wt=8,On=9,zn=15,Gn=8,Vn=29,Wn=256,Ii=Wn+1+Vn,Zn=30,Yn=19,jn=2*Ii+1,qn=15,P=3,ve=258,ce=ve+P+1,Jn=32,br=42,pr=69,Ri=73,Bi=91,Di=103,dt=113,Gt=666,Y=1,xt=2,Re=3,Je=4,$n=3;function Ye(i,t){return i.msg=ur[t],t}function gr(i){return(i<<1)-(i>4?9:0)}function Me(i){for(var t=i.length;--t>=0;)i[t]=0}function we(i){var t=i.state,e=t.pending;e>i.avail_out&&(e=i.avail_out),e!==0&&(ae(i.output,t.pending_buf,t.pending_out,e,i.next_out),i.next_out+=e,t.pending_out+=e,i.total_out+=e,i.avail_out-=e,t.pending-=e,t.pending===0&&(t.pending_out=0))}function j(i,t){fr(i,i.block_start>=0?i.block_start:-1,i.strstart-i.block_start,t),i.block_start=i.strstart,we(i.strm)}function Q(i,t){i.pending_buf[i.pending++]=t}function ut(i,t){i.pending_buf[i.pending++]=t>>>8&255,i.pending_buf[i.pending++]=t&255}function ea(i,t,e,s){var r=i.avail_in;return r>s&&(r=s),r===0?0:(i.avail_in-=r,ae(t,i.input,i.next_in,r,e),i.state.wrap===1?i.adler=ze(i.adler,t,r,e):i.state.wrap===2&&(i.adler=G(i.adler,t,r,e)),i.next_in+=r,i.total_in+=r,r)}function mr(i,t){var e=i.max_chain_length,s=i.strstart,r,n,a=i.prev_length,h=i.nice_match,_=i.strstart>i.w_size-ce?i.strstart-(i.w_size-ce):0,o=i.window,l=i.w_mask,x=i.prev,u=i.strstart+ve,d=o[s+a-1],X=o[s+a];i.prev_length>=i.good_match&&(e>>=2),h>i.lookahead&&(h=i.lookahead);do if(r=t,!(o[r+a]!==X||o[r+a-1]!==d||o[r]!==o[s]||o[++r]!==o[s+1])){s+=2,r++;do;while(o[++s]===o[++r]&&o[++s]===o[++r]&&o[++s]===o[++r]&&o[++s]===o[++r]&&o[++s]===o[++r]&&o[++s]===o[++r]&&o[++s]===o[++r]&&o[++s]===o[++r]&&sa){if(i.match_start=t,a=n,n>=h)break;d=o[s+a-1],X=o[s+a]}}while((t=x[t&l])>_&&--e!==0);return a<=i.lookahead?a:i.lookahead}function pt(i){var t=i.w_size,e,s,r,n,a;do{if(n=i.window_size-i.lookahead-i.strstart,i.strstart>=t+(t-ce)){ae(i.window,i.window,t,t,0),i.match_start-=t,i.strstart-=t,i.block_start-=t,s=i.hash_size,e=s;do r=i.head[--e],i.head[e]=r>=t?r-t:0;while(--s);s=t,e=s;do r=i.prev[--e],i.prev[e]=r>=t?r-t:0;while(--s);n+=t}if(i.strm.avail_in===0)break;if(s=ea(i.strm,i.window,i.strstart+i.lookahead,n),i.lookahead+=s,i.lookahead+i.insert>=P)for(a=i.strstart-i.insert,i.ins_h=i.window[a],i.ins_h=(i.ins_h<i.pending_buf_size-5&&(e=i.pending_buf_size-5);;){if(i.lookahead<=1){if(pt(i),i.lookahead===0&&t===Be)return Y;if(i.lookahead===0)break}i.strstart+=i.lookahead,i.lookahead=0;var s=i.block_start+e;if((i.strstart===0||i.strstart>=s)&&(i.lookahead=i.strstart-s,i.strstart=s,j(i,!1),i.strm.avail_out===0)||i.strstart-i.block_start>=i.w_size-ce&&(j(i,!1),i.strm.avail_out===0))return Y}return i.insert=0,t===ye?(j(i,!0),i.strm.avail_out===0?Re:Je):(i.strstart>i.block_start&&(j(i,!1),i.strm.avail_out===0),Y)}function Qi(i,t){for(var e,s;;){if(i.lookahead=P&&(i.ins_h=(i.ins_h<=P)if(s=be(i,i.strstart-i.match_start,i.match_length-P),i.lookahead-=i.match_length,i.match_length<=i.max_lazy_match&&i.lookahead>=P){i.match_length--;do i.strstart++,i.ins_h=(i.ins_h<=P&&(i.ins_h=(i.ins_h<4096)&&(i.match_length=P-1)),i.prev_length>=P&&i.match_length<=i.prev_length){r=i.strstart+i.lookahead-P,s=be(i,i.strstart-1-i.prev_match,i.prev_length-P),i.lookahead-=i.prev_length-1,i.prev_length-=2;do++i.strstart<=r&&(i.ins_h=(i.ins_h<=P&&i.strstart>0&&(r=i.strstart-1,s=a[r],s===a[++r]&&s===a[++r]&&s===a[++r])){n=i.strstart+ve;do;while(s===a[++r]&&s===a[++r]&&s===a[++r]&&s===a[++r]&&s===a[++r]&&s===a[++r]&&s===a[++r]&&s===a[++r]&&ri.lookahead&&(i.match_length=i.lookahead)}if(i.match_length>=P?(e=be(i,1,i.match_length-P),i.lookahead-=i.match_length,i.strstart+=i.match_length,i.match_length=0):(e=be(i,0,i.window[i.strstart]),i.lookahead--,i.strstart++),e&&(j(i,!1),i.strm.avail_out===0))return Y}return i.insert=0,t===ye?(j(i,!0),i.strm.avail_out===0?Re:Je):i.last_lit&&(j(i,!1),i.strm.avail_out===0)?Y:xt}function sa(i,t){for(var e;;){if(i.lookahead===0&&(pt(i),i.lookahead===0)){if(t===Be)return Y;break}if(i.match_length=0,e=be(i,0,i.window[i.strstart]),i.lookahead--,i.strstart++,e&&(j(i,!1),i.strm.avail_out===0))return Y}return i.insert=0,t===ye?(j(i,!0),i.strm.avail_out===0?Re:Je):i.last_lit&&(j(i,!1),i.strm.avail_out===0)?Y:xt}function ue(i,t,e,s,r){this.good_length=i,this.max_lazy=t,this.nice_length=e,this.max_chain=s,this.func=r}var je;je=[new ue(0,0,0,0,ta),new ue(4,4,8,4,Qi),new ue(4,5,16,8,Qi),new ue(4,6,32,32,Qi),new ue(4,4,16,16,Ze),new ue(8,16,32,32,Ze),new ue(8,16,128,128,Ze),new ue(8,32,128,256,Ze),new ue(32,128,258,1024,Ze),new ue(32,258,258,4096,Ze)];function ra(i){i.window_size=2*i.w_size,Me(i.head),i.max_lazy_match=je[i.level].max_lazy,i.good_match=je[i.level].good_length,i.nice_match=je[i.level].nice_length,i.max_chain_length=je[i.level].max_chain,i.strstart=0,i.block_start=0,i.lookahead=0,i.insert=0,i.match_length=i.prev_length=P-1,i.match_available=0,i.ins_h=0}function na(){this.strm=null,this.status=0,this.pending_buf=null,this.pending_buf_size=0,this.pending_out=0,this.pending=0,this.wrap=0,this.gzhead=null,this.gzindex=0,this.method=Wt,this.last_flush=-1,this.w_size=0,this.w_bits=0,this.w_mask=0,this.window=null,this.window_size=0,this.prev=null,this.head=null,this.ins_h=0,this.hash_size=0,this.hash_bits=0,this.hash_mask=0,this.hash_shift=0,this.block_start=0,this.match_length=0,this.prev_match=0,this.match_available=0,this.strstart=0,this.match_start=0,this.lookahead=0,this.prev_length=0,this.max_chain_length=0,this.max_lazy_match=0,this.level=0,this.strategy=0,this.good_match=0,this.nice_match=0,this.dyn_ltree=new ie(jn*2),this.dyn_dtree=new ie((2*Zn+1)*2),this.bl_tree=new ie((2*Yn+1)*2),Me(this.dyn_ltree),Me(this.dyn_dtree),Me(this.bl_tree),this.l_desc=null,this.d_desc=null,this.bl_desc=null,this.bl_count=new ie(qn+1),this.heap=new ie(2*Ii+1),Me(this.heap),this.heap_len=0,this.heap_max=0,this.depth=new ie(2*Ii+1),Me(this.depth),this.l_buf=0,this.lit_bufsize=0,this.last_lit=0,this.d_buf=0,this.opt_len=0,this.static_len=0,this.matches=0,this.insert=0,this.bi_buf=0,this.bi_valid=0}function aa(i){var t;return!i||!i.state?Ye(i,qe):(i.total_in=i.total_out=0,i.data_type=Nn,t=i.state,t.pending=0,t.pending_out=0,t.wrap<0&&(t.wrap=-t.wrap),t.status=t.wrap?br:dt,i.adler=t.wrap===2?0:1,t.last_flush=Be,cr(t),Te)}function oa(i){var t=aa(i);return t===Te&&ra(i.state),t}function ha(i,t,e,s,r,n){if(!i)return qe;var a=1;if(t===Pi&&(t=6),s<0?(a=0,s=-s):s>15&&(a=2,s-=16),r<1||r>On||e!==Wt||s<8||s>15||t<0||t>9||n<0||n>Hn)return Ye(i,qe);s===8&&(s=9);var h=new na;return i.state=h,h.strm=i,h.wrap=a,h.gzhead=null,h.w_bits=s,h.w_size=1<dr||t<0)return i?Ye(i,qe):qe;if(s=i.state,!i.output||!i.input&&i.avail_in!==0||s.status===Gt&&t!==ye)return Ye(i,i.avail_out===0?Mi:qe);if(s.strm=i,e=s.last_flush,s.last_flush=t,s.status===br)if(s.wrap===2)i.adler=0,Q(s,31),Q(s,139),Q(s,8),s.gzhead?(Q(s,(s.gzhead.text?1:0)+(s.gzhead.hcrc?2:0)+(s.gzhead.extra?4:0)+(s.gzhead.name?8:0)+(s.gzhead.comment?16:0)),Q(s,s.gzhead.time&255),Q(s,s.gzhead.time>>8&255),Q(s,s.gzhead.time>>16&255),Q(s,s.gzhead.time>>24&255),Q(s,s.level===9?2:s.strategy>=zt||s.level<2?4:0),Q(s,s.gzhead.os&255),s.gzhead.extra&&s.gzhead.extra.length&&(Q(s,s.gzhead.extra.length&255),Q(s,s.gzhead.extra.length>>8&255)),s.gzhead.hcrc&&(i.adler=G(i.adler,s.pending_buf,s.pending,0)),s.gzindex=0,s.status=pr):(Q(s,0),Q(s,0),Q(s,0),Q(s,0),Q(s,0),Q(s,s.level===9?2:s.strategy>=zt||s.level<2?4:0),Q(s,$n),s.status=dt);else{var a=Wt+(s.w_bits-8<<4)<<8,h=-1;s.strategy>=zt||s.level<2?h=0:s.level<6?h=1:s.level===6?h=2:h=3,a|=h<<6,s.strstart!==0&&(a|=Jn),a+=31-a%31,s.status=dt,ut(s,a),s.strstart!==0&&(ut(s,i.adler>>>16),ut(s,i.adler&65535)),i.adler=1}if(s.status===pr)if(s.gzhead.extra){for(r=s.pending;s.gzindex<(s.gzhead.extra.length&65535)&&!(s.pending===s.pending_buf_size&&(s.gzhead.hcrc&&s.pending>r&&(i.adler=G(i.adler,s.pending_buf,s.pending-r,r)),we(i),r=s.pending,s.pending===s.pending_buf_size));)Q(s,s.gzhead.extra[s.gzindex]&255),s.gzindex++;s.gzhead.hcrc&&s.pending>r&&(i.adler=G(i.adler,s.pending_buf,s.pending-r,r)),s.gzindex===s.gzhead.extra.length&&(s.gzindex=0,s.status=Ri)}else s.status=Ri;if(s.status===Ri)if(s.gzhead.name){r=s.pending;do{if(s.pending===s.pending_buf_size&&(s.gzhead.hcrc&&s.pending>r&&(i.adler=G(i.adler,s.pending_buf,s.pending-r,r)),we(i),r=s.pending,s.pending===s.pending_buf_size)){n=1;break}s.gzindexr&&(i.adler=G(i.adler,s.pending_buf,s.pending-r,r)),n===0&&(s.gzindex=0,s.status=Bi)}else s.status=Bi;if(s.status===Bi)if(s.gzhead.comment){r=s.pending;do{if(s.pending===s.pending_buf_size&&(s.gzhead.hcrc&&s.pending>r&&(i.adler=G(i.adler,s.pending_buf,s.pending-r,r)),we(i),r=s.pending,s.pending===s.pending_buf_size)){n=1;break}s.gzindexr&&(i.adler=G(i.adler,s.pending_buf,s.pending-r,r)),n===0&&(s.status=Di)}else s.status=Di;if(s.status===Di&&(s.gzhead.hcrc?(s.pending+2>s.pending_buf_size&&we(i),s.pending+2<=s.pending_buf_size&&(Q(s,i.adler&255),Q(s,i.adler>>8&255),i.adler=0,s.status=dt)):s.status=dt),s.pending!==0){if(we(i),i.avail_out===0)return s.last_flush=-1,Te}else if(i.avail_in===0&&gr(t)<=gr(e)&&t!==ye)return Ye(i,Mi);if(s.status===Gt&&i.avail_in!==0)return Ye(i,Mi);if(i.avail_in!==0||s.lookahead!==0||t!==Be&&s.status!==Gt){var _=s.strategy===zt?sa(s,t):s.strategy===Pn?ia(s,t):je[s.level].func(s,t);if((_===Re||_===Je)&&(s.status=Gt),_===Y||_===Re)return i.avail_out===0&&(s.last_flush=-1),Te;if(_===xt&&(t===Qn?_r(s):t!==dr&&(Ti(s,0,0,!1),t===Vt&&(Me(s.head),s.lookahead===0&&(s.strstart=0,s.block_start=0,s.insert=0))),we(i),i.avail_out===0))return s.last_flush=-1,Te}return t!==ye?Te:s.wrap<=0?xr:(s.wrap===2?(Q(s,i.adler&255),Q(s,i.adler>>8&255),Q(s,i.adler>>16&255),Q(s,i.adler>>24&255),Q(s,i.total_in&255),Q(s,i.total_in>>8&255),Q(s,i.total_in>>16&255),Q(s,i.total_in>>24&255)):(ut(s,i.adler>>>16),ut(s,i.adler&65535)),we(i),s.wrap>0&&(s.wrap=-s.wrap),s.pending!==0?Te:xr)}var gt=class{constructor(){this.strm=new at,this.chunkSize=1024*10*10,this.outputBuffer=new Uint8Array(this.chunkSize),wr(this.strm,Pi)}deflate(t){this.strm.input=t,this.strm.avail_in=this.strm.input.length,this.strm.next_in=0,this.strm.output=this.outputBuffer,this.strm.avail_out=this.chunkSize,this.strm.next_out=0;let e=Hi(this.strm,Vt),s=new Uint8Array(this.strm.output.buffer,0,this.strm.next_out);if(e<0)throw new Error("zlib deflate failed");if(this.strm.avail_in>0){let r=[s],n=s.length;do{if(this.strm.output=new Uint8Array(this.chunkSize),this.strm.next_out=0,this.strm.avail_out=this.chunkSize,e=Hi(this.strm,Vt),e<0)throw new Error("zlib deflate failed");let _=new Uint8Array(this.strm.output.buffer,0,this.strm.next_out);n+=_.length,r.push(_)}while(this.strm.avail_in>0);let a=new Uint8Array(n),h=0;for(let _=0;_=32&&i<=255)return i;let t=la[i];return t!==void 0?t:16777216|i}};var Ui={8:"Backspace",9:"Tab",10:"NumpadClear",13:"Enter",16:"ShiftLeft",17:"ControlLeft",18:"AltLeft",19:"Pause",20:"CapsLock",21:"Lang1",25:"Lang2",27:"Escape",28:"Convert",29:"NonConvert",32:"Space",33:"PageUp",34:"PageDown",35:"End",36:"Home",37:"ArrowLeft",38:"ArrowUp",39:"ArrowRight",40:"ArrowDown",41:"Select",44:"PrintScreen",45:"Insert",46:"Delete",47:"Help",48:"Digit0",49:"Digit1",50:"Digit2",51:"Digit3",52:"Digit4",53:"Digit5",54:"Digit6",55:"Digit7",56:"Digit8",57:"Digit9",91:"MetaLeft",92:"MetaRight",93:"ContextMenu",95:"Sleep",96:"Numpad0",97:"Numpad1",98:"Numpad2",99:"Numpad3",100:"Numpad4",101:"Numpad5",102:"Numpad6",103:"Numpad7",104:"Numpad8",105:"Numpad9",106:"NumpadMultiply",107:"NumpadAdd",108:"NumpadDecimal",109:"NumpadSubtract",110:"NumpadDecimal",111:"NumpadDivide",112:"F1",113:"F2",114:"F3",115:"F4",116:"F5",117:"F6",118:"F7",119:"F8",120:"F9",121:"F10",122:"F11",123:"F12",124:"F13",125:"F14",126:"F15",127:"F16",128:"F17",129:"F18",130:"F19",131:"F20",132:"F21",133:"F22",134:"F23",135:"F24",144:"NumLock",145:"ScrollLock",166:"BrowserBack",167:"BrowserForward",168:"BrowserRefresh",169:"BrowserStop",170:"BrowserSearch",171:"BrowserFavorites",172:"BrowserHome",173:"AudioVolumeMute",174:"AudioVolumeDown",175:"AudioVolumeUp",176:"MediaTrackNext",177:"MediaTrackPrevious",178:"MediaStop",179:"MediaPlayPause",180:"LaunchMail",181:"MediaSelect",182:"LaunchApp1",183:"LaunchApp2",225:"AltRight"};var Ni={Backspace:"Backspace",AltLeft:"Alt",AltRight:"Alt",CapsLock:"CapsLock",ContextMenu:"ContextMenu",ControlLeft:"Control",ControlRight:"Control",Enter:"Enter",MetaLeft:"Meta",MetaRight:"Meta",ShiftLeft:"Shift",ShiftRight:"Shift",Tab:"Tab",Delete:"Delete",End:"End",Help:"Help",Home:"Home",Insert:"Insert",PageDown:"PageDown",PageUp:"PageUp",ArrowDown:"ArrowDown",ArrowLeft:"ArrowLeft",ArrowRight:"ArrowRight",ArrowUp:"ArrowUp",NumLock:"NumLock",NumpadBackspace:"Backspace",NumpadClear:"Clear",Escape:"Escape",F1:"F1",F2:"F2",F3:"F3",F4:"F4",F5:"F5",F6:"F6",F7:"F7",F8:"F8",F9:"F9",F10:"F10",F11:"F11",F12:"F12",F13:"F13",F14:"F14",F15:"F15",F16:"F16",F17:"F17",F18:"F18",F19:"F19",F20:"F20",F21:"F21",F22:"F22",F23:"F23",F24:"F24",F25:"F25",F26:"F26",F27:"F27",F28:"F28",F29:"F29",F30:"F30",F31:"F31",F32:"F32",F33:"F33",F34:"F34",F35:"F35",PrintScreen:"PrintScreen",ScrollLock:"ScrollLock",Pause:"Pause",BrowserBack:"BrowserBack",BrowserFavorites:"BrowserFavorites",BrowserForward:"BrowserForward",BrowserHome:"BrowserHome",BrowserRefresh:"BrowserRefresh",BrowserSearch:"BrowserSearch",BrowserStop:"BrowserStop",Eject:"Eject",LaunchApp1:"LaunchMyComputer",LaunchApp2:"LaunchCalendar",LaunchMail:"LaunchMail",MediaPlayPause:"MediaPlay",MediaStop:"MediaStop",MediaTrackNext:"MediaTrackNext",MediaTrackPrevious:"MediaTrackPrevious",Power:"Power",Sleep:"Sleep",AudioVolumeDown:"AudioVolumeDown",AudioVolumeMute:"AudioVolumeMute",AudioVolumeUp:"AudioVolumeUp",WakeUp:"WakeUp"};var De={};function w(i,t){if(t===void 0)throw new Error('Undefined keysym for key "'+i+'"');if(i in De)throw new Error('Duplicate entry for key "'+i+'"');De[i]=[t,t,t,t]}function Zt(i,t,e){if(t===void 0)throw new Error('Undefined keysym for key "'+i+'"');if(e===void 0)throw new Error('Undefined keysym for key "'+i+'"');if(i in De)throw new Error('Duplicate entry for key "'+i+'"');De[i]=[t,t,e,t]}function H(i,t,e){if(t===void 0)throw new Error('Undefined keysym for key "'+i+'"');if(e===void 0)throw new Error('Undefined keysym for key "'+i+'"');if(i in De)throw new Error('Duplicate entry for key "'+i+'"');De[i]=[t,t,t,e]}Zt("Alt",f.XK_Alt_L,f.XK_Alt_R);w("AltGraph",f.XK_ISO_Level3_Shift);w("CapsLock",f.XK_Caps_Lock);Zt("Control",f.XK_Control_L,f.XK_Control_R);Zt("Meta",f.XK_Super_L,f.XK_Super_R);w("NumLock",f.XK_Num_Lock);w("ScrollLock",f.XK_Scroll_Lock);Zt("Shift",f.XK_Shift_L,f.XK_Shift_R);H("Enter",f.XK_Return,f.XK_KP_Enter);w("Tab",f.XK_Tab);H(" ",f.XK_space,f.XK_KP_Space);H("ArrowDown",f.XK_Down,f.XK_KP_Down);H("ArrowLeft",f.XK_Left,f.XK_KP_Left);H("ArrowRight",f.XK_Right,f.XK_KP_Right);H("ArrowUp",f.XK_Up,f.XK_KP_Up);H("End",f.XK_End,f.XK_KP_End);H("Home",f.XK_Home,f.XK_KP_Home);H("PageDown",f.XK_Next,f.XK_KP_Next);H("PageUp",f.XK_Prior,f.XK_KP_Prior);w("Backspace",f.XK_BackSpace);H("Clear",f.XK_Clear,f.XK_KP_Begin);w("Copy",f.XF86XK_Copy);w("Cut",f.XF86XK_Cut);H("Delete",f.XK_Delete,f.XK_KP_Delete);H("Insert",f.XK_Insert,f.XK_KP_Insert);w("Paste",f.XF86XK_Paste);w("Redo",f.XK_Redo);w("Undo",f.XK_Undo);w("Cancel",f.XK_Cancel);w("ContextMenu",f.XK_Menu);w("Escape",f.XK_Escape);w("Execute",f.XK_Execute);w("Find",f.XK_Find);w("Help",f.XK_Help);w("Pause",f.XK_Pause);w("Select",f.XK_Select);w("ZoomIn",f.XF86XK_ZoomIn);w("ZoomOut",f.XF86XK_ZoomOut);w("BrightnessDown",f.XF86XK_MonBrightnessDown);w("BrightnessUp",f.XF86XK_MonBrightnessUp);w("Eject",f.XF86XK_Eject);w("LogOff",f.XF86XK_LogOff);w("Power",f.XF86XK_PowerOff);w("PowerOff",f.XF86XK_PowerDown);w("PrintScreen",f.XK_Print);w("Hibernate",f.XF86XK_Hibernate);w("Standby",f.XF86XK_Standby);w("WakeUp",f.XF86XK_WakeUp);w("AllCandidates",f.XK_MultipleCandidate);w("Alphanumeric",f.XK_Eisu_toggle);w("CodeInput",f.XK_Codeinput);w("Compose",f.XK_Multi_key);w("Convert",f.XK_Henkan);w("GroupFirst",f.XK_ISO_First_Group);w("GroupLast",f.XK_ISO_Last_Group);w("GroupNext",f.XK_ISO_Next_Group);w("GroupPrevious",f.XK_ISO_Prev_Group);w("NonConvert",f.XK_Muhenkan);w("PreviousCandidate",f.XK_PreviousCandidate);w("SingleCandidate",f.XK_SingleCandidate);w("HangulMode",f.XK_Hangul);w("HanjaMode",f.XK_Hangul_Hanja);w("JunjaMode",f.XK_Hangul_Jeonja);w("Eisu",f.XK_Eisu_toggle);w("Hankaku",f.XK_Hankaku);w("Hiragana",f.XK_Hiragana);w("HiraganaKatakana",f.XK_Hiragana_Katakana);w("KanaMode",f.XK_Kana_Shift);w("KanjiMode",f.XK_Kanji);w("Katakana",f.XK_Katakana);w("Romaji",f.XK_Romaji);w("Zenkaku",f.XK_Zenkaku);w("ZenkakuHankaku",f.XK_Zenkaku_Hankaku);w("F1",f.XK_F1);w("F2",f.XK_F2);w("F3",f.XK_F3);w("F4",f.XK_F4);w("F5",f.XK_F5);w("F6",f.XK_F6);w("F7",f.XK_F7);w("F8",f.XK_F8);w("F9",f.XK_F9);w("F10",f.XK_F10);w("F11",f.XK_F11);w("F12",f.XK_F12);w("F13",f.XK_F13);w("F14",f.XK_F14);w("F15",f.XK_F15);w("F16",f.XK_F16);w("F17",f.XK_F17);w("F18",f.XK_F18);w("F19",f.XK_F19);w("F20",f.XK_F20);w("F21",f.XK_F21);w("F22",f.XK_F22);w("F23",f.XK_F23);w("F24",f.XK_F24);w("F25",f.XK_F25);w("F26",f.XK_F26);w("F27",f.XK_F27);w("F28",f.XK_F28);w("F29",f.XK_F29);w("F30",f.XK_F30);w("F31",f.XK_F31);w("F32",f.XK_F32);w("F33",f.XK_F33);w("F34",f.XK_F34);w("F35",f.XK_F35);w("Close",f.XF86XK_Close);w("MailForward",f.XF86XK_MailForward);w("MailReply",f.XF86XK_Reply);w("MailSend",f.XF86XK_Send);w("MediaFastForward",f.XF86XK_AudioForward);w("MediaPause",f.XF86XK_AudioPause);w("MediaPlay",f.XF86XK_AudioPlay);w("MediaRecord",f.XF86XK_AudioRecord);w("MediaRewind",f.XF86XK_AudioRewind);w("MediaStop",f.XF86XK_AudioStop);w("MediaTrackNext",f.XF86XK_AudioNext);w("MediaTrackPrevious",f.XF86XK_AudioPrev);w("New",f.XF86XK_New);w("Open",f.XF86XK_Open);w("Print",f.XK_Print);w("Save",f.XF86XK_Save);w("SpellCheck",f.XF86XK_Spell);w("AudioVolumeDown",f.XF86XK_AudioLowerVolume);w("AudioVolumeUp",f.XF86XK_AudioRaiseVolume);w("AudioVolumeMute",f.XF86XK_AudioMute);w("MicrophoneVolumeMute",f.XF86XK_AudioMicMute);w("LaunchApplication1",f.XF86XK_MyComputer);w("LaunchApplication2",f.XF86XK_Calculator);w("LaunchCalendar",f.XF86XK_Calendar);w("LaunchMail",f.XF86XK_Mail);w("LaunchMediaPlayer",f.XF86XK_AudioMedia);w("LaunchMusicPlayer",f.XF86XK_Music);w("LaunchPhone",f.XF86XK_Phone);w("LaunchScreenSaver",f.XF86XK_ScreenSaver);w("LaunchSpreadsheet",f.XF86XK_Excel);w("LaunchWebBrowser",f.XF86XK_WWW);w("LaunchWebCam",f.XF86XK_WebCam);w("LaunchWordProcessor",f.XF86XK_Word);w("BrowserBack",f.XF86XK_Back);w("BrowserFavorites",f.XF86XK_Favorites);w("BrowserForward",f.XF86XK_Forward);w("BrowserHome",f.XF86XK_HomePage);w("BrowserRefresh",f.XF86XK_Refresh);w("BrowserSearch",f.XF86XK_Search);w("BrowserStop",f.XF86XK_Stop);w("Dimmer",f.XF86XK_BrightnessAdjust);w("MediaAudioTrack",f.XF86XK_AudioCycleTrack);w("RandomToggle",f.XF86XK_AudioRandomPlay);w("SplitScreenToggle",f.XF86XK_SplitScreen);w("Subtitle",f.XF86XK_Subtitle);w("VideoModeNext",f.XF86XK_Next_VMode);H("=",f.XK_equal,f.XK_KP_Equal);H("+",f.XK_plus,f.XK_KP_Add);H("-",f.XK_minus,f.XK_KP_Subtract);H("*",f.XK_asterisk,f.XK_KP_Multiply);H("/",f.XK_slash,f.XK_KP_Divide);H(".",f.XK_period,f.XK_KP_Decimal);H(",",f.XK_comma,f.XK_KP_Separator);H("0",f.XK_0,f.XK_KP_0);H("1",f.XK_1,f.XK_KP_1);H("2",f.XK_2,f.XK_KP_2);H("3",f.XK_3,f.XK_KP_3);H("4",f.XK_4,f.XK_KP_4);H("5",f.XK_5,f.XK_KP_5);H("6",f.XK_6,f.XK_KP_6);H("7",f.XK_7,f.XK_KP_7);H("8",f.XK_8,f.XK_KP_8);H("9",f.XK_9,f.XK_KP_9);var Oi=De;function $e(i){if(i.code){switch(i.code){case"OSLeft":return"MetaLeft";case"OSRight":return"MetaRight"}return i.code}if(i.keyCode in Ui){let t=Ui[i.keyCode];if(me()&&t==="ContextMenu"&&(t="MetaRight"),i.location===2)switch(t){case"ShiftLeft":return"ShiftRight";case"ControlLeft":return"ControlRight";case"AltLeft":return"AltRight"}if(i.location===3)switch(t){case"Delete":return"NumpadDecimal";case"Insert":return"Numpad0";case"End":return"Numpad1";case"ArrowDown":return"Numpad2";case"PageDown":return"Numpad3";case"ArrowLeft":return"Numpad4";case"ArrowRight":return"Numpad6";case"Home":return"Numpad7";case"ArrowUp":return"Numpad8";case"PageUp":return"Numpad9";case"Enter":return"NumpadEnter"}return t}return"Unidentified"}function ca(i){if(i.key!==void 0&&i.key!=="Unidentified"){switch(i.key){case"OS":return"Meta";case"LaunchMyComputer":return"LaunchApplication1";case"LaunchCalculator":return"LaunchApplication2"}switch(i.key){case"UIKeyInputUpArrow":return"ArrowUp";case"UIKeyInputDownArrow":return"ArrowDown";case"UIKeyInputLeftArrow":return"ArrowLeft";case"UIKeyInputRightArrow":return"ArrowRight";case"UIKeyInputEscape":return"Escape"}return i.key==="\0"&&i.code==="NumpadDecimal"?"Delete":i.key}let t=$e(i);return t in Ni?Ni[t]:i.charCode?String.fromCharCode(i.charCode):"Unidentified"}function yr(i){let t=ca(i);if(t==="Unidentified")return null;if(t in Oi){let s=i.location;if(t==="Meta"&&s===0&&(s=2),t==="Clear"&&s===3&&$e(i)==="NumLock"&&(s=0),(s===void 0||s>3)&&(s=0),t==="Meta"){let r=$e(i);if(r==="AltLeft")return f.XK_Meta_L;if(r==="AltRight")return f.XK_Meta_R}if(t==="Clear"&&$e(i)==="NumLock")return f.XK_Num_Lock;if(He())switch(t){case"Zenkaku":case"Hankaku":return f.XK_Zenkaku_Hankaku;case"Romaji":case"KanaMode":return f.XK_Romaji}return Oi[t][s]}if(t.length!==1)return null;let e=t.charCodeAt();return e?vr.lookup(e):null}var bt=class{constructor(t){this._target=t||null,this._keyDownList={},this._altGrArmed=!1,this._eventHandlers={keyup:this._handleKeyUp.bind(this),keydown:this._handleKeyDown.bind(this),blur:this._allKeysUp.bind(this)},this.onkeyevent=()=>{}}_sendKeyEvent(t,e,s,r=null,n=null){if(s)this._keyDownList[e]=t;else{if(!(e in this._keyDownList))return;delete this._keyDownList[e]}L("onkeyevent "+(s?"down":"up")+", keysym: "+t,", code: "+e+", numlock: "+r+", capslock: "+n),this.onkeyevent(t,e,s,r,n)}_getKeyCode(t){let e=$e(t);if(e!=="Unidentified")return e;if(t.keyCode&&t.keyCode!==229)return"Platform"+t.keyCode;if(t.keyIdentifier){if(t.keyIdentifier.substr(0,2)!=="U+")return t.keyIdentifier;let s=parseInt(t.keyIdentifier.substr(2),16);return"Platform"+String.fromCharCode(s).toUpperCase().charCodeAt()}return"Unidentified"}_handleKeyDown(t){let e=this._getKeyCode(t),s=yr(t),r=t.getModifierState("NumLock"),n=t.getModifierState("CapsLock");if((me()||Ue())&&(r=null),this._altGrArmed&&(this._altGrArmed=!1,clearTimeout(this._altGrTimeout),e==="AltRight"&&t.timeStamp-this._altGrCtrlTime<50?s=f.XK_ISO_Level3_Shift:this._sendKeyEvent(f.XK_Control_L,"ControlLeft",!0,r,n)),e==="Unidentified"){s&&(this._sendKeyEvent(s,e,!0,r,n),this._sendKeyEvent(s,e,!1,r,n)),Se(t);return}if(me()||Ue())switch(s){case f.XK_Super_L:s=f.XK_Alt_L;break;case f.XK_Super_R:s=f.XK_Super_L;break;case f.XK_Alt_L:s=f.XK_Mode_switch;break;case f.XK_Alt_R:s=f.XK_ISO_Level3_Shift;break}if(e in this._keyDownList&&(s=this._keyDownList[e]),(me()||Ue())&&t.metaKey&&e!=="MetaLeft"&&e!=="MetaRight"){this._sendKeyEvent(s,e,!0,r,n),this._sendKeyEvent(s,e,!1,r,n),Se(t);return}if((me()||Ue())&&e==="CapsLock"){this._sendKeyEvent(f.XK_Caps_Lock,"CapsLock",!0,r,n),this._sendKeyEvent(f.XK_Caps_Lock,"CapsLock",!1,r,n),Se(t);return}let a=[f.XK_Zenkaku_Hankaku,f.XK_Eisu_toggle,f.XK_Katakana,f.XK_Hiragana,f.XK_Romaji];if(He()&&a.includes(s)){this._sendKeyEvent(s,e,!0,r,n),this._sendKeyEvent(s,e,!1,r,n),Se(t);return}if(Se(t),e==="ControlLeft"&&He()&&!("ControlLeft"in this._keyDownList)){this._altGrArmed=!0,this._altGrTimeout=setTimeout(this._interruptAltGrSequence.bind(this),100),this._altGrCtrlTime=t.timeStamp;return}this._sendKeyEvent(s,e,!0,r,n)}_handleKeyUp(t){Se(t);let e=this._getKeyCode(t);if(this._interruptAltGrSequence(),(me()||Ue())&&e==="CapsLock"){this._sendKeyEvent(f.XK_Caps_Lock,"CapsLock",!0),this._sendKeyEvent(f.XK_Caps_Lock,"CapsLock",!1);return}this._sendKeyEvent(this._keyDownList[e],e,!1),He()&&(e==="ShiftLeft"||e==="ShiftRight")&&("ShiftRight"in this._keyDownList&&this._sendKeyEvent(this._keyDownList.ShiftRight,"ShiftRight",!1),"ShiftLeft"in this._keyDownList&&this._sendKeyEvent(this._keyDownList.ShiftLeft,"ShiftLeft",!1))}_interruptAltGrSequence(){this._altGrArmed&&(this._altGrArmed=!1,clearTimeout(this._altGrTimeout),this._sendKeyEvent(f.XK_Control_L,"ControlLeft",!0))}_allKeysUp(){L(">> Keyboard.allKeysUp"),this._interruptAltGrSequence();for(let t in this._keyDownList)this._sendKeyEvent(this._keyDownList[t],t,!1);L("<< Keyboard.allKeysUp")}grab(){this._target.addEventListener("keydown",this._eventHandlers.keydown),this._target.addEventListener("keyup",this._eventHandlers.keyup),window.addEventListener("blur",this._eventHandlers.blur)}ungrab(){this._target.removeEventListener("keydown",this._eventHandlers.keydown),this._target.removeEventListener("keyup",this._eventHandlers.keyup),window.removeEventListener("blur",this._eventHandlers.blur),this._allKeysUp()}};var mt=class{constructor(){this._target=null,this._state=127,this._tracked=[],this._ignored=[],this._waitingRelease=!1,this._releaseStart=0,this._longpressTimeoutId=null,this._twoTouchTimeoutId=null,this._boundEventHandler=this._eventHandler.bind(this)}attach(t){this.detach(),this._target=t,this._target.addEventListener("touchstart",this._boundEventHandler),this._target.addEventListener("touchmove",this._boundEventHandler),this._target.addEventListener("touchend",this._boundEventHandler),this._target.addEventListener("touchcancel",this._boundEventHandler)}detach(){this._target&&(this._stopLongpressTimeout(),this._stopTwoTouchTimeout(),this._target.removeEventListener("touchstart",this._boundEventHandler),this._target.removeEventListener("touchmove",this._boundEventHandler),this._target.removeEventListener("touchend",this._boundEventHandler),this._target.removeEventListener("touchcancel",this._boundEventHandler),this._target=null)}_eventHandler(t){let e;switch(t.stopPropagation(),t.preventDefault(),t.type){case"touchstart":e=this._touchStart;break;case"touchmove":e=this._touchMove;break;case"touchend":case"touchcancel":e=this._touchEnd;break}for(let s=0;s0&&Date.now()-this._tracked[0].started>250){this._state=0,this._ignored.push(t);return}if(this._waitingRelease){this._state=0,this._ignored.push(t);return}switch(this._tracked.push({id:t,started:Date.now(),active:!0,firstX:e,firstY:s,lastX:e,lastY:s,angle:0}),this._tracked.length){case 1:this._startLongpressTimeout();break;case 2:this._state&=-26,this._stopLongpressTimeout();break;case 3:this._state&=-99;break;default:this._state=0}}_touchMove(t,e,s){let r=this._tracked.find(h=>h.id===t);if(r===void 0)return;r.lastX=e,r.lastY=s;let n=e-r.firstX,a=s-r.firstY;if((r.firstX!==r.lastX||r.firstY!==r.lastY)&&(r.angle=Math.atan2(a,n)*180/Math.PI),!this._hasDetectedGesture()){if(Math.hypot(n,a)<50)return;if(this._state&=-24,this._stopLongpressTimeout(),this._tracked.length!==1&&(this._state&=-9),this._tracked.length!==2&&(this._state&=-97),this._tracked.length===2){let h=this._tracked.find(o=>o.id!==t);if(Math.hypot(h.firstX-h.lastX,h.firstY-h.lastY)>50){let o=Math.abs(r.angle-h.angle);o=Math.abs((o+180)%360-180),o>90?this._state&=-33:this._state&=-65,this._isTwoTouchTimeoutRunning()&&this._stopTwoTouchTimeout()}else this._isTwoTouchTimeoutRunning()||this._startTwoTouchTimeout()}if(!this._hasDetectedGesture())return;this._pushEvent("gesturestart")}this._pushEvent("gesturemove")}_touchEnd(t,e,s){if(this._ignored.indexOf(t)!==-1){this._ignored.splice(this._ignored.indexOf(t),1),this._ignored.length===0&&this._tracked.length===0&&(this._state=127,this._waitingRelease=!1);return}if(!this._hasDetectedGesture()&&this._isTwoTouchTimeoutRunning()&&(this._stopTwoTouchTimeout(),this._state=0),!this._hasDetectedGesture()&&(this._state&=-105,this._state&=-17,this._stopLongpressTimeout(),!this._waitingRelease))switch(this._releaseStart=Date.now(),this._waitingRelease=!0,this._tracked.length){case 1:this._state&=-7;break;case 2:this._state&=-6;break}if(this._waitingRelease){Date.now()-this._releaseStart>250&&(this._state=0),this._tracked.some(n=>Date.now()-n.started>1e3)&&(this._state=0);let r=this._tracked.find(n=>n.id===t);if(r.active=!1,this._hasDetectedGesture())this._pushEvent("gesturestart");else if(this._state!==0)return}this._hasDetectedGesture()&&this._pushEvent("gestureend");for(let r=0;rt.active))}_startLongpressTimeout(){this._stopLongpressTimeout(),this._longpressTimeoutId=setTimeout(()=>this._longpressTimeout(),1e3)}_stopLongpressTimeout(){clearTimeout(this._longpressTimeoutId),this._longpressTimeoutId=null}_longpressTimeout(){if(this._hasDetectedGesture())throw new Error("A longpress gesture failed, conflict with a different gesture");this._state=16,this._pushEvent("gesturestart")}_startTwoTouchTimeout(){this._stopTwoTouchTimeout(),this._twoTouchTimeoutId=setTimeout(()=>this._twoTouchTimeout(),50)}_stopTwoTouchTimeout(){clearTimeout(this._twoTouchTimeoutId),this._twoTouchTimeoutId=null}_isTwoTouchTimeoutRunning(){return this._twoTouchTimeoutId!==null}_twoTouchTimeout(){if(this._tracked.length===0)throw new Error("A pinch or two drag gesture failed, no tracked touches");let t=this._getAverageMovement(),e=Math.abs(t.x),s=Math.abs(t.y),r=this._getAverageDistance(),n=Math.abs(Math.hypot(r.first.x,r.first.y)-Math.hypot(r.last.x,r.last.y));s{this._target&&(e=document.elementFromPoint(t.clientX,t.clientY),this._updateVisibility(e))},0)}_showCursor(){this._canvas.style.visibility==="hidden"&&(this._canvas.style.visibility="")}_hideCursor(){this._canvas.style.visibility!=="hidden"&&(this._canvas.style.visibility="hidden")}_shouldShowCursor(t){return t?t===this._target?!0:!(!this._target.contains(t)||window.getComputedStyle(t).cursor!=="none"):!1}_updateVisibility(t){this._captureIsActive()&&(t=document.captureElement),this._shouldShowCursor(t)?this._showCursor():this._hideCursor()}_updatePosition(){this._canvas.style.left=this._position.x+"px",this._canvas.style.top=this._position.y+"px"}_captureIsActive(){return document.captureElement&&document.documentElement.contains(document.captureElement)}};var zi=40*1024*1024,Yt={CONNECTING:"connecting",OPEN:"open",CLOSING:"closing",CLOSED:"closed"},jt={CONNECTING:[WebSocket.CONNECTING,Yt.CONNECTING],OPEN:[WebSocket.OPEN,Yt.OPEN],CLOSING:[WebSocket.CLOSING,Yt.CLOSING],CLOSED:[WebSocket.CLOSED,Yt.CLOSED]},Xr=["send","close","binaryType","onerror","onmessage","onopen","protocol","readyState"],yt=class{constructor(){this._websocket=null,this._rQi=0,this._rQlen=0,this._rQbufferSize=1024*1024*4,this._rQ=null,this._sQbufferSize=1024*10,this._sQlen=0,this._sQ=null,this._eventHandlers={message:()=>{},open:()=>{},close:()=>{},error:()=>{}}}get readyState(){let t;return this._websocket===null?"unused":(t=this._websocket.readyState,jt.CONNECTING.includes(t)?"connecting":jt.OPEN.includes(t)?"open":jt.CLOSING.includes(t)?"closing":jt.CLOSED.includes(t)?"closed":"unknown")}rQpeek8(){return this._rQ[this._rQi]}rQskipBytes(t){this._rQi+=t}rQshift8(){return this._rQshift(1)}rQshift16(){return this._rQshift(2)}rQshift32(){return this._rQshift(4)}_rQshift(t){let e=0;for(let s=t-1;s>=0;s--)e+=this._rQ[this._rQi++]<>>0}rQshiftStr(t){let e="";for(let s=0;s>8&255,this._sQ[this._sQlen++]=t>>0&255}sQpush32(t){this._sQensureSpace(4),this._sQ[this._sQlen++]=t>>24&255,this._sQ[this._sQlen++]=t>>16&255,this._sQ[this._sQlen++]=t>>8&255,this._sQ[this._sQlen++]=t>>0&255}sQpushString(t){let e=t.split("").map(s=>s.charCodeAt(0));this.sQpushBytes(new Uint8Array(e))}sQpushBytes(t){for(let e=0;et.length-e&&(s=t.length-e),this._sQ.set(t.subarray(e,e+s),this._sQlen),this._sQlen+=s,e+=s}}flush(){this._sQlen>0&&this.readyState==="open"&&(this._websocket.send(new Uint8Array(this._sQ.buffer,0,this._sQlen)),this._sQlen=0)}_sQensureSpace(t){this._sQbufferSize-this._sQlen{}}on(t,e){this._eventHandlers[t]=e}_allocateBuffers(){this._rQ=new Uint8Array(this._rQbufferSize),this._sQ=new Uint8Array(this._sQbufferSize)}init(){this._allocateBuffers(),this._rQi=0,this._websocket=null}open(t,e){this.attach(new WebSocket(t,e))}attach(t){this.init();let e=[...Object.keys(t),...Object.getOwnPropertyNames(Object.getPrototypeOf(t))];for(let s=0;s{L(">> WebSock.onopen"),this._websocket.protocol&&V("Server choose sub-protocol: "+this._websocket.protocol),this._eventHandlers.open(),L("<< WebSock.onopen")},this._websocket.onclose=s=>{L(">> WebSock.onclose"),this._eventHandlers.close(s),L("<< WebSock.onclose")},this._websocket.onerror=s=>{L(">> WebSock.onerror: "+s),this._eventHandlers.error(s),L("<< WebSock.onerror: "+s)}}close(){this._websocket&&((this.readyState==="connecting"||this.readyState==="open")&&(V("Closing WebSocket connection"),this._websocket.close()),this._websocket.onmessage=()=>{})}_expandCompactRQ(t){let e=(this._rQlen-this._rQi+t)*8,s=this._rQbufferSizezi&&(this._rQbufferSize=zi,this._rQbufferSize-(this._rQlen-this._rQi)this._rQbufferSize-this._rQlen&&this._expandCompactRQ(e.length),this._rQ.set(e,this._rQlen),this._rQlen+=e.length,this._rQlen-this._rQi>0?this._eventHandlers.message():L("Ignoring empty message")}};var Kr={Again:57349,AltLeft:56,AltRight:57400,ArrowDown:57424,ArrowLeft:57419,ArrowRight:57421,ArrowUp:57416,AudioVolumeDown:57390,AudioVolumeMute:57376,AudioVolumeUp:57392,Backquote:41,Backslash:43,Backspace:14,BracketLeft:26,BracketRight:27,BrowserBack:57450,BrowserFavorites:57446,BrowserForward:57449,BrowserHome:57394,BrowserRefresh:57447,BrowserSearch:57445,BrowserStop:57448,CapsLock:58,Comma:51,ContextMenu:57437,ControlLeft:29,ControlRight:57373,Convert:121,Copy:57464,Cut:57404,Delete:57427,Digit0:11,Digit1:2,Digit2:3,Digit3:4,Digit4:5,Digit5:6,Digit6:7,Digit7:8,Digit8:9,Digit9:10,Eject:57469,End:57423,Enter:28,Equal:13,Escape:1,F1:59,F10:68,F11:87,F12:88,F13:93,F14:94,F15:95,F16:85,F17:57347,F18:57463,F19:57348,F2:60,F20:90,F21:116,F22:57465,F23:109,F24:111,F3:61,F4:62,F5:63,F6:64,F7:65,F8:66,F9:67,Find:57409,Help:57461,Hiragana:119,Home:57415,Insert:57426,IntlBackslash:86,IntlRo:115,IntlYen:125,KanaMode:112,Katakana:120,KeyA:30,KeyB:48,KeyC:46,KeyD:32,KeyE:18,KeyF:33,KeyG:34,KeyH:35,KeyI:23,KeyJ:36,KeyK:37,KeyL:38,KeyM:50,KeyN:49,KeyO:24,KeyP:25,KeyQ:16,KeyR:19,KeyS:31,KeyT:20,KeyU:22,KeyV:47,KeyW:17,KeyX:45,KeyY:21,KeyZ:44,Lang1:114,Lang2:113,Lang3:120,Lang4:119,Lang5:118,LaunchApp1:57451,LaunchApp2:57377,LaunchMail:57452,MediaPlayPause:57378,MediaSelect:57453,MediaStop:57380,MediaTrackNext:57369,MediaTrackPrevious:57360,MetaLeft:57435,MetaRight:57436,Minus:12,NonConvert:123,NumLock:69,Numpad0:82,Numpad1:79,Numpad2:80,Numpad3:81,Numpad4:75,Numpad5:76,Numpad6:77,Numpad7:71,Numpad8:72,Numpad9:73,NumpadAdd:78,NumpadComma:126,NumpadDecimal:83,NumpadDivide:57397,NumpadEnter:57372,NumpadEqual:89,NumpadMultiply:55,NumpadParenLeft:57462,NumpadParenRight:57467,NumpadSubtract:74,Open:100,PageDown:57425,PageUp:57417,Paste:101,Pause:57414,Period:52,Power:57438,PrintScreen:84,Props:57350,Quote:40,ScrollLock:70,Semicolon:39,ShiftLeft:42,ShiftRight:54,Slash:53,Sleep:57439,Space:57,Suspend:57381,Tab:15,Undo:57351,WakeUp:57443};var R={encodingRaw:0,encodingCopyRect:1,encodingRRE:2,encodingHextile:5,encodingZlib:6,encodingTight:7,encodingZRLE:16,encodingTightPNG:-260,encodingJPEG:21,encodingH264:50,pseudoEncodingQualityLevel9:-23,pseudoEncodingQualityLevel0:-32,pseudoEncodingDesktopSize:-223,pseudoEncodingLastRect:-224,pseudoEncodingCursor:-239,pseudoEncodingQEMUExtendedKeyEvent:-258,pseudoEncodingQEMULedEvent:-261,pseudoEncodingDesktopName:-307,pseudoEncodingExtendedDesktopSize:-308,pseudoEncodingXvp:-309,pseudoEncodingFence:-312,pseudoEncodingContinuousUpdates:-313,pseudoEncodingExtendedMouseButtons:-316,pseudoEncodingCompressLevel9:-247,pseudoEncodingCompressLevel0:-256,pseudoEncodingVMwareCursor:1464686180,pseudoEncodingExtendedClipboard:3231835598};var qt=class i{constructor(){this._key=null}get algorithm(){return{name:"AES-ECB"}}static async importKey(t,e,s,r){let n=new i;return await n._importKey(t,s,r),n}async _importKey(t,e,s){this._key=await window.crypto.subtle.importKey("raw",t,{name:"AES-CBC"},e,s)}async encrypt(t,e){let s=new Uint8Array(e);if(s.length%16!==0||this._key===null)return null;let r=s.length/16;for(let n=0;n>>6;for(let n=0;n<15;n++)e[n]=t[n+1]>>6|t[n]<<2,t[n]=t[n+1]>>7|t[n]<<1;let r=[0,135,14,137];e[14]^=s>>>1,e[15]=t[15]<<2^r[s],t[15]=t[15]<<1^r[s>>1],this._k1=t,this._k2=e}async _encryptCTR(t,e){let s=await window.crypto.subtle.encrypt({name:"AES-CTR",counter:e,length:128},this._ctrKey,t);return new Uint8Array(s)}async _decryptCTR(t,e){let s=await window.crypto.subtle.decrypt({name:"AES-CTR",counter:e,length:128},this._ctrKey,t);return new Uint8Array(s)}async _computeCMAC(t,e){if(e.length!==16)return null;let s=Math.floor(t.length/16),r=Math.ceil(t.length/16),n=t.length-s*16,a=new Uint8Array((r+1)*16);if(a.set(e),a.set(t,16),n===0)for(let o=0;o<16;o++)a[s*16+o]^=this._k1[o];else{a[(s+1)*16+n]=128;for(let o=0;o<16;o++)a[(s+1)*16+o]^=this._k2[o]}let h=await window.crypto.subtle.encrypt({name:"AES-CBC",iv:this._zeroBlock},this._cbcKey,a);return h=new Uint8Array(h),h.slice(h.length-32,h.length-16)}static async importKey(t,e,s,r){let n=new i;return await n._importKey(t),n}async _importKey(t){this._rawKey=t,this._ctrKey=await window.crypto.subtle.importKey("raw",t,{name:"AES-CTR"},!1,["encrypt","decrypt"]),this._cbcKey=await window.crypto.subtle.importKey("raw",t,{name:"AES-CBC"},!1,["encrypt"]),await this._initCMAC()}async encrypt(t,e){let s=t.additionalData,r=t.iv,n=await this._computeCMAC(r,this._prefixBlock0),a=await this._encryptCTR(e,n),h=await this._computeCMAC(s,this._prefixBlock1),_=await this._computeCMAC(a,this._prefixBlock2);for(let l=0;l<16;l++)_[l]^=n[l]^h[l];let o=new Uint8Array(16+a.length);return o.set(a),o.set(_,a.length),o}async decrypt(t,e){let s=e.slice(0,e.length-16),r=t.additionalData,n=t.iv,a=e.slice(e.length-16),h=await this._computeCMAC(n,this._prefixBlock0),_=await this._computeCMAC(r,this._prefixBlock1),o=await this._computeCMAC(s,this._prefixBlock2);for(let x=0;x<16;x++)o[x]^=h[x]^_[x];if(o.length!==a.length)return null;for(let x=0;x>>3]&1<>>10,this.keys[h]|=(o&4032)>>>6,++h,this.keys[h]=(_&258048)<<12,this.keys[h]|=(_&63)<<16,this.keys[h]|=(o&258048)>>>4,this.keys[h]|=o&63,++h}}enc8(t){let e=t.slice(),s=0,r,n,a;r=e[s++]<<24|e[s++]<<16|e[s++]<<8|e[s++],n=e[s++]<<24|e[s++]<<16|e[s++]<<8|e[s++],a=(r>>>4^n)&252645135,n^=a,r^=a<<4,a=(r>>>16^n)&65535,n^=a,r^=a<<16,a=(n>>>2^r)&858993459,r^=a,n^=a<<2,a=(n>>>8^r)&16711935,r^=a,n^=a<<8,n=n<<1|n>>>31&1,a=(r^n)&2863311530,r^=a,n^=a,r=r<<1|r>>>31&1;for(let h=0,_=0;h<8;++h){a=n<<28|n>>>4,a^=this.keys[_++];let o=Tr[a&63];o|=Cr[a>>>8&63],o|=Er[a>>>16&63],o|=kr[a>>>24&63],a=n^this.keys[_++],o|=Mr[a&63],o|=Lr[a>>>8&63],o|=Ar[a>>>16&63],o|=Sr[a>>>24&63],r^=o,a=r<<28|r>>>4,a^=this.keys[_++],o=Tr[a&63],o|=Cr[a>>>8&63],o|=Er[a>>>16&63],o|=kr[a>>>24&63],a=r^this.keys[_++],o|=Mr[a&63],o|=Lr[a>>>8&63],o|=Ar[a>>>16&63],o|=Sr[a>>>24&63],n^=o}for(n=n<<31|n>>>1,a=(r^n)&2863311530,r^=a,n^=a,r=r<<31|r>>>1,a=(r>>>8^n)&16711935,n^=a,r^=a<<8,a=(r>>>2^n)&858993459,n^=a,r^=a<<2,a=(n>>>16^r)&65535,r^=a,n^=a<<16,a=(n>>>4^r)&252645135,r^=a,n^=a<<4,a=[n,r],s=0;s<8;s++)e[s]=(a[s>>>2]>>>8*(3-s%4))%256,e[s]<0&&(e[s]+=256);return e}},ei=class i{constructor(){this._cipher=null}get algorithm(){return{name:"DES-ECB"}}static importKey(t,e,s,r){let n=new i;return n._importKey(t),n}_importKey(t,e,s){this._cipher=new $t(t)}encrypt(t,e){let s=new Uint8Array(e);if(s.length%8!==0||this._cipher===null)return null;let r=s.length/8;for(let n=0;n0n;)(t&1n)===1n&&(s=s*i%e),t=t>>1n,i=i*i%e;return s}function tt(i,t=0){let e=i.toString(16);t===0&&(t=Math.ceil(e.length/2)),e=e.padStart(t*2,"0");let s=e.length/2,r=new Uint8Array(s);for(let n=0;nthis._keyBytes-11)return null;let s=new Uint8Array(this._keyBytes-e.length-3);window.crypto.getRandomValues(s);for(let h=0;hthis._keyBytes?s:this._keyBytes,a=et(ne(r),this._privateKeyBigInt,this._pBigInt);return tt(a,n).slice(0,n)}};async function Rr(i){let t="";for(let e=0;e>2);for(let e=0;e>5]|=(255&i.charCodeAt(e/8))<>5]>>>e%32&255);return t}function pa(i,t){i[t>>5]|=128<>>9<<4)]=t;let e=1732584193,s=-271733879,r=-1732584194,n=271733878;for(let a=0;a>16)+(t>>16)+(e>>16)<<16|65535&e}function ga(i,t){return i<>>32-t}var Vi=class{constructor(){this._algorithms={"AES-ECB":qt,"AES-EAX":Jt,"DES-ECB":ei,"DES-CBC":ti,"RSA-PKCS1-v1_5":ii,DH:si,MD5:Rr}}encrypt(t,e,s){if(e.algorithm.name!==t.name)throw new Error("algorithm does not match");if(typeof e.encrypt!="function")throw new Error("key does not support encryption");return e.encrypt(t,s)}decrypt(t,e,s){if(e.algorithm.name!==t.name)throw new Error("algorithm does not match");if(typeof e.decrypt!="function")throw new Error("key does not support encryption");return e.decrypt(t,s)}importKey(t,e,s,r,n){if(t!=="raw")throw new Error("key format is not supported");let a=this._algorithms[s.name];if(typeof a>"u"||typeof a.importKey!="function")throw new Error("algorithm is not supported");return a.importKey(e,s,r,n)}generateKey(t,e,s){let r=this._algorithms[t.name];if(typeof r>"u"||typeof r.generateKey!="function")throw new Error("algorithm is not supported");return r.generateKey(t,e,s)}exportKey(t,e){if(t!=="raw")throw new Error("key format is not supported");if(typeof e.exportKey!="function")throw new Error("key does not support exportKey");return e.exportKey()}digest(t,e){let s=this._algorithms[t];if(typeof s!="function")throw new Error("algorithm is not supported");return s(e)}deriveBits(t,e,s){if(e.algorithm.name!==t.name)throw new Error("algorithm does not match");if(typeof e.deriveBits!="function")throw new Error("key does not support deriveBits");return e.deriveBits(t,s)}},O=new Vi;var ni=class{constructor(){this._cipher=null,this._counter=new Uint8Array(16)}async setKey(t){this._cipher=await O.importKey("raw",t,{name:"AES-EAX"},!1,["encrypt, decrypt"])}async makeMessage(t){let e=new Uint8Array([(t.length&65280)>>>8,t.length&255]),s=await O.encrypt({name:"AES-EAX",iv:this._counter,additionalData:e},this._cipher,t);for(let n=0;n<16&&this._counter[n]++===255;n++);let r=new Uint8Array(t.length+2+16);return r.set(e),r.set(s,2),r}async receiveMessage(t,e){let s=new Uint8Array([(t&65280)>>>8,t&255]),r=await O.decrypt({name:"AES-EAX",iv:this._counter,additionalData:s},this._cipher,e);for(let n=0;n<16&&this._counter[n]++===255;n++);return r}},Xt=class extends Ee{constructor(t,e){super(),this._hasStarted=!1,this._checkSock=null,this._checkCredentials=null,this._approveServerResolve=null,this._sockReject=null,this._credentialsReject=null,this._approveServerReject=null,this._sock=t,this._getCredentials=e}_waitSockAsync(t){return new Promise((e,s)=>{let r=()=>!this._sock.rQwait("RA2",t);r()?e():(this._checkSock=()=>{r()&&(e(),this._checkSock=null,this._sockReject=null)},this._sockReject=s)})}_waitApproveKeyAsync(){return new Promise((t,e)=>{this._approveServerResolve=t,this._approveServerReject=e})}_waitCredentialsAsync(t){let e=()=>t===1&&this._getCredentials().username!==void 0&&this._getCredentials().password!==void 0?!0:t===2&&this._getCredentials().password!==void 0;return new Promise((s,r)=>{e()?s():(this._checkCredentials=()=>{e()&&(s(),this._checkCredentials=null,this._credentialsReject=null)},this._credentialsReject=r)})}checkInternalEvents(){this._checkSock!==null&&this._checkSock(),this._checkCredentials!==null&&this._checkCredentials()}approveServer(){this._approveServerResolve!==null&&(this._approveServerResolve(),this._approveServerResolve=null)}disconnect(){this._sockReject!==null&&(this._sockReject(new Error("disconnect normally")),this._sockReject=null),this._credentialsReject!==null&&(this._credentialsReject(new Error("disconnect normally")),this._credentialsReject=null),this._approveServerReject!==null&&(this._approveServerReject(new Error("disconnect normally")),this._approveServerReject=null)}async negotiateRA2neAuthAsync(){this._hasStarted=!0,await this._waitSockAsync(4);let t=this._sock.rQpeekBytes(4),e=this._sock.rQshift32();if(e<1024)throw new Error("RA2: server public key is too short: "+e);if(e>8192)throw new Error("RA2: server public key is too long: "+e);let s=Math.ceil(e/8);await this._waitSockAsync(s*2);let r=this._sock.rQshiftBytes(s),n=this._sock.rQshiftBytes(s),a=await O.importKey("raw",{n:r,e:n},{name:"RSA-PKCS1-v1_5"},!1,["encrypt"]),h=new Uint8Array(4+s*2);h.set(t),h.set(r,4),h.set(n,4+s);let _=this._waitApproveKeyAsync();this.dispatchEvent(new CustomEvent("serververification",{detail:{type:"RSA",publickey:h}})),await _;let o=2048,l=Math.ceil(o/8),x=(await O.generateKey({name:"RSA-PKCS1-v1_5",modulusLength:o,publicExponent:new Uint8Array([1,0,1])},!0,["encrypt"])).privateKey,u=await O.exportKey("raw",x),d=u.n,X=u.e,F=new Uint8Array(4+l*2);F[0]=(o&4278190080)>>>24,F[1]=(o&16711680)>>>16,F[2]=(o&65280)>>>8,F[3]=o&255,F.set(d,4),F.set(X,4+l),this._sock.sQpushBytes(F),this._sock.flush();let A=new Uint8Array(16);window.crypto.getRandomValues(A);let k=await O.encrypt({name:"RSA-PKCS1-v1_5"},a,A),T=new Uint8Array(2+s);if(T[0]=(s&65280)>>>8,T[1]=s&255,T.set(k,2),this._sock.sQpushBytes(T),this._sock.flush(),await this._waitSockAsync(2),this._sock.rQshift16()!==l)throw new Error("RA2: wrong encrypted message length");let E=this._sock.rQshiftBytes(l),K=await O.decrypt({name:"RSA-PKCS1-v1_5"},x,E);if(K===null||K.length!==16)throw new Error("RA2: corrupted server encrypted random");let C=new Uint8Array(32),I=new Uint8Array(32);C.set(K),C.set(A,16),I.set(A),I.set(K,16),C=await window.crypto.subtle.digest("SHA-1",C),C=new Uint8Array(C).slice(0,16),I=await window.crypto.subtle.digest("SHA-1",I),I=new Uint8Array(I).slice(0,16);let S=new ni;await S.setKey(C);let D=new ni;await D.setKey(I);let B=new Uint8Array(8+s*2+l*2),M=new Uint8Array(8+s*2+l*2);if(B.set(h),B.set(F,4+s*2),M.set(F),M.set(h,4+l*2),B=await window.crypto.subtle.digest("SHA-1",B),M=await window.crypto.subtle.digest("SHA-1",M),B=new Uint8Array(B),M=new Uint8Array(M),this._sock.sQpushBytes(await S.makeMessage(M)),this._sock.flush(),await this._waitSockAsync(38),this._sock.rQshift16()!==20)throw new Error("RA2: wrong server hash");let z=await D.receiveMessage(20,this._sock.rQshiftBytes(36));if(z===null)throw new Error("RA2: failed to authenticate the message");for(let Z=0;Z<20;Z++)if(z[Z]!==B[Z])throw new Error("RA2: wrong server hash");if(await this._waitSockAsync(19),this._sock.rQshift16()!==1)throw new Error("RA2: wrong subtype");let te=await D.receiveMessage(1,this._sock.rQshiftBytes(17));if(te===null)throw new Error("RA2: failed to authenticate the message");te=te[0];let Ie=this._waitCredentialsAsync(te);if(te===1)(this._getCredentials().username===void 0||this._getCredentials().password===void 0)&&this.dispatchEvent(new CustomEvent("credentialsrequired",{detail:{types:["username","password"]}}));else if(te===2)this._getCredentials().password===void 0&&this.dispatchEvent(new CustomEvent("credentialsrequired",{detail:{types:["password"]}}));else throw new Error("RA2: wrong subtype");await Ie;let oe;te===1?oe=he(this._getCredentials().username).slice(0,255):oe="";let Fe=he(this._getCredentials().password).slice(0,255),_e=new Uint8Array(oe.length+Fe.length+2);_e[0]=oe.length,_e[oe.length+1]=Fe.length;for(let Z=0;Z0;){if(n.rQwait("RAW",o))return!1;let l=e+(r-this._lines),x=n.rQshiftBytes(o,!1);if(h==8){let u=new Uint8Array(s*4);for(let d=0;d>0&3)*255/3,u[d*4+1]=(x[d]>>2&3)*255/3,u[d*4+2]=(x[d]>>4&3)*255/3,u[d*4+3]=255;x=u}for(let u=0;u0;){if(n.rQwait("RRE",12))return!1;let _=n.rQshiftBytes(4),o=n.rQshift16(),l=n.rQshift16(),x=n.rQshift16(),u=n.rQshift16();a.fillRect(t+o,e+l,x,u,_),this._subrects--}return!0}};var St=class{constructor(){this._tiles=0,this._lastsubencoding=0,this._tileBuffer=new Uint8Array(16*16*4)}decodeRect(t,e,s,r,n,a,h){for(this._tiles===0&&(this._tilesX=Math.ceil(s/16),this._tilesY=Math.ceil(r/16),this._totalTiles=this._tilesX*this._tilesY,this._tiles=this._totalTiles);this._tiles>0;){let _=1;if(n.rQwait("HEXTILE",_))return!1;let o=n.rQpeek8();if(o>30)throw new Error("Illegal hextile subencoding (subencoding: "+o+")");let l=this._totalTiles-this._tiles,x=l%this._tilesX,u=Math.floor(l/this._tilesX),d=t+x*16,X=e+u*16,F=Math.min(16,t+s-d),A=Math.min(16,e+r-X);if(o&1)_+=F*A*4;else if(o&2&&(_+=4),o&4&&(_+=4),o&8){if(_++,n.rQwait("HEXTILE",_))return!1;let k=n.rQpeekBytes(_).at(-1);o&16?_+=k*6:_+=k*2}if(n.rQwait("HEXTILE",_))return!1;if(n.rQshift8(),o===0)this._lastsubencoding&1?L(" Ignoring blank after RAW"):a.fillRect(d,X,F,A,this._background);else if(o&1){let k=F*A,T=n.rQshiftBytes(k*4,!1);for(let E=0;E>4,I=K&15,S=n.rQshift8(),D=(S>>4)+1,B=(S&15)+1;this._subTile(C,I,D,B,E)}}this._finishTile(a)}this._lastsubencoding=o,this._tiles--}return!0}_startTile(t,e,s,r,n){this._tileX=t,this._tileY=e,this._tileW=s,this._tileH=r;let a=n[0],h=n[1],_=n[2],o=this._tileBuffer;for(let l=0;l>o&1&&(this._zlibs[o].reset(),V("Reset zlib stream "+o));this._ctl=this._ctl>>4}let _;if(this._ctl===8)_=this._fillRect(t,e,s,r,n,a,h);else if(this._ctl===9)_=this._jpegRect(t,e,s,r,n,a,h);else if(this._ctl===10)_=this._pngRect(t,e,s,r,n,a,h);else if(!(this._ctl&8))_=this._basicRect(this._ctl,t,e,s,r,n,a,h);else throw new Error("Illegal tight compression received (ctl: "+this._ctl+")");return _&&(this._ctl=null),_}_fillRect(t,e,s,r,n,a,h){if(n.rQwait("TIGHT",3))return!1;let _=n.rQshiftBytes(3);return a.fillRect(t,e,s,r,_,!1),!0}_jpegRect(t,e,s,r,n,a,h){let _=this._readData(n);return _===null?!1:(a.imageRect(t,e,s,r,"image/jpeg",_),!0)}_pngRect(t,e,s,r,n,a,h){throw new Error("PNG received in standard Tight rect")}_basicRect(t,e,s,r,n,a,h,_){if(this._filter===null)if(t&4){if(a.rQwait("TIGHT",1))return!1;this._filter=a.rQshift8()}else this._filter=0;let o=t&3,l;switch(this._filter){case 0:l=this._copyFilter(o,e,s,r,n,a,h,_);break;case 1:l=this._paletteFilter(o,e,s,r,n,a,h,_);break;case 2:l=this._gradientFilter(o,e,s,r,n,a,h,_);break;default:throw new Error("Illegal tight filter received (ctl: "+this._filter+")")}return l&&(this._filter=null),l}_copyFilter(t,e,s,r,n,a,h,_){let o=r*n*3,l;if(o===0)return!0;if(o<12){if(a.rQwait("TIGHT",o))return!1;l=a.rQshiftBytes(o)}else{if(l=this._readData(a),l===null)return!1;this._zlibs[t].setInput(l),l=this._zlibs[t].inflate(o),this._zlibs[t].setInput(null)}let x=new Uint8Array(r*n*4);for(let u=0,d=0;u=0;F--)u=(x*s+X*8+7-F)*4,d=(n[x*o+X]>>F&1)*3,_[u]=a[d],_[u+1]=a[d+1],_[u+2]=a[d+2],_[u+3]=255;for(let F=7;F>=8-s%8;F--)u=(x*s+X*8+7-F)*4,d=(n[x*o+X]>>F&1)*3,_[u]=a[d],_[u+1]=a[d+1],_[u+2]=a[d+2],_[u+3]=255}h.blitImage(t,e,s,r,_,0,!1)}_paletteRect(t,e,s,r,n,a,h){let _=this._getScratchBuffer(s*r*4),o=s*r*4;for(let l=0,x=0;l255&&(C=255);let I=l[d++]+C;x[u++]=I,k[K]=A[K],X[K]=I}x[u++]=255,F++}}return h.blitImage(e,s,r,n,x,0,!1),!0}_readData(t){if(this._len===0){if(t.rQwait("TIGHT",3))return null;let s;s=t.rQshift8(),this._len=s&127,s&128&&(s=t.rQshift8(),this._len|=(s&127)<<7,s&128&&(s=t.rQshift8(),this._len|=s<<14))}if(t.rQwait("TIGHT",this._len))return null;let e=t.rQshiftBytes(this._len,!1);return this._len=0,e}_getScratchBuffer(t){return(!this._scratchBuffer||this._scratchBuffer.length=2&&X<=16){let F=this._decodePaletteTile(X,d,u,l);a.blitImage(x,o,u,l,F,0,!1)}else if(X===128){let F=this._decodeRLETile(d);a.blitImage(x,o,u,l,F,0,!1)}else if(X>=130&&X<=255){let F=this._decodeRLEPaletteTile(X-128,d);a.blitImage(x,o,u,l,F,0,!1)}else throw new Error("Unknown subencoding: "+X)}}return this._length=0,!0}_getBitsPerPixelInPalette(t){if(t<=2)return 1;if(t<=4)return 2;if(t<=16)return 4}_readPixels(t){let e=this._pixelBuffer,s=this._inflator.inflate(3*t);for(let r=0,n=0;r>u&_;n[o]=a[X*4],n[o+1]=a[X*4+1],n[o+2]=a[X*4+2],n[o+3]=a[X*4+3],o+=4,u-=h}u<8-h&&x=128&&(a-=128,h=this._readRLELength()),a>t)throw new Error("Too big index in palette: "+a+", palette size: "+t);if(n+h>e)throw new Error("Too big rle length in palette mode: "+h+", allowed length is: "+(e-n));for(let _=0;_d[1]==192||d[1]==194);if(l==-1)throw new Error("Illegal JPEG image without SOF");o.length===0&&this._segments.splice(l+1,0,...this._cachedQuantTables),_.length===0&&this._segments.splice(l+1,0,...this._cachedHuffmanTables);let x=0;for(let d of this._segments)x+=d.length;let u=new Uint8Array(x);x=0;for(let d of this._segments)u.set(d,x),x+=d.length;return a.imageRect(t,e,s,r,"image/jpeg",u),_.length!==0&&(this._cachedHuffmanTables=_),o.length!==0&&(this._cachedQuantTables=o),this._segments=[],!0}_readSegment(t){if(t.rQwait("JPEG",2))return null;let e=t.rQshift8();if(e!=255)throw new Error("Illegal JPEG marker received (byte: "+e+")");let s=t.rQshift8();if(s>=208&&s<=217||s==1)return new Uint8Array([e,s]);if(t.rQwait("JPEG",2,2))return null;let r=t.rQshift16();if(r<2)throw new Error("Illegal JPEG length received (length: "+r+")");if(t.rQwait("JPEG",r-2,4))return null;let n=0;if(s===218)for(n+=2;;){if(t.rQwait("JPEG",r-2+n,4))return null;let h=t.rQpeekBytes(r-2+n,!1);if(h.at(-2)===255&&h.at(-1)!==0&&!(h.at(-1)>=208&&h.at(-1)<=215)){n-=2;break}n++}let a=new Uint8Array(2+r+n);return a[0]=e,a[1]=s,a[2]=r>>8,a[3]=r,a.set(t.rQshiftBytes(r-2+n,!1),4),a}};var Wi=class{constructor(t){this._data=t,this._index=0,this.profileIdc=null,this.constraintSet=null,this.levelIdc=null}_getStartSequenceLen(t){let e=this._data;return e[t+0]==0&&e[t+1]==0&&e[t+2]==0&&e[t+3]==1?4:e[t+0]==0&&e[t+1]==0&&e[t+2]==1?3:0}_indexOfNextNalUnit(t){let e=this._data;for(let s=t;sthis._handleFrame(n),error:n=>this._handleError(n)}));let r="avc1."+t.toString(16).padStart(2,"0")+e.toString(16).padStart(2,"0")+s.toString(16).padStart(2,"0");this._decoder.configure({codec:r,codedWidth:this._width,codedHeight:this._height,optimizeForLatency:!0})}_preparePendingFrame(t){let e={timestamp:t,promise:null,resolve:null,frame:null,ready:!1,keep:!1};return e.promise=new Promise(s=>{e.resolve=s}),this._pendingFrames.push(e),e}decode(t){let e=new Wi(t),s=null,r=Math.round(window.performance.now()*1e3);for(;;){let n=e.parse();if(n===null)break;if(e.profileIdc!==null&&(self._profileIdc=e.profileIdc,self._constraintSet=e.constraintSet,self._levelIdc=e.levelIdc),this._decoder===null||this._decoder.state!=="configured"){if(!n.key){W("Missing key frame. Can't decode until one arrives");continue}if(self._profileIdc===null){W("Cannot config decoder. Have not received SPS and PPS yet.");continue}this._configureDecoder(self._profileIdc,self._constraintSet,self._levelIdc)}s=this._preparePendingFrame(r);let a=new EncodedVideoChunk({timestamp:r,type:n.key?"key":"delta",data:n.frame});try{this._decoder.decode(a)}catch(h){W("Failed to decode:",h)}}return s!==null&&(s.keep=!0),s}},Tt=class{constructor(){this._tick=0,this._contexts={}}_contextId(t,e,s,r){return[t,e,s,r].join(",")}_findOldestContextId(){let t=Number.MAX_VALUE,e;for(let[s,r]of Object.entries(this._contexts))r.lastUsed=64){let h=this._findOldestContextId();delete this._contexts[h]}let a=new Zi(s,r);return this._contexts[this._contextId(t,e,s,r)]=a,a}_getContext(t,e,s,r){let n=this._contexts[this._contextId(t,e,s,r)];return n!==void 0?n:this._createContext(t,e,s,r)}_resetContext(t,e,s,r){delete this._contexts[this._contextId(t,e,s,r)]}_resetAllContexts(){this._contexts={}}decodeRect(t,e,s,r,n,a,h){if(n.rQwait("h264 header",8))return!1;let l=n.rQshift32(),x=n.rQshift32();if(n.rQwait("h264 payload",l,8))return!1;x&2?this._resetAllContexts():x&1&&this._resetContext(t,e,s,r);let u=this._getContext(t,e,s,r);if(u.lastUsed=this._tick++,l!==0){let d=n.rQshiftBytes(l,!1),X=u.decode(d);X!==null&&a.videoFrame(t,e,s,r,X)}return!0}};var ba=3,ma="rgb(40, 40, 40)",Br=17,Dr=50,Qr=19,Mt=75,Ke=50,wa=1e3,va=50,Ir=1,hi=2,Pr=6,Hr=16,Yi=19,Ur=22,Nr=30,Or=113,zr=129,Gr=256,de=1;var Vr=1<<24,li=1<<25,Wr=1<<26,Rt=1<<27,ci=1<<28,se=class i extends Ee{constructor(t,e,s){if(!t)throw new Error("Must specify target");if(!e)throw new Error("Must specify URL, WebSocket or RTCDataChannel");window.isSecureContext||U("noVNC requires a secure context (TLS). Expect crashes!"),super(),this._target=t,typeof e=="string"?this._url=e:(this._url=null,this._rawChannel=e),s=s||{},this._rfbCredentials=s.credentials||{},this._shared="shared"in s?!!s.shared:!0,this._repeaterID=s.repeaterID||"",this._wsProtocols=s.wsProtocols||[],this._rfbConnectionState="",this._rfbInitState="",this._rfbAuthScheme=-1,this._rfbCleanDisconnect=!0,this._rfbRSAAESAuthenticationState=null,this._rfbVersion=0,this._rfbMaxVersion=3.8,this._rfbTightVNC=!1,this._rfbVeNCryptState=0,this._rfbXvpVer=0,this._fbWidth=0,this._fbHeight=0,this._fbName="",this._capabilities={power:!1},this._supportsFence=!1,this._supportsContinuousUpdates=!1,this._enabledContinuousUpdates=!1,this._supportsSetDesktopSize=!1,this._screenID=0,this._screenFlags=0,this._pendingRemoteResize=!1,this._lastResize=0,this._qemuExtKeyEventSupported=!1,this._extendedPointerEventSupported=!1,this._clipboardText=null,this._clipboardServerCapabilitiesActions={},this._clipboardServerCapabilitiesFormats={},this._sock=null,this._display=null,this._flushing=!1,this._keyboard=null,this._gestures=null,this._resizeObserver=null,this._disconnTimer=null,this._resizeTimeout=null,this._mouseMoveTimer=null,this._decoders={},this._FBU={rects:0,x:0,y:0,width:0,height:0,encoding:null},this._mousePos={},this._mouseButtonMask=0,this._mouseLastMoveTime=0,this._viewportDragging=!1,this._viewportDragPos={},this._viewportHasMoved=!1,this._accumulatedWheelDeltaX=0,this._accumulatedWheelDeltaY=0,this._gestureLastTapTime=null,this._gestureFirstDoubleTapEv=null,this._gestureLastMagnitudeX=0,this._gestureLastMagnitudeY=0,this._eventHandlers={focusCanvas:this._focusCanvas.bind(this),handleResize:this._handleResize.bind(this),handleMouse:this._handleMouse.bind(this),handleWheel:this._handleWheel.bind(this),handleGesture:this._handleGesture.bind(this),handleRSAAESCredentialsRequired:this._handleRSAAESCredentialsRequired.bind(this),handleRSAAESServerVerification:this._handleRSAAESServerVerification.bind(this)},L(">> RFB.constructor"),this._screen=document.createElement("div"),this._screen.style.display="flex",this._screen.style.width="100%",this._screen.style.height="100%",this._screen.style.overflow="auto",this._screen.style.background=ma,this._canvas=document.createElement("canvas"),this._canvas.style.margin="auto",this._canvas.style.outline="none",this._canvas.width=0,this._canvas.height=0,this._canvas.tabIndex=-1,this._screen.appendChild(this._canvas),this._cursor=new vt,this._cursorImage=i.cursors.none,this._decoders[R.encodingRaw]=new Kt,this._decoders[R.encodingCopyRect]=new Ft,this._decoders[R.encodingRRE]=new kt,this._decoders[R.encodingHextile]=new St,this._decoders[R.encodingZlib]=new Et,this._decoders[R.encodingTight]=new Qe,this._decoders[R.encodingTightPNG]=new At,this._decoders[R.encodingZRLE]=new Ct,this._decoders[R.encodingJPEG]=new Lt,this._decoders[R.encodingH264]=new Tt;try{this._display=new rt(this._canvas)}catch(r){throw U("Display exception: "+r),r}this._keyboard=new bt(this._canvas),this._keyboard.onkeyevent=this._handleKeyEvent.bind(this),this._remoteCapsLock=null,this._remoteNumLock=null,this._gestures=new mt,this._sock=new yt,this._sock.on("open",this._socketOpen.bind(this)),this._sock.on("close",this._socketClose.bind(this)),this._sock.on("message",this._handleMessage.bind(this)),this._sock.on("error",this._socketError.bind(this)),this._expectedClientWidth=null,this._expectedClientHeight=null,this._resizeObserver=new ResizeObserver(this._eventHandlers.handleResize),this._updateConnectionState("connecting"),L("<< RFB.constructor"),this.dragViewport=!1,this.focusOnClick=!0,this._viewOnly=!1,this._clipViewport=!1,this._clippingViewport=!1,this._scaleViewport=!1,this._resizeSession=!1,this._showDotCursor=!1,s.showDotCursor!==void 0&&(W("Specifying showDotCursor as a RFB constructor argument is deprecated"),this._showDotCursor=s.showDotCursor),this._qualityLevel=6,this._compressionLevel=2}get viewOnly(){return this._viewOnly}set viewOnly(t){this._viewOnly=t,(this._rfbConnectionState==="connecting"||this._rfbConnectionState==="connected")&&(t?this._keyboard.ungrab():this._keyboard.grab())}get capabilities(){return this._capabilities}get clippingViewport(){return this._clippingViewport}_setClippingViewport(t){t!==this._clippingViewport&&(this._clippingViewport=t,this.dispatchEvent(new CustomEvent("clippingviewport",{detail:this._clippingViewport})))}get touchButton(){return 0}set touchButton(t){W("Using old API!")}get clipViewport(){return this._clipViewport}set clipViewport(t){this._clipViewport=t,this._updateClip()}get scaleViewport(){return this._scaleViewport}set scaleViewport(t){this._scaleViewport=t,t&&this._clipViewport&&this._updateClip(),this._updateScale(),!t&&this._clipViewport&&this._updateClip()}get resizeSession(){return this._resizeSession}set resizeSession(t){this._resizeSession=t,t&&this._requestRemoteResize()}get showDotCursor(){return this._showDotCursor}set showDotCursor(t){this._showDotCursor=t,this._refreshCursor()}get background(){return this._screen.style.background}set background(t){this._screen.style.background=t}get qualityLevel(){return this._qualityLevel}set qualityLevel(t){if(!Number.isInteger(t)||t<0||t>9){U("qualityLevel must be an integer between 0 and 9");return}this._qualityLevel!==t&&(this._qualityLevel=t,this._rfbConnectionState==="connected"&&this._sendEncodings())}get compressionLevel(){return this._compressionLevel}set compressionLevel(t){if(!Number.isInteger(t)||t<0||t>9){U("compressionLevel must be an integer between 0 and 9");return}this._compressionLevel!==t&&(this._compressionLevel=t,this._rfbConnectionState==="connected"&&this._sendEncodings())}disconnect(){this._updateConnectionState("disconnecting"),this._sock.off("error"),this._sock.off("message"),this._sock.off("open"),this._rfbRSAAESAuthenticationState!==null&&this._rfbRSAAESAuthenticationState.disconnect()}approveServer(){this._rfbRSAAESAuthenticationState!==null&&this._rfbRSAAESAuthenticationState.approveServer()}sendCredentials(t){this._rfbCredentials=t,this._resumeAuthentication()}sendCtrlAltDel(){this._rfbConnectionState!=="connected"||this._viewOnly||(V("Sending Ctrl-Alt-Del"),this.sendKey(f.XK_Control_L,"ControlLeft",!0),this.sendKey(f.XK_Alt_L,"AltLeft",!0),this.sendKey(f.XK_Delete,"Delete",!0),this.sendKey(f.XK_Delete,"Delete",!1),this.sendKey(f.XK_Alt_L,"AltLeft",!1),this.sendKey(f.XK_Control_L,"ControlLeft",!1))}machineShutdown(){this._xvpOp(1,2)}machineReboot(){this._xvpOp(1,3)}machineReset(){this._xvpOp(1,4)}sendKey(t,e,s){if(this._rfbConnectionState!=="connected"||this._viewOnly)return;if(s===void 0){this.sendKey(t,e,!0),this.sendKey(t,e,!1);return}let r=Kr[e];if(this._qemuExtKeyEventSupported&&r)t=t||0,V("Sending key ("+(s?"down":"up")+"): keysym "+t+", scancode "+r),i.messages.QEMUExtendedKeyEvent(this._sock,t,s,r);else{if(!t)return;V("Sending keysym ("+(s?"down":"up")+"): "+t),i.messages.keyEvent(this._sock,t,s?1:0)}}focus(t){this._canvas.focus(t)}blur(){this._canvas.blur()}clipboardPasteFrom(t){if(!(this._rfbConnectionState!=="connected"||this._viewOnly))if(this._clipboardServerCapabilitiesFormats[de]&&this._clipboardServerCapabilitiesActions[Rt])this._clipboardText=t,i.messages.extendedClipboardNotify(this._sock,[de]);else{let e,s,r;e=0;for(let n of t)e++;r=new Uint8Array(e),s=0;for(let n of t){let a=n.codePointAt(0);a>255&&(a=63),r[s++]=a}i.messages.clientCutText(this._sock,r)}}getImageData(){return this._display.getImageData()}toDataURL(t,e){return this._display.toDataURL(t,e)}toBlob(t,e,s){return this._display.toBlob(t,e,s)}_connect(){if(L(">> RFB.connect"),this._url)V(`connecting to ${this._url}`),this._sock.open(this._url,this._wsProtocols);else{if(V(`attaching ${this._rawChannel} to Websock`),this._sock.attach(this._rawChannel),this._sock.readyState==="closed")throw Error("Cannot use already closed WebSocket/RTCDataChannel");this._sock.readyState==="open"&&this._socketOpen()}this._target.appendChild(this._screen),this._gestures.attach(this._canvas),this._cursor.attach(this._canvas),this._refreshCursor(),this._resizeObserver.observe(this._screen),this._canvas.addEventListener("mousedown",this._eventHandlers.focusCanvas),this._canvas.addEventListener("touchstart",this._eventHandlers.focusCanvas),this._canvas.addEventListener("mousedown",this._eventHandlers.handleMouse),this._canvas.addEventListener("mouseup",this._eventHandlers.handleMouse),this._canvas.addEventListener("mousemove",this._eventHandlers.handleMouse),this._canvas.addEventListener("click",this._eventHandlers.handleMouse),this._canvas.addEventListener("contextmenu",this._eventHandlers.handleMouse),this._canvas.addEventListener("wheel",this._eventHandlers.handleWheel),this._canvas.addEventListener("gesturestart",this._eventHandlers.handleGesture),this._canvas.addEventListener("gesturemove",this._eventHandlers.handleGesture),this._canvas.addEventListener("gestureend",this._eventHandlers.handleGesture),L("<< RFB.connect")}_disconnect(){L(">> RFB.disconnect"),this._cursor.detach(),this._canvas.removeEventListener("gesturestart",this._eventHandlers.handleGesture),this._canvas.removeEventListener("gesturemove",this._eventHandlers.handleGesture),this._canvas.removeEventListener("gestureend",this._eventHandlers.handleGesture),this._canvas.removeEventListener("wheel",this._eventHandlers.handleWheel),this._canvas.removeEventListener("mousedown",this._eventHandlers.handleMouse),this._canvas.removeEventListener("mouseup",this._eventHandlers.handleMouse),this._canvas.removeEventListener("mousemove",this._eventHandlers.handleMouse),this._canvas.removeEventListener("click",this._eventHandlers.handleMouse),this._canvas.removeEventListener("contextmenu",this._eventHandlers.handleMouse),this._canvas.removeEventListener("mousedown",this._eventHandlers.focusCanvas),this._canvas.removeEventListener("touchstart",this._eventHandlers.focusCanvas),this._resizeObserver.disconnect(),this._keyboard.ungrab(),this._gestures.detach(),this._sock.close();try{this._target.removeChild(this._screen)}catch(t){if(t.name!=="NotFoundError")throw t}clearTimeout(this._resizeTimeout),clearTimeout(this._mouseMoveTimer),L("<< RFB.disconnect")}_socketOpen(){this._rfbConnectionState==="connecting"&&this._rfbInitState===""?(this._rfbInitState="ProtocolVersion",L("Starting VNC handshake")):this._fail("Unexpected server connection while "+this._rfbConnectionState)}_socketClose(t){L("WebSocket on-close event");let e="";switch(t.code&&(e="(code: "+t.code,t.reason&&(e+=", reason: "+t.reason),e+=")"),this._rfbConnectionState){case"connecting":this._fail("Connection closed "+e);break;case"connected":this._updateConnectionState("disconnecting"),this._updateConnectionState("disconnected");break;case"disconnecting":this._updateConnectionState("disconnected");break;case"disconnected":this._fail("Unexpected server disconnect when already disconnected "+e);break;default:this._fail("Unexpected server disconnect before connecting "+e);break}this._sock.off("close"),this._rawChannel=null}_socketError(t){W("WebSocket on-error event")}_focusCanvas(t){this.focusOnClick&&this.focus({preventScroll:!0})}_setDesktopName(t){this._fbName=t,this.dispatchEvent(new CustomEvent("desktopname",{detail:{name:this._fbName}}))}_saveExpectedClientSize(){this._expectedClientWidth=this._screen.clientWidth,this._expectedClientHeight=this._screen.clientHeight}_currentClientSize(){return[this._screen.clientWidth,this._screen.clientHeight]}_clientHasExpectedSize(){let[t,e]=this._currentClientSize();return t==this._expectedClientWidth&&e==this._expectedClientHeight}_handleResize(){this._clientHasExpectedSize()||(window.requestAnimationFrame(()=>{this._updateClip(),this._updateScale(),this._saveExpectedClientSize()}),this._requestRemoteResize())}_updateClip(){let t=this._display.clipViewport,e=this._clipViewport;if(this._scaleViewport&&(e=!1),t!==e&&(this._display.clipViewport=e),e){let s=this._screenSize();this._display.viewportChangeSize(s.w,s.h),this._fixScrollbars(),this._setClippingViewport(s.w{U("Disconnection timed out."),this._updateConnectionState("disconnected")},ba*1e3);break;case"disconnected":this.dispatchEvent(new CustomEvent("disconnect",{detail:{clean:this._rfbCleanDisconnect}}));break}}_fail(t){switch(this._rfbConnectionState){case"disconnecting":U("Failed when disconnecting: "+t);break;case"connected":U("Failed while connected: "+t);break;case"connecting":U("Failed when connecting: "+t);break;default:U("RFB failure: "+t);break}return this._rfbCleanDisconnect=!1,this._updateConnectionState("disconnecting"),this._updateConnectionState("disconnected"),!1}_setCapability(t,e){this._capabilities[t]=e,this.dispatchEvent(new CustomEvent("capabilities",{detail:{capabilities:this._capabilities}}))}_handleMessage(){if(this._sock.rQwait("message",1)){W("handleMessage called on an empty receive queue");return}switch(this._rfbConnectionState){case"disconnected":U("Got data while disconnected");break;case"connected":for(;!(this._flushing||!this._normalMsg()||this._sock.rQwait("message",1)););break;case"connecting":for(;this._rfbConnectionState==="connecting"&&this._initMsg(););break;default:U("Got data while in an invalid state");break}}_handleKeyEvent(t,e,s,r,n){e=="CapsLock"&&s&&(this._remoteCapsLock=null),this._remoteCapsLock!==null&&n!==null&&this._remoteCapsLock!==n&&s&&(L("Fixing remote caps lock"),this.sendKey(f.XK_Caps_Lock,"CapsLock",!0),this.sendKey(f.XK_Caps_Lock,"CapsLock",!1),this._remoteCapsLock=null),e=="NumLock"&&s&&(this._remoteNumLock=null),this._remoteNumLock!==null&&r!==null&&this._remoteNumLock!==r&&s&&(L("Fixing remote num lock"),this.sendKey(f.XK_Num_Lock,"NumLock",!0),this.sendKey(f.XK_Num_Lock,"NumLock",!1),this._remoteNumLock=null),this.sendKey(t,e,s)}static _convertButtonMask(t){let e={0:1,1:4,2:2,3:128,4:256},s=0;for(let r=0;r<5;r++)t&1<st||Math.abs(a)>st)&&(this._viewportHasMoved=!0,this._viewportDragPos={x:e.x,y:e.y},this._display.viewportChangePos(n,a));break}this._handleMouseMove(e.x,e.y);break}}_handleMouseButton(t,e,s){this._flushMouseMoveTimer(t,e),this._mouseButtonMask=s,this._sendMouse(t,e,this._mouseButtonMask)}_handleMouseMove(t,e){if(this._mousePos={x:t,y:e},this._mouseMoveTimer==null){let s=Date.now()-this._mouseLastMoveTime;s>Br?(this._sendMouse(t,e,this._mouseButtonMask),this._mouseLastMoveTime=Date.now()):this._mouseMoveTimer=setTimeout(()=>{this._handleDelayedMouseMove()},Br-s)}}_handleDelayedMouseMove(){this._mouseMoveTimer=null,this._sendMouse(this._mousePos.x,this._mousePos.y,this._mouseButtonMask),this._mouseLastMoveTime=Date.now()}_sendMouse(t,e,s){if(this._rfbConnectionState!=="connected"||this._viewOnly)return;if(s&32768)throw new Error("Illegal mouse button mask (mask: "+s+")");let r=s&32640;this._extendedPointerEventSupported&&r?i.messages.extendedPointerEvent(this._sock,this._display.absX(t),this._display.absY(e),s):i.messages.pointerEvent(this._sock,this._display.absX(t),this._display.absY(e),s)}_handleWheel(t){if(this._rfbConnectionState!=="connected"||this._viewOnly)return;t.stopPropagation(),t.preventDefault();let e=Ne(t.clientX,t.clientY,this._canvas),s=i._convertButtonMask(t.buttons),r=t.deltaX,n=t.deltaY;t.deltaMode!==0&&(r*=Qr,n*=Qr),this._accumulatedWheelDeltaX+=r,this._accumulatedWheelDeltaY+=n,Math.abs(this._accumulatedWheelDeltaX)>=Dr&&(this._accumulatedWheelDeltaX<0?(this._handleMouseButton(e.x,e.y,s|32),this._handleMouseButton(e.x,e.y,s)):this._accumulatedWheelDeltaX>0&&(this._handleMouseButton(e.x,e.y,s|64),this._handleMouseButton(e.x,e.y,s)),this._accumulatedWheelDeltaX=0),Math.abs(this._accumulatedWheelDeltaY)>=Dr&&(this._accumulatedWheelDeltaY<0?(this._handleMouseButton(e.x,e.y,s|8),this._handleMouseButton(e.x,e.y,s)):this._accumulatedWheelDeltaY>0&&(this._handleMouseButton(e.x,e.y,s|16),this._handleMouseButton(e.x,e.y,s)),this._accumulatedWheelDeltaY=0)}_fakeMouseMove(t,e,s){this._handleMouseMove(e,s),this._cursor.move(t.detail.clientX,t.detail.clientY)}_handleTapEvent(t,e){let s=Ne(t.detail.clientX,t.detail.clientY,this._canvas);if(this._gestureLastTapTime!==null&&Date.now()-this._gestureLastTapTimest||Math.abs(n)>st)&&(this._viewportHasMoved=!0,this._viewportDragPos={x:s.x,y:s.y},this._display.viewportChangePos(r,n))}else this._fakeMouseMove(t,s.x,s.y);break;case"twodrag":for(this._fakeMouseMove(t,s.x,s.y);t.detail.magnitudeY-this._gestureLastMagnitudeY>Ke;)this._handleMouseButton(s.x,s.y,8),this._handleMouseButton(s.x,s.y,0),this._gestureLastMagnitudeY+=Ke;for(;t.detail.magnitudeY-this._gestureLastMagnitudeY<-Ke;)this._handleMouseButton(s.x,s.y,16),this._handleMouseButton(s.x,s.y,0),this._gestureLastMagnitudeY-=Ke;for(;t.detail.magnitudeX-this._gestureLastMagnitudeX>Ke;)this._handleMouseButton(s.x,s.y,32),this._handleMouseButton(s.x,s.y,0),this._gestureLastMagnitudeX+=Ke;for(;t.detail.magnitudeX-this._gestureLastMagnitudeX<-Ke;)this._handleMouseButton(s.x,s.y,64),this._handleMouseButton(s.x,s.y,0),this._gestureLastMagnitudeX-=Ke;break;case"pinch":if(this._fakeMouseMove(t,s.x,s.y),e=Math.hypot(t.detail.magnitudeX,t.detail.magnitudeY),Math.abs(e-this._gestureLastMagnitudeX)>Mt){for(this._handleKeyEvent(f.XK_Control_L,"ControlLeft",!0);e-this._gestureLastMagnitudeX>Mt;)this._handleMouseButton(s.x,s.y,8),this._handleMouseButton(s.x,s.y,0),this._gestureLastMagnitudeX+=Mt;for(;e-this._gestureLastMagnitudeX<-Mt;)this._handleMouseButton(s.x,s.y,16),this._handleMouseButton(s.x,s.y,0),this._gestureLastMagnitudeX-=Mt}this._handleKeyEvent(f.XK_Control_L,"ControlLeft",!1);break}break;case"gestureend":switch(t.detail.type){case"onetap":case"twotap":case"threetap":case"pinch":case"twodrag":break;case"drag":this.dragViewport?this._viewportDragging=!1:(this._fakeMouseMove(t,s.x,s.y),this._handleMouseButton(s.x,s.y,0));break;case"longpress":if(this._viewportHasMoved)break;this.dragViewport&&!this._viewportHasMoved?(this._fakeMouseMove(t,s.x,s.y),this._handleMouseButton(s.x,s.y,4),this._handleMouseButton(s.x,s.y,0),this._viewportDragging=!1):(this._fakeMouseMove(t,s.x,s.y),this._handleMouseButton(s.x,s.y,0));break}break}}_flushMouseMoveTimer(t,e){this._mouseMoveTimer!==null&&(clearTimeout(this._mouseMoveTimer),this._mouseMoveTimer=null,this._sendMouse(t,e,this._mouseButtonMask))}_negotiateProtocolVersion(){if(this._sock.rQwait("version",12))return!1;let t=this._sock.rQshiftStr(12).substr(4,7);V("Server ProtocolVersion: "+t);let e=0;switch(t){case"000.000":e=1;break;case"003.003":case"003.006":this._rfbVersion=3.3;break;case"003.007":this._rfbVersion=3.7;break;case"003.008":case"003.889":case"004.000":case"004.001":case"005.000":this._rfbVersion=3.8;break;default:return this._fail("Invalid server version "+t)}if(e){let r="ID:"+this._repeaterID;for(;r.length<250;)r+="\0";return this._sock.sQpushString(r),this._sock.flush(),!0}this._rfbVersion>this._rfbMaxVersion&&(this._rfbVersion=this._rfbMaxVersion);let s="00"+parseInt(this._rfbVersion,10)+".00"+this._rfbVersion*10%10;this._sock.sQpushString("RFB "+s+` +`),this._sock.flush(),L("Sent ProtocolVersion: "+s),this._rfbInitState="Security"}_isSupportedSecurityType(t){return[Ir,hi,Pr,Hr,Yi,Ur,Nr,Or,Gr].includes(t)}_negotiateSecurity(){if(this._rfbVersion>=3.7){let t=this._sock.rQshift8();if(this._sock.rQwait("security type",t,1))return!1;if(t===0)return this._rfbInitState="SecurityReason",this._securityContext="no security types",this._securityStatus=1,!0;let e=this._sock.rQshiftBytes(t);L("Server security types: "+e),this._rfbAuthScheme=-1;for(let s of e)if(this._isSupportedSecurityType(s)){this._rfbAuthScheme=s;break}if(this._rfbAuthScheme===-1)return this._fail("Unsupported security types (types: "+e+")");this._sock.sQpush8(this._rfbAuthScheme),this._sock.flush()}else{if(this._sock.rQwait("security scheme",4))return!1;if(this._rfbAuthScheme=this._sock.rQshift32(),this._rfbAuthScheme==0)return this._rfbInitState="SecurityReason",this._securityContext="authentication scheme",this._securityStatus=1,!0}return this._rfbInitState="Authentication",L("Authenticating using scheme: "+this._rfbAuthScheme),!0}_handleSecurityReason(){if(this._sock.rQwait("reason length",4))return!1;let t=this._sock.rQshift32(),e="";if(t>0){if(this._sock.rQwait("reason",t,4))return!1;e=this._sock.rQshiftStr(t)}return e!==""?(this.dispatchEvent(new CustomEvent("securityfailure",{detail:{status:this._securityStatus,reason:e}})),this._fail("Security negotiation failed on "+this._securityContext+" (reason: "+e+")")):(this.dispatchEvent(new CustomEvent("securityfailure",{detail:{status:this._securityStatus}})),this._fail("Security negotiation failed on "+this._securityContext))}_negotiateXvpAuth(){return this._rfbCredentials.username===void 0||this._rfbCredentials.password===void 0||this._rfbCredentials.target===void 0?(this.dispatchEvent(new CustomEvent("credentialsrequired",{detail:{types:["username","password","target"]}})),!1):(this._sock.sQpush8(this._rfbCredentials.username.length),this._sock.sQpush8(this._rfbCredentials.target.length),this._sock.sQpushString(this._rfbCredentials.username),this._sock.sQpushString(this._rfbCredentials.target),this._sock.flush(),this._rfbAuthScheme=hi,this._negotiateAuthentication())}_negotiateVeNCryptAuth(){if(this._rfbVeNCryptState==0){if(this._sock.rQwait("vencrypt version",2))return!1;let t=this._sock.rQshift8(),e=this._sock.rQshift8();if(!(t==0&&e==2))return this._fail("Unsupported VeNCrypt version "+t+"."+e);this._sock.sQpush8(0),this._sock.sQpush8(2),this._sock.flush(),this._rfbVeNCryptState=1}if(this._rfbVeNCryptState==1){if(this._sock.rQwait("vencrypt ack",1))return!1;let t=this._sock.rQshift8();if(t!=0)return this._fail("VeNCrypt failure "+t);this._rfbVeNCryptState=2}if(this._rfbVeNCryptState==2){if(this._sock.rQwait("vencrypt subtypes length",1))return!1;let t=this._sock.rQshift8();if(t<1)return this._fail("VeNCrypt subtypes empty");this._rfbVeNCryptSubtypesLength=t,this._rfbVeNCryptState=3}if(this._rfbVeNCryptState==3){if(this._sock.rQwait("vencrypt subtypes",4*this._rfbVeNCryptSubtypesLength))return!1;let t=[];for(let e=0;e0&&this._sock.rQwait("tunnel capabilities",16*r,4))return!1;if(this._rfbTightVNC=!0,r>0)return this._negotiateTightTunnels(r),!1}if(this._sock.rQwait("sub auth count",4))return!1;let t=this._sock.rQshift32();if(t===0)return this._rfbInitState="SecurityResult",!0;if(this._sock.rQwait("sub auth capabilities",16*t,4))return!1;let e={STDVNOAUTH__:1,STDVVNCAUTH_:2,TGHTULGNAUTH:129},s=[];for(let r=0;rthis._rfbCredentials),this._rfbRSAAESAuthenticationState.addEventListener("serververification",this._eventHandlers.handleRSAAESServerVerification),this._rfbRSAAESAuthenticationState.addEventListener("credentialsrequired",this._eventHandlers.handleRSAAESCredentialsRequired)),this._rfbRSAAESAuthenticationState.checkInternalEvents(),this._rfbRSAAESAuthenticationState.hasStarted||this._rfbRSAAESAuthenticationState.negotiateRA2neAuthAsync().catch(t=>{t.message!=="disconnect normally"&&this._fail(t.message)}).then(()=>(this._rfbInitState="SecurityResult",!0)).finally(()=>{this._rfbRSAAESAuthenticationState.removeEventListener("serververification",this._eventHandlers.handleRSAAESServerVerification),this._rfbRSAAESAuthenticationState.removeEventListener("credentialsrequired",this._eventHandlers.handleRSAAESCredentialsRequired),this._rfbRSAAESAuthenticationState=null}),!1}_negotiateMSLogonIIAuth(){if(this._sock.rQwait("mslogonii dh param",24))return!1;if(this._rfbCredentials.username===void 0||this._rfbCredentials.password===void 0)return this.dispatchEvent(new CustomEvent("credentialsrequired",{detail:{types:["username","password"]}})),!1;let t=this._sock.rQshiftBytes(8),e=this._sock.rQshiftBytes(8),s=this._sock.rQshiftBytes(8),r=O.generateKey({name:"DH",g:t,p:e},!0,["deriveBits"]),n=O.exportKey("raw",r.publicKey),a=O.deriveBits({name:"DH",public:s},r.privateKey,64),h=O.importKey("raw",a,{name:"DES-CBC"},!1,["encrypt"]),_=he(this._rfbCredentials.username).substring(0,255),o=he(this._rfbCredentials.password).substring(0,63),l=new Uint8Array(256),x=new Uint8Array(64);window.crypto.getRandomValues(l),window.crypto.getRandomValues(x);for(let u=0;u<_.length;u++)l[u]=_.charCodeAt(u);l[_.length]=0;for(let u=0;u=3.8?this._rfbInitState="SecurityResult":this._rfbInitState="ClientInitialisation",!0;case Ur:return this._negotiateXvpAuth();case Nr:return this._negotiateARDAuth();case hi:return this._negotiateStdVNCAuth();case Hr:return this._negotiateTightAuth();case Yi:return this._negotiateVeNCryptAuth();case Gr:return this._negotiatePlainAuth();case zr:return this._negotiateTightUnixAuth();case Pr:return this._negotiateRA2neAuth();case Or:return this._negotiateMSLogonIIAuth();default:return this._fail("Unsupported auth scheme (scheme: "+this._rfbAuthScheme+")")}}_handleSecurityResult(){if(this._sock.rQwait("VNC auth response ",4))return!1;let t=this._sock.rQshift32();return t===0?(this._rfbInitState="ClientInitialisation",L("Authentication OK"),!0):this._rfbVersion>=3.8?(this._rfbInitState="SecurityReason",this._securityContext="security result",this._securityStatus=t,!0):(this.dispatchEvent(new CustomEvent("securityfailure",{detail:{status:t}})),this._fail("Security handshake failed"))}_negotiateServerInit(){if(this._sock.rQwait("server initialization",24))return!1;let t=this._sock.rQshift16(),e=this._sock.rQshift16(),s=this._sock.rQshift8(),r=this._sock.rQshift8(),n=this._sock.rQshift8(),a=this._sock.rQshift8(),h=this._sock.rQshift16(),_=this._sock.rQshift16(),o=this._sock.rQshift16(),l=this._sock.rQshift8(),x=this._sock.rQshift8(),u=this._sock.rQshift8();this._sock.rQskipBytes(3);let d=this._sock.rQshift32();if(this._sock.rQwait("server init name",d,24))return!1;let X=this._sock.rQshiftStr(d);if(X=Bt(X,!0),this._rfbTightVNC){if(this._sock.rQwait("TightVNC extended server init header",8,24+d))return!1;let F=this._sock.rQshift16(),A=this._sock.rQshift16(),k=this._sock.rQshift16();this._sock.rQskipBytes(2);let T=(F+A+k)*16;if(this._sock.rQwait("TightVNC extended server init header",T,32+d))return!1;this._sock.rQskipBytes(16*F),this._sock.rQskipBytes(16*A),this._sock.rQskipBytes(16*k)}return V("Screen: "+t+"x"+e+", bpp: "+s+", depth: "+r+", bigEndian: "+n+", trueColor: "+a+", redMax: "+h+", greenMax: "+_+", blueMax: "+o+", redShift: "+l+", greenShift: "+x+", blueShift: "+u),this._setDesktopName(X),this._resize(t,e),this._viewOnly||this._keyboard.grab(),this._fbDepth=24,this._fbName==="Intel(r) AMT KVM"&&(W("Intel AMT KVM only supports 8/16 bit depths. Using low color mode."),this._fbDepth=8),i.messages.pixelFormat(this._sock,this._fbDepth,!0),this._sendEncodings(),i.messages.fbUpdateRequest(this._sock,!1,0,0,this._fbWidth,this._fbHeight),this._updateConnectionState("connected"),!0}_sendEncodings(){let t=[];t.push(R.encodingCopyRect),this._fbDepth==24&&(fi&&t.push(R.encodingH264),t.push(R.encodingTight),t.push(R.encodingTightPNG),t.push(R.encodingZRLE),t.push(R.encodingJPEG),t.push(R.encodingHextile),t.push(R.encodingRRE),t.push(R.encodingZlib)),t.push(R.encodingRaw),t.push(R.pseudoEncodingQualityLevel0+this._qualityLevel),t.push(R.pseudoEncodingCompressLevel0+this._compressionLevel),t.push(R.pseudoEncodingDesktopSize),t.push(R.pseudoEncodingLastRect),t.push(R.pseudoEncodingQEMUExtendedKeyEvent),t.push(R.pseudoEncodingQEMULedEvent),t.push(R.pseudoEncodingExtendedDesktopSize),t.push(R.pseudoEncodingXvp),t.push(R.pseudoEncodingFence),t.push(R.pseudoEncodingContinuousUpdates),t.push(R.pseudoEncodingDesktopName),t.push(R.pseudoEncodingExtendedClipboard),t.push(R.pseudoEncodingExtendedMouseButtons),this._fbDepth==24&&(t.push(R.pseudoEncodingVMwareCursor),t.push(R.pseudoEncodingCursor)),i.messages.clientEncodings(this._sock,t)}_initMsg(){switch(this._rfbInitState){case"ProtocolVersion":return this._negotiateProtocolVersion();case"Security":return this._negotiateSecurity();case"Authentication":return this._negotiateAuthentication();case"SecurityResult":return this._handleSecurityResult();case"SecurityReason":return this._handleSecurityReason();case"ClientInitialisation":return this._sock.sQpush8(this._shared?1:0),this._sock.flush(),this._rfbInitState="ServerInitialisation",!0;case"ServerInitialisation":return this._negotiateServerInit();default:return this._fail("Unknown init state (state: "+this._rfbInitState+")")}}_resumeAuthentication(){setTimeout(this._initMsg.bind(this),0)}_handleSetColourMapMsg(){return L("SetColorMapEntries"),this._fail("Unexpected SetColorMapEntries message")}_handleServerCutText(){if(L("ServerCutText"),this._sock.rQwait("ServerCutText header",7,1))return!1;this._sock.rQskipBytes(3);let t=this._sock.rQshift32();if(t=it(t),this._sock.rQwait("ServerCutText content",Math.abs(t),8))return!1;if(t>=0){let e=this._sock.rQshiftStr(t);if(this._viewOnly)return!0;this.dispatchEvent(new CustomEvent("clipboard",{detail:{text:e}}))}else{t=Math.abs(t);let e=this._sock.rQshift32(),s=e&65535,r=e&4278190080;if(!!(r&Vr)){this._clipboardServerCapabilitiesFormats={},this._clipboardServerCapabilitiesActions={};for(let h=0;h<=15;h++){let _=1<0&&_.charAt(_.length-1)==="\0"&&(_=_.slice(0,-1)),_=_.replaceAll(`\r +`,` +`),this.dispatchEvent(new CustomEvent("clipboard",{detail:{text:_}}))}}else return this._fail("Unexpected action in extended clipboard message: "+r)}return!0}_handleServerFenceMsg(){if(this._sock.rQwait("ServerFence header",8,1))return!1;this._sock.rQskipBytes(3);let t=this._sock.rQshift32(),e=this._sock.rQshift8();if(this._sock.rQwait("ServerFence payload",e,9))return!1;e>64&&(W("Bad payload length ("+e+") in fence response"),e=64);let s=this._sock.rQshiftStr(e);return this._supportsFence=!0,t&1<<31?(t&=3,i.messages.clientFence(this._sock,t,s),!0):this._fail("Unexpected fence response")}_handleXvpMsg(){if(this._sock.rQwait("XVP version and message",3,1))return!1;this._sock.rQskipBytes(1);let t=this._sock.rQshift8(),e=this._sock.rQshift8();switch(e){case 0:U("XVP operation failed");break;case 1:this._rfbXvpVer=t,V("XVP extensions enabled (version "+this._rfbXvpVer+")"),this._setCapability("power",!0);break;default:this._fail("Illegal server XVP message (msg: "+e+")");break}return!0}_normalMsg(){let t;this._FBU.rects>0?t=0:t=this._sock.rQshift8();let e,s;switch(t){case 0:return s=this._framebufferUpdate(),s&&!this._enabledContinuousUpdates&&i.messages.fbUpdateRequest(this._sock,!0,0,0,this._fbWidth,this._fbHeight),s;case 1:return this._handleSetColourMapMsg();case 2:return L("Bell"),this.dispatchEvent(new CustomEvent("bell",{detail:{}})),!0;case 3:return this._handleServerCutText();case 150:return e=!this._supportsContinuousUpdates,this._supportsContinuousUpdates=!0,this._enabledContinuousUpdates=!1,e&&(this._enabledContinuousUpdates=!0,this._updateContinuousUpdates(),V("Enabling continuous updates.")),!0;case 248:return this._handleServerFenceMsg();case 250:return this._handleXvpMsg();default:return this._fail("Unexpected server message (type "+t+")"),L("sock.rQpeekBytes(30): "+this._sock.rQpeekBytes(30)),!0}}_framebufferUpdate(){if(this._FBU.rects===0){if(this._sock.rQwait("FBU header",3,1))return!1;if(this._sock.rQskipBytes(1),this._FBU.rects=this._sock.rQshift16(),this._display.pending())return this._flushing=!0,this._display.flush().then(()=>{this._flushing=!1,this._sock.rQwait("message",1)||this._handleMessage()}),!1}for(;this._FBU.rects>0;){if(this._FBU.encoding===null){if(this._sock.rQwait("rect header",12))return!1;this._FBU.x=this._sock.rQshift16(),this._FBU.y=this._sock.rQshift16(),this._FBU.width=this._sock.rQshift16(),this._FBU.height=this._sock.rQshift16(),this._FBU.encoding=this._sock.rQshift32(),this._FBU.encoding>>=0}if(!this._handleRect())return!1;this._FBU.rects--,this._FBU.encoding=null}return this._display.flip(),!0}_handleRect(){switch(this._FBU.encoding){case R.pseudoEncodingLastRect:return this._FBU.rects=1,!0;case R.pseudoEncodingVMwareCursor:return this._handleVMwareCursor();case R.pseudoEncodingCursor:return this._handleCursor();case R.pseudoEncodingQEMUExtendedKeyEvent:return this._qemuExtKeyEventSupported=!0,!0;case R.pseudoEncodingDesktopName:return this._handleDesktopName();case R.pseudoEncodingDesktopSize:return this._resize(this._FBU.width,this._FBU.height),!0;case R.pseudoEncodingExtendedDesktopSize:return this._handleExtendedDesktopSize();case R.pseudoEncodingExtendedMouseButtons:return this._extendedPointerEventSupported=!0,!0;case R.pseudoEncodingQEMULedEvent:return this._handleLedEvent();default:return this._handleDataRect()}}_handleVMwareCursor(){let t=this._FBU.x,e=this._FBU.y,s=this._FBU.width,r=this._FBU.height;if(this._sock.rQwait("VMware cursor encoding",1))return!1;let n=this._sock.rQshift8();this._sock.rQshift8();let a,h=4;if(n==0){if(a=new Array(s*r*h),this._sock.rQwait("VMware cursor classic encoding",s*r*h*2,2))return!1;let o=new Array(s*r);for(let x=0;x>8&255,X=u>>16&255,F=u>>24&255;a[x*h]=d,a[x*h+1]=X,a[x*h+2]=F,a[x*h+3]=255}else(o[x]&-256)==-256?l[x]==0?(a[x*h]=0,a[x*h+1]=0,a[x*h+2]=0,a[x*h+3]=0):(l[x]&-256,a[x*h]=0,a[x*h+1]=0,a[x*h+2]=0,a[x*h+3]=255):(a[x*h]=0,a[x*h+1]=0,a[x*h+2]=0,a[x*h+3]=255)}else if(n==1){if(this._sock.rQwait("VMware cursor alpha encoding",s*r*4,2))return!1;a=new Array(s*r*h);for(let _=0;_>24&255,a[_*4+1]=o>>16&255,a[_*4+2]=o>>8&255,a[_*4+3]=o&255}}else return W("The given cursor type is not supported: "+n+" given."),!1;return this._updateCursor(a,t,e,s,r),!0}_handleCursor(){let t=this._FBU.x,e=this._FBU.y,s=this._FBU.width,r=this._FBU.height,n=s*r*4,a=Math.ceil(s/8)*r,h=n+a;if(this._sock.rQwait("cursor encoding",h))return!1;let _=this._sock.rQshiftBytes(n),o=this._sock.rQshiftBytes(a),l=new Uint8Array(s*r*4),x=0;for(let u=0;un.charCodeAt(0)),r=O.importKey("raw",s,{name:"DES-ECB"},!1,["encrypt"]);return O.encrypt({name:"DES-ECB"},r,e)}};se.messages={keyEvent(i,t,e){i.sQpush8(4),i.sQpush8(e),i.sQpush16(0),i.sQpush32(t),i.flush()},QEMUExtendedKeyEvent(i,t,e,s){function r(a){let h=s>>8,_=s&255;return h===224&&_<127?_|128:a}i.sQpush8(255),i.sQpush8(0),i.sQpush16(e),i.sQpush32(t);let n=r(s);i.sQpush32(n),i.flush()},pointerEvent(i,t,e,s){i.sQpush8(5),s=s&127,i.sQpush8(s),i.sQpush16(t),i.sQpush16(e),i.flush()},extendedPointerEvent(i,t,e,s){i.sQpush8(5);let r=s>>7&255;if(r&252)throw new Error("Invalid mouse button mask: "+s);let n=s&127;n|=128,i.sQpush8(n),i.sQpush16(t),i.sQpush16(e),i.sQpush8(r),i.flush()},_buildExtendedClipboardFlags(i,t){let e=new Uint8Array(4),s=0,r=0;for(let n=0;n>24,e[1]=0,e[2]=0,e[3]=s,e},extendedClipboardProvide(i,t,e){let s=new gt,r=[];for(let h=0;h>24&255,_.length>>16&255,_.length>>8&255,_.length&255);for(let o=0;o<_.length;o++)r.push(_.charCodeAt(o))}let n=s.deflate(new Uint8Array(r)),a=new Uint8Array(4+n.length);a.set(se.messages._buildExtendedClipboardFlags([ci],t)),a.set(n,4),se.messages.clientCutText(i,a,!0)},extendedClipboardNotify(i,t){let e=se.messages._buildExtendedClipboardFlags([Rt],t);se.messages.clientCutText(i,e,!0)},extendedClipboardRequest(i,t){let e=se.messages._buildExtendedClipboardFlags([li],t);se.messages.clientCutText(i,e,!0)},extendedClipboardCaps(i,t,e){let s=Object.keys(e),r=new Uint8Array(4+4*s.length);s.map(a=>parseInt(a)),s.sort((a,h)=>a-h),r.set(se.messages._buildExtendedClipboardFlags(t,[]));let n=4;for(let a=0;a>24,r[n+1]=e[s[a]]>>16,r[n+2]=e[s[a]]>>8,r[n+3]=e[s[a]]>>0,n+=4,r[3]|=1<16?s=32:t>8?s=16:s=8;let r=Math.floor(t/3);i.sQpush8(0),i.sQpush8(0),i.sQpush8(0),i.sQpush8(0),i.sQpush8(s),i.sQpush8(t),i.sQpush8(0),i.sQpush8(e?1:0),i.sQpush16((1<"u"&&(e=0),typeof s>"u"&&(s=0),i.sQpush8(3),i.sQpush8(t?1:0),i.sQpush16(e),i.sQpush16(s),i.sQpush16(r),i.sQpush16(n),i.flush()},xvpOp(i,t,e){i.sQpush8(250),i.sQpush8(0),i.sQpush8(t),i.sQpush8(e),i.flush()}};se.cursors={none:{rgbaPixels:new Uint8Array,w:0,h:0,hotx:0,hoty:0},dot:{rgbaPixels:new Uint8Array([255,255,255,255,0,0,0,255,255,255,255,255,0,0,0,255,0,0,0,0,0,0,0,255,255,255,255,255,0,0,0,255,255,255,255,255]),w:3,h:3,hotx:1,hoty:1}};var Lo=se;export{Lo as default}; diff --git a/mobile/screen.html b/mobile/screen.html new file mode 100644 index 0000000..9e482a3 --- /dev/null +++ b/mobile/screen.html @@ -0,0 +1,109 @@ + + + + + + +Bot 的電腦 + + + + +
+ +
Bot 的電腦
連線中…
+ + +
+
連線中…
+
+ + + +
+ + + diff --git a/relay/src/main.rs b/relay/src/main.rs index 5ca309e..17a07e9 100644 --- a/relay/src/main.rs +++ b/relay/src/main.rs @@ -29,6 +29,9 @@ use std::sync::{Arc, Condvar, Mutex}; use std::time::{Duration, Instant}; const PAGE: &str = include_str!("../../mobile/index.html"); +/// The bots' computer, watched and used in a browser (noVNC). +const SCREEN_PAGE: &str = include_str!("../../mobile/screen.html"); +const NOVNC: &str = include_str!("../../mobile/novnc.js"); const MANIFEST: &str = r##"{"name":"AnyBot","short_name":"AnyBot","start_url":"./","display":"standalone","background_color":"#121212","theme_color":"#121212","icons":[{"src":"icon.svg","sizes":"any","type":"image/svg+xml"},{"src":"icon-192.png","sizes":"192x192","type":"image/png","purpose":"any maskable"},{"src":"icon-512.png","sizes":"512x512","type":"image/png","purpose":"any maskable"}]}"##; /// AnyBot's icon (the same drawing as the app's), and PNGs for the iPhone home screen and Android. const ICON: &str = include_str!("../../mobile/icon.svg"); @@ -200,6 +203,10 @@ struct Relay { /// The brain each AnyBot shows (when it is a screen of another), and which lend their hands. uses: Mutex>, hands: Mutex>, + /// People watching a bots' computer: stream id → (where the host's side goes, that host). + streams: Mutex>, String)>>, + /// One-time links for a browser to watch a host's computer: ticket → (account, host, made). + tickets: Mutex>, /// 配對碼: code → (account, the AnyBot it is for, when made). Ten minutes, once. pairs: Mutex>, /// When each host last asked (in memory; saved now and then). @@ -210,6 +217,32 @@ struct Relay { contact: String, } +/// How long a link the app opens for a browser works (reconnects included). +const TICKET_LIFE: Duration = Duration::from_secs(600); + +/// How long a connection's loop waits to read before sending what is queued: 2 ms while things +/// move (a live screen), 40 ms when quiet. A fixed 40 ms added up to 40 ms to every message. +struct Pace { + moved: Instant, + busy: Option, +} + +impl Pace { + fn new() -> Pace { + Pace { moved: Instant::now() - Duration::from_secs(60), busy: None } + } + fn moved(&mut self) { + self.moved = Instant::now(); + } + fn tune(&mut self, c: &std::net::TcpStream) { + let busy = self.moved.elapsed() < Duration::from_secs(2); + if self.busy != Some(busy) { + self.busy = Some(busy); + let _ = c.set_read_timeout(Some(Duration::from_millis(if busy { 2 } else { 40 }))); + } + } +} + fn now() -> i64 { chrono::Utc::now().timestamp() } @@ -229,6 +262,8 @@ impl Relay { flights: Mutex::new(HashMap::new()), uses: Mutex::new(HashMap::new()), hands: Mutex::new(std::collections::HashSet::new()), + streams: Mutex::new(HashMap::new()), + tickets: Mutex::new(HashMap::new()), pairs: Mutex::new(HashMap::new()), seen: Mutex::new(HashMap::new()), fails: Mutex::new(HashMap::new()), @@ -294,6 +329,95 @@ impl Relay { e.1 = Instant::now(); } + /// A person's browser watching (and maybe using) an AnyBot's computer: its VNC stream goes + /// to that AnyBot over its lasting connection, and back. Signed in by the account session + /// (?t=, the phone page) or a ticket the account's app asked for (?ticket=); owners and + /// admins only. + fn vnc_viewer(self: &Arc, req: Req, mut c: std::net::TcpStream, host: &str) { + use std::io::Write; + use tungstenite::{protocol::Role, Message, WebSocket}; + let user = match req.query.get("ticket").filter(|t| !t.is_empty()) { + Some(t) => self.tickets.lock().unwrap().get(t).filter(|(_, h, made)| h == host && made.elapsed() < TICKET_LIFE).map(|(u, _, _)| u.clone()), + None => self.session(&req), + }; + let Some(user) = user else { return httpd::write_response(&mut c, &Resp::error(401, "請先登入")) }; + let allowed = self.data.lock().unwrap().hosts.iter().any(|x| x.id == host && matches!(x.role_of(&user).as_deref(), Some("owner") | Some("admin"))); + if !allowed { + return httpd::write_response(&mut c, &Resp::error(404, "找不到這台 AnyBot")); + } + let Some(to_host) = self.sockets.lock().unwrap().get(host).map(|s| s.tx.clone()) else { + return httpd::write_response(&mut c, &Resp::error(503, "這台 AnyBot 現在沒有連線")); + }; + let key = req.header("sec-websocket-key").to_string(); + if key.is_empty() { + return httpd::write_response(&mut c, &Resp::error(400, "bad request")); + } + let accept = tungstenite::handshake::derive_accept_key(key.as_bytes()); + if c.write_all(format!("HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: {accept}\r\n\r\n").as_bytes()).is_err() { + return; + } + let _ = c.set_nodelay(true); + let mut ws = WebSocket::from_raw_socket(c, Role::Server, None); + let sid = httpd::random_hex(8); + let (tx, rx) = channel::>(); + self.streams.lock().unwrap().insert(sid.clone(), (tx, host.to_string())); + if to_host.send(wire::pack(&json!({"t": "vopen", "sid": sid}), &[])).is_err() { + self.streams.lock().unwrap().remove(&sid); + return; + } + let (mut heard, mut pinged) = (Instant::now(), Instant::now()); + let mut pace = Pace::new(); + 'live: loop { + pace.tune(ws.get_ref()); + let up = match ws.read() { + Ok(Message::Binary(b)) => Some(("vdata", b.to_vec())), + Ok(Message::Text(t)) => Some(("vtext", t.as_bytes().to_vec())), + Ok(Message::Close(_)) => break, + Ok(_) => None, + Err(tungstenite::Error::Io(e)) if matches!(e.kind(), std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut) => None, + Err(_) => break, + }; + if let Some((t, b)) = up { + heard = Instant::now(); + pace.moved(); + if to_host.send(wire::pack(&json!({"t": t, "sid": sid}), &b)).is_err() { + break; + } + } + loop { + match rx.try_recv() { + Ok(b) => { + pace.moved(); + if ws.write(Message::Binary(b.into())).is_err() { + break 'live; + } + } + Err(std::sync::mpsc::TryRecvError::Empty) => break, + // The host closed it (or went away). + Err(std::sync::mpsc::TryRecvError::Disconnected) => break 'live, + } + } + if pinged.elapsed().as_secs() >= WS_PING_SECS { + pinged = Instant::now(); + if ws.write(Message::Ping(Vec::new().into())).is_err() { + break; + } + } + match ws.flush() { + Err(tungstenite::Error::Io(e)) if matches!(e.kind(), std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut) => {} + Err(_) => break, + Ok(()) => {} + } + if heard.elapsed().as_secs() >= WS_DEAD_SECS { + break; + } + } + let _ = ws.close(None); + let _ = ws.flush(); + self.streams.lock().unwrap().remove(&sid); + let _ = to_host.send(wire::pack(&json!({"t": "vclose", "sid": sid}), &[])); + } + /// Hand a request to a host and wait for its answer. fn forward(&self, host_id: &str, method: &str, path: &str, query: &HashMap, body: &[u8], who: (&str, &str)) -> Resp { self.forward_raw(host_id, method, path, query, body, false, who, false, "").into_resp() @@ -358,6 +482,10 @@ impl Relay { fn ws(self: &Arc, req: Req, mut c: std::net::TcpStream) { use std::io::Write; use tungstenite::{protocol::Role, Message, WebSocket}; + if let Some(host) = req.path.strip_prefix("/relay/vnc/") { + let host = host.to_string(); + return self.vnc_viewer(req, c, &host); + } if req.path != "/relay/host/ws" { return httpd::write_response(&mut c, &Resp::error(404, "沒有這個網址")); } @@ -379,10 +507,13 @@ impl Relay { self.sockets.lock().unwrap().insert(h.id.clone(), Sock { tx: tx.clone(), serial }); self.seen.lock().unwrap().insert(h.id.clone(), Instant::now()); let (mut heard, mut pinged, mut marked) = (Instant::now(), Instant::now(), Instant::now()); + let mut pace = Pace::new(); 'live: loop { + pace.tune(ws.get_ref()); match ws.read() { Ok(Message::Binary(b)) => { heard = Instant::now(); + pace.moved(); self.on_frame(&h, &b, &tx); } Ok(Message::Close(_)) => break, @@ -391,6 +522,7 @@ impl Relay { Err(_) => break, } while let Ok(f) = rx.try_recv() { + pace.moved(); if ws.write(Message::Binary(f.into())).is_err() { break 'live; } @@ -414,7 +546,8 @@ impl Relay { self.seen.lock().unwrap().insert(h.id.clone(), Instant::now()); } } - // Gone: whoever waits on it hears so now, not after a timeout. + // Gone: its viewers' streams end, and whoever waits on it hears so now, not after a timeout. + self.streams.lock().unwrap().retain(|_, (_, host)| *host != h.id); { let mut socks = self.sockets.lock().unwrap(); if socks.get(&h.id).map(|s| s.serial) == Some(serial) { @@ -434,6 +567,20 @@ impl Relay { let Some((head, body)) = wire::unpack(b) else { return }; let s = |k: &str| head[k].as_str().unwrap_or("").to_string(); match head["t"].as_str().unwrap_or("") { + // A viewer's stream: only the host it was opened on may write to it. + "vdata" => { + if let Some((v, host)) = self.streams.lock().unwrap().get(&s("sid")) { + if *host == h.id { + let _ = v.send(body); + } + } + } + "vclose" => { + let mut all = self.streams.lock().unwrap(); + if all.get(&s("sid")).map(|(_, host)| *host == h.id).unwrap_or(false) { + all.remove(&s("sid")); + } + } "reply" => { // Only the host the job went to may answer it. let w = { @@ -499,6 +646,25 @@ impl Relay { ("GET", []) | ("GET", ["index.html"]) => Resp::bytes("text/html; charset=utf-8", PAGE.as_bytes().to_vec()) .with_header("Content-Security-Policy", "default-src 'self'; img-src 'self' blob: data:; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'; connect-src 'self'") .with_header("Strict-Transport-Security", "max-age=31536000"), + ("GET", ["screen"]) => Resp::bytes("text/html; charset=utf-8", SCREEN_PAGE.as_bytes().to_vec()) + .with_header("Content-Security-Policy", "default-src 'self'; img-src 'self' blob: data:; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'; connect-src 'self' wss: ws:") + .with_header("Strict-Transport-Security", "max-age=31536000"), + ("GET", ["novnc.js"]) => Resp::bytes("text/javascript; charset=utf-8", NOVNC.as_bytes().to_vec()).with_header("Cache-Control", "public, max-age=86400"), + // A link for this account's browser to watch one of its AnyBots' computer (the app + // opens it; the browser has no session of its own). + ("POST", ["relay", "screen-ticket"]) => { + let Some(h) = self.host(&req) else { return Resp::error(401, "這台電腦的金鑰無效") }; + let want = req.json()["host"].as_str().unwrap_or("").to_string(); + let ok = self.data.lock().unwrap().hosts.iter().any(|x| x.id == want && matches!(x.role_of(&h.user).as_deref(), Some("owner") | Some("admin"))); + if !ok { + return Resp::error(404, "找不到這台 AnyBot"); + } + let t = httpd::random_hex(24); + let mut all = self.tickets.lock().unwrap(); + all.retain(|_, (_, _, made)| made.elapsed() < TICKET_LIFE); + all.insert(t.clone(), (h.user.clone(), want, Instant::now())); + Resp::json(&json!({"ticket": t})) + } ("GET", ["manifest.webmanifest"]) => Resp::bytes("application/manifest+json", MANIFEST.as_bytes().to_vec()), ("GET", ["icon.svg"]) => Resp::bytes("image/svg+xml", ICON.as_bytes().to_vec()), ("GET", [name]) if ICON_PNGS.iter().any(|(n, _)| n == name) => Resp::bytes("image/png", ICON_PNGS.iter().find(|(n, _)| n == name).map(|(_, b)| b.to_vec()).unwrap_or_default()), diff --git a/src/app.rs b/src/app.rs index 41c3f34..8a8658e 100644 --- a/src/app.rs +++ b/src/app.rs @@ -371,13 +371,6 @@ impl App { app.state.selected = 2; app.show_routines = Some(2); } - Ok("viewer") => { - crate::cloudbox::start_background(); - let _ = crate::cloudbox::ensure_running(); - app.cu.demo_live = true; - app.cu.viewer_open = true; - app.cu.fit = true; - } Ok("attach") => { app.state.selected = 1; let dir = std::env::temp_dir().join("anybot-shot-attach"); @@ -511,7 +504,6 @@ impl App { // The real box, so the 電腦 card shows its screen. crate::cloudbox::start_background(); let _ = crate::cloudbox::ensure_running(); - app.cu.demo_live = true; app.state.selected = 1; app.panel = Some(panel_ui::PanelTab::Activity); } @@ -3427,11 +3419,6 @@ impl App { ctx.send_viewport_cmd(egui::ViewportCommand::InnerSize(vec2(w, h))); } } - // Real data: the computer viewer opens a little after start, as a person would open it (a - // screen of a brain has said so by then, so it shows the brain's computer). - if self.frames == 10 && !self.demo && std::env::var("ANYBOT_SHOT_VIEW").as_deref() == Ok("viewer") { - self.open_viewer(); - } // ANYBOT_SHOT_PAGE=id: open that page late (a screen gets its pages from the brain first). if self.frames == need * 3 / 4 { if let Some(id) = std::env::var("ANYBOT_SHOT_PAGE").ok().and_then(|v| v.parse().ok()) { @@ -3767,7 +3754,6 @@ impl eframe::App for App { self.delete_dialog(ctx); self.routines_window(ctx); self.rebuild_dialog(ctx); - self.viewer_window(ctx); self.local_control_bar(ctx); self.teach_bar(ctx); self.approval_popup(ctx); diff --git a/src/app/computers_ui.rs b/src/app/computers_ui.rs index 794466b..9f78e72 100644 --- a/src/app/computers_ui.rs +++ b/src/app/computers_ui.rs @@ -7,9 +7,6 @@ use super::*; use crate::cloudbox::{self, Rebuild, Run}; -/// 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 floating bar stays this long after the bot's last mouse/keyboard action on this Mac. const LOCAL_BAR_SECS: u64 = 8; @@ -30,16 +27,8 @@ pub struct ComputerUi { pub confirm_reset: bool, /// 一律允許的應用程式 is expanded. pub apps_open: bool, - pub viewer_open: bool, - /// 取得控制權: the person's clicks and keys go to the box. + /// 接管 during a hand-off: this app told the bots' computer to let the person have it. pub control: bool, - pub fit: bool, - texture: Option, - frame: Arc>>, - shown_seq: u64, - frame_size: [usize; 2], - worker: Option>, - input_tx: Option>, pub confirm: Option, pub note: Arc>, disk: Arc>>, @@ -51,8 +40,6 @@ pub struct ComputerUi { teach_name: String, teach_for: Option, teach_error: String, - /// Screenshot mode: show the real box in the viewer. - pub demo_live: bool, schedule_edit: String, pub update_ready: bool, update_checked: Option, @@ -62,16 +49,6 @@ pub struct ComputerUi { thumb_img: Arc>>, thumb_busy: Arc, thumb_at: Option, - /// The viewer shows the brain's computer (this window is a screen of a brain): how to reach it. - remote: Option, -} - -enum Input { - Click(i64, i64, &'static str, u32), - Move(i64, i64), - Scroll(i64, i64, bool, u32), - Key(String), - Text(String), } fn status_color(r: &Run, p: Palette) -> Color32 { @@ -103,16 +80,6 @@ fn status_pill(ui: &mut egui::Ui, r: &Run, p: Palette) { } /// A little monitor drawn with lines (no emoji font needed). -fn monitor_icon(ui: &mut egui::Ui, size: f32, col: Color32) { - let (rect, _) = ui.allocate_exact_size(vec2(size, size), egui::Sense::hover()); - let s = size / 20.0; - let screen = egui::Rect::from_min_size(rect.min + vec2(2.0 * s, 3.0 * s), vec2(16.0 * s, 11.0 * s)); - ui.painter().rect_stroke(screen, 2.5 * s, Stroke::new(1.5, col), egui::StrokeKind::Inside); - ui.painter().line_segment([rect.min + vec2(10.0 * s, 14.0 * s), rect.min + vec2(10.0 * s, 17.0 * s)], Stroke::new(1.5, col)); - ui.painter().line_segment([rect.min + vec2(6.5 * s, 17.5 * s), rect.min + vec2(13.5 * s, 17.5 * s)], Stroke::new(1.5, col)); -} - - impl App { /// Called once at start. pub(super) fn computers_start(&mut self) { @@ -946,455 +913,67 @@ impl App { self.cu.thumb.as_ref().map(|t| (t.id(), t.size_vec2())) } + /// 看畫面: the bots' computer in the browser (noVNC), live, where the person can also take + /// over. On a screen of a brain it is the brain's computer, through the account server (a + /// link made for this once); here it is this computer's own box. pub(super) fn open_viewer(&mut self) { - self.cu.viewer_open = true; - self.cu.fit = true; + self.open_screen(false); + } + + fn open_screen(&mut self, control: bool) { self.show_settings = false; - // A screen of a brain watches the brain's computer (where its bots really work). + let name = self.state.bots.iter().find(|b| b.id == self.state.selected).map(|b| format!("{} 的電腦", b.name)).unwrap_or_else(|| "Bot 的電腦".into()); if self.mir.on { let s = self.state.remote.clone(); - self.cu.remote = Some(s.clone()); - if let Some(stop) = self.cu.worker.take() { - stop.store(true, Ordering::SeqCst); - } - self.cu.texture = None; - std::thread::spawn(move || { - let _ = crate::remote::sync_post(&s, "screen", &serde_json::json!({"kind": "viewer", "on": true})); - let _ = crate::remote::sync_post(&s, "screen", &serde_json::json!({"kind": "wake"})); + self.box_task(move || { + let ticket = crate::remote::screen_ticket(&s)?; + let enc = |t: &str| t.bytes().map(|b| if b.is_ascii_alphanumeric() { (b as char).to_string() } else { format!("%{b:02X}") }).collect::(); + let url = format!("{}/screen#host={}&ticket={ticket}&name={}{}", s.relay_url.trim_end_matches('/'), s.brain_id, enc(&name), if control { "&control=1" } else { "" }); + crate::preview::open_with_system(&url); + Ok(String::new()) }); return; } - if self.cu.remote.take().is_some() { - if let Some(stop) = self.cu.worker.take() { - stop.store(true, Ordering::SeqCst); - } - self.cu.texture = None; - } - cloudbox::set_viewer_open(true); - if !cloudbox::run_state().is_running() && !self.demo { - self.box_task(|| cloudbox::ensure_running().map(|_| String::new())); + if self.demo { + return; } + self.box_task(move || { + let url = cloudbox::viewer_url(control)?; + crate::preview::open_with_system(&url); + Ok(String::new()) + }); } - /// 接管: open the computer and take control (the bot waits for the person anyway). + /// 接管: open the computer with control (the bot waits for the person meanwhile). pub(super) fn take_over_computer(&mut self) { - self.open_viewer(); + self.open_screen(true); if self.cu.control { return; } self.cu.control = true; - match self.cu.remote.clone() { - Some(s) => { - std::thread::spawn(move || { - let _ = crate::remote::sync_post(&s, "screen", &serde_json::json!({"kind": "hold", "on": true})); - }); - } - None => cloudbox::HELD_BY_PERSON.store(true, Ordering::SeqCst), - } + self.hold_computer(true); } - /// 我完成了 / 跳過: the bot gets its computer back (its steps would otherwise wait for the - /// person to let go). + /// 我完成了 / 跳過: the bot gets its computer back. pub(super) fn give_back_computer(&mut self) { if !self.cu.control { return; } self.cu.control = false; - match self.cu.remote.clone() { - Some(s) => { - std::thread::spawn(move || { - let _ = crate::remote::sync_post(&s, "screen", &serde_json::json!({"kind": "hold", "on": false})); - }); - } - None => cloudbox::HELD_BY_PERSON.store(false, Ordering::SeqCst), - } + self.hold_computer(false); } - fn close_viewer(&mut self) { - self.cu.viewer_open = false; - self.cu.control = false; - if let Some(s) = self.cu.remote.clone() { + fn hold_computer(&self, on: bool) { + if self.mir.on { + let s = self.state.remote.clone(); std::thread::spawn(move || { - let _ = crate::remote::sync_post(&s, "screen", &serde_json::json!({"kind": "hold", "on": false})); - let _ = crate::remote::sync_post(&s, "screen", &serde_json::json!({"kind": "viewer", "on": false})); + let _ = crate::remote::sync_post(&s, "screen", &serde_json::json!({"kind": "hold", "on": on})); }); } else { - cloudbox::HELD_BY_PERSON.store(false, Ordering::SeqCst); - cloudbox::set_viewer_open(false); - } - if let Some(stop) = self.cu.worker.take() { - stop.store(true, Ordering::SeqCst); - } - self.cu.input_tx = None; - } - - fn ensure_viewer_threads(&mut self, ctx: &egui::Context) { - if self.cu.worker.is_some() || (self.demo && !self.cu.demo_live) { - return; - } - let stop = Arc::new(AtomicBool::new(false)); - self.cu.worker = Some(stop.clone()); - let slot = self.cu.frame.clone(); - let c = ctx.clone(); - let s2 = stop.clone(); - let brain = self.cu.remote.clone(); - if let Some(s) = brain.clone() { - // The brain's computer: pictures and the person's input go through the account server. - 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_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; - } - Ok(_) => {} - Err(_) => { - std::thread::sleep(std::time::Duration::from_millis(1500)); - c.request_repaint(); - } - } - } - }); - let (tx, rx) = channel::(); - self.cu.input_tx = Some(tx); - std::thread::spawn(move || { - let mut last_move = Instant::now(); - while let Ok(i) = rx.recv() { - let v = match i { - Input::Click(x, y, b, n) => serde_json::json!({"kind": "click", "x": x, "y": y, "button": b, "count": n}), - // Hovering sends many moves; a few a second are enough over the network. - Input::Move(x, y) => { - if last_move.elapsed().as_millis() < 250 { - continue; - } - last_move = Instant::now(); - serde_json::json!({"kind": "move", "x": x, "y": y}) - } - Input::Scroll(x, y, down, n) => serde_json::json!({"kind": "scroll", "x": x, "y": y, "down": down, "steps": n}), - Input::Key(k) => serde_json::json!({"kind": "key", "key": k}), - Input::Text(t) => serde_json::json!({"kind": "text", "text": t}), - }; - let _ = crate::remote::sync_post(&s, "screen", &v); - } - }); - return; - } - std::thread::spawn(move || { - let mut seq = 0u64; - while !s2.load(Ordering::SeqCst) { - 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(); - } - } - } - }); - let (tx, rx) = channel::(); - self.cu.input_tx = Some(tx); - std::thread::spawn(move || { - while let Ok(i) = rx.recv() { - match i { - Input::Click(x, y, b, n) => cloudbox::viewer_click(x, y, b, n), - Input::Move(x, y) => cloudbox::viewer_move(x, y), - Input::Scroll(x, y, down, n) => cloudbox::viewer_scroll(x, y, down, n), - Input::Key(k) => cloudbox::viewer_key(&k), - Input::Text(t) => cloudbox::viewer_text(&t), - } - } - }); - } - - pub(super) fn viewer_window(&mut self, ctx: &egui::Context) { - if !self.cu.viewer_open { - return; - } - self.ensure_viewer_threads(ctx); - let p = self.pal; - if let Some((seq, img)) = self.cu.frame.lock().unwrap().take() { - if seq != self.cu.shown_seq { - self.cu.shown_seq = seq; - self.cu.frame_size = img.size; - match &mut self.cu.texture { - Some(t) => t.set(img, egui::TextureOptions::LINEAR), - None => self.cu.texture = Some(ctx.load_texture("box-screen", img, egui::TextureOptions::LINEAR)), - } - } - } - let brain = self.cu.remote.is_some(); - // The brain's computer reports its state with the brain's answers. - let run = if brain { - match (self.mir.box_state["running"].as_bool().unwrap_or(false), self.mir.box_state["available"].as_bool().unwrap_or(true)) { - (true, _) => Run::Running, - (false, false) => Run::NoDocker("大腦那台沒有 Docker".into()), - _ => Run::Asleep, - } - } else { - cloudbox::run_state() - }; - let screen = ctx.content_rect(); - let open = true; - let default = vec2((screen.width() * 0.62).min(900.0), (screen.width() * 0.62).min(900.0) * 0.625 + 52.0); - let busy = if brain { self.mir.box_state["busy"].as_bool().unwrap_or(false) } else { cloudbox::is_busy() }; - let title = self.state.bot(self.state.selected).map(|b| crate::model::own_computer_name(&b.name)).unwrap_or_else(|| crate::model::BOT_COMPUTER_NAME.to_string()); - - let mut close_clicked = false; - let mut done_clicked = false; - let selected = self.state.selected; - let handoff = self.approvals.get(&selected).map(|a| a.kind == crate::providers::ApprovalKind::Handoff).unwrap_or(false); - egui::Window::new("box-viewer") - .id(egui::Id::unique("box-viewer")) - .title_bar(false) - .default_size(default) - .default_pos(screen.center() - default / 2.0) - .min_size(vec2(420.0, 300.0)) - .resizable(true) - .collapsible(false) - .frame( - Frame::new() - .fill(p.elevated) - .corner_radius(14) - .inner_margin(Margin::symmetric(14, 12)) - .stroke(Stroke::new(1.0, p.border)) - .shadow(egui::epaint::Shadow { offset: [0, 14], blur: 40, spread: 0, color: Color32::from_black_alpha(60) }), - ) - .show(ctx, |ui| { - // Buttons first, from the right; the title and state get what is left and end in - // … when it's short (the state used to slide under 「取得控制權」 in a narrow window). - ui.horizontal(|ui| { - ui.with_layout(Layout::right_to_left(Align::Center), |ui| { - let (rect, r) = ui.allocate_exact_size(vec2(28.0, 28.0), egui::Sense::click()); - if r.hovered() { - ui.painter().rect_filled(rect, 8, p.hover); - } - let c = rect.center(); - let st = Stroke::new(1.5, if r.hovered() { p.text } else { p.text2 }); - ui.painter().line_segment([c + vec2(-5.0, -5.0), c + vec2(5.0, 5.0)], st); - ui.painter().line_segment([c + vec2(-5.0, 5.0), c + vec2(5.0, -5.0)], st); - if r.on_hover_text(t("關閉")).clicked() { - close_clicked = true; - } - ui.add_space(4.0); - // The bot is waiting for the person here: done, and it carries on. - if handoff && ui.add(egui::Button::new(RichText::new(t("我完成了")).strong().color(p.on_primary)).fill(p.primary).corner_radius(8).min_size(vec2(84.0, 30.0))).clicked() { - done_clicked = true; - } - if !brain && ui.add(secondary_button("全螢幕")).on_hover_text(t("在瀏覽器用 noVNC 全螢幕開啟")).clicked() { - let control = self.cu.control; - self.box_task(move || { - let url = cloudbox::viewer_url(control)?; - crate::preview::open_with_system(&url); - Ok(String::new()) - }); - } - let fit_label = if self.cu.fit { "實際大小" } else { "調整至視窗大小" }; - if ui.add(secondary_button(fit_label)).clicked() { - self.cu.fit = !self.cu.fit; - } - let (label, fill, col) = if self.cu.control { ("交還給 Bot", p.accent, Color32::WHITE) } else { ("取得控制權", p.primary, p.on_primary) }; - let btn = egui::Button::new(RichText::new(label).strong().color(col)).fill(fill).corner_radius(8).min_size(vec2(96.0, 30.0)); - if ui.add_enabled(run.is_running(), btn).on_hover_text(if self.cu.control { "Bot 可以繼續操作" } else { "你自己點擊和輸入;Bot 的操作會先暫停" }).clicked() { - self.cu.control = !self.cu.control; - match self.cu.remote.clone() { - Some(s) => { - let on = self.cu.control; - std::thread::spawn(move || { - let _ = crate::remote::sync_post(&s, "screen", &serde_json::json!({"kind": "hold", "on": on})); - }); - } - None => cloudbox::HELD_BY_PERSON.store(self.cu.control, Ordering::SeqCst), - } - } - ui.add_space(8.0); - ui.with_layout(Layout::left_to_right(Align::Center), |ui| { - monitor_icon(ui, 20.0, p.text); - let note = if self.cu.control { Some(t("你正在操作,Bot 已暫停")) } else if busy { Some(t("Bot 正在操作")) } else { None }; - let room = (ui.available_width() - 96.0 - if note.is_some() { 120.0 } else { 0.0 }).max(60.0); - ui.label(RichText::new(super::sidebar_ui::shorten_width(ui, &title, room, FONT_LG)).strong().size(FONT_LG)); - if ui.available_width() > 80.0 { - status_pill(ui, &run, p); - } - if let Some(n) = note.filter(|_| ui.available_width() > 110.0) { - crate::icons::label(ui, if self.cu.control { crate::icons::Icon::Users } else { crate::icons::Icon::Bot }, &n, FONT_XS, p.accent); - } - }); - }); - }); - ui.add_space(6.0); - let avail = ui.available_size(); - match (self.cu.texture.as_ref().map(|t| t.id()), run.is_running()) { - (Some(tex_id), true) => { - let [fw, fh] = self.cu.frame_size; - let (fw, fh) = (fw.max(1) as f32, fh.max(1) as f32); - let size = if self.cu.fit { - let k = (avail.x / fw).min((avail.y.max(120.0)) / fh); - vec2(fw * k, fh * k) - } else { - vec2(fw, fh) - }; - egui::ScrollArea::both().auto_shrink([false, false]).show(ui, |ui| { - let sense = if self.cu.control { egui::Sense::click_and_drag() } else { egui::Sense::hover() }; - // Centred in the window (it sat at the left with an empty band beside it). - let resp = if self.cu.fit { - let pad = ((ui.available_width() - size.x) / 2.0).max(0.0); - ui.horizontal(|ui| { - ui.add_space(pad); - ui.add(egui::Image::new((tex_id, size)).corner_radius(8).sense(sense)) - }) - .inner - } else { - ui.add(egui::Image::new((tex_id, size)).corner_radius(8).sense(sense)) - }; - if self.cu.control { - ui.painter().rect_stroke(resp.rect, 8, Stroke::new(2.0, p.accent), egui::StrokeKind::Outside); - self.forward_input(ui, &resp, (fw, fh)); - } - }); - } - _ => { - let (rect, _) = ui.allocate_exact_size(vec2(avail.x, avail.y.max(200.0)), egui::Sense::hover()); - ui.painter().rect_filled(rect, 8, Color32::from_rgb(0x2b, 0x2f, 0x36)); - let text = match &run { - Run::Running => "正在連線…".to_string(), - Run::NoDocker(why) => why.clone(), - Run::Failed(e) => format!("發生問題:{e}"), - other => format!("{}…", other.label()), - }; - ui.painter().text(rect.center(), egui::Align2::CENTER_CENTER, text, egui::FontId::proportional(FONT_BASE), Color32::from_gray(220)); - if matches!(run, Run::Asleep | Run::Absent) { - let b = egui::Rect::from_center_size(rect.center() + vec2(0.0, 34.0), vec2(90.0, 30.0)); - if ui.put(b, primary_button("喚醒", p)).clicked() { - match self.cu.remote.clone() { - Some(s) => { - std::thread::spawn(move || { - let _ = crate::remote::sync_post(&s, "screen", &serde_json::json!({"kind": "wake"})); - }); - } - None => self.box_task(|| cloudbox::ensure_running().map(|_| String::new())), - } - } - } - } - } - }); - if done_clicked { - self.give_back_computer(); - self.answer(ctx, selected, crate::providers::ApprovalAnswer::Once); - self.close_viewer(); - } else if close_clicked || !open { - self.close_viewer(); - } - ctx.request_repaint_after(std::time::Duration::from_millis(VIEWER_FRAME_MS)); - } - - /// Clicks, scrolls and keys on the picture go to the box while the person has control. - fn forward_input(&mut self, ui: &mut egui::Ui, resp: &egui::Response, frame: (f32, f32)) { - let Some(tx) = self.cu.input_tx.clone() else { return }; - let to_box = |pos: egui::Pos2| -> (i64, i64) { - let r = resp.rect; - (((pos.x - r.left()) / r.width() * frame.0).round() as i64, ((pos.y - r.top()) / r.height() * frame.1).round() as i64) - }; - if resp.clicked() || resp.secondary_clicked() || resp.double_clicked() { - resp.request_focus(); - } - if let Some(pos) = resp.interact_pointer_pos().or(resp.hover_pos()) { - let (x, y) = to_box(pos); - if resp.double_clicked() { - let _ = tx.send(Input::Click(x, y, "left", 2)); - } else if resp.clicked() { - let _ = tx.send(Input::Click(x, y, "left", 1)); - } else if resp.secondary_clicked() { - let _ = tx.send(Input::Click(x, y, "right", 1)); - } else if resp.hovered() { - let d = ui.input(|i| i.smooth_scroll_delta.y); - if d.abs() > 0.5 { - let _ = tx.send(Input::Scroll(x, y, d < 0.0, ((d.abs() / 40.0).ceil() as u32).max(1))); - } else if ui.input(|i| i.pointer.delta().length() > 3.0) { - let _ = tx.send(Input::Move(x, y)); - } - } - } - if !resp.has_focus() { - return; - } - ui.memory_mut(|m| m.set_focus_lock_filter(resp.id, egui::EventFilter { tab: true, horizontal_arrows: true, vertical_arrows: true, escape: true })); - let events = ui.input(|i| i.events.clone()); - for e in events { - match e { - egui::Event::Text(t) => { - let _ = tx.send(Input::Text(t)); - } - egui::Event::Paste(t) => { - let _ = tx.send(Input::Text(t)); - } - egui::Event::Key { key, pressed: true, modifiers, .. } => { - let name = match key { - Key::Enter => "Return", - Key::Tab => "Tab", - Key::Backspace => "BackSpace", - Key::Delete => "Delete", - Key::Escape => "Escape", - Key::ArrowUp => "Up", - Key::ArrowDown => "Down", - Key::ArrowLeft => "Left", - Key::ArrowRight => "Right", - Key::Home => "Home", - Key::End => "End", - Key::PageUp => "Prior", - Key::PageDown => "Next", - _ if modifiers.command || modifiers.ctrl || modifiers.alt => { - let n = key.name(); - if n.chars().count() == 1 { - n - } else { - continue; - } - } - _ => continue, - }; - let mut combo = vec![]; - if modifiers.command || modifiers.ctrl { - combo.push("ctrl".to_string()); - } - if modifiers.alt { - combo.push("alt".to_string()); - } - if modifiers.shift && (name.len() > 1 || !combo.is_empty()) { - combo.push("shift".to_string()); - } - combo.push(if name.len() == 1 { name.to_lowercase() } else { name.to_string() }); - let _ = tx.send(Input::Key(combo.join("+"))); - } - _ => {} - } + cloudbox::HELD_BY_PERSON.store(on, Ordering::SeqCst); } } - // ---------- the floating bar while a bot drives this Mac ---------- - pub(super) fn local_control_bar(&mut self, ctx: &egui::Context) { if self.demo { return; diff --git a/src/cloudbox.rs b/src/cloudbox.rs index 8d75f28..f19adf6 100644 --- a/src/cloudbox.rs +++ b/src/cloudbox.rs @@ -206,6 +206,16 @@ pub fn vnc_password() -> String { pw } +/// The password the box's VNC server really has: the one it was made with (its VNC_PASSWORD), so a +/// viewer signs in even when this AnyBot's settings were made anew; else this AnyBot's own. +pub fn box_vnc_password() -> String { + dk_timeout(&["exec", container(), "printenv", "VNC_PASSWORD"], 5_000) + .ok() + .map(|b| String::from_utf8_lossy(&b).trim().to_string()) + .filter(|p| !p.is_empty()) + .unwrap_or_else(vnc_password) +} + static BUSY: AtomicUsize = AtomicUsize::new(0); /// The person took control in the viewer: the bot's screen actions wait. pub static HELD_BY_PERSON: AtomicBool = AtomicBool::new(false); @@ -417,6 +427,13 @@ fn docker() -> Command { c } +/// A program in the box with its stdin and stdout piped to this side (as the bot user). +pub fn exec_piped(args: &[&str]) -> Result { + let mut a = vec!["exec", "-i", "-u", "bot", container()]; + a.extend_from_slice(args); + docker().args(&a).stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::null()).spawn().map_err(|e| format!("找不到 docker:{e}")) +} + /// The `docker …` prefix for shell strings (used by the command tools). fn docker_prefix() -> String { let host = docker_host(); @@ -1188,164 +1205,16 @@ pub fn thumbnail(width: u32) -> Option { image::open(&path).ok().map(|i| i.to_rgba8()) } -/// 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. +/// The screen now, for a small picture (no waking: None when the box isn't running). People watch +/// it live through VNC (vnc.rs). pub fn frame() -> Option { if !run_state().is_running() { return None; } - 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()?), - }; + let jpeg = 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). fn box_size() -> Result<(u32, u32), String> { if let Some(s) = shared().lock().unwrap_or_else(|e| e.into_inner()).size { @@ -1605,8 +1474,8 @@ pub fn computer_run(name: &str, v: &Value) -> Result<(String, Option), S } _ => {} } - if HELD_BY_PERSON.load(Ordering::SeqCst) { - return Err("使用者正在自己操作這台電腦(已取得控制權)。請等他交還控制權後再操作,或先截圖看看畫面。".into()); + if HELD_BY_PERSON.load(Ordering::SeqCst) || crate::vnc::person_active() { + return Err("使用者正在自己操作這台電腦。請等他操作完(停下幾秒後)再繼續,或先截圖看看畫面。".into()); } let (note, picture) = if name == "computer_use_open_app" { let note = open_on_box(v["app"].as_str().unwrap_or(""))?; @@ -1623,85 +1492,13 @@ 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, -// 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)) { - send_input(&script, stdin.as_deref()); - } - touch(); -} - -pub fn viewer_click(x: i64, y: i64, button: &str, count: u32) { - viewer("computer_use_click", serde_json::json!({"x": x, "y": y, "button": button, "count": count.clamp(1, 3)})); -} - -pub fn viewer_move(x: i64, y: i64) { - viewer("computer_use_mouse_move", serde_json::json!({"x": x, "y": y})); -} - -pub fn viewer_scroll(x: i64, y: i64, down: bool, steps: u32) { - viewer("computer_use_scroll", serde_json::json!({"x": x, "y": y, "direction": if down { "down" } else { "up" }, "amount": steps.clamp(1, 20)})); -} - -/// A key from a viewer: an X key name (「BackSpace」「Return」, ctrl+c…) or the bot's names. -pub fn viewer_key(combo: &str) { - let c = combo.trim(); - if c.is_empty() || !c.chars().all(|ch| ch.is_ascii_alphanumeric() || "+_-".contains(ch)) { - return; - } - // X key names pass straight through (they are what the viewers send). - let x = combo_to_x(c).unwrap_or_else(|_| c.to_string()); - send_input(&format!("xdotool key --clearmodifiers {x}"), None); - touch(); -} +// ---------- text a person types in a viewer ---------- +/// Text from a person's viewer (any language): typed or pasted into the window in front, like +/// the bot's typing. (Their mouse and keys go through VNC itself.) pub fn viewer_text(text: &str) { if !text.is_empty() { - send_input(&type_script(text), Some(text)); + let _ = box_exec(&type_script(text), Some(text), false); } touch(); } @@ -1876,65 +1673,8 @@ 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, + /// The bot's clicks land where its picture says, the same spot twice doesn't hang, typing (ASCII, Chinese, a key by name) reaches the app, /// and every step comes back with the screen after it. #[test] #[ignore] @@ -1960,9 +1700,6 @@ mod tests { println!("two clicks with pictures: {} ms", t.elapsed().as_millis()); assert!(t.elapsed() < Duration::from_secs(6)); assert_eq!(pointer(), ((40.0 * w as f64 / fw as f64).round() as i64, (40.0 * h as f64 / fh as f64).round() as i64)); - // A phone's tap is in the full-size frame it was sent. - viewer_click(321, 123, "left", 1); - assert_eq!(pointer(), (321, 123)); // Typing reaches the app in front: a fresh terminal, ASCII then Chinese, a key by name, return. let _ = dk(&["exec", "-u", "bot", container(), "rm", "-f", "/home/bot/exact.txt"]); let (txt, img) = computer_run("computer_use_open_app", &serde_json::json!({"app": "終端機"})).unwrap(); diff --git a/src/computer.rs b/src/computer.rs index 404c165..2044a7a 100644 --- a/src/computer.rs +++ b/src/computer.rs @@ -536,13 +536,29 @@ fn open_app(app: &str) -> Result { if app.is_empty() { return Err("請給要打開的 App 名稱".into()); } - crate::desktop::launch(app).or_else(|e| match app_named(app) { - Some(real) if real != app => crate::desktop::launch(&real), + launch(app).or_else(|e| match app_named(app) { + Some(real) if real != app => launch(&real), _ => Err(e), })?; Ok(format!("已打開「{app}」,放在最前面。")) } +/// Open an app or bring it forward. macOS: LaunchServices (`open -a`), which needs no +/// permission (agent-desktop's launch waited on the window through accessibility and failed +/// without 輔助使用 now and then); elsewhere agent-desktop's. +fn launch(app: &str) -> Result<(), String> { + #[cfg(target_os = "macos")] + { + let out = std::process::Command::new("open").args(["-a", app]).output().map_err(|e| e.to_string())?; + if out.status.success() { + return Ok(()); + } + Err(format!("找不到「{app}」這個 App")) + } + #[cfg(not(target_os = "macos"))] + crate::desktop::launch(app) +} + /// Bring the app a control belongs to in front, so a click at its spot lands in it (and not in /// a window lying over it). Nothing to do when it is in front already. fn bring_forward(app: &str) { @@ -555,7 +571,7 @@ fn bring_forward(app: &str) { if app.is_empty() || front.eq_ignore_ascii_case(app) { return; } - if crate::desktop::launch(app).is_ok() { + if launch(app).is_ok() { std::thread::sleep(std::time::Duration::from_millis(300)); } } diff --git a/src/desktop.rs b/src/desktop.rs index 02217a4..b5f3b35 100644 --- a/src/desktop.rs +++ b/src/desktop.rs @@ -259,7 +259,8 @@ pub fn read_app(app: &str) -> Result<(String, Vec), String> { Ok((app_name, lines)) } -/// Open an app, or bring it to the front when it runs. +/// Open an app, or bring it to the front when it runs (macOS uses LaunchServices instead). +#[cfg(not(target_os = "macos"))] pub fn launch(app: &str) -> Result<(), String> { run("launch", &serde_json::json!({"app": app})).map(|_| ()) } diff --git a/src/main.rs b/src/main.rs index 654c1e3..fdcab69 100644 --- a/src/main.rs +++ b/src/main.rs @@ -47,6 +47,7 @@ mod tools; mod triggers; mod vault; mod vault_store; +mod vnc; mod voice; mod tunnel; mod web; diff --git a/src/remote.rs b/src/remote.rs index 2e40d56..6b3c4b2 100644 --- a/src/remote.rs +++ b/src/remote.rs @@ -322,6 +322,8 @@ pub fn handle(req: Req, via_relay: bool) -> Resp { let path: Vec<&str> = parts.iter().map(|s| *s).collect(); match (req.method.as_str(), path.as_slice()) { ("GET", []) | ("GET", ["index.html"]) => return page(), + ("GET", ["screen"]) => return Resp::bytes("text/html; charset=utf-8", SCREEN_PAGE.as_bytes().to_vec()).with_header("Content-Security-Policy", "default-src 'self'; img-src 'self' blob: data:; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'; connect-src 'self' ws: wss:"), + ("GET", ["novnc.js"]) => return Resp::bytes("text/javascript; charset=utf-8", NOVNC.as_bytes().to_vec()).with_header("Cache-Control", "public, max-age=86400"), ("GET", ["manifest.webmanifest"]) => return Resp::bytes("application/manifest+json", MANIFEST.as_bytes().to_vec()), ("GET", ["icon.svg"]) => return Resp::bytes("image/svg+xml", ICON.as_bytes().to_vec()), ("GET", [name]) if ICON_PNGS.iter().any(|(n, _)| n == name) => return Resp::bytes("image/png", ICON_PNGS.iter().find(|(n, _)| n == name).map(|(_, b)| b.to_vec()).unwrap_or_default()), @@ -415,7 +417,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, req.query.get("after").and_then(|a| a.parse().ok())), + ("GET", ["api", "screen", which]) => screen(which, &s), ("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"]) => { @@ -435,24 +437,16 @@ pub fn handle(req: Req, via_relay: bool) -> Resp { Err(e) => Resp::error(503, &e), }, ("POST", ["api", "sync", "upload", id]) => upload(id.parse().unwrap_or(0), &req), - // A screen of this brain watches (and may take over) the bots' own computer, which lives - // here: its picture on that screen used to be its own computer's, not this one's. - ("GET", ["api", "sync", "screen"]) => { - if req.query.get("thumb").map(|t| t == "1").unwrap_or(false) { - match crate::cloudbox::thumbnail(320) { - Some(img) => { - let rgb = image::DynamicImage::ImageRgba8(img).to_rgb8(); - match crate::computer::encode_jpeg(&rgb) { - Some(j) => Resp::bytes("image/jpeg", j), - None => Resp::error(500, "畫面編碼失敗"), - } - } - None => Resp::error(404, "還沒有畫面"), - } - } else { - screen("box", &s, req.query.get("after").and_then(|a| a.parse().ok())) - } - } + // A screen of this brain shows the bots' own computer, which lives here: a small picture + // (people watch it live through VNC, see vnc.rs). + ("GET", ["api", "sync", "screen"]) => match crate::cloudbox::thumbnail(320) { + Some(img) => match crate::computer::encode_jpeg(&image::DynamicImage::ImageRgba8(img).to_rgb8()) { + Some(j) => Resp::bytes("image/jpeg", j), + None => Resp::error(500, "畫面編碼失敗"), + }, + None => Resp::error(404, "還沒有畫面"), + }, + // 接管 / 我完成了 from a screen: the bot's steps wait (or not) for the person. ("POST", ["api", "sync", "screen"]) => { let v = req.json(); match v["kind"].as_str().unwrap_or("") { @@ -460,25 +454,11 @@ pub fn handle(req: Req, via_relay: bool) -> Resp { crate::cloudbox::HELD_BY_PERSON.store(v["on"].as_bool().unwrap_or(false), Ordering::SeqCst); Resp::json(&json!({"ok": true})) } - "viewer" => { - crate::cloudbox::set_viewer_open(v["on"].as_bool().unwrap_or(false)); - Resp::json(&json!({"ok": true})) - } - // The person's own hands on the picture (frame pixels, like this computer's own - // viewer; not the bot's tools, which stop while the person holds it). - "click" | "move" | "scroll" | "key" | "text" => { - crate::cloudbox::touch(); - let (x, y) = (v["x"].as_i64().unwrap_or(0), v["y"].as_i64().unwrap_or(0)); - match v["kind"].as_str().unwrap_or("") { - "click" => crate::cloudbox::viewer_click(x, y, if v["button"].as_str() == Some("right") { "right" } else { "left" }, v["count"].as_u64().unwrap_or(1).clamp(1, 3) as u32), - "move" => crate::cloudbox::viewer_move(x, y), - "scroll" => crate::cloudbox::viewer_scroll(x, y, v["down"].as_bool().unwrap_or(true), v["steps"].as_u64().unwrap_or(1).clamp(1, 20) as u32), - "key" => crate::cloudbox::viewer_key(v["key"].as_str().unwrap_or("")), - _ => crate::cloudbox::viewer_text(v["text"].as_str().unwrap_or("")), - } - Resp::json(&json!({"ok": true})) - } - _ => screen_input("box", &v, &s), + "wake" => match crate::cloudbox::ensure_running() { + Ok(()) => Resp::json(&json!({"ok": true})), + Err(e) => Resp::error(500, &e), + }, + _ => Resp::error(400, "不認得的操作"), } } ("POST", ["api", "sync", "models"]) => match hub().ask_sync_for(SyncKind::Models(req.json()["provider"].as_str().unwrap_or("").to_string()), 45) { @@ -606,27 +586,10 @@ fn upload(bot: u64, req: &Req) -> Resp { } /// A frame of the bot's computer ("box") or this Mac ("mac") as JPEG. -/// 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 { +/// This Mac's screen for the phone (when allowed). The bots' computer is watched through VNC +/// (the page /screen, vnc.rs). +fn screen(which: &str, s: &Settings) -> Resp { match which { - "box" => { - crate::cloudbox::touch(); - 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 的電腦在休息,按「叫醒」"), - } - } "mac" if s.mac_from_phone => { let (text, ok, img) = crate::computer::run_by("computer_use_screenshot", "{}", "手機"); match (ok, img) { @@ -644,41 +607,15 @@ fn screen(which: &str, s: &Settings, after: Option) -> Resp { } } -/// The phone taps, types or scrolls on a screen, as the person, in the pixels of the picture it -/// was sent: the box's frame is its desktop at full size, this computer's is the bot-sized -/// picture (shot_size). It used to go through the bot's tools, which mapped the tap with the -/// bot's last screenshot (a different size: taps landed a quarter off, or failed with "take a -/// screenshot first") and refused it while the person held the screen. +/// The phone taps, types or scrolls on this Mac's screen, as the person, in the pixels of the +/// picture it was sent (shot_size). The bots' computer takes the person's input through VNC. fn screen_input(which: &str, v: &Value, s: &Settings) -> Resp { - let kind = v["kind"].as_str().unwrap_or(""); - if which == "box" && kind == "wake" { - return match crate::cloudbox::ensure_running() { - Ok(()) => Resp::json(&json!({"ok": true})), - Err(e) => Resp::error(500, &e), - }; - } let (x, y) = (v["x"].as_f64().unwrap_or(0.0), v["y"].as_f64().unwrap_or(0.0)); let down = v["direction"].as_str().unwrap_or("down") != "up"; let amount = v["amount"].as_i64().unwrap_or(3).clamp(1, 20); match which { - "box" => { - if !matches!(kind, "click" | "type" | "key" | "scroll") { - return Resp::error(400, "不認得的操作"); - } - if let Err(e) = crate::cloudbox::ensure_running() { - return Resp::error(500, &e); - } - let (xi, yi) = (x.round() as i64, y.round() as i64); - match kind { - "click" => crate::cloudbox::viewer_click(xi, yi, if v["button"].as_str() == Some("right") { "right" } else { "left" }, v["count"].as_u64().unwrap_or(1) as u32), - "type" => crate::cloudbox::viewer_text(v["text"].as_str().unwrap_or("")), - "key" => crate::cloudbox::viewer_key(v["key"].as_str().unwrap_or("")), - _ => crate::cloudbox::viewer_scroll(xi, yi, down, amount as u32), - } - Resp::json(&json!({"ok": true})) - } "mac" if s.mac_from_phone => { - let (name, args) = match kind { + let (name, args) = match v["kind"].as_str().unwrap_or("") { "click" => ("computer_use_click", json!({"x": x, "y": y, "button": v["button"].as_str().unwrap_or("left"), "count": v["count"].as_u64().unwrap_or(1)})), "type" => ("computer_use_typing", json!({"value": v["text"]})), "key" => ("computer_use_press_key", json!({"key": v["key"]})), @@ -690,7 +627,8 @@ fn screen_input(which: &str, v: &Value, s: &Settings) -> Resp { Err(e) => Resp::error(500, &e), } } - _ => Resp::error(403, "這台 Mac 沒有開放從手機操作"), + "mac" => Resp::error(403, "這台 Mac 沒有開放從手機操作"), + _ => Resp::error(404, "沒有這個畫面"), } } @@ -868,10 +806,69 @@ pub fn set_lan(on: bool) -> Result { .ok_or("找不到可用的連接埠")?; let port = listener.local_addr().map_err(|e| e.to_string())?.port(); *LAN_BOUND.lock().unwrap() = Some(port); - std::thread::spawn(move || httpd::serve(listener, Arc::new(|r| handle(r, false)), Arc::new(move || LAN_GEN.load(Ordering::SeqCst) == gen))); + std::thread::spawn(move || httpd::serve_upgrade(listener, Arc::new(|r| handle(r, false)), Some(Arc::new(lan_vnc)), Arc::new(move || LAN_GEN.load(Ordering::SeqCst) == gen))); Ok(port) } +/// The phone page on the local network watching the bots' computer: /api/vnc, signed in with +/// the phone's password like every other request here; the VNC stream goes straight to the box. +fn lan_vnc(req: Req, mut c: std::net::TcpStream) { + use std::io::Write; + use tungstenite::{protocol::Role, Message, WebSocket}; + if req.path != "/api/vnc" { + return httpd::write_response(&mut c, &Resp::error(404, "沒有這個網址")); + } + if devices_ok(&req.bearer()).is_none() { + return httpd::write_response(&mut c, &Resp::error(401, "請先登入")); + } + let key = req.header("sec-websocket-key").to_string(); + let accept = tungstenite::handshake::derive_accept_key(key.as_bytes()); + if key.is_empty() || c.write_all(format!("HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: {accept}\r\n\r\n").as_bytes()).is_err() { + return; + } + let _ = c.set_nodelay(true); + let _ = c.set_read_timeout(Some(Duration::from_millis(2))); + let mut ws = WebSocket::from_raw_socket(c, Role::Server, None); + let (tx, rx) = std::sync::mpsc::channel::>(); + let send = move |b: &[u8]| tx.send(b.to_vec()).is_ok(); + let Ok(mut session) = crate::vnc::open(send.clone()) else { return }; + if !send(crate::vnc::GREETING) { + return; + } + 'live: loop { + match ws.read() { + Ok(Message::Binary(b)) => { + if !session.feed(&b, &send) { + break; + } + } + Ok(Message::Text(t)) => crate::vnc::person_typed(&t), + Ok(Message::Close(_)) => break, + Ok(_) => {} + Err(tungstenite::Error::Io(e)) if matches!(e.kind(), std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut) => {} + Err(_) => break, + } + loop { + match rx.try_recv() { + // An empty send: the box's side ended. + Ok(b) if b.is_empty() => break 'live, + Ok(b) => { + if ws.write(Message::Binary(b.into())).is_err() { + break 'live; + } + } + Err(_) => break, + } + } + match ws.flush() { + Err(tungstenite::Error::Io(e)) if matches!(e.kind(), std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut) => {} + Err(_) => break, + Ok(()) => {} + } + } + let _ = ws.close(None); +} + /// Addresses a phone can open (local network and Tailscale). pub fn addresses(port: u16) -> Vec { // macOS: ifconfig ("inet 192.168.1.5 netmask…"); Linux: `ip -4 -o addr` ("… inet 192.168.1.5/24 …"). @@ -1212,16 +1209,11 @@ 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, a.headers)); + return r.map(|a| (a.status, a.body)); } 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("&")))?; @@ -1229,9 +1221,8 @@ fn to_brain_full(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, headers)) + Ok((st, bytes)) } /// What changed at the brain since `since` (waits up to LONG_POLL_SECS when nothing has). @@ -1259,15 +1250,21 @@ 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), "大腦")); +/// A link for this account's browser to watch the brain's computer (/screen on the account +/// server; one-time, it works for ten minutes). +pub fn screen_ticket(s: &Settings) -> Result { + if s.relay_url.is_empty() || s.brain_id.is_empty() { + return Err("這台沒有連到帳號的大腦".into()); } - 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)) + let url = format!("{}/relay/screen-ticket", s.relay_url.trim_end_matches('/')); + let mut r = agent(15) + .post(&url) + .header("Authorization", &format!("Bearer {}", s.host_token)) + .header("Content-Type", "application/json") + .send(json!({"host": s.brain_id}).to_string().as_bytes()) + .map_err(|e| format!("連不到帳號伺服器:{}", why_unreachable(&e)))?; + let v: Value = r.body_mut().read_json().map_err(|_| "帳號伺服器的回答看不懂".to_string())?; + v["ticket"].as_str().map(String::from).ok_or_else(|| "帳號伺服器沒有給看畫面的連結(它可能是舊版,請更新)".into()) } /// A file the brain keeps (an attachment shown in a chat), as bytes. @@ -1526,6 +1523,9 @@ pub fn devices_online(s: &Settings) -> Vec<(String, String, bool)> { // ---------- the phone page ---------- const PAGE: &str = include_str!("../mobile/index.html"); +/// The bots' computer, watched and used in a browser (noVNC; see vnc.rs). +const SCREEN_PAGE: &str = include_str!("../mobile/screen.html"); +const NOVNC: &str = include_str!("../mobile/novnc.js"); pub const MANIFEST: &str = r##"{"name":"AnyBot","short_name":"AnyBot","start_url":"./","display":"standalone","background_color":"#121212","theme_color":"#121212","icons":[{"src":"icon.svg","sizes":"any","type":"image/svg+xml"},{"src":"icon-192.png","sizes":"192x192","type":"image/png","purpose":"any maskable"},{"src":"icon-512.png","sizes":"512x512","type":"image/png","purpose":"any maskable"}]}"##; /// The app's icon, the same drawing everywhere (icons/anybot.svg is the Mac/Linux one). pub const ICON: &str = include_str!("../mobile/icon.svg"); diff --git a/src/tunnel.rs b/src/tunnel.rs index 0aafda1..619b2e4 100644 --- a/src/tunnel.rs +++ b/src/tunnel.rs @@ -26,7 +26,6 @@ const CUT: &str = "和帳號伺服器的連線斷了,正在重新連線"; pub struct Answer { pub status: u16, pub body: Vec, - pub headers: Vec<(String, String)>, } type Calls = Arc>>>>; @@ -229,8 +228,15 @@ pub fn run(url: &str, token: &str, alive: &dyn Fn() -> bool, status: &dyn Fn(&st true } +/// How long a turn of the connection's loop waits for something to read before sending what is +/// queued: short while things are moving (a live screen), longer when quiet. A fixed 40 ms +/// added up to 40 ms to every message sent, on each side. +const READ_WAIT_BUSY: Duration = Duration::from_millis(2); +const READ_WAIT_QUIET: Duration = Duration::from_millis(40); + fn keep(mut ws: WebSocket, serial: u64, alive: &dyn Fn() -> bool) { - let _ = ws.get_ref().tcp().set_read_timeout(Some(Duration::from_millis(40))); + let _ = ws.get_ref().tcp().set_read_timeout(Some(READ_WAIT_QUIET)); + let (mut moved, mut busy) = (Instant::now() - Duration::from_secs(60), false); let (tx, rx) = channel::>(); let calls: Calls = Arc::default(); *link().lock().unwrap() = Some(Link { tx: tx.clone(), calls: calls.clone(), serial }); @@ -238,9 +244,15 @@ fn keep(mut ws: WebSocket, serial: u64, alive: &dyn Fn() -> bool) { let _ = tx.send(wire::pack(&json!({"t": "hello", "brain": s.brain_id, "hands": s.device_link}), &[])); let (mut heard, mut pinged) = (Instant::now(), Instant::now()); 'live: while alive() { + let now_busy = moved.elapsed() < Duration::from_secs(2); + if now_busy != busy { + busy = now_busy; + let _ = ws.get_ref().tcp().set_read_timeout(Some(if busy { READ_WAIT_BUSY } else { READ_WAIT_QUIET })); + } match ws.read() { Ok(Message::Binary(b)) => { heard = Instant::now(); + moved = heard; if let Some((head, body)) = wire::unpack(&b) { // The relay says this machine was unregistered (signed out): stop for good. if head["t"] == "bye" { @@ -255,6 +267,7 @@ fn keep(mut ws: WebSocket, serial: u64, alive: &dyn Fn() -> bool) { Err(_) => break, } while let Ok(f) = rx.try_recv() { + moved = Instant::now(); if ws.write(Message::Binary(f.into())).is_err() { break 'live; } @@ -280,14 +293,72 @@ fn keep(mut ws: WebSocket, serial: u64, alive: &dyn Fn() -> bool) { *l = None; } drop(l); + streams().lock().unwrap().clear(); // Whoever waits on an answer hears now, not after a timeout. for (_, w) in calls.lock().unwrap().drain() { let _ = w.send(Err(CUT.into())); } } +/// People watching the bots' computer through the account server: one VNC stream each, by the +/// relay's stream id (vopen / vdata / vtext / vclose). +fn streams() -> &'static Mutex> { + static S: OnceLock>> = OnceLock::new(); + S.get_or_init(Default::default) +} + +fn on_stream(kind: &str, sid: String, body: Vec, tx: &Sender>) { + let send = { + let (tx, sid) = (tx.clone(), sid.clone()); + move |b: &[u8]| tx.send(wire::pack(&json!({"t": "vdata", "sid": sid}), b)).is_ok() + }; + let close = { + let (tx, sid) = (tx.clone(), sid.clone()); + move |why: &str| { + streams().lock().unwrap().remove(&sid); + let _ = tx.send(wire::pack(&json!({"t": "vclose", "sid": sid, "why": why}), &[])); + } + }; + match kind { + "vopen" => { + std::thread::spawn(move || { + let out = { + let (send, close) = (send.clone(), close.clone()); + move |b: &[u8]| if b.is_empty() { close("這台電腦的畫面關了"); false } else { send(b) } + }; + match crate::vnc::open(out) { + Ok(s) => { + streams().lock().unwrap().insert(sid, s); + // Only now: the viewer answers this at once. + if !send(crate::vnc::GREETING) { + close(""); + } + } + Err(e) => close(&e), + } + }); + } + "vdata" => { + let mut all = streams().lock().unwrap(); + let ok = all.get_mut(&sid).map(|s| s.feed(&body, &send)).unwrap_or(false); + drop(all); + if !ok { + close("這台電腦的畫面關了"); + } + } + "vtext" => crate::vnc::person_typed(&String::from_utf8_lossy(&body)), + _ => { + streams().lock().unwrap().remove(&sid); + } + } +} + fn on_frame(head: Value, body: Vec, tx: &Sender>, calls: &Calls) { - match head["t"].as_str().unwrap_or("") { + let t = head["t"].as_str().unwrap_or(""); + if matches!(t, "vopen" | "vdata" | "vtext" | "vclose") { + return on_stream(t, head["sid"].as_str().unwrap_or("").to_string(), body, tx); + } + match t { "job" => { let tx = tx.clone(); std::thread::spawn(move || { @@ -310,8 +381,7 @@ 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 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 })); + let _ = w.send(body.map(|body| Answer { status: head["status"].as_u64().unwrap_or(502) as u16, body })); } } _ => {} diff --git a/src/vnc.rs b/src/vnc.rs new file mode 100644 index 0000000..a2cefb9 --- /dev/null +++ b/src/vnc.rs @@ -0,0 +1,263 @@ +// People watching and helping on the bots' computer: VNC, carried to them as a stream. +// +// The box already runs a VNC server (x11vnc, inside the container, with a password). A person's +// viewer (noVNC in a browser: the phone, or any computer through the account server) speaks VNC +// to this proxy, which reaches the box's server through one `docker exec` (a few lines of +// Python in the box joining its stdin/stdout to 127.0.0.1:5900). VNC sends only what changed on +// the screen and every mouse move and key as it happens, so watching is live and helping is +// like sitting at it — unlike pictures sent one by one. +// +// The proxy signs in to the box with its password itself and offers the viewer "no password", +// so the password never leaves this AnyBot; whoever reaches the proxy was already let in (an +// account session or one-time ticket at the account server, or the phone's password here). +// While the person clicks or types, the bot's own steps wait (HELD_BY_PERSON), and go on a +// little after they stop (person_active). + +use std::io::{Read, Write}; +use std::process::{Child, ChildStdin}; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::Mutex; +use std::time::{Duration, Instant}; + +/// The bot waits this long after the person's last click or key before it goes on. +const HELD_AFTER_INPUT: Duration = Duration::from_secs(8); + +/// Joins the box's VNC server to stdin/stdout, and ends when either side does (a loop left in +/// the box would hold the connection open). +const BRIDGE: &str = r#"import os,select,socket +s=socket.create_connection(('127.0.0.1',5900)) +while True: + r=select.select([s,0],[],[])[0] + if s in r: + d=s.recv(65536) + if not d: break + os.write(1,d) + if 0 in r: + d=os.read(0,65536) + if not d: break + s.sendall(d) +"#; + +/// Viewers connected now (the box stays awake while there is one). +static OPEN: AtomicUsize = AtomicUsize::new(0); +/// When the person last clicked or typed through a viewer. +static LAST_INPUT: Mutex> = Mutex::new(None); + +/// VNC authentication: the 16-byte challenge encrypted with DES, the key being the password's +/// first 8 bytes with each byte's bits reversed (RFC 6143 §7.2.2, as every VNC server does it). +pub fn vnc_response(password: &str, challenge: &[u8; 16]) -> [u8; 16] { + use des::cipher::{BlockEncrypt, KeyInit}; + let mut key = [0u8; 8]; + for (k, b) in key.iter_mut().zip(password.bytes()) { + *k = b.reverse_bits(); + } + let c = des::Des::new_from_slice(&key).expect("8-byte key"); + let mut out = *challenge; + for block in out.chunks_mut(8) { + c.encrypt_block(des::cipher::generic_array::GenericArray::from_mut_slice(block)); + } + out +} + +/// This proxy's first words to a viewer, as a VNC server: version 3.8 (then "no password"; see +/// Session::feed). +pub const GREETING: &[u8] = b"RFB 003.008\n"; + +/// One person's viewer joined to the box. +pub struct Session { + child: Child, + stdin: ChildStdin, + /// The viewer's side of the handshake still to read: its version (12 bytes) and its choice + /// of security type (1 byte). + greeting: usize, + /// Viewer → box messages not yet complete (to notice clicks and keys). + pending: Vec, + sniffing: bool, + /// The viewer's ClientInit (one byte) is still to come. + init: bool, +} + +/// Open a viewer's way to the box: sign in to its VNC server, and start sending what the server +/// says to `out` (from a thread of its own; `out` returns false when the viewer is gone, and +/// gets an empty slice when the box's side ended). The caller then sends the viewer GREETING +/// (once it is ready for the viewer's answer). +pub fn open(out: impl Fn(&[u8]) -> bool + Send + 'static) -> Result { + crate::cloudbox::ensure_running()?; + let mut child = crate::cloudbox::exec_piped(&["python3", "-c", BRIDGE])?; + let mut stdin = child.stdin.take().ok_or("連不上這台電腦的畫面")?; + let mut stdout = child.stdout.take().ok_or("連不上這台電腦的畫面")?; + let fail = |child: &mut Child, why: &str| -> String { + let _ = child.kill(); + let _ = child.wait(); + format!("連不上這台電腦的畫面:{why}") + }; + let mut read = |n: usize| -> Result, String> { + let mut b = vec![0u8; n]; + stdout.read_exact(&mut b).map(|_| b).map_err(|_| "畫面伺服器沒有回應".to_string()) + }; + // The box's side, as a VNC client: version 3.8, then its password. + let signed_in = (|| -> Result<(), String> { + let v = read(12)?; + if !v.starts_with(b"RFB 003.") { + return Err("不是 VNC 伺服器".into()); + } + stdin.write_all(b"RFB 003.008\n").map_err(|e| e.to_string())?; + let n = read(1)?[0] as usize; + if n == 0 { + return Err("畫面伺服器拒絕連線".into()); + } + let types = read(n)?; + if types.contains(&2) { + stdin.write_all(&[2]).map_err(|e| e.to_string())?; + let c: [u8; 16] = read(16)?.try_into().map_err(|_| "認證失敗".to_string())?; + stdin.write_all(&vnc_response(&crate::cloudbox::box_vnc_password(), &c)).map_err(|e| e.to_string())?; + } else if types.contains(&1) { + stdin.write_all(&[1]).map_err(|e| e.to_string())?; + } else { + return Err("畫面伺服器要的登入方式不支援".into()); + } + if read(4)? != [0, 0, 0, 0] { + return Err("畫面伺服器的密碼不對".into()); + } + Ok(()) + })(); + if let Err(e) = signed_in { + return Err(fail(&mut child, &e)); + } + OPEN.fetch_add(1, Ordering::SeqCst); + crate::cloudbox::set_viewer_open(true); + std::thread::spawn(move || { + let mut buf = vec![0u8; 64 * 1024]; + loop { + match stdout.read(&mut buf) { + Ok(0) | Err(_) => break, + Ok(n) => { + if !out(&buf[..n]) { + return; + } + } + } + } + // The box's side ended: an empty send says so. + out(&[]); + }); + Ok(Session { child, stdin, greeting: 13, pending: Vec::new(), sniffing: true, init: true }) +} + +impl Session { + /// Bytes from the viewer. False when the box's side is gone. + pub fn feed(&mut self, mut b: &[u8], reply: impl Fn(&[u8]) -> bool) -> bool { + if self.greeting > 0 { + let take = self.greeting.min(b.len()); + let before = self.greeting; + self.greeting -= take; + b = &b[take..]; + // After the version (12 bytes): the one security type, "None". + if before > 1 && self.greeting <= 1 && !reply(&[1, 1]) { + return false; + } + // After the choice: OK. + if self.greeting == 0 && !reply(&[0, 0, 0, 0]) { + return false; + } + if b.is_empty() { + return true; + } + } + self.notice_input(b); + self.stdin.write_all(b).and_then(|_| self.stdin.flush()).is_ok() + } + + /// Follow the viewer's messages (RFC 6143 §7.5) to see clicks and keys. Anything it doesn't + /// know stops the watching, never the forwarding. + fn notice_input(&mut self, b: &[u8]) { + if !self.sniffing { + return; + } + self.pending.extend_from_slice(b); + // The first byte after the handshake is ClientInit (the shared flag). + if self.init && !self.pending.is_empty() { + self.pending.remove(0); + self.init = false; + } + let mut at = 0; + loop { + let rest = &self.pending[at..]; + let Some(&kind) = rest.first() else { break }; + let len = match kind { + 0 => 20, + 2 if rest.len() >= 4 => 4 + 4 * u16::from_be_bytes([rest[2], rest[3]]) as usize, + 3 => 10, + 4 => 8, + 5 => 6, + 6 if rest.len() >= 8 => 8 + u32::from_be_bytes([rest[4], rest[5], rest[6], rest[7]]) as usize, + 2 | 6 => break, + _ => { + self.sniffing = false; + self.pending.clear(); + return; + } + }; + if rest.len() < len { + break; + } + match kind { + 4 if rest[1] == 1 => person_acted(), + 5 if rest[1] != 0 => person_acted(), + _ => {} + } + at += len; + } + self.pending.drain(..at); + } +} + +impl Drop for Session { + fn drop(&mut self) { + let _ = self.child.kill(); + let _ = self.child.wait(); + if OPEN.fetch_sub(1, Ordering::SeqCst) == 1 { + crate::cloudbox::set_viewer_open(false); + } + } +} + +/// The person clicked or typed: the bot's steps wait until a little after they stop. +pub fn person_acted() { + *LAST_INPUT.lock().unwrap_or_else(|e| e.into_inner()) = Some(Instant::now()); +} + +/// The person is using the box through a viewer right now (input in the last few seconds). +pub fn person_active() -> bool { + LAST_INPUT.lock().unwrap_or_else(|e| e.into_inner()).map(|t| t.elapsed() < HELD_AFTER_INPUT).unwrap_or(false) +} + +/// Text the person typed in the viewer's own box (any language): pasted into the box. +pub fn person_typed(text: &str) { + if !text.is_empty() { + person_acted(); + crate::cloudbox::viewer_text(text); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_bridge_keeps_its_indentation() { + // Python needs its indentation (a Rust "\" line continuation would strip it). + assert!(BRIDGE.contains("\n r=select") && BRIDGE.contains("\n if not d: break")); + } + + #[test] + fn vnc_password_answer_matches_the_standard() { + // Password "password", challenge 00..0f: DES-ECB with the bit-reversed key 0e86ceceeef64e26 + // (worked out with `openssl enc -des-ecb -nopad`). + let c: [u8; 16] = core::array::from_fn(|i| i as u8); + let want = [0xb8, 0x66, 0x92, 0x41, 0x25, 0xc8, 0xee, 0xbb, 0x9d, 0xeb, 0xc1, 0xdb, 0x61, 0xc5, 0x38, 0xe2]; + assert_eq!(vnc_response("password", &c), want); + // Only the first 8 bytes of a password count. + assert_eq!(vnc_response("password-and-more", &c), want); + } +} From 2867c8e822310b58b146a5ffac42a0d962dc4d56 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E6=80=A7=E9=A9=8A?= Date: Thu, 5 Nov 2026 23:21:47 +0800 Subject: [PATCH 3/3] Remove what nothing uses and keep one copy of what was written several times MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Unused (found by taking away the dead-code allowances; nothing else used them, tests aside): computer::specs, quiet_button, the never-made Poke::Spin, ten icons and their SVGs, the theme's chrome/bubble_agent colours, ImportRow.line, voice's multipart/transcribe/speak wrappers, and the old keychain writers vault::save/remove/index_add with their live test (the vault store replaced them; reading old keychain entries for the import stays). One copy each: percent-encoding (httpd::url_encode, was four), tool arguments (tools::args_of, was three), the WebSocket handshake (httpd::accept_websocket, was three), the read pacing (wire::Pace, was two: relay and app), docker with input and a time limit (dk_with_input, was two). Docs: FEATURES §看畫面 (VNC), protocol (v* frames, /screen, tickets), architecture. Co-Authored-By: Claude Opus 5.5 --- FEATURES.md | 18 +++++---- deploy/linux/dev/test.sh | 4 +- docs/architecture.md | 3 +- docs/protocol.md | 22 +++++++++-- icons/lucide/chevron-down.svg | 15 ------- icons/lucide/external-link.svg | 17 -------- icons/lucide/globe.svg | 17 -------- icons/lucide/info.svg | 17 -------- icons/lucide/keyboard.svg | 23 ----------- icons/lucide/mail.svg | 16 -------- icons/lucide/panel-right.svg | 16 -------- icons/lucide/phone.svg | 15 ------- icons/lucide/volume-2.svg | 17 -------- relay/src/main.rs | 46 ++-------------------- src/app.rs | 6 --- src/app/computers_ui.rs | 3 +- src/app/sidebar_ui.rs | 4 -- src/cloudbox.rs | 72 ++++++++++++---------------------- src/computer.rs | 15 ++----- src/httpd.rs | 19 +++++++++ src/icons.rs | 23 +---------- src/phone.rs | 4 +- src/remote.rs | 17 +------- src/teams.rs | 8 +--- src/theme.rs | 8 ---- src/tools.rs | 3 +- src/tunnel.rs | 19 ++------- src/vault.rs | 71 ++------------------------------- src/vault_store.rs | 7 +--- src/voice.rs | 19 +-------- src/wire.rs | 26 ++++++++++++ 31 files changed, 131 insertions(+), 439 deletions(-) delete mode 100644 icons/lucide/chevron-down.svg delete mode 100644 icons/lucide/external-link.svg delete mode 100644 icons/lucide/globe.svg delete mode 100644 icons/lucide/info.svg delete mode 100644 icons/lucide/keyboard.svg delete mode 100644 icons/lucide/mail.svg delete mode 100644 icons/lucide/panel-right.svg delete mode 100644 icons/lucide/phone.svg delete mode 100644 icons/lucide/volume-2.svg diff --git a/FEATURES.md b/FEATURES.md index 5c15869..84572a0 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -729,15 +729,17 @@ AnyBot 是一個桌面聊天 App,介面和運作方式照 Grok Bot 重做。 - 換位置後會在新的地方重新建立;舊檔案可以先「儲存快照」,換過去再「從快照復原」。 - **「網路流量改走這台 Mac」**(原版:網路流量可以改走你的本機電腦,「僅對新連線生效」,管理員可以關掉):打開後,Bot 的電腦之後執行的指令會經過這台 Mac 上的代理伺服器出去(例如要連公司內網或 VPN 才看得到的服務)。代理只聽這台 Mac 自己(127.0.0.1),用原版的埠 8791,被占用就往後找。設定頁顯示經過的連線數。Bot 的電腦在別的主機上時不能用。管理員可以用 `defaults write com.anybot.AnyBot AllowBoxTrafficViaThisMac false` 鎖住,這時顯示「🔒 由你的管理員設定,無法變更」。 -### 看畫面、取得控制權 +### 看畫面、取得控制權(像同事一樣看,需要時接手) -- 設定頁的「開啟畫面」,或聊天頂部的「電腦」按鈕。 -- 視窗:螢幕圖示+「Bot 的電腦」+狀態圓點;右邊是「取得控制權」「實際大小/調整至視窗大小」「全螢幕」和關閉。 -- 畫面大約每 0.45 秒更新一次(估計值)。Bot 正在操作時顯示「● Bot 正在操作」。 -- **取得控制權**:畫面外框變成強調色,你的點擊、右鍵、連點、捲動、打字(包括中文)和快速鍵都會送到 Bot 的電腦(⌘ 會當成 Ctrl)。這時 Bot 的滑鼠鍵盤操作會暫停,它會收到「使用者正在自己操作 Bot 的電腦,請等他交還控制權」。按「交還給 Bot」結束。 -- **僅觀看**:沒有取得控制權時,畫面只能看。 -- **全螢幕**:在瀏覽器用 noVNC 開啟(有控制權時可以操作,沒有時只能看)。 -- 電腦休眠或還沒建立時,畫面中間有「喚醒」按鈕。 +- 核心只有三件事:Bot 操作它的電腦;你像同事一樣即時看它在做什麼;它需要你(登入、驗證碼)時你接手幫忙。看和接手用 **VNC(noVNC)**:只傳畫面上變了的地方,每個滑鼠移動和按鍵即時送過去,手機可以用觸控。原版也是用 noVNC 看 box。 +- **在哪裡看**:手機網頁的「看 Bot 的電腦」、需要處理卡片的「接管」,Mac 和其他電腦的「看畫面」「開啟電腦」都打開同一個網頁 `/screen`(電腦上在瀏覽器開)。 + - 大腦在別台(伺服器):網頁在帳號伺服器上,VNC 經過帳號伺服器和大腦的長連線到大腦的 Bot 的電腦。電腦上的 App 會先向帳號伺服器要一個 10 分鐘有效的連結(不用在瀏覽器登入);手機網頁用自己的登入。只有擁有者和管理員能看。 + - 同一個網路的手機(不經帳號伺服器):同一個網頁,連 AnyBot 自己的 `/api/vnc`。 + - 這台電腦自己的 Bot 的電腦:直接開它的 noVNC。 +- **網頁**:最上面是返回、電腦名稱、狀態(即時觀看/你在操作)和「接管/交還給 Bot」。預設只看,按「接管」才會把你的滑鼠鍵盤送過去;需要處理卡片的「接管」直接打開接管。接管時有「打字」:打中文或任何語言(貼進目前的視窗),還有 Enter、Esc、Tab、⌫。斷線會自己重連。 +- **你在操作時 Bot 會等**:你點擊或按鍵後 8 秒內,Bot 的滑鼠鍵盤操作會收到「使用者正在自己操作這台電腦」。不用另外按什麼;停下 8 秒它就繼續。需要處理卡片的「接管」會讓 Bot 一直等,直到你按「我完成了」或「跳過」。 +- **VNC 密碼不出大腦**:大腦用 Bot 的電腦自己的 VNC 密碼登入,給看的人的是「不用密碼」;能連到這裡的人已經過帳號伺服器(或手機密碼)。 +- 以前的做法(每 0.45~1 秒拍一張 1 MB 的圖、解開再壓、經過帳號伺服器傳;點擊另走一條路)已經拆掉。 --- diff --git a/deploy/linux/dev/test.sh b/deploy/linux/dev/test.sh index f86e7a6..c9e82e9 100755 --- a/deploy/linux/dev/test.sh +++ b/deploy/linux/dev/test.sh @@ -2,8 +2,7 @@ # Build and test the Linux version from a Mac (or anywhere with Docker), on a virtual screen. # docker build -t anybot-linux-dev deploy/linux/dev (once) # deploy/linux/dev/test.sh (from app/) -# Runs: every unit test; the live X11 test (screenshot, clicks, keys, recording a demo); the -# keyring test (save / find / remove a login); the live Wayland test (headless sway: screenshot, +# Runs: every unit test; the live X11 test (screenshot, clicks, keys, recording a demo); the live Wayland test (headless sway: screenshot, # clicks, Chinese typing, keys, scroll); and screenshots of the window into ./linux-shots. set -e cd "$(dirname "$0")/../../.." @@ -17,7 +16,6 @@ export DISPLAY=:99 LANG=zh_TW.UTF-8 sleep 1 cargo test --release 2>&1 | grep -E "test result|FAILED|panicked" cargo test --release x11_live -- --ignored 2>&1 | grep -E "test result|panicked" -dbus-run-session -- sh -c "echo -n test | gnome-keyring-daemon --unlock --components=secrets >/dev/null 2>&1; sleep 1; cargo test --release keyring_live -- --ignored 2>&1 | grep -E \"test result|panicked\"" useradd -m t 2>/dev/null; chmod 777 /out runuser -u t -- env CARGO_TARGET_DIR=/target HOME=/home/t sh deploy/linux/dev/wayland_live.sh 2>&1 | sed -n "/--- wev heard/,\$p" for v in chat computers general; do diff --git a/docs/architecture.md b/docs/architecture.md index 0255e68..0ff456d 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -42,7 +42,8 @@ AnyBot 是一個 Rust 程式(`anybot`),用 egui/eframe 畫介面。同一 | `tools.rs` | 檔案、指令、背景指令;指令在 macOS 沙盒裡跑(見「安全」) | | `policy.rs` | 哪些動作直接做、先問、拒絕 | | `cloudbox.rs`、`box/` | Bot 自己的電腦:一個 Docker 容器(有桌面、瀏覽器) | -| `computer.rs`、`desktop.rs`、`axread.rs` | 看畫面、滑鼠鍵盤;讀 App 的按鈕清單(agent-desktop) | +| `computer.rs`、`desktop.rs`、`axread.rs` | 看畫面、滑鼠鍵盤;讀 App 的按鈕清單(agent-desktop,只讀) | +| `vnc.rs`、`mobile/screen.html` | 人看 Bot 的電腦、需要時接手:VNC 代理(經帳號伺服器或同一個網路),noVNC 網頁 | | `botbrowser.rs`、`browser.rs`、`web.rs` | AnyBot 自己的瀏覽器(Chrome DevTools 協定)、讀你的瀏覽器、抓網頁 | | `routines.rs`、`triggers.rs` | 例行任務和觸發 | | `channels.rs` | 訊息管道(Slack、Discord、Telegram) | diff --git a/docs/protocol.md b/docs/protocol.md index a4c565b..c6964b0 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -54,6 +54,14 @@ 轉送時伺服器附上 `user`、`role` 和 `from`(問的那台主機 id)。對方不在線時立刻回 503「目前離線」;收到了但 35 秒內沒回答,回 504。 +看 Bot 的電腦(VNC,一條串流,不是一問一答): + +| 路徑 | 誰 | 說明 | +|---|---|---| +| `GET /screen`、`GET /novnc.js` | 任何人 | 看畫面的網頁和 noVNC 1.6.0(MPL-2.0) | +| `POST /relay/screen-ticket` `{host}` | 同帳號的主機(host token) | → `{ticket}`:10 分鐘內有效,讓這台的 App 開瀏覽器看那台的電腦 | +| WebSocket `/relay/vnc/?t=` 或 `?ticket=` | 擁有者、管理員 | 二進位訊息是 VNC;文字訊息是要打進去的字。伺服器把它接到那台 AnyBot 的長連線(下面的 `v*`) | + ## AnyBot 提供的介面(`remote::handle`) 只有經過帳號伺服器(或同一個網路、用手機密碼登入)才到得了。 @@ -63,7 +71,11 @@ | GET | `/api/state?since=` | 手機 | Bot 清單、誰在工作、等核准的事(有變化才回,最多等 20 秒) | | GET | `/api/bots/?since=` | 手機 | 一個 Bot 的訊息 | | POST | `/api/bots//<動作>` | 手機 | `send`、`stop`、`answer`、`read`、`new`、`upload`… | -| GET/POST | `/api/screen/` | 手機 | 看畫面、點擊打字 | +| GET/POST | `/api/screen/mac` | 手機 | 看這台 Mac 的畫面、點擊打字(開了「從手機操作」才可以) | +| GET | `/screen`、`/novnc.js` | 任何人 | 看 Bot 的電腦的網頁(noVNC);本身沒有資料 | +| WebSocket | `/api/vnc?t=` | 手機(同一個網路) | VNC,直接到這台的 Bot 的電腦(見 vnc.rs) | +| GET | `/api/sync/screen` | mirror | Bot 的電腦的小縮圖(JPEG,寬 320) | +| POST | `/api/sync/screen` | mirror | `{kind: "hold", on}`(接管時 Bot 等你)、`{kind: "wake"}` | | POST | `/api/device/run` | device | `{tool, args, who}` → `{text, ok, image}`:在這台跑一個工具 | | GET | `/api/sync?since=&client=` | mirror | 大腦的變化(見 architecture.md) | | POST | `/api/sync/edit` | mirror | `{ops, client, seq}` → `{ok, applied}` | @@ -88,9 +100,13 @@ WebSocket 的二進位訊息,每則是: | `job` | 伺服器 → AnyBot | `id, method, path, query, user, role, from, z, z_ok` | 給這台的要求 | | `reply` | AnyBot → 伺服器 | `id, status, ctype, headers, z` | 回答 job | | `call` | AnyBot → 伺服器 | `id, to, method, path, query, z` | 要問帳號裡的另一台(只能是 `/api/sync…` 或 `/api/device/run`) | -| `ret` | 伺服器 → AnyBot | `id, status, ctype, z` | call 的回答 | +| `ret` | 伺服器 → AnyBot | `id, status, ctype, z, headers` | call 的回答 | +| `vopen` | 伺服器 → AnyBot | `sid` | 有人要看這台的 Bot 的電腦:接上它的 VNC(AnyBot 先回 VNC 的開頭) | +| `vdata` | 雙向 | `sid` | VNC 的位元組(內容) | +| `vtext` | 伺服器 → AnyBot | `sid` | 看的人打的字(UTF-8),貼進 Bot 的電腦 | +| `vclose` | 雙向 | `sid`(AnyBot 送的有 `why`) | 結束這條 | -心跳用 WebSocket 的 ping/pong。連線斷掉時,伺服器把還在等的 job 都回 503「斷線了」,AnyBot 把還在等的 call 都回「和帳號伺服器的連線斷了」。 +兩邊的迴圈有東西在跑時每 2 毫秒、安靜時每 40 毫秒看一次有沒有要讀的(`wire::Pace`),之後立刻送出排隊的訊息。心跳用 WebSocket 的 ping/pong。連線斷掉時,伺服器把還在等的 job 都回 503「斷線了」,AnyBot 把還在等的 call 都回「和帳號伺服器的連線斷了」。 ## 同步的改動(`/api/sync/edit` 的 ops) diff --git a/icons/lucide/chevron-down.svg b/icons/lucide/chevron-down.svg deleted file mode 100644 index e17e4c3..0000000 --- a/icons/lucide/chevron-down.svg +++ /dev/null @@ -1,15 +0,0 @@ - - - - diff --git a/icons/lucide/external-link.svg b/icons/lucide/external-link.svg deleted file mode 100644 index f0927e8..0000000 --- a/icons/lucide/external-link.svg +++ /dev/null @@ -1,17 +0,0 @@ - - - - - - diff --git a/icons/lucide/globe.svg b/icons/lucide/globe.svg deleted file mode 100644 index 6c8dff2..0000000 --- a/icons/lucide/globe.svg +++ /dev/null @@ -1,17 +0,0 @@ - - - - - - diff --git a/icons/lucide/info.svg b/icons/lucide/info.svg deleted file mode 100644 index c35697b..0000000 --- a/icons/lucide/info.svg +++ /dev/null @@ -1,17 +0,0 @@ - - - - - - diff --git a/icons/lucide/keyboard.svg b/icons/lucide/keyboard.svg deleted file mode 100644 index 014e458..0000000 --- a/icons/lucide/keyboard.svg +++ /dev/null @@ -1,23 +0,0 @@ - - - - - - - - - - - - diff --git a/icons/lucide/mail.svg b/icons/lucide/mail.svg deleted file mode 100644 index fa09a67..0000000 --- a/icons/lucide/mail.svg +++ /dev/null @@ -1,16 +0,0 @@ - - - - - diff --git a/icons/lucide/panel-right.svg b/icons/lucide/panel-right.svg deleted file mode 100644 index c87b24f..0000000 --- a/icons/lucide/panel-right.svg +++ /dev/null @@ -1,16 +0,0 @@ - - - - - diff --git a/icons/lucide/phone.svg b/icons/lucide/phone.svg deleted file mode 100644 index b9649d8..0000000 --- a/icons/lucide/phone.svg +++ /dev/null @@ -1,15 +0,0 @@ - - - - diff --git a/icons/lucide/volume-2.svg b/icons/lucide/volume-2.svg deleted file mode 100644 index ba0e148..0000000 --- a/icons/lucide/volume-2.svg +++ /dev/null @@ -1,17 +0,0 @@ - - - - - - diff --git a/relay/src/main.rs b/relay/src/main.rs index 17a07e9..c4fb037 100644 --- a/relay/src/main.rs +++ b/relay/src/main.rs @@ -220,29 +220,6 @@ struct Relay { /// How long a link the app opens for a browser works (reconnects included). const TICKET_LIFE: Duration = Duration::from_secs(600); -/// How long a connection's loop waits to read before sending what is queued: 2 ms while things -/// move (a live screen), 40 ms when quiet. A fixed 40 ms added up to 40 ms to every message. -struct Pace { - moved: Instant, - busy: Option, -} - -impl Pace { - fn new() -> Pace { - Pace { moved: Instant::now() - Duration::from_secs(60), busy: None } - } - fn moved(&mut self) { - self.moved = Instant::now(); - } - fn tune(&mut self, c: &std::net::TcpStream) { - let busy = self.moved.elapsed() < Duration::from_secs(2); - if self.busy != Some(busy) { - self.busy = Some(busy); - let _ = c.set_read_timeout(Some(Duration::from_millis(if busy { 2 } else { 40 }))); - } - } -} - fn now() -> i64 { chrono::Utc::now().timestamp() } @@ -334,7 +311,6 @@ impl Relay { /// (?t=, the phone page) or a ticket the account's app asked for (?ticket=); owners and /// admins only. fn vnc_viewer(self: &Arc, req: Req, mut c: std::net::TcpStream, host: &str) { - use std::io::Write; use tungstenite::{protocol::Role, Message, WebSocket}; let user = match req.query.get("ticket").filter(|t| !t.is_empty()) { Some(t) => self.tickets.lock().unwrap().get(t).filter(|(_, h, made)| h == host && made.elapsed() < TICKET_LIFE).map(|(u, _, _)| u.clone()), @@ -348,15 +324,9 @@ impl Relay { let Some(to_host) = self.sockets.lock().unwrap().get(host).map(|s| s.tx.clone()) else { return httpd::write_response(&mut c, &Resp::error(503, "這台 AnyBot 現在沒有連線")); }; - let key = req.header("sec-websocket-key").to_string(); - if key.is_empty() { - return httpd::write_response(&mut c, &Resp::error(400, "bad request")); - } - let accept = tungstenite::handshake::derive_accept_key(key.as_bytes()); - if c.write_all(format!("HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: {accept}\r\n\r\n").as_bytes()).is_err() { + if !httpd::accept_websocket(&req, &mut c) { return; } - let _ = c.set_nodelay(true); let mut ws = WebSocket::from_raw_socket(c, Role::Server, None); let sid = httpd::random_hex(8); let (tx, rx) = channel::>(); @@ -366,7 +336,7 @@ impl Relay { return; } let (mut heard, mut pinged) = (Instant::now(), Instant::now()); - let mut pace = Pace::new(); + let mut pace = wire::Pace::new(); 'live: loop { pace.tune(ws.get_ref()); let up = match ws.read() { @@ -480,7 +450,6 @@ impl Relay { /// 長連線: an AnyBot keeps one WebSocket open; requests for it come down it at once, and its /// own requests to the account's other AnyBots go up it. fn ws(self: &Arc, req: Req, mut c: std::net::TcpStream) { - use std::io::Write; use tungstenite::{protocol::Role, Message, WebSocket}; if let Some(host) = req.path.strip_prefix("/relay/vnc/") { let host = host.to_string(); @@ -490,16 +459,9 @@ impl Relay { return httpd::write_response(&mut c, &Resp::error(404, "沒有這個網址")); } let Some(h) = self.host(&req) else { return httpd::write_response(&mut c, &Resp::error(401, "這台電腦的金鑰無效")) }; - let key = req.header("sec-websocket-key").to_string(); - if key.is_empty() { - return httpd::write_response(&mut c, &Resp::error(400, "bad request")); - } - let accept = tungstenite::handshake::derive_accept_key(key.as_bytes()); - if c.write_all(format!("HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: {accept}\r\n\r\n").as_bytes()).is_err() { + if !httpd::accept_websocket(&req, &mut c) { return; } - let _ = c.set_read_timeout(Some(Duration::from_millis(40))); - let _ = c.set_nodelay(true); let cfg = tungstenite::protocol::WebSocketConfig::default().max_message_size(Some(httpd::MAX_BODY * 2)).max_frame_size(Some(httpd::MAX_BODY * 2)); let mut ws = WebSocket::from_raw_socket(c, Role::Server, Some(cfg)); let serial = SERIAL.fetch_add(1, Ordering::SeqCst); @@ -507,7 +469,7 @@ impl Relay { self.sockets.lock().unwrap().insert(h.id.clone(), Sock { tx: tx.clone(), serial }); self.seen.lock().unwrap().insert(h.id.clone(), Instant::now()); let (mut heard, mut pinged, mut marked) = (Instant::now(), Instant::now(), Instant::now()); - let mut pace = Pace::new(); + let mut pace = wire::Pace::new(); 'live: loop { pace.tune(ws.get_ref()); match ws.read() { diff --git a/src/app.rs b/src/app.rs index 8a8658e..ad52057 100644 --- a/src/app.rs +++ b/src/app.rs @@ -4132,12 +4132,6 @@ fn icon_button(ui: &mut egui::Ui, icon: &str, tip: &str, p: Palette) -> egui::Re ui.add(egui::Button::new(RichText::new(icon).size(15.0).color(p.text2)).frame(false).min_size(vec2(26.0, 26.0))).on_hover_text(t(tip)) } -/// A header action: text only, a soft fill on hover. -#[allow(dead_code)] -fn quiet_button(ui: &mut egui::Ui, text: &str, p: Palette) -> egui::Response { - ui.add(egui::Button::new(RichText::new(t(text)).size(FONT_SM).color(p.text2)).frame_when_inactive(false).corner_radius(8).min_size(vec2(0.0, 28.0))) -} - fn small_button(ui: &mut egui::Ui, text: &str, p: Palette) -> egui::Response { ui.add(egui::Button::new(RichText::new(t(text)).size(11.5).color(p.text3)).frame_when_inactive(false).corner_radius(6).min_size(vec2(0.0, 22.0))) } diff --git a/src/app/computers_ui.rs b/src/app/computers_ui.rs index 9f78e72..06c7b22 100644 --- a/src/app/computers_ui.rs +++ b/src/app/computers_ui.rs @@ -927,8 +927,7 @@ impl App { let s = self.state.remote.clone(); self.box_task(move || { let ticket = crate::remote::screen_ticket(&s)?; - let enc = |t: &str| t.bytes().map(|b| if b.is_ascii_alphanumeric() { (b as char).to_string() } else { format!("%{b:02X}") }).collect::(); - let url = format!("{}/screen#host={}&ticket={ticket}&name={}{}", s.relay_url.trim_end_matches('/'), s.brain_id, enc(&name), if control { "&control=1" } else { "" }); + let url = format!("{}/screen#host={}&ticket={ticket}&name={}{}", s.relay_url.trim_end_matches('/'), s.brain_id, crate::httpd::url_encode(&name), if control { "&control=1" } else { "" }); crate::preview::open_with_system(&url); Ok(String::new()) }); diff --git a/src/app/sidebar_ui.rs b/src/app/sidebar_ui.rs index 0ebf064..21905b6 100644 --- a/src/app/sidebar_ui.rs +++ b/src/app/sidebar_ui.rs @@ -29,9 +29,7 @@ impl Face { } #[derive(Clone, Copy, Debug, PartialEq)] -#[allow(dead_code)] pub enum Poke { - Spin, Bounce, Nod, /// Glance left, then right (the greeting). @@ -44,7 +42,6 @@ const SPLASH_FADE: f64 = 0.35; fn duration(p: Poke) -> f64 { match p { - Poke::Spin => 0.9, Poke::Bounce => 0.62, Poke::Nod => 0.5, Poke::Look => 0.7, @@ -121,7 +118,6 @@ impl App { f.poke = None; } else { match kind { - Poke::Spin => pose.yaw = 180.0 * 2.0 * (1.0 - (PI * e).cos()), Poke::Nod => pose.pitch = 16.0 * bump(e, 0.0, 1.0), Poke::Look => pose.gaze = vec2(-(2.0 * PI * e).sin() * 0.9, 0.1), Poke::Bounce => { diff --git a/src/cloudbox.rs b/src/cloudbox.rs index f19adf6..8d02ce1 100644 --- a/src/cloudbox.rs +++ b/src/cloudbox.rs @@ -453,9 +453,24 @@ fn dk(args: &[&str]) -> Result { } fn dk_timeout(args: &[&str], ms: u64) -> Result, String> { - use std::io::Read; - let mut child = docker().args(args).stdout(Stdio::piped()).stderr(Stdio::piped()).spawn().map_err(|e| format!("找不到 docker:{e}"))?; - // Drain both pipes while waiting, or a big screenshot fills the pipe and stalls docker. + dk_with_input(args, None, ms) +} + +/// Run docker with `input` on its stdin (if any): Ok(stdout) or Err(stderr), killed after `ms` +/// ("逾時"). Both pipes are drained while waiting, or a big screenshot fills a pipe and stalls +/// docker. +fn dk_with_input(args: &[&str], input: Option<&[u8]>, ms: u64) -> Result, String> { + use std::io::{Read, Write}; + let mut child = docker() + .args(args) + .stdin(if input.is_some() { Stdio::piped() } else { Stdio::null() }) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .map_err(|e| format!("找不到 docker:{e}"))?; + if let (Some(b), Some(mut si)) = (input, child.stdin.take()) { + si.write_all(b).map_err(|e| e.to_string())?; + } let mut so = child.stdout.take().ok_or("沒有輸出")?; let mut se = child.stderr.take().ok_or("沒有輸出")?; let out_t = std::thread::spawn(move || { @@ -478,7 +493,7 @@ fn dk_timeout(args: &[&str], ms: u64) -> Result, String> { let _ = child.wait(); return Err("逾時".into()); } - std::thread::sleep(Duration::from_millis(10)); + std::thread::sleep(Duration::from_millis(5)); }; let out = out_t.join().unwrap_or_default(); let err = err_t.join().unwrap_or_default(); @@ -1359,51 +1374,14 @@ fn type_script(text: &str) -> String { /// Run a step's script in the box; with `picture`, wait for the screen to settle and return it. fn box_exec(script: &str, stdin: Option<&str>, picture: bool) -> Result, String> { - use std::io::{Read, Write}; let full = if picture { format!("{{ {script}; }} || exit $?; sleep {:.2}; import -window root png:- 2>/dev/null || true", crate::computer::SETTLE_MS as f64 / 1000.0) } else { script.to_string() }; - let mut child = docker() - .args(["exec", "-i", "-u", "bot", "-e", "DISPLAY=:1", container(), "sh", "-c", &full]) - .stdin(Stdio::piped()) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .spawn() - .map_err(|e| format!("找不到 docker:{e}"))?; - { - let mut si = child.stdin.take().ok_or("寫不進 Bot 的電腦")?; - if let Some(t) = stdin { - si.write_all(t.as_bytes()).map_err(|e| e.to_string())?; - } - } - let mut so = child.stdout.take().ok_or("沒有輸出")?; - let mut se = child.stderr.take().ok_or("沒有輸出")?; - let out_t = std::thread::spawn(move || { - let mut v = vec![]; - let _ = so.read_to_end(&mut v); - v - }); - let err_t = std::thread::spawn(move || { - let mut v = vec![]; - let _ = se.read_to_end(&mut v); - v - }); - let start = Instant::now(); - let status = loop { - if let Ok(Some(st)) = child.try_wait() { - break st; - } - if start.elapsed() > Duration::from_secs(30) { - let _ = child.kill(); - let _ = child.wait(); - return Err("這台電腦太久沒有回應".into()); - } - std::thread::sleep(Duration::from_millis(5)); + // Always an stdin (closed at once when there's no text), as `docker exec -i` expects. + let out = match dk_with_input(&["exec", "-i", "-u", "bot", "-e", "DISPLAY=:1", container(), "sh", "-c", &full], Some(stdin.unwrap_or("").as_bytes()), 30_000) { + Ok(o) => o, + Err(e) if e == "逾時" => return Err("這台電腦太久沒有回應".into()), + Err(e) if e.is_empty() => return Err("這一步沒有成功".into()), + Err(e) => return Err(format!("xdotool:{e}")), }; - let out = out_t.join().unwrap_or_default(); - let err = err_t.join().unwrap_or_default(); - if !status.success() { - let e = String::from_utf8_lossy(&err).trim().to_string(); - return Err(if e.is_empty() { "這一步沒有成功".into() } else { format!("xdotool:{e}") }); - } if !picture || out.is_empty() { return Ok(None); } diff --git a/src/computer.rs b/src/computer.rs index 2044a7a..278228a 100644 --- a/src/computer.rs +++ b/src/computer.rs @@ -215,12 +215,9 @@ pub fn label(name: &str) -> &'static str { } } -fn args_of(args: &str) -> Value { - serde_json::from_str(if args.trim().is_empty() { "{}" } else { args }).unwrap_or(Value::Null) -} pub fn describe(name: &str, args: &str) -> String { - let v = args_of(args); + let v = crate::tools::args_of(args); // By number from the app's list (e7), else where on the screen (it said "(0, 0)" for every // click by number). let xy = || match v["ref"].as_str().map(str::trim).filter(|r| !r.is_empty()) { @@ -251,11 +248,6 @@ pub fn describe(name: &str, args: &str) -> String { } } -#[allow(dead_code)] -pub fn specs() -> Vec { - specs_for(&[], "") -} - /// The screen tools, each with a `computer` choice. One way to act (a real click or keystroke), /// two ways to see: the picture, or an app's controls as numbered text. pub fn specs_for(computers: &[String], default: &str) -> Vec { @@ -445,7 +437,6 @@ fn flags_of(v: &Value) -> u64 { } /// Run a computer_use tool. Returns (text, ok, screenshot as base64 JPEG). -#[allow(dead_code)] pub fn run(name: &str, args: &str) -> (String, bool, Option) { run_by(name, args, "") } @@ -455,7 +446,7 @@ pub fn run_by(name: &str, args: &str, who: &str) -> (String, bool, Option (t, true, img), Err(e) => (e, false, None), } @@ -789,7 +780,7 @@ pub fn person_act(name: &str, v: &Value) -> Result<(), String> { /// The app a screen action will land in: the window under the point for clicks, else the /// frontmost app. Used for the per-app permission (「AnyBot 可以看到 {app},並在其中點擊和輸入」). pub fn target_app(name: &str, args: &str) -> Option { - let v = args_of(args); + let v = crate::tools::args_of(args); if let Some(app) = v["app"].as_str().filter(|a| !a.trim().is_empty() && matches!(name, "computer_use_read_app" | "computer_use_open_app")) { return Some(app.trim().to_string()); } diff --git a/src/httpd.rs b/src/httpd.rs index eac8782..155c527 100644 --- a/src/httpd.rs +++ b/src/httpd.rs @@ -9,6 +9,25 @@ use std::net::{TcpListener, TcpStream}; use std::sync::Arc; use std::time::Duration; +/// Answer a WebSocket upgrade: "101 Switching Protocols" (or 400 without a key). True when the +/// connection is now a WebSocket. +pub fn accept_websocket(req: &Req, c: &mut TcpStream) -> bool { + let key = req.header("sec-websocket-key"); + if key.is_empty() { + write_response(c, &Resp::error(400, "bad request")); + return false; + } + let accept = tungstenite::handshake::derive_accept_key(key.as_bytes()); + let ok = c.write_all(format!("HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: {accept}\r\n\r\n").as_bytes()).is_ok(); + let _ = c.set_nodelay(true); + ok +} + +/// Percent-encode a query value or form field (RFC 3986: letters, digits and -_.~ stay). +pub fn url_encode(s: &str) -> String { + s.bytes().map(|b| if b.is_ascii_alphanumeric() || b"-_.~".contains(&b) { (b as char).to_string() } else { format!("%{b:02X}") }).collect() +} + /// Largest request body accepted (a phone photo fits). pub const MAX_BODY: usize = 30 * 1024 * 1024; diff --git a/src/icons.rs b/src/icons.rs index 153cddf..d4315c5 100644 --- a/src/icons.rs +++ b/src/icons.rs @@ -7,7 +7,6 @@ use std::collections::HashMap; use std::sync::{Mutex, OnceLock}; #[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)] -#[allow(dead_code)] pub enum Icon { SidebarCollapse, SidebarExpand, @@ -19,7 +18,6 @@ pub enum Icon { Stop, Monitor, Mic, - Speaker, Sparkle, Chat, Shield, @@ -29,8 +27,6 @@ pub enum Icon { Person, Eye, CloudDownload, - PanelRight, - Phone, Clock, Calendar, Wave, @@ -56,7 +52,6 @@ pub enum Icon { NewChat, History, Eraser, - Info, Bot, Users, Template, @@ -70,10 +65,6 @@ pub enum Icon { File, Lock, Update, - Keyboard, - Globe, - ChevronRight, - ChevronDown, Zap, Alert, Summary, @@ -81,10 +72,8 @@ pub enum Icon { MapPin, CloudSun, ShoppingBag, - ExternalLink, Message, Alarm, - Mail, CircleCheck, CircleX, Minus, @@ -93,6 +82,7 @@ pub enum Icon { Pause, FileJson, FileWord, + ChevronRight, } impl Icon { @@ -115,6 +105,7 @@ impl Icon { /// The Lucide file. fn svg(self) -> &'static str { match self { + Icon::ChevronRight => include_str!("../icons/lucide/chevron-right.svg"), Icon::SidebarCollapse => include_str!("../icons/lucide/panel-left-close.svg"), Icon::SidebarExpand => include_str!("../icons/lucide/panel-left-open.svg"), Icon::Plus => include_str!("../icons/lucide/plus.svg"), @@ -125,7 +116,6 @@ impl Icon { Icon::Stop => include_str!("../icons/lucide/square.svg"), Icon::Monitor => include_str!("../icons/lucide/monitor.svg"), Icon::Mic => include_str!("../icons/lucide/mic.svg"), - Icon::Speaker => include_str!("../icons/lucide/volume-2.svg"), Icon::Sparkle => include_str!("../icons/lucide/sparkles.svg"), Icon::Chat => include_str!("../icons/lucide/messages-square.svg"), Icon::Shield => include_str!("../icons/lucide/shield-check.svg"), @@ -135,8 +125,6 @@ impl Icon { Icon::Person => include_str!("../icons/lucide/user-round.svg"), Icon::Eye => include_str!("../icons/lucide/eye.svg"), Icon::CloudDownload => include_str!("../icons/lucide/cloud-download.svg"), - Icon::PanelRight => include_str!("../icons/lucide/panel-right.svg"), - Icon::Phone => include_str!("../icons/lucide/phone.svg"), Icon::Clock => include_str!("../icons/lucide/clock.svg"), Icon::Calendar => include_str!("../icons/lucide/calendar.svg"), Icon::Wave => include_str!("../icons/lucide/audio-lines.svg"), @@ -162,7 +150,6 @@ impl Icon { Icon::NewChat => include_str!("../icons/lucide/square-pen.svg"), Icon::History => include_str!("../icons/lucide/history.svg"), Icon::Eraser => include_str!("../icons/lucide/eraser.svg"), - Icon::Info => include_str!("../icons/lucide/info.svg"), Icon::Bot => include_str!("../icons/lucide/bot.svg"), Icon::Users => include_str!("../icons/lucide/users.svg"), Icon::Template => include_str!("../icons/lucide/layout-template.svg"), @@ -176,10 +163,6 @@ impl Icon { Icon::File => include_str!("../icons/lucide/file.svg"), Icon::Lock => include_str!("../icons/lucide/lock.svg"), Icon::Update => include_str!("../icons/lucide/circle-arrow-up.svg"), - Icon::Keyboard => include_str!("../icons/lucide/keyboard.svg"), - Icon::Globe => include_str!("../icons/lucide/globe.svg"), - Icon::ChevronRight => include_str!("../icons/lucide/chevron-right.svg"), - Icon::ChevronDown => include_str!("../icons/lucide/chevron-down.svg"), Icon::Zap => include_str!("../icons/lucide/zap.svg"), Icon::Alert => include_str!("../icons/lucide/circle-alert.svg"), Icon::Summary => include_str!("../icons/lucide/notebook-pen.svg"), @@ -187,10 +170,8 @@ impl Icon { Icon::MapPin => include_str!("../icons/lucide/map-pin.svg"), Icon::CloudSun => include_str!("../icons/lucide/cloud-sun.svg"), Icon::ShoppingBag => include_str!("../icons/lucide/shopping-bag.svg"), - Icon::ExternalLink => include_str!("../icons/lucide/external-link.svg"), Icon::Message => include_str!("../icons/lucide/message-circle.svg"), Icon::Alarm => include_str!("../icons/lucide/alarm-clock.svg"), - Icon::Mail => include_str!("../icons/lucide/mail.svg"), Icon::CircleCheck => include_str!("../icons/lucide/circle-check.svg"), Icon::CircleX => include_str!("../icons/lucide/circle-x.svg"), Icon::Minus => include_str!("../icons/lucide/minus.svg"), diff --git a/src/phone.rs b/src/phone.rs index 5155559..73f2524 100644 --- a/src/phone.rs +++ b/src/phone.rs @@ -35,9 +35,7 @@ pub fn twilio_auth(token: &str) -> Result<(String, String), String> { } fn form(pairs: &[(&str, &str)]) -> String { - fn enc(s: &str) -> String { - s.bytes().map(|b| if b.is_ascii_alphanumeric() || b"-_.~".contains(&b) { (b as char).to_string() } else { format!("%{b:02X}") }).collect() - } + let enc = crate::httpd::url_encode; pairs.iter().map(|(k, v)| format!("{}={}", enc(k), enc(v))).collect::>().join("&") } diff --git a/src/remote.rs b/src/remote.rs index 6b3c4b2..b06644c 100644 --- a/src/remote.rs +++ b/src/remote.rs @@ -813,7 +813,6 @@ pub fn set_lan(on: bool) -> Result { /// The phone page on the local network watching the bots' computer: /api/vnc, signed in with /// the phone's password like every other request here; the VNC stream goes straight to the box. fn lan_vnc(req: Req, mut c: std::net::TcpStream) { - use std::io::Write; use tungstenite::{protocol::Role, Message, WebSocket}; if req.path != "/api/vnc" { return httpd::write_response(&mut c, &Resp::error(404, "沒有這個網址")); @@ -821,12 +820,9 @@ fn lan_vnc(req: Req, mut c: std::net::TcpStream) { if devices_ok(&req.bearer()).is_none() { return httpd::write_response(&mut c, &Resp::error(401, "請先登入")); } - let key = req.header("sec-websocket-key").to_string(); - let accept = tungstenite::handshake::derive_accept_key(key.as_bytes()); - if key.is_empty() || c.write_all(format!("HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: {accept}\r\n\r\n").as_bytes()).is_err() { + if !httpd::accept_websocket(&req, &mut c) { return; } - let _ = c.set_nodelay(true); let _ = c.set_read_timeout(Some(Duration::from_millis(2))); let mut ws = WebSocket::from_raw_socket(c, Role::Server, None); let (tx, rx) = std::sync::mpsc::channel::>(); @@ -1215,7 +1211,7 @@ fn to_brain_raw(s: &Settings, method: &str, rest: &str, query: &[(&str, String)] 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)); } - let q: Vec = query.iter().map(|(k, v)| format!("{k}={}", url_escape(v))).collect(); + let q: Vec = query.iter().map(|(k, v)| format!("{k}={}", httpd::url_encode(v))).collect(); let url = brain_url(s, &format!("{rest}{}{}", if q.is_empty() { "" } else { "?" }, q.join("&")))?; let auth = format!("Bearer {}", s.host_token); 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) }; @@ -1276,15 +1272,6 @@ pub fn sync_file(s: &Settings, path: &str) -> Result, String> { Ok(bytes) } -fn url_escape(s: &str) -> String { - s.bytes() - .map(|b| match b { - b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => (b as char).to_string(), - _ => format!("%{b:02X}"), - }) - .collect() -} - /// A notification on the person's phones (through the relay; phones that turned them on). pub fn notify(title: &str, body: &str, bot: u64) { let s = hub().settings(); diff --git a/src/teams.rs b/src/teams.rs index 479fae7..a6196b8 100644 --- a/src/teams.rs +++ b/src/teams.rs @@ -24,16 +24,12 @@ fn agent() -> ureq::Agent { } fn form(url: &str, fields: &[(&str, &str)]) -> Result { - let body: String = fields.iter().map(|(k, v)| format!("{}={}", k, urlencode(v))).collect::>().join("&"); + let body: String = fields.iter().map(|(k, v)| format!("{}={}", k, crate::httpd::url_encode(v))).collect::>().join("&"); let mut resp = agent().post(url).header("Content-Type", "application/x-www-form-urlencoded").send(body.as_bytes()).map_err(|e| e.to_string())?; let text = resp.body_mut().read_to_string().unwrap_or_default(); serde_json::from_str(&text).map_err(|_| text.chars().take(200).collect()) } -pub fn urlencode(s: &str) -> String { - s.bytes().map(|b| if b.is_ascii_alphanumeric() || b"-_.~".contains(&b) { (b as char).to_string() } else { format!("%{b:02X}") }).collect() -} - /// Step 1: ask for a code. Returns (user code, address, device code, poll seconds). pub fn device_code(client_id: &str, tenant: &str) -> Result<(String, String, String, u64), String> { let v = form(&format!("{}/{}/oauth2/v2.0/devicecode", login_base(), tenant_or_default(tenant)), &[("client_id", client_id.trim()), ("scope", SCOPES)])?; @@ -266,6 +262,6 @@ mod tests { let (cur, items) = new_messages(&v, "2026-11-04T01:00:01Z", "ME"); assert_eq!(cur, "2026-11-04T01:00:03Z"); assert_eq!(items, vec![("小美".to_string(), "週報 好了嗎?".to_string(), "2".to_string())]); - assert_eq!(urlencode("a b/c"), "a%20b%2Fc"); + assert_eq!(crate::httpd::url_encode("a b/c"), "a%20b%2Fc"); } } diff --git a/src/theme.rs b/src/theme.rs index 7ec60e8..8ebbdd0 100644 --- a/src/theme.rs +++ b/src/theme.rs @@ -6,8 +6,6 @@ use eframe::egui::Color32; #[derive(Clone, Copy)] pub struct Palette { pub pane: Color32, - #[allow(dead_code)] - pub chrome: Color32, pub sidebar: Color32, pub elevated: Color32, pub hover: Color32, @@ -18,8 +16,6 @@ pub struct Palette { pub text3: Color32, pub on_primary: Color32, pub bubble_user: Color32, - #[allow(dead_code)] - pub bubble_agent: Color32, pub border: Color32, pub focus: Color32, pub accent: Color32, @@ -32,7 +28,6 @@ const fn hexa(r: u8, g: u8, b: u8, a: u8) -> Color32 { pub const LIGHT: Palette = Palette { pane: hexa(0xff, 0xff, 0xff, 0xff), - chrome: hexa(0xfc, 0xfc, 0xfc, 0xff), sidebar: hexa(0xf5, 0xf5, 0xf5, 0xff), elevated: hexa(0xff, 0xff, 0xff, 0xff), hover: hexa(0x1c, 0x1c, 0x1c, 0x0f), @@ -43,7 +38,6 @@ pub const LIGHT: Palette = Palette { text3: hexa(0x1c, 0x1c, 0x1c, 0x8f), on_primary: hexa(0xff, 0xff, 0xff, 0xff), bubble_user: hexa(0x1c, 0x1c, 0x1c, 0xff), - bubble_agent: hexa(0x1c, 0x1c, 0x1c, 0x0f), border: hexa(0x1c, 0x1c, 0x1c, 0x1f), focus: hexa(0x1e, 0x86, 0xfc, 0xff), accent: hexa(0x16, 0x80, 0xf3, 0xff), @@ -57,7 +51,6 @@ pub const LIGHT: Palette = Palette { // Measured from the original's dark window: chat rgb 19, sidebar 16, bubbles/cards 31–33. pub const DARK: Palette = Palette { pane: hexa(0x13, 0x13, 0x13, 0xff), - chrome: hexa(0x13, 0x13, 0x13, 0xff), sidebar: hexa(0x10, 0x10, 0x10, 0xff), elevated: hexa(0x1f, 0x1f, 0x1f, 0xff), hover: hexa(0xf5, 0xf5, 0xf5, 0x0f), @@ -68,7 +61,6 @@ pub const DARK: Palette = Palette { text3: hexa(0xf5, 0xf5, 0xf5, 0x8a), on_primary: hexa(0x1f, 0x1f, 0x1f, 0xff), bubble_user: hexa(0xf5, 0xf5, 0xf5, 0x2e), - bubble_agent: hexa(0xf5, 0xf5, 0xf5, 0x0a), border: hexa(0xf5, 0xf5, 0xf5, 0x1a), focus: hexa(0x18, 0x84, 0xfc, 0xff), accent: hexa(0x18, 0x84, 0xfc, 0xff), diff --git a/src/tools.rs b/src/tools.rs index c53b4f9..37b7907 100644 --- a/src/tools.rs +++ b/src/tools.rs @@ -90,8 +90,9 @@ pub fn label(name: &str) -> &'static str { } } +/// A tool call's arguments (none = an empty object). pub fn args_of(args: &str) -> Value { - serde_json::from_str(args).unwrap_or(Value::Null) + serde_json::from_str(if args.trim().is_empty() { "{}" } else { args }).unwrap_or(Value::Null) } fn arg_str(v: &Value, k: &str) -> String { diff --git a/src/tunnel.rs b/src/tunnel.rs index 619b2e4..5eb89db 100644 --- a/src/tunnel.rs +++ b/src/tunnel.rs @@ -228,15 +228,8 @@ pub fn run(url: &str, token: &str, alive: &dyn Fn() -> bool, status: &dyn Fn(&st true } -/// How long a turn of the connection's loop waits for something to read before sending what is -/// queued: short while things are moving (a live screen), longer when quiet. A fixed 40 ms -/// added up to 40 ms to every message sent, on each side. -const READ_WAIT_BUSY: Duration = Duration::from_millis(2); -const READ_WAIT_QUIET: Duration = Duration::from_millis(40); - fn keep(mut ws: WebSocket, serial: u64, alive: &dyn Fn() -> bool) { - let _ = ws.get_ref().tcp().set_read_timeout(Some(READ_WAIT_QUIET)); - let (mut moved, mut busy) = (Instant::now() - Duration::from_secs(60), false); + let mut pace = wire::Pace::new(); let (tx, rx) = channel::>(); let calls: Calls = Arc::default(); *link().lock().unwrap() = Some(Link { tx: tx.clone(), calls: calls.clone(), serial }); @@ -244,15 +237,11 @@ fn keep(mut ws: WebSocket, serial: u64, alive: &dyn Fn() -> bool) { let _ = tx.send(wire::pack(&json!({"t": "hello", "brain": s.brain_id, "hands": s.device_link}), &[])); let (mut heard, mut pinged) = (Instant::now(), Instant::now()); 'live: while alive() { - let now_busy = moved.elapsed() < Duration::from_secs(2); - if now_busy != busy { - busy = now_busy; - let _ = ws.get_ref().tcp().set_read_timeout(Some(if busy { READ_WAIT_BUSY } else { READ_WAIT_QUIET })); - } + pace.tune(ws.get_ref().tcp()); match ws.read() { Ok(Message::Binary(b)) => { heard = Instant::now(); - moved = heard; + pace.moved(); if let Some((head, body)) = wire::unpack(&b) { // The relay says this machine was unregistered (signed out): stop for good. if head["t"] == "bye" { @@ -267,7 +256,7 @@ fn keep(mut ws: WebSocket, serial: u64, alive: &dyn Fn() -> bool) { Err(_) => break, } while let Ok(f) = rx.try_recv() { - moved = Instant::now(); + pace.moved(); if ws.write(Message::Binary(f.into())).is_err() { break 'live; } diff --git a/src/vault.rs b/src/vault.rs index 5ee0831..ba92ed8 100644 --- a/src/vault.rs +++ b/src/vault.rs @@ -203,29 +203,6 @@ pub fn find(host: &str) -> Option { None } -/// Save a login in the macOS Keychain (the person types it in AnyBot's settings). -/// (Kept for the live tests and older callers.) -#[cfg_attr(not(test), allow(dead_code))] -pub fn save(site: &str, username: &str, password: &str) -> Result<(), String> { - let host = host_of(site); - let host = if host.is_empty() { site.trim().to_lowercase() } else { host }; - if host.is_empty() || password.is_empty() { - return Err("請填網站和密碼".into()); - } - if !cfg!(target_os = "macos") { - let label = format!("{LABEL}:{host}"); - let o = secret_tool(&["store", "--label", &label, "application", "anybot", "server", &host, "user", username], Some(password)) - .ok_or("找不到 secret-tool:請安裝 libsecret-tools(系統鑰匙圈的指令)。")?; - return if o.status.success() { Ok(()) } else { Err(format!("存不進系統鑰匙圈:{}", String::from_utf8_lossy(&o.stderr).trim())) }; - } - let out = Command::new("security").args(["add-internet-password", "-U", "-s", &host, "-a", username, "-l", LABEL, "-w", password]).output().map_err(|e| e.to_string())?; - if out.status.success() { - index_add(&host, username); - Ok(()) - } else { - Err(String::from_utf8_lossy(&out.stderr).trim().to_string()) - } -} /// AnyBot's list of the logins it saved: site and account only (the passwords stay in the /// keychain). macOS prints a non-ASCII label like 「AnyBot 登入」 as hex bytes, so the keychain @@ -254,15 +231,6 @@ fn write_index(list: &[(String, String)]) { let _ = std::fs::write(index_path(), serde_json::to_string_pretty(list).unwrap_or_default()); } -/// (Kept for the live tests and older callers.) -#[cfg_attr(not(test), allow(dead_code))] -fn index_add(site: &str, account: &str) { - let mut list = read_index().unwrap_or_default(); - if !list.iter().any(|(s, a)| s == site && a == account) { - list.push((site.to_string(), account.to_string())); - write_index(&list); - } -} /// The keychain has this login (no password is read, so no prompt). fn keychain_has(site: &str, account: &str) -> bool { @@ -305,19 +273,6 @@ pub fn saved_sites() -> Vec<(String, String)> { kept } -/// (Kept for the live tests and older callers.) -#[cfg_attr(not(test), allow(dead_code))] -pub fn remove(site: &str, account: &str) { - if !cfg!(target_os = "macos") { - let _ = secret_tool(&["clear", "application", "anybot", "server", site, "user", account], None); - return; - } - let _ = Command::new("security").args(["delete-internet-password", "-s", site, "-a", account]).output(); - if let Some(mut list) = read_index() { - list.retain(|(s, a)| !(s == site && a == account)); - write_index(&list); - } -} /// The JavaScript that types the login into the page (as a JSON-encoded string literal). pub fn fill_script(username: &str, password: &str, submit: bool) -> String { @@ -355,9 +310,6 @@ pub fn fill_script(username: &str, password: &str, submit: bool) -> String { ) } -fn args_of(args: &str) -> Value { - serde_json::from_str(if args.trim().is_empty() { "{}" } else { args }).unwrap_or_default() -} /// The current tab's host in AnyBot's browser. fn current_host() -> Result { @@ -393,7 +345,7 @@ impl LoginPlan { } pub fn plan_login(args: &str) -> Result { - let v = args_of(args); + let v = crate::tools::args_of(args); let tab = current_host()?; let host = v["site"].as_str().filter(|s| !s.trim().is_empty()).map(host_of).filter(|h| !h.is_empty()).unwrap_or(tab); if let Some(id) = v["login_id"].as_str().filter(|s| !s.is_empty()) { @@ -532,13 +484,13 @@ fn filled_summary(r: &Value) -> (Vec<&'static str>, u64) { /// For the approval question: (current host, card label) without secrets. pub fn plan_card(args: &str) -> Result<(String, crate::vault_store::Item), String> { - let id = args_of(args)["card_id"].as_str().unwrap_or("").to_string(); + let id = crate::tools::args_of(args)["card_id"].as_str().unwrap_or("").to_string(); let card = crate::vault_store::get(&id).filter(|i| i.kind == crate::vault_store::Kind::Card).ok_or("保險庫裡沒有這張卡片(先用 vault_list 看 id)")?; Ok((current_host()?, card)) } pub fn fill_card(args: &str) -> Result { - let id = args_of(args)["card_id"].as_str().unwrap_or("").to_string(); + let id = crate::tools::args_of(args)["card_id"].as_str().unwrap_or("").to_string(); let c = crate::vault_store::card_secret(&id).ok_or("保險庫裡沒有這張卡片")?; let values = serde_json::json!({ "cc-number": c.number, @@ -562,7 +514,7 @@ pub fn fill_card(args: &str) -> Result { } pub fn plan_contact(args: &str) -> Result<(String, crate::vault_store::Item), String> { - let id = args_of(args)["contact_id"].as_str().unwrap_or("").to_string(); + let id = crate::tools::args_of(args)["contact_id"].as_str().unwrap_or("").to_string(); let c = crate::vault_store::get(&id).filter(|i| i.kind == crate::vault_store::Kind::Contact).ok_or("保險庫裡沒有這筆聯絡資訊(先用 vault_list 看 id)")?; Ok((current_host()?, c)) } @@ -615,21 +567,6 @@ mod tests { assert!(js.contains("input[type=password]") && js.contains("if (true && (pw || user))")); } - /// Needs a keyring: on Linux run under `dbus-run-session` with gnome-keyring unlocked. - #[test] - #[ignore] - fn keyring_live() { - save("https://login.example.org/x", "me@example.org", "s3cret pass").unwrap(); - let l = find("login.example.org").expect("found"); - assert_eq!((l.username.as_str(), l.password.as_str()), ("me@example.org", "s3cret pass")); - assert!(saved_sites().iter().any(|(s, a)| s == "login.example.org" && a == "me@example.org")); - // Logins from before the list existed (label printed as hex by `security`) show up too. - if cfg!(target_os = "macos") { - assert!(saved_sites().iter().any(|(s, _)| s == "legacy.example.org"), "legacy entry: {:?}", saved_sites()); - } - remove("login.example.org", "me@example.org"); - assert!(find("login.example.org").is_none()); - } /// The whole bot-side flow on this computer: local pages in AnyBot's browser (a throwaway /// profile), items in a scratch vault, and the fill tools. Run with ANYBOT_DATA_DIR set to a diff --git a/src/vault_store.rs b/src/vault_store.rs index 3f89d6e..2d5b100 100644 --- a/src/vault_store.rs +++ b/src/vault_store.rs @@ -790,8 +790,6 @@ pub enum RowStatus { #[derive(Clone, Debug)] pub struct ImportRow { - #[allow(dead_code)] - pub line: usize, pub kind: Kind, pub label: String, pub secondary: String, @@ -864,7 +862,7 @@ pub fn plan_import(text: &str) -> Result { if format == CsvFormat::LastPass && url.trim() == "http://sn" { let extra = get(r, c_notes); if !extra.starts_with("NoteType:Credit Card") { - out.push(ImportRow { line, kind: Kind::Login, label: if title.is_empty() { format!("第 {line} 列") } else { title }, secondary: String::new(), status: RowStatus::Skipped("不是登入資訊或卡片"), site: String::new(), username: String::new(), password: String::new(), notes: String::new(), card: None }); + out.push(ImportRow { kind: Kind::Login, label: if title.is_empty() { format!("第 {line} 列") } else { title }, secondary: String::new(), status: RowStatus::Skipped("不是登入資訊或卡片"), site: String::new(), username: String::new(), password: String::new(), notes: String::new(), card: None }); continue; } let f = lastpass_fields(&extra); @@ -899,7 +897,6 @@ pub fn plan_import(text: &str) -> Result { }; let expiry = exp.map(|(m, y)| format!("{m:02}/{}", y % 100)).unwrap_or_default(); out.push(ImportRow { - line, kind: Kind::Card, label: if title.is_empty() { card_brand(&number).to_string() } else { title.clone() }, secondary: format!("•••• {last4}"), @@ -940,7 +937,7 @@ pub fn plan_import(text: &str) -> Result { RowStatus::Ready } }; - out.push(ImportRow { line, kind: Kind::Login, label, secondary: if user.is_empty() { "••••••••".into() } else { format!("{user} · ••••••••") }, status, site: host, username: user, password: pw, notes, card: None }); + out.push(ImportRow { kind: Kind::Login, label, secondary: if user.is_empty() { "••••••••".into() } else { format!("{user} · ••••••••") }, status, site: host, username: user, password: pw, notes, card: None }); } Ok(ImportPlan { format, rows: out }) } diff --git a/src/voice.rs b/src/voice.rs index 3c59f2a..f8fff6e 100644 --- a/src/voice.rs +++ b/src/voice.rs @@ -196,11 +196,6 @@ impl Recording { } } -/// multipart/form-data body for /audio/transcriptions. -#[allow(dead_code)] -pub fn multipart(model: &str, file_name: &str, audio: &[u8], boundary: &str) -> Vec { - multipart_lang(model, file_name, audio, boundary, "") -} /// Same, telling the service the spoken language (ISO 639-1, e.g. "zh"; empty = detect). pub fn multipart_lang(model: &str, file_name: &str, audio: &[u8], boundary: &str, lang: &str) -> Vec { @@ -219,11 +214,6 @@ pub fn multipart_lang(model: &str, file_name: &str, audio: &[u8], boundary: &str b } -/// Audio → text through `{base}/audio/transcriptions`. -#[allow(dead_code)] -pub fn transcribe(base_url: &str, api_key: &str, file: &std::path::Path) -> Result { - transcribe_lang(base_url, api_key, file, "") -} /// The spoken languages offered for voice chat (the original: 語言 Auto-detect…): (code, name). pub const SPOKEN: [(&str, &str); 6] = [("", "自動偵測"), ("zh", "中文"), ("en", "English"), ("ja", "日本語"), ("ko", "한국어"), ("es", "Español")]; @@ -364,11 +354,6 @@ pub fn voice_for(text: &str) -> Option<&'static str> { text.chars().any(|c| ('\u{4e00}'..='\u{9fff}').contains(&c)).then_some(if cfg!(target_os = "macos") { "Meijia" } else { "cmn" }) } -/// Read an answer aloud (stops whatever was being read). -#[allow(dead_code)] -pub fn speak(text: &str) { - speak_as(text, "", 1.0); -} /// Words per minute of the system voices at 1x (`say`'s usual rate). pub const BASE_RATE: f32 = 175.0; @@ -461,12 +446,12 @@ mod tests { }); let f = std::env::temp_dir().join(format!("anybot-voice-test-{}.m4a", std::process::id())); std::fs::write(&f, vec![7u8; 2048]).unwrap(); - let text = transcribe(&format!("http://127.0.0.1:{port}/v1"), "sk-test", &f).unwrap(); + let text = transcribe_lang(&format!("http://127.0.0.1:{port}/v1"), "sk-test", &f, "").unwrap(); let req = seen.join().unwrap(); let _ = std::fs::remove_file(&f); assert_eq!(text, "你好,幫我排行程"); assert!(req.starts_with("POST /v1/audio/transcriptions")); assert!(req.contains("Bearer sk-test") && req.contains("name=\"model\"\r\n\r\ngpt-4o-mini-transcribe") && req.contains("filename=\"voice.m4a\"")); - assert!(transcribe("http://127.0.0.1:1", "", &std::path::PathBuf::from("/x")).unwrap_err().contains("OpenAI")); + assert!(transcribe_lang("http://127.0.0.1:1", "", &std::path::PathBuf::from("/x"), "").unwrap_err().contains("OpenAI")); } } diff --git a/src/wire.rs b/src/wire.rs index c83ab97..1d52bef 100644 --- a/src/wire.rs +++ b/src/wire.rs @@ -28,6 +28,32 @@ pub fn unpack(b: &[u8]) -> Option<(Value, Vec)> { Some((head, b[4 + n..].to_vec())) } +/// How long a connection's loop waits to read before it sends what is queued: 2 ms while things +/// move (a live screen), 40 ms when quiet. A fixed 40 ms added up to 40 ms to every message on +/// each side of each hop. +pub struct Pace { + moved: std::time::Instant, + busy: Option, +} + +impl Pace { + pub fn new() -> Pace { + Pace { moved: std::time::Instant::now() - std::time::Duration::from_secs(60), busy: None } + } + /// Something was read or sent. + pub fn moved(&mut self) { + self.moved = std::time::Instant::now(); + } + /// Set the socket's read wait for now (only when it changes). + pub fn tune(&mut self, c: &std::net::TcpStream) { + let busy = self.moved.elapsed() < std::time::Duration::from_secs(2); + if self.busy != Some(busy) { + self.busy = Some(busy); + let _ = c.set_read_timeout(Some(std::time::Duration::from_millis(if busy { 2 } else { 40 }))); + } + } +} + #[cfg(test)] mod tests { #[test]