host: the popover host (webview, session, z-order, close rules) is the host's

popover_host owns the one popover webview of an app: creation (recreated above tabs created after
it), placement through popover::place, the session (seq, source tab, app meta flattened into the
JSON the page reads), the toggle-closed window, close on layout change, navigation (the app says
whether the context moved on), shell raise, and the popover_only / current caller checks. The app
passes a PopoverConfig (page url, open event, macOS ground). Tests: layout staleness and the
session JSON shape.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
CRTkafa 2026-10-01 14:33:10 +07:00
parent 7ab3ecd02e
commit 1edc6440ef
2 changed files with 295 additions and 0 deletions

View file

@ -107,6 +107,8 @@ mod update_commands;
pub mod devloop;
#[cfg(feature = "tauri")]
pub mod host;
#[cfg(feature = "tauri")]
pub mod popover_host;
#[cfg(all(feature = "tauri", feature = "net"))]
pub mod updater;

View file

@ -0,0 +1,293 @@
//! The popover host: one small trusted local webview (`popover-<n>`, the app's own page) layered
//! over a site tab right under a button of the site's page ([`crate::popover`] places it), so the
//! site stays visible and interactive around it. The app decides what the card shows and which
//! commands it may call; this module only owns the webview and the session.
//!
//! A session has the opening's number (`seq`, so a stale click from an earlier opening fails), the
//! tab that opened it (`from`), the placement and the app's own `meta` (flattened into the session
//! JSON the popover page reads, e.g. `kind`, `contextUrl`).
//!
//! Z-order: a new child webview is placed on top of its siblings (wry: HWND_TOP on Windows,
//! addSubview on macOS). A tab webview created after the popover would cover it, so the popover is
//! recreated whenever a tab webview was created since it was ([`tab_created`]).
//!
//! Closing: the popover closes when the source tab's layout changes ([`layout_changed`]), when the
//! app says the tab moved on ([`tab_navigated`]), when the shell is raised over the tabs
//! ([`shell_raised`]) or by the app's own commands ([`close`]). A click that lands right after a
//! close is the same click that blurred it: it toggles closed instead of reopening.
use crate::host::tab_webview;
use crate::policy;
use crate::popover::{self, Placement, Rect};
use serde::Serialize;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Mutex, MutexGuard, OnceLock};
use std::time::{Duration, Instant};
use tauri::webview::NewWindowResponse;
use tauri::{AppHandle, LogicalPosition, LogicalSize, Manager, Webview, WebviewBuilder, WebviewUrl};
/// What the app declares about its popover.
#[derive(Clone, Copy, Debug)]
pub struct PopoverConfig {
/// The app page the popover loads (`index.html?popover=drafts`).
pub url: &'static str,
/// The window event a reused popover page listens to, re-reading its session (detail: `seq`).
pub open_event: &'static str,
/// macOS has no transparent webview without the private API: the margin shows this ground.
pub ground: (u8, u8, u8),
}
/// Bumped whenever a tab webview is created (see the z-order note above).
static TAB_GENERATION: AtomicU64 = AtomicU64::new(0);
/// A click that lands right after the popover closed on blur is the same click that blurred it.
const TOGGLE_WINDOW: Duration = Duration::from_millis(350);
pub fn tab_created() {
TAB_GENERATION.fetch_add(1, Ordering::SeqCst);
}
/// What the popover shows.
#[derive(Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct Session {
pub seq: u64,
pub from: String,
/// The app's own fields, flattened into this object.
#[serde(flatten)]
pub meta: serde_json::Map<String, serde_json::Value>,
pub placement: Placement,
#[serde(skip)]
tab_rect: Rect,
}
#[derive(Default)]
struct Popover {
label: Option<String>,
created_generation: u64,
next_label: u64,
seq: u64,
open: Option<Session>,
last_closed: Option<(String, Instant)>,
}
fn state() -> MutexGuard<'static, Popover> {
static STATE: OnceLock<Mutex<Popover>> = OnceLock::new();
STATE.get_or_init(Default::default).lock().unwrap_or_else(|poisoned| poisoned.into_inner())
}
/// Only the popover webview, on the app's own local origin.
pub fn popover_only(webview: &Webview) -> Result<(), String> {
let url = webview.url().map_err(|_| "FORBIDDEN: cannot verify popover origin")?;
if popover::valid_label(webview.label()) && policy::local_navigation_allowed(&url, cfg!(debug_assertions)) {
Ok(())
} else {
Err("FORBIDDEN: popover required".into())
}
}
/// The open session, if `seq` names it and the caller is the current popover webview.
pub fn current(webview: &Webview, seq: u64) -> Result<Session, String> {
popover_only(webview)?;
let data = state();
if data.label.as_deref() != Some(webview.label()) {
return Err("FORBIDDEN: stale popover".into());
}
match &data.open {
Some(session) if session.seq == seq => Ok(session.clone()),
_ => Err("CLOSED: the popover is not open".into()),
}
}
/// The session of the popover `webview` (None when it is not the current popover or is closed).
pub fn session_of(webview: &Webview) -> Result<Option<Session>, String> {
popover_only(webview)?;
let data = state();
if data.label.as_deref() != Some(webview.label()) {
return Ok(None);
}
Ok(data.open.clone())
}
/// Whether tab `id` opened the popover that is open now.
pub fn opened_by(id: &str) -> bool {
state().open.as_ref().is_some_and(|session| session.from == id)
}
fn logical_rect(tab: &Webview) -> Result<Rect, String> {
// GTK child webviews report no position of their own; the host knows where it put them.
#[cfg(target_os = "linux")]
if let Some((x, y, width, height)) = crate::host::placed_rect(tab) {
return Ok(Rect::new(x, y, width, height));
}
let scale = tab.window().scale_factor().map_err(|e| format!("POPOVER: {e}"))?;
let position = tab.position().map_err(|e| format!("POPOVER: {e}"))?.to_logical::<f64>(scale);
let size = tab.size().map_err(|e| format!("POPOVER: {e}"))?.to_logical::<f64>(scale);
Ok(Rect::new(position.x, position.y, size.width, size.height))
}
fn builder(config: &PopoverConfig, label: &str) -> WebviewBuilder<tauri::Wry> {
let builder = WebviewBuilder::new(label, WebviewUrl::App(config.url.into()))
.disable_drag_drop_handler()
.focused(true)
.on_navigation(|url| policy::local_navigation_allowed(url, cfg!(debug_assertions)))
.on_new_window(|_, _| NewWindowResponse::Deny);
// The card draws its own rounded corners and shadow over the site. macOS would need the private
// API for transparency; there the margin shows the popover's ground instead.
#[cfg(not(target_os = "macos"))]
let builder = {
let _ = config.ground;
builder.transparent(true)
};
#[cfg(target_os = "macos")]
let builder = builder.background_color(tauri::window::Color(config.ground.0, config.ground.1, config.ground.2, 255));
builder
}
/// Opens (or moves) the popover for tab `from` under `anchor` (the button, in the tab's viewport).
/// An error means the caller should fall back to something else.
pub fn open(app: &AppHandle, config: &PopoverConfig, tab: &Webview, from: String, meta: serde_json::Map<String, serde_json::Value>, anchor: Rect) -> Result<(), String> {
let tab_rect = logical_rect(tab)?;
let placement = popover::place(anchor, tab_rect).ok_or("POPOVER: no room in this pane")?;
let window = tab.window();
let generation = TAB_GENERATION.load(Ordering::SeqCst);
let (label, fresh, session) = {
let mut data = state();
if let Some((closed_from, at)) = &data.last_closed {
if *closed_from == from && at.elapsed() < TOGGLE_WINDOW && data.open.is_none() {
data.last_closed = None;
return Ok(());
}
}
// Recreate when a tab webview may now sit above it, or when it is gone.
let existing = data.label.clone().and_then(|label| app.get_webview(&label));
let reuse = existing.filter(|_| data.created_generation == generation);
if reuse.is_none() {
if let Some(old) = data.label.take().and_then(|label| app.get_webview(&label)) {
let _ = old.close();
}
data.next_label += 1;
data.label = Some(format!("{}{}", popover::LABEL_PREFIX, data.next_label));
data.created_generation = generation;
}
data.seq += 1;
let session = Session { seq: data.seq, from, meta, placement, tab_rect };
data.open = Some(session.clone());
(data.label.clone().unwrap_or_default(), reuse.is_none(), session)
};
let rect = placement.webview;
let popover = if fresh {
window
.add_child(builder(config, &label), LogicalPosition::new(rect.x, rect.y), LogicalSize::new(rect.width, rect.height))
.map_err(|error| {
let mut data = state();
data.label = None;
data.open = None;
format!("POPOVER: {error}")
})?
} else {
let popover = app.get_webview(&label).ok_or("POPOVER: missing webview")?;
crate::host::place_child(&popover, rect.x, rect.y, rect.width, rect.height);
// The page re-reads its session (placement, content) on this event.
let _ = popover.eval(&format!("window.dispatchEvent(new CustomEvent('{}',{{detail:{}}}))", config.open_event, session.seq));
popover
};
// Linux: out of the window box, over the tabs (see `host::place_child`).
#[cfg(target_os = "linux")]
if fresh {
crate::host::place_child(&popover, rect.x, rect.y, rect.width, rect.height);
}
let _ = popover.show();
let _ = popover.set_focus();
Ok(())
}
/// Hides the popover. `refocus` gives keyboard focus back to the tab (Esc, after inserting).
pub fn close(app: &AppHandle, refocus: bool) -> Option<Session> {
let (session, label) = {
let mut data = state();
let session = data.open.take()?;
data.last_closed = Some((session.from.clone(), Instant::now()));
(session, data.label.clone())
};
if let Some(popover) = label.and_then(|label| app.get_webview(&label)) {
let _ = popover.hide();
}
if refocus {
if let Some(tab) = tab_webview(app, &session.from) {
let _ = tab.set_focus();
}
}
Some(session)
}
/// Whether the popover must close after a layout change: its source tab is gone, hidden or moved.
fn layout_stale(session: &Session, tabs: &[(String, Rect, bool)]) -> bool {
!tabs.iter().any(|(id, rect, visible)| {
*id == session.from
&& *visible
&& (rect.x - session.tab_rect.x).abs() < 2.0
&& (rect.y - session.tab_rect.y).abs() < 2.0
&& (rect.width - session.tab_rect.width).abs() < 2.0
&& (rect.height - session.tab_rect.height).abs() < 2.0
})
}
/// Layout changed (tabs_sync): close unless the source tab is still visible at the same place.
pub fn layout_changed(app: &AppHandle, tabs: &[(String, Rect, bool)]) {
let stale = state().open.as_ref().is_some_and(|session| layout_stale(session, tabs));
if stale {
close(app, false);
}
}
/// Tab `id` navigated (SPA or full load): close if it opened the popover and `moved_on` says the
/// session's context is gone (the app compares its own `meta` with the new place).
pub fn tab_navigated(app: &AppHandle, id: &str, moved_on: impl Fn(&Session) -> bool) {
let navigated = state().open.as_ref().is_some_and(|session| session.from == id && moved_on(session));
if navigated {
close(app, false);
}
}
/// The shell is raised over the tabs for one of its own overlays: the popover would sit under it.
pub fn shell_raised(app: &AppHandle) {
close(app, false);
}
#[cfg(test)]
mod tests {
use super::*;
fn session(from: &str, tab: Rect) -> Session {
let placement = popover::place(Rect::new(10.0, 10.0, 20.0, 20.0), tab).unwrap();
Session { seq: 1, from: from.into(), meta: Default::default(), placement, tab_rect: tab }
}
#[test]
fn a_layout_change_keeps_the_popover_only_for_the_same_visible_tab_in_the_same_place() {
let tab = Rect::new(100.0, 90.0, 800.0, 700.0);
let s = session("a", tab);
assert!(!layout_stale(&s, &[("a".into(), Rect::new(101.0, 91.0, 799.0, 701.0), true)]));
assert!(layout_stale(&s, &[("a".into(), Rect::new(100.0, 90.0, 800.0, 700.0), false)]), "hidden");
assert!(layout_stale(&s, &[("a".into(), Rect::new(160.0, 90.0, 800.0, 700.0), true)]), "moved");
assert!(layout_stale(&s, &[("b".into(), tab, true)]), "another tab");
assert!(layout_stale(&s, &[]), "gone");
}
#[test]
fn the_session_json_carries_the_app_fields_beside_its_own() {
let tab = Rect::new(0.0, 0.0, 800.0, 700.0);
let mut s = session("a", tab);
s.meta.insert("kind".into(), "reply".into());
s.meta.insert("contextUrl".into(), "https://example.com/p".into());
let json = serde_json::to_value(&s).unwrap();
assert_eq!(json["seq"], 1);
assert_eq!(json["from"], "a");
assert_eq!(json["kind"], "reply");
assert_eq!(json["contextUrl"], "https://example.com/p");
assert!(json["placement"]["card"].is_object());
assert!(json.get("tabRect").is_none() && json.get("tab_rect").is_none());
}
}