@agent-native/core 0.114.6 → 0.114.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (155) hide show
  1. package/corpus/README.md +2 -2
  2. package/corpus/core/CHANGELOG.md +15 -0
  3. package/corpus/core/package.json +1 -1
  4. package/corpus/core/src/cli/mcp-config-writers.ts +6 -4
  5. package/corpus/core/src/cli/mcp.ts +124 -0
  6. package/corpus/core/src/cli/skills-content/help.ts +4 -3
  7. package/corpus/core/src/cli/skills-content/index.ts +1 -0
  8. package/corpus/core/src/cli/skills-content/rewind-skill.ts +52 -0
  9. package/corpus/core/src/cli/skills.ts +115 -13
  10. package/corpus/core/src/mcp/screen-memory-stdio.ts +1360 -104
  11. package/corpus/core/src/mcp-client/index.ts +9 -0
  12. package/corpus/core/src/mcp-client/screen-memory-local.ts +847 -13
  13. package/corpus/core/src/server/agent-chat-plugin.ts +16 -9
  14. package/corpus/templates/analytics/app/components/dashboard/SqlChart.tsx +19 -7
  15. package/corpus/templates/analytics/app/hooks/use-chart-tooltip-portal.ts +60 -0
  16. package/corpus/templates/analytics/app/routes/chart.tsx +1 -1
  17. package/corpus/templates/analytics/changelog/2026-07-20-dashboard-requests-no-longer-stall-without-progress.md +6 -0
  18. package/corpus/templates/analytics/server/plugins/agent-chat.ts +6 -0
  19. package/corpus/templates/clips/actions/apply-rewind-extension.ts +195 -0
  20. package/corpus/templates/clips/actions/delete-agent-recording-if-unpromoted.ts +64 -0
  21. package/corpus/templates/clips/actions/get-rewind-extension-request.ts +22 -0
  22. package/corpus/templates/clips/actions/get-screen-memory-status.ts +18 -2
  23. package/corpus/templates/clips/actions/list-rewind-extension-requests.ts +52 -0
  24. package/corpus/templates/clips/actions/make-recording-private-for-rewind.ts +55 -0
  25. package/corpus/templates/clips/actions/query-screen-memory-context.ts +4 -4
  26. package/corpus/templates/clips/actions/request-rewind-extension.ts +81 -0
  27. package/corpus/templates/clips/actions/update-rewind-extension-request.ts +47 -0
  28. package/corpus/templates/clips/app/components/editor/editor-layout.tsx +25 -0
  29. package/corpus/templates/clips/app/components/editor/editor-toolbar.tsx +36 -0
  30. package/corpus/templates/clips/app/components/editor/rewind-extension-dialog.tsx +338 -0
  31. package/corpus/templates/clips/app/components/editor/stitch-manager.tsx +1 -16
  32. package/corpus/templates/clips/app/components/editor/timeline.tsx +26 -0
  33. package/corpus/templates/clips/app/i18n/en-US.ts +18 -0
  34. package/corpus/templates/clips/app/lib/ffmpeg-export.ts +19 -12
  35. package/corpus/templates/clips/app/lib/rewind-visibility.ts +31 -0
  36. package/corpus/templates/clips/app/lib/timestamp-mapping.ts +7 -0
  37. package/corpus/templates/clips/app/lib/upload-file-client.ts +17 -0
  38. package/corpus/templates/clips/changelog/2026-07-14-rewind-keeps-recent-screen-context-local-and-turns-it-into-clips.md +6 -0
  39. package/corpus/templates/clips/changelog/2026-07-17-active-clips-keep-rewind-stable-and-settings-clear.md +12 -0
  40. package/corpus/templates/clips/changelog/2026-07-17-clicking-the-clips-icon-during-a-recording-now-opens-the-app.md +6 -0
  41. package/corpus/templates/clips/changelog/2026-07-17-default-mic-now-uses-the-mac-default-and-transcription-shares-one-capture.md +6 -0
  42. package/corpus/templates/clips/changelog/2026-07-17-full-screen-clips-finalize-even-when-screencapturekit-stop-hangs.md +6 -0
  43. package/corpus/templates/clips/changelog/2026-07-17-normal-full-screen-clips-no-longer-stop-immediately-after-th.md +6 -0
  44. package/corpus/templates/clips/changelog/2026-07-17-rewind-can-hand-one-bounded-private-clip-to-your-agent.md +12 -0
  45. package/corpus/templates/clips/changelog/2026-07-17-rewind-home-can-copy-a-local-agent-prompt.md +6 -0
  46. package/corpus/templates/clips/changelog/2026-07-17-rewind-now-has-a-calm-home-status-focused-first-run-consent-.md +6 -0
  47. package/corpus/templates/clips/changelog/2026-07-17-rewind-settings-are-smaller-clearer-and-agent-first.md +6 -0
  48. package/corpus/templates/clips/changelog/2026-07-17-the-full-clips-interface-stays-available-while-recording.md +6 -0
  49. package/corpus/templates/clips/changelog/2026-07-18-rewind-organizes-recent-work-into-local-searchable-chapters.md +8 -0
  50. package/corpus/templates/clips/changelog/2026-07-18-searchable-rewind-chapters.md +6 -0
  51. package/corpus/templates/clips/changelog/2026-07-19-rewind-backed-recordings-now-start-immediately-after-the-cou.md +6 -0
  52. package/corpus/templates/clips/changelog/2026-07-20-rewind-can-set-up-your-local-agent-once-so-future-requests-w.md +6 -0
  53. package/corpus/templates/clips/desktop/package.json +1 -0
  54. package/corpus/templates/clips/desktop/scripts/sign-macos-local.ts +12 -1
  55. package/corpus/templates/clips/desktop/src/app.tsx +2498 -239
  56. package/corpus/templates/clips/desktop/src/components/ReadinessPanel.tsx +21 -1
  57. package/corpus/templates/clips/desktop/src/components/Switch.tsx +3 -0
  58. package/corpus/templates/clips/desktop/src/hooks/useMeetingTranscription.ts +156 -24
  59. package/corpus/templates/clips/desktop/src/lib/native-recording-warm.ts +6 -7
  60. package/corpus/templates/clips/desktop/src/lib/recorder.ts +1763 -1160
  61. package/corpus/templates/clips/desktop/src/lib/rewind-agent-prompt.ts +9 -0
  62. package/corpus/templates/clips/desktop/src/lib/rewind-recording-start.ts +20 -0
  63. package/corpus/templates/clips/desktop/src/main.tsx +3 -0
  64. package/corpus/templates/clips/desktop/src/overlays/countdown.tsx +14 -14
  65. package/corpus/templates/clips/desktop/src/overlays/onboarding.tsx +4 -4
  66. package/corpus/templates/clips/desktop/src/overlays/preparing.tsx +13 -0
  67. package/corpus/templates/clips/desktop/src/overlays/toolbar.tsx +27 -2
  68. package/corpus/templates/clips/desktop/src/shared/config.ts +16 -0
  69. package/corpus/templates/clips/desktop/src/styles.css +956 -134
  70. package/corpus/templates/clips/desktop/src-tauri/Cargo.toml +5 -0
  71. package/corpus/templates/clips/desktop/src-tauri/build.rs +69 -1
  72. package/corpus/templates/clips/desktop/src-tauri/src/accessibility.rs +386 -12
  73. package/corpus/templates/clips/desktop/src-tauri/src/capture_audio_bus.rs +382 -0
  74. package/corpus/templates/clips/desktop/src-tauri/src/capture_graph.rs +777 -0
  75. package/corpus/templates/clips/desktop/src-tauri/src/clips/mod.rs +249 -22
  76. package/corpus/templates/clips/desktop/src-tauri/src/config.rs +148 -1
  77. package/corpus/templates/clips/desktop/src-tauri/src/lib.rs +58 -4
  78. package/corpus/templates/clips/desktop/src-tauri/src/logfile.rs +13 -1
  79. package/corpus/templates/clips/desktop/src-tauri/src/native_screen/custom_capture.rs +1505 -51
  80. package/corpus/templates/clips/desktop/src-tauri/src/native_screen/live_upload.rs +124 -0
  81. package/corpus/templates/clips/desktop/src-tauri/src/native_screen.rs +1006 -95
  82. package/corpus/templates/clips/desktop/src-tauri/src/rewind_capture_suspension.rs +165 -0
  83. package/corpus/templates/clips/desktop/src-tauri/src/rewind_chapters.rs +2434 -0
  84. package/corpus/templates/clips/desktop/src-tauri/src/rewind_clip.rs +1650 -0
  85. package/corpus/templates/clips/desktop/src-tauri/src/rewind_egress.rs +643 -0
  86. package/corpus/templates/clips/desktop/src-tauri/src/rewind_local_ask.rs +803 -0
  87. package/corpus/templates/clips/desktop/src-tauri/src/rewind_meeting_history.rs +595 -0
  88. package/corpus/templates/clips/desktop/src-tauri/src/screen_memory.rs +2900 -91
  89. package/corpus/templates/clips/desktop/src-tauri/src/screen_memory_ocr.rs +360 -0
  90. package/corpus/templates/clips/desktop/src-tauri/src/screen_memory_transcript.rs +408 -0
  91. package/corpus/templates/clips/desktop/src-tauri/src/shortcuts.rs +114 -33
  92. package/corpus/templates/clips/desktop/src-tauri/src/state.rs +2 -2
  93. package/corpus/templates/clips/desktop/src-tauri/src/system_audio.rs +13 -3
  94. package/corpus/templates/clips/desktop/src-tauri/src/tray.rs +4 -1
  95. package/corpus/templates/clips/desktop/src-tauri/src/util.rs +7 -13
  96. package/corpus/templates/clips/desktop/src-tauri/src/whisper_speech.rs +198 -11
  97. package/corpus/templates/clips/desktop/src-tauri/tauri.alpha.conf.json +17 -0
  98. package/corpus/toolkit/CHANGELOG.md +6 -0
  99. package/corpus/toolkit/package.json +1 -1
  100. package/dist/cli/mcp-config-writers.d.ts +2 -2
  101. package/dist/cli/mcp-config-writers.d.ts.map +1 -1
  102. package/dist/cli/mcp-config-writers.js +6 -6
  103. package/dist/cli/mcp-config-writers.js.map +1 -1
  104. package/dist/cli/mcp.d.ts +14 -0
  105. package/dist/cli/mcp.d.ts.map +1 -1
  106. package/dist/cli/mcp.js +79 -0
  107. package/dist/cli/mcp.js.map +1 -1
  108. package/dist/cli/skills-content/help.d.ts +1 -1
  109. package/dist/cli/skills-content/help.d.ts.map +1 -1
  110. package/dist/cli/skills-content/help.js +4 -3
  111. package/dist/cli/skills-content/help.js.map +1 -1
  112. package/dist/cli/skills-content/index.d.ts +1 -0
  113. package/dist/cli/skills-content/index.d.ts.map +1 -1
  114. package/dist/cli/skills-content/index.js +1 -0
  115. package/dist/cli/skills-content/index.js.map +1 -1
  116. package/dist/cli/skills-content/rewind-skill.d.ts +2 -0
  117. package/dist/cli/skills-content/rewind-skill.d.ts.map +1 -0
  118. package/dist/cli/skills-content/rewind-skill.js +53 -0
  119. package/dist/cli/skills-content/rewind-skill.js.map +1 -0
  120. package/dist/cli/skills.d.ts +6 -0
  121. package/dist/cli/skills.d.ts.map +1 -1
  122. package/dist/cli/skills.js +90 -14
  123. package/dist/cli/skills.js.map +1 -1
  124. package/dist/collab/struct-routes.d.ts +1 -1
  125. package/dist/mcp/screen-memory-stdio.d.ts +365 -0
  126. package/dist/mcp/screen-memory-stdio.d.ts.map +1 -1
  127. package/dist/mcp/screen-memory-stdio.js +1014 -91
  128. package/dist/mcp/screen-memory-stdio.js.map +1 -1
  129. package/dist/mcp-client/index.d.ts +1 -1
  130. package/dist/mcp-client/index.d.ts.map +1 -1
  131. package/dist/mcp-client/index.js +1 -1
  132. package/dist/mcp-client/index.js.map +1 -1
  133. package/dist/mcp-client/screen-memory-local.d.ts +90 -0
  134. package/dist/mcp-client/screen-memory-local.d.ts.map +1 -1
  135. package/dist/mcp-client/screen-memory-local.js +590 -12
  136. package/dist/mcp-client/screen-memory-local.js.map +1 -1
  137. package/dist/observability/routes.d.ts +1 -1
  138. package/dist/progress/routes.d.ts +1 -1
  139. package/dist/provider-api/actions/provider-api.d.ts +4 -4
  140. package/dist/resources/handlers.d.ts +1 -1
  141. package/dist/server/agent-chat-plugin.d.ts +5 -0
  142. package/dist/server/agent-chat-plugin.d.ts.map +1 -1
  143. package/dist/server/agent-chat-plugin.js +10 -9
  144. package/dist/server/agent-chat-plugin.js.map +1 -1
  145. package/package.json +2 -2
  146. package/src/cli/mcp-config-writers.ts +6 -4
  147. package/src/cli/mcp.ts +124 -0
  148. package/src/cli/skills-content/help.ts +4 -3
  149. package/src/cli/skills-content/index.ts +1 -0
  150. package/src/cli/skills-content/rewind-skill.ts +52 -0
  151. package/src/cli/skills.ts +115 -13
  152. package/src/mcp/screen-memory-stdio.ts +1360 -104
  153. package/src/mcp-client/index.ts +9 -0
  154. package/src/mcp-client/screen-memory-local.ts +847 -13
  155. package/src/server/agent-chat-plugin.ts +16 -9
