diff --git a/patches/components/constellation/constellation.rs.patch b/patches/components/constellation/constellation.rs.patch index 9f538a5..4bdc0e1 100644 --- a/patches/components/constellation/constellation.rs.patch +++ b/patches/components/constellation/constellation.rs.patch @@ -506,7 +506,7 @@ }, #[cfg(feature = "webgpu")] ScriptToConstellationMessage::RequestAdapter(response_sender, options, ids) => self -@@ -2099,7 +2341,988 @@ +@@ -2099,7 +2341,997 @@ } }, }, @@ -756,11 +756,20 @@ + interface, + method, + args, ++ signature, + callback, + ) => { + #[cfg(any(target_os = "linux", target_os = "macos"))] -+ self.dbus -+ .call(bus, destination, path, interface, method, args, callback); ++ self.dbus.call( ++ bus, ++ destination, ++ path, ++ interface, ++ method, ++ args, ++ signature, ++ callback, ++ ); + #[cfg(not(any(target_os = "linux", target_os = "macos")))] + let _ = callback.send(Err("D-Bus not available on this platform".to_owned())); + }, @@ -1495,7 +1504,7 @@ } /// Check the origin of a message against that of the pipeline it came from. -@@ -2418,6 +3641,55 @@ +@@ -2418,6 +3650,55 @@ TransferState::TransferInProgress(queue) => queue.push_back(task), TransferState::CompletionFailed(queue) => queue.push_back(task), TransferState::CompletionRequested(_, queue) => queue.push_back(task), @@ -1551,7 +1560,7 @@ } } -@@ -3325,6 +4597,40 @@ +@@ -3325,6 +4606,40 @@ /// fn handle_close_top_level_browsing_context(&mut self, webview_id: WebViewId) { debug!("{webview_id}: Closing"); @@ -1592,7 +1601,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 = -@@ -3601,8 +4907,27 @@ +@@ -3601,8 +4916,27 @@ opener_webview_id, opener_pipeline_id, response_sender, @@ -1620,7 +1629,7 @@ let Some((webview_id_sender, webview_id_receiver)) = generic_channel::channel() else { warn!("Failed to create channel"); let _ = response_sender.send(None); -@@ -3701,6 +5026,392 @@ +@@ -3701,6 +5035,392 @@ }); } @@ -2013,7 +2022,7 @@ #[servo_tracing::instrument(skip_all)] fn handle_refresh_cursor(&self, pipeline_id: PipelineId) { let Some(pipeline) = self.pipelines.get(&pipeline_id) else { -@@ -4250,7 +5961,7 @@ +@@ -4250,7 +5970,7 @@ }, }; @@ -2022,7 +2031,7 @@ match self.browsing_contexts.get_mut(&browsing_context_id) { Some(browsing_context) => { let old_pipeline_id = browsing_context.pipeline_id; -@@ -4259,6 +5970,7 @@ +@@ -4259,6 +5979,7 @@ old_pipeline_id, browsing_context.parent_pipeline_id, browsing_context.webview_id, @@ -2030,7 +2039,7 @@ ) }, None => { -@@ -4268,6 +5980,15 @@ +@@ -4268,6 +5989,15 @@ self.unload_document(old_pipeline_id); @@ -2046,7 +2055,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()); -@@ -4836,7 +6557,7 @@ +@@ -4836,7 +6566,7 @@ } #[servo_tracing::instrument(skip_all)] @@ -2055,7 +2064,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. -@@ -4948,9 +6669,22 @@ +@@ -4948,9 +6678,22 @@ self.constellation_to_embedder_proxy .send(ConstellationToEmbedderMsg::HistoryChanged( webview_id, @@ -2079,7 +2088,7 @@ } #[servo_tracing::instrument(skip_all)] -@@ -4969,7 +6703,7 @@ +@@ -4969,7 +6712,7 @@ webview.focused_browsing_context_id = change.browsing_context_id; } @@ -2088,7 +2097,7 @@ match self.browsing_contexts.get_mut(&change.browsing_context_id) { Some(browsing_context) => { debug!("Adding pipeline to existing browsing context."); -@@ -4976,11 +6710,15 @@ +@@ -4976,11 +6719,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); @@ -2106,7 +2115,7 @@ }, }; -@@ -4988,6 +6726,18 @@ +@@ -4988,6 +6735,18 @@ self.unload_document(old_pipeline_id); } diff --git a/patches/components/constellation/dbus_service.rs.patch b/patches/components/constellation/dbus_service.rs.patch index 35dfc5d..49cfb0d 100644 --- a/patches/components/constellation/dbus_service.rs.patch +++ b/patches/components/constellation/dbus_service.rs.patch @@ -1,6 +1,6 @@ --- original +++ modified -@@ -0,0 +1,537 @@ +@@ -0,0 +1,782 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +use std::collections::HashMap; @@ -8,11 +8,12 @@ +use std::sync::atomic::{AtomicU32, Ordering}; + +use crossbeam_channel::Sender; -+use dbus::arg::messageitem::MessageItem; ++use dbus::arg::messageitem::{MessageItem, MessageItemArray, MessageItemDict}; +use dbus::arg::{ArgType, RefArg}; +use dbus::channel::MatchingReceiver; +use dbus::message::MatchRule; +use dbus::nonblock::{NonblockReply, SyncConnection}; ++use dbus::strings::{Path, Signature}; +use dbus_tokio::connection; +use log::{debug, error}; +use serde_json::{Number, Value}; @@ -96,6 +97,7 @@ + interface: String, + method: String, + args: Vec, ++ signature: String, + callback: GenericCallback>, + ) { + let conn = match self.get_connection(&bus) { @@ -110,8 +112,26 @@ + let mut msg = dbus::Message::new_method_call(&destination, &path, &interface, &method) + .expect("Failed to create message"); + -+ for arg in &args { -+ msg = append_dbus_value(msg, arg); ++ if signature.is_empty() { ++ // No signature: infer each arg's type from its value (bool/int/ ++ // double/string), the historical behavior. ++ for arg in &args { ++ msg = append_dbus_value(msg, arg); ++ } ++ } else { ++ // Signature given: marshal each arg against its declared type, so ++ // object paths, dicts and byte arrays reach the wire correctly. ++ match build_signed_args(&args, &signature) { ++ Ok(items) => { ++ for item in items { ++ msg = msg.append1(item); ++ } ++ }, ++ Err(e) => { ++ let _ = callback.send(Err(format!("D-Bus marshalling failed: {e}"))); ++ return; ++ }, ++ } + } + + let result = match send_and_await(&conn, msg).await { @@ -290,11 +310,14 @@ + let msg_path = msg.path().map(|p| p.to_string()).unwrap_or_default(); + + let args: Vec<_> = msg.iter_init().collect(); ++ // Serialize to a JSON string here: DbusSignalEvent crosses the ++ // script IPC boundary and postcard cannot encode a dynamic ++ // serde_json::Value. + let args_json = if args.is_empty() { -+ Value::Array(vec![]) ++ "[]".to_owned() + } else { + let items: Vec = args.iter().map(|a| refarg_to_json(a)).collect(); -+ Value::Array(items) ++ Value::Array(items).to_string() + }; + + let _ = signal_sender.send(DbusSignalEvent { @@ -538,3 +561,225 @@ + DbusValue::Array(_) => msg, + } +} ++ ++// --------------------------------------------------------------------------- ++// Signature-driven marshalling ++// ++// When a `call` supplies a D-Bus signature, each argument is marshalled against ++// its declared type instead of being inferred from the JS value. This is what ++// lets callers send object paths (`o`), dicts (`a{sv}`) and byte arrays (`ay`), ++// none of which the value-inference path can express. Container-typed arguments ++// arrive as JSON strings (the JS side JSON.stringify's them) and are expanded ++// here; scalar arguments are taken from their DbusValue directly. ++// --------------------------------------------------------------------------- ++ ++// True for types whose argument is carried as a JSON string to be expanded: ++// arrays/dicts (`a...`), structs (`(...)`), and variants (`v`). ++fn sig_is_container(sig: &str) -> bool { ++ matches!(sig.as_bytes().first(), Some(b'a') | Some(b'(') | Some(b'v')) ++} ++ ++// Length of the single complete type starting at `sig[0]` (handles nesting). ++fn complete_type_len(sig: &[u8]) -> usize { ++ match sig.first() { ++ Some(b'a') => 1 + complete_type_len(&sig[1..]), ++ Some(b'(') => matching_bracket(sig, b'(', b')'), ++ Some(b'{') => matching_bracket(sig, b'{', b'}'), ++ Some(_) => 1, ++ None => 0, ++ } ++} ++ ++fn matching_bracket(sig: &[u8], open: u8, close: u8) -> usize { ++ let mut depth = 0usize; ++ for (i, &b) in sig.iter().enumerate() { ++ if b == open { ++ depth += 1; ++ } else if b == close { ++ depth -= 1; ++ if depth == 0 { ++ return i + 1; ++ } ++ } ++ } ++ sig.len() ++} ++ ++// Split a signature into its top-level complete types, e.g. ++// "a{sa{sv}}oo" -> ["a{sa{sv}}", "o", "o"]. ++fn split_signature(sig: &str) -> Vec { ++ let bytes = sig.as_bytes(); ++ let mut out = Vec::new(); ++ let mut i = 0; ++ while i < bytes.len() { ++ let len = complete_type_len(&bytes[i..]).max(1); ++ out.push(String::from_utf8_lossy(&bytes[i..i + len]).into_owned()); ++ i += len; ++ } ++ out ++} ++ ++fn dbusvalue_to_json(value: &DbusValue) -> Value { ++ match value { ++ DbusValue::Bool(b) => Value::Bool(*b), ++ DbusValue::Int32(i) => Value::Number((*i as i64).into()), ++ DbusValue::Uint32(u) => Value::Number((*u as u64).into()), ++ DbusValue::Int64(i) => Value::Number((*i).into()), ++ DbusValue::Double(d) => Number::from_f64(*d) ++ .map(Value::Number) ++ .unwrap_or(Value::Null), ++ DbusValue::String(s) => Value::String(s.clone()), ++ DbusValue::Array(a) => Value::Array(a.iter().map(dbusvalue_to_json).collect()), ++ } ++} ++ ++fn build_signed_args(args: &[DbusValue], signature: &str) -> Result, String> { ++ let sigs = split_signature(signature); ++ if sigs.len() != args.len() { ++ return Err(format!( ++ "signature {signature:?} describes {} args but {} were supplied", ++ sigs.len(), ++ args.len(), ++ )); ++ } ++ let mut items = Vec::with_capacity(args.len()); ++ for (arg, sig) in args.iter().zip(sigs.iter()) { ++ // A String destined for a container type carries JSON to expand. ++ let json = match arg { ++ DbusValue::String(s) if sig_is_container(sig) => { ++ serde_json::from_str(s).map_err(|e| format!("arg for {sig}: invalid JSON: {e}"))? ++ }, ++ other => dbusvalue_to_json(other), ++ }; ++ items.push(json_to_message_item(&json, sig)?); ++ } ++ Ok(items) ++} ++ ++fn make_signature(sig: &str) -> Result, String> { ++ Signature::new(sig.to_owned()).map_err(|e| format!("invalid signature {sig:?}: {e}")) ++} ++ ++fn json_to_message_item(value: &Value, sig: &str) -> Result { ++ match sig.as_bytes().first() { ++ Some(b'b') => Ok(MessageItem::Bool(value.as_bool().unwrap_or(false))), ++ Some(b'y') => Ok(MessageItem::Byte(value.as_u64().unwrap_or(0) as u8)), ++ Some(b'n') => Ok(MessageItem::Int16(value.as_i64().unwrap_or(0) as i16)), ++ Some(b'q') => Ok(MessageItem::UInt16(value.as_u64().unwrap_or(0) as u16)), ++ Some(b'i') => Ok(MessageItem::Int32(value.as_i64().unwrap_or(0) as i32)), ++ Some(b'u') => Ok(MessageItem::UInt32(value.as_u64().unwrap_or(0) as u32)), ++ Some(b'x') => Ok(MessageItem::Int64(value.as_i64().unwrap_or(0))), ++ Some(b't') => Ok(MessageItem::UInt64(value.as_u64().unwrap_or(0))), ++ Some(b'd') => Ok(MessageItem::Double(value.as_f64().unwrap_or(0.0))), ++ Some(b's') => Ok(MessageItem::Str( ++ value.as_str().unwrap_or_default().to_owned(), ++ )), ++ Some(b'o') => { ++ let s = value.as_str().unwrap_or_default(); ++ Ok(MessageItem::ObjectPath( ++ Path::new(s.to_owned()).map_err(|e| format!("invalid object path {s:?}: {e}"))?, ++ )) ++ }, ++ Some(b'g') => { ++ let s = value.as_str().unwrap_or_default(); ++ Ok(MessageItem::Signature(make_signature(s)?)) ++ }, ++ Some(b'v') => marshal_variant(value), ++ Some(b'a') if sig.as_bytes().get(1) == Some(&b'{') => marshal_dict(value, sig), ++ Some(b'a') => marshal_array(value, sig), ++ Some(b'(') => marshal_struct(value, sig), ++ Some(other) => Err(format!( ++ "unsupported type '{}' in signature", ++ *other as char ++ )), ++ None => Err("empty type signature".to_owned()), ++ } ++} ++ ++fn marshal_array(value: &Value, sig: &str) -> Result { ++ let elem_sig = &sig[1..]; ++ let arr = value ++ .as_array() ++ .ok_or_else(|| format!("expected a JSON array for {sig}"))?; ++ let items = arr ++ .iter() ++ .map(|v| json_to_message_item(v, elem_sig)) ++ .collect::, _>>()?; ++ MessageItemArray::new(items, make_signature(sig)?) ++ .map(MessageItem::Array) ++ .map_err(|e| format!("array {sig}: {e:?}")) ++} ++ ++fn marshal_dict(value: &Value, sig: &str) -> Result { ++ // sig is "a{}"; the inner slice is "". ++ let inner = &sig[2..sig.len() - 1]; ++ let key_len = complete_type_len(inner.as_bytes()).max(1); ++ let key_sig = &inner[..key_len]; ++ let value_sig = &inner[key_len..]; ++ let obj = value ++ .as_object() ++ .ok_or_else(|| format!("expected a JSON object for {sig}"))?; ++ let mut pairs = Vec::with_capacity(obj.len()); ++ for (k, v) in obj { ++ let key = json_to_message_item(&Value::String(k.clone()), key_sig)?; ++ let val = json_to_message_item(v, value_sig)?; ++ pairs.push((key, val)); ++ } ++ MessageItemDict::new(pairs, make_signature(key_sig)?, make_signature(value_sig)?) ++ .map(MessageItem::Dict) ++ .map_err(|e| format!("dict {sig}: {e:?}")) ++} ++ ++fn marshal_struct(value: &Value, sig: &str) -> Result { ++ let field_sigs = split_signature(&sig[1..sig.len() - 1]); ++ let arr = value ++ .as_array() ++ .ok_or_else(|| format!("expected a JSON array for struct {sig}"))?; ++ if arr.len() != field_sigs.len() { ++ return Err(format!( ++ "struct {sig}: expected {} fields, got {}", ++ field_sigs.len(), ++ arr.len(), ++ )); ++ } ++ let items = arr ++ .iter() ++ .zip(field_sigs.iter()) ++ .map(|(v, fs)| json_to_message_item(v, fs)) ++ .collect::, _>>()?; ++ Ok(MessageItem::Struct(items)) ++} ++ ++fn marshal_variant(value: &Value) -> Result { ++ // Explicit inner type: { "$sig": "ay", "$val": ... }. Needed whenever the ++ // inner type can't be inferred from JSON alone (e.g. a byte-array SSID). ++ if let Some(obj) = value.as_object() { ++ if let (Some(Value::String(inner_sig)), Some(inner_val)) = ++ (obj.get("$sig"), obj.get("$val")) ++ { ++ return Ok(MessageItem::Variant(Box::new(json_to_message_item( ++ inner_val, inner_sig, ++ )?))); ++ } ++ } ++ // Otherwise infer a reasonable D-Bus type from the JSON value. ++ let inner = match value { ++ Value::Bool(b) => MessageItem::Bool(*b), ++ Value::String(s) => MessageItem::Str(s.clone()), ++ Value::Number(n) => match n.as_i64() { ++ Some(i) if (i32::MIN as i64..=i32::MAX as i64).contains(&i) => { ++ MessageItem::Int32(i as i32) ++ }, ++ Some(i) => MessageItem::Int64(i), ++ None => MessageItem::Double(n.as_f64().unwrap_or(0.0)), ++ }, ++ Value::Object(_) => json_to_message_item(value, "a{sv}")?, ++ Value::Array(_) => { ++ return Err("cannot infer a variant type for an array; tag it as \ ++ {\"$sig\":\"...\",\"$val\":[...]}" ++ .to_owned()); ++ }, ++ Value::Null => return Err("cannot marshal null into a variant".to_owned()), ++ }; ++ Ok(MessageItem::Variant(Box::new(inner))) ++} diff --git a/patches/components/script/dom/dbus.rs.patch b/patches/components/script/dom/dbus.rs.patch index 1a52c55..198d11b 100644 --- a/patches/components/script/dom/dbus.rs.patch +++ b/patches/components/script/dom/dbus.rs.patch @@ -1,6 +1,6 @@ --- original +++ modified -@@ -0,0 +1,292 @@ +@@ -0,0 +1,293 @@ +/* SPDX Id: AGPL-3.0-or-later */ + +use std::rc::Rc; @@ -100,6 +100,7 @@ + options.interfaceName.to_string(), + options.method.to_string(), + args, ++ options.signature.to_string(), + callback, + )) + .is_err() diff --git a/patches/components/shared/constellation/from_script_message.rs.patch b/patches/components/shared/constellation/from_script_message.rs.patch index 54aaaff..6910191 100644 --- a/patches/components/shared/constellation/from_script_message.rs.patch +++ b/patches/components/shared/constellation/from_script_message.rs.patch @@ -95,8 +95,8 @@ + DBusSignal, + /// Interest in ATProto events (login / logout / feedupdated). + AtProtoEvent, - } - ++} ++ +#[derive(Deserialize, Serialize)] +pub enum AtProtoRequest { + /// User, Password @@ -168,8 +168,8 @@ + pub email_auth_factor: bool, + pub active: bool, + pub status: Option, -+} -+ + } + +/// Data returned by com.atproto.server.refreshSession xrpc calls. +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] @@ -258,7 +258,7 @@ /// Mark a new document as active ActivateDocument, /// Set the document state for a pipeline (used by screenshot / reftests) -@@ -803,6 +981,177 @@ +@@ -803,6 +981,180 @@ /// aggregate lock count and notify the provider only when the count transitions from N to 0. /// ReleaseWakeLock(WakeLockType), @@ -342,7 +342,9 @@ + /// Content blocker: reset counts for an origin. + ContentBlockerResetCounts(String, GenericCallback>), + /// D-Bus: call a method. -+ /// Args: bus, destination, path, interface, method, args, callback. ++ /// Args: bus, destination, path, interface, method, args, signature, callback. ++ /// `signature` is the D-Bus type signature of the args; when empty, arg ++ /// types are inferred from the value types (bool/int/double/string only). + DBusCall( + String, + String, @@ -350,6 +352,7 @@ + String, + String, + Vec, ++ String, + GenericCallback>, + ), + /// D-Bus: get a property. diff --git a/patches/components/shared/constellation/lib.rs.patch b/patches/components/shared/constellation/lib.rs.patch index fbf0248..aa5b3e8 100644 --- a/patches/components/shared/constellation/lib.rs.patch +++ b/patches/components/shared/constellation/lib.rs.patch @@ -19,7 +19,7 @@ }; pub use from_script_message::*; use malloc_size_of_derive::MallocSizeOf; -@@ -30,15 +32,185 @@ +@@ -30,15 +32,189 @@ use rustc_hash::FxHashMap; use serde::{Deserialize, Serialize}; use servo_base::cross_process_instant::CrossProcessInstant; @@ -121,7 +121,11 @@ + pub signal: String, + pub interface: String, + pub path: String, -+ pub args_json: serde_json::Value, ++ /// The signal arguments as a serialized JSON array string. This is a ++ /// String rather than a `serde_json::Value` because the event crosses the ++ /// script IPC boundary: postcard cannot serialize a dynamic ++ /// `serde_json::Value` (it has no static shape, hence `WontImplement`). ++ pub args_json: String, +} + +/// An ATProto event broadcasted to the pipelines that registered @@ -207,7 +211,7 @@ /// Messages to the Constellation from the embedding layer, whether from `ServoRenderer` or /// from `libservo` itself. #[derive(IntoStaticStr)] -@@ -116,6 +288,9 @@ +@@ -116,6 +292,9 @@ UpdatePinchZoomInfos(PipelineId, PinchZoomInfos), /// Activate or deactivate accessibility features for the given `WebView`. SetAccessibilityActive(WebViewId, bool), diff --git a/ui/settings/index.css b/ui/settings/index.css index c7767cb..012d845 100644 --- a/ui/settings/index.css +++ b/ui/settings/index.css @@ -13,6 +13,10 @@ body { font-family: var(--font-family-base); background: var(--bg-main); color: var(--color-text-menu); + /* Clamp the document to the viewport so .settings-content is the scroll + boundary (matches search.css / homescreen). Without this the page just + grows past the viewport and nothing scrolls. */ + overflow: hidden; } /* ===== Shared form elements ===== */ @@ -79,6 +83,7 @@ label { display: flex; flex-direction: column; border-right: 1px solid var(--color-border); + min-height: 0; /* let the nav below shrink + scroll */ } .settings-sidebar h1 { @@ -90,6 +95,8 @@ label { .settings-nav { flex: 1; + min-height: 0; + overflow-y: auto; display: flex; flex-direction: column; padding: var(--spacing-sm); @@ -125,9 +132,13 @@ label { font-size: var(--font-size-lg); } -/* Right content area */ +/* Right content area: a plain block scroll pane. min-height:0 lets this flex + child (of body) shrink to the viewport so overflow-y actually engages; do NOT + make it a flex column, or the sections become flex items that shrink to fit + instead of overflowing, and it never scrolls. */ .settings-content { flex: 1; + min-height: 0; overflow-y: auto; padding: var(--spacing-lg); } @@ -710,3 +721,368 @@ input:focus-visible + .toggle-slider { opacity: 0.5; cursor: not-allowed; } + +/* ===== Wi-Fi ===== */ + +.wifi-unavailable { + display: flex; + align-items: center; + gap: var(--spacing-sm); + padding: var(--spacing-md); + margin-top: var(--spacing-md); + border-radius: var(--radius-md); + background: var(--bg-webview); + border: 1px solid var(--color-border); + color: var(--color-text-secondary); + font-size: var(--font-size-sm); +} + +.wifi-unavailable lucide-icon { + color: var(--color-text-tertiary); + font-size: var(--font-size-lg); +} + +/* Four-bar signal meter: bars grow left to right, lit ones use the accent. */ +wifi-signal { + display: inline-flex; + align-items: flex-end; + gap: 2px; + height: 18px; + flex-shrink: 0; +} + +wifi-signal span { + width: 3px; + border-radius: 1px; + background: var(--color-border); + /* Bars fade in/out as the live signal crosses a level: the meter breathes. */ + transition: background-color 0.35s ease; +} + +wifi-signal span:nth-child(1) { + height: 25%; +} +wifi-signal span:nth-child(2) { + height: 50%; +} +wifi-signal span:nth-child(3) { + height: 75%; +} +wifi-signal span:nth-child(4) { + height: 100%; +} + +wifi-signal span.on { + background: var(--color-primary); +} + +/* The connected network, held apart and tinted with the theme accent. */ +.wifi-active { + display: flex; + align-items: center; + gap: var(--spacing-md); + padding: var(--spacing-md); + margin-top: var(--spacing-md); + border: 1px solid var(--color-primary); + border-radius: var(--radius-md); + background: color-mix(in oklch, var(--color-primary) 8%, var(--bg-webview)); +} + +.wifi-active wifi-signal { + height: 22px; +} + +.wifi-active-info { + flex: 1; + display: flex; + flex-direction: column; + gap: 4px; + min-width: 0; +} + +.wifi-active-name { + font-weight: var(--font-weight-bold); + font-size: var(--font-size-lg); +} + +.wifi-active-meta { + display: flex; + align-items: center; + gap: var(--spacing-sm); +} + +.wifi-connected-pill { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: var(--font-size-xs); + font-weight: var(--font-weight-bold); + padding: 2px var(--spacing-sm); + border-radius: var(--radius-sm); + background: var(--color-primary); + color: var(--color-text-on-header); +} + +/* A slow breathing dot signals the connection is live. */ +.wifi-connected-pill::before { + content: ""; + width: 6px; + height: 6px; + border-radius: 50%; + background: currentColor; + animation: wifi-pulse 2.4s ease-in-out infinite; +} + +.wifi-active-detail { + font-size: var(--font-size-xs); + color: var(--color-text-secondary); + font-variant-numeric: tabular-nums; +} + +.wifi-disconnect { + padding: var(--spacing-xs) var(--spacing-md); + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); + background: var(--bg-webview); + color: inherit; + cursor: pointer; + font-size: var(--font-size-sm); + white-space: nowrap; +} + +.wifi-disconnect:hover { + background: var(--color-menu-item-hover); +} + +.wifi-disconnect:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.wifi-list-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-top: var(--spacing-lg); + margin-bottom: var(--spacing-xs); +} + +.wifi-list-title { + font-weight: var(--font-weight-bold); + font-size: var(--font-size-menu); +} + +.wifi-rescan { + display: inline-flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); + background: var(--bg-webview); + color: var(--color-text-secondary); + cursor: pointer; +} + +.wifi-rescan:hover { + background: var(--color-menu-item-hover); + color: var(--color-text-menu); +} + +.wifi-rescan.spinning lucide-icon { + display: inline-block; + animation: wifi-spin 1s linear infinite; +} + +@keyframes wifi-spin { + to { + transform: rotate(360deg); + } +} + +/* Entrance for discrete, user-triggered surfaces (the connected card, the + password form). Rows are deliberately NOT animated: they update from signals + and would flicker. */ +@keyframes wifi-rise { + from { + opacity: 0; + transform: translateY(6px); + } +} + +@keyframes wifi-pulse { + 50% { + opacity: 0.35; + } +} + +.wifi-active { + animation: wifi-rise 0.35s cubic-bezier(0.16, 1, 0.3, 1); +} + +.wifi-connect-form { + animation: wifi-rise 0.22s cubic-bezier(0.16, 1, 0.3, 1); +} + +@media (prefers-reduced-motion: reduce) { + .wifi-rescan.spinning lucide-icon, + .wifi-active, + .wifi-connect-form, + .wifi-connected-pill::before { + animation: none; + } +} + +/* The panel and its list are plain blocks: a long network list grows the + settings content pane, which scrolls as a whole (like every other section). */ +/* The panel and its list are plain blocks: a long network list grows the + settings content pane, which scrolls as a whole (like every other section). */ +wifi-panel { + display: block; +} + +.wifi-network-list { + display: block; +} + +wifi-network { + display: block; + border-bottom: 1px solid var(--color-border); +} + +wifi-network:last-child { + border-bottom: none; +} + +/* The connected card is a ; it carries its own box, so + drop the row separator on that variant. */ +wifi-network[active] { + border-bottom: none; +} + +.wifi-network-main { + display: flex; + align-items: center; + gap: var(--spacing-md); + padding: var(--spacing-sm); + border-radius: var(--radius-sm); + cursor: pointer; + transition: background var(--transition-fast); +} + +.wifi-network-main:hover { + background: var(--color-menu-item-hover); +} + +/* Bold focus ring for keyboard + d-pad navigation (media centers at 3m). */ +.wifi-network-main:focus-visible { + outline: 2px solid var(--color-primary); + outline-offset: -2px; + background: var(--color-menu-item-hover); +} + +.wifi-network-info { + flex: 1; + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; +} + +.wifi-network-name { + font-size: var(--font-size-menu); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +/* Right-aligned per-row detail: band + a lock for secured networks. */ +.wifi-network-detail { + display: inline-flex; + align-items: center; + gap: var(--spacing-sm); + flex-shrink: 0; +} + +.wifi-band { + font-size: var(--font-size-xs); + color: var(--color-text-tertiary); + font-variant-numeric: tabular-nums; +} + +.wifi-lock { + color: var(--color-text-tertiary); + font-size: var(--font-size-menu); + flex-shrink: 0; +} + +.wifi-network-state { + font-size: var(--font-size-sm); + color: var(--color-primary); + white-space: nowrap; + flex-shrink: 0; +} + +/* Inline password entry, revealed under a secured row. */ +.wifi-connect-form { + display: flex; + flex-direction: column; + gap: var(--spacing-sm); + padding: var(--spacing-sm) var(--spacing-sm) var(--spacing-md); +} + +.wifi-connect-actions { + display: flex; + justify-content: flex-end; + gap: var(--spacing-sm); +} + +.wifi-connect-go, +.wifi-connect-cancel { + padding: var(--spacing-sm) var(--spacing-md); + border-radius: var(--radius-sm); + border: 1px solid var(--color-border); + cursor: pointer; + font-size: var(--font-size-sm); +} + +.wifi-connect-cancel { + background: var(--bg-webview); + color: inherit; +} + +.wifi-connect-cancel:hover { + background: var(--color-menu-item-hover); +} + +.wifi-connect-go { + background: var(--color-primary); + color: var(--color-text-on-header); + border-color: var(--color-primary); +} + +.wifi-connect-go:hover { + background: color-mix(in oklch, var(--color-primary), black 12%); +} + +.wifi-connect-error { + color: var(--color-danger); + font-size: var(--font-size-sm); +} + +/* Focus rings on every control, sized for keyboard + d-pad use. */ +.wifi-rescan:focus-visible, +.wifi-disconnect:focus-visible, +.wifi-connect-go:focus-visible, +.wifi-connect-cancel:focus-visible { + outline: 2px solid var(--color-primary); + outline-offset: 2px; +} + +.wifi-empty { + padding: var(--spacing-lg) var(--spacing-md); + text-align: center; + color: var(--color-text-tertiary); + font-size: var(--font-size-sm); +} diff --git a/ui/settings/index.html b/ui/settings/index.html index a69f57f..c2e7395 100644 --- a/ui/settings/index.html +++ b/ui/settings/index.html @@ -38,6 +38,10 @@ Privacy + + + Wi-Fi + P2P @@ -159,6 +163,17 @@ +
+
+ + + Wi-Fi + +
+ +
+
+
diff --git a/ui/settings/index.js b/ui/settings/index.js index 6197618..a371e72 100644 --- a/ui/settings/index.js +++ b/ui/settings/index.js @@ -8,6 +8,10 @@ import { import SearchEngines from "beaver://shared/search/engines.js"; +// Registers the / / custom elements; +// in index.html self-initializes. +import "./wifi.js"; + // Bi-directionial binding between a preference and an element's value. class PreferenceBindings { constructor() { diff --git a/ui/settings/wifi.js b/ui/settings/wifi.js new file mode 100644 index 0000000..0c2c3ed --- /dev/null +++ b/ui/settings/wifi.js @@ -0,0 +1,574 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +// The Wi-Fi settings panel as Lit custom elements over the NetworkManager +// wrapper: +// a four-bar strength meter +// one network: owns its AccessPoint and re-renders itself on +// that AP's strength changes (as a row, or the connected card +// when `active`) +// orchestrates the device and reacts to its events; it holds +// no monitor bookkeeping and never re-renders on strength +// +// All three render into light DOM so they reuse the settings design system +// (.setting-row, .toggle-switch, theme tokens, the styles in index.css). + +import { + LitElement, + html, +} from "beaver://shared/third_party/lit/lit-all.min.js"; +import { + networkManager, + NMDeviceState, +} from "beaver://shared/dbus/network_manager.js"; + +const SCAN_INTERVAL_MS = 20000; +const CONNECT_TIMEOUT_MS = 25000; + +// Map a 0-100 signal strength onto a 0-4 meter level. +function signalLevel(strength) { + if (strength <= 0) return 0; + if (strength < 25) return 1; + if (strength < 50) return 2; + if (strength < 75) return 3; + return 4; +} + +// Wi-Fi band label from the AP frequency in MHz (a detail tinkerers care about). +function band(frequency) { + if (!frequency) return ""; + if (frequency < 2500) return "2.4 GHz"; + if (frequency < 5925) return "5 GHz"; + return "6 GHz"; +} + +// --------------------------------------------------------------------------- +// +// --------------------------------------------------------------------------- + +export class WifiSignal extends LitElement { + static properties = { strength: { type: Number } }; + + constructor() { + super(); + this.strength = 0; + } + + createRenderRoot() { + return this; // light DOM; styled by index.css + } + + render() { + const level = signalLevel(this.strength); + return html`${[1, 2, 3, 4].map( + (n) => html``, + )}`; + } +} +customElements.define("wifi-signal", WifiSignal); + +// --------------------------------------------------------------------------- +// : one network, bound to an AccessPoint via the `.ap` property. +// It subscribes to that AP's `strength-changed` and re-renders only itself, so +// a signal update never re-renders the whole panel. Emits `activate`, +// `connect` ({password}) and `cancel` as a row; `disconnect` when `active`. +// --------------------------------------------------------------------------- + +export class WifiNetwork extends LitElement { + static properties = { + ap: { type: Object }, + active: { type: Boolean }, + connecting: { type: Boolean }, + expanded: { type: Boolean }, + error: { type: String }, + }; + + constructor() { + super(); + this.ap = null; + this.active = false; + this.connecting = false; + this.expanded = false; + this.error = ""; + this._onStrength = () => this.requestUpdate(); + } + + createRenderRoot() { + return this; + } + + willUpdate(changed) { + // Follow whichever AccessPoint we are bound to for live strength. + if (changed.has("ap")) { + changed + .get("ap") + ?.removeEventListener("strength-changed", this._onStrength); + this.ap?.addEventListener("strength-changed", this._onStrength); + } + } + disconnectedCallback() { + super.disconnectedCallback(); + this.ap?.removeEventListener("strength-changed", this._onStrength); + } + + render() { + if (!this.ap) { + return html``; + } + return this.active ? this._renderActive() : this._renderRow(); + } + + _renderActive() { + const ap = this.ap; + const detail = [band(ap.frequency), ap.secured ? ap.security : "Open"] + .filter(Boolean) + .join(" · "); + return html` +
+ +
+
${ap.ssid || "(hidden network)"}
+
+ Connected + ${detail} +
+
+ +
+ `; + } + + _renderRow() { + const ap = this.ap; + return html` +
+ +
+
${ap.ssid}
+
+ ${this._detail()} +
+ ${this.expanded ? this._form() : ""} + `; + } + _detail() { + if (this.connecting) { + return html`Connecting…`; + } + const freq = band(this.ap.frequency); + return html` + + ${freq ? html`${freq}` : ""} + ${ + this.ap.secured + ? html`` + : "" + } + + `; + } + _form() { + return html` +
+ +
+ + +
+ ${ + this.error + ? html`
${this.error}
` + : "" + } +
+ `; + } + + updated(changed) { + if (changed.has("expanded") && this.expanded) { + this.querySelector(".wifi-password")?.focus(); + } + } + _activate() { + this.dispatchEvent(new CustomEvent("activate", { bubbles: true })); + } + _onKeydown(event) { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + this._activate(); + } + } + + _submit() { + const input = this.querySelector(".wifi-password"); + this.dispatchEvent( + new CustomEvent("connect", { + bubbles: true, + detail: { password: input ? input.value : "" }, + }), + ); + } + + _onFormKeydown(event) { + if (event.key === "Enter") { + this._submit(); + } + } + + _cancel() { + this.dispatchEvent(new CustomEvent("cancel", { bubbles: true })); + } + + _disconnect() { + this.dispatchEvent(new CustomEvent("disconnect", { bubbles: true })); + } +} +customElements.define("wifi-network", WifiNetwork); + +// --------------------------------------------------------------------------- +// : the orchestrator. Owns the device lifecycle and reacts to its +// `access-points-changed` / `state-changed` events; the device owns the AP +// cache + monitors, so the panel keeps none. +// --------------------------------------------------------------------------- + +export class WifiPanel extends LitElement { + static properties = { + available: { type: Boolean }, + unavailableMessage: { type: String }, + wifiEnabled: { type: Boolean }, + networks: { type: Array }, + active: { type: Object }, + connectingKey: { type: String }, + expandedKey: { type: String }, + connectError: { type: String }, + scanning: { type: Boolean }, + }; + + constructor() { + super(); + this.available = true; + this.unavailableMessage = ""; + this.wifiEnabled = false; + this.networks = []; + this.active = null; + this.connectingKey = null; + this.expandedKey = null; + this.connectError = null; + this.scanning = false; + + this._device = null; + this._scanTimer = null; + this._connectTimer = null; + this._scanUiTimer = null; + this._details = null; + this._onDetailsToggle = null; + this._onApsChanged = () => this._syncFromDevice(); + this._onState = (event) => this._onDeviceState(event); + } + + createRenderRoot() { + return this; + } + + connectedCallback() { + super.connectedCallback(); + this._init(); + // Only monitor while the accordion section is open. + this._details = this.closest("details"); + if (this._details) { + this._onDetailsToggle = () => { + if (this._details.open && this.wifiEnabled) { + this._start(); + } else { + this._stop(); + } + }; + this._details.addEventListener("toggle", this._onDetailsToggle); + } + } + + disconnectedCallback() { + super.disconnectedCallback(); + if (this._details && this._onDetailsToggle) { + this._details.removeEventListener("toggle", this._onDetailsToggle); + } + this._stop(); + } + + async _init() { + if (!networkManager.available) { + this.available = false; + this.unavailableMessage = + "Wi-Fi controls are unavailable on this device."; + return; + } + try { + this._device = await networkManager.primaryWifiDevice(); + } catch { + this._device = null; + } + if (!this._device) { + this.available = false; + this.unavailableMessage = "No Wi-Fi hardware was found."; + return; + } + + this._device.addEventListener("access-points-changed", this._onApsChanged); + this._device.addEventListener("state-changed", this._onState); + + try { + this.wifiEnabled = await networkManager.wirelessEnabled(); + } catch { + this.wifiEnabled = false; + } + + if ((!this._details || this._details.open) && this.wifiEnabled) { + this._start(); + } + } + + // Pull the current view out of the device (called on access-points-changed). + _syncFromDevice() { + if (!this._device) { + return; + } + this.active = this._device.activeAccessPoint; + this.networks = this._device.visibleNetworks(); + if (this.networks.length > 0) { + this.scanning = false; // results are in + } + } + + // ---- rendering ---------------------------------------------------------- + + render() { + if (!this.available) { + return html` +
+ + ${this.unavailableMessage} +
+ `; + } + return html` +
+
+ + + Scan for and join wireless networks + +
+ +
+ ${this.wifiEnabled ? this._renderBody() : ""} + `; + } + + _renderBody() { + return html` + ${ + this.active + ? html`` + : "" + } +
+ Networks + +
+
+ ${ + this.networks.length === 0 + ? html`
+ ${ + this.scanning + ? "Searching for networks…" + : "No networks found. Make sure a network is in range." + } +
` + : this.networks.map((ap) => this._renderRow(ap)) + } +
+ `; + } + + _renderRow(ap) { + const expanded = this.expandedKey === ap.ssid; + return html` + this._onActivate(ap)} + @connect=${(event) => this._connect(ap, event.detail.password)} + @cancel=${this._collapse} + > + `; + } + + // ---- actions ------------------------------------------------------------ + + _onActivate(ap) { + if (this.connectingKey) { + return; // a join is already in flight + } + this.connectError = null; + if (ap.secured) { + this.expandedKey = this.expandedKey === ap.ssid ? null : ap.ssid; + } else { + this._connect(ap, ""); + } + } + + _collapse() { + this.expandedKey = null; + this.connectError = null; + } + + async _connect(ap, password) { + this.connectingKey = ap.ssid; + this.expandedKey = null; + this.connectError = null; + try { + await this._device.connect(ap, password); + // Success/failure arrives as a device state-changed event; guard against + // a silent stall. + clearTimeout(this._connectTimer); + this._connectTimer = setTimeout(() => { + if (this.connectingKey === ap.ssid) { + this._fail(ap.ssid, "Connection timed out."); + } + }, CONNECT_TIMEOUT_MS); + } catch (error) { + this._fail(ap.ssid, `Could not connect: ${error.message || error}`); + } + } + + _fail(ssid, message) { + clearTimeout(this._connectTimer); + this.connectingKey = null; + this.expandedKey = ssid; + this.connectError = message; + } + + _disconnect() { + // The active card + list update via the device's state-changed -> + // access-points-changed; nothing to refresh here. + this._device?.disconnect().catch((error) => { + console.error("[wifi] disconnect failed:", error); + }); + } + + _onDeviceState(event) { + // Connect-result feedback only; active/networks refresh via + // access-points-changed (the device emits both on a state change). + const { state } = event.detail; + if (state === NMDeviceState.ACTIVATED) { + clearTimeout(this._connectTimer); + this.connectingKey = null; + this.connectError = null; + } else if (state === NMDeviceState.FAILED && this.connectingKey) { + this._fail( + this.connectingKey, + "Could not connect. Check the password and try again.", + ); + } + } + + async _toggleWifi(event) { + const on = event.target.checked; + try { + await networkManager.setWirelessEnabled(on); + this.wifiEnabled = on; + } catch (error) { + console.error("[wifi] toggling Wi-Fi failed:", error); + event.target.checked = !on; + return; + } + if (on) { + // Give the radio a moment to come up before the first scan. + setTimeout(() => this._start(), 500); + } else { + this._stop(); + this.active = null; + this.networks = []; + } + } + + _rescan() { + this._beginScan(); + } + + // Kick off a scan and show the searching state; results arrive via signals, + // so the state clears when they land (or after a short ceiling). + _beginScan() { + if (!this._device) { + return; + } + this.scanning = true; + clearTimeout(this._scanUiTimer); + this._scanUiTimer = setTimeout(() => { + this.scanning = false; + }, 3500); + this._device.scan().catch(() => {}); + } + + // ---- lifecycle ---------------------------------------------------------- + + _start() { + if (!this._device || this._scanTimer) { + return; + } + // startMonitoring builds the AP cache and emits access-points-changed, + // which drives _syncFromDevice. Membership + per-AP strength then update + // from signals; we only nudge NM to rescan periodically. + this._device.startMonitoring(); + this._beginScan(); + this._scanTimer = setInterval( + () => this._device.scan().catch(() => {}), + SCAN_INTERVAL_MS, + ); + } + + _stop() { + clearInterval(this._scanTimer); + this._scanTimer = null; + clearTimeout(this._scanUiTimer); + this.scanning = false; + this._device?.stopMonitoring(); + } +} +customElements.define("wifi-panel", WifiPanel); diff --git a/ui/shared/dbus/network_manager.js b/ui/shared/dbus/network_manager.js new file mode 100644 index 0000000..004b7e0 --- /dev/null +++ b/ui/shared/dbus/network_manager.js @@ -0,0 +1,841 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +/* + * A class-based NetworkManager WiFi API built on top of the DOM D-Bus bridge + * (`navigator.embedder.dbus`, see source/components/script/dom/dbus.rs). + * + * Three entry points, all extending EventTarget so callers can `addEventListener`: + * - NetworkManager : the system-wide singleton (state, wireless toggle, devices). + * - WifiDevice : one wireless interface (its access points, live add/remove). + * - AccessPoint : one visible network (ssid, strength, security, live strength). + * + * Bridge constraints that shape this design (verified against dbus.rs + + * dbus_service.rs): + * - `call`/`getProperty` resolve with a JSON *string*; we JSON.parse it. + * - D-Bus types map to JSON as: object path -> string, `ao` -> string[], + * the SSID `ay` -> number[] (bytes, decoded here), `a{sv}` -> object, + * variants unwrapped, numbers/bools as-is. + * - Outbound args are inferred from JS value types (bool/int/double/string) + * UNLESS a D-Bus `signature` is supplied on the call, in which case + * container args (object paths `o`, dicts `a{sv}`, byte arrays `ay`) are + * passed as JSON strings and expanded per the signature. connect() and + * scan() rely on this to send the connection profile and object paths. + * - Signals dispatch on the shared `dbus` EventTarget as an event named after + * 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. + */ + +// --------------------------------------------------------------------------- +// D-Bus names +// --------------------------------------------------------------------------- + +const NM_SERVICE = "org.freedesktop.NetworkManager"; +const NM_PATH = "/org/freedesktop/NetworkManager"; +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) +// --------------------------------------------------------------------------- + +/** Overall NetworkManager state (NMState). */ +export const NMState = { + UNKNOWN: 0, + ASLEEP: 10, + DISCONNECTED: 20, + DISCONNECTING: 30, + CONNECTING: 40, + CONNECTED_LOCAL: 50, + CONNECTED_SITE: 60, + CONNECTED_GLOBAL: 70, +}; + +/** Result of CheckConnectivity (NMConnectivityState). */ +export const NMConnectivity = { + UNKNOWN: 0, + NONE: 1, + PORTAL: 2, + LIMITED: 3, + FULL: 4, +}; + +/** Device kinds (NMDeviceType); WIFI is the one this module cares about. */ +export const NMDeviceType = { + UNKNOWN: 0, + ETHERNET: 1, + WIFI: 2, + MODEM: 8, + BT: 5, + BRIDGE: 13, + TUN: 16, + WIREGUARD: 29, +}; + +/** Per-device state (NMDeviceState). */ +export const NMDeviceState = { + UNKNOWN: 0, + UNMANAGED: 10, + UNAVAILABLE: 20, + DISCONNECTED: 30, + PREPARE: 40, + CONFIG: 50, + NEED_AUTH: 60, + IP_CONFIG: 70, + IP_CHECK: 80, + SECONDARIES: 90, + ACTIVATED: 100, + DEACTIVATING: 110, + FAILED: 120, +}; + +// 802.11 access-point flag bits used to summarize security. +const AP_FLAG_PRIVACY = 0x1; // NM80211ApFlags.PRIVACY (WEP) +const SEC_KEY_MGMT_802_1X = 0x200; // NM80211ApSecurityFlags +const SEC_KEY_MGMT_PSK = 0x100; +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); + +/** + * What this module can do on the current bridge. `connect` and `scan` need + * object-path / dict argument marshalling the DOM D-Bus API does not have yet. + */ +export const CAPABILITIES = Object.freeze({ + listAccessPoints: true, + liveUpdates: true, + monitorStrength: true, + wirelessToggle: true, + disconnect: true, + connect: true, + scan: true, +}); + +// --------------------------------------------------------------------------- +// Bridge access + typed call helpers (module-private) +// --------------------------------------------------------------------------- + +/** 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); +} + +/** 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, + destination: NM_SERVICE, + path, + interfaceName: 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); +} + +/** Write one primitive property value. */ +function setProp(path, iface, property, value) { + return bus().setProperty( + { + bus: SYSTEM, + destination: NM_SERVICE, + path, + interfaceName: 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])) || {}; +} + +// The SSID comes over the wire as `ay` (a byte array of numbers), not a string. +const ssidDecoder = new TextDecoder("utf-8", { fatal: false }); +function decodeSsid(bytes) { + if (Array.isArray(bytes)) { + return ssidDecoder.decode(Uint8Array.from(bytes)); + } + return typeof bytes === "string" ? bytes : ""; +} + +// A D-Bus variant wrapping the SSID as its real `ay` byte-array type. The Rust +// marshaller reads the {$sig,$val} tag to type the variant's inner value. +function ssidVariant(ssid) { + return { $sig: "ay", $val: [...new TextEncoder().encode(ssid)] }; +} + +// A minimal NM connection profile (a{sa{sv}}) for `ssid`. For secured networks +// `security` picks the key management ("WPA3" -> SAE, otherwise WPA/WPA2 PSK). +function buildConnectionProfile(ssid, password, security) { + const profile = { + connection: { type: "802-11-wireless", id: ssid }, + "802-11-wireless": { ssid: ssidVariant(ssid), mode: "infrastructure" }, + }; + if (security && security !== "open") { + const wirelessSecurity = { + "key-mgmt": security === "WPA3" ? "sae" : "wpa-psk", + }; + if (password) { + wirelessSecurity.psk = password; + } + profile["802-11-wireless-security"] = wirelessSecurity; + } + 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. + */ + 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 || {}; + 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("[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, + }), + }; + sub.idPromise.catch(() => {}); // no unhandled rejection if subscribe fails + this._subs.set(key, sub); + } + sub.count += 1; + + let active = true; + return () => { + 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(() => {}); + } + }; + } +} + +const hub = new SignalHub(); + +// --------------------------------------------------------------------------- +// AccessPoint +// --------------------------------------------------------------------------- + +/** + * A single visible access point. Property getters read a cached snapshot of the + * AP's D-Bus properties; call `refresh()` or `startMonitoring()` to keep it live. + * + * Events: "changed" (any property update), "strength-changed" (signal strength). + */ +export class AccessPoint extends EventTarget { + constructor(path, props) { + super(); + this._path = path; + this._props = props || {}; + this._subs = null; + } + + /** Build an AccessPoint and populate it from D-Bus in one round trip. */ + static async load(path) { + return new AccessPoint(path, await getAllProps(path, AP_IFACE)); + } + + /** Keep the strongest AP per SSID (what a WiFi picker usually wants). */ + static strongestPerSsid(list) { + const best = new Map(); + for (const ap of list) { + const key = ap.ssid || ap.bssid; + const seen = best.get(key); + if (!seen || ap.strength > seen.strength) { + best.set(key, ap); + } + } + return [...best.values()]; + } + + get path() { + return this._path; + } + get ssidBytes() { + return this._props.Ssid || []; + } + get ssid() { + return decodeSsid(this._props.Ssid); + } + /** Signal strength, 0-100. */ + get strength() { + return this._props.Strength ?? 0; + } + /** Radio frequency in MHz. */ + get frequency() { + return this._props.Frequency ?? 0; + } + get bssid() { + return this._props.HwAddress || ""; + } + get mode() { + return this._props.Mode ?? 0; + } + get maxBitrate() { + return this._props.MaxBitrate ?? 0; + } + get flags() { + return this._props.Flags ?? 0; + } + get wpaFlags() { + return this._props.WpaFlags ?? 0; + } + get rsnFlags() { + return this._props.RsnFlags ?? 0; + } + + /** Short security label derived from the flag triple. */ + get security() { + const rsn = this.rsnFlags; + const wpa = this.wpaFlags; + if (rsn & SEC_KEY_MGMT_SAE) { + return "WPA3"; + } + if (rsn & (SEC_KEY_MGMT_OWE | SEC_KEY_MGMT_OWE_TM)) { + return "OWE"; + } + if (rsn & SEC_KEY_MGMT_802_1X || wpa & SEC_KEY_MGMT_802_1X) { + return "802.1X"; + } + if (rsn & SEC_KEY_MGMT_PSK) { + return "WPA2"; + } + if (wpa) { + return "WPA"; + } + if (this.flags & AP_FLAG_PRIVACY) { + return "WEP"; + } + return "open"; + } + + get secured() { + return this.security !== "open"; + } + + /** Re-read all properties; emits "changed" (and "strength-changed" if moved). */ + async refresh() { + const prev = this.strength; + this._props = await getAllProps(this._path, AP_IFACE); + if (this.strength !== prev) { + this._emitStrength(); + } + this.dispatchEvent( + new CustomEvent("changed", { detail: { accessPoint: this } }), + ); + return this; + } + + /** Watch this AP's properties live (strength, etc.). Idempotent. */ + startMonitoring() { + if (this._subs) { + return this; + } + this._subs = [ + // Standard: PropertiesChanged(s interface, a{sv} changed, as invalidated). + hub.on(SYSTEM, PROPS_IFACE, "PropertiesChanged", this._path, (detail) => { + const [iface, changed] = detail.args || []; + if (iface === AP_IFACE) { + this._merge(changed); + } + }), + // Legacy NM: AccessPoint.PropertiesChanged(a{sv} changed). + hub.on(SYSTEM, AP_IFACE, "PropertiesChanged", this._path, (detail) => { + this._merge(detail.args?.[0]); + }), + ]; + return this; + } + + stopMonitoring() { + if (this._subs) { + this._subs.forEach((off) => off()); + this._subs = null; + } + return this; + } + + /** Plain snapshot, handy for logging or handing to a template. */ + toJSON() { + return { + path: this._path, + ssid: this.ssid, + strength: this.strength, + frequency: this.frequency, + bssid: this.bssid, + security: this.security, + secured: this.secured, + }; + } + + _merge(changed) { + if (!changed || typeof changed !== "object") { + return; + } + const prev = this.strength; + Object.assign(this._props, changed); + if ("Strength" in changed && this.strength !== prev) { + this._emitStrength(); + } + this.dispatchEvent( + new CustomEvent("changed", { detail: { accessPoint: this, changed } }), + ); + } + + _emitStrength() { + this.dispatchEvent( + new CustomEvent("strength-changed", { + detail: { strength: this.strength }, + }), + ); + } +} + +// --------------------------------------------------------------------------- +// WifiDevice +// --------------------------------------------------------------------------- + +/** + * One wireless interface. Lists its access points and, while monitoring, emits + * "ap-added" / "ap-removed" / "state-changed". + */ +export class WifiDevice extends EventTarget { + constructor(path) { + super(); + this._path = path; + this._subs = null; // device-level signal subscriptions + this._aps = new Map(); // object path -> live AccessPoint (while monitoring) + this._activePath = null; // object path of the active AP, or null + } + + get path() { + return this._path; + } + + /** Kernel interface name, e.g. "wlan0". */ + interfaceName() { + return getProp(this._path, DEVICE_IFACE, "Interface"); + } + + /** Current NMDeviceState value. */ + state() { + return getProp(this._path, DEVICE_IFACE, "State"); + } + + /** + * The live access points, kept current by signals while monitoring. Each is a + * stable, self-monitoring AccessPoint (so its strength updates in place, and + * repeat refreshes reuse the same instance rather than re-fetching). Empty + * until startMonitoring(). + */ + get accessPoints() { + return [...this._aps.values()]; + } + + /** The AP we are associated with, taken from the live cache (or null). */ + get activeAccessPoint() { + return this._activePath ? this._aps.get(this._activePath) || null : null; + } + + /** + * What a picker renders: strongest AP per SSID, excluding the active one and + * hidden (empty-SSID) networks, strongest first. Recompute on + * `access-points-changed`. + */ + visibleNetworks() { + return AccessPoint.strongestPerSsid(this.accessPoints) + .filter((ap) => ap.ssid && ap.path !== this._activePath) + .sort((a, b) => b.strength - a.strength); + } + + /** Disconnect this device (Device.Disconnect, no arguments). */ + disconnect() { + return callMethod(this._path, DEVICE_IFACE, "Disconnect"); + } + + /** + * Associate with `target` (an AccessPoint or an SSID string), optionally with + * `password`. `options.security` overrides the detected security ("open", + * "WPA2", "WPA3", ...); by default it is taken from the AccessPoint. + * + * Calls AddAndActivateConnection(a{sa{sv}} connection, o device, o ap): the + * connection profile is marshalled from a JSON string via the call signature. + * + * @returns {Promise<{connectionPath: string, activeConnectionPath: string}>} + */ + async connect(target, password, options = {}) { + const ap = target instanceof AccessPoint ? target : null; + const ssid = ap ? ap.ssid : String(target); + const apPath = ap ? ap.path : "/"; + const security = + options.security ?? (ap ? ap.security : password ? "WPA2" : "open"); + const profile = buildConnectionProfile(ssid, password, security); + const result = await callMethod( + NM_PATH, + NM_IFACE, + "AddAndActivateConnection", + [JSON.stringify(profile), this._path, apPath], + "a{sa{sv}}oo", + ); + const [connectionPath, activeConnectionPath] = result || []; + return { connectionPath, activeConnectionPath }; + } + + /** Ask NetworkManager to rescan (RequestScan with empty options). */ + scan() { + return callMethod( + this._path, + WIRELESS_IFACE, + "RequestScan", + [JSON.stringify({})], + "a{sv}", + ); + } + + /** + * Build the live AP cache and keep it current from D-Bus signals. Emits + * `access-points-changed` when the set or the active AP changes, and + * `state-changed` {state, oldState, reason} on device state transitions. + * Consumers listen to an individual AccessPoint for `strength-changed` + * rather than re-reading the whole list. Idempotent. + */ + async startMonitoring() { + if (this._subs) { + return this; + } + this._subs = [ + hub.on(SYSTEM, WIRELESS_IFACE, "AccessPointAdded", this._path, (detail) => + this._onApAdded(detail.args?.[0]), + ), + hub.on( + SYSTEM, + WIRELESS_IFACE, + "AccessPointRemoved", + this._path, + (detail) => this._onApRemoved(detail.args?.[0]), + ), + hub.on(SYSTEM, DEVICE_IFACE, "StateChanged", this._path, (detail) => + this._onStateChanged(detail.args || []), + ), + ]; + await this._syncAccessPoints(); + await this._resolveActive(); + this.dispatchEvent(new CustomEvent("access-points-changed")); + return this; + } + + stopMonitoring() { + if (this._subs) { + this._subs.forEach((off) => off()); + this._subs = null; + } + for (const ap of this._aps.values()) { + ap.stopMonitoring(); + } + this._aps.clear(); + this._activePath = null; + return this; + } + + // ---- live cache maintenance (private) ----------------------------------- + + // Reconcile the cache with the device's current AccessPoints: monitor newly + // seen paths, drop vanished ones, keep the rest untouched (no re-fetch). + async _syncAccessPoints() { + let paths = []; + try { + paths = (await getProp(this._path, WIRELESS_IFACE, "AccessPoints")) || []; + } catch { + paths = []; + } + const wanted = new Set(paths); + for (const [path, ap] of this._aps) { + if (!wanted.has(path)) { + ap.stopMonitoring(); + this._aps.delete(path); + } + } + await Promise.all(paths.map((path) => this._ensureCached(path))); + } + + // Return the cached AccessPoint for `path`, loading + monitoring it once. + async _ensureCached(path) { + if (!path || path === "/") { + return null; + } + const existing = this._aps.get(path); + if (existing) { + return existing; + } + const ap = await AccessPoint.load(path).catch(() => new AccessPoint(path)); + const won = this._aps.get(path); + if (won) { + return won; // another call cached it while we were loading + } + ap.startMonitoring(); + this._aps.set(path, ap); + return ap; + } + + async _onApAdded(path) { + if (!path || this._aps.has(path)) { + return; + } + await this._ensureCached(path); + this.dispatchEvent(new CustomEvent("access-points-changed")); + } + + _onApRemoved(path) { + const ap = path && this._aps.get(path); + if (!ap) { + return; + } + ap.stopMonitoring(); + this._aps.delete(path); + if (this._activePath === path) { + this._activePath = null; + } + this.dispatchEvent(new CustomEvent("access-points-changed")); + } + + async _onStateChanged(args) { + const [state, oldState, reason] = args; + await this._resolveActive(); + this.dispatchEvent(new CustomEvent("access-points-changed")); + this.dispatchEvent( + new CustomEvent("state-changed", { detail: { state, oldState, reason } }), + ); + } + + async _resolveActive() { + let path = null; + try { + path = await getProp(this._path, WIRELESS_IFACE, "ActiveAccessPoint"); + } catch { + path = null; + } + if (!path || path === "/") { + this._activePath = null; + return; + } + await this._ensureCached(path); + this._activePath = path; + } +} + +// --------------------------------------------------------------------------- +// NetworkManager (system-wide entry point) +// --------------------------------------------------------------------------- + +/** + * The NetworkManager singleton. While monitoring, emits "state-changed", + * "device-added" and "device-removed". + */ +export class NetworkManager extends EventTarget { + constructor() { + super(); + this._subs = null; + } + + /** True when this page can reach the D-Bus bridge at all. */ + get available() { + return !!globalThis.navigator?.embedder?.dbus; + } + + /** Overall NMState value. */ + state() { + return getProp(NM_PATH, NM_IFACE, "State"); + } + + /** Probe connectivity now (NMConnectivity value). */ + connectivity() { + return callMethod(NM_PATH, NM_IFACE, "CheckConnectivity"); + } + + wirelessEnabled() { + return getProp(NM_PATH, NM_IFACE, "WirelessEnabled"); + } + + setWirelessEnabled(enabled) { + return setProp(NM_PATH, NM_IFACE, "WirelessEnabled", !!enabled); + } + + /** All device object paths. */ + devicePaths() { + return callMethod(NM_PATH, NM_IFACE, "GetDevices").then( + (paths) => paths || [], + ); + } + + /** WifiDevice wrappers for every wireless interface. */ + async wifiDevices() { + const paths = await this.devicePaths(); + const types = await Promise.all( + paths.map((path) => + getProp(path, DEVICE_IFACE, "DeviceType").catch(() => 0), + ), + ); + return paths + .filter((_, i) => types[i] === NMDeviceType.WIFI) + .map((path) => new WifiDevice(path)); + } + + /** The first wireless device, or null when there is none. */ + async primaryWifiDevice() { + return (await this.wifiDevices())[0] || null; + } + + /** Emit live NM state and device add/remove. Idempotent. */ + startMonitoring() { + if (this._subs) { + return this; + } + this._subs = [ + hub.on(SYSTEM, NM_IFACE, "StateChanged", NM_PATH, (detail) => { + this.dispatchEvent( + new CustomEvent("state-changed", { + detail: { state: detail.args?.[0] }, + }), + ); + }), + hub.on(SYSTEM, NM_IFACE, "DeviceAdded", "", (detail) => { + this.dispatchEvent( + new CustomEvent("device-added", { + detail: { path: detail.args?.[0] }, + }), + ); + }), + hub.on(SYSTEM, NM_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; NM is a system singleton and subscriptions are global. */ +export const networkManager = new NetworkManager();