@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/rust/src/lib.rs CHANGED
@@ -1,23 +1,13 @@
1
- //! Application-owned Cua Driver lifecycle. No agent, UI, or conversation storage.
1
+ //! Computer-use types and ABI transport.
2
2
  pub mod gateway;
3
3
  pub mod permissions;
4
4
  pub mod preview;
5
- mod wire;
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
- const START_TIMEOUT: Duration = Duration::from_secs(12);
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
- 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,
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
- 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
- }
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.cursor_theme = Some((id, bytes));
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
- 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
- })
48
+ self.handle.call("status", json!({}))
95
49
  }
96
-
97
50
  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
- }))
51
+ self.handle.call("connection", json!({"session":session}))
126
52
  }
127
-
128
53
  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()
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
- 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(())
57
+ self.handle.call_async("stop", json!({"session":session})).await
277
58
  }
278
-
279
59
  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
60
+ self.handle.call_async("shutdown", json!({})).await
283
61
  }
284
-
285
62
  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(())
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.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"));
66
+ self.handle.call::<()>("kill_on_exit", json!({})).expect("Native computer exit failed");
407
67
  }
408
68
  }
@@ -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
- #[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
- }
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
- #[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;
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
  }
@@ -1,6 +1,7 @@
1
- //! Independent, bounded window capture; never touches the driver's grounding caches.
2
1
  use crate::gateway::Target;
3
- #[derive(Clone, Debug, serde::Serialize, specta::Type)]
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
- 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
- }
19
+ Library::module("computer")?.call("computer.preview_capture", json!({"target":target,"cursor":cursor}))
82
20
  }