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

commitf6ccc2019342743f782a3a43455327825cd2a33e
parent37a1378909
authorLucas Galante <[email protected]>
date2026-08-28 11:21
feat(wpe): JS dialogs and HTTP auth, with a real modal

Both verified end to end in a shadow session rather than by inspection.
A page calling prompt() gets its answer back — clicking OK produced
title="got:anonymous" — and a 401 with WWW-Authenticate produced a sign-in
prompt whose credentials reached the server, title="authed:ab".

WebKit's contract for both is the same: return TRUE from the signal to say
we will answer, and the page stays blocked inside the engine until we do.
So the dialog is reffed and held rather than answered inline, which is
what lets the chrome draw a real prompt instead of the handler having to
guess. Two consequences the code encodes: a cancelled prompt must return
null rather than "", because a page distinguishes them, and while a modal
is up the chrome swallows all input — the page really is blocked, so
letting Ctrl+T or a click through would misrepresent the engine's state.

Credentials are WEBKIT_CREDENTIAL_PERSISTENCE_FOR_SESSION. Writing them to
the profile is a deliberate decision about storing passwords on disk and
should be made on purpose, not inherited from a default.

The password field masks in display() so the text never reaches the paint
list, and copy and cut are refused on a masked field — a password should
not leave through the clipboard by a chord the user may not have meant.
Paste stays, since that is how a password manager hands one over.

src/lineedit.rs is written as the shared editor the chrome should use
everywhere, but it currently backs only the dialog fields. The URL bar
still has this logic welded into BrowserApp across 59 call sites, and
migrating it is a mechanical change with real regression risk to the
shipping Servo browser — worth doing on its own rather than folded into a
feature. Two editors exist until then, which is deliberate and temporary.

Servo raises neither signal, so the modal is cfg'd to the wpe feature and
the default build is unchanged. Both build, tests pass.

Co-Authored-By: Claude Opus 5 <[email protected]>

 src/lineedit.rs | 180 ++++++++++++++++++++++++++++++++
 src/main.rs     | 315 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 src/wpe/host.rs | 172 +++++++++++++++++++++++++++++++
 src/wpe/mod.rs  |   2 +-
 4 files changed, 668 insertions(+), 1 deletion(-)

