mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-07-21 21:31:12 +02:00
feat(x): auto-stop on call end + redirect to finished notes (Granola parity phase 3)
- mic-monitor now resolves each mic owner's bundle ID (CoreAudio) and executable path (libproc) natively — the main process never shells out during capture (child-process spawns fail with EBADF while Electron holds a capture session; this bit the ps-based first cut) - detector matches owners by bundle-ID prefix (com.google.chrome, us.zoom, com.tinyspeck.slackmacgap, …) with executable-name fallback, and recognizes Rowboat's own capture (com.rowboat / dev electron) - call-end detection: once a meeting app has been seen on the mic during a recording, its absence for 3s means the call ended → auto-stop → summarize — same flow as a manual stop. Arms only when a meeting app was actually observed, so in-person recordings never auto-stop - post-summary redirect: Rowboat foregrounds itself on the finished note (app:focusMainWindow now steals focus); the new meeting_notes_ready notification stays as a background-only fallback with a toggle in Settings > Notifications
This commit is contained in:
parent
66865b4f6b
commit
95f232cca9
8 changed files with 320 additions and 84 deletions
|
|
@ -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<CFString>? = nil
|
||||
var size = UInt32(MemoryLayout<Unmanaged<CFString>?>.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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {};
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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 }) {
|
||||
|
|
|
|||
|
|
@ -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<string, unknown>;
|
||||
}
|
||||
|
||||
// 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<DetectedMeetingKind, string> = {
|
|||
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<void> {
|
||||
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<void>
|
|||
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<typeof m> => m !== null);
|
||||
const dedicated = matches.find((m) => !BROWSERS.includes(m.app));
|
||||
return dedicated ?? matches[0] ?? null;
|
||||
}
|
||||
async function findLikelyMeetingApp(): Promise<PlatformMatch | null> {
|
||||
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<string[]> {
|
||||
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<Set<string> | null> {
|
||||
return new Promise((resolve) => {
|
||||
execFile("ps", ["-axo", "comm="], { maxBuffer: 4 * 1024 * 1024 }, (err, stdout) => {
|
||||
|
|
|
|||
|
|
@ -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': {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
};
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue