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:
2026-09-01 14:10:31 +02:00
parent 21a9f065f0
commit 8f989fb8f6
10 changed files with 343 additions and 65 deletions
+55 -20
View File
@@ -20,8 +20,8 @@ pub struct AppState {
pub stage: Mutex<Stage>,
/// Webviews exist. Guards against bootstrapping twice on a hot reload.
pub booted: Mutex<bool>,
/// What the last page probe reported, for the notification diagnostic.
pub diag: Mutex<String>,
/// What each app's page last reported about itself, keyed by app id.
pub diag: Mutex<std::collections::HashMap<String, String>>,
/// The last notification a page raised, and what macOS did with it.
pub last_notification: Mutex<String>,
}
@@ -65,7 +65,7 @@ pub fn build_state(handle: &AppHandle) -> Result<AppState, String> {
active: Mutex::new(None),
stage: Mutex::new((240.0, 38.0, 800.0, 600.0)),
booted: Mutex::new(false),
diag: Mutex::new(String::new()),
diag: Mutex::new(std::collections::HashMap::new()),
last_notification: Mutex::new(String::new()),
})
}
@@ -139,6 +139,10 @@ pub fn bootstrap(app: AppHandle, state: State<'_, AppState>) -> Result<(), Strin
if let Err(e) = webviews::create(&app, &a, &cfg, stage) {
eprintln!("could not create webview for {}: {e}", a.name);
}
// Each new view is added on top of its siblings, so the app being
// looked at has to be put back in front after every one.
let active = state.active.lock().unwrap().clone();
webviews::show_only(&app, active.as_deref(), &cfg, stage);
}
});
@@ -156,7 +160,8 @@ pub fn set_active(app_id: String, app: AppHandle, state: State<'_, AppState>) {
/// Hides every app, so a dialog is not painted over by a native view.
#[tauri::command]
pub fn hide_stage(app: AppHandle, state: State<'_, AppState>) {
webviews::hide_all(&app, &state.cfg());
let stage = *state.stage.lock().unwrap();
webviews::hide_all(&app, &state.cfg(), stage);
}
/// Puts the active app back after a dialog closes.
@@ -246,6 +251,8 @@ pub fn add_app(
let stage = *state.stage.lock().unwrap();
if *state.booted.lock().unwrap() {
webviews::create(&app, &new, &cfg, stage)?;
let active = state.active.lock().unwrap().clone();
webviews::show_only(&app, active.as_deref(), &cfg, stage);
}
Ok(cfg)
}
@@ -411,17 +418,6 @@ pub fn set_hidden(
Ok(state.cfg())
}
/// Starts the in-page element picker, for reaching something a right-click
/// cannot land on cleanly.
#[tauri::command]
pub fn pick_hidden(app_id: String, app: AppHandle) -> Result<(), String> {
let wv = app
.get_webview(&webviews::label_for(&app_id))
.ok_or_else(|| format!("{app_id} has no webview"))?;
wv.eval("window.__workAppPick && window.__workAppPick()")
.map_err(|e| e.to_string())
}
/// Permission state, and what a notification raised straight from Rust does.
///
/// Splits the chain in two: if this succeeds and the page test does not, the
@@ -446,12 +442,8 @@ pub fn notification_status(app: AppHandle) -> String {
};
let app_state = app.state::<AppState>();
let last = app_state.last_notification.lock().unwrap().clone();
let diag = app_state.diag.lock().unwrap().clone();
let from_page = if last.is_empty() { "none yet".into() } else { last };
// The probe's own report, which distinguishes "the page never ran" from
// "the page ran and the message did not arrive".
let probe = if diag.is_empty() { "no reply".into() } else { diag };
format!("permission: {state} · direct: {raised} · from page: {from_page} · probe: {probe}")
format!("permission: {state} · direct: {raised} · from page: {from_page}")
}
/// Asks macOS for notification permission, and claims this app's identity.
@@ -632,6 +624,49 @@ pub fn reload_active(app: &AppHandle) {
}
}
/// Asks every app's page what it currently believes about itself.
///
/// Built because guessing was not working: it separates "the page is asleep"
/// from "the page is awake and thinks it is on screen" from "the page is awake,
/// knows it is hidden, and simply has nothing to say".
#[tauri::command]
pub fn probe_apps(app: AppHandle, state: State<'_, AppState>) -> Result<(), String> {
state.diag.lock().unwrap().clear();
for a in state.cfg().apps {
let Some(wv) = app.get_webview(&webviews::label_for(&a.id)) else { continue };
let _ = wv.eval(
r#"(function () {
if (!window.__workAppSend) return;
var st = window.__workAppState ? window.__workAppState() : {};
window.__workAppSend('diag', {
title: String(document.title || '').slice(0, 40),
active: String(st.active),
seen: String(st.seen),
queued: String(st.queued) + '/' + String(st.sending)
});
})();"#,
);
}
Ok(())
}
/// What every app reported, from the newest run only.
#[tauri::command]
pub fn app_reports(state: State<'_, AppState>) -> Vec<(String, String)> {
let cfg = state.cfg();
let diag = state.diag.lock().unwrap();
cfg.apps
.iter()
.map(|a| {
let report = diag
.get(&a.id)
.cloned()
.unwrap_or_else(|| "no reply — page is asleep or gone".into());
(a.name.clone(), report)
})
.collect()
}
// -------------------------------------------------- notification clicks
/// Runs the page's own click handler for a notification it raised.