mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-06-12 19:55:19 +02:00
fix: detect browser meetings on macOS, prevent duplicate popups (#562)
- probe-macos.ts: match NoIdleSleepAssertion (Chrome's WebRTC signal) so browser meetings are detected; tighten PreventUserIdle regex to drop caffeinate/powerd noise - browser-match.ts: tighten Slack/Teams rules to require real in-call URLs; prioritize google-meet/zoom/teams/webex over slack-huddle when multiple tabs match - suppression.ts: 90s per-app notify cooldown to stop repeat popups when the WebRTC assertion flickers mid-meeting Tested live on macOS — single correctly-labeled popup confirmed.
This commit is contained in:
parent
5d8eecd3dc
commit
af4cde329d
7 changed files with 289 additions and 42 deletions
|
|
@ -1,5 +1,5 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import { matchTitleOrUrl } from "./browser-match.js";
|
||||
import { matchTitleOrUrl, pickBestMatch } from "./browser-match.js";
|
||||
|
||||
describe("matchTitleOrUrl", () => {
|
||||
it("matches Google Meet by URL", () => {
|
||||
|
|
@ -22,8 +22,8 @@ describe("matchTitleOrUrl", () => {
|
|||
expect(m?.platform).toBe("zoom-web");
|
||||
});
|
||||
|
||||
it("matches Teams web", () => {
|
||||
const m = matchTitleOrUrl("Meeting | Microsoft Teams", "https://teams.microsoft.com/_#/calendarv2");
|
||||
it("matches Teams web on a real meeting (meetup-join) URL", () => {
|
||||
const m = matchTitleOrUrl("Meeting | Microsoft Teams", "https://teams.microsoft.com/l/meetup-join/19%3ameeting_abc");
|
||||
expect(m?.platform).toBe("teams-web");
|
||||
});
|
||||
|
||||
|
|
@ -32,6 +32,22 @@ describe("matchTitleOrUrl", () => {
|
|||
expect(m).toBeNull();
|
||||
});
|
||||
|
||||
// Tightened rules — being open is not being in a call (issue #562 follow-up).
|
||||
it("does NOT match a plain Slack tab (DM/channel open, no huddle)", () => {
|
||||
const m = matchTitleOrUrl("Gagan (DM) - rowboat - Slack", "https://app.slack.com/client/T077R8M5U94/D0B77701AN7");
|
||||
expect(m).toBeNull();
|
||||
});
|
||||
|
||||
it("matches a Slack huddle by its title marker", () => {
|
||||
const m = matchTitleOrUrl("Huddle in general - rowboat - Slack", "https://app.slack.com/client/T077/C123");
|
||||
expect(m?.platform).toBe("slack-huddle");
|
||||
});
|
||||
|
||||
it("does NOT match a Teams calendar/chat tab (no meetup-join)", () => {
|
||||
const m = matchTitleOrUrl("Calendar | Microsoft Teams", "https://teams.microsoft.com/_#/calendarv2");
|
||||
expect(m).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for empty input", () => {
|
||||
expect(matchTitleOrUrl(undefined, undefined)).toBeNull();
|
||||
expect(matchTitleOrUrl("", "")).toBeNull();
|
||||
|
|
@ -42,3 +58,54 @@ describe("matchTitleOrUrl", () => {
|
|||
expect(m?.platform).toBe("zoom-web");
|
||||
});
|
||||
});
|
||||
|
||||
describe("pickBestMatch", () => {
|
||||
// Verbatim tab set from the live session in issue #562: a Slack DM tab sat
|
||||
// in front of the real Google Meet call. The old first-match logic labeled
|
||||
// the popup "Slack huddle"; priority must now pick Google Meet — and the
|
||||
// plain Slack tab must not match at all under the tightened rules.
|
||||
const LIVE_TABS = [
|
||||
"https://www.coursera.org/learn/dao-3022/lecture/3d0S8/benchmarking-evaluation-part-1",
|
||||
"Benchmarking & Evaluation- Part 1 | Coursera",
|
||||
"https://www.youtube.com/watch?v=qt2XslRMOto",
|
||||
"(58) Inside India's Wealth Gap ... - YouTube",
|
||||
"https://app.slack.com/client/T077R8M5U94/D0B77701AN7",
|
||||
"Gagan (DM) - rowboat - Slack",
|
||||
"https://github.com/rowboatlabs/rowboat/pull/562",
|
||||
"feat: detect meeting joins ... · Pull Request #562",
|
||||
"https://mail.google.com/mail/u/0/?tab=rm&ogbl#inbox",
|
||||
"Inbox (4,067) - prakhar9999pandey@gmail.com - Gmail",
|
||||
"https://meet.google.com/uaz-funz-pvy?authuser=0",
|
||||
"Meet – uaz-funz-pvy",
|
||||
];
|
||||
|
||||
it("picks Google Meet over a backgrounded plain Slack tab (the #562 bug)", () => {
|
||||
const m = pickBestMatch(LIVE_TABS);
|
||||
expect(m?.platform).toBe("google-meet");
|
||||
expect(m?.hint).toContain("meet.google.com/uaz-funz-pvy");
|
||||
});
|
||||
|
||||
it("prioritizes google-meet over a genuine slack-huddle when both are open", () => {
|
||||
const m = pickBestMatch([
|
||||
"Huddle in general - rowboat - Slack",
|
||||
"https://meet.google.com/abc-defg-hij",
|
||||
]);
|
||||
expect(m?.platform).toBe("google-meet");
|
||||
});
|
||||
|
||||
it("still returns the slack-huddle when it is the only meeting tab", () => {
|
||||
const m = pickBestMatch([
|
||||
"Inbox - Gmail",
|
||||
"Huddle in general - rowboat - Slack",
|
||||
]);
|
||||
expect(m?.platform).toBe("slack-huddle");
|
||||
});
|
||||
|
||||
it("returns null when no tab is an actual meeting", () => {
|
||||
expect(pickBestMatch([
|
||||
"Gagan (DM) - rowboat - Slack",
|
||||
"Calendar | Microsoft Teams",
|
||||
"Inbox - Gmail",
|
||||
])).toBeNull();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -14,23 +14,39 @@ interface TitleRule {
|
|||
needles: string[];
|
||||
}
|
||||
|
||||
// Substrings we look for in the foreground window title (or URL when we
|
||||
// have it). On Chrome/Edge/Firefox the page title is embedded in the window
|
||||
// title, which is the most reliable cross-platform signal.
|
||||
// Meet page title: "Meet - Daily Standup" → matches "meet -"
|
||||
// Zoom web client: "Zoom Meeting" → matches "zoom meeting"
|
||||
// Teams web: "<topic> | Microsoft Teams" → matches "microsoft teams"
|
||||
// Substrings that indicate the user is ACTIVELY IN A CALL — not merely that the
|
||||
// app happens to be open in a tab. Bare domains ("app.slack.com",
|
||||
// "teams.microsoft.com") match any Slack DM or Teams calendar tab, so we require
|
||||
// call-specific URL paths or title markers instead.
|
||||
// Meet: meeting URLs are meet.google.com/<code>; title "Meet - <name>".
|
||||
// Zoom: web client lives at zoom.us/j/<id> or zoom.us/wc/<id>.
|
||||
// Teams: a live meeting join URL contains "meetup-join" (teams.microsoft.com
|
||||
// or teams.live.com); the bare domain (calendar, chat) does not.
|
||||
// Slack: a huddle shows "huddle" in the tab title; a plain Slack tab does not.
|
||||
// Webex: meeting URLs contain webex.com/meet or /wbxmjs.
|
||||
const RULES: TitleRule[] = [
|
||||
{ platform: "google-meet", needles: ["meet.google.com", "google meet", "meet -", "meet —", "meet |"] },
|
||||
{ platform: "google-meet", needles: ["meet.google.com/", "google meet", "meet -", "meet —", "meet |"] },
|
||||
{ platform: "zoom-web", needles: ["zoom.us/j/", "zoom.us/wc/", "zoom meeting"] },
|
||||
{ platform: "teams-web", needles: ["teams.microsoft.com", "microsoft teams"] },
|
||||
{ platform: "slack-huddle", needles: ["app.slack.com", "slack huddle"] },
|
||||
{ platform: "teams-web", needles: ["meetup-join", "teams.live.com/meet"] },
|
||||
{ platform: "webex-web", needles: ["webex.com/meet", "webex.com/wbxmjs", "webex meeting"] },
|
||||
{ platform: "slack-huddle", needles: ["huddle"] },
|
||||
];
|
||||
|
||||
// When several tabs match different platforms (e.g. a Slack DM open behind the
|
||||
// real Google Meet call), prefer the more definitive meeting. Dedicated meeting
|
||||
// platforms outrank a Slack huddle, whose "huddle" title marker is the weakest
|
||||
// signal. Lower index = higher precedence.
|
||||
const PLATFORM_PRIORITY: BrowserMeetingPlatform[] = [
|
||||
"google-meet",
|
||||
"zoom-web",
|
||||
"teams-web",
|
||||
"webex-web",
|
||||
"slack-huddle",
|
||||
];
|
||||
|
||||
/**
|
||||
* Look at the foreground window. If it's a browser and the title matches a
|
||||
* known meeting URL/platform, return a match. Returns null otherwise.
|
||||
* Look at the browser's open tabs/windows. If any matches a known meeting
|
||||
* URL/platform, return the highest-priority match. Returns null otherwise.
|
||||
*
|
||||
* Caller is expected to only invoke this when the detector classified the
|
||||
* mic-holder as `kind: "browser"`. That keeps active-win calls cheap — we
|
||||
|
|
@ -39,13 +55,28 @@ const RULES: TitleRule[] = [
|
|||
export async function matchBrowserMeeting(executable?: string): Promise<BrowserMeetingMatch | null> {
|
||||
const snap = await getWindowSnapshot(executable);
|
||||
if (!snap) return null;
|
||||
// Scan ALL known window titles — on Windows tasklist returns every window,
|
||||
// so even a backgrounded Meet tab still matches while Chrome holds the mic.
|
||||
for (const title of snap.titles) {
|
||||
return pickBestMatch(snap.titles);
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan every tab title/URL, collect matches, and return the highest-priority
|
||||
* one — not just the first tab that matches. This prevents a backgrounded Slack
|
||||
* DM from beating the real Google Meet call to the result. Pure; exposed for tests.
|
||||
*/
|
||||
export function pickBestMatch(titles: string[]): BrowserMeetingMatch | null {
|
||||
let best: BrowserMeetingMatch | null = null;
|
||||
let bestRank = Number.POSITIVE_INFINITY;
|
||||
for (const title of titles) {
|
||||
const m = matchTitleOrUrl(title, undefined);
|
||||
if (m) return m;
|
||||
if (!m) continue;
|
||||
const rank = PLATFORM_PRIORITY.indexOf(m.platform);
|
||||
if (rank < bestRank) {
|
||||
best = m;
|
||||
bestRank = rank;
|
||||
if (rank === 0) break; // nothing outranks the top platform
|
||||
}
|
||||
}
|
||||
return null;
|
||||
return best;
|
||||
}
|
||||
|
||||
/** Pure matcher — exposed for tests; no OS calls. */
|
||||
|
|
|
|||
76
apps/x/apps/main/src/meeting-detect/probe-macos.test.ts
Normal file
76
apps/x/apps/main/src/meeting-detect/probe-macos.test.ts
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import { parseAssertions } from "./probe-macos.js";
|
||||
|
||||
// Verbatim `pmset -g assertions` capture from a live macOS session (issue #562):
|
||||
// Google Chrome is in a Google Meet call with the camera on, while caffeinate
|
||||
// and powerd hold unrelated PreventUserIdleSystemSleep locks. The browser holds
|
||||
// a NoIdleSleepAssertion ("WebRTC has active PeerConnections") — the regex must
|
||||
// match that and ignore the System-sleep noise.
|
||||
const PMSET_WEBRTC_CALL = `2026-06-11 22:59:21 +0530
|
||||
Assertion status system-wide:
|
||||
BackgroundTask 0
|
||||
ApplePushServiceTask 0
|
||||
UserIsActive 1
|
||||
PreventUserIdleDisplaySleep 0
|
||||
SoftwareUpdateTask 0
|
||||
PreventSystemSleep 0
|
||||
ExternalMedia 0
|
||||
PreventUserIdleSystemSleep 1
|
||||
NetworkClientActive 0
|
||||
Listed by owning process:
|
||||
pid 171(WindowServer): [0x00003a1100099303] 00:00:00 UserIsActive named: "com.apple.iohideventsystem.queue.tickle serviceID:100000944 service:AppleMultitouchDevice product:Apple Internal Keyboard / Trackpad eventType:11"
|
||||
\tTimeout will fire in 119 secs Action=TimeoutActionRelease
|
||||
pid 664(Google Chrome): [0x00003c6000019337] 00:00:59 NoIdleSleepAssertion named: "WebRTC has active PeerConnections"
|
||||
pid 72851(caffeinate): [0x00003b3700019329] 00:00:12 PreventUserIdleSystemSleep named: "caffeinate command-line tool"
|
||||
\tDetails: caffeinate asserting for 300 secs
|
||||
\tLocalized=THE CAFFEINATE TOOL IS PREVENTING SLEEP.
|
||||
\tTimeout will fire in 287 secs Action=TimeoutActionRelease
|
||||
pid 107(powerd): [0x00003a1100019304] 00:06:26 PreventUserIdleSystemSleep named: "Powerd - Prevent sleep while display is on"
|
||||
No kernel assertions.
|
||||
`;
|
||||
|
||||
describe("parseAssertions", () => {
|
||||
it("matches a browser's NoIdleSleepAssertion (WebRTC) and ignores System-sleep noise", () => {
|
||||
const users = parseAssertions(PMSET_WEBRTC_CALL);
|
||||
|
||||
// Chrome (NoIdleSleepAssertion) is in; caffeinate + powerd
|
||||
// (PreventUserIdleSystemSleep) are filtered out.
|
||||
expect(users).toEqual([{ executable: "Google Chrome", pid: 664 }]);
|
||||
});
|
||||
|
||||
it("matches a native app's PreventUserIdleDisplaySleep assertion", () => {
|
||||
const stdout = [
|
||||
"Listed by owning process:",
|
||||
` pid 4711(zoom.us): [0x00000ff100099303] 00:23:14 PreventUserIdleDisplaySleep named: "zoom.us is in a meeting"`,
|
||||
].join("\n");
|
||||
|
||||
expect(parseAssertions(stdout)).toEqual([{ executable: "zoom.us", pid: 4711 }]);
|
||||
});
|
||||
|
||||
it("does NOT match PreventUserIdleSystemSleep (caffeinate/powerd noise)", () => {
|
||||
const stdout =
|
||||
` pid 72851(caffeinate): [0x00003b3700019329] 00:00:12 PreventUserIdleSystemSleep named: "caffeinate command-line tool"`;
|
||||
|
||||
expect(parseAssertions(stdout)).toEqual([]);
|
||||
});
|
||||
|
||||
it("dedupes a pid that holds multiple matching assertions (first wins)", () => {
|
||||
const stdout = [
|
||||
` pid 664(Google Chrome): [0xaaa] 00:00:59 NoIdleSleepAssertion named: "WebRTC has active PeerConnections"`,
|
||||
` pid 664(Google Chrome): [0xbbb] 00:01:00 PreventUserIdleDisplaySleep named: "screen share"`,
|
||||
].join("\n");
|
||||
|
||||
expect(parseAssertions(stdout)).toEqual([{ executable: "Google Chrome", pid: 664 }]);
|
||||
});
|
||||
|
||||
it("returns an empty list when nothing holds a meeting assertion", () => {
|
||||
const stdout = [
|
||||
"Assertion status system-wide:",
|
||||
" PreventUserIdleDisplaySleep 0",
|
||||
"Listed by owning process:",
|
||||
` pid 171(WindowServer): [0x00003a1100099303] 00:00:00 UserIsActive named: "tickle"`,
|
||||
].join("\n");
|
||||
|
||||
expect(parseAssertions(stdout)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
|
@ -17,7 +17,18 @@ const execFileAsync = promisify(execFile);
|
|||
//
|
||||
// Output format from `pmset -g assertions`:
|
||||
// pid 4711(zoom.us): [0x00000ff...] 00:23:14 PreventUserIdleDisplaySleep named: "..."
|
||||
const ASSERTION_LINE = /^\s*pid\s+(\d+)\((.+?)\):\s+\[[^\]]+\]\s+\S+\s+(PreventUserIdle\w+)/;
|
||||
// pid 664(Google Chrome): [0x...] 00:00:59 NoIdleSleepAssertion named: "WebRTC has active PeerConnections"
|
||||
//
|
||||
// We key on two assertion types:
|
||||
// - PreventUserIdleDisplaySleep — native meeting apps keep the screen on
|
||||
// during a call. We deliberately do NOT match PreventUserIdleSystemSleep,
|
||||
// which is held by `caffeinate`, `powerd`, downloads, etc. (noise).
|
||||
// - NoIdleSleepAssertion — browsers (Chrome/Arc/Safari/etc.) hold this with
|
||||
// the reason "WebRTC has active PeerConnections" whenever a WebRTC call is
|
||||
// live (Google Meet, Zoom web, Teams web, Discord, Slack huddles). This is
|
||||
// the most reliable browser-meeting signal. False positives (e.g. a WebRTC
|
||||
// YouTube tab) are filtered downstream by browser-match's tab-title check.
|
||||
const ASSERTION_LINE = /^\s*pid\s+(\d+)\((.+?)\):\s+\[[^\]]+\]\s+\S+\s+(PreventUserIdleDisplaySleep|NoIdleSleepAssertion)/;
|
||||
|
||||
export class MacOsMicProbe implements MicProbe {
|
||||
async probe(): Promise<MicUser[]> {
|
||||
|
|
@ -32,16 +43,25 @@ export class MacOsMicProbe implements MicProbe {
|
|||
return [];
|
||||
}
|
||||
|
||||
const seen = new Map<number, MicUser>();
|
||||
for (const line of stdout.split("\n")) {
|
||||
const m = ASSERTION_LINE.exec(line);
|
||||
if (!m) continue;
|
||||
const pid = Number(m[1]);
|
||||
const command = m[2].trim();
|
||||
if (!Number.isFinite(pid)) continue;
|
||||
if (seen.has(pid)) continue;
|
||||
seen.set(pid, { executable: command, pid });
|
||||
}
|
||||
return Array.from(seen.values());
|
||||
return parseAssertions(stdout);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse `pmset -g assertions` stdout into the distinct processes holding a
|
||||
* meeting-relevant assertion. Pure (no OS calls) so it's unit-testable against
|
||||
* captured pmset output. One entry per pid; the first matching line wins.
|
||||
*/
|
||||
export function parseAssertions(stdout: string): MicUser[] {
|
||||
const seen = new Map<number, MicUser>();
|
||||
for (const line of stdout.split("\n")) {
|
||||
const m = ASSERTION_LINE.exec(line);
|
||||
if (!m) continue;
|
||||
const pid = Number(m[1]);
|
||||
const command = m[2].trim();
|
||||
if (!Number.isFinite(pid)) continue;
|
||||
if (seen.has(pid)) continue;
|
||||
seen.set(pid, { executable: command, pid });
|
||||
}
|
||||
return Array.from(seen.values());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -134,7 +134,7 @@ export class MeetingDetectService {
|
|||
} else {
|
||||
this.notifier.notify(payload.notify);
|
||||
}
|
||||
await this.suppression.markNotified(event.sessionKey);
|
||||
await this.suppression.markNotified(event.sessionKey, event.executable);
|
||||
console.log(`[MeetingDetect] popup fired for ${event.executable} (kind=${event.kind}, eventId=${correlated?.eventId ?? "ad-hoc"})`);
|
||||
} catch (err) {
|
||||
console.error("[MeetingDetect] popup failed:", err);
|
||||
|
|
|
|||
|
|
@ -16,13 +16,37 @@ describe("Suppression", () => {
|
|||
});
|
||||
|
||||
it("blocks re-popup for the same session once marked notified", async () => {
|
||||
await suppression.markNotified("zoom.us#100");
|
||||
await suppression.markNotified("zoom.us#100", "zoom.us");
|
||||
expect(suppression.shouldNotify("zoom.us#100", "zoom.us")).toBe(false);
|
||||
});
|
||||
|
||||
it("allows a different session for the same exe", async () => {
|
||||
await suppression.markNotified("zoom.us#100");
|
||||
expect(suppression.shouldNotify("zoom.us#101", "zoom.us")).toBe(true);
|
||||
it("blocks a different session for the same exe within the notify cooldown", async () => {
|
||||
// A flaky mic assertion clears the session and re-detects under a new key;
|
||||
// the per-app cooldown must suppress the duplicate popup (issue #562 follow-up).
|
||||
const t0 = new Date();
|
||||
await suppression.markNotified("zoom.us#100", "zoom.us", t0);
|
||||
const soon = new Date(t0.getTime() + 30 * 1000); // 30s later — within the 90s cooldown
|
||||
expect(suppression.shouldNotify("zoom.us#101", "zoom.us", soon)).toBe(false);
|
||||
});
|
||||
|
||||
it("allows the same exe again once the notify cooldown has elapsed", async () => {
|
||||
const t0 = new Date();
|
||||
await suppression.markNotified("zoom.us#100", "zoom.us", t0);
|
||||
const after = new Date(t0.getTime() + 100 * 1000); // 100s — past the 90s cooldown
|
||||
// Cooldown GC drops stale entries on reload, mirroring the dismiss-cooldown test.
|
||||
const reloaded = new Suppression(store);
|
||||
await reloaded.init();
|
||||
expect(reloaded.shouldNotify("zoom.us#101", "zoom.us", after)).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps the cooldown across clearSession (the flicker case)", async () => {
|
||||
const t0 = new Date();
|
||||
await suppression.markNotified("Google Chrome#664", "Google Chrome", t0);
|
||||
// Mic assertion blinks out → detector clears the session.
|
||||
await suppression.clearSession("Google Chrome#664");
|
||||
// Same session re-detected moments later must NOT re-popup.
|
||||
const soon = new Date(t0.getTime() + 30 * 1000);
|
||||
expect(suppression.shouldNotify("Google Chrome#664", "Google Chrome", soon)).toBe(false);
|
||||
});
|
||||
|
||||
it("respects the dismiss cooldown window", async () => {
|
||||
|
|
@ -51,7 +75,7 @@ describe("Suppression", () => {
|
|||
});
|
||||
|
||||
it("persists state through save/load", async () => {
|
||||
await suppression.markNotified("zoom.us#100");
|
||||
await suppression.markNotified("zoom.us#100", "zoom.us");
|
||||
await suppression.muteApp("Discord");
|
||||
|
||||
const snap = store.snapshot();
|
||||
|
|
|
|||
|
|
@ -5,12 +5,21 @@ import { WorkDir } from "@x/core/dist/config/config.js";
|
|||
const STATE_FILE = path.join(WorkDir, "meeting_detect_state.json");
|
||||
// Don't re-popup for the same exe within this window if the user dismissed.
|
||||
const DISMISS_COOLDOWN_MS = 30 * 60 * 1000;
|
||||
// After showing a popup for an app, stay quiet for the SAME app for this long —
|
||||
// even if the mic/WebRTC assertion flickers and the detector clears + re-fires
|
||||
// the session. The macOS pmset assertion blinks out for stretches of a single
|
||||
// call (observed dropouts maxed around 84s), which would otherwise re-fire a
|
||||
// fresh popup every time it reappears. 90s covers the worst observed flicker.
|
||||
const NOTIFY_COOLDOWN_MS = 90 * 1000;
|
||||
// Drop session-key entries older than 24h.
|
||||
const SESSION_TTL_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
interface SuppressionState {
|
||||
// Mic sessions we've already shown a popup for — keyed by detector sessionKey.
|
||||
notifiedSessions: Record<string, { notifiedAt: string }>;
|
||||
// Apps we've recently shown a popup for — keyed by exe basename. Drives the
|
||||
// per-app cooldown; survives clearSession() so a flickering session can't spam.
|
||||
recentlyNotified: Record<string, { notifiedAt: string }>;
|
||||
// User explicitly dismissed for this exe at this time.
|
||||
recentlyDismissed: Record<string, { dismissedAt: string }>;
|
||||
// Permanent "never offer for this app" list — exe substring matches.
|
||||
|
|
@ -18,7 +27,7 @@ interface SuppressionState {
|
|||
}
|
||||
|
||||
function empty(): SuppressionState {
|
||||
return { notifiedSessions: {}, recentlyDismissed: {}, mutedApps: [] };
|
||||
return { notifiedSessions: {}, recentlyNotified: {}, recentlyDismissed: {}, mutedApps: [] };
|
||||
}
|
||||
|
||||
export interface SuppressionStore {
|
||||
|
|
@ -52,6 +61,7 @@ function normalize(raw: unknown): SuppressionState {
|
|||
const obj = raw as Partial<SuppressionState>;
|
||||
return {
|
||||
notifiedSessions: obj.notifiedSessions && typeof obj.notifiedSessions === "object" ? obj.notifiedSessions : {},
|
||||
recentlyNotified: obj.recentlyNotified && typeof obj.recentlyNotified === "object" ? obj.recentlyNotified : {},
|
||||
recentlyDismissed: obj.recentlyDismissed && typeof obj.recentlyDismissed === "object" ? obj.recentlyDismissed : {},
|
||||
mutedApps: Array.isArray(obj.mutedApps) ? obj.mutedApps.filter((x) => typeof x === "string") : [],
|
||||
};
|
||||
|
|
@ -77,8 +87,19 @@ export class Suppression {
|
|||
if (this.isMuted(executable)) return false;
|
||||
if (this.state.notifiedSessions[sessionKey]) return false;
|
||||
|
||||
const dismissKey = dismissKeyFor(executable);
|
||||
const recent = this.state.recentlyDismissed[dismissKey];
|
||||
const appKey = dismissKeyFor(executable);
|
||||
|
||||
// Per-app cooldown: once we've popped for this app, stay quiet for the
|
||||
// window even if the session cleared and re-fired (flaky mic assertion).
|
||||
const lastNotified = this.state.recentlyNotified[appKey];
|
||||
if (lastNotified) {
|
||||
const notifiedAt = Date.parse(lastNotified.notifiedAt);
|
||||
if (Number.isFinite(notifiedAt) && now.getTime() - notifiedAt < NOTIFY_COOLDOWN_MS) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const recent = this.state.recentlyDismissed[appKey];
|
||||
if (recent) {
|
||||
const dismissedAt = Date.parse(recent.dismissedAt);
|
||||
if (Number.isFinite(dismissedAt) && now.getTime() - dismissedAt < DISMISS_COOLDOWN_MS) {
|
||||
|
|
@ -88,8 +109,11 @@ export class Suppression {
|
|||
return true;
|
||||
}
|
||||
|
||||
async markNotified(sessionKey: string, now: Date = new Date()): Promise<void> {
|
||||
async markNotified(sessionKey: string, executable: string, now: Date = new Date()): Promise<void> {
|
||||
this.state.notifiedSessions[sessionKey] = { notifiedAt: now.toISOString() };
|
||||
// App-level stamp drives the cooldown and is intentionally NOT removed by
|
||||
// clearSession() — that's what makes a flickering session stop re-popping.
|
||||
this.state.recentlyNotified[dismissKeyFor(executable)] = { notifiedAt: now.toISOString() };
|
||||
await this.persist();
|
||||
}
|
||||
|
||||
|
|
@ -146,12 +170,17 @@ function gc(state: SuppressionState): SuppressionState {
|
|||
const ts = Date.parse(v.notifiedAt);
|
||||
if (Number.isFinite(ts) && now - ts < SESSION_TTL_MS) sessions[k] = v;
|
||||
}
|
||||
const notified: SuppressionState["recentlyNotified"] = {};
|
||||
for (const [k, v] of Object.entries(state.recentlyNotified)) {
|
||||
const ts = Date.parse(v.notifiedAt);
|
||||
if (Number.isFinite(ts) && now - ts < NOTIFY_COOLDOWN_MS) notified[k] = v;
|
||||
}
|
||||
const dismissed: SuppressionState["recentlyDismissed"] = {};
|
||||
for (const [k, v] of Object.entries(state.recentlyDismissed)) {
|
||||
const ts = Date.parse(v.dismissedAt);
|
||||
if (Number.isFinite(ts) && now - ts < DISMISS_COOLDOWN_MS) dismissed[k] = v;
|
||||
}
|
||||
return { notifiedSessions: sessions, recentlyDismissed: dismissed, mutedApps: state.mutedApps };
|
||||
return { notifiedSessions: sessions, recentlyNotified: notified, recentlyDismissed: dismissed, mutedApps: state.mutedApps };
|
||||
}
|
||||
|
||||
/** In-memory store for tests. */
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue