diff --git a/Cargo.lock b/Cargo.lock index be51b1aa..9dc43523 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8381,6 +8381,7 @@ name = "tcode-ui" version = "0.1.0" dependencies = [ "agent", + "async-channel", "base64 0.23.1", "block2 0.6.2", "chrono", @@ -8396,6 +8397,7 @@ dependencies = [ "imara-diff", "lb-wry", "log", + "objc2 0.6.4", "objc2-app-kit 0.3.2", "objc2-core-foundation", "objc2-foundation 0.3.2", diff --git a/crates/computer-use-mcp/src/permissions.rs b/crates/computer-use-mcp/src/permissions.rs index 1ce779a7..8a7c01bb 100644 --- a/crates/computer-use-mcp/src/permissions.rs +++ b/crates/computer-use-mcp/src/permissions.rs @@ -21,6 +21,44 @@ pub struct PermissionStatus { pub screen_recording: bool, } +/// The explicit user-facing action to perform for a missing permission. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PermissionGrantAction { + Request, + OpenSettings, +} + +/// Chooses the next action for a missing permission without invoking TCC or +/// opening another application. The Settings UI owns those platform effects. +#[derive(Debug, Default)] +pub struct PermissionGrantFlow { + attempted: PermissionStatus, +} + +impl PermissionGrantFlow { + /// Return the action the UI should label without mutating the flow. + pub fn action(&self, kind: PermissionKind) -> PermissionGrantAction { + if self.attempted.granted(kind) { + PermissionGrantAction::OpenSettings + } else { + PermissionGrantAction::Request + } + } + + /// Return the explicit effect for this click and advance a first request to + /// the System Settings fallback for later clicks. + pub fn advance(&mut self, kind: PermissionKind) -> PermissionGrantAction { + let action = self.action(kind); + if action == PermissionGrantAction::Request { + match kind { + PermissionKind::Accessibility => self.attempted.accessibility = true, + PermissionKind::ScreenRecording => self.attempted.screen_recording = true, + } + } + action + } +} + impl PermissionStatus { pub fn granted(&self, kind: PermissionKind) -> bool { match kind { @@ -39,10 +77,11 @@ pub fn check() -> PermissionStatus { imp::check() } -/// Fire the OS prompt for one permission kind. Accessibility prompts inline; -/// Screen Recording prompts at most once per TCC reset, after which the user -/// must flip the toggle in System Settings — pair this with -/// [`open_settings_pane`]. Returns the (possibly already-granted) status. +/// Fire the native request for one permission kind. The system prompt may +/// complete asynchronously or stop appearing after an earlier attempt. Callers +/// should offer [`open_settings_pane`] as a later, explicit fallback instead of +/// opening it in the same action as this request. The return value is the native +/// API's passthrough result, not a completion signal; use [`check`] for state. pub fn request(kind: PermissionKind) -> bool { imp::request(kind) } @@ -187,4 +226,36 @@ mod tests { "\"screen_recording\"" ); } + + #[test] + fn first_grant_action_requests_permission() { + let mut flow = PermissionGrantFlow::default(); + + assert_eq!( + flow.advance(PermissionKind::ScreenRecording), + PermissionGrantAction::Request + ); + } + + #[test] + fn repeated_grant_action_opens_settings() { + let mut flow = PermissionGrantFlow::default(); + let _ = flow.advance(PermissionKind::ScreenRecording); + + assert_eq!( + flow.advance(PermissionKind::ScreenRecording), + PermissionGrantAction::OpenSettings + ); + } + + #[test] + fn grant_flow_exposes_the_next_action() { + let mut flow = PermissionGrantFlow::default(); + let _ = flow.advance(PermissionKind::ScreenRecording); + + assert_eq!( + flow.action(PermissionKind::ScreenRecording), + PermissionGrantAction::OpenSettings + ); + } } diff --git a/crates/protocol/src/command.rs b/crates/protocol/src/command.rs index fb4c3233..c7f5f2bb 100644 --- a/crates/protocol/src/command.rs +++ b/crates/protocol/src/command.rs @@ -101,6 +101,7 @@ pub enum Command { WriteRelaunchMarker { reopen_settings: String, }, + ClearRelaunchMarker, SetTerminalHeight { height: f32, }, diff --git a/crates/protocol/src/tests.rs b/crates/protocol/src/tests.rs index c453fe13..1c351f3b 100644 --- a/crates/protocol/src/tests.rs +++ b/crates/protocol/src/tests.rs @@ -296,6 +296,7 @@ fn assert_command_crosses_ndjson(id: u64, command: Command) { Command::SetActiveAcpAgent { .. } => {} Command::ResetSettings => {} Command::WriteRelaunchMarker { .. } => {} + Command::ClearRelaunchMarker => {} Command::SetTerminalHeight { .. } => {} Command::ToggleTerminalPanel => {} Command::CloseTerminalPanel => {} @@ -444,6 +445,7 @@ fn every_command_and_query_crosses_ndjson() { Command::WriteRelaunchMarker { reopen_settings: "providers".into(), }, + Command::ClearRelaunchMarker, Command::SetTerminalHeight { height: 260.0 }, Command::ToggleTerminalPanel, Command::CloseTerminalPanel, diff --git a/crates/runtime/src/app/mod.rs b/crates/runtime/src/app/mod.rs index 17c03868..5bfa54c2 100644 --- a/crates/runtime/src/app/mod.rs +++ b/crates/runtime/src/app/mod.rs @@ -399,6 +399,13 @@ fn emit_runtime(cx: &mut HostCx, event: RuntimeEvent) { cx.emit(HostEvent::Runtime(event)); } +fn permission_relaunch_marker( + marker: Option, + permissions: computer_use_mcp::permissions::PermissionStatus, +) -> Option { + marker.filter(|marker| marker.reopen_settings != "computer_use" || permissions.screen_recording) +} + impl AppState { pub fn new(store: SessionStore) -> Self { Self::new_with_terminal_registry(store, LocalTerminalRegistry::default(), false) @@ -425,7 +432,12 @@ impl AppState { // the first call, not just after a settings change. computer_use_mcp::config::set(settings.computer_use.clone()); // Consume any restart-continuity marker left by a permission grant. - let pending_relaunch = tcode_services::relaunch::take(store.root()); + // A denied Screen Recording flow must not reopen Settings on a later, + // unrelated launch even if the foreground cleanup never ran. + let pending_relaunch = permission_relaunch_marker( + tcode_services::relaunch::take(store.root()), + computer_use_mcp::permissions::check(), + ); let terminal_preferences_path = store.root().join("terminal-ui.json"); let terminal_preferences = std::fs::read(&terminal_preferences_path) .ok() diff --git a/crates/runtime/src/app/sessions.rs b/crates/runtime/src/app/sessions.rs index 64a7e2e8..e735c7be 100644 --- a/crates/runtime/src/app/sessions.rs +++ b/crates/runtime/src/app/sessions.rs @@ -371,8 +371,8 @@ impl AppState { } /// Persist a restart-continuity marker naming the Settings page to reopen and - /// the session that is active now. Written *before* a permission grant or an - /// explicit relaunch, so an externally-initiated quit reopens cleanly. + /// the session that is active now. Written before a Screen Recording request + /// or an explicit relaunch, so an externally-initiated quit reopens cleanly. pub fn write_relaunch_marker(&self, reopen_settings: &str) { let marker = tcode_services::relaunch::RelaunchMarker { reopen_settings: reopen_settings.to_string(), @@ -383,6 +383,12 @@ impl AppState { } } + pub fn clear_relaunch_marker(&self) { + if let Err(err) = tcode_services::relaunch::clear(self.store.root()) { + log::warn!("failed to clear relaunch marker: {err}"); + } + } + /// Apply a marker taken at launch: reopen the recorded session and open /// Settings on the recorded page. The page reruns a permission recheck as it /// mounts, so the user immediately sees the post-restart status. No-op when diff --git a/crates/runtime/src/app/tests.rs b/crates/runtime/src/app/tests.rs index dd47924e..22bcda02 100644 --- a/crates/runtime/src/app/tests.rs +++ b/crates/runtime/src/app/tests.rs @@ -6,6 +6,22 @@ use tcode_core::project::group_sessions; use tcode_core::settings::{SettingsPatch, ThemeMode}; use tcode_protocol::{Command, CommandResponse, HostMessage}; +#[test] +fn denied_screen_recording_drops_permission_relaunch_marker() { + let marker = tcode_services::relaunch::RelaunchMarker { + reopen_settings: "computer_use".into(), + active_session: Some("session-1".into()), + }; + + assert_eq!( + permission_relaunch_marker( + Some(marker), + computer_use_mcp::permissions::PermissionStatus::default(), + ), + None + ); +} + #[test] fn provider_native_subagent_events_create_and_feed_read_only_mirror_session() { let cx = &mut TestAppContext::default(); diff --git a/crates/runtime/src/pipe.rs b/crates/runtime/src/pipe.rs index c1cebc0e..9ec3411d 100644 --- a/crates/runtime/src/pipe.rs +++ b/crates/runtime/src/pipe.rs @@ -586,6 +586,7 @@ fn dispatch_command( Command::WriteRelaunchMarker { reopen_settings } => { app.write_relaunch_marker(&reopen_settings) } + Command::ClearRelaunchMarker => app.clear_relaunch_marker(), Command::SetTerminalHeight { height } => app.set_terminal_height(height, cx), Command::ToggleTerminalPanel => app.toggle_terminal_panel(cx), Command::CloseTerminalPanel => app.close_terminal_panel(cx), diff --git a/crates/services/src/relaunch.rs b/crates/services/src/relaunch.rs index 22b9208b..334a5df8 100644 --- a/crates/services/src/relaunch.rs +++ b/crates/services/src/relaunch.rs @@ -2,10 +2,12 @@ //! //! macOS applies some TCC grants (notably Screen Recording) only after the app //! restarts, and may quit tcode from its own "Quit & Reopen" dialog. Before any -//! permission flow, the app drops a small `relaunch.json` marker into the data -//! dir recording which Settings page to reopen and which session was active. -//! On the next launch the marker is *taken* (read + deleted) so the app can -//! reopen the session, reopen Settings on the recorded page, and recheck. +//! Screen Recording permission flow, the app drops a small `relaunch.json` +//! marker into the data dir recording which Settings page to reopen and which +//! session was active. A denied flow clears the marker when the app becomes +//! active again. On the next launch any remaining marker is *taken* (read + +//! deleted) so the app can reopen the session, reopen Settings on the recorded +//! page, and recheck. use std::path::{Path, PathBuf}; @@ -34,6 +36,15 @@ pub fn write(data_dir: &Path, marker: &RelaunchMarker) -> std::io::Result<()> { std::fs::write(marker_path(data_dir), data) } +/// Discard a pending marker. Missing markers are already clear. +pub fn clear(data_dir: &Path) -> std::io::Result<()> { + match std::fs::remove_file(marker_path(data_dir)) { + Ok(()) => Ok(()), + Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(err) => Err(err), + } +} + /// Read the marker and delete it (consume-once). Returns `None` when absent or /// unparsable; the file is removed either way so a corrupt marker can't wedge /// every future launch into a relaunch loop. @@ -84,4 +95,24 @@ mod tests { let _ = std::fs::remove_dir_all(root); } + + #[test] + fn clear_discards_a_pending_marker() { + let root = + std::env::temp_dir().join(format!("tcode-relaunch-clear-{}", uuid::Uuid::new_v4())); + std::fs::create_dir_all(&root).unwrap(); + write( + &root, + &RelaunchMarker { + reopen_settings: "computer_use".into(), + active_session: None, + }, + ) + .unwrap(); + + clear(&root).unwrap(); + + assert_eq!(take(&root), None); + let _ = std::fs::remove_dir_all(root); + } } diff --git a/crates/ui/Cargo.toml b/crates/ui/Cargo.toml index 27ae94ae..12aacb55 100644 --- a/crates/ui/Cargo.toml +++ b/crates/ui/Cargo.toml @@ -19,6 +19,7 @@ gpui-component-assets = { git = "https://github.com/longbridge/gpui-component", gpui-component-macros = { git = "https://github.com/longbridge/gpui-component", rev = "fd3bc2bbb8a2c4dfe268c1475682476ada54cd0c" } image = { version = "0.25.10", default-features = false, features = ["png", "jpeg", "gif", "webp", "bmp", "tiff"] } base64 = "0.23" +async-channel = "2" chrono = "0.4" log = "0.4" serde = { version = "1", features = ["derive"] } @@ -37,9 +38,10 @@ raw-window-handle = { version = "0.6", features = ["std"] } [target.'cfg(target_os = "macos")'.dependencies] block2 = "0.6.2" -objc2-app-kit = { version = "0.3.2", features = ["NSGraphicsContext", "NSImage", "NSImageRep", "NSPasteboard", "objc2-core-graphics"] } +objc2 = "0.6.4" +objc2-app-kit = { version = "0.3.2", features = ["NSApplication", "NSGraphicsContext", "NSImage", "NSImageRep", "NSPasteboard", "objc2-core-graphics"] } objc2-core-foundation = { version = "0.3.2", features = ["CFData", "CFString"] } -objc2-foundation = { version = "0.3.2", features = ["NSError"] } +objc2-foundation = { version = "0.3.2", features = ["NSError", "NSNotification", "NSOperation", "NSString", "block2"] } objc2-image-io = { version = "0.3.2", features = ["CGImageDestination"] } objc2-web-kit = { version = "0.3.2", features = ["WKSnapshotConfiguration", "WKWebView"] } diff --git a/crates/ui/src/app_activation.rs b/crates/ui/src/app_activation.rs new file mode 100644 index 00000000..44d0cad0 --- /dev/null +++ b/crates/ui/src/app_activation.rs @@ -0,0 +1,58 @@ +//! Cross-platform app-activation events for permission rechecks. + +pub(crate) struct AppActivationObserver { + #[cfg(target_os = "macos")] + center: objc2::rc::Retained, + #[cfg(target_os = "macos")] + token: + objc2::rc::Retained>, +} + +#[cfg(target_os = "macos")] +impl Drop for AppActivationObserver { + fn drop(&mut self) { + // SAFETY: `token` was returned by this notification center and remains + // retained for the lifetime of the observer. + let protocol: &objc2::runtime::ProtocolObject = + &self.token; + let observer = AsRef::::as_ref(protocol); + unsafe { self.center.removeObserver(observer) }; + } +} + +pub(crate) fn observe() -> (AppActivationObserver, async_channel::Receiver<()>) { + let (sender, receiver) = async_channel::unbounded(); + + #[cfg(target_os = "macos")] + { + use std::ptr::NonNull; + + use block2::RcBlock; + use objc2_app_kit::NSApplicationDidBecomeActiveNotification; + use objc2_foundation::{NSNotification, NSNotificationCenter, NSOperationQueue}; + + let block: RcBlock)> = RcBlock::new(move |_notification| { + let _ = sender.try_send(()); + }); + let center = NSNotificationCenter::defaultCenter(); + let queue = NSOperationQueue::mainQueue(); + // SAFETY: AppKit posts this notification on the main thread, the block + // is retained by the notification center, and `queue` keeps delivery on + // the main operation queue. + let token = unsafe { + center.addObserverForName_object_queue_usingBlock( + Some(NSApplicationDidBecomeActiveNotification), + None, + Some(&queue), + &block, + ) + }; + (AppActivationObserver { center, token }, receiver) + } + + #[cfg(not(target_os = "macos"))] + { + drop(sender); + (AppActivationObserver {}, receiver) + } +} diff --git a/crates/ui/src/lib.rs b/crates/ui/src/lib.rs index 56f506f8..e50d1f2d 100644 --- a/crates/ui/src/lib.rs +++ b/crates/ui/src/lib.rs @@ -1,5 +1,6 @@ mod acp_panel; mod add_project_dialog; +mod app_activation; pub mod assets; mod attachments; mod chat; diff --git a/crates/ui/src/settings_page.rs b/crates/ui/src/settings_page.rs index 46eba8e8..2021b2d3 100644 --- a/crates/ui/src/settings_page.rs +++ b/crates/ui/src/settings_page.rs @@ -25,7 +25,8 @@ use gpui::{ use gpui_base::{StyledExt as _, v_flex}; use computer_use_mcp::permissions::{ - self, PermissionKind, PermissionStatus, open_settings_pane, relaunch_app, request, + self, PermissionGrantAction, PermissionGrantFlow, PermissionKind, PermissionStatus, + open_settings_pane, relaunch_app, request, }; use crate::acp_panel::{AcpAgentCard, AcpPanel}; @@ -112,6 +113,13 @@ pub struct SettingsPage { /// Whether a Screen Recording grant looks pending-restart (a fresh grant /// only takes effect after tcode relaunches). Drives the restart banner. sr_restart_hint: bool, + /// A temporary continuity marker exists for an in-flight Screen Recording + /// request. It is cleared when the app becomes active without a grant. + screen_recording_marker_pending: bool, + /// Separates the initial native TCC request from the explicit fallback that + /// opens System Settings when macOS will no longer show its prompt. + permission_grant_flow: PermissionGrantFlow, + _app_activation_observer: crate::app_activation::AppActivationObserver, /// One focus handle per toggle row, keyed by row id. The row owns keyboard /// activation, so its capture-phase Space handler must be able to tell /// "the row is focused" from "the inline reset button inside it is". @@ -247,6 +255,7 @@ impl SettingsPage { // opened by a post-grant relaunch this is the "automatic recheck" that // surfaces the new status immediately. let perm_status = permissions::check(); + let (app_activation_observer, app_activation_events) = crate::app_activation::observe(); let mut page = Self { store, window_state, @@ -262,6 +271,9 @@ impl SettingsPage { auto_archive_keep_input: auto_archive_keep_input.clone(), perm_status, sr_restart_hint: false, + screen_recording_marker_pending: false, + permission_grant_flow: PermissionGrantFlow::default(), + _app_activation_observer: app_activation_observer, toggle_focus: HashMap::new(), _subscriptions: subscriptions, }; @@ -287,6 +299,21 @@ impl SettingsPage { ); page.build_provider_cards(cx); page.sync_acp_cards(window, cx); + cx.spawn(async move |this, cx| { + while app_activation_events.recv().await.is_ok() { + if this + .update(cx, |this, cx| { + if this.section == Section::ComputerUse { + this.recheck_permissions(true, cx); + } + }) + .is_err() + { + break; + } + } + }) + .detach(); page } @@ -1545,6 +1572,11 @@ impl SettingsPage { fn permission_row(&self, kind: PermissionKind, cx: &mut Context) -> AnyElement { let granted = self.perm_status.granted(kind); + let grant_action = self.permission_grant_flow.action(kind); + let grant_label = match grant_action { + PermissionGrantAction::Request => crate::tr!("permissions.grant"), + PermissionGrantAction::OpenSettings => crate::tr!("permissions.open_settings"), + }; let (name_key, why_key, grant_id, recheck_id) = match kind { PermissionKind::Accessibility => ( "permissions.accessibility.name", @@ -1570,7 +1602,7 @@ impl SettingsPage { Button::new(grant_id) .outline() .small() - .label(crate::tr!("permissions.grant")) + .label(grant_label) .on_click(cx.listener(move |this, _, _, cx| { this.grant_permission(kind, cx); })), @@ -1581,7 +1613,7 @@ impl SettingsPage { .small() .label(crate::tr!("permissions.recheck")) .on_click(cx.listener(|this, _, _, cx| { - this.recheck_permissions(cx); + this.recheck_permissions(true, cx); })), ); } @@ -1641,24 +1673,41 @@ impl SettingsPage { .into_any_element() } - /// Persist the restart-continuity marker, then fire the OS prompt and open - /// the matching System Settings pane. The marker must be written *first*: - /// macOS may quit tcode from its own "Quit & Reopen" dialog. + /// Fire the native prompt first. If the permission remains missing, the + /// next explicit click opens System Settings as a fallback; doing both at + /// once races macOS's own consent dialog and duplicates its Open Settings + /// action. fn grant_permission(&mut self, kind: PermissionKind, cx: &mut Context) { - self.store.update(cx, |store, _cx| { - store.write_relaunch_marker("computer_use".into()); - }); - let _ = request(kind); - open_settings_pane(kind); - if kind == PermissionKind::ScreenRecording { - self.sr_restart_hint = true; + match self.permission_grant_flow.advance(kind) { + PermissionGrantAction::Request => { + if kind == PermissionKind::ScreenRecording { + self.store.update(cx, |store, _cx| { + store.write_relaunch_marker("computer_use".into()); + }); + self.screen_recording_marker_pending = true; + } + let _ = request(kind); + // Both native request APIs may return before the user has + // completed the system UI, so this immediate snapshot must not + // clear the temporary Screen Recording marker. + self.recheck_permissions(false, cx); + } + PermissionGrantAction::OpenSettings => { + open_settings_pane(kind); + cx.notify(); + } } - self.perm_status = permissions::check(); - cx.notify(); } - fn recheck_permissions(&mut self, cx: &mut Context) { + fn recheck_permissions(&mut self, clear_ungranted_marker: bool, cx: &mut Context) { let fresh = permissions::check(); + if clear_ungranted_marker && self.screen_recording_marker_pending && !fresh.screen_recording + { + self.store.update(cx, |store, _cx| { + store.clear_relaunch_marker(); + }); + self.screen_recording_marker_pending = false; + } // A Screen Recording grant that flips on still needs a restart to take // effect for the running process, so surface the relaunch affordance. if fresh.screen_recording && !self.perm_status.screen_recording { diff --git a/crates/ui/src/store/intents.rs b/crates/ui/src/store/intents.rs index ca3aa63e..378ebeb5 100644 --- a/crates/ui/src/store/intents.rs +++ b/crates/ui/src/store/intents.rs @@ -152,6 +152,9 @@ impl WorkspaceStore { pub fn write_relaunch_marker(&mut self, reopen_settings: String) { self.dispatch(Command::WriteRelaunchMarker { reopen_settings }); } + pub fn clear_relaunch_marker(&mut self) { + self.dispatch(Command::ClearRelaunchMarker); + } pub fn set_sidebar_collapsed(&mut self, collapsed: bool) { self.dispatch(Command::SetSidebarCollapsed { collapsed }); } diff --git a/docs/computer-use-permissions-research.md b/docs/computer-use-permissions-research.md new file mode 100644 index 00000000..3976ca42 --- /dev/null +++ b/docs/computer-use-permissions-research.md @@ -0,0 +1,133 @@ +# macOS Computer Use 权限 UX 调研 + +调研日期:2026-08-27。范围仅限 tcode 的 Accessibility 与 Screen Recording +授权引导;实现仍使用现有 Core Graphics / AX API。本结论核对了 Apple 官方文档、 +Apple Support、Apple DTS 回复,以及本机 Xcode 26.5 所带 macOS 26.5 SDK 头文件。 + +## 结论 + +1. **首次请求不要同时主动打开 System Settings。** 用户点击授权后,只调用对应的 + 系统请求 API,让系统弹窗完成这一轮交互。Apple 对 Accessibility 的用户指南明确 + 要求用户在系统弹窗中选择 **Open System Settings**;应用同时跳转会抢在用户选择前 + 打开同一页面,重复了系统交互。[Apple Support:Accessibility 授权流程][ax-support] +2. **把“请求权限”和“打开系统设置”拆成两个动作。** 只有请求后仍检测为未授权,或 + 用户稍后重试时,才显示独立的 **Open System Settings** 兜底按钮。Apple HIG 要求在 + 功能确实需要资源时请求,并依赖系统标准权限弹窗让用户作决定。[Apple HIG:Privacy][hig-privacy] +3. **请求 API 的返回值不能承担完整状态机。** Accessibility 请求的提示是异步的, + 明确“不影响返回值”;Screen Recording 的公开文档与 SDK 头文件没有定义 request + 返回的 `Bool` 如何区分首次、拒绝或待重启。实际授权状态应分别以 + `AXIsProcessTrusted()` 和 `CGPreflightScreenCaptureAccess()` 的后续结果为准。 +4. **只有确认 Screen Recording 从未授权变为已授权,才提示重启。** Apple 的 + ScreenCaptureKit 官方示例明确要求首次授权后重启应用才能捕获;仅仅点击 Grant、 + 弹出窗口或返回 `false` 都不代表需要重启。[Apple ScreenCaptureKit 示例][sc-sample] + +## API 能知道什么 + +| 权限 | 无提示检查 | 请求 | 可可靠表达 | 不能表达 | +| --- | --- | --- | --- | --- | +| Accessibility | `AXIsProcessTrusted()` | `AXIsProcessTrustedWithOptions({ prompt: true })` | 当前进程此刻是否为 trusted client | 弹窗是否出现、用户是否正在选择、`false` 是首次还是拒绝 | +| Screen Recording | `CGPreflightScreenCaptureAccess()` | `CGRequestScreenCaptureAccess()` | 当前进程是否已有 screen capture access | `false` 是首次/拒绝/等待设置/待重启,系统是否会再次弹窗 | + +### Accessibility + +Apple 对 `AXIsProcessTrustedWithOptions` 的契约很明确:返回值只表示当前进程是否已被 +信任;`kAXTrustedCheckOptionPrompt` 触发的提示是异步的,且不影响返回值。因此首次调用 +时返回 `false` 是预期行为,不能被解释成用户刚刚拒绝,也没有 completion callback。 +[Apple Developer Documentation][ax-options] + +本机 SDK 的 +`ApplicationServices.framework/.../HIServices.framework/.../AXUIElement.h:55-74` +与线上文档一致;无提示复检可继续使用 `AXIsProcessTrusted()`。 + +### Screen Recording + +本机 SDK 的 `CoreGraphics.framework/.../Headers/CGWindow.h:294-298` 只承诺: + +- `CGPreflightScreenCaptureAccess()` 检查当前进程是否已经有捕获权限; +- `CGRequestScreenCaptureAccess()` 在缺少权限时发起请求,并且“potentially prompting”。 + +Apple 的公开 API 页面只给出 `CGRequestScreenCaptureAccess() -> Bool` 声明,没有 +Return Value 语义。[Apple Developer Documentation:request][cg-request] +Apple DTS 也将职责拆成“request 用于触发对话框,preflight 用于检测是否已授权”。 +[Apple Developer Forums(DTS 回复)][cg-dts] + +所以稳健做法是:忽略 request 返回值的产品含义,调用后再以 preflight 为事实来源; +不要据此制造 `denied`、`prompt_shown` 或 `restart_required` 等系统并未提供的状态。 + +## 推荐产品流程 + +```text +页面出现 / 应用重新激活 + └─ 无提示复检 + ├─ 已授权 → Granted + └─ 未授权 + ├─ 本轮尚未请求 → Request Access + │ └─ 只调用系统 request,不主动跳设置 + └─ 已请求仍未授权 → Open System Settings + Recheck + +Screen Recording 在同一进程观察到 false → true + └─ Granted · Restart required → 用户主动 Relaunch +``` + +具体约束: + +- 页面 mount、用户点 **Recheck**、以及从系统弹窗或 System Settings 回到 tcode 时复检。 + AppKit 的 `NSApplication.didBecomeActiveNotification` 会在应用重新 active 后发出,适合 + 触发这次复检。[Apple Developer Documentation:didBecomeActive][app-active] +- 首次 **Request Access** 只调用 request。若仍是 `false`,界面保持“未授权/等待用户 + 完成系统操作”,不要立即宣告拒绝。 +- 后续 **Open System Settings** 必须是用户明确点击的兜底动作,不能与 request 在同一 + 点击处理器中无条件连用。精确 pane deep-link 只是导航便利,不是授权状态信号;若 + 系统版本不接受链接,应保留可读的手动路径说明。 +- 重复调用 request 不应承诺一定再出现弹窗:AX 提示是异步通知,CG 头文件只写 + “potentially prompting”。一旦本轮请求后仍未授权,主行动应变为打开设置并复检。 +- Screen Recording 由 `false` 变为 `true` 后再显示 **Relaunch tcode**;Accessibility + 不因一次请求显示重启提示。Apple 当前的 screen recording 用户指南也允许用户随时 + 在 Privacy & Security 中更改选择,因此每次使用前的事实检查仍有价值。 + [Apple Support:Screen & System Audio Recording][screen-support] + +### Relaunch marker + +授权请求不等于授权成功,因此 marker 不应同时充当“已需要重启”的证据: + +- 普通的 app-controlled relaunch:在用户点击 **Relaunch tcode** 后、真正 relaunch 前 + 写 marker。 +- 如果 tcode 要在权限设置期间发生任何 quit/relaunch 后继续恢复原页面,可在发起 + Screen Recording 请求前写一个**临时 flow marker**,但应用重新 active 且 preflight + 仍为 `false` 时必须清除;它只能用于恢复页面/会话,不能驱动 restart banner。 +- 启动后消费 marker 并重新检查;最终 UI 始终以两个官方检查 API 为准。 + +## 对当前实现的直接含义 + +修复前的 `SettingsPage::grant_permission` 在一次点击里依次执行 request 和 +`open_settings_pane`,并且对 Screen Recording 无条件设置 restart hint。应改为: + +1. 删除 request 后的自动 `open_settings_pane`; +2. 为仍未授权的行保留独立 **Open System Settings** 兜底; +3. 在 app 重新 active 时自动复检,同时保留手动 **Recheck**; +4. 只在观察到 Screen Recording `false -> true` 时设置 restart hint; +5. Accessibility 请求不写 relaunch marker;Screen Recording 的临时 marker 在未授权 + 返回时清理。 + +## 验证矩阵 + +- 全新 TCC 状态:请求后只出现系统弹窗,不由 tcode 同时打开设置。 +- 系统弹窗中拒绝:仍显示未授权和 Open System Settings,不显示重启,不遗留 marker。 +- 从弹窗或设置中授权 Accessibility:回到 tcode 后自动变为 Granted,无重启提示。 +- 从弹窗或设置中授权 Screen Recording:复检确认后才显示 Restart required;重启后 + 仍为 Granted,marker 被消费。 +- 已拒绝后再次点击:即使系统不再弹窗,Open System Settings 仍可完成流程。 +- 在系统设置中撤销任一权限:下次激活/使用前复检回到未授权。 +- Debug 与 release 使用稳定签名身份;Apple DTS 确认改变 code-signing identity 会让 + 系统把构建视为不同应用,导致已有 screen capture 权限不再匹配。 + [Apple Developer Forums(DTS 回复)][signing-dts] + +[ax-support]: https://support.apple.com/en-gb/guide/mac-help/mh43185/mac +[hig-privacy]: https://developer.apple.com/design/human-interface-guidelines/privacy +[ax-options]: https://developer.apple.com/documentation/applicationservices/1459186-axisprocesstrustedwithoptions +[cg-request]: https://developer.apple.com/documentation/coregraphics/cgrequestscreencaptureaccess() +[cg-dts]: https://developer.apple.com/forums/thread/683860?answerId=684400022 +[sc-sample]: https://developer.apple.com/documentation/screencapturekit/capturing-screen-content-in-macos +[app-active]: https://developer.apple.com/documentation/appkit/nsapplication/didbecomeactivenotification +[screen-support]: https://support.apple.com/en-ie/guide/mac-help/mchld6aa7d23/mac +[signing-dts]: https://developer.apple.com/forums/thread/819406 diff --git a/docs/computer-use.md b/docs/computer-use.md index f596b8b3..cabc9b5c 100644 --- a/docs/computer-use.md +++ b/docs/computer-use.md @@ -123,21 +123,25 @@ Settings gains two pages: allow-JS-evaluate toggle. Its in-process WKWebView snapshot tool needs no TCC permission. - **Computer Use** — master enable toggle, image mode (`auto` / `always` / `never`), allow-input-actions toggle (off = observe-only), and one permission row per TCC kind: - live status, a **Grant** button (fires the TCC prompt and opens the matching - `x-apple.systempreferences` pane), and **Recheck**. + live status, a primary action, and **Recheck**. The primary action starts as + **Request Access** and fires only the native TCC request. If the permission is still missing, + the next explicit action becomes **Open System Settings** and deep-links the matching + `x-apple.systempreferences` pane. Returning to tcode also triggers a recheck. ### Restart continuity macOS applies some grants (notably Screen Recording) only after the app restarts, and shows its -own "Quit & Reopen" dialog. tcode therefore treats any permission flow as a potential restart: +own "Quit & Reopen" dialog. tcode therefore preserves Screen Recording flows across a restart: -1. When the user clicks **Grant**, tcode first writes a small `relaunch.json` marker into the - data dir: `{ reopen_settings: "computer_use", active_session: }`. +1. Before a Screen Recording request, tcode writes a temporary `relaunch.json` marker into the + data dir: `{ reopen_settings: "computer_use", active_session: }`. Accessibility does not + need this marker. Returning without a grant clears it. 2. Session timelines are already continuously persisted (JSONL + resume cursors), so an externally-initiated quit loses nothing. -3. On startup, a present marker is consumed: the previous active session is reopened, the - Settings window is reopened on the recorded page, and permissions are rechecked - automatically so the user immediately sees the new status. +3. On startup, a present marker is consumed and validated against the current Screen Recording + status. After a real grant, the previous active session is reopened, the Settings window is + reopened on the recorded page, and permissions are rechecked automatically. A denied or stale + marker is discarded without changing the launch route. 4. The Computer Use page also offers an explicit **Relaunch tcode** button (shown when a grant was detected as pending-restart) that writes the same marker and relaunches via `open -n `. diff --git a/locales/en.yml b/locales/en.yml index 580cb382..4692292c 100644 --- a/locales/en.yml +++ b/locales/en.yml @@ -287,7 +287,8 @@ browser: permissions: granted: "Granted" missing: "Not granted" - grant: "Grant" + grant: "Request Access" + open_settings: "Open System Settings" recheck: "Recheck" unsupported: "Only available on macOS." restart_banner: "Screen Recording grants take effect only after tcode restarts." diff --git a/locales/zh-CN.yml b/locales/zh-CN.yml index b7399a5f..3fe6f7ed 100644 --- a/locales/zh-CN.yml +++ b/locales/zh-CN.yml @@ -287,7 +287,8 @@ browser: permissions: granted: "已授权" missing: "未授权" - grant: "去授权" + grant: "请求授权" + open_settings: "打开系统设置" recheck: "重新检查" unsupported: "仅在 macOS 上可用。" restart_banner: "屏幕录制授权需重新启动 tcode 后才会生效。"