@forsion/tangu-computer-use 0.5.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.
- package/CHANGELOG.md +223 -0
- package/LICENSE +26 -0
- package/LICENSE.upstream +21 -0
- package/README.md +169 -0
- package/UPSTREAM.md +277 -0
- package/check.mjs +369 -0
- package/icon.png +0 -0
- package/install.sh +112 -0
- package/main.js +541 -0
- package/manifest.json +47 -0
- package/native/linux/bridge-rs/Cargo.lock +1204 -0
- package/native/linux/bridge-rs/Cargo.toml +18 -0
- package/native/linux/bridge-rs/src/atspi.rs +640 -0
- package/native/linux/bridge-rs/src/error.rs +65 -0
- package/native/linux/bridge-rs/src/lib.rs +11 -0
- package/native/linux/bridge-rs/src/main.rs +1074 -0
- package/native/linux/bridge-rs/src/protocol.rs +49 -0
- package/native/linux/bridge-rs/src/state.rs +293 -0
- package/native/linux/bridge-rs/src/wayland.rs +89 -0
- package/native/linux/bridge-rs/src/x11.rs +909 -0
- package/native/linux/bridge-rs/tests/protocol_tests.rs +73 -0
- package/native/macos/agent_cursor.swift +184 -0
- package/native/macos/agent_cursor_motion.swift +252 -0
- package/native/macos/agent_cursor_tests.swift +128 -0
- package/native/macos/agent_highlight.swift +229 -0
- package/native/macos/agent_highlight_tests.swift +99 -0
- package/native/macos/bridge.swift +3852 -0
- package/native/macos/foreground_activity.swift +57 -0
- package/native/macos/foreground_activity_tests.swift +31 -0
- package/native/macos/live_stream.swift +289 -0
- package/native/windows/bridge-rs/Cargo.lock +396 -0
- package/native/windows/bridge-rs/Cargo.toml +30 -0
- package/native/windows/bridge-rs/src/capture.rs +518 -0
- package/native/windows/bridge-rs/src/error.rs +81 -0
- package/native/windows/bridge-rs/src/input.rs +501 -0
- package/native/windows/bridge-rs/src/lib.rs +16 -0
- package/native/windows/bridge-rs/src/main.rs +1683 -0
- package/native/windows/bridge-rs/src/protocol.rs +53 -0
- package/native/windows/bridge-rs/src/refs.rs +237 -0
- package/native/windows/bridge-rs/src/state.rs +55 -0
- package/native/windows/bridge-rs/src/uia.rs +1252 -0
- package/native/windows/bridge-rs/src/window.rs +718 -0
- package/native/windows/bridge-rs/tests/protocol_tests.rs +252 -0
- package/native/windows/bridge-rs/tests/refs_tests.rs +86 -0
- package/native/windows/bridge-rs/tests/state_tests.rs +33 -0
- package/package.json +74 -0
- package/prebuilt/linux/arm64/linux-bridge +0 -0
- package/prebuilt/linux/x64/linux-bridge +0 -0
- package/prebuilt/macos/arm64/bridge +0 -0
- package/prebuilt/macos/arm64/tangu-computer-use.app.json +10 -0
- package/prebuilt/macos/arm64/tangu-computer-use.app.zip +0 -0
- package/prebuilt/macos/x64/bridge +0 -0
- package/prebuilt/macos/x64/tangu-computer-use.app.json +10 -0
- package/prebuilt/macos/x64/tangu-computer-use.app.zip +0 -0
- package/prebuilt/windows/windows-bridge.exe +0 -0
- package/scripts/blind-click.check.mjs +128 -0
- package/scripts/build-native.mjs +302 -0
- package/scripts/build.mjs +84 -0
- package/scripts/calc-fixture.mjs +78 -0
- package/scripts/cli-exit.check.mjs +49 -0
- package/scripts/helper-path.check.mjs +82 -0
- package/scripts/helper-refresh.check.mjs +62 -0
- package/scripts/helper-signal.check.mjs +59 -0
- package/scripts/highlight.check.mjs +32 -0
- package/scripts/keychain-free-install.check.mjs +140 -0
- package/scripts/live-view.check.mjs +124 -0
- package/scripts/macos-bundle.d.mts +1 -0
- package/scripts/macos-bundle.mjs +119 -0
- package/scripts/make-signing-cert.sh +57 -0
- package/scripts/mini-foreground.check.mjs +11 -0
- package/scripts/no-foreground.check.mjs +81 -0
- package/scripts/overlay-visible.check.mjs +221 -0
- package/scripts/package-macos-app.mjs +39 -0
- package/scripts/permissions.check.mjs +199 -0
- package/scripts/platform-contract.check.mjs +30 -0
- package/scripts/setup-helper.mjs +154 -0
- package/scripts/tangu-computer-use.entitlements +10 -0
- package/scripts/verify-macos-bundles.mjs +25 -0
- package/scripts/verify-package.mjs +74 -0
- package/skills/computer-use/SKILL.md +110 -0
- package/src/foregroundNote.ts +43 -0
- package/src/helperState.ts +31 -0
- package/src/index.ts +63 -0
- package/src/onboarding.ts +198 -0
- package/src/pi-compat.ts +49 -0
- package/src/settings.ts +11 -0
- package/src/setup.ts +97 -0
- package/src/tools.ts +289 -0
- package/src/vendor/actions.ts +130 -0
- package/src/vendor/bridge.ts +2405 -0
- package/src/vendor/cdp.ts +658 -0
- package/src/vendor/config.ts +113 -0
- package/src/vendor/contract.ts +104 -0
- package/src/vendor/note.ts +195 -0
- package/src/vendor/outline.ts +651 -0
- package/src/vendor/output.ts +134 -0
- package/src/vendor/permissions.ts +111 -0
- package/src/vendor/platform/architecture.ts +23 -0
- package/src/vendor/platform/coerce.ts +16 -0
- package/src/vendor/platform/index.ts +59 -0
- package/src/vendor/platform/linux/backend.ts +186 -0
- package/src/vendor/platform/linux/helper.ts +238 -0
- package/src/vendor/platform/macos/backend.ts +131 -0
- package/src/vendor/platform/macos/browser.ts +110 -0
- package/src/vendor/platform/macos/helper-path.d.mts +9 -0
- package/src/vendor/platform/macos/helper-path.mjs +34 -0
- package/src/vendor/platform/macos/helper.ts +291 -0
- package/src/vendor/platform/macos/permissions.ts +146 -0
- package/src/vendor/platform/types.ts +222 -0
- package/src/vendor/platform/windows/backend.ts +142 -0
- package/src/vendor/platform/windows/helper.ts +140 -0
- package/src/vendor/root-selection.ts +25 -0
- package/src/vendor/runtime.ts +129 -0
- package/src/vendor/state.ts +146 -0
- package/src/vendor/view.ts +147 -0
- package/tangu-plugins/computer-use/dist/foregroundNote.js +19 -0
- package/tangu-plugins/computer-use/dist/index.js +5672 -0
- package/tangu-plugins/computer-use/tangu-plugin.json +9 -0
- package/tsconfig.json +22 -0
- package/types/tangu-agent.d.ts +142 -0
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
[package]
|
|
2
|
+
name = "linux-bridge"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
edition = "2021"
|
|
5
|
+
description = "AT-SPI native helper for tangu-computer-use on Linux"
|
|
6
|
+
|
|
7
|
+
[dependencies]
|
|
8
|
+
base64 = "0.22"
|
|
9
|
+
png = "0.17"
|
|
10
|
+
serde = { version = "1", features = ["derive"] }
|
|
11
|
+
serde_json = "1"
|
|
12
|
+
tokio = { version = "1", features = ["io-std", "io-util", "macros", "rt-multi-thread", "sync", "time"] }
|
|
13
|
+
x11rb = { version = "0.13", features = ["composite", "xtest"] }
|
|
14
|
+
zbus = { version = "4", default-features = false, features = ["tokio"] }
|
|
15
|
+
|
|
16
|
+
[lib]
|
|
17
|
+
name = "linux_bridge"
|
|
18
|
+
path = "src/lib.rs"
|
|
@@ -0,0 +1,640 @@
|
|
|
1
|
+
//! Minimal, dependency-light AT-SPI2 client built on zbus.
|
|
2
|
+
//!
|
|
3
|
+
//! Linux desktop accessibility is intentionally used in preference to global
|
|
4
|
+
//! pointer/keyboard injection: Action and EditableText calls are delivered to
|
|
5
|
+
//! applications in the background and do not steal focus.
|
|
6
|
+
|
|
7
|
+
use std::collections::{HashMap, HashSet, VecDeque};
|
|
8
|
+
|
|
9
|
+
use serde_json::{json, Value};
|
|
10
|
+
use zbus::zvariant::OwnedObjectPath;
|
|
11
|
+
use zbus::{Connection, Proxy};
|
|
12
|
+
|
|
13
|
+
use crate::{ErrorCode, ProtocolError};
|
|
14
|
+
|
|
15
|
+
const DBUS_DESTINATION: &str = "org.freedesktop.DBus";
|
|
16
|
+
const DBUS_ROOT: &str = "/org/freedesktop/DBus";
|
|
17
|
+
const DBUS_INTERFACE: &str = "org.freedesktop.DBus";
|
|
18
|
+
const REGISTRY_DESTINATION: &str = "org.a11y.atspi.Registry";
|
|
19
|
+
const REGISTRY_ROOT: &str = "/org/a11y/atspi/accessible/root";
|
|
20
|
+
const ACCESSIBLE: &str = "org.a11y.atspi.Accessible";
|
|
21
|
+
const COMPONENT: &str = "org.a11y.atspi.Component";
|
|
22
|
+
const ACTION: &str = "org.a11y.atspi.Action";
|
|
23
|
+
const MAX_ATSPI_ACTIONS: usize = 64;
|
|
24
|
+
const EDITABLE_TEXT: &str = "org.a11y.atspi.EditableText";
|
|
25
|
+
const TEXT: &str = "org.a11y.atspi.Text";
|
|
26
|
+
|
|
27
|
+
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
|
28
|
+
pub struct AccessibleRef {
|
|
29
|
+
pub destination: String,
|
|
30
|
+
pub path: String,
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
#[derive(Debug, Clone, PartialEq)]
|
|
34
|
+
pub struct Rect {
|
|
35
|
+
pub x: i32,
|
|
36
|
+
pub y: i32,
|
|
37
|
+
pub width: i32,
|
|
38
|
+
pub height: i32,
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
#[derive(Debug, Clone)]
|
|
42
|
+
pub struct RootSnapshot {
|
|
43
|
+
pub accessible: AccessibleRef,
|
|
44
|
+
pub pid: u64,
|
|
45
|
+
pub name: String,
|
|
46
|
+
pub app_name: String,
|
|
47
|
+
pub role: String,
|
|
48
|
+
pub frame: Option<Rect>,
|
|
49
|
+
pub x11_window: Option<u32>,
|
|
50
|
+
pub is_focused: bool,
|
|
51
|
+
pub is_minimized: bool,
|
|
52
|
+
pub z_order: Option<usize>,
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
#[derive(Debug, Clone)]
|
|
56
|
+
pub struct NodeSnapshot {
|
|
57
|
+
pub reference: String,
|
|
58
|
+
pub accessible: AccessibleRef,
|
|
59
|
+
pub parent: Option<AccessibleRef>,
|
|
60
|
+
pub name: String,
|
|
61
|
+
pub role: String,
|
|
62
|
+
pub description: String,
|
|
63
|
+
pub value: String,
|
|
64
|
+
pub interfaces: HashSet<String>,
|
|
65
|
+
pub bounds: Option<Rect>,
|
|
66
|
+
pub is_secure: bool,
|
|
67
|
+
pub depth: usize,
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
impl NodeSnapshot {
|
|
71
|
+
#[cfg(test)]
|
|
72
|
+
pub(crate) fn minimal(accessible: AccessibleRef) -> Self {
|
|
73
|
+
Self {
|
|
74
|
+
reference: String::new(),
|
|
75
|
+
accessible,
|
|
76
|
+
parent: None,
|
|
77
|
+
name: String::new(),
|
|
78
|
+
role: String::new(),
|
|
79
|
+
description: String::new(),
|
|
80
|
+
value: String::new(),
|
|
81
|
+
interfaces: HashSet::new(),
|
|
82
|
+
bounds: None,
|
|
83
|
+
is_secure: false,
|
|
84
|
+
depth: 0,
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
pub fn searchable_text(&self) -> String {
|
|
89
|
+
format!("{} {} {}", self.name, self.description, self.value).to_lowercase()
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
#[derive(Clone)]
|
|
94
|
+
pub struct AtspiClient {
|
|
95
|
+
connection: Connection,
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
impl AtspiClient {
|
|
99
|
+
pub async fn connect() -> Result<Self, ProtocolError> {
|
|
100
|
+
let session = Connection::session().await.map_err(unavailable)?;
|
|
101
|
+
let bus = Proxy::new(&session, "org.a11y.Bus", "/org/a11y/bus", "org.a11y.Bus")
|
|
102
|
+
.await
|
|
103
|
+
.map_err(unavailable)?;
|
|
104
|
+
let address: String = bus.call("GetAddress", &()).await.map_err(unavailable)?;
|
|
105
|
+
let connection = zbus::connection::Builder::address(address.as_str())
|
|
106
|
+
.map_err(unavailable)?
|
|
107
|
+
.build()
|
|
108
|
+
.await
|
|
109
|
+
.map_err(unavailable)?;
|
|
110
|
+
Ok(Self { connection })
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
pub async fn available() -> bool {
|
|
114
|
+
Self::connect().await.is_ok()
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
async fn proxy<'a>(
|
|
118
|
+
&'a self,
|
|
119
|
+
accessible: &'a AccessibleRef,
|
|
120
|
+
interface: &'a str,
|
|
121
|
+
) -> Result<Proxy<'a>, ProtocolError> {
|
|
122
|
+
Proxy::new(
|
|
123
|
+
&self.connection,
|
|
124
|
+
accessible.destination.as_str(),
|
|
125
|
+
accessible.path.as_str(),
|
|
126
|
+
interface,
|
|
127
|
+
)
|
|
128
|
+
.await
|
|
129
|
+
.map_err(atspi_error)
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
async fn children(
|
|
133
|
+
&self,
|
|
134
|
+
accessible: &AccessibleRef,
|
|
135
|
+
) -> Result<Vec<AccessibleRef>, ProtocolError> {
|
|
136
|
+
let proxy = self.proxy(accessible, ACCESSIBLE).await?;
|
|
137
|
+
let children: Vec<(String, OwnedObjectPath)> =
|
|
138
|
+
proxy.call("GetChildren", &()).await.map_err(atspi_error)?;
|
|
139
|
+
Ok(children
|
|
140
|
+
.into_iter()
|
|
141
|
+
.filter(|(destination, path)| !destination.is_empty() && path.as_str() != "/")
|
|
142
|
+
.map(|(destination, path)| AccessibleRef {
|
|
143
|
+
destination,
|
|
144
|
+
path: path.to_string(),
|
|
145
|
+
})
|
|
146
|
+
.collect())
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
async fn string_property(&self, accessible: &AccessibleRef, property: &str) -> String {
|
|
150
|
+
match self.proxy(accessible, ACCESSIBLE).await {
|
|
151
|
+
Ok(proxy) => proxy.get_property(property).await.unwrap_or_default(),
|
|
152
|
+
Err(_) => String::new(),
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
async fn role(&self, accessible: &AccessibleRef) -> String {
|
|
157
|
+
match self.proxy(accessible, ACCESSIBLE).await {
|
|
158
|
+
Ok(proxy) => proxy.call("GetRoleName", &()).await.unwrap_or_default(),
|
|
159
|
+
Err(_) => String::new(),
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
async fn interfaces(&self, accessible: &AccessibleRef) -> HashSet<String> {
|
|
164
|
+
match self.proxy(accessible, ACCESSIBLE).await {
|
|
165
|
+
Ok(proxy) => proxy
|
|
166
|
+
.call::<_, _, Vec<String>>("GetInterfaces", &())
|
|
167
|
+
.await
|
|
168
|
+
.unwrap_or_default()
|
|
169
|
+
.into_iter()
|
|
170
|
+
.collect(),
|
|
171
|
+
Err(_) => HashSet::new(),
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
async fn bounds(&self, accessible: &AccessibleRef) -> Option<Rect> {
|
|
176
|
+
let proxy = self.proxy(accessible, COMPONENT).await.ok()?;
|
|
177
|
+
let (x, y, width, height): (i32, i32, i32, i32) =
|
|
178
|
+
proxy.call("GetExtents", &(0u32)).await.ok()?;
|
|
179
|
+
(width > 0 && height > 0).then_some(Rect {
|
|
180
|
+
x,
|
|
181
|
+
y,
|
|
182
|
+
width,
|
|
183
|
+
height,
|
|
184
|
+
})
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
async fn application_pid(&self, accessible: &AccessibleRef) -> u64 {
|
|
188
|
+
let application_id = match self.proxy(accessible, "org.a11y.atspi.Application").await {
|
|
189
|
+
Ok(proxy) => proxy.get_property::<i32>("Id").await.unwrap_or(0),
|
|
190
|
+
Err(_) => 0,
|
|
191
|
+
};
|
|
192
|
+
if let Some(pid) = positive_application_pid(application_id) {
|
|
193
|
+
return pid;
|
|
194
|
+
}
|
|
195
|
+
let Ok(bus) = Proxy::new(
|
|
196
|
+
&self.connection,
|
|
197
|
+
DBUS_DESTINATION,
|
|
198
|
+
DBUS_ROOT,
|
|
199
|
+
DBUS_INTERFACE,
|
|
200
|
+
)
|
|
201
|
+
.await
|
|
202
|
+
else {
|
|
203
|
+
return 0;
|
|
204
|
+
};
|
|
205
|
+
let connection_pid: Option<u32> = bus
|
|
206
|
+
.call(
|
|
207
|
+
"GetConnectionUnixProcessID",
|
|
208
|
+
&(accessible.destination.as_str()),
|
|
209
|
+
)
|
|
210
|
+
.await
|
|
211
|
+
.ok();
|
|
212
|
+
fallback_application_pid(application_id, connection_pid)
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
pub async fn list_roots(
|
|
216
|
+
&self,
|
|
217
|
+
filter_pid: Option<u64>,
|
|
218
|
+
) -> Result<Vec<RootSnapshot>, ProtocolError> {
|
|
219
|
+
let registry = AccessibleRef {
|
|
220
|
+
destination: REGISTRY_DESTINATION.to_owned(),
|
|
221
|
+
path: REGISTRY_ROOT.to_owned(),
|
|
222
|
+
};
|
|
223
|
+
let applications = self.children(®istry).await?;
|
|
224
|
+
let mut roots = Vec::new();
|
|
225
|
+
for app in applications {
|
|
226
|
+
let pid = self.application_pid(&app).await;
|
|
227
|
+
if filter_pid.is_some_and(|wanted| wanted != pid) {
|
|
228
|
+
continue;
|
|
229
|
+
}
|
|
230
|
+
let app_name = self.string_property(&app, "Name").await;
|
|
231
|
+
let windows = self.children(&app).await.unwrap_or_default();
|
|
232
|
+
for window in windows {
|
|
233
|
+
let role = self.role(&window).await;
|
|
234
|
+
let name = self.string_property(&window, "Name").await;
|
|
235
|
+
roots.push(RootSnapshot {
|
|
236
|
+
frame: self.bounds(&window).await,
|
|
237
|
+
accessible: window,
|
|
238
|
+
pid,
|
|
239
|
+
name: if name.is_empty() {
|
|
240
|
+
app_name.clone()
|
|
241
|
+
} else {
|
|
242
|
+
name
|
|
243
|
+
},
|
|
244
|
+
app_name: app_name.clone(),
|
|
245
|
+
role,
|
|
246
|
+
x11_window: None,
|
|
247
|
+
is_focused: false,
|
|
248
|
+
is_minimized: false,
|
|
249
|
+
z_order: None,
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
Ok(roots)
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
pub async fn snapshot(
|
|
257
|
+
&self,
|
|
258
|
+
root: &RootSnapshot,
|
|
259
|
+
max_nodes: usize,
|
|
260
|
+
max_depth: usize,
|
|
261
|
+
) -> Result<Vec<NodeSnapshot>, ProtocolError> {
|
|
262
|
+
self.snapshot_from(&root.accessible, max_nodes, max_depth)
|
|
263
|
+
.await
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
pub async fn snapshot_from(
|
|
267
|
+
&self,
|
|
268
|
+
start: &AccessibleRef,
|
|
269
|
+
max_nodes: usize,
|
|
270
|
+
max_depth: usize,
|
|
271
|
+
) -> Result<Vec<NodeSnapshot>, ProtocolError> {
|
|
272
|
+
let mut result = Vec::new();
|
|
273
|
+
let mut seen = HashSet::new();
|
|
274
|
+
let mut queue = VecDeque::from([(start.clone(), None, 0usize)]);
|
|
275
|
+
while let Some((accessible, parent, depth)) = queue.pop_front() {
|
|
276
|
+
if result.len() >= max_nodes || depth > max_depth || !seen.insert(accessible.clone()) {
|
|
277
|
+
continue;
|
|
278
|
+
}
|
|
279
|
+
let interfaces = self.interfaces(&accessible).await;
|
|
280
|
+
let role = self.role(&accessible).await;
|
|
281
|
+
let name = self.string_property(&accessible, "Name").await;
|
|
282
|
+
let description = self.string_property(&accessible, "Description").await;
|
|
283
|
+
let is_secure =
|
|
284
|
+
role.eq_ignore_ascii_case("password text") || role.eq_ignore_ascii_case("password");
|
|
285
|
+
let value = if !is_secure && interfaces.contains(TEXT) {
|
|
286
|
+
self.read_text(&accessible).await.unwrap_or_default()
|
|
287
|
+
} else {
|
|
288
|
+
String::new()
|
|
289
|
+
};
|
|
290
|
+
let children = self.children(&accessible).await.unwrap_or_default();
|
|
291
|
+
for child in children {
|
|
292
|
+
queue.push_back((child, Some(accessible.clone()), depth + 1));
|
|
293
|
+
}
|
|
294
|
+
result.push(NodeSnapshot {
|
|
295
|
+
reference: String::new(),
|
|
296
|
+
bounds: self.bounds(&accessible).await,
|
|
297
|
+
accessible,
|
|
298
|
+
parent,
|
|
299
|
+
name,
|
|
300
|
+
role,
|
|
301
|
+
description,
|
|
302
|
+
value,
|
|
303
|
+
interfaces,
|
|
304
|
+
is_secure,
|
|
305
|
+
depth,
|
|
306
|
+
});
|
|
307
|
+
}
|
|
308
|
+
Ok(result)
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
pub async fn read_text(&self, accessible: &AccessibleRef) -> Result<String, ProtocolError> {
|
|
312
|
+
let proxy = self.proxy(accessible, TEXT).await?;
|
|
313
|
+
proxy
|
|
314
|
+
.call("GetText", &(0i32, -1i32))
|
|
315
|
+
.await
|
|
316
|
+
.map_err(atspi_error)
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
pub async fn press(&self, node: &NodeSnapshot) -> Result<bool, ProtocolError> {
|
|
320
|
+
if !node.interfaces.contains(ACTION) {
|
|
321
|
+
return Err(ProtocolError::new(
|
|
322
|
+
"Target does not expose the AT-SPI Action interface",
|
|
323
|
+
ErrorCode::CapabilityDeferred,
|
|
324
|
+
));
|
|
325
|
+
}
|
|
326
|
+
let proxy = self.proxy(&node.accessible, ACTION).await?;
|
|
327
|
+
let action_count = bounded_action_count(proxy.get_property::<i32>("NActions").await.ok())?;
|
|
328
|
+
let mut names = Vec::new();
|
|
329
|
+
if action_count > 1 {
|
|
330
|
+
names.reserve(action_count);
|
|
331
|
+
for index in 0..action_count {
|
|
332
|
+
let name: String = proxy
|
|
333
|
+
.call("GetName", &(index as i32))
|
|
334
|
+
.await
|
|
335
|
+
.unwrap_or_default();
|
|
336
|
+
names.push(name);
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
let Some(action_index) = selected_action_index(action_count, &names) else {
|
|
340
|
+
return Ok(false);
|
|
341
|
+
};
|
|
342
|
+
proxy
|
|
343
|
+
.call("DoAction", &(action_index))
|
|
344
|
+
.await
|
|
345
|
+
.map_err(atspi_error)
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
pub async fn set_text(&self, node: &NodeSnapshot, text: &str) -> Result<bool, ProtocolError> {
|
|
349
|
+
if !node.interfaces.contains(EDITABLE_TEXT) {
|
|
350
|
+
return Err(ProtocolError::new(
|
|
351
|
+
"Target does not expose the AT-SPI EditableText interface",
|
|
352
|
+
ErrorCode::CapabilityDeferred,
|
|
353
|
+
));
|
|
354
|
+
}
|
|
355
|
+
let proxy = self.proxy(&node.accessible, EDITABLE_TEXT).await?;
|
|
356
|
+
proxy
|
|
357
|
+
.call("SetTextContents", &(text))
|
|
358
|
+
.await
|
|
359
|
+
.map_err(atspi_error)
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
pub fn root_json(reference: &str, root: &RootSnapshot, z_order: usize) -> Value {
|
|
364
|
+
let kind = if root.role.to_lowercase().contains("dialog") {
|
|
365
|
+
"dialog"
|
|
366
|
+
} else {
|
|
367
|
+
"window"
|
|
368
|
+
};
|
|
369
|
+
let frame = root
|
|
370
|
+
.frame
|
|
371
|
+
.as_ref()
|
|
372
|
+
.map(|r| json!({"x":r.x,"y":r.y,"width":r.width,"height":r.height}))
|
|
373
|
+
.unwrap_or_else(|| json!({"x":0,"y":0,"width":0,"height":0}));
|
|
374
|
+
json!({
|
|
375
|
+
"kind": kind,
|
|
376
|
+
"rootRef": reference,
|
|
377
|
+
"windowRef": reference,
|
|
378
|
+
"ref": reference,
|
|
379
|
+
"windowId": root.x11_window,
|
|
380
|
+
"title": root.name,
|
|
381
|
+
"pid": root.pid,
|
|
382
|
+
"appName": root.app_name,
|
|
383
|
+
"processName": root.app_name,
|
|
384
|
+
"role": root.role,
|
|
385
|
+
"subrole": "",
|
|
386
|
+
"zOrder": root.z_order.unwrap_or(z_order),
|
|
387
|
+
"framePoints": frame,
|
|
388
|
+
"bounds": frame,
|
|
389
|
+
"scaleFactor": 1.0,
|
|
390
|
+
"isFocused": root.is_focused,
|
|
391
|
+
"isMain": root.is_focused || z_order == 0,
|
|
392
|
+
"isMinimized": root.is_minimized,
|
|
393
|
+
"isOnscreen": true,
|
|
394
|
+
"isModal": kind == "dialog",
|
|
395
|
+
"metadata": {"backend":"at-spi2","busName":root.accessible.destination,"objectPath":root.accessible.path},
|
|
396
|
+
"isBrowser": false,
|
|
397
|
+
"browserFamily": Value::Null
|
|
398
|
+
})
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
pub fn outline_json(root: &RootSnapshot, nodes: &[NodeSnapshot], max_nodes: usize) -> Value {
|
|
402
|
+
let mut descendants: HashMap<AccessibleRef, Vec<Value>> = HashMap::new();
|
|
403
|
+
let mut roots = Vec::new();
|
|
404
|
+
for node in nodes.iter().rev() {
|
|
405
|
+
let rect = node
|
|
406
|
+
.bounds
|
|
407
|
+
.as_ref()
|
|
408
|
+
.map(|r| {
|
|
409
|
+
let origin_x = root.frame.as_ref().map(|f| f.x).unwrap_or(0);
|
|
410
|
+
let origin_y = root.frame.as_ref().map(|f| f.y).unwrap_or(0);
|
|
411
|
+
json!({"x":relative_coordinate(r.x, origin_x),"y":relative_coordinate(r.y, origin_y),"w":r.width,"h":r.height})
|
|
412
|
+
})
|
|
413
|
+
.unwrap_or_else(|| json!({"x":0,"y":0,"w":0,"h":0}));
|
|
414
|
+
let can_press = node.interfaces.contains(ACTION);
|
|
415
|
+
let can_set_value = node.interfaces.contains(EDITABLE_TEXT);
|
|
416
|
+
let mut actions = Vec::new();
|
|
417
|
+
if can_press {
|
|
418
|
+
actions.push("press");
|
|
419
|
+
}
|
|
420
|
+
if can_set_value {
|
|
421
|
+
actions.push("setValue");
|
|
422
|
+
}
|
|
423
|
+
let mut children = descendants.remove(&node.accessible).unwrap_or_default();
|
|
424
|
+
children.reverse();
|
|
425
|
+
let value = json!({
|
|
426
|
+
"ref": node.reference,
|
|
427
|
+
"role": node.role,
|
|
428
|
+
"subrole": "",
|
|
429
|
+
"identifier": node.accessible.path,
|
|
430
|
+
"title": node.name,
|
|
431
|
+
"description": node.description,
|
|
432
|
+
"value": if node.is_secure { "" } else { &node.value },
|
|
433
|
+
"actions": actions,
|
|
434
|
+
"canPress": can_press,
|
|
435
|
+
"canFocus": false,
|
|
436
|
+
"canSetValue": can_set_value,
|
|
437
|
+
"canScroll": false,
|
|
438
|
+
"canIncrement": false,
|
|
439
|
+
"canDecrement": false,
|
|
440
|
+
"isTextInput": can_set_value,
|
|
441
|
+
"rect": rect,
|
|
442
|
+
"focused": false,
|
|
443
|
+
"offscreen": node.bounds.is_none(),
|
|
444
|
+
"pictureOnly": false,
|
|
445
|
+
"truncated": nodes.len() >= max_nodes && node.depth == 0,
|
|
446
|
+
"text": if node.value.is_empty() || node.is_secure { json!([]) } else {
|
|
447
|
+
json!([{"string":node.value,"confidence":1.0,"rect":rect}])
|
|
448
|
+
},
|
|
449
|
+
"children": children
|
|
450
|
+
});
|
|
451
|
+
if let Some(parent) = &node.parent {
|
|
452
|
+
descendants.entry(parent.clone()).or_default().push(value);
|
|
453
|
+
} else {
|
|
454
|
+
roots.push(value);
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
roots.reverse();
|
|
458
|
+
if roots.is_empty() {
|
|
459
|
+
json!({"role":"window","title":"","children":[],"truncated":false})
|
|
460
|
+
} else {
|
|
461
|
+
let mut root = roots.remove(0);
|
|
462
|
+
if !roots.is_empty() {
|
|
463
|
+
root["children"]
|
|
464
|
+
.as_array_mut()
|
|
465
|
+
.expect("children array")
|
|
466
|
+
.extend(roots);
|
|
467
|
+
}
|
|
468
|
+
root
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
fn normalized_action_name(name: &str) -> String {
|
|
473
|
+
name.trim()
|
|
474
|
+
.to_ascii_lowercase()
|
|
475
|
+
.replace(['-', '_'], " ")
|
|
476
|
+
.split_whitespace()
|
|
477
|
+
.collect::<Vec<_>>()
|
|
478
|
+
.join(" ")
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
fn activation_action_index(names: &[String]) -> usize {
|
|
482
|
+
const PRIORITY: [&str; 5] = ["activate", "click", "press", "invoke", "open"];
|
|
483
|
+
PRIORITY
|
|
484
|
+
.iter()
|
|
485
|
+
.find_map(|wanted| {
|
|
486
|
+
names
|
|
487
|
+
.iter()
|
|
488
|
+
.position(|name| normalized_action_name(name) == *wanted)
|
|
489
|
+
})
|
|
490
|
+
.unwrap_or(0)
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
fn selected_action_index(action_count: usize, names: &[String]) -> Option<i32> {
|
|
494
|
+
match action_count {
|
|
495
|
+
0 => None,
|
|
496
|
+
1 => Some(0),
|
|
497
|
+
_ => Some(activation_action_index(names) as i32),
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
fn bounded_action_count(raw_count: Option<i32>) -> Result<usize, ProtocolError> {
|
|
502
|
+
let count = raw_count.unwrap_or(0);
|
|
503
|
+
if count <= 1 {
|
|
504
|
+
return Ok(count.max(0) as usize);
|
|
505
|
+
}
|
|
506
|
+
let count = usize::try_from(count).map_err(|_| {
|
|
507
|
+
ProtocolError::new(
|
|
508
|
+
"AT-SPI action count is invalid",
|
|
509
|
+
ErrorCode::CapabilityDeferred,
|
|
510
|
+
)
|
|
511
|
+
})?;
|
|
512
|
+
if count > MAX_ATSPI_ACTIONS {
|
|
513
|
+
return Err(ProtocolError::new(
|
|
514
|
+
format!(
|
|
515
|
+
"AT-SPI target exposes {count} actions; maximum supported is {MAX_ATSPI_ACTIONS}"
|
|
516
|
+
),
|
|
517
|
+
ErrorCode::CapabilityDeferred,
|
|
518
|
+
));
|
|
519
|
+
}
|
|
520
|
+
Ok(count)
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
fn relative_coordinate(value: i32, origin: i32) -> i32 {
|
|
524
|
+
value.saturating_sub(origin)
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
fn positive_application_pid(application_id: i32) -> Option<u64> {
|
|
528
|
+
u64::try_from(application_id).ok().filter(|pid| *pid > 0)
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
fn fallback_application_pid(application_id: i32, connection_pid: Option<u32>) -> u64 {
|
|
532
|
+
positive_application_pid(application_id)
|
|
533
|
+
.or_else(|| connection_pid.filter(|pid| *pid > 0).map(u64::from))
|
|
534
|
+
.unwrap_or(0)
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
fn unavailable(error: impl std::fmt::Display) -> ProtocolError {
|
|
538
|
+
ProtocolError::new(
|
|
539
|
+
format!("AT-SPI2 is unavailable: {error}"),
|
|
540
|
+
ErrorCode::CapabilityDeferred,
|
|
541
|
+
)
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
fn atspi_error(error: impl std::fmt::Display) -> ProtocolError {
|
|
545
|
+
ProtocolError::new(
|
|
546
|
+
format!("AT-SPI2 request failed: {error}"),
|
|
547
|
+
ErrorCode::InternalError,
|
|
548
|
+
)
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
#[cfg(test)]
|
|
552
|
+
mod tests {
|
|
553
|
+
use super::*;
|
|
554
|
+
|
|
555
|
+
#[test]
|
|
556
|
+
fn activation_action_selection_prefers_semantics_over_index_zero() {
|
|
557
|
+
let row = ["expand or contract", "edit", "activate"].map(str::to_owned);
|
|
558
|
+
assert_eq!(activation_action_index(&row), 2);
|
|
559
|
+
let mixed = ["open", "press", "click"].map(str::to_owned);
|
|
560
|
+
assert_eq!(activation_action_index(&mixed), 2);
|
|
561
|
+
let unsupported = ["expand or contract", "edit"].map(str::to_owned);
|
|
562
|
+
assert_eq!(activation_action_index(&unsupported), 0);
|
|
563
|
+
assert_eq!(activation_action_index(&[" InVoKe ".into()]), 0);
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
#[test]
|
|
567
|
+
fn action_count_and_dispatch_boundaries_are_explicit() {
|
|
568
|
+
assert_eq!(bounded_action_count(Some(0)).unwrap(), 0);
|
|
569
|
+
assert_eq!(selected_action_index(0, &[]), None);
|
|
570
|
+
assert_eq!(bounded_action_count(Some(1)).unwrap(), 1);
|
|
571
|
+
assert_eq!(selected_action_index(1, &[]), Some(0));
|
|
572
|
+
|
|
573
|
+
let mut names = vec!["edit".to_owned(); 64];
|
|
574
|
+
names[63] = "activate".to_owned();
|
|
575
|
+
assert_eq!(bounded_action_count(Some(64)).unwrap(), 64);
|
|
576
|
+
assert_eq!(selected_action_index(64, &names), Some(63));
|
|
577
|
+
assert!(bounded_action_count(Some(65)).is_err());
|
|
578
|
+
assert!(bounded_action_count(Some(i32::MAX)).is_err());
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
#[test]
|
|
582
|
+
fn relative_coordinates_saturate_instead_of_overflowing() {
|
|
583
|
+
assert_eq!(relative_coordinate(i32::MIN, i32::MAX), i32::MIN);
|
|
584
|
+
assert_eq!(relative_coordinate(i32::MAX, i32::MIN), i32::MAX);
|
|
585
|
+
assert_eq!(relative_coordinate(355, 100), 255);
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
#[test]
|
|
589
|
+
fn application_pid_prefers_positive_atspi_id_then_bus_fallback() {
|
|
590
|
+
assert_eq!(fallback_application_pid(42, Some(99)), 42);
|
|
591
|
+
assert_eq!(fallback_application_pid(0, Some(99)), 99);
|
|
592
|
+
assert_eq!(fallback_application_pid(-1, Some(99)), 99);
|
|
593
|
+
assert_eq!(fallback_application_pid(0, Some(0)), 0);
|
|
594
|
+
assert_eq!(fallback_application_pid(0, None), 0);
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
#[tokio::test]
|
|
598
|
+
#[ignore = "requires a live session AT-SPI bus"]
|
|
599
|
+
async fn connects_through_get_address_method() {
|
|
600
|
+
AtspiClient::connect()
|
|
601
|
+
.await
|
|
602
|
+
.expect("org.a11y.Bus.GetAddress should return a usable bus address");
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
#[test]
|
|
606
|
+
fn root_shape_has_cross_platform_aliases() {
|
|
607
|
+
let root = RootSnapshot {
|
|
608
|
+
accessible: AccessibleRef {
|
|
609
|
+
destination: ":1.2".into(),
|
|
610
|
+
path: "/window".into(),
|
|
611
|
+
},
|
|
612
|
+
pid: 42,
|
|
613
|
+
name: "Editor".into(),
|
|
614
|
+
app_name: "Text Editor".into(),
|
|
615
|
+
role: "frame".into(),
|
|
616
|
+
frame: Some(Rect {
|
|
617
|
+
x: 1,
|
|
618
|
+
y: 2,
|
|
619
|
+
width: 3,
|
|
620
|
+
height: 4,
|
|
621
|
+
}),
|
|
622
|
+
x11_window: Some(99),
|
|
623
|
+
is_focused: true,
|
|
624
|
+
is_minimized: false,
|
|
625
|
+
z_order: Some(0),
|
|
626
|
+
};
|
|
627
|
+
let value = root_json("@w1", &root, 0);
|
|
628
|
+
assert_eq!(value["rootRef"], "@w1");
|
|
629
|
+
assert_eq!(value["windowRef"], "@w1");
|
|
630
|
+
assert_eq!(value["windowId"], 99);
|
|
631
|
+
assert_eq!(value["metadata"]["backend"], "at-spi2");
|
|
632
|
+
assert_eq!(value["title"], "Editor");
|
|
633
|
+
assert_eq!(value["appName"], "Text Editor");
|
|
634
|
+
assert_eq!(value["processName"], "Text Editor");
|
|
635
|
+
|
|
636
|
+
let mut wayland_root = root;
|
|
637
|
+
wayland_root.x11_window = None;
|
|
638
|
+
assert!(root_json("@w2", &wayland_root, 0)["windowId"].is_null());
|
|
639
|
+
}
|
|
640
|
+
}
|