git.lucas.co / cce-browser
web browser (Servo)
git clone https://git.lucas.co/cce-browser.git

commit9dbefb1a10725f576a37daf874151495a87b5c33
parent128889c0d9
authorLucas Galante <lsgalante12@gmail.com>
date2026-10-02 12:24
feat(accounts): fill sign-in iframes, and offer to save new logins

Account autocomplete was top-frame only, so a sign-in form in a frame
of its own (iCloud's is idmsa.apple.com inside icloud.com) got nothing.
It also never saved a login; cce-secrets was the only way in.

Frames: the watcher now runs in every frame, still in its private world,
and fields are matched against the frame's own origin. For a frame from
another site the page's accounts are offered too, after the frame's,
under a "sign-in form from <host>" line. A frame places itself by relay:
it announces a random token to its parent by postMessage, each parent
adds the frame's offset by event.source, and the top reports it. A fill
is no longer a script: a focused field asks on a reply channel, and a
pick answers exactly the ask the list was opened for, with the
credential as data.

The channels are shared by every tab and say nothing about which spoke,
so watchers report and ask only while document.hasFocus() (only the
shown tab can), every document carries a random token, and a tab switch
drops queued events and open asks.

Saving: a sign-in going out (a form's submit, which fires only after the
page's validation, or Enter / a sign-in button on formless pages) is
held as an offer, shown only when the index has no entry for that site
and username and the site is not on the never list. Save writes what
cce-secrets writes (default collection, UserName, URL = form origin,
text/plain, never replacing); a locked collection is refused. Never is
per form host, in never-save.txt.

examples/wpe_autofill.rs checks the engine side across a cross-origin
frame; the chrome side was exercised end to end in a shadow session
against an isolated keyring.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

 CLAUDE.md                |  91 ++++++--
 WPE-PORT.md              |   1 +
 examples/wpe_autofill.rs | 230 +++++++++++++++++++
 src/accounts.rs          | 153 ++++++++++++-
 src/main.rs              | 558 ++++++++++++++++++++++++++++++++++++++++++-----
 src/wpe/formwatch.rs     | 422 +++++++++++++++++++++++++----------
 src/wpe/host.rs          | 187 ++++++++++++----
 src/wpe/mod.rs           |   4 +-
 8 files changed, 1415 insertions(+), 231 deletions(-)

diff --git a/CLAUDE.md b/CLAUDE.md
index bd38379..f1bd257 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -27,8 +27,8 @@ Sixteen files, ~8.8k lines. The ten that carry the design:
 | `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 |
+| `src/accounts.rs` | accounts from cce-secrets: the Secret Service worker, which entries a host earns, saving a new login, and the never-save list |
+| `src/wpe/formwatch.rs` | the page half of account autocomplete: the watcher every frame runs (fields, frame-offset relay, fill asks, sign-in capture) and the events it sends |
 
 ## Build
 
@@ -405,7 +405,9 @@ own multiplier on a precise delta; the direct path has always moved that far.)
 ## 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
+site; picking one fills the username and password. A sign-in the keyring does
+not know yet is offered for saving (Save / Never / Not now), and a saved entry
+is offered from then on. 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
@@ -423,11 +425,33 @@ The security shape is the design, not decoration:
   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.
+- **Every frame, matched by the frame's own origin.** Sign-in forms are often
+  an iframe from another site (iCloud's is `idmsa.apple.com` inside
+  `icloud.com`), and that frame is where the password goes, so accounts are
+  matched against the *frame's* host. Inside the private world
+  `location.origin` is the frame's real origin, so it can be believed; a top
+  frame must still be on the tab's own host. For a frame from another site the
+  page's own accounts are offered too, after the frame's, under a "sign-in form
+  from <host>" line — a deliberate widening (it would hand the page's password
+  to the embedded site if picked) that is what makes iCloud work with an
+  `icloud.com` entry.
+- **A frame places itself by relay.** It knows its field only in its own
+  viewport, so each frame announces a random token to its parent with
+  `postMessage`; each parent's watcher finds the sending frame by
+  `event.source`, adds its offset and passes it up, and the top reports a
+  `Frame` offset the chrome keeps in `frame_offsets`. Only geometry travels
+  that way (a forgery misplaces the list, nothing more), and the watcher
+  swallows those messages so the page never sees them.
+- **A fill is an answer, not a script.** The chrome can evaluate script only
+  in the top frame, so a focused field *asks* to be filled on a reply channel
+  (`FILL_CHANNEL`); the host holds the newest asks by token and a pick answers
+  exactly the one the list was opened for, with the credential as data. No
+  password is ever spliced into script source.
+- **Only a focused document speaks.** The channels are shared by every tab and
+  say nothing about which one spoke, so watchers report and ask only while
+  `document.hasFocus()` — true only in the shown tab, now that the window's
+  focus reaches the page (`WebKitHost::focus`) — and a tab switch drops every
+  queued event and open ask.
 - **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
@@ -438,8 +462,9 @@ The security shape is the design, not decoration:
   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.
+  unless the list is still open, still holds that account, the tab is still
+  on the host it was opened for, and the asking document's token still has
+  an ask open.
 - **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
@@ -454,9 +479,11 @@ Things that were learned the hard way and are easy to undo:
   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.
+  is, on a page that autofocuses. The chrome keeps its own copy of the last
+  field report (`last_field`) and replays it when the index arrives; without
+  that the first login form of a session silently gets nothing. (A replay, not
+  a rescan script, because the field may be in a frame the chrome cannot run
+  script in.)
 - **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
@@ -470,11 +497,42 @@ Things that were learned the hard way and are easy to undo:
   *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).
 
+### Saving a new login
+
+The watcher reports a sign-in going out — a form's `submit` (which fires only
+once the page's own validation passed), or, on the many pages with no form,
+Enter in a login field or a button that says it signs in — with the username
+and password. The chrome holds it as a `SaveOffer` and asks only when the
+index has no entry for that site with that username (the same set the field
+would have been offered, so filling from the page's entry into a sign-in frame
+is not "new"), and the site is not on the never list. Points that are choices:
+
+- **It writes what cce-secrets writes**: default collection, the page's host as
+  the title, `UserName`, `URL` = the *form's* origin (where it will be offered
+  next), `text/plain`, never replacing an item. cce-keyring-sync adopts it like
+  any keyring-born entry.
+- **A locked collection is refused, not unlocked** — same rule as listing.
+- **The offer outlives the page.** It sits in the dot's corner, survives the
+  navigation a sign-in usually causes, and waits for an answer; it is not
+  modal, and only a press on its own plate is its.
+- **"Never" is per form host**, in `~/.local/state/cce/browser/never-save.txt`.
+  There is no UI to undo it yet; delete the line.
+- The typed password crosses the watcher's channel as `formwatch::Password` and
+  lives as `accounts::Secret` — both print redacted — and only until answered.
+- An existing entry is never updated (a changed password is not detected);
+  that needs comparing secrets, which this deliberately never fetches unasked.
+
 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.
+so serve the fixture over http on localhost (`127.0.0.1` and `localhost` are
+two origins, which is a cross-site sign-in frame for free). Two traps: give
+`gnome-keyring-daemon` a **short** `-C` control directory (a long scratch path
+overflows the 108-byte socket path and fails as "Address already in use"), and
+run it `--foreground` in the background so one process owns the bus name.
+`examples/wpe_autofill.rs` covers the engine side (frames, relay, fill, submit,
+focus gating) with no keyring at all.
 
 ## `cce://` pages
 
@@ -575,9 +633,8 @@ clipboard path as the rest of the DE.
 
 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
+WPE backend and are Servo-only gaps now.) Account autocomplete saves new logins but
+never updates a changed password, and "never save" has no undo UI. 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/WPE-PORT.md b/WPE-PORT.md
index 1569543..caa61fa 100644
--- a/WPE-PORT.md
+++ b/WPE-PORT.md
@@ -333,6 +333,7 @@ The examples are the test suite; all need `--features wpe`.
 | `wpe_host` | boot, frames, page state, navigation, history |
 | `wpe_input` | pointer / keyboard / wheel reaching the page, read back via `document.title` |
 | `wpe_tabs` | several views on one display, and a **backgrounded** tab still updating |
+| `wpe_autofill` | account autocomplete across a cross-origin frame: origin, offset relay, fill by token, submit capture, focus gating |
 | `wpe_focus` | window focus reaching every tab's page (`document.hasFocus()`), the condition WebKit paints the text caret on |
 | `wpe_loop` | blocking on GLib's fds vs polling, with the wakeup counts |
 | `wpe_dark` | force-dark, asserted on rendered pixels rather than on the call |
diff --git a/examples/wpe_autofill.rs b/examples/wpe_autofill.rs
new file mode 100644
index 0000000..2cf1964
--- /dev/null
+++ b/examples/wpe_autofill.rs
@@ -0,0 +1,230 @@
+//! Proves the page half of account autocomplete across a frame boundary.
+//!
+//! Serves a page on `127.0.0.1` with a sign-in form in an iframe from
+//! `localhost` — two origins, the shape of iCloud's sign-in — and checks,
+//! against the real engine:
+//!
+//! * a focused field in the frame is reported with the **frame's** origin,
+//!   and the frame's offset in the page arrives as a `Frame` event;
+//! * a fill answered to that frame's token lands in the frame's fields,
+//!   quotes and all;
+//! * pressing the sign-in button reports the credential for saving;
+//! * the relay's `postMessage` traffic never reaches the page's own script;
+//! * a document without focus (a background tab, a window the person left)
+//!   reports nothing.
+//!
+//! No keyring is involved: this is the engine side only.
+//!
+//! `cargo run --release -p cce-browser --example wpe_autofill`
+
+#[cfg(not(feature = "wpe"))]
+fn main() {
+    eprintln!("build with --features wpe");
+}
+
+#[cfg(feature = "wpe")]
+#[derive(Debug, Clone, Copy)]
+pub enum EditingCommand { Copy, Cut, Paste }
+
+#[cfg(feature = "wpe")]
+#[path = "../src/pages.rs"]
+mod pages;
+#[cfg(feature = "wpe")]
+#[path = "../src/downloads.rs"]
+mod downloads;
+#[cfg(feature = "wpe")]
+#[path = "../src/wpe/mod.rs"]
+mod wpe;
+
+/// Quotes, a backslash and a closing script tag: everything that would end a
+/// string spliced into script source. (No newline — a password field strips
+/// line breaks, so one could never round-trip.)
+const PASSWORD: &str = "s3\"cr\\et</script>";
+
+/// The embedding page: the frame sits at a known place, behind a border and
+/// padding, so the reported offset can be checked to the pixel.
+const TOP: &str = r#"<!doctype html><html><body style="margin:0">
+<form onsubmit="event.preventDefault()">
+  <input id="tu" type="email" style="position:absolute; left:10px; top:10px; width:200px; height:24px">
+  <input id="tp" type="password" style="position:absolute; left:10px; top:50px; width:200px; height:24px">
+</form>
+<iframe id="f" src="http://localhost:PORT/frame"
+  style="position:absolute; left:100px; top:150px; width:400px; height:200px;
+         border:5px solid #888; padding:7px"></iframe>
+<script>
+  // The page's own listener: it must see the frame's probes, never the
+  // watcher's relay.
+  const tu = document.getElementById('tu'), tp = document.getElementById('tp');
+  tp.addEventListener('input', () => { document.title = 'top:' + tu.value + '|' + tp.value; });
+  window.addEventListener('message', (e) => {
+    if (e.data && e.data.cceAccountsFrame) { document.title += ' LEAK'; return; }
+    if (e.data && e.data.probe !== undefined) document.title = 'frame:' + e.data.probe;
+  });
+</script></body></html>"#;
+
+/// The sign-in frame. Its own script reports what its fields hold, which is
+/// how the test reads a fill back out of a cross-origin frame.
+const FRAME: &str = r#"<!doctype html><html><body style="margin:0">
+<form onsubmit="event.preventDefault()">
+  <input id="u" name="username" style="position:absolute; left:10px; top:10px; width:200px; height:24px">
+  <input id="p" type="password" style="position:absolute; left:10px; top:50px; width:200px; height:24px">
+  <button id="go" type="submit" style="position:absolute; left:10px; top:90px; width:80px; height:24px">Sign in</button>
+</form>
+<script>
+  const u = document.getElementById('u'), p = document.getElementById('p');
+  const tell = () => parent.postMessage({ probe: u.value + '|' + p.value }, '*');
+  u.addEventListener('input', tell); p.addEventListener('input', tell);
+</script></body></html>"#;
+
+#[cfg(feature = "wpe")]
+fn serve() -> u16 {
+    use std::io::{Read, Write};
+    let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
+    let port = listener.local_addr().unwrap().port();
+    std::thread::spawn(move || {
+        for stream in listener.incoming() {
+            let Ok(mut s) = stream else { continue };
+            let mut buf = [0u8; 4096];
+            let n = s.read(&mut buf).unwrap_or(0);
+            let req = String::from_utf8_lossy(&buf[..n]);
+            let body = if req.starts_with("GET /frame") { FRAME.to_string() } else {
+                TOP.replace("PORT", &port.to_string())
+            };
+            let _ = write!(
+                s,
+                "HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
+                body.len(),
+                body
+            );
+        }
+    });
+    port
+}
+
+#[cfg(feature = "wpe")]
+fn main() {
+    use cce_ui::widget::MouseButton;
+    use wpe::FormEvent;
+
+    let port = serve();
+    let mut host = wpe::WebKitHost::new(url::Url::parse("about:blank").unwrap(), (800, 600));
+    host.set_accounts_enabled(true);
+    host.focus(true);
+    let settle = |h: &mut wpe::WebKitHost, n: u32| {
+        for _ in 0..n {
+            h.pump();
+            std::thread::sleep(std::time::Duration::from_millis(50));
+        }
+    };
+    let drain = |h: &wpe::WebKitHost| {
+        let mut out = Vec::new();
+        while let Some(e) = h.take_form_event() {
+            out.push(e);
+        }
+        out
+    };
+    let click = |h: &mut wpe::WebKitHost, x: f32, y: f32| {
+        h.mouse_move(x, y);
+        h.mouse_button_ui(MouseButton::Left, true, x, y);
+        h.mouse_button_ui(MouseButton::Left, false, x, y);
+    };
+
+    settle(&mut host, 10);
+    host.load(url::Url::parse(&format!("http://127.0.0.1:{port}/top")).unwrap());
+    settle(&mut host, 40);
+    drain(&host);
+
+    let mut ok = true;
+    let mut check = |what: &str, pass: bool, detail: String| {
+        ok &= pass;
+        println!("{what:<44} {} {detail}", if pass { "OK   " } else { "WRONG" });
+    };
+
+    // The frame's content box starts at 100+5+7, 150+5+7; its username
+    // field at 10,10 inside that.
+    click(&mut host, 112.0 + 50.0, 162.0 + 20.0);
+    settle(&mut host, 10);
+    let events = drain(&host);
+    let field = events.iter().find_map(|e| match e {
+        FormEvent::Field { origin, frame, top, password, rect, moved: false, .. } => {
+            Some((origin.clone(), frame.clone(), *top, *password, *rect))
+        }
+        _ => None,
+    });
+    let token = field.as_ref().map(|f| f.1.clone()).unwrap_or_default();
+    check(
+        "frame field reported with the frame's origin",
+        field.as_ref().is_some_and(|f| {
+            f.0 == format!("http://localhost:{port}") && !f.2 && !f.3 && f.4.0 == 10.0 && f.4.1 == 10.0
+        }),
+        format!("{field:?}"),
+    );
+    let offset = events.iter().find_map(|e| match e {
+        FormEvent::Frame { frame, offset } if *frame == token => Some(*offset),
+        _ => None,
+    });
+    check("frame offset relayed to the top", offset == Some((112.0, 162.0)), format!("{offset:?}"));
+
+    let filled = host.fill_credentials(&token, "alice", PASSWORD);
+    settle(&mut host, 10);
+    let title = host.title().unwrap_or_default();
+    check(
+        "fill answered to the frame's token",
+        filled && title == format!("frame:alice|{PASSWORD}"),
+        format!("{title:?}"),
+    );
+    check("a token nobody asked with fills nothing", !host.fill_credentials("00", "x", "y"), String::new());
+
+    click(&mut host, 112.0 + 40.0, 162.0 + 100.0);
+    settle(&mut host, 10);
+    let submit = drain(&host).into_iter().find_map(|e| match e {
+        FormEvent::Submit { origin, username, password, top, .. } => {
+            Some((origin, username, password.expose().to_string(), top))
+        }
+        _ => None,
+    });
+    check(
+        "sign-in button reports the credential",
+        submit.as_ref().is_some_and(|s| {
+            s.0 == format!("http://localhost:{port}") && s.1 == "alice" && s.2 == PASSWORD && !s.3
+        }),
+        format!("{:?}", submit.as_ref().map(|s| (&s.0, &s.1, s.3))),
+    );
+
+    check("the page never saw the relay", !host.title().unwrap_or_default().contains("LEAK"), String::new());
+
+    // The top frame takes the same path, with its own token.
+    click(&mut host, 60.0, 20.0);
+    settle(&mut host, 10);
+    let top = drain(&host).into_iter().find_map(|e| match e {
+        FormEvent::Field { frame, top: true, moved: false, origin, .. } => Some((frame, origin)),
+        _ => None,
+    });
+    check(
+        "top-frame field reported as the top",
+        top.as_ref().is_some_and(|t| t.1 == format!("http://127.0.0.1:{port}") && t.0.len() == 24),
+        format!("{top:?}"),
+    );
+    let top_token = top.map(|t| t.0).unwrap_or_default();
+    check("an answered ask cannot be answered again", !host.fill_credentials(&token, "x", "y"), String::new());
+    let filled = host.fill_credentials(&top_token, "bob@example.com", "pw");
+    settle(&mut host, 10);
+    let title = host.title().unwrap_or_default();
+    check("fill answered to the top's token", filled && title == "top:bob@example.com|pw", format!("{title:?}"));
+
+    // Without focus the document is not live: nothing is reported.
+    host.focus(false);
+    settle(&mut host, 4);
+    drain(&host);
+    click(&mut host, 112.0 + 50.0, 162.0 + 60.0);
+    settle(&mut host, 10);
+    let quiet = drain(&host);
+    check(
+        "an unfocused document reports no fields",
+        !quiet.iter().any(|e| matches!(e, FormEvent::Field { .. })),
+        format!("{} events", quiet.len()),
+    );
+
+    println!("\nautofill: {}", if ok { "OK" } else { "BROKEN" });
+    std::process::exit(if ok { 0 } else { 1 });
+}
diff --git a/src/accounts.rs b/src/accounts.rs
index 223e93e..e93ed34 100644
--- a/src/accounts.rs
+++ b/src/accounts.rs
@@ -7,13 +7,19 @@
 //! 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.
+//! Three 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`.
 //!
+//! **Saving writes what cce-secrets writes.** A new login goes into the
+//! default collection with the same shape cce-secrets' own "new entry" form
+//! produces — the label as the title, `UserName` and `URL` attributes, a
+//! `text/plain` secret — so it lists there, and cce-keyring-sync adopts it
+//! like any keyring-born entry.
+//!
 //! **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
@@ -46,6 +52,12 @@ impl Secret {
     }
 }
 