diff --git a/src/lineedit.rs b/src/lineedit.rs
new file mode 100644
index 0000000..db0d1fa
--- /dev/null
+++ b/src/lineedit.rs
@@ -0,0 +1,180 @@
+//! A one-line text field: the text, a caret, and a selection.
+//!
+//! Written as the shared editor the chrome should use everywhere. The URL bar
+//! still has its own copy of this logic welded into `BrowserApp` (59 call
+//! sites); migrating it is a mechanical change worth doing on its own rather
+//! than folded into a feature, so for now this backs the dialog fields only.
+
+use cce_ui::widget::{ElementState, Key, KeyEvent, NamedKey};
+
+/// What a keystroke meant, beyond editing the text.
+#[derive(Debug, PartialEq)]
+pub enum EditOutcome {
+    /// Nothing structural — redraw and carry on.
+    Edited,
+    /// Enter: the caller commits.
+    Submit,
+    /// Escape: the caller cancels.
+    Cancel,
+    /// Not ours (a chord the chrome owns).
+    Ignored,
+}
+
+#[derive(Default)]
+pub struct LineEdit {
+    pub text: String,
+    pub cursor: usize,
+    /// Normalized (start < end). Any edit replaces or drops it.
+    pub selection: Option<(usize, usize)>,
+    /// Render as bullets. Set for password fields.
+    pub masked: bool,
+}
+
+fn prev_boundary(s: &str, i: usize) -> usize {
+    let mut j = i;
+    while j > 0 {
+        j -= 1;
+        if s.is_char_boundary(j) {
+            return j;
+        }
+    }
+    0
+}
+
+fn next_boundary(s: &str, i: usize) -> usize {
+    let mut j = i;
+    while j < s.len() {
+        j += 1;
+        if s.is_char_boundary(j) {
+            return j;
+        }
+    }
+    s.len()
+}
+
+impl LineEdit {
+    pub fn with_text(text: impl Into<String>) -> Self {
+        let text = text.into();
+        Self { cursor: text.len(), text, ..Self::default() }
+    }
+
+    pub fn masked() -> Self {
+        Self { masked: true, ..Self::default() }
+    }
+
+    /// What to draw. Never returns the password itself.
+    pub fn display(&self) -> String {
+        if self.masked {
+            "\u{2022}".repeat(self.text.chars().count())
+        } else {
+            self.text.clone()
+        }
+    }
+
+    pub fn select_all(&mut self) {
+        self.cursor = self.text.len();
+        self.selection = (self.cursor > 0).then_some((0, self.cursor));
+    }
+
+    fn take_selection(&mut self) -> bool {
+        match self.selection.take() {
+            Some((a, b)) if a < b && b <= self.text.len() => {
+                self.text.replace_range(a..b, "");
+                self.cursor = a;
+                true
+            }
+            _ => false,
+        }
+    }
+
+    pub fn handle_key(&mut self, event: &KeyEvent) -> EditOutcome {
+        if event.state != ElementState::Pressed {
+            return EditOutcome::Ignored;
+        }
+        match &event.logical_key {
+            Key::Named(NamedKey::Enter) => return EditOutcome::Submit,
+            Key::Named(NamedKey::Escape) => return EditOutcome::Cancel,
+            Key::Named(NamedKey::Backspace) => {
+                if !self.take_selection() && self.cursor > 0 {
+                    let prev = prev_boundary(&self.text, self.cursor);
+                    self.text.replace_range(prev..self.cursor, "");
+                    self.cursor = prev;
+                }
+            }
+            Key::Named(NamedKey::Delete) => {
+                if !self.take_selection() && self.cursor < self.text.len() {
+                    let next = next_boundary(&self.text, self.cursor);
+                    self.text.replace_range(self.cursor..next, "");
+                }
+            }
+            // Arrows collapse a selection to the edge they move toward.
+            Key::Named(NamedKey::ArrowLeft) => {
+                self.cursor = match self.selection.take() {
+                    Some((a, _)) => a,
+                    None => prev_boundary(&self.text, self.cursor),
+                };
+            }
+            Key::Named(NamedKey::ArrowRight) => {
+                self.cursor = match self.selection.take() {
+                    Some((_, b)) => b,
+                    None => next_boundary(&self.text, self.cursor),
+                };
+            }
+            Key::Named(NamedKey::Home) => {
+                self.selection = None;
+                self.cursor = 0;
+            }
+            Key::Named(NamedKey::End) => {
+                self.selection = None;
+                self.cursor = self.text.len();
+            }
+            Key::Character(c) if event.ctrl => match c.as_str() {
+                "a" => self.select_all(),
+                "u" => {
+                    self.text.clear();
+                    self.cursor = 0;
+                    self.selection = None;
+                }
+                // Copy and cut are deliberately absent on a masked field:
+                // a password should not leave through the clipboard by a
+                // chord the user may not have meant. Paste is allowed, since
+                // that is how password managers hand one over.
+                "v" => {
+                    if let Some(t) = cce_ui::widget::clipboard::read_from_clipboard() {
+                        let flat: String = t.chars().filter(|c| !c.is_control()).collect();
+                        if !flat.is_empty() {
+                            self.take_selection();
+                            self.text.insert_str(self.cursor, &flat);
+                            self.cursor += flat.len();
+                        }
+                    }
+                }
+                "c" | "x" if !self.masked => {
+                    if let Some((a, b)) = self.selection.filter(|&(a, b)| a < b) {
+                        cce_ui::widget::clipboard::copy_to_clipboard(&self.text[a..b]);
+                        if c == "x" {
+                            self.take_selection();
+                        }
+                    }
+                }
+                _ => return EditOutcome::Ignored,
+            },
+            _ => {
+                let insert = match (&event.text, &event.logical_key) {
+                    (Some(t), _) if !event.ctrl && !t.chars().any(char::is_control) => {
+                        Some(t.clone())
+                    }
+                    (None, Key::Named(NamedKey::Space)) => Some(" ".to_string()),
+                    (None, Key::Character(c)) if !event.ctrl => Some(c.clone()),
+                    _ => return EditOutcome::Ignored,
+                };
+                if let Some(t) = insert {
+                    self.take_selection();
+                    self.text.insert_str(self.cursor, &t);
+                    self.cursor += t.len();
+                }
+            }
+        }
+        EditOutcome::Edited
+    }
+}
diff --git a/src/main.rs b/src/main.rs
index 5cf9d50..7eb8bc6 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -7,6 +7,8 @@
 //! input events; the URL bar is a small hand-rolled line editor.
 
 mod downloads;
