2026-11-04 12:56:06 +00:00
|
|
|
|
// `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:
|
2026-11-04 12:38:45 +00:00
|
|
|
|
//
|
2026-11-04 12:56:06 +00:00
|
|
|
|
// 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.
|
2026-11-04 12:38:45 +00:00
|
|
|
|
|
|
|
|
|
|
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";
|
|
|
|
|
|
|
2026-11-05 07:48:22 +00:00
|
|
|
|
/// 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
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-11-04 12:38:45 +00:00
|
|
|
|
#[derive(Clone, Copy, PartialEq, Debug)]
|
|
|
|
|
|
enum Role {
|
|
|
|
|
|
Hands,
|
|
|
|
|
|
Brain,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[derive(Clone, Copy, PartialEq, Debug)]
|
|
|
|
|
|
enum Step {
|
2026-11-04 12:56:06 +00:00
|
|
|
|
Form,
|
2026-11-04 12:38:45 +00:00
|
|
|
|
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,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
Clippy is clean on macOS and Linux, for the app and the relay
Every warning `cargo clippy --all-targets` gave (50 on macOS, 8 more only on
Linux, 1 in the relay), fixed without changing what anything does:
- long types get a name (Peer, Members, Imported, Joining, Mention, Stream…);
- the two big enum variants are boxed (the vault's login plan, joining an
account);
- a test now waits for the copy it relaunched (it left a zombie behind);
- a hint's format had a dead `if` whose two branches were both "";
- the rest is what clippy suggests: sort_by_key, as_chunks, is_none_or,
is_multiple_of, while let, from_ref, next_back, struct init, no casts to
the same type.
Checked: cargo test (177 macOS, 181 Linux), relay tests (11), remote_e2e
28/28, and the live Linux test (X11; Wayland clicks, keys, Chinese typing,
scroll and screenshots).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uirha2RVfSYmrhqNnqx9Z4
2026-11-07 02:10:28 +00:00
|
|
|
|
/// 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>;
|
|
|
|
|
|
|
2026-11-04 12:38:45 +00:00
|
|
|
|
enum Msg {
|
2026-11-04 12:56:06 +00:00
|
|
|
|
/// Joined the account; with a code, the brain it was made for.
|
Clippy is clean on macOS and Linux, for the app and the relay
Every warning `cargo clippy --all-targets` gave (50 on macOS, 8 more only on
Linux, 1 in the relay), fixed without changing what anything does:
- long types get a name (Peer, Members, Imported, Joining, Mention, Stream…);
- the two big enum variants are boxed (the vault's login plan, joining an
account);
- a test now waits for the copy it relaunched (it left a zombie behind);
- a hint's format had a dead `if` whose two branches were both "";
- the rest is what clippy suggests: sort_by_key, as_chunks, is_none_or,
is_multiple_of, while let, from_ref, next_back, struct init, no casts to
the same type.
Checked: cargo test (177 macOS, 181 Linux), relay tests (11), remote_e2e
28/28, and the live Linux test (X11; Wayland clicks, keys, Chinese typing,
scroll and screenshots).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uirha2RVfSYmrhqNnqx9Z4
2026-11-07 02:10:28 +00:00
|
|
|
|
Joined(Box<Joining>),
|
2026-11-04 12:38:45 +00:00
|
|
|
|
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);
|
2026-11-04 12:56:06 +00:00
|
|
|
|
Env { linux, display, systemd }
|
2026-11-04 12:38:45 +00:00
|
|
|
|
}
|
|
|
|
|
|
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,
|
2026-11-04 12:56:06 +00:00
|
|
|
|
/// Sign in with the account and password instead of a code.
|
|
|
|
|
|
password: bool,
|
2026-11-04 12:38:45 +00:00
|
|
|
|
url: String,
|
2026-11-04 12:56:06 +00:00
|
|
|
|
code: String,
|
2026-11-04 12:38:45 +00:00
|
|
|
|
user: String,
|
|
|
|
|
|
pass: String,
|
|
|
|
|
|
focus: usize,
|
|
|
|
|
|
joined: Option<Settings>,
|
|
|
|
|
|
peers: Vec<Peer>,
|
2026-11-04 12:56:06 +00:00
|
|
|
|
sel: usize,
|
2026-11-04 12:38:45 +00:00
|
|
|
|
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,
|
2026-11-04 12:56:06 +00:00
|
|
|
|
name: Option<String>,
|
2026-11-04 12:38:45 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
impl Wiz {
|
2026-11-04 12:56:06 +00:00
|
|
|
|
fn new(role: Role) -> Wiz {
|
|
|
|
|
|
let saved = State::load().remote;
|
2026-11-04 12:38:45 +00:00
|
|
|
|
let (tx, rx) = channel();
|
|
|
|
|
|
let env = Env::detect();
|
|
|
|
|
|
Wiz {
|
2026-11-04 12:56:06 +00:00
|
|
|
|
role,
|
|
|
|
|
|
password: false,
|
|
|
|
|
|
step: Step::Form,
|
2026-11-04 12:38:45 +00:00
|
|
|
|
url: saved.relay_url.clone(),
|
2026-11-04 12:56:06 +00:00
|
|
|
|
code: String::new(),
|
2026-11-04 12:38:45 +00:00
|
|
|
|
user: saved.relay_user.clone(),
|
|
|
|
|
|
pass: String::new(),
|
2026-11-04 12:56:06 +00:00
|
|
|
|
focus: if saved.relay_url.is_empty() { 0 } else { 1 },
|
2026-11-04 12:38:45 +00:00
|
|
|
|
joined: None,
|
|
|
|
|
|
peers: vec![],
|
2026-11-04 12:56:06 +00:00
|
|
|
|
sel: 0,
|
2026-11-04 12:38:45 +00:00
|
|
|
|
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,
|
2026-11-04 12:56:06 +00:00
|
|
|
|
name: None,
|
2026-11-04 12:38:45 +00:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-11-04 12:56:06 +00:00
|
|
|
|
/// 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
|
2026-11-04 12:38:45 +00:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-11-04 12:56:06 +00:00
|
|
|
|
fn title(&self) -> (String, String) {
|
2026-11-04 12:38:45 +00:00
|
|
|
|
match self.step {
|
2026-11-04 12:56:06 +00:00
|
|
|
|
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()),
|
2026-11-04 12:38:45 +00:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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 {
|
2026-11-04 12:56:06 +00:00
|
|
|
|
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 位數字"));
|
2026-11-04 12:38:45 +00:00
|
|
|
|
}
|
2026-11-04 12:56:06 +00:00
|
|
|
|
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)]);
|
2026-11-04 12:38:45 +00:00
|
|
|
|
}
|
|
|
|
|
|
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]);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-11-04 12:56:06 +00:00
|
|
|
|
Step::Install | Step::Done => b.extend(progress_lines(&self.progress, &self.tips, self.spin, w)),
|
2026-11-04 12:38:45 +00:00
|
|
|
|
}
|
|
|
|
|
|
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![]);
|
2026-11-04 12:56:06 +00:00
|
|
|
|
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)]);
|
2026-11-04 12:38:45 +00:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
b
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
fn keys(&self) -> &'static str {
|
2026-11-04 12:56:06 +00:00
|
|
|
|
if self.busy.is_some() || self.step == Step::Install {
|
2026-11-04 12:38:45 +00:00
|
|
|
|
return "Ctrl+C 離開";
|
|
|
|
|
|
}
|
|
|
|
|
|
match self.step {
|
2026-11-04 12:56:06 +00:00
|
|
|
|
Step::Form => "Enter 連線 ↑↓ 換欄位 Esc 離開",
|
|
|
|
|
|
Step::Brain => "↑↓ 選擇 Enter 確定",
|
|
|
|
|
|
_ => "Enter 離開",
|
2026-11-04 12:38:45 +00:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
fn render(&self, out: &mut impl Write) -> std::io::Result<()> {
|
|
|
|
|
|
let (cols, rows) = terminal::size().unwrap_or((80, 24));
|
|
|
|
|
|
let (title, sub) = self.title();
|
2026-11-04 12:56:06 +00:00
|
|
|
|
let lines = card_page(cols as usize, &title, &sub, |w| self.body(w), self.keys());
|
|
|
|
|
|
draw(out, lines, cols as usize, rows as usize)
|
2026-11-04 12:38:45 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-11-04 12:56:06 +00:00
|
|
|
|
// ----- moving along -----
|
2026-11-04 12:38:45 +00:00
|
|
|
|
|
|
|
|
|
|
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());
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-11-04 12:56:06 +00:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
Clippy is clean on macOS and Linux, for the app and the relay
Every warning `cargo clippy --all-targets` gave (50 on macOS, 8 more only on
Linux, 1 in the relay), fixed without changing what anything does:
- long types get a name (Peer, Members, Imported, Joining, Mention, Stream…);
- the two big enum variants are boxed (the vault's login plan, joining an
account);
- a test now waits for the copy it relaunched (it left a zombie behind);
- a hint's format had a dead `if` whose two branches were both "";
- the rest is what clippy suggests: sort_by_key, as_chunks, is_none_or,
is_multiple_of, while let, from_ref, next_back, struct init, no casts to
the same type.
Checked: cargo test (177 macOS, 181 Linux), relay tests (11), remote_e2e
28/28, and the live Linux test (X11; Wayland clicks, keys, Chinese typing,
scroll and screenshots).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uirha2RVfSYmrhqNnqx9Z4
2026-11-07 02:10:28 +00:00
|
|
|
|
self.start("正在登入…", move || Msg::Joined(Box::new(join_with_password(&base, &url, &user, &pass))));
|
2026-11-04 12:56:06 +00:00
|
|
|
|
} 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;
|
|
|
|
|
|
}
|
Clippy is clean on macOS and Linux, for the app and the relay
Every warning `cargo clippy --all-targets` gave (50 on macOS, 8 more only on
Linux, 1 in the relay), fixed without changing what anything does:
- long types get a name (Peer, Members, Imported, Joining, Mention, Stream…);
- the two big enum variants are boxed (the vault's login plan, joining an
account);
- a test now waits for the copy it relaunched (it left a zombie behind);
- a hint's format had a dead `if` whose two branches were both "";
- the rest is what clippy suggests: sort_by_key, as_chunks, is_none_or,
is_multiple_of, while let, from_ref, next_back, struct init, no casts to
the same type.
Checked: cargo test (177 macOS, 181 Linux), relay tests (11), remote_e2e
28/28, and the live Linux test (X11; Wayland clicks, keys, Chinese typing,
scroll and screenshots).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uirha2RVfSYmrhqNnqx9Z4
2026-11-07 02:10:28 +00:00
|
|
|
|
self.start("正在連線…", move || Msg::Joined(Box::new(remote::pair_claim(&base, &url, &code).map(|s| (s.clone(), brain_of(&s), vec![])))));
|
2026-11-04 12:56:06 +00:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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));
|
2026-11-04 12:38:45 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
fn on_msg(&mut self, m: Msg) {
|
|
|
|
|
|
match m {
|
|
|
|
|
|
Msg::Joined(r) => {
|
|
|
|
|
|
self.busy = None;
|
Clippy is clean on macOS and Linux, for the app and the relay
Every warning `cargo clippy --all-targets` gave (50 on macOS, 8 more only on
Linux, 1 in the relay), fixed without changing what anything does:
- long types get a name (Peer, Members, Imported, Joining, Mention, Stream…);
- the two big enum variants are boxed (the vault's login plan, joining an
account);
- a test now waits for the copy it relaunched (it left a zombie behind);
- a hint's format had a dead `if` whose two branches were both "";
- the rest is what clippy suggests: sort_by_key, as_chunks, is_none_or,
is_multiple_of, while let, from_ref, next_back, struct init, no casts to
the same type.
Checked: cargo test (177 macOS, 181 Linux), relay tests (11), remote_e2e
28/28, and the live Linux test (X11; Wayland clicks, keys, Chinese typing,
scroll and screenshots).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uirha2RVfSYmrhqNnqx9Z4
2026-11-07 02:10:28 +00:00
|
|
|
|
match *r {
|
2026-11-04 12:56:06 +00:00
|
|
|
|
Ok((s, brain, choices)) => {
|
2026-11-04 12:38:45 +00:00
|
|
|
|
self.joined = Some(s);
|
|
|
|
|
|
self.pass.clear();
|
2026-11-04 12:56:06 +00:00
|
|
|
|
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();
|
2026-11-04 12:38:45 +00:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
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;
|
|
|
|
|
|
}
|
2026-11-04 12:56:06 +00:00
|
|
|
|
if self.step == Step::Done {
|
|
|
|
|
|
if matches!(code, KeyCode::Enter | KeyCode::Esc | KeyCode::Char('q')) {
|
|
|
|
|
|
self.quit = true;
|
2026-11-04 12:38:45 +00:00
|
|
|
|
}
|
2026-11-04 12:56:06 +00:00
|
|
|
|
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();
|
2026-11-04 12:38:45 +00:00
|
|
|
|
}
|
2026-11-04 12:56:06 +00:00
|
|
|
|
_ => {}
|
2026-11-04 12:38:45 +00:00
|
|
|
|
}
|
2026-11-04 12:56:06 +00:00
|
|
|
|
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;
|
2026-11-04 12:38:45 +00:00
|
|
|
|
}
|
2026-11-04 12:56:06 +00:00
|
|
|
|
KeyCode::Enter if self.focus + 2 < rows && !self.field_done() => self.focus += 1,
|
|
|
|
|
|
KeyCode::Enter => self.submit(),
|
2026-11-04 12:38:45 +00:00
|
|
|
|
KeyCode::Char('u') if mods.contains(KeyModifiers::CONTROL) => {
|
|
|
|
|
|
if let Some(f) = self.text_field() {
|
|
|
|
|
|
f.clear();
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-11-04 12:56:06 +00:00
|
|
|
|
KeyCode::Char(c) if !mods.contains(KeyModifiers::CONTROL) => self.type_text(&c.to_string()),
|
2026-11-04 12:38:45 +00:00
|
|
|
|
KeyCode::Backspace => {
|
|
|
|
|
|
if let Some(f) = self.text_field() {
|
|
|
|
|
|
f.pop();
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
_ => {}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-11-04 12:56:06 +00:00
|
|
|
|
/// 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() }
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-11-04 12:38:45 +00:00
|
|
|
|
fn text_field(&mut self) -> Option<&mut String> {
|
2026-11-04 12:56:06 +00:00
|
|
|
|
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),
|
2026-11-04 12:38:45 +00:00
|
|
|
|
_ => None,
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
fn type_text(&mut self, s: &str) {
|
|
|
|
|
|
let s: String = s.chars().filter(|c| !c.is_control()).collect();
|
One-step install: install.sh, fixed Linux and server builds, clearer README
- install.sh (curl | sh): app, hands, server and deps modes; installs system packages
(right names per release, e.g. libasound2t64 on Ubuntu 24.04), Rust via rustup (1.95+),
Docker for the server; limits parallel compiles on small machines
- Server Docker image: add the ALSA build/runtime packages (the build failed), cache the
cargo registry and target between rebuilds, JOBS build arg
- 加一台電腦 now gives a line that installs AnyBot on a blank computer before joining it
- Makefile: make deps installs Rust too, make doctor, clear message when Rust is missing/old
- README rewritten: one line per use, getting started, update/remove, troubleshooting,
deployment options; docs/install.md and multi-device.md follow it
- Tested on fresh ubuntu:24.04 (app) and debian:12 (curl | sh hands) containers
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uirha3oEtWeHh58zA7ErHa
2026-11-04 16:52:10 +00:00
|
|
|
|
// 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();
|
2026-11-04 12:56:06 +00:00
|
|
|
|
self.focus = 1;
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-11-04 12:38:45 +00:00
|
|
|
|
if let Some(f) = self.text_field() {
|
|
|
|
|
|
f.push_str(&s);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-11-04 12:56:06 +00:00
|
|
|
|
/// 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)?;
|
Bug sweep: gates, relay 2FA, Windows input, snapshots, encodings
Permissions and reach
- a file leaving a computer passes the same questions as reading it;
"looking" shell commands that do more than look ask first
- a symlink out of the work/sealed folder counts as outside it
(real paths, not just lexical ones); the search walk skips links
- web_fetch and skill_install read the public web only: localhost, the
LAN and cloud metadata are refused, at every redirect
- preview shows a file without running it; browser_upload may ask
- vault: logins no longer match across shared suffixes (pages.dev,
com.tw, ...); registrable_domain knows public suffixes
- axwrite: found elements are retained (use-after-free)
Relay
- replacing or turning off 2FA needs the current code (rate-limited);
new recovery codes only count once enrollment is verified
- a removed computer loses its socket and its `call` access
- a site name taken by another account answers 409
- RELAY_TRUST_PROXY: the login lock counts the caller's address, not
the proxy's (compose files and README)
- httpd: half a body is not a request
Desktop
- sign-in, join and restore work with 2FA on (code field; --serve reads
ANYBOT_ACCOUNT_CODE; the setup wizard points to a pairing code)
- Windows: text was typed twice; modifiers are held through click, drag
and scroll; horizontal scroll; screen control is no longer refused
- a recording that hits the limit is transcribed, not thrown away
- bot's computer: a new home volume restores the snapshot; a snapshot
is read through before the home is wiped; a failed volume removal
stops reset/restore
- events of a stopped turn no longer land in the next one; each channel
message gets a turn of its own
- saving a bot's settings keeps what it got meanwhile (three-way merge);
a save file that cannot be read is kept
- pages in Big5/GBK/Shift_JIS are read in their own encoding
(encoding_rs); osascript output past 64 KB no longer stalls
- pick_tab keeps a job's own non-http tab; pictures larger than the
texture limit are fitted; the auto-update time field takes typing
- skills live in <data>/skills; memories are unnumbered in the prompt;
the phone may skillify and skill_install
CI
- the test step fails when the tests fail (it piped into tail)
Tests: anybot 268 passed, 27 ignored; relay 16 passed; clippy unchanged.
Not run: Windows on a real machine (win.rs type-checked alone), the
Docker paths of the bot's computer, the live accessibility writes.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-10 03:46:17 +00:00
|
|
|
|
// (No field for a 兩步驗證 code on these pages: the pairing code is the way in then.)
|
|
|
|
|
|
let s = remote::relay_join(base, &url, user, pass, false, "", "").map_err(|e| if e.starts_with(remote::NEED_CODE) { format!("{e}這裡請改用配對碼:在手機或電腦的 AnyBot 按「加一台電腦」。") } else { e })?;
|
2026-11-04 12:56:06 +00:00
|
|
|
|
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
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-11-04 12:38:45 +00:00
|
|
|
|
/// 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()
|
2026-11-04 12:56:06 +00:00
|
|
|
|
.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))
|
2026-11-04 12:38:45 +00:00
|
|
|
|
.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]);
|
|
|
|
|
|
}
|
2026-11-08 16:49:42 +00:00
|
|
|
|
// 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;
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
2026-11-04 12:38:45 +00:00
|
|
|
|
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();
|
2026-11-08 16:49:42 +00:00
|
|
|
|
// Let go before the service starts again (it takes the folder next).
|
|
|
|
|
|
drop(folder);
|
2026-11-04 12:56:06 +00:00
|
|
|
|
say(0, Mark::Ok, "");
|
2026-11-04 12:38:45 +00:00
|
|
|
|
|
|
|
|
|
|
// 2. Start with the computer.
|
|
|
|
|
|
say(1, Mark::Run, "");
|
|
|
|
|
|
let started = if !self.env.linux {
|
One-step install: install.sh, fixed Linux and server builds, clearer README
- install.sh (curl | sh): app, hands, server and deps modes; installs system packages
(right names per release, e.g. libasound2t64 on Ubuntu 24.04), Rust via rustup (1.95+),
Docker for the server; limits parallel compiles on small machines
- Server Docker image: add the ALSA build/runtime packages (the build failed), cache the
cargo registry and target between rebuilds, JOBS build arg
- 加一台電腦 now gives a line that installs AnyBot on a blank computer before joining it
- Makefile: make deps installs Rust too, make doctor, clear message when Rust is missing/old
- README rewritten: one line per use, getting started, update/remove, troubleshooting,
deployment options; docs/install.md and multi-device.md follow it
- Tested on fresh ubuntu:24.04 (app) and debian:12 (curl | sh hands) containers
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uirha3oEtWeHh58zA7ErHa
2026-11-04 16:52:10 +00:00
|
|
|
|
say(1, Mark::Warn, "Mac 和 Windows:打開 AnyBot App 就會用這些設定連上(設定 › 一般 › 手機與帳號)。");
|
2026-11-04 12:38:45 +00:00
|
|
|
|
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)),
|
One-step install: install.sh, fixed Linux and server builds, clearer README
- install.sh (curl | sh): app, hands, server and deps modes; installs system packages
(right names per release, e.g. libasound2t64 on Ubuntu 24.04), Rust via rustup (1.95+),
Docker for the server; limits parallel compiles on small machines
- Server Docker image: add the ALSA build/runtime packages (the build failed), cache the
cargo registry and target between rebuilds, JOBS build arg
- 加一台電腦 now gives a line that installs AnyBot on a blank computer before joining it
- Makefile: make deps installs Rust too, make doctor, clear message when Rust is missing/old
- README rewritten: one line per use, getting started, update/remove, troubleshooting,
deployment options; docs/install.md and multi-device.md follow it
- Tested on fresh ubuntu:24.04 (app) and debian:12 (curl | sh hands) containers
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uirha3oEtWeHh58zA7ErHa
2026-11-04 16:52:10 +00:00
|
|
|
|
Role::Brain => tips.push("其他電腦:AnyBot › 設定 › 一般 › 手機與帳號,登入同一個帳號,在「大腦」選這台。沒有視窗的 Linux:anybot setup 選「手腳」。".into()),
|
2026-11-04 12:38:45 +00:00
|
|
|
|
}
|
|
|
|
|
|
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");
|
2026-11-05 07:48:22 +00:00
|
|
|
|
write_atomic(&launcher, launcher_script(self.role, &exe, &self.env)).map_err(|e| format!("寫入 {} 失敗:{e}", launcher.display()))?;
|
2026-11-04 12:38:45 +00:00
|
|
|
|
#[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",
|
2026-11-05 07:48:22 +00:00
|
|
|
|
systemd_quote(&launcher)
|
2026-11-04 12:38:45 +00:00
|
|
|
|
);
|
2026-11-05 07:48:22 +00:00
|
|
|
|
write_atomic(&units.join(UNIT), unit).map_err(|e| e.to_string())?;
|
2026-11-04 12:38:45 +00:00
|
|
|
|
systemctl(&["daemon-reload"])?;
|
|
|
|
|
|
systemctl(&["enable", UNIT])?;
|
|
|
|
|
|
systemctl(&["restart", UNIT])?;
|
|
|
|
|
|
Ok(format!("systemd 使用者服務「anybot」,已啟動({})", launcher.display()))
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-11-05 00:10:13 +00:00
|
|
|
|
/// `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;
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
2026-11-05 07:48:22 +00:00
|
|
|
|
if let Err(e) = write_atomic(&launcher, launcher_script(role, &exe, &env)) {
|
2026-11-05 00:10:13 +00:00
|
|
|
|
eprintln!("✗ 寫入 {} 失敗:{e}", launcher.display());
|
|
|
|
|
|
return 1;
|
|
|
|
|
|
}
|
|
|
|
|
|
match systemctl(&["restart", UNIT]) {
|
|
|
|
|
|
Ok(_) => {
|
|
|
|
|
|
println!("已更新並重新啟動 AnyBot 服務。");
|
|
|
|
|
|
0
|
|
|
|
|
|
}
|
|
|
|
|
|
Err(e) => {
|
|
|
|
|
|
eprintln!("✗ 重新啟動失敗:{e}");
|
|
|
|
|
|
1
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-11-04 12:38:45 +00:00
|
|
|
|
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";
|
2026-11-05 00:10:13 +00:00
|
|
|
|
let real_screen = std::env::args().any(|a| a == "--real-screen");
|
2026-11-04 12:38:45 +00:00
|
|
|
|
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");
|
|
|
|
|
|
}
|
2026-11-05 00:10:13 +00:00
|
|
|
|
} 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";
|
2026-11-04 12:38:45 +00:00
|
|
|
|
}
|
|
|
|
|
|
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 ----------
|
|
|
|
|
|
|
2026-11-04 12:56:06 +00:00
|
|
|
|
/// 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"];
|
|
|
|
|
|
|
2026-11-04 12:38:45 +00:00
|
|
|
|
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") {
|
2026-11-05 00:10:13 +00:00
|
|
|
|
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。");
|
2026-11-04 12:38:45 +00:00
|
|
|
|
return 0;
|
|
|
|
|
|
}
|
2026-11-05 00:10:13 +00:00
|
|
|
|
if args.iter().any(|a| a == "--refresh-service") {
|
|
|
|
|
|
return refresh_service();
|
|
|
|
|
|
}
|
2026-11-04 12:56:06 +00:00
|
|
|
|
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);
|
2026-11-04 12:38:45 +00:00
|
|
|
|
}
|
2026-11-04 12:56:06 +00:00
|
|
|
|
let name_for_screen = name.clone();
|
|
|
|
|
|
if !std::io::stdin().is_terminal() || !std::io::stdout().is_terminal() {
|
|
|
|
|
|
eprintln!("✗ 請用:anybot setup 網址 配對碼(在手機或電腦的 AnyBot 按「加一台電腦」)");
|
|
|
|
|
|
return 1;
|
2026-11-04 12:38:45 +00:00
|
|
|
|
}
|
2026-11-04 12:56:06 +00:00
|
|
|
|
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;
|
2026-11-04 12:38:45 +00:00
|
|
|
|
let mut out = std::io::stdout();
|
|
|
|
|
|
if terminal::enable_raw_mode().is_err() {
|
2026-11-04 12:56:06 +00:00
|
|
|
|
eprintln!("✗ 這個終端機不能用互動畫面。請用:anybot setup 網址 配對碼");
|
|
|
|
|
|
return 1;
|
2026-11-04 12:38:45 +00:00
|
|
|
|
}
|
|
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-11-04 12:56:06 +00:00
|
|
|
|
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 {
|
2026-11-04 12:38:45 +00:00
|
|
|
|
0
|
|
|
|
|
|
} else {
|
|
|
|
|
|
1
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-11-04 12:56:06 +00:00
|
|
|
|
/// 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);
|
2026-11-04 12:38:45 +00:00
|
|
|
|
} else {
|
2026-11-04 12:56:06 +00:00
|
|
|
|
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;
|
2026-11-04 12:38:45 +00:00
|
|
|
|
}
|
2026-11-04 12:56:06 +00:00
|
|
|
|
};
|
|
|
|
|
|
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;
|
2026-11-04 12:38:45 +00:00
|
|
|
|
}
|
|
|
|
|
|
};
|
2026-11-04 12:56:06 +00:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
2026-11-04 12:38:45 +00:00
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-11-04 12:56:06 +00:00
|
|
|
|
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 {
|
2026-11-04 12:38:45 +00:00
|
|
|
|
let env = Env::detect();
|
2026-11-04 12:56:06 +00:00
|
|
|
|
let plan = Plan { role, settings: s, brain, service: service && env.systemd, env };
|
2026-11-04 12:38:45 +00:00
|
|
|
|
let steps = plan.steps();
|
|
|
|
|
|
let (tx, rx) = channel();
|
|
|
|
|
|
std::thread::spawn(move || plan.run(&tx));
|
|
|
|
|
|
let mut failed = false;
|
2026-11-04 12:56:06 +00:00
|
|
|
|
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 };
|
2026-11-04 12:38:45 +00:00
|
|
|
|
match m {
|
2026-11-04 12:56:06 +00:00
|
|
|
|
Msg::Progress(i, Mark::Run, _) => current = steps.get(i).cloned().unwrap_or_default(),
|
|
|
|
|
|
Msg::Progress(i, mark, note) if mark != Mark::Wait => {
|
|
|
|
|
|
current.clear();
|
2026-11-04 12:38:45 +00:00
|
|
|
|
failed |= mark == Mark::Fail;
|
2026-11-04 12:56:06 +00:00
|
|
|
|
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) }]);
|
|
|
|
|
|
}
|
2026-11-04 12:38:45 +00:00
|
|
|
|
}
|
|
|
|
|
|
Msg::Finished(tips) => {
|
2026-11-04 12:56:06 +00:00
|
|
|
|
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)]),
|
|
|
|
|
|
}
|
2026-11-04 12:38:45 +00:00
|
|
|
|
}
|
2026-11-04 12:56:06 +00:00
|
|
|
|
say(vec![]);
|
2026-11-04 12:38:45 +00:00
|
|
|
|
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() {
|
2026-11-04 12:56:06 +00:00
|
|
|
|
let env = Env { linux: true, display: String::new(), systemd: true };
|
2026-11-04 12:38:45 +00:00
|
|
|
|
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]
|
2026-11-04 12:56:06 +00:00
|
|
|
|
fn every_screen_fits_a_narrow_terminal() {
|
|
|
|
|
|
let mut w = Wiz::new(Role::Hands);
|
2026-11-04 12:38:45 +00:00
|
|
|
|
w.peers = vec![Peer { id: "1".into(), name: "很長很長很長很長很長很長很長很長的大腦名字".into(), online: true, last: String::new() }];
|
2026-11-04 12:56:06 +00:00
|
|
|
|
w.error = "配對碼不對,或已經過期(10 分鐘內有效,只能用一次)".into();
|
|
|
|
|
|
for step in [Step::Form, Step::Brain, Step::Install, Step::Done] {
|
2026-11-04 12:38:45 +00:00
|
|
|
|
w.step = step;
|
2026-11-04 12:56:06 +00:00
|
|
|
|
for pw in [false, true] {
|
|
|
|
|
|
w.password = pw;
|
|
|
|
|
|
for l in w.body(40) {
|
|
|
|
|
|
assert!(width(&clip(l, 40)) <= 40);
|
|
|
|
|
|
}
|
2026-11-04 12:38:45 +00:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-11-04 12:56:06 +00:00
|
|
|
|
|
|
|
|
|
|
#[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"));
|
One-step install: install.sh, fixed Linux and server builds, clearer README
- install.sh (curl | sh): app, hands, server and deps modes; installs system packages
(right names per release, e.g. libasound2t64 on Ubuntu 24.04), Rust via rustup (1.95+),
Docker for the server; limits parallel compiles on small machines
- Server Docker image: add the ALSA build/runtime packages (the build failed), cache the
cargo registry and target between rebuilds, JOBS build arg
- 加一台電腦 now gives a line that installs AnyBot on a blank computer before joining it
- Makefile: make deps installs Rust too, make doctor, clear message when Rust is missing/old
- README rewritten: one line per use, getting started, update/remove, troubleshooting,
deployment options; docs/install.md and multi-device.md follow it
- Tested on fresh ubuntu:24.04 (app) and debian:12 (curl | sh hands) containers
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uirha3oEtWeHh58zA7ErHa
2026-11-04 16:52:10 +00:00
|
|
|
|
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"));
|
2026-11-04 12:56:06 +00:00
|
|
|
|
}
|
2026-11-04 12:38:45 +00:00
|
|
|
|
}
|