@@ -77,6 +77,11 @@ libc = "0.2"
77
77
  core-foundation = "0.10"
78
78
  core-graphics = "0.25"
79
79
  objc2 = { version = "0.6", features = ["exception"] }
80
+ objc2-app-kit = { version = "0.3", default-features = false, features = [
81
+ "std",
82
+ "NSEvent",
83
+ "block2",
84
+ ] }
80
85
  # Grapheme-cluster-aware chunking for voice-dictation Unicode typing in
81
86
  # terminal apps, so a chunk boundary never splits a multi-scalar emoji/
82
87
  # combining-mark sequence across two synthetic keyboard events. Already
@@ -1,12 +1,78 @@
1
- use std::path::Path;
1
+ use std::path::{Path, PathBuf};
2
2
  use std::process::Command;
3
3
 
4
4
  fn main() {
5
5
  emit_sentry_env_reruns();
6
+ compile_screen_memory_ocr_helper();
6
7
  add_swift_runtime_rpaths();
7
8
  tauri_build::build()
8
9
  }
9
10
 
11
+ /// Build the tiny, macOS-only AVFoundation/Vision bridge used by local Screen
12
+ /// Memory OCR. Keeping this outside the Rust dependency graph avoids adding a
13
+ /// large Objective-C binding surface for a single, OS-provided capability.
14
+ fn compile_screen_memory_ocr_helper() {
15
+ if std::env::var("CARGO_CFG_TARGET_OS").as_deref() != Ok("macos") {
16
+ return;
17
+ }
18
+
19
+ let source = Path::new("native/screen_memory_ocr.swift");
20
+ println!("cargo:rerun-if-changed={}", source.display());
21
+
22
+ let out_dir = PathBuf::from(std::env::var("OUT_DIR").expect("Cargo sets OUT_DIR"));
23
+ let object = out_dir.join("screen_memory_ocr_helper.o");
24
+ let archive = out_dir.join("libscreen_memory_ocr_helper.a");
25
+
26
+ // `tauri build --target universal-apple-darwin` invokes Cargo once per
27
+ // architecture. swiftc otherwise emits an object for the runner's host
28
+ // architecture, which makes the x86_64 link fail on arm64 CI runners.
29
+ let swift_arch = match std::env::var("CARGO_CFG_TARGET_ARCH").as_deref() {
30
+ Ok("aarch64") => "arm64",
31
+ Ok("x86_64") => "x86_64",
32
+ Ok(arch) => panic!("unsupported macOS OCR helper architecture: {arch}"),
33
+ Err(error) => panic!("Cargo target architecture is required: {error}"),
34
+ };
35
+ let deployment_target =
36
+ std::env::var("MACOSX_DEPLOYMENT_TARGET").unwrap_or_else(|_| "13.0".to_string());
37
+ let swift_target = format!("{swift_arch}-apple-macosx{deployment_target}");
38
+
39
+ let swift_status = Command::new("xcrun")
40
+ .args([
41
+ "swiftc",
42
+ "-parse-as-library",
43
+ "-emit-object",
44
+ "-target",
45
+ &swift_target,
46
+ source.to_str().expect("UTF-8 source path"),
47
+ "-o",
48
+ object.to_str().expect("UTF-8 output path"),
49
+ ])
50
+ .status()
51
+ .expect("Xcode's swiftc is required to build the macOS OCR helper");
52
+ assert!(swift_status.success(), "failed to compile macOS OCR helper");
53
+
54
+ let archive_status = Command::new("ar")
55
+ .args([
56
+ "crus",
57
+ archive.to_str().expect("UTF-8 archive path"),
58
+ object.to_str().expect("UTF-8 object path"),
59
+ ])
60
+ .status()
61
+ .expect("ar is required to archive the macOS OCR helper");
62
+ assert!(
63
+ archive_status.success(),
64
+ "failed to archive macOS OCR helper"
65
+ );
66
+
67
+ println!("cargo:rustc-link-search=native={}", out_dir.display());
68
+ println!("cargo:rustc-link-lib=static=screen_memory_ocr_helper");
69
+ // The helper is written in Swift, while the rest of the native desktop
70
+ // stack already carries Swift runtime rpaths through ScreenCaptureKit.
71
+ for library in ["swiftCore", "swiftFoundation", "swift_Concurrency"] {
72
+ println!("cargo:rustc-link-lib=dylib={library}");
73
+ }
74
+ }
75
+
10
76
  fn emit_sentry_env_reruns() {
11
77
  for name in [
12
78
  "CLIPS_DESKTOP_SENTRY_DSN",
@@ -47,6 +113,8 @@ fn add_swift_runtime_rpaths() {
47
113
  // CMTime symbols we touch via raw `msg_send!` / `extern "C"`.
48
114
  println!("cargo:rustc-link-lib=framework=AVFoundation");
49
115
  println!("cargo:rustc-link-lib=framework=CoreMedia");
116
+ println!("cargo:rustc-link-lib=framework=Vision");
117
+ println!("cargo:rustc-link-lib=framework=CoreGraphics");
50
118
  println!("cargo:rustc-link-lib=framework=IOKit");
51
119
 
52
120
  // The screencapturekit crate builds a Swift bridge. Its build script adds
@@ -54,7 +54,120 @@
54
54
  //! - AXValue can also be a CFNumber (sliders) or CFURL — we only return
55
55
  //! a String when it's a CFString; otherwise "".
56
56
 
57
- use serde::Serialize;
57
+ use serde::{Deserialize, Serialize};
58
+
59
+ /// A deliberately small, retention-bound summary of the current accessibility
60
+ /// surface. This is evidence for local Rewind scenes, not a serialised AX tree.
61
+ #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
62
+ #[serde(rename_all = "camelCase")]
63
+ pub struct AccessibilityFingerprint {
64
+ #[serde(skip_serializing_if = "Option::is_none")]
65
+ pub document: Option<AccessibilitySemanticNode>,
66
+ #[serde(skip_serializing_if = "Option::is_none")]
67
+ pub focused: Option<AccessibilitySemanticNode>,
68
+ #[serde(default, skip_serializing_if = "Vec::is_empty")]
69
+ pub visible_labels: Vec<String>,
70
+ }
71
+
72
+ #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
73
+ #[serde(rename_all = "camelCase")]
74
+ pub struct AccessibilitySemanticNode {
75
+ pub role: String,
76
+ #[serde(skip_serializing_if = "Option::is_none")]
77
+ pub title: Option<String>,
78
+ #[serde(skip_serializing_if = "Option::is_none")]
79
+ pub description: Option<String>,
80
+ #[serde(skip_serializing_if = "Option::is_none")]
81
+ pub document: Option<String>,
82
+ #[serde(skip_serializing_if = "Option::is_none")]
83
+ pub url: Option<String>,
84
+ #[serde(skip_serializing_if = "Option::is_none")]
85
+ pub selected_text: Option<String>,
86
+ }
87
+
88
+ const MAX_AX_NODES: usize = 6;
89
+ const MAX_AX_DEPTH: usize = 1;
90
+ const MAX_AX_STRING_BYTES: usize = 160;
91
+ const MAX_AX_LABELS: usize = 8;
92
+ const MAX_AX_TOTAL_BYTES: usize = 1_200;
93
+
94
+ /// Keep text useful for scene continuity without retaining arbitrary field
95
+ /// contents. Password-like controls and credential-shaped values are omitted.
96
+ fn safe_ax_text(value: Option<String>, remaining: &mut usize) -> Option<String> {
97
+ let value = value?;
98
+ let value = value.split_whitespace().collect::<Vec<_>>().join(" ");
99
+ if value.is_empty() || looks_like_credential(&value) {
100
+ return None;
101
+ }
102
+ let limit = MAX_AX_STRING_BYTES.min(*remaining);
103
+ if limit == 0 {
104
+ return None;
105
+ }
106
+ let mut end = value.len().min(limit);
107
+ while end > 0 && !value.is_char_boundary(end) {
108
+ end -= 1;
109
+ }
110
+ let value = value[..end].to_string();
111
+ if value.is_empty() {
112
+ return None;
113
+ }
114
+ *remaining = remaining.saturating_sub(value.len());
115
+ Some(value)
116
+ }
117
+
118
+ fn looks_like_credential(value: &str) -> bool {
119
+ let lower = value.to_ascii_lowercase();
120
+ if [
121
+ "password",
122
+ "passcode",
123
+ "secret=",
124
+ "client secret",
125
+ "api key",
126
+ "api_key",
127
+ "access token",
128
+ "auth token",
129
+ "bearer ",
130
+ ]
131
+ .iter()
132
+ .any(|marker| lower.contains(marker))
133
+ {
134
+ return true;
135
+ }
136
+ let compact = value
137
+ .chars()
138
+ .filter(|c| !c.is_whitespace())
139
+ .collect::<String>();
140
+ let tokenish = compact.len() >= 32
141
+ && compact
142
+ .chars()
143
+ .all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '.'));
144
+ let jwtish = compact.matches('.').count() == 2 && compact.len() >= 32;
145
+ let private_key = value.contains("-----BEGIN") && value.contains("PRIVATE KEY-----");
146
+ tokenish || jwtish || private_key
147
+ }
148
+
149
+ fn role_is_secure(role: &str) -> bool {
150
+ let role = role.to_ascii_lowercase();
151
+ role.contains("secure") || role.contains("password")
152
+ }
153
+
154
+ fn safe_ax_url(url: String, remaining: &mut usize) -> Option<String> {
155
+ let url = url.split(['?', '#']).next()?.to_string();
156
+ // Never retain a URL that embeds credentials in its authority component.
157
+ let authority = url
158
+ .split_once("://")?
159
+ .1
160
+ .split('/')
161
+ .next()
162
+ .unwrap_or_default();
163
+ if authority.contains('@') {
164
+ return None;
165
+ }
166
+ if url.split('/').skip(3).any(looks_like_credential) {
167
+ return None;
168
+ }
169
+ safe_ax_text(Some(url), remaining)
170
+ }
58
171
 
59
172
  #[derive(Debug, Clone, Serialize)]
60
173
  #[serde(rename_all = "camelCase")]
@@ -122,9 +235,53 @@ pub async fn accessibility_request_permission() -> Result<bool, String> {
122
235
  }
123
236
  }