+#[cfg(feature = "wpe")]
+mod lineedit;
 mod pages;
 mod settings;
 mod webview;
@@ -79,6 +81,88 @@ const ACCENT: [f32; 4] = [0.35, 0.55, 0.85, 1.0];
 const TEXT: [u8; 3] = [220, 220, 225];
 const TEXT_DIM: [u8; 3] = [120, 122, 128];
 
+/// A page-blocking prompt drawn over the content.
+///
+/// Modal on purpose: the page is genuinely blocked inside WebKit until it is
+/// answered, so letting the chrome carry on as if nothing were pending would
+/// misrepresent what the engine is doing.
+#[cfg(feature = "wpe")]
+struct Modal {
+    title: String,
+    message: String,
+    /// Editable fields, in tab order. Empty for a bare alert or confirm.
+    fields: Vec<(&'static str, lineedit::LineEdit)>,
+    focused: usize,
+    has_cancel: bool,
+    kind: ModalKind,
+}
+
+#[cfg(feature = "wpe")]
+enum ModalKind {
+    /// `alert` / `confirm` / `prompt`.
+    Script,
+    /// An HTTP auth challenge.
+    Auth,
+}
+
+#[cfg(feature = "wpe")]
+const MODAL_W: f32 = 420.0;
+#[cfg(feature = "wpe")]
+const MODAL_PAD: f32 = 18.0;
+#[cfg(feature = "wpe")]
+const MODAL_FIELD_H: f32 = 26.0;
+#[cfg(feature = "wpe")]
+const MODAL_BTN_W: f32 = 84.0;
+
+#[cfg(feature = "wpe")]
+impl Modal {
+    fn height(&self) -> f32 {
+        MODAL_PAD * 2.0
+            + 20.0
+            + 22.0
+            + self.fields.len() as f32 * (MODAL_FIELD_H + 8.0)
+            + 12.0
+            + BTN_H
+    }
+
+    /// Centred, and clamped so it stays on screen on a small window.
+    fn rect(&self, win: (f32, f32)) -> Rect {
+        let w = MODAL_W.min(win.0 - 40.0).max(240.0);
+        let h = self.height();
+        Rect {
+            x: ((win.0 - w) / 2.0).max(0.0),
+            y: ((win.1 - h) / 2.0).max(0.0),
+            width: w,
+            height: h,
+        }
+    }
+
+    fn field_rect(&self, r: &Rect, i: usize) -> Rect {
+        Rect {
+            x: r.x + MODAL_PAD,
+            y: r.y + MODAL_PAD + 42.0 + i as f32 * (MODAL_FIELD_H + 8.0),
+            width: r.width - MODAL_PAD * 2.0,
+            height: MODAL_FIELD_H,
+        }
+    }
+
+    /// (ok, cancel) — cancel is `None` for a bare alert.
+    fn button_rects(&self, r: &Rect) -> (Rect, Option<Rect>) {
+        let y = r.y + r.height - MODAL_PAD - BTN_H;
+        let ok = Rect {
+            x: r.x + r.width - MODAL_PAD - MODAL_BTN_W,
+            y,
+            width: MODAL_BTN_W,
+            height: BTN_H,
+        };
+        let cancel = self.has_cancel.then(|| Rect {
+            x: ok.x - MODAL_BTN_W - BTN_GAP,
+            ..ok
+        });
+        (ok, cancel)
+    }
+}
+
 #[derive(Debug, Clone)]
 pub enum Message {
     /// Servo requested an event-loop spin (waker or delegate signal).
@@ -106,6 +190,11 @@ struct BrowserApp {
     /// Page title; drives the toplevel title (the engine re-applies
     /// `settings().title` whenever it changes).
     title: Option<String>,
+    /// The page-blocking dialog or auth challenge currently on screen, if
+    /// any. Only the WPE backend raises these — Servo has no delegate hooks
+    /// for them, which is why they were listed as "not implemented".
+    #[cfg(feature = "wpe")]
+    modal: Option<Modal>,
     /// Kept so the WPE backend's calloop sources can fire `Spin`; Servo
     /// wakes the loop itself through its `EventLoopWaker`.
     #[cfg(feature = "wpe")]
@@ -350,6 +439,77 @@ impl BrowserApp {
         }
     }
 
+    /// Adopt whatever the engine is blocked on. Returns whether the chrome
+    /// needs redrawing.
+    #[cfg(feature = "wpe")]
+    fn sync_modal(&mut self) -> bool {
+        if self.modal.is_some() {
+            return false;
+        }
+        if let Some(d) = self.host.pending_dialog() {
+            let mut fields = Vec::new();
+            if let Some(default) = d.prompt_default.clone() {
+                let mut e = lineedit::LineEdit::with_text(default);
+                e.select_all();
+                fields.push(("", e));
+            }
+            self.modal = Some(Modal {
+                title: "This page says".to_string(),
+                message: d.message,
+                fields,
+                focused: 0,
+                has_cancel: d.has_cancel,
+                kind: ModalKind::Script,
+            });
+            return true;
+        }
+        if let Some(a) = self.host.pending_auth() {
+            let where_ = if a.realm.is_empty() {
+                a.host.clone()
+            } else {
+                format!("{} — {}", a.host, a.realm)
+            };
+            self.modal = Some(Modal {
+                title: if a.retry {
+                    "Sign in failed — try again".to_string()
+                } else {
+                    "Sign in".to_string()
+                },
+                message: where_,
+                fields: vec![
+                    ("Username", lineedit::LineEdit::default()),
+                    ("Password", lineedit::LineEdit::masked()),
+                ],
+                focused: 0,
+                has_cancel: true,
+                kind: ModalKind::Auth,
+            });
+            return true;
+        }
+        false
+    }
+
+    /// Answer the engine and dismiss. `ok` false is cancel.
+    #[cfg(feature = "wpe")]
+    fn close_modal(&mut self, ok: bool) {
+        let Some(m) = self.modal.take() else { return };
+        match m.kind {
+            ModalKind::Script => {
+                let text = m.fields.first().map(|(_, e)| e.text.clone());
+                self.host.respond_dialog(ok, text.as_deref());
+            }
+            ModalKind::Auth => {
+                if ok {
+                    let user = m.fields[0].1.text.clone();
+                    let password = m.fields[1].1.text.clone();
+                    self.host.respond_auth(Some((&user, &password)));
+                } else {
+                    self.host.respond_auth(None);
+                }
+            }
+        }
+    }
+
     fn navigate(&mut self) {
         if let Some(url) = parse_url_input(&self.url_input, &self.settings.search_prefix) {
             self.host.load(url);
@@ -484,6 +644,79 @@ impl BrowserApp {
         String::new()
     }
 
+    /// Draw the page-blocking prompt, if one is up. Same primitives as the
+    /// utility bar — there are no cce-ui widgets in this app — with a scrim
+    /// over the page so it reads as blocked, which it genuinely is.
+    #[cfg(feature = "wpe")]
+    fn paint_modal(&mut self, pc: &mut PaintCtx, sans: &str) {
+        let Some(m) = self.modal.as_ref() else { return };
+        let r = m.rect(self.win);
+
+        pc.quad(
+            Rect { x: 0.0, y: 0.0, width: self.win.0, height: self.win.1 },
+            [0.0, 0.0, 0.0, 0.45],
+        );
+        let radii = (BAR_RADIUS, BAR_RADIUS, BAR_RADIUS, BAR_RADIUS);
+        pc.plate(r, radii, [0.13, 0.14, 0.16, 1.0], cce_ui::layout::bevel_width().min(4.0));
+
+        pc.text(
+            m.title.clone(),
+            r.x + MODAL_PAD,
+            r.y + MODAL_PAD,
+            14.0,
+            TEXT,
+        );
+        pc.text(
+            Self::fit_text(&m.message, sans, 13.0, r.width - MODAL_PAD * 2.0),
+            r.x + MODAL_PAD,
+            r.y + MODAL_PAD + 22.0,
+            13.0,
+            TEXT_DIM,
+        );
+
+        for (i, (label, edit)) in m.fields.iter().enumerate() {
+            let f = m.field_rect(&r, i);
+            let focused = i == m.focused;
+            pc.rounded_rect(
+                Rect { x: f.x - 1.0, y: f.y - 1.0, width: f.width + 2.0, height: f.height + 2.0 },
+                7.0,
+                (true, true, true, true),
+                if focused { RIM_FOCUS } else { RIM },
+            );
+            pc.rounded_rect(f, 6.0, (true, true, true, true), FIELD_BG);
+            let ty = cce_ui::layout::align_text_y(f.y, f.height, URL_FONT, 0.0);
+            // `display()` masks a password field; the text itself never
+            // reaches the paint list.
+            let shown = edit.display();
+            if shown.is_empty() && !label.is_empty() {
+                pc.text(*label, f.x + URL_PAD_X, ty, URL_FONT, TEXT_DIM);
+            } else {
+                pc.text(shown, f.x + URL_PAD_X, ty, URL_FONT, TEXT);
+            }
+        }
+
+        let (ok, cancel) = m.button_rects(&r);
+        for (rect, label, accent) in [(Some(ok), "OK", true), (cancel, "Cancel", false)]
+            .into_iter()
+            .filter_map(|(rc, l, a)| rc.map(|rc| (rc, l, a)))
+        {
+            pc.rounded_rect(
+                rect,
+                6.0,
+                (true, true, true, true),
+                if accent { ACCENT } else { BTN_BG },
+            );
+            let w = measure_text_width(label, sans, 13.0);
+            pc.text(
+                label,
+                rect.x + (rect.width - w) / 2.0,
+                cce_ui::layout::align_text_y(rect.y, rect.height, 13.0, 0.0),
+                13.0,
+                TEXT,
+            );
+        }
+    }
+
     fn cursor_from_click(&mut self, click_x: f32, field: &Rect) -> usize {
         let rel = click_x - field.x - URL_PAD_X;
         // Boundary x offsets from the same shaped buffer the bar draws (font=None,
@@ -673,6 +906,8 @@ impl Application for BrowserApp {
             loading: true,
             title: None,
             #[cfg(feature = "wpe")]
+            modal: None,
+            #[cfg(feature = "wpe")]
             sender,
             font_system: cce_ui::create_font_system(),
         }
@@ -739,6 +974,10 @@ impl Application for BrowserApp {
         match msg {
             Message::Spin => {
                 let (new_frame, dirty) = self.host.pump();
+                #[cfg(feature = "wpe")]
+                if self.sync_modal() {
+                    *needs_rebuild = true;
+                }
                 if self.host.take_download_started() {
                     self.open_internal_page("cce://downloads");
                 }
@@ -787,6 +1026,34 @@ impl Application for BrowserApp {
     ) -> Option<Self::Message> {
         let pressed = state == ElementState::Pressed;
 
+        #[cfg(feature = "wpe")]
+        if self.modal.is_some() {
+            if !pressed || button != MouseButton::Left {
+                return None;
+            }
+            *needs_rebuild = true;
+            let (hit_ok, hit_cancel, field) = {
+                let m = self.modal.as_ref().unwrap();
+                let r = m.rect(self.win);
+                let (ok, cancel) = m.button_rects(&r);
+                (
+                    hit(&ok, pos.x, pos.y),
+                    cancel.is_some_and(|c| hit(&c, pos.x, pos.y)),
+                    (0..m.fields.len()).find(|&i| hit(&m.field_rect(&r, i), pos.x, pos.y)),
+                )
+            };
+            if hit_ok {
+                self.close_modal(true);
+            } else if hit_cancel {
+                self.close_modal(false);
+            } else if let (Some(i), Some(m)) = (field, self.modal.as_mut()) {
+                m.focused = i;
+            }
+            // Anything else is swallowed: the page must not receive clicks
+            // while it is blocked waiting on this.
+            return None;
+        }
+
         let bar = self.bar();
         if hit(&bar, pos.x, pos.y) {
             if !pressed || !matches!(button, MouseButton::Left | MouseButton::Middle) {
@@ -876,6 +1143,51 @@ impl Application for BrowserApp {
     }
 
     fn handle_key_input(&mut self, event: &KeyEvent, needs_rebuild: &mut bool) -> Option<Self::Message> {
+        // A modal is exactly that: the page is blocked inside WebKit, so the
+        // chrome's own chords must not fire behind it either.
+        #[cfg(feature = "wpe")]
+        if self.modal.is_some() {
+            *needs_rebuild = true;
+            if event.state == ElementState::Pressed
+                && event.logical_key == Key::Named(NamedKey::Tab)
+            {
+                if let Some(m) = self.modal.as_mut() {
+                    if !m.fields.is_empty() {
+                        let n = m.fields.len();
+                        m.focused = if event.shift {
+                            (m.focused + n - 1) % n
+                        } else {
+                            (m.focused + 1) % n
+                        };
+                    }
+                }
+                return None;
+            }
+            let outcome = match self.modal.as_mut() {
+                Some(m) if !m.fields.is_empty() => {
+                    let i = m.focused;
+                    m.fields[i].1.handle_key(event)
+                }
+                // No field: Enter accepts, Escape cancels, nothing else acts.
+                Some(_) => match (&event.logical_key, event.state) {
+                    (Key::Named(NamedKey::Enter), ElementState::Pressed) => {
+                        lineedit::EditOutcome::Submit
+                    }
+                    (Key::Named(NamedKey::Escape), ElementState::Pressed) => {
+                        lineedit::EditOutcome::Cancel
+                    }
+                    _ => lineedit::EditOutcome::Ignored,
+                },
+                None => lineedit::EditOutcome::Ignored,
+            };
+            match outcome {
+                lineedit::EditOutcome::Submit => self.close_modal(true),
+                lineedit::EditOutcome::Cancel => self.close_modal(false),
+                _ => {}
+            }
+            return None;
+        }
+
         // Tab shortcuts work regardless of URL-bar focus.
         if event.state == ElementState::Pressed && event.ctrl {
             let count = self.host.tab_count();
@@ -1132,6 +1444,9 @@ impl Application for BrowserApp {
             }
         });
 
+        #[cfg(feature = "wpe")]
+        self.paint_modal(&mut pc, &sans);
+
         Some(pc.finish())
     }
 
diff --git a/src/wpe/host.rs b/src/wpe/host.rs
index 1aaf3c7..0369504 100644
--- a/src/wpe/host.rs
+++ b/src/wpe/host.rs
@@ -139,6 +139,8 @@ pub struct WebKitHost {
     clear_cookies: std::sync::Arc<std::sync::atomic::AtomicBool>,
     session: *mut WebKitNetworkSession,
     download_started: Rc<Cell<bool>>,
+    /// A page asked something and is blocked until we answer.
+    prompts: Rc<RefCell<Prompts>>,
     /// Retained only so tests can assert on rendered output; the registry
     /// owns the copy that actually gets drawn.
     last_frame: Option<(Vec<u8>, u32, u32)>,
@@ -230,6 +232,7 @@ impl WebKitHost {
                 0,
             );
 
+            let prompts = Rc::new(RefCell::new(Prompts::default()));
             let pending = Rc::new(std::cell::RefCell::new(Pending::default()));
             let sink = pending.clone();
             FRAME_SINK = Some(Box::new(move |buffer: *mut WPEBuffer| {
@@ -262,6 +265,7 @@ impl WebKitHost {
                 clear_cookies,
                 session,
                 download_started,
+                prompts,
                 last_frame: None,
                 ucm: webkit_user_content_manager_new(),
             };
@@ -294,6 +298,21 @@ impl WebKitHost {
             for sig in ["notify::title", "notify::uri", "notify::is-loading"] {
                 connect_notify(wv, sig, state);
             }
+            // A page's alert/confirm/prompt, and HTTP auth challenges. Both
+            // are held open and answered later, so the chrome can draw a real
+            // dialog rather than the handler having to decide inline.
+            connect_raw(
+                wv,
+                "script-dialog",
+                on_script_dialog as *const () as usize,
+                &self.prompts,
+            );
+            connect_raw(
+                wv,
+                "authenticate",
+                on_authenticate as *const () as usize,
+                &self.prompts,
+            );
             wpe_view_resized(view, self.size_px.0 as i32, self.size_px.1 as i32);
             wpe_view_set_visible(view, 1);
             wpe_view_map(view);
@@ -589,6 +608,69 @@ impl WebKitHost {
         }
     }
 
+    // ---- pending prompts ----
+
+    /// The dialog a page is currently blocked on, if any. Cloned rather than
+    /// taken: the chrome redraws from this every frame, and the page stays
+    /// blocked until [`Self::respond_dialog`].
+    pub fn pending_dialog(&self) -> Option<PendingDialog> {
+        self.prompts.borrow().dialog.as_ref().map(|(_, d)| d.clone())
+    }
+
+    pub fn pending_auth(&self) -> Option<PendingAuth> {
+        self.prompts.borrow().auth.as_ref().map(|(_, a)| a.clone())
+    }
+
+    /// Answer the page. `text` carries a `prompt`'s reply; it is ignored for
+    /// alert and confirm.
+    pub fn respond_dialog(&self, ok: bool, text: Option<&str>) {
+        let Some((dialog, pending)) = self.prompts.borrow_mut().dialog.take() else {
+            return;
+        };
+        unsafe {
+            if pending.prompt_default.is_some() {
+                // A cancelled prompt must return null, not "" — a page
+                // distinguishes the two.
+                if ok {
+                    let t = cstr(text.unwrap_or(""));
+                    webkit_script_dialog_prompt_set_text(dialog, t.as_ptr());
+                } else {
+                    webkit_script_dialog_prompt_set_text(dialog, std::ptr::null());
+                }
+            } else if pending.has_cancel {
+                webkit_script_dialog_confirm_set_confirmed(dialog, ok as gboolean);
+            }
+            webkit_script_dialog_close(dialog);
+            webkit_script_dialog_unref(dialog);
+        }
+    }
+
+    /// Answer an auth challenge, or cancel it. Credentials are used for this
+    /// session only — `WEBKIT_CREDENTIAL_PERSISTENCE_FOR_SESSION` — rather
+    /// than written to the profile, which would need a deliberate decision
+    /// about storing passwords on disk.
+    pub fn respond_auth(&self, credentials: Option<(&str, &str)>) {
+        let Some((request, _)) = self.prompts.borrow_mut().auth.take() else {
+            return;
+        };
+        unsafe {
+            match credentials {
+                Some((user, password)) => {
+                    let (u, p) = (cstr(user), cstr(password));
+                    let cred = webkit_credential_new(
+                        u.as_ptr(),
+                        p.as_ptr(),
+                        WebKitCredentialPersistence::WEBKIT_CREDENTIAL_PERSISTENCE_FOR_SESSION,
+                    );
+                    webkit_authentication_request_authenticate(request, cred);
+                    webkit_credential_free(cred);
+                }
+                None => webkit_authentication_request_cancel(request),
+            }
+            g_object_unref(request as *mut _);
+        }
+    }
+
     // ---- input ----
     //
     // Coordinates are device pixels relative to the view origin, matching
@@ -924,3 +1006,93 @@ unsafe extern "C" fn on_failed(_d: *mut WebKitDownload, error: *mut GError, data
         one.ctx.downloads.set_finished(one.id.get(), Err(msg));
     }
 }
+
+/// What a page is currently blocked on. At most one of each: WebKit will not
+/// raise a second dialog on the same view until the first is answered.
+#[derive(Default)]
+pub(super) struct Prompts {
+    dialog: Option<(*mut WebKitScriptDialog, PendingDialog)>,
+    auth: Option<(*mut WebKitAuthenticationRequest, PendingAuth)>,
+}
+
+/// A page's `alert` / `confirm` / `prompt`, waiting on the chrome.
+#[derive(Debug, Clone)]
+pub struct PendingDialog {
+    pub message: String,
+    /// `Some` for `prompt`, carrying its default text; `None` otherwise.
+    pub prompt_default: Option<String>,
+    /// `confirm` and `beforeunload` offer a choice; `alert` only acknowledges.
+    pub has_cancel: bool,
+}
+
+/// An HTTP auth challenge, waiting on the chrome.
+#[derive(Debug, Clone)]
+pub struct PendingAuth {
+    pub host: String,
+    pub realm: String,
+    /// Set when the previous credentials were rejected — worth telling the
+    /// user, since the field otherwise looks identical to the first attempt.
+    pub retry: bool,
+}
+
+unsafe fn connect_raw(
+    wv: *mut WebKitWebView,
+    signal: &str,
+    cb: usize,
+    prompts: &Rc<RefCell<Prompts>>,
+) {
+    let name = cstr(signal);
+    g_signal_connect_data(
+        wv as *mut _,
+        name.as_ptr(),
+        Some(std::mem::transmute::<usize, unsafe extern "C" fn()>(cb)),
+        Rc::into_raw(prompts.clone()) as gpointer,
+        Some(drop_prompts_ref),
+        0,
+    );
+}
+
+unsafe extern "C" fn drop_prompts_ref(data: gpointer, _c: *mut GClosure) {
+    drop(Rc::from_raw(data as *const RefCell<Prompts>));
+}
+
+/// Returning TRUE means *we* will answer. The dialog is reffed and held; the
+/// page stays blocked until `respond_dialog` closes it.
+unsafe extern "C" fn on_script_dialog(
+    _wv: *mut WebKitWebView,
+    dialog: *mut WebKitScriptDialog,
+    data: gpointer,
+) -> gboolean {
+    let prompts = &*(data as *const RefCell<Prompts>);
+    let kind = webkit_script_dialog_get_dialog_type(dialog);
+    let message = from_cstr(webkit_script_dialog_get_message(dialog)).unwrap_or_default();
+    let is_prompt = kind == WebKitScriptDialogType::WEBKIT_SCRIPT_DIALOG_PROMPT;
+    let pending = PendingDialog {
+        message,
+        prompt_default: is_prompt
+            .then(|| from_cstr(webkit_script_dialog_prompt_get_default_text(dialog)))
+            .flatten()
+            .or_else(|| is_prompt.then(String::new)),
+        has_cancel: kind != WebKitScriptDialogType::WEBKIT_SCRIPT_DIALOG_ALERT,
+    };
+    webkit_script_dialog_ref(dialog);
+    prompts.borrow_mut().dialog = Some((dialog, pending));
+    1
+}
+
+/// Same contract: TRUE means we answer, and the request is reffed until we do.
+unsafe extern "C" fn on_authenticate(
+    _wv: *mut WebKitWebView,
+    request: *mut WebKitAuthenticationRequest,
+    data: gpointer,
+) -> gboolean {
+    let prompts = &*(data as *const RefCell<Prompts>);
+    let pending = PendingAuth {
+        host: from_cstr(webkit_authentication_request_get_host(request)).unwrap_or_default(),
+        realm: from_cstr(webkit_authentication_request_get_realm(request)).unwrap_or_default(),
+        retry: webkit_authentication_request_is_retry(request) != 0,
+    };
+    g_object_ref(request as *mut _);
+    prompts.borrow_mut().auth = Some((request, pending));
+    1
+}
diff --git a/src/wpe/mod.rs b/src/wpe/mod.rs
index 3c11e30..07c6756 100644
--- a/src/wpe/mod.rs
+++ b/src/wpe/mod.rs
@@ -16,4 +16,4 @@ mod host;
 
 // Not consumed yet — main.rs still drives ServoHost.
 #[allow(unused_imports)]
-pub use host::{Tab, WebKitHost};
+pub use host::{PendingAuth, PendingDialog, Tab, WebKitHost};