diff --git a/apps/x/apps/main/native/mic-monitor.swift b/apps/x/apps/main/native/mic-monitor.swift index bb923485..239de7ee 100644 --- a/apps/x/apps/main/native/mic-monitor.swift +++ b/apps/x/apps/main/native/mic-monitor.swift @@ -8,14 +8,18 @@ // // Two signals, best available wins: // - macOS 14.4+: per-process audio objects (kAudioHardwarePropertyProcessObjectList -// + kAudioProcessPropertyIsRunningInput) give the PIDs that own the mic, -// so the consumer can attribute the call to Chrome vs Zoom vs Slack. +// + kAudioProcessPropertyIsRunningInput) give the processes that own the +// mic. Each owner is resolved to its bundle ID (CoreAudio) and executable +// path (libproc) HERE, natively — the consumer must not need to shell out +// (child-process spawns from Electron's main process fail with EBADF +// while capture is active). // - Fallback: kAudioDevicePropertyDeviceIsRunningSomewhere on the default // input device — mic in use by *someone*, no attribution. // // Protocol: one JSON object per line on stdout, emitted on every state -// change (and once at startup): {"micInUse":true|false,"pids":[123,456]} -// ("pids" is empty when attribution is unavailable.) +// change (and once at startup): +// {"micInUse":true,"owners":[{"pid":123,"bundleId":"com.google.Chrome.helper","path":"/Applications/..."}]} +// ("owners" is empty when attribution is unavailable.) // The process exits when stdin closes, so it can never outlive the app. // // Compiled by apps/main/bundle.mjs (best-effort, macOS only) to @@ -23,6 +27,7 @@ import Foundation import CoreAudio +import Darwin func defaultInputDeviceID() -> AudioDeviceID? { var deviceID = AudioDeviceID(0) @@ -87,14 +92,64 @@ func processPID(_ object: AudioObjectID) -> Int32? { return Int32(pid) } -/// PIDs of processes currently capturing from any input device (macOS 14.4+; +func processBundleID(_ object: AudioObjectID) -> String? { + var addr = AudioObjectPropertyAddress( + mSelector: kAudioProcessPropertyBundleID, + mScope: kAudioObjectPropertyScopeGlobal, + mElement: kAudioObjectPropertyElementMain) + var value: Unmanaged? = nil + var size = UInt32(MemoryLayout?>.size) + guard AudioObjectGetPropertyData(object, &addr, 0, nil, &size, &value) == noErr, + let cf = value else { return nil } + let str = cf.takeRetainedValue() as String + return str.isEmpty ? nil : str +} + +func processPath(_ pid: Int32) -> String? { + var buffer = [CChar](repeating: 0, count: 4096) + let length = proc_pidpath(pid, &buffer, UInt32(buffer.count)) + guard length > 0 else { return nil } + return String(cString: buffer) +} + +struct MicOwner: Equatable { + let pid: Int32 + let bundleId: String + let path: String +} + +/// Processes currently capturing from any input device (macOS 14.4+; /// returns [] where unsupported and the device-level fallback takes over). -func micOwningPIDs() -> [Int32] { - var pids: [Int32] = [] +func micOwners() -> [MicOwner] { + var owners: [MicOwner] = [] for object in audioProcessObjectIDs() where processIsRunningInput(object) { - if let pid = processPID(object) { pids.append(pid) } + guard let pid = processPID(object) else { continue } + owners.append(MicOwner( + pid: pid, + bundleId: processBundleID(object) ?? "", + path: processPath(pid) ?? "")) } - return pids.sorted() + return owners.sorted { $0.pid < $1.pid } +} + +func jsonEscape(_ s: String) -> String { + var out = "" + for ch in s.unicodeScalars { + switch ch { + case "\"": out += "\\\"" + case "\\": out += "\\\\" + case "\n": out += "\\n" + case "\r": out += "\\r" + case "\t": out += "\\t" + default: + if ch.value < 0x20 { + out += String(format: "\\u%04x", ch.value) + } else { + out.unicodeScalars.append(ch) + } + } + } + return out } // Exit when the parent closes our stdin (app quit/crash) — never orphan. @@ -106,18 +161,20 @@ Thread { setbuf(stdout, nil) var lastInUse: Bool? = nil -var lastPids: [Int32] = [] +var lastOwners: [MicOwner] = [] while true { - let pids = micOwningPIDs() + let owners = micOwners() // Re-resolve the default device every poll: the user can switch input // devices (AirPods in/out) mid-session. let deviceInUse = defaultInputDeviceID().map(isRunningSomewhere) ?? false - let inUse = deviceInUse || !pids.isEmpty - if inUse != lastInUse || pids != lastPids { + let inUse = deviceInUse || !owners.isEmpty + if inUse != lastInUse || owners != lastOwners { lastInUse = inUse - lastPids = pids - let pidList = pids.map(String.init).joined(separator: ",") - print("{\"micInUse\":\(inUse),\"pids\":[\(pidList)]}") + lastOwners = owners + let ownerJson = owners.map { o in + "{\"pid\":\(o.pid),\"bundleId\":\"\(jsonEscape(o.bundleId))\",\"path\":\"\(jsonEscape(o.path))\"}" + }.joined(separator: ",") + print("{\"micInUse\":\(inUse),\"owners\":[\(ownerJson)]}") } Thread.sleep(forTimeInterval: 1.0) } diff --git a/apps/x/apps/main/src/ipc.ts b/apps/x/apps/main/src/ipc.ts index 85f6b047..ae258b49 100644 --- a/apps/x/apps/main/src/ipc.ts +++ b/apps/x/apps/main/src/ipc.ts @@ -68,6 +68,7 @@ import { isOnboardingComplete, markOnboardingComplete } from '@x/core/dist/confi import { loadNotificationSettings, saveNotificationSettings } from '@x/core/dist/config/notification_config.js'; import { saveAppSettings } from '@x/core/dist/config/app_settings.js'; import { setSelfCaptureActive } from '@x/core/dist/meetings/detector.js'; +import { notifyIfEnabled } from '@x/core/dist/application/notification/notifier.js'; import { consumePendingToggleMeetingNotes, setTrayRecordingState } from './tray.js'; import { closeMeetingPopup, getMeetingPopupPayload, handleMeetingPopupAction } from './meeting-popup.js'; @@ -887,6 +888,19 @@ export function setupIpcHandlers() { updateSelfCaptureState(); return { success: true as const }; }, + 'meeting:notifyNotesReady': async (_event, args) => { + // Granola-style re-entry point: the note refreshed in place, but the + // user has usually switched back to the meeting app — the notification + // brings them back. Suppressed while the app is focused. + void notifyIfEnabled('meeting_notes_ready', { + title: 'Meeting notes ready', + message: `Your notes for "${args.title}" are ready.`, + link: `rowboat://open?type=file&path=${encodeURIComponent(args.notePath)}`, + actionLabel: 'Open notes', + onlyWhenBackground: true, + }); + return { success: true as const }; + }, 'meetingDetect:getPayload': async () => { return { payload: getMeetingPopupPayload() }; }, @@ -2246,6 +2260,9 @@ export function setupIpcHandlers() { if (main.isMinimized()) main.restore(); main.show(); main.focus(); + // The user is typically in another app (e.g. just left a meeting) — + // a plain focus() won't take the foreground from it. + app.focus({ steal: true }); } return {}; }, diff --git a/apps/x/apps/main/src/main.ts b/apps/x/apps/main/src/main.ts index cd4c18ba..1ec99a0a 100644 --- a/apps/x/apps/main/src/main.ts +++ b/apps/x/apps/main/src/main.ts @@ -538,6 +538,13 @@ app.whenReady().then(async () => { initMeetingDetection({ helperPath: path.join(__dirname, "mic-monitor"), onDetected: (meeting) => showMeetingPopup(meeting), + // Call ended while recording (meeting app released the mic) — the + // renderer stops capture and generates notes, same as a manual stop. + onExternalCallEnded: () => { + const win = mainWindow; + if (!win || win.isDestroyed() || win.webContents.isLoading()) return; + win.webContents.send("meeting:externalCallEnded", null); + }, }); // Start workspace watcher as a main-process service diff --git a/apps/x/apps/renderer/src/App.tsx b/apps/x/apps/renderer/src/App.tsx index 65a9c934..99791ba9 100644 --- a/apps/x/apps/renderer/src/App.tsx +++ b/apps/x/apps/renderer/src/App.tsx @@ -1116,6 +1116,16 @@ function App() { .catch(() => { /* tray may be unavailable */ }) }, [meetingTranscription.state]) + // Main detected the meeting app released the mic (call ended) — stop and + // generate notes, exactly like a manual stop. Listener only exists while + // recording, so a stale signal can never toggle a new recording ON. + useEffect(() => { + if (meetingTranscription.state !== 'recording') return + return window.ipc.on('meeting:externalCallEnded', () => { + handleToggleMeetingRef.current?.() + }) + }, [meetingTranscription.state]) + // Check if voice is available on mount and when OAuth state changes const refreshVoiceAvailability = useCallback(() => { Promise.all([ @@ -5664,6 +5674,14 @@ function App() { }) // Refresh the file view await handleVoiceNoteCreated(notePath) + // Notes are done — bring Rowboat to the foreground on the + // finished note (the post-call "redirect"). The notification + // below is background-only, so it only fires if the focus + // grab didn't take. + void window.ipc.invoke('app:focusMainWindow', null).catch(() => {}) + void window.ipc + .invoke('meeting:notifyNotesReady', { notePath, title: noteTitle }) + .catch(() => { /* notification is best-effort */ }) } } } catch (err) { diff --git a/apps/x/apps/renderer/src/components/settings-dialog.tsx b/apps/x/apps/renderer/src/components/settings-dialog.tsx index ce223cbd..f4daa12c 100644 --- a/apps/x/apps/renderer/src/components/settings-dialog.tsx +++ b/apps/x/apps/renderer/src/components/settings-dialog.tsx @@ -2149,7 +2149,7 @@ function CodeModeSettings({ dialogOpen }: { dialogOpen: boolean }) { // --- Notification Settings --- -type NotificationCategoryKey = "chat_completion" | "new_email" | "agent_permission" | "background_task" | "meeting_detection" +type NotificationCategoryKey = "chat_completion" | "new_email" | "agent_permission" | "background_task" | "meeting_detection" | "meeting_notes_ready" const NOTIFICATION_CATEGORIES: { key: NotificationCategoryKey; label: string; description: string }[] = [ { @@ -2177,6 +2177,11 @@ const NOTIFICATION_CATEGORIES: { key: NotificationCategoryKey; label: string; de label: "Meeting detection", description: "A popup offering to take notes when Rowboat notices you're in a call or meeting. Nothing records until you accept.", }, + { + key: "meeting_notes_ready", + label: "Meeting notes ready", + description: "When your meeting notes finish generating after a call. Click to open the note. Only shown while the app is in the background.", + }, ] function NotificationSettings({ dialogOpen }: { dialogOpen: boolean }) { diff --git a/apps/x/packages/core/src/meetings/detector.ts b/apps/x/packages/core/src/meetings/detector.ts index d3da04e3..36a82b1f 100644 --- a/apps/x/packages/core/src/meetings/detector.ts +++ b/apps/x/packages/core/src/meetings/detector.ts @@ -9,20 +9,26 @@ import { isNotificationCategoryEnabled } from "../config/notification_config.js" * Ambient meeting detection (Granola-style). * * Signal: the mic-monitor helper (apps/main/native/mic-monitor.swift) reports - * when ANY process starts using the microphone — with the owning PIDs on - * macOS 14.4+, so the call is attributed to the app that actually holds the - * mic (Chrome vs Zoom vs Slack), not just whatever meeting app happens to be - * running. When the mic has been in use continuously for a few seconds — and - * it isn't Rowboat's own capture — we label the popup ("Huddle detected" for - * Slack, "Call detected" for FaceTime/WhatsApp, "Meeting detected" - * otherwise), merge with a calendar event whose window covers now - * (start − 15 min through end), and emit a DetectedMeeting. + * when ANY process starts using the microphone — with the owning processes' + * bundle IDs and executable paths on macOS 14.4+, resolved natively in the + * helper. That matters twice over: attribution is exact (a Meet call in + * Chrome attributes to Chrome even while Slack/Zoom idle in the background), + * and the main process never has to shell out while capture is active + * (child-process spawns from Electron's main fail with EBADF during capture). * - * Never auto-records: the consumer (main process) shows a "Take Notes?" - * popup and waits for a click. + * When the mic has been in use continuously for a few seconds — and it isn't + * Rowboat's own capture — we label the popup ("Huddle detected" for Slack, + * "Call detected" for FaceTime/WhatsApp, "Meeting detected" otherwise), + * merge with a calendar event whose window covers now (start − 15 min + * through end), and emit a DetectedMeeting. Never auto-records: the consumer + * (main process) shows a "Take Notes?" popup and waits for a click. * * Fires at most once per continuous mic-in-use session; the session resets * after the mic has been idle for MIC_SESSION_RESET_MS. + * + * While Rowboat records, the same owner list powers call-end detection: once + * a meeting app has been seen on the mic, its absence for CALL_END_GRACE_MS + * means the call ended. */ const POLL_INTERVAL_MS = 2_000; @@ -34,6 +40,10 @@ const MIC_SESSION_RESET_MS = 30_000; // A calendar event merges with a detected call from 15 min before its start // (Granola merges ad-hoc calls within 15 minutes of a scheduled event). const CALENDAR_MERGE_LEAD_MS = 15 * 60_000; +// While recording, the meeting app must be off the mic this long before we +// call the meeting over. Kept short so notes arrive right after hang-up; +// only guards against sub-poll device churn, not full reconnects. +const CALL_END_GRACE_MS = 3_000; const CALENDAR_SYNC_DIR = path.join(WorkDir, "calendar_sync"); const HELPER_MAX_RESTARTS = 3; @@ -51,12 +61,47 @@ export interface DetectedMeeting { calendarEvent?: Record; } -// Matched against process names by case-insensitive prefix (Electron/Chromium -// apps capture through helper processes: "Google Chrome Helper (Renderer)", -// "Slack Helper"…). When mic-owner PIDs are available this is exact -// attribution; otherwise it's a running-app heuristic ordered by confidence: -// dedicated conferencing apps first, always-running chat apps after, browsers -// as the generic fallback. +interface PlatformMatch { + app: string; + kind: DetectedMeetingKind; +} + +// Bundle-ID prefixes (lowercased) → platform. The primary matcher: exact and +// stable, covers helper processes too (com.google.Chrome.helper etc.). +const BUNDLE_MATCHERS: Array<{ prefix: string; app: string; kind: DetectedMeetingKind }> = [ + { prefix: "us.zoom", app: "Zoom", kind: "meeting" }, + { prefix: "com.microsoft.teams", app: "Microsoft Teams", kind: "meeting" }, + { prefix: "com.cisco.webex", app: "Webex", kind: "meeting" }, + { prefix: "com.webex", app: "Webex", kind: "meeting" }, + { prefix: "com.tinyspeck.slackmacgap", app: "Slack", kind: "huddle" }, + { prefix: "com.apple.facetime", app: "FaceTime", kind: "call" }, + { prefix: "net.whatsapp", app: "WhatsApp", kind: "call" }, + { prefix: "com.hnc.discord", app: "Discord", kind: "call" }, + // Browsers — generic "Meeting detected". + { prefix: "com.google.chrome", app: "Google Chrome", kind: "meeting" }, + { prefix: "com.apple.safari", app: "Safari", kind: "meeting" }, + { prefix: "com.apple.webkit", app: "Safari", kind: "meeting" }, + { prefix: "org.mozilla.firefox", app: "Firefox", kind: "meeting" }, + { prefix: "com.brave.browser", app: "Brave Browser", kind: "meeting" }, + { prefix: "com.microsoft.edgemac", app: "Microsoft Edge", kind: "meeting" }, + { prefix: "company.thebrowser", app: "Arc", kind: "meeting" }, +]; + +// Rowboat's own capture (and the dev Electron shell) — never a "meeting". +const SELF_BUNDLE_PREFIXES = ["com.rowboat", "com.github.electron"]; + +const BROWSER_APPS = new Set([ + "Google Chrome", + "Safari", + "Firefox", + "Brave Browser", + "Microsoft Edge", + "Arc", +]); + +// Process-name fallbacks (case-insensitive prefix on the executable +// basename): used when an owner has no bundle ID, and by the pre-14.4 +// running-app heuristic. Ordered by confidence for the heuristic. const MEETING_APPS: Array<{ proc: string; app: string; kind: DetectedMeetingKind }> = [ { proc: "zoom.us", app: "Zoom", kind: "meeting" }, { proc: "MSTeams", app: "Microsoft Teams", kind: "meeting" }, @@ -85,21 +130,38 @@ const KIND_TITLES: Record = { meeting: "Meeting detected", }; +interface MicOwner { + pid: number; + bundleId: string; + path: string; +} + interface DetectorOptions { /** Absolute path to the compiled mic-monitor helper binary. */ helperPath: string; onDetected: (meeting: DetectedMeeting) => void; + /** + * Fired once per recording session when the meeting app that was on the + * mic has released it for CALL_END_GRACE_MS while Rowboat is still + * capturing — i.e. the call ended. Needs per-process attribution + * (macOS 14.4+); silently unavailable otherwise. + */ + onExternalCallEnded?: () => void; } let started = false; let selfCaptureActive = false; let micInUse = false; -// PIDs currently capturing from the mic (macOS 14.4+; empty = unknown). -let micPids: number[] = []; +// Processes currently capturing from the mic (macOS 14.4+; empty = unknown). +let micOwners: MicOwner[] = []; let micInUseSince: number | null = null; let micIdleSince: number | null = null; let sessionNotified = false; let helperRestarts = 0; +// Call-end tracking for the current self-capture session. +let externalAppSeen = false; +let externalAbsentSince: number | null = null; +let callEndFired = false; /** * Rowboat's own capture (meeting recording, assistant voice/video call) also @@ -108,6 +170,10 @@ let helperRestarts = 0; */ export function setSelfCaptureActive(active: boolean): void { selfCaptureActive = active; + // Fresh capture session — re-arm call-end tracking. + externalAppSeen = false; + externalAbsentSince = null; + callEndFired = false; if (active) { // Whatever mic session is in flight is ours — don't prompt when the // user stops and the mic lingers. @@ -131,6 +197,7 @@ export function init(options: DetectorOptions): void { setInterval(() => { try { tick(options.onDetected); + checkExternalCallEnd(options.onExternalCallEnded); } catch (err) { console.error("[MeetingDetect] tick failed:", err); } @@ -159,8 +226,13 @@ function spawnHelper(helperPath: string): void { const parsed = JSON.parse(line); if (typeof parsed?.micInUse === "boolean") { micInUse = parsed.micInUse; - micPids = Array.isArray(parsed.pids) - ? parsed.pids.filter((p: unknown): p is number => typeof p === "number") + micOwners = Array.isArray(parsed.owners) + ? parsed.owners.filter( + (o: unknown): o is MicOwner => + typeof (o as MicOwner)?.pid === "number" && + typeof (o as MicOwner)?.bundleId === "string" && + typeof (o as MicOwner)?.path === "string", + ) : []; } } catch { @@ -171,7 +243,7 @@ function spawnHelper(helperPath: string): void { child.on("exit", (code) => { micInUse = false; - micPids = []; + micOwners = []; if (helperRestarts < HELPER_MAX_RESTARTS) { helperRestarts += 1; const delay = 5_000 * helperRestarts; @@ -211,18 +283,66 @@ function tick(onDetected: DetectorOptions["onDetected"]): void { } if (now - micInUseSince < MIC_DEBOUNCE_MS) return; - // Claim the session before the async work — a slow ps/calendar scan must + // Claim the session before the async work — a slow calendar scan must // not let a second tick double-fire. sessionNotified = true; void detect(onDetected); } +/** + * While Rowboat is recording, watch whether any known meeting app/browser + * still owns the mic. Once one has been seen, its absence for + * CALL_END_GRACE_MS means the call ended — fire once per session. Fully + * synchronous: reads the helper-provided owner list, spawns nothing. + */ +function checkExternalCallEnd(onExternalCallEnded?: () => void): void { + if (!onExternalCallEnded) return; + if (!selfCaptureActive || callEndFired) return; + // No attribution data (pre-14.4 macOS) — feature unavailable. + if (micOwners.length === 0) return; + + const now = Date.now(); + const externalOnMic = micOwners.some((owner) => { + const match = matchOwner(owner); + return match !== null && match !== "self"; + }); + if (externalOnMic) { + if (!externalAppSeen) { + console.log( + `[MeetingDetect] call-end watch armed — mic owners: ${describeOwners(micOwners)}`, + ); + } + externalAppSeen = true; + externalAbsentSince = null; + return; + } + if (!externalAppSeen) return; + if (externalAbsentSince === null) { + externalAbsentSince = now; + console.log( + `[MeetingDetect] meeting app off the mic — remaining owners: ${describeOwners(micOwners)}`, + ); + return; + } + if (now - externalAbsentSince >= CALL_END_GRACE_MS) { + callEndFired = true; + console.log("[MeetingDetect] meeting app released the mic — call likely ended"); + onExternalCallEnded(); + } +} + +function describeOwners(owners: MicOwner[]): string { + return owners + .map((o) => o.bundleId || path.basename(o.path) || `pid ${o.pid}`) + .join(", "); +} + async function detect(onDetected: DetectorOptions["onDetected"]): Promise { if (!isNotificationCategoryEnabled("meeting_detection")) return; const source = await findLikelyMeetingApp(); if (!source) { - // Mic in use but nothing call-capable is running (dictation, voice + // Mic in use but nothing call-capable owns it (dictation, voice // memo, unknown recorder) — stay quiet to avoid noise. return; } @@ -246,14 +366,33 @@ async function detect(onDetected: DetectorOptions["onDetected"]): Promise onDetected(meeting); } -function defaultNoteTitle(source: { app: string; kind: DetectedMeetingKind }): string { +function defaultNoteTitle(source: PlatformMatch): string { if (source.kind === "huddle") return `${source.app} huddle`; if (source.kind === "call") return `${source.app} call`; - return BROWSERS.includes(source.app) ? "Meeting" : `${source.app} meeting`; + return BROWSER_APPS.has(source.app) || BROWSERS.includes(source.app) + ? "Meeting" + : `${source.app} meeting`; +} + +/** Match a mic owner to a platform (or to Rowboat itself). */ +function matchOwner(owner: MicOwner): PlatformMatch | "self" | null { + const bundle = owner.bundleId.toLowerCase(); + if (bundle) { + for (const prefix of SELF_BUNDLE_PREFIXES) { + if (bundle.startsWith(prefix)) return "self"; + } + for (const matcher of BUNDLE_MATCHERS) { + if (bundle.startsWith(matcher.prefix)) { + return { app: matcher.app, kind: matcher.kind }; + } + } + } + const basename = path.basename(owner.path); + return basename ? matchProcessName(basename) : null; } /** Match one process name to a platform, by case-insensitive prefix. */ -function matchProcessName(name: string): { app: string; kind: DetectedMeetingKind } | null { +function matchProcessName(name: string): PlatformMatch | null { const lower = name.toLowerCase(); // Safari captures through WebKit's out-of-process media stack. if (lower.startsWith("com.apple.webkit")) return { app: "Safari", kind: "meeting" }; @@ -271,24 +410,20 @@ function matchProcessName(name: string): { app: string; kind: DetectedMeetingKin } /** - * Attribute the call to an app. Exact when mic-owner PIDs are known: resolve - * their process names and match those (a Meet call in Chrome attributes to - * Chrome even while Slack/Zoom idle in the background). If owners are known - * but none is call-capable (voice memo, dictation, screen recorder), that's - * NOT a meeting — stay quiet. Only without PID info (pre-14.4 macOS) fall - * back to the running-app heuristic. + * Attribute the call to an app. Exact when mic owners are known (bundle IDs + * from the helper, no child processes involved). Owners known but none + * call-capable (voice memo, dictation, screen recorder) → NOT a meeting, + * stay quiet. Only without attribution (pre-14.4 macOS) fall back to the + * running-app heuristic. */ -async function findLikelyMeetingApp(): Promise<{ app: string; kind: DetectedMeetingKind } | null> { - if (micPids.length > 0) { - const owners = await processNamesForPids(micPids); - if (owners.length > 0) { - // Prefer dedicated apps over browsers when several own the mic. - const matches = owners - .map(matchProcessName) - .filter((m): m is NonNullable => m !== null); - const dedicated = matches.find((m) => !BROWSERS.includes(m.app)); - return dedicated ?? matches[0] ?? null; - } +async function findLikelyMeetingApp(): Promise { + if (micOwners.length > 0) { + const matches = micOwners + .map(matchOwner) + .filter((m): m is PlatformMatch => m !== null && m !== "self"); + // Prefer dedicated apps over browsers when several own the mic. + const dedicated = matches.find((m) => !BROWSER_APPS.has(m.app)); + return dedicated ?? matches[0] ?? null; } // No attribution available — running-app heuristic in table order @@ -311,30 +446,6 @@ async function findLikelyMeetingApp(): Promise<{ app: string; kind: DetectedMeet return null; } -function processNamesForPids(pids: number[]): Promise { - return new Promise((resolve) => { - execFile( - "ps", - ["-o", "comm=", "-p", pids.join(",")], - { maxBuffer: 1024 * 1024 }, - (err, stdout) => { - if (err) { - resolve([]); - return; - } - const names: string[] = []; - for (const line of stdout.split("\n")) { - const trimmed = line.trim(); - if (!trimmed) continue; - // GUI apps list as full executable paths — use the basename. - names.push(path.basename(trimmed)); - } - resolve(names); - }, - ); - }); -} - function runningProcessNames(): Promise | null> { return new Promise((resolve) => { execFile("ps", ["-axo", "comm="], { maxBuffer: 4 * 1024 * 1024 }, (err, stdout) => { diff --git a/apps/x/packages/shared/src/ipc.ts b/apps/x/packages/shared/src/ipc.ts index c6452399..5d7c8a31 100644 --- a/apps/x/packages/shared/src/ipc.ts +++ b/apps/x/packages/shared/src/ipc.ts @@ -786,6 +786,23 @@ const ipcSchemas = { success: z.literal(true), }), }, + // Renderer → main: meeting notes finished generating — fire the "notes + // ready" notification (background only; click opens the note). + 'meeting:notifyNotesReady': { + req: z.object({ + notePath: z.string(), + title: z.string(), + }), + res: z.object({ + success: z.literal(true), + }), + }, + // Main → renderer: the meeting app released the microphone while a + // recording was running — the call likely ended, auto-stop and summarize. + 'meeting:externalCallEnded': { + req: z.null(), + res: z.null(), + }, // Renderer → main: assistant voice/video call holds the mic — suppresses // ambient meeting detection (it would otherwise see our own capture). 'voice:setCallActive': { diff --git a/apps/x/packages/shared/src/notification-settings.ts b/apps/x/packages/shared/src/notification-settings.ts index 3a245bf2..1f31d194 100644 --- a/apps/x/packages/shared/src/notification-settings.ts +++ b/apps/x/packages/shared/src/notification-settings.ts @@ -8,6 +8,7 @@ import { z } from 'zod'; * - agent_permission: an agent is requesting permission to run a tool * - background_task: a background task agent pinged via the notify-user tool * - meeting_detection: popup when Rowboat detects you're in a call/meeting + * - meeting_notes_ready: meeting notes finished generating after a call */ export const NotificationCategorySchema = z.enum([ 'chat_completion', @@ -15,6 +16,7 @@ export const NotificationCategorySchema = z.enum([ 'agent_permission', 'background_task', 'meeting_detection', + 'meeting_notes_ready', ]); export const NotificationCategoriesSchema = z.object({ @@ -23,6 +25,7 @@ export const NotificationCategoriesSchema = z.object({ agent_permission: z.boolean(), background_task: z.boolean(), meeting_detection: z.boolean(), + meeting_notes_ready: z.boolean(), }); export const NotificationSettingsSchema = z.object({ @@ -36,6 +39,7 @@ export const DEFAULT_NOTIFICATION_SETTINGS: NotificationSettings = { agent_permission: true, background_task: true, meeting_detection: true, + meeting_notes_ready: true, }, };