diff --git a/deploy/mac/bundle.sh b/deploy/mac/bundle.sh index 8639713..1f928f0 100755 --- a/deploy/mac/bundle.sh +++ b/deploy/mac/bundle.sh @@ -2,7 +2,7 @@ # Make AnyBot.app from the release build and zip it (run from app/ on a Mac, after # `cargo build --release`): deploy/mac/bundle.sh 1.2.0 → dist/AnyBot-1.2.0-macos-.zip # With KEEP_APP=1 it leaves dist/AnyBot.app instead of zipping it (make install). -# Signed ad hoc (no Apple developer account): the first time, right-click › 打開. +# Signed with this Mac's own fixed key (no Apple developer account): the first time, right-click › 打開. set -e VERSION=${1:?version} BIN=${CARGO_TARGET_DIR:-target}/release/anybot @@ -39,7 +39,8 @@ cat > "$APP/Contents/Info.plist" <NSAppleEventsUsageDescription讓 Bot 在你允許時操作其他 App。 PLIST -codesign --force --deep -s - "$APP" +# The same signature every build, so macOS keeps the permissions it gave (see sign.sh). +sh "$(dirname "$0")/sign.sh" "$APP" if [ "${KEEP_APP:-}" = 1 ]; then exit 0 fi diff --git a/deploy/mac/sign.sh b/deploy/mac/sign.sh new file mode 100755 index 0000000..9d9afb9 --- /dev/null +++ b/deploy/mac/sign.sh @@ -0,0 +1,57 @@ +#!/bin/sh +# Sign AnyBot.app with this Mac's own signing key, the same one every build: +# deploy/mac/sign.sh dist/AnyBot.app +# macOS keeps 輔助使用 / 螢幕錄製 for an app by its signature. Signed "ad hoc", every build was a +# new app to it, so each update quietly lost those permissions and the bots could no longer read +# or operate other apps (agent-desktop answered PERM_DENIED). One fixed self-signed key, kept in +# its own keychain under AnyBot's folder (the login keychain is not touched), keeps them. +# Anything going wrong falls back to ad hoc signing. +set -u +APP=${1:?app} +DIR="$HOME/Library/Application Support/AnyBot/signing" +KC="$DIR/anybot-signing.keychain-db" +PWF="$DIR/keychain-password" +NAME="AnyBot Local Signing" + +make_key() { + mkdir -p "$DIR" && chmod 700 "$DIR" || return 1 + head -c 24 /dev/urandom | base64 > "$PWF" && chmod 600 "$PWF" || return 1 + PW=$(cat "$PWF") + T=$(mktemp -d) + cat > "$T/cert.cnf" </dev/null 2>&1 \ + && /usr/bin/openssl pkcs12 -export -inkey "$T/key.pem" -in "$T/cert.pem" -out "$T/id.p12" -passout pass:"$PW" >/dev/null 2>&1 \ + && security create-keychain -p "$PW" "$KC" >/dev/null \ + && security set-keychain-settings "$KC" \ + && security unlock-keychain -p "$PW" "$KC" \ + && security import "$T/id.p12" -k "$KC" -P "$PW" -T /usr/bin/codesign >/dev/null \ + && security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$PW" "$KC" >/dev/null 2>&1 \ + && ok=0 + rm -rf "$T" + return $ok +} + +signed=1 +if [ -f "$KC" ] || make_key; then + if security unlock-keychain -p "$(cat "$PWF")" "$KC" 2>/dev/null \ + && codesign --force --deep --keychain "$KC" -s "$NAME" "$APP" 2>/dev/null; then + signed=0 + echo "已用這台 Mac 固定的簽章簽好(更新後「輔助使用」「螢幕錄製」權限會保留)" + fi +fi +if [ $signed -ne 0 ]; then + codesign --force --deep -s - "$APP" + echo "注意:用臨時簽章(更新後要重新允許「輔助使用」「螢幕錄製」)" +fi diff --git a/src/axread.rs b/src/axread.rs index a794bfa..701f261 100644 --- a/src/axread.rs +++ b/src/axread.rs @@ -303,8 +303,8 @@ pub fn read_person_browser(exe: &str, max_chars: usize) -> Result if !cfg!(target_os = "macos") { return Err("只支援 macOS".into()); } - if !trusted(true) { - let _ = crate::computer::spawn_reaped(&mut std::process::Command::new("open").arg("x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility")); + if !trusted(false) { + crate::computer::ask_permission("Privacy_Accessibility"); return Err("macOS 還沒讓 AnyBot 使用「輔助使用」,所以看不到你的瀏覽器。我已經打開「系統設定 → 隱私權與安全性 → 輔助使用」:把 AnyBot(或「終端機」)打開,然後再叫我試一次。只要開一次。".into()); } let ps = std::process::Command::new("ps").args(["-axo", "pid=,args="]).output().map(|o| String::from_utf8_lossy(&o.stdout).into_owned()).unwrap_or_default(); diff --git a/src/computer.rs b/src/computer.rs index d75d535..6890f34 100644 --- a/src/computer.rs +++ b/src/computer.rs @@ -394,6 +394,53 @@ fn run_text_mode_name(name: &str, v: &Value) -> Option<()> { } } +/// Ask macOS for a permission (its own prompt, then 系統設定 at that pane) at most once every ten +/// minutes. Asking on every step meant a bot working through several steps — or a brain far away +/// working on this computer — covered the screen with prompts and Settings windows, and the +/// person could do nothing else. +pub fn ask_permission(pane: &str) { + static LAST: std::sync::Mutex> = std::sync::Mutex::new(Vec::new()); + { + let mut l = LAST.lock().unwrap_or_else(|e| e.into_inner()); + if l.iter().any(|(p, t)| p == pane && t.elapsed() < std::time::Duration::from_secs(600)) { + return; + } + l.retain(|(p, _)| p != pane); + l.push((pane.to_string(), std::time::Instant::now())); + } + if !cfg!(target_os = "macos") { + return; + } + match pane { + "Privacy_Accessibility" => { + let _ = crate::axread::trusted(true); + } + #[cfg(target_os = "macos")] + "Privacy_ScreenCapture" => { + let _ = mac::screen_capture_allowed(); + } + _ => {} + } + let _ = spawn_reaped(&mut std::process::Command::new("open").arg(format!("x-apple.systempreferences:com.apple.preference.security?{pane}"))); +} + +/// The app a shown name stands for (「計算機」 → Calculator), from the apps' display names. +#[cfg(target_os = "macos")] +fn app_named(name: &str) -> Option { + if name.is_empty() || name.contains(['"', '\\', '\'']) { + return None; + } + let q = format!("kMDItemDisplayName == \"{name}\"cd && kMDItemContentType == \"com.apple.application-bundle\""); + let out = std::process::Command::new("mdfind").arg(q).output().ok()?; + let path = String::from_utf8_lossy(&out.stdout).lines().next()?.trim().to_string(); + std::path::Path::new(&path).file_stem().map(|s| s.to_string_lossy().to_string()) +} + +#[cfg(not(target_os = "macos"))] +fn app_named(_name: &str) -> Option { + None +} + /// The accessibility (text) way: agent-desktop, by element number, in the background. fn run_text_mode(name: &str, v: &Value) -> Option), String>> { let rf = v["ref"].as_str().unwrap_or("").trim().to_string(); @@ -407,9 +454,20 @@ fn run_text_mode(name: &str, v: &Value) -> Option }), "computer_use_open_app" => { let app = v["app"].as_str().unwrap_or("").trim().to_string(); - crate::desktop::run("launch", &json!({"app": app})).and_then(|_| { + // A name as the person sees it (「計算機」 on a Chinese Mac) isn't one the system + // launches by; find the app it is and launch that. + let launched = crate::desktop::run("launch", &json!({"app": app})).or_else(|e| match app_named(&app) { + Some(real) if real != app => crate::desktop::run("launch", &json!({"app": real})), + _ => Err(e), + }); + launched.map(|_| { std::thread::sleep(std::time::Duration::from_millis(800)); - crate::desktop::read_app(&app).map(|(a, lines)| format!("已打開「{a}」。\n{}", lines.join("\n"))) + // Open is open, even when its window can't be read (that's a separate problem, + // and the bot said "couldn't open" while the app sat there on the screen). + match crate::desktop::read_app(&app) { + Ok((a, lines)) => format!("已打開「{a}」。\n{}", lines.join("\n")), + Err(e) => format!("已打開「{app}」,但讀不到它的畫面:{e}"), + } }) } "computer_use_select" => crate::desktop::act("select", &rf, &json!({"value": v["value"]})), @@ -464,8 +522,8 @@ fn run_inner(name: &str, v: &Value) -> Result<(String, Option), String> if HELD_BY_PERSON.load(std::sync::atomic::Ordering::SeqCst) { return Err("使用者正在自己操作這台電腦(已取得控制權)。請等他交還控制權後再操作;可以先截圖看看。".into()); } - if cfg!(target_os = "macos") && !crate::axread::trusted(true) { - let _ = spawn_reaped(&mut std::process::Command::new("open").arg("x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility")); + if cfg!(target_os = "macos") && !crate::axread::trusted(false) { + ask_permission("Privacy_Accessibility"); return Err("macOS 還沒讓 AnyBot 使用「輔助使用」,所以不能動滑鼠和鍵盤。我已經打開「系統設定 → 隱私權與安全性 → 輔助使用」:把 AnyBot(或「終端機」)打開,再叫我試一次。只要開一次。".into()); } let scale = || SCALE.lock().unwrap_or_else(|e| e.into_inner()).ok_or_else(|| "請先用 computer_use_screenshot 看一下畫面,座標要用截圖上的位置。".to_string()); @@ -631,8 +689,8 @@ pub fn app_at_point(x: f64, y: f64) -> Option { } fn screenshot() -> Result<(String, String), String> { - if !mac::screen_capture_allowed() { - let _ = spawn_reaped(&mut std::process::Command::new("open").arg("x-apple.systempreferences:com.apple.preference.security?Privacy_ScreenCapture")); + if !mac::screen_capture_preflight() { + ask_permission("Privacy_ScreenCapture"); return Err("macOS 還沒讓 AnyBot 錄製螢幕,所以看不到畫面。我已經打開「系統設定 → 隱私權與安全性 → 螢幕與系統錄音」:把 AnyBot(或「終端機」)打開,再叫我試一次。只要開一次。".into()); } let img = capture_screen()?;