diff --git a/crates/beaver_shell/src/main.rs b/crates/beaver_shell/src/main.rs index 06ace14..ed5ad05 100644 --- a/crates/beaver_shell/src/main.rs +++ b/crates/beaver_shell/src/main.rs @@ -20,6 +20,7 @@ use std::collections::HashMap; use std::error::Error; use std::path::PathBuf; use std::rc::Rc; +use std::time::{Duration, Instant}; use chrono::{DateTime, Local}; use content_blocker::ContentBlocker; @@ -226,6 +227,9 @@ struct AppState { /// `Some` only when launched with `--webdriver `. Drained each /// event-loop tick via `handle_webdriver_messages`. webdriver: Option, + /// When we last told Servo the user was active, to rate-limit those reports. + /// See [`AppState::note_user_activity`]. + last_activity_report: Cell>, } impl AppState { @@ -234,6 +238,33 @@ impl AppState { self.windows.borrow().get(&id).cloned() } + /// Tell Servo the user just did something, for the Idle Detection API. + /// + /// Servo cannot see this for itself: it only receives the input aimed at web + /// content, so the chrome, the dock and global hotkeys would all look like + /// nobody was there. + /// + /// Rate-limited, because pointer motion arrives at device rates while the + /// shortest idle threshold anyone can ask for is measured in seconds. + /// + /// This is still window-scoped: winit only delivers events to our own + /// windows, so a user busy in another application still counts as idle. That + /// is exact under a kiosk compositor, where we are the only client, and + /// wrong on a desktop; fixing the desktop case needs a platform source + /// (Wayland's `ext_idle_notify_v1`) reporting through the same call. + fn note_user_activity(&self) { + const REPORT_INTERVAL: Duration = Duration::from_secs(1); + + let now = Instant::now(); + if let Some(last) = self.last_activity_report.get() && + now.duration_since(last) < REPORT_INTERVAL + { + return; + } + self.last_activity_report.set(Some(now)); + self.servo.notify_user_idle_time(Duration::ZERO); + } + // ---- WebDriver helpers ------------------------------------------------ pub(crate) fn servo(&self) -> &Servo { @@ -1204,6 +1235,7 @@ impl ApplicationHandler for App { #[cfg(feature = "global-hotkeys")] hotkey_manager, webdriver, + last_activity_report: Cell::new(None), }); let state2 = Rc::clone(&app_state); @@ -1270,6 +1302,31 @@ impl ApplicationHandler for App { app_state.servo.spin_event_loop(); app_state.handle_webdriver_messages(); + // Anything the user physically did counts as activity for the Idle + // Detection API. Deliberately a whitelist: this handler also sees redraw, + // resize, focus and occlusion events, none of which mean anyone is there, + // and treating those as activity would stop the device ever going idle. + // + // `CursorEntered`/`CursorLeft` and `ModifiersChanged` are left out on the + // same grounds: a window moving under a still cursor, or a modifier state + // resync, is not the user doing something. + if matches!( + event, + WindowEvent::KeyboardInput { .. } | + WindowEvent::Ime(..) | + WindowEvent::CursorMoved { .. } | + WindowEvent::MouseInput { .. } | + WindowEvent::MouseWheel { .. } | + WindowEvent::Touch(..) | + WindowEvent::TouchpadPressure { .. } | + WindowEvent::PinchGesture { .. } | + WindowEvent::PanGesture { .. } | + WindowEvent::RotationGesture { .. } | + WindowEvent::DoubleTapGesture { .. } + ) { + app_state.note_user_activity(); + } + let Some(browser_window) = app_state.window(window_id.into()) else { return; }; diff --git a/crates/beaver_shell/src/prefs.rs b/crates/beaver_shell/src/prefs.rs index 7d25f1d..3694bae 100644 --- a/crates/beaver_shell/src/prefs.rs +++ b/crates/beaver_shell/src/prefs.rs @@ -35,6 +35,11 @@ pub struct BeaverPreferences { /// URL of the custom video player page (empty string disables) #[serde(default = "default_video_player_url")] pub video_player_url: String, + /// Blank the screen after this many milliseconds without user interaction. + /// 0 disables it. Values below 10000 are raised to that: the Idle Detection + /// API refuses shorter thresholds even for privileged pages. + #[serde(default = "default_screen_timeout_ms")] + pub screen_timeout_ms: i64, } fn default_start_url() -> String { @@ -61,6 +66,12 @@ fn default_false() -> bool { false } +/// Off by default: blanking the screen is only safe where a touch reliably wakes +/// it, so this is opted into per device rather than assumed. +fn default_screen_timeout_ms() -> i64 { + BeaverPreferences::DEFAULT.screen_timeout_ms +} + fn default_true() -> bool { true } @@ -77,6 +88,7 @@ impl BeaverPreferences { media_center_mode: false, content_blocking: true, video_player_url: String::new(), + screen_timeout_ms: 15_000, }; } @@ -92,6 +104,7 @@ impl Default for BeaverPreferences { media_center_mode: false, content_blocking: true, video_player_url: default_video_player_url(), + screen_timeout_ms: default_screen_timeout_ms(), } } } @@ -103,6 +116,7 @@ static EXPERIMENTAL_PREFS: &[&str] = &[ "dom_async_clipboard_enabled", "dom_exec_command_enabled", "dom_fontface_enabled", + "dom_idle_detection_enabled", "dom_indexeddb_enabled", "dom_intersection_observer_enabled", "dom_navigator_protocol_handlers_enabled", diff --git a/patches/components/config/prefs.rs.patch b/patches/components/config/prefs.rs.patch index 238ac89..b2dbafe 100644 --- a/patches/components/config/prefs.rs.patch +++ b/patches/components/config/prefs.rs.patch @@ -54,3 +54,20 @@ /// A convenience macro for accessing a preference value using its static path. /// Passing an invalid path is a compile-time error. #[macro_export] +@@ -177,6 +198,8 @@ + pub dom_geolocation_enabled: bool, + // feature: Screen Wake Lock API | #43615 | Web/API/Screen_Wake_Lock_API + pub dom_wakelock_enabled: bool, ++ // feature: Idle Detection API | Web/API/Idle_Detection_API ++ pub dom_idle_detection_enabled: bool, + // feature: IndexedDB | #6963 | Web/API/IndexedDB_API + pub dom_indexeddb_enabled: bool, + // feature: IntersectionObserver | #35767 | Web/API/Intersection_Observer_API +@@ -445,6 +468,7 @@ + dom_gamepad_enabled: true, + dom_geolocation_enabled: false, + dom_wakelock_enabled: false, ++ dom_idle_detection_enabled: false, + dom_indexeddb_enabled: false, + dom_intersection_observer_enabled: false, + dom_microdata_testing_enabled: false, diff --git a/patches/components/constellation/constellation.rs.patch b/patches/components/constellation/constellation.rs.patch index a0d1be4..1695093 100644 --- a/patches/components/constellation/constellation.rs.patch +++ b/patches/components/constellation/constellation.rs.patch @@ -77,13 +77,13 @@ use crate::event_loop::EventLoop; +use crate::pairing::{P2pMessage, PairingService}; use crate::pipeline::Pipeline; -+use crate::power_service; use crate::process_manager::ProcessManager; use crate::serviceworker::ServiceWorkerUnprivilegedContent; use crate::session_history::{NeedsToReload, SessionHistoryChange, SessionHistoryDiff}; +use crate::webtasks::{ + PendingTaskRequest, TaskDisplayMode, TaskProvider, TaskProviderInfo, TaskRegistry, +}; ++use crate::{idle_service, power_service}; struct PendingApprovalNavigation { load_data: LoadData, @@ -114,7 +114,7 @@ /// The currently activated keyboard modifiers. active_keyboard_modifiers: Modifiers, -@@ -523,6 +548,43 @@ +@@ -523,6 +548,47 @@ /// 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, @@ -147,6 +147,10 @@ + /// Brightness control (screen backlight, keyboard LEDs), backed by `beaver_hal`. + power: power_service::PowerService, + ++ /// How long the user has been idle, derived from forwarded input events and ++ /// reported to the pipelines that registered `ConstellationInterest::IdleState`. ++ idle: idle_service::IdleService, ++ + /// ATProto events emitted by `AtProtoManager`, broadcast to interested pipelines. + atproto_event_receiver: crossbeam_channel::Receiver, + @@ -158,7 +162,7 @@ } /// State needed to construct a constellation. -@@ -586,6 +648,9 @@ +@@ -586,6 +652,9 @@ /// The wake lock provider for acquiring and releasing OS-level screen wake locks. pub wake_lock_provider: Box, @@ -168,7 +172,7 @@ } /// When we are exiting a pipeline, we can either force exiting or not. A normal exit -@@ -668,6 +733,11 @@ +@@ -668,6 +737,11 @@ let broken_image_icon_data = resources::read_bytes(Resource::BrokenImageIcon); @@ -180,7 +184,7 @@ let mut constellation: Constellation = Constellation { event_loops: Default::default(), namespace_receiver, -@@ -689,7 +759,7 @@ +@@ -689,7 +763,7 @@ script_to_devtools_callback: Default::default(), #[cfg(feature = "bluetooth")] bluetooth_ipc_sender: state.bluetooth_thread, @@ -189,7 +193,7 @@ private_resource_threads: state.private_resource_threads, public_storage_threads: state.public_storage_threads, private_storage_threads: state.private_storage_threads, -@@ -726,6 +796,7 @@ +@@ -726,6 +800,7 @@ canvas: OnceCell::new(), pending_approval_navigations: Default::default(), pressed_mouse_buttons: 0, @@ -197,7 +201,7 @@ active_keyboard_modifiers: Modifiers::empty(), hard_fail, active_media_session: None, -@@ -742,6 +813,22 @@ +@@ -742,6 +817,23 @@ pending_viewport_changes: Default::default(), screenshot_readiness_requests: Vec::new(), user_contents_for_manager_id: Default::default(), @@ -210,6 +214,7 @@ + #[cfg(any(target_os = "linux", target_os = "macos"))] + dbus_signal_receiver: dbus_signal_rx, + power: power_service::PowerService::new(), ++ idle: idle_service::IdleService::new(), + at_proto: AtProtoManager::new( + state.public_resource_threads.core_thread, + atproto_event_tx, @@ -220,7 +225,7 @@ }; constellation.run(); -@@ -767,6 +854,18 @@ +@@ -767,6 +859,18 @@ fn clean_up_finished_script_event_loops(&mut self) { self.event_loop_join_handles .retain(|join_handle| !join_handle.is_finished()); @@ -239,7 +244,7 @@ self.event_loops .retain(|event_loop| event_loop.upgrade().is_some()); } -@@ -1046,6 +1145,11 @@ +@@ -1046,6 +1150,11 @@ .get(&webview_id) .and_then(|webview| webview.user_content_manager_id); @@ -251,7 +256,7 @@ let new_pipeline_info = NewPipelineInfo { parent_info: parent_pipeline_id, new_pipeline_id, -@@ -1057,6 +1161,13 @@ +@@ -1057,6 +1166,13 @@ user_content_manager_id, theme, target_snapshot_params, @@ -265,7 +270,7 @@ }; let pipeline = match Pipeline::spawn(new_pipeline_info, event_loop, self, throttled) { Ok(pipeline) => pipeline, -@@ -1222,6 +1333,9 @@ +@@ -1222,6 +1338,9 @@ Script((WebViewId, PipelineId, ScriptToConstellationMessage)), BackgroundHangMonitor(HangMonitorAlert), Embedder(EmbedderToConstellationMessage), @@ -275,7 +280,7 @@ RemoveProcess(usize), } // Get one incoming request. -@@ -1241,6 +1355,28 @@ +@@ -1241,6 +1360,28 @@ sel.recv(&self.background_hang_monitor_receiver); sel.recv(&self.embedder_to_constellation_receiver); @@ -304,7 +309,7 @@ self.process_manager.register(&mut sel); let request = { -@@ -1267,9 +1403,30 @@ +@@ -1267,9 +1408,30 @@ oper.recv(&self.embedder_to_constellation_receiver) .expect("Unexpected embedder channel panic in constellation"), )), @@ -336,7 +341,7 @@ let _ = oper.recv(self.process_manager.receiver_at(process_index)); Ok(Request::RemoveProcess(process_index)) }, -@@ -1292,6 +1449,37 @@ +@@ -1292,10 +1454,67 @@ Request::BackgroundHangMonitor(message) => { self.handle_request_from_background_hang_monitor(message); }, @@ -374,7 +379,37 @@ Request::RemoveProcess(index) => self.process_manager.remove(index), } } -@@ -1443,6 +1631,15 @@ + ++ /// Report the current idle state to one pipeline. ++ fn send_idle_state_to(&mut self, pipeline_id: PipelineId) { ++ let event = self.idle.state(); ++ self.send_message_to_pipeline( ++ pipeline_id, ++ ScriptThreadMessage::DispatchIdleState(pipeline_id, event), ++ "Closed before the idle state could be sent", ++ ); ++ } ++ ++ /// Report the current idle state to every pipeline that asked for it. ++ fn broadcast_idle_state(&mut self) { ++ // Snapshot the ids: sending borrows `self` mutably, so that a dead script ++ // thread can be cleaned up as part of the send. ++ let Some(interested) = self ++ .pipeline_interests ++ .get(&ConstellationInterest::IdleState) ++ else { ++ return; ++ }; ++ let interested: Vec = interested.iter().copied().collect(); ++ for pipeline_id in interested { ++ self.send_idle_state_to(pipeline_id); ++ } ++ } ++ + #[servo_tracing::instrument(skip_all)] + fn handle_request_for_pipeline_namespace(&mut self, request: PipelineNamespaceRequest) { + let PipelineNamespaceRequest(sender) = request; +@@ -1443,6 +1662,15 @@ EmbedderToConstellationMessage::ThemeChange(webview_id, theme) => { self.handle_theme_change(webview_id, theme); }, @@ -390,7 +425,24 @@ EmbedderToConstellationMessage::TickAnimation(webview_ids) => { self.handle_tick_animation(webview_ids) }, -@@ -1515,11 +1712,7 @@ +@@ -1461,6 +1689,16 @@ + EmbedderToConstellationMessage::ForwardInputEvent(webview_id, event, hit_test) => { + self.forward_input_event(webview_id, event, hit_test); + }, ++ EmbedderToConstellationMessage::NotifyUserIdleTime(idle_for) => { ++ if self.idle.note_idle_time(idle_for) { ++ self.broadcast_idle_state(); ++ } ++ }, ++ EmbedderToConstellationMessage::NotifyScreenLocked(locked) => { ++ if self.idle.note_screen_locked(locked) { ++ self.broadcast_idle_state(); ++ } ++ }, + EmbedderToConstellationMessage::RefreshCursor(pipeline_id) => { + self.handle_refresh_cursor(pipeline_id) + }, +@@ -1515,11 +1753,7 @@ } }, EmbedderToConstellationMessage::PreferencesUpdated(updates) => { @@ -403,7 +455,7 @@ let _ = event_loop.send(ScriptThreadMessage::PreferencesUpdated( updates .iter() -@@ -1546,6 +1739,18 @@ +@@ -1546,6 +1780,18 @@ EmbedderToConstellationMessage::SetAccessibilityActive(webview_id, active) => { self.set_accessibility_active(webview_id, active); }, @@ -422,7 +474,7 @@ } } -@@ -1743,7 +1948,13 @@ +@@ -1743,7 +1989,13 @@ return warn!("Attempt to add channel name from an unexpected origin."); } self.broadcast_channels @@ -437,7 +489,7 @@ }, ScriptToConstellationMessage::RemoveBroadcastChannelNameInRouter( router_id, -@@ -1757,7 +1968,13 @@ +@@ -1757,7 +2009,13 @@ return warn!("Attempt to remove channel name from an unexpected origin."); } self.broadcast_channels @@ -452,7 +504,7 @@ }, ScriptToConstellationMessage::RemoveBroadcastChannelRouter(router_id, origin) => { if self -@@ -1769,6 +1986,12 @@ +@@ -1769,6 +2027,12 @@ self.broadcast_channels .remove_broadcast_channel_router(router_id); }, @@ -465,7 +517,7 @@ ScriptToConstellationMessage::ScheduleBroadcast(router_id, message) => { if self .check_origin_against_pipeline(&source_pipeline_id, &message.origin) -@@ -1778,8 +2001,15 @@ +@@ -1778,8 +2042,15 @@ "Attempt to schedule broadcast from an origin not matching the origin of the msg." ); } @@ -482,7 +534,7 @@ }, ScriptToConstellationMessage::PipelineExited => { self.handle_pipeline_exited(source_pipeline_id); -@@ -1799,6 +2029,12 @@ +@@ -1799,6 +2070,12 @@ ScriptToConstellationMessage::CreateAuxiliaryWebView(load_info) => { self.handle_script_new_auxiliary(load_info); }, @@ -495,7 +547,7 @@ ScriptToConstellationMessage::ChangeRunningAnimationsState(animation_state) => { self.handle_change_running_animations_state(source_pipeline_id, animation_state) }, -@@ -1850,7 +2086,7 @@ +@@ -1850,7 +2127,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) { @@ -504,7 +556,20 @@ } else { warn!("constellation got set final url message for dead pipeline"); } -@@ -2000,6 +2236,29 @@ +@@ -1975,6 +2252,12 @@ + .entry(interest) + .or_default() + .insert(source_pipeline_id); ++ // Idle state is a level, not an edge: a detector that starts ++ // while the user is already away would otherwise hear nothing ++ // until they came back. Send the current state straight away. ++ if interest == ConstellationInterest::IdleState { ++ self.send_idle_state_to(source_pipeline_id); ++ } + }, + ScriptToConstellationMessage::UnregisterInterest(interest) => { + if let Some(set) = self.pipeline_interests.get_mut(&interest) { +@@ -2000,6 +2283,29 @@ new_value, ); }, @@ -534,7 +599,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 -@@ -2018,8 +2277,13 @@ +@@ -2018,8 +2324,13 @@ }; self.active_media_session = Some(pipeline_id); self.constellation_to_embedder_proxy.send( @@ -549,7 +614,7 @@ }, #[cfg(feature = "webgpu")] ScriptToConstellationMessage::RequestAdapter(response_sender, options, ids) => self -@@ -2092,7 +2356,1081 @@ +@@ -2092,9 +2403,1083 @@ } }, }, @@ -1307,9 +1372,9 @@ + 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()); @@ -1602,7 +1667,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, .. } | @@ -1628,10 +1693,12 @@ + let _ = event_loop.send(ScriptThreadMessage::DispatchPairingEvent(event.clone())); + } + } - } - ++ } ++ /// Check the origin of a message against that of the pipeline it came from. -@@ -2411,6 +3749,55 @@ + /// Note: this is still limited as a security check, + /// see +@@ -2411,6 +3796,55 @@ TransferState::TransferInProgress(queue) => queue.push_back(task), TransferState::CompletionFailed(queue) => queue.push_back(task), TransferState::CompletionRequested(_, queue) => queue.push_back(task), @@ -1687,7 +1754,7 @@ } } -@@ -3222,6 +4609,101 @@ +@@ -3222,6 +4656,101 @@ ); } @@ -1789,7 +1856,7 @@ fn forward_input_event( &mut self, webview_id: WebViewId, -@@ -3241,6 +4723,66 @@ +@@ -3241,6 +4770,66 @@ let pressed_mouse_buttons = self.pressed_mouse_buttons; let active_keyboard_modifiers = self.active_keyboard_modifiers; @@ -1856,7 +1923,7 @@ let event_id = event.id; let Some(webview) = self.webviews.get_mut(&webview_id) else { warn!("Got input event for unknown WebViewId: {webview_id:?}"); -@@ -3343,6 +4885,40 @@ +@@ -3343,6 +4932,40 @@ /// fn handle_close_top_level_browsing_context(&mut self, webview_id: WebViewId) { debug!("{webview_id}: Closing"); @@ -1897,7 +1964,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 = -@@ -3619,8 +5195,27 @@ +@@ -3619,8 +5242,27 @@ opener_webview_id, opener_pipeline_id, response_sender, @@ -1925,7 +1992,7 @@ let Some((webview_id_sender, webview_id_receiver)) = generic_channel::channel() else { warn!("Failed to create channel"); let _ = response_sender.send(None); -@@ -3719,6 +5314,402 @@ +@@ -3719,6 +5361,402 @@ }); } @@ -2328,7 +2395,7 @@ #[servo_tracing::instrument(skip_all)] fn handle_refresh_cursor(&self, pipeline_id: PipelineId) { let Some(pipeline) = self.pipelines.get(&pipeline_id) else { -@@ -4268,7 +6259,7 @@ +@@ -4268,7 +6306,7 @@ }, }; @@ -2337,7 +2404,7 @@ match self.browsing_contexts.get_mut(&browsing_context_id) { Some(browsing_context) => { let old_pipeline_id = browsing_context.pipeline_id; -@@ -4277,6 +6268,7 @@ +@@ -4277,6 +6315,7 @@ old_pipeline_id, browsing_context.parent_pipeline_id, browsing_context.webview_id, @@ -2345,7 +2412,7 @@ ) }, None => { -@@ -4286,6 +6278,15 @@ +@@ -4286,6 +6325,15 @@ self.unload_document(old_pipeline_id); @@ -2361,7 +2428,7 @@ if let Some(new_pipeline) = self.pipelines.get(&new_pipeline_id) { if let Some(ref chan) = self.devtools_sender { let state = NavigationState::Start(new_pipeline.url.clone()); -@@ -4854,7 +6855,7 @@ +@@ -4854,7 +6902,7 @@ } #[servo_tracing::instrument(skip_all)] @@ -2370,7 +2437,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. -@@ -4966,9 +6967,22 @@ +@@ -4966,9 +7014,22 @@ self.constellation_to_embedder_proxy .send(ConstellationToEmbedderMsg::HistoryChanged( webview_id, @@ -2394,7 +2461,7 @@ } #[servo_tracing::instrument(skip_all)] -@@ -4987,7 +7001,7 @@ +@@ -4987,7 +7048,7 @@ webview.focused_browsing_context_id = change.browsing_context_id; } @@ -2403,7 +2470,7 @@ match self.browsing_contexts.get_mut(&change.browsing_context_id) { Some(browsing_context) => { debug!("Adding pipeline to existing browsing context."); -@@ -4994,11 +7008,15 @@ +@@ -4994,11 +7055,15 @@ let old_pipeline_id = browsing_context.pipeline_id; browsing_context.pipelines.insert(change.new_pipeline_id); browsing_context.update_current_entry(change.new_pipeline_id); @@ -2421,7 +2488,7 @@ }, }; -@@ -5006,6 +7024,18 @@ +@@ -5006,6 +7071,18 @@ self.unload_document(old_pipeline_id); } diff --git a/patches/components/constellation/idle_service.rs.patch b/patches/components/constellation/idle_service.rs.patch new file mode 100644 index 0000000..21db15f --- /dev/null +++ b/patches/components/constellation/idle_service.rs.patch @@ -0,0 +1,86 @@ +--- original ++++ modified +@@ -0,0 +1,83 @@ ++/* This Source Code Form is subject to the terms of the Mozilla Public ++ * License, v. 2.0. If a copy of the MPL was not distributed with this ++ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ ++ ++//! Tracks how long the user has been idle and whether the screen is locked, for ++//! the Idle Detection API. ++//! ++//! The embedder is the only thing that can answer either, so it reports and this ++//! service just remembers. ++//! ++//! An embedder that reports nothing leaves every detector reading "active" and ++//! "unlocked". ++ ++use std::time::{Duration, Instant}; ++ ++use servo_constellation_traits::IdleStateEvent; ++ ++/// How much time must pass between two reports to script. An embedder may report ++/// at device rates, but the shortest threshold anyone can ask for is measured in ++/// seconds, so there is nothing to gain from reporting more often than this. ++const BROADCAST_INTERVAL: Duration = Duration::from_secs(1); ++ ++pub(crate) struct IdleService { ++ /// When the user last did anything. ++ last_activity: Instant, ++ /// When we last reported, used to rate-limit. ++ last_broadcast: Instant, ++ /// Whether a lock screen is covering the display, as last reported. ++ screen_locked: bool, ++} ++ ++impl IdleService { ++ pub(crate) fn new() -> Self { ++ let now = Instant::now(); ++ Self { ++ last_activity: now, ++ last_broadcast: now, ++ screen_locked: false, ++ } ++ } ++ ++ /// Record the platform's answer, as of now. Returns true when script should ++ /// be told, i.e. when enough time has passed since the last report. ++ pub(crate) fn note_idle_time(&mut self, idle_for: Duration) -> bool { ++ let now = Instant::now(); ++ // Underflow needs an idle time longer than the clock's epoch, which no ++ // real platform can report. If it somehow happens, treating the user as ++ // active is the safe choice. ++ self.last_activity = now.checked_sub(idle_for).unwrap_or(now); ++ if now.duration_since(self.last_broadcast) < BROADCAST_INTERVAL { ++ return false; ++ } ++ self.last_broadcast = now; ++ true ++ } ++ ++ /// Record whether a lock screen is active. Returns true when ++ /// script should be told. ++ /// ++ /// Not rate-limited: locking and unlocking are rare edges rather than a ++ /// stream, and swallowing one would leave `screenState` wrong until the next ++ /// report. ++ pub(crate) fn note_screen_locked(&mut self, locked: bool) -> bool { ++ if self.screen_locked == locked { ++ return false; ++ } ++ self.screen_locked = locked; ++ self.last_broadcast = Instant::now(); ++ true ++ } ++ ++ /// The state to report to script. ++ pub(crate) fn state(&self) -> IdleStateEvent { ++ IdleStateEvent { ++ idle_for_ms: Instant::now() ++ .duration_since(self.last_activity) ++ .as_millis() ++ .try_into() ++ .unwrap_or(u64::MAX), ++ screen_locked: self.screen_locked, ++ } ++ } ++} diff --git a/patches/components/constellation/lib.rs.patch b/patches/components/constellation/lib.rs.patch index 6ab64ab..94f9d2d 100644 --- a/patches/components/constellation/lib.rs.patch +++ b/patches/components/constellation/lib.rs.patch @@ -1,6 +1,6 @@ --- original +++ modified -@@ -7,18 +7,24 @@ +@@ -7,18 +7,25 @@ #[macro_use] mod tracing; @@ -13,6 +13,7 @@ +mod dbus_service; mod embedder; mod event_loop; ++mod idle_service; mod logging; +mod pairing; mod pipeline; diff --git a/patches/components/constellation/tracing.rs.patch b/patches/components/constellation/tracing.rs.patch index 24b5c1f..c2919a8 100644 --- a/patches/components/constellation/tracing.rs.patch +++ b/patches/components/constellation/tracing.rs.patch @@ -8,7 +8,16 @@ Self::TickAnimation(..) => target!("TickAnimation"), Self::WebDriverCommand(..) => target!("WebDriverCommand"), Self::Reload(..) => target!("Reload"), -@@ -83,6 +84,7 @@ +@@ -64,6 +65,8 @@ + Self::FocusWebView(..) => target!("FocusWebView"), + Self::BlurWebView => target!("BlurWebView"), + Self::ForwardInputEvent(_webview_id, event, ..) => event.log_target(), ++ Self::NotifyUserIdleTime(..) => target!("NotifyUserIdleTime"), ++ Self::NotifyScreenLocked(..) => target!("NotifyScreenLocked"), + Self::RefreshCursor(..) => target!("RefreshCursor"), + Self::ToggleProfiler(..) => target!("EnableProfiler"), + Self::ExitFullScreen(_) => target!("ExitFullScreen"), +@@ -83,6 +86,7 @@ Self::UserContentManagerAction(..) => target!("UserContentManagerAction"), Self::UpdatePinchZoomInfos(..) => target!("UpdatePinchZoomInfos"), Self::SetAccessibilityActive(..) => target!("SetAccessibilityActive"), @@ -16,7 +25,7 @@ } } } -@@ -134,6 +136,10 @@ +@@ -134,6 +138,10 @@ Self::DisentanglePorts(..) => target!("DisentanglePorts"), Self::NewBroadcastChannelRouter(..) => target!("NewBroadcastChannelRouter"), Self::RemoveBroadcastChannelRouter(..) => target!("RemoveBroadcastChannelRouter"), @@ -27,7 +36,7 @@ Self::NewBroadcastChannelNameInRouter(..) => { target!("NewBroadcastChannelNameInRouter") }, -@@ -144,6 +150,7 @@ +@@ -144,6 +152,7 @@ Self::RegisterInterest(..) => target!("RegisterInterest"), Self::UnregisterInterest(..) => target!("UnregisterInterest"), Self::BroadcastStorageEvent(..) => target!("BroadcastStorageEvent"), @@ -35,7 +44,7 @@ Self::ChangeRunningAnimationsState(..) => target!("ChangeRunningAnimationsState"), Self::CreateCanvasPaintThread(..) => target!("CreateCanvasPaintThread"), Self::FocusAncestorBrowsingContextsForFocusingSteps(..) => { -@@ -153,6 +160,8 @@ +@@ -153,6 +162,8 @@ Self::GetTopForBrowsingContext(..) => target!("GetTopForBrowsingContext"), Self::GetBrowsingContextInfo(..) => target!("GetBrowsingContextInfo"), Self::GetDocumentOrigin(..) => target!("GetDocumentOrigin"), @@ -44,7 +53,7 @@ Self::GetChildBrowsingContextId(..) => target!("GetChildBrowsingContextId"), Self::LoadComplete => target!("LoadComplete"), Self::LoadUrl(..) => target!("LoadUrl"), -@@ -168,6 +177,8 @@ +@@ -168,6 +179,8 @@ Self::ScriptLoadedURLInIFrame(..) => target!("ScriptLoadedURLInIFrame"), Self::ScriptNewIFrame(..) => target!("ScriptNewIFrame"), Self::CreateAuxiliaryWebView(..) => target!("ScriptNewAuxiliary"), @@ -53,7 +62,7 @@ Self::ActivateDocument => target!("ActivateDocument"), Self::SetDocumentState(..) => target!("SetDocumentState"), Self::SetFinalUrl(..) => target!("SetFinalUrl"), -@@ -192,6 +203,84 @@ +@@ -192,6 +205,84 @@ Self::TriggerGarbageCollection => target!("TriggerGarbageCollection"), Self::AcquireWakeLock(..) => target!("AcquireWakeLock"), Self::ReleaseWakeLock(..) => target!("ReleaseWakeLock"), diff --git a/patches/components/layout/display_list/mod.rs.patch b/patches/components/layout/display_list/mod.rs.patch index 4ba7292..7d90aaa 100644 --- a/patches/components/layout/display_list/mod.rs.patch +++ b/patches/components/layout/display_list/mod.rs.patch @@ -15,9 +15,9 @@ - PropertyBinding, PropertyBindingKey, RasterSpace, SpatialId, StackingContextFlags, - TransformStyle, units, + ClipChainId, ClipMode, ColorF, CommonItemProperties, ComplexClipRegion, ExternalScrollId, -+ GlyphInstance, MixBlendMode, NinePatchBorder, NinePatchBorderSource, NormalBorder, PrimitiveFlags, -+ PropertyBinding, PropertyBindingKey, RasterSpace, ReferenceFrameKind, SpatialId, -+ StackingContextFlags, TransformStyle, units, ++ GlyphInstance, MixBlendMode, NinePatchBorder, NinePatchBorderSource, NormalBorder, ++ PrimitiveFlags, PropertyBinding, PropertyBindingKey, RasterSpace, ReferenceFrameKind, ++ SpatialId, StackingContextFlags, TransformStyle, units, }; use wr::units::LayoutVector2D; diff --git a/patches/components/paint/painter.rs.patch b/patches/components/paint/painter.rs.patch index 8e22169..11a4832 100644 --- a/patches/components/paint/painter.rs.patch +++ b/patches/components/paint/painter.rs.patch @@ -19,7 +19,7 @@ }; use webrender_api::{ self, BuiltDisplayList, BuiltDisplayListDescriptor, ColorF, DirtyRect, DisplayListPayload, -@@ -81,7 +82,14 @@ +@@ -81,8 +82,15 @@ pub(crate) painter_id: PainterId, /// Our [`WebViewRenderer`]s, one for every `WebView`. @@ -27,14 +27,15 @@ + /// Using BTreeMap to ensure deterministic iteration order by WebViewId, + /// which is important for proper z-ordering in the display list (parents before children). + pub(crate) webview_renderers: BTreeMap, -+ + + /// Set of WebViewIds that are embedded webviews. These should not be rendered + /// as top-level iframes in the root display list, as they are already referenced + /// by their parent's display list through IFrameFragment. + pub(crate) embedded_webview_ids: FxHashSet, - ++ /// Tracks whether or not the view needs to be repainted. pub(crate) needs_repaint: Cell, + @@ -266,6 +274,7 @@ painter_id, embedder_to_constellation_sender, @@ -199,7 +200,28 @@ #[track_caller] fn assert_no_gl_error(&self) { debug_assert_eq!(self.webrender_gl.get_error(), gleam::gl::NO_ERROR); -@@ -609,7 +733,16 @@ +@@ -339,6 +463,20 @@ + self.webview_renderers.get(&webview_id) + } + ++ /// The display list epoch a pipeline has reached, wherever it lives. ++ /// ++ /// Deliberately searches every [`WebViewRenderer`], not just one: an ++ /// embedded `` is a separate top-level webview with a renderer of ++ /// its own, while the constellation counts its pipelines as part of the ++ /// embedding webview (they are nested browsing contexts). So the set of ++ /// pipelines a screenshot waits on can span several renderers. ++ pub(crate) fn pipeline_display_list_epoch(&self, pipeline_id: &PipelineId) -> Option { ++ self.webview_renderers ++ .values() ++ .find_map(|renderer| renderer.pipelines.get(pipeline_id)) ++ .and_then(|pipeline| pipeline.display_list_epoch) ++ } ++ + pub(crate) fn webview_renderer_mut( + &mut self, + webview_id: WebViewId, +@@ -609,7 +747,16 @@ let root_clip_id = builder.define_clip_rect(root_reference_frame, viewport_rect); let clip_chain_id = builder.define_clip_chain(None, [root_clip_id]); @@ -216,7 +238,7 @@ if webview_renderer.hidden() { continue; } -@@ -669,7 +802,7 @@ +@@ -669,7 +816,7 @@ /// Set the root pipeline for our WebRender scene to a display list that consists of an iframe /// for each visible top-level browsing context, applying a transformation on the root for /// pinch zoom, page zoom, and HiDPI scaling. @@ -225,7 +247,7 @@ let mut transaction = Transaction::new(); self.send_root_pipeline_display_list_in_transaction(&mut transaction); self.generate_frame(&mut transaction, RenderReasons::SCENE); -@@ -676,6 +809,112 @@ +@@ -676,6 +823,115 @@ self.send_transaction(transaction); } @@ -306,8 +328,11 @@ + let built = builder.end(); + txn.set_display_list(WebRenderEpoch(0), built); + self.update_transaction_with_all_scroll_offsets(&mut txn); -+ // present=true is required: WebRender skips the composite pass when present=false -+ txn.generate_frame(0, true, false, RenderReasons::SCENE); ++ // present=true is required: WebRender skips the composite pass when present=false. ++ // Go through `generate_frame` rather than the raw transaction call: WebRender will ++ // report this frame ready like any other, and that reply decrements the pending ++ // count, so skipping the increment here drives the counter negative. ++ self.generate_frame(&mut txn, RenderReasons::SCENE); + self.webrender_api + .send_transaction(self.webrender_document, txn); + @@ -338,7 +363,24 @@ /// Update the given transaction with the scroll offsets of all active scroll nodes in /// the WebRender scene. This is necessary because WebRender does not preserve scroll /// offsets between scroll tree modifications. If a display list could potentially -@@ -805,6 +1044,26 @@ +@@ -758,7 +1014,15 @@ + } + + pub(crate) fn decrement_pending_frames(&self) { +- self.pending_frames.set(self.pending_frames.get() - 1); ++ // Clamped at zero. An unbalanced decrement used to wrap to usize::MAX, ++ // which left `has_pending_frames()` permanently true and silently wedged ++ // everything that waits for a quiet renderer (screenshots, for one). ++ let pending = self.pending_frames.get(); ++ if pending == 0 { ++ warn!("Pending frame count underflowed: more frames reported than generated"); ++ return; ++ } ++ self.pending_frames.set(pending - 1); + } + + pub(crate) fn report_memory(&self) -> MemoryReport { +@@ -805,6 +1069,26 @@ self.send_root_pipeline_display_list(); } @@ -365,7 +407,7 @@ pub(crate) fn set_throttled( &mut self, webview_id: WebViewId, -@@ -1204,15 +1463,23 @@ +@@ -1204,15 +1488,23 @@ webview: Box, viewport_details: ViewportDetails, ) { @@ -393,7 +435,7 @@ } pub(crate) fn remove_webview(&mut self, webview_id: WebViewId) { -@@ -1414,7 +1681,6 @@ +@@ -1414,7 +1706,6 @@ result: InputEventResult, ) { let Some(webview_renderer) = self.webview_renderers.get_mut(&webview_id) else { diff --git a/patches/components/paint/screenshot.rs.patch b/patches/components/paint/screenshot.rs.patch new file mode 100644 index 0000000..e6d57d9 --- /dev/null +++ b/patches/components/paint/screenshot.rs.patch @@ -0,0 +1,32 @@ +--- original ++++ modified +@@ -112,15 +112,23 @@ + continue; + }; + +- let Some(webview) = renderer.webview_renderer(screenshot_request.webview_id) else { ++ if renderer ++ .webview_renderer(screenshot_request.webview_id) ++ .is_none() ++ { + continue; +- }; ++ } + ++ // Look the pipelines up across every renderer, not just this ++ // webview's. The constellation counts an embedded ``'s ++ // pipelines as part of the embedding webview, because they are ++ // nested browsing contexts, but each one is a separate top-level ++ // webview with a `WebViewRenderer` of its own. Searching only the ++ // requesting renderer leaves those pipelines permanently missing and ++ // the screenshot never completes. + if pipelines.iter().all(|(pipeline_id, expected_epoch)| { +- webview +- .pipelines +- .get(pipeline_id) +- .and_then(|pipeline| pipeline.display_list_epoch) ++ renderer ++ .pipeline_display_list_epoch(pipeline_id) + .is_some_and(|epoch| epoch >= *expected_epoch) + }) { + screenshot_request.phase = ScreenshotRequestPhase::WaitingOnFrame; diff --git a/patches/components/script/dom/abort/abortsignal.rs.patch b/patches/components/script/dom/abort/abortsignal.rs.patch new file mode 100644 index 0000000..0688142 --- /dev/null +++ b/patches/components/script/dom/abort/abortsignal.rs.patch @@ -0,0 +1,48 @@ +--- original ++++ modified +@@ -29,6 +29,8 @@ + use crate::dom::bindings::str::DOMString; + use crate::dom::eventtarget::EventTarget; + use crate::dom::globalscope::GlobalScope; ++use crate::dom::idledetector::IdleDetector; ++use crate::dom::promise::Promise; + use crate::dom::readablestream::PipeTo; + use crate::fetch::{DeferredFetchRecordId, FetchContext}; + use crate::realms::enter_auto_realm; +@@ -53,10 +55,22 @@ + ), + /// + FetchLater(#[no_trace] DeferredFetchRecordId), ++ /// ++ IdleDetector(IdleDetectorAbort), + } + ++/// The abort steps for `IdleDetector.start()`'s `signal` option: stop the ++/// detector and reject the promise `start()` returned. + #[derive(Clone, JSTraceable, MallocSizeOf)] + #[cfg_attr(crown, crown::unrooted_must_root_lint::must_root)] ++pub(crate) struct IdleDetectorAbort { ++ pub(crate) detector: Trusted, ++ #[conditional_malloc_size_of] ++ pub(crate) promise: Rc, ++} ++ ++#[derive(Clone, JSTraceable, MallocSizeOf)] ++#[cfg_attr(crown, crown::unrooted_must_root_lint::must_root)] + pub(crate) struct RemovableDomEventListener { + pub(crate) event_target: Dom, + pub(crate) ty: DOMString, +@@ -209,6 +223,13 @@ + &removable_listener.options, + ); + }, ++ AbortAlgorithm::IdleDetector(idle_detector_abort) => { ++ rooted!(&in(cx) let mut reason = UndefinedValue()); ++ reason.set(self.abort_reason.get()); ++ // Set [[state]] to "stopped" and reject the start() promise. ++ idle_detector_abort.detector.root().stop(); ++ idle_detector_abort.promise.reject(cx, reason.handle()); ++ }, + } + } + diff --git a/patches/components/script/dom/document/document.rs.patch b/patches/components/script/dom/document/document.rs.patch index 4453576..628baeb 100644 --- a/patches/components/script/dom/document/document.rs.patch +++ b/patches/components/script/dom/document/document.rs.patch @@ -8,7 +8,7 @@ use html5ever::{LocalName, QualName, local_name, ns}; use hyper_serde::Serde; use indexmap::IndexSet; -@@ -57,11 +58,13 @@ +@@ -57,11 +58,14 @@ use script_traits::{DocumentActivity, ProgressiveWebMetricType}; use servo_arc::Arc; use servo_base::cross_process_instant::CrossProcessInstant; @@ -19,12 +19,13 @@ use servo_config::pref; -use servo_constellation_traits::{NavigationHistoryBehavior, ScriptToConstellationMessage}; +use servo_constellation_traits::{ -+ EmbeddedWebViewEventType, NavigationHistoryBehavior, ScriptToConstellationMessage, ++ ConstellationInterest, EmbeddedWebViewEventType, IdleStateEvent, NavigationHistoryBehavior, ++ ScriptToConstellationMessage, +}; use servo_media::{ClientContextId, ServoMedia}; use servo_url::{ImmutableOrigin, MutableOrigin, ServoUrl}; use style::attr::AttrValue; -@@ -92,6 +95,8 @@ +@@ -92,6 +96,8 @@ use crate::dom::bindings::codegen::Bindings::ElementBinding::ScrollLogicalPosition; use crate::dom::bindings::codegen::Bindings::EventBinding::Event_Binding::EventMethods; use crate::dom::bindings::codegen::Bindings::HTMLIFrameElementBinding::HTMLIFrameElement_Binding::HTMLIFrameElementMethods; @@ -33,7 +34,15 @@ #[cfg(any(feature = "webxr", feature = "gamepad"))] use crate::dom::bindings::codegen::Bindings::NavigatorBinding::Navigator_Binding::NavigatorMethods; use crate::dom::bindings::codegen::Bindings::NodeBinding::NodeMethods; -@@ -198,7 +203,9 @@ +@@ -169,6 +175,7 @@ + use crate::dom::html::htmlscriptelement::{HTMLScriptElement, ScriptResult}; + use crate::dom::html::htmltitleelement::HTMLTitleElement; + use crate::dom::htmldetailselement::DetailsNameGroups; ++use crate::dom::idledetector::IdleDetector; + use crate::dom::intersectionobserver::IntersectionObserver; + use crate::dom::iterators::ShadowIncluding; + use crate::dom::keyboardevent::KeyboardEvent; +@@ -198,7 +205,9 @@ use crate::dom::touchevent::TouchEvent as DomTouchEvent; use crate::dom::touchlist::TouchList; use crate::dom::trustedtypes::trustedhtml::TrustedHTML; @@ -44,7 +53,7 @@ use crate::dom::uievent::UIEvent; use crate::dom::websocket::WebSocket; use crate::dom::window::Window; -@@ -218,6 +225,7 @@ +@@ -218,6 +227,7 @@ use crate::task_manager::TaskManager; use crate::task_source::TaskSourceName; use crate::timers::{OneshotTimerCallback, OneshotTimers}; @@ -52,7 +61,7 @@ use crate::xpath::parse_expression; #[derive(Clone, Copy, PartialEq)] -@@ -413,6 +421,8 @@ +@@ -413,6 +423,8 @@ applets: MutNullableDom, /// Information about the `` in this [`Document`]. iframes: RefCell, @@ -61,7 +70,22 @@ /// Shared locks used for style attributes, author-origin stylesheets, and user and /// user agent stylesheets in this document. Can be acquired once for accessing many /// objects. This is shared with the owning [`ScriptThread`]. -@@ -654,6 +664,9 @@ +@@ -577,6 +589,14 @@ + /// - + /// - + resize_observers: DomRefCell>>, ++ /// The `IdleDetector`s started in this document, which the constellation's ++ /// idle reports are delivered to. ++ /// ++ idle_detectors: DomRefCell>>, ++ /// The last idle report, used to seed detectors that start between reports. ++ #[no_trace] ++ #[ignore_malloc_size_of = "Copy type from servo_constellation_traits"] ++ last_idle_state: Cell>, + /// The set of all fonts loaded by this document. + /// + fonts: MutNullableDom, +@@ -654,6 +674,9 @@ #[no_trace] favicon: RefCell>, @@ -71,7 +95,7 @@ /// All websockets created that are associated with this document. websockets: DOMTracker, -@@ -707,6 +720,11 @@ +@@ -707,6 +730,11 @@ /// history: MutNullableDom, @@ -83,7 +107,7 @@ } impl Document { -@@ -891,6 +909,68 @@ +@@ -891,6 +919,68 @@ self.dirty_root.take() } @@ -152,7 +176,7 @@ #[inline] pub(crate) fn loader(&self) -> Ref<'_, DocumentLoader> { self.loader.borrow() -@@ -962,6 +1042,12 @@ +@@ -962,6 +1052,12 @@ // Set the document's activity level, reflow if necessary, and suspend or resume timers. self.activity.set(activity); @@ -165,7 +189,7 @@ let media = ServoMedia::get(); let pipeline_id = self.window().pipeline_id(); let client_context_id = -@@ -975,6 +1061,7 @@ +@@ -975,6 +1071,7 @@ self.title_changed(); self.notify_embedder_favicon(); @@ -173,7 +197,7 @@ self.dirty_all_nodes(cx.no_gc()); self.window().resume(cx); media.resume(&client_context_id); -@@ -1362,6 +1449,9 @@ +@@ -1362,6 +1459,9 @@ LoadStatus::Started, )); self.send_to_embedder(EmbedderMsg::Status(self.webview_id(), None)); @@ -183,7 +207,7 @@ } }, DocumentReadyState::Complete => { -@@ -1370,6 +1460,9 @@ +@@ -1370,6 +1470,9 @@ self.webview_id(), LoadStatus::Complete, )); @@ -193,7 +217,7 @@ } update_with_current_instant(&self.navigation_timing.dom_complete); }, -@@ -1450,7 +1543,13 @@ +@@ -1450,7 +1553,13 @@ let window = self.window(); if window.is_top_level() { let title = self.title().map(String::from); @@ -208,7 +232,7 @@ } } -@@ -1459,6 +1558,18 @@ +@@ -1459,6 +1568,18 @@ window.send_to_embedder(msg); } @@ -227,7 +251,7 @@ pub(crate) fn dirty_all_nodes(&self, no_gc: &NoGC) { let root = match self.GetDocumentElement() { Some(root) => root, -@@ -2907,6 +3018,17 @@ +@@ -2907,6 +3028,17 @@ self.iframes.borrow_mut() } @@ -245,7 +269,68 @@ pub(crate) fn set_navigation_start(&self, navigation_start: CrossProcessInstant) { self.interactive_time .borrow_mut() -@@ -3764,6 +3886,7 @@ +@@ -3187,6 +3319,60 @@ + .push(Dom::from_ref(resize_observer)); + } + ++ /// Start delivering idle reports to `detector`. ++ /// ++ /// The constellation only reports while the user is interacting, so a ++ /// detector started during a quiet spell would otherwise sit with a null ++ /// `userState` indefinitely. Registering interest makes the constellation ++ /// reply with the current state, but only for the first detector in the ++ /// pipeline (interest is refcounted); later ones are seeded from the last ++ /// report instead. ++ pub(crate) fn add_idle_detector(&self, detector: &IdleDetector) { ++ self.idle_detectors ++ .borrow_mut() ++ .push(Dom::from_ref(detector)); ++ self.window() ++ .as_global_scope() ++ .register_interest(ConstellationInterest::IdleState); ++ if let Some(event) = self.last_idle_state.get() { ++ detector.handle_idle_state(&event); ++ } ++ } ++ ++ /// Stop delivering idle reports to `detector`. Once the last one goes, the ++ /// constellation stops reporting to this pipeline at all. ++ pub(crate) fn remove_idle_detector(&self, detector: &IdleDetector) { ++ { ++ let mut detectors = self.idle_detectors.borrow_mut(); ++ let Some(index) = detectors.iter().position(|entry| &**entry == detector) else { ++ return; ++ }; ++ detectors.remove(index); ++ } ++ self.window() ++ .as_global_scope() ++ .unregister_interest(ConstellationInterest::IdleState); ++ } ++ ++ /// Hand the constellation's idle report to every detector in this document. ++ pub(crate) fn notify_idle_state(&self, event: &IdleStateEvent) { ++ self.last_idle_state.set(Some(*event)); ++ // Most documents have no detectors; skip them before allocating. ++ if self.idle_detectors.borrow().is_empty() { ++ return; ++ } ++ ++ let detectors: Vec> = self ++ .idle_detectors ++ .borrow() ++ .iter() ++ .map(|detector| DomRoot::from_ref(&**detector)) ++ .collect(); ++ for detector in detectors { ++ detector.handle_idle_state(event); ++ } ++ } ++ + /// + /// + pub(crate) fn gather_active_resize_observations_at_depth( +@@ -3764,6 +3950,7 @@ anchors: Default::default(), applets: Default::default(), iframes: RefCell::new(IFrameCollection::new()), @@ -253,7 +338,16 @@ shared_style_locks, stylesheets: DomRefCell::new(DocumentStylesheetSet::new()), stylesheet_list: MutNullableDom::new(None), -@@ -3844,6 +3967,7 @@ +@@ -3823,6 +4010,8 @@ + dirty_root: Default::default(), + declarative_refresh: Default::default(), + resize_observers: Default::default(), ++ idle_detectors: Default::default(), ++ last_idle_state: Cell::new(None), + fonts: Default::default(), + visibility_state: Cell::new(DocumentVisibilityState::Hidden), + status_code, +@@ -3844,6 +4033,7 @@ active_sandboxing_flag_set: Cell::new(creation_sandboxing_flag_set), creation_sandboxing_flag_set: Cell::new(creation_sandboxing_flag_set), favicon: RefCell::new(None), @@ -261,7 +355,7 @@ websockets: DOMTracker::new(), details_name_groups: Default::default(), protocol_handler_automation_mode: Default::default(), -@@ -3864,6 +3988,7 @@ +@@ -3864,6 +4054,7 @@ )), image_cache, history: Default::default(), @@ -269,7 +363,7 @@ } } -@@ -4892,6 +5017,36 @@ +@@ -4892,6 +5083,36 @@ pub(crate) fn notify_embedder_favicon(&self) { if let Some(ref image) = *self.favicon.borrow() { @@ -306,7 +400,7 @@ self.send_to_embedder(EmbedderMsg::NewFavicon(self.webview_id(), image.clone())); } } -@@ -4970,6 +5125,30 @@ +@@ -4970,6 +5191,30 @@ pub(crate) fn set_iframe_load_in_progress(&self, value: bool) { self.iframe_load_in_progress.set(value) } diff --git a/patches/components/script/dom/embedder.rs.patch b/patches/components/script/dom/embedder.rs.patch index 879b00b..93c17ff 100644 --- a/patches/components/script/dom/embedder.rs.patch +++ b/patches/components/script/dom/embedder.rs.patch @@ -1,15 +1,20 @@ --- original +++ modified -@@ -0,0 +1,553 @@ +@@ -0,0 +1,642 @@ +/* SPDX Id: AGPL-3.0-or-later */ + +//! The `Embedder` interface provides communication between web content and the embedder. +//! This is a Servo-specific, non-standard API. + +use std::ptr; ++use std::rc::Rc; + +use dom_struct::dom_struct; -+use embedder_traits::{EmbedderMsg, NewOSWindowParams, ServoErrorType}; ++use embedder_traits::{ ++ EmbeddedWebViewScreenshotError, EmbeddedWebViewScreenshotRequest, ++ EmbeddedWebViewScreenshotResult, EmbedderMsg, NewOSWindowParams, ScreenshotImageType, ++ ServoErrorType, ++}; +use js::context::JSContext; +use js::jsapi::{JS_NewObject, JSPROP_ENUMERATE}; +use js::jsval::{BooleanValue, Int32Value, ObjectValue, UndefinedValue}; @@ -22,17 +27,20 @@ +use script_bindings::str::USVString; +use servo_base::id::ScriptEventLoopId; +use servo_config::pref_util::PrefValue; -+use servo_constellation_traits::ScriptToConstellationMessage; ++use servo_constellation_traits::{BlobImpl, ScriptToConstellationMessage}; +use servo_url::ServoUrl; + +use crate::dom::bindings::codegen::Bindings::CustomEventBinding::CustomEventMethods; +use crate::dom::bindings::codegen::Bindings::EmbedderBinding::{ + EmbedderMethods, TaskProviderDescriptor, +}; ++use crate::dom::bindings::codegen::Bindings::HTMLWebViewElementBinding::ScreenshotOptions; ++use crate::dom::bindings::error::{Error, Fallible}; +use crate::dom::bindings::inheritance::Castable; +use crate::dom::bindings::reflector::DomGlobal; +use crate::dom::bindings::root::{DomRoot, MutNullableDom}; +use crate::dom::bindings::str::DOMString; ++use crate::dom::blob::Blob; +use crate::dom::contentblocker::ContentBlocker; +use crate::dom::customevent::CustomEvent; +use crate::dom::dbus::DBus; @@ -41,6 +49,8 @@ +use crate::dom::globalscope::GlobalScope; +use crate::dom::pairing::Pairing; +use crate::dom::power::Power; ++use crate::dom::promise::Promise; ++use crate::routed_promise::{RoutedPromiseListener, callback_promise}; + +#[dom_struct] +pub(crate) struct Embedder { @@ -517,6 +527,53 @@ + ); + } + ++ /// Capture this page's whole webview, embedded ``s included. ++ /// ++ /// Shares the capture path with `HTMLWebViewElement.takeScreenshot`, which ++ /// can only be called on an embedded `` element. The chrome has no ++ /// element standing for itself, so it asks by its own webview id instead. ++ fn CaptureScreen( ++ &self, ++ cx: &mut JSContext, ++ options: &ScreenshotOptions, ++ ) -> Fallible> { ++ let global = self.global(); ++ let Some(webview_id) = global.webview_id() else { ++ return Err(Error::InvalidState(Some( ++ "This page is not in a webview".to_string(), ++ ))); ++ }; ++ ++ let promise = Promise::new(cx, &global); ++ ++ let image_type = match &*options.type_.str() { ++ "image/jpeg" => ScreenshotImageType::Jpeg, ++ "image/webp" => ScreenshotImageType::Webp, ++ _ => ScreenshotImageType::Png, ++ }; ++ ++ let request = EmbeddedWebViewScreenshotRequest { ++ image_type, ++ quality: options.quality.clamp(0.0, 1.0), ++ }; ++ ++ let callback = callback_promise( ++ &promise, ++ self, ++ global.task_manager().dom_manipulation_task_source(), ++ ); ++ ++ if let Err(error) = global.script_to_constellation_chan().send( ++ ScriptToConstellationMessage::EmbeddedWebViewTakeScreenshot( ++ webview_id, request, callback, ++ ), ++ ) { ++ log::warn!("Failed to send EmbeddedWebViewTakeScreenshot: {error:?}"); ++ } ++ ++ Ok(promise) ++ } ++ + fn Pairing(&self, cx: &mut JSContext) -> DomRoot { + self.pairing.or_init(|| Pairing::new(cx, &self.global())) + } @@ -554,3 +611,35 @@ + SetOntaskprovidersupdate + ); +} ++ ++/// Turn a capture reply into a `Blob`, or a rejection. ++impl RoutedPromiseListener> ++ for Embedder ++{ ++ fn handle_response( ++ &self, ++ cx: &mut JSContext, ++ response: Result, ++ promise: &Rc, ++ ) { ++ match response { ++ Ok(result) => { ++ let global = self.global(); ++ let blob_impl = BlobImpl::new_from_bytes(result.bytes, result.mime_type); ++ let blob = Blob::new(cx, &global, blob_impl); ++ promise.resolve_native(cx, &blob); ++ }, ++ Err(error) => { ++ let message = match error { ++ EmbeddedWebViewScreenshotError::NotEmbeddedWebView => "Not an embedded webview", ++ EmbeddedWebViewScreenshotError::WebViewDoesNotExist => { ++ "The webview does not exist" ++ }, ++ EmbeddedWebViewScreenshotError::CaptureFailed => "Failed to capture the screen", ++ EmbeddedWebViewScreenshotError::EncodingFailed => "Failed to encode the image", ++ }; ++ promise.reject_error(cx, Error::InvalidState(Some(message.to_string()))); ++ }, ++ } ++ } ++} diff --git a/patches/components/script/dom/html/htmlwebviewelement.rs.patch b/patches/components/script/dom/html/htmlwebviewelement.rs.patch index 1a04168..1840940 100644 --- a/patches/components/script/dom/html/htmlwebviewelement.rs.patch +++ b/patches/components/script/dom/html/htmlwebviewelement.rs.patch @@ -1,6 +1,6 @@ --- original +++ modified -@@ -0,0 +1,1745 @@ +@@ -0,0 +1,1747 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ @@ -744,6 +744,7 @@ + "screen-wake-lock" + }, + PermissionFeature::Gamepad => "gamepad", ++ PermissionFeature::IdleDetection => "idle-detection", + }; + + let feature_name = match permission_request.feature { @@ -762,6 +763,7 @@ + "Screen Wake Lock" + }, + PermissionFeature::Gamepad => "Gamepad", ++ PermissionFeature::IdleDetection => "Idle Detection", + }; + + // Store the response sender for later use by respondToPermissionPrompt diff --git a/patches/components/script/dom/idledetector.rs.patch b/patches/components/script/dom/idledetector.rs.patch new file mode 100644 index 0000000..c4e2cbf --- /dev/null +++ b/patches/components/script/dom/idledetector.rs.patch @@ -0,0 +1,361 @@ +--- original ++++ modified +@@ -0,0 +1,358 @@ ++/* This Source Code Form is subject to the terms of the Mozilla Public ++ * License, v. 2.0. If a copy of the MPL was not distributed with this ++ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ ++ ++//! The Idle Detection API: ++//! ++//! The constellation reports elapsed-since-last-interaction (see ++//! `IdleStateEvent`); each detector applies its own threshold to that and arms a ++//! timer for whatever is left. ++ ++use std::cell::Cell; ++use std::rc::Rc; ++use std::time::{Duration, Instant}; ++ ++use dom_struct::dom_struct; ++use js::context::JSContext; ++use js::jsval::UndefinedValue; ++use js::rust::HandleObject; ++use script_bindings::error::Error; ++use script_bindings::reflector::reflect_dom_object_with_proto; ++use servo_constellation_traits::IdleStateEvent; ++ ++use crate::dom::abortsignal::{AbortAlgorithm, IdleDetectorAbort}; ++use crate::dom::bindings::codegen::Bindings::AbortSignalBinding::AbortSignalMethods; ++use crate::dom::bindings::codegen::Bindings::IdleDetectorBinding::{ ++ IdleDetectorMethods, IdleOptions, ScreenIdleState, UserIdleState, ++}; ++use crate::dom::bindings::codegen::Bindings::PermissionStatusBinding::{ ++ PermissionName, PermissionState, ++}; ++use crate::dom::bindings::codegen::Bindings::WindowBinding::WindowMethods; ++use crate::dom::bindings::inheritance::Castable; ++use crate::dom::bindings::refcounted::Trusted; ++use crate::dom::bindings::reflector::DomGlobal; ++use crate::dom::bindings::root::DomRoot; ++use crate::dom::eventtarget::EventTarget; ++use crate::dom::globalscope::GlobalScope; ++use crate::dom::permissions::{descriptor_permission_state, request_permission_to_use}; ++use crate::dom::promise::Promise; ++use crate::dom::window::Window; ++use crate::timers::{OneshotTimerCallback, OneshotTimerHandle}; ++ ++/// The spec's minimum threshold, in milliseconds. Anything shorter would let a ++/// site read typing cadence off the active/idle edges (spec section 3.2). ++const MINIMUM_THRESHOLD_MS: u64 = 60_000; ++ ++/// `[[state]]` ++#[derive(Clone, Copy, JSTraceable, MallocSizeOf, PartialEq)] ++enum DetectorState { ++ Stopped, ++ Starting, ++ Started, ++} ++ ++/// ++#[dom_struct] ++pub(crate) struct IdleDetector { ++ eventtarget: EventTarget, ++ /// `[[state]]` ++ state: Cell, ++ /// `[[threshold]]`, in milliseconds. Only meaningful while started. ++ threshold: Cell, ++ /// `[[userState]]`. None until started, hence the nullable attribute. ++ user_state: Cell>, ++ /// `[[screenState]]`. None until started. ++ screen_state: Cell>, ++ /// When the user will have been idle for `threshold`, as of the last report. ++ /// Reports arrive far more often than the threshold elapses, so this moves ++ /// forward while the timer below stays armed. ++ #[ignore_malloc_size_of = "Defined in std::time"] ++ idle_deadline: Cell>, ++ /// Armed while the user is active, to flip `user_state` once `idle_deadline` ++ /// passes. Deliberately not re-armed per report: unscheduling walks the ++ /// global's whole timer list and wakes the timer thread, and reports arrive ++ /// every second. ++ idle_timer: Cell>, ++} ++ ++impl IdleDetector { ++ fn new_inherited() -> IdleDetector { ++ IdleDetector { ++ eventtarget: EventTarget::new_inherited(), ++ state: Cell::new(DetectorState::Stopped), ++ threshold: Cell::new(0), ++ user_state: Cell::new(None), ++ screen_state: Cell::new(None), ++ idle_deadline: Cell::new(None), ++ idle_timer: Cell::new(None), ++ } ++ } ++ ++ fn new_with_proto( ++ cx: &mut JSContext, ++ global: &GlobalScope, ++ proto: Option, ++ ) -> DomRoot { ++ reflect_dom_object_with_proto(cx, Box::new(IdleDetector::new_inherited()), global, proto) ++ } ++ ++ /// Stop monitoring. Used by the `signal` abort steps. ++ pub(crate) fn stop(&self) { ++ self.state.set(DetectorState::Stopped); ++ self.clear_timer(); ++ // Always a Window: the interface is `Exposed=Window`. ++ let global = self.global(); ++ global.as_window().Document().remove_idle_detector(self); ++ } ++ ++ fn clear_timer(&self) { ++ self.idle_deadline.set(None); ++ if let Some(handle) = self.idle_timer.take() { ++ self.global().unschedule_callback(handle); ++ } ++ } ++ ++ /// Arm the threshold timer for `delay`, unless one is already pending. ++ fn arm_timer(&self, delay: Duration) { ++ if self.idle_timer.get().is_some() { ++ return; ++ } ++ let callback = OneshotTimerCallback::IdleDetectorThreshold(IdleThresholdCallback { ++ detector: Trusted::new(self), ++ }); ++ self.idle_timer ++ .set(Some(self.global().schedule_callback(callback, delay))); ++ } ++ ++ /// ++ /// ++ /// Apply a report from the constellation, firing `change` if either state ++ /// moved. Called for every started detector in the document. ++ pub(crate) fn handle_idle_state(&self, event: &IdleStateEvent) { ++ if self.state.get() != DetectorState::Started { ++ return; ++ } ++ ++ let threshold = self.threshold.get(); ++ let user_state = if event.idle_for_ms >= threshold { ++ UserIdleState::Idle ++ } else { ++ UserIdleState::Active ++ }; ++ let screen_state = if event.screen_locked { ++ ScreenIdleState::Locked ++ } else { ++ ScreenIdleState::Unlocked ++ }; ++ ++ // While the user is active, idleness is still ahead of us: note when it ++ // would arrive and make sure a timer is pending, because the ++ // constellation rate-limits its reports and will not tell us the moment ++ // the threshold passes. An armed timer is left alone and re-checks the ++ // deadline when it fires, rather than being rescheduled every report. ++ if user_state == UserIdleState::Active { ++ let remaining = Duration::from_millis(threshold.saturating_sub(event.idle_for_ms)); ++ self.idle_deadline ++ .set(Instant::now().checked_add(remaining)); ++ self.arm_timer(remaining); ++ } else { ++ self.clear_timer(); ++ } ++ ++ self.update_states(user_state, Some(screen_state)); ++ } ++ ++ /// The timer fired. Either the user has now been idle for the threshold, or ++ /// they interacted since it was armed and the deadline moved out from under ++ /// it, in which case arm again for the remainder. ++ fn handle_threshold_reached(&self) { ++ self.idle_timer.set(None); ++ if self.state.get() != DetectorState::Started { ++ return; ++ } ++ if let Some(remaining) = self ++ .idle_deadline ++ .get() ++ .and_then(|deadline| deadline.checked_duration_since(Instant::now())) ++ { ++ self.arm_timer(remaining); ++ return; ++ } ++ self.update_states(UserIdleState::Idle, None); ++ } ++ ++ /// Set either state, firing one `change` if anything actually moved. Both ++ /// start out null, so the first report always counts as a change. ++ fn update_states(&self, user_state: UserIdleState, screen_state: Option) { ++ let mut changed = false; ++ if self.user_state.get() != Some(user_state) { ++ self.user_state.set(Some(user_state)); ++ changed = true; ++ } ++ if let Some(screen_state) = screen_state && ++ self.screen_state.get() != Some(screen_state) ++ { ++ self.screen_state.set(Some(screen_state)); ++ changed = true; ++ } ++ if !changed { ++ return; ++ } ++ ++ self.global() ++ .task_manager() ++ .idle_detection_task_source() ++ .queue_simple_event(self.upcast(), atom!("change")); ++ } ++} ++ ++impl IdleDetectorMethods for IdleDetector { ++ /// ++ fn Constructor( ++ cx: &mut JSContext, ++ window: &Window, ++ proto: Option, ++ ) -> DomRoot { ++ IdleDetector::new_with_proto(cx, window.as_global_scope(), proto) ++ } ++ ++ /// ++ fn GetUserState(&self) -> Option { ++ self.user_state.get() ++ } ++ ++ /// ++ fn GetScreenState(&self) -> Option { ++ self.screen_state.get() ++ } ++ ++ // https://wicg.github.io/idle-detection/#dom-idledetector-onchange ++ event_handler!(change, GetOnchange, SetOnchange); ++ ++ /// ++ fn RequestPermission(cx: &mut JSContext, window: &Window) -> Rc { ++ let global = window.as_global_scope(); ++ let promise = Promise::new(cx, global); ++ ++ // Step 2. If the relevant global object does not have transient ++ // activation, reject with "NotAllowedError". ++ if !window.has_transient_activation() { ++ promise.reject_error(cx, Error::NotAllowed(None)); ++ return promise; ++ } ++ ++ // Step 4. In parallel, request permission to use "idle-detection". ++ // ++ // Not actually in parallel: Servo's permission request blocks until the ++ // embedder answers. ++ let state = request_permission_to_use(PermissionName::Idle_detection, global); ++ promise.resolve_native(cx, &state); ++ promise ++ } ++ ++ /// ++ fn Start(&self, cx: &mut JSContext, options: &IdleOptions) -> Rc { ++ let global = self.global(); ++ let promise = Promise::new(cx, &global); ++ ++ // Step 1-2. If the document is not fully active, reject with ++ // "InvalidStateError". Always a Window: `Exposed=Window`. ++ let window = global.as_window(); ++ let document = window.Document(); ++ if !document.is_fully_active() { ++ promise.reject_error(cx, Error::InvalidState(None)); ++ return promise; ++ } ++ ++ // Step 3. If the document is not allowed to use "idle-detection", reject ++ // with "NotAllowedError". ++ if !document.allowed_to_use_feature(PermissionName::Idle_detection) { ++ promise.reject_error(cx, Error::NotAllowed(None)); ++ return promise; ++ } ++ ++ // Step 4. If [[state]] is not "stopped", reject with "InvalidStateError". ++ if self.state.get() != DetectorState::Stopped { ++ promise.reject_error(cx, Error::InvalidState(None)); ++ return promise; ++ } ++ ++ // Step 5. Set [[state]] to "starting". ++ self.state.set(DetectorState::Starting); ++ ++ // Step 6. If threshold is below the minimum, reject with a TypeError. ++ let threshold = options.threshold; ++ if threshold < minimum_threshold_ms(&global) { ++ self.state.set(DetectorState::Stopped); ++ promise.reject_error( ++ cx, ++ Error::Type(c"threshold is below the minimum".to_owned()), ++ ); ++ return promise; ++ } ++ ++ // Step 8. Honour options["signal"]. ++ if let Some(signal) = options.signal.as_ref() { ++ if signal.aborted() { ++ self.state.set(DetectorState::Stopped); ++ rooted!(&in(cx) let mut reason = UndefinedValue()); ++ signal.Reason(reason.handle_mut()); ++ promise.reject(cx, reason.handle()); ++ return promise; ++ } ++ signal.add(&AbortAlgorithm::IdleDetector(IdleDetectorAbort { ++ detector: Trusted::new(self), ++ promise: promise.clone(), ++ })); ++ } ++ ++ // Step 9. In parallel, check the permission state. ++ // ++ // The spec's algorithm only rejects on "denied", but its own prose says ++ // start() fails unless the permission has been *granted*, which is what ++ // Chromium and tests/wpt/tests/idle-detection/idle-permission.tentative ++ // expect. Follow the prose: "prompt" is not enough. ++ if descriptor_permission_state(PermissionName::Idle_detection, Some(&global)) != ++ PermissionState::Granted ++ { ++ self.state.set(DetectorState::Stopped); ++ promise.reject_error(cx, Error::NotAllowed(None)); ++ return promise; ++ } ++ ++ self.threshold.set(threshold); ++ self.state.set(DetectorState::Started); ++ ++ // Subscribe. This also delivers the current state, so a detector started ++ // while the user is already away reports "idle" rather than staying null. ++ document.add_idle_detector(self); ++ ++ promise.resolve_native(cx, &()); ++ promise ++ } ++} ++ ++/// The shortest threshold this global may ask for. ++/// ++/// Beaver deviation from the spec's flat 60s: privileged `beaver://` pages get ++/// 10s, so the chrome can run a screen timeout that is not absurdly long. ++fn minimum_threshold_ms(global: &GlobalScope) -> u64 { ++ if crate::dom::embedder::Embedder::is_allowed_to_embed_for_url(&global.get_url()) { ++ 10_000 ++ } else { ++ MINIMUM_THRESHOLD_MS ++ } ++} ++ ++/// Fires when a detector's threshold elapses with the user still away. ++#[derive(JSTraceable, MallocSizeOf)] ++pub(crate) struct IdleThresholdCallback { ++ pub(crate) detector: Trusted, ++} ++ ++impl IdleThresholdCallback { ++ pub(crate) fn invoke(self) { ++ self.detector.root().handle_threshold_reached(); ++ } ++} diff --git a/patches/components/script/dom/mod.rs.patch b/patches/components/script/dom/mod.rs.patch index f31ef13..5ff1da4 100644 --- a/patches/components/script/dom/mod.rs.patch +++ b/patches/components/script/dom/mod.rs.patch @@ -35,7 +35,12 @@ pub(crate) mod event; pub(crate) use self::event::*; pub(crate) mod eventsource; -@@ -283,6 +288,7 @@ +@@ -279,10 +284,12 @@ + pub(crate) use self::globalscope::*; + pub(crate) mod html; + pub(crate) use self::html::*; ++pub(crate) mod idledetector; + pub(crate) mod indexeddb; pub(crate) use self::indexeddb::*; pub(crate) mod intersectionobserver; pub(crate) use self::intersectionobserver::*; @@ -43,7 +48,7 @@ pub(crate) mod media; pub(crate) use self::media::*; pub(crate) mod mimetype; -@@ -294,10 +300,15 @@ +@@ -294,10 +301,15 @@ pub(crate) mod node; pub(crate) use self::node::*; pub(crate) mod notification; diff --git a/patches/components/script/dom/permission/permissions.rs.patch b/patches/components/script/dom/permission/permissions.rs.patch index f5956ad..e4acae8 100644 --- a/patches/components/script/dom/permission/permissions.rs.patch +++ b/patches/components/script/dom/permission/permissions.rs.patch @@ -1,6 +1,6 @@ --- original +++ modified -@@ -300,6 +300,7 @@ +@@ -300,9 +300,18 @@ if pref!(dom_permissions_testing_allowed_in_nonsecure_contexts) { return PermissionState::Granted; } @@ -8,7 +8,18 @@ return PermissionState::Denied; } -@@ -360,12 +361,24 @@ ++ // Beaver deviation: privileged `beaver://` pages are granted idle detection ++ // instead of prompting. ++ if feature == PermissionName::Idle_detection && ++ crate::dom::embedder::Embedder::is_allowed_to_embed_for_url(&global_scope.get_url()) ++ { ++ return PermissionState::Granted; ++ } ++ + // Step 3. Let feature be descriptor's name. + // The caller has already converted the descriptor into a name. + +@@ -360,12 +369,24 @@ return PermissionState::Denied; }; let (sender, receiver) = generic_channel::channel().expect("Failed to create IPC channel!"); @@ -38,3 +49,11 @@ match receiver.recv() { Ok(AllowOrDeny::Allow) => PermissionState::Granted, Ok(AllowOrDeny::Deny) => PermissionState::Denied, +@@ -397,6 +418,7 @@ + PermissionFeature::ScreenWakeLock(WakeLockType::Screen) + }, + PermissionName::Gamepad => PermissionFeature::Gamepad, ++ PermissionName::Idle_detection => PermissionFeature::IdleDetection, + } + } + } diff --git a/patches/components/script/messaging.rs.patch b/patches/components/script/messaging.rs.patch index bc6d548..04808dd 100644 --- a/patches/components/script/messaging.rs.patch +++ b/patches/components/script/messaging.rs.patch @@ -8,7 +8,7 @@ ScriptThreadMessage::SetDocumentActivity(id, ..) => Some(*id), ScriptThreadMessage::SetThrottled(_, id, ..) => Some(*id), ScriptThreadMessage::SetThrottledInContainingIframe(_, id, ..) => Some(*id), -@@ -110,6 +111,17 @@ +@@ -110,6 +111,18 @@ ScriptThreadMessage::UpdatePinchZoomInfos(id, _) => Some(*id), ScriptThreadMessage::SetAccessibilityActive(..) => None, ScriptThreadMessage::TriggerGarbageCollection => None, @@ -17,6 +17,7 @@ + ScriptThreadMessage::DispatchPairingEvent(..) => None, + ScriptThreadMessage::DispatchDBusSignal(..) => None, + ScriptThreadMessage::DispatchAtProtoEvent(..) => None, ++ ScriptThreadMessage::DispatchIdleState(id, _) => Some(*id), + ScriptThreadMessage::DispatchKeyboardInputEvent(..) => None, + ScriptThreadMessage::DispatchPeerStream(..) => None, + ScriptThreadMessage::SetSpatialNavigation(pipeline_id, ..) => Some(*pipeline_id), diff --git a/patches/components/script/script_thread.rs.patch b/patches/components/script/script_thread.rs.patch index ffdfa22..80b9f86 100644 --- a/patches/components/script/script_thread.rs.patch +++ b/patches/components/script/script_thread.rs.patch @@ -124,7 +124,7 @@ ScriptThreadMessage::ForwardKeyboardScroll(pipeline_id, scroll) => { if let Some(document) = self.documents.borrow().find_document(pipeline_id) { document.event_handler().do_keyboard_scroll(cx, scroll); -@@ -2034,6 +2085,49 @@ +@@ -2034,6 +2085,52 @@ ScriptThreadMessage::TriggerGarbageCollection => unsafe { JS_GC(cx, GCReason::API); }, @@ -147,6 +147,9 @@ + ScriptThreadMessage::DispatchAtProtoEvent(event) => { + self.handle_dispatch_atproto_event(cx, event); + }, ++ ScriptThreadMessage::DispatchIdleState(pipeline_id, event) => { ++ self.handle_dispatch_idle_state(pipeline_id, event); ++ }, + ScriptThreadMessage::DispatchKeyboardInputEvent(surrounding) => { + self.handle_dispatch_keyboard_input_event(cx, surrounding); + }, @@ -174,7 +177,7 @@ } } -@@ -3097,16 +3191,32 @@ +@@ -3097,16 +3194,32 @@ reason: UpdatePipelineIdReason, cx: &mut js::context::JSContext, ) { @@ -187,13 +190,15 @@ .borrow() .find_iframe(parent_pipeline_id, browsing_context_id); - let Some(frame_element) = frame_element else { +- return; + let webview = if iframe.is_none() { + self.documents + .borrow() + .find_webview(parent_pipeline_id, browsing_context_id) + } else { + None -+ }; + }; +- if !frame_element.update_pipeline_id(new_pipeline_id, reason, cx) { + let is_embedded_webview = webview.is_some(); + + let updated = if let Some(frame_element) = &iframe { @@ -203,15 +208,13 @@ + } else { return; }; -- if !frame_element.update_pipeline_id(new_pipeline_id, reason, cx) { + if !updated { - return; -- }; ++ return; + } let Some(window) = self.documents.borrow().find_window(new_pipeline_id) else { return; -@@ -3125,6 +3235,7 @@ +@@ -3125,6 +3238,7 @@ // is no need to pass along existing opener information that // will be discarded. None, @@ -219,7 +222,7 @@ ); } -@@ -3416,6 +3527,200 @@ +@@ -3416,6 +3530,212 @@ } } @@ -305,6 +308,18 @@ + } + } + ++ /// Report the user's idle state to the `IdleDetector`s of one document. Each ++ /// decides for itself whether the elapsed time meets its own threshold. ++ fn handle_dispatch_idle_state( ++ &self, ++ pipeline_id: PipelineId, ++ event: servo_constellation_traits::IdleStateEvent, ++ ) { ++ if let Some(document) = self.documents.borrow().find_document(pipeline_id) { ++ document.notify_idle_state(&event); ++ } ++ } ++ + fn handle_dispatch_keyboard_input_event( + &self, + cx: &mut js::context::JSContext, @@ -420,7 +435,7 @@ fn ask_constellation_for_top_level_info( &self, sender_webview_id: WebViewId, -@@ -3528,7 +3833,13 @@ +@@ -3528,7 +3848,13 @@ self.senders.pipeline_to_embedder_sender.clone(), self.senders.constellation_sender.clone(), incomplete.pipeline_id, @@ -435,7 +450,7 @@ incomplete.viewport_details, origin.clone(), final_url.clone(), -@@ -3550,6 +3861,8 @@ +@@ -3550,6 +3876,8 @@ #[cfg(feature = "webgpu")] self.gpu_id_hub.clone(), incomplete.load_data.inherited_secure_context, @@ -444,7 +459,7 @@ incomplete.theme, self.this.clone(), ); -@@ -3655,6 +3968,8 @@ +@@ -3655,6 +3983,8 @@ ); } @@ -453,7 +468,7 @@ document.set_ready_state(cx, DocumentReadyState::Loading); self.documents -@@ -3661,8 +3976,6 @@ +@@ -3661,8 +3991,6 @@ .borrow_mut() .insert(incomplete.pipeline_id, &document); @@ -462,7 +477,7 @@ // Initialize the browsing context for the window. let window_proxy = self.window_proxies.local_window_proxy( cx, -@@ -3673,6 +3986,7 @@ +@@ -3673,6 +4001,7 @@ incomplete.webview_id, incomplete.parent_info, incomplete.opener, @@ -470,7 +485,7 @@ ); if window_proxy.parent().is_some() { // https://html.spec.whatwg.org/multipage/#navigating-across-documents:delaying-load-events-mode-2 -@@ -3845,11 +4159,11 @@ +@@ -3845,11 +4174,11 @@ target_snapshot_params: TargetSnapshotParams, cx: &mut js::context::JSContext, ) { @@ -485,12 +500,12 @@ iframe.navigate_or_reload_child_browsing_context( load_data, history_handling, -@@ -3857,6 +4171,16 @@ +@@ -3857,7 +4186,17 @@ target_snapshot_params, cx, ); + return; -+ } + } + // The navigating browsing context may be an embedded (a top-level WebView with + // a parent), whose navigations are also routed here via `NavigateIframe`. + if let Some(webview) = self @@ -499,10 +514,11 @@ + .find_webview(parent_pipeline_id, browsing_context_id) + { + webview.navigate(load_data, history_handling, target_snapshot_params); - } ++ } } -@@ -4406,10 +4730,88 @@ + /// Turn javascript: URL into JS code to eval, according to the steps in +@@ -4406,10 +4745,88 @@ document.event_handler().handle_refresh_cursor(); } @@ -591,7 +607,7 @@ fn handle_request_screenshot_readiness( &self, webview_id: WebViewId, -@@ -4450,7 +4852,7 @@ +@@ -4450,7 +4867,7 @@ pinch_zoom_infos: PinchZoomInfos, ) { let Some(window) = self.documents.borrow().find_window(pipeline_id) else { diff --git a/patches/components/script/task_manager.rs.patch b/patches/components/script/task_manager.rs.patch index ee1f7fb..1611f3a 100644 --- a/patches/components/script/task_manager.rs.patch +++ b/patches/components/script/task_manager.rs.patch @@ -1,6 +1,6 @@ --- original +++ modified -@@ -141,6 +141,7 @@ +@@ -141,9 +141,11 @@ task_source_functions!(self, dom_manipulation_task_source, DOMManipulation); task_source_functions!(self, file_reading_task_source, FileReading); task_source_functions!(self, font_loading_task_source, FontLoading); @@ -8,3 +8,7 @@ task_source_functions!(self, gamepad_task_source, Gamepad); // FIXME(arihant2math): uncomment when geolocation is implemented. // task_source_functions!(self, geolocation_task_source, Geolocation); ++ task_source_functions!(self, idle_detection_task_source, IdleDetection); + task_source_functions!(self, media_element_task_source, MediaElement); + task_source_functions!( + self, diff --git a/patches/components/script/task_source.rs.patch b/patches/components/script/task_source.rs.patch new file mode 100644 index 0000000..554412b --- /dev/null +++ b/patches/components/script/task_source.rs.patch @@ -0,0 +1,19 @@ +--- original ++++ modified +@@ -52,6 +52,8 @@ + Geolocation, + /// + IntersectionObserver, ++ /// ++ IdleDetection, + /// + Storage, + /// +@@ -87,6 +89,7 @@ + TaskSourceName::Timer => ScriptThreadEventCategory::TimerEvent, + TaskSourceName::Gamepad => ScriptThreadEventCategory::InputEvent, + TaskSourceName::IntersectionObserver => ScriptThreadEventCategory::ScriptEvent, ++ TaskSourceName::IdleDetection => ScriptThreadEventCategory::ScriptEvent, + TaskSourceName::Storage => ScriptThreadEventCategory::ScriptEvent, + TaskSourceName::WebGPU => ScriptThreadEventCategory::ScriptEvent, + } diff --git a/patches/components/script/timers.rs.patch b/patches/components/script/timers.rs.patch index c9cca1e..f56cb23 100644 --- a/patches/components/script/timers.rs.patch +++ b/patches/components/script/timers.rs.patch @@ -1,6 +1,6 @@ --- original +++ modified -@@ -34,6 +34,7 @@ +@@ -34,9 +34,11 @@ use crate::dom::bindings::str::DOMString; use crate::dom::csp::CspReporting; use crate::dom::document::RefreshRedirectDue; @@ -8,19 +8,26 @@ use crate::dom::eventsource::EventSourceTimeoutCallback; use crate::dom::globalscope::GlobalScope; use crate::dom::globalscope::script_execution::{ErrorReporting, RethrowErrors}; -@@ -130,6 +131,7 @@ ++use crate::dom::idledetector::IdleThresholdCallback; + #[cfg(feature = "testbinding")] + use crate::dom::testbinding::TestBindingCallback; + use crate::dom::trustedtypes::trustedscript::TrustedScript; +@@ -130,6 +132,9 @@ #[cfg(feature = "testbinding")] TestBindingCallback(TestBindingCallback), RefreshRedirectDue(RefreshRedirectDue), + LongPressContextMenu(LongPressContextMenuCallback), ++ /// ++ IdleDetectorThreshold(IdleThresholdCallback), /// RunStepsAfterTimeout { /// Step 1. timerKey -@@ -154,6 +156,7 @@ +@@ -154,6 +159,8 @@ #[cfg(feature = "testbinding")] OneshotTimerCallback::TestBindingCallback(callback) => callback.invoke(cx), OneshotTimerCallback::RefreshRedirectDue(callback) => callback.invoke(cx, global), + OneshotTimerCallback::LongPressContextMenu(callback) => callback.invoke(cx), ++ OneshotTimerCallback::IdleDetectorThreshold(callback) => callback.invoke(), OneshotTimerCallback::RunStepsAfterTimeout { completion, .. } => { // // Step 4.4 Perform completionSteps. diff --git a/patches/components/script_bindings/codegen/Bindings.conf.patch b/patches/components/script_bindings/codegen/Bindings.conf.patch index bbb0417..a1b930c 100644 --- a/patches/components/script_bindings/codegen/Bindings.conf.patch +++ b/patches/components/script_bindings/codegen/Bindings.conf.patch @@ -53,7 +53,7 @@ +'Embedder': { + 'additionalTraits': ['crate::interfaces::EmbedderHelpers'], -+ 'cx': ['ContentBlocker', 'Dbus', 'Pairing', 'Power'], ++ 'cx': ['CaptureScreen', 'ContentBlocker', 'Dbus', 'Pairing', 'Power'], +}, + 'Event': { @@ -70,7 +70,15 @@ 'IDBCursor': { 'cx': ['Key', 'PrimaryKey'] }, -@@ -836,6 +878,10 @@ +@@ -832,10 +874,18 @@ + 'cx': ['Abort', 'ObjectStore', 'ObjectStoreNames'], + }, + ++'IdleDetector': { ++ 'cx': ['RequestPermission', 'Start'], ++}, ++ + 'IntersectionObserver': { 'cx': ['Thresholds'] }, @@ -81,7 +89,7 @@ 'KeyframeEffect': { 'cx': ['GetKeyframes', 'SetKeyframes'] }, -@@ -907,7 +953,7 @@ +@@ -907,7 +957,7 @@ }, 'Navigator': { @@ -90,7 +98,7 @@ 'Storage', 'Plugins', 'UserActivation', 'WakeLock', 'Xr', 'MediaDevices', 'MediaSession', 'Permissions', 'GetGamepads'], }, -@@ -961,6 +1007,10 @@ +@@ -961,6 +1011,10 @@ 'cx': ['RegisterPaint'], }, @@ -101,7 +109,7 @@ 'Performance': { 'cx': ['Mark', 'Measure', 'Navigation'], }, -@@ -1342,6 +1392,7 @@ +@@ -1342,6 +1396,7 @@ 'additionalTraits': ['crate::interfaces::WindowHelpers', 'crate::interfaces::HasOrigin'], 'realm': ['CreateImageBitmap', 'CreateImageBitmap_', 'WebdriverCallback', 'GetOpener', 'Fetch'], 'cx': [ diff --git a/patches/components/script_bindings/webidls/Embedder.webidl.patch b/patches/components/script_bindings/webidls/Embedder.webidl.patch index d8cd44d..38aed64 100644 --- a/patches/components/script_bindings/webidls/Embedder.webidl.patch +++ b/patches/components/script_bindings/webidls/Embedder.webidl.patch @@ -1,6 +1,6 @@ --- original +++ modified -@@ -0,0 +1,65 @@ +@@ -0,0 +1,73 @@ +/* SPDX Id: AGPL-3.0-or-later */ + +// Servo-specific API for communication between web content and the embedder. @@ -34,6 +34,14 @@ + // Register a task provider. Only available to privileged pages. + undefined registerTaskProvider(TaskProviderDescriptor descriptor); + ++ // Capture what is currently on screen, as an image Blob. ++ // ++ // This is the whole composited scene of this page's webview: the chrome and ++ // every embedded nested inside it, which are rendered as part of ++ // the same scene. It is a snapshot of the current frame, not a wait for the ++ // page to settle, so it is usable on a live animating UI. ++ [Throws] Promise captureScreen(optional ScreenshotOptions options = {}); ++ + // Event fired when Servo encounters an error. + // The event is a CustomEvent with detail: { errorType: string, message: string } + attribute EventHandler onservoerror; diff --git a/patches/components/script_bindings/webidls/IdleDetector.webidl.patch b/patches/components/script_bindings/webidls/IdleDetector.webidl.patch new file mode 100644 index 0000000..64b22eb --- /dev/null +++ b/patches/components/script_bindings/webidls/IdleDetector.webidl.patch @@ -0,0 +1,39 @@ +--- original ++++ modified +@@ -0,0 +1,36 @@ ++/* This Source Code Form is subject to the terms of the Mozilla Public ++ * License, v. 2.0. If a copy of the MPL was not distributed with this ++ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ ++ ++// https://wicg.github.io/idle-detection/ ++ ++enum UserIdleState { ++ "active", ++ "idle" ++}; ++ ++enum ScreenIdleState { ++ "locked", ++ "unlocked" ++}; ++ ++dictionary IdleOptions { ++ // The spec IDL has no default, but `start()` with no threshold (or an explicit ++ // `undefined`) is required to succeed, and 60s is the spec minimum. Making that ++ // the IDL default is what produces the observable behaviour the tests in ++ // tests/wpt/tests/idle-detection/basics.tentative.https.window.js expect. ++ [EnforceRange] unsigned long long threshold = 60000; ++ AbortSignal signal; ++}; ++ ++// The spec says Exposed=(Window,DedicatedWorker). Window only for now: idle state ++// is delivered per-document, so worker globals would need their own routing. ++[SecureContext, Exposed=Window, Pref="dom_idle_detection_enabled"] ++interface IdleDetector : EventTarget { ++ constructor(); ++ readonly attribute UserIdleState? userState; ++ readonly attribute ScreenIdleState? screenState; ++ attribute EventHandler onchange; ++ [Exposed=Window] static Promise requestPermission(); ++ Promise start(optional IdleOptions options = {}); ++}; diff --git a/patches/components/script_bindings/webidls/PermissionStatus.webidl.patch b/patches/components/script_bindings/webidls/PermissionStatus.webidl.patch new file mode 100644 index 0000000..1f5a408 --- /dev/null +++ b/patches/components/script_bindings/webidls/PermissionStatus.webidl.patch @@ -0,0 +1,11 @@ +--- original ++++ modified +@@ -28,6 +28,8 @@ + "persistent-storage", + "screen-wake-lock", + "gamepad", ++ // https://wicg.github.io/idle-detection/#permissions ++ "idle-detection", + }; + + [Pref="dom_permissions_enabled", Exposed=(Window,Worker)] diff --git a/patches/components/servo/servo.rs.patch b/patches/components/servo/servo.rs.patch index 3657b7c..3a9bd91 100644 --- a/patches/components/servo/servo.rs.patch +++ b/patches/components/servo/servo.rs.patch @@ -206,7 +206,30 @@ /// **EXPERIMENTAL:** Intialize GL accelerated media playback. This currently only works on a limited number /// of platforms. This should be run *before* creating [`Servo`] and its first [`WebView`]. pub fn initialize_gl_accelerated_media(display: NativeDisplay, api: GlApi, context: GlContext) { -@@ -1093,6 +1194,14 @@ +@@ -1079,6 +1180,22 @@ + .send(EmbedderToConstellationMessage::WebDriverCommand(command)); + } + ++ /// Report how long the user has been idle system-wide, for the Idle Detection ++ /// API (`IdleDetector`). ++ pub fn notify_user_idle_time(&self, idle_for: Duration) { ++ self.0 ++ .constellation_proxy ++ .send(EmbedderToConstellationMessage::NotifyUserIdleTime(idle_for)); ++ } ++ ++ /// Report whether a lock screen or screensaver is active, for ++ /// the Idle Detection API's `screenState`. ++ pub fn notify_screen_locked(&self, locked: bool) { ++ self.0 ++ .constellation_proxy ++ .send(EmbedderToConstellationMessage::NotifyScreenLocked(locked)); ++ } ++ + pub fn set_preference(&self, name: &str, value: PrefValue) { + let mut preferences = prefs::get().clone(); + preferences.set_value(name, value); +@@ -1093,6 +1210,14 @@ &self.0.site_data_manager } @@ -221,7 +244,7 @@ pub(crate) fn paint<'a>(&'a self) -> Ref<'a, Paint> { self.0.paint.borrow() } -@@ -1201,6 +1310,7 @@ +@@ -1201,6 +1326,7 @@ async_runtime: Box, public_storage_threads: StorageThreads, private_storage_threads: StorageThreads, @@ -229,7 +252,7 @@ ) { // Global configuration options, parsed from the command line. let opts = opts::get(); -@@ -1244,6 +1354,7 @@ +@@ -1244,6 +1370,7 @@ async_runtime, privileged_urls, wake_lock_provider: Box::new(DefaultWakeLockDelegate), diff --git a/patches/components/shared/constellation/from_script_message.rs.patch b/patches/components/shared/constellation/from_script_message.rs.patch index 0ab18d0..6fb56f4 100644 --- a/patches/components/shared/constellation/from_script_message.rs.patch +++ b/patches/components/shared/constellation/from_script_message.rs.patch @@ -87,7 +87,7 @@ /// Specifies the information required to load an iframe. #[derive(Debug, Deserialize, Serialize)] pub struct IFrameLoadInfo { -@@ -596,8 +633,141 @@ +@@ -596,8 +633,143 @@ pub enum ConstellationInterest { /// Interest in `storage` events (fired when another same-origin pipeline modifies storage). StorageEvent, @@ -98,6 +98,8 @@ + /// Interest in keyboard input-context changes: the focused editable's text or + /// selection changed. Used by the virtual keyboard's `surroundingtextchange` event. + KeyboardInput, ++ /// Interest in the user's idle state. Used by the Idle Detection API. ++ IdleState, +} + +#[derive(Deserialize, Serialize)] @@ -126,8 +128,8 @@ + SearchUrl(String, bool), + /// Run a read-only SQL query over the local store: (sql, text params). + QueryStore(String, Vec), -+} -+ + } + +/// Data returned by com.atproto.server.createSession xrpc calls. +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] @@ -171,8 +173,8 @@ + pub email_auth_factor: bool, + pub active: bool, + pub status: Option, - } - ++} ++ +/// Data returned by com.atproto.server.refreshSession xrpc calls. +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] @@ -229,7 +231,7 @@ /// Messages from the script to the constellation. #[derive(Deserialize, IntoStaticStr, Serialize)] pub enum ScriptToConstellationMessage { -@@ -643,6 +813,10 @@ +@@ -643,6 +815,10 @@ NewBroadcastChannelNameInRouter(BroadcastChannelRouterId, String, ImmutableOrigin), /// A global stopped managing broadcast channels for a given channel-name. RemoveBroadcastChannelNameInRouter(BroadcastChannelRouterId, String, ImmutableOrigin), @@ -240,7 +242,7 @@ /// Broadcast a message to all same-origin broadcast channels, /// excluding the source of the broadcast. ScheduleBroadcast(BroadcastChannelRouterId, BroadcastChannelMsg), -@@ -661,6 +835,9 @@ +@@ -661,6 +837,9 @@ Option, Option, ), @@ -250,7 +252,7 @@ /// Indicates whether this pipeline is currently running animations. ChangeRunningAnimationsState(AnimationState), /// Requests that a new 2D canvas thread be created. (This is done in the constellation because -@@ -748,6 +925,10 @@ +@@ -748,6 +927,10 @@ ScriptNewIFrame(IFrameLoadInfoWithData), /// Script has opened a new auxiliary browsing context. CreateAuxiliaryWebView(AuxiliaryWebViewCreationRequest), @@ -261,7 +263,7 @@ /// Mark a new document as active ActivateDocument, /// Set the document state for a pipeline (used by screenshot / reftests) -@@ -803,6 +984,209 @@ +@@ -803,6 +986,209 @@ /// aggregate lock count and notify the provider only when the count transitions from N to 0. /// ReleaseWakeLock(WakeLockType), diff --git a/patches/components/shared/constellation/lib.rs.patch b/patches/components/shared/constellation/lib.rs.patch index eabadb6..bb2429f 100644 --- a/patches/components/shared/constellation/lib.rs.patch +++ b/patches/components/shared/constellation/lib.rs.patch @@ -19,7 +19,7 @@ }; pub use from_script_message::*; use malloc_size_of_derive::MallocSizeOf; -@@ -30,15 +32,226 @@ +@@ -30,15 +32,242 @@ use rustc_hash::FxHashMap; use serde::{Deserialize, Serialize}; use servo_base::cross_process_instant::CrossProcessInstant; @@ -177,6 +177,22 @@ + FeedUpdated { did: String, added: u32 }, +} + ++/// The user's idle state, broadcast to the pipelines that registered ++/// `ConstellationInterest::IdleState`. Drives the Idle Detection API. ++/// ++/// This carries elapsed time rather than an active/idle flag because each ++/// `IdleDetector` has its own threshold: the constellation reports how long it ++/// has been since the user did anything, and every detector decides for itself ++/// whether that counts as idle. A detector that starts while the user is already ++/// away therefore gets the right answer from the first event. ++#[derive(Clone, Copy, Debug, Deserialize, Serialize)] ++pub struct IdleStateEvent { ++ /// Milliseconds since the last user interaction, as of this event. ++ pub idle_for_ms: u64, ++ /// Whether a lock screen is active. ++ pub screen_locked: bool, ++} ++ +/// Information about a remote P2P peer. +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct PeerInfo { @@ -248,7 +264,7 @@ /// Messages to the Constellation from the embedding layer, whether from `ServoRenderer` or /// from `libservo` itself. #[derive(IntoStaticStr)] -@@ -55,6 +268,15 @@ +@@ -55,6 +284,15 @@ ChangeViewportDetails(WebViewId, ViewportDetails, WindowSizeType), /// Inform the constellation of a theme change. ThemeChange(WebViewId, Theme), @@ -264,7 +280,20 @@ /// Requests that the constellation instruct script/layout to try to layout again and tick /// animations. TickAnimation(Vec), -@@ -116,6 +338,9 @@ +@@ -79,6 +317,12 @@ + BlurWebView, + /// Forward an input event to an appropriate ScriptTask. + ForwardInputEvent(WebViewId, InputEventAndId, Option), ++ /// Report how long the user has been idle system-wide, as the platform sees ++ /// it, for the Idle Detection API. ++ NotifyUserIdleTime(Duration), ++ /// Report whether a lock screen or screensaver is active, for ++ /// the Idle Detection API's `screenState`. ++ NotifyScreenLocked(bool), + /// Request that the given pipeline refresh the cursor by doing a hit test at the most + /// recently hovered cursor position and resetting the cursor. This happens after a + /// display list update is rendered. +@@ -116,6 +360,9 @@ UpdatePinchZoomInfos(PipelineId, PinchZoomInfos), /// Activate or deactivate accessibility features for the given `WebView`. SetAccessibilityActive(WebViewId, bool), diff --git a/patches/components/shared/embedder/lib.rs.patch b/patches/components/shared/embedder/lib.rs.patch index 0a7ad71..e2bc0fa 100644 --- a/patches/components/shared/embedder/lib.rs.patch +++ b/patches/components/shared/embedder/lib.rs.patch @@ -148,7 +148,15 @@ } impl Debug for EmbedderMsg { -@@ -1108,6 +1188,54 @@ +@@ -633,6 +713,7 @@ + PersistentStorage, + ScreenWakeLock(WakeLockType), + Gamepad, ++ IdleDetection, + } + + /// Used to specify the kind of input method editor appropriate to edit a field. +@@ -1108,6 +1189,54 @@ WebViewDoesNotExist, } @@ -203,7 +211,7 @@ #[derive(Clone, Copy, Debug, Deserialize, Serialize)] pub struct RgbColor { pub red: u8, -@@ -1209,3 +1337,26 @@ +@@ -1209,3 +1338,26 @@ /// from N to 0. fn release(&self, type_: WakeLockType) -> Result<(), Box>; } diff --git a/patches/components/shared/script/lib.rs.patch b/patches/components/shared/script/lib.rs.patch index 53944b2..6ac25b6 100644 --- a/patches/components/shared/script/lib.rs.patch +++ b/patches/components/shared/script/lib.rs.patch @@ -68,7 +68,7 @@ /// Notify the `ScriptThread` that the Servo renderer is no longer waiting on /// asynchronous image uploads for the given `Pipeline`. These are mainly used /// by canvas to perform uploads while the display list is being built. -@@ -332,6 +357,30 @@ +@@ -332,6 +357,32 @@ SetAccessibilityActive(PipelineId, bool, Epoch), /// Force a garbage collection in this script thread. TriggerGarbageCollection, @@ -91,6 +91,8 @@ + DispatchDBusSignal(servo_constellation_traits::DbusSignalEvent), + /// Dispatch an ATProto event to `navigator.atproto` instances in this script thread. + DispatchAtProtoEvent(servo_constellation_traits::AtProtoEvent), ++ /// Report the user's idle state to the `IdleDetector`s in this pipeline. ++ DispatchIdleState(PipelineId, servo_constellation_traits::IdleStateEvent), + /// Dispatch a peer stream event — a remote peer is offering a MessagePort. + /// Contains (peer_id, serialized remote port_id bytes, stream_id, from_peer_id, target_url). + DispatchPeerStream(String, Vec, String, String, String), diff --git a/ui/shared/power/dusk.js b/ui/shared/power/dusk.js new file mode 100644 index 0000000..f51549e --- /dev/null +++ b/ui/shared/power/dusk.js @@ -0,0 +1,269 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +// The dusk transition: what the screen does on its way out. + +import { screenBrightness } from "beaver://shared/power/brightness.js"; +import { + cancelScreenOffEffect, + runScreenOffEffect, +} from "beaver://shared/power/screen_off_effects.js"; + +// Above --z-modal (8000): the power menu may be open when this runs. +const VEIL_Z_INDEX = 9000; + +// How dark the panel gets before it is cut. Not 0: the last step to black +// should be the panel switching off, not the backlight bottoming out first. +const FLOOR_PERCENT = 3; + +// The fallback veil at full strength. +const VEIL_MAX_OPACITY = 0.96; + +// Backlight steps, in percent. Each write is an IPC round trip, and asking for +// a level the panel cannot represent buys nothing. +const BACKLIGHT_STEP = 8; + +// --------------------------------------------------------------------------- +// Debug knobs, for watching the effect rather than using it. +// +// The real transitions last a few hundred milliseconds and finish by cutting +// the panel, which makes them almost impossible to inspect: the interesting +// frames are the ones you never see. +// +// DEBUG_SLOW_MS non-zero overrides every duration, so set 5000 to take +// five seconds. +// DEBUG_HOLD true means never let the caller blank the screen. The run +// ends by restoring the display instead, so the whole thing +// is visible and repeatable. +// +// Both are read per run, and the localStorage keys below win over the +// constants, so a value can be changed from a console without a reload. Editing +// the constants needs only a UI reload - no rebuild either way. +// --------------------------------------------------------------------------- +const DEBUG_SLOW_MS = 0; +const DEBUG_HOLD = false; + +// How long to sit on the finished frame before restoring, in hold mode. +const DEBUG_HOLD_LINGER_MS = 700; + +function debugNumber(key, fallback) { + try { + const value = Number(globalThis.localStorage?.getItem(key)); + return Number.isFinite(value) && value > 0 ? value : fallback; + } catch { + // localStorage can be unavailable; the constant is the answer then. + return fallback; + } +} + +function debugFlag(key, fallback) { + try { + const value = globalThis.localStorage?.getItem(key); + return value === null || value === undefined ? fallback : value === "true"; + } catch { + return fallback; + } +} + +// Bumped for every run, so a late frame from an aborted one cannot repaint. +let runId = 0; +let veil = null; +// Brightness before the current run, to put back afterwards. +let savedPercent = null; + +function reducedMotion() { + return globalThis.matchMedia?.("(prefers-reduced-motion: reduce)").matches; +} + +// Cubic ease-in: barely moves at first, then falls away. +function easeIn(t) { + return t * t * t; +} + +/** + * Ramp the backlight down to the floor over `duration`. + * + * Fire and forget: the caller runs this alongside the visual and does not wait + * for it. Steps are coarse and only written when the value actually changes. + */ +function rampBacklight(duration, id, cancelled) { + if (savedPercent === null) { + return; + } + const start = performance.now(); + let lastWritten = savedPercent; + + const step = (now) => { + if (cancelled(id)) { + return; + } + const t = Math.min(1, (now - start) / duration); + const span = savedPercent - FLOOR_PERCENT; + const ideal = savedPercent - span * easeIn(t); + const stepped = Math.max( + FLOOR_PERCENT, + Math.round(ideal / BACKLIGHT_STEP) * BACKLIGHT_STEP, + ); + if (stepped !== lastWritten) { + lastWritten = stepped; + screenBrightness.set(stepped); + } + if (t < 1) { + requestAnimationFrame(step); + } else if (lastWritten !== FLOOR_PERCENT) { + screenBrightness.set(FLOOR_PERCENT); + } + }; + requestAnimationFrame(step); +} + +function showVeil() { + veil = document.createElement("div"); + veil.className = "dusk-veil"; + veil.style.cssText = [ + "position: fixed", + "inset: 0", + `z-index: ${VEIL_Z_INDEX}`, + "pointer-events: none", + "background: radial-gradient(circle at 50% 45%," + + " oklch(36% 0.08 58) 0%," + + " oklch(22% 0.05 48) 100%)", + "opacity: 0", + ].join(";"); + document.body.appendChild(veil); + return veil; +} + +// Take the veil down. +function hideVeil() { + veil?.remove(); + veil = null; +} + +// The CSS fallback, used when the dissolve cannot run. +async function veilFade(duration, id, cancelled) { + const element = showVeil(); + const start = performance.now(); + rampBacklight(duration, id, cancelled); + + await new Promise((resolve) => { + const step = (now) => { + if (cancelled(id)) { + resolve(); + return; + } + const t = Math.min(1, (now - start) / duration); + element.style.opacity = String(easeIn(t) * VEIL_MAX_OPACITY); + if (t < 1) { + requestAnimationFrame(step); + } else { + resolve(); + } + }; + requestAnimationFrame(step); + }); + + return !cancelled(id); +} + +/** + * Run the transition, then resolve. + * + * Resolves `true` when it finished, meaning the caller should now cut the panel + * or shut down, and `false` when it was cancelled, in which case the screen is + * back the way it was and the caller should do nothing. + * + * `effect` names the shader to use; omitted, it follows the module default and + * the `beaver.dusk_effect` override. + * + * Brightness is restored either way. On the completing path the caller blanks + * the panel first, so putting the level back is invisible and the screen later + * returns at the user's setting rather than at the floor. + */ +export async function dusk({ duration = 900, effect } = {}) { + // Someone else's run is stale the moment this one starts. + cancelDusk(); + + const id = ++runId; + const cancelled = (runningId) => runningId !== runId; + + if (reducedMotion()) { + return true; + } + + const slow = debugNumber("beaver.dusk_slow_ms", DEBUG_SLOW_MS); + const hold = debugFlag("beaver.dusk_hold", DEBUG_HOLD); + if (slow) { + duration = slow; + } + + savedPercent = await screenBrightness.get(); + if (cancelled(id)) { + return false; + } + + // The backlight ramp starts only once the still is up. Dimming any earlier + // would be captured into the still and then applied a second time over it. + const finished = await runScreenOffEffect({ + duration, + effect, + onFrozen: () => rampBacklight(duration, id, cancelled), + }); + + if (cancelled(id)) { + return false; + } + + // The dissolve could not run here: no WebGL, or the capture was refused. + const completed = finished || (await veilFade(duration, id, cancelled)); + if (!completed) { + return false; + } + + if (hold) { + // Sit on the finished frame, then put the screen back rather than letting + // the caller blank it. Reported as "did not complete" so the caller does + // nothing, which is exactly the behaviour wanted while inspecting. + await new Promise((resolve) => { + setTimeout(resolve, DEBUG_HOLD_LINGER_MS); + }); + cancelDusk(); + return false; + } + + return true; +} + +/** + * Abort a run in progress and restore the screen. + * + * Safe to call when nothing is running. Callers should invoke this the moment + * the user comes back, before waking the panel. + */ +export function cancelDusk() { + runId += 1; + hideVeil(); + cancelScreenOffEffect(); + if (savedPercent !== null) { + screenBrightness.set(savedPercent); + savedPercent = null; + } +} + +/** + * Put the brightness back once the panel has been cut, so the screen returns + * at the user's level. Call after turning the panel off. + */ +export function restoreBrightness() { + hideVeil(); + cancelScreenOffEffect(); + if (savedPercent !== null) { + screenBrightness.set(savedPercent); + savedPercent = null; + } +} + +/** + * Build the GPU resources ahead of time, so the first transition does not pay + * for context creation at the moment it is meant to look smooth. + */ +export { warmScreenOffEffect } from "beaver://shared/power/screen_off_effects.js"; diff --git a/ui/shared/power/power_key.js b/ui/shared/power/power_key.js index 7f445e9..edd526e 100644 --- a/ui/shared/power/power_key.js +++ b/ui/shared/power/power_key.js @@ -4,6 +4,11 @@ // press offers the device power actions. import { screenPower } from "beaver://shared/power/screen.js"; +import { + cancelDusk, + dusk, + restoreBrightness, +} from "beaver://shared/power/dusk.js"; import { powerMenu, isPowerMenuOpen, @@ -51,6 +56,10 @@ export function bindPowerKey() { pressed = true; consumed = false; + // A press during the fade means the user changed their mind. Undo it + // here, on the way down, so neither branch below acts on a dim screen. + cancelDusk(); + // With the menu up, the button is the way back out of it. It must not // also toggle the screen on release. if (isPowerMenuOpen()) { @@ -90,13 +99,25 @@ export function bindPowerKey() { if (!wasPressed) { // Unexpected: a keyup with no keydown. Wake up to be on the safe side. + cancelDusk(); screenPower.wake(); return; } // Read the real state first: the screen may have been turned back on by // something else. - screenPower.read().then((on) => screenPower.set(!on)); + screenPower.read().then(async (on) => { + if (!on) { + cancelDusk(); + screenPower.wake(); + return; + } + // Shorter than the idle fade since this is a deliberate press. + if (await dusk({ duration: 500 })) { + screenPower.off(); + restoreBrightness(); + } + }); }, true, ); diff --git a/ui/shared/power/power_menu.js b/ui/shared/power/power_menu.js index ad399d9..30bd4fd 100644 --- a/ui/shared/power/power_menu.js +++ b/ui/shared/power/power_menu.js @@ -8,6 +8,7 @@ import { css, } from "beaver://shared/third_party/lit/lit-all.min.js"; import { systemPower } from "beaver://shared/power/system.js"; +import { cancelDusk, dusk } from "beaver://shared/power/dusk.js"; /** * How long to wait, after the embedder accepted an action, before saying that @@ -287,14 +288,23 @@ export class PowerMenu extends LitElement { return; } - // Accepted. Stay busy: the device is going down, and closing now would - // flash the UI back at the user for the last moment of its life. If it - // survives, hand the menu back rather than stranding the user in a modal - // whose only remaining exit is the power button. + // Accepted, and there is no coming back from this one, so the fade runs + // without a cancel path. Longer than the screen-off fades: the device is + // leaving for good, and the wait is covered by the shutdown itself, which + // takes far longer than this. + dusk({ duration: 800 }); + + // Stay busy: the device is going down, and closing now would flash the UI + // back at the user for the last moment of its life. If it survives, hand + // the menu back rather than stranding the user in a modal whose only + // remaining exit is the power button. this.#stallTimer = setTimeout(() => { this.#stallTimer = null; this._running = null; this._stalled = true; + // The device outlived the fade, so put the screen back with the menu: + // leaving it dimmed under an amber veil would look like a fault. + cancelDusk(); }, STALL_MS); } diff --git a/ui/shared/power/screen_off_effects.js b/ui/shared/power/screen_off_effects.js new file mode 100644 index 0000000..54fb654 --- /dev/null +++ b/ui/shared/power/screen_off_effects.js @@ -0,0 +1,400 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +// Screen-off effects: a captured still of the screen, animated on the GPU. +// +// Runs over the top of the live UI at modal z-index, so the frozen copy +// hides the still-running original underneath. +// +// Each effect is a fragment shader over that still, taking the same uniforms +// (`uScreen`, `uProgress`), so they are interchangeable. See `EFFECTS`. + +const VERT_SRC = `#version 300 es +in vec2 a_position; +// Texture coordinates, with y flipped: the capture arrives as a normal +// top-down image (the framebuffer readback already un-flips it), while GL +// texture space puts v=0 at the bottom. Without this the screen appears +// upside down. +out vec2 v_uv; +// Screen position, y up, for effects that care where a pixel is rather than +// which part of the image it holds. +out vec2 v_pos; +void main() { + v_pos = a_position * 0.5 + 0.5; + v_uv = vec2(v_pos.x, 1.0 - v_pos.y); + gl_Position = vec4(a_position, 0.0, 1.0); +} +`; + +// A dithered dissolve: each cell of the screen has its own moment to go, and +// crumbles through a warm burn on the way out. +// +// The important property is that at uProgress = 0 the output is pixel-identical +// to the capture. An earlier version lit ~8% of cells amber on the very first +// frame, scattered over the whole screen, which read as the image being +// replaced by static rather than dissolving. Every pixel here is strictly +// untouched until its own start time arrives. +// +// Per-cell noise decides that start time, so the fade never crosses a flat +// threshold and cannot band the way an 8-bit gradient does. +const FRAG_DISSOLVE = `#version 300 es +precision highp float; + +in vec2 v_uv; +in vec2 v_pos; +out vec4 outColor; + +uniform sampler2D uScreen; +uniform float uProgress; // 0 -> 1 + +// How long a single cell takes to crumble, as a fraction of the whole run. +// Wide enough that cells overlap heavily, so the screen is always mid-dissolve +// somewhere rather than resolving in visible waves. +const float CELL_SPAN = 0.28; + +// How much later the top of the screen goes than the bottom. Gives the collapse +// a direction without turning it into a hard wipe. +const float SWEEP = 0.65; + +// Grain size in device pixels. Large enough to read as flakes at arm's length; +// smaller values look like sensor noise. +const float CELL_PX = 3.0; + +// How dark the not-yet-dissolved image gets by the end. The intact region has +// to darken too, or the picture never appears to be going anywhere - it just +// gets eaten by black speckles. +const float DIM = 0.25; + +// Cheap value-noise hash. Grain, not smoothness. +float hash(vec2 p) { + p = fract(p * vec2(123.34, 456.21)); + p += dot(p, p + 45.32); + return fract(p.x * p.y); +} + +void main() { + vec3 screen = texture(uScreen, v_uv).rgb; + + vec2 cell = floor(gl_FragCoord.xy / CELL_PX); + // When this cell starts to go: its own noise, plus a delay for how high up it + // sits. Uses the screen position, not the texture coordinate, so the sweep + // direction is independent of the image's row order. + float startsAt = hash(cell) + v_pos.y * SWEEP; + + // Sweep far enough that the last cell finishes exactly at uProgress = 1. + float sweep = uProgress * (1.0 + SWEEP + CELL_SPAN); + + // 0 = untouched, 1 = gone. + float fade = smoothstep(startsAt, startsAt + CELL_SPAN, sweep); + + // The burn peaks halfway through a cell's own transition, so the glow travels + // with the crumbling edge instead of sitting on the whole screen. + float glow = 4.0 * fade * (1.0 - fade); + + vec3 ember = vec3(1.0, 0.45, 0.12); + vec3 color = screen * mix(1.0, DIM, uProgress); + color = mix(color, ember, glow * 0.8); + color *= 1.0 - fade; + + outColor = vec4(color, 1.0); +} +`; + +// An old CRT losing its deflection: the picture squashes into a bright +// horizontal line, the line whitens then contracts to a point. +const FRAG_CRT = `#version 300 es +precision highp float; + +in vec2 v_uv; +in vec2 v_pos; +out vec4 outColor; + +uniform sampler2D uScreen; +uniform float uProgress; + +// The vertical collapse finishes here, leaving a line. +const float COLLAPSE_END = 0.55; +// The line then contracts sideways between these. +const float PINCH_START = 0.55; +const float PINCH_END = 0.95; +// And the leftover glow decays over this. +const float DECAY_START = 0.80; + +// How much brighter the line gets as the picture folds into it. A real tube +// concentrates the same beam energy into less area. +const float MAX_BOOST = 4.0; + +// Slight horizontal stretch while collapsing, the way overscan grows as the +// vertical deflection fails. +const float STRETCH = 0.06; + +void main() { + float collapse = smoothstep(0.0, COLLAPSE_END, uProgress); + float pinch = smoothstep(PINCH_START, PINCH_END, uProgress); + float decay = 1.0 - smoothstep(DECAY_START, 1.0, uProgress); + + vec2 d = v_pos - 0.5; + + // Un-squash to find the source row: as the band closes, rows from further + // away are pulled into it, and eventually only the middle row survives. + float squash = max(1.0 - collapse, 1e-4); + float sy = 0.5 + d.y / squash; + float sx = 0.5 + d.x / (1.0 + STRETCH * collapse); + + vec3 color = vec3(0.0); + bool insideImage = sy >= 0.0 && sy <= 1.0 && sx >= 0.0 && sx <= 1.0; + // Once pinching, the line is also clipped inward from the sides. + float halfWidth = max(0.5 * (1.0 - pinch), 0.0015); + bool insideLine = abs(d.x) <= halfWidth; + + if (insideImage && insideLine) { + // v_uv is flipped for the image's row order, so flip the computed row too. + color = texture(uScreen, vec2(sx, 1.0 - sy)).rgb; + color *= 1.0 + (MAX_BOOST - 1.0) * collapse; + } + + // Phosphor bleed around the band: tight, and only once the collapse is under + // way, so the first frame carries no glow at all. + float spread = mix(0.06, 0.0015, collapse); + float bleed = exp(-abs(d.y) / spread) * collapse; + if (!insideLine) { + bleed = 0.0; + } + // Whitening rather than tinting: a saturating tube goes toward white. + color += vec3(0.85, 0.95, 1.0) * bleed * 0.9; + + outColor = vec4(color * decay, 1.0); +} +`; + +// The available effects, by name. Each is a fragment shader over the captured +// still taking the same two uniforms, so they are interchangeable. +const EFFECTS = { + dissolve: FRAG_DISSOLVE, + crt: FRAG_CRT, +}; + +// Which one a run uses unless told otherwise. Overridable per run, and from +// `localStorage` under `beaver.dusk_effect` for trying them out on device. +const DEFAULT_EFFECT = "crt"; + +// Bumped per run so a late frame from an aborted one cannot repaint. +let runId = 0; +let canvas = null; +let gl = null; +let texture = null; +// Compiled effects, by name. Built on demand and kept, because compiling in the +// middle of a transition is exactly the stall this module exists to avoid. +const programs = new Map(); + +function compile(type, source) { + const shader = gl.createShader(type); + gl.shaderSource(shader, source); + gl.compileShader(shader); + if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) { + console.error("[screen-off] compile:", gl.getShaderInfoLog(shader)); + return null; + } + return shader; +} + +// Build the canvas, context and program once and keep them. +export function warmScreenOffEffect() { + if (gl) { + return true; + } + + canvas = document.createElement("canvas"); + canvas.className = "screen-off-canvas"; + canvas.style.cssText = [ + "position: fixed", + "inset: 0", + "width: 100%", + "height: 100%", + "z-index: var(--z-modal, 8000)", + "pointer-events: none", + "display: none", + ].join(";"); + document.body.appendChild(canvas); + + gl = canvas.getContext("webgl2", { alpha: false, antialias: false }); + if (!gl) { + console.warn("[screen-off] WebGL2 unavailable"); + canvas.remove(); + canvas = null; + return false; + } + + const buffer = gl.createBuffer(); + gl.bindBuffer(gl.ARRAY_BUFFER, buffer); + gl.bufferData( + gl.ARRAY_BUFFER, + new Float32Array([-1, -1, 1, -1, -1, 1, -1, 1, 1, -1, 1, 1]), + gl.STATIC_DRAW, + ); + + texture = gl.createTexture(); + gl.bindTexture(gl.TEXTURE_2D, texture); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE); + + // Compile the effect that will actually be used, so the first run pays + // nothing. + return buildProgram(effectName()) !== null; +} + +// Which effect to use: the argument, else the localStorage override, else the +// default. +function effectName(requested) { + let name = requested; + if (!name) { + try { + name = globalThis.localStorage?.getItem("beaver.dusk_effect"); + } catch { + // No localStorage here; the default is the answer. + } + } + return name && name in EFFECTS ? name : DEFAULT_EFFECT; +} + +// Compile and link an effect, or return a cached one. Null if it cannot build. +function buildProgram(name) { + const cached = programs.get(name); + if (cached !== undefined) { + return cached; + } + + const vs = compile(gl.VERTEX_SHADER, VERT_SRC); + const fs = compile(gl.FRAGMENT_SHADER, EFFECTS[name]); + if (!vs || !fs) { + // Cache the failure: retrying a broken shader every frame is pointless. + programs.set(name, null); + return null; + } + + const program = gl.createProgram(); + gl.attachShader(program, vs); + gl.attachShader(program, fs); + gl.linkProgram(program); + if (!gl.getProgramParameter(program, gl.LINK_STATUS)) { + console.error(`[screen-off] link ${name}:`, gl.getProgramInfoLog(program)); + programs.set(name, null); + return null; + } + + // The quad attribute and uniform locations are per-program. + gl.useProgram(program); + const position = gl.getAttribLocation(program, "a_position"); + gl.enableVertexAttribArray(position); + gl.vertexAttribPointer(position, 2, gl.FLOAT, false, 0, 0); + + const entry = { + program, + screen: gl.getUniformLocation(program, "uScreen"), + progress: gl.getUniformLocation(program, "uProgress"), + }; + programs.set(name, entry); + return entry; +} + +// Grab the composited scene and upload it. Returns false when unavailable. +async function captureToTexture() { + const embedder = globalThis.navigator?.embedder; + if (typeof embedder?.captureScreen !== "function") { + return false; + } + + let bitmap; + try { + const blob = await embedder.captureScreen({ + type: "image/jpeg", + quality: 0.85, + }); + bitmap = await createImageBitmap(blob); + } catch (error) { + console.warn("[screen-off] capture failed:", error); + return false; + } + + canvas.width = bitmap.width; + canvas.height = bitmap.height; + gl.viewport(0, 0, bitmap.width, bitmap.height); + gl.bindTexture(gl.TEXTURE_2D, texture); + gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, bitmap); + bitmap.close?.(); + return true; +} + +/** + * Run the selected effect, then resolve. + * + * Resolves `true` only when the animation ran to the end. `false` covers both + * "could not run here" (no WebGL, capture refused) and "cancelled": the caller + * knows which, because it is the one that cancels. + * + * `onFrozen` fires once the still is captured and covering the screen. Anything + * that changes what the panel looks like - dimming the backlight, say - belongs + * there rather than earlier, or it would be captured into the still and then + * applied again on top of it. + */ +export async function runScreenOffEffect({ + duration = 900, + effect, + onFrozen, +} = {}) { + cancelScreenOffEffect(); + const id = ++runId; + const cancelled = () => id !== runId; + + if (!warmScreenOffEffect()) { + return false; + } + const chosen = buildProgram(effectName(effect)); + if (!chosen || !(await captureToTexture()) || cancelled()) { + return false; + } + + canvas.style.display = "block"; + onFrozen?.(); + const start = performance.now(); + + await new Promise((resolve) => { + const step = (now) => { + if (cancelled()) { + resolve(); + return; + } + const progress = Math.min(1, (now - start) / duration); + + gl.useProgram(chosen.program); + gl.bindTexture(gl.TEXTURE_2D, texture); + gl.uniform1i(chosen.screen, 0); + gl.uniform1f(chosen.progress, progress); + gl.drawArrays(gl.TRIANGLES, 0, 6); + + if (progress < 1) { + requestAnimationFrame(step); + } else { + resolve(); + } + }; + requestAnimationFrame(step); + }); + + return !cancelled(); +} + +/** + * Abort a run and take the frozen frame off the screen. + * + * Hidden rather than removed, because the canvas is kept for the next run; it + * is `pointer-events: none` and `display: none`, so it takes no input and has + * no box while idle. + */ +export function cancelScreenOffEffect() { + runId += 1; + if (canvas) { + canvas.style.display = "none"; + } +} diff --git a/ui/shared/power/screen_timeout.js b/ui/shared/power/screen_timeout.js new file mode 100644 index 0000000..1329a6e --- /dev/null +++ b/ui/shared/power/screen_timeout.js @@ -0,0 +1,90 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +// Blanks the screen after a period without user interaction, and brings it back +// on the next interaction. + +import { screenPower } from "beaver://shared/power/screen.js"; +import { + cancelDusk, + dusk, + restoreBrightness, +} from "beaver://shared/power/dusk.js"; + +// The lowest threshold a beaver:// page is allowed to ask for. +const MINIMUM_MS = 10_000; + +const PREF = "beaver.screen_timeout_ms"; + +let installed = false; + +// Milliseconds of inactivity before blanking, or 0 when the feature is off. +function configuredTimeout() { + let value = 0; + try { + value = Number(navigator.servo?.getIntPreference(PREF) ?? 0); + } catch { + // The pref is missing: treat that as "off". + return 0; + } + if (!Number.isFinite(value) || value <= 0) { + console.warn(`[screen-timeout] invalid value for ${PREF} : ${value}`); + return 0; + } + if (value < MINIMUM_MS) { + console.warn( + `[screen-timeout] ${PREF}=${value} is below the ${MINIMUM_MS}ms minimum, using ${MINIMUM_MS}`, + ); + return MINIMUM_MS; + } + return value; +} + +/** + * Start watching for inactivity. Safe to call more than once; does nothing when + * the pref is unset, the API is missing, or there is no screen to blank. + */ +export async function bindScreenTimeout() { + if (installed) { + return; + } + + // Unset or zero means the feature is off, which is the default: not + // something to warn about. + const threshold = configuredTimeout(); + if (!threshold) { + return; + } + if (typeof globalThis.IdleDetector !== "function") { + console.warn("[screen-timeout] IdleDetector is unavailable"); + return; + } + if (!screenPower.available) { + return; + } + + installed = true; + + const detector = new IdleDetector(); + detector.addEventListener("change", async () => { + // Only `userState` matters. + if (detector.userState === "idle") { + // Fade first. If the user comes back mid-fade the `active` branch cancels + // it, `dusk()` resolves false, and the panel is left alone. + if (await dusk()) { + screenPower.off(); + restoreBrightness(); + } + } else if (detector.userState === "active") { + cancelDusk(); + screenPower.wake(); + } + }); + + try { + await detector.start({ threshold }); + } catch (error) { + // Should never happen... + installed = false; + console.warn("[screen-timeout] could not start idle detection:", error); + } +} diff --git a/ui/system/mobile/init.js b/ui/system/mobile/init.js index 3c98637..f626a1b 100644 --- a/ui/system/mobile/init.js +++ b/ui/system/mobile/init.js @@ -4,12 +4,18 @@ import { WebView } from "../web_view.js"; import { MobileSplashScreen } from "./splash_screen.js"; import { notifications } from "../services/index.js"; import { bindPowerKey } from "beaver://shared/power/power_key.js"; +import { bindScreenTimeout } from "beaver://shared/power/screen_timeout.js"; +import { warmScreenOffEffect } from "beaver://shared/power/dusk.js"; export function initMobile( layoutManager, { createNewView, switchToHomescreen, openView, pairingHandler, services }, ) { bindPowerKey(); + // Off unless beaver.screen_timeout_ms is set. + bindScreenTimeout(); + // Build the screen-off effect's GL context ahead of when it's needed. + warmScreenOffEffect(); const splash = new MobileSplashScreen(); splash.show();