diff --git a/README.md b/README.md index e9b3cce..c32bc5e 100644 --- a/README.md +++ b/README.md @@ -98,14 +98,17 @@ Build, replace the copy in `/Applications`, and relaunch — one command: npm run ship ``` -One build, two destinations, every time: +That replaces `/Applications/Work.app` and relaunches it. -- `/Applications/Work.app` — the copy tested here -- `~/Desktop/Work-.dmg` — the installer to send someone else +To hand a build to someone else: -Both come from the same build, so a tester runs byte for byte what was just verified -rather than a second build that drifted. The disk image is the ordinary drag-to-Applications -kind — what someone expects to be handed, rather than a bare bundle to file themselves. +```bash +npm run ship -- --dmg +``` + +which also writes `~/Desktop/Work-.dmg` from the same build, so a tester runs +byte for byte what was just verified rather than a second build that drifted. It is the +ordinary drag-to-Applications disk image. The app is ad-hoc signed, so the first launch on someone else's Mac needs **right-click → Open** rather than a double-click. Gatekeeper refuses it silently 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 27cb5f2..7601e83 100644 --- a/docs/superpowers/specs/2026-09-01-work-app-design.md +++ b/docs/superpowers/specs/2026-09-01-work-app-design.md @@ -240,7 +240,15 @@ always believes that. They come from the **unread count in the title**: `Inbox ( 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. +The site's own notifications still work when they fire — both paths feed the same channel, +which is exactly how the same arrival came to be announced twice: Gmail names the sender, +and the count says "1 new" behind it seconds later. + +A count is therefore skipped when the app has spoken for itself in the last twenty seconds. +The window is generous because a count is only noticed on a four-second tick, well after +the app raised its own. Settings can also turn count notifications off outright, for +someone who would rather hear only what an app says in its own words — at the cost of the +tools that never say anything, Google Chat among them. ### Counting what was missed diff --git a/scripts/ship.sh b/scripts/ship.sh index 91aac4e..0302f78 100755 --- a/scripts/ship.sh +++ b/scripts/ship.sh @@ -3,12 +3,15 @@ # Build once, then put the result in both places, every time: # # /Applications/Work.app the copy being tested here -# ~/Desktop/Work-.dmg the installer to send someone else # -# One build, two destinations — so the thing a tester runs is byte for byte the -# thing that was just tested here, rather than a second build that drifted. +# Pass --dmg to also drop a drag-to-Applications installer on the Desktop, from +# the same build — so the thing a tester runs is byte for byte the thing that +# was just tested here, rather than a second build that drifted. set -euo pipefail +WANT_DMG=0 +[ "${1:-}" = "--dmg" ] && WANT_DMG=1 + cd "$(dirname "$0")/.." APP="Work.app" BUILT="src-tauri/target/release/bundle/macos/$APP" @@ -17,7 +20,11 @@ VERSION="$(node -p "require('./package.json').version")" DMG="$HOME/Desktop/Work-$VERSION.dmg" echo "==> building $VERSION" -npm run tauri build -- --bundles app,dmg +if [ "$WANT_DMG" = "1" ]; then + npm run tauri build -- --bundles app,dmg +else + npm run tauri build -- --bundles app +fi echo "==> quitting the running copy" osascript -e 'quit app "Work"' >/dev/null 2>&1 || true @@ -32,17 +39,23 @@ echo "==> installing to $DEST" rm -rf "$DEST" cp -R "$BUILT" "$DEST" -# The disk image the bundler just made — drag-to-Applications, which is what +# The disk image, only when asked for — drag-to-Applications, which is what # someone expects to be handed rather than a bare bundle to file themselves. -echo "==> copying the installer to $DMG" -BUILT_DMG="$(find src-tauri/target/release/bundle/dmg -name '*.dmg' -maxdepth 1 | head -1)" -if [ -z "$BUILT_DMG" ]; then - echo "no .dmg was produced — check the bundler output above" >&2 - exit 1 +if [ "$WANT_DMG" = "1" ]; then + echo "==> copying the installer to $DMG" + BUILT_DMG="$(find src-tauri/target/release/bundle/dmg -name '*.dmg' -maxdepth 1 | head -1)" + if [ -z "$BUILT_DMG" ]; then + echo "no .dmg was produced — check the bundler output above" >&2 + exit 1 + fi + rm -f "$DMG" + cp "$BUILT_DMG" "$DMG" fi -rm -f "$DMG" -cp "$BUILT_DMG" "$DMG" echo "==> launching" open "$DEST" -echo "==> done — $DEST and $DMG" +if [ "$WANT_DMG" = "1" ]; then + echo "==> done — $DEST and $DMG" +else + echo "==> done — $DEST" +fi diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 391120a..c5f6d80 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -20,6 +20,9 @@ pub struct AppState { /// Title bar height: how far a child webview's origin sits above the /// content the shell measures from. pub chrome: Mutex, + /// When each app last raised a notification in its own words, so a count + /// does not immediately say the same thing again in worse words. + pub last_spoke: Mutex>, /// A login waiting on an answer: host, account, password. Held only until /// it is saved or declined, and never written anywhere but the Keychain. pub pending_password: Mutex>, @@ -80,6 +83,7 @@ pub fn build_state(handle: &AppHandle) -> Result { dir, radius: Mutex::new(0.0), chrome: Mutex::new(0.0), + last_spoke: Mutex::new(std::collections::HashMap::new()), pending_password: Mutex::new(None), config: Mutex::new(config), active: Mutex::new(None), @@ -466,6 +470,16 @@ pub fn set_nav_collapsed(collapsed: bool, state: State<'_, AppState>) -> Result< state.persist() } +#[tauri::command] +pub fn set_count_notifications( + enabled: bool, + state: State<'_, AppState>, +) -> Result { + state.config.lock().unwrap().settings.count_notifications = enabled; + state.persist()?; + Ok(state.cfg()) +} + #[tauri::command] pub fn set_theme(theme: String, app: AppHandle, state: State<'_, AppState>) -> Result<(), String> { state.config.lock().unwrap().settings.theme = theme.clone(); diff --git a/src-tauri/src/config.rs b/src-tauri/src/config.rs index 19bae72..80ac45b 100644 --- a/src-tauri/src/config.rs +++ b/src-tauri/src/config.rs @@ -88,18 +88,29 @@ pub struct Settings { /// The app that was showing when the window last closed. #[serde(default)] pub last_app: Option, + /// Whether an app's unread count may raise a notification of its own. + /// + /// On for the tools that never notify by themselves. Off if you would + /// rather hear only what an app says in its own words. + #[serde(default = "yes")] + pub count_notifications: bool, } fn default_theme() -> String { "system".into() } +fn yes() -> bool { + true +} + impl Default for Settings { fn default() -> Self { Self { nav_collapsed: false, theme: default_theme(), last_app: None, + count_notifications: true, } } } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 4579f28..51837a2 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -116,6 +116,7 @@ pub fn run() { commands::delete_group, commands::set_nav_collapsed, commands::set_theme, + commands::set_count_notifications, commands::reset_config, commands::set_window_chrome, commands::chrome_height, diff --git a/src-tauri/src/webviews.rs b/src-tauri/src/webviews.rs index 4d790f5..7379ead 100644 --- a/src-tauri/src/webviews.rs +++ b/src-tauri/src/webviews.rs @@ -159,6 +159,16 @@ fn handle_sentinel( let body = params.get("b").cloned().unwrap_or_default(); let app_name = params.get("a").cloned().unwrap_or_default(); let notification_id = params.get("id").cloned().unwrap_or_default(); + + // Noted so a count does not repeat, seconds later and worse, + // what the app has just said properly. + handle + .state::() + .last_spoke + .lock() + .unwrap() + .insert(from.clone(), std::time::Instant::now()); + notify(&handle, &from, &app_name, &title, &body, notification_id); } @@ -245,7 +255,10 @@ fn handle_sentinel( }; let _ = handle.emit("unread-changed", crate::commands::unread_list(&state)); - if total > previous && showing.as_deref() != Some(from.as_str()) { + if total > previous + && showing.as_deref() != Some(from.as_str()) + && count_may_speak(&handle, &from) + { let name = state .cfg() .app(&from) @@ -299,6 +312,25 @@ fn handle_sentinel( }); } +/// Whether an unread count should raise a notification of its own. +/// +/// Two things can suppress it. The setting, for someone who would rather hear +/// only what an app says in its own words. And an app having just said it: +/// Gmail raises a proper notification naming the sender, and the count arriving +/// behind it saying "1 new" is the same news told worse. The window is generous +/// because a count is noticed on a four-second tick, well after the app spoke. +fn count_may_speak(handle: &AppHandle, app_id: &str) -> bool { + let state = handle.state::(); + if !state.cfg().settings.count_notifications { + return false; + } + let spoke = state.last_spoke.lock().unwrap().get(app_id).copied(); + match spoke { + Some(at) => at.elapsed() > std::time::Duration::from_secs(20), + None => true, + } +} + /// Raises a macOS notification and waits, on its own thread, to see it clicked. /// /// Not the notification plugin: that has no way to report a click, and a diff --git a/src/api.ts b/src/api.ts index 23feeab..d64c9c6 100644 --- a/src/api.ts +++ b/src/api.ts @@ -64,3 +64,5 @@ export const resetConfig = () => invoke("reset_config"); export const setWindowChrome = (dark: boolean) => invoke("set_window_chrome", { dark }); export const chromeHeight = () => invoke("chrome_height"); +export const setCountNotifications = (enabled: boolean) => + invoke("set_count_notifications", { enabled }); diff --git a/src/components/Settings.tsx b/src/components/Settings.tsx index 3f9a004..7305828 100644 --- a/src/components/Settings.tsx +++ b/src/components/Settings.tsx @@ -305,6 +305,21 @@ export default function Settings({ {notifyStatus && (

{notifyStatus}

)} +
+ + value={config.settings.countNotifications ? "on" : "off"} + onChange={(v) => run(() => api.setCountNotifications(v === "on"))} + options={[ + { value: "on", label: "On" }, + { value: "off", label: "Off" }, + ]} + /> + + Notify from unread counts, for apps that never notify by themselves — + Google Chat is one. A count is skipped when the app has just said the same + thing in its own words, so mail does not arrive twice. + +

WKWebView defines a notification API that silently does nothing, so it is replaced with one that forwards to macOS. Notifications raised by a service diff --git a/src/types.ts b/src/types.ts index 251cf1d..5095225 100644 --- a/src/types.ts +++ b/src/types.ts @@ -25,6 +25,7 @@ export interface Settings { navCollapsed: boolean; theme: "system" | "light" | "dark"; lastApp: string | null; + countNotifications: boolean; } export interface Config {