1use serde::Serialize;2use sigil_protocol::{KeyframeRequestReasonV3, MediaFeedbackReportV1};3use std::collections::BTreeSet;4use std::ffi::OsString;5use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering};6use std::sync::{Arc, Mutex as StdMutex};7use tokio::sync::Mutex as TokioMutex;8910static MACOS_CURSOR_HIDE_DEPTH: StdMutex<usize> = StdMutex::new(0);1112static MACOS_CURSOR_GRABBED: AtomicBool = AtomicBool::new(false);13141516unsafe extern "C" {17 fn CGMainDisplayID() -> u32;18 fn CGAssociateMouseAndMouseCursorPosition(connected: u32) -> i32;19 fn CGDisplayHideCursor(display: u32) -> i32;20 fn CGDisplayShowCursor(display: u32) -> i32;21}222324fn set_native_cursor_grab(_window: &tauri::WebviewWindow, grab: bool) -> Result<(), String> {25 if !should_apply_native_cursor_association(MACOS_CURSOR_GRABBED.load(Ordering::Acquire), grab) {26 return Ok(());27 }28 let status = unsafe { CGAssociateMouseAndMouseCursorPosition(u32::from(!grab)) };29 if status != 0 {30 return Err(format!(31 "Could not {} native cursor grab: CGError {status}",32 if grab { "enable" } else { "release" }33 ));34 }35 MACOS_CURSOR_GRABBED.store(grab, Ordering::Release);36 Ok(())37}3839/// CoreGraphics cursor disassociation is scoped to the foreground application.40/// Re-applying an active grab is therefore required after the window regains41/// focus even though Sigil's desired grab state has not changed.4243const fn should_apply_native_cursor_association(current: bool, requested: bool) -> bool {44 requested || current != requested45}464748495051fn set_native_cursor_grab(window: &tauri::WebviewWindow, grab: bool) -> Result<(), String> {52 window53 .set_cursor_grab(grab)54 .map_err(|error| format!("Could not set native cursor grab to {grab}: {error}"))55}565758596061fn set_native_cursor_grab(_window: &tauri::WebviewWindow, grab: bool) -> Result<(), String> {62 if grab {63 return Err(64 "Relative pointer capture is unavailable on this Portal build; rebuild with the \65 experimental-non-macos-pointer-capture feature for platform UAT"66 .to_string(),67 );68 }69 Ok(())70}717273fn set_macos_cursor_hidden(hidden: bool) -> Result<(), String> {74 let mut depth = MACOS_CURSOR_HIDE_DEPTH75 .lock()76 .map_err(|_| "macOS cursor hide state is poisoned".to_string())?;77 update_macos_cursor_hide_depth(78 &mut depth,79 hidden,80 false,81 hide_macos_cursor,82 show_macos_cursor,83 )84}858687fn reassert_macos_cursor_hidden() -> Result<(), String> {88 let mut depth = MACOS_CURSOR_HIDE_DEPTH89 .lock()90 .map_err(|_| "macOS cursor hide state is poisoned".to_string())?;91 update_macos_cursor_hide_depth(&mut depth, true, true, hide_macos_cursor, show_macos_cursor)92}939495fn hide_macos_cursor() -> Result<(), String> {96 // kCGDirectMainDisplay is a macro that calls CGMainDisplayID(), not a97 // zero-valued sentinel. Passing zero can report success without hiding the98 // cursor on the active display.99 let status = unsafe { CGDisplayHideCursor(CGMainDisplayID()) };100 (status == 0)101 .then_some(())102 .ok_or_else(|| format!("Could not globally hide the macOS cursor: CGError {status}"))103}104105106fn show_macos_cursor() -> Result<(), String> {107 let status = unsafe { CGDisplayShowCursor(CGMainDisplayID()) };108 (status == 0)109 .then_some(())110 .ok_or_else(|| format!("Could not restore the macOS cursor: CGError {status}"))111}112113114fn update_macos_cursor_hide_depth(115 depth: &mut usize,116 hidden: bool,117 force: bool,118 mut hide: impl FnMut() -> Result<(), String>,119 mut show: impl FnMut() -> Result<(), String>,120) -> Result<(), String> {121 if hidden {122 if *depth > 0 && !force {123 return Ok(());124 }125 hide()?;126 *depth = depth127 .checked_add(1)128 .ok_or_else(|| "macOS cursor hide depth overflowed".to_string())?;129 return Ok(());130 }131132 while *depth > 0 {133 show()?;134 *depth -= 1;135 }136 Ok(())137}138139140fn set_macos_cursor_hidden(_hidden: bool) -> Result<(), String> {141 Ok(())142}143144pub(crate) fn restore_client_cursor() {145 let _ = set_macos_cursor_hidden(false);146 147 {148 let status = unsafe { CGAssociateMouseAndMouseCursorPosition(1) };149 if status == 0 {150 MACOS_CURSOR_GRABBED.store(false, Ordering::Release);151 }152 }153}154155156pub(crate) fn reassert_client_cursor_grab(window: &tauri::WebviewWindow) -> Result<(), String> {157 if !MACOS_CURSOR_GRABBED.load(Ordering::Acquire) {158 return Ok(());159 }160 set_native_cursor_grab(window, true)?;161 reassert_macos_cursor_hidden()?;162 let cursor_rect_result = reapply_hidden_cursor_rect(|visible| {163 window.set_cursor_visible(visible).map_err(|error| {164 format!(165 "Could not {} after focus changed: {error}",166 if visible {167 "reset native cursor visibility state"168 } else {169 "re-hide native cursor"170 }171 )172 })173 });174 // WebKit also rebuilds cursor rectangles after focus. Refresh both native175 // layers: the CoreGraphics hide is acquired before Tao briefly resets its176 // cursor state, so the local pointer cannot flash at its frozen position.177 cursor_rect_result178}179180181pub(crate) fn reassert_client_cursor_grab(_window: &tauri::WebviewWindow) -> Result<(), String> {182 Ok(())183}184185fn reapply_hidden_cursor_rect(186 mut set_visible: impl FnMut(bool) -> Result<(), String>,187) -> Result<(), String> {188 // Tao only invalidates the platform cursor state when this boolean189 // changes. Force that transition while the native grab is active; on190 // macOS the balanced CoreGraphics hide prevents the intermediate state191 // from flashing at the frozen cursor position.192 set_visible(true)?;193 set_visible(false)194}195196pub const AUDIO_DELIVERY_CAPACITY: usize = 3;197198199pub struct AudioDeliveryState {200 generation: Option<u64>,201 next_delivery_id: u64,202 outstanding: BTreeSet<u64>,203}204205impl AudioDeliveryState {206 pub fn begin_generation(&mut self, generation: u64) -> Result<(), String> {207 if generation == 0 {208 return Err("Audio generation must be non-zero".to_string());209 }210 self.generation = Some(generation);211 self.next_delivery_id = 1;212 self.outstanding.clear();213 Ok(())214 }215216 pub fn reserve(&mut self, generation: u64) -> Result<Option<u64>, String> {217 if self.generation != Some(generation) {218 return Err(format!(219 "Stale audio generation {generation}; current generation is {:?}",220 self.generation221 ));222 }223 if self.outstanding.len() >= AUDIO_DELIVERY_CAPACITY {224 return Ok(None);225 }226 let delivery_id = self.next_delivery_id;227 self.next_delivery_id = delivery_id228 .checked_add(1)229 .ok_or_else(|| "Audio delivery ID overflowed".to_string())?;230 if !self.outstanding.insert(delivery_id) {231 return Err(format!("Audio delivery ID {delivery_id} was reused"));232 }233 Ok(Some(delivery_id))234 }235236 pub fn acknowledge(&mut self, generation: u64, delivery_id: u64) -> Result<(), String> {237 if self.generation != Some(generation) {238 return Err(format!(239 "Stale audio generation {generation}; current generation is {:?}",240 self.generation241 ));242 }243 if !self.outstanding.remove(&delivery_id) {244 return Err(format!(245 "Unknown or already acknowledged audio delivery ID {delivery_id}"246 ));247 }248 Ok(())249 }250251 pub fn release_failed_delivery(&mut self, generation: u64, delivery_id: u64) -> bool {252 self.generation == Some(generation) && self.outstanding.remove(&delivery_id)253 }254255 pub fn cancel_generation(&mut self, expected_generation: u64) -> bool {256 if self.generation != Some(expected_generation) {257 return false;258 }259 self.generation = None;260 self.outstanding.clear();261 true262 }263264 pub fn depth(&self, generation: u64) -> Option<usize> {265 (self.generation == Some(generation)).then_some(self.outstanding.len())266 }267268 pub fn generation(&self) -> Option<u64> {269 self.generation270 }271272 pub fn clear(&mut self) {273 self.generation = None;274 self.outstanding.clear();275 }276}277278pub const fn development_direct_node_available() -> bool {279 cfg!(any(debug_assertions, feature = "demo-direct-node"))280}281282283pub struct LaunchOptions {284 pub dev_connect_node_id: Option<iroh::PublicKey>,285}286287pub fn parse_launch_options<I, S>(args: I, allow_dev_connect: bool) -> Result<LaunchOptions, String>288where289 I: IntoIterator<Item = S>,290 S: Into<OsString>,291{292 let mut options = LaunchOptions::default();293 let mut args = args.into_iter().map(Into::into);294295 // The first value is the executable path. Unknown values are left for the296 // platform/Tauri runtime, but Portal-owned flags are parsed strictly.297 let _ = args.next();298 while let Some(arg) = args.next() {299 if arg == "--daemon" {300 return Err(301 "--daemon is not supported by Portal; run the separate sigil executable"302 .to_string(),303 );304 }305306 let arg = arg307 .into_string()308 .map_err(|_| "Portal command-line arguments must be valid UTF-8".to_string())?;309 let node_id = if arg == "--dev-connect" {310 Some(311 args.next()312 .ok_or_else(|| "--dev-connect requires an iroh node ID".to_string())?313 .into_string()314 .map_err(|_| "The --dev-connect node ID must be valid UTF-8".to_string())?,315 )316 } else {317 arg.strip_prefix("--dev-connect=").map(str::to_owned)318 };319320 let Some(node_id) = node_id else {321 continue;322 };323 if !allow_dev_connect {324 return Err(325 "--dev-connect requires a debug build or the explicit demo-direct-node feature; ordinary release builds require the normal passkey flow"326 .to_string(),327 );328 }329 if options.dev_connect_node_id.is_some() {330 return Err("--dev-connect may be specified only once".to_string());331 }332 if node_id.is_empty() {333 return Err("--dev-connect requires an iroh node ID".to_string());334 }335 options.dev_connect_node_id = Some(336 node_id337 .parse::<iroh::PublicKey>()338 .map_err(|e| format!("Invalid iroh node ID for --dev-connect: {e}"))?,339 );340 }341342 Ok(options)343}344345pub struct AppState {346 pub enrollment: super::enrollment::EnrollmentState,347 pub input_send: TokioMutex<Option<tokio::sync::mpsc::Sender<sigil_protocol::InputEvent>>>,348 pub client_endpoint: TokioMutex<Option<iroh::Endpoint>>,349 pub media_connection: TokioMutex<Option<(u64, iroh::endpoint::Connection)>>,350 pub media_control: TokioMutex<Option<(u64, MediaControlRequestSender)>>,351 pub media_feedback: TokioMutex<Option<(u64, iroh::endpoint::Connection, MediaFeedbackSender)>>,352 pub media_feedback_report_id: Arc<AtomicU64>,353 pub frame_delivery: TokioMutex<Option<(u64, Arc<AtomicUsize>)>>,354 pub client_media_generation: Arc<AtomicU64>,355 pub audio_deliveries: Arc<StdMutex<AudioDeliveryState>>,356 pub audio_connection: TokioMutex<Option<(u64, iroh::endpoint::Connection)>>,357 pub audio_connection_generation: Arc<AtomicU64>,358 pub client_connection_serial: TokioMutex<()>,359 pub client_connection_active: Arc<AtomicBool>,360 pub webcodecs: AtomicBool,361 pub dev_connect_node_id: Option<iroh::PublicKey>,362}363364pub type MediaControlRequestSender =365 tokio::sync::mpsc::Sender<(KeyframeRequestReasonV3, Option<u64>)>;366pub type MediaFeedbackSender = tokio::sync::watch::Sender<Option<AccumulatedMediaFeedback>>;367368const MAX_MEDIA_FEEDBACK_INTERVAL_MS: u64 = 5_000;369370/// Constant-size latest receiver state plus cumulative interval pressure.371///372/// A watch channel deliberately coalesces updates while its consumer is373/// blocked. Keeping cumulative counters here lets the writer recover the full374/// delta since its last successful write instead of losing the reports that375/// were replaced in the meantime.376377pub struct AccumulatedMediaFeedback {378 latest: MediaFeedbackReportV1,379 interval_ms_total: u64,380 transport_dropped_total: u64,381 frontend_dropped_total: u64,382 decoder_dropped_total: u64,383 presenter_dropped_total: u64,384}385386impl AccumulatedMediaFeedback {387 pub fn new(report: MediaFeedbackReportV1) -> Self {388 Self {389 latest: report,390 interval_ms_total: u64::from(report.interval_ms),391 transport_dropped_total: u64::from(report.transport_dropped_delta),392 frontend_dropped_total: u64::from(report.frontend_dropped_delta),393 decoder_dropped_total: u64::from(report.decoder_dropped_delta),394 presenter_dropped_total: u64::from(report.presenter_dropped_delta),395 }396 }397398 pub fn merge(&mut self, report: MediaFeedbackReportV1) {399 self.latest = report;400 self.interval_ms_total = self401 .interval_ms_total402 .saturating_add(u64::from(report.interval_ms));403 self.transport_dropped_total = self404 .transport_dropped_total405 .saturating_add(u64::from(report.transport_dropped_delta));406 self.frontend_dropped_total = self407 .frontend_dropped_total408 .saturating_add(u64::from(report.frontend_dropped_delta));409 self.decoder_dropped_total = self410 .decoder_dropped_total411 .saturating_add(u64::from(report.decoder_dropped_delta));412 self.presenter_dropped_total = self413 .presenter_dropped_total414 .saturating_add(u64::from(report.presenter_dropped_delta));415 }416417 pub fn report_since(self, previous: Option<Self>) -> MediaFeedbackReportV1 {418 let previous = previous.unwrap_or(Self {419 latest: self.latest,420 interval_ms_total: 0,421 transport_dropped_total: 0,422 frontend_dropped_total: 0,423 decoder_dropped_total: 0,424 presenter_dropped_total: 0,425 });426 let mut report = self.latest;427 report.interval_ms = self428 .interval_ms_total429 .saturating_sub(previous.interval_ms_total)430 .min(MAX_MEDIA_FEEDBACK_INTERVAL_MS) as u16;431 report.transport_dropped_delta = cumulative_delta(432 self.transport_dropped_total,433 previous.transport_dropped_total,434 );435 report.frontend_dropped_delta =436 cumulative_delta(self.frontend_dropped_total, previous.frontend_dropped_total);437 report.decoder_dropped_delta =438 cumulative_delta(self.decoder_dropped_total, previous.decoder_dropped_total);439 report.presenter_dropped_delta = cumulative_delta(440 self.presenter_dropped_total,441 previous.presenter_dropped_total,442 );443 report444 }445}446447fn cumulative_delta(current: u64, previous: u64) -> u32 {448 current.saturating_sub(previous).min(u64::from(u32::MAX)) as u32449}450451impl Default for AppState {452 fn default() -> Self {453 Self::new(LaunchOptions::default())454 }455}456457impl AppState {458 pub fn from_args<I, S>(args: I) -> Result<Self, String>459 where460 I: IntoIterator<Item = S>,461 S: Into<OsString>,462 {463 parse_launch_options(args, development_direct_node_available()).map(Self::new)464 }465466 fn new(options: LaunchOptions) -> Self {467 Self {468 enrollment: super::enrollment::EnrollmentState::default(),469 input_send: TokioMutex::new(None),470 client_endpoint: TokioMutex::new(None),471 media_connection: TokioMutex::new(None),472 media_control: TokioMutex::new(None),473 media_feedback: TokioMutex::new(None),474 media_feedback_report_id: Arc::new(AtomicU64::new(0)),475 frame_delivery: TokioMutex::new(None),476 client_media_generation: Arc::new(AtomicU64::new(0)),477 audio_deliveries: Arc::new(StdMutex::new(AudioDeliveryState::default())),478 audio_connection: TokioMutex::new(None),479 audio_connection_generation: Arc::new(AtomicU64::new(0)),480 client_connection_serial: TokioMutex::new(()),481 client_connection_active: Arc::new(AtomicBool::new(false)),482 webcodecs: AtomicBool::new(false),483 dev_connect_node_id: options.dev_connect_node_id,484 }485 }486}487488// ─── Simple state Tauri commands ─────────────────────────────────────────────489490491pub struct DevelopmentConnectionMode {492 pub enabled: bool,493 pub host_node_id: Option<String>,494 pub warning: &'static str,495}496497498pub fn development_connection_mode(state: tauri::State<'_, AppState>) -> DevelopmentConnectionMode {499 DevelopmentConnectionMode {500 enabled: state.dev_connect_node_id.is_some(),501 host_node_id: state.dev_connect_node_id.map(|node_id| node_id.to_string()),502 warning: "Development direct-node routing skips passkey identity lookup; it is not client authorization.",503 }504}505506507pub fn set_client_cursor_grab(window: tauri::WebviewWindow, grab: bool) -> Result<bool, String> {508 if grab {509 set_native_cursor_grab(&window, true)?;510 if let Err(error) = reapply_hidden_cursor_rect(|visible| {511 window512 .set_cursor_visible(visible)513 .map_err(|error| format!("Could not set native cursor visibility: {error}"))514 }) {515 let _ = set_native_cursor_grab(&window, false);516 return Err(error);517 }518 if let Err(error) = set_macos_cursor_hidden(true) {519 let _ = window.set_cursor_visible(true);520 let _ = set_native_cursor_grab(&window, false);521 return Err(error);522 }523 // CoreGraphics disassociation already supplies relative deltas on524 // macOS. WebKit Pointer Lock would be a second asynchronous cursor525 // owner and can reveal the native pointer after this command returns.526 return Ok(!cfg!(target_os = "macos"));527 }528529 let global_visibility_result = set_macos_cursor_hidden(false);530 let visibility_result = window531 .set_cursor_visible(true)532 .map_err(|error| format!("Could not restore native cursor visibility: {error}"));533 let release_result = set_native_cursor_grab(&window, false);534 global_visibility_result535 .and(release_result)536 .and(visibility_result)537 .map(|()| !cfg!(target_os = "macos"))538}539540541pub fn set_webcodecs_available(state: tauri::State<'_, AppState>, available: bool) -> bool {542 state.webcodecs.store(available, Ordering::SeqCst);543 available544}545546547pub fn is_webcodecs_available(state: tauri::State<'_, AppState>) -> bool {548 state.webcodecs.load(Ordering::SeqCst)549}550551552mod tests {553 use super::*;554555 fn test_node_id() -> String {556 iroh::SecretKey::generate().public().to_string()557 }558559 560 fn active_native_cursor_grab_is_reapplied_after_focus_changes() {561 assert!(should_apply_native_cursor_association(true, true));562 assert!(should_apply_native_cursor_association(false, true));563 assert!(should_apply_native_cursor_association(true, false));564 assert!(!should_apply_native_cursor_association(false, false));565 }566567 568 fn cursor_focus_reassertion_forces_tao_visibility_transition() {569 let mut visibility = Vec::new();570571 reapply_hidden_cursor_rect(|visible| {572 visibility.push(visible);573 Ok(())574 })575 .unwrap();576577 assert_eq!(visibility, [true, false]);578 }579580 581 fn cursor_focus_reassertion_owns_each_core_graphics_hide() {582 use std::cell::Cell;583584 let mut depth = 0;585 let hides = Cell::new(0);586 let shows = Cell::new(0);587 for force in [false, false, true] {588 update_macos_cursor_hide_depth(589 &mut depth,590 true,591 force,592 || {593 hides.set(hides.get() + 1);594 Ok(())595 },596 || {597 shows.set(shows.get() + 1);598 Ok(())599 },600 )601 .unwrap();602 }603 assert_eq!((depth, hides.get(), shows.get()), (2, 2, 0));604605 update_macos_cursor_hide_depth(606 &mut depth,607 false,608 false,609 || {610 hides.set(hides.get() + 1);611 Ok(())612 },613 || {614 shows.set(shows.get() + 1);615 Ok(())616 },617 )618 .unwrap();619 assert_eq!((depth, hides.get(), shows.get()), (0, 2, 2));620 }621622 623 fn cursor_hide_depth_keeps_failed_operations_recoverable() {624 let mut depth = 0;625 let error = update_macos_cursor_hide_depth(626 &mut depth,627 true,628 false,629 || Err("hide failed".to_string()),630 || Ok(()),631 )632 .unwrap_err();633 assert_eq!(error, "hide failed");634 assert_eq!(depth, 0);635636 depth = 2;637 let error = update_macos_cursor_hide_depth(638 &mut depth,639 false,640 false,641 || Ok(()),642 || Err("show failed".to_string()),643 )644 .unwrap_err();645 assert_eq!(error, "show failed");646 assert_eq!(depth, 2);647 }648649 650 fn parses_debug_direct_node_flag() {651 let node_id = test_node_id();652 let options = parse_launch_options(["portal", "--dev-connect", &node_id], true).unwrap();653654 assert_eq!(options.dev_connect_node_id.unwrap().to_string(), node_id);655 }656657 658 fn rejects_legacy_daemon_mode() {659 let error = parse_launch_options(["portal", "--daemon"], true).unwrap_err();660 assert!(error.contains("separate sigil executable"));661 }662663 664 fn parses_equals_form() {665 let node_id = test_node_id();666 let flag = format!("--dev-connect={node_id}");667 let options = parse_launch_options(["portal", &flag], true).unwrap();668669 assert_eq!(options.dev_connect_node_id.unwrap().to_string(), node_id);670 }671672 673 fn rejects_direct_node_when_debug_mode_is_disabled() {674 let node_id = test_node_id();675 let error = parse_launch_options(["portal", "--dev-connect", &node_id], false).unwrap_err();676677 assert!(error.contains("debug build or the explicit demo-direct-node feature"));678 }679680 681 fn ordinary_release_excludes_direct_node_bypass() {682 if cfg!(not(debug_assertions)) && cfg!(not(feature = "demo-direct-node")) {683 assert!(!development_direct_node_available());684 }685 }686687 688 fn app_state_accepts_direct_node_only_in_debug_builds() {689 let node_id = test_node_id();690 let result = AppState::from_args(["portal", "--dev-connect", &node_id]);691692 assert_eq!(result.is_ok(), development_direct_node_available());693 }694695 696 fn app_state_starts_without_owned_transport_connections() {697 let state = AppState::default();698699 assert!(state.client_endpoint.try_lock().unwrap().is_none());700 assert!(state.media_connection.try_lock().unwrap().is_none());701 assert!(state.media_control.try_lock().unwrap().is_none());702 assert!(state.media_feedback.try_lock().unwrap().is_none());703 assert!(state.audio_connection.try_lock().unwrap().is_none());704 }705706 707 fn rejects_missing_invalid_and_duplicate_node_ids() {708 assert!(709 parse_launch_options(["portal", "--dev-connect"], true)710 .unwrap_err()711 .contains("requires an iroh node ID")712 );713 assert!(714 parse_launch_options(["portal", "--dev-connect", "not-a-node-id"], true)715 .unwrap_err()716 .contains("Invalid iroh node ID")717 );718719 let node_id = test_node_id();720 assert!(721 parse_launch_options(722 [723 "portal",724 "--dev-connect",725 &node_id,726 "--dev-connect",727 &node_id,728 ],729 true,730 )731 .unwrap_err()732 .contains("only once")733 );734 }735}