Add a computer with one line: pairing codes, simpler setups
- 加一台電腦 (phone menu and the app's 大腦 card) gives a ten-minute, one-time code and the line to run: anybot setup <address> <code>. The new computer joins as hands for that brain with no password and nothing to answer, starts with the computer and checks the brain sees it. - anybot setup without arguments is one screen: address and code (a pasted line fills both), or the account and password instead. No role, name or confirm questions; a brain is asked only when there are several. - Hands computers are no longer offered as brains. - The server setup asks only the domain, account and password. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uirha3oEtWeHh58zA7ErHa
This commit is contained in:
parent
cb922da3fe
commit
365dc87e54
|
|
@ -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(選「用現有設定」)"
|
||||||
|
|
|
||||||
|
|
@ -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");
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
983
src/setup.rs
983
src/setup.rs
File diff suppressed because it is too large
Load Diff
|
|
@ -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}), &[]));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue