From b57c489c4150da5459f80242908fc6ecef11706b Mon Sep 17 00:00:00 2001 From: webbeef Date: Sun, 26 Jul 2026 08:51:56 -0700 Subject: [PATCH] power: new api to turn the screen on/off Signed-off-by: webbeef --- Cargo.lock | 2 + crates/beaver_hal/Cargo.toml | 7 +- crates/beaver_hal/src/bin/screen.rs | 56 ++++ crates/beaver_hal/src/lib.rs | 1 + crates/beaver_hal/src/screen/dummy.rs | 37 +++ crates/beaver_hal/src/screen/linux.rs | 298 ++++++++++++++++++ crates/beaver_hal/src/screen/mod.rs | 47 +++ .../constellation/constellation.rs.patch | 57 ++-- .../constellation/power_service.rs.patch | 88 +++++- .../components/constellation/tracing.rs.patch | 4 +- patches/components/script/dom/power.rs.patch | 60 +++- .../codegen/Bindings.conf.patch | 2 +- .../webidls/Power.webidl.patch | 10 +- .../from_script_message.rs.patch | 14 +- ui/shared/dbus/network_manager.js | 30 +- ui/shared/dbus/wifi_toggle.js | 13 +- ui/shared/power/power_key.js | 68 ++++ ui/shared/power/screen.js | 55 ++++ ui/system/mobile/init.js | 3 + 19 files changed, 791 insertions(+), 61 deletions(-) create mode 100644 crates/beaver_hal/src/bin/screen.rs create mode 100644 crates/beaver_hal/src/screen/dummy.rs create mode 100644 crates/beaver_hal/src/screen/linux.rs create mode 100644 crates/beaver_hal/src/screen/mod.rs create mode 100644 ui/shared/power/power_key.js create mode 100644 ui/shared/power/screen.js diff --git a/Cargo.lock b/Cargo.lock index 2f5aa51..91c7ed6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -793,6 +793,8 @@ version = "0.1.0" dependencies = [ "dbus", "log", + "wayland-client", + "wayland-protocols-wlr", ] [[package]] diff --git a/crates/beaver_hal/Cargo.toml b/crates/beaver_hal/Cargo.toml index c18242a..bf48428 100644 --- a/crates/beaver_hal/Cargo.toml +++ b/crates/beaver_hal/Cargo.toml @@ -6,4 +6,9 @@ license = "AGPL-3.0-or-later" [dependencies] log = "0.4" -dbus = "0.9" \ No newline at end of file +dbus = "0.9" + +# Wayland is Linux-only unlike dbus. +[target.'cfg(target_os = "linux")'.dependencies] +wayland-client = "0.31" +wayland-protocols-wlr = { version = "0.3", features = ["client"] } diff --git a/crates/beaver_hal/src/bin/screen.rs b/crates/beaver_hal/src/bin/screen.rs new file mode 100644 index 0000000..73a6aaf --- /dev/null +++ b/crates/beaver_hal/src/bin/screen.rs @@ -0,0 +1,56 @@ +// SPDX Id: AGPL-3.0-or-later + +//! Screen power control test utility. +//! +//! Usage: +//! screen list the outputs and the current state +//! screen off turn the screen off +//! screen on turn it back on +//! screen cycle off, wait 5s, on again (the recovery test) + +use std::env; +use std::thread::sleep; +use std::time::Duration; + +use beaver_hal::screen::Screen; + +fn main() { + let screen = Screen::default(); + let Some(power) = screen.power.as_ref() else { + println!( + "No screen power control on this platform.\n\ + On Linux this means the compositor does not implement \ + zwlr_output_manager_v1 (check with `wayland-info | grep zwlr`), or \ + WAYLAND_DISPLAY is unset -- WAYLAND_DISPLAY={:?}", + std::env::var("WAYLAND_DISPLAY").unwrap_or_else(|_| "".into()), + ); + return; + }; + + println!("Outputs:"); + for (name, enabled) in power.outputs() { + println!(" {name}: {}", if enabled { "enabled" } else { "disabled" }); + } + println!("Screen currently on: {}", power.is_on()); + + match env::args().nth(1).as_deref() { + Some("off") => report("off", power.set_on(false)), + Some("on") => report("on", power.set_on(true)), + Some("cycle") => { + report("off", power.set_on(false)); + println!("waiting 5s (the screen should be blank)..."); + sleep(Duration::from_secs(5)); + report("on", power.set_on(true)); + println!("Screen currently on: {}", power.is_on()); + }, + Some(other) => println!("Unknown command {other:?}; expected on, off or cycle."), + None => println!("Pass `on`, `off` or `cycle` to change the state."), + } +} + +fn report(what: &str, result: Result<(), Box>) { + match result { + Ok(()) => println!("turned {what}"), + Err(err) => println!("failed to turn {what}: {err}"), + } +} diff --git a/crates/beaver_hal/src/lib.rs b/crates/beaver_hal/src/lib.rs index 364689e..16bc80a 100644 --- a/crates/beaver_hal/src/lib.rs +++ b/crates/beaver_hal/src/lib.rs @@ -1,3 +1,4 @@ /* SPDX Id: AGPL-3.0-or-later */ pub mod brightness; +pub mod screen; diff --git a/crates/beaver_hal/src/screen/dummy.rs b/crates/beaver_hal/src/screen/dummy.rs new file mode 100644 index 0000000..cf3c4fe --- /dev/null +++ b/crates/beaver_hal/src/screen/dummy.rs @@ -0,0 +1,37 @@ +// SPDX Id: AGPL-3.0-or-later + +//! Fallback screen power for platforms without a supported compositor +//! (macOS, Windows). + +use std::sync::Mutex; + +use log::debug; + +use crate::screen::ScreenPower; + +#[derive(Debug)] +struct DummyScreen { + on: Mutex, +} + +impl ScreenPower for DummyScreen { + fn set_on(&self, on: bool) -> Result<(), Box> { + debug!("Dummy screen turned {}", if on { "on" } else { "off" }); + *self.on.lock().expect("dummy screen lock") = on; + Ok(()) + } + + fn is_on(&self) -> bool { + *self.on.lock().expect("dummy screen lock") + } + + fn outputs(&self) -> Vec<(String, bool)> { + vec![("dummy-screen".to_owned(), self.is_on())] + } +} + +pub(crate) fn screen_power() -> Option> { + Some(Box::new(DummyScreen { + on: Mutex::new(true), + })) +} diff --git a/crates/beaver_hal/src/screen/linux.rs b/crates/beaver_hal/src/screen/linux.rs new file mode 100644 index 0000000..fa0a862 --- /dev/null +++ b/crates/beaver_hal/src/screen/linux.rs @@ -0,0 +1,298 @@ +// SPDX Id: AGPL-3.0-or-later + +//! Screen power via `zwlr_output_manager_v1` (wlroots output-management), the +//! same protocol `wlr-randr` uses. + +use std::error::Error; +use std::sync::Mutex; + +use log::{debug, warn}; +use wayland_client::globals::{GlobalListContents, registry_queue_init}; +use wayland_client::protocol::wl_registry; +use wayland_client::{Connection, Dispatch, EventQueue, QueueHandle, delegate_noop}; +use wayland_protocols_wlr::output_management::v1::client::zwlr_output_configuration_v1::{ + self, ZwlrOutputConfigurationV1, +}; +use wayland_protocols_wlr::output_management::v1::client::zwlr_output_head_v1::{ + self, ZwlrOutputHeadV1, +}; +use wayland_protocols_wlr::output_management::v1::client::zwlr_output_manager_v1::{ + self, ZwlrOutputManagerV1, +}; +use wayland_protocols_wlr::output_management::v1::client::zwlr_output_mode_v1::ZwlrOutputModeV1; + +use crate::screen::ScreenPower; + +/// One output as the compositor describes it. +#[derive(Debug)] +struct Head { + head: ZwlrOutputHeadV1, + name: String, + enabled: bool, +} + +/// Everything the manager has told us so far. Rebuilt on each round trip: heads +/// come as a burst of events terminated by `done`, which also carries the serial +/// that `create_configuration` requires. +#[derive(Debug, Default)] +struct State { + heads: Vec, + serial: Option, + /// Set by the configuration's result event. + result: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq)] +enum ConfigResult { + Succeeded, + Failed, + Cancelled, +} + +pub(crate) struct WlrScreen { + /// Guards the whole connection: `wayland_client` queues are not shareable. + inner: Mutex, +} + +struct Inner { + connection: Connection, + /// The queue the manager and heads are bound to; all their events arrive + /// here, so it must be kept and reused rather than recreated per call. + queue: EventQueue, + manager: ZwlrOutputManagerV1, + state: State, +} + +impl std::fmt::Debug for WlrScreen { + fn fmt(&self, fmt: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + fmt.write_str("WlrScreen") + } +} + +/// Connect to the compositor and bind the output manager, or return `None` when +/// there is no Wayland display or it does not implement the protocol. +pub(crate) fn screen_power() -> Option> { + match WlrScreen::new() { + Ok(screen) => Some(Box::new(screen)), + Err(err) => { + debug!("[screen] output-management unavailable: {err}"); + None + }, + } +} + +impl WlrScreen { + fn new() -> Result> { + let connection = Connection::connect_to_env()?; + let (globals, mut queue) = registry_queue_init::(&connection)?; + // Version 1 is enough: we only enable/disable heads. + let manager: ZwlrOutputManagerV1 = globals.bind(&queue.handle(), 1..=4, ())?; + + let mut state = State::default(); + // The manager advertises every head, then `done` with the serial. + queue.roundtrip(&mut state)?; + while state.serial.is_none() { + queue.blocking_dispatch(&mut state)?; + } + + Ok(Self { + inner: Mutex::new(Inner { + connection, + queue, + manager, + state, + }), + }) + } +} + +impl ScreenPower for WlrScreen { + fn is_on(&self) -> bool { + let Ok(mut inner) = self.inner.lock() else { + return true; + }; + // Pick up any state the compositor pushed since the last call. + let _ = inner.pump(); + // "On" means at least one output is enabled; with none, nothing is lit. + inner.state.heads.iter().any(|head| head.enabled) + } + + fn outputs(&self) -> Vec<(String, bool)> { + let Ok(mut inner) = self.inner.lock() else { + return Vec::new(); + }; + let _ = inner.pump(); + inner + .state + .heads + .iter() + .map(|head| (head.name.clone(), head.enabled)) + .collect() + } + + fn set_on(&self, on: bool) -> Result<(), Box> { + let mut inner = self + .inner + .lock() + .map_err(|_| "screen power lock poisoned".to_owned())?; + + // A stale serial makes the compositor cancel the configuration, so + // refresh state and retry once before giving up. + for attempt in 0..2 { + inner.pump()?; + match inner.apply(on)? { + ConfigResult::Succeeded => return Ok(()), + ConfigResult::Cancelled if attempt == 0 => { + debug!("[screen] configuration cancelled (stale serial), retrying"); + }, + ConfigResult::Cancelled => return Err("configuration cancelled".into()), + ConfigResult::Failed => return Err("compositor rejected the configuration".into()), + } + } + Err("configuration cancelled".into()) + } +} + +impl Inner { + /// Drain pending events so `heads` and `serial` reflect the compositor. + fn pump(&mut self) -> Result<(), Box> { + self.queue.roundtrip(&mut self.state)?; + Ok(()) + } + + /// Build and apply a configuration that sets every head to `on`. + fn apply(&mut self, on: bool) -> Result> { + if self.state.heads.is_empty() { + return Err("no outputs to control".into()); + } + let serial = self.state.serial.ok_or("no configuration serial yet")?; + + let handle = self.queue.handle(); + let config = self.manager.create_configuration(serial, &handle, ()); + + // Every head must be configured exactly once: omitting one is a + // protocol error that kills the connection. + for head in &self.state.heads { + if on { + // Enabling with no properties set lets the compositor restore + // the output's preferred mode, position and scale. + let _ = config.enable_head(&head.head, &handle, ()); + } else { + config.disable_head(&head.head); + } + } + + self.state.result = None; + config.apply(); + self.connection.flush()?; + while self.state.result.is_none() { + self.queue.blocking_dispatch(&mut self.state)?; + } + let result = self.state.result.take().unwrap_or(ConfigResult::Failed); + config.destroy(); + + // The heads' enabled flags changed; let the next read pick that up. + if result == ConfigResult::Succeeded { + for head in &mut self.state.heads { + head.enabled = on; + } + } + Ok(result) + } +} + +// The manager tells us about heads and hands out configuration serials. +impl Dispatch for State { + fn event( + state: &mut Self, + _manager: &ZwlrOutputManagerV1, + event: zwlr_output_manager_v1::Event, + _data: &(), + _conn: &Connection, + _handle: &QueueHandle, + ) { + match event { + zwlr_output_manager_v1::Event::Head { head } => { + state.heads.push(Head { + head, + name: String::new(), + enabled: false, + }); + }, + zwlr_output_manager_v1::Event::Done { serial } => { + state.serial = Some(serial); + }, + _ => {}, + } + } + + wayland_client::event_created_child!(State, ZwlrOutputManagerV1, [ + zwlr_output_manager_v1::EVT_HEAD_OPCODE => (ZwlrOutputHeadV1, ()), + ]); +} + +// Each head reports its name and whether it is currently enabled. +impl Dispatch for State { + fn event( + state: &mut Self, + head: &ZwlrOutputHeadV1, + event: zwlr_output_head_v1::Event, + _data: &(), + _conn: &Connection, + _handle: &QueueHandle, + ) { + let Some(entry) = state.heads.iter_mut().find(|h| &h.head == head) else { + return; + }; + match event { + zwlr_output_head_v1::Event::Name { name } => entry.name = name, + zwlr_output_head_v1::Event::Enabled { enabled } => entry.enabled = enabled != 0, + zwlr_output_head_v1::Event::Finished => { + state.heads.retain(|h| &h.head != head); + }, + _ => {}, + } + } + + wayland_client::event_created_child!(State, ZwlrOutputHeadV1, [ + zwlr_output_head_v1::EVT_MODE_OPCODE => (ZwlrOutputModeV1, ()), + ]); +} + +// The configuration reports whether it took effect. +impl Dispatch for State { + fn event( + state: &mut Self, + _config: &ZwlrOutputConfigurationV1, + event: zwlr_output_configuration_v1::Event, + _data: &(), + _conn: &Connection, + _handle: &QueueHandle, + ) { + state.result = Some(match event { + zwlr_output_configuration_v1::Event::Succeeded => ConfigResult::Succeeded, + zwlr_output_configuration_v1::Event::Failed => { + warn!("[screen] compositor rejected the output configuration"); + ConfigResult::Failed + }, + zwlr_output_configuration_v1::Event::Cancelled => ConfigResult::Cancelled, + _ => return, + }); + } +} + +// Bound but not otherwise interesting to us. +delegate_noop!(State: ignore ZwlrOutputModeV1); +delegate_noop!(State: ignore wayland_protocols_wlr::output_management::v1::client::zwlr_output_configuration_head_v1::ZwlrOutputConfigurationHeadV1); + +impl Dispatch for State { + fn event( + _state: &mut Self, + _registry: &wl_registry::WlRegistry, + _event: wl_registry::Event, + _data: &GlobalListContents, + _conn: &Connection, + _handle: &QueueHandle, + ) { + } +} diff --git a/crates/beaver_hal/src/screen/mod.rs b/crates/beaver_hal/src/screen/mod.rs new file mode 100644 index 0000000..6f9a52d --- /dev/null +++ b/crates/beaver_hal/src/screen/mod.rs @@ -0,0 +1,47 @@ +// SPDX Id: AGPL-3.0-or-later + +//! Turning the screen on and off. + +use std::fmt::Debug; + +/// A screen whose power state can be controlled. +/// +/// `Send + Sync` so embedders can share it across threads: the state change +/// talks to the compositor and belongs on a blocking pool, like brightness. +pub trait ScreenPower: Debug + Send + Sync { + /// Turn the screen on or off. + fn set_on(&self, on: bool) -> Result<(), Box>; + + /// Whether the screen is currently on. Errors are reported as `true`, since + /// a screen we cannot inspect is far more likely lit than dark. + fn is_on(&self) -> bool; + + /// The outputs being controlled, as `(name, enabled)`. For diagnostics: the + /// `screen` probe binary prints these to confirm what was discovered. + fn outputs(&self) -> Vec<(String, bool)>; +} + +#[cfg(target_os = "linux")] +mod linux; +#[cfg(target_os = "linux")] +pub(crate) use linux::screen_power; + +// Fallback implementation +#[cfg(not(target_os = "linux"))] +mod dummy; +#[cfg(not(target_os = "linux"))] +pub(crate) use dummy::screen_power; + +/// The screen, when this platform can control its power state. +#[derive(Debug)] +pub struct Screen { + pub power: Option>, +} + +impl Default for Screen { + fn default() -> Self { + Self { + power: screen_power(), + } + } +} diff --git a/patches/components/constellation/constellation.rs.patch b/patches/components/constellation/constellation.rs.patch index 21b459c..db813f8 100644 --- a/patches/components/constellation/constellation.rs.patch +++ b/patches/components/constellation/constellation.rs.patch @@ -534,21 +534,22 @@ 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,7 +2277,12 @@ +@@ -2018,8 +2277,13 @@ }; self.active_media_session = Some(pipeline_id); self.constellation_to_embedder_proxy.send( - ConstellationToEmbedderMsg::MediaSessionEvent(webview_id, event), + ConstellationToEmbedderMsg::MediaSessionEvent(webview_id, event.clone()), -+ ); + ); + // Also route to embedded webview parent iframe. + self.handle_embedded_webview_notification( + webview_id, + EmbeddedWebViewEventType::MediaSessionEvent(event), - ); ++ ); }, #[cfg(feature = "webgpu")] -@@ -2092,9 +2356,1071 @@ + ScriptToConstellationMessage::RequestAdapter(response_sender, options, ids) => self +@@ -2092,7 +2356,1075 @@ } }, }, @@ -943,6 +944,12 @@ + ScriptToConstellationMessage::PowerSetBrightness(id, percent) => { + self.power.set_brightness(id, percent); + }, ++ ScriptToConstellationMessage::PowerGetScreenOn(callback) => { ++ self.power.get_screen_on(callback); ++ }, ++ ScriptToConstellationMessage::PowerSetScreenOn(on) => { ++ self.power.set_screen_on(on); ++ }, + ScriptToConstellationMessage::CreatePeerStream( + peer_id, + local_port_id, @@ -1294,9 +1301,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()); @@ -1584,7 +1591,7 @@ + } + } + return; -+ } + } + + // Handle peer disconnect: clean up remote channel state. + if let PairingEvent::PeerExpired { ref id } = event { @@ -1615,12 +1622,10 @@ + let _ = event_loop.send(ScriptThreadMessage::DispatchPairingEvent(event.clone())); + } + } -+ } -+ + } + /// Check the origin of a message against that of the pipeline it came from. - /// Note: this is still limited as a security check, - /// see -@@ -2411,6 +3737,55 @@ +@@ -2411,6 +3743,55 @@ TransferState::TransferInProgress(queue) => queue.push_back(task), TransferState::CompletionFailed(queue) => queue.push_back(task), TransferState::CompletionRequested(_, queue) => queue.push_back(task), @@ -1676,7 +1681,7 @@ } } -@@ -3222,6 +4597,101 @@ +@@ -3222,6 +4603,101 @@ ); } @@ -1778,7 +1783,7 @@ fn forward_input_event( &mut self, webview_id: WebViewId, -@@ -3241,6 +4711,66 @@ +@@ -3241,6 +4717,66 @@ let pressed_mouse_buttons = self.pressed_mouse_buttons; let active_keyboard_modifiers = self.active_keyboard_modifiers; @@ -1845,7 +1850,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 +4873,40 @@ +@@ -3343,6 +4879,40 @@ /// fn handle_close_top_level_browsing_context(&mut self, webview_id: WebViewId) { debug!("{webview_id}: Closing"); @@ -1886,7 +1891,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 +5183,27 @@ +@@ -3619,8 +5189,27 @@ opener_webview_id, opener_pipeline_id, response_sender, @@ -1914,7 +1919,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 +5302,402 @@ +@@ -3719,6 +5308,402 @@ }); } @@ -2317,7 +2322,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 +6247,7 @@ +@@ -4268,7 +6253,7 @@ }, }; @@ -2326,7 +2331,7 @@ match self.browsing_contexts.get_mut(&browsing_context_id) { Some(browsing_context) => { let old_pipeline_id = browsing_context.pipeline_id; -@@ -4277,6 +6256,7 @@ +@@ -4277,6 +6262,7 @@ old_pipeline_id, browsing_context.parent_pipeline_id, browsing_context.webview_id, @@ -2334,7 +2339,7 @@ ) }, None => { -@@ -4286,6 +6266,15 @@ +@@ -4286,6 +6272,15 @@ self.unload_document(old_pipeline_id); @@ -2350,7 +2355,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 +6843,7 @@ +@@ -4854,7 +6849,7 @@ } #[servo_tracing::instrument(skip_all)] @@ -2359,7 +2364,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 +6955,22 @@ +@@ -4966,9 +6961,22 @@ self.constellation_to_embedder_proxy .send(ConstellationToEmbedderMsg::HistoryChanged( webview_id, @@ -2383,7 +2388,7 @@ } #[servo_tracing::instrument(skip_all)] -@@ -4987,7 +6989,7 @@ +@@ -4987,7 +6995,7 @@ webview.focused_browsing_context_id = change.browsing_context_id; } @@ -2392,7 +2397,7 @@ match self.browsing_contexts.get_mut(&change.browsing_context_id) { Some(browsing_context) => { debug!("Adding pipeline to existing browsing context."); -@@ -4994,11 +6996,15 @@ +@@ -4994,11 +7002,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); @@ -2410,7 +2415,7 @@ }, }; -@@ -5006,6 +7012,18 @@ +@@ -5006,6 +7018,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 6b3571d..e5f9fe7 100644 --- a/patches/components/constellation/power_service.rs.patch +++ b/patches/components/constellation/power_service.rs.patch @@ -1,9 +1,9 @@ --- original +++ modified -@@ -0,0 +1,135 @@ +@@ -0,0 +1,203 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + -+//! Screen/keyboard brightness control. ++//! Screen/keyboard brightness, and screen power. +//! +//! The devices are enumerated once, lazily, and addressed afterwards by their +//! index in that list (`BrightnessDeviceInfo::id`). Enumeration, reads and writes @@ -13,7 +13,8 @@ +use std::collections::HashMap; +use std::sync::{Arc, Mutex, OnceLock}; + -+use beaver_hal::brightness::{BrightnessDevice, DeviceKind, Devices}; ++use beaver_hal::brightness::{BrightnessDevice, Devices}; ++use beaver_hal::screen::{Screen, ScreenPower}; +use log::{error, warn}; +use net::async_runtime::spawn_blocking; +use servo_base::generic_channel::GenericCallback; @@ -22,18 +23,27 @@ +/// 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. ++type ScreenCell = 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>>>; + ++/// The screen's own write slot, with the same meaning as an entry of ++/// `PendingWrites`: `None` = no writer, `Some(None)` = writer running with ++/// nothing queued, `Some(Some(on))` = that state queued behind the writer. ++type PendingScreenWrite = Arc>>>; ++ +pub(crate) struct PowerService { + devices: DeviceCell, ++ screen: ScreenCell, + pending: PendingWrites, ++ screen_pending: PendingScreenWrite, +} + +/// Enumerate the devices on first use and keep them for the process lifetime. -+/// Only ever called from inside a blocking task: scanning sysfs and reading each -+/// device's `max_brightness` is real I/O. +fn devices_of(cell: &OnceLock>>) -> &[Box] { + cell.get_or_init(|| { + let Devices { leds, backlight } = Devices::default(); @@ -42,11 +52,18 @@ + }) +} + ++/// Connect to the platform's screen power control on first use. ++fn screen_of(cell: &OnceLock>>) -> Option<&dyn ScreenPower> { ++ cell.get_or_init(|| Screen::default().power).as_deref() ++} ++ +impl PowerService { + pub(crate) fn new() -> Self { + Self { + devices: Arc::new(OnceLock::new()), ++ screen: Arc::new(OnceLock::new()), + pending: Arc::new(Mutex::new(HashMap::new())), ++ screen_pending: Arc::new(Mutex::new(None)), + } + } + @@ -91,11 +108,7 @@ + /// Set a device's brightness. Fire and forget: the JS side exposes this as a + /// plain property write, so failures are logged rather than reported back. + /// -+ /// Writes are coalesced per device: a slider drag asks for a new value every -+ /// frame, but the hardware write is slow (a blocking logind call), so at most -+ /// one is in flight and only the newest queued value survives. That keeps the -+ /// blocking pool from filling up with stale writes, and guarantees the last -+ /// value requested is the one the hardware ends on. ++ /// Writes are coalesced per device. + pub(crate) fn set_brightness(&self, id: u32, percent: f64) { + { + let mut pending = self.pending.lock().expect("power pending lock"); @@ -135,4 +148,59 @@ + } + }); + } ++ ++ /// Reply with whether the screen is currently on. ++ pub(crate) fn get_screen_on(&self, callback: GenericCallback>) { ++ let cell = self.screen.clone(); ++ spawn_blocking(move || { ++ let response = match screen_of(&cell) { ++ Some(screen) => Ok(screen.is_on()), ++ None => Err("No screen power control on this platform".to_owned()), ++ }; ++ if let Err(err) = callback.send(response) { ++ error!("[Power] Failed to send screen state: {err:?}"); ++ } ++ }); ++ } ++ ++ /// Turn the screen on or off. Fire and forget, and coalesced. ++ pub(crate) fn set_screen_on(&self, on: bool) { ++ { ++ let mut pending = self.screen_pending.lock().expect("power pending lock"); ++ if let Some(slot) = pending.as_mut() { ++ // A writer is already running: let it pick this up. ++ *slot = Some(on); ++ return; ++ } ++ // Claim the writer slot, with nothing queued behind. ++ *pending = Some(None); ++ } ++ ++ let cell = self.screen.clone(); ++ let pending = self.screen_pending.clone(); ++ spawn_blocking(move || { ++ let Some(screen) = screen_of(&cell) else { ++ warn!("[Power] No screen power control on this platform"); ++ *pending.lock().expect("power pending lock") = None; ++ return; ++ }; ++ ++ let mut next = Some(on); ++ while let Some(on) = next { ++ if let Err(err) = screen.set_on(on) { ++ warn!( ++ "[Power] Failed to turn the screen {}: {err}", ++ if on { "on" } else { "off" } ++ ); ++ } ++ // Take whatever arrived while we were writing; release the slot ++ // under the same lock so a new request can't be dropped. ++ let mut pending = pending.lock().expect("power pending lock"); ++ next = pending.as_mut().and_then(Option::take); ++ if next.is_none() { ++ *pending = None; ++ } ++ } ++ }); ++ } +} diff --git a/patches/components/constellation/tracing.rs.patch b/patches/components/constellation/tracing.rs.patch index 9cedf06..1c6535d 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,80 @@ +@@ -192,6 +203,82 @@ Self::TriggerGarbageCollection => target!("TriggerGarbageCollection"), Self::AcquireWakeLock(..) => target!("AcquireWakeLock"), Self::ReleaseWakeLock(..) => target!("ReleaseWakeLock"), @@ -131,6 +131,8 @@ + Self::PowerListDevices(..) => target!("PowerListDevices"), + Self::PowerGetBrightness(..) => target!("PowerGetBrightness"), + Self::PowerSetBrightness(..) => target!("PowerSetBrightness"), ++ Self::PowerGetScreenOn(..) => target!("PowerGetScreenOn"), ++ Self::PowerSetScreenOn(..) => target!("PowerSetScreenOn"), } } } diff --git a/patches/components/script/dom/power.rs.patch b/patches/components/script/dom/power.rs.patch index fbc9804..95cea9d 100644 --- a/patches/components/script/dom/power.rs.patch +++ b/patches/components/script/dom/power.rs.patch @@ -1,11 +1,12 @@ --- original +++ modified -@@ -0,0 +1,77 @@ +@@ -0,0 +1,133 @@ +/* SPDX Id: AGPL-3.0-or-later */ + +//! `navigator.embedder.power`: control of the device's brightness-capable -+//! hardware. ++//! hardware, and the screen's power state. + ++use std::cell::Cell; +use std::rc::Rc; + +use dom_struct::dom_struct; @@ -25,12 +26,17 @@ +#[dom_struct] +pub(crate) struct Power { + _reflector: Reflector, ++ /// Last known screen state. Optimistic on write and refreshed by ++ /// `readScreenOn()`, so the attribute getter never has to block. ++ screen_on: Cell, +} + +impl Power { + fn new_inherited() -> Power { + Power { + _reflector: Reflector::new(), ++ // TODO: initialize with actual screen state. ++ screen_on: Cell::new(true), + } + } + @@ -56,6 +62,56 @@ + } + promise + } ++ ++ fn ScreenOn(&self) -> bool { ++ self.screen_on.get() ++ } ++ ++ /// Turn the screen on or off. ++ fn SetScreenOn(&self, on: bool) { ++ self.screen_on.set(on); ++ ++ let _ = self ++ .global() ++ .script_to_constellation_chan() ++ .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. ++ fn ReadScreenOn(&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::PowerGetScreenOn(callback)) ++ .is_err() ++ { ++ promise.reject_error(cx, Error::Operation(None)); ++ } ++ promise ++ } ++} ++ ++impl RoutedPromiseListener> for Power { ++ fn handle_response( ++ &self, ++ cx: &mut JSContext, ++ response: Result, ++ promise: &Rc, ++ ) { ++ match response { ++ Ok(on) => { ++ self.screen_on.set(on); ++ promise.resolve_native(cx, &on); ++ }, ++ Err(msg) => promise.reject_error(cx, Error::Operation(Some(msg))), ++ } ++ } +} + +impl RoutedPromiseListener, String>> for Power { diff --git a/patches/components/script_bindings/codegen/Bindings.conf.patch b/patches/components/script_bindings/codegen/Bindings.conf.patch index ab2f81f..3279fc5 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'], ++ 'cx': ['Devices', 'ReadScreenOn'], +}, + +'BrightnessDevice': { diff --git a/patches/components/script_bindings/webidls/Power.webidl.patch b/patches/components/script_bindings/webidls/Power.webidl.patch index a9d0df0..5d32a7c 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,40 @@ +@@ -0,0 +1,48 @@ +/* SPDX Id: AGPL-3.0-or-later */ + +// Control of the device's brightness-capable hardware (screen backlight, keyboard LEDs). @@ -35,6 +35,14 @@ + // All brightness-controllable devices, backlights first. Resolves with an + // empty list on platforms with no such hardware. + 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. ++ Promise readScreenOn(); +}; + +partial interface Embedder { diff --git a/patches/components/shared/constellation/from_script_message.rs.patch b/patches/components/shared/constellation/from_script_message.rs.patch index 2202464..f130392 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 @@ -126,8 +126,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")] @@ -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,198 @@ +@@ -803,6 +984,202 @@ /// aggregate lock count and notify the provider only when the count transitions from N to 0. /// ReleaseWakeLock(WakeLockType), @@ -409,6 +409,10 @@ + /// Args: device id, percent. Fire and forget: the DOM exposes this as a plain + /// property write, so there is no reply and failures are logged. + PowerSetBrightness(u32, f64), ++ /// Power: read whether the screen is on. ++ PowerGetScreenOn(GenericCallback>), ++ /// Power: turn the screen on or off. Fire and forget, as above. ++ PowerSetScreenOn(bool), + /// 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/ui/shared/dbus/network_manager.js b/ui/shared/dbus/network_manager.js index f5faeed..7c39cc9 100644 --- a/ui/shared/dbus/network_manager.js +++ b/ui/shared/dbus/network_manager.js @@ -625,15 +625,21 @@ export class WifiDevice extends EventTarget { */ watchState(handler) { nmLog("watchState on", this._path); - return hub.on(SYSTEM, DEVICE_IFACE, "StateChanged", this._path, (detail) => { - const state = Array.isArray(detail.args) ? detail.args[0] : undefined; - nmLog("StateChanged", this._path, { - state, - name: nmDeviceStateName(state), - args: detail.args, - }); - handler(state); - }); + return hub.on( + SYSTEM, + DEVICE_IFACE, + "StateChanged", + this._path, + (detail) => { + const state = Array.isArray(detail.args) ? detail.args[0] : undefined; + nmLog("StateChanged", this._path, { + state, + name: nmDeviceStateName(state), + args: detail.args, + }); + handler(state); + }, + ); } /** @@ -821,7 +827,11 @@ export class WifiDevice extends EventTarget { } await this._ensureCached(path); this._activePath = path; - nmLog("active AP cached", path, "ssid=" + (this.activeAccessPoint?.ssid ?? "")); + nmLog( + "active AP cached", + path, + "ssid=" + (this.activeAccessPoint?.ssid ?? ""), + ); } } diff --git a/ui/shared/dbus/wifi_toggle.js b/ui/shared/dbus/wifi_toggle.js index 64da972..dcd575b 100644 --- a/ui/shared/dbus/wifi_toggle.js +++ b/ui/shared/dbus/wifi_toggle.js @@ -95,7 +95,12 @@ export class WifiToggle extends LitElement { this.#unwatch = device.watchState((state) => { // Any transition can change what we display: ACTIVATED brings an SSID, // DISCONNECTED/UNAVAILABLE take it away. - nmLog("tile: state ->", state, "activated?", state === NMDeviceState.ACTIVATED); + nmLog( + "tile: state ->", + state, + "activated?", + state === NMDeviceState.ACTIVATED, + ); this.#syncSsid(state === NMDeviceState.ACTIVATED); }); } @@ -191,9 +196,9 @@ export class WifiToggle extends LitElement { class="${this.tileClass} ${this.on ? "on" : ""}" role="switch" aria-checked=${this.on ? "true" : "false"} - aria-label=${this.ssid - ? `Wi-Fi: ${this.ssid}, signal ${this.strength}%` - : "Wi-Fi"} + aria-label=${ + this.ssid ? `Wi-Fi: ${this.ssid}, signal ${this.strength}%` : "Wi-Fi" + } @click=${this.#onToggle} > diff --git a/ui/shared/power/power_key.js b/ui/shared/power/power_key.js new file mode 100644 index 0000000..3e3e1f8 --- /dev/null +++ b/ui/shared/power/power_key.js @@ -0,0 +1,68 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +// Binds the phone's power button to the screen's power state. + +import { screenPower } from "beaver://shared/power/screen.js"; + +const POWER_KEY = "Power"; + +const LONG_PRESS_MS = 500; + +let installed = false; + +/** Toggle the screen on a short press of the power button. */ +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; + + window.addEventListener( + "keydown", + (event) => { + if (event.key !== POWER_KEY) { + 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(); + } + }, + true, + ); + + window.addEventListener( + "keyup", + (event) => { + if (event.key !== POWER_KEY) { + return; + } + event.preventDefault(); + + const started = pressedAt; + pressedAt = null; + + // TODO: Held down, trigger a system menu for reboot/shutdown etc. + if (started !== null && Date.now() - started > LONG_PRESS_MS) { + return; + } + + if (started === null) { + // Unexpected: a keyup with no keydown. Wake up to be on the safe side. + 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)); + }, + true, + ); +} diff --git a/ui/shared/power/screen.js b/ui/shared/power/screen.js new file mode 100644 index 0000000..9c75b52 --- /dev/null +++ b/ui/shared/power/screen.js @@ -0,0 +1,55 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +// Screen power (on/off), 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 ScreenPower { + // True when this page can reach the power API at all. + get available() { + return powerApi() !== null; + } + + /** + * Whether the screen is on, from the last known value. Synchronous; call + * `read()` first if something outside this page may have changed it. + */ + get on() { + return powerApi()?.screenOn ?? true; + } + + // Re-read the real state from the compositor. + async read() { + const api = powerApi(); + if (!api) { + return true; + } + try { + return await api.readScreenOn(); + } catch (error) { + console.warn("[screen] Failed to read the screen state:", error); + return api.screenOn; + } + } + + // Turn the screen on or off. + set(on) { + const api = powerApi(); + if (api) { + api.screenOn = !!on; + } + } + + off() { + this.set(false); + } + + wake() { + this.set(true); + } +} + +export const screenPower = new ScreenPower(); diff --git a/ui/system/mobile/init.js b/ui/system/mobile/init.js index b3349e1..3c98637 100644 --- a/ui/system/mobile/init.js +++ b/ui/system/mobile/init.js @@ -3,11 +3,14 @@ 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"; export function initMobile( layoutManager, { createNewView, switchToHomescreen, openView, pairingHandler, services }, ) { + bindPowerKey(); + const splash = new MobileSplashScreen(); splash.show(); -- 2.51.2