AnyBot/src/app/computers_ui.rs

1509 lines
76 KiB
Rust
Raw Normal View History

// The computers part of the window: 「Bot 的電腦」 (status, live screen, take control, rebuilds,
// snapshots, secrets, where it runs), 「你的電腦」 (macOS permissions, the per-app grants) and
// the floating bar shown while a bot drives this Mac.
//
// Wording follows the original's strings where it has them (ORIGINAL_FEATURES.md §9–§11).
use super::*;
use crate::cloudbox::{self, Rebuild, Run};
/// How often the viewer asks the box for a new picture (estimated).
const VIEWER_FRAME_MS: u64 = 450;
/// The floating bar stays this long after the bot's last mouse/keyboard action on this Mac.
const LOCAL_BAR_SECS: u64 = 8;
#[derive(Default)]
pub struct ComputerUi {
/// 其他電腦 is expanded.
pub others_open: bool,
/// The account's other AnyBots (id, name, online), fetched from the relay now and then.
pub peers: Arc<Mutex<Option<Vec<(String, String, bool)>>>>,
pub peers_at: Option<Instant>,
/// 你的電腦's name being edited.
pub renaming_machine: Option<String>,
/// 更新: the first click arms it for 3 s (「再點選一次以確認」).
pub arm_update: Option<Instant>,
/// 要移除「…」嗎? for another computer (its index).
pub confirm_remove: Option<usize>,
/// 要重設 AnyBot 的電腦嗎?
pub confirm_reset: bool,
/// 一律允許的應用程式 is expanded.
pub apps_open: bool,
pub viewer_open: bool,
/// 取得控制權: the person's clicks and keys go to the box.
pub control: bool,
pub fit: bool,
texture: Option<egui::TextureHandle>,
frame: Arc<Mutex<Option<(u64, egui::ColorImage)>>>,
shown_seq: u64,
frame_size: [usize; 2],
worker: Option<Arc<AtomicBool>>,
input_tx: Option<Sender<Input>>,
pub confirm: Option<Rebuild>,
pub note: Arc<Mutex<String>>,
disk: Arc<Mutex<Option<String>>>,
new_secret: (String, String),
replace_secret: Option<String>,
secret_error: String,
docker_host: Option<String>,
show_log: bool,
teach_name: String,
teach_for: Option<u64>,
teach_error: String,
/// Screenshot mode: show the real box in the viewer.
pub demo_live: bool,
schedule_edit: String,
pub update_ready: bool,
update_checked: Option<Instant>,
update_flag: Arc<AtomicBool>,
/// The 電腦 card's small live picture of the box's screen.
thumb: Option<egui::TextureHandle>,
thumb_img: Arc<Mutex<Option<egui::ColorImage>>>,
thumb_busy: Arc<AtomicBool>,
thumb_at: Option<Instant>,
}
enum Input {
Click(i64, i64, &'static str, u32),
Move(i64, i64),
Scroll(i64, i64, bool, u32),
Key(String),
Text(String),
}
fn status_color(r: &Run, p: Palette) -> Color32 {
match r {
Run::Running => Color32::from_rgb(0x14, 0xc3, 0x70),
Run::Starting(_) | Run::Rebuilding(..) => Color32::from_rgb(0xf6, 0x94, 0x12),
Run::Failed(_) => p.danger,
_ => p.text3,
}
}
/// A rounded status pill: coloured dot + label.
fn status_pill(ui: &mut egui::Ui, r: &Run, p: Palette) {
let label = r.label();
let font = egui::FontId::proportional(FONT_XS);
let galley = ui.painter().layout_no_wrap(label, font, p.text2);
let size = vec2(galley.size().x + 26.0, 22.0);
let (rect, _) = ui.allocate_exact_size(size, egui::Sense::hover());
ui.painter().rect_filled(rect, 11, p.hover);
let c = rect.left_center() + vec2(11.0, 0.0);
let col = status_color(r, p);
if matches!(r, Run::Starting(_) | Run::Rebuilding(..)) {
let t = ui.input(|i| i.time) as f32;
ui.painter().circle_filled(c, 4.0 + (t * 4.0).sin().abs() * 2.0, col.gamma_multiply(0.35));
ui.ctx().request_repaint_after(std::time::Duration::from_millis(60));
}
ui.painter().circle_filled(c, 3.5, col);
ui.painter().galley(rect.left_center() + vec2(20.0, -galley.size().y / 2.0), galley, p.text2);
}
/// A little monitor drawn with lines (no emoji font needed).
fn monitor_icon(ui: &mut egui::Ui, size: f32, col: Color32) {
let (rect, _) = ui.allocate_exact_size(vec2(size, size), egui::Sense::hover());
let s = size / 20.0;
let screen = egui::Rect::from_min_size(rect.min + vec2(2.0 * s, 3.0 * s), vec2(16.0 * s, 11.0 * s));
ui.painter().rect_stroke(screen, 2.5 * s, Stroke::new(1.5, col), egui::StrokeKind::Inside);
ui.painter().line_segment([rect.min + vec2(10.0 * s, 14.0 * s), rect.min + vec2(10.0 * s, 17.0 * s)], Stroke::new(1.5, col));
ui.painter().line_segment([rect.min + vec2(6.5 * s, 17.5 * s), rect.min + vec2(13.5 * s, 17.5 * s)], Stroke::new(1.5, col));
}
impl App {
/// Called once at start.
pub(super) fn computers_start(&mut self) {
if !self.demo {
cloudbox::start_background();
}
}
// ---------- settings page ----------
pub(super) fn computers_page(&mut self, ui: &mut egui::Ui) {
let p = self.pal;
let run = cloudbox::run_state();
// Check now and then whether this AnyBot carries a newer computer.
if !self.demo && self.cu.update_checked.map_or(true, |t| t.elapsed().as_secs() > 30) {
self.cu.update_checked = Some(Instant::now());
let flag = self.cu.update_flag.clone();
std::thread::spawn(move || flag.store(cloudbox::update_available(), Ordering::SeqCst));
}
self.cu.update_ready = self.cu.update_flag.load(Ordering::SeqCst);
ui.spacing_mut().item_spacing.y = 0.0;
// ── 你的電腦 ── (title: this computer's name, with a pencil to rename it)
let label = if self.state.machine_label.trim().is_empty() { crate::remote::default_host_name() } else { self.state.machine_label.clone() };
let renaming = self.cu.renaming_machine.clone();
let mut rename_to: Option<Option<String>> = None;
let mut keep_buf: Option<String> = None;
let mut start_rename = false;
sec_title(ui, "你的電腦", p, |ui| match renaming {
Some(mut buf) => {
let r = ui.add(egui::TextEdit::singleline(&mut buf).desired_width(180.0).char_limit(100));
if !r.has_focus() && !r.lost_focus() {
r.request_focus();
}
if r.lost_focus() {
let cancel = ui.input(|i| i.key_pressed(Key::Escape));
let v = buf.trim().to_string();
rename_to = Some(if !cancel && !v.is_empty() { Some(v) } else { None });
} else {
keep_buf = Some(buf);
}
}
None => {
if crate::icons::button(ui, crate::icons::Icon::Pencil, 14.0, p.text3, p.hover).on_hover_text(format!("{},{label}", t("重新命名這台電腦"))).clicked() {
start_rename = true;
}
ui.label(RichText::new(&label).size(FONT_SM).color(p.text2));
}
});
if let Some(buf) = keep_buf {
self.cu.renaming_machine = Some(buf);
}
match rename_to {
Some(Some(v)) => {
self.state.machine_label = v;
self.cu.renaming_machine = None;
self.mark();
}
Some(None) => self.cu.renaming_machine = None,
None => {}
}
if start_rename {
self.cu.renaming_machine = Some(label.clone());
}
sec_card(ui, p, |ui| {
// 在這台電腦上執行: 一律允許 / 每次詢問 / 永不允許 (default 每次詢問).
let locked = !self.managed.local.allows();
let current = if self.state.local_never || locked { "never" } else if self.state.local_execution { "always" } else { "ask" };
let name = |v: &str| {
t(match v {
"always" => "一律允許",
"never" => "永不允許",
_ => "每次詢問",
})
};
let mut pick: Option<&str> = None;
sec_row(ui, p, "在這台電腦上執行", "讓 Bot 在你的電腦上開啟檔案並執行任務。自動審核仍會先檢查所有操作。", |ui| {
ui.add_enabled_ui(!locked, |ui| {
egui::ComboBox::from_id_salt("local-permission").selected_text(name(current)).width(110.0).show_ui(ui, |ui| {
for v in ["always", "ask", "never"] {
if ui.selectable_label(current == v, name(v)).clicked() {
pick = Some(v);
}
}
});
});
});
if let Some(v) = pick {
self.state.local_execution = v == "always";
self.state.local_never = v == "never";
self.mark();
}
if locked {
sec_note(ui, p, "由你的管理員設定,無法變更");
}
// 經這台電腦轉送流量 (the box's new connections leave through this computer).
let allowed = self.managed.egress.allows() && cloudbox::docker_host().is_empty();
let mut via = cloudbox::egress_via_mac() && allowed;
let help = "將 Bot 的電腦的網路流量改經這台電腦而非雲端轉送,例如要連公司內網或 VPN 才看得到的服務。僅對新連線生效。";
let desc = if !self.managed.egress.allows() {
"已由管理員停用".to_string()
} else if !cloudbox::docker_host().is_empty() {
"Bot 的電腦在別的主機上時不能用".to_string()
} else if via {
match (crate::egress::port(), &run) {
(_, Run::Asleep) => "電腦休眠中,喚醒後會重新連線".to_string(),
(Some(_), Run::Running) => format!("本次已轉送 {} 個連線", crate::egress::CONNECTIONS.load(Ordering::Relaxed)),
_ => "等待 Bot 的電腦連線".to_string(),
}
} else {
String::new()
};
let mut egress_err = None;
sec_row_s(ui, p, "經這台電腦轉送流量", &desc, help, |ui| {
if ui.add_enabled(allowed, toggle(&mut via)).changed() {
if let Err(e) = cloudbox::set_egress_via_mac(via) {
egress_err = Some(e);
}
}
});
if let Some(e) = egress_err {
*self.cu.note.lock().unwrap() = e;
}
// 其他電腦: every AnyBot signed in to this account (from the relay), then SSH computers.
let signed_in = !self.state.remote.relay_url.is_empty() && !self.state.remote.host_token.is_empty();
if signed_in && !self.demo && self.cu.peers_at.map_or(true, |t| t.elapsed().as_secs() > 20) {
self.cu.peers_at = Some(Instant::now());
let (slot, settings) = (self.cu.peers.clone(), self.state.remote.clone());
let ctx = ui.ctx().clone();
std::thread::spawn(move || {
let me = settings.host_id.clone();
let list: Vec<_> = crate::remote::devices_online(&settings).into_iter().filter(|(id, _, _)| *id != me).collect();
*slot.lock().unwrap() = Some(list);
ctx.request_repaint();
});
}
let peers: Vec<(String, String, bool)> = if self.demo { vec![("d1".into(), "辦公室的 Mac mini".into(), true), ("d2".into(), "家裡的筆電".into(), false)] } else { self.cu.peers.lock().unwrap().clone().unwrap_or_default() };
let ssh = self.state.remote_computers.clone();
let n = peers.len() + ssh.len();
let mut names: Vec<String> = peers.iter().map(|p| p.1.clone()).collect();
names.extend(ssh.iter().map(|c| c.name.clone()));
let others_help = if signed_in { "在其他電腦安裝 AnyBot 並登入同一個帳號,就會自動出現在這裡。也可以用 SSH 加入。" } else { "登入 AnyBot 帳號(設定 → 一般 → 手機與帳號)後,同一個帳號裡的電腦會自動出現在這裡。也可以用 SSH 加入。" };
let desc = if n == 0 {
String::new()
} else if n == 1 {
match (peers.first(), ssh.first()) {
(Some(p), _) => format!("{} · {}", p.1, t(if p.2 { "目前在線上" } else { "離線" })),
(_, Some(c)) => format!("{} · SSH", c.name),
_ => String::new(),
}
} else {
names.join("、")
};
let open = self.cu.others_open;
let mut flip = false;
sec_row_s(ui, p, "其他電腦", &desc, others_help, |ui| {
let b = egui::Button::new(RichText::new(format!("{n} {}", if open { "⌄" } else { "›" })).size(FONT_SM)).corner_radius(8);
if ui.add(b).clicked() {
flip = true;
}
});
if flip {
self.cu.others_open = !open;
}
if open {
for (_, name, online) in &peers {
let status = t(if *online { "目前在線上" } else { "離線" });
sec_row_s(ui, p, name, &format!("{} · {status}", t("同一個帳號")), "", |ui| {
let (r, _) = ui.allocate_exact_size(vec2(10.0, 10.0), egui::Sense::hover());
ui.painter().circle_filled(r.center(), 3.0, if *online { Color32::from_rgb(0x22, 0xa0, 0x55) } else { p.text3 });
});
}
for (i, c) in ssh.iter().enumerate() {
let mut remove = false;
sec_row_s(ui, p, &c.name, &format!("SSH · {}", c.ssh_target), "", |ui| {
if ui.add(egui::Button::new(RichText::new(t("移除")).color(p.danger)).corner_radius(8)).on_hover_text(format!("{} {}", t("移除"), c.name)).clicked() {
remove = true;
}
});
if remove {
self.cu.confirm_remove = Some(i);
}
}
let mut add = None;
let taken = self.state.computer_names();
Frame::new().inner_margin(Margin::symmetric(14, 8)).show(ui, |ui| {
ui.horizontal(|ui| {
let w = (ui.available_width() - 80.0) / 2.0;
ui.add(egui::TextEdit::singleline(&mut self.new_remote.0).hint_text(t("SSH 電腦名稱,例如 工作站")).desired_width(w - 8.0));
ui.add(egui::TextEdit::singleline(&mut self.new_remote.1).hint_text("me@studio.local").desired_width(w - 8.0));
let ok = !self.new_remote.0.trim().is_empty() && !self.new_remote.1.trim().is_empty() && !taken.contains(&self.new_remote.0.trim().to_string());
if ui.add_enabled(ok, secondary_button("新增")).clicked() {
add = Some(crate::model::RemoteComputer { name: self.new_remote.0.trim().into(), ssh_target: self.new_remote.1.trim().into(), dir: String::new() });
}
});
});
if let Some(c) = add {
self.state.remote_computers.push(c);
self.new_remote = Default::default();
self.mark();
}
}
// This computer for the account's other AnyBots (裝置連結).
if signed_in {
let mut link = self.state.remote.device_link;
sec_row(ui, p, "讓帳號裡的其他電腦使用這台", "同一個帳號的其他 AnyBot(例如放在伺服器上的)可以看這台的畫面、在這台上做事。每個動作還是會先問你。要讓 Bot 操作別台,就在那台打開這個。要移除一台電腦,在那台登出帳號即可。", |ui| {
ui.add(toggle(&mut link));
});
if link != self.state.remote.device_link {
self.state.remote.device_link = link;
self.mark();
}
}
});
// ── 訊息 (Mac) ──
if cfg!(target_os = "macos") {
sec_title(ui, "訊息", p, |_| {});
let mut send = self.state.messages_send;
let mut no_ask = self.state.messages_no_ask;
sec_card(ui, p, |ui| {
sec_row(ui, p, "傳送訊息", "允許 Bot 以你的身分從這台 Mac 上的「訊息」傳送訊息", |ui| {
ui.add(toggle(&mut send));
});
if send {
sec_row(ui, p, "無需詢問直接傳送", "每則訊息都跳過核准卡片", |ui| {
ui.add(toggle(&mut no_ask));
});
}
});
if send != self.state.messages_send || no_ask != self.state.messages_no_ask {
self.state.messages_send = send;
self.state.messages_no_ask = no_ask;
self.mark();
}
}
// ── AnyBot Computer Use: seeing and using this computer's screen ──
self.mac_permissions_card(ui);
// ── 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 {
None,
Recheck,
Download,
Docker,
Browser,
Sleep,
Wake,
View,
UpdateClick,
Reset,
Snapshot,
AskClean,
Disk,
}
let mut act = Act::None;
let armed = self.cu.arm_update.filter(|t| t.elapsed().as_secs_f32() < 3.0).is_some();
if armed {
ui.ctx().request_repaint_after(std::time::Duration::from_millis(300));
}
let busy_rebuild = matches!(run, Run::Rebuilding(..));
let update_ready = self.cu.update_ready;
let working: Vec<String> = self.streams.keys().filter_map(|id| self.state.bot(*id).map(|b| b.name.clone())).collect();
let mut on = cloudbox::auto_update_at().is_some();
let mut at = cloudbox::auto_update_at().unwrap_or_else(|| self.cu.schedule_edit.clone());
if at.is_empty() {
at = "03:00".into();
}
let mut schedule_changed = false;
let disk = self.cu.disk.lock().unwrap().clone();
let note = self.cu.note.lock().unwrap().clone();
sec_card(ui, p, |ui| {
match &run {
Run::NoDocker(why) => {
sec_row_s(ui, p, "Bot 的電腦", "還沒有 Docker", &format!("{why}\n在那之前,Bot 的電腦是一個封閉的資料夾(沒有桌面)。"), |ui| {
if ui.add(secondary_button("重新檢查")).clicked() {
act = Act::Recheck;
}
if cfg!(target_os = "macos") {
if ui.add(primary_button("下載 OrbStack", p)).clicked() {
act = Act::Download;
}
} else if ui.add(primary_button("安裝 Docker 的說明", p)).clicked() {
act = Act::Docker;
}
});
return;
}
Run::Rebuilding(phase, detail) => {
let steps: &[&str] = if matches!(phase.as_str(), "backing-up" | "moving") { &["backing-up", "creating", "moving"] } else { &["cleaning-up", "wiping", "creating", "restoring"] };
let at = steps.iter().position(|s| s == phase).unwrap_or(0);
Frame::new().inner_margin(Margin::symmetric(14, 10)).show(ui, |ui| {
ui.set_width(ui.available_width());
ui.horizontal(|ui| {
for (i, s) in steps.iter().enumerate() {
let done = i < at;
let on = i == at;
let col = if done || on { p.accent } else { p.text3 };
let icon = if done { crate::icons::Icon::CircleCheck } else if on { crate::icons::Icon::Loader } else { crate::icons::Icon::Minus };
crate::icons::label(ui, icon, cloudbox::phase_label(s), FONT_XS, col);
if i + 1 < steps.len() {
ui.label(RichText::new("—").size(FONT_XS).color(p.text3));
}
}
});
ui.add_space(6.0);
ui.add(egui::ProgressBar::new((at as f32 + 0.5) / steps.len() as f32).desired_height(6.0).fill(p.accent));
ui.add_space(4.0);
hint(ui, format!("{detail}…(開始後就不能取消)"), p);
});
}
_ => {
// The screen: AnyBot has no per-bot overview, so it opens from here.
sec_row(ui, p, "Bot 的電腦", "一台 Linux 電腦,你所有的 Bot 共用,每個 Bot 有自己的資料夾。閒置一陣子會自動休眠,需要時自動喚醒。", |ui| {
if ui.add(primary_button("開啟電腦", p)).on_hover_text(t("看 Bot 的電腦的螢幕,也可以取得控制權自己操作")).clicked() {
act = Act::View;
}
match run {
Run::Running => {
if ui.add(secondary_button("休眠")).clicked() {
act = Act::Sleep;
}
}
_ => {
if ui.add(secondary_button("喚醒電腦")).clicked() {
act = Act::Wake;
}
}
}
if ui.add(secondary_button("在瀏覽器開啟")).on_hover_text(t("用 noVNC 全螢幕觀看")).clicked() {
act = Act::Browser;
}
});
}
}
// 更新 Bot 的電腦: two clicks (the first arms it for 3 s).
sec_row(ui, p, "更新 Bot 的電腦", "更新你的 Bot 共用的電腦。你的檔案和登入資訊會保留,但已安裝的應用程式和套件會被移除。所有 Bot 會一起更新。", |ui| {
let b = if armed {
egui::Button::new(RichText::new(t("再點選一次以確認")).color(p.danger)).fill(p.danger.gamma_multiply(0.14)).corner_radius(8).min_size(vec2(64.0, 30.0))
} else {
secondary_button("更新")
};
if ui.add_enabled(update_ready && !busy_rebuild, b).clicked() {
act = Act::UpdateClick;
}
});
// The status line under it.
let (icon, text, accent) = if !update_ready {
(crate::icons::Icon::CircleCheck, t("你的電腦已是最新版本"), true)
} else if !working.is_empty() {
let names = if working.len() > 3 { format!("{}、另外 {} 位", working[..3].join("、"), working.len() - 3) } else { working.join("、") };
(crate::icons::Icon::Clock, format!("更新會停止 {names}"), false)
} else {
(crate::icons::Icon::Update, t("有新版的 Bot 的電腦可以更新"), true)
};
Frame::new().inner_margin(Margin { left: 14, right: 14, top: 0, bottom: 8 }).show(ui, |ui| {
let fill = if accent { p.accent.gamma_multiply(0.09) } else { p.hover };
let stroke = if accent { Stroke::new(0.5, p.accent.gamma_multiply(0.6)) } else { Stroke::NONE };
Frame::new().fill(fill).stroke(stroke).corner_radius(8).inner_margin(Margin { left: 12, right: 8, top: 8, bottom: 8 }).show(ui, |ui| {
ui.set_width(ui.available_width());
crate::icons::label(ui, icon, &text, FONT_SM, if accent { p.accent } else { p.text2 });
});
});
// 自動更新 (at a local time each day, an hour to catch up).
let desc = if on { format!("每天 {}", at.trim()) } else { String::new() };
sec_row_s(ui, p, "自動更新", &desc, "有新映像檔可用時,電腦會在你選的時間自動更新;錯過的話一小時內還會補做。開始後不能取消;有 Bot 在用電腦時會等它做完。", |ui| {
let changed = ui.add(toggle(&mut on)).changed();
let r = ui.add(egui::TextEdit::singleline(&mut at).desired_width(56.0).char_limit(5).font(egui::TextStyle::Monospace));
if changed || r.lost_focus() {
schedule_changed = true;
}
});
// 重設 Bot 的電腦: from the last snapshot (red, asks first).
let snap = cloudbox::snapshot_time().map(|t| t.format("%m/%d %H:%M").to_string());
let desc = match &snap { Some(t) => format!("上次快照:{t}"), None => t("還沒有快照") };
sec_row_s(ui, p, "重設 Bot 的電腦", &desc, "電腦卡住時可重新開始。它會從你上次儲存的快照重建,最近的變更可能會遺失;沒有快照時會換一台全新的電腦。「儲存快照」把 Bot 的檔案備份到這台電腦,休眠前也會自動存一份。", |ui| {
if ui.add_enabled(!busy_rebuild, danger_button("重設", p)).clicked() {
act = Act::Reset;
}
if ui.add_enabled(run.is_running(), secondary_button("儲存快照")).on_hover_text(t("把 Bot 的檔案備份到這台電腦;休眠前也會自動存一份")).clicked() {
act = Act::Snapshot;
}
});
// 磁碟 (AnyBot).
sec_row(ui, p, "磁碟", "查看 Bot 的電腦用了多少空間,或請主要 Bot 提出清理方案(等你核准才刪)", |ui| {
if ui.add(secondary_button("請 Bot 提出清理方案")).clicked() {
act = Act::AskClean;
}
if ui.add(secondary_button("檢查用量")).clicked() {
act = Act::Disk;
}
});
if let Some(d) = &disk {
Frame::new().inner_margin(Margin::symmetric(14, 6)).show(ui, |ui| {
Frame::new().fill(p.hover).corner_radius(8).inner_margin(Margin::symmetric(10, 8)).show(ui, |ui| {
ui.set_width(ui.available_width());
ui.label(RichText::new(d).monospace().size(11.5).color(p.text2));
});
});
}
if !note.is_empty() {
sec_note(ui, p, &note);
}
});
if schedule_changed && chrono::NaiveTime::parse_from_str(at.trim(), "%H:%M").is_ok() {
cloudbox::set_auto_update_at(on.then(|| at.trim().to_string()));
}
self.cu.schedule_edit = at;
match act {
Act::None => {}
Act::Recheck => cloudbox::refresh(),
Act::Download => crate::preview::open_with_system("https://orbstack.dev/download"),
Act::Docker => crate::preview::open_with_system("https://docs.docker.com/engine/install/"),
Act::View => self.open_viewer(),
Act::Sleep => self.box_task(|| cloudbox::sleep_now().map(|_| "已休眠。".to_string())),
Act::Wake => self.box_task(|| cloudbox::ensure_running().map(|_| "Bot 的電腦已經開好了。".to_string())),
Act::Browser => self.box_task(|| {
let url = cloudbox::viewer_url(false)?;
crate::preview::open_with_system(&url);
Ok(String::new())
}),
Act::UpdateClick => {
if armed {
self.cu.arm_update = None;
let ids: Vec<u64> = self.streams.keys().copied().collect();
for id in ids {
self.stop(id);
}
*self.cu.note.lock().unwrap() = match cloudbox::rebuild(Rebuild::Update) {
Ok(()) => t("更新已開始。"),
Err(e) => e,
};
} else {
self.cu.arm_update = Some(Instant::now());
}
}
Act::Reset => self.cu.confirm_reset = true,
Act::Snapshot => self.box_task(|| cloudbox::save_snapshot().map(|n| format!("已儲存快照({})。", cloudbox::human(n)))),
Act::AskClean => self.ask_primary_bot("檢查「Bot 的電腦」的磁碟使用情況,並提出安全的清理方案供我核准。先列出用量最大的地方,不要直接刪除任何東西。"),
Act::Disk => {
let slot = self.cu.disk.clone();
*slot.lock().unwrap() = Some("檢查中…".into());
std::thread::spawn(move || {
let r = cloudbox::disk_report().unwrap_or_else(|e| e);
*slot.lock().unwrap() = Some(r);
});
}
}
// ── 終端機與環境密鑰 ──
if !matches!(run, Run::NoDocker(_)) {
sec_title(ui, "終端機與環境密鑰", p, |_| {});
sec_card(ui, p, |ui| {
Frame::new().inner_margin(Margin::symmetric(14, 8)).show(ui, |ui| {
ui.set_width(ui.available_width());
ui.spacing_mut().item_spacing.y = 4.0;
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = 4.0;
ui.label(RichText::new(t("給指令用的帳密")).size(FONT_SM).color(p.text2));
help_mark(ui, "例如 CRM_API_TOKEN。Bot 執行指令時會變成環境變數;值只能寫入,不能讀出。", p);
});
let names = cloudbox::secret_names();
let mut remove = None;
for n in &names {
ui.horizontal(|ui| {
ui.label(RichText::new(n).monospace().size(FONT_SM));
ui.label(RichText::new("••••••••").size(FONT_SM).color(p.text3));
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
if ui.add(egui::Button::new(RichText::new(t("刪除")).size(12.0).color(p.danger)).frame(false)).clicked() {
remove = Some(n.clone());
}
if ui.add(egui::Button::new(RichText::new(t("取代")).size(12.0).color(p.text2)).frame(false)).clicked() {
self.cu.new_secret = (n.clone(), String::new());
self.cu.replace_secret = Some(n.clone());
}
});
});
}
if let Some(n) = remove {
cloudbox::remove_secret(&n);
}
ui.horizontal(|ui| {
let w = (ui.available_width() - 80.0) / 2.0;
let replacing = self.cu.replace_secret.is_some();
ui.add_enabled(!replacing, egui::TextEdit::singleline(&mut self.cu.new_secret.0).hint_text(t("名稱,例如 CRM_API_TOKEN")).desired_width(w - 8.0).font(egui::TextStyle::Monospace));
ui.add(egui::TextEdit::singleline(&mut self.cu.new_secret.1).password(true).hint_text(t("值")).desired_width(w - 8.0));
let ok = !self.cu.new_secret.0.trim().is_empty() && !self.cu.new_secret.1.is_empty();
if ui.add_enabled(ok, primary_button(if replacing { "取代" } else { "新增" }, p)).clicked() {
match cloudbox::set_secret(&self.cu.new_secret.0, &self.cu.new_secret.1) {
Ok(()) => {
self.cu.new_secret = Default::default();
self.cu.replace_secret = None;
self.cu.secret_error.clear();
}
Err(e) => self.cu.secret_error = e,
}
}
});
let up = self.cu.new_secret.0.to_uppercase();
if up != self.cu.new_secret.0 {
self.cu.new_secret.0 = up;
}
if !self.cu.secret_error.is_empty() {
ui.label(RichText::new(&self.cu.secret_error).size(FONT_XS).color(p.danger));
}
});
});
// ── 在哪裡執行 ──
sec_title(ui, "在哪裡執行", p, |_| {});
let current = cloudbox::docker_host();
let mut edit = self.cu.docker_host.take().unwrap_or_else(|| current.clone());
let mut apply = false;
sec_card(ui, p, |ui| {
sec_row(ui, p, "Docker 主機", "預設在這台電腦的 Docker 裡。也可以放到雲端或公司伺服器,例如 ssh://me@server。換位置後會在新的地方重新建立,舊檔案可以用快照搬過去。", |ui| {
if ui.add_enabled(edit.trim() != current, primary_button("套用", p)).clicked() {
apply = true;
}
ui.add(egui::TextEdit::singleline(&mut edit).hint_text(t("這台電腦(預設)")).desired_width(180.0).font(egui::TextStyle::Monospace));
});
});
if apply {
cloudbox::set_docker_host(edit.trim());
}
self.cu.docker_host = Some(edit);
if ui.add(egui::Button::new(RichText::new(t(if self.cu.show_log { "隱藏紀錄" } else { "顯示紀錄" })).size(FONT_XS).color(p.text3)).frame(false)).clicked() {
self.cu.show_log = !self.cu.show_log;
}
if self.cu.show_log {
let lines = cloudbox::log_lines();
Frame::new().fill(p.hover).corner_radius(8).inner_margin(Margin::symmetric(10, 8)).show(ui, |ui| {
ui.set_width(ui.available_width());
if lines.is_empty() {
ui.label(RichText::new(t("(沒有紀錄)")).size(11.5).color(p.text3));
}
for l in lines.iter().rev().take(30) {
ui.label(RichText::new(l).monospace().size(11.0).color(p.text2));
}
});
}
}
}
// ── 示範教學、瀏覽器 ──
ui.add_space(SEC_GAP * 0.5);
ui.spacing_mut().item_spacing.y = 6.0;
self.teach_card(ui);
setting_card(ui, p, |ui| self.browser_profiles_section(ui, p));
self.computers_dialogs(ui.ctx());
}
/// 要移除「…」嗎? and 要重設 AnyBot 的電腦嗎?
fn computers_dialogs(&mut self, ctx: &egui::Context) {
let p = self.pal;
if let Some(i) = self.cu.confirm_remove {
let name = self.state.remote_computers.get(i).map(|c| c.name.clone()).unwrap_or_default();
let mut keep = true;
let mut remove = false;
let close = dialog(ctx, "remove-computer", &format!("要移除「{name}」嗎?"), 420.0, true, p, |ui| {
dialog_body(ui, "remove-computer-body", 200.0, |ui| {
ui.label(t("Bot 將無法再在這台電腦上執行任務。之後可以再用 SSH 加回來。"));
});
dialog_footer(ui, p, |ui| {
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
if ui.add(danger_button("移除", p)).clicked() {
remove = true;
keep = false;
}
if ui.add(secondary_button("取消")).clicked() {
keep = false;
}
});
});
});
if remove && i < self.state.remote_computers.len() {
self.state.remote_computers.remove(i);
self.mark();
}
if !keep || close {
self.cu.confirm_remove = None;
}
}
if self.cu.confirm_reset {
let working = self.streams.len();
let has_snap = cloudbox::snapshot_time().is_some();
let mut keep = true;
let mut go = false;
let close = dialog(ctx, "reset-computer", "要重設 AnyBot 的電腦嗎?", 460.0, true, p, |ui| {
dialog_body(ui, "reset-computer-body", 320.0, |ui| {
let w = if working == 0 { t("所有執行中的工作都將被中斷。") } else { format!("{working} 個 Bot 正在工作,將會被中斷。") };
ui.label(if has_snap { format!("從上次儲存的快照還原。這會關閉目前的電腦,並以最新版本啟動一台全新的電腦。{w}") } else { format!("還沒有快照:這會換一台全新的電腦,裡面的檔案和登入資訊都會清除。{w}") });
ui.add_space(8.0);
Frame::new().fill(p.danger.gamma_multiply(0.08)).corner_radius(8).inner_margin(Margin::same(10)).show(ui, |ui| {
ui.add(egui::Label::new(RichText::new(t("警告:尚未同步到快照的工作(包括最近的檔案)將會遺失。已安裝的應用程式和套件會被移除。")).size(FONT_SM).color(p.danger)).wrap());
});
});
dialog_footer(ui, p, |ui| {
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
if ui.add(danger_button("重設 AnyBot 的電腦", p)).clicked() {
go = true;
keep = false;
}
if ui.add(secondary_button("取消")).clicked() {
keep = false;
}
});
});
});
if go {
let ids: Vec<u64> = self.streams.keys().copied().collect();
for id in ids {
self.stop(id);
}
let kind = if has_snap { Rebuild::Restore } else { Rebuild::Reset };
*self.cu.note.lock().unwrap() = match cloudbox::rebuild(kind) {
Ok(()) => t("重設已開始。"),
Err(e) => e,
};
}
if !keep || close {
self.cu.confirm_reset = false;
}
}
}
/// 「AnyBot Computer Use」: what seeing and using this computer's screen needs.
pub(super) fn mac_permissions_card(&mut self, ui: &mut egui::Ui) {
let p = self.pal;
if cfg!(target_os = "linux") {
#[cfg(target_os = "linux")]
{
let kind = crate::desk::kind();
let (state, sub) = match kind {
crate::desk::Kind::X11 => ("已授予", "X11 桌面:可以截圖、點擊、打字,也能錄製示範"),
crate::desk::Kind::Wlr => ("已授予", "Wayland 桌面:可以截圖、點擊、打字(錄製示範需要 Xorg 工作階段)"),
crate::desk::Kind::Portal => ("第一次使用時詢問", "Wayland 桌面:第一次使用時系統會跳出視窗,打開「允許遠端操作」再按「分享」(錄製示範需要 Xorg 工作階段)"),
crate::desk::Kind::None if crate::desk::wayland() => ("無法使用", "找不到 xdg-desktop-portal 的遠端桌面功能,請安裝 xdg-desktop-portal-gnome 或 -kde"),
crate::desk::Kind::None => ("無法使用", "這台電腦沒有顯示器"),
};
sec_title(ui, "AnyBot Computer Use", p, |_| {});
sec_card(ui, p, |ui| {
sec_row(ui, p, "看畫面和操作", sub, |ui| {
ui.label(RichText::new(t(state)).size(FONT_SM).color(p.text2));
});
});
}
self.allowed_apps_section(ui);
return;
}
if !cfg!(target_os = "macos") {
return;
}
let see = crate::computer::screen_capture_ok();
let control = crate::axread::trusted(false);
sec_title(ui, "AnyBot Computer Use", p, |_| {});
let mut open_pane = None;
sec_card(ui, p, |ui| {
let state = |ok: bool| t(if ok { "已授予" } else { "未授予" });
sec_row(ui, p, "螢幕錄製", "讓 Bot 看到你的螢幕", |ui| {
ui.label(RichText::new(state(see)).size(FONT_SM).color(if see { p.text2 } else { p.danger }));
});
sec_row(ui, p, "輔助使用", "讓 Bot 點擊和打字,也用來錄製示範", |ui| {
ui.label(RichText::new(state(control)).size(FONT_SM).color(if control { p.text2 } else { p.danger }));
});
if !see || !control {
sec_row(ui, p, "授予權限", "打開系統設定的面板。把 AnyBot(或「終端機」)加到清單裡並打開。", |ui| {
if ui.add(primary_button("開啟設定", p)).clicked() {
open_pane = Some(if !see { "Privacy_ScreenCapture" } else { "Privacy_Accessibility" });
}
});
}
});
if let Some(pane) = open_pane {
let _ = std::process::Command::new("open").arg(format!("x-apple.systempreferences:com.apple.preference.security?{pane}")).spawn();
}
self.allowed_apps_section(ui);
}
/// 一律允許的應用程式: apps bots may use without asking (per bot), with a remove button.
fn allowed_apps_section(&mut self, ui: &mut egui::Ui) {
let p = self.pal;
let apps: Vec<(u64, String, String)> = self
.state
.bots
.iter()
.flat_map(|b| b.allow_rules.iter().filter_map(|g| g.strip_prefix("app:")).map(|a| (b.id, b.name.clone(), a.to_string())).collect::<Vec<_>>())
.collect();
if apps.is_empty() {
return;
}
let open = self.cu.apps_open;
let mut remove = None;
let mut flip = false;
sec_card(ui, p, |ui| {
sec_row(ui, p, "一律允許的應用程式", "Bot 無需事先詢問即可使用這些應用程式", |ui| {
let b = egui::Button::new(RichText::new(format!("{} 個應用程式 {}", apps.len(), if open { "⌄" } else { "›" })).size(FONT_SM)).corner_radius(8);
if ui.add(b).clicked() {
flip = true;
}
});
if open {
for (id, bot, app) in &apps {
sec_row_s(ui, p, app, &format!("{bot} 可以使用"), "", |ui| {
if crate::icons::button(ui, crate::icons::Icon::Trash, 14.0, p.text3, p.hover).on_hover_text(format!("{} {app}", t("移除"))).clicked() {
remove = Some((*id, format!("app:{app}")));
}
});
}
}
});
if flip {
self.cu.apps_open = !open;
}
if let Some((id, g)) = remove {
if let Some(b) = self.state.bot_mut(id) {
b.allow_rules.retain(|x| *x != g);
}
self.mark();
}
}
fn box_task(&mut self, f: impl FnOnce() -> Result<String, String> + Send + 'static) {
let note = self.cu.note.clone();
*note.lock().unwrap() = "處理中…".into();
std::thread::spawn(move || {
let r = f();
*note.lock().unwrap() = match r {
Ok(s) => s,
Err(e) => e,
};
cloudbox::refresh();
});
}
fn ask_primary_bot(&mut self, text: &str) {
let id = self.state.primary_bot;
self.drafts.insert(id, text.to_string());
self.state.selected = id;
self.show_settings = false;
}
/// The confirm dialog for a rebuild (the original's wording).
pub(super) fn rebuild_dialog(&mut self, ctx: &egui::Context) {
let Some(kind) = self.cu.confirm else { return };
let p = self.pal;
let mut go = false;
let mut close = dialog(ctx, "box-rebuild", &format!("{}?", kind.title()), 420.0, true, p, |ui| {
dialog_body(ui, "box-rebuild-body", 300.0, |ui| {
ui.add(egui::Label::new(RichText::new(kind.warning()).size(FONT_BASE)).wrap());
if kind == Rebuild::Update && cloudbox::is_busy() {
ui.add_space(6.0);
ui.label(RichText::new(t("有 Bot 正在用這台電腦,等它做完再重建。")).size(FONT_XS).color(p.danger));
}
});
dialog_footer(ui, p, |ui| {
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
let btn = if kind == Rebuild::Reset { danger_button(kind.title(), p) } else { primary_button(kind.title(), p) };
if ui.add(btn).clicked() {
go = true;
}
if ui.add(secondary_button("取消")).clicked() {
go = false;
self.cu.confirm = None;
}
});
});
});
if go {
if kind == Rebuild::Update {
// 更新會先停掉正在工作的 Bot,聊天會保留.
let ids: Vec<u64> = self.streams.keys().copied().collect();
for id in ids {
self.stop(id);
}
}
*self.cu.note.lock().unwrap() = match cloudbox::rebuild(kind) {
Ok(()) => format!("{}已開始。", kind.title()),
Err(e) => e,
};
close = true;
}
if close {
self.cu.confirm = None;
}
}
// ---------- the live screen ----------
/// The box's screen as a small picture, refreshed every few seconds while it is shown (taking
/// it never wakes the box; asleep it shows the last picture).
pub(super) fn box_thumbnail(&mut self, ctx: &egui::Context) -> Option<(egui::TextureId, egui::Vec2)> {
if let Some(img) = self.cu.thumb_img.lock().unwrap().take() {
match &mut self.cu.thumb {
Some(t) => t.set(img, egui::TextureOptions::LINEAR),
None => self.cu.thumb = Some(ctx.load_texture("box-thumb", img, egui::TextureOptions::LINEAR)),
}
}
let every = if cloudbox::run_state().is_running() { 3 } else { 30 };
if !self.cu.thumb_at.map(|t| t.elapsed().as_secs() < every).unwrap_or(false) && !self.cu.thumb_busy.swap(true, Ordering::SeqCst) {
self.cu.thumb_at = Some(Instant::now());
let (slot, busy, c) = (self.cu.thumb_img.clone(), self.cu.thumb_busy.clone(), ctx.clone());
std::thread::spawn(move || {
if let Some(img) = cloudbox::thumbnail(320) {
let size = [img.width() as usize, img.height() as usize];
*slot.lock().unwrap() = Some(egui::ColorImage::from_rgba_unmultiplied(size, img.as_raw()));
c.request_repaint();
}
busy.store(false, Ordering::SeqCst);
});
}
ctx.request_repaint_after(std::time::Duration::from_secs(every));
self.cu.thumb.as_ref().map(|t| (t.id(), t.size_vec2()))
}
pub(super) fn open_viewer(&mut self) {
self.cu.viewer_open = true;
self.cu.fit = true;
cloudbox::set_viewer_open(true);
self.show_settings = false;
if !cloudbox::run_state().is_running() && !self.demo {
self.box_task(|| cloudbox::ensure_running().map(|_| String::new()));
}
}
fn close_viewer(&mut self) {
self.cu.viewer_open = false;
self.cu.control = false;
cloudbox::HELD_BY_PERSON.store(false, Ordering::SeqCst);
cloudbox::set_viewer_open(false);
if let Some(stop) = self.cu.worker.take() {
stop.store(true, Ordering::SeqCst);
}
self.cu.input_tx = None;
}
fn ensure_viewer_threads(&mut self, ctx: &egui::Context) {
if self.cu.worker.is_some() || (self.demo && !self.cu.demo_live) {
return;
}
let stop = Arc::new(AtomicBool::new(false));
self.cu.worker = Some(stop.clone());
let slot = self.cu.frame.clone();
let c = ctx.clone();
let s2 = stop.clone();
std::thread::spawn(move || {
let mut seq = 0u64;
while !s2.load(Ordering::SeqCst) {
let got = cloudbox::frame();
if let Some(img) = got {
seq += 1;
let size = [img.width() as usize, img.height() as usize];
let ci = egui::ColorImage::from_rgba_unmultiplied(size, img.as_raw());
*slot.lock().unwrap() = Some((seq, ci));
c.request_repaint();
std::thread::sleep(std::time::Duration::from_millis(VIEWER_FRAME_MS));
} else {
std::thread::sleep(std::time::Duration::from_millis(1000));
c.request_repaint();
}
}
});
let (tx, rx) = channel::<Input>();
self.cu.input_tx = Some(tx);
std::thread::spawn(move || {
while let Ok(i) = rx.recv() {
match i {
Input::Click(x, y, b, n) => cloudbox::viewer_click(x, y, b, n),
Input::Move(x, y) => cloudbox::viewer_move(x, y),
Input::Scroll(x, y, down, n) => cloudbox::viewer_scroll(x, y, down, n),
Input::Key(k) => cloudbox::viewer_key(&k),
Input::Text(t) => cloudbox::viewer_text(&t),
}
}
});
}
pub(super) fn viewer_window(&mut self, ctx: &egui::Context) {
if !self.cu.viewer_open {
return;
}
self.ensure_viewer_threads(ctx);
let p = self.pal;
if let Some((seq, img)) = self.cu.frame.lock().unwrap().take() {
if seq != self.cu.shown_seq {
self.cu.shown_seq = seq;
self.cu.frame_size = img.size;
match &mut self.cu.texture {
Some(t) => t.set(img, egui::TextureOptions::LINEAR),
None => self.cu.texture = Some(ctx.load_texture("box-screen", img, egui::TextureOptions::LINEAR)),
}
}
}
let run = cloudbox::run_state();
let screen = ctx.content_rect();
let open = true;
let default = vec2((screen.width() * 0.62).min(900.0), (screen.width() * 0.62).min(900.0) * 0.625 + 52.0);
let busy = cloudbox::is_busy();
let mut close_clicked = false;
egui::Window::new("Bot 的電腦")
.id(egui::Id::unique("box-viewer"))
.title_bar(false)
.default_size(default)
.default_pos(screen.center() - default / 2.0)
.min_size(vec2(420.0, 300.0))
.resizable(true)
.collapsible(false)
.frame(
Frame::new()
.fill(p.elevated)
.corner_radius(14)
.inner_margin(Margin::symmetric(14, 12))
.stroke(Stroke::new(1.0, p.border))
.shadow(egui::epaint::Shadow { offset: [0, 14], blur: 40, spread: 0, color: Color32::from_black_alpha(60) }),
)
.show(ctx, |ui| {
ui.horizontal(|ui| {
monitor_icon(ui, 20.0, p.text);
ui.label(RichText::new(crate::model::BOT_COMPUTER_NAME).strong().size(FONT_LG));
status_pill(ui, &run, p);
if busy && !self.cu.control {
crate::icons::label(ui, crate::icons::Icon::Bot, &t("Bot 正在操作"), FONT_XS, p.accent);
}
if self.cu.control {
crate::icons::label(ui, crate::icons::Icon::Users, &t("你正在操作,Bot 已暫停"), FONT_XS, p.accent);
}
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
let (rect, r) = ui.allocate_exact_size(vec2(28.0, 28.0), egui::Sense::click());
if r.hovered() {
ui.painter().rect_filled(rect, 8, p.hover);
}
let c = rect.center();
let st = Stroke::new(1.5, if r.hovered() { p.text } else { p.text2 });
ui.painter().line_segment([c + vec2(-5.0, -5.0), c + vec2(5.0, 5.0)], st);
ui.painter().line_segment([c + vec2(-5.0, 5.0), c + vec2(5.0, -5.0)], st);
if r.on_hover_text(t("關閉")).clicked() {
close_clicked = true;
}
ui.add_space(4.0);
if ui.add(secondary_button("全螢幕")).on_hover_text(t("在瀏覽器用 noVNC 全螢幕開啟")).clicked() {
let control = self.cu.control;
self.box_task(move || {
let url = cloudbox::viewer_url(control)?;
crate::preview::open_with_system(&url);
Ok(String::new())
});
}
let fit_label = if self.cu.fit { "實際大小" } else { "調整至視窗大小" };
if ui.add(secondary_button(fit_label)).clicked() {
self.cu.fit = !self.cu.fit;
}
let (label, fill, col) = if self.cu.control { ("交還給 Bot", p.accent, Color32::WHITE) } else { ("取得控制權", p.primary, p.on_primary) };
let btn = egui::Button::new(RichText::new(label).strong().color(col)).fill(fill).corner_radius(8).min_size(vec2(96.0, 30.0));
if ui.add_enabled(run.is_running(), btn).on_hover_text(if self.cu.control { "Bot 可以繼續操作" } else { "你自己點擊和輸入;Bot 的操作會先暫停" }).clicked() {
self.cu.control = !self.cu.control;
cloudbox::HELD_BY_PERSON.store(self.cu.control, Ordering::SeqCst);
}
});
});
ui.add_space(6.0);
let avail = ui.available_size();
match (self.cu.texture.as_ref().map(|t| t.id()), run.is_running()) {
(Some(tex_id), true) => {
let [fw, fh] = self.cu.frame_size;
let (fw, fh) = (fw.max(1) as f32, fh.max(1) as f32);
let size = if self.cu.fit {
let k = (avail.x / fw).min((avail.y.max(120.0)) / fh);
vec2(fw * k, fh * k)
} else {
vec2(fw, fh)
};
egui::ScrollArea::both().auto_shrink([false, false]).show(ui, |ui| {
let sense = if self.cu.control { egui::Sense::click_and_drag() } else { egui::Sense::hover() };
let resp = ui.add(egui::Image::new((tex_id, size)).corner_radius(8).sense(sense));
if self.cu.control {
ui.painter().rect_stroke(resp.rect, 8, Stroke::new(2.0, p.accent), egui::StrokeKind::Outside);
self.forward_input(ui, &resp, (fw, fh));
}
});
}
_ => {
let (rect, _) = ui.allocate_exact_size(vec2(avail.x, avail.y.max(200.0)), egui::Sense::hover());
ui.painter().rect_filled(rect, 8, Color32::from_rgb(0x2b, 0x2f, 0x36));
let text = match &run {
Run::Running => "正在連線…".to_string(),
Run::NoDocker(why) => why.clone(),
Run::Failed(e) => format!("發生問題:{e}"),
other => format!("{}…", other.label()),
};
ui.painter().text(rect.center(), egui::Align2::CENTER_CENTER, text, egui::FontId::proportional(FONT_BASE), Color32::from_gray(220));
if matches!(run, Run::Asleep | Run::Absent) {
let b = egui::Rect::from_center_size(rect.center() + vec2(0.0, 34.0), vec2(90.0, 30.0));
if ui.put(b, primary_button("喚醒", p)).clicked() {
self.box_task(|| cloudbox::ensure_running().map(|_| String::new()));
}
}
}
}
});
if close_clicked || !open {
self.close_viewer();
}
ctx.request_repaint_after(std::time::Duration::from_millis(VIEWER_FRAME_MS));
}
/// Clicks, scrolls and keys on the picture go to the box while the person has control.
fn forward_input(&mut self, ui: &mut egui::Ui, resp: &egui::Response, frame: (f32, f32)) {
let Some(tx) = self.cu.input_tx.clone() else { return };
let to_box = |pos: egui::Pos2| -> (i64, i64) {
let r = resp.rect;
(((pos.x - r.left()) / r.width() * frame.0).round() as i64, ((pos.y - r.top()) / r.height() * frame.1).round() as i64)
};
if resp.clicked() || resp.secondary_clicked() || resp.double_clicked() {
resp.request_focus();
}
if let Some(pos) = resp.interact_pointer_pos().or(resp.hover_pos()) {
let (x, y) = to_box(pos);
if resp.double_clicked() {
let _ = tx.send(Input::Click(x, y, "left", 2));
} else if resp.clicked() {
let _ = tx.send(Input::Click(x, y, "left", 1));
} else if resp.secondary_clicked() {
let _ = tx.send(Input::Click(x, y, "right", 1));
} else if resp.hovered() {
let d = ui.input(|i| i.smooth_scroll_delta.y);
if d.abs() > 0.5 {
let _ = tx.send(Input::Scroll(x, y, d < 0.0, ((d.abs() / 40.0).ceil() as u32).max(1)));
} else if ui.input(|i| i.pointer.delta().length() > 3.0) {
let _ = tx.send(Input::Move(x, y));
}
}
}
if !resp.has_focus() {
return;
}
ui.memory_mut(|m| m.set_focus_lock_filter(resp.id, egui::EventFilter { tab: true, horizontal_arrows: true, vertical_arrows: true, escape: true }));
let events = ui.input(|i| i.events.clone());
for e in events {
match e {
egui::Event::Text(t) => {
let _ = tx.send(Input::Text(t));
}
egui::Event::Paste(t) => {
let _ = tx.send(Input::Text(t));
}
egui::Event::Key { key, pressed: true, modifiers, .. } => {
let name = match key {
Key::Enter => "Return",
Key::Tab => "Tab",
Key::Backspace => "BackSpace",
Key::Delete => "Delete",
Key::Escape => "Escape",
Key::ArrowUp => "Up",
Key::ArrowDown => "Down",
Key::ArrowLeft => "Left",
Key::ArrowRight => "Right",
Key::Home => "Home",
Key::End => "End",
Key::PageUp => "Prior",
Key::PageDown => "Next",
_ if modifiers.command || modifiers.ctrl || modifiers.alt => {
let n = key.name();
if n.chars().count() == 1 {
n
} else {
continue;
}
}
_ => continue,
};
let mut combo = vec![];
if modifiers.command || modifiers.ctrl {
combo.push("ctrl".to_string());
}
if modifiers.alt {
combo.push("alt".to_string());
}
if modifiers.shift && (name.len() > 1 || !combo.is_empty()) {
combo.push("shift".to_string());
}
combo.push(if name.len() == 1 { name.to_lowercase() } else { name.to_string() });
let _ = tx.send(Input::Key(combo.join("+")));
}
_ => {}
}
}
}
// ---------- the floating bar while a bot drives this Mac ----------
pub(super) fn local_control_bar(&mut self, ctx: &egui::Context) {
if self.demo {
return;
}
let held = crate::computer::HELD_BY_PERSON.load(Ordering::SeqCst);
let recent = crate::computer::LAST_ACTION.lock().unwrap().clone().filter(|(t, _)| t.elapsed().as_secs() < LOCAL_BAR_SECS);
if !held && recent.is_none() {
return;
}
let who = recent.map(|r| r.1).filter(|w| !w.is_empty()).unwrap_or_else(|| "Bot".into());
let p = self.pal;
let mut stop_bot: Option<String> = None;
let mut toggle = false;
let monitor_w = ctx.input(|i| i.viewport().monitor_size).map(|s| s.x).unwrap_or(1440.0);
ctx.show_viewport_immediate(
egui::ViewportId::from_hash_of("anybot-local-control"),
egui::ViewportBuilder::default()
.with_title("AnyBot 正在操作你的電腦")
.with_inner_size([440.0, 46.0])
.with_position([(monitor_w - 440.0) / 2.0, 34.0])
.with_decorations(false)
.with_always_on_top()
.with_resizable(false)
.with_transparent(true),
|ui, _| {
Frame::new().fill(p.elevated).stroke(Stroke::new(1.0, p.border)).corner_radius(23).inner_margin(Margin::symmetric(16, 8)).show(ui, |ui| {
ui.set_width(ui.available_width());
ui.horizontal(|ui| {
let (r, _) = ui.allocate_exact_size(vec2(10.0, 10.0), egui::Sense::hover());
ui.painter().circle_filled(r.center(), 4.5, if held { p.text3 } else { Color32::from_rgb(0xfc, 0x2c, 0x3e) });
let text = if held { "你正在操作,Bot 已暫停".to_string() } else { format!("{who} 正在操作你的電腦") };
ui.label(RichText::new(text).size(FONT_SM).strong());
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
if !held && ui.add(egui::Button::new(RichText::new(t("停止")).size(FONT_XS)).corner_radius(14)).clicked() {
stop_bot = Some(who.clone());
}
let label = if held { "交還給 Bot" } else { "取得控制權" };
if ui.add(egui::Button::new(RichText::new(label).size(FONT_XS).strong().color(p.on_primary)).fill(p.primary).corner_radius(14)).clicked() {
toggle = true;
}
});
});
});
},
);
if toggle {
crate::computer::HELD_BY_PERSON.store(!held, Ordering::SeqCst);
if held {
*crate::computer::LAST_ACTION.lock().unwrap() = None;
}
}
if let Some(name) = stop_bot {
let ids: Vec<u64> = self.streams.keys().copied().filter(|id| self.state.bot(*id).map(|b| b.name == name).unwrap_or(false)).collect();
for id in ids {
self.stop(id);
}
*crate::computer::LAST_ACTION.lock().unwrap() = None;
}
ctx.request_repaint_after(std::time::Duration::from_millis(500));
}
}
// ---------- 示範教學 ----------
impl App {
/// 教它一項任務 from the + menu: start recording right away under a dated name.
pub(super) fn teach_quick(&mut self, bot_id: u64) {
let Some(bot) = self.state.bot(bot_id).cloned() else { return };
if crate::teach::recording().is_some() {
return;
}
self.cu.teach_name = format!("{} {}", t("示範"), chrono::Local::now().format("%m/%d %H:%M"));
match crate::teach::start(&bot.name) {
Ok(()) => {
self.cu.teach_for = Some(bot_id);
self.cu.teach_error.clear();
}
Err(e) => self.att.error = Some(e),
}
}
pub(super) fn teach_card(&mut self, ui: &mut egui::Ui) {
if !crate::computer::demo_supported() {
return;
}
let p = self.pal;
let bot_id = self.state.selected;
let Some(bot) = self.state.bot(bot_id).cloned() else { return };
setting_card(ui, p, |ui| {
setting_title(ui, "示範教學", &format!("錄下你在這台 Mac 上執行任務的過程。{} 會學習這些步驟,之後可以自行再次執行。", bot.name), p);
ui.horizontal(|ui| {
ui.add(egui::TextEdit::singleline(&mut self.cu.teach_name).hint_text(t("這個任務叫什麼,例如 匯出本月報表")).desired_width(ui.available_width() - 110.0));
let ok = !self.cu.teach_name.trim().is_empty() && crate::teach::recording().is_none();
if ui.add_enabled(ok, primary_button("開始錄製", p)).clicked() {
match crate::teach::start(&bot.name) {
Ok(()) => {
self.cu.teach_for = Some(bot_id);
self.cu.teach_error.clear();
self.show_settings = false;
}
Err(e) => self.cu.teach_error = e,
}
}
});
if !self.cu.teach_error.is_empty() {
ui.add(egui::Label::new(RichText::new(&self.cu.teach_error).size(FONT_XS).color(p.danger)).wrap());
}
hint(ui, "按下開始後照平常的方式操作;做完按畫面上方的「完成」。會記錄點擊、打字和捲動,並且每 0.5 秒拍一張螢幕畫面(需要「螢幕與系統錄音」權限),完成後合成錄影;每一步會留一張當時的畫面給 Bot 看。不會擋住或改變你的操作。", p);
let mut remove = None;
for (i, d) in bot.demos.iter().enumerate() {
ui.add_space(6.0);
Frame::new().fill(p.elevated).corner_radius(10).inner_margin(Margin::symmetric(12, 8)).show(ui, |ui| {
ui.set_width(ui.available_width());
ui.horizontal(|ui| {
ui.label(RichText::new(&d.name).strong().size(FONT_SM));
ui.label(RichText::new(format!("{} · {} 個步驟", d.created, d.steps.len())).size(11.5).color(p.text3));
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
if ui.add(egui::Button::new(RichText::new(t("刪除")).size(12.0).color(p.text3)).frame(false)).clicked() {
remove = Some(i);
}
if !d.video.is_empty() && ui.add(egui::Button::new(RichText::new(t("播放錄影")).size(12.0).color(p.accent)).frame(false)).clicked() {
crate::preview::open_with_system(&d.video.to_string());
}
});
});
egui::CollapsingHeader::new(RichText::new(t("步驟")).size(FONT_XS).color(p.text2)).id_salt(("demo", bot_id, i)).show(ui, |ui| {
for (n, s) in d.steps.iter().enumerate() {
ui.horizontal(|ui| {
ui.label(RichText::new(format!("{}. {s}", n + 1)).size(FONT_XS).color(p.text2));
if let Some(shot) = d.shots.get(n).filter(|x| !x.is_empty()) {
if ui.add(egui::Button::new(RichText::new(t("畫面")).size(11.0).color(p.accent)).frame(false)).on_hover_text(t("打開這一步當時的畫面")).clicked() {
crate::preview::open_with_system(&shot.to_string());
}
}
});
}
});
});
}
if let Some(i) = remove {
if let Some(b) = self.state.bot_mut(bot_id) {
b.demos.remove(i);
}
self.mark();
}
});
}
/// 「{Bot} 正在觀看並學習」 with 完成 / 取消, on top of every window while recording.
pub(super) fn teach_bar(&mut self, ctx: &egui::Context) {
let Some((who, since)) = crate::teach::recording() else { return };
let p = self.pal;
let mut finish = false;
let mut cancel = false;
let monitor_w = ctx.input(|i| i.viewport().monitor_size).map(|s| s.x).unwrap_or(1440.0);
ctx.show_viewport_immediate(
egui::ViewportId::from_hash_of("anybot-teach"),
egui::ViewportBuilder::default()
.with_title("AnyBot 示範教學")
.with_inner_size([420.0, 46.0])
.with_position([(monitor_w - 420.0) / 2.0, 34.0])
.with_decorations(false)
.with_always_on_top()
.with_resizable(false)
.with_transparent(true),
|ui, _| {
Frame::new().fill(p.elevated).stroke(Stroke::new(1.0, p.border)).corner_radius(23).inner_margin(Margin::symmetric(16, 8)).show(ui, |ui| {
ui.set_width(ui.available_width());
ui.horizontal(|ui| {
let (r, _) = ui.allocate_exact_size(vec2(10.0, 10.0), egui::Sense::hover());
let t = ui.input(|i| i.time) as f32;
ui.painter().circle_filled(r.center(), 4.5, Color32::from_rgb(0xfc, 0x2c, 0x3e).gamma_multiply(0.6 + 0.4 * (t * 3.0).sin().abs()));
let secs = since.elapsed().as_secs();
ui.label(RichText::new(format!("{who} 正在觀看並學習 {:02}:{:02}", secs / 60, secs % 60)).size(FONT_SM).strong());
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
if ui.add(egui::Button::new(RichText::new(crate::i18n::t("取消")).size(FONT_XS)).corner_radius(14)).clicked() {
cancel = true;
}
if ui.add(egui::Button::new(RichText::new(crate::i18n::t("完成")).size(FONT_XS).strong().color(p.on_primary)).fill(p.primary).corner_radius(14)).clicked() {
finish = true;
}
});
});
});
},
);
ctx.request_repaint_after(std::time::Duration::from_millis(250));
if cancel {
crate::teach::cancel();
}
if finish {
let demo = crate::teach::finish(&self.cu.teach_name);
let n = demo.steps.len();
if let Some(id) = self.cu.teach_for.take() {
if let Some(b) = self.state.bot_mut(id) {
b.demos.retain(|d| d.name != demo.name);
b.demos.push(demo);
}
self.notice = Some(format!("已記下「{}」({n} 個步驟)。之後跟 Bot 說「照示範做 {}」就可以。", self.cu.teach_name.trim(), self.cu.teach_name.trim()));
self.state.selected = id;
}
self.cu.teach_name.clear();
self.mark();
}
}
}
/// An on/off switch (the original's toggle, size md).
fn toggle(on: &mut bool) -> impl egui::Widget + '_ {
move |ui: &mut egui::Ui| -> egui::Response {
let size = vec2(36.0, 20.0);
let (rect, mut r) = ui.allocate_exact_size(size, egui::Sense::click());
if r.clicked() {
*on = !*on;
r.mark_changed();
}
let t = ui.ctx().animate_bool_responsive(r.id, *on);
let off_col = ui.visuals().widgets.inactive.bg_fill;
let on_col = ui.visuals().selection.bg_fill;
let fill = if ui.is_enabled() { egui::lerp(egui::Rgba::from(off_col)..=egui::Rgba::from(on_col), t).into() } else { off_col.gamma_multiply(0.6) };
ui.painter().rect_filled(rect, 10, fill);
let x = egui::lerp((rect.left() + 10.0)..=(rect.right() - 10.0), t);
ui.painter().circle_filled(egui::pos2(x, rect.center().y), 8.0, Color32::WHITE);
r.on_hover_cursor(egui::CursorIcon::PointingHand)
}
}
/// The original's settings layout: sections 28 px apart, a small grey title (and something on
/// its right), then a card of rows (12 px corners, a hairline, 8 px top and bottom).
const SEC_GAP: f32 = 28.0;
fn sec_title(ui: &mut egui::Ui, title: &str, p: Palette, accessory: impl FnOnce(&mut egui::Ui)) {
ui.add_space(SEC_GAP * 0.5);
ui.horizontal(|ui| {
ui.add_space(14.0);
ui.label(RichText::new(t(title)).size(FONT_SM).color(p.text2));
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
ui.add_space(4.0);
accessory(ui);
});
});
ui.add_space(8.0);
}
fn sec_card(ui: &mut egui::Ui, p: Palette, body: impl FnOnce(&mut egui::Ui)) {
Frame::new()
.fill(p.hover.gamma_multiply(0.45))
.stroke(Stroke::new(0.5, p.border))
.corner_radius(12)
.inner_margin(Margin::symmetric(0, 8))
.show(ui, |ui| {
ui.set_width(ui.available_width());
ui.spacing_mut().item_spacing.y = 0.0;
body(ui)
});
ui.add_space(SEC_GAP * 0.5);
}
/// A row: the setting's name (「?」 explains on hover) on the left, its control on the right.
fn sec_row(ui: &mut egui::Ui, p: Palette, label: &str, help: &str, control: impl FnOnce(&mut egui::Ui)) {
sec_row_s(ui, p, label, "", help, control)
}
/// A row with a short status line under the name (目前在線上、上次快照…).
fn sec_row_s(ui: &mut egui::Ui, p: Palette, label: &str, status: &str, help: &str, control: impl FnOnce(&mut egui::Ui)) {
Frame::new().inner_margin(Margin::symmetric(14, 10)).show(ui, |ui| {
ui.set_width(ui.available_width());
ui.horizontal(|ui| {
let w = (ui.available_width() * 0.58).max(200.0);
ui.vertical(|ui| {
ui.set_width(w);
ui.spacing_mut().item_spacing.y = 2.0;
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = 4.0;
ui.label(RichText::new(t(label)).size(FONT_BASE).color(p.text));
help_mark(ui, help, p);
});
if !status.is_empty() {
ui.add(egui::Label::new(RichText::new(t(status)).size(FONT_SM).color(p.text2)).truncate());
}
});
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
ui.spacing_mut().item_spacing.x = 6.0;
control(ui)
});
});
});
}
/// A small note inside a card.
fn sec_note(ui: &mut egui::Ui, p: Palette, text: &str) {
Frame::new().inner_margin(Margin { left: 14, right: 14, top: 0, bottom: 6 }).show(ui, |ui| {
ui.add(egui::Label::new(RichText::new(t(text)).size(FONT_XS).color(p.text3)).wrap());
});
}