AnyBot/src/setup.rs

1250 lines
48 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// `anybot setup`: join this computer to your AnyBot, as hands for your brain (or as a brain), from
// a terminal (over SSH, or on a computer without a window). As little to answer as possible:
//
// anybot setup https://anybot.example.com 482913 the line 「加一台電腦」 shows on the phone or in
// the app: nothing to answer at all
// anybot setup one screen: the address and the code (or the
// account and password instead)
// anybot setup --brain … this computer is a brain, not hands
//
// Then it saves the settings, makes AnyBot start with the computer (systemd; a virtual screen when
// there is none), and waits until the account server and the brain see it. For scripts:
// anybot setup --server URL --user NAME [--brain-name 雲端大腦] [--no-service]
// with the password in ANYBOT_ACCOUNT_PASSWORD.
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";
/// Write a file all at once, as far as the filesystem allows: tmp file + rename. A Ctrl+C in
/// the middle of installing used to leave a half-written launcher/unit behind, and systemd
/// then ran (or refused to run) a broken service with no easy way to see why.
fn write_atomic(path: &std::path::Path, body: String) -> std::io::Result<()> {
let tmp = path.with_extension("anybot-tmp");
std::fs::write(&tmp, body)?;
std::fs::rename(&tmp, path)?;
Ok(())
}
/// Quote a path for a systemd ExecStart line: a home folder with spaces (or a quote sign)
/// made the unit fail to start with no way to see why.
fn systemd_quote(p: &std::path::Path) -> String {
let mut out = String::from("\"");
for c in p.display().to_string().chars() {
match c {
'"' | '$' | '\\' | '`' => {
out.push('\\');
out.push(c);
}
c => out.push(c),
}
}
out.push('"');
out
}
#[derive(Clone, Copy, PartialEq, Debug)]
enum Role {
Hands,
Brain,
}
#[derive(Clone, Copy, PartialEq, Debug)]
enum Step {
Form,
Brain,
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,
}
/// Joining an account: its settings, the brain a code was made for, the brains to choose from.
type Joining = Result<(Settings, Option<Peer>, Vec<Peer>), String>;
enum Msg {
/// Joined the account; with a code, the brain it was made for.
Joined(Box<Joining>),
Progress(usize, Mark, String),
Finished(Vec<String>),
}
/// What this computer has.
#[derive(Clone)]
struct Env {
linux: bool,
display: String,
systemd: 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 }
}
fn has_display(&self) -> bool {
!self.display.is_empty()
}
}
// ---------- 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,
/// Sign in with the account and password instead of a code.
password: bool,
url: String,
code: String,
user: String,
pass: String,
focus: usize,
joined: Option<Settings>,
peers: Vec<Peer>,
sel: usize,
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,
name: Option<String>,
}
impl Wiz {
fn new(role: Role) -> Wiz {
let saved = State::load().remote;
let (tx, rx) = channel();
let env = Env::detect();
Wiz {
role,
password: false,
step: Step::Form,
url: saved.relay_url.clone(),
code: String::new(),
user: saved.relay_user.clone(),
pass: String::new(),
focus: if saved.relay_url.is_empty() { 0 } else { 1 },
joined: None,
peers: vec![],
sel: 0,
brain: None,
busy: None,
rx: Some(rx),
tx,
spin: 0,
error: String::new(),
progress: vec![],
tips: vec![],
ok: false,
quit: false,
service: env.systemd,
env,
name: None,
}
}
/// The rows of the form: text fields, then the switch to the other way of signing in.
fn rows(&self) -> usize {
if self.password {
4
} else {
3
}
}
fn title(&self) -> (String, String) {
match self.step {
Step::Form => (
match self.role {
Role::Hands => "把這台電腦接到你的 AnyBot".into(),
Role::Brain => "把這台設成大腦".into(),
},
if self.password { "用手機登入 AnyBot 的那個網址和帳號。".into() } else { "在手機或電腦的 AnyBot 按「加一台電腦」,會看到網址和配對碼。".into() },
),
Step::Brain => ("要給哪個大腦用?".into(), "帳號裡有好幾台,選一台:只有它能用這台電腦。".into()),
Step::Install => ("正在設定…".into(), String::new()),
Step::Done => (if self.ok { "好了".into() } else { "沒有完成".into() }, 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::Form => {
b.push(self.field("網址", &self.url, self.focus == 0, false, "https://…"));
if self.password {
b.push(self.field("帳號", &self.user, self.focus == 1, false, ""));
b.push(self.field("密碼", &self.pass, self.focus == 2, true, ""));
} else {
b.push(self.field("配對碼", &self.code, self.focus == 1, false, "6 位數字"));
}
b.push(vec![]);
let on = self.focus == self.rows() - 1;
let other = if self.password { "改用配對碼 ›" } else { "沒有配對碼?用帳號密碼登入 ›" };
b.push(vec![if on { colored("❯ ", ACC) } else { plain(" ") }, sp(other, Some(if on { ACC } else { DIM }), false)]);
}
Step::Brain => {
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]);
}
}
Step::Install | Step::Done => b.extend(progress_lines(&self.progress, &self.tips, self.spin, w)),
}
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 (i, l) in wrap(&self.error, w.saturating_sub(2)).into_iter().enumerate() {
b.push(vec![colored(if i == 0 { "✗ " } else { " " }, BAD), colored(l, BAD)]);
}
}
b
}
fn keys(&self) -> &'static str {
if self.busy.is_some() || self.step == Step::Install {
return "Ctrl+C 離開";
}
match self.step {
Step::Form => "Enter 連線 ↑↓ 換欄位 Esc 離開",
Step::Brain => "↑↓ 選擇 Enter 確定",
_ => "Enter 離開",
}
}
fn render(&self, out: &mut impl Write) -> std::io::Result<()> {
let (cols, rows) = terminal::size().unwrap_or((80, 24));
let (title, sub) = self.title();
let lines = card_page(cols as usize, &title, &sub, |w| self.body(w), self.keys());
draw(out, lines, cols as usize, rows as usize)
}
// ----- moving along -----
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 submit(&mut self) {
let base = base_settings(self.name.clone());
let url = self.url.clone();
if self.password {
let (user, pass) = (self.user.trim().to_string(), self.pass.clone());
if user.is_empty() || pass.is_empty() {
self.error = "請輸入帳號和密碼".into();
return;
}
self.start("正在登入…", move || Msg::Joined(Box::new(join_with_password(&base, &url, &user, &pass))));
} else {
let code: String = self.code.chars().filter(|c| c.is_ascii_digit()).collect();
if code.len() != 6 {
self.error = "配對碼是 6 位數字".into();
self.focus = 1;
return;
}
self.start("正在連線…", move || Msg::Joined(Box::new(remote::pair_claim(&base, &url, &code).map(|s| (s.clone(), brain_of(&s), vec![])))));
}
}
fn install(&mut self) {
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));
}
fn on_msg(&mut self, m: Msg) {
match m {
Msg::Joined(r) => {
self.busy = None;
match *r {
Ok((s, brain, choices)) => {
self.joined = Some(s);
self.pass.clear();
if self.role == Role::Hands && brain.is_none() && choices.len() > 1 {
self.sel = choices.iter().position(|p| p.online).unwrap_or(0);
self.peers = choices;
self.step = Step::Brain;
} else {
self.brain = brain;
self.install();
}
}
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 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;
}
if self.step == Step::Done {
if matches!(code, KeyCode::Enter | KeyCode::Esc | KeyCode::Char('q')) {
self.quit = true;
}
return;
}
if self.step == Step::Brain {
let n = self.peers.len().max(1);
match code {
KeyCode::Up => self.sel = (self.sel + n - 1) % n,
KeyCode::Down => self.sel = (self.sel + 1) % n,
KeyCode::Enter => {
self.brain = self.peers.get(self.sel).cloned();
self.install();
}
_ => {}
}
return;
}
let rows = self.rows();
match code {
KeyCode::Esc => self.quit = true,
KeyCode::Up | KeyCode::BackTab => self.focus = (self.focus + rows - 1) % rows,
KeyCode::Down | KeyCode::Tab => self.focus = (self.focus + 1) % rows,
KeyCode::Enter if self.focus == rows - 1 => {
// The switch between a code and the password.
self.password = !self.password;
self.error.clear();
self.focus = 1;
}
KeyCode::Enter if self.focus + 2 < rows && !self.field_done() => self.focus += 1,
KeyCode::Enter => self.submit(),
KeyCode::Char('u') if mods.contains(KeyModifiers::CONTROL) => {
if let Some(f) = self.text_field() {
f.clear();
}
}
KeyCode::Char(c) if !mods.contains(KeyModifiers::CONTROL) => self.type_text(&c.to_string()),
KeyCode::Backspace => {
if let Some(f) = self.text_field() {
f.pop();
}
}
_ => {}
}
}
/// Every text field is filled (Enter connects from anywhere then).
fn field_done(&self) -> bool {
!self.url.trim().is_empty() && if self.password { !self.user.trim().is_empty() && !self.pass.is_empty() } else { !self.code.trim().is_empty() }
}
fn text_field(&mut self) -> Option<&mut String> {
match (self.password, self.focus) {
(_, 0) => Some(&mut self.url),
(false, 1) => Some(&mut self.code),
(true, 1) => Some(&mut self.user),
(true, 2) => Some(&mut self.pass),
_ => None,
}
}
fn type_text(&mut self, s: &str) {
let s: String = s.chars().filter(|c| !c.is_control()).collect();
// A pasted 「anybot setup 網址 配對碼」 line (or the 「curl … | sh -s -- hands 網址 配對碼」 one)
// fills both.
let words: Vec<&str> = s.split_whitespace().collect();
let n = words.len();
if n >= 2 && words[n - 1].chars().all(|c| c.is_ascii_digit()) && words[n - 2].contains("://") && !self.password {
self.url = words[n - 2].to_string();
self.code = words[n - 1].to_string();
self.focus = 1;
return;
}
if let Some(f) = self.text_field() {
f.push_str(&s);
}
}
}
/// The brain a pairing code was made for.
fn brain_of(s: &Settings) -> Option<Peer> {
(!s.hands_for.is_empty()).then(|| Peer { id: s.hands_for.clone(), name: s.hands_for_name.clone(), online: true, last: String::new() })
}
/// Sign in with the password; the brain is picked when there is only one to pick.
fn join_with_password(base: &Settings, url: &str, user: &str, pass: &str) -> Result<(Settings, Option<Peer>, Vec<Peer>), String> {
let url = remote::check_server(url)?;
let s = remote::relay_join(base, &url, user, pass, false, "")?;
let list = list_brains(&s).unwrap_or_default();
let online: Vec<&Peer> = list.iter().filter(|p| p.online).collect();
let brain = if list.len() == 1 { list.first().cloned() } else if online.len() == 1 { Some(online[0].clone()) } else { None };
Ok((s, brain, list))
}
/// A page: the name on top, one card, the keys below.
fn card_page(cols: usize, title: &str, sub: &str, body: impl Fn(usize) -> Vec<Line>, keys: &str) -> Vec<Line> {
let w = cols.saturating_sub(4).clamp(36, 78);
let inner = w - 4;
let mut lines: Vec<Line> = vec![vec![], vec![plain(" "), colored("◆ ", ACC), strong("AnyBot")], vec![]];
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(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(keys)]);
lines
}
fn draw(out: &mut impl Write, lines: Vec<Line>, cols: usize, rows: usize) -> std::io::Result<()> {
queue!(out, terminal::BeginSynchronizedUpdate, terminal::Clear(terminal::ClearType::All))?;
for (y, l) in lines.into_iter().enumerate().take(rows) {
queue!(out, cursor::MoveTo(0, y as u16))?;
print_line(out, l, cols)?;
}
queue!(out, terminal::EndSynchronizedUpdate)?;
out.flush()
}
fn print_line(out: &mut impl Write, l: Line, cols: usize) -> std::io::Result<()> {
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)?;
}
Ok(())
}
/// The steps with their marks, then the tips.
fn progress_lines(progress: &[(String, Mark, String)], tips: &[String], spin: usize, w: usize) -> Vec<Line> {
let mut b: Vec<Line> = vec![];
for (label, mark, note) in progress {
let icon = match mark {
Mark::Wait => dim("·"),
Mark::Run => colored(SPIN[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 f: fn(String) -> Span = match mark {
Mark::Fail => |s| colored(s, BAD),
Mark::Warn => |s| colored(s, WARN),
_ => dim,
};
b.extend(para(note, 2, w, f));
}
}
if !tips.is_empty() {
b.push(vec![]);
for t in 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));
}
}
}
b
}
/// 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() && h["hands"].as_bool() != Some(true))
.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]);
}
// No other AnyBot may be using this folder while the settings are written (a window
// would overwrite them with its own copy). The service was just stopped: give it a
// moment to let go.
let folder = match crate::lock::take(Duration::from_secs(10)) {
Ok(l) => l,
Err(e) => {
say(0, Mark::Fail, &e);
let _ = tx.send(Msg::Finished(vec!["關掉另一個 AnyBot 之後,再執行一次 anybot setup。".into()]));
return;
}
};
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();
// Let go before the service starts again (it takes the folder next).
drop(folder);
say(0, Mark::Ok, "");
// 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");
write_atomic(&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",
systemd_quote(&launcher)
);
write_atomic(&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()))
}
}
/// `anybot setup --refresh-service` (install.sh runs it after an update): write the start script
/// again — a newer AnyBot may start differently — and restart. The computer stays joined as it was,
/// and a screen remembered at setup stays.
fn refresh_service() -> i32 {
let Some(home) = dirs::home_dir() else { return 1 };
let launcher = home.join(".local/bin/anybot-run");
let Ok(old) = std::fs::read_to_string(&launcher) else {
println!("這台沒有設成開機自動執行,不用更新服務。");
return 0;
};
let role = if old.contains("accessibility bus") { Role::Hands } else { Role::Brain };
let display = old.lines().find_map(|l| l.strip_prefix("export DISPLAY=\"${DISPLAY:-").and_then(|r| r.strip_suffix("}\""))).unwrap_or("").to_string();
let env = Env { linux: cfg!(target_os = "linux"), display, systemd: true };
let exe = match std::env::current_exe().and_then(|p| p.canonicalize()) {
Ok(e) => e,
Err(e) => {
eprintln!("✗ 找不到 AnyBot 程式:{e}");
return 1;
}
};
if let Err(e) = write_atomic(&launcher, launcher_script(role, &exe, &env)) {
eprintln!("✗ 寫入 {} 失敗:{e}", launcher.display());
return 1;
}
match systemctl(&["restart", UNIT]) {
Ok(_) => {
println!("已更新並重新啟動 AnyBot 服務。");
0
}
Err(e) => {
eprintln!("✗ 重新啟動失敗:{e}");
1
}
}
}
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";
let real_screen = std::env::args().any(|a| a == "--real-screen");
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");
}
} else if !real_screen {
// Set up without a screen (over SSH, or on Wayland): the bots get a virtual screen of
// their own, even when a desktop session is logged in here — nobody would be there to
// answer its 「允許分享螢幕」 dialog. `anybot setup --real-screen` uses the desktop.
s += "unset DISPLAY WAYLAND_DISPLAY XDG_SESSION_TYPE\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 ----------
/// Flags that take a value (so the value isn't read as the address or the code).
const VALUED: [&str; 4] = ["--server", "--user", "--name", "--brain-name"];
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:把這台電腦接到你的 AnyBot。\n\n anybot setup 網址 配對碼 (手機或電腦的 AnyBot 按「加一台電腦」會給你這一行)\n anybot setup 一個畫面:填網址和配對碼(或帳號密碼)\n 加 --brain 這台當大腦,不當手腳\n 加 --no-service 不設開機自動執行\n 加 --real-screen 讓 Bot 用這台登入中的桌面(預設用自己的虛擬螢幕)\n\n給程式用:anybot setup --server 網址 --user 帳號 [--brain-name 大腦名字],密碼放在 ANYBOT_ACCOUNT_PASSWORD。");
return 0;
}
if args.iter().any(|a| a == "--refresh-service") {
return refresh_service();
}
let role = if args.iter().any(|a| a == "--brain") { Role::Brain } else { Role::Hands };
let mut pos = vec![];
let mut skip = false;
for a in args {
if skip {
skip = false;
} else if VALUED.contains(&a.as_str()) {
skip = true;
} else if !a.starts_with("--") {
pos.push(a.clone());
}
}
let service = !args.iter().any(|a| a == "--no-service");
let name = flag("--name");
if flag("--server").is_some() || flag("--user").is_some() {
return run_script(role, &flag("--server").unwrap_or_default(), &flag("--user").unwrap_or_default(), flag("--brain-name"), name, service);
}
if pos.len() >= 2 {
return run_code(role, &pos[0], &pos[1], name, service);
}
let name_for_screen = name.clone();
if !std::io::stdin().is_terminal() || !std::io::stdout().is_terminal() {
eprintln!("✗ 請用:anybot setup 網址 配對碼(在手機或電腦的 AnyBot 按「加一台電腦」)");
return 1;
}
run_screen(role, service, name_for_screen)
}
/// The one-screen form, then the progress.
fn run_screen(role: Role, service: bool, name: Option<String>) -> i32 {
let mut w = Wiz::new(role);
w.name = name;
w.service = service && w.env.systemd;
let mut out = std::io::stdout();
if terminal::enable_raw_mode().is_err() {
eprintln!("✗ 這個終端機不能用互動畫面。請用:anybot setup 網址 配對碼");
return 1;
}
let _ = execute!(out, terminal::EnterAlternateScreen, cursor::Hide, event::EnableBracketedPaste);
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;
}
}
let _ = execute!(out, event::DisableBracketedPaste, cursor::Show, terminal::LeaveAlternateScreen);
let _ = terminal::disable_raw_mode();
if w.step != Step::Done {
println!("沒有改任何設定。");
return 1;
}
// What happened stays in the terminal.
let mut lines = vec![vec![], vec![plain(" "), colored("◆ ", ACC), strong("AnyBot")], vec![]];
lines.extend(progress_lines(&w.progress, &w.tips, 0, 72).into_iter().map(|l| [vec![plain(" ")], l].concat()));
for l in lines {
let _ = print_line(&mut out, l, 200);
println!();
}
if w.ok {
0
} else {
1
}
}
/// Print a line now (colors only in a terminal).
fn say(l: Line) {
let mut out = std::io::stdout();
if out.is_terminal() {
let _ = print_line(&mut out, l, 400);
} else {
print!("{}", l.into_iter().map(|(s, _)| s).collect::<String>());
}
println!();
}
/// Shows a spinner on this line while `job` runs (in a terminal).
fn waiting<T: Send + 'static>(what: &str, job: impl FnOnce() -> T + Send + 'static) -> T {
let (tx, rx) = channel();
std::thread::spawn(move || {
let _ = tx.send(job());
});
let tty = std::io::stdout().is_terminal();
let mut n = 0;
loop {
if let Ok(v) = rx.recv_timeout(Duration::from_millis(100)) {
if tty {
print!("\r\x1b[K");
let _ = std::io::stdout().flush();
}
return v;
}
if tty {
let mut out = std::io::stdout();
print!("\r\x1b[K ");
let _ = print_line(&mut out, vec![colored(SPIN[n % SPIN.len()], ACC), plain(" "), colored(what.to_string(), ACC)], 200);
let _ = out.flush();
}
n += 1;
}
}
fn header() {
say(vec![]);
say(vec![plain(" "), colored("◆ ", ACC), strong("AnyBot")]);
say(vec![]);
}
fn base_settings(name: Option<String>) -> Settings {
let mut base = State::load().remote;
if let Some(n) = name.filter(|n| !n.trim().is_empty()) {
base.host_name = n.trim().chars().take(60).collect();
}
if base.host_name.is_empty() {
base.host_name = remote::default_host_name();
}
base
}
/// `anybot setup 網址 配對碼`: nothing to answer.
fn run_code(role: Role, url: &str, code: &str, name: Option<String>, service: bool) -> i32 {
header();
let base = base_settings(name);
let (url, code) = (url.to_string(), code.chars().filter(|c| c.is_ascii_digit()).collect::<String>());
let r = waiting("正在連線…", move || remote::pair_claim(&base, &url, &code));
let s = match r {
Ok(s) => s,
Err(e) => {
say(vec![plain(" "), colored("✗ ", BAD), colored(e, BAD)]);
say(vec![plain(" "), dim("在手機或電腦的 AnyBot 按「加一台電腦」拿新的一行再試。")]);
return 1;
}
};
say(vec![plain(" "), colored("✓ ", GOOD), plain(format!("加入帳號 {}(這台叫「{}」)", s.relay_user, s.host_name))]);
let brain = brain_of(&s);
finish(role, s, brain, service)
}
/// Scripts: the account and password.
fn run_script(role: Role, server: &str, user: &str, brain_name: Option<String>, name: Option<String>, service: bool) -> i32 {
header();
let pass = std::env::var("ANYBOT_ACCOUNT_PASSWORD").unwrap_or_default();
if server.is_empty() || user.is_empty() || pass.is_empty() {
say(vec![plain(" "), colored("✗ 請給 --server 和 --user,密碼放在 ANYBOT_ACCOUNT_PASSWORD", BAD)]);
return 1;
}
let base = base_settings(name);
let (server, user2) = (server.to_string(), user.to_string());
let (s, mut brain, list) = match waiting("正在登入…", move || join_with_password(&base, &server, &user2, &pass)) {
Ok(v) => v,
Err(e) => {
say(vec![plain(" "), colored("✗ ", BAD), colored(e, BAD)]);
return 1;
}
};
say(vec![plain(" "), colored("✓ ", GOOD), plain(format!("登入帳號 {user}(這台叫「{}」)", s.host_name))]);
if let Some(want) = brain_name {
match list.into_iter().find(|p| p.name == want) {
Some(p) => brain = Some(p),
None => {
say(vec![plain(" "), colored(format!("✗ 帳號裡沒有叫「{want}」的 AnyBot"), BAD)]);
return 1;
}
}
}
finish(role, s, brain, service)
}
/// Save, start with the computer, check; printed line by line.
fn finish(role: Role, s: Settings, brain: Option<Peer>, service: bool) -> i32 {
let env = Env::detect();
let plan = Plan { role, settings: s, brain, service: service && env.systemd, env };
let steps = plan.steps();
let (tx, rx) = channel();
std::thread::spawn(move || plan.run(&tx));
let mut failed = false;
let mut current = String::new();
loop {
let m = if std::io::stdout().is_terminal() && !current.is_empty() {
let what = current.clone();
let rx2 = &rx;
// Spin until the next message.
let mut n = 0;
loop {
match rx2.recv_timeout(Duration::from_millis(100)) {
Ok(m) => {
print!("\r\x1b[K");
break Some(m);
}
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {
let mut out = std::io::stdout();
print!("\r\x1b[K ");
let _ = print_line(&mut out, vec![colored(SPIN[n % SPIN.len()], ACC), plain(" "), plain(what.clone())], 200);
let _ = out.flush();
n += 1;
}
Err(_) => break None,
}
}
} else {
rx.recv().ok()
};
let Some(m) = m else { break };
match m {
Msg::Progress(i, Mark::Run, _) => current = steps.get(i).cloned().unwrap_or_default(),
Msg::Progress(i, mark, note) if mark != Mark::Wait => {
current.clear();
failed |= mark == Mark::Fail;
let (icon, c) = match mark {
Mark::Ok => ("✓ ", GOOD),
Mark::Warn => ("! ", WARN),
_ => ("✗ ", BAD),
};
say(vec![plain(" "), colored(icon, c), plain(steps.get(i).cloned().unwrap_or_default())]);
if !note.is_empty() {
say(vec![plain(" "), if mark == Mark::Ok { dim(note) } else { colored(note, c) }]);
}
}
Msg::Finished(tips) => {
say(vec![]);
for t in tips {
match t.split_once('\t') {
Some((k, v)) => say(vec![plain(" "), dim(format!("{k}{}", " ".repeat(10usize.saturating_sub(k.width())))), colored(v.to_string(), ACC)]),
None => say(vec![plain(" "), plain(t)]),
}
}
say(vec![]);
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 };
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_screen_fits_a_narrow_terminal() {
let mut w = Wiz::new(Role::Hands);
w.peers = vec![Peer { id: "1".into(), name: "很長很長很長很長很長很長很長很長的大腦名字".into(), online: true, last: String::new() }];
w.error = "配對碼不對,或已經過期(10 分鐘內有效,只能用一次)".into();
for step in [Step::Form, Step::Brain, Step::Install, Step::Done] {
w.step = step;
for pw in [false, true] {
w.password = pw;
for l in w.body(40) {
assert!(width(&clip(l, 40)) <= 40);
}
}
}
}
#[test]
fn a_pasted_line_fills_the_form() {
let mut w = Wiz::new(Role::Hands);
w.focus = 0;
w.url.clear();
w.type_text("anybot setup https://anybot.example.com 482913");
assert_eq!((w.url.as_str(), w.code.as_str()), ("https://anybot.example.com", "482913"));
let mut w = Wiz::new(Role::Hands);
w.focus = 0;
w.url.clear();
w.type_text(&crate::remote::pair_line("https://anybot.example.com/", "135790"));
assert_eq!((w.url.as_str(), w.code.as_str()), ("https://anybot.example.com", "135790"));
}
}