diff --git a/src/main/browser/browser-client-page-profile-binding.test.ts b/src/main/browser/browser-client-page-profile-binding.test.ts index 56f10481b6c..fc0c1ff140d 100644 --- a/src/main/browser/browser-client-page-profile-binding.test.ts +++ b/src/main/browser/browser-client-page-profile-binding.test.ts @@ -72,6 +72,7 @@ function createGuest(id: number) { return { id, isDestroyed: vi.fn(() => false), + isCurrentlyAudible: vi.fn(() => false), getType: vi.fn(() => 'webview'), setBackgroundThrottling: browserMocks.guestSetBackgroundThrottlingMock, setWindowOpenHandler: browserMocks.guestSetWindowOpenHandlerMock, diff --git a/src/main/browser/browser-manager-auth-user-agent.test.ts b/src/main/browser/browser-manager-auth-user-agent.test.ts index 14a283d1a35..37fee8641d0 100644 --- a/src/main/browser/browser-manager-auth-user-agent.test.ts +++ b/src/main/browser/browser-manager-auth-user-agent.test.ts @@ -93,6 +93,8 @@ describe('browserManager', () => { const guest = { id: 408, isDestroyed: vi.fn(() => false), + isCurrentlyAudible: vi.fn(() => false), + send: vi.fn(), getType: vi.fn(() => 'webview'), setBackgroundThrottling: guestSetBackgroundThrottlingMock, setWindowOpenHandler: guestSetWindowOpenHandlerMock, @@ -158,6 +160,8 @@ describe('browserManager', () => { const guest = { id: 409, isDestroyed: vi.fn(() => false), + isCurrentlyAudible: vi.fn(() => false), + send: vi.fn(), getType: vi.fn(() => 'webview'), setBackgroundThrottling: guestSetBackgroundThrottlingMock, setWindowOpenHandler: guestSetWindowOpenHandlerMock, @@ -208,6 +212,8 @@ describe('browserManager', () => { const guest = { id: 418, isDestroyed: vi.fn(() => false), + isCurrentlyAudible: vi.fn(() => false), + send: vi.fn(), getType: vi.fn(() => 'webview'), setBackgroundThrottling: guestSetBackgroundThrottlingMock, setWindowOpenHandler: guestSetWindowOpenHandlerMock, @@ -343,6 +349,8 @@ describe('browserManager', () => { const guest = { id: 419, isDestroyed: vi.fn(() => false), + isCurrentlyAudible: vi.fn(() => false), + send: vi.fn(), getType: vi.fn(() => 'webview'), setBackgroundThrottling: guestSetBackgroundThrottlingMock, setWindowOpenHandler: guestSetWindowOpenHandlerMock, @@ -386,6 +394,8 @@ describe('browserManager', () => { const guest = { id: 420, isDestroyed: vi.fn(() => false), + isCurrentlyAudible: vi.fn(() => false), + send: vi.fn(), getType: vi.fn(() => 'webview'), setBackgroundThrottling: guestSetBackgroundThrottlingMock, setWindowOpenHandler: guestSetWindowOpenHandlerMock, diff --git a/src/main/browser/browser-manager-client-hosted-downloads.test.ts b/src/main/browser/browser-manager-client-hosted-downloads.test.ts index b2b7ce54956..2b13b17588f 100644 --- a/src/main/browser/browser-manager-client-hosted-downloads.test.ts +++ b/src/main/browser/browser-manager-client-hosted-downloads.test.ts @@ -318,6 +318,7 @@ function createGuest(id: number) { return { id, isDestroyed: vi.fn(() => false), + isCurrentlyAudible: vi.fn(() => false), getType: vi.fn(() => 'webview'), setBackgroundThrottling: browserMocks.guestSetBackgroundThrottlingMock, setWindowOpenHandler: browserMocks.guestSetWindowOpenHandlerMock, diff --git a/src/main/browser/browser-manager-destroyed-guest-test-fixture.ts b/src/main/browser/browser-manager-destroyed-guest-test-fixture.ts index 3542a6c8b7e..64253c3925f 100644 --- a/src/main/browser/browser-manager-destroyed-guest-test-fixture.ts +++ b/src/main/browser/browser-manager-destroyed-guest-test-fixture.ts @@ -61,6 +61,9 @@ export class DestroyedGuestTestContents extends EventEmitter { setUserAgent(): void {} setWindowOpenHandler(): void {} setBackgroundThrottling(): void {} + isCurrentlyAudible(): boolean { + return false + } destroy(): void { this.destroyed = true diff --git a/src/main/browser/browser-manager-downloads.test.ts b/src/main/browser/browser-manager-downloads.test.ts index 3315c5e2493..61868bfa489 100644 --- a/src/main/browser/browser-manager-downloads.test.ts +++ b/src/main/browser/browser-manager-downloads.test.ts @@ -74,6 +74,7 @@ describe('browserManager', () => { const guest = { id: 407, isDestroyed: vi.fn(() => false), + isCurrentlyAudible: vi.fn(() => false), getType: vi.fn(() => 'webview'), setBackgroundThrottling: guestSetBackgroundThrottlingMock, setWindowOpenHandler: guestSetWindowOpenHandlerMock, @@ -154,6 +155,7 @@ describe('browserManager', () => { const guest = { id: 408, isDestroyed: vi.fn(() => false), + isCurrentlyAudible: vi.fn(() => false), getType: vi.fn(() => 'webview'), setBackgroundThrottling: guestSetBackgroundThrottlingMock, setWindowOpenHandler: guestSetWindowOpenHandlerMock, @@ -224,6 +226,7 @@ describe('browserManager', () => { const guest = { id: 409, isDestroyed: vi.fn(() => false), + isCurrentlyAudible: vi.fn(() => false), getType: vi.fn(() => 'webview'), setBackgroundThrottling: guestSetBackgroundThrottlingMock, setWindowOpenHandler: guestSetWindowOpenHandlerMock, @@ -256,16 +259,19 @@ describe('browserManager', () => { rendererWebContentsId }) - expect(rendererSendMock.mock.calls.map(([channel]) => channel)).toEqual([ - 'browser:download-requested', - 'browser:download-finished' - ]) + // Registration also reports audio state; only download sends matter here. + expect( + rendererSendMock.mock.calls + .map(([channel]) => channel) + .filter((channel) => channel.startsWith('browser:download-')) + ).toEqual(['browser:download-requested', 'browser:download-finished']) }) it('drops pending download records when an unregistered guest is destroyed', () => { const guest = { id: 412, isDestroyed: vi.fn(() => false), + isCurrentlyAudible: vi.fn(() => false), getType: vi.fn(() => 'webview'), setBackgroundThrottling: guestSetBackgroundThrottlingMock, setWindowOpenHandler: guestSetWindowOpenHandlerMock, @@ -296,6 +302,7 @@ describe('browserManager', () => { const guest = { id: 410, isDestroyed: vi.fn(() => false), + isCurrentlyAudible: vi.fn(() => false), getType: vi.fn(() => 'webview'), setBackgroundThrottling: guestSetBackgroundThrottlingMock, setWindowOpenHandler: guestSetWindowOpenHandlerMock, @@ -340,6 +347,7 @@ describe('browserManager', () => { const guest = { id: 411, isDestroyed: vi.fn(() => false), + isCurrentlyAudible: vi.fn(() => false), getType: vi.fn(() => 'webview'), setBackgroundThrottling: guestSetBackgroundThrottlingMock, setWindowOpenHandler: guestSetWindowOpenHandlerMock, @@ -371,10 +379,12 @@ describe('browserManager', () => { browserManager.handleGuestWillDownload({ guestWebContentsId: guest.id, item }) expect(item.cancel).toHaveBeenCalledTimes(1) - expect(rendererSendMock.mock.calls.map(([channel]) => channel)).toEqual([ - 'browser:download-requested', - 'browser:download-finished' - ]) + // Registration also reports audio state; only download sends matter here. + expect( + rendererSendMock.mock.calls + .map(([channel]) => channel) + .filter((channel) => channel.startsWith('browser:download-')) + ).toEqual(['browser:download-requested', 'browser:download-finished']) expect(rendererSendMock).toHaveBeenCalledWith( 'browser:download-finished', expect.objectContaining({ @@ -393,6 +403,7 @@ describe('browserManager', () => { const guest = { id: 420, isDestroyed: () => false, + isCurrentlyAudible: () => false, getType: () => 'webview', on: guestOnMock, off: guestOffMock, @@ -413,10 +424,12 @@ describe('browserManager', () => { getDownloadItemEventHandler(item, 'on', 'updated')?.(event, 'interrupted') await new Promise((resolve) => setImmediate(resolve)) expect(item.cancel).toHaveBeenCalledTimes(1) - expect(rendererSendMock.mock.calls.map(([channel]) => channel)).toEqual([ - 'browser:download-requested', - 'browser:download-finished' - ]) + // Registration also reports audio state; only download sends matter here. + expect( + rendererSendMock.mock.calls + .map(([channel]) => channel) + .filter((channel) => channel.startsWith('browser:download-')) + ).toEqual(['browser:download-requested', 'browser:download-finished']) expect(rendererSendMock).toHaveBeenCalledWith( 'browser:download-finished', expect.objectContaining({ status: 'failed', error: 'Download was interrupted.' }) diff --git a/src/main/browser/browser-manager-grab-shortcuts.test.ts b/src/main/browser/browser-manager-grab-shortcuts.test.ts index 53a69d24fee..42097a1dc94 100644 --- a/src/main/browser/browser-manager-grab-shortcuts.test.ts +++ b/src/main/browser/browser-manager-grab-shortcuts.test.ts @@ -196,6 +196,8 @@ describe('browserManager grab operations', () => { webContentsId: GRAB_GUEST_WEB_CONTENTS_ID, rendererWebContentsId: GRAB_RENDERER_WEB_CONTENTS_ID }) + // Registration also reports audio state; this test is about the chord. + rendererSendMock.mockClear() const preventDefault = pressAnnotateChord() diff --git a/src/main/browser/browser-manager-grab-test-harness.ts b/src/main/browser/browser-manager-grab-test-harness.ts index 3eb985261b4..008fff89a71 100644 --- a/src/main/browser/browser-manager-grab-test-harness.ts +++ b/src/main/browser/browser-manager-grab-test-harness.ts @@ -22,9 +22,11 @@ export type GrabTestMocks = { } export function makeGuest(id: number, mocks: GrabTestMocks): Electron.WebContents { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: grab tests drive only the guest members listed here. return { id, isDestroyed: mocks.guestIsDestroyedMock, + isCurrentlyAudible: vi.fn(() => false), getType: vi.fn(() => 'webview'), setBackgroundThrottling: mocks.guestSetBackgroundThrottlingMock, setWindowOpenHandler: mocks.guestSetWindowOpenHandlerMock, @@ -75,5 +77,7 @@ export function resetGrabTestEnvironment(mocks: GrabTestMocks): Electron.WebCont webContentsId: GRAB_GUEST_WEB_CONTENTS_ID, rendererWebContentsId: GRAB_RENDERER_WEB_CONTENTS_ID }) + // Registration also reports audio state; grab tests count only grab-driven sends. + mocks.rendererSendMock.mockClear() return guest } diff --git a/src/main/browser/browser-manager-guest-lifecycle.test.ts b/src/main/browser/browser-manager-guest-lifecycle.test.ts index 040b056aafa..8e1b9bea77f 100644 --- a/src/main/browser/browser-manager-guest-lifecycle.test.ts +++ b/src/main/browser/browser-manager-guest-lifecycle.test.ts @@ -95,6 +95,8 @@ describe('browserManager', () => { id: 104, isDestroyed: vi.fn(() => false), getType: vi.fn(() => 'webview'), + isCurrentlyAudible: vi.fn(() => false), + send: vi.fn(), setBackgroundThrottling: guestSetBackgroundThrottlingMock, setWindowOpenHandler: guestSetWindowOpenHandlerMock, on: guestOnMock, @@ -123,6 +125,8 @@ describe('browserManager', () => { id: 129, isDestroyed: vi.fn(() => false), getType: vi.fn(() => 'webview'), + isCurrentlyAudible: vi.fn(() => false), + send: vi.fn(), setBackgroundThrottling: guestSetBackgroundThrottlingMock, setWindowOpenHandler: guestSetWindowOpenHandlerMock, on: guestOnMock, @@ -196,6 +200,8 @@ describe('browserManager', () => { id: 139, isDestroyed: vi.fn(() => false), getType: vi.fn(() => 'webview'), + isCurrentlyAudible: vi.fn(() => false), + send: vi.fn(), setBackgroundThrottling: guestSetBackgroundThrottlingMock, setWindowOpenHandler: guestSetWindowOpenHandlerMock, on: guestOnMock, @@ -231,6 +237,8 @@ describe('browserManager', () => { const guest = { isDestroyed: vi.fn(() => false), getType: vi.fn(() => 'webview'), + isCurrentlyAudible: vi.fn(() => false), + send: vi.fn(), getURL: vi.fn(() => 'https://example.com/'), setBackgroundThrottling: guestSetBackgroundThrottlingMock, setWindowOpenHandler: guestSetWindowOpenHandlerMock, @@ -259,6 +267,8 @@ describe('browserManager', () => { const guest = { isDestroyed: vi.fn(() => false), getType: vi.fn(() => 'webview'), + isCurrentlyAudible: vi.fn(() => false), + send: vi.fn(), getURL: vi.fn(() => currentUrl), setBackgroundThrottling: guestSetBackgroundThrottlingMock, setWindowOpenHandler: guestSetWindowOpenHandlerMock, @@ -283,6 +293,8 @@ describe('browserManager', () => { id: 101, isDestroyed: vi.fn(() => false), getType: vi.fn(() => 'webview'), + isCurrentlyAudible: vi.fn(() => false), + send: vi.fn(), setBackgroundThrottling: guestSetBackgroundThrottlingMock, setWindowOpenHandler: guestSetWindowOpenHandlerMock, on: guestOnMock, @@ -344,6 +356,8 @@ describe('browserManager', () => { id: 777, isDestroyed: vi.fn(() => false), getType: vi.fn(() => 'webview'), + isCurrentlyAudible: vi.fn(() => false), + send: vi.fn(), setBackgroundThrottling: guestSetBackgroundThrottlingMock, setWindowOpenHandler: guestSetWindowOpenHandlerMock, on: guestOnMock, @@ -367,6 +381,8 @@ describe('browserManager', () => { id: 303, isDestroyed: vi.fn(() => false), getType: vi.fn(() => 'webview'), + isCurrentlyAudible: vi.fn(() => false), + send: vi.fn(), setBackgroundThrottling: guestSetBackgroundThrottlingMock, setWindowOpenHandler: guestSetWindowOpenHandlerMock, on: guestOnMock, @@ -391,6 +407,8 @@ describe('browserManager', () => { id: 304, isDestroyed: vi.fn(() => false), getType: vi.fn(() => 'webview'), + isCurrentlyAudible: vi.fn(() => false), + send: vi.fn(), setBackgroundThrottling: guestSetBackgroundThrottlingMock, setWindowOpenHandler: guestSetWindowOpenHandlerMock, on: guestOnMock, @@ -421,6 +439,8 @@ describe('browserManager', () => { id: 306, isDestroyed: vi.fn(() => false), getType: vi.fn(() => 'webview'), + isCurrentlyAudible: vi.fn(() => false), + send: vi.fn(), setBackgroundThrottling: guestSetBackgroundThrottlingMock, setWindowOpenHandler: guestSetWindowOpenHandlerMock, on: guestOnMock, @@ -453,6 +473,8 @@ describe('browserManager', () => { id: 305, isDestroyed: vi.fn(() => false), getType: vi.fn(() => 'webview'), + isCurrentlyAudible: vi.fn(() => false), + send: vi.fn(), setBackgroundThrottling: guestSetBackgroundThrottlingMock, setWindowOpenHandler: guestSetWindowOpenHandlerMock, on: guestOnMock, @@ -513,6 +535,8 @@ describe('browserManager', () => { id: 501, isDestroyed: vi.fn(() => false), getType: vi.fn(() => 'webview'), + isCurrentlyAudible: vi.fn(() => false), + send: vi.fn(), setBackgroundThrottling: guestSetBackgroundThrottlingMock, setWindowOpenHandler: guestSetWindowOpenHandlerMock, on: oldGuestOnMock, @@ -524,6 +548,8 @@ describe('browserManager', () => { id: 502, isDestroyed: vi.fn(() => false), getType: vi.fn(() => 'webview'), + isCurrentlyAudible: vi.fn(() => false), + send: vi.fn(), setBackgroundThrottling: guestSetBackgroundThrottlingMock, setWindowOpenHandler: guestSetWindowOpenHandlerMock, on: newGuestOnMock, @@ -558,6 +584,8 @@ describe('browserManager', () => { webContentsId: newGuest.id, rendererWebContentsId }) + // Registration also reports audio state; this test is about load-failure routing. + rendererSendMock.mockClear() const oldDidFailLoadHandler = oldGuestOnMock.mock.calls.find( ([event]) => event === 'did-fail-load' @@ -608,6 +636,8 @@ describe('browserManager', () => { id: 808, isDestroyed: vi.fn(() => false), getType: vi.fn(() => 'webview'), + isCurrentlyAudible: vi.fn(() => false), + send: vi.fn(), setBackgroundThrottling: guestSetBackgroundThrottlingMock, setWindowOpenHandler: guestSetWindowOpenHandlerMock, on: guestOnMock, @@ -654,6 +684,8 @@ describe('browserManager', () => { id: 809, isDestroyed: vi.fn(() => false), getType: vi.fn(() => 'webview'), + isCurrentlyAudible: vi.fn(() => false), + send: vi.fn(), setBackgroundThrottling: guestSetBackgroundThrottlingMock, setWindowOpenHandler: guestSetWindowOpenHandlerMock, on: guestOnMock, @@ -691,4 +723,49 @@ describe('browserManager', () => { expect(debuggerHandlers.has('detach')).toBe(false) expect(debuggerAttachMock).not.toHaveBeenCalled() }) + + // The renderer re-applies a tab's mute on each registration report and clears the + // speaker on the unregister report, so neither may be skipped. + it('reports page audio on registration and on change, and silence on unregister', () => { + const rendererSend = vi.fn() + const guest = { + id: 301, + isDestroyed: vi.fn(() => false), + getType: vi.fn(() => 'webview'), + isCurrentlyAudible: vi.fn(() => true), + setBackgroundThrottling: guestSetBackgroundThrottlingMock, + setWindowOpenHandler: guestSetWindowOpenHandlerMock, + on: guestOnMock, + off: guestOffMock, + openDevTools: guestOpenDevToolsMock + } + webContentsFromIdMock.mockImplementation((id: number) => + id === guest.id + ? guest + : id === rendererWebContentsId + ? { isDestroyed: () => false, send: rendererSend } + : null + ) + browserManager.attachGuestPolicies(guest as never) + const onAudioStateChanged: (event: { audible: boolean }) => void = guestOnMock.mock.calls.find( + ([event]) => event === 'audio-state-changed' + )?.[1] + + browserManager.registerGuest({ + browserPageId: 'page-1', + webContentsId: guest.id, + rendererWebContentsId + }) + onAudioStateChanged({ audible: false }) + browserManager.unregisterGuest('page-1') + onAudioStateChanged({ audible: true }) + + expect( + rendererSend.mock.calls.filter(([channel]) => channel === 'browser:audio-state-changed') + ).toEqual([ + ['browser:audio-state-changed', { browserPageId: 'page-1', audible: true }], + ['browser:audio-state-changed', { browserPageId: 'page-1', audible: false }], + ['browser:audio-state-changed', { browserPageId: 'page-1', audible: false }] + ]) + }) }) diff --git a/src/main/browser/browser-manager-guest-policy-profile.test.ts b/src/main/browser/browser-manager-guest-policy-profile.test.ts index 0c82f9515c3..f0752bda9de 100644 --- a/src/main/browser/browser-manager-guest-policy-profile.test.ts +++ b/src/main/browser/browser-manager-guest-policy-profile.test.ts @@ -41,6 +41,8 @@ type GuestFake = { windowOpenHandler: ((details: { url: string; frameName: string }) => unknown) | null webRtcPolicy: string | null isDestroyed: () => boolean + isCurrentlyAudible: () => boolean + send: ReturnType isFocused: () => boolean getURL: () => string getType: () => string @@ -68,6 +70,8 @@ function createGuest(id: number, url: string): GuestFake { windowOpenHandler: null, webRtcPolicy: null, isDestroyed: () => false, + isCurrentlyAudible: () => false, + send: vi.fn(), isFocused: () => true, getURL: () => guest.url, getType: () => 'webview', diff --git a/src/main/browser/browser-manager-guest-policy.ts b/src/main/browser/browser-manager-guest-policy.ts index 5e19993ee3d..fa180413694 100644 --- a/src/main/browser/browser-manager-guest-policy.ts +++ b/src/main/browser/browser-manager-guest-policy.ts @@ -31,6 +31,15 @@ export abstract class BrowserManagerGuestPolicy extends BrowserManagerGuestClean guest.setBackgroundThrottling(false) const disposePopupPolicy = this.installGuestPopupPolicy(guest) const disposeNavigationPolicy = this.installGuestNavigationPolicy(guest) + const forwardAudioState = ( + event: Electron.Event + ): void => { + const browserPageId = this.tabIdByWebContentsId.get(guest.id) + if (browserPageId) { + this.notifyAudioStateChanged(browserPageId, event.audible) + } + } + guest.on('audio-state-changed', forwardAudioState) const disposeDevToolsWindow = installGuestDevToolsWindowBehavior({ guest, getKeybindings: () => this.settingsResolver?.().keybindings, @@ -45,6 +54,16 @@ export abstract class BrowserManagerGuestPolicy extends BrowserManagerGuestClean disposePopupPolicy() disposeNavigationPolicy() disposeDevToolsWindow() + if (!guest.isDestroyed()) { + guest.off('audio-state-changed', forwardAudioState) + } + }) + } + + protected notifyAudioStateChanged(browserPageId: string, audible: boolean): void { + this.resolveRendererForBrowserTab(browserPageId)?.send('browser:audio-state-changed', { + browserPageId, + audible }) } diff --git a/src/main/browser/browser-manager-guest-shortcuts.test.ts b/src/main/browser/browser-manager-guest-shortcuts.test.ts index 86ce9e84ab5..19bce4eed4a 100644 --- a/src/main/browser/browser-manager-guest-shortcuts.test.ts +++ b/src/main/browser/browser-manager-guest-shortcuts.test.ts @@ -70,6 +70,7 @@ describe('browserManager', () => { const guest = { id: 405, isDestroyed: vi.fn(() => false), + isCurrentlyAudible: vi.fn(() => false), getType: vi.fn(() => 'webview'), setBackgroundThrottling: guestSetBackgroundThrottlingMock, setWindowOpenHandler: guestSetWindowOpenHandlerMock, @@ -94,6 +95,8 @@ describe('browserManager', () => { webContentsId: guest.id, rendererWebContentsId }) + // Registration also reports audio state; this test is about shortcut forwarding. + rendererSendMock.mockClear() const beforeInputHandler = guestOnMock.mock.calls.findLast( ([event]) => event === 'before-input-event' @@ -161,6 +164,7 @@ describe('browserManager', () => { const guest = { id: 406, isDestroyed: vi.fn(() => false), + isCurrentlyAudible: vi.fn(() => false), getType: vi.fn(() => 'webview'), setBackgroundThrottling: guestSetBackgroundThrottlingMock, setWindowOpenHandler: guestSetWindowOpenHandlerMock, @@ -185,6 +189,8 @@ describe('browserManager', () => { webContentsId: guest.id, rendererWebContentsId }) + // Registration also reports audio state; this test is about shortcut forwarding. + rendererSendMock.mockClear() const beforeInputHandler = guestOnMock.mock.calls.findLast( ([event]) => event === 'before-input-event' @@ -332,6 +338,7 @@ describe('browserManager', () => { const guest = { id: 407, isDestroyed: vi.fn(() => false), + isCurrentlyAudible: vi.fn(() => false), getType: vi.fn(() => 'webview'), setBackgroundThrottling: guestSetBackgroundThrottlingMock, setWindowOpenHandler: guestSetWindowOpenHandlerMock, @@ -362,6 +369,8 @@ describe('browserManager', () => { webContentsId: guest.id, rendererWebContentsId }) + // Registration also reports audio state; this test is about shortcut forwarding. + rendererSendMock.mockClear() const beforeInputHandler = guestOnMock.mock.calls.findLast( ([event]) => event === 'before-input-event' @@ -436,6 +445,7 @@ describe('browserManager', () => { const guest = { id: 407, isDestroyed: vi.fn(() => false), + isCurrentlyAudible: vi.fn(() => false), getType: vi.fn(() => 'webview'), setBackgroundThrottling: guestSetBackgroundThrottlingMock, setWindowOpenHandler: guestSetWindowOpenHandlerMock, @@ -460,6 +470,8 @@ describe('browserManager', () => { webContentsId: guest.id, rendererWebContentsId }) + // Registration also reports audio state; this test is about shortcut forwarding. + rendererSendMock.mockClear() const beforeInputHandler = guestOnMock.mock.calls.findLast( ([event]) => event === 'before-input-event' @@ -506,6 +518,7 @@ describe('browserManager', () => { const guest = { id: 408, isDestroyed: vi.fn(() => false), + isCurrentlyAudible: vi.fn(() => false), getType: vi.fn(() => 'webview'), setBackgroundThrottling: guestSetBackgroundThrottlingMock, setWindowOpenHandler: guestSetWindowOpenHandlerMock, diff --git a/src/main/browser/browser-manager-guest-visibility.test.ts b/src/main/browser/browser-manager-guest-visibility.test.ts index 79025e907f3..e4d0eed4a5e 100644 --- a/src/main/browser/browser-manager-guest-visibility.test.ts +++ b/src/main/browser/browser-manager-guest-visibility.test.ts @@ -70,6 +70,7 @@ describe('browserManager', () => { const guest = { id: 1707, isDestroyed: vi.fn(() => false), + isCurrentlyAudible: vi.fn(() => false), getType: vi.fn(() => 'webview'), setBackgroundThrottling: guestSetBackgroundThrottlingMock, setWindowOpenHandler: guestSetWindowOpenHandlerMock, @@ -102,6 +103,8 @@ describe('browserManager', () => { webContentsId: guest.id, rendererWebContentsId }) + // Registration also reports audio state; this test is about paint holds. + renderer.send.mockClear() // Synchronous: a capture never waits on the desktop renderer. const releaseFirst = browserManager.holdPaintForCapture(guest.id) diff --git a/src/main/browser/browser-manager-load-failure-replay.test.ts b/src/main/browser/browser-manager-load-failure-replay.test.ts index c74c53b1c4e..b2946221591 100644 --- a/src/main/browser/browser-manager-load-failure-replay.test.ts +++ b/src/main/browser/browser-manager-load-failure-replay.test.ts @@ -80,6 +80,7 @@ describe('browserManager', () => { isDestroyed: vi.fn(() => false), getType: vi.fn(() => 'window'), setBackgroundThrottling: vi.fn(), + isCurrentlyAudible: vi.fn(() => false), setWindowOpenHandler: vi.fn(), on: vi.fn(), off: vi.fn(), @@ -119,6 +120,7 @@ describe('browserManager', () => { isDestroyed: vi.fn(() => false), getType: vi.fn(() => 'webview'), setBackgroundThrottling: guestSetBackgroundThrottlingMock, + isCurrentlyAudible: vi.fn(() => false), setWindowOpenHandler: guestSetWindowOpenHandlerMock, on: guestOnMock, off: guestOffMock, @@ -165,7 +167,9 @@ describe('browserManager', () => { rendererWebContentsId }) - expect(rendererSendMock).toHaveBeenCalledTimes(1) + expect( + rendererSendMock.mock.calls.filter(([channel]) => channel === 'browser:guest-load-failed') + ).toHaveLength(1) expect(rendererSendMock).toHaveBeenCalledWith('browser:guest-load-failed', { browserPageId: 'browser-1', loadError: { @@ -196,6 +200,7 @@ describe('browserManager', () => { isDestroyed: vi.fn(() => false), getType: vi.fn(() => 'webview'), setBackgroundThrottling: guestSetBackgroundThrottlingMock, + isCurrentlyAudible: vi.fn(() => false), setWindowOpenHandler: guestSetWindowOpenHandlerMock, on: guestOnMock, off: guestOffMock, @@ -248,6 +253,7 @@ describe('browserManager', () => { isDestroyed: vi.fn(() => false), getType: vi.fn(() => 'webview'), setBackgroundThrottling: guestSetBackgroundThrottlingMock, + isCurrentlyAudible: vi.fn(() => false), setWindowOpenHandler: guestSetWindowOpenHandlerMock, on: guestOnMock, off: guestOffMock, @@ -291,6 +297,7 @@ describe('browserManager', () => { isDestroyed: vi.fn(() => false), getType: vi.fn(() => 'webview'), setBackgroundThrottling: guestSetBackgroundThrottlingMock, + isCurrentlyAudible: vi.fn(() => false), setWindowOpenHandler: guestSetWindowOpenHandlerMock, on: guestOnMock, off: guestOffMock, diff --git a/src/main/browser/browser-manager-page-prompt-routing.test.ts b/src/main/browser/browser-manager-page-prompt-routing.test.ts index af7a0f4427e..f7e2790b72c 100644 --- a/src/main/browser/browser-manager-page-prompt-routing.test.ts +++ b/src/main/browser/browser-manager-page-prompt-routing.test.ts @@ -71,6 +71,7 @@ describe('browserManager page prompt routing', () => { const guest = { id: 107, isDestroyed: vi.fn(() => false), + isCurrentlyAudible: vi.fn(() => false), getType: vi.fn(() => 'webview'), getURL: vi.fn(() => 'https://trusted.example/home'), setBackgroundThrottling: guestSetBackgroundThrottlingMock, @@ -162,6 +163,7 @@ describe('browserManager page prompt routing', () => { const guest = { id: 108, isDestroyed: vi.fn(() => false), + isCurrentlyAudible: vi.fn(() => false), getType: vi.fn(() => 'webview'), setBackgroundThrottling: guestSetBackgroundThrottlingMock, setWindowOpenHandler: guestSetWindowOpenHandlerMock, @@ -206,6 +208,7 @@ describe('browserManager page prompt routing', () => { const guest = { id: 109, isDestroyed: vi.fn(() => false), + isCurrentlyAudible: vi.fn(() => false), getType: vi.fn(() => 'webview'), getURL: vi.fn(() => ''), setBackgroundThrottling: guestSetBackgroundThrottlingMock, diff --git a/src/main/browser/browser-manager-popup-child.test.ts b/src/main/browser/browser-manager-popup-child.test.ts index bf4caf2b0ba..9807e9c3e71 100644 --- a/src/main/browser/browser-manager-popup-child.test.ts +++ b/src/main/browser/browser-manager-popup-child.test.ts @@ -68,6 +68,7 @@ describe('browserManager popup child policies', () => { const childGuest = { id: 4040, isDestroyed: vi.fn(() => false), + isCurrentlyAudible: vi.fn(() => false), getType: vi.fn(() => 'webview'), setBackgroundThrottling: childSetBackgroundThrottlingMock, setWindowOpenHandler: childSetWindowOpenHandlerMock, @@ -78,6 +79,7 @@ describe('browserManager popup child policies', () => { const guest = { id: 404, isDestroyed: vi.fn(() => false), + isCurrentlyAudible: vi.fn(() => false), getType: vi.fn(() => 'webview'), setBackgroundThrottling: guestSetBackgroundThrottlingMock, setWindowOpenHandler: guestSetWindowOpenHandlerMock, diff --git a/src/main/browser/browser-manager-popup-openerless-tab.test.ts b/src/main/browser/browser-manager-popup-openerless-tab.test.ts index 81823d16090..2708af73b55 100644 --- a/src/main/browser/browser-manager-popup-openerless-tab.test.ts +++ b/src/main/browser/browser-manager-popup-openerless-tab.test.ts @@ -82,6 +82,7 @@ function attachClickedGuest(): WindowOpenHandler { id: 2200, session: guestSession, isDestroyed: vi.fn(() => false), + isCurrentlyAudible: vi.fn(() => false), getType: vi.fn(() => 'webview'), setBackgroundThrottling: vi.fn(), setWindowOpenHandler: guestSetWindowOpenHandlerMock, diff --git a/src/main/browser/browser-manager-popup-routing.test.ts b/src/main/browser/browser-manager-popup-routing.test.ts index c03cc015cae..aa79cae268f 100644 --- a/src/main/browser/browser-manager-popup-routing.test.ts +++ b/src/main/browser/browser-manager-popup-routing.test.ts @@ -76,6 +76,7 @@ describe('browserManager', () => { it('validates popup URLs before opening externally', () => { const guest = { isDestroyed: vi.fn(() => false), + isCurrentlyAudible: vi.fn(() => false), getType: vi.fn(() => 'webview'), setBackgroundThrottling: guestSetBackgroundThrottlingMock, setWindowOpenHandler: guestSetWindowOpenHandlerMock, @@ -109,6 +110,7 @@ describe('browserManager', () => { const guest = { id: 103, isDestroyed: vi.fn(() => false), + isCurrentlyAudible: vi.fn(() => false), getType: vi.fn(() => 'webview'), setBackgroundThrottling: guestSetBackgroundThrottlingMock, setWindowOpenHandler: guestSetWindowOpenHandlerMock, @@ -133,6 +135,8 @@ describe('browserManager', () => { webContentsId: guest.id, rendererWebContentsId }) + // Registration also reports audio state; this test is about popup routing. + rendererSendMock.mockClear() const handler = guestSetWindowOpenHandlerMock.mock.calls[0][0] as (details: { url: string @@ -184,6 +188,8 @@ describe('browserManager', () => { const guest = { id: 140, isDestroyed: vi.fn(() => false), + isCurrentlyAudible: vi.fn(() => false), + send: vi.fn(), getType: vi.fn(() => 'webview'), setBackgroundThrottling: guestSetBackgroundThrottlingMock, setWindowOpenHandler: guestSetWindowOpenHandlerMock, @@ -249,6 +255,7 @@ describe('browserManager', () => { const guest = { id: 142, isDestroyed: vi.fn(() => false), + isCurrentlyAudible: vi.fn(() => false), getType: vi.fn(() => 'webview'), setBackgroundThrottling: guestSetBackgroundThrottlingMock, setWindowOpenHandler: guestSetWindowOpenHandlerMock, @@ -325,6 +332,7 @@ describe('browserManager', () => { const guest = { id: 143, isDestroyed: vi.fn(() => false), + isCurrentlyAudible: vi.fn(() => false), getType: vi.fn(() => 'webview'), setBackgroundThrottling: guestSetBackgroundThrottlingMock, setWindowOpenHandler: guestSetWindowOpenHandlerMock, @@ -384,6 +392,7 @@ describe('browserManager', () => { const guest = { id: 144, isDestroyed: vi.fn(() => false), + isCurrentlyAudible: vi.fn(() => false), getType: vi.fn(() => 'webview'), setBackgroundThrottling: guestSetBackgroundThrottlingMock, setWindowOpenHandler: guestSetWindowOpenHandlerMock, @@ -470,6 +479,7 @@ describe('browserManager', () => { const guest = { id: 150, isDestroyed: vi.fn(() => false), + isCurrentlyAudible: vi.fn(() => false), getType: vi.fn(() => 'webview'), setBackgroundThrottling: guestSetBackgroundThrottlingMock, setWindowOpenHandler: guestSetWindowOpenHandlerMock, @@ -510,6 +520,7 @@ describe('browserManager', () => { const child = { id: 1500 + index, isDestroyed: vi.fn(() => false), + isCurrentlyAudible: vi.fn(() => false), getType: vi.fn(() => 'webview'), setBackgroundThrottling: vi.fn(), setWindowOpenHandler: childSetWindowOpenHandlerMock, @@ -569,6 +580,7 @@ describe('browserManager', () => { session: { marker: 'synthetic-opener-session' }, id: 150, isDestroyed: vi.fn(() => false), + isCurrentlyAudible: vi.fn(() => false), getType: vi.fn(() => 'webview'), setBackgroundThrottling: guestSetBackgroundThrottlingMock, setWindowOpenHandler: guestSetWindowOpenHandlerMock, @@ -670,6 +682,7 @@ describe('browserManager', () => { const guest = { id: 106, isDestroyed: vi.fn(() => false), + isCurrentlyAudible: vi.fn(() => false), getType: vi.fn(() => 'webview'), setBackgroundThrottling: guestSetBackgroundThrottlingMock, setWindowOpenHandler: guestSetWindowOpenHandlerMock, @@ -719,6 +732,7 @@ describe('browserManager', () => { const guest = { id: 105, isDestroyed: vi.fn(() => false), + isCurrentlyAudible: vi.fn(() => false), getType: vi.fn(() => 'webview'), setBackgroundThrottling: guestSetBackgroundThrottlingMock, setWindowOpenHandler: guestSetWindowOpenHandlerMock, @@ -744,6 +758,7 @@ describe('browserManager', () => { const guest = { id: 104, isDestroyed: vi.fn(() => false), + isCurrentlyAudible: vi.fn(() => false), getType: vi.fn(() => 'webview'), setBackgroundThrottling: guestSetBackgroundThrottlingMock, setWindowOpenHandler: guestSetWindowOpenHandlerMock, diff --git a/src/main/browser/browser-manager-registration.ts b/src/main/browser/browser-manager-registration.ts index 9b3037e7f7d..402a390f2c9 100644 --- a/src/main/browser/browser-manager-registration.ts +++ b/src/main/browser/browser-manager-registration.ts @@ -72,6 +72,8 @@ export abstract class BrowserManagerRegistration extends BrowserManagerGuestPoli } this.certificateTrustController?.onGuestRegistered(webContentsId, browserTabId) this.siteDisplayController?.onGuestRegistered(guest, sessionProfileId ?? null) + // Earlier audio events had no page; this report also cues the renderer to re-apply a tab's mute. + this.notifyAudioStateChanged(browserTabId, guest.isCurrentlyAudible()) this.setupContextMenu(browserTabId, guest) this.setupGrabShortcut(browserTabId, guest) @@ -99,6 +101,7 @@ export abstract class BrowserManagerRegistration extends BrowserManagerGuestPoli // Resolve pending grabs before removing their registration. this.cancelGrabOp(browserTabId, 'evicted') cancelBrowserPagePrompts(browserTabId) + this.notifyAudioStateChanged(browserTabId, false) const guestWebContentsId = this.webContentsIdByTabId.get(browserTabId) if (guestWebContentsId !== undefined) { diff --git a/src/main/browser/browser-manager-viewport-test-fixtures.ts b/src/main/browser/browser-manager-viewport-test-fixtures.ts index 0ffd5634206..80474805203 100644 --- a/src/main/browser/browser-manager-viewport-test-fixtures.ts +++ b/src/main/browser/browser-manager-viewport-test-fixtures.ts @@ -79,6 +79,8 @@ export function createViewportGuestFactory( id, isDestroyed: vi.fn(() => false), isCrashed: vi.fn(() => rendererCrashed), + isCurrentlyAudible: vi.fn(() => false), + send: vi.fn(), getType: vi.fn(() => 'webview'), getURL: vi.fn(() => committedUrl), getUserAgent: vi.fn(() => currentUa), diff --git a/src/main/browser/cdp-bridge-integration.test.ts b/src/main/browser/cdp-bridge-integration.test.ts index a4fc69fd9fc..84e23ccba2d 100644 --- a/src/main/browser/cdp-bridge-integration.test.ts +++ b/src/main/browser/cdp-bridge-integration.test.ts @@ -184,6 +184,7 @@ function createMockGuest( const guest = { id, isDestroyed: vi.fn(() => false), + isCurrentlyAudible: vi.fn(() => false), getType: vi.fn(() => 'webview'), getURL: vi.fn(() => currentUrl), getTitle: vi.fn(() => currentTitle), diff --git a/src/main/ipc/browser-guest-view-ipc.ts b/src/main/ipc/browser-guest-view-ipc.ts index a5c628e289d..0245d4506e5 100644 --- a/src/main/ipc/browser-guest-view-ipc.ts +++ b/src/main/ipc/browser-guest-view-ipc.ts @@ -44,6 +44,7 @@ export function registerBrowserGuestViewHandlers(): void { ipcMain.removeHandler('browser:getPageSiteDisplay') ipcMain.removeHandler('browser:zoomPage') ipcMain.removeHandler('browser:setPageSiteColorScheme') + ipcMain.removeHandler('browser:setPageAudioMuted') // Page/session operations exclude document previews. const pageGuestId = (event: IpcMainInvokeEvent, browserPageId: unknown): number | null => @@ -95,6 +96,18 @@ export function registerBrowserGuestViewHandlers(): void { } ) + ipcMain.handle( + 'browser:setPageAudioMuted', + (event, args: { browserPageId: string; muted: unknown }): boolean => { + const guest = pageGuest(event, args?.browserPageId) + if (!guest || typeof args.muted !== 'boolean') { + return false + } + guest.setAudioMuted(args.muted) + return true + } + ) + ipcMain.handle( 'browser:openDevTools', (event, args: { browserPageId: string; inspectAt?: unknown }) => { diff --git a/src/main/ipc/browser-preview-tool-authorization.test.ts b/src/main/ipc/browser-preview-tool-authorization.test.ts index 2602c755cf2..5ca63a99ba9 100644 --- a/src/main/ipc/browser-preview-tool-authorization.test.ts +++ b/src/main/ipc/browser-preview-tool-authorization.test.ts @@ -128,6 +128,7 @@ const BROWSER_PAGE_CHANNELS = [ 'browser:setViewportOverride', 'browser:getPageSiteDisplay', 'browser:zoomPage', + 'browser:setPageAudioMuted', 'browser:setPageSiteColorScheme', 'browser:publishClientPageMetadata', 'browser:cancelDownload', diff --git a/src/preload/api/browser-api.ts b/src/preload/api/browser-api.ts index a7067f5c001..9e8aef7d8dd 100644 --- a/src/preload/api/browser-api.ts +++ b/src/preload/api/browser-api.ts @@ -122,6 +122,7 @@ export type BrowserApi = { browserPageId: string colorScheme: BrowserSiteColorScheme | null }) => Promise + setPageAudioMuted: (args: { browserPageId: string; muted: boolean }) => Promise reportViewportScrollState?: (args: { browserPageId: string state: BrowserViewportScrollState @@ -138,6 +139,9 @@ export type BrowserApi = { onCertificateFailureChanged: ( callback: (event: { browserPageId: string; failure: BrowserCertificateFailure | null }) => void ) => () => void + onAudioStateChanged: ( + callback: (event: { browserPageId: string; audible: boolean }) => void + ) => () => void proceedCertificate: (args: { browserPageId: string challengeId: string diff --git a/src/preload/api/browser-bridge-guest-registration-and-downloads.ts b/src/preload/api/browser-bridge-guest-registration-and-downloads.ts index 08a0f06fbb7..fb077ec8ca7 100644 --- a/src/preload/api/browser-bridge-guest-registration-and-downloads.ts +++ b/src/preload/api/browser-bridge-guest-registration-and-downloads.ts @@ -98,6 +98,8 @@ export const browserGuestRegistrationAndDownloadsApi = { colorScheme: BrowserSiteColorScheme | null }): Promise => ipcRenderer.invoke('browser:setPageSiteColorScheme', args), + setPageAudioMuted: (args: { browserPageId: string; muted: boolean }): Promise => + ipcRenderer.invoke('browser:setPageAudioMuted', args), reportViewportScrollState: (args: { browserPageId: string state: { @@ -135,6 +137,14 @@ export const browserGuestRegistrationAndDownloadsApi = { ipcRenderer.on('browser:certificate-failure-changed', listener) return () => ipcRenderer.removeListener('browser:certificate-failure-changed', listener) }, + onAudioStateChanged: (callback): (() => void) => { + const listener = ( + _event: Electron.IpcRendererEvent, + data: Parameters[0] + ): void => callback(data) + ipcRenderer.on('browser:audio-state-changed', listener) + return () => ipcRenderer.removeListener('browser:audio-state-changed', listener) + }, proceedCertificate: (args) => ipcRenderer.invoke('browser:proceedCertificate', args), onPermissionDenied: ( callback: (event: { browserPageId: string; permission: string; origin: string }) => void diff --git a/src/renderer/src/components/tab-bar/BrowserTab.test.tsx b/src/renderer/src/components/tab-bar/BrowserTab.test.tsx index 929770867a9..a0f10222ec8 100644 --- a/src/renderer/src/components/tab-bar/BrowserTab.test.tsx +++ b/src/renderer/src/components/tab-bar/BrowserTab.test.tsx @@ -2,12 +2,27 @@ import type * as ReactModule from 'react' import { beforeEach, describe, expect, it, vi } from 'vitest' import { isValidElement } from 'react' import type { BrowserTab as BrowserTabState } from '../../../../shared/browser-workspace-types' +import type * as StoreModule from '@/store' const reactHookRuntime = vi.hoisted(() => ({ states: [] as unknown[], index: 0 })) +// Why: the shallow harness has no React dispatcher, so the store hook reads state directly. +vi.mock('@/store', async () => { + const actual = await vi.importActual('@/store') + const store = actual.useAppStore + return { + ...actual, + useAppStore: Object.assign( + (selector: (state: ReturnType) => T): T => + selector(store.getState()), + store + ) + } +}) + vi.mock('react', async () => { const actual = await vi.importActual('react') return { @@ -90,6 +105,12 @@ vi.mock('lucide-react', () => ({ Rows2: function Rows2(props: Record) { return { type: 'Rows2', props } }, + Volume2: function Volume2(props: Record) { + return { type: 'Volume2', props } + }, + VolumeX: function VolumeX(props: Record) { + return { type: 'VolumeX', props } + }, X: function X(props: Record) { return { type: 'X', props } } diff --git a/src/renderer/src/components/tab-bar/BrowserTab.tsx b/src/renderer/src/components/tab-bar/BrowserTab.tsx index 54e62826aa4..5f476cd08c2 100644 --- a/src/renderer/src/components/tab-bar/BrowserTab.tsx +++ b/src/renderer/src/components/tab-bar/BrowserTab.tsx @@ -1,6 +1,6 @@ import { useEffect, useState } from 'react' import { useSortable } from '@dnd-kit/sortable' -import { X, ExternalLink, Copy, CopyX, Pin, PinOff } from 'lucide-react' +import { X, ExternalLink, Copy, CopyX, Pin, PinOff, Volume2, VolumeX } from 'lucide-react' import { DropdownMenu, DropdownMenuContent, @@ -33,6 +33,7 @@ import { cn } from '@/lib/utils' import { BrowserFavicon } from '@/components/browser-favicon' import { CopyTabIdMenuItem } from './CopyTabIdMenuItem' import { CloseSplitPaneMenuItem } from './CloseSplitPaneMenuItem' +import { useAppStore } from '@/store' export function formatBrowserTabUrlLabel(url: string): string { if (url === ORCA_BROWSER_BLANK_URL || url === 'about:blank') { @@ -117,6 +118,16 @@ export default function BrowserTab({ // invalid URL — leave disabled } const tabLabel = getBrowserTabLabel(tab) + const audible = useAppStore((s) => + (s.browserPagesByWorkspace[tab.id] ?? []).some((page) => s.browserAudiblePageIds[page.id]) + ) + const audioMuted = useAppStore((s) => Boolean(s.browserAudioMutedTabIds[tab.id])) + // Why: a muted tab keeps its icon after sound stops, so it can be unmuted from the strip. + const showAudioControl = audible || audioMuted + const setBrowserTabAudioMuted = useAppStore((s) => s.setBrowserTabAudioMuted) + const toggleAudioMutedLabel = audioMuted + ? translate('components.tab.bar.BrowserTab.unmuteTab', 'Unmute Tab') + : translate('components.tab.bar.BrowserTab.muteTab', 'Mute Tab') useEffect(() => { const closeMenu = (): void => setMenuOpen(false) @@ -191,6 +202,21 @@ export default function BrowserTab({ /> {isPinned && } {tabLabel} + {showAudioControl && ( + // Why: unlike close, stays visible on inactive tabs; finding the playing tab is the point. + + )} {!isPinned && (