Rewild Your Web
18
fork

Configure Feed

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

p2p: improve pairing

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

webbeef a2b10044 7b16ca8f

+246 -60
-2
crates/beaver_p2p/src/lib.rs
··· 106 106 .await 107 107 .unwrap(); 108 108 109 - println!("Endpoint {name}, {}", endpoint.id()); 110 - 111 109 let mdns = MdnsAddressLookup::builder().build(endpoint.id()).unwrap(); 112 110 endpoint.address_lookup().add(mdns.clone()); 113 111
+2 -1
crates/beaver_p2p/src/state.rs
··· 242 242 existing.status = EndpointStatus::PairedConnected; 243 243 existing.addr = addr; 244 244 existing.name = name.to_owned(); 245 - self.notify(PeerEvent::Discovery(event.clone())); 245 + // Fire PairingAccepted so JS gets "peerjoined" (not "peerdiscovered"). 246 + self.notify(PeerEvent::PairingAccepted(endpoint_info.endpoint_id)); 246 247 return; 247 248 } 248 249 }
+1 -1
forkme.lock
··· 1 - f78ca1cef4517e96e6a5423c64736c746c164432 1 + de4a79facf12bf758d8939617f5bd83e5953314d
+23 -7
patches/components/constellation/constellation.rs.patch
··· 322 322 }, 323 323 #[cfg(feature = "webgpu")] 324 324 ScriptToConstellationMessage::RequestAdapter(response_sender, options, ids) => self 325 - @@ -2045,6 +2178,391 @@ 325 + @@ -2045,6 +2178,407 @@ 326 326 let _ = event_loop.send(ScriptThreadMessage::TriggerGarbageCollection); 327 327 } 328 328 }, ··· 559 559 + fn handle_pairing_event(&mut self, event: constellation_traits::PairingEvent) { 560 560 + if let constellation_traits::PairingEvent::MessageReceived { ref from, ref data } = event { 561 561 + debug!("P2P message received from {from}, {} bytes", data.len()); 562 + + 563 + + // On first message from a peer, sync our channels to them. 564 + + // This handles the case where our initial sync at discovery 565 + + // time failed because the connection wasn't ready yet. 566 + + if self.pairing.confirm_peer(from) { 567 + + let channels: Vec<_> = self 568 + + .broadcast_channels 569 + + .open_channels() 570 + + .into_iter() 571 + + .map(|(origin, name)| (origin.ascii_serialization(), name)) 572 + + .collect(); 573 + + if !channels.is_empty() { 574 + + self.pairing.sync_channels_to_peer(from, &channels); 575 + + } 576 + + } 577 + + 562 578 + if let Some((from, message)) = self.pairing.handle_incoming_message(from, data) { 563 579 + debug!("Decoded P2P message: {message:?}"); 564 580 + match message { ··· 714 730 } 715 731 } 716 732 717 - @@ -2364,6 +2882,29 @@ 733 + @@ -2364,6 +2898,29 @@ 718 734 TransferState::TransferInProgress(queue) => queue.push_back(task), 719 735 TransferState::CompletionFailed(queue) => queue.push_back(task), 720 736 TransferState::CompletionRequested(_, queue) => queue.push_back(task), ··· 744 760 } 745 761 } 746 762 747 - @@ -3243,6 +3784,13 @@ 763 + @@ -3243,6 +3800,13 @@ 748 764 /// <https://html.spec.whatwg.org/multipage/#destroy-a-top-level-traversable> 749 765 fn handle_close_top_level_browsing_context(&mut self, webview_id: WebViewId) { 750 766 debug!("{webview_id}: Closing"); ··· 758 774 let browsing_context_id = BrowsingContextId::from(webview_id); 759 775 // Step 5. Remove traversable from the user agent's top-level traversable set. 760 776 let browsing_context = 761 - @@ -3519,8 +4067,27 @@ 777 + @@ -3519,8 +4083,27 @@ 762 778 opener_webview_id, 763 779 opener_pipeline_id, 764 780 response_sender, ··· 786 802 let Some((webview_id_sender, webview_id_receiver)) = generic_channel::channel() else { 787 803 warn!("Failed to create channel"); 788 804 let _ = response_sender.send(None); 789 - @@ -3619,6 +4186,361 @@ 805 + @@ -3619,6 +4202,361 @@ 790 806 }); 791 807 } 792 808 ··· 1148 1164 #[servo_tracing::instrument(skip_all)] 1149 1165 fn handle_refresh_cursor(&self, pipeline_id: PipelineId) { 1150 1166 let Some(pipeline) = self.pipelines.get(&pipeline_id) else { 1151 - @@ -4744,7 +5666,7 @@ 1167 + @@ -4744,7 +5682,7 @@ 1152 1168 } 1153 1169 1154 1170 #[servo_tracing::instrument(skip_all)] ··· 1157 1173 // Send a flat projection of the history to embedder. 1158 1174 // The final vector is a concatenation of the URLs of the past 1159 1175 // entries, the current entry and the future entries. 1160 - @@ -4847,9 +5769,23 @@ 1176 + @@ -4847,9 +5785,23 @@ 1161 1177 ); 1162 1178 self.embedder_proxy.send(EmbedderMsg::HistoryChanged( 1163 1179 webview_id,
+14 -2
patches/components/constellation/pairing.rs.patch
··· 1 1 --- original 2 2 +++ modified 3 - @@ -0,0 +1,804 @@ 3 + @@ -0,0 +1,816 @@ 4 4 +// SPDX-License-Identifier: AGPL-3.0-or-later 5 5 + 6 6 +//! P2P pairing service integration with the constellation. ··· 83 83 + /// Remote broadcast channels announced by each paired peer. 84 84 + /// Maps a endpoint ID to a set of (origin, channel_name). 85 85 + remote_channels: Arc<Mutex<HashMap<String, HashSet<(String, String)>>>>, 86 + + /// Peers that have confirmed bidirectional communication 87 + + /// (we received at least one message from them). 88 + + confirmed_peers: HashSet<String>, 86 89 +} 87 90 + 88 91 +impl PairingService { ··· 92 95 + local_info: Default::default(), 93 96 + event_receiver: None, 94 97 + remote_channels: Default::default(), 98 + + confirmed_peers: Default::default(), 95 99 + } 96 100 + } 97 101 + ··· 624 628 + } 625 629 + 626 630 + /// Clear all remote channel state for a given peer (e.g. on disconnect). 627 - + pub(crate) fn clear_remote_peer(&self, peer_id: &str) { 631 + + pub(crate) fn clear_remote_peer(&mut self, peer_id: &str) { 632 + + self.confirmed_peers.remove(peer_id); 628 633 + let remote_channels = self.remote_channels.clone(); 629 634 + let peer_id = peer_id.to_owned(); 630 635 + net::async_runtime::spawn_task(async move { ··· 682 687 + } 683 688 + } 684 689 + }); 690 + + } 691 + + 692 + + /// Returns true and marks the peer as confirmed if this is the first 693 + + /// message we've received from them. Used to trigger a reciprocal 694 + + /// channel sync when the connection is proven to be working. 695 + + pub(crate) fn confirm_peer(&mut self, peer_id: &str) -> bool { 696 + + self.confirmed_peers.insert(peer_id.to_owned()) 685 697 + } 686 698 +} 687 699 +
+34
ui/system/index.css
··· 199 199 border: none; 200 200 } 201 201 202 + /* Media panel (local + remote controls) */ 203 + #media-panel { 204 + position: fixed; 205 + bottom: 0; 206 + left: var(--sidebar-width); 207 + width: var(--size-panel-width); 208 + z-index: var(--z-panel); 209 + background: var(--bg-menu); 210 + box-shadow: 4px -4px 16px var(--color-shadow-menu); 211 + border-radius: var(--radius-md) var(--radius-md) 0 0; 212 + transform: translateY(100%); 213 + transition: transform var(--transition-fast), visibility var(--transition-fast); 214 + visibility: hidden; 215 + pointer-events: none; 216 + } 217 + 218 + #media-panel.open { 219 + transform: translateY(0); 220 + visibility: visible; 221 + pointer-events: auto; 222 + } 223 + 224 + #media-panel media-control { 225 + border-bottom: 1px solid var(--color-border); 226 + } 227 + 228 + #media-panel media-control:last-child { 229 + border-bottom: none; 230 + } 231 + 202 232 /* Each panel is a full-viewport-width container that holds a split tree */ 203 233 .panel { 204 234 position: relative; /* For absolutely positioned resize handles */ ··· 271 301 } 272 302 273 303 #notifications-badge.hidden { 304 + display: none; 305 + } 306 + 307 + #media-icon.hidden { 274 308 display: none; 275 309 } 276 310
+3 -1
ui/system/index.html
··· 36 36 <lucide-icon name="plus" id="plus-icon"></lucide-icon> 37 37 </div> 38 38 <notification-panel id="notification-panel"></notification-panel> 39 - <media-control id="media-control"></media-control> 39 + <div id="media-panel"> 40 + <media-control id="media-control"></media-control> 41 + </div> 40 42 <div id="root"></div> 41 43 </main> 42 44 <toast-manager id="toast-manager"></toast-manager>
+151 -5
ui/system/index.js
··· 917 917 const mediaIcon = document.getElementById("media-icon"); 918 918 let mediaSessionWebviewId = null; 919 919 920 + // P2P media control: broadcast local media state and receive remote state. 921 + // Every broadcast includes the full accumulated state so late joiners get everything. 922 + const mediaChannel = new BroadcastChannel("beaver-media-control"); 923 + let localDeviceName = null; 924 + let localSessionId = null; 925 + let localMediaState = { title: "", artist: "", album: "", playbackState: "none", duration: 0, position: 0 }; 926 + const remoteMediaControls = new Map(); // sessionId -> media-control element 927 + 928 + mediaChannel.onmessage = (e) => { 929 + handleRemoteMediaMessage(e.data); 930 + }; 931 + 932 + // When a paired peer connects (or reconnects), send a hello so active 933 + // players on the other side respond with their current state. 934 + // Delayed to allow the P2P channel sync to complete first. 935 + navigator.embedder.pairing.addEventListener("peerjoined", () => { 936 + setTimeout(() => { 937 + mediaChannel.postMessage({ type: "hello" }); 938 + }, 5000); 939 + }); 940 + 941 + // Lazily resolve local device name. 942 + navigator.embedder.pairing 943 + ?.local() 944 + .then((info) => { 945 + localDeviceName = info.displayName; 946 + }) 947 + .catch(() => {}); 948 + 949 + function broadcastMediaState(eventType, detail) { 950 + if (!localSessionId) return; 951 + // Merge into accumulated state. 952 + if (eventType === "metadata") { 953 + localMediaState.title = detail.title || ""; 954 + localMediaState.artist = detail.artist || ""; 955 + localMediaState.album = detail.album || ""; 956 + } else if (eventType === "playbackstate") { 957 + localMediaState.playbackState = detail.playbackState || "none"; 958 + } else if (eventType === "positionstate") { 959 + localMediaState.duration = detail.duration || 0; 960 + localMediaState.position = detail.position || 0; 961 + } 962 + mediaChannel.postMessage({ 963 + type: "state", 964 + sessionId: localSessionId, 965 + deviceName: localDeviceName || "Unknown device", 966 + ...localMediaState, 967 + }); 968 + } 969 + 970 + function getOrCreateRemoteControl(sessionId, deviceName) { 971 + let ctrl = remoteMediaControls.get(sessionId); 972 + if (!ctrl) { 973 + ctrl = document.createElement("media-control"); 974 + ctrl.classList.add("inline"); 975 + ctrl.deviceId = sessionId; 976 + ctrl.deviceName = deviceName; 977 + ctrl.hasMedia = true; 978 + ctrl.addEventListener("media-action", handleRemoteMediaAction); 979 + remoteMediaControls.set(sessionId, ctrl); 980 + 981 + // Insert into the media panel alongside local control. 982 + const panel = document.getElementById("media-panel"); 983 + if (panel) { 984 + panel.appendChild(ctrl); 985 + } 986 + updateMediaIcon(); 987 + } 988 + if (deviceName) ctrl.deviceName = deviceName; 989 + return ctrl; 990 + } 991 + 992 + function handleRemoteMediaMessage(data) { 993 + if (data.type === "state") { 994 + // Ignore our own broadcasts. 995 + if (data.sessionId === localSessionId) return; 996 + 997 + const ctrl = getOrCreateRemoteControl(data.sessionId, data.deviceName); 998 + ctrl.title = data.title || ""; 999 + ctrl.artist = data.artist || ""; 1000 + ctrl.album = data.album || ""; 1001 + ctrl.playbackState = data.playbackState || "none"; 1002 + ctrl.duration = data.duration || 0; 1003 + ctrl.position = data.position || 0; 1004 + ctrl.hasMedia = data.playbackState !== "none"; 1005 + updateMediaIcon(); 1006 + } else if (data.type === "action") { 1007 + // A remote device is requesting an action on our media session. 1008 + if (data.sessionId !== localSessionId) return; 1009 + if (mediaSessionWebviewId == null) return; 1010 + const entry = layoutManager.webviews.get(mediaSessionWebviewId); 1011 + if (entry) { 1012 + entry.webview.ensureIframe(); 1013 + entry.webview.iframe.mediaSessionAction(data.action); 1014 + } 1015 + } else if (data.type === "hello") { 1016 + // A peer just joined — send our current state if we have an active session. 1017 + if (localSessionId && localMediaState.playbackState !== "none") { 1018 + mediaChannel.postMessage({ 1019 + type: "state", 1020 + sessionId: localSessionId, 1021 + deviceName: localDeviceName || "Unknown device", 1022 + ...localMediaState, 1023 + }); 1024 + } 1025 + } 1026 + } 1027 + 1028 + function handleRemoteMediaAction(event) { 1029 + // Send the action back to the originating device via BroadcastChannel. 1030 + const msg = { 1031 + type: "action", 1032 + sessionId: event.detail.deviceId, // deviceId on the control is the sessionId 1033 + action: event.detail.action, 1034 + }; 1035 + console.warn("[P2P Media] Sending action:", msg); 1036 + mediaChannel.postMessage(msg); 1037 + } 1038 + 920 1039 if (mediaIcon) { 921 1040 mediaIcon.onclick = () => { 922 - if (mediaControl) { 923 - mediaControl.open = !mediaControl.open; 1041 + const panel = document.getElementById("media-panel"); 1042 + if (panel) { 1043 + panel.classList.toggle("open"); 924 1044 } 925 1045 }; 926 1046 } 927 1047 1048 + function hasAnyActiveMedia() { 1049 + if (mediaControl?.hasMedia) return true; 1050 + for (const ctrl of remoteMediaControls.values()) { 1051 + if (ctrl.hasMedia) return true; 1052 + } 1053 + return false; 1054 + } 1055 + 1056 + function updateMediaIcon() { 1057 + if (!mediaIcon) return; 1058 + if (hasAnyActiveMedia()) { 1059 + mediaIcon.classList.remove("hidden"); 1060 + } else { 1061 + mediaIcon.classList.add("hidden"); 1062 + } 1063 + } 1064 + 928 1065 function updateMediaControl(ctrl, eventType, detail) { 929 1066 if (!ctrl) { 930 1067 return; ··· 934 1071 ctrl.artist = detail.artist || ""; 935 1072 ctrl.album = detail.album || ""; 936 1073 ctrl.hasMedia = true; 937 - if (mediaIcon) mediaIcon.classList.remove("hidden"); 938 1074 } else if (eventType === "playbackstate") { 939 1075 ctrl.playbackState = detail.playbackState || "none"; 940 1076 if (detail.playbackState === "none") { 941 1077 ctrl.hasMedia = false; 942 1078 ctrl.open = false; 943 - if (mediaIcon) mediaIcon.classList.add("hidden"); 944 1079 } else { 945 1080 ctrl.hasMedia = true; 946 - if (mediaIcon) mediaIcon.classList.remove("hidden"); 947 1081 } 948 1082 } else if (eventType === "positionstate") { 949 1083 ctrl.duration = detail.duration || 0; 950 1084 ctrl.position = detail.position || 0; 951 1085 } 1086 + updateMediaIcon(); 952 1087 } 953 1088 954 1089 document ··· 956 1091 .addEventListener("webview-mediasession", (e) => { 957 1092 const { webviewId, eventType } = e.detail; 958 1093 mediaSessionWebviewId = webviewId; 1094 + // Generate a session ID on the first media event. 1095 + if (!localSessionId) { 1096 + localSessionId = `ms-${Date.now()}-${Math.random().toString(36).slice(2)}`; 1097 + } 959 1098 updateMediaControl(mediaControl, eventType, e.detail); 960 1099 // Also update mobile notification sheet's media control 961 1100 if (mobileNotificationSheet) { ··· 963 1102 "#mobile-media-control", 964 1103 ); 965 1104 updateMediaControl(mobileCtrl, eventType, e.detail); 1105 + } 1106 + // Broadcast to remote peers 1107 + broadcastMediaState(eventType, e.detail); 1108 + // Clear session ID when playback stops 1109 + if (eventType === "playbackstate" && e.detail.playbackState === "none") { 1110 + localSessionId = null; 1111 + localMediaState = { title: "", artist: "", album: "", playbackState: "none", duration: 0, position: 0 }; 966 1112 } 967 1113 }); 968 1114
+10 -40
ui/system/media_control.css
··· 1 1 /* SPDX-License-Identifier: AGPL-3.0-or-later */ 2 2 3 3 :host { 4 - position: fixed; 5 - bottom: 0; 6 - left: var(--sidebar-width); 7 - width: var(--size-panel-width); 8 - z-index: var(--z-panel); 9 - pointer-events: none; 4 + display: block; 10 5 font-family: var(--font-family-base); 11 6 } 12 7 13 - :host([open]) { 14 - pointer-events: auto; 8 + :host(:not([has-media])) { 9 + display: none; 15 10 } 16 11 17 12 .panel { 18 - position: absolute; 19 - bottom: 0; 20 - left: 0; 21 - width: 100%; 22 - background: var(--bg-menu); 23 - box-shadow: 4px -4px 16px var(--color-shadow-menu); 24 - border-radius: var(--radius-md) var(--radius-md) 0 0; 25 - transform: translateY(100%); 26 - transition: transform var(--transition-fast), visibility var(--transition-fast); 27 - visibility: hidden; 28 13 padding: var(--spacing-md); 29 14 } 30 15 31 - :host([open]) .panel { 32 - transform: translateY(0); 33 - visibility: visible; 34 - } 35 - 36 - /* Mobile: inline display, no panel positioning */ 37 - :host(.inline) { 38 - position: static; 39 - width: auto; 40 - pointer-events: auto; 16 + /* Mobile notification sheet: hide when no media */ 17 + :host(.inline:not([has-media])) { 18 + display: none; 41 19 } 42 20 43 - :host(.inline) .panel { 44 - position: static; 45 - transform: none; 46 - visibility: visible; 47 - box-shadow: none; 48 - border-radius: 0; 49 - border-bottom: 1px solid var(--color-border); 50 - width: auto; 51 - } 52 - 53 - :host(.inline:not([has-media])) { 54 - display: none; 21 + .media-device { 22 + font-size: var(--font-size-xs); 23 + color: var(--color-text-tertiary); 24 + margin-bottom: var(--spacing-xs); 55 25 } 56 26 57 27 .media-info {
+8 -1
ui/system/media_control.js
··· 16 16 position: { type: Number }, 17 17 open: { type: Boolean, reflect: true }, 18 18 hasMedia: { type: Boolean, reflect: true, attribute: "has-media" }, 19 + deviceName: { type: String, attribute: "device-name" }, 20 + deviceId: { type: String, attribute: "device-id" }, 19 21 }; 20 22 21 23 static styles = css` ··· 32 34 this.position = 0; 33 35 this.open = false; 34 36 this.hasMedia = false; 37 + this.deviceName = ""; 38 + this.deviceId = ""; 35 39 } 36 40 37 41 handleAction(action) { ··· 39 43 new CustomEvent("media-action", { 40 44 bubbles: true, 41 45 composed: true, 42 - detail: { action }, 46 + detail: { action, deviceId: this.deviceId }, 43 47 }), 44 48 ); 45 49 } ··· 56 60 57 61 return html` 58 62 <div class="panel"> 63 + ${this.deviceName 64 + ? html`<div class="media-device">${this.deviceName}</div>` 65 + : ""} 59 66 <div class="media-info"> 60 67 <div class="media-title">${this.title || "Untitled"}</div> 61 68 ${this.artist