web browser (Servo)
git clone https://git.lucas.co/cce-browser.git
feat: account autocomplete from cce-secrets on login forms
A login field now gets a list of the accounts the keyring holds for that
site; picking one fills the username and password. Same store cce-secrets
fronts — the freedesktop Secret Service — read the same way: item label as
title, UserName and URL as attributes. Typing filters, arrows and Enter pick,
Escape dismisses, and the list follows its field as the page scrolls.
`browser.accounts` (default true) is the single switch: off, nothing is
injected and the keyring is never opened.
The security shape is the design. Both halves run in a private script world,
so the page can neither replace the watcher's helpers nor post on the
chrome's channel; injection is top-frame only and every event's origin is
checked against the tab's own host. Matching is exact-host or parent-domain,
never upward or sideways, with a title fallback only for entries that have no
URL at all. No password is fetched to build a list — the pick fetches one
secret by object path — and accounts::Secret prints as Secret(…) so Message's
derived Debug cannot spill it. The fill re-checks on arrival that the list is
still open, still holds that account, and the tab is still on the same host,
and drops the credential otherwise. Nothing fills without a pick, nothing
submits, and a locked collection is skipped rather than unlocked. The list
says so on a non-https, non-loopback page.
Three findings worth the comments they carry: the engine's dirty flag is not
a navigation (clearing on it closed the list in the pump that opened it, so it
keys on the URL changing and events are drained after); a field focused before
the index loads needs the watcher nudged to re-report, or the first login form
of a session gets nothing; and the fill's own input/change events came back as
typing until the watcher learned to suppress them.
Verified against an isolated dbus + gnome-keyring fixture, never the real
store: list at the field, flip above when there is no room below, filter,
arrows, Enter, click, Escape, password-field focus, the fill reaching both
fields through the native setter, and the off-switch injecting nothing. 29
tests pass (9 new, covering host matching, credential escaping, event parsing
and the insecure-origin rule).
Co-Authored-By: Claude Opus 5 <[email protected]>
CLAUDE.md | 84 +++++++-
Cargo.toml | 12 ++
build.rs | 7 +
src/accounts.rs | 387 +++++++++++++++++++++++++++++++++++++
src/main.rs | 531 ++++++++++++++++++++++++++++++++++++++++++++++++++-
src/settings.rs | 6 +
src/wpe/formwatch.rs | 282 +++++++++++++++++++++++++++
src/wpe/host.rs | 163 ++++++++++++++++
src/wpe/mod.rs | 4 +
9 files changed, 1472 insertions(+), 4 deletions(-)
diff --git a/CLAUDE.md b/CLAUDE.md
index b6eb8c5..08aae92 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -25,6 +25,8 @@ Eight files, ~3k lines:
| `src/downloads.rs` | the chrome-side download pipeline (Servo has none) |
| `src/session.rs` | open-tab persistence: the tab set survives a restart |
| `src/settings.rs` | the per-app KDL config |
+| `src/accounts.rs` | accounts from cce-secrets: the Secret Service worker, and which entries a host earns |
+| `src/wpe/formwatch.rs` | the page half of account autocomplete: the watcher script, the fill script, and the events between them |
## Build
@@ -294,6 +296,80 @@ Wheel events pass **winit-signed deltas** (positive = up) with no separate scrol
event: Servo hit-tests the wheel, gives the page its `preventDefault` chance, and
applies the inverted delta itself.
+## Account autocomplete (cce-secrets)
+
+A login field on a page gets a list of the accounts the keyring holds for that
+site; picking one fills the username and password. There is no cce-secrets
+*protocol* — that app fronts the freedesktop **Secret Service** (gnome-keyring
+here) and so does this, reading the same entries: item label as the title,
+`UserName` and `URL` as attributes. `browser.accounts` (default true) is the
+one switch; with it off nothing is injected and the keyring is never opened.
+
+Three files meet: `accounts.rs` (which entries a host earns, and the worker
+that reads them), `wpe/formwatch.rs` (the page half), and `AcMenu` in
+`main.rs` (the list itself, drawn at the field like every other menu here).
+WPE only — the retired Servo backend has no user-script hooks — so the chrome
+side is `#[cfg(feature = "wpe")]`, while `accounts.rs` is not.
+
+The security shape is the design, not decoration:
+
+- **Everything runs in a private script world** (`formwatch::WORLD`). The page
+ cannot see or replace the watcher's helpers, so it cannot hook the moment a
+ credential is filled, and it cannot post on the chrome's message channel to
+ fake a focused field.
+- **Top frame only.** A password field in a cross-origin iframe gets no
+ suggestions: such a frame cannot report a position in the top document's
+ coordinates anyway, and an embedded frame asking for the embedder's
+ credentials is the attack this must not enable. The reported `location.origin`
+ is checked against the tab's own host on every event, on top of that.
+- **Matching is narrow** (`Account::matches`): exact host, or a *parent* domain
+ covering its subdomains — never upward, never sideways. An entry with no URL
+ falls back to its title against the site name (`GitHub` → `github.com`), the
+ one guess in here, made only when there is nothing better.
+- **No password is fetched to build a list.** Listing reads labels, usernames
+ and URLs; the pick is what asks the keyring for one secret, by object path.
+ `accounts::Secret` prints as `Secret(…)` so a derived `Debug` on `Message`
+ cannot spill it into a log.
+- **The fill is re-checked when it lands.** An unlock prompt can put seconds
+ between the pick and the answer, so `fill_account` drops the credential
+ unless the list is still open, still holds that account, and the tab is
+ still on the host it was opened for.
+- **Never automatic.** Nothing fills without a pick, nothing submits the form,
+ and a locked collection is skipped rather than unlocked — the browser asking
+ for the keyring password because a page happened to show a login field would
+ be its own phishing lesson. cce-secrets is where unlocking belongs.
+- The list says so when the page is not https and not loopback
+ (`insecure_origin`): the password would cross the network in the clear, and
+ only the person can decide that is fine.
+
+Things that were learned the hard way and are easy to undo:
+
+- **The keyring is read on the first login field, never at launch.** A browser
+ that never sees one never opens the store, which is what keeps this from
+ costing an unlock prompt at login.
+- **A field can be focused before the index has finished loading** — it always
+ is, on a page that autofocuses. The chrome answers the load by asking the
+ watcher to re-report (`request_form_state` → `RESCAN_JS`); without that
+ nudge the first login form of a session silently gets nothing.
+- **The engine's dirty flag is not a navigation.** Clearing the list on
+ `dirty` closed it in the same pump that opened it (title and loading
+ transitions set it too). It is keyed on the tab's URL actually changing
+ (`nav_url`), and form events are drained *after* that check so an event
+ arriving with the load survives it.
+- **A fill must not report itself.** The `input` and `change` events the fill
+ dispatches — which are the point, since frameworks ignore a plain assignment
+ — came back as "the user typed" and re-opened the list, filtered by the name
+ just filled in. The watcher holds a `filling` flag across the fill.
+- **CSS pixels are the chrome's logical pixels.** `resize` hands WPE the
+ *logical* size and sets the scale separately, so a viewport rect from the
+ page needs no conversion at any output scale (verified at scale 2).
+
+Testing it needs an isolated keyring, never the real one: `dbus-run-session`
+plus `gnome-keyring-daemon --unlock --components=secrets`, seeded with
+`secret-tool`, and the browser launched into that bus with
+`DBUS_SESSION_BUS_ADDRESS`. A `file:` page will not do — its origin is `null`,
+so serve the fixture over http on localhost.
+
## `cce://` pages
`CceProtocol` registers the `cce` scheme with Servo's `ProtocolRegistry`, so
@@ -391,9 +467,11 @@ clipboard path as the rest of the DE.
## Not implemented yet
-Worth knowing before assuming a bug: no find-in-page, no zoom, no context menu, no
-favicons, no history/URL autocomplete, and no delegate hooks for JS dialogs
-(`alert`/`confirm`), permission prompts, or HTTP auth. Ctrl+Shift+O ("hand this page to
+Worth knowing before assuming a bug: no find-in-page, no zoom, no favicons, and no
+history/URL autocomplete. (The context menu, JS dialogs and HTTP auth landed with the
+WPE backend and are Servo-only gaps now.) Account autocomplete does not *save* a new
+login — cce-secrets is where entries are written — and it does not fill inside
+cross-origin iframes. Ctrl+Shift+O ("hand this page to
another browser") is the deliberate escape hatch for pages Servo cannot follow, such as
a Cloudflare challenge that never completes.
diff --git a/Cargo.toml b/Cargo.toml
index 060e5dd..1a66601 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -31,6 +31,18 @@ euclid = { version = "0.22", optional = true }
rustls = { version = "0.23", features = ["aws-lc-rs"], optional = true }
log = "0.4"
env_logger = "0.11"
+# Accounts from cce-secrets: the same Secret Service store cce-secrets
+# fronts (gnome-keyring here), read over D-Bus. The *blocking* API on a
+# worker thread, deliberately — an unlock prompt can block for as long as it
+# takes and must never be on the frame path — so no async runtime is pulled
+# into the browser: async-io, not tokio.
+secret-service = { version = "5", features = ["rt-async-io-crypto-rust"] }
+# The account watcher's page messages are JSON, and cce-ui parses config
+# through serde_json already — this only names the parser directly.
+serde_json = "1"
+# Naming a Secret Service object path (the handle a picked account's secret is
+# fetched by). Already in the tree under secret-service; this only names it.
+zbus = "5"
# Only used by the `wpe` backend, to hold GLib's changing pollfd set in one
# epoll fd that calloop can watch. Optional so a default build skips it.
rustix = { version = "0.38", features = ["event"], optional = true }
diff --git a/build.rs b/build.rs
index 7ffab51..e70e069 100644
--- a/build.rs
+++ b/build.rs
@@ -29,7 +29,14 @@ fn main() {
// The engine, the embedding layer, and just enough GObject to
// register subclasses and turn a main loop.
.allowlist_item("(wpe|WPE|webkit|WebKit)_?.*")
+ // JavaScriptCore: a script message from the page arrives as a
+ // JSCValue, so reading one needs `jsc_value_*`. webkit.h already
+ // pulls in jsc.h; only the allowlist kept these out.
+ .allowlist_item("(jsc|JSC)_?.*")
.allowlist_item("g_(object|type|signal|bytes|timeout|free|error)_.*")
+ // `g_free` itself, with no trailing word, misses the pattern above —
+ // and a JSC string comes back owned, so it is needed to hand it back.
+ .allowlist_function("g_free")
// The main loop AND the context: `pump` drains the context directly.
.allowlist_item("g_main_(loop|context)_.*")
.allowlist_item("G(Object|Type|Value|Bytes|Error|MainLoop|ParamSpec|Closure).*")
diff --git a/src/accounts.rs b/src/accounts.rs
new file mode 100644
index 0000000..223e93e
--- /dev/null
+++ b/src/accounts.rs
@@ -0,0 +1,387 @@
+//! Accounts from cce-secrets — the login suggestions the URL of a page earns.
+//!
+//! There is no cce-secrets *protocol*: that app fronts the freedesktop
+//! **Secret Service** (gnome-keyring on this machine), and so does this. The
+//! entry shape is the one cce-secrets writes and KeePassXC maps onto its own
+//! fields: the item label is the title, and `UserName` / `URL` are ordinary
+//! attributes beside it. Read the sibling crate's `CLAUDE.md` before changing
+//! the attribute names here — both ends have to agree.
+//!
+//! Two rules shape everything below.
+//!
+//! **Secrets are fetched one at a time, at the moment of a pick.** Listing
+//! reads labels, usernames and URLs only; no password is fetched to build a
+//! menu, and none is held afterwards. [`Secret`] exists so that a password
+//! cannot reach a log through a derived `Debug`.
+//!
+//! **The keyring is never touched on the frame path.** A locked collection
+//! prompts, and a prompt blocks for as long as the person takes to answer it,
+//! so all of it runs on a worker thread that talks back through the app's
+//! calloop channel. That is also why this uses the *blocking* Secret Service
+//! API: on its own thread, blocking is the simple correct thing, and it keeps
+//! an async runtime out of the browser.
+
+use std::sync::mpsc;
+
+use crate::Message;
+
+/// Attribute names to read a username from, in order of preference. cce-secrets
+/// writes `UserName`; entries born elsewhere in the keyring use lowercase.
+const USER_KEYS: [&str; 3] = ["UserName", "username", "user"];
+/// Same, for the entry's site.
+const URL_KEYS: [&str; 3] = ["URL", "url", "uri"];
+
+/// A password on its way from the keyring to one page field.
+///
+/// The wrapper is the point: `Message` derives `Debug`, and a plain `String`
+/// in it would put a live password into any log line that ever formats a
+/// message. This one prints as `Secret(…)` and hands over its contents only
+/// to a caller that asks for them by name.
+#[derive(Clone, PartialEq)]
+pub struct Secret(String);
+
+impl Secret {
+ pub fn expose(&self) -> &str {
+ &self.0
+ }
+}
+
+impl std::fmt::Debug for Secret {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ f.write_str("Secret(…)")
+ }
+}
+
+/// One keyring entry as the chrome lists it — never its secret.
+#[derive(Clone, Debug, PartialEq)]
+pub struct Account {
+ /// Secret Service object path: the handle the secret is fetched by when
+ /// this account is picked.
+ pub path: String,
+ /// The entry's title.
+ pub label: String,
+ pub username: String,
+ /// The `URL` attribute as stored, empty when the entry has none.
+ pub url: String,
+}
+
+impl Account {
+ /// The host this entry claims, if any. Entries are written by people and
+ /// by importers, so the field holds anything from a full URL to a bare
+ /// domain; both have to work.
+ pub fn host(&self) -> Option<String> {
+ entry_host(&self.url)
+ }
+
+ /// Whether this entry is worth offering on `host`.
+ ///
+ /// Deliberately narrow. An exact host matches, and a *parent* domain
+ /// matches its subdomains — an entry for `example.com` is offered on
+ /// `login.example.com`, which is how sites actually split their login
+ /// pages. The reverse is not true: an entry for `login.example.com` is
+ /// not offered on `example.com`, and never on an unrelated host, because
+ /// a suggestion is a request to hand a password to whatever is on screen.
+ ///
+ /// An entry with no URL at all falls back to its title: a KeePass entry
+ /// called "GitHub" is offered on `github.com`. That one is a guess, so it
+ /// is only made when there is nothing better to go on.
+ pub fn matches(&self, host: &str) -> bool {
+ let page = normalize_host(host);
+ if page.is_empty() {
+ return false;
+ }
+ match self.host() {
+ Some(entry) => {
+ page == entry || (entry.contains('.') && page.ends_with(&format!(".{entry}")))
+ }
+ None => {
+ let title = self.label.trim().to_lowercase();
+ !title.is_empty()
+ && registrable_label(&page).is_some_and(|name| name == title)
+ }
+ }
+ }
+}
+
+/// Lowercase, and without the `www.` that no one means.
+fn normalize_host(host: &str) -> String {
+ let h = host.trim().to_lowercase();
+ h.strip_prefix("www.").unwrap_or(&h).to_string()
+}
+
+/// The host inside a stored `URL` attribute: a real URL as written, a bare
+/// host by guessing the scheme the same way the URL bar does.
+pub fn entry_host(url: &str) -> Option<String> {
+ let s = url.trim();
+ if s.is_empty() {
+ return None;
+ }
+ let parsed = url::Url::parse(s)
+ .ok()
+ .or_else(|| url::Url::parse(&format!("https://{s}")).ok())?;
+ let host = parsed.host_str()?;
+ let host = normalize_host(host);
+ (!host.is_empty()).then_some(host)
+}
+
+/// The name a site goes by: `github` out of `github.com`, `bbc` out of
+/// `bbc.co.uk`. Not a public-suffix list — it exists only for the
+/// title-matching fallback, where being roughly right is the whole ambition.
+fn registrable_label(host: &str) -> Option<String> {
+ let parts: Vec<&str> = host.split('.').filter(|p| !p.is_empty()).collect();
+ if parts.len() < 2 {
+ return None;
+ }
+ // Two-letter final labels are country codes, where the name sits one
+ // further left (co.uk, com.au) unless the domain is only two deep.
+ let idx = if parts.len() >= 3 && parts[parts.len() - 1].len() == 2 && parts[parts.len() - 2].len() <= 3
+ {
+ parts.len() - 3
+ } else {
+ parts.len() - 2
+ };
+ Some(parts[idx].to_string())
+}
+
+/// What the worker is asked to do.
+enum Request {
+ /// Read every account the keyring holds (labels and attributes only).
+ Load,
+ /// Fetch one entry's password, by object path.
+ Fetch(String),
+}
+
+/// The account index, and the thread that reads it.
+///
+/// Nothing here touches the keyring until something asks: the first login
+/// field on the first page is what wakes it, so a browser that never sees a
+/// login form never opens the store — and never triggers an unlock prompt at
+/// launch, which is the behaviour that would have made this unwelcome.
+pub struct Accounts {
+ tx: mpsc::Sender<Request>,
+ /// Every account the last load returned.
+ all: Vec<Account>,
+ /// A load has been asked for and not yet answered.
+ loading: bool,
+ /// Set once a load has come back, so an empty keyring is not retried on
+ /// every focus.
+ loaded: bool,
+ /// Why the last load failed, for the menu to say so instead of showing
+ /// an empty list that looks like "no accounts".
+ pub error: Option<String>,
+}
+
+impl Accounts {
+ /// Start the worker. It is idle until [`Accounts::ensure_loaded`].
+ pub fn spawn(sender: calloop::channel::Sender<Message>) -> Self {
+ let (tx, rx) = mpsc::channel();
+ std::thread::Builder::new()
+ .name("cce-accounts".to_string())
+ .spawn(move || worker(rx, sender))
+ .expect("spawn the accounts worker");
+ Self { tx, all: Vec::new(), loading: false, loaded: false, error: None }
+ }
+
+ /// Ask for the index if it is not already here or on its way.
+ pub fn ensure_loaded(&mut self) {
+ if self.loaded || self.loading {
+ return;
+ }
+ self.loading = true;
+ let _ = self.tx.send(Request::Load);
+ }
+
+ /// Take the worker's answer.
+ pub fn loaded(&mut self, result: Result<Vec<Account>, String>) {
+ self.loading = false;
+ self.loaded = true;
+ match result {
+ Ok(all) => {
+ self.all = all;
+ self.error = None;
+ }
+ Err(e) => {
+ self.all.clear();
+ self.error = Some(e);
+ }
+ }
+ }
+
+ /// Fetch one password. It comes back as [`Message::Credential`].
+ pub fn fetch(&self, path: &str) {
+ let _ = self.tx.send(Request::Fetch(path.to_string()));
+ }
+
+ /// The accounts worth offering on `host`, best first: entries with a real
+ /// URL ahead of ones matched by their title alone, then by label.
+ pub fn matching(&self, host: &str) -> Vec<Account> {
+ let mut hits: Vec<Account> =
+ self.all.iter().filter(|a| a.matches(host)).cloned().collect();
+ hits.sort_by(|a, b| {
+ b.host()
+ .is_some()
+ .cmp(&a.host().is_some())
+ .then_with(|| a.label.to_lowercase().cmp(&b.label.to_lowercase()))
+ });
+ hits
+ }
+
+ pub fn is_loading(&self) -> bool {
+ self.loading
+ }
+}
+
+/// The worker thread: one Secret Service connection, held for the life of the
+/// browser, serving requests in order.
+fn worker(rx: mpsc::Receiver<Request>, tx: calloop::channel::Sender<Message>) {
+ use secret_service::blocking::SecretService;
+ use secret_service::EncryptionType;
+
+ let mut service: Option<SecretService> = None;
+ while let Ok(request) = rx.recv() {
+ // Connect on the first request, and again after a failure — the
+ // daemon can come and go.
+ if service.is_none() {
+ // Dh, not Plain: the secret then crosses the bus encrypted under a
+ // session key rather than in the clear.
+ match SecretService::connect(EncryptionType::Dh) {
+ Ok(s) => service = Some(s),
+ Err(e) => {
+ let _ = tx.send(Message::Accounts(Err(format!("no secret service: {e}"))));
+ continue;
+ }
+ }
+ }
+ let Some(ss) = service.as_ref() else { continue };
+ match request {
+ Request::Load => {
+ let _ = tx.send(Message::Accounts(load(ss)));
+ }
+ Request::Fetch(path) => {
+ if let Some(secret) = fetch(ss, &path) {
+ let _ = tx.send(Message::Credential(path, secret));
+ }
+ }
+ }
+ }
+}
+
+fn load(ss: &secret_service::blocking::SecretService) -> Result<Vec<Account>, String> {
+ let collections = ss
+ .get_all_collections()
+ .map_err(|e| format!("listing collections failed: {e}"))?;
+ let mut accounts = Vec::new();
+ for collection in &collections {
+ // A locked collection is skipped rather than unlocked: the browser
+ // asking for the keyring password because a page happened to have a
+ // login field would be its own kind of phishing lesson. cce-secrets
+ // is the place to unlock.
+ if collection.is_locked().unwrap_or(true) {
+ continue;
+ }
+ let Ok(items) = collection.get_all_items() else { continue };
+ for item in items {
+ let Ok(attrs) = item.get_attributes() else { continue };
+ let pick = |keys: &[&str]| -> String {
+ keys.iter()
+ .find_map(|k| attrs.get(*k).filter(|v| !v.trim().is_empty()))
+ .cloned()
+ .unwrap_or_default()
+ };
+ let username = pick(&USER_KEYS);
+ let url = pick(&URL_KEYS);
+ // An entry with neither is not an account — a note, a key, a
+ // token — and has nothing to offer a login form.
+ if username.is_empty() && url.is_empty() {
+ continue;
+ }
+ accounts.push(Account {
+ path: item.item_path.to_string(),
+ label: item.get_label().unwrap_or_default(),
+ username,
+ url,
+ });
+ }
+ }
+ Ok(accounts)
+}
+
+/// One entry's password. A failure is silent on purpose: the error text from
+/// this call can carry the item's own label, and it has nowhere to go but a
+/// log.
+fn fetch(ss: &secret_service::blocking::SecretService, path: &str) -> Option<Secret> {
+ let path = zbus::zvariant::OwnedObjectPath::try_from(path).ok()?;
+ let item = ss.get_item_by_path(path).ok()?;
+ let bytes = item.get_secret().ok()?;
+ Some(Secret(String::from_utf8_lossy(&bytes).into_owned()))
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn account(label: &str, url: &str) -> Account {
+ Account {
+ path: "/org/freedesktop/secrets/item/1".to_string(),
+ label: label.to_string(),
+ username: "me".to_string(),
+ url: url.to_string(),
+ }
+ }
+
+ #[test]
+ fn a_stored_url_matches_its_own_host_and_its_subdomains() {
+ let a = account("Example", "https://example.com/login?next=/");
+ assert!(a.matches("example.com"));
+ assert!(a.matches("www.example.com"), "www is not a different site");
+ assert!(a.matches("login.example.com"), "a parent domain covers its subdomains");
+ assert!(!a.matches("example.com.evil.test"), "suffix games are not matches");
+ assert!(!a.matches("notexample.com"));
+ assert!(!a.matches("example.org"));
+ }
+
+ #[test]
+ fn a_subdomain_entry_does_not_leak_upward() {
+ let a = account("Mail", "https://mail.example.com/");
+ assert!(a.matches("mail.example.com"));
+ assert!(!a.matches("example.com"), "the parent is a different site");
+ assert!(!a.matches("chat.example.com"), "so is a sibling");
+ }
+
+ #[test]
+ fn a_bare_host_is_a_url_too() {
+ assert_eq!(entry_host("example.com"), Some("example.com".to_string()));
+ assert_eq!(entry_host("https://WWW.Example.COM/x"), Some("example.com".to_string()));
+ assert_eq!(entry_host(" "), None);
+ assert_eq!(entry_host("not a url at all"), None);
+ }
+
+ #[test]
+ fn an_entry_without_a_url_falls_back_to_its_title() {
+ let a = account("GitHub", "");
+ assert!(a.matches("github.com"));
+ assert!(a.matches("gist.github.com"), "the site name is the same one");
+ assert!(!a.matches("github.evil.test"));
+ assert!(!a.matches("gitlab.com"));
+
+ // The fallback is only for entries with nothing else to go on.
+ let titled = account("GitHub", "https://example.com/");
+ assert!(!titled.matches("github.com"), "a stored URL wins over the title");
+ }
+
+ #[test]
+ fn country_code_domains_still_find_their_name() {
+ assert_eq!(registrable_label("bbc.co.uk").as_deref(), Some("bbc"));
+ assert_eq!(registrable_label("www.example.com").as_deref(), Some("example"));
+ assert_eq!(registrable_label("localhost"), None);
+ }
+
+ #[test]
+ fn a_password_never_prints_itself() {
+ let s = Secret("hunter2".to_string());
+ assert_eq!(format!("{s:?}"), "Secret(…)");
+ assert_eq!(format!("{:?}", Message::Credential("/p".into(), s.clone())),
+ "Credential(\"/p\", Secret(…))");
+ assert_eq!(s.expose(), "hunter2");
+ }
+}
diff --git a/src/main.rs b/src/main.rs
index 7f0c054..fe0df1c 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -8,6 +8,7 @@
//! unfolds from under it. Input over the page area is translated into Servo input events; the
//! URL bar is a small hand-rolled line editor.
+mod accounts;
mod downloads;
mod instance;
mod lineedit;
@@ -105,6 +106,16 @@ const BM_GAP: f32 = 6.0;
const BM_RM_W: f32 = 24.0;
const BM_FONT: f32 = 13.0;
const BM_TEXT_PAD: f32 = 10.0;
+/// The account list: suggestions from cce-secrets, dropped at the login
+/// field they are for rather than at the bar, because that is where the
+/// person is looking.
+const AC_W: f32 = 300.0;
+const AC_ROW_H: f32 = 34.0;
+const AC_PAD: f32 = 5.0;
+/// Rows before the list scrolls with the selection.
+const AC_MAX_ROWS: usize = 6;
+const AC_FONT: f32 = 13.0;
+const AC_SUB_FONT: f32 = 11.0;
/// Utility-bar fill; the negative alpha marks the plate as blur-behind.
/// The blurred page is the base and this color tints it at |alpha|
/// opacity — keep |alpha| low so the frosted content shows through.
@@ -310,6 +321,86 @@ enum BmHit {
Manage,
}
+/// The account list: what cce-secrets can offer the login field that is
+/// focused right now.
+///
+/// It belongs to a *field*, not to the bar — it opens when one takes focus,
+/// follows it when the page scrolls, and goes when focus does. Only the
+/// accounts matching the tab's own host are ever in it, and no password is
+/// fetched to build it: a pick is what asks the keyring for one.
+#[cfg(feature = "wpe")]
+struct AcMenu {
+ /// Matches for this host, before filtering.
+ all: Vec<accounts::Account>,
+ /// What survives what has been typed into the username field.
+ shown: Vec<accounts::Account>,
+ /// Keyboard selection, an index into `shown`.
+ selected: usize,
+ /// First visible row, when `shown` is longer than the list can show.
+ scroll: usize,
+ /// The field, in the chrome's own coordinates.
+ anchor: Rect,
+ /// The host this list was built for. A fetched password is checked
+ /// against it before it is filled: the keyring answers asynchronously,
+ /// and by then the tab could be somewhere else entirely.
+ host: String,
+ /// The page is not on a secure origin — worth saying before a password
+ /// goes into it.
+ insecure: bool,
+ /// Pointer-hovered row.
+ hover: Option<usize>,
+}
+
+#[cfg(feature = "wpe")]
+impl AcMenu {
+ /// Narrow the list to what has been typed. Matching is on the username
+ /// and the entry's title, case-insensitively and anywhere in either —
+ /// people type the middle of an address as readily as its start.
+ fn refilter(&mut self, typed: &str) {
+ let needle = typed.trim().to_lowercase();
+ self.shown = self
+ .all
+ .iter()
+ .filter(|a| {
+ needle.is_empty()
+ || a.username.to_lowercase().contains(&needle)
+ || a.label.to_lowercase().contains(&needle)
+ })
+ .cloned()
+ .collect();
+ self.selected = self.selected.min(self.shown.len().saturating_sub(1));
+ self.scroll = self.scroll.min(self.shown.len().saturating_sub(1));
+ self.keep_selected_visible();
+ }
+
+ fn first_row(&self) -> usize {
+ self.scroll
+ .min(self.shown.len().saturating_sub(self.shown.len().min(AC_MAX_ROWS)))
+ }
+
+ /// Move the keyboard selection, scrolling the window to follow it.
+ fn step(&mut self, delta: isize) {
+ if self.shown.is_empty() {
+ return;
+ }
+ let n = self.shown.len() as isize;
+ self.selected = (((self.selected as isize + delta) % n + n) % n) as usize;
+ self.keep_selected_visible();
+ }
+
+ fn keep_selected_visible(&mut self) {
+ let rows = self.shown.len().min(AC_MAX_ROWS);
+ if rows == 0 {
+ return;
+ }
+ if self.selected < self.scroll {
+ self.scroll = self.selected;
+ } else if self.selected >= self.scroll + rows {
+ self.scroll = self.selected + 1 - rows;
+ }
+ }
+}
+
/// Every rect the menu draws and hit-tests, derived once — the same
/// one-geometry rule the bar's own helpers follow.
struct BmLayout {
@@ -326,6 +417,11 @@ struct BmLayout {
#[derive(Debug, Clone)]
pub enum Message {
+ /// The accounts worker answered a load: the index, or why it failed.
+ Accounts(Result<Vec<accounts::Account>, String>),
+ /// One entry's password arrived for the account at this object path.
+ /// The payload prints as `Secret(…)`; see `accounts::Secret`.
+ Credential(String, accounts::Secret),
/// Servo requested an event-loop spin (waker or delegate signal).
Spin,
/// Last tab closed: exit the app.
@@ -388,6 +484,18 @@ struct BrowserApp {
bookmarks: std::sync::Arc<pages::Bookmarks>,
/// The open bookmarks menu, if any.
bm_menu: Option<BmMenu>,
+ /// Accounts from cce-secrets, and the worker that reads them.
+ accounts: accounts::Accounts,
+ /// The open account list, if a login field is focused and something in
+ /// the keyring matches the page.
+ #[cfg(feature = "wpe")]
+ ac_menu: Option<AcMenu>,
+ /// The active tab's URL as of the last page-state sync, for spotting an
+ /// actual navigation. The engine's dirty flag is not that: it also fires
+ /// for a title, a loading transition, a favicon — and treating those as
+ /// navigations closed the account list in the same pump that opened it.
+ #[cfg(feature = "wpe")]
+ nav_url: Option<String>,
/// Hovered pill in the favorites strip — a repaint, like the dot.
fav_hover: Option<usize>,
}
@@ -521,6 +629,18 @@ fn url_rect(bar: &Rect, position: settings::BarPosition) -> Rect {
Rect { x, y: controls_y(bar), width: (right - x).max(60.0), height: BTN_H }
}
+/// Whether a password filled into this page would leave it in the clear.
+///
+/// Loopback is not: nothing crosses a network. Everything else that is not
+/// https is, including a `file:` page, which has no origin to speak of.
+#[cfg(feature = "wpe")]
+fn insecure_origin(origin: &str, host: &str) -> bool {
+ let secure_scheme = origin.split(':').next() == Some("https");
+ let loopback = matches!(host, "localhost" | "127.0.0.1" | "::1")
+ || host.ends_with(".localhost");
+ !secure_scheme && !loopback
+}
+
/// Turn URL-bar input into something loadable: a real URL as-is, a bare
/// host gets https://, anything else becomes a search.
fn parse_url_input(input: &str, search_prefix: &str) -> Option<Url> {
@@ -613,6 +733,185 @@ impl BrowserApp {
self.refresh_favorites();
}
+ /// The tab's host, for matching accounts and for checking that a field
+ /// event came from the page the chrome thinks is on screen.
+ fn page_host(&self) -> Option<String> {
+ self.host.url().and_then(|u| u.host_str().map(str::to_string))
+ }
+
+ /// A login field was reported. Open, move or refill the account list.
+ ///
+ /// The origin check is the guard: the watcher runs in the top frame, so
+ /// its origin must be the tab's own. Anything else is dropped rather than
+ /// offered a credential.
+ #[cfg(feature = "wpe")]
+ fn on_form_event(&mut self, event: wpe::FormEvent) -> bool {
+ use wpe::FormEvent;
+ if !self.settings.accounts {
+ return false;
+ }
+ match event {
+ FormEvent::Blur => {
+ let was = self.ac_menu.is_some();
+ self.ac_menu = None;
+ was
+ }
+ FormEvent::Field { origin, password, rect, value, moved } => {
+ log::debug!(
+ "login field: password={password} moved={moved} origin={origin} \
+ page={:?} rect={rect:?}",
+ self.page_host()
+ );
+ let Some(host) = self.page_host() else {
+ self.ac_menu = None;
+ return false;
+ };
+ let same_origin = url::Url::parse(&origin)
+ .ok()
+ .and_then(|u| u.host_str().map(|h| h == host))
+ .unwrap_or(false);
+ if !same_origin {
+ self.ac_menu = None;
+ return false;
+ }
+ // The index is read the first time a login field appears —
+ // never at launch, so a browser that sees no login form never
+ // opens the keyring.
+ self.accounts.ensure_loaded();
+ let anchor = self.field_rect(rect);
+ let all = self.accounts.matching(&host);
+ log::debug!("{} accounts match {host}", all.len());
+ let insecure = insecure_origin(&origin, &host);
+ // A password field filters by nothing; a username field by
+ // what is in it.
+ let filter = if password { String::new() } else { value };
+ match self.ac_menu.as_mut() {
+ Some(menu) if moved => {
+ menu.anchor = anchor;
+ menu.all = all;
+ menu.host = host.clone();
+ menu.insecure = insecure;
+ menu.refilter(&filter);
+ }
+ _ => {
+ let mut menu = AcMenu {
+ all,
+ shown: Vec::new(),
+ selected: 0,
+ scroll: 0,
+ anchor,
+ host: host.clone(),
+ insecure,
+ hover: None,
+ };
+ menu.refilter(&filter);
+ self.ac_menu = Some(menu);
+ }
+ }
+ // An empty list is no list: nothing matched, or the index is
+ // still loading and the next `Accounts` message will reopen.
+ if self.ac_menu.as_ref().is_some_and(|m| m.shown.is_empty()) {
+ self.ac_menu = None;
+ }
+ true
+ }
+ }
+ }
+
+ /// A viewport rect from the page, in the chrome's coordinates.
+ ///
+ /// These are the same space, and that is worth stating rather than
+ /// rediscovering: `resize` gives WPE the **logical** size and sets the
+ /// scale separately (`logical_size`), so a CSS pixel in the page is a
+ /// logical pixel in the chrome at any output scale. Verified at scale 2.
+ #[cfg(feature = "wpe")]
+ fn field_rect(&self, rect: (f32, f32, f32, f32)) -> Rect {
+ Rect { x: rect.0, y: rect.1, width: rect.2, height: rect.3 }
+ }
+
+ /// Hand a picked account's credential to the page and close the list.
+ ///
+ /// The keyring answers on its own schedule — an unlock prompt can put
+ /// seconds between the pick and this — so everything is checked again
+ /// here: the list is still open, it still holds the account that was
+ /// picked, and the tab is still on the host it was opened for. If any of
+ /// that has changed the credential is dropped on the floor rather than
+ /// typed into whatever page is there now.
+ #[cfg(feature = "wpe")]
+ fn fill_account(&mut self, path: &str, secret: &accounts::Secret) {
+ let same_page = self
+ .ac_menu
+ .as_ref()
+ .zip(self.page_host())
+ .is_some_and(|(menu, host)| menu.host == host);
+ let username = self
+ .ac_menu
+ .as_ref()
+ .filter(|_| same_page)
+ .and_then(|m| m.shown.iter().find(|a| a.path == path))
+ .map(|a| a.username.clone());
+ match username {
+ Some(username) => self.host.fill_credentials(&username, secret.expose()),
+ None => log::warn!("dropped a credential: the page moved on before it arrived"),
+ }
+ self.ac_menu = None;
+ }
+
+ /// Ask for the password behind the selected row.
+ #[cfg(feature = "wpe")]
+ fn pick_account(&mut self, index: usize) {
+ let Some(account) = self.ac_menu.as_ref().and_then(|m| m.shown.get(index)) else {
+ return;
+ };
+ // The secret is fetched now, for this one entry, and arrives as
+ // `Message::Credential`. Nothing is held in the menu.
+ self.accounts.fetch(&account.path);
+ }
+
+ /// The account list's plate and rows, or `None` when it is closed. Draw
+ /// and hit-test read this, as everywhere else in this chrome.
+ #[cfg(feature = "wpe")]
+ fn ac_layout(&self) -> Option<(Rect, Vec<Rect>)> {
+ let menu = self.ac_menu.as_ref()?;
+ if menu.shown.is_empty() {
+ return None;
+ }
+ let rows = menu.shown.len().min(AC_MAX_ROWS);
+ let height = 2.0 * AC_PAD + rows as f32 * AC_ROW_H + if menu.insecure { 18.0 } else { 0.0 };
+ let width = AC_W.min(self.win.0 - 2.0 * BAR_MARGIN).max(180.0);
+ let x = menu
+ .anchor
+ .x
+ .clamp(0.0, (self.win.0 - width).max(0.0));
+ // Under the field, or above it when there is no room below — the
+ // list must never cover the field it is filling.
+ let below = menu.anchor.y + menu.anchor.height + 2.0;
+ let y = if below + height <= self.win.1 - BAR_MARGIN {
+ below
+ } else {
+ (menu.anchor.y - 2.0 - height).max(0.0)
+ };
+ let plate = Rect { x, y, width, height };
+ // Row *positions*; which account each shows is `first_row() + k`.
+ let rects = (0..rows)
+ .map(|k| Rect {
+ x: plate.x + 2.0,
+ y: plate.y + AC_PAD + (k as f32) * AC_ROW_H,
+ width: plate.width - 4.0,
+ height: AC_ROW_H,
+ })
+ .collect();
+ Some((plate, rects))
+ }
+
+ /// The account row at a pointer position, if any.
+ #[cfg(feature = "wpe")]
+ fn ac_hit(&self, x: f32, y: f32) -> Option<usize> {
+ let (_, rows) = self.ac_layout()?;
+ let first = self.ac_menu.as_ref()?.first_row();
+ rows.iter().position(|r| hit(r, x, y)).map(|k| first + k)
+ }
+
/// Whether the active page is one that can be saved at all: an internal
/// page or a blank tab cannot.
fn saveable(&self) -> bool {
@@ -1049,6 +1348,13 @@ impl BrowserApp {
return false;
}
downloads::set_download_dir(new.download_dir.clone());
+ #[cfg(feature = "wpe")]
+ if new.accounts != self.settings.accounts {
+ self.host.set_accounts_enabled(new.accounts);
+ if !new.accounts {
+ self.ac_menu = None;
+ }
+ }
self.host.set_history_enabled(new.history);
self.host.set_color_scheme_dark(new.color_scheme.is_dark());
self.host.set_force_dark(new.color_scheme.forces_dark());
@@ -1144,6 +1450,12 @@ impl BrowserApp {
}
fn switch_tab(&mut self, index: usize) {
+ // The other tab has its own fields, and may have none.
+ #[cfg(feature = "wpe")]
+ {
+ self.ac_menu = None;
+ self.host.clear_form_events();
+ }
self.host.activate(index);
self.url_focused = false;
self.sync_page_state();
@@ -1261,6 +1573,71 @@ impl BrowserApp {
}
}
+ /// Draw the account list at the login field it belongs to.
+ ///
+ /// Two lines per row: the username that will be filled, and the entry's
+ /// own title under it, because a keyring holds several accounts on one
+ /// site and the title is how they were told apart when they were saved.
+ #[cfg(feature = "wpe")]
+ fn paint_ac_menu(&mut self, pc: &mut PaintCtx, sans: &str) {
+ let Some((plate, rows)) = self.ac_layout() else { return };
+ let Some(menu) = self.ac_menu.as_ref() else { return };
+ let first = menu.first_row();
+ pc.plate(
+ plate,
+ (8.0, 8.0, 8.0, 8.0),
+ [0.13, 0.14, 0.16, 1.0],
+ cce_ui::layout::bevel_width().min(3.0),
+ );
+ for (k, r) in rows.iter().enumerate() {
+ let Some(account) = menu.shown.get(first + k) else { continue };
+ let picked = first + k == menu.selected || menu.hover == Some(first + k);
+ if picked {
+ pc.rounded_rect(*r, 5.0, (true, true, true, true), TAB_ACTIVE_BG);
+ }
+ let width = r.width - 2.0 * BM_TEXT_PAD;
+ let user = if account.username.is_empty() {
+ account.label.clone()
+ } else {
+ account.username.clone()
+ };
+ pc.text(
+ Self::fit_text(&user, sans, AC_FONT, width),
+ r.x + BM_TEXT_PAD,
+ r.y + 5.0,
+ AC_FONT,
+ TEXT,
+ );
+ // The second line names where the entry came from: its title, and
+ // the site it is stored against when that is not the title.
+ let mut sub = account.label.clone();
+ if let Some(host) = account.host() {
+ if !sub.to_lowercase().contains(&host) {
+ sub = if sub.is_empty() { host } else { format!("{sub} — {host}") };
+ }
+ }
+ pc.text(
+ Self::fit_text(&sub, sans, AC_SUB_FONT, width),
+ r.x + BM_TEXT_PAD,
+ r.y + 5.0 + AC_FONT + 3.0,
+ AC_SUB_FONT,
+ TEXT_DIM,
+ );
+ }
+ // Say it plainly when the page is not https: the password is about to
+ // cross the network in the clear, and only the person can decide that
+ // is fine.
+ if menu.insecure {
+ pc.text(
+ "insecure page — this password would be sent unencrypted",
+ plate.x + BM_TEXT_PAD,
+ plate.y + plate.height - 15.0,
+ AC_SUB_FONT,
+ [212, 155, 155],
+ );
+ }
+ }
+
/// Draw the bookmarks menu: the same plate-and-rows vocabulary as the
/// right-click menu, in three sections — what to do with this page, the
/// pages already saved, and the way out to the full collection.
@@ -1510,7 +1887,8 @@ impl Application for BrowserApp {
let first = queue.remove(0);
#[cfg(all(not(feature = "wpe"), feature = "servo"))]
- let mut host = Host::new(sender, first, (1200, 800), settings.color_scheme.forces_dark());
+ let mut host =
+ Host::new(sender.clone(), first, (1200, 800), settings.color_scheme.forces_dark());
#[cfg(feature = "wpe")]
let mut host = {
let _ = &sender; // WPE wakes through register_sources, not a waker
@@ -1535,6 +1913,9 @@ impl Application for BrowserApp {
host.set_color_scheme_dark(settings.color_scheme.is_dark());
#[cfg(feature = "wpe")]
host.set_force_dark(settings.color_scheme.forces_dark());
+ #[cfg(feature = "wpe")]
+ host.set_accounts_enabled(settings.accounts);
+ let accounts = accounts::Accounts::spawn(sender.clone());
let favorites = host.favorites();
let favs = favorites.snapshot();
let bookmarks = host.bookmarks();
@@ -1564,6 +1945,11 @@ impl Application for BrowserApp {
fav_hover: None,
bookmarks,
bm_menu: None,
+ accounts,
+ #[cfg(feature = "wpe")]
+ ac_menu: None,
+ #[cfg(feature = "wpe")]
+ nav_url: None,
}
}
@@ -1641,15 +2027,61 @@ impl Application for BrowserApp {
self.open_internal_page("cce://downloads");
}
if dirty {
+ // A navigation retires whatever field was focused. Only a
+ // real one: the URL changing, not the dirty flag, which
+ // also fires while the page that owns the field is still
+ // settling.
+ #[cfg(feature = "wpe")]
+ {
+ let now = self.host.url().map(|u| u.to_string());
+ if now != self.nav_url {
+ self.nav_url = now;
+ self.ac_menu = None;
+ }
+ }
self.sync_page_state();
// Navigation reaches the tab set through these signals,
// so this is where an address change gets persisted.
self.persist_session();
}
+ // Drained after the navigation check, so a field reported in
+ // the same pump that finished the load is not thrown away
+ // with the page it arrived on.
+ #[cfg(feature = "wpe")]
+ while let Some(event) = self.host.take_form_event() {
+ if self.on_form_event(event) {
+ *needs_rebuild = true;
+ }
+ }
if new_frame || dirty {
*needs_rebuild = true;
}
}
+ Message::Accounts(result) => {
+ match &result {
+ Ok(list) => log::info!("accounts: {} entries from the keyring", list.len()),
+ Err(e) => log::warn!("accounts unavailable: {e}"),
+ }
+ self.accounts.loaded(result);
+ // A field may have been focused while the index was still
+ // being read; this is when its list can finally open.
+ #[cfg(feature = "wpe")]
+ {
+ self.host.request_form_state();
+ *needs_rebuild = true;
+ }
+ }
+ Message::Credential(path, secret) => {
+ #[cfg(feature = "wpe")]
+ {
+ self.fill_account(&path, &secret);
+ *needs_rebuild = true;
+ }
+ #[cfg(not(feature = "wpe"))]
+ {
+ let _ = (path, secret);
+ }
+ }
Message::Quit => *exit = true,
Message::OpenExternal(arg) => {
match arg {
@@ -1718,6 +2150,22 @@ impl Application for BrowserApp {
*_needs_rebuild = true;
return;
}
+ // The account list tracks hover the same way, and shields the page
+ // under it.
+ #[cfg(feature = "wpe")]
+ if self.ac_menu.is_some() {
+ let over = self.ac_hit(pos.x, pos.y);
+ if self.ac_menu.as_ref().is_some_and(|m| m.hover != over) {
+ if let Some(m) = self.ac_menu.as_mut() {
+ m.hover = over;
+ }
+ *_needs_rebuild = true;
+ }
+ if over.is_some() {
+ return;
+ }
+ }
+
// An open bookmarks menu tracks hover, and the page under it sees
// no moves at all.
if self.bm_menu.is_some() {
@@ -1803,6 +2251,25 @@ impl Application for BrowserApp {
return None;
}
+ // A click on an account row picks it. A click anywhere else closes
+ // the list and goes on to the page as usual — unlike the chrome's own
+ // menus, this one sits over the page's own controls, and swallowing
+ // the click that dismisses it would eat a button press.
+ #[cfg(feature = "wpe")]
+ if self.ac_menu.is_some() {
+ if let Some(index) = self.ac_hit(pos.x, pos.y) {
+ if pressed && button == MouseButton::Left {
+ self.pick_account(index);
+ }
+ *needs_rebuild = true;
+ return None;
+ }
+ if pressed {
+ self.ac_menu = None;
+ *needs_rebuild = true;
+ }
+ }
+
// The bookmarks menu owns the next click while it is open: a row
// acts, a click off the plate closes it, and either way the click
// goes no further — the rule the right-click menu already follows.
@@ -1933,6 +2400,16 @@ impl Application for BrowserApp {
}
fn handle_mouse_wheel(&mut self, delta: &MouseScrollDelta, pos: LogicalPosition, needs_rebuild: &mut bool) {
+ // The account list moves with its field, so the page keeps the
+ // wheel — but not under the plate itself.
+ #[cfg(feature = "wpe")]
+ if self
+ .ac_layout()
+ .is_some_and(|(plate, _)| hit(&plate, pos.x, pos.y))
+ {
+ return;
+ }
+
// An open menu takes the wheel: over its plate it scrolls the list,
// anywhere else it is swallowed rather than scrolling the page
// behind it.
@@ -2015,6 +2492,43 @@ impl Application for BrowserApp {
return None;
}
+ // An open account list takes the keys that drive it, and passes on
+ // everything else — the person is typing into the page's own field,
+ // and that typing is what filters the list.
+ #[cfg(feature = "wpe")]
+ if self.ac_menu.is_some() && event.state == ElementState::Pressed && !self.url_focused {
+ match &event.logical_key {
+ Key::Named(NamedKey::ArrowDown) => {
+ if let Some(m) = self.ac_menu.as_mut() {
+ m.step(1);
+ }
+ *needs_rebuild = true;
+ return None;
+ }
+ Key::Named(NamedKey::ArrowUp) => {
+ if let Some(m) = self.ac_menu.as_mut() {
+ m.step(-1);
+ }
+ *needs_rebuild = true;
+ return None;
+ }
+ Key::Named(NamedKey::Enter) => {
+ let selected = self.ac_menu.as_ref().map(|m| m.selected);
+ if let Some(i) = selected {
+ self.pick_account(i);
+ }
+ *needs_rebuild = true;
+ return None;
+ }
+ Key::Named(NamedKey::Escape) => {
+ self.ac_menu = None;
+ *needs_rebuild = true;
+ return None;
+ }
+ _ => {}
+ }
+ }
+
// An open bookmarks menu owns Escape, ahead of the URL bar and the
// page both.
if self.bm_menu.is_some()
@@ -2364,6 +2878,8 @@ impl Application for BrowserApp {
self.paint_bm_menu(&mut pc, &sans);
#[cfg(feature = "wpe")]
+ self.paint_ac_menu(&mut pc, &sans);
+ #[cfg(feature = "wpe")]
self.paint_ctx_menu(&mut pc, &sans);
#[cfg(feature = "wpe")]
self.paint_modal(&mut pc, &sans);
@@ -2417,6 +2933,19 @@ mod tests {
std::fs::remove_file(&page).unwrap();
}
+ #[cfg(feature = "wpe")]
+ #[test]
+ fn only_loopback_escapes_the_insecure_warning() {
+ assert!(!insecure_origin("https://example.com", "example.com"));
+ assert!(insecure_origin("http://example.com", "example.com"));
+ assert!(!insecure_origin("http://localhost:8731", "localhost"));
+ assert!(!insecure_origin("http://127.0.0.1:8080", "127.0.0.1"));
+ assert!(!insecure_origin("http://dev.localhost", "dev.localhost"));
+ // A file: page has no transport to secure, and no origin worth the
+ // name; say so rather than stay quiet.
+ assert!(insecure_origin("null", ""));
+ }
+
#[test]
fn startup_arg_still_takes_urls_and_searches() {
let u = parse_startup_arg("https://example.com/x", SEARCH).unwrap();
diff --git a/src/settings.rs b/src/settings.rs
index 3ea64ea..0c5795d 100644
--- a/src/settings.rs
+++ b/src/settings.rs
@@ -88,6 +88,10 @@ pub struct Settings {
pub download_dir: Option<PathBuf>,
/// Record page visits to cce://history.
pub history: bool,
+ /// Offer accounts from cce-secrets on login forms. On by default, and a
+ /// single switch for the whole feature: with it off the browser injects
+ /// no watcher script and never opens the keyring.
+ pub accounts: bool,
/// Window edge the utility bar floats against.
pub bar_position: BarPosition,
/// What pages are told to prefer.
@@ -104,6 +108,7 @@ impl Default for Settings {
search_prefix: search_prefix("duckduckgo").to_string(),
download_dir: None,
history: true,
+ accounts: true,
bar_position: BarPosition::Top,
color_scheme: ColorScheme::Dark,
external_browser: None,
@@ -146,6 +151,7 @@ pub fn load() -> Settings {
search_prefix: search_prefix(b["search"].as_str().unwrap_or("duckduckgo")).to_string(),
download_dir,
history: b["history"].as_bool().unwrap_or(true),
+ accounts: b["accounts"].as_bool().unwrap_or(true),
bar_position: BarPosition::from_key(b["bar-position"].as_str().unwrap_or("top")),
color_scheme: ColorScheme::from_key(b["color-scheme"].as_str().unwrap_or("dark")),
external_browser: b["external-browser"]
diff --git a/src/wpe/formwatch.rs b/src/wpe/formwatch.rs
new file mode 100644
index 0000000..1f0103e
--- /dev/null
+++ b/src/wpe/formwatch.rs
@@ -0,0 +1,282 @@
+//! The page half of account autocomplete: what the chrome knows about a
+//! login form, and how a picked account gets into it.
+//!
+//! Both directions run in a **private script world** (`WORLD`), not the
+//! page's. Two things follow, and they are the reason for the whole
+//! arrangement: the page cannot see or replace the helpers this installs, so
+//! it cannot hook the moment a credential is filled; and the message channel
+//! the chrome listens on cannot be spoofed by page script, so a page cannot
+//! make the chrome believe a login field is focused when none is.
+//!
+//! The script is injected into the **top frame only**. A password field
+//! inside a cross-origin iframe therefore gets no suggestions — the deliberate
+//! trade: such a frame cannot report a position in the top document's
+//! coordinates, and an embedded frame asking for the embedder's credentials
+//! is exactly the shape of the attack this feature must not enable.
+
+/// The isolated world everything here lives in.
+pub const WORLD: &str = "cce-accounts";
+/// The message channel the injected script posts on.
+pub const CHANNEL: &str = "cceAccounts";
+
+/// Watches the top frame for login fields and reports them to the chrome.
+///
+/// It reports *positions*, *field kinds* and *what is typed* — never page
+/// content at large. Rects are CSS pixels relative to the viewport, which the
+/// chrome converts with the same scale it sized the view at.
+pub const WATCH_JS: &str = r#"
+(() => {
+ const post = (m) => {
+ try { window.webkit.messageHandlers.cceAccounts.postMessage(JSON.stringify(m)); }
+ catch (e) {}
+ };
+ const state = { user: null, pass: null, filling: false };
+ window.__cceAccounts = state;
+
+ const isPassword = (el) =>
+ el && el.tagName === 'INPUT' && el.type === 'password' && !el.disabled && !el.readOnly;
+ // A username field is a text-ish input that keeps company with a password
+ // one: same form, or — for the many login pages that use no form element —
+ // anywhere on a page that has one. Autocomplete hints and the usual names
+ // are accepted on their own, since some pages ask for the username first
+ // and only render the password field on the next step.
+ const textish = (el) =>
+ el && el.tagName === 'INPUT' &&
+ ['text', 'email', 'tel', ''].includes((el.type || '').toLowerCase()) &&
+ !el.disabled && !el.readOnly;
+ const named = (el) => {
+ const hint = ((el.autocomplete || '') + ' ' + (el.name || '') + ' ' +
+ (el.id || '') + ' ' + (el.getAttribute('aria-label') || '')).toLowerCase();
+ return /user|email|login|account|ident/.test(hint);
+ };
+ const passwordsIn = (root) =>
+ Array.from((root || document).querySelectorAll('input[type=password]'))
+ .filter(isPassword);
+
+ const kindOf = (el) => {
+ if (isPassword(el)) return 'pass';
+ if (!textish(el)) return null;
+ const form = el.form;
+ if (passwordsIn(form).length) return 'user';
+ if (named(el) && passwordsIn(document).length) return 'user';
+ if (named(el) && el.type.toLowerCase() === 'email') return 'user';
+ return null;
+ };
+
+ const rectOf = (el) => {
+ const r = el.getBoundingClientRect();
+ return [r.left, r.top, r.width, r.height];
+ };
+
+ const report = (el, kind, type) => {
+ // A fill is not something to report back: the input events it dispatches
+ // would arrive as "the user typed", re-opening the list that was just
+ // used and filtering it by the name it had just filled in.
+ if (state.filling) return;
+ if (kind === 'pass') { state.pass = el; } else { state.user = el; }
+ // Remember the pair, so filling reaches both fields from either one.
+ const form = el.form;
+ const pass = passwordsIn(form).concat(passwordsIn(document))[0] || null;
+ if (pass) state.pass = pass;
+ if (kind === 'user') state.user = el;
+ post({
+ t: type,
+ kind: kind,
+ origin: location.origin,
+ rect: rectOf(el),
+ value: kind === 'pass' ? '' : (el.value || ''),
+ });
+ };
+
+ document.addEventListener('focusin', (e) => {
+ const kind = kindOf(e.target);
+ if (kind) report(e.target, kind, 'focus');
+ }, true);
+
+ document.addEventListener('focusout', (e) => {
+ if (kindOf(e.target)) post({ t: 'blur' });
+ }, true);
+
+ // Typing in the username field is the filter; the password field's own
+ // text is never reported.
+ document.addEventListener('input', (e) => {
+ const kind = kindOf(e.target);
+ if (kind === 'user' && document.activeElement === e.target) {
+ report(e.target, kind, 'input');
+ }
+ }, true);
+
+ // The page moving under an open list would leave it pointing at nothing.
+ const moved = () => {
+ const el = document.activeElement;
+ const kind = kindOf(el);
+ if (kind) report(el, kind, 'move'); else post({ t: 'blur' });
+ };
+ window.addEventListener('scroll', moved, true);
+ window.addEventListener('resize', moved, true);
+ // The chrome calls this when it has something new to offer — the account
+ // index finishing its first read, after a field was already focused.
+ state.rescan = moved;
+})();
+"#;
+
+/// Fill the remembered pair. Evaluated in [`WORLD`], so it reads the elements
+/// the watcher recorded rather than trusting anything the page exposes.
+///
+/// Values go in through the prototype's own `value` setter and are followed by
+/// `input` and `change` events: frameworks that track their inputs (React's
+/// value tracker above all) ignore a plain assignment, and a page whose state
+/// never saw the credential appear will submit an empty form.
+///
+/// It does not submit. Filling is the chrome's business; pressing the button
+/// is the person's.
+pub fn fill_js(username: &str, password: &str) -> String {
+ format!(
+ r#"
+(() => {{
+ const s = window.__cceAccounts || {{}};
+ // Suppress the watcher for the duration: dispatching `input` is the whole
+ // point of filling, and it must not come back as typing. Synchronous, so
+ // the flag is down again before anything else runs.
+ s.filling = true;
+ const set = (el, v) => {{
+ if (!el) return false;
+ const d = Object.getOwnPropertyDescriptor(Object.getPrototypeOf(el), 'value');
+ if (d && d.set) {{ d.set.call(el, v); }} else {{ el.value = v; }}
+ el.dispatchEvent(new Event('input', {{ bubbles: true }}));
+ el.dispatchEvent(new Event('change', {{ bubbles: true }}));
+ return true;
+ }};
+ const user = {user};
+ const pass = {pass};
+ const filledUser = user.length ? set(s.user, user) : false;
+ const filledPass = set(s.pass, pass);
+ if (filledUser && !filledPass && s.user) {{ s.user.focus(); }}
+ s.filling = false;
+}})();
+"#,
+ user = json_string(username),
+ pass = json_string(password),
+ )
+}
+
+/// A JSON string literal — the only escaping this file needs, and it has to be
+/// exact: a credential is about to cross into a script source, where a stray
+/// quote would end the string and the rest would be parsed as code.
+pub fn json_string(s: &str) -> String {
+ let mut out = String::with_capacity(s.len() + 2);
+ out.push('"');
+ for c in s.chars() {
+ match c {
+ '"' => out.push_str("\\\""),
+ '\\' => out.push_str("\\\\"),
+ '\n' => out.push_str("\\n"),
+ '\r' => out.push_str("\\r"),
+ '\t' => out.push_str("\\t"),
+ // Line separators are literal newlines to a JS parser.
+ '\u{2028}' => out.push_str("\\u2028"),
+ '\u{2029}' => out.push_str("\\u2029"),
+ c if (c as u32) < 0x20 => out.push_str(&format!("\\u{:04x}", c as u32)),
+ c => out.push(c),
+ }
+ }
+ out.push('"');
+ out
+}
+
+/// Ask the watcher to re-report whatever login field is focused right now.
+///
+/// The chrome needs this exactly once per page in practice: a field can take
+/// focus before the account index has finished its first read, and without a
+/// nudge nothing would report it again until the person clicked away and back.
+pub const RESCAN_JS: &str =
+ "window.__cceAccounts && window.__cceAccounts.rescan && window.__cceAccounts.rescan();";
+
+/// What the watcher saw, as the chrome consumes it.
+#[derive(Debug, Clone)]
+pub enum FormEvent {
+ /// A login field took focus, or moved, or its text changed.
+ Field {
+ /// `location.origin` of the frame that reported it, checked against
+ /// the tab's own URL before anything is offered.
+ origin: String,
+ /// A password field rather than a username one.
+ password: bool,
+ /// Viewport rect in CSS pixels: x, y, width, height.
+ rect: (f32, f32, f32, f32),
+ /// What the username field holds, for filtering. Always empty for a
+ /// password field — the chrome has no business with what is typed
+ /// into one.
+ value: String,
+ /// True when this is a re-report of a field that was already focused
+ /// (scroll, resize, typing) rather than a fresh focus.
+ moved: bool,
+ },
+ /// Focus left the login field.
+ Blur,
+}
+
+/// Parse one message from the watcher. Anything unexpected is dropped: this
+/// is a channel the chrome acts on, so it takes only what it recognizes.
+pub fn parse_event(json: &str) -> Option<FormEvent> {
+ let value: serde_json::Value = serde_json::from_str(json).ok()?;
+ match value["t"].as_str()? {
+ "blur" => Some(FormEvent::Blur),
+ t @ ("focus" | "input" | "move") => {
+ let rect = value["rect"].as_array()?;
+ let num = |i: usize| rect.get(i).and_then(|v| v.as_f64()).map(|f| f as f32);
+ Some(FormEvent::Field {
+ origin: value["origin"].as_str().unwrap_or_default().to_string(),
+ password: value["kind"].as_str() == Some("pass"),
+ rect: (num(0)?, num(1)?, num(2)?, num(3)?),
+ value: value["value"].as_str().unwrap_or_default().to_string(),
+ moved: t != "focus",
+ })
+ }
+ _ => None,
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn a_credential_cannot_break_out_of_the_fill_script() {
+ // The whole hazard in one string: quotes, a backslash, a closing
+ // script tag, a newline and a line separator.
+ let nasty = "a\"b\\c</script>\nd\u{2028}e";
+ let quoted = json_string(nasty);
+ assert_eq!(quoted, "\"a\\\"b\\\\c</script>\\nd\\u2028e\"");
+ let js = fill_js("user", nasty);
+ assert!(js.contains("ed));
+ // No raw newline from the credential ever reaches the source.
+ assert!(!js.contains("d\u{2028}"));
+ }
+
+ #[test]
+ fn events_parse_and_junk_is_dropped() {
+ let focus = parse_event(
+ r#"{"t":"focus","kind":"user","origin":"https://example.com","rect":[10,20,120,24],"value":"me"}"#,
+ );
+ match focus {
+ Some(FormEvent::Field { origin, password, rect, value, moved }) => {
+ assert_eq!(origin, "https://example.com");
+ assert!(!password);
+ assert_eq!(rect, (10.0, 20.0, 120.0, 24.0));
+ assert_eq!(value, "me");
+ assert!(!moved);
+ }
+ other => panic!("expected a field event, got {other:?}"),
+ }
+ assert!(matches!(parse_event(r#"{"t":"blur"}"#), Some(FormEvent::Blur)));
+ assert!(matches!(
+ parse_event(r#"{"t":"input","kind":"pass","origin":"x","rect":[0,0,1,1],"value":""}"#),
+ Some(FormEvent::Field { password: true, moved: true, .. })
+ ));
+ // Nonsense, and a field event with no rect, are both ignored.
+ assert!(parse_event("not json").is_none());
+ assert!(parse_event(r#"{"t":"focus","kind":"user"}"#).is_none());
+ assert!(parse_event(r#"{"t":"evil"}"#).is_none());
+ }
+}
diff --git a/src/wpe/host.rs b/src/wpe/host.rs
index 8f59818..489f6d3 100644
--- a/src/wpe/host.rs
+++ b/src/wpe/host.rs
@@ -142,6 +142,10 @@ pub struct WebKitHost {
download_started: Rc<Cell<bool>>,
/// A page asked something and is blocked until we answer.
prompts: Rc<RefCell<Prompts>>,
+ /// The injected account watcher, kept so the setting can take it away
+ /// again. `None` when account autocomplete is off, which is also when no
+ /// page carries the script at all.
+ watcher: Option<*mut WebKitUserScript>,
/// Retained only so tests can assert on rendered output; the registry
/// owns the copy that actually gets drawn.
last_frame: Option<(Vec<u8>, u32, u32)>,
@@ -291,13 +295,145 @@ impl WebKitHost {
prompts,
last_frame: None,
ucm: webkit_user_content_manager_new(),
+ watcher: None,
spare: None,
};
+ // The account watcher's channel, in its own script world. Both
+ // halves are registered here, once, on the shared content
+ // manager every tab is built against.
+ host.register_account_channel();
host.open_tab(url);
host
}
}
+ /// Listen for the account watcher's messages, in its private world.
+ ///
+ /// The world is the security boundary: page script cannot post on a
+ /// channel registered for another world, so a message arriving here came
+ /// from the injected watcher and not from the page pretending to be one.
+ fn register_account_channel(&self) {
+ use super::formwatch;
+ unsafe {
+ let name = cstr(formwatch::CHANNEL);
+ let world = cstr(formwatch::WORLD);
+ if webkit_user_content_manager_register_script_message_handler(
+ self.ucm,
+ name.as_ptr(),
+ world.as_ptr(),
+ ) == 0
+ {
+ log::warn!("could not register the account message channel");
+ return;
+ }
+ let signal = cstr(&format!("script-message-received::{}", formwatch::CHANNEL));
+ g_signal_connect_data(
+ self.ucm as *mut _,
+ signal.as_ptr(),
+ Some(std::mem::transmute::<usize, unsafe extern "C" fn()>(
+ on_account_message as *const () as usize,
+ )),
+ Rc::into_raw(self.prompts.clone()) as gpointer,
+ Some(drop_prompts_ref),
+ 0,
+ );
+ }
+ }
+
+ /// Install or remove the login-field watcher — the whole page-side
+ /// footprint of the feature, so a browser with accounts turned off
+ /// injects nothing at all.
+ pub fn set_accounts_enabled(&mut self, on: bool) {
+ use super::formwatch;
+ unsafe {
+ match (on, self.watcher.take()) {
+ (true, None) => {
+ let source = cstr(formwatch::WATCH_JS);
+ let world = cstr(formwatch::WORLD);
+ // Top frame only, and at document start so the listeners
+ // are in place before a login page's own script runs.
+ let script = webkit_user_script_new_for_world(
+ source.as_ptr(),
+ WebKitUserContentInjectedFrames::WEBKIT_USER_CONTENT_INJECT_TOP_FRAME,
+ WebKitUserScriptInjectionTime::WEBKIT_USER_SCRIPT_INJECT_AT_DOCUMENT_START,
+ world.as_ptr(),
+ std::ptr::null(),
+ std::ptr::null(),
+ );
+ webkit_user_content_manager_add_script(self.ucm, script);
+ self.watcher = Some(script);
+ }
+ (false, Some(script)) => {
+ webkit_user_content_manager_remove_script(self.ucm, script);
+ webkit_user_script_unref(script);
+ }
+ // Already in the asked-for state; `take` above is why the
+ // enabled case has to put its handle back.
+ (true, Some(script)) => self.watcher = Some(script),
+ (false, None) => {}
+ }
+ }
+ }
+
+ /// Nudge the watcher into re-reporting the focused login field, for when
+ /// the chrome has something to offer that it did not have a moment ago.
+ pub fn request_form_state(&self) {
+ if self.watcher.is_none() {
+ return;
+ }
+ unsafe {
+ let source = cstr(super::formwatch::RESCAN_JS);
+ let world = cstr(super::formwatch::WORLD);
+ webkit_web_view_evaluate_javascript(
+ self.active_tab().webview,
+ source.as_ptr(),
+ -1,
+ world.as_ptr(),
+ std::ptr::null(),
+ std::ptr::null_mut(),
+ None,
+ std::ptr::null_mut(),
+ );
+ }
+ }
+
+ /// The next login-field event the watcher reported.
+ pub fn take_form_event(&self) -> Option<super::formwatch::FormEvent> {
+ self.prompts.borrow_mut().form_events.pop_front()
+ }
+
+ /// Drop anything the watcher reported for a page that is going away, so a
+ /// stale focus cannot open a list over the next one.
+ pub fn clear_form_events(&self) {
+ self.prompts.borrow_mut().form_events.clear();
+ }
+
+ /// Put a picked account into the page's login fields.
+ ///
+ /// Runs in the watcher's world, where the elements it recorded live and
+ /// where the page cannot have replaced the setter being used. The script
+ /// carries the credential, so it is built here and dropped immediately;
+ /// it is never logged, and `source_uri` is left null so it cannot show up
+ /// named in a devtools listing either.
+ pub fn fill_credentials(&self, username: &str, password: &str) {
+ use super::formwatch;
+ let script = formwatch::fill_js(username, password);
+ unsafe {
+ let source = cstr(&script);
+ let world = cstr(formwatch::WORLD);
+ webkit_web_view_evaluate_javascript(
+ self.active_tab().webview,
+ source.as_ptr(),
+ -1,
+ world.as_ptr(),
+ std::ptr::null(),
+ std::ptr::null_mut(),
+ None,
+ std::ptr::null_mut(),
+ );
+ }
+ }
+
fn build_webview(&self, url: &Url, state: &Rc<TabState>) -> (*mut WebKitWebView, *mut WPEView) {
unsafe {
let (p_display, p_ucm, p_session) = (
@@ -1195,6 +1331,10 @@ pub(super) struct Prompts {
auth: Option<(*mut WebKitAuthenticationRequest, PendingAuth)>,
/// The page asked for a context menu; the chrome draws its own.
context_menu: Option<ContextMenuInfo>,
+ /// Login fields the account watcher reported, oldest first. A queue and
+ /// not a slot: a blur followed by a focus is two different states, and
+ /// collapsing them would leave the list open over the wrong field.
+ form_events: std::collections::VecDeque<crate::wpe::formwatch::FormEvent>,
}
/// What was under the pointer when the page asked for a context menu, read
@@ -1245,6 +1385,29 @@ unsafe fn connect_raw(
);
}
+/// A message from the account watcher. Anything that does not parse as one of
+/// its events is dropped without comment — this is a channel the chrome acts
+/// on, so it accepts only what it recognizes.
+unsafe extern "C" fn on_account_message(
+ _ucm: *mut WebKitUserContentManager,
+ value: *mut JSCValue,
+ data: gpointer,
+) {
+ let prompts = &*(data as *const RefCell<Prompts>);
+ let raw = jsc_value_to_string(value);
+ let Some(json) = from_cstr(raw) else { return };
+ g_free(raw as *mut _);
+ if let Some(event) = super::formwatch::parse_event(&json) {
+ let mut p = prompts.borrow_mut();
+ // A page that spins on scroll must not grow this without bound; the
+ // chrome only ever cares about the last few.
+ if p.form_events.len() > 8 {
+ p.form_events.pop_front();
+ }
+ p.form_events.push_back(event);
+ }
+}
+
unsafe extern "C" fn drop_prompts_ref(data: gpointer, _c: *mut GClosure) {
drop(Rc::from_raw(data as *const RefCell<Prompts>));
}
diff --git a/src/wpe/mod.rs b/src/wpe/mod.rs
index 59405be..47645c7 100644
--- a/src/wpe/mod.rs
+++ b/src/wpe/mod.rs
@@ -13,7 +13,11 @@ mod subclass;
mod input;
mod glib_source;
mod host;
+/// The page half of account autocomplete: the watcher script, the fill
+/// script, and the events they exchange with the chrome.
+pub mod formwatch;
// Not consumed yet — main.rs still drives ServoHost.
#[allow(unused_imports)]
+pub use formwatch::FormEvent;
pub use host::{ContextMenuInfo, PendingAuth, PendingDialog, Tab, WebKitHost};