Remove what nothing uses and keep one copy of what was written several times

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 <noreply@anthropic.com>
This commit is contained in:
王性驊 2026-11-05 23:21:47 +08:00
parent 02bca39b98
commit 2867c8e822
31 changed files with 131 additions and 439 deletions

View File

@ -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` 鎖住,這時顯示「🔒 由你的管理員設定,無法變更」。 - **「網路流量改走這台 Mac」**(原版:網路流量可以改走你的本機電腦,「僅對新連線生效」,管理員可以關掉):打開後,Bot 的電腦之後執行的指令會經過這台 Mac 上的代理伺服器出去(例如要連公司內網或 VPN 才看得到的服務)。代理只聽這台 Mac 自己(127.0.0.1),用原版的埠 8791,被占用就往後找。設定頁顯示經過的連線數。Bot 的電腦在別的主機上時不能用。管理員可以用 `defaults write com.anybot.AnyBot AllowBoxTrafficViaThisMac false` 鎖住,這時顯示「🔒 由你的管理員設定,無法變更」。
### 看畫面、取得控制權 ### 看畫面、取得控制權(像同事一樣看,需要時接手)
- 設定頁的「開啟畫面」,或聊天頂部的「電腦」按鈕。 - 核心只有三件事:Bot 操作它的電腦;你像同事一樣即時看它在做什麼;它需要你(登入、驗證碼)時你接手幫忙。看和接手用 **VNC(noVNC)**:只傳畫面上變了的地方,每個滑鼠移動和按鍵即時送過去,手機可以用觸控。原版也是用 noVNC 看 box。
- 視窗:螢幕圖示+「Bot 的電腦」+狀態圓點;右邊是「取得控制權」「實際大小/調整至視窗大小」「全螢幕」和關閉。 - **在哪裡看**:手機網頁的「看 Bot 的電腦」、需要處理卡片的「接管」,Mac 和其他電腦的「看畫面」「開啟電腦」都打開同一個網頁 `/screen`(電腦上在瀏覽器開)。
- 畫面大約每 0.45 秒更新一次(估計值)。Bot 正在操作時顯示「● Bot 正在操作」。 - 大腦在別台(伺服器):網頁在帳號伺服器上,VNC 經過帳號伺服器和大腦的長連線到大腦的 Bot 的電腦。電腦上的 App 會先向帳號伺服器要一個 10 分鐘有效的連結(不用在瀏覽器登入);手機網頁用自己的登入。只有擁有者和管理員能看。
- **取得控制權**:畫面外框變成強調色,你的點擊、右鍵、連點、捲動、打字(包括中文)和快速鍵都會送到 Bot 的電腦(⌘ 會當成 Ctrl)。這時 Bot 的滑鼠鍵盤操作會暫停,它會收到「使用者正在自己操作 Bot 的電腦,請等他交還控制權」。按「交還給 Bot」結束。 - 同一個網路的手機(不經帳號伺服器):同一個網頁,連 AnyBot 自己的 `/api/vnc`。
- **僅觀看**:沒有取得控制權時,畫面只能看。 - 這台電腦自己的 Bot 的電腦:直接開它的 noVNC。
- **全螢幕**:在瀏覽器用 noVNC 開啟(有控制權時可以操作,沒有時只能看)。 - **網頁**:最上面是返回、電腦名稱、狀態(即時觀看/你在操作)和「接管/交還給 Bot」。預設只看,按「接管」才會把你的滑鼠鍵盤送過去;需要處理卡片的「接管」直接打開接管。接管時有「打字」:打中文或任何語言(貼進目前的視窗),還有 Enter、Esc、Tab、⌫。斷線會自己重連。
- 電腦休眠或還沒建立時,畫面中間有「喚醒」按鈕。 - **你在操作時 Bot 會等**:你點擊或按鍵後 8 秒內,Bot 的滑鼠鍵盤操作會收到「使用者正在自己操作這台電腦」。不用另外按什麼;停下 8 秒它就繼續。需要處理卡片的「接管」會讓 Bot 一直等,直到你按「我完成了」或「跳過」。
- **VNC 密碼不出大腦**:大腦用 Bot 的電腦自己的 VNC 密碼登入,給看的人的是「不用密碼」;能連到這裡的人已經過帳號伺服器(或手機密碼)。
- 以前的做法(每 0.45~1 秒拍一張 1 MB 的圖、解開再壓、經過帳號伺服器傳;點擊另走一條路)已經拆掉。
--- ---

View File

@ -2,8 +2,7 @@
# Build and test the Linux version from a Mac (or anywhere with Docker), on a virtual screen. # 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) # docker build -t anybot-linux-dev deploy/linux/dev (once)
# deploy/linux/dev/test.sh (from app/) # deploy/linux/dev/test.sh (from app/)
# Runs: every unit test; the live X11 test (screenshot, clicks, keys, recording a demo); the # Runs: every unit test; the live X11 test (screenshot, clicks, keys, recording a demo); the live Wayland test (headless sway: screenshot,
# keyring test (save / find / remove a login); the live Wayland test (headless sway: screenshot,
# clicks, Chinese typing, keys, scroll); and screenshots of the window into ./linux-shots. # clicks, Chinese typing, keys, scroll); and screenshots of the window into ./linux-shots.
set -e set -e
cd "$(dirname "$0")/../../.." cd "$(dirname "$0")/../../.."
@ -17,7 +16,6 @@ export DISPLAY=:99 LANG=zh_TW.UTF-8
sleep 1 sleep 1
cargo test --release 2>&1 | grep -E "test result|FAILED|panicked" cargo test --release 2>&1 | grep -E "test result|FAILED|panicked"
cargo test --release x11_live -- --ignored 2>&1 | grep -E "test result|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 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" 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 for v in chat computers general; do

View File

@ -42,7 +42,8 @@ AnyBot 是一個 Rust 程式(`anybot`),用 egui/eframe 畫介面。同一
| `tools.rs` | 檔案、指令、背景指令;指令在 macOS 沙盒裡跑(見「安全」) | | `tools.rs` | 檔案、指令、背景指令;指令在 macOS 沙盒裡跑(見「安全」) |
| `policy.rs` | 哪些動作直接做、先問、拒絕 | | `policy.rs` | 哪些動作直接做、先問、拒絕 |
| `cloudbox.rs`、`box/` | Bot 自己的電腦:一個 Docker 容器(有桌面、瀏覽器) | | `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 協定)、讀你的瀏覽器、抓網頁 | | `botbrowser.rs`、`browser.rs`、`web.rs` | AnyBot 自己的瀏覽器(Chrome DevTools 協定)、讀你的瀏覽器、抓網頁 |
| `routines.rs`、`triggers.rs` | 例行任務和觸發 | | `routines.rs`、`triggers.rs` | 例行任務和觸發 |
| `channels.rs` | 訊息管道(Slack、Discord、Telegram) | | `channels.rs` | 訊息管道(Slack、Discord、Telegram) |

