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:
parent
7ab3ecd02e
commit
1edc6440ef
2 changed files with 295 additions and 0 deletions
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
293
crates/waybetter-host/src/popover_host.rs
Normal file
293
crates/waybetter-host/src/popover_host.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue