AnyBot/src/setup.rs

1200 lines
48 KiB
Rust
Raw Normal View History

// `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<String, String>),
Joined(Result<Settings, String>),
Peers(Result<Vec<Peer>, String>),
Progress(usize, Mark, String),
Finished(Vec<String>),
}
/// 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<Color>,
bold: bool,
}
type Span = (String, Sty);
type Line = Vec<Span>;
fn sp(s: impl Into<String>, fg: Option<Color>, bold: bool) -> Span {
(s.into(), Sty { fg, bold })
}
fn plain(s: impl Into<String>) -> Span {
sp(s, None, false)
}
fn dim(s: impl Into<String>) -> Span {
sp(s, Some(DIM), false)
}
fn strong(s: impl Into<String>) -> Span {
sp(s, None, true)
}
fn colored(s: impl Into<String>, 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<String> {
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<Line> {
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<Settings>,
peers: Vec<Peer>,
brain: Option<Peer>,
busy: Option<String>,
rx: Option<Receiver<Msg>>,
tx: Sender<Msg>,
spin: usize,
error: String,
progress: Vec<(String, Mark, String)>,
tips: Vec<String>,
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<Line> {
let mut b: Vec<Line> = 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<Line> = 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<Line> = 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<Vec<Peer>, String> {
let all = remote::peers(s)?;
let mut v: Vec<Peer> = 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<Peer>,
env: Env,
service: bool,
}
impl Plan {
fn steps(&self) -> Vec<String> {
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<Msg>) {
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, &note);
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<String, String> {
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);
}
}
}
}