Two separate faults, both hiding the same feature. The events never reached the shell. They were emitted from inside WebKit's download delegate, which runs on the main thread, and delivering an event means running JavaScript in a webview - which cannot happen from in there. They are now emitted from a spawned task, like every other event in the app that works. And the card was drawn over the page, where it could never be seen: an app's webview is a native view painted above everything the shell draws, so a card over the page is a card behind the page. It now lives in the nav, which is the shell's own - full detail when the nav is open, just the state icon on the rail. Verified end to end on a Gmail attachment: requested, named, saved, and shown as "Downloaded" with a Show button that reveals it.
1096 lines
42 KiB
Rust
1096 lines
42 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,
|
|
}
|
|
|
|
/// A login worth offering to remember. Carries no password: the value stays in
|
|
/// Rust until it is either written to the Keychain or dropped.
|
|
#[derive(Clone, Serialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct PasswordOffer {
|
|
pub app_id: String,
|
|
pub host: String,
|
|
pub account: 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();
|
|
|
|
// Noted so a count does not repeat, seconds later and worse,
|
|
// what the app has just said properly.
|
|
handle
|
|
.state::<crate::commands::AppState>()
|
|
.last_spoke
|
|
.lock()
|
|
.unwrap()
|
|
.insert(from.clone(), std::time::Instant::now());
|
|
|
|
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() },
|
|
);
|
|
}
|
|
}
|
|
|
|
// An app stating its own unread count outright. Absolute, unlike
|
|
// the title watcher's deltas, so it replaces rather than adds.
|
|
"badge" => {
|
|
let Some(total) = params.get("n").and_then(|n| n.parse::<u32>().ok()) else {
|
|
return;
|
|
};
|
|
let state = handle.state::<crate::commands::AppState>();
|
|
let showing = state.active.lock().unwrap().clone();
|
|
let previous = {
|
|
let mut counts = state.unread.lock().unwrap();
|
|
let previous = counts.get(&from).copied().unwrap_or(0);
|
|
if total == 0 {
|
|
counts.remove(&from);
|
|
} else {
|
|
counts.insert(from.clone(), total);
|
|
}
|
|
previous
|
|
};
|
|
let _ = handle.emit("unread-changed", crate::commands::unread_list(&state));
|
|
|
|
if total > previous
|
|
&& showing.as_deref() != Some(from.as_str())
|
|
&& count_may_speak(&handle, &from)
|
|
{
|
|
let name = state
|
|
.cfg()
|
|
.app(&from)
|
|
.map(|a| a.name.clone())
|
|
.unwrap_or_else(|| "Work".into());
|
|
let n = total - previous;
|
|
let title = if n == 1 { "1 new".to_string() } else { format!("{n} new") };
|
|
notify(&handle, &from, &name, &title, &format!("{total} unread"), String::new());
|
|
}
|
|
}
|
|
|
|
// A login was just submitted. The password is held here and offered;
|
|
// the shell is told only the host and the username, because that is
|
|
// all it needs to ask the question and the less that value travels
|
|
// the better.
|
|
"savepw" => {
|
|
let (Some(host), Some(pass)) = (params.get("h"), params.get("p")) else {
|
|
return;
|
|
};
|
|
if pass.is_empty() {
|
|
return;
|
|
}
|
|
let account = params.get("u").cloned().unwrap_or_default();
|
|
let state = handle.state::<crate::commands::AppState>();
|
|
*state.pending_password.lock().unwrap() =
|
|
Some((from.clone(), host.clone(), account.clone(), pass.clone()));
|
|
let _ = handle.emit(
|
|
"password-offer",
|
|
PasswordOffer { app_id: from.clone(), host: host.clone(), account },
|
|
);
|
|
}
|
|
|
|
// Asked for by hand, from the page's own right-click menu.
|
|
"fillpw" => {
|
|
let state = handle.state::<crate::commands::AppState>();
|
|
if let Err(e) = crate::commands::fill_password_for(&handle, &from, &state) {
|
|
eprintln!("could not fill a password: {e}");
|
|
}
|
|
}
|
|
|
|
"emptycache" => {
|
|
empty_cache(&handle, &from);
|
|
}
|
|
|
|
"manage" => {
|
|
let _ = handle.emit("manage-hidden", from.clone());
|
|
}
|
|
|
|
_ => {}
|
|
}
|
|
});
|
|
}
|
|
|
|
/// Whether an unread count should raise a notification of its own.
|
|
///
|
|
/// Two things can suppress it. The setting, for someone who would rather hear
|
|
/// only what an app says in its own words. And an app having just said it:
|
|
/// Gmail raises a proper notification naming the sender, and the count arriving
|
|
/// behind it saying "1 new" is the same news told worse. The window is generous
|
|
/// because a count is noticed on a four-second tick, well after the app spoke.
|
|
fn count_may_speak(handle: &AppHandle, app_id: &str) -> bool {
|
|
let state = handle.state::<crate::commands::AppState>();
|
|
if !state.cfg().settings.count_notifications {
|
|
return false;
|
|
}
|
|
let spoke = state.last_spoke.lock().unwrap().get(app_id).copied();
|
|
match spoke {
|
|
Some(at) => at.elapsed() > std::time::Duration::from_secs(20),
|
|
None => true,
|
|
}
|
|
}
|
|
|
|
/// 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) {}
|
|
|
|
/// Throws away this app's caches and reloads it.
|
|
///
|
|
/// Caches only — never cookies or local storage. Emptying a browser's cache is
|
|
/// a thing you do when a page is serving something stale; signing you out of
|
|
/// the tool while you do it would be a different and much less welcome feature.
|
|
#[cfg(target_os = "macos")]
|
|
pub fn empty_cache(handle: &AppHandle, app_id: &str) {
|
|
use block2::RcBlock;
|
|
use objc2_foundation::{MainThreadMarker, NSDate, NSSet, NSString};
|
|
use objc2_web_kit::WKWebsiteDataStore;
|
|
|
|
let Some(wv) = handle.get_webview(&label_for(app_id)) else { return };
|
|
let handle = handle.clone();
|
|
let app_id = app_id.to_string();
|
|
|
|
let _ = wv.with_webview(move |_platform| unsafe {
|
|
let Some(mtm) = MainThreadMarker::new() else { return };
|
|
|
|
let types = NSSet::from_retained_slice(&[
|
|
NSString::from_str("WKWebsiteDataTypeDiskCache"),
|
|
NSString::from_str("WKWebsiteDataTypeMemoryCache"),
|
|
NSString::from_str("WKWebsiteDataTypeOfflineWebApplicationCache"),
|
|
NSString::from_str("WKWebsiteDataTypeFetchCache"),
|
|
NSString::from_str("WKWebsiteDataTypeServiceWorkerRegistrations"),
|
|
]);
|
|
|
|
let done = RcBlock::new(move || {
|
|
// Reloaded only once the cache is actually gone, or the reload
|
|
// would refill it from what we were trying to throw away.
|
|
if let Some(wv) = handle.get_webview(&label_for(&app_id)) {
|
|
let _ = wv.eval("location.reload(true)");
|
|
}
|
|
});
|
|
|
|
WKWebsiteDataStore::defaultDataStore(mtm)
|
|
.removeDataOfTypes_modifiedSince_completionHandler(
|
|
&types,
|
|
&NSDate::distantPast(),
|
|
&done,
|
|
);
|
|
});
|
|
}
|
|
|
|
#[cfg(not(target_os = "macos"))]
|
|
pub fn empty_cache(_: &AppHandle, _: &str) {}
|
|
|
|
/// 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 dl_handle = handle.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
|
|
})
|
|
// WebKit saves the file happily on its own; what it never does is
|
|
// mention it. Redirected to the chosen folder, and announced.
|
|
.on_download(move |_wv, event| {
|
|
use tauri::webview::DownloadEvent;
|
|
let state = dl_handle.state::<crate::commands::AppState>();
|
|
|
|
match event {
|
|
DownloadEvent::Requested { url, destination } => {
|
|
let dir = crate::downloads::folder(&dl_handle);
|
|
let _ = std::fs::create_dir_all(&dir);
|
|
let name = crate::downloads::name_from(&url, destination);
|
|
let path = crate::downloads::unique(&dir, &name);
|
|
|
|
let id = crate::downloads::next_id();
|
|
state
|
|
.download_paths
|
|
.lock()
|
|
.unwrap()
|
|
.insert(url.to_string(), (id, path.clone()));
|
|
|
|
crate::downloads::announce(
|
|
&dl_handle,
|
|
"download-started",
|
|
crate::downloads::DownloadStarted {
|
|
id,
|
|
name,
|
|
path: path.to_string_lossy().into_owned(),
|
|
},
|
|
);
|
|
crate::downloads::watch(&dl_handle, id, path.clone());
|
|
*destination = path;
|
|
}
|
|
|
|
DownloadEvent::Finished { url, success, .. } => {
|
|
// The path is never reported back on macOS, so it comes
|
|
// from what was assigned when the download was requested.
|
|
let found = state.download_paths.lock().unwrap().remove(&url.to_string());
|
|
if let Some((id, path)) = found {
|
|
state.finished_downloads.lock().unwrap().insert(id);
|
|
crate::downloads::announce(
|
|
&dl_handle,
|
|
"download-finished",
|
|
crate::downloads::DownloadFinished {
|
|
id,
|
|
success,
|
|
path: path.to_string_lossy().into_owned(),
|
|
},
|
|
);
|
|
}
|
|
}
|
|
|
|
_ => {}
|
|
}
|
|
true
|
|
})
|
|
// 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),
|
|
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
|
|
}
|
|
|
|
/// Where the traffic lights actually sit, as (left inset, right edge) in
|
|
/// logical pixels.
|
|
///
|
|
/// Asked rather than assumed. The collapsed rail has to be wide enough to give
|
|
/// the cluster the same margin on its right as macOS gives it on its left, and
|
|
/// those numbers are macOS's to choose — they differ by window style and have
|
|
/// changed between releases.
|
|
#[cfg(target_os = "macos")]
|
|
pub fn traffic_lights(handle: &AppHandle) -> Option<(f64, f64)> {
|
|
use objc2::runtime::AnyObject;
|
|
use objc2_foundation::NSRect;
|
|
|
|
let wv = handle.get_webview_window("main")?;
|
|
let (tx, rx) = std::sync::mpsc::channel::<Option<(f64, f64)>>();
|
|
|
|
let sent = wv.with_webview(move |platform| unsafe {
|
|
let view = platform.inner() as *mut AnyObject;
|
|
if view.is_null() {
|
|
let _ = tx.send(None);
|
|
return;
|
|
}
|
|
let window: *mut AnyObject = objc2::msg_send![view, window];
|
|
if window.is_null() {
|
|
let _ = tx.send(None);
|
|
return;
|
|
}
|
|
|
|
// NSWindowCloseButton = 0, NSWindowZoomButton = 2.
|
|
let close: *mut AnyObject = objc2::msg_send![window, standardWindowButton: 0isize];
|
|
let zoom: *mut AnyObject = objc2::msg_send![window, standardWindowButton: 2isize];
|
|
if close.is_null() || zoom.is_null() {
|
|
let _ = tx.send(None);
|
|
return;
|
|
}
|
|
|
|
let nil: *mut AnyObject = std::ptr::null_mut();
|
|
let cb: NSRect = objc2::msg_send![close, bounds];
|
|
let zb: NSRect = objc2::msg_send![zoom, bounds];
|
|
let c: NSRect = objc2::msg_send![close, convertRect: cb, toView: nil];
|
|
let z: NSRect = objc2::msg_send![zoom, convertRect: zb, toView: nil];
|
|
|
|
let _ = tx.send(Some((c.origin.x, z.origin.x + z.size.width)));
|
|
});
|
|
|
|
if sent.is_err() {
|
|
return None;
|
|
}
|
|
rx.recv_timeout(std::time::Duration::from_millis(400)).ok().flatten()
|
|
}
|
|
|
|
#[cfg(not(target_os = "macos"))]
|
|
pub fn traffic_lights(_: &AppHandle) -> Option<(f64, f64)> {
|
|
None
|
|
}
|
|
|
|
/// The title bar's height, for the shell to keep clear of.
|
|
///
|
|
/// Not an offset for positioning apps. The window's content view runs the full
|
|
/// height *including* the title bar, so a child webview and the shell share an
|
|
/// origin and no correction is needed between them — the shell simply pads
|
|
/// itself by this much, and the rect it then reports is already right.
|
|
///
|
|
/// Adding it here as well was the bug that left a title bar's worth of gap
|
|
/// above every page: the inset was being applied twice.
|
|
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),
|
|
) {
|
|
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, stage.1));
|
|
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();
|
|
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, stage.1));
|
|
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 + 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,
|
|
saved_account: 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"));
|
|
}
|
|
}
|