diff --git a/Cargo.lock b/Cargo.lock index 50caf7e..a6135db 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -9048,6 +9048,7 @@ dependencies = [ "petname", "postcard", "rand 0.9.2", + "regex", "rustc-hash 2.1.2", "serde", "serde_json", diff --git a/patches/components/constellation/Cargo.toml.patch b/patches/components/constellation/Cargo.toml.patch index a23690d..c82570b 100644 --- a/patches/components/constellation/Cargo.toml.patch +++ b/patches/components/constellation/Cargo.toml.patch @@ -17,8 +17,11 @@ keyboard-types = { workspace = true } layout_api = { workspace = true } log = { workspace = true } -@@ -43,11 +46,14 @@ +@@ -41,13 +44,17 @@ + media = { workspace = true } + net = { workspace = true } net_traits = { workspace = true } ++regex = { workspace = true } paint_api = { workspace = true } parking_lot = { workspace = true } +petname = "2.0" @@ -32,7 +35,7 @@ servo-background-hang-monitor = { workspace = true } servo-background-hang-monitor-api = { workspace = true } servo-base = { workspace = true } -@@ -61,6 +67,8 @@ +@@ -61,6 +68,8 @@ storage_traits = { workspace = true } stylo = { workspace = true } stylo_traits = { workspace = true } diff --git a/patches/components/constellation/constellation.rs.patch b/patches/components/constellation/constellation.rs.patch index b345bb0..d9c8664 100644 --- a/patches/components/constellation/constellation.rs.patch +++ b/patches/components/constellation/constellation.rs.patch @@ -8,7 +8,15 @@ use std::rc::{Rc, Weak}; use std::sync::Arc; use std::thread::JoinHandle; -@@ -107,12 +108,12 @@ +@@ -99,6 +100,7 @@ + BackgroundHangMonitorControlMsg, BackgroundHangMonitorRegister, HangMonitorAlert, + }; + use content_security_policy::sandboxing_directive::SandboxingFlagSet; ++use content_security_policy::url::Url; + use crossbeam_channel::{Receiver, Select, Sender, unbounded}; + use devtools_traits::{ + ChromeToDevtoolsControlMsg, DevtoolsControlMsg, DevtoolsPageInfo, NavigationState, +@@ -107,12 +109,12 @@ use embedder_traits::resources::{self, Resource}; use embedder_traits::user_contents::{UserContentManagerId, UserContents}; use embedder_traits::{ @@ -27,7 +35,7 @@ }; use euclid::Size2D; use euclid::default::Size2D as UntypedSize2D; -@@ -159,12 +160,14 @@ +@@ -159,12 +161,14 @@ use servo_canvas_traits::webgl::WebGLThreads; use servo_config::{opts, pref}; use servo_constellation_traits::{ @@ -48,7 +56,7 @@ }; use servo_url::{Host, ImmutableOrigin, ServoUrl}; use storage_traits::StorageThreads; -@@ -178,6 +181,7 @@ +@@ -178,6 +182,7 @@ use webgpu_traits::{WebGPU, WebGPURequest}; use super::embedder::ConstellationToEmbedderMsg; @@ -56,7 +64,7 @@ use crate::broadcastchannel::BroadcastChannels; use crate::browsingcontext::{ AllBrowsingContextsIterator, BrowsingContext, FullyActiveBrowsingContextsIterator, -@@ -185,6 +189,7 @@ +@@ -185,10 +190,12 @@ }; use crate::constellation_webview::ConstellationWebView; use crate::event_loop::EventLoop; @@ -64,17 +72,25 @@ use crate::pipeline::Pipeline; use crate::process_manager::ProcessManager; use crate::serviceworker::ServiceWorkerUnprivilegedContent; -@@ -213,6 +218,9 @@ + use crate::session_history::{NeedsToReload, SessionHistoryChange, SessionHistoryDiff}; ++use crate::tasks; + + type PendingApprovalNavigations = FxHashMap; + +@@ -213,6 +220,12 @@ /// While a completion failed, another global requested to complete the transfer. /// We are still buffering messages, and awaiting the return of the buffer from the global who failed. CompletionRequested(MessagePortRouterId, VecDeque), + /// The port is managed by a remote P2P peer. + /// Messages routed to this port are serialized and sent over the P2P link. + Remote(String), ++ /// The port is a virtual caller-side port for a web task delegation. ++ /// Messages routed to this port resolve the caller's promise via the stored callback. ++ Task(String), } #[derive(Debug)] -@@ -514,6 +522,25 @@ +@@ -514,6 +527,31 @@ /// to the `UserContents` need to be forwared to all the `ScriptThread`s that host /// the relevant `WebView`. pub(crate) user_contents_for_manager_id: FxHashMap, @@ -97,10 +113,16 @@ + + /// The main process side of the ATProdo DOM API. + at_proto: AtProtoManager, ++ ++ /// Registry of web task providers for the delegation system. ++ task_registry: tasks::TaskRegistry, ++ ++ /// Pending web task requests awaiting provider selection or result. ++ pending_task_requests: HashMap, } /// State needed to construct a constellation. -@@ -574,6 +601,9 @@ +@@ -574,6 +612,9 @@ /// The async runtime. pub async_runtime: Box, @@ -110,7 +132,7 @@ } /// When we are exiting a pipeline, we can either force exiting or not. A normal exit -@@ -683,7 +713,7 @@ +@@ -683,7 +724,7 @@ script_to_devtools_callback: Default::default(), #[cfg(feature = "bluetooth")] bluetooth_ipc_sender: state.bluetooth_thread, @@ -119,7 +141,7 @@ private_resource_threads: state.private_resource_threads, public_storage_threads: state.public_storage_threads, private_storage_threads: state.private_storage_threads, -@@ -735,6 +765,13 @@ +@@ -735,6 +776,15 @@ pending_viewport_changes: Default::default(), screenshot_readiness_requests: Vec::new(), user_contents_for_manager_id: Default::default(), @@ -130,10 +152,12 @@ + at_proto: AtProtoManager::new( + state.public_resource_threads.core_thread, + ), ++ task_registry: tasks::TaskRegistry::new(state.config_dir.as_deref()), ++ pending_task_requests: HashMap::new(), }; constellation.run(); -@@ -760,6 +797,18 @@ +@@ -760,6 +810,18 @@ fn clean_up_finished_script_event_loops(&mut self) { self.event_loop_join_handles .retain(|join_handle| !join_handle.is_finished()); @@ -152,7 +176,7 @@ self.event_loops .retain(|event_loop| event_loop.upgrade().is_some()); } -@@ -1052,6 +1101,11 @@ +@@ -1052,6 +1114,11 @@ .get(&webview_id) .and_then(|webview| webview.user_content_manager_id); @@ -164,7 +188,7 @@ let new_pipeline_info = NewPipelineInfo { parent_info: parent_pipeline_id, new_pipeline_id, -@@ -1062,6 +1116,13 @@ +@@ -1062,6 +1129,13 @@ viewport_details: initial_viewport_details, user_content_manager_id, theme, @@ -178,7 +202,7 @@ }; let pipeline = match Pipeline::spawn(new_pipeline_info, event_loop, self, throttled) { Ok(pipeline) => pipeline, -@@ -1228,6 +1289,7 @@ +@@ -1228,6 +1302,7 @@ BackgroundHangMonitor(HangMonitorAlert), Embedder(EmbedderToConstellationMessage), FromSWManager(SWManagerMsg), @@ -186,7 +210,7 @@ RemoveProcess(usize), } // Get one incoming request. -@@ -1248,6 +1310,15 @@ +@@ -1248,6 +1323,15 @@ sel.recv(&self.embedder_to_constellation_receiver); sel.recv(&self.swmanager_receiver); @@ -202,7 +226,7 @@ self.process_manager.register(&mut sel); let request = { -@@ -1276,9 +1347,13 @@ +@@ -1276,9 +1360,13 @@ .recv(&self.swmanager_receiver) .expect("Unexpected SW channel panic in constellation") .map(Request::FromSWManager), @@ -217,7 +241,7 @@ let _ = oper.recv(self.process_manager.receiver_at(process_index)); Ok(Request::RemoveProcess(process_index)) }, -@@ -1304,6 +1379,9 @@ +@@ -1304,6 +1392,9 @@ Request::FromSWManager(message) => { self.handle_request_from_swmanager(message); }, @@ -227,7 +251,7 @@ Request::RemoveProcess(index) => self.process_manager.remove(index), } } -@@ -1532,11 +1610,7 @@ +@@ -1532,11 +1623,7 @@ } }, EmbedderToConstellationMessage::PreferencesUpdated(updates) => { @@ -240,7 +264,7 @@ let _ = event_loop.send(ScriptThreadMessage::PreferencesUpdated( updates .iter() -@@ -1563,6 +1637,18 @@ +@@ -1563,6 +1650,18 @@ EmbedderToConstellationMessage::SetAccessibilityActive(webview_id, active) => { self.set_accessibility_active(webview_id, active); }, @@ -259,7 +283,7 @@ } } -@@ -1760,7 +1846,13 @@ +@@ -1760,7 +1859,13 @@ return warn!("Attempt to add channel name from an unexpected origin."); } self.broadcast_channels @@ -274,7 +298,7 @@ }, ScriptToConstellationMessage::RemoveBroadcastChannelNameInRouter( router_id, -@@ -1774,7 +1866,13 @@ +@@ -1774,7 +1879,13 @@ return warn!("Attempt to remove channel name from an unexpected origin."); } self.broadcast_channels @@ -289,7 +313,7 @@ }, ScriptToConstellationMessage::RemoveBroadcastChannelRouter(router_id, origin) => { if self -@@ -1786,6 +1884,12 @@ +@@ -1786,6 +1897,12 @@ self.broadcast_channels .remove_broadcast_channel_router(router_id); }, @@ -302,7 +326,7 @@ ScriptToConstellationMessage::ScheduleBroadcast(router_id, message) => { if self .check_origin_against_pipeline(&source_pipeline_id, &message.origin) -@@ -1795,8 +1899,15 @@ +@@ -1795,8 +1912,15 @@ "Attempt to schedule broadcast from an origin not matching the origin of the msg." ); } @@ -319,7 +343,7 @@ }, ScriptToConstellationMessage::PipelineExited => { self.handle_pipeline_exited(source_pipeline_id); -@@ -1816,6 +1927,12 @@ +@@ -1816,6 +1940,12 @@ ScriptToConstellationMessage::CreateAuxiliaryWebView(load_info) => { self.handle_script_new_auxiliary(load_info); }, @@ -332,7 +356,16 @@ ScriptToConstellationMessage::ChangeRunningAnimationsState(animation_state) => { self.handle_change_running_animations_state(source_pipeline_id, animation_state) }, -@@ -1989,6 +2106,29 @@ +@@ -1862,7 +1992,7 @@ + ScriptToConstellationMessage::SetFinalUrl(final_url) => { + // The script may have finished loading after we already started shutting down. + if let Some(ref mut pipeline) = self.pipelines.get_mut(&source_pipeline_id) { +- pipeline.url = final_url; ++ pipeline.url = final_url.clone(); + } else { + warn!("constellation got set final url message for dead pipeline"); + } +@@ -1989,6 +2119,29 @@ new_value, ); }, @@ -362,7 +395,7 @@ ScriptToConstellationMessage::MediaSessionEvent(pipeline_id, event) => { // Unlikely at this point, but we may receive events coming from // different media sessions, so we set the active media session based -@@ -2008,7 +2148,12 @@ +@@ -2008,7 +2161,12 @@ } self.active_media_session = Some(pipeline_id); self.constellation_to_embedder_proxy.send( @@ -376,7 +409,7 @@ ); }, #[cfg(feature = "webgpu")] -@@ -2063,9 +2208,412 @@ +@@ -2063,7 +2221,769 @@ let _ = event_loop.send(ScriptThreadMessage::TriggerGarbageCollection); } }, @@ -611,9 +644,221 @@ + ScriptToConstellationMessage::AtProto(request, response) => { + self.at_proto.process_request(request, response); + }, - } - } - ++ ScriptToConstellationMessage::RequestTask( ++ task_name, ++ caller_data_json, ++ _display, ++ data, ++ provider_port_id, ++ callback, ++ ) => { ++ debug!("RequestTask: {task_name}"); ++ ++ // Parse caller data for filter matching. ++ let caller_data: HashMap = ++ serde_json::from_str(&caller_data_json).unwrap_or_default(); ++ ++ // Look up matching providers in the registry. ++ let provider_infos = self ++ .task_registry ++ .get_provider_infos(&task_name, &caller_data); ++ let providers_json = ++ serde_json::to_string(&provider_infos).unwrap_or_else(|_| "[]".into()); ++ let request_id = format!("task-{webview_id:?}-{source_pipeline_id:?}"); ++ ++ // Register provider_port_id as a Task port in the constellation. ++ // When the provider posts on its entangled port, the message arrives here ++ // and gets routed through the callback to resolve the caller's promise. ++ self.message_ports.insert( ++ provider_port_id, ++ MessagePortInfo { ++ state: TransferState::Task(request_id.clone()), ++ entangled_with: None, ++ }, ++ ); ++ ++ // Store the pending request with data and callback. ++ self.pending_task_requests.insert( ++ request_id.clone(), ++ tasks::PendingTaskRequest { ++ task_name: task_name.clone(), ++ data, ++ callback, ++ provider: None, ++ provider_port_id, ++ provider_url: None, ++ dispatched: false, ++ provider_webview_id: None, ++ remote_caller_peer_id: None, ++ remote_providers: HashMap::new(), ++ }, ++ ); ++ ++ // Send to all script threads — the system UI will handle it ++ // via navigator.embedder.ontaskrequest. ++ for event_loop in self.event_loops() { ++ let _ = event_loop.send(ScriptThreadMessage::ShowTaskChooser( ++ request_id.clone(), ++ task_name.clone(), ++ providers_json.clone(), ++ )); ++ } ++ ++ // Broadcast TaskQuery to paired connected devices for remote provider discovery. ++ self.pairing.broadcast_message(&P2pMessage::TaskQuery { ++ request_id: request_id.clone(), ++ task_name: task_name.clone(), ++ caller_data_json: caller_data_json.clone(), ++ }); ++ }, ++ ScriptToConstellationMessage::RegisterTaskProvider( ++ task_name, ++ title, ++ description, ++ href, ++ filters_json, ++ returns_type, ++ display_mode, ++ icon, ++ ) => { ++ debug!("RegisterTaskProvider: {task_name} -> {href}"); ++ let display = match display_mode.as_str() { ++ "inline" => tasks::TaskDisplayMode::Inline, ++ _ => tasks::TaskDisplayMode::Window, ++ }; ++ let filters = serde_json::from_str(&filters_json).unwrap_or_default(); ++ if let Ok(url) = ServoUrl::parse(&href) { ++ self.task_registry.register(tasks::TaskProvider { ++ task_name, ++ title, ++ description, ++ href: url, ++ filters, ++ returns_type, ++ display, ++ icon, ++ }); ++ } ++ }, ++ ScriptToConstellationMessage::TaskProviderSelected(request_id, provider_id) => { ++ debug!("TaskProviderSelected: {request_id} -> {provider_id:?}"); ++ ++ let Some(mut pending) = self.pending_task_requests.remove(&request_id) else { ++ warn!("TaskProviderSelected: unknown request {request_id}"); ++ return; ++ }; ++ ++ let Some(provider_id) = provider_id else { ++ // User cancelled. ++ let _ = pending.callback.send(Err("Task cancelled".into())); ++ return; ++ }; ++ ++ // Check if this is a remote provider (ID starts with "remote:{peer_id}:"). ++ if provider_id.starts_with("remote:") { ++ let parts: Vec<&str> = provider_id.splitn(3, ':').collect(); ++ if parts.len() >= 3 { ++ let peer_id = parts[1]; ++ ++ // Look up the remote provider's href from stored info. ++ let provider_href = pending ++ .remote_providers ++ .get(&provider_id) ++ .map(|p| p.href.clone()) ++ .unwrap_or_default(); ++ ++ // Serialize the caller's data for P2P transfer. ++ let caller_data = pending ++ .data ++ .as_ref() ++ .and_then(|d| postcard::to_allocvec(d).ok()) ++ .unwrap_or_default(); ++ ++ self.pairing.send_message( ++ peer_id, ++ &P2pMessage::TaskExecute { ++ request_id: request_id.clone(), ++ task_name: pending.task_name.clone(), ++ provider_href, ++ caller_data, ++ }, ++ ); ++ ++ // Keep the pending request for when TaskResult arrives. ++ self.pending_task_requests ++ .insert(request_id.clone(), pending); ++ } ++ return; ++ } ++ ++ // Resolve the provider ID to a TaskProvider. ++ let Some(provider) = self.task_registry.resolve_provider(&provider_id).cloned() ++ else { ++ let _ = pending ++ .callback ++ .send(Err(format!("Unknown provider: {provider_id}"))); ++ return; ++ }; ++ ++ // Store the pending request back (with provider info) for when ++ // the provider webview opens and later completes. ++ pending.provider = Some(provider.clone()); ++ ++ // Build the provider URL with the request ID as a query param ++ // so the provider pipeline can be correlated. ++ let mut provider_url = provider.href.clone().into_url(); ++ provider_url ++ .query_pairs_mut() ++ .append_pair("taskRequestId", &request_id); ++ ++ pending.provider_url = Some(provider_url.to_string()); ++ self.pending_task_requests ++ .insert(request_id.clone(), pending); ++ ++ // Tell the system UI to open the provider webview. ++ for event_loop in self.event_loops() { ++ let _ = event_loop.send(ScriptThreadMessage::OpenTaskProvider( ++ request_id.clone(), ++ provider_url.to_string(), ++ provider.title.clone(), ++ )); ++ } ++ }, ++ ScriptToConstellationMessage::AcceptTask(callback) => { ++ // Find a pending task request whose provider URL matches this pipeline's URL. ++ let pipeline_url = self ++ .pipelines ++ .get(&source_pipeline_id) ++ .map(|p| p.url.as_str().to_string()); ++ ++ let matching_request = pipeline_url.and_then(|url| { ++ self.pending_task_requests ++ .iter() ++ .find(|(_, p)| p.provider_url.as_deref() == Some(&url) && !p.dispatched) ++ .map(|(id, _)| id.clone()) ++ }); ++ ++ if let Some(request_id) = matching_request { ++ if let Some(pending) = self.pending_task_requests.get_mut(&request_id) { ++ pending.dispatched = true; ++ pending.provider_webview_id = Some(webview_id); ++ let port_id_bytes = ++ postcard::to_allocvec(&pending.provider_port_id).unwrap_or_default(); ++ let task_name = pending.task_name.clone(); ++ let data = pending.data.take(); ++ ++ let _ = callback.send(Some((task_name, data, port_id_bytes))); ++ } else { ++ let _ = callback.send(None); ++ } ++ } else { ++ // No matching task request — this page is not a task provider. ++ let _ = callback.send(None); ++ } ++ }, ++ } ++ } ++ + fn handle_pairing_event(&mut self, event: PairingEvent) { + if let PairingEvent::MessageReceived { ref from, ref data } = event { + debug!("P2P message received from {from}, {} bytes", data.len()); @@ -749,6 +994,153 @@ + self.message_ports.remove(&port_id); + } + }, ++ P2pMessage::TaskQuery { ++ ref request_id, ++ ref task_name, ++ ref caller_data_json, ++ } => { ++ // Remote device is asking if we have matching providers. ++ let caller_data: HashMap = ++ serde_json::from_str(caller_data_json).unwrap_or_default(); ++ let provider_infos = self ++ .task_registry ++ .get_provider_infos(task_name, &caller_data); ++ let providers_json = ++ serde_json::to_string(&provider_infos).unwrap_or_else(|_| "[]".into()); ++ let device_name = self.pairing.get_local_name_sync(); ++ self.pairing.send_message( ++ &from, ++ &P2pMessage::TaskQueryResponse { ++ request_id: request_id.clone(), ++ providers_json, ++ device_name, ++ }, ++ ); ++ }, ++ P2pMessage::TaskQueryResponse { ++ ref request_id, ++ ref providers_json, ++ ref device_name, ++ } => { ++ // Remote device responded with matching providers. ++ // Parse and send update to the system UI. ++ if let Ok(mut remote_providers) = ++ serde_json::from_str::>(providers_json) ++ { ++ // Tag each provider with the remote device info. ++ for p in &mut remote_providers { ++ p.device_name = Some(device_name.clone()); ++ p.remote_peer_id = Some(from.clone()); ++ // Prefix the ID to avoid collisions with local providers. ++ p.id = format!("remote:{}:{}", from, p.id); ++ } ++ if !remote_providers.is_empty() { ++ // Store remote providers for later lookup when user selects one. ++ if let Some(pending) = ++ self.pending_task_requests.get_mut(request_id) ++ { ++ for p in &remote_providers { ++ pending.remote_providers.insert(p.id.clone(), p.clone()); ++ } ++ } ++ ++ let update_json = serde_json::to_string(&remote_providers) ++ .unwrap_or_else(|_| "[]".into()); ++ // Send update to system UI via ScriptThreadMessage. ++ for event_loop in self.event_loops() { ++ let _ = ++ event_loop.send(ScriptThreadMessage::TaskProvidersUpdate( ++ request_id.clone(), ++ update_json.clone(), ++ )); ++ } ++ } ++ } ++ }, ++ P2pMessage::TaskExecute { ++ ref request_id, ++ ref task_name, ++ ref provider_href, ++ ref caller_data, ++ } => { ++ // Remote device wants us to execute a task. ++ debug!("TaskExecute from {from}: {task_name} -> {provider_href}"); ++ let data: Option = ++ postcard::from_bytes(caller_data).ok(); ++ let provider_port_id = MessagePortId::new(); ++ ++ // Register the provider port as a remote-task port. ++ // When the provider posts a result, we'll send it back to the caller. ++ self.message_ports.insert( ++ provider_port_id, ++ MessagePortInfo { ++ state: TransferState::Task(request_id.clone()), ++ entangled_with: None, ++ }, ++ ); ++ ++ // Create a no-op callback — the actual result will be sent ++ // back via P2P in the TransferState::Task handler. ++ let noop_callback = GenericCallback::new( ++ move |_: Result, _>| {}, ++ ) ++ .expect("Could not create callback"); ++ ++ // Store the pending request with the remote caller's peer ID. ++ let mut provider_url = Url::parse(provider_href) ++ .unwrap_or_else(|_| Url::parse("about:blank").unwrap()); ++ provider_url ++ .query_pairs_mut() ++ .append_pair("taskRequestId", request_id); ++ ++ self.pending_task_requests.insert( ++ request_id.clone(), ++ tasks::PendingTaskRequest { ++ task_name: task_name.clone(), ++ data, ++ callback: noop_callback, ++ provider: None, ++ provider_port_id, ++ provider_url: Some(provider_url.to_string()), ++ dispatched: false, ++ provider_webview_id: None, ++ remote_caller_peer_id: Some(from.clone()), ++ remote_providers: HashMap::new(), ++ }, ++ ); ++ ++ // Tell the system UI to open the provider webview. ++ for event_loop in self.event_loops() { ++ let _ = event_loop.send(ScriptThreadMessage::OpenTaskProvider( ++ request_id.clone(), ++ provider_url.to_string(), ++ task_name.clone(), ++ )); ++ } ++ }, ++ P2pMessage::TaskResult { ++ ref request_id, ++ success, ++ ref result_data, ++ } => { ++ // Remote device returned a task result. ++ debug!("TaskResult from {from}: {request_id} success={success}"); ++ if let Some(pending) = self.pending_task_requests.remove(request_id) { ++ if success { ++ if let Ok(data) = ++ postcard::from_bytes::(result_data) ++ { ++ let _ = pending.callback.send(Ok(data)); ++ } else { ++ let _ = pending.callback.send(Err( ++ "Failed to deserialize remote task result".into(), ++ )); ++ } ++ } else { ++ let _ = pending.callback.send(Err("Remote task cancelled".into())); ++ } ++ } ++ }, + _ => {}, + } + } @@ -758,7 +1150,7 @@ + // Handle peer disconnect: clean up remote channel state. + if let PairingEvent::PeerExpired { ref id } = event { + self.pairing.clear_remote_peer(id); -+ } + } + + // When a peer connects or reconnects, sync our open broadcast channels to it. + if let PairingEvent::PeerDiscovered { ref id, .. } | @@ -784,12 +1176,10 @@ + let _ = event_loop.send(ScriptThreadMessage::DispatchPairingEvent(event.clone())); + } + } -+ } -+ + } + /// Check the origin of a message against that of the pipeline it came from. - /// Note: this is still limited as a security check, - /// see -@@ -2382,6 +2930,29 @@ +@@ -2382,6 +3302,55 @@ TransferState::TransferInProgress(queue) => queue.push_back(task), TransferState::CompletionFailed(queue) => queue.push_back(task), TransferState::CompletionRequested(_, queue) => queue.push_back(task), @@ -815,15 +1205,68 @@ + warn!("Failed to serialize PortMessageTask for remote port: {err}"); + }, + } ++ }, ++ TransferState::Task(request_id) => { ++ // The provider posted a result on its port. ++ let request_id = request_id.clone(); ++ if let Some(pending) = self.pending_task_requests.remove(&request_id) { ++ if let Some(ref remote_peer_id) = pending.remote_caller_peer_id { ++ // Remote task: send result back via P2P. ++ let result_data = postcard::to_allocvec(&task.data).unwrap_or_default(); ++ self.pairing.send_message( ++ remote_peer_id, ++ &P2pMessage::TaskResult { ++ request_id: request_id.clone(), ++ success: true, ++ result_data, ++ }, ++ ); ++ } else { ++ // Local task: resolve the caller's promise directly. ++ let _ = pending.callback.send(Ok(task.data)); ++ } ++ ++ // Close the provider webview. ++ if let Some(provider_webview_id) = pending.provider_webview_id { ++ self.handle_close_top_level_browsing_context(provider_webview_id); ++ } ++ } + }, } } -@@ -3273,6 +3844,13 @@ +@@ -3273,6 +4242,40 @@ /// fn handle_close_top_level_browsing_context(&mut self, webview_id: WebViewId) { debug!("{webview_id}: Closing"); + ++ // If this is a task provider webview, reject the caller's promise. ++ let task_request_id = self ++ .pending_task_requests ++ .iter() ++ .find(|(_, p)| p.provider_webview_id == Some(webview_id)) ++ .map(|(id, _)| id.clone()); ++ if let Some(request_id) = task_request_id { ++ if let Some(pending) = self.pending_task_requests.remove(&request_id) { ++ if let Some(ref remote_peer_id) = pending.remote_caller_peer_id { ++ // Remote task: send cancellation back via P2P. ++ self.pairing.send_message( ++ remote_peer_id, ++ &P2pMessage::TaskResult { ++ request_id: request_id.clone(), ++ success: false, ++ result_data: vec![], ++ }, ++ ); ++ } else { ++ // Local task: reject the caller's promise. ++ let _ = pending ++ .callback ++ .send(Err("Task provider closed without completing".into())); ++ } ++ } ++ } ++ + // Notify embedded webview parent before closing (if this is an embedded webview) + self.handle_embedded_webview_notification(webview_id, EmbeddedWebViewEventType::Closed); + @@ -833,7 +1276,7 @@ let browsing_context_id = BrowsingContextId::from(webview_id); // Step 5. Remove traversable from the user agent's top-level traversable set. let browsing_context = -@@ -3547,8 +4125,27 @@ +@@ -3547,8 +4550,27 @@ opener_webview_id, opener_pipeline_id, response_sender, @@ -861,7 +1304,7 @@ let Some((webview_id_sender, webview_id_receiver)) = generic_channel::channel() else { warn!("Failed to create channel"); let _ = response_sender.send(None); -@@ -3649,6 +4246,397 @@ +@@ -3649,6 +4671,397 @@ }); } @@ -1259,7 +1702,7 @@ #[servo_tracing::instrument(skip_all)] fn handle_refresh_cursor(&self, pipeline_id: PipelineId) { let Some(pipeline) = self.pipelines.get(&pipeline_id) else { -@@ -4776,7 +5764,7 @@ +@@ -4776,7 +6189,7 @@ } #[servo_tracing::instrument(skip_all)] @@ -1268,7 +1711,7 @@ // Send a flat projection of the history to embedder. // The final vector is a concatenation of the URLs of the past // entries, the current entry and the future entries. -@@ -4880,9 +5868,22 @@ +@@ -4880,9 +6293,22 @@ self.constellation_to_embedder_proxy .send(ConstellationToEmbedderMsg::HistoryChanged( webview_id, diff --git a/patches/components/constellation/lib.rs.patch b/patches/components/constellation/lib.rs.patch index ac7ccd8..674adc3 100644 --- a/patches/components/constellation/lib.rs.patch +++ b/patches/components/constellation/lib.rs.patch @@ -8,7 +8,7 @@ mod broadcastchannel; mod browsingcontext; mod constellation; -@@ -14,6 +15,7 @@ +@@ -14,11 +15,13 @@ mod embedder; mod event_loop; mod logging; @@ -16,3 +16,9 @@ mod pipeline; mod process_manager; mod sandboxing; + mod serviceworker; + mod session_history; ++mod tasks; + + pub use crate::constellation::{Constellation, InitialConstellationState}; + pub use crate::embedder::ConstellationToEmbedderMsg; diff --git a/patches/components/constellation/pairing.rs.patch b/patches/components/constellation/pairing.rs.patch index 76dfe3f..0df2241 100644 --- a/patches/components/constellation/pairing.rs.patch +++ b/patches/components/constellation/pairing.rs.patch @@ -1,6 +1,6 @@ --- original +++ modified -@@ -0,0 +1,816 @@ +@@ -0,0 +1,862 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +//! P2P pairing service integration with the constellation. @@ -64,6 +64,33 @@ + }, + /// Deny a port offer — the remote side refused the stream. + PortOfferDenied { stream_id: String }, ++ ++ // Web Tasks P2P messages ++ /// Query remote device for matching task providers. ++ TaskQuery { ++ request_id: String, ++ task_name: String, ++ caller_data_json: String, ++ }, ++ /// Response with matching providers from the remote device. ++ TaskQueryResponse { ++ request_id: String, ++ providers_json: String, ++ device_name: String, ++ }, ++ /// Request remote device to execute a task with a specific provider. ++ TaskExecute { ++ request_id: String, ++ task_name: String, ++ provider_href: String, ++ caller_data: Vec, ++ }, ++ /// Result from a remotely executed task. ++ TaskResult { ++ request_id: String, ++ success: bool, ++ result_data: Vec, ++ }, +} + +impl P2pMessage { @@ -99,6 +126,18 @@ + } + } + ++ /// Get the local device name synchronously (best effort). ++ pub(crate) fn get_local_name_sync(&self) -> String { ++ let local_info = self.local_info.clone(); ++ match local_info.try_lock() { ++ Ok(guard) => guard ++ .as_ref() ++ .map(|info| info.name.clone()) ++ .unwrap_or_else(|| "Unknown device".to_string()), ++ Err(_) => "Unknown device".to_string(), ++ } ++ } ++ + /// Returns the event receiver for use in the constellation's Select loop. + pub(crate) fn event_receiver(&self) -> Option<&crossbeam_channel::Receiver> { + self.event_receiver.as_ref() @@ -624,6 +663,13 @@ + // Return to constellation for port routing. + Some((from.to_owned(), message)) + }, ++ P2pMessage::TaskQuery { .. } | ++ P2pMessage::TaskQueryResponse { .. } | ++ P2pMessage::TaskExecute { .. } | ++ P2pMessage::TaskResult { .. } => { ++ // Return to constellation for task delegation handling. ++ Some((from.to_owned(), message)) ++ }, + } + } + diff --git a/patches/components/constellation/tasks.rs.patch b/patches/components/constellation/tasks.rs.patch new file mode 100644 index 0000000..eda573e --- /dev/null +++ b/patches/components/constellation/tasks.rs.patch @@ -0,0 +1,518 @@ +--- original ++++ modified +@@ -0,0 +1,515 @@ ++// SPDX-License-Identifier: AGPL-3.0-or-later ++ ++//! Task provider registry for the Web Tasks delegation system. ++//! ++//! The constellation maintains a registry of task providers. Providers can be ++//! registered by privileged system pages or discovered from web page meta tags. ++ ++use std::collections::HashMap; ++use std::fs; ++use std::path::{Path, PathBuf}; ++ ++use log::{debug, error}; ++use serde::{Deserialize, Serialize}; ++use servo_base::generic_channel::GenericCallback; ++use servo_base::id::{MessagePortId, WebViewId}; ++use servo_constellation_traits::StructuredSerializedData; ++use servo_url::ServoUrl; ++ ++/// How a task provider should be displayed when launched. ++#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)] ++pub enum TaskDisplayMode { ++ /// Shown as a webview overlay on top of the caller's webview. ++ Inline, ++ /// Opened in a new webview (full navigation). ++ Window, ++} ++ ++/// A filter value for task provider matching. ++/// Follows the MozActivities filter spec. ++#[derive(Clone, Debug, Deserialize, Serialize)] ++#[serde(untagged)] ++pub enum FilterValue { ++ /// A basic value: optional field, must equal this if present. ++ String(String), ++ /// A numeric value. ++ Number(f64), ++ /// An array of allowed values: optional field, must equal one if present. ++ StringArray(Vec), ++ /// A filter definition object with detailed matching rules. ++ Object(FilterObject), ++} ++ ++/// Detailed filter rules for a single field. ++#[derive(Clone, Debug, Deserialize, Serialize)] ++pub struct FilterObject { ++ /// If true, the field must exist in the caller's data. ++ #[serde(default)] ++ pub required: bool, ++ /// Allowed value(s). Can be a single value or array. ++ #[serde(default)] ++ pub value: Option, ++ /// Minimum numeric value (inclusive). ++ #[serde(default)] ++ pub min: Option, ++ /// Maximum numeric value (inclusive). ++ #[serde(default)] ++ pub max: Option, ++ /// Regex pattern the value must match. ++ #[serde(default)] ++ pub pattern: Option, ++ /// Regex flags (e.g., "i" for case-insensitive). ++ #[serde(default, rename = "patternFlags")] ++ pub pattern_flags: Option, ++} ++ ++/// Allowed values: a single value or an array. ++#[derive(Clone, Debug, Deserialize, Serialize)] ++#[serde(untagged)] ++pub enum FilterAllowedValues { ++ Single(String), ++ Multiple(Vec), ++} ++ ++/// A registered task provider. ++#[derive(Clone, Debug, Deserialize, Serialize)] ++pub struct TaskProvider { ++ /// The task name this provider handles (e.g., "pick-image"). ++ pub task_name: String, ++ /// Human-readable title shown in the chooser. ++ pub title: String, ++ /// Optional description for the chooser. ++ pub description: Option, ++ /// URL of the handler page. ++ pub href: ServoUrl, ++ /// Filters for matching. Keys are field names from the caller's data. ++ pub filters: HashMap, ++ /// What the provider returns: "blob", "text", "json", or "none". ++ pub returns_type: Option, ++ /// How the provider should be displayed. ++ pub display: TaskDisplayMode, ++ /// Optional icon as a data: URL (base64 encoded). ++ pub icon: Option, ++} ++ ++/// Serializable provider info sent to the embedder for the chooser UI. ++#[derive(Clone, Debug, Deserialize, Serialize)] ++pub struct TaskProviderInfo { ++ pub id: String, ++ pub title: String, ++ pub description: Option, ++ pub display: TaskDisplayMode, ++ pub icon: Option, ++ pub origin: String, ++ pub href: String, ++ pub device_name: Option, ++ pub remote_peer_id: Option, ++} ++ ++/// Registry of all known task providers. ++pub struct TaskRegistry { ++ /// Map from task name → list of providers. ++ providers: HashMap>, ++ /// Path to persist providers. ++ config_path: Option, ++} ++ ++impl Default for TaskRegistry { ++ fn default() -> Self { ++ Self { ++ providers: HashMap::new(), ++ config_path: None, ++ } ++ } ++} ++ ++impl TaskRegistry { ++ pub fn new(config_dir: Option<&Path>) -> Self { ++ let mut registry = Self { ++ providers: HashMap::new(), ++ config_path: config_dir.map(|d| d.join("task-providers.json")), ++ }; ++ registry.load(); ++ registry ++ } ++ ++ /// Register a new task provider. Uses (task_name, href) as the dedup key. ++ /// Re-registering with the same key updates the other fields. ++ pub fn register(&mut self, provider: TaskProvider) { ++ let providers = self ++ .providers ++ .entry(provider.task_name.clone()) ++ .or_default(); ++ let href_str = provider.href.as_str(); ++ ++ // Check for existing provider with same href. ++ if let Some(existing) = providers.iter_mut().find(|p| p.href.as_str() == href_str) { ++ // Update existing registration. ++ existing.title = provider.title; ++ existing.description = provider.description; ++ existing.filters = provider.filters; ++ existing.returns_type = provider.returns_type; ++ existing.display = provider.display; ++ existing.icon = provider.icon; ++ debug!( ++ "Updated task provider: {} -> {}", ++ existing.task_name, href_str ++ ); ++ } else { ++ debug!( ++ "Registered task provider: {} -> {}", ++ provider.task_name, href_str ++ ); ++ providers.push(provider); ++ } ++ ++ self.save(); ++ } ++ ++ /// Find all providers matching a task name and caller data. ++ /// The caller_data is a map of field names to values for filter matching. ++ pub fn find_providers( ++ &self, ++ task_name: &str, ++ caller_data: &HashMap, ++ ) -> Vec<&TaskProvider> { ++ let Some(providers) = self.providers.get(task_name) else { ++ return vec![]; ++ }; ++ ++ providers ++ .iter() ++ .filter(|p| filters_match(&p.filters, caller_data)) ++ .collect() ++ } ++ ++ /// Get provider info suitable for sending to the embedder (chooser UI). ++ pub fn get_provider_infos( ++ &self, ++ task_name: &str, ++ caller_data: &HashMap, ++ ) -> Vec { ++ self.find_providers(task_name, caller_data) ++ .iter() ++ .enumerate() ++ .map(|(i, p)| { ++ let origin = p.href.origin().ascii_serialization(); ++ TaskProviderInfo { ++ id: format!("{}:{}", task_name, i), ++ title: p.title.clone(), ++ description: p.description.clone(), ++ display: p.display.clone(), ++ icon: p.icon.clone(), ++ origin, ++ href: p.href.as_str().to_string(), ++ device_name: None, ++ remote_peer_id: None, ++ } ++ }) ++ .collect() ++ } ++ ++ /// Resolve a provider ID (e.g., "share:0") to the actual TaskProvider. ++ pub fn resolve_provider(&self, provider_id: &str) -> Option<&TaskProvider> { ++ let (task_name, index_str) = provider_id.rsplit_once(':')?; ++ let index: usize = index_str.parse().ok()?; ++ let providers = self.providers.get(task_name)?; ++ providers.get(index) ++ } ++ ++ /// Save all providers to disk. ++ fn save(&self) { ++ let Some(path) = &self.config_path else { ++ return; ++ }; ++ // Collect all providers into a flat list for serialization. ++ let all_providers: Vec<&TaskProvider> = ++ self.providers.values().flat_map(|v| v.iter()).collect(); ++ match serde_json::to_string_pretty(&all_providers) { ++ Ok(json) => { ++ if let Err(e) = fs::write(path, json) { ++ error!("Failed to save task providers: {e}"); ++ } ++ }, ++ Err(e) => error!("Failed to serialize task providers: {e}"), ++ } ++ } ++ ++ /// Load providers from disk. ++ fn load(&mut self) { ++ let Some(path) = &self.config_path else { ++ return; ++ }; ++ let Ok(json) = fs::read_to_string(path) else { ++ return; // File doesn't exist yet, that's fine. ++ }; ++ match serde_json::from_str::>(&json) { ++ Ok(providers) => { ++ for provider in providers { ++ self.providers ++ .entry(provider.task_name.clone()) ++ .or_default() ++ .push(provider); ++ } ++ debug!( ++ "Loaded {} task providers from {}", ++ self.providers.values().map(|v| v.len()).sum::(), ++ path.display() ++ ); ++ }, ++ Err(e) => error!( ++ "Failed to parse task providers from {}: {e}", ++ path.display() ++ ), ++ } ++ } ++} ++ ++/// A pending task request waiting for provider selection or result. ++pub struct PendingTaskRequest { ++ /// The task name. ++ pub task_name: String, ++ /// The caller's data (structured clone serialized). ++ pub data: Option, ++ /// Callback to resolve the caller's promise with result data or error. ++ pub callback: GenericCallback>, ++ /// The selected provider (set after chooser selection). ++ pub provider: Option, ++ /// The virtual port ID for the provider side of the MessagePort pair. ++ pub provider_port_id: MessagePortId, ++ /// The URL the provider was opened with (set after provider selection). ++ /// Used to match new pipelines to pending task requests. ++ pub provider_url: Option, ++ /// Whether the task data has been dispatched to the provider. ++ pub dispatched: bool, ++ /// The webview ID of the provider (set when the provider calls acceptTask). ++ pub provider_webview_id: Option, ++ /// If this is a remote task, the peer ID of the requesting device. ++ /// When set, the result is sent back via P2P instead of resolving a local callback. ++ pub remote_caller_peer_id: Option, ++ /// Remote providers discovered via P2P, keyed by provider ID. ++ pub remote_providers: HashMap, ++} ++ ++/// Check if all provider filters are satisfied by the caller's data. ++/// A provider with no filters matches everything. ++fn filters_match( ++ filters: &HashMap, ++ caller_data: &HashMap, ++) -> bool { ++ if filters.is_empty() { ++ return true; ++ } ++ ++ for (field_name, filter) in filters { ++ let caller_value = caller_data.get(field_name); ++ ++ match filter { ++ FilterValue::String(expected) => { ++ // Optional field, but if present must equal expected. ++ if let Some(val) = caller_value { ++ if val.as_str() != Some(expected.as_str()) { ++ return false; ++ } ++ } ++ }, ++ FilterValue::Number(expected) => { ++ if let Some(val) = caller_value { ++ if val.as_f64() != Some(*expected) { ++ return false; ++ } ++ } ++ }, ++ FilterValue::StringArray(allowed) => { ++ // Optional field, but if present must be one of allowed values. ++ if let Some(val) = caller_value { ++ let val_str = val.as_str().unwrap_or_default(); ++ if !allowed.iter().any(|a| a == val_str) { ++ return false; ++ } ++ } ++ }, ++ FilterValue::Object(obj) => { ++ if !filter_object_matches(obj, caller_value) { ++ return false; ++ } ++ }, ++ } ++ } ++ ++ true ++} ++ ++/// Check if a filter definition object matches a caller's value. ++fn filter_object_matches(filter: &FilterObject, caller_value: Option<&serde_json::Value>) -> bool { ++ // Check required. ++ if filter.required && caller_value.is_none() { ++ return false; ++ } ++ ++ let Some(val) = caller_value else { ++ // Not required and not present — passes. ++ return true; ++ }; ++ ++ // Check value constraint. ++ if let Some(allowed) = &filter.value { ++ let val_str = val.as_str().unwrap_or_default(); ++ let matches = match allowed { ++ FilterAllowedValues::Single(s) => val_str == s, ++ FilterAllowedValues::Multiple(arr) => arr.iter().any(|a| a == val_str), ++ }; ++ if !matches { ++ return false; ++ } ++ } ++ ++ // Check min/max for numeric values. ++ if let Some(val_num) = val.as_f64() { ++ if let Some(min) = filter.min { ++ if val_num < min { ++ return false; ++ } ++ } ++ if let Some(max) = filter.max { ++ if val_num > max { ++ return false; ++ } ++ } ++ } ++ ++ // Check pattern. ++ if let Some(pattern) = &filter.pattern { ++ let val_str = val.as_str().unwrap_or_default(); ++ let flags = filter.pattern_flags.as_deref().unwrap_or(""); ++ let regex_str = if flags.contains('i') { ++ format!("(?i){pattern}") ++ } else { ++ pattern.clone() ++ }; ++ if let Ok(re) = regex::Regex::new(®ex_str) { ++ if !re.is_match(val_str) { ++ return false; ++ } ++ } ++ } ++ ++ true ++} ++ ++#[cfg(test)] ++mod tests { ++ use super::*; ++ ++ #[test] ++ fn test_empty_filters_match_everything() { ++ let filters = HashMap::new(); ++ let data = HashMap::new(); ++ assert!(filters_match(&filters, &data)); ++ } ++ ++ #[test] ++ fn test_string_filter() { ++ let mut filters = HashMap::new(); ++ filters.insert("type".into(), FilterValue::String("url".into())); ++ ++ let mut data = HashMap::new(); ++ data.insert("type".into(), serde_json::json!("url")); ++ assert!(filters_match(&filters, &data)); ++ ++ data.insert("type".into(), serde_json::json!("text")); ++ assert!(!filters_match(&filters, &data)); ++ ++ // Field not present — passes (optional). ++ let empty_data = HashMap::new(); ++ assert!(filters_match(&filters, &empty_data)); ++ } ++ ++ #[test] ++ fn test_array_filter() { ++ let mut filters = HashMap::new(); ++ filters.insert( ++ "type".into(), ++ FilterValue::StringArray(vec!["url".into(), "text".into()]), ++ ); ++ ++ let mut data = HashMap::new(); ++ data.insert("type".into(), serde_json::json!("url")); ++ assert!(filters_match(&filters, &data)); ++ ++ data.insert("type".into(), serde_json::json!("image")); ++ assert!(!filters_match(&filters, &data)); ++ } ++ ++ #[test] ++ fn test_required_filter() { ++ let mut filters = HashMap::new(); ++ filters.insert( ++ "url".into(), ++ FilterValue::Object(FilterObject { ++ required: true, ++ value: None, ++ min: None, ++ max: None, ++ pattern: None, ++ pattern_flags: None, ++ }), ++ ); ++ ++ let empty_data = HashMap::new(); ++ assert!(!filters_match(&filters, &empty_data)); ++ ++ let mut data = HashMap::new(); ++ data.insert("url".into(), serde_json::json!("https://example.com")); ++ assert!(filters_match(&filters, &data)); ++ } ++ ++ #[test] ++ fn test_pattern_filter() { ++ let mut filters = HashMap::new(); ++ filters.insert( ++ "url".into(), ++ FilterValue::Object(FilterObject { ++ required: true, ++ value: None, ++ min: None, ++ max: None, ++ pattern: Some("^https://".into()), ++ pattern_flags: None, ++ }), ++ ); ++ ++ let mut data = HashMap::new(); ++ data.insert("url".into(), serde_json::json!("https://example.com")); ++ assert!(filters_match(&filters, &data)); ++ ++ data.insert("url".into(), serde_json::json!("http://example.com")); ++ assert!(!filters_match(&filters, &data)); ++ } ++ ++ #[test] ++ fn test_min_max_filter() { ++ let mut filters = HashMap::new(); ++ filters.insert( ++ "width".into(), ++ FilterValue::Object(FilterObject { ++ required: false, ++ value: None, ++ min: Some(100.0), ++ max: Some(1000.0), ++ pattern: None, ++ pattern_flags: None, ++ }), ++ ); ++ ++ let mut data = HashMap::new(); ++ data.insert("width".into(), serde_json::json!(500)); ++ assert!(filters_match(&filters, &data)); ++ ++ data.insert("width".into(), serde_json::json!(50)); ++ assert!(!filters_match(&filters, &data)); ++ ++ data.insert("width".into(), serde_json::json!(1500)); ++ assert!(!filters_match(&filters, &data)); ++ } ++} diff --git a/patches/components/constellation/tracing.rs.patch b/patches/components/constellation/tracing.rs.patch index 579742f..cfbebc4 100644 --- a/patches/components/constellation/tracing.rs.patch +++ b/patches/components/constellation/tracing.rs.patch @@ -36,7 +36,7 @@ Self::ActivateDocument => target!("ActivateDocument"), Self::SetDocumentState(..) => target!("SetDocumentState"), Self::SetFinalUrl(..) => target!("SetFinalUrl"), -@@ -187,6 +195,50 @@ +@@ -187,6 +195,54 @@ target!("RespondToScreenshotReadinessRequest") }, Self::TriggerGarbageCollection => target!("TriggerGarbageCollection"), @@ -84,6 +84,10 @@ + Self::CreatePeerStream(..) => target!("CreatePeerStream"), + Self::PeerStreamResponse(..) => target!("PeerStreamResponse"), + Self::AtProto(..) => target!("AtProto"), ++ Self::RequestTask(..) => target!("RequestTask"), ++ Self::RegisterTaskProvider(..) => target!("RegisterTaskProvider"), ++ Self::TaskProviderSelected(..) => target!("TaskProviderSelected"), ++ Self::AcceptTask(..) => target!("AcceptTask"), } } } diff --git a/patches/components/devtools/lib.rs.patch b/patches/components/devtools/lib.rs.patch new file mode 100644 index 0000000..498f348 --- /dev/null +++ b/patches/components/devtools/lib.rs.patch @@ -0,0 +1,19 @@ +--- original ++++ modified +@@ -237,11 +237,11 @@ + continue; + }; + // connection succeeded and accepted +- sender +- .send(DevtoolsControlMsg::FromChrome( +- ChromeToDevtoolsControlMsg::AddClient(stream), +- )) +- .unwrap(); ++ if let Err(err) = sender.send(DevtoolsControlMsg::FromChrome( ++ ChromeToDevtoolsControlMsg::AddClient(stream), ++ )) { ++ eprintln!("Failed to send new client message to devtools control: {err}"); ++ } + } + }) + .expect("Thread spawning failed"); diff --git a/patches/components/script/dom/embedder.rs.patch b/patches/components/script/dom/embedder.rs.patch index 1179d75..7ade181 100644 --- a/patches/components/script/dom/embedder.rs.patch +++ b/patches/components/script/dom/embedder.rs.patch @@ -1,6 +1,6 @@ --- original +++ modified -@@ -0,0 +1,277 @@ +@@ -0,0 +1,512 @@ +/* SPDX Id: AGPL-3.0-or-later */ + +//! The `Embedder` interface provides communication between web content and the embedder. @@ -24,7 +24,9 @@ +use servo_url::ServoUrl; + +use crate::dom::bindings::codegen::Bindings::CustomEventBinding::CustomEventMethods; -+use crate::dom::bindings::codegen::Bindings::EmbedderBinding::EmbedderMethods; ++use crate::dom::bindings::codegen::Bindings::EmbedderBinding::{ ++ EmbedderMethods, TaskProviderDescriptor, ++}; +use crate::dom::bindings::inheritance::Castable; +use crate::dom::bindings::reflector::{DomGlobal, reflect_dom_object}; +use crate::dom::bindings::root::{DomRoot, MutNullableDom}; @@ -188,6 +190,190 @@ + .fire(self.upcast::(), can_gc); + } + ++ /// Dispatch a taskrequest event with the given task name and providers. ++ /// The system UI should call `respondToTaskRequest(requestId, providerId)` to respond. ++ #[expect(unsafe_code)] ++ pub(crate) fn dispatch_task_request( ++ &self, ++ request_id: &str, ++ task_name: &str, ++ providers_json: &str, ++ can_gc: CanGc, ++ ) { ++ let cx = GlobalScope::get_cx(); ++ rooted!(in(*cx) let mut detail = UndefinedValue()); ++ ++ unsafe { ++ rooted!(in(*cx) let detail_obj = JS_NewObject(*cx, ptr::null())); ++ if !detail_obj.get().is_null() { ++ // Set requestId property ++ rooted!(in(*cx) let mut id_val = UndefinedValue()); ++ request_id.safe_to_jsval(cx, id_val.handle_mut(), can_gc); ++ JS_DefineProperty( ++ *cx, ++ detail_obj.handle(), ++ c"requestId".as_ptr(), ++ id_val.handle(), ++ JSPROP_ENUMERATE as u32, ++ ); ++ ++ // Set taskName property ++ rooted!(in(*cx) let mut name_val = UndefinedValue()); ++ task_name.safe_to_jsval(cx, name_val.handle_mut(), can_gc); ++ JS_DefineProperty( ++ *cx, ++ detail_obj.handle(), ++ c"taskName".as_ptr(), ++ name_val.handle(), ++ JSPROP_ENUMERATE as u32, ++ ); ++ ++ // Set providers property (JSON string for now) ++ rooted!(in(*cx) let mut providers_val = UndefinedValue()); ++ providers_json.safe_to_jsval(cx, providers_val.handle_mut(), can_gc); ++ JS_DefineProperty( ++ *cx, ++ detail_obj.handle(), ++ c"providers".as_ptr(), ++ providers_val.handle(), ++ JSPROP_ENUMERATE as u32, ++ ); ++ ++ detail.set(ObjectValue(detail_obj.get())); ++ } ++ } ++ ++ let global = self.global(); ++ let custom_event = CustomEvent::new_uninitialized(&global, can_gc); ++ custom_event.InitCustomEvent( ++ cx, ++ DOMString::from("taskrequest"), ++ false, ++ false, ++ detail.handle(), ++ ); ++ ++ custom_event ++ .upcast::() ++ .fire(self.upcast::(), can_gc); ++ } ++ ++ /// Dispatch an opentaskprovider event so the system UI opens a provider webview. ++ #[expect(unsafe_code)] ++ pub(crate) fn dispatch_open_task_provider( ++ &self, ++ request_id: &str, ++ url: &str, ++ title: &str, ++ can_gc: CanGc, ++ ) { ++ let cx = GlobalScope::get_cx(); ++ rooted!(in(*cx) let mut detail = UndefinedValue()); ++ ++ unsafe { ++ rooted!(in(*cx) let detail_obj = JS_NewObject(*cx, ptr::null())); ++ if !detail_obj.get().is_null() { ++ rooted!(in(*cx) let mut val = UndefinedValue()); ++ ++ request_id.safe_to_jsval(cx, val.handle_mut(), can_gc); ++ JS_DefineProperty( ++ *cx, ++ detail_obj.handle(), ++ c"requestId".as_ptr(), ++ val.handle(), ++ JSPROP_ENUMERATE as u32, ++ ); ++ ++ url.safe_to_jsval(cx, val.handle_mut(), can_gc); ++ JS_DefineProperty( ++ *cx, ++ detail_obj.handle(), ++ c"url".as_ptr(), ++ val.handle(), ++ JSPROP_ENUMERATE as u32, ++ ); ++ ++ title.safe_to_jsval(cx, val.handle_mut(), can_gc); ++ JS_DefineProperty( ++ *cx, ++ detail_obj.handle(), ++ c"title".as_ptr(), ++ val.handle(), ++ JSPROP_ENUMERATE as u32, ++ ); ++ ++ detail.set(ObjectValue(detail_obj.get())); ++ } ++ } ++ ++ let global = self.global(); ++ let custom_event = CustomEvent::new_uninitialized(&global, can_gc); ++ custom_event.InitCustomEvent( ++ cx, ++ DOMString::from("opentaskprovider"), ++ false, ++ false, ++ detail.handle(), ++ ); ++ ++ custom_event ++ .upcast::() ++ .fire(self.upcast::(), can_gc); ++ } ++ ++ /// Dispatch a taskprovidersupdate event with additional remote providers. ++ #[expect(unsafe_code)] ++ pub(crate) fn dispatch_task_providers_update( ++ &self, ++ request_id: &str, ++ providers_json: &str, ++ can_gc: CanGc, ++ ) { ++ let cx = GlobalScope::get_cx(); ++ rooted!(in(*cx) let mut detail = UndefinedValue()); ++ ++ unsafe { ++ rooted!(in(*cx) let detail_obj = JS_NewObject(*cx, ptr::null())); ++ if !detail_obj.get().is_null() { ++ rooted!(in(*cx) let mut val = UndefinedValue()); ++ ++ request_id.safe_to_jsval(cx, val.handle_mut(), can_gc); ++ JS_DefineProperty( ++ *cx, ++ detail_obj.handle(), ++ c"requestId".as_ptr(), ++ val.handle(), ++ JSPROP_ENUMERATE as u32, ++ ); ++ ++ providers_json.safe_to_jsval(cx, val.handle_mut(), can_gc); ++ JS_DefineProperty( ++ *cx, ++ detail_obj.handle(), ++ c"providers".as_ptr(), ++ val.handle(), ++ JSPROP_ENUMERATE as u32, ++ ); ++ ++ detail.set(ObjectValue(detail_obj.get())); ++ } ++ } ++ ++ let global = self.global(); ++ let custom_event = CustomEvent::new_uninitialized(&global, can_gc); ++ custom_event.InitCustomEvent( ++ cx, ++ DOMString::from("taskprovidersupdate"), ++ false, ++ false, ++ detail.handle(), ++ ); ++ ++ custom_event ++ .upcast::() ++ .fire(self.upcast::(), can_gc); ++ } ++ + pub(crate) fn is_allowed_to_embed_for_url(url: &ServoUrl) -> bool { + // TODO: better permission mechanism with finer granularity + url.scheme() == "beaver" @@ -263,6 +449,45 @@ + .send(EmbedderMsg::StartWindowResize(webview_id)); + } + ++ /// Respond to a web task request with the selected provider id, or null to cancel. ++ fn RespondToTaskRequest(&self, request_id: DOMString, provider_id: Option) { ++ let global = self.global(); ++ let _ = global.script_to_constellation_chan().send( ++ ScriptToConstellationMessage::TaskProviderSelected( ++ request_id.to_string(), ++ provider_id.map(|s| s.to_string()), ++ ), ++ ); ++ } ++ ++ /// Register a task provider from a privileged page. ++ fn RegisterTaskProvider(&self, descriptor: &TaskProviderDescriptor) { ++ let global = self.global(); ++ let base_url = global.api_base_url(); ++ ++ // Resolve href relative to the page's base URL. ++ let href = match ServoUrl::parse_with_base(Some(&base_url), &descriptor.href) { ++ Ok(url) => url.to_string(), ++ Err(_) => { ++ log::warn!("registerTaskProvider: invalid href '{}'", &*descriptor.href); ++ return; ++ }, ++ }; ++ ++ let _ = global.script_to_constellation_chan().send( ++ ScriptToConstellationMessage::RegisterTaskProvider( ++ descriptor.name.to_string(), ++ descriptor.title.to_string(), ++ descriptor.description.as_ref().map(|s| s.to_string()), ++ href, ++ descriptor.filters.to_string(), ++ descriptor.returns.as_ref().map(|s| s.to_string()), ++ descriptor.display.to_string(), ++ descriptor.icon.as_ref().map(|s| s.to_string()), ++ ), ++ ); ++ } ++ + fn Pairing(&self, can_gc: CanGc) -> DomRoot { + self.pairing + .or_init(|| Pairing::new(&self.global(), can_gc)) @@ -277,4 +502,14 @@ + GetOnpreferencechanged, + SetOnpreferencechanged + ); ++ ++ // Event handler for web task request events ++ event_handler!(taskrequest, GetOntaskrequest, SetOntaskrequest); ++ ++ // Event handler for task providers update (remote providers discovered) ++ event_handler!( ++ taskprovidersupdate, ++ GetOntaskprovidersupdate, ++ SetOntaskprovidersupdate ++ ); +} diff --git a/patches/components/script/dom/html/htmliframeelement.rs.patch b/patches/components/script/dom/html/htmliframeelement.rs.patch index 3fddd94..a19d4bc 100644 --- a/patches/components/script/dom/html/htmliframeelement.rs.patch +++ b/patches/components/script/dom/html/htmliframeelement.rs.patch @@ -108,7 +108,7 @@ }; self.pipeline_id.set(Some(new_pipeline_id)); -@@ -597,6 +636,148 @@ +@@ -597,6 +636,128 @@ ); } @@ -160,10 +160,6 @@ + load_data.destination = Destination::IFrame; + load_data.policy_container = Some(window.as_global_scope().policy_container()); + -+ // Clone load_data for spawning the pipeline later -+ let load_data_for_spawn = load_data.clone(); -+ let theme = window.theme(); -+ + // Get the iframe's size to use as the viewport for the embedded webview. + // We use border_box which gives us the size in CSS pixels. + let hidpi_scale_factor = window.device_pixel_ratio(); @@ -188,6 +184,7 @@ + ipc::channel().expect("Failed to create IPC channel for embedded webview"); + + let hide_focus = self.has_hide_focus(); ++ let theme = window.theme(); + let request = EmbeddedWebViewCreationRequest { + load_data, + parent_pipeline_id: pipeline_id, @@ -220,25 +217,8 @@ + self.pipeline_id.set(Some(response.new_pipeline_id)); + self.webview_id.set(Some(response.new_webview_id)); + -+ // Spawn the pipeline in the script thread -+ // Embedded webviews are top-level, so parent_info is None -+ let new_pipeline_info = NewPipelineInfo { -+ parent_info: None, -+ new_pipeline_id: response.new_pipeline_id, -+ browsing_context_id: response.new_browsing_context_id, -+ webview_id: response.new_webview_id, -+ opener: None, -+ load_data: load_data_for_spawn, -+ viewport_details, -+ user_content_manager_id: None, -+ theme, -+ is_embedded_webview: true, -+ hide_focus, -+ }; -+ -+ with_script_thread(|script_thread| { -+ script_thread.spawn_pipeline(new_pipeline_info); -+ }); ++ // The constellation already spawns the pipeline via Pipeline::spawn() ++ // in handle_create_embedded_webview, so we don't need to spawn it here. + }, + Ok(None) => { + warn!("Embedded webview creation was rejected by embedder"); @@ -257,7 +237,7 @@ fn destroy_nested_browsing_context(&self) { self.pipeline_id.set(None); self.pending_pipeline_id.set(None); -@@ -659,6 +840,13 @@ +@@ -659,6 +820,13 @@ lazy_load_resumption_steps: Default::default(), pending_navigation: Default::default(), already_fired_synchronous_load_event: Default::default(), @@ -271,7 +251,7 @@ } } -@@ -694,7 +882,158 @@ +@@ -694,6 +862,157 @@ self.webview_id.get() } @@ -376,7 +356,7 @@ + + /// Returns true if this iframe is hosting an embedded webview (created with "embed" attribute). + /// Embedded webviews have their own top-level WebViewId and window.parent === window.self. - #[inline] ++ #[inline] + pub(crate) fn is_embedded_webview(&self) -> bool { + self.is_embedded_webview.get() + } @@ -426,11 +406,10 @@ + self.page_zoom.set(zoom); + } + -+ #[inline] + #[inline] pub(crate) fn sandboxing_flag_set(&self) -> SandboxingFlagSet { self.sandboxing_flag_set - .get() -@@ -1078,6 +1417,89 @@ +@@ -1078,6 +1397,89 @@ // https://html.spec.whatwg.org/multipage/#dom-iframe-longdesc make_url_setter!(SetLongDesc, "longdesc"); @@ -520,15 +499,29 @@ } impl VirtualMethods for HTMLIFrameElement { -@@ -1134,10 +1556,40 @@ +@@ -1133,9 +1535,54 @@ + // may be in a different script thread. Instead, we check to see if the parent // is in a document tree and has a browsing context, which is what causes // the child browsing context to be created. - if self.upcast::().is_connected_with_browsing_context() { -- debug!("iframe src set while in browsing context."); -- self.process_the_iframe_attributes(ProcessingMode::NotFirstTime, cx); ++ ++ // Only process if the value actually changed (not just re-set to the same value). ++ let value_changed = match mutation { ++ AttributeMutation::Set(old_value, _) => { ++ old_value.map_or(true, |old| **old != **attr.value()) ++ }, ++ AttributeMutation::Removed => true, ++ }; ++ ++ if value_changed && self.upcast::().is_connected_with_browsing_context() { + // For embedded webviews, navigate using the load() method instead of + // processing iframe attributes (which is for regular nested iframes). + if self.is_embedded_webview.get() { ++ // Skip the initial src set (old=None) since create_embedded_webview ++ // already navigated. Only re-navigate on actual src changes. ++ let is_initial_set = matches!(mutation, AttributeMutation::Set(None, _)); ++ if is_initial_set { ++ return; ++ } + if let Some(webview_id) = self.embedded_webview_id.get() { + let url = self + .shared_attribute_processing_steps_for_iframe_and_frame_elements( @@ -548,22 +541,22 @@ + debug!("iframe src set while in browsing context."); + self.process_the_iframe_attributes(ProcessingMode::NotFirstTime, cx); + } - } - }, ++ } ++ }, + local_name!("embed") => { + // The embed attribute determines whether this iframe hosts an embedded webview. + // Warn if it's changed after the iframe is already connected, as this is not supported. -+ if self.upcast::().is_connected_with_browsing_context() { + if self.upcast::().is_connected_with_browsing_context() { +- debug!("iframe src set while in browsing context."); +- self.process_the_iframe_attributes(ProcessingMode::NotFirstTime, cx); + warn!( + "The 'embed' attribute on iframe should not be changed after insertion. \ + The iframe mode (nested vs embedded webview) is determined at insertion time." + ); -+ } -+ }, + } + }, local_name!("loading") => { - // https://html.spec.whatwg.org/multipage/#attr-iframe-loading - // > When the loading attribute's state is changed to the Eager state, the user agent must run these steps: -@@ -1200,6 +1652,23 @@ +@@ -1200,6 +1647,23 @@ debug!(" + + + `; + } + renderPermissionPrompt() { if (!this.currentPermission) { return ""; @@ -1173,6 +1211,7 @@ export class WebView extends LitElement { @embednotificationshow=${this.onnotificationshow} @embedloadstatuschange=${this.onloadstatuschange} @embedmediasessionevent=${this.onmediasessionevent} + @embedclosed=${this.close} > ${this.renderDialog()} ${this.renderPermissionPrompt()} ${this.renderColorPicker()} + ${this.renderInlineProvider()} `;