#!/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