+impl From<String> for Secret {
+    fn from(s: String) -> Self {
+        Self(s)
+    }
+}
+
 impl std::fmt::Debug for Secret {
     fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
         f.write_str("Secret(…)")
@@ -149,6 +161,19 @@ enum Request {
     Load,
     /// Fetch one entry's password, by object path.
     Fetch(String),
+    /// Write a new entry.
+    Save(NewLogin),
+}
+
+/// A login to write to the keyring, as the person agreed to save it.
+#[derive(Clone, Debug)]
+pub struct NewLogin {
+    /// The entry's title.
+    pub label: String,
+    pub username: String,
+    /// The origin the credential was typed into: what it will be offered on.
+    pub url: String,
+    pub password: Secret,
 }
 
 /// The account index, and the thread that reads it.
@@ -212,6 +237,32 @@ impl Accounts {
         let _ = self.tx.send(Request::Fetch(path.to_string()));
     }
 
+    /// Write a new entry. The outcome comes back as [`Message::Saved`], and
+    /// a successful save re-reads the index so the entry is offered at once.
+    pub fn save(&mut self, login: NewLogin) {
+        let _ = self.tx.send(Request::Save(login));
+    }
+
+    /// Forget the index, so the next [`Accounts::ensure_loaded`] reads it
+    /// again — after a save, which changed it.
+    pub fn invalidate(&mut self) {
+        self.loaded = false;
+    }
+
+    /// Whether the index has been read (successfully or not) and nothing is
+    /// in flight — when a question about what it holds can be answered.
+    pub fn is_ready(&self) -> bool {
+        self.loaded && !self.loading
+    }
+
+    /// Whether `username` already has an entry among `accounts` — what
+    /// decides that a sign-in is not new. Case-insensitive, since sites are,
+    /// and an entry with no username counts for an empty one.
+    pub fn knows(accounts: &[Account], username: &str) -> bool {
+        let wanted = username.trim().to_lowercase();
+        accounts.iter().any(|a| a.username.trim().to_lowercase() == wanted)
+    }
+
     /// 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> {
@@ -262,6 +313,9 @@ fn worker(rx: mpsc::Receiver<Request>, tx: calloop::channel::Sender<Message>) {
                     let _ = tx.send(Message::Credential(path, secret));
                 }
             }
