@desklink/host 0.2.0 → 0.3.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.
Files changed (82) hide show
  1. package/NOTICE +26 -0
  2. package/README.md +134 -56
  3. package/bin/desklink-host.mjs +13 -5
  4. package/dist/bridge.d.ts +1 -0
  5. package/dist/bridge.d.ts.map +1 -1
  6. package/dist/bridge.js +12 -4
  7. package/dist/bridge.js.map +1 -1
  8. package/dist/engineProcess.d.ts +6 -1
  9. package/dist/engineProcess.d.ts.map +1 -1
  10. package/dist/engineProcess.js +35 -4
  11. package/dist/engineProcess.js.map +1 -1
  12. package/dist/protocol.d.ts +58 -0
  13. package/dist/protocol.d.ts.map +1 -1
  14. package/dist/protocol.js.map +1 -1
  15. package/dist/resolveEngine.d.ts +7 -1
  16. package/dist/resolveEngine.d.ts.map +1 -1
  17. package/dist/resolveEngine.js +66 -14
  18. package/dist/resolveEngine.js.map +1 -1
  19. package/docs/BOUNDARY.md +15 -7
  20. package/docs/PROTOCOL.md +267 -27
  21. package/engine/Cargo.lock +47 -2
  22. package/engine/Cargo.toml +18 -13
  23. package/engine/build.rs +86 -4
  24. package/engine/native/agent_overlay_mac.m +51 -4
  25. package/engine/native/agent_overlay_wayland.c +145 -12
  26. package/engine/native/agent_overlay_x11.c +72 -0
  27. package/engine/native/nvenc_shim.c +453 -0
  28. package/engine/native/vaapi_shim.c +727 -0
  29. package/engine/native/vpx_shim.c +54 -4
  30. package/engine/native/vt_shim.c +448 -0
  31. package/engine/src/capture.rs +167 -58
  32. package/engine/src/capture_mac.rs +24 -14
  33. package/engine/src/capture_win.rs +23 -0
  34. package/engine/src/clipboard_win.rs +8 -0
  35. package/engine/src/convert.rs +286 -61
  36. package/engine/src/cursor.rs +118 -0
  37. package/engine/src/encoder.rs +122 -13
  38. package/engine/src/h264.rs +188 -0
  39. package/engine/src/h264_nvenc.rs +308 -0
  40. package/engine/src/h264_openh264.rs +735 -0
  41. package/engine/src/h264_vaapi.rs +263 -0
  42. package/engine/src/h264_vt.rs +368 -0
  43. package/engine/src/indicator.rs +104 -3
  44. package/engine/src/input_win.rs +91 -0
  45. package/engine/src/keymap_win.rs +39 -0
  46. package/engine/src/mac.rs +34 -9
  47. package/engine/src/main.rs +186 -26
  48. package/engine/src/peer.rs +466 -28
  49. package/engine/src/portal.rs +42 -4
  50. package/engine/src/portal_win.rs +37 -0
  51. package/engine/src/protocol.rs +64 -2
  52. package/engine/src/session.rs +1252 -105
  53. package/engine/src/win.rs +110 -0
  54. package/engine/src/x11.rs +225 -9
  55. package/engine/src/x11_mac.rs +12 -0
  56. package/engine/vendor/libva/COPYING +19 -0
  57. package/engine/vendor/libva/va/va.h +5450 -0
  58. package/engine/vendor/libva/va/va_dec_av1.h +695 -0
  59. package/engine/vendor/libva/va/va_dec_hevc.h +616 -0
  60. package/engine/vendor/libva/va/va_dec_jpeg.h +157 -0
  61. package/engine/vendor/libva/va/va_dec_vp8.h +249 -0
  62. package/engine/vendor/libva/va/va_dec_vp9.h +312 -0
  63. package/engine/vendor/libva/va/va_dec_vvc.h +688 -0
  64. package/engine/vendor/libva/va/va_drm.h +61 -0
  65. package/engine/vendor/libva/va/va_enc_av1.h +1010 -0
  66. package/engine/vendor/libva/va/va_enc_h264.h +659 -0
  67. package/engine/vendor/libva/va/va_enc_hevc.h +958 -0
  68. package/engine/vendor/libva/va/va_enc_jpeg.h +162 -0
  69. package/engine/vendor/libva/va/va_enc_mpeg2.h +308 -0
  70. package/engine/vendor/libva/va/va_enc_vp8.h +350 -0
  71. package/engine/vendor/libva/va/va_enc_vp9.h +639 -0
  72. package/engine/vendor/libva/va/va_fei.h +170 -0
  73. package/engine/vendor/libva/va/va_fei_h264.h +505 -0
  74. package/engine/vendor/libva/va/va_fei_hevc.h +254 -0
  75. package/engine/vendor/libva/va/va_prot.h +525 -0
  76. package/engine/vendor/libva/va/va_version.h +87 -0
  77. package/engine/vendor/libva/va/va_vpp.h +1590 -0
  78. package/engine/vendor/nv-codec-headers/LICENSE +24 -0
  79. package/engine/vendor/nv-codec-headers/README +7 -0
  80. package/engine/vendor/nv-codec-headers/include/ffnvcodec/nvEncodeAPI.h +3825 -0
  81. package/examples/reference-client.html +1 -1
  82. package/package.json +4 -3