124
237
 
238
+ #[cfg(test)]
239
+ mod tests {
240
+ use super::*;
241
+
242
+ #[test]
243
+ fn ax_text_is_bounded_and_budgeted() {
244
+ let mut remaining = 12;
245
+ let value = safe_ax_text(Some("a very long accessible title".into()), &mut remaining)
246
+ .expect("safe title");
247
+ assert_eq!(value.len(), 12);
248
+ assert_eq!(remaining, 0);
249
+ assert!(safe_ax_text(Some("another title".into()), &mut remaining).is_none());
250
+ }
251
+
252
+ #[test]
253
+ fn credential_shaped_text_is_not_retained() {
254
+ let mut remaining = MAX_AX_TOTAL_BYTES;
255
+ assert!(safe_ax_text(Some("password: hunter2".into()), &mut remaining).is_none());
256
+ assert!(
257
+ safe_ax_text(Some("API key: short-but-sensitive".into()), &mut remaining).is_none()
258
+ );
259
+ assert!(safe_ax_text(
260
+ Some("eyJhbGciOiJIUzI1NiJ9.abcdefghijklmnopqrstuvwxyz.0123456789abcdef".into()),
261
+ &mut remaining
262
+ )
263
+ .is_none());
264
+ assert!(role_is_secure("AXSecureTextField"));
265
+ assert!(safe_ax_url(
266
+ "https://alice:secret@example.test/document".into(),
267
+ &mut remaining
268
+ )
269
+ .is_none());
270
+ assert!(safe_ax_url(
271
+ "https://example.test/session/abcdefghijklmnopqrstuvwxyz123456".into(),
272
+ &mut remaining
273
+ )
274
+ .is_none());
275
+ }
276
+ }
277
+
125
278
  #[cfg(target_os = "macos")]
126
279
  pub(crate) mod macos {
127
- use super::ActiveWindowContext;
280
+ use super::{
281
+ role_is_secure, safe_ax_text, safe_ax_url, AccessibilityFingerprint,
282
+ AccessibilitySemanticNode, ActiveWindowContext, MAX_AX_DEPTH, MAX_AX_LABELS, MAX_AX_NODES,
283
+ MAX_AX_TOTAL_BYTES,
284
+ };
128
285
  use core_foundation::array::CFArray;
129
286
  use core_foundation::base::{CFType, TCFType};
130
287
  use core_foundation::dictionary::CFDictionary;
@@ -140,6 +297,7 @@ pub(crate) mod macos {
140
297
  type CFTypeRef = *const c_void;
141
298
  type CFStringRef = *const c_void;
142
299
  type CFDictionaryRef = *const c_void;
300
+ type CFArrayRef = *const c_void;
143
301
  type CFBooleanRef = *const c_void;
144
302
  type CFAllocatorRef = *const c_void;
145
303
  type CFIndex = isize;
@@ -166,6 +324,7 @@ pub(crate) mod macos {
166
324
  extern "C" {
167
325
  fn AXIsProcessTrustedWithOptions(options: CFDictionaryRef) -> Boolean;
168
326
  fn AXUIElementCreateSystemWide() -> AXUIElementRef;
327
+ fn AXUIElementCreateApplication(pid: i32) -> AXUIElementRef;
169
328
  fn AXUIElementCopyAttributeValue(
170
329
  element: AXUIElementRef,
171
330
  attribute: CFStringRef,
@@ -194,6 +353,10 @@ pub(crate) mod macos {
194
353
  fn CFRelease(cf: CFTypeRef);
195
354
  fn CFGetTypeID(cf: CFTypeRef) -> CFTypeID;
196
355
  fn CFStringGetTypeID() -> CFTypeID;
356
+ fn CFURLGetTypeID() -> CFTypeID;
357
+ fn CFArrayGetCount(array: CFArrayRef) -> CFIndex;
358
+ fn CFArrayGetValueAtIndex(array: CFArrayRef, index: CFIndex) -> *const c_void;
359
+ fn CFURLGetString(url: CFTypeRef) -> CFStringRef;
197
360
  fn CFDictionaryCreate(
198
361
  allocator: CFAllocatorRef,
199
362
  keys: *const *const c_void,
@@ -341,25 +504,214 @@ pub(crate) mod macos {
341
504
  }
342
505
  }
343
506
 
507
+ /// Collect the semantic surface from the same application selected by the
508
+ /// Core Graphics foreground-window sample. This avoids combining an
509
+ /// ignored Clips utility window's AX tree with the underlying work app.
510
+ pub fn semantic_fingerprint_for_pid_impl(pid: i32) -> Option<AccessibilityFingerprint> {
511
+ unsafe {
512
+ if !is_trusted(false) {
513
+ return None;
514
+ }
515
+ let application = AXUIElementCreateApplication(pid);
516
+ if application.is_null() {
517
+ return None;
518
+ }
519
+ let window = copy_ax_element_attribute(application, "AXFocusedWindow");
520
+ let focused = copy_ax_element_attribute(application, "AXFocusedUIElement");
521
+ CFRelease(application as CFTypeRef);
522
+
523
+ let document_element = window.or(focused)?;
524
+ let mut remaining = MAX_AX_TOTAL_BYTES;
525
+ let document = semantic_node(document_element, false, &mut remaining);
526
+ let focused_node =
527
+ focused.and_then(|element| semantic_node(element, true, &mut remaining));
528
+ let mut labels = Vec::new();
529
+ collect_visible_labels(document_element, 0, &mut labels, &mut remaining);
530
+
531
+ if let Some(window) = window {
532
+ CFRelease(window);
533
+ }
534
+ if let Some(focused) = focused {
535
+ CFRelease(focused);
536
+ }
537
+
538
+ if document.is_none() && focused_node.is_none() && labels.is_empty() {
539
+ None
540
+ } else {
541
+ Some(AccessibilityFingerprint {
542
+ document,
543
+ focused: focused_node,
544
+ visible_labels: labels,
545
+ })
546
+ }
547
+ }
548
+ }
549
+
550
+ unsafe fn copy_ax_element_attribute(
551
+ element: AXUIElementRef,
552
+ attribute: &str,
553
+ ) -> Option<AXUIElementRef> {
554
+ let attribute = cfstr(attribute);
555
+ if attribute.is_null() {
556
+ return None;
557
+ }
558
+ let mut value = ptr::null();
559
+ let error = AXUIElementCopyAttributeValue(element, attribute, &mut value);
560
+ CFRelease(attribute as CFTypeRef);
561
+ (error == AX_ERROR_SUCCESS && !value.is_null()).then_some(value as AXUIElementRef)
562
+ }
563
+
564
+ unsafe fn ax_string_attribute(element: AXUIElementRef, attribute: &str) -> Option<String> {
565
+ let attribute = cfstr(attribute);
566
+ if attribute.is_null() {
567
+ return None;
568
+ }
569
+ let mut raw_value = ptr::null();
570
+ let error = AXUIElementCopyAttributeValue(element, attribute, &mut raw_value);
571
+ CFRelease(attribute as CFTypeRef);
572
+ if error != AX_ERROR_SUCCESS || raw_value.is_null() {
573
+ return None;
574
+ }
575
+ let value = if CFGetTypeID(raw_value) == CFStringGetTypeID() {
576
+ cfstring_to_string(raw_value as CFStringRef)
577
+ } else {
578
+ None
579
+ };
580
+ CFRelease(raw_value);
581
+ value
582
+ }
583
+
584
+ unsafe fn ax_url_attribute(element: AXUIElementRef) -> Option<String> {
585
+ let attribute = cfstr("AXURL");
586
+ if attribute.is_null() {
587
+ return None;
588
+ }
589
+ let mut value = ptr::null();
590
+ let error = AXUIElementCopyAttributeValue(element, attribute, &mut value);
591
+ CFRelease(attribute as CFTypeRef);
592
+ if error != AX_ERROR_SUCCESS || value.is_null() {
593
+ return None;
594
+ }
595
+ // URLs may contain query-string credentials. Keep only the stable
596
+ // origin/path portion, and omit anything that cannot be read safely.
597
+ let string = if CFGetTypeID(value) == CFStringGetTypeID() {
598
+ value as CFStringRef
599
+ } else if CFGetTypeID(value) == CFURLGetTypeID() {
600
+ CFURLGetString(value)
601
+ } else {
602
+ ptr::null()
603
+ };
604
+ let result = cfstring_to_string(string)
605
+ .and_then(|url| url.split(['?', '#']).next().map(str::to_string));
606
+ CFRelease(value);
607
+ result
608
+ }
609
+
610
+ unsafe fn semantic_node(
611
+ element: AXUIElementRef,
612
+ focused: bool,
613
+ remaining: &mut usize,
614
+ ) -> Option<AccessibilitySemanticNode> {
615
+ let role = ax_string_attribute(element, "AXRole")?;
616
+ if role_is_secure(&role) {
617
+ return None;
618
+ }
619
+ let role = safe_ax_text(Some(role), remaining)?;
620
+ let title = safe_ax_text(ax_string_attribute(element, "AXTitle"), remaining);
621
+ let description = safe_ax_text(ax_string_attribute(element, "AXDescription"), remaining);
622
+ let document = safe_ax_text(ax_string_attribute(element, "AXDocument"), remaining);
623
+ let url = ax_url_attribute(element).and_then(|url| safe_ax_url(url, remaining));
624
+ let selected_text = focused
625
+ .then(|| ax_string_attribute(element, "AXSelectedText"))
626
+ .flatten()
627
+ .and_then(|text| safe_ax_text(Some(text), remaining));
628
+ Some(AccessibilitySemanticNode {
629
+ role,
630
+ title,
631
+ description,
632
+ document,
633
+ url,
634
+ selected_text,
635
+ })
636
+ }
637
+
638
+ unsafe fn collect_visible_labels(
639
+ element: AXUIElementRef,
640
+ depth: usize,
641
+ labels: &mut Vec<String>,
642
+ remaining: &mut usize,
643
+ ) {
644
+ if depth >= MAX_AX_DEPTH || labels.len() >= MAX_AX_LABELS || *remaining == 0 {
645
+ return;
646
+ }
647
+ let attribute = cfstr("AXChildren");
648
+ if attribute.is_null() {
649
+ return;
650
+ }
651
+ let mut children = ptr::null();
652
+ let error = AXUIElementCopyAttributeValue(element, attribute, &mut children);
653
+ CFRelease(attribute as CFTypeRef);
654
+ if error != AX_ERROR_SUCCESS || children.is_null() {
655
+ return;
656
+ }
657
+ let count = (CFArrayGetCount(children) as usize).min(MAX_AX_NODES);
658
+ for index in 0..count {
659
+ if labels.len() >= MAX_AX_LABELS || *remaining == 0 {
660
+ break;
661
+ }
662
+ let child = CFArrayGetValueAtIndex(children, index as CFIndex) as AXUIElementRef;
663
+ if child.is_null() {
664
+ continue;
665
+ }
666
+ let role = ax_string_attribute(child, "AXRole");
667
+ if role.as_deref().is_some_and(role_is_secure) {
668
+ continue;
669
+ }
670
+ let label = ax_string_attribute(child, "AXTitle")
671
+ .or_else(|| ax_string_attribute(child, "AXDescription"))
672
+ .and_then(|text| safe_ax_text(Some(text), remaining));
673
+ if let Some(label) = label {
674
+ labels.push(label);
675
+ }
676
+ }
677
+ CFRelease(children);
678
+ }
679
+
344
680
  pub fn active_window_context_impl() -> ActiveWindowContext {
681
+ active_window_context_with_pid_impl().0
682
+ }
683
+
684
+ pub(crate) fn active_window_context_with_pid_impl() -> (ActiveWindowContext, Option<i32>) {
345
685
  let window = active_window_from_core_graphics();
346
686
  let frontmost = frontmost_application();
347
-
348
- ActiveWindowContext {
349
- app_name: window
350
- .as_ref()
351
- .and_then(|w| w.app_name.clone())
352
- .or_else(|| frontmost.as_ref().and_then(|app| app.app_name.clone())),
353
- window_title: window.and_then(|w| w.window_title),
354
- bundle_id: frontmost.and_then(|app| app.bundle_id),
355
- source: "core-graphics".to_string(),
356
- }
687
+ let owner = window
688
+ .as_ref()
689
+ .and_then(|window| window.owner_pid)
690
+ .and_then(application_for_pid);
691
+ let owner_pid = window.as_ref().and_then(|window| window.owner_pid);
692
+
693
+ (
694
+ ActiveWindowContext {
695
+ app_name: window
696
+ .as_ref()
697
+ .and_then(|w| w.app_name.clone())
698
+ .or_else(|| owner.as_ref().and_then(|app| app.app_name.clone()))
699
+ .or_else(|| frontmost.as_ref().and_then(|app| app.app_name.clone())),
700
+ window_title: window.and_then(|w| w.window_title),
701
+ bundle_id: owner
702
+ .and_then(|app| app.bundle_id)
703
+ .or_else(|| frontmost.and_then(|app| app.bundle_id)),
704
+ source: "core-graphics".to_string(),
705
+ },
706
+ owner_pid,
707
+ )
357
708
  }
358
709
 
359
710
  #[derive(Debug, Clone)]
360
711
  struct WindowInfo {
361
712
  app_name: Option<String>,
362
713
  window_title: Option<String>,
714
+ owner_pid: Option<i32>,
363
715
  }
364
716
 
365
717
  #[derive(Debug, Clone)]
@@ -400,6 +752,8 @@ pub(crate) mod macos {
400
752
  return Some(WindowInfo {
401
753
  app_name: owner,
402
754
  window_title: title,
755
+ owner_pid: dict_number_i64(&window, "kCGWindowOwnerPID")
756
+ .and_then(|pid| i32::try_from(pid).ok()),
403
757
  });
404
758
  }
405
759
  }
@@ -430,6 +784,26 @@ pub(crate) mod macos {
430
784
  }
431
785
  }
432
786
 
787
+ fn application_for_pid(pid: i32) -> Option<AppInfo> {
788
+ use objc2::msg_send;
789
+ use objc2::runtime::{AnyClass, AnyObject};
790
+
791
+ unsafe {
792
+ let class_name = std::ffi::CString::new("NSRunningApplication").ok()?;
793
+ let cls: &AnyClass = AnyClass::get(&class_name)?;
794
+ let app: *mut AnyObject = msg_send![cls, runningApplicationWithProcessIdentifier: pid];
795
+ if app.is_null() {
796
+ return None;
797
+ }
798
+ let app_name: *mut AnyObject = msg_send![app, localizedName];
799
+ let bundle_id: *mut AnyObject = msg_send![app, bundleIdentifier];
800
+ Some(AppInfo {
801
+ app_name: ns_string_to_owned(app_name),
802
+ bundle_id: ns_string_to_owned(bundle_id),
803
+ })
804
+ }
805
+ }
806
+
433
807
  fn dict_string(dict: &CFDictionary<CFString, CFType>, key: &'static str) -> Option<String> {
434
808
  let key = CFString::from_static_string(key);
435
809
  let value = dict.find(&key)?;