+            Request::Save(login) => {
+                let _ = tx.send(Message::Saved(save(ss, &login)));
+            }
         }
     }
 }
@@ -306,6 +360,79 @@ fn load(ss: &secret_service::blocking::SecretService) -> Result<Vec<Account>, St
     Ok(accounts)
 }
 
+/// Write one entry to the default collection — cce-secrets' own target, and
+/// the one cce-keyring-sync syncs.
+///
+/// A locked collection is refused rather than unlocked, for the same reason
+/// listing skips one: the browser does not ask for the keyring password.
+/// It is normally unlocked at login, so this is rare and says what to do.
+/// `replace` is false: an existing entry is never overwritten from here.
+fn save(ss: &secret_service::blocking::SecretService, login: &NewLogin) -> Result<String, String> {
+    let collection = ss
+        .get_default_collection()
+        .map_err(|e| format!("no default keyring collection: {e}"))?;
+    if collection.is_locked().unwrap_or(true) {
+        return Err("the keyring is locked — unlock it in cce-secrets, then sign in again".into());
+    }
+    let mut attrs = std::collections::HashMap::new();
+    if !login.username.is_empty() {
+        attrs.insert("UserName", login.username.as_str());
+    }
+    if !login.url.is_empty() {
+        attrs.insert("URL", login.url.as_str());
+    }
+    collection
+        .create_item(&login.label, attrs, login.password.expose().as_bytes(), false, "text/plain")
+        .map(|_| login.label.clone())
+        // The error text names the operation, not the secret.
+        .map_err(|e| format!("could not save to the keyring: {e}"))
+}
+
+/// Hosts the person has said never to offer saving on. One host per line in
+/// `~/.local/state/cce/browser/never-save.txt`, beside history and bookmarks.
+pub struct NeverSave {
+    path: std::path::PathBuf,
+    hosts: Vec<String>,
+}
+
+impl NeverSave {
+    pub fn load() -> Self {
+        Self::at(crate::pages::state_dir().join("never-save.txt"))
+    }
+
+    fn at(path: std::path::PathBuf) -> Self {
+        let hosts = std::fs::read_to_string(&path)
+            .map(|s| {
+                s.lines()
+                    .map(|l| normalize_host(l))
+                    .filter(|l| !l.is_empty())
+                    .collect()
+            })
+            .unwrap_or_default();
+        Self { path, hosts }
+    }
+
+    pub fn contains(&self, host: &str) -> bool {
+        self.hosts.contains(&normalize_host(host))
+    }
+
+    pub fn add(&mut self, host: &str) {
+        let host = normalize_host(host);
+        if host.is_empty() || self.hosts.contains(&host) {
+            return;
+        }
+        self.hosts.push(host);
+        if let Some(dir) = self.path.parent() {
+            let _ = std::fs::create_dir_all(dir);
+        }
+        let mut body = self.hosts.join("\n");
+        body.push('\n');
+        if let Err(e) = std::fs::write(&self.path, body) {
+            log::warn!("could not write {}: {e}", self.path.display());
+        }
+    }
+}
+
 /// 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.
@@ -376,6 +503,30 @@ mod tests {
         assert_eq!(registrable_label("localhost"), None);
     }
 
