Clippy is clean on macOS and Linux, for the app and the relay

Every warning `cargo clippy --all-targets` gave (50 on macOS, 8 more only on
Linux, 1 in the relay), fixed without changing what anything does:
- long types get a name (Peer, Members, Imported, Joining, Mention, Stream…);
- the two big enum variants are boxed (the vault's login plan, joining an
  account);
- a test now waits for the copy it relaunched (it left a zombie behind);
- a hint's format had a dead `if` whose two branches were both "";
- the rest is what clippy suggests: sort_by_key, as_chunks, is_none_or,
  is_multiple_of, while let, from_ref, next_back, struct init, no casts to
  the same type.

Checked: cargo test (177 macOS, 181 Linux), relay tests (11), remote_e2e
28/28, and the live Linux test (X11; Wayland clicks, keys, Chinese typing,
scroll and screenshots).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uirha2RVfSYmrhqNnqx9Z4
This commit is contained in:
王性驊 2026-11-07 10:10:28 +08:00
parent f3ccd129d6
commit 4506cc149b
32 changed files with 107 additions and 85 deletions

View File

@ -187,6 +187,9 @@ struct Sock {
static SERIAL: AtomicU64 = AtomicU64::new(1); static SERIAL: AtomicU64 = AtomicU64::new(1);
/// A screen being watched: where the host's side goes, and that host.
type Stream = (Sender<Vec<u8>>, String);
struct Relay { struct Relay {
dir: PathBuf, dir: PathBuf,
data: Mutex<Data>, data: Mutex<Data>,
@ -204,7 +207,7 @@ struct Relay {
uses: Mutex<HashMap<String, String>>, uses: Mutex<HashMap<String, String>>,
hands: Mutex<std::collections::HashSet<String>>, hands: Mutex<std::collections::HashSet<String>>,
/// People watching a bots' computer: stream id → (where the host's side goes, that host). /// People watching a bots' computer: stream id → (where the host's side goes, that host).
streams: Mutex<HashMap<String, (Sender<Vec<u8>>, String)>>, streams: Mutex<HashMap<String, Stream>>,
/// One-time links for a browser to watch a host's computer: ticket → (account, host, made). /// One-time links for a browser to watch a host's computer: ticket → (account, host, made).
tickets: Mutex<HashMap<String, (String, String, Instant)>>, tickets: Mutex<HashMap<String, (String, String, Instant)>>,
/// 配對碼: code → (account, the AnyBot it is for, when made). Ten minutes, once. /// 配對碼: code → (account, the AnyBot it is for, when made). Ten minutes, once.

View File

@ -1037,7 +1037,7 @@ pub fn spawn(job: Job, bot_id: u64, tx: Sender<(u64, StreamEvent)>, cancel: Arc<
} }
if call.name == crate::vault::TOOL || call.name == crate::vault::CARD_TOOL || call.name == crate::vault::CONTACT_TOOL { if call.name == crate::vault::TOOL || call.name == crate::vault::CARD_TOOL || call.name == crate::vault::CONTACT_TOOL {
enum Plan { enum Plan {
Login(crate::vault::LoginPlan), Login(Box<crate::vault::LoginPlan>),
Card, Card,
Contact, Contact,
} }
@ -1045,7 +1045,7 @@ pub fn spawn(job: Job, bot_id: u64, tx: Sender<(u64, StreamEvent)>, cancel: Arc<
crate::vault::plan_login(&call.args).map(|pl| { crate::vault::plan_login(&call.args).map(|pl| {
let q = pl.question(); let q = pl.question();
let grant = if pl.matched { format!("login:{}", pl.host) } else { String::new() }; let grant = if pl.matched { format!("login:{}", pl.host) } else { String::new() };
(Plan::Login(pl), q, grant) (Plan::Login(Box::new(pl)), q, grant)
}) })
} else if call.name == crate::vault::CARD_TOOL { } else if call.name == crate::vault::CARD_TOOL {
crate::vault::plan_card(&call.args).map(|(host, c)| (Plan::Card, format!("用保險庫的卡片({} •••• {})在 {host} 填入付款資料(Bot 看不到卡號和安全碼)", c.display_title(), c.last4), String::new())) crate::vault::plan_card(&call.args).map(|(host, c)| (Plan::Card, format!("用保險庫的卡片({} •••• {})在 {host} 填入付款資料(Bot 看不到卡號和安全碼)", c.display_title(), c.last4), String::new()))
@ -1855,8 +1855,8 @@ echo '<meta charset=utf-8><title>done</title>送出了' > done.html; (setsid pyt
#[test] #[test]
fn a_click_by_number_is_named_in_the_question() { fn a_click_by_number_is_named_in_the_question() {
let list = Message::tool_result("c1", "computer_use_screenshot", "[王小明的Mac mini] 螢幕截圖(1024×576)。\n「計算機」上可以操作的東西(圖上同樣的編號,用 ref 點):\n 文字「0」\n[4] 按鈕「全部清除」\n[7] 按鈕「7」"); let list = Message::tool_result("c1", "computer_use_screenshot", "[王小明的Mac mini] 螢幕截圖(1024×576)。\n「計算機」上可以操作的東西(圖上同樣的編號,用 ref 點):\n 文字「0」\n[4] 按鈕「全部清除」\n[7] 按鈕「7」");
assert_eq!(element_named(&[list.clone()], "7").as_deref(), Some("按鈕「7」")); assert_eq!(element_named(std::slice::from_ref(&list), "7").as_deref(), Some("按鈕「7」"));
assert_eq!(element_named(&[list.clone()], "e7").as_deref(), Some("按鈕「7」")); assert_eq!(element_named(std::slice::from_ref(&list), "e7").as_deref(), Some("按鈕「7」"));
assert_eq!(element_named(&[list], "9"), None); assert_eq!(element_named(&[list], "9"), None);
assert_eq!(computer::describe("computer_use_click", r#"{"ref":"7"}"#), "點 #7"); assert_eq!(computer::describe("computer_use_click", r#"{"ref":"7"}"#), "點 #7");
assert_eq!(computer::describe("computer_use_click", r#"{"x":10,"y":20}"#), "點 (10, 20)"); assert_eq!(computer::describe("computer_use_click", r#"{"x":10,"y":20}"#), "點 (10, 20)");

View File

@ -571,9 +571,8 @@ impl App {
} }
// …or the side panel, or the computer viewer, as they look with that data. // …or the side panel, or the computer viewer, as they look with that data.
if app.screenshot.is_some() && !app.demo { if app.screenshot.is_some() && !app.demo {
match std::env::var("ANYBOT_SHOT_VIEW").as_deref() { if let Ok("panel") = std::env::var("ANYBOT_SHOT_VIEW").as_deref() {
Ok("panel") => app.panel = Some(panel_ui::PanelTab::Activity), app.panel = Some(panel_ui::PanelTab::Activity);
_ => {}
} }
} }
app app
@ -2597,10 +2596,8 @@ impl App {
ui.label(RichText::new(t("你平常的 Chrome,切到哪個設定檔就讀哪個")).size(11.5).color(p.text2)); ui.label(RichText::new(t("你平常的 Chrome,切到哪個設定檔就讀哪個")).size(11.5).color(p.text2));
if crate::axread::trusted(false) { if crate::axread::trusted(false) {
crate::icons::label(ui, crate::icons::Icon::Check, &t("輔助使用已打開"), 11.5, p.text3); crate::icons::label(ui, crate::icons::Icon::Check, &t("輔助使用已打開"), 11.5, p.text3);
} else if ui.small_button(t("打開輔助使用")).on_hover_text(t("macOS 要你允許一次,AnyBot 才看得到你的瀏覽器視窗")).clicked() { } else if ui.small_button(t("打開輔助使用")).on_hover_text(t("macOS 要你允許一次,AnyBot 才看得到你的瀏覽器視窗")).clicked() && !crate::axread::trusted(true) {
if !crate::axread::trusted(true) { let _ = crate::computer::spawn_reaped(std::process::Command::new("open").arg("x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility"));
let _ = crate::computer::spawn_reaped(&mut std::process::Command::new("open").arg("x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility"));
}
} }
return; return;
} }
@ -4451,12 +4448,13 @@ mod tests {
let mut old = std::process::Command::new("sleep").arg("1").spawn().unwrap(); let mut old = std::process::Command::new("sleep").arg("1").spawn().unwrap();
// A leftover start-up switch in this copy's environment must not reach the new copy. // A leftover start-up switch in this copy's environment must not reach the new copy.
std::env::set_var("ANYBOT_SHOT_VIEW", "leftover"); std::env::set_var("ANYBOT_SHOT_VIEW", "leftover");
relaunch_command(old.id(), &fake_app).spawn().unwrap(); let mut relaunched = relaunch_command(old.id(), &fake_app).spawn().unwrap();
std::env::remove_var("ANYBOT_SHOT_VIEW"); std::env::remove_var("ANYBOT_SHOT_VIEW");
std::thread::sleep(std::time::Duration::from_millis(500)); std::thread::sleep(std::time::Duration::from_millis(500));
assert!(!marker.exists(), "started before the old copy exited"); assert!(!marker.exists(), "started before the old copy exited");
old.wait().unwrap(); old.wait().unwrap();
std::thread::sleep(std::time::Duration::from_millis(700)); std::thread::sleep(std::time::Duration::from_millis(700));
assert_eq!(std::fs::read_to_string(&marker).unwrap().trim(), "clean"); assert_eq!(std::fs::read_to_string(&marker).unwrap().trim(), "clean");
relaunched.wait().unwrap();
} }
} }

View File

@ -158,7 +158,7 @@ impl App {
}); });
}); });
let bot = bots.iter().find(|b| b.0 == c.bot_id).map(|b| b.1.clone()).unwrap_or_else(|| "(Bot 已刪除)".into()); let bot = bots.iter().find(|b| b.0 == c.bot_id).map(|b| b.1.clone()).unwrap_or_else(|| "(Bot 已刪除)".into());
hint(ui, format!("由「{bot}」回覆 · {}{}", if c.target.is_empty() { "任何聊天".to_string() } else { c.target.clone() }, if c.kind == Kind::Telegram { "" } else { "" }), p); hint(ui, format!("由「{bot}」回覆 · {}", if c.target.is_empty() { "任何聊天".to_string() } else { c.target.clone() }), p);
}); });
} }
if let Some(i) = remove { if let Some(i) = remove {

View File

@ -10,12 +10,15 @@ use crate::cloudbox::{self, Rebuild, Run};
/// The floating bar stays this long after the bot's last mouse/keyboard action on this Mac. /// The floating bar stays this long after the bot's last mouse/keyboard action on this Mac.
const LOCAL_BAR_SECS: u64 = 8; const LOCAL_BAR_SECS: u64 = 8;
/// Another AnyBot of the account: (id, name, online).
pub type Peer = (String, String, bool);
#[derive(Default)] #[derive(Default)]
pub struct ComputerUi { pub struct ComputerUi {
/// 其他電腦 is expanded. /// 其他電腦 is expanded.
pub others_open: bool, pub others_open: bool,
/// The account's other AnyBots (id, name, online), fetched from the relay now and then. /// 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: Arc<Mutex<Option<Vec<Peer>>>>,
pub peers_at: Option<Instant>, pub peers_at: Option<Instant>,
/// 你的電腦's name being edited. /// 你的電腦's name being edited.
pub renaming_machine: Option<String>, pub renaming_machine: Option<String>,
@ -101,7 +104,7 @@ impl App {
let p = self.pal; let p = self.pal;
let run = cloudbox::run_state(); let run = cloudbox::run_state();
// Check now and then whether this AnyBot carries a newer computer. // 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) { if !self.demo && self.cu.update_checked.is_none_or(|t| t.elapsed().as_secs() > 30) {
self.cu.update_checked = Some(Instant::now()); self.cu.update_checked = Some(Instant::now());
let flag = self.cu.update_flag.clone(); let flag = self.cu.update_flag.clone();
let facts = self.cu.runtime_facts.clone(); let facts = self.cu.runtime_facts.clone();
@ -216,7 +219,7 @@ impl App {
} }
// 其他電腦: every AnyBot signed in to this account (from the relay), then SSH computers. // 其他電腦: 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(); 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) { if signed_in && !self.demo && self.cu.peers_at.is_none_or(|t| t.elapsed().as_secs() > 20) {
self.cu.peers_at = Some(Instant::now()); self.cu.peers_at = Some(Instant::now());
let (slot, settings) = (self.cu.peers.clone(), self.state.remote.clone()); let (slot, settings) = (self.cu.peers.clone(), self.state.remote.clone());
let ctx = ui.ctx().clone(); let ctx = ui.ctx().clone();

View File

@ -72,7 +72,9 @@ pub fn parse(text: &str) -> Vec<Block> {
let mut in_inline: Option<Option<u8>> = None; // Some(None)=paragraph, Some(Some(n))=heading let mut in_inline: Option<Option<u8>> = None; // Some(None)=paragraph, Some(Some(n))=heading
let mut code: Option<(String, String)> = None; let mut code: Option<(String, String)> = None;
// Tables. // Tables.
let mut table: Option<(Vec<Alignment>, Vec<Vec<Span>>, Vec<Vec<Vec<Span>>>, bool)> = None; // (column alignments, header cells, body rows, inside the header)
type Table = (Vec<Alignment>, Vec<Vec<Span>>, Vec<Vec<Vec<Span>>>, bool);
let mut table: Option<Table> = None;
let mut row: Vec<Vec<Span>> = vec![]; let mut row: Vec<Vec<Span>> = vec![];
fn push_block(stack: &mut [Frame], b: Block) { fn push_block(stack: &mut [Frame], b: Block) {

View File

@ -353,6 +353,9 @@ enum Out {
Move { ops: Vec<Value>, n: usize }, Move { ops: Vec<Value>, n: usize },
} }
/// An edit sent to the brain and not yet confirmed: (its number, the operations).
type Pending = (u64, Vec<Value>);
#[derive(Default)] #[derive(Default)]
pub struct Mirror { pub struct Mirror {
/// This window shows a brain (instead of its own bots). /// This window shows a brain (instead of its own bots).
@ -382,7 +385,7 @@ pub struct Mirror {
/// Requests to the brain, sent in order by one worker. /// Requests to the brain, sent in order by one worker.
outbox: Option<Sender<Out>>, outbox: Option<Sender<Out>>,
/// Edits sent but not yet confirmed (by number). /// Edits sent but not yet confirmed (by number).
pending: Arc<Mutex<Vec<(u64, Vec<Value>)>>>, pending: Arc<Mutex<Vec<Pending>>>,
seq: u64, seq: u64,
/// Something didn't reach the brain: ask for everything again. /// Something didn't reach the brain: ask for everything again.
resync: Arc<AtomicBool>, resync: Arc<AtomicBool>,

View File

@ -356,7 +356,7 @@ impl App {
} }
} }
} }
files.sort_by(|a, b| b.2.cmp(&a.2)); files.sort_by_key(|f| std::cmp::Reverse(f.2));
// 頁面 the bot made come first. // 頁面 the bot made come first.
let has_pages = self.pages_library(ui, bot); let has_pages = self.pages_library(ui, bot);
if files.is_empty() && has_pages { if files.is_empty() && has_pages {

View File

@ -7,6 +7,9 @@ use crate::remote::{self, Cmd, Settings};
use serde_json::json; use serde_json::json;
use std::hash::{Hash, Hasher}; use std::hash::{Hash, Hasher};
/// 團隊 as the relay answered: each (user, role), or why it could not be loaded.
type Members = Result<Vec<(String, String)>, String>;
#[derive(Default)] #[derive(Default)]
pub struct RemoteUi { pub struct RemoteUi {
sig: u64, sig: u64,
@ -28,7 +31,7 @@ pub struct RemoteUi {
last_seen_mark: Option<Instant>, last_seen_mark: Option<Instant>,
peers_at: Option<Instant>, peers_at: Option<Instant>,
/// 團隊: (user, role) shared with, loaded from the relay; the form to add one. /// 團隊: (user, role) shared with, loaded from the relay; the form to add one.
members: Arc<Mutex<Option<Result<Vec<(String, String)>, String>>>>, members: Arc<Mutex<Option<Members>>>,
new_member: String, new_member: String,
new_admin: bool, new_admin: bool,
started: bool, started: bool,

View File

@ -255,7 +255,7 @@ impl App {
let q = self.search.trim().to_lowercase(); let q = self.search.trim().to_lowercase();
let matches = |b: &Bot| q.is_empty() || b.name.to_lowercase().contains(&q) || b.description.to_lowercase().contains(&q); let matches = |b: &Bot| q.is_empty() || b.name.to_lowercase().contains(&q) || b.description.to_lowercase().contains(&q);
let primary = self.state.bot(self.state.primary_bot).filter(|b| matches(b)).map(|b| b.id); let primary = self.state.bot(self.state.primary_bot).filter(|b| matches(b)).map(|b| b.id);
let pins: Vec<u64> = self.pinned_ids().into_iter().filter(|id| self.state.bot(*id).map(|b| matches(b)).unwrap_or(false)).collect(); let pins: Vec<u64> = self.pinned_ids().into_iter().filter(|id| self.state.bot(*id).is_some_and(matches)).collect();
let folders = self.all_folders(); let folders = self.all_folders();
let has_sections = !folders.is_empty(); let has_sections = !folders.is_empty();
// 分區 in order (None = 未分區); pinned bots show only in the grid. // 分區 in order (None = 未分區); pinned bots show only in the grid.

View File

@ -56,6 +56,9 @@ pub struct ContactForm {
pub err: String, pub err: String,
} }
/// An import's result: (logins, cards, the ones that failed with why).
pub type Imported = (usize, usize, Vec<(String, String)>);
#[derive(Default)] #[derive(Default)]
pub struct ImportState { pub struct ImportState {
pub picking: Option<Arc<Mutex<Option<Vec<PathBuf>>>>>, pub picking: Option<Arc<Mutex<Option<Vec<PathBuf>>>>>,
@ -66,7 +69,7 @@ pub struct ImportState {
pub queue: Vec<usize>, pub queue: Vec<usize>,
pub total: usize, pub total: usize,
pub stop: bool, pub stop: bool,
pub done: Option<(usize, usize, Vec<(String, String)>)>, pub done: Option<Imported>,
} }
pub enum VaultDialog { pub enum VaultDialog {

View File

@ -1594,7 +1594,9 @@ fn box_scale() -> Result<(f64, f64), String> {
/// steps in one exec with one stdin, so steps can't ride on it). /// steps in one exec with one stdin, so steps can't ride on it).
pub fn box_script(name: &str, v: &Value, scale: (f64, f64)) -> Result<(String, String), String> { pub fn box_script(name: &str, v: &Value, scale: (f64, f64)) -> Result<(String, String), String> {
// A control's number from the latest picture: its centre on the desktop. // A control's number from the latest picture: its centre on the desktop.
let by_number = |p: &Value| -> Option<Result<((i64, i64), String), String>> { // (the point to click, what it is) or why there is none.
type Found = Result<((i64, i64), String), String>;
let by_number = |p: &Value| -> Option<Found> {
let r = match &p["ref"] { let r = match &p["ref"] {
Value::String(r) if !r.trim().is_empty() => r.trim().to_string(), Value::String(r) if !r.trim().is_empty() => r.trim().to_string(),
Value::Number(n) => n.to_string(), Value::Number(n) => n.to_string(),

View File

@ -357,7 +357,7 @@ pub fn encode_jpeg(img: &image::RgbImage) -> Option<Vec<u8>> {
if out.len() <= MAX_IMAGE_BYTES { if out.len() <= MAX_IMAGE_BYTES {
return Some(out); return Some(out);
} }
if best.as_ref().map_or(true, |b| out.len() < b.len()) { if best.as_ref().is_none_or(|b| out.len() < b.len()) {
best = Some(out); best = Some(out);
} }
} }
@ -517,7 +517,7 @@ pub fn ask_permission(pane: &str) {
} }
_ => {} _ => {}
} }
let _ = spawn_reaped(&mut std::process::Command::new("open").arg(format!("x-apple.systempreferences:com.apple.preference.security?{pane}"))); let _ = spawn_reaped(std::process::Command::new("open").arg(format!("x-apple.systempreferences:com.apple.preference.security?{pane}")));
} }
const NO_ACCESSIBILITY: &str = "macOS 還沒讓 AnyBot 使用「輔助使用」,所以不能讀 App,也不能動滑鼠和鍵盤。這台電腦上已經跳出詢問並打開「系統設定 → 隱私權與安全性 → 輔助使用」:請使用者把 AnyBot 打開(只要一次),再叫我試一次。"; const NO_ACCESSIBILITY: &str = "macOS 還沒讓 AnyBot 使用「輔助使用」,所以不能讀 App,也不能動滑鼠和鍵盤。這台電腦上已經跳出詢問並打開「系統設定 → 隱私權與安全性 → 輔助使用」:請使用者把 AnyBot 打開(只要一次),再叫我試一次。";

View File

@ -43,9 +43,7 @@ pub fn svg(source: &str, dark: bool) -> Result<String, String> {
/// Mermaid source → RGBA pixels at `scale` (2.0 for sharp text on Retina). /// Mermaid source → RGBA pixels at `scale` (2.0 for sharp text on Retina).
pub fn render(source: &str, dark: bool, scale: f32) -> Result<image::RgbaImage, String> { pub fn render(source: &str, dark: bool, scale: f32) -> Result<image::RgbaImage, String> {
let svg = svg(source, dark)?; let svg = svg(source, dark)?;
let mut opt = resvg::usvg::Options::default(); let opt = resvg::usvg::Options { fontdb: fontdb(), font_family: "sans-serif".into(), ..Default::default() };
opt.fontdb = fontdb();
opt.font_family = "sans-serif".into();
let tree = resvg::usvg::Tree::from_str(&svg, &opt).map_err(|e| e.to_string())?; let tree = resvg::usvg::Tree::from_str(&svg, &opt).map_err(|e| e.to_string())?;
let size = tree.size(); let size = tree.size();
let (w, h) = ((size.width() * scale).ceil().max(1.0) as u32, (size.height() * scale).ceil().max(1.0) as u32); let (w, h) = ((size.width() * scale).ceil().max(1.0) as u32, (size.height() * scale).ceil().max(1.0) as u32);
@ -56,7 +54,7 @@ pub fn render(source: &str, dark: bool, scale: f32) -> Result<image::RgbaImage,
resvg::render(&tree, resvg::tiny_skia::Transform::from_scale(scale, scale), &mut pm.as_mut()); resvg::render(&tree, resvg::tiny_skia::Transform::from_scale(scale, scale), &mut pm.as_mut());
// tiny-skia is premultiplied; egui wants straight alpha. // tiny-skia is premultiplied; egui wants straight alpha.
let mut px = pm.take(); let mut px = pm.take();
for c in px.chunks_exact_mut(4) { for c in px.as_chunks_mut::<4>().0 {
let a = c[3] as u32; let a = c[3] as u32;
if a > 0 && a < 255 { if a > 0 && a < 255 {
for v in &mut c[..3] { for v in &mut c[..3] {

View File

@ -185,7 +185,7 @@ fn jxa(script: &str, args: &Value) -> Result<String, String> {
fn permission_hint(app: &str, err: &str) -> String { fn permission_hint(app: &str, err: &str) -> String {
let e = err.to_lowercase(); let e = err.to_lowercase();
if e.contains("-1743") || e.contains("not allowed") || e.contains("不允許") || e.contains("not authorized") { if e.contains("-1743") || e.contains("not allowed") || e.contains("不允許") || e.contains("not authorized") {
let _ = crate::computer::spawn_reaped(&mut std::process::Command::new("open").arg("x-apple.systempreferences:com.apple.preference.security?Privacy_Automation")); let _ = crate::computer::spawn_reaped(std::process::Command::new("open").arg("x-apple.systempreferences:com.apple.preference.security?Privacy_Automation"));
return format!("AnyBot 還不能控制這台 Mac 的「{app}」。我已經打開「系統設定 → 隱私權與安全性 → 自動化」:在 AnyBot(或「終端機」)底下打開「{app}」,再叫我試一次。"); return format!("AnyBot 還不能控制這台 Mac 的「{app}」。我已經打開「系統設定 → 隱私權與安全性 → 自動化」:在 AnyBot(或「終端機」)底下打開「{app}」,再叫我試一次。");
} }
format!("「{app}」回報錯誤:{}", err.lines().last().unwrap_or(err)) format!("「{app}」回報錯誤:{}", err.lines().last().unwrap_or(err))

View File

@ -160,7 +160,7 @@ fn svg_icon(svg: &str, px: u32) -> egui::IconData {
resvg::render(&tree, resvg::tiny_skia::Transform::from_scale(s, s), &mut pm.as_mut()); resvg::render(&tree, resvg::tiny_skia::Transform::from_scale(s, s), &mut pm.as_mut());
// tiny-skia is premultiplied; IconData wants straight alpha. // tiny-skia is premultiplied; IconData wants straight alpha.
let mut rgba = pm.take(); let mut rgba = pm.take();
for c in rgba.chunks_exact_mut(4) { for c in rgba.as_chunks_mut::<4>().0 {
let a = c[3] as u32; let a = c[3] as u32;
if a > 0 && a < 255 { if a > 0 && a < 255 {
for v in &mut c[..3] { for v in &mut c[..3] {

View File

@ -117,10 +117,10 @@ pub fn banner(title: &str, body: &str) {
if !cfg!(target_os = "macos") { if !cfg!(target_os = "macos") {
// The desktop's notification service (GNOME, KDE, XFCE…) through notify-send. // The desktop's notification service (GNOME, KDE, XFCE…) through notify-send.
let body: String = body.chars().take(180).collect(); let body: String = body.chars().take(180).collect();
let _ = crate::computer::spawn_reaped(&mut Command::new("notify-send").args(["-a", "AnyBot", title, &body]).stdin(Stdio::null()).stdout(Stdio::null()).stderr(Stdio::null())); let _ = crate::computer::spawn_reaped(Command::new("notify-send").args(["-a", "AnyBot", title, &body]).stdin(Stdio::null()).stdout(Stdio::null()).stderr(Stdio::null()));
return; return;
} }
let _ = crate::computer::spawn_reaped(&mut Command::new("osascript").arg("-e").arg(banner_script(title, body)).stdin(Stdio::null()).stdout(Stdio::null()).stderr(Stdio::null())); let _ = crate::computer::spawn_reaped(Command::new("osascript").arg("-e").arg(banner_script(title, body)).stdin(Stdio::null()).stdout(Stdio::null()).stderr(Stdio::null()));
} }
#[cfg(test)] #[cfg(test)]

View File

@ -335,10 +335,9 @@ pub fn steps(src: &str) -> Vec<(Step, String, String)> {
(Step::Done, r) (Step::Done, r)
} else if let Some(r) = t.strip_prefix("[>]").or_else(|| t.strip_prefix("[~]")).or_else(|| t.strip_prefix("[-]")) { } else if let Some(r) = t.strip_prefix("[>]").or_else(|| t.strip_prefix("[~]")).or_else(|| t.strip_prefix("[-]")) {
(Step::Now, r) (Step::Now, r)
} else if let Some(r) = t.strip_prefix("[ ]") {
(Step::Todo, r)
} else { } else {
return None; let r = t.strip_prefix("[ ]")?;
(Step::Todo, r)
}; };
let mut c = rest.splitn(2, '|'); let mut c = rest.splitn(2, '|');
let name = plain(c.next().unwrap_or("").trim()); let name = plain(c.next().unwrap_or("").trim());

View File

@ -133,8 +133,11 @@ pub fn x_post(token: &str, text: &str, reply_to: &str) -> Result<String, String>
Ok(format!("https://x.com/i/status/{}", v["data"]["id"].as_str().unwrap_or(""))) Ok(format!("https://x.com/i/status/{}", v["data"]["id"].as_str().unwrap_or("")))
} }
/// Mentions newer than `since_id`, oldest first: (id, author username, text). Returns the newest id. /// A mention on X: (id, author username, text).
pub fn x_mentions(token: &str, user_id: &str, since_id: &str) -> Result<(String, Vec<(String, String, String)>), String> { pub type Mention = (String, String, String);
/// Mentions newer than `since_id`, oldest first. Returns the newest id.
pub fn x_mentions(token: &str, user_id: &str, since_id: &str) -> Result<(String, Vec<Mention>), String> {
let q = if since_id.is_empty() { String::new() } else { format!("&since_id={since_id}") }; let q = if since_id.is_empty() { String::new() } else { format!("&since_id={since_id}") };
let v = x_get(token, &format!("/2/users/{user_id}/mentions?max_results=20&expansions=author_id&user.fields=username{q}"))?; let v = x_get(token, &format!("/2/users/{user_id}/mentions?max_results=20&expansions=author_id&user.fields=username{q}"))?;
Ok(mentions_new(&v, since_id)) Ok(mentions_new(&v, since_id))
@ -220,8 +223,11 @@ mod tests {
use super::*; use super::*;
use std::io::{Read, Write}; use std::io::{Read, Write};
/// Requests the stand-in got: (request line, Authorization header, body).
type Requests = std::sync::Arc<Mutex<Vec<(String, String, String)>>>;
/// A stand-in for Twilio and X: records requests, answers like they do. /// A stand-in for Twilio and X: records requests, answers like they do.
fn fake() -> (u16, std::sync::Arc<Mutex<Vec<(String, String, String)>>>) { fn fake() -> (u16, Requests) {
let l = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); let l = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let port = l.local_addr().unwrap().port(); let port = l.local_addr().unwrap().port();
let log = std::sync::Arc::new(Mutex::new(vec![])); let log = std::sync::Arc::new(Mutex::new(vec![]));

View File

@ -51,19 +51,6 @@ pub fn localize_value(v: &mut serde_json::Value) {
} }
} }
#[cfg(test)]
mod tests {
#[test]
fn mac_words_follow_the_system() {
let s = super::localize("檔案在這台 Mac;on this Mac");
if cfg!(target_os = "macos") {
assert_eq!(s, "檔案在這台 Mac;on this Mac");
} else {
assert_eq!(s, "檔案在這台電腦;on this computer");
}
}
}
// ---------- file dialogs on Linux: zenity (GNOME and most desktops) or kdialog (KDE) ---------- // ---------- file dialogs on Linux: zenity (GNOME and most desktops) or kdialog (KDE) ----------
fn dialog(zenity: &[&str], kdialog: &[&str]) -> Option<String> { fn dialog(zenity: &[&str], kdialog: &[&str]) -> Option<String> {
@ -112,3 +99,16 @@ pub fn clipboard_png_linux() -> Option<Vec<u8>> {
let o = std::process::Command::new(get[0]).args(&get[1..]).stderr(std::process::Stdio::null()).output().ok()?; let o = std::process::Command::new(get[0]).args(&get[1..]).stderr(std::process::Stdio::null()).output().ok()?;
(o.status.success() && o.stdout.starts_with(b"\x89PNG")).then_some(o.stdout) (o.status.success() && o.stdout.starts_with(b"\x89PNG")).then_some(o.stdout)
} }
#[cfg(test)]
mod tests {
#[test]
fn mac_words_follow_the_system() {
let s = super::localize("檔案在這台 Mac;on this Mac");
if cfg!(target_os = "macos") {
assert_eq!(s, "檔案在這台 Mac;on this Mac");
} else {
assert_eq!(s, "檔案在這台電腦;on this computer");
}
}
}

View File

@ -168,7 +168,7 @@ pub fn decode_image(path: &Path) -> Option<image::RgbaImage> {
resvg::render(&tree, resvg::tiny_skia::Transform::from_scale(scale, scale), &mut pm.as_mut()); resvg::render(&tree, resvg::tiny_skia::Transform::from_scale(scale, scale), &mut pm.as_mut());
// tiny-skia keeps premultiplied pixels. // tiny-skia keeps premultiplied pixels.
let mut px = pm.take(); let mut px = pm.take();
for c in px.chunks_exact_mut(4) { for c in px.as_chunks_mut::<4>().0 {
let a = c[3] as u32; let a = c[3] as u32;
if a > 0 && a < 255 { if a > 0 && a < 255 {
for v in &mut c[..3] { for v in &mut c[..3] {
@ -233,7 +233,7 @@ pub fn pdf_pages(path: &Path, width: f32, max_pages: usize, mut each: impl FnMut
let (w, h) = (pm.width() as u32, pm.height() as u32); let (w, h) = (pm.width() as u32, pm.height() as u32);
let mut px = pm.take_rgba8(hayro::vello_cpu::peniko::ImageAlphaType::AlphaPremultiplied); let mut px = pm.take_rgba8(hayro::vello_cpu::peniko::ImageAlphaType::AlphaPremultiplied);
// Paper is opaque: composite onto white (premultiplied: add what alpha left uncovered). // Paper is opaque: composite onto white (premultiplied: add what alpha left uncovered).
for c in px.chunks_exact_mut(4) { for c in px.as_chunks_mut::<4>().0 {
let rest = 255 - c[3]; let rest = 255 - c[3];
c[0] = c[0].saturating_add(rest); c[0] = c[0].saturating_add(rest);
c[1] = c[1].saturating_add(rest); c[1] = c[1].saturating_add(rest);
@ -490,8 +490,7 @@ fn find_ci(hay: &str, needle: &str) -> Option<usize> {
pub fn html_markdown(html: &str) -> String { pub fn html_markdown(html: &str) -> String {
let mut s = html.to_string(); let mut s = html.to_string();
for tag in ["head", "script", "style", "noscript", "svg", "template"] { for tag in ["head", "script", "style", "noscript", "svg", "template"] {
loop { while let Some(a) = find_open_tag(&s, tag) {
let Some(a) = find_open_tag(&s, tag) else { break };
let end = find_ci(&s[a..], &format!("</{tag}>")).map(|e| a + e + tag.len() + 3).unwrap_or(s.len()); let end = find_ci(&s[a..], &format!("</{tag}>")).map(|e| a + e + tag.len() + 3).unwrap_or(s.len());
s.replace_range(a..end, ""); s.replace_range(a..end, "");
} }

View File

@ -331,7 +331,7 @@ fn devices_ok(token: &str) -> Option<String> {
/// `via_relay`: the relay already checked the account, so no phone password is needed. /// `via_relay`: the relay already checked the account, so no phone password is needed.
pub fn handle(req: Req, via_relay: bool) -> Resp { pub fn handle(req: Req, via_relay: bool) -> Resp {
let parts = req.parts(); let parts = req.parts();
let path: Vec<&str> = parts.iter().map(|s| *s).collect(); let path: Vec<&str> = parts.to_vec();
match (req.method.as_str(), path.as_slice()) { match (req.method.as_str(), path.as_slice()) {
("GET", []) | ("GET", ["index.html"]) => return page(), ("GET", []) | ("GET", ["index.html"]) => return page(),
("GET", ["screen"]) => return Resp::bytes("text/html; charset=utf-8", SCREEN_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' ws: wss:"), ("GET", ["screen"]) => return Resp::bytes("text/html; charset=utf-8", SCREEN_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' ws: wss:"),
@ -1558,8 +1558,10 @@ fn hands_ids() -> &'static Mutex<std::collections::HashSet<String>> {
} }
/// The account's computers as last seen: id → (name, the brain it uses, lends its hands). /// The account's computers as last seen: id → (name, the brain it uses, lends its hands).
fn peer_rows() -> &'static Mutex<HashMap<String, (String, String, bool)>> { type PeerRows = Mutex<HashMap<String, (String, String, bool)>>;
static P: OnceLock<Mutex<HashMap<String, (String, String, bool)>>> = OnceLock::new();
fn peer_rows() -> &'static PeerRows {
static P: OnceLock<PeerRows> = OnceLock::new();
P.get_or_init(Mutex::default) P.get_or_init(Mutex::default)
} }

View File

@ -323,15 +323,14 @@ fn exact_next(s: &Schedule, created: i64, after: i64) -> Option<i64> {
let tz = tz_of(&s.timezone); let tz = tz_of(&s.timezone);
let at = Utc.timestamp_opt(created, 0).single()?.with_timezone(&tz); let at = Utc.timestamp_opt(created, 0).single()?.with_timezone(&tz);
// Offset seconds of that zone at `created` (local wall clock minus UTC). // Offset seconds of that zone at `created` (local wall clock minus UTC).
let off = (at.naive_local() - at.naive_utc()).num_seconds() as i64; let off = (at.naive_local() - at.naive_utc()).num_seconds();
let hour0 = (created + off).div_euclid(3600); let hour0 = (created + off).div_euclid(3600);
let mut h = (after + off).div_euclid(3600); let first = (after + off).div_euclid(3600);
for _ in 0..(n * 2 + 2) { for h in first..first + n * 2 + 2 {
let ts = h * 3600 + minute * 60 - off; let ts = h * 3600 + minute * 60 - off;
if ts > after && (h - hour0).rem_euclid(n) == 0 { if ts > after && (h - hour0).rem_euclid(n) == 0 {
return Some(ts); return Some(ts);
} }
h += 1;
} }
return None; return None;
} }

View File

@ -88,9 +88,12 @@ enum Mark {
Fail, Fail,
} }
/// Joining an account: its settings, the brain a code was made for, the brains to choose from.
type Joining = Result<(Settings, Option<Peer>, Vec<Peer>), String>;
enum Msg { enum Msg {
/// Joined the account; with a code, the brain it was made for. /// Joined the account; with a code, the brain it was made for.
Joined(Result<(Settings, Option<Peer>, Vec<Peer>), String>), Joined(Box<Joining>),
Progress(usize, Mark, String), Progress(usize, Mark, String),
Finished(Vec<String>), Finished(Vec<String>),
} }
@ -397,7 +400,7 @@ impl Wiz {
self.error = "請輸入帳號和密碼".into(); self.error = "請輸入帳號和密碼".into();
return; return;
} }
self.start("正在登入…", move || Msg::Joined(join_with_password(&base, &url, &user, &pass))); self.start("正在登入…", move || Msg::Joined(Box::new(join_with_password(&base, &url, &user, &pass))));
} else { } else {
let code: String = self.code.chars().filter(|c| c.is_ascii_digit()).collect(); let code: String = self.code.chars().filter(|c| c.is_ascii_digit()).collect();
if code.len() != 6 { if code.len() != 6 {
@ -405,7 +408,7 @@ impl Wiz {
self.focus = 1; self.focus = 1;
return; return;
} }
self.start("正在連線…", move || Msg::Joined(remote::pair_claim(&base, &url, &code).map(|s| (s.clone(), brain_of(&s), vec![])))); self.start("正在連線…", move || Msg::Joined(Box::new(remote::pair_claim(&base, &url, &code).map(|s| (s.clone(), brain_of(&s), vec![])))));
} }
} }
@ -421,7 +424,7 @@ impl Wiz {
match m { match m {
Msg::Joined(r) => { Msg::Joined(r) => {
self.busy = None; self.busy = None;
match r { match *r {
Ok((s, brain, choices)) => { Ok((s, brain, choices)) => {
self.joined = Some(s); self.joined = Some(s);
self.pass.clear(); self.pass.clear();

View File

@ -481,7 +481,7 @@ mod mac {
match rx.recv_timeout(std::time::Duration::from_secs(3)) { match rx.recv_timeout(std::time::Duration::from_secs(3)) {
Ok(true) => Ok(()), Ok(true) => Ok(()),
_ => { _ => {
let _ = crate::computer::spawn_reaped(&mut std::process::Command::new("open").arg("x-apple.systempreferences:com.apple.preference.security?Privacy_ListenEvent")); let _ = crate::computer::spawn_reaped(std::process::Command::new("open").arg("x-apple.systempreferences:com.apple.preference.security?Privacy_ListenEvent"));
Err("macOS 還沒讓 AnyBot 觀看你的操作。我已經打開「系統設定 → 隱私權與安全性 → 輸入監控」:把 AnyBot(或「終端機」)打開,再按一次「開始錄製」。".into()) Err("macOS 還沒讓 AnyBot 觀看你的操作。我已經打開「系統設定 → 隱私權與安全性 → 輸入監控」:把 AnyBot(或「終端機」)打開,再按一次「開始錄製」。".into())
} }
} }
@ -602,7 +602,7 @@ mod tests {
assert!(std::fs::metadata(&v).unwrap().len() > 0); assert!(std::fs::metadata(&v).unwrap().len() > 0);
} }
let demo = Demo { name: "報表".into(), created: String::new(), steps: vec!["點 (1, 2)".into()], video: String::new(), shots: vec![dir.join("frame_00002.jpg").display().to_string()] }; let demo = Demo { name: "報表".into(), created: String::new(), steps: vec!["點 (1, 2)".into()], video: String::new(), shots: vec![dir.join("frame_00002.jpg").display().to_string()] };
let (t, ok, img) = view_step(&[demo.clone()], r#"{"demo":"報表","step":1}"#); let (t, ok, img) = view_step(std::slice::from_ref(&demo), r#"{"demo":"報表","step":1}"#);
assert!(ok && img.is_some() && t.contains("第 1 步"), "{t}"); assert!(ok && img.is_some() && t.contains("第 1 步"), "{t}");
assert!(!view_step(&[demo], r#"{"demo":"報表","step":5}"#).1); assert!(!view_step(&[demo], r#"{"demo":"報表","step":5}"#).1);
let _ = std::fs::remove_dir_all(&dir); let _ = std::fs::remove_dir_all(&dir);

View File

@ -1371,7 +1371,7 @@ mod tests {
let secret = std::env::temp_dir().join(format!("anybot-secret-{}", std::process::id())); let secret = std::env::temp_dir().join(format!("anybot-secret-{}", std::process::id()));
std::fs::create_dir_all(&secret).unwrap(); std::fs::create_dir_all(&secret).unwrap();
std::fs::write(secret.join("k.txt"), "top-secret").unwrap(); std::fs::write(secret.join("k.txt"), "top-secret").unwrap();
let out = build_command(&format!("cat '{}/k.txt'", secret.display()), &w, &[secret.clone()], "1", "r").output().unwrap(); let out = build_command(&format!("cat '{}/k.txt'", secret.display()), &w, std::slice::from_ref(&secret), "1", "r").output().unwrap();
assert!(!String::from_utf8_lossy(&out.stdout).contains("top-secret")); assert!(!String::from_utf8_lossy(&out.stdout).contains("top-secret"));
let out = build_command(&format!("cat '{}/k.txt'", secret.display()), &w, &[], "1", "r").output().unwrap(); let out = build_command(&format!("cat '{}/k.txt'", secret.display()), &w, &[], "1", "r").output().unwrap();
assert!(String::from_utf8_lossy(&out.stdout).contains("top-secret")); assert!(String::from_utf8_lossy(&out.stdout).contains("top-secret"));
@ -1379,7 +1379,7 @@ mod tests {
let att = secret.join("attachments"); let att = secret.join("attachments");
std::fs::create_dir_all(&att).unwrap(); std::fs::create_dir_all(&att).unwrap();
std::fs::write(att.join("photo.txt"), "the-photo").unwrap(); std::fs::write(att.join("photo.txt"), "the-photo").unwrap();
let run = |cmd: String| build_command_sealed(&cmd, &w, &[secret.clone()], &[att.clone()], None, "1", "r").output().unwrap(); let run = |cmd: String| build_command_sealed(&cmd, &w, std::slice::from_ref(&secret), std::slice::from_ref(&att), None, "1", "r").output().unwrap();
assert!(String::from_utf8_lossy(&run(format!("cat '{}/photo.txt'", att.display())).stdout).contains("the-photo")); assert!(String::from_utf8_lossy(&run(format!("cat '{}/photo.txt'", att.display())).stdout).contains("the-photo"));
assert!(!String::from_utf8_lossy(&run(format!("cat '{}/k.txt'", secret.display())).stdout).contains("top-secret")); assert!(!String::from_utf8_lossy(&run(format!("cat '{}/k.txt'", secret.display())).stdout).contains("top-secret"));
run(format!("echo changed > '{}/photo.txt'", att.display())); run(format!("echo changed > '{}/photo.txt'", att.display()));

View File

@ -63,7 +63,7 @@ pub fn list_for_bot(args: &str) -> String {
/// "https://accounts.example.com/login?x" → "accounts.example.com" /// "https://accounts.example.com/login?x" → "accounts.example.com"
pub fn host_of(url: &str) -> String { pub fn host_of(url: &str) -> String {
let rest = url.split("://").nth(1).unwrap_or(url); let rest = url.split("://").nth(1).unwrap_or(url);
rest.split(['/', '?', '#']).next().unwrap_or("").split('@').last().unwrap_or("").split(':').next().unwrap_or("").to_lowercase() rest.split(['/', '?', '#']).next().unwrap_or("").split('@').next_back().unwrap_or("").split(':').next().unwrap_or("").to_lowercase()
} }
/// accounts.google.com → [accounts.google.com, google.com] /// accounts.google.com → [accounts.google.com, google.com]

View File

@ -193,7 +193,7 @@ pub fn new_id() -> String {
pub fn host_of(s: &str) -> String { pub fn host_of(s: &str) -> String {
let s = s.trim(); let s = s.trim();
let rest = s.split("://").nth(1).unwrap_or(s); let rest = s.split("://").nth(1).unwrap_or(s);
let h = rest.split(['/', '?', '#']).next().unwrap_or("").split('@').last().unwrap_or("").split(':').next().unwrap_or("").to_lowercase(); let h = rest.split(['/', '?', '#']).next().unwrap_or("").split('@').next_back().unwrap_or("").split(':').next().unwrap_or("").to_lowercase();
h.strip_prefix("www.").map(String::from).unwrap_or(h) h.strip_prefix("www.").map(String::from).unwrap_or(h)
} }
@ -237,7 +237,7 @@ pub fn luhn_ok(number: &str) -> bool {
} }
n n
}).sum(); }).sum();
sum % 10 == 0 sum.is_multiple_of(10)
} }
pub fn card_brand(number: &str) -> &'static str { pub fn card_brand(number: &str) -> &'static str {
@ -317,7 +317,7 @@ fn hex(b: &[u8]) -> String {
fn unhex(s: &str) -> Option<Vec<u8>> { fn unhex(s: &str) -> Option<Vec<u8>> {
let s = s.trim(); let s = s.trim();
(s.len() % 2 == 0).then_some(())?; s.len().is_multiple_of(2).then_some(())?;
(0..s.len() / 2).map(|i| u8::from_str_radix(&s[i * 2..i * 2 + 2], 16).ok()).collect() (0..s.len() / 2).map(|i| u8::from_str_radix(&s[i * 2..i * 2 + 2], 16).ok()).collect()
} }
@ -482,7 +482,7 @@ fn import_keychain_once() {
pub fn list() -> Result<Vec<Item>, String> { pub fn list() -> Result<Vec<Item>, String> {
import_keychain_once(); import_keychain_once();
let mut v: Vec<Item> = load()?.items.iter().map(Item::public).collect(); let mut v: Vec<Item> = load()?.items.iter().map(Item::public).collect();
v.sort_by(|a, b| b.updated_ms.cmp(&a.updated_ms)); v.sort_by_key(|i| std::cmp::Reverse(i.updated_ms));
Ok(v) Ok(v)
} }

View File

@ -163,7 +163,7 @@ impl Recording {
} }
match said.trim() { match said.trim() {
"denied" => { "denied" => {
let _ = crate::computer::spawn_reaped(&mut Command::new("open").arg("x-apple.systempreferences:com.apple.preference.security?Privacy_Microphone")); let _ = crate::computer::spawn_reaped(Command::new("open").arg("x-apple.systempreferences:com.apple.preference.security?Privacy_Microphone"));
Err("macOS 還沒讓 AnyBot 使用麥克風。我已經打開「系統設定 → 隱私權與安全性 → 麥克風」:把 AnyBot(或「終端機」)打開,再試一次。".into()) Err("macOS 還沒讓 AnyBot 使用麥克風。我已經打開「系統設定 → 隱私權與安全性 → 麥克風」:把 AnyBot(或「終端機」)打開,再試一次。".into())
} }
"fail" => Err("找不到可以用的麥克風。".into()), "fail" => Err("找不到可以用的麥克風。".into()),

View File

@ -404,7 +404,7 @@ fn explain(err: &str, browser: &str) -> String {
format!("macOS 擋下了 AnyBot 控制 {browser}。我已經幫你打開「系統設定 → 隱私權與安全性 → 自動化」:在 AnyBot(或「終端機」)底下打開「{browser}」,然後再叫我試一次。") format!("macOS 擋下了 AnyBot 控制 {browser}。我已經幫你打開「系統設定 → 隱私權與安全性 → 自動化」:在 AnyBot(或「終端機」)底下打開「{browser}」,然後再叫我試一次。")
} else if lower.contains("javascript") { } else if lower.contains("javascript") {
if browser == "Safari" { if browser == "Safari" {
format!("Safari 還沒允許讀取網頁內容:請在 Safari 選單「開發 → 允許來自 Apple 事件的 JavaScript」打開(沒有「開發」選單的話,先到 Safari 設定 → 進階 → 勾選「顯示網頁開發者功能」),然後再叫我試一次。") "Safari 還沒允許讀取網頁內容:請在 Safari 選單「開發 → 允許來自 Apple 事件的 JavaScript」打開(沒有「開發」選單的話,先到 Safari 設定 → 進階 → 勾選「顯示網頁開發者功能」),然後再叫我試一次。".to_string()
} else { } else {
format!("{browser} 還沒允許讀取網頁內容:請在 {browser} 選單「檢視 → 開發人員 → 允許來自 Apple 事件的 JavaScript」打開,然後再叫我試一次。") format!("{browser} 還沒允許讀取網頁內容:請在 {browser} 選單「檢視 → 開發人員 → 允許來自 Apple 事件的 JavaScript」打開,然後再叫我試一次。")
} }

View File

@ -362,7 +362,7 @@ pub fn to_rgba(data: &[u8], fmt: wl_shm::Format, w: u32, h: u32, stride: u32, y_
let r = if y_invert { h - 1 - row } else { row }; let r = if y_invert { h - 1 - row } else { row };
let (a, b) = ((r as u64 * stride as u64) as usize, ((r as u64 * stride as u64) as usize) + (w as usize) * 4); let (a, b) = ((r as u64 * stride as u64) as usize, ((r as u64 * stride as u64) as usize) + (w as usize) * 4);
let line = data.get(a..b)?; let line = data.get(a..b)?;
for px in line.chunks_exact(4) { for px in line.as_chunks::<4>().0 {
if bgr { if bgr {
out.extend_from_slice(&[px[2], px[1], px[0], 255]); out.extend_from_slice(&[px[2], px[1], px[0], 255]);
} else { } else {
@ -614,7 +614,7 @@ impl Dispatch<top_handle::ZwlrForeignToplevelHandleV1, ()> for State {
} }
} }
top_handle::Event::State { state } => { top_handle::Event::State { state } => {
let active = state.chunks_exact(4).any(|c| u32::from_ne_bytes([c[0], c[1], c[2], c[3]]) == top_handle::State::Activated as u32); let active = state.as_chunks::<4>().0.iter().any(|c| u32::from_ne_bytes(*c) == top_handle::State::Activated as u32);
if let Some(t) = st.tops.iter_mut().find(|t| t.id == id) { if let Some(t) = st.tops.iter_mut().find(|t| t.id == id) {
t.active = active; t.active = active;
} }

View File

@ -55,7 +55,7 @@ pub fn capture() -> Result<image::RgbImage, String> {
let lsb = setup.image_byte_order == xproto::ImageOrder::LSB_FIRST; let lsb = setup.image_byte_order == xproto::ImageOrder::LSB_FIRST;
let (w, h) = (x.width as usize, x.height as usize); let (w, h) = (x.width as usize, x.height as usize);
let mut rgb = Vec::with_capacity(w * h * 3); let mut rgb = Vec::with_capacity(w * h * 3);
for px in reply.data.chunks_exact(4).take(w * h) { for px in reply.data.as_chunks::<4>().0.iter().take(w * h) {
if lsb { if lsb {
rgb.extend_from_slice(&[px[2], px[1], px[0]]); rgb.extend_from_slice(&[px[2], px[1], px[0]]);
} else { } else {
@ -421,7 +421,6 @@ pub fn record(mut each: impl FnMut(Input), keep_going: impl Fn() -> bool + Send
}; };
let stop = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); let stop = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
let stopper = std::thread::spawn({ let stopper = std::thread::spawn({
let ctx = ctx;
let stop = stop.clone(); let stop = stop.clone();
move || { move || {
// Disabling the context ends the data stream below. // Disabling the context ends the data stream below.
@ -450,7 +449,7 @@ pub fn record(mut each: impl FnMut(Input), keep_going: impl Fn() -> bool + Send
if reply.category != 0 { if reply.category != 0 {
continue; continue;
} }
for ev in reply.data.chunks_exact(32) { for ev in reply.data.as_chunks::<32>().0 {
let (kind, detail) = (ev[0] & 0x7f, ev[1]); let (kind, detail) = (ev[0] & 0x7f, ev[1]);
let rx = i16::from_ne_bytes([ev[20], ev[21]]) as f64; let rx = i16::from_ne_bytes([ev[20], ev[21]]) as f64;
let ry = i16::from_ne_bytes([ev[22], ev[23]]) as f64; let ry = i16::from_ne_bytes([ev[22], ev[23]]) as f64;
@ -541,7 +540,7 @@ mod tests {
assert!(available(), "no X display"); assert!(available(), "no X display");
let (w, h) = screen_pixels(); let (w, h) = screen_pixels();
let shot = capture().expect("screenshot"); let shot = capture().expect("screenshot");
assert_eq!((shot.width(), shot.height()), (w as u32, h as u32)); assert_eq!((shot.width(), shot.height()), (w, h));
// Record while clicking and typing through XTEST: the recorder must see both. // Record while clicking and typing through XTEST: the recorder must see both.
let got = std::sync::Arc::new(Mutex::new(Vec::new())); let got = std::sync::Arc::new(Mutex::new(Vec::new()));
let g = got.clone(); let g = got.clone();