From cb922da3fe575c2dadb7b1c2e5370666674f999e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E6=80=A7=E9=A9=8A?= Date: Wed, 4 Nov 2026 20:38:45 +0800 Subject: [PATCH] anybot setup and the server setup: guided setup with a brain picker - anybot setup: a full-screen terminal guide (role, name, account server, account, which brain) that saves the settings, installs a systemd user service (with a virtual screen when there is none), and checks that the account server and the brain see the computer. Flags for scripts. - Hands can be limited to one brain; the account server says which AnyBot asks. Choosing a brain in the app does the same. - deploy/server/setup.sh: domain, account, sign-ups and keys step by step; writes .env and Caddyfile, starts everything and checks HTTPS. The server's brain creates the account on first start. - deploy/linux/hands.sh installs what's needed and opens anybot setup (no password kept in a file any more). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Uirha3oEtWeHh58zA7ErHa --- .gitignore | 2 + Cargo.lock | 129 +++- Cargo.toml | 3 + deploy/linux/hands.sh | 95 +-- deploy/linux/install.sh | 1 + deploy/server/docker-compose.yml | 12 +- deploy/server/setup.sh | 283 +++++++ relay/src/main.rs | 15 +- src/app.rs | 11 +- src/app/mirror_ui.rs | 7 + src/main.rs | 5 + src/remote.rs | 46 +- src/setup.rs | 1199 ++++++++++++++++++++++++++++++ src/tunnel.rs | 2 +- 14 files changed, 1707 insertions(+), 103 deletions(-) create mode 100755 deploy/server/setup.sh create mode 100644 src/setup.rs diff --git a/.gitignore b/.gitignore index 3fc2395..0dc47b9 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,8 @@ # Local secrets (deploy/server/.env.example is the template) .env +# Written by deploy/server/setup.sh +/deploy/server/Caddyfile *.env.local # macOS diff --git a/Cargo.lock b/Cargo.lock index aa4bb9b..db37e28 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -343,6 +343,7 @@ dependencies = [ "chrono", "chrono-tz", "cpal", + "crossterm", "dirs", "eframe", "flate2", @@ -362,6 +363,7 @@ dependencies = [ "serde_json", "tungstenite", "unicode-normalization", + "unicode-width", "ureq", "wayland-client", "wayland-protocols", @@ -396,7 +398,7 @@ dependencies = [ "objc2-foundation 0.3.2", "parking_lot", "percent-encoding", - "windows-sys 0.52.0", + "windows-sys 0.61.2", "wl-clipboard-rs", "x11rb 0.13.2", ] @@ -1032,6 +1034,15 @@ version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" +[[package]] +name = "convert_case" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" +dependencies = [ + "unicode-segmentation", +] + [[package]] name = "cookie" version = "0.18.2" @@ -1227,6 +1238,33 @@ version = "0.8.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6" +[[package]] +name = "crossterm" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b" +dependencies = [ + "bitflags 2.13.2", + "crossterm_winapi", + "derive_more", + "document-features", + "mio", + "parking_lot", + "rustix 1.1.5", + "signal-hook", + "signal-hook-mio", + "winapi", +] + +[[package]] +name = "crossterm_winapi" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" +dependencies = [ + "winapi", +] + [[package]] name = "crunchy" version = "0.2.4" @@ -1294,6 +1332,28 @@ version = "0.5.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9e9de72ce2ad1f90dc62fa25f0f430ef85eb4b0d8fa0be4f30373bc40a21d28e" +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "convert_case", + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.119", +] + [[package]] name = "digest" version = "0.10.7" @@ -1644,7 +1704,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -2879,6 +2939,18 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "mio" +version = "1.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1788edb87fdc09c7e26304471e2f5be8cdefb1b6930d6e3985fc02ff53bf86ee" +dependencies = [ + "libc", + "log", + "wasi", + "windows-sys 0.61.2", +] + [[package]] name = "moxcms" version = "0.8.1" @@ -3499,7 +3571,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -4205,7 +4277,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -4392,6 +4464,27 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" +[[package]] +name = "signal-hook" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" +dependencies = [ + "libc", + "signal-hook-registry", +] + +[[package]] +name = "signal-hook-mio" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc" +dependencies = [ + "libc", + "mio", + "signal-hook", +] + [[package]] name = "signal-hook-registry" version = "1.4.8" @@ -4855,10 +4948,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.3.4", + "getrandom 0.4.3", "once_cell", "rustix 1.1.5", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -5904,15 +5997,37 @@ dependencies = [ "web-sys", ] +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + [[package]] name = "winapi-util" version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + [[package]] name = "windows" version = "0.62.2" diff --git a/Cargo.toml b/Cargo.toml index 68baaf6..315b011 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,6 +14,9 @@ ureq = { version = "3", features = ["json"] } rustls = { version = "0.23", default-features = false, features = ["ring", "std", "tls12", "logging"] } webpki-roots = "1" flate2 = "1" +# `anybot setup`: the guided setup in a terminal (servers and Linux computers without a window). +crossterm = "0.29" +unicode-width = "0.2" serde = { version = "1", features = ["derive"] } serde_json = "1" dirs = "7" diff --git a/deploy/linux/hands.sh b/deploy/linux/hands.sh index bb77f70..7b9e33d 100755 --- a/deploy/linux/hands.sh +++ b/deploy/linux/hands.sh @@ -1,29 +1,28 @@ #!/bin/sh -# Make this Linux computer a pair of hands for your AnyBot brain: the brain's bots can see and use -# its screen, files and browser (asking you first, as usual). Works on a desktop and on a computer -# without a screen (a virtual screen is made for it). Run from the app/ folder: +# Make this Linux computer a pair of hands for your AnyBot brain (or a brain itself): installs what +# it needs, then opens the guided setup (`anybot setup`), where you pick the brain from your +# account's AnyBots. Works on a desktop and on a computer without a screen. Run from app/: # # ./deploy/linux/hands.sh --deps also install what a computer without a screen needs (sudo) -# ./deploy/linux/hands.sh set up and start it (asks for your account the first time) -# ./deploy/linux/hands.sh --stop stop it and don't start it with the computer any more +# ./deploy/linux/hands.sh install AnyBot if needed, then the setup +# ./deploy/linux/hands.sh --stop stop AnyBot and don't start it with the computer any more set -e cd "$(dirname "$0")/../.." -UNIT="$HOME/.config/systemd/user/anybot-hands.service" -ENVF="$HOME/.config/anybot/hands.env" -RUN="$HOME/.local/bin/anybot-hands" if [ "$1" = "--stop" ]; then - systemctl --user disable --now anybot-hands.service 2>/dev/null || true - rm -f "$UNIT" - systemctl --user daemon-reload - echo "Stopped. (Your account details stay in $ENVF; delete it if you like.)" + for u in anybot.service anybot-hands.service; do + systemctl --user disable --now "$u" 2>/dev/null || true + rm -f "$HOME/.config/systemd/user/$u" + done + systemctl --user daemon-reload 2>/dev/null || true + echo "Stopped. Run ./deploy/linux/hands.sh (or anybot setup) to set it up again." exit 0 fi if [ "$1" = "--deps" ]; then # A virtual screen, a window manager, the accessibility bus (for reading apps' buttons), a session bus. if command -v apt-get >/dev/null; then - sudo apt-get update && sudo apt-get install -y xvfb openbox at-spi2-core libatk-adaptor dbus-x11 xauth fonts-noto-cjk + sudo apt-get update && sudo apt-get install -y xvfb openbox at-spi2-core libatk-adaptor dbus-x11 dbus-user-session xauth fonts-noto-cjk elif command -v dnf >/dev/null; then sudo dnf install -y xorg-x11-server-Xvfb openbox at-spi2-core at-spi2-atk dbus-x11 xorg-x11-xauth google-noto-sans-cjk-fonts elif command -v pacman >/dev/null; then @@ -33,71 +32,7 @@ if [ "$1" = "--deps" ]; then fi fi -command -v systemctl >/dev/null || { echo "This needs systemd (to keep AnyBot running)."; exit 1; } [ -x "$HOME/.local/bin/anybot" ] || sh deploy/linux/install.sh - -if [ ! -f "$ENVF" ]; then - echo "Your AnyBot account (the same one your brain uses):" - printf " Account server address (e.g. https://anybot.example.com): "; read -r URL - printf " Account name: "; read -r USERNAME - printf " Password: "; stty -echo 2>/dev/null; read -r PASSWORD; stty echo 2>/dev/null; echo - printf " Name for this computer [%s]: " "$(hostname)"; read -r NAME - printf " Brain's name (empty = only lend hands, show no bots here): "; read -r BRAIN - mkdir -p "$(dirname "$ENVF")" - umask 077 - # Each value in single quotes, so a password with $ or spaces stays as typed. - q() { printf "%s='%s'\n" "$1" "$(printf %s "$2" | sed "s/'/'\\\\''/g")"; } - { - q ANYBOT_ACCOUNT "${URL%/}|$USERNAME" - q ANYBOT_ACCOUNT_PASSWORD "$PASSWORD" - q ANYBOT_HOST_NAME "${NAME:-$(hostname)}" - if [ -n "$BRAIN" ]; then q ANYBOT_BRAIN "$BRAIN"; fi - } > "$ENVF" - chmod 600 "$ENVF" -fi - -mkdir -p "$(dirname "$RUN")" "$(dirname "$UNIT")" -cat > "$RUN" <<'SH' -#!/bin/sh -# Started by systemd (anybot-hands.service). -set -a; . "$HOME/.config/anybot/hands.env"; set +a -export ANYBOT_DEVICE_LINK=1 -export LANG="${LANG:-C.UTF-8}" -# The accessibility bus, so bots can read apps' buttons instead of only looking at pixels. -export NO_AT_BRIDGE=0 GTK_A11Y=atspi QT_LINUX_ACCESSIBILITY_ALWAYS_ON=1 -if [ -z "$DISPLAY" ] && [ -z "$WAYLAND_DISPLAY" ]; then - # No screen: a virtual one with a window manager, and a session bus for the accessibility bus. - exec xvfb-run -a -s "-screen 0 1440x900x24" dbus-run-session -- sh -c 'openbox >/dev/null 2>&1 & exec "$HOME/.local/bin/anybot" --serve' -fi -exec "$HOME/.local/bin/anybot" --serve -SH -chmod 755 "$RUN" - -cat > "$UNIT" </dev/null | grep -q "Linger=yes"; then - echo "To keep it running after you log out and after a restart, run once:" - echo " sudo loginctl enable-linger $USER" -fi +# Older versions of this script kept the account in a file; the setup keeps only a key. +rm -f "$HOME/.config/anybot/hands.env" "$HOME/.local/bin/anybot-hands" +exec "$HOME/.local/bin/anybot" setup diff --git a/deploy/linux/install.sh b/deploy/linux/install.sh index 83e6fa2..deffda3 100755 --- a/deploy/linux/install.sh +++ b/deploy/linux/install.sh @@ -58,6 +58,7 @@ command -v update-desktop-database >/dev/null && update-desktop-database "$APPS" command -v gtk-update-icon-cache >/dev/null && gtk-update-icon-cache -q "$ICONS" 2>/dev/null || true echo "AnyBot is installed: find it in your app menu, or run: anybot" +echo "To make this computer a brain, or hands for your brain (also without a screen): anybot setup" case ":$PATH:" in *":$BIN:"*) ;; *) echo "(Add $BIN to your PATH to start it from a terminal.)" ;; esac if [ "${XDG_SESSION_TYPE:-}" = "wayland" ]; then echo "Wayland: bots can see and use your screen. On GNOME/KDE the first time, a system window asks:" diff --git a/deploy/server/docker-compose.yml b/deploy/server/docker-compose.yml index 70ad677..865d4bb 100644 --- a/deploy/server/docker-compose.yml +++ b/deploy/server/docker-compose.yml @@ -1,5 +1,7 @@ # Everything on one small server: the account server (relay), HTTPS (Caddy), and AnyBot itself. -# 1. Point your domain at the server and put it in ../../relay/Caddyfile. +# Easiest: ./setup.sh (asks for the domain, account and keys, writes .env and Caddyfile, starts +# everything and checks it). By hand: +# 1. Point your domain at the server; copy ../../relay/Caddyfile here and put the domain in it. # 2. Put your account name and password in .env (see .env.example). # 3. docker compose up -d # 4. On the phone open https://your-domain, create the account (first time), and use it. @@ -10,7 +12,7 @@ # and works even while your computers sleep. On each of your computers: 設定 › 手機與帳號 › # 大腦 › 「改用它當大腦」. Those computers then show these bots, and the bots can use them as hands # (screen, files, browser), asking you first as usual. A Linux computer without a screen can be -# hands too: deploy/linux/hands.sh. +# hands too: run `anybot setup` there and pick this brain. services: relay: build: @@ -28,7 +30,8 @@ services: restart: unless-stopped ports: ["80:80", "443:443"] volumes: - - ../../relay/Caddyfile:/etc/caddy/Caddyfile:ro + # Written by setup.sh (your domain); ../../relay/Caddyfile is the template. + - ./Caddyfile:/etc/caddy/Caddyfile:ro - caddy-data:/data depends_on: [relay] anybot: @@ -41,6 +44,9 @@ services: ANYBOT_ACCOUNT: "http://relay:8080|${ANYBOT_USER}" ANYBOT_ACCOUNT_PASSWORD: "${ANYBOT_PASSWORD}" ANYBOT_HOST_NAME: "${ANYBOT_HOST_NAME:-雲端 AnyBot}" + # First start on a new server: make the account (the first one needs no invite). + ANYBOT_ACCOUNT_CREATE: "1" + ANYBOT_RELAY_INVITE: "${ANYBOT_RELAY_INVITE:-}" XAI_API_KEY: "${XAI_API_KEY:-}" ANTHROPIC_API_KEY: "${ANTHROPIC_API_KEY:-}" OPENAI_API_KEY: "${OPENAI_API_KEY:-}" diff --git a/deploy/server/setup.sh b/deploy/server/setup.sh new file mode 100755 index 0000000..e33c443 --- /dev/null +++ b/deploy/server/setup.sh @@ -0,0 +1,283 @@ +#!/usr/bin/env bash +# AnyBot 伺服器設定: the account server, HTTPS and the brain on this server, step by step. +# +# cd app/deploy/server && ./setup.sh ask, write .env and Caddyfile, start, check +# ./setup.sh --dry-run ask and write the files, but don't start anything +# +# Run it again any time: it offers to keep the current settings (to update and restart). +set -u +cd "$(dirname "$0")" +DRY=0 +[ "${1:-}" = "--dry-run" ] && DRY=1 + +# ---------- looks ---------- +if [ -t 1 ]; then + B=$'\e[1m'; D=$'\e[90m'; A=$'\e[36m'; G=$'\e[32m'; Y=$'\e[33m'; R=$'\e[31m'; N=$'\e[0m' +else + B=""; D=""; A=""; G=""; Y=""; R=""; N="" +fi +STEPS=("檢查" "網址" "帳號" "金鑰" "啟動") +AT=0 + +header() { + printf '\e[2J\e[H\n' + printf ' %s◆%s %sAnyBot 伺服器設定%s ' "$A" "$N" "$B" "$N" + local i + for i in "${!STEPS[@]}"; do + [ "$i" -gt 0 ] && printf '%s ─ %s' "$D" "$N" + if [ "$i" -lt "$AT" ]; then printf '%s✓ %s%s' "$G" "${STEPS[$i]}" "$N" + elif [ "$i" -eq "$AT" ]; then printf '%s%s%d %s%s' "$A" "$B" $((i + 1)) "${STEPS[$i]}" "$N" + else printf '%s%d %s%s' "$D" $((i + 1)) "${STEPS[$i]}" "$N"; fi + done + printf '\n\n' +} +# A card: title, then lines (each printed after the left edge). +card_open() { printf ' %s╭─%s %s%s%s\n %s│%s\n' "$D" "$N" "$B" "$1" "$N" "$D" "$N"; } +line() { printf ' %s│%s %s\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" "$*"; } +warn() { printf ' %s│%s %s!%s %s\n' "$D" "$N" "$Y" "$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"; } + +# Pad a label to 10 columns (Chinese characters are two columns wide). +padl() { + local w + w=$(printf '%s' "$1" | LC_ALL=C.UTF-8 wc -L 2>/dev/null || printf '%s' "$1" | wc -m) + printf '%s%*s' "$1" $((10 - w > 1 ? 10 - w : 1)) "" +} + +# ask VAR "標籤" [default] [secret]: once answered, the line is redrawn without the marker. +ask() { + local __v=$1 label=$2 def=${3:-} secret=${4:-} got shown + printf ' %s│%s %s❯%s %s%s%s' "$D" "$N" "$A" "$N" "$A" "$(padl "$label")" "$N" + if [ -n "$secret" ]; then + IFS= read -rs got; printf '\n' + shown=$(printf '%s' "$got" | sed 's/./●/g') + else + IFS= read -r -e -i "$def" got || true + shown=$got + fi + printf '\e[1A\r\e[K %s│%s %s%s%s%s\n' "$D" "$N" "$D" "$(padl "$label")" "$N" "$shown" + printf -v "$__v" '%s' "$got" +} + +# choose VAR "問題" "選項|說明" ... (↑↓ and Enter) +choose() { + local __v=$1 title=$2; shift 2 + local opts=("$@") sel=0 n=$# key rest i + while :; do + header + card_open "$title" + for i in "${!opts[@]}"; do + local name=${opts[$i]%%|*} desc=${opts[$i]#*|} + if [ "$i" -eq "$sel" ]; then line "$A❯ ● $B$name$N"; else line " $D○$N $name"; fi + [ "$desc" != "${opts[$i]}" ] && [ -n "$desc" ] && note " $desc" + line "" + done + card_close "↑↓ 選擇 Enter 確定" + IFS= read -rsn1 key + if [ "$key" = $'\e' ]; then + IFS= read -rsn2 -t 0.05 rest || true + case "$rest" in "[A") sel=$(((sel + n - 1) % n)) ;; "[B") sel=$(((sel + 1) % n)) ;; esac + elif [ "$key" = "k" ]; then sel=$(((sel + n - 1) % n)) + elif [ "$key" = "j" ]; then sel=$(((sel + 1) % n)) + elif [ -z "$key" ]; then break + fi + done + printf -v "$__v" '%s' "$sel" +} + +spin() { # spin "做什麼" command… : runs it, shows a spinner and the newest log line + local what=$1; shift + local log; log=$(mktemp) + "$@" >"$log" 2>&1 & + local pid=$! f=0 c=("⠋" "⠙" "⠹" "⠸" "⠼" "⠴" "⠦" "⠧" "⠇" "⠏") + while kill -0 "$pid" 2>/dev/null; do + local last; last=$(tail -n 1 "$log" | tr -d '\r' | cut -c1-60) + printf '\r\e[K %s│%s %s%s%s %s %s%s%s' "$D" "$N" "$A" "${c[$((f % 10))]}" "$N" "$what" "$D" "$last" "$N" + f=$((f + 1)); sleep 0.15 + done + wait "$pid"; local rc=$? + printf '\r\e[K' + if [ $rc -eq 0 ]; then ok "$what"; else bad "$what 失敗:"; tail -n 8 "$log" | while IFS= read -r l; do note " $l"; done; fi + rm -f "$log" + return $rc +} + +pause() { printf ' %s%s%s' "$D" "${1:-按 Enter 繼續}" "$N"; IFS= read -r _; } +die() { card_close; printf '\n'; exit 1; } + +# ---------- 0. this server ---------- +AT=0; header; card_open "檢查這台伺服器" +FAIL=0 +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 [ $FAIL = 0 ] && ! docker info >/dev/null 2>&1; then bad "沒有權限使用 Docker:sudo usermod -aG docker \$USER,登出再登入"; FAIL=1; fi +if command -v ss >/dev/null; then + busy=$(ss -ltnH 2>/dev/null | awk '{print $4}' | grep -E ':(80|443)$' | grep -v docker || true) + if [ -n "$busy" ] && ! docker compose ps -q caddy 2>/dev/null | grep -q .; then warn "80 或 443 埠已被別的程式佔用(HTTPS 需要它們)"; else ok "80、443 埠可以用"; fi +fi +mem=$(awk '/MemTotal/ {print int($2/1024/1024+0.5)}' /proc/meminfo 2>/dev/null || echo 0) +[ "${mem:-0}" -ge 2 ] && ok "記憶體 ${mem} GB" || warn "記憶體只有 ${mem:-?} GB(建議 2 GB 以上,第一次建置會比較久)" +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 以上)" +if [ $FAIL = 1 ] && [ $DRY = 0 ]; then note ""; note "修好上面的問題後再執行一次 ./setup.sh"; die; fi +card_close; pause + +# Keep the current settings? +if [ -f .env ] && [ -f Caddyfile ]; then + # shellcheck disable=SC1091 + set -a; . ./.env; set +a + choose KEEP "這台已經設定過了(${ANYBOT_RELAY_PUBLIC_URL:-})" \ + "用現有設定,更新並重新啟動|拉下最新版、重建、重新啟動,帳號和 Bot 都不變" \ + "重新設定|網址、帳號或金鑰要改" + if [ "$KEEP" = 0 ]; then + DOMAIN=${ANYBOT_RELAY_PUBLIC_URL#https://}; USERNAME=$ANYBOT_USER; INVITE=${ANYBOT_RELAY_INVITE:-}; BRAIN=${ANYBOT_HOST_NAME:-} + AT=4; SKIP=1 + fi +fi + +if [ "${SKIP:-0}" != 1 ]; then + # ---------- 1. the address ---------- + AT=1; MSG="" + while :; do + header; card_open "手機和其他電腦用哪個網址連到這台?" + note "網域要先指到這台伺服器(DNS 的 A 紀錄)。HTTPS 憑證會自動申請、自動更新。" + line "" + [ -n "$MSG" ] && { printf '%s\n' "$MSG"; line ""; } + ask DOMAIN "網域" "${DOMAIN:-}" + DOMAIN=${DOMAIN#https://}; DOMAIN=${DOMAIN#http://}; DOMAIN=${DOMAIN%%/*} + if ! printf '%s' "$DOMAIN" | grep -Eq '^[A-Za-z0-9.-]+\.[A-Za-z]{2,}$'; then MSG=$(bad "請輸入像 anybot.example.com 這樣的網域"); continue; fi + ips=$(getent ahosts "$DOMAIN" 2>/dev/null | awk '{print $1}' | sort -u | tr '\n' ' ') + if [ -z "$ips" ]; then + card_close + choose GO "「$DOMAIN」還沒有指到任何位址" "重新輸入|" "仍然繼續|DNS 剛設好、還沒生效時可以先繼續;生效後憑證會自動申請" + [ "$GO" = 0 ] && { MSG=""; continue; } + else + mine=$(hostname -I 2>/dev/null || true) + hit=0; for ip in $ips; do case " $mine " in *" $ip "*) hit=1 ;; esac; done + if [ $hit = 1 ]; then MSG=$(ok "$DOMAIN → $ips(就是這台)"); else MSG=$(warn "$DOMAIN → $ips(這台的位址:${mine:-不知道};在 NAT 或雲端主機後面時這是正常的)"); fi + header; card_open "手機和其他電腦用哪個網址連到這台?"; printf '%s\n' "$MSG"; card_close; sleep 1 + fi + break + done + + # ---------- 2. the account ---------- + AT=2; MSG="" + while :; do + header; card_open "你的 AnyBot 帳號" + note "手機、電腦都用這個帳號登入。第一次啟動時會自動建立。" + line "" + [ -n "$MSG" ] && { printf '%s\n' "$MSG"; line ""; } + ask USERNAME "帳號" "${USERNAME:-}" + if ! printf '%s' "$USERNAME" | grep -Eq '^[A-Za-z0-9._@-]{2,64}$'; then MSG=$(bad "帳號只能用英文字母、數字和 . _ @ -(2–64 個字)"); continue; fi + ask PW1 "密碼" "" secret + ask PW2 "再一次" "" secret + if [ "${#PW1}" -lt 8 ]; then MSG=$(bad "密碼至少 8 個字"); continue; fi + if [ "$PW1" != "$PW2" ]; then MSG=$(bad "兩次密碼不一樣"); continue; fi + case "$PW1" in *"'"*) MSG=$(bad "密碼裡不能有單引號 '"); continue ;; esac + ask BRAIN "大腦名字" "${BRAIN:-雲端大腦}" + [ -z "$BRAIN" ] && BRAIN="雲端大腦" + break + done + choose OTHERS "別人可以在這台註冊帳號嗎?" \ + "只有我|最安全" \ + "給邀請碼的人可以|家人或同事拿邀請碼註冊,各自有自己的帳號和 Bot" + INVITE="" + [ "$OTHERS" = 1 ] && INVITE=$(LC_ALL=C tr -dc 'a-z0-9' .env + chmod 600 .env + umask 022 + sed "s/^anybot.example.com {/$DOMAIN {/" ../../relay/Caddyfile >Caddyfile +fi + +# ---------- 4. start ---------- +AT=4; header; card_open "啟動" +ok "寫好設定(.env、Caddyfile)" +if [ $DRY = 1 ]; then + warn "試跑(--dry-run):沒有啟動。要啟動:docker compose up -d --build" + card_close; exit 0 +fi +if [ "${SKIP:-0}" = 1 ] && [ -d ../../../.git ] && command -v git >/dev/null; then + spin "拉下最新版" git -C ../../.. pull --ff-only || true +fi +spin "建置並啟動(第一次約 5–15 分鐘)" docker compose up -d --build || die + +# The brain joins the account (and makes it, the first time). +f=0; c=("⠋" "⠙" "⠹" "⠸" "⠼" "⠴" "⠦" "⠧" "⠇" "⠏"); joined="" +for _ in $(seq 1 600); do + logs=$(docker compose logs --no-color anybot 2>/dev/null | tail -n 40) + if printf '%s' "$logs" | grep -q "透過帳號"; then joined=1; break; fi + printf '\r\e[K %s│%s %s%s%s 大腦正在登入帳號…' "$D" "$N" "$A" "${c[$((f % 10))]}" "$N"; f=$((f + 1)); sleep 0.3 +done +printf '\r\e[K' +if [ -n "$joined" ]; then + printf '%s' "$logs" | grep -q "已建立帳號" && ok "建立帳號 ${USERNAME}" || ok "大腦「${BRAIN}」已登入帳號" +else + bad "大腦沒有登入帳號。最近的紀錄:" + printf '%s\n' "$logs" | grep "AnyBot:" | tail -n 4 | while IFS= read -r l; do note " ${l#*| }"; done +fi + +# HTTPS from outside (the certificate can take a minute). +https="" +for _ in $(seq 1 120); do + if curl -fsS --max-time 5 "https://$DOMAIN/api/hello" 2>/dev/null | grep -q anybot-relay; then https=1; break; fi + printf '\r\e[K %s│%s %s%s%s 等 HTTPS 憑證…' "$D" "$N" "$A" "${c[$((f % 10))]}" "$N"; f=$((f + 1)); sleep 1 +done +printf '\r\e[K' +if [ -n "$https" ]; then ok "https://$DOMAIN 可以連"; else warn "https://$DOMAIN 還連不到(DNS 還沒生效,或 80/443 埠被防火牆擋住)。看紀錄:docker compose logs caddy"; fi +card_close + +printf '\n' +card_open "$([ -n "$joined" ] && echo "完成了" || echo "差一點")" +line "${B}手機${N} 打開 ${A}https://$DOMAIN${N},用 ${B}$USERNAME${N} 登入,可以「加入主畫面」" +line "${B}電腦${N} AnyBot › 設定 › 手機與帳號 登入,在「大腦」選「$BRAIN」" +line "${B}Linux 手腳${N} anybot setup(選「手腳」,再選「$BRAIN」)" +[ -n "${INVITE:-}" ] && line "${B}邀請碼${N} $INVITE(給要註冊的人)" +line "" +note "看紀錄 docker compose logs -f anybot" +note "更新 ./setup.sh(選「用現有設定」)" +card_close +printf '\n' diff --git a/relay/src/main.rs b/relay/src/main.rs index 8c95480..4d44cb7 100644 --- a/relay/src/main.rs +++ b/relay/src/main.rs @@ -282,12 +282,13 @@ impl Relay { /// Hand a request to a host and wait for its answer. fn forward(&self, host_id: &str, method: &str, path: &str, query: &HashMap, body: &[u8], who: (&str, &str)) -> Resp { - self.forward_raw(host_id, method, path, query, body, false, who, false).into_resp() + self.forward_raw(host_id, method, path, query, body, false, who, false, "").into_resp() } /// `z`: the body is compressed; `z_ok`: the asker reads a compressed answer. #[allow(clippy::too_many_arguments)] - fn forward_raw(&self, host_id: &str, method: &str, path: &str, query: &HashMap, body: &[u8], z: bool, who: (&str, &str), z_ok: bool) -> Answer { + /// `from`: the AnyBot of the account asking (empty for the phone). + fn forward_raw(&self, host_id: &str, method: &str, path: &str, query: &HashMap, body: &[u8], z: bool, who: (&str, &str), z_ok: bool, from: &str) -> Answer { use base64::Engine; if !self.online(host_id) { return Answer::error(503, OFFLINE); @@ -295,7 +296,7 @@ impl Relay { let id = httpd::random_hex(12); let (tx, rx) = channel(); self.waiting.lock().unwrap().insert(id.clone(), tx); - let head = json!({"t": "job", "id": id, "method": method, "path": path, "query": query, "user": who.0, "role": who.1, "z": z, "z_ok": z_ok}); + let head = json!({"t": "job", "id": id, "method": method, "path": path, "query": query, "user": who.0, "role": who.1, "z": z, "z_ok": z_ok, "from": from}); // Connected the lasting way: straight down that connection. let sock = self.sockets.lock().unwrap().get(host_id).map(|s| (s.tx.clone(), s.serial)); if let Some((stx, serial)) = sock { @@ -443,9 +444,9 @@ impl Relay { return; } let query: HashMap = head["query"].as_object().map(|o| o.iter().map(|(k, v)| (k.clone(), v.as_str().unwrap_or("").to_string())).collect()).unwrap_or_default(); - let (me, tx, user, method, z) = (self.clone(), tx.clone(), h.user.clone(), s("method"), head["z"].as_bool().unwrap_or(false)); + let (me, tx, user, method, z, from) = (self.clone(), tx.clone(), h.user.clone(), s("method"), head["z"].as_bool().unwrap_or(false), h.id.clone()); std::thread::spawn(move || { - let a = me.forward_raw(&to, &method, &path, &query, &body, z, (&user, role), true); + let a = me.forward_raw(&to, &method, &path, &query, &body, z, (&user, role), true, &from); let _ = tx.send(wire::pack(&json!({"t": "ret", "id": id, "status": a.status, "ctype": a.ctype, "z": a.z}), &a.body)); }); } @@ -706,7 +707,7 @@ impl Relay { if !same || *target == me.id { return Resp::error(403, "不是同一個帳號的電腦"); } - self.forward(target, "POST", "/api/device/run", &HashMap::new(), &req.body, (&me.user, "device")) + self.forward_raw(target, "POST", "/api/device/run", &HashMap::new(), &req.body, false, (&me.user, "device"), false, &me.id).into_resp() } // 大腦: one AnyBot of the account shows (and edits) another one's bots and chats. (m, ["relay", "hosts", target, "api", "sync", rest @ ..]) => { @@ -716,7 +717,7 @@ impl Relay { return Resp::error(403, "不是同一個帳號的電腦"); } let path = if rest.is_empty() { "/api/sync".to_string() } else { format!("/api/sync/{}", rest.join("/")) }; - self.forward(target, m, &path, &req.query, &req.body, (&me.user, "mirror")) + self.forward_raw(target, m, &path, &req.query, &req.body, false, (&me.user, "mirror"), false, &me.id).into_resp() } // The phone, through to one of the account's AnyBots. (m, ["h", host, rest @ ..]) => { diff --git a/src/app.rs b/src/app.rs index a01eeab..73b5b3c 100644 --- a/src/app.rs +++ b/src/app.rs @@ -3061,7 +3061,16 @@ impl App { return; } let Some((url, user)) = acct.rsplit_once('|') else { return }; - match crate::remote::relay_join(&self.state.remote, url, user, &pw, false, "") { + // ANYBOT_ACCOUNT_CREATE=1 (the server's own brain, first start): make the account if it isn't there. + let mut r = crate::remote::relay_join(&self.state.remote, url, user, &pw, false, ""); + if r.is_err() && std::env::var("ANYBOT_ACCOUNT_CREATE").map(|v| v == "1").unwrap_or(false) { + let invite = std::env::var("ANYBOT_RELAY_INVITE").unwrap_or_default(); + if let Ok(s) = crate::remote::relay_join(&self.state.remote, url, user, &pw, true, &invite) { + eprintln!("AnyBot:已建立帳號 {user}"); + r = Ok(s); + } + } + match r { Ok(s) => { self.state.remote = s; self.dirty = true; diff --git a/src/app/mirror_ui.rs b/src/app/mirror_ui.rs index 685a15d..d4c4dd4 100644 --- a/src/app/mirror_ui.rs +++ b/src/app/mirror_ui.rs @@ -741,6 +741,8 @@ impl App { let mut remote = self.state.remote.clone(); remote.brain_id.clear(); remote.brain_name.clear(); + remote.hands_for.clear(); + remote.hands_for_name.clear(); local.remote = remote; local.theme = self.state.theme; local.accent = self.state.accent; @@ -751,6 +753,8 @@ impl App { } else { self.state.remote.brain_id.clear(); self.state.remote.brain_name.clear(); + self.state.remote.hands_for.clear(); + self.state.remote.hands_for_name.clear(); } self.state.save(); self.dirty = false; @@ -825,6 +829,9 @@ impl App { if self.mir.on { self.mirror_leave(); } + // This computer's hands are for that brain only. + self.state.remote.hands_for = id.clone(); + self.state.remote.hands_for_name = name.clone(); self.state.remote.brain_id = id; self.state.remote.brain_name = name; self.state.save(); diff --git a/src/main.rs b/src/main.rs index 590c12a..2797ff6 100644 --- a/src/main.rs +++ b/src/main.rs @@ -37,6 +37,7 @@ mod preview; mod providers; mod remote; mod routines; +mod setup; mod teach; mod share; mod teams; @@ -218,6 +219,10 @@ fn main() -> eframe::Result<()> { } std::process::exit(0); } + // `anybot setup`: make this computer a brain, or hands for a brain (in a terminal). + if args.get(1).map(String::as_str) == Some("setup") { + std::process::exit(setup::run(&args[2..])); + } if args.get(1).map(String::as_str) == Some("--ask") { let text = args[2..].join(" "); let ctx = egui::Context::default(); diff --git a/src/remote.rs b/src/remote.rs index 6bf32df..a40e20a 100644 --- a/src/remote.rs +++ b/src/remote.rs @@ -78,11 +78,16 @@ pub struct Settings { pub brain_id: String, #[serde(default, skip_serializing_if = "String::is_empty")] pub brain_name: String, + /// 手腳: only this brain (host id) may use this computer (empty = any AnyBot of the account). + #[serde(default, skip_serializing_if = "String::is_empty")] + pub hands_for: String, + #[serde(default, skip_serializing_if = "String::is_empty")] + pub hands_for_name: String, } impl Default for Settings { fn default() -> Self { - Settings { lan: false, password: String::new(), devices: vec![], mac_from_phone: false, device_link: false, relay_url: String::new(), relay_user: String::new(), host_id: String::new(), host_token: String::new(), host_name: default_host_name(), backup_key: String::new(), auto_backup: true, last_backup: 0, brain_id: String::new(), brain_name: String::new() } + Settings { lan: false, password: String::new(), devices: vec![], mac_from_phone: false, device_link: false, relay_url: String::new(), relay_user: String::new(), host_id: String::new(), host_token: String::new(), host_name: default_host_name(), backup_key: String::new(), auto_backup: true, last_backup: 0, brain_id: String::new(), brain_name: String::new(), hands_for: String::new(), hands_for_name: String::new() } } } @@ -370,7 +375,7 @@ pub fn handle(req: Req, via_relay: bool) -> Resp { } ("GET", ["api", "screen", which]) => screen(which, &s), ("POST", ["api", "screen", which]) => screen_input(which, &req.json(), &s), - ("POST", ["api", "device", "run"]) => device_run(&req.json(), &s, via_relay), + ("POST", ["api", "device", "run"]) => device_run(&req.json(), &s, via_relay, req.header("x-anybot-from")), ("GET", ["api", "sync"]) => { let since: u64 = req.query.get("since").and_then(|v| v.parse().ok()).unwrap_or(0); let client = req.query.get("client").cloned().unwrap_or_default(); @@ -573,10 +578,13 @@ fn screen_input(which: &str, v: &Value, s: &Settings) -> Resp { /// 裝置連結: another AnyBot in this account (e.g. the one on the server) runs a screen tool on /// this Mac. Only through the relay, and only when this Mac allows it. -fn device_run(v: &Value, s: &Settings, via_relay: bool) -> Resp { +fn device_run(v: &Value, s: &Settings, via_relay: bool, from: &str) -> Resp { if !via_relay || !s.device_link { return Resp::error(403, "這台電腦沒有開放給帳號裡的其他 AnyBot 操作"); } + if !s.hands_for.is_empty() && from != s.hands_for { + return Resp::error(403, &format!("這台電腦只讓大腦「{}」使用", s.hands_for_name)); + } let tool = v["tool"].as_str().unwrap_or(""); let args = v["args"].as_str().unwrap_or("{}"); let who = v["who"].as_str().unwrap_or("遠端 AnyBot"); @@ -843,7 +851,7 @@ pub fn req_from_json(job: &Value) -> Req { path: job["path"].as_str().unwrap_or("/").to_string(), query: job["query"].as_object().map(|o| o.iter().map(|(k, v)| (k.clone(), v.as_str().unwrap_or("").to_string())).collect()).unwrap_or_default(), // Who asked, as the relay vouches for it (團隊: owner / admin / member; "device" for 裝置連結). - headers: [("x-anybot-user", job["user"].as_str().unwrap_or("")), ("x-anybot-role", job["role"].as_str().unwrap_or("owner"))].iter().map(|(k, v)| (k.to_string(), v.to_string())).collect(), + headers: [("x-anybot-user", job["user"].as_str().unwrap_or("")), ("x-anybot-role", job["role"].as_str().unwrap_or("owner")), ("x-anybot-from", job["from"].as_str().unwrap_or(""))].iter().map(|(k, v)| (k.to_string(), v.to_string())).collect(), body: { let b = base64::engine::general_purpose::STANDARD.decode(job["body"].as_str().unwrap_or("")).unwrap_or_default(); if job["z"].as_bool().unwrap_or(false) { @@ -1082,6 +1090,36 @@ pub fn set_member(s: &Settings, user: &str, role: &str) -> Result<(), String> { } /// The other AnyBots signed in to this account (for 裝置連結). +/// The account's AnyBots as the account server sees them (for choosing a brain). +pub fn peers(s: &Settings) -> Result, 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 st = r.status().as_u16(); + let v: Value = serde_json::from_str(&r.body_mut().read_to_string().unwrap_or_default()).unwrap_or(Value::Null); + if !(200..300).contains(&st) { + return Err(why_refused(st, &v, "帳號伺服器")); + } + Ok(v["hosts"].as_array().cloned().unwrap_or_default()) +} + +/// Is this an AnyBot account server? (Gives the address to use.) +pub fn check_server(url: &str) -> Result { + let mut u = url.trim().trim_end_matches('/').to_string(); + if u.is_empty() { + return Err("請輸入帳號伺服器的網址".into()); + } + if !u.contains("://") { + let host = u.split(['/', ':']).next().unwrap_or(""); + let plain = host == "localhost" || host.parse::().is_ok() || !host.contains('.'); + u = format!("{}{u}", if plain { "http://" } else { "https://" }); + } + let mut r = agent(15).get(&format!("{u}/api/hello")).call().map_err(|e| format!("連不到 {u}:{}", why_unreachable(&e)))?; + let v: Value = serde_json::from_str(&r.body_mut().read_to_string().unwrap_or_default()).unwrap_or(Value::Null); + if v["app"] != "anybot-relay" { + return Err(format!("{u} 有回應,但不是 AnyBot 帳號伺服器(網址打錯了?)")); + } + Ok(u) +} + pub fn devices_online(s: &Settings) -> Vec<(String, String, bool)> { if s.relay_url.is_empty() { return vec![]; diff --git a/src/setup.rs b/src/setup.rs new file mode 100644 index 0000000..4dac0f4 --- /dev/null +++ b/src/setup.rs @@ -0,0 +1,1199 @@ +// `anybot setup`: a guided setup in the terminal, for computers set up over SSH or without a +// window (a server, a Linux box). Five short steps: what this computer is (大腦 or 手腳), its +// name, the account server, the account, and — for hands — which brain may use it, picked from +// the account's AnyBots as they are right now. Then it saves the settings, makes AnyBot start with +// the computer (systemd), and waits until the account server and the brain see it. +// +// Without a terminal (a script), the same can be given as flags: +// anybot setup --role hands --name 書房電腦 --server https://anybot.example.com --user me +// --brain 雲端大腦 [--create] [--invite 碼] [--no-service] +// with the password in ANYBOT_ACCOUNT_PASSWORD (or on stdin with --password-stdin). + +use crate::model::State; +use crate::remote::{self, Settings}; +use crossterm::event::{self, Event, KeyCode, KeyEventKind, KeyModifiers}; +use crossterm::style::{Attribute, Color, Print, ResetColor, SetAttribute, SetForegroundColor}; +use crossterm::{cursor, execute, queue, terminal}; +use std::io::{IsTerminal, Write}; +use std::sync::mpsc::{channel, Receiver, Sender}; +use std::time::{Duration, Instant}; +use unicode_width::{UnicodeWidthChar, UnicodeWidthStr}; + +const ACC: Color = Color::Cyan; +const GOOD: Color = Color::Green; +const WARN: Color = Color::Yellow; +const BAD: Color = Color::Red; +const DIM: Color = Color::DarkGrey; +const SPIN: [&str; 10] = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; +const UNIT: &str = "anybot.service"; + +#[derive(Clone, Copy, PartialEq, Debug)] +enum Role { + Hands, + Brain, +} + +#[derive(Clone, Copy, PartialEq, Debug)] +enum Step { + Role, + Name, + Server, + Account, + Brain, + Confirm, + Install, + Done, +} + +#[derive(Clone, Debug)] +struct Peer { + id: String, + name: String, + online: bool, + last: String, +} + +#[derive(Clone, Copy, PartialEq, Debug)] +enum Mark { + Wait, + Run, + Ok, + Warn, + Fail, +} + +enum Msg { + Server(Result), + Joined(Result), + Peers(Result, String>), + Progress(usize, Mark, String), + Finished(Vec), +} + +/// What this computer has. +#[derive(Clone)] +struct Env { + linux: bool, + display: String, + systemd: bool, + xvfb: bool, +} + +impl Env { + fn detect() -> Env { + let var = |k: &str| std::env::var(k).ok().filter(|v| !v.is_empty()); + // "wayland" when only Wayland is there (nothing to remember for a service). + let linux = cfg!(target_os = "linux"); + // A Mac or Windows PC always has its screen. + let display = if !linux { "desktop".to_string() } else { var("DISPLAY").or_else(|| var("WAYLAND_DISPLAY").map(|_| "wayland".to_string())).unwrap_or_default() }; + let systemd = linux + && std::process::Command::new("systemctl") + .args(["--user", "is-system-running"]) + .output() + .map(|o| { + let out = String::from_utf8_lossy(&o.stdout); + !out.trim().is_empty() && !out.contains("offline") + }) + .unwrap_or(false); + Env { linux, display, systemd, xvfb: which("xvfb-run") } + } + fn has_display(&self) -> bool { + !self.display.is_empty() + } + fn summary(&self) -> String { + let os = if self.linux { + "Linux" + } else if cfg!(target_os = "macos") { + "macOS" + } else { + "Windows" + }; + let screen = if self.has_display() { + "有螢幕" + } else if self.xvfb { + "沒有螢幕(會開一個虛擬螢幕)" + } else { + "沒有螢幕" + }; + let service = if self.systemd { "會設成開機自動執行" } else { "沒有 systemd(要自己啟動)" }; + format!("這台:{os} · {screen} · {service}") + } +} + +fn which(cmd: &str) -> bool { + std::env::var_os("PATH").map(|p| std::env::split_paths(&p).any(|d| d.join(cmd).is_file())).unwrap_or(false) +} + +// ---------- drawing ---------- + +#[derive(Clone, Copy, Default)] +struct Sty { + fg: Option, + bold: bool, +} + +type Span = (String, Sty); +type Line = Vec; + +fn sp(s: impl Into, fg: Option, bold: bool) -> Span { + (s.into(), Sty { fg, bold }) +} +fn plain(s: impl Into) -> Span { + sp(s, None, false) +} +fn dim(s: impl Into) -> Span { + sp(s, Some(DIM), false) +} +fn strong(s: impl Into) -> Span { + sp(s, None, true) +} +fn colored(s: impl Into, c: Color) -> Span { + sp(s, Some(c), false) +} + +fn width(l: &Line) -> usize { + l.iter().map(|(s, _)| s.width()).sum() +} + +/// Break text to lines at most `w` wide (anywhere: Chinese has no spaces). +fn wrap(text: &str, w: usize) -> Vec { + let mut out = vec![]; + for para in text.split('\n') { + let mut cur = String::new(); + let mut cw = 0; + for ch in para.chars() { + let c = ch.width().unwrap_or(0); + if cw + c > w && !cur.is_empty() { + // A line never starts with closing punctuation: take the last character along. + let closing = |c: char| "。,、;:」)!?』,.;:!?)".contains(c); + let mut carry = String::new(); + if closing(ch) { + while let Some(p) = cur.pop() { + carry.insert(0, p); + if !closing(p) || cur.is_empty() { + break; + } + } + } + out.push(std::mem::take(&mut cur)); + cw = carry.width(); + cur = carry; + } + cur.push(ch); + cw += c; + } + out.push(cur); + } + out +} + +fn para(text: &str, indent: usize, w: usize, f: fn(String) -> Span) -> Vec { + wrap(text, w.saturating_sub(indent).max(10)).into_iter().map(|t| vec![plain(" ".repeat(indent)), f(t)]).collect() +} + +/// Cut a line to `w` columns. +fn clip(l: Line, w: usize) -> Line { + let mut out = vec![]; + let mut used = 0; + for (s, st) in l { + let mut keep = String::new(); + for ch in s.chars() { + let c = ch.width().unwrap_or(0); + if used + c > w { + break; + } + keep.push(ch); + used += c; + } + out.push((keep, st)); + if used >= w { + break; + } + } + out +} + +// ---------- the setup ---------- + +struct Wiz { + env: Env, + step: Step, + role: Role, + sel: usize, + name: String, + url: String, + user: String, + pass: String, + invite: String, + create: bool, + focus: usize, + server: String, + joined: Option, + peers: Vec, + brain: Option, + busy: Option, + rx: Option>, + tx: Sender, + spin: usize, + error: String, + progress: Vec<(String, Mark, String)>, + tips: Vec, + ok: bool, + quit: bool, + service: bool, + saved: Settings, +} + +impl Wiz { + fn new() -> Wiz { + let st = State::load(); + let saved = st.remote.clone(); + let (tx, rx) = channel(); + let env = Env::detect(); + Wiz { + role: if saved.device_link || !env.has_display() { Role::Hands } else { Role::Brain }, + sel: if saved.device_link || !env.has_display() { 0 } else { 1 }, + step: Step::Role, + name: if saved.host_name.is_empty() { remote::default_host_name() } else { saved.host_name.clone() }, + url: saved.relay_url.clone(), + user: saved.relay_user.clone(), + pass: String::new(), + invite: String::new(), + create: false, + focus: if saved.relay_user.is_empty() { 0 } else { 1 }, + server: String::new(), + joined: None, + peers: vec![], + brain: None, + busy: None, + rx: Some(rx), + tx, + spin: 0, + error: String::new(), + progress: vec![], + tips: vec![], + ok: false, + quit: false, + service: env.systemd, + saved, + env, + } + } + + fn crumbs(&self) -> Vec<&'static str> { + let role = if self.step == Step::Role { if self.sel == 0 { Role::Hands } else { Role::Brain } } else { self.role }; + match role { + Role::Hands => vec!["角色", "名字", "帳號", "大腦", "完成"], + Role::Brain => vec!["角色", "名字", "帳號", "完成"], + } + } + + fn crumb_at(&self) -> usize { + let last = self.crumbs().len() - 1; + match self.step { + Step::Role => 0, + Step::Name => 1, + Step::Server | Step::Account => 2, + Step::Brain => 3, + Step::Confirm | Step::Install | Step::Done => last, + } + } + + // ----- what each step shows ----- + + fn title(&self) -> (&'static str, String) { + match self.step { + Step::Role => ("這台電腦要做什麼?", "之後隨時可以再執行 anybot setup 改。".into()), + Step::Name => ("這台電腦叫什麼?", "手機、大腦和其他電腦上看到的名字。".into()), + Step::Server => ("帳號伺服器在哪裡?", "就是手機登入 AnyBot 的那個網址,例如 https://anybot.example.com".into()), + Step::Account => ("登入帳號", "和手機、大腦用同一個帳號。密碼只用來登入,不會存在這台電腦上。".into()), + Step::Brain => ("要當哪個大腦的手腳?", "只有選的大腦能使用這台電腦;它的 Bot 每個動作仍照大腦的規則先問你。".into()), + Step::Confirm => ("確認一下", "按 Enter 開始設定。".into()), + Step::Install => (if self.ok { "設定好了" } else { "正在設定…" }, String::new()), + Step::Done => (if self.ok { "設定好了" } else { "沒有完成" }, String::new()), + } + } + + fn field(&self, label: &str, value: &str, focused: bool, secret: bool, hint: &str) -> Line { + let shown = if secret { "●".repeat(value.chars().count()) } else { value.to_string() }; + let mut l = vec![if focused { colored("❯ ", ACC) } else { plain(" ") }, sp(format!("{label}{}", " ".repeat(8usize.saturating_sub(label.width()))), Some(if focused { ACC } else { DIM }), focused)]; + if shown.is_empty() && !focused { + l.push(dim(hint)); + } else { + l.push(strong(shown)); + if focused { + l.push(colored("▌", ACC)); + if value.is_empty() && !hint.is_empty() { + l.push(dim(format!(" {hint}"))); + } + } + } + l + } + + fn body(&self, w: usize) -> Vec { + let mut b: Vec = vec![]; + match self.step { + Step::Role => { + let opts = [ + ("手腳", "讓大腦的 Bot 使用這台電腦:看畫面、點擊打字、檔案和指令、這台的瀏覽器。"), + ("大腦", "這台保存所有 Bot、聊天、記憶和排程,一直開著;其他電腦和手機都連到它。"), + ]; + for (i, (name, desc)) in opts.iter().enumerate() { + let on = self.sel == i; + b.push(vec![if on { colored("❯ ", ACC) } else { plain(" ") }, sp(if on { "● " } else { "○ " }, Some(if on { ACC } else { DIM }), false), sp(*name, if on { Some(ACC) } else { None }, true)]); + b.extend(para(desc, 6, w, dim)); + b.push(vec![]); + } + b.push(vec![dim(self.env.summary())]); + } + Step::Name => b.push(self.field("名字", &self.name, true, false, "")), + Step::Server => b.push(self.field("網址", &self.url, true, false, "https://…")), + Step::Account => { + let reuse = self.can_reuse(); + b.push(self.field("帳號", &self.user, self.focus == 0, false, "帳號名稱")); + b.push(self.field("密碼", &self.pass, self.focus == 1, true, if reuse { "已登入過,留空直接沿用" } else { "" })); + b.push(vec![ + if self.focus == 2 { colored("❯ ", ACC) } else { plain(" ") }, + sp(if self.create { "■ " } else { "□ " }, Some(if self.focus == 2 { ACC } else { DIM }), false), + sp("這是新帳號(第一次用這台伺服器)", if self.focus == 2 { Some(ACC) } else { None }, false), + dim(if self.focus == 2 { " 空白鍵切換" } else { "" }), + ]); + if self.create { + b.push(self.field("邀請碼", &self.invite, self.focus == 3, false, "伺服器管理員給的(第一個帳號不用)")); + } + } + Step::Brain => { + if self.peers.is_empty() && self.busy.is_none() { + b.extend(para("帳號裡還沒有其他 AnyBot。先把大腦裝好(伺服器上執行 deploy/server/setup.sh,或在一台電腦上開 AnyBot 登入帳號),再按 r 重新整理。", 0, w, plain)); + b.push(vec![]); + } + for (i, p) in self.peers.iter().enumerate() { + let on = self.sel == i; + let status = if p.online { colored("在線上", GOOD) } else { dim(format!("離線({})", p.last)) }; + let name = format!("{} ", p.name); + let pad = 28usize.saturating_sub(name.width()); + b.push(vec![if on { colored("❯ ", ACC) } else { plain(" ") }, sp(if p.online { "● " } else { "○ " }, Some(if p.online { GOOD } else { DIM }), false), sp(name, if on { Some(ACC) } else { None }, true), plain(" ".repeat(pad)), status]); + } + let i = self.peers.len(); + let on = self.sel == i; + if !self.peers.is_empty() { + b.push(vec![]); + } + b.push(vec![if on { colored("❯ ", ACC) } else { plain(" ") }, sp("○ ", Some(DIM), false), sp("不指定", if on { Some(ACC) } else { None }, true)]); + b.extend(para("帳號裡任何一台 AnyBot 都能使用這台電腦。", 6, w, dim)); + } + Step::Confirm => { + let row = |k: &str, v: String| vec![dim(format!("{k}{}", " ".repeat(14usize.saturating_sub(k.width())))), strong(v)]; + b.push(row("這台是", match self.role { Role::Hands => "手腳".into(), Role::Brain => "大腦".into() })); + b.push(row("名字", self.name.clone())); + b.push(row("帳號伺服器", self.server.clone())); + b.push(row("帳號", self.user.clone())); + if self.role == Role::Hands { + b.push(row("給哪個大腦用", self.brain.as_ref().map(|p| p.name.clone()).unwrap_or_else(|| "不指定(帳號裡任何一台)".into()))); + let screen = if self.env.has_display() { + "用這台的螢幕".to_string() + } else if self.env.xvfb { + "開一個 1440×900 的虛擬螢幕".to_string() + } else { + "沒有螢幕:只能用檔案和指令".to_string() + }; + b.push(row("螢幕", screen)); + } + b.push(row("開機自動執行", if self.service { "會(systemd 使用者服務 anybot)".into() } else if self.env.linux { "不會(這台沒有 systemd)".into() } else { "不會(請開 AnyBot App)".into() })); + if self.role == Role::Hands && !self.env.has_display() && !self.env.xvfb && self.env.linux { + b.push(vec![]); + b.extend(para("想讓 Bot 看這台的畫面、操作程式:先執行 ./deploy/linux/hands.sh --deps 裝好虛擬螢幕,再回來。", 0, w, |s| colored(s, WARN))); + } + } + Step::Install | Step::Done => { + for (label, mark, note) in &self.progress { + let icon = match mark { + Mark::Wait => dim("·"), + Mark::Run => colored(SPIN[self.spin % SPIN.len()], ACC), + Mark::Ok => colored("✓", GOOD), + Mark::Warn => colored("!", WARN), + Mark::Fail => colored("✗", BAD), + }; + b.push(vec![icon, plain(" "), sp(label.clone(), if *mark == Mark::Wait { Some(DIM) } else { None }, *mark == Mark::Run)]); + if !note.is_empty() { + let c = match mark { + Mark::Fail => BAD, + Mark::Warn => WARN, + _ => DIM, + }; + b.extend(para(note, 2, w, if c == BAD { |s| colored(s, BAD) } else if c == WARN { |s| colored(s, WARN) } else { dim })); + } + } + if !self.tips.is_empty() { + b.push(vec![]); + for t in &self.tips { + if let Some((k, v)) = t.split_once('\t') { + b.push(vec![dim(format!("{k}{}", " ".repeat(10usize.saturating_sub(k.width())))), colored(v.to_string(), ACC)]); + } else { + b.extend(para(t, 0, w, plain)); + } + } + } + } + } + if let Some(what) = &self.busy { + b.push(vec![]); + b.push(vec![colored(SPIN[self.spin % SPIN.len()], ACC), plain(" "), colored(what.clone(), ACC)]); + } + if !self.error.is_empty() { + b.push(vec![]); + for l in wrap(&self.error, w.saturating_sub(2)) { + b.push(vec![colored(if b.last().map(|x| x.is_empty()).unwrap_or(true) { "✗ " } else { " " }, BAD), colored(l, BAD)]); + } + } + b + } + + fn keys(&self) -> &'static str { + if self.busy.is_some() { + return "Ctrl+C 離開"; + } + match self.step { + Step::Brain => "↑↓ 選擇 Enter 確定 r 重新整理 Esc 上一步", + Step::Role => "↑↓ 選擇 Enter 下一步 Esc 離開", + Step::Account => "↑↓ 換欄位 Enter 登入 Esc 上一步", + Step::Confirm => "Enter 開始設定 Esc 上一步", + Step::Install => "Ctrl+C 離開", + Step::Done => "Enter 離開", + _ => "Enter 下一步 Esc 上一步", + } + } + + fn render(&self, out: &mut impl Write) -> std::io::Result<()> { + let (cols, rows) = terminal::size().unwrap_or((80, 24)); + let cols = cols as usize; + let w = cols.saturating_sub(4).clamp(36, 78); + let inner = w - 4; + let mut lines: Vec = vec![vec![]]; + // Header: the name, and where we are. + let mut head = vec![plain(" "), colored("◆ ", ACC), strong("AnyBot 設定")]; + let at = self.crumb_at(); + let mut crumbs: Line = vec![plain(" ")]; + for (i, c) in self.crumbs().iter().enumerate() { + if i > 0 { + crumbs.push(dim(" ─ ")); + } + let done = i < at || (self.step == Step::Done && self.ok); + crumbs.push(if done { + colored(format!("✓ {c}"), GOOD) + } else if i == at { + sp(format!("{} {c}", i + 1), Some(ACC), true) + } else { + dim(format!("{} {c}", i + 1)) + }); + } + if width(&head) + width(&crumbs) + 4 <= w + 2 { + let gap = (w + 2).saturating_sub(width(&head) + width(&crumbs)); + head.push(plain(" ".repeat(gap))); + head.extend(crumbs.into_iter().skip(1)); + lines.push(head); + } else { + lines.push(head); + lines.push(crumbs); + } + lines.push(vec![]); + // The card. + let (title, sub) = self.title(); + let mut card: Vec = vec![vec![], vec![strong(title)]]; + if !sub.is_empty() { + card.extend(para(&sub, 0, inner, dim)); + } + card.push(vec![]); + card.extend(self.body(inner)); + card.push(vec![]); + let edge = |l: &str, r: &str| vec![plain(" "), dim(format!("{l}{}{r}", "─".repeat(w - 2)))]; + lines.push(edge("╭", "╮")); + for l in card { + let l = clip(l, inner); + let pad = inner.saturating_sub(width(&l)); + let mut row = vec![plain(" "), dim("│ ")]; + row.extend(l); + row.push(plain(" ".repeat(pad))); + row.push(dim(" │")); + lines.push(row); + } + lines.push(edge("╰", "╯")); + lines.push(vec![plain(" "), dim(self.keys())]); + + queue!(out, terminal::BeginSynchronizedUpdate, terminal::Clear(terminal::ClearType::All))?; + for (y, l) in lines.into_iter().enumerate().take(rows as usize) { + queue!(out, cursor::MoveTo(0, y as u16))?; + for (s, st) in clip(l, cols) { + if let Some(c) = st.fg { + queue!(out, SetForegroundColor(c))?; + } + if st.bold { + queue!(out, SetAttribute(Attribute::Bold))?; + } + queue!(out, Print(s), SetAttribute(Attribute::Reset), ResetColor)?; + } + } + queue!(out, terminal::EndSynchronizedUpdate)?; + out.flush() + } + + // ----- moving through the steps ----- + + fn can_reuse(&self) -> bool { + let s = &self.saved; + !s.host_token.is_empty() && s.relay_url == self.server && s.relay_user == self.user.trim() && s.host_name == self.name.trim() + } + + fn start(&mut self, what: &str, job: impl FnOnce() -> Msg + Send + 'static) { + self.error.clear(); + self.busy = Some(what.to_string()); + let tx = self.tx.clone(); + std::thread::spawn(move || { + let _ = tx.send(job()); + }); + } + + fn load_peers(&mut self) { + let Some(s) = self.joined.clone() else { return }; + self.start("正在找帳號裡的大腦…", move || Msg::Peers(list_brains(&s))); + } + + fn on_msg(&mut self, m: Msg) { + match m { + Msg::Server(r) => { + self.busy = None; + match r { + Ok(u) => { + self.server = u.clone(); + self.url = u; + self.focus = if self.user.is_empty() { 0 } else { 1 }; + self.step = Step::Account; + } + Err(e) => self.error = e, + } + } + Msg::Joined(r) => { + self.busy = None; + match r { + Ok(s) => { + self.joined = Some(s); + self.pass.clear(); + match self.role { + Role::Hands => { + self.step = Step::Brain; + self.load_peers(); + } + Role::Brain => self.step = Step::Confirm, + } + } + Err(e) => self.error = e, + } + } + Msg::Peers(r) => { + self.busy = None; + match r { + Ok(p) => { + let want = if self.saved.hands_for.is_empty() { None } else { p.iter().position(|x| x.id == self.saved.hands_for) }; + self.sel = want.or_else(|| p.iter().position(|x| x.online)).unwrap_or(0); + self.peers = p; + } + Err(e) => self.error = e, + } + } + Msg::Progress(i, mark, note) => { + if let Some(p) = self.progress.get_mut(i) { + p.1 = mark; + p.2 = note; + } + } + Msg::Finished(tips) => { + self.ok = !self.progress.iter().any(|p| p.1 == Mark::Fail); + self.tips = tips; + self.step = Step::Done; + } + } + } + + fn back(&mut self) { + self.error.clear(); + self.step = match self.step { + Step::Role => { + self.quit = true; + Step::Role + } + Step::Name => Step::Role, + Step::Server => Step::Name, + Step::Account => Step::Server, + Step::Brain => Step::Account, + Step::Confirm if self.role == Role::Hands => Step::Brain, + Step::Confirm => Step::Account, + s => s, + }; + if self.step == Step::Role { + self.sel = if self.role == Role::Hands { 0 } else { 1 }; + } + } + + fn enter(&mut self) { + self.error.clear(); + match self.step { + Step::Role => { + self.role = if self.sel == 0 { Role::Hands } else { Role::Brain }; + self.step = Step::Name; + } + Step::Name => { + let n = self.name.trim().to_string(); + if n.is_empty() || n.chars().count() > 60 { + self.error = "名字要 1 到 60 個字".into(); + return; + } + self.name = n; + self.step = Step::Server; + } + Step::Server => { + let u = self.url.clone(); + self.start("正在找帳號伺服器…", move || Msg::Server(remote::check_server(&u))); + } + Step::Account => { + // Enter on the name goes to the password first. + if self.focus == 0 && self.pass.is_empty() && !self.can_reuse() { + self.focus = 1; + return; + } + let user = self.user.trim().to_string(); + if user.is_empty() { + self.error = "請輸入帳號".into(); + self.focus = 0; + return; + } + if self.pass.is_empty() { + if self.can_reuse() { + self.joined = Some(self.saved.clone()); + self.on_msg(Msg::Joined(Ok(self.saved.clone()))); + return; + } + self.error = "請輸入密碼".into(); + self.focus = 1; + return; + } + let mut base = self.saved.clone(); + base.host_name = self.name.clone(); + let (url, pass, create, invite) = (self.server.clone(), self.pass.clone(), self.create, self.invite.trim().to_string()); + self.start(if create { "正在建立帳號…" } else { "正在登入…" }, move || Msg::Joined(remote::relay_join(&base, &url, &user, &pass, create, &invite))); + } + Step::Brain => { + self.brain = self.peers.get(self.sel).cloned(); + self.step = Step::Confirm; + } + Step::Confirm => { + self.step = Step::Install; + let plan = Plan { role: self.role, settings: self.joined.clone().unwrap_or_default(), brain: self.brain.clone(), env: self.env.clone(), service: self.service }; + self.progress = plan.steps().into_iter().map(|s| (s, Mark::Wait, String::new())).collect(); + let tx = self.tx.clone(); + std::thread::spawn(move || plan.run(&tx)); + } + Step::Install => {} + Step::Done => self.quit = true, + } + } + + fn key(&mut self, code: KeyCode, mods: KeyModifiers) { + if mods.contains(KeyModifiers::CONTROL) && matches!(code, KeyCode::Char('c') | KeyCode::Char('d')) { + self.quit = true; + return; + } + if self.busy.is_some() || self.step == Step::Install { + return; + } + let rows = match self.step { + Step::Role => 2, + Step::Brain => self.peers.len() + 1, + Step::Account => { + if self.create { + 4 + } else { + 3 + } + } + _ => 0, + }; + match code { + KeyCode::Esc => self.back(), + KeyCode::Enter => self.enter(), + KeyCode::Up | KeyCode::BackTab if rows > 0 => { + if self.step == Step::Account { + self.focus = (self.focus + rows - 1) % rows; + } else { + self.sel = (self.sel + rows - 1) % rows; + } + } + KeyCode::Down | KeyCode::Tab if rows > 0 => { + if self.step == Step::Account { + self.focus = (self.focus + 1) % rows; + } else { + self.sel = (self.sel + 1) % rows; + } + } + KeyCode::Char('r') if self.step == Step::Brain => self.load_peers(), + KeyCode::Char(' ') if self.step == Step::Account && self.focus == 2 => { + self.create = !self.create; + if !self.create && self.focus > 2 { + self.focus = 2; + } + } + KeyCode::Char(c) if !mods.contains(KeyModifiers::CONTROL) => self.type_text(&c.to_string()), + KeyCode::Char('u') if mods.contains(KeyModifiers::CONTROL) => { + if let Some(f) = self.text_field() { + f.clear(); + } + } + KeyCode::Backspace => { + if let Some(f) = self.text_field() { + f.pop(); + } + } + _ => {} + } + } + + fn text_field(&mut self) -> Option<&mut String> { + match (self.step, self.focus) { + (Step::Name, _) => Some(&mut self.name), + (Step::Server, _) => Some(&mut self.url), + (Step::Account, 0) => Some(&mut self.user), + (Step::Account, 1) => Some(&mut self.pass), + (Step::Account, 3) => Some(&mut self.invite), + _ => None, + } + } + + fn type_text(&mut self, s: &str) { + let s: String = s.chars().filter(|c| !c.is_control()).collect(); + if let Some(f) = self.text_field() { + f.push_str(&s); + } + } +} + +/// The account's AnyBots that can be a brain (not this one, not one showing another's bots). +fn list_brains(s: &Settings) -> Result, String> { + let all = remote::peers(s)?; + let mut v: Vec = all + .iter() + .filter(|h| h["id"].as_str() != Some(s.host_id.as_str()) && h["uses"].as_str().unwrap_or("").is_empty()) + .map(|h| Peer { + id: h["id"].as_str().unwrap_or("").into(), + name: h["name"].as_str().unwrap_or("").into(), + online: h["online"].as_bool().unwrap_or(false), + last: h["last_seen_text"].as_str().unwrap_or("").into(), + }) + .collect(); + v.sort_by_key(|p| !p.online); + Ok(v) +} + +// ---------- doing it ---------- + +struct Plan { + role: Role, + settings: Settings, + brain: Option, + env: Env, + service: bool, +} + +impl Plan { + fn steps(&self) -> Vec { + let mut v = vec!["儲存設定".to_string(), "開機自動執行".into(), "連上帳號伺服器".into()]; + if let (Role::Hands, Some(b)) = (self.role, &self.brain) { + v.push(format!("大腦「{}」", b.name)); + } + v + } + + fn run(&self, tx: &Sender) { + let say = |i: usize, m: Mark, n: &str| { + let _ = tx.send(Msg::Progress(i, m, n.to_string())); + }; + // 1. Settings (this AnyBot's own file; the service reads it). + say(0, Mark::Run, ""); + if self.service { + let _ = systemctl(&["stop", UNIT]); + } + let mut st = State::load(); + let mut r = self.settings.clone(); + match self.role { + Role::Hands => { + r.device_link = true; + match &self.brain { + Some(b) => { + r.hands_for = b.id.clone(); + r.hands_for_name = b.name.clone(); + } + None => { + r.hands_for.clear(); + r.hands_for_name.clear(); + } + } + } + Role::Brain => { + // A brain shows its own bots and lends its hands to no one. + r.brain_id.clear(); + r.brain_name.clear(); + r.hands_for.clear(); + r.hands_for_name.clear(); + r.device_link = false; + } + } + st.remote = r.clone(); + st.save(); + say(0, Mark::Ok, &format!("存在 {}", State::path().display())); + + // 2. Start with the computer. + say(1, Mark::Run, ""); + let started = if !self.env.linux { + say(1, Mark::Warn, "Mac 和 Windows:打開 AnyBot App 就會用這些設定連上(設定 › 手機與帳號)。"); + false + } else if !self.service { + say(1, Mark::Warn, "這台沒有 systemd:請用「anybot --serve」啟動,或把它加到開機執行。"); + false + } else { + match self.install_service() { + Ok(note) => { + say(1, Mark::Ok, ¬e); + true + } + Err(e) => { + say(1, Mark::Fail, &e); + false + } + } + }; + + // 3. The account server sees it. + if started { + say(2, Mark::Run, ""); + let t0 = Instant::now(); + let mut seen = false; + while t0.elapsed() < Duration::from_secs(40) { + if let Ok(all) = remote::peers(&r) { + if all.iter().any(|h| h["id"].as_str() == Some(r.host_id.as_str()) && h["online"].as_bool() == Some(true)) { + seen = true; + break; + } + } + std::thread::sleep(Duration::from_secs(1)); + } + if seen { + say(2, Mark::Ok, &format!("已連線({} 秒)", t0.elapsed().as_secs().max(1))); + } else { + say(2, Mark::Fail, "40 秒內沒有連上。看看紀錄:journalctl --user -u anybot -n 50"); + } + } else { + say(2, Mark::Warn, "啟動 AnyBot 後就會連上。"); + } + + // 4. The brain is there. + if let (Role::Hands, Some(b)) = (self.role, &self.brain) { + say(3, Mark::Run, ""); + let online = remote::peers(&r).ok().and_then(|all| all.iter().find(|h| h["id"].as_str() == Some(b.id.as_str())).map(|h| h["online"].as_bool() == Some(true))); + match online { + Some(true) => say(3, Mark::Ok, "在線上。大腦約一分鐘內就會看到這台,它的 Bot 就能用這台了。"), + Some(false) => say(3, Mark::Warn, "目前離線;它開機連上後就能用這台。"), + None => say(3, Mark::Warn, "帳號裡找不到它了(被移除了?)"), + } + } + + let mut tips = vec![]; + match self.role { + Role::Hands => tips.push(format!("在大腦上跟 Bot 說「用 {} 的瀏覽器打開…」或「看一下 {} 的畫面」試試。", r.host_name, r.host_name)), + Role::Brain => tips.push("其他電腦:AnyBot › 設定 › 手機與帳號,登入同一個帳號,在「大腦」選這台。沒有視窗的 Linux:anybot setup 選「手腳」。".into()), + } + if started { + tips.push(String::new()); + tips.push("看狀態\tsystemctl --user status anybot".into()); + tips.push("看紀錄\tjournalctl --user -u anybot -f".into()); + } + tips.push("改設定\tanybot setup".into()); + if started && !lingering() { + tips.push(String::new()); + tips.push(format!("登出或重開機後也要繼續跑,請執行一次:sudo loginctl enable-linger {}", std::env::var("USER").unwrap_or_default())); + } + let _ = tx.send(Msg::Finished(tips)); + } + + /// A small launcher and a systemd user service that runs it. + fn install_service(&self) -> Result { + let home = dirs::home_dir().ok_or("找不到家目錄")?; + let exe = std::env::current_exe().and_then(|p| p.canonicalize()).map_err(|e| format!("找不到 AnyBot 程式:{e}"))?; + let bin = home.join(".local/bin"); + std::fs::create_dir_all(&bin).map_err(|e| format!("建立 {} 失敗:{e}", bin.display()))?; + let launcher = bin.join("anybot-run"); + std::fs::write(&launcher, launcher_script(self.role, &exe, &self.env)).map_err(|e| format!("寫入 {} 失敗:{e}", launcher.display()))?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let _ = std::fs::set_permissions(&launcher, std::fs::Permissions::from_mode(0o755)); + } + let units = home.join(".config/systemd/user"); + std::fs::create_dir_all(&units).map_err(|e| e.to_string())?; + // An older setup (deploy/linux/hands.sh) used another service name. + if units.join("anybot-hands.service").exists() { + let _ = systemctl(&["disable", "--now", "anybot-hands.service"]); + let _ = std::fs::remove_file(units.join("anybot-hands.service")); + } + let what = match self.role { + Role::Hands => "手腳", + Role::Brain => "大腦", + }; + let unit = format!( + "[Unit]\nDescription=AnyBot({what})\nAfter=network-online.target graphical-session.target\nWants=network-online.target\n\n[Service]\nExecStart={}\nRestart=always\nRestartSec=5\n\n[Install]\nWantedBy=default.target\n", + launcher.display() + ); + std::fs::write(units.join(UNIT), unit).map_err(|e| e.to_string())?; + systemctl(&["daemon-reload"])?; + systemctl(&["enable", UNIT])?; + systemctl(&["restart", UNIT])?; + Ok(format!("systemd 使用者服務「anybot」,已啟動({})", launcher.display())) + } +} + +fn launcher_script(role: Role, exe: &std::path::Path, env: &Env) -> String { + let q = |s: &str| format!("'{}'", s.replace('\'', "'\\''")); + let mut s = String::from("#!/bin/sh\n# Started by systemd (anybot.service); written by `anybot setup`.\n"); + s += "export LANG=\"${LANG:-C.UTF-8}\"\n"; + if let Ok(d) = std::env::var("ANYBOT_DATA_DIR") { + s += &format!("export ANYBOT_DATA_DIR={}\n", q(&d)); + } + let bin = q(&exe.display().to_string()); + if role == Role::Hands { + s += "# The accessibility bus, so bots can read apps' buttons instead of only looking at pixels.\n"; + s += "export NO_AT_BRIDGE=0 GTK_A11Y=atspi QT_LINUX_ACCESSIBILITY_ALWAYS_ON=1\n"; + if env.has_display() && env.display != "wayland" && env.display != "desktop" { + s += &format!("# The screen this computer had when it was set up.\nexport DISPLAY=\"${{DISPLAY:-{}}}\"\n", env.display); + if let Ok(x) = std::env::var("XAUTHORITY") { + s += &format!("export XAUTHORITY=\"${{XAUTHORITY:-{x}}}\"\n"); + } + } + s += "if [ -z \"$DISPLAY\" ] && [ -z \"$WAYLAND_DISPLAY\" ] && command -v xvfb-run >/dev/null; then\n"; + s += " # No screen: a virtual one, a window manager, and a session bus for the accessibility bus.\n"; + s += &format!(" exec xvfb-run -a -s \"-screen 0 1440x900x24\" dbus-run-session -- sh -c 'command -v openbox >/dev/null && (openbox >/dev/null 2>&1 &); exec \"$0\" --serve' {bin}\n"); + s += "fi\n"; + } + s += &format!("exec {bin} --serve\n"); + s +} + +fn systemctl(args: &[&str]) -> Result<(), String> { + let o = std::process::Command::new("systemctl").arg("--user").args(args).output().map_err(|e| format!("執行 systemctl 失敗:{e}"))?; + if o.status.success() { + Ok(()) + } else { + Err(format!("systemctl {} 失敗:{}", args.join(" "), String::from_utf8_lossy(&o.stderr).trim())) + } +} + +fn lingering() -> bool { + let user = std::env::var("USER").unwrap_or_default(); + std::process::Command::new("loginctl").args(["show-user", &user, "-p", "Linger"]).output().map(|o| String::from_utf8_lossy(&o.stdout).contains("Linger=yes")).unwrap_or(true) +} + +// ---------- entry ---------- + +pub fn run(args: &[String]) -> i32 { + let flag = |k: &str| args.iter().position(|a| a == k).and_then(|i| args.get(i + 1)).cloned(); + if args.iter().any(|a| a == "-h" || a == "--help") { + println!("anybot setup:把這台設成大腦,或大腦的手腳。\n\n在終端機裡直接執行會一步一步問你。給程式用:\n anybot setup --role hands|brain --name 名字 --server 網址 --user 帳號 [--brain 大腦名字|any]\n [--create] [--invite 碼] [--no-service] [--password-stdin]\n 密碼放在 ANYBOT_ACCOUNT_PASSWORD,或加 --password-stdin 從標準輸入讀。"); + return 0; + } + if flag("--role").is_some() || !std::io::stdin().is_terminal() || !std::io::stdout().is_terminal() { + return run_plain(args); + } + let mut w = Wiz::new(); + if args.iter().any(|a| a == "--no-service") { + w.service = false; + } + let mut out = std::io::stdout(); + if terminal::enable_raw_mode().is_err() { + return run_plain(args); + } + let _ = execute!(out, terminal::EnterAlternateScreen, cursor::Hide, event::EnableBracketedPaste); + let restore = |out: &mut std::io::Stdout| { + let _ = execute!(out, event::DisableBracketedPaste, cursor::Show, terminal::LeaveAlternateScreen); + let _ = terminal::disable_raw_mode(); + }; + let rx = w.rx.take().expect("channel"); + let mut dirty = true; + while !w.quit { + if dirty { + if w.render(&mut out).is_err() { + break; + } + dirty = false; + } + if event::poll(Duration::from_millis(90)).unwrap_or(false) { + match event::read() { + Ok(Event::Key(k)) if k.kind != KeyEventKind::Release => w.key(k.code, k.modifiers), + Ok(Event::Paste(s)) => w.type_text(&s), + _ => {} + } + dirty = true; + } + while let Ok(m) = rx.try_recv() { + w.on_msg(m); + dirty = true; + } + if w.busy.is_some() || w.step == Step::Install { + w.spin += 1; + dirty = true; + } + } + restore(&mut out); + // Leave a short note behind in the terminal. + if w.step == Step::Done { + let (mark, c) = if w.ok { ("✓", GOOD) } else { ("✗", BAD) }; + let _ = execute!(out, SetForegroundColor(c), Print(format!("{mark} ")), ResetColor); + println!( + "{}", + match (w.ok, w.role) { + (true, Role::Hands) => format!("「{}」現在是{}的手腳。", w.name, w.brain.as_ref().map(|b| format!("大腦「{}」", b.name)).unwrap_or_else(|| "帳號裡大腦".into())), + (true, Role::Brain) => format!("「{}」現在是大腦。", w.name), + (false, _) => "設定沒有完成,再執行一次 anybot setup 看看。".into(), + } + ); + for (label, mark, note) in &w.progress { + if matches!(mark, Mark::Fail | Mark::Warn) { + println!(" {label}:{note}"); + } + } + 0 + } else { + println!("沒有改任何設定。"); + 1 + } +} + +/// No terminal (or --role given): the same, from flags, printed line by line. +fn run_plain(args: &[String]) -> i32 { + let flag = |k: &str| args.iter().position(|a| a == k).and_then(|i| args.get(i + 1)).cloned(); + let fail = |m: &str| { + eprintln!("✗ {m}"); + 1 + }; + let role = match flag("--role").as_deref() { + Some("hands") | Some("手腳") => Role::Hands, + Some("brain") | Some("大腦") => Role::Brain, + _ => return fail("請用 --role hands 或 --role brain(在終端機直接執行 anybot setup 會一步一步問)"), + }; + let saved = State::load().remote; + let name = flag("--name").unwrap_or_else(|| if saved.host_name.is_empty() { remote::default_host_name() } else { saved.host_name.clone() }); + let Some(server) = flag("--server").or_else(|| (!saved.relay_url.is_empty()).then(|| saved.relay_url.clone())) else { return fail("請用 --server 給帳號伺服器的網址") }; + let server = match remote::check_server(&server) { + Ok(u) => u, + Err(e) => return fail(&e), + }; + println!("✓ 帳號伺服器 {server}"); + let user = flag("--user").unwrap_or_else(|| saved.relay_user.clone()); + let mut pass = std::env::var("ANYBOT_ACCOUNT_PASSWORD").unwrap_or_default(); + if args.iter().any(|a| a == "--password-stdin") { + let mut s = String::new(); + let _ = std::io::stdin().read_line(&mut s); + pass = s.trim_end_matches(['\n', '\r']).to_string(); + } + let joined = if pass.is_empty() && !saved.host_token.is_empty() && saved.relay_url == server && saved.relay_user == user && saved.host_name == name { + saved.clone() + } else { + if user.is_empty() || pass.is_empty() { + return fail("請給 --user,密碼放在 ANYBOT_ACCOUNT_PASSWORD(或用 --password-stdin)"); + } + let mut base = saved.clone(); + base.host_name = name.clone(); + match remote::relay_join(&base, &server, &user, &pass, args.iter().any(|a| a == "--create"), &flag("--invite").unwrap_or_default()) { + Ok(s) => s, + Err(e) => return fail(&e), + } + }; + println!("✓ 已登入帳號 {user}(這台叫「{name}」)"); + let mut brain = None; + if role == Role::Hands { + match flag("--brain").as_deref() { + None | Some("any") | Some("") => {} + Some(want) => match list_brains(&joined) { + Ok(list) => match list.into_iter().find(|p| p.name == want) { + Some(p) => brain = Some(p), + None => return fail(&format!("帳號裡沒有叫「{want}」的 AnyBot")), + }, + Err(e) => return fail(&e), + }, + } + } + let env = Env::detect(); + let plan = Plan { role, settings: joined, brain, service: env.systemd && !args.iter().any(|a| a == "--no-service"), env }; + let steps = plan.steps(); + let (tx, rx) = channel(); + std::thread::spawn(move || plan.run(&tx)); + let mut failed = false; + for m in rx { + match m { + Msg::Progress(i, mark, note) if mark != Mark::Run && mark != Mark::Wait => { + let icon = match mark { + Mark::Ok => "✓", + Mark::Warn => "!", + _ => "✗", + }; + failed |= mark == Mark::Fail; + println!("{icon} {}{}", steps.get(i).cloned().unwrap_or_default(), if note.is_empty() { String::new() } else { format!(":{note}") }); + } + Msg::Finished(tips) => { + for t in tips.iter().filter(|t| !t.is_empty()) { + println!(" {}", t.replace('\t', " ")); + } + break; + } + _ => {} + } + } + if failed { + 1 + } else { + 0 + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn wraps_chinese_by_width() { + let lines = wrap("讓大腦的 Bot 使用這台電腦", 10); + assert!(lines.iter().all(|l| l.width() <= 10), "{lines:?}"); + assert_eq!(lines.concat(), "讓大腦的 Bot 使用這台電腦"); + let l = wrap("一二三四五。", 5); + assert!(!l[1].starts_with('。'), "{l:?}"); + let l = wrap("一二三四(五)。", 5); + assert!(l.iter().all(|x| !x.starts_with(')') && !x.starts_with('。')), "{l:?}"); + } + + #[test] + fn launcher_makes_a_screen_when_there_is_none() { + let env = Env { linux: true, display: String::new(), systemd: true, xvfb: true }; + let s = launcher_script(Role::Hands, std::path::Path::new("/home/a b/anybot"), &env); + assert!(s.contains("xvfb-run") && s.contains("'/home/a b/anybot'") && s.ends_with("exec '/home/a b/anybot' --serve\n")); + let brain = launcher_script(Role::Brain, std::path::Path::new("/x/anybot"), &env); + assert!(!brain.contains("xvfb-run")); + } + + #[test] + fn every_step_fits_a_narrow_terminal() { + let mut w = Wiz::new(); + w.peers = vec![Peer { id: "1".into(), name: "很長很長很長很長很長很長很長很長的大腦名字".into(), online: true, last: String::new() }]; + for step in [Step::Role, Step::Name, Step::Server, Step::Account, Step::Brain, Step::Confirm, Step::Done] { + w.step = step; + for l in w.body(40) { + assert!(width(&clip(l, 40)) <= 40); + } + } + } +} diff --git a/src/tunnel.rs b/src/tunnel.rs index 4a2d6f3..bfe7f09 100644 --- a/src/tunnel.rs +++ b/src/tunnel.rs @@ -289,7 +289,7 @@ fn on_frame(head: Value, body: Vec, tx: &Sender>, calls: &Calls) { method: s("method"), path: s("path"), query: head["query"].as_object().map(|o| o.iter().map(|(k, v)| (k.clone(), v.as_str().unwrap_or("").to_string())).collect()).unwrap_or_default(), - headers: [("x-anybot-user", s("user")), ("x-anybot-role", if head["role"].is_string() { s("role") } else { "owner".into() })].into_iter().map(|(k, v)| (k.to_string(), v)).collect(), + headers: [("x-anybot-user", s("user")), ("x-anybot-role", if head["role"].is_string() { s("role") } else { "owner".into() }), ("x-anybot-from", s("from"))].into_iter().map(|(k, v)| (k.to_string(), v)).collect(), body, peer: "relay".into(), };