AnyBot/install.sh

367 lines
18 KiB
Bash
Raw Normal View History

#!/bin/sh
# AnyBot 一鍵安裝。什麼都沒裝的電腦也可以:會自己裝好需要的東西(系統套件、Rust、Docker)。
#
# curl -fsSL https://code.30cm.net/daniel.w/AnyBot/raw/branch/main/install.sh | sh
# 桌面 App(Mac、有螢幕的 Linux)。沒有螢幕的 Linux 會自動改成「手腳」
# … | sh -s -- hands [網址 配對碼]
# 沒有螢幕的 Linux:裝好後接成大腦的手腳(網址和配對碼在 AnyBot 的「加一台電腦」)
# … | sh -s -- server
# 伺服器:裝 Docker,架好帳號伺服器 + 大腦 + HTTPS(只問網域、帳號、密碼)
# … | sh -s -- deps
# 只裝建置需要的東西(之後自己 make)
#
# 已經 clone 下來的話,在資料夾裡執行 ./install.sh [app|hands|server|deps],效果一樣。
# 環境變數:ANYBOT_DIR(放原始碼的地方,預設 ~/AnyBot)、ANYBOT_BRANCH(預設 main)。
set -eu
REPO=${ANYBOT_REPO:-https://code.30cm.net/daniel.w/AnyBot.git}
BRANCH=${ANYBOT_BRANCH:-main}
DIR=${ANYBOT_DIR:-$HOME/AnyBot}
RUST_MIN=95 # Rust 1.95 以上
MODE=${1:-auto}
[ $# -gt 0 ] && shift
case $MODE in
app|hands|headless|server|deps|auto) ;;
-h|--help|help)
# 用 curl | sh 執行時 $0 是「sh」,讀不到腳本原文:改印內嵌的說明。
usage=$(sed -n '2,14p' "$0" 2>/dev/null | sed 's/^# \{0,1\}//' || true)
[ -n "$usage" ] || usage='AnyBot 一鍵安裝:
sh install.sh 桌面 App(Mac、有螢幕的 Linux)
sh install.sh hands [網址 配對碼] 沒有螢幕的 Linux,接成大腦的手腳
sh install.sh server 伺服器:帳號伺服器 + 大腦 + HTTPS(Docker)
sh install.sh deps 只裝建置需要的東西(之後自己 make)
環境變數:ANYBOT_DIR(原始碼位置,預設 ~/AnyBot)、ANYBOT_BRANCH(預設 main)。'
printf '%s\n' "$usage"; exit 0 ;;
*) echo "不認得「${MODE}」。可以用:app、hands、server、deps"; exit 1 ;;
esac
[ "$MODE" = headless ] && MODE=hands
# ---------- 外觀 ----------
if [ -t 1 ]; then B=$(printf '\033[1m'); D=$(printf '\033[90m'); G=$(printf '\033[32m'); Y=$(printf '\033[33m'); R=$(printf '\033[31m'); C=$(printf '\033[36m'); N=$(printf '\033[0m')
else B=; D=; G=; Y=; R=; C=; N=; fi
STEP=0; TOTAL=4
step() { STEP=$((STEP + 1)); printf '\n%s[%d/%d]%s %s%s%s\n' "$C" "$STEP" "$TOTAL" "$N" "$B" "$1" "$N"; }
ok() { printf ' %s✓%s %s\n' "$G" "$N" "$1"; }
info() { printf ' %s%s%s\n' "$D" "$1" "$N"; }
warn() { printf ' %s!%s %s\n' "$Y" "$N" "$1"; }
die() { printf '\n %s✗ %s%s\n' "$R" "$1" "$N"; [ $# -gt 1 ] && printf ' %s\n' "$2"; exit 1; }
have() { command -v "$1" >/dev/null 2>&1; }
# 互動輸入:用 curl | sh 時標準輸入是腳本本身,改從終端機讀。
TTY=/dev/tty
[ -r "$TTY" ] && [ -w "$TTY" ] && (exec <"$TTY") 2>/dev/null || TTY=
# PATH 提示要看的是使用者登入後的 PATH,不是腳本自己改過的(下面會加 ~/.local/bin)。
START_PATH=$PATH
# ---------- 這台是什麼 ----------
OS=$(uname -s); ARCH=$(uname -m)
case $OS in
Darwin) PLAT=mac ;;
Linux) PLAT=linux ;;
*) die "這個腳本只支援 macOS 和 Linux。" "Windows:照 docs/install.md 用 cargo build --release。" ;;
esac
if [ "$MODE" = auto ]; then
if [ $PLAT = linux ] && [ -z "${DISPLAY:-}${WAYLAND_DISPLAY:-}" ] && ! have gnome-shell && ! have plasmashell && ! have startxfce4; then
MODE=hands
else
MODE=app
fi
fi
[ $PLAT = mac ] && [ "$MODE" = hands ] && MODE=app
SUDO=
if [ $PLAT = linux ] && [ "$(id -u)" != 0 ]; then
have sudo || die "需要 sudo 才能安裝系統套件。" "請用 root 執行,或先裝 sudo。"
SUDO=sudo
fi
PM=
if [ $PLAT = linux ]; then
for p in apt-get dnf pacman zypper; do have $p && { PM=$p; break; }; done
fi
case $MODE in
app) TITLE="桌面 App" ;;
hands) TITLE="沒有螢幕的電腦(手腳)" ;;
server) TITLE="伺服器(帳號伺服器 + 大腦 + HTTPS)"; TOTAL=3 ;;
deps) TITLE="只裝建置需要的東西"; TOTAL=2 ;;
esac
printf '\n %s◆ AnyBot 安裝%s %s%s · %s %s%s\n' "$C$B" "$N" "$D" "$TITLE" "$OS" "$ARCH" "$N"
# ---------- 系統套件 ----------
# apt:只裝這個發行版有的名字(例如 Ubuntu 24.04 的 libasound2 叫 libasound2t64)。
# 最後加 true:清單最後一個套件不存在時,最後一条 apt-cache 判斷會失敗,
# 在 set -e 下整個腳本會無訊息退出(呼叫端是 pkgs=$(apt_pick …))。
apt_pick() {
for p in "$@"; do
case $p in
*\|*) # 幾個名字選第一個有的
for alt in $(echo "$p" | tr '|' ' '); do
if apt-cache show "$alt" >/dev/null 2>&1; then echo "$alt"; break; fi
done ;;
*) apt-cache show "$p" >/dev/null 2>&1 && echo "$p" ;;
esac
done
true
}
# zypper:同 apt_pick,裝之前先過濾掉這台找不到的名字(SUSE 各版本套件名差異大,
# 一個名字不存在會讓整組安裝失敗)。
zypper_pick() {
for p in "$@"; do
zypper --non-interactive info "$p" >/dev/null 2>&1 && echo "$p"
done
true
}
linux_packages() {
# $1: build | app | hands | server
case $PM in
apt-get)
case $1 in
server) set -- curl ca-certificates git ;;
build) set -- build-essential pkg-config git curl ca-certificates libasound2-dev ;;
# 手腳:虛擬螢幕、視窗管理員、輔助使用服務(讀 App 的按鈕)、session bus,Bot 才看得到這台的畫面。
hands) set -- build-essential pkg-config git curl ca-certificates libasound2-dev "libasound2t64|libasound2" \
xvfb openbox at-spi2-core libatk-adaptor dbus-x11 dbus-user-session xauth fonts-noto-cjk ;;
app) set -- build-essential pkg-config git curl ca-certificates libasound2-dev "libasound2t64|libasound2" \
libxkbcommon-x11-0 "libgl1|libgl1-mesa-glx" xdg-utils libnotify-bin libsecret-tools espeak-ng zenity \
xclip wl-clipboard ffmpeg fonts-noto-cjk ;;
esac
# A fresh system has no package lists yet: fetch them first, or nothing can be picked.
if ! ls /var/lib/apt/lists/*_Packages >/dev/null 2>&1; then
$SUDO apt-get update -qq || warn "apt-get update 有錯誤,繼續試試看"
UPDATED=1
fi
pkgs=$(apt_pick "$@" | tr '\n' ' ')
# Already there (an update): nothing to install, no sudo password needed.
missing=""
for p in $pkgs; do dpkg -s "$p" >/dev/null 2>&1 || missing="$missing $p"; done
if [ -z "$missing" ]; then info "都已經裝好了"; return 0; fi
pkgs=$missing
[ -n "${UPDATED:-}" ] || $SUDO apt-get update -qq || warn "apt-get update 有錯誤,繼續試試看"
info "apt-get install$pkgs"
$SUDO env DEBIAN_FRONTEND=noninteractive apt-get install -y -qq $pkgs >/dev/null || die "安裝系統套件失敗。" "請看上面的錯誤,或手動執行:sudo apt-get install $pkgs"
;;
dnf)
base="gcc gcc-c++ make pkgconf-pkg-config git curl ca-certificates alsa-lib-devel"
extra=""
[ "$1" = app ] && extra="libxkbcommon-x11 mesa-libGL xdg-utils libnotify libsecret espeak-ng zenity xclip wl-clipboard ffmpeg-free google-noto-sans-cjk-fonts"
[ "$1" = hands ] && extra="xorg-x11-server-Xvfb openbox at-spi2-core at-spi2-atk dbus-x11 xorg-x11-xauth google-noto-sans-cjk-fonts"
[ "$1" = server ] && base="curl git ca-certificates"
$SUDO dnf install -y -q $base $extra || die "安裝系統套件失敗。" "sudo dnf install $base $extra"
;;
pacman)
base="base-devel git curl alsa-lib"
[ "$1" = app ] && base="$base libxkbcommon-x11 mesa xdg-utils libnotify libsecret espeak-ng zenity xclip wl-clipboard ffmpeg noto-fonts-cjk"
[ "$1" = hands ] && base="$base xorg-server-xvfb openbox at-spi2-core dbus xorg-xauth noto-fonts-cjk"
[ "$1" = server ] && base="curl git"
$SUDO pacman -S --needed --noconfirm $base || die "安裝系統套件失敗。" "sudo pacman -S --needed $base"
;;
zypper)
base="gcc gcc-c++ make pkg-config git curl alsa-devel"
extra=""
# app/hands 也要有這台该有的套件(之前只有 base,桌面模式缺 xkbcommon/GL/通知…,
# 編譯或執行時才炸)。名字逐個過濾,找不到的跳過。
[ "$1" = app ] && extra="libxkbcommon-x11-0 Mesa libGL1 xdg-utils libnotify-tools libnotify4 libsecret-1-0 espeak zenity xclip wl-clipboard ffmpeg fonts-noto"
[ "$1" = hands ] && extra="xorg-x11-server-Xvfb openbox at-spi2-core at-spi2-atk-common dbus-1-x11 xauth fonts-noto"
[ "$1" = server ] && base="curl git"
pkgs=$(zypper_pick $base $extra)
$SUDO zypper -n install $pkgs || die "安裝系統套件失敗。" "sudo zypper install $pkgs"
;;
*)
die "認不出這台的套件管理程式(apt、dnf、pacman、zypper 都沒有)。" \
"請自己裝:C 編譯器、pkg-config、git、curl、ALSA 開發套件,再執行一次。"
;;
esac
}
mac_tools() {
if xcode-select -p >/dev/null 2>&1; then ok "Xcode Command Line Tools"; return; fi
warn "需要 Xcode Command Line Tools,正在打開安裝視窗…"
xcode-select --install 2>/dev/null || true
die "裝好之後(約 5 分鐘),再執行一次同一行指令。"
}
# ---------- Rust ----------
rust_ok() {
have cargo && have rustc || return 1
# 抓「主版號 次版號」:舊寫法死鎖在 1.x,rustc 2.x 會被当成「太舊」。
set -- $(rustc --version 2>/dev/null | sed -n 's/^rustc \([0-9][0-9]*\)\.\([0-9][0-9]*\).*/\1 \2/p')
[ $# -ge 2 ] || return 1
[ "$1" -gt 1 ] && return 0
[ "$1" -eq 1 ] && [ "$2" -ge "$RUST_MIN" ]
}
ensure_rust() {
[ -f "$HOME/.cargo/env" ] && . "$HOME/.cargo/env"
if rust_ok; then ok "Rust $(rustc --version | cut -d' ' -f2)"; return; fi
if have rustup; then
info "Rust 太舊,更新中(rustup update stable)…"
rustup update stable >/dev/null 2>&1 && rustup default stable >/dev/null 2>&1 || true
# rustup 裝在 ~/.cargo/bin;這個終端機可能還沒有它(--no-modify-path 裝的)。
[ -f "$HOME/.cargo/env" ] && . "$HOME/.cargo/env"
else
if have rustc; then warn "系統的 Rust $(rustc --version | cut -d' ' -f2) 太舊(要 1.$RUST_MIN 以上),另外用 rustup 裝新版"; fi
info "安裝 Rust(rustup,裝在 ~/.cargo,不用 root)…"
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y -q --profile minimal --no-modify-path >/dev/null 2>&1 \
|| die "安裝 Rust 失敗。" "手動:curl --proto '=https' -sSf https://sh.rustup.rs | sh"
. "$HOME/.cargo/env"
fi
rust_ok || die "Rust 還是太舊:$(rustc --version 2>/dev/null)" "執行 rustup update stable,或移除系統的 rustc/cargo 套件。"
ok "Rust $(rustc --version | cut -d' ' -f2)"
case ":$PATH:" in *":$HOME/.cargo/bin:"*) ;; *)
for rc in "$HOME/.profile" "$HOME/.bashrc" "$HOME/.zshrc"; do
[ -f "$rc" ] && ! grep -q '.cargo/env' "$rc" && echo '. "$HOME/.cargo/env"' >>"$rc"
done ;;
esac
}
# ---------- 原始碼 ----------
SRC=
find_source() {
here=$(cd "$(dirname "$0")" 2>/dev/null && pwd || true)
if [ -n "$here" ] && [ -f "$here/Cargo.toml" ] && grep -q '^name = "anybot"' "$here/Cargo.toml"; then
SRC=$here; ok "用這份原始碼:$SRC"; return
fi
have git || die "需要 git。"
if [ -d "$DIR/.git" ]; then
info "更新 $DIR …"
git -C "$DIR" pull --ff-only -q || warn "沒辦法更新(你改過裡面的檔案?),用現有的版本"
else
[ -e "$DIR" ] && die "$DIR 已經存在,但不是 AnyBot 的原始碼。" "換個地方:ANYBOT_DIR=~/別的資料夾 再執行一次。"
info "下載原始碼到 $DIR …"
git clone -q --depth 1 -b "$BRANCH" "$REPO" "$DIR" || die "下載原始碼失敗。" "網路連得到 $REPO 嗎?"
fi
SRC=$DIR; ok "原始碼:${SRC}($(git -C "$SRC" log -1 --format=%h))"
}
# 記憶體小的機器同時編太多會被系統砍掉:每 2 GB 一個工作。
build_jobs() {
mem_mb=0
if [ -r /proc/meminfo ]; then
mem_mb=$(awk '/MemTotal/ {m=$2} /SwapTotal/ {s=$2} END {print int((m + s / 2) / 1024)}' /proc/meminfo)
fi
[ "$mem_mb" -gt 0 ] || { echo ""; return; }
j=$((mem_mb / 2000)); [ $j -lt 1 ] && j=1
cpus=$(nproc 2>/dev/null || echo $j); [ $j -gt "$cpus" ] && j=$cpus
echo $j
if [ "$mem_mb" -lt 3000 ]; then
warn "這台記憶體只有 ${mem_mb} MB,編譯會比較久(一次編一個)。若被中斷,加 2 GB swap 再試:" >&2
info " sudo fallocate -l 2G /swapfile && sudo chmod 600 /swapfile && sudo mkswap /swapfile && sudo swapon /swapfile" >&2
fi
}
build() {
jobs=$(build_jobs)
info "編譯中,第一次約 5–15 分鐘(之後更新只重編改過的部分)…"
log="$SRC/target/install-build.log"; mkdir -p "$SRC/target"
if (cd "$SRC" && cargo build --release --bin anybot ${jobs:+-j $jobs}) >"$log" 2>&1; then
ok "編譯完成"
else
tail -n 25 "$log" | sed 's/^/ /'
die "編譯失敗(完整紀錄:${log})" "把紀錄的最後幾行貼給維護的人;常見原因見 docs/install.md 的「常見問題」。"
fi
}
# ---------- Docker(伺服器)----------
ensure_docker() {
if ! have docker; then
info "安裝 Docker(官方腳本 get.docker.com)…"
curl -fsSL https://get.docker.com | $SUDO sh >/dev/null || die "安裝 Docker 失敗。" "手動:https://docs.docker.com/engine/install/"
fi
$SUDO systemctl enable --now docker >/dev/null 2>&1 || true
docker compose version >/dev/null 2>&1 || $SUDO docker compose version >/dev/null 2>&1 || {
case $PM in
apt-get) $SUDO apt-get install -y -qq docker-compose-plugin >/dev/null 2>&1 || $SUDO apt-get install -y -qq docker-compose-v2 >/dev/null 2>&1 || true ;;
dnf) $SUDO dnf install -y -q docker-compose-plugin >/dev/null 2>&1 || true ;;
esac
}
docker compose version >/dev/null 2>&1 || $SUDO docker compose version >/dev/null 2>&1 || die "沒有 docker compose。" "請更新 Docker:https://docs.docker.com/compose/install/"
ok "Docker $(docker --version 2>/dev/null | sed 's/Docker version \([^,]*\).*/\1/')"
if ! docker info >/dev/null 2>&1; then
if [ -n "$SUDO" ]; then
$SUDO usermod -aG docker "$(id -un)" 2>/dev/null || true
DOCKER_SUDO=1
info "已把你加進 docker 群組(下次登入後不用 sudo);這次先用 sudo。"
fi
fi
}
# =====================================================================
case $MODE in
deps)
step "系統套件"
if [ $PLAT = mac ]; then mac_tools; else linux_packages app; ok "系統套件"; fi
step "Rust"
ensure_rust
printf '\n %s好了。%s在原始碼資料夾裡:%smake%s 建置、%smake install%s 安裝。\n' "$G$B" "$N" "$B" "$N" "$B" "$N"
[ -n "${PS1:-}" ] || info "這個終端機還找不到 cargo 的話:. \"\$HOME/.cargo/env\""
;;
server)
[ $PLAT = linux ] || die "伺服器模式要在 Linux 上跑。" "Mac 當大腦的做法見 docs/multi-device.md。"
step "系統套件和 Docker"
linux_packages server
ensure_docker
step "原始碼"
find_source
step "設定和啟動"
info "接下來會問網域、帳號、密碼。"
cd "$SRC/deploy/server"
IN=$TTY
if [ -n "${ANYBOT_SETUP_USER:-}" ] && [ -n "${ANYBOT_SETUP_PASSWORD:-}" ]; then IN=/dev/null # 免問模式
else [ -n "$TTY" ] || die "需要一個終端機回答問題。" "在伺服器上直接執行:cd $SRC && ./install.sh server(或用 ANYBOT_SETUP_USER、ANYBOT_SETUP_PASSWORD 免問)"; fi
if [ "${DOCKER_SUDO:-0}" = 1 ]; then exec $SUDO -E ./setup.sh "$@" <"$IN"; else exec ./setup.sh "$@" <"$IN"; fi
;;
app|hands)
step "系統套件"
if [ $PLAT = mac ]; then mac_tools
elif [ "$MODE" = hands ] && have anybot && [ $# -gt 0 ]; then ok "AnyBot 已經裝好,跳過"
else linux_packages $MODE; ok "系統套件"; fi
if [ "$MODE" = hands ] && have anybot && [ $# -gt 0 ]; then
STEP=3; info "已經有 $(command -v anybot),直接接上。(要更新:不帶網址再執行一次)"
else
step "Rust"
ensure_rust
step "下載和編譯"
find_source
build
step "安裝"
if [ $PLAT = mac ]; then
(cd "$SRC" && make install >/dev/null) || die "安裝失敗。" "在 $SRC 執行 make install 看錯誤。"
ok "AnyBot.app 放在 ~/Applications"
else
sh "$SRC/deploy/linux/install.sh" >/dev/null || die "安裝失敗。" "執行 sh $SRC/deploy/linux/install.sh 看錯誤。"
ok "程式放在 ~/.local/bin/anybot"
[ "$MODE" = app ] && ok "應用程式選單裡有 AnyBot"
# 更新: the one running in the background (anybot setup's service) switches to the new one.
if [ -f "$HOME/.local/bin/anybot-run" ] && systemctl --user is-enabled --quiet anybot 2>/dev/null; then
"$HOME/.local/bin/anybot" setup --refresh-service >/dev/null 2>&1 && ok "重新啟動背景執行的 AnyBot(換成新版)"
fi
fi
fi
PATH="$HOME/.local/bin:$PATH"
if [ "$MODE" = hands ]; then
if [ $# -gt 0 ]; then
printf '\n'
[ -n "$TTY" ] && exec anybot setup "$@" <"$TTY"
exec anybot setup "$@"
fi
printf '\n %s裝好了。%s接著把這台接到你的大腦:\n' "$G$B" "$N"
printf ' 1. 在手機或電腦的 AnyBot 按「加一台電腦」,取得配對碼\n'
printf ' 2. 在這裡執行:%sanybot setup 網址 配對碼%s\n' "$B" "$N"
else
printf '\n %s裝好了。%s' "$G$B" "$N"
if [ $PLAT = mac ]; then printf '打開 ~/Applications/AnyBot(第一次:按右鍵 › 打開)。\n'
else printf '從應用程式選單打開 AnyBot,或在終端機輸入 %sanybot%s。\n' "$B" "$N"; fi
if ! have docker; then info "Bot 自己的電腦(一個隔離的桌面)需要 Docker,要用時 AnyBot 會告訴你怎麼裝。"; fi
info "要更新:再執行一次同一行指令。"
fi
case ":$START_PATH:" in *":$HOME/.local/bin:"*) ;; *) [ $PLAT = linux ] && info "(終端機找不到 anybot 的話,重新登入,或把 ~/.local/bin 加進 PATH)" ;; esac
;;
esac