From aa19da48de86bd4bf88316b3c5c76c0d04594c38 Mon Sep 17 00:00:00 2001 From: Vincent Date: Wed, 2 Sep 2026 09:36:57 +0200 Subject: [PATCH] Stop announcing the same email twice Both notification paths feed one channel, which is how a single arrival came to be announced twice: Gmail names the sender, then the unread count says "1 new" behind it seconds later. A count is now skipped when the app has spoken for itself in the last twenty seconds - 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 hearing only what an app says in its own words, at the cost of the tools that never say anything. `npm run ship` now updates only /Applications. The Desktop installer moved behind `--dmg`, for when a build is going to someone else. --- README.md | 15 ++++--- .../specs/2026-09-01-work-app-design.md | 10 ++++- scripts/ship.sh | 39 ++++++++++++------- src-tauri/src/commands.rs | 14 +++++++ src-tauri/src/config.rs | 11 ++++++ src-tauri/src/lib.rs | 1 + src-tauri/src/webviews.rs | 34 +++++++++++++++- src/api.ts | 2 + src/components/Settings.tsx | 15 +++++++ src/types.ts | 1 + 10 files changed, 121 insertions(+), 21 deletions(-) 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 {