View File

@ -54,6 +54,14 @@
轉送時伺服器附上 `user`、`role` 和 `from`(問的那台主機 id)。對方不在線時立刻回 503「目前離線」;收到了但 35 秒內沒回答,回 504。 轉送時伺服器附上 `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/<host>?t=` 或 `?ticket=` | 擁有者、管理員 | 二進位訊息是 VNC;文字訊息是要打進去的字。伺服器把它接到那台 AnyBot 的長連線(下面的 `v*`) |
## AnyBot 提供的介面(`remote::handle`) ## AnyBot 提供的介面(`remote::handle`)
只有經過帳號伺服器(或同一個網路、用手機密碼登入)才到得了。 只有經過帳號伺服器(或同一個網路、用手機密碼登入)才到得了。
@ -63,7 +71,11 @@
| GET | `/api/state?since=` | 手機 | Bot 清單、誰在工作、等核准的事(有變化才回,最多等 20 秒) | | GET | `/api/state?since=` | 手機 | Bot 清單、誰在工作、等核准的事(有變化才回,最多等 20 秒) |
| GET | `/api/bots/<id>?since=` | 手機 | 一個 Bot 的訊息 | | GET | `/api/bots/<id>?since=` | 手機 | 一個 Bot 的訊息 |
| POST | `/api/bots/<id>/<動作>` | 手機 | `send`、`stop`、`answer`、`read`、`new`、`upload`… | | POST | `/api/bots/<id>/<動作>` | 手機 | `send`、`stop`、`answer`、`read`、`new`、`upload`… |
| GET/POST | `/api/screen/<box\|mac>` | 手機 | 看畫面、點擊打字 | | 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}`:在這台跑一個工具 | | POST | `/api/device/run` | device | `{tool, args, who}` → `{text, ok, image}`:在這台跑一個工具 |
| GET | `/api/sync?since=&client=` | mirror | 大腦的變化(見 architecture.md) | | GET | `/api/sync?since=&client=` | mirror | 大腦的變化(見 architecture.md) |
| POST | `/api/sync/edit` | mirror | `{ops, client, seq}` → `{ok, applied}` | | 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` | 給這台的要求 | | `job` | 伺服器 → AnyBot | `id, method, path, query, user, role, from, z, z_ok` | 給這台的要求 |
| `reply` | AnyBot → 伺服器 | `id, status, ctype, headers, z` | 回答 job | | `reply` | AnyBot → 伺服器 | `id, status, ctype, headers, z` | 回答 job |
| `call` | AnyBot → 伺服器 | `id, to, method, path, query, z` | 要問帳號裡的另一台(只能是 `/api/sync…` 或 `/api/device/run`) | | `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) ## 同步的改動(`/api/sync/edit` 的 ops)

View File

@ -1,15 +0,0 @@
<!-- @license lucide-static v1.67.0 - ISC -->
<svg
class="lucide lucide-chevron-down"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="m6 9 6 6 6-6" />
</svg>

Before

Width:  |  Height:  |  Size: 319 B

View File

@ -1,17 +0,0 @@
<!-- @license lucide-static v1.67.0 - ISC -->
<svg
class="lucide lucide-external-link"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M15 3h6v6" />
<path d="M10 14 21 3" />
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" />
</svg>

Before

Width:  |  Height:  |  Size: 416 B

View File

@ -1,17 +0,0 @@
<!-- @license lucide-static v1.67.0 - ISC -->
<svg
class="lucide lucide-globe"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<circle cx="12" cy="12" r="10" />
<path d="M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20" />
<path d="M2 12h20" />
</svg>

Before

Width:  |  Height:  |  Size: 407 B

View File

@ -1,17 +0,0 @@
<!-- @license lucide-static v1.67.0 - ISC -->
<svg
class="lucide lucide-info"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<circle cx="12" cy="12" r="10" />
<path d="M12 16v-4" />
<path d="M12 8h.01" />
</svg>

Before

Width:  |  Height:  |  Size: 369 B

View File

@ -1,23 +0,0 @@
<!-- @license lucide-static v1.67.0 - ISC -->
<svg
class="lucide lucide-keyboard"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M10 8h.01" />
<path d="M12 12h.01" />
<path d="M14 8h.01" />
<path d="M16 12h.01" />
<path d="M18 8h.01" />
<path d="M6 8h.01" />
<path d="M7 16h10" />
<path d="M8 12h.01" />
<rect width="20" height="16" x="2" y="4" rx="2" />
</svg>

Before

Width:  |  Height:  |  Size: 540 B

View File

@ -1,16 +0,0 @@
<!-- @license lucide-static v1.67.0 - ISC -->
<svg
class="lucide lucide-mail"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="m22 7-8.991 5.727a2 2 0 0 1-2.009 0L2 7" />
<rect x="2" y="4" width="20" height="16" rx="2" />
</svg>

Before

Width:  |  Height:  |  Size: 391 B

View File

@ -1,16 +0,0 @@
<!-- @license lucide-static v1.67.0 - ISC -->
<svg
class="lucide lucide-panel-right"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<rect width="18" height="18" x="3" y="3" rx="2" />
<path d="M15 3v18" />
</svg>

Before

Width:  |  Height:  |  Size: 367 B

View File

@ -1,15 +0,0 @@
<!-- @license lucide-static v1.67.0 - ISC -->
<svg
class="lucide lucide-phone"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M13.832 16.568a1 1 0 0 0 1.213-.303l.355-.465A2 2 0 0 1 17 15h3a2 2 0 0 1 2 2v3a2 2 0 0 1-2 2A18 18 0 0 1 2 4a2 2 0 0 1 2-2h3a2 2 0 0 1 2 2v3a2 2 0 0 1-.8 1.6l-.468.351a1 1 0 0 0-.292 1.233 14 14 0 0 0 6.392 6.384" />
</svg>

Before

Width:  |  Height:  |  Size: 513 B

View File

@ -1,17 +0,0 @@
<!-- @license lucide-static v1.67.0 - ISC -->
<svg
class="lucide lucide-volume-2"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M11 4.702a.705.705 0 0 0-1.203-.498L6.413 7.587A1.4 1.4 0 0 1 5.416 8H3a1 1 0 0 0-1 1v6a1 1 0 0 0 1 1h2.416a1.4 1.4 0 0 1 .997.413l3.383 3.384A.705.705 0 0 0 11 19.298z" />
<path d="M16 9a5 5 0 0 1 0 6" />
<path d="M19.364 18.364a9 9 0 0 0 0-12.728" />
</svg>

Before

Width:  |  Height:  |  Size: 555 B

View File

@ -220,29 +220,6 @@ struct Relay {
/// How long a link the app opens for a browser works (reconnects included). /// How long a link the app opens for a browser works (reconnects included).
const TICKET_LIFE: Duration = Duration::from_secs(600); 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<bool>,
}
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 { fn now() -> i64 {
chrono::Utc::now().timestamp() 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 /// (?t=, the phone page) or a ticket the account's app asked for (?ticket=); owners and
/// admins only. /// admins only.
fn vnc_viewer(self: &Arc<Self>, req: Req, mut c: std::net::TcpStream, host: &str) { fn vnc_viewer(self: &Arc<Self>, req: Req, mut c: std::net::TcpStream, host: &str) {
use std::io::Write;
use tungstenite::{protocol::Role, Message, WebSocket}; use tungstenite::{protocol::Role, Message, WebSocket};
let user = match req.query.get("ticket").filter(|t| !t.is_empty()) { 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()), 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 { 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 現在沒有連線")); return httpd::write_response(&mut c, &Resp::error(503, "這台 AnyBot 現在沒有連線"));
}; };
let key = req.header("sec-websocket-key").to_string(); if !httpd::accept_websocket(&req, &mut c) {
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; return;
} }
let _ = c.set_nodelay(true);
let mut ws = WebSocket::from_raw_socket(c, Role::Server, None); let mut ws = WebSocket::from_raw_socket(c, Role::Server, None);
let sid = httpd::random_hex(8); let sid = httpd::random_hex(8);
let (tx, rx) = channel::<Vec<u8>>(); let (tx, rx) = channel::<Vec<u8>>();
@ -366,7 +336,7 @@ impl Relay {
return; return;
} }
let (mut heard, mut pinged) = (Instant::now(), Instant::now()); let (mut heard, mut pinged) = (Instant::now(), Instant::now());
let mut pace = Pace::new(); let mut pace = wire::Pace::new();
'live: loop { 'live: loop {
pace.tune(ws.get_ref()); pace.tune(ws.get_ref());
let up = match ws.read() { 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 /// 長連線: 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. /// own requests to the account's other AnyBots go up it.
fn ws(self: &Arc<Self>, req: Req, mut c: std::net::TcpStream) { fn ws(self: &Arc<Self>, req: Req, mut c: std::net::TcpStream) {
use std::io::Write;
use tungstenite::{protocol::Role, Message, WebSocket}; use tungstenite::{protocol::Role, Message, WebSocket};
if let Some(host) = req.path.strip_prefix("/relay/vnc/") { if let Some(host) = req.path.strip_prefix("/relay/vnc/") {
let host = host.to_string(); let host = host.to_string();
@ -490,16 +459,9 @@ impl Relay {
return httpd::write_response(&mut c, &Resp::error(404, "沒有這個網址")); 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 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 !httpd::accept_websocket(&req, &mut c) {
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; 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 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 mut ws = WebSocket::from_raw_socket(c, Role::Server, Some(cfg));
let serial = SERIAL.fetch_add(1, Ordering::SeqCst); 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.sockets.lock().unwrap().insert(h.id.clone(), Sock { tx: tx.clone(), serial });
self.seen.lock().unwrap().insert(h.id.clone(), Instant::now()); 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 heard, mut pinged, mut marked) = (Instant::now(), Instant::now(), Instant::now());
let mut pace = Pace::new(); let mut pace = wire::Pace::new();
'live: loop { 'live: loop {
pace.tune(ws.get_ref()); pace.tune(ws.get_ref());
match ws.read() { match ws.read() {

View File

@ -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)) 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 { 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))) 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)))
} }

View File

@ -927,8 +927,7 @@ impl App {
let s = self.state.remote.clone(); let s = self.state.remote.clone();
self.box_task(move || { self.box_task(move || {
let ticket = crate::remote::screen_ticket(&s)?; 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::<String>(); 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 { "" });
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); crate::preview::open_with_system(&url);
Ok(String::new()) Ok(String::new())
}); });

View File

@ -29,9 +29,7 @@ impl Face {
} }
#[derive(Clone, Copy, Debug, PartialEq)] #[derive(Clone, Copy, Debug, PartialEq)]
#[allow(dead_code)]
pub enum Poke { pub enum Poke {
Spin,
Bounce, Bounce,
Nod, Nod,
/// Glance left, then right (the greeting). /// Glance left, then right (the greeting).
@ -44,7 +42,6 @@ const SPLASH_FADE: f64 = 0.35;
fn duration(p: Poke) -> f64 { fn duration(p: Poke) -> f64 {
match p { match p {
Poke::Spin => 0.9,
Poke::Bounce => 0.62, Poke::Bounce => 0.62,
Poke::Nod => 0.5, Poke::Nod => 0.5,
Poke::Look => 0.7, Poke::Look => 0.7,
@ -121,7 +118,6 @@ impl App {
f.poke = None; f.poke = None;
} else { } else {
match kind { 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::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::Look => pose.gaze = vec2(-(2.0 * PI * e).sin() * 0.9, 0.1),
Poke::Bounce => { Poke::Bounce => {

View File

@ -453,9 +453,24 @@ fn dk(args: &[&str]) -> Result<String, String> {
} }
fn dk_timeout(args: &[&str], ms: u64) -> Result<Vec<u8>, String> { fn dk_timeout(args: &[&str], ms: u64) -> Result<Vec<u8>, String> {
use std::io::Read; dk_with_input(args, None, ms)
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.
/// 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<Vec<u8>, 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 so = child.stdout.take().ok_or("沒有輸出")?;
let mut se = child.stderr.take().ok_or("沒有輸出")?; let mut se = child.stderr.take().ok_or("沒有輸出")?;
let out_t = std::thread::spawn(move || { let out_t = std::thread::spawn(move || {
@ -478,7 +493,7 @@ fn dk_timeout(args: &[&str], ms: u64) -> Result<Vec<u8>, String> {
let _ = child.wait(); let _ = child.wait();
return Err("逾時".into()); 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 out = out_t.join().unwrap_or_default();
let err = err_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. /// 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<Option<image::RgbImage>, String> { fn box_exec(script: &str, stdin: Option<&str>, picture: bool) -> Result<Option<image::RgbImage>, 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 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() // Always an stdin (closed at once when there's no text), as `docker exec -i` expects.
.args(["exec", "-i", "-u", "bot", "-e", "DISPLAY=:1", container(), "sh", "-c", &full]) 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) {
.stdin(Stdio::piped()) Ok(o) => o,
.stdout(Stdio::piped()) Err(e) if e == "逾時" => return Err("這台電腦太久沒有回應".into()),
.stderr(Stdio::piped()) Err(e) if e.is_empty() => return Err("這一步沒有成功".into()),
.spawn() Err(e) => return Err(format!("xdotool:{e}")),
.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));
}; };
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() { if !picture || out.is_empty() {
return Ok(None); return Ok(None);
} }

View File

@ -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 { 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 // By number from the app's list (e7), else where on the screen (it said "(0, 0)" for every
// click by number). // click by number).
let xy = || match v["ref"].as_str().map(str::trim).filter(|r| !r.is_empty()) { 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<Value> {
specs_for(&[], "")
}
/// The screen tools, each with a `computer` choice. One way to act (a real click or keystroke), /// 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. /// two ways to see: the picture, or an app's controls as numbered text.
pub fn specs_for(computers: &[String], default: &str) -> Vec<Value> { pub fn specs_for(computers: &[String], default: &str) -> Vec<Value> {
@ -445,7 +437,6 @@ fn flags_of(v: &Value) -> u64 {
} }
/// Run a computer_use tool. Returns (text, ok, screenshot as base64 JPEG). /// 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<String>) { pub fn run(name: &str, args: &str) -> (String, bool, Option<String>) {
run_by(name, args, "") run_by(name, args, "")
} }
@ -455,7 +446,7 @@ pub fn run_by(name: &str, args: &str, who: &str) -> (String, bool, Option<String
if moves_things(name) { if moves_things(name) {
*LAST_ACTION.lock().unwrap_or_else(|e| e.into_inner()) = Some((std::time::Instant::now(), who.to_string())); *LAST_ACTION.lock().unwrap_or_else(|e| e.into_inner()) = Some((std::time::Instant::now(), who.to_string()));
} }
match run_inner(name, &args_of(args)) { match run_inner(name, &crate::tools::args_of(args)) {
Ok((t, img)) => (t, true, img), Ok((t, img)) => (t, true, img),
Err(e) => (e, false, None), 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 /// 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},並在其中點擊和輸入」). /// frontmost app. Used for the per-app permission (「AnyBot 可以看到 {app},並在其中點擊和輸入」).
pub fn target_app(name: &str, args: &str) -> Option<String> { pub fn target_app(name: &str, args: &str) -> Option<String> {
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")) { 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()); return Some(app.trim().to_string());
} }

View File

@ -9,6 +9,25 @@ use std::net::{TcpListener, TcpStream};
use std::sync::Arc; use std::sync::Arc;
use std::time::Duration; 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). /// Largest request body accepted (a phone photo fits).
pub const MAX_BODY: usize = 30 * 1024 * 1024; pub const MAX_BODY: usize = 30 * 1024 * 1024;

View File

@ -7,7 +7,6 @@ use std::collections::HashMap;
use std::sync::{Mutex, OnceLock}; use std::sync::{Mutex, OnceLock};
#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)] #[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
#[allow(dead_code)]
pub enum Icon { pub enum Icon {
SidebarCollapse, SidebarCollapse,
SidebarExpand, SidebarExpand,
@ -19,7 +18,6 @@ pub enum Icon {
Stop, Stop,
Monitor, Monitor,
Mic, Mic,
Speaker,
Sparkle, Sparkle,
Chat, Chat,
Shield, Shield,
@ -29,8 +27,6 @@ pub enum Icon {
Person, Person,
Eye, Eye,
CloudDownload, CloudDownload,
PanelRight,
Phone,
Clock, Clock,
Calendar, Calendar,
Wave, Wave,
@ -56,7 +52,6 @@ pub enum Icon {
NewChat, NewChat,
History, History,
Eraser, Eraser,
Info,
Bot, Bot,
Users, Users,
Template, Template,
@ -70,10 +65,6 @@ pub enum Icon {
File, File,
Lock, Lock,
Update, Update,
Keyboard,
Globe,
ChevronRight,
ChevronDown,
Zap, Zap,
Alert, Alert,
Summary, Summary,
@ -81,10 +72,8 @@ pub enum Icon {
MapPin, MapPin,
CloudSun, CloudSun,
ShoppingBag, ShoppingBag,
ExternalLink,
Message, Message,
Alarm, Alarm,
Mail,
CircleCheck, CircleCheck,
CircleX, CircleX,
Minus, Minus,
@ -93,6 +82,7 @@ pub enum Icon {
Pause, Pause,
FileJson, FileJson,
FileWord, FileWord,
ChevronRight,
} }
impl Icon { impl Icon {
@ -115,6 +105,7 @@ impl Icon {
/// The Lucide file. /// The Lucide file.
fn svg(self) -> &'static str { fn svg(self) -> &'static str {
match self { match self {
Icon::ChevronRight => include_str!("../icons/lucide/chevron-right.svg"),
Icon::SidebarCollapse => include_str!("../icons/lucide/panel-left-close.svg"), Icon::SidebarCollapse => include_str!("../icons/lucide/panel-left-close.svg"),
Icon::SidebarExpand => include_str!("../icons/lucide/panel-left-open.svg"), Icon::SidebarExpand => include_str!("../icons/lucide/panel-left-open.svg"),
Icon::Plus => include_str!("../icons/lucide/plus.svg"), Icon::Plus => include_str!("../icons/lucide/plus.svg"),
@ -125,7 +116,6 @@ impl Icon {
Icon::Stop => include_str!("../icons/lucide/square.svg"), Icon::Stop => include_str!("../icons/lucide/square.svg"),
Icon::Monitor => include_str!("../icons/lucide/monitor.svg"), Icon::Monitor => include_str!("../icons/lucide/monitor.svg"),
Icon::Mic => include_str!("../icons/lucide/mic.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::Sparkle => include_str!("../icons/lucide/sparkles.svg"),
Icon::Chat => include_str!("../icons/lucide/messages-square.svg"), Icon::Chat => include_str!("../icons/lucide/messages-square.svg"),
Icon::Shield => include_str!("../icons/lucide/shield-check.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::Person => include_str!("../icons/lucide/user-round.svg"),
Icon::Eye => include_str!("../icons/lucide/eye.svg"), Icon::Eye => include_str!("../icons/lucide/eye.svg"),
Icon::CloudDownload => include_str!("../icons/lucide/cloud-download.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::Clock => include_str!("../icons/lucide/clock.svg"),
Icon::Calendar => include_str!("../icons/lucide/calendar.svg"), Icon::Calendar => include_str!("../icons/lucide/calendar.svg"),
Icon::Wave => include_str!("../icons/lucide/audio-lines.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::NewChat => include_str!("../icons/lucide/square-pen.svg"),
Icon::History => include_str!("../icons/lucide/history.svg"), Icon::History => include_str!("../icons/lucide/history.svg"),
Icon::Eraser => include_str!("../icons/lucide/eraser.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::Bot => include_str!("../icons/lucide/bot.svg"),
Icon::Users => include_str!("../icons/lucide/users.svg"), Icon::Users => include_str!("../icons/lucide/users.svg"),
Icon::Template => include_str!("../icons/lucide/layout-template.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::File => include_str!("../icons/lucide/file.svg"),
Icon::Lock => include_str!("../icons/lucide/lock.svg"), Icon::Lock => include_str!("../icons/lucide/lock.svg"),
Icon::Update => include_str!("../icons/lucide/circle-arrow-up.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::Zap => include_str!("../icons/lucide/zap.svg"),
Icon::Alert => include_str!("../icons/lucide/circle-alert.svg"), Icon::Alert => include_str!("../icons/lucide/circle-alert.svg"),
Icon::Summary => include_str!("../icons/lucide/notebook-pen.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::MapPin => include_str!("../icons/lucide/map-pin.svg"),
Icon::CloudSun => include_str!("../icons/lucide/cloud-sun.svg"), Icon::CloudSun => include_str!("../icons/lucide/cloud-sun.svg"),
Icon::ShoppingBag => include_str!("../icons/lucide/shopping-bag.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::Message => include_str!("../icons/lucide/message-circle.svg"),
Icon::Alarm => include_str!("../icons/lucide/alarm-clock.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::CircleCheck => include_str!("../icons/lucide/circle-check.svg"),
Icon::CircleX => include_str!("../icons/lucide/circle-x.svg"), Icon::CircleX => include_str!("../icons/lucide/circle-x.svg"),
Icon::Minus => include_str!("../icons/lucide/minus.svg"), Icon::Minus => include_str!("../icons/lucide/minus.svg"),

View File

@ -35,9 +35,7 @@ pub fn twilio_auth(token: &str) -> Result<(String, String), String> {
} }
fn form(pairs: &[(&str, &str)]) -> String { fn form(pairs: &[(&str, &str)]) -> String {
fn enc(s: &str) -> String { let enc = crate::httpd::url_encode;
s.bytes().map(|b| if b.is_ascii_alphanumeric() || b"-_.~".contains(&b) { (b as char).to_string() } else { format!("%{b:02X}") }).collect()
}
pairs.iter().map(|(k, v)| format!("{}={}", enc(k), enc(v))).collect::<Vec<_>>().join("&") pairs.iter().map(|(k, v)| format!("{}={}", enc(k), enc(v))).collect::<Vec<_>>().join("&")
} }

View File

@ -813,7 +813,6 @@ pub fn set_lan(on: bool) -> Result<u16, String> {
/// The phone page on the local network watching the bots' computer: /api/vnc, signed in with /// 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. /// 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) { fn lan_vnc(req: Req, mut c: std::net::TcpStream) {
use std::io::Write;
use tungstenite::{protocol::Role, Message, WebSocket}; use tungstenite::{protocol::Role, Message, WebSocket};
if req.path != "/api/vnc" { if req.path != "/api/vnc" {
return httpd::write_response(&mut c, &Resp::error(404, "沒有這個網址")); 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() { if devices_ok(&req.bearer()).is_none() {
return httpd::write_response(&mut c, &Resp::error(401, "請先登入")); return httpd::write_response(&mut c, &Resp::error(401, "請先登入"));
} }
let key = req.header("sec-websocket-key").to_string(); if !httpd::accept_websocket(&req, &mut c) {
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; return;
} }
let _ = c.set_nodelay(true);
let _ = c.set_read_timeout(Some(Duration::from_millis(2))); let _ = c.set_read_timeout(Some(Duration::from_millis(2)));
let mut ws = WebSocket::from_raw_socket(c, Role::Server, None); let mut ws = WebSocket::from_raw_socket(c, Role::Server, None);
let (tx, rx) = std::sync::mpsc::channel::<Vec<u8>>(); let (tx, rx) = std::sync::mpsc::channel::<Vec<u8>>();
@ -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) { 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));
} }
let q: Vec<String> = query.iter().map(|(k, v)| format!("{k}={}", url_escape(v))).collect(); let q: Vec<String> = 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 url = brain_url(s, &format!("{rest}{}{}", if q.is_empty() { "" } else { "?" }, q.join("&")))?;
let auth = format!("Bearer {}", s.host_token); 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) }; 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<Vec<u8>, String> {
Ok(bytes) 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). /// A notification on the person's phones (through the relay; phones that turned them on).
pub fn notify(title: &str, body: &str, bot: u64) { pub fn notify(title: &str, body: &str, bot: u64) {
let s = hub().settings(); let s = hub().settings();

View File

@ -24,16 +24,12 @@ fn agent() -> ureq::Agent {
} }
fn form(url: &str, fields: &[(&str, &str)]) -> Result<Value, String> { fn form(url: &str, fields: &[(&str, &str)]) -> Result<Value, String> {
let body: String = fields.iter().map(|(k, v)| format!("{}={}", k, urlencode(v))).collect::<Vec<_>>().join("&"); let body: String = fields.iter().map(|(k, v)| format!("{}={}", k, crate::httpd::url_encode(v))).collect::<Vec<_>>().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 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(); let text = resp.body_mut().read_to_string().unwrap_or_default();
serde_json::from_str(&text).map_err(|_| text.chars().take(200).collect()) 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). /// 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> { 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)])?; 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"); let (cur, items) = new_messages(&v, "2026-11-04T01:00:01Z", "ME");
assert_eq!(cur, "2026-11-04T01:00:03Z"); assert_eq!(cur, "2026-11-04T01:00:03Z");
assert_eq!(items, vec![("小美".to_string(), "週報 好了嗎?".to_string(), "2".to_string())]); 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");
} }
} }

View File

@ -6,8 +6,6 @@ use eframe::egui::Color32;
#[derive(Clone, Copy)] #[derive(Clone, Copy)]
pub struct Palette { pub struct Palette {
pub pane: Color32, pub pane: Color32,
#[allow(dead_code)]
pub chrome: Color32,
pub sidebar: Color32, pub sidebar: Color32,
pub elevated: Color32, pub elevated: Color32,
pub hover: Color32, pub hover: Color32,
@ -18,8 +16,6 @@ pub struct Palette {
pub text3: Color32, pub text3: Color32,
pub on_primary: Color32, pub on_primary: Color32,
pub bubble_user: Color32, pub bubble_user: Color32,
#[allow(dead_code)]
pub bubble_agent: Color32,
pub border: Color32, pub border: Color32,
pub focus: Color32, pub focus: Color32,
pub accent: 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 { pub const LIGHT: Palette = Palette {
pane: hexa(0xff, 0xff, 0xff, 0xff), pane: hexa(0xff, 0xff, 0xff, 0xff),
chrome: hexa(0xfc, 0xfc, 0xfc, 0xff),
sidebar: hexa(0xf5, 0xf5, 0xf5, 0xff), sidebar: hexa(0xf5, 0xf5, 0xf5, 0xff),
elevated: hexa(0xff, 0xff, 0xff, 0xff), elevated: hexa(0xff, 0xff, 0xff, 0xff),
hover: hexa(0x1c, 0x1c, 0x1c, 0x0f), hover: hexa(0x1c, 0x1c, 0x1c, 0x0f),
@ -43,7 +38,6 @@ pub const LIGHT: Palette = Palette {
text3: hexa(0x1c, 0x1c, 0x1c, 0x8f), text3: hexa(0x1c, 0x1c, 0x1c, 0x8f),
on_primary: hexa(0xff, 0xff, 0xff, 0xff), on_primary: hexa(0xff, 0xff, 0xff, 0xff),
bubble_user: hexa(0x1c, 0x1c, 0x1c, 0xff), bubble_user: hexa(0x1c, 0x1c, 0x1c, 0xff),
bubble_agent: hexa(0x1c, 0x1c, 0x1c, 0x0f),
border: hexa(0x1c, 0x1c, 0x1c, 0x1f), border: hexa(0x1c, 0x1c, 0x1c, 0x1f),
focus: hexa(0x1e, 0x86, 0xfc, 0xff), focus: hexa(0x1e, 0x86, 0xfc, 0xff),
accent: hexa(0x16, 0x80, 0xf3, 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. // Measured from the original's dark window: chat rgb 19, sidebar 16, bubbles/cards 31–33.
pub const DARK: Palette = Palette { pub const DARK: Palette = Palette {
pane: hexa(0x13, 0x13, 0x13, 0xff), pane: hexa(0x13, 0x13, 0x13, 0xff),
chrome: hexa(0x13, 0x13, 0x13, 0xff),
sidebar: hexa(0x10, 0x10, 0x10, 0xff), sidebar: hexa(0x10, 0x10, 0x10, 0xff),
elevated: hexa(0x1f, 0x1f, 0x1f, 0xff), elevated: hexa(0x1f, 0x1f, 0x1f, 0xff),
hover: hexa(0xf5, 0xf5, 0xf5, 0x0f), hover: hexa(0xf5, 0xf5, 0xf5, 0x0f),
@ -68,7 +61,6 @@ pub const DARK: Palette = Palette {
text3: hexa(0xf5, 0xf5, 0xf5, 0x8a), text3: hexa(0xf5, 0xf5, 0xf5, 0x8a),
on_primary: hexa(0x1f, 0x1f, 0x1f, 0xff), on_primary: hexa(0x1f, 0x1f, 0x1f, 0xff),
bubble_user: hexa(0xf5, 0xf5, 0xf5, 0x2e), bubble_user: hexa(0xf5, 0xf5, 0xf5, 0x2e),
bubble_agent: hexa(0xf5, 0xf5, 0xf5, 0x0a),
border: hexa(0xf5, 0xf5, 0xf5, 0x1a), border: hexa(0xf5, 0xf5, 0xf5, 0x1a),
focus: hexa(0x18, 0x84, 0xfc, 0xff), focus: hexa(0x18, 0x84, 0xfc, 0xff),
accent: hexa(0x18, 0x84, 0xfc, 0xff), accent: hexa(0x18, 0x84, 0xfc, 0xff),

View File

@ -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 { 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 { fn arg_str(v: &Value, k: &str) -> String {

View File

@ -228,15 +228,8 @@ pub fn run(url: &str, token: &str, alive: &dyn Fn() -> bool, status: &dyn Fn(&st
true 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<Stream>, serial: u64, alive: &dyn Fn() -> bool) { fn keep(mut ws: WebSocket<Stream>, serial: u64, alive: &dyn Fn() -> bool) {
let _ = ws.get_ref().tcp().set_read_timeout(Some(READ_WAIT_QUIET)); let mut pace = wire::Pace::new();
let (mut moved, mut busy) = (Instant::now() - Duration::from_secs(60), false);
let (tx, rx) = channel::<Vec<u8>>(); let (tx, rx) = channel::<Vec<u8>>();
let calls: Calls = Arc::default(); let calls: Calls = Arc::default();
*link().lock().unwrap() = Some(Link { tx: tx.clone(), calls: calls.clone(), serial }); *link().lock().unwrap() = Some(Link { tx: tx.clone(), calls: calls.clone(), serial });
@ -244,15 +237,11 @@ fn keep(mut ws: WebSocket<Stream>, serial: u64, alive: &dyn Fn() -> bool) {
let _ = tx.send(wire::pack(&json!({"t": "hello", "brain": s.brain_id, "hands": s.device_link}), &[])); 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()); let (mut heard, mut pinged) = (Instant::now(), Instant::now());
'live: while alive() { 'live: while alive() {
let now_busy = moved.elapsed() < Duration::from_secs(2); pace.tune(ws.get_ref().tcp());
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() { match ws.read() {
Ok(Message::Binary(b)) => { Ok(Message::Binary(b)) => {
heard = Instant::now(); heard = Instant::now();
moved = heard; pace.moved();
if let Some((head, body)) = wire::unpack(&b) { if let Some((head, body)) = wire::unpack(&b) {
// The relay says this machine was unregistered (signed out): stop for good. // The relay says this machine was unregistered (signed out): stop for good.
if head["t"] == "bye" { if head["t"] == "bye" {
@ -267,7 +256,7 @@ fn keep(mut ws: WebSocket<Stream>, serial: u64, alive: &dyn Fn() -> bool) {
Err(_) => break, Err(_) => break,
} }
while let Ok(f) = rx.try_recv() { while let Ok(f) = rx.try_recv() {
moved = Instant::now(); pace.moved();
if ws.write(Message::Binary(f.into())).is_err() { if ws.write(Message::Binary(f.into())).is_err() {
break 'live; break 'live;
} }

View File

@ -203,29 +203,6 @@ pub fn find(host: &str) -> Option<Login> {
None 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 /// 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 /// 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()); 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). /// The keychain has this login (no password is read, so no prompt).
fn keychain_has(site: &str, account: &str) -> bool { fn keychain_has(site: &str, account: &str) -> bool {
@ -305,19 +273,6 @@ pub fn saved_sites() -> Vec<(String, String)> {
kept 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). /// 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 { 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. /// The current tab's host in AnyBot's browser.
fn current_host() -> Result<String, String> { fn current_host() -> Result<String, String> {
@ -393,7 +345,7 @@ impl LoginPlan {
} }
pub fn plan_login(args: &str) -> Result<LoginPlan, String> { pub fn plan_login(args: &str) -> Result<LoginPlan, String> {
let v = args_of(args); let v = crate::tools::args_of(args);
let tab = current_host()?; 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); 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()) { 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. /// For the approval question: (current host, card label) without secrets.
pub fn plan_card(args: &str) -> Result<(String, crate::vault_store::Item), String> { 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)")?; 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)) Ok((current_host()?, card))
} }
pub fn fill_card(args: &str) -> Result<String, String> { pub fn fill_card(args: &str) -> Result<String, 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 c = crate::vault_store::card_secret(&id).ok_or("保險庫裡沒有這張卡片")?; let c = crate::vault_store::card_secret(&id).ok_or("保險庫裡沒有這張卡片")?;
let values = serde_json::json!({ let values = serde_json::json!({
"cc-number": c.number, "cc-number": c.number,
@ -562,7 +514,7 @@ pub fn fill_card(args: &str) -> Result<String, String> {
} }
pub fn plan_contact(args: &str) -> Result<(String, crate::vault_store::Item), String> { 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)")?; 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)) Ok((current_host()?, c))
} }
@ -615,21 +567,6 @@ mod tests {
assert!(js.contains("input[type=password]") && js.contains("if (true && (pw || user))")); 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 /// 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 /// profile), items in a scratch vault, and the fill tools. Run with ANYBOT_DATA_DIR set to a

View File

@ -790,8 +790,6 @@ pub enum RowStatus {
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
pub struct ImportRow { pub struct ImportRow {
#[allow(dead_code)]
pub line: usize,
pub kind: Kind, pub kind: Kind,
pub label: String, pub label: String,
pub secondary: String, pub secondary: String,
@ -864,7 +862,7 @@ pub fn plan_import(text: &str) -> Result<ImportPlan, String> {
if format == CsvFormat::LastPass && url.trim() == "http://sn" { if format == CsvFormat::LastPass && url.trim() == "http://sn" {
let extra = get(r, c_notes); let extra = get(r, c_notes);
if !extra.starts_with("NoteType:Credit Card") { 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; continue;
} }
let f = lastpass_fields(&extra); let f = lastpass_fields(&extra);
@ -899,7 +897,6 @@ pub fn plan_import(text: &str) -> Result<ImportPlan, String> {
}; };
let expiry = exp.map(|(m, y)| format!("{m:02}/{}", y % 100)).unwrap_or_default(); let expiry = exp.map(|(m, y)| format!("{m:02}/{}", y % 100)).unwrap_or_default();
out.push(ImportRow { out.push(ImportRow {
line,
kind: Kind::Card, kind: Kind::Card,
label: if title.is_empty() { card_brand(&number).to_string() } else { title.clone() }, label: if title.is_empty() { card_brand(&number).to_string() } else { title.clone() },
secondary: format!("•••• {last4}"), secondary: format!("•••• {last4}"),
@ -940,7 +937,7 @@ pub fn plan_import(text: &str) -> Result<ImportPlan, String> {
RowStatus::Ready 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 }) Ok(ImportPlan { format, rows: out })
} }

View File

@ -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<u8> {
multipart_lang(model, file_name, audio, boundary, "")
}
/// Same, telling the service the spoken language (ISO 639-1, e.g. "zh"; empty = detect). /// 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<u8> { pub fn multipart_lang(model: &str, file_name: &str, audio: &[u8], boundary: &str, lang: &str) -> Vec<u8> {
@ -219,11 +214,6 @@ pub fn multipart_lang(model: &str, file_name: &str, audio: &[u8], boundary: &str
b b
} }
/// Audio → text through `{base}/audio/transcriptions`.
#[allow(dead_code)]
pub fn transcribe(base_url: &str, api_key: &str, file: &std::path::Path) -> Result<String, String> {
transcribe_lang(base_url, api_key, file, "")
}
/// The spoken languages offered for voice chat (the original: 語言 Auto-detect…): (code, name). /// 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")]; 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" }) 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). /// Words per minute of the system voices at 1x (`say`'s usual rate).
pub const BASE_RATE: f32 = 175.0; 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())); let f = std::env::temp_dir().join(format!("anybot-voice-test-{}.m4a", std::process::id()));
std::fs::write(&f, vec![7u8; 2048]).unwrap(); 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 req = seen.join().unwrap();
let _ = std::fs::remove_file(&f); let _ = std::fs::remove_file(&f);
assert_eq!(text, "你好,幫我排行程"); assert_eq!(text, "你好,幫我排行程");
assert!(req.starts_with("POST /v1/audio/transcriptions")); 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!(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"));
} }
} }

View File

@ -28,6 +28,32 @@ pub fn unpack(b: &[u8]) -> Option<(Value, Vec<u8>)> {
Some((head, b[4 + n..].to_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<bool>,
}
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)] #[cfg(test)]
mod tests { mod tests {
#[test] #[test]