+    #[test]
+    fn a_known_username_is_not_new() {
+        let mut a = account("Example", "https://example.com/");
+        a.username = "Me@Example.com".into();
+        assert!(Accounts::knows(&[a.clone()], " me@example.com"));
+        assert!(!Accounts::knows(&[a], "someone@example.com"));
+        assert!(!Accounts::knows(&[], "me"));
+    }
+
+    #[test]
+    fn never_save_persists_hosts() {
+        let dir = std::env::temp_dir().join(format!("cce-never-save-{}", std::process::id()));
+        let path = dir.join("never-save.txt");
+        let mut n = NeverSave::at(path.clone());
+        assert!(!n.contains("example.com"));
+        n.add("WWW.Example.com");
+        n.add("example.com");
+        assert!(n.contains("example.com"));
+        let again = NeverSave::at(path.clone());
+        assert!(again.contains("www.example.com"), "www is not a different site");
+        assert_eq!(std::fs::read_to_string(&path).unwrap(), "example.com\n");
+        let _ = std::fs::remove_dir_all(dir);
+    }
+
     #[test]
     fn a_password_never_prints_itself() {
         let s = Secret("hunter2".to_string());
diff --git a/src/main.rs b/src/main.rs
index 8cfbdeb..731a680 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -342,12 +342,14 @@ enum BmHit {
 /// 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
+/// follows it when the page scrolls, and goes when focus does. Only accounts
+/// matching the site the field sends to are in it — plus, for a sign-in frame
+/// from another site, the page's own, marked as such — 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.
+    /// Matches for this field, before filtering: the form's site's first,
+    /// then — for a sign-in frame from another site — the page's.
     all: Vec<accounts::Account>,
     /// What survives what has been typed into the username field.
     shown: Vec<accounts::Account>,
@@ -355,11 +357,19 @@ struct AcMenu {
     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.
+    /// The field, in its own frame's viewport coordinates. The top frame's
+    /// are the chrome's; a child frame's are moved by `frame_offsets`.
+    field: Rect,
+    /// The reporting document's token: where the fill goes, and whose
+    /// offset places the list.
+    frame: String,
+    top: bool,
+    /// The host of the frame the field is in — where the password would go.
+    /// Differs from `host` only for a sign-in frame from another site.
+    form_host: String,
+    /// The tab's host when this list was built. 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.
@@ -368,6 +378,60 @@ struct AcMenu {
     hover: Option<usize>,
 }
 
+/// A sign-in the keyring does not know yet, offered for saving.
+///
+/// Not modal and not tied to the page: the sign-in it came from usually
+/// navigates away at once, and the offer has to outlive that. It waits in the
+/// corner for an answer — Save, Never for this site, or Not now — and holds
+/// the typed password only until then.
+#[cfg(feature = "wpe")]
+struct SaveOffer {
+    /// The frame's origin, stored as the entry's `URL`: the place the
+    /// credential was typed into, so it is offered there next time.
+    origin: String,
+    /// Host of `origin`, and what "Never" remembers.
+    form_host: String,
+    /// The tab's host, which titles the entry: the site the person was
+    /// signing in to, even when the form was a frame from somewhere else.
+    page_host: String,
+    username: String,
+    password: accounts::Secret,
+    stage: SaveStage,
+}
+
+#[cfg(feature = "wpe")]
+#[derive(Clone, PartialEq)]
+enum SaveStage {
+    /// The account index is still being read; whether this is new is not
+    /// known yet, so nothing is shown.
+    Checking,
+    Asking,
+    Saving,
+    Failed(String),
+}
+
+#[cfg(feature = "wpe")]
+const SAVE_W: f32 = 340.0;
+#[cfg(feature = "wpe")]
+const SAVE_BTN_W: f32 = 92.0;
+
+/// The save offer's plate and buttons, from `save_layout`: the one geometry
+/// draw and hit-test read.
+#[cfg(feature = "wpe")]
+struct SaveLayout {
+    plate: Rect,
+    /// Save, Never, Not now — or only the last, as Close, after a failure.
+    buttons: Vec<(Rect, SaveButton)>,
+}
+
+#[cfg(feature = "wpe")]
+#[derive(Clone, Copy, PartialEq)]
+enum SaveButton {
+    Save,
+    Never,
+    Dismiss,
+}
+
 #[cfg(feature = "wpe")]
 impl AcMenu {
     /// Narrow the list to what has been typed. Matching is on the username
@@ -439,6 +503,8 @@ pub enum Message {
     /// One entry's password arrived for the account at this object path.
     /// The payload prints as `Secret(…)`; see `accounts::Secret`.
     Credential(String, accounts::Secret),
+    /// A save to the keyring finished: the new entry's title, or why not.
+    Saved(Result<String, String>),
     /// Servo requested an event-loop spin (waker or delegate signal).
     Spin,
     /// Last tab closed: exit the app.
@@ -523,6 +589,20 @@ struct BrowserApp {
     scroll_sent: (f32, f32),
     /// Accounts from cce-secrets, and the worker that reads them.
     accounts: accounts::Accounts,
+    /// Hosts the person said never to offer saving on.
+    never_save: accounts::NeverSave,
+    /// A new sign-in waiting for Save / Never / Not now.
+    #[cfg(feature = "wpe")]
+    save_offer: Option<SaveOffer>,
+    /// Where each child frame's viewport sits in the top frame's, by the
+    /// frame's token, as the watchers relay it. Cleared on navigation.
+    #[cfg(feature = "wpe")]
+    frame_offsets: std::collections::HashMap<String, (f32, f32)>,
+    /// The last login field reported, from any frame — replayed when the
+    /// account index finishes loading, since a field focused before then got
+    /// no list and will not report itself again.
+    #[cfg(feature = "wpe")]
+    last_field: Option<wpe::FormEvent>,
     /// The open account list, if a login field is focused and something in
     /// the keyring matches the page.
     #[cfg(feature = "wpe")]
@@ -825,11 +905,56 @@ impl BrowserApp {
         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 accounts a field earns, best first.
     ///
-    /// 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.
+    /// A field is offered the accounts of the site it sends to: `form_host`,
+    /// the frame it lives in. When that frame is from another site than the
+    /// page — iCloud's sign-in is `idmsa.apple.com` inside `icloud.com` — the
+    /// page's own accounts are offered too, after the frame's and marked with
+    /// after the frame's, because the entry people have is usually for the
+    /// site they typed, and the form's host is an implementation detail of it.
+    /// That is a real widening: it hands the page's password to an embedded
+    /// site if picked. It is never automatic, the list says which site the
+    /// form is from (`ac_notes`), and an ad frame with a password field is
+    /// not what sits inside the sites this is for.
+    #[cfg(feature = "wpe")]
+    fn offered(&self, form_host: &str, page_host: &str) -> Vec<accounts::Account> {
+        let mut all = self.accounts.matching(form_host);
+        if form_host != page_host {
+            for a in self.accounts.matching(page_host) {
+                if !all.iter().any(|b| b.path == a.path) {
+                    all.push(a);
+                }
+            }
+        }
+        all
+    }
+
+    /// The lines under the account list, each with its colour: whose form
+    /// this is, when it is not the page's own, and whether the password
+    /// would cross the network in the clear.
+    #[cfg(feature = "wpe")]
+    fn ac_notes(menu: &AcMenu) -> Vec<(String, [u8; 3])> {
+        let mut notes = Vec::new();
+        if menu.form_host != menu.host {
+            notes.push((format!("sign-in form from {}", menu.form_host), TEXT_DIM));
+        }
+        if menu.insecure {
+            notes.push((
+                "insecure page — this password would be sent unencrypted".to_string(),
+                [212, 155, 155],
+            ));
+        }
+        notes
+    }
+
+    /// A login-field event from a watcher. Open, move, refill or close the
+    /// account list; place child frames; take a sign-in for saving.
+    ///
+    /// Which frame spoke is believable — the watchers run in a world the page
+    /// cannot reach, and report their own `location.origin` — so the guard
+    /// is: a top frame must be on the tab's own host, and a child frame is
+    /// matched against its own.
     #[cfg(feature = "wpe")]
     fn on_form_event(&mut self, event: wpe::FormEvent) -> bool {
         use wpe::FormEvent;
@@ -837,14 +962,38 @@ impl BrowserApp {
             return false;
         }
         match event {
-            FormEvent::Blur => {
-                let was = self.ac_menu.is_some();
-                self.ac_menu = None;
+            FormEvent::Blur { frame } => {
+                // Only the field's own frame can close its list: focus moving
+                // from one frame to another blurs one and focuses the other,
+                // and the two reports can arrive in either order.
+                let was = self.ac_menu.as_ref().is_some_and(|m| m.frame == frame);
+                if was {
+                    self.ac_menu = None;
+                }
+                if self.last_field.as_ref().is_some_and(
+                    |e| matches!(e, FormEvent::Field { frame: f, .. } if *f == frame),
+                ) {
+                    self.last_field = None;
+                }
                 was
             }
-            FormEvent::Field { origin, password, rect, value, moved } => {
+            FormEvent::Frame { frame, offset } => {
+                // A page could invent frames; a bound keeps that from growing.
+                if self.frame_offsets.len() >= 32 && !self.frame_offsets.contains_key(&frame) {
+                    self.frame_offsets.clear();
+                }
+                let changed = self.frame_offsets.insert(frame.clone(), offset) != Some(offset);
+                changed && self.ac_menu.as_ref().is_some_and(|m| m.frame == frame)
+            }
+            FormEvent::Submit { origin, top, username, password, .. } => {
+                self.on_submit(origin, top, username, password.into_inner().into());
+                self.save_offer
+                    .as_ref()
+                    .is_some_and(|o| o.stage != SaveStage::Checking)
+            }
+            FormEvent::Field { ref origin, ref frame, top, password, rect, ref value, moved } => {
                 log::debug!(
-                    "login field: password={password} moved={moved} origin={origin} \
+                    "login field: password={password} moved={moved} top={top} origin={origin} \
                      page={:?} rect={rect:?}",
                     self.page_host()
                 );
@@ -852,30 +1001,38 @@ impl BrowserApp {
                     self.ac_menu = None;
                     return false;
                 };
-                let same_origin = url::Url::parse(&origin)
+                let Some(form_host) = url::Url::parse(origin)
                     .ok()
-                    .and_then(|u| u.host_str().map(|h| h == host))
-                    .unwrap_or(false);
-                if !same_origin {
+                    .and_then(|u| u.host_str().map(str::to_string))
+                else {
+                    self.ac_menu = None;
+                    return false;
+                };
+                if top && form_host != host {
                     self.ac_menu = None;
                     return false;
                 }
+                let (frame, filter, insecure) = (
+                    frame.clone(),
+                    // A password field filters by nothing; a username field
+                    // by what is in it.
+                    if password { String::new() } else { value.clone() },
+                    insecure_origin(origin, &form_host),
+                );
+                self.last_field = Some(event);
                 // 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 };
+                let field = self.field_rect(rect);
+                let all = self.offered(&form_host, &host);
+                log::debug!("{} accounts match {form_host} (page {host})", all.len());
                 match self.ac_menu.as_mut() {
-                    Some(menu) if moved => {
-                        menu.anchor = anchor;
+                    Some(menu) if moved && menu.frame == frame => {
+                        menu.field = field;
                         menu.all = all;
                         menu.host = host.clone();
+                        menu.form_host = form_host;
                         menu.insecure = insecure;
                         menu.refilter(&filter);
                     }
@@ -885,7 +1042,10 @@ impl BrowserApp {
                             shown: Vec::new(),
                             selected: 0,
                             scroll: 0,
-                            anchor,
+                            field,
+                            frame,
+                            top,
+                            form_host,
                             host: host.clone(),
                             insecure,
                             hover: None,
@@ -904,6 +1064,167 @@ impl BrowserApp {
         }
     }
 
+    /// A sign-in went out. Hold it as an offer to save, unless it is already
+    /// in the keyring, or the site is on the never list.
+    ///
+    /// Whether it is new can only be answered once the index is read, so the
+    /// offer starts as `Checking` and `resolve_save` decides — now, or when
+    /// the index arrives.
+    #[cfg(feature = "wpe")]
+    fn on_submit(&mut self, origin: String, top: bool, username: String, password: accounts::Secret) {
+        let Some(page_host) = self.page_host() else { return };
+        let Some(form_host) = url::Url::parse(&origin)
+            .ok()
+            .and_then(|u| u.host_str().map(str::to_string))
+        else {
+            return;
+        };
+        // Same guard as a field: a top frame must be the tab's own.
+        if top && form_host != page_host {
+            return;
+        }
+        if self.never_save.contains(&form_host) {
+            return;
+        }
+        // The fill path's own submit — or the same sign-in reported twice —
+        // is not a second offer.
+        if self.save_offer.as_ref().is_some_and(|o| {
+            o.form_host == form_host && o.username == username && o.password == password
+        }) {
+            return;
+        }
+        self.save_offer = Some(SaveOffer {
+            origin,
+            form_host,
+            page_host,
+            username,
+            password,
+            stage: SaveStage::Checking,
+        });
+        self.accounts.ensure_loaded();
+        self.resolve_save();
+    }
+
+    /// Decide a `Checking` offer, once the index can answer: drop it when an
+    /// entry for that site already has this username, show it otherwise.
+    ///
+    /// "That site" is everything the field would have been offered, borrowed
+    /// entries included — a sign-in filled from the page's own entry into a
+    /// frame from another site is that entry, not a new login.
+    #[cfg(feature = "wpe")]
+    fn resolve_save(&mut self) {
+        let Some(offer) = self.save_offer.as_ref() else { return };
+        if offer.stage != SaveStage::Checking || !self.accounts.is_ready() {
+            return;
+        }
+        if let Some(e) = self.accounts.error.as_ref() {
+            // Nothing could be saved either; saying so on every sign-in
+            // would be noise. The load failure is already logged.
+            log::info!("not offering to save a login: the keyring is unavailable ({e})");
+            self.save_offer = None;
+            return;
+        }
+        let known = self.offered(&offer.form_host, &offer.page_host);
+        if accounts::Accounts::knows(&known, &offer.username) {
+            self.save_offer = None;
+        } else if let Some(o) = self.save_offer.as_mut() {
+            o.stage = SaveStage::Asking;
+        }
+    }
+
+    /// Where the save offer sits: in the corner the bar hangs from, under
+    /// the dot for a top bar and over it for a bottom one — or past the bar
+    /// itself while that is out — so it never covers the controls it sits by.
+    #[cfg(feature = "wpe")]
+    fn save_layout(&self) -> Option<SaveLayout> {
+        let offer = self.save_offer.as_ref()?;
+        if offer.stage == SaveStage::Checking {
+            return None;
+        }
+        let width = SAVE_W.min(self.win.0 - 2.0 * bar_margin()).max(220.0);
+        let height = plate_pad() * 2.0 + 20.0 + 18.0 + 18.0 + inner_gap() + BTN_H;
+        let (cx, cy) = self.dot_center();
+        let edge = cx + plate_dock::CORNER_R;
+        let x = (edge - width).clamp(0.0, (self.win.0 - width).max(0.0));
+        let gap = item_gap();
+        let y = match self.settings.bar_position {
+            settings::BarPosition::Top => {
+                let below = if self.chrome_t > 0.0 {
+                    let (bar, _) = self.chrome_plate();
+                    bar.y + bar.height
+                } else {
+                    cy + plate_dock::CORNER_R
+                };
+                below + gap
+            }
+            settings::BarPosition::Bottom => {
+                let above = if self.chrome_t > 0.0 {
+                    self.chrome_plate().0.y
+                } else {
+                    cy - plate_dock::CORNER_R
+                };
+                (above - gap - height).max(0.0)
+            }
+        };
+        let plate = Rect { x, y, width, height };
+        let by = plate.y + plate.height - plate_pad() - BTN_H;
+        let at = |k: f32| Rect {
+            x: plate.x + plate.width - plate_pad() - (k + 1.0) * SAVE_BTN_W - k * inner_gap(),
+            y: by,
+            width: SAVE_BTN_W,
+            height: BTN_H,
+        };
+        let buttons = match offer.stage {
+            SaveStage::Failed(_) => vec![(at(0.0), SaveButton::Dismiss)],
+            SaveStage::Saving => Vec::new(),
+            _ => vec![
+                (at(0.0), SaveButton::Save),
+                (at(1.0), SaveButton::Dismiss),
+                (at(2.0), SaveButton::Never),
+            ],
+        };
+        Some(SaveLayout { plate, buttons })
+    }
+
+    /// Act on a press at a point over the save offer. Returns whether the
+    /// offer took the press — anywhere on its plate does.
+    #[cfg(feature = "wpe")]
+    fn save_click(&mut self, x: f32, y: f32) -> bool {
+        let Some(layout) = self.save_layout() else { return false };
+        if !hit(&layout.plate, x, y) {
+            return false;
+        }
+        let Some((_, button)) = layout.buttons.iter().find(|(r, _)| hit(r, x, y)) else {
+            return true;
+        };
+        match button {
+            SaveButton::Save => {
+                if let Some(offer) = self.save_offer.as_mut() {
+                    offer.stage = SaveStage::Saving;
+                    let label = offer
+                        .page_host
+                        .strip_prefix("www.")
+                        .unwrap_or(&offer.page_host)
+                        .to_string();
+                    let login = accounts::NewLogin {
+                        label,
+                        username: offer.username.clone(),
+                        url: offer.origin.clone(),
+                        password: offer.password.clone(),
+                    };
+                    self.accounts.save(login);
+                }
+            }
+            SaveButton::Never => {
+                if let Some(offer) = self.save_offer.take() {
+                    self.never_save.add(&offer.form_host);
+                }
+            }
+            SaveButton::Dismiss => self.save_offer = 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
@@ -930,15 +1251,19 @@ impl BrowserApp {
             .as_ref()
             .zip(self.page_host())
             .is_some_and(|(menu, host)| menu.host == host);
-        let username = self
+        let target = 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"),
+            .and_then(|m| m.shown.iter().find(|a| a.path == path).map(|a| (a, &m.frame)))
+            .map(|(a, frame)| (a.username.clone(), frame.clone()));
+        // The fill goes to the ask of the document the list was opened for,
+        // and only that one; if it has gone, so does the credential.
+        let filled = target.is_some_and(|(username, frame)| {
+            self.host.fill_credentials(&frame, &username, secret.expose())
+        });
+        if !filled {
+            log::warn!("dropped a credential: the page moved on before it arrived");
         }
         self.ac_menu = None;
     }
@@ -962,22 +1287,25 @@ impl BrowserApp {
         if menu.shown.is_empty() {
             return None;
         }
+        // A child frame's field is placed by the frame's offset, which the
+        // watchers relay separately; until it has arrived there is nowhere
+        // honest to draw the list, so it waits.
+        let (dx, dy) = if menu.top { (0.0, 0.0) } else { *self.frame_offsets.get(&menu.frame)? };
+        let anchor = Rect { x: menu.field.x + dx, y: menu.field.y + dy, ..menu.field };
         let rows = menu.shown.len().min(AC_MAX_ROWS);
-        let height = 2.0 * plate_pad() + rows as f32 * AC_ROW_H + if menu.insecure { 18.0 } else { 0.0 };
+        let height =
+            2.0 * plate_pad() + rows as f32 * AC_ROW_H + Self::ac_notes(menu).len() as f32 * 18.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));
+        let x = 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.
         // style: deliberate — 2px off the field, so the list reads as
         // attached to it; the field is page content, not a plate sibling.
-        let below = menu.anchor.y + menu.anchor.height + 2.0;
+        let below = anchor.y + 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)
+            (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`.
@@ -1586,6 +1914,7 @@ impl BrowserApp {
         #[cfg(feature = "wpe")]
         {
             self.ac_menu = None;
+            self.last_field = None;
             self.host.clear_form_events();
         }
         self.host.activate(index);
@@ -1756,16 +2085,78 @@ impl BrowserApp {
                 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 {
+        // Say it plainly when the form is another site's, and when the page
+        // is not https: the password is about to go somewhere the person may
+        // not have expected, and only they can decide that is fine.
+        let notes = Self::ac_notes(menu);
+        let first_note = plate.y + plate.height - plate_pad() - notes.len() as f32 * 18.0;
+        for (k, (note, color)) in notes.into_iter().enumerate() {
             pc.text(
-                "insecure page — this password would be sent unencrypted",
+                Self::fit_text(&note, sans, AC_SUB_FONT, plate.width - 2.0 * text_pad()),
                 plate.x + text_pad(),
-                plate.y + plate.height - 15.0,
+                first_note + k as f32 * 18.0 + 3.0,
                 AC_SUB_FONT,
-                [212, 155, 155],
+                color,
+            );
+        }
+    }
+
+    /// Draw the save offer: what would be saved, and the three answers.
+    /// The password itself is never drawn.
+    #[cfg(feature = "wpe")]
+    fn paint_save_offer(&mut self, pc: &mut PaintCtx, sans: &str) {
+        let Some(l) = self.save_layout() else { return };
+        let Some(offer) = self.save_offer.as_ref() else { return };
+        pc.plate(
+            l.plate,
+            (8.0, 8.0, 8.0, 8.0),
+            &cce_ui::scene::Material::opaque([0.13, 0.14, 0.16, 1.0]),
+            cce_ui::layout::bevel_width().min(3.0),
+        );
+        let x = l.plate.x + plate_pad();
+        let w = l.plate.width - 2.0 * plate_pad();
+        let y = l.plate.y + plate_pad();
+        let (title, line, sub, sub_color) = match &offer.stage {
+            SaveStage::Failed(why) => {
+                ("Password not saved".to_string(), why.clone(), String::new(), TEXT_DIM)
+            }
+            stage => {
+                let title = if *stage == SaveStage::Saving {
+                    "Saving to the keyring…"
+                } else {
+                    "Save this password?"
+                };
+                let who = if offer.username.is_empty() {
+                    "(no username)".to_string()
+                } else {
+                    offer.username.clone()
+                };
+                let site = if offer.form_host == offer.page_host {
+                    offer.form_host.clone()
+                } else {
+                    format!("{} — form from {}", offer.page_host, offer.form_host)
+                };
+                (title.to_string(), who, site, TEXT_DIM)
+            }
+        };
+        pc.text(title, x, y, 14.0, TEXT);
+        pc.text(Self::fit_text(&line, sans, 13.0, w), x, y + 22.0, 13.0, TEXT);
+        pc.text(Self::fit_text(&sub, sans, 12.0, w), x, y + 40.0, 12.0, sub_color);
+        for (rect, button) in &l.buttons {
+            let (label, accent) = match (button, &offer.stage) {
+                (SaveButton::Save, _) => ("Save", true),
+                (SaveButton::Never, _) => ("Never", false),
+                (SaveButton::Dismiss, SaveStage::Failed(_)) => ("Close", false),
+                (SaveButton::Dismiss, _) => ("Not now", false),
+            };
+            pc.rounded_rect(*rect, 6.0, (true, true, true, true), if accent { ACCENT } else { BTN_BG });
+            let tw = measure_text_width(label, sans, 13.0);
+            pc.text(
+                label,
+                rect.x + (rect.width - tw) / 2.0,
+                cce_ui::layout::align_text_y(rect.y, rect.height, 13.0, 0.0),
+                13.0,
+                TEXT,
             );
         }
     }
@@ -2082,6 +2473,13 @@ impl Application for BrowserApp {
             scroll: cce_ui::widget::scroll_motion::ScrollMotion::new(),
             scroll_sent: (0.0, 0.0),
             accounts,
+            never_save: accounts::NeverSave::load(),
+            #[cfg(feature = "wpe")]
+            save_offer: None,
+            #[cfg(feature = "wpe")]
+            frame_offsets: std::collections::HashMap::new(),
+            #[cfg(feature = "wpe")]
+            last_field: None,
             #[cfg(feature = "wpe")]
             ac_menu: None,
             nav_url: None,
@@ -2173,6 +2571,8 @@ impl Application for BrowserApp {
                         #[cfg(feature = "wpe")]
                         {
                             self.ac_menu = None;
+                            self.frame_offsets.clear();
+                            self.last_field = None;
                         }
                     }
                     self.sync_page_state();
@@ -2200,12 +2600,49 @@ impl Application for BrowserApp {
                 }
                 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.
+                // being read; this is when its list can finally open. The
+                // chrome replays its own copy of that report — the frame it
+                // came from may be one it cannot run script in.
+                #[cfg(feature = "wpe")]
+                {
+                    if let Some(wpe::FormEvent::Field {
+                        origin, frame, top, password, rect, value, ..
+                    }) = self.last_field.take()
+                    {
+                        let replay = wpe::FormEvent::Field {
+                            origin, frame, top, password, rect, value, moved: false,
+                        };
+                        self.on_form_event(replay);
+                    }
+                    // And a sign-in may be waiting to learn whether it is new.
+                    self.resolve_save();
+                    *needs_rebuild = true;
+                }
+            }
+            Message::Saved(result) => {
                 #[cfg(feature = "wpe")]
                 {
-                    self.host.request_form_state();
+                    match result {
+                        Ok(label) => {
+                            log::info!("accounts: saved a login as \"{label}\"");
+                            self.save_offer = None;
+                            // The next login field reads the index again, new
+                            // entry and all.
+                            self.accounts.invalidate();
+                        }
+                        Err(why) => {
+                            log::warn!("accounts: {why}");
+                            if let Some(o) = self.save_offer.as_mut() {
+                                o.stage = SaveStage::Failed(why);
+                            }
+                        }
+                    }
                     *needs_rebuild = true;
                 }
+                #[cfg(not(feature = "wpe"))]
+                {
+                    let _ = result;
+                }
             }
             Message::Credential(path, secret) => {
                 #[cfg(feature = "wpe")]
@@ -2419,6 +2856,17 @@ impl Application for BrowserApp {
             return None;
         }
 
+        // The save offer takes any press on its plate, buttons or not; the
+        // page under it never sees one. Presses elsewhere leave it waiting.
+        #[cfg(feature = "wpe")]
+        if self.save_layout().is_some_and(|l| hit(&l.plate, pos.x, pos.y)) {
+            if pressed && button == MouseButton::Left {
+                self.save_click(pos.x, pos.y);
+            }
+            *needs_rebuild = true;
+            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
@@ -2692,7 +3140,7 @@ impl Application for BrowserApp {
         // 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 {
+        if self.ac_layout().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() {
@@ -3084,6 +3532,8 @@ impl Application for BrowserApp {
         #[cfg(feature = "wpe")]
         self.paint_ac_menu(&mut pc, &sans);
         #[cfg(feature = "wpe")]
+        self.paint_save_offer(&mut pc, &sans);
+        #[cfg(feature = "wpe")]
         self.paint_ctx_menu(&mut pc, &sans);
         #[cfg(feature = "wpe")]
         self.paint_modal(&mut pc, &sans);
diff --git a/src/wpe/formwatch.rs b/src/wpe/formwatch.rs
index 1f0103e..e63a4c4 100644
--- a/src/wpe/formwatch.rs
+++ b/src/wpe/formwatch.rs
@@ -1,36 +1,71 @@
 //! The page half of account autocomplete: what the chrome knows about a
-//! login form, and how a picked account gets into it.
+//! login form, how a picked account gets into it, and how a new sign-in is
+//! offered for saving.
 //!
-//! 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.
+//! Everything runs 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 channels 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. Inside that world
+//! `location.origin` is the frame's real origin, so what a watcher reports
+//! about *where* it is can be believed.
 //!
-//! 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 watcher runs in **every frame**. Sign-in forms are often a frame of
+//! their own — iCloud's is `idmsa.apple.com` inside `www.icloud.com` — and
+//! that frame is where the credential actually goes, so it is the frame's
+//! origin the chrome matches accounts against (see `on_form_event` in
+//! `main.rs` for what else is offered there, and how it is labelled). Two
+//! things a top-frame-only watcher got for free have to be rebuilt:
+//!
+//! * **Position.** A frame only knows its fields relative to its own
+//!   viewport. Each frame names itself with a random token and announces it
+//!   to its parent with `postMessage`; the parent's watcher finds which of its
+//!   frames sent it (`event.source`), adds that frame's offset, and passes it
+//!   up, until the top reports `{t: 'frame', offset}` to the chrome. Only
+//!   geometry travels this way — the page could forge a relay, and the worst
+//!   a forgery does is draw the list in the wrong place.
+//! * **Filling.** The chrome can evaluate script only in the top frame. So a
+//!   frame with a focused login field *asks* to be filled, on `FILL_CHANNEL`,
+//!   a channel with a reply: the chrome holds the newest ask, and answers it
+//!   with the picked credential or with nothing. The credential therefore
+//!   goes to exactly the frame that asked, and never through the page.
 
 /// The isolated world everything here lives in.
 pub const WORLD: &str = "cce-accounts";
-/// The message channel the injected script posts on.
+/// The message channel the injected script reports on.
 pub const CHANNEL: &str = "cceAccounts";
+/// The channel a frame asks to be filled on; its replies carry credentials.
+pub const FILL_CHANNEL: &str = "cceAccountsFill";
 
-/// Watches the top frame for login fields and reports them to the chrome.
+/// Watches a 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.
+/// It reports *positions*, *field kinds* and *what is typed into a username
+/// field* — never page content at large. The password crosses only on a
+/// submit, as the thing being offered for saving. Rects are CSS pixels
+/// relative to the frame's viewport; a frame's offset in the top document
+/// arrives separately, as a `frame` event.
 pub const WATCH_JS: &str = r#"
 (() => {
+  const handlers = window.webkit && window.webkit.messageHandlers;
+  if (!handlers || !handlers.cceAccounts) return;
   const post = (m) => {
-    try { window.webkit.messageHandlers.cceAccounts.postMessage(JSON.stringify(m)); }
-    catch (e) {}
+    try { handlers.cceAccounts.postMessage(JSON.stringify(m)); } catch (e) {}
   };
-  const state = { user: null, pass: null, filling: false };
+  const isTop = window === window.top;
+  // The chrome's name for this document, random so that nothing can aim a
+  // relay or a fill at a document it did not see announced. The top frame
+  // gets one too: every tab's top frame shares these channels, and a fill
+  // meant for this tab must not be answerable by another tab's page.
+  const token = Array.from(crypto.getRandomValues(new Uint8Array(12)),
+    (b) => b.toString(16).padStart(2, '0')).join('');
+  // Every tab's watchers share the chrome's channels, and nothing on them says
+  // which tab spoke. Focus does: only the shown tab's view is focused, so a
+  // document without it is in a background tab (or a window the person left)
+  // and has nothing to report or ask for. A page cannot fake this from its
+  // own world.
+  const live = () => document.hasFocus();
+  const state = { user: null, pass: null, filling: false, typed: '', sent: '' };
   window.__cceAccounts = state;
 
   const isPassword = (el) =>
@@ -68,24 +103,104 @@ pub const WATCH_JS: &str = r#"
     return [r.left, r.top, r.width, r.height];
   };
 
+  // ---- where this frame is, relayed up to the top ----
+
+  // The child frame whose field is focused, as last announced through here:
+  // a scroll in this document moves it, and only this document can say so.
+  let child = null;
+  const isFrame = (el) => el && (el.tagName === 'IFRAME' || el.tagName === 'FRAME');
+  const relay = (el, frame, inner) => {
+    const r = el.getBoundingClientRect();
+    const cs = getComputedStyle(el);
+    const dx = r.left + el.clientLeft + (parseFloat(cs.paddingLeft) || 0) + inner[0];
+    const dy = r.top + el.clientTop + (parseFloat(cs.paddingTop) || 0) + inner[1];
+    child = { el, frame, inner };
+    if (isTop) {
+      post({ t: 'frame', frame, offset: [dx, dy] });
+    } else {
+      window.parent.postMessage({ cceAccountsFrame: frame, dx, dy }, '*');
+    }
+  };
+  const announce = () => {
+    if (!isTop && live()) window.parent.postMessage({ cceAccountsFrame: token, dx: 0, dy: 0 }, '*');
+  };
+  window.addEventListener('message', (e) => {
+    const d = e.data;
+    if (!d || typeof d !== 'object' || typeof d.cceAccountsFrame !== 'string') return;
+    // Ours: the page has no use for it, so it never sees it.
+    e.stopImmediatePropagation();
+    const el = Array.from(document.querySelectorAll('iframe, frame'))
+      .find((f) => f.contentWindow === e.source);
+    if (!el) return;
+    relay(el, d.cceAccountsFrame, [Number(d.dx) || 0, Number(d.dy) || 0]);
+  }, true);
+
+  // ---- filling, on request ----
+
+  const fill = (cred) => {
+    // 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.
+    state.filling = true;
+    // Values go in through the prototype's own `value` setter and are
+    // followed by `input` and `change`: 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 submits an empty form.
+    const set = (el, v) => {
+      if (!el || !el.isConnected) 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 = String(cred.u || '');
+    const filledUser = user.length ? set(state.user, user) : false;
+    if (user.length) state.typed = user;
+    const filledPass = set(state.pass, String(cred.p || ''));
+    // A username-first page: the password field is not there yet.
+    if (filledUser && !filledPass && state.user) state.user.focus();
+    state.filling = false;
+  };
+  // One ask outstanding per document. The chrome keeps only the newest ask
+  // from any frame and answers a superseded one with nothing, which is what
+  // clears this flag for the next focus.
+  let asking = false;
+  const ask = () => {
+    if (asking || !live()) return;
+    asking = true;
+    let pending;
+    try { pending = handlers.cceAccountsFill.postMessage(token); } catch (e) { asking = false; return; }
+    Promise.resolve(pending).then((r) => {
+      asking = false;
+      if (typeof r === 'string' && r) fill(JSON.parse(r));
+    }, () => { asking = false; });
+  };
+
+  // ---- login fields ----
+
   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 (state.filling || !live()) 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;
+    if (kind === 'user') { state.user = el; state.typed = el.value || state.typed; }
     post({
       t: type,
       kind: kind,
+      frame: token,
+      top: isTop,
       origin: location.origin,
       rect: rectOf(el),
       value: kind === 'pass' ? '' : (el.value || ''),
     });
+    announce();
+    if (type === 'focus') ask();
   };
 
   document.addEventListener('focusin', (e) => {
@@ -94,7 +209,7 @@ pub const WATCH_JS: &str = r#"
   }, true);
 
   document.addEventListener('focusout', (e) => {
-    if (kindOf(e.target)) post({ t: 'blur' });
+    if (kindOf(e.target)) post({ t: 'blur', frame: token });
   }, true);
 
   // Typing in the username field is the filter; the password field's own
@@ -109,100 +224,127 @@ pub const WATCH_JS: &str = r#"
   // The page moving under an open list would leave it pointing at nothing.
   const moved = () => {
     const el = document.activeElement;
+    // Focus is inside a child frame: what moved is that frame.
+    if (isFrame(el)) {
+      if (child && child.el === el) relay(el, child.frame, child.inner);
+      return;
+    }
     const kind = kindOf(el);
-    if (kind) report(el, kind, 'move'); else post({ t: 'blur' });
+    if (kind) report(el, kind, 'move'); else post({ t: 'blur', frame: token });
   };
   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;
+  // Focus coming back to this document — the window, or this frame within
+  // the page — while a login field is the focused element: report it, since
+  // its `focusin` was ignored while the document was not live.
+  window.addEventListener('focus', () => {
+    const el = document.activeElement;
+    const kind = kindOf(el);
+    if (kind) report(el, kind, 'focus');
+  });
+
+  // ---- a sign-in going out, offered for saving ----
+
+  // The username that goes with a password: the last username field before
+  // it that holds something, else whatever was typed into one earlier in
+  // this document — a username-first page has replaced that field by now.
+  const userFor = (pass) => {
+    const scope = pass.form || document;
+    let found = '';
+    for (const el of scope.querySelectorAll('input')) {
+      if (el === pass) break;
+      if (kindOf(el) === 'user' && el.value) found = el.value;
+    }
+    return found || state.typed || '';
+  };
+  const submitted = (scope) => {
+    if (!live()) return;
+    const pass = passwordsIn(scope).concat(passwordsIn(document)).find((p) => p.value);
+    if (!pass) return;
+    const user = userFor(pass);
+    // A form can be submitted several ways at once (Enter, then the submit
+    // event it causes); once per credential is enough.
+    const key = user + '\n' + pass.value;
+    if (key === state.sent) return;
+    state.sent = key;
+    post({ t: 'submit', frame: token, top: isTop, origin: location.origin,
+           user: user, pass: pass.value });
+  };
+  // A real form says it went out with `submit`, which fires only once the
+  // page's own validation has passed — an Enter or a button press inside a
+  // form is left to it, or a sign-in the page refused would be offered.
+  document.addEventListener('submit', (e) => submitted(e.target), true);
+  // Most sign-in pages have no form at all: a script reads the fields when
+  // Enter is pressed or the button is. Those count, and only those — a
+  // button that says it signs in, never the show-password eye beside the
+  // field.
+  document.addEventListener('keydown', (e) => {
+    if (e.key === 'Enter' && kindOf(e.target) && !e.target.form) submitted(document);
+  }, true);
+  document.addEventListener('click', (e) => {
+    const b = e.target && e.target.closest &&
+      e.target.closest('button, input[type=submit], input[type=image], [role=button]');
+    if (!b) return;
+    const type = (b.getAttribute('type') || (b.tagName === 'BUTTON' ? 'submit' : '')).toLowerCase();
+    if (b.form && (type === 'submit' || type === 'image')) return;
+    const says = ((b.textContent || '') + ' ' + (b.value || '') + ' ' + (b.id || '') + ' ' +
+                  (b.getAttribute('aria-label') || '')).toLowerCase();
+    if (/sign|log ?in|continue|next|submit|enter|go\b/.test(says)) submitted(b.form);
+  }, true);
 })();
 "#;
 
-/// 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),
-    )
+/// The answer to a frame's fill ask: the credential, as a JSON string the
+/// watcher parses. It travels as data on the reply — never spliced into a
+/// script source — so no character in a password can become code.
+pub fn fill_reply(username: &str, password: &str) -> String {
+    serde_json::json!({ "u": username, "p": password }).to_string()
 }
 
-/// 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),
-        }
+/// A password typed into a page, on its way to being offered for saving.
+///
+/// Prints as `Password(…)`: `FormEvent` derives `Debug`, and so does the
+/// chrome's message type, so a plain `String` here would put a live password
+/// into any log line that ever formatted one. (`accounts::Secret` is the same
+/// idea on the keyring side; this module stays free of the app's types so the
+/// examples can build it on its own.)
+#[derive(Clone, PartialEq)]
+pub struct Password(String);
+
+impl Password {
+    pub fn expose(&self) -> &str {
+        &self.0
+    }
+    pub fn into_inner(self) -> String {
+        self.0
     }
-    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();";
+impl std::fmt::Debug for Password {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        f.write_str("Password(…)")
+    }
+}
 
 /// 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.
+        /// `location.origin` of the frame that reported it. For the top frame
+        /// it is checked against the tab's own URL; for a child frame it is
+        /// the site the credential would go to, and what accounts match.
         origin: String,
+        /// The reporting document's token. A child frame's `Frame` offset
+        /// events carry the same one, and a fill is answered only to the ask
+        /// that carries the token the list was opened for.
+        frame: String,
+        /// The tab's top frame, rather than a frame inside it.
+        top: bool,
         /// A password field rather than a username one.
         password: bool,
-        /// Viewport rect in CSS pixels: x, y, width, height.
+        /// Rect in CSS pixels relative to the reporting frame's viewport:
+        /// 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
@@ -212,21 +354,54 @@ pub enum FormEvent {
         /// (scroll, resize, typing) rather than a fresh focus.
         moved: bool,
     },
-    /// Focus left the login field.
-    Blur,
+    /// Where a child frame's viewport sits in the top frame's, in CSS pixels.
+    Frame { frame: String, offset: (f32, f32) },
+    /// Focus left the login field in this frame.
+    Blur { frame: String },
+    /// A sign-in went out: a form submitted, Enter in a login field, or a
+    /// sign-in button pressed with a password filled in.
+    Submit {
+        /// The frame's origin — the site these credentials belong to.
+        origin: String,
+        frame: String,
+        top: bool,
+        username: String,
+        password: Password,
+    },
 }
 
 /// 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()?;
+    let frame = || value["frame"].as_str().unwrap_or_default().to_string();
     match value["t"].as_str()? {
-        "blur" => Some(FormEvent::Blur),
+        "blur" => Some(FormEvent::Blur { frame: frame() }),
+        "frame" => {
+            let offset = value["offset"].as_array()?;
+            let num = |i: usize| offset.get(i).and_then(|v| v.as_f64()).map(|f| f as f32);
+            let frame = frame();
+            (!frame.is_empty()).then_some(())?;
+            Some(FormEvent::Frame { frame, offset: (num(0)?, num(1)?) })
+        }
+        "submit" => {
+            let password = value["pass"].as_str()?;
+            (!password.is_empty()).then_some(())?;
+            Some(FormEvent::Submit {
+                origin: value["origin"].as_str()?.to_string(),
+                frame: frame(),
+                top: value["top"].as_bool().unwrap_or(false),
+                username: value["user"].as_str().unwrap_or_default().to_string(),
+                password: Password(password.to_string()),
+            })
+        }
         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(),
+                frame: frame(),
+                top: value["top"].as_bool().unwrap_or(false),
                 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(),
@@ -242,26 +417,26 @@ mod tests {
     use super::*;
 
     #[test]
-    fn a_credential_cannot_break_out_of_the_fill_script() {
+    fn a_credential_travels_as_data() {
         // 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(&quoted));
-        // No raw newline from the credential ever reaches the source.
-        assert!(!js.contains("d\u{2028}"));
+        let reply = fill_reply("user", nasty);
+        let back: serde_json::Value = serde_json::from_str(&reply).unwrap();
+        assert_eq!(back["u"], "user");
+        assert_eq!(back["p"], nasty, "the password survives the trip exactly");
     }
 
     #[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"}"#,
+            r#"{"t":"focus","kind":"user","frame":"ab12","origin":"https://example.com","rect":[10,20,120,24],"value":"me"}"#,
         );
         match focus {
-            Some(FormEvent::Field { origin, password, rect, value, moved }) => {
+            Some(FormEvent::Field { origin, frame, top, password, rect, value, moved }) => {
+                assert!(!top, "a missing top flag is a child frame");
                 assert_eq!(origin, "https://example.com");
+                assert_eq!(frame, "ab12");
                 assert!(!password);
                 assert_eq!(rect, (10.0, 20.0, 120.0, 24.0));
                 assert_eq!(value, "me");
@@ -269,14 +444,41 @@ mod tests {
             }
             other => panic!("expected a field event, got {other:?}"),
         }
-        assert!(matches!(parse_event(r#"{"t":"blur"}"#), Some(FormEvent::Blur)));
+        assert!(matches!(
+            parse_event(r#"{"t":"blur","frame":""}"#),
+            Some(FormEvent::Blur { frame }) if frame.is_empty()
+        ));
         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!(matches!(
+            parse_event(r#"{"t":"frame","frame":"ab12","offset":[5,7.5]}"#),
+            Some(FormEvent::Frame { offset: (5.0, 7.5), .. })
+        ));
+        // Nonsense, a field event with no rect, an offset with no frame, and a
+        // submit with no password are all 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());
+        assert!(parse_event(r#"{"t":"frame","frame":"","offset":[1,1]}"#).is_none());
+        assert!(parse_event(r#"{"t":"submit","origin":"https://x.test","user":"me","pass":""}"#).is_none());
+    }
+
+    #[test]
+    fn a_submitted_password_never_prints_itself() {
+        let e = parse_event(
+            r#"{"t":"submit","frame":"","origin":"https://x.test","user":"me","pass":"hunter2"}"#,
+        )
+        .unwrap();
+        let printed = format!("{e:?}");
+        assert!(!printed.contains("hunter2"), "{printed}");
+        match e {
+            FormEvent::Submit { password, username, .. } => {
+                assert_eq!(username, "me");
+                assert_eq!(password.expose(), "hunter2");
+            }
+            other => panic!("expected a submit, got {other:?}"),
+        }
     }
 }
diff --git a/src/wpe/host.rs b/src/wpe/host.rs
index fcee5a9..e8c544e 100644
--- a/src/wpe/host.rs
+++ b/src/wpe/host.rs
@@ -388,6 +388,34 @@ impl WebKitHost {
                 Some(drop_prompts_ref),
                 0,
             );
+
+            // The fill asks: a channel with a reply, so a credential goes back
+            // to exactly the frame that asked. Same world, same guarantee —
+            // page script cannot ask on it.
+            let name = cstr(formwatch::FILL_CHANNEL);
+            if webkit_user_content_manager_register_script_message_handler_with_reply(
+                self.ucm,
+                name.as_ptr(),
+                world.as_ptr(),
+            ) == 0
+            {
+                log::warn!("could not register the account fill channel");
+                return;
+            }
+            let signal = cstr(&format!(
+                "script-message-with-reply-received::{}",
+                formwatch::FILL_CHANNEL
+            ));
+            g_signal_connect_data(
+                self.ucm as *mut _,
+                signal.as_ptr(),
+                Some(std::mem::transmute::<usize, unsafe extern "C" fn()>(
+                    on_fill_ask as *const () as usize,
+                )),
+                Rc::into_raw(self.prompts.clone()) as gpointer,
+                Some(drop_prompts_ref),
+                0,
+            );
         }
     }
 
@@ -401,11 +429,12 @@ impl WebKitHost {
                 (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.
+                    // Every frame — sign-in forms are often a frame of their
+                    // own — 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,
+                        WebKitUserContentInjectedFrames::WEBKIT_USER_CONTENT_INJECT_ALL_FRAMES,
                         WebKitUserScriptInjectionTime::WEBKIT_USER_SCRIPT_INJECT_AT_DOCUMENT_START,
                         world.as_ptr(),
                         std::ptr::null(),
@@ -417,6 +446,7 @@ impl WebKitHost {
                 (false, Some(script)) => {
                     webkit_user_content_manager_remove_script(self.ucm, script);
                     webkit_user_script_unref(script);
+                    self.drop_fill_asks();
                 }
                 // Already in the asked-for state; `take` above is why the
                 // enabled case has to put its handle back.
@@ -426,28 +456,6 @@ impl WebKitHost {
         }
     }
 
-    /// 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()
@@ -459,29 +467,32 @@ impl WebKitHost {
         self.prompts.borrow_mut().form_events.clear();
     }
 
-    /// Put a picked account into the page's login fields.
+    /// Put a picked account into the login fields of the document `frame`.
     ///
-    /// 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(),
-            );
+    /// Answers that document's fill ask with the credential, as data on the
+    /// reply — the watcher fills its own recorded fields. Returns false, and
+    /// sends nothing, when that document has no ask open: it navigated, a
+    /// newer ask displaced it, or the tab was switched away from.
+    pub fn fill_credentials(&self, frame: &str, username: &str, password: &str) -> bool {
+        let reply = {
+            let mut p = self.prompts.borrow_mut();
+            let Some(at) = p.fill_asks.iter().position(|(t, _)| t == frame) else {
+                return false;
+            };
+            p.fill_asks.remove(at).map(|(_, r)| r)
+        };
+        let Some(reply) = reply else { return false };
+        let answer = super::formwatch::fill_reply(username, password);
+        unsafe { answer_fill(reply, Some(&answer)) };
+        true
+    }
+
+    /// Answer every open fill ask with nothing. On a tab switch and on a
+    /// navigation: whatever field asked is no longer the one on screen.
+    pub fn drop_fill_asks(&self) {
+        let asks: Vec<_> = self.prompts.borrow_mut().fill_asks.drain(..).collect();
+        for (_, reply) in asks {
+            unsafe { answer_fill(reply, None) };
         }
     }
 
@@ -645,6 +656,11 @@ impl WebKitHost {
                 wpe_view_set_visible(old.view, 0);
             }
             self.active = index;
+            // Login fields reported by, and fill asks from, the tab going
+            // away: a list must not open over the next one, and a pick made
+            // there must have nowhere to land.
+            self.clear_form_events();
+            self.drop_fill_asks();
             let tab = &self.tabs[index];
             wpe_view_set_toplevel(tab.view, self.toplevel);
             wpe_view_set_visible(tab.view, 1);
@@ -1525,6 +1541,11 @@ pub(super) struct Prompts {
     /// 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>,
+    /// Open fill asks, oldest first, by the asking document's token. Each
+    /// is a reply a watcher's promise is waiting on, held with a ref, and
+    /// every one is answered exactly once: with a credential, or with
+    /// nothing when it is displaced or dropped.
+    fill_asks: std::collections::VecDeque<(String, *mut WebKitScriptMessageReply)>,
 }
 
 /// What was under the pointer when the page asked for a context menu, read
@@ -1598,6 +1619,78 @@ unsafe extern "C" fn on_account_message(
     }
 }
 
+/// A login field asking to be filled. The reply is held until a pick
+/// answers it, or a newer ask displaces it. Returning TRUE says it will be
+/// answered — later, which is the point.
+unsafe extern "C" fn on_fill_ask(
+    _ucm: *mut WebKitUserContentManager,
+    value: *mut JSCValue,
+    reply: *mut WebKitScriptMessageReply,
+    data: gpointer,
+) -> gboolean {
+    let prompts = &*(data as *const RefCell<Prompts>);
+    let raw = jsc_value_to_string(value);
+    let token = from_cstr(raw);
+    g_free(raw as *mut _);
+    // The watcher's tokens are 24 hex digits; anything else is not one.
+    let Some(token) =
+        token.filter(|t| t.len() == 24 && t.bytes().all(|b| b.is_ascii_hexdigit()))
+    else {
+        webkit_script_message_reply_ref(reply);
+        answer_fill(reply, None);
+        return 1;
+    };
+    webkit_script_message_reply_ref(reply);
+    let displaced: Vec<_> = {
+        let mut p = prompts.borrow_mut();
+        let mut out = Vec::new();
+        p.fill_asks.retain(|(t, r)| {
+            let same = *t == token;
+            if same {
+                out.push(*r);
+            }
+            !same
+        });
+        p.fill_asks.push_back((token, reply));
+        // Only the focused field's ask matters; a few spare cover a list
+        // still open while focus wanders between frames.
+        while p.fill_asks.len() > 4 {
+            if let Some((_, r)) = p.fill_asks.pop_front() {
+                out.push(r);
+            }
+        }
+        out
+    };
+    for r in displaced {
+        answer_fill(r, None);
+    }
+    1
+}
+
+/// Answer a fill ask — with the credential's JSON, or with null — and let
+/// go of it.
+unsafe fn answer_fill(reply: *mut WebKitScriptMessageReply, value: Option<&str>) {
+    thread_local! {
+        /// A context to build reply values in. Any will do: the value is
+        /// serialized across to the web process, not run here.
+        static JSC: *mut JSCContext = unsafe { jsc_context_new() };
+    }
+    JSC.with(|ctx| {
+        let v = match value {
+            // JSON has no raw NUL — serde escapes it — so this cannot fail on
+            // a credential.
+            Some(s) => {
+                let c = cstr(s);
+                jsc_value_new_string(*ctx, c.as_ptr())
+            }
+            None => jsc_value_new_null(*ctx),
+        };
+        webkit_script_message_reply_return_value(reply, v);
+        g_object_unref(v as *mut _);
+    });
+    webkit_script_message_reply_unref(reply);
+}
+
 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 47645c7..d55dc78 100644
--- a/src/wpe/mod.rs
+++ b/src/wpe/mod.rs
@@ -13,8 +13,8 @@ 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.
+/// The page half of account autocomplete: the watcher script and the events
+/// it exchanges with the chrome.
 pub mod formwatch;
 
 // Not consumed yet — main.rs still drives ServoHost.