diff --git a/docs/superpowers/specs/2026-09-01-work-app-design.md b/docs/superpowers/specs/2026-09-01-work-app-design.md index e1afc11..18221c2 100644 --- a/docs/superpowers/specs/2026-09-01-work-app-design.md +++ b/docs/superpowers/specs/2026-09-01-work-app-design.md @@ -204,6 +204,44 @@ wrong at first: Service-worker push is **not** covered — only notifications a page raises while it is open. +## Keeping background apps alive + +The app is only worth having if a tool you are not looking at still tells you something +arrived. Getting there took three wrong turns, and each one is why the code looks as it +does: + +1. **Hiding the inactive views.** The obvious way to switch apps, and it kills them: + WebKit reads a hidden `NSView` as a page that is not visible, throttles its timers and + eventually suspends it. Now nothing is hidden — every view keeps its full size and stays + in the window, and the active one is simply ordered on top. Sibling views covering each + other is not something WebKit tracks. + +2. **Telling the page it was hidden.** The idea was that a page believing itself hidden + would notify rather than stay quiet. It backfired: told it was hidden, Gmail throttled + its *own* syncing, and its unread count sat unchanged for two and a half minutes. The + spoof is gone. Pages are told nothing about visibility. + +3. **Assuming that was enough.** It was not. A backgrounded page keeps running its timers + but loses the long-lived connection its updates arrive on, and WKWebView offers no + equivalent of Electron's `backgroundThrottling: false`. So every background app is + **poked every 45 seconds** with the events a page uses to catch up when you return to a + tab. It fetches as if you had just looked at it, and keeps its state — unlike a reload, + which would throw away a half-written reply. + +**Measured end to end:** Gmail behind Odoo went from 155 to 157 unread without being +touched, and the app raised `Gmail / 1 new`. + +### Unread counting + +Notifications do not come from the site's own notification code, because Gmail declines to +raise one while it believes you are looking at it — and with the visibility spoof gone, it +always believes that. They come from the **unread count in the title**: `Inbox (12)`, +`(3) Chat`. Every one of these tools publishes it, it cannot be switched off, and it never +moves into a service worker this app cannot reach. A rise while the app is not the one on +screen raises a banner; a fall is you reading things, and is not news. + +The site's own notifications still work when they fire — both paths feed the same channel. + ## Zoom Per app, on a fixed ladder so ⌘0 returns to exactly 100% rather than to whatever a diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index b0d0bc1..52f7a52 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -20,8 +20,8 @@ pub struct AppState { pub stage: Mutex, /// Webviews exist. Guards against bootstrapping twice on a hot reload. pub booted: Mutex, - /// What the last page probe reported, for the notification diagnostic. - pub diag: Mutex, + /// What each app's page last reported about itself, keyed by app id. + pub diag: Mutex>, /// The last notification a page raised, and what macOS did with it. pub last_notification: Mutex, } @@ -65,7 +65,7 @@ pub fn build_state(handle: &AppHandle) -> Result { 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::(); 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. diff --git a/src-tauri/src/inject.js b/src-tauri/src/inject.js index dc7eb09..f66e7dc 100644 --- a/src-tauri/src/inject.js +++ b/src-tauri/src/inject.js @@ -37,6 +37,29 @@ drain(); } + /* -------------------------------------------------- background tabs */ + + /* Which app is on screen. Deliberately *not* wired to the page's own idea of + visibility. + + Two earlier attempts failed, and both failures are the reason this is + shaped the way it is. Hiding the view lets WebKit suspend the page, and a + suspended tool stops fetching. Leaving the view alone but telling the page + it is hidden makes the page throttle its own syncing instead — measured on + Gmail, whose unread count sat unchanged for two and a half minutes. + + So the page is told nothing at all. It believes it is on screen, keeps its + connection, and keeps counting. What it will not do is raise a + notification, since as far as it knows you are looking straight at it — + and that is the job the unread watcher below picks up. */ + var active = true; + window.__workAppSetVisible = function (v) { active = !!v; }; + /* Readable from the probe, so "it never fired" can be told apart from + "it fired and the message never left". */ + window.__workAppState = function () { + return { active: active, queued: queue.length, sending: sending, seen: lastCount }; + }; + /* ------------------------------------------------------------ routing */ function abs(href) { @@ -422,6 +445,54 @@ }); } catch (e) { window.Notification = WorkNotification; } + /* --------------------------------------------------- unread counting */ + + /* Sites put their unread count in the title — "Inbox (12)", "(3) Chat". + It is the one signal they all share, and unlike their notification code it + cannot be switched off, gated behind a heuristic about whether you are + looking, or moved into a service worker this app cannot reach. + + Gmail, measured: left believing it is on screen it keeps fetching and its + title tracks the count, but it will not raise a notification while it + thinks you are looking. The title does not care what it thinks. */ + var lastCount = null; + + function titleCount() { + var m = /\((\d+)\)/.exec(document.title || ''); + return m ? parseInt(m[1], 10) : 0; + } + + function checkUnread() { + var n = titleCount(); + if (lastCount === null) { lastCount = n; return; } + /* Only while nobody is looking, and only on a rise — a count going down is + you reading things, which is not news. */ + if (n > lastCount && !active) { + send('unread', { n: String(n), d: String(n - lastCount) }); + } + lastCount = n; + } + + /* Gmail's long-lived connection does not survive being in the background in + WKWebView, and there is no public switch for that — Electron has + backgroundThrottling, WebKit has nothing. Measured: a backgrounded Gmail + sat on the same unread count for minutes while its timers kept running, so + it is the connection that goes, not the page. + + What a page does respond to is the event it uses to catch up when you come + back to a tab. Sending that on a timer makes a background app fetch as if + you had just looked at it, which costs nothing and keeps its state — unlike + a reload, which would throw away a half-written reply. */ + function poke() { + if (active) return; + try { document.dispatchEvent(new Event('visibilitychange')); } catch (e) {} + try { window.dispatchEvent(new Event('focus')); } catch (e) {} + try { window.dispatchEvent(new Event('online')); } catch (e) {} + } + + setInterval(checkUnread, 4000); + setInterval(poke, 45000); + /* ------------------------------------------------------------- start */ if (document.readyState === 'loading') { diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 9561532..62ca506 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -107,9 +107,10 @@ pub fn run() { commands::notification_click, commands::set_zoom, commands::set_hidden, - commands::pick_hidden, commands::test_notification, commands::notification_status, + commands::probe_apps, + commands::app_reports, ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/src-tauri/src/webviews.rs b/src-tauri/src/webviews.rs index 3467b66..8590c8c 100644 --- a/src-tauri/src/webviews.rs +++ b/src-tauri/src/webviews.rs @@ -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::() + .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::().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(); diff --git a/src/App.tsx b/src/App.tsx index cc100f3..8d508d3 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -150,7 +150,6 @@ export default function App() { onBack={() => activeId && api.historyGo(activeId, -1)} onForward={() => activeId && api.historyGo(activeId, 1)} onReload={() => activeId && api.historyGo(activeId, 0)} - onPick={() => activeId && api.pickHidden(activeId)} /> {/* The hole an app's native webview is positioned into. It stays empty diff --git a/src/api.ts b/src/api.ts index 018719c..74d8754 100644 --- a/src/api.ts +++ b/src/api.ts @@ -38,7 +38,6 @@ export const setTheme = (theme: string) => invoke("set_theme", { theme }); export const setHidden = (appId: string, hidden: string[]) => invoke("set_hidden", { appId, hidden }); -export const pickHidden = (appId: string) => invoke("pick_hidden", { appId }); export const testNotification = (appId: string) => invoke("test_notification", { appId }); @@ -49,3 +48,5 @@ export const setZoom = (appId: string, zoom: number) => export const notificationClick = (appId: string, notificationId: string) => invoke("notification_click", { appId, notificationId }); export const focusWindow = () => invoke("focus_window"); +export const probeApps = () => invoke("probe_apps"); +export const appReports = () => invoke<[string, string][]>("app_reports"); diff --git a/src/components/Nav.tsx b/src/components/Nav.tsx index 07cd90b..153eaff 100644 --- a/src/components/Nav.tsx +++ b/src/components/Nav.tsx @@ -1,5 +1,5 @@ import type { Config, Group, WorkApp } from "../types"; -import { Back, Cog, Collapse, EyeOff, Forward, Reload } from "./icons"; +import { Back, Cog, Collapse, Forward, Reload } from "./icons"; import { Favicon, HEADING, ICON_CHROME } from "./ui"; interface Props { @@ -13,7 +13,6 @@ interface Props { onBack: () => void; onForward: () => void; onReload: () => void; - onPick: () => void; } /** @@ -30,7 +29,7 @@ export const navWidth = (collapsed: boolean) => (collapsed ? RAIL : PANEL); export default function Nav({ config, activeId, collapsed, onSelect, onToggleCollapse, onOpenSettings, onToggleGroup, - onBack, onForward, onReload, onPick, + onBack, onForward, onReload, }: Props) { const groups = [...config.groups].sort((a, b) => a.order - b.order); const inGroup = (id: string | null) => @@ -42,8 +41,9 @@ export default function Nav({ "flex shrink-0 flex-col overflow-hidden border-r border-slate-300 bg-white " + "dark:border-slate-800 dark:bg-slate-900"; - /* Back, forward, reload, hide-an-element and settings, in that order — - navigation first because it is what gets reached for most. */ + /* Back, forward, reload and settings. Nothing hides an element here — that + lives on the right-click menu, where you are already pointing at the thing + you want gone. */ const controls = ( <> - @@ -95,8 +87,13 @@ export default function Nav({ return (