- commit
- 0d9d3bb5001cc4ee35477eb9c68bc4f880484769
- parent
- da393bb1abdff37f3970cb5c5ac4969d1527aad6
- author
- Dami <dami.etoile@gmail.com>
@@ -310,13 +310,20 @@ fn apply_mutation(
310310 ) -> Result<AuthorizationAdminResult> {311311 let effect = sessions.apply_authorization_mutation(&mutation)?;312312 if effect.neutralize_input {313- input_operations.reset()?;314- if !effect.disconnected {315- sessions.complete_authorization_neutralization(316- mutation.peer,317- mutation.authorization_revision,313+ if let Some(transition_id) = effect.focus_transition_id {314+ input_operations.neutralize_focus_transition(315+ sessions,316+ transition_id,317+ sigil_protocol::FocusTransitionReasonV2::Revoked,318318 )?;319+ } else {320+ input_operations.reset()?;319321 }322+ sessions.complete_authorization_neutralization(323+ mutation.peer,324+ mutation.authorization_revision,325+ None,326+ )?;320327 }321328 Ok(AuthorizationAdminResult::Mutation {322329 handle: mutation.handle,@@ -417,6 +424,7 @@ mod tests {
417424 identity_path: state_path.join("unused-identity"),418425 state_path: state_path.to_path_buf(),419426 max_viewers: crate::config::DEFAULT_MAX_VIEWERS,427+ focus_owner: None,420428 source: VideoSource::TestPattern,421429 width: Some(1280),422430 height: Some(800),@@ -568,6 +576,7 @@ mod tests {
568576 slot: ControllerSlot::ZERO,569577 expected_revision: 1,570578 expected_focus_generation: None,579+ expected_proposal_id: None,571580 },572581 )573582 .unwrap();@@ -89,6 +89,10 @@ struct Args {
8989 /// against a roster revision that may advance while spectators join.9090 9191 disconnect_with_focus: bool,92+ /// Participate in bounded holder-approved slot-0 handoffs and stop after93+ /// observing this many successor generations.94+ 95+ focus_handoffs: u32,9296 /// Request a configured recovery keyframe after three accepted frames,9397 /// then prove no delta history is delivered before the recovery barrier.9498 @@ -760,6 +764,7 @@ async fn run_control_v2_smoke(
760764 expected_size: Option<(u16, u16)>,761765 spectator: bool,762766 disconnect_with_focus: bool,767+ focus_handoffs: u32,763768 ) -> Result<()> {764769 let control_connection = endpoint765770 .connect(address.clone(), CONTROL_ALPN_V2)@@ -802,7 +807,7 @@ async fn run_control_v2_smoke(
802807 initial.revision > 0,803808 "initial control v2 snapshot was not revisioned"804809 );805- if !spectator {810+ if !spectator && focus_handoffs == 0 {806811 ensure!(807812 matches!(initial.focus, FocusStateV2::Vacant { .. }),808813 "initial control v2 snapshot did not advertise a vacant slot"@@ -828,6 +833,18 @@ async fn run_control_v2_smoke(
828833 }),829834 )830835 .await?;836+ if focus_handoffs > 0 {837+ return run_focus_handoff_participant(838+ control_connection,839+ control_send,840+ control_recv,841+ media,842+ initial,843+ focus_handoffs,844+ timeout_seconds,845+ )846+ .await;847+ }831848 if spectator {832849 let mut accepted = 0_u32;833850 let mut last_sequence = None;@@ -871,6 +888,7 @@ async fn run_control_v2_smoke(
871888 slot: ControllerSlot::ZERO,872889 expected_revision: initial.revision,873890 expected_focus_generation: None,891+ expected_proposal_id: None,874892 };875893 write_client_control_v2(876894 &mut control_send,@@ -985,6 +1003,7 @@ async fn run_control_v2_smoke(
9851003 slot: ControllerSlot::ZERO,9861004 expected_revision: focused.revision,9871005 expected_focus_generation: Some(focus_generation),1006+ expected_proposal_id: None,9881007 };9891008 write_client_control_v2(9901009 &mut control_send,@@ -1031,6 +1050,158 @@ async fn run_control_v2_smoke(
10311050 Ok(())10321051 }10331052 1053+fn focus_holder_identity(1054+ snapshot: &sigil_protocol::SessionSnapshotV2,1055+) -> Option<(String, u64, u64)> {1056+ match &snapshot.focus {1057+ FocusStateV2::Held {1058+ holder,1059+ session_id,1060+ focus_generation,1061+ ..1062+ } => Some((holder.as_str().to_owned(), *session_id, *focus_generation)),1063+ FocusStateV2::Vacant { .. } | FocusStateV2::Neutralizing { .. } => None,1064+ }1065+}1066+1067+fn next_handoff_command(1068+ snapshot: &sigil_protocol::SessionSnapshotV2,1069+ request_id: u64,1070+) -> Option<FocusCommandV2> {1071+ let self_viewer = snapshot.self_viewer();1072+ match &snapshot.focus {1073+ FocusStateV2::Vacant { slot } => Some(FocusCommandV2 {1074+ request_id,1075+ action: FocusCommandActionV2::Request,1076+ slot: *slot,1077+ expected_revision: snapshot.revision,1078+ expected_focus_generation: None,1079+ expected_proposal_id: None,1080+ }),1081+ FocusStateV2::Held {1082+ slot,1083+ holder,1084+ session_id,1085+ focus_generation,1086+ } if holder == &snapshot.self_presence_id && *session_id == self_viewer.session_id => {1087+ snapshot1088+ .focus_proposal1089+ .as_ref()1090+ .map(|proposal| FocusCommandV2 {1091+ request_id,1092+ action: FocusCommandActionV2::Approve,1093+ slot: *slot,1094+ expected_revision: snapshot.revision,1095+ expected_focus_generation: Some(*focus_generation),1096+ expected_proposal_id: Some(proposal.proposal_id),1097+ })1098+ }1099+ FocusStateV2::Held { slot, .. } if snapshot.focus_proposal.is_none() => {1100+ Some(FocusCommandV2 {1101+ request_id,1102+ action: FocusCommandActionV2::Request,1103+ slot: *slot,1104+ expected_revision: snapshot.revision,1105+ expected_focus_generation: None,1106+ expected_proposal_id: None,1107+ })1108+ }1109+ FocusStateV2::Held { .. } | FocusStateV2::Neutralizing { .. } => None,1110+ }1111+}1112+1113+async fn run_focus_handoff_participant(1114+ control_connection: iroh::endpoint::Connection,1115+ mut control_send: iroh::endpoint::SendStream,1116+ mut control_recv: iroh::endpoint::RecvStream,1117+ mut media: MoqProbeReceiver,1118+ mut snapshot: sigil_protocol::SessionSnapshotV2,1119+ required_handoffs: u32,1120+ timeout_seconds: u64,1121+) -> Result<()> {1122+ let mut request_id = 1_u64;1123+ let mut command_in_flight = false;1124+ let mut observed_handoffs = 0_u32;1125+ let mut accepted_frames = 0_u32;1126+ let mut maximum_roster_viewers = snapshot.viewers.len();1127+ let mut last_holder = focus_holder_identity(&snapshot);1128+ let mut next_command_at = tokio::time::Instant::now();1129+ let deadline =1130+ tokio::time::Instant::now() + Duration::from_secs(timeout_seconds.max(1).saturating_mul(2));1131+1132+ while observed_handoffs < required_handoffs {1133+ ensure!(1134+ tokio::time::Instant::now() < deadline,1135+ "timed out after observing {observed_handoffs} of {required_handoffs} focus handoffs"1136+ );1137+ if !command_in_flight && tokio::time::Instant::now() >= next_command_at {1138+ if let Some(command) = next_handoff_command(&snapshot, request_id) {1139+ write_client_control_v2(1140+ &mut control_send,1141+ &ClientControlEnvelopeV2::Focus { command },1142+ )1143+ .await1144+ .context("writing bounded focus handoff command")?;1145+ request_id = request_id1146+ .checked_add(1)1147+ .context("focus handoff request id exhausted")?;1148+ command_in_flight = true;1149+ next_command_at = tokio::time::Instant::now() + Duration::from_millis(300);1150+ }1151+ }1152+1153+ tokio::select! {1154+ envelope = read_server_control_v2(&mut control_recv) => {1155+ let envelope = envelope?1156+ .context("host closed during bounded focus handoff proof")?;1157+ match envelope {1158+ ServerControlEnvelopeV2::Snapshot { snapshot: next } => {1159+ ensure!(1160+ next.revision >= snapshot.revision,1161+ "focus handoff snapshot revision regressed"1162+ );1163+ if next.revision == snapshot.revision {1164+ continue;1165+ }1166+ if let Some(holder) = focus_holder_identity(&next) {1167+ if last_holder.as_ref().is_some_and(|previous| previous != &holder) {1168+ observed_handoffs = observed_handoffs.saturating_add(1);1169+ }1170+ last_holder = Some(holder);1171+ }1172+ maximum_roster_viewers = maximum_roster_viewers.max(next.viewers.len());1173+ snapshot = next;1174+ command_in_flight = false;1175+ }1176+ ServerControlEnvelopeV2::FocusResult { result } => {1177+ if !result.accepted {1178+ command_in_flight = false;1179+ next_command_at = tokio::time::Instant::now() + Duration::from_millis(300);1180+ }1181+ }1182+ }1183+ }1184+ outcome = media.next() => {1185+ if matches!(outcome?, Some(MoqProbeOutcome::Frame { .. })) {1186+ accepted_frames = accepted_frames.saturating_add(1);1187+ }1188+ }1189+ _ = tokio::time::sleep_until(deadline) => {1190+ bail!("timed out after observing {observed_handoffs} of {required_handoffs} focus handoffs");1191+ }1192+ }1193+ }1194+1195+ control_connection.close(0_u32.into(), b"focus handoff proof complete");1196+ println!("probe=ok");1197+ println!("control_alpn=sigil/control/2");1198+ println!("focus_handoffs_observed={observed_handoffs}");1199+ println!("roster_viewers={maximum_roster_viewers}");1200+ println!("focus_handoff_media_frames={accepted_frames}");1201+ println!("reset_before_successor=host-ordered");1202+ Ok(())1203+}1204+10341205 async fn read_v2_snapshot_after(10351206 recv: &mut iroh::endpoint::RecvStream,10361207 previous_revision: u64,@@ -1663,6 +1834,7 @@ async fn main() -> Result<()> {
16631834 args.expect_size,16641835 args.spectator,16651836 args.disconnect_with_focus,1837+ args.focus_handoffs,16661838 )16671839 .await;16681840 }@@ -373,6 +373,10 @@ pub struct HostConfig {
373373 /// fixed bound and legacy transports remain exclusive.374374 375375 pub max_viewers: u8,376+ /// Optional stable opaque authorization handle allowed to preempt slot 0.377+ /// Other viewers can request a holder-approved handoff but cannot preempt.378+ 379+ pub focus_owner: Option<String>,376380 377381 pub source: VideoSource,378382 /// Optional encoded-size override. Gamescope uses its advertised native@@ -482,6 +486,16 @@ impl HostConfig {
482486 (1..=MAX_VIEWERS).contains(&self.max_viewers),483487 "max_viewers must be between 1 and {MAX_VIEWERS}"484488 );489+ if let Some(handle) = &self.focus_owner {490+ ensure!(491+ handle.len() == 23492+ && handle.starts_with("viewer-")493+ && handle["viewer-".len()..]494+ .bytes()495+ .all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase()),496+ "focus_owner must be a stable opaque viewer- followed by 16 lowercase hexadecimal digits"497+ );498+ }485499 match (self.width, self.height) {486500 (Some(width), Some(height)) => validate_video_dimensions(width, height)?,487501 (None, None) => {}@@ -736,6 +750,24 @@ state_path = "/tmp/state"
736750 assert!(config.validate().is_err());737751 }738752 753+ 754+ fn focus_owner_accepts_only_stable_opaque_authorization_handles() {755+ let mut config: HostConfig = toml::from_str(756+ r#"757+identity_path = "/tmp/host.key"758+state_path = "/tmp/state"759+focus_owner = "viewer-0123456789abcdef"760+"#,761+ )762+ .unwrap();763+ config.validate().unwrap();764+765+ config.focus_owner = Some("viewer-owner".into());766+ assert!(config.validate().is_err());767+ config.focus_owner = Some("viewer-0123456789ABCDEF".into());768+ assert!(config.validate().is_err());769+ }770+739771 740772 fn validates_strict_gamescope_pipewire_configuration() {741773 let input = r#"@@ -770,6 +770,7 @@ async fn probe_test_pattern(args: CaptureProbeArgs) -> Result<()> {
770770 identity_path: PathBuf::from("unused-by-capture-probe"),771771 state_path: PathBuf::from("."),772772 max_viewers: config::DEFAULT_MAX_VIEWERS,773+ focus_owner: None,773774 source: VideoSource::TestPattern,774775 width: Some(width),775776 height: Some(height),@@ -980,7 +981,10 @@ async fn serve_command(args: ServeArgs) -> Result<()> {
980981 };981982 let secret = identity::load(&config.identity_path)?;982983 let host_secret = secret.to_bytes();983- let sessions = Arc::new(SessionRegistry::new(config.max_viewers));984+ let sessions = Arc::new(SessionRegistry::new_with_focus_owner(985+ config.max_viewers,986+ config.focus_owner.clone(),987+ ));984988 let publisher = appliance::RuntimePublisher::start(985989 secret.public(),986990 Arc::clone(&sessions),@@ -1333,6 +1337,7 @@ fn load_serve_config(args: &ServeArgs) -> Result<LoadedServeConfig> {
13331337 identity_path,13341338 state_path,13351339 max_viewers: config::DEFAULT_MAX_VIEWERS,1340+ focus_owner: None,13361341 source: source.into(),13371342 width: Some(args.width),13381343 height: Some(args.height),@@ -0,0 +1,551 @@
1+use std::collections::{HashMap, VecDeque};2+use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};3+4+use anyhow::{Context, Result, bail, ensure};5+use sigil_protocol::{6+ ControllerSlot, FocusProposalV2, FocusStateV2, FocusTransitionReasonV2, ViewerPresenceId,7+};8+9+const FOCUS_PROPOSAL_TTL: Duration = Duration::from_secs(15);10+const FOCUS_ACTIVATION_TTL: Duration = Duration::from_secs(10);11+const FOCUS_COMMAND_RATE_WINDOW: Duration = Duration::from_secs(2);12+const MAX_FOCUS_COMMANDS_PER_WINDOW: usize = 8;13+14+15+pub(super) struct FocusCandidate {16+ pub(super) presence_id: ViewerPresenceId,17+ pub(super) session_id: u64,18+}19+20+21+pub(crate) struct FocusNeutralization {22+ pub(crate) transition_id: u64,23+ pub(crate) former_session_id: u64,24+}25+26+27+pub(super) struct FocusMutation {28+ pub(super) changed: bool,29+ pub(super) neutralization: Option<FocusNeutralization>,30+}31+32+33+struct FocusSuccessor {34+ candidate: FocusCandidate,35+}36+37+38+struct ActivationDeadline {39+ presence_id: ViewerPresenceId,40+ session_id: u64,41+ focus_generation: u64,42+ deadline: Instant,43+}44+45+46+pub(super) struct FocusArbiter {47+ state: FocusStateV2,48+ proposal: Option<FocusProposalV2>,49+ proposal_deadline: Option<Instant>,50+ transition_reason: FocusTransitionReasonV2,51+ configured_owner: Option<ViewerPresenceId>,52+ successor: Option<FocusSuccessor>,53+ activation: Option<ActivationDeadline>,54+ next_focus_generation: u64,55+ next_transition_id: u64,56+ next_proposal_id: u64,57+ command_times: HashMap<(ViewerPresenceId, u64), VecDeque<Instant>>,58+}59+60+impl FocusArbiter {61+ pub(super) fn new(configured_owner: Option<ViewerPresenceId>) -> Self {62+ Self {63+ state: FocusStateV2::Vacant {64+ slot: ControllerSlot::ZERO,65+ },66+ proposal: None,67+ proposal_deadline: None,68+ transition_reason: FocusTransitionReasonV2::Initial,69+ configured_owner,70+ successor: None,71+ activation: None,72+ next_focus_generation: 0,73+ next_transition_id: 0,74+ next_proposal_id: 0,75+ command_times: HashMap::new(),76+ }77+ }78+79+ pub(super) fn state(&self) -> &FocusStateV2 {80+ &self.state81+ }82+83+ pub(super) fn proposal(&self) -> Option<&FocusProposalV2> {84+ self.proposal.as_ref()85+ }86+87+ pub(super) fn transition_reason(&self) -> FocusTransitionReasonV2 {88+ self.transition_reason89+ }90+91+ pub(super) fn is_configured_owner(&self, presence_id: &ViewerPresenceId) -> bool {92+ self.configured_owner.as_ref() == Some(presence_id)93+ }94+95+ pub(super) fn check_rate_limit(96+ &mut self,97+ requester: &FocusCandidate,98+ now: Instant,99+ ) -> Result<()> {100+ let key = (requester.presence_id.clone(), requester.session_id);101+ let entries = self.command_times.entry(key).or_default();102+ while entries103+ .front()104+ .is_some_and(|time| now.duration_since(*time) >= FOCUS_COMMAND_RATE_WINDOW)105+ {106+ entries.pop_front();107+ }108+ ensure!(109+ entries.len() < MAX_FOCUS_COMMANDS_PER_WINDOW,110+ "focus command rate limit exceeded"111+ );112+ entries.push_back(now);113+ Ok(())114+ }115+116+ pub(super) fn expire_proposal(&mut self, now: Instant) -> bool {117+ if self.proposal_deadline.is_none_or(|deadline| deadline > now) {118+ return false;119+ }120+ self.clear_proposal();121+ self.transition_reason = FocusTransitionReasonV2::ProposalExpired;122+ true123+ }124+125+ pub(super) fn request(126+ &mut self,127+ requester: FocusCandidate,128+ now: Instant,129+ ) -> Result<FocusMutation> {130+ self.expire_proposal(now);131+ match &self.state {132+ FocusStateV2::Vacant { slot } => {133+ let slot = *slot;134+ self.grant(slot, requester, now)?;135+ self.transition_reason = FocusTransitionReasonV2::Requested;136+ Ok(FocusMutation {137+ changed: true,138+ neutralization: None,139+ })140+ }141+ FocusStateV2::Held {142+ slot,143+ holder,144+ session_id,145+ ..146+ } => {147+ ensure!(148+ holder != &requester.presence_id || *session_id != requester.session_id,149+ "current holder cannot request focus from itself"150+ );151+ ensure!(152+ self.proposal.is_none(),153+ "controller slot 0 already has a pending handoff proposal"154+ );155+ self.next_proposal_id = next_nonzero(self.next_proposal_id, "focus proposal id")?;156+ let expires_at_unix_ms = unix_millis_after(FOCUS_PROPOSAL_TTL)?;157+ self.proposal = Some(FocusProposalV2 {158+ proposal_id: self.next_proposal_id,159+ slot: *slot,160+ requester: requester.presence_id,161+ requester_session_id: requester.session_id,162+ holder: holder.clone(),163+ holder_session_id: *session_id,164+ expires_at_unix_ms,165+ });166+ self.proposal_deadline = Some(now + FOCUS_PROPOSAL_TTL);167+ self.transition_reason = FocusTransitionReasonV2::HandoffRequested;168+ Ok(FocusMutation {169+ changed: true,170+ neutralization: None,171+ })172+ }173+ FocusStateV2::Neutralizing { .. } => {174+ bail!("controller slot 0 is neutralizing")175+ }176+ }177+ }178+179+ pub(super) fn approve(180+ &mut self,181+ holder: &FocusCandidate,182+ expected_focus_generation: u64,183+ expected_proposal_id: u64,184+ ) -> Result<FocusMutation> {185+ self.ensure_holder(holder, expected_focus_generation)?;186+ let proposal = self.ensure_proposal(holder, expected_proposal_id)?.clone();187+ self.begin_neutralization(188+ FocusTransitionReasonV2::HandoffApproved,189+ Some(FocusCandidate {190+ presence_id: proposal.requester,191+ session_id: proposal.requester_session_id,192+ }),193+ )194+ }195+196+ pub(super) fn deny(197+ &mut self,198+ holder: &FocusCandidate,199+ expected_focus_generation: u64,200+ expected_proposal_id: u64,201+ ) -> Result<FocusMutation> {202+ self.ensure_holder(holder, expected_focus_generation)?;203+ self.ensure_proposal(holder, expected_proposal_id)?;204+ self.clear_proposal();205+ self.transition_reason = FocusTransitionReasonV2::HandoffDenied;206+ Ok(FocusMutation {207+ changed: true,208+ neutralization: None,209+ })210+ }211+212+ pub(super) fn release(213+ &mut self,214+ holder: &FocusCandidate,215+ expected_focus_generation: u64,216+ ) -> Result<FocusMutation> {217+ self.ensure_holder(holder, expected_focus_generation)?;218+ let successor = self.proposal.as_ref().map(|proposal| FocusCandidate {219+ presence_id: proposal.requester.clone(),220+ session_id: proposal.requester_session_id,221+ });222+ self.begin_neutralization(FocusTransitionReasonV2::Released, successor)223+ }224+225+ pub(super) fn preempt(226+ &mut self,227+ owner: FocusCandidate,228+ expected_focus_generation: u64,229+ ) -> Result<FocusMutation> {230+ ensure!(231+ self.is_configured_owner(&owner.presence_id),232+ "viewer is not the configured focus owner"233+ );234+ let FocusStateV2::Held {235+ holder,236+ session_id,237+ focus_generation,238+ ..239+ } = &self.state240+ else {241+ bail!("controller slot 0 is not held")242+ };243+ ensure!(244+ *focus_generation == expected_focus_generation,245+ "preemption expected a stale focus generation"246+ );247+ ensure!(248+ holder != &owner.presence_id || *session_id != owner.session_id,249+ "configured owner already holds focus"250+ );251+ self.begin_neutralization(FocusTransitionReasonV2::Preempted, Some(owner))252+ }253+254+ pub(super) fn begin_invalidation(255+ &mut self,256+ candidate: &FocusCandidate,257+ reason: FocusTransitionReasonV2,258+ ) -> Result<FocusMutation> {259+ let proposal_involved = self.proposal.as_ref().is_some_and(|proposal| {260+ proposal.requester == candidate.presence_id261+ && proposal.requester_session_id == candidate.session_id262+ || proposal.holder == candidate.presence_id263+ && proposal.holder_session_id == candidate.session_id264+ });265+ if proposal_involved {266+ self.clear_proposal();267+ }268+ let held = matches!(269+ &self.state,270+ FocusStateV2::Held { holder, session_id, .. }271+ if holder == &candidate.presence_id && *session_id == candidate.session_id272+ );273+ if held {274+ return self.begin_neutralization(reason, None);275+ }276+ if proposal_involved {277+ self.transition_reason = reason;278+ }279+ Ok(FocusMutation {280+ changed: proposal_involved,281+ neutralization: None,282+ })283+ }284+285+ pub(super) fn mark_activated(&mut self, candidate: &FocusCandidate, focus_generation: u64) {286+ if self.activation.as_ref().is_some_and(|activation| {287+ activation.presence_id == candidate.presence_id288+ && activation.session_id == candidate.session_id289+ && activation.focus_generation == focus_generation290+ }) {291+ self.activation = None;292+ }293+ }294+295+ pub(super) fn begin_activation_expiry(&mut self, now: Instant) -> Result<FocusMutation> {296+ let Some(activation) = self.activation.as_ref() else {297+ return Ok(FocusMutation::default());298+ };299+ if activation.deadline > now {300+ return Ok(FocusMutation::default());301+ }302+ let candidate = FocusCandidate {303+ presence_id: activation.presence_id.clone(),304+ session_id: activation.session_id,305+ };306+ let focus_generation = activation.focus_generation;307+ self.ensure_holder(&candidate, focus_generation)?;308+ self.begin_neutralization(FocusTransitionReasonV2::ActivationExpired, None)309+ }310+311+ pub(super) fn transition_successor(&self, transition_id: u64) -> Option<&FocusCandidate> {312+ match &self.state {313+ FocusStateV2::Neutralizing {314+ transition_id: current,315+ ..316+ } if *current == transition_id => self317+ .successor318+ .as_ref()319+ .map(|successor| &successor.candidate),320+ _ => None,321+ }322+ }323+324+ pub(super) fn complete_transition(325+ &mut self,326+ transition_id: u64,327+ successor_is_valid: bool,328+ now: Instant,329+ ) -> Result<bool> {330+ let FocusStateV2::Neutralizing {331+ slot,332+ transition_id: current,333+ ..334+ } = self.state335+ else {336+ return Ok(false);337+ };338+ if current != transition_id {339+ return Ok(false);340+ }341+ let successor = self.successor.take().filter(|_| successor_is_valid);342+ self.clear_proposal();343+ if let Some(successor) = successor {344+ self.grant(slot, successor.candidate, now)?;345+ if self.transition_reason == FocusTransitionReasonV2::Released {346+ self.transition_reason = FocusTransitionReasonV2::HandoffApproved;347+ }348+ } else {349+ self.state = FocusStateV2::Vacant { slot };350+ self.activation = None;351+ }352+ Ok(true)353+ }354+355+ fn ensure_holder(356+ &self,357+ candidate: &FocusCandidate,358+ expected_focus_generation: u64,359+ ) -> Result<()> {360+ ensure!(361+ matches!(362+ &self.state,363+ FocusStateV2::Held {364+ holder,365+ session_id,366+ focus_generation,367+ ..368+ } if holder == &candidate.presence_id369+ && *session_id == candidate.session_id370+ && *focus_generation == expected_focus_generation371+ ),372+ "focus command does not match the current holder generation"373+ );374+ Ok(())375+ }376+377+ fn ensure_proposal(378+ &self,379+ holder: &FocusCandidate,380+ expected_proposal_id: u64,381+ ) -> Result<&FocusProposalV2> {382+ self.proposal383+ .as_ref()384+ .filter(|proposal| {385+ proposal.proposal_id == expected_proposal_id386+ && proposal.holder == holder.presence_id387+ && proposal.holder_session_id == holder.session_id388+ })389+ .context("handoff decision does not match the pending proposal")390+ }391+392+ fn begin_neutralization(393+ &mut self,394+ reason: FocusTransitionReasonV2,395+ successor: Option<FocusCandidate>,396+ ) -> Result<FocusMutation> {397+ let FocusStateV2::Held {398+ slot,399+ holder,400+ session_id,401+ focus_generation,402+ } = self.state.clone()403+ else {404+ bail!("controller slot 0 is not held")405+ };406+ self.next_transition_id = next_nonzero(self.next_transition_id, "focus transition id")?;407+ self.state = FocusStateV2::Neutralizing {408+ slot,409+ former_holder: holder,410+ former_session_id: session_id,411+ former_focus_generation: focus_generation,412+ transition_id: self.next_transition_id,413+ };414+ self.transition_reason = reason;415+ self.successor = successor.map(|candidate| FocusSuccessor { candidate });416+ self.activation = None;417+ self.clear_proposal();418+ Ok(FocusMutation {419+ changed: true,420+ neutralization: Some(FocusNeutralization {421+ transition_id: self.next_transition_id,422+ former_session_id: session_id,423+ }),424+ })425+ }426+427+ fn grant(428+ &mut self,429+ slot: ControllerSlot,430+ candidate: FocusCandidate,431+ now: Instant,432+ ) -> Result<()> {433+ self.next_focus_generation = next_nonzero(self.next_focus_generation, "focus generation")?;434+ self.state = FocusStateV2::Held {435+ slot,436+ holder: candidate.presence_id.clone(),437+ session_id: candidate.session_id,438+ focus_generation: self.next_focus_generation,439+ };440+ self.activation = Some(ActivationDeadline {441+ presence_id: candidate.presence_id,442+ session_id: candidate.session_id,443+ focus_generation: self.next_focus_generation,444+ deadline: now + FOCUS_ACTIVATION_TTL,445+ });446+ Ok(())447+ }448+449+ fn clear_proposal(&mut self) {450+ self.proposal = None;451+ self.proposal_deadline = None;452+ }453+}454+455+fn next_nonzero(current: u64, name: &'static str) -> Result<u64> {456+ current457+ .checked_add(1)458+ .with_context(|| format!("{name} exhausted"))459+}460+461+fn unix_millis_after(duration: Duration) -> Result<u64> {462+ let now = SystemTime::now()463+ .duration_since(UNIX_EPOCH)464+ .context("system clock is before the Unix epoch")?;465+ u64::try_from((now + duration).as_millis()).context("focus proposal expiry overflowed")466+}467+468+469+mod tests {470+ use super::*;471+472+ fn candidate(handle: &str, session_id: u64) -> FocusCandidate {473+ FocusCandidate {474+ presence_id: ViewerPresenceId::new(handle).unwrap(),475+ session_id,476+ }477+ }478+479+ 480+ fn handoff_publishes_neutralizing_before_successor_generation() {481+ let now = Instant::now();482+ let first = candidate("viewer-0000000000000001", 1);483+ let second = candidate("viewer-0000000000000002", 2);484+ let mut focus = FocusArbiter::new(None);485+ focus.request(first.clone(), now).unwrap();486+ let generation = match focus.state() {487+ FocusStateV2::Held {488+ focus_generation, ..489+ } => *focus_generation,490+ _ => panic!("focus was not granted"),491+ };492+ focus.request(second.clone(), now).unwrap();493+ let proposal_id = focus.proposal().unwrap().proposal_id;494+ let mutation = focus.approve(&first, generation, proposal_id).unwrap();495+ let transition = mutation.neutralization.unwrap();496+ assert!(matches!(focus.state(), FocusStateV2::Neutralizing { .. }));497+ assert_eq!(498+ focus.transition_successor(transition.transition_id),499+ Some(&second)500+ );501+ focus502+ .complete_transition(transition.transition_id, true, now)503+ .unwrap();504+ assert!(matches!(505+ focus.state(),506+ FocusStateV2::Held { holder, session_id: 2, focus_generation, .. }507+ if holder == &second.presence_id && *focus_generation > generation508+ ));509+ }510+511+ 512+ fn only_configured_owner_can_preempt_and_commands_are_rate_limited() {513+ let now = Instant::now();514+ let owner = candidate("viewer-0000000000000001", 1);515+ let holder = candidate("viewer-0000000000000002", 2);516+ let mut focus = FocusArbiter::new(Some(owner.presence_id.clone()));517+ focus.request(holder.clone(), now).unwrap();518+ let generation = match focus.state() {519+ FocusStateV2::Held {520+ focus_generation, ..521+ } => *focus_generation,522+ _ => unreachable!(),523+ };524+ assert!(525+ focus526+ .preempt(candidate("viewer-0000000000000003", 3), generation)527+ .is_err()528+ );529+ assert!(focus.preempt(owner.clone(), generation).is_ok());530+531+ let mut limited = FocusArbiter::new(None);532+ for _ in 0..MAX_FOCUS_COMMANDS_PER_WINDOW {533+ limited.check_rate_limit(&owner, now).unwrap();534+ }535+ assert!(limited.check_rate_limit(&owner, now).is_err());536+ }537+538+ 539+ fn proposals_expire_without_creating_a_queue() {540+ let now = Instant::now();541+ let first = candidate("viewer-0000000000000001", 1);542+ let second = candidate("viewer-0000000000000002", 2);543+ let third = candidate("viewer-0000000000000003", 3);544+ let mut focus = FocusArbiter::new(None);545+ focus.request(first, now).unwrap();546+ focus.request(second, now).unwrap();547+ assert!(focus.request(third.clone(), now).is_err());548+ assert!(focus.expire_proposal(now + FOCUS_PROPOSAL_TTL));549+ assert!(focus.request(third, now + FOCUS_PROPOSAL_TTL).is_ok());550+ }551+}@@ -220,6 +220,32 @@ impl InputOperations {
220220 };221221 self.backend.reset_session()222222 }223+224+ pub(crate) fn neutralize_focus_transition(225+ &self,226+ sessions: &SessionRegistry,227+ transition_id: u64,228+ reason: FocusTransitionReasonV2,229+ ) -> Result<()> {230+ info!(231+ transition_id,232+ ?reason,233+ "focus transition entered no-inject neutralizing state"234+ );235+ self.reset()?;236+ info!(237+ transition_id,238+ ?reason,239+ "focus transition virtual input neutralized"240+ );241+ sessions.complete_v2_focus_transition(transition_id)?;242+ info!(243+ transition_id,244+ ?reason,245+ "focus transition successor committed after neutralization"246+ );247+ Ok(())248+ }223249 }224250 225251 @@ -49,6 +49,7 @@ const SOURCE_REAP_GRACE_TIMEOUT: Duration = Duration::from_secs(1);
4949 const ENCODER_CONTROL_COMMIT_TIMEOUT: Duration = Duration::from_secs(2);5050 5151 mod adaptive;52+mod focus;5253 mod generation;5354 mod handlers;5455 mod media_v3;@@ -195,6 +196,7 @@ fn moq_test_config() -> HostConfig {
195196 identity_path: "identity".into(),196197 state_path: "state".into(),197198 max_viewers: crate::config::DEFAULT_MAX_VIEWERS,199+ focus_owner: None,198200 source: VideoSource::TestPattern,199201 width: Some(1280),200202 height: Some(800),@@ -757,9 +757,17 @@ pub(super) async fn serve_control_moq_v2(
757757 Arc::clone(&shared.telemetry),758758 shared.encoder_control.clone(),759759 )?;760- if lease.replacement_neutralize {761- input_operations.reset()?;762- initial_snapshot = sessions.complete_v2_replacement(remote, lease.session_id)?;760+ if let Some(transition) = lease.replacement_transition {761+ input_operations.neutralize_focus_transition(762+ sessions,763+ transition.transition_id,764+ FocusTransitionReasonV2::Replaced,765+ )?;766+ initial_snapshot = sessions767+ .subscribe_v2_snapshots(remote, lease.session_id)?768+ .borrow()769+ .clone()770+ .context("replacement completion did not publish a session snapshot")?;763771 }764772 // Keep the predecessor's generation lease alive until this viewer has765773 // acquired the shared generation, then retire only that replaced control@@ -848,14 +856,14 @@ pub(super) async fn serve_control_moq_v2(
848856 )849857 .await;850858 851- if sessions.invalidate_v2_focus(859+ if let Some(transition) = sessions.invalidate_v2_focus(852860 remote,853861 lease.session_id,854862 FocusTransitionReasonV2::Disconnected,855863 )? {856- input_operations.reset()?;857- sessions.complete_v2_focus_neutralization(858- lease.session_id,864+ input_operations.neutralize_focus_transition(865+ sessions,866+ transition.transition_id,859867 FocusTransitionReasonV2::Disconnected,860868 )?;861869 }@@ -920,8 +928,19 @@ async fn forward_control_v2_requests(
920928 keyframes: tokio::sync::watch::Sender<Option<MediaControlRequestV3>>,921929 ) -> Result<()> {922930 let mut snapshots = sessions.subscribe_v2_snapshots(remote, session_id)?;931+ let mut focus_deadlines = tokio::time::interval(Duration::from_millis(250));932+ focus_deadlines.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);923933 loop {924934 tokio::select! {935+ _ = focus_deadlines.tick() => {936+ if let Some(transition) = sessions.expire_v2_focus()? {937+ input_operations.neutralize_focus_transition(938+ &sessions,939+ transition.transition_id,940+ FocusTransitionReasonV2::ActivationExpired,941+ )?;942+ }943+ }925944 changed = snapshots.changed() => {926945 changed.context("v2 session snapshot publisher stopped")?;927946 let Some(snapshot) = snapshots.borrow_and_update().clone() else {@@ -941,13 +960,17 @@ async fn forward_control_v2_requests(
941960 let outcome = sessions.apply_focus_command(remote, session_id, &command);942961 let result = match outcome {943962 Ok(effect) => {944- if effect.neutralize {945- input_operations.reset()?;963+ if let Some(transition) = effect.neutralization {964+ input_operations.neutralize_focus_transition(965+ &sessions,966+ transition.transition_id,967+ effect.snapshot.transition_reason,968+ )?;946969 }947970 FocusCommandResultV2 {948971 request_id,949972 accepted: true,950- revision: effect.snapshot.revision,973+ revision: sessions.v2_revision(remote, session_id)?,951974 message: None,952975 }953976 }@@ -16,6 +16,7 @@ use sigil_protocol::{
1616 };1717 use tracing::{debug, warn};1818 19+use super::focus::{FocusArbiter, FocusCandidate, FocusNeutralization};1920 use super::{ENCODER_CONTROL_COMMIT_TIMEOUT, VideoDimensions};2021 use crate::authorization::{AuthorizationMutation, AuthorizedViewer};2122 use crate::clock::SessionClock;@@ -29,7 +30,6 @@ pub struct SessionRegistry {
2930 pending_moq: Mutex<HashMap<(EndpointId, u64), PendingMoqAttachment>>,3031 max_viewers: usize,3132 next_session_id: AtomicU64,32- next_focus_generation: AtomicU64,3333 authorization_committed_revision: AtomicU64,3434 v2_state: Mutex<V2SessionState>,3535 pub(super) session_changed: tokio::sync::Notify,@@ -103,21 +103,17 @@ enum SessionMode {
103103 struct V2SessionState {104104 viewers: HashMap<EndpointId, V2ViewerSession>,105105 revision: u64,106- focus: FocusStateV2,107- transition_reason: FocusTransitionReasonV2,106+ focus: FocusArbiter,108107 media: Option<MediaGenerationDescriptorV2>,109108 live_control_leases: usize,110109 }111110 112-impl Default for V2SessionState {113- fn default() -> Self {111+impl V2SessionState {112+ fn new(configured_owner: Option<ViewerPresenceId>) -> Self {114113 Self {115114 viewers: HashMap::new(),116115 revision: 0,117- focus: FocusStateV2::Vacant {118- slot: ControllerSlot::ZERO,119- },120- transition_reason: FocusTransitionReasonV2::Initial,116+ focus: FocusArbiter::new(configured_owner),121117 media: None,122118 live_control_leases: 0,123119 }@@ -135,13 +131,14 @@ struct V2ViewerSession {
135131 136132 pub(crate) struct FocusCommandEffect {137133 pub(crate) snapshot: SessionSnapshotV2,138- pub(crate) neutralize: bool,134+ pub(crate) neutralization: Option<FocusNeutralization>,139135 }140136 141137 142138 pub struct AuthorizationSessionEffect {143139 pub disconnected: bool,144140 pub neutralize_input: bool,141+ pub focus_transition_id: Option<u64>,145142 }146143 147144 @@ -392,19 +389,26 @@ impl Default for SessionRegistry {
392389 393390 impl SessionRegistry {394391 pub fn new(max_viewers: u8) -> Self {392+ Self::new_with_focus_owner(max_viewers, None)393+ }394+395+ pub fn new_with_focus_owner(max_viewers: u8, focus_owner: Option<String>) -> Self {395396 assert!(396397 (1..=crate::config::MAX_VIEWERS).contains(&max_viewers),397398 "validated max_viewers must be between 1 and {}",398399 crate::config::MAX_VIEWERS399400 );401+ let focus_owner = focus_owner402+ .map(ViewerPresenceId::new)403+ .transpose()404+ .expect("validated focus_owner must be an opaque viewer handle");400405 Self {401406 active: Mutex::new(None),402407 pending_moq: Mutex::new(HashMap::with_capacity(crate::config::MAX_VIEWERS.into())),403408 max_viewers: usize::from(max_viewers),404409 next_session_id: AtomicU64::new(0),405- next_focus_generation: AtomicU64::new(0),406410 authorization_committed_revision: AtomicU64::new(1),407- v2_state: Mutex::new(V2SessionState::default()),411+ v2_state: Mutex::new(V2SessionState::new(focus_owner)),408412 session_changed: tokio::sync::Notify::new(),409413 pending_handshakes: tokio::sync::Semaphore::new(410414 MAX_PENDING_HANDSHAKES * usize::from(max_viewers),@@ -544,39 +548,19 @@ impl SessionRegistry {
544548 "viewer presence id is already active for another peer"545549 );546550 let replaced = state.viewers.remove(&remote);547- let replacement_neutralize = replaced.as_ref().is_some_and(|viewer| {548- matches!(549- &state.focus,550- FocusStateV2::Held {551- holder,552- session_id: holder_session_id,553- ..554- } if holder == &viewer.presence_id555- && *holder_session_id == viewer.session.session_id556- )557- });558- if replacement_neutralize {559- let replaced = replaced.as_ref().expect("replacement viewer disappeared");560- let FocusStateV2::Held {561- slot,562- focus_generation,563- ..564- } = state.focus.clone()565- else {566- unreachable!("replacement focus match was already checked");567- };568- let transition_id = self.next_focus_generation.fetch_add(1, Ordering::Relaxed) + 1;569- state.focus = FocusStateV2::Neutralizing {570- slot,571- former_holder: replaced.presence_id.clone(),572- former_session_id: replaced.session.session_id,573- former_focus_generation: focus_generation,574- transition_id,575- };576- state.transition_reason = FocusTransitionReasonV2::Replaced;577- } else if replacing {578- state.transition_reason = FocusTransitionReasonV2::Replaced;579- }551+ let replacement_transition = replaced552+ .as_ref()553+ .map(|viewer| FocusCandidate {554+ presence_id: viewer.presence_id.clone(),555+ session_id: viewer.session.session_id,556+ })557+ .map(|candidate| {558+ state559+ .focus560+ .begin_invalidation(&candidate, FocusTransitionReasonV2::Replaced)561+ })562+ .transpose()?563+ .and_then(|mutation| mutation.neutralization);580564 let media = state.media.clone().unwrap_or(MediaGenerationDescriptorV2 {581565 generation_id: session_id,582566 broadcast_name: media_generation_moq_broadcast_name(session_id)?,@@ -628,7 +612,7 @@ impl SessionRegistry {
628612 authorization_revision: authorized.authorization_revision,629613 authorization_committed_revision: authorized.committed_revision,630614 replaced_snapshots,631- replacement_neutralize,615+ replacement_transition,632616 })633617 }634618 @@ -670,28 +654,6 @@ impl SessionRegistry {
670654 Ok(snapshot)671655 }672656 673- pub(super) fn complete_v2_replacement(674- &self,675- remote: EndpointId,676- session_id: u64,677- ) -> Result<SessionSnapshotV2> {678- let mut state = self.v2_state.lock().expect("v2 session state poisoned");679- state680- .viewers681- .get(&remote)682- .filter(|viewer| viewer.session.session_id == session_id)683- .context("replacement completion does not match the active viewer")?;684- if matches!(state.focus, FocusStateV2::Neutralizing { .. }) {685- state.focus = FocusStateV2::Vacant {686- slot: ControllerSlot::ZERO,687- };688- state.transition_reason = FocusTransitionReasonV2::Replaced;689- advance_v2_revision(&mut state)?;690- publish_v2_snapshots(&state)?;691- }692- v2_snapshot_for(&state, remote)693- }694-695657 pub(super) fn claim_input(696658 self: &Arc<Self>,697659 remote: EndpointId,@@ -772,7 +734,12 @@ impl SessionRegistry {
772734 state.revision773735 );774736 775- let neutralize = match command.action {737+ let candidate = FocusCandidate {738+ presence_id,739+ session_id,740+ };741+ state.focus.check_rate_limit(&candidate, Instant::now())?;742+ let mutation = match command.action {776743 FocusCommandActionV2::Request => {777744 ensure!(778745 !authorization_neutralizing,@@ -783,51 +750,59 @@ impl SessionRegistry {
783750 || grants.contains(InvitationGrants::GAMEPAD),784751 "viewer is not input-capable"785752 );753+ state.focus.request(candidate, Instant::now())?754+ }755+ FocusCommandActionV2::Approve => state.focus.approve(756+ &candidate,757+ command758+ .expected_focus_generation759+ .expect("validated approval carries a focus generation"),760+ command761+ .expected_proposal_id762+ .expect("validated approval carries a proposal id"),763+ )?,764+ FocusCommandActionV2::Deny => state.focus.deny(765+ &candidate,766+ command767+ .expected_focus_generation768+ .expect("validated denial carries a focus generation"),769+ command770+ .expected_proposal_id771+ .expect("validated denial carries a proposal id"),772+ )?,773+ FocusCommandActionV2::Preempt => {774+ ensure!(775+ !authorization_neutralizing,776+ "input authorization is still neutralizing"777+ );786778 ensure!(787- matches!(state.focus, FocusStateV2::Vacant { .. }),788- "controller slot 0 is occupied; handoff is unavailable"779+ grants.contains(InvitationGrants::POINTER_KEYBOARD)780+ || grants.contains(InvitationGrants::GAMEPAD),781+ "viewer is not input-capable"789782 );790- let focus_generation =791- self.next_focus_generation.fetch_add(1, Ordering::Relaxed) + 1;792- state.focus = FocusStateV2::Held {793- slot: command.slot,794- holder: presence_id.clone(),795- session_id,796- focus_generation,797- };798- state.transition_reason = FocusTransitionReasonV2::Requested;799- false783+ state.focus.preempt(784+ candidate,785+ command786+ .expected_focus_generation787+ .expect("validated preemption carries a focus generation"),788+ )?800789 }801790 FocusCommandActionV2::Release => {802791 let expected_focus_generation = command803792 .expected_focus_generation804793 .expect("validated focus release carries a generation");805- ensure!(806- matches!(807- &state.focus,808- FocusStateV2::Held {809- holder,810- session_id: holder_session_id,811- focus_generation,812- ..813- } if holder == &presence_id814- && *holder_session_id == session_id815- && *focus_generation == expected_focus_generation816- ),817- "focus release does not match the current holder generation"818- );819- state.focus = FocusStateV2::Vacant { slot: command.slot };820- state.transition_reason = FocusTransitionReasonV2::Released;821- true794+ state.focus.release(&candidate, expected_focus_generation)?822795 }823796 };824- advance_v2_revision(&mut state)?;825- publish_v2_snapshots(&state)?;797+ if mutation.changed {798+ advance_v2_revision(&mut state)?;799+ publish_v2_snapshots(&state)?;800+ }826801 let snapshot = v2_snapshot_for(&state, remote)?;827802 self.session_changed.notify_waiters();828803 Ok(FocusCommandEffect {829804 snapshot,830- neutralize,805+ neutralization: mutation.neutralization,831806 })832807 }833808 @@ -836,7 +811,7 @@ impl SessionRegistry {
836811 remote: EndpointId,837812 session_id: u64,838813 reason: FocusTransitionReasonV2,839- ) -> Result<bool> {814+ ) -> Result<Option<FocusNeutralization>> {840815 let mut state = self.v2_state.lock().expect("v2 session state poisoned");841816 let Some(presence_id) = state842817 .viewers@@ -844,60 +819,67 @@ impl SessionRegistry {
844819 .filter(|viewer| viewer.session.session_id == session_id)845820 .map(|viewer| viewer.presence_id.clone())846821 else {847- return Ok(false);848- };849- let FocusStateV2::Held {850- slot,851- holder,852- session_id: holder_session_id,853- focus_generation,854- } = state.focus.clone()855- else {856- return Ok(false);822+ return Ok(None);857823 };858- if holder != presence_id || holder_session_id != session_id {859- return Ok(false);824+ let mutation = state.focus.begin_invalidation(825+ &FocusCandidate {826+ presence_id,827+ session_id,828+ },829+ reason,830+ )?;831+ if mutation.changed {832+ advance_v2_revision(&mut state)?;833+ publish_v2_snapshots(&state)?;860834 }861- state.focus = FocusStateV2::Neutralizing {862- slot,863- former_holder: holder,864- former_session_id: session_id,865- former_focus_generation: focus_generation,866- transition_id: self.next_focus_generation.fetch_add(1, Ordering::Relaxed) + 1,867- };868- state.transition_reason = reason;869- advance_v2_revision(&mut state)?;870- publish_v2_snapshots(&state)?;871835 self.session_changed.notify_waiters();872- Ok(true)836+ Ok(mutation.neutralization)873837 }874838 875- pub(super) fn complete_v2_focus_neutralization(876- &self,877- session_id: u64,878- reason: FocusTransitionReasonV2,879- ) -> Result<()> {839+ pub(super) fn complete_v2_focus_transition(&self, transition_id: u64) -> Result<()> {880840 let mut state = self.v2_state.lock().expect("v2 session state poisoned");881- if !matches!(882- state.focus,883- FocusStateV2::Neutralizing {884- former_session_id,885- ..886- } if former_session_id == session_id887- ) {888- return Ok(());841+ let successor_is_valid = state842+ .focus843+ .transition_successor(transition_id)844+ .and_then(|candidate| {845+ state.viewers.values().find(|viewer| {846+ viewer.presence_id == candidate.presence_id847+ && viewer.session.session_id == candidate.session_id848+ && viewer.session.media_active849+ && !viewer.authorization_neutralizing850+ && (viewer851+ .session852+ .grants853+ .contains(InvitationGrants::POINTER_KEYBOARD)854+ || viewer.session.grants.contains(InvitationGrants::GAMEPAD))855+ })856+ })857+ .is_some();858+ if state859+ .focus860+ .complete_transition(transition_id, successor_is_valid, Instant::now())?861+ {862+ advance_v2_revision(&mut state)?;863+ publish_v2_snapshots(&state)?;889864 }890- state.focus = FocusStateV2::Vacant {891- slot: ControllerSlot::ZERO,892- };893- state.transition_reason = reason;894- advance_v2_revision(&mut state)?;895- publish_v2_snapshots(&state)?;896865 drop(state);897866 self.session_changed.notify_waiters();898867 Ok(())899868 }900869 870+ pub(super) fn expire_v2_focus(&self) -> Result<Option<FocusNeutralization>> {871+ let mut state = self.v2_state.lock().expect("v2 session state poisoned");872+ let now = Instant::now();873+ let proposal_expired = state.focus.expire_proposal(now);874+ let activation = state.focus.begin_activation_expiry(now)?;875+ if proposal_expired || activation.changed {876+ advance_v2_revision(&mut state)?;877+ publish_v2_snapshots(&state)?;878+ self.session_changed.notify_waiters();879+ }880+ Ok(activation.neutralization)881+ }882+901883 pub fn apply_authorization_mutation(902884 &self,903885 mutation: &AuthorizationMutation,@@ -927,16 +909,19 @@ impl SessionRegistry {
927909 || mutation.previous_grants.contains(InvitationGrants::GAMEPAD)928910 && !current_grants.contains(InvitationGrants::GAMEPAD);929911 930- let held_focus = matches!(931- &state.focus,932- FocusStateV2::Held {933- holder,934- session_id,935- ..936- } if holder == &viewer.presence_id && *session_id == viewer.session.session_id937- );938- let neutralize_input = input_reduced;912+ let focus_candidate = FocusCandidate {913+ presence_id: viewer.presence_id.clone(),914+ session_id: viewer.session.session_id,915+ };916+ let input_claimed = viewer.session.input_claimed;939917 let disconnected = mutation.current_grants.is_none();918+ let focus_mutation = if disconnected || input_reduced {919+ state920+ .focus921+ .begin_invalidation(&focus_candidate, FocusTransitionReasonV2::Revoked)?922+ } else {923+ Default::default()924+ };940925 941926 if disconnected {942927 let viewer = state@@ -944,38 +929,9 @@ impl SessionRegistry {
944929 .remove(&mutation.peer)945930 .expect("validated authorization viewer disappeared");946931 viewer.snapshots.send_replace(None);947- if held_focus {948- let FocusStateV2::Held {949- slot,950- focus_generation,951- ..952- } = state.focus.clone()953- else {954- unreachable!("held focus was already checked");955- };956- state.focus = FocusStateV2::Neutralizing {957- slot,958- former_holder: viewer.presence_id,959- former_session_id: viewer.session.session_id,960- former_focus_generation: focus_generation,961- transition_id: self.next_focus_generation.fetch_add(1, Ordering::Relaxed) + 1,962- };963- }964932 let mut pending = self.pending_moq.lock().expect("MoQ registry poisoned");965933 pending.remove(&(mutation.peer, viewer.session.session_id));966- state.transition_reason = FocusTransitionReasonV2::Revoked;967934 } else if let Some(grants) = mutation.current_grants {968- let held_focus_details = held_focus.then(|| {969- let FocusStateV2::Held {970- slot,971- focus_generation,972- ..973- } = state.focus.clone()974- else {975- unreachable!("held focus was already checked");976- };977- (slot, focus_generation)978- });979935 let viewer = state980936 .viewers981937 .get_mut(&mutation.peer)@@ -984,20 +940,8 @@ impl SessionRegistry {
984940 viewer.session.authorization_committed_revision = mutation.committed_revision;985941 viewer.session.grants = grants;986942 if input_reduced {987- viewer.authorization_neutralizing = true;988- let former_holder = viewer.presence_id.clone();989- let former_session_id = viewer.session.session_id;990- if let Some((slot, focus_generation)) = held_focus_details {991- state.focus = FocusStateV2::Neutralizing {992- slot,993- former_holder,994- former_session_id,995- former_focus_generation: focus_generation,996- transition_id: self.next_focus_generation.fetch_add(1, Ordering::Relaxed)997- + 1,998- };999- }1000- state.transition_reason = FocusTransitionReasonV2::Revoked;943+ viewer.authorization_neutralizing =944+ input_claimed || focus_mutation.neutralization.is_some();1001945 }1002946 }1003947 advance_v2_revision(&mut state)?;@@ -1006,7 +950,10 @@ impl SessionRegistry {
1006950 self.session_changed.notify_waiters();1007951 Ok(AuthorizationSessionEffect {1008952 disconnected,1009- neutralize_input: neutralize_input || disconnected && held_focus,953+ neutralize_input: input_claimed || focus_mutation.neutralization.is_some(),954+ focus_transition_id: focus_mutation955+ .neutralization956+ .map(|transition| transition.transition_id),1010957 })1011958 }1012959 @@ -1014,6 +961,7 @@ impl SessionRegistry {
1014961 &self,1015962 remote: EndpointId,1016963 authorization_revision: u64,964+ focus_transition_id: Option<u64>,1017965 ) -> Result<()> {1018966 let mut state = self.v2_state.lock().expect("v2 session state poisoned");1019967 if let Some(viewer) = state.viewers.get_mut(&remote)@@ -1021,10 +969,14 @@ impl SessionRegistry {
1021969 {1022970 viewer.authorization_neutralizing = false;1023971 }1024- if matches!(state.focus, FocusStateV2::Neutralizing { .. }) {1025- state.focus = FocusStateV2::Vacant {1026- slot: ControllerSlot::ZERO,1027- };972+ let completed_focus = if let Some(transition_id) = focus_transition_id {973+ state974+ .focus975+ .complete_transition(transition_id, false, Instant::now())?976+ } else {977+ false978+ };979+ if completed_focus {1028980 advance_v2_revision(&mut state)?;1029981 publish_v2_snapshots(&state)?;1030982 }@@ -1060,7 +1012,7 @@ impl SessionRegistry {
10601012 .context("input v2 connection does not match the active session")?;10611013 ensure!(10621014 matches!(1063- &state.focus,1015+ state.focus.state(),10641016 FocusStateV2::Held {10651017 holder,10661018 session_id: holder_session_id,@@ -1073,6 +1025,13 @@ impl SessionRegistry {
10731025 ),10741026 "input v2 connection does not own the authoritative focus generation"10751027 );1028+ state.focus.mark_activated(1029+ &FocusCandidate {1030+ presence_id: presence_id.clone(),1031+ session_id,1032+ },1033+ focus_generation,1034+ );10761035 let viewer = state10771036 .viewers10781037 .get_mut(&remote)@@ -1107,7 +1066,7 @@ impl SessionRegistry {
11071066 return false;11081067 };11091068 matches!(1110- &state.focus,1069+ state.focus.state(),11111070 FocusStateV2::Held {11121071 holder,11131072 session_id: holder_session_id,@@ -1505,18 +1464,13 @@ impl SessionRegistry {
15051464 return;15061465 };15071466 let presence_id = viewer.presence_id.clone();1508- let input_claimed = viewer.session.input_claimed;1509- let held_focus = match &state.focus {1510- FocusStateV2::Held {1511- holder,1512- session_id: holder_session_id,1513- focus_generation,1514- slot,1515- } if holder == &presence_id && *holder_session_id == session_id => {1516- Some((*slot, *focus_generation))1517- }1518- _ => None,1519- };1467+ let focus_mutation = state.focus.begin_invalidation(1468+ &FocusCandidate {1469+ presence_id: presence_id.clone(),1470+ session_id,1471+ },1472+ FocusTransitionReasonV2::Disconnected,1473+ );15201474 let viewer = state15211475 .viewers15221476 .remove(&remote)@@ -1526,19 +1480,8 @@ impl SessionRegistry {
15261480 .lock()15271481 .expect("MoQ registry poisoned")15281482 .remove(&(remote, session_id));1529- if let Some((slot, focus_generation)) = held_focus {1530- if input_claimed {1531- state.focus = FocusStateV2::Neutralizing {1532- slot,1533- former_holder: presence_id,1534- former_session_id: session_id,1535- former_focus_generation: focus_generation,1536- transition_id: self.next_focus_generation.fetch_add(1, Ordering::Relaxed) + 1,1537- };1538- } else {1539- state.focus = FocusStateV2::Vacant { slot };1540- }1541- state.transition_reason = FocusTransitionReasonV2::Disconnected;1483+ if let Err(error) = focus_mutation {1484+ warn!(%error, session_id, "failed to invalidate focus during viewer release");15421485 }15431486 if state.viewers.is_empty() {15441487 state.media = None;@@ -1591,17 +1534,18 @@ impl SessionRegistry {
15911534 {15921535 viewer.session.input_claimed = false;15931536 }1594- let completed_neutralization = matches!(1595- &state.focus,1537+ let completed_neutralization = match state.focus.state() {15961538 FocusStateV2::Neutralizing {15971539 former_session_id,1540+ transition_id,15981541 ..1599- } if *former_session_id == session_id1600- );1601- if completed_neutralization {1602- state.focus = FocusStateV2::Vacant {1603- slot: ControllerSlot::ZERO,1604- };1542+ } if *former_session_id == session_id => Some(*transition_id),1543+ _ => None,1544+ };1545+ if let Some(transition_id) = completed_neutralization {1546+ let _ = state1547+ .focus1548+ .complete_transition(transition_id, false, Instant::now());16051549 if advance_v2_revision(&mut state).is_ok() {16061550 let _ = publish_v2_snapshots(&state);16071551 }@@ -1690,8 +1634,10 @@ fn v2_snapshot_for(state: &V2SessionState, remote: EndpointId) -> Result<Session
16901634 revision: state.revision,16911635 self_presence_id: self_viewer.presence_id.clone(),16921636 viewers,1693- focus: state.focus.clone(),1694- transition_reason: state.transition_reason,1637+ focus: state.focus.state().clone(),1638+ focus_proposal: state.focus.proposal().cloned(),1639+ self_is_focus_owner: state.focus.is_configured_owner(&self_viewer.presence_id),1640+ transition_reason: state.focus.transition_reason(),16951641 media: state.media.clone().unwrap_or(MediaGenerationDescriptorV2 {16961642 generation_id: self_viewer.session.media_generation_id,16971643 broadcast_name: self_viewer.session.media_broadcast_name.clone(),@@ -1721,7 +1667,7 @@ pub(crate) struct V2SessionLease {
17211667 pub(super) authorization_revision: u64,17221668 pub(super) authorization_committed_revision: u64,17231669 replaced_snapshots: Option<tokio::sync::watch::Sender<Option<SessionSnapshotV2>>>,1724- pub(super) replacement_neutralize: bool,1670+ pub(super) replacement_transition: Option<FocusNeutralization>,17251671 }17261672 17271673 impl V2SessionLease {@@ -1982,12 +1928,13 @@ mod tests {
19821928 slot: ControllerSlot::ZERO,19831929 expected_revision: 1,19841930 expected_focus_generation: None,1931+ expected_proposal_id: None,19851932 };19861933 let granted = sessions19871934 .apply_focus_command(remote, media.session_id, &request)19881935 .unwrap();19891936 assert_eq!(granted.snapshot.revision, 2);1990- assert!(!granted.neutralize);1937+ assert!(granted.neutralization.is_none());19911938 let focus_generation = granted.snapshot.self_focus_generation().unwrap();19921939 assert!(sessions.is_v2_focus_owner(19931940 remote,@@ -2022,12 +1969,15 @@ mod tests {
20221969 slot: ControllerSlot::ZERO,20231970 expected_revision: 2,20241971 expected_focus_generation: Some(focus_generation),1972+ expected_proposal_id: None,20251973 };20261974 let released = sessions20271975 .apply_focus_command(remote, media.session_id, &release)20281976 .unwrap();20291977 assert_eq!(released.snapshot.revision, 3);2030- assert!(released.neutralize);1978+ let transition = released1979+ .neutralization1980+ .expect("release must publish neutralizing state");20311981 assert!(!sessions.is_v2_focus_owner(20321982 remote,20331983 media.session_id,@@ -2040,6 +1990,10 @@ mod tests {
20401990 .is_err(),20411991 "a stale release must not mutate the newer snapshot revision"20421992 );1993+ sessions1994+ .complete_v2_focus_transition(transition.transition_id)1995+ .unwrap();1996+ assert_eq!(sessions.v2_revision(remote, media.session_id).unwrap(), 4);20431997 drop(input);20441998 drop(media);20451999 }@@ -2129,6 +2083,7 @@ mod tests {
21292083 slot: ControllerSlot::ZERO,21302084 expected_revision: 1,21312085 expected_focus_generation: None,2086+ expected_proposal_id: None,21322087 },21332088 )21342089 .unwrap();@@ -2141,13 +2096,21 @@ mod tests {
21412096 authorized_viewer("viewer-one", InvitationGrants::ALL),21422097 )21432098 .unwrap();2144- assert!(replacement.replacement_neutralize);2099+ let transition = replacement2100+ .replacement_transition2101+ .expect("focused replacement must neutralize");21452102 assert!(matches!(21462103 replacement.initial_snapshot.focus,21472104 FocusStateV2::Neutralizing { .. }21482105 ));2106+ sessions2107+ .complete_v2_focus_transition(transition.transition_id)2108+ .unwrap();21492109 let replacement_snapshot = sessions2150- .complete_v2_replacement(remote, replacement.session_id)2110+ .subscribe_v2_snapshots(remote, replacement.session_id)2111+ .unwrap()2112+ .borrow()2113+ .clone()21512114 .unwrap();21522115 assert!(matches!(21532116 replacement_snapshot.focus,@@ -2160,7 +2123,7 @@ mod tests {
21602123 }21612124 21622125 2163- fn occupied_focus_rejects_another_viewer_without_queueing_or_mutating_revision() {2126+ fn occupied_focus_retains_one_proposal_without_queueing_additional_viewers() {21642127 let sessions = Arc::new(SessionRegistry::new(3));21652128 let first = sessions21662129 .claim_v2_authorized(@@ -2176,7 +2139,14 @@ mod tests {
21762139 authorized_viewer("viewer-two", InvitationGrants::ALL),21772140 )21782141 .unwrap();2179- let revision = second.initial_snapshot.revision;2142+ let third = sessions2143+ .claim_v2_authorized(2144+ endpoint(3),2145+ [3; 16],2146+ authorized_viewer("viewer-three", InvitationGrants::ALL),2147+ )2148+ .unwrap();2149+ let revision = third.initial_snapshot.revision;21802150 sessions21812151 .apply_focus_command(21822152 endpoint(1),@@ -2187,13 +2157,14 @@ mod tests {
21872157 slot: ControllerSlot::ZERO,21882158 expected_revision: revision,21892159 expected_focus_generation: None,2160+ expected_proposal_id: None,21902161 },21912162 )21922163 .unwrap();21932164 let occupied_revision = sessions21942165 .v2_revision(endpoint(2), second.session_id)21952166 .unwrap();2196- let error = sessions2167+ let proposal = sessions21972168 .apply_focus_command(21982169 endpoint(2),21992170 second.session_id,@@ -2203,15 +2174,35 @@ mod tests {
22032174 slot: ControllerSlot::ZERO,22042175 expected_revision: occupied_revision,22052176 expected_focus_generation: None,2177+ expected_proposal_id: None,2178+ },2179+ )2180+ .unwrap();2181+ assert_eq!(2182+ proposal.snapshot.focus_proposal.as_ref().unwrap().requester,2183+ second.initial_snapshot.self_presence_id2184+ );2185+ let proposal_revision = proposal.snapshot.revision;2186+ let error = sessions2187+ .apply_focus_command(2188+ endpoint(3),2189+ third.session_id,2190+ &FocusCommandV2 {2191+ request_id: 3,2192+ action: FocusCommandActionV2::Request,2193+ slot: ControllerSlot::ZERO,2194+ expected_revision: proposal_revision,2195+ expected_focus_generation: None,2196+ expected_proposal_id: None,22062197 },22072198 )22082199 .unwrap_err();2209- assert!(error.to_string().contains("handoff is unavailable"));2200+ assert!(error.to_string().contains("pending handoff proposal"));22102201 assert_eq!(22112202 sessions22122203 .v2_revision(endpoint(2), second.session_id)22132204 .unwrap(),2214- occupied_revision2205+ proposal_revision22152206 );22162207 }22172208 @@ -2242,6 +2233,7 @@ mod tests {
22422233 slot: ControllerSlot::ZERO,22432234 expected_revision: 1,22442235 expected_focus_generation: None,2236+ expected_proposal_id: None,22452237 },22462238 )22472239 .unwrap();@@ -2269,6 +2261,7 @@ mod tests {
22692261 slot: ControllerSlot::ZERO,22702262 expected_revision: 3,22712263 expected_focus_generation: None,2264+ expected_proposal_id: None,22722265 },22732266 )22742267 .unwrap_err()@@ -2276,7 +2269,7 @@ mod tests {
22762269 .contains("still neutralizing")22772270 );22782271 sessions2279- .complete_authorization_neutralization(remote, 2)2272+ .complete_authorization_neutralization(remote, 2, effect.focus_transition_id)22802273 .unwrap();22812274 assert!(22822275 sessions@@ -2289,6 +2282,7 @@ mod tests {
22892282 slot: ControllerSlot::ZERO,22902283 expected_revision: 4,22912284 expected_focus_generation: None,2285+ expected_proposal_id: None,22922286 },22932287 )22942288 .is_ok()@@ -813,6 +813,7 @@ mod tests {
813813 identity_path: PathBuf::from("/tmp/host.key"),814814 state_path: PathBuf::from("/tmp/state"),815815 max_viewers: crate::config::DEFAULT_MAX_VIEWERS,816+ focus_owner: None,816817 source: VideoSource::GamescopePipewire,817818 width: Some(1280),818819 height: Some(800),@@ -119,6 +119,12 @@ impl ViewerPresenceV2 {
119119 pub enum FocusTransitionReasonV2 {120120 Initial,121121 Requested,122+ HandoffRequested,123+ HandoffApproved,124+ HandoffDenied,125+ ProposalExpired,126+ Preempted,127+ ActivationExpired,122128 Released,123129 Disconnected,124130 Replaced,@@ -194,6 +200,40 @@ impl FocusStateV2 {
194200 }195201 }196202 203+204+205+pub struct FocusProposalV2 {206+ pub proposal_id: u64,207+ pub slot: ControllerSlot,208+ pub requester: ViewerPresenceId,209+ pub requester_session_id: u64,210+ pub holder: ViewerPresenceId,211+ pub holder_session_id: u64,212+ pub expires_at_unix_ms: u64,213+}214+215+impl FocusProposalV2 {216+ pub fn validate(&self) -> Result<()> {217+ if self.proposal_id == 0218+ || self.requester_session_id == 0219+ || self.holder_session_id == 0220+ || self.expires_at_unix_ms == 0221+ {222+ return invalid(223+ "focus proposal",224+ "proposal, session, and expiry identifiers must be non-zero",225+ );226+ }227+ self.slot.validate()?;228+ self.requester.validate()?;229+ self.holder.validate()?;230+ if self.requester == self.holder && self.requester_session_id == self.holder_session_id {231+ return invalid("focus proposal", "holder cannot request focus from itself");232+ }233+ Ok(())234+ }235+}236+197237 198238 199239 pub struct MediaGenerationDescriptorV2 {@@ -226,6 +266,9 @@ pub struct SessionSnapshotV2 {
226266 pub self_presence_id: ViewerPresenceId,227267 pub viewers: Vec<ViewerPresenceV2>,228268 pub focus: FocusStateV2,269+ 270+ pub focus_proposal: Option<FocusProposalV2>,271+ pub self_is_focus_owner: bool,229272 pub transition_reason: FocusTransitionReasonV2,230273 pub media: MediaGenerationDescriptorV2,231274 }@@ -273,6 +316,37 @@ impl SessionSnapshotV2 {
273316 {274317 return invalid("session snapshot", "focus holder is absent from the roster");275318 }319+ if let Some(proposal) = &self.focus_proposal {320+ proposal.validate()?;321+ let FocusStateV2::Held {322+ slot,323+ holder,324+ session_id,325+ ..326+ } = &self.focus327+ else {328+ return invalid("session snapshot", "focus proposals require a held slot");329+ };330+ if *slot != proposal.slot331+ || holder != &proposal.holder332+ || *session_id != proposal.holder_session_id333+ {334+ return invalid(335+ "session snapshot",336+ "focus proposal does not match the authoritative holder",337+ );338+ }339+ if !self.viewers.iter().any(|viewer| {340+ viewer.presence_id == proposal.requester341+ && viewer.session_id == proposal.requester_session_id342+ && viewer.input_capable343+ }) {344+ return invalid(345+ "session snapshot",346+ "focus proposal requester is absent or not input-capable",347+ );348+ }349+ }276350 self.media.validate()277351 }278352 @@ -304,7 +378,10 @@ impl SessionSnapshotV2 {
304378 305379 pub enum FocusCommandActionV2 {306380 Request,381+ Approve,382+ Deny,307383 Release,384+ Preempt,308385 }309386 310387 @@ -316,6 +393,8 @@ pub struct FocusCommandV2 {
316393 pub expected_revision: u64,317394 318395 pub expected_focus_generation: Option<u64>,396+ 397+ pub expected_proposal_id: Option<u64>,319398 }320399 321400 impl FocusCommandV2 {@@ -327,19 +406,28 @@ impl FocusCommandV2 {
327406 );328407 }329408 self.slot.validate()?;409+ let focus_generation = self.expected_focus_generation.filter(|value| *value != 0);410+ let proposal_id = self.expected_proposal_id.filter(|value| *value != 0);330411 match self.action {331- FocusCommandActionV2::Request if self.expected_focus_generation.is_some() => invalid(332- "focus command",333- "focus requests must not carry an expected focus generation",334- ),335- FocusCommandActionV2::Release336- if self337- .expected_focus_generation338- .is_none_or(|generation| generation == 0) =>412+ FocusCommandActionV2::Request413+ if focus_generation.is_some() || proposal_id.is_some() =>414+ {415+ invalid("focus command", "focus requests forbid expected ownership")416+ }417+ FocusCommandActionV2::Approve | FocusCommandActionV2::Deny418+ if focus_generation.is_none() || proposal_id.is_none() =>419+ {420+ invalid(421+ "focus command",422+ "handoff decisions require focus generation and proposal id",423+ )424+ }425+ FocusCommandActionV2::Release | FocusCommandActionV2::Preempt426+ if focus_generation.is_none() || proposal_id.is_some() =>339427 {340428 invalid(341429 "focus command",342- "focus release requires a non-zero expected focus generation",430+ "release and preempt require only a focus generation",343431 )344432 }345433 _ => Ok(()),@@ -666,6 +754,8 @@ mod tests {
666754 focus: FocusStateV2::Vacant {667755 slot: ControllerSlot::ZERO,668756 },757+ focus_proposal: None,758+ self_is_focus_owner: false,669759 transition_reason: FocusTransitionReasonV2::Initial,670760 media: MediaGenerationDescriptorV2 {671761 generation_id: 7,@@ -678,7 +768,7 @@ mod tests {
678768 fn control_v2_snapshot_json_is_a_golden_vector() {679769 assert_eq!(680770 serde_json::to_string(&snapshot(1)).unwrap(),681- r#"{"revision":1,"self_presence_id":"viewer-0001","viewers":[{"presence_id":"viewer-0001","session_id":7,"input_capable":true,"you":true}],"focus":{"state":"vacant","slot":0},"transition_reason":"initial","media":{"generation_id":7,"broadcast_name":"sigil/session/7/video"}}"#771+ r#"{"revision":1,"self_presence_id":"viewer-0001","viewers":[{"presence_id":"viewer-0001","session_id":7,"input_capable":true,"you":true}],"focus":{"state":"vacant","slot":0},"self_is_focus_owner":false,"transition_reason":"initial","media":{"generation_id":7,"broadcast_name":"sigil/session/7/video"}}"#682772 );683773 }684774 @@ -699,6 +789,7 @@ mod tests {
699789 slot: ControllerSlot::ZERO,700790 expected_revision: 1,701791 expected_focus_generation: None,792+ expected_proposal_id: None,702793 };703794 request.validate().unwrap();704795 let mut release = request.clone();@@ -719,6 +810,7 @@ mod tests {
719810 slot: ControllerSlot::ZERO,720811 expected_revision: 1,721812 expected_focus_generation: None,813+ expected_proposal_id: None,722814 },723815 };724816 let (mut sender, mut receiver) = duplex(4096);@@ -23,11 +23,12 @@ mod subscription;
2323 pub use audio::{AUDIO_HEADER_LEN, AudioCodec, AudioFlags, AudioPacket, AudioPacketHeader};2424 pub use control_v2::{2525 ClientControlEnvelopeV2, ClientHelloV2, ControlKeyframeReasonV2, FocusCommandActionV2,26- FocusCommandResultV2, FocusCommandV2, FocusStateV2, FocusTransitionReasonV2, HostHelloV2,27- MAX_CONTROL_V2_MESSAGE_LEN, MAX_SESSION_ROSTER_VIEWERS, MediaGenerationDescriptorV2,28- ServerControlEnvelopeV2, SessionSnapshotV2, ViewerPresenceId, ViewerPresenceV2,29- read_client_control_v2, read_client_hello_v2, read_host_hello_v2, read_server_control_v2,30- write_client_control_v2, write_client_hello_v2, write_host_hello_v2, write_server_control_v2,26+ FocusCommandResultV2, FocusCommandV2, FocusProposalV2, FocusStateV2, FocusTransitionReasonV2,27+ HostHelloV2, MAX_CONTROL_V2_MESSAGE_LEN, MAX_SESSION_ROSTER_VIEWERS,28+ MediaGenerationDescriptorV2, ServerControlEnvelopeV2, SessionSnapshotV2, ViewerPresenceId,29+ ViewerPresenceV2, read_client_control_v2, read_client_hello_v2, read_host_hello_v2,30+ read_server_control_v2, write_client_control_v2, write_client_hello_v2, write_host_hello_v2,31+ write_server_control_v2,3132 };3233 pub use error::{ProtocolError, Result};3334 pub use feedback::{@@ -80,6 +80,22 @@ Concurrent capacity and durable enrollment are intentionally separate:
8080 | `max_viewers` | 3 | 8 | Simultaneously connected native-MoQ v2 viewers |8181 | `MAX_ENROLLED_VIEWERS` | fixed | 32 | Durable authorization-v2 viewer records |8282 83+Slot-0 focus uses one bounded holder-approved handoff proposal, not a FIFO.84+When the slot is free, the first eligible request wins. While occupied, one85+requester may wait up to 15 seconds for the holder to approve, deny, or release86+toward it; additional requests receive a busy response. A grant must activate87+an input-v2 sidecar within 10 seconds or Sigil revokes it. Every transfer first88+publishes a no-inject neutralizing state, resets all virtual input devices, and89+only then publishes a successor focus generation.90+91+An operator may set `focus_owner = "viewer-<16 lowercase hex digits>"` to the92+stable opaque handle shown by enrollment administration. That exact enrolled93+viewer may request controller-confirmed preemption. The setting does not grant94+view or input capability, does not allow another viewer to preempt, and does95+not create an administrative or arbitrary preemption path. Like96+`max_viewers`, it is a reviewed direct TOML setting and requires service97+restart; omission disables owner preemption.98+8399 Requests are strict JSON and contain an optimistic-concurrency revision:84100 85101 ```json@@ -55,6 +55,7 @@ export function createControlRuntime({
5555 let controlTransitionInProgress = false;5656 let controlTransitionGeneration = 0;5757 let nativeCursorCommand = Promise.resolve();58+ let hostReleaseCommand = Promise.resolve();5859 let browserPointerLockRequired = true;5960 6061 function connection() {@@ -137,6 +138,7 @@ export function createControlRuntime({
137138 }138139 139140 function exit({ resetEscape = false, releaseHostFocus = true } = {}) {141+ if (!controlMode && !controlTransitionInProgress) return false;140142 controlTransitionGeneration += 1;141143 controlTransitionInProgress = false;142144 controllerActivationGate.reset();@@ -146,7 +148,19 @@ export function createControlRuntime({
146148 controlMode = false;147149 releasePointerLock();148150 onChange();149- if (releaseHostFocus) onExit();151+ if (releaseHostFocus) {152+ const releaseGeneration = controlTransitionGeneration;153+ hostReleaseCommand = Promise.resolve(inputRuntime.drain(250))154+ .then(() => {155+ if (releaseGeneration !== controlTransitionGeneration || controlMode) return false;156+ return onExit();157+ })158+ .catch((error) => {159+ logger.error('host focus release failed after local neutralization:', error);160+ return false;161+ });162+ }163+ return true;150164 }151165 152166 function exitAfterBrowserPointerLockFailure() {@@ -251,6 +265,7 @@ export function createControlRuntime({
251265 252266 async function prepareDisconnect(timeoutMs = 250) {253267 exit();268+ await hostReleaseCommand;254269 await inputRuntime.drain(timeoutMs);255270 }256271 @@ -82,12 +82,14 @@ function newHarness({
8282 change: 0,8383 releaseFailures: [],8484 exits: 0,85+ hostReleases: 0,86+ exitOrder: [],8587 };8688 const inputRuntime = {8789 send: (event) => { sent.push(event); return true; },8890 clear: () => { calls.clear += 1; },89- releaseHeld: () => { calls.releaseHeld += 1; },90- drain: async (timeoutMs) => { calls.drains.push(timeoutMs); },91+ releaseHeld: () => { calls.releaseHeld += 1; calls.exitOrder.push('neutral'); },92+ drain: async (timeoutMs) => { calls.drains.push(timeoutMs); calls.exitOrder.push('drain'); },9193 };9294 const pointerLock = {9395 target,@@ -113,6 +115,7 @@ function newHarness({
113115 publishController: () => { calls.publish += 1; },114116 resetControllerEscape: () => { calls.resetEscape += 1; },115117 onChange: () => { calls.change += 1; },118+ onExit: async () => { calls.hostReleases += 1; calls.exitOrder.push('host-release'); },116119 onReleaseFailure: (error) => calls.releaseFailures.push(error),117120 pointerLockTimeoutMs,118121 scheduleTimeout,@@ -272,6 +275,8 @@ test('losing a browser-owned lock exits active control exactly once', async () =
272275 assert.equal(harness.calls.resetEscape, 1);273276 assert.equal(harness.calls.releaseHeld, 1);274277 assert.equal(harness.runtime.handleBrowserPointerLockChange(), false);278+ await eventually(() => assert.equal(harness.calls.hostReleases, 1));279+ assert.deepEqual(harness.calls.exitOrder.slice(0, 3), ['neutral', 'drain', 'host-release']);275280 });276281 277282 test('a native-only session ignores browser ownership changes', async () => {@@ -341,7 +346,7 @@ test('disconnect exits control and awaits the requested finite input drain', asy
341346 342347 assert.equal(harness.runtime.active, false);343348 assert.equal(harness.calls.releaseHeld, 1);344- assert.deepEqual(harness.calls.drains, [250]);349+ assert.deepEqual(harness.calls.drains, [250, 250]);345350 assert.equal(harness.calls.change, 2);346351 });347352 @@ -0,0 +1,51 @@
1+function selfViewer(snapshot) {2+ return snapshot?.viewers?.find((viewer) => viewer.you === true) ?? null;3+}4+5+export function handoffProposalForSelf(session) {6+ const snapshot = session?.snapshot;7+ const proposal = snapshot?.focus_proposal;8+ const self = selfViewer(snapshot);9+ if (!proposal || !self || !session.focused) return null;10+ if (proposal.holder !== self.presence_id || proposal.holder_session_id !== self.session_id) {11+ return null;12+ }13+ return Object.freeze({14+ type: 'handoff',15+ proposalId: proposal.proposal_id,16+ requester: proposal.requester,17+ revision: snapshot.revision,18+ });19+}20+21+export function preemptionCandidateForSelf(session) {22+ const snapshot = session?.snapshot;23+ const focus = snapshot?.focus;24+ if (snapshot?.self_is_focus_owner !== true25+ || !session.eligible26+ || session.focused27+ || focus?.state !== 'held') return null;28+ return Object.freeze({29+ type: 'preempt',30+ holder: focus.holder,31+ holderSessionId: focus.session_id,32+ focusGeneration: focus.focus_generation,33+ revision: snapshot.revision,34+ });35+}36+37+export function focusOverlayStillCurrent(overlay, session) {38+ if (!overlay) return false;39+ if (overlay.type === 'handoff') {40+ const current = handoffProposalForSelf(session);41+ return current?.proposalId === overlay.proposalId && current.revision >= overlay.revision;42+ }43+ if (overlay.type === 'preempt') {44+ const current = preemptionCandidateForSelf(session);45+ return current?.holder === overlay.holder46+ && current.holderSessionId === overlay.holderSessionId47+ && current.focusGeneration === overlay.focusGeneration48+ && current.revision >= overlay.revision;49+ }50+ return false;51+}@@ -0,0 +1,72 @@
1+import test from 'node:test';2+import assert from 'node:assert/strict';3+import fs from 'node:fs';4+5+import {6+ focusOverlayStillCurrent,7+ handoffProposalForSelf,8+ preemptionCandidateForSelf,9+} from './focus-overlay.mjs';10+11+const html = fs.readFileSync(new URL('./index.html', import.meta.url), 'utf8');12+const main = fs.readFileSync(new URL('./main.js', import.meta.url), 'utf8');13+14+function session() {15+ return {16+ eligible: true,17+ focused: true,18+ snapshot: {19+ revision: 4,20+ self_presence_id: 'viewer-a',21+ self_is_focus_owner: false,22+ viewers: [23+ { presence_id: 'viewer-a', session_id: 1, you: true, input_capable: true },24+ { presence_id: 'viewer-b', session_id: 2, you: false, input_capable: true },25+ ],26+ focus: { state: 'held', holder: 'viewer-a', session_id: 1, focus_generation: 8 },27+ focus_proposal: {28+ proposal_id: 3,29+ holder: 'viewer-a',30+ holder_session_id: 1,31+ requester: 'viewer-b',32+ requester_session_id: 2,33+ },34+ },35+ };36+}37+38+test('holder sees one opaque handoff proposal and stale proposals dismiss immediately', () => {39+ const current = session();40+ const overlay = handoffProposalForSelf(current);41+ assert.deepEqual(overlay, {42+ type: 'handoff', proposalId: 3, requester: 'viewer-b', revision: 4,43+ });44+ assert.equal(focusOverlayStillCurrent(overlay, current), true);45+ current.snapshot = { ...current.snapshot, revision: 5, focus_proposal: null };46+ assert.equal(focusOverlayStillCurrent(overlay, current), false);47+});48+49+test('only configured owner receives an exact-holder preemption candidate', () => {50+ const current = session();51+ current.focused = false;52+ current.snapshot = {53+ ...current.snapshot,54+ self_is_focus_owner: true,55+ focus_proposal: null,56+ focus: { state: 'held', holder: 'viewer-b', session_id: 2, focus_generation: 9 },57+ };58+ const overlay = preemptionCandidateForSelf(current);59+ assert.equal(overlay.holder, 'viewer-b');60+ assert.equal(focusOverlayStillCurrent(overlay, current), true);61+ current.snapshot = { ...current.snapshot, revision: 5, focus: { state: 'vacant', slot: 0 } };62+ assert.equal(focusOverlayStillCurrent(overlay, current), false);63+});64+65+test('handoff and preemption dialogs are controller-scoped with safe defaults and Back behavior', () => {66+ assert.match(html, /id="deny-focus-handoff" data-controller-focus/);67+ assert.match(html, /id="cancel-focus-preempt" data-controller-focus/);68+ assert.match(main, /if \(!handoff\.classList\.contains\('hidden'\)\) return handoff/);69+ assert.match(main, /if \(!preempt\.classList\.contains\('hidden'\)\) return preempt/);70+ assert.match(main, /void denyFocusHandoff\(\)/);71+ assert.match(main, /cancelFocusPreempt\(\)/);72+});@@ -1,3 +1,14 @@
1+function currentProposalForHolder(session) {2+ const proposal = session.snapshot?.focus_proposal;3+ const focus = session.snapshot?.focus;4+ if (!proposal || focus?.state !== 'held' || !session.focused) return null;5+ if (proposal.holder !== session.snapshot.self_presence_id6+ || proposal.holder_session_id !== session.snapshot.viewers.find((viewer) => viewer.you)?.session_id) {7+ return null;8+ }9+ return proposal;10+}11+112 export function createFocusRuntime({213 invokeCommand,314 getSession,@@ -12,10 +23,10 @@ export function createFocusRuntime({
1223 let nextRequestId = 1;1324 let pending = null;1425 15- async function queue(command, args, controllerInitiated = false) {26+ async function queue(kind, command, args, controllerInitiated = false) {1627 if (pending !== null) return false;1728 const requestId = nextRequestId++;18- pending = { requestId, command, controllerInitiated };29+ pending = { requestId, kind, command, controllerInitiated };1930 onChange(snapshot());2031 try {2132 const queued = await invokeCommand(command, { requestId, ...args });@@ -32,36 +43,94 @@ export function createFocusRuntime({
3243 async function request({ controllerInitiated = false } = {}) {3344 const session = getSession();3445 if (session.mode !== 'control-v2' || !session.eligible || session.focused) return false;35- return queue('iroh_client_request_focus', {46+ return queue('request', 'iroh_client_request_focus', {3647 generation: session.nativeGeneration,3748 expectedRevision: session.revision,3849 }, controllerInitiated);3950 }4051 52+ async function approve() {53+ const session = getSession();54+ const proposal = currentProposalForHolder(session);55+ if (!proposal) return false;56+ return queue('approve', 'iroh_client_approve_focus', {57+ generation: session.nativeGeneration,58+ expectedRevision: session.revision,59+ expectedFocusGeneration: session.focusGeneration,60+ expectedProposalId: proposal.proposal_id,61+ });62+ }63+64+ async function deny() {65+ const session = getSession();66+ const proposal = currentProposalForHolder(session);67+ if (!proposal) return false;68+ return queue('deny', 'iroh_client_deny_focus', {69+ generation: session.nativeGeneration,70+ expectedRevision: session.revision,71+ expectedFocusGeneration: session.focusGeneration,72+ expectedProposalId: proposal.proposal_id,73+ });74+ }75+4176 async function release() {4277 const session = getSession();4378 if (session.mode !== 'control-v2' || !session.focused) return false;44- return queue('iroh_client_release_focus', {79+ return queue('release', 'iroh_client_release_focus', {4580 generation: session.nativeGeneration,4681 expectedRevision: session.revision,4782 expectedFocusGeneration: session.focusGeneration,4883 });4984 }5085 86+ async function preempt({ controllerInitiated = false } = {}) {87+ const session = getSession();88+ const focus = session.snapshot?.focus;89+ if (session.mode !== 'control-v2'90+ || !session.eligible91+ || session.focused92+ || session.snapshot?.self_is_focus_owner !== true93+ || focus?.state !== 'held') return false;94+ return queue('preempt', 'iroh_client_preempt_focus', {95+ generation: session.nativeGeneration,96+ expectedRevision: session.revision,97+ expectedFocusGeneration: focus.focus_generation,98+ }, controllerInitiated);99+ }100+51101 async function observeSession() {52102 const session = getSession();53103 const request = pending;54- if (!request || request.command !== 'iroh_client_request_focus' || !session.focused) return false;55- pending = null;56- onChange(snapshot());57- await startFocusedInput();58- await activateLocalControl({ controllerInitiated: request.controllerInitiated });59- return true;104+ if (!request) return false;105+ if ((request.kind === 'request' || request.kind === 'preempt') && session.focused) {106+ pending = null;107+ onChange(snapshot());108+ await startFocusedInput();109+ await activateLocalControl({ controllerInitiated: request.controllerInitiated });110+ return true;111+ }112+ if (request.kind === 'release' && !session.focused) {113+ pending = null;114+ onChange(snapshot());115+ return true;116+ }117+ if (request.kind === 'request') {118+ const proposal = session.snapshot?.focus_proposal;119+ const selfId = session.snapshot?.self_presence_id;120+ const stillPending = proposal?.requester === selfId;121+ const terminalReason = ['handoff_denied', 'proposal_expired', 'disconnected', 'replaced', 'revoked']122+ .includes(session.snapshot?.transition_reason);123+ if (!stillPending && terminalReason) {124+ pending = null;125+ onChange(snapshot());126+ }127+ }128+ return false;60129 }61130 62131 function observeResult(result) {63132 if (!pending || result?.request_id !== pending.requestId) return false;64- if (result.accepted === false) {133+ if (result.accepted === false || ['approve', 'deny'].includes(pending.kind)) {65134 pending = null;66135 onChange(snapshot());67136 }@@ -74,8 +143,22 @@ export function createFocusRuntime({
74143 }75144 76145 function snapshot() {77- return { pending: pending !== null, requestId: pending?.requestId ?? null };146+ return {147+ pending: pending !== null,148+ kind: pending?.kind ?? null,149+ requestId: pending?.requestId ?? null,150+ };78151 }79152 80- return Object.freeze({ request, release, observeSession, observeResult, reset, snapshot });153+ return Object.freeze({154+ approve,155+ deny,156+ preempt,157+ release,158+ request,159+ observeSession,160+ observeResult,161+ reset,162+ snapshot,163+ });81164 }@@ -54,3 +54,77 @@ test('release carries authoritative revision and focus generation', async () =>
5454 assert.equal(call[1].expectedRevision, 9);5555 assert.equal(call[1].expectedFocusGeneration, 12);5656 });57+58+test('holder approve and deny are bound to the exact proposal and focus generation', async () => {59+ const calls = [];60+ const session = {61+ mode: 'control-v2', eligible: true, focused: true, nativeGeneration: 6,62+ revision: 9, focusGeneration: 12,63+ snapshot: {64+ self_presence_id: 'viewer-a',65+ viewers: [{ presence_id: 'viewer-a', session_id: 1, you: true }],66+ focus: { state: 'held', holder: 'viewer-a', session_id: 1, focus_generation: 12 },67+ focus_proposal: {68+ proposal_id: 4, holder: 'viewer-a', holder_session_id: 1,69+ requester: 'viewer-b', requester_session_id: 2,70+ },71+ },72+ };73+ const runtime = createFocusRuntime({74+ invokeCommand: async (command, args) => { calls.push([command, args]); return true; },75+ getSession: () => structuredClone(session),76+ startFocusedInput: async () => {},77+ activateLocalControl: async () => {},78+ });79+ assert.equal(await runtime.approve(), true);80+ assert.equal(calls[0][0], 'iroh_client_approve_focus');81+ assert.equal(calls[0][1].expectedProposalId, 4);82+ assert.equal(calls[0][1].expectedFocusGeneration, 12);83+ runtime.observeResult({ request_id: calls[0][1].requestId, accepted: true });84+ assert.equal(await runtime.deny(), true);85+ assert.equal(calls[1][0], 'iroh_client_deny_focus');86+});87+88+test('configured owner preemption remains non-optimistic and suppresses granting A on activation', async () => {89+ const order = [];90+ const session = {91+ mode: 'control-v2', eligible: true, focused: false, nativeGeneration: 8,92+ revision: 10, focusGeneration: null,93+ snapshot: {94+ self_is_focus_owner: true,95+ focus: { state: 'held', holder: 'viewer-b', session_id: 2, focus_generation: 20 },96+ },97+ };98+ const runtime = createFocusRuntime({99+ invokeCommand: async (command, args) => { order.push([command, args]); return true; },100+ getSession: () => structuredClone(session),101+ startFocusedInput: async () => order.push('input'),102+ activateLocalControl: async ({ controllerInitiated }) => order.push(`activate:${controllerInitiated}`),103+ });104+ assert.equal(await runtime.preempt({ controllerInitiated: true }), true);105+ assert.equal(session.focused, false);106+ session.focused = true;107+ session.focusGeneration = 21;108+ session.revision = 12;109+ assert.equal(await runtime.observeSession(), true);110+ assert.deepEqual(order.slice(1), ['input', 'activate:true']);111+});112+113+test('denied and expired handoff requests clear without optimistic possession', async () => {114+ const session = {115+ mode: 'control-v2', eligible: true, focused: false, nativeGeneration: 3, revision: 4,116+ snapshot: { self_presence_id: 'viewer-b', transition_reason: 'handoff_requested' },117+ };118+ const runtime = createFocusRuntime({119+ invokeCommand: async () => true,120+ getSession: () => structuredClone(session),121+ startFocusedInput: async () => {},122+ activateLocalControl: async () => {},123+ });124+ await runtime.request();125+ session.revision = 5;126+ session.snapshot.transition_reason = 'proposal_expired';127+ await runtime.observeSession();128+ assert.equal(runtime.snapshot().pending, false);129+ assert.equal(session.focused, false);130+});@@ -64,6 +64,30 @@
6464 </div>6565 </div>6666 67+ <div class="overlay-screen hidden focus-overlay" id="focus-handoff-overlay" role="alertdialog" aria-modal="true" aria-labelledby="focus-handoff-title">68+ <div class="overlay-content focus-overlay-content">69+ <h1 id="focus-handoff-title">pass control?</h1>70+ <p><span class="focus-peer" id="focus-handoff-requester"></span> is asking for slot 0. Your held input is neutralized before control moves.</p>71+ <div class="overlay-actions">72+ <button class="overlay-btn" id="deny-focus-handoff" data-controller-focus onclick="denyFocusHandoff()">keep control</button>73+ <button class="overlay-btn primary" id="approve-focus-handoff" data-controller-focus onclick="approveFocusHandoff()">pass control</button>74+ </div>75+ <p class="controller-hint">Safe default: keep control · B dismisses the request</p>76+ </div>77+ </div>78+79+ <div class="overlay-screen hidden focus-overlay" id="focus-preempt-overlay" role="alertdialog" aria-modal="true" aria-labelledby="focus-preempt-title">80+ <div class="overlay-content focus-overlay-content">81+ <h1 id="focus-preempt-title">take control?</h1>82+ <p>This displaces <span class="focus-peer" id="focus-preempt-holder"></span> after Sigil neutralizes every held input.</p>83+ <div class="overlay-actions">84+ <button class="overlay-btn" id="cancel-focus-preempt" data-controller-focus onclick="cancelFocusPreempt()">cancel</button>85+ <button class="overlay-btn danger" id="confirm-focus-preempt" data-controller-focus onclick="confirmFocusPreempt()">take control</button>86+ </div>87+ <p class="controller-hint">Safe default: cancel · B closes</p>88+ </div>89+ </div>90+6791 <div class="overlay-screen hidden" id="tap-overlay" style="z-index: 200;">6892 <div class="overlay-content">6993 <div class="flow-steps" aria-hidden="true">@@ -11,6 +11,11 @@ import { createInputRuntime } from './input-runtime.mjs';
1111 import { createControlRuntime } from './control-runtime.mjs';1212 import { createSessionState } from './session-state.mjs';1313 import { createFocusRuntime } from './focus-runtime.mjs';14+import {15+ focusOverlayStillCurrent,16+ handoffProposalForSelf,17+ preemptionCandidateForSelf,18+} from './focus-overlay.mjs';1419 import { renderRoster, rosterPresentation } from './roster-ui.mjs';1520 import { formatVideoDiscardTelemetry } from './frame-stats.mjs';1621 import { networkDiagnosticsPresentation } from './network-diagnostics.mjs';@@ -55,6 +60,7 @@ let controlRuntime = null;
5560 let controllerRuntime = null;5661 let sessionStateRuntime = null;5762 let focusRuntime = null;63+let activeFocusOverlay = null;5864 5965 const invoke = (...args) => window.__TAURI__.core.invoke(...args);6066 const listen = (...args) => window.__TAURI__.event.listen(...args);@@ -587,6 +593,68 @@ function togglePanel(force) {
587593 if (open) setTimeout(() => document.getElementById('panel-close').focus(), 0);588594 }589595 596+function closeFocusOverlay() {597+ activeFocusOverlay = null;598+ document.getElementById('focus-handoff-overlay').classList.add('hidden');599+ document.getElementById('focus-preempt-overlay').classList.add('hidden');600+}601+602+function renderFocusOverlay() {603+ const session = sessionStateRuntime?.snapshot();604+ if (!session) return;605+ const handoff = handoffProposalForSelf(session);606+ if (handoff) {607+ activeFocusOverlay = handoff;608+ document.getElementById('focus-preempt-overlay').classList.add('hidden');609+ document.getElementById('focus-handoff-requester').textContent = handoff.requester;610+ document.getElementById('focus-handoff-overlay').classList.remove('hidden');611+ setTimeout(() => setControllerFocus(document.getElementById('deny-focus-handoff')), 0);612+ return;613+ }614+ if (activeFocusOverlay && !focusOverlayStillCurrent(activeFocusOverlay, session)) {615+ closeFocusOverlay();616+ }617+}618+619+function openFocusPreempt() {620+ const candidate = preemptionCandidateForSelf(sessionStateRuntime.snapshot());621+ if (!candidate) return false;622+ activeFocusOverlay = candidate;623+ document.getElementById('focus-preempt-holder').textContent = candidate.holder;624+ document.getElementById('focus-preempt-overlay').classList.remove('hidden');625+ setTimeout(() => setControllerFocus(document.getElementById('cancel-focus-preempt')), 0);626+ return true;627+}628+629+function cancelFocusPreempt() {630+ if (activeFocusOverlay?.type !== 'preempt') return;631+ closeFocusOverlay();632+ setControllerFocus(document.getElementById('control-toggle'));633+}634+635+async function approveFocusHandoff() {636+ if (activeFocusOverlay?.type !== 'handoff'637+ || !focusOverlayStillCurrent(activeFocusOverlay, sessionStateRuntime.snapshot())) return false;638+ controlRuntime.exit({ releaseHostFocus: false, resetEscape: true });639+ await inputRuntime.drain(250);640+ closeFocusOverlay();641+ return focusRuntime.approve();642+}643+644+async function denyFocusHandoff() {645+ if (activeFocusOverlay?.type !== 'handoff'646+ || !focusOverlayStillCurrent(activeFocusOverlay, sessionStateRuntime.snapshot())) return false;647+ closeFocusOverlay();648+ return focusRuntime.deny();649+}650+651+async function confirmFocusPreempt() {652+ if (activeFocusOverlay?.type !== 'preempt'653+ || !focusOverlayStillCurrent(activeFocusOverlay, sessionStateRuntime.snapshot())) return false;654+ closeFocusOverlay();655+ return focusRuntime.preempt({ controllerInitiated: controllerActivationInProgress });656+}657+590658 // ─── WebCodecs detection ──────────────────────────────────────────────────────591659 // Finish the codec probe and publish the result to Rust before exposing any592660 // connect handlers. Rust refuses to connect when WebCodecs is unavailable, so@@ -644,8 +712,12 @@ controlRuntime = createControlRuntime({
644712 },645713 onExit: () => {646714 if (connectionState.controlProtocol === 'control-v2') {647- void focusRuntime?.release().catch((error) => console.error('focus release failed:', error));715+ return focusRuntime?.release().catch((error) => {716+ console.error('focus release failed:', error);717+ return false;718+ });648719 }720+ return false;649721 },650722 onReleaseFailure: () => setStatus('err', 'cursor release failed · quit app'),651723 });@@ -830,6 +902,10 @@ async function toggleControl() {
830902 // Capture synchronous controller provenance before cursor acquisition yields.831903 // The DOM click dispatcher does not await this async handler.832904 if (connectionState.controlProtocol === 'control-v2' && !sessionStateRuntime.focused) {905+ if (preemptionCandidateForSelf(sessionStateRuntime.snapshot())) {906+ openFocusPreempt();907+ return;908+ }833909 await focusRuntime.request({ controllerInitiated: controllerActivationInProgress });834910 updateControlUI();835911 return;@@ -892,8 +968,12 @@ function updateControlUI() {
892968 const occupiedByAnother = v2893969 && sessionStateRuntime.snapshot().snapshot?.focus?.state === 'held'894970 && !sessionStateRuntime.focused;895- const controlAction = v2 && !sessionStateRuntime.focused896- ? occupiedByAnother ? 'spectating · request control' : 'request control'971+ const focusPending = focusRuntime?.snapshot().pending === true;972+ const ownerCanPreempt = preemptionCandidateForSelf(sessionStateRuntime.snapshot()) !== null;973+ const controlAction = focusPending974+ ? 'control request pending'975+ : v2 && !sessionStateRuntime.focused976+ ? ownerCanPreempt ? 'spectating · take control' : occupiedByAnother ? 'spectating · request control' : 'request control'897977 : controlling ? 'release control' : 'activate control';898978 el.textContent = available899979 ? `${controlAction} · ${describeInputCapabilities()}${controlling && capabilities.relativePointer ? ' · Ctrl+Alt+Esc to exit' : controlling && capabilities.gamepad ? ' · hold Back+Start to exit' : ''}`@@ -919,6 +999,7 @@ function updateControlUI() {
919999 9201000 function updateSessionUI() {9211001 updateControlUI();1002+ renderFocusOverlay();9221003 const viewerStatus = document.getElementById('viewer-status');9231004 const rosterSection = document.getElementById('roster-section');9241005 const rosterSummary = document.getElementById('roster-summary');@@ -1066,6 +1147,11 @@ window.addEventListener('blur', () => {
10661147 controlRuntime.exit();10671148 });10681149 1150+document.addEventListener('visibilitychange', () => {1151+ if (!document.hidden || (!controlRuntime.active && !controlRuntime.transitioning)) return;1152+ controlRuntime.exit();1153+});1154+10691155 // ─── Controller PIN entry ─────────────────────────────────────────────────────10701156 let activePinInput = null;10711157 let pinPadReturnFocus = null;@@ -1133,6 +1219,10 @@ for (const id of ['enrollment-pin', 'intro-pin', 'pin-input']) {
11331219 function controllerScope() {11341220 const tap = document.getElementById('tap-overlay');11351221 if (!tap.classList.contains('hidden')) return tap;1222+ const handoff = document.getElementById('focus-handoff-overlay');1223+ if (!handoff.classList.contains('hidden')) return handoff;1224+ const preempt = document.getElementById('focus-preempt-overlay');1225+ if (!preempt.classList.contains('hidden')) return preempt;11361226 const pinPad = document.getElementById('pin-pad-overlay');11371227 if (!pinPad.classList.contains('hidden')) return pinPad;11381228 const resetEnrollment = document.getElementById('reset-enrollment-overlay');@@ -1190,7 +1280,11 @@ function activateControllerFocus() {
11901280 }11911281 11921282 function controllerBack() {1193- if (!document.getElementById('pin-pad-overlay').classList.contains('hidden')) {1283+ if (!document.getElementById('focus-handoff-overlay').classList.contains('hidden')) {1284+ void denyFocusHandoff();1285+ } else if (!document.getElementById('focus-preempt-overlay').classList.contains('hidden')) {1286+ cancelFocusPreempt();1287+ } else if (!document.getElementById('pin-pad-overlay').classList.contains('hidden')) {11941288 closePinPad();11951289 } else if (!document.getElementById('reset-enrollment-overlay').classList.contains('hidden')) {11961290 cancelEnrollmentReset();@@ -1297,6 +1391,7 @@ Object.assign(window, {
12971391 openPinPad, closePinPad, pinPadDigit, pinPadBackspace, pinPadClear,12981392 openEnrollmentReset, cancelEnrollmentReset, confirmEnrollmentReset,12991393 derivePortalIdentity, chooseInvitationFile, confirmInvitation, cancelInvitation,1394+ approveFocusHandoff, denyFocusHandoff, cancelFocusPreempt, confirmFocusPreempt,13001395 sigilController: Object.freeze({13011396 getLatestState: () => controllerRuntime.latest,13021397 setObserver: controllerRuntime.setObserver,@@ -14,6 +14,8 @@ controller-runtime.mjs
1414 controller-state.mjs1515 decoder-recovery.mjs1616 enrollment.mjs17+focus-overlay.mjs18+focus-runtime.mjs1719 frame-envelope.mjs1820 frame-session.mjs1921 frame-stats.mjs@@ -24,6 +26,8 @@ keyboard-map.mjs
2426 main.js2527 network-diagnostics.mjs2628 pointer-feedback.mjs29+roster-ui.mjs30+session-state.mjs2731 stream-metrics.mjs2832 stream-runtime.mjs2933 style.css@@ -493,6 +493,22 @@ body.native-pointer-control * { cursor: none !important; }
493493 #tap-overlay .flow-step.active { color: var(--ochre); }494494 .enrollment-content,495495 .invitation-content { width: 460px; }496+.focus-overlay { z-index: 170; }497+.focus-overlay-content { width: 420px; }498+.focus-overlay-content::before {499+ background-image: repeating-linear-gradient(500+ 90deg,501+ var(--sand) 0 8px,502+ transparent 8px 16px,503+ var(--moss) 16px 20px,504+ transparent 20px 32px505+ );506+}507+.focus-peer {508+ color: var(--sand);509+ font-weight: 700;510+ overflow-wrap: anywhere;511+}496512 .enrollment-peer {497513 margin: 20px 0 4px;498514 padding: 12px;@@ -546,6 +562,11 @@ body.native-pointer-control * { cursor: none !important; }
546562 color: var(--destructive);547563 border-color: color-mix(in srgb, var(--destructive) 55%, var(--border));548564 }565+.overlay-btn.danger:hover {566+ color: var(--paper);567+ background: var(--destructive);568+ text-decoration: none;569+}549570 .shortcut-key {550571 color: var(--foreground);551572 }@@ -29,6 +29,8 @@ assert_shared_generation=false
2929 replace_viewer=03030 legacy_exclusive=false3131 expect_second_rejected=false32+focus_handoffs=033+assert_neutral_before_successor=false3234 3335 usage() {3436 cat <<'EOF'@@ -56,6 +58,9 @@ Options:
5658 --replace-viewer INDEX Replace this 1-based control-v2 viewer identity5759 --legacy-exclusive Exercise the unchanged exclusive legacy path5860 --expect-second-rejected Require explicit legacy second-client rejection61+ --focus-handoffs COUNT Run bounded holder-approved control transfers62+ --assert-neutral-before-successor63+ Require host ordering evidence for every transfer5964 --help Show this help6065 EOF6166 }@@ -147,6 +152,15 @@ while [[ $# -gt 0 ]]; do
147152 expect_second_rejected=true148153 shift149154 ;;155+ --focus-handoffs)156+ [[ $# -ge 2 ]] || die "--focus-handoffs requires a value"157+ focus_handoffs="$2"158+ shift 2159+ ;;160+ --assert-neutral-before-successor)161+ assert_neutral_before_successor=true162+ shift163+ ;;150164 --help|-h)151165 usage152166 exit 0@@ -171,6 +185,15 @@ require_positive_integer "--reconnect-cycles" "$reconnect_cycles"
171185 require_positive_integer "--reconnect-frames" "$reconnect_frames"172186 require_positive_integer "--probe-timeout-seconds" "$probe_timeout_seconds"173187 require_positive_integer "--viewers" "$viewers"188+case "$focus_handoffs" in189+ ''|*[!0-9]*) die "--focus-handoffs must be a non-negative integer" ;;190+esac191+if [[ "$focus_handoffs" -gt 0 && ( "$control_v2" != true || "$viewers" -lt 2 ) ]]; then192+ die "--focus-handoffs requires --control-v2 with at least two viewers"193+fi194+if [[ "$assert_neutral_before_successor" == true && "$focus_handoffs" -eq 0 ]]; then195+ die "--assert-neutral-before-successor requires --focus-handoffs"196+fi174197 if [[ "$control_v2" == true && ( "$viewers" -lt 1 || "$viewers" -gt 3 ) ]]; then175198 die "this bounded control-v2 proof supports --viewers 1 through 3"176199 fi@@ -584,7 +607,12 @@ if [[ "$control_v2" == true ]]; then
584607 viewer_identities+=("$viewer_identity")585608 viewer_log="$tmp_root/control-v2-viewer-${viewer}.log"586609 viewer_logs+=("$viewer_log")587- if [[ "$viewer" -gt 1 ]]; then610+ if [[ "$focus_handoffs" -gt 0 && "$viewer" -le 2 ]]; then611+ "$probe_bin" --control-v2 --focus-handoffs "$focus_handoffs" \612+ --identity "$viewer_identity" --node-id "$node_id" --frames "$primary_frames" \613+ --timeout-seconds "$probe_timeout_seconds" --expect-size 1280x800 \614+ >"$viewer_log" 2>&1 &615+ elif [[ "$viewer" -gt 1 ]]; then588616 "$probe_bin" --control-v2 --spectator \589617 --identity "$viewer_identity" --node-id "$node_id" --frames "$primary_frames" \590618 --timeout-seconds "$probe_timeout_seconds" --expect-size 1280x800 \@@ -596,7 +624,7 @@ if [[ "$control_v2" == true ]]; then
596624 >"$viewer_log" 2>&1 &597625 fi598626 viewer_pids+=("$!")599- if [[ "$viewer" -eq 1 ]]; then627+ if [[ "$viewer" -eq 1 && "$focus_handoffs" -eq 0 ]]; then600628 wait_for_log_count "$host_log" 'input v2 client accepted' 1 "$host_pid" 'sigil' \601629 || die "slot-0 holder did not acquire focused input before spectators joined"602630 fi@@ -639,8 +667,17 @@ if [[ "$control_v2" == true ]]; then
639667 viewer=$((viewer + 1))640668 done641669 viewer_pids=()642- grep -Fxq 'slot_0_input=ok' "${viewer_logs[0]}" || die "slot-0 holder input failed"643- spectator=2670+ if [[ "$focus_handoffs" -gt 0 ]]; then671+ participant=1672+ while [[ "$participant" -le 2 ]]; do673+ grep -Fxq "focus_handoffs_observed=${focus_handoffs}" "${viewer_logs[$((participant - 1))]}" \674+ || die "focus participant $participant did not observe all handoffs"675+ participant=$((participant + 1))676+ done677+ else678+ grep -Fxq 'slot_0_input=ok' "${viewer_logs[0]}" || die "slot-0 holder input failed"679+ fi680+ spectator=$([[ "$focus_handoffs" -gt 0 ]] && printf 3 || printf 2)644681 while [[ "$spectator" -le "$viewers" ]]; do645682 if [[ "$spectator" -eq "$replace_viewer" ]]; then646683 spectator=$((spectator + 1))@@ -660,6 +697,41 @@ if [[ "$control_v2" == true ]]; then
660697 || die "multi-viewer control-v2 started more than one generation"661698 [[ "$(grep -Fc -- 'shared media generation stopped with all sources cleaned up' "$host_log" || true)" -eq 1 ]] \662699 || die "multi-viewer control-v2 stopped the generation more than once"700+ if [[ "$assert_neutral_before_successor" == true ]]; then701+ clean_focus_log="$tmp_root/host-focus-transitions.log"702+ sed $'s/\033\[[0-9;]*m//g' "$host_log" >"$clean_focus_log"703+ if ! awk -v required="$focus_handoffs" '704+ function transition_id(line, value) {705+ if (match(line, /transition_id=[0-9]+/)) {706+ value = substr(line, RSTART + 14, RLENGTH - 14)707+ return value + 0708+ }709+ return 0710+ }711+ /focus transition entered no-inject neutralizing state/ {712+ id = transition_id($0); if (id > 0) entered[id] = NR713+ }714+ /focus transition virtual input neutralized/ {715+ id = transition_id($0); if (id > 0) neutral[id] = NR716+ }717+ /focus transition successor committed after neutralization/ {718+ id = transition_id($0)719+ if (id > 0) {720+ committed[id] = NR721+ count += 1722+ }723+ }724+ END {725+ if (count < required) exit 1726+ for (id in committed) {727+ if (!(id in entered) || !(id in neutral) || entered[id] >= neutral[id] || neutral[id] >= committed[id]) exit 1728+ }729+ }730+ ' "$clean_focus_log"; then731+ grep -F 'focus transition' "$clean_focus_log" >&2 || true732+ die "focus successor was published before complete neutralization evidence"733+ fi734+ fi663735 664736 kill -TERM "$host_pid" 2>/dev/null || true665737 if wait "$host_pid"; then host_status=0; else host_status=$?; fi@@ -673,6 +745,8 @@ if [[ "$control_v2" == true ]]; then
673745 printf 'same_peer_replacement=%s\n' "$([[ "$replace_viewer" -gt 0 ]] && printf ok || printf not-requested)"674746 printf 'shared_generation=ok\n'675747 printf 'slot_0_single_holder=ok\n'748+ printf 'focus_handoffs=%s\n' "$focus_handoffs"749+ printf 'neutral_before_successor=%s\n' "$([[ "$assert_neutral_before_successor" == true ]] && printf ok || printf not-requested)"676750 printf 'spectator_progress=ok\n'677751 exit 0678752 fi@@ -62,6 +62,7 @@ pub async fn iroh_client_request_focus(
6262 FocusCommandActionV2::Request,6363 expected_revision,6464 None,65+ None,6566 )6667 .await6768 }@@ -81,6 +82,69 @@ pub async fn iroh_client_release_focus(
8182 FocusCommandActionV2::Release,8283 expected_revision,8384 Some(expected_focus_generation),85+ None,86+ )87+ .await88+}89+90+91+pub async fn iroh_client_approve_focus(92+ state: State<'_, AppState>,93+ generation: u64,94+ request_id: u64,95+ expected_revision: u64,96+ expected_focus_generation: u64,97+ expected_proposal_id: u64,98+) -> Result<bool, String> {99+ send_focus_command(100+ &state.session_control,101+ generation,102+ request_id,103+ FocusCommandActionV2::Approve,104+ expected_revision,105+ Some(expected_focus_generation),106+ Some(expected_proposal_id),107+ )108+ .await109+}110+111+112+pub async fn iroh_client_deny_focus(113+ state: State<'_, AppState>,114+ generation: u64,115+ request_id: u64,116+ expected_revision: u64,117+ expected_focus_generation: u64,118+ expected_proposal_id: u64,119+) -> Result<bool, String> {120+ send_focus_command(121+ &state.session_control,122+ generation,123+ request_id,124+ FocusCommandActionV2::Deny,125+ expected_revision,126+ Some(expected_focus_generation),127+ Some(expected_proposal_id),128+ )129+ .await130+}131+132+133+pub async fn iroh_client_preempt_focus(134+ state: State<'_, AppState>,135+ generation: u64,136+ request_id: u64,137+ expected_revision: u64,138+ expected_focus_generation: u64,139+) -> Result<bool, String> {140+ send_focus_command(141+ &state.session_control,142+ generation,143+ request_id,144+ FocusCommandActionV2::Preempt,145+ expected_revision,146+ Some(expected_focus_generation),147+ None,84148 )85149 .await86150 }@@ -107,6 +107,9 @@ fn main() {
107107 commands::network::iroh_client_send_input,108108 commands::network::iroh_client_request_focus,109109 commands::network::iroh_client_release_focus,110+ commands::network::iroh_client_approve_focus,111+ commands::network::iroh_client_deny_focus,112+ commands::network::iroh_client_preempt_focus,110113 commands::network::iroh_client_start_focused_input,111114 ])112115 .build(tauri::generate_context!())@@ -11,7 +11,7 @@ use sigil_protocol::{
1111 };1212 use tauri::ipc::Channel;1313 14-use crate::commands::state::AppState;14+use crate::commands::state::{AppState, SessionSnapshotState};1515 use crate::media::network_diagnostics::{NetworkSessionDiagnostics, lock_network_diagnostics};1616 use crate::media::transport::{NegotiatedV1Stream, negotiate_v1};1717 use crate::platform_capabilities::relative_pointer_capture_enabled;@@ -327,6 +327,8 @@ pub(crate) enum InputWire {
327327 session_id: u64,328328 slot: ControllerSlot,329329 focus_generation: u64,330+ native_generation: u64,331+ session_snapshot: Arc<tokio::sync::Mutex<Option<SessionSnapshotState>>>,330332 },331333 }332334 @@ -341,17 +343,35 @@ impl InputWire {
341343 session_id,342344 slot,343345 focus_generation,344- } => write_input_event_v2(345- send,346- &InputEventV2 {347- session_id: *session_id,348- slot: *slot,349- focus_generation: *focus_generation,350- event: event.clone(),351- },352- )353- .await354- .map_err(|error| error.to_string()),346+ native_generation,347+ session_snapshot,348+ } => {349+ let owns_focus =350+ session_snapshot351+ .lock()352+ .await353+ .as_ref()354+ .is_some_and(|(generation, snapshot)| {355+ *generation == *native_generation356+ && snapshot.self_focus_generation() == Some(*focus_generation)357+ });358+ if !owns_focus {359+ return Err(360+ "Authoritative focus changed before the input wire write".to_string()361+ );362+ }363+ write_input_event_v2(364+ send,365+ &InputEventV2 {366+ session_id: *session_id,367+ slot: *slot,368+ focus_generation: *focus_generation,369+ event: event.clone(),370+ },371+ )372+ .await373+ .map_err(|error| error.to_string())374+ }355375 }356376 }357377 @@ -413,6 +433,8 @@ pub(crate) async fn open_input_session_v2(
413433 nonce: [u8; 16],414434 media_session_id: u64,415435 focus_generation: u64,436+ native_generation: u64,437+ session_snapshot: Arc<tokio::sync::Mutex<Option<SessionSnapshotState>>>,416438 grants: InvitationGrants,417439 ) -> Result<InputSession, String> {418440 let offered = input_capability_offers(grants)@@ -477,6 +499,8 @@ pub(crate) async fn open_input_session_v2(
477499 session_id: media_session_id,478500 slot: ControllerSlot::ZERO,479501 focus_generation,502+ native_generation,503+ session_snapshot,480504 },481505 recv,482506 capabilities,@@ -517,6 +541,8 @@ pub(crate) async fn start_focused_input_v2(state: &AppState) -> Result<InputAvai
517541 context.nonce,518542 context.media_session_id,519543 focus_generation,544+ context.native_generation,545+ Arc::clone(&state.session_snapshot),520546 context.grants,521547 )522548 .await?;@@ -120,6 +120,7 @@ pub(crate) async fn send_focus_command(
120120 action: FocusCommandActionV2,121121 expected_revision: u64,122122 expected_focus_generation: Option<u64>,123+ expected_proposal_id: Option<u64>,123124 ) -> Result<bool, String> {124125 let sender = sender125126 .lock()@@ -135,6 +136,7 @@ pub(crate) async fn send_focus_command(
135136 slot: sigil_protocol::ControllerSlot::ZERO,136137 expected_revision,137138 expected_focus_generation,139+ expected_proposal_id,138140 },139141 };140142 command@@ -183,6 +185,8 @@ mod tests {
183185 focus: FocusStateV2::Vacant {184186 slot: ControllerSlot::ZERO,185187 },188+ focus_proposal: None,189+ self_is_focus_owner: false,186190 transition_reason: FocusTransitionReasonV2::Initial,187191 media: MediaGenerationDescriptorV2 {188192 generation_id: 7,@@ -203,12 +207,12 @@ mod tests {
203207 let (sender, mut receiver) = tokio::sync::mpsc::channel(1);204208 let state = tokio::sync::Mutex::new(Some((5, sender)));205209 assert!(206- send_focus_command(&state, 5, 1, FocusCommandActionV2::Request, 2, None)210+ send_focus_command(&state, 5, 1, FocusCommandActionV2::Request, 2, None, None)207211 .await208212 .unwrap()209213 );210214 assert!(211- !send_focus_command(&state, 5, 2, FocusCommandActionV2::Request, 2, None)215+ !send_focus_command(&state, 5, 2, FocusCommandActionV2::Request, 2, None, None)212216 .await213217 .unwrap()214218 );@@ -217,7 +221,7 @@ mod tests {
217221 Some(ClientControlEnvelopeV2::Focus { .. })218222 ));219223 assert!(220- send_focus_command(&state, 4, 3, FocusCommandActionV2::Request, 2, None)224+ send_focus_command(&state, 4, 3, FocusCommandActionV2::Request, 2, None, None)221225 .await222226 .is_err()223227 );