mirror of
https://github.com/stablyai/orca.git
synced 2026-11-01 17:47:40 +00:00
feat(browser): speaker icon on tabs playing sound, with tab mute (#32981)
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -61,6 +61,9 @@ export class DestroyedGuestTestContents extends EventEmitter {
|
||||
setUserAgent(): void {}
|
||||
setWindowOpenHandler(): void {}
|
||||
setBackgroundThrottling(): void {}
|
||||
isCurrentlyAudible(): boolean {
|
||||
return false
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
this.destroyed = true
|
||||
|
||||
@@ -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<void>((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.' })
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 }]
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -41,6 +41,8 @@ type GuestFake = {
|
||||
windowOpenHandler: ((details: { url: string; frameName: string }) => unknown) | null
|
||||
webRtcPolicy: string | null
|
||||
isDestroyed: () => boolean
|
||||
isCurrentlyAudible: () => boolean
|
||||
send: ReturnType<typeof vi.fn>
|
||||
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',
|
||||
|
||||
@@ -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<Electron.WebContentsAudioStateChangedEventParams>
|
||||
): 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
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -128,6 +128,7 @@ const BROWSER_PAGE_CHANNELS = [
|
||||
'browser:setViewportOverride',
|
||||
'browser:getPageSiteDisplay',
|
||||
'browser:zoomPage',
|
||||
'browser:setPageAudioMuted',
|
||||
'browser:setPageSiteColorScheme',
|
||||
'browser:publishClientPageMetadata',
|
||||
'browser:cancelDownload',
|
||||
|
||||
@@ -122,6 +122,7 @@ export type BrowserApi = {
|
||||
browserPageId: string
|
||||
colorScheme: BrowserSiteColorScheme | null
|
||||
}) => Promise<BrowserPageSiteDisplay | null>
|
||||
setPageAudioMuted: (args: { browserPageId: string; muted: boolean }) => Promise<boolean>
|
||||
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
|
||||
|
||||
@@ -98,6 +98,8 @@ export const browserGuestRegistrationAndDownloadsApi = {
|
||||
colorScheme: BrowserSiteColorScheme | null
|
||||
}): Promise<BrowserPageSiteDisplay | null> =>
|
||||
ipcRenderer.invoke('browser:setPageSiteColorScheme', args),
|
||||
setPageAudioMuted: (args: { browserPageId: string; muted: boolean }): Promise<boolean> =>
|
||||
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<typeof callback>[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
|
||||
|
||||
@@ -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<typeof StoreModule>('@/store')
|
||||
const store = actual.useAppStore
|
||||
return {
|
||||
...actual,
|
||||
useAppStore: Object.assign(
|
||||
<T,>(selector: (state: ReturnType<typeof store.getState>) => T): T =>
|
||||
selector(store.getState()),
|
||||
store
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('react', async () => {
|
||||
const actual = await vi.importActual<typeof ReactModule>('react')
|
||||
return {
|
||||
@@ -90,6 +105,12 @@ vi.mock('lucide-react', () => ({
|
||||
Rows2: function Rows2(props: Record<string, unknown>) {
|
||||
return { type: 'Rows2', props }
|
||||
},
|
||||
Volume2: function Volume2(props: Record<string, unknown>) {
|
||||
return { type: 'Volume2', props }
|
||||
},
|
||||
VolumeX: function VolumeX(props: Record<string, unknown>) {
|
||||
return { type: 'VolumeX', props }
|
||||
},
|
||||
X: function X(props: Record<string, unknown>) {
|
||||
return { type: 'X', props }
|
||||
}
|
||||
|
||||
@@ -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 && <Pin className="mr-1 size-3 shrink-0 text-muted-foreground" aria-hidden />}
|
||||
<span className={`${TAB_LABEL_WIDTH_CLASSES} mr-1`}>{tabLabel}</span>
|
||||
{showAudioControl && (
|
||||
// Why: unlike close, stays visible on inactive tabs; finding the playing tab is the point.
|
||||
<button
|
||||
type="button"
|
||||
aria-label={toggleAudioMutedLabel}
|
||||
className="flex items-center justify-center w-4 h-4 rounded-sm shrink-0 text-muted-foreground hover:text-foreground hover:bg-muted"
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setBrowserTabAudioMuted(tab.id, !audioMuted)
|
||||
}}
|
||||
>
|
||||
{audioMuted ? <VolumeX className="size-3" /> : <Volume2 className="size-3" />}
|
||||
</button>
|
||||
)}
|
||||
{!isPinned && (
|
||||
<button
|
||||
className={`flex items-center justify-center w-4 h-4 rounded-sm shrink-0 ${
|
||||
@@ -275,6 +301,12 @@ export default function BrowserTab({
|
||||
? translate('auto.components.tab.bar.BrowserTab.c5aaee8c39', 'Unpin Tab')
|
||||
: translate('auto.components.tab.bar.BrowserTab.911542656f', 'Pin Tab')}
|
||||
</DropdownMenuItem>
|
||||
{showAudioControl ? (
|
||||
<DropdownMenuItem onSelect={() => setBrowserTabAudioMuted(tab.id, !audioMuted)}>
|
||||
{audioMuted ? <Volume2 className="size-3.5" /> : <VolumeX className="size-3.5" />}
|
||||
{toggleAudioMutedLabel}
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
<CopyTabIdMenuItem unifiedTabId={dragData.unifiedTabId} />
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onSelect={() => !isPinned && onClose()} disabled={isPinned}>
|
||||
|
||||
@@ -110,13 +110,21 @@ vi.mock('@/store', () => ({
|
||||
settings: null
|
||||
unreadTerminalTabs: Record<string, boolean>
|
||||
agentStatusByPaneKey: Record<string, never>
|
||||
browserPagesByWorkspace: Record<string, never>
|
||||
browserAudiblePageIds: Record<string, never>
|
||||
browserAudioMutedTabIds: Record<string, never>
|
||||
setBrowserTabAudioMuted: () => void
|
||||
}) => unknown
|
||||
) =>
|
||||
selector({
|
||||
openMarkdownPreview: vi.fn(),
|
||||
settings: null,
|
||||
unreadTerminalTabs: {},
|
||||
agentStatusByPaneKey: {}
|
||||
agentStatusByPaneKey: {},
|
||||
browserPagesByWorkspace: {},
|
||||
browserAudiblePageIds: {},
|
||||
browserAudioMutedTabIds: {},
|
||||
setBrowserTabAudioMuted: vi.fn()
|
||||
})
|
||||
}))
|
||||
|
||||
|
||||
@@ -76,6 +76,8 @@ export type IpcEventsHarnessOptions = {
|
||||
clientHostedBrowserRowsSnapshot?: ClientHostedBrowserRowsEvent[]
|
||||
/** Rejects the hydration round trip instead of resolving it. */
|
||||
clientHostedBrowserRowsSnapshotError?: Error
|
||||
/** Extra `window.api.browser` members, e.g. a listener capture for a channel under test. */
|
||||
browserApi?: Record<string, unknown>
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -233,6 +235,7 @@ export async function loadIpcEventsHarness(
|
||||
onClearDismissal: () => () => {}
|
||||
},
|
||||
browser: createApiNamespaceStub({
|
||||
...options.browserApi,
|
||||
onNavigationUpdate: (
|
||||
listener: (event: { browserPageId: string; url: string; title: string }) => void
|
||||
) => {
|
||||
|
||||
@@ -4,6 +4,7 @@ import { openUrlInTabBesideBrowserPage } from '@/lib/browser-page-tab-opening'
|
||||
import { redactKagiSessionToken } from '../../../../shared/browser-url'
|
||||
import { LOCAL_EXECUTION_HOST_ID } from '../../../../shared/execution-host'
|
||||
import { useAppStore } from '../../store'
|
||||
import { findPage } from '../../store/slices/browser-page-records'
|
||||
import {
|
||||
acquireBrowserAutomationVisibility,
|
||||
releaseBrowserAutomationVisibility
|
||||
@@ -62,6 +63,21 @@ export function registerBrowserStateIpcBridge(
|
||||
if (unsubscribeCertificateFailure) {
|
||||
unsubs.push(unsubscribeCertificateFailure)
|
||||
}
|
||||
// Why no runtime guard: only guests in this process report, and streamed pages have none here.
|
||||
const unsubscribeAudioState = window.api.browser.onAudioStateChanged?.(
|
||||
({ browserPageId, audible }) => {
|
||||
const store = useAppStore.getState()
|
||||
store.setBrowserPageAudible(browserPageId, audible)
|
||||
// Why: main reports each newly registered guest, and a new guest (eviction, crash) starts unmuted.
|
||||
const tabId = findPage(store.browserPagesByWorkspace, browserPageId)?.workspaceId
|
||||
if (tabId && store.browserAudioMutedTabIds[tabId]) {
|
||||
void window.api.browser.setPageAudioMuted({ browserPageId, muted: true })
|
||||
}
|
||||
}
|
||||
)
|
||||
if (unsubscribeAudioState) {
|
||||
unsubs.push(unsubscribeAudioState)
|
||||
}
|
||||
unsubs.push(
|
||||
window.api.browser.onNavigationUpdate(({ browserPageId, url, title }) => {
|
||||
if (isRuntimeEnvironmentActive()) {
|
||||
|
||||
@@ -74,6 +74,54 @@ describe('browser certificate failures while a runtime environment is active', (
|
||||
})
|
||||
})
|
||||
|
||||
type AudioStateEvent = { browserPageId: string; audible: boolean }
|
||||
|
||||
// A rebuilt guest (eviction, crash recovery, client-hosted rebind) starts unmuted; main's
|
||||
// registration report is the renderer's only cue to carry the tab's mute over to it. Local pages
|
||||
// (e.g. floating browser tabs) still report while a remote environment is active.
|
||||
describe('browser audio state', () => {
|
||||
it('re-applies a muted tab to each reporting page, even with a remote environment active', async () => {
|
||||
const setBrowserPageAudible = vi.fn()
|
||||
const setPageAudioMuted = vi.fn(() => Promise.resolve(true))
|
||||
let audioStateChanged: (event: AudioStateEvent) => void = () => {}
|
||||
const harness = await loadIpcEventsHarness(
|
||||
createHarnessStoreState({
|
||||
tabsByWorktree: {},
|
||||
remoteBrowserPageHandlesByPageId: {},
|
||||
browserPagesByWorkspace: {
|
||||
'tab-muted': [{ id: 'page-muted', workspaceId: 'tab-muted' }],
|
||||
'tab-loud': [{ id: 'page-loud', workspaceId: 'tab-loud' }]
|
||||
},
|
||||
browserAudioMutedTabIds: { 'tab-muted': true },
|
||||
setBrowserPageAudible,
|
||||
settings: {
|
||||
terminalFontSize: 13,
|
||||
experimentalNativeChat: false,
|
||||
activeRuntimeEnvironmentId: 'env-a'
|
||||
}
|
||||
}),
|
||||
{
|
||||
browserApi: {
|
||||
onAudioStateChanged: (listener: (event: AudioStateEvent) => void) => {
|
||||
audioStateChanged = listener
|
||||
return () => {}
|
||||
},
|
||||
setPageAudioMuted
|
||||
}
|
||||
}
|
||||
)
|
||||
harness.useIpcEvents()
|
||||
|
||||
audioStateChanged({ browserPageId: 'page-muted', audible: false })
|
||||
audioStateChanged({ browserPageId: 'page-loud', audible: true })
|
||||
|
||||
expect(setBrowserPageAudible).toHaveBeenCalledWith('page-muted', false)
|
||||
expect(setBrowserPageAudible).toHaveBeenCalledWith('page-loud', true)
|
||||
expect(setPageAudioMuted).toHaveBeenCalledTimes(1)
|
||||
expect(setPageAudioMuted).toHaveBeenCalledWith({ browserPageId: 'page-muted', muted: true })
|
||||
})
|
||||
})
|
||||
|
||||
function storeStateWithRuntime(
|
||||
setBrowserPageCertificateFailure: ReturnType<typeof vi.fn>,
|
||||
remoteBrowserPageHandlesByPageId: Record<string, unknown>
|
||||
|
||||
@@ -19702,7 +19702,9 @@
|
||||
"switchToChatView": "Continue in Chat UI"
|
||||
},
|
||||
"BrowserTab": {
|
||||
"closeOthers": "Close Others"
|
||||
"closeOthers": "Close Others",
|
||||
"muteTab": "Mute Tab",
|
||||
"unmuteTab": "Unmute Tab"
|
||||
},
|
||||
"EditorFileTabContextMenu": {
|
||||
"closeOthers": "Close Others"
|
||||
|
||||
@@ -19373,7 +19373,9 @@
|
||||
"switchToChatView": "Continuar en la interfaz de chat"
|
||||
},
|
||||
"BrowserTab": {
|
||||
"closeOthers": "Cerrar otras"
|
||||
"closeOthers": "Cerrar otras",
|
||||
"muteTab": "Silenciar pestaña",
|
||||
"unmuteTab": "Reactivar sonido de la pestaña"
|
||||
},
|
||||
"EditorFileTabContextMenu": {
|
||||
"closeOthers": "Cerrar otras"
|
||||
|
||||
@@ -19513,7 +19513,9 @@
|
||||
"switchToChatView": "Continuer dans l’interface de chat"
|
||||
},
|
||||
"BrowserTab": {
|
||||
"closeOthers": "Fermer les autres"
|
||||
"closeOthers": "Fermer les autres",
|
||||
"muteTab": "Couper le son de l'onglet",
|
||||
"unmuteTab": "Réactiver le son de l'onglet"
|
||||
},
|
||||
"EditorFileTabContextMenu": {
|
||||
"closeOthers": "Fermer les autres"
|
||||
|
||||
@@ -19407,7 +19407,9 @@
|
||||
"switchToChatView": "チャット UI で続ける"
|
||||
},
|
||||
"BrowserTab": {
|
||||
"closeOthers": "その他を閉じる"
|
||||
"closeOthers": "その他を閉じる",
|
||||
"muteTab": "タブをミュート",
|
||||
"unmuteTab": "タブのミュートを解除"
|
||||
},
|
||||
"EditorFileTabContextMenu": {
|
||||
"closeOthers": "その他を閉じる"
|
||||
|
||||
@@ -19407,7 +19407,9 @@
|
||||
"switchToChatView": "채팅 UI에서 계속하기"
|
||||
},
|
||||
"BrowserTab": {
|
||||
"closeOthers": "다른 탭 닫기"
|
||||
"closeOthers": "다른 탭 닫기",
|
||||
"muteTab": "탭 음소거",
|
||||
"unmuteTab": "탭 음소거 해제"
|
||||
},
|
||||
"EditorFileTabContextMenu": {
|
||||
"closeOthers": "다른 탭 닫기"
|
||||
|
||||
@@ -19936,7 +19936,9 @@
|
||||
"switchToChatView": "Alternar para visualização de chat"
|
||||
},
|
||||
"BrowserTab": {
|
||||
"closeOthers": "Fechar Outras"
|
||||
"closeOthers": "Fechar Outras",
|
||||
"muteTab": "Silenciar Aba",
|
||||
"unmuteTab": "Reativar Som da Aba"
|
||||
},
|
||||
"EditorFileTabContextMenu": {
|
||||
"closeOthers": "Fechar Outras"
|
||||
|
||||
@@ -19372,7 +19372,9 @@
|
||||
"switchToChatView": "在聊天界面中继续"
|
||||
},
|
||||
"BrowserTab": {
|
||||
"closeOthers": "关闭其他"
|
||||
"closeOthers": "关闭其他",
|
||||
"muteTab": "静音标签",
|
||||
"unmuteTab": "取消静音标签"
|
||||
},
|
||||
"EditorFileTabContextMenu": {
|
||||
"closeOthers": "关闭其他"
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import type { BrowserSlice, BrowserSliceGet, BrowserSliceSet } from './browser-slice-contract'
|
||||
import { omitRecordKeys } from '../worktrees/teardown/record-key-omission'
|
||||
|
||||
export function createBrowserAudioActions(
|
||||
set: BrowserSliceSet,
|
||||
get: BrowserSliceGet
|
||||
): Pick<BrowserSlice, 'setBrowserPageAudible' | 'setBrowserTabAudioMuted'> {
|
||||
return {
|
||||
setBrowserPageAudible: (pageId, audible) => {
|
||||
set((s) => {
|
||||
if (audible === Boolean(s.browserAudiblePageIds[pageId])) {
|
||||
return s
|
||||
}
|
||||
return {
|
||||
browserAudiblePageIds: audible
|
||||
? { ...s.browserAudiblePageIds, [pageId]: true }
|
||||
: omitRecordKeys(s.browserAudiblePageIds, [pageId])
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
setBrowserTabAudioMuted: (tabId, muted) => {
|
||||
set((s) => {
|
||||
if (muted === Boolean(s.browserAudioMutedTabIds[tabId])) {
|
||||
return s
|
||||
}
|
||||
return {
|
||||
browserAudioMutedTabIds: muted
|
||||
? { ...s.browserAudioMutedTabIds, [tabId]: true }
|
||||
: omitRecordKeys(s.browserAudioMutedTabIds, [tabId])
|
||||
}
|
||||
})
|
||||
for (const page of get().browserPagesByWorkspace[tabId] ?? []) {
|
||||
void window.api.browser.setPageAudioMuted({ browserPageId: page.id, muted })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -186,6 +186,7 @@ export function createBrowserCloseActions(
|
||||
recentlyClosedBrowserPagesByWorkspace: nextRecentlyClosedBrowserPagesByWorkspace,
|
||||
remoteBrowserPageHandlesByPageId: nextRemoteBrowserPageHandlesByPageId,
|
||||
browserCertificateFailuresByPageId: nextBrowserCertificateFailuresByPageId,
|
||||
browserAudioMutedTabIds: omitRecordKeys(s.browserAudioMutedTabIds, [tabId]),
|
||||
browserAnnotationsByPageId: nextBrowserAnnotationsByPageId,
|
||||
browserAnnotationMarkerIdsByPageId: nextBrowserAnnotationMarkerIdsByPageId
|
||||
}
|
||||
|
||||
@@ -123,6 +123,10 @@ export type BrowserSlice = {
|
||||
browserTabsByWorktree: Record<string, BrowserWorkspace[]>
|
||||
browserPagesByWorkspace: Record<string, BrowserPage[]>
|
||||
browserCertificateFailuresByPageId: Record<string, BrowserCertificateFailure>
|
||||
/** May precede the page record (client-hosted pages); main's false on guest teardown clears it. */
|
||||
browserAudiblePageIds: Record<string, true>
|
||||
/** Tab mute lives for the tab's life; each new guest of its pages is muted again. */
|
||||
browserAudioMutedTabIds: Record<string, true>
|
||||
browserAnnotationsByPageId: Record<string, BrowserPageAnnotation[]>
|
||||
browserAnnotationMarkerIdsByPageId: Record<string, string[]>
|
||||
remoteBrowserPageHandlesByPageId: Record<string, RemoteBrowserPageHandle>
|
||||
@@ -198,6 +202,8 @@ export type BrowserSlice = {
|
||||
pageId: string,
|
||||
failure: BrowserCertificateFailure | null
|
||||
) => void
|
||||
setBrowserPageAudible: (pageId: string, audible: boolean) => void
|
||||
setBrowserTabAudioMuted: (tabId: string, muted: boolean) => void
|
||||
setBrowserTabUrl: (pageId: string, url: string) => void
|
||||
setBrowserPageUrl: (pageId: string, url: string, options?: SetBrowserPageUrlOptions) => void
|
||||
setRemoteBrowserPageHandle: (pageId: string, handle: RemoteBrowserPageHandle) => void
|
||||
|
||||
@@ -9,6 +9,7 @@ import { createBrowserPageCreateActions } from './browser-page-create-actions'
|
||||
import { createBrowserPageConversionActions } from './browser-page-conversion-actions'
|
||||
import { createBrowserPageFocusActions } from './browser-page-focus-actions'
|
||||
import { createBrowserPageStateActions } from './browser-page-state-actions'
|
||||
import { createBrowserAudioActions } from './browser-audio-actions'
|
||||
import { createBrowserPageMetadataActions } from './browser-page-metadata-actions'
|
||||
import { createBrowserHydrationActions } from './browser-hydration-actions'
|
||||
import { createBrowserProfileListActions } from './browser-profile-list-actions'
|
||||
@@ -21,6 +22,8 @@ export const createBrowserSlice: StateCreator<AppState, [], [], BrowserSlice> =
|
||||
browserTabsByWorktree: {},
|
||||
browserPagesByWorkspace: {},
|
||||
browserCertificateFailuresByPageId: {},
|
||||
browserAudiblePageIds: {},
|
||||
browserAudioMutedTabIds: {},
|
||||
browserAnnotationsByPageId: {},
|
||||
browserAnnotationMarkerIdsByPageId: {},
|
||||
remoteBrowserPageHandlesByPageId: {},
|
||||
@@ -52,6 +55,7 @@ export const createBrowserSlice: StateCreator<AppState, [], [], BrowserSlice> =
|
||||
...createBrowserPageConversionActions(set, get),
|
||||
...createBrowserPageFocusActions(set, get),
|
||||
...createBrowserPageStateActions(set, get),
|
||||
...createBrowserAudioActions(set, get),
|
||||
...createBrowserPageMetadataActions(set, get),
|
||||
...createBrowserHydrationActions(set, get),
|
||||
...createBrowserProfileListActions(set, get),
|
||||
|
||||
Reference in New Issue
Block a user