Keep background apps alive, and notify from their unread count
The report was that a backgrounded site dies slowly and stops fetching. It did, for three separate reasons, and the first two fixes each traded one failure for another: Hiding inactive views lets WebKit suspend them, so nothing is hidden any more - every view keeps its size and stays in the window, and the active one is ordered on top. Telling a background page it was hidden then made Gmail throttle its own syncing; its unread count sat unchanged for two and a half minutes. That spoof is gone. Neither was enough on its own: a background page keeps its timers but loses the connection its updates arrive on, and WKWebView has no equivalent of Electron's backgroundThrottling. Background apps are now poked every 45s with the events a page uses to catch up after you return to a tab - it fetches without losing a half-written reply. Notifications now come from the unread count in the title rather than the site's notification code, which Gmail will not run while it believes you are looking at it. Measured: Gmail behind Odoo went 155 -> 157 untouched and raised "Gmail / 1 new". Also adds a background-app probe to Settings, which is what turned this from guesswork into measurement, and drops the nav's blocked-element count and the hide-element button.
This commit is contained in:
+142
-24
@@ -154,7 +154,24 @@ fn handle_sentinel(
|
||||
.map(|(k, v)| format!("{k}={v}"))
|
||||
.collect();
|
||||
parts.sort();
|
||||
*state.diag.lock().unwrap() = parts.join(" ");
|
||||
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 title = if n == 1 { "1 new".to_string() } else { format!("{n} new") };
|
||||
notify(&handle, &from, &name, &title, &format!("{total} unread"), String::new());
|
||||
}
|
||||
|
||||
"manage" => {
|
||||
@@ -256,6 +273,73 @@ fn notify(
|
||||
}
|
||||
}
|
||||
|
||||
/// 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) {}
|
||||
|
||||
/// 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
|
||||
@@ -344,6 +428,14 @@ pub fn create(
|
||||
.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
|
||||
@@ -355,20 +447,26 @@ pub fn create(
|
||||
.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);
|
||||
}
|
||||
|
||||
// Created hidden. `show_only` is what puts one on screen, so startup does
|
||||
// not flash every app in turn as they are built.
|
||||
if let Some(wv) = handle.get_webview(&label_for(&id)) {
|
||||
let _ = wv.hide();
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Shows one app and hides the rest, sizing it to the stage.
|
||||
/// 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>,
|
||||
@@ -377,34 +475,54 @@ pub fn show_only(
|
||||
) {
|
||||
for app in &cfg.apps {
|
||||
let Some(wv) = handle.get_webview(&label_for(&app.id)) else { continue };
|
||||
if Some(app.id.as_str()) == app_id {
|
||||
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();
|
||||
} else {
|
||||
let _ = wv.hide();
|
||||
}
|
||||
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_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 whichever app is showing. Called on every layout change.
|
||||
/// 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)) {
|
||||
let Some(id) = active else { return };
|
||||
let Some(wv) = handle.get_webview(&label_for(id)) else { return };
|
||||
let _ = wv.set_position(LogicalPosition::new(stage.0, stage.1));
|
||||
let _ = wv.set_size(LogicalSize::new(stage.2, stage.3));
|
||||
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));
|
||||
}
|
||||
if let Some(id) = active {
|
||||
raise_to_front(handle, id);
|
||||
}
|
||||
}
|
||||
|
||||
/// Hides every app webview, so the shell can draw over the whole window.
|
||||
pub fn hide_all(handle: &AppHandle, cfg: &Config) {
|
||||
/// 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.hide();
|
||||
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();
|
||||
|
||||
Reference in New Issue
Block a user