Merge branch 'smooth-remote': people watch and help on the bots' computer through VNC (noVNC) over the account server; unused and duplicated code removed

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
王性驊 2026-11-05 23:27:11 +08:00
commit 212d18b542
39 changed files with 930 additions and 996 deletions

10
Cargo.lock generated
View File

@ -345,6 +345,7 @@ dependencies = [
"chrono-tz", "chrono-tz",
"cpal", "cpal",
"crossterm", "crossterm",
"des",
"dirs", "dirs",
"eframe", "eframe",
"flate2", "flate2",
@ -1356,6 +1357,15 @@ dependencies = [
"syn 2.0.119", "syn 2.0.119",
] ]
[[package]]
name = "des"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ffdd80ce8ce993de27e9f063a444a4d53ce8e8db4c1f00cc03af5ad5a9867a1e"
dependencies = [
"cipher",
]
[[package]] [[package]]
name = "digest" name = "digest"
version = "0.10.7" version = "0.10.7"

View File

@ -25,6 +25,8 @@ serde_json = "1"
dirs = "7" dirs = "7"
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "gif", "webp", "bmp", "tiff", "ico"] } image = { version = "0.25", default-features = false, features = ["png", "jpeg", "gif", "webp", "bmp", "tiff", "ico"] }
base64 = "0.22" base64 = "0.22"
# VNC sign-in (the 16-byte challenge) for people watching the bots' computer.
des = "0.8"
chrono = "0.4" chrono = "0.4"
chrono-tz = "0.10" chrono-tz = "0.10"
iana-time-zone = "0.1" iana-time-zone = "0.1"

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