@@ -13,7 +13,7 @@ use pw::spa;
13
13
  use pw::spa::pod::{serialize::PodSerializer, Pod};
14
14
  use std::io::Cursor;
15
15
  use std::os::fd::OwnedFd;
16
- use std::sync::atomic::{AtomicU64, Ordering};
16
+ use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
17
17
  use std::sync::{mpsc, Arc, Mutex};
18
18
  use std::thread::JoinHandle;
19
19
  use std::time::{Duration, Instant};
@@ -44,13 +44,18 @@ pub struct StreamGeometry {
44
44
  pub buffer_type: String,
45
45
  }
46
46
 
47
- pub type FrameSink = Box<dyn Fn(I420, u64, Vec<u8>) + Send + 'static>;
47
+ /// Receives each converted frame, its sequence number and a way to produce the
48
+ /// frame's packed BGRX pixels, which only a local frame consumer needs; the
49
+ /// sink calls it at most once, and only then are the pixels copied.
50
+ pub type FrameSink = Box<dyn Fn(I420, u64, Pixels) + Send + 'static>;
51
+ pub type Pixels<'a> = &'a mut dyn FnMut() -> Option<Vec<u8>>;
48
52
 
49
53
  /// A running capture. Dropping it quits the PipeWire loop and joins its thread.
