Files
work-app/src-tauri/src/webviews.rs
T
Vincent 5eb9157b7e Place apps below the title bar, not behind it
A child webview is positioned against the window frame while the shell
measures the hole it left from inside the content view. Borderless, those
origins coincided and it never showed. With a title bar back they are a
title bar apart, so every app was drawn that much too high - painting
over the right-hand part of the bar, which read as the bar being tinted
by whichever site was open, and leaving a strip of the same height along
the bottom.

Tauri cannot report that height. Both inner_position/outer_position and
inner_size/outer_size come back identical on macOS - measured, both
differences were zero against a window whose content is plainly a title
bar shorter than its frame. NSWindow.contentLayoutRect knows, so it is
asked once and cached.

Also hides the window title, and centres the navigation row in its own
strip rather than letting it crowd the bar above it.
2026-09-01 15:14:56 +02:00

828 lines
31 KiB
Rust

//! One child webview per configured app, stacked in the stage rect.
//!
//! Child webviews are native views layered above the shell's content. They take
//! no part in CSS layout, so the shell measures the stage and reports it here,
//! and anything the shell wants to draw over an app (a dialog) requires hiding
//! the app first.
use std::collections::HashMap;
use std::sync::atomic::{AtomicUsize, Ordering};
use serde::Serialize;
use tauri::{
webview::{NewWindowResponse, WebviewBuilder},
AppHandle, Emitter, LogicalPosition, LogicalSize, Manager, WebviewUrl,
};
use url::Url;
use crate::config::{App, Config};
use crate::routing::{self, AppScope, Decision};
/// Scheme prefix the injected script uses to talk back.
///
/// A made-up scheme rather than Tauri IPC: IPC to a remote origin means
/// granting google.com the ability to call into this app, and none of these
/// messages need anything that dangerous. `on_navigation` answers them and
/// cancels the navigation, so nothing ever loads.
const SCHEME_PREFIX: &str = "workapp-";
pub fn label_for(app_id: &str) -> String {
format!("app-{app_id}")
}
#[derive(Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct SwitchEvent {
pub app_id: String,
pub url: String,
}
#[derive(Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct UrlEvent {
pub app_id: String,
pub url: String,
}
/// A notification the user clicked, and the page object that raised it.
#[derive(Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct NotificationClick {
pub app_id: String,
pub notification_id: String,
}
#[derive(Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct IconEvent {
pub app_id: String,
pub icon: String,
}
#[derive(Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct HiddenEvent {
pub app_id: String,
pub selector: String,
}
/// The page script, with this app's configuration written above it.
fn script_for(app: &App) -> String {
let cfg = serde_json::json!({
"scopes": app.scopes(),
"hidden": app.hidden,
"name": app.name,
});
format!(
"window.__WORKAPP = {};\n{}",
cfg,
include_str!("inject.js")
)
}
/// Splits a sentinel URL into its kind and query parameters.
pub fn sentinel(url: &Url) -> Option<(String, HashMap<String, String>)> {
let kind = url.scheme().strip_prefix(SCHEME_PREFIX)?.to_string();
let params = url
.query_pairs()
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect();
Some((kind, params))
}
/// Acts on a routing decision. Called off the navigation delegate, never on it.
fn apply(handle: &AppHandle, decision: Decision) {
match decision {
Decision::Stay => {}
Decision::Switch { app_id, url } => {
let _ = handle.emit("switch-app", SwitchEvent { app_id, url });
}
Decision::External { url } => {
let _ = tauri_plugin_opener::open_url(url, None::<&str>);
}
}
}
/// Answers one message from the page, without blocking the delegate.
fn handle_sentinel(
handle: &AppHandle,
from: &str,
kind: String,
params: HashMap<String, String>,
scopes: Vec<AppScope>,
) {
let handle = handle.clone();
let from = from.to_string();
tauri::async_runtime::spawn(async move {
match kind.as_str() {
"route" => {
let Some(target) = params.get("u") else { return };
match routing::decide(target, Some(&from), &scopes) {
// Only an identity provider reaches here, and the click was
// already cancelled to ask the question — so completing it
// is now this side's job.
Decision::Stay => {
if let Some(wv) = handle.get_webview(&label_for(&from)) {
if let Ok(u) = Url::parse(target) {
let _ = wv.navigate(u);
}
}
}
other => apply(&handle, other),
}
}
"hide" => {
let Some(selector) = params.get("s") else { return };
let state = handle.state::<crate::commands::AppState>();
if state.add_hidden(&from, selector).is_ok() {
let _ = handle.emit(
"hidden-added",
HiddenEvent { app_id: from.clone(), selector: selector.clone() },
);
}
}
"notify" => {
let title = params.get("t").cloned().unwrap_or_default();
let body = params.get("b").cloned().unwrap_or_default();
let app_name = params.get("a").cloned().unwrap_or_default();
let notification_id = params.get("id").cloned().unwrap_or_default();
notify(&handle, &from, &app_name, &title, &body, notification_id);
}
// Reports what the page found, over the same channel a real
// notification uses — so a silent failure says which half broke.
"diag" => {
let state = handle.state::<crate::commands::AppState>();
let mut parts: Vec<String> = params
.iter()
.map(|(k, v)| format!("{k}={v}"))
.collect();
parts.sort();
state.diag.lock().unwrap().insert(from.clone(), parts.join(" "));
}
// The site's own title said something arrived while nobody was
// looking. Used because a site may decline to raise a notification
// itself, but its unread count never lies.
"unread" => {
let total = params.get("n").cloned().unwrap_or_default();
let delta = params.get("d").cloned().unwrap_or_default();
let name = handle
.state::<crate::commands::AppState>()
.cfg()
.app(&from)
.map(|a| a.name.clone())
.unwrap_or_else(|| "Work".into());
let n: u32 = delta.parse().unwrap_or(1);
{
let state = handle.state::<crate::commands::AppState>();
let mut counts = state.unread.lock().unwrap();
*counts.entry(from.clone()).or_insert(0) += n;
}
let state = handle.state::<crate::commands::AppState>();
let _ = handle.emit("unread-changed", crate::commands::unread_list(&state));
let title = if n == 1 { "1 new".to_string() } else { format!("{n} new") };
notify(&handle, &from, &name, &title, &format!("{total} unread"), String::new());
}
// The site told us its own icon. Stored rather than merely shown,
// so the nav is right at launch instead of blank until every page
// has finished loading.
"icon" => {
let Some(url) = params.get("u") else { return };
let state = handle.state::<crate::commands::AppState>();
let changed = {
let mut cfg = state.config.lock().unwrap();
match cfg.apps.iter_mut().find(|a| a.id == from) {
Some(a) if a.icon.as_deref() != Some(url.as_str()) => {
a.icon = Some(url.clone());
true
}
_ => false,
}
};
if changed {
let _ = state.save();
let _ = handle.emit(
"icon-changed",
IconEvent { app_id: from.clone(), icon: url.clone() },
);
}
}
"manage" => {
let _ = handle.emit("manage-hidden", from.clone());
}
_ => {}
}
});
}
/// Raises a macOS notification and waits, on its own thread, to see it clicked.
///
/// Not the notification plugin: that has no way to report a click, and a
/// notification you cannot click through to the message is barely a
/// notification. `send_notification` blocks until the user acts or it is
/// dismissed, which is why this gets a thread of its own.
fn notify(
handle: &AppHandle,
app_id: &str,
app_name: &str,
title: &str,
body: &str,
notification_id: String,
) {
// The app's own name leads, or a notification from four tools in one
// window says nothing about which one wants you.
// Waiting for a click costs a parked thread, and a notification left
// sitting in Notification Centre never resolves — so the wait is capped.
// Past the cap the notification still appears, it just cannot be clicked
// through, which is a far better failure than an unbounded thread count.
const MAX_WAITING: usize = 32;
static WAITING: AtomicUsize = AtomicUsize::new(0);
let heading = if app_name.is_empty() { "Work".to_string() } else { app_name.to_string() };
let subtitle = title.to_string();
let message = body.to_string();
let handle = handle.clone();
let app_id = app_id.to_string();
let waiting = WAITING.fetch_add(1, Ordering::SeqCst) < MAX_WAITING;
if !waiting {
WAITING.fetch_sub(1, Ordering::SeqCst);
}
let spawned = std::thread::Builder::new()
// Small, because these park rather than compute, and there may be many.
.stack_size(512 * 1024)
.spawn(move || {
let state = handle.state::<crate::commands::AppState>();
// Recorded before the call, not after: `wait_for_click` blocks until
// the user acts, so waiting for the return made a notification that
// was sitting on screen read as "none yet".
*state.last_notification.lock().unwrap() = format!("{heading} / {subtitle} → raised");
// `wait_for_click` is what makes this block until the user acts.
// Without it the call returns immediately with `None`, and no click is
// ever observed — the notification appears, and clicking it does
// nothing at all.
let mut options = mac_notification_sys::Notification::new();
options.wait_for_click(waiting);
let response = mac_notification_sys::send_notification(
&heading,
if subtitle.is_empty() { None } else { Some(&subtitle) },
&message,
Some(&options),
);
if waiting {
WAITING.fetch_sub(1, Ordering::SeqCst);
}
match response {
Ok(mac_notification_sys::NotificationResponse::Click) => {
*state.last_notification.lock().unwrap() =
format!("{heading} / {subtitle} → clicked");
let _ = handle.emit(
"notification-clicked",
NotificationClick { app_id, notification_id },
);
}
Ok(other) => {
*state.last_notification.lock().unwrap() =
format!("{heading} / {subtitle}{other:?}");
}
Err(e) => {
eprintln!("could not raise a notification: {e}");
*state.last_notification.lock().unwrap() = format!("failed: {e}");
}
}
});
if let Err(e) = spawned {
eprintln!("could not start a notification thread: {e}");
if waiting {
WAITING.fetch_sub(1, Ordering::SeqCst);
}
}
}
/// Raises one app's view above its siblings, and keeps every view alive.
///
/// The obvious way to switch apps is to hide the others, and it is wrong.
/// WebKit treats a hidden `NSView` as a page that is not visible: it throttles
/// timers, stops polling, and eventually suspends the page altogether. A tool
/// left in the background then quietly stops fetching, and its notifications
/// never arrive — which is the whole reason the other apps stay loaded.
///
/// So nothing is hidden. Every view keeps its full size and stays in the view
/// hierarchy; the active one is simply ordered on top. Sibling views covering
/// each other is not something WebKit tracks, so all of them keep running.
#[cfg(target_os = "macos")]
fn raise_to_front(handle: &AppHandle, app_id: &str) {
use objc2::runtime::AnyObject;
let Some(wv) = handle.get_webview(&label_for(app_id)) else { return };
let _ = wv.with_webview(|platform| unsafe {
let view = platform.inner() as *mut AnyObject;
if view.is_null() {
return;
}
let superview: *mut AnyObject = objc2::msg_send![view, superview];
if superview.is_null() {
return;
}
// NSWindowAbove, relative to nil: straight to the top of the stack.
let above: isize = 1;
let nil: *mut AnyObject = std::ptr::null_mut();
let _: () = objc2::msg_send![
superview,
addSubview: view,
positioned: above,
relativeTo: nil,
];
});
}
#[cfg(not(target_os = "macos"))]
fn raise_to_front(_: &AppHandle, _: &str) {}
/// Stops WebKit throttling the page when the window is covered by another app.
///
/// Window occlusion is the one kind of hiding WebKit does track, and it applies
/// to every view in the window at once — so a Work window buried behind a
/// browser would stop fetching mail. Private API, hence the check first: if a
/// future WebKit drops it, notifications degrade rather than the app breaking.
#[cfg(target_os = "macos")]
fn keep_running_while_covered(handle: &AppHandle, app_id: &str) {
use objc2::runtime::{AnyObject, Bool};
let Some(wv) = handle.get_webview(&label_for(app_id)) else { return };
let _ = wv.with_webview(|platform| unsafe {
let view = platform.inner() as *mut AnyObject;
if view.is_null() {
return;
}
let sel = objc2::sel!(_setWindowOcclusionDetectionEnabled:);
let responds: Bool = objc2::msg_send![view, respondsToSelector: sel];
if responds.as_bool() {
let _: () = objc2::msg_send![view, _setWindowOcclusionDetectionEnabled: false];
}
});
}
#[cfg(not(target_os = "macos"))]
fn keep_running_while_covered(_: &AppHandle, _: &str) {}
/// A still of one app, as a PNG data URL, for the shell to blur behind a dialog.
///
/// A dialog is drawn by the shell webview, and every app's webview is a native
/// view that paints above it — so an app has to be moved out of the way before
/// a dialog can be seen at all, and once it is moved there is nothing left to
/// blur. A still taken on the way out is the only way to keep the background
/// there. Deliberately small: it is going behind a blur.
#[cfg(target_os = "macos")]
pub fn snapshot(handle: &AppHandle, app_id: &str) -> Option<String> {
use block2::RcBlock;
use objc2_app_kit::{NSBitmapImageFileType, NSBitmapImageRep, NSImage};
use objc2_foundation::{
MainThreadMarker, NSDataBase64EncodingOptions, NSDictionary, NSError, NSNumber,
};
use objc2_web_kit::{WKSnapshotConfiguration, WKWebView};
let wv = handle.get_webview(&label_for(app_id))?;
let (tx, rx) = std::sync::mpsc::channel::<Option<String>>();
let sent = wv.with_webview(move |platform| unsafe {
let ptr = platform.inner() as *const WKWebView;
let Some(mtm) = MainThreadMarker::new() else {
let _ = tx.send(None);
return;
};
if ptr.is_null() {
let _ = tx.send(None);
return;
}
let webview: &WKWebView = &*ptr;
let config = WKSnapshotConfiguration::new(mtm);
config.setSnapshotWidth(Some(&NSNumber::new_f64(640.0)));
let handler = RcBlock::new(move |image: *mut NSImage, _error: *mut NSError| {
let encoded = (|| {
let image = image.as_ref()?;
let tiff = image.TIFFRepresentation()?;
let rep = NSBitmapImageRep::imageRepWithData(&tiff)?;
let png = rep.representationUsingType_properties(
NSBitmapImageFileType::PNG,
&NSDictionary::new(),
)?;
Some(
png.base64EncodedStringWithOptions(NSDataBase64EncodingOptions::empty())
.to_string(),
)
})();
let _ = tx.send(encoded);
});
webview.takeSnapshotWithConfiguration_completionHandler(Some(&config), &handler);
});
if sent.is_err() {
return None;
}
// A snapshot that takes longer than this is not worth making someone wait
// for; the dialog opens over a plain backdrop instead.
rx.recv_timeout(std::time::Duration::from_millis(2500))
.ok()
.flatten()
.map(|b64| format!("data:image/png;base64,{b64}"))
}
#[cfg(not(target_os = "macos"))]
pub fn snapshot(_: &AppHandle, _: &str) -> Option<String> {
None
}
/// Rounds an app's right-hand corners to match the window's inner frame.
///
/// A `rounded-xl` on the container around it does nothing: the app is a native
/// view sitting on top, not something the shell lays out, so it keeps its own
/// square corners and overhangs the curve. The rounding has to go on its layer.
///
/// Only the right pair — the left edge butts against the nav, and rounding it
/// would cut a notch out of the middle of the window.
#[cfg(target_os = "macos")]
pub fn set_corner_radius(handle: &AppHandle, app_id: &str, radius: f64) {
use objc2::runtime::AnyObject;
let Some(wv) = handle.get_webview(&label_for(app_id)) else { return };
let _ = wv.with_webview(move |platform| unsafe {
let view = platform.inner() as *mut AnyObject;
if view.is_null() {
return;
}
let _: () = objc2::msg_send![view, setWantsLayer: true];
let layer: *mut AnyObject = objc2::msg_send![view, layer];
if layer.is_null() {
return;
}
// kCALayerMaxXMinYCorner | kCALayerMaxXMaxYCorner — both right corners,
// whichever way round the layer's Y axis happens to run.
let right_corners: usize = (1 << 1) | (1 << 3);
let _: () = objc2::msg_send![layer, setCornerRadius: radius];
let _: () = objc2::msg_send![layer, setMaskedCorners: right_corners];
let _: () = objc2::msg_send![layer, setMasksToBounds: radius > 0.0];
});
}
#[cfg(not(target_os = "macos"))]
pub fn set_corner_radius(_: &AppHandle, _: &str, _: f64) {}
/// Turns on WKWebView's two-finger back and forward swipes.
///
/// wry supports it but Tauri does not expose it, so it is set on the native
/// view after the fact. It is the only navigation gesture the app has, now
/// that there is no toolbar carrying arrows.
#[cfg(target_os = "macos")]
fn enable_swipe_navigation(handle: &AppHandle, app_id: &str) {
use objc2_web_kit::WKWebView;
if let Some(wv) = handle.get_webview(&label_for(app_id)) {
let _ = wv.with_webview(|platform| unsafe {
let ptr = platform.inner() as *const WKWebView;
if ptr.is_null() {
return;
}
(*ptr).setAllowsBackForwardNavigationGestures(true);
});
}
}
#[cfg(not(target_os = "macos"))]
fn enable_swipe_navigation(_: &AppHandle, _: &str) {}
/// Builds the child webview for one app and parks it in the stage rect.
pub fn create(
handle: &AppHandle,
app: &App,
cfg: &Config,
stage: (f64, f64, f64, f64),
) -> Result<(), String> {
let window = handle
.get_window("main")
.ok_or_else(|| "main window is gone".to_string())?;
let url = Url::parse(&app.url).map_err(|e| format!("{}: {e}", app.url))?;
let id = app.id.clone();
let scopes = cfg.scopes();
let nav_handle = handle.clone();
let nav_id = id.clone();
let nav_scopes = scopes.clone();
let win_handle = handle.clone();
let win_id = id.clone();
let win_scopes = scopes.clone();
let load_handle = handle.clone();
let load_id = id.clone();
let builder = WebviewBuilder::new(label_for(&id), WebviewUrl::External(url))
.user_agent(&app.ua())
.initialization_script(script_for(app))
.on_navigation(move |url| {
// A sentinel is a question, not a destination: answer it and
// cancel. Everything else is allowed — a strict filter here would
// break every OAuth redirect chain.
if let Some((kind, params)) = sentinel(url) {
handle_sentinel(&nav_handle, &nav_id, kind, params, nav_scopes.clone());
return false;
}
let _ = nav_handle.emit(
"url-changed",
UrlEvent { app_id: nav_id.clone(), url: url.to_string() },
);
true
})
.on_new_window(move |url, _features| {
// Nothing may open a window of its own; the same rules apply.
match routing::decide(url.as_str(), Some(&win_id), &win_scopes) {
Decision::Stay => {
if let Some(wv) = win_handle.get_webview(&label_for(&win_id)) {
let _ = wv.navigate(url);
}
}
other => apply(&win_handle, other),
}
NewWindowResponse::Deny
})
// The script carries a snapshot of the hidden list from when the view
// was built, so anything chosen since would come back on reload. This
// re-asserts the real list on every navigation.
.on_page_load(move |_wv, _payload| {
let state = load_handle.state::<crate::commands::AppState>();
let hidden = state
.cfg()
.app(&load_id)
.map(|a| a.hidden.clone())
.unwrap_or_default();
push_hidden(&load_handle, &load_id, &hidden);
// A fresh page starts out believing it is visible, and the call
// that said otherwise was made before it had loaded — so a
// background app would come back thinking it is on screen, and
// stay quiet about everything that arrived.
let active = state.active.lock().unwrap().clone();
let is_active = active.as_deref() == Some(load_id.as_str());
set_page_visibility(&load_handle, &load_id, is_active);
});
window
.add_child(
builder,
LogicalPosition::new(stage.0, stage.1 + chrome_offset(handle)),
LogicalSize::new(stage.2, stage.3),
)
.map_err(|e| e.to_string())?;
enable_swipe_navigation(handle, &id);
keep_running_while_covered(handle, &id);
if let Some(wv) = handle.get_webview(&label_for(&id)) {
let _ = wv.set_zoom(app.zoom);
}
Ok(())
}
/// How far the window's frame sits above its content, in logical pixels.
///
/// A child webview is positioned against the window frame, but the shell
/// measures the hole it left from inside the content view — and with a title
/// bar those two origins are a title bar apart. Without this every app is drawn
/// a title bar too high: it paints over the right half of the bar, which is why
/// the bar looked like it was tinted by whichever site was open, and leaves an
/// empty strip of the same height along the bottom.
///
/// Asks AppKit how tall the title bar is, and remembers the answer.
///
/// Tauri cannot say. Both `inner_position`/`outer_position` and
/// `inner_size`/`outer_size` come back identical on macOS — measured, both
/// reported a difference of zero against a window whose content is plainly a
/// title bar shorter than its frame. `contentLayoutRect` is the one thing that
/// knows, so it is asked once and cached; the height does not change.
#[cfg(target_os = "macos")]
pub fn measure_chrome(handle: &AppHandle) -> f64 {
use objc2::runtime::AnyObject;
use objc2_foundation::NSRect;
let Some(wv) = handle.get_webview_window("main") else { return 0.0 };
let (tx, rx) = std::sync::mpsc::channel::<f64>();
let sent = wv.with_webview(move |platform| unsafe {
let view = platform.inner() as *mut AnyObject;
if view.is_null() {
let _ = tx.send(0.0);
return;
}
let window: *mut AnyObject = objc2::msg_send![view, window];
if window.is_null() {
let _ = tx.send(0.0);
return;
}
let frame: NSRect = objc2::msg_send![window, frame];
let content: NSRect = objc2::msg_send![window, contentLayoutRect];
let _ = tx.send((frame.size.height - content.size.height).max(0.0));
});
if sent.is_err() {
return 0.0;
}
rx.recv_timeout(std::time::Duration::from_millis(500)).unwrap_or(0.0)
}
#[cfg(not(target_os = "macos"))]
pub fn measure_chrome(_: &AppHandle) -> f64 {
0.0
}
/// How far the window's frame sits above its content, in logical pixels.
pub fn chrome_offset(handle: &AppHandle) -> f64 {
*handle.state::<crate::commands::AppState>().chrome.lock().unwrap()
}
fn radius(handle: &AppHandle) -> f64 {
*handle.state::<crate::commands::AppState>().radius.lock().unwrap()
}
/// Tells a page whether it is the one being looked at.
///
/// Separate from the view's real visibility on purpose — see the note in
/// `inject.js`. The view never hides; only the page's own idea of it changes.
fn set_page_visibility(handle: &AppHandle, app_id: &str, visible: bool) {
let Some(wv) = handle.get_webview(&label_for(app_id)) else { return };
let _ = wv.eval(&format!(
"window.__workAppSetVisible && window.__workAppSetVisible({visible})"
));
}
/// Brings one app to the front. Every other app stays live behind it.
pub fn show_only(
handle: &AppHandle,
app_id: Option<&str>,
cfg: &Config,
stage: (f64, f64, f64, f64),
) {
let top = stage.1 + chrome_offset(handle);
for app in &cfg.apps {
let Some(wv) = handle.get_webview(&label_for(&app.id)) else { continue };
let _ = wv.set_position(LogicalPosition::new(stage.0, top));
let _ = wv.set_size(LogicalSize::new(stage.2, stage.3));
let _ = wv.set_zoom(app.zoom);
let _ = wv.show();
set_corner_radius(handle, &app.id, radius(handle));
set_page_visibility(handle, &app.id, Some(app.id.as_str()) == app_id);
}
if let Some(id) = app_id {
raise_to_front(handle, id);
}
}
/// Re-sizes every app to the stage. Called on every layout change.
///
/// All of them, not just the visible one: they are all really on screen now,
/// stacked, so one left at a stale size would show around the edges of the
/// active app the moment the window grew.
pub fn set_stage(
handle: &AppHandle,
active: Option<&str>,
stage: (f64, f64, f64, f64),
radius: f64,
) {
let cfg = handle.state::<crate::commands::AppState>().cfg();
let top = stage.1 + chrome_offset(handle);
for app in &cfg.apps {
let Some(wv) = handle.get_webview(&label_for(&app.id)) else { continue };
let _ = wv.set_position(LogicalPosition::new(stage.0, top));
let _ = wv.set_size(LogicalSize::new(stage.2, stage.3));
set_corner_radius(handle, &app.id, radius);
}
if let Some(id) = active {
raise_to_front(handle, id);
}
}
/// Clears the stage so the shell can draw a dialog over the whole window.
///
/// Parked below the window rather than hidden. Hiding is what WebKit reads as
/// "not visible", and it would suspend every app for as long as Settings stayed
/// open — long enough to miss mail. A view moved out of the window is still a
/// visible view as far as WebKit is concerned; it simply is not on screen.
pub fn hide_all(handle: &AppHandle, cfg: &Config, stage: (f64, f64, f64, f64)) {
for app in &cfg.apps {
if let Some(wv) = handle.get_webview(&label_for(&app.id)) {
let _ = wv.set_position(LogicalPosition::new(
stage.0,
stage.1 + chrome_offset(handle) + PARKED_OFFSET,
));
}
// Nothing is on screen behind a dialog, so nothing should think it is.
set_page_visibility(handle, &app.id, false);
}
}
/// Far enough below the window that nothing shows, near enough that no
/// coordinate system objects to it.
const PARKED_OFFSET: f64 = 10_000.0;
pub fn destroy(handle: &AppHandle, app_id: &str) {
if let Some(wv) = handle.get_webview(&label_for(app_id)) {
let _ = wv.close();
}
}
/// Re-applies an app's hidden selectors without a reload.
///
/// The script owns the stylesheet, so changing the list from Settings is a
/// message to the page rather than a rebuild of the view.
pub fn push_hidden(handle: &AppHandle, app_id: &str, hidden: &[String]) {
let Some(wv) = handle.get_webview(&label_for(app_id)) else { return };
let json = serde_json::to_string(hidden).unwrap_or_else(|_| "[]".into());
let script = format!(
r#"(function(){{
var css = {json}.length ? {json}.join(',\n') + ' {{ display: none !important; }}' : '';
var el = document.getElementById('__workapp_hidden');
if (!el) {{
el = document.createElement('style');
el.id = '__workapp_hidden';
(document.head || document.documentElement).appendChild(el);
}}
el.textContent = css;
try {{ localStorage.setItem('__workapp_hidden', JSON.stringify({json})); }} catch (e) {{}}
}})();"#
);
let _ = wv.eval(&script);
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn sentinel_splits_kind_from_parameters() {
let u = Url::parse("workapp-route:/?u=https%3A%2F%2Fx.com%2Fa%3Fb%3D1").unwrap();
let (kind, params) = sentinel(&u).unwrap();
assert_eq!(kind, "route");
assert_eq!(params["u"], "https://x.com/a?b=1");
}
#[test]
fn sentinel_reads_a_notification() {
let u = Url::parse("workapp-notify:/?t=New%20mail&b=From%20Sam&a=Gmail").unwrap();
let (kind, params) = sentinel(&u).unwrap();
assert_eq!(kind, "notify");
assert_eq!(params["t"], "New mail");
assert_eq!(params["b"], "From Sam");
}
#[test]
fn sentinel_reads_a_hide_selector() {
let u = Url::parse("workapp-hide:/?s=%23promo%20.banner").unwrap();
let (kind, params) = sentinel(&u).unwrap();
assert_eq!(kind, "hide");
assert_eq!(params["s"], "#promo .banner");
}
#[test]
fn ordinary_urls_are_not_sentinels() {
assert!(sentinel(&Url::parse("https://github.com/?u=x").unwrap()).is_none());
assert!(sentinel(&Url::parse("mailto:a@b.com").unwrap()).is_none());
}
#[test]
fn the_script_carries_this_apps_own_configuration() {
let app = App {
id: "a".into(),
name: "Gmail".into(),
url: "https://mail.google.com".into(),
scope: vec!["mail.google.com".into()],
group_id: None,
user_agent: None,
hidden: vec![".ad".into()],
icon: None,
zoom: 1.0,
order: 0,
};
let s = script_for(&app);
assert!(s.contains("mail.google.com"));
assert!(s.contains(".ad"));
assert!(s.contains("__workAppReady"));
}
}