@ -272,7 +272,7 @@ a.plink{color:var(--accent);text-decoration:underline;text-underline-offset:3px;
<section class="view" id="v-screen"> <section class="view" id="v-screen">
<header> <header>
<button class="icon-btn" id="b-back2"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M15 5l-7 7 7 7"/></svg></button> <button class="icon-btn" id="b-back2"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M15 5l-7 7 7 7"/></svg></button>
<div class="seg" id="seg"><button data-w="box" class="on">Bot 的電腦</button><button data-w="mac">這台 Mac</button></div> <div class="title">這台 Mac</div>
<span style="flex:1"></span> <span style="flex:1"></span>
<span class="sub" id="screen-live"></span> <span class="sub" id="screen-live"></span>
</header> </header>
@ -287,7 +287,6 @@ a.plink{color:var(--accent);text-decoration:underline;text-underline-offset:3px;
<button class="btn" data-scroll="up">↑ 捲動</button> <button class="btn" data-scroll="up">↑ 捲動</button>
<button class="btn" data-scroll="down">↓ 捲動</button> <button class="btn" data-scroll="down">↓ 捲動</button>
<button class="btn" id="k-right">右鍵:關</button> <button class="btn" id="k-right">右鍵:關</button>
<button class="btn" id="k-wake">叫醒</button>
</div> </div>
</section> </section>
</div> </div>
@ -295,6 +294,7 @@ a.plink{color:var(--accent);text-decoration:underline;text-underline-offset:3px;
<div class="sheet-bg" id="sheet"><div class="sheet"> <div class="sheet-bg" id="sheet"><div class="sheet">
<button id="m-notify">開啟通知</button> <button id="m-notify">開啟通知</button>
<div class="note" id="m-note">Bot 需要你允許、或在你不在時做完事,手機會跳通知。</div> <div class="note" id="m-note">Bot 需要你允許、或在你不在時做完事,手機會跳通知。</div>
<button id="m-mac" style="display:none">看這台 Mac 的螢幕</button>
<button id="m-pair" style="display:none">加一台電腦(Linux、伺服器)</button> <button id="m-pair" style="display:none">加一台電腦(Linux、伺服器)</button>
<button id="m-host" style="display:none">切換到別台 AnyBot</button> <button id="m-host" style="display:none">切換到別台 AnyBot</button>
<button class="no" id="m-out">登出這支手機</button> <button class="no" id="m-out">登出這支手機</button>
@ -692,7 +692,7 @@ function renderAsk() {
// 需要處理 (a step only you can do on a computer): its screen is one tap away, then 我完成了 / 跳過. // 需要處理 (a step only you can do on a computer): its screen is one tap away, then 我完成了 / 跳過.
if (a.kind === "handoff") { if (a.kind === "handoff") {
$("ask").innerHTML = `<div class="ask"><div class="q">需要處理 · ${esc(a.computer || "")}</div><div class="why">${esc(a.reason)}</div><div class="btns"><button class="btn" data-screen="1">接管</button><button class="btn main" data-a="once">我完成了</button><button class="btn no" data-a="reject" title="取消此要求且不執行該步驟;Bot 會跳過它並繼續">跳過</button></div></div>`; $("ask").innerHTML = `<div class="ask"><div class="q">需要處理 · ${esc(a.computer || "")}</div><div class="why">${esc(a.reason)}</div><div class="btns"><button class="btn" data-screen="1">接管</button><button class="btn main" data-a="once">我完成了</button><button class="btn no" data-a="reject" title="取消此要求且不執行該步驟;Bot 會跳過它並繼續">跳過</button></div></div>`;
$("ask").querySelector("[data-screen]").onclick = () => { S.back = S.view; show("screen"); $("seg").children[0].textContent = ownComputer(); $("seg").children[1].style.display = S.mac ? "" : "none"; startFrames(); }; $("ask").querySelector("[data-screen]").onclick = () => openBox(true);
} else { } else {
$("ask").innerHTML = `<div class="ask"><div class="q">${esc(a.title)}</div><div class="why">${esc(a.reason)}</div><div class="btns"><button class="btn main" data-a="once">允許一次</button>${a.can_always ? `<button class="btn" data-a="always">一律允許</button>` : ""}<button class="btn no" data-a="reject">拒絕</button></div></div>`; $("ask").innerHTML = `<div class="ask"><div class="q">${esc(a.title)}</div><div class="why">${esc(a.reason)}</div><div class="btns"><button class="btn main" data-a="once">允許一次</button>${a.can_always ? `<button class="btn" data-a="always">一律允許</button>` : ""}<button class="btn no" data-a="reject">拒絕</button></div></div>`;
} }
@ -737,27 +737,35 @@ function renderChips() {
// ---------- screens ---------- // ---------- screens ----------
// Each bot's own computer goes by the bot's name (「Master 的電腦」). // Each bot's own computer goes by the bot's name (「Master 的電腦」).
function ownComputer() { const n = botName(S.open); return n ? n + " 的電腦" : "Bot 的電腦"; } function ownComputer() { const n = botName(S.open); return n ? n + " 的電腦" : "Bot 的電腦"; }
$("b-screen").onclick = $("b-screen2").onclick = () => { S.back = S.view; show("screen"); $("seg").children[0].textContent = ownComputer(); $("seg").children[1].style.display = S.mac ? "" : "none"; startFrames(); }; // The bots' computer: live in the viewer page (VNC), where you can also take over.
function openBox(control) {
const at = S.relay ? "host=" + encodeURIComponent(S.host) : "lan=1";
location.href = "screen#" + at + "&name=" + encodeURIComponent(ownComputer()) + (control ? "&control=1" : "");
}
$("b-screen").onclick = $("b-screen2").onclick = () => openBox(false);
$("m-mac").onclick = () => { $("sheet").classList.remove("on"); S.back = S.view; S.screen = "mac"; show("screen"); startFrames(); };
$("b-back2").onclick = () => show(S.back === "chat" ? "chat" : "list"); $("b-back2").onclick = () => show(S.back === "chat" ? "chat" : "list");
document.querySelectorAll("#seg button").forEach((b) => b.onclick = () => { document.querySelectorAll("#seg button").forEach((x) => x.classList.toggle("on", x === b)); S.screen = b.dataset.w; $("k-wake").style.display = S.screen === "box" ? "" : "none"; startFrames(); }); // This Mac's screen: a picture at a time.
let busyFrame = false, lastUrl = null; let lastUrl = null;
function startFrames() { stopFrames(); frame(); S.frameTimer = setInterval(frame, 1000); } const nap = (ms) => new Promise((r) => setTimeout(r, ms));
function stopFrames() { clearInterval(S.frameTimer); S.frameTimer = null; } function startFrames() { stopFrames(); const run = S.frameRun = {}; frames(run); }
async function frame() { function stopFrames() { S.frameRun = null; }
if (busyFrame || document.hidden) return; busyFrame = true; async function frames(run) {
while (S.frameRun === run) {
if (document.hidden) { await nap(800); continue; }
try { try {
const r = await api(base() + "api/screen/" + S.screen, {raw: true}); const r = await api(base() + "api/screen/mac", {raw: true});
if (!r.ok) { const v = await r.json().catch(() => ({})); $("stage").innerHTML = `<div class="msg">${esc(v.error || "看不到畫面")}</div>`; $("screen-live").textContent = ""; } if (S.frameRun !== run) return;
else { if (!r.ok) { const v = await r.json().catch(() => ({})); $("stage").innerHTML = `<div class="msg">${esc(v.error || "看不到畫面")}</div>`; $("screen-live").textContent = ""; await nap(1500); continue; }
const sz = (r.headers.get("X-Size") || "0x0").split("x").map(Number); S.size = sz; S.size = (r.headers.get("X-Size") || "0x0").split("x").map(Number);
const url = URL.createObjectURL(await r.blob()); const url = URL.createObjectURL(await r.blob());
let img = $("stage").querySelector("img"); let img = $("stage").querySelector("img");
if (!img) { $("stage").innerHTML = ""; img = document.createElement("img"); img.draggable = false; $("stage").appendChild(img); bindStage(img); } if (!img) { $("stage").innerHTML = ""; img = document.createElement("img"); img.draggable = false; $("stage").appendChild(img); bindStage(img); }
img.src = url; if (lastUrl) URL.revokeObjectURL(lastUrl); lastUrl = url; img.src = url; if (lastUrl) URL.revokeObjectURL(lastUrl); lastUrl = url;
$("screen-live").innerHTML = `<span class="live"></span>即時`; $("screen-live").innerHTML = `<span class="live"></span>即時`;
await nap(700);
} catch (e) { $("screen-live").textContent = ""; await nap(1500); }
} }
} catch (e) { $("screen-live").textContent = ""; }
busyFrame = false;
} }
function point(img, e) { function point(img, e) {
const r = img.getBoundingClientRect(); const r = img.getBoundingClientRect();
@ -777,7 +785,7 @@ function act(img, e, button) {
input2({kind: "click", x: p.x, y: p.y, button}); input2({kind: "click", x: p.x, y: p.y, button});
} }
async function input2(body) { async function input2(body) {
try { await api(base() + "api/screen/" + S.screen, {json: body}); setTimeout(frame, 250); } catch (e) { toast(e.message); } try { await api(base() + "api/screen/mac", {json: body}); } catch (e) { toast(e.message); }
} }
document.querySelectorAll("[data-key]").forEach((b) => b.onclick = () => input2({kind: "key", key: b.dataset.key})); document.querySelectorAll("[data-key]").forEach((b) => b.onclick = () => input2({kind: "key", key: b.dataset.key}));
document.querySelectorAll("[data-scroll]").forEach((b) => b.onclick = () => input2({kind: "scroll", x: Math.round(S.size[0] / 2), y: Math.round(S.size[1] / 2), direction: b.dataset.scroll, amount: 5})); document.querySelectorAll("[data-scroll]").forEach((b) => b.onclick = () => input2({kind: "scroll", x: Math.round(S.size[0] / 2), y: Math.round(S.size[1] / 2), direction: b.dataset.scroll, amount: 5}));
@ -785,9 +793,8 @@ $("k-type").onclick = () => { $("typebar").classList.toggle("on"); if ($("typeba
$("b-type").onclick = () => { const t = $("typein").value; if (!t) return; $("typein").value = ""; input2({kind: "type", text: t}); }; $("b-type").onclick = () => { const t = $("typein").value; if (!t) return; $("typein").value = ""; input2({kind: "type", text: t}); };
$("typein").addEventListener("keydown", (e) => { if (e.key === "Enter" && !e.isComposing) { e.preventDefault(); $("b-type").click(); } }); $("typein").addEventListener("keydown", (e) => { if (e.key === "Enter" && !e.isComposing) { e.preventDefault(); $("b-type").click(); } });
$("k-right").onclick = () => { S.right = !S.right; $("k-right").textContent = "右鍵:" + (S.right ? "開" : "關"); }; $("k-right").onclick = () => { S.right = !S.right; $("k-right").textContent = "右鍵:" + (S.right ? "開" : "關"); };
$("k-wake").onclick = () => { toast("正在叫醒" + ownComputer() + "…"); input2({kind: "wake"}); };
// ---------- menu, notifications ---------- // ---------- menu, notifications ----------
$("b-more").onclick = () => { $("m-host").style.display = S.relay ? "" : "none"; $("m-pair").style.display = S.relay ? "" : "none"; notifyLabel(); $("sheet").classList.add("on"); }; $("b-more").onclick = () => { $("m-mac").style.display = S.mac ? "" : "none"; $("m-host").style.display = S.relay ? "" : "none"; $("m-pair").style.display = S.relay ? "" : "none"; notifyLabel(); $("sheet").classList.add("on"); };
$("sheet").onclick = (e) => { if (e.target.id === "sheet") $("sheet").classList.remove("on"); }; $("sheet").onclick = (e) => { if (e.target.id === "sheet") $("sheet").classList.remove("on"); };
$("m-cancel").onclick = () => $("sheet").classList.remove("on"); $("m-cancel").onclick = () => $("sheet").classList.remove("on");
$("m-out").onclick = () => { $("sheet").classList.remove("on"); if (confirm("要登出這支手機嗎?")) signOut(); }; $("m-out").onclick = () => { $("sheet").classList.remove("on"); if (confirm("要登出這支手機嗎?")) signOut(); };
@ -861,7 +868,7 @@ if ("serviceWorker" in navigator) navigator.serviceWorker.addEventListener("mess
if (d.host && d.host !== S.host && S.relay) { S.host = d.host; localStorage.setItem("anybot.host", S.host); S.version = 0; } if (d.host && d.host !== S.host && S.relay) { S.host = d.host; localStorage.setItem("anybot.host", S.host); S.version = 0; }
if (d.bot) openChat(+d.bot); if (d.bot) openChat(+d.bot);
}); });
document.addEventListener("visibilitychange", () => { if (!document.hidden && S.view === "screen") frame(); }); document.addEventListener("visibilitychange", () => { if (!document.hidden && S.view === "screen") startFrames(); });
hello(); hello();
</script> </script>
</body> </body>

8
mobile/novnc.js Normal file

File diff suppressed because one or more lines are too long

109
mobile/screen.html Normal file
View File

@ -0,0 +1,109 @@
<!doctype html>
<html lang="zh-Hant">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover, user-scalable=no">
<meta name="theme-color" content="#16181d">
<title>Bot 的電腦</title>
<link rel="icon" href="icon.svg">
<style>
:root { --bg: #16181d; --bar: #1f2228; --line: #2e323a; --text: #eceef2; --dim: #9aa1ad; --accent: #4f8cff; --warn: #e5a13a; --ok: #3fb67a; }
* { box-sizing: border-box; }
html, body { margin: 0; height: 100%; background: var(--bg); color: var(--text); font: 15px/1.4 -apple-system, BlinkMacSystemFont, "PingFang TC", "Noto Sans TC", sans-serif; overflow: hidden; }
body { display: flex; flex-direction: column; height: 100dvh; }
header { display: flex; align-items: center; gap: 8px; padding: 8px 12px; padding-top: max(8px, env(safe-area-inset-top)); background: var(--bar); border-bottom: 1px solid var(--line); }
header .name { font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
header .state { color: var(--dim); font-size: 13px; white-space: nowrap; display: flex; align-items: center; gap: 6px; }
.dot { width: 8px; height: 8px; border-radius: 50%; background: var(--dim); flex: none; }
.dot.live { background: var(--ok); }
.dot.hold { background: var(--warn); }
.grow { flex: 1; min-width: 0; display: flex; flex-direction: column; }
button { font: inherit; color: var(--text); background: #2b2f37; border: 1px solid var(--line); border-radius: 8px; padding: 6px 12px; cursor: pointer; white-space: nowrap; }
button.main { background: var(--accent); border-color: var(--accent); color: #fff; font-weight: 600; }
button.icon { padding: 6px 10px; }
#screen { flex: 1; min-height: 0; position: relative; background: #000; }
#screen > div { background: #000 !important; }
#msg { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; text-align: center; padding: 24px; color: var(--dim); pointer-events: none; }
#type { display: none; gap: 6px; padding: 8px 12px; padding-bottom: max(8px, env(safe-area-inset-bottom)); background: var(--bar); border-top: 1px solid var(--line); flex-wrap: wrap; }
#type.on { display: flex; }
#type input { flex: 1 1 160px; min-width: 0; font: inherit; color: var(--text); background: #111318; border: 1px solid var(--line); border-radius: 8px; padding: 7px 10px; }
@media (max-width: 420px) { header .hint { display: none; } }
</style>
</head>
<body>
<header>
<button class="icon" id="back" title="返回">←</button>
<div class="grow"><div class="name" id="name">Bot 的電腦</div><div class="state"><span class="dot" id="dot"></span><span id="state">連線中…</span></div></div>
<button id="kb" title="打字(任何語言)">打字</button>
<button class="main" id="take">接管</button>
</header>
<div id="screen"><div id="msg">連線中…</div></div>
<div id="type">
<input id="text" placeholder="要打的字(中文也可以),按送出" autocomplete="off" enterkeyhint="send">
<button class="main" id="send">送出</button>
<button data-k="Return">Enter</button><button data-k="Escape">Esc</button><button data-k="Tab">Tab</button><button data-k="BackSpace">⌫</button>
</div>
<script type="module">
import RFB from "./novnc.js";
// #host=…&name=…&ticket=… (the app's link) or #host=… with this site's sign-in (the phone page);
// #lan=1 on an AnyBot's own phone page. &control=1 starts with control (a bot asked for help).
const q = new URLSearchParams(location.hash.slice(1));
const name = q.get("name") || "Bot 的電腦";
const token = localStorage.getItem("anybot.token") || "";
const wsBase = (location.protocol === "https:" ? "wss://" : "ws://") + location.host;
const url = q.get("lan") === "1"
? `${wsBase}/api/vnc?t=${encodeURIComponent(token)}`
: `${wsBase}/relay/vnc/${encodeURIComponent(q.get("host") || "")}?` + (q.get("ticket") ? "ticket=" + encodeURIComponent(q.get("ticket")) : "t=" + encodeURIComponent(token));
const $ = (id) => document.getElementById(id);
document.title = name;
$("name").textContent = name;
let rfb = null, ws = null, control = q.get("control") === "1", tries = 0, connected = false;
const KEYS = { Return: 0xff0d, Escape: 0xff1b, Tab: 0xff09, BackSpace: 0xff08 };
function state(text, kind) { $("state").textContent = text; $("dot").className = "dot" + (kind ? " " + kind : ""); }
function message(text) { $("msg").textContent = text; $("msg").style.display = text ? "" : "none"; }
function showControl() {
$("take").textContent = control ? "交還給 Bot" : "接管";
$("take").className = control ? "" : "main";
$("kb").style.display = control ? "" : "none";
if (!control) $("type").classList.remove("on");
if (rfb) { rfb.viewOnly = !control; if (control) rfb.focus(); }
if (connected) state(control ? "你在操作(Bot 會等你停下)" : "即時觀看", control ? "hold" : "live");
}
function connect() {
message("連線中…"); state("連線中…");
ws = new WebSocket(url);
rfb = new RFB($("screen"), ws, { shared: true });
rfb.scaleViewport = true;
rfb.resizeSession = false;
rfb.qualityLevel = 6;
rfb.compressionLevel = 2;
rfb.showDotCursor = true;
rfb.viewOnly = !control;
rfb.addEventListener("connect", () => { tries = 0; connected = true; message(""); showControl(); });
rfb.addEventListener("disconnect", () => {
rfb = null; connected = false;
tries += 1;
state("連線中斷,正在重新連線…");
message(tries > 3 ? "連不上這台電腦。它的 AnyBot 可能關了,或連結已過期:回到 AnyBot 再打開一次。" : "連線中斷,正在重新連線…");
setTimeout(connect, Math.min(1000 * tries, 8000));
});
}
$("take").onclick = () => { control = !control; showControl(); };
$("kb").onclick = () => { $("type").classList.toggle("on"); if ($("type").classList.contains("on")) $("text").focus(); };
// Typed text goes over the same connection as words (any language), pasted in the bots' computer.
function send() { const t = $("text").value; if (!t || !ws || ws.readyState !== 1) return; ws.send(t); $("text").value = ""; }
$("send").onclick = send;
$("text").addEventListener("keydown", (e) => { if (e.key === "Enter" && !e.isComposing) { e.preventDefault(); send(); } });
document.querySelectorAll("[data-k]").forEach((b) => b.onclick = () => { if (rfb) rfb.sendKey(KEYS[b.dataset.k], b.dataset.k); });
$("back").onclick = () => { if (history.length > 1) history.back(); else window.close(); };
showControl();
connect();
</script>
</body>
</html>

View File

@ -29,6 +29,9 @@ use std::sync::{Arc, Condvar, Mutex};
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
const PAGE: &str = include_str!("../../mobile/index.html"); const PAGE: &str = include_str!("../../mobile/index.html");
/// The bots' computer, watched and used in a browser (noVNC).
const SCREEN_PAGE: &str = include_str!("../../mobile/screen.html");
const NOVNC: &str = include_str!("../../mobile/novnc.js");
const MANIFEST: &str = r##"{"name":"AnyBot","short_name":"AnyBot","start_url":"./","display":"standalone","background_color":"#121212","theme_color":"#121212","icons":[{"src":"icon.svg","sizes":"any","type":"image/svg+xml"},{"src":"icon-192.png","sizes":"192x192","type":"image/png","purpose":"any maskable"},{"src":"icon-512.png","sizes":"512x512","type":"image/png","purpose":"any maskable"}]}"##; const MANIFEST: &str = r##"{"name":"AnyBot","short_name":"AnyBot","start_url":"./","display":"standalone","background_color":"#121212","theme_color":"#121212","icons":[{"src":"icon.svg","sizes":"any","type":"image/svg+xml"},{"src":"icon-192.png","sizes":"192x192","type":"image/png","purpose":"any maskable"},{"src":"icon-512.png","sizes":"512x512","type":"image/png","purpose":"any maskable"}]}"##;
/// AnyBot's icon (the same drawing as the app's), and PNGs for the iPhone home screen and Android. /// AnyBot's icon (the same drawing as the app's), and PNGs for the iPhone home screen and Android.
const ICON: &str = include_str!("../../mobile/icon.svg"); const ICON: &str = include_str!("../../mobile/icon.svg");
@ -200,6 +203,10 @@ struct Relay {
/// The brain each AnyBot shows (when it is a screen of another), and which lend their hands. /// The brain each AnyBot shows (when it is a screen of another), and which lend their hands.
uses: Mutex<HashMap<String, String>>, uses: Mutex<HashMap<String, String>>,
hands: Mutex<std::collections::HashSet<String>>, hands: Mutex<std::collections::HashSet<String>>,
/// People watching a bots' computer: stream id → (where the host's side goes, that host).
streams: Mutex<HashMap<String, (Sender<Vec<u8>>, String)>>,
/// One-time links for a browser to watch a host's computer: ticket → (account, host, made).
tickets: Mutex<HashMap<String, (String, String, Instant)>>,
/// 配對碼: code → (account, the AnyBot it is for, when made). Ten minutes, once. /// 配對碼: code → (account, the AnyBot it is for, when made). Ten minutes, once.
pairs: Mutex<HashMap<String, (String, String, Instant)>>, pairs: Mutex<HashMap<String, (String, String, Instant)>>,
/// When each host last asked (in memory; saved now and then). /// When each host last asked (in memory; saved now and then).
@ -210,6 +217,9 @@ struct Relay {
contact: String, contact: String,
} }
/// How long a link the app opens for a browser works (reconnects included).
const TICKET_LIFE: Duration = Duration::from_secs(600);
fn now() -> i64 { fn now() -> i64 {
chrono::Utc::now().timestamp() chrono::Utc::now().timestamp()
} }
@ -229,6 +239,8 @@ impl Relay {
flights: Mutex::new(HashMap::new()), flights: Mutex::new(HashMap::new()),
uses: Mutex::new(HashMap::new()), uses: Mutex::new(HashMap::new()),
hands: Mutex::new(std::collections::HashSet::new()), hands: Mutex::new(std::collections::HashSet::new()),
streams: Mutex::new(HashMap::new()),
tickets: Mutex::new(HashMap::new()),
pairs: Mutex::new(HashMap::new()), pairs: Mutex::new(HashMap::new()),
seen: Mutex::new(HashMap::new()), seen: Mutex::new(HashMap::new()),
fails: Mutex::new(HashMap::new()), fails: Mutex::new(HashMap::new()),
@ -294,6 +306,88 @@ impl Relay {
e.1 = Instant::now(); e.1 = Instant::now();
} }
/// A person's browser watching (and maybe using) an AnyBot's computer: its VNC stream goes
/// to that AnyBot over its lasting connection, and back. Signed in by the account session
/// (?t=, the phone page) or a ticket the account's app asked for (?ticket=); owners and
/// admins only.
fn vnc_viewer(self: &Arc<Self>, req: Req, mut c: std::net::TcpStream, host: &str) {
use tungstenite::{protocol::Role, Message, WebSocket};
let user = match req.query.get("ticket").filter(|t| !t.is_empty()) {
Some(t) => self.tickets.lock().unwrap().get(t).filter(|(_, h, made)| h == host && made.elapsed() < TICKET_LIFE).map(|(u, _, _)| u.clone()),
None => self.session(&req),
};
let Some(user) = user else { return httpd::write_response(&mut c, &Resp::error(401, "請先登入")) };
let allowed = self.data.lock().unwrap().hosts.iter().any(|x| x.id == host && matches!(x.role_of(&user).as_deref(), Some("owner") | Some("admin")));
if !allowed {
return httpd::write_response(&mut c, &Resp::error(404, "找不到這台 AnyBot"));
}
let Some(to_host) = self.sockets.lock().unwrap().get(host).map(|s| s.tx.clone()) else {
return httpd::write_response(&mut c, &Resp::error(503, "這台 AnyBot 現在沒有連線"));
};
if !httpd::accept_websocket(&req, &mut c) {
return;
}
let mut ws = WebSocket::from_raw_socket(c, Role::Server, None);
let sid = httpd::random_hex(8);
let (tx, rx) = channel::<Vec<u8>>();
self.streams.lock().unwrap().insert(sid.clone(), (tx, host.to_string()));
if to_host.send(wire::pack(&json!({"t": "vopen", "sid": sid}), &[])).is_err() {
self.streams.lock().unwrap().remove(&sid);
return;
}
let (mut heard, mut pinged) = (Instant::now(), Instant::now());
let mut pace = wire::Pace::new();
'live: loop {
pace.tune(ws.get_ref());
let up = match ws.read() {
Ok(Message::Binary(b)) => Some(("vdata", b.to_vec())),
Ok(Message::Text(t)) => Some(("vtext", t.as_bytes().to_vec())),
Ok(Message::Close(_)) => break,
Ok(_) => None,
Err(tungstenite::Error::Io(e)) if matches!(e.kind(), std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut) => None,
Err(_) => break,
};
if let Some((t, b)) = up {
heard = Instant::now();
pace.moved();
if to_host.send(wire::pack(&json!({"t": t, "sid": sid}), &b)).is_err() {
break;
}
}
loop {
match rx.try_recv() {
Ok(b) => {
pace.moved();
if ws.write(Message::Binary(b.into())).is_err() {
break 'live;
}
}
Err(std::sync::mpsc::TryRecvError::Empty) => break,
// The host closed it (or went away).
Err(std::sync::mpsc::TryRecvError::Disconnected) => break 'live,
}
}
if pinged.elapsed().as_secs() >= WS_PING_SECS {
pinged = Instant::now();
if ws.write(Message::Ping(Vec::new().into())).is_err() {
break;
}
}
match ws.flush() {
Err(tungstenite::Error::Io(e)) if matches!(e.kind(), std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut) => {}
Err(_) => break,
Ok(()) => {}
}
if heard.elapsed().as_secs() >= WS_DEAD_SECS {
break;
}
}
let _ = ws.close(None);
let _ = ws.flush();
self.streams.lock().unwrap().remove(&sid);
let _ = to_host.send(wire::pack(&json!({"t": "vclose", "sid": sid}), &[]));
}
/// Hand a request to a host and wait for its answer. /// Hand a request to a host and wait for its answer.
fn forward(&self, host_id: &str, method: &str, path: &str, query: &HashMap<String, String>, body: &[u8], who: (&str, &str)) -> Resp { fn forward(&self, host_id: &str, method: &str, path: &str, query: &HashMap<String, String>, body: &[u8], who: (&str, &str)) -> Resp {
self.forward_raw(host_id, method, path, query, body, false, who, false, "").into_resp() self.forward_raw(host_id, method, path, query, body, false, who, false, "").into_resp()
@ -356,22 +450,18 @@ 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/") {
let host = host.to_string();
return self.vnc_viewer(req, c, &host);
}
if req.path != "/relay/host/ws" { if req.path != "/relay/host/ws" {
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);
@ -379,10 +469,13 @@ 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 = wire::Pace::new();
'live: loop { 'live: loop {
pace.tune(ws.get_ref());
match ws.read() { match ws.read() {
Ok(Message::Binary(b)) => { Ok(Message::Binary(b)) => {
heard = Instant::now(); heard = Instant::now();
pace.moved();
self.on_frame(&h, &b, &tx); self.on_frame(&h, &b, &tx);
} }
Ok(Message::Close(_)) => break, Ok(Message::Close(_)) => break,
@ -391,6 +484,7 @@ impl Relay {
Err(_) => break, Err(_) => break,
} }
while let Ok(f) = rx.try_recv() { while let Ok(f) = rx.try_recv() {
pace.moved();
if ws.write(Message::Binary(f.into())).is_err() { if ws.write(Message::Binary(f.into())).is_err() {
break 'live; break 'live;
} }
@ -414,7 +508,8 @@ impl Relay {
self.seen.lock().unwrap().insert(h.id.clone(), Instant::now()); self.seen.lock().unwrap().insert(h.id.clone(), Instant::now());
} }
} }
// Gone: whoever waits on it hears so now, not after a timeout. // Gone: its viewers' streams end, and whoever waits on it hears so now, not after a timeout.
self.streams.lock().unwrap().retain(|_, (_, host)| *host != h.id);
{ {
let mut socks = self.sockets.lock().unwrap(); let mut socks = self.sockets.lock().unwrap();
if socks.get(&h.id).map(|s| s.serial) == Some(serial) { if socks.get(&h.id).map(|s| s.serial) == Some(serial) {
@ -434,6 +529,20 @@ impl Relay {
let Some((head, body)) = wire::unpack(b) else { return }; let Some((head, body)) = wire::unpack(b) else { return };
let s = |k: &str| head[k].as_str().unwrap_or("").to_string(); let s = |k: &str| head[k].as_str().unwrap_or("").to_string();
match head["t"].as_str().unwrap_or("") { match head["t"].as_str().unwrap_or("") {
// A viewer's stream: only the host it was opened on may write to it.
"vdata" => {
if let Some((v, host)) = self.streams.lock().unwrap().get(&s("sid")) {
if *host == h.id {
let _ = v.send(body);
}
}
}
"vclose" => {
let mut all = self.streams.lock().unwrap();
if all.get(&s("sid")).map(|(_, host)| *host == h.id).unwrap_or(false) {
all.remove(&s("sid"));
}
}
"reply" => { "reply" => {
// Only the host the job went to may answer it. // Only the host the job went to may answer it.
let w = { let w = {
@ -469,7 +578,9 @@ impl Relay {
let (me, tx, user, method, z, from) = (self.clone(), tx.clone(), h.user.clone(), s("method"), head["z"].as_bool().unwrap_or(false), h.id.clone()); let (me, tx, user, method, z, from) = (self.clone(), tx.clone(), h.user.clone(), s("method"), head["z"].as_bool().unwrap_or(false), h.id.clone());
std::thread::spawn(move || { std::thread::spawn(move || {
let a = me.forward_raw(&to, &method, &path, &query, &body, z, (&user, role), true, &from); let a = me.forward_raw(&to, &method, &path, &query, &body, z, (&user, role), true, &from);
let _ = tx.send(wire::pack(&json!({"t": "ret", "id": id, "status": a.status, "ctype": a.ctype, "z": a.z}), &a.body)); // The answer's headers too (a live picture's number, a screen's size).
let headers: Vec<[&str; 2]> = a.headers.iter().map(|(k, v)| [k.as_str(), v.as_str()]).collect();
let _ = tx.send(wire::pack(&json!({"t": "ret", "id": id, "status": a.status, "ctype": a.ctype, "z": a.z, "headers": headers}), &a.body));
}); });
} }
"hello" => { "hello" => {
@ -497,6 +608,25 @@ impl Relay {
("GET", []) | ("GET", ["index.html"]) => Resp::bytes("text/html; charset=utf-8", PAGE.as_bytes().to_vec()) ("GET", []) | ("GET", ["index.html"]) => Resp::bytes("text/html; charset=utf-8", PAGE.as_bytes().to_vec())
.with_header("Content-Security-Policy", "default-src 'self'; img-src 'self' blob: data:; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'; connect-src 'self'") .with_header("Content-Security-Policy", "default-src 'self'; img-src 'self' blob: data:; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'; connect-src 'self'")
.with_header("Strict-Transport-Security", "max-age=31536000"), .with_header("Strict-Transport-Security", "max-age=31536000"),
("GET", ["screen"]) => Resp::bytes("text/html; charset=utf-8", SCREEN_PAGE.as_bytes().to_vec())
.with_header("Content-Security-Policy", "default-src 'self'; img-src 'self' blob: data:; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'; connect-src 'self' wss: ws:")
.with_header("Strict-Transport-Security", "max-age=31536000"),
("GET", ["novnc.js"]) => Resp::bytes("text/javascript; charset=utf-8", NOVNC.as_bytes().to_vec()).with_header("Cache-Control", "public, max-age=86400"),
// A link for this account's browser to watch one of its AnyBots' computer (the app
// opens it; the browser has no session of its own).
("POST", ["relay", "screen-ticket"]) => {
let Some(h) = self.host(&req) else { return Resp::error(401, "這台電腦的金鑰無效") };
let want = req.json()["host"].as_str().unwrap_or("").to_string();
let ok = self.data.lock().unwrap().hosts.iter().any(|x| x.id == want && matches!(x.role_of(&h.user).as_deref(), Some("owner") | Some("admin")));
if !ok {
return Resp::error(404, "找不到這台 AnyBot");
}
let t = httpd::random_hex(24);
let mut all = self.tickets.lock().unwrap();
all.retain(|_, (_, _, made)| made.elapsed() < TICKET_LIFE);
all.insert(t.clone(), (h.user.clone(), want, Instant::now()));
Resp::json(&json!({"ticket": t}))
}
("GET", ["manifest.webmanifest"]) => Resp::bytes("application/manifest+json", MANIFEST.as_bytes().to_vec()), ("GET", ["manifest.webmanifest"]) => Resp::bytes("application/manifest+json", MANIFEST.as_bytes().to_vec()),
("GET", ["icon.svg"]) => Resp::bytes("image/svg+xml", ICON.as_bytes().to_vec()), ("GET", ["icon.svg"]) => Resp::bytes("image/svg+xml", ICON.as_bytes().to_vec()),
("GET", [name]) if ICON_PNGS.iter().any(|(n, _)| n == name) => Resp::bytes("image/png", ICON_PNGS.iter().find(|(n, _)| n == name).map(|(_, b)| b.to_vec()).unwrap_or_default()), ("GET", [name]) if ICON_PNGS.iter().any(|(n, _)| n == name) => Resp::bytes("image/png", ICON_PNGS.iter().find(|(n, _)| n == name).map(|(_, b)| b.to_vec()).unwrap_or_default()),

View File

@ -371,13 +371,6 @@ impl App {
app.state.selected = 2; app.state.selected = 2;
app.show_routines = Some(2); app.show_routines = Some(2);
} }
Ok("viewer") => {
crate::cloudbox::start_background();
let _ = crate::cloudbox::ensure_running();
app.cu.demo_live = true;
app.cu.viewer_open = true;
app.cu.fit = true;
}
Ok("attach") => { Ok("attach") => {
app.state.selected = 1; app.state.selected = 1;
let dir = std::env::temp_dir().join("anybot-shot-attach"); let dir = std::env::temp_dir().join("anybot-shot-attach");
@ -511,7 +504,6 @@ impl App {
// The real box, so the 電腦 card shows its screen. // The real box, so the 電腦 card shows its screen.
crate::cloudbox::start_background(); crate::cloudbox::start_background();
let _ = crate::cloudbox::ensure_running(); let _ = crate::cloudbox::ensure_running();
app.cu.demo_live = true;
app.state.selected = 1; app.state.selected = 1;
app.panel = Some(panel_ui::PanelTab::Activity); app.panel = Some(panel_ui::PanelTab::Activity);
} }
@ -3427,11 +3419,6 @@ impl App {
ctx.send_viewport_cmd(egui::ViewportCommand::InnerSize(vec2(w, h))); ctx.send_viewport_cmd(egui::ViewportCommand::InnerSize(vec2(w, h)));
} }
} }
// Real data: the computer viewer opens a little after start, as a person would open it (a
// screen of a brain has said so by then, so it shows the brain's computer).
if self.frames == 10 && !self.demo && std::env::var("ANYBOT_SHOT_VIEW").as_deref() == Ok("viewer") {
self.open_viewer();
}
// ANYBOT_SHOT_PAGE=id: open that page late (a screen gets its pages from the brain first). // ANYBOT_SHOT_PAGE=id: open that page late (a screen gets its pages from the brain first).
if self.frames == need * 3 / 4 { if self.frames == need * 3 / 4 {
if let Some(id) = std::env::var("ANYBOT_SHOT_PAGE").ok().and_then(|v| v.parse().ok()) { if let Some(id) = std::env::var("ANYBOT_SHOT_PAGE").ok().and_then(|v| v.parse().ok()) {
@ -3767,7 +3754,6 @@ impl eframe::App for App {
self.delete_dialog(ctx); self.delete_dialog(ctx);
self.routines_window(ctx); self.routines_window(ctx);
self.rebuild_dialog(ctx); self.rebuild_dialog(ctx);
self.viewer_window(ctx);
self.local_control_bar(ctx); self.local_control_bar(ctx);
self.teach_bar(ctx); self.teach_bar(ctx);
self.approval_popup(ctx); self.approval_popup(ctx);
@ -4146,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

@ -7,10 +7,6 @@
use super::*; use super::*;
use crate::cloudbox::{self, Rebuild, Run}; use crate::cloudbox::{self, Rebuild, Run};
/// How often the viewer asks the box for a new picture (estimated).
const VIEWER_FRAME_MS: u64 = 450;
/// The brain's computer comes over the account server: a picture about every 0.7 s.
const REMOTE_FRAME_MS: u64 = 700;
/// The floating bar stays this long after the bot's last mouse/keyboard action on this Mac. /// The floating bar stays this long after the bot's last mouse/keyboard action on this Mac.
const LOCAL_BAR_SECS: u64 = 8; const LOCAL_BAR_SECS: u64 = 8;
@ -31,16 +27,8 @@ pub struct ComputerUi {
pub confirm_reset: bool, pub confirm_reset: bool,
/// 一律允許的應用程式 is expanded. /// 一律允許的應用程式 is expanded.
pub apps_open: bool, pub apps_open: bool,
pub viewer_open: bool, /// 接管 during a hand-off: this app told the bots' computer to let the person have it.
/// 取得控制權: the person's clicks and keys go to the box.
pub control: bool, pub control: bool,
pub fit: bool,
texture: Option<egui::TextureHandle>,
frame: Arc<Mutex<Option<(u64, egui::ColorImage)>>>,
shown_seq: u64,
frame_size: [usize; 2],
worker: Option<Arc<AtomicBool>>,
input_tx: Option<Sender<Input>>,
pub confirm: Option<Rebuild>, pub confirm: Option<Rebuild>,
pub note: Arc<Mutex<String>>, pub note: Arc<Mutex<String>>,
disk: Arc<Mutex<Option<String>>>, disk: Arc<Mutex<Option<String>>>,
@ -52,8 +40,6 @@ pub struct ComputerUi {
teach_name: String, teach_name: String,
teach_for: Option<u64>, teach_for: Option<u64>,
teach_error: String, teach_error: String,
/// Screenshot mode: show the real box in the viewer.
pub demo_live: bool,
schedule_edit: String, schedule_edit: String,
pub update_ready: bool, pub update_ready: bool,
update_checked: Option<Instant>, update_checked: Option<Instant>,
@ -63,16 +49,6 @@ pub struct ComputerUi {
thumb_img: Arc<Mutex<Option<egui::ColorImage>>>, thumb_img: Arc<Mutex<Option<egui::ColorImage>>>,
thumb_busy: Arc<AtomicBool>, thumb_busy: Arc<AtomicBool>,
thumb_at: Option<Instant>, thumb_at: Option<Instant>,
/// The viewer shows the brain's computer (this window is a screen of a brain): how to reach it.
remote: Option<crate::remote::Settings>,
}
enum Input {
Click(i64, i64, &'static str, u32),
Move(i64, i64),
Scroll(i64, i64, bool, u32),
Key(String),
Text(String),
} }
fn status_color(r: &Run, p: Palette) -> Color32 { fn status_color(r: &Run, p: Palette) -> Color32 {
@ -104,16 +80,6 @@ fn status_pill(ui: &mut egui::Ui, r: &Run, p: Palette) {
} }
/// A little monitor drawn with lines (no emoji font needed). /// A little monitor drawn with lines (no emoji font needed).
fn monitor_icon(ui: &mut egui::Ui, size: f32, col: Color32) {
let (rect, _) = ui.allocate_exact_size(vec2(size, size), egui::Sense::hover());
let s = size / 20.0;
let screen = egui::Rect::from_min_size(rect.min + vec2(2.0 * s, 3.0 * s), vec2(16.0 * s, 11.0 * s));
ui.painter().rect_stroke(screen, 2.5 * s, Stroke::new(1.5, col), egui::StrokeKind::Inside);
ui.painter().line_segment([rect.min + vec2(10.0 * s, 14.0 * s), rect.min + vec2(10.0 * s, 17.0 * s)], Stroke::new(1.5, col));
ui.painter().line_segment([rect.min + vec2(6.5 * s, 17.5 * s), rect.min + vec2(13.5 * s, 17.5 * s)], Stroke::new(1.5, col));
}
impl App { impl App {
/// Called once at start. /// Called once at start.
pub(super) fn computers_start(&mut self) { pub(super) fn computers_start(&mut self) {
@ -947,443 +913,65 @@ impl App {
self.cu.thumb.as_ref().map(|t| (t.id(), t.size_vec2())) self.cu.thumb.as_ref().map(|t| (t.id(), t.size_vec2()))
} }
/// 看畫面: the bots' computer in the browser (noVNC), live, where the person can also take
/// over. On a screen of a brain it is the brain's computer, through the account server (a
/// link made for this once); here it is this computer's own box.
pub(super) fn open_viewer(&mut self) { pub(super) fn open_viewer(&mut self) {
self.cu.viewer_open = true; self.open_screen(false);
self.cu.fit = true; }
fn open_screen(&mut self, control: bool) {
self.show_settings = false; self.show_settings = false;
// A screen of a brain watches the brain's computer (where its bots really work). let name = self.state.bots.iter().find(|b| b.id == self.state.selected).map(|b| format!("{} 的電腦", b.name)).unwrap_or_else(|| "Bot 的電腦".into());
if self.mir.on { if self.mir.on {
let s = self.state.remote.clone(); let s = self.state.remote.clone();
self.cu.remote = Some(s.clone()); self.box_task(move || {
if let Some(stop) = self.cu.worker.take() { let ticket = crate::remote::screen_ticket(&s)?;
stop.store(true, Ordering::SeqCst); 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);
self.cu.texture = None; Ok(String::new())
std::thread::spawn(move || {
let _ = crate::remote::sync_post(&s, "screen", &serde_json::json!({"kind": "viewer", "on": true}));
let _ = crate::remote::sync_post(&s, "screen", &serde_json::json!({"kind": "wake"}));
}); });
return; return;
} }
if self.cu.remote.take().is_some() { if self.demo {
if let Some(stop) = self.cu.worker.take() {
stop.store(true, Ordering::SeqCst);
}
self.cu.texture = None;
}
cloudbox::set_viewer_open(true);
if !cloudbox::run_state().is_running() && !self.demo {
self.box_task(|| cloudbox::ensure_running().map(|_| String::new()));
}
}
/// 接管: open the computer and take control (the bot waits for the person anyway).
pub(super) fn take_over_computer(&mut self) {
self.open_viewer();
if self.cu.control {
return; return;
} }
self.cu.control = true;
match self.cu.remote.clone() {
Some(s) => {
std::thread::spawn(move || {
let _ = crate::remote::sync_post(&s, "screen", &serde_json::json!({"kind": "hold", "on": true}));
});
}
None => cloudbox::HELD_BY_PERSON.store(true, Ordering::SeqCst),
}
}
/// 我完成了 / 跳過: the bot gets its computer back (its steps would otherwise wait for the
/// person to let go).
pub(super) fn give_back_computer(&mut self) {
if !self.cu.control {
return;
}
self.cu.control = false;
match self.cu.remote.clone() {
Some(s) => {
std::thread::spawn(move || {
let _ = crate::remote::sync_post(&s, "screen", &serde_json::json!({"kind": "hold", "on": false}));
});
}
None => cloudbox::HELD_BY_PERSON.store(false, Ordering::SeqCst),
}
}
fn close_viewer(&mut self) {
self.cu.viewer_open = false;
self.cu.control = false;
if let Some(s) = self.cu.remote.clone() {
std::thread::spawn(move || {
let _ = crate::remote::sync_post(&s, "screen", &serde_json::json!({"kind": "hold", "on": false}));
let _ = crate::remote::sync_post(&s, "screen", &serde_json::json!({"kind": "viewer", "on": false}));
});
} else {
cloudbox::HELD_BY_PERSON.store(false, Ordering::SeqCst);
cloudbox::set_viewer_open(false);
}
if let Some(stop) = self.cu.worker.take() {
stop.store(true, Ordering::SeqCst);
}
self.cu.input_tx = None;
}
fn ensure_viewer_threads(&mut self, ctx: &egui::Context) {
if self.cu.worker.is_some() || (self.demo && !self.cu.demo_live) {
return;
}
let stop = Arc::new(AtomicBool::new(false));
self.cu.worker = Some(stop.clone());
let slot = self.cu.frame.clone();
let c = ctx.clone();
let s2 = stop.clone();
let brain = self.cu.remote.clone();
if let Some(s) = brain.clone() {
// The brain's computer: pictures and the person's input go through the account server.
let s_frames = s.clone();
std::thread::spawn(move || {
let s = s_frames;
let mut seq = 0u64;
while !s2.load(Ordering::SeqCst) {
match crate::remote::sync_screen(&s, false).ok().and_then(|b| image::load_from_memory(&b).ok()) {
Some(img) => {
let img = img.to_rgba8();
seq += 1;
let size = [img.width() as usize, img.height() as usize];
*slot.lock().unwrap() = Some((seq, egui::ColorImage::from_rgba_unmultiplied(size, img.as_raw())));
c.request_repaint();
std::thread::sleep(std::time::Duration::from_millis(REMOTE_FRAME_MS));
}
None => {
std::thread::sleep(std::time::Duration::from_millis(1500));
c.request_repaint();
}
}
}
});
let (tx, rx) = channel::<Input>();
self.cu.input_tx = Some(tx);
std::thread::spawn(move || {
let mut last_move = Instant::now();
while let Ok(i) = rx.recv() {
let v = match i {
Input::Click(x, y, b, n) => serde_json::json!({"kind": "click", "x": x, "y": y, "button": b, "count": n}),
// Hovering sends many moves; a few a second are enough over the network.
Input::Move(x, y) => {
if last_move.elapsed().as_millis() < 250 {
continue;
}
last_move = Instant::now();
serde_json::json!({"kind": "move", "x": x, "y": y})
}
Input::Scroll(x, y, down, n) => serde_json::json!({"kind": "scroll", "x": x, "y": y, "down": down, "steps": n}),
Input::Key(k) => serde_json::json!({"kind": "key", "key": k}),
Input::Text(t) => serde_json::json!({"kind": "text", "text": t}),
};
let _ = crate::remote::sync_post(&s, "screen", &v);
}
});
return;
}
std::thread::spawn(move || {
let mut seq = 0u64;
while !s2.load(Ordering::SeqCst) {
let got = cloudbox::frame();
if let Some(img) = got {
seq += 1;
let size = [img.width() as usize, img.height() as usize];
let ci = egui::ColorImage::from_rgba_unmultiplied(size, img.as_raw());
*slot.lock().unwrap() = Some((seq, ci));
c.request_repaint();
std::thread::sleep(std::time::Duration::from_millis(VIEWER_FRAME_MS));
} else {
std::thread::sleep(std::time::Duration::from_millis(1000));
c.request_repaint();
}
}
});
let (tx, rx) = channel::<Input>();
self.cu.input_tx = Some(tx);
std::thread::spawn(move || {
while let Ok(i) = rx.recv() {
match i {
Input::Click(x, y, b, n) => cloudbox::viewer_click(x, y, b, n),
Input::Move(x, y) => cloudbox::viewer_move(x, y),
Input::Scroll(x, y, down, n) => cloudbox::viewer_scroll(x, y, down, n),
Input::Key(k) => cloudbox::viewer_key(&k),
Input::Text(t) => cloudbox::viewer_text(&t),
}
}
});
}
pub(super) fn viewer_window(&mut self, ctx: &egui::Context) {
if !self.cu.viewer_open {
return;
}
self.ensure_viewer_threads(ctx);
let p = self.pal;
if let Some((seq, img)) = self.cu.frame.lock().unwrap().take() {
if seq != self.cu.shown_seq {
self.cu.shown_seq = seq;
self.cu.frame_size = img.size;
match &mut self.cu.texture {
Some(t) => t.set(img, egui::TextureOptions::LINEAR),
None => self.cu.texture = Some(ctx.load_texture("box-screen", img, egui::TextureOptions::LINEAR)),
}
}
}
let brain = self.cu.remote.is_some();
// The brain's computer reports its state with the brain's answers.
let run = if brain {
match (self.mir.box_state["running"].as_bool().unwrap_or(false), self.mir.box_state["available"].as_bool().unwrap_or(true)) {
(true, _) => Run::Running,
(false, false) => Run::NoDocker("大腦那台沒有 Docker".into()),
_ => Run::Asleep,
}
} else {
cloudbox::run_state()
};
let screen = ctx.content_rect();
let open = true;
let default = vec2((screen.width() * 0.62).min(900.0), (screen.width() * 0.62).min(900.0) * 0.625 + 52.0);
let busy = if brain { self.mir.box_state["busy"].as_bool().unwrap_or(false) } else { cloudbox::is_busy() };
let title = self.state.bot(self.state.selected).map(|b| crate::model::own_computer_name(&b.name)).unwrap_or_else(|| crate::model::BOT_COMPUTER_NAME.to_string());
let mut close_clicked = false;
let mut done_clicked = false;
let selected = self.state.selected;
let handoff = self.approvals.get(&selected).map(|a| a.kind == crate::providers::ApprovalKind::Handoff).unwrap_or(false);
egui::Window::new("box-viewer")
.id(egui::Id::unique("box-viewer"))
.title_bar(false)
.default_size(default)
.default_pos(screen.center() - default / 2.0)
.min_size(vec2(420.0, 300.0))
.resizable(true)
.collapsible(false)
.frame(
Frame::new()
.fill(p.elevated)
.corner_radius(14)
.inner_margin(Margin::symmetric(14, 12))
.stroke(Stroke::new(1.0, p.border))
.shadow(egui::epaint::Shadow { offset: [0, 14], blur: 40, spread: 0, color: Color32::from_black_alpha(60) }),
)
.show(ctx, |ui| {
// Buttons first, from the right; the title and state get what is left and end in
// … when it's short (the state used to slide under 「取得控制權」 in a narrow window).
ui.horizontal(|ui| {
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
let (rect, r) = ui.allocate_exact_size(vec2(28.0, 28.0), egui::Sense::click());
if r.hovered() {
ui.painter().rect_filled(rect, 8, p.hover);
}
let c = rect.center();
let st = Stroke::new(1.5, if r.hovered() { p.text } else { p.text2 });
ui.painter().line_segment([c + vec2(-5.0, -5.0), c + vec2(5.0, 5.0)], st);
ui.painter().line_segment([c + vec2(-5.0, 5.0), c + vec2(5.0, -5.0)], st);
if r.on_hover_text(t("關閉")).clicked() {
close_clicked = true;
}
ui.add_space(4.0);
// The bot is waiting for the person here: done, and it carries on.
if handoff && ui.add(egui::Button::new(RichText::new(t("我完成了")).strong().color(p.on_primary)).fill(p.primary).corner_radius(8).min_size(vec2(84.0, 30.0))).clicked() {
done_clicked = true;
}
if !brain && ui.add(secondary_button("全螢幕")).on_hover_text(t("在瀏覽器用 noVNC 全螢幕開啟")).clicked() {
let control = self.cu.control;
self.box_task(move || { self.box_task(move || {
let url = cloudbox::viewer_url(control)?; let url = cloudbox::viewer_url(control)?;
crate::preview::open_with_system(&url); crate::preview::open_with_system(&url);
Ok(String::new()) Ok(String::new())
}); });
} }
let fit_label = if self.cu.fit { "實際大小" } else { "調整至視窗大小" };
if ui.add(secondary_button(fit_label)).clicked() { /// 接管: open the computer with control (the bot waits for the person meanwhile).
self.cu.fit = !self.cu.fit; pub(super) fn take_over_computer(&mut self) {
self.open_screen(true);
if self.cu.control {
return;
} }
let (label, fill, col) = if self.cu.control { ("交還給 Bot", p.accent, Color32::WHITE) } else { ("取得控制權", p.primary, p.on_primary) }; self.cu.control = true;
let btn = egui::Button::new(RichText::new(label).strong().color(col)).fill(fill).corner_radius(8).min_size(vec2(96.0, 30.0)); self.hold_computer(true);
if ui.add_enabled(run.is_running(), btn).on_hover_text(if self.cu.control { "Bot 可以繼續操作" } else { "你自己點擊和輸入;Bot 的操作會先暫停" }).clicked() { }
self.cu.control = !self.cu.control;
match self.cu.remote.clone() { /// 我完成了 / 跳過: the bot gets its computer back.
Some(s) => { pub(super) fn give_back_computer(&mut self) {
let on = self.cu.control; if !self.cu.control {
return;
}
self.cu.control = false;
self.hold_computer(false);
}
fn hold_computer(&self, on: bool) {
if self.mir.on {
let s = self.state.remote.clone();
std::thread::spawn(move || { std::thread::spawn(move || {
let _ = crate::remote::sync_post(&s, "screen", &serde_json::json!({"kind": "hold", "on": on})); let _ = crate::remote::sync_post(&s, "screen", &serde_json::json!({"kind": "hold", "on": on}));
}); });
}
None => cloudbox::HELD_BY_PERSON.store(self.cu.control, Ordering::SeqCst),
}
}
ui.add_space(8.0);
ui.with_layout(Layout::left_to_right(Align::Center), |ui| {
monitor_icon(ui, 20.0, p.text);
let note = if self.cu.control { Some(t("你正在操作,Bot 已暫停")) } else if busy { Some(t("Bot 正在操作")) } else { None };
let room = (ui.available_width() - 96.0 - if note.is_some() { 120.0 } else { 0.0 }).max(60.0);
ui.label(RichText::new(super::sidebar_ui::shorten_width(ui, &title, room, FONT_LG)).strong().size(FONT_LG));
if ui.available_width() > 80.0 {
status_pill(ui, &run, p);
}
if let Some(n) = note.filter(|_| ui.available_width() > 110.0) {
crate::icons::label(ui, if self.cu.control { crate::icons::Icon::Users } else { crate::icons::Icon::Bot }, &n, FONT_XS, p.accent);
}
});
});
});
ui.add_space(6.0);
let avail = ui.available_size();
match (self.cu.texture.as_ref().map(|t| t.id()), run.is_running()) {
(Some(tex_id), true) => {
let [fw, fh] = self.cu.frame_size;
let (fw, fh) = (fw.max(1) as f32, fh.max(1) as f32);
let size = if self.cu.fit {
let k = (avail.x / fw).min((avail.y.max(120.0)) / fh);
vec2(fw * k, fh * k)
} else { } else {
vec2(fw, fh) cloudbox::HELD_BY_PERSON.store(on, Ordering::SeqCst);
};
egui::ScrollArea::both().auto_shrink([false, false]).show(ui, |ui| {
let sense = if self.cu.control { egui::Sense::click_and_drag() } else { egui::Sense::hover() };
// Centred in the window (it sat at the left with an empty band beside it).
let resp = if self.cu.fit {
let pad = ((ui.available_width() - size.x) / 2.0).max(0.0);
ui.horizontal(|ui| {
ui.add_space(pad);
ui.add(egui::Image::new((tex_id, size)).corner_radius(8).sense(sense))
})
.inner
} else {
ui.add(egui::Image::new((tex_id, size)).corner_radius(8).sense(sense))
};
if self.cu.control {
ui.painter().rect_stroke(resp.rect, 8, Stroke::new(2.0, p.accent), egui::StrokeKind::Outside);
self.forward_input(ui, &resp, (fw, fh));
}
});
}
_ => {
let (rect, _) = ui.allocate_exact_size(vec2(avail.x, avail.y.max(200.0)), egui::Sense::hover());
ui.painter().rect_filled(rect, 8, Color32::from_rgb(0x2b, 0x2f, 0x36));
let text = match &run {
Run::Running => "正在連線…".to_string(),
Run::NoDocker(why) => why.clone(),
Run::Failed(e) => format!("發生問題:{e}"),
other => format!("{}…", other.label()),
};
ui.painter().text(rect.center(), egui::Align2::CENTER_CENTER, text, egui::FontId::proportional(FONT_BASE), Color32::from_gray(220));
if matches!(run, Run::Asleep | Run::Absent) {
let b = egui::Rect::from_center_size(rect.center() + vec2(0.0, 34.0), vec2(90.0, 30.0));
if ui.put(b, primary_button("喚醒", p)).clicked() {
match self.cu.remote.clone() {
Some(s) => {
std::thread::spawn(move || {
let _ = crate::remote::sync_post(&s, "screen", &serde_json::json!({"kind": "wake"}));
});
}
None => self.box_task(|| cloudbox::ensure_running().map(|_| String::new())),
} }
} }
}
}
}
});
if done_clicked {
self.give_back_computer();
self.answer(ctx, selected, crate::providers::ApprovalAnswer::Once);
self.close_viewer();
} else if close_clicked || !open {
self.close_viewer();
}
ctx.request_repaint_after(std::time::Duration::from_millis(VIEWER_FRAME_MS));
}
/// Clicks, scrolls and keys on the picture go to the box while the person has control.
fn forward_input(&mut self, ui: &mut egui::Ui, resp: &egui::Response, frame: (f32, f32)) {
let Some(tx) = self.cu.input_tx.clone() else { return };
let to_box = |pos: egui::Pos2| -> (i64, i64) {
let r = resp.rect;
(((pos.x - r.left()) / r.width() * frame.0).round() as i64, ((pos.y - r.top()) / r.height() * frame.1).round() as i64)
};
if resp.clicked() || resp.secondary_clicked() || resp.double_clicked() {
resp.request_focus();
}
if let Some(pos) = resp.interact_pointer_pos().or(resp.hover_pos()) {
let (x, y) = to_box(pos);
if resp.double_clicked() {
let _ = tx.send(Input::Click(x, y, "left", 2));
} else if resp.clicked() {
let _ = tx.send(Input::Click(x, y, "left", 1));
} else if resp.secondary_clicked() {
let _ = tx.send(Input::Click(x, y, "right", 1));
} else if resp.hovered() {
let d = ui.input(|i| i.smooth_scroll_delta.y);
if d.abs() > 0.5 {
let _ = tx.send(Input::Scroll(x, y, d < 0.0, ((d.abs() / 40.0).ceil() as u32).max(1)));
} else if ui.input(|i| i.pointer.delta().length() > 3.0) {
let _ = tx.send(Input::Move(x, y));
}
}
}
if !resp.has_focus() {
return;
}
ui.memory_mut(|m| m.set_focus_lock_filter(resp.id, egui::EventFilter { tab: true, horizontal_arrows: true, vertical_arrows: true, escape: true }));
let events = ui.input(|i| i.events.clone());
for e in events {
match e {
egui::Event::Text(t) => {
let _ = tx.send(Input::Text(t));
}
egui::Event::Paste(t) => {
let _ = tx.send(Input::Text(t));
}
egui::Event::Key { key, pressed: true, modifiers, .. } => {
let name = match key {
Key::Enter => "Return",
Key::Tab => "Tab",
Key::Backspace => "BackSpace",
Key::Delete => "Delete",
Key::Escape => "Escape",
Key::ArrowUp => "Up",
Key::ArrowDown => "Down",
Key::ArrowLeft => "Left",
Key::ArrowRight => "Right",
Key::Home => "Home",
Key::End => "End",
Key::PageUp => "Prior",
Key::PageDown => "Next",
_ if modifiers.command || modifiers.ctrl || modifiers.alt => {
let n = key.name();
if n.chars().count() == 1 {
n
} else {
continue;
}
}
_ => continue,
};
let mut combo = vec![];
if modifiers.command || modifiers.ctrl {
combo.push("ctrl".to_string());
}
if modifiers.alt {
combo.push("alt".to_string());
}
if modifiers.shift && (name.len() > 1 || !combo.is_empty()) {
combo.push("shift".to_string());
}
combo.push(if name.len() == 1 { name.to_lowercase() } else { name.to_string() });
let _ = tx.send(Input::Key(combo.join("+")));
}
_ => {}
}
}
}
// ---------- the floating bar while a bot drives this Mac ----------
pub(super) fn local_control_bar(&mut self, ctx: &egui::Context) { pub(super) fn local_control_bar(&mut self, ctx: &egui::Context) {
if self.demo { if self.demo {

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

@ -206,6 +206,16 @@ pub fn vnc_password() -> String {
pw pw
} }
/// The password the box's VNC server really has: the one it was made with (its VNC_PASSWORD), so a
/// viewer signs in even when this AnyBot's settings were made anew; else this AnyBot's own.
pub fn box_vnc_password() -> String {
dk_timeout(&["exec", container(), "printenv", "VNC_PASSWORD"], 5_000)
.ok()
.map(|b| String::from_utf8_lossy(&b).trim().to_string())
.filter(|p| !p.is_empty())
.unwrap_or_else(vnc_password)
}
static BUSY: AtomicUsize = AtomicUsize::new(0); static BUSY: AtomicUsize = AtomicUsize::new(0);
/// The person took control in the viewer: the bot's screen actions wait. /// The person took control in the viewer: the bot's screen actions wait.
pub static HELD_BY_PERSON: AtomicBool = AtomicBool::new(false); pub static HELD_BY_PERSON: AtomicBool = AtomicBool::new(false);
@ -417,6 +427,13 @@ fn docker() -> Command {
c c
} }
/// A program in the box with its stdin and stdout piped to this side (as the bot user).
pub fn exec_piped(args: &[&str]) -> Result<std::process::Child, String> {
let mut a = vec!["exec", "-i", "-u", "bot", container()];
a.extend_from_slice(args);
docker().args(&a).stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::null()).spawn().map_err(|e| format!("找不到 docker:{e}"))
}
/// The `docker …` prefix for shell strings (used by the command tools). /// The `docker …` prefix for shell strings (used by the command tools).
fn docker_prefix() -> String { fn docker_prefix() -> String {
let host = docker_host(); let host = docker_host();
@ -436,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 || {
@ -461,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();
@ -1188,13 +1220,14 @@ pub fn thumbnail(width: u32) -> Option<image::RgbaImage> {
image::open(&path).ok().map(|i| i.to_rgba8()) image::open(&path).ok().map(|i| i.to_rgba8())
} }
/// A frame for the viewer (no waking: returns None when the box isn't running). /// The screen now, for a small picture (no waking: None when the box isn't running). People watch
/// it live through VNC (vnc.rs).
pub fn frame() -> Option<image::RgbaImage> { pub fn frame() -> Option<image::RgbaImage> {
if !run_state().is_running() { if !run_state().is_running() {
return None; return None;
} }
let png = dk_timeout(&["exec", container(), "import", "-window", "root", "-display", DISPLAY, "-quality", "1", "png:-"], 5_000).ok()?; let jpeg = dk_timeout(&["exec", container(), "import", "-window", "root", "-display", DISPLAY, "-quality", "80", "jpeg:-"], 5_000).ok()?;
image::load_from_memory(&png).ok().map(|i| i.to_rgba8()) image::load_from_memory(&jpeg).ok().map(|i| i.to_rgba8())
} }
/// The desktop's size in pixels (asked once per box). /// The desktop's size in pixels (asked once per box).
@ -1341,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);
} }
@ -1456,8 +1452,8 @@ pub fn computer_run(name: &str, v: &Value) -> Result<(String, Option<String>), S
} }
_ => {} _ => {}
} }
if HELD_BY_PERSON.load(Ordering::SeqCst) { if HELD_BY_PERSON.load(Ordering::SeqCst) || crate::vnc::person_active() {
return Err("使用者正在自己操作這台電腦(已取得控制權)。請等他交還控制權後再操作,或先截圖看看畫面。".into()); return Err("使用者正在自己操作這台電腦。請等他操作完(停下幾秒後)再繼續,或先截圖看看畫面。".into());
} }
let (note, picture) = if name == "computer_use_open_app" { let (note, picture) = if name == "computer_use_open_app" {
let note = open_on_box(v["app"].as_str().unwrap_or(""))?; let note = open_on_box(v["app"].as_str().unwrap_or(""))?;
@ -1474,40 +1470,10 @@ pub fn computer_run(name: &str, v: &Value) -> Result<(String, Option<String>), S
} }
} }
// ---------- the viewer's own input (when the person takes control) ---------- // ---------- text a person types in a viewer ----------
// Same scripts, at the desktop's own pixels (the viewer shows it full size), no picture back.
fn viewer(name: &str, v: Value) {
if let Ok((script, stdin, _)) = box_script(name, &v, (1.0, 1.0)) {
let _ = box_exec(&script, stdin.as_deref(), false);
}
touch();
}
pub fn viewer_click(x: i64, y: i64, button: &str, count: u32) {
viewer("computer_use_click", serde_json::json!({"x": x, "y": y, "button": button, "count": count.clamp(1, 3)}));
}
pub fn viewer_move(x: i64, y: i64) {
viewer("computer_use_mouse_move", serde_json::json!({"x": x, "y": y}));
}
pub fn viewer_scroll(x: i64, y: i64, down: bool, steps: u32) {
viewer("computer_use_scroll", serde_json::json!({"x": x, "y": y, "direction": if down { "down" } else { "up" }, "amount": steps.clamp(1, 20)}));
}
/// A key from a viewer: an X key name (「BackSpace」「Return」, ctrl+c…) or the bot's names.
pub fn viewer_key(combo: &str) {
let c = combo.trim();
if c.is_empty() || !c.chars().all(|ch| ch.is_ascii_alphanumeric() || "+_-".contains(ch)) {
return;
}
// X key names pass straight through (they are what the viewers send).
let x = combo_to_x(c).unwrap_or_else(|_| c.to_string());
let _ = box_exec(&format!("xdotool key --clearmodifiers {x}"), None, false);
touch();
}
/// Text from a person's viewer (any language): typed or pasted into the window in front, like
/// the bot's typing. (Their mouse and keys go through VNC itself.)
pub fn viewer_text(text: &str) { pub fn viewer_text(text: &str) {
if !text.is_empty() { if !text.is_empty() {
let _ = box_exec(&type_script(text), Some(text), false); let _ = box_exec(&type_script(text), Some(text), false);
@ -1686,8 +1652,7 @@ mod tests {
} }
/// Uses the real Docker: `cargo test --release -- --ignored box_screen_is_exact --nocapture`. /// Uses the real Docker: `cargo test --release -- --ignored box_screen_is_exact --nocapture`.
/// The bot's clicks land where its picture says, a phone's tap where the phone's picture says, /// The bot's clicks land where its picture says, the same spot twice doesn't hang, typing (ASCII, Chinese, a key by name) reaches the app,
/// the same spot twice doesn't hang, typing (ASCII, Chinese, a key by name) reaches the app,
/// and every step comes back with the screen after it. /// and every step comes back with the screen after it.
#[test] #[test]
#[ignore] #[ignore]
@ -1713,9 +1678,6 @@ mod tests {
println!("two clicks with pictures: {} ms", t.elapsed().as_millis()); println!("two clicks with pictures: {} ms", t.elapsed().as_millis());
assert!(t.elapsed() < Duration::from_secs(6)); assert!(t.elapsed() < Duration::from_secs(6));
assert_eq!(pointer(), ((40.0 * w as f64 / fw as f64).round() as i64, (40.0 * h as f64 / fh as f64).round() as i64)); assert_eq!(pointer(), ((40.0 * w as f64 / fw as f64).round() as i64, (40.0 * h as f64 / fh as f64).round() as i64));
// A phone's tap is in the full-size frame it was sent.
viewer_click(321, 123, "left", 1);
assert_eq!(pointer(), (321, 123));
// Typing reaches the app in front: a fresh terminal, ASCII then Chinese, a key by name, return. // Typing reaches the app in front: a fresh terminal, ASCII then Chinese, a key by name, return.
let _ = dk(&["exec", "-u", "bot", container(), "rm", "-f", "/home/bot/exact.txt"]); let _ = dk(&["exec", "-u", "bot", container(), "rm", "-f", "/home/bot/exact.txt"]);
let (txt, img) = computer_run("computer_use_open_app", &serde_json::json!({"app": "終端機"})).unwrap(); let (txt, img) = computer_run("computer_use_open_app", &serde_json::json!({"app": "終端機"})).unwrap();

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),
} }
@ -536,13 +527,29 @@ fn open_app(app: &str) -> Result<String, String> {
if app.is_empty() { if app.is_empty() {
return Err("請給要打開的 App 名稱".into()); return Err("請給要打開的 App 名稱".into());
} }
crate::desktop::launch(app).or_else(|e| match app_named(app) { launch(app).or_else(|e| match app_named(app) {
Some(real) if real != app => crate::desktop::launch(&real), Some(real) if real != app => launch(&real),
_ => Err(e), _ => Err(e),
})?; })?;
Ok(format!("已打開「{app}」,放在最前面。")) Ok(format!("已打開「{app}」,放在最前面。"))
} }
/// Open an app or bring it forward. macOS: LaunchServices (`open -a`), which needs no
/// permission (agent-desktop's launch waited on the window through accessibility and failed
/// without 輔助使用 now and then); elsewhere agent-desktop's.
fn launch(app: &str) -> Result<(), String> {
#[cfg(target_os = "macos")]
{
let out = std::process::Command::new("open").args(["-a", app]).output().map_err(|e| e.to_string())?;
if out.status.success() {
return Ok(());
}
Err(format!("找不到「{app}」這個 App"))
}
#[cfg(not(target_os = "macos"))]
crate::desktop::launch(app)
}
/// Bring the app a control belongs to in front, so a click at its spot lands in it (and not in /// Bring the app a control belongs to in front, so a click at its spot lands in it (and not in
/// a window lying over it). Nothing to do when it is in front already. /// a window lying over it). Nothing to do when it is in front already.
fn bring_forward(app: &str) { fn bring_forward(app: &str) {
@ -555,7 +562,7 @@ fn bring_forward(app: &str) {
if app.is_empty() || front.eq_ignore_ascii_case(app) { if app.is_empty() || front.eq_ignore_ascii_case(app) {
return; return;
} }
if crate::desktop::launch(app).is_ok() { if launch(app).is_ok() {
std::thread::sleep(std::time::Duration::from_millis(300)); std::thread::sleep(std::time::Duration::from_millis(300));
} }
} }
@ -773,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

@ -259,7 +259,8 @@ pub fn read_app(app: &str) -> Result<(String, Vec<String>), String> {
Ok((app_name, lines)) Ok((app_name, lines))
} }
/// Open an app, or bring it to the front when it runs. /// Open an app, or bring it to the front when it runs (macOS uses LaunchServices instead).
#[cfg(not(target_os = "macos"))]
pub fn launch(app: &str) -> Result<(), String> { pub fn launch(app: &str) -> Result<(), String> {
run("launch", &serde_json::json!({"app": app})).map(|_| ()) run("launch", &serde_json::json!({"app": app})).map(|_| ())
} }

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

@ -47,6 +47,7 @@ mod tools;
mod triggers; mod triggers;
mod vault; mod vault;
mod vault_store; mod vault_store;
mod vnc;
mod voice; mod voice;
mod tunnel; mod tunnel;
mod web; mod web;

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

@ -322,6 +322,8 @@ pub fn handle(req: Req, via_relay: bool) -> Resp {
let path: Vec<&str> = parts.iter().map(|s| *s).collect(); let path: Vec<&str> = parts.iter().map(|s| *s).collect();
match (req.method.as_str(), path.as_slice()) { match (req.method.as_str(), path.as_slice()) {
("GET", []) | ("GET", ["index.html"]) => return page(), ("GET", []) | ("GET", ["index.html"]) => return page(),
("GET", ["screen"]) => return Resp::bytes("text/html; charset=utf-8", SCREEN_PAGE.as_bytes().to_vec()).with_header("Content-Security-Policy", "default-src 'self'; img-src 'self' blob: data:; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'; connect-src 'self' ws: wss:"),
("GET", ["novnc.js"]) => return Resp::bytes("text/javascript; charset=utf-8", NOVNC.as_bytes().to_vec()).with_header("Cache-Control", "public, max-age=86400"),
("GET", ["manifest.webmanifest"]) => return Resp::bytes("application/manifest+json", MANIFEST.as_bytes().to_vec()), ("GET", ["manifest.webmanifest"]) => return Resp::bytes("application/manifest+json", MANIFEST.as_bytes().to_vec()),
("GET", ["icon.svg"]) => return Resp::bytes("image/svg+xml", ICON.as_bytes().to_vec()), ("GET", ["icon.svg"]) => return Resp::bytes("image/svg+xml", ICON.as_bytes().to_vec()),
("GET", [name]) if ICON_PNGS.iter().any(|(n, _)| n == name) => return Resp::bytes("image/png", ICON_PNGS.iter().find(|(n, _)| n == name).map(|(_, b)| b.to_vec()).unwrap_or_default()), ("GET", [name]) if ICON_PNGS.iter().any(|(n, _)| n == name) => return Resp::bytes("image/png", ICON_PNGS.iter().find(|(n, _)| n == name).map(|(_, b)| b.to_vec()).unwrap_or_default()),
@ -435,24 +437,16 @@ pub fn handle(req: Req, via_relay: bool) -> Resp {
Err(e) => Resp::error(503, &e), Err(e) => Resp::error(503, &e),
}, },
("POST", ["api", "sync", "upload", id]) => upload(id.parse().unwrap_or(0), &req), ("POST", ["api", "sync", "upload", id]) => upload(id.parse().unwrap_or(0), &req),
// A screen of this brain watches (and may take over) the bots' own computer, which lives // A screen of this brain shows the bots' own computer, which lives here: a small picture
// here: its picture on that screen used to be its own computer's, not this one's. // (people watch it live through VNC, see vnc.rs).
("GET", ["api", "sync", "screen"]) => { ("GET", ["api", "sync", "screen"]) => match crate::cloudbox::thumbnail(320) {
if req.query.get("thumb").map(|t| t == "1").unwrap_or(false) { Some(img) => match crate::computer::encode_jpeg(&image::DynamicImage::ImageRgba8(img).to_rgb8()) {
match crate::cloudbox::thumbnail(320) {
Some(img) => {
let rgb = image::DynamicImage::ImageRgba8(img).to_rgb8();
match crate::computer::encode_jpeg(&rgb) {
Some(j) => Resp::bytes("image/jpeg", j), Some(j) => Resp::bytes("image/jpeg", j),
None => Resp::error(500, "畫面編碼失敗"), None => Resp::error(500, "畫面編碼失敗"),
} },
}
None => Resp::error(404, "還沒有畫面"), None => Resp::error(404, "還沒有畫面"),
} },
} else { // 接管 / 我完成了 from a screen: the bot's steps wait (or not) for the person.
screen("box", &s)
}
}
("POST", ["api", "sync", "screen"]) => { ("POST", ["api", "sync", "screen"]) => {
let v = req.json(); let v = req.json();
match v["kind"].as_str().unwrap_or("") { match v["kind"].as_str().unwrap_or("") {
@ -460,25 +454,11 @@ pub fn handle(req: Req, via_relay: bool) -> Resp {
crate::cloudbox::HELD_BY_PERSON.store(v["on"].as_bool().unwrap_or(false), Ordering::SeqCst); crate::cloudbox::HELD_BY_PERSON.store(v["on"].as_bool().unwrap_or(false), Ordering::SeqCst);
Resp::json(&json!({"ok": true})) Resp::json(&json!({"ok": true}))
} }
"viewer" => { "wake" => match crate::cloudbox::ensure_running() {
crate::cloudbox::set_viewer_open(v["on"].as_bool().unwrap_or(false)); Ok(()) => Resp::json(&json!({"ok": true})),
Resp::json(&json!({"ok": true})) Err(e) => Resp::error(500, &e),
} },
// The person's own hands on the picture (frame pixels, like this computer's own _ => Resp::error(400, "不認得的操作"),
// viewer; not the bot's tools, which stop while the person holds it).
"click" | "move" | "scroll" | "key" | "text" => {
crate::cloudbox::touch();
let (x, y) = (v["x"].as_i64().unwrap_or(0), v["y"].as_i64().unwrap_or(0));
match v["kind"].as_str().unwrap_or("") {
"click" => crate::cloudbox::viewer_click(x, y, if v["button"].as_str() == Some("right") { "right" } else { "left" }, v["count"].as_u64().unwrap_or(1).clamp(1, 3) as u32),
"move" => crate::cloudbox::viewer_move(x, y),
"scroll" => crate::cloudbox::viewer_scroll(x, y, v["down"].as_bool().unwrap_or(true), v["steps"].as_u64().unwrap_or(1).clamp(1, 20) as u32),
"key" => crate::cloudbox::viewer_key(v["key"].as_str().unwrap_or("")),
_ => crate::cloudbox::viewer_text(v["text"].as_str().unwrap_or("")),
}
Resp::json(&json!({"ok": true}))
}
_ => screen_input("box", &v, &s),
} }
} }
("POST", ["api", "sync", "models"]) => match hub().ask_sync_for(SyncKind::Models(req.json()["provider"].as_str().unwrap_or("").to_string()), 45) { ("POST", ["api", "sync", "models"]) => match hub().ask_sync_for(SyncKind::Models(req.json()["provider"].as_str().unwrap_or("").to_string()), 45) {
@ -606,21 +586,10 @@ fn upload(bot: u64, req: &Req) -> Resp {
} }
/// A frame of the bot's computer ("box") or this Mac ("mac") as JPEG. /// A frame of the bot's computer ("box") or this Mac ("mac") as JPEG.
/// This Mac's screen for the phone (when allowed). The bots' computer is watched through VNC
/// (the page /screen, vnc.rs).
fn screen(which: &str, s: &Settings) -> Resp { fn screen(which: &str, s: &Settings) -> Resp {
match which { match which {
"box" => {
crate::cloudbox::touch();
match crate::cloudbox::frame() {
Some(img) => {
let rgb = image::DynamicImage::ImageRgba8(img).to_rgb8();
match crate::computer::encode_jpeg(&rgb) {
Some(j) => Resp::bytes("image/jpeg", j).with_header("X-Size", &format!("{}x{}", rgb.width(), rgb.height())),
None => Resp::error(500, "畫面編碼失敗"),
}
}
None => Resp::error(503, "Bot 的電腦在休息,按「叫醒」"),
}
}
"mac" if s.mac_from_phone => { "mac" if s.mac_from_phone => {
let (text, ok, img) = crate::computer::run_by("computer_use_screenshot", "{}", "手機"); let (text, ok, img) = crate::computer::run_by("computer_use_screenshot", "{}", "手機");
match (ok, img) { match (ok, img) {
@ -638,41 +607,15 @@ fn screen(which: &str, s: &Settings) -> Resp {
} }
} }
/// The phone taps, types or scrolls on a screen, as the person, in the pixels of the picture it /// The phone taps, types or scrolls on this Mac's screen, as the person, in the pixels of the
/// was sent: the box's frame is its desktop at full size, this computer's is the bot-sized /// picture it was sent (shot_size). The bots' computer takes the person's input through VNC.
/// picture (shot_size). It used to go through the bot's tools, which mapped the tap with the
/// bot's last screenshot (a different size: taps landed a quarter off, or failed with "take a
/// screenshot first") and refused it while the person held the screen.
fn screen_input(which: &str, v: &Value, s: &Settings) -> Resp { fn screen_input(which: &str, v: &Value, s: &Settings) -> Resp {
let kind = v["kind"].as_str().unwrap_or("");
if which == "box" && kind == "wake" {
return match crate::cloudbox::ensure_running() {
Ok(()) => Resp::json(&json!({"ok": true})),
Err(e) => Resp::error(500, &e),
};
}
let (x, y) = (v["x"].as_f64().unwrap_or(0.0), v["y"].as_f64().unwrap_or(0.0)); let (x, y) = (v["x"].as_f64().unwrap_or(0.0), v["y"].as_f64().unwrap_or(0.0));
let down = v["direction"].as_str().unwrap_or("down") != "up"; let down = v["direction"].as_str().unwrap_or("down") != "up";
let amount = v["amount"].as_i64().unwrap_or(3).clamp(1, 20); let amount = v["amount"].as_i64().unwrap_or(3).clamp(1, 20);
match which { match which {
"box" => {
if !matches!(kind, "click" | "type" | "key" | "scroll") {
return Resp::error(400, "不認得的操作");
}
if let Err(e) = crate::cloudbox::ensure_running() {
return Resp::error(500, &e);
}
let (xi, yi) = (x.round() as i64, y.round() as i64);
match kind {
"click" => crate::cloudbox::viewer_click(xi, yi, if v["button"].as_str() == Some("right") { "right" } else { "left" }, v["count"].as_u64().unwrap_or(1) as u32),
"type" => crate::cloudbox::viewer_text(v["text"].as_str().unwrap_or("")),
"key" => crate::cloudbox::viewer_key(v["key"].as_str().unwrap_or("")),
_ => crate::cloudbox::viewer_scroll(xi, yi, down, amount as u32),
}
Resp::json(&json!({"ok": true}))
}
"mac" if s.mac_from_phone => { "mac" if s.mac_from_phone => {
let (name, args) = match kind { let (name, args) = match v["kind"].as_str().unwrap_or("") {
"click" => ("computer_use_click", json!({"x": x, "y": y, "button": v["button"].as_str().unwrap_or("left"), "count": v["count"].as_u64().unwrap_or(1)})), "click" => ("computer_use_click", json!({"x": x, "y": y, "button": v["button"].as_str().unwrap_or("left"), "count": v["count"].as_u64().unwrap_or(1)})),
"type" => ("computer_use_typing", json!({"value": v["text"]})), "type" => ("computer_use_typing", json!({"value": v["text"]})),
"key" => ("computer_use_press_key", json!({"key": v["key"]})), "key" => ("computer_use_press_key", json!({"key": v["key"]})),
@ -684,7 +627,8 @@ fn screen_input(which: &str, v: &Value, s: &Settings) -> Resp {
Err(e) => Resp::error(500, &e), Err(e) => Resp::error(500, &e),
} }
} }
_ => Resp::error(403, "這台 Mac 沒有開放從手機操作"), "mac" => Resp::error(403, "這台 Mac 沒有開放從手機操作"),
_ => Resp::error(404, "沒有這個畫面"),
} }
} }
@ -862,10 +806,65 @@ pub fn set_lan(on: bool) -> Result<u16, String> {
.ok_or("找不到可用的連接埠")?; .ok_or("找不到可用的連接埠")?;
let port = listener.local_addr().map_err(|e| e.to_string())?.port(); let port = listener.local_addr().map_err(|e| e.to_string())?.port();
*LAN_BOUND.lock().unwrap() = Some(port); *LAN_BOUND.lock().unwrap() = Some(port);
std::thread::spawn(move || httpd::serve(listener, Arc::new(|r| handle(r, false)), Arc::new(move || LAN_GEN.load(Ordering::SeqCst) == gen))); std::thread::spawn(move || httpd::serve_upgrade(listener, Arc::new(|r| handle(r, false)), Some(Arc::new(lan_vnc)), Arc::new(move || LAN_GEN.load(Ordering::SeqCst) == gen)));
Ok(port) Ok(port)
} }
/// The phone page on the local network watching the bots' computer: /api/vnc, signed in with
/// the phone's password like every other request here; the VNC stream goes straight to the box.
fn lan_vnc(req: Req, mut c: std::net::TcpStream) {
use tungstenite::{protocol::Role, Message, WebSocket};
if req.path != "/api/vnc" {
return httpd::write_response(&mut c, &Resp::error(404, "沒有這個網址"));
}
if devices_ok(&req.bearer()).is_none() {
return httpd::write_response(&mut c, &Resp::error(401, "請先登入"));
}
if !httpd::accept_websocket(&req, &mut c) {
return;
}
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::<Vec<u8>>();
let send = move |b: &[u8]| tx.send(b.to_vec()).is_ok();
let Ok(mut session) = crate::vnc::open(send.clone()) else { return };
if !send(crate::vnc::GREETING) {
return;
}
'live: loop {
match ws.read() {
Ok(Message::Binary(b)) => {
if !session.feed(&b, &send) {
break;
}
}
Ok(Message::Text(t)) => crate::vnc::person_typed(&t),
Ok(Message::Close(_)) => break,
Ok(_) => {}
Err(tungstenite::Error::Io(e)) if matches!(e.kind(), std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut) => {}
Err(_) => break,
}
loop {
match rx.try_recv() {
// An empty send: the box's side ended.
Ok(b) if b.is_empty() => break 'live,
Ok(b) => {
if ws.write(Message::Binary(b.into())).is_err() {
break 'live;
}
}
Err(_) => break,
}
}
match ws.flush() {
Err(tungstenite::Error::Io(e)) if matches!(e.kind(), std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut) => {}
Err(_) => break,
Ok(()) => {}
}
}
let _ = ws.close(None);
}
/// Addresses a phone can open (local network and Tailscale). /// Addresses a phone can open (local network and Tailscale).
pub fn addresses(port: u16) -> Vec<String> { pub fn addresses(port: u16) -> Vec<String> {
// macOS: ifconfig ("inet 192.168.1.5 netmask…"); Linux: `ip -4 -o addr` ("… inet 192.168.1.5/24 …"). // macOS: ifconfig ("inet 192.168.1.5 netmask…"); Linux: `ip -4 -o addr` ("… inet 192.168.1.5/24 …").
@ -1212,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) };
@ -1247,6 +1246,23 @@ pub fn sync_screen(s: &Settings, thumb: bool) -> Result<Vec<u8>, String> {
Ok(bytes) Ok(bytes)
} }
/// A link for this account's browser to watch the brain's computer (/screen on the account
/// server; one-time, it works for ten minutes).
pub fn screen_ticket(s: &Settings) -> Result<String, String> {
if s.relay_url.is_empty() || s.brain_id.is_empty() {
return Err("這台沒有連到帳號的大腦".into());
}
let url = format!("{}/relay/screen-ticket", s.relay_url.trim_end_matches('/'));
let mut r = agent(15)
.post(&url)
.header("Authorization", &format!("Bearer {}", s.host_token))
.header("Content-Type", "application/json")
.send(json!({"host": s.brain_id}).to_string().as_bytes())
.map_err(|e| format!("連不到帳號伺服器:{}", why_unreachable(&e)))?;
let v: Value = r.body_mut().read_json().map_err(|_| "帳號伺服器的回答看不懂".to_string())?;
v["ticket"].as_str().map(String::from).ok_or_else(|| "帳號伺服器沒有給看畫面的連結(它可能是舊版,請更新)".into())
}
/// A file the brain keeps (an attachment shown in a chat), as bytes. /// A file the brain keeps (an attachment shown in a chat), as bytes.
pub fn sync_file(s: &Settings, path: &str) -> Result<Vec<u8>, String> { pub fn sync_file(s: &Settings, path: &str) -> Result<Vec<u8>, String> {
let (st, bytes) = to_brain_raw(s, "GET", "/file", &[("path", path.to_string())], &[], 120)?; let (st, bytes) = to_brain_raw(s, "GET", "/file", &[("path", path.to_string())], &[], 120)?;
@ -1256,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();
@ -1503,6 +1510,9 @@ pub fn devices_online(s: &Settings) -> Vec<(String, String, bool)> {
// ---------- the phone page ---------- // ---------- the phone page ----------
const PAGE: &str = include_str!("../mobile/index.html"); const PAGE: &str = include_str!("../mobile/index.html");
/// The bots' computer, watched and used in a browser (noVNC; see vnc.rs).
const SCREEN_PAGE: &str = include_str!("../mobile/screen.html");
const NOVNC: &str = include_str!("../mobile/novnc.js");
pub const MANIFEST: &str = r##"{"name":"AnyBot","short_name":"AnyBot","start_url":"./","display":"standalone","background_color":"#121212","theme_color":"#121212","icons":[{"src":"icon.svg","sizes":"any","type":"image/svg+xml"},{"src":"icon-192.png","sizes":"192x192","type":"image/png","purpose":"any maskable"},{"src":"icon-512.png","sizes":"512x512","type":"image/png","purpose":"any maskable"}]}"##; pub const MANIFEST: &str = r##"{"name":"AnyBot","short_name":"AnyBot","start_url":"./","display":"standalone","background_color":"#121212","theme_color":"#121212","icons":[{"src":"icon.svg","sizes":"any","type":"image/svg+xml"},{"src":"icon-192.png","sizes":"192x192","type":"image/png","purpose":"any maskable"},{"src":"icon-512.png","sizes":"512x512","type":"image/png","purpose":"any maskable"}]}"##;
/// The app's icon, the same drawing everywhere (icons/anybot.svg is the Mac/Linux one). /// The app's icon, the same drawing everywhere (icons/anybot.svg is the Mac/Linux one).
pub const ICON: &str = include_str!("../mobile/icon.svg"); pub const ICON: &str = include_str!("../mobile/icon.svg");

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

@ -51,7 +51,10 @@ pub fn zip(b: &[u8]) -> Vec<u8> {
/// The body to send and whether it is compressed: only when that saves a good part of it /// The body to send and whether it is compressed: only when that saves a good part of it
/// (pictures and PDFs are compressed already). /// (pictures and PDFs are compressed already).
fn maybe_zip(b: Vec<u8>) -> (Vec<u8>, bool) { fn maybe_zip(b: Vec<u8>) -> (Vec<u8>, bool) {
if b.len() <= ZIP_OVER { // JPEG, PNG, GIF, WebP, PDF, zip/docx, gzip: compressed already, not worth a try (the live
// picture is a JPEG several times a second).
const PACKED: [&[u8]; 7] = [b"\xff\xd8\xff", b"\x89PNG", b"GIF8", b"RIFF", b"%PDF", b"PK\x03\x04", b"\x1f\x8b"];
if b.len() <= ZIP_OVER || PACKED.iter().any(|m| b.starts_with(m)) {
return (b, false); return (b, false);
} }
let z = zip(&b); let z = zip(&b);
@ -226,7 +229,7 @@ pub fn run(url: &str, token: &str, alive: &dyn Fn() -> bool, status: &dyn Fn(&st
} }
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(Duration::from_millis(40))); let mut pace = wire::Pace::new();
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 });
@ -234,9 +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() {
pace.tune(ws.get_ref().tcp());
match ws.read() { match ws.read() {
Ok(Message::Binary(b)) => { Ok(Message::Binary(b)) => {
heard = Instant::now(); heard = Instant::now();
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" {
@ -251,6 +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() {
pace.moved();
if ws.write(Message::Binary(f.into())).is_err() { if ws.write(Message::Binary(f.into())).is_err() {
break 'live; break 'live;
} }
@ -276,14 +282,72 @@ fn keep(mut ws: WebSocket<Stream>, serial: u64, alive: &dyn Fn() -> bool) {
*l = None; *l = None;
} }
drop(l); drop(l);
streams().lock().unwrap().clear();
// Whoever waits on an answer hears now, not after a timeout. // Whoever waits on an answer hears now, not after a timeout.
for (_, w) in calls.lock().unwrap().drain() { for (_, w) in calls.lock().unwrap().drain() {
let _ = w.send(Err(CUT.into())); let _ = w.send(Err(CUT.into()));
} }
} }
/// People watching the bots' computer through the account server: one VNC stream each, by the
/// relay's stream id (vopen / vdata / vtext / vclose).
fn streams() -> &'static Mutex<HashMap<String, crate::vnc::Session>> {
static S: OnceLock<Mutex<HashMap<String, crate::vnc::Session>>> = OnceLock::new();
S.get_or_init(Default::default)
}
fn on_stream(kind: &str, sid: String, body: Vec<u8>, tx: &Sender<Vec<u8>>) {
let send = {
let (tx, sid) = (tx.clone(), sid.clone());
move |b: &[u8]| tx.send(wire::pack(&json!({"t": "vdata", "sid": sid}), b)).is_ok()
};
let close = {
let (tx, sid) = (tx.clone(), sid.clone());
move |why: &str| {
streams().lock().unwrap().remove(&sid);
let _ = tx.send(wire::pack(&json!({"t": "vclose", "sid": sid, "why": why}), &[]));
}
};
match kind {
"vopen" => {
std::thread::spawn(move || {
let out = {
let (send, close) = (send.clone(), close.clone());
move |b: &[u8]| if b.is_empty() { close("這台電腦的畫面關了"); false } else { send(b) }
};
match crate::vnc::open(out) {
Ok(s) => {
streams().lock().unwrap().insert(sid, s);
// Only now: the viewer answers this at once.
if !send(crate::vnc::GREETING) {
close("");
}
}
Err(e) => close(&e),
}
});
}
"vdata" => {
let mut all = streams().lock().unwrap();
let ok = all.get_mut(&sid).map(|s| s.feed(&body, &send)).unwrap_or(false);
drop(all);
if !ok {
close("這台電腦的畫面關了");
}
}
"vtext" => crate::vnc::person_typed(&String::from_utf8_lossy(&body)),
_ => {
streams().lock().unwrap().remove(&sid);
}
}
}
fn on_frame(head: Value, body: Vec<u8>, tx: &Sender<Vec<u8>>, calls: &Calls) { fn on_frame(head: Value, body: Vec<u8>, tx: &Sender<Vec<u8>>, calls: &Calls) {
match head["t"].as_str().unwrap_or("") { let t = head["t"].as_str().unwrap_or("");
if matches!(t, "vopen" | "vdata" | "vtext" | "vclose") {
return on_stream(t, head["sid"].as_str().unwrap_or("").to_string(), body, tx);
}
match t {
"job" => { "job" => {
let tx = tx.clone(); let tx = tx.clone();
std::thread::spawn(move || { std::thread::spawn(move || {

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

263
src/vnc.rs Normal file
View File

@ -0,0 +1,263 @@
// People watching and helping on the bots' computer: VNC, carried to them as a stream.
//
// The box already runs a VNC server (x11vnc, inside the container, with a password). A person's
// viewer (noVNC in a browser: the phone, or any computer through the account server) speaks VNC
// to this proxy, which reaches the box's server through one `docker exec` (a few lines of
// Python in the box joining its stdin/stdout to 127.0.0.1:5900). VNC sends only what changed on
// the screen and every mouse move and key as it happens, so watching is live and helping is
// like sitting at it — unlike pictures sent one by one.
//
// The proxy signs in to the box with its password itself and offers the viewer "no password",
// so the password never leaves this AnyBot; whoever reaches the proxy was already let in (an
// account session or one-time ticket at the account server, or the phone's password here).
// While the person clicks or types, the bot's own steps wait (HELD_BY_PERSON), and go on a
// little after they stop (person_active).
use std::io::{Read, Write};
use std::process::{Child, ChildStdin};
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Mutex;
use std::time::{Duration, Instant};
/// The bot waits this long after the person's last click or key before it goes on.
const HELD_AFTER_INPUT: Duration = Duration::from_secs(8);
/// Joins the box's VNC server to stdin/stdout, and ends when either side does (a loop left in
/// the box would hold the connection open).
const BRIDGE: &str = r#"import os,select,socket
s=socket.create_connection(('127.0.0.1',5900))
while True:
r=select.select([s,0],[],[])[0]
if s in r:
d=s.recv(65536)
if not d: break
os.write(1,d)
if 0 in r:
d=os.read(0,65536)
if not d: break
s.sendall(d)
"#;
/// Viewers connected now (the box stays awake while there is one).
static OPEN: AtomicUsize = AtomicUsize::new(0);
/// When the person last clicked or typed through a viewer.
static LAST_INPUT: Mutex<Option<Instant>> = Mutex::new(None);
/// VNC authentication: the 16-byte challenge encrypted with DES, the key being the password's
/// first 8 bytes with each byte's bits reversed (RFC 6143 §7.2.2, as every VNC server does it).
pub fn vnc_response(password: &str, challenge: &[u8; 16]) -> [u8; 16] {
use des::cipher::{BlockEncrypt, KeyInit};
let mut key = [0u8; 8];
for (k, b) in key.iter_mut().zip(password.bytes()) {
*k = b.reverse_bits();
}
let c = des::Des::new_from_slice(&key).expect("8-byte key");
let mut out = *challenge;
for block in out.chunks_mut(8) {
c.encrypt_block(des::cipher::generic_array::GenericArray::from_mut_slice(block));
}
out
}
/// This proxy's first words to a viewer, as a VNC server: version 3.8 (then "no password"; see
/// Session::feed).
pub const GREETING: &[u8] = b"RFB 003.008\n";
/// One person's viewer joined to the box.
pub struct Session {
child: Child,
stdin: ChildStdin,
/// The viewer's side of the handshake still to read: its version (12 bytes) and its choice
/// of security type (1 byte).
greeting: usize,
/// Viewer → box messages not yet complete (to notice clicks and keys).
pending: Vec<u8>,
sniffing: bool,
/// The viewer's ClientInit (one byte) is still to come.
init: bool,
}
/// Open a viewer's way to the box: sign in to its VNC server, and start sending what the server
/// says to `out` (from a thread of its own; `out` returns false when the viewer is gone, and
/// gets an empty slice when the box's side ended). The caller then sends the viewer GREETING
/// (once it is ready for the viewer's answer).
pub fn open(out: impl Fn(&[u8]) -> bool + Send + 'static) -> Result<Session, String> {
crate::cloudbox::ensure_running()?;
let mut child = crate::cloudbox::exec_piped(&["python3", "-c", BRIDGE])?;
let mut stdin = child.stdin.take().ok_or("連不上這台電腦的畫面")?;
let mut stdout = child.stdout.take().ok_or("連不上這台電腦的畫面")?;
let fail = |child: &mut Child, why: &str| -> String {
let _ = child.kill();
let _ = child.wait();
format!("連不上這台電腦的畫面:{why}")
};
let mut read = |n: usize| -> Result<Vec<u8>, String> {
let mut b = vec![0u8; n];
stdout.read_exact(&mut b).map(|_| b).map_err(|_| "畫面伺服器沒有回應".to_string())
};
// The box's side, as a VNC client: version 3.8, then its password.
let signed_in = (|| -> Result<(), String> {
let v = read(12)?;
if !v.starts_with(b"RFB 003.") {
return Err("不是 VNC 伺服器".into());
}
stdin.write_all(b"RFB 003.008\n").map_err(|e| e.to_string())?;
let n = read(1)?[0] as usize;
if n == 0 {
return Err("畫面伺服器拒絕連線".into());
}
let types = read(n)?;
if types.contains(&2) {
stdin.write_all(&[2]).map_err(|e| e.to_string())?;
let c: [u8; 16] = read(16)?.try_into().map_err(|_| "認證失敗".to_string())?;
stdin.write_all(&vnc_response(&crate::cloudbox::box_vnc_password(), &c)).map_err(|e| e.to_string())?;
} else if types.contains(&1) {
stdin.write_all(&[1]).map_err(|e| e.to_string())?;
} else {
return Err("畫面伺服器要的登入方式不支援".into());
}
if read(4)? != [0, 0, 0, 0] {
return Err("畫面伺服器的密碼不對".into());
}
Ok(())
})();
if let Err(e) = signed_in {
return Err(fail(&mut child, &e));
}
OPEN.fetch_add(1, Ordering::SeqCst);
crate::cloudbox::set_viewer_open(true);
std::thread::spawn(move || {
let mut buf = vec![0u8; 64 * 1024];
loop {
match stdout.read(&mut buf) {
Ok(0) | Err(_) => break,
Ok(n) => {
if !out(&buf[..n]) {
return;
}
}
}
}
// The box's side ended: an empty send says so.
out(&[]);
});
Ok(Session { child, stdin, greeting: 13, pending: Vec::new(), sniffing: true, init: true })
}
impl Session {
/// Bytes from the viewer. False when the box's side is gone.
pub fn feed(&mut self, mut b: &[u8], reply: impl Fn(&[u8]) -> bool) -> bool {
if self.greeting > 0 {
let take = self.greeting.min(b.len());
let before = self.greeting;
self.greeting -= take;
b = &b[take..];
// After the version (12 bytes): the one security type, "None".
if before > 1 && self.greeting <= 1 && !reply(&[1, 1]) {
return false;
}
// After the choice: OK.
if self.greeting == 0 && !reply(&[0, 0, 0, 0]) {
return false;
}
if b.is_empty() {
return true;
}
}
self.notice_input(b);
self.stdin.write_all(b).and_then(|_| self.stdin.flush()).is_ok()
}
/// Follow the viewer's messages (RFC 6143 §7.5) to see clicks and keys. Anything it doesn't
/// know stops the watching, never the forwarding.
fn notice_input(&mut self, b: &[u8]) {
if !self.sniffing {
return;
}
self.pending.extend_from_slice(b);
// The first byte after the handshake is ClientInit (the shared flag).
if self.init && !self.pending.is_empty() {
self.pending.remove(0);
self.init = false;
}
let mut at = 0;
loop {
let rest = &self.pending[at..];
let Some(&kind) = rest.first() else { break };
let len = match kind {
0 => 20,
2 if rest.len() >= 4 => 4 + 4 * u16::from_be_bytes([rest[2], rest[3]]) as usize,
3 => 10,
4 => 8,
5 => 6,
6 if rest.len() >= 8 => 8 + u32::from_be_bytes([rest[4], rest[5], rest[6], rest[7]]) as usize,
2 | 6 => break,
_ => {
self.sniffing = false;
self.pending.clear();
return;
}
};
if rest.len() < len {
break;
}
match kind {
4 if rest[1] == 1 => person_acted(),
5 if rest[1] != 0 => person_acted(),
_ => {}
}
at += len;
}
self.pending.drain(..at);
}
}
impl Drop for Session {
fn drop(&mut self) {
let _ = self.child.kill();
let _ = self.child.wait();
if OPEN.fetch_sub(1, Ordering::SeqCst) == 1 {
crate::cloudbox::set_viewer_open(false);
}
}
}
/// The person clicked or typed: the bot's steps wait until a little after they stop.
pub fn person_acted() {
*LAST_INPUT.lock().unwrap_or_else(|e| e.into_inner()) = Some(Instant::now());
}
/// The person is using the box through a viewer right now (input in the last few seconds).
pub fn person_active() -> bool {
LAST_INPUT.lock().unwrap_or_else(|e| e.into_inner()).map(|t| t.elapsed() < HELD_AFTER_INPUT).unwrap_or(false)
}
/// Text the person typed in the viewer's own box (any language): pasted into the box.
pub fn person_typed(text: &str) {
if !text.is_empty() {
person_acted();
crate::cloudbox::viewer_text(text);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_bridge_keeps_its_indentation() {
// Python needs its indentation (a Rust "\" line continuation would strip it).
assert!(BRIDGE.contains("\n r=select") && BRIDGE.contains("\n if not d: break"));
}
#[test]
fn vnc_password_answer_matches_the_standard() {
// Password "password", challenge 00..0f: DES-ECB with the bit-reversed key 0e86ceceeef64e26
// (worked out with `openssl enc -des-ecb -nopad`).
let c: [u8; 16] = core::array::from_fn(|i| i as u8);
let want = [0xb8, 0x66, 0x92, 0x41, 0x25, 0xc8, 0xee, 0xbb, 0x9d, 0xeb, 0xc1, 0xdb, 0x61, 0xc5, 0x38, 0xe2];
assert_eq!(vnc_response("password", &c), want);
// Only the first 8 bytes of a password count.
assert_eq!(vnc_response("password-and-more", &c), want);
}
}

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]