Rewild Your Web
18
fork

Configure Feed

Select the types of activity you want to include in your feed.

p2p: guest mode

Signed-off-by: webbeef <me@webbeef.org>

webbeef 54d191d4 924a79ba

+1115 -55
+1
Cargo.lock
··· 698 698 "parking_lot", 699 699 "petname", 700 700 "postcard", 701 + "rand 0.9.4", 701 702 "serde", 702 703 "thiserror 2.0.18", 703 704 "tokio",
+1
crates/beaver_p2p/Cargo.toml
··· 13 13 parking_lot = "0.12" 14 14 petname = "2.0" 15 15 postcard = "1.1" 16 + rand = "0.9" 16 17 serde = "1.0" 17 18 thiserror = "2.0" 18 19 tokio = { version = "1.50", features = ["signal"] }
+102
crates/beaver_p2p/src/lib.rs
··· 20 20 use n0_future::StreamExt; 21 21 pub use packet::PacketError; 22 22 use packet::PostcardPacket; 23 + use rand::Rng; 23 24 use thiserror::Error; 24 25 use tokio::sync::Mutex; 25 26 use tokio::task::AbortHandle; ··· 31 32 32 33 #[derive(Debug, Error)] 33 34 pub enum PairingError { 35 + #[error("Pairing manager not initialized")] 36 + NotInitialized, 34 37 #[error("Unknown remote endpoint")] 35 38 UnknownRemote, 36 39 #[error("Endpoint pairing already requested")] ··· 189 192 result 190 193 } 191 194 195 + /// Request guest pairing with a remote peer using a PIN. 196 + /// The remote must have guest mode enabled with a matching PIN. 197 + pub async fn request_guest_pairing( 198 + &self, 199 + id: &EndpointId, 200 + pin: &str, 201 + ) -> Result<PairingResult, PairingError> { 202 + info!("Guest pairing with {id}"); 203 + let Some(ref inner) = self.inner else { 204 + return Err(PairingError::NotInitialized); 205 + }; 206 + 207 + let addr = { 208 + let state = inner.state.lock().await; 209 + let Some(remote) = state.by_id(id) else { 210 + return Err(PairingError::UnknownRemote); 211 + }; 212 + remote.addr() 213 + }; 214 + 215 + let connection = inner.router.endpoint().connect(addr, PAIRING_ALPN).await?; 216 + let (mut sender, mut receiver) = connection.open_bi().await?; 217 + 218 + PostcardPacket::send(PairingCommand::GuestRequest(pin.to_string()), &mut sender).await?; 219 + 220 + let command: PairingCommand = PostcardPacket::recv(&mut receiver).await?; 221 + let result = match command { 222 + PairingCommand::Accept => PairingResult::Accepted, 223 + PairingCommand::Reject => PairingResult::Rejected, 224 + _ => { 225 + error!("Unexpected response to guest pairing: {command:?}"); 226 + return Err(PairingError::InvalidState); 227 + }, 228 + }; 229 + 230 + PostcardPacket::send(PairingCommand::Ack, &mut sender).await?; 231 + sender.finish()?; 232 + let _ = sender.stopped().await; 233 + 234 + let mut state = inner.state.lock().await; 235 + match &result { 236 + PairingResult::Accepted => { 237 + state.notify(PeerEvent::PairingAccepted(*id)); 238 + state.set_status(id, EndpointStatus::PairedConnected); 239 + }, 240 + PairingResult::Rejected => { 241 + state.notify(PeerEvent::PairingRejected(*id)); 242 + }, 243 + } 244 + 245 + Ok(result) 246 + } 247 + 192 248 /// Perform the pairing handshake over the wire. Separated from request_pairing 193 249 /// so that cleanup always runs regardless of where an error occurs. 194 250 async fn do_pairing_handshake( ··· 428 484 }; 429 485 430 486 inner.state.lock().await.remove_endpoint(id); 487 + } 488 + 489 + // --- Guest mode --- 490 + 491 + /// Enable guest mode with a random 6-digit PIN. 492 + /// Returns the PIN to display to the user. 493 + pub async fn enable_guest_mode(&self, duration_minutes: u32) -> Result<String, PairingError> { 494 + let Some(ref inner) = self.inner else { 495 + return Err(PairingError::NotInitialized); 496 + }; 497 + 498 + let pin: String = { 499 + let mut rng = rand::rng(); 500 + (0..6) 501 + .map(|_| char::from(b'0' + rng.random_range(0..10u8))) 502 + .collect() 503 + }; 504 + let duration_secs = (duration_minutes as u64) * 60; 505 + 506 + inner 507 + .state 508 + .lock() 509 + .await 510 + .enable_guest_mode(pin.clone(), duration_secs); 511 + 512 + info!("[P2P] Guest mode enabled, PIN: {}", pin); 513 + Ok(pin) 514 + } 515 + 516 + /// Disable guest mode and remove all guest peers. 517 + pub async fn disable_guest_mode(&self) -> Result<(), PairingError> { 518 + let Some(ref inner) = self.inner else { 519 + return Err(PairingError::NotInitialized); 520 + }; 521 + 522 + let mut state = inner.state.lock().await; 523 + let removed = state.disable_guest_mode(); 524 + for peer_id in &removed { 525 + state.notify(PeerEvent::GuestExpired(*peer_id)); 526 + } 527 + 528 + info!( 529 + "[P2P] Guest mode disabled, removed {} guests", 530 + removed.len() 531 + ); 532 + Ok(()) 431 533 } 432 534 }
+6
crates/beaver_p2p/src/message_protocol.rs
··· 31 31 remote_id 32 32 ); 33 33 34 + // Track this connection so it can be closed when the peer is removed. 35 + self.state 36 + .lock() 37 + .await 38 + .track_incoming_connection(remote_id, connection.clone()); 39 + 34 40 // Accept uni streams — each incoming message arrives on its own stream. 35 41 while let Ok(mut receiver) = connection.accept_uni().await { 36 42 match BasePacket::recv(&mut receiver).await {
+11 -12
crates/beaver_p2p/src/pairing_hook.rs
··· 38 38 String::from_utf8_lossy(conn.alpn()) 39 39 ); 40 40 41 - // For message protocol, only allow PairedConnected endpoints. 42 - if conn.alpn() == MESSAGE_ALPN && 43 - !self 44 - .state 45 - .lock() 46 - .await 47 - .has(&conn.remote_id(), EndpointStatus::PairedConnected) 48 - { 49 - return AfterHandshakeOutcome::Reject { 50 - error_code: 401u32.into(), 51 - reason: b"not paired".into(), 52 - }; 41 + // For message protocol, only allow paired or guest-connected endpoints. 42 + if conn.alpn() == MESSAGE_ALPN { 43 + let state = self.state.lock().await; 44 + let allowed = state.has(&conn.remote_id(), EndpointStatus::PairedConnected) || 45 + state.has(&conn.remote_id(), EndpointStatus::GuestConnected); 46 + if !allowed { 47 + return AfterHandshakeOutcome::Reject { 48 + error_code: 401u32.into(), 49 + reason: b"not paired".into(), 50 + }; 51 + } 53 52 } 54 53 55 54 AfterHandshakeOutcome::Accept
+47 -1
crates/beaver_p2p/src/pairing_protocol.rs
··· 68 68 .await 69 69 .expect("Failed to read"); 70 70 71 - // Step 1. Receive a request, store the tokio channel sender and ack receiver in the state. 72 71 match command { 73 72 PairingCommand::Request => { 74 73 let state = self.state.lock().await; ··· 77 76 .map(|ep| ep.name().to_owned()) 78 77 .unwrap_or_default(); 79 78 state.notify(PeerEvent::PairingRequest(remote_id, name)); 79 + }, 80 + PairingCommand::GuestRequest(pin) => { 81 + let mut state = self.state.lock().await; 82 + if state.validate_guest_pin(&pin) { 83 + state.set_status(&remote_id, EndpointStatus::GuestConnected); 84 + state.add_guest_peer(&remote_id); 85 + drop(state); 86 + 87 + PostcardPacket::send(PairingCommand::Accept, &mut sender) 88 + .await 89 + .expect("Failed to send guest accept"); 90 + 91 + match PostcardPacket::recv(&mut receiver).await { 92 + Ok(PairingCommand::Ack) => { 93 + self.state 94 + .lock() 95 + .await 96 + .notify(PeerEvent::GuestAccepted(remote_id)); 97 + info!("[P2P] Guest paired: {remote_id}"); 98 + }, 99 + Ok(cmd) => { 100 + error!("Unexpected command after guest accept: {cmd:?}"); 101 + self.state 102 + .lock() 103 + .await 104 + .notify(PeerEvent::PairingFailed(remote_id)); 105 + }, 106 + Err(e) => { 107 + error!("Failed to read guest Ack: {e}"); 108 + self.state 109 + .lock() 110 + .await 111 + .notify(PeerEvent::PairingFailed(remote_id)); 112 + }, 113 + } 114 + return Ok(()); 115 + } else { 116 + info!("[P2P] Guest pairing rejected: invalid PIN from {remote_id}"); 117 + PostcardPacket::send(PairingCommand::Reject, &mut sender) 118 + .await 119 + .expect("Failed to send guest reject"); 120 + self.state 121 + .lock() 122 + .await 123 + .notify(PeerEvent::PairingRejected(remote_id)); 124 + return Ok(()); 125 + } 80 126 }, 81 127 _ => { 82 128 error!("Unexpected command: {command:?}");
+89 -2
crates/beaver_p2p/src/state.rs
··· 3 3 use std::collections::{HashMap, HashSet}; 4 4 use std::sync::Arc; 5 5 use std::sync::mpsc::Sender; 6 + use std::time::Instant; 6 7 7 8 use iroh::address_lookup::DiscoveryEvent; 8 9 use iroh::endpoint::Connection; ··· 23 24 PairedConnected, 24 25 /// Paired but currently disconnected. 25 26 PairedDisconnected, 27 + /// Temporary guest pairing, currently connected. 28 + GuestConnected, 26 29 } 27 30 28 31 #[derive(Debug)] ··· 60 63 } 61 64 62 65 pub(crate) fn is_paired(&self) -> bool { 63 - self.status == EndpointStatus::PairedConnected || 64 - self.status == EndpointStatus::PairedDisconnected 66 + matches!( 67 + self.status, 68 + EndpointStatus::PairedConnected | 69 + EndpointStatus::PairedDisconnected | 70 + EndpointStatus::GuestConnected 71 + ) 65 72 } 66 73 67 74 pub(crate) fn connection(&self) -> Option<&Connection> { ··· 86 93 PairingAccepted(EndpointId), 87 94 PairingRejected(EndpointId), 88 95 PairingFailed(EndpointId), 96 + GuestAccepted(EndpointId), 97 + GuestExpired(EndpointId), 89 98 Message(EndpointId, Vec<u8>), 90 99 } 91 100 92 101 #[derive(Serialize, Deserialize, Debug, PartialEq)] 93 102 pub(crate) enum PairingCommand { 94 103 Request, 104 + /// Guest pairing request with a 6-digit PIN. 105 + GuestRequest(String), 95 106 Accept, 96 107 Reject, 97 108 Ack, ··· 114 125 status: value.status.clone(), 115 126 } 116 127 } 128 + } 129 + 130 + /// Guest mode state — active when the host is accepting temporary guest pairings. 131 + #[derive(Debug)] 132 + pub(crate) struct GuestModeState { 133 + pub pin: String, 134 + pub expires_at: Instant, 135 + pub guest_peers: HashSet<EndpointId>, 117 136 } 118 137 119 138 #[derive(Debug)] ··· 132 151 133 152 /// The sender side of the channel used to receive high level events. 134 153 sender: Sender<PeerEvent>, 154 + 155 + /// Guest mode state, if active. 156 + pub(crate) guest_mode: Option<GuestModeState>, 157 + 158 + /// Accepted incoming message connections, keyed by remote endpoint ID. 159 + /// Used to actively close connections when a peer is removed. 160 + incoming_connections: HashMap<EndpointId, Connection>, 135 161 } 136 162 137 163 impl State { ··· 142 168 pairing_responders: HashMap::new(), 143 169 pending_ack: HashSet::new(), 144 170 sender, 171 + guest_mode: None, 172 + incoming_connections: HashMap::new(), 145 173 } 146 174 } 147 175 ··· 186 214 self.pairing_requested.remove(id); 187 215 self.pending_ack.remove(id); 188 216 self.pairing_responders.remove(id); 217 + self.close_incoming_connection(id); 189 218 } 190 219 191 220 fn discovered(&self, id: &EndpointId) -> bool { ··· 225 254 self.pairing_responders.remove(id) 226 255 } 227 256 257 + // --- Incoming connection tracking --- 258 + 259 + pub(crate) fn track_incoming_connection(&mut self, id: EndpointId, conn: Connection) { 260 + self.incoming_connections.insert(id, conn); 261 + } 262 + 263 + pub(crate) fn close_incoming_connection(&mut self, id: &EndpointId) { 264 + if let Some(conn) = self.incoming_connections.remove(id) { 265 + conn.close(403u32.into(), b"removed"); 266 + } 267 + } 268 + 269 + // --- Guest mode --- 270 + 271 + pub(crate) fn enable_guest_mode(&mut self, pin: String, duration_secs: u64) { 272 + self.guest_mode = Some(GuestModeState { 273 + pin, 274 + expires_at: Instant::now() + std::time::Duration::from_secs(duration_secs), 275 + guest_peers: HashSet::new(), 276 + }); 277 + } 278 + 279 + pub(crate) fn disable_guest_mode(&mut self) -> Vec<EndpointId> { 280 + let mut removed = Vec::new(); 281 + if let Some(guest_state) = self.guest_mode.take() { 282 + for peer_id in &guest_state.guest_peers { 283 + self.endpoints.remove(peer_id); 284 + self.close_incoming_connection(peer_id); 285 + removed.push(*peer_id); 286 + } 287 + } 288 + removed 289 + } 290 + 291 + pub(crate) fn is_guest_mode_active(&self) -> bool { 292 + self.guest_mode 293 + .as_ref() 294 + .is_some_and(|g| Instant::now() < g.expires_at) 295 + } 296 + 297 + pub(crate) fn validate_guest_pin(&self, pin: &str) -> bool { 298 + self.guest_mode 299 + .as_ref() 300 + .is_some_and(|g| Instant::now() < g.expires_at && g.pin == pin) 301 + } 302 + 303 + pub(crate) fn add_guest_peer(&mut self, id: &EndpointId) { 304 + if let Some(ref mut guest_state) = self.guest_mode { 305 + guest_state.guest_peers.insert(*id); 306 + } 307 + } 308 + 228 309 pub(crate) fn on_discovery(&mut self, event: &DiscoveryEvent) { 229 310 match event { 230 311 DiscoveryEvent::Discovered { endpoint_info, .. } => { ··· 266 347 }, 267 348 DiscoveryEvent::Expired { endpoint_id } => { 268 349 // PairedConnected -> PairedDisconnected 350 + // GuestConnected -> removed (no persistence) 269 351 // Discovered -> removed 270 352 if let Some(mut old_desc) = self.endpoints.remove(endpoint_id) { 271 353 if old_desc.status == EndpointStatus::PairedConnected { 272 354 old_desc.status = EndpointStatus::PairedDisconnected; 273 355 self.endpoints.insert(*endpoint_id, old_desc); 356 + } else if old_desc.status == EndpointStatus::GuestConnected { 357 + if let Some(ref mut guest_state) = self.guest_mode { 358 + guest_state.guest_peers.remove(endpoint_id); 359 + } 360 + self.close_incoming_connection(endpoint_id); 274 361 } else if old_desc.status != EndpointStatus::Discovered { 275 362 warn!( 276 363 "Unexpected status for expired endpoint: {:?}",
+33 -23
patches/components/constellation/constellation.rs.patch
··· 396 396 ScriptToConstellationMessage::MediaSessionEvent(pipeline_id, event) => { 397 397 // Unlikely at this point, but we may receive events coming from 398 398 // different media sessions, so we set the active media session based 399 - @@ -2068,8 +2220,13 @@ 399 + @@ -2068,7 +2220,12 @@ 400 400 } 401 401 self.active_media_session = Some(pipeline_id); 402 402 self.constellation_to_embedder_proxy.send( 403 403 - ConstellationToEmbedderMsg::MediaSessionEvent(webview_id, event), 404 404 + ConstellationToEmbedderMsg::MediaSessionEvent(webview_id, event.clone()), 405 - ); 405 + + ); 406 406 + // Also route to embedded webview parent iframe. 407 407 + self.handle_embedded_webview_notification( 408 408 + webview_id, 409 409 + EmbeddedWebViewEventType::MediaSessionEvent(event), 410 - + ); 410 + ); 411 411 }, 412 412 #[cfg(feature = "webgpu")] 413 - ScriptToConstellationMessage::RequestAdapter(response_sender, options, ids) => self 414 - @@ -2143,7 +2300,862 @@ 413 + @@ -2143,9 +2300,873 @@ 415 414 } 416 415 }, 417 416 }, ··· 603 602 + ScriptToConstellationMessage::PairingRemovePeer(id, callback) => { 604 603 + self.pairing.remove_peer(&id, callback); 605 604 + }, 605 + + ScriptToConstellationMessage::PairingEnableGuestMode(duration, callback) => { 606 + + self.pairing.enable_guest_mode(duration, callback); 607 + + }, 608 + + ScriptToConstellationMessage::PairingDisableGuestMode(callback) => { 609 + + self.pairing.disable_guest_mode(callback); 610 + + }, 611 + + ScriptToConstellationMessage::PairingRequestGuestPairing(id, pin, callback) => { 612 + + self.pairing.request_guest_pairing(&id, &pin, callback); 613 + + }, 606 614 + ScriptToConstellationMessage::CreatePeerStream( 607 615 + peer_id, 608 616 + local_port_id, ··· 951 959 + let _ = callback.send(None); 952 960 + } 953 961 + }, 954 - + } 955 - + } 956 - + 962 + } 963 + } 964 + 957 965 + fn handle_pairing_event(&mut self, event: PairingEvent) { 958 966 + if let PairingEvent::MessageReceived { ref from, ref data } = event { 959 967 + debug!("P2P message received from {from}, {} bytes", data.len()); ··· 1264 1272 + if !channels.is_empty() { 1265 1273 + self.pairing.sync_channels_to_peer(id, &channels); 1266 1274 + } 1267 - } 1275 + + } 1268 1276 + 1269 1277 + for event_loop in self.event_loops() { 1270 1278 + if self.embedder_error_listeners.contains(&event_loop.id()) { 1271 1279 + let _ = event_loop.send(ScriptThreadMessage::DispatchPairingEvent(event.clone())); 1272 1280 + } 1273 1281 + } 1274 - } 1275 - 1282 + + } 1283 + + 1276 1284 /// Check the origin of a message against that of the pipeline it came from. 1277 - @@ -2462,6 +3474,55 @@ 1285 + /// Note: this is still limited as a security check, 1286 + /// see <https://github.com/servo/servo/issues/11722> 1287 + @@ -2462,6 +3483,55 @@ 1278 1288 TransferState::TransferInProgress(queue) => queue.push_back(task), 1279 1289 TransferState::CompletionFailed(queue) => queue.push_back(task), 1280 1290 TransferState::CompletionRequested(_, queue) => queue.push_back(task), ··· 1330 1340 } 1331 1341 } 1332 1342 1333 - @@ -3361,6 +4422,40 @@ 1343 + @@ -3361,6 +4431,40 @@ 1334 1344 /// <https://html.spec.whatwg.org/multipage/#destroy-a-top-level-traversable> 1335 1345 fn handle_close_top_level_browsing_context(&mut self, webview_id: WebViewId) { 1336 1346 debug!("{webview_id}: Closing"); ··· 1371 1381 let browsing_context_id = BrowsingContextId::from(webview_id); 1372 1382 // Step 5. Remove traversable from the user agent's top-level traversable set. 1373 1383 let browsing_context = 1374 - @@ -3637,8 +4732,27 @@ 1384 + @@ -3637,8 +4741,27 @@ 1375 1385 opener_webview_id, 1376 1386 opener_pipeline_id, 1377 1387 response_sender, ··· 1399 1409 let Some((webview_id_sender, webview_id_receiver)) = generic_channel::channel() else { 1400 1410 warn!("Failed to create channel"); 1401 1411 let _ = response_sender.send(None); 1402 - @@ -3737,6 +4851,395 @@ 1412 + @@ -3737,6 +4860,395 @@ 1403 1413 }); 1404 1414 } 1405 1415 ··· 1795 1805 #[servo_tracing::instrument(skip_all)] 1796 1806 fn handle_refresh_cursor(&self, pipeline_id: PipelineId) { 1797 1807 let Some(pipeline) = self.pipelines.get(&pipeline_id) else { 1798 - @@ -4286,7 +5789,7 @@ 1808 + @@ -4286,7 +5798,7 @@ 1799 1809 }, 1800 1810 }; 1801 1811 ··· 1804 1814 match self.browsing_contexts.get_mut(&browsing_context_id) { 1805 1815 Some(browsing_context) => { 1806 1816 let old_pipeline_id = browsing_context.pipeline_id; 1807 - @@ -4295,6 +5798,7 @@ 1817 + @@ -4295,6 +5807,7 @@ 1808 1818 old_pipeline_id, 1809 1819 browsing_context.parent_pipeline_id, 1810 1820 browsing_context.webview_id, ··· 1812 1822 ) 1813 1823 }, 1814 1824 None => { 1815 - @@ -4304,6 +5808,15 @@ 1825 + @@ -4304,6 +5817,15 @@ 1816 1826 1817 1827 self.unload_document(old_pipeline_id); 1818 1828 ··· 1828 1838 if let Some(new_pipeline) = self.pipelines.get(&new_pipeline_id) { 1829 1839 if let Some(ref chan) = self.devtools_sender { 1830 1840 let state = NavigationState::Start(new_pipeline.url.clone()); 1831 - @@ -4862,7 +6375,7 @@ 1841 + @@ -4862,7 +6384,7 @@ 1832 1842 } 1833 1843 1834 1844 #[servo_tracing::instrument(skip_all)] ··· 1837 1847 // Send a flat projection of the history to embedder. 1838 1848 // The final vector is a concatenation of the URLs of the past 1839 1849 // entries, the current entry and the future entries. 1840 - @@ -4966,9 +6479,22 @@ 1850 + @@ -4966,9 +6488,22 @@ 1841 1851 self.constellation_to_embedder_proxy 1842 1852 .send(ConstellationToEmbedderMsg::HistoryChanged( 1843 1853 webview_id, ··· 1861 1871 } 1862 1872 1863 1873 #[servo_tracing::instrument(skip_all)] 1864 - @@ -4987,7 +6513,7 @@ 1874 + @@ -4987,7 +6522,7 @@ 1865 1875 } 1866 1876 } 1867 1877 ··· 1870 1880 match self.browsing_contexts.get_mut(&change.browsing_context_id) { 1871 1881 Some(browsing_context) => { 1872 1882 debug!("Adding pipeline to existing browsing context."); 1873 - @@ -4994,11 +6520,15 @@ 1883 + @@ -4994,11 +6529,15 @@ 1874 1884 let old_pipeline_id = browsing_context.pipeline_id; 1875 1885 browsing_context.pipelines.insert(change.new_pipeline_id); 1876 1886 browsing_context.update_current_entry(change.new_pipeline_id); ··· 1888 1898 }, 1889 1899 }; 1890 1900 1891 - @@ -5006,6 +6536,18 @@ 1901 + @@ -5006,6 +6545,18 @@ 1892 1902 self.unload_document(old_pipeline_id); 1893 1903 } 1894 1904
+97 -2
patches/components/constellation/pairing.rs.patch
··· 1 1 --- original 2 2 +++ modified 3 - @@ -0,0 +1,907 @@ 3 + @@ -0,0 +1,1002 @@ 4 4 +// SPDX-License-Identifier: AGPL-3.0-or-later 5 5 + 6 6 +//! P2P pairing service integration with the constellation. ··· 13 13 +use std::path::PathBuf; 14 14 +use std::sync::Arc; 15 15 + 16 - +use beaver_p2p::{PairingManager, PeerEvent}; 16 + +use beaver_p2p::{PairingManager, PairingResult, PeerEvent}; 17 17 +use iroh::EndpointId; 18 18 +use iroh::address_lookup::mdns::DiscoveryEvent; 19 19 +use log::{debug, error, info, warn}; ··· 297 297 + EndpointStatus::PairedDisconnected => { 298 298 + EndpointStatus::PairedDisconnected 299 299 + }, 300 + + EndpointStatus::GuestConnected => EndpointStatus::GuestConnected, 300 301 + }, 301 302 + }) 302 303 + .collect(); ··· 778 779 + pub(crate) fn confirm_peer(&mut self, peer_id: &str) -> bool { 779 780 + self.confirmed_peers.insert(peer_id.to_owned()) 780 781 + } 782 + + 783 + + // --- Guest mode --- 784 + + 785 + + pub(crate) fn enable_guest_mode( 786 + + &self, 787 + + duration_minutes: u32, 788 + + callback: GenericCallback<Result<String, String>>, 789 + + ) { 790 + + let manager = self.manager.clone(); 791 + + net::async_runtime::spawn_task(async move { 792 + + let mgr = { 793 + + let guard = manager.lock().await; 794 + + match guard.as_ref() { 795 + + Some(mgr) => mgr.clone(), 796 + + None => { 797 + + let _ = callback.send(Err("Pairing service not started".to_owned())); 798 + + return; 799 + + }, 800 + + } 801 + + }; 802 + + match mgr.enable_guest_mode(duration_minutes).await { 803 + + Ok(pin) => { 804 + + let _ = callback.send(Ok(pin)); 805 + + }, 806 + + Err(e) => { 807 + + let _ = callback.send(Err(format!("Failed to enable guest mode: {e}"))); 808 + + }, 809 + + } 810 + + }); 811 + + } 812 + + 813 + + pub(crate) fn disable_guest_mode(&self, callback: GenericCallback<Result<(), String>>) { 814 + + let manager = self.manager.clone(); 815 + + net::async_runtime::spawn_task(async move { 816 + + let mgr = { 817 + + let guard = manager.lock().await; 818 + + match guard.as_ref() { 819 + + Some(mgr) => mgr.clone(), 820 + + None => { 821 + + let _ = callback.send(Err("Pairing service not started".to_owned())); 822 + + return; 823 + + }, 824 + + } 825 + + }; 826 + + match mgr.disable_guest_mode().await { 827 + + Ok(()) => { 828 + + let _ = callback.send(Ok(())); 829 + + }, 830 + + Err(e) => { 831 + + let _ = callback.send(Err(format!("Failed to disable guest mode: {e}"))); 832 + + }, 833 + + } 834 + + }); 835 + + } 836 + + 837 + + pub(crate) fn request_guest_pairing( 838 + + &self, 839 + + id: &str, 840 + + pin: &str, 841 + + callback: GenericCallback<Result<bool, String>>, 842 + + ) { 843 + + let endpoint_id: EndpointId = match id.parse() { 844 + + Ok(id) => id, 845 + + Err(e) => { 846 + + let _ = callback.send(Err(format!("Invalid endpoint ID: {e}"))); 847 + + return; 848 + + }, 849 + + }; 850 + + 851 + + let manager = self.manager.clone(); 852 + + let pin = pin.to_string(); 853 + + net::async_runtime::spawn_task(async move { 854 + + let mgr = { 855 + + let guard = manager.lock().await; 856 + + match guard.as_ref() { 857 + + Some(mgr) => mgr.clone(), 858 + + None => { 859 + + let _ = callback.send(Err("Pairing service not started".to_owned())); 860 + + return; 861 + + }, 862 + + } 863 + + }; 864 + + match mgr.request_guest_pairing(&endpoint_id, &pin).await { 865 + + Ok(result) => { 866 + + let _ = callback.send(Ok(result == PairingResult::Accepted)); 867 + + }, 868 + + Err(e) => { 869 + + let _ = callback.send(Err(format!("Guest pairing failed: {e}"))); 870 + + }, 871 + + } 872 + + }); 873 + + } 781 874 +} 782 875 + 783 876 +/// Load the secret key from the config directory, or generate and persist a new one. ··· 902 995 + Some(PairingEvent::PairingRejected { id: id.to_string() }) 903 996 + }, 904 997 + PeerEvent::PairingFailed(id) => Some(PairingEvent::PairingFailed { id: id.to_string() }), 998 + + PeerEvent::GuestAccepted(id) => Some(PairingEvent::PairingAccepted { id: id.to_string() }), 999 + + PeerEvent::GuestExpired(id) => Some(PairingEvent::PeerExpired { id: id.to_string() }), 905 1000 + PeerEvent::Message(id, data) => Some(PairingEvent::MessageReceived { 906 1001 + from: id.to_string(), 907 1002 + data: data.clone(),
+4 -1
patches/components/constellation/tracing.rs.patch
··· 36 36 Self::ActivateDocument => target!("ActivateDocument"), 37 37 Self::SetDocumentState(..) => target!("SetDocumentState"), 38 38 Self::SetFinalUrl(..) => target!("SetFinalUrl"), 39 - @@ -193,6 +201,61 @@ 39 + @@ -193,6 +201,64 @@ 40 40 Self::TriggerGarbageCollection => target!("TriggerGarbageCollection"), 41 41 Self::AcquireWakeLock(..) => target!("AcquireWakeLock"), 42 42 Self::ReleaseWakeLock(..) => target!("ReleaseWakeLock"), ··· 87 87 + Self::PairingAcceptPairing(..) => target!("PairingAcceptPairing"), 88 88 + Self::PairingRejectPairing(..) => target!("PairingRejectPairing"), 89 89 + Self::PairingRemovePeer(..) => target!("PairingRemovePeer"), 90 + + Self::PairingEnableGuestMode(..) => target!("PairingEnableGuestMode"), 91 + + Self::PairingDisableGuestMode(..) => target!("PairingDisableGuestMode"), 92 + + Self::PairingRequestGuestPairing(..) => target!("PairingRequestGuestPairing"), 90 93 + Self::CreatePeerStream(..) => target!("CreatePeerStream"), 91 94 + Self::PeerStreamResponse(..) => target!("PeerStreamResponse"), 92 95 + Self::AtProto(..) => target!("AtProto"),
+40
patches/components/script/dom/html/input_element/mod.rs.patch
··· 1 + --- original 2 + +++ modified 3 + @@ -7,7 +7,9 @@ 4 + use std::{f64, ptr}; 5 + 6 + use dom_struct::dom_struct; 7 + -use embedder_traits::{EmbedderControlRequest, InputMethodRequest, RgbColor, SelectedFile}; 8 + +use embedder_traits::{ 9 + + EmbedderControlRequest, InputMethodRequest, InputMethodType, RgbColor, SelectedFile, 10 + +}; 11 + use encoding_rs::Encoding; 12 + use fonts::{ByteIndex, TextByteRange}; 13 + use html5ever::{LocalName, Prefix, local_name}; 14 + @@ -1899,10 +1901,25 @@ 15 + .hide_embedder_control(self.upcast()); 16 + } else if *event_type == *"focus" { 17 + let input_type = &*self.input_type(); 18 + - let Ok(input_method_type) = input_type.try_into() else { 19 + + let Ok(mut input_method_type) = input_type.try_into() else { 20 + return; 21 + }; 22 + 23 + + if let Some(inputmode) = self 24 + + .upcast::<Element>() 25 + + .get_attribute(&local_name!("inputmode")) 26 + + { 27 + + let mode = inputmode.value(); 28 + + match &*mode.to_ascii_lowercase() { 29 + + "numeric" | "decimal" => input_method_type = InputMethodType::Number, 30 + + "tel" => input_method_type = InputMethodType::Tel, 31 + + "url" => input_method_type = InputMethodType::Url, 32 + + "email" => input_method_type = InputMethodType::Email, 33 + + "search" => input_method_type = InputMethodType::Search, 34 + + _ => {}, 35 + + } 36 + + } 37 + + 38 + self.owner_document() 39 + .embedder_controls() 40 + .show_embedder_control(
+79 -1
patches/components/script/dom/pairing.rs.patch
··· 1 1 --- original 2 2 +++ modified 3 - @@ -0,0 +1,321 @@ 3 + @@ -0,0 +1,399 @@ 4 4 +/* SPDX Id: AGPL-3.0-or-later */ 5 5 + 6 6 +use std::rc::Rc; ··· 178 178 + promise 179 179 + } 180 180 + 181 + + fn EnableGuestMode(&self, duration_minutes: u32, comp: InRealm, can_gc: CanGc) -> Rc<Promise> { 182 + + let global = &self.global(); 183 + + let promise = Promise::new_in_current_realm(comp, can_gc); 184 + + let task_source = global.task_manager().dom_manipulation_task_source(); 185 + + let callback = callback_promise(&promise, self, task_source); 186 + + 187 + + let chan = global.script_to_constellation_chan(); 188 + + if chan 189 + + .send(ScriptToConstellationMessage::PairingEnableGuestMode( 190 + + duration_minutes, 191 + + callback, 192 + + )) 193 + + .is_err() 194 + + { 195 + + promise.reject_error(Error::Operation(None), can_gc); 196 + + } 197 + + promise 198 + + } 199 + + 200 + + fn DisableGuestMode(&self, comp: InRealm, can_gc: CanGc) -> Rc<Promise> { 201 + + let global = &self.global(); 202 + + let promise = Promise::new_in_current_realm(comp, can_gc); 203 + + let task_source = global.task_manager().dom_manipulation_task_source(); 204 + + let callback = callback_promise(&promise, self, task_source); 205 + + 206 + + let chan = global.script_to_constellation_chan(); 207 + + if chan 208 + + .send(ScriptToConstellationMessage::PairingDisableGuestMode( 209 + + callback, 210 + + )) 211 + + .is_err() 212 + + { 213 + + promise.reject_error(Error::Operation(None), can_gc); 214 + + } 215 + + promise 216 + + } 217 + + 218 + + fn RequestGuestPairing( 219 + + &self, 220 + + peer: &Peer, 221 + + pin: crate::dom::bindings::str::DOMString, 222 + + comp: InRealm, 223 + + can_gc: CanGc, 224 + + ) -> Rc<Promise> { 225 + + let global = &self.global(); 226 + + let promise = Promise::new_in_current_realm(comp, can_gc); 227 + + let task_source = global.task_manager().dom_manipulation_task_source(); 228 + + let callback = callback_promise(&promise, self, task_source); 229 + + 230 + + let chan = global.script_to_constellation_chan(); 231 + + if chan 232 + + .send(ScriptToConstellationMessage::PairingRequestGuestPairing( 233 + + peer.id().to_string(), 234 + + pin.to_string(), 235 + + callback, 236 + + )) 237 + + .is_err() 238 + + { 239 + + promise.reject_error(Error::Operation(None), can_gc); 240 + + } 241 + + promise 242 + + } 243 + + 181 244 + fn SetName( 182 245 + &self, 183 246 + name: crate::dom::bindings::str::DOMString, ··· 292 355 + EndpointStatus::Discovered => "discovered", 293 356 + EndpointStatus::PairedConnected => "paired-connected", 294 357 + EndpointStatus::PairedDisconnected => "paired-disconnected", 358 + + EndpointStatus::GuestConnected => "guest-connected", 295 359 + }; 296 360 + Peer::new_with_status( 297 361 + &global, ··· 322 386 + } 323 387 + } 324 388 +} 389 + + 390 + +impl RoutedPromiseListener<Result<String, String>> for Pairing { 391 + + fn handle_response( 392 + + &self, 393 + + cx: &mut JSContext, 394 + + response: Result<String, String>, 395 + + promise: &Rc<Promise>, 396 + + ) { 397 + + match response { 398 + + Ok(value) => promise.resolve_native(&value, CanGc::from_cx(cx)), 399 + + Err(msg) => promise.reject_error(Error::Operation(Some(msg)), CanGc::from_cx(cx)), 400 + + } 401 + + } 402 + +}
+2 -2
patches/components/script_bindings/codegen/Bindings.conf.patch
··· 42 42 }, 43 43 44 44 +'Pairing': { 45 - + 'inRealms': ['Start', 'Stop', 'Local', 'Peers', 'RequestPairing', 'SetName', 'AcceptPairing', 'RejectPairing', 'RemovePeer'], 46 - + 'canGc': ['Start', 'Stop', 'Local', 'Peers', 'RequestPairing', 'SetName', 'AcceptPairing', 'RejectPairing', 'RemovePeer'], 45 + + 'inRealms': ['Start', 'Stop', 'Local', 'Peers', 'RequestPairing', 'SetName', 'AcceptPairing', 'RejectPairing', 'RemovePeer', 'EnableGuestMode', 'DisableGuestMode', 'RequestGuestPairing'], 46 + + 'canGc': ['Start', 'Stop', 'Local', 'Peers', 'RequestPairing', 'SetName', 'AcceptPairing', 'RejectPairing', 'RemovePeer', 'EnableGuestMode', 'DisableGuestMode', 'RequestGuestPairing'], 47 47 +}, 48 48 + 49 49 'PerformanceObserver': {
+11 -1
patches/components/script_bindings/webidls/Pairing.webidl.patch
··· 1 1 --- original 2 2 +++ modified 3 - @@ -0,0 +1,71 @@ 3 + @@ -0,0 +1,81 @@ 4 4 +/* SPDX Id: AGPL-3.0-or-later */ 5 5 + 6 6 +[Exposed=Window, ··· 54 54 + 55 55 + // Remove a paired peer (unpair and forget). 56 56 + Promise<undefined> removePeer(Peer peer); 57 + + 58 + + // Guest mode: enable temporary pairing with a PIN. 59 + + // Returns the 6-digit PIN to display. 60 + + Promise<DOMString> enableGuestMode(optional unsigned long durationMinutes = 240); 61 + + 62 + + // Guest mode: disable and disconnect all guests. 63 + + Promise<undefined> disableGuestMode(); 64 + + 65 + + // Guest pairing: request pairing with a host using a PIN. 66 + + Promise<boolean> requestGuestPairing(Peer peer, DOMString pin); 57 67 + 58 68 + // A new unpaired peer was discovered. 59 69 + attribute EventHandler onpeerdiscovered;
+7 -1
patches/components/shared/constellation/from_script_message.rs.patch
··· 209 209 /// Mark a new document as active 210 210 ActivateDocument, 211 211 /// Set the document state for a pipeline (used by screenshot / reftests) 212 - @@ -754,6 +887,116 @@ 212 + @@ -754,6 +887,122 @@ 213 213 /// aggregate lock count and notify the provider only when the count transitions from N to 0. 214 214 /// <https://w3c.github.io/screen-wake-lock/#dfn-release-wake-lock> 215 215 ReleaseWakeLock(WakeLockType), ··· 275 275 + PairingRejectPairing(String, GenericCallback<Result<(), String>>), 276 276 + /// Remove a paired peer (unpair and forget). 277 277 + PairingRemovePeer(String, GenericCallback<Result<(), String>>), 278 + + /// Enable guest pairing mode with a duration in minutes. Returns the PIN. 279 + + PairingEnableGuestMode(u32, GenericCallback<Result<String, String>>), 280 + + /// Disable guest pairing mode and remove all guests. 281 + + PairingDisableGuestMode(GenericCallback<Result<(), String>>), 282 + + /// Request guest pairing with a PIN. 283 + + PairingRequestGuestPairing(String, String, GenericCallback<Result<bool, String>>), 278 284 + /// Create a peer stream: create a virtual remote port entangled with a local port, 279 285 + /// and send the offer to a remote peer. 280 286 + /// Args: peer_id, local_port_id, remote_port_id, target_url, callback.
+3
ui/keyboard/index.js
··· 65 65 66 66 const INPUT_TYPE_LAYOUTS = { 67 67 number: { layout: numpad, defaultVariant: "default" }, 68 + numeric: { layout: numpad, defaultVariant: "default" }, 69 + decimal: { layout: numpad, defaultVariant: "default" }, 70 + tel: { layout: numpad, defaultVariant: "default" }, 68 71 }; 69 72 70 73 let currentLayoutIndex = 0;
+18
ui/settings/index.css
··· 648 648 cursor: not-allowed; 649 649 } 650 650 651 + .p2p-guest-pin { 652 + display: flex; 653 + align-items: center; 654 + gap: var(--spacing-md); 655 + padding: var(--spacing-md); 656 + margin-top: var(--spacing-sm); 657 + background: var(--bg-webview); 658 + border-radius: var(--radius-md); 659 + border: 1px solid var(--color-border); 660 + } 661 + 662 + .p2p-pin-code { 663 + font-size: 1.6em; 664 + letter-spacing: 0.2em; 665 + font-weight: var(--font-weight-bold); 666 + color: var(--color-primary); 667 + } 668 + 651 669 /* ===== ATProto ===== */ 652 670 653 671 .atproto-btn {
+21
ui/settings/index.html
··· 182 182 <span class="setting-description">No peers discovered yet.</span> 183 183 </div> 184 184 </div> 185 + 186 + <div id="p2p-guest-mode" class="p2p-section" style="display:none"> 187 + <div class="setting-row"> 188 + <div class="setting-info"> 189 + <label for="p2p-guest-toggle" class="setting-label"> 190 + Guest Mode 191 + </label> 192 + <span class="setting-description"> 193 + Let guests pair temporarily with a PIN 194 + </span> 195 + </div> 196 + <label class="toggle-switch"> 197 + <input type="checkbox" id="p2p-guest-toggle" /> 198 + <span class="toggle-slider"></span> 199 + </label> 200 + </div> 201 + <div id="p2p-guest-pin-display" class="p2p-guest-pin" style="display:none"> 202 + <span class="setting-label">PIN</span> 203 + <code id="p2p-guest-pin-value" class="p2p-pin-code"></code> 204 + </div> 205 + </div> 185 206 </div> 186 207 </details> 187 208 </section>
+37
ui/settings/index.js
··· 254 254 const idValue = document.getElementById("p2p-id-value"); 255 255 const peerList = document.getElementById("p2p-peer-list"); 256 256 const refreshBtn = document.getElementById("p2p-refresh-peers"); 257 + const guestModeSection = document.getElementById("p2p-guest-mode"); 258 + const guestToggle = document.getElementById("p2p-guest-toggle"); 259 + const guestPinDisplay = document.getElementById("p2p-guest-pin-display"); 260 + const guestPinValue = document.getElementById("p2p-guest-pin-value"); 257 261 258 262 const pairing = navigator.embedder.pairing; 259 263 ··· 360 364 function setRunning(running) { 361 365 localInfo.style.display = running ? "" : "none"; 362 366 peersSection.style.display = running ? "" : "none"; 367 + guestModeSection.style.display = running ? "" : "none"; 368 + if (!running) { 369 + guestToggle.checked = false; 370 + guestPinDisplay.style.display = "none"; 371 + } 363 372 } 364 373 365 374 toggle.addEventListener("change", async () => { ··· 404 413 }); 405 414 406 415 refreshBtn.addEventListener("click", refreshPeers); 416 + 417 + guestToggle.addEventListener("change", async () => { 418 + if (guestToggle.checked) { 419 + guestToggle.disabled = true; 420 + try { 421 + const pin = await pairing.enableGuestMode(); 422 + guestPinValue.textContent = pin; 423 + guestPinDisplay.style.display = ""; 424 + } catch (e) { 425 + console.error("Failed to enable guest mode:", e); 426 + guestToggle.checked = false; 427 + } finally { 428 + guestToggle.disabled = false; 429 + } 430 + } else { 431 + guestToggle.disabled = true; 432 + try { 433 + await pairing.disableGuestMode(); 434 + guestPinDisplay.style.display = "none"; 435 + guestPinValue.textContent = ""; 436 + } catch (e) { 437 + console.error("Failed to disable guest mode:", e); 438 + guestToggle.checked = true; 439 + } finally { 440 + guestToggle.disabled = false; 441 + } 442 + } 443 + }); 407 444 408 445 // Listen for peer events to auto-refresh 409 446 pairing.addEventListener("peerdiscovered", refreshPeers);
+56
ui/system/mediacenter/ambient_view.js
··· 492 492 mediaTitle: { type: String }, 493 493 mediaArtist: { type: String }, 494 494 playbackState: { type: String }, 495 + guestPin: { type: String }, 495 496 _time: { state: true }, 496 497 _date: { state: true }, 497 498 _weatherTemp: { state: true }, ··· 620 621 0%, 100% { opacity: 1; transform: scale(1); } 621 622 50% { opacity: 0.4; transform: scale(0.8); } 622 623 } 624 + 625 + /* --- Guest PIN display --- */ 626 + 627 + .guest-pin { 628 + position: fixed; 629 + bottom: 2.5em; 630 + right: 2.5em; 631 + display: flex; 632 + flex-direction: column; 633 + align-items: flex-end; 634 + gap: 0.5em; 635 + z-index: 2; 636 + } 637 + 638 + .guest-pin .pin-label { 639 + font-size: clamp(0.5em, 1.3vw, 0.85em); 640 + color: var(--mc-text-muted); 641 + letter-spacing: 0.05em; 642 + text-transform: uppercase; 643 + } 644 + 645 + .guest-pin .pin-digits { 646 + display: flex; 647 + gap: 0.3em; 648 + } 649 + 650 + .guest-pin .digit { 651 + display: flex; 652 + align-items: center; 653 + justify-content: center; 654 + width: 1.8em; 655 + height: 2.2em; 656 + font-size: clamp(1.2em, 2vw, 1.6em); 657 + font-weight: var(--font-weight-bold); 658 + color: var(--mc-text-primary); 659 + background: var(--mc-overlay-light); 660 + backdrop-filter: blur(16px); 661 + border-radius: var(--radius-md); 662 + border: 1px solid oklch(35% 0.03 65 / 0.25); 663 + font-variant-numeric: tabular-nums; 664 + } 623 665 `; 624 666 625 667 constructor() { ··· 627 669 this.mediaTitle = ""; 628 670 this.mediaArtist = ""; 629 671 this.playbackState = "none"; 672 + this.guestPin = ""; 630 673 this._time = ""; 631 674 this._date = ""; 632 675 this._weatherTemp = null; ··· 872 915 ` 873 916 : ""} 874 917 </div> 918 + 919 + ${this.guestPin 920 + ? html` 921 + <div class="guest-pin"> 922 + <span class="pin-label">Guest PIN</span> 923 + <div class="pin-digits"> 924 + ${[...this.guestPin].map( 925 + (d) => html`<span class="digit">${d}</span>`, 926 + )} 927 + </div> 928 + </div> 929 + ` 930 + : ""} 875 931 876 932 <div class="now-playing-pill ${hasMedia ? "" : "hidden"}"> 877 933 <div class="pulse"></div>
+2
ui/system/mediacenter/dpad.js
··· 69 69 break; 70 70 case "Enter": 71 71 event.preventDefault(); 72 + event.stopImmediatePropagation(); 72 73 ctx.onSelect?.(items[this.#focusIndex], this.#focusIndex); 73 74 break; 74 75 case "Escape": 75 76 event.preventDefault(); 77 + event.stopImmediatePropagation(); 76 78 ctx.onBack?.(); 77 79 break; 78 80 }
+33
ui/system/mediacenter/index.js
··· 297 297 if (systemMenuOpen) return; 298 298 systemMenuOpen = true; 299 299 stateBeforeMenu = currentState; 300 + systemMenu.guestModeActive = !!ambient.guestPin; 300 301 systemMenu.open = true; 301 302 dpad.clearContext(); 302 303 systemMenu.updateComplete.then(() => { ··· 329 330 } else if (action === "settings") { 330 331 // Open settings as a page 331 332 launchContent({ category: "system", url: "beaver://settings/index.html", title: "Settings" }); 333 + } else if (action === "guest-enable") { 334 + enableGuestMode(); 335 + } else if (action === "guest-disable") { 336 + disableGuestMode(); 332 337 } 333 338 }); 339 + 340 + async function enableGuestMode() { 341 + const pairing = navigator.embedder?.pairing; 342 + if (!pairing) return; 343 + try { 344 + const pin = await pairing.enableGuestMode(); 345 + ambient.guestPin = pin; 346 + console.log("[MediaCenter] Guest mode enabled, PIN:", pin); 347 + } catch (e) { 348 + console.error("[MediaCenter] Failed to enable guest mode:", e); 349 + } 350 + } 351 + 352 + async function disableGuestMode() { 353 + const pairing = navigator.embedder?.pairing; 354 + if (!pairing) return; 355 + try { 356 + await pairing.disableGuestMode(); 357 + ambient.guestPin = ""; 358 + console.log("[MediaCenter] Guest mode disabled"); 359 + } catch (e) { 360 + console.error("[MediaCenter] Failed to disable guest mode:", e); 361 + } 362 + } 334 363 335 364 document.addEventListener("menu-dismiss", () => { 336 365 closeSystemMenu(); ··· 568 597 569 598 await pairing.setName("Beaver Media Center").catch((e) => 570 599 console.error("[P2P] setName failed:", e), 600 + ); 601 + 602 + await pairing.stop().catch((e) => 603 + console.error("[P2P] stop failed:", e), 571 604 ); 572 605 573 606 await pairing.start().catch((e) =>
+7
ui/system/mediacenter/system_menu.js
··· 9 9 class SystemMenu extends LitElement { 10 10 static properties = { 11 11 open: { type: Boolean, reflect: true }, 12 + guestModeActive: { type: Boolean }, 12 13 }; 13 14 14 15 static styles = css` ··· 99 100 constructor() { 100 101 super(); 101 102 this.open = false; 103 + this.guestModeActive = false; 102 104 } 103 105 104 106 get menuItems() { 107 + const guestItem = this.guestModeActive 108 + ? { id: "guest-disable", label: "Disable Guest Access", description: "Disconnect all guests", icon: "user-x" } 109 + : { id: "guest-enable", label: "Guest Access", description: "Let guests pair with a PIN", icon: "user-plus" }; 110 + 105 111 return [ 112 + guestItem, 106 113 { id: "settings", label: "Settings", description: "Preferences and configuration", icon: "settings" }, 107 114 { id: "quit", label: "Quit", description: "Exit Beaver", icon: "power", danger: true }, 108 115 ];
+104
ui/system/remote/index.css
··· 127 127 line-height: 1.6; 128 128 } 129 129 130 + /* ===== Guest Connect (device list) ===== */ 131 + 132 + .device-item-guest { 133 + display: flex; 134 + align-items: center; 135 + gap: 1.2em; 136 + padding: 1.3em 1.5em; 137 + background: none; 138 + border-radius: var(--radius-md); 139 + margin-bottom: 0.6em; 140 + cursor: pointer; 141 + transition: 142 + background var(--transition-fast), 143 + transform 0.1s cubic-bezier(0.16, 1, 0.3, 1); 144 + border: 1px dashed var(--color-border); 145 + } 146 + 147 + .device-item-guest:active { 148 + background: var(--bg-hover); 149 + transform: scale(0.97); 150 + } 151 + 152 + .device-item-guest lucide-icon { 153 + font-size: 1.6em; 154 + color: var(--color-text-secondary); 155 + } 156 + 157 + /* ===== PIN Entry Screen ===== */ 158 + 159 + .pin-form { 160 + display: flex; 161 + flex-direction: column; 162 + align-items: center; 163 + padding: 2em; 164 + gap: 1.5em; 165 + } 166 + 167 + .pin-input { 168 + width: 10em; 169 + font-size: 2em; 170 + text-align: center; 171 + letter-spacing: 0.3em; 172 + padding: 0.5em 0.6em; 173 + background: var(--bg-surface); 174 + border: 2px solid var(--color-border); 175 + border-radius: var(--radius-md); 176 + color: var(--color-text); 177 + font-family: var(--font-family-base); 178 + font-weight: var(--font-weight-bold); 179 + caret-color: var(--color-primary); 180 + transition: border-color var(--transition-fast); 181 + } 182 + 183 + .pin-input:focus { 184 + outline: none; 185 + border-color: var(--color-primary); 186 + box-shadow: 0 0 0 3px var(--color-focus-ring); 187 + } 188 + 189 + .pin-error { 190 + color: var(--color-danger); 191 + font-size: var(--font-size-sm); 192 + } 193 + 194 + .pin-actions { 195 + display: flex; 196 + gap: 1em; 197 + margin-top: 0.5em; 198 + } 199 + 200 + .pin-btn { 201 + padding: 0.8em 2em; 202 + border-radius: var(--radius-md); 203 + border: 1px solid var(--color-border); 204 + background: var(--bg-surface); 205 + color: var(--color-text); 206 + font-family: var(--font-family-base); 207 + font-size: 1em; 208 + cursor: pointer; 209 + transition: 210 + background var(--transition-fast), 211 + transform 0.1s cubic-bezier(0.16, 1, 0.3, 1); 212 + } 213 + 214 + .pin-btn:active { 215 + transform: scale(0.95); 216 + } 217 + 218 + .pin-btn:disabled { 219 + opacity: 0.5; 220 + cursor: not-allowed; 221 + } 222 + 223 + .pin-btn-submit { 224 + background: var(--color-primary); 225 + border-color: var(--color-primary); 226 + color: var(--color-text-on-header); 227 + font-weight: var(--font-weight-bold); 228 + } 229 + 230 + .pin-btn-cancel { 231 + background: none; 232 + } 233 + 130 234 /* ===== Connection Bar ===== */ 131 235 132 236 .connection-bar {
+17
ui/system/remote/index.html
··· 28 28 </div> 29 29 </div> 30 30 31 + <!-- PIN entry (shown when connecting as guest) --> 32 + <div id="pin-entry" class="screen hidden"> 33 + <div class="selector-header"> 34 + <div class="app-title">Guest Access</div> 35 + <div class="app-subtitle" id="pin-device-name">Enter the PIN shown on the media center</div> 36 + </div> 37 + <div class="pin-form"> 38 + <input type="text" id="pin-input" class="pin-input" maxlength="6" 39 + inputmode="numeric" pattern="[0-9]*" placeholder="000000" autocomplete="off" /> 40 + <div id="pin-error" class="pin-error hidden">Wrong PIN. Try again.</div> 41 + <div class="pin-actions"> 42 + <button id="pin-cancel" class="pin-btn pin-btn-cancel">Cancel</button> 43 + <button id="pin-submit" class="pin-btn pin-btn-submit">Connect</button> 44 + </div> 45 + </div> 46 + </div> 47 + 31 48 <!-- Remote control (shown when connected) --> 32 49 <div id="remote-control" class="screen hidden"> 33 50 <!-- Connection status -->
+111 -8
ui/system/remote/index.js
··· 16 16 const npArtist = document.getElementById("np-artist"); 17 17 const npPlayIcon = document.getElementById("np-play-icon"); 18 18 19 + // PIN entry elements 20 + const pinEntry = document.getElementById("pin-entry"); 21 + const pinDeviceName = document.getElementById("pin-device-name"); 22 + const pinInput = document.getElementById("pin-input"); 23 + const pinError = document.getElementById("pin-error"); 24 + const pinCancel = document.getElementById("pin-cancel"); 25 + const pinSubmit = document.getElementById("pin-submit"); 26 + 19 27 // --- State --- 20 28 let connectedPeerId = null; 21 29 let remotePort = null; ··· 58 66 59 67 try { 60 68 const peers = await pairing.peers(); 61 - const mediacenters = peers.filter( 69 + 70 + // Paired and connected media centers 71 + const paired = peers.filter( 62 72 (p) => 63 - p.status === "paired-connected" && 73 + (p.status === "paired-connected" || p.status === "guest-connected") && 64 74 p.displayName?.toLowerCase().includes("media center"), 65 75 ); 66 76 67 - if (mediacenters.length === 0) { 77 + // Discovered but unpaired media centers (potential guest targets) 78 + const discovered = peers.filter( 79 + (p) => 80 + p.status === "discovered" && 81 + p.displayName?.toLowerCase().includes("media center"), 82 + ); 83 + 84 + if (paired.length === 0 && discovered.length === 0) { 68 85 deviceList.innerHTML = 69 - '<div class="no-devices">No media centers found.<br>Make sure your media center is on and paired.</div>'; 86 + '<div class="no-devices">No media centers found.<br>Make sure your media center is on and paired, or ask the host to enable Guest Access.</div>'; 70 87 return; 71 88 } 72 89 73 - // If only one, auto-connect 74 - if (mediacenters.length === 1) { 75 - connectToDevice(mediacenters[0]); 90 + // If only one paired device, auto-connect 91 + if (paired.length === 1 && discovered.length === 0) { 92 + connectToDevice(paired[0]); 76 93 return; 77 94 } 78 95 79 96 // Show list 80 97 deviceList.innerHTML = ""; 81 - for (const peer of mediacenters) { 98 + 99 + for (const peer of paired) { 82 100 const item = document.createElement("div"); 83 101 item.className = "device-item"; 84 102 item.innerHTML = ` ··· 90 108 <lucide-icon name="chevron-right"></lucide-icon> 91 109 `; 92 110 item.addEventListener("click", () => connectToDevice(peer)); 111 + deviceList.appendChild(item); 112 + } 113 + 114 + for (const peer of discovered) { 115 + const item = document.createElement("div"); 116 + item.className = "device-item-guest"; 117 + item.innerHTML = ` 118 + <lucide-icon name="user-plus"></lucide-icon> 119 + <div class="device-item-info"> 120 + <div class="device-item-name">${peer.displayName}</div> 121 + <div class="device-item-status">Connect as Guest</div> 122 + </div> 123 + <lucide-icon name="chevron-right"></lucide-icon> 124 + `; 125 + item.addEventListener("click", () => showPinEntry(peer)); 93 126 deviceList.appendChild(item); 94 127 } 95 128 } catch (e) { ··· 194 227 195 228 // Disconnect 196 229 disconnectBtn.addEventListener("click", disconnect); 230 + 231 + // --- Guest PIN Entry --- 232 + 233 + let guestPeer = null; 234 + 235 + function showPinEntry(peer) { 236 + guestPeer = peer; 237 + pinDeviceName.textContent = `Enter the PIN shown on ${peer.displayName}`; 238 + pinInput.value = ""; 239 + pinError.classList.add("hidden"); 240 + pinSubmit.disabled = false; 241 + 242 + deviceSelector.classList.add("hidden"); 243 + pinEntry.classList.remove("hidden"); 244 + pinInput.focus(); 245 + } 246 + 247 + function hidePinEntry() { 248 + pinEntry.classList.add("hidden"); 249 + deviceSelector.classList.remove("hidden"); 250 + guestPeer = null; 251 + } 252 + 253 + async function submitGuestPin() { 254 + const pin = pinInput.value.trim(); 255 + if (pin.length !== 6) return; 256 + 257 + const pairing = navigator.embedder?.pairing; 258 + if (!pairing || !guestPeer) return; 259 + 260 + pinSubmit.disabled = true; 261 + pinError.classList.add("hidden"); 262 + 263 + try { 264 + // Re-fetch peer to get a fresh object 265 + const peers = await pairing.peers(); 266 + const peer = peers.find((p) => p.id === guestPeer.id); 267 + if (!peer) { 268 + pinError.textContent = "Device no longer available."; 269 + pinError.classList.remove("hidden"); 270 + pinSubmit.disabled = false; 271 + return; 272 + } 273 + 274 + const accepted = await pairing.requestGuestPairing(peer, pin); 275 + if (accepted) { 276 + pinEntry.classList.add("hidden"); 277 + connectToDevice(peer); 278 + } else { 279 + pinError.textContent = "Wrong PIN. Try again."; 280 + pinError.classList.remove("hidden"); 281 + pinInput.value = ""; 282 + pinInput.focus(); 283 + pinSubmit.disabled = false; 284 + } 285 + } catch (e) { 286 + console.error("[Remote] Guest pairing failed:", e); 287 + pinError.textContent = "Connection failed. Try again."; 288 + pinError.classList.remove("hidden"); 289 + pinSubmit.disabled = false; 290 + } 291 + } 292 + 293 + pinCancel.addEventListener("click", hidePinEntry); 294 + pinSubmit.addEventListener("click", submitGuestPin); 295 + 296 + pinInput.addEventListener("keydown", (e) => { 297 + if (e.key === "Enter") submitGuestPin(); 298 + if (e.key === "Escape") hidePinEntry(); 299 + }); 197 300 198 301 // --- Initialize --- 199 302
+176 -1
ui/system/system_menu.js
··· 6 6 static properties = { 7 7 ...MenuBase.properties, 8 8 atprotoHandle: { type: String, state: true }, 9 + _discoveredPeers: { type: Array, state: true }, 10 + _guestPeer: { type: Object, state: true }, 11 + _guestError: { type: String, state: true }, 9 12 }; 10 13 11 14 constructor() { 12 15 super(); 13 16 this.atprotoHandle = ""; 17 + this._discoveredPeers = []; 18 + this._guestPeer = null; 19 + this._guestError = ""; 14 20 this.handleKeyDown = this.handleKeyDown.bind(this); 15 21 } 16 22 ··· 24 30 if (this.open) { 25 31 document.addEventListener("keydown", this.handleKeyDown); 26 32 this.refreshAtprotoStatus(); 33 + this.refreshDiscoveredPeers(); 27 34 } else { 28 35 this.removeEventListeners(); 36 + this._guestPeer = null; 37 + this._guestError = ""; 29 38 } 30 39 } 31 40 } ··· 43 52 } 44 53 } 45 54 55 + async refreshDiscoveredPeers() { 56 + const pairing = navigator.embedder?.pairing; 57 + if (!pairing) { 58 + this._discoveredPeers = []; 59 + return; 60 + } 61 + try { 62 + const peers = await pairing.peers(); 63 + this._discoveredPeers = peers.filter((p) => p.status === "discovered"); 64 + } catch { 65 + this._discoveredPeers = []; 66 + } 67 + } 68 + 69 + showGuestPinEntry(peer) { 70 + this._guestPeer = peer; 71 + this._guestError = ""; 72 + this.updateComplete.then(() => { 73 + const input = this.shadowRoot.querySelector("#guest-pin-input"); 74 + if (input) input.focus(); 75 + }); 76 + } 77 + 78 + async submitGuestPin() { 79 + const input = this.shadowRoot.querySelector("#guest-pin-input"); 80 + const pin = input?.value?.trim(); 81 + if (!pin || pin.length !== 6) return; 82 + 83 + const pairing = navigator.embedder?.pairing; 84 + if (!pairing || !this._guestPeer) return; 85 + 86 + try { 87 + const peers = await pairing.peers(); 88 + const peer = peers.find((p) => p.id === this._guestPeer.id); 89 + if (!peer) { 90 + this._guestError = "Device no longer available."; 91 + return; 92 + } 93 + const accepted = await pairing.requestGuestPairing(peer, pin); 94 + if (accepted) { 95 + this._guestPeer = null; 96 + this._guestError = ""; 97 + this.close(); 98 + } else { 99 + this._guestError = "Wrong PIN. Try again."; 100 + input.value = ""; 101 + input.focus(); 102 + } 103 + } catch (e) { 104 + this._guestError = "Connection failed."; 105 + } 106 + } 107 + 46 108 handleKeyDown(e) { 47 109 if (e.key === "Escape") { 48 - this.close(); 110 + if (this._guestPeer) { 111 + this._guestPeer = null; 112 + this._guestError = ""; 113 + } else { 114 + this.close(); 115 + } 116 + } else if (e.key === "Enter" && this._guestPeer) { 117 + this.submitGuestPin(); 49 118 } 50 119 } 51 120 ··· 57 126 58 127 static styles = css` 59 128 @import url(beaver://system/system_menu.css); 129 + 130 + .guest-pin-overlay { 131 + position: absolute; 132 + inset: 0; 133 + z-index: 1002; 134 + display: flex; 135 + align-items: center; 136 + justify-content: center; 137 + } 138 + 139 + .guest-pin-dialog { 140 + background: var(--bg-menu); 141 + border: 1px solid var(--color-border); 142 + border-radius: var(--radius-md); 143 + padding: 1.5em; 144 + min-width: 280px; 145 + display: flex; 146 + flex-direction: column; 147 + gap: 1em; 148 + } 149 + 150 + .guest-pin-title { 151 + font-weight: var(--font-weight-bold); 152 + font-size: 1.05em; 153 + } 154 + 155 + .guest-pin-subtitle { 156 + font-size: var(--font-size-sm); 157 + color: var(--color-text-secondary); 158 + } 159 + 160 + .guest-pin-input { 161 + font-size: 1.5em; 162 + text-align: center; 163 + letter-spacing: 0.25em; 164 + padding: 0.4em; 165 + background: var(--bg-webview); 166 + border: 2px solid var(--color-border); 167 + border-radius: var(--radius-sm); 168 + color: var(--color-text); 169 + font-family: var(--font-family-base); 170 + font-weight: var(--font-weight-bold); 171 + } 172 + 173 + .guest-pin-input:focus { 174 + outline: none; 175 + border-color: var(--color-primary); 176 + box-shadow: 0 0 0 2px var(--color-focus-ring); 177 + } 178 + 179 + .guest-pin-error { 180 + color: var(--color-danger); 181 + font-size: var(--font-size-sm); 182 + text-align: center; 183 + } 184 + 185 + .guest-pin-actions { 186 + display: flex; 187 + gap: 0.6em; 188 + justify-content: flex-end; 189 + } 190 + 191 + .guest-pin-actions button { 192 + padding: 0.5em 1.2em; 193 + border: 1px solid var(--color-border); 194 + border-radius: var(--radius-sm); 195 + background: var(--bg-webview); 196 + color: var(--color-text); 197 + font-family: var(--font-family-base); 198 + cursor: pointer; 199 + } 200 + 201 + .guest-pin-actions .submit { 202 + background: var(--color-primary); 203 + border-color: var(--color-primary); 204 + color: var(--color-text-on-header); 205 + font-weight: var(--font-weight-bold); 206 + } 60 207 `; 61 208 62 209 render() { 210 + if (this._guestPeer) { 211 + return html` 212 + <div class="backdrop" @click=${() => { this._guestPeer = null; }}></div> 213 + <div class="guest-pin-overlay"> 214 + <div class="guest-pin-dialog"> 215 + <div class="guest-pin-title">Connect as Guest</div> 216 + <div class="guest-pin-subtitle">Enter the PIN shown on ${this._guestPeer.displayName}</div> 217 + <input type="text" class="guest-pin-input" id="guest-pin-input" 218 + maxlength="6" inputmode="numeric" pattern="[0-9]*" placeholder="000000" autocomplete="off" /> 219 + ${this._guestError ? html`<div class="guest-pin-error">${this._guestError}</div>` : ""} 220 + <div class="guest-pin-actions"> 221 + <button @click=${() => { this._guestPeer = null; }}>Cancel</button> 222 + <button class="submit" @click=${() => this.submitGuestPin()}>Connect</button> 223 + </div> 224 + </div> 225 + </div> 226 + `; 227 + } 228 + 63 229 return html` 64 230 <div class="backdrop" @click=${this.handleBackdropClick}></div> 65 231 <menu> ··· 80 246 <span>Overview</span> 81 247 </li> 82 248 <li class="menu-separator"></li> 249 + ${this._discoveredPeers.map( 250 + (peer) => html` 251 + <li @click=${() => this.showGuestPinEntry(peer)}> 252 + <lucide-icon name="user-plus"></lucide-icon> 253 + <span>Guest: ${peer.displayName}</span> 254 + </li> 255 + `, 256 + )} 257 + ${this._discoveredPeers.length > 0 ? html`<li class="menu-separator"></li>` : ""} 83 258 <li @click=${() => this.handleItemClick("settings")}> 84 259 <lucide-icon name="settings"></lucide-icon> 85 260 <span>Settings</span>