//! 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)> { 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, scopes: Vec, ) { 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::(); 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::(); let mut parts: Vec = 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::() .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::(); let mut counts = state.unread.lock().unwrap(); *counts.entry(from.clone()).or_insert(0) += n; } let state = handle.state::(); 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::(); 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::(); // 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 { 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::>(); 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 { 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::(); 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::(); 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::().chrome.lock().unwrap() } fn radius(handle: &AppHandle) -> f64 { *handle.state::().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::().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")); } }