@alwith/module-computer 0.1.3 → 0.1.4
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/package.json +9 -7
- package/rust/Cargo.toml +4 -14
- package/rust/src/gateway.rs +26 -730
- package/rust/src/lib.rs +21 -361
- package/rust/src/permissions.rs +11 -46
- package/rust/src/preview.rs +5 -67
- package/rust/Cargo.lock +0 -2651
- package/rust/src/wire.rs +0 -48
package/rust/src/lib.rs
CHANGED
|
@@ -1,23 +1,13 @@
|
|
|
1
|
-
//!
|
|
1
|
+
//! Computer-use types and ABI transport.
|
|
2
2
|
pub mod gateway;
|
|
3
3
|
pub mod permissions;
|
|
4
4
|
pub mod preview;
|
|
5
|
-
|
|
6
|
-
|
|
5
|
+
use alwith_native_client::Handle;
|
|
7
6
|
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
|
-
|
|
7
|
+
use serde_json::{Value, json};
|
|
8
|
+
use std::path::PathBuf;
|
|
17
9
|
pub const DRIVER_VERSION: &str = "0.36.0";
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
#[derive(Debug, Clone, Serialize, specta::Type)]
|
|
10
|
+
#[derive(Debug, Clone, Serialize, specta::Type, serde::Deserialize)]
|
|
21
11
|
#[serde(rename_all = "camelCase")]
|
|
22
12
|
pub struct Status {
|
|
23
13
|
pub permissions: permissions::Permissions,
|
|
@@ -26,14 +16,12 @@ pub struct Status {
|
|
|
26
16
|
pub ready: bool,
|
|
27
17
|
pub version: String,
|
|
28
18
|
}
|
|
29
|
-
|
|
30
|
-
#[derive(Debug, Clone, Serialize, specta::Type)]
|
|
19
|
+
#[derive(Debug, Clone, Serialize, specta::Type, serde::Deserialize)]
|
|
31
20
|
pub struct Environment {
|
|
32
21
|
pub name: String,
|
|
33
22
|
pub value: String,
|
|
34
23
|
}
|
|
35
|
-
|
|
36
|
-
#[derive(Debug, Clone, Serialize, specta::Type)]
|
|
24
|
+
#[derive(Debug, Clone, Serialize, specta::Type, serde::Deserialize)]
|
|
37
25
|
pub struct Connection {
|
|
38
26
|
pub generation: String,
|
|
39
27
|
pub command: String,
|
|
@@ -41,368 +29,40 @@ pub struct Connection {
|
|
|
41
29
|
pub env: Vec<Environment>,
|
|
42
30
|
}
|
|
43
31
|
|
|
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
32
|
pub struct ComputerHost {
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
cursor_theme: Option<(String, Vec<u8>)>,
|
|
59
|
-
operations: tokio::sync::Mutex<()>,
|
|
60
|
-
running: Mutex<Option<Running>>,
|
|
61
|
-
closed: std::sync::atomic::AtomicBool,
|
|
33
|
+
handle: Handle,
|
|
34
|
+
options: Value,
|
|
62
35
|
}
|
|
63
|
-
|
|
64
36
|
impl ComputerHost {
|
|
65
37
|
pub fn new(binary: PathBuf, bundle_id: String) -> Self {
|
|
66
|
-
|
|
67
|
-
|
|
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
|
-
}
|
|
38
|
+
let options = json!({"binary":binary,"bundle_id":bundle_id});
|
|
39
|
+
Self { handle: Handle::open("computer", options.clone()).expect("Native computer module is required"), options }
|
|
74
40
|
}
|
|
75
|
-
|
|
76
41
|
pub fn with_cursor_theme(mut self, id: String, bytes: Vec<u8>) -> Self {
|
|
77
|
-
self.
|
|
42
|
+
self.handle.close().expect("Native computer builder close failed");
|
|
43
|
+
self.options["cursor_theme"] = json!({"id":id,"bytes":bytes});
|
|
44
|
+
self.handle = Handle::open("computer", self.options.clone()).expect("Native computer builder failed");
|
|
78
45
|
self
|
|
79
46
|
}
|
|
80
|
-
|
|
81
47
|
pub fn status(&self) -> Result<Status, String> {
|
|
82
|
-
|
|
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
|
-
})
|
|
48
|
+
self.handle.call("status", json!({}))
|
|
95
49
|
}
|
|
96
|
-
|
|
97
50
|
pub fn connection(&self, session: &str) -> Result<Option<Connection>, String> {
|
|
98
|
-
|
|
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
|
-
}))
|
|
51
|
+
self.handle.call("connection", json!({"session":session}))
|
|
126
52
|
}
|
|
127
|
-
|
|
128
53
|
pub async fn start(&self, session: String) -> Result<Status, String> {
|
|
129
|
-
|
|
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()
|
|
54
|
+
self.handle.call_async("start", json!({"session":session})).await
|
|
258
55
|
}
|
|
259
|
-
|
|
260
|
-
/// Revoke before waiting for startup/cleanup, and never stop a different owner.
|
|
261
56
|
pub async fn stop(&self, session: &str) -> Result<(), String> {
|
|
262
|
-
|
|
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(())
|
|
57
|
+
self.handle.call_async("stop", json!({"session":session})).await
|
|
277
58
|
}
|
|
278
|
-
|
|
279
59
|
pub async fn shutdown(&self) -> Result<(), String> {
|
|
280
|
-
self.
|
|
281
|
-
let _operation = self.operations.lock().await;
|
|
282
|
-
self.drain().await
|
|
60
|
+
self.handle.call_async("shutdown", json!({})).await
|
|
283
61
|
}
|
|
284
|
-
|
|
285
62
|
pub async fn stop_current(&self) -> Result<(), String> {
|
|
286
|
-
|
|
287
|
-
if let Some(owner) = owner {
|
|
288
|
-
self.stop(&owner).await?;
|
|
289
|
-
}
|
|
290
|
-
Ok(())
|
|
63
|
+
self.handle.call_async("stop_current", json!({})).await
|
|
291
64
|
}
|
|
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
65
|
pub fn kill_on_exit(&self) {
|
|
329
|
-
self.
|
|
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"));
|
|
66
|
+
self.handle.call::<()>("kill_on_exit", json!({})).expect("Native computer exit failed");
|
|
407
67
|
}
|
|
408
68
|
}
|
package/rust/src/permissions.rs
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
+
use alwith_native_client::Library;
|
|
1
2
|
use serde::Serialize;
|
|
2
|
-
|
|
3
|
-
#[derive(Debug, Clone, Serialize, specta::Type)]
|
|
3
|
+
use serde_json::json;
|
|
4
|
+
#[derive(Debug, Clone, Serialize, specta::Type, serde::Deserialize)]
|
|
4
5
|
#[serde(rename_all = "camelCase")]
|
|
5
6
|
pub struct Permissions {
|
|
6
7
|
pub supported: bool,
|
|
@@ -9,50 +10,14 @@ pub struct Permissions {
|
|
|
9
10
|
}
|
|
10
11
|
|
|
11
12
|
pub fn status() -> Permissions {
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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
|
-
}
|
|
13
|
+
Library::module("computer")
|
|
14
|
+
.expect("Native computer module is required")
|
|
15
|
+
.call("computer.permissions_status", json!({}))
|
|
16
|
+
.expect("Native permission status failed")
|
|
29
17
|
}
|
|
30
|
-
|
|
31
18
|
pub fn request() -> Permissions {
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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;
|
|
19
|
+
Library::module("computer")
|
|
20
|
+
.expect("Native computer module is required")
|
|
21
|
+
.call("computer.permissions_request", json!({}))
|
|
22
|
+
.expect("Native permission request failed")
|
|
58
23
|
}
|
package/rust/src/preview.rs
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
//! Independent, bounded window capture; never touches the driver's grounding caches.
|
|
2
1
|
use crate::gateway::Target;
|
|
3
|
-
|
|
2
|
+
use alwith_native_client::Library;
|
|
3
|
+
use serde_json::json;
|
|
4
|
+
#[derive(Clone, Debug, serde::Serialize, specta::Type, serde::Deserialize)]
|
|
4
5
|
#[serde(rename_all = "camelCase")]
|
|
5
6
|
pub struct Frame {
|
|
6
7
|
pub image: String,
|
|
@@ -8,75 +9,12 @@ pub struct Frame {
|
|
|
8
9
|
pub app_name: String,
|
|
9
10
|
pub cursor: Option<CursorPosition>,
|
|
10
11
|
}
|
|
11
|
-
#[derive(Clone, Debug, serde::Serialize, specta::Type)]
|
|
12
|
+
#[derive(Clone, Debug, serde::Serialize, specta::Type, serde::Deserialize)]
|
|
12
13
|
pub struct CursorPosition {
|
|
13
14
|
pub x: f64,
|
|
14
15
|
pub y: f64,
|
|
15
16
|
}
|
|
16
17
|
|
|
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
18
|
pub fn capture(target: &Target, cursor: Option<CursorPosition>) -> Result<Frame, String> {
|
|
28
|
-
|
|
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
|
-
}
|
|
19
|
+
Library::module("computer")?.call("computer.preview_capture", json!({"target":target,"cursor":cursor}))
|
|
82
20
|
}
|