From 3aa9d9c3f7f750f37deca2254215fd748395e8e5 Mon Sep 17 00:00:00 2001 From: Vincent Date: Tue, 1 Sep 2026 15:23:46 +0200 Subject: [PATCH] Empty-cache menu item; catch two more ways an app can notify Right-click now offers "Empty cache and reload". Caches only - never cookies or local storage: emptying a cache is what you do when a page is serving something stale, and signing you out while doing it would be a different and much less welcome feature. The reload waits for the removal to finish, or it would refill from what was being thrown away. Google Chat could play its own alert sound and still reach none of this, because some apps never construct a Notification - they hand it to their service worker registration instead. ServiceWorkerRegistration.prototype .showNotification now routes to the same place. A worker calling it from its own context is still out of reach; a page calling it is not. Also shims the badge API, which is the precise form of an unread count - a number an app states outright rather than one scraped from its title. WKWebView does not implement it, so an app calling it was talking to nobody. Chat's title carries no count at all, which is why no counter ever appeared for it. The background-apps probe now reports all three signals per app, so which one an app actually uses is a reading rather than a guess. --- src-tauri/Cargo.toml | 4 +- src-tauri/src/commands.rs | 7 +++- src-tauri/src/inject.js | 40 ++++++++++++++++++- src-tauri/src/webviews.rs | 82 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 127 insertions(+), 6 deletions(-) diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 693bc8f..85ce816 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -25,9 +25,9 @@ uuid = { version = "1", features = ["v4"] } [target.'cfg(target_os = "macos")'.dependencies] objc2 = "0.6" -objc2-web-kit = { version = "0.3.2", features = ["WKWebView", "WKWebViewConfiguration", "WKPreferences", "WKSnapshotConfiguration", "block2"] } +objc2-web-kit = { version = "0.3.2", features = ["WKWebView", "WKWebViewConfiguration", "WKPreferences", "WKSnapshotConfiguration", "WKWebsiteDataStore", "WKWebsiteDataRecord", "block2"] } objc2-app-kit = { version = "0.3", features = ["NSImage", "NSBitmapImageRep", "NSImageRep", "NSGraphics"] } -objc2-foundation = { version = "0.3", features = ["NSData", "NSString", "NSDictionary", "NSValue", "NSError", "NSGeometry"] } +objc2-foundation = { version = "0.3", features = ["NSData", "NSString", "NSDictionary", "NSValue", "NSError", "NSGeometry", "NSSet", "NSDate", "NSArray"] } block2 = "0.6" # Notifications are raised here rather than through the plugin, which offers no # way to learn that one was clicked. diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 7f4a497..332c815 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -732,10 +732,13 @@ pub fn probe_apps(app: AppHandle, state: State<'_, AppState>) -> Result<(), Stri if (!window.__workAppSend) return; var st = window.__workAppState ? window.__workAppState() : {}; window.__workAppSend('diag', { - title: String(document.title || '').slice(0, 40), + title: String(document.title || '').slice(0, 32), active: String(st.active), seen: String(st.seen), - queued: String(st.queued) + '/' + String(st.sending) + // The three ways an app can say something arrived. + notified: String(st.raised), + badged: String(st.badged), + sw: typeof (window.ServiceWorkerRegistration || {}).prototype }); })();"#, ); diff --git a/src-tauri/src/inject.js b/src-tauri/src/inject.js index b21f391..542ed7d 100644 --- a/src-tauri/src/inject.js +++ b/src-tauri/src/inject.js @@ -57,7 +57,10 @@ /* 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 }; + return { + active: active, queued: queue.length, sending: sending, + seen: lastCount, raised: raised, badged: badged + }; }; /* ------------------------------------------------------------ routing */ @@ -311,7 +314,8 @@ [ ['Hide this element', function () { hide(target); }], ['Pick an element to hide…', startPicking], - ['Manage hidden elements…', function () { send('manage', { x: '1' }); }] + ['Manage hidden elements…', function () { send('manage', { x: '1' }); }], + ['Empty cache and reload', function () { send('emptycache', { x: '1' }); }] ].forEach(function (item) { var b = document.createElement('div'); b.textContent = item[0]; @@ -354,6 +358,8 @@ notification was about — this app does not, and should not have to. */ var notifications = {}; var notifySeq = 0; + var raised = 0; + var badged = 0; function WorkNotification(title, options) { options = options || {}; @@ -445,6 +451,36 @@ }); } catch (e) { window.Notification = WorkNotification; } + /* Some apps do not construct a Notification at all — they hand it to their + service worker registration instead. Google Chat is one, which is why it + could play its own alert sound and still never reach any of this. Routed + to the same place; a worker calling it from its own context is still out + of reach, but a page calling it is not. */ + try { + var reg = window.ServiceWorkerRegistration; + if (reg && reg.prototype && reg.prototype.showNotification) { + reg.prototype.showNotification = function (title, options) { + try { new WorkNotification(title, options); } catch (e) {} + return Promise.resolve(); + }; + } + } catch (e) {} + + /* The badge API is the precise version of the unread count: a number the app + states outright rather than one scraped from its title. WKWebView does not + implement it, so an app calling it is talking to nobody. */ + try { + navigator.setAppBadge = function (n) { + badged += 1; + send('badge', { n: String(n == null ? 0 : n) }); + return Promise.resolve(); + }; + navigator.clearAppBadge = function () { + send('badge', { n: '0' }); + return Promise.resolve(); + }; + } catch (e) {} + /* ---------------------------------------------------------- the icon */ /* The site's own icon, read off the page it is on. diff --git a/src-tauri/src/webviews.rs b/src-tauri/src/webviews.rs index f17d810..5aa0235 100644 --- a/src-tauri/src/webviews.rs +++ b/src-tauri/src/webviews.rs @@ -215,6 +215,42 @@ fn handle_sentinel( } } + // 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::().ok()) else { + return; + }; + let state = handle.state::(); + 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()) { + 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()); + } + } + + "emptycache" => { + empty_cache(&handle, &from); + } + "manage" => { let _ = handle.emit("manage-hidden", from.clone()); } @@ -486,6 +522,52 @@ pub fn set_corner_radius(handle: &AppHandle, app_id: &str, radius: f64) { #[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