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 18221c2..7e300c8 100644 --- a/docs/superpowers/specs/2026-09-01-work-app-design.md +++ b/docs/superpowers/specs/2026-09-01-work-app-design.md @@ -242,6 +242,33 @@ 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. +### Counting what was missed + +Each rise in an app's unread count while you are elsewhere adds to a per-app tally shown +against its name in the nav — and on the rail, as a dot on the icon. Looking at an app is +the only thing that clears it; nothing else does, because nothing else means you have seen +it. Counts live in memory rather than `apps.json`: a restart reloads every app anyway, and +a number that survived would be a claim the app can no longer support. + +## The window + +There is no title bar and no toolbar, so in a normal window the shell keeps a 6px margin +around itself. That margin is the only part of the window that is not a web page, and +therefore the only place left to grab it by. Full screen has no use for it and gets the +room back. + +## Dialogs + +An app's webview is a native view that paints above the shell, so a dialog cannot simply +sit on a higher z-index — the app has to be moved out of the way first. Once it is moved +there is nothing left behind the dialog to look at, so a still is taken on the way out with +`takeSnapshotWithConfiguration` and shown blurred behind it. + +The snapshot is deliberately 640px wide: it is going behind a 16px blur. It is also taken +on a blocking worker rather than the calling thread — its completion handler runs on the +main thread, and waiting for it *there* deadlocks until the timeout and returns nothing +every time. + ## Zoom Per app, on a fixed ladder so ⌘0 returns to exactly 100% rather than to whatever a diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index bf00f3e..aa9677f 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -2205,9 +2205,17 @@ checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" dependencies = [ "bitflags 2.13.1", "block2", + "libc", "objc2", + "objc2-cloud-kit", + "objc2-core-data", "objc2-core-foundation", + "objc2-core-graphics", + "objc2-core-image", + "objc2-core-text", + "objc2-core-video", "objc2-foundation", + "objc2-quartz-core", ] [[package]] @@ -2227,6 +2235,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b402a653efbb5e82ce4df10683b6b28027616a2715e90009947d50b8dd298fa" dependencies = [ + "bitflags 2.13.1", "objc2", "objc2-foundation", ] @@ -2287,6 +2296,19 @@ dependencies = [ "objc2-core-graphics", ] +[[package]] +name = "objc2-core-video" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d425caf1df73233f29fd8a5c3e5edbc30d2d4307870f802d18f00d83dc5141a6" +dependencies = [ + "bitflags 2.13.1", + "objc2", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-io-surface", +] + [[package]] name = "objc2-encode" version = "4.1.0" @@ -4868,8 +4890,11 @@ checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" name = "work-app" version = "0.1.0" dependencies = [ + "block2", "mac-notification-sys", "objc2", + "objc2-app-kit", + "objc2-foundation", "objc2-web-kit", "serde", "serde_json", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 00288e9..82328a7 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -25,7 +25,10 @@ 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"] } +objc2-web-kit = { version = "0.3.2", features = ["WKWebView", "WKWebViewConfiguration", "WKPreferences", "WKSnapshotConfiguration", "block2"] } +objc2-app-kit = { version = "0.3", features = ["NSImage", "NSBitmapImageRep", "NSImageRep", "NSGraphics"] } +objc2-foundation = { version = "0.3", features = ["NSData", "NSString", "NSDictionary", "NSValue", "NSError"] } +block2 = "0.6" # Notifications are raised here rather than through the plugin, which offers no # way to learn that one was clicked. mac-notification-sys = "0.6" diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 52f7a52..2649ccd 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -24,6 +24,10 @@ pub struct AppState { pub diag: Mutex>, /// The last notification a page raised, and what macOS did with it. pub last_notification: Mutex, + /// How much has arrived in each app since you last looked at it. + pub unread: Mutex>, + /// Size of the last dialog backdrop still, or why there wasn't one. + pub last_snapshot: Mutex, } impl AppState { @@ -67,6 +71,8 @@ pub fn build_state(handle: &AppHandle) -> Result { booted: Mutex::new(false), diag: Mutex::new(std::collections::HashMap::new()), last_notification: Mutex::new(String::new()), + unread: Mutex::new(std::collections::HashMap::new()), + last_snapshot: Mutex::new(String::new()), }) } @@ -155,9 +161,67 @@ pub fn set_active(app_id: String, app: AppHandle, state: State<'_, AppState>) { let stage = *state.stage.lock().unwrap(); *state.active.lock().unwrap() = Some(app_id.clone()); webviews::show_only(&app, Some(&app_id), &cfg, stage); + + // Looking at an app is what clears its count. Nothing else does. + state.unread.lock().unwrap().remove(&app_id); + let _ = app.emit("unread-changed", unread_list(&state)); +} + +/// Every app's count, in the shape the nav wants. +pub fn unread_list(state: &AppState) -> Vec<(String, u32)> { + state + .unread + .lock() + .unwrap() + .iter() + .map(|(k, v)| (k.clone(), *v)) + .collect() +} + +#[tauri::command] +pub fn unread_counts(state: State<'_, AppState>) -> Vec<(String, u32)> { + unread_list(&state) +} + +/// Bumps the version every favicon URL carries, so a wrong one is refetched. +#[tauri::command] +pub fn refresh_favicons(state: State<'_, AppState>) -> Result { + { + let mut cfg = state.config.lock().unwrap(); + cfg.settings.favicon_version = cfg.settings.favicon_version.wrapping_add(1); + } + state.persist()?; + Ok(state.cfg()) } /// Hides every app, so a dialog is not painted over by a native view. +/// A still of the app on screen, taken before a dialog covers it. +/// +/// Async, and the wait happens on a blocking worker: the snapshot's completion +/// handler runs on the main thread, so waiting for it *on* the main thread +/// deadlocks until the timeout and returns nothing every time. +#[tauri::command] +pub async fn stage_snapshot(app: AppHandle) -> Option { + let id = { + let state = app.state::(); + let active = state.active.lock().unwrap(); + active.clone()? + }; + let handle = app.clone(); + let shot = tauri::async_runtime::spawn_blocking(move || webviews::snapshot(&app, &id)) + .await + .ok() + .flatten(); + + // Recorded so the diagnostic can say whether the still was taken at all, + // rather than leaving a flat backdrop to be interpreted by eye. + *handle.state::().last_snapshot.lock().unwrap() = match &shot { + Some(d) => format!("{} bytes", d.len()), + None => "none".into(), + }; + shot +} + #[tauri::command] pub fn hide_stage(app: AppHandle, state: State<'_, AppState>) { let stage = *state.stage.lock().unwrap(); @@ -443,7 +507,9 @@ pub fn notification_status(app: AppHandle) -> String { let app_state = app.state::(); let last = app_state.last_notification.lock().unwrap().clone(); let from_page = if last.is_empty() { "none yet".into() } else { last }; - format!("permission: {state} · direct: {raised} · from page: {from_page}") + let shot = app_state.last_snapshot.lock().unwrap().clone(); + let shot = if shot.is_empty() { "not taken".into() } else { shot }; + format!("permission: {state} · direct: {raised} · from page: {from_page} · backdrop: {shot}") } /// Asks macOS for notification permission, and claims this app's identity. diff --git a/src-tauri/src/config.rs b/src-tauri/src/config.rs index 97ef2fb..7d8559a 100644 --- a/src-tauri/src/config.rs +++ b/src-tauri/src/config.rs @@ -77,6 +77,9 @@ pub struct Settings { pub nav_collapsed: bool, #[serde(default = "default_theme")] pub theme: String, + /// Bumped to defeat a cached favicon that came back wrong. + #[serde(default)] + pub favicon_version: u32, } fn default_theme() -> String { @@ -88,6 +91,7 @@ impl Default for Settings { Self { nav_collapsed: false, theme: default_theme(), + favicon_version: 0, } } } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 62ca506..7e12700 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -89,6 +89,7 @@ pub fn run() { commands::set_stage, commands::set_active, commands::hide_stage, + commands::stage_snapshot, commands::show_stage, commands::navigate_app, commands::history_go, @@ -103,6 +104,8 @@ pub fn run() { commands::delete_group, commands::set_nav_collapsed, commands::set_theme, + commands::unread_counts, + commands::refresh_favicons, commands::focus_window, commands::notification_click, commands::set_zoom, diff --git a/src-tauri/src/webviews.rs b/src-tauri/src/webviews.rs index 8590c8c..7d94d8e 100644 --- a/src-tauri/src/webviews.rs +++ b/src-tauri/src/webviews.rs @@ -170,6 +170,15 @@ fn handle_sentinel( .map(|a| a.name.clone()) .unwrap_or_else(|| "Work".into()); let n: u32 = delta.parse().unwrap_or(1); + + { + let state = handle.state::(); + let mut counts = state.unread.lock().unwrap(); + *counts.entry(from.clone()).or_insert(0) += n; + } + let state = handle.state::(); + let _ = handle.emit("unread-changed", crate::commands::unread_list(&state)); + let title = if n == 1 { "1 new".to_string() } else { format!("{n} new") }; notify(&handle, &from, &name, &title, &format!("{total} unread"), String::new()); } @@ -340,6 +349,76 @@ fn keep_running_while_covered(handle: &AppHandle, app_id: &str) { #[cfg(not(target_os = "macos"))] fn keep_running_while_covered(_: &AppHandle, _: &str) {} +/// A still of one app, as a PNG data URL, for the shell to blur behind a dialog. +/// +/// A dialog is drawn by the shell webview, and every app's webview is a native +/// view that paints above it — so an app has to be moved out of the way before +/// a dialog can be seen at all, and once it is moved there is nothing left to +/// blur. A still taken on the way out is the only way to keep the background +/// there. Deliberately small: it is going behind a blur. +#[cfg(target_os = "macos")] +pub fn snapshot(handle: &AppHandle, app_id: &str) -> Option { + use block2::RcBlock; + use objc2_app_kit::{NSBitmapImageFileType, NSBitmapImageRep, NSImage}; + use objc2_foundation::{ + MainThreadMarker, NSDataBase64EncodingOptions, NSDictionary, NSError, NSNumber, + }; + use objc2_web_kit::{WKSnapshotConfiguration, WKWebView}; + + let wv = handle.get_webview(&label_for(app_id))?; + let (tx, rx) = std::sync::mpsc::channel::>(); + + let sent = wv.with_webview(move |platform| unsafe { + let ptr = platform.inner() as *const WKWebView; + let Some(mtm) = MainThreadMarker::new() else { + let _ = tx.send(None); + return; + }; + if ptr.is_null() { + let _ = tx.send(None); + return; + } + let webview: &WKWebView = &*ptr; + + let config = WKSnapshotConfiguration::new(mtm); + config.setSnapshotWidth(Some(&NSNumber::new_f64(640.0))); + + let handler = RcBlock::new(move |image: *mut NSImage, _error: *mut NSError| { + let encoded = (|| { + let image = image.as_ref()?; + let tiff = image.TIFFRepresentation()?; + let rep = NSBitmapImageRep::imageRepWithData(&tiff)?; + let png = rep.representationUsingType_properties( + NSBitmapImageFileType::PNG, + &NSDictionary::new(), + )?; + Some( + png.base64EncodedStringWithOptions(NSDataBase64EncodingOptions::empty()) + .to_string(), + ) + })(); + let _ = tx.send(encoded); + }); + + webview.takeSnapshotWithConfiguration_completionHandler(Some(&config), &handler); + }); + + if sent.is_err() { + return None; + } + // A snapshot that takes longer than this is not worth making someone wait + // for; the dialog opens over a plain backdrop instead. + rx.recv_timeout(std::time::Duration::from_millis(2500)) + .ok() + .flatten() + .map(|b64| format!("data:image/png;base64,{b64}")) +} + +#[cfg(not(target_os = "macos"))] +pub fn snapshot(_: &AppHandle, _: &str) -> Option { + None +} + /// 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 diff --git a/src/App.tsx b/src/App.tsx index 8d508d3..9f9b4a5 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,5 +1,6 @@ import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react"; import { listen } from "@tauri-apps/api/event"; +import { getCurrentWindow } from "@tauri-apps/api/window"; import * as api from "./api"; import Nav from "./components/Nav"; @@ -14,6 +15,9 @@ export default function App() { const [settingsOpen, setSettingsOpen] = useState(false); const [focusHidden, setFocusHidden] = useState(null); const [theme, setTheme] = useAppearance("system"); + const [unread, setUnread] = useState>({}); + const [fullscreen, setFullscreen] = useState(false); + const [backdrop, setBackdrop] = useState(null); const stageRef = useRef(null); const booted = useRef(false); @@ -24,6 +28,20 @@ export default function App() { const collapsed = config?.settings.navCollapsed ?? false; + /* The window has no frame of its own, so in a normal window the shell keeps + a margin around itself: somewhere to grab that is not a web page, and the + only way to move or place the window by hand. Full screen has no use for + it and gives the room back. */ + useEffect(() => { + const w = getCurrentWindow(); + const check = () => void w.isFullscreen().then(setFullscreen); + check(); + const un = w.onResized(check); + return () => { + void un.then((f) => f()); + }; + }, []); + useEffect(() => { api.getConfig().then((c) => { setConfig(c); @@ -103,17 +121,37 @@ export default function App() { listen<[string, number]>("zoom-changed", () => { void api.getConfig().then(setConfig); }), + listen<[string, number][]>("unread-changed", (e) => { + setUnread(Object.fromEntries(e.payload)); + }), ]; return () => { unlisten.forEach((p) => p.then((f) => f())); }; }, []); - // A native view paints over anything the shell draws, so a dialog needs the - // stage out of the way rather than merely on a higher z-index. + /* A native view paints over anything the shell draws, so a dialog needs the + app moved out of the way rather than merely a higher z-index — and once it + is moved there is nothing left behind the dialog to look at. A still taken + on the way out, blurred, puts the background back. */ useEffect(() => { if (!config) return; - void (settingsOpen ? api.hideStage() : api.showStage()); + let cancelled = false; + + if (settingsOpen) { + void (async () => { + const shot = await api.stageSnapshot().catch(() => null); + if (cancelled) return; + setBackdrop(shot); + await api.hideStage(); + })(); + } else { + setBackdrop(null); + void api.showStage(); + } + return () => { + cancelled = true; + }; }, [settingsOpen, config]); const toggleCollapse = () => { @@ -137,8 +175,20 @@ export default function App() { if (!config) return null; + const frame = fullscreen ? 0 : 6; + return ( -
+
+
+
+ {settingsButton} +
); } @@ -132,8 +168,9 @@ export default function Nav({ title={app.url} className={`${row} ${app.id === activeId ? active : inactive}`} > - + {app.name} + {badge(app.id, app.id === activeId)} ); @@ -161,7 +198,7 @@ export default function Nav({
{!g.collapsed &&
    {apps.map(appRow)}
}
@@ -187,6 +222,10 @@ export default function Nav({

)} + +
+ {settingsButton} +
); } diff --git a/src/components/Settings.tsx b/src/components/Settings.tsx index 49a228b..2b7ed4a 100644 --- a/src/components/Settings.tsx +++ b/src/components/Settings.tsx @@ -99,7 +99,16 @@ export default function Settings({ {/* ----------------------------------------------------- apps */}
- Apps +
+ Apps + +
{config.apps.length === 0 &&

Nothing yet. Add the first one below.

} {orderedApps.map((app) => ( @@ -107,7 +116,7 @@ export default function Settings({ key={app.id} className="flex items-center gap-2 rounded-lg border border-slate-200 px-2 py-1.5 dark:border-slate-800" > - + patch(app, { name: e.target.value })} @@ -183,7 +192,7 @@ export default function Settings({ {withHidden.map((app) => (
- + {app.name} {app.hidden.length}
@@ -231,7 +240,7 @@ export default function Settings({ key={app.id} className="flex items-center gap-3 rounded-lg border border-slate-200 px-2 py-1.5 dark:border-slate-800" > - + {app.name}
e.stopPropagation()} - className={`w-full ${wide ? "max-w-2xl max-h-[82vh] overflow-y-auto" : "max-w-sm"} - rounded-2xl border border-slate-300 bg-white p-5 shadow-2xl - dark:border-slate-700 dark:bg-slate-900`} + className={`w-full ${ + wide + ? "max-w-[min(1120px,92vw)] max-h-[90vh] overflow-y-auto" + : "max-w-sm" + } rounded-2xl border border-slate-300 bg-white p-5 shadow-2xl + dark:border-slate-700 dark:bg-slate-900`} >

{title}

@@ -201,10 +209,13 @@ export function Favicon({ url, name, size = 16, + version = 0, }: { url: string; name: string; size?: number; + /** Bumped by "Refresh icons" to get past a wrongly cached one. */ + version?: number; }) { let host = ""; try { @@ -228,7 +239,7 @@ export function Favicon({ {host && (