diff --git a/patches/components/constellation/constellation.rs.patch b/patches/components/constellation/constellation.rs.patch index 1695093..aaaf629 100644 --- a/patches/components/constellation/constellation.rs.patch +++ b/patches/components/constellation/constellation.rs.patch @@ -614,7 +614,7 @@ }, #[cfg(feature = "webgpu")] ScriptToConstellationMessage::RequestAdapter(response_sender, options, ids) => self -@@ -2092,9 +2403,1083 @@ +@@ -2092,9 +2403,1088 @@ } }, }, @@ -1014,6 +1014,11 @@ + }, + ScriptToConstellationMessage::PowerSetScreenOn(on) => { + self.power.set_screen_on(on); ++ // Waking the panel counts as the user being "active". ++ if on { ++ self.idle.note_user_active(); ++ self.broadcast_idle_state(); ++ } + }, + ScriptToConstellationMessage::PowerShutdown(callback) => { + self.power.shutdown(callback); @@ -1698,7 +1703,7 @@ /// 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 +3796,55 @@ +@@ -2411,6 +3801,55 @@ TransferState::TransferInProgress(queue) => queue.push_back(task), TransferState::CompletionFailed(queue) => queue.push_back(task), TransferState::CompletionRequested(_, queue) => queue.push_back(task), @@ -1754,7 +1759,7 @@ } } -@@ -3222,6 +4656,101 @@ +@@ -3222,6 +4661,101 @@ ); } @@ -1856,7 +1861,7 @@ fn forward_input_event( &mut self, webview_id: WebViewId, -@@ -3241,6 +4770,66 @@ +@@ -3241,6 +4775,66 @@ let pressed_mouse_buttons = self.pressed_mouse_buttons; let active_keyboard_modifiers = self.active_keyboard_modifiers; @@ -1923,7 +1928,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 +4932,40 @@ +@@ -3343,6 +4937,40 @@ /// fn handle_close_top_level_browsing_context(&mut self, webview_id: WebViewId) { debug!("{webview_id}: Closing"); @@ -1964,7 +1969,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 +5242,27 @@ +@@ -3619,8 +5247,27 @@ opener_webview_id, opener_pipeline_id, response_sender, @@ -1992,7 +1997,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 +5361,402 @@ +@@ -3719,6 +5366,402 @@ }); } @@ -2395,7 +2400,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 +6306,7 @@ +@@ -4268,7 +6311,7 @@ }, }; @@ -2404,7 +2409,7 @@ match self.browsing_contexts.get_mut(&browsing_context_id) { Some(browsing_context) => { let old_pipeline_id = browsing_context.pipeline_id; -@@ -4277,6 +6315,7 @@ +@@ -4277,6 +6320,7 @@ old_pipeline_id, browsing_context.parent_pipeline_id, browsing_context.webview_id, @@ -2412,7 +2417,7 @@ ) }, None => { -@@ -4286,6 +6325,15 @@ +@@ -4286,6 +6330,15 @@ self.unload_document(old_pipeline_id); @@ -2428,7 +2433,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 +6902,7 @@ +@@ -4854,7 +6907,7 @@ } #[servo_tracing::instrument(skip_all)] @@ -2437,7 +2442,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 +7014,22 @@ +@@ -4966,9 +7019,22 @@ self.constellation_to_embedder_proxy .send(ConstellationToEmbedderMsg::HistoryChanged( webview_id, @@ -2461,7 +2466,7 @@ } #[servo_tracing::instrument(skip_all)] -@@ -4987,7 +7048,7 @@ +@@ -4987,7 +7053,7 @@ webview.focused_browsing_context_id = change.browsing_context_id; } @@ -2470,7 +2475,7 @@ match self.browsing_contexts.get_mut(&change.browsing_context_id) { Some(browsing_context) => { debug!("Adding pipeline to existing browsing context."); -@@ -4994,11 +7055,15 @@ +@@ -4994,11 +7060,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); @@ -2488,7 +2493,7 @@ }, }; -@@ -5006,6 +7071,18 @@ +@@ -5006,6 +7076,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 index 21db15f..e76b429 100644 --- a/patches/components/constellation/idle_service.rs.patch +++ b/patches/components/constellation/idle_service.rs.patch @@ -1,6 +1,6 @@ --- original +++ modified -@@ -0,0 +1,83 @@ +@@ -0,0 +1,89 @@ +/* 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/. */ @@ -72,6 +72,12 @@ + true + } + ++ /// Record the the user is active, even when not triggered by an input event. ++ pub(crate) fn note_user_active(&mut self) { ++ self.last_activity = Instant::now(); ++ self.last_broadcast = self.last_activity; ++ } ++ + /// The state to report to script. + pub(crate) fn state(&self) -> IdleStateEvent { + IdleStateEvent { diff --git a/ui/shared/dbus/battery.js b/ui/shared/dbus/battery.js new file mode 100644 index 0000000..cfa8fac --- /dev/null +++ b/ui/shared/dbus/battery.js @@ -0,0 +1,219 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +// Battery policy: which battery the chrome shows, and how it describes it. + +import { WarningLevel, upower } from "beaver://shared/dbus/power.js"; + +// Percentages we call low and critical when UPower's own warning level says +// nothing. UPower's thresholds are device-configured, so prefer them and treat +// these as the floor. +const LOW_PERCENT = 20; +const CRITICAL_PERCENT = 10; + +let batteryPromise = null; + +/** + * The battery to display, or null when there is none. + * Monitoring is started on it, so its getters stay current and consumers only + * need to listen for "changed". + */ +export function primaryBattery() { + if (!batteryPromise) { + batteryPromise = resolveBattery(); + } + return batteryPromise; +} + +async function resolveBattery() { + if (!upower.available) { + return null; + } + try { + // UPower's aggregate device first: on a machine with two batteries it is + // the sum. + const display = await upower.displayDevice(); + if (display?.isPresent) { + return display.startMonitoring(); + } + const [first] = await upower.batteries(); + return first ? first.startMonitoring() : null; + } catch (error) { + console.warn("[battery] could not read the battery:", error); + return null; + } +} + +// "4h 20m", or "35m" under an hour. +export function hhmm(seconds) { + const total = Math.round(seconds / 60); + const hours = Math.floor(total / 60); + const minutes = total % 60; + return hours + ? `${hours}h ${String(minutes).padStart(2, "0")}m` + : `${minutes}m`; +} + +// How much trouble the battery is in: "ok", "low" or "critical". +export function batterySeverity(device) { + if (!device) { + return "ok"; + } + const warning = device.warningLevel; + const percent = device.percentage; + if ( + warning === WarningLevel.CRITICAL || + warning === WarningLevel.ACTION || + (percent > 0 && percent <= CRITICAL_PERCENT) + ) { + return "critical"; + } + if (warning === WarningLevel.LOW || (percent > 0 && percent <= LOW_PERCENT)) { + return "low"; + } + return "ok"; +} + +// The lucide glyph for a battery's charge and state. +export function batteryIcon(device) { + if (!device) { + return "battery"; + } + if (device.isCharging) { + return "battery-charging"; + } + if (batterySeverity(device) === "critical") { + return "battery-warning"; + } + if (device.percentage >= 90) { + return "battery-full"; + } + if (device.percentage >= 45) { + return "battery-medium"; + } + // One bar. Also where "low" severity lands, so the glyph and the pebble's + // alert always agree. + return "battery-low"; +} + +/** + * One line: charge, and what is happening to it. `plugged` comes from the + * daemon's OnBattery and only matters when the battery's own state is + * uninformative (plugged in but holding, say at a charge limit). + */ +export function batteryLabel(device, plugged) { + if (!device) { + return ""; + } + const percent = `${Math.round(device.percentage)}%`; + if (device.isFullyCharged) { + return `${percent} · Full`; + } + if (device.isCharging) { + const left = device.timeToFull; + return left + ? `${percent} · ${hhmm(left)} to full` + : `${percent} · Charging`; + } + if (device.isDischarging) { + const left = device.timeToEmpty; + return left ? `${percent} · ${hhmm(left)} left` : `${percent} · On battery`; + } + return `${percent} · ${plugged ? "Plugged in" : "On battery"}`; +} + +/** + * True when the battery deserves to be surfaced outside the quick-settings + * surface. + */ +export function batteryNeedsAttention(device) { + if (!device || device.isCharging || device.isFullyCharged) { + return false; + } + return batterySeverity(device) !== "ok"; +} + +/** + * Everything an out-of-the-way warning surface needs, or null when there is + * nothing worth displaying. + */ +export function batteryAlert(device) { + if (!batteryNeedsAttention(device)) { + return null; + } + return { + severity: batterySeverity(device), + icon: batteryIcon(device), + label: batteryLabel(device, false), + }; +} + +/** + * Call `listener(device)` once the battery is known, and again on every change. + * Returns an unsubscribe. + */ +export function watchBattery(listener) { + let device = null; + let stopped = false; + const onChange = () => listener(device); + primaryBattery().then((found) => { + if (stopped || !found) { + return; + } + device = found; + device.addEventListener("changed", onChange); + listener(device); + }); + return () => { + stopped = true; + device?.removeEventListener("changed", onChange); + }; +} + +// One read and one daemon subscription for the whole chrome. Every consumer +// wants the same bit, and it is kept current by the same signal each of them +// would have subscribed to, so a late consumer gets the truth for free. +const mainsListeners = new Set(); +let mainsPlugged = null; +let mainsStarted = false; + +function setMains(plugged) { + mainsPlugged = plugged; + mainsListeners.forEach((listener) => listener(plugged)); +} + +function startMains() { + if (mainsStarted) { + return; + } + mainsStarted = true; + upower.startMonitoring(); + upower.addEventListener("changed", (event) => { + const value = event.detail?.values?.OnBattery; + if (value !== undefined) { + setMains(!value); + } + }); + // Assume battery power if the daemon will not say. + upower.onBattery().then( + (value) => setMains(!value), + () => setMains(false), + ); +} + +/** + * Call `listener(plugged)` with the mains state, now and whenever it moves. + * Returns an unsubscribe. + */ +export function watchMains(listener) { + // Subscribing throws outright without the bridge (a non-Linux build, or a + // page that cannot reach it). + if (!upower.available) { + return () => {}; + } + startMains(); + mainsListeners.add(listener); + if (mainsPlugged !== null) { + listener(mainsPlugged); + } + return () => mainsListeners.delete(listener); +} diff --git a/ui/shared/dbus/battery_status.css b/ui/shared/dbus/battery_status.css new file mode 100644 index 0000000..1963c6a --- /dev/null +++ b/ui/shared/dbus/battery_status.css @@ -0,0 +1,46 @@ +/* SPDX-License-Identifier: AGPL-3.0-or-later */ + +/* Shared styling for ``, imported by every quick-settings + surface that hosts it (the mobile pebble tray, the desktop dock popover). */ + +battery-status { + display: flex; + min-width: 0; +} + +/* display:flex above beats the UA [hidden] rule, which the element sets on + hardware with no battery. */ +battery-status[hidden] { + display: none; +} + +battery-status .tile { + flex: 1; + min-width: 0; +} + +.tile.readout { + background: transparent; + cursor: default; +} + +.tile.readout:hover { + background: transparent; +} + +.tile.readout:active { + transform: none; +} + +.tile.readout.low lucide-icon { + color: var(--color-text); +} + +.tile.readout.critical { + border-color: color-mix(in oklch, var(--color-danger) 45%, transparent); + color: var(--color-danger); +} + +.tile.readout.critical lucide-icon { + color: var(--color-danger); +} diff --git a/ui/shared/dbus/battery_status.js b/ui/shared/dbus/battery_status.js new file mode 100644 index 0000000..b23db6e --- /dev/null +++ b/ui/shared/dbus/battery_status.js @@ -0,0 +1,84 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +/** + * `` : a compact charge readout. + * + * Light DOM, and the tile class comes from the host surface, same contract as + * ``. + */ + +import { + LitElement, + html, +} from "beaver://shared/third_party/lit/lit-all.min.js"; +import { + batteryIcon, + batteryLabel, + batterySeverity, + primaryBattery, + watchBattery, + watchMains, +} from "beaver://shared/dbus/battery.js"; + +export class BatteryStatus extends LitElement { + #device = null; + #stopBattery = null; + #stopMains = null; + + static properties = { + // Class for the tile, so each shell styles its own tile shape. + tileClass: { type: String, attribute: "tile-class" }, + plugged: { type: Boolean, state: true }, + }; + + constructor() { + super(); + this.tileClass = "tile"; + this.plugged = false; + } + + createRenderRoot() { + return this; + } + + connectedCallback() { + super.connectedCallback(); + // PowerDevice updates its snapshot in place, so the object identity never + // changes and Lit has nothing to diff: ask for the re-render by hand. + this.#stopBattery = watchBattery((device) => { + this.#device = device; + this.requestUpdate(); + }); + this.#stopMains = watchMains((plugged) => { + this.plugged = plugged; + }); + // Nothing to show without a battery: no hardware, or no bridge to ask. + primaryBattery().then((device) => { + this.hidden = !device; + }); + } + + disconnectedCallback() { + super.disconnectedCallback(); + this.#stopBattery?.(); + this.#stopMains?.(); + this.#stopBattery = null; + this.#stopMains = null; + } + + render() { + const label = batteryLabel(this.#device, this.plugged); + return html` + + `; + } +} + +customElements.define("battery-status", BatteryStatus); diff --git a/ui/shared/dbus/bus.js b/ui/shared/dbus/bus.js new file mode 100644 index 0000000..ff75cc4 --- /dev/null +++ b/ui/shared/dbus/bus.js @@ -0,0 +1,281 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +/* + * Shared plumbing for the DOM D-Bus bridge (`navigator.embedder.dbus`, see + * source/components/script/dom/dbus.rs). + * + * Bridge contract, as implemented in dbus.rs: + * - `call` / `getProperty` resolve with a JSON *string*; parse it. + * - D-Bus -> JSON: object path -> string, `ao` -> string[], `ay` -> number[], + * `a{sv}` -> object, variants unwrapped, numbers and bools as-is. + * - Outbound args are typed from the JS values (bool/int/double/string) + * unless a `signature` is given, in which case container args are passed as + * JSON strings and expanded per the signature. Variants can be tagged + * `{$sig, $val}`. + * - `subscribe` takes an optional `path`, which narrows the *bus* match only; + * dispatch to JS is still by member name, so client-side filtering stays + * necessary. + */ + +export const SYSTEM = "system"; +export const SESSION = "session"; + +export const PROPS_IFACE = "org.freedesktop.DBus.Properties"; + +/** + * A verbose log channel, off by default. Each D-Bus module gets its own so one + * can be turned on without the others. Enable before load with + * `localStorage.setItem("beaver.debug.", "1")`, or at runtime with + * `window.__Debug = true`. + */ +export function makeDebugLog(tag, key) { + const flag = `__${key}Debug`; + const stored = `beaver.debug.${key}`; + return (...args) => { + let enabled = globalThis[flag]; + if (enabled === undefined) { + try { + enabled = localStorage.getItem(stored) === "1"; + } catch { + enabled = false; + } + } + if (enabled) { + console.log(`[${tag}]`, ...args); + } + }; +} + +// This module's own channel: the hub's signal routing. +export const dbusLog = makeDebugLog("dbus", "dbus"); + +// The DOM D-Bus object, or throw a clear error if this page cannot reach it. +export function bus() { + const dbus = globalThis.navigator?.embedder?.dbus; + if (!dbus) { + throw new Error( + "navigator.embedder.dbus unavailable: needs a beaver:// page on a " + + "D-Bus (Linux) build", + ); + } + return dbus; +} + +// True when this page can reach the bridge at all. +export function busAvailable() { + return !!globalThis.navigator?.embedder?.dbus; +} + +// `call` / `getProperty` resolve with a JSON string ("null" when empty). +export function parseReply(json) { + return json == null || json === "" ? null : JSON.parse(json); +} + +/** + * Call a method. Args are limited to bool/int/double/string unless a D-Bus + * `signature` is given, in which case container args (dicts, arrays, object + * paths) are passed as JSON strings and expanded per the signature. + */ +export async function callMethod({ + bus: busName = SYSTEM, + destination, + path, + iface, + method, + args = [], + signature = "", +}) { + const json = await bus().call({ + bus: busName, + destination, + path, + interfaceName: iface, + method, + args, + signature, + }); + return parseReply(json); +} + +// Read one property; the bridge has already unwrapped the variant. +export async function getProperty({ + bus: busName = SYSTEM, + destination, + path, + iface, + property, +}) { + const json = await bus().getProperty({ + bus: busName, + destination, + path, + interfaceName: iface, + property, + }); + return parseReply(json); +} + +// Write one primitive property value. +export function setProperty( + { bus: busName = SYSTEM, destination, path, iface, property }, + value, +) { + return bus().setProperty( + { + bus: busName, + destination, + path, + interfaceName: iface, + property, + }, + value, + ); +} + +/** + * Read every property of an interface in one round trip + * (`a{sv}` -> object). + */ +export async function getAllProperties({ + bus: busName = SYSTEM, + destination, + path, + iface, +}) { + return ( + (await callMethod({ + bus: busName, + destination, + path, + iface: PROPS_IFACE, + method: "GetAll", + args: [iface], + })) || {} + ); +} + +/** + * Routes incoming signals to handlers, and ref-counts the underlying bus + * subscriptions. + * + * Two layers, because the bridge collapses signals onto the member name: + * one DOM listener per member name, fanning out to the handlers whose interface + * and path match; and one bus subscription per (bus, interface, member), shared + * by every handler that wants it. + */ +class SignalHub { + constructor() { + this._entries = new Map(); // member -> Set<{iface, path, handler}> + this._routers = new Map(); // member -> the addEventListener callback + this._subs = new Map(); // "bus|iface|member" -> {count, idPromise} + } + + /** + * Route a signal to `handler(detail)` when it matches `iface` and optional + * `path`. Returns an unsubscribe function with a `ready` promise attached, + * which resolves once the underlying bus subscription actually exists. + */ + on(busName, iface, member, path, handler) { + const entry = { iface, path: path || "", handler }; + + let set = this._entries.get(member); + if (!set) { + set = new Set(); + this._entries.set(member, set); + // One DOM listener per member name; it fans out to matching entries. + const router = (event) => { + const detail = event.detail || {}; + dbusLog("signal", member, { + interface: detail.interface, + path: detail.path, + args: detail.args, + listeners: set.size, + }); + for (const en of set) { + if (en.iface && detail.interface !== en.iface) { + continue; + } + if (en.path && detail.path !== en.path) { + continue; + } + try { + en.handler(detail); + } catch (err) { + console.error("[dbus] signal handler failed:", err); + } + } + }; + this._routers.set(member, router); + bus().addEventListener(member, router); + } + set.add(entry); + + // One bus subscription per (bus, interface, member), ref-counted. + const key = `${busName}|${iface}|${member}`; + let sub = this._subs.get(key); + if (!sub) { + sub = { + count: 0, + idPromise: bus().subscribe({ + bus: busName, + interfaceName: iface, + signal: member, + }), + }; + dbusLog("subscribe ->", key); + sub.idPromise.then( + (id) => dbusLog("subscribed", key, "id=" + id), + (err) => console.warn("[dbus] subscribe FAILED", key, err), + ); + // No unhandled rejection if the subscribe itself fails. + sub.idPromise.catch(() => {}); + this._subs.set(key, sub); + } + sub.count += 1; + + let active = true; + const unsubscribe = () => { + if (!active) { + return; + } + active = false; + + set.delete(entry); + if (set.size === 0) { + this._entries.delete(member); + const router = this._routers.get(member); + if (router) { + bus().removeEventListener(member, router); + this._routers.delete(member); + } + } + + sub.count -= 1; + if (sub.count <= 0) { + this._subs.delete(key); + sub.idPromise.then((id) => bus().unsubscribe(id)).catch(() => {}); + } + }; + // Resolves when the bus subscription is live, so callers can avoid racing + // a signal they are about to provoke. Never rejects: a failed subscribe + // just means the handler won't fire, which callers already tolerate. + unsubscribe.ready = sub.idPromise.then( + () => {}, + () => {}, + ); + return unsubscribe; + } +} + +// The one hub every D-Bus consumer shares. +export const hub = new SignalHub(); + +// Look up an enum member name by value, for logging and debugging. +export function enumName(enumObject, value) { + for (const [key, candidate] of Object.entries(enumObject)) { + if (candidate === value) { + return key; + } + } + return String(value); +} diff --git a/ui/shared/dbus/network_manager.js b/ui/shared/dbus/network_manager.js index 7c39cc9..9e10c02 100644 --- a/ui/shared/dbus/network_manager.js +++ b/ui/shared/dbus/network_manager.js @@ -24,10 +24,23 @@ * the *member only* (detail = {signal, interface, path, args}), and the bus * match is interface+member bus-wide. So two interfaces sharing a member * name (e.g. "StateChanged", "PropertiesChanged") collide on the event name; - * every handler must filter by interface (and usually path). The private - * SignalHub below does that routing and ref-counts the subscriptions. + * every handler must filter by interface (and usually path). The shared + * hub in bus.js does that routing and ref-counts the subscriptions. */ +import { + PROPS_IFACE, + SYSTEM, + busAvailable, + callMethod as dbusCall, + enumName, + getAllProperties as dbusGetAll, + getProperty as dbusGetProperty, + hub, + makeDebugLog, + setProperty as dbusSetProperty, +} from "beaver://shared/dbus/bus.js"; + // --------------------------------------------------------------------------- // D-Bus names // --------------------------------------------------------------------------- @@ -38,9 +51,6 @@ const NM_IFACE = "org.freedesktop.NetworkManager"; const DEVICE_IFACE = "org.freedesktop.NetworkManager.Device"; const WIRELESS_IFACE = "org.freedesktop.NetworkManager.Device.Wireless"; const AP_IFACE = "org.freedesktop.NetworkManager.AccessPoint"; -const PROPS_IFACE = "org.freedesktop.DBus.Properties"; - -const SYSTEM = "system"; // --------------------------------------------------------------------------- // Enums (values are NetworkManager's own; names are ours for readability) @@ -104,18 +114,9 @@ const SEC_KEY_MGMT_SAE = 0x400; // WPA3-Personal const SEC_KEY_MGMT_OWE = 0x800; // Enhanced Open const SEC_KEY_MGMT_OWE_TM = 0x1000; -function nameOf(enumObj, value) { - for (const [key, v] of Object.entries(enumObj)) { - if (v === value) { - return key; - } - } - return String(value); -} - // Human-readable name for an NMState / NMDeviceState value. -export const nmStateName = (value) => nameOf(NMState, value); -export const nmDeviceStateName = (value) => nameOf(NMDeviceState, value); +export const nmStateName = (value) => enumName(NMState, value); +export const nmDeviceStateName = (value) => enumName(NMDeviceState, value); /** * What this module can do on the current bridge. `connect` and `scan` need @@ -136,89 +137,42 @@ export const CAPABILITIES = Object.freeze({ // --------------------------------------------------------------------------- /** - * Signal-path tracing. turn it on from any beaver:// console - * with `localStorage.setItem("beaver.debug.nm", "1")` and reload, or at runtime - * with `window.__nmDebug = true`. + * Signal-path tracing. Turn it on from any beaver:// console with + * `localStorage.setItem("beaver.debug.nm", "1")` and reload, or at runtime with + * `window.__nmDebug = true`. */ -function nmDebugEnabled() { - if (globalThis.__nmDebug !== undefined) { - return !!globalThis.__nmDebug; - } - try { - return localStorage.getItem("beaver.debug.nm") === "1"; - } catch { - return false; - } -} - -export function nmLog(...args) { - if (nmDebugEnabled()) { - console.log("[nm]", ...args); - } -} - -// The DOM D-Bus object, or throw a clear error if this page can't reach it. -function bus() { - const dbus = globalThis.navigator?.embedder?.dbus; - if (!dbus) { - throw new Error( - "navigator.embedder.dbus unavailable: NetworkManager needs a beaver:// " + - "page on a D-Bus (Linux) build", - ); - } - return dbus; -} - -// `call` / `getProperty` resolve with a JSON string ("null" when empty). -function parseReply(json) { - return json == null || json === "" ? null : JSON.parse(json); -} +export const nmLog = makeDebugLog("nm", "nm"); /** Call an NM method. Args are limited to bool/int/double/string unless a * D-Bus `signature` is given, in which case container args (dicts, arrays, * object paths) are passed as JSON strings and expanded per the signature. */ -async function callMethod(path, iface, method, args = [], signature = "") { - const json = await bus().call({ - bus: SYSTEM, +function callMethod(path, iface, method, args = [], signature = "") { + return dbusCall({ destination: NM_SERVICE, path, - interfaceName: iface, + iface, method, args, signature, }); - return parseReply(json); } // Read one property (variant already unwrapped by the bridge). -async function getProp(path, iface, property) { - const json = await bus().getProperty({ - bus: SYSTEM, - destination: NM_SERVICE, - path, - interfaceName: iface, - property, - }); - return parseReply(json); +function getProp(path, iface, property) { + return dbusGetProperty({ destination: NM_SERVICE, path, iface, property }); } // Write one primitive property value. function setProp(path, iface, property, value) { - return bus().setProperty( - { - bus: SYSTEM, - destination: NM_SERVICE, - path, - interfaceName: iface, - property, - }, + return dbusSetProperty( + { destination: NM_SERVICE, path, iface, property }, value, ); } // Read every property of an interface in one round trip (a{sv} -> object). -async function getAllProps(path, iface) { - return (await callMethod(path, PROPS_IFACE, "GetAll", [iface])) || {}; +function getAllProps(path, iface) { + return dbusGetAll({ destination: NM_SERVICE, path, iface }); } // The SSID comes over the wire as `ay` (a byte array of numbers), not a string. @@ -255,130 +209,6 @@ function buildConnectionProfile(ssid, password, security) { return profile; } -// --------------------------------------------------------------------------- -// SignalHub: subscribe once per (bus, interface, member), route to interested -// wrappers filtered by interface + object path. Ref-counted so the last -// listener leaving tears the subscription down. -// --------------------------------------------------------------------------- - -class SignalHub { - constructor() { - this._entries = new Map(); // member -> Set<{iface, path, handler}> - this._routers = new Map(); // member -> the addEventListener callback - this._subs = new Map(); // "bus|iface|member" -> {count, idPromise} - } - - /** - * Route a signal to `handler(detail)` when it matches `iface` and (if given) - * `path`. Returns an unsubscribe function with a `ready` promise attached, - * which resolves once the underlying bus subscription actually exists. - */ - on(busName, iface, member, path, handler) { - const entry = { iface, path: path || "", handler }; - - let set = this._entries.get(member); - if (!set) { - set = new Set(); - this._entries.set(member, set); - // One DOM listener per member name; it fans out to matching entries. - const router = (event) => { - const detail = event.detail || {}; - nmLog("signal", member, { - interface: detail.interface, - path: detail.path, - args: detail.args, - listeners: set.size, - }); - for (const en of set) { - if (en.iface && detail.interface !== en.iface) { - nmLog(" skip (interface)", member, { - want: en.iface, - got: detail.interface, - }); - continue; - } - if (en.path && detail.path !== en.path) { - nmLog(" skip (path)", member, { - want: en.path, - got: detail.path, - }); - continue; - } - nmLog(" deliver", member, en.iface, en.path); - try { - en.handler(detail); - } catch (err) { - console.error("[network_manager] signal handler failed:", err); - } - } - }; - this._routers.set(member, router); - bus().addEventListener(member, router); - } - set.add(entry); - - // One bus subscription per (bus, interface, member), ref-counted. - const key = `${busName}|${iface}|${member}`; - let sub = this._subs.get(key); - if (!sub) { - sub = { - count: 0, - idPromise: bus().subscribe({ - bus: busName, - interfaceName: iface, - signal: member, - }), - }; - nmLog("subscribe ->", key); - sub.idPromise.then( - (id) => nmLog("subscribed", key, "id=" + id), - (err) => console.warn("[nm] subscribe FAILED", key, err), - ); - sub.idPromise.catch(() => {}); // no unhandled rejection if subscribe fails - this._subs.set(key, sub); - } - sub.count += 1; - nmLog("listener +1", key, { - path: path || "(any)", - listeners: sub.count, - }); - - let active = true; - const unsubscribe = () => { - if (!active) { - return; - } - active = false; - - set.delete(entry); - if (set.size === 0) { - this._entries.delete(member); - const router = this._routers.get(member); - if (router) { - bus().removeEventListener(member, router); - this._routers.delete(member); - } - } - - sub.count -= 1; - if (sub.count <= 0) { - this._subs.delete(key); - sub.idPromise.then((id) => bus().unsubscribe(id)).catch(() => {}); - } - }; - // Resolves when the bus subscription is live, so callers can avoid racing - // a signal they are about to provoke. Never rejects: a failed subscribe - // just means the handler won't fire, which callers already tolerate. - unsubscribe.ready = sub.idPromise.then( - () => {}, - () => {}, - ); - return unsubscribe; - } -} - -const hub = new SignalHub(); - // --------------------------------------------------------------------------- // AccessPoint // --------------------------------------------------------------------------- @@ -851,7 +681,7 @@ export class NetworkManager extends EventTarget { // True when this page can reach the D-Bus bridge at all. get available() { - return !!globalThis.navigator?.embedder?.dbus; + return busAvailable(); } // Overall NMState value. diff --git a/ui/shared/dbus/power.js b/ui/shared/dbus/power.js new file mode 100644 index 0000000..160cb72 --- /dev/null +++ b/ui/shared/dbus/power.js @@ -0,0 +1,577 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +/* + * Battery and power-supply state, from UPower over the DOM D-Bus bridge. + * + * Two entry points, both extending EventTarget so callers can use + * `addEventListener`: + * - `upower` : the system singleton. Whether we are on battery, the + * batteries present, live add/remove. + * - `PowerDevice` : one power source. A battery, but also line power, a UPS, + * or a peripheral that reports its own charge. + * + * Reading is cheap and snapshot-based: a device pulls all its properties in one + * `GetAll` round trip and serves getters from that, so a UI can render a whole + * battery panel from one call. `refresh()` re-reads; `startMonitoring()` keeps + * it current from `PropertiesChanged`. + * + * UPower reference: https://upower.freedesktop.org/docs/ + */ + +import { + PROPS_IFACE, + SYSTEM, + busAvailable, + callMethod, + enumName, + getAllProperties, + getProperty, + hub, +} from "beaver://shared/dbus/bus.js"; + +const UPOWER_SERVICE = "org.freedesktop.UPower"; +const UPOWER_PATH = "/org/freedesktop/UPower"; +const UPOWER_IFACE = "org.freedesktop.UPower"; +const DEVICE_IFACE = "org.freedesktop.UPower.Device"; + +// --------------------------------------------------------------------------- +// Enums. Values are UPower's own; see its `up-types.h`. +// --------------------------------------------------------------------------- + +// What kind of power source a device is (UpDeviceKind). +export const DeviceType = { + UNKNOWN: 0, + LINE_POWER: 1, + BATTERY: 2, + UPS: 3, + MONITOR: 4, + MOUSE: 5, + KEYBOARD: 6, + PDA: 7, + PHONE: 8, + MEDIA_PLAYER: 9, + TABLET: 10, + COMPUTER: 11, + GAMING_INPUT: 12, + PEN: 13, + TOUCHPAD: 14, + MODEM: 15, + NETWORK: 16, + HEADSET: 17, + SPEAKERS: 18, + HEADPHONES: 19, + VIDEO: 20, + OTHER_AUDIO: 21, + REMOTE_CONTROL: 22, + PRINTER: 23, + SCANNER: 24, + CAMERA: 25, + WEARABLE: 26, + TOY: 27, + BLUETOOTH_GENERIC: 28, +}; + +// Charge state (UpDeviceState). +export const DeviceState = { + UNKNOWN: 0, + CHARGING: 1, + DISCHARGING: 2, + EMPTY: 3, + FULLY_CHARGED: 4, + PENDING_CHARGE: 5, + PENDING_DISCHARGE: 6, +}; + +// Cell chemistry (UpDeviceTechnology). +export const DeviceTechnology = { + UNKNOWN: 0, + LITHIUM_ION: 1, + LITHIUM_POLYMER: 2, + LITHIUM_IRON_PHOSPHATE: 3, + LEAD_ACID: 4, + NICKEL_CADMIUM: 5, + NICKEL_METAL_HYDRIDE: 6, +}; + +// How urgent the charge situation is (UpDeviceLevel). +export const WarningLevel = { + UNKNOWN: 0, + NONE: 1, + DISCHARGING: 2, // UPS only + LOW: 3, + CRITICAL: 4, + ACTION: 5, +}; + +/** + * Coarse charge level, for devices that report buckets rather than a percentage + * (many Bluetooth peripherals). `NONE` means "ignore this, use percentage". + */ +export const BatteryLevel = { + UNKNOWN: 0, + NONE: 1, + LOW: 3, + CRITICAL: 4, + NORMAL: 6, + HIGH: 7, + FULL: 8, +}; + +export const deviceTypeName = (value) => enumName(DeviceType, value); +export const deviceStateName = (value) => enumName(DeviceState, value); +export const technologyName = (value) => enumName(DeviceTechnology, value); +export const warningLevelName = (value) => enumName(WarningLevel, value); + +// --------------------------------------------------------------------------- +// Local helpers, bound to the UPower service +// --------------------------------------------------------------------------- + +const call = (path, iface, method, args = []) => + callMethod({ destination: UPOWER_SERVICE, path, iface, method, args }); + +const prop = (path, iface, property) => + getProperty({ destination: UPOWER_SERVICE, path, iface, property }); + +const allProps = (path, iface) => + getAllProperties({ destination: UPOWER_SERVICE, path, iface }); + +// UPower reports "unknown" durations as 0 rather than omitting them, which is +// indistinguishable from "zero seconds remaining" unless you know. Map it to +// null so callers cannot accidentally render "0:00 remaining". +function duration(seconds) { + return typeof seconds === "number" && seconds > 0 ? seconds : null; +} + +// --------------------------------------------------------------------------- +// PowerDevice +// --------------------------------------------------------------------------- + +/** + * One power source. + * + * Getters read a cached snapshot taken by `refresh()` so they are synchronous and free. + * A device that has never been refreshed reports zeroes and `UNKNOWN`. + * + * Events: "changed", with `detail.properties` naming what UPower said changed. + */ +export class PowerDevice extends EventTarget { + constructor(path, properties) { + super(); + this._path = path; + this._props = properties || {}; + this._off = null; + } + + get path() { + return this._path; + } + + /** The raw property snapshot, for anything not surfaced below. */ + get properties() { + return this._props; + } + + get type() { + return this._props.Type ?? DeviceType.UNKNOWN; + } + + get vendor() { + return this._props.Vendor || ""; + } + + get model() { + return this._props.Model || ""; + } + + get serial() { + return this._props.Serial || ""; + } + + // Kernel name, e.g. "BAT0". Useful as a stable-ish label. + get nativePath() { + return this._props.NativePath || ""; + } + + /** + * True when this device powers the system, rather than being a peripheral + * that happens to report a battery (a wireless mouse, say). This is the flag + * to filter on before showing something as "the" battery. + */ + get isPowerSupply() { + return !!this._props.PowerSupply; + } + + get isPresent() { + return !!this._props.IsPresent; + } + + get isRechargeable() { + return !!this._props.IsRechargeable; + } + + get isBattery() { + return this.type === DeviceType.BATTERY; + } + + get isLinePower() { + return this.type === DeviceType.LINE_POWER; + } + + // Charge in percent, 0..100 + get percentage() { + return this._props.Percentage ?? 0; + } + + get state() { + return this._props.State ?? DeviceState.UNKNOWN; + } + + get stateName() { + return deviceStateName(this.state); + } + + get isCharging() { + return ( + this.state === DeviceState.CHARGING || + this.state === DeviceState.PENDING_CHARGE + ); + } + + get isDischarging() { + return this.state === DeviceState.DISCHARGING; + } + + get isFullyCharged() { + return this.state === DeviceState.FULLY_CHARGED; + } + + /** + * For line power only: whether something is plugged into it. Meaningless on + * batteries, where UPower leaves it false. + */ + get online() { + return !!this._props.Online; + } + + // Seconds until empty, or null when UPower does not know. + get timeToEmpty() { + return duration(this._props.TimeToEmpty); + } + + // Seconds until full, or null when UPower does not know. + get timeToFull() { + return duration(this._props.TimeToFull); + } + + /** + * Seconds until the state that is actually approaching: empty while + * discharging, full while charging. Null when unknown or neither applies. + */ + get timeRemaining() { + if (this.isCharging) { + return this.timeToFull; + } + if (this.isDischarging) { + return this.timeToEmpty; + } + return null; + } + + // Current charge in watt-hours. + get energy() { + return this._props.Energy ?? 0; + } + + // Charge when full, in watt-hours. Shrinks as the cell ages. + get energyFull() { + return this._props.EnergyFull ?? 0; + } + + // Charge when new, in watt-hours. + get energyFullDesign() { + return this._props.EnergyFullDesign ?? 0; + } + + /** + * Rate of change in watts: what is being drawn while discharging, or pushed + * in while charging. 0 when idle or unknown. + */ + get energyRate() { + return this._props.EnergyRate ?? 0; + } + + get voltage() { + return this._props.Voltage ?? 0; + } + + // Degrees Celsius, 0 when the device does not report it. + get temperature() { + return this._props.Temperature ?? 0; + } + + get chargeCycles() { + // -1 means unknown, which is not a cycle count any caller wants to show. + const cycles = this._props.ChargeCycles; + return typeof cycles === "number" && cycles >= 0 ? cycles : null; + } + + /** + * Health as a percentage of design capacity. Null when unknown; UPower + * reports 0 for devices that cannot measure it, which would read as a dead + * battery rather than as missing data. + */ + get capacity() { + const capacity = this._props.Capacity; + return typeof capacity === "number" && capacity > 0 ? capacity : null; + } + + get technology() { + return this._props.Technology ?? DeviceTechnology.UNKNOWN; + } + + get warningLevel() { + return this._props.WarningLevel ?? WarningLevel.UNKNOWN; + } + + /** + * Coarse level for devices that report buckets instead of a percentage. + * `BatteryLevel.NONE` means the percentage is the real answer. + */ + get batteryLevel() { + return this._props.BatteryLevel ?? BatteryLevel.UNKNOWN; + } + + // The icon name UPower suggests, from the freedesktop naming spec. + get iconName() { + return this._props.IconName || ""; + } + + // When UPower last updated this device, as a Date, or null. + get updated() { + const seconds = this._props.UpdateTime; + return typeof seconds === "number" && seconds > 0 + ? new Date(seconds * 1000) + : null; + } + + // Re-read every property. One round trip. + async refresh() { + this._props = await allProps(this._path, DEVICE_IFACE); + return this; + } + + /** + * Keep the snapshot current from `PropertiesChanged`, emitting "changed". + * Idempotent; returns `this` so it can be chained onto a constructor. + */ + startMonitoring() { + if (this._off) { + return this; + } + this._off = hub.on( + SYSTEM, + PROPS_IFACE, + "PropertiesChanged", + this._path, + (detail) => { + // args = (interface_name, changed_properties, invalidated_properties) + const [iface, changed] = detail.args || []; + if (iface !== DEVICE_IFACE || !changed) { + return; + } + Object.assign(this._props, changed); + this.dispatchEvent( + new CustomEvent("changed", { + detail: { properties: Object.keys(changed) }, + }), + ); + }, + ); + return this; + } + + stopMonitoring() { + if (this._off) { + this._off(); + this._off = null; + } + return this; + } +} + +// --------------------------------------------------------------------------- +// UPower +// --------------------------------------------------------------------------- + +/** + * The UPower daemon. + * + * Events (after `startMonitoring()`): "changed" when the daemon's own + * properties move (chiefly `OnBattery`), "device-added" and "device-removed" + * with `detail.path`. + */ +export class UPower extends EventTarget { + constructor() { + super(); + this._subs = null; + } + + /** True when this page can reach the D-Bus bridge at all. */ + get available() { + return busAvailable(); + } + + /** + * Whether the system is running from a battery. + * + * UPower defines this over power-supply devices only, so a Bluetooth mouse + * reporting a low battery never affects it. False on a machine with no + * battery at all, which is the right answer: it is on mains. + */ + onBattery() { + return prop(UPOWER_PATH, UPOWER_IFACE, "OnBattery").then( + (value) => !!value, + ); + } + + // Whether the system is on external power. The inverse of `onBattery()`. + async pluggedIn() { + return !(await this.onBattery()); + } + + /** + * What UPower will do when the battery becomes critical, as a bare string + * ("HybridSleep", "Hibernate", "PowerOff", "Ignore"). + */ + criticalAction() { + return call(UPOWER_PATH, UPOWER_IFACE, "GetCriticalAction"); + } + + lidIsPresent() { + return prop(UPOWER_PATH, UPOWER_IFACE, "LidIsPresent").then( + (value) => !!value, + ); + } + + lidIsClosed() { + return prop(UPOWER_PATH, UPOWER_IFACE, "LidIsClosed").then( + (value) => !!value, + ); + } + + daemonVersion() { + return prop(UPOWER_PATH, UPOWER_IFACE, "DaemonVersion"); + } + + /** Object paths of every device UPower knows about. */ + devicePaths() { + return call(UPOWER_PATH, UPOWER_IFACE, "EnumerateDevices").then( + (paths) => paths || [], + ); + } + + /** + * Every device, each already carrying a full property snapshot. Properties + * are fetched in parallel, so this is one round trip plus one per device + * rather than a serial walk. + */ + async devices() { + const paths = await this.devicePaths(); + const snapshots = await Promise.all( + paths.map((path) => allProps(path, DEVICE_IFACE).catch(() => null)), + ); + return paths + .map((path, index) => + snapshots[index] ? new PowerDevice(path, snapshots[index]) : null, + ) + .filter(Boolean); + } + + /** + * The batteries that power the system, most charged last so `[0]` is the one + * to worry about. Peripherals with their own cells are excluded: they are + * batteries, but not this machine's. + */ + async batteries() { + const devices = await this.devices(); + return devices + .filter((device) => device.isBattery && device.isPowerSupply) + .sort((a, b) => a.percentage - b.percentage); + } + + // The line-power (mains) devices, if any. + async linePowerDevices() { + const devices = await this.devices(); + return devices.filter((device) => device.isLinePower); + } + + /** + * UPower's synthetic summary device: one aggregate battery standing for + * all of them, which is what a status indicator should usually show. Null + * when the daemon offers none. + * + * Its `Type` is BATTERY even on a desktop with no battery, so check + * `isPresent` before rendering it. + */ + async displayDevice() { + const path = await call(UPOWER_PATH, UPOWER_IFACE, "GetDisplayDevice"); + if (!path || path === "/") { + return null; + } + const device = new PowerDevice(path); + await device.refresh(); + return device; + } + + // Emit daemon property changes and device add/remove. Idempotent. + startMonitoring() { + if (this._subs) { + return this; + } + this._subs = [ + hub.on( + SYSTEM, + PROPS_IFACE, + "PropertiesChanged", + UPOWER_PATH, + (detail) => { + const [iface, changed] = detail.args || []; + if (iface !== UPOWER_IFACE || !changed) { + return; + } + this.dispatchEvent( + new CustomEvent("changed", { + detail: { properties: Object.keys(changed), values: changed }, + }), + ); + }, + ), + hub.on(SYSTEM, UPOWER_IFACE, "DeviceAdded", "", (detail) => { + this.dispatchEvent( + new CustomEvent("device-added", { + detail: { path: detail.args?.[0] }, + }), + ); + }), + hub.on(SYSTEM, UPOWER_IFACE, "DeviceRemoved", "", (detail) => { + this.dispatchEvent( + new CustomEvent("device-removed", { + detail: { path: detail.args?.[0] }, + }), + ); + }), + ]; + return this; + } + + stopMonitoring() { + if (this._subs) { + this._subs.forEach((off) => off()); + this._subs = null; + } + return this; + } +} + +// Shared instance: UPower is a system singleton, subscriptions are global. +export const upower = new UPower(); diff --git a/ui/shared/dbus/power_probe.js b/ui/shared/dbus/power_probe.js new file mode 100644 index 0000000..54f5b78 --- /dev/null +++ b/ui/shared/dbus/power_probe.js @@ -0,0 +1,152 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +/** + * Verification harness for the UPower module (ui/shared/dbus/power.js). + * + * No UI, and nothing calls it: it exists to be run by hand from a beaver:// + * console when checking the module against `upower -d`, which is also why it + * is the only consumer of the rarer PowerDevice getters. + * + * import("beaver://shared/dbus/power_probe.js").then((m) => m.probeUPower()) + * + * It dumps the daemon state and every power device once, then logs each change + * as it arrives. Returns a teardown that stops the monitoring it started. + */ + +import { + deviceTypeName, + technologyName, + upower, + warningLevelName, +} from "beaver://shared/dbus/power.js"; +import { hhmm } from "beaver://shared/dbus/battery.js"; + +const TAG = "[upower]"; + +/** Seconds to "2h 05m", or "-" when UPower does not know. */ +function duration(seconds) { + return seconds === null ? "-" : hhmm(seconds); +} + +/** One line per device, enough to see at a glance if the values are sane. */ +function summary(device) { + const bits = [deviceTypeName(device.type), device.stateName]; + if (device.isBattery) { + bits.push(`${device.percentage.toFixed(1)}%`); + bits.push(`${duration(device.timeRemaining)} left`); + } + if (device.isLinePower) { + bits.push(device.online ? "online" : "offline"); + } + if (!device.isPowerSupply) { + bits.push("peripheral"); + } + return `${device.path}\n ${bits.join(", ")}`; +} + +/** The rest of the battery numbers, once at startup. */ +function detail(device) { + return { + model: `${device.vendor} ${device.model}`.trim(), + native: device.nativePath, + technology: technologyName(device.technology), + warning: warningLevelName(device.warningLevel), + energy: `${device.energy} / ${device.energyFull} Wh`, + design: `${device.energyFullDesign} Wh`, + rate: `${device.energyRate} W`, + voltage: device.voltage, + temperature: device.temperature, + capacity: device.capacity, + cycles: device.chargeCycles, + icon: device.iconName, + updated: device.updated?.toISOString() ?? null, + }; +} + +/** + * Dump everything, then watch for changes. Resolves with a teardown function. + * Never throws: a missing bridge or absent daemon is logged and gives back a + * no-op. + */ +export async function probeUPower() { + // Left behind for poking the API by hand once the dump has run. + globalThis.__upower = upower; + + if (!upower.available) { + console.log(TAG, "D-Bus bridge unavailable, skipping probe"); + return () => {}; + } + + let devices; + try { + // Independent reads: one round trip of wall time rather than five. + const [version, onBattery, critical, lidPresent, lidClosed, found] = + await Promise.all([ + upower.daemonVersion(), + upower.onBattery(), + upower.criticalAction(), + upower.lidIsPresent(), + upower.lidIsClosed(), + upower.devices(), + ]); + console.log( + TAG, + `daemon ${version},`, + `onBattery=${onBattery},`, + `criticalAction=${critical},`, + `lid=${lidPresent}/${lidClosed}`, + ); + devices = found; + } catch (error) { + console.warn(TAG, "probe failed, is upowerd running?", error); + return () => {}; + } + + console.log(TAG, `${devices.length} device(s)`); + for (const device of devices) { + console.log(TAG, summary(device)); + if (device.isBattery) { + console.log(TAG, " ", detail(device)); + } + } + + const display = await upower.displayDevice(); + console.log( + TAG, + "display device:", + display ? summary(display) : "none", + display && !display.isPresent ? "(not present)" : "", + ); + + // Watch the power supplies only. A Bluetooth mouse reporting its charge every + // few minutes is noise for this probe. + const watched = devices.filter((device) => device.isPowerSupply); + for (const device of watched) { + device.startMonitoring(); + device.addEventListener("changed", (event) => { + console.log( + TAG, + "changed", + summary(device), + `[${event.detail.properties.join(" ")}]`, + ); + }); + } + + upower.startMonitoring(); + upower.addEventListener("changed", (event) => { + console.log(TAG, "daemon changed", event.detail.values); + }); + upower.addEventListener("device-added", (event) => { + console.log(TAG, "device added", event.detail.path); + }); + upower.addEventListener("device-removed", (event) => { + console.log(TAG, "device removed", event.detail.path); + }); + + return () => { + upower.stopMonitoring(); + watched.forEach((device) => device.stopMonitoring()); + display?.stopMonitoring(); + }; +} diff --git a/ui/shared/lucide_icon.js b/ui/shared/lucide_icon.js index e95ded0..f67f221 100644 --- a/ui/shared/lucide_icon.js +++ b/ui/shared/lucide_icon.js @@ -2,6 +2,15 @@ import { html, css, LitElement } from "./third_party/lit/lit-all.min.js"; +/** + * The lucide icon-font class for a glyph name. The one place that knows the + * prefix, so a shell swapping the icon on a bare `` (the desktop dock tray + * does this) stays in step with ``. + */ +export function lucideClass(name) { + return `icon-${name}`; +} + class LucideIcon extends LitElement { constructor() { super(); @@ -32,7 +41,7 @@ class LucideIcon extends LitElement { rel="stylesheet" href="beaver://shared/third_party/lucide/lucide.css" /> - + `; } } diff --git a/ui/shared/power/plug_wake.js b/ui/shared/power/plug_wake.js new file mode 100644 index 0000000..77f2277 --- /dev/null +++ b/ui/shared/power/plug_wake.js @@ -0,0 +1,40 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +// Wakes the screen when the power source changes on <-> off. +// +// This also resets the idle timer + +import { watchMains } from "beaver://shared/dbus/battery.js"; +import { screenPower } from "beaver://shared/power/screen.js"; +import { cancelDusk } from "beaver://shared/power/dusk.js"; + +let installed = false; + +// Start watching the power source. Safe to call more than once. +export function bindPlugWake() { + if (installed || !screenPower.available) { + return; + } + installed = true; + + // watchMains reports the current state first, which is not a change: waking + // on it would mean the screen could never be turned off while plugged in. + let known = null; + watchMains((plugged) => { + const first = known === null; + known = plugged; + if (!first) { + wake(); + } + }); +} + +async function wake() { + // A fade in progress means the timeout was about to blank the screen. + cancelDusk(); + // Read the real state rather than trusting the cached one: something else may + // have woken the panel already. + if (!(await screenPower.read())) { + screenPower.wake(); + } +} diff --git a/ui/system/desktop/battery_tray.js b/ui/system/desktop/battery_tray.js new file mode 100644 index 0000000..feaeaa9 --- /dev/null +++ b/ui/system/desktop/battery_tray.js @@ -0,0 +1,43 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +/** + * Battery tray glyph : the dock's low-battery warning. + * + * Present only when the charge is low and nothing is being done about it. + * Clicking it opens quick settings, where the full readout lives. + */ + +import { batteryAlert, watchBattery } from "beaver://shared/dbus/battery.js"; +import { lucideClass } from "beaver://shared/lucide_icon.js"; + +/** + * @param {object} deps + * @param {HTMLElement|null} deps.button The `#tray-battery` glyph. + * @param {Function} deps.onActivate Opens the quick-settings popover. + * @returns {{ stop: Function }} + */ +export function initBatteryTray({ button, onActivate }) { + button?.addEventListener("click", () => onActivate?.(button)); + + const stop = watchBattery((device) => { + if (!button) { + return; + } + // Hidden is the resting state: see batteryAlert for what counts as worth + // showing. + const alert = batteryAlert(device); + button.hidden = !alert; + if (!alert) { + delete button.dataset.severity; + return; + } + const glyph = button.querySelector("i"); + if (glyph) { + glyph.className = lucideClass(alert.icon); + } + button.dataset.severity = alert.severity; + button.title = alert.label; + }); + + return { stop }; +} diff --git a/ui/system/desktop/index.js b/ui/system/desktop/index.js index 95195ef..3589570 100644 --- a/ui/system/desktop/index.js +++ b/ui/system/desktop/index.js @@ -45,6 +45,7 @@ import { initDock } from "./dock.js"; import { initPeers } from "./peers.js"; import { initRepos } from "./repos.js"; import { initQuickSettings } from "./quick_settings.js"; +import { initBatteryTray } from "./battery_tray.js"; import { dockAnchorPosition } from "./popover.js"; import { initDeskSettings } from "./desk_settings.js"; @@ -283,6 +284,7 @@ const elTrayPeers = $("#tray-peers"); const elTrayNotif = $("#tray-notif"); const elTrayRepos = $("#tray-repos"); const elTrayQuickSettings = $("#tray-quick-settings"); +const elTrayBattery = $("#tray-battery"); const elNotificationPanel = $("#notification-panel"); const elPeersPopover = $("#peers-popover"); const elReposPopover = $("#repos-popover"); @@ -680,11 +682,8 @@ function closeDeskSettings() { /* ----- Peers popover ------------------------------------------------------ Click on the tray peers icon → list of paired+connected peers above the dock. Static for now (no per-peer actions); the palette's "Send - active page to " group covers the action side. */ - -function openPeersPopover(anchorBtn) { - peersController?.open(anchorBtn); -} + active page to " group covers the action side. The controller wires + its own tray button, so there is no open helper here. */ function closePeersPopover() { peersController?.close(); @@ -830,6 +829,12 @@ quickSettingsController = initQuickSettings({ setDockPosition: (pos) => session?.setDockPosition(pos), openSettings: () => openOrFocusApp("settings"), }); +// Low-battery warning in the dock. The full readout is in quick settings, so +// the glyph just opens that rather than owning a popover of its own. +initBatteryTray({ + button: elTrayBattery, + onActivate: (anchor) => quickSettingsController?.toggle(anchor), +}); deskSettingsController = initDeskSettings({ element: elDeskSettings, nameInput: elDeskSettingsName, diff --git a/ui/system/desktop/peers.js b/ui/system/desktop/peers.js index f14b0a0..11e29ba 100644 --- a/ui/system/desktop/peers.js +++ b/ui/system/desktop/peers.js @@ -10,11 +10,12 @@ * via the shared `Popover` helper and pushes the array onto the * element via the `peers` property. * - * Anchored above the tray button (the tray sits at the bottom of the - * dock; the popover grows upward into the surface area). + * Anchored to the tray button via the shared dockAnchorPosition(), so it opens + * away from whichever edge the dock is on, like the repos and quick-settings + * popovers beside it. */ -import { Popover } from "./popover.js"; +import { Popover, bindTrayToggle, dockAnchorPosition } from "./popover.js"; const PEER_DOT_PALETTE = [ "oklch(48% 0.12 155)", @@ -29,7 +30,8 @@ const PEER_DOT_PALETTE = [ * @param {HTMLElement|null} deps.trayButton The `#tray-peers` trigger. * @param {HTMLElement|null} deps.badge The `#tray-peers-badge` count badge. * @param {HTMLElement|null} deps.popoverEl The `` element. - * @returns {{ peers: Array, refresh: Function, open: Function, close: Function, isOpen: Function }} + * @returns {{ peers: Array, refresh: Function, open: Function, + * close: Function, toggle: Function, isOpen: Function }} */ export function initPeers({ trayButton, badge, popoverEl }) { /** @type {Array<{id: string, name: string, color: string, delay: string}>} */ @@ -85,36 +87,15 @@ export function initPeers({ trayButton, badge, popoverEl }) { function open(anchor) { if (!popoverEl) return; pushToElement(); - const rect = anchor?.getBoundingClientRect(); - let position; - if (rect && rect.width > 0) { - const right = Math.round(window.innerWidth - rect.right); - position = { - bottom: `${Math.round(window.innerHeight - rect.top + 6)}px`, - // Keep the right edge aligned to the trigger so the popover - // stays inside the viewport on narrow screens. - right: `${Math.max(8, right - 32)}px`, - }; - } else { - position = { - bottom: "calc(var(--dock-clearance) + 8px)", - right: "16px", - }; - } - popover.open(anchor, position); + popover.open(anchor, dockAnchorPosition(anchor)); } function close() { popover.close(); } - // Click on tray button toggles the popover. - if (trayButton) { - trayButton.addEventListener("click", () => { - if (popover.isOpen()) close(); - else open(trayButton); - }); - } + const isOpen = () => popover.isOpen(); + const toggle = bindTrayToggle(trayButton, { open, close, isOpen }); // Hook pairing events. Skipped silently when the embedder doesn't // expose pairing (most test environments). @@ -126,5 +107,5 @@ export function initPeers({ trayButton, badge, popoverEl }) { refresh(); } - return { peers, refresh, open, close, isOpen: () => popover.isOpen() }; + return { peers, refresh, open, close, toggle, isOpen }; } diff --git a/ui/system/desktop/popover.js b/ui/system/desktop/popover.js index 2f05e74..881a830 100644 --- a/ui/system/desktop/popover.js +++ b/ui/system/desktop/popover.js @@ -134,6 +134,30 @@ export class Popover { } } +/** + * Wire a dock tray button to a controller's open/close pair, and hand back the + * toggle so another trigger can share it (eg. the battery glyph opens quick + * settings, rather than owning a popover of its own). + * + * This cannot live on `Popover`: every tray controller's `open()` does its own + * pre-render work first which this helper knows nothing about. + * + * @param {HTMLElement|null} button The tray trigger. `null` is tolerated. + * @param {{open: Function, close: Function, isOpen: Function}} controller + * @returns {Function} `toggle(anchor)`, defaulting to `button` as the anchor. + */ +export function bindTrayToggle(button, { open, close, isOpen }) { + const toggle = (anchor = button) => { + if (isOpen()) { + close(); + } else { + open(anchor); + } + }; + button?.addEventListener("click", () => toggle()); + return toggle; +} + /** * Compute a CSS position for a dock popover anchored to its tray button, aware * of the dock edge: the popover opens over the surface (away from the dock) and diff --git a/ui/system/desktop/quick_settings.js b/ui/system/desktop/quick_settings.js index 2f0d6a8..72e7250 100644 --- a/ui/system/desktop/quick_settings.js +++ b/ui/system/desktop/quick_settings.js @@ -8,7 +8,7 @@ * hide. Rows for absent hardware are dropped by the element itself. */ -import { Popover, dockAnchorPosition } from "./popover.js"; +import { Popover, bindTrayToggle, dockAnchorPosition } from "./popover.js"; import { powerMenu } from "beaver://shared/power/power_menu.js"; /** @@ -73,15 +73,8 @@ export function initQuickSettings({ }); } - if (trayButton) { - trayButton.addEventListener("click", () => { - if (popover.isOpen()) { - close(); - } else { - open(trayButton); - } - }); - } + const isOpen = () => popover.isOpen(); + const toggle = bindTrayToggle(trayButton, { open, close, isOpen }); - return { open, close, isOpen: () => popover.isOpen() }; + return { open, close, toggle, isOpen }; } diff --git a/ui/system/desktop/quick_settings_popover.css b/ui/system/desktop/quick_settings_popover.css index 126538e..001a742 100644 --- a/ui/system/desktop/quick_settings_popover.css +++ b/ui/system/desktop/quick_settings_popover.css @@ -1,5 +1,7 @@ /* SPDX-License-Identifier: AGPL-3.0-or-later */ +@import url("beaver://shared/dbus/battery_status.css"); + /* Quick settings popover : opens from the tray sliders button. Same material and entrance as the repos / peers popovers so the tray reads as one family; the controller re-anchors it for left/right docks, hence the bottom-edge @@ -117,6 +119,12 @@ wifi-toggle { min-width: 0; } +/* Same deal for (the rest of its styling comes from the + shared partial): it sits in a flex row here, so it has to claim the width. */ +battery-status { + flex: 1; +} + .tile { display: flex; flex: 1; @@ -149,6 +157,7 @@ wifi-toggle { .tile lucide-icon { flex: none; + font-size: var(--font-size-lg); color: var(--color-text-secondary); } diff --git a/ui/system/desktop/quick_settings_popover.js b/ui/system/desktop/quick_settings_popover.js index e4cf946..143c1e0 100644 --- a/ui/system/desktop/quick_settings_popover.js +++ b/ui/system/desktop/quick_settings_popover.js @@ -21,6 +21,8 @@ import { } from "beaver://shared/third_party/lit/lit-all.min.js"; import { screenBrightness } from "beaver://shared/power/brightness.js"; import { WifiToggle } from "beaver://shared/dbus/wifi_toggle.js"; +import { primaryBattery } from "beaver://shared/dbus/battery.js"; +import "beaver://shared/dbus/battery_status.js"; import "beaver://shared/power/brightness_slider.js"; // Matches the palette's dock entries, so the two surfaces agree. @@ -37,6 +39,7 @@ export class QuickSettingsPopover extends LitElement { dockPosition: { type: String }, hasBrightness: { type: Boolean, state: true }, hasWifi: { type: Boolean, state: true }, + hasBattery: { type: Boolean, state: true }, }; static styles = css` @@ -48,6 +51,7 @@ export class QuickSettingsPopover extends LitElement { this.dockPosition = "bottom"; this.hasBrightness = false; this.hasWifi = false; + this.hasBattery = false; } connectedCallback() { @@ -57,6 +61,9 @@ export class QuickSettingsPopover extends LitElement { screenBrightness.device().then((device) => { this.hasBrightness = !!device; }); + primaryBattery().then((battery) => { + this.hasBattery = !!battery; + }); } // Re-read the hardware. Called by the controller when the popover opens. @@ -137,6 +144,13 @@ export class QuickSettingsPopover extends LitElement { ` : "" } + ${ + this.hasBattery + ? html`
+ +
` + : "" + } ${this.#renderDock()} `; diff --git a/ui/system/desktop/repos.js b/ui/system/desktop/repos.js index f443b9e..acdfed1 100644 --- a/ui/system/desktop/repos.js +++ b/ui/system/desktop/repos.js @@ -10,7 +10,7 @@ * to the tray button, so it lands beside whichever edge the dock is on. */ -import { Popover, dockAnchorPosition } from "./popover.js"; +import { Popover, bindTrayToggle, dockAnchorPosition } from "./popover.js"; import * as indexer from "../services/atproto_indexer.js"; /** @@ -18,7 +18,8 @@ import * as indexer from "../services/atproto_indexer.js"; * @param {HTMLElement|null} deps.trayButton The `#tray-repos` trigger. * @param {HTMLElement|null} deps.badge The `#tray-repos-badge` count badge. * @param {HTMLElement|null} deps.popoverEl The `` element. - * @returns {{ refresh: Function, open: Function, close: Function, isOpen: Function }} + * @returns {{ refresh: Function, open: Function, close: Function, + * toggle: Function, isOpen: Function }} */ export function initRepos({ trayButton, badge, popoverEl }) { const popover = new Popover(popoverEl, { dataAttr: "reposPopoverOpen" }); @@ -76,20 +77,14 @@ export function initRepos({ trayButton, badge, popoverEl }) { popover.close(); } - if (trayButton) { - trayButton.addEventListener("click", () => { - if (popover.isOpen()) { - close(); - } else { - open(trayButton); - } - }); - } + const isOpen = () => popover.isOpen(); + const toggle = bindTrayToggle(trayButton, { open, close, isOpen }); return { refresh: pushToElement, open, close, - isOpen: () => popover.isOpen(), + toggle, + isOpen, }; } diff --git a/ui/system/mediacenter/ambient_view.js b/ui/system/mediacenter/ambient_view.js index 445fa38..3bf3338 100644 --- a/ui/system/mediacenter/ambient_view.js +++ b/ui/system/mediacenter/ambient_view.js @@ -5,6 +5,7 @@ import { html, css, } from "beaver://shared/third_party/lit/lit-all.min.js"; +import { batteryAlert, watchBattery } from "beaver://shared/dbus/battery.js"; // --- Weather API (same as homescreen widget) --- @@ -518,6 +519,10 @@ class AmbientView extends LitElement { _weatherCondition: { state: true }, _weatherIcon: { state: true }, _weatherLocation: { state: true }, + // Low-battery notice: "" when there is nothing to say, else the severity. + _batterySeverity: { state: true }, + _batteryIcon: { state: true }, + _batteryDetail: { state: true }, }; static styles = css` @@ -648,8 +653,63 @@ class AmbientView extends LitElement { } } - /* --- Guest PIN display --- */ + /* --- Low battery --- */ + + /* Bottom-left, the one free corner: same material as the now-playing pill + and the guest PIN, so it belongs to the ambient display rather than + interrupting it. Only rendered when the charge is low and nothing is + being done about it, which on a mains-powered box means never. */ + .battery-warning { + position: fixed; + bottom: 2.5em; + left: 2.5em; + display: flex; + align-items: center; + gap: 0.75em; + padding: 0.7em 1.4em; + background: var(--mc-overlay-light); + backdrop-filter: blur(16px); + border-radius: 2em; + border: 1px solid oklch(35% 0.03 65 / 0.25); + color: var(--mc-text-secondary); + font-size: clamp(0.85em, 1.5vw, 1.05em); + z-index: 2; + } + + /* Stacked, not strung out on one line: the now-playing pill is centred and + can reach 25vw to the left, and a single-line version of this gets close + enough to touch it on a narrow screen. */ + .battery-warning .lines { + display: flex; + flex-direction: column; + gap: 0.15em; + line-height: 1.2; + } + + .battery-warning lucide-icon { + font-size: var(--font-size-xl); + color: var(--mc-text-secondary); + } + + .battery-warning.critical { + border-color: color-mix(in oklch, var(--mc-danger) 45%, transparent); + color: var(--mc-danger); + } + + .battery-warning.critical lucide-icon { + color: var(--mc-danger); + } + + .battery-warning .headline { + font-weight: var(--font-weight-bold); + letter-spacing: 0.02em; + } + + .battery-warning .detail { + color: var(--mc-text-muted); + } + /* --- Guest PIN display --- */ .guest-pin { position: fixed; bottom: 2.5em; @@ -702,6 +762,10 @@ class AmbientView extends LitElement { this._weatherCondition = ""; this._weatherIcon = "cloud"; this._weatherLocation = ""; + this._batterySeverity = ""; + this._batteryIcon = "battery-low"; + this._batteryDetail = ""; + this._unwatchBattery = null; this._shader = null; this._animFrame = null; this._startTime = performance.now(); @@ -759,6 +823,16 @@ class AmbientView extends LitElement { } } + connectedCallback() { + super.connectedCallback(); + this._unwatchBattery = watchBattery((device) => { + const alert = batteryAlert(device); + this._batterySeverity = alert?.severity ?? ""; + this._batteryIcon = alert?.icon ?? this._batteryIcon; + this._batteryDetail = alert?.label ?? ""; + }); + } + disconnectedCallback() { super.disconnectedCallback(); clearInterval(this._clockInterval); @@ -767,6 +841,8 @@ class AmbientView extends LitElement { if (this._resizeObserver) { this._resizeObserver.disconnect(); } + this._unwatchBattery?.(); + this._unwatchBattery = null; } _resizeCanvas(canvas) { @@ -980,6 +1056,25 @@ class AmbientView extends LitElement { ` : "" } + ${ + this._batterySeverity + ? html` +
+ +
+ + ${ + this._batterySeverity === "critical" + ? "Battery critical" + : "Battery low" + } + + ${this._batteryDetail} +
+
+ ` + : "" + }
diff --git a/ui/system/mediacenter/nav_dock.js b/ui/system/mediacenter/nav_dock.js index 2b5fe66..7706f44 100644 --- a/ui/system/mediacenter/nav_dock.js +++ b/ui/system/mediacenter/nav_dock.js @@ -5,12 +5,21 @@ import { html, css, } from "beaver://shared/third_party/lit/lit-all.min.js"; +import { + batteryIcon, + batterySeverity, + watchBattery, + watchMains, +} from "beaver://shared/dbus/battery.js"; class NavDock extends LitElement { static properties = { hasActiveMedia: { type: Boolean }, pageCount: { type: Number }, notificationCount: { type: Number }, + // Charge readout, shown only while running on battery: a media center is + // usually on mains, and a permanent full-battery icon would be furniture. + _onBattery: { state: true }, }; static styles = css` @@ -28,6 +37,35 @@ class NavDock extends LitElement { backdrop-filter: blur(24px); border-radius: var(--radius-lg) var(--radius-lg) 0 0; border-top: 1px solid oklch(30% 0.04 65 / 0.3); + position: relative; + } + + /* Status, not navigation: parked at the right edge so it never shifts the + centred items, and deliberately absent from getDpadItems() - there is + nothing to press OK on. */ + .battery { + position: absolute; + right: 2.5em; + top: 50%; + transform: translateY(-50%); + display: flex; + align-items: center; + gap: 0.4em; + color: var(--mc-text-muted); + font-size: var(--font-size-base); + font-variant-numeric: tabular-nums; + } + + .battery lucide-icon { + font-size: var(--font-size-lg); + } + + .battery.low { + color: var(--mc-text-secondary); + } + + .battery.critical { + color: var(--mc-danger); } .dock-item { @@ -100,6 +138,30 @@ class NavDock extends LitElement { this.hasActiveMedia = false; this.pageCount = 0; this.notificationCount = 0; + this._onBattery = false; + this._battery = null; + this._unwatch = []; + } + + connectedCallback() { + super.connectedCallback(); + this._unwatch = [ + // PowerDevice updates its snapshot in place, so the object identity never + // changes and Lit has nothing to diff: ask for the re-render by hand. + watchBattery((device) => { + this._battery = device; + this.requestUpdate(); + }), + watchMains((plugged) => { + this._onBattery = !plugged; + }), + ]; + } + + disconnectedCallback() { + super.disconnectedCallback(); + this._unwatch.forEach((stop) => stop()); + this._unwatch = []; } get items() { @@ -179,6 +241,14 @@ class NavDock extends LitElement {
`, )} + ${ + this._onBattery && this._battery + ? html`
+ + ${Math.round(this._battery.percentage)}% +
` + : "" + } `; } diff --git a/ui/system/mobile/init.js b/ui/system/mobile/init.js index f626a1b..06952be 100644 --- a/ui/system/mobile/init.js +++ b/ui/system/mobile/init.js @@ -5,6 +5,7 @@ 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 { bindPlugWake } from "beaver://shared/power/plug_wake.js"; import { warmScreenOffEffect } from "beaver://shared/power/dusk.js"; export function initMobile( @@ -14,6 +15,9 @@ export function initMobile( bindPowerKey(); // Off unless beaver.screen_timeout_ms is set. bindScreenTimeout(); + // Independent of the timeout: a charger going in or out should wake the screen + // however it came to be off. + bindPlugWake(); // Build the screen-off effect's GL context ahead of when it's needed. warmScreenOffEffect(); diff --git a/ui/system/mobile/pebble.js b/ui/system/mobile/pebble.js index f01c4d9..b46b30e 100644 --- a/ui/system/mobile/pebble.js +++ b/ui/system/mobile/pebble.js @@ -9,6 +9,7 @@ import { } from "beaver://shared/third_party/lit/lit-all.min.js"; import { SearchController } from "beaver://shared/search/controller.js"; +import { batteryAlert, watchBattery } from "beaver://shared/dbus/battery.js"; import "beaver://shared/power/brightness_slider.js"; import "./pebble_radial.js"; import "./pebble_tray.js"; @@ -81,6 +82,10 @@ export class MobilePebble extends LitElement { favicon: { type: String }, themeColor: { type: String, state: true }, notifications: { type: Array }, + // Low-battery warning on the ambient pebble: "" when there is nothing worth + // interrupting the user about, else the severity ("low" or "critical"). + batteryWarning: { type: String, state: true }, + batteryGlyph: { type: String, state: true }, // Content blocker blockerOrigin: { type: String, state: true }, blockerEnabled: { type: Boolean, state: true }, @@ -132,6 +137,8 @@ export class MobilePebble extends LitElement { this.favicon = ""; this.themeColor = ""; this.notifications = []; + this.batteryWarning = ""; + this.batteryGlyph = "battery-low"; this.blockerOrigin = ""; this.blockerEnabled = true; @@ -170,6 +177,8 @@ export class MobilePebble extends LitElement { this.toggleAttribute("data-scroll-hidden", true); this._boundViewScrollEnd = () => this.toggleAttribute("data-scroll-hidden", false); + // Tears down the battery watch behind the low-charge glyph. + this._unwatchBattery = null; this._initSearchController(); } @@ -183,6 +192,7 @@ export class MobilePebble extends LitElement { this._boundViewScrollStart, ); document.addEventListener("webview-scroll-end", this._boundViewScrollEnd); + this._watchBattery(); } disconnectedCallback() { @@ -201,6 +211,19 @@ export class MobilePebble extends LitElement { "webview-scroll-end", this._boundViewScrollEnd, ); + this._unwatchBattery?.(); + this._unwatchBattery = null; + } + + // Show a glyph on the ambient pebble when the battery is low and nothing is + // being done about it. The device is shared with the tray tile, so this costs + // one extra listener, no extra bus traffic. + _watchBattery() { + this._unwatchBattery = watchBattery((device) => { + const alert = batteryAlert(device); + this.batteryWarning = alert?.severity ?? ""; + this.batteryGlyph = alert?.icon ?? this.batteryGlyph; + }); } updated(changedProperties) { @@ -1137,6 +1160,16 @@ export class MobilePebble extends LitElement { class="meta-notif ${notifCount > 0 ? "active" : ""}" aria-label=${notifCount > 0 ? `${notifCount} unread` : ""} > + ${ + this.batteryWarning + ? html`` + : "" + } `; } diff --git a/ui/system/mobile/pebble_tray.css b/ui/system/mobile/pebble_tray.css index 3a180a1..c46e48a 100644 --- a/ui/system/mobile/pebble_tray.css +++ b/ui/system/mobile/pebble_tray.css @@ -1,5 +1,7 @@ /* SPDX-License-Identifier: AGPL-3.0-or-later */ +@import url("beaver://shared/dbus/battery_status.css"); + /* Quick-settings tray : hangs below the pebble when pulled down from the ambient state. Same material as the pebble itself (menu surface, warm shadow, hand-cut radii) so the two read as one object that unfurled, not a @@ -50,6 +52,10 @@ wifi-toggle .tile { min-width: 0; } +/* Battery: full width under the toggles, because it is the one item here that + has something to say (charge, state, time left) rather than something to + switch. Everything else about it comes from battery_status.css. */ + .tile { display: flex; align-items: center; @@ -85,6 +91,10 @@ wifi-toggle .tile { .tile lucide-icon { flex: none; + /* Sized as a glyph, not as label text: without this it inherits the tile's + font-size and comes out smaller than the brightness glyph directly above + it, which reads as a mistake in a 52px touch target. */ + font-size: var(--font-size-xl); color: var(--color-text-secondary); } diff --git a/ui/system/mobile/pebble_tray.js b/ui/system/mobile/pebble_tray.js index 0d37b54..d2a090c 100644 --- a/ui/system/mobile/pebble_tray.js +++ b/ui/system/mobile/pebble_tray.js @@ -3,7 +3,8 @@ /** * `` : the quick-settings drawer that hangs below the pebble when * pulled down from the ambient state. Holds the handful of device controls - * worth reaching in one gesture: screen brightness, Wi-Fi, torch. + * worth reaching in one gesture - screen brightness, Wi-Fi, torch - and the + * battery readout below them. * * Owns its own hardware state (unlike the other pebble sub-elements, which are * pure presenters) because each control's availability decides whether the tray @@ -22,6 +23,8 @@ import { } from "beaver://shared/third_party/lit/lit-all.min.js"; import { screenBrightness, torch } from "beaver://shared/power/brightness.js"; import { WifiToggle } from "beaver://shared/dbus/wifi_toggle.js"; +import { primaryBattery } from "beaver://shared/dbus/battery.js"; +import "beaver://shared/dbus/battery_status.js"; import "beaver://shared/power/brightness_slider.js"; export class PebbleTray extends LitElement { @@ -29,6 +32,7 @@ export class PebbleTray extends LitElement { hasBrightness: { type: Boolean, state: true }, hasWifi: { type: Boolean, state: true }, hasTorch: { type: Boolean, state: true }, + hasBattery: { type: Boolean, state: true }, torchOn: { type: Boolean, state: true }, }; @@ -41,6 +45,7 @@ export class PebbleTray extends LitElement { this.hasBrightness = false; this.hasWifi = false; this.hasTorch = false; + this.hasBattery = false; this.torchOn = false; } @@ -51,7 +56,9 @@ export class PebbleTray extends LitElement { /** True once any control is available, i.e. the tray is worth opening. */ hasContent() { - return this.hasBrightness || this.hasWifi || this.hasTorch; + return ( + this.hasBrightness || this.hasWifi || this.hasTorch || this.hasBattery + ); } /** @@ -73,12 +80,14 @@ export class PebbleTray extends LitElement { // One-time availability probe, so absent hardware never renders a tile. async #probe() { - const [backlight, led] = await Promise.all([ + const [backlight, led, battery] = await Promise.all([ screenBrightness.device(), torch.device(), + primaryBattery(), ]); this.hasBrightness = !!backlight; this.hasTorch = !!led; + this.hasBattery = !!battery; this.hasWifi = WifiToggle.available; await this.#refreshTorch(); this.dispatchEvent( @@ -137,6 +146,11 @@ export class PebbleTray extends LitElement { : "" } ${tiles.length ? html`
${tiles}
` : ""} + ${ + this.hasBattery + ? html`` + : "" + } `; }