Rewild Your Web
18
fork

Configure Feed

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

p2p: pairing flow

webbeef 4369d6a2 dbf3e2b4

+1278 -212
+2
Cargo.lock
··· 9111 9111 "log", 9112 9112 "parking_lot", 9113 9113 "petname", 9114 + "postcard", 9114 9115 "rand 0.9.2", 9115 9116 "rustc-hash 2.1.1", 9116 9117 "serde", 9118 + "serde_json", 9117 9119 "servo-background-hang-monitor", 9118 9120 "servo-background-hang-monitor-api", 9119 9121 "servo-base",
+74 -36
crates/beaver_p2p/src/lib.rs
··· 13 13 use iroh::address_lookup::mdns::MdnsAddressLookup; 14 14 use iroh::endpoint::{ClosedStream, ConnectError, ConnectionError, WriteError}; 15 15 use iroh::protocol::Router; 16 - use iroh::{Endpoint, EndpointId, RelayMode, SecretKey}; 16 + use iroh::{Endpoint, EndpointAddr, EndpointId, RelayMode, SecretKey}; 17 17 use log::{error, info}; 18 18 use n0_future::StreamExt; 19 + pub use packet::PacketError; 19 20 use packet::PostcardPacket; 20 21 use thiserror::Error; 21 22 use tokio::sync::Mutex; ··· 36 37 InvalidState, 37 38 #[error("Failure to receive command Ack")] 38 39 FailedAck, 39 - #[error("Pairing rejected")] 40 - Rejected, 41 40 #[error("Failed to connect")] 42 41 Connect(#[from] ConnectError), 43 42 #[error("Connection error")] ··· 48 47 ClosedStream(#[from] ClosedStream), 49 48 #[error("Postcard error")] 50 49 Postcard(#[from] postcard::Error), 50 + #[error("Packet error")] 51 + Packet(#[from] packet::PacketError), 52 + } 53 + 54 + /// The outcome of a pairing request. 55 + #[derive(Debug, Clone, PartialEq)] 56 + pub enum PairingResult { 57 + /// The remote peer accepted the pairing request. 58 + Accepted, 59 + /// The remote peer rejected the pairing request. 60 + Rejected, 51 61 } 52 62 53 63 #[derive(Debug, Error)] ··· 126 136 } 127 137 128 138 // Send a pairing request to an endpoint. 129 - pub async fn request_pairing(&self, id: &EndpointId) -> Result<(), PairingError> { 139 + pub async fn request_pairing(&self, id: &EndpointId) -> Result<PairingResult, PairingError> { 130 140 info!("Pairing with {id}"); 131 141 let Some(ref inner) = self.inner else { 132 142 error!("Not initialized"); ··· 153 163 } 154 164 } 155 165 166 + let result = self.do_pairing_handshake(inner, addr).await; 167 + 168 + // Always clean up the requested state, regardless of success or failure. 169 + let mut state = inner.state.lock().await; 170 + state.remove_pairing_requested(id); 171 + 172 + match &result { 173 + Ok(PairingResult::Accepted) => { 174 + state.notify(PeerEvent::PairingAccepted(*id)); 175 + state.set_status(id, EndpointStatus::PairedConnected); 176 + }, 177 + Ok(PairingResult::Rejected) => { 178 + state.notify(PeerEvent::PairingRejected(*id)); 179 + }, 180 + Err(_) => { 181 + state.notify(PeerEvent::PairingFailed(*id)); 182 + }, 183 + } 184 + 185 + result 186 + } 187 + 188 + /// Perform the pairing handshake over the wire. Separated from request_pairing 189 + /// so that cleanup always runs regardless of where an error occurs. 190 + async fn do_pairing_handshake( 191 + &self, 192 + inner: &PairingManagerInner, 193 + addr: EndpointAddr, 194 + ) -> Result<PairingResult, PairingError> { 156 195 let connection = inner.router.endpoint().connect(addr, PAIRING_ALPN).await?; 157 196 158 - // Send the the request. 197 + // Send the request. 159 198 let (mut sender, mut receiver) = connection.open_bi().await?; 160 199 161 - let command = PairingCommand::Request; 162 - PostcardPacket::send(command, &mut sender) 163 - .await 164 - .expect("Failed to send"); 200 + PostcardPacket::send(PairingCommand::Request, &mut sender).await?; 165 201 166 202 // Wait for accept or reject 167 - let command: PairingCommand = PostcardPacket::recv(&mut receiver) 168 - .await 169 - .expect("Failed to receive"); 203 + let command: PairingCommand = PostcardPacket::recv(&mut receiver).await?; 170 204 171 - let accepted = match command { 172 - PairingCommand::Accept => true, 173 - PairingCommand::Reject => false, 174 - PairingCommand::Request => { 175 - error!("Unexpected Request in response to a pairing request"); 176 - return Err(PairingError::InvalidState); 177 - }, 178 - PairingCommand::Ack => { 179 - error!("Unexpected Ack in response to a pairing request"); 205 + let result = match command { 206 + PairingCommand::Accept => PairingResult::Accepted, 207 + PairingCommand::Reject => PairingResult::Rejected, 208 + _ => { 209 + error!("Unexpected response to pairing request: {command:?}"); 180 210 return Err(PairingError::InvalidState); 181 211 }, 182 212 }; 183 213 184 214 // Send Ack 185 - PostcardPacket::send(PairingCommand::Ack, &mut sender) 186 - .await 187 - .expect("Failed to send Ack"); 215 + PostcardPacket::send(PairingCommand::Ack, &mut sender).await?; 188 216 189 - let mut state = inner.state.lock().await; 190 - state.remove_pairing_requested(id); 217 + // Finish the send stream and wait for it to be acknowledged by the remote, 218 + // ensuring the Ack is received before the connection is dropped. 191 219 sender.finish()?; 220 + let _ = sender.stopped().await; 192 221 193 - if accepted { 194 - state.notify(PeerEvent::PairingAccepted(*id)); 195 - state.set_status(id, EndpointStatus::PairedConnected); 196 - Ok(()) 197 - } else { 198 - state.remove_pairing_requested(id); 199 - state.notify(PeerEvent::PairingRejected(*id)); 200 - Err(PairingError::Rejected) 201 - } 222 + Ok(result) 202 223 } 203 224 204 225 // Common code for accept/reject of a pairing request ··· 252 273 let res = self 253 274 .send_pairing_response(from, PairingCommand::Accept) 254 275 .await; 255 - println!("accept_pairing ok 3"); 256 276 let mut state = inner.state.lock().await; 257 277 res.map(|_| { 258 278 // Add the endpoint to the set of pending acks if successfully sending. ··· 365 385 }; 366 386 367 387 inner.state.lock().await.set_status(endpoint, status); 388 + } 389 + 390 + /// Register a previously paired peer at startup. 391 + /// Creates the endpoint entry with PairedDisconnected status and an empty address. 392 + /// When the peer is discovered via mDNS, its address will be updated. 393 + pub async fn register_paired_peer(&self, id: &EndpointId, name: &str) { 394 + let Some(ref inner) = self.inner else { 395 + error!("Not initialized"); 396 + return; 397 + }; 398 + 399 + let addr = EndpointAddr { 400 + id: *id, 401 + addrs: std::collections::BTreeSet::new(), 402 + }; 403 + let proxy = 404 + crate::state::EndpointProxy::new(name, *id, addr, EndpointStatus::PairedDisconnected); 405 + inner.state.lock().await.add_endpoint(id, proxy); 368 406 } 369 407 }
+16 -5
crates/beaver_p2p/src/main.rs
··· 1 1 use std::sync::mpsc::channel; 2 2 3 - use beaver_p2p::{PairingManager, PeerEvent}; 3 + use beaver_p2p::{PairingManager, PairingResult, PeerEvent}; 4 4 use iroh::address_lookup::DiscoveryEvent; 5 5 use log::info; 6 + use serde::{Deserialize, Serialize}; 6 7 7 8 #[tokio::main] 8 9 async fn main() { ··· 29 30 endpoint_info.user_data() 30 31 ); 31 32 if start_pairing { 32 - let _ = manager.request_pairing(&endpoint_info.endpoint_id).await; 33 + match manager.request_pairing(&endpoint_info.endpoint_id).await { 34 + Ok(PairingResult::Accepted) => info!("Pairing accepted!"), 35 + Ok(PairingResult::Rejected) => info!("Pairing rejected."), 36 + Err(e) => info!("Pairing failed: {e}"), 37 + } 33 38 } 34 39 }, 35 40 PeerEvent::Discovery(DiscoveryEvent::Expired { endpoint_id }) => { 36 41 info!("MDNS expired: {endpoint_id}"); 37 42 }, 38 - PeerEvent::PairingRequest(endpoint_id) => { 39 - info!("Accepting pairing request from {endpoint_id}"); 43 + PeerEvent::PairingRequest(endpoint_id, name) => { 44 + info!("Accepting pairing request from {name} ({endpoint_id})"); 40 45 manager 41 46 .accept_pairing(&endpoint_id) 42 47 .await ··· 52 57 info!("Pairing failed with {endpoint_id}"); 53 58 }, 54 59 PeerEvent::Message(endpoint_id, payload) => { 55 - info!("Message of {} bytes from {}", payload.len(), endpoint_id); 60 + match postcard::from_bytes::<P2pMessage>(&payload) { 61 + Ok(msg) => info!("Message from {endpoint_id}: {msg:?}"), 62 + Err(e) => info!( 63 + "Raw message of {} bytes from {endpoint_id} (decode error: {e})", 64 + payload.len() 65 + ), 66 + } 56 67 }, 57 68 }, 58 69 Err(err) => {
+20 -7
crates/beaver_p2p/src/pairing_protocol.rs
··· 71 71 // Step 1. Receive a request, store the tokio channel sender and ack receiver in the state. 72 72 match command { 73 73 PairingCommand::Request => { 74 - self.state 75 - .lock() 76 - .await 77 - .notify(PeerEvent::PairingRequest(remote_id)); 74 + let state = self.state.lock().await; 75 + let name = state 76 + .by_id(&remote_id) 77 + .map(|ep| ep.name().to_owned()) 78 + .unwrap_or_default(); 79 + state.notify(PeerEvent::PairingRequest(remote_id, name)); 78 80 }, 79 81 _ => { 80 82 error!("Unexpected command: {command:?}"); ··· 104 106 .expect("Failed to send"); 105 107 106 108 // Step 3. Wait for the Ack from the other side. 107 - let command: PairingCommand = PostcardPacket::recv(&mut receiver) 108 - .await 109 - .expect("Failed to read"); 109 + let command: PairingCommand = match PostcardPacket::recv(&mut receiver).await { 110 + Ok(cmd) => cmd, 111 + Err(e) => { 112 + error!("Failed to read Ack: {e}"); 113 + self.state 114 + .lock() 115 + .await 116 + .notify(PeerEvent::PairingFailed(remote_id)); 117 + let _ = ack_sender.send(false).await; 118 + return Err(AcceptError::from(n0_error::AnyError::from(format!( 119 + "Failed to read Ack: {e}" 120 + )))); 121 + }, 122 + }; 110 123 111 124 match command { 112 125 PairingCommand::Ack => {
+26 -9
crates/beaver_p2p/src/state.rs
··· 50 50 self.addr.clone() 51 51 } 52 52 53 + pub(crate) fn name(&self) -> &str { 54 + &self.name 55 + } 56 + 53 57 pub(crate) fn is_paired(&self) -> bool { 54 58 self.status == EndpointStatus::PairedConnected || 55 59 self.status == EndpointStatus::PairedDisconnected ··· 61 65 #[derive(Debug)] 62 66 pub enum PeerEvent { 63 67 Discovery(DiscoveryEvent), 64 - PairingRequest(EndpointId), 68 + PairingRequest(EndpointId, String), 65 69 PairingAccepted(EndpointId), 66 70 PairingRejected(EndpointId), 67 71 PairingFailed(EndpointId), ··· 219 223 pub(crate) fn on_discovery(&mut self, event: &DiscoveryEvent) { 220 224 match event { 221 225 DiscoveryEvent::Discovered { endpoint_info, .. } => { 222 - // Ignore if we already know about this endpoint. 226 + // Ignore if we already know about this endpoint (except PairedDisconnected). 223 227 if self.discovered(&endpoint_info.endpoint_id) { 224 228 return; 225 229 } 226 230 231 + let name = endpoint_info 232 + .data 233 + .user_data() 234 + .map(|d| d.as_ref()) 235 + .unwrap_or_else(|| "<no name>"); 236 + let addr = endpoint_info.to_endpoint_addr(); 237 + 238 + // If this peer was previously paired and disconnected, reconnect it. 239 + if let Some(existing) = self.endpoints.get_mut(&endpoint_info.endpoint_id) { 240 + if existing.status == EndpointStatus::PairedDisconnected { 241 + existing.status = EndpointStatus::PairedConnected; 242 + existing.addr = addr; 243 + existing.name = name.to_owned(); 244 + self.notify(PeerEvent::Discovery(event.clone())); 245 + return; 246 + } 247 + } 248 + 227 249 // Add it as Discovered and notify the listener. 228 250 let description = EndpointProxy { 229 - name: endpoint_info 230 - .data 231 - .user_data() 232 - .map(|d| d.as_ref()) 233 - .unwrap_or_else(|| "<no name>") 234 - .into(), 251 + name: name.into(), 235 252 id: endpoint_info.endpoint_id, 236 - addr: endpoint_info.to_endpoint_addr(), 253 + addr, 237 254 status: EndpointStatus::Discovered, 238 255 message_sender: None, 239 256 };
+58 -7
crates/beaver_shell/src/main.rs
··· 66 66 } 67 67 } 68 68 69 + /// Load Servo preferences from `servo-prefs.json` in the config directory, 70 + /// merged with hardcoded defaults for beaver. 71 + fn load_servo_prefs(config_dir: Option<PathBuf>) -> Preferences { 72 + // Start with beaver's defaults. 73 + let mut preferences = Preferences { 74 + viewport_meta_enabled: true, 75 + devtools_server_enabled: true, 76 + devtools_server_listen_address: "0.0.0.0:6222".to_owned(), 77 + shell_background_color_rgba: [0.0, 0.0, 0.0, 0.0], 78 + ..Default::default() 79 + }; 80 + 81 + // Try to load saved preferences and merge on top. 82 + if let Some(path) = config_dir.map(|d| d.join("servo-prefs.json")) { 83 + if let Ok(content) = std::fs::read_to_string(&path) { 84 + match serde_json::from_str::<Preferences>(&content) { 85 + Ok(saved) => { 86 + preferences = saved; 87 + info!("Loaded Servo preferences from {}", path.display()); 88 + }, 89 + Err(e) => { 90 + warn!("Failed to parse servo-prefs.json: {e}"); 91 + }, 92 + } 93 + } 94 + } 95 + 96 + preferences 97 + } 98 + 99 + /// Observer that persists Servo preferences to disk whenever they change. 100 + struct ServoPrefsPersister { 101 + path: PathBuf, 102 + } 103 + 104 + impl servo::prefs::PreferencesObserver for ServoPrefsPersister { 105 + fn prefs_changed(&self, _changes: &[(&'static str, servo::PrefValue)]) { 106 + let prefs = servo::prefs::get().clone(); 107 + match serde_json::to_string_pretty(&prefs) { 108 + Ok(json) => { 109 + if let Some(dir) = self.path.parent() { 110 + let _ = std::fs::create_dir_all(dir); 111 + } 112 + if let Err(e) = std::fs::write(&self.path, &json) { 113 + error!("Failed to save Servo preferences: {e}"); 114 + } 115 + }, 116 + Err(e) => error!("Failed to serialize Servo preferences: {e}"), 117 + } 118 + } 119 + } 120 + 69 121 fn main() -> Result<(), Box<dyn Error>> { 70 122 rustls::crypto::aws_lc_rs::default_provider() 71 123 .install_default() ··· 691 743 ..Default::default() 692 744 }; 693 745 694 - let preferences = Preferences { 695 - viewport_meta_enabled: true, 696 - devtools_server_enabled: true, 697 - devtools_server_listen_address: "0.0.0.0:6222".to_owned(), 698 - shell_background_color_rgba: [0.0, 0.0, 0.0, 0.0], 699 - ..Default::default() 700 - }; 746 + let preferences = load_servo_prefs(config_dir()); 701 747 702 748 let servo = ServoBuilder::default() 703 749 .opts(opts) ··· 705 751 .protocol_registry(protocol_registry) 706 752 .event_loop_waker(Box::new(waker.clone())) 707 753 .build(); 754 + 755 + // Register observer to persist Servo preferences on change. 756 + if let Some(path) = config_dir().map(|d| d.join("servo-prefs.json")) { 757 + servo::prefs::add_observer(Box::new(ServoPrefsPersister { path })); 758 + } 708 759 709 760 vhost::start_vhost(8888); 710 761
+11 -2
patches/components/constellation/Cargo.toml.patch
··· 17 17 keyboard-types = { workspace = true } 18 18 layout_api = { workspace = true } 19 19 log = { workspace = true } 20 - @@ -47,6 +50,7 @@ 20 + @@ -47,6 +50,8 @@ 21 21 net_traits = { workspace = true } 22 22 paint_api = { workspace = true } 23 23 parking_lot = { workspace = true } 24 24 +petname = "2.0" 25 + +postcard = "1.1" 25 26 profile = { package = "servo-profile", path = "../profile" } 26 27 profile_traits = { workspace = true } 27 28 rand = { workspace = true } 28 - @@ -59,6 +63,7 @@ 29 + @@ -53,6 +58,7 @@ 30 + rustc-hash = { workspace = true } 31 + script_traits = { workspace = true } 32 + serde = { workspace = true } 33 + +serde_json = { workspace = true } 34 + servo-tracing = { workspace = true } 35 + servo_config = { package = "servo-config", path = "../config" } 36 + servo_url = { package = "servo-url", path = "../url" } 37 + @@ -59,6 +65,7 @@ 29 38 storage_traits = { workspace = true } 30 39 stylo = { workspace = true } 31 40 stylo_traits = { workspace = true }
+24
patches/components/constellation/broadcastchannel.rs.patch
··· 1 + --- original 2 + +++ modified 3 + @@ -127,4 +127,21 @@ 4 + ); 5 + } 6 + } 7 + + 8 + + /// Broadcast a message to ALL local routers for the given origin and channel name, 9 + + /// without excluding any sender. Used for messages received from remote P2P peers. 10 + + pub fn broadcast_to_all(&self, message: &BroadcastChannelMsg) { 11 + + if let Some(channels) = self.channels.get(&message.origin) { 12 + + let Some(routers) = channels.get(&message.channel_name) else { 13 + + return; 14 + + }; 15 + + for router in routers { 16 + + if let Some(broadcast_ipc_sender) = self.routers.get(router) { 17 + + if broadcast_ipc_sender.send(message.clone()).is_err() { 18 + + warn!("Failed to broadcast remote message to router: {:?}", router); 19 + + } 20 + + } 21 + + } 22 + + } 23 + + } 24 + }
+128 -23
patches/components/constellation/constellation.rs.patch
··· 27 27 }; 28 28 use euclid::Size2D; 29 29 use euclid::default::Size2D as UntypedSize2D; 30 - @@ -510,6 +511,22 @@ 30 + @@ -183,6 +184,7 @@ 31 + }; 32 + use crate::constellation_webview::ConstellationWebView; 33 + use crate::event_loop::EventLoop; 34 + +use crate::pairing; 35 + use crate::pipeline::Pipeline; 36 + use crate::process_manager::ProcessManager; 37 + use crate::serviceworker::ServiceWorkerUnprivilegedContent; 38 + @@ -510,6 +512,22 @@ 31 39 32 40 /// Whether accessibility trees are being built and sent to the underlying platform. 33 41 pub(crate) accessibility_active: bool, ··· 46 54 + embedder_error_listeners: FxHashSet<ScriptEventLoopId>, 47 55 + 48 56 + /// The P2P pairing service. 49 - + pairing: crate::pairing::PairingService, 57 + + pairing: pairing::PairingService, 50 58 } 51 59 52 60 /// State needed to construct a constellation. 53 - @@ -729,6 +746,10 @@ 61 + @@ -729,6 +747,10 @@ 54 62 screenshot_readiness_requests: Vec::new(), 55 63 user_contents_for_manager_id: Default::default(), 56 64 accessibility_active: false, 57 65 + embedded_webview_to_iframe: FxHashMap::default(), 58 66 + active_ime_webview: None, 59 67 + embedder_error_listeners: Default::default(), 60 - + pairing: crate::pairing::PairingService::new(), 68 + + pairing: pairing::PairingService::new(), 61 69 }; 62 70 63 71 constellation.run(); 64 - @@ -754,6 +775,18 @@ 72 + @@ -754,6 +776,18 @@ 65 73 fn clean_up_finished_script_event_loops(&mut self) { 66 74 self.event_loop_join_handles 67 75 .retain(|join_handle| !join_handle.is_finished()); ··· 80 88 self.event_loops 81 89 .retain(|event_loop| event_loop.upgrade().is_some()); 82 90 } 83 - @@ -1045,6 +1078,11 @@ 91 + @@ -1045,6 +1079,11 @@ 84 92 .get(&webview_id) 85 93 .and_then(|webview| webview.user_content_manager_id); 86 94 ··· 92 100 let new_pipeline_info = NewPipelineInfo { 93 101 parent_info: parent_pipeline_id, 94 102 new_pipeline_id, 95 - @@ -1055,6 +1093,13 @@ 103 + @@ -1055,6 +1094,13 @@ 96 104 viewport_details: initial_viewport_details, 97 105 user_content_manager_id, 98 106 theme, ··· 106 114 }; 107 115 let pipeline = match Pipeline::spawn(new_pipeline_info, event_loop, self, throttled) { 108 116 Ok(pipeline) => pipeline, 109 - @@ -1229,6 +1274,7 @@ 117 + @@ -1229,6 +1275,7 @@ 110 118 BackgroundHangMonitor(HangMonitorAlert), 111 119 Embedder(EmbedderToConstellationMessage), 112 120 FromSWManager(SWManagerMsg), ··· 114 122 RemoveProcess(usize), 115 123 } 116 124 // Get one incoming request. 117 - @@ -1249,6 +1295,15 @@ 125 + @@ -1249,6 +1296,15 @@ 118 126 sel.recv(&self.embedder_to_constellation_receiver); 119 127 sel.recv(&self.swmanager_receiver); 120 128 ··· 130 138 self.process_manager.register(&mut sel); 131 139 132 140 let request = { 133 - @@ -1277,9 +1332,13 @@ 141 + @@ -1277,9 +1333,13 @@ 134 142 .recv(&self.swmanager_receiver) 135 143 .expect("Unexpected SW channel panic in constellation") 136 144 .map(Request::FromSWManager), ··· 145 153 let _ = oper.recv(self.process_manager.receiver_at(process_index)); 146 154 Ok(Request::RemoveProcess(process_index)) 147 155 }, 148 - @@ -1305,6 +1364,9 @@ 156 + @@ -1305,6 +1365,9 @@ 149 157 Request::FromSWManager(message) => { 150 158 self.handle_request_from_swmanager(message); 151 159 }, ··· 155 163 Request::RemoveProcess(index) => self.process_manager.remove(index), 156 164 } 157 165 } 158 - @@ -1534,11 +1596,7 @@ 166 + @@ -1534,11 +1597,7 @@ 159 167 } 160 168 }, 161 169 EmbedderToConstellationMessage::PreferencesUpdated(updates) => { ··· 168 176 let _ = event_loop.send(ScriptThreadMessage::PreferencesUpdated( 169 177 updates 170 178 .iter() 171 - @@ -1565,6 +1623,18 @@ 179 + @@ -1565,6 +1624,18 @@ 172 180 EmbedderToConstellationMessage::SetAccessibilityActive(active) => { 173 181 self.set_accessibility_active(active); 174 182 }, ··· 187 195 } 188 196 } 189 197 190 - @@ -1788,6 +1858,12 @@ 198 + @@ -1762,7 +1833,13 @@ 199 + return warn!("Attempt to add channel name from an unexpected origin."); 200 + } 201 + self.broadcast_channels 202 + - .new_broadcast_channel_name_in_router(router_id, channel_name, origin); 203 + + .new_broadcast_channel_name_in_router( 204 + + router_id, 205 + + channel_name.clone(), 206 + + origin.clone(), 207 + + ); 208 + + self.pairing 209 + + .on_broadcast_channel_open(&origin.ascii_serialization(), &channel_name); 210 + }, 211 + ScriptToConstellationMessage::RemoveBroadcastChannelNameInRouter( 212 + router_id, 213 + @@ -1776,7 +1853,13 @@ 214 + return warn!("Attempt to remove channel name from an unexpected origin."); 215 + } 216 + self.broadcast_channels 217 + - .remove_broadcast_channel_name_in_router(router_id, channel_name, origin); 218 + + .remove_broadcast_channel_name_in_router( 219 + + router_id, 220 + + channel_name.clone(), 221 + + origin.clone(), 222 + + ); 223 + + self.pairing 224 + + .on_broadcast_channel_close(&origin.ascii_serialization(), &channel_name); 225 + }, 226 + ScriptToConstellationMessage::RemoveBroadcastChannelRouter(router_id, origin) => { 227 + if self 228 + @@ -1788,6 +1871,12 @@ 191 229 self.broadcast_channels 192 230 .remove_broadcast_channel_router(router_id); 193 231 }, ··· 200 238 ScriptToConstellationMessage::ScheduleBroadcast(router_id, message) => { 201 239 if self 202 240 .check_origin_against_pipeline(&source_pipeline_id, &message.origin) 203 - @@ -1818,6 +1894,12 @@ 241 + @@ -1797,8 +1886,15 @@ 242 + "Attempt to schedule broadcast from an origin not matching the origin of the msg." 243 + ); 244 + } 245 + + // Forward to local routers. 246 + self.broadcast_channels 247 + - .schedule_broadcast(router_id, message); 248 + + .schedule_broadcast(router_id, message.clone()); 249 + + // Forward to paired peers that have this channel active. 250 + + self.pairing.on_local_broadcast( 251 + + &message.origin.ascii_serialization(), 252 + + &message.channel_name, 253 + + &message.data.serialized, 254 + + ); 255 + }, 256 + ScriptToConstellationMessage::PipelineExited => { 257 + self.handle_pipeline_exited(source_pipeline_id); 258 + @@ -1818,6 +1914,12 @@ 204 259 ScriptToConstellationMessage::CreateAuxiliaryWebView(load_info) => { 205 260 self.handle_script_new_auxiliary(load_info); 206 261 }, ··· 213 268 ScriptToConstellationMessage::ChangeRunningAnimationsState(animation_state) => { 214 269 self.handle_change_running_animations_state(source_pipeline_id, animation_state) 215 270 }, 216 - @@ -1984,6 +2066,23 @@ 271 + @@ -1984,6 +2086,29 @@ 217 272 new_value, 218 273 ); 219 274 }, ··· 232 287 + if name.contains('.') { 233 288 + self.embedder_proxy 234 289 + .send(EmbedderMsg::EmbedderPreferenceChanged(name, value)); 290 + + } else { 291 + + // For core Servo preferences, update the main process's prefs 292 + + // so that observers (e.g. preference persistence) see the change. 293 + + let mut current_prefs = servo_config::prefs::get().clone(); 294 + + current_prefs.set_value(&name, value); 295 + + servo_config::prefs::set(current_prefs); 235 296 + } 236 297 + }, 237 298 ScriptToConstellationMessage::MediaSessionEvent(pipeline_id, event) => { 238 299 // Unlikely at this point, but we may receive events coming from 239 300 // different media sessions, so we set the active media session based 240 - @@ -2057,9 +2156,155 @@ 301 + @@ -2057,9 +2182,199 @@ 241 302 let _ = event_loop.send(ScriptThreadMessage::TriggerGarbageCollection); 242 303 } 243 304 }, ··· 377 438 + self.pairing.get_peers(callback); 378 439 + }, 379 440 + ScriptToConstellationMessage::PairingSetName(name, callback) => { 380 - + self.pairing.set_name(name, callback); 441 + + self.pairing.set_name(&name, callback); 442 + + }, 443 + + ScriptToConstellationMessage::PairingRequestPairing(id, callback) => { 444 + + self.pairing.request_pairing(&id, callback); 445 + + }, 446 + + ScriptToConstellationMessage::PairingAcceptPairing(id, callback) => { 447 + + self.pairing.accept_pairing(&id, callback); 448 + + }, 449 + + ScriptToConstellationMessage::PairingRejectPairing(id, callback) => { 450 + + self.pairing.reject_pairing(&id, callback); 381 451 + }, 382 452 } 383 453 } 384 454 385 455 + fn handle_pairing_event(&self, event: constellation_traits::PairingEvent) { 456 + + // Handle incoming P2P messages. 457 + + if let constellation_traits::PairingEvent::MessageReceived { ref from, ref data } = event { 458 + + if let Some((_from, message)) = self.pairing.handle_incoming_message(from, data) { 459 + + // Dispatch BroadcastChannelMessage locally. 460 + + if let pairing::P2pMessage::BroadcastChannelMessage { 461 + + ref origin, 462 + + ref name, 463 + + data: ref serialized, 464 + + } = message 465 + + { 466 + + info!("Remote broadcast from {from}: {origin} / {name}"); 467 + + let Some(origin) = servo_url::ServoUrl::parse(origin).ok().map(|u| u.origin()) 468 + + else { 469 + + warn!("Failed to parse origin: {origin}"); 470 + + return; 471 + + }; 472 + + let msg = constellation_traits::BroadcastChannelMsg { 473 + + origin, 474 + + channel_name: name.clone(), 475 + + data: constellation_traits::StructuredSerializedData { 476 + + serialized: serialized.clone(), 477 + + ..Default::default() 478 + + }, 479 + + }; 480 + + self.broadcast_channels.broadcast_to_all(&msg); 481 + + } 482 + + } 483 + + return; 484 + + } 485 + + 486 + + // Handle peer disconnect: clean up remote channel state. 487 + + if let constellation_traits::PairingEvent::PeerExpired { ref id } = event { 488 + + self.pairing.clear_remote_peer(id); 489 + + } 490 + + 386 491 + for event_loop in self.event_loops() { 387 492 + if self.embedder_error_listeners.contains(&event_loop.id()) { 388 493 + let _ = event_loop.send(ScriptThreadMessage::DispatchPairingEvent(event.clone())); ··· 393 498 /// Check the origin of a message against that of the pipeline it came from. 394 499 /// Note: this is still limited as a security check, 395 500 /// see <https://github.com/servo/servo/issues/11722> 396 - @@ -3178,6 +3423,13 @@ 501 + @@ -3178,6 +3493,13 @@ 397 502 /// <https://html.spec.whatwg.org/multipage/#destroy-a-top-level-traversable> 398 503 fn handle_close_top_level_browsing_context(&mut self, webview_id: WebViewId) { 399 504 debug!("{webview_id}: Closing"); ··· 407 512 let browsing_context_id = BrowsingContextId::from(webview_id); 408 513 // Step 5. Remove traversable from the user agent's top-level traversable set. 409 514 let browsing_context = 410 - @@ -3454,8 +3706,27 @@ 515 + @@ -3454,8 +3776,27 @@ 411 516 opener_webview_id, 412 517 opener_pipeline_id, 413 518 response_sender, ··· 435 540 let Some((webview_id_sender, webview_id_receiver)) = generic_channel::channel() else { 436 541 warn!("Failed to create channel"); 437 542 let _ = response_sender.send(None); 438 - @@ -3554,6 +3825,361 @@ 543 + @@ -3554,6 +3895,361 @@ 439 544 }); 440 545 } 441 546 ··· 797 902 #[servo_tracing::instrument(skip_all)] 798 903 fn handle_refresh_cursor(&self, pipeline_id: PipelineId) { 799 904 let Some(pipeline) = self.pipelines.get(&pipeline_id) else { 800 - @@ -4680,7 +5306,7 @@ 905 + @@ -4680,7 +5376,7 @@ 801 906 } 802 907 803 908 #[servo_tracing::instrument(skip_all)] ··· 806 911 // Send a flat projection of the history to embedder. 807 912 // The final vector is a concatenation of the URLs of the past 808 913 // entries, the current entry and the future entries. 809 - @@ -4783,9 +5409,23 @@ 914 + @@ -4783,9 +5479,23 @@ 810 915 ); 811 916 self.embedder_proxy.send(EmbedderMsg::HistoryChanged( 812 917 webview_id,
+448 -41
patches/components/constellation/pairing.rs.patch
··· 1 1 --- original 2 2 +++ modified 3 - @@ -0,0 +1,255 @@ 3 + @@ -0,0 +1,662 @@ 4 4 +// SPDX-License-Identifier: AGPL-3.0-or-later 5 5 + 6 6 +//! P2P pairing service integration with the constellation. ··· 9 9 +//! and bridges its async events into the constellation's crossbeam-based event loop. 10 10 +//! The endpoint's secret key and display name are persisted to the config directory. 11 11 + 12 + +use std::collections::{HashMap, HashSet}; 12 13 +use std::path::PathBuf; 13 14 +use std::sync::Arc; 14 15 + 15 16 +use base::generic_channel::GenericCallback; 16 17 +use beaver_p2p::{EndpointStatus, PairingManager, PeerEvent}; 17 18 +use constellation_traits::{LocalPeerInfo, PairingEvent, PeerInfo, PeerStatus}; 19 + +use iroh::EndpointId; 18 20 +use iroh::address_lookup::DiscoveryEvent; 19 - +use log::{info, warn}; 21 + +use log::{error, info, warn}; 22 + +use serde::{Deserialize, Serialize}; 20 23 +use tokio::sync::Mutex; 21 24 + 25 + +/// Typed messages exchanged between paired P2P endpoints. 26 + +/// Serialized with postcard for wire transmission. 27 + +#[derive(Debug, Clone, Serialize, Deserialize)] 28 + +pub(crate) enum P2pMessage { 29 + + /// Notify peers that a broadcast channel was opened. 30 + + BroadcastChannelOpen { origin: String, name: String }, 31 + + /// Notify peers that a broadcast channel was closed. 32 + + BroadcastChannelClose { origin: String, name: String }, 33 + + /// Forward a broadcast channel message. 34 + + BroadcastChannelMessage { 35 + + origin: String, 36 + + name: String, 37 + + data: Vec<u8>, 38 + + }, 39 + +} 40 + + 41 + +impl P2pMessage { 42 + + pub(crate) fn to_bytes(&self) -> Result<Vec<u8>, postcard::Error> { 43 + + postcard::to_allocvec(self) 44 + + } 45 + + 46 + + pub(crate) fn from_bytes(data: &[u8]) -> Result<Self, postcard::Error> { 47 + + postcard::from_bytes(data) 48 + + } 49 + +} 50 + + 22 51 +pub(crate) struct PairingService { 23 52 + manager: Arc<Mutex<Option<PairingManager>>>, 24 53 + local_info: Arc<Mutex<Option<LocalPeerInfo>>>, 25 54 + event_receiver: Option<crossbeam_channel::Receiver<PairingEvent>>, 55 + + /// Remote broadcast channels announced by each paired peer. 56 + + /// Maps peer_id → set of (origin, channel_name). 57 + + remote_channels: Arc<Mutex<HashMap<String, HashSet<(String, String)>>>>, 26 58 +} 27 59 + 28 60 +impl PairingService { ··· 31 63 + manager: Default::default(), 32 64 + local_info: Default::default(), 33 65 + event_receiver: None, 66 + + remote_channels: Default::default(), 34 67 + } 35 68 + } 36 69 + ··· 60 93 + // Load or generate the persistent secret key. 61 94 + let key = load_or_create_key(config_dir.as_ref()).await; 62 95 + 63 - + // Load or generate the persistent display name. 64 - + let name = load_or_create_name(config_dir.as_ref()).await; 96 + + // Load config (display name + paired peers). 97 + + let mut config = load_config(config_dir.as_ref()).await; 98 + + if config.name.is_empty() { 99 + + config.name = generate_name(); 100 + + save_config(config_dir.as_ref(), &config).await; 101 + + } 102 + + let name = config.name.clone(); 65 103 + info!("Pairing service starting as \"{name}\""); 66 104 + 67 105 + // Store local endpoint info (id is derived from the secret key). ··· 72 110 + 73 111 + let (event_sender, event_receiver) = std::sync::mpsc::channel(); 74 112 + let new_manager = PairingManager::create(event_sender, &name, key).await; 113 + + 114 + + // Load and register previously paired peers. 115 + + for peer in &config.paired_peers { 116 + + if let Ok(endpoint_id) = peer.id.parse() { 117 + + new_manager 118 + + .register_paired_peer(&endpoint_id, &peer.name) 119 + + .await; 120 + + info!("Registered paired peer: {} ({})", peer.name, peer.id); 121 + + } 122 + + } 123 + + 75 124 + *guard = Some(new_manager); 76 125 + 77 126 + // Bridge: forward PeerEvents to the crossbeam channel as PairingEvents. ··· 157 206 + 158 207 + /// Update the persisted display name. The caller should stop and restart 159 208 + /// the service for the new name to take effect on mDNS. 160 - + pub(crate) fn set_name(&self, name: String, callback: GenericCallback<Result<(), String>>) { 209 + + pub(crate) fn set_name(&self, name: &str, callback: GenericCallback<Result<(), String>>) { 161 210 + let local_info = self.local_info.clone(); 211 + + let name = name.to_owned(); 162 212 + net::async_runtime::spawn_task(async move { 163 213 + let config_dir = servo_config::opts::get().config_dir.clone(); 164 - + if let Some(dir) = config_dir.as_ref() { 165 - + let name_path = dir.join("pairing-name.txt"); 166 - + if let Err(e) = tokio::fs::create_dir_all(dir).await { 167 - + let _ = callback.send(Err(format!("Failed to create config dir: {e}"))); 168 - + return; 169 - + } 170 - + if let Err(e) = tokio::fs::write(&name_path, &name).await { 171 - + let _ = callback.send(Err(format!("Failed to write name: {e}"))); 172 - + return; 173 - + } 174 - + } 214 + + let mut config = load_config(config_dir.as_ref()).await; 215 + + config.name = name.clone(); 216 + + save_config(config_dir.as_ref(), &config).await; 217 + + 175 218 + // Update the cached local info if the service is running. 176 219 + if let Some(info) = local_info.lock().await.as_mut() { 177 220 + info.name = name; ··· 179 222 + let _ = callback.send(Ok(())); 180 223 + }); 181 224 + } 225 + + 226 + + /// Request pairing with a remote peer. Returns true if accepted, false if rejected. 227 + + pub(crate) fn request_pairing( 228 + + &self, 229 + + id: &str, 230 + + callback: GenericCallback<Result<bool, String>>, 231 + + ) { 232 + + let endpoint_id: EndpointId = match id.parse() { 233 + + Ok(id) => id, 234 + + Err(e) => { 235 + + let _ = callback.send(Err(format!("Invalid endpoint ID: {e}"))); 236 + + return; 237 + + }, 238 + + }; 239 + + 240 + + let id = id.to_owned(); 241 + + let manager = self.manager.clone(); 242 + + net::async_runtime::spawn_task(async move { 243 + + let mgr = { 244 + + let guard = manager.lock().await; 245 + + match guard.as_ref() { 246 + + Some(mgr) => mgr.clone(), 247 + + None => { 248 + + let _ = callback.send(Err("Pairing service not started".to_owned())); 249 + + return; 250 + + }, 251 + + } 252 + + }; 253 + + match mgr.request_pairing(&endpoint_id).await { 254 + + Ok(beaver_p2p::PairingResult::Accepted) => { 255 + + // Look up the peer name and persist. 256 + + let peer_name = mgr 257 + + .peers() 258 + + .await 259 + + .into_iter() 260 + + .find(|p| p.id == endpoint_id) 261 + + .map(|p| p.name) 262 + + .unwrap_or_default(); 263 + + let config_dir = servo_config::opts::get().config_dir.clone(); 264 + + let mut config = load_config(config_dir.as_ref()).await; 265 + + add_paired_peer(&mut config, &id, &peer_name); 266 + + save_config(config_dir.as_ref(), &config).await; 267 + + let _ = callback.send(Ok(true)); 268 + + }, 269 + + Ok(beaver_p2p::PairingResult::Rejected) => { 270 + + let _ = callback.send(Ok(false)); 271 + + }, 272 + + Err(e) => { 273 + + let _ = callback.send(Err(format!("Pairing failed: {e}"))); 274 + + }, 275 + + } 276 + + }); 277 + + } 278 + + 279 + + /// Accept an incoming pairing request from a remote peer. 280 + + pub(crate) fn accept_pairing(&self, id: &str, callback: GenericCallback<Result<(), String>>) { 281 + + let endpoint_id: EndpointId = match id.parse() { 282 + + Ok(id) => id, 283 + + Err(e) => { 284 + + let _ = callback.send(Err(format!("Invalid endpoint ID: {e}"))); 285 + + return; 286 + + }, 287 + + }; 288 + + let manager = self.manager.clone(); 289 + + let id = id.to_owned(); 290 + + net::async_runtime::spawn_task(async move { 291 + + let mgr = { 292 + + let guard = manager.lock().await; 293 + + match guard.as_ref() { 294 + + Some(mgr) => mgr.clone(), 295 + + None => { 296 + + let _ = callback.send(Err("Pairing service not started".to_owned())); 297 + + return; 298 + + }, 299 + + } 300 + + }; 301 + + match mgr.accept_pairing(&endpoint_id).await { 302 + + Ok(()) => { 303 + + // Look up the peer name and persist. 304 + + let peer_name = mgr 305 + + .peers() 306 + + .await 307 + + .into_iter() 308 + + .find(|p| p.id == endpoint_id) 309 + + .map(|p| p.name) 310 + + .unwrap_or_default(); 311 + + let config_dir = servo_config::opts::get().config_dir.clone(); 312 + + let mut config = load_config(config_dir.as_ref()).await; 313 + + add_paired_peer(&mut config, &id, &peer_name); 314 + + save_config(config_dir.as_ref(), &config).await; 315 + + let _ = callback.send(Ok(())); 316 + + }, 317 + + Err(e) => { 318 + + let _ = callback.send(Err(format!("Accept pairing failed: {e}"))); 319 + + }, 320 + + } 321 + + }); 322 + + } 323 + + 324 + + /// Reject an incoming pairing request from a remote peer. 325 + + pub(crate) fn reject_pairing(&self, id: &str, callback: GenericCallback<Result<(), String>>) { 326 + + let endpoint_id: EndpointId = match id.parse() { 327 + + Ok(id) => id, 328 + + Err(e) => { 329 + + let _ = callback.send(Err(format!("Invalid endpoint ID: {e}"))); 330 + + return; 331 + + }, 332 + + }; 333 + + let manager = self.manager.clone(); 334 + + net::async_runtime::spawn_task(async move { 335 + + let mgr = { 336 + + let guard = manager.lock().await; 337 + + match guard.as_ref() { 338 + + Some(mgr) => mgr.clone(), 339 + + None => { 340 + + let _ = callback.send(Err("Pairing service not started".to_owned())); 341 + + return; 342 + + }, 343 + + } 344 + + }; 345 + + match mgr.reject_pairing(&endpoint_id).await { 346 + + Ok(()) => { 347 + + let _ = callback.send(Ok(())); 348 + + }, 349 + + Err(e) => { 350 + + let _ = callback.send(Err(format!("Reject pairing failed: {e}"))); 351 + + }, 352 + + } 353 + + }); 354 + + } 355 + + 356 + + /// Send a typed message to a specific paired peer. 357 + + pub(crate) fn send_message(&self, peer_id: &str, message: &P2pMessage) { 358 + + let bytes = match message.to_bytes() { 359 + + Ok(b) => b, 360 + + Err(e) => { 361 + + error!("Failed to serialize P2pMessage: {e}"); 362 + + return; 363 + + }, 364 + + }; 365 + + let endpoint_id: EndpointId = match peer_id.parse() { 366 + + Ok(id) => id, 367 + + Err(e) => { 368 + + error!("Invalid endpoint ID for message: {e}"); 369 + + return; 370 + + }, 371 + + }; 372 + + let manager = self.manager.clone(); 373 + + net::async_runtime::spawn_task(async move { 374 + + let mgr = { 375 + + let guard = manager.lock().await; 376 + + match guard.as_ref() { 377 + + Some(mgr) => mgr.clone(), 378 + + None => { 379 + + error!("Cannot send message: pairing service not started"); 380 + + return; 381 + + }, 382 + + } 383 + + }; 384 + + if let Err(e) = mgr.send_message(&endpoint_id, &bytes).await { 385 + + error!("Failed to send message to {endpoint_id}: {e}"); 386 + + } 387 + + }); 388 + + } 389 + + 390 + + /// Broadcast a typed message to all paired and connected peers. 391 + + pub(crate) fn broadcast_message(&self, message: &P2pMessage) { 392 + + let bytes = match message.to_bytes() { 393 + + Ok(b) => b, 394 + + Err(e) => { 395 + + error!("Failed to serialize P2pMessage: {e}"); 396 + + return; 397 + + }, 398 + + }; 399 + + let manager = self.manager.clone(); 400 + + net::async_runtime::spawn_task(async move { 401 + + let mgr = { 402 + + let guard = manager.lock().await; 403 + + match guard.as_ref() { 404 + + Some(mgr) => mgr.clone(), 405 + + None => { 406 + + error!("Cannot broadcast: pairing service not started"); 407 + + return; 408 + + }, 409 + + } 410 + + }; 411 + + let peers = mgr.peers().await; 412 + + for peer in peers { 413 + + if peer.status == beaver_p2p::EndpointStatus::PairedConnected { 414 + + if let Err(e) = mgr.send_message(&peer.id, &bytes).await { 415 + + error!("Failed to send broadcast to {}: {e}", peer.id); 416 + + } 417 + + } 418 + + } 419 + + }); 420 + + } 421 + + 422 + + /// Notify all paired peers that a broadcast channel was opened locally. 423 + + pub(crate) fn on_broadcast_channel_open(&self, origin: &str, name: &str) { 424 + + self.broadcast_message(&P2pMessage::BroadcastChannelOpen { 425 + + origin: origin.to_owned(), 426 + + name: name.to_owned(), 427 + + }); 428 + + } 429 + + 430 + + /// Notify all paired peers that a broadcast channel was closed locally. 431 + + pub(crate) fn on_broadcast_channel_close(&self, origin: &str, name: &str) { 432 + + self.broadcast_message(&P2pMessage::BroadcastChannelClose { 433 + + origin: origin.to_owned(), 434 + + name: name.to_owned(), 435 + + }); 436 + + } 437 + + 438 + + /// Forward a local broadcast to paired peers that have the same channel active. 439 + + pub(crate) fn on_local_broadcast(&self, origin: &str, name: &str, data: &[u8]) { 440 + + let msg = P2pMessage::BroadcastChannelMessage { 441 + + origin: origin.to_owned(), 442 + + name: name.to_owned(), 443 + + data: data.to_vec(), 444 + + }; 445 + + let bytes = match msg.to_bytes() { 446 + + Ok(b) => b, 447 + + Err(e) => { 448 + + error!("Failed to serialize BroadcastChannelMessage: {e}"); 449 + + return; 450 + + }, 451 + + }; 452 + + 453 + + let remote_channels = self.remote_channels.clone(); 454 + + let origin = origin.to_owned(); 455 + + let name = name.to_owned(); 456 + + let manager = self.manager.clone(); 457 + + 458 + + net::async_runtime::spawn_task(async move { 459 + + let mgr = { 460 + + let guard = manager.lock().await; 461 + + match guard.as_ref() { 462 + + Some(mgr) => mgr.clone(), 463 + + None => return, 464 + + } 465 + + }; 466 + + let channels = remote_channels.lock().await; 467 + + let key = (origin, name); 468 + + for (peer_id, peer_channels) in channels.iter() { 469 + + if peer_channels.contains(&key) { 470 + + if let Ok(endpoint_id) = peer_id.parse() { 471 + + if let Err(e) = mgr.send_message(&endpoint_id, &bytes).await { 472 + + error!("Failed to forward broadcast to {peer_id}: {e}"); 473 + + } 474 + + } 475 + + } 476 + + } 477 + + }); 478 + + } 479 + + 480 + + /// Handle an incoming P2P message from a remote peer. 481 + + /// Returns a `P2pMessage` if it needs further processing by the constellation (e.g. BroadcastChannelMessage). 482 + + pub(crate) fn handle_incoming_message( 483 + + &self, 484 + + from: &str, 485 + + data: &[u8], 486 + + ) -> Option<(String, P2pMessage)> { 487 + + let message = match P2pMessage::from_bytes(data) { 488 + + Ok(msg) => msg, 489 + + Err(e) => { 490 + + error!("Failed to deserialize P2pMessage from {from}: {e}"); 491 + + return None; 492 + + }, 493 + + }; 494 + + 495 + + match &message { 496 + + P2pMessage::BroadcastChannelOpen { origin, name } => { 497 + + info!("Remote channel open from {from}: {origin} / {name}"); 498 + + let remote_channels = self.remote_channels.clone(); 499 + + let from = from.to_owned(); 500 + + let origin = origin.clone(); 501 + + let name = name.clone(); 502 + + net::async_runtime::spawn_task(async move { 503 + + remote_channels 504 + + .lock() 505 + + .await 506 + + .entry(from) 507 + + .or_default() 508 + + .insert((origin, name)); 509 + + }); 510 + + None 511 + + }, 512 + + P2pMessage::BroadcastChannelClose { origin, name } => { 513 + + info!("Remote channel close from {from}: {origin} / {name}"); 514 + + let remote_channels = self.remote_channels.clone(); 515 + + let from = from.to_owned(); 516 + + let origin = origin.clone(); 517 + + let name = name.clone(); 518 + + net::async_runtime::spawn_task(async move { 519 + + let mut channels = remote_channels.lock().await; 520 + + if let Some(peer_channels) = channels.get_mut(&from) { 521 + + peer_channels.remove(&(origin, name)); 522 + + if peer_channels.is_empty() { 523 + + channels.remove(&from); 524 + + } 525 + + } 526 + + }); 527 + + None 528 + + }, 529 + + P2pMessage::BroadcastChannelMessage { .. } => { 530 + + // Return to constellation for local dispatch. 531 + + Some((from.to_owned(), message)) 532 + + }, 533 + + } 534 + + } 535 + + 536 + + /// Clear all remote channel state for a given peer (e.g. on disconnect). 537 + + pub(crate) fn clear_remote_peer(&self, peer_id: &str) { 538 + + let remote_channels = self.remote_channels.clone(); 539 + + let peer_id = peer_id.to_owned(); 540 + + net::async_runtime::spawn_task(async move { 541 + + remote_channels.lock().await.remove(&peer_id); 542 + + }); 543 + + } 182 544 +} 183 545 + 184 546 +/// Load the secret key from the config directory, or generate and persist a new one. ··· 193 555 + retriever.lenient().get().await 194 556 +} 195 557 + 196 - +/// Load the display name from the config directory, or generate and persist a new one. 197 - +/// The name is stored as a plain text file `pairing-name.txt`. 198 - +async fn load_or_create_name(config_dir: Option<&PathBuf>) -> String { 199 - + if let Some(dir) = config_dir { 200 - + let name_path = dir.join("pairing-name.txt"); 201 - + if let Ok(name) = tokio::fs::read_to_string(&name_path).await { 202 - + let name = name.trim().to_owned(); 203 - + if !name.is_empty() { 204 - + return name; 205 - + } 206 - + } 558 + +/// Generate a memorable display name using petname. 559 + +fn generate_name() -> String { 560 + + petname::petname(3, "-").unwrap_or_else(|| format!("beaver-{}", rand::random::<u32>())) 561 + +} 207 562 + 208 - + let name = generate_name(); 563 + +/// Persisted P2P configuration: display name and paired peers. 564 + +#[derive(Clone, Debug, Default, Serialize, Deserialize)] 565 + +struct P2pConfig { 566 + + /// The local display name. 567 + + #[serde(default)] 568 + + name: String, 569 + + /// The list of paired peers. 570 + + #[serde(default)] 571 + + paired_peers: Vec<PairedPeer>, 572 + +} 209 573 + 210 - + // Ensure directory exists and write the name. 574 + +/// A persisted paired peer entry. 575 + +#[derive(Clone, Debug, Serialize, Deserialize)] 576 + +struct PairedPeer { 577 + + id: String, 578 + + name: String, 579 + +} 580 + + 581 + +fn config_path(config_dir: Option<&PathBuf>) -> Option<PathBuf> { 582 + + config_dir.map(|dir| dir.join("p2p-config.json")) 583 + +} 584 + + 585 + +async fn load_config(config_dir: Option<&PathBuf>) -> P2pConfig { 586 + + let Some(path) = config_path(config_dir) else { 587 + + return P2pConfig::default(); 588 + + }; 589 + + match tokio::fs::read_to_string(&path).await { 590 + + Ok(content) => serde_json::from_str(&content).unwrap_or_default(), 591 + + Err(_) => P2pConfig::default(), 592 + + } 593 + +} 594 + + 595 + +async fn save_config(config_dir: Option<&PathBuf>, config: &P2pConfig) { 596 + + let Some(path) = config_path(config_dir) else { 597 + + return; 598 + + }; 599 + + if let Some(dir) = config_dir { 211 600 + if let Err(e) = tokio::fs::create_dir_all(dir).await { 212 - + warn!("Failed to create config dir for pairing name: {e}"); 213 - + } else if let Err(e) = tokio::fs::write(&name_path, &name).await { 214 - + warn!("Failed to persist pairing name: {e}"); 601 + + warn!("Failed to create config dir: {e}"); 602 + + return; 215 603 + } 216 - + 217 - + name 218 - + } else { 219 - + generate_name() 604 + + } 605 + + match serde_json::to_string_pretty(config) { 606 + + Ok(json) => { 607 + + if let Err(e) = tokio::fs::write(&path, json).await { 608 + + warn!("Failed to write p2p config: {e}"); 609 + + } 610 + + }, 611 + + Err(e) => warn!("Failed to serialize p2p config: {e}"), 220 612 + } 221 613 +} 222 614 + 223 - +/// Generate a memorable display name using petname. 224 - +fn generate_name() -> String { 225 - + petname::petname(3, "-").unwrap_or_else(|| format!("beaver-{}", rand::random::<u32>())) 615 + +/// Add a paired peer to the config (deduplicating by id). 616 + +fn add_paired_peer(config: &mut P2pConfig, id: &str, name: &str) { 617 + + if let Some(existing) = config.paired_peers.iter_mut().find(|p| p.id == id) { 618 + + if !name.is_empty() { 619 + + existing.name = name.to_owned(); 620 + + } 621 + + } else { 622 + + config.paired_peers.push(PairedPeer { 623 + + id: id.to_owned(), 624 + + name: name.to_owned(), 625 + + }); 626 + + } 226 627 +} 227 628 + 228 629 +/// Convert a beaver_p2p PeerEvent to a serializable PairingEvent. ··· 245 646 + id: endpoint_id.to_string(), 246 647 + }) 247 648 + }, 248 - + PeerEvent::PairingRequest(id) => Some(PairingEvent::PairingRequest { id: id.to_string() }), 649 + + PeerEvent::PairingRequest(id, name) => Some(PairingEvent::PairingRequest { 650 + + id: id.to_string(), 651 + + name: name.clone(), 652 + + }), 249 653 + PeerEvent::PairingAccepted(id) => { 250 654 + Some(PairingEvent::PairingAccepted { id: id.to_string() }) 251 655 + }, ··· 253 657 + Some(PairingEvent::PairingRejected { id: id.to_string() }) 254 658 + }, 255 659 + PeerEvent::PairingFailed(id) => Some(PairingEvent::PairingFailed { id: id.to_string() }), 256 - + PeerEvent::Message(..) => None, 660 + + PeerEvent::Message(id, data) => Some(PairingEvent::MessageReceived { 661 + + from: id.to_string(), 662 + + data: data.clone(), 663 + + }), 257 664 + } 258 665 +}
+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 - @@ -186,6 +194,40 @@ 39 + @@ -186,6 +194,43 @@ 40 40 target!("RespondToScreenshotReadinessRequest") 41 41 }, 42 42 Self::TriggerGarbageCollection => target!("TriggerGarbageCollection"), ··· 74 74 + Self::PairingGetLocal(..) => target!("PairingGetLocal"), 75 75 + Self::PairingGetPeers(..) => target!("PairingGetPeers"), 76 76 + Self::PairingSetName(..) => target!("PairingSetName"), 77 + + Self::PairingRequestPairing(..) => target!("PairingRequestPairing"), 78 + + Self::PairingAcceptPairing(..) => target!("PairingAcceptPairing"), 79 + + Self::PairingRejectPairing(..) => target!("PairingRejectPairing"), 77 80 } 78 81 } 79 82 }
+5 -5
patches/components/layout/display_list/mod.rs.patch
··· 39 39 + // because embedded webviews are rendered via push_iframe from their parent's display list. 40 40 + let page_zoom_for_rendering = paint_info.viewport_details.page_zoom_for_rendering; 41 41 + let zoom_reference_frame_spatial_id = if let Some(zoom) = page_zoom_for_rendering { 42 - + log::warn!( 43 - + "DisplayListBuilder: applying page_zoom_for_rendering={} for pipeline {:?}", 44 - + zoom, 45 - + pipeline_id 46 - + ); 42 + + // log::warn!( 43 + + // "DisplayListBuilder: applying page_zoom_for_rendering={} for pipeline {:?}", 44 + + // zoom, 45 + + // pipeline_id 46 + + // ); 47 47 + let transform = LayoutTransform::scale(zoom, zoom, 1.0); 48 48 + let root_ref_frame = SpatialId::root_reference_frame(pipeline_id); 49 49 + let zoom_spatial_id = webrender_display_list_builder.push_reference_frame(
+27
patches/components/paint/web_content_animation.rs.patch
··· 1 + --- original 2 + +++ modified 3 + @@ -3,6 +3,7 @@ 4 + * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ 5 + 6 + use std::cell::{Cell, RefCell}; 7 + +use std::collections::BTreeMap; 8 + use std::rc::Rc; 9 + use std::sync::Arc; 10 + use std::sync::atomic::{AtomicBool, Ordering}; 11 + @@ -10,7 +11,6 @@ 12 + 13 + use base::id::WebViewId; 14 + use embedder_traits::EventLoopWaker; 15 + -use rustc_hash::FxHashMap; 16 + use servo_config::prefs; 17 + use webrender_api::{ColorF, PropertyBindingKey, PropertyValue}; 18 + 19 + @@ -74,7 +74,7 @@ 20 + 21 + pub(crate) fn update( 22 + &self, 23 + - webview_renderers: &FxHashMap<WebViewId, WebViewRenderer>, 24 + + webview_renderers: &BTreeMap<WebViewId, WebViewRenderer>, 25 + ) -> Option<Vec<PropertyValue<ColorF>>> { 26 + if !self.need_update.load(Ordering::Relaxed) { 27 + return None;
+75 -4
patches/components/script/dom/pairing.rs.patch
··· 1 1 --- original 2 2 +++ modified 3 - @@ -0,0 +1,225 @@ 3 + @@ -0,0 +1,296 @@ 4 4 +/* SPDX Id: AGPL-3.0-or-later */ 5 5 + 6 6 +use std::rc::Rc; ··· 49 49 + ("peerdiscovered", id.as_str(), name.as_str()) 50 50 + }, 51 51 + PairingEvent::PeerExpired { id } => ("peerleft", id.as_str(), ""), 52 - + PairingEvent::PairingRequest { id } => ("pairingrequest", id.as_str(), ""), 52 + + PairingEvent::PairingRequest { id, name } => { 53 + + ("pairingrequest", id.as_str(), name.as_str()) 54 + + }, 53 55 + PairingEvent::PairingAccepted { id } => ("peerjoined", id.as_str(), ""), 54 56 + PairingEvent::PairingRejected { id } => ("peerleft", id.as_str(), ""), 55 57 + PairingEvent::PairingFailed { id } => ("peerleft", id.as_str(), ""), 58 + + // MessageReceived is handled at the constellation level, not dispatched to DOM. 59 + + PairingEvent::MessageReceived { .. } => return, 56 60 + }; 57 61 + 58 62 + let global = self.global(); ··· 97 101 + promise 98 102 + } 99 103 + 100 - + fn RequestPairing(&self, _peer: &Peer, _comp: InRealm, _can_gc: CanGc) -> Rc<Promise> { 101 - + todo!() 104 + + fn RequestPairing(&self, peer: &Peer, comp: InRealm, can_gc: CanGc) -> Rc<Promise> { 105 + + let global = &self.global(); 106 + + let promise = Promise::new_in_current_realm(comp, can_gc); 107 + + let task_source = global.task_manager().dom_manipulation_task_source(); 108 + + let callback = callback_promise(&promise, self, task_source); 109 + + 110 + + let chan = global.script_to_constellation_chan(); 111 + + if chan 112 + + .send(ScriptToConstellationMessage::PairingRequestPairing( 113 + + peer.id().to_string(), 114 + + callback, 115 + + )) 116 + + .is_err() 117 + + { 118 + + promise.reject_error(Error::Operation(None), can_gc); 119 + + } 120 + + promise 121 + + } 122 + + 123 + + fn AcceptPairing(&self, peer: &Peer, comp: InRealm, can_gc: CanGc) -> Rc<Promise> { 124 + + let global = &self.global(); 125 + + let promise = Promise::new_in_current_realm(comp, can_gc); 126 + + let task_source = global.task_manager().dom_manipulation_task_source(); 127 + + let callback = callback_promise(&promise, self, task_source); 128 + + 129 + + let chan = global.script_to_constellation_chan(); 130 + + if chan 131 + + .send(ScriptToConstellationMessage::PairingAcceptPairing( 132 + + peer.id().to_string(), 133 + + callback, 134 + + )) 135 + + .is_err() 136 + + { 137 + + promise.reject_error(Error::Operation(None), can_gc); 138 + + } 139 + + promise 140 + + } 141 + + 142 + + fn RejectPairing(&self, peer: &Peer, comp: InRealm, can_gc: CanGc) -> Rc<Promise> { 143 + + let global = &self.global(); 144 + + let promise = Promise::new_in_current_realm(comp, can_gc); 145 + + let task_source = global.task_manager().dom_manipulation_task_source(); 146 + + let callback = callback_promise(&promise, self, task_source); 147 + + 148 + + let chan = global.script_to_constellation_chan(); 149 + + if chan 150 + + .send(ScriptToConstellationMessage::PairingRejectPairing( 151 + + peer.id().to_string(), 152 + + callback, 153 + + )) 154 + + .is_err() 155 + + { 156 + + promise.reject_error(Error::Operation(None), can_gc); 157 + + } 158 + + promise 102 159 + } 103 160 + 104 161 + fn SetName( ··· 226 283 + } 227 284 + } 228 285 +} 286 + + 287 + +impl RoutedPromiseListener<Result<bool, String>> for Pairing { 288 + + fn handle_response( 289 + + &self, 290 + + response: Result<bool, String>, 291 + + promise: &Rc<Promise>, 292 + + can_gc: CanGc, 293 + + ) { 294 + + match response { 295 + + Ok(accepted) => promise.resolve_native(&accepted, can_gc), 296 + + Err(msg) => promise.reject_error(Error::Operation(Some(msg)), can_gc), 297 + + } 298 + + } 299 + +}
+5 -1
patches/components/script/dom/peer.rs.patch
··· 1 1 --- original 2 2 +++ modified 3 - @@ -0,0 +1,75 @@ 3 + @@ -0,0 +1,79 @@ 4 4 +/* SPDX Id: AGPL-3.0-or-later */ 5 5 + 6 6 +use std::cell::RefCell; ··· 31 31 + display_name: RefCell::new(display_name), 32 32 + status, 33 33 + } 34 + + } 35 + + 36 + + pub(crate) fn id(&self) -> &str { 37 + + &self.id 34 38 + } 35 39 + 36 40 + pub(crate) fn new(
+2 -2
patches/components/script_bindings/codegen/Bindings.conf.patch
··· 17 17 }, 18 18 19 19 +'Pairing': { 20 - + 'inRealms': ['Start', 'Stop', 'Local', 'Peers', 'RequestPairing', 'SetName'], 21 - + 'canGc': ['Start', 'Stop', 'Local', 'Peers', 'RequestPairing', 'SetName'], 20 + + 'inRealms': ['Start', 'Stop', 'Local', 'Peers', 'RequestPairing', 'SetName', 'AcceptPairing', 'RejectPairing'], 21 + + 'canGc': ['Start', 'Stop', 'Local', 'Peers', 'RequestPairing', 'SetName', 'AcceptPairing', 'RejectPairing'], 22 22 +}, 23 23 + 24 24 'PerformanceObserver': {
+7 -1
patches/components/script_bindings/webidls/Pairing.webidl.patch
··· 1 1 --- original 2 2 +++ modified 3 - @@ -0,0 +1,62 @@ 3 + @@ -0,0 +1,68 @@ 4 4 +/* SPDX Id: AGPL-3.0-or-later */ 5 5 + 6 6 +[Exposed=Window, ··· 45 45 + 46 46 + // Start a pairing handshake with a discovered peer. 47 47 + Promise<boolean> requestPairing(Peer peer); 48 + + 49 + + // Accept an incoming pairing request. 50 + + Promise<undefined> acceptPairing(Peer peer); 51 + + 52 + + // Reject an incoming pairing request. 53 + + Promise<undefined> rejectPairing(Peer peer); 48 54 + 49 55 + // A new unpaired peer was discovered. 50 56 + attribute EventHandler onpeerdiscovered;
+7 -1
patches/components/shared/constellation/from_script_message.rs.patch
··· 119 119 /// Mark a new document as active 120 120 ActivateDocument, 121 121 /// Set the document state for a pipeline (used by screenshot / reftests) 122 - @@ -726,6 +775,54 @@ 122 + @@ -726,6 +775,60 @@ 123 123 RespondToScreenshotReadinessRequest(ScreenshotReadinessResponse), 124 124 /// Request the constellation to force garbage collection in all `ScriptThread`'s. 125 125 TriggerGarbageCollection, ··· 171 171 + PairingGetPeers(GenericCallback<Result<Vec<super::PeerInfo>, String>>), 172 172 + /// Update the display name of the local P2P endpoint and restart the service. 173 173 + PairingSetName(String, GenericCallback<Result<(), String>>), 174 + + /// Request pairing with a remote peer. Returns true if accepted, false if rejected. 175 + + PairingRequestPairing(String, GenericCallback<Result<bool, String>>), 176 + + /// Accept an incoming pairing request from a remote peer. 177 + + PairingAcceptPairing(String, GenericCallback<Result<(), String>>), 178 + + /// Reject an incoming pairing request from a remote peer. 179 + + PairingRejectPairing(String, GenericCallback<Result<(), String>>), 174 180 } 175 181 176 182 impl fmt::Debug for ScriptToConstellationMessage {
+11 -2
patches/components/shared/constellation/lib.rs.patch
··· 21 21 }; 22 22 pub use from_script_message::*; 23 23 use malloc_size_of_derive::MallocSizeOf; 24 - @@ -36,9 +37,138 @@ 24 + @@ -36,9 +37,147 @@ 25 25 use servo_url::{ImmutableOrigin, ServoUrl}; 26 26 pub use structured_data::*; 27 27 use strum::IntoStaticStr; ··· 129 129 + PairingRequest { 130 130 + /// The endpoint ID as a string. 131 131 + id: String, 132 + + /// The display name of the peer. 133 + + name: String, 132 134 + }, 133 135 + /// A pairing request we sent was accepted. 134 136 + PairingAccepted { ··· 145 147 + /// The endpoint ID as a string. 146 148 + id: String, 147 149 + }, 150 + + /// A message was received from a paired peer. 151 + + MessageReceived { 152 + + /// The endpoint ID of the sender. 153 + + from: String, 154 + + /// The raw message bytes (postcard-serialized P2pMessage). 155 + + data: Vec<u8>, 156 + + }, 148 157 +} 149 158 + 150 159 +/// The type of simple dialog to show. ··· 161 170 /// Messages to the Constellation from the embedding layer, whether from `ServoRenderer` or 162 171 /// from `libservo` itself. 163 172 #[derive(IntoStaticStr)] 164 - @@ -118,6 +248,9 @@ 173 + @@ -118,6 +257,9 @@ 165 174 UpdatePinchZoomInfos(PipelineId, PinchZoomInfos), 166 175 /// Activate or deactivate accessibility features. 167 176 SetAccessibilityActive(bool),
+20
ui/settings/index.css
··· 538 538 border-bottom: none; 539 539 } 540 540 541 + .p2p-pair-btn { 542 + padding: var(--spacing-xs) var(--spacing-md); 543 + border: 1px solid var(--color-border); 544 + border-radius: var(--radius-sm); 545 + background: var(--bg-webview); 546 + color: inherit; 547 + cursor: pointer; 548 + font-size: var(--font-size-sm); 549 + white-space: nowrap; 550 + } 551 + 552 + .p2p-pair-btn:hover { 553 + background: var(--color-menu-item-hover); 554 + } 555 + 556 + .p2p-pair-btn:disabled { 557 + opacity: 0.5; 558 + cursor: not-allowed; 559 + } 560 + 541 561 .p2p-peer-name { 542 562 font-weight: var(--font-weight-bold); 543 563 font-size: var(--font-size-menu);
+70 -60
ui/settings/index.js
··· 76 76 } 77 77 } 78 78 79 - // Setup sidebar navigation 80 - function setupNavigation() { 79 + // Navigate to a category by ID: update nav, open details, scroll. 80 + function navigateToCategory(categoryId) { 81 81 const navItems = document.querySelectorAll(".nav-item"); 82 + const category = document.getElementById(categoryId); 83 + if (!category) return; 82 84 83 - navItems.forEach((item) => { 84 - item.addEventListener("click", (event) => { 85 - event.preventDefault(); 86 - const categoryId = item.dataset.category; 85 + // Update active nav item 86 + navItems.forEach((nav) => nav.classList.remove("active")); 87 + const activeNav = document.querySelector(`[data-category="${categoryId}"]`); 88 + if (activeNav) activeNav.classList.add("active"); 87 89 88 - // Update active nav item 89 - navItems.forEach((nav) => nav.classList.remove("active")); 90 - item.classList.add("active"); 90 + // Open the details element and scroll to it 91 + const details = category.querySelector("details"); 92 + if (details) details.open = true; 93 + category.scrollIntoView({ behavior: "smooth", block: "start" }); 91 94 92 - // Scroll to category and open its details 93 - const category = document.getElementById(categoryId); 94 - if (category) { 95 - category.scrollIntoView({ behavior: "smooth", block: "start" }); 96 - const details = category.querySelector("details"); 97 - if (details) { 98 - details.open = true; 99 - } 100 - } 95 + // Handle mobile drill-down 96 + if (isMobile()) { 97 + document.body.classList.add("show-detail"); 98 + document.querySelectorAll(".settings-category").forEach((cat) => { 99 + cat.classList.remove("active"); 101 100 }); 101 + category.classList.add("active"); 102 + const mobileTitle = document.querySelector(".mobile-title"); 103 + if (activeNav && mobileTitle) { 104 + mobileTitle.textContent = activeNav.querySelector("span").textContent; 105 + } 106 + } 107 + } 108 + 109 + function isMobile() { 110 + return window.matchMedia("(max-width: 600px)").matches; 111 + } 112 + 113 + // Setup sidebar navigation — let anchors set the hash naturally. 114 + function setupNavigation() { 115 + // Navigate on hash change (covers both clicks and direct URL access). 116 + window.addEventListener("hashchange", () => { 117 + const hash = location.hash.replace("#", ""); 118 + if (hash) navigateToCategory(hash); 102 119 }); 120 + 121 + // Handle initial hash on page load. 122 + const initialHash = location.hash.replace("#", ""); 123 + if (initialHash) { 124 + navigateToCategory(initialHash); 125 + } 103 126 } 104 127 105 128 // Mobile drill-down navigation 106 129 function setupMobileNavigation() { 107 - const navItems = document.querySelectorAll(".nav-item"); 108 130 const backButton = document.querySelector(".back-button"); 109 131 const mobileTitle = document.querySelector(".mobile-title"); 110 132 const categories = document.querySelectorAll(".settings-category"); 111 133 112 - // Check if we're in mobile view 113 - function isMobile() { 114 - return window.matchMedia("(max-width: 600px)").matches; 115 - } 116 - 117 - // Show detail view for a category 118 - function showDetail(categoryId) { 119 - if (!isMobile()) { 120 - return; 121 - } 122 - 123 - document.body.classList.add("show-detail"); 124 - 125 - // Mark the active category 126 - categories.forEach((cat) => { 127 - cat.classList.remove("active"); 128 - if (cat.id === categoryId) { 129 - cat.classList.add("active"); 130 - // Open the details element 131 - const details = cat.querySelector("details"); 132 - if (details) details.open = true; 133 - } 134 - }); 135 - 136 - // Update title 137 - const activeNav = document.querySelector(`[data-category="${categoryId}"]`); 138 - if (activeNav && mobileTitle) { 139 - mobileTitle.textContent = activeNav.querySelector("span").textContent; 140 - } 141 - } 142 - 143 134 // Return to list view 144 135 function showList() { 145 136 document.body.classList.remove("show-detail"); 146 137 categories.forEach((cat) => cat.classList.remove("active")); 147 138 if (mobileTitle) mobileTitle.textContent = "Settings"; 139 + // Clear hash so back button feels like going back 140 + history.replaceState(null, "", location.pathname); 148 141 } 149 - 150 - // Nav item click handler (enhanced for mobile) 151 - navItems.forEach((item) => { 152 - item.addEventListener("click", () => { 153 - if (isMobile()) { 154 - showDetail(item.dataset.category); 155 - } 156 - }); 157 - }); 158 142 159 143 // Back button handler 160 144 if (backButton) { ··· 297 281 <div class="p2p-peer-name">${p.displayName || p.id}</div> 298 282 <div class="p2p-peer-id">${p.id}</div> 299 283 </div> 300 - <span class="p2p-peer-status ${p.status}">${p.status}</span> 284 + ${p.status === "discovered" 285 + ? `<button class="p2p-pair-btn" data-id="${p.id}">Pair</button>` 286 + : `<span class="p2p-peer-status ${p.status}">${p.status}</span>`} 301 287 </div>`, 302 288 ) 303 289 .join(""); 290 + 291 + // Attach pair button handlers 292 + peerList.querySelectorAll(".p2p-pair-btn").forEach((btn) => { 293 + btn.addEventListener("click", async () => { 294 + const id = btn.dataset.id; 295 + btn.disabled = true; 296 + btn.textContent = "Pairing..."; 297 + try { 298 + const peersList = await pairing.peers(); 299 + const peer = peersList.find((p) => p.id === id); 300 + if (!peer) { 301 + btn.textContent = "Not found"; 302 + return; 303 + } 304 + const accepted = await pairing.requestPairing(peer); 305 + btn.textContent = accepted ? "Paired" : "Rejected"; 306 + await refreshPeers(); 307 + } catch (e) { 308 + console.error("Pairing failed:", e); 309 + btn.textContent = "Failed"; 310 + setTimeout(() => refreshPeers(), 2000); 311 + } 312 + }); 313 + }); 304 314 } 305 315 } catch (e) { 306 316 console.error("Failed to get peers:", e);
+75
ui/system/index.css
··· 273 273 #notifications-badge.hidden { 274 274 display: none; 275 275 } 276 + 277 + /* Pairing dialog */ 278 + #pairing-dialog { 279 + position: fixed; 280 + z-index: 10000; 281 + background: var(--bg-menu); 282 + color: var(--color-text-menu); 283 + border: 1px solid var(--color-border); 284 + border-radius: 12px; 285 + padding: 1.5rem; 286 + min-width: 320px; 287 + max-width: 90vw; 288 + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4); 289 + } 290 + 291 + #pairing-dialog::backdrop { 292 + background: rgba(0, 0, 0, 0.6); 293 + z-index: 9999; 294 + } 295 + 296 + #pairing-dialog h2 { 297 + font-size: 1.1rem; 298 + margin: 0 0 1rem; 299 + } 300 + 301 + .pairing-request-item { 302 + padding: 0.75rem; 303 + margin: 0.5rem 0; 304 + background: var(--bg-webview); 305 + border-radius: 8px; 306 + } 307 + 308 + .pairing-request-name { 309 + font-weight: bold; 310 + } 311 + 312 + .pairing-request-id { 313 + font-size: 0.75rem; 314 + opacity: 0.7; 315 + word-break: break-all; 316 + margin-bottom: 0.5rem; 317 + } 318 + 319 + .pairing-request-buttons { 320 + display: flex; 321 + gap: 0.5rem; 322 + } 323 + 324 + .pairing-request-buttons button { 325 + padding: 0.4rem 1rem; 326 + border: none; 327 + border-radius: 6px; 328 + cursor: pointer; 329 + font-size: 0.85rem; 330 + } 331 + 332 + .btn-accept { 333 + background: #2d7a2d; 334 + color: white; 335 + } 336 + 337 + .btn-reject { 338 + background: #7a2d2d; 339 + color: white; 340 + } 341 + 342 + .btn-close-dialog { 343 + margin-top: 1rem; 344 + padding: 0.5rem 1.5rem; 345 + background: transparent; 346 + border: 1px solid var(--color-border); 347 + color: inherit; 348 + border-radius: 6px; 349 + cursor: pointer; 350 + }
+163 -5
ui/system/index.js
··· 94 94 } 95 95 } 96 96 97 + function updateNotificationUIs() { 98 + const snapshot = [...notifications]; 99 + if (notificationPanel) { 100 + notificationPanel.notifications = snapshot; 101 + } 102 + if (mobileNotificationSheet) { 103 + mobileNotificationSheet.notifications = snapshot; 104 + } 105 + updateNotificationBadge(); 106 + } 107 + 97 108 function addNotification(notification) { 98 109 // Add timestamp if not present 99 110 if (!notification.timestamp) { ··· 129 140 notifications = notifications.slice(0, MAX_NOTIFICATIONS); 130 141 } 131 142 132 - // Update panel and badge 133 - if (notificationPanel) { 134 - notificationPanel.notifications = [...notifications]; 135 - } 136 - updateNotificationBadge(); 143 + // Update panels and badge 144 + updateNotificationUIs(); 145 + } 146 + 147 + function removeNotificationByTag(tag) { 148 + notifications = notifications.filter((n) => n.tag !== tag); 149 + updateNotificationUIs(); 137 150 } 138 151 139 152 function dismissNotification(notification) { ··· 153 166 notificationPanel.notifications = []; 154 167 } 155 168 updateNotificationBadge(); 169 + } 170 + 171 + // P2P pairing request handling 172 + const pendingPairingRequests = new Map(); // id -> peer 173 + const pairingApi = navigator.embedder.pairing; 174 + 175 + pairingApi.addEventListener("pairingrequest", (e) => { 176 + const peer = e.peer; 177 + console.log(`[P2P] Pairing request from ${peer.displayName} (${peer.id})`); 178 + pendingPairingRequests.set(peer.id, peer); 179 + 180 + addNotification({ 181 + title: "Pairing Request", 182 + body: `${peer.displayName || peer.id} wants to pair`, 183 + tag: "pairing-requests", 184 + iconUrl: "", 185 + }); 186 + }); 187 + 188 + // Clean up pending requests when pairing completes or the peer leaves. 189 + pairingApi.addEventListener("peerjoined", (e) => { 190 + pendingPairingRequests.delete(e.peer.id); 191 + if (pendingPairingRequests.size === 0) { 192 + removeNotificationByTag("pairing-requests"); 193 + } 194 + }); 195 + 196 + pairingApi.addEventListener("peerleft", (e) => { 197 + pendingPairingRequests.delete(e.peer.id); 198 + if (pendingPairingRequests.size === 0) { 199 + removeNotificationByTag("pairing-requests"); 200 + } 201 + }); 202 + 203 + function showPairingDialog() { 204 + // Close any existing dialog 205 + const existing = document.getElementById("pairing-dialog"); 206 + if (existing) { 207 + existing.close(); 208 + existing.remove(); 209 + } 210 + 211 + if (pendingPairingRequests.size === 0) return; 212 + 213 + const dialog = document.createElement("dialog"); 214 + dialog.id = "pairing-dialog"; 215 + dialog.innerHTML = ` 216 + <h2>Pairing Requests</h2> 217 + <div class="pairing-request-list"> 218 + ${Array.from(pendingPairingRequests.entries()).map(([id, peer]) => ` 219 + <div class="pairing-request-item" data-id="${id}"> 220 + <div class="pairing-request-info"> 221 + <div class="pairing-request-name">${peer.displayName || id}</div> 222 + <div class="pairing-request-id">${id}</div> 223 + </div> 224 + <div class="pairing-request-buttons"> 225 + <button class="btn-accept" data-id="${id}">Accept</button> 226 + <button class="btn-reject" data-id="${id}">Reject</button> 227 + </div> 228 + </div> 229 + `).join("")} 230 + </div> 231 + <button class="btn-close-dialog">Close</button> 232 + `; 233 + 234 + document.body.appendChild(dialog); 235 + 236 + // Close on backdrop click 237 + dialog.addEventListener("click", (e) => { 238 + if (e.target === dialog) dialog.close(); 239 + }); 240 + dialog.addEventListener("close", () => dialog.remove()); 241 + 242 + dialog.querySelector(".btn-close-dialog").addEventListener("click", () => { 243 + dialog.close(); 244 + }); 245 + 246 + dialog.querySelectorAll(".btn-accept").forEach((btn) => { 247 + btn.addEventListener("click", async () => { 248 + const id = btn.dataset.id; 249 + const peer = pendingPairingRequests.get(id); 250 + if (!peer) return; 251 + btn.disabled = true; 252 + btn.textContent = "Accepting..."; 253 + try { 254 + await pairingApi.acceptPairing(peer); 255 + console.log(`[P2P] Accepted pairing from ${id}`); 256 + pendingPairingRequests.delete(id); 257 + updatePairingDialog(dialog); 258 + } catch (e) { 259 + console.error(`[P2P] Accept failed:`, e); 260 + btn.textContent = "Failed"; 261 + } 262 + }); 263 + }); 264 + 265 + dialog.querySelectorAll(".btn-reject").forEach((btn) => { 266 + btn.addEventListener("click", async () => { 267 + const id = btn.dataset.id; 268 + const peer = pendingPairingRequests.get(id); 269 + if (!peer) return; 270 + btn.disabled = true; 271 + btn.textContent = "Rejecting..."; 272 + try { 273 + await pairingApi.rejectPairing(peer); 274 + console.log(`[P2P] Rejected pairing from ${id}`); 275 + pendingPairingRequests.delete(id); 276 + updatePairingDialog(dialog); 277 + } catch (e) { 278 + console.error(`[P2P] Reject failed:`, e); 279 + btn.textContent = "Failed"; 280 + } 281 + }); 282 + }); 283 + 284 + try { 285 + dialog.showModal(); 286 + } catch (e) { 287 + console.error(`[P2P] showModal() failed:`, e); 288 + } 289 + } 290 + 291 + function updatePairingDialog(dialog) { 292 + if (pendingPairingRequests.size === 0) { 293 + dialog.close(); 294 + removeNotificationByTag("pairing-requests"); 295 + } else { 296 + dialog.close(); 297 + showPairingDialog(); 298 + } 156 299 } 157 300 158 301 window.servo = { ··· 635 778 636 779 mobileNotificationSheet.addEventListener("notification-click", (e) => { 637 780 const notification = e.detail.notification; 781 + 782 + // Handle pairing request notifications specially 783 + if (notification.tag === "pairing-requests") { 784 + mobileNotificationSheet.open = false; 785 + if (pendingPairingRequests.size > 0) showPairingDialog(); 786 + return; 787 + } 788 + 638 789 if (notification.webviewId && layoutManager.webviews) { 639 790 for (const [id, entry] of layoutManager.webviews) { 640 791 if (id.toString() === notification.webviewId) { ··· 821 972 if (notificationPanel) { 822 973 notificationPanel.addEventListener("notification-click", (e) => { 823 974 const notification = e.detail.notification; 975 + 976 + // Handle pairing request notifications specially 977 + if (notification.tag === "pairing-requests") { 978 + notificationPanel.open = false; 979 + if (pendingPairingRequests.size > 0) showPairingDialog(); 980 + return; 981 + } 824 982 825 983 // Focus the source webview if possible 826 984 if (notification.webviewId && layoutManager.webviews) {