diff --git a/Cargo.lock b/Cargo.lock index 91c7ed6..e760328 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -793,6 +793,7 @@ version = "0.1.0" dependencies = [ "dbus", "log", + "serde", "wayland-client", "wayland-protocols-wlr", ] @@ -9905,6 +9906,7 @@ name = "servo-constellation-traits" version = "0.4.0" dependencies = [ "base64 0.22.1", + "beaver-hal", "beaver-p2p", "content-security-policy", "encoding_rs", @@ -10781,6 +10783,7 @@ dependencies = [ "backtrace", "base64 0.22.1", "base64ct", + "beaver-hal", "bitflags 2.13.1", "brotli", "buf-read-ext", diff --git a/crates/beaver_hal/Cargo.toml b/crates/beaver_hal/Cargo.toml index bf48428..00998fd 100644 --- a/crates/beaver_hal/Cargo.toml +++ b/crates/beaver_hal/Cargo.toml @@ -7,6 +7,7 @@ license = "AGPL-3.0-or-later" [dependencies] log = "0.4" dbus = "0.9" +serde = { workspace = true } # Wayland is Linux-only unlike dbus. [target.'cfg(target_os = "linux")'.dependencies] diff --git a/crates/beaver_hal/src/lib.rs b/crates/beaver_hal/src/lib.rs index 16bc80a..b4bfc1d 100644 --- a/crates/beaver_hal/src/lib.rs +++ b/crates/beaver_hal/src/lib.rs @@ -2,3 +2,4 @@ pub mod brightness; pub mod screen; +pub mod system; diff --git a/crates/beaver_hal/src/system/dummy.rs b/crates/beaver_hal/src/system/dummy.rs new file mode 100644 index 0000000..db6a2ee --- /dev/null +++ b/crates/beaver_hal/src/system/dummy.rs @@ -0,0 +1,26 @@ +// SPDX Id: AGPL-3.0-or-later + +//! Fallback for platforms without a real implementation (macOS, Windows). + +use log::warn; + +use crate::system::{RebootTarget, SystemPower}; + +#[derive(Debug)] +struct DummyPower; + +impl SystemPower for DummyPower { + fn shutdown(&self) -> Result<(), Box> { + warn!("[system] shutdown() ignored: not implemented on this platform"); + Ok(()) + } + + fn reboot(&self, target: RebootTarget) -> Result<(), Box> { + warn!("[system] reboot({target:?}) ignored: not implemented on this platform"); + Ok(()) + } +} + +pub(crate) fn system_power() -> Option> { + Some(Box::new(DummyPower)) +} diff --git a/crates/beaver_hal/src/system/linux.rs b/crates/beaver_hal/src/system/linux.rs new file mode 100644 index 0000000..2fb7e60 --- /dev/null +++ b/crates/beaver_hal/src/system/linux.rs @@ -0,0 +1,69 @@ +// SPDX Id: AGPL-3.0-or-later + +//! Shutdown and reboot via logind. +//! +//! `Manager.PowerOff` / `Manager.Reboot` take an `interactive` flag; we pass +//! `false`. With `true`, logind asks polkit to prompt the user through an +//! authentication agent, but we don't want that. + +use std::time::Duration; + +use dbus::blocking::Connection; +use log::debug; + +use crate::system::{RebootTarget, SystemPower}; + +const LOGIND_SERVICE: &str = "org.freedesktop.login1"; +const LOGIND_PATH: &str = "/org/freedesktop/login1"; +const MANAGER_IFACE: &str = "org.freedesktop.login1.Manager"; + +const TIMEOUT: Duration = Duration::from_secs(5); + +#[derive(Debug)] +struct LogindPower; + +impl LogindPower { + fn manager_call( + &self, + method: &str, + args: A, + ) -> Result<(), Box> { + let conn = Connection::new_system()?; + let proxy = conn.with_proxy(LOGIND_SERVICE, LOGIND_PATH, TIMEOUT); + let (): () = proxy + .method_call(MANAGER_IFACE, method, args) + .map_err(Box::new)?; + Ok(()) + } +} + +impl SystemPower for LogindPower { + fn shutdown(&self) -> Result<(), Box> { + debug!("[system] PowerOff"); + // PowerOff(in b interactive) + self.manager_call("PowerOff", (false,)) + } + + fn reboot(&self, target: RebootTarget) -> Result<(), Box> { + // A target other than the OS needs the reboot parameter set first. If + // that fails, report it rather than rebooting. + if let Some(parameter) = target.parameter() { + debug!("[system] SetRebootParameter({parameter})"); + // SetRebootParameter(in s parameter) + self.manager_call("SetRebootParameter", (parameter,)) + .map_err(|err| { + format!( + "cannot reboot to {parameter}: setting the reboot parameter failed ({err})" + ) + })?; + } + + debug!("[system] Reboot"); + // Reboot(in b interactive) + self.manager_call("Reboot", (false,)) + } +} + +pub(crate) fn system_power() -> Option> { + Some(Box::new(LogindPower)) +} diff --git a/crates/beaver_hal/src/system/mod.rs b/crates/beaver_hal/src/system/mod.rs new file mode 100644 index 0000000..be302d5 --- /dev/null +++ b/crates/beaver_hal/src/system/mod.rs @@ -0,0 +1,63 @@ +// SPDX Id: AGPL-3.0-or-later + +//! Powering the device off and rebooting it. + +use std::fmt::Debug; + +use serde::{Deserialize, Serialize}; + +/// What to boot into after a reboot. +#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Eq, Serialize)] +pub enum RebootTarget { + /// A normal reboot back into the OS. + System, + /// The bootloader / fastboot mode. + Bootloader, + /// The recovery partition. + Recovery, +} + +impl RebootTarget { + /// The reboot parameter the platform expects, or `None` for a plain reboot. + pub fn parameter(&self) -> Option<&'static str> { + match self { + RebootTarget::System => None, + RebootTarget::Bootloader => Some("bootloader"), + RebootTarget::Recovery => Some("recovery"), + } + } +} + +/// Device-level power actions. +pub trait SystemPower: Debug + Send + Sync { + /// Power the device off. + fn shutdown(&self) -> Result<(), Box>; + + /// Reboot, optionally into the bootloader or recovery. + fn reboot(&self, target: RebootTarget) -> Result<(), Box>; +} + +#[cfg(target_os = "linux")] +mod linux; +#[cfg(target_os = "linux")] +pub(crate) use linux::system_power; + +// Fallback implementation +#[cfg(not(target_os = "linux"))] +mod dummy; +#[cfg(not(target_os = "linux"))] +pub(crate) use dummy::system_power; + +/// The device's power actions, when this platform supports them. +#[derive(Debug)] +pub struct System { + pub power: Option>, +} + +impl Default for System { + fn default() -> Self { + Self { + power: system_power(), + } + } +} diff --git a/patches/components/constellation/constellation.rs.patch b/patches/components/constellation/constellation.rs.patch index db813f8..a0d1be4 100644 --- a/patches/components/constellation/constellation.rs.patch +++ b/patches/components/constellation/constellation.rs.patch @@ -549,7 +549,7 @@ }, #[cfg(feature = "webgpu")] ScriptToConstellationMessage::RequestAdapter(response_sender, options, ids) => self -@@ -2092,7 +2356,1075 @@ +@@ -2092,7 +2356,1081 @@ } }, }, @@ -950,6 +950,12 @@ + ScriptToConstellationMessage::PowerSetScreenOn(on) => { + self.power.set_screen_on(on); + }, ++ ScriptToConstellationMessage::PowerShutdown(callback) => { ++ self.power.shutdown(callback); ++ }, ++ ScriptToConstellationMessage::PowerReboot(target, callback) => { ++ self.power.reboot(target, callback); ++ }, + ScriptToConstellationMessage::CreatePeerStream( + peer_id, + local_port_id, @@ -1591,12 +1597,12 @@ + } + } + return; - } ++ } + + // 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, .. } | @@ -1625,7 +1631,7 @@ } /// Check the origin of a message against that of the pipeline it came from. -@@ -2411,6 +3743,55 @@ +@@ -2411,6 +3749,55 @@ TransferState::TransferInProgress(queue) => queue.push_back(task), TransferState::CompletionFailed(queue) => queue.push_back(task), TransferState::CompletionRequested(_, queue) => queue.push_back(task), @@ -1681,7 +1687,7 @@ } } -@@ -3222,6 +4603,101 @@ +@@ -3222,6 +4609,101 @@ ); } @@ -1783,7 +1789,7 @@ fn forward_input_event( &mut self, webview_id: WebViewId, -@@ -3241,6 +4717,66 @@ +@@ -3241,6 +4723,66 @@ let pressed_mouse_buttons = self.pressed_mouse_buttons; let active_keyboard_modifiers = self.active_keyboard_modifiers; @@ -1850,7 +1856,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 +4879,40 @@ +@@ -3343,6 +4885,40 @@ /// fn handle_close_top_level_browsing_context(&mut self, webview_id: WebViewId) { debug!("{webview_id}: Closing"); @@ -1891,7 +1897,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 +5189,27 @@ +@@ -3619,8 +5195,27 @@ opener_webview_id, opener_pipeline_id, response_sender, @@ -1919,7 +1925,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 +5308,402 @@ +@@ -3719,6 +5314,402 @@ }); } @@ -2322,7 +2328,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 +6253,7 @@ +@@ -4268,7 +6259,7 @@ }, }; @@ -2331,7 +2337,7 @@ match self.browsing_contexts.get_mut(&browsing_context_id) { Some(browsing_context) => { let old_pipeline_id = browsing_context.pipeline_id; -@@ -4277,6 +6262,7 @@ +@@ -4277,6 +6268,7 @@ old_pipeline_id, browsing_context.parent_pipeline_id, browsing_context.webview_id, @@ -2339,7 +2345,7 @@ ) }, None => { -@@ -4286,6 +6272,15 @@ +@@ -4286,6 +6278,15 @@ self.unload_document(old_pipeline_id); @@ -2355,7 +2361,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 +6849,7 @@ +@@ -4854,7 +6855,7 @@ } #[servo_tracing::instrument(skip_all)] @@ -2364,7 +2370,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 +6961,22 @@ +@@ -4966,9 +6967,22 @@ self.constellation_to_embedder_proxy .send(ConstellationToEmbedderMsg::HistoryChanged( webview_id, @@ -2388,7 +2394,7 @@ } #[servo_tracing::instrument(skip_all)] -@@ -4987,7 +6995,7 @@ +@@ -4987,7 +7001,7 @@ webview.focused_browsing_context_id = change.browsing_context_id; } @@ -2397,7 +2403,7 @@ match self.browsing_contexts.get_mut(&change.browsing_context_id) { Some(browsing_context) => { debug!("Adding pipeline to existing browsing context."); -@@ -4994,11 +7002,15 @@ +@@ -4994,11 +7008,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); @@ -2415,7 +2421,7 @@ }, }; -@@ -5006,6 +7018,18 @@ +@@ -5006,6 +7024,18 @@ self.unload_document(old_pipeline_id); } diff --git a/patches/components/constellation/power_service.rs.patch b/patches/components/constellation/power_service.rs.patch index e5f9fe7..23bde95 100644 --- a/patches/components/constellation/power_service.rs.patch +++ b/patches/components/constellation/power_service.rs.patch @@ -1,20 +1,19 @@ --- original +++ modified -@@ -0,0 +1,203 @@ +@@ -0,0 +1,251 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + -+//! Screen/keyboard brightness, and screen power. ++//! Screen/keyboard brightness, screen power, and device power actions. +//! +//! The devices are enumerated once, lazily, and addressed afterwards by their -+//! index in that list (`BrightnessDeviceInfo::id`). Enumeration, reads and writes -+//! all touch the platform (sysfs, and a blocking logind D-Bus call on Linux), so -+//! they run on the blocking pool rather than the constellation thread. ++//! index in that list (`BrightnessDeviceInfo::id`). + +use std::collections::HashMap; +use std::sync::{Arc, Mutex, OnceLock}; + +use beaver_hal::brightness::{BrightnessDevice, Devices}; +use beaver_hal::screen::{Screen, ScreenPower}; ++use beaver_hal::system::{RebootTarget, System, SystemPower}; +use log::{error, warn}; +use net::async_runtime::spawn_blocking; +use servo_base::generic_channel::GenericCallback; @@ -23,10 +22,12 @@ +/// The enumerated devices, shared with the blocking tasks that use them. +type DeviceCell = Arc>>>; + -+/// The screen power control, or `None` where the platform has none. Connecting -+/// talks to the compositor, so this is also resolved inside a blocking task. ++/// The screen power control, or `None` where the platform has none. +type ScreenCell = Arc>>>; + ++/// The device's power actions, or `None` where unsupported. ++type SystemCell = Arc>>>; ++ +/// Device id -> the newest percent not yet written. A single entry exists while +/// a writer task is running for that device. +type PendingWrites = Arc>>>; @@ -39,6 +40,7 @@ +pub(crate) struct PowerService { + devices: DeviceCell, + screen: ScreenCell, ++ system: SystemCell, + pending: PendingWrites, + screen_pending: PendingScreenWrite, +} @@ -52,16 +54,22 @@ + }) +} + -+/// Connect to the platform's screen power control on first use. ++/// Connect to the platform's screen power control on first use, then reuse it. +fn screen_of(cell: &OnceLock>>) -> Option<&dyn ScreenPower> { + cell.get_or_init(|| Screen::default().power).as_deref() +} + ++/// The device's power actions, resolved on first use. ++fn system_of(cell: &OnceLock>>) -> Option<&dyn SystemPower> { ++ cell.get_or_init(|| System::default().power).as_deref() ++} ++ +impl PowerService { + pub(crate) fn new() -> Self { + Self { + devices: Arc::new(OnceLock::new()), + screen: Arc::new(OnceLock::new()), ++ system: Arc::new(OnceLock::new()), + pending: Arc::new(Mutex::new(HashMap::new())), + screen_pending: Arc::new(Mutex::new(None)), + } @@ -163,7 +171,7 @@ + }); + } + -+ /// Turn the screen on or off. Fire and forget, and coalesced. ++ /// Turn the screen on or off. Fire and forget, like `set_brightness`, and coalesced. + pub(crate) fn set_screen_on(&self, on: bool) { + { + let mut pending = self.screen_pending.lock().expect("power pending lock"); @@ -203,4 +211,44 @@ + } + }); + } ++ ++ /// Power the device off. ++ /// ++ /// The caller can know it was refused (polkit may deny it) from the callback. ++ pub(crate) fn shutdown(&self, callback: GenericCallback>) { ++ let cell = self.system.clone(); ++ spawn_blocking(move || { ++ let response = match system_of(&cell) { ++ Some(system) => system.shutdown().map_err(|err| err.to_string()), ++ None => Err("No power control on this platform".to_owned()), ++ }; ++ if let Err(err) = &response { ++ warn!("[Power] Shutdown failed: {err}"); ++ } ++ if let Err(err) = callback.send(response) { ++ error!("[Power] Failed to send shutdown result: {err:?}"); ++ } ++ }); ++ } ++ ++ /// Reboot the device, optionally into the bootloader or recovery. ++ pub(crate) fn reboot( ++ &self, ++ target: RebootTarget, ++ callback: GenericCallback>, ++ ) { ++ let cell = self.system.clone(); ++ spawn_blocking(move || { ++ let response = match system_of(&cell) { ++ Some(system) => system.reboot(target).map_err(|err| err.to_string()), ++ None => Err("No power control on this platform".to_owned()), ++ }; ++ if let Err(err) = &response { ++ warn!("[Power] Reboot ({target:?}) failed: {err}"); ++ } ++ if let Err(err) = callback.send(response) { ++ error!("[Power] Failed to send reboot result: {err:?}"); ++ } ++ }); ++ } +} diff --git a/patches/components/constellation/tracing.rs.patch b/patches/components/constellation/tracing.rs.patch index 1c6535d..24b5c1f 100644 --- a/patches/components/constellation/tracing.rs.patch +++ b/patches/components/constellation/tracing.rs.patch @@ -53,7 +53,7 @@ Self::ActivateDocument => target!("ActivateDocument"), Self::SetDocumentState(..) => target!("SetDocumentState"), Self::SetFinalUrl(..) => target!("SetFinalUrl"), -@@ -192,6 +203,82 @@ +@@ -192,6 +203,84 @@ Self::TriggerGarbageCollection => target!("TriggerGarbageCollection"), Self::AcquireWakeLock(..) => target!("AcquireWakeLock"), Self::ReleaseWakeLock(..) => target!("ReleaseWakeLock"), @@ -133,6 +133,8 @@ + Self::PowerSetBrightness(..) => target!("PowerSetBrightness"), + Self::PowerGetScreenOn(..) => target!("PowerGetScreenOn"), + Self::PowerSetScreenOn(..) => target!("PowerSetScreenOn"), ++ Self::PowerShutdown(..) => target!("PowerShutdown"), ++ Self::PowerReboot(..) => target!("PowerReboot"), } } } diff --git a/patches/components/script/Cargo.toml.patch b/patches/components/script/Cargo.toml.patch new file mode 100644 index 0000000..ff2019f --- /dev/null +++ b/patches/components/script/Cargo.toml.patch @@ -0,0 +1,10 @@ +--- original ++++ modified +@@ -51,6 +51,7 @@ + backtrace = { workspace = true } + base64 = { workspace = true } + base64ct = { workspace = true } ++beaver-hal = { path = "../../../crates/beaver_hal" } + bitflags = { workspace = true } + brotli = { workspace = true } + buf-read-ext = { workspace = true } diff --git a/patches/components/script/dom/power.rs.patch b/patches/components/script/dom/power.rs.patch index 95cea9d..6b22583 100644 --- a/patches/components/script/dom/power.rs.patch +++ b/patches/components/script/dom/power.rs.patch @@ -1,21 +1,22 @@ --- original +++ modified -@@ -0,0 +1,133 @@ +@@ -0,0 +1,188 @@ +/* SPDX Id: AGPL-3.0-or-later */ + +//! `navigator.embedder.power`: control of the device's brightness-capable -+//! hardware, and the screen's power state. ++//! hardware, the screen's power state, and device power actions. + +use std::cell::Cell; +use std::rc::Rc; + ++use beaver_hal::system::RebootTarget as HalRebootTarget; +use dom_struct::dom_struct; +use js::context::JSContext; +use script_bindings::error::Error; +use script_bindings::reflector::{Reflector, reflect_dom_object_with_cx}; +use servo_constellation_traits::{BrightnessDeviceInfo, ScriptToConstellationMessage}; + -+use crate::dom::bindings::codegen::Bindings::PowerBinding::PowerMethods; ++use crate::dom::bindings::codegen::Bindings::PowerBinding::{PowerMethods, RebootTarget}; +use crate::dom::bindings::reflector::DomGlobal; +use crate::dom::bindings::root::DomRoot; +use crate::dom::brightnessdevice::BrightnessDevice; @@ -35,7 +36,7 @@ + fn new_inherited() -> Power { + Power { + _reflector: Reflector::new(), -+ // TODO: initialize with actual screen state. ++ // TODO: initialize with the current value. + screen_on: Cell::new(true), + } + } @@ -77,8 +78,7 @@ + .send(ScriptToConstellationMessage::PowerSetScreenOn(on)); + } + -+ /// Re-read the real state and refresh the cached value. Needed because the -+ /// cache only tracks this page's own writes. ++ /// Re-read the real state and refresh the cached value. + fn ReadScreenOn(&self, cx: &mut JSContext) -> Rc { + let global = &self.global(); + let promise = Promise::new(cx, global); @@ -95,6 +95,67 @@ + } + promise + } ++ ++ fn Shutdown(&self, cx: &mut JSContext) -> Rc { ++ let global = &self.global(); ++ let promise = Promise::new(cx, global); ++ let task_manager = global.task_manager(); ++ let task_source = task_manager.dom_manipulation_task_source(); ++ let callback = callback_promise(&promise, self, task_source); ++ ++ let chan = global.script_to_constellation_chan(); ++ if chan ++ .send(ScriptToConstellationMessage::PowerShutdown(callback)) ++ .is_err() ++ { ++ promise.reject_error(cx, Error::Operation(None)); ++ } ++ promise ++ } ++ ++ fn Reboot(&self, cx: &mut JSContext, target: RebootTarget) -> Rc { ++ let global = &self.global(); ++ let promise = Promise::new(cx, global); ++ let task_manager = global.task_manager(); ++ let task_source = task_manager.dom_manipulation_task_source(); ++ let callback = callback_promise(&promise, self, task_source); ++ ++ let target = match target { ++ RebootTarget::System => HalRebootTarget::System, ++ RebootTarget::Bootloader => HalRebootTarget::Bootloader, ++ RebootTarget::Recovery => HalRebootTarget::Recovery, ++ }; ++ ++ let chan = global.script_to_constellation_chan(); ++ if chan ++ .send(ScriptToConstellationMessage::PowerReboot(target, callback)) ++ .is_err() ++ { ++ promise.reject_error(cx, Error::Operation(None)); ++ } ++ promise ++ } ++} ++ ++impl RoutedPromiseListener, String>> for Power { ++ fn handle_response( ++ &self, ++ cx: &mut JSContext, ++ response: Result, String>, ++ promise: &Rc, ++ ) { ++ match response { ++ Ok(infos) => { ++ let global = self.global(); ++ let devices: Vec> = infos ++ .into_iter() ++ .map(|info| BrightnessDevice::new(cx, &global, info)) ++ .collect(); ++ promise.resolve_native(cx, &devices); ++ }, ++ Err(msg) => promise.reject_error(cx, Error::Operation(Some(msg))), ++ } ++ } +} + +impl RoutedPromiseListener> for Power { @@ -114,22 +175,16 @@ + } +} + -+impl RoutedPromiseListener, String>> for Power { ++/// Shutdown and reboot: nothing to resolve with, only whether it was accepted. ++impl RoutedPromiseListener> for Power { + fn handle_response( + &self, + cx: &mut JSContext, -+ response: Result, String>, ++ response: Result<(), String>, + promise: &Rc, + ) { + match response { -+ Ok(infos) => { -+ let global = self.global(); -+ let devices: Vec> = infos -+ .into_iter() -+ .map(|info| BrightnessDevice::new(cx, &global, info)) -+ .collect(); -+ promise.resolve_native(cx, &devices); -+ }, ++ Ok(()) => promise.resolve_native(cx, &()), + Err(msg) => promise.reject_error(cx, Error::Operation(Some(msg))), + } + } diff --git a/patches/components/script_bindings/codegen/Bindings.conf.patch b/patches/components/script_bindings/codegen/Bindings.conf.patch index 3279fc5..ed824ca 100644 --- a/patches/components/script_bindings/codegen/Bindings.conf.patch +++ b/patches/components/script_bindings/codegen/Bindings.conf.patch @@ -37,7 +37,7 @@ +}, + +'Power': { -+ 'cx': ['Devices', 'ReadScreenOn'], ++ 'cx': ['Devices', 'ReadScreenOn', 'Shutdown', 'Reboot'], +}, + +'BrightnessDevice': { diff --git a/patches/components/script_bindings/webidls/Power.webidl.patch b/patches/components/script_bindings/webidls/Power.webidl.patch index 5d32a7c..f670371 100644 --- a/patches/components/script_bindings/webidls/Power.webidl.patch +++ b/patches/components/script_bindings/webidls/Power.webidl.patch @@ -1,6 +1,6 @@ --- original +++ modified -@@ -0,0 +1,48 @@ +@@ -0,0 +1,56 @@ +/* SPDX Id: AGPL-3.0-or-later */ + +// Control of the device's brightness-capable hardware (screen backlight, keyboard LEDs). @@ -29,20 +29,28 @@ + readonly attribute boolean isOnOff; +}; + ++// What to boot into after a reboot. ++enum RebootTarget { "system", "bootloader", "recovery" }; ++ +[Exposed=Window, +Func="Embedder::is_allowed_to_embed"] +interface Power { -+ // All brightness-controllable devices, backlights first. Resolves with an -+ // empty list on platforms with no such hardware. ++ // All brightness-controllable devices, backlights first. + Promise> devices(); + + // Whether the screen is on. + attribute boolean screenOn; + + // Re-read the actual state and refresh `screenOn`, resolving with the fresh -+ // value. -+ // TODO: replace by an "onchange" event. ++ // value. Use when something outside this page may have changed it. + Promise readScreenOn(); ++ ++ // Power the device off. Rejects if the platform refuses. ++ Promise shutdown(); ++ ++ // Reboot the device. Rejects if the platform refuses, or if the requested ++ // target is unsupported. ++ Promise reboot(optional RebootTarget target = "system"); +}; + +partial interface Embedder { diff --git a/patches/components/shared/constellation/Cargo.toml.patch b/patches/components/shared/constellation/Cargo.toml.patch index 93bc8f4..af1a39d 100644 --- a/patches/components/shared/constellation/Cargo.toml.patch +++ b/patches/components/shared/constellation/Cargo.toml.patch @@ -1,14 +1,15 @@ --- original +++ modified -@@ -19,6 +19,7 @@ +@@ -19,6 +19,8 @@ [dependencies] base64 = { workspace = true } ++beaver-hal = { path = "../../../../crates/beaver_hal" } +beaver-p2p = { path = "../../../../crates/beaver_p2p" } content-security-policy = { workspace = true } devtools_traits = { workspace = true } embedder_traits = { workspace = true } -@@ -37,6 +38,7 @@ +@@ -37,6 +39,7 @@ profile_traits = { workspace = true } rustc-hash = { workspace = true } serde = { workspace = true } diff --git a/patches/components/shared/constellation/from_script_message.rs.patch b/patches/components/shared/constellation/from_script_message.rs.patch index f130392..0ab18d0 100644 --- a/patches/components/shared/constellation/from_script_message.rs.patch +++ b/patches/components/shared/constellation/from_script_message.rs.patch @@ -98,8 +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, - } - ++} ++ +#[derive(Deserialize, Serialize)] +pub enum AtProtoRequest { + /// User, Password @@ -171,8 +171,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")] @@ -261,7 +261,7 @@ /// Mark a new document as active ActivateDocument, /// Set the document state for a pipeline (used by screenshot / reftests) -@@ -803,6 +984,202 @@ +@@ -803,6 +984,209 @@ /// aggregate lock count and notify the provider only when the count transitions from N to 0. /// ReleaseWakeLock(WakeLockType), @@ -413,6 +413,13 @@ + PowerGetScreenOn(GenericCallback>), + /// Power: turn the screen on or off. Fire and forget, as above. + PowerSetScreenOn(bool), ++ /// Power: power the device off. ++ PowerShutdown(GenericCallback>), ++ /// Power: reboot the device. ++ PowerReboot( ++ beaver_hal::system::RebootTarget, ++ GenericCallback>, ++ ), + /// Create a peer stream: create a virtual remote port entangled with a local port, + /// and send the offer to a remote peer. + /// Args: peer_id, local_port_id, remote_port_id, target_url, callback. diff --git a/patches/components/shared/constellation/lib.rs.patch b/patches/components/shared/constellation/lib.rs.patch index ce27766..eabadb6 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,218 @@ +@@ -30,15 +32,226 @@ use rustc_hash::FxHashMap; use serde::{Deserialize, Serialize}; use servo_base::cross_process_instant::CrossProcessInstant; @@ -144,6 +144,14 @@ + pub percent: f64, +} + ++/// What to boot into after a reboot. Mirrors `beaver_hal`'s `RebootTarget`. ++// #[derive(Clone, Copy, Debug, Deserialize, PartialEq, Serialize)] ++// pub enum RebootTarget { ++// System, ++// Bootloader, ++// Recovery, ++// } ++ +/// A D-Bus signal received from a subscription. +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct DbusSignalEvent { @@ -240,7 +248,7 @@ /// Messages to the Constellation from the embedding layer, whether from `ServoRenderer` or /// from `libservo` itself. #[derive(IntoStaticStr)] -@@ -55,6 +260,15 @@ +@@ -55,6 +268,15 @@ ChangeViewportDetails(WebViewId, ViewportDetails, WindowSizeType), /// Inform the constellation of a theme change. ThemeChange(WebViewId, Theme), @@ -256,7 +264,7 @@ /// Requests that the constellation instruct script/layout to try to layout again and tick /// animations. TickAnimation(Vec), -@@ -116,6 +330,9 @@ +@@ -116,6 +338,9 @@ UpdatePinchZoomInfos(PipelineId, PinchZoomInfos), /// Activate or deactivate accessibility features for the given `WebView`. SetAccessibilityActive(WebViewId, bool), diff --git a/ui/shared/power/power_key.js b/ui/shared/power/power_key.js index 3e3e1f8..7f445e9 100644 --- a/ui/shared/power/power_key.js +++ b/ui/shared/power/power_key.js @@ -1,8 +1,13 @@ // SPDX-License-Identifier: AGPL-3.0-or-later -// Binds the phone's power button to the screen's power state. +// Binds the phone's power button: a short press toggles the screen, a long +// press offers the device power actions. import { screenPower } from "beaver://shared/power/screen.js"; +import { + powerMenu, + isPowerMenuOpen, +} from "beaver://shared/power/power_menu.js"; const POWER_KEY = "Power"; @@ -10,15 +15,28 @@ const LONG_PRESS_MS = 500; let installed = false; -/** Toggle the screen on a short press of the power button. */ +// Wire the power button. Safe to call more than once. export function bindPowerKey() { if (installed || !screenPower.available) { return; } installed = true; - // When the current press started, or null when no press is in flight. - let pressedAt = null; + // Whether a press is in flight. The button auto-repeats while held but the + // cage compositor does not set `event.repeat` for it, so this is what + // distinguishes the first keydown of a press from the rest. + let pressed = false; + // Set once the press has been dealt with, so the matching keyup does nothing. + let consumed = false; + // Pending long press, or null. + let timer = null; + + const cancelTimer = () => { + if (timer !== null) { + clearTimeout(timer); + timer = null; + } + }; window.addEventListener( "keydown", @@ -27,12 +45,27 @@ export function bindPowerKey() { return; } event.preventDefault(); - // Only the first keydown of a press starts the clock. A held button - // auto-repeats, but the cage compositor does not set `event.repeat` - // for this key. - if (pressedAt === null) { - pressedAt = Date.now(); + if (pressed) { + return; + } + pressed = true; + consumed = false; + + // With the menu up, the button is the way back out of it. It must not + // also toggle the screen on release. + if (isPowerMenuOpen()) { + powerMenu().close(); + consumed = true; + return; } + + // The menu opens while the button is still held, rather than on release: + // waiting would make a deliberate hold feel like nothing was happening. + timer = setTimeout(() => { + timer = null; + consumed = true; + longPress(); + }, LONG_PRESS_MS); }, true, ); @@ -44,16 +77,18 @@ export function bindPowerKey() { return; } event.preventDefault(); + cancelTimer(); - const started = pressedAt; - pressedAt = null; + const wasPressed = pressed; + const wasConsumed = consumed; + pressed = false; + consumed = false; - // TODO: Held down, trigger a system menu for reboot/shutdown etc. - if (started !== null && Date.now() - started > LONG_PRESS_MS) { + if (wasConsumed) { return; } - if (started === null) { + if (!wasPressed) { // Unexpected: a keyup with no keydown. Wake up to be on the safe side. screenPower.wake(); return; @@ -66,3 +101,14 @@ export function bindPowerKey() { true, ); } + +// A long press offers shutdown and reboot, except on a dark screen: there +// would be nothing to read, and the menu would be waiting unasked once the +// screen came back. Waking is the useful answer there. +function longPress() { + if (!screenPower.on) { + screenPower.wake(); + return; + } + powerMenu().show(); +} diff --git a/ui/shared/power/power_menu.js b/ui/shared/power/power_menu.js new file mode 100644 index 0000000..7eecf5e --- /dev/null +++ b/ui/shared/power/power_menu.js @@ -0,0 +1,386 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +// ``: the modal offered on a long press of the power button. + +import { + LitElement, + html, + css, +} from "beaver://shared/third_party/lit/lit-all.min.js"; +import { systemPower } from "beaver://shared/power/system.js"; + +/** + * How long to wait, after the embedder accepted an action, before saying that + * nothing came of it. The device normally dies long before this. + */ +const STALL_MS = 6000; + +/** + * The actions offered, in order. The advanced ones sit below a divider: they + * only mean something on hardware with a bootloader or recovery worth reaching, + * and they are the ones most likely to be refused. + */ +const ACTIONS = [ + { + id: "shutdown", + label: "Power off", + busy: "Powering off", + icon: "power", + danger: true, + run: () => systemPower.shutdown(), + }, + { + id: "restart", + label: "Restart", + busy: "Restarting", + icon: "rotate-ccw", + run: () => systemPower.reboot("system"), + }, + { + id: "bootloader", + label: "Restart to bootloader", + busy: "Restarting", + icon: "hard-drive", + advanced: true, + run: () => systemPower.reboot("bootloader"), + }, + { + id: "recovery", + label: "Restart to recovery", + busy: "Restarting", + icon: "life-buoy", + advanced: true, + run: () => systemPower.reboot("recovery"), + }, +]; + +export class PowerMenu extends LitElement { + // Timer for the "accepted but nothing happened" note, or null. + #stallTimer = null; + + static properties = { + open: { type: Boolean, reflect: true }, + // The action being run, or null. Set while a request is in flight, and + // deliberately left set after it succeeds: the device is on its way down. + _running: { state: true }, + _error: { state: true }, + _stalled: { state: true }, + }; + + static styles = css` + :host { + display: none; + position: fixed; + inset: 0; + z-index: var(--z-modal, 8000); + font-family: var(--font-family-base, system-ui); + } + + :host([open]) { + display: block; + } + + .backdrop { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + padding: var(--spacing-3xl, 24px); + background: var(--color-backdrop, rgb(0 0 0 / 0.5)); + } + + .panel { + width: 100%; + max-width: 320px; + padding: var(--spacing-2xl, 16px); + border-radius: var(--radius-md, 16px); + background: var(--bg-surface, #fff); + color: var(--color-text, #222); + box-shadow: 0 8px 32px var(--color-shadow, rgb(0 0 0 / 0.3)); + /* Finite on purpose: an infinite animation would pin the document as + needing a display list rebuild every frame. */ + animation: rise 0.18s var(--ease-out, ease-out); + } + + .panel:focus { + outline: none; + } + + @keyframes rise { + from { + opacity: 0; + transform: translateY(8px); + } + } + + .title { + margin: 0 0 var(--spacing-md, 8px); + padding: 0 var(--spacing-lg, 10px); + font-size: var(--font-size-sm, 12px); + font-weight: var(--font-weight-bold, bold); + color: var(--color-text-tertiary, #999); + } + + .action { + display: flex; + align-items: center; + gap: var(--spacing-2xl, 16px); + width: 100%; + /* Comfortable finger target, whatever the type scale does. */ + min-height: 48px; + padding: var(--spacing-md, 8px) var(--spacing-lg, 10px); + border: 0; + border-radius: var(--radius-sm, 8px); + background: none; + color: inherit; + font-family: inherit; + font-size: var(--font-size-lg, 16px); + text-align: left; + cursor: pointer; + transition: background var(--transition-fast, 0.18s); + } + + .action:hover:not(:disabled) { + background: var(--bg-hover, rgb(0 0 0 / 0.06)); + } + + .action:disabled { + opacity: var(--opacity-muted, 0.6); + cursor: default; + } + + .action.danger { + color: var(--color-danger, #c33); + } + + .glyph { + flex: none; + display: flex; + align-items: center; + font-size: var(--font-size-xl, 18px); + } + + /* The second tier: reachable, but not competing with the two that + matter. */ + .advanced { + margin-top: var(--spacing-md, 8px); + padding-top: var(--spacing-md, 8px); + border-top: 1px solid var(--color-border, rgb(0 0 0 / 0.18)); + } + + .advanced .action { + min-height: 40px; + font-size: var(--font-size-base, 13px); + color: var(--color-text-secondary, #555); + } + + .cancel { + width: 100%; + margin-top: var(--spacing-2xl, 16px); + min-height: 44px; + padding: var(--spacing-md, 8px); + border: 1px solid var(--color-border, rgb(0 0 0 / 0.18)); + border-radius: var(--radius-sm, 8px); + background: none; + color: var(--color-text, #222); + font-family: inherit; + font-size: var(--font-size-md, 14px); + cursor: pointer; + transition: background var(--transition-fast, 0.18s); + } + + .cancel:hover { + background: var(--bg-hover, rgb(0 0 0 / 0.06)); + } + + .note { + display: flex; + align-items: flex-start; + gap: var(--spacing-md, 8px); + margin-top: var(--spacing-2xl, 16px); + padding: 0 var(--spacing-lg, 10px); + font-size: var(--font-size-sm, 12px); + line-height: 1.4; + } + + .note.failed { + color: var(--color-danger, #c33); + } + + .note.stalled { + color: var(--color-text-secondary, #555); + } + `; + + constructor() { + super(); + this.open = false; + this._running = null; + this._error = null; + this._stalled = false; + } + + disconnectedCallback() { + super.disconnectedCallback(); + this.#clearStall(); + window.removeEventListener("keydown", this.#onKeyDown, true); + } + + // Show the menu, cleared of whatever the last press left behind. + show() { + this._running = null; + this._error = null; + this._stalled = false; + this.#clearStall(); + this.open = true; + window.addEventListener("keydown", this.#onKeyDown, true); + // Focus the panel rather than an action. + this.updateComplete.then(() => { + this.renderRoot?.querySelector(".panel")?.focus(); + }); + } + + close() { + if (!this.open) { + return; + } + this.open = false; + this.#clearStall(); + window.removeEventListener("keydown", this.#onKeyDown, true); + } + + #onKeyDown = (event) => { + if (event.key === "Escape") { + event.preventDefault(); + event.stopPropagation(); + this.close(); + } + }; + + #clearStall() { + if (this.#stallTimer !== null) { + clearTimeout(this.#stallTimer); + this.#stallTimer = null; + } + } + + async #run(action) { + if (this._running) { + return; + } + this._running = action; + this._error = null; + this._stalled = false; + + try { + await action.run(); + } catch (error) { + // Refused, or unsupported. Offer the menu again rather than sitting on a + // request that will never complete. + this._running = null; + this._error = String(error?.message || error); + 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. + this.#stallTimer = setTimeout(() => { + this.#stallTimer = null; + this._running = null; + this._stalled = true; + }, STALL_MS); + } + + #renderAction(action) { + const running = this._running?.id === action.id; + return html` + + `; + } + + render() { + if (!this.open) { + return html``; + } + + const primary = ACTIONS.filter((action) => !action.advanced); + const advanced = ACTIONS.filter((action) => action.advanced); + + return html` +
+ +
+ `; + } +} + +customElements.define("power-menu", PowerMenu); + +// One shared instance, created on first use. +let instance = null; + +// The shared menu, creating and mounting it if this is the first press. +export function powerMenu() { + if (!instance) { + instance = document.createElement("power-menu"); + document.body.appendChild(instance); + } + return instance; +} + +// Whether the menu is currently up, without creating it to find out. +export function isPowerMenuOpen() { + return instance !== null && instance.open; +} diff --git a/ui/shared/power/system.js b/ui/shared/power/system.js new file mode 100644 index 0000000..108d25a --- /dev/null +++ b/ui/shared/power/system.js @@ -0,0 +1,44 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +// Device power actions (shutdown, reboot), on top of `navigator.embedder.power`. + +// The DOM power object, or null on a page/build that can't reach it. +function powerApi() { + return globalThis.navigator?.embedder?.power ?? null; +} + +class SystemPower { + // True when this page can reach the power API at all. + get available() { + return powerApi() !== null; + } + + /** + * Power the device off. + * + * Rejects when the platform refuses (logind asks polkit, which may deny it), + * so a caller can tell "not permitted" from "about to go down". + */ + async shutdown() { + const api = powerApi(); + if (!api) { + throw new Error("navigator.embedder.power unavailable"); + } + return api.shutdown(); + } + + /** + * Reboot the device. + * + * @param {"system"|"bootloader"|"recovery"} [target="system"] + */ + async reboot(target = "system") { + const api = powerApi(); + if (!api) { + throw new Error("navigator.embedder.power unavailable"); + } + return api.reboot(target); + } +} + +export const systemPower = new SystemPower();