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