diff --git a/apps/x/apps/main/src/meeting-detect/service.ts b/apps/x/apps/main/src/meeting-detect/service.ts index 82a19824..91080ee0 100644 --- a/apps/x/apps/main/src/meeting-detect/service.ts +++ b/apps/x/apps/main/src/meeting-detect/service.ts @@ -53,7 +53,16 @@ export class MeetingDetectService { this.pending.add(work); void work.finally(() => this.pending.delete(work)); }); + this.detector.on("meeting-cleared", (event) => { + // Mic released → drop the session's suppression so the next call + // (same Chrome process, new Meet) can fire again. + this.suppression.clearSession(event.sessionKey).catch((err) => { + console.error("[MeetingDetect] clearSession failed:", err); + }); + console.log(`[MeetingDetect] session cleared: ${event.sessionKey}`); + }); this.detector.start(); + console.log("[MeetingDetect] service started — polling for meeting apps holding the mic"); } stop(): void { @@ -68,7 +77,11 @@ export class MeetingDetectService { } private async handleActive(event: MeetingActiveEvent): Promise { - if (!this.suppression.shouldNotify(event.sessionKey, event.executable)) return; + console.log(`[MeetingDetect] active: ${event.executable} (kind=${event.kind})`); + if (!this.suppression.shouldNotify(event.sessionKey, event.executable)) { + console.log(`[MeetingDetect] suppressed (already notified or muted): ${event.sessionKey}`); + return; + } // For browsers we MUST confirm the foreground tab is a meeting page — // otherwise we'd popup for YouTube, Spotify web, etc. diff --git a/apps/x/apps/main/src/meeting-detect/suppression.ts b/apps/x/apps/main/src/meeting-detect/suppression.ts index cdea3648..c2b42ece 100644 --- a/apps/x/apps/main/src/meeting-detect/suppression.ts +++ b/apps/x/apps/main/src/meeting-detect/suppression.ts @@ -93,6 +93,18 @@ export class Suppression { await this.persist(); } + /** + * Clear the notified mark for a session. Called when the detector observes + * the mic being released — without this, on Windows (no pid in sessionKey) + * the same browser would never re-fire because every new Meet call reuses + * the same exe-keyed session. + */ + async clearSession(sessionKey: string): Promise { + if (!this.state.notifiedSessions[sessionKey]) return; + delete this.state.notifiedSessions[sessionKey]; + await this.persist(); + } + async markDismissed(executable: string, now: Date = new Date()): Promise { this.state.recentlyDismissed[dismissKeyFor(executable)] = { dismissedAt: now.toISOString() }; await this.persist(); diff --git a/apps/x/apps/renderer/src/App.tsx b/apps/x/apps/renderer/src/App.tsx index a35fbca7..c6184e47 100644 --- a/apps/x/apps/renderer/src/App.tsx +++ b/apps/x/apps/renderer/src/App.tsx @@ -3968,36 +3968,53 @@ function App() { return window.ipc.on('app:openUrl', ({ url }) => handle(url)) }, []) - // Triggered by main when the user clicks a calendar-meeting notification. - // Reuses the same flow as the in-app "Join meeting & take notes" button. + // Triggered by main when the user clicks a meeting-notes notification — + // either the calendar-time notification (event populated) or the mic-detect + // ad-hoc notification (event=null, title=string). Both routes feed the same + // calendar-block flow which kicks off startMeetingNow(). // When `openMeeting` is true, also opens the meeting URL in the system browser. useEffect(() => { - return window.ipc.on('app:takeMeetingNotes', ({ event, openMeeting }) => { - const e = event as { - summary?: string - start?: { dateTime?: string; date?: string; timeZone?: string } - end?: { dateTime?: string; date?: string; timeZone?: string } - location?: string - htmlLink?: string - hangoutLink?: string - conferenceData?: { entryPoints?: Array<{ entryPointType?: string; uri?: string }> } - } - if (!e || typeof e !== 'object') return - const conferenceLink = extractConferenceLink(e as Record) - if (openMeeting && conferenceLink) { - window.open(conferenceLink, '_blank') - } else if (openMeeting) { - console.warn('[take-meeting-notes] openMeeting requested but event has no conference link', e) - } - window.__pendingCalendarEvent = { - summary: e.summary, - start: e.start, - end: e.end, - location: e.location, - htmlLink: e.htmlLink, - conferenceLink, - source: 'calendar-sync', + return window.ipc.on('app:takeMeetingNotes', ({ event, openMeeting, title }) => { + const payload = event as + | { + summary?: string + start?: { dateTime?: string; date?: string; timeZone?: string } + end?: { dateTime?: string; date?: string; timeZone?: string } + location?: string + htmlLink?: string + hangoutLink?: string + conferenceData?: { entryPoints?: Array<{ entryPointType?: string; uri?: string }> } + } + | null + | undefined + + if (payload && typeof payload === 'object') { + const conferenceLink = extractConferenceLink(payload as Record) + if (openMeeting && conferenceLink) { + window.open(conferenceLink, '_blank') + } else if (openMeeting) { + console.warn('[take-meeting-notes] openMeeting requested but event has no conference link', payload) + } + window.__pendingCalendarEvent = { + summary: payload.summary, + start: payload.start, + end: payload.end, + location: payload.location, + htmlLink: payload.htmlLink, + conferenceLink, + source: 'calendar-sync', + } + } else if (typeof title === 'string' && title.length > 0) { + // Ad-hoc detection — no calendar event matched. Build a minimal + // pending event from the title so the meeting flow can still start. + window.__pendingCalendarEvent = { + summary: title, + source: 'meeting-detect', + } + } else { + return } + window.dispatchEvent(new Event('calendar-block:join-meeting')) }) }, []) diff --git a/apps/x/packages/shared/src/ipc.ts b/apps/x/packages/shared/src/ipc.ts index 230d384c..f61aae7e 100644 --- a/apps/x/packages/shared/src/ipc.ts +++ b/apps/x/packages/shared/src/ipc.ts @@ -395,11 +395,13 @@ const ipcSchemas = { }, 'app:takeMeetingNotes': { req: z.object({ - // Pass the raw calendar event JSON through; renderer adapts to its existing flow. + // Calendar event JSON when correlated; null for mic-detect ad-hoc fires. event: z.unknown(), // When true, the renderer should also open the meeting URL (Zoom/Meet/etc.) // in addition to triggering the take-notes flow. openMeeting: z.boolean().optional(), + // Fallback title for ad-hoc detection (no calendar event matched). + title: z.string().nullable().optional(), }), res: z.null(), },