Skip to content

Commit 35bfe27

Browse files
fix(desktop): restore meeting auto-stop fallback (#5439)
* fix(desktop): restore meeting auto-stop fallback Stop recordings when trigger apps disappear even if micStopped reports a helper process, while keeping active-trigger false positive protection. * fix(desktop): preserve auto-stop confidence Keep direct trigger-stop timers from being downgraded by later helper-only micStopped events, and cover the failed mic snapshot regression.
1 parent ff54cda commit 35bfe27

2 files changed

Lines changed: 295 additions & 76 deletions

File tree

apps/desktop/src/stt/contexts.test.tsx

Lines changed: 222 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -273,6 +273,161 @@ describe("ListenerProvider detect events", () => {
273273
expect(stopSpy).not.toHaveBeenCalled();
274274
});
275275

276+
test("does not stop after non-trigger MicStopped when a trigger app is still active", async () => {
277+
const store = createListenerStore();
278+
const stopSpy = vi.fn();
279+
280+
store.setState({ stop: stopSpy });
281+
store.getState().setTriggerAppIds(["us.zoom.xos"]);
282+
setStoreActive(store);
283+
listMicUsingApplicationsMock.mockResolvedValue({
284+
status: "ok",
285+
data: [{ id: "us.zoom.xos", name: "Zoom" }],
286+
});
287+
288+
render(
289+
<ListenerProvider store={store}>
290+
<div>child</div>
291+
</ListenerProvider>,
292+
);
293+
294+
await vi.waitFor(() => expect(listenMock).toHaveBeenCalledTimes(1));
295+
296+
const handler = listenMock.mock.calls[0]?.[0];
297+
expect(handler).toBeTypeOf("function");
298+
299+
vi.useFakeTimers();
300+
listMicUsingApplicationsMock.mockClear();
301+
302+
handler({
303+
payload: {
304+
type: "micStopped",
305+
apps: [{ id: "/opt/homebrew/bin/ffmpeg", name: "ffmpeg" }],
306+
},
307+
});
308+
309+
await vi.advanceTimersByTimeAsync(AUTO_STOP_CONFIRM_DELAY_MS);
310+
311+
expect(listMicUsingApplicationsMock).toHaveBeenCalledTimes(1);
312+
expect(stopSpy).not.toHaveBeenCalled();
313+
});
314+
315+
test("auto-stops when MicStopped omits the trigger app and no trigger app remains active (regression: #5436)", async () => {
316+
const store = createListenerStore();
317+
const stopSpy = vi.fn();
318+
319+
store.setState({ stop: stopSpy });
320+
store.getState().setTriggerAppIds(["com.microsoft.teams2"]);
321+
setStoreActive(store);
322+
323+
render(
324+
<ListenerProvider store={store}>
325+
<div>child</div>
326+
</ListenerProvider>,
327+
);
328+
329+
await vi.waitFor(() => expect(listenMock).toHaveBeenCalledTimes(1));
330+
331+
const handler = listenMock.mock.calls[0]?.[0];
332+
expect(handler).toBeTypeOf("function");
333+
334+
vi.useFakeTimers();
335+
listMicUsingApplicationsMock.mockClear();
336+
337+
handler({
338+
payload: {
339+
type: "micStopped",
340+
apps: [{ id: "pid:42", name: "Microsoft Teams Helper" }],
341+
},
342+
});
343+
344+
await vi.advanceTimersByTimeAsync(AUTO_STOP_CONFIRM_DELAY_MS);
345+
346+
expect(listMicUsingApplicationsMock).toHaveBeenCalledTimes(1);
347+
expect(stopSpy).toHaveBeenCalledTimes(1);
348+
});
349+
350+
test("auto-stops Teams running in a browser when the browser no longer uses the mic (regression: #5436)", async () => {
351+
const store = createListenerStore();
352+
const stopSpy = vi.fn();
353+
354+
store.setState({ stop: stopSpy });
355+
store.getState().setTriggerAppIds(["company.thebrowser.Browser"]);
356+
setStoreActive(store);
357+
358+
render(
359+
<ListenerProvider store={store}>
360+
<div>child</div>
361+
</ListenerProvider>,
362+
);
363+
364+
await vi.waitFor(() => expect(listenMock).toHaveBeenCalledTimes(1));
365+
366+
const handler = listenMock.mock.calls[0]?.[0];
367+
expect(handler).toBeTypeOf("function");
368+
369+
vi.useFakeTimers();
370+
listMicUsingApplicationsMock.mockClear();
371+
372+
handler({
373+
payload: {
374+
type: "micStopped",
375+
apps: [{ id: "company.thebrowser.Browser", name: "Arc" }],
376+
},
377+
});
378+
379+
await vi.advanceTimersByTimeAsync(AUTO_STOP_CONFIRM_DELAY_MS);
380+
381+
expect(listMicUsingApplicationsMock).toHaveBeenCalledTimes(1);
382+
expect(stopSpy).toHaveBeenCalledTimes(1);
383+
});
384+
385+
test("keeps direct trigger auto-stop confidence when a later helper stop arrives", async () => {
386+
const store = createListenerStore();
387+
const stopSpy = vi.fn();
388+
389+
store.setState({ stop: stopSpy });
390+
store.getState().setTriggerAppIds(["us.zoom.xos"]);
391+
setStoreActive(store);
392+
listMicUsingApplicationsMock.mockResolvedValue({
393+
status: "error",
394+
error: "failed to read mic snapshot",
395+
});
396+
397+
render(
398+
<ListenerProvider store={store}>
399+
<div>child</div>
400+
</ListenerProvider>,
401+
);
402+
403+
await vi.waitFor(() => expect(listenMock).toHaveBeenCalledTimes(1));
404+
405+
const handler = listenMock.mock.calls[0]?.[0];
406+
expect(handler).toBeTypeOf("function");
407+
408+
vi.useFakeTimers();
409+
listMicUsingApplicationsMock.mockClear();
410+
411+
handler({
412+
payload: {
413+
type: "micStopped",
414+
apps: [{ id: "us.zoom.xos", name: "Zoom" }],
415+
},
416+
});
417+
418+
handler({
419+
payload: {
420+
type: "micStopped",
421+
apps: [{ id: "pid:42", name: "Zoom Helper" }],
422+
},
423+
});
424+
425+
await vi.advanceTimersByTimeAsync(AUTO_STOP_CONFIRM_DELAY_MS);
426+
427+
expect(listMicUsingApplicationsMock).toHaveBeenCalledTimes(1);
428+
expect(stopSpy).toHaveBeenCalledTimes(1);
429+
});
430+
276431
test("passes ignorable app ids and footer metadata through mic-detected notifications", async () => {
277432
const store = createListenerStore();
278433

@@ -466,67 +621,73 @@ describe("ListenerProvider detect events", () => {
466621
expect(stopSpy).toHaveBeenCalledTimes(1);
467622
});
468623

469-
test("asks before stopping when a browser meeting stops well before the scheduled end", async () => {
470-
const store = createListenerStore();
471-
const stopSpy = vi.fn();
472-
const now = new Date("2026-05-19T10:05:00.000Z");
473-
474-
store.setState({ stop: stopSpy });
475-
store.getState().setTriggerAppIds(["com.google.Chrome"]);
476-
setStoreActive(store);
477-
(useStoreMock as any).mockReturnValue(
478-
mockSessionEventStore({
479-
started_at: "2026-05-19T10:00:00.000Z",
480-
ended_at: "2026-05-19T10:30:00.000Z",
481-
}),
482-
);
483-
484-
render(
485-
<ListenerProvider store={store}>
486-
<div>child</div>
487-
</ListenerProvider>,
488-
);
489-
490-
await vi.waitFor(() => expect(listenMock).toHaveBeenCalledTimes(1));
491-
492-
const handler = listenMock.mock.calls[0]?.[0];
493-
expect(handler).toBeTypeOf("function");
494-
495-
vi.useFakeTimers();
496-
vi.setSystemTime(now);
497-
listMicUsingApplicationsMock.mockClear();
498-
499-
handler({
500-
payload: {
501-
type: "micStopped",
502-
apps: [{ id: "com.google.Chrome", name: "Google Chrome" }],
503-
},
504-
});
505-
506-
await vi.advanceTimersByTimeAsync(AUTO_STOP_CONFIRM_DELAY_MS);
507-
508-
expect(listMicUsingApplicationsMock).toHaveBeenCalledTimes(1);
509-
expect(stopSpy).not.toHaveBeenCalled();
510-
const notification = showNotificationMock.mock.calls[0]?.[0];
511-
expect(parseAutoStopEndedNotificationKey(notification.key)).toBe(
512-
"session-1",
513-
);
514-
expect(notification).toEqual({
515-
key: expect.stringContaining("auto-stop-ended:session-1"),
516-
title: "Did your meeting end?",
517-
message:
518-
"Google Chrome stopped using the microphone before the scheduled end time.",
519-
timeout: { secs: 60, nanos: 0 },
520-
source: null,
521-
start_time: null,
522-
participants: null,
523-
event_details: null,
524-
action_label: "Stop recording",
525-
options: null,
526-
footer: null,
527-
icon: { type: "bundle_id", bundle_id: "com.google.Chrome" },
528-
});
529-
});
624+
test.each([
625+
{ id: "com.google.Chrome", name: "Google Chrome" },
626+
{ id: "at.studio.AsideBrowser", name: "Aside" },
627+
{ id: "net.imput.helium", name: "Helium" },
628+
])(
629+
"asks before stopping when $name stops well before the scheduled end",
630+
async (browser) => {
631+
const store = createListenerStore();
632+
const stopSpy = vi.fn();
633+
const now = new Date("2026-05-19T10:05:00.000Z");
634+
635+
store.setState({ stop: stopSpy });
636+
store.getState().setTriggerAppIds([browser.id]);
637+
setStoreActive(store);
638+
(useStoreMock as any).mockReturnValue(
639+
mockSessionEventStore({
640+
started_at: "2026-05-19T10:00:00.000Z",
641+
ended_at: "2026-05-19T10:30:00.000Z",
642+
}),
643+
);
644+
645+
render(
646+
<ListenerProvider store={store}>
647+
<div>child</div>
648+
</ListenerProvider>,
649+
);
650+
651+
await vi.waitFor(() => expect(listenMock).toHaveBeenCalledTimes(1));
652+
653+
const handler = listenMock.mock.calls[0]?.[0];
654+
expect(handler).toBeTypeOf("function");
655+
656+
vi.useFakeTimers();
657+
vi.setSystemTime(now);
658+
listMicUsingApplicationsMock.mockClear();
659+
660+
handler({
661+
payload: {
662+
type: "micStopped",
663+
apps: [browser],
664+
},
665+
});
666+
667+
await vi.advanceTimersByTimeAsync(AUTO_STOP_CONFIRM_DELAY_MS);
668+
669+
expect(listMicUsingApplicationsMock).toHaveBeenCalledTimes(1);
670+
expect(stopSpy).not.toHaveBeenCalled();
671+
const notification = showNotificationMock.mock.calls[0]?.[0];
672+
expect(parseAutoStopEndedNotificationKey(notification.key)).toBe(
673+
"session-1",
674+
);
675+
expect(notification).toEqual({
676+
key: expect.stringContaining("auto-stop-ended:session-1"),
677+
title: "Did your meeting end?",
678+
message: `${browser.name} stopped using the microphone before the scheduled end time.`,
679+
timeout: { secs: 60, nanos: 0 },
680+
source: null,
681+
start_time: null,
682+
participants: null,
683+
event_details: null,
684+
action_label: "Stop recording",
685+
options: null,
686+
footer: null,
687+
icon: { type: "bundle_id", bundle_id: browser.id },
688+
});
689+
},
690+
);
530691

531692
test("auto-stops browser meetings inside the scheduled end window", async () => {
532693
const store = createListenerStore();

0 commit comments

Comments
 (0)