50
54
  pub struct Capture {
51
55
  quit: pw::channel::Sender<()>,
52
56
  thread: Option<JoinHandle<()>>,
53
57
  pub source: SelectedSource,
58
+ cursor_positions: Arc<AtomicBool>,
54
59
  geometry: Arc<Mutex<Option<StreamGeometry>>>,
55
60
  frames: Arc<AtomicU64>,
56
61
  dropped: Arc<AtomicU64>,
@@ -59,6 +64,9 @@ pub struct Capture {
59
64
  }
60
65
 
61
66
  impl Capture {
67
+ pub fn cursor_positions(&self) -> bool {
68
+ self.cursor_positions.load(Ordering::Relaxed)
69
+ }
62
70
  pub fn geometry(&self) -> Option<StreamGeometry> {
63
71
  self.geometry.lock().ok().and_then(|g| g.clone())
64
72
  }
@@ -189,16 +197,29 @@ fn buffer_pod(buffer: &mut Vec<u8>) -> Result<()> {
189
197
  }
190
198
 
191
199
  /// Start capturing `session`'s node, delivering I420 frames to `sink`.
200
+ #[allow(clippy::too_many_arguments)]
192
201
  pub fn start(
193
202
  session: PortalSession,
194
203
  encoded_width: usize,
195
204
  encoded_height: usize,
196
205
  max_fps: u32,
206
+ cursor_sink: Option<crate::cursor::CursorSink>,
197
207
  sink: FrameSink,
198
208
  indicator_position: Option<Arc<Mutex<Option<(i64, i64)>>>>,
199
209
  on_stop: Box<dyn Fn(String) + Send>,
200
210
  ) -> Result<Capture> {
201
- let PortalSession { fd, source, .. } = session;
211
+ let PortalSession {
212
+ fd,
213
+ source,
214
+ cursor_mode,
215
+ ..
216
+ } = session;
217
+ let cursor_positions = Arc::new(AtomicBool::new(false));
218
+ let cursor_sink = if cursor_mode == "metadata" {
219
+ cursor_sink
220
+ } else {
221
+ None
222
+ };
202
223
  let geometry = Arc::new(Mutex::new(None));
203
224
  let frames = Arc::new(AtomicU64::new(0));
204
225
  let dropped = Arc::new(AtomicU64::new(0));
@@ -206,7 +227,9 @@ pub fn start(
206
227
  let (ready_tx, ready_rx) = mpsc::channel::<Result<(), String>>();
207
228
 
208
229
  let node_id = source.node_id;
230
+ let source_size = (source.width.max(1) as usize, source.height.max(1) as usize);
209
231
  let thread = {
232
+ let cursor_positions = cursor_positions.clone();
210
233
  let geometry = geometry.clone();
211
234
  let frames = frames.clone();
212
235
  let dropped = dropped.clone();
@@ -217,9 +240,12 @@ pub fn start(
217
240
  let result = run_loop(
218
241
  fd,
219
242
  node_id,
243
+ source_size,
220
244
  encoded_width,
221
245
  encoded_height,
222
246
  max_fps,
247
+ cursor_sink,
248
+ cursor_positions,
223
249
  sink,
224
250
  indicator_position,
225
251
  on_stop,
@@ -246,6 +272,7 @@ pub fn start(
246
272
  quit,
247
273
  thread: Some(thread),
248
274
  source,
275
+ cursor_positions,
249
276
  geometry,
250
277
  frames,
251
278
  dropped,
@@ -264,9 +291,12 @@ pub fn start(
264
291
  fn run_loop(
265
292
  fd: OwnedFd,
266
293
  node_id: u32,
294
+ source_size: (usize, usize),
267
295
  encoded_width: usize,
268
296
  encoded_height: usize,
269
297
  max_fps: u32,
298
+ cursor_sink: Option<crate::cursor::CursorSink>,
299
+ cursor_positions: Arc<AtomicBool>,
270
300
  sink: FrameSink,
271
301
  indicator_position: Option<Arc<Mutex<Option<(i64, i64)>>>>,
272
302
  on_stop: Box<dyn Fn(String) + Send>,
@@ -298,6 +328,8 @@ fn run_loop(
298
328
  let stream = pw::stream::StreamBox::new(&core, "desklink-capture", props)
299
329
  .context("pw_stream_new failed")?;
300
330
 
331
+ let wants_cursor_meta = cursor_sink.is_some();
332
+ let mut cursor = cursor_sink.map(crate::cursor::Reporter::new);
301
333
  let mut state = StreamState {
302
334
  box_w: encoded_width,
303
335
  box_h: encoded_height,
@@ -316,14 +348,26 @@ fn run_loop(
316
348
  let mut ready = Some(ready);
317
349
  let _listener = stream
318
350
  .add_local_listener_with_user_data(&mut state)
319
- .state_changed({ let main_loop = main_loop.clone(); move |_, state, old, new| {
320
- let reason = match new {
321
- pw::stream::StreamState::Error(message) => Some(format!("PipeWire stream stopped: {message}")),
322
- pw::stream::StreamState::Unconnected if old == pw::stream::StreamState::Streaming => Some(String::from("PipeWire stream disconnected")),
323
- _ => None,
324
- };
325
- if let Some(reason) = reason { (state.on_stop)(reason); main_loop.quit(); }
326
- } })
351
+ .state_changed({
352
+ let main_loop = main_loop.clone();
353
+ move |_, state, old, new| {
354
+ let reason = match new {
355
+ pw::stream::StreamState::Error(message) => {
356
+ Some(format!("PipeWire stream stopped: {message}"))
357
+ }
358
+ pw::stream::StreamState::Unconnected
359
+ if old == pw::stream::StreamState::Streaming =>
360
+ {
361
+ Some(String::from("PipeWire stream disconnected"))
362
+ }
363
+ _ => None,
364
+ };
365
+ if let Some(reason) = reason {
366
+ (state.on_stop)(reason);
367
+ main_loop.quit();
368
+ }
369
+ }
370
+ })
327
371
  .param_changed(|_, state, id, param| {
328
372
  if id != pw::spa::param::ParamType::Format.as_raw() {
329
373
  return;
@@ -351,6 +395,31 @@ fn run_loop(
351
395
  let Some(mut buffer) = stream.dequeue_buffer() else {
352
396
  return;
353
397
  };
398
+ if let Some(cursor) = cursor.as_mut() {
399
+ if let Some(meta) = buffer.find_meta::<spa::buffer::meta::MetaCursor>() {
400
+ cursor_positions.store(true, Ordering::Relaxed);
401
+ // The wrapper's size check bounds the bitmap header;
402
+ // never follow an unchecked bitmap offset.
403
+ let bitmap = buffer
404
+ .find_meta::<CursorWithBitmap>()
405
+ .filter(|m| {
406
+ m.cursor.bitmap_offset as usize
407
+ == std::mem::size_of::<spa::sys::spa_meta_cursor>()
408
+ })
409
+ .map(|m| &m.bitmap);
410
+ if state.logical_w == 0 || state.logical_h == 0 {
411
+ return;
412
+ }
413
+ let mut position = *meta.as_raw();
414
+ position.position.x = (i64::from(position.position.x) * source_size.0 as i64)
415
+ .div_euclid(state.logical_w as i64)
416
+ as i32;
417
+ position.position.y = (i64::from(position.position.y) * source_size.1 as i64)
418
+ .div_euclid(state.logical_h as i64)
419
+ as i32;
420
+ cursor.update_spa(&position, bitmap, source_size.0, source_size.1);
421
+ }
422
+ }
354
423
  let Some(format) = state.format else { return };
355
424
  let datas = buffer.datas_mut();
356
425
  if datas.is_empty() {
@@ -383,54 +452,58 @@ fn run_loop(
383
452
  // second time and hand the encoder a frame it refuses whenever the
384
453
  // stream's size differs from the one the portal reported.
385
454
  let seq = state.frames.load(Ordering::Relaxed);
386
- let raw = crate::convert::to_bgrx(
387
- chunk_bytes,
388
- w,
389
- h,
390
- stride,
391
- format,
392
- state.box_w,
393
- state.box_h,
394
- );
395
- if let Some(mut raw) = raw {
396
- let frame = if let Some(mask) = state.mask.as_mut() {
397
- mask.apply(&mut raw, state.box_w, state.box_h, w, h);
398
- to_i420(
399
- &raw,
400
- state.box_w,
401
- state.box_h,
402
- state.box_w * 4,
403
- PixelFormat::Bgrx,
404
- state.box_w,
405
- state.box_h,
406
- )
407
- } else {
408
- to_i420(chunk_bytes, w, h, stride, format, state.box_w, state.box_h)
455
+ let (box_w, box_h) = (state.box_w, state.box_h);
456
+ // The capture indicator is masked out of BGRX pixels, so a masked
457
+ // frame is converted from that copy; otherwise straight from the
458
+ // stream, and BGRX is only made if the sink asks for it.
459
+ let mut masked = None;
460
+ let frame = if let Some(mask) = state.mask.as_mut() {
461
+ let Some(mut raw) =
462
+ crate::convert::to_bgrx(chunk_bytes, w, h, stride, format, box_w, box_h)
463
+ else {
464
+ state.dropped.fetch_add(1, Ordering::Relaxed);
465
+ return;
409
466
  };
410
- if let Some(i420) = frame {
411
- if state.geometry.lock().map(|g| g.is_none()).unwrap_or(false) {
412
- if let Ok(mut g) = state.geometry.lock() {
413
- *g = Some(StreamGeometry {
414
- source_width: w,
415
- source_height: h,
416
- origin_x: 0,
417
- origin_y: 0,
418
- format: format!("{format:?}").to_lowercase(),
419
- buffer_type: state.buffer_type.clone(),
420
- });
421
- }
422
- }
423
- state.frames.fetch_add(1, Ordering::Relaxed);
424
- (state.sink)(i420, seq, raw);
425
- // The first frame the loop actually delivers is the only
426
- // proof capture started; sending this when `run_loop`
427
- // returns would be after the main loop quits, too late for
428
- // `start` to wait on.
429
- if let Some(sender) = ready.take() {
430
- let _ = sender.send(Ok(()));
467
+ mask.apply(&mut raw, box_w, box_h, w, h);
468
+ let frame = to_i420(
469
+ &raw,
470
+ box_w,
471
+ box_h,
472
+ box_w * 4,
473
+ PixelFormat::Bgrx,
474
+ box_w,
475
+ box_h,
476
+ );
477
+ masked = Some(raw);
478
+ frame
479
+ } else {
480
+ to_i420(chunk_bytes, w, h, stride, format, box_w, box_h)
481
+ };
482
+ if let Some(i420) = frame {
483
+ if state.geometry.lock().map(|g| g.is_none()).unwrap_or(false) {
484
+ if let Ok(mut g) = state.geometry.lock() {
485
+ *g = Some(StreamGeometry {
486
+ source_width: w,
487
+ source_height: h,
488
+ origin_x: 0,
489
+ origin_y: 0,
490
+ format: format!("{format:?}").to_lowercase(),
491
+ buffer_type: state.buffer_type.clone(),
492
+ });
431
493
  }
432
- } else {
433
- state.dropped.fetch_add(1, Ordering::Relaxed);
494
+ }
495
+ state.frames.fetch_add(1, Ordering::Relaxed);
496
+ (state.sink)(i420, seq, &mut || {
497
+ masked.take().or_else(|| {
498
+ crate::convert::to_bgrx(chunk_bytes, w, h, stride, format, box_w, box_h)
499
+ })
500
+ });
501
+ // The first frame the loop actually delivers is the only
502
+ // proof capture started; sending this when `run_loop`
503
+ // returns would be after the main loop quits, too late for
504
+ // `start` to wait on.
505
+ if let Some(sender) = ready.take() {
506
+ let _ = sender.send(Ok(()));
434
507
  }
435
508
  } else {
436
509
  state.dropped.fetch_add(1, Ordering::Relaxed);
@@ -443,10 +516,15 @@ fn run_loop(
443
516
  format_pod(&mut format_buf, max_fps)?;
444
517
  let mut buffer_buf = Vec::new();
445
518
  buffer_pod(&mut buffer_buf)?;
446
- let mut params = [
519
+ let mut cursor_buf = Vec::new();
520
+ cursor_pod(&mut cursor_buf)?;
521
+ let mut params = vec![
447
522
  Pod::from_bytes(&format_buf).context("invalid EnumFormat pod")?,
448
523
  Pod::from_bytes(&buffer_buf).context("invalid Buffers pod")?,
449
524
  ];
525
+ if wants_cursor_meta {
526
+ params.push(Pod::from_bytes(&cursor_buf).context("invalid Cursor Meta pod")?);
527
+ }
450
528
 
451
529
  stream
452
530
  .connect(
@@ -638,6 +716,7 @@ mod tests {
638
716
  origin_x: 0,
639
717
  origin_y: 0,
640
718
  },
719
+ cursor_positions: Arc::new(AtomicBool::new(false)),
641
720
  geometry: Arc::new(Mutex::new(None)),
642
721
  frames: Arc::new(AtomicU64::new(0)),
643
722
  dropped: Arc::new(AtomicU64::new(0)),
@@ -658,3 +737,33 @@ mod tests {
658
737
  .expect("capture stopped and joined");
659
738
  }
660
739
  }
740
+
741
+ fn cursor_pod(buffer: &mut Vec<u8>) -> Result<()> {
742
+ use spa::pod::{object, property, Value};
743
+ let obj = object!(
744
+ spa::utils::SpaTypes::ObjectParamMeta,
745
+ spa::param::ParamType::Meta,
746
+ property!(
747
+ RawKey(spa::sys::SPA_PARAM_META_type),
748
+ Id,
749
+ RawKey(spa::sys::SPA_META_Cursor)
750
+ ),
751
+ property!(
752
+ RawKey(spa::sys::SPA_PARAM_META_size),
753
+ Int,
754
+ (std::mem::size_of::<CursorWithBitmap>() + 256 * 256 * 4) as i32
755
+ )
756
+ );
757
+ PodSerializer::serialize(Cursor::new(buffer), &Value::Object(obj))
758
+ .map(|_| ())
759
+ .map_err(|e| anyhow::anyhow!("failed to build Cursor Meta pod: {e}"))
760
+ }
761
+
762
+ #[repr(C)]
763
+ struct CursorWithBitmap {
764
+ cursor: spa::sys::spa_meta_cursor,
765
+ bitmap: spa::sys::spa_meta_bitmap,
766
+ }
767
+ impl spa::buffer::meta::Metadata for CursorWithBitmap {
768
+ const META_TYPE: u32 = spa::sys::SPA_META_Cursor;
769
+ }
@@ -4,7 +4,10 @@ use anyhow::{bail, Result};
4
4
  use std::ffi::{c_char, c_void, CStr};
5
5
  use std::sync::Mutex;
6
6
 
7
- pub type FrameSink = Box<dyn Fn(I420, u64, Vec<u8>) + Send + 'static>;
7
+ /// See the Linux `capture::FrameSink`: the closure makes the BGRX copy, only
8
+ /// when the sink asks for it.
9
+ pub type FrameSink = Box<dyn Fn(I420, u64, Pixels) + Send + 'static>;
10
+ pub type Pixels<'a> = &'a mut dyn FnMut() -> Option<Vec<u8>>;
8
11
 
9
12
  struct State {
10
13
  sink: FrameSink,
@@ -41,8 +44,13 @@ unsafe extern "C" {
41
44
  extern "C" fn stream_status(context: *mut c_void, reason: *const c_char, running: bool) {
42
45
  let state = unsafe { &*(context as *const Mutex<State>) };
43
46
  if let Ok(state) = state.lock() {
44
- let reason = if reason.is_null() { String::from("stream stopped") }
45
- else { unsafe { CStr::from_ptr(reason) }.to_string_lossy().into_owned() };
47
+ let reason = if reason.is_null() {
48
+ String::from("stream stopped")
49
+ } else {
50
+ unsafe { CStr::from_ptr(reason) }
51
+ .to_string_lossy()
52
+ .into_owned()
53
+ };
46
54
  (state.status)(running, reason);
47
55
  }
48
56
  }
@@ -67,15 +75,6 @@ extern "C" fn receive_frame(
67
75
  let state = unsafe { &*(context as *const Mutex<State>) };
68
76
  let Ok(mut state) = state.lock() else { return };
69
77
  let source = unsafe { std::slice::from_raw_parts(pixels, length) };
70
- let raw = to_bgrx(
71
- source,
72
- source_width,
73
- source_height,
74
- stride,
75
- PixelFormat::Bgra,
76
- state.width,
77
- state.height,
78
- );
79
78
  let frame = to_i420(
80
79
  source,
81
80
  source_width,
@@ -85,8 +84,19 @@ extern "C" fn receive_frame(
85
84
  state.width,
86
85
  state.height,
87
86
  );
88
- if let (Some(frame), Some(raw)) = (frame, raw) {
89
- (state.sink)(frame, state.sequence, raw);
87
+ if let Some(frame) = frame {
88
+ let (width, height) = (state.width, state.height);
89
+ (state.sink)(frame, state.sequence, &mut || {
90
+ to_bgrx(
91
+ source,
92
+ source_width,
93
+ source_height,
94
+ stride,
95
+ PixelFormat::Bgra,
96
+ width,
97
+ height,
98
+ )
99
+ });
90
100
  state.sequence += 1;
91
101
  }
92
102
  }
@@ -0,0 +1,23 @@
1
+ //! Windows desktop capture is deferred; encoded sources never call this adapter.
2
+ use crate::convert::I420;
3
+ use crate::portal::PortalSession;
4
+ use anyhow::{bail, Result};
5
+
6
+ pub type FrameSink = Box<dyn Fn(I420, u64, Pixels) + Send + 'static>;
7
+ pub type Pixels<'a> = &'a mut dyn FnMut() -> Option<Vec<u8>>;
8
+ pub struct Capture;
9
+
10
+ pub fn unavailable_reason() -> &'static str {
11
+ "Windows display capture is not implemented in this build."
12
+ }
13
+
14
+ pub fn start(
15
+ _session: PortalSession,
16
+ _width: usize,
17
+ _height: usize,
18
+ _fps: u32,
19
+ _sink: FrameSink,
20
+ _status: Box<dyn Fn(bool, String) + Send>,
21
+ ) -> Result<Capture> {
22
+ bail!("{}", unavailable_reason())
23
+ }
@@ -0,0 +1,8 @@
1
+ //! Windows clipboard seam. This build never reads or writes the clipboard.
2
+ pub const MAX_CLIPBOARD_BYTES: usize = 256 * 1024;
3
+ pub fn read_or_explain() -> Result<(String, bool), String> {
4
+ Err("Windows clipboard transfer is not implemented in this build.".into())
5
+ }
6
+ pub fn write(_text: &str) -> anyhow::Result<()> {
7
+ anyhow::bail!("Windows clipboard transfer is not implemented in this build.")
8
+ }