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] 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]