Checked by using it: tests stay off the real box, gVisor scripts run clean, times are the person's

- box_numbers_only_on_what_is_seen is ignored again (a test inserted before it in cbada6b took
  its mark: every cargo test drove the real box), and the path test runs
- gvisor-vm.sh restarts the VM's Docker only when runsc was just registered (a restart stopped
  Bot 的電腦), and `${var}` before full-width text (bash read 「)」's first byte as part of
  the name and stopped)
- a scheduled box update and a PDF's name use the person's time zone (a server keeps UTC)
- 容器隔離 lists 標準 before 強隔離
- screenshots of real data can open 設定 › 電腦 (ANYBOT_SHOT_VIEW=computers) and scroll
  (ANYBOT_SHOT_SCROLL)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uirha2RVfSYmrhqNnqx9Z4
This commit is contained in:
王性驊 2026-11-07 00:19:56 +08:00
parent 4605b317d8
commit 9a23411e51
6 changed files with 58 additions and 26 deletions

View File

@ -14,9 +14,9 @@ VMHOST="unix://$HOME/.lima/$VM/sock/docker.sock"
command -v docker >/dev/null 2>&1 || { echo "找不到 docker 指令"; exit 1; }
if [ -n "${DOCKER_HOST:-}" ]; then
echo "現在環境裡有 DOCKER_HOST=$DOCKER_HOST(要從本機 Docker 搬出,請用普通終端機跑)"; exit 1
echo "現在環境裡有 DOCKER_HOST=${DOCKER_HOST}(要從本機 Docker 搬出,請用普通終端機跑)"; exit 1
fi
docker volume inspect "$VOL" >/dev/null 2>&1 || { echo "本機找不到資料卷 $VOL(box 還沒建立過?)"; exit 1; }
docker volume inspect "$VOL" >/dev/null 2>&1 || { echo "本機找不到資料卷 ${VOL}(box 還沒建立過?)"; exit 1; }
limactl list "$VM" --format '{{.Status}}' 2>/dev/null | grep -q Running || { echo "VM $VM 沒在跑,先跑 gvisor-vm.sh"; exit 1; }
# The Mac app's process is "AnyBot" and `anybot --serve` is "anybot": match either case.
if pgrep -ix anybot >/dev/null 2>&1; then

View File

@ -20,7 +20,7 @@ DISK=60 # GiB
if [ "${1:-}" = "--rm" ]; then
limactl delete --force "$VM" 2>/dev/null || true
echo "已移除 VM $VM。"
echo "已移除 VM ${VM}。"
exit 0
fi
@ -30,7 +30,7 @@ STATUS=$(limactl list "$VM" --format '{{.Status}}' 2>/dev/null || true)
if [ "$STATUS" = "Running" ]; then
echo "VM $VM 已在執行。"
elif [ -n "$STATUS" ]; then
echo "VM $VM 是 $STATUS,啟動中…"
echo "VM $VM 是 ${STATUS},啟動中…"
limactl start --tty=false "$VM"
else
echo "建立 VM ${VM}($CPUS CPU / ${MEM}GB / ${DISK}GB,第一次要下載映像檔,幾分鐘)…"
@ -50,15 +50,19 @@ if ! command -v runsc >/dev/null 2>&1; then
sudo apt-get install -y -qq runsc
fi
# Register the runtime in the VM'\''s Docker daemon (merge, do not clobber other settings).
sudo python3 - <<EOF
# Restart Docker only when that changed: a restart stops Bot 的電腦 running in this VM.
CHANGED=$(sudo python3 - <<EOF
import json, os
p = "/etc/docker/daemon.json"
d = json.load(open(p)) if os.path.exists(p) else {}
if d.get("runtimes", {}).get("runsc", {}).get("path") != "runsc":
d.setdefault("runtimes", {})["runsc"] = {"path": "runsc"}
os.makedirs("/etc/docker", exist_ok=True)
json.dump(d, open(p, "w"), indent=2)
print("changed")
EOF
sudo systemctl restart docker
)
if [ "$CHANGED" = changed ]; then sudo systemctl restart docker; fi
# Prove the wall works: a container whose kernel really is gVisor.
docker run --rm --runtime=runsc --entrypoint sh alpine:3.20 -c "grep -q gVisor /proc/version && uname -r" | sed "s/^/runsc 容器核心: /"
'
@ -75,7 +79,7 @@ KERNEL=$(DOCKER_HOST="unix://$SOCK" docker run --rm --runtime=runsc --entrypoint
cat <<EOF
都好了(從 Mac 驗證:runsc 容器的核心是 $KERNEL)。AnyBot 設定 › 電腦 › 在哪裡執行:
都好了(從 Mac 驗證:runsc 容器的核心是 ${KERNEL})。AnyBot 設定 › 電腦 › 在哪裡執行:
Docker 主機: unix://$SOCK
容器隔離: 強隔離(gVisor)
然後在電腦卡片按「建立/更新」。要讓舊 box 的檔案與登入跟著走,

View File

@ -554,11 +554,21 @@ 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") {
// Real-data screenshots can open 設定 › 手機與帳號 (to press its buttons with ANYBOT_SHOT_CLICK)
// or 設定 › 電腦 (容器隔離 shows what the box really runs on).
if app.screenshot.is_some() && !app.demo {
match std::env::var("ANYBOT_SHOT_VIEW").as_deref() {
Ok("remote") => {
app.show_settings = true;
app.settings_tab = SettingsTab::Remote;
}
Ok("computers") => {
app.show_settings = true;
app.settings_tab = SettingsTab::Computers;
}
_ => {}
}
}
// …or the side panel, or the computer viewer, as they look with that data.
if app.screenshot.is_some() && !app.demo {
match std::env::var("ANYBOT_SHOT_VIEW").as_deref() {
@ -3716,6 +3726,18 @@ impl eframe::App for App {
}
}
}
// ANYBOT_SHOT_SCROLL="x,y,dy@frame": from that frame on, a wheel step of dy over x,y on
// each of 100 frames (egui eases a wheel in over real time; screenshot frames are quick).
if let Ok(sc) = std::env::var("ANYBOT_SHOT_SCROLL") {
if let Some((v, at)) = sc.split_once('@') {
let n: Vec<f32> = v.split(',').filter_map(|x| x.trim().parse().ok()).collect();
let (at, f) = (at.trim().parse::<u32>().unwrap_or(30), self.frames + 1);
if n.len() == 3 && f >= at && f < at + 100 {
raw.events.push(egui::Event::PointerMoved(egui::pos2(n[0], n[1])));
raw.events.push(egui::Event::MouseWheel { unit: egui::MouseWheelUnit::Point, delta: vec2(0.0, n[2]), phase: egui::TouchPhase::Move, source: egui::MouseWheelSource::Unknown, modifiers: Default::default() });
}
}
}
let Ok(spec) = std::env::var("ANYBOT_SHOT_CLICK") else { return };
for one in spec.split(';') {
let (pos, right) = match one.strip_suffix(":right") { Some(p) => (p, true), None => (one, false) };

View File

@ -641,14 +641,15 @@ impl App {
let rt = if self.cu.box_runtime.is_empty() { rt_now.clone() } else { self.cu.box_runtime.clone() };
let std_on = rt == "runc";
let gvis_on = rt == "runsc";
if ui.selectable_label(std_on, t("標準")).clicked() {
self.cu.box_runtime = "runc".into();
}
// (The row lays out right to left: 強隔離 first puts 標準 on its left.)
let g = ui.add_enabled_ui(gvisor_here || gvis_on, |ui| ui.selectable_label(gvis_on, t("強隔離(gVisor)"))).inner;
if g.clicked() {
self.cu.box_runtime = "runsc".into();
}
g.on_disabled_hover_text(t("這台 Docker 主機沒有裝 gVisor(runsc)"));
if ui.selectable_label(std_on, t("標準")).clicked() {
self.cu.box_runtime = "runc".into();
}
if !self.cu.box_runtime.is_empty() && self.cu.box_runtime != rt_now {
if ui.add(primary_button("套用", p)).clicked() {
cloudbox::set_box_runtime(&self.cu.box_runtime);

View File

@ -764,7 +764,7 @@ pub fn run(name: &str, args: &Value) -> Result<(String, Option<String>), String>
let bytes = base64::engine::general_purpose::STANDARD.decode(r["data"].as_str().unwrap_or("")).map_err(|e| e.to_string())?;
let (title, _, _) = snapshot(&mut tab);
let safe: String = title.chars().filter(|c| !"/\\:*?\"<>|".contains(*c)).take(60).collect();
let name = format!("{}-{}.pdf", if safe.trim().is_empty() { "網頁".into() } else { safe }, chrono::Local::now().format("%Y%m%d-%H%M%S"));
let name = format!("{}-{}.pdf", if safe.trim().is_empty() { "網頁".into() } else { safe }, chrono::Utc::now().with_timezone(&crate::routines::person_tz()).format("%Y%m%d-%H%M%S"));
// Saved where the bot can open it: on Bot 的電腦 when that's the browser's computer.
let path = if botbrowser::using_box() {
let p = format!("{}/{name}", crate::cloudbox::BOX_DOWNLOADS);

View File

@ -420,7 +420,8 @@ fn maybe_scheduled_update() {
(c.auto_update_at.clone(), c.auto_update_done.clone())
};
let Some(at) = at else { return };
let now = chrono::Local::now();
// The person's clock (a brain on a server keeps UTC: 03:00 used to be 11:00 in Taipei).
let now = chrono::Utc::now().with_timezone(&crate::routines::person_tz());
let today = now.format("%Y-%m-%d").to_string();
if done == today || !in_schedule_window(&at, now.time()) || is_busy() || !update_available() {
return;
@ -2240,13 +2241,6 @@ mod tests {
/// (xterm has no accessibility; clicking a number there used to type into the terminal).
#[test]
#[ignore]
fn box_paths_are_the_bot_users() {
assert_eq!(box_path("~/bot-6/附件/IMG_0587.jpeg"), "/home/bot/bot-6/附件/IMG_0587.jpeg");
assert_eq!(box_path(" ~ "), "/home/bot");
assert_eq!(box_path("/tmp/a.jpg"), "/tmp/a.jpg");
}
#[test]
fn box_numbers_only_on_what_is_seen() {
ensure_running().expect("box starts");
let cancel = AtomicBool::new(false);
@ -2576,6 +2570,13 @@ mod tests {
let _ = run_on("shell", r#"{"command":"cd .. && rm -rf bot-998"}"#, &t, &cancel, &ids);
}
#[test]
fn box_paths_are_the_bot_users() {
assert_eq!(box_path("~/bot-6/附件/IMG_0587.jpeg"), "/home/bot/bot-6/附件/IMG_0587.jpeg");
assert_eq!(box_path(" ~ "), "/home/bot");
assert_eq!(box_path("/tmp/a.jpg"), "/tmp/a.jpg");
}
#[test]
fn schedule_window_is_one_hour() {
let t = |s: &str| chrono::NaiveTime::parse_from_str(s, "%H:%M").unwrap();
@ -2585,6 +2586,10 @@ mod tests {
assert!(!in_schedule_window("03:00", t("02:59")));
assert!(in_schedule_window("23:30", t("00:10")));
assert!(!in_schedule_window("bad", t("00:10")));
// On a server (UTC), 03:00 is the person's 03:00: 19:00 UTC in Taipei.
use chrono::TimeZone;
let at = chrono::Utc.with_ymd_and_hms(2026, 11, 6, 19, 0, 0).unwrap().with_timezone(&chrono_tz::Asia::Taipei);
assert!(in_schedule_window("03:00", at.time()));
assert_eq!(wanted_version(), "7");
}