@desklink/host 0.1.0 → 0.1.1
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/NOTICE +3 -0
- package/README.md +50 -16
- package/bin/desklink-host.mjs +13 -2
- package/dist/bridge.d.ts.map +1 -1
- package/dist/bridge.js +5 -1
- package/dist/bridge.js.map +1 -1
- package/dist/engineProcess.d.ts.map +1 -1
- package/dist/engineProcess.js +17 -5
- package/dist/engineProcess.js.map +1 -1
- package/dist/protocol.d.ts +42 -5
- package/dist/protocol.d.ts.map +1 -1
- package/dist/protocol.js +1 -1
- package/dist/protocol.js.map +1 -1
- package/dist/resolveEngine.d.ts +1 -0
- package/dist/resolveEngine.d.ts.map +1 -1
- package/dist/resolveEngine.js +16 -1
- package/dist/resolveEngine.js.map +1 -1
- package/docs/BOUNDARY.md +4 -3
- package/docs/PROTOCOL.md +95 -27
- package/engine/Cargo.lock +195 -1
- package/engine/Cargo.toml +25 -5
- package/engine/build.rs +98 -23
- package/engine/native/agent_overlay_mac.m +133 -0
- package/engine/native/agent_overlay_wayland.c +192 -0
- package/engine/native/agent_overlay_x11.c +103 -0
- package/engine/native/mac_stream.mm +188 -0
- package/engine/src/capture.rs +169 -19
- package/engine/src/capture_mac.rs +145 -0
- package/engine/src/clipboard_mac.rs +182 -0
- package/engine/src/convert.rs +57 -1
- package/engine/src/indicator.rs +162 -0
- package/engine/src/input.rs +12 -0
- package/engine/src/input_mac.rs +312 -0
- package/engine/src/keymap.rs +25 -0
- package/engine/src/keymap_mac.rs +209 -0
- package/engine/src/mac.rs +722 -0
- package/engine/src/main.rs +116 -2
- package/engine/src/portal.rs +4 -0
- package/engine/src/portal_mac.rs +68 -0
- package/engine/src/protocol.rs +48 -1
- package/engine/src/session.rs +556 -62
- package/engine/src/x11.rs +23 -3
- package/engine/src/x11_mac.rs +40 -0
- package/examples/reference-client.html +33 -4
- package/package.json +2 -2
|
@@ -0,0 +1,722 @@
|
|
|
1
|
+
use serde_json::{json, Value};
|
|
2
|
+
use std::os::raw::c_void;
|
|
3
|
+
use std::sync::atomic::{AtomicBool, Ordering};
|
|
4
|
+
use std::sync::{Arc, Condvar, Mutex};
|
|
5
|
+
use std::time::{Duration, Instant};
|
|
6
|
+
|
|
7
|
+
#[repr(C)]
|
|
8
|
+
#[derive(Clone, Copy)]
|
|
9
|
+
struct CGPoint {
|
|
10
|
+
x: f64,
|
|
11
|
+
y: f64,
|
|
12
|
+
}
|
|
13
|
+
#[repr(C)]
|
|
14
|
+
#[derive(Clone, Copy)]
|
|
15
|
+
struct CGSize {
|
|
16
|
+
width: f64,
|
|
17
|
+
height: f64,
|
|
18
|
+
}
|
|
19
|
+
#[repr(C)]
|
|
20
|
+
#[derive(Clone, Copy)]
|
|
21
|
+
struct CGRect {
|
|
22
|
+
origin: CGPoint,
|
|
23
|
+
size: CGSize,
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
type DisplayId = u32;
|
|
27
|
+
|
|
28
|
+
unsafe extern "C" {
|
|
29
|
+
fn desklink_agent_overlay_main(display_id: i32) -> i32;
|
|
30
|
+
fn desklink_image_bgra(image: *const c_void, length: *mut usize, stride: *mut usize)
|
|
31
|
+
-> *mut u8;
|
|
32
|
+
}
|
|
33
|
+
type CfTypeRef = *const c_void;
|
|
34
|
+
type DisplayModeRef = *const c_void;
|
|
35
|
+
|
|
36
|
+
#[link(name = "ApplicationServices", kind = "framework")]
|
|
37
|
+
unsafe extern "C" {
|
|
38
|
+
pub(crate) fn CGPreflightScreenCaptureAccess() -> bool;
|
|
39
|
+
pub(crate) fn CGPreflightPostEventAccess() -> bool;
|
|
40
|
+
pub(crate) fn CGRequestPostEventAccess() -> bool;
|
|
41
|
+
fn CGGetActiveDisplayList(max: u32, displays: *mut DisplayId, count: *mut u32) -> i32;
|
|
42
|
+
fn CGMainDisplayID() -> DisplayId;
|
|
43
|
+
fn CGDisplayPixelsWide(display: DisplayId) -> usize;
|
|
44
|
+
fn CGDisplayPixelsHigh(display: DisplayId) -> usize;
|
|
45
|
+
fn CGDisplayCopyDisplayMode(display: DisplayId) -> DisplayModeRef;
|
|
46
|
+
fn CGDisplayModeGetPixelWidth(mode: DisplayModeRef) -> usize;
|
|
47
|
+
fn CGDisplayModeGetPixelHeight(mode: DisplayModeRef) -> usize;
|
|
48
|
+
fn CGDisplayModeRelease(mode: DisplayModeRef);
|
|
49
|
+
fn CGDisplayBounds(display: DisplayId) -> CGRect;
|
|
50
|
+
fn CGSessionCopyCurrentDictionary() -> CfTypeRef;
|
|
51
|
+
static kCGSSessionOnConsoleKey: CfTypeRef;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
#[link(name = "Carbon", kind = "framework")]
|
|
55
|
+
unsafe extern "C" {
|
|
56
|
+
fn TISCopyCurrentKeyboardLayoutInputSource() -> CfTypeRef;
|
|
57
|
+
fn TISGetInputSourceProperty(source: CfTypeRef, property: CfTypeRef) -> CfTypeRef;
|
|
58
|
+
static kTISPropertyInputSourceID: CfTypeRef;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
#[link(name = "CoreFoundation", kind = "framework")]
|
|
62
|
+
unsafe extern "C" {
|
|
63
|
+
fn CFDictionaryGetValue(dictionary: CfTypeRef, key: CfTypeRef) -> CfTypeRef;
|
|
64
|
+
fn CFBooleanGetValue(value: CfTypeRef) -> bool;
|
|
65
|
+
fn CFStringGetCString(value: CfTypeRef, buffer: *mut i8, size: isize, encoding: u32) -> bool;
|
|
66
|
+
fn CFRelease(value: CfTypeRef);
|
|
67
|
+
fn CFRunLoopRunInMode(mode: CfTypeRef, seconds: f64, return_after_source_handled: u8) -> i32;
|
|
68
|
+
static kCFRunLoopDefaultMode: CfTypeRef;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
pub(crate) fn displays() -> Vec<Value> {
|
|
72
|
+
let mut ids = [0; 16];
|
|
73
|
+
let mut count = 0;
|
|
74
|
+
let result = unsafe { CGGetActiveDisplayList(ids.len() as u32, ids.as_mut_ptr(), &mut count) };
|
|
75
|
+
if result != 0 {
|
|
76
|
+
return Vec::new();
|
|
77
|
+
}
|
|
78
|
+
let main = unsafe { CGMainDisplayID() };
|
|
79
|
+
ids.into_iter().take(count as usize).map(|id| {
|
|
80
|
+
let bounds = unsafe { CGDisplayBounds(id) };
|
|
81
|
+
let mode = unsafe { CGDisplayCopyDisplayMode(id) };
|
|
82
|
+
let (width, height) = if mode.is_null() {
|
|
83
|
+
(unsafe { CGDisplayPixelsWide(id) }, unsafe { CGDisplayPixelsHigh(id) })
|
|
84
|
+
} else {
|
|
85
|
+
let pixels = unsafe { (CGDisplayModeGetPixelWidth(mode), CGDisplayModeGetPixelHeight(mode)) };
|
|
86
|
+
unsafe { CGDisplayModeRelease(mode); }
|
|
87
|
+
pixels
|
|
88
|
+
};
|
|
89
|
+
json!({
|
|
90
|
+
"id": id,
|
|
91
|
+
"width": width,
|
|
92
|
+
"height": height,
|
|
93
|
+
"scale": if bounds.size.width > 0.0 { width as f64 / bounds.size.width } else { 1.0 },
|
|
94
|
+
"points": { "width": bounds.size.width, "height": bounds.size.height },
|
|
95
|
+
"origin": { "x": bounds.origin.x, "y": bounds.origin.y },
|
|
96
|
+
"main": id == main
|
|
97
|
+
})
|
|
98
|
+
}).collect()
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
pub(crate) fn display_geometry(id: DisplayId) -> Option<(f64, f64, f64, f64)> {
|
|
102
|
+
let bounds = unsafe { CGDisplayBounds(id) };
|
|
103
|
+
(bounds.size.width > 0.0 && bounds.size.height > 0.0).then_some((
|
|
104
|
+
bounds.origin.x,
|
|
105
|
+
bounds.origin.y,
|
|
106
|
+
bounds.size.width,
|
|
107
|
+
bounds.size.height,
|
|
108
|
+
))
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
pub(crate) fn on_console() -> bool {
|
|
112
|
+
let dictionary = unsafe { CGSessionCopyCurrentDictionary() };
|
|
113
|
+
if dictionary.is_null() {
|
|
114
|
+
return false;
|
|
115
|
+
}
|
|
116
|
+
let value = unsafe { CFDictionaryGetValue(dictionary, kCGSSessionOnConsoleKey) };
|
|
117
|
+
let result = !value.is_null() && unsafe { CFBooleanGetValue(value) };
|
|
118
|
+
unsafe {
|
|
119
|
+
CFRelease(dictionary);
|
|
120
|
+
}
|
|
121
|
+
result
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
pub(crate) fn tcc_responsible_app_name() -> String {
|
|
125
|
+
if let Ok(executable) = std::env::current_exe() {
|
|
126
|
+
let path = executable.to_string_lossy();
|
|
127
|
+
if let Some((bundle, _)) = path.split_once(".app/Contents/MacOS/") {
|
|
128
|
+
if let Some(name) = std::path::Path::new(bundle)
|
|
129
|
+
.file_name()
|
|
130
|
+
.and_then(|name| name.to_str())
|
|
131
|
+
{
|
|
132
|
+
return name.to_owned();
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
match std::env::var("TERM_PROGRAM").as_deref() {
|
|
137
|
+
Ok("Apple_Terminal") => String::from("Terminal"),
|
|
138
|
+
Ok("iTerm.app") => String::from("iTerm"),
|
|
139
|
+
_ => String::from("Node.js"),
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
pub(crate) fn keyboard_layout() -> String {
|
|
144
|
+
let source = unsafe { TISCopyCurrentKeyboardLayoutInputSource() };
|
|
145
|
+
if source.is_null() {
|
|
146
|
+
return String::from("unknown");
|
|
147
|
+
}
|
|
148
|
+
let value = unsafe { TISGetInputSourceProperty(source, kTISPropertyInputSourceID) };
|
|
149
|
+
let mut buffer = [0i8; 256];
|
|
150
|
+
let copied = !value.is_null()
|
|
151
|
+
&& unsafe {
|
|
152
|
+
CFStringGetCString(
|
|
153
|
+
value,
|
|
154
|
+
buffer.as_mut_ptr(),
|
|
155
|
+
buffer.len() as isize,
|
|
156
|
+
0x0800_0100,
|
|
157
|
+
)
|
|
158
|
+
};
|
|
159
|
+
unsafe {
|
|
160
|
+
CFRelease(source);
|
|
161
|
+
}
|
|
162
|
+
if copied {
|
|
163
|
+
unsafe { std::ffi::CStr::from_ptr(buffer.as_ptr()) }
|
|
164
|
+
.to_string_lossy()
|
|
165
|
+
.into_owned()
|
|
166
|
+
} else {
|
|
167
|
+
String::from("unknown")
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
#[cfg(not(desklink_vpx))]
|
|
172
|
+
fn capabilities() -> Value {
|
|
173
|
+
let capture_granted = unsafe { CGPreflightScreenCaptureAccess() };
|
|
174
|
+
let input_granted = unsafe { CGPreflightPostEventAccess() };
|
|
175
|
+
json!({
|
|
176
|
+
"protocol": 3,
|
|
177
|
+
"engine": format!("desklink-host/{}", env!("CARGO_PKG_VERSION")),
|
|
178
|
+
"platform": "macos",
|
|
179
|
+
"session": { "kind": "quartz", "on_console": on_console() },
|
|
180
|
+
"capture": {
|
|
181
|
+
"mechanism": "screencapturekit",
|
|
182
|
+
"backends": ["screencapturekit"],
|
|
183
|
+
"formats": ["bgra"],
|
|
184
|
+
"cursor": "embedded",
|
|
185
|
+
"audio": false,
|
|
186
|
+
"displays": displays(),
|
|
187
|
+
"grant": if capture_granted { "granted" } else { "missing-screen-recording" },
|
|
188
|
+
"unavailable_reason": if capture_granted { Value::Null } else { json!({
|
|
189
|
+
"reason": format!("Screen Recording permission has not been granted to {}.", tcc_responsible_app_name()),
|
|
190
|
+
"remedy": format!("Allow {} in System Settings › Privacy & Security › Screen & System Audio Recording, then reconnect.", tcc_responsible_app_name())
|
|
191
|
+
}) }
|
|
192
|
+
},
|
|
193
|
+
"encode": { "codecs": [], "hardware": false },
|
|
194
|
+
"input": {
|
|
195
|
+
"mechanism": "quartz-cgevent",
|
|
196
|
+
"pointer": input_granted,
|
|
197
|
+
"wheel": input_granted,
|
|
198
|
+
"keyboard": input_granted,
|
|
199
|
+
"text": ["unicode", "us-ansi-keymap"],
|
|
200
|
+
"layout": keyboard_layout(),
|
|
201
|
+
"grant": if input_granted { "granted" } else { "missing-accessibility" },
|
|
202
|
+
"unavailable_reason": if input_granted { Value::Null } else { json!({
|
|
203
|
+
"reason": "Accessibility permission has not been granted to desklink-host.",
|
|
204
|
+
"remedy": "Allow desklink-host in System Settings › Privacy & Security › Accessibility on the Mac, then reconnect."
|
|
205
|
+
}) }
|
|
206
|
+
},
|
|
207
|
+
"clipboard": { "read": true, "write": true, "mime": ["text/plain;charset=utf-8"], "maxBytes": crate::clipboard::MAX_CLIPBOARD_BYTES }
|
|
208
|
+
})
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
#[cfg(not(desklink_macos_cli))]
|
|
212
|
+
fn ensure_disclaimed() -> Result<(), String> {
|
|
213
|
+
if std::env::var("DESKLINK_DISCLAIMED").as_deref() == Ok("1") {
|
|
214
|
+
return Ok(());
|
|
215
|
+
}
|
|
216
|
+
unsafe {
|
|
217
|
+
let mut attributes: libc::posix_spawnattr_t = std::ptr::null_mut();
|
|
218
|
+
if libc::posix_spawnattr_init(&mut attributes) != 0 {
|
|
219
|
+
return Err("could not initialize macOS spawn attributes".into());
|
|
220
|
+
}
|
|
221
|
+
let symbol = libc::dlsym(
|
|
222
|
+
libc::RTLD_DEFAULT,
|
|
223
|
+
b"responsibility_spawnattrs_setdisclaim\0".as_ptr().cast(),
|
|
224
|
+
);
|
|
225
|
+
if symbol.is_null() {
|
|
226
|
+
libc::posix_spawnattr_destroy(&mut attributes);
|
|
227
|
+
return Err("macOS process-disclaim API is unavailable; refusing to run with the parent process TCC identity".into());
|
|
228
|
+
}
|
|
229
|
+
let set_disclaim: unsafe extern "C" fn(
|
|
230
|
+
*mut libc::posix_spawnattr_t,
|
|
231
|
+
libc::c_int,
|
|
232
|
+
) -> libc::c_int = std::mem::transmute(symbol);
|
|
233
|
+
let flags_ok = libc::posix_spawnattr_setflags(
|
|
234
|
+
&mut attributes,
|
|
235
|
+
libc::POSIX_SPAWN_SETEXEC as libc::c_short,
|
|
236
|
+
) == 0;
|
|
237
|
+
let disclaim_ok = set_disclaim(&mut attributes, 1) == 0;
|
|
238
|
+
if !flags_ok || !disclaim_ok {
|
|
239
|
+
libc::posix_spawnattr_destroy(&mut attributes);
|
|
240
|
+
return Err("could not configure macOS process-disclaim attributes".into());
|
|
241
|
+
}
|
|
242
|
+
let executable = std::env::current_exe().map_err(|e| e.to_string())?;
|
|
243
|
+
let executable_c = std::ffi::CString::new(executable.as_os_str().as_encoded_bytes())
|
|
244
|
+
.map_err(|e| e.to_string())?;
|
|
245
|
+
let mut argv: Vec<std::ffi::CString> = std::env::args_os()
|
|
246
|
+
.map(|s| std::ffi::CString::new(s.as_encoded_bytes()).map_err(|e| e.to_string()))
|
|
247
|
+
.collect::<Result<_, _>>()?;
|
|
248
|
+
let mut env: Vec<std::ffi::CString> = std::env::vars_os()
|
|
249
|
+
.filter(|(k, _)| k != "DESKLINK_DISCLAIMED")
|
|
250
|
+
.map(|(k, v)| {
|
|
251
|
+
std::ffi::CString::new(format!("{}={}", k.to_string_lossy(), v.to_string_lossy()))
|
|
252
|
+
.map_err(|e| e.to_string())
|
|
253
|
+
})
|
|
254
|
+
.collect::<Result<_, _>>()?;
|
|
255
|
+
env.push(std::ffi::CString::new("DESKLINK_DISCLAIMED=1").unwrap());
|
|
256
|
+
let mut argv_ptrs: Vec<*mut libc::c_char> =
|
|
257
|
+
argv.iter_mut().map(|s| s.as_ptr() as *mut _).collect();
|
|
258
|
+
argv_ptrs.push(std::ptr::null_mut());
|
|
259
|
+
let mut env_ptrs: Vec<*mut libc::c_char> =
|
|
260
|
+
env.iter_mut().map(|s| s.as_ptr() as *mut _).collect();
|
|
261
|
+
env_ptrs.push(std::ptr::null_mut());
|
|
262
|
+
let mut child = 0;
|
|
263
|
+
let error = libc::posix_spawn(
|
|
264
|
+
&mut child,
|
|
265
|
+
executable_c.as_ptr(),
|
|
266
|
+
std::ptr::null(),
|
|
267
|
+
&attributes,
|
|
268
|
+
argv_ptrs.as_ptr(),
|
|
269
|
+
env_ptrs.as_ptr(),
|
|
270
|
+
);
|
|
271
|
+
libc::posix_spawnattr_destroy(&mut attributes);
|
|
272
|
+
if error != 0 {
|
|
273
|
+
return Err(format!("disclaimed self-exec failed: {error}"));
|
|
274
|
+
}
|
|
275
|
+
// SETEXEC normally replaces this process. If the platform instead
|
|
276
|
+
// returns with a child, wait and preserve its exit status.
|
|
277
|
+
let mut status = 0;
|
|
278
|
+
if libc::waitpid(child, &mut status, 0) < 0 {
|
|
279
|
+
return Err("could not wait for disclaimed engine process".into());
|
|
280
|
+
}
|
|
281
|
+
std::process::exit(if libc::WIFEXITED(status) {
|
|
282
|
+
libc::WEXITSTATUS(status)
|
|
283
|
+
} else {
|
|
284
|
+
128 + libc::WTERMSIG(status)
|
|
285
|
+
});
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
pub(crate) fn request_screen_capture_content() -> Result<(), String> {
|
|
290
|
+
use block2::RcBlock;
|
|
291
|
+
use objc2_screen_capture_kit::SCShareableContent;
|
|
292
|
+
|
|
293
|
+
let completed = Arc::new(AtomicBool::new(false));
|
|
294
|
+
let callback_state = completed.clone();
|
|
295
|
+
let completion = RcBlock::new(move |_content, _error| {
|
|
296
|
+
callback_state.store(true, Ordering::Release);
|
|
297
|
+
});
|
|
298
|
+
unsafe { SCShareableContent::getShareableContentWithCompletionHandler(&completion) };
|
|
299
|
+
let deadline = Instant::now() + Duration::from_secs(60);
|
|
300
|
+
while !completed.load(Ordering::Acquire) && Instant::now() < deadline {
|
|
301
|
+
let remaining = deadline.saturating_duration_since(Instant::now());
|
|
302
|
+
unsafe {
|
|
303
|
+
CFRunLoopRunInMode(
|
|
304
|
+
kCFRunLoopDefaultMode,
|
|
305
|
+
remaining.min(Duration::from_millis(100)).as_secs_f64(),
|
|
306
|
+
0,
|
|
307
|
+
)
|
|
308
|
+
};
|
|
309
|
+
}
|
|
310
|
+
if completed.load(Ordering::Acquire) {
|
|
311
|
+
Ok(())
|
|
312
|
+
} else {
|
|
313
|
+
Err(String::from(
|
|
314
|
+
"capture-permission: timed out waiting for ScreenCaptureKit permission request",
|
|
315
|
+
))
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
#[cfg(desklink_vpx)]
|
|
320
|
+
fn encode_probe() -> Result<(), String> {
|
|
321
|
+
let mut frame = crate::convert::I420 {
|
|
322
|
+
width: 640,
|
|
323
|
+
height: 360,
|
|
324
|
+
data: vec![96; 640 * 360 + 2 * 320 * 180],
|
|
325
|
+
};
|
|
326
|
+
frame.data[640 * 360..].fill(128);
|
|
327
|
+
let mut encoder = crate::encoder::Encoder::new(640, 360, 1000, 30, 1)
|
|
328
|
+
.map_err(|error| format!("VP9 encoder initialization failed: {error:#}"))?;
|
|
329
|
+
let packet = encoder
|
|
330
|
+
.encode(&frame, true)
|
|
331
|
+
.map_err(|error| format!("VP9 encoding failed: {error:#}"))?;
|
|
332
|
+
println!(
|
|
333
|
+
"{}",
|
|
334
|
+
serde_json::json!({"codec":"vp9", "width":640, "height":360, "bytes":packet.data.len(), "keyframe":packet.keyframe})
|
|
335
|
+
);
|
|
336
|
+
Ok(())
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
pub(crate) fn capture_display(
|
|
340
|
+
display_id: DisplayId,
|
|
341
|
+
) -> Result<(Vec<u8>, usize, usize, usize), String> {
|
|
342
|
+
use block2::RcBlock;
|
|
343
|
+
use objc2_core_foundation::{
|
|
344
|
+
CGPoint as NativePoint, CGRect as NativeRect, CGSize as NativeSize,
|
|
345
|
+
};
|
|
346
|
+
use objc2_screen_capture_kit::SCScreenshotManager;
|
|
347
|
+
|
|
348
|
+
let bounds = unsafe { CGDisplayBounds(display_id) };
|
|
349
|
+
let rect = NativeRect {
|
|
350
|
+
origin: NativePoint {
|
|
351
|
+
x: bounds.origin.x,
|
|
352
|
+
y: bounds.origin.y,
|
|
353
|
+
},
|
|
354
|
+
size: NativeSize {
|
|
355
|
+
width: bounds.size.width,
|
|
356
|
+
height: bounds.size.height,
|
|
357
|
+
},
|
|
358
|
+
};
|
|
359
|
+
let ready = Arc::new((
|
|
360
|
+
Mutex::new(None::<Result<(Vec<u8>, usize, usize, usize), String>>),
|
|
361
|
+
Condvar::new(),
|
|
362
|
+
));
|
|
363
|
+
let callback_state = ready.clone();
|
|
364
|
+
let callback = RcBlock::new(
|
|
365
|
+
move |image: *mut objc2_core_graphics::CGImage, error: *mut objc2_foundation::NSError| {
|
|
366
|
+
let frame = if !error.is_null() || image.is_null() {
|
|
367
|
+
Err(String::from(
|
|
368
|
+
"ScreenCaptureKit returned an error or no image",
|
|
369
|
+
))
|
|
370
|
+
} else {
|
|
371
|
+
let image = unsafe { &*image };
|
|
372
|
+
let width = objc2_core_graphics::CGImage::width(Some(image));
|
|
373
|
+
let height = objc2_core_graphics::CGImage::height(Some(image));
|
|
374
|
+
let mut length = 0;
|
|
375
|
+
let mut stride = 0;
|
|
376
|
+
let pointer = unsafe {
|
|
377
|
+
desklink_image_bgra(
|
|
378
|
+
image as *const _ as *const c_void,
|
|
379
|
+
&mut length,
|
|
380
|
+
&mut stride,
|
|
381
|
+
)
|
|
382
|
+
};
|
|
383
|
+
if pointer.is_null() || width == 0 || height == 0 {
|
|
384
|
+
Err(String::from(
|
|
385
|
+
"ScreenCaptureKit image could not be converted to BGRA",
|
|
386
|
+
))
|
|
387
|
+
} else {
|
|
388
|
+
let bytes = unsafe { std::slice::from_raw_parts(pointer, length) }.to_vec();
|
|
389
|
+
unsafe { libc::free(pointer.cast()) };
|
|
390
|
+
Ok((bytes, width, height, stride))
|
|
391
|
+
}
|
|
392
|
+
};
|
|
393
|
+
let (lock, wake) = &*callback_state;
|
|
394
|
+
if let Ok(mut result) = lock.lock() {
|
|
395
|
+
*result = Some(frame);
|
|
396
|
+
wake.notify_one();
|
|
397
|
+
}
|
|
398
|
+
},
|
|
399
|
+
);
|
|
400
|
+
unsafe {
|
|
401
|
+
SCScreenshotManager::captureImageInRect_completionHandler(rect, Some(&callback));
|
|
402
|
+
}
|
|
403
|
+
let deadline = Instant::now() + Duration::from_secs(5);
|
|
404
|
+
let (lock, wake) = &*ready;
|
|
405
|
+
let mut result = lock
|
|
406
|
+
.lock()
|
|
407
|
+
.map_err(|_| String::from("capture lock poisoned"))?;
|
|
408
|
+
while result.is_none() && Instant::now() < deadline {
|
|
409
|
+
let (next, _) = wake
|
|
410
|
+
.wait_timeout(result, deadline.saturating_duration_since(Instant::now()))
|
|
411
|
+
.map_err(|_| String::from("capture wait failed"))?;
|
|
412
|
+
result = next;
|
|
413
|
+
}
|
|
414
|
+
result
|
|
415
|
+
.take()
|
|
416
|
+
.unwrap_or_else(|| Err(String::from("ScreenCaptureKit timed out")))
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
#[cfg(all(desklink_vpx, not(desklink_macos_cli)))]
|
|
420
|
+
fn indicator_demo(display_id: u32, path: &str) -> Result<(), String> {
|
|
421
|
+
use std::io::Write;
|
|
422
|
+
let (width, height) = (unsafe { CGDisplayPixelsWide(display_id) }, unsafe {
|
|
423
|
+
CGDisplayPixelsHigh(display_id)
|
|
424
|
+
});
|
|
425
|
+
let mut indicator = crate::indicator::Indicator::start(display_id, width, height)
|
|
426
|
+
.map_err(|error| error.to_string())?;
|
|
427
|
+
let (out_w, out_h) = crate::convert::fit(width, height, 960, 640);
|
|
428
|
+
let mut file = std::fs::File::create(path).map_err(|error| error.to_string())?;
|
|
429
|
+
for frame in 0..56 {
|
|
430
|
+
let t = frame as f64 / 55.0;
|
|
431
|
+
let x = ((0.15 + t * 0.7) * width as f64) as i64;
|
|
432
|
+
let y = ((0.35 + (t * std::f64::consts::TAU).sin() * 0.13) * height as f64) as i64;
|
|
433
|
+
indicator.event(if frame == 14 || frame == 35 { 'C' } else { 'M' }, x, y);
|
|
434
|
+
if frame == 24 {
|
|
435
|
+
indicator.event('T', -1, -1);
|
|
436
|
+
}
|
|
437
|
+
let (pixels, w, h, stride) = capture_display(display_id)?;
|
|
438
|
+
if frame == 0 {
|
|
439
|
+
eprintln!(
|
|
440
|
+
"demo capture: {w}x{h} stride={stride} bytes={}",
|
|
441
|
+
pixels.len()
|
|
442
|
+
);
|
|
443
|
+
}
|
|
444
|
+
let raw = crate::convert::to_bgrx(
|
|
445
|
+
&pixels,
|
|
446
|
+
w,
|
|
447
|
+
h,
|
|
448
|
+
stride,
|
|
449
|
+
crate::convert::PixelFormat::Bgra,
|
|
450
|
+
out_w,
|
|
451
|
+
out_h,
|
|
452
|
+
)
|
|
453
|
+
.ok_or_else(|| String::from("could not scale demo frame"))?;
|
|
454
|
+
file.write_all(&raw).map_err(|error| error.to_string())?;
|
|
455
|
+
std::thread::sleep(Duration::from_millis(65));
|
|
456
|
+
}
|
|
457
|
+
drop(indicator);
|
|
458
|
+
println!("{}x{} frames=56 path={path}", out_w, out_h);
|
|
459
|
+
Ok(())
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
#[cfg(all(desklink_vpx, not(desklink_macos_cli)))]
|
|
463
|
+
fn record_agent_overlay(path: &str) -> Result<(), String> {
|
|
464
|
+
use std::io::Write;
|
|
465
|
+
let display = unsafe { CGMainDisplayID() };
|
|
466
|
+
let (w, h) = crate::convert::fit(unsafe { CGDisplayPixelsWide(display) },
|
|
467
|
+
unsafe { CGDisplayPixelsHigh(display) }, 960, 640);
|
|
468
|
+
let mut file = std::fs::File::create(path).map_err(|error| error.to_string())?;
|
|
469
|
+
for _ in 0..30 {
|
|
470
|
+
let (pixels, sw, sh, stride) = capture_display(display)?;
|
|
471
|
+
let raw = crate::convert::to_bgrx(&pixels, sw, sh, stride,
|
|
472
|
+
crate::convert::PixelFormat::Bgra, w, h)
|
|
473
|
+
.ok_or_else(|| String::from("could not scale recording frame"))?;
|
|
474
|
+
file.write_all(&raw).map_err(|error| error.to_string())?;
|
|
475
|
+
std::thread::sleep(Duration::from_millis(100));
|
|
476
|
+
}
|
|
477
|
+
println!("{w}x{h} frames=30 path={path}");
|
|
478
|
+
Ok(())
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
fn capture_probe(seconds: u64) -> Result<Value, String> {
|
|
482
|
+
use block2::RcBlock;
|
|
483
|
+
use objc2_core_foundation::{
|
|
484
|
+
CGPoint as NativePoint, CGRect as NativeRect, CGSize as NativeSize,
|
|
485
|
+
};
|
|
486
|
+
use objc2_screen_capture_kit::SCScreenshotManager;
|
|
487
|
+
|
|
488
|
+
let bounds = unsafe { CGDisplayBounds(CGMainDisplayID()) };
|
|
489
|
+
let rect = NativeRect {
|
|
490
|
+
origin: NativePoint {
|
|
491
|
+
x: bounds.origin.x,
|
|
492
|
+
y: bounds.origin.y,
|
|
493
|
+
},
|
|
494
|
+
size: NativeSize {
|
|
495
|
+
width: bounds.size.width,
|
|
496
|
+
height: bounds.size.height,
|
|
497
|
+
},
|
|
498
|
+
};
|
|
499
|
+
let ready = Arc::new((
|
|
500
|
+
Mutex::new(None::<Result<(usize, usize, u64), String>>),
|
|
501
|
+
Condvar::new(),
|
|
502
|
+
));
|
|
503
|
+
let callback_state = ready.clone();
|
|
504
|
+
let callback = RcBlock::new(
|
|
505
|
+
move |image: *mut objc2_core_graphics::CGImage, error: *mut objc2_foundation::NSError| {
|
|
506
|
+
let frame = if !error.is_null() || image.is_null() {
|
|
507
|
+
Err(String::from(
|
|
508
|
+
"ScreenCaptureKit returned an error or no image",
|
|
509
|
+
))
|
|
510
|
+
} else {
|
|
511
|
+
let image = unsafe { &*image };
|
|
512
|
+
let provider = objc2_core_graphics::CGImage::data_provider(Some(image));
|
|
513
|
+
let data = provider
|
|
514
|
+
.as_deref()
|
|
515
|
+
.and_then(|provider| objc2_core_graphics::CGDataProvider::data(Some(provider)));
|
|
516
|
+
match data {
|
|
517
|
+
Some(data) => {
|
|
518
|
+
let length = data.length().max(0) as usize;
|
|
519
|
+
let pointer = data.byte_ptr();
|
|
520
|
+
if length == 0 || pointer.is_null() {
|
|
521
|
+
Err(String::from("ScreenCaptureKit image has empty pixel data"))
|
|
522
|
+
} else {
|
|
523
|
+
let bytes = unsafe { std::slice::from_raw_parts(pointer, length) };
|
|
524
|
+
let mut hash = 0xcbf29ce484222325u64;
|
|
525
|
+
for byte in bytes {
|
|
526
|
+
hash = (hash ^ *byte as u64).wrapping_mul(0x100000001b3);
|
|
527
|
+
}
|
|
528
|
+
Ok((
|
|
529
|
+
objc2_core_graphics::CGImage::width(Some(image)),
|
|
530
|
+
objc2_core_graphics::CGImage::height(Some(image)),
|
|
531
|
+
hash,
|
|
532
|
+
))
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
None => Err(String::from("ScreenCaptureKit image has no pixel data")),
|
|
536
|
+
}
|
|
537
|
+
};
|
|
538
|
+
let (lock, wake) = &*callback_state;
|
|
539
|
+
if let Ok(mut result) = lock.lock() {
|
|
540
|
+
*result = Some(frame);
|
|
541
|
+
wake.notify_one();
|
|
542
|
+
}
|
|
543
|
+
},
|
|
544
|
+
);
|
|
545
|
+
let deadline = Instant::now() + Duration::from_secs(seconds.max(1));
|
|
546
|
+
let mut frames = 0u64;
|
|
547
|
+
let mut dimensions = (0, 0);
|
|
548
|
+
let mut distinct_frames = std::collections::BTreeSet::new();
|
|
549
|
+
while Instant::now() < deadline {
|
|
550
|
+
unsafe {
|
|
551
|
+
SCScreenshotManager::captureImageInRect_completionHandler(rect, Some(&callback));
|
|
552
|
+
}
|
|
553
|
+
let (lock, wake) = &*ready;
|
|
554
|
+
let mut result = lock
|
|
555
|
+
.lock()
|
|
556
|
+
.map_err(|_| String::from("capture-probe lock poisoned"))?;
|
|
557
|
+
while result.is_none() && Instant::now() < deadline {
|
|
558
|
+
let (next, _) = wake
|
|
559
|
+
.wait_timeout(result, Duration::from_millis(100))
|
|
560
|
+
.map_err(|_| String::from("capture-probe wait failed"))?;
|
|
561
|
+
result = next;
|
|
562
|
+
}
|
|
563
|
+
if let Some(frame) = result.take() {
|
|
564
|
+
let (width, height, hash) = frame?;
|
|
565
|
+
dimensions = (width, height);
|
|
566
|
+
distinct_frames.insert(hash);
|
|
567
|
+
frames += 1;
|
|
568
|
+
}
|
|
569
|
+
std::thread::sleep(Duration::from_millis(33));
|
|
570
|
+
}
|
|
571
|
+
if frames == 0 {
|
|
572
|
+
return Err(String::from("ScreenCaptureKit delivered no frames"));
|
|
573
|
+
}
|
|
574
|
+
Ok(
|
|
575
|
+
serde_json::json!({"frames": frames, "distinct_frames": distinct_frames.len(), "source": {"kind":"screencapturekit", "width":dimensions.0, "height":dimensions.1}}),
|
|
576
|
+
)
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
pub fn run() -> i32 {
|
|
580
|
+
let mut args = std::env::args().skip(1);
|
|
581
|
+
let command = args.next().unwrap_or_else(|| "help".into());
|
|
582
|
+
if matches!(
|
|
583
|
+
command.as_str(),
|
|
584
|
+
"serve" | "capabilities" | "capture-probe" | "indicator-demo" | "axi-record"
|
|
585
|
+
) {
|
|
586
|
+
#[cfg(not(desklink_macos_cli))]
|
|
587
|
+
if let Err(error) = ensure_disclaimed() {
|
|
588
|
+
eprintln!("error: {error}");
|
|
589
|
+
return 1;
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
match command.as_str() {
|
|
593
|
+
"axi-bridge" => {
|
|
594
|
+
let Some(cli) = args.next() else { eprintln!("usage: desklink-host axi-bridge <absolute desklink-axi.js> [start flags]"); return 2; };
|
|
595
|
+
let engine = match std::env::current_exe() { Ok(path) => path, Err(error) => { eprintln!("engine path: {error}"); return 1; } };
|
|
596
|
+
let result = std::process::Command::new("/opt/homebrew/bin/node")
|
|
597
|
+
.arg(cli).arg("--bridge").args(args)
|
|
598
|
+
.env("DESKLINK_AXI_ENGINE", engine).env("DESKLINK_MACOS", "1")
|
|
599
|
+
.status();
|
|
600
|
+
return match result { Ok(status) => status.code().unwrap_or(1), Err(error) => { eprintln!("axi bridge: {error}"); 1 } };
|
|
601
|
+
}
|
|
602
|
+
"agent-overlay" => {
|
|
603
|
+
let display = args.next().and_then(|value| value.parse().ok()).unwrap_or(0);
|
|
604
|
+
return unsafe { desklink_agent_overlay_main(display) };
|
|
605
|
+
}
|
|
606
|
+
"version" | "--version" | "-V" => println!("{}", env!("CARGO_PKG_VERSION")),
|
|
607
|
+
"capabilities" => {
|
|
608
|
+
#[cfg(desklink_vpx)]
|
|
609
|
+
let value = crate::session::capabilities();
|
|
610
|
+
#[cfg(not(desklink_vpx))]
|
|
611
|
+
let value = capabilities();
|
|
612
|
+
println!("{}", serde_json::to_string_pretty(&value).unwrap());
|
|
613
|
+
}
|
|
614
|
+
"serve" => {
|
|
615
|
+
#[cfg(desklink_vpx)]
|
|
616
|
+
{
|
|
617
|
+
let runtime = match tokio::runtime::Builder::new_multi_thread().enable_all().build() {
|
|
618
|
+
Ok(runtime) => runtime,
|
|
619
|
+
Err(error) => { eprintln!("error: cannot start the runtime: {error}"); return 1; }
|
|
620
|
+
};
|
|
621
|
+
return crate::report(runtime.block_on(crate::serve()));
|
|
622
|
+
}
|
|
623
|
+
#[cfg(not(desklink_vpx))]
|
|
624
|
+
{
|
|
625
|
+
eprintln!("serve requires a VP9-enabled build; set DESKLINK_VPX_STATIC_DIR and rebuild");
|
|
626
|
+
return 1;
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
"encode-probe" => {
|
|
630
|
+
#[cfg(desklink_vpx)]
|
|
631
|
+
if let Err(error) = encode_probe() {
|
|
632
|
+
eprintln!("encode-probe: {error}");
|
|
633
|
+
return 1;
|
|
634
|
+
}
|
|
635
|
+
#[cfg(not(desklink_vpx))]
|
|
636
|
+
{
|
|
637
|
+
eprintln!("encode-probe requires DESKLINK_VPX_STATIC_DIR");
|
|
638
|
+
return 1;
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
#[cfg(all(desklink_vpx, not(desklink_macos_cli)))]
|
|
642
|
+
"axi-record" => {
|
|
643
|
+
let Some(path) = args.next() else { eprintln!("axi-record requires an output path"); return 2; };
|
|
644
|
+
if let Err(error) = record_agent_overlay(&path) { eprintln!("axi-record: {error}"); return 1; }
|
|
645
|
+
}
|
|
646
|
+
#[cfg(all(desklink_vpx, not(desklink_macos_cli)))]
|
|
647
|
+
"indicator-demo" => {
|
|
648
|
+
let display = args.next().and_then(|value| value.parse().ok()).unwrap_or(0);
|
|
649
|
+
let path = args.next().unwrap_or_else(|| String::from("indicator-demo.bgrx"));
|
|
650
|
+
if let Err(error) = indicator_demo(display, &path) {
|
|
651
|
+
eprintln!("indicator-demo: {error}"); return 1;
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
"capture-probe" => {
|
|
655
|
+
let mut seconds = 3;
|
|
656
|
+
let mut output_path = None;
|
|
657
|
+
while let Some(argument) = args.next() {
|
|
658
|
+
if argument == "--out" {
|
|
659
|
+
output_path = args.next();
|
|
660
|
+
} else if let Ok(value) = argument.parse() {
|
|
661
|
+
seconds = value;
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
let result = if unsafe { CGPreflightScreenCaptureAccess() } {
|
|
665
|
+
capture_probe(seconds)
|
|
666
|
+
} else {
|
|
667
|
+
request_screen_capture_content().and_then(|_| Err(format!("capture-permission: allow {} in System Settings › Privacy & Security › Screen & System Audio Recording, then reconnect", tcc_responsible_app_name())))
|
|
668
|
+
};
|
|
669
|
+
let output = result.as_ref().map_or_else(
|
|
670
|
+
|error| serde_json::json!({"error": error}),
|
|
671
|
+
|value| value.clone(),
|
|
672
|
+
);
|
|
673
|
+
if let Some(path) = output_path {
|
|
674
|
+
if let Err(error) = std::fs::write(&path, serde_json::to_vec_pretty(&output).unwrap()) {
|
|
675
|
+
eprintln!("capture-probe: could not write {path}: {error}");
|
|
676
|
+
return 1;
|
|
677
|
+
}
|
|
678
|
+
} else {
|
|
679
|
+
println!("{}", serde_json::to_string_pretty(&output).unwrap());
|
|
680
|
+
}
|
|
681
|
+
if let Err(error) = result {
|
|
682
|
+
eprintln!("capture-probe: {error}");
|
|
683
|
+
return 1;
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
"setup-input" => {
|
|
687
|
+
if crate::input::probe().is_ok() {
|
|
688
|
+
println!("Accessibility permission is already granted to {}.", tcc_responsible_app_name());
|
|
689
|
+
} else {
|
|
690
|
+
let _ = crate::input::request_access();
|
|
691
|
+
println!("If the Accessibility alert did not open, allow {} in System Settings › Privacy & Security › Accessibility.", tcc_responsible_app_name());
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
_ => println!("desklink-host {}\nUSAGE: desklink-host [serve|capabilities|capture-probe|encode-probe|setup-input|version]", env!("CARGO_PKG_VERSION")),
|
|
695
|
+
}
|
|
696
|
+
0
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
#[cfg(test)]
|
|
700
|
+
mod tests {
|
|
701
|
+
use super::*;
|
|
702
|
+
|
|
703
|
+
#[cfg(desklink_vpx)]
|
|
704
|
+
#[test]
|
|
705
|
+
fn session_capabilities_are_non_prompting_and_expose_mac_features() {
|
|
706
|
+
let value = crate::session::capabilities();
|
|
707
|
+
assert_eq!(value["protocol"], crate::protocol::PROTOCOL_VERSION);
|
|
708
|
+
assert_eq!(value["platform"], "macos");
|
|
709
|
+
assert_eq!(value["capture"]["mechanism"], "screencapturekit");
|
|
710
|
+
assert_eq!(value["encode"]["codecs"], json!(["vp9"]));
|
|
711
|
+
assert!(value["capture"]["displays"].is_array());
|
|
712
|
+
assert!(value["clipboard"]["read"].as_bool().unwrap());
|
|
713
|
+
assert!(matches!(
|
|
714
|
+
value["capture"]["grant"].as_str(),
|
|
715
|
+
Some("granted" | "missing-screen-recording")
|
|
716
|
+
));
|
|
717
|
+
assert!(matches!(
|
|
718
|
+
value["input"]["grant"].as_str(),
|
|
719
|
+
Some("granted" | "missing-accessibility")
|
|
720
|
+
));
|
|
721
|
+
}
|
|
722
|
+
}
|