From 5841d797be70456897a43bc2c4295c1e06304607 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E6=80=A7=E9=A9=8A?= Date: Wed, 4 Nov 2026 17:46:43 +0800 Subject: [PATCH 1/9] One brain, many screens: other AnyBots show and edit the brain's bots MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Brain answers /api/sync (per-screen deltas of settings and messages), /api/sync/edit (small edit operations) and /api/sync/act (chat actions). - Relay forwards host-to-host sync requests within one account (role mirror). - Desktop: choose a brain in 手機與帳號; its own bots are kept aside; offline shows the last copy read-only and reconnects by itself. - Keys and tokens stay on the brain (redacted and restored on edits). - Browser tools can run on another computer of the account (computer arg). - anybot --serve: ANYBOT_DEVICE_LINK=1 and ANYBOT_BRAIN=名稱. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Uirha3oEtWeHh58zA7ErHa --- relay/src/main.rs | 10 + src/agent.rs | 19 +- src/app.rs | 103 ++++- src/app/mirror_ui.rs | 882 +++++++++++++++++++++++++++++++++++++++++++ src/app/remote_ui.rs | 88 ++++- src/remote.rs | 146 ++++++- src/web.rs | 25 +- 7 files changed, 1254 insertions(+), 19 deletions(-) create mode 100644 src/app/mirror_ui.rs diff --git a/relay/src/main.rs b/relay/src/main.rs index 98823e9..aa14d66 100644 --- a/relay/src/main.rs +++ b/relay/src/main.rs @@ -499,6 +499,16 @@ impl Relay { } self.forward(target, "POST", "/api/device/run", &HashMap::new(), &req.body, (&me.user, "device")) } + // 大腦: one AnyBot of the account shows (and edits) another one's bots and chats. + (m, ["relay", "hosts", target, "api", "sync", rest @ ..]) => { + let Some(me) = self.host(&req) else { return Resp::error(401, "這台電腦的金鑰無效") }; + let same = self.data.lock().unwrap().hosts.iter().any(|h| h.id == *target && h.user == me.user); + if !same || *target == me.id { + return Resp::error(403, "不是同一個帳號的電腦"); + } + let path = if rest.is_empty() { "/api/sync".to_string() } else { format!("/api/sync/{}", rest.join("/")) }; + self.forward(target, m, &path, &req.query, &req.body, (&me.user, "mirror")) + } // The phone, through to one of the account's AnyBots. (m, ["h", host, rest @ ..]) => { let Some(user) = self.session(&req) else { return Resp::error(401, "請先登入") }; diff --git a/src/agent.rs b/src/agent.rs index 3a7caff..59231fe 100644 --- a/src/agent.rs +++ b/src/agent.rs @@ -114,6 +114,16 @@ fn target_for(job: &Job, args: &str) -> Result { }) } +/// The other computer (裝置連結) a call names, if it names one. +fn device_named(job: &Job, args: &str) -> Option { + let v = tools::args_of(args); + let n = v.get("computer").and_then(|c| c.as_str()).map(str::trim).filter(|c| !c.is_empty())?; + job.computers.iter().find(|t| t.name == n).and_then(|t| match &t.place { + tools::Place::Device { host } => Some(host.clone()), + _ => None, + }) +} + /// Rough token count: about 4 Latin characters or 1 Chinese character per token (estimated). /// Which screen a computer_use call is for: the one named, else the bot's default when it has a /// screen, else the bot's own desktop when there is one, else this Mac. @@ -321,7 +331,14 @@ pub fn spawn(job: Job, bot_id: u64, tx: Sender<(u64, StreamEvent)>, cancel: Arc< // …and neither does AnyBot's own browser (like Grok Bot's own browser). if call.name == "web_fetch" || (web::is_web_tool(&call.name) && !web::uses_this_computer(&call.name, &call.args)) { send(StreamEvent::ToolStatus(call.id.clone(), ToolStatus::Running)); - let (output, ok, img) = web::run_full(&call.name, &call.args, &cancel); + // 裝置連結: the AnyBot browser of another computer (its own logins). + let (output, ok, img) = match device_named(&job, &call.args).filter(|_| call.name != "web_fetch") { + Some(host) => match crate::remote::run_on_device(&host, &call.name, &call.args, &job.via_name) { + Ok(v) => (v["text"].as_str().unwrap_or("").to_string(), v["ok"].as_bool().unwrap_or(false), v["image"].as_str().map(String::from)), + Err(e) => (e, false, None), + }, + None => web::run_full(&call.name, &call.args, &cancel), + }; send(StreamEvent::ToolStatus(call.id.clone(), if ok { ToolStatus::Done } else { ToolStatus::Failed })); let mut result = Message::tool_result(&call.id, &call.name, &output); result.images.extend(img); diff --git a/src/app.rs b/src/app.rs index 2cec2ec..f46266d 100644 --- a/src/app.rs +++ b/src/app.rs @@ -33,6 +33,7 @@ mod sidebar_ui; mod menu_ui; mod vault_ui; mod markdown_ui; +mod mirror_ui; // Sidebar rules from index.eager-app: defaults `FT={expandedWidth:280}` (@912025), // clamp `jT(..,240,400)` (@914394), and the drag rule in `QJe` (@969523): @@ -197,11 +198,16 @@ pub struct App { group_menu_for: Option, /// When the launch greeting started. splash_at: Option, + /// 大腦: this window shows another AnyBot's bots (a screen), or answers screens (the brain). + mir: mirror_ui::Mirror, + scr: mirror_ui::Screens, + ctx_handle: Option, } impl App { pub fn new(ctx: &egui::Context, screenshot: Option) -> Self { - let demo = screenshot.is_some(); + // ANYBOT_SHOT_REAL=1: a screenshot of the real data (and connections), not the demo. + let demo = screenshot.is_some() && std::env::var("ANYBOT_SHOT_REAL").is_err(); let state = if demo { State::demo() } else { State::load() }; let (tx, rx) = channel(); let mut app = App { @@ -279,6 +285,9 @@ impl App { renaming: None, group_menu_for: None, splash_at: None, + mir: Default::default(), + scr: Default::default(), + ctx_handle: None, }; crate::i18n::set(&app.state.language); app.computers_start(); @@ -593,8 +602,14 @@ impl App { let quoted: String = q.lines().take(8).map(|l| format!("> {l}\n")).collect(); text = format!("{quoted}\n{}", text.trim()); } - self.drafts.insert(bot_id, String::new()); let attachments = self.take_attachments(bot_id); + if self.mir.on { + if self.mirror_send(bot_id, text.trim().to_string(), attachments) { + self.drafts.insert(bot_id, String::new()); + } + return; + } + self.drafts.insert(bot_id, String::new()); // While it works, a new message steers the running turn (the original's `turnSteer`: // it doesn't count as a new turn) and reaches the model at its next step. if let Some(l) = self.streams.get(&bot_id) { @@ -609,6 +624,10 @@ impl App { } fn start_reply(&mut self, ctx: &egui::Context, bot_id: u64) { + if self.mir.on { + self.mirror_act(serde_json::json!({"bot": bot_id, "action": "reply"})); + return; + } let Some(conv) = self.state.bot(bot_id).cloned() else { return }; // 群組聊天: the next member answers, with its own model, instructions and computers. let speaker = if conv.members.is_empty() { @@ -642,7 +661,8 @@ impl App { let default_computer = if names.contains(&b.default_computer) { b.default_computer.clone() } else { crate::model::BOT_COMPUTER_NAME.to_string() }; let mut tool_specs = appctl::specs(); tool_specs.extend(tools::specs_for(&names, &default_computer)); - tool_specs.extend(web::specs()); + let devices: Vec = computers.iter().filter(|t| matches!(t.place, tools::Place::Device { .. })).map(|t| t.name.clone()).collect(); + tool_specs.extend(web::specs_for(&devices)); // Screen tools: the bot's own desktop (when it has one) and this Mac. let mut screens = vec![]; if crate::cloudbox::available() { @@ -873,6 +893,12 @@ impl App { } fn stop(&mut self, bot_id: u64) { + if self.mir.on { + if self.streams.contains_key(&bot_id) || self.approvals.contains_key(&bot_id) { + self.mirror_act(serde_json::json!({"bot": bot_id, "action": "stop"})); + } + return; + } self.approvals.remove(&bot_id); if let Some(call) = self.app_pending.remove(&bot_id) { let _ = call.reply.send(("已停止".into(), false)); @@ -961,6 +987,17 @@ impl App { } fn answer(&mut self, ctx: &egui::Context, bot_id: u64, answer: ApprovalAnswer) { + if self.mir.on { + let a = match answer { + ApprovalAnswer::Once => "once", + ApprovalAnswer::Always(_) => "always", + ApprovalAnswer::Reject => "reject", + }; + self.approvals.remove(&bot_id); + self.popup_hidden.remove(&bot_id); + self.mirror_act(serde_json::json!({"bot": bot_id, "action": "answer", "answer": a})); + return; + } if self.want_direct.replace(false) { self.enable_direct_mode(); } @@ -1004,6 +1041,10 @@ impl App { } fn regenerate(&mut self, ctx: &egui::Context, bot_id: u64) { + if self.mir.on { + self.mirror_act(serde_json::json!({"bot": bot_id, "action": "regenerate"})); + return; + } if self.streams.contains_key(&bot_id) { return; } @@ -1448,7 +1489,9 @@ impl App { // 討論串: put this conversation aside and start fresh; earlier ones can come back. if menu_ui::item(ui, p, Icon::NewChat, "開始新對話").clicked() { self.stop(id); - if let Some(b) = self.state.bot_mut(id) { + if self.mir.on { + self.mirror_act(serde_json::json!({"bot": id, "action": "new"})); + } else if let Some(b) = self.state.bot_mut(id) { if !b.messages.is_empty() { let title = b.messages.iter().find(|m| m.role == Role::User).map(|m| first_line(&m.text, 24)).unwrap_or_else(|| "對話".into()); let msgs = std::mem::take(&mut b.messages); @@ -1465,7 +1508,9 @@ impl App { for (i, title) in threads.iter().enumerate() { if menu_ui::item_text(ui, p, None, title).clicked() { self.stop(id); - if let Some(b) = self.state.bot_mut(id) { + if self.mir.on { + self.mirror_act(serde_json::json!({"bot": id, "action": "restore", "index": i})); + } else if let Some(b) = self.state.bot_mut(id) { let (_, msgs) = b.threads.remove(i); if !b.messages.is_empty() { let cur_title = b.messages.iter().find(|m| m.role == Role::User).map(|m| first_line(&m.text, 24)).unwrap_or_else(|| "對話".into()); @@ -1564,6 +1609,7 @@ impl App { return; }; self.header(ui, &bot); + self.mirror_banner(ui); // The composer grows with a long draft: keep room for last frame's height. let h_id = egui::Id::unique("composer-height"); let composer_h: f32 = ui.data(|d| d.get_temp(h_id)).unwrap_or(116.0); @@ -1964,7 +2010,7 @@ impl App { }); let right_w = 28.0 * 2.0 + 8.0 + 4.0; let text_w = (ui.available_width() - right_w).max(60.0); - let hint = t("傳訊息給 {name}").replace("{name}", &bot.name); + let hint = if self.mir.on && !self.mir.online { t("連不到大腦,現在只能看") } else { t("傳訊息給 {name}").replace("{name}", &bot.name) }; ui.allocate_ui_with_layout(vec2(text_w, 28.0), Layout::top_down(Align::Min), |ui| { egui::ScrollArea::vertical().id_salt(("composer_scroll", bot.id)).max_height(160.0).show(ui, |ui| { ui.add_space(4.0); @@ -2957,11 +3003,17 @@ impl App { /// Everything that keeps bots working, without drawing (the window runs it every frame; /// `anybot --serve` runs it on its own). fn background(&mut self, ctx: &egui::Context) { + self.remote_start(ctx); + self.mirror_tick(); + // A screen of another AnyBot's brain: its bots work there, not here. + if self.mir.on { + self.remote_tick(ctx); + return; + } self.drain_events(ctx); if !self.demo { self.channels_poll(ctx); } - self.remote_start(ctx); self.remote_tick(ctx); self.tick_routines(ctx); self.watchdog(); @@ -2971,7 +3023,11 @@ impl App { fn save_if_dirty(&mut self) { if self.dirty && !self.demo && (self.streams.is_empty() || self.last_save.elapsed().as_secs() >= 2) { - self.state.save(); + if self.mir.on { + self.mirror_save(); + } else { + self.state.save(); + } self.dirty = false; self.last_save = Instant::now(); } @@ -3033,7 +3089,7 @@ impl App { } app.drain_events(ctx); app.watchdog(); - let _ = ctx.run_ui(Default::default(), |_| {}); + ctx.run_ui(Default::default(), |_| {}).textures_delta.clear(); std::thread::sleep(std::time::Duration::from_millis(100)); } let secs = t0.elapsed().as_secs(); @@ -3081,6 +3137,11 @@ impl App { app.dirty = true; } } + // 手腳: a computer the brain's bots may operate (screen, files, browser). + if std::env::var("ANYBOT_DEVICE_LINK").map(|v| v == "1").unwrap_or(false) && !app.state.remote.device_link { + app.state.remote.device_link = true; + app.dirty = true; + } app.serve_join(); app.background(ctx); if let Some(port) = crate::remote::lan_port() { @@ -3095,16 +3156,28 @@ impl App { eprintln!("AnyBot:還沒有設定手機密碼或帳號。用 ANYBOT_PHONE_PASSWORD=… 或 ANYBOT_ACCOUNT=網址|帳號 + ANYBOT_ACCOUNT_PASSWORD=… 啟動。"); } let mut last_try = Instant::now(); + let mut last_brain = Instant::now() - std::time::Duration::from_secs(60); loop { // The account server may not be up yet (or the account not made yet): keep trying. if app.state.remote.host_token.is_empty() && last_try.elapsed().as_secs() >= 30 { last_try = Instant::now(); app.serve_join(); } + // ANYBOT_BRAIN=名稱: show that AnyBot's bots instead of running its own. + if let Ok(want) = std::env::var("ANYBOT_BRAIN") { + if !want.trim().is_empty() && !app.state.remote.host_token.is_empty() && app.state.remote.brain_name != want && last_brain.elapsed().as_secs() >= 20 { + last_brain = Instant::now(); + if let Some((id, name, _)) = crate::remote::devices_online(&app.state.remote).into_iter().find(|(_, n, _)| n == want.trim()) { + eprintln!("AnyBot:用「{name}」當大腦,這台當手腳"); + app.mir.want = Some((id, name)); + app.state.remote.device_link = true; + } + } + } app.background(ctx); app.save_if_dirty(); // egui wants a frame now and then to forget old state; nothing is drawn. - let _ = ctx.run_ui(Default::default(), |_| {}); + ctx.run_ui(Default::default(), |_| {}).textures_delta.clear(); std::thread::sleep(std::time::Duration::from_millis(if app.streams.is_empty() { 250 } else { 60 })); } } @@ -3216,6 +3289,10 @@ impl eframe::App for App { fn on_exit(&mut self) { // Closing mid-reply ends that turn as cancelled, so it isn't left looking stuck. + // (On a screen of a brain the bots keep working there.) + if self.mir.on { + self.streams.clear(); + } let ids: Vec = self.streams.keys().copied().collect(); for id in ids { self.stop(id); @@ -3223,7 +3300,11 @@ impl eframe::App for App { tools::stop_all(); crate::cloudbox::shutdown(); if !self.demo { - self.state.save(); + if self.mir.on { + self.mirror_save(); + } else { + self.state.save(); + } } } } diff --git a/src/app/mirror_ui.rs b/src/app/mirror_ui.rs new file mode 100644 index 0000000..2760a2b --- /dev/null +++ b/src/app/mirror_ui.rs @@ -0,0 +1,882 @@ +// 大腦: one AnyBot of the account keeps the bots, chats, memory and schedules (the brain, usually +// `anybot --serve` next to the account server). Every other AnyBot signed in to the account shows +// the brain's bots in its own window and lends its hands (screen, files, browser) through 裝置連結. +// +// The brain side answers "what changed since version N?" per screen: the settings (bots without +// their chats) when they changed, and for each bot only the messages from the first one that +// changed. The screen side keeps a copy, sends the person's edits back as small operations, and +// turns chat actions (send, stop, approve…) into requests. When the brain can't be reached the +// window stays readable and waits. + +use super::*; +use crate::remote::{self, SyncKind}; +use serde_json::{json, Value}; +use std::hash::{Hash, Hasher}; +use std::sync::atomic::AtomicU64; + +/// Settings that belong to each computer, never to the brain. +const LOCAL_KEYS: &[&str] = &["remote", "theme", "accent", "sidebar_collapsed", "sidebar_width", "selected", "machine_label", "local_execution", "local_never", "messages_send", "messages_no_ask"]; +/// Bot fields the brain alone changes (its chat, and what a turn leaves behind). +const BOT_RUNTIME: &[&str] = &["id", "messages", "threads", "unread", "last_turn", "context_tokens"]; +/// Stands in for a key or token the brain doesn't hand out. +pub const REDACTED: &str = "••••(已設定,在大腦上)"; + +// ---------- shared ---------- + +/// The settings as JSON: everything but the chats (messages and earlier conversations). +pub(super) fn config_json(st: &mut State) -> Value { + let mut bots = std::mem::take(&mut st.bots); + let mut top = serde_json::to_value(&*st).unwrap_or(Value::Null); + let mut arr = Vec::with_capacity(bots.len()); + for b in bots.iter_mut() { + let m = std::mem::take(&mut b.messages); + let th = std::mem::take(&mut b.threads); + arr.push(serde_json::to_value(&*b).unwrap_or(Value::Null)); + b.messages = m; + b.threads = th; + } + st.bots = bots; + top["bots"] = Value::Array(arr); + top +} + +/// Keys and tokens stay on the brain. +fn redact(top: &mut Value) { + if let Some(o) = top.as_object_mut() { + o.remove("remote"); + } + let hide = |v: &mut Value| { + if v.as_str().map(|s| !s.is_empty()).unwrap_or(false) { + *v = Value::String(REDACTED.into()); + } + }; + if let Some(ps) = top["providers"].as_object_mut() { + for p in ps.values_mut() { + hide(&mut p["api_key"]); + } + } + for c in top["channels"].as_array_mut().into_iter().flatten() { + hide(&mut c["token"]); + } + for m in top["mcp_servers"].as_array_mut().into_iter().flatten() { + for k in ["env", "headers"] { + for v in m[k].as_object_mut().into_iter().flat_map(|o| o.values_mut()) { + hide(v); + } + } + } +} + +/// Put the brain's own keys back wherever an edit still carries the stand-in. +fn unredact(new: &mut Value, old: &Value) { + match new { + Value::String(s) if s == REDACTED => *new = old.clone(), + Value::Object(o) => { + for (k, v) in o.iter_mut() { + unredact(v, old.get(k).unwrap_or(&Value::Null)); + } + } + Value::Array(a) => { + for (i, v) in a.iter_mut().enumerate() { + let key = |x: &Value| x.get("id").cloned().filter(|x| !x.is_null()).or_else(|| x.get("name").cloned()); + let twin = old.as_array().and_then(|olds| match key(v) { + Some(k) => olds.iter().find(|o| key(o).as_ref() == Some(&k)), + None => olds.get(i), + }); + unredact(v, twin.unwrap_or(&Value::Null)); + } + } + _ => {} + } +} + +/// A cheap fingerprint of one message: enough to see it changed (streaming, a tool finishing…). +fn msg_sig(m: &Message) -> u64 { + let mut h = std::collections::hash_map::DefaultHasher::new(); + (m.role as u8, m.text.len(), m.thinking.len(), m.error, m.summary, m.images.len(), m.attachments.len(), m.at, &m.tool_call_id, &m.reactions).hash(&mut h); + m.text.chars().rev().take(48).collect::().hash(&mut h); + for c in &m.tool_calls { + (&c.id, c.args.len(), c.status as u8).hash(&mut h); + } + h.finish() +} + +fn threads_sig(b: &Bot) -> u64 { + let mut h = std::collections::hash_map::DefaultHasher::new(); + for (t, msgs) in &b.threads { + (t, msgs.len()).hash(&mut h); + } + h.finish() +} + +fn hash_value(v: &Value) -> u64 { + let mut h = std::collections::hash_map::DefaultHasher::new(); + v.to_string().hash(&mut h); + h.finish() +} + +/// The person's edits since `base`, as operations the brain applies one by one. +pub(super) fn diff_ops(base: &Value, cur: &Value) -> Vec { + let mut ops = vec![]; + let (Some(b), Some(c)) = (base.as_object(), cur.as_object()) else { return ops }; + for (k, v) in c { + if k == "bots" || LOCAL_KEYS.contains(&k.as_str()) { + continue; + } + if b.get(k) != Some(v) { + ops.push(json!({"op": "set", "key": k, "value": v})); + } + } + let id = |x: &Value| x["id"].as_u64().unwrap_or(0); + let empty = vec![]; + let old = base["bots"].as_array().unwrap_or(&empty); + let new = cur["bots"].as_array().unwrap_or(&empty); + for nb in new { + match old.iter().find(|o| id(o) == id(nb)) { + None => ops.push(json!({"op": "bot_add", "bot": nb})), + Some(ob) => { + for (k, v) in nb.as_object().into_iter().flatten() { + if k == "unread" { + if ob[k] == true && *v == false { + ops.push(json!({"op": "bot_read", "id": id(nb)})); + } + continue; + } + if BOT_RUNTIME.contains(&k.as_str()) { + continue; + } + if ob.get(k) != Some(v) { + ops.push(json!({"op": "bot_set", "id": id(nb), "key": k, "value": v})); + } + } + } + } + } + for ob in old { + if !new.iter().any(|n| id(n) == id(ob)) { + ops.push(json!({"op": "bot_del", "id": id(ob)})); + } + } + let order = |a: &Vec| a.iter().map(id).collect::>(); + let (oo, no) = (order(old), order(new)); + let kept: Vec = oo.iter().copied().filter(|i| no.contains(i)).collect(); + let kept_new: Vec = no.iter().copied().filter(|i| oo.contains(i)).collect(); + if kept != kept_new { + ops.push(json!({"op": "bot_order", "ids": no})); + } + ops +} + +// ---------- the brain's side ---------- + +/// What the brain last sent each screen. +#[derive(Default)] +pub struct Screens { + seen: HashMap, + /// The settings fingerprint, refreshed now and then while something changes. + pub(super) cfg_hash: u64, + cfg_at: Option, +} + +#[derive(Default)] +struct Seen { + version: u64, + config: u64, + bots: HashMap>, + threads: HashMap, + at: Option, +} + +// ---------- the screen's side ---------- + +pub enum Incoming { + Pull(Value), + Offline(String), +} + +#[derive(Default)] +pub struct Mirror { + /// This window shows a brain (instead of its own bots). + pub on: bool, + /// Heard from the brain lately. + pub online: bool, + /// The brain's settings as last received (edits are what differs from this). + pub(super) base: Value, + pub status: Arc>, + rx: Option>, + tx: Option>, + gen: Arc, + version: Arc, + client: String, + /// This computer's own data, kept aside while it shows the brain. + local: Option, + started: bool, + /// A request to the brain failed (shown above the message box). + pub(super) error: Arc>, + /// The person chose a brain in 設定 (applied on the next frame). + pub(super) want: Option<(String, String)>, + pub(super) leave: bool, + pub(super) moving: Arc, + pub(super) move_note: Arc>, +} + +fn cache_path() -> PathBuf { + State::path().with_file_name("brain-copy.json") +} + +impl App { + /// The brain: answer the screens' requests (on the app's thread, where the state is). + pub(super) fn brain_serve(&mut self) { + let reqs = remote::hub().take_sync(); + if reqs.is_empty() { + return; + } + for r in reqs { + let v = match r.kind { + SyncKind::Pull { client, since } => self.brain_pull(&client, since), + SyncKind::Edit(body) => { + if self.mir.on { + json!({"error": "這台 AnyBot 本身連著別的大腦"}) + } else { + self.brain_edit(&body) + } + } + }; + let _ = r.reply.send(v); + } + } + + /// Keep the settings fingerprint fresh (at most once a second) so screens hear of edits. + pub(super) fn brain_watch(&mut self) { + if self.mir.on || !self.dirty || self.scr.cfg_at.map(|t| t.elapsed().as_millis() < 1000).unwrap_or(false) { + return; + } + self.scr.cfg_at = Some(Instant::now()); + let mut c = config_json(&mut self.state); + redact(&mut c); + self.scr.cfg_hash = hash_value(&c); + } + + fn brain_pull(&mut self, client: &str, since: u64) -> Value { + if self.mir.on { + return json!({"error": "這台 AnyBot 本身連著別的大腦"}); + } + let version = remote::hub().version(); + // Forget screens not heard from in a day. + self.scr.seen.retain(|_, s| s.at.map(|t| t.elapsed().as_secs() < 86_400).unwrap_or(false)); + let seen = self.scr.seen.entry(client.to_string()).or_default(); + // A screen that missed an answer (or is new) gets everything again. + if seen.version != since || since == 0 { + *seen = Seen::default(); + } + seen.at = Some(Instant::now()); + seen.version = version; + let mut out = json!({"version": version, "brain": self.state.remote.host_name}); + let mut c = config_json(&mut self.state); + redact(&mut c); + let ch = hash_value(&c); + if seen.config != ch { + seen.config = ch; + out["config"] = c; + } + let mut bots = vec![]; + let mut threads = vec![]; + for b in &self.state.bots { + let sigs: Vec = b.messages.iter().map(msg_sig).collect(); + let old = seen.bots.get(&b.id); + let from = match old { + Some(o) => o.iter().zip(sigs.iter()).position(|(a, b)| a != b).unwrap_or(o.len().min(sigs.len())), + None => 0, + }; + if old.map(|o| o.len() != sigs.len() || from < sigs.len()).unwrap_or(true) { + bots.push(json!({"id": b.id, "from": from, "messages": &b.messages[from..]})); + } + seen.bots.insert(b.id, sigs); + let ts = threads_sig(b); + if seen.threads.get(&b.id) != Some(&ts) { + seen.threads.insert(b.id, ts); + threads.push(json!({"id": b.id, "threads": b.threads})); + } + } + seen.bots.retain(|id, _| self.state.bots.iter().any(|b| b.id == *id)); + out["bots"] = Value::Array(bots); + out["threads"] = Value::Array(threads); + out["live"] = self.streams.iter().map(|(id, l)| (id.to_string(), json!({"status": l.status, "paused": l.paused}))).collect::>().into(); + out["approvals"] = self + .approvals + .iter() + .map(|(id, a)| { + let kind = match a.kind { + ApprovalKind::Action => "action", + ApprovalKind::EnableTools => "tools", + ApprovalKind::EnableThisComputer => "local", + }; + json!({"bot": id, "call_id": a.call_id, "tool": a.tool, "reason": a.reason, "grant": a.grant, "kind": kind}) + }) + .collect(); + out + } + + /// A screen's edits. + pub(super) fn brain_edit(&mut self, body: &Value) -> Value { + let mut applied = 0; + for op in body["ops"].as_array().into_iter().flatten() { + let ok = match op["op"].as_str().unwrap_or("") { + "set" => self.brain_set_top(op["key"].as_str().unwrap_or(""), op["value"].clone()), + "bot_add" => { + let mut v = op["bot"].clone(); + match serde_json::from_value::(v.take()) { + Ok(mut b) => { + if b.id == 0 || self.state.bot(b.id).is_some() { + b.id = self.state.next_id.max(1); + } + self.state.next_id = self.state.next_id.max(b.id + 1); + self.state.bots.push(b); + true + } + Err(_) => false, + } + } + "bot_del" => { + let id = op["id"].as_u64().unwrap_or(0); + if id == self.state.primary_bot || self.state.bots.len() <= 1 { + false + } else { + self.stop(id); + self.state.bots.retain(|b| b.id != id); + true + } + } + "merge" => self.brain_merge_top(op["key"].as_str().unwrap_or(""), &op["value"]), + "bot_set" => self.brain_set_bot(op["id"].as_u64().unwrap_or(0), op["key"].as_str().unwrap_or(""), op["value"].clone()), + "bot_read" => match self.state.bot_mut(op["id"].as_u64().unwrap_or(0)) { + Some(b) => { + b.unread = false; + true + } + None => false, + }, + "bot_order" => { + let ids: Vec = op["ids"].as_array().into_iter().flatten().filter_map(Value::as_u64).collect(); + self.state.bots.sort_by_key(|b| ids.iter().position(|i| *i == b.id).unwrap_or(usize::MAX)); + true + } + _ => false, + }; + if ok { + applied += 1; + } + } + if applied > 0 { + self.mark(); + // Screens hear of it at once. + self.scr.cfg_at = None; + self.brain_watch(); + } + json!({"ok": true, "applied": applied}) + } + + fn brain_set_top(&mut self, key: &str, mut value: Value) -> bool { + if key.is_empty() || key == "bots" || LOCAL_KEYS.contains(&key) { + return false; + } + let bots = std::mem::take(&mut self.state.bots); + let mut top = serde_json::to_value(&self.state).unwrap_or(Value::Null); + unredact(&mut value, &top[key]); + // Counters never go back (a screen may be behind). + if key.starts_with("next_") { + value = json!(value.as_u64().unwrap_or(0).max(top[key].as_u64().unwrap_or(0))); + } + top[key] = value; + let ok = match serde_json::from_value::(top) { + Ok(mut st) => { + st.remote = std::mem::take(&mut self.state.remote); + self.state = st; + true + } + Err(_) => false, + }; + self.state.bots = bots; + ok + } + + /// 搬到大腦: add what the brain doesn't have yet (keys it lacks, skills and connectors by name). + fn brain_merge_top(&mut self, key: &str, value: &Value) -> bool { + let bots = std::mem::take(&mut self.state.bots); + let top = serde_json::to_value(&self.state).unwrap_or(Value::Null); + self.state.bots = bots; + let mut cur = top[key].clone(); + match (key, &mut cur) { + ("providers", Value::Object(o)) => { + for (k, v) in value.as_object().into_iter().flatten() { + let missing = o.get(k).map(|x| x["api_key"].as_str().unwrap_or("").is_empty()).unwrap_or(true); + if missing && v["api_key"].as_str().map(|s| !s.is_empty() && s != REDACTED).unwrap_or(false) { + o.insert(k.clone(), v.clone()); + } + } + } + ("skills" | "mcp_servers", Value::Array(a)) => { + for v in value.as_array().into_iter().flatten() { + if !a.iter().any(|x| x["name"] == v["name"]) { + a.push(v.clone()); + } + } + } + _ => return false, + } + self.brain_set_top(key, cur) + } + + fn brain_set_bot(&mut self, id: u64, key: &str, value: Value) -> bool { + if BOT_RUNTIME.contains(&key) { + return false; + } + let Some(i) = self.state.bots.iter().position(|b| b.id == id) else { return false }; + let b = &mut self.state.bots[i]; + let msgs = std::mem::take(&mut b.messages); + let threads = std::mem::take(&mut b.threads); + let mut v = serde_json::to_value(&*b).unwrap_or(Value::Null); + v[key] = value; + let ok = match serde_json::from_value::(v) { + Ok(nb) => { + *b = nb; + true + } + Err(_) => false, + }; + let b = &mut self.state.bots[i]; + b.messages = msgs; + b.threads = threads; + ok + } + + // ---------- the screen ---------- + + /// Once at start: a brain was chosen earlier, so show its copy. + pub(super) fn mirror_boot(&mut self) { + if self.mir.started || self.demo { + return; + } + self.mir.started = true; + self.mir.client = crate::httpd::random_hex(8); + if !self.state.remote.brain_id.is_empty() && !self.state.remote.host_token.is_empty() { + self.mirror_enter(); + } + } + + /// Start showing the brain: this computer's own bots are kept aside, untouched. + fn mirror_enter(&mut self) { + let ids: Vec = self.streams.keys().copied().collect(); + for id in ids { + self.stop(id); + } + self.approvals.clear(); + let local = self.state.clone(); + if let Some(copy) = std::fs::read_to_string(cache_path()).ok().and_then(|s| serde_json::from_str::(&s).ok()).filter(|s| !s.bots.is_empty()) { + self.state = copy; + self.keep_local_fields(&local); + // Edits wait for the brain's own answer; the copy may be old. + } + self.mir.base = Value::Null; + self.mir.local = Some(local); + self.mir.on = true; + self.mir.online = false; + self.mir.version.store(0, Ordering::SeqCst); + *self.mir.status.lock().unwrap() = "正在連到大腦…".into(); + self.mirror_worker(); + } + + /// Back to this computer's own bots. + fn mirror_leave(&mut self) { + self.mir.gen.fetch_add(1, Ordering::SeqCst); + self.mir.on = false; + self.mir.online = false; + self.streams.clear(); + self.approvals.clear(); + if let Some(mut local) = self.mir.local.take() { + let mut remote = self.state.remote.clone(); + remote.brain_id.clear(); + remote.brain_name.clear(); + local.remote = remote; + local.theme = self.state.theme; + local.accent = self.state.accent; + if local.bot(local.selected).is_none() { + local.selected = local.primary_bot; + } + self.state = local; + } else { + self.state.remote.brain_id.clear(); + self.state.remote.brain_name.clear(); + } + self.state.save(); + self.dirty = false; + } + + fn keep_local_fields(&mut self, from: &State) { + let st = &mut self.state; + st.remote = from.remote.clone(); + st.theme = from.theme; + st.accent = from.accent; + st.sidebar_collapsed = from.sidebar_collapsed; + st.sidebar_width = from.sidebar_width; + st.machine_label = from.machine_label.clone(); + st.local_execution = from.local_execution; + st.local_never = from.local_never; + st.messages_send = from.messages_send; + st.messages_no_ask = from.messages_no_ask; + if st.bot(from.selected).is_some() { + st.selected = from.selected; + } else if st.bot(st.selected).is_none() { + st.selected = st.primary_bot; + } + } + + fn mirror_worker(&mut self) { + let gen = self.mir.gen.fetch_add(1, Ordering::SeqCst) + 1; + let (tx, rx) = channel(); + self.mir.rx = Some(rx); + self.mir.tx = Some(tx.clone()); + let (g, version, client, s) = (self.mir.gen.clone(), self.mir.version.clone(), self.mir.client.clone(), self.state.remote.clone()); + let repaint = self.repaint_fn(); + std::thread::spawn(move || { + let mut backoff = 1u64; + while g.load(Ordering::SeqCst) == gen { + match remote::sync_pull(&s, version.load(Ordering::SeqCst), &client) { + Ok(v) => { + backoff = 1; + if g.load(Ordering::SeqCst) != gen { + break; + } + version.store(v["version"].as_u64().unwrap_or(0), Ordering::SeqCst); + let _ = tx.send(Incoming::Pull(v)); + } + Err(e) => { + let _ = tx.send(Incoming::Offline(e)); + // Start over (with everything) once it is back. + version.store(0, Ordering::SeqCst); + std::thread::sleep(std::time::Duration::from_secs(backoff)); + backoff = (backoff * 2).min(20); + } + } + repaint(); + } + }); + } + + fn repaint_fn(&self) -> Arc { + match &self.ctx_handle { + Some(c) => { + let c = c.clone(); + Arc::new(move || c.request_repaint()) + } + None => Arc::new(|| {}), + } + } + + /// Every frame on a screen: take in what the brain sent. + pub(super) fn mirror_tick(&mut self) { + if let Some((id, name)) = self.mir.want.take() { + if self.mir.on { + self.mirror_leave(); + } + self.state.remote.brain_id = id; + self.state.remote.brain_name = name; + self.state.save(); + self.mirror_enter(); + } + if std::mem::take(&mut self.mir.leave) && self.mir.on { + self.mirror_leave(); + } + if !self.mir.on { + return; + } + let mut got = vec![]; + if let Some(rx) = &self.mir.rx { + while let Ok(m) = rx.try_recv() { + got.push(m); + } + } + for m in got { + match m { + Incoming::Offline(e) => { + self.mir.online = false; + self.mir.base = Value::Null; + *self.mir.status.lock().unwrap() = format!("連不到大腦「{}」:{e}。現在只能看,連上後會自動恢復。", self.state.remote.brain_name); + } + Incoming::Pull(v) => { + if let Some(e) = v["error"].as_str() { + self.mir.online = false; + *self.mir.status.lock().unwrap() = e.to_string(); + continue; + } + self.mir.online = true; + *self.mir.status.lock().unwrap() = format!("已連到大腦「{}」", v["brain"].as_str().unwrap_or(&self.state.remote.brain_name)); + self.mirror_apply(v); + } + } + } + } + + fn mirror_apply(&mut self, v: Value) { + if v["config"].is_object() { + let cfg = v["config"].clone(); + let local = std::mem::replace(&mut self.state, State::first_run()); + match serde_json::from_value::(cfg.clone()) { + Ok(mut st) => { + // Chats stay as they are (they come separately). + let mut old = local.bots.clone(); + for b in st.bots.iter_mut() { + if let Some(o) = old.iter_mut().find(|o| o.id == b.id) { + b.messages = std::mem::take(&mut o.messages); + b.threads = std::mem::take(&mut o.threads); + } + } + self.state = st; + self.keep_local_fields(&local); + self.mir.base = cfg; + } + Err(e) => { + self.state = local; + *self.mir.error.lock().unwrap() = format!("大腦的設定讀不出來(兩邊 AnyBot 版本不同?):{e}"); + } + } + } + for d in v["bots"].as_array().into_iter().flatten() { + let id = d["id"].as_u64().unwrap_or(0); + let from = d["from"].as_u64().unwrap_or(0) as usize; + let msgs: Vec = serde_json::from_value(d["messages"].clone()).unwrap_or_default(); + if let Some(b) = self.state.bot_mut(id) { + b.messages.truncate(from); + b.messages.extend(msgs); + } + } + for d in v["threads"].as_array().into_iter().flatten() { + if let Some(b) = self.state.bot_mut(d["id"].as_u64().unwrap_or(0)) { + b.threads = serde_json::from_value(d["threads"].clone()).unwrap_or_default(); + } + } + // Who is working, and what waits for the person. + let live: HashMap, bool)> = v["live"].as_object().into_iter().flatten().filter_map(|(k, l)| Some((k.parse().ok()?, (l["status"].as_str().map(String::from), l["paused"].as_bool().unwrap_or(false))))).collect(); + self.streams.retain(|id, _| live.contains_key(id)); + for (id, (status, paused)) in live { + let l = self.streams.entry(id).or_insert_with(|| Live { + cancel: Arc::new(AtomicBool::new(false)), + started: Instant::now(), + last: Instant::now(), + produced: true, + status: None, + paused: false, + steer: Arc::default(), + turn_id: String::new(), + direct: Arc::default(), + }); + l.status = status; + l.paused = paused; + l.last = Instant::now(); + } + let mut waiting = HashMap::new(); + for a in v["approvals"].as_array().into_iter().flatten() { + let id = a["bot"].as_u64().unwrap_or(0); + let kind = match a["kind"].as_str().unwrap_or("") { + "tools" => ApprovalKind::EnableTools, + "local" => ApprovalKind::EnableThisComputer, + _ => ApprovalKind::Action, + }; + let (reply, _) = channel(); + let s = |k: &str| a[k].as_str().unwrap_or("").to_string(); + waiting.insert(id, Approval { call_id: s("call_id"), tool: s("tool"), reason: s("reason"), grant: s("grant"), reply, kind }); + } + let ctx = self.ctx_handle.clone(); + for (id, a) in waiting { + let new = self.approvals.get(&id).map(|o| o.call_id != a.call_id).unwrap_or(true); + self.approvals.insert(id, a); + if new { + if let Some(c) = &ctx { + self.on_new_approval(c, id); + } + } + } + let keep: Vec = v["approvals"].as_array().into_iter().flatten().filter_map(|a| a["bot"].as_u64()).collect(); + self.approvals.retain(|id, _| keep.contains(id)); + if self.state.bot(self.state.selected).is_none() { + self.state.selected = self.state.primary_bot; + } + self.mirror_save_copy(); + } + + fn mirror_save_copy(&mut self) { + if let Ok(s) = serde_json::to_string(&self.state) { + let p = cache_path(); + let tmp = p.with_extension("tmp"); + if std::fs::write(&tmp, s).is_ok() { + let _ = std::fs::rename(&tmp, &p); + } + } + } + + /// Saving on a screen: edits go to the brain; this computer's own file keeps its own bots. + pub(super) fn mirror_save(&mut self) { + if let Some(local) = self.mir.local.as_mut() { + local.remote = self.state.remote.clone(); + local.theme = self.state.theme; + local.accent = self.state.accent; + local.sidebar_collapsed = self.state.sidebar_collapsed; + local.sidebar_width = self.state.sidebar_width; + local.machine_label = self.state.machine_label.clone(); + local.local_execution = self.state.local_execution; + local.local_never = self.state.local_never; + local.messages_send = self.state.messages_send; + local.messages_no_ask = self.state.messages_no_ask; + local.save(); + } + if !self.mir.online || !self.mir.base.is_object() { + return; + } + let cur = config_json(&mut self.state); + let ops = diff_ops(&self.mir.base, &cur); + if ops.is_empty() { + return; + } + self.mir.base = cur; + let (s, err) = (self.state.remote.clone(), self.mir.error.clone()); + std::thread::spawn(move || { + if let Err(e) = remote::sync_post(&s, "edit", &json!({"ops": ops})) { + *err.lock().unwrap() = format!("改動沒有存到大腦:{e}"); + } + }); + } + + /// A chat action on a screen goes to the brain. + pub(super) fn mirror_act(&mut self, body: Value) { + if !self.mir.online { + *self.mir.error.lock().unwrap() = "連不到大腦,現在只能看。".into(); + return; + } + let (s, err) = (self.state.remote.clone(), self.mir.error.clone()); + std::thread::spawn(move || { + if let Err(e) = remote::sync_post(&s, "act", &body) { + *err.lock().unwrap() = format!("大腦沒有收到:{e}"); + } + }); + } + + /// Send a message through the brain: files go up first, then the text. + pub(super) fn mirror_send(&mut self, bot: u64, text: String, atts: Vec) -> bool { + if !self.mir.online { + *self.mir.error.lock().unwrap() = "連不到大腦,訊息沒有送出。".into(); + return false; + } + // Shown at once; the brain's copy replaces it. + if !self.streams.contains_key(&bot) { + if let Some(b) = self.state.bot_mut(bot) { + b.messages.push(Message { attachments: atts.clone(), ..Message::user(&text) }); + } + } + let (s, err) = (self.state.remote.clone(), self.mir.error.clone()); + std::thread::spawn(move || { + let mut paths = vec![]; + for a in &atts { + match std::fs::read(&a.path).map_err(|e| e.to_string()).and_then(|b| remote::sync_upload(&s, bot, &a.name, &b)) { + Ok(p) => paths.push(p), + Err(e) => { + *err.lock().unwrap() = format!("「{}」沒有傳到大腦:{e}", a.name); + } + } + } + if let Err(e) = remote::sync_post(&s, "act", &json!({"bot": bot, "action": "send", "text": text, "attachments": paths})) { + *err.lock().unwrap() = format!("訊息沒有送到大腦:{e}"); + } + }); + true + } + + /// 把這台的 Bot 搬到大腦: copy this computer's own bots (with their chats), keys, skills and + /// connectors to the brain, as new bots there. + pub(super) fn mirror_move_local(&mut self) { + let Some(local) = self.mir.local.clone() else { return }; + if self.mir.moving.swap(true, Ordering::SeqCst) { + return; + } + let mut ops: Vec = vec![]; + for b in &local.bots { + let mut v = serde_json::to_value(b).unwrap_or(Value::Null); + v["id"] = json!(0); + ops.push(json!({"op": "bot_add", "bot": v})); + } + let top = serde_json::to_value(&local).unwrap_or(Value::Null); + for k in ["providers", "skills", "mcp_servers"] { + ops.push(json!({"op": "merge", "key": k, "value": top[k]})); + } + let (s, note, busy, n) = (self.state.remote.clone(), self.mir.move_note.clone(), self.mir.moving.clone(), local.bots.len()); + *note.lock().unwrap() = "搬移中…".into(); + std::thread::spawn(move || { + *note.lock().unwrap() = match remote::sync_post(&s, "edit", &json!({"ops": ops})) { + Ok(v) => format!("已把 {n} 個 Bot 搬到大腦({} 項)", v["applied"].as_u64().unwrap_or(0)), + Err(e) => format!("搬移失敗:{e}"), + }; + busy.store(false, Ordering::SeqCst); + }); + } + + /// The bar above the chat while showing a brain. + pub(super) fn mirror_banner(&mut self, ui: &mut egui::Ui) { + if !self.mir.on { + return; + } + let p = self.pal; + let err = std::mem::take(&mut *self.mir.error.lock().unwrap()); + if !err.is_empty() { + self.notice = Some(err); + } + if self.mir.online { + return; + } + let status = self.mir.status.lock().unwrap().clone(); + ui.vertical_centered(|ui| { + ui.set_max_width(TRANSCRIPT_MAX_WIDTH.min(ui.available_width() - 48.0)); + Frame::new().fill(p.danger.gamma_multiply(0.08)).corner_radius(8).inner_margin(Margin::symmetric(12, 8)).show(ui, |ui| { + ui.add(egui::Label::new(RichText::new(status).size(FONT_SM).color(p.text2)).wrap()); + }); + }); + ui.add_space(6.0); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn edits_become_small_operations() { + let base = json!({"theme": "light", "folders": ["a"], "bots": [{"id": 1, "name": "A", "unread": true}, {"id": 2, "name": "B"}]}); + let cur = json!({"theme": "dark", "folders": ["a", "b"], "bots": [{"id": 1, "name": "A2", "unread": false}, {"id": 3, "name": "C"}]}); + let ops = diff_ops(&base, &cur); + // theme is this computer's own; folders changed. + assert!(ops.iter().any(|o| o["op"] == "set" && o["key"] == "folders")); + assert!(!ops.iter().any(|o| o["key"] == "theme")); + assert!(ops.iter().any(|o| o["op"] == "bot_set" && o["key"] == "name" && o["value"] == "A2")); + assert!(ops.iter().any(|o| o["op"] == "bot_read" && o["id"] == 1)); + assert!(ops.iter().any(|o| o["op"] == "bot_add" && o["bot"]["id"] == 3)); + assert!(ops.iter().any(|o| o["op"] == "bot_del" && o["id"] == 2)); + } + + #[test] + fn keys_stay_on_the_brain() { + let mut v = json!({"remote": {"host_token": "x"}, "providers": {"xai": {"api_key": "sk-1"}, "openai": {"api_key": ""}}, "channels": [{"id": 4, "token": "t"}]}); + let old = v.clone(); + redact(&mut v); + assert!(v.get("remote").is_none()); + assert_eq!(v["providers"]["xai"]["api_key"], REDACTED); + assert_eq!(v["providers"]["openai"]["api_key"], ""); + assert_eq!(v["channels"][0]["token"], REDACTED); + // An edit that kept the stand-in keeps the real key. + let mut edit = v["providers"].clone(); + edit["openai"]["api_key"] = json!("sk-new"); + unredact(&mut edit, &old["providers"]); + assert_eq!(edit["xai"]["api_key"], "sk-1"); + assert_eq!(edit["openai"]["api_key"], "sk-new"); + let mut ch = v["channels"].clone(); + unredact(&mut ch, &old["channels"]); + assert_eq!(ch[0]["token"], "t"); + } +} diff --git a/src/app/remote_ui.rs b/src/app/remote_ui.rs index bd08465..b0aa019 100644 --- a/src/app/remote_ui.rs +++ b/src/app/remote_ui.rs @@ -51,9 +51,11 @@ impl App { return; } self.rm.started = true; + self.ctx_handle = Some(ctx.clone()); let c = ctx.clone(); remote::hub().set_repaint(Arc::new(move || c.request_repaint())); self.remote_apply_settings(); + self.mirror_boot(); } /// Hand changed settings to the server threads (and restart what depends on them). @@ -84,8 +86,14 @@ impl App { } self.remote_apply_settings(); for c in remote::hub().take_cmds() { + // A screen of another brain has no bots of its own to run phone requests on. + if self.mir.on && !matches!(c, Cmd::DeviceAdded(_) | Cmd::DeviceSeen(_)) { + continue; + } self.remote_cmd(ctx, c); } + self.brain_serve(); + self.brain_watch(); if let Some(rx) = &self.rm.join_rx { if let Ok(r) = rx.try_recv() { self.rm.join_rx = None; @@ -118,7 +126,7 @@ impl App { } // 自動備份: once a day while signed in. let s = &self.state.remote; - if s.auto_backup && !s.backup_key.is_empty() && !s.relay_url.is_empty() && chrono::Utc::now().timestamp() - s.last_backup > 86_400 && !self.rm.busy.load(Ordering::SeqCst) { + if !self.mir.on && s.auto_backup && !s.backup_key.is_empty() && !s.relay_url.is_empty() && chrono::Utc::now().timestamp() - s.last_backup > 86_400 && !self.rm.busy.load(Ordering::SeqCst) { self.remote_backup(); } // 裝置連結: keep the list of this account's computers fresh (once a minute). @@ -132,7 +140,7 @@ impl App { } }); } - if !self.rm.last_publish.map(|t| t.elapsed().as_millis() < 120).unwrap_or(false) { + if !self.mir.on && !self.rm.last_publish.map(|t| t.elapsed().as_millis() < 120).unwrap_or(false) { self.remote_publish(); } } @@ -140,6 +148,7 @@ impl App { fn remote_signature(&self) -> u64 { let mut h = std::collections::hash_map::DefaultHasher::new(); self.state.remote.host_name.hash(&mut h); + self.scr.cfg_hash.hash(&mut h); for b in &self.state.bots { (b.id, &b.name, &b.color, b.unread, b.messages.len()).hash(&mut h); if let Some(m) = b.messages.last() { @@ -214,6 +223,27 @@ impl App { self.start_reply(ctx, bot); } Cmd::Stop { bot } => self.stop(bot), + Cmd::Regenerate { bot } => self.regenerate(ctx, bot), + Cmd::Reply { bot } => { + if !self.streams.contains_key(&bot) && self.state.bot(bot).and_then(|b| b.messages.last()).map(|m| m.role == Role::User).unwrap_or(false) { + self.start_reply(ctx, bot); + } + } + Cmd::RestoreThread { bot, index } => { + self.stop(bot); + if let Some(b) = self.state.bot_mut(bot) { + if index < b.threads.len() { + let (_, msgs) = b.threads.remove(index); + if !b.messages.is_empty() { + let title = b.messages.iter().find(|m| m.role == Role::User).map(|m| first_line(&m.text, 24)).unwrap_or_else(|| "對話".into()); + let cur = std::mem::take(&mut b.messages); + b.threads.insert(0, (title, cur)); + } + b.messages = msgs; + } + } + self.mark(); + } Cmd::Answer { bot, answer } => { let grant = self.approvals.get(&bot).map(|a| a.grant.clone()).unwrap_or_default(); let a = match answer.as_str() { @@ -380,6 +410,60 @@ impl App { } }); + // ── 大腦 ── + if !self.state.remote.host_token.is_empty() { + setting_card(ui, p, |ui| { + ui.horizontal(|ui| { + ui.label(RichText::new(t("大腦")).size(FONT_BASE).strong()); + help_mark(ui, "帳號裡選一台 AnyBot 當大腦(通常是跟帳號伺服器放在一起、一直開著的那台)。所有 Bot、聊天、記憶和例行任務都放在大腦上,由它思考和排程。其他電腦打開 AnyBot 看到的都是同一份,同時也是大腦的手腳:大腦的 Bot 可以透過裝置連結操作它們。大腦連不到時,這裡還能看之前的對話,但不能傳訊息,連回來就恢復。", p); + }); + let s = &self.state.remote; + let status = self.mir.status.lock().unwrap().clone(); + if self.mir.on { + ui.horizontal(|ui| { + let (r, _) = ui.allocate_exact_size(vec2(10.0, 10.0), egui::Sense::hover()); + ui.painter().circle_filled(r.center(), 4.0, if self.mir.online { Color32::from_rgb(0x14, 0xc3, 0x70) } else { p.text3 }); + ui.label(RichText::new(status).size(13.0).color(p.text2)); + }); + ui.horizontal(|ui| { + let busy = self.mir.moving.load(Ordering::SeqCst); + if ui.add_enabled(self.mir.online && !busy, secondary_button("把這台的 Bot 搬過去")).on_hover_text(t("把這台電腦原本的 Bot(連同聊天)、模型金鑰、技能和連接器複製到大腦。這台的原始資料不會刪掉。")).clicked() { + self.mirror_move_local(); + } + if ui.add(secondary_button("改回用這台自己的 Bot")).clicked() { + self.mir.leave = true; + } + }); + let note = self.mir.move_note.lock().unwrap().clone(); + if !note.is_empty() { + ui.label(RichText::new(note).size(12.5).color(p.text2)); + } + } else { + ui.label(RichText::new(t("這台就是大腦:Bot 在這台電腦上思考和工作。")).size(13.0).color(p.text2)); + let me = s.host_id.clone(); + let peers: Vec<(String, String, bool)> = self.rm.peers.lock().unwrap().iter().filter(|(id, _, _)| !id.is_empty() && *id != me).cloned().collect(); + if peers.is_empty() { + hint(ui, "帳號裡還沒有其他 AnyBot。在伺服器上啟動 anybot --serve 並登入同一個帳號,它就會出現在這裡。", p); + } + for (id, name, online) in peers { + ui.horizontal(|ui| { + let (r, _) = ui.allocate_exact_size(vec2(10.0, 10.0), egui::Sense::hover()); + ui.painter().circle_filled(r.center(), 4.0, if online { Color32::from_rgb(0x14, 0xc3, 0x70) } else { p.text3 }); + ui.label(RichText::new(&name).size(13.5)); + ui.with_layout(Layout::right_to_left(Align::Center), |ui| { + if ui.add_enabled(online, secondary_button("改用它當大腦")).clicked() { + self.mir.want = Some((id.clone(), name.clone())); + // The brain's bots may use this computer (each action still asks). + self.state.remote.device_link = true; + changed = true; + } + }); + }); + } + } + }); + } + // ── 同一個網路 ── setting_card(ui, p, |ui| { setting_title(ui, "同一個網路直接連", "手機和這台電腦在同一個 Wi-Fi,或都裝了 Tailscale 時使用。", p); diff --git a/src/remote.rs b/src/remote.rs index ed25cc2..676415d 100644 --- a/src/remote.rs +++ b/src/remote.rs @@ -72,11 +72,17 @@ pub struct Settings { pub auto_backup: bool, #[serde(default)] pub last_backup: i64, + /// 大腦: the AnyBot of this account whose bots, chats and settings this one shows (its relay + /// host id). Empty = this AnyBot is its own brain. When set, this one is a screen and hands. + #[serde(default, skip_serializing_if = "String::is_empty")] + pub brain_id: String, + #[serde(default, skip_serializing_if = "String::is_empty")] + pub brain_name: String, } impl Default for Settings { fn default() -> Self { - Settings { lan: false, password: String::new(), devices: vec![], mac_from_phone: false, device_link: false, relay_url: String::new(), relay_user: String::new(), host_id: String::new(), host_token: String::new(), host_name: default_host_name(), backup_key: String::new(), auto_backup: true, last_backup: 0 } + Settings { lan: false, password: String::new(), devices: vec![], mac_from_phone: false, device_link: false, relay_url: String::new(), relay_user: String::new(), host_id: String::new(), host_token: String::new(), host_name: default_host_name(), backup_key: String::new(), auto_backup: true, last_backup: 0, brain_id: String::new(), brain_name: String::new() } } } @@ -103,6 +109,11 @@ pub enum Cmd { Answer { bot: u64, answer: String }, Read { bot: u64 }, NewThread { bot: u64 }, + /// Answer the last message again (重新產生) / reply to what is there (重新傳送). + Regenerate { bot: u64 }, + Reply { bot: u64 }, + /// Bring back an earlier conversation (過去的對話). + RestoreThread { bot: u64, index: usize }, /// A new phone signed in (to remember), or one was signed out. DeviceAdded(Device), DeviceSeen(String), @@ -126,11 +137,23 @@ pub struct Hub { settings: Mutex, fails: Mutex<(u32, Option)>, relay_status: Mutex, + sync: Mutex>, +} + +/// 大腦: another AnyBot of this account (a screen) asks for what changed, or sends its edits. +pub enum SyncKind { + Pull { client: String, since: u64 }, + Edit(Value), +} + +pub struct SyncReq { + pub kind: SyncKind, + pub reply: std::sync::mpsc::Sender, } pub fn hub() -> &'static Hub { static H: OnceLock = OnceLock::new(); - H.get_or_init(|| Hub { view: Mutex::new(View::default()), changed: Condvar::new(), cmds: Mutex::new(vec![]), repaint: Mutex::new(None), settings: Mutex::new(Settings::default()), fails: Mutex::new((0, None)), relay_status: Mutex::new(String::new()) }) + H.get_or_init(|| Hub { view: Mutex::new(View::default()), changed: Condvar::new(), cmds: Mutex::new(vec![]), repaint: Mutex::new(None), settings: Mutex::new(Settings::default()), fails: Mutex::new((0, None)), relay_status: Mutex::new(String::new()), sync: Mutex::new(vec![]) }) } static VERSION: AtomicU64 = AtomicU64::new(1); @@ -141,6 +164,21 @@ impl Hub { *self.view.lock().unwrap() = v; self.changed.notify_all(); } + pub fn take_sync(&self) -> Vec { + std::mem::take(&mut *self.sync.lock().unwrap()) + } + /// Hand a sync request to the app's thread and wait for its answer. + fn ask_sync(&self, kind: SyncKind) -> Result { + let (tx, rx) = std::sync::mpsc::channel(); + self.sync.lock().unwrap().push(SyncReq { kind, reply: tx }); + if let Some(r) = self.repaint.lock().unwrap().as_ref() { + r(); + } + rx.recv_timeout(Duration::from_secs(10)).map_err(|_| "大腦沒有及時回應".to_string()) + } + pub fn version(&self) -> u64 { + self.view.lock().unwrap().version + } pub fn take_cmds(&self) -> Vec { std::mem::take(&mut *self.cmds.lock().unwrap()) } @@ -328,6 +366,44 @@ pub fn handle(req: Req, via_relay: bool) -> Resp { ("GET", ["api", "screen", which]) => screen(which, &s), ("POST", ["api", "screen", which]) => screen_input(which, &req.json(), &s), ("POST", ["api", "device", "run"]) => device_run(&req.json(), &s, via_relay), + ("GET", ["api", "sync"]) => { + let since: u64 = req.query.get("since").and_then(|v| v.parse().ok()).unwrap_or(0); + let client = req.query.get("client").cloned().unwrap_or_default(); + if since > 0 { + let _ = hub().wait_newer(since, LONG_POLL_SECS); + } + match hub().ask_sync(SyncKind::Pull { client, since }) { + Ok(v) => Resp::json(&v), + Err(e) => Resp::error(503, &e), + } + } + ("POST", ["api", "sync", "edit"]) => match hub().ask_sync(SyncKind::Edit(req.json())) { + Ok(v) if v["error"].is_string() => Resp::error(400, v["error"].as_str().unwrap_or("")), + Ok(v) => Resp::json(&v), + Err(e) => Resp::error(503, &e), + }, + ("POST", ["api", "sync", "upload", id]) => upload(id.parse().unwrap_or(0), &req), + ("POST", ["api", "sync", "act"]) => { + let b = req.json(); + let bot = b["bot"].as_u64().unwrap_or(0); + let cmd = match b["action"].as_str().unwrap_or("") { + "send" => Cmd::Send { + bot, + text: b["text"].as_str().unwrap_or("").trim().to_string(), + attachments: b["attachments"].as_array().map(|a| a.iter().filter_map(|x| x.as_str().map(String::from)).collect()).unwrap_or_default(), + }, + "stop" => Cmd::Stop { bot }, + "answer" => Cmd::Answer { bot, answer: b["answer"].as_str().unwrap_or("reject").to_string() }, + "read" => Cmd::Read { bot }, + "new" => Cmd::NewThread { bot }, + "regenerate" => Cmd::Regenerate { bot }, + "reply" => Cmd::Reply { bot }, + "restore" => Cmd::RestoreThread { bot, index: b["index"].as_u64().unwrap_or(0) as usize }, + _ => return Resp::error(404, "沒有這個動作"), + }; + hub().push(cmd); + Resp::json(&json!({"ok": true})) + } ("POST", ["api", "logout"]) if !via_relay => { let h = httpd::sha256_hex(&req.bearer()); hub().push(Cmd::DeviceSeen(format!("-{h}"))); @@ -342,9 +418,12 @@ pub fn handle(req: Req, via_relay: bool) -> Resp { /// account) may run tools here. pub fn role_allows(role: &str, method: &str, path: &[&str]) -> bool { let device = matches!(path, ["api", "device", "run"]); + let sync = matches!(path, ["api", "sync", ..]); match role { "device" => device, - "owner" | "admin" => !device, + // 大腦: the owner's other AnyBots showing this one's bots. + "mirror" => sync, + "owner" | "admin" => !device && !sync, "member" => matches!((method, path), ("GET", ["api", "state"]) | ("GET", ["api", "bots", _]) | ("POST", ["api", "bots", _, "send" | "read" | "upload"])), _ => false, } @@ -484,6 +563,15 @@ fn device_run(v: &Value, s: &Settings, via_relay: bool) -> Resp { let (text, ok, img) = crate::computer::run_by(tool, args, who); return Resp::json(&json!({"text": text, "ok": ok, "image": img})); } + // The AnyBot browser here (its logins are this computer's). + if crate::web::is_web_tool(tool) && tool != "web_fetch" { + let mut v: Value = serde_json::from_str(args).unwrap_or(json!({})); + if let Some(o) = v.as_object_mut() { + o.remove("computer"); + } + let (text, ok, img) = crate::web::run_full(tool, &v.to_string(), &std::sync::atomic::AtomicBool::new(false)); + return Resp::json(&json!({"text": text, "ok": ok, "image": img})); + } if crate::macapps::is_mac_tool(tool) && cfg!(target_os = "macos") { let (text, ok) = crate::macapps::run(tool, args); return Resp::json(&json!({"text": text, "ok": ok})); @@ -753,6 +841,58 @@ pub fn run_on_device(host_id: &str, tool: &str, args: &str, who: &str) -> Result Ok(v) } +// ---------- 大腦: this AnyBot as a screen of another ---------- + +fn brain_url(s: &Settings, rest: &str) -> Result { + if s.relay_url.is_empty() || s.host_token.is_empty() { + return Err("沒有登入帳號".into()); + } + if s.brain_id.is_empty() { + return Err("沒有選大腦".into()); + } + Ok(format!("{}/relay/hosts/{}/api/sync{rest}", s.relay_url, s.brain_id)) +} + +fn read_reply(r: Result, ureq::Error>) -> Result { + let mut resp = r.map_err(|e| format!("連不到帳號伺服器:{e}"))?; + let st = resp.status().as_u16(); + let text = resp.body_mut().with_config().limit(200 * 1024 * 1024).read_to_string().unwrap_or_default(); + let v: Value = serde_json::from_str(&text).unwrap_or(Value::Null); + if !(200..300).contains(&st) { + return Err(v["error"].as_str().map(String::from).unwrap_or(format!("大腦沒有回應(HTTP {st})"))); + } + Ok(v) +} + +/// What changed at the brain since `since` (waits up to LONG_POLL_SECS when nothing has). +pub fn sync_pull(s: &Settings, since: u64, client: &str) -> Result { + let url = brain_url(s, &format!("?since={since}&client={client}"))?; + read_reply(agent(LONG_POLL_SECS + 25).get(&url).header("Authorization", &format!("Bearer {}", s.host_token)).call()) +} + +/// Send edits ("edit") or a chat action ("act") to the brain. +pub fn sync_post(s: &Settings, what: &str, body: &Value) -> Result { + let url = brain_url(s, &format!("/{what}"))?; + read_reply(agent(60).post(&url).header("Authorization", &format!("Bearer {}", s.host_token)).header("Content-Type", "application/json").send(body.to_string().as_bytes())) +} + +/// A file for the next message, kept at the brain; gives back its path there. +pub fn sync_upload(s: &Settings, bot: u64, name: &str, bytes: &[u8]) -> Result { + let q: String = url_escape(name); + let url = brain_url(s, &format!("/upload/{bot}?name={q}"))?; + let v = read_reply(agent(120).post(&url).header("Authorization", &format!("Bearer {}", s.host_token)).header("Content-Type", "application/octet-stream").send(bytes))?; + v["path"].as_str().map(String::from).ok_or("大腦沒有收下檔案".into()) +} + +fn url_escape(s: &str) -> String { + s.bytes() + .map(|b| match b { + b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => (b as char).to_string(), + _ => format!("%{b:02X}"), + }) + .collect() +} + /// A notification on the person's phones (through the relay; phones that turned them on). pub fn notify(title: &str, body: &str, bot: u64) { let s = hub().settings(); diff --git a/src/web.rs b/src/web.rs index 80ab527..8315ed3 100644 --- a/src/web.rs +++ b/src/web.rs @@ -59,7 +59,28 @@ pub fn read_needs_permission(args: &str) -> bool { uses_person_browser(args) } -pub fn specs() -> Vec { +/// The browser tools, also when the browser is on another computer of the account (裝置連結): +/// then each takes a `computer`, since each computer's AnyBot browser has its own logins. +pub fn specs_for(devices: &[String]) -> Vec { + let local = crate::botbrowser::available(); + let mut v = specs_inner(local || !devices.is_empty()); + if !devices.is_empty() { + let mut names: Vec = devices.to_vec(); + if local { + names.insert(0, crate::model::THIS_COMPUTER_NAME.to_string()); + } + let desc = format!( + "Which computer's AnyBot browser to use. Each computer's browser has its own logins (a site logged in on one isn't on another). {}", + if local { format!("Leave out for {}.", crate::model::THIS_COMPUTER_NAME) } else { "This computer has no browser: always name one.".to_string() } + ); + for t in v.iter_mut().filter(|t| t["name"].as_str().map(|n| n.starts_with("browser_")).unwrap_or(false)) { + t["parameters"]["properties"]["computer"] = json!({"type": "string", "enum": names, "description": desc}); + } + } + v +} + +fn specs_inner(browser: bool) -> Vec { let (names, current) = if crate::botbrowser::available() { (crate::botbrowser::choices().join(", "), crate::botbrowser::current()) } else { @@ -76,7 +97,7 @@ pub fn specs() -> Vec { json!({"name": "browser_profiles", "description": "List, add or switch AnyBot browser profiles (設定檔/profile). Use when the person wants another profile or account: 'list' shows them, 'add' makes a new empty one (they log in once there) and switches to it, 'use' switches. Switching changes which profile browser_open/browser_read use.", "parameters": {"type": "object", "properties": {"action": {"type": "string", "enum": ["list", "add", "use"]}, "name": {"type": "string", "description": "Profile name for add/use."}}, "required": ["action"]}}), ]; - if crate::botbrowser::available() { + if browser { v.extend(crate::browser::specs(&profile)); } v -- 2.40.1 From a70d29fa51b812f475040e8fad08b0db0970a87c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E6=80=A7=E9=A9=8A?= Date: Wed, 4 Nov 2026 19:07:55 +0800 Subject: [PATCH 2/9] Brain sync: edits in order and kept while typing, plain-language offline messages - One worker sends edits, messages and actions to the brain in order. - Edits carry a number; the brain says which it has, and a screen replays the rest on top of newer settings so typing isn't undone. - A message or edit that didn't arrive makes the screen fetch everything again. - An older copy of a routine can't reset when it last/next runs. - Network and server failures read as words (no HTTP codes), naming what can't be reached; the account server notices a closed AnyBot in 8s. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Uirha3oEtWeHh58zA7ErHa --- relay/src/main.rs | 19 ++- src/app/mirror_ui.rs | 292 ++++++++++++++++++++++++++++++++++++------- src/app/remote_ui.rs | 2 +- src/remote.rs | 62 +++++++-- 4 files changed, 321 insertions(+), 54 deletions(-) diff --git a/relay/src/main.rs b/relay/src/main.rs index aa14d66..8d2e88f 100644 --- a/relay/src/main.rs +++ b/relay/src/main.rs @@ -33,6 +33,8 @@ const ICON: &str = r##" Resp::error(504, "那台 AnyBot 沒有及時回應"), + Err(_) => Resp::error(504, "那台 AnyBot 收到了,但太久沒有回應(可能太忙,或網路很慢)"), } } diff --git a/src/app/mirror_ui.rs b/src/app/mirror_ui.rs index 2760a2b..19099ab 100644 --- a/src/app/mirror_ui.rs +++ b/src/app/mirror_ui.rs @@ -167,6 +167,66 @@ pub(super) fn diff_ops(base: &Value, cur: &Value) -> Vec { ops } +/// Replay edit operations on settings JSON: a screen keeps its own not-yet-confirmed edits on +/// top of what the brain last sent, so typing isn't undone by the brain's slightly older copy. +pub(super) fn apply_ops(cfg: &mut Value, ops: &[Value]) { + let id = |x: &Value| x["id"].as_u64().unwrap_or(0); + for op in ops { + let target = op["id"].as_u64().unwrap_or(0); + match op["op"].as_str().unwrap_or("") { + "set" => { + if let Some(k) = op["key"].as_str() { + cfg[k] = op["value"].clone(); + } + } + "bot_add" => { + if let Some(a) = cfg["bots"].as_array_mut() { + if !a.iter().any(|b| id(b) == id(&op["bot"])) { + a.push(op["bot"].clone()); + } + } + } + "bot_del" => { + if let Some(a) = cfg["bots"].as_array_mut() { + a.retain(|b| id(b) != target); + } + } + "bot_set" | "bot_read" => { + if let Some(b) = cfg["bots"].as_array_mut().and_then(|a| a.iter_mut().find(|b| id(b) == target)) { + if op["op"] == "bot_read" { + b["unread"] = json!(false); + } else if let Some(k) = op["key"].as_str() { + b[k] = op["value"].clone(); + } + } + } + "bot_order" => { + let ids: Vec = op["ids"].as_array().into_iter().flatten().filter_map(Value::as_u64).collect(); + if let Some(a) = cfg["bots"].as_array_mut() { + a.sort_by_key(|b| ids.iter().position(|i| *i == id(b)).unwrap_or(usize::MAX)); + } + } + _ => {} + } + } +} + +/// A schedule edited on a screen keeps what the brain knows about runs (when it last ran, when it +/// runs next), so an older copy can't make a routine run twice. +fn keep_run_times(new: &mut Value, old: &Value) { + for r in new.as_array_mut().into_iter().flatten() { + let Some(o) = old.as_array().and_then(|a| a.iter().find(|o| o["id"] == r["id"])) else { continue }; + let last = |x: &Value| x["last_run"].as_i64(); + if last(o) > last(r) || r["last_run"].is_null() { + r["last_run"] = o["last_run"].clone(); + } + let same = ["schedule", "trigger", "paused"].iter().all(|k| r[*k] == o[*k]); + if same { + r["next_run"] = o["next_run"].clone(); + } + } +} + // ---------- the brain's side ---------- /// What the brain last sent each screen. @@ -176,6 +236,8 @@ pub struct Screens { /// The settings fingerprint, refreshed now and then while something changes. pub(super) cfg_hash: u64, cfg_at: Option, + /// The last edit (by number) each screen sent, so it knows which of its edits are in. + acked: HashMap, } #[derive(Default)] @@ -194,6 +256,14 @@ pub enum Incoming { Offline(String), } +/// What a screen sends to the brain, one at a time and in order. +enum Out { + Edit { seq: u64, ops: Vec }, + Act(Value), + Send { bot: u64, text: String, atts: Vec }, + Move { ops: Vec, n: usize }, +} + #[derive(Default)] pub struct Mirror { /// This window shows a brain (instead of its own bots). @@ -218,6 +288,16 @@ pub struct Mirror { pub(super) leave: bool, pub(super) moving: Arc, pub(super) move_note: Arc>, + /// Requests to the brain, sent in order by one worker. + outbox: Option>, + /// Edits sent but not yet confirmed (by number). + pending: Arc)>>>, + seq: u64, + /// Something didn't reach the brain: ask for everything again. + resync: Arc, + /// Why the brain can't be reached (shown above the chat), and since when. + pub(super) reason: String, + down_since: Option, } fn cache_path() -> PathBuf { @@ -271,7 +351,8 @@ impl App { } seen.at = Some(Instant::now()); seen.version = version; - let mut out = json!({"version": version, "brain": self.state.remote.host_name}); + let acked = self.scr.acked.get(client).copied().unwrap_or(0); + let mut out = json!({"version": version, "brain": self.state.remote.host_name, "edit_seq": acked}); let mut c = config_json(&mut self.state); redact(&mut c); let ch = hash_value(&c); @@ -320,6 +401,7 @@ impl App { /// A screen's edits. pub(super) fn brain_edit(&mut self, body: &Value) -> Value { let mut applied = 0; + let total = body["ops"].as_array().map(|a| a.len()).unwrap_or(0); for op in body["ops"].as_array().into_iter().flatten() { let ok = match op["op"].as_str().unwrap_or("") { "set" => self.brain_set_top(op["key"].as_str().unwrap_or(""), op["value"].clone()), @@ -367,6 +449,15 @@ impl App { applied += 1; } } + if let (Some(client), Some(seq)) = (body["client"].as_str(), body["seq"].as_u64()) { + self.scr.acked.insert(client.to_string(), seq); + // Something was refused: send that screen the settings again, so it drops it. + if applied < total { + if let Some(s) = self.scr.seen.get_mut(client) { + s.config = 0; + } + } + } if applied > 0 { self.mark(); // Screens hear of it at once. @@ -436,6 +527,10 @@ impl App { let msgs = std::mem::take(&mut b.messages); let threads = std::mem::take(&mut b.threads); let mut v = serde_json::to_value(&*b).unwrap_or(Value::Null); + let mut value = value; + if key == "routines" { + keep_run_times(&mut value, &v["routines"]); + } v[key] = value; let ok = match serde_json::from_value::(v) { Ok(nb) => { @@ -482,8 +577,12 @@ impl App { self.mir.on = true; self.mir.online = false; self.mir.version.store(0, Ordering::SeqCst); + self.mir.pending.lock().unwrap().clear(); + self.mir.reason.clear(); + self.mir.down_since = None; *self.mir.status.lock().unwrap() = "正在連到大腦…".into(); self.mirror_worker(); + self.mirror_outbox(); } /// Back to this computer's own bots. @@ -491,6 +590,7 @@ impl App { self.mir.gen.fetch_add(1, Ordering::SeqCst); self.mir.on = false; self.mir.online = false; + self.mir.outbox = None; self.streams.clear(); self.approvals.clear(); if let Some(mut local) = self.mir.local.take() { @@ -536,12 +636,13 @@ impl App { let (tx, rx) = channel(); self.mir.rx = Some(rx); self.mir.tx = Some(tx.clone()); - let (g, version, client, s) = (self.mir.gen.clone(), self.mir.version.clone(), self.mir.client.clone(), self.state.remote.clone()); + let (g, version, client, s, resync) = (self.mir.gen.clone(), self.mir.version.clone(), self.mir.client.clone(), self.state.remote.clone(), self.mir.resync.clone()); let repaint = self.repaint_fn(); std::thread::spawn(move || { let mut backoff = 1u64; while g.load(Ordering::SeqCst) == gen { - match remote::sync_pull(&s, version.load(Ordering::SeqCst), &client) { + let since = if resync.swap(false, Ordering::SeqCst) { 0 } else { version.load(Ordering::SeqCst) }; + match remote::sync_pull(&s, since, &client) { Ok(v) => { backoff = 1; if g.load(Ordering::SeqCst) != gen { @@ -601,15 +702,21 @@ impl App { Incoming::Offline(e) => { self.mir.online = false; self.mir.base = Value::Null; - *self.mir.status.lock().unwrap() = format!("連不到大腦「{}」:{e}。現在只能看,連上後會自動恢復。", self.state.remote.brain_name); + self.mir.down_since.get_or_insert_with(Instant::now); + *self.mir.status.lock().unwrap() = format!("連不到大腦:{e}"); + self.mir.reason = e; } Incoming::Pull(v) => { if let Some(e) = v["error"].as_str() { self.mir.online = false; + self.mir.down_since.get_or_insert_with(Instant::now); *self.mir.status.lock().unwrap() = e.to_string(); + self.mir.reason = e.to_string(); continue; } self.mir.online = true; + self.mir.down_since = None; + self.mir.reason.clear(); *self.mir.status.lock().unwrap() = format!("已連到大腦「{}」", v["brain"].as_str().unwrap_or(&self.state.remote.brain_name)); self.mirror_apply(v); } @@ -618,8 +725,21 @@ impl App { } fn mirror_apply(&mut self, v: Value) { + // Edits the brain has taken in are no longer "on the way". + let acked = v["edit_seq"].as_u64().unwrap_or(0); + self.mir.pending.lock().unwrap().retain(|(s, _)| *s > acked); if v["config"].is_object() { - let cfg = v["config"].clone(); + // What the brain will have once the edits on the way arrive, and on top of that what + // the person changed here and hasn't sent yet. + let inflight: Vec = self.mir.pending.lock().unwrap().iter().flat_map(|(_, o)| o.clone()).collect(); + let unsaved = if self.mir.base.is_object() { diff_ops(&self.mir.base, &config_json(&mut self.state)) } else { vec![] }; + let mut base = v["config"].clone(); + apply_ops(&mut base, &inflight); + let mut cfg = base.clone(); + apply_ops(&mut cfg, &unsaved); + if !unsaved.is_empty() { + self.dirty = true; + } let local = std::mem::replace(&mut self.state, State::first_run()); match serde_json::from_value::(cfg.clone()) { Ok(mut st) => { @@ -633,7 +753,7 @@ impl App { } self.state = st; self.keep_local_fields(&local); - self.mir.base = cfg; + self.mir.base = base; } Err(e) => { self.state = local; @@ -738,10 +858,64 @@ impl App { return; } self.mir.base = cur; - let (s, err) = (self.state.remote.clone(), self.mir.error.clone()); + self.mir.seq += 1; + let seq = self.mir.seq; + self.mir.pending.lock().unwrap().push((seq, ops.clone())); + self.mirror_out(Out::Edit { seq, ops }); + } + + fn mirror_out(&mut self, o: Out) { + if let Some(tx) = &self.mir.outbox { + let _ = tx.send(o); + } + } + + /// One worker sends everything to the brain in order (a rename before the message that uses + /// it, two quick edits in the order they were made). + fn mirror_outbox(&mut self) { + let (tx, rx) = channel::(); + self.mir.outbox = Some(tx); + let (s, err, client, pending, resync) = (self.state.remote.clone(), self.mir.error.clone(), self.mir.client.clone(), self.mir.pending.clone(), self.mir.resync.clone()); + let (note, busy) = (self.mir.move_note.clone(), self.mir.moving.clone()); + let repaint = self.repaint_fn(); std::thread::spawn(move || { - if let Err(e) = remote::sync_post(&s, "edit", &json!({"ops": ops})) { - *err.lock().unwrap() = format!("改動沒有存到大腦:{e}"); + for o in rx { + match o { + Out::Edit { seq, ops } => { + if let Err(e) = remote::sync_post(&s, "edit", &json!({"ops": ops, "client": client, "seq": seq})) { + *err.lock().unwrap() = format!("剛才的改動沒有存到大腦:{e}"); + pending.lock().unwrap().retain(|(n, _)| *n != seq); + resync.store(true, Ordering::SeqCst); + } + } + Out::Act(body) => { + if let Err(e) = remote::sync_post(&s, "act", &body) { + *err.lock().unwrap() = format!("大腦沒有收到:{e}"); + } + } + Out::Send { bot, text, atts } => { + let mut paths = vec![]; + for a in &atts { + match std::fs::read(&a.path).map_err(|e| format!("讀不到這個檔案({e})")).and_then(|b| remote::sync_upload(&s, bot, &a.name, &b)) { + Ok(p) => paths.push(p), + Err(e) => *err.lock().unwrap() = format!("「{}」沒有傳到大腦:{e}", a.name), + } + } + if let Err(e) = remote::sync_post(&s, "act", &json!({"bot": bot, "action": "send", "text": text, "attachments": paths})) { + *err.lock().unwrap() = format!("訊息沒有送到大腦:{e}"); + // Take back the message shown ahead of time. + resync.store(true, Ordering::SeqCst); + } + } + Out::Move { ops, n } => { + *note.lock().unwrap() = match remote::sync_post(&s, "edit", &json!({"ops": ops})) { + Ok(v) => format!("已把 {n} 個 Bot 搬到大腦({} 項)", v["applied"].as_u64().unwrap_or(0)), + Err(e) => format!("搬移失敗:{e}"), + }; + busy.store(false, Ordering::SeqCst); + } + } + repaint(); } }); } @@ -752,12 +926,7 @@ impl App { *self.mir.error.lock().unwrap() = "連不到大腦,現在只能看。".into(); return; } - let (s, err) = (self.state.remote.clone(), self.mir.error.clone()); - std::thread::spawn(move || { - if let Err(e) = remote::sync_post(&s, "act", &body) { - *err.lock().unwrap() = format!("大腦沒有收到:{e}"); - } - }); + self.mirror_out(Out::Act(body)); } /// Send a message through the brain: files go up first, then the text. @@ -772,21 +941,11 @@ impl App { b.messages.push(Message { attachments: atts.clone(), ..Message::user(&text) }); } } - let (s, err) = (self.state.remote.clone(), self.mir.error.clone()); - std::thread::spawn(move || { - let mut paths = vec![]; - for a in &atts { - match std::fs::read(&a.path).map_err(|e| e.to_string()).and_then(|b| remote::sync_upload(&s, bot, &a.name, &b)) { - Ok(p) => paths.push(p), - Err(e) => { - *err.lock().unwrap() = format!("「{}」沒有傳到大腦:{e}", a.name); - } - } - } - if let Err(e) = remote::sync_post(&s, "act", &json!({"bot": bot, "action": "send", "text": text, "attachments": paths})) { - *err.lock().unwrap() = format!("訊息沒有送到大腦:{e}"); - } - }); + // Settings changed just before go first. + if self.dirty { + self.mirror_save(); + } + self.mirror_out(Out::Send { bot, text, atts }); true } @@ -807,15 +966,9 @@ impl App { for k in ["providers", "skills", "mcp_servers"] { ops.push(json!({"op": "merge", "key": k, "value": top[k]})); } - let (s, note, busy, n) = (self.state.remote.clone(), self.mir.move_note.clone(), self.mir.moving.clone(), local.bots.len()); - *note.lock().unwrap() = "搬移中…".into(); - std::thread::spawn(move || { - *note.lock().unwrap() = match remote::sync_post(&s, "edit", &json!({"ops": ops})) { - Ok(v) => format!("已把 {n} 個 Bot 搬到大腦({} 項)", v["applied"].as_u64().unwrap_or(0)), - Err(e) => format!("搬移失敗:{e}"), - }; - busy.store(false, Ordering::SeqCst); - }); + *self.mir.move_note.lock().unwrap() = "搬移中…".into(); + let n = local.bots.len(); + self.mirror_out(Out::Move { ops, n }); } /// The bar above the chat while showing a brain. @@ -831,11 +984,25 @@ impl App { if self.mir.online { return; } - let status = self.mir.status.lock().unwrap().clone(); + // A blip (the account server restarting) isn't worth a warning. + let connecting = self.mir.down_since.is_none(); + let (title, reason, tail) = match self.mir.down_since { + None => (format!("正在連到大腦「{}」…", self.state.remote.brain_name), String::new(), String::new()), + Some(t) if t.elapsed().as_secs() < 4 => return, + Some(_) => (format!("連不到大腦「{}」", self.state.remote.brain_name), self.mir.reason.clone(), t("之前的對話還能看,但現在不能傳訊息。連回來後會自動恢復。")), + }; + ui.ctx().request_repaint_after(std::time::Duration::from_secs(1)); ui.vertical_centered(|ui| { ui.set_max_width(TRANSCRIPT_MAX_WIDTH.min(ui.available_width() - 48.0)); - Frame::new().fill(p.danger.gamma_multiply(0.08)).corner_radius(8).inner_margin(Margin::symmetric(12, 8)).show(ui, |ui| { - ui.add(egui::Label::new(RichText::new(status).size(FONT_SM).color(p.text2)).wrap()); + Frame::new().fill(if connecting { p.hover } else { p.danger.gamma_multiply(0.08) }).corner_radius(8).inner_margin(Margin::symmetric(12, 8)).show(ui, |ui| { + ui.vertical(|ui| { + ui.label(RichText::new(title).size(FONT_SM).strong().color(p.text)); + for line in [reason, tail] { + if !line.is_empty() { + ui.add(egui::Label::new(RichText::new(line).size(FONT_SM).color(p.text2)).wrap()); + } + } + }); }); }); ui.add_space(6.0); @@ -879,4 +1046,45 @@ mod tests { unredact(&mut ch, &old["channels"]); assert_eq!(ch[0]["token"], "t"); } + + #[test] + fn edits_replay_on_a_newer_copy() { + let base = json!({"folders": ["a"], "bots": [{"id": 1, "name": "A", "unread": true}, {"id": 2, "name": "B"}]}); + let cur = json!({"folders": ["a", "b"], "bots": [{"id": 2, "name": "B"}, {"id": 1, "name": "A2", "unread": false}, {"id": 3, "name": "C"}]}); + let ops = diff_ops(&base, &cur); + let mut again = base.clone(); + apply_ops(&mut again, &ops); + assert_eq!(again, cur); + // The brain meanwhile renamed bot 2: the screen's own edit stays, the brain's shows too. + let mut newer = json!({"folders": ["a"], "bots": [{"id": 1, "name": "A", "unread": true}, {"id": 2, "name": "B from brain"}]}); + apply_ops(&mut newer, &ops); + assert_eq!(newer["bots"][1]["name"], "A2"); + assert_eq!(newer["bots"][0]["name"], "B from brain"); + assert_eq!(newer["folders"], json!(["a", "b"])); + } + + #[test] + fn an_old_copy_cant_make_a_routine_run_twice() { + let brain = json!([{"id": 7, "schedule": "daily", "trigger": null, "paused": false, "last_run": 200, "next_run": 300}]); + let mut edit = json!([{"id": 7, "title": "new", "schedule": "daily", "trigger": null, "paused": false, "last_run": 100, "next_run": 150}]); + keep_run_times(&mut edit, &brain); + assert_eq!(edit[0]["last_run"], 200); + assert_eq!(edit[0]["next_run"], 300); + assert_eq!(edit[0]["title"], "new"); + // A new schedule gets worked out again. + let mut moved = json!([{"id": 7, "schedule": "weekly", "trigger": null, "paused": false, "last_run": 100, "next_run": null}]); + keep_run_times(&mut moved, &brain); + assert_eq!(moved[0]["last_run"], 200); + assert!(moved[0]["next_run"].is_null()); + } + + #[test] + fn failures_read_as_words_not_codes() { + let w = remote::why_refused(502, &Value::Null, "大腦"); + assert!(!w.contains("502") && w.contains("帳號伺服器")); + let off = remote::why_refused(503, &json!({"error": "那台 AnyBot 目前離線(電腦關機、睡眠,或 AnyBot 沒開)"}), "大腦"); + assert!(off.starts_with("大腦目前離線")); + assert!(remote::why_refused(401, &Value::Null, "大腦").contains("重新登入")); + assert!(remote::why_unreachable(&ureq::Error::HostNotFound).contains("網路")); + } } diff --git a/src/app/remote_ui.rs b/src/app/remote_ui.rs index b0aa019..08dc09e 100644 --- a/src/app/remote_ui.rs +++ b/src/app/remote_ui.rs @@ -152,7 +152,7 @@ impl App { for b in &self.state.bots { (b.id, &b.name, &b.color, b.unread, b.messages.len()).hash(&mut h); if let Some(m) = b.messages.last() { - (m.text.len(), m.tool_calls.len(), m.error).hash(&mut h); + (m.text.len(), m.thinking.len(), m.tool_calls.len(), m.error).hash(&mut h); for c in &m.tool_calls { (c.status as u8).hash(&mut h); } diff --git a/src/remote.rs b/src/remote.rs index 676415d..b3fd06c 100644 --- a/src/remote.rs +++ b/src/remote.rs @@ -174,7 +174,7 @@ impl Hub { if let Some(r) = self.repaint.lock().unwrap().as_ref() { r(); } - rx.recv_timeout(Duration::from_secs(10)).map_err(|_| "大腦沒有及時回應".to_string()) + rx.recv_timeout(Duration::from_secs(10)).map_err(|_| "大腦太忙,沒有及時回應".to_string()) } pub fn version(&self) -> u64 { self.view.lock().unwrap().version @@ -646,17 +646,56 @@ fn agent(secs: u64) -> ureq::Agent { ureq::Agent::config_builder().timeout_global(Some(Duration::from_secs(secs))).http_status_as_error(false).build().into() } +/// Why a request never got an answer, in plain words (never an error code). +pub fn why_unreachable(e: &ureq::Error) -> String { + use std::io::ErrorKind as K; + use ureq::Error as E; + let text = e.to_string().to_lowercase(); + let w = match e { + E::HostNotFound => "找不到伺服器:這台電腦可能沒連上網路,或伺服器網址打錯了", + E::ConnectionFailed => "伺服器沒有回應:可能沒開機,或網路把連線擋住了", + E::Timeout(_) => "等太久沒有回應:網路很慢或斷斷續續", + E::Io(io) => match io.kind() { + K::ConnectionRefused => "伺服器沒有開(連線被拒絕)", + K::ConnectionReset | K::ConnectionAborted | K::BrokenPipe | K::UnexpectedEof => "連線到一半斷掉了", + K::TimedOut => "等太久沒有回應:網路很慢或斷斷續續", + K::NetworkUnreachable | K::HostUnreachable | K::NetworkDown | K::AddrNotAvailable => "這台電腦沒有連上網路", + _ => "網路連線出了問題", + }, + _ if text.contains("tls") || text.contains("certificate") => "和伺服器的安全連線失敗(伺服器的憑證有問題)", + _ if text.contains("dns") || text.contains("resolve") => "找不到伺服器:這台電腦可能沒連上網路,或伺服器網址打錯了", + _ => "網路連線出了問題", + }; + w.to_string() +} + +/// An answer that wasn't a yes, in plain words. `whom` names the other side (大腦, 那台電腦…). +pub fn why_refused(st: u16, v: &Value, whom: &str) -> String { + if let Some(e) = v["error"].as_str().filter(|e| !e.is_empty()) { + return e.replace("那台 AnyBot ", whom).replace("那台 AnyBot", whom); + } + match st { + 401 | 403 => "這台電腦的帳號登入失效了,請到「設定 › 手機與帳號」重新登入".into(), + 404 => format!("{whom}不在這個帳號裡了(可能被移除,或兩邊 AnyBot 版本不同)"), + 413 => "檔案太大,帳號伺服器不收".into(), + 502 | 503 => "帳號伺服器暫時無法使用(可能正在重新啟動),稍後會自動再試".into(), + 504 => format!("{whom}沒有及時回應(可能太忙或網路很慢)"), + 500..=599 => "帳號伺服器出了問題,稍後會自動再試".into(), + _ => format!("{whom}的回應看不懂,請把兩邊的 AnyBot 都更新到最新版"), + } +} + pub fn relay_post(url: &str, bearer: &str, body: &Value) -> Result { let mut r = agent(30).post(url).header("Content-Type", "application/json"); if !bearer.is_empty() { r = r.header("Authorization", &format!("Bearer {bearer}")); } - let mut resp = r.send(body.to_string().as_bytes()).map_err(|e| format!("連不到帳號伺服器:{e}"))?; + let mut resp = r.send(body.to_string().as_bytes()).map_err(|e| format!("連不到帳號伺服器:{}", why_unreachable(&e)))?; let st = resp.status().as_u16(); let text = resp.body_mut().read_to_string().unwrap_or_default(); let v: Value = serde_json::from_str(&text).unwrap_or(Value::Null); if !(200..300).contains(&st) { - return Err(v["error"].as_str().map(String::from).unwrap_or(format!("HTTP {st}"))); + return Err(why_refused(st, &v, "帳號伺服器")); } Ok(v) } @@ -725,7 +764,7 @@ fn relay_worker(url: &str, token: &str, gen: u64) { let mut resp = match r { Ok(r) => r, Err(e) => { - hub().set_relay_status(&format!("連不上帳號伺服器:{e}")); + hub().set_relay_status(&format!("連不上帳號伺服器:{}", why_unreachable(&e))); std::thread::sleep(Duration::from_secs(backoff)); backoff = (backoff * 2).min(30); continue; @@ -743,7 +782,9 @@ fn relay_worker(url: &str, token: &str, gen: u64) { } let text = resp.body_mut().read_to_string().unwrap_or_default(); let Ok(job) = serde_json::from_str::(&text) else { + hub().set_relay_status(&format!("連不上帳號伺服器:{}", why_refused(st, &Value::Null, "帳號伺服器"))); std::thread::sleep(Duration::from_secs(backoff)); + backoff = (backoff * 2).min(30); continue; }; hub().set_relay_status("已連線"); @@ -831,12 +872,12 @@ pub fn run_on_device(host_id: &str, tool: &str, args: &str, who: &str) -> Result .header("Authorization", &format!("Bearer {}", s.host_token)) .header("Content-Type", "application/json") .send(json!({"tool": tool, "args": args, "who": who}).to_string().as_bytes()) - .map_err(|e| format!("連不到那台電腦:{e}"))?; + .map_err(|e| format!("連不到帳號伺服器,所以也連不到那台電腦:{}", why_unreachable(&e)))?; let st = resp.status().as_u16(); let text = resp.body_mut().with_config().limit(40 * 1024 * 1024).read_to_string().unwrap_or_default(); let v: Value = serde_json::from_str(&text).unwrap_or(Value::Null); if !(200..300).contains(&st) { - return Err(v["error"].as_str().map(String::from).unwrap_or(format!("那台電腦沒有回應(HTTP {st})"))); + return Err(why_refused(st, &v, "那台電腦")); } Ok(v) } @@ -854,12 +895,15 @@ fn brain_url(s: &Settings, rest: &str) -> Result { } fn read_reply(r: Result, ureq::Error>) -> Result { - let mut resp = r.map_err(|e| format!("連不到帳號伺服器:{e}"))?; + let mut resp = r.map_err(|e| format!("連不到帳號伺服器:{}", why_unreachable(&e)))?; let st = resp.status().as_u16(); - let text = resp.body_mut().with_config().limit(200 * 1024 * 1024).read_to_string().unwrap_or_default(); + let text = resp.body_mut().with_config().limit(200 * 1024 * 1024).read_to_string().map_err(|_| "和大腦的連線到一半斷掉了".to_string())?; let v: Value = serde_json::from_str(&text).unwrap_or(Value::Null); if !(200..300).contains(&st) { - return Err(v["error"].as_str().map(String::from).unwrap_or(format!("大腦沒有回應(HTTP {st})"))); + return Err(why_refused(st, &v, "大腦")); + } + if v.is_null() { + return Err("大腦的回應看不懂,請把兩邊的 AnyBot 都更新到最新版".into()); } Ok(v) } -- 2.40.1 From b153051e58956683220fbc80fb0e6b818c27025e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E6=80=A7=E9=A9=8A?= Date: Wed, 4 Nov 2026 19:30:32 +0800 Subject: [PATCH 3/9] Lasting connection to the account server, per-item sync, files and models via the brain - Each AnyBot keeps one WebSocket to the account server (pinged, reopened within a second when cut). Requests come down it at once; calls to other AnyBots of the account (brain sync, hands) go up it. Frames carry raw bytes, compressed when that saves space. HTTP long-poll stays as fallback. - The account server knows at once when an AnyBot goes away. - Edits to lists and maps go item by item, so two computers editing one list keep both changes. - Screens fetch chat files from the brain, get model lists through the brain's keys, and show the brain's computer as the brain's. - Moving this computer's bots skips starter bots the brain has and bots already moved. - The phone goes to the brain instead of a computer that shows it. - deploy/linux/hands.sh: a Linux computer (with or without a screen) as hands, kept running by systemd. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Uirha3oEtWeHh58zA7ErHa --- Cargo.lock | 3 + Cargo.toml | 4 + deploy/linux/hands.sh | 103 +++++++++ deploy/server/docker-compose.yml | 6 + mobile/index.html | 15 +- relay/Caddyfile | 5 +- relay/Cargo.lock | 161 +++++++++++++- relay/Cargo.toml | 1 + relay/src/main.rs | 252 ++++++++++++++++++--- src/app.rs | 22 +- src/app/attach_ui.rs | 2 +- src/app/computers_ui.rs | 11 +- src/app/mirror_ui.rs | 233 +++++++++++++++++++- src/httpd.rs | 13 ++ src/main.rs | 2 + src/remote.rs | 117 ++++++++-- src/tunnel.rs | 365 +++++++++++++++++++++++++++++++ src/wire.rs | 42 ++++ 18 files changed, 1291 insertions(+), 66 deletions(-) create mode 100755 deploy/linux/hands.sh create mode 100644 src/tunnel.rs create mode 100644 src/wire.rs diff --git a/Cargo.lock b/Cargo.lock index 3b8845a..aa4bb9b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -345,6 +345,7 @@ dependencies = [ "cpal", "dirs", "eframe", + "flate2", "futures-lite", "hayro", "iana-time-zone", @@ -356,6 +357,7 @@ dependencies = [ "resvg", "ring", "rodio", + "rustls", "serde", "serde_json", "tungstenite", @@ -365,6 +367,7 @@ dependencies = [ "wayland-protocols", "wayland-protocols-misc", "wayland-protocols-wlr", + "webpki-roots", "x11rb 0.13.2", "zbus", "zhconv", diff --git a/Cargo.toml b/Cargo.toml index 4f94769..68baaf6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,6 +10,10 @@ path = "src/main.rs" [dependencies] eframe = "0.37" ureq = { version = "3", features = ["json"] } +# 長連線 to the account server (WebSocket over the same TLS ureq uses), and compressing what goes over it. +rustls = { version = "0.23", default-features = false, features = ["ring", "std", "tls12", "logging"] } +webpki-roots = "1" +flate2 = "1" serde = { version = "1", features = ["derive"] } serde_json = "1" dirs = "7" diff --git a/deploy/linux/hands.sh b/deploy/linux/hands.sh new file mode 100755 index 0000000..bb77f70 --- /dev/null +++ b/deploy/linux/hands.sh @@ -0,0 +1,103 @@ +#!/bin/sh +# Make this Linux computer a pair of hands for your AnyBot brain: the brain's bots can see and use +# its screen, files and browser (asking you first, as usual). Works on a desktop and on a computer +# without a screen (a virtual screen is made for it). Run from the app/ folder: +# +# ./deploy/linux/hands.sh --deps also install what a computer without a screen needs (sudo) +# ./deploy/linux/hands.sh set up and start it (asks for your account the first time) +# ./deploy/linux/hands.sh --stop stop it and don't start it with the computer any more +set -e +cd "$(dirname "$0")/../.." +UNIT="$HOME/.config/systemd/user/anybot-hands.service" +ENVF="$HOME/.config/anybot/hands.env" +RUN="$HOME/.local/bin/anybot-hands" + +if [ "$1" = "--stop" ]; then + systemctl --user disable --now anybot-hands.service 2>/dev/null || true + rm -f "$UNIT" + systemctl --user daemon-reload + echo "Stopped. (Your account details stay in $ENVF; delete it if you like.)" + exit 0 +fi + +if [ "$1" = "--deps" ]; then + # A virtual screen, a window manager, the accessibility bus (for reading apps' buttons), a session bus. + if command -v apt-get >/dev/null; then + sudo apt-get update && sudo apt-get install -y xvfb openbox at-spi2-core libatk-adaptor dbus-x11 xauth fonts-noto-cjk + elif command -v dnf >/dev/null; then + sudo dnf install -y xorg-x11-server-Xvfb openbox at-spi2-core at-spi2-atk dbus-x11 xorg-x11-xauth google-noto-sans-cjk-fonts + elif command -v pacman >/dev/null; then + sudo pacman -S --needed xorg-server-xvfb openbox at-spi2-core dbus xorg-xauth noto-fonts-cjk + else + echo "Install with your package manager: Xvfb, openbox, at-spi2-core, dbus, xauth" + fi +fi + +command -v systemctl >/dev/null || { echo "This needs systemd (to keep AnyBot running)."; exit 1; } +[ -x "$HOME/.local/bin/anybot" ] || sh deploy/linux/install.sh + +if [ ! -f "$ENVF" ]; then + echo "Your AnyBot account (the same one your brain uses):" + printf " Account server address (e.g. https://anybot.example.com): "; read -r URL + printf " Account name: "; read -r USERNAME + printf " Password: "; stty -echo 2>/dev/null; read -r PASSWORD; stty echo 2>/dev/null; echo + printf " Name for this computer [%s]: " "$(hostname)"; read -r NAME + printf " Brain's name (empty = only lend hands, show no bots here): "; read -r BRAIN + mkdir -p "$(dirname "$ENVF")" + umask 077 + # Each value in single quotes, so a password with $ or spaces stays as typed. + q() { printf "%s='%s'\n" "$1" "$(printf %s "$2" | sed "s/'/'\\\\''/g")"; } + { + q ANYBOT_ACCOUNT "${URL%/}|$USERNAME" + q ANYBOT_ACCOUNT_PASSWORD "$PASSWORD" + q ANYBOT_HOST_NAME "${NAME:-$(hostname)}" + if [ -n "$BRAIN" ]; then q ANYBOT_BRAIN "$BRAIN"; fi + } > "$ENVF" + chmod 600 "$ENVF" +fi + +mkdir -p "$(dirname "$RUN")" "$(dirname "$UNIT")" +cat > "$RUN" <<'SH' +#!/bin/sh +# Started by systemd (anybot-hands.service). +set -a; . "$HOME/.config/anybot/hands.env"; set +a +export ANYBOT_DEVICE_LINK=1 +export LANG="${LANG:-C.UTF-8}" +# The accessibility bus, so bots can read apps' buttons instead of only looking at pixels. +export NO_AT_BRIDGE=0 GTK_A11Y=atspi QT_LINUX_ACCESSIBILITY_ALWAYS_ON=1 +if [ -z "$DISPLAY" ] && [ -z "$WAYLAND_DISPLAY" ]; then + # No screen: a virtual one with a window manager, and a session bus for the accessibility bus. + exec xvfb-run -a -s "-screen 0 1440x900x24" dbus-run-session -- sh -c 'openbox >/dev/null 2>&1 & exec "$HOME/.local/bin/anybot" --serve' +fi +exec "$HOME/.local/bin/anybot" --serve +SH +chmod 755 "$RUN" + +cat > "$UNIT" </dev/null | grep -q "Linger=yes"; then + echo "To keep it running after you log out and after a restart, run once:" + echo " sudo loginctl enable-linger $USER" +fi diff --git a/deploy/server/docker-compose.yml b/deploy/server/docker-compose.yml index a09616e..70ad677 100644 --- a/deploy/server/docker-compose.yml +++ b/deploy/server/docker-compose.yml @@ -5,6 +5,12 @@ # 4. On the phone open https://your-domain, create the account (first time), and use it. # AI keys: set them in .env, or set ANYBOT_RESTORE_FROM_ACCOUNT=1 to start from the newest backup # your Mac made (bots, chats, routines and the keys you set there). +# +# 大腦: this AnyBot is the brain of the account. It keeps the bots, chats, memory and routines, +# and works even while your computers sleep. On each of your computers: 設定 › 手機與帳號 › +# 大腦 › 「改用它當大腦」. Those computers then show these bots, and the bots can use them as hands +# (screen, files, browser), asking you first as usual. A Linux computer without a screen can be +# hands too: deploy/linux/hands.sh. services: relay: build: diff --git a/mobile/index.html b/mobile/index.html index 464e3f1..00f5580 100644 --- a/mobile/index.html +++ b/mobile/index.html @@ -298,11 +298,20 @@ $("b-out1").onclick = () => signOut(); async function pickHost() { const me = await api("relay/me"); - const hosts = me.hosts || []; + const all = me.hosts || []; + // A computer that shows another one's bots (大腦) leads to that brain. + const byId = Object.fromEntries(all.map((h) => [h.id, h])); + const target = (id) => (byId[id] && byId[id].uses && byId[byId[id].uses]) ? byId[id].uses : id; + const hosts = all.filter((h) => !(h.uses && byId[h.uses])); + const sharers = (id) => all.filter((h) => h.uses === id).map((h) => h.name); const live = hosts.filter((h) => h.online); - if (S.host && hosts.some((h) => h.id === S.host && h.online)) return start(); + if (S.host) { + const t = target(S.host); + if (byId[t] && byId[t].online) { S.host = t; localStorage.setItem("anybot.host", t); return start(); } + } if (live.length === 1) { S.host = live[0].id; localStorage.setItem("anybot.host", S.host); return start(); } - $("hosts").innerHTML = hosts.length ? hosts.map((h) => `
${esc((h.name || "?")[0])}
${esc(h.name)}
${h.online ? "在線上" : "離線(" + esc(h.last_seen_text || "很久沒連線") + ")"}
`).join("") + const note = (h) => { const n = sharers(h.id); return n.length ? " · " + esc(n.join("、")) + " 也用這個大腦" : ""; }; + $("hosts").innerHTML = hosts.length ? hosts.map((h) => `
${esc((h.name || "?")[0])}
${esc(h.name)}
${h.online ? "在線上" : "離線(" + esc(h.last_seen_text || "很久沒連線") + ")"}${note(h)}
`).join("") : `
這個帳號還沒有連上任何 AnyBot。
在電腦上打開 AnyBot › 設定 › 手機與帳號,登入同一個帳號。
`; document.querySelectorAll("#hosts .row").forEach((r) => r.onclick = () => { S.host = r.dataset.h; localStorage.setItem("anybot.host", S.host); start(); }); show("hosts"); diff --git a/relay/Caddyfile b/relay/Caddyfile index 0487d13..0be4b12 100644 --- a/relay/Caddyfile +++ b/relay/Caddyfile @@ -3,9 +3,12 @@ anybot.example.com { encode gzip reverse_proxy relay:8080 { - # The phone's "anything new?" request waits up to 35 s. + # The phone's "anything new?" request waits up to 35 s. Each AnyBot also keeps one + # WebSocket open here (pinged every 15–20 s, so it never sits idle for 60 s). transport http { read_timeout 60s } + # Reloading this file doesn't cut those connections at once. + stream_close_delay 5m } } diff --git a/relay/Cargo.lock b/relay/Cargo.lock index 16a817c..056ab2d 100644 --- a/relay/Cargo.lock +++ b/relay/Cargo.lock @@ -26,6 +26,7 @@ dependencies = [ "ring", "serde", "serde_json", + "tungstenite", "ureq", ] @@ -47,6 +48,15 @@ version = "0.23.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" +[[package]] +name = "block-buffer" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +dependencies = [ + "hybrid-array", +] + [[package]] name = "bumpalo" version = "3.20.3" @@ -75,6 +85,17 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" +[[package]] +name = "chacha20" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" +dependencies = [ + "cfg-if", + "cpufeatures", + "rand_core", +] + [[package]] name = "chrono" version = "0.4.45" @@ -88,12 +109,27 @@ dependencies = [ "windows-link", ] +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + [[package]] name = "core-foundation-sys" version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "cpufeatures" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" +dependencies = [ + "libc", +] + [[package]] name = "crc32fast" version = "1.5.4" @@ -103,6 +139,32 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "data-encoding" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" + +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer", + "const-oid", + "crypto-common", +] + [[package]] name = "find-msvc-tools" version = "0.1.15" @@ -155,6 +217,18 @@ dependencies = [ "wasi", ] +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "rand_core", +] + [[package]] name = "http" version = "1.5.0" @@ -171,6 +245,15 @@ version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" +[[package]] +name = "hybrid-array" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27f864f10dfb56725ce5ce5472bc52252c8f93a4ab86327122cebf62c5f59a17" +dependencies = [ + "typenum", +] + [[package]] name = "iana-time-zone" version = "0.1.65" @@ -285,6 +368,29 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + [[package]] name = "ring" version = "0.17.14" @@ -293,7 +399,7 @@ checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" dependencies = [ "cc", "cfg-if", - "getrandom", + "getrandom 0.2.17", "libc", "untrusted", "windows-sys", @@ -383,6 +489,17 @@ dependencies = [ "zmij", ] +[[package]] +name = "sha1" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + [[package]] name = "shlex" version = "2.0.1" @@ -429,6 +546,48 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "thiserror" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "tungstenite" +version = "0.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e48ac77174b19c110a50ab2128b24215ac9cb40e0e12e093fb602d175c569d22" +dependencies = [ + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "rand", + "sha1", + "thiserror", +] + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + [[package]] name = "unicode-ident" version = "1.0.27" diff --git a/relay/Cargo.toml b/relay/Cargo.toml index b969f28..28f04dc 100644 --- a/relay/Cargo.toml +++ b/relay/Cargo.toml @@ -12,6 +12,7 @@ ring = "0.17" base64 = "0.22" chrono = "0.4" ureq = "3" +tungstenite = { version = "0.30", default-features = false, features = ["handshake"] } [profile.release] diff --git a/relay/src/main.rs b/relay/src/main.rs index 8d2e88f..8c95480 100644 --- a/relay/src/main.rs +++ b/relay/src/main.rs @@ -14,6 +14,8 @@ #[path = "../../src/httpd.rs"] mod httpd; +#[path = "../../src/wire.rs"] +mod wire; mod push; use httpd::{Req, Resp}; @@ -22,6 +24,7 @@ use serde_json::{json, Value}; use std::collections::{HashMap, VecDeque}; use std::path::PathBuf; use std::sync::mpsc::{channel, Sender}; +use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::{Arc, Condvar, Mutex}; use std::time::{Duration, Instant}; @@ -35,6 +38,11 @@ const HOST_POLL_SECS: u64 = 25; const FORWARD_SECS: u64 = 35; /// How long a request may wait to be picked up before the host counts as gone. const PICKUP_SECS: u64 = 8; +/// The lasting connection: a ping this often, and given up after this long without a word. +const WS_PING_SECS: u64 = 20; +const WS_DEAD_SECS: u64 = 60; +const GONE: &str = "那台 AnyBot 斷線了(網路中斷,或 AnyBot 關掉了)"; +const OFFLINE: &str = "那台 AnyBot 目前離線(電腦可能關機、睡眠、斷網,或 AnyBot 沒開)"; /// A host counts as online when it asked within this many seconds. const ONLINE_SECS: u64 = 60; /// Sessions last this long without use. @@ -124,6 +132,51 @@ self.addEventListener("notificationclick", (e) => { }); "#; +/// A forwarded request's answer. +struct Answer { + status: u16, + ctype: String, + headers: Vec<(String, String)>, + body: Vec, + /// The body is deflate-compressed (only when the asker said it can read that). + z: bool, +} + +impl Answer { + fn error(status: u16, msg: &str) -> Answer { + Answer { status, ctype: "application/json; charset=utf-8".into(), headers: vec![], body: json!({"error": msg}).to_string().into_bytes(), z: false } + } + fn from_json(v: &Value) -> Answer { + use base64::Engine; + Answer { + status: v["status"].as_u64().unwrap_or(502) as u16, + ctype: v["ctype"].as_str().unwrap_or("application/octet-stream").to_string(), + headers: v["headers"].as_array().into_iter().flatten().filter_map(|h| Some((h[0].as_str()?.to_string(), h[1].as_str()?.to_string()))).collect(), + body: base64::engine::general_purpose::STANDARD.decode(v["body"].as_str().unwrap_or("")).unwrap_or_default(), + z: v["z"].as_bool().unwrap_or(false), + } + } + fn into_resp(self) -> Resp { + let mut resp = Resp::bytes(&self.ctype, self.body); + resp.status = self.status; + for (k, v) in self.headers { + // Only harmless headers pass through. + if k.eq_ignore_ascii_case("x-size") { + resp.headers.push((k, v)); + } + } + resp + } +} + +/// An AnyBot's lasting connection: frames to write to it. +struct Sock { + tx: Sender>, + serial: u64, +} + +static SERIAL: AtomicU64 = AtomicU64::new(1); + struct Relay { dir: PathBuf, data: Mutex, @@ -131,7 +184,12 @@ struct Relay { queues: Mutex>>, arrived: Condvar, /// Where each forwarded request's answer goes. - waiting: Mutex>>, + waiting: Mutex>>, + /// AnyBots connected the lasting way (WebSocket), and which requests went over which. + sockets: Mutex>, + flights: Mutex>, + /// The brain each AnyBot shows (when it is a screen of another). + uses: Mutex>, /// When each host last asked (in memory; saved now and then). seen: Mutex>, fails: Mutex>, @@ -155,6 +213,9 @@ impl Relay { queues: Mutex::new(HashMap::new()), arrived: Condvar::new(), waiting: Mutex::new(HashMap::new()), + sockets: Mutex::new(HashMap::new()), + flights: Mutex::new(HashMap::new()), + uses: Mutex::new(HashMap::new()), seen: Mutex::new(HashMap::new()), fails: Mutex::new(HashMap::new()), invite: std::env::var("ANYBOT_RELAY_INVITE").unwrap_or_default(), @@ -201,7 +262,7 @@ impl Relay { } fn online(&self, id: &str) -> bool { - self.seen.lock().unwrap().get(id).map(|t| t.elapsed().as_secs() < ONLINE_SECS).unwrap_or(false) + self.sockets.lock().unwrap().contains_key(id) || self.seen.lock().unwrap().get(id).map(|t| t.elapsed().as_secs() < ONLINE_SECS).unwrap_or(false) } fn too_many_fails(&self, key: &str) -> bool { @@ -221,14 +282,36 @@ impl Relay { /// Hand a request to a host and wait for its answer. fn forward(&self, host_id: &str, method: &str, path: &str, query: &HashMap, body: &[u8], who: (&str, &str)) -> Resp { + self.forward_raw(host_id, method, path, query, body, false, who, false).into_resp() + } + + /// `z`: the body is compressed; `z_ok`: the asker reads a compressed answer. + #[allow(clippy::too_many_arguments)] + fn forward_raw(&self, host_id: &str, method: &str, path: &str, query: &HashMap, body: &[u8], z: bool, who: (&str, &str), z_ok: bool) -> Answer { use base64::Engine; if !self.online(host_id) { - return Resp::error(503, "那台 AnyBot 目前離線(電腦關機、睡眠,或 AnyBot 沒開)"); + return Answer::error(503, OFFLINE); } let id = httpd::random_hex(12); let (tx, rx) = channel(); self.waiting.lock().unwrap().insert(id.clone(), tx); - let job = json!({"id": id, "method": method, "path": path, "query": query, "body": base64::engine::general_purpose::STANDARD.encode(body), "user": who.0, "role": who.1}); + let head = json!({"t": "job", "id": id, "method": method, "path": path, "query": query, "user": who.0, "role": who.1, "z": z, "z_ok": z_ok}); + // Connected the lasting way: straight down that connection. + let sock = self.sockets.lock().unwrap().get(host_id).map(|s| (s.tx.clone(), s.serial)); + if let Some((stx, serial)) = sock { + self.flights.lock().unwrap().insert(id.clone(), (host_id.to_string(), serial)); + let sent = stx.send(wire::pack(&head, body)).is_ok(); + let r = if sent { rx.recv_timeout(Duration::from_secs(FORWARD_SECS)).ok() } else { None }; + self.waiting.lock().unwrap().remove(&id); + self.flights.lock().unwrap().remove(&id); + return match r { + Some(a) => a, + None if !sent => Answer::error(503, GONE), + None => Answer::error(504, "那台 AnyBot 收到了,但太久沒有回應(可能太忙,或網路很慢)"), + }; + } + let mut job = head; + job["body"] = json!(base64::engine::general_purpose::STANDARD.encode(body)); self.queues.lock().unwrap().entry(host_id.to_string()).or_default().push_back(job); self.arrived.notify_all(); // A running AnyBot always has a worker waiting, so it picks a request up within a moment. @@ -236,35 +319,146 @@ impl Relay { let mut r = rx.recv_timeout(Duration::from_secs(PICKUP_SECS)); if r.is_err() { let queued = self.queues.lock().unwrap().get(host_id).map(|q| q.iter().any(|j| j["id"].as_str() == Some(id.as_str()))).unwrap_or(false); - if queued { - self.waiting.lock().unwrap().remove(&id); - if let Some(q) = self.queues.lock().unwrap().get_mut(host_id) { - q.retain(|j| j["id"].as_str() != Some(id.as_str())); - } - return Resp::error(503, "那台 AnyBot 目前離線(電腦可能關機、睡眠、斷網,或 AnyBot 沒開)"); + if !queued { + r = rx.recv_timeout(Duration::from_secs(FORWARD_SECS - PICKUP_SECS)); } - r = rx.recv_timeout(Duration::from_secs(FORWARD_SECS - PICKUP_SECS)); } self.waiting.lock().unwrap().remove(&id); // Not picked up in time: drop it so it doesn't run late. - if let Some(q) = self.queues.lock().unwrap().get_mut(host_id) { + let was_queued = self.queues.lock().unwrap().get_mut(host_id).map(|q| { + let n = q.len(); q.retain(|j| j["id"].as_str() != Some(id.as_str())); - } + n != q.len() + }); match r { - Ok(v) => { - let mut resp = Resp::bytes(v["ctype"].as_str().unwrap_or("application/octet-stream"), base64::engine::general_purpose::STANDARD.decode(v["body"].as_str().unwrap_or("")).unwrap_or_default()); - resp.status = v["status"].as_u64().unwrap_or(502) as u16; - for h in v["headers"].as_array().into_iter().flatten() { - if let (Some(k), Some(val)) = (h[0].as_str(), h[1].as_str()) { - // Only harmless headers pass through. - if k.eq_ignore_ascii_case("x-size") { - resp.headers.push((k.into(), val.into())); - } - } + Ok(a) => a, + Err(_) if was_queued == Some(true) => Answer::error(503, OFFLINE), + Err(_) => Answer::error(504, "那台 AnyBot 收到了,但太久沒有回應(可能太忙,或網路很慢)"), + } + } + + /// 長連線: an AnyBot keeps one WebSocket open; requests for it come down it at once, and its + /// own requests to the account's other AnyBots go up it. + fn ws(self: &Arc, req: Req, mut c: std::net::TcpStream) { + use std::io::Write; + use tungstenite::{protocol::Role, Message, WebSocket}; + if req.path != "/relay/host/ws" { + return httpd::write_response(&mut c, &Resp::error(404, "沒有這個網址")); + } + let Some(h) = self.host(&req) else { return httpd::write_response(&mut c, &Resp::error(401, "這台電腦的金鑰無效")) }; + let key = req.header("sec-websocket-key").to_string(); + if key.is_empty() { + return httpd::write_response(&mut c, &Resp::error(400, "bad request")); + } + let accept = tungstenite::handshake::derive_accept_key(key.as_bytes()); + if c.write_all(format!("HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: {accept}\r\n\r\n").as_bytes()).is_err() { + return; + } + let _ = c.set_read_timeout(Some(Duration::from_millis(40))); + let _ = c.set_nodelay(true); + let cfg = tungstenite::protocol::WebSocketConfig::default().max_message_size(Some(httpd::MAX_BODY * 2)).max_frame_size(Some(httpd::MAX_BODY * 2)); + let mut ws = WebSocket::from_raw_socket(c, Role::Server, Some(cfg)); + let serial = SERIAL.fetch_add(1, Ordering::SeqCst); + let (tx, rx) = channel::>(); + self.sockets.lock().unwrap().insert(h.id.clone(), Sock { tx: tx.clone(), serial }); + self.seen.lock().unwrap().insert(h.id.clone(), Instant::now()); + let (mut heard, mut pinged, mut marked) = (Instant::now(), Instant::now(), Instant::now()); + 'live: loop { + match ws.read() { + Ok(Message::Binary(b)) => { + heard = Instant::now(); + self.on_frame(&h, &b, &tx); } - resp + Ok(Message::Close(_)) => break, + Ok(_) => heard = Instant::now(), + Err(tungstenite::Error::Io(e)) if matches!(e.kind(), std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut) => {} + Err(_) => break, } - Err(_) => Resp::error(504, "那台 AnyBot 收到了,但太久沒有回應(可能太忙,或網路很慢)"), + while let Ok(f) = rx.try_recv() { + if ws.write(Message::Binary(f.into())).is_err() { + break 'live; + } + } + if pinged.elapsed().as_secs() >= WS_PING_SECS { + pinged = Instant::now(); + if ws.write(Message::Ping(Vec::new().into())).is_err() { + break; + } + } + match ws.flush() { + Err(tungstenite::Error::Io(e)) if matches!(e.kind(), std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut) => {} + Err(_) => break, + Ok(()) => {} + } + if heard.elapsed().as_secs() >= WS_DEAD_SECS { + break; + } + if marked.elapsed().as_secs() >= 10 { + marked = Instant::now(); + self.seen.lock().unwrap().insert(h.id.clone(), Instant::now()); + } + } + // Gone: whoever waits on it hears so now, not after a timeout. + { + let mut socks = self.sockets.lock().unwrap(); + if socks.get(&h.id).map(|s| s.serial) == Some(serial) { + socks.remove(&h.id); + self.seen.lock().unwrap().remove(&h.id); + } + } + let lost: Vec = self.flights.lock().unwrap().iter().filter(|(_, (host, s))| *host == h.id && *s == serial).map(|(id, _)| id.clone()).collect(); + for id in lost { + if let Some(w) = self.waiting.lock().unwrap().remove(&id) { + let _ = w.send(Answer::error(503, GONE)); + } + } + } + + fn on_frame(self: &Arc, h: &Host, b: &[u8], tx: &Sender>) { + let Some((head, body)) = wire::unpack(b) else { return }; + let s = |k: &str| head[k].as_str().unwrap_or("").to_string(); + match head["t"].as_str().unwrap_or("") { + "reply" => { + if let Some(w) = self.waiting.lock().unwrap().remove(&s("id")) { + let headers = head["headers"].as_array().into_iter().flatten().filter_map(|x| Some((x[0].as_str()?.to_string(), x[1].as_str()?.to_string()))).collect(); + let _ = w.send(Answer { status: head["status"].as_u64().unwrap_or(502) as u16, ctype: s("ctype"), headers, body, z: head["z"].as_bool().unwrap_or(false) }); + } + } + // One AnyBot asks another of the same account (大腦 sync, 裝置連結). + "call" => { + let (id, to, path) = (s("id"), s("to"), s("path")); + let role = if path == "/api/device/run" { + "device" + } else if path == "/api/sync" || path.starts_with("/api/sync/") { + "mirror" + } else { + let a = Answer::error(404, "沒有這個網址"); + let _ = tx.send(wire::pack(&json!({"t": "ret", "id": id, "status": a.status, "ctype": a.ctype}), &a.body)); + return; + }; + let same = self.data.lock().unwrap().hosts.iter().any(|x| x.id == to && x.user == h.user); + if !same || to == h.id { + let a = Answer::error(403, "不是同一個帳號的電腦"); + let _ = tx.send(wire::pack(&json!({"t": "ret", "id": id, "status": a.status, "ctype": a.ctype}), &a.body)); + return; + } + let query: HashMap = head["query"].as_object().map(|o| o.iter().map(|(k, v)| (k.clone(), v.as_str().unwrap_or("").to_string())).collect()).unwrap_or_default(); + let (me, tx, user, method, z) = (self.clone(), tx.clone(), h.user.clone(), s("method"), head["z"].as_bool().unwrap_or(false)); + std::thread::spawn(move || { + let a = me.forward_raw(&to, &method, &path, &query, &body, z, (&user, role), true); + let _ = tx.send(wire::pack(&json!({"t": "ret", "id": id, "status": a.status, "ctype": a.ctype, "z": a.z}), &a.body)); + }); + } + "hello" => { + let brain = s("brain"); + let mut u = self.uses.lock().unwrap(); + if brain.is_empty() { + u.remove(&h.id); + } else { + u.insert(h.id.clone(), brain); + } + } + _ => {} } } @@ -389,7 +583,7 @@ impl Relay { return Resp::error(401, "這台電腦的金鑰無效"); } if let Some(tx) = self.waiting.lock().unwrap().remove(*id) { - let _ = tx.send(req.json()); + let _ = tx.send(Answer::from_json(&req.json())); } Resp::json(&json!({"ok": true})) } @@ -542,7 +736,7 @@ impl Relay { fn host_json(&self, h: &Host) -> Value { let online = self.online(&h.id); - json!({"id": h.id, "name": h.name, "online": online, "last_seen_text": if online { "在線上".to_string() } else if h.last_seen > 0 { chrono::DateTime::from_timestamp(h.last_seen, 0).map(|t| t.format("%m/%d %H:%M UTC").to_string()).unwrap_or_default() } else { "從未連線".into() }}) + json!({"id": h.id, "name": h.name, "online": online, "uses": self.uses.lock().unwrap().get(&h.id).cloned().unwrap_or_default(), "last_seen_text": if online { "在線上".to_string() } else if h.last_seen > 0 { chrono::DateTime::from_timestamp(h.last_seen, 0).map(|t| t.format("%m/%d %H:%M UTC").to_string()).unwrap_or_default() } else { "從未連線".into() }}) } fn signup(&self, req: &Req) -> Resp { @@ -630,8 +824,8 @@ fn main() { std::thread::sleep(Duration::from_secs(60)); r2.save_seen(); }); - let r = relay.clone(); - httpd::serve(listener, Arc::new(move |req| r.handle(req)), Arc::new(|| true)); + let (r, w) = (relay.clone(), relay.clone()); + httpd::serve_upgrade(listener, Arc::new(move |req| r.handle(req)), Some(Arc::new(move |req, c| w.ws(req, c))), Arc::new(|| true)); } #[cfg(test)] diff --git a/src/app.rs b/src/app.rs index f46266d..a01eeab 100644 --- a/src/app.rs +++ b/src/app.rs @@ -518,6 +518,11 @@ impl App { } } app.apply_theme(ctx); + // Real-data screenshots can open 設定 › 手機與帳號 (to press its buttons with ANYBOT_SHOT_CLICK). + if app.screenshot.is_some() && !app.demo && std::env::var("ANYBOT_SHOT_VIEW").as_deref() == Ok("remote") { + app.show_settings = true; + app.settings_tab = SettingsTab::Remote; + } app } @@ -1245,6 +1250,20 @@ impl App { } fn fetch_models(&mut self, kind: ProviderKind) { + // A screen of a brain: the brain has the key, so it asks. + if self.mir.on { + let (tx, rx) = channel(); + let s = self.state.remote.clone(); + std::thread::spawn(move || { + let name = serde_json::to_value(kind).unwrap_or_default(); + let r = crate::remote::sync_post(&s, "models", &serde_json::json!({"provider": name})) + .and_then(|v| v["error"].as_str().map(|e| Err(e.to_string())).unwrap_or_else(|| Ok(v["models"].as_array().into_iter().flatten().filter_map(|m| m.as_str().map(String::from)).collect()))); + let _ = tx.send(r); + }); + self.model_fetch.insert(kind, rx); + self.fetch_status.insert(kind, "正在請大腦取得模型清單…".into()); + return; + } let ps = self.state.provider(kind); let base = providers::resolve_base(kind, &ps.base_url); let key = providers::resolve_key(kind, &ps.api_key); @@ -3008,6 +3027,7 @@ impl App { // A screen of another AnyBot's brain: its bots work there, not here. if self.mir.on { self.remote_tick(ctx); + self.poll_fetches(); return; } self.drain_events(ctx); @@ -3187,7 +3207,7 @@ impl eframe::App for App { /// Screenshot mode only: ANYBOT_SHOT_CLICK="x,y@frame[;x,y@frame…][:right]" clicks there at that /// frame (to check buttons really respond). fn raw_input_hook(&mut self, _ctx: &egui::Context, raw: &mut egui::RawInput) { - if !self.demo { + if self.screenshot.is_none() { return; } // ANYBOT_SHOT_DRAG="x1,y1>x2,y2@frame": press, move over 12 frames, release. diff --git a/src/app/attach_ui.rs b/src/app/attach_ui.rs index 032bb61..97b72c6 100644 --- a/src/app/attach_ui.rs +++ b/src/app/attach_ui.rs @@ -8,7 +8,7 @@ use crate::attach::{self, Attachment}; pub struct AttachUi { pub pending: HashMap>, picked: Option)>>, - thumbs: HashMap>, + pub(super) thumbs: HashMap>, pub error: Option, voice: Option<(u64, crate::voice::Recording)>, voice_rx: Option)>>, diff --git a/src/app/computers_ui.rs b/src/app/computers_ui.rs index 1820a05..6a5be14 100644 --- a/src/app/computers_ui.rs +++ b/src/app/computers_ui.rs @@ -351,7 +351,14 @@ impl App { // ── AnyBot Computer Use: seeing and using this computer's screen ── self.mac_permissions_card(ui); - // ── Bot 的電腦 ── + // ── Bot 的電腦 ── (on a screen of a brain, it is the brain's) + if self.mir.on { + sec_title(ui, "Bot 的電腦", p, |_| {}); + sec_card(ui, p, |ui| { + let name = self.state.remote.brain_name.clone(); + sec_row(ui, p, "在大腦上", &format!("Bot 用的電腦在大腦「{name}」上,要在那台管理。這台電腦是大腦的手腳:上面的權限決定 Bot 能不能看這台的畫面、操作這台。"), |_| {}); + }); + } else { sec_title(ui, "Bot 的電腦", p, |ui| status_pill(ui, &run, p)); #[derive(PartialEq)] enum Act { @@ -656,6 +663,8 @@ impl App { } } + } + // ── 示範教學、瀏覽器 ── ui.add_space(SEC_GAP * 0.5); ui.spacing_mut().item_spacing.y = 6.0; diff --git a/src/app/mirror_ui.rs b/src/app/mirror_ui.rs index 19099ab..685a15d 100644 --- a/src/app/mirror_ui.rs +++ b/src/app/mirror_ui.rs @@ -115,6 +115,81 @@ fn hash_value(v: &Value) -> u64 { h.finish() } +/// What names an item in a list: its id, else its name, else (plain values) the value itself. +fn item_key(x: &Value) -> Option { + match x { + Value::Object(o) => o.get("id").filter(|v| !v.is_null()).or_else(|| o.get("name").filter(|v| v.is_string())).cloned(), + Value::Array(_) | Value::Null => None, + v => Some(v.clone()), + } +} + +/// Every item has its own key, and no two share one. +fn keyed(a: &[Value]) -> bool { + let mut seen = std::collections::HashSet::new(); + a.iter().all(|x| item_key(x).map(|k| seen.insert(k.to_string())).unwrap_or(false)) +} + +/// The smallest change from `old` to `new`: lists of named things and maps change item by item, +/// so two computers editing different items of one list don't undo each other. +pub(super) fn patch_of(old: &Value, new: &Value) -> Value { + match (old, new) { + (Value::Object(o), Value::Object(n)) => { + let put: serde_json::Map = n.iter().filter(|(k, v)| o.get(*k) != Some(*v)).map(|(k, v)| (k.clone(), v.clone())).collect(); + let del: Vec<&String> = o.keys().filter(|k| !n.contains_key(*k)).collect(); + json!({"map": {"put": put, "del": del}}) + } + (Value::Array(o), Value::Array(n)) if keyed(o) && keyed(n) => { + let key = |x: &Value| item_key(x).unwrap_or(Value::Null); + let put: Vec<&Value> = n.iter().filter(|x| !o.iter().any(|y| key(y) == key(x) && y == *x)).collect(); + let del: Vec = o.iter().map(key).filter(|k| !n.iter().any(|x| key(x) == *k)).collect(); + let common_old: Vec = o.iter().map(key).filter(|k| n.iter().any(|x| key(x) == *k)).collect(); + let common_new: Vec = n.iter().map(key).filter(|k| o.iter().any(|x| key(x) == *k)).collect(); + let mut p = json!({"list": {"put": put, "del": del}}); + if common_old != common_new { + p["list"]["order"] = Value::Array(n.iter().map(key).collect()); + } + p + } + _ => json!({"value": new}), + } +} + +/// Apply a change made by `patch_of` to whatever the value is now. +pub(super) fn apply_patch(cur: &mut Value, p: &Value) { + if let Some(v) = p.get("value") { + *cur = v.clone(); + } else if let Some(m) = p.get("map") { + if !cur.is_object() { + *cur = json!({}); + } + let o = cur.as_object_mut().expect("object"); + for (k, v) in m["put"].as_object().into_iter().flatten() { + o.insert(k.clone(), v.clone()); + } + for k in m["del"].as_array().into_iter().flatten().filter_map(Value::as_str) { + o.remove(k); + } + } else if let Some(l) = p.get("list") { + if !cur.is_array() { + *cur = json!([]); + } + let a = cur.as_array_mut().expect("array"); + let key = |x: &Value| item_key(x).unwrap_or(Value::Null); + for item in l["put"].as_array().into_iter().flatten() { + match a.iter_mut().find(|x| key(x) == key(item)) { + Some(x) => *x = item.clone(), + None => a.push(item.clone()), + } + } + let del: Vec = l["del"].as_array().cloned().unwrap_or_default(); + a.retain(|x| !del.contains(&key(x))); + if let Some(order) = l["order"].as_array() { + a.sort_by_key(|x| order.iter().position(|k| *k == key(x)).unwrap_or(usize::MAX)); + } + } +} + /// The person's edits since `base`, as operations the brain applies one by one. pub(super) fn diff_ops(base: &Value, cur: &Value) -> Vec { let mut ops = vec![]; @@ -124,7 +199,8 @@ pub(super) fn diff_ops(base: &Value, cur: &Value) -> Vec { continue; } if b.get(k) != Some(v) { - ops.push(json!({"op": "set", "key": k, "value": v})); + let p = patch_of(b.get(k).unwrap_or(&Value::Null), v); + ops.push(if p.get("value").is_some() { json!({"op": "set", "key": k, "value": v}) } else { json!({"op": "patch", "key": k, "patch": p}) }); } } let id = |x: &Value| x["id"].as_u64().unwrap_or(0); @@ -146,7 +222,8 @@ pub(super) fn diff_ops(base: &Value, cur: &Value) -> Vec { continue; } if ob.get(k) != Some(v) { - ops.push(json!({"op": "bot_set", "id": id(nb), "key": k, "value": v})); + let p = patch_of(ob.get(k).unwrap_or(&Value::Null), v); + ops.push(if p.get("value").is_some() { json!({"op": "bot_set", "id": id(nb), "key": k, "value": v}) } else { json!({"op": "bot_patch", "id": id(nb), "key": k, "patch": p}) }); } } } @@ -179,6 +256,16 @@ pub(super) fn apply_ops(cfg: &mut Value, ops: &[Value]) { cfg[k] = op["value"].clone(); } } + "patch" => { + if let Some(k) = op["key"].as_str() { + apply_patch(&mut cfg[k], &op["patch"]); + } + } + "bot_patch" => { + if let (Some(b), Some(k)) = (cfg["bots"].as_array_mut().and_then(|a| a.iter_mut().find(|b| id(b) == target)), op["key"].as_str()) { + apply_patch(&mut b[k], &op["patch"]); + } + } "bot_add" => { if let Some(a) = cfg["bots"].as_array_mut() { if !a.iter().any(|b| id(b) == id(&op["bot"])) { @@ -254,6 +341,8 @@ struct Seen { pub enum Incoming { Pull(Value), Offline(String), + /// A file from the brain arrived (its local copy's path). + File(String), } /// What a screen sends to the brain, one at a time and in order. @@ -298,6 +387,15 @@ pub struct Mirror { /// Why the brain can't be reached (shown above the chat), and since when. pub(super) reason: String, down_since: Option, + /// Files from the brain being fetched (by local path). + fetching: std::collections::HashSet, +} + +/// Where a screen keeps its copy of a file the brain has. +fn local_copy(brain_path: &str, name: &str) -> PathBuf { + let h = crate::httpd::sha256_hex(brain_path); + let name: String = name.chars().filter(|c| !matches!(c, '/' | '\\' | '\0' | ':')).take(80).collect(); + State::path().with_file_name("brain-files").join(format!("{}-{name}", &h[..16])) } fn cache_path() -> PathBuf { @@ -314,6 +412,25 @@ impl App { for r in reqs { let v = match r.kind { SyncKind::Pull { client, since } => self.brain_pull(&client, since), + SyncKind::Models(name) => { + let kind = serde_json::from_value::(json!(name)).ok(); + match kind { + Some(kind) => { + let ps = self.state.provider(kind); + let (base, key) = (crate::providers::resolve_base(kind, &ps.base_url), crate::providers::resolve_key(kind, &ps.api_key)); + let reply = r.reply; + // The list can take a while; the app doesn't wait for it. + std::thread::spawn(move || { + let _ = reply.send(match crate::providers::list_models(kind, &base, &key) { + Ok(models) => json!({"models": models}), + Err(e) => json!({"error": e}), + }); + }); + continue; + } + None => json!({"error": "大腦不認得這個模型服務(兩邊 AnyBot 版本不同?)"}), + } + } SyncKind::Edit(body) => { if self.mir.on { json!({"error": "這台 AnyBot 本身連著別的大腦"}) @@ -430,6 +547,24 @@ impl App { } } "merge" => self.brain_merge_top(op["key"].as_str().unwrap_or(""), &op["value"]), + "patch" => { + let key = op["key"].as_str().unwrap_or(""); + let mut v = self.top_value(key); + apply_patch(&mut v, &op["patch"]); + self.brain_set_top(key, v) + } + "bot_patch" => { + let (id, key) = (op["id"].as_u64().unwrap_or(0), op["key"].as_str().unwrap_or("")); + match self.state.bot(id).map(|b| serde_json::to_value(b).ok().map(|v| v[key].clone()).unwrap_or(Value::Null)) { + Some(mut v) => { + let old = v.clone(); + apply_patch(&mut v, &op["patch"]); + unredact(&mut v, &old); + self.brain_set_bot(id, key, v) + } + None => false, + } + } "bot_set" => self.brain_set_bot(op["id"].as_u64().unwrap_or(0), op["key"].as_str().unwrap_or(""), op["value"].clone()), "bot_read" => match self.state.bot_mut(op["id"].as_u64().unwrap_or(0)) { Some(b) => { @@ -467,6 +602,14 @@ impl App { json!({"ok": true, "applied": applied}) } + /// One top-level setting as JSON (with the brain's real keys). + fn top_value(&mut self, key: &str) -> Value { + let bots = std::mem::take(&mut self.state.bots); + let v = serde_json::to_value(&self.state).map(|t| t[key].clone()).unwrap_or(Value::Null); + self.state.bots = bots; + v + } + fn brain_set_top(&mut self, key: &str, mut value: Value) -> bool { if key.is_empty() || key == "bots" || LOCAL_KEYS.contains(&key) { return false; @@ -583,6 +726,7 @@ impl App { *self.mir.status.lock().unwrap() = "正在連到大腦…".into(); self.mirror_worker(); self.mirror_outbox(); + crate::tunnel::hello(&self.state.remote.brain_id); } /// Back to this computer's own bots. @@ -610,6 +754,7 @@ impl App { } self.state.save(); self.dirty = false; + crate::tunnel::hello(""); } fn keep_local_fields(&mut self, from: &State) { @@ -706,6 +851,10 @@ impl App { *self.mir.status.lock().unwrap() = format!("連不到大腦:{e}"); self.mir.reason = e; } + Incoming::File(path) => { + self.mir.fetching.remove(&path); + self.att.thumbs.remove(&path); + } Incoming::Pull(v) => { if let Some(e) = v["error"].as_str() { self.mir.online = false; @@ -764,7 +913,8 @@ impl App { for d in v["bots"].as_array().into_iter().flatten() { let id = d["id"].as_u64().unwrap_or(0); let from = d["from"].as_u64().unwrap_or(0) as usize; - let msgs: Vec = serde_json::from_value(d["messages"].clone()).unwrap_or_default(); + let mut msgs: Vec = serde_json::from_value(d["messages"].clone()).unwrap_or_default(); + self.mirror_files(&mut msgs); if let Some(b) = self.state.bot_mut(id) { b.messages.truncate(from); b.messages.extend(msgs); @@ -824,6 +974,35 @@ impl App { self.mirror_save_copy(); } + /// Files in the brain's chats live on the brain: show local copies, fetched once. + fn mirror_files(&mut self, msgs: &mut [Message]) { + for a in msgs.iter_mut().flat_map(|m| m.attachments.iter_mut()) { + if std::path::Path::new(&a.path).exists() { + continue; + } + let local = local_copy(&a.path, &a.name); + let lp = local.display().to_string(); + let brain_path = std::mem::replace(&mut a.path, lp.clone()); + if local.exists() || !self.mir.fetching.insert(lp.clone()) { + continue; + } + let (s, tx, repaint) = (self.state.remote.clone(), self.mir.tx.clone(), self.repaint_fn()); + std::thread::spawn(move || { + if let Ok(bytes) = remote::sync_file(&s, &brain_path) { + let _ = std::fs::create_dir_all(local.parent().unwrap_or(std::path::Path::new("."))); + let tmp = local.with_extension("part"); + if std::fs::write(&tmp, bytes).is_ok() { + let _ = std::fs::rename(&tmp, &local); + } + } + if let Some(tx) = tx { + let _ = tx.send(Incoming::File(lp)); + } + repaint(); + }); + } + } + fn mirror_save_copy(&mut self) { if let Ok(s) = serde_json::to_string(&self.state) { let p = cache_path(); @@ -909,7 +1088,8 @@ impl App { } Out::Move { ops, n } => { *note.lock().unwrap() = match remote::sync_post(&s, "edit", &json!({"ops": ops})) { - Ok(v) => format!("已把 {n} 個 Bot 搬到大腦({} 項)", v["applied"].as_u64().unwrap_or(0)), + Ok(_) if n == 0 => "大腦上已經有這台的 Bot 了;模型金鑰、技能和連接器有缺的已補上".into(), + Ok(_) => format!("已把 {n} 個 Bot(連同聊天)搬到大腦"), Err(e) => format!("搬移失敗:{e}"), }; busy.store(false, Ordering::SeqCst); @@ -957,7 +1137,15 @@ impl App { return; } let mut ops: Vec = vec![]; - for b in &local.bots { + // Skip what the brain already has: the same starter bot never used here, or a bot moved + // before (same name, same chat). + let last = |b: &Bot| b.messages.last().map(|m| (m.at, m.text.clone())); + let moving: Vec<&Bot> = local + .bots + .iter() + .filter(|b| !self.state.bots.iter().any(|o| o.name == b.name && (b.messages.is_empty() || (o.messages.len() == b.messages.len() && last(o) == last(b))))) + .collect(); + for b in moving.iter().copied() { let mut v = serde_json::to_value(b).unwrap_or(Value::Null); v["id"] = json!(0); ops.push(json!({"op": "bot_add", "bot": v})); @@ -966,8 +1154,8 @@ impl App { for k in ["providers", "skills", "mcp_servers"] { ops.push(json!({"op": "merge", "key": k, "value": top[k]})); } + let n = moving.len(); *self.mir.move_note.lock().unwrap() = "搬移中…".into(); - let n = local.bots.len(); self.mirror_out(Out::Move { ops, n }); } @@ -1019,7 +1207,7 @@ mod tests { let cur = json!({"theme": "dark", "folders": ["a", "b"], "bots": [{"id": 1, "name": "A2", "unread": false}, {"id": 3, "name": "C"}]}); let ops = diff_ops(&base, &cur); // theme is this computer's own; folders changed. - assert!(ops.iter().any(|o| o["op"] == "set" && o["key"] == "folders")); + assert!(ops.iter().any(|o| o["op"] == "patch" && o["key"] == "folders" && o["patch"]["list"]["put"] == json!(["b"]))); assert!(!ops.iter().any(|o| o["key"] == "theme")); assert!(ops.iter().any(|o| o["op"] == "bot_set" && o["key"] == "name" && o["value"] == "A2")); assert!(ops.iter().any(|o| o["op"] == "bot_read" && o["id"] == 1)); @@ -1087,4 +1275,35 @@ mod tests { assert!(remote::why_refused(401, &Value::Null, "大腦").contains("重新登入")); assert!(remote::why_unreachable(&ureq::Error::HostNotFound).contains("網路")); } + + #[test] + fn two_computers_edit_one_list_without_undoing_each_other() { + let base = json!({"skills": [{"name": "a", "on": true}, {"name": "b", "on": true}], "providers": {"xai": {"model": "1"}, "openai": {"model": "1"}}, "bots": [{"id": 1, "memories": ["x", "y"]}]}); + // Laptop turns skill a off, changes xai, forgets memory x. + let mut laptop = base.clone(); + laptop["skills"][0]["on"] = json!(false); + laptop["providers"]["xai"]["model"] = json!("2"); + laptop["bots"][0]["memories"] = json!(["y"]); + // Meanwhile the desk computer turned skill b off, changed openai, and the bot learned z. + let mut brain = base.clone(); + brain["skills"][1]["on"] = json!(false); + brain["skills"].as_array_mut().unwrap().push(json!({"name": "c", "on": true})); + brain["providers"]["openai"]["model"] = json!("3"); + brain["bots"][0]["memories"] = json!(["x", "y", "z"]); + let ops = diff_ops(&base, &laptop); + assert!(ops.iter().all(|o| o["op"] != "set" || o["key"] != "skills"), "{ops:?}"); + apply_ops(&mut brain, &ops); + assert_eq!(brain["skills"], json!([{"name": "a", "on": false}, {"name": "b", "on": false}, {"name": "c", "on": true}])); + assert_eq!(brain["providers"], json!({"xai": {"model": "2"}, "openai": {"model": "3"}})); + assert_eq!(brain["bots"][0]["memories"], json!(["y", "z"])); + } + + #[test] + fn lists_without_names_are_sent_whole() { + let p = patch_of(&json!([[1], [2]]), &json!([[2]])); + assert_eq!(p, json!({"value": [[2]]})); + let mut cur = json!([{"id": 1}, {"id": 2}]); + apply_patch(&mut cur, &patch_of(&json!([{"id": 1}, {"id": 2}]), &json!([{"id": 2}, {"id": 1}]))); + assert_eq!(cur, json!([{"id": 2}, {"id": 1}])); + } } diff --git a/src/httpd.rs b/src/httpd.rs index 37141f9..461b3e9 100644 --- a/src/httpd.rs +++ b/src/httpd.rs @@ -178,9 +178,16 @@ pub fn write_response(c: &mut TcpStream, r: &Resp) { } pub type Handler = Arc Resp + Send + Sync>; +/// Takes over a connection that asked to become a WebSocket (it answers the handshake itself). +pub type Upgrade = Arc; /// Answer every connection on this listener with `handler` (each on its own thread). pub fn serve(listener: TcpListener, handler: Handler, alive: Arc bool + Send + Sync>) { + serve_upgrade(listener, handler, None, alive) +} + +/// Like `serve`, and connections asking for a WebSocket go to `upgrade`. +pub fn serve_upgrade(listener: TcpListener, handler: Handler, upgrade: Option, alive: Arc bool + Send + Sync>) { listener.set_nonblocking(true).ok(); loop { if !alive() { @@ -190,7 +197,13 @@ pub fn serve(listener: TcpListener, handler: Handler, alive: Arc boo Ok((mut c, _)) => { c.set_nonblocking(false).ok(); let h = handler.clone(); + let up = upgrade.clone(); std::thread::spawn(move || match read_request(&mut c) { + Ok(req) if req.header("upgrade").eq_ignore_ascii_case("websocket") && up.is_some() => { + if let Some(u) = up { + u(req, c) + } + } Ok(req) => { let resp = h(req); write_response(&mut c, &resp); diff --git a/src/main.rs b/src/main.rs index 27b4ef6..590c12a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -46,7 +46,9 @@ mod triggers; mod vault; mod vault_store; mod voice; +mod tunnel; mod web; +mod wire; use eframe::egui; use std::path::PathBuf; diff --git a/src/remote.rs b/src/remote.rs index b3fd06c..6bf32df 100644 --- a/src/remote.rs +++ b/src/remote.rs @@ -144,6 +144,8 @@ pub struct Hub { pub enum SyncKind { Pull { client: String, since: u64 }, Edit(Value), + /// A provider's model list, fetched with the brain's own key (screens don't have it). + Models(String), } pub struct SyncReq { @@ -169,12 +171,15 @@ impl Hub { } /// Hand a sync request to the app's thread and wait for its answer. fn ask_sync(&self, kind: SyncKind) -> Result { + self.ask_sync_for(kind, 10) + } + fn ask_sync_for(&self, kind: SyncKind, secs: u64) -> Result { let (tx, rx) = std::sync::mpsc::channel(); self.sync.lock().unwrap().push(SyncReq { kind, reply: tx }); if let Some(r) = self.repaint.lock().unwrap().as_ref() { r(); } - rx.recv_timeout(Duration::from_secs(10)).map_err(|_| "大腦太忙,沒有及時回應".to_string()) + rx.recv_timeout(Duration::from_secs(secs)).map_err(|_| "大腦太忙,沒有及時回應".to_string()) } pub fn version(&self) -> u64 { self.view.lock().unwrap().version @@ -383,6 +388,22 @@ pub fn handle(req: Req, via_relay: bool) -> Resp { Err(e) => Resp::error(503, &e), }, ("POST", ["api", "sync", "upload", id]) => upload(id.parse().unwrap_or(0), &req), + ("POST", ["api", "sync", "models"]) => match hub().ask_sync_for(SyncKind::Models(req.json()["provider"].as_str().unwrap_or("").to_string()), 45) { + Ok(v) => Resp::json(&v), + Err(e) => Resp::error(503, &e), + }, + // A file shown in a chat (only what AnyBot keeps as attachments). + ("GET", ["api", "sync", "file"]) => { + let want = std::path::PathBuf::from(req.query.get("path").cloned().unwrap_or_default()); + let root = crate::model::State::path().parent().map(|p| p.join("attachments")).and_then(|p| p.canonicalize().ok()); + match (want.canonicalize().ok(), root) { + (Some(f), Some(root)) if f.starts_with(&root) && f.is_file() => match std::fs::read(&f) { + Ok(b) => Resp::bytes("application/octet-stream", b), + Err(_) => Resp::error(404, "大腦上找不到這個檔案"), + }, + _ => Resp::error(404, "大腦上找不到這個檔案"), + } + } ("POST", ["api", "sync", "act"]) => { let b = req.json(); let bot = b["bot"].as_u64().unwrap_or(0); @@ -642,8 +663,17 @@ pub fn addresses(port: u16) -> Vec { static RELAY_GEN: AtomicU64 = AtomicU64::new(0); +/// One agent per timeout, kept: its connections stay open between requests (no new TCP and TLS +/// handshake each time). fn agent(secs: u64) -> ureq::Agent { - ureq::Agent::config_builder().timeout_global(Some(Duration::from_secs(secs))).http_status_as_error(false).build().into() + static AGENTS: OnceLock>> = OnceLock::new(); + AGENTS + .get_or_init(Mutex::default) + .lock() + .unwrap() + .entry(secs) + .or_insert_with(|| ureq::Agent::config_builder().timeout_global(Some(Duration::from_secs(secs))).http_status_as_error(false).build().into()) + .clone() } /// Why a request never got an answer, in plain words (never an error code). @@ -746,11 +776,17 @@ pub fn relay_restart(s: &Settings) { } let (url, token) = (s.relay_url.clone(), s.host_token.clone()); hub().set_relay_status("連線中…"); - // A few workers, so a slow request (a long-poll) doesn't block the others. - for _ in 0..4 { - let (url, token) = (url.clone(), token.clone()); - std::thread::spawn(move || relay_worker(&url, &token, gen)); - } + std::thread::spawn(move || { + // 長連線 first; an older account server without it gets asked over and over instead. + if crate::tunnel::run(&url, &token, &|| alive(gen), &|m| hub().set_relay_status(m)) || !alive(gen) { + return; + } + // A few workers, so a slow request (a long-poll) doesn't block the others. + for _ in 0..4 { + let (url, token) = (url.clone(), token.clone()); + std::thread::spawn(move || relay_worker(&url, &token, gen)); + } + }); } fn alive(gen: u64) -> bool { @@ -808,7 +844,14 @@ pub fn req_from_json(job: &Value) -> Req { query: job["query"].as_object().map(|o| o.iter().map(|(k, v)| (k.clone(), v.as_str().unwrap_or("").to_string())).collect()).unwrap_or_default(), // Who asked, as the relay vouches for it (團隊: owner / admin / member; "device" for 裝置連結). headers: [("x-anybot-user", job["user"].as_str().unwrap_or("")), ("x-anybot-role", job["role"].as_str().unwrap_or("owner"))].iter().map(|(k, v)| (k.to_string(), v.to_string())).collect(), - body: base64::engine::general_purpose::STANDARD.decode(job["body"].as_str().unwrap_or("")).unwrap_or_default(), + body: { + let b = base64::engine::general_purpose::STANDARD.decode(job["body"].as_str().unwrap_or("")).unwrap_or_default(); + if job["z"].as_bool().unwrap_or(false) { + crate::tunnel::unzip(&b).unwrap_or_default() + } else { + b + } + }, peer: "relay".into(), } } @@ -866,6 +909,10 @@ pub fn run_on_device(host_id: &str, tool: &str, args: &str, who: &str) -> Result if s.relay_url.is_empty() { return Err("沒有登入帳號,連不到其他裝置".into()); } + let body = json!({"tool": tool, "args": args, "who": who}).to_string(); + if let Some(r) = crate::tunnel::call(host_id, "POST", "/api/device/run", &[], body.as_bytes(), 50) { + return r.map_err(|e| format!("連不到那台電腦:{e}")).and_then(|a| answer_value(a.status, &a.body, "那台電腦")); + } let url = format!("{}/relay/hosts/{host_id}/api/device/run", s.relay_url); let mut resp = agent(45) .post(&url) @@ -894,40 +941,66 @@ fn brain_url(s: &Settings, rest: &str) -> Result { Ok(format!("{}/relay/hosts/{}/api/sync{rest}", s.relay_url, s.brain_id)) } -fn read_reply(r: Result, ureq::Error>) -> Result { - let mut resp = r.map_err(|e| format!("連不到帳號伺服器:{}", why_unreachable(&e)))?; - let st = resp.status().as_u16(); - let text = resp.body_mut().with_config().limit(200 * 1024 * 1024).read_to_string().map_err(|_| "和大腦的連線到一半斷掉了".to_string())?; - let v: Value = serde_json::from_str(&text).unwrap_or(Value::Null); +/// A JSON answer, or what went wrong in words. +fn answer_value(st: u16, bytes: &[u8], whom: &str) -> Result { + let v: Value = serde_json::from_slice(bytes).unwrap_or(Value::Null); if !(200..300).contains(&st) { - return Err(why_refused(st, &v, "大腦")); + return Err(why_refused(st, &v, whom)); } if v.is_null() { - return Err("大腦的回應看不懂,請把兩邊的 AnyBot 都更新到最新版".into()); + return Err(format!("{whom}的回應看不懂,請把兩邊的 AnyBot 都更新到最新版")); } Ok(v) } +/// Ask the brain: over the lasting connection when it is open, else an ordinary request. +fn to_brain(s: &Settings, method: &str, rest: &str, query: &[(&str, String)], body: &[u8], secs: u64) -> Result { + let (st, bytes) = to_brain_raw(s, method, rest, query, body, secs)?; + answer_value(st, &bytes, "大腦") +} + +fn to_brain_raw(s: &Settings, method: &str, rest: &str, query: &[(&str, String)], body: &[u8], secs: u64) -> Result<(u16, Vec), String> { + if s.brain_id.is_empty() { + return Err("沒有選大腦".into()); + } + if let Some(r) = crate::tunnel::call(&s.brain_id, method, &format!("/api/sync{rest}"), query, body, secs) { + return r.map(|a| (a.status, a.body)); + } + let q: Vec = query.iter().map(|(k, v)| format!("{k}={}", url_escape(v))).collect(); + let url = brain_url(s, &format!("{rest}{}{}", if q.is_empty() { "" } else { "?" }, q.join("&")))?; + let auth = format!("Bearer {}", s.host_token); + let r = if method == "GET" { agent(secs).get(&url).header("Authorization", &auth).call() } else { agent(secs).post(&url).header("Authorization", &auth).header("Content-Type", "application/octet-stream").send(body) }; + let mut resp = r.map_err(|e| format!("連不到帳號伺服器:{}", why_unreachable(&e)))?; + let st = resp.status().as_u16(); + let bytes = resp.body_mut().with_config().limit(200 * 1024 * 1024).read_to_vec().map_err(|_| "和大腦的連線到一半斷掉了".to_string())?; + Ok((st, bytes)) +} + /// What changed at the brain since `since` (waits up to LONG_POLL_SECS when nothing has). pub fn sync_pull(s: &Settings, since: u64, client: &str) -> Result { - let url = brain_url(s, &format!("?since={since}&client={client}"))?; - read_reply(agent(LONG_POLL_SECS + 25).get(&url).header("Authorization", &format!("Bearer {}", s.host_token)).call()) + to_brain(s, "GET", "", &[("since", since.to_string()), ("client", client.to_string())], &[], LONG_POLL_SECS + 25) } /// Send edits ("edit") or a chat action ("act") to the brain. pub fn sync_post(s: &Settings, what: &str, body: &Value) -> Result { - let url = brain_url(s, &format!("/{what}"))?; - read_reply(agent(60).post(&url).header("Authorization", &format!("Bearer {}", s.host_token)).header("Content-Type", "application/json").send(body.to_string().as_bytes())) + to_brain(s, "POST", &format!("/{what}"), &[], body.to_string().as_bytes(), 60) } /// A file for the next message, kept at the brain; gives back its path there. pub fn sync_upload(s: &Settings, bot: u64, name: &str, bytes: &[u8]) -> Result { - let q: String = url_escape(name); - let url = brain_url(s, &format!("/upload/{bot}?name={q}"))?; - let v = read_reply(agent(120).post(&url).header("Authorization", &format!("Bearer {}", s.host_token)).header("Content-Type", "application/octet-stream").send(bytes))?; + let v = to_brain(s, "POST", &format!("/upload/{bot}"), &[("name", name.to_string())], bytes, 120)?; v["path"].as_str().map(String::from).ok_or("大腦沒有收下檔案".into()) } +/// A file the brain keeps (an attachment shown in a chat), as bytes. +pub fn sync_file(s: &Settings, path: &str) -> Result, String> { + let (st, bytes) = to_brain_raw(s, "GET", "/file", &[("path", path.to_string())], &[], 120)?; + if !(200..300).contains(&st) { + return Err(why_refused(st, &serde_json::from_slice(&bytes).unwrap_or(Value::Null), "大腦")); + } + Ok(bytes) +} + fn url_escape(s: &str) -> String { s.bytes() .map(|b| match b { diff --git a/src/tunnel.rs b/src/tunnel.rs new file mode 100644 index 0000000..4a2d6f3 --- /dev/null +++ b/src/tunnel.rs @@ -0,0 +1,365 @@ +// 長連線: one lasting WebSocket from this AnyBot to the account server. Requests for this AnyBot +// (the phone, a screen of this brain, a brain using this computer's hands) come down it at once; +// this AnyBot's own requests to the account's other AnyBots go up it. Big bodies are compressed. +// A ping every 15 s keeps it (and any proxy on the way) awake; 45 s without a word and it is +// opened again. If the account server is older and has no WebSocket, the old way (asking +// "anything for me?" over and over) is used instead. + +use crate::httpd::Req; +use crate::wire; +use serde_json::{json, Value}; +use std::collections::HashMap; +use std::io::{ErrorKind, Read, Write}; +use std::net::{TcpStream, ToSocketAddrs}; +use std::sync::mpsc::{channel, Sender}; +use std::sync::{Arc, Mutex, OnceLock}; +use std::time::{Duration, Instant}; +use tungstenite::{Message, WebSocket}; + +const PING_SECS: u64 = 15; +const DEAD_SECS: u64 = 45; +/// Bodies bigger than this are compressed. +const ZIP_OVER: usize = 1024; +const CUT: &str = "和帳號伺服器的連線斷了,正在重新連線"; + +/// Another AnyBot's answer to a call. +pub struct Answer { + pub status: u16, + pub body: Vec, +} + +type Calls = Arc>>>>; + +struct Link { + tx: Sender>, + calls: Calls, + serial: u64, +} + +fn link() -> &'static Mutex> { + static L: OnceLock>> = OnceLock::new(); + L.get_or_init(|| Mutex::new(None)) +} + + +pub fn zip(b: &[u8]) -> Vec { + let mut e = flate2::write::DeflateEncoder::new(Vec::with_capacity(b.len() / 3), flate2::Compression::fast()); + let _ = e.write_all(b); + e.finish().unwrap_or_default() +} + +/// The body to send and whether it is compressed: only when that saves a good part of it +/// (pictures and PDFs are compressed already). +fn maybe_zip(b: Vec) -> (Vec, bool) { + if b.len() <= ZIP_OVER { + return (b, false); + } + let z = zip(&b); + if z.len() * 10 < b.len() * 9 { + (z, true) + } else { + (b, false) + } +} + +pub fn unzip(b: &[u8]) -> Result, String> { + let mut out = vec![]; + flate2::read::DeflateDecoder::new(b).take(512 * 1024 * 1024).read_to_end(&mut out).map_err(|_| "收到的資料壞掉了".to_string())?; + Ok(out) +} + +// ---------- the connection ---------- + +enum Stream { + Plain(TcpStream), + Tls(Box>), +} + +impl Stream { + fn tcp(&self) -> &TcpStream { + match self { + Stream::Plain(t) => t, + Stream::Tls(s) => &s.sock, + } + } +} + +impl Read for Stream { + fn read(&mut self, b: &mut [u8]) -> std::io::Result { + match self { + Stream::Plain(t) => t.read(b), + Stream::Tls(s) => s.read(b), + } + } +} + +impl Write for Stream { + fn write(&mut self, b: &[u8]) -> std::io::Result { + match self { + Stream::Plain(t) => t.write(b), + Stream::Tls(s) => s.write(b), + } + } + fn flush(&mut self) -> std::io::Result<()> { + match self { + Stream::Plain(t) => t.flush(), + Stream::Tls(s) => s.flush(), + } + } +} + +fn tls_config() -> Arc { + static C: OnceLock> = OnceLock::new(); + C.get_or_init(|| { + let roots = rustls::RootCertStore { roots: webpki_roots::TLS_SERVER_ROOTS.to_vec() }; + Arc::new( + rustls::ClientConfig::builder_with_provider(Arc::new(rustls::crypto::ring::default_provider())) + .with_safe_default_protocol_versions() + .expect("TLS versions") + .with_root_certificates(roots) + .with_no_client_auth(), + ) + }) + .clone() +} + +fn would_block(e: &tungstenite::Error) -> bool { + matches!(e, tungstenite::Error::Io(io) if matches!(io.kind(), ErrorKind::WouldBlock | ErrorKind::TimedOut)) +} + +/// Why it couldn't connect; `old` = the account server has no WebSocket (use the old way). +struct NoLink { + old: bool, + why: String, +} + +fn open(url: &str, token: &str) -> Result, NoLink> { + use tungstenite::client::IntoClientRequest; + let fail = |why: &str| NoLink { old: false, why: why.to_string() }; + let (tls, rest) = match (url.strip_prefix("https://"), url.strip_prefix("http://")) { + (Some(r), _) => (true, r), + (_, Some(r)) => (false, r), + _ => return Err(fail("帳號伺服器的網址不對")), + }; + let hostport = rest.split('/').next().unwrap_or(""); + let (host, port) = match hostport.rsplit_once(':') { + Some((h, p)) if p.parse::().is_ok() => (h, p.parse().unwrap_or(443)), + _ => (hostport, if tls { 443 } else { 80 }), + }; + let addrs: Vec<_> = (host, port).to_socket_addrs().map_err(|_| fail("找不到伺服器:這台電腦可能沒連上網路,或伺服器網址打錯了"))?.collect(); + // Each address in turn ("localhost" is both ::1 and 127.0.0.1). + let mut last = None; + let mut tcp = None; + for a in &addrs { + match TcpStream::connect_timeout(a, Duration::from_secs(10)) { + Ok(t) => { + tcp = Some(t); + break; + } + Err(e) => last = Some(e.kind()), + } + } + let tcp = tcp.ok_or_else(|| { + fail(match last { + None => "找不到伺服器", + Some(ErrorKind::ConnectionRefused) => "伺服器沒有開(連線被拒絕)", + Some(ErrorKind::TimedOut | ErrorKind::WouldBlock) => "伺服器沒有回應:可能沒開機,或網路把連線擋住了", + _ => "這台電腦沒有連上網路", + }) + })?; + let _ = tcp.set_nodelay(true); + let _ = tcp.set_read_timeout(Some(Duration::from_secs(15))); + let _ = tcp.set_write_timeout(Some(Duration::from_secs(30))); + let stream = if tls { + let name = rustls::pki_types::ServerName::try_from(host.to_string()).map_err(|_| fail("帳號伺服器的網址不對"))?; + let conn = rustls::ClientConnection::new(tls_config(), name).map_err(|_| fail("和伺服器的安全連線失敗"))?; + Stream::Tls(Box::new(rustls::StreamOwned::new(conn, tcp))) + } else { + Stream::Plain(tcp) + }; + let mut req = format!("{}://{hostport}/relay/host/ws", if tls { "wss" } else { "ws" }).into_client_request().map_err(|_| fail("帳號伺服器的網址不對"))?; + if let Ok(v) = format!("Bearer {token}").parse() { + req.headers_mut().insert("Authorization", v); + } + let cfg = tungstenite::protocol::WebSocketConfig::default().max_message_size(Some(512 << 20)).max_frame_size(Some(512 << 20)); + match tungstenite::client::client_with_config(req, stream, Some(cfg)) { + Ok((ws, _)) => Ok(ws), + Err(tungstenite::HandshakeError::Failure(tungstenite::Error::Http(r))) => match r.status().as_u16() { + 401 | 403 => Err(fail("這台電腦的帳號登入失效了,請到「設定 › 手機與帳號」重新登入")), + 400 | 404 | 405 | 426 => Err(NoLink { old: true, why: String::new() }), + _ => Err(fail("帳號伺服器暫時無法使用,稍後會自動再試")), + }, + Err(tungstenite::HandshakeError::Failure(e)) if e.to_string().to_lowercase().contains("certificate") || e.to_string().to_lowercase().contains("tls") => Err(fail("和伺服器的安全連線失敗(伺服器的憑證有問題)")), + Err(_) => Err(fail("連線到一半斷掉了")), + } +} + +/// Keep the connection for as long as `alive()`. Returns false when the account server has no +/// WebSocket (the caller then uses the old way). +pub fn run(url: &str, token: &str, alive: &dyn Fn() -> bool, status: &dyn Fn(&str)) -> bool { + static SERIAL: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1); + let mut backoff = 1u64; + while alive() { + match open(url, token) { + Err(NoLink { old: true, .. }) => return false, + Err(e) => { + status(&format!("連不上帳號伺服器:{}", e.why)); + // Wait, but notice at once when told to stop. + let until = Instant::now() + Duration::from_secs(backoff); + while Instant::now() < until && alive() { + std::thread::sleep(Duration::from_millis(200)); + } + backoff = (backoff * 2).min(30); + } + Ok(ws) => { + backoff = 1; + status("已連線"); + let serial = SERIAL.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + keep(ws, serial, alive); + if alive() { + status("連線中斷,正在重新連線…"); + } + } + } + } + true +} + +fn keep(mut ws: WebSocket, serial: u64, alive: &dyn Fn() -> bool) { + let _ = ws.get_ref().tcp().set_read_timeout(Some(Duration::from_millis(40))); + let (tx, rx) = channel::>(); + let calls: Calls = Arc::default(); + *link().lock().unwrap() = Some(Link { tx: tx.clone(), calls: calls.clone(), serial }); + let _ = tx.send(wire::pack(&json!({"t": "hello", "brain": crate::remote::hub().settings().brain_id}), &[])); + let (mut heard, mut pinged) = (Instant::now(), Instant::now()); + 'live: while alive() { + match ws.read() { + Ok(Message::Binary(b)) => { + heard = Instant::now(); + if let Some((head, body)) = wire::unpack(&b) { + on_frame(head, body, &tx, &calls); + } + } + Ok(Message::Close(_)) => break, + Ok(_) => heard = Instant::now(), + Err(e) if would_block(&e) => {} + Err(_) => break, + } + while let Ok(f) = rx.try_recv() { + if ws.write(Message::Binary(f.into())).is_err() { + break 'live; + } + } + if pinged.elapsed().as_secs() >= PING_SECS { + pinged = Instant::now(); + if ws.write(Message::Ping(Vec::new().into())).is_err() { + break; + } + } + match ws.flush() { + Err(e) if !would_block(&e) => break, + _ => {} + } + if heard.elapsed().as_secs() >= DEAD_SECS { + break; + } + } + let _ = ws.close(None); + let _ = ws.flush(); + let mut l = link().lock().unwrap(); + if l.as_ref().map(|l| l.serial) == Some(serial) { + *l = None; + } + drop(l); + // Whoever waits on an answer hears now, not after a timeout. + for (_, w) in calls.lock().unwrap().drain() { + let _ = w.send(Err(CUT.into())); + } +} + +fn on_frame(head: Value, body: Vec, tx: &Sender>, calls: &Calls) { + match head["t"].as_str().unwrap_or("") { + "job" => { + let tx = tx.clone(); + std::thread::spawn(move || { + let s = |k: &str| head[k].as_str().unwrap_or("").to_string(); + let id = s("id"); + let body = if head["z"].as_bool().unwrap_or(false) { unzip(&body).unwrap_or_default() } else { body }; + let req = Req { + method: s("method"), + path: s("path"), + query: head["query"].as_object().map(|o| o.iter().map(|(k, v)| (k.clone(), v.as_str().unwrap_or("").to_string())).collect()).unwrap_or_default(), + headers: [("x-anybot-user", s("user")), ("x-anybot-role", if head["role"].is_string() { s("role") } else { "owner".into() })].into_iter().map(|(k, v)| (k.to_string(), v)).collect(), + body, + peer: "relay".into(), + }; + let resp = crate::remote::handle(req, true); + let (out, z) = if head["z_ok"].as_bool().unwrap_or(false) { maybe_zip(resp.body) } else { (resp.body, false) }; + let _ = tx.send(wire::pack(&json!({"t": "reply", "id": id, "status": resp.status, "ctype": resp.ctype, "headers": resp.headers, "z": z}), &out)); + }); + } + "ret" => { + if let Some(w) = calls.lock().unwrap().remove(head["id"].as_str().unwrap_or("")) { + let body = if head["z"].as_bool().unwrap_or(false) { unzip(&body) } else { Ok(body) }; + let _ = w.send(body.map(|body| Answer { status: head["status"].as_u64().unwrap_or(502) as u16, body })); + } + } + _ => {} + } +} + +/// Tell the account server which brain this AnyBot shows (so the phone can go there). +pub fn hello(brain: &str) { + if let Some(l) = link().lock().unwrap().as_ref() { + let _ = l.tx.send(wire::pack(&json!({"t": "hello", "brain": brain}), &[])); + } +} + +/// Ask another AnyBot of this account over the lasting connection. None when it isn't open +/// (the caller then uses an ordinary request). +pub fn call(to: &str, method: &str, path: &str, query: &[(&str, String)], body: &[u8], secs: u64) -> Option> { + let (tx, calls) = { + let l = link().lock().unwrap(); + let l = l.as_ref()?; + (l.tx.clone(), l.calls.clone()) + }; + let id = crate::httpd::random_hex(8); + let (rtx, rrx) = channel(); + calls.lock().unwrap().insert(id.clone(), rtx); + let (out, z) = maybe_zip(body.to_vec()); + let q: serde_json::Map = query.iter().map(|(k, v)| (k.to_string(), json!(v))).collect(); + if tx.send(wire::pack(&json!({"t": "call", "id": id, "to": to, "method": method, "path": path, "query": q, "z": z}), &out)).is_err() { + calls.lock().unwrap().remove(&id); + return None; + } + Some(match rrx.recv_timeout(Duration::from_secs(secs)) { + Ok(r) => r, + Err(_) => { + calls.lock().unwrap().remove(&id); + Err("等太久沒有回應:網路很慢或斷斷續續".into()) + } + }) +} + +#[cfg(test)] +mod tests { + #[test] + fn compression_round_trips_and_shrinks_text() { + let text = "你好,這是一段會重複的聊天內容。".repeat(400); + let z = super::zip(text.as_bytes()); + assert!(z.len() * 5 < text.len()); + assert_eq!(super::unzip(&z).unwrap(), text.as_bytes()); + // Already-compressed bytes go as they are. + let mut x = 88172645463325252u64; + let noise: Vec = (0..5000) + .map(|_| { + x ^= x << 13; + x ^= x >> 7; + x ^= x << 17; + (x >> 24) as u8 + }) + .collect(); + assert!(!super::maybe_zip(noise).1); + assert!(super::maybe_zip(text.into_bytes()).1); + } +} diff --git a/src/wire.rs b/src/wire.rs new file mode 100644 index 0000000..c83ab97 --- /dev/null +++ b/src/wire.rs @@ -0,0 +1,42 @@ +// (Shared with relay/.) +#![allow(dead_code)] +// One frame on the lasting connection between an AnyBot and the account server (a WebSocket): +// a small JSON header, then the body as raw bytes (no base64), in one binary message. +// +// Headers ("t" says which): +// server → AnyBot job {id, method, path, query, user, role, z, z_ok} a request for this AnyBot +// AnyBot → server reply {id, status, ctype, headers, z} its answer +// AnyBot → server call {id, to, method, path, query, z} a request for another AnyBot of the account +// server → AnyBot ret {id, status, ctype, z} that one's answer +// AnyBot → server hello {brain} what this AnyBot shows +// "z": the body is deflate-compressed. "z_ok": the asker can read a compressed answer. + +use serde_json::Value; + +pub fn pack(head: &Value, body: &[u8]) -> Vec { + let h = head.to_string(); + let mut out = Vec::with_capacity(4 + h.len() + body.len()); + out.extend_from_slice(&(h.len() as u32).to_be_bytes()); + out.extend_from_slice(h.as_bytes()); + out.extend_from_slice(body); + out +} + +pub fn unpack(b: &[u8]) -> Option<(Value, Vec)> { + let n = u32::from_be_bytes(b.get(..4)?.try_into().ok()?) as usize; + let head = serde_json::from_slice(b.get(4..4 + n)?).ok()?; + Some((head, b[4 + n..].to_vec())) +} + +#[cfg(test)] +mod tests { + #[test] + fn frames_round_trip() { + let head = serde_json::json!({"t": "job", "id": "a"}); + let f = super::pack(&head, b"\x00\x01body"); + let (h, b) = super::unpack(&f).unwrap(); + assert_eq!(h, head); + assert_eq!(b, b"\x00\x01body"); + assert!(super::unpack(&f[..3]).is_none()); + } +} -- 2.40.1 From cb922da3fe575c2dadb7b1c2e5370666674f999e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E6=80=A7=E9=A9=8A?= Date: Wed, 4 Nov 2026 20:38:45 +0800 Subject: [PATCH 4/9] anybot setup and the server setup: guided setup with a brain picker - anybot setup: a full-screen terminal guide (role, name, account server, account, which brain) that saves the settings, installs a systemd user service (with a virtual screen when there is none), and checks that the account server and the brain see the computer. Flags for scripts. - Hands can be limited to one brain; the account server says which AnyBot asks. Choosing a brain in the app does the same. - deploy/server/setup.sh: domain, account, sign-ups and keys step by step; writes .env and Caddyfile, starts everything and checks HTTPS. The server's brain creates the account on first start. - deploy/linux/hands.sh installs what's needed and opens anybot setup (no password kept in a file any more). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Uirha3oEtWeHh58zA7ErHa --- .gitignore | 2 + Cargo.lock | 129 +++- Cargo.toml | 3 + deploy/linux/hands.sh | 95 +-- deploy/linux/install.sh | 1 + deploy/server/docker-compose.yml | 12 +- deploy/server/setup.sh | 283 +++++++ relay/src/main.rs | 15 +- src/app.rs | 11 +- src/app/mirror_ui.rs | 7 + src/main.rs | 5 + src/remote.rs | 46 +- src/setup.rs | 1199 ++++++++++++++++++++++++++++++ src/tunnel.rs | 2 +- 14 files changed, 1707 insertions(+), 103 deletions(-) create mode 100755 deploy/server/setup.sh create mode 100644 src/setup.rs diff --git a/.gitignore b/.gitignore index 3fc2395..0dc47b9 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,8 @@ # Local secrets (deploy/server/.env.example is the template) .env +# Written by deploy/server/setup.sh +/deploy/server/Caddyfile *.env.local # macOS diff --git a/Cargo.lock b/Cargo.lock index aa4bb9b..db37e28 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -343,6 +343,7 @@ dependencies = [ "chrono", "chrono-tz", "cpal", + "crossterm", "dirs", "eframe", "flate2", @@ -362,6 +363,7 @@ dependencies = [ "serde_json", "tungstenite", "unicode-normalization", + "unicode-width", "ureq", "wayland-client", "wayland-protocols", @@ -396,7 +398,7 @@ dependencies = [ "objc2-foundation 0.3.2", "parking_lot", "percent-encoding", - "windows-sys 0.52.0", + "windows-sys 0.61.2", "wl-clipboard-rs", "x11rb 0.13.2", ] @@ -1032,6 +1034,15 @@ version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" +[[package]] +name = "convert_case" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" +dependencies = [ + "unicode-segmentation", +] + [[package]] name = "cookie" version = "0.18.2" @@ -1227,6 +1238,33 @@ version = "0.8.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6" +[[package]] +name = "crossterm" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b" +dependencies = [ + "bitflags 2.13.2", + "crossterm_winapi", + "derive_more", + "document-features", + "mio", + "parking_lot", + "rustix 1.1.5", + "signal-hook", + "signal-hook-mio", + "winapi", +] + +[[package]] +name = "crossterm_winapi" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" +dependencies = [ + "winapi", +] + [[package]] name = "crunchy" version = "0.2.4" @@ -1294,6 +1332,28 @@ version = "0.5.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9e9de72ce2ad1f90dc62fa25f0f430ef85eb4b0d8fa0be4f30373bc40a21d28e" +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "convert_case", + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.119", +] + [[package]] name = "digest" version = "0.10.7" @@ -1644,7 +1704,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -2879,6 +2939,18 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "mio" +version = "1.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1788edb87fdc09c7e26304471e2f5be8cdefb1b6930d6e3985fc02ff53bf86ee" +dependencies = [ + "libc", + "log", + "wasi", + "windows-sys 0.61.2", +] + [[package]] name = "moxcms" version = "0.8.1" @@ -3499,7 +3571,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -4205,7 +4277,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -4392,6 +4464,27 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" +[[package]] +name = "signal-hook" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" +dependencies = [ + "libc", + "signal-hook-registry", +] + +[[package]] +name = "signal-hook-mio" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc" +dependencies = [ + "libc", + "mio", + "signal-hook", +] + [[package]] name = "signal-hook-registry" version = "1.4.8" @@ -4855,10 +4948,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.3.4", + "getrandom 0.4.3", "once_cell", "rustix 1.1.5", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -5904,15 +5997,37 @@ dependencies = [ "web-sys", ] +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + [[package]] name = "winapi-util" version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + [[package]] name = "windows" version = "0.62.2" diff --git a/Cargo.toml b/Cargo.toml index 68baaf6..315b011 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,6 +14,9 @@ ureq = { version = "3", features = ["json"] } rustls = { version = "0.23", default-features = false, features = ["ring", "std", "tls12", "logging"] } webpki-roots = "1" flate2 = "1" +# `anybot setup`: the guided setup in a terminal (servers and Linux computers without a window). +crossterm = "0.29" +unicode-width = "0.2" serde = { version = "1", features = ["derive"] } serde_json = "1" dirs = "7" diff --git a/deploy/linux/hands.sh b/deploy/linux/hands.sh index bb77f70..7b9e33d 100755 --- a/deploy/linux/hands.sh +++ b/deploy/linux/hands.sh @@ -1,29 +1,28 @@ #!/bin/sh -# Make this Linux computer a pair of hands for your AnyBot brain: the brain's bots can see and use -# its screen, files and browser (asking you first, as usual). Works on a desktop and on a computer -# without a screen (a virtual screen is made for it). Run from the app/ folder: +# Make this Linux computer a pair of hands for your AnyBot brain (or a brain itself): installs what +# it needs, then opens the guided setup (`anybot setup`), where you pick the brain from your +# account's AnyBots. Works on a desktop and on a computer without a screen. Run from app/: # # ./deploy/linux/hands.sh --deps also install what a computer without a screen needs (sudo) -# ./deploy/linux/hands.sh set up and start it (asks for your account the first time) -# ./deploy/linux/hands.sh --stop stop it and don't start it with the computer any more +# ./deploy/linux/hands.sh install AnyBot if needed, then the setup +# ./deploy/linux/hands.sh --stop stop AnyBot and don't start it with the computer any more set -e cd "$(dirname "$0")/../.." -UNIT="$HOME/.config/systemd/user/anybot-hands.service" -ENVF="$HOME/.config/anybot/hands.env" -RUN="$HOME/.local/bin/anybot-hands" if [ "$1" = "--stop" ]; then - systemctl --user disable --now anybot-hands.service 2>/dev/null || true - rm -f "$UNIT" - systemctl --user daemon-reload - echo "Stopped. (Your account details stay in $ENVF; delete it if you like.)" + for u in anybot.service anybot-hands.service; do + systemctl --user disable --now "$u" 2>/dev/null || true + rm -f "$HOME/.config/systemd/user/$u" + done + systemctl --user daemon-reload 2>/dev/null || true + echo "Stopped. Run ./deploy/linux/hands.sh (or anybot setup) to set it up again." exit 0 fi if [ "$1" = "--deps" ]; then # A virtual screen, a window manager, the accessibility bus (for reading apps' buttons), a session bus. if command -v apt-get >/dev/null; then - sudo apt-get update && sudo apt-get install -y xvfb openbox at-spi2-core libatk-adaptor dbus-x11 xauth fonts-noto-cjk + sudo apt-get update && sudo apt-get install -y xvfb openbox at-spi2-core libatk-adaptor dbus-x11 dbus-user-session xauth fonts-noto-cjk elif command -v dnf >/dev/null; then sudo dnf install -y xorg-x11-server-Xvfb openbox at-spi2-core at-spi2-atk dbus-x11 xorg-x11-xauth google-noto-sans-cjk-fonts elif command -v pacman >/dev/null; then @@ -33,71 +32,7 @@ if [ "$1" = "--deps" ]; then fi fi -command -v systemctl >/dev/null || { echo "This needs systemd (to keep AnyBot running)."; exit 1; } [ -x "$HOME/.local/bin/anybot" ] || sh deploy/linux/install.sh - -if [ ! -f "$ENVF" ]; then - echo "Your AnyBot account (the same one your brain uses):" - printf " Account server address (e.g. https://anybot.example.com): "; read -r URL - printf " Account name: "; read -r USERNAME - printf " Password: "; stty -echo 2>/dev/null; read -r PASSWORD; stty echo 2>/dev/null; echo - printf " Name for this computer [%s]: " "$(hostname)"; read -r NAME - printf " Brain's name (empty = only lend hands, show no bots here): "; read -r BRAIN - mkdir -p "$(dirname "$ENVF")" - umask 077 - # Each value in single quotes, so a password with $ or spaces stays as typed. - q() { printf "%s='%s'\n" "$1" "$(printf %s "$2" | sed "s/'/'\\\\''/g")"; } - { - q ANYBOT_ACCOUNT "${URL%/}|$USERNAME" - q ANYBOT_ACCOUNT_PASSWORD "$PASSWORD" - q ANYBOT_HOST_NAME "${NAME:-$(hostname)}" - if [ -n "$BRAIN" ]; then q ANYBOT_BRAIN "$BRAIN"; fi - } > "$ENVF" - chmod 600 "$ENVF" -fi - -mkdir -p "$(dirname "$RUN")" "$(dirname "$UNIT")" -cat > "$RUN" <<'SH' -#!/bin/sh -# Started by systemd (anybot-hands.service). -set -a; . "$HOME/.config/anybot/hands.env"; set +a -export ANYBOT_DEVICE_LINK=1 -export LANG="${LANG:-C.UTF-8}" -# The accessibility bus, so bots can read apps' buttons instead of only looking at pixels. -export NO_AT_BRIDGE=0 GTK_A11Y=atspi QT_LINUX_ACCESSIBILITY_ALWAYS_ON=1 -if [ -z "$DISPLAY" ] && [ -z "$WAYLAND_DISPLAY" ]; then - # No screen: a virtual one with a window manager, and a session bus for the accessibility bus. - exec xvfb-run -a -s "-screen 0 1440x900x24" dbus-run-session -- sh -c 'openbox >/dev/null 2>&1 & exec "$HOME/.local/bin/anybot" --serve' -fi -exec "$HOME/.local/bin/anybot" --serve -SH -chmod 755 "$RUN" - -cat > "$UNIT" </dev/null | grep -q "Linger=yes"; then - echo "To keep it running after you log out and after a restart, run once:" - echo " sudo loginctl enable-linger $USER" -fi +# Older versions of this script kept the account in a file; the setup keeps only a key. +rm -f "$HOME/.config/anybot/hands.env" "$HOME/.local/bin/anybot-hands" +exec "$HOME/.local/bin/anybot" setup diff --git a/deploy/linux/install.sh b/deploy/linux/install.sh index 83e6fa2..deffda3 100755 --- a/deploy/linux/install.sh +++ b/deploy/linux/install.sh @@ -58,6 +58,7 @@ command -v update-desktop-database >/dev/null && update-desktop-database "$APPS" command -v gtk-update-icon-cache >/dev/null && gtk-update-icon-cache -q "$ICONS" 2>/dev/null || true echo "AnyBot is installed: find it in your app menu, or run: anybot" +echo "To make this computer a brain, or hands for your brain (also without a screen): anybot setup" case ":$PATH:" in *":$BIN:"*) ;; *) echo "(Add $BIN to your PATH to start it from a terminal.)" ;; esac if [ "${XDG_SESSION_TYPE:-}" = "wayland" ]; then echo "Wayland: bots can see and use your screen. On GNOME/KDE the first time, a system window asks:" diff --git a/deploy/server/docker-compose.yml b/deploy/server/docker-compose.yml index 70ad677..865d4bb 100644 --- a/deploy/server/docker-compose.yml +++ b/deploy/server/docker-compose.yml @@ -1,5 +1,7 @@ # Everything on one small server: the account server (relay), HTTPS (Caddy), and AnyBot itself. -# 1. Point your domain at the server and put it in ../../relay/Caddyfile. +# Easiest: ./setup.sh (asks for the domain, account and keys, writes .env and Caddyfile, starts +# everything and checks it). By hand: +# 1. Point your domain at the server; copy ../../relay/Caddyfile here and put the domain in it. # 2. Put your account name and password in .env (see .env.example). # 3. docker compose up -d # 4. On the phone open https://your-domain, create the account (first time), and use it. @@ -10,7 +12,7 @@ # and works even while your computers sleep. On each of your computers: 設定 › 手機與帳號 › # 大腦 › 「改用它當大腦」. Those computers then show these bots, and the bots can use them as hands # (screen, files, browser), asking you first as usual. A Linux computer without a screen can be -# hands too: deploy/linux/hands.sh. +# hands too: run `anybot setup` there and pick this brain. services: relay: build: @@ -28,7 +30,8 @@ services: restart: unless-stopped ports: ["80:80", "443:443"] volumes: - - ../../relay/Caddyfile:/etc/caddy/Caddyfile:ro + # Written by setup.sh (your domain); ../../relay/Caddyfile is the template. + - ./Caddyfile:/etc/caddy/Caddyfile:ro - caddy-data:/data depends_on: [relay] anybot: @@ -41,6 +44,9 @@ services: ANYBOT_ACCOUNT: "http://relay:8080|${ANYBOT_USER}" ANYBOT_ACCOUNT_PASSWORD: "${ANYBOT_PASSWORD}" ANYBOT_HOST_NAME: "${ANYBOT_HOST_NAME:-雲端 AnyBot}" + # First start on a new server: make the account (the first one needs no invite). + ANYBOT_ACCOUNT_CREATE: "1" + ANYBOT_RELAY_INVITE: "${ANYBOT_RELAY_INVITE:-}" XAI_API_KEY: "${XAI_API_KEY:-}" ANTHROPIC_API_KEY: "${ANTHROPIC_API_KEY:-}" OPENAI_API_KEY: "${OPENAI_API_KEY:-}" diff --git a/deploy/server/setup.sh b/deploy/server/setup.sh new file mode 100755 index 0000000..e33c443 --- /dev/null +++ b/deploy/server/setup.sh @@ -0,0 +1,283 @@ +#!/usr/bin/env bash +# AnyBot 伺服器設定: the account server, HTTPS and the brain on this server, step by step. +# +# cd app/deploy/server && ./setup.sh ask, write .env and Caddyfile, start, check +# ./setup.sh --dry-run ask and write the files, but don't start anything +# +# Run it again any time: it offers to keep the current settings (to update and restart). +set -u +cd "$(dirname "$0")" +DRY=0 +[ "${1:-}" = "--dry-run" ] && DRY=1 + +# ---------- looks ---------- +if [ -t 1 ]; then + B=$'\e[1m'; D=$'\e[90m'; A=$'\e[36m'; G=$'\e[32m'; Y=$'\e[33m'; R=$'\e[31m'; N=$'\e[0m' +else + B=""; D=""; A=""; G=""; Y=""; R=""; N="" +fi +STEPS=("檢查" "網址" "帳號" "金鑰" "啟動") +AT=0 + +header() { + printf '\e[2J\e[H\n' + printf ' %s◆%s %sAnyBot 伺服器設定%s ' "$A" "$N" "$B" "$N" + local i + for i in "${!STEPS[@]}"; do + [ "$i" -gt 0 ] && printf '%s ─ %s' "$D" "$N" + if [ "$i" -lt "$AT" ]; then printf '%s✓ %s%s' "$G" "${STEPS[$i]}" "$N" + elif [ "$i" -eq "$AT" ]; then printf '%s%s%d %s%s' "$A" "$B" $((i + 1)) "${STEPS[$i]}" "$N" + else printf '%s%d %s%s' "$D" $((i + 1)) "${STEPS[$i]}" "$N"; fi + done + printf '\n\n' +} +# A card: title, then lines (each printed after the left edge). +card_open() { printf ' %s╭─%s %s%s%s\n %s│%s\n' "$D" "$N" "$B" "$1" "$N" "$D" "$N"; } +line() { printf ' %s│%s %s\n' "$D" "$N" "$*"; } +note() { printf ' %s│%s %s%s%s\n' "$D" "$N" "$D" "$*" "$N"; } +ok() { printf ' %s│%s %s✓%s %s\n' "$D" "$N" "$G" "$N" "$*"; } +warn() { printf ' %s│%s %s!%s %s\n' "$D" "$N" "$Y" "$N" "$*"; } +bad() { printf ' %s│%s %s✗ %s%s\n' "$D" "$N" "$R" "$*" "$N"; } +card_close() { printf ' %s│%s\n %s╰────────────────────────────────────────────────%s\n' "$D" "$N" "$D" "$N"; [ -n "${1:-}" ] && printf ' %s%s%s\n' "$D" "$1" "$N"; } + +# Pad a label to 10 columns (Chinese characters are two columns wide). +padl() { + local w + w=$(printf '%s' "$1" | LC_ALL=C.UTF-8 wc -L 2>/dev/null || printf '%s' "$1" | wc -m) + printf '%s%*s' "$1" $((10 - w > 1 ? 10 - w : 1)) "" +} + +# ask VAR "標籤" [default] [secret]: once answered, the line is redrawn without the marker. +ask() { + local __v=$1 label=$2 def=${3:-} secret=${4:-} got shown + printf ' %s│%s %s❯%s %s%s%s' "$D" "$N" "$A" "$N" "$A" "$(padl "$label")" "$N" + if [ -n "$secret" ]; then + IFS= read -rs got; printf '\n' + shown=$(printf '%s' "$got" | sed 's/./●/g') + else + IFS= read -r -e -i "$def" got || true + shown=$got + fi + printf '\e[1A\r\e[K %s│%s %s%s%s%s\n' "$D" "$N" "$D" "$(padl "$label")" "$N" "$shown" + printf -v "$__v" '%s' "$got" +} + +# choose VAR "問題" "選項|說明" ... (↑↓ and Enter) +choose() { + local __v=$1 title=$2; shift 2 + local opts=("$@") sel=0 n=$# key rest i + while :; do + header + card_open "$title" + for i in "${!opts[@]}"; do + local name=${opts[$i]%%|*} desc=${opts[$i]#*|} + if [ "$i" -eq "$sel" ]; then line "$A❯ ● $B$name$N"; else line " $D○$N $name"; fi + [ "$desc" != "${opts[$i]}" ] && [ -n "$desc" ] && note " $desc" + line "" + done + card_close "↑↓ 選擇 Enter 確定" + IFS= read -rsn1 key + if [ "$key" = $'\e' ]; then + IFS= read -rsn2 -t 0.05 rest || true + case "$rest" in "[A") sel=$(((sel + n - 1) % n)) ;; "[B") sel=$(((sel + 1) % n)) ;; esac + elif [ "$key" = "k" ]; then sel=$(((sel + n - 1) % n)) + elif [ "$key" = "j" ]; then sel=$(((sel + 1) % n)) + elif [ -z "$key" ]; then break + fi + done + printf -v "$__v" '%s' "$sel" +} + +spin() { # spin "做什麼" command… : runs it, shows a spinner and the newest log line + local what=$1; shift + local log; log=$(mktemp) + "$@" >"$log" 2>&1 & + local pid=$! f=0 c=("⠋" "⠙" "⠹" "⠸" "⠼" "⠴" "⠦" "⠧" "⠇" "⠏") + while kill -0 "$pid" 2>/dev/null; do + local last; last=$(tail -n 1 "$log" | tr -d '\r' | cut -c1-60) + printf '\r\e[K %s│%s %s%s%s %s %s%s%s' "$D" "$N" "$A" "${c[$((f % 10))]}" "$N" "$what" "$D" "$last" "$N" + f=$((f + 1)); sleep 0.15 + done + wait "$pid"; local rc=$? + printf '\r\e[K' + if [ $rc -eq 0 ]; then ok "$what"; else bad "$what 失敗:"; tail -n 8 "$log" | while IFS= read -r l; do note " $l"; done; fi + rm -f "$log" + return $rc +} + +pause() { printf ' %s%s%s' "$D" "${1:-按 Enter 繼續}" "$N"; IFS= read -r _; } +die() { card_close; printf '\n'; exit 1; } + +# ---------- 0. this server ---------- +AT=0; header; card_open "檢查這台伺服器" +FAIL=0 +if command -v docker >/dev/null; then ok "Docker 已安裝"; else bad "沒有 Docker:curl -fsSL https://get.docker.com | sh"; FAIL=1; fi +if docker compose version >/dev/null 2>&1; then ok "docker compose 可以用"; elif [ $FAIL = 0 ]; then bad "沒有 docker compose(Docker 太舊?請更新 Docker)"; FAIL=1; fi +if [ $FAIL = 0 ] && ! docker info >/dev/null 2>&1; then bad "沒有權限使用 Docker:sudo usermod -aG docker \$USER,登出再登入"; FAIL=1; fi +if command -v ss >/dev/null; then + busy=$(ss -ltnH 2>/dev/null | awk '{print $4}' | grep -E ':(80|443)$' | grep -v docker || true) + if [ -n "$busy" ] && ! docker compose ps -q caddy 2>/dev/null | grep -q .; then warn "80 或 443 埠已被別的程式佔用(HTTPS 需要它們)"; else ok "80、443 埠可以用"; fi +fi +mem=$(awk '/MemTotal/ {print int($2/1024/1024+0.5)}' /proc/meminfo 2>/dev/null || echo 0) +[ "${mem:-0}" -ge 2 ] && ok "記憶體 ${mem} GB" || warn "記憶體只有 ${mem:-?} GB(建議 2 GB 以上,第一次建置會比較久)" +disk=$(df -Pk . 2>/dev/null | awk 'NR==2 {print int($4/1024/1024)}') +[ "${disk:-0}" -ge 10 ] && ok "硬碟還有 ${disk} GB" || warn "硬碟只剩 ${disk:-?} GB(建議 10 GB 以上)" +if [ $FAIL = 1 ] && [ $DRY = 0 ]; then note ""; note "修好上面的問題後再執行一次 ./setup.sh"; die; fi +card_close; pause + +# Keep the current settings? +if [ -f .env ] && [ -f Caddyfile ]; then + # shellcheck disable=SC1091 + set -a; . ./.env; set +a + choose KEEP "這台已經設定過了(${ANYBOT_RELAY_PUBLIC_URL:-})" \ + "用現有設定,更新並重新啟動|拉下最新版、重建、重新啟動,帳號和 Bot 都不變" \ + "重新設定|網址、帳號或金鑰要改" + if [ "$KEEP" = 0 ]; then + DOMAIN=${ANYBOT_RELAY_PUBLIC_URL#https://}; USERNAME=$ANYBOT_USER; INVITE=${ANYBOT_RELAY_INVITE:-}; BRAIN=${ANYBOT_HOST_NAME:-} + AT=4; SKIP=1 + fi +fi + +if [ "${SKIP:-0}" != 1 ]; then + # ---------- 1. the address ---------- + AT=1; MSG="" + while :; do + header; card_open "手機和其他電腦用哪個網址連到這台?" + note "網域要先指到這台伺服器(DNS 的 A 紀錄)。HTTPS 憑證會自動申請、自動更新。" + line "" + [ -n "$MSG" ] && { printf '%s\n' "$MSG"; line ""; } + ask DOMAIN "網域" "${DOMAIN:-}" + DOMAIN=${DOMAIN#https://}; DOMAIN=${DOMAIN#http://}; DOMAIN=${DOMAIN%%/*} + if ! printf '%s' "$DOMAIN" | grep -Eq '^[A-Za-z0-9.-]+\.[A-Za-z]{2,}$'; then MSG=$(bad "請輸入像 anybot.example.com 這樣的網域"); continue; fi + ips=$(getent ahosts "$DOMAIN" 2>/dev/null | awk '{print $1}' | sort -u | tr '\n' ' ') + if [ -z "$ips" ]; then + card_close + choose GO "「$DOMAIN」還沒有指到任何位址" "重新輸入|" "仍然繼續|DNS 剛設好、還沒生效時可以先繼續;生效後憑證會自動申請" + [ "$GO" = 0 ] && { MSG=""; continue; } + else + mine=$(hostname -I 2>/dev/null || true) + hit=0; for ip in $ips; do case " $mine " in *" $ip "*) hit=1 ;; esac; done + if [ $hit = 1 ]; then MSG=$(ok "$DOMAIN → $ips(就是這台)"); else MSG=$(warn "$DOMAIN → $ips(這台的位址:${mine:-不知道};在 NAT 或雲端主機後面時這是正常的)"); fi + header; card_open "手機和其他電腦用哪個網址連到這台?"; printf '%s\n' "$MSG"; card_close; sleep 1 + fi + break + done + + # ---------- 2. the account ---------- + AT=2; MSG="" + while :; do + header; card_open "你的 AnyBot 帳號" + note "手機、電腦都用這個帳號登入。第一次啟動時會自動建立。" + line "" + [ -n "$MSG" ] && { printf '%s\n' "$MSG"; line ""; } + ask USERNAME "帳號" "${USERNAME:-}" + if ! printf '%s' "$USERNAME" | grep -Eq '^[A-Za-z0-9._@-]{2,64}$'; then MSG=$(bad "帳號只能用英文字母、數字和 . _ @ -(2–64 個字)"); continue; fi + ask PW1 "密碼" "" secret + ask PW2 "再一次" "" secret + if [ "${#PW1}" -lt 8 ]; then MSG=$(bad "密碼至少 8 個字"); continue; fi + if [ "$PW1" != "$PW2" ]; then MSG=$(bad "兩次密碼不一樣"); continue; fi + case "$PW1" in *"'"*) MSG=$(bad "密碼裡不能有單引號 '"); continue ;; esac + ask BRAIN "大腦名字" "${BRAIN:-雲端大腦}" + [ -z "$BRAIN" ] && BRAIN="雲端大腦" + break + done + choose OTHERS "別人可以在這台註冊帳號嗎?" \ + "只有我|最安全" \ + "給邀請碼的人可以|家人或同事拿邀請碼註冊,各自有自己的帳號和 Bot" + INVITE="" + [ "$OTHERS" = 1 ] && INVITE=$(LC_ALL=C tr -dc 'a-z0-9' .env + chmod 600 .env + umask 022 + sed "s/^anybot.example.com {/$DOMAIN {/" ../../relay/Caddyfile >Caddyfile +fi + +# ---------- 4. start ---------- +AT=4; header; card_open "啟動" +ok "寫好設定(.env、Caddyfile)" +if [ $DRY = 1 ]; then + warn "試跑(--dry-run):沒有啟動。要啟動:docker compose up -d --build" + card_close; exit 0 +fi +if [ "${SKIP:-0}" = 1 ] && [ -d ../../../.git ] && command -v git >/dev/null; then + spin "拉下最新版" git -C ../../.. pull --ff-only || true +fi +spin "建置並啟動(第一次約 5–15 分鐘)" docker compose up -d --build || die + +# The brain joins the account (and makes it, the first time). +f=0; c=("⠋" "⠙" "⠹" "⠸" "⠼" "⠴" "⠦" "⠧" "⠇" "⠏"); joined="" +for _ in $(seq 1 600); do + logs=$(docker compose logs --no-color anybot 2>/dev/null | tail -n 40) + if printf '%s' "$logs" | grep -q "透過帳號"; then joined=1; break; fi + printf '\r\e[K %s│%s %s%s%s 大腦正在登入帳號…' "$D" "$N" "$A" "${c[$((f % 10))]}" "$N"; f=$((f + 1)); sleep 0.3 +done +printf '\r\e[K' +if [ -n "$joined" ]; then + printf '%s' "$logs" | grep -q "已建立帳號" && ok "建立帳號 ${USERNAME}" || ok "大腦「${BRAIN}」已登入帳號" +else + bad "大腦沒有登入帳號。最近的紀錄:" + printf '%s\n' "$logs" | grep "AnyBot:" | tail -n 4 | while IFS= read -r l; do note " ${l#*| }"; done +fi + +# HTTPS from outside (the certificate can take a minute). +https="" +for _ in $(seq 1 120); do + if curl -fsS --max-time 5 "https://$DOMAIN/api/hello" 2>/dev/null | grep -q anybot-relay; then https=1; break; fi + printf '\r\e[K %s│%s %s%s%s 等 HTTPS 憑證…' "$D" "$N" "$A" "${c[$((f % 10))]}" "$N"; f=$((f + 1)); sleep 1 +done +printf '\r\e[K' +if [ -n "$https" ]; then ok "https://$DOMAIN 可以連"; else warn "https://$DOMAIN 還連不到(DNS 還沒生效,或 80/443 埠被防火牆擋住)。看紀錄:docker compose logs caddy"; fi +card_close + +printf '\n' +card_open "$([ -n "$joined" ] && echo "完成了" || echo "差一點")" +line "${B}手機${N} 打開 ${A}https://$DOMAIN${N},用 ${B}$USERNAME${N} 登入,可以「加入主畫面」" +line "${B}電腦${N} AnyBot › 設定 › 手機與帳號 登入,在「大腦」選「$BRAIN」" +line "${B}Linux 手腳${N} anybot setup(選「手腳」,再選「$BRAIN」)" +[ -n "${INVITE:-}" ] && line "${B}邀請碼${N} $INVITE(給要註冊的人)" +line "" +note "看紀錄 docker compose logs -f anybot" +note "更新 ./setup.sh(選「用現有設定」)" +card_close +printf '\n' diff --git a/relay/src/main.rs b/relay/src/main.rs index 8c95480..4d44cb7 100644 --- a/relay/src/main.rs +++ b/relay/src/main.rs @@ -282,12 +282,13 @@ impl Relay { /// Hand a request to a host and wait for its answer. fn forward(&self, host_id: &str, method: &str, path: &str, query: &HashMap, body: &[u8], who: (&str, &str)) -> Resp { - self.forward_raw(host_id, method, path, query, body, false, who, false).into_resp() + self.forward_raw(host_id, method, path, query, body, false, who, false, "").into_resp() } /// `z`: the body is compressed; `z_ok`: the asker reads a compressed answer. #[allow(clippy::too_many_arguments)] - fn forward_raw(&self, host_id: &str, method: &str, path: &str, query: &HashMap, body: &[u8], z: bool, who: (&str, &str), z_ok: bool) -> Answer { + /// `from`: the AnyBot of the account asking (empty for the phone). + fn forward_raw(&self, host_id: &str, method: &str, path: &str, query: &HashMap, body: &[u8], z: bool, who: (&str, &str), z_ok: bool, from: &str) -> Answer { use base64::Engine; if !self.online(host_id) { return Answer::error(503, OFFLINE); @@ -295,7 +296,7 @@ impl Relay { let id = httpd::random_hex(12); let (tx, rx) = channel(); self.waiting.lock().unwrap().insert(id.clone(), tx); - let head = json!({"t": "job", "id": id, "method": method, "path": path, "query": query, "user": who.0, "role": who.1, "z": z, "z_ok": z_ok}); + let head = json!({"t": "job", "id": id, "method": method, "path": path, "query": query, "user": who.0, "role": who.1, "z": z, "z_ok": z_ok, "from": from}); // Connected the lasting way: straight down that connection. let sock = self.sockets.lock().unwrap().get(host_id).map(|s| (s.tx.clone(), s.serial)); if let Some((stx, serial)) = sock { @@ -443,9 +444,9 @@ impl Relay { return; } let query: HashMap = head["query"].as_object().map(|o| o.iter().map(|(k, v)| (k.clone(), v.as_str().unwrap_or("").to_string())).collect()).unwrap_or_default(); - let (me, tx, user, method, z) = (self.clone(), tx.clone(), h.user.clone(), s("method"), head["z"].as_bool().unwrap_or(false)); + let (me, tx, user, method, z, from) = (self.clone(), tx.clone(), h.user.clone(), s("method"), head["z"].as_bool().unwrap_or(false), h.id.clone()); std::thread::spawn(move || { - let a = me.forward_raw(&to, &method, &path, &query, &body, z, (&user, role), true); + let a = me.forward_raw(&to, &method, &path, &query, &body, z, (&user, role), true, &from); let _ = tx.send(wire::pack(&json!({"t": "ret", "id": id, "status": a.status, "ctype": a.ctype, "z": a.z}), &a.body)); }); } @@ -706,7 +707,7 @@ impl Relay { if !same || *target == me.id { return Resp::error(403, "不是同一個帳號的電腦"); } - self.forward(target, "POST", "/api/device/run", &HashMap::new(), &req.body, (&me.user, "device")) + self.forward_raw(target, "POST", "/api/device/run", &HashMap::new(), &req.body, false, (&me.user, "device"), false, &me.id).into_resp() } // 大腦: one AnyBot of the account shows (and edits) another one's bots and chats. (m, ["relay", "hosts", target, "api", "sync", rest @ ..]) => { @@ -716,7 +717,7 @@ impl Relay { return Resp::error(403, "不是同一個帳號的電腦"); } let path = if rest.is_empty() { "/api/sync".to_string() } else { format!("/api/sync/{}", rest.join("/")) }; - self.forward(target, m, &path, &req.query, &req.body, (&me.user, "mirror")) + self.forward_raw(target, m, &path, &req.query, &req.body, false, (&me.user, "mirror"), false, &me.id).into_resp() } // The phone, through to one of the account's AnyBots. (m, ["h", host, rest @ ..]) => { diff --git a/src/app.rs b/src/app.rs index a01eeab..73b5b3c 100644 --- a/src/app.rs +++ b/src/app.rs @@ -3061,7 +3061,16 @@ impl App { return; } let Some((url, user)) = acct.rsplit_once('|') else { return }; - match crate::remote::relay_join(&self.state.remote, url, user, &pw, false, "") { + // ANYBOT_ACCOUNT_CREATE=1 (the server's own brain, first start): make the account if it isn't there. + let mut r = crate::remote::relay_join(&self.state.remote, url, user, &pw, false, ""); + if r.is_err() && std::env::var("ANYBOT_ACCOUNT_CREATE").map(|v| v == "1").unwrap_or(false) { + let invite = std::env::var("ANYBOT_RELAY_INVITE").unwrap_or_default(); + if let Ok(s) = crate::remote::relay_join(&self.state.remote, url, user, &pw, true, &invite) { + eprintln!("AnyBot:已建立帳號 {user}"); + r = Ok(s); + } + } + match r { Ok(s) => { self.state.remote = s; self.dirty = true; diff --git a/src/app/mirror_ui.rs b/src/app/mirror_ui.rs index 685a15d..d4c4dd4 100644 --- a/src/app/mirror_ui.rs +++ b/src/app/mirror_ui.rs @@ -741,6 +741,8 @@ impl App { let mut remote = self.state.remote.clone(); remote.brain_id.clear(); remote.brain_name.clear(); + remote.hands_for.clear(); + remote.hands_for_name.clear(); local.remote = remote; local.theme = self.state.theme; local.accent = self.state.accent; @@ -751,6 +753,8 @@ impl App { } else { self.state.remote.brain_id.clear(); self.state.remote.brain_name.clear(); + self.state.remote.hands_for.clear(); + self.state.remote.hands_for_name.clear(); } self.state.save(); self.dirty = false; @@ -825,6 +829,9 @@ impl App { if self.mir.on { self.mirror_leave(); } + // This computer's hands are for that brain only. + self.state.remote.hands_for = id.clone(); + self.state.remote.hands_for_name = name.clone(); self.state.remote.brain_id = id; self.state.remote.brain_name = name; self.state.save(); diff --git a/src/main.rs b/src/main.rs index 590c12a..2797ff6 100644 --- a/src/main.rs +++ b/src/main.rs @@ -37,6 +37,7 @@ mod preview; mod providers; mod remote; mod routines; +mod setup; mod teach; mod share; mod teams; @@ -218,6 +219,10 @@ fn main() -> eframe::Result<()> { } std::process::exit(0); } + // `anybot setup`: make this computer a brain, or hands for a brain (in a terminal). + if args.get(1).map(String::as_str) == Some("setup") { + std::process::exit(setup::run(&args[2..])); + } if args.get(1).map(String::as_str) == Some("--ask") { let text = args[2..].join(" "); let ctx = egui::Context::default(); diff --git a/src/remote.rs b/src/remote.rs index 6bf32df..a40e20a 100644 --- a/src/remote.rs +++ b/src/remote.rs @@ -78,11 +78,16 @@ pub struct Settings { pub brain_id: String, #[serde(default, skip_serializing_if = "String::is_empty")] pub brain_name: String, + /// 手腳: only this brain (host id) may use this computer (empty = any AnyBot of the account). + #[serde(default, skip_serializing_if = "String::is_empty")] + pub hands_for: String, + #[serde(default, skip_serializing_if = "String::is_empty")] + pub hands_for_name: String, } impl Default for Settings { fn default() -> Self { - Settings { lan: false, password: String::new(), devices: vec![], mac_from_phone: false, device_link: false, relay_url: String::new(), relay_user: String::new(), host_id: String::new(), host_token: String::new(), host_name: default_host_name(), backup_key: String::new(), auto_backup: true, last_backup: 0, brain_id: String::new(), brain_name: String::new() } + Settings { lan: false, password: String::new(), devices: vec![], mac_from_phone: false, device_link: false, relay_url: String::new(), relay_user: String::new(), host_id: String::new(), host_token: String::new(), host_name: default_host_name(), backup_key: String::new(), auto_backup: true, last_backup: 0, brain_id: String::new(), brain_name: String::new(), hands_for: String::new(), hands_for_name: String::new() } } } @@ -370,7 +375,7 @@ pub fn handle(req: Req, via_relay: bool) -> Resp { } ("GET", ["api", "screen", which]) => screen(which, &s), ("POST", ["api", "screen", which]) => screen_input(which, &req.json(), &s), - ("POST", ["api", "device", "run"]) => device_run(&req.json(), &s, via_relay), + ("POST", ["api", "device", "run"]) => device_run(&req.json(), &s, via_relay, req.header("x-anybot-from")), ("GET", ["api", "sync"]) => { let since: u64 = req.query.get("since").and_then(|v| v.parse().ok()).unwrap_or(0); let client = req.query.get("client").cloned().unwrap_or_default(); @@ -573,10 +578,13 @@ fn screen_input(which: &str, v: &Value, s: &Settings) -> Resp { /// 裝置連結: another AnyBot in this account (e.g. the one on the server) runs a screen tool on /// this Mac. Only through the relay, and only when this Mac allows it. -fn device_run(v: &Value, s: &Settings, via_relay: bool) -> Resp { +fn device_run(v: &Value, s: &Settings, via_relay: bool, from: &str) -> Resp { if !via_relay || !s.device_link { return Resp::error(403, "這台電腦沒有開放給帳號裡的其他 AnyBot 操作"); } + if !s.hands_for.is_empty() && from != s.hands_for { + return Resp::error(403, &format!("這台電腦只讓大腦「{}」使用", s.hands_for_name)); + } let tool = v["tool"].as_str().unwrap_or(""); let args = v["args"].as_str().unwrap_or("{}"); let who = v["who"].as_str().unwrap_or("遠端 AnyBot"); @@ -843,7 +851,7 @@ pub fn req_from_json(job: &Value) -> Req { path: job["path"].as_str().unwrap_or("/").to_string(), query: job["query"].as_object().map(|o| o.iter().map(|(k, v)| (k.clone(), v.as_str().unwrap_or("").to_string())).collect()).unwrap_or_default(), // Who asked, as the relay vouches for it (團隊: owner / admin / member; "device" for 裝置連結). - headers: [("x-anybot-user", job["user"].as_str().unwrap_or("")), ("x-anybot-role", job["role"].as_str().unwrap_or("owner"))].iter().map(|(k, v)| (k.to_string(), v.to_string())).collect(), + headers: [("x-anybot-user", job["user"].as_str().unwrap_or("")), ("x-anybot-role", job["role"].as_str().unwrap_or("owner")), ("x-anybot-from", job["from"].as_str().unwrap_or(""))].iter().map(|(k, v)| (k.to_string(), v.to_string())).collect(), body: { let b = base64::engine::general_purpose::STANDARD.decode(job["body"].as_str().unwrap_or("")).unwrap_or_default(); if job["z"].as_bool().unwrap_or(false) { @@ -1082,6 +1090,36 @@ pub fn set_member(s: &Settings, user: &str, role: &str) -> Result<(), String> { } /// The other AnyBots signed in to this account (for 裝置連結). +/// The account's AnyBots as the account server sees them (for choosing a brain). +pub fn peers(s: &Settings) -> Result, String> { + let mut r = agent(15).get(&format!("{}/relay/host/peers", s.relay_url)).header("Authorization", &format!("Bearer {}", s.host_token)).call().map_err(|e| format!("連不到帳號伺服器:{}", why_unreachable(&e)))?; + let st = r.status().as_u16(); + let v: Value = serde_json::from_str(&r.body_mut().read_to_string().unwrap_or_default()).unwrap_or(Value::Null); + if !(200..300).contains(&st) { + return Err(why_refused(st, &v, "帳號伺服器")); + } + Ok(v["hosts"].as_array().cloned().unwrap_or_default()) +} + +/// Is this an AnyBot account server? (Gives the address to use.) +pub fn check_server(url: &str) -> Result { + let mut u = url.trim().trim_end_matches('/').to_string(); + if u.is_empty() { + return Err("請輸入帳號伺服器的網址".into()); + } + if !u.contains("://") { + let host = u.split(['/', ':']).next().unwrap_or(""); + let plain = host == "localhost" || host.parse::().is_ok() || !host.contains('.'); + u = format!("{}{u}", if plain { "http://" } else { "https://" }); + } + let mut r = agent(15).get(&format!("{u}/api/hello")).call().map_err(|e| format!("連不到 {u}:{}", why_unreachable(&e)))?; + let v: Value = serde_json::from_str(&r.body_mut().read_to_string().unwrap_or_default()).unwrap_or(Value::Null); + if v["app"] != "anybot-relay" { + return Err(format!("{u} 有回應,但不是 AnyBot 帳號伺服器(網址打錯了?)")); + } + Ok(u) +} + pub fn devices_online(s: &Settings) -> Vec<(String, String, bool)> { if s.relay_url.is_empty() { return vec![]; diff --git a/src/setup.rs b/src/setup.rs new file mode 100644 index 0000000..4dac0f4 --- /dev/null +++ b/src/setup.rs @@ -0,0 +1,1199 @@ +// `anybot setup`: a guided setup in the terminal, for computers set up over SSH or without a +// window (a server, a Linux box). Five short steps: what this computer is (大腦 or 手腳), its +// name, the account server, the account, and — for hands — which brain may use it, picked from +// the account's AnyBots as they are right now. Then it saves the settings, makes AnyBot start with +// the computer (systemd), and waits until the account server and the brain see it. +// +// Without a terminal (a script), the same can be given as flags: +// anybot setup --role hands --name 書房電腦 --server https://anybot.example.com --user me +// --brain 雲端大腦 [--create] [--invite 碼] [--no-service] +// with the password in ANYBOT_ACCOUNT_PASSWORD (or on stdin with --password-stdin). + +use crate::model::State; +use crate::remote::{self, Settings}; +use crossterm::event::{self, Event, KeyCode, KeyEventKind, KeyModifiers}; +use crossterm::style::{Attribute, Color, Print, ResetColor, SetAttribute, SetForegroundColor}; +use crossterm::{cursor, execute, queue, terminal}; +use std::io::{IsTerminal, Write}; +use std::sync::mpsc::{channel, Receiver, Sender}; +use std::time::{Duration, Instant}; +use unicode_width::{UnicodeWidthChar, UnicodeWidthStr}; + +const ACC: Color = Color::Cyan; +const GOOD: Color = Color::Green; +const WARN: Color = Color::Yellow; +const BAD: Color = Color::Red; +const DIM: Color = Color::DarkGrey; +const SPIN: [&str; 10] = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; +const UNIT: &str = "anybot.service"; + +#[derive(Clone, Copy, PartialEq, Debug)] +enum Role { + Hands, + Brain, +} + +#[derive(Clone, Copy, PartialEq, Debug)] +enum Step { + Role, + Name, + Server, + Account, + Brain, + Confirm, + Install, + Done, +} + +#[derive(Clone, Debug)] +struct Peer { + id: String, + name: String, + online: bool, + last: String, +} + +#[derive(Clone, Copy, PartialEq, Debug)] +enum Mark { + Wait, + Run, + Ok, + Warn, + Fail, +} + +enum Msg { + Server(Result), + Joined(Result), + Peers(Result, String>), + Progress(usize, Mark, String), + Finished(Vec), +} + +/// What this computer has. +#[derive(Clone)] +struct Env { + linux: bool, + display: String, + systemd: bool, + xvfb: bool, +} + +impl Env { + fn detect() -> Env { + let var = |k: &str| std::env::var(k).ok().filter(|v| !v.is_empty()); + // "wayland" when only Wayland is there (nothing to remember for a service). + let linux = cfg!(target_os = "linux"); + // A Mac or Windows PC always has its screen. + let display = if !linux { "desktop".to_string() } else { var("DISPLAY").or_else(|| var("WAYLAND_DISPLAY").map(|_| "wayland".to_string())).unwrap_or_default() }; + let systemd = linux + && std::process::Command::new("systemctl") + .args(["--user", "is-system-running"]) + .output() + .map(|o| { + let out = String::from_utf8_lossy(&o.stdout); + !out.trim().is_empty() && !out.contains("offline") + }) + .unwrap_or(false); + Env { linux, display, systemd, xvfb: which("xvfb-run") } + } + fn has_display(&self) -> bool { + !self.display.is_empty() + } + fn summary(&self) -> String { + let os = if self.linux { + "Linux" + } else if cfg!(target_os = "macos") { + "macOS" + } else { + "Windows" + }; + let screen = if self.has_display() { + "有螢幕" + } else if self.xvfb { + "沒有螢幕(會開一個虛擬螢幕)" + } else { + "沒有螢幕" + }; + let service = if self.systemd { "會設成開機自動執行" } else { "沒有 systemd(要自己啟動)" }; + format!("這台:{os} · {screen} · {service}") + } +} + +fn which(cmd: &str) -> bool { + std::env::var_os("PATH").map(|p| std::env::split_paths(&p).any(|d| d.join(cmd).is_file())).unwrap_or(false) +} + +// ---------- drawing ---------- + +#[derive(Clone, Copy, Default)] +struct Sty { + fg: Option, + 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, + sel: usize, + name: String, + url: String, + user: String, + pass: String, + invite: String, + create: bool, + focus: usize, + server: String, + joined: Option, + peers: Vec, + 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, + saved: Settings, +} + +impl Wiz { + fn new() -> Wiz { + let st = State::load(); + let saved = st.remote.clone(); + let (tx, rx) = channel(); + let env = Env::detect(); + Wiz { + role: if saved.device_link || !env.has_display() { Role::Hands } else { Role::Brain }, + sel: if saved.device_link || !env.has_display() { 0 } else { 1 }, + step: Step::Role, + name: if saved.host_name.is_empty() { remote::default_host_name() } else { saved.host_name.clone() }, + url: saved.relay_url.clone(), + user: saved.relay_user.clone(), + pass: String::new(), + invite: String::new(), + create: false, + focus: if saved.relay_user.is_empty() { 0 } else { 1 }, + server: String::new(), + joined: None, + peers: vec![], + brain: None, + busy: None, + rx: Some(rx), + tx, + spin: 0, + error: String::new(), + progress: vec![], + tips: vec![], + ok: false, + quit: false, + service: env.systemd, + saved, + env, + } + } + + fn crumbs(&self) -> Vec<&'static str> { + let role = if self.step == Step::Role { if self.sel == 0 { Role::Hands } else { Role::Brain } } else { self.role }; + match role { + Role::Hands => vec!["角色", "名字", "帳號", "大腦", "完成"], + Role::Brain => vec!["角色", "名字", "帳號", "完成"], + } + } + + fn crumb_at(&self) -> usize { + let last = self.crumbs().len() - 1; + match self.step { + Step::Role => 0, + Step::Name => 1, + Step::Server | Step::Account => 2, + Step::Brain => 3, + Step::Confirm | Step::Install | Step::Done => last, + } + } + + // ----- what each step shows ----- + + fn title(&self) -> (&'static str, String) { + match self.step { + Step::Role => ("這台電腦要做什麼?", "之後隨時可以再執行 anybot setup 改。".into()), + Step::Name => ("這台電腦叫什麼?", "手機、大腦和其他電腦上看到的名字。".into()), + Step::Server => ("帳號伺服器在哪裡?", "就是手機登入 AnyBot 的那個網址,例如 https://anybot.example.com".into()), + Step::Account => ("登入帳號", "和手機、大腦用同一個帳號。密碼只用來登入,不會存在這台電腦上。".into()), + Step::Brain => ("要當哪個大腦的手腳?", "只有選的大腦能使用這台電腦;它的 Bot 每個動作仍照大腦的規則先問你。".into()), + Step::Confirm => ("確認一下", "按 Enter 開始設定。".into()), + Step::Install => (if self.ok { "設定好了" } else { "正在設定…" }, String::new()), + Step::Done => (if self.ok { "設定好了" } else { "沒有完成" }, String::new()), + } + } + + fn field(&self, label: &str, value: &str, focused: bool, secret: bool, hint: &str) -> Line { + let shown = if secret { "●".repeat(value.chars().count()) } else { value.to_string() }; + let mut l = vec![if focused { colored("❯ ", ACC) } else { plain(" ") }, sp(format!("{label}{}", " ".repeat(8usize.saturating_sub(label.width()))), Some(if focused { ACC } else { DIM }), focused)]; + if shown.is_empty() && !focused { + l.push(dim(hint)); + } else { + l.push(strong(shown)); + if focused { + l.push(colored("▌", ACC)); + if value.is_empty() && !hint.is_empty() { + l.push(dim(format!(" {hint}"))); + } + } + } + l + } + + fn body(&self, w: usize) -> Vec { + let mut b: Vec = vec![]; + match self.step { + Step::Role => { + let opts = [ + ("手腳", "讓大腦的 Bot 使用這台電腦:看畫面、點擊打字、檔案和指令、這台的瀏覽器。"), + ("大腦", "這台保存所有 Bot、聊天、記憶和排程,一直開著;其他電腦和手機都連到它。"), + ]; + for (i, (name, desc)) in opts.iter().enumerate() { + let on = self.sel == i; + b.push(vec![if on { colored("❯ ", ACC) } else { plain(" ") }, sp(if on { "● " } else { "○ " }, Some(if on { ACC } else { DIM }), false), sp(*name, if on { Some(ACC) } else { None }, true)]); + b.extend(para(desc, 6, w, dim)); + b.push(vec![]); + } + b.push(vec![dim(self.env.summary())]); + } + Step::Name => b.push(self.field("名字", &self.name, true, false, "")), + Step::Server => b.push(self.field("網址", &self.url, true, false, "https://…")), + Step::Account => { + let reuse = self.can_reuse(); + b.push(self.field("帳號", &self.user, self.focus == 0, false, "帳號名稱")); + b.push(self.field("密碼", &self.pass, self.focus == 1, true, if reuse { "已登入過,留空直接沿用" } else { "" })); + b.push(vec![ + if self.focus == 2 { colored("❯ ", ACC) } else { plain(" ") }, + sp(if self.create { "■ " } else { "□ " }, Some(if self.focus == 2 { ACC } else { DIM }), false), + sp("這是新帳號(第一次用這台伺服器)", if self.focus == 2 { Some(ACC) } else { None }, false), + dim(if self.focus == 2 { " 空白鍵切換" } else { "" }), + ]); + if self.create { + b.push(self.field("邀請碼", &self.invite, self.focus == 3, false, "伺服器管理員給的(第一個帳號不用)")); + } + } + Step::Brain => { + if self.peers.is_empty() && self.busy.is_none() { + b.extend(para("帳號裡還沒有其他 AnyBot。先把大腦裝好(伺服器上執行 deploy/server/setup.sh,或在一台電腦上開 AnyBot 登入帳號),再按 r 重新整理。", 0, w, plain)); + b.push(vec![]); + } + for (i, p) in self.peers.iter().enumerate() { + let on = self.sel == i; + let status = if p.online { colored("在線上", GOOD) } else { dim(format!("離線({})", p.last)) }; + let name = format!("{} ", p.name); + let pad = 28usize.saturating_sub(name.width()); + b.push(vec![if on { colored("❯ ", ACC) } else { plain(" ") }, sp(if p.online { "● " } else { "○ " }, Some(if p.online { GOOD } else { DIM }), false), sp(name, if on { Some(ACC) } else { None }, true), plain(" ".repeat(pad)), status]); + } + let i = self.peers.len(); + let on = self.sel == i; + if !self.peers.is_empty() { + b.push(vec![]); + } + b.push(vec![if on { colored("❯ ", ACC) } else { plain(" ") }, sp("○ ", Some(DIM), false), sp("不指定", if on { Some(ACC) } else { None }, true)]); + b.extend(para("帳號裡任何一台 AnyBot 都能使用這台電腦。", 6, w, dim)); + } + Step::Confirm => { + let row = |k: &str, v: String| vec![dim(format!("{k}{}", " ".repeat(14usize.saturating_sub(k.width())))), strong(v)]; + b.push(row("這台是", match self.role { Role::Hands => "手腳".into(), Role::Brain => "大腦".into() })); + b.push(row("名字", self.name.clone())); + b.push(row("帳號伺服器", self.server.clone())); + b.push(row("帳號", self.user.clone())); + if self.role == Role::Hands { + b.push(row("給哪個大腦用", self.brain.as_ref().map(|p| p.name.clone()).unwrap_or_else(|| "不指定(帳號裡任何一台)".into()))); + let screen = if self.env.has_display() { + "用這台的螢幕".to_string() + } else if self.env.xvfb { + "開一個 1440×900 的虛擬螢幕".to_string() + } else { + "沒有螢幕:只能用檔案和指令".to_string() + }; + b.push(row("螢幕", screen)); + } + b.push(row("開機自動執行", if self.service { "會(systemd 使用者服務 anybot)".into() } else if self.env.linux { "不會(這台沒有 systemd)".into() } else { "不會(請開 AnyBot App)".into() })); + if self.role == Role::Hands && !self.env.has_display() && !self.env.xvfb && self.env.linux { + b.push(vec![]); + b.extend(para("想讓 Bot 看這台的畫面、操作程式:先執行 ./deploy/linux/hands.sh --deps 裝好虛擬螢幕,再回來。", 0, w, |s| colored(s, WARN))); + } + } + Step::Install | Step::Done => { + for (label, mark, note) in &self.progress { + let icon = match mark { + Mark::Wait => dim("·"), + Mark::Run => colored(SPIN[self.spin % SPIN.len()], ACC), + Mark::Ok => colored("✓", GOOD), + Mark::Warn => colored("!", WARN), + Mark::Fail => colored("✗", BAD), + }; + b.push(vec![icon, plain(" "), sp(label.clone(), if *mark == Mark::Wait { Some(DIM) } else { None }, *mark == Mark::Run)]); + if !note.is_empty() { + let c = match mark { + Mark::Fail => BAD, + Mark::Warn => WARN, + _ => DIM, + }; + b.extend(para(note, 2, w, if c == BAD { |s| colored(s, BAD) } else if c == WARN { |s| colored(s, WARN) } else { dim })); + } + } + if !self.tips.is_empty() { + b.push(vec![]); + for t in &self.tips { + if let Some((k, v)) = t.split_once('\t') { + b.push(vec![dim(format!("{k}{}", " ".repeat(10usize.saturating_sub(k.width())))), colored(v.to_string(), ACC)]); + } else { + b.extend(para(t, 0, w, plain)); + } + } + } + } + } + if let Some(what) = &self.busy { + b.push(vec![]); + b.push(vec![colored(SPIN[self.spin % SPIN.len()], ACC), plain(" "), colored(what.clone(), ACC)]); + } + if !self.error.is_empty() { + b.push(vec![]); + for l in wrap(&self.error, w.saturating_sub(2)) { + b.push(vec![colored(if b.last().map(|x| x.is_empty()).unwrap_or(true) { "✗ " } else { " " }, BAD), colored(l, BAD)]); + } + } + b + } + + fn keys(&self) -> &'static str { + if self.busy.is_some() { + return "Ctrl+C 離開"; + } + match self.step { + Step::Brain => "↑↓ 選擇 Enter 確定 r 重新整理 Esc 上一步", + Step::Role => "↑↓ 選擇 Enter 下一步 Esc 離開", + Step::Account => "↑↓ 換欄位 Enter 登入 Esc 上一步", + Step::Confirm => "Enter 開始設定 Esc 上一步", + Step::Install => "Ctrl+C 離開", + Step::Done => "Enter 離開", + _ => "Enter 下一步 Esc 上一步", + } + } + + fn render(&self, out: &mut impl Write) -> std::io::Result<()> { + let (cols, rows) = terminal::size().unwrap_or((80, 24)); + let cols = cols as usize; + let w = cols.saturating_sub(4).clamp(36, 78); + let inner = w - 4; + let mut lines: Vec = vec![vec![]]; + // Header: the name, and where we are. + let mut head = vec![plain(" "), colored("◆ ", ACC), strong("AnyBot 設定")]; + let at = self.crumb_at(); + let mut crumbs: Line = vec![plain(" ")]; + for (i, c) in self.crumbs().iter().enumerate() { + if i > 0 { + crumbs.push(dim(" ─ ")); + } + let done = i < at || (self.step == Step::Done && self.ok); + crumbs.push(if done { + colored(format!("✓ {c}"), GOOD) + } else if i == at { + sp(format!("{} {c}", i + 1), Some(ACC), true) + } else { + dim(format!("{} {c}", i + 1)) + }); + } + if width(&head) + width(&crumbs) + 4 <= w + 2 { + let gap = (w + 2).saturating_sub(width(&head) + width(&crumbs)); + head.push(plain(" ".repeat(gap))); + head.extend(crumbs.into_iter().skip(1)); + lines.push(head); + } else { + lines.push(head); + lines.push(crumbs); + } + lines.push(vec![]); + // The card. + let (title, sub) = self.title(); + let mut card: Vec = vec![vec![], vec![strong(title)]]; + if !sub.is_empty() { + card.extend(para(&sub, 0, inner, dim)); + } + card.push(vec![]); + card.extend(self.body(inner)); + card.push(vec![]); + let edge = |l: &str, r: &str| vec![plain(" "), dim(format!("{l}{}{r}", "─".repeat(w - 2)))]; + lines.push(edge("╭", "╮")); + for l in card { + let l = clip(l, inner); + let pad = inner.saturating_sub(width(&l)); + let mut row = vec![plain(" "), dim("│ ")]; + row.extend(l); + row.push(plain(" ".repeat(pad))); + row.push(dim(" │")); + lines.push(row); + } + lines.push(edge("╰", "╯")); + lines.push(vec![plain(" "), dim(self.keys())]); + + queue!(out, terminal::BeginSynchronizedUpdate, terminal::Clear(terminal::ClearType::All))?; + for (y, l) in lines.into_iter().enumerate().take(rows as usize) { + queue!(out, cursor::MoveTo(0, y as u16))?; + for (s, st) in clip(l, cols) { + if let Some(c) = st.fg { + queue!(out, SetForegroundColor(c))?; + } + if st.bold { + queue!(out, SetAttribute(Attribute::Bold))?; + } + queue!(out, Print(s), SetAttribute(Attribute::Reset), ResetColor)?; + } + } + queue!(out, terminal::EndSynchronizedUpdate)?; + out.flush() + } + + // ----- moving through the steps ----- + + fn can_reuse(&self) -> bool { + let s = &self.saved; + !s.host_token.is_empty() && s.relay_url == self.server && s.relay_user == self.user.trim() && s.host_name == self.name.trim() + } + + fn start(&mut self, what: &str, job: impl FnOnce() -> Msg + Send + 'static) { + self.error.clear(); + self.busy = Some(what.to_string()); + let tx = self.tx.clone(); + std::thread::spawn(move || { + let _ = tx.send(job()); + }); + } + + fn load_peers(&mut self) { + let Some(s) = self.joined.clone() else { return }; + self.start("正在找帳號裡的大腦…", move || Msg::Peers(list_brains(&s))); + } + + fn on_msg(&mut self, m: Msg) { + match m { + Msg::Server(r) => { + self.busy = None; + match r { + Ok(u) => { + self.server = u.clone(); + self.url = u; + self.focus = if self.user.is_empty() { 0 } else { 1 }; + self.step = Step::Account; + } + Err(e) => self.error = e, + } + } + Msg::Joined(r) => { + self.busy = None; + match r { + Ok(s) => { + self.joined = Some(s); + self.pass.clear(); + match self.role { + Role::Hands => { + self.step = Step::Brain; + self.load_peers(); + } + Role::Brain => self.step = Step::Confirm, + } + } + Err(e) => self.error = e, + } + } + Msg::Peers(r) => { + self.busy = None; + match r { + Ok(p) => { + let want = if self.saved.hands_for.is_empty() { None } else { p.iter().position(|x| x.id == self.saved.hands_for) }; + self.sel = want.or_else(|| p.iter().position(|x| x.online)).unwrap_or(0); + self.peers = p; + } + Err(e) => self.error = e, + } + } + Msg::Progress(i, mark, note) => { + if let Some(p) = self.progress.get_mut(i) { + p.1 = mark; + p.2 = note; + } + } + Msg::Finished(tips) => { + self.ok = !self.progress.iter().any(|p| p.1 == Mark::Fail); + self.tips = tips; + self.step = Step::Done; + } + } + } + + fn back(&mut self) { + self.error.clear(); + self.step = match self.step { + Step::Role => { + self.quit = true; + Step::Role + } + Step::Name => Step::Role, + Step::Server => Step::Name, + Step::Account => Step::Server, + Step::Brain => Step::Account, + Step::Confirm if self.role == Role::Hands => Step::Brain, + Step::Confirm => Step::Account, + s => s, + }; + if self.step == Step::Role { + self.sel = if self.role == Role::Hands { 0 } else { 1 }; + } + } + + fn enter(&mut self) { + self.error.clear(); + match self.step { + Step::Role => { + self.role = if self.sel == 0 { Role::Hands } else { Role::Brain }; + self.step = Step::Name; + } + Step::Name => { + let n = self.name.trim().to_string(); + if n.is_empty() || n.chars().count() > 60 { + self.error = "名字要 1 到 60 個字".into(); + return; + } + self.name = n; + self.step = Step::Server; + } + Step::Server => { + let u = self.url.clone(); + self.start("正在找帳號伺服器…", move || Msg::Server(remote::check_server(&u))); + } + Step::Account => { + // Enter on the name goes to the password first. + if self.focus == 0 && self.pass.is_empty() && !self.can_reuse() { + self.focus = 1; + return; + } + let user = self.user.trim().to_string(); + if user.is_empty() { + self.error = "請輸入帳號".into(); + self.focus = 0; + return; + } + if self.pass.is_empty() { + if self.can_reuse() { + self.joined = Some(self.saved.clone()); + self.on_msg(Msg::Joined(Ok(self.saved.clone()))); + return; + } + self.error = "請輸入密碼".into(); + self.focus = 1; + return; + } + let mut base = self.saved.clone(); + base.host_name = self.name.clone(); + let (url, pass, create, invite) = (self.server.clone(), self.pass.clone(), self.create, self.invite.trim().to_string()); + self.start(if create { "正在建立帳號…" } else { "正在登入…" }, move || Msg::Joined(remote::relay_join(&base, &url, &user, &pass, create, &invite))); + } + Step::Brain => { + self.brain = self.peers.get(self.sel).cloned(); + self.step = Step::Confirm; + } + Step::Confirm => { + self.step = Step::Install; + let plan = Plan { role: self.role, settings: self.joined.clone().unwrap_or_default(), brain: self.brain.clone(), env: self.env.clone(), service: self.service }; + self.progress = plan.steps().into_iter().map(|s| (s, Mark::Wait, String::new())).collect(); + let tx = self.tx.clone(); + std::thread::spawn(move || plan.run(&tx)); + } + Step::Install => {} + Step::Done => self.quit = true, + } + } + + fn key(&mut self, code: KeyCode, mods: KeyModifiers) { + if mods.contains(KeyModifiers::CONTROL) && matches!(code, KeyCode::Char('c') | KeyCode::Char('d')) { + self.quit = true; + return; + } + if self.busy.is_some() || self.step == Step::Install { + return; + } + let rows = match self.step { + Step::Role => 2, + Step::Brain => self.peers.len() + 1, + Step::Account => { + if self.create { + 4 + } else { + 3 + } + } + _ => 0, + }; + match code { + KeyCode::Esc => self.back(), + KeyCode::Enter => self.enter(), + KeyCode::Up | KeyCode::BackTab if rows > 0 => { + if self.step == Step::Account { + self.focus = (self.focus + rows - 1) % rows; + } else { + self.sel = (self.sel + rows - 1) % rows; + } + } + KeyCode::Down | KeyCode::Tab if rows > 0 => { + if self.step == Step::Account { + self.focus = (self.focus + 1) % rows; + } else { + self.sel = (self.sel + 1) % rows; + } + } + KeyCode::Char('r') if self.step == Step::Brain => self.load_peers(), + KeyCode::Char(' ') if self.step == Step::Account && self.focus == 2 => { + self.create = !self.create; + if !self.create && self.focus > 2 { + self.focus = 2; + } + } + KeyCode::Char(c) if !mods.contains(KeyModifiers::CONTROL) => self.type_text(&c.to_string()), + KeyCode::Char('u') if mods.contains(KeyModifiers::CONTROL) => { + if let Some(f) = self.text_field() { + f.clear(); + } + } + KeyCode::Backspace => { + if let Some(f) = self.text_field() { + f.pop(); + } + } + _ => {} + } + } + + fn text_field(&mut self) -> Option<&mut String> { + match (self.step, self.focus) { + (Step::Name, _) => Some(&mut self.name), + (Step::Server, _) => Some(&mut self.url), + (Step::Account, 0) => Some(&mut self.user), + (Step::Account, 1) => Some(&mut self.pass), + (Step::Account, 3) => Some(&mut self.invite), + _ => None, + } + } + + fn type_text(&mut self, s: &str) { + let s: String = s.chars().filter(|c| !c.is_control()).collect(); + if let Some(f) = self.text_field() { + f.push_str(&s); + } + } +} + +/// The account's AnyBots that can be a brain (not this one, not one showing another's bots). +fn list_brains(s: &Settings) -> Result, 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()) + .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]); + } + let mut st = State::load(); + let mut r = self.settings.clone(); + match self.role { + Role::Hands => { + r.device_link = true; + match &self.brain { + Some(b) => { + r.hands_for = b.id.clone(); + r.hands_for_name = b.name.clone(); + } + None => { + r.hands_for.clear(); + r.hands_for_name.clear(); + } + } + } + Role::Brain => { + // A brain shows its own bots and lends its hands to no one. + r.brain_id.clear(); + r.brain_name.clear(); + r.hands_for.clear(); + r.hands_for_name.clear(); + r.device_link = false; + } + } + st.remote = r.clone(); + st.save(); + say(0, Mark::Ok, &format!("存在 {}", State::path().display())); + + // 2. Start with the computer. + say(1, Mark::Run, ""); + let started = if !self.env.linux { + say(1, Mark::Warn, "Mac 和 Windows:打開 AnyBot App 就會用這些設定連上(設定 › 手機與帳號)。"); + false + } else if !self.service { + say(1, Mark::Warn, "這台沒有 systemd:請用「anybot --serve」啟動,或把它加到開機執行。"); + false + } else { + match self.install_service() { + Ok(note) => { + say(1, Mark::Ok, ¬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"); + std::fs::write(&launcher, launcher_script(self.role, &exe, &self.env)).map_err(|e| format!("寫入 {} 失敗:{e}", launcher.display()))?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let _ = std::fs::set_permissions(&launcher, std::fs::Permissions::from_mode(0o755)); + } + let units = home.join(".config/systemd/user"); + std::fs::create_dir_all(&units).map_err(|e| e.to_string())?; + // An older setup (deploy/linux/hands.sh) used another service name. + if units.join("anybot-hands.service").exists() { + let _ = systemctl(&["disable", "--now", "anybot-hands.service"]); + let _ = std::fs::remove_file(units.join("anybot-hands.service")); + } + let what = match self.role { + Role::Hands => "手腳", + Role::Brain => "大腦", + }; + let unit = format!( + "[Unit]\nDescription=AnyBot({what})\nAfter=network-online.target graphical-session.target\nWants=network-online.target\n\n[Service]\nExecStart={}\nRestart=always\nRestartSec=5\n\n[Install]\nWantedBy=default.target\n", + launcher.display() + ); + std::fs::write(units.join(UNIT), unit).map_err(|e| e.to_string())?; + systemctl(&["daemon-reload"])?; + systemctl(&["enable", UNIT])?; + systemctl(&["restart", UNIT])?; + Ok(format!("systemd 使用者服務「anybot」,已啟動({})", launcher.display())) + } +} + +fn launcher_script(role: Role, exe: &std::path::Path, env: &Env) -> String { + let q = |s: &str| format!("'{}'", s.replace('\'', "'\\''")); + let mut s = String::from("#!/bin/sh\n# Started by systemd (anybot.service); written by `anybot setup`.\n"); + s += "export LANG=\"${LANG:-C.UTF-8}\"\n"; + if let Ok(d) = std::env::var("ANYBOT_DATA_DIR") { + s += &format!("export ANYBOT_DATA_DIR={}\n", q(&d)); + } + let bin = q(&exe.display().to_string()); + if role == Role::Hands { + s += "# The accessibility bus, so bots can read apps' buttons instead of only looking at pixels.\n"; + s += "export NO_AT_BRIDGE=0 GTK_A11Y=atspi QT_LINUX_ACCESSIBILITY_ALWAYS_ON=1\n"; + if env.has_display() && env.display != "wayland" && env.display != "desktop" { + s += &format!("# The screen this computer had when it was set up.\nexport DISPLAY=\"${{DISPLAY:-{}}}\"\n", env.display); + if let Ok(x) = std::env::var("XAUTHORITY") { + s += &format!("export XAUTHORITY=\"${{XAUTHORITY:-{x}}}\"\n"); + } + } + s += "if [ -z \"$DISPLAY\" ] && [ -z \"$WAYLAND_DISPLAY\" ] && command -v xvfb-run >/dev/null; then\n"; + s += " # No screen: a virtual one, a window manager, and a session bus for the accessibility bus.\n"; + s += &format!(" exec xvfb-run -a -s \"-screen 0 1440x900x24\" dbus-run-session -- sh -c 'command -v openbox >/dev/null && (openbox >/dev/null 2>&1 &); exec \"$0\" --serve' {bin}\n"); + s += "fi\n"; + } + s += &format!("exec {bin} --serve\n"); + s +} + +fn systemctl(args: &[&str]) -> Result<(), String> { + let o = std::process::Command::new("systemctl").arg("--user").args(args).output().map_err(|e| format!("執行 systemctl 失敗:{e}"))?; + if o.status.success() { + Ok(()) + } else { + Err(format!("systemctl {} 失敗:{}", args.join(" "), String::from_utf8_lossy(&o.stderr).trim())) + } +} + +fn lingering() -> bool { + let user = std::env::var("USER").unwrap_or_default(); + std::process::Command::new("loginctl").args(["show-user", &user, "-p", "Linger"]).output().map(|o| String::from_utf8_lossy(&o.stdout).contains("Linger=yes")).unwrap_or(true) +} + +// ---------- entry ---------- + +pub fn run(args: &[String]) -> i32 { + let flag = |k: &str| args.iter().position(|a| a == k).and_then(|i| args.get(i + 1)).cloned(); + if args.iter().any(|a| a == "-h" || a == "--help") { + println!("anybot setup:把這台設成大腦,或大腦的手腳。\n\n在終端機裡直接執行會一步一步問你。給程式用:\n anybot setup --role hands|brain --name 名字 --server 網址 --user 帳號 [--brain 大腦名字|any]\n [--create] [--invite 碼] [--no-service] [--password-stdin]\n 密碼放在 ANYBOT_ACCOUNT_PASSWORD,或加 --password-stdin 從標準輸入讀。"); + return 0; + } + if flag("--role").is_some() || !std::io::stdin().is_terminal() || !std::io::stdout().is_terminal() { + return run_plain(args); + } + let mut w = Wiz::new(); + if args.iter().any(|a| a == "--no-service") { + w.service = false; + } + let mut out = std::io::stdout(); + if terminal::enable_raw_mode().is_err() { + return run_plain(args); + } + let _ = execute!(out, terminal::EnterAlternateScreen, cursor::Hide, event::EnableBracketedPaste); + let restore = |out: &mut std::io::Stdout| { + let _ = execute!(out, event::DisableBracketedPaste, cursor::Show, terminal::LeaveAlternateScreen); + let _ = terminal::disable_raw_mode(); + }; + let rx = w.rx.take().expect("channel"); + let mut dirty = true; + while !w.quit { + if dirty { + if w.render(&mut out).is_err() { + break; + } + dirty = false; + } + if event::poll(Duration::from_millis(90)).unwrap_or(false) { + match event::read() { + Ok(Event::Key(k)) if k.kind != KeyEventKind::Release => w.key(k.code, k.modifiers), + Ok(Event::Paste(s)) => w.type_text(&s), + _ => {} + } + dirty = true; + } + while let Ok(m) = rx.try_recv() { + w.on_msg(m); + dirty = true; + } + if w.busy.is_some() || w.step == Step::Install { + w.spin += 1; + dirty = true; + } + } + restore(&mut out); + // Leave a short note behind in the terminal. + if w.step == Step::Done { + let (mark, c) = if w.ok { ("✓", GOOD) } else { ("✗", BAD) }; + let _ = execute!(out, SetForegroundColor(c), Print(format!("{mark} ")), ResetColor); + println!( + "{}", + match (w.ok, w.role) { + (true, Role::Hands) => format!("「{}」現在是{}的手腳。", w.name, w.brain.as_ref().map(|b| format!("大腦「{}」", b.name)).unwrap_or_else(|| "帳號裡大腦".into())), + (true, Role::Brain) => format!("「{}」現在是大腦。", w.name), + (false, _) => "設定沒有完成,再執行一次 anybot setup 看看。".into(), + } + ); + for (label, mark, note) in &w.progress { + if matches!(mark, Mark::Fail | Mark::Warn) { + println!(" {label}:{note}"); + } + } + 0 + } else { + println!("沒有改任何設定。"); + 1 + } +} + +/// No terminal (or --role given): the same, from flags, printed line by line. +fn run_plain(args: &[String]) -> i32 { + let flag = |k: &str| args.iter().position(|a| a == k).and_then(|i| args.get(i + 1)).cloned(); + let fail = |m: &str| { + eprintln!("✗ {m}"); + 1 + }; + let role = match flag("--role").as_deref() { + Some("hands") | Some("手腳") => Role::Hands, + Some("brain") | Some("大腦") => Role::Brain, + _ => return fail("請用 --role hands 或 --role brain(在終端機直接執行 anybot setup 會一步一步問)"), + }; + let saved = State::load().remote; + let name = flag("--name").unwrap_or_else(|| if saved.host_name.is_empty() { remote::default_host_name() } else { saved.host_name.clone() }); + let Some(server) = flag("--server").or_else(|| (!saved.relay_url.is_empty()).then(|| saved.relay_url.clone())) else { return fail("請用 --server 給帳號伺服器的網址") }; + let server = match remote::check_server(&server) { + Ok(u) => u, + Err(e) => return fail(&e), + }; + println!("✓ 帳號伺服器 {server}"); + let user = flag("--user").unwrap_or_else(|| saved.relay_user.clone()); + let mut pass = std::env::var("ANYBOT_ACCOUNT_PASSWORD").unwrap_or_default(); + if args.iter().any(|a| a == "--password-stdin") { + let mut s = String::new(); + let _ = std::io::stdin().read_line(&mut s); + pass = s.trim_end_matches(['\n', '\r']).to_string(); + } + let joined = if pass.is_empty() && !saved.host_token.is_empty() && saved.relay_url == server && saved.relay_user == user && saved.host_name == name { + saved.clone() + } else { + if user.is_empty() || pass.is_empty() { + return fail("請給 --user,密碼放在 ANYBOT_ACCOUNT_PASSWORD(或用 --password-stdin)"); + } + let mut base = saved.clone(); + base.host_name = name.clone(); + match remote::relay_join(&base, &server, &user, &pass, args.iter().any(|a| a == "--create"), &flag("--invite").unwrap_or_default()) { + Ok(s) => s, + Err(e) => return fail(&e), + } + }; + println!("✓ 已登入帳號 {user}(這台叫「{name}」)"); + let mut brain = None; + if role == Role::Hands { + match flag("--brain").as_deref() { + None | Some("any") | Some("") => {} + Some(want) => match list_brains(&joined) { + Ok(list) => match list.into_iter().find(|p| p.name == want) { + Some(p) => brain = Some(p), + None => return fail(&format!("帳號裡沒有叫「{want}」的 AnyBot")), + }, + Err(e) => return fail(&e), + }, + } + } + let env = Env::detect(); + let plan = Plan { role, settings: joined, brain, service: env.systemd && !args.iter().any(|a| a == "--no-service"), env }; + let steps = plan.steps(); + let (tx, rx) = channel(); + std::thread::spawn(move || plan.run(&tx)); + let mut failed = false; + for m in rx { + match m { + Msg::Progress(i, mark, note) if mark != Mark::Run && mark != Mark::Wait => { + let icon = match mark { + Mark::Ok => "✓", + Mark::Warn => "!", + _ => "✗", + }; + failed |= mark == Mark::Fail; + println!("{icon} {}{}", steps.get(i).cloned().unwrap_or_default(), if note.is_empty() { String::new() } else { format!(":{note}") }); + } + Msg::Finished(tips) => { + for t in tips.iter().filter(|t| !t.is_empty()) { + println!(" {}", t.replace('\t', " ")); + } + break; + } + _ => {} + } + } + if failed { + 1 + } else { + 0 + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn wraps_chinese_by_width() { + let lines = wrap("讓大腦的 Bot 使用這台電腦", 10); + assert!(lines.iter().all(|l| l.width() <= 10), "{lines:?}"); + assert_eq!(lines.concat(), "讓大腦的 Bot 使用這台電腦"); + let l = wrap("一二三四五。", 5); + assert!(!l[1].starts_with('。'), "{l:?}"); + let l = wrap("一二三四(五)。", 5); + assert!(l.iter().all(|x| !x.starts_with(')') && !x.starts_with('。')), "{l:?}"); + } + + #[test] + fn launcher_makes_a_screen_when_there_is_none() { + let env = Env { linux: true, display: String::new(), systemd: true, xvfb: true }; + let s = launcher_script(Role::Hands, std::path::Path::new("/home/a b/anybot"), &env); + assert!(s.contains("xvfb-run") && s.contains("'/home/a b/anybot'") && s.ends_with("exec '/home/a b/anybot' --serve\n")); + let brain = launcher_script(Role::Brain, std::path::Path::new("/x/anybot"), &env); + assert!(!brain.contains("xvfb-run")); + } + + #[test] + fn every_step_fits_a_narrow_terminal() { + let mut w = Wiz::new(); + w.peers = vec![Peer { id: "1".into(), name: "很長很長很長很長很長很長很長很長的大腦名字".into(), online: true, last: String::new() }]; + for step in [Step::Role, Step::Name, Step::Server, Step::Account, Step::Brain, Step::Confirm, Step::Done] { + w.step = step; + for l in w.body(40) { + assert!(width(&clip(l, 40)) <= 40); + } + } + } +} diff --git a/src/tunnel.rs b/src/tunnel.rs index 4a2d6f3..bfe7f09 100644 --- a/src/tunnel.rs +++ b/src/tunnel.rs @@ -289,7 +289,7 @@ fn on_frame(head: Value, body: Vec, tx: &Sender>, calls: &Calls) { method: s("method"), path: s("path"), query: head["query"].as_object().map(|o| o.iter().map(|(k, v)| (k.clone(), v.as_str().unwrap_or("").to_string())).collect()).unwrap_or_default(), - headers: [("x-anybot-user", s("user")), ("x-anybot-role", if head["role"].is_string() { s("role") } else { "owner".into() })].into_iter().map(|(k, v)| (k.to_string(), v)).collect(), + headers: [("x-anybot-user", s("user")), ("x-anybot-role", if head["role"].is_string() { s("role") } else { "owner".into() }), ("x-anybot-from", s("from"))].into_iter().map(|(k, v)| (k.to_string(), v)).collect(), body, peer: "relay".into(), }; -- 2.40.1 From 365dc87e54f9baa9c277b07b81250818f4012be7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E6=80=A7=E9=A9=8A?= Date: Wed, 4 Nov 2026 20:56:06 +0800 Subject: [PATCH 5/9] Add a computer with one line: pairing codes, simpler setups MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 加一台電腦 (phone menu and the app's 大腦 card) gives a ten-minute, one-time code and the line to run: anybot setup
. The new computer joins as hands for that brain with no password and nothing to answer, starts with the computer and checks the brain sees it. - anybot setup without arguments is one screen: address and code (a pasted line fills both), or the account and password instead. No role, name or confirm questions; a brain is asked only when there are several. - Hands computers are no longer offered as brains. - The server setup asks only the domain, account and password. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Uirha3oEtWeHh58zA7ErHa --- deploy/server/setup.sh | 54 +-- mobile/index.html | 45 +- relay/src/main.rs | 118 ++++- src/app/mirror_ui.rs | 6 +- src/app/remote_ui.rs | 61 ++- src/remote.rs | 37 ++ src/setup.rs | 983 ++++++++++++++++++++--------------------- src/tunnel.rs | 7 +- 8 files changed, 735 insertions(+), 576 deletions(-) diff --git a/deploy/server/setup.sh b/deploy/server/setup.sh index e33c443..5231fa2 100755 --- a/deploy/server/setup.sh +++ b/deploy/server/setup.sh @@ -16,7 +16,7 @@ if [ -t 1 ]; then else B=""; D=""; A=""; G=""; Y=""; R=""; N="" fi -STEPS=("檢查" "網址" "帳號" "金鑰" "啟動") +STEPS=("檢查" "網址" "帳號" "啟動") AT=0 header() { @@ -36,7 +36,7 @@ card_open() { printf ' %s╭─%s %s%s%s\n %s│%s\n' "$D" "$N" "$B" "$1" "$N" line() { printf ' %s│%s %s\n' "$D" "$N" "$*"; } note() { printf ' %s│%s %s%s%s\n' "$D" "$N" "$D" "$*" "$N"; } ok() { printf ' %s│%s %s✓%s %s\n' "$D" "$N" "$G" "$N" "$*"; } -warn() { printf ' %s│%s %s!%s %s\n' "$D" "$N" "$Y" "$N" "$*"; } +warn() { WARNED=1; printf ' %s│%s %s!%s %s\n' "$D" "$N" "$Y" "$N" "$*"; } bad() { printf ' %s│%s %s✗ %s%s\n' "$D" "$N" "$R" "$*" "$N"; } card_close() { printf ' %s│%s\n %s╰────────────────────────────────────────────────%s\n' "$D" "$N" "$D" "$N"; [ -n "${1:-}" ] && printf ' %s%s%s\n' "$D" "$1" "$N"; } @@ -110,7 +110,7 @@ die() { card_close; printf '\n'; exit 1; } # ---------- 0. this server ---------- AT=0; header; card_open "檢查這台伺服器" -FAIL=0 +FAIL=0; WARNED=0 if command -v docker >/dev/null; then ok "Docker 已安裝"; else bad "沒有 Docker:curl -fsSL https://get.docker.com | sh"; FAIL=1; fi if docker compose version >/dev/null 2>&1; then ok "docker compose 可以用"; elif [ $FAIL = 0 ]; then bad "沒有 docker compose(Docker 太舊?請更新 Docker)"; FAIL=1; fi if [ $FAIL = 0 ] && ! docker info >/dev/null 2>&1; then bad "沒有權限使用 Docker:sudo usermod -aG docker \$USER,登出再登入"; FAIL=1; fi @@ -123,7 +123,8 @@ mem=$(awk '/MemTotal/ {print int($2/1024/1024+0.5)}' /proc/meminfo 2>/dev/null | disk=$(df -Pk . 2>/dev/null | awk 'NR==2 {print int($4/1024/1024)}') [ "${disk:-0}" -ge 10 ] && ok "硬碟還有 ${disk} GB" || warn "硬碟只剩 ${disk:-?} GB(建議 10 GB 以上)" if [ $FAIL = 1 ] && [ $DRY = 0 ]; then note ""; note "修好上面的問題後再執行一次 ./setup.sh"; die; fi -card_close; pause +# All fine: go on by itself. +if [ $WARNED = 1 ]; then card_close; pause; else card_close; sleep 1; fi # Keep the current settings? if [ -f .env ] && [ -f Caddyfile ]; then @@ -134,7 +135,7 @@ if [ -f .env ] && [ -f Caddyfile ]; then "重新設定|網址、帳號或金鑰要改" if [ "$KEEP" = 0 ]; then DOMAIN=${ANYBOT_RELAY_PUBLIC_URL#https://}; USERNAME=$ANYBOT_USER; INVITE=${ANYBOT_RELAY_INVITE:-}; BRAIN=${ANYBOT_HOST_NAME:-} - AT=4; SKIP=1 + AT=3; SKIP=1 fi fi @@ -177,41 +178,12 @@ if [ "${SKIP:-0}" != 1 ]; then if [ "${#PW1}" -lt 8 ]; then MSG=$(bad "密碼至少 8 個字"); continue; fi if [ "$PW1" != "$PW2" ]; then MSG=$(bad "兩次密碼不一樣"); continue; fi case "$PW1" in *"'"*) MSG=$(bad "密碼裡不能有單引號 '"); continue ;; esac - ask BRAIN "大腦名字" "${BRAIN:-雲端大腦}" - [ -z "$BRAIN" ] && BRAIN="雲端大腦" + BRAIN="雲端大腦" break done - choose OTHERS "別人可以在這台註冊帳號嗎?" \ - "只有我|最安全" \ - "給邀請碼的人可以|家人或同事拿邀請碼註冊,各自有自己的帳號和 Bot" - INVITE="" - [ "$OTHERS" = 1 ] && INVITE=$(LC_ALL=C tr -dc 'a-z0-9'
Bot 需要你允許、或在你不在時做完事,手機會跳通知。
+
+
+
加一台電腦
+
在那台電腦的終端機貼上這一行執行,它就會變成「」的手腳,開機自動執行。不用帳號密碼。
+
······
+ + +
+ +
+