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:
parent
f3ccd129d6
commit
4506cc149b
|
|
@ -187,6 +187,9 @@ struct Sock {
|
|||
|
||||
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 {
|
||||
dir: PathBuf,
|
||||
data: Mutex<Data>,
|
||||
|
|
@ -204,7 +207,7 @@ struct Relay {
|
|||
uses: Mutex<HashMap<String, String>>,
|
||||
hands: Mutex<std::collections::HashSet<String>>,
|
||||
/// 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).
|
||||
tickets: Mutex<HashMap<String, (String, String, Instant)>>,
|
||||
/// 配對碼: code → (account, the AnyBot it is for, when made). Ten minutes, once.
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
enum Plan {
|
||||
Login(crate::vault::LoginPlan),
|
||||
Login(Box<crate::vault::LoginPlan>),
|
||||
Card,
|
||||
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| {
|
||||
let q = pl.question();
|
||||
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 {
|
||||
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]
|
||||
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」");
|
||||
assert_eq!(element_named(&[list.clone()], "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), "7").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!(computer::describe("computer_use_click", r#"{"ref":"7"}"#), "點 #7");
|
||||
assert_eq!(computer::describe("computer_use_click", r#"{"x":10,"y":20}"#), "點 (10, 20)");
|
||||
|
|
|
|||
14
src/app.rs
14
src/app.rs
|
|
@ -571,9 +571,8 @@ impl App {
|
|||
}
|
||||
// …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() {
|
||||
Ok("panel") => app.panel = Some(panel_ui::PanelTab::Activity),
|
||||
_ => {}
|
||||
if let Ok("panel") = std::env::var("ANYBOT_SHOT_VIEW").as_deref() {
|
||||
app.panel = Some(panel_ui::PanelTab::Activity);
|
||||
}
|
||||
}
|
||||
app
|
||||
|
|
@ -2597,10 +2596,8 @@ impl App {
|
|||
ui.label(RichText::new(t("你平常的 Chrome,切到哪個設定檔就讀哪個")).size(11.5).color(p.text2));
|
||||
if crate::axread::trusted(false) {
|
||||
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() {
|
||||
if !crate::axread::trusted(true) {
|
||||
let _ = crate::computer::spawn_reaped(&mut std::process::Command::new("open").arg("x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility"));
|
||||
}
|
||||
} else if ui.small_button(t("打開輔助使用")).on_hover_text(t("macOS 要你允許一次,AnyBot 才看得到你的瀏覽器視窗")).clicked() && !crate::axread::trusted(true) {
|
||||
let _ = crate::computer::spawn_reaped(std::process::Command::new("open").arg("x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility"));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
|
@ -4451,12 +4448,13 @@ mod tests {
|
|||
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.
|
||||
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::thread::sleep(std::time::Duration::from_millis(500));
|
||||
assert!(!marker.exists(), "started before the old copy exited");
|
||||
old.wait().unwrap();
|
||||
std::thread::sleep(std::time::Duration::from_millis(700));
|
||||
assert_eq!(std::fs::read_to_string(&marker).unwrap().trim(), "clean");
|
||||
relaunched.wait().unwrap();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
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 {
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
const LOCAL_BAR_SECS: u64 = 8;
|
||||
|
||||
/// Another AnyBot of the account: (id, name, online).
|
||||
pub type Peer = (String, String, bool);
|
||||
|
||||
#[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: Arc<Mutex<Option<Vec<Peer>>>>,
|
||||
pub peers_at: Option<Instant>,
|
||||
/// 你的電腦's name being edited.
|
||||
pub renaming_machine: Option<String>,
|
||||
|
|
@ -101,7 +104,7 @@ impl App {
|
|||
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) {
|
||||
if !self.demo && self.cu.update_checked.is_none_or(|t| t.elapsed().as_secs() > 30) {
|
||||
self.cu.update_checked = Some(Instant::now());
|
||||
let flag = self.cu.update_flag.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.
|
||||
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());
|
||||
let (slot, settings) = (self.cu.peers.clone(), self.state.remote.clone());
|
||||
let ctx = ui.ctx().clone();
|
||||
|
|
|
|||
|
|
@ -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 code: Option<(String, String)> = None;
|
||||
// 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![];
|
||||
|
||||
fn push_block(stack: &mut [Frame], b: Block) {
|
||||
|
|
|
|||
|
|
@ -353,6 +353,9 @@ enum Out {
|
|||
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)]
|
||||
pub struct Mirror {
|
||||
/// 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.
|
||||
outbox: Option<Sender<Out>>,
|
||||
/// Edits sent but not yet confirmed (by number).
|
||||
pending: Arc<Mutex<Vec<(u64, Vec<Value>)>>>,
|
||||
pending: Arc<Mutex<Vec<Pending>>>,
|
||||
seq: u64,
|
||||
/// Something didn't reach the brain: ask for everything again.
|
||||
resync: Arc<AtomicBool>,
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
let has_pages = self.pages_library(ui, bot);
|
||||
if files.is_empty() && has_pages {
|
||||
|
|
|
|||
|
|
@ -7,6 +7,9 @@ use crate::remote::{self, Cmd, Settings};
|
|||
use serde_json::json;
|
||||
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)]
|
||||
pub struct RemoteUi {
|
||||
sig: u64,
|
||||
|
|
@ -28,7 +31,7 @@ pub struct RemoteUi {
|
|||
last_seen_mark: Option<Instant>,
|
||||
peers_at: Option<Instant>,
|
||||
/// 團隊: (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_admin: bool,
|
||||
started: bool,
|
||||
|
|
|
|||
|
|
@ -255,7 +255,7 @@ impl App {
|
|||
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 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 has_sections = !folders.is_empty();
|
||||
// 分區 in order (None = 未分區); pinned bots show only in the grid.
|
||||
|
|
|
|||
|
|
@ -56,6 +56,9 @@ pub struct ContactForm {
|
|||
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)]
|
||||
pub struct ImportState {
|
||||
pub picking: Option<Arc<Mutex<Option<Vec<PathBuf>>>>>,
|
||||
|
|
@ -66,7 +69,7 @@ pub struct ImportState {
|
|||
pub queue: Vec<usize>,
|
||||
pub total: usize,
|
||||
pub stop: bool,
|
||||
pub done: Option<(usize, usize, Vec<(String, String)>)>,
|
||||
pub done: Option<Imported>,
|
||||
}
|
||||
|
||||
pub enum VaultDialog {
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
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.
|
||||
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"] {
|
||||
Value::String(r) if !r.trim().is_empty() => r.trim().to_string(),
|
||||
Value::Number(n) => n.to_string(),
|
||||
|
|
|
|||
|
|
@ -357,7 +357,7 @@ pub fn encode_jpeg(img: &image::RgbImage) -> Option<Vec<u8>> {
|
|||
if out.len() <= MAX_IMAGE_BYTES {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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 打開(只要一次),再叫我試一次。";
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
pub fn render(source: &str, dark: bool, scale: f32) -> Result<image::RgbaImage, String> {
|
||||
let svg = svg(source, dark)?;
|
||||
let mut opt = resvg::usvg::Options::default();
|
||||
opt.fontdb = fontdb();
|
||||
opt.font_family = "sans-serif".into();
|
||||
let opt = resvg::usvg::Options { fontdb: fontdb(), font_family: "sans-serif".into(), ..Default::default() };
|
||||
let tree = resvg::usvg::Tree::from_str(&svg, &opt).map_err(|e| e.to_string())?;
|
||||
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);
|
||||
|
|
@ -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());
|
||||
// tiny-skia is premultiplied; egui wants straight alpha.
|
||||
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;
|
||||
if a > 0 && a < 255 {
|
||||
for v in &mut c[..3] {
|
||||
|
|
|
|||
|
|
@ -185,7 +185,7 @@ fn jxa(script: &str, args: &Value) -> Result<String, String> {
|
|||
fn permission_hint(app: &str, err: &str) -> String {
|
||||
let e = err.to_lowercase();
|
||||
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}」,再叫我試一次。");
|
||||
}
|
||||
format!("「{app}」回報錯誤:{}", err.lines().last().unwrap_or(err))
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
// tiny-skia is premultiplied; IconData wants straight alpha.
|
||||
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;
|
||||
if a > 0 && a < 255 {
|
||||
for v in &mut c[..3] {
|
||||
|
|
|
|||
|
|
@ -117,10 +117,10 @@ pub fn banner(title: &str, body: &str) {
|
|||
if !cfg!(target_os = "macos") {
|
||||
// The desktop's notification service (GNOME, KDE, XFCE…) through notify-send.
|
||||
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;
|
||||
}
|
||||
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)]
|
||||
|
|
|
|||
|
|
@ -335,10 +335,9 @@ pub fn steps(src: &str) -> Vec<(Step, String, String)> {
|
|||
(Step::Done, r)
|
||||
} else if let Some(r) = t.strip_prefix("[>]").or_else(|| t.strip_prefix("[~]")).or_else(|| t.strip_prefix("[-]")) {
|
||||
(Step::Now, r)
|
||||
} else if let Some(r) = t.strip_prefix("[ ]") {
|
||||
(Step::Todo, r)
|
||||
} else {
|
||||
return None;
|
||||
let r = t.strip_prefix("[ ]")?;
|
||||
(Step::Todo, r)
|
||||
};
|
||||
let mut c = rest.splitn(2, '|');
|
||||
let name = plain(c.next().unwrap_or("").trim());
|
||||
|
|
|
|||
12
src/phone.rs
12
src/phone.rs
|
|
@ -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("")))
|
||||
}
|
||||
|
||||
/// Mentions newer than `since_id`, oldest first: (id, author username, text). Returns the newest id.
|
||||
pub fn x_mentions(token: &str, user_id: &str, since_id: &str) -> Result<(String, Vec<(String, String, String)>), String> {
|
||||
/// A mention on X: (id, author username, text).
|
||||
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 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))
|
||||
|
|
@ -220,8 +223,11 @@ mod tests {
|
|||
use super::*;
|
||||
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.
|
||||
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 port = l.local_addr().unwrap().port();
|
||||
let log = std::sync::Arc::new(Mutex::new(vec![]));
|
||||
|
|
|
|||
|
|
@ -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) ----------
|
||||
|
||||
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()?;
|
||||
(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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
// tiny-skia keeps premultiplied pixels.
|
||||
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;
|
||||
if a > 0 && a < 255 {
|
||||
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 mut px = pm.take_rgba8(hayro::vello_cpu::peniko::ImageAlphaType::AlphaPremultiplied);
|
||||
// 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];
|
||||
c[0] = c[0].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 {
|
||||
let mut s = html.to_string();
|
||||
for tag in ["head", "script", "style", "noscript", "svg", "template"] {
|
||||
loop {
|
||||
let Some(a) = find_open_tag(&s, tag) else { break };
|
||||
while let Some(a) = find_open_tag(&s, tag) {
|
||||
let end = find_ci(&s[a..], &format!("</{tag}>")).map(|e| a + e + tag.len() + 3).unwrap_or(s.len());
|
||||
s.replace_range(a..end, "");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
pub fn handle(req: Req, via_relay: bool) -> Resp {
|
||||
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()) {
|
||||
("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:"),
|
||||
|
|
@ -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).
|
||||
fn peer_rows() -> &'static Mutex<HashMap<String, (String, String, bool)>> {
|
||||
static P: OnceLock<Mutex<HashMap<String, (String, String, bool)>>> = OnceLock::new();
|
||||
type PeerRows = Mutex<HashMap<String, (String, String, bool)>>;
|
||||
|
||||
fn peer_rows() -> &'static PeerRows {
|
||||
static P: OnceLock<PeerRows> = OnceLock::new();
|
||||
P.get_or_init(Mutex::default)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -323,15 +323,14 @@ fn exact_next(s: &Schedule, created: i64, after: i64) -> Option<i64> {
|
|||
let tz = tz_of(&s.timezone);
|
||||
let at = Utc.timestamp_opt(created, 0).single()?.with_timezone(&tz);
|
||||
// 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 mut h = (after + off).div_euclid(3600);
|
||||
for _ in 0..(n * 2 + 2) {
|
||||
let first = (after + off).div_euclid(3600);
|
||||
for h in first..first + n * 2 + 2 {
|
||||
let ts = h * 3600 + minute * 60 - off;
|
||||
if ts > after && (h - hour0).rem_euclid(n) == 0 {
|
||||
return Some(ts);
|
||||
}
|
||||
h += 1;
|
||||
}
|
||||
return None;
|
||||
}
|
||||
|
|
|
|||
11
src/setup.rs
11
src/setup.rs
|
|
@ -88,9 +88,12 @@ enum Mark {
|
|||
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 {
|
||||
/// 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),
|
||||
Finished(Vec<String>),
|
||||
}
|
||||
|
|
@ -397,7 +400,7 @@ impl Wiz {
|
|||
self.error = "請輸入帳號和密碼".into();
|
||||
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 {
|
||||
let code: String = self.code.chars().filter(|c| c.is_ascii_digit()).collect();
|
||||
if code.len() != 6 {
|
||||
|
|
@ -405,7 +408,7 @@ impl Wiz {
|
|||
self.focus = 1;
|
||||
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 {
|
||||
Msg::Joined(r) => {
|
||||
self.busy = None;
|
||||
match r {
|
||||
match *r {
|
||||
Ok((s, brain, choices)) => {
|
||||
self.joined = Some(s);
|
||||
self.pass.clear();
|
||||
|
|
|
|||
|
|
@ -481,7 +481,7 @@ mod mac {
|
|||
match rx.recv_timeout(std::time::Duration::from_secs(3)) {
|
||||
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())
|
||||
}
|
||||
}
|
||||
|
|
@ -602,7 +602,7 @@ mod tests {
|
|||
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 (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!(!view_step(&[demo], r#"{"demo":"報表","step":5}"#).1);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
|
|
|
|||
|
|
@ -1371,7 +1371,7 @@ mod tests {
|
|||
let secret = std::env::temp_dir().join(format!("anybot-secret-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&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"));
|
||||
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"));
|
||||
|
|
@ -1379,7 +1379,7 @@ mod tests {
|
|||
let att = secret.join("attachments");
|
||||
std::fs::create_dir_all(&att).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 '{}/k.txt'", secret.display())).stdout).contains("top-secret"));
|
||||
run(format!("echo changed > '{}/photo.txt'", att.display()));
|
||||
|
|
|
|||
|
|
@ -63,7 +63,7 @@ pub fn list_for_bot(args: &str) -> String {
|
|||
/// "https://accounts.example.com/login?x" → "accounts.example.com"
|
||||
pub fn host_of(url: &str) -> String {
|
||||
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]
|
||||
|
|
|
|||
|
|
@ -193,7 +193,7 @@ pub fn new_id() -> String {
|
|||
pub fn host_of(s: &str) -> String {
|
||||
let s = s.trim();
|
||||
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)
|
||||
}
|
||||
|
||||
|
|
@ -237,7 +237,7 @@ pub fn luhn_ok(number: &str) -> bool {
|
|||
}
|
||||
n
|
||||
}).sum();
|
||||
sum % 10 == 0
|
||||
sum.is_multiple_of(10)
|
||||
}
|
||||
|
||||
pub fn card_brand(number: &str) -> &'static str {
|
||||
|
|
@ -317,7 +317,7 @@ fn hex(b: &[u8]) -> String {
|
|||
|
||||
fn unhex(s: &str) -> Option<Vec<u8>> {
|
||||
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()
|
||||
}
|
||||
|
||||
|
|
@ -482,7 +482,7 @@ fn import_keychain_once() {
|
|||
pub fn list() -> Result<Vec<Item>, String> {
|
||||
import_keychain_once();
|
||||
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)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -163,7 +163,7 @@ impl Recording {
|
|||
}
|
||||
match said.trim() {
|
||||
"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())
|
||||
}
|
||||
"fail" => Err("找不到可以用的麥克風。".into()),
|
||||
|
|
|
|||
|
|
@ -404,7 +404,7 @@ fn explain(err: &str, browser: &str) -> String {
|
|||
format!("macOS 擋下了 AnyBot 控制 {browser}。我已經幫你打開「系統設定 → 隱私權與安全性 → 自動化」:在 AnyBot(或「終端機」)底下打開「{browser}」,然後再叫我試一次。")
|
||||
} else if lower.contains("javascript") {
|
||||
if browser == "Safari" {
|
||||
format!("Safari 還沒允許讀取網頁內容:請在 Safari 選單「開發 → 允許來自 Apple 事件的 JavaScript」打開(沒有「開發」選單的話,先到 Safari 設定 → 進階 → 勾選「顯示網頁開發者功能」),然後再叫我試一次。")
|
||||
"Safari 還沒允許讀取網頁內容:請在 Safari 選單「開發 → 允許來自 Apple 事件的 JavaScript」打開(沒有「開發」選單的話,先到 Safari 設定 → 進階 → 勾選「顯示網頁開發者功能」),然後再叫我試一次。".to_string()
|
||||
} else {
|
||||
format!("{browser} 還沒允許讀取網頁內容:請在 {browser} 選單「檢視 → 開發人員 → 允許來自 Apple 事件的 JavaScript」打開,然後再叫我試一次。")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 (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)?;
|
||||
for px in line.chunks_exact(4) {
|
||||
for px in line.as_chunks::<4>().0 {
|
||||
if bgr {
|
||||
out.extend_from_slice(&[px[2], px[1], px[0], 255]);
|
||||
} else {
|
||||
|
|
@ -614,7 +614,7 @@ impl Dispatch<top_handle::ZwlrForeignToplevelHandleV1, ()> for 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) {
|
||||
t.active = active;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -55,7 +55,7 @@ pub fn capture() -> Result<image::RgbImage, String> {
|
|||
let lsb = setup.image_byte_order == xproto::ImageOrder::LSB_FIRST;
|
||||
let (w, h) = (x.width as usize, x.height as usize);
|
||||
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 {
|
||||
rgb.extend_from_slice(&[px[2], px[1], px[0]]);
|
||||
} 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 stopper = std::thread::spawn({
|
||||
let ctx = ctx;
|
||||
let stop = stop.clone();
|
||||
move || {
|
||||
// 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 {
|
||||
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 rx = i16::from_ne_bytes([ev[20], ev[21]]) 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");
|
||||
let (w, h) = screen_pixels();
|
||||
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.
|
||||
let got = std::sync::Arc::new(Mutex::new(Vec::new()));
|
||||
let g = got.clone();
|
||||
|
|
|
|||
Loading…
Reference in New Issue