@alwith/module-computer 0.0.0-stage → 0.1.0
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/LICENSE +94 -0
- package/README.md +36 -2
- package/THIRD_PARTY_NOTICES.md +12 -0
- package/dist/artifacts.d.ts +36 -0
- package/dist/artifacts.js +1 -0
- package/dist/index.d.ts +35 -0
- package/dist/index.js +1 -0
- package/licenses/base64-LICENSE-MIT +21 -0
- package/licenses/cua-driver-LICENSE.md +21 -0
- package/licenses/cua-driver-THIRD_PARTY_NOTICES.md +41 -0
- package/licenses/image-LICENSE-MIT +25 -0
- package/licenses/xcap-LICENSE +201 -0
- package/package.json +32 -4
- package/proxy/stdio.mjs +10 -0
- package/rust/Cargo.lock +2651 -0
- package/rust/Cargo.toml +23 -0
- package/rust/src/gateway.rs +774 -0
- package/rust/src/lib.rs +408 -0
- package/rust/src/permissions.rs +58 -0
- package/rust/src/preview.rs +82 -0
- package/rust/src/wire.rs +48 -0
package/rust/src/lib.rs
ADDED
|
@@ -0,0 +1,408 @@
|
|
|
1
|
+
//! Application-owned Cua Driver lifecycle. No agent, UI, or conversation storage.
|
|
2
|
+
pub mod gateway;
|
|
3
|
+
pub mod permissions;
|
|
4
|
+
pub mod preview;
|
|
5
|
+
mod wire;
|
|
6
|
+
|
|
7
|
+
use serde::Serialize;
|
|
8
|
+
use serde_json::json;
|
|
9
|
+
use std::{
|
|
10
|
+
path::PathBuf,
|
|
11
|
+
process::{Child, ChildStdin, Command, Stdio},
|
|
12
|
+
sync::Mutex,
|
|
13
|
+
time::{Duration, Instant},
|
|
14
|
+
};
|
|
15
|
+
use tempfile::TempDir;
|
|
16
|
+
|
|
17
|
+
pub const DRIVER_VERSION: &str = "0.36.0";
|
|
18
|
+
const START_TIMEOUT: Duration = Duration::from_secs(12);
|
|
19
|
+
|
|
20
|
+
#[derive(Debug, Clone, Serialize, specta::Type)]
|
|
21
|
+
#[serde(rename_all = "camelCase")]
|
|
22
|
+
pub struct Status {
|
|
23
|
+
pub permissions: permissions::Permissions,
|
|
24
|
+
pub available: bool,
|
|
25
|
+
pub session_id: Option<String>,
|
|
26
|
+
pub ready: bool,
|
|
27
|
+
pub version: String,
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
#[derive(Debug, Clone, Serialize, specta::Type)]
|
|
31
|
+
pub struct Environment {
|
|
32
|
+
pub name: String,
|
|
33
|
+
pub value: String,
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
#[derive(Debug, Clone, Serialize, specta::Type)]
|
|
37
|
+
pub struct Connection {
|
|
38
|
+
pub generation: String,
|
|
39
|
+
pub command: String,
|
|
40
|
+
pub args: Vec<String>,
|
|
41
|
+
pub env: Vec<Environment>,
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
struct Running {
|
|
45
|
+
session: String,
|
|
46
|
+
generation: String,
|
|
47
|
+
endpoint: String,
|
|
48
|
+
child: Child,
|
|
49
|
+
liveness: Option<ChildStdin>,
|
|
50
|
+
_directory: TempDir,
|
|
51
|
+
ready: bool,
|
|
52
|
+
revoked: bool,
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
pub struct ComputerHost {
|
|
56
|
+
binary: PathBuf,
|
|
57
|
+
bundle_id: String,
|
|
58
|
+
cursor_theme: Option<(String, Vec<u8>)>,
|
|
59
|
+
operations: tokio::sync::Mutex<()>,
|
|
60
|
+
running: Mutex<Option<Running>>,
|
|
61
|
+
closed: std::sync::atomic::AtomicBool,
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
impl ComputerHost {
|
|
65
|
+
pub fn new(binary: PathBuf, bundle_id: String) -> Self {
|
|
66
|
+
Self {
|
|
67
|
+
binary,
|
|
68
|
+
bundle_id,
|
|
69
|
+
cursor_theme: None,
|
|
70
|
+
operations: tokio::sync::Mutex::new(()),
|
|
71
|
+
running: Mutex::new(None),
|
|
72
|
+
closed: std::sync::atomic::AtomicBool::new(false),
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
pub fn with_cursor_theme(mut self, id: String, bytes: Vec<u8>) -> Self {
|
|
77
|
+
self.cursor_theme = Some((id, bytes));
|
|
78
|
+
self
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
pub fn status(&self) -> Result<Status, String> {
|
|
82
|
+
let mut slot = self.running.lock().unwrap();
|
|
83
|
+
if let Some(running) = slot.as_mut()
|
|
84
|
+
&& running.child.try_wait().map_err(|e| e.to_string())?.is_some()
|
|
85
|
+
{
|
|
86
|
+
*slot = None;
|
|
87
|
+
}
|
|
88
|
+
Ok(Status {
|
|
89
|
+
permissions: permissions::status(),
|
|
90
|
+
available: self.binary.is_file(),
|
|
91
|
+
session_id: slot.as_ref().map(|r| r.session.clone()),
|
|
92
|
+
ready: slot.as_ref().is_some_and(|r| r.ready && !r.revoked),
|
|
93
|
+
version: DRIVER_VERSION.into(),
|
|
94
|
+
})
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
pub fn connection(&self, session: &str) -> Result<Option<Connection>, String> {
|
|
98
|
+
let status = self.status()?;
|
|
99
|
+
if status.session_id.as_deref() != Some(session) || !status.ready {
|
|
100
|
+
return Ok(None);
|
|
101
|
+
}
|
|
102
|
+
if !status.permissions.accessibility || !status.permissions.screen_capture {
|
|
103
|
+
return Err("Computer permissions were revoked; stop computer use and grant permissions again".into());
|
|
104
|
+
}
|
|
105
|
+
let slot = self.running.lock().unwrap();
|
|
106
|
+
Ok(slot.as_ref().filter(|r| r.session == session && r.ready && !r.revoked).map(|r| Connection {
|
|
107
|
+
generation: r.generation.clone(),
|
|
108
|
+
command: self.binary.to_string_lossy().into(),
|
|
109
|
+
args: vec![
|
|
110
|
+
"mcp".into(),
|
|
111
|
+
"--embedded".into(),
|
|
112
|
+
"--socket".into(),
|
|
113
|
+
r.endpoint.clone(),
|
|
114
|
+
"--host-bundle-id".into(),
|
|
115
|
+
self.bundle_id.clone(),
|
|
116
|
+
],
|
|
117
|
+
env: vec![
|
|
118
|
+
Environment { name: "CUA_DRIVER_RS_TELEMETRY_ENABLED".into(), value: "0".into() },
|
|
119
|
+
Environment { name: "CUA_TELEMETRY_ENABLED".into(), value: "0".into() },
|
|
120
|
+
Environment {
|
|
121
|
+
name: "CUA_DRIVER_RS_HOME".into(),
|
|
122
|
+
value: r._directory.path().join("state").to_string_lossy().into_owned(),
|
|
123
|
+
},
|
|
124
|
+
],
|
|
125
|
+
}))
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
pub async fn start(&self, session: String) -> Result<Status, String> {
|
|
129
|
+
if session.is_empty() || session.len() > 256 {
|
|
130
|
+
return Err("Invalid computer session identity".into());
|
|
131
|
+
}
|
|
132
|
+
let _operation = self.operations.lock().await;
|
|
133
|
+
if self.closed.load(std::sync::atomic::Ordering::Acquire) {
|
|
134
|
+
return Err("Computer host is shutting down".into());
|
|
135
|
+
}
|
|
136
|
+
let status = self.status()?;
|
|
137
|
+
if !status.permissions.supported {
|
|
138
|
+
return Err("Computer use currently supports macOS and Windows".into());
|
|
139
|
+
}
|
|
140
|
+
if !status.permissions.accessibility || !status.permissions.screen_capture {
|
|
141
|
+
return Err("Grant Accessibility and Screen Recording to the host application first".into());
|
|
142
|
+
}
|
|
143
|
+
if let Some(owner) = status.session_id {
|
|
144
|
+
if owner == session && status.ready {
|
|
145
|
+
return self.status();
|
|
146
|
+
}
|
|
147
|
+
return Err("Another computer session is active or stopping".into());
|
|
148
|
+
}
|
|
149
|
+
if !self.binary.is_absolute() || !self.binary.is_file() {
|
|
150
|
+
return Err("Bundled Cua Driver is missing; run staging or reinstall the application".into());
|
|
151
|
+
}
|
|
152
|
+
let directory = tempfile::Builder::new().prefix("alwith-cu-").tempdir().map_err(|e| e.to_string())?;
|
|
153
|
+
let generation = uuid::Uuid::new_v4().to_string();
|
|
154
|
+
#[cfg(unix)]
|
|
155
|
+
let endpoint = directory.path().join("driver.sock").to_string_lossy().into_owned();
|
|
156
|
+
#[cfg(windows)]
|
|
157
|
+
let endpoint = format!(r"\\.\pipe\alwith-computer-{generation}");
|
|
158
|
+
let mut command = Command::new(&self.binary);
|
|
159
|
+
command
|
|
160
|
+
.args([
|
|
161
|
+
"serve",
|
|
162
|
+
"--embedded",
|
|
163
|
+
"--parent-liveness-stdio",
|
|
164
|
+
"--no-permissions-gate",
|
|
165
|
+
"--socket",
|
|
166
|
+
&endpoint,
|
|
167
|
+
"--host-bundle-id",
|
|
168
|
+
&self.bundle_id,
|
|
169
|
+
"--permission-mode",
|
|
170
|
+
"standard",
|
|
171
|
+
])
|
|
172
|
+
.env_clear()
|
|
173
|
+
.stdin(Stdio::piped())
|
|
174
|
+
.stdout(Stdio::null())
|
|
175
|
+
.stderr(Stdio::null());
|
|
176
|
+
for (key, value) in std::env::vars_os() {
|
|
177
|
+
if inherited_environment(&key.to_string_lossy()) {
|
|
178
|
+
command.env(key, value);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
command
|
|
182
|
+
.env("CUA_DRIVER_EMBEDDED_HOST_PID", std::process::id().to_string())
|
|
183
|
+
.env("CUA_DRIVER_RS_HOME", directory.path().join("state"))
|
|
184
|
+
.env("CUA_DRIVER_RS_TELEMETRY_ENABLED", "0")
|
|
185
|
+
.env("CUA_TELEMETRY_ENABLED", "0")
|
|
186
|
+
.env("CUA_DRIVER_RS_UPDATE_CHECK", "0");
|
|
187
|
+
#[cfg(windows)]
|
|
188
|
+
{
|
|
189
|
+
use std::os::windows::process::CommandExt;
|
|
190
|
+
command.creation_flags(0x08000000);
|
|
191
|
+
}
|
|
192
|
+
if let Some((id, bytes)) = &self.cursor_theme {
|
|
193
|
+
if id.is_empty() || !id.bytes().all(|c| c.is_ascii_alphanumeric() || matches!(c, b'.' | b'-' | b'_')) {
|
|
194
|
+
return Err("Invalid bundled cursor theme identity".into());
|
|
195
|
+
}
|
|
196
|
+
let themes = directory.path().join("cursor-themes");
|
|
197
|
+
std::fs::create_dir_all(&themes).map_err(|e| e.to_string())?;
|
|
198
|
+
std::fs::write(themes.join(format!("{id}.cua-theme")), bytes).map_err(|e| e.to_string())?;
|
|
199
|
+
command.args(["--cursor-theme", id]).env("CUA_DRIVER_CURSOR_THEME_DIR", themes);
|
|
200
|
+
}
|
|
201
|
+
let mut child = command.spawn().map_err(|e| format!("Cannot start Cua Driver: {e}"))?;
|
|
202
|
+
let pid = child.id();
|
|
203
|
+
let liveness = child.stdin.take();
|
|
204
|
+
*self.running.lock().unwrap() = Some(Running {
|
|
205
|
+
session,
|
|
206
|
+
generation,
|
|
207
|
+
endpoint: endpoint.clone(),
|
|
208
|
+
child,
|
|
209
|
+
liveness,
|
|
210
|
+
_directory: directory,
|
|
211
|
+
ready: false,
|
|
212
|
+
revoked: false,
|
|
213
|
+
});
|
|
214
|
+
let started = Instant::now();
|
|
215
|
+
let result = loop {
|
|
216
|
+
{
|
|
217
|
+
let mut slot = self.running.lock().unwrap();
|
|
218
|
+
let running = slot.as_mut().ok_or("Computer driver exited during startup")?;
|
|
219
|
+
if running.revoked || self.closed.load(std::sync::atomic::Ordering::Acquire) {
|
|
220
|
+
break Err("Computer startup cancelled".into());
|
|
221
|
+
}
|
|
222
|
+
match running.child.try_wait() {
|
|
223
|
+
Ok(Some(exit)) => break Err(format!("Cua Driver exited during startup: {exit}")),
|
|
224
|
+
Err(error) => break Err(error.to_string()),
|
|
225
|
+
Ok(None) => {}
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
if let Ok(Ok(metadata)) = tokio::time::timeout(Duration::from_millis(300), wire::metadata(&endpoint)).await
|
|
229
|
+
{
|
|
230
|
+
break validate_metadata(&metadata, pid, &self.bundle_id);
|
|
231
|
+
}
|
|
232
|
+
if started.elapsed() >= START_TIMEOUT {
|
|
233
|
+
break Err("Cua Driver did not become ready in time".into());
|
|
234
|
+
}
|
|
235
|
+
tokio::time::sleep(Duration::from_millis(60)).await;
|
|
236
|
+
};
|
|
237
|
+
if let Err(error) = result {
|
|
238
|
+
let cleanup = self.drain().await;
|
|
239
|
+
return Err(match cleanup {
|
|
240
|
+
Ok(()) => error,
|
|
241
|
+
Err(cleanup) => format!("{error}; cleanup: {cleanup}"),
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
let revoked = {
|
|
245
|
+
let mut slot = self.running.lock().unwrap();
|
|
246
|
+
let running = slot.as_mut().ok_or("Computer driver exited during startup")?;
|
|
247
|
+
let revoked = running.revoked || self.closed.load(std::sync::atomic::Ordering::Acquire);
|
|
248
|
+
if !revoked {
|
|
249
|
+
running.ready = true;
|
|
250
|
+
}
|
|
251
|
+
revoked
|
|
252
|
+
};
|
|
253
|
+
if revoked {
|
|
254
|
+
self.drain().await?;
|
|
255
|
+
return Err("Computer startup cancelled".into());
|
|
256
|
+
}
|
|
257
|
+
self.status()
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/// Revoke before waiting for startup/cleanup, and never stop a different owner.
|
|
261
|
+
pub async fn stop(&self, session: &str) -> Result<(), String> {
|
|
262
|
+
let generation = {
|
|
263
|
+
let mut slot = self.running.lock().unwrap();
|
|
264
|
+
let Some(running) = slot.as_mut().filter(|r| r.session == session) else {
|
|
265
|
+
return Ok(());
|
|
266
|
+
};
|
|
267
|
+
running.revoked = true;
|
|
268
|
+
running.generation.clone()
|
|
269
|
+
};
|
|
270
|
+
let _operation = self.operations.lock().await;
|
|
271
|
+
let matches =
|
|
272
|
+
self.running.lock().unwrap().as_ref().is_some_and(|r| r.session == session && r.generation == generation);
|
|
273
|
+
if matches {
|
|
274
|
+
self.drain().await?;
|
|
275
|
+
}
|
|
276
|
+
Ok(())
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
pub async fn shutdown(&self) -> Result<(), String> {
|
|
280
|
+
self.closed.store(true, std::sync::atomic::Ordering::Release);
|
|
281
|
+
let _operation = self.operations.lock().await;
|
|
282
|
+
self.drain().await
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
pub async fn stop_current(&self) -> Result<(), String> {
|
|
286
|
+
let owner = self.running.lock().unwrap().as_ref().map(|r| r.session.clone());
|
|
287
|
+
if let Some(owner) = owner {
|
|
288
|
+
self.stop(&owner).await?;
|
|
289
|
+
}
|
|
290
|
+
Ok(())
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
async fn drain(&self) -> Result<(), String> {
|
|
294
|
+
let endpoint = {
|
|
295
|
+
let mut slot = self.running.lock().unwrap();
|
|
296
|
+
let Some(running) = slot.as_mut() else {
|
|
297
|
+
return Ok(());
|
|
298
|
+
};
|
|
299
|
+
running.revoked = true;
|
|
300
|
+
running.liveness.take();
|
|
301
|
+
running.endpoint.clone()
|
|
302
|
+
};
|
|
303
|
+
let _ =
|
|
304
|
+
tokio::time::timeout(Duration::from_millis(300), wire::request(&endpoint, json!({"method":"shutdown"})))
|
|
305
|
+
.await;
|
|
306
|
+
let started = Instant::now();
|
|
307
|
+
loop {
|
|
308
|
+
{
|
|
309
|
+
let mut slot = self.running.lock().unwrap();
|
|
310
|
+
let Some(running) = slot.as_mut() else {
|
|
311
|
+
return Ok(());
|
|
312
|
+
};
|
|
313
|
+
if running.child.try_wait().map_err(|e| e.to_string())?.is_some() {
|
|
314
|
+
*slot = None;
|
|
315
|
+
return Ok(());
|
|
316
|
+
}
|
|
317
|
+
if started.elapsed() > Duration::from_secs(2) {
|
|
318
|
+
running.child.kill().map_err(|e| format!("Cannot stop Cua Driver: {e}"))?;
|
|
319
|
+
running.child.wait().map_err(|e| e.to_string())?;
|
|
320
|
+
*slot = None;
|
|
321
|
+
return Ok(());
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
tokio::time::sleep(Duration::from_millis(30)).await;
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
pub fn kill_on_exit(&self) {
|
|
329
|
+
self.closed.store(true, std::sync::atomic::Ordering::Release);
|
|
330
|
+
if let Some(mut running) = self.running.lock().unwrap().take() {
|
|
331
|
+
running.liveness.take();
|
|
332
|
+
let _ = running.child.kill();
|
|
333
|
+
let _ = running.child.wait();
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
impl Drop for ComputerHost {
|
|
339
|
+
fn drop(&mut self) {
|
|
340
|
+
self.kill_on_exit();
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
fn inherited_environment(name: &str) -> bool {
|
|
345
|
+
matches!(
|
|
346
|
+
name.to_ascii_uppercase().as_str(),
|
|
347
|
+
"PATH"
|
|
348
|
+
| "HOME"
|
|
349
|
+
| "USER"
|
|
350
|
+
| "LOGNAME"
|
|
351
|
+
| "TMPDIR"
|
|
352
|
+
| "TMP"
|
|
353
|
+
| "TEMP"
|
|
354
|
+
| "LANG"
|
|
355
|
+
| "USERPROFILE"
|
|
356
|
+
| "USERNAME"
|
|
357
|
+
| "HOMEDRIVE"
|
|
358
|
+
| "HOMEPATH"
|
|
359
|
+
| "SYSTEMROOT"
|
|
360
|
+
| "WINDIR"
|
|
361
|
+
| "COMSPEC"
|
|
362
|
+
| "PATHEXT"
|
|
363
|
+
| "APPDATA"
|
|
364
|
+
| "LOCALAPPDATA"
|
|
365
|
+
| "PROGRAMDATA"
|
|
366
|
+
) || name.starts_with("LC_")
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
fn validate_metadata(value: &serde_json::Value, pid: u32, bundle: &str) -> Result<(), String> {
|
|
370
|
+
if value["driver_version"] != DRIVER_VERSION
|
|
371
|
+
|| value["contract_version"] != "0.8.0"
|
|
372
|
+
|| value["pid"] != pid
|
|
373
|
+
|| value["embedded"] != true
|
|
374
|
+
|| value["host_bundle_id"] != bundle
|
|
375
|
+
{
|
|
376
|
+
return Err("Cua Driver identity or protocol version mismatch".into());
|
|
377
|
+
}
|
|
378
|
+
Ok(())
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
#[cfg(test)]
|
|
382
|
+
mod tests {
|
|
383
|
+
use super::*;
|
|
384
|
+
#[test]
|
|
385
|
+
fn rejects_wrong_driver_identity_and_untrusted_environment() {
|
|
386
|
+
let metadata = json!({"driver_version":DRIVER_VERSION,"contract_version":"0.8.0","pid":42,"embedded":true,"host_bundle_id":"ai.test"});
|
|
387
|
+
assert!(validate_metadata(&metadata, 42, "ai.test").is_ok());
|
|
388
|
+
assert!(validate_metadata(&metadata, 43, "ai.test").is_err());
|
|
389
|
+
assert!(validate_metadata(&metadata, 42, "other").is_err());
|
|
390
|
+
for name in [
|
|
391
|
+
"CUA_DRIVER_PERMISSION_MODE",
|
|
392
|
+
"CUA_DRIVER_DANGEROUSLY_BYPASS_APPROVALS",
|
|
393
|
+
"OPENAI_API_KEY",
|
|
394
|
+
"DYLD_INSERT_LIBRARIES",
|
|
395
|
+
] {
|
|
396
|
+
assert!(!inherited_environment(name));
|
|
397
|
+
}
|
|
398
|
+
assert!(inherited_environment("SystemRoot"));
|
|
399
|
+
}
|
|
400
|
+
#[tokio::test]
|
|
401
|
+
async fn stopping_an_unknown_session_never_starts_a_process() {
|
|
402
|
+
let host = ComputerHost::new(PathBuf::from("/missing/cua-driver"), "test".into());
|
|
403
|
+
host.stop("unknown").await.unwrap();
|
|
404
|
+
assert!(host.connection("unknown").unwrap().is_none());
|
|
405
|
+
host.shutdown().await.unwrap();
|
|
406
|
+
assert!(host.start("new".into()).await.unwrap_err().contains("shutting down"));
|
|
407
|
+
}
|
|
408
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
use serde::Serialize;
|
|
2
|
+
|
|
3
|
+
#[derive(Debug, Clone, Serialize, specta::Type)]
|
|
4
|
+
#[serde(rename_all = "camelCase")]
|
|
5
|
+
pub struct Permissions {
|
|
6
|
+
pub supported: bool,
|
|
7
|
+
pub accessibility: bool,
|
|
8
|
+
pub screen_capture: bool,
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
pub fn status() -> Permissions {
|
|
12
|
+
#[cfg(target_os = "macos")]
|
|
13
|
+
// SAFETY: these system preflight functions take no pointers and do not prompt.
|
|
14
|
+
unsafe {
|
|
15
|
+
Permissions {
|
|
16
|
+
supported: true,
|
|
17
|
+
accessibility: AXIsProcessTrusted(),
|
|
18
|
+
screen_capture: CGPreflightScreenCaptureAccess(),
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
#[cfg(target_os = "windows")]
|
|
22
|
+
{
|
|
23
|
+
Permissions { supported: true, accessibility: true, screen_capture: true }
|
|
24
|
+
}
|
|
25
|
+
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
|
|
26
|
+
{
|
|
27
|
+
Permissions { supported: false, accessibility: false, screen_capture: false }
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
pub fn request() -> Permissions {
|
|
32
|
+
#[cfg(target_os = "macos")]
|
|
33
|
+
{
|
|
34
|
+
use core_foundation::{base::TCFType, boolean::CFBoolean, dictionary::CFDictionary, string::CFString};
|
|
35
|
+
let options =
|
|
36
|
+
CFDictionary::from_CFType_pairs(&[(CFString::new("AXTrustedCheckOptionPrompt"), CFBoolean::true_value())]);
|
|
37
|
+
// SAFETY: the dictionary lives throughout the synchronous system call.
|
|
38
|
+
unsafe {
|
|
39
|
+
AXIsProcessTrustedWithOptions(options.as_concrete_TypeRef().cast());
|
|
40
|
+
CGRequestScreenCaptureAccess();
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
status()
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
#[cfg(target_os = "macos")]
|
|
47
|
+
#[link(name = "ApplicationServices", kind = "framework")]
|
|
48
|
+
unsafe extern "C" {
|
|
49
|
+
fn AXIsProcessTrusted() -> bool;
|
|
50
|
+
fn AXIsProcessTrustedWithOptions(options: *const std::ffi::c_void) -> bool;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
#[cfg(target_os = "macos")]
|
|
54
|
+
#[link(name = "CoreGraphics", kind = "framework")]
|
|
55
|
+
unsafe extern "C" {
|
|
56
|
+
fn CGPreflightScreenCaptureAccess() -> bool;
|
|
57
|
+
fn CGRequestScreenCaptureAccess() -> bool;
|
|
58
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
//! Independent, bounded window capture; never touches the driver's grounding caches.
|
|
2
|
+
use crate::gateway::Target;
|
|
3
|
+
#[derive(Clone, Debug, serde::Serialize, specta::Type)]
|
|
4
|
+
#[serde(rename_all = "camelCase")]
|
|
5
|
+
pub struct Frame {
|
|
6
|
+
pub image: String,
|
|
7
|
+
pub title: String,
|
|
8
|
+
pub app_name: String,
|
|
9
|
+
pub cursor: Option<CursorPosition>,
|
|
10
|
+
}
|
|
11
|
+
#[derive(Clone, Debug, serde::Serialize, specta::Type)]
|
|
12
|
+
pub struct CursorPosition {
|
|
13
|
+
pub x: f64,
|
|
14
|
+
pub y: f64,
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
pub fn normalize_cursor(cursor: CursorPosition, x: f64, y: f64, width: f64, height: f64) -> Option<CursorPosition> {
|
|
18
|
+
if width <= 0.0 || height <= 0.0 {
|
|
19
|
+
return None;
|
|
20
|
+
}
|
|
21
|
+
let x = (cursor.x - x) / width;
|
|
22
|
+
let y = (cursor.y - y) / height;
|
|
23
|
+
(x.is_finite() && y.is_finite() && (0.0..=1.0).contains(&x) && (0.0..=1.0).contains(&y))
|
|
24
|
+
.then_some(CursorPosition { x, y })
|
|
25
|
+
}
|
|
26
|
+
#[cfg(any(target_os = "macos", target_os = "windows"))]
|
|
27
|
+
pub fn capture(target: &Target, cursor: Option<CursorPosition>) -> Result<Frame, String> {
|
|
28
|
+
use base64::Engine;
|
|
29
|
+
if !crate::permissions::status().screen_capture {
|
|
30
|
+
return Err("Screen capture permission is unavailable".into());
|
|
31
|
+
}
|
|
32
|
+
let windows = xcap::Window::all().map_err(|e| e.to_string())?;
|
|
33
|
+
let window = windows
|
|
34
|
+
.into_iter()
|
|
35
|
+
.find(|w| w.id().ok() == Some(target.window_id) && w.pid().ok() == Some(target.pid))
|
|
36
|
+
.ok_or("Target window is no longer available")?;
|
|
37
|
+
if window.is_minimized().map_err(|e| e.to_string())? {
|
|
38
|
+
return Err("Target window is minimized".into());
|
|
39
|
+
}
|
|
40
|
+
let title = window.title().map_err(|e| e.to_string())?;
|
|
41
|
+
let app_name = window.app_name().map_err(|e| e.to_string())?;
|
|
42
|
+
let cursor = cursor.and_then(|point| {
|
|
43
|
+
normalize_cursor(
|
|
44
|
+
point,
|
|
45
|
+
window.x().ok()? as f64,
|
|
46
|
+
window.y().ok()? as f64,
|
|
47
|
+
window.width().ok()? as f64,
|
|
48
|
+
window.height().ok()? as f64,
|
|
49
|
+
)
|
|
50
|
+
});
|
|
51
|
+
let pixels = window.capture_image().map_err(|e| e.to_string())?;
|
|
52
|
+
let image = image::DynamicImage::ImageRgba8(pixels).thumbnail(960, 600).to_rgb8();
|
|
53
|
+
let mut bytes = Vec::new();
|
|
54
|
+
image::codecs::jpeg::JpegEncoder::new_with_quality(&mut bytes, 65)
|
|
55
|
+
.encode_image(&image)
|
|
56
|
+
.map_err(|e| e.to_string())?;
|
|
57
|
+
if bytes.len() > 1024 * 1024 {
|
|
58
|
+
return Err("Preview frame exceeds size limit".into());
|
|
59
|
+
}
|
|
60
|
+
Ok(Frame {
|
|
61
|
+
image: format!("data:image/jpeg;base64,{}", base64::engine::general_purpose::STANDARD.encode(bytes)),
|
|
62
|
+
title,
|
|
63
|
+
app_name,
|
|
64
|
+
cursor,
|
|
65
|
+
})
|
|
66
|
+
}
|
|
67
|
+
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
|
|
68
|
+
pub fn capture(_: &Target, _: Option<CursorPosition>) -> Result<Frame, String> {
|
|
69
|
+
Err("Window preview is unavailable on this platform".into())
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
#[cfg(test)]
|
|
73
|
+
mod tests {
|
|
74
|
+
use super::*;
|
|
75
|
+
#[test]
|
|
76
|
+
fn cursor_coordinates_follow_window_geometry_and_ignore_other_displays() {
|
|
77
|
+
let p = normalize_cursor(CursorPosition { x: -900.0, y: 150.0 }, -1000.0, 50.0, 200.0, 200.0).unwrap();
|
|
78
|
+
assert_eq!((p.x, p.y), (0.5, 0.5));
|
|
79
|
+
assert!(normalize_cursor(CursorPosition { x: 100.0, y: 150.0 }, -1000.0, 50.0, 200.0, 200.0).is_none());
|
|
80
|
+
assert!(normalize_cursor(CursorPosition { x: f64::NAN, y: 0.0 }, 0.0, 0.0, 200.0, 200.0).is_none());
|
|
81
|
+
}
|
|
82
|
+
}
|
package/rust/src/wire.rs
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
use serde_json::{Value, json};
|
|
2
|
+
use tokio::io::{AsyncBufReadExt, AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt, BufReader};
|
|
3
|
+
|
|
4
|
+
pub async fn request(endpoint: &str, value: Value) -> Result<Value, String> {
|
|
5
|
+
#[cfg(unix)]
|
|
6
|
+
let stream = tokio::net::UnixStream::connect(endpoint).await.map_err(|e| e.to_string())?;
|
|
7
|
+
#[cfg(windows)]
|
|
8
|
+
let stream = tokio::net::windows::named_pipe::ClientOptions::new().open(endpoint).map_err(|e| e.to_string())?;
|
|
9
|
+
exchange(stream, value).await
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
async fn exchange<S: AsyncRead + AsyncWrite + Unpin>(mut stream: S, value: Value) -> Result<Value, String> {
|
|
13
|
+
let bytes = serde_json::to_vec(&value).map_err(|e| e.to_string())?;
|
|
14
|
+
stream.write_all(&bytes).await.map_err(|e| e.to_string())?;
|
|
15
|
+
stream.write_all(b"\n").await.map_err(|e| e.to_string())?;
|
|
16
|
+
let mut line = String::new();
|
|
17
|
+
BufReader::new(stream).take(65537).read_line(&mut line).await.map_err(|e| e.to_string())?;
|
|
18
|
+
if line.len() > 65536 || !line.ends_with('\n') {
|
|
19
|
+
return Err("Invalid Driver control response framing".into());
|
|
20
|
+
}
|
|
21
|
+
let response: Value = serde_json::from_str(&line).map_err(|e| e.to_string())?;
|
|
22
|
+
if response["ok"] != true {
|
|
23
|
+
return Err(response["error"].as_str().unwrap_or("Driver control request failed").into());
|
|
24
|
+
}
|
|
25
|
+
response.get("result").cloned().ok_or_else(|| "Driver response omitted result".into())
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
pub async fn metadata(endpoint: &str) -> Result<Value, String> {
|
|
29
|
+
request(endpoint, json!({"method":"metadata"})).await
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
#[cfg(test)]
|
|
33
|
+
mod tests {
|
|
34
|
+
use super::*;
|
|
35
|
+
#[tokio::test]
|
|
36
|
+
async fn rejects_truncated_and_failed_control_responses() {
|
|
37
|
+
for reply in ["{\"ok\":true,\"result\":{}}", "{\"ok\":false,\"error\":\"denied\"}\n", "{\"ok\":true}\n"] {
|
|
38
|
+
let (client, mut server) = tokio::io::duplex(1024);
|
|
39
|
+
let reply = reply.to_owned();
|
|
40
|
+
tokio::spawn(async move {
|
|
41
|
+
let mut input = [0u8; 64];
|
|
42
|
+
let _ = server.read(&mut input).await;
|
|
43
|
+
server.write_all(reply.as_bytes()).await.unwrap();
|
|
44
|
+
});
|
|
45
|
+
assert!(exchange(client, json!({"method":"metadata"})).await.is_err());
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|