// `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, Vec), String>; enum Msg { /// Joined the account; with a code, the brain it was made for. Joined(Box), Progress(usize, Mark, String), Finished(Vec), } /// 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, bold: bool, } type Span = (String, Sty); type Line = Vec; fn sp(s: impl Into, fg: Option, bold: bool) -> Span { (s.into(), Sty { fg, bold }) } fn plain(s: impl Into) -> Span { sp(s, None, false) } fn dim(s: impl Into) -> Span { sp(s, Some(DIM), false) } fn strong(s: impl Into) -> Span { sp(s, None, true) } fn colored(s: impl Into, c: Color) -> Span { sp(s, Some(c), false) } fn width(l: &Line) -> usize { l.iter().map(|(s, _)| s.width()).sum() } /// Break text to lines at most `w` wide (anywhere: Chinese has no spaces). fn wrap(text: &str, w: usize) -> Vec { let mut out = vec![]; for para in text.split('\n') { let mut cur = String::new(); let mut cw = 0; for ch in para.chars() { let c = ch.width().unwrap_or(0); if cw + c > w && !cur.is_empty() { // A line never starts with closing punctuation: take the last character along. let closing = |c: char| "。,、;:」)!?』,.;:!?)".contains(c); let mut carry = String::new(); if closing(ch) { while let Some(p) = cur.pop() { carry.insert(0, p); if !closing(p) || cur.is_empty() { break; } } } out.push(std::mem::take(&mut cur)); cw = carry.width(); cur = carry; } cur.push(ch); cw += c; } out.push(cur); } out } fn para(text: &str, indent: usize, w: usize, f: fn(String) -> Span) -> Vec { wrap(text, w.saturating_sub(indent).max(10)).into_iter().map(|t| vec![plain(" ".repeat(indent)), f(t)]).collect() } /// Cut a line to `w` columns. fn clip(l: Line, w: usize) -> Line { let mut out = vec![]; let mut used = 0; for (s, st) in l { let mut keep = String::new(); for ch in s.chars() { let c = ch.width().unwrap_or(0); if used + c > w { break; } keep.push(ch); used += c; } out.push((keep, st)); if used >= w { break; } } out } // ---------- the setup ---------- struct Wiz { env: Env, step: Step, role: Role, /// 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, peers: Vec, sel: usize, brain: Option, busy: Option, rx: Option>, tx: Sender, spin: usize, error: String, progress: Vec<(String, Mark, String)>, tips: Vec, ok: bool, quit: bool, service: bool, name: Option, } 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 { let mut b: Vec = 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 { (!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, Vec), 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, keys: &str) -> Vec { let w = cols.saturating_sub(4).clamp(36, 78); let inner = w - 4; let mut lines: Vec = vec![vec![], vec![plain(" "), colored("◆ ", ACC), strong("AnyBot")], vec![]]; let mut card: Vec = vec![vec![], vec![strong(title)]]; if !sub.is_empty() { card.extend(para(sub, 0, inner, dim)); } card.push(vec![]); card.extend(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, 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 { let mut b: Vec = 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, String> { let all = remote::peers(s)?; let mut v: Vec = all .iter() .filter(|h| h["id"].as_str() != Some(s.host_id.as_str()) && h["uses"].as_str().unwrap_or("").is_empty() && 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, env: Env, service: bool, } impl Plan { fn steps(&self) -> Vec { let mut v = vec!["儲存設定".to_string(), "開機自動執行".into(), "連上帳號伺服器".into()]; if let (Role::Hands, Some(b)) = (self.role, &self.brain) { v.push(format!("大腦「{}」", b.name)); } v } fn run(&self, tx: &Sender) { let say = |i: usize, m: Mark, n: &str| { let _ = tx.send(Msg::Progress(i, m, n.to_string())); }; // 1. Settings (this AnyBot's own file; the service reads it). say(0, Mark::Run, ""); if self.service { let _ = systemctl(&["stop", UNIT]); } // 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, ¬e); true } Err(e) => { say(1, Mark::Fail, &e); false } } }; // 3. The account server sees it. if started { say(2, Mark::Run, ""); let t0 = Instant::now(); let mut seen = false; while t0.elapsed() < Duration::from_secs(40) { if let Ok(all) = remote::peers(&r) { if all.iter().any(|h| h["id"].as_str() == Some(r.host_id.as_str()) && h["online"].as_bool() == Some(true)) { seen = true; break; } } std::thread::sleep(Duration::from_secs(1)); } if seen { say(2, Mark::Ok, &format!("已連線({} 秒)", t0.elapsed().as_secs().max(1))); } else { say(2, Mark::Fail, "40 秒內沒有連上。看看紀錄:journalctl --user -u anybot -n 50"); } } else { say(2, Mark::Warn, "啟動 AnyBot 後就會連上。"); } // 4. The brain is there. if let (Role::Hands, Some(b)) = (self.role, &self.brain) { say(3, Mark::Run, ""); let online = remote::peers(&r).ok().and_then(|all| all.iter().find(|h| h["id"].as_str() == Some(b.id.as_str())).map(|h| h["online"].as_bool() == Some(true))); match online { Some(true) => say(3, Mark::Ok, "在線上。大腦約一分鐘內就會看到這台,它的 Bot 就能用這台了。"), Some(false) => say(3, Mark::Warn, "目前離線;它開機連上後就能用這台。"), None => say(3, Mark::Warn, "帳號裡找不到它了(被移除了?)"), } } let mut tips = vec![]; match self.role { Role::Hands => tips.push(format!("在大腦上跟 Bot 說「用 {} 的瀏覽器打開…」或「看一下 {} 的畫面」試試。", r.host_name, r.host_name)), Role::Brain => tips.push("其他電腦:AnyBot › 設定 › 一般 › 手機與帳號,登入同一個帳號,在「大腦」選這台。沒有視窗的 Linux:anybot setup 選「手腳」。".into()), } if started { tips.push(String::new()); tips.push("看狀態\tsystemctl --user status anybot".into()); tips.push("看紀錄\tjournalctl --user -u anybot -f".into()); } tips.push("改設定\tanybot setup".into()); if started && !lingering() { tips.push(String::new()); tips.push(format!("登出或重開機後也要繼續跑,請執行一次:sudo loginctl enable-linger {}", std::env::var("USER").unwrap_or_default())); } let _ = tx.send(Msg::Finished(tips)); } /// A small launcher and a systemd user service that runs it. fn install_service(&self) -> Result { let home = dirs::home_dir().ok_or("找不到家目錄")?; let exe = std::env::current_exe().and_then(|p| p.canonicalize()).map_err(|e| format!("找不到 AnyBot 程式:{e}"))?; let bin = home.join(".local/bin"); std::fs::create_dir_all(&bin).map_err(|e| format!("建立 {} 失敗:{e}", bin.display()))?; let launcher = bin.join("anybot-run"); 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) -> 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::()); } println!(); } /// Shows a spinner on this line while `job` runs (in a terminal). fn waiting(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) -> 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, service: bool) -> i32 { header(); let base = base_settings(name); let (url, code) = (url.to_string(), code.chars().filter(|c| c.is_ascii_digit()).collect::()); 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, name: Option, 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, 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 = ℞ // 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")); } }