Remove what nothing uses and keep one copy of what was written several times
Unused (found by taking away the dead-code allowances; nothing else used them, tests aside): computer::specs, quiet_button, the never-made Poke::Spin, ten icons and their SVGs, the theme's chrome/bubble_agent colours, ImportRow.line, voice's multipart/transcribe/speak wrappers, and the old keychain writers vault::save/remove/index_add with their live test (the vault store replaced them; reading old keychain entries for the import stays). One copy each: percent-encoding (httpd::url_encode, was four), tool arguments (tools::args_of, was three), the WebSocket handshake (httpd::accept_websocket, was three), the read pacing (wire::Pace, was two: relay and app), docker with input and a time limit (dk_with_input, was two). Docs: FEATURES §看畫面 (VNC), protocol (v* frames, /screen, tickets), architecture. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
18
FEATURES.md
|
|
@ -729,15 +729,17 @@ AnyBot 是一個桌面聊天 App,介面和運作方式照 Grok Bot 重做。
|
||||||
- 換位置後會在新的地方重新建立;舊檔案可以先「儲存快照」,換過去再「從快照復原」。
|
- 換位置後會在新的地方重新建立;舊檔案可以先「儲存快照」,換過去再「從快照復原」。
|
||||||
- **「網路流量改走這台 Mac」**(原版:網路流量可以改走你的本機電腦,「僅對新連線生效」,管理員可以關掉):打開後,Bot 的電腦之後執行的指令會經過這台 Mac 上的代理伺服器出去(例如要連公司內網或 VPN 才看得到的服務)。代理只聽這台 Mac 自己(127.0.0.1),用原版的埠 8791,被占用就往後找。設定頁顯示經過的連線數。Bot 的電腦在別的主機上時不能用。管理員可以用 `defaults write com.anybot.AnyBot AllowBoxTrafficViaThisMac false` 鎖住,這時顯示「🔒 由你的管理員設定,無法變更」。
|
- **「網路流量改走這台 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 的圖、解開再壓、經過帳號伺服器傳;點擊另走一條路)已經拆掉。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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) |
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -220,29 +220,6 @@ struct Relay {
|
||||||
/// How long a link the app opens for a browser works (reconnects included).
|
/// How long a link the app opens for a browser works (reconnects included).
|
||||||
const TICKET_LIFE: Duration = Duration::from_secs(600);
|
const TICKET_LIFE: Duration = Duration::from_secs(600);
|
||||||
|
|
||||||
/// How long a connection's loop waits to read before sending what is queued: 2 ms while things
|
|
||||||
/// move (a live screen), 40 ms when quiet. A fixed 40 ms added up to 40 ms to every message.
|
|
||||||
struct Pace {
|
|
||||||
moved: Instant,
|
|
||||||
busy: Option<bool>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Pace {
|
|
||||||
fn new() -> Pace {
|
|
||||||
Pace { moved: Instant::now() - Duration::from_secs(60), busy: None }
|
|
||||||
}
|
|
||||||
fn moved(&mut self) {
|
|
||||||
self.moved = Instant::now();
|
|
||||||
}
|
|
||||||
fn tune(&mut self, c: &std::net::TcpStream) {
|
|
||||||
let busy = self.moved.elapsed() < Duration::from_secs(2);
|
|
||||||
if self.busy != Some(busy) {
|
|
||||||
self.busy = Some(busy);
|
|
||||||
let _ = c.set_read_timeout(Some(Duration::from_millis(if busy { 2 } else { 40 })));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn now() -> i64 {
|
fn now() -> i64 {
|
||||||
chrono::Utc::now().timestamp()
|
chrono::Utc::now().timestamp()
|
||||||
}
|
}
|
||||||
|
|
@ -334,7 +311,6 @@ impl Relay {
|
||||||
/// (?t=, the phone page) or a ticket the account's app asked for (?ticket=); owners and
|
/// (?t=, the phone page) or a ticket the account's app asked for (?ticket=); owners and
|
||||||
/// admins only.
|
/// admins only.
|
||||||
fn vnc_viewer(self: &Arc<Self>, req: Req, mut c: std::net::TcpStream, host: &str) {
|
fn vnc_viewer(self: &Arc<Self>, req: Req, mut c: std::net::TcpStream, host: &str) {
|
||||||
use std::io::Write;
|
|
||||||
use tungstenite::{protocol::Role, Message, WebSocket};
|
use tungstenite::{protocol::Role, Message, WebSocket};
|
||||||
let user = match req.query.get("ticket").filter(|t| !t.is_empty()) {
|
let user = match req.query.get("ticket").filter(|t| !t.is_empty()) {
|
||||||
Some(t) => self.tickets.lock().unwrap().get(t).filter(|(_, h, made)| h == host && made.elapsed() < TICKET_LIFE).map(|(u, _, _)| u.clone()),
|
Some(t) => self.tickets.lock().unwrap().get(t).filter(|(_, h, made)| h == host && made.elapsed() < TICKET_LIFE).map(|(u, _, _)| u.clone()),
|
||||||
|
|
@ -348,15 +324,9 @@ impl Relay {
|
||||||
let Some(to_host) = self.sockets.lock().unwrap().get(host).map(|s| s.tx.clone()) else {
|
let Some(to_host) = self.sockets.lock().unwrap().get(host).map(|s| s.tx.clone()) else {
|
||||||
return httpd::write_response(&mut c, &Resp::error(503, "這台 AnyBot 現在沒有連線"));
|
return httpd::write_response(&mut c, &Resp::error(503, "這台 AnyBot 現在沒有連線"));
|
||||||
};
|
};
|
||||||
let key = req.header("sec-websocket-key").to_string();
|
if !httpd::accept_websocket(&req, &mut c) {
|
||||||
if key.is_empty() {
|
|
||||||
return httpd::write_response(&mut c, &Resp::error(400, "bad request"));
|
|
||||||
}
|
|
||||||
let accept = tungstenite::handshake::derive_accept_key(key.as_bytes());
|
|
||||||
if c.write_all(format!("HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: {accept}\r\n\r\n").as_bytes()).is_err() {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let _ = c.set_nodelay(true);
|
|
||||||
let mut ws = WebSocket::from_raw_socket(c, Role::Server, None);
|
let mut ws = WebSocket::from_raw_socket(c, Role::Server, None);
|
||||||
let sid = httpd::random_hex(8);
|
let sid = httpd::random_hex(8);
|
||||||
let (tx, rx) = channel::<Vec<u8>>();
|
let (tx, rx) = channel::<Vec<u8>>();
|
||||||
|
|
@ -366,7 +336,7 @@ impl Relay {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let (mut heard, mut pinged) = (Instant::now(), Instant::now());
|
let (mut heard, mut pinged) = (Instant::now(), Instant::now());
|
||||||
let mut pace = Pace::new();
|
let mut pace = wire::Pace::new();
|
||||||
'live: loop {
|
'live: loop {
|
||||||
pace.tune(ws.get_ref());
|
pace.tune(ws.get_ref());
|
||||||
let up = match ws.read() {
|
let up = match ws.read() {
|
||||||
|
|
@ -480,7 +450,6 @@ impl Relay {
|
||||||
/// 長連線: an AnyBot keeps one WebSocket open; requests for it come down it at once, and its
|
/// 長連線: an AnyBot keeps one WebSocket open; requests for it come down it at once, and its
|
||||||
/// own requests to the account's other AnyBots go up it.
|
/// own requests to the account's other AnyBots go up it.
|
||||||
fn ws(self: &Arc<Self>, req: Req, mut c: std::net::TcpStream) {
|
fn ws(self: &Arc<Self>, req: Req, mut c: std::net::TcpStream) {
|
||||||
use std::io::Write;
|
|
||||||
use tungstenite::{protocol::Role, Message, WebSocket};
|
use tungstenite::{protocol::Role, Message, WebSocket};
|
||||||
if let Some(host) = req.path.strip_prefix("/relay/vnc/") {
|
if let Some(host) = req.path.strip_prefix("/relay/vnc/") {
|
||||||
let host = host.to_string();
|
let host = host.to_string();
|
||||||
|
|
@ -490,16 +459,9 @@ impl Relay {
|
||||||
return httpd::write_response(&mut c, &Resp::error(404, "沒有這個網址"));
|
return httpd::write_response(&mut c, &Resp::error(404, "沒有這個網址"));
|
||||||
}
|
}
|
||||||
let Some(h) = self.host(&req) else { return httpd::write_response(&mut c, &Resp::error(401, "這台電腦的金鑰無效")) };
|
let Some(h) = self.host(&req) else { return httpd::write_response(&mut c, &Resp::error(401, "這台電腦的金鑰無效")) };
|
||||||
let key = req.header("sec-websocket-key").to_string();
|
if !httpd::accept_websocket(&req, &mut c) {
|
||||||
if key.is_empty() {
|
|
||||||
return httpd::write_response(&mut c, &Resp::error(400, "bad request"));
|
|
||||||
}
|
|
||||||
let accept = tungstenite::handshake::derive_accept_key(key.as_bytes());
|
|
||||||
if c.write_all(format!("HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: {accept}\r\n\r\n").as_bytes()).is_err() {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let _ = c.set_read_timeout(Some(Duration::from_millis(40)));
|
|
||||||
let _ = c.set_nodelay(true);
|
|
||||||
let cfg = tungstenite::protocol::WebSocketConfig::default().max_message_size(Some(httpd::MAX_BODY * 2)).max_frame_size(Some(httpd::MAX_BODY * 2));
|
let cfg = tungstenite::protocol::WebSocketConfig::default().max_message_size(Some(httpd::MAX_BODY * 2)).max_frame_size(Some(httpd::MAX_BODY * 2));
|
||||||
let mut ws = WebSocket::from_raw_socket(c, Role::Server, Some(cfg));
|
let mut ws = WebSocket::from_raw_socket(c, Role::Server, Some(cfg));
|
||||||
let serial = SERIAL.fetch_add(1, Ordering::SeqCst);
|
let serial = SERIAL.fetch_add(1, Ordering::SeqCst);
|
||||||
|
|
@ -507,7 +469,7 @@ impl Relay {
|
||||||
self.sockets.lock().unwrap().insert(h.id.clone(), Sock { tx: tx.clone(), serial });
|
self.sockets.lock().unwrap().insert(h.id.clone(), Sock { tx: tx.clone(), serial });
|
||||||
self.seen.lock().unwrap().insert(h.id.clone(), Instant::now());
|
self.seen.lock().unwrap().insert(h.id.clone(), Instant::now());
|
||||||
let (mut heard, mut pinged, mut marked) = (Instant::now(), Instant::now(), Instant::now());
|
let (mut heard, mut pinged, mut marked) = (Instant::now(), Instant::now(), Instant::now());
|
||||||
let mut pace = Pace::new();
|
let mut pace = wire::Pace::new();
|
||||||
'live: loop {
|
'live: loop {
|
||||||
pace.tune(ws.get_ref());
|
pace.tune(ws.get_ref());
|
||||||
match ws.read() {
|
match ws.read() {
|
||||||
|
|
|
||||||
|
|
@ -4132,12 +4132,6 @@ fn icon_button(ui: &mut egui::Ui, icon: &str, tip: &str, p: Palette) -> egui::Re
|
||||||
ui.add(egui::Button::new(RichText::new(icon).size(15.0).color(p.text2)).frame(false).min_size(vec2(26.0, 26.0))).on_hover_text(t(tip))
|
ui.add(egui::Button::new(RichText::new(icon).size(15.0).color(p.text2)).frame(false).min_size(vec2(26.0, 26.0))).on_hover_text(t(tip))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A header action: text only, a soft fill on hover.
|
|
||||||
#[allow(dead_code)]
|
|
||||||
fn quiet_button(ui: &mut egui::Ui, text: &str, p: Palette) -> egui::Response {
|
|
||||||
ui.add(egui::Button::new(RichText::new(t(text)).size(FONT_SM).color(p.text2)).frame_when_inactive(false).corner_radius(8).min_size(vec2(0.0, 28.0)))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn small_button(ui: &mut egui::Ui, text: &str, p: Palette) -> egui::Response {
|
fn small_button(ui: &mut egui::Ui, text: &str, p: Palette) -> egui::Response {
|
||||||
ui.add(egui::Button::new(RichText::new(t(text)).size(11.5).color(p.text3)).frame_when_inactive(false).corner_radius(6).min_size(vec2(0.0, 22.0)))
|
ui.add(egui::Button::new(RichText::new(t(text)).size(11.5).color(p.text3)).frame_when_inactive(false).corner_radius(6).min_size(vec2(0.0, 22.0)))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -927,8 +927,7 @@ impl App {
|
||||||
let s = self.state.remote.clone();
|
let s = self.state.remote.clone();
|
||||||
self.box_task(move || {
|
self.box_task(move || {
|
||||||
let ticket = crate::remote::screen_ticket(&s)?;
|
let ticket = crate::remote::screen_ticket(&s)?;
|
||||||
let enc = |t: &str| t.bytes().map(|b| if b.is_ascii_alphanumeric() { (b as char).to_string() } else { format!("%{b:02X}") }).collect::<String>();
|
let url = format!("{}/screen#host={}&ticket={ticket}&name={}{}", s.relay_url.trim_end_matches('/'), s.brain_id, crate::httpd::url_encode(&name), if control { "&control=1" } else { "" });
|
||||||
let url = format!("{}/screen#host={}&ticket={ticket}&name={}{}", s.relay_url.trim_end_matches('/'), s.brain_id, enc(&name), if control { "&control=1" } else { "" });
|
|
||||||
crate::preview::open_with_system(&url);
|
crate::preview::open_with_system(&url);
|
||||||
Ok(String::new())
|
Ok(String::new())
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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 => {
|
||||||
|
|
|
||||||
|
|
@ -453,9 +453,24 @@ fn dk(args: &[&str]) -> Result<String, String> {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn dk_timeout(args: &[&str], ms: u64) -> Result<Vec<u8>, String> {
|
fn dk_timeout(args: &[&str], ms: u64) -> Result<Vec<u8>, String> {
|
||||||
use std::io::Read;
|
dk_with_input(args, None, ms)
|
||||||
let mut child = docker().args(args).stdout(Stdio::piped()).stderr(Stdio::piped()).spawn().map_err(|e| format!("找不到 docker:{e}"))?;
|
}
|
||||||
// Drain both pipes while waiting, or a big screenshot fills the pipe and stalls docker.
|
|
||||||
|
/// Run docker with `input` on its stdin (if any): Ok(stdout) or Err(stderr), killed after `ms`
|
||||||
|
/// ("逾時"). Both pipes are drained while waiting, or a big screenshot fills a pipe and stalls
|
||||||
|
/// docker.
|
||||||
|
fn dk_with_input(args: &[&str], input: Option<&[u8]>, ms: u64) -> Result<Vec<u8>, String> {
|
||||||
|
use std::io::{Read, Write};
|
||||||
|
let mut child = docker()
|
||||||
|
.args(args)
|
||||||
|
.stdin(if input.is_some() { Stdio::piped() } else { Stdio::null() })
|
||||||
|
.stdout(Stdio::piped())
|
||||||
|
.stderr(Stdio::piped())
|
||||||
|
.spawn()
|
||||||
|
.map_err(|e| format!("找不到 docker:{e}"))?;
|
||||||
|
if let (Some(b), Some(mut si)) = (input, child.stdin.take()) {
|
||||||
|
si.write_all(b).map_err(|e| e.to_string())?;
|
||||||
|
}
|
||||||
let mut so = child.stdout.take().ok_or("沒有輸出")?;
|
let mut so = child.stdout.take().ok_or("沒有輸出")?;
|
||||||
let mut se = child.stderr.take().ok_or("沒有輸出")?;
|
let mut se = child.stderr.take().ok_or("沒有輸出")?;
|
||||||
let out_t = std::thread::spawn(move || {
|
let out_t = std::thread::spawn(move || {
|
||||||
|
|
@ -478,7 +493,7 @@ fn dk_timeout(args: &[&str], ms: u64) -> Result<Vec<u8>, String> {
|
||||||
let _ = child.wait();
|
let _ = child.wait();
|
||||||
return Err("逾時".into());
|
return Err("逾時".into());
|
||||||
}
|
}
|
||||||
std::thread::sleep(Duration::from_millis(10));
|
std::thread::sleep(Duration::from_millis(5));
|
||||||
};
|
};
|
||||||
let out = out_t.join().unwrap_or_default();
|
let out = out_t.join().unwrap_or_default();
|
||||||
let err = err_t.join().unwrap_or_default();
|
let err = err_t.join().unwrap_or_default();
|
||||||
|
|
@ -1359,51 +1374,14 @@ fn type_script(text: &str) -> String {
|
||||||
|
|
||||||
/// Run a step's script in the box; with `picture`, wait for the screen to settle and return it.
|
/// Run a step's script in the box; with `picture`, wait for the screen to settle and return it.
|
||||||
fn box_exec(script: &str, stdin: Option<&str>, picture: bool) -> Result<Option<image::RgbImage>, String> {
|
fn box_exec(script: &str, stdin: Option<&str>, picture: bool) -> Result<Option<image::RgbImage>, String> {
|
||||||
use std::io::{Read, Write};
|
|
||||||
let full = if picture { format!("{{ {script}; }} || exit $?; sleep {:.2}; import -window root png:- 2>/dev/null || true", crate::computer::SETTLE_MS as f64 / 1000.0) } else { script.to_string() };
|
let full = if picture { format!("{{ {script}; }} || exit $?; sleep {:.2}; import -window root png:- 2>/dev/null || true", crate::computer::SETTLE_MS as f64 / 1000.0) } else { script.to_string() };
|
||||||
let mut child = docker()
|
// Always an stdin (closed at once when there's no text), as `docker exec -i` expects.
|
||||||
.args(["exec", "-i", "-u", "bot", "-e", "DISPLAY=:1", container(), "sh", "-c", &full])
|
let out = match dk_with_input(&["exec", "-i", "-u", "bot", "-e", "DISPLAY=:1", container(), "sh", "-c", &full], Some(stdin.unwrap_or("").as_bytes()), 30_000) {
|
||||||
.stdin(Stdio::piped())
|
Ok(o) => o,
|
||||||
.stdout(Stdio::piped())
|
Err(e) if e == "逾時" => return Err("這台電腦太久沒有回應".into()),
|
||||||
.stderr(Stdio::piped())
|
Err(e) if e.is_empty() => return Err("這一步沒有成功".into()),
|
||||||
.spawn()
|
Err(e) => return Err(format!("xdotool:{e}")),
|
||||||
.map_err(|e| format!("找不到 docker:{e}"))?;
|
|
||||||
{
|
|
||||||
let mut si = child.stdin.take().ok_or("寫不進 Bot 的電腦")?;
|
|
||||||
if let Some(t) = stdin {
|
|
||||||
si.write_all(t.as_bytes()).map_err(|e| e.to_string())?;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let mut so = child.stdout.take().ok_or("沒有輸出")?;
|
|
||||||
let mut se = child.stderr.take().ok_or("沒有輸出")?;
|
|
||||||
let out_t = std::thread::spawn(move || {
|
|
||||||
let mut v = vec![];
|
|
||||||
let _ = so.read_to_end(&mut v);
|
|
||||||
v
|
|
||||||
});
|
|
||||||
let err_t = std::thread::spawn(move || {
|
|
||||||
let mut v = vec![];
|
|
||||||
let _ = se.read_to_end(&mut v);
|
|
||||||
v
|
|
||||||
});
|
|
||||||
let start = Instant::now();
|
|
||||||
let status = loop {
|
|
||||||
if let Ok(Some(st)) = child.try_wait() {
|
|
||||||
break st;
|
|
||||||
}
|
|
||||||
if start.elapsed() > Duration::from_secs(30) {
|
|
||||||
let _ = child.kill();
|
|
||||||
let _ = child.wait();
|
|
||||||
return Err("這台電腦太久沒有回應".into());
|
|
||||||
}
|
|
||||||
std::thread::sleep(Duration::from_millis(5));
|
|
||||||
};
|
};
|
||||||
let out = out_t.join().unwrap_or_default();
|
|
||||||
let err = err_t.join().unwrap_or_default();
|
|
||||||
if !status.success() {
|
|
||||||
let e = String::from_utf8_lossy(&err).trim().to_string();
|
|
||||||
return Err(if e.is_empty() { "這一步沒有成功".into() } else { format!("xdotool:{e}") });
|
|
||||||
}
|
|
||||||
if !picture || out.is_empty() {
|
if !picture || out.is_empty() {
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -215,12 +215,9 @@ pub fn label(name: &str) -> &'static str {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn args_of(args: &str) -> Value {
|
|
||||||
serde_json::from_str(if args.trim().is_empty() { "{}" } else { args }).unwrap_or(Value::Null)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn describe(name: &str, args: &str) -> String {
|
pub fn describe(name: &str, args: &str) -> String {
|
||||||
let v = args_of(args);
|
let v = crate::tools::args_of(args);
|
||||||
// By number from the app's list (e7), else where on the screen (it said "(0, 0)" for every
|
// By number from the app's list (e7), else where on the screen (it said "(0, 0)" for every
|
||||||
// click by number).
|
// click by number).
|
||||||
let xy = || match v["ref"].as_str().map(str::trim).filter(|r| !r.is_empty()) {
|
let xy = || match v["ref"].as_str().map(str::trim).filter(|r| !r.is_empty()) {
|
||||||
|
|
@ -251,11 +248,6 @@ pub fn describe(name: &str, args: &str) -> String {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[allow(dead_code)]
|
|
||||||
pub fn specs() -> Vec<Value> {
|
|
||||||
specs_for(&[], "")
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The screen tools, each with a `computer` choice. One way to act (a real click or keystroke),
|
/// The screen tools, each with a `computer` choice. One way to act (a real click or keystroke),
|
||||||
/// two ways to see: the picture, or an app's controls as numbered text.
|
/// two ways to see: the picture, or an app's controls as numbered text.
|
||||||
pub fn specs_for(computers: &[String], default: &str) -> Vec<Value> {
|
pub fn specs_for(computers: &[String], default: &str) -> Vec<Value> {
|
||||||
|
|
@ -445,7 +437,6 @@ fn flags_of(v: &Value) -> u64 {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Run a computer_use tool. Returns (text, ok, screenshot as base64 JPEG).
|
/// Run a computer_use tool. Returns (text, ok, screenshot as base64 JPEG).
|
||||||
#[allow(dead_code)]
|
|
||||||
pub fn run(name: &str, args: &str) -> (String, bool, Option<String>) {
|
pub fn run(name: &str, args: &str) -> (String, bool, Option<String>) {
|
||||||
run_by(name, args, "")
|
run_by(name, args, "")
|
||||||
}
|
}
|
||||||
|
|
@ -455,7 +446,7 @@ pub fn run_by(name: &str, args: &str, who: &str) -> (String, bool, Option<String
|
||||||
if moves_things(name) {
|
if moves_things(name) {
|
||||||
*LAST_ACTION.lock().unwrap_or_else(|e| e.into_inner()) = Some((std::time::Instant::now(), who.to_string()));
|
*LAST_ACTION.lock().unwrap_or_else(|e| e.into_inner()) = Some((std::time::Instant::now(), who.to_string()));
|
||||||
}
|
}
|
||||||
match run_inner(name, &args_of(args)) {
|
match run_inner(name, &crate::tools::args_of(args)) {
|
||||||
Ok((t, img)) => (t, true, img),
|
Ok((t, img)) => (t, true, img),
|
||||||
Err(e) => (e, false, None),
|
Err(e) => (e, false, None),
|
||||||
}
|
}
|
||||||
|
|
@ -789,7 +780,7 @@ pub fn person_act(name: &str, v: &Value) -> Result<(), String> {
|
||||||
/// The app a screen action will land in: the window under the point for clicks, else the
|
/// The app a screen action will land in: the window under the point for clicks, else the
|
||||||
/// frontmost app. Used for the per-app permission (「AnyBot 可以看到 {app},並在其中點擊和輸入」).
|
/// frontmost app. Used for the per-app permission (「AnyBot 可以看到 {app},並在其中點擊和輸入」).
|
||||||
pub fn target_app(name: &str, args: &str) -> Option<String> {
|
pub fn target_app(name: &str, args: &str) -> Option<String> {
|
||||||
let v = args_of(args);
|
let v = crate::tools::args_of(args);
|
||||||
if let Some(app) = v["app"].as_str().filter(|a| !a.trim().is_empty() && matches!(name, "computer_use_read_app" | "computer_use_open_app")) {
|
if let Some(app) = v["app"].as_str().filter(|a| !a.trim().is_empty() && matches!(name, "computer_use_read_app" | "computer_use_open_app")) {
|
||||||
return Some(app.trim().to_string());
|
return Some(app.trim().to_string());
|
||||||
}
|
}
|
||||||
|
|
|
||||||
19
src/httpd.rs
|
|
@ -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;
|
||||||
|
|
||||||
|
|
|
||||||
23
src/icons.rs
|
|
@ -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"),
|
||||||
|
|
|
||||||
|
|
@ -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("&")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -813,7 +813,6 @@ pub fn set_lan(on: bool) -> Result<u16, String> {
|
||||||
/// The phone page on the local network watching the bots' computer: /api/vnc, signed in with
|
/// The phone page on the local network watching the bots' computer: /api/vnc, signed in with
|
||||||
/// the phone's password like every other request here; the VNC stream goes straight to the box.
|
/// the phone's password like every other request here; the VNC stream goes straight to the box.
|
||||||
fn lan_vnc(req: Req, mut c: std::net::TcpStream) {
|
fn lan_vnc(req: Req, mut c: std::net::TcpStream) {
|
||||||
use std::io::Write;
|
|
||||||
use tungstenite::{protocol::Role, Message, WebSocket};
|
use tungstenite::{protocol::Role, Message, WebSocket};
|
||||||
if req.path != "/api/vnc" {
|
if req.path != "/api/vnc" {
|
||||||
return httpd::write_response(&mut c, &Resp::error(404, "沒有這個網址"));
|
return httpd::write_response(&mut c, &Resp::error(404, "沒有這個網址"));
|
||||||
|
|
@ -821,12 +820,9 @@ fn lan_vnc(req: Req, mut c: std::net::TcpStream) {
|
||||||
if devices_ok(&req.bearer()).is_none() {
|
if devices_ok(&req.bearer()).is_none() {
|
||||||
return httpd::write_response(&mut c, &Resp::error(401, "請先登入"));
|
return httpd::write_response(&mut c, &Resp::error(401, "請先登入"));
|
||||||
}
|
}
|
||||||
let key = req.header("sec-websocket-key").to_string();
|
if !httpd::accept_websocket(&req, &mut c) {
|
||||||
let accept = tungstenite::handshake::derive_accept_key(key.as_bytes());
|
|
||||||
if key.is_empty() || c.write_all(format!("HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: {accept}\r\n\r\n").as_bytes()).is_err() {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let _ = c.set_nodelay(true);
|
|
||||||
let _ = c.set_read_timeout(Some(Duration::from_millis(2)));
|
let _ = c.set_read_timeout(Some(Duration::from_millis(2)));
|
||||||
let mut ws = WebSocket::from_raw_socket(c, Role::Server, None);
|
let mut ws = WebSocket::from_raw_socket(c, Role::Server, None);
|
||||||
let (tx, rx) = std::sync::mpsc::channel::<Vec<u8>>();
|
let (tx, rx) = std::sync::mpsc::channel::<Vec<u8>>();
|
||||||
|
|
@ -1215,7 +1211,7 @@ fn to_brain_raw(s: &Settings, method: &str, rest: &str, query: &[(&str, String)]
|
||||||
if let Some(r) = crate::tunnel::call(&s.brain_id, method, &format!("/api/sync{rest}"), query, body, secs) {
|
if let Some(r) = crate::tunnel::call(&s.brain_id, method, &format!("/api/sync{rest}"), query, body, secs) {
|
||||||
return r.map(|a| (a.status, a.body));
|
return r.map(|a| (a.status, a.body));
|
||||||
}
|
}
|
||||||
let q: Vec<String> = query.iter().map(|(k, v)| format!("{k}={}", url_escape(v))).collect();
|
let q: Vec<String> = query.iter().map(|(k, v)| format!("{k}={}", httpd::url_encode(v))).collect();
|
||||||
let url = brain_url(s, &format!("{rest}{}{}", if q.is_empty() { "" } else { "?" }, q.join("&")))?;
|
let url = brain_url(s, &format!("{rest}{}{}", if q.is_empty() { "" } else { "?" }, q.join("&")))?;
|
||||||
let auth = format!("Bearer {}", s.host_token);
|
let auth = format!("Bearer {}", s.host_token);
|
||||||
let r = if method == "GET" { agent(secs).get(&url).header("Authorization", &auth).call() } else { agent(secs).post(&url).header("Authorization", &auth).header("Content-Type", "application/octet-stream").send(body) };
|
let r = if method == "GET" { agent(secs).get(&url).header("Authorization", &auth).call() } else { agent(secs).post(&url).header("Authorization", &auth).header("Content-Type", "application/octet-stream").send(body) };
|
||||||
|
|
@ -1276,15 +1272,6 @@ pub fn sync_file(s: &Settings, path: &str) -> Result<Vec<u8>, String> {
|
||||||
Ok(bytes)
|
Ok(bytes)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn url_escape(s: &str) -> String {
|
|
||||||
s.bytes()
|
|
||||||
.map(|b| match b {
|
|
||||||
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => (b as char).to_string(),
|
|
||||||
_ => format!("%{b:02X}"),
|
|
||||||
})
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A notification on the person's phones (through the relay; phones that turned them on).
|
/// A notification on the person's phones (through the relay; phones that turned them on).
|
||||||
pub fn notify(title: &str, body: &str, bot: u64) {
|
pub fn notify(title: &str, body: &str, bot: u64) {
|
||||||
let s = hub().settings();
|
let s = hub().settings();
|
||||||
|
|
|
||||||
|
|
@ -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");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -228,15 +228,8 @@ pub fn run(url: &str, token: &str, alive: &dyn Fn() -> bool, status: &dyn Fn(&st
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
/// How long a turn of the connection's loop waits for something to read before sending what is
|
|
||||||
/// queued: short while things are moving (a live screen), longer when quiet. A fixed 40 ms
|
|
||||||
/// added up to 40 ms to every message sent, on each side.
|
|
||||||
const READ_WAIT_BUSY: Duration = Duration::from_millis(2);
|
|
||||||
const READ_WAIT_QUIET: Duration = Duration::from_millis(40);
|
|
||||||
|
|
||||||
fn keep(mut ws: WebSocket<Stream>, serial: u64, alive: &dyn Fn() -> bool) {
|
fn keep(mut ws: WebSocket<Stream>, serial: u64, alive: &dyn Fn() -> bool) {
|
||||||
let _ = ws.get_ref().tcp().set_read_timeout(Some(READ_WAIT_QUIET));
|
let mut pace = wire::Pace::new();
|
||||||
let (mut moved, mut busy) = (Instant::now() - Duration::from_secs(60), false);
|
|
||||||
let (tx, rx) = channel::<Vec<u8>>();
|
let (tx, rx) = channel::<Vec<u8>>();
|
||||||
let calls: Calls = Arc::default();
|
let calls: Calls = Arc::default();
|
||||||
*link().lock().unwrap() = Some(Link { tx: tx.clone(), calls: calls.clone(), serial });
|
*link().lock().unwrap() = Some(Link { tx: tx.clone(), calls: calls.clone(), serial });
|
||||||
|
|
@ -244,15 +237,11 @@ fn keep(mut ws: WebSocket<Stream>, serial: u64, alive: &dyn Fn() -> bool) {
|
||||||
let _ = tx.send(wire::pack(&json!({"t": "hello", "brain": s.brain_id, "hands": s.device_link}), &[]));
|
let _ = tx.send(wire::pack(&json!({"t": "hello", "brain": s.brain_id, "hands": s.device_link}), &[]));
|
||||||
let (mut heard, mut pinged) = (Instant::now(), Instant::now());
|
let (mut heard, mut pinged) = (Instant::now(), Instant::now());
|
||||||
'live: while alive() {
|
'live: while alive() {
|
||||||
let now_busy = moved.elapsed() < Duration::from_secs(2);
|
pace.tune(ws.get_ref().tcp());
|
||||||
if now_busy != busy {
|
|
||||||
busy = now_busy;
|
|
||||||
let _ = ws.get_ref().tcp().set_read_timeout(Some(if busy { READ_WAIT_BUSY } else { READ_WAIT_QUIET }));
|
|
||||||
}
|
|
||||||
match ws.read() {
|
match ws.read() {
|
||||||
Ok(Message::Binary(b)) => {
|
Ok(Message::Binary(b)) => {
|
||||||
heard = Instant::now();
|
heard = Instant::now();
|
||||||
moved = heard;
|
pace.moved();
|
||||||
if let Some((head, body)) = wire::unpack(&b) {
|
if let Some((head, body)) = wire::unpack(&b) {
|
||||||
// The relay says this machine was unregistered (signed out): stop for good.
|
// The relay says this machine was unregistered (signed out): stop for good.
|
||||||
if head["t"] == "bye" {
|
if head["t"] == "bye" {
|
||||||
|
|
@ -267,7 +256,7 @@ fn keep(mut ws: WebSocket<Stream>, serial: u64, alive: &dyn Fn() -> bool) {
|
||||||
Err(_) => break,
|
Err(_) => break,
|
||||||
}
|
}
|
||||||
while let Ok(f) = rx.try_recv() {
|
while let Ok(f) = rx.try_recv() {
|
||||||
moved = Instant::now();
|
pace.moved();
|
||||||
if ws.write(Message::Binary(f.into())).is_err() {
|
if ws.write(Message::Binary(f.into())).is_err() {
|
||||||
break 'live;
|
break 'live;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
71
src/vault.rs
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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 })
|
||||||
}
|
}
|
||||||
|
|
|
||||||
19
src/voice.rs
|
|
@ -196,11 +196,6 @@ impl Recording {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// multipart/form-data body for /audio/transcriptions.
|
|
||||||
#[allow(dead_code)]
|
|
||||||
pub fn multipart(model: &str, file_name: &str, audio: &[u8], boundary: &str) -> Vec<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"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
26
src/wire.rs
|
|
@ -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]
|
||||||
|
|
|
||||||