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.
This commit is contained in:
2026-09-02 09:36:57 +02:00
parent c989fef6fe
commit aa19da48de
10 changed files with 121 additions and 21 deletions
+9 -6
View File
@@ -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-<version>.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-<version>.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
@@ -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
+26 -13
View File
@@ -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-<version>.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
+14
View File
@@ -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<f64>,
/// 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<std::collections::HashMap<String, std::time::Instant>>,
/// 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<Option<(String, String, String, String)>>,
@@ -80,6 +83,7 @@ pub fn build_state(handle: &AppHandle) -> Result<AppState, String> {
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<Config, String> {
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();
+11
View File
@@ -88,18 +88,29 @@ pub struct Settings {
/// The app that was showing when the window last closed.
#[serde(default)]
pub last_app: Option<String>,
/// 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,
}
}
}
+1
View File
@@ -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,
+33 -1
View File
@@ -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::<crate::commands::AppState>()
.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::<crate::commands::AppState>();
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
+2
View File
@@ -64,3 +64,5 @@ export const resetConfig = () => invoke<Config>("reset_config");
export const setWindowChrome = (dark: boolean) =>
invoke<void>("set_window_chrome", { dark });
export const chromeHeight = () => invoke<number>("chrome_height");
export const setCountNotifications = (enabled: boolean) =>
invoke<Config>("set_count_notifications", { enabled });
+15
View File
@@ -305,6 +305,21 @@ export default function Settings({
{notifyStatus && (
<p className={`${HELP} font-mono`}>{notifyStatus}</p>
)}
<div className="flex items-center gap-3 pt-1">
<Segmented<"on" | "off">
value={config.settings.countNotifications ? "on" : "off"}
onChange={(v) => run(() => api.setCountNotifications(v === "on"))}
options={[
{ value: "on", label: "On" },
{ value: "off", label: "Off" },
]}
/>
<span className={HELP}>
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.
</span>
</div>
<p className={HELP}>
WKWebView defines a notification API that silently does nothing, so it is
replaced with one that forwards to macOS. Notifications raised by a service
+1
View File
@@ -25,6 +25,7 @@ export interface Settings {
navCollapsed: boolean;
theme: "system" | "light" | "dark";
lastApp: string | null;
countNotifications: boolean;
}
export interface Config {