// 手機遙控: use AnyBot from a phone (or any browser). // // Two ways in, one API: // • 區域網路/Tailscale: this AnyBot answers on port 8793 itself (password login). // • 帳號(薄後端): this AnyBot keeps an outgoing connection to the relay (relay/ in this repo, // run on a small server). The phone signs in to the relay with the account, and the relay hands // each request to this AnyBot. No open port, works from anywhere, and the relay also keeps the // account, the device list and an encrypted backup of AnyBot's data. // // The original keeps its brain in its cloud and reaches devices from there (its device tools wait // 30 s for the device). Here the brain stays in AnyBot — on this Mac, or on an always-on server // started with `anybot --serve` — and the relay only passes requests along. use crate::httpd::{self, Req, Resp}; use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; use std::collections::HashMap; use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::{Arc, Condvar, Mutex, OnceLock}; use std::time::{Duration, Instant}; pub const LAN_PORT: u16 = 8793; /// How long the phone's "anything new?" request waits. pub const LONG_POLL_SECS: u64 = 20; /// Wrong passwords allowed before logins slow down. const MAX_FAILS: u32 = 5; #[derive(Clone, Serialize, Deserialize, Debug, Default, PartialEq)] pub struct Device { pub name: String, pub token_hash: String, pub created: i64, #[serde(default)] pub last_seen: i64, } #[derive(Clone, Serialize, Deserialize, Debug, PartialEq)] pub struct Settings { /// Answer phones on the local network (and Tailscale) on LAN_PORT. #[serde(default)] pub lan: bool, /// The phone password, stored as salt$hash. #[serde(default)] pub password: String, /// Phones and browsers signed in directly. #[serde(default)] pub devices: Vec, /// The phone may see and operate this Mac's own screen (not just the bot's computer). #[serde(default)] pub mac_from_phone: bool, /// Other AnyBots in the same account may operate this Mac (裝置連結). Each action still follows /// this Mac's rules and asks the person on the brain's side. #[serde(default)] pub device_link: bool, /// The relay (thin backend), e.g. https://anybot.example.com #[serde(default)] pub relay_url: String, #[serde(default)] pub relay_user: String, /// This AnyBot's own key at the relay (a "host"). #[serde(default)] pub host_id: String, #[serde(default)] pub host_token: String, /// What this AnyBot is called on the phone. #[serde(default = "default_host_name")] pub host_name: String, /// Key for the encrypted backup (made from the account password; never sent). #[serde(default)] pub backup_key: String, #[serde(default)] pub auto_backup: bool, #[serde(default)] pub last_backup: i64, } 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 } } } pub fn default_host_name() -> String { if !cfg!(target_os = "macos") { let pretty = std::process::Command::new("hostnamectl").arg("--pretty").output().ok().map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string()).unwrap_or_default(); let name = if pretty.is_empty() { std::fs::read_to_string("/etc/hostname").unwrap_or_default().trim().to_string() } else { pretty }; if !name.is_empty() { return name; } } let out = std::process::Command::new("scutil").args(["--get", "ComputerName"]).output().ok().map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string()).unwrap_or_default(); if !out.is_empty() { return out; } std::env::var("HOSTNAME").ok().filter(|h| !h.is_empty()).unwrap_or_else(|| "AnyBot".into()) } /// What the phone asks AnyBot to do; the app carries these out on its own thread. #[derive(Clone, Debug, PartialEq)] pub enum Cmd { Send { bot: u64, text: String, attachments: Vec }, Stop { bot: u64 }, Answer { bot: u64, answer: String }, Read { bot: u64 }, NewThread { bot: u64 }, /// A new phone signed in (to remember), or one was signed out. DeviceAdded(Device), DeviceSeen(String), } /// What the phone can see, refreshed by the app when something changes. #[derive(Default, Clone)] pub struct View { pub host: String, pub bots: Vec, pub approvals: Vec, pub messages: HashMap>, pub version: u64, } pub struct Hub { view: Mutex, changed: Condvar, cmds: Mutex>, repaint: Mutex>>, settings: Mutex, fails: Mutex<(u32, Option)>, relay_status: Mutex, } 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()) }) } static VERSION: AtomicU64 = AtomicU64::new(1); impl Hub { pub fn publish(&self, mut v: View) { v.version = VERSION.fetch_add(1, Ordering::SeqCst) + 1; *self.view.lock().unwrap() = v; self.changed.notify_all(); } pub fn take_cmds(&self) -> Vec { std::mem::take(&mut *self.cmds.lock().unwrap()) } fn push(&self, c: Cmd) { self.cmds.lock().unwrap().push(c); if let Some(r) = self.repaint.lock().unwrap().as_ref() { r(); } } pub fn set_repaint(&self, f: Arc) { *self.repaint.lock().unwrap() = Some(f); } pub fn set_settings(&self, s: &Settings) { *self.settings.lock().unwrap() = s.clone(); } pub fn settings(&self) -> Settings { self.settings.lock().unwrap().clone() } pub fn relay_status(&self) -> String { self.relay_status.lock().unwrap().clone() } fn set_relay_status(&self, s: &str) { *self.relay_status.lock().unwrap() = s.to_string(); } /// The view once it is newer than `since` (or after the wait). fn wait_newer(&self, since: u64, secs: u64) -> View { let g = self.view.lock().unwrap(); let (g, _) = self.changed.wait_timeout_while(g, Duration::from_secs(secs), |v| v.version <= since).unwrap(); g.clone() } } // ---------- shaping the chat for the phone ---------- pub fn tool_label(name: &str) -> &'static str { if crate::appctl::is_app_tool(name) { crate::appctl::label(name) } else if crate::macapps::is_mac_tool(name) { crate::macapps::label(name) } else if crate::computer::is_computer_tool(name) { crate::computer::label(name) } else if crate::web::is_web_tool(name) { crate::web::label(name) } else { crate::tools::label(name) } } fn tool_detail(name: &str, args: &str) -> String { let d = if crate::appctl::is_app_tool(name) { crate::appctl::describe(name, args) } else if crate::computer::is_computer_tool(name) { crate::computer::describe(name, args) } else if crate::web::is_web_tool(name) { crate::web::describe(name, args) } else { crate::tools::describe(name, args) }; d.chars().take(120).collect() } /// One message as the phone shows it (tool results are folded away like in the window). pub fn message_json(m: &crate::model::Message, names: &HashMap) -> Option { use crate::model::{Role, ToolStatus}; if m.role == Role::Tool { return None; } let calls: Vec = m .tool_calls .iter() .map(|c| { let st = match c.status { ToolStatus::Waiting => "waiting", ToolStatus::Running => "running", ToolStatus::Done => "done", ToolStatus::Denied => "denied", ToolStatus::Failed => "failed", }; if c.name == "show_card" { json!({"card": serde_json::from_str::(&c.args).unwrap_or(Value::Null), "status": st}) } else { json!({"label": tool_label(&c.name), "detail": tool_detail(&c.name, &c.args), "status": st}) } }) .collect(); Some(json!({ "role": match m.role { Role::User => "user", _ => "assistant" }, "text": m.text, "via": m.via, "error": m.error, "summary": m.summary, "wake": m.wake, "author": if m.author == 0 { String::new() } else { names.get(&m.author).cloned().unwrap_or_default() }, "reactions": m.reactions, "attachments": m.attachments.iter().map(|a| json!({"name": a.name, "image": a.image})).collect::>(), "tools": calls, })) } /// Newest messages kept in the phone's view of each chat. pub const PHONE_TAIL: usize = 120; // ---------- the API (both the local server and the relay call this) ---------- fn devices_ok(token: &str) -> Option { if token.is_empty() { return None; } let h = httpd::sha256_hex(token); hub().settings.lock().unwrap().devices.iter().find(|d| d.token_hash == h).map(|d| d.token_hash.clone()) } /// `via_relay`: the relay already checked the account, so no phone password is needed. pub fn handle(req: Req, via_relay: bool) -> Resp { let parts = req.parts(); let path: Vec<&str> = parts.iter().map(|s| *s).collect(); match (req.method.as_str(), path.as_slice()) { ("GET", []) | ("GET", ["index.html"]) => return page(), ("GET", ["manifest.webmanifest"]) => return Resp::bytes("application/manifest+json", MANIFEST.as_bytes().to_vec()), ("GET", ["icon.svg"]) => return Resp::bytes("image/svg+xml", ICON.as_bytes().to_vec()), ("GET", ["api", "hello"]) => { let s = hub().settings(); return Resp::json(&json!({"app": "anybot", "host": s.host_name, "relay": via_relay, "needs_password": !via_relay})); } ("POST", ["api", "login"]) if !via_relay => return login(&req), _ => {} } if !via_relay { match devices_ok(&req.bearer()) { Some(h) => hub().push(Cmd::DeviceSeen(h)), None => return Resp::error(401, "請先登入"), } } let s = hub().settings(); // Direct phones are the owner's. Through the relay, the relay says who is asking. let role = if via_relay { Some(req.header("x-anybot-role")).filter(|r| !r.is_empty()).unwrap_or("owner").to_string() } else { "owner".to_string() }; if !role_allows(&role, &req.method, &path) { return Resp::error(403, "你在這台 AnyBot 的角色不能做這件事(請擁有者處理)"); } match (req.method.as_str(), path.as_slice()) { ("GET", ["api", "state"]) => { let since: u64 = req.query.get("since").and_then(|v| v.parse().ok()).unwrap_or(0); let v = hub().wait_newer(since, if since == 0 { 0 } else { LONG_POLL_SECS }); Resp::json(&json!({ "version": v.version, "host": v.host, "bots": v.bots, "approvals": v.approvals, "box": crate::cloudbox::run_state().label(), "box_available": crate::cloudbox::available(), "mac": s.mac_from_phone && cfg!(target_os = "macos") && role != "member", "role": role, })) } ("GET", ["api", "bots", id]) => { let id: u64 = id.parse().unwrap_or(0); let v = hub().view.lock().unwrap(); match v.messages.get(&id) { Some(m) => Resp::json(&json!({"version": v.version, "messages": m})), None => Resp::error(404, "找不到這個 Bot"), } } ("POST", ["api", "bots", id, action]) => { let bot: u64 = id.parse().unwrap_or(0); let b = req.json(); let cmd = match *action { "send" => { let text = b["text"].as_str().unwrap_or("").trim().to_string(); let attachments: Vec = b["attachments"].as_array().map(|a| a.iter().filter_map(|x| x.as_str().map(String::from)).collect()).unwrap_or_default(); if text.is_empty() && attachments.is_empty() { return Resp::error(400, "訊息是空的"); } // 團隊: the bot knows who is talking when it isn't the owner. let who = req.header("x-anybot-user"); let text = if role != "owner" && !who.is_empty() { format!("【{who}】{text}") } else { text }; Cmd::Send { bot, text, attachments } } "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 }, "upload" => return upload(bot, &req), _ => return Resp::error(404, "沒有這個動作"), }; hub().push(cmd); Resp::json(&json!({"ok": true})) } ("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", "logout"]) if !via_relay => { let h = httpd::sha256_hex(&req.bearer()); hub().push(Cmd::DeviceSeen(format!("-{h}"))); Resp::json(&json!({"ok": true})) } _ => Resp::error(404, "沒有這個網址"), } } /// 團隊角色: what each role may do. "member" chats and attaches files; "admin" also approves, /// stops, starts new chats and uses the screens; only "device" (another AnyBot of the owner's /// account) may run tools here. pub fn role_allows(role: &str, method: &str, path: &[&str]) -> bool { let device = matches!(path, ["api", "device", "run"]); match role { "device" => device, "owner" | "admin" => !device, "member" => matches!((method, path), ("GET", ["api", "state"]) | ("GET", ["api", "bots", _]) | ("POST", ["api", "bots", _, "send" | "read" | "upload"])), _ => false, } } fn login(req: &Req) -> Resp { { let mut f = hub().fails.lock().unwrap(); if let (n, Some(t)) = *f { if n >= MAX_FAILS && t.elapsed() < Duration::from_secs(60) { return Resp::error(429, "密碼錯太多次,請一分鐘後再試"); } if t.elapsed() >= Duration::from_secs(60) { *f = (0, None); } } } let b = req.json(); let pw = b["password"].as_str().unwrap_or(""); let stored = hub().settings().password; if stored.is_empty() || !httpd::check_password(pw, &stored) { let mut f = hub().fails.lock().unwrap(); *f = (f.0 + 1, Some(Instant::now())); // Slow guessing down a little more. std::thread::sleep(Duration::from_millis(600)); return Resp::error(401, if stored.is_empty() { "這台 AnyBot 還沒設定手機密碼" } else { "密碼不對" }); } *hub().fails.lock().unwrap() = (0, None); let token = httpd::random_hex(32); let name = b["device"].as_str().filter(|d| !d.trim().is_empty()).unwrap_or("手機").chars().take(40).collect::(); let d = Device { name, token_hash: httpd::sha256_hex(&token), created: chrono::Utc::now().timestamp(), last_seen: chrono::Utc::now().timestamp() }; // Known at once (the app saves it a moment later). hub().settings.lock().unwrap().devices.push(d.clone()); hub().push(Cmd::DeviceAdded(d)); Resp::json(&json!({"token": token})) } fn upload(bot: u64, req: &Req) -> Resp { let name = req.query.get("name").cloned().unwrap_or_else(|| "手機上傳".into()); let name: String = name.chars().filter(|c| !matches!(c, '/' | '\\' | '\0')).take(80).collect(); if req.body.is_empty() { return Resp::error(400, "檔案是空的"); } let dir = std::env::temp_dir().join(format!("anybot-phone-{}", httpd::random_hex(6))); let _ = std::fs::create_dir_all(&dir); let tmp = dir.join(if name.is_empty() { "upload".to_string() } else { name }); if let Err(e) = std::fs::write(&tmp, &req.body) { return Resp::error(500, &e.to_string()); } let r = crate::attach::store(bot, &tmp); let _ = std::fs::remove_dir_all(&dir); match r { Ok(a) => Resp::json(&json!({"path": a.path, "name": a.name, "image": a.image})), Err(e) => Resp::error(400, &e), } } /// A frame of the bot's computer ("box") or this Mac ("mac") as JPEG. fn screen(which: &str, s: &Settings) -> Resp { match which { "box" => { crate::cloudbox::touch(); match crate::cloudbox::frame() { Some(img) => { let rgb = image::DynamicImage::ImageRgba8(img).to_rgb8(); match crate::computer::encode_jpeg(&rgb) { Some(j) => Resp::bytes("image/jpeg", j).with_header("X-Size", &format!("{}x{}", rgb.width(), rgb.height())), None => Resp::error(500, "畫面編碼失敗"), } } None => Resp::error(503, "Bot 的電腦在休息,按「叫醒」"), } } "mac" if s.mac_from_phone => { let (text, ok, img) = crate::computer::run_by("computer_use_screenshot", "{}", "手機"); match (ok, img) { (true, Some(b)) => { use base64::Engine; let bytes = base64::engine::general_purpose::STANDARD.decode(b).unwrap_or_default(); let size = image::load_from_memory(&bytes).map(|i| format!("{}x{}", i.width(), i.height())).unwrap_or_default(); Resp::bytes("image/jpeg", bytes).with_header("X-Size", &size) } _ => Resp::error(503, &text), } } "mac" => Resp::error(403, "這台 Mac 沒有開放從手機操作(AnyBot 設定 › 手機與帳號)"), _ => Resp::error(404, "沒有這個畫面"), } } /// The phone taps, types or scrolls on a screen (coordinates in the frame's pixels). fn screen_input(which: &str, v: &Value, s: &Settings) -> Resp { let kind = v["kind"].as_str().unwrap_or(""); if which == "box" && kind == "wake" { return match crate::cloudbox::ensure_running() { Ok(()) => Resp::json(&json!({"ok": true})), Err(e) => Resp::error(500, &e), }; } let (name, args) = match kind { "click" => ("computer_use_click", json!({"x": v["x"], "y": v["y"], "button": v["button"].as_str().unwrap_or("left"), "count": v["count"].as_u64().unwrap_or(1)})), "type" => ("computer_use_typing", json!({"text": v["text"]})), "key" => ("computer_use_press_key", json!({"key": v["key"]})), "scroll" => ("computer_use_scroll", json!({"x": v["x"], "y": v["y"], "direction": v["direction"].as_str().unwrap_or("down"), "amount": v["amount"].as_i64().unwrap_or(3)})), _ => return Resp::error(400, "不認得的操作"), }; match which { "box" => { crate::cloudbox::touch(); match crate::cloudbox::computer_run(name, &args) { Ok(_) => Resp::json(&json!({"ok": true})), Err(e) => Resp::error(500, &e), } } "mac" if s.mac_from_phone => { let (t, ok, _) = crate::computer::run_by(name, &args.to_string(), "手機"); if ok { Resp::json(&json!({"ok": true})) } else { Resp::error(500, &t) } } _ => Resp::error(403, "這台 Mac 沒有開放從手機操作"), } } /// 裝置連結: 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 { if !via_relay || !s.device_link { return Resp::error(403, "這台電腦沒有開放給帳號裡的其他 AnyBot 操作"); } 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"); if crate::computer::is_computer_tool(tool) { let (text, ok, img) = crate::computer::run_by(tool, args, who); 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})); } if matches!(tool, "shell" | "read_file" | "edit_file" | "list_dir" | "search" | "read_terminal" | "write_terminal") { let t = crate::tools::Target { name: crate::model::THIS_COMPUTER_NAME.into(), place: crate::tools::Place::Local { workdir: crate::tools::expand_home(v["workdir"].as_str().unwrap_or("~")) } }; let ids = crate::tools::RunIds { conversation: "device".into(), request: httpd::random_hex(6) }; let (text, ok) = crate::tools::run_on(tool, args, &t, &std::sync::atomic::AtomicBool::new(false), &ids); return Resp::json(&json!({"text": text, "ok": ok})); } Resp::error(400, "這個工具不能遠端使用") } // ---------- the local server ---------- static LAN_GEN: AtomicU64 = AtomicU64::new(0); static LAN_BOUND: Mutex> = Mutex::new(None); pub fn lan_port() -> Option { *LAN_BOUND.lock().unwrap() } /// Start (or stop) answering phones on the local network. pub fn set_lan(on: bool) -> Result { let gen = LAN_GEN.fetch_add(1, Ordering::SeqCst) + 1; *LAN_BOUND.lock().unwrap() = None; if !on { return Ok(0); } let port_env: Option = std::env::var("ANYBOT_PHONE_PORT").ok().and_then(|p| p.parse().ok()); let start = port_env.unwrap_or(LAN_PORT); // The old listener notices the new generation within 50 ms; give it the port back. let listener = (0..40) .find_map(|_| { let l = (start..start + 10).find_map(|p| std::net::TcpListener::bind(("0.0.0.0", p)).ok()); if l.is_none() { std::thread::sleep(Duration::from_millis(60)); } l }) .ok_or("找不到可用的連接埠")?; let port = listener.local_addr().map_err(|e| e.to_string())?.port(); *LAN_BOUND.lock().unwrap() = Some(port); std::thread::spawn(move || httpd::serve(listener, Arc::new(|r| handle(r, false)), Arc::new(move || LAN_GEN.load(Ordering::SeqCst) == gen))); Ok(port) } /// Addresses a phone can open (local network and Tailscale). pub fn addresses(port: u16) -> Vec { // macOS: ifconfig ("inet 192.168.1.5 netmask…"); Linux: `ip -4 -o addr` ("… inet 192.168.1.5/24 …"). let mut out = std::process::Command::new("ifconfig").output().map(|o| String::from_utf8_lossy(&o.stdout).to_string()).unwrap_or_default(); if out.trim().is_empty() { out = std::process::Command::new("ip").args(["-4", "-o", "addr"]).output().map(|o| String::from_utf8_lossy(&o.stdout).to_string()).unwrap_or_default(); } let mut v: Vec = out .lines() .filter_map(|l| l.split("inet ").nth(1)) .filter_map(|l| l.split_whitespace().next()) .map(|ip| ip.split('/').next().unwrap_or(ip)) .filter(|ip| !ip.starts_with("127.") && !ip.starts_with("169.254.")) .map(|ip| format!("http://{ip}:{port}")) .collect(); v.dedup(); v } // ---------- the relay connection (this AnyBot as a "host") ---------- static RELAY_GEN: AtomicU64 = AtomicU64::new(0); fn agent(secs: u64) -> ureq::Agent { ureq::Agent::config_builder().timeout_global(Some(Duration::from_secs(secs))).http_status_as_error(false).build().into() } 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 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}"))); } Ok(v) } fn base(url: &str) -> String { url.trim().trim_end_matches('/').to_string() } /// Sign in (or create the account) at the relay and register this AnyBot as one of its hosts. /// Returns the settings to keep. pub fn relay_join(base_settings: &Settings, url: &str, user: &str, password: &str, create: bool, invite: &str) -> Result { let host_name = base_settings.host_name.as_str(); let url = base(url); // Plain http only inside one machine or one Docker network (e.g. http://relay:8080). let host = url.strip_prefix("http://").map(|r| r.split(['/', ':']).next().unwrap_or("")).unwrap_or(""); // Tailscale (100.64.0.0/10 and *.ts.net) is already encrypted end to end. let tailscale = host.ends_with(".ts.net") || host.split('.').collect::>().as_slice().first().map(|a| *a == "100").unwrap_or(false) && host.split('.').nth(1).and_then(|b| b.parse::().ok()).map(|b| (64..128).contains(&b)).unwrap_or(false); let internal = host == "127.0.0.1" || host == "localhost" || tailscale || (!host.is_empty() && !host.contains('.')); if !url.starts_with("https://") && !internal { return Err("帳號伺服器要用 https:// 開頭的網址(這台電腦或 Tailscale 位址可以用 http://)".into()); } if create { relay_post(&format!("{url}/relay/signup"), "", &json!({"user": user, "password": password, "invite": invite}))?; } let v = relay_post(&format!("{url}/relay/login"), "", &json!({"user": user, "password": password, "device": host_name}))?; let session = v["token"].as_str().ok_or("帳號伺服器沒有給登入憑證")?.to_string(); let h = relay_post(&format!("{url}/relay/hosts"), &session, &json!({"name": host_name}))?; // The desktop doesn't need to stay signed in as a person; only the host key is kept. let _ = relay_post(&format!("{url}/relay/logout"), &session, &json!({})); let mut s = base_settings.clone(); s.relay_url = url; s.relay_user = user.to_string(); s.host_id = h["id"].as_str().unwrap_or("").to_string(); s.host_token = h["token"].as_str().unwrap_or("").to_string(); s.backup_key = httpd::derive_key(password, user).iter().map(|b| format!("{b:02x}")).collect(); if s.host_id.is_empty() || s.host_token.is_empty() { return Err("帳號伺服器沒有給這台電腦的金鑰".into()); } Ok(s) } /// Keep the outgoing connection to the relay: wait for the phone's requests and answer them. pub fn relay_restart(s: &Settings) { let gen = RELAY_GEN.fetch_add(1, Ordering::SeqCst) + 1; if s.relay_url.is_empty() || s.host_token.is_empty() { hub().set_relay_status(""); return; } 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)); } } fn alive(gen: u64) -> bool { RELAY_GEN.load(Ordering::SeqCst) == gen } fn relay_worker(url: &str, token: &str, gen: u64) { let mut backoff = 1u64; while alive(gen) { let r = agent(40).get(&format!("{url}/relay/host/next")).header("Authorization", &format!("Bearer {token}")).call(); let mut resp = match r { Ok(r) => r, Err(e) => { hub().set_relay_status(&format!("連不上帳號伺服器:{e}")); std::thread::sleep(Duration::from_secs(backoff)); backoff = (backoff * 2).min(30); continue; } }; let st = resp.status().as_u16(); if st == 401 { hub().set_relay_status("這台電腦的金鑰失效了,請重新登入帳號"); return; } if st == 204 { hub().set_relay_status("已連線"); backoff = 1; continue; } let text = resp.body_mut().read_to_string().unwrap_or_default(); let Ok(job) = serde_json::from_str::(&text) else { std::thread::sleep(Duration::from_secs(backoff)); continue; }; hub().set_relay_status("已連線"); backoff = 1; let id = job["id"].as_str().unwrap_or("").to_string(); let req = req_from_json(&job); // Answer on a separate thread so this worker can pick up the next request at once. let (url, token) = (url.to_string(), token.to_string()); std::thread::spawn(move || { let resp = handle(req, true); let _ = agent(60).post(&format!("{url}/relay/host/reply/{id}")).header("Authorization", &format!("Bearer {token}")).header("Content-Type", "application/json").send(resp_to_json(&resp).to_string().as_bytes()); }); } } pub fn req_from_json(job: &Value) -> Req { use base64::Engine; Req { method: job["method"].as_str().unwrap_or("GET").to_string(), 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(), body: base64::engine::general_purpose::STANDARD.decode(job["body"].as_str().unwrap_or("")).unwrap_or_default(), peer: "relay".into(), } } pub fn resp_to_json(r: &Resp) -> Value { use base64::Engine; json!({"status": r.status, "ctype": r.ctype, "body": base64::engine::general_purpose::STANDARD.encode(&r.body), "headers": r.headers}) } /// Upload an encrypted copy of AnyBot's data to the relay. pub fn backup_now(s: &Settings, state_json: &[u8]) -> Result { let key = key_of(&s.backup_key).ok_or("還沒登入帳號")?; let sealed = httpd::seal(&key, state_json); let mut resp = agent(120) .put(&format!("{}/relay/backup", s.relay_url)) .header("Authorization", &format!("Bearer {}", s.host_token)) .header("Content-Type", "application/octet-stream") .send(&sealed[..]) .map_err(|e| format!("備份上傳失敗:{e}"))?; let st = resp.status().as_u16(); if !(200..300).contains(&st) { return Err(format!("備份上傳失敗:{}", resp.body_mut().read_to_string().unwrap_or_default())); } Ok(sealed.len()) } /// Download and open the newest backup of this account (for a new computer). pub fn restore(url: &str, user: &str, password: &str) -> Result, String> { let url = base(url); let v = relay_post(&format!("{url}/relay/login"), "", &json!({"user": user, "password": password, "device": "還原備份"}))?; let session = v["token"].as_str().unwrap_or("").to_string(); let mut resp = agent(120).get(&format!("{url}/relay/backup")).header("Authorization", &format!("Bearer {session}")).call().map_err(|e| e.to_string())?; let _ = relay_post(&format!("{url}/relay/logout"), &session, &json!({})); if resp.status().as_u16() == 404 { return Err("這個帳號還沒有備份".into()); } let sealed = resp.body_mut().with_config().limit(200 * 1024 * 1024).read_to_vec().map_err(|e| e.to_string())?; httpd::open(&httpd::derive_key(password, user), &sealed).ok_or_else(|| "備份打不開(密碼不對,或備份壞了)".to_string()) } fn key_of(hex: &str) -> Option<[u8; 32]> { if hex.len() != 64 { return None; } let mut k = [0u8; 32]; for i in 0..32 { k[i] = u8::from_str_radix(&hex[i * 2..i * 2 + 2], 16).ok()?; } Some(k) } /// 裝置連結: run a tool on another AnyBot of this account (through the relay). pub fn run_on_device(host_id: &str, tool: &str, args: &str, who: &str) -> Result { let s = hub().settings(); if s.relay_url.is_empty() { return Err("沒有登入帳號,連不到其他裝置".into()); } let url = format!("{}/relay/hosts/{host_id}/api/device/run", s.relay_url); let mut resp = agent(45) .post(&url) .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}"))?; 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})"))); } Ok(v) } /// 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(); if s.relay_url.is_empty() || s.host_token.is_empty() { return; } let (title, body) = (title.to_string(), body.to_string()); std::thread::spawn(move || { let _ = relay_post(&format!("{}/relay/host/notify", s.relay_url), &s.host_token, &json!({"title": title, "body": body, "bot": bot})); }); } /// 發布網站: upload a folder (or one page) to the account server; returns the public address. pub fn publish_site(name: &str, local: &std::path::Path) -> Result { use base64::Engine; let s = hub().settings(); if s.relay_url.is_empty() || s.host_token.is_empty() { return Err("要先在 設定 › 手機與帳號 登入帳號,才能發布網站".into()); } let mut files = vec![]; let mut total = 0u64; if local.is_file() { let bytes = std::fs::read(local).map_err(|e| e.to_string())?; let is_html = local.extension().map(|e| e == "html" || e == "htm").unwrap_or(false); let n = if is_html { "index.html".to_string() } else { local.file_name().unwrap_or_default().to_string_lossy().to_string() }; if bytes.len() > 25 * 1024 * 1024 { return Err("網頁太大了(最多 25 MB)".into()); } files.push(json!({"path": n, "data": base64::engine::general_purpose::STANDARD.encode(bytes)})); } else { let mut stack = vec![local.to_path_buf()]; while let Some(d) = stack.pop() { for e in std::fs::read_dir(&d).map_err(|e| format!("讀不到資料夾:{e}"))?.flatten() { let p = e.path(); let fname = e.file_name().to_string_lossy().to_string(); if fname.starts_with('.') || fname == "node_modules" { continue; } if p.is_dir() { stack.push(p); } else if let Ok(bytes) = std::fs::read(&p) { total += bytes.len() as u64; if files.len() >= 500 || total > 25 * 1024 * 1024 { return Err("網站太大了(最多 500 個檔案、25 MB)".into()); } let rel = p.strip_prefix(local).unwrap_or(&p).to_string_lossy().replace('\\', "/"); files.push(json!({"path": rel, "data": base64::engine::general_purpose::STANDARD.encode(bytes)})); } } } } if !files.iter().any(|f| f["path"] == "index.html") { return Err("找不到 index.html(網站的首頁)".into()); } let v = relay_post(&format!("{}/relay/host/sites/{name}", s.relay_url), &s.host_token, &json!({"files": files}))?; let url = v["url"].as_str().filter(|u| !u.is_empty()).map(String::from).unwrap_or_else(|| format!("{}{}", s.relay_url, v["path"].as_str().unwrap_or(""))); Ok(url) } /// 團隊: who else this AnyBot is shared with (user, role). pub fn members(s: &Settings) -> Result, String> { let mut r = agent(10).get(&format!("{}/relay/host/members", s.relay_url)).header("Authorization", &format!("Bearer {}", s.host_token)).call().map_err(|e| e.to_string())?; let v: Value = serde_json::from_str(&r.body_mut().read_to_string().unwrap_or_default()).unwrap_or(Value::Null); Ok(v["members"].as_array().map(|a| a.iter().map(|m| (m["user"].as_str().unwrap_or("").to_string(), m["role"].as_str().unwrap_or("").to_string())).collect()).unwrap_or_default()) } /// Share with another account ("member" / "admin"), or stop sharing ("remove"). pub fn set_member(s: &Settings, user: &str, role: &str) -> Result<(), String> { relay_post(&format!("{}/relay/host/members", s.relay_url), &s.host_token, &json!({"user": user, "role": role})).map(|_| ()) } /// The other AnyBots signed in to this account (for 裝置連結). pub fn devices_online(s: &Settings) -> Vec<(String, String, bool)> { if s.relay_url.is_empty() { return vec![]; } let Ok(mut r) = agent(10).get(&format!("{}/relay/host/peers", s.relay_url)).header("Authorization", &format!("Bearer {}", s.host_token)).call() else { return vec![] }; let v: Value = serde_json::from_str(&r.body_mut().read_to_string().unwrap_or_default()).unwrap_or(Value::Null); v["hosts"].as_array().map(|a| a.iter().map(|h| (h["id"].as_str().unwrap_or("").to_string(), h["name"].as_str().unwrap_or("").to_string(), h["online"].as_bool().unwrap_or(false))).collect()).unwrap_or_default() } // ---------- the phone page ---------- const PAGE: &str = include_str!("../mobile/index.html"); pub const MANIFEST: &str = r##"{"name":"AnyBot","short_name":"AnyBot","start_url":"./","display":"standalone","background_color":"#121212","theme_color":"#121212","icons":[{"src":"icon.svg","sizes":"any","type":"image/svg+xml"}]}"##; pub const ICON: &str = r##""##; pub fn page() -> Resp { Resp::bytes("text/html; charset=utf-8", PAGE.as_bytes().to_vec()).with_header("Content-Security-Policy", "default-src 'self'; img-src 'self' blob: data:; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'; connect-src 'self'") } #[cfg(test)] mod tests { use super::*; #[test] fn phone_login_and_api() { let mut s = Settings { password: httpd::hash_password("pw-123"), ..Default::default() }; s.host_name = "測試 Mac".into(); hub().set_settings(&s); // Not signed in: refused. let r = handle(Req { method: "GET".into(), path: "/api/state".into(), ..Default::default() }, false); assert_eq!(r.status, 401); let bad = handle(Req { method: "POST".into(), path: "/api/login".into(), body: br#"{"password":"nope"}"#.to_vec(), ..Default::default() }, false); assert_eq!(bad.status, 401); let ok = handle(Req { method: "POST".into(), path: "/api/login".into(), body: br#"{"password":"pw-123","device":"iPhone"}"#.to_vec(), ..Default::default() }, false); assert_eq!(ok.status, 200); let token = serde_json::from_slice::(&ok.body).unwrap()["token"].as_str().unwrap().to_string(); hub().publish(View { host: "測試 Mac".into(), bots: vec![json!({"id": 1, "name": "小助理"})], ..Default::default() }); let mut req = Req { method: "GET".into(), path: "/api/state".into(), ..Default::default() }; req.headers.insert("authorization".into(), format!("Bearer {token}")); let st = handle(req.clone(), false); assert_eq!(st.status, 200, "{}", String::from_utf8_lossy(&st.body)); let v: Value = serde_json::from_slice(&st.body).unwrap(); assert_eq!(v["bots"][0]["name"], "小助理"); // Sending queues a command for the app. let mut send = Req { method: "POST".into(), path: "/api/bots/1/send".into(), body: r#"{"text":"你好"}"#.as_bytes().to_vec(), ..Default::default() }; send.headers = req.headers.clone(); assert_eq!(handle(send, false).status, 200); let cmds = hub().take_cmds(); assert!(cmds.iter().any(|c| matches!(c, Cmd::DeviceAdded(d) if d.name == "iPhone"))); assert!(cmds.contains(&Cmd::Send { bot: 1, text: "你好".into(), attachments: vec![] })); // The Mac's own screen stays closed unless allowed. let mut mac = Req { method: "GET".into(), path: "/api/screen/mac".into(), ..Default::default() }; mac.headers = req.headers.clone(); assert_eq!(handle(mac, false).status, 403); // Device link needs the relay and the switch. assert_eq!(handle(Req { method: "POST".into(), path: "/api/device/run".into(), body: br#"{"tool":"shell"}"#.to_vec(), ..Default::default() }, true).status, 403); } #[test] fn team_roles() { assert!(role_allows("member", "POST", &["api", "bots", "3", "send"])); assert!(role_allows("member", "GET", &["api", "state"])); for (m, p) in [("POST", &["api", "bots", "3", "answer"][..]), ("POST", &["api", "bots", "3", "stop"][..]), ("GET", &["api", "screen", "box"][..]), ("POST", &["api", "device", "run"][..])] { assert!(!role_allows("member", m, p), "{p:?}"); } assert!(role_allows("admin", "POST", &["api", "bots", "3", "answer"])); assert!(!role_allows("admin", "POST", &["api", "device", "run"])); assert!(role_allows("device", "POST", &["api", "device", "run"]) && !role_allows("device", "GET", &["api", "state"])); assert!(!role_allows("stranger", "GET", &["api", "state"])); // The relay's word reaches handle() as headers. let req = req_from_json(&json!({"method": "POST", "path": "/api/bots/1/answer", "user": "amy", "role": "member", "body": ""})); assert_eq!(handle(req, true).status, 403); } #[test] fn relay_messages_round_trip() { let r = Resp::bytes("image/jpeg", vec![1, 2, 3]).with_header("X-Size", "2x2"); let j = resp_to_json(&r); assert_eq!(j["status"], 200); let req = req_from_json(&json!({"method": "POST", "path": "/api/bots/2/send", "query": {"since": "4"}, "body": "eyJ0ZXh0IjoiaGkifQ=="})); assert_eq!(req.json()["text"], "hi"); assert_eq!(req.query["since"], "4"); } }