多台電腦(大腦、手腳、螢幕)、一行加電腦、文件與 make #2

Merged
daniel.w merged 9 commits from daniel.w/multi-device into main 2026-11-04 14:40:38 +00:00
8 changed files with 735 additions and 576 deletions
Showing only changes of commit 365dc87e54 - Show all commits

View File

@ -16,7 +16,7 @@ if [ -t 1 ]; then
else else
B=""; D=""; A=""; G=""; Y=""; R=""; N="" B=""; D=""; A=""; G=""; Y=""; R=""; N=""
fi fi
STEPS=("檢查" "網址" "帳號" "金鑰" "啟動") STEPS=("檢查" "網址" "帳號" "啟動")
AT=0 AT=0
header() { header() {
@ -36,7 +36,7 @@ card_open() { printf ' %s╭─%s %s%s%s\n %s│%s\n' "$D" "$N" "$B" "$1" "$N"
line() { printf ' %s│%s %s\n' "$D" "$N" "$*"; } line() { printf ' %s│%s %s\n' "$D" "$N" "$*"; }
note() { printf ' %s│%s %s%s%s\n' "$D" "$N" "$D" "$*" "$N"; } note() { printf ' %s│%s %s%s%s\n' "$D" "$N" "$D" "$*" "$N"; }
ok() { printf ' %s│%s %s✓%s %s\n' "$D" "$N" "$G" "$N" "$*"; } ok() { printf ' %s│%s %s✓%s %s\n' "$D" "$N" "$G" "$N" "$*"; }
warn() { printf ' %s│%s %s!%s %s\n' "$D" "$N" "$Y" "$N" "$*"; } warn() { WARNED=1; printf ' %s│%s %s!%s %s\n' "$D" "$N" "$Y" "$N" "$*"; }
bad() { printf ' %s│%s %s✗ %s%s\n' "$D" "$N" "$R" "$*" "$N"; } bad() { printf ' %s│%s %s✗ %s%s\n' "$D" "$N" "$R" "$*" "$N"; }
card_close() { printf ' %s│%s\n %s╰────────────────────────────────────────────────%s\n' "$D" "$N" "$D" "$N"; [ -n "${1:-}" ] && printf ' %s%s%s\n' "$D" "$1" "$N"; } card_close() { printf ' %s│%s\n %s╰────────────────────────────────────────────────%s\n' "$D" "$N" "$D" "$N"; [ -n "${1:-}" ] && printf ' %s%s%s\n' "$D" "$1" "$N"; }
@ -110,7 +110,7 @@ die() { card_close; printf '\n'; exit 1; }
# ---------- 0. this server ---------- # ---------- 0. this server ----------
AT=0; header; card_open "檢查這台伺服器" AT=0; header; card_open "檢查這台伺服器"
FAIL=0 FAIL=0; WARNED=0
if command -v docker >/dev/null; then ok "Docker 已安裝"; else bad "沒有 Docker:curl -fsSL https://get.docker.com | sh"; FAIL=1; fi if command -v docker >/dev/null; then ok "Docker 已安裝"; else bad "沒有 Docker:curl -fsSL https://get.docker.com | sh"; FAIL=1; fi
if docker compose version >/dev/null 2>&1; then ok "docker compose 可以用"; elif [ $FAIL = 0 ]; then bad "沒有 docker compose(Docker 太舊?請更新 Docker)"; FAIL=1; fi if docker compose version >/dev/null 2>&1; then ok "docker compose 可以用"; elif [ $FAIL = 0 ]; then bad "沒有 docker compose(Docker 太舊?請更新 Docker)"; FAIL=1; fi
if [ $FAIL = 0 ] && ! docker info >/dev/null 2>&1; then bad "沒有權限使用 Docker:sudo usermod -aG docker \$USER,登出再登入"; FAIL=1; fi if [ $FAIL = 0 ] && ! docker info >/dev/null 2>&1; then bad "沒有權限使用 Docker:sudo usermod -aG docker \$USER,登出再登入"; FAIL=1; fi
@ -123,7 +123,8 @@ mem=$(awk '/MemTotal/ {print int($2/1024/1024+0.5)}' /proc/meminfo 2>/dev/null |
disk=$(df -Pk . 2>/dev/null | awk 'NR==2 {print int($4/1024/1024)}') disk=$(df -Pk . 2>/dev/null | awk 'NR==2 {print int($4/1024/1024)}')
[ "${disk:-0}" -ge 10 ] && ok "硬碟還有 ${disk} GB" || warn "硬碟只剩 ${disk:-?} GB(建議 10 GB 以上)" [ "${disk:-0}" -ge 10 ] && ok "硬碟還有 ${disk} GB" || warn "硬碟只剩 ${disk:-?} GB(建議 10 GB 以上)"
if [ $FAIL = 1 ] && [ $DRY = 0 ]; then note ""; note "修好上面的問題後再執行一次 ./setup.sh"; die; fi if [ $FAIL = 1 ] && [ $DRY = 0 ]; then note ""; note "修好上面的問題後再執行一次 ./setup.sh"; die; fi
card_close; pause # All fine: go on by itself.
if [ $WARNED = 1 ]; then card_close; pause; else card_close; sleep 1; fi
# Keep the current settings? # Keep the current settings?
if [ -f .env ] && [ -f Caddyfile ]; then if [ -f .env ] && [ -f Caddyfile ]; then
@ -134,7 +135,7 @@ if [ -f .env ] && [ -f Caddyfile ]; then
"重新設定|網址、帳號或金鑰要改" "重新設定|網址、帳號或金鑰要改"
if [ "$KEEP" = 0 ]; then if [ "$KEEP" = 0 ]; then
DOMAIN=${ANYBOT_RELAY_PUBLIC_URL#https://}; USERNAME=$ANYBOT_USER; INVITE=${ANYBOT_RELAY_INVITE:-}; BRAIN=${ANYBOT_HOST_NAME:-} DOMAIN=${ANYBOT_RELAY_PUBLIC_URL#https://}; USERNAME=$ANYBOT_USER; INVITE=${ANYBOT_RELAY_INVITE:-}; BRAIN=${ANYBOT_HOST_NAME:-}
AT=4; SKIP=1 AT=3; SKIP=1
fi fi
fi fi
@ -177,41 +178,12 @@ if [ "${SKIP:-0}" != 1 ]; then
if [ "${#PW1}" -lt 8 ]; then MSG=$(bad "密碼至少 8 個字"); continue; fi if [ "${#PW1}" -lt 8 ]; then MSG=$(bad "密碼至少 8 個字"); continue; fi
if [ "$PW1" != "$PW2" ]; then MSG=$(bad "兩次密碼不一樣"); continue; fi if [ "$PW1" != "$PW2" ]; then MSG=$(bad "兩次密碼不一樣"); continue; fi
case "$PW1" in *"'"*) MSG=$(bad "密碼裡不能有單引號 '"); continue ;; esac case "$PW1" in *"'"*) MSG=$(bad "密碼裡不能有單引號 '"); continue ;; esac
ask BRAIN "大腦名字" "${BRAIN:-雲端大腦}" BRAIN="雲端大腦"
[ -z "$BRAIN" ] && BRAIN="雲端大腦"
break break
done done
choose OTHERS "別人可以在這台註冊帳號嗎?" \ # Only this account (others can be let in later: ANYBOT_RELAY_INVITE in .env). AI keys are set
"只有我|最安全" \ # in the app (設定 › 模型); they stay on the brain.
"給邀請碼的人可以|家人或同事拿邀請碼註冊,各自有自己的帳號和 Bot" INVITE=""; XAI=""; ANT=""; OAI=""; GEM=""; RESTORE=""
INVITE=""
[ "$OTHERS" = 1 ] && INVITE=$(LC_ALL=C tr -dc 'a-z0-9' </dev/urandom | head -c 10)
# ---------- 3. keys ----------
AT=3
choose KEYS "AI 模型的金鑰" \
"之後在 App 裡設定|在手機或電腦的 AnyBot › 設定 › 模型 填,金鑰只存在大腦上" \
"現在輸入|xAI、Anthropic、OpenAI、Gemini,有哪個填哪個" \
"從我電腦的備份接手|帳號已經存在、電腦做過「備份到帳號」時:Bot、聊天、排程和金鑰一起搬過來"
XAI=""; ANT=""; OAI=""; GEM=""; RESTORE=""
if [ "$KEYS" = 1 ]; then
header; card_open "AI 模型的金鑰(沒有的直接按 Enter 跳過)"; line ""
ask XAI "xAI" "" secret; ask ANT "Anthropic" "" secret; ask OAI "OpenAI" "" secret; ask GEM "Gemini" "" secret
card_close
elif [ "$KEYS" = 2 ]; then RESTORE=1; fi
# ---------- confirm ----------
AT=4
header; card_open "確認一下"
line "${D}網址 ${N}${B}https://$DOMAIN${N}"
line "${D}帳號 ${N}${B}$USERNAME${N}"
line "${D}大腦 ${N}${B}$BRAIN${N}"
line "${D}註冊 ${N}${B}$([ -n "$INVITE" ] && echo "邀請碼 $INVITE" || echo "只有我")${N}"
line "${D}金鑰 ${N}${B}$(case $KEYS in 0) echo "之後在 App 裡設定" ;; 1) n=0; for k in "$XAI" "$ANT" "$OAI" "$GEM"; do [ -n "$k" ] && n=$((n + 1)); done; echo "$n 個" ;; 2) echo "從電腦的備份接手" ;; esac)${N}"
line ""
note "設定會寫進 $(pwd)/.env(只有你看得到)和 Caddyfile。"
card_close "Enter 開始 Ctrl+C 取消"
IFS= read -r _
q() { printf "%s='%s'\n" "$1" "$2"; } q() { printf "%s='%s'\n" "$1" "$2"; }
umask 077 umask 077
@ -234,7 +206,7 @@ if [ "${SKIP:-0}" != 1 ]; then
fi fi
# ---------- 4. start ---------- # ---------- 4. start ----------
AT=4; header; card_open "啟動" AT=3; header; card_open "啟動"
ok "寫好設定(.env、Caddyfile)" ok "寫好設定(.env、Caddyfile)"
if [ $DRY = 1 ]; then if [ $DRY = 1 ]; then
warn "試跑(--dry-run):沒有啟動。要啟動:docker compose up -d --build" warn "試跑(--dry-run):沒有啟動。要啟動:docker compose up -d --build"
@ -274,8 +246,8 @@ printf '\n'
card_open "$([ -n "$joined" ] && echo "完成了" || echo "差一點")" card_open "$([ -n "$joined" ] && echo "完成了" || echo "差一點")"
line "${B}手機${N} 打開 ${A}https://$DOMAIN${N},用 ${B}$USERNAME${N} 登入,可以「加入主畫面」" line "${B}手機${N} 打開 ${A}https://$DOMAIN${N},用 ${B}$USERNAME${N} 登入,可以「加入主畫面」"
line "${B}電腦${N} AnyBot › 設定 › 手機與帳號 登入,在「大腦」選「$BRAIN」" line "${B}電腦${N} AnyBot › 設定 › 手機與帳號 登入,在「大腦」選「$BRAIN」"
line "${B}Linux 手腳${N} anybot setup(選「手腳」,再選「$BRAIN」)" line "${B}AI 金鑰${N} 手機或電腦的 AnyBot › 設定 › 模型(只存在大腦上)"
[ -n "${INVITE:-}" ] && line "${B}邀請碼${N} $INVITE(給要註冊的人)" line "${B}加一台電腦${N} 手機 AnyBot 右上 ⋯ › 加一台電腦,把那一行貼到那台的終端機"
line "" line ""
note "看紀錄 docker compose logs -f anybot" note "看紀錄 docker compose logs -f anybot"
note "更新 ./setup.sh(選「用現有設定」)" note "更新 ./setup.sh(選「用現有設定」)"

View File

@ -132,6 +132,11 @@ header .sub{font-size:12px;color:var(--text3);font-weight:400}
.sheet button{display:block;width:100%;text-align:left;padding:14px 16px;border-radius:12px;font-size:16px} .sheet button{display:block;width:100%;text-align:left;padding:14px 16px;border-radius:12px;font-size:16px}
.sheet button:active{background:var(--raise)} .sheet button:active{background:var(--raise)}
.sheet .no{color:var(--danger)} .sheet .no{color:var(--danger)}
.pair{padding:18px 16px calc(env(safe-area-inset-bottom) + 12px)}
.pair-t{font-size:18px;font-weight:600;margin:2px 4px 6px}
.pair-code{font:600 34px/1.2 ui-monospace,Menlo,monospace;letter-spacing:6px;text-align:center;margin:16px 0 10px}
.pair-line{display:block;font:13px/1.5 ui-monospace,Menlo,monospace;background:var(--raise);border-radius:10px;padding:10px 12px;word-break:break-all;margin-bottom:12px;user-select:all}
.sheet .pair-copy,.pair .btn.main{text-align:center;padding:13px}
.sheet .note{color:var(--text3);font-size:12.5px;padding:4px 16px 8px} .sheet .note{color:var(--text3);font-size:12.5px;padding:4px 16px 8px}
.toast{position:fixed;left:50%;bottom:calc(env(safe-area-inset-bottom) + 90px);transform:translateX(-50%);background:var(--text);color:var(--bg);padding:9px 16px;border-radius:12px;font-size:14px;opacity:0;transition:opacity .2s;pointer-events:none;max-width:88vw;text-align:center} .toast{position:fixed;left:50%;bottom:calc(env(safe-area-inset-bottom) + 90px);transform:translateX(-50%);background:var(--text);color:var(--bg);padding:9px 16px;border-radius:12px;font-size:14px;opacity:0;transition:opacity .2s;pointer-events:none;max-width:88vw;text-align:center}
.toast.on{opacity:.95} .toast.on{opacity:.95}
@ -217,11 +222,22 @@ header .sub{font-size:12px;color:var(--text3);font-weight:400}
<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-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>
<button id="m-cancel">取消</button> <button id="m-cancel">取消</button>
</div></div> </div></div>
<div class="sheet-bg" id="pair"><div class="sheet pair">
<div class="pair-t">加一台電腦</div>
<div class="note">在那台電腦的終端機貼上這一行執行,它就會變成「<b id="pair-for"></b>」的手腳,開機自動執行。不用帳號密碼。</div>
<div class="pair-code" id="pair-code">······</div>
<code class="pair-line" id="pair-line"></code>
<button class="btn main" id="pair-copy">複製這一行</button>
<div class="note" id="pair-left"></div>
<button id="pair-close">關閉</button>
</div></div>
<script> <script>
"use strict"; "use strict";
// Lucide icons (ISC licence), the same set as the app. // Lucide icons (ISC licence), the same set as the app.
@ -526,11 +542,38 @@ $("typein").addEventListener("keydown", (e) => { if (e.key === "Enter" && !e.isC
$("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("正在叫醒 Bot 的電腦…"); input2({kind: "wake"}); }; $("k-wake").onclick = () => { toast("正在叫醒 Bot 的電腦…"); input2({kind: "wake"}); };
// ---------- menu, notifications ---------- // ---------- menu, notifications ----------
$("b-more").onclick = () => { $("m-host").style.display = S.relay ? "" : "none"; notifyLabel(); $("sheet").classList.add("on"); }; $("b-more").onclick = () => { $("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(); };
$("m-host").onclick = () => { $("sheet").classList.remove("on"); S.host = ""; localStorage.removeItem("anybot.host"); S.version = 0; pickHost(); }; $("m-host").onclick = () => { $("sheet").classList.remove("on"); S.host = ""; localStorage.removeItem("anybot.host"); S.version = 0; pickHost(); };
// 加一台電腦: a code for a new computer (ten minutes, once), as hands for this AnyBot.
$("m-pair").onclick = async () => {
$("sheet").classList.remove("on");
$("pair-for").textContent = $("host-name").textContent || "這台 AnyBot";
$("pair-code").textContent = "······"; $("pair-line").textContent = "正在產生…"; $("pair-left").textContent = "";
$("pair").classList.add("on");
try {
const v = await api("relay/pair", {json: {for: S.host}});
const line = `anybot setup ${location.origin} ${v.code}`;
$("pair-code").textContent = v.code.slice(0, 3) + " " + v.code.slice(3);
$("pair-line").textContent = line;
const end = Date.now() + (v.expires_in || 600) * 1000;
clearInterval(S.pairTimer);
S.pairTimer = setInterval(() => {
const left = Math.max(0, Math.round((end - Date.now()) / 1000));
$("pair-left").textContent = left ? `還有 ${Math.floor(left / 60)} 分 ${String(left % 60).padStart(2, "0")} 秒,只能用一次` : "過期了,關掉再按一次「加一台電腦」";
if (!left) clearInterval(S.pairTimer);
}, 1000);
} catch (e) { $("pair-line").textContent = "沒有拿到配對碼:" + e.message; }
};
$("pair-copy").onclick = async () => {
const line = $("pair-line").textContent;
if (!line.startsWith("anybot")) return;
try { await navigator.clipboard.writeText(line); toast("已複製"); } catch (_) { toast("請長按上面那一行複製"); }
};
$("pair-close").onclick = () => { clearInterval(S.pairTimer); $("pair").classList.remove("on"); };
$("pair").onclick = (e) => { if (e.target.id === "pair") $("pair-close").click(); };
const pushOK = () => S.relay && "serviceWorker" in navigator && "PushManager" in window && "Notification" in window; const pushOK = () => S.relay && "serviceWorker" in navigator && "PushManager" in window && "Notification" in window;
async function notifyLabel() { async function notifyLabel() {
const b = $("m-notify"), n = $("m-note"); const b = $("m-notify"), n = $("m-note");

View File

@ -36,6 +36,8 @@ const ICON: &str = r##"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 96 9
const HOST_POLL_SECS: u64 = 25; const HOST_POLL_SECS: u64 = 25;
/// How long a phone's request waits for the AnyBot to answer (its own long-poll is 20 s). /// How long a phone's request waits for the AnyBot to answer (its own long-poll is 20 s).
const FORWARD_SECS: u64 = 35; const FORWARD_SECS: u64 = 35;
/// How long a 配對碼 lasts.
const PAIR_SECS: u64 = 600;
/// How long a request may wait to be picked up before the host counts as gone. /// How long a request may wait to be picked up before the host counts as gone.
const PICKUP_SECS: u64 = 8; const PICKUP_SECS: u64 = 8;
/// The lasting connection: a ping this often, and given up after this long without a word. /// The lasting connection: a ping this often, and given up after this long without a word.
@ -188,8 +190,11 @@ struct Relay {
/// AnyBots connected the lasting way (WebSocket), and which requests went over which. /// AnyBots connected the lasting way (WebSocket), and which requests went over which.
sockets: Mutex<HashMap<String, Sock>>, sockets: Mutex<HashMap<String, Sock>>,
flights: Mutex<HashMap<String, (String, u64)>>, flights: Mutex<HashMap<String, (String, u64)>>,
/// The brain each AnyBot shows (when it is a screen of another). /// 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>>,
/// 配對碼: code → (account, the AnyBot it is for, when made). Ten minutes, once.
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).
seen: Mutex<HashMap<String, Instant>>, seen: Mutex<HashMap<String, Instant>>,
fails: Mutex<HashMap<String, (u32, Instant)>>, fails: Mutex<HashMap<String, (u32, Instant)>>,
@ -216,6 +221,8 @@ impl Relay {
sockets: Mutex::new(HashMap::new()), sockets: Mutex::new(HashMap::new()),
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()),
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()),
invite: std::env::var("ANYBOT_RELAY_INVITE").unwrap_or_default(), invite: std::env::var("ANYBOT_RELAY_INVITE").unwrap_or_default(),
@ -451,6 +458,11 @@ impl Relay {
}); });
} }
"hello" => { "hello" => {
if head["hands"].as_bool().unwrap_or(false) {
self.hands.lock().unwrap().insert(h.id.clone());
} else {
self.hands.lock().unwrap().remove(&h.id);
}
let brain = s("brain"); let brain = s("brain");
let mut u = self.uses.lock().unwrap(); let mut u = self.uses.lock().unwrap();
if brain.is_empty() { if brain.is_empty() {
@ -548,19 +560,47 @@ impl Relay {
("POST", ["relay", "hosts"]) => { ("POST", ["relay", "hosts"]) => {
let Some(user) = self.session(&req) else { return Resp::error(401, "請先登入") }; let Some(user) = self.session(&req) else { return Resp::error(401, "請先登入") };
let name: String = req.json()["name"].as_str().unwrap_or("AnyBot").chars().take(60).collect(); let name: String = req.json()["name"].as_str().unwrap_or("AnyBot").chars().take(60).collect();
let token = httpd::random_hex(32); let (h, token) = self.add_host(&user, &name);
let h = Host { id: httpd::random_hex(8), name: name.clone(), user: user.clone(), token_hash: httpd::sha256_hex(&token), created: now(), last_seen: 0, members: vec![] };
let mut d = self.data.lock().unwrap();
// Signing the same computer in again replaces its old key (keeping who it's shared with).
let mut h = h;
if let Some(old) = d.hosts.iter().find(|x| x.user == user && x.name == name) {
h.members = old.members.clone();
}
d.hosts.retain(|x| !(x.user == user && x.name == name));
d.hosts.push(h.clone());
self.save(&d);
Resp::json(&json!({"id": h.id, "token": token})) Resp::json(&json!({"id": h.id, "token": token}))
} }
// 加一台電腦: a signed-in phone or AnyBot asks for a code; the new computer gives it back.
("POST", ["relay", "pair"]) => {
let Some(user) = self.session(&req).or_else(|| self.host(&req).map(|h| h.user)) else { return Resp::error(401, "請先登入") };
let want = req.json()["for"].as_str().unwrap_or("").to_string();
let for_host = if self.data.lock().unwrap().hosts.iter().any(|h| h.id == want && h.user == user) { want } else { String::new() };
let mut pairs = self.pairs.lock().unwrap();
pairs.retain(|_, p| p.2.elapsed().as_secs() < PAIR_SECS);
let code = loop {
let n = u32::from_str_radix(&httpd::random_hex(4), 16).unwrap_or(0) % 1_000_000;
let c = format!("{n:06}");
if !pairs.contains_key(&c) {
break c;
}
};
pairs.insert(code.clone(), (user, for_host, Instant::now()));
Resp::json(&json!({"code": code, "expires_in": PAIR_SECS}))
}
("POST", ["relay", "pair", "claim"]) => {
let key = format!("pair:{}", req.peer);
if self.too_many_fails(&key) {
return Resp::error(429, "配對碼錯太多次,請 5 分鐘後再試");
}
let v = req.json();
let code: String = v["code"].as_str().unwrap_or("").chars().filter(|c| c.is_ascii_digit()).collect();
let got = {
let mut pairs = self.pairs.lock().unwrap();
pairs.retain(|_, p| p.2.elapsed().as_secs() < PAIR_SECS);
pairs.remove(&code)
};
let Some((user, for_host, _)) = got else {
self.failed(&key);
return Resp::error(404, "配對碼不對,或已經過期(10 分鐘內有效,只能用一次)");
};
let name: String = v["name"].as_str().unwrap_or("AnyBot").chars().take(60).collect();
let (h, token) = self.add_host(&user, &name);
let for_name = self.data.lock().unwrap().hosts.iter().find(|x| x.id == for_host).map(|x| x.name.clone()).unwrap_or_default();
Resp::json(&json!({"id": h.id, "token": token, "user": user, "for": for_host, "for_name": for_name}))
}
("POST", ["relay", "hosts", id, "remove"]) => { ("POST", ["relay", "hosts", id, "remove"]) => {
let Some(user) = self.session(&req) else { return Resp::error(401, "請先登入") }; let Some(user) = self.session(&req) else { return Resp::error(401, "請先登入") };
let mut d = self.data.lock().unwrap(); let mut d = self.data.lock().unwrap();
@ -735,9 +775,24 @@ impl Relay {
} }
} }
/// Register an AnyBot of `user`. The same name again replaces its old key (keeping who it's
/// shared with).
fn add_host(&self, user: &str, name: &str) -> (Host, String) {
let token = httpd::random_hex(32);
let mut h = Host { id: httpd::random_hex(8), name: name.to_string(), user: user.to_string(), token_hash: httpd::sha256_hex(&token), created: now(), last_seen: 0, members: vec![] };
let mut d = self.data.lock().unwrap();
if let Some(old) = d.hosts.iter().find(|x| x.user == user && x.name == name) {
h.members = old.members.clone();
}
d.hosts.retain(|x| !(x.user == user && x.name == name));
d.hosts.push(h.clone());
self.save(&d);
(h, token)
}
fn host_json(&self, h: &Host) -> Value { fn host_json(&self, h: &Host) -> Value {
let online = self.online(&h.id); let online = self.online(&h.id);
json!({"id": h.id, "name": h.name, "online": online, "uses": self.uses.lock().unwrap().get(&h.id).cloned().unwrap_or_default(), "last_seen_text": if online { "在線上".to_string() } else if h.last_seen > 0 { chrono::DateTime::from_timestamp(h.last_seen, 0).map(|t| t.format("%m/%d %H:%M UTC").to_string()).unwrap_or_default() } else { "從未連線".into() }}) json!({"id": h.id, "name": h.name, "online": online, "uses": self.uses.lock().unwrap().get(&h.id).cloned().unwrap_or_default(), "hands": self.hands.lock().unwrap().contains(&h.id), "last_seen_text": if online { "在線上".to_string() } else if h.last_seen > 0 { chrono::DateTime::from_timestamp(h.last_seen, 0).map(|t| t.format("%m/%d %H:%M UTC").to_string()).unwrap_or_default() } else { "從未連線".into() }})
} }
fn signup(&self, req: &Req) -> Resp { fn signup(&self, req: &Req) -> Resp {
@ -979,4 +1034,41 @@ mod tests {
assert_eq!(post(port, "/relay/host/sites/Bad_Name", &ht, json!({})).0, 400); assert_eq!(post(port, "/relay/host/sites/Bad_Name", &ht, json!({})).0, 400);
let _ = std::fs::remove_dir_all(&dir); let _ = std::fs::remove_dir_all(&dir);
} }
#[test]
fn a_pairing_code_adds_a_computer_once() {
let dir = std::env::temp_dir().join(format!("anybot-relay-pair-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let relay = Arc::new(Relay::load(dir.clone()));
let l = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let port = l.local_addr().unwrap().port();
let r = relay.clone();
std::thread::spawn(move || httpd::serve(l, Arc::new(move |req| r.handle(req)), Arc::new(|| true)));
post(port, "/relay/signup", "", json!({"user": "amy", "password": "12345678"}));
let (_, v) = post(port, "/relay/login", "", json!({"user": "amy", "password": "12345678", "device": "phone"}));
let session = v["token"].as_str().unwrap().to_string();
let (_, brain) = post(port, "/relay/hosts", &session, json!({"name": "雲端大腦"}));
// Asked from the phone, for the brain.
let (st, p) = post(port, "/relay/pair", &session, json!({"for": brain["id"]}));
assert_eq!(st, 200);
let code = p["code"].as_str().unwrap().to_string();
assert_eq!(code.len(), 6);
// Without signing in: no code.
assert_eq!(post(port, "/relay/pair", "", json!({})).0, 401);
let (st, h) = post(port, "/relay/pair/claim", "", json!({"code": format!("{} {}", &code[..3], &code[3..]), "name": "書房電腦"}));
assert_eq!(st, 200, "{h}");
assert_eq!(h["user"], "amy");
assert_eq!(h["for_name"], "雲端大腦");
// The new computer's key works.
let (st, peers) = get(port, "/relay/host/peers", h["token"].as_str().unwrap());
assert_eq!(st, 200);
assert!(String::from_utf8_lossy(&peers).contains("雲端大腦"));
// Once only; a wrong code is refused, and guessing is stopped.
assert_eq!(post(port, "/relay/pair/claim", "", json!({"code": code, "name": "x"})).0, 404);
for _ in 0..10 {
post(port, "/relay/pair/claim", "", json!({"code": "000000", "name": "x"}));
}
assert_eq!(post(port, "/relay/pair/claim", "", json!({"code": "000000", "name": "x"})).0, 429);
let _ = std::fs::remove_dir_all(&dir);
}
} }

View File

@ -389,6 +389,8 @@ pub struct Mirror {
down_since: Option<Instant>, down_since: Option<Instant>,
/// Files from the brain being fetched (by local path). /// Files from the brain being fetched (by local path).
fetching: std::collections::HashSet<String>, fetching: std::collections::HashSet<String>,
/// 加一台電腦: the line to run on the new computer (or why there is none), and when it was made.
pub(super) pair: Arc<Mutex<(String, bool, Option<Instant>)>>,
} }
/// Where a screen keeps its copy of a file the brain has. /// Where a screen keeps its copy of a file the brain has.
@ -726,7 +728,7 @@ impl App {
*self.mir.status.lock().unwrap() = "正在連到大腦…".into(); *self.mir.status.lock().unwrap() = "正在連到大腦…".into();
self.mirror_worker(); self.mirror_worker();
self.mirror_outbox(); self.mirror_outbox();
crate::tunnel::hello(&self.state.remote.brain_id); crate::tunnel::hello(&self.state.remote.brain_id, self.state.remote.device_link);
} }
/// Back to this computer's own bots. /// Back to this computer's own bots.
@ -758,7 +760,7 @@ impl App {
} }
self.state.save(); self.state.save();
self.dirty = false; self.dirty = false;
crate::tunnel::hello(""); crate::tunnel::hello("", self.state.remote.device_link);
} }
fn keep_local_fields(&mut self, from: &State) { fn keep_local_fields(&mut self, from: &State) {

View File

@ -145,6 +145,62 @@ impl App {
} }
} }
/// 加一台電腦: one line to run on the new computer (a Linux box, a server); it joins as hands for
/// the brain, with no password and nothing to answer.
fn pair_section(&mut self, ui: &mut egui::Ui) {
let p = self.pal;
ui.add_space(6.0);
ui.separator();
ui.horizontal(|ui| {
ui.label(RichText::new(t("加一台電腦")).size(13.5).strong());
help_mark(ui, "讓另一台電腦(例如沒有螢幕的 Linux)也當大腦的手腳:在那台的終端機貼上這一行就好,不用帳號密碼、不用回答問題。它會設成開機自動執行,只讓這個大腦使用。", p);
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
if ui.add(secondary_button("產生配對碼")).clicked() {
let (s, slot) = (self.state.remote.clone(), self.mir.pair.clone());
let for_host = if self.mir.on { s.brain_id.clone() } else { s.host_id.clone() };
*slot.lock().unwrap() = (t("正在產生…"), false, None);
std::thread::spawn(move || {
*slot.lock().unwrap() = match crate::remote::pair_code(&s, &for_host) {
Ok(code) => (format!("anybot setup {} {code}", s.relay_url), true, Some(Instant::now())),
Err(e) => (e, false, None),
};
});
}
});
});
let (line, ok, at) = self.mir.pair.lock().unwrap().clone();
if line.is_empty() {
return;
}
let left = at.map(|t| 600u64.saturating_sub(t.elapsed().as_secs())).unwrap_or(0);
if ok && left == 0 {
hint(ui, "配對碼過期了,再按一次「產生配對碼」。", p);
return;
}
if !ok {
let c = if line.ends_with('…') { p.text2 } else { p.danger };
ui.label(RichText::new(line).size(12.5).color(c));
return;
}
Frame::new().fill(p.hover).corner_radius(8).inner_margin(Margin::symmetric(10, 8)).show(ui, |ui| {
ui.horizontal(|ui| {
ui.add(egui::Label::new(RichText::new(&line).monospace().size(13.0)).selectable(true).wrap());
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
if ui.add(secondary_button("複製")).clicked() {
ui.ctx().copy_text(line.clone());
self.notice = Some(t("已複製,貼到那台電腦的終端機執行"));
}
});
});
});
ui.label(RichText::new(format!("{}(還有 {} 分 {:02} 秒,只能用一次)", t("在那台電腦的終端機貼上執行"), left / 60, left % 60)).size(12.0).color(p.text3));
// Just made: bring it into view.
if at.map(|t| t.elapsed().as_millis() < 800).unwrap_or(false) {
ui.scroll_to_cursor(Some(Align::Max));
}
ui.ctx().request_repaint_after(std::time::Duration::from_secs(1));
}
fn remote_signature(&self) -> u64 { fn remote_signature(&self) -> u64 {
let mut h = std::collections::hash_map::DefaultHasher::new(); let mut h = std::collections::hash_map::DefaultHasher::new();
self.state.remote.host_name.hash(&mut h); self.state.remote.host_name.hash(&mut h);
@ -441,9 +497,9 @@ impl App {
} else { } else {
ui.label(RichText::new(t("這台就是大腦:Bot 在這台電腦上思考和工作。")).size(13.0).color(p.text2)); ui.label(RichText::new(t("這台就是大腦:Bot 在這台電腦上思考和工作。")).size(13.0).color(p.text2));
let me = s.host_id.clone(); let me = s.host_id.clone();
let peers: Vec<(String, String, bool)> = self.rm.peers.lock().unwrap().iter().filter(|(id, _, _)| !id.is_empty() && *id != me).cloned().collect(); let peers: Vec<(String, String, bool)> = self.rm.peers.lock().unwrap().iter().filter(|(id, _, _)| !id.is_empty() && *id != me && !remote::is_hands(id)).cloned().collect();
if peers.is_empty() { if peers.is_empty() {
hint(ui, "帳號裡還沒有其他 AnyBot。在伺服器上啟動 anybot --serve 並登入同一個帳號,它就會出現在這裡。", p); hint(ui, "帳號裡還沒有別台可以當大腦的 AnyBot。", p);
} }
for (id, name, online) in peers { for (id, name, online) in peers {
ui.horizontal(|ui| { ui.horizontal(|ui| {
@ -461,6 +517,7 @@ impl App {
}); });
} }
} }
self.pair_section(ui);
}); });
} }

View File

@ -1090,6 +1090,32 @@ pub fn set_member(s: &Settings, user: &str, role: &str) -> Result<(), String> {
} }
/// The other AnyBots signed in to this account (for 裝置連結). /// The other AnyBots signed in to this account (for 裝置連結).
/// 加一台電腦: a code (ten minutes, once) another computer uses to join this account, as hands for
/// `for_host` (empty = any AnyBot of the account).
pub fn pair_code(s: &Settings, for_host: &str) -> Result<String, String> {
let v = relay_post(&format!("{}/relay/pair", s.relay_url), &s.host_token, &json!({"for": for_host}))?;
v["code"].as_str().map(String::from).ok_or("帳號伺服器沒有給配對碼".into())
}
/// Join the account with a 配對碼 (no password). Gives the settings to keep.
pub fn pair_claim(base_settings: &Settings, url: &str, code: &str) -> Result<Settings, String> {
let url = check_server(url)?;
let v = relay_post(&format!("{url}/relay/pair/claim"), "", &json!({"code": code, "name": base_settings.host_name}))?;
let mut s = base_settings.clone();
s.relay_url = url;
s.relay_user = v["user"].as_str().unwrap_or("").to_string();
s.host_id = v["id"].as_str().unwrap_or("").to_string();
s.host_token = v["token"].as_str().unwrap_or("").to_string();
// No password here, so no key for backups (hands keep nothing to back up).
s.backup_key.clear();
s.hands_for = v["for"].as_str().unwrap_or("").to_string();
s.hands_for_name = v["for_name"].as_str().unwrap_or("").to_string();
if s.host_id.is_empty() || s.host_token.is_empty() {
return Err("帳號伺服器沒有給這台電腦的金鑰".into());
}
Ok(s)
}
/// The account's AnyBots as the account server sees them (for choosing a brain). /// The account's AnyBots as the account server sees them (for choosing a brain).
pub fn peers(s: &Settings) -> Result<Vec<Value>, String> { pub fn peers(s: &Settings) -> Result<Vec<Value>, String> {
let mut r = agent(15).get(&format!("{}/relay/host/peers", s.relay_url)).header("Authorization", &format!("Bearer {}", s.host_token)).call().map_err(|e| format!("連不到帳號伺服器:{}", why_unreachable(&e)))?; let mut r = agent(15).get(&format!("{}/relay/host/peers", s.relay_url)).header("Authorization", &format!("Bearer {}", s.host_token)).call().map_err(|e| format!("連不到帳號伺服器:{}", why_unreachable(&e)))?;
@ -1120,12 +1146,23 @@ pub fn check_server(url: &str) -> Result<String, String> {
Ok(u) Ok(u)
} }
/// AnyBots of the account that lend their hands (not offered as a brain).
pub fn is_hands(id: &str) -> bool {
hands_ids().lock().unwrap().contains(id)
}
fn hands_ids() -> &'static Mutex<std::collections::HashSet<String>> {
static H: OnceLock<Mutex<std::collections::HashSet<String>>> = OnceLock::new();
H.get_or_init(Mutex::default)
}
pub fn devices_online(s: &Settings) -> Vec<(String, String, bool)> { pub fn devices_online(s: &Settings) -> Vec<(String, String, bool)> {
if s.relay_url.is_empty() { if s.relay_url.is_empty() {
return vec![]; return vec![];
} }
let Ok(mut r) = agent(10).get(&format!("{}/relay/host/peers", s.relay_url)).header("Authorization", &format!("Bearer {}", s.host_token)).call() else { return vec![] }; let Ok(mut r) = agent(10).get(&format!("{}/relay/host/peers", s.relay_url)).header("Authorization", &format!("Bearer {}", s.host_token)).call() else { return vec![] };
let v: Value = serde_json::from_str(&r.body_mut().read_to_string().unwrap_or_default()).unwrap_or(Value::Null); let v: Value = serde_json::from_str(&r.body_mut().read_to_string().unwrap_or_default()).unwrap_or(Value::Null);
*hands_ids().lock().unwrap() = v["hosts"].as_array().into_iter().flatten().filter(|h| h["hands"].as_bool() == Some(true) || !h["uses"].as_str().unwrap_or("").is_empty()).filter_map(|h| h["id"].as_str().map(String::from)).collect();
v["hosts"].as_array().map(|a| a.iter().map(|h| (h["id"].as_str().unwrap_or("").to_string(), h["name"].as_str().unwrap_or("").to_string(), h["online"].as_bool().unwrap_or(false))).collect()).unwrap_or_default() v["hosts"].as_array().map(|a| a.iter().map(|h| (h["id"].as_str().unwrap_or("").to_string(), h["name"].as_str().unwrap_or("").to_string(), h["online"].as_bool().unwrap_or(false))).collect()).unwrap_or_default()
} }

File diff suppressed because it is too large Load Diff

View File

@ -230,7 +230,8 @@ fn keep(mut ws: WebSocket<Stream>, serial: u64, alive: &dyn Fn() -> bool) {
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 });
let _ = tx.send(wire::pack(&json!({"t": "hello", "brain": crate::remote::hub().settings().brain_id}), &[])); let s = crate::remote::hub().settings();
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() {
match ws.read() { match ws.read() {
@ -309,9 +310,9 @@ fn on_frame(head: Value, body: Vec<u8>, tx: &Sender<Vec<u8>>, calls: &Calls) {
} }
/// Tell the account server which brain this AnyBot shows (so the phone can go there). /// Tell the account server which brain this AnyBot shows (so the phone can go there).
pub fn hello(brain: &str) { pub fn hello(brain: &str, hands: bool) {
if let Some(l) = link().lock().unwrap().as_ref() { if let Some(l) = link().lock().unwrap().as_ref() {
let _ = l.tx.send(wire::pack(&json!({"t": "hello", "brain": brain}), &[])); let _ = l.tx.send(wire::pack(&json!({"t": "hello", "brain": brain, "hands": hands}), &[]));
} }
} }