mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 08:02:28 +00:00
Open target=_blank links and unnamed popups in new Orca tabs (#16720)
* feat(browser): open target=_blank links and unnamed popups in new Orca t - Treat target=_blank as a new-tab request matching browser behavior - Route unnamed, featureless window.open() calls to Orca tabs instead of native popups - Add rate limiting to prevent page-initiated tab loops - Inherit session profiles when opening links to maintain isolation boundaries * fix(browser): deny new-tab window.open when renderer is destroyed Move deny action outside conditional to ensure new-tab intents are safely rejected even if renderer vanishes mid-open, preventing native popup fallthrough. Add test coverage and simplify comments. * Share page-initiated tab budget across opener popup tree Prevent pages from bypassing the new-tab rate limit by chaining popup windows. The page-initiated tab quota is now shared by all popups in an opener tree (root + named children), so child windows inherit their root's budget instead of each getting a fresh allocation.
This commit is contained in:
@@ -64,7 +64,7 @@ describe('browser clicked-link routing', () => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('routes plain target=_blank links back into the current Orca tab', () => {
|
||||
it('routes plain target=_blank links into a new Orca tab', () => {
|
||||
const link = document.createElement('a')
|
||||
link.href = 'https://docs.example.com/guide'
|
||||
link.target = '_blank'
|
||||
@@ -73,9 +73,8 @@ describe('browser clicked-link routing', () => {
|
||||
|
||||
const { event, open } = clickLink(link)
|
||||
|
||||
expect(event.defaultPrevented).toBe(false)
|
||||
expect(open).not.toHaveBeenCalled()
|
||||
expect(link.getAttribute('target')).toBe('_self')
|
||||
expect(event.defaultPrevented).toBe(true)
|
||||
expect(open).toHaveBeenCalledWith('https://docs.example.com/guide', FOREGROUND_FRAME_NAME)
|
||||
})
|
||||
|
||||
it('routes the host-platform modifier without trusting an emulated guest user agent', () => {
|
||||
@@ -127,9 +126,11 @@ describe('browser clicked-link routing', () => {
|
||||
|
||||
expect(clickLink(cancelled).open).not.toHaveBeenCalled()
|
||||
expect(cancelled.getAttribute('target')).toBe('_blank')
|
||||
expect(clickLink(rewritten).open).not.toHaveBeenCalled()
|
||||
expect(rewritten.href).toBe('https://example.com/rewritten')
|
||||
expect(rewritten.getAttribute('target')).toBe('_self')
|
||||
// The page's own handler ran first, so routing must follow the rewritten href.
|
||||
expect(clickLink(rewritten).open).toHaveBeenCalledWith(
|
||||
'https://example.com/rewritten',
|
||||
FOREGROUND_FRAME_NAME
|
||||
)
|
||||
})
|
||||
|
||||
it('routes SVG links but leaves download links and links without href alone', () => {
|
||||
@@ -145,8 +146,10 @@ describe('browser clicked-link routing', () => {
|
||||
document.body.append(svgLink, areaDownload, noHref)
|
||||
installRouting()
|
||||
|
||||
expect(clickLink(svgLink).open).not.toHaveBeenCalled()
|
||||
expect(svgLink.getAttribute('target')).toBe('_self')
|
||||
expect(clickLink(svgLink).open).toHaveBeenCalledWith(
|
||||
'https://example.com/svg',
|
||||
FOREGROUND_FRAME_NAME
|
||||
)
|
||||
expect(clickLink(areaDownload).open).not.toHaveBeenCalled()
|
||||
expect(clickLink(noHref).open).not.toHaveBeenCalled()
|
||||
})
|
||||
@@ -195,7 +198,7 @@ describe('browser clicked-link routing', () => {
|
||||
expect(script).not.toContain('BrowserClickedLinkRoutingState')
|
||||
})
|
||||
|
||||
it('routes plain iframe target=_blank links into the top-level guest', () => {
|
||||
it('routes plain iframe target=_blank links into a new Orca tab', () => {
|
||||
const link = document.createElement('a')
|
||||
link.href = 'https://example.com/from-frame'
|
||||
link.target = '_blank'
|
||||
@@ -204,9 +207,8 @@ describe('browser clicked-link routing', () => {
|
||||
|
||||
const { event, open } = clickLink(link)
|
||||
|
||||
expect(event.defaultPrevented).toBe(false)
|
||||
expect(open).not.toHaveBeenCalled()
|
||||
expect(link.getAttribute('target')).toBe('_top')
|
||||
expect(event.defaultPrevented).toBe(true)
|
||||
expect(open).toHaveBeenCalledWith('https://example.com/from-frame', FOREGROUND_FRAME_NAME)
|
||||
})
|
||||
|
||||
it('routes explicit iframe new-tab gestures through one-use frame names', () => {
|
||||
|
||||
@@ -75,8 +75,9 @@ export function installBrowserClickedLinkRouting(
|
||||
const baseTarget = document.querySelector('base[target]')?.getAttribute('target') ?? ''
|
||||
const ownTarget = link.getAttribute('target')
|
||||
const effectiveTarget = (ownTarget === null ? baseTarget : ownTarget).trim().toLowerCase()
|
||||
const opensNewContext = middleClick || modifierClick
|
||||
if (!opensNewContext && effectiveTarget !== '_blank') {
|
||||
// target=_blank is a new-tab request, exactly as it is in every other
|
||||
// browser; the modifiers are the other two ways to ask for one.
|
||||
if (!(middleClick || modifierClick || effectiveTarget === '_blank')) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -96,13 +97,6 @@ export function installBrowserClickedLinkRouting(
|
||||
return
|
||||
}
|
||||
|
||||
if (!opensNewContext) {
|
||||
// Why: changing only the browsing context keeps Chromium's native anchor
|
||||
// navigation, including referrer policy, attribution, and history.
|
||||
link.setAttribute('target', '_self')
|
||||
return
|
||||
}
|
||||
|
||||
// Why: Electron reports direct link clicks and featureless window.open()
|
||||
// with the same disposition. The private frame name preserves that one
|
||||
// distinction without weakening OAuth popups that need window.opener.
|
||||
@@ -118,8 +112,8 @@ export function installBrowserClickedLinkRouting(
|
||||
}
|
||||
|
||||
/**
|
||||
* Keeps plain target=_blank clicks inside the top-level guest when Electron's
|
||||
* isolated-world API cannot target a child frame.
|
||||
* Same routing for links inside child frames, which Electron's isolated-world
|
||||
* API cannot reach, so this runs in the page world against a one-use token.
|
||||
*/
|
||||
export function installBrowserIframeClickedLinkRouting(
|
||||
frameName: string,
|
||||
@@ -161,8 +155,7 @@ export function installBrowserIframeClickedLinkRouting(
|
||||
const baseTarget = document.querySelector('base[target]')?.getAttribute('target') ?? ''
|
||||
const ownTarget = link.getAttribute('target')
|
||||
const effectiveTarget = (ownTarget === null ? baseTarget : ownTarget).trim().toLowerCase()
|
||||
const opensNewContext = middleClick || modifierClick
|
||||
if (!opensNewContext && effectiveTarget !== '_blank') {
|
||||
if (!(middleClick || modifierClick || effectiveTarget === '_blank')) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -182,13 +175,6 @@ export function installBrowserIframeClickedLinkRouting(
|
||||
return
|
||||
}
|
||||
|
||||
if (!opensNewContext) {
|
||||
// Why: WebContents isolated worlds only cover the main frame. Rewriting
|
||||
// to `_top` preserves native anchor semantics without opening a popup.
|
||||
link.setAttribute('target', '_top')
|
||||
return
|
||||
}
|
||||
|
||||
// Why: child-frame code runs in the page world, so each token is one-use.
|
||||
// A page that observes a real click cannot replay it to create more tabs.
|
||||
event.preventDefault()
|
||||
|
||||
@@ -0,0 +1,253 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const browserMocks = vi.hoisted(() => ({
|
||||
appGetPathMock: vi.fn(() => '/downloads'),
|
||||
menuBuildFromTemplateMock: vi.fn(),
|
||||
guestOffMock: vi.fn(),
|
||||
guestOnMock: vi.fn(),
|
||||
guestSetBackgroundThrottlingMock: vi.fn(),
|
||||
guestSetWindowOpenHandlerMock: vi.fn(),
|
||||
guestOpenDevToolsMock: vi.fn(),
|
||||
webContentsFromIdMock: vi.fn(),
|
||||
browserWindowFromWebContentsMock: vi.fn(),
|
||||
screenGetCursorScreenPointMock: vi.fn(() => ({ x: 0, y: 0 })),
|
||||
shellOpenExternalMock: vi.fn(),
|
||||
openPopupWithOriginBarMock: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
app: { getPath: browserMocks.appGetPathMock },
|
||||
BrowserWindow: { fromWebContents: browserMocks.browserWindowFromWebContentsMock },
|
||||
clipboard: { writeText: vi.fn() },
|
||||
shell: { openExternal: browserMocks.shellOpenExternalMock },
|
||||
Menu: { buildFromTemplate: browserMocks.menuBuildFromTemplateMock },
|
||||
screen: { getCursorScreenPoint: browserMocks.screenGetCursorScreenPointMock },
|
||||
webContents: { fromId: browserMocks.webContentsFromIdMock }
|
||||
}))
|
||||
|
||||
vi.mock('./popup-origin-bar-window', () => ({
|
||||
openPopupWithOriginBar: browserMocks.openPopupWithOriginBarMock
|
||||
}))
|
||||
|
||||
import { browserManager } from './browser-manager'
|
||||
import {
|
||||
createDownloadItem,
|
||||
getDownloadItemEventHandler,
|
||||
rendererWebContentsId,
|
||||
resetBrowserManagerMocks,
|
||||
resetBrowserManagerState
|
||||
} from './browser-manager-test-harness'
|
||||
|
||||
const {
|
||||
guestOffMock,
|
||||
guestOnMock,
|
||||
guestSetBackgroundThrottlingMock,
|
||||
guestSetWindowOpenHandlerMock,
|
||||
guestOpenDevToolsMock,
|
||||
webContentsFromIdMock,
|
||||
shellOpenExternalMock
|
||||
} = browserMocks
|
||||
|
||||
describe('browserManager popup child policies', () => {
|
||||
beforeEach(() => {
|
||||
resetBrowserManagerMocks(browserMocks)
|
||||
resetBrowserManagerState()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('attaches guest policies to created popup child windows', () => {
|
||||
const rendererSendMock = vi.fn()
|
||||
const childSetBackgroundThrottlingMock = vi.fn()
|
||||
const childSetWindowOpenHandlerMock = vi.fn()
|
||||
const childOnMock = vi.fn()
|
||||
const childOffMock = vi.fn()
|
||||
const childOpenDevToolsMock = vi.fn()
|
||||
const childGuest = {
|
||||
id: 4040,
|
||||
isDestroyed: vi.fn(() => false),
|
||||
getType: vi.fn(() => 'webview'),
|
||||
setBackgroundThrottling: childSetBackgroundThrottlingMock,
|
||||
setWindowOpenHandler: childSetWindowOpenHandlerMock,
|
||||
on: childOnMock,
|
||||
off: childOffMock,
|
||||
openDevTools: childOpenDevToolsMock
|
||||
}
|
||||
const guest = {
|
||||
id: 404,
|
||||
isDestroyed: vi.fn(() => false),
|
||||
getType: vi.fn(() => 'webview'),
|
||||
setBackgroundThrottling: guestSetBackgroundThrottlingMock,
|
||||
setWindowOpenHandler: guestSetWindowOpenHandlerMock,
|
||||
on: guestOnMock,
|
||||
off: guestOffMock,
|
||||
openDevTools: guestOpenDevToolsMock
|
||||
}
|
||||
webContentsFromIdMock.mockImplementation((id: number) => {
|
||||
if (id === guest.id) {
|
||||
return guest
|
||||
}
|
||||
if (id === rendererWebContentsId) {
|
||||
return { isDestroyed: vi.fn(() => false), send: rendererSendMock }
|
||||
}
|
||||
return null
|
||||
})
|
||||
|
||||
browserManager.attachGuestPolicies(guest as never)
|
||||
browserManager.registerGuest({
|
||||
browserPageId: 'browser-1',
|
||||
webContentsId: guest.id,
|
||||
rendererWebContentsId
|
||||
})
|
||||
|
||||
const didCreateWindowHandler = guestOnMock.mock.calls.find(
|
||||
([event]) => event === 'did-create-window'
|
||||
)?.[1] as ((window: { webContents: typeof childGuest }) => void) | undefined
|
||||
expect(didCreateWindowHandler).toBeTypeOf('function')
|
||||
|
||||
didCreateWindowHandler?.({ webContents: childGuest })
|
||||
|
||||
expect(childSetBackgroundThrottlingMock).toHaveBeenCalledWith(false)
|
||||
expect(childSetWindowOpenHandlerMock).toHaveBeenCalledTimes(1)
|
||||
expect(childOnMock.mock.calls.filter(([event]) => event === 'did-create-window')).toHaveLength(
|
||||
1
|
||||
)
|
||||
expect(childOnMock.mock.calls.filter(([event]) => event === 'will-navigate')).toHaveLength(1)
|
||||
expect(childOnMock.mock.calls.filter(([event]) => event === 'will-redirect')).toHaveLength(1)
|
||||
|
||||
const childWindowOpenHandler = childSetWindowOpenHandlerMock.mock.calls[0][0] as (details: {
|
||||
url: string
|
||||
}) => { action: 'allow' | 'deny' }
|
||||
expect(childWindowOpenHandler({ url: 'https://identity.example.com/login' })).toMatchObject({
|
||||
action: 'allow'
|
||||
})
|
||||
expect(childWindowOpenHandler({ url: 'file:///etc/passwd' })).toEqual({ action: 'deny' })
|
||||
expect(rendererSendMock).toHaveBeenCalledWith('browser:popup', {
|
||||
browserPageId: 'browser-1',
|
||||
origin: 'null',
|
||||
action: 'blocked'
|
||||
})
|
||||
browserManager.notifyPermissionDenied({
|
||||
guestWebContentsId: childGuest.id,
|
||||
permission: 'notifications',
|
||||
rawUrl: 'https://identity.example.com/login'
|
||||
})
|
||||
expect(rendererSendMock).toHaveBeenCalledWith('browser:permission-denied', {
|
||||
browserPageId: 'browser-1',
|
||||
permission: 'notifications',
|
||||
origin: 'https://identity.example.com'
|
||||
})
|
||||
|
||||
const childDidFailLoadHandler = childOnMock.mock.calls.find(
|
||||
([event]) => event === 'did-fail-load'
|
||||
)?.[1] as
|
||||
| ((
|
||||
event: Electron.Event,
|
||||
errorCode: number,
|
||||
errorDescription: string,
|
||||
validatedURL: string,
|
||||
isMainFrame: boolean
|
||||
) => void)
|
||||
| undefined
|
||||
childDidFailLoadHandler?.(
|
||||
{} as Electron.Event,
|
||||
-105,
|
||||
'Name not resolved',
|
||||
'https://identity.example.com/unavailable',
|
||||
true
|
||||
)
|
||||
expect(rendererSendMock).not.toHaveBeenCalledWith(
|
||||
'browser:guest-load-failed',
|
||||
expect.anything()
|
||||
)
|
||||
|
||||
const childDownloadItem = createDownloadItem()
|
||||
browserManager.handleGuestWillDownload({
|
||||
guestWebContentsId: childGuest.id,
|
||||
item: childDownloadItem
|
||||
})
|
||||
expect(rendererSendMock).toHaveBeenCalledWith(
|
||||
'browser:download-requested',
|
||||
expect.objectContaining({ browserPageId: 'browser-1' })
|
||||
)
|
||||
const childDownloadDoneHandler = getDownloadItemEventHandler(childDownloadItem, 'once', 'done')
|
||||
childDownloadDoneHandler?.({} as Electron.Event, 'completed')
|
||||
|
||||
const managerState = browserManager as unknown as {
|
||||
popupOwnerContextByGuestId: Map<number, unknown>
|
||||
}
|
||||
expect(managerState.popupOwnerContextByGuestId.has(childGuest.id)).toBe(true)
|
||||
|
||||
const cleanupChildOnMock = vi.fn()
|
||||
const cleanupChildGuest = {
|
||||
...childGuest,
|
||||
id: 4041,
|
||||
on: cleanupChildOnMock,
|
||||
off: vi.fn(),
|
||||
setBackgroundThrottling: vi.fn(),
|
||||
setWindowOpenHandler: vi.fn()
|
||||
}
|
||||
const childDidCreateWindowHandler = childOnMock.mock.calls.find(
|
||||
([event]) => event === 'did-create-window'
|
||||
)?.[1] as ((window: { webContents: typeof cleanupChildGuest }) => void) | undefined
|
||||
childDidCreateWindowHandler?.({ webContents: cleanupChildGuest })
|
||||
expect(managerState.popupOwnerContextByGuestId.has(cleanupChildGuest.id)).toBe(true)
|
||||
const cleanupChildWindowOpenHandler = cleanupChildGuest.setWindowOpenHandler.mock
|
||||
.calls[0][0] as (details: { url: string }) => { action: 'allow' | 'deny' }
|
||||
expect(
|
||||
cleanupChildWindowOpenHandler({ url: 'https://identity.example.com/continue' })
|
||||
).toMatchObject({ action: 'allow' })
|
||||
const cleanupChildDestroyedHandler = cleanupChildOnMock.mock.calls.find(
|
||||
([event]) => event === 'destroyed'
|
||||
)?.[1] as (() => void) | undefined
|
||||
cleanupChildDestroyedHandler?.()
|
||||
expect(managerState.popupOwnerContextByGuestId.has(cleanupChildGuest.id)).toBe(false)
|
||||
|
||||
const replacementGuest = {
|
||||
...guest,
|
||||
id: 405,
|
||||
on: vi.fn(),
|
||||
off: vi.fn(),
|
||||
setBackgroundThrottling: vi.fn(),
|
||||
setWindowOpenHandler: vi.fn()
|
||||
}
|
||||
webContentsFromIdMock.mockImplementation((id: number) => {
|
||||
if (id === guest.id) {
|
||||
return guest
|
||||
}
|
||||
if (id === replacementGuest.id) {
|
||||
return replacementGuest
|
||||
}
|
||||
if (id === rendererWebContentsId) {
|
||||
return { isDestroyed: vi.fn(() => false), send: rendererSendMock }
|
||||
}
|
||||
return null
|
||||
})
|
||||
browserManager.attachGuestPolicies(replacementGuest as never)
|
||||
browserManager.registerGuest({
|
||||
browserPageId: 'browser-1',
|
||||
webContentsId: replacementGuest.id,
|
||||
rendererWebContentsId
|
||||
})
|
||||
|
||||
expect(childWindowOpenHandler({ url: 'https://identity.example.com/next' })).toEqual({
|
||||
action: 'deny'
|
||||
})
|
||||
expect(shellOpenExternalMock).toHaveBeenCalledWith('https://identity.example.com/next')
|
||||
expect(managerState.popupOwnerContextByGuestId.has(childGuest.id)).toBe(false)
|
||||
|
||||
const childDestroyedHandler = childOnMock.mock.calls.find(
|
||||
([event]) => event === 'destroyed'
|
||||
)?.[1] as (() => void) | undefined
|
||||
childDestroyedHandler?.()
|
||||
expect(managerState.popupOwnerContextByGuestId.has(childGuest.id)).toBe(false)
|
||||
|
||||
browserManager.unregisterAll()
|
||||
|
||||
expect(childOffMock).toHaveBeenCalledWith('did-create-window', expect.any(Function))
|
||||
expect(childOffMock).toHaveBeenCalledWith('will-navigate', expect.any(Function))
|
||||
expect(childOffMock).toHaveBeenCalledWith('will-redirect', expect.any(Function))
|
||||
})
|
||||
})
|
||||
@@ -40,9 +40,8 @@ vi.mock('./popup-origin-bar-window', () => ({
|
||||
}))
|
||||
|
||||
import { browserManager } from './browser-manager'
|
||||
import { MAX_PAGE_INITIATED_TABS_PER_WINDOW } from './browser-page-initiated-tab-budget'
|
||||
import {
|
||||
createDownloadItem,
|
||||
getDownloadItemEventHandler,
|
||||
rendererWebContentsId,
|
||||
resetBrowserManagerMocks,
|
||||
resetBrowserManagerState
|
||||
@@ -169,11 +168,12 @@ describe('browserManager', () => {
|
||||
expect(rendererSendMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps featureless window.open popups in-app for every disposition', () => {
|
||||
it('keeps opener-dependent window.open popups in-app for every disposition', () => {
|
||||
// Regression guard for the reverted #8332: gating the allow on
|
||||
// disposition === 'new-window' silently broke featureless window.open()
|
||||
// OAuth flows (disposition 'foreground-tab'), whose returned handle must
|
||||
// stay live. Disposition is a UX hint, not a trust signal.
|
||||
// OAuth flows, whose returned handle must stay live. A named target, a
|
||||
// features string, and a blank URL each mark such a flow, so all three keep
|
||||
// a real child window no matter which disposition Chromium reports.
|
||||
const guest = {
|
||||
id: 140,
|
||||
isDestroyed: vi.fn(() => false),
|
||||
@@ -199,14 +199,186 @@ describe('browserManager', () => {
|
||||
features: string
|
||||
disposition: string
|
||||
}) => { action: 'allow' | 'deny' }
|
||||
const openerDependentOpens = [
|
||||
{ url: 'https://sso.example.com/auth', frameName: 'ssoWindow', features: '' },
|
||||
{ url: 'https://sso.example.com/auth', frameName: '', features: 'width=500,height=600' },
|
||||
{ url: 'about:blank', frameName: '', features: '' }
|
||||
]
|
||||
for (const disposition of ['foreground-tab', 'background-tab', 'new-window']) {
|
||||
expect(
|
||||
handler({ url: 'https://sso.example.com/auth', frameName: '', features: '', disposition })
|
||||
).toMatchObject({ action: 'allow' })
|
||||
for (const open of openerDependentOpens) {
|
||||
expect(handler({ ...open, disposition })).toMatchObject({ action: 'allow' })
|
||||
}
|
||||
}
|
||||
expect(shellOpenExternalMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('routes unnamed featureless window.open safely, including after renderer destruction', () => {
|
||||
const rendererSendMock = vi.fn()
|
||||
let rendererDestroyed = false
|
||||
const guest = {
|
||||
id: 142,
|
||||
isDestroyed: vi.fn(() => false),
|
||||
getType: vi.fn(() => 'webview'),
|
||||
setBackgroundThrottling: guestSetBackgroundThrottlingMock,
|
||||
setWindowOpenHandler: guestSetWindowOpenHandlerMock,
|
||||
on: guestOnMock,
|
||||
off: guestOffMock,
|
||||
openDevTools: guestOpenDevToolsMock
|
||||
}
|
||||
webContentsFromIdMock.mockImplementation((id: number) => {
|
||||
if (id === guest.id) {
|
||||
return guest
|
||||
}
|
||||
if (id === rendererWebContentsId) {
|
||||
return { isDestroyed: vi.fn(() => rendererDestroyed), send: rendererSendMock }
|
||||
}
|
||||
return null
|
||||
})
|
||||
|
||||
browserManager.attachGuestPolicies(guest as never)
|
||||
browserManager.registerGuest({
|
||||
browserPageId: 'browser-1',
|
||||
webContentsId: guest.id,
|
||||
rendererWebContentsId
|
||||
})
|
||||
|
||||
const handler = guestSetWindowOpenHandlerMock.mock.calls[0][0] as (details: {
|
||||
url: string
|
||||
frameName: string
|
||||
features: string
|
||||
disposition: string
|
||||
}) => { action: 'allow' | 'deny' }
|
||||
for (const disposition of ['foreground-tab', 'background-tab']) {
|
||||
expect(
|
||||
handler({
|
||||
url: 'https://docs.example.com/guide',
|
||||
frameName: '',
|
||||
features: '',
|
||||
disposition
|
||||
})
|
||||
).toEqual({ action: 'deny' })
|
||||
}
|
||||
|
||||
expect(rendererSendMock).toHaveBeenCalledWith('browser:open-link-in-orca-tab', {
|
||||
browserPageId: 'browser-1',
|
||||
url: 'https://docs.example.com/guide'
|
||||
})
|
||||
expect(rendererSendMock).toHaveBeenCalledWith('browser:popup', {
|
||||
browserPageId: 'browser-1',
|
||||
origin: 'https://docs.example.com',
|
||||
action: 'opened-in-orca'
|
||||
})
|
||||
expect(openPopupWithOriginBarMock).not.toHaveBeenCalled()
|
||||
expect(shellOpenExternalMock).not.toHaveBeenCalled()
|
||||
rendererDestroyed = true
|
||||
expect(
|
||||
handler({
|
||||
url: 'https://docs.example.com/guide',
|
||||
frameName: '',
|
||||
features: '',
|
||||
disposition: 'foreground-tab'
|
||||
})
|
||||
).toEqual({ action: 'deny' })
|
||||
expect(openPopupWithOriginBarMock).not.toHaveBeenCalled()
|
||||
expect(shellOpenExternalMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('shares the page-initiated tab budget across the whole opener popup tree', () => {
|
||||
const rendererSendMock = vi.fn()
|
||||
const guest = {
|
||||
id: 150,
|
||||
isDestroyed: vi.fn(() => false),
|
||||
getType: vi.fn(() => 'webview'),
|
||||
setBackgroundThrottling: guestSetBackgroundThrottlingMock,
|
||||
setWindowOpenHandler: guestSetWindowOpenHandlerMock,
|
||||
on: guestOnMock,
|
||||
once: vi.fn(),
|
||||
off: guestOffMock,
|
||||
openDevTools: guestOpenDevToolsMock
|
||||
}
|
||||
const guestsById = new Map<number, unknown>([[guest.id, guest]])
|
||||
webContentsFromIdMock.mockImplementation((id: number) => {
|
||||
if (id === rendererWebContentsId) {
|
||||
return { isDestroyed: vi.fn(() => false), send: rendererSendMock }
|
||||
}
|
||||
return guestsById.get(id) ?? null
|
||||
})
|
||||
|
||||
browserManager.attachGuestPolicies(guest as never)
|
||||
browserManager.registerGuest({
|
||||
browserPageId: 'browser-1',
|
||||
webContentsId: guest.id,
|
||||
rendererWebContentsId
|
||||
})
|
||||
|
||||
type WindowOpenHandler = (details: {
|
||||
url: string
|
||||
frameName: string
|
||||
features: string
|
||||
disposition: string
|
||||
}) => {
|
||||
action: 'allow' | 'deny'
|
||||
createWindow?: (options: Record<string, never>) => unknown
|
||||
}
|
||||
const handler = guestSetWindowOpenHandlerMock.mock.calls[0][0] as WindowOpenHandler
|
||||
|
||||
// A named child popup keeps a real child window; each one used to start with a fresh budget.
|
||||
const openNamedChild = (index: number): WindowOpenHandler => {
|
||||
const childSetWindowOpenHandlerMock = vi.fn()
|
||||
const child = {
|
||||
id: 1500 + index,
|
||||
isDestroyed: vi.fn(() => false),
|
||||
getType: vi.fn(() => 'webview'),
|
||||
setBackgroundThrottling: vi.fn(),
|
||||
setWindowOpenHandler: childSetWindowOpenHandlerMock,
|
||||
on: vi.fn(),
|
||||
once: vi.fn(),
|
||||
off: vi.fn(),
|
||||
openDevTools: vi.fn()
|
||||
}
|
||||
guestsById.set(child.id, child)
|
||||
openPopupWithOriginBarMock.mockReturnValueOnce({
|
||||
contentWebContents: child,
|
||||
close: vi.fn(),
|
||||
onClosed: vi.fn()
|
||||
})
|
||||
const result = handler({
|
||||
url: `https://sso.example.com/child-${index}`,
|
||||
frameName: `child-${index}`,
|
||||
features: '',
|
||||
disposition: 'new-window'
|
||||
})
|
||||
expect(result).toMatchObject({ action: 'allow' })
|
||||
result.createWindow?.({})
|
||||
return childSetWindowOpenHandlerMock.mock.calls[0][0] as WindowOpenHandler
|
||||
}
|
||||
|
||||
const childHandlers = [openNamedChild(0), openNamedChild(1), openNamedChild(2)]
|
||||
for (const [childIndex, childHandler] of childHandlers.entries()) {
|
||||
for (let openIndex = 0; openIndex < MAX_PAGE_INITIATED_TABS_PER_WINDOW; openIndex++) {
|
||||
expect(
|
||||
childHandler({
|
||||
url: `https://docs.example.com/${childIndex}-${openIndex}`,
|
||||
frameName: '',
|
||||
features: '',
|
||||
disposition: 'foreground-tab'
|
||||
})
|
||||
).toEqual({ action: 'deny' })
|
||||
}
|
||||
}
|
||||
|
||||
const routedTabs = rendererSendMock.mock.calls.filter(
|
||||
([channel]) => channel === 'browser:open-link-in-orca-tab'
|
||||
)
|
||||
expect(routedTabs).toHaveLength(MAX_PAGE_INITIATED_TABS_PER_WINDOW)
|
||||
expect(rendererSendMock).toHaveBeenCalledWith('browser:popup', {
|
||||
browserPageId: 'browser-1',
|
||||
origin: 'https://docs.example.com',
|
||||
action: 'blocked'
|
||||
})
|
||||
expect(openPopupWithOriginBarMock).toHaveBeenCalledTimes(childHandlers.length)
|
||||
})
|
||||
|
||||
it('keeps plain links current and routes explicit new-tab gestures to Orca tabs', async () => {
|
||||
const rendererSendMock = vi.fn()
|
||||
const executeJavaScriptInIsolatedWorldMock = vi.fn().mockResolvedValue(undefined)
|
||||
@@ -589,197 +761,4 @@ describe('browserManager', () => {
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('attaches guest policies to created popup child windows', () => {
|
||||
const rendererSendMock = vi.fn()
|
||||
const childSetBackgroundThrottlingMock = vi.fn()
|
||||
const childSetWindowOpenHandlerMock = vi.fn()
|
||||
const childOnMock = vi.fn()
|
||||
const childOffMock = vi.fn()
|
||||
const childOpenDevToolsMock = vi.fn()
|
||||
const childGuest = {
|
||||
id: 4040,
|
||||
isDestroyed: vi.fn(() => false),
|
||||
getType: vi.fn(() => 'webview'),
|
||||
setBackgroundThrottling: childSetBackgroundThrottlingMock,
|
||||
setWindowOpenHandler: childSetWindowOpenHandlerMock,
|
||||
on: childOnMock,
|
||||
off: childOffMock,
|
||||
openDevTools: childOpenDevToolsMock
|
||||
}
|
||||
const guest = {
|
||||
id: 404,
|
||||
isDestroyed: vi.fn(() => false),
|
||||
getType: vi.fn(() => 'webview'),
|
||||
setBackgroundThrottling: guestSetBackgroundThrottlingMock,
|
||||
setWindowOpenHandler: guestSetWindowOpenHandlerMock,
|
||||
on: guestOnMock,
|
||||
off: guestOffMock,
|
||||
openDevTools: guestOpenDevToolsMock
|
||||
}
|
||||
webContentsFromIdMock.mockImplementation((id: number) => {
|
||||
if (id === guest.id) {
|
||||
return guest
|
||||
}
|
||||
if (id === rendererWebContentsId) {
|
||||
return { isDestroyed: vi.fn(() => false), send: rendererSendMock }
|
||||
}
|
||||
return null
|
||||
})
|
||||
|
||||
browserManager.attachGuestPolicies(guest as never)
|
||||
browserManager.registerGuest({
|
||||
browserPageId: 'browser-1',
|
||||
webContentsId: guest.id,
|
||||
rendererWebContentsId
|
||||
})
|
||||
|
||||
const didCreateWindowHandler = guestOnMock.mock.calls.find(
|
||||
([event]) => event === 'did-create-window'
|
||||
)?.[1] as ((window: { webContents: typeof childGuest }) => void) | undefined
|
||||
expect(didCreateWindowHandler).toBeTypeOf('function')
|
||||
|
||||
didCreateWindowHandler?.({ webContents: childGuest })
|
||||
|
||||
expect(childSetBackgroundThrottlingMock).toHaveBeenCalledWith(false)
|
||||
expect(childSetWindowOpenHandlerMock).toHaveBeenCalledTimes(1)
|
||||
expect(childOnMock.mock.calls.filter(([event]) => event === 'did-create-window')).toHaveLength(
|
||||
1
|
||||
)
|
||||
expect(childOnMock.mock.calls.filter(([event]) => event === 'will-navigate')).toHaveLength(1)
|
||||
expect(childOnMock.mock.calls.filter(([event]) => event === 'will-redirect')).toHaveLength(1)
|
||||
|
||||
const childWindowOpenHandler = childSetWindowOpenHandlerMock.mock.calls[0][0] as (details: {
|
||||
url: string
|
||||
}) => { action: 'allow' | 'deny' }
|
||||
expect(childWindowOpenHandler({ url: 'https://identity.example.com/login' })).toMatchObject({
|
||||
action: 'allow'
|
||||
})
|
||||
expect(childWindowOpenHandler({ url: 'file:///etc/passwd' })).toEqual({ action: 'deny' })
|
||||
expect(rendererSendMock).toHaveBeenCalledWith('browser:popup', {
|
||||
browserPageId: 'browser-1',
|
||||
origin: 'null',
|
||||
action: 'blocked'
|
||||
})
|
||||
browserManager.notifyPermissionDenied({
|
||||
guestWebContentsId: childGuest.id,
|
||||
permission: 'notifications',
|
||||
rawUrl: 'https://identity.example.com/login'
|
||||
})
|
||||
expect(rendererSendMock).toHaveBeenCalledWith('browser:permission-denied', {
|
||||
browserPageId: 'browser-1',
|
||||
permission: 'notifications',
|
||||
origin: 'https://identity.example.com'
|
||||
})
|
||||
|
||||
const childDidFailLoadHandler = childOnMock.mock.calls.find(
|
||||
([event]) => event === 'did-fail-load'
|
||||
)?.[1] as
|
||||
| ((
|
||||
event: Electron.Event,
|
||||
errorCode: number,
|
||||
errorDescription: string,
|
||||
validatedURL: string,
|
||||
isMainFrame: boolean
|
||||
) => void)
|
||||
| undefined
|
||||
childDidFailLoadHandler?.(
|
||||
{} as Electron.Event,
|
||||
-105,
|
||||
'Name not resolved',
|
||||
'https://identity.example.com/unavailable',
|
||||
true
|
||||
)
|
||||
expect(rendererSendMock).not.toHaveBeenCalledWith(
|
||||
'browser:guest-load-failed',
|
||||
expect.anything()
|
||||
)
|
||||
|
||||
const childDownloadItem = createDownloadItem()
|
||||
browserManager.handleGuestWillDownload({
|
||||
guestWebContentsId: childGuest.id,
|
||||
item: childDownloadItem
|
||||
})
|
||||
expect(rendererSendMock).toHaveBeenCalledWith(
|
||||
'browser:download-requested',
|
||||
expect.objectContaining({ browserPageId: 'browser-1' })
|
||||
)
|
||||
const childDownloadDoneHandler = getDownloadItemEventHandler(childDownloadItem, 'once', 'done')
|
||||
childDownloadDoneHandler?.({} as Electron.Event, 'completed')
|
||||
|
||||
const managerState = browserManager as unknown as {
|
||||
popupOwnerContextByGuestId: Map<number, unknown>
|
||||
}
|
||||
expect(managerState.popupOwnerContextByGuestId.has(childGuest.id)).toBe(true)
|
||||
|
||||
const cleanupChildOnMock = vi.fn()
|
||||
const cleanupChildGuest = {
|
||||
...childGuest,
|
||||
id: 4041,
|
||||
on: cleanupChildOnMock,
|
||||
off: vi.fn(),
|
||||
setBackgroundThrottling: vi.fn(),
|
||||
setWindowOpenHandler: vi.fn()
|
||||
}
|
||||
const childDidCreateWindowHandler = childOnMock.mock.calls.find(
|
||||
([event]) => event === 'did-create-window'
|
||||
)?.[1] as ((window: { webContents: typeof cleanupChildGuest }) => void) | undefined
|
||||
childDidCreateWindowHandler?.({ webContents: cleanupChildGuest })
|
||||
expect(managerState.popupOwnerContextByGuestId.has(cleanupChildGuest.id)).toBe(true)
|
||||
const cleanupChildWindowOpenHandler = cleanupChildGuest.setWindowOpenHandler.mock
|
||||
.calls[0][0] as (details: { url: string }) => { action: 'allow' | 'deny' }
|
||||
expect(
|
||||
cleanupChildWindowOpenHandler({ url: 'https://identity.example.com/continue' })
|
||||
).toMatchObject({ action: 'allow' })
|
||||
const cleanupChildDestroyedHandler = cleanupChildOnMock.mock.calls.find(
|
||||
([event]) => event === 'destroyed'
|
||||
)?.[1] as (() => void) | undefined
|
||||
cleanupChildDestroyedHandler?.()
|
||||
expect(managerState.popupOwnerContextByGuestId.has(cleanupChildGuest.id)).toBe(false)
|
||||
|
||||
const replacementGuest = {
|
||||
...guest,
|
||||
id: 405,
|
||||
on: vi.fn(),
|
||||
off: vi.fn(),
|
||||
setBackgroundThrottling: vi.fn(),
|
||||
setWindowOpenHandler: vi.fn()
|
||||
}
|
||||
webContentsFromIdMock.mockImplementation((id: number) => {
|
||||
if (id === guest.id) {
|
||||
return guest
|
||||
}
|
||||
if (id === replacementGuest.id) {
|
||||
return replacementGuest
|
||||
}
|
||||
if (id === rendererWebContentsId) {
|
||||
return { isDestroyed: vi.fn(() => false), send: rendererSendMock }
|
||||
}
|
||||
return null
|
||||
})
|
||||
browserManager.attachGuestPolicies(replacementGuest as never)
|
||||
browserManager.registerGuest({
|
||||
browserPageId: 'browser-1',
|
||||
webContentsId: replacementGuest.id,
|
||||
rendererWebContentsId
|
||||
})
|
||||
|
||||
expect(childWindowOpenHandler({ url: 'https://identity.example.com/next' })).toEqual({
|
||||
action: 'deny'
|
||||
})
|
||||
expect(shellOpenExternalMock).toHaveBeenCalledWith('https://identity.example.com/next')
|
||||
expect(managerState.popupOwnerContextByGuestId.has(childGuest.id)).toBe(false)
|
||||
|
||||
const childDestroyedHandler = childOnMock.mock.calls.find(
|
||||
([event]) => event === 'destroyed'
|
||||
)?.[1] as (() => void) | undefined
|
||||
childDestroyedHandler?.()
|
||||
expect(managerState.popupOwnerContextByGuestId.has(childGuest.id)).toBe(false)
|
||||
|
||||
browserManager.unregisterAll()
|
||||
|
||||
expect(childOffMock).toHaveBeenCalledWith('did-create-window', expect.any(Function))
|
||||
expect(childOffMock).toHaveBeenCalledWith('will-navigate', expect.any(Function))
|
||||
expect(childOffMock).toHaveBeenCalledWith('will-redirect', expect.any(Function))
|
||||
})
|
||||
})
|
||||
|
||||
@@ -43,6 +43,11 @@ import {
|
||||
buildBrowserClickedLinkRoutingScript,
|
||||
buildBrowserIframeClickedLinkRoutingScript
|
||||
} from './browser-clicked-link-routing'
|
||||
import {
|
||||
createPageInitiatedTabBudget,
|
||||
type PageInitiatedTabBudget
|
||||
} from './browser-page-initiated-tab-budget'
|
||||
import { isNewBrowserTabPopupIntent } from './browser-popup-new-tab-intent'
|
||||
import { cleanElectronUserAgent } from './browser-session-ua'
|
||||
import { getBrowserSessionUserAgentMode } from './browser-session-user-agent-mode'
|
||||
import { googleAuthUserAgent, isGoogleAuthUrl } from './browser-google-auth-ua'
|
||||
@@ -233,6 +238,8 @@ export class BrowserManager {
|
||||
// Why: reverse map gives O(1) guest→tab lookups on every mouse/load/permission/popup event.
|
||||
private readonly tabIdByWebContentsId = new Map<number, string>()
|
||||
private readonly popupOwnerContextByGuestId = new Map<number, PopupOwnerContext>()
|
||||
// Why: keyed by the opener tree's root so named child popups can't each mint a fresh tab quota.
|
||||
private readonly pageInitiatedTabBudgetByRootGuestId = new Map<number, PageInitiatedTabBudget>()
|
||||
// Why: guests are keyed by page id but renderer visibility by workspace id; bridge the mismatch to activate the right tab before capture.
|
||||
private readonly workspaceIdByPageId = new Map<string, string>()
|
||||
private readonly sessionProfileIdByPageId = new Map<string, string | null>()
|
||||
@@ -394,6 +401,16 @@ export class BrowserManager {
|
||||
return null
|
||||
}
|
||||
|
||||
/** Shared across the whole opener tree, so a chain of popups draws from one budget. */
|
||||
private tryConsumePageInitiatedTab(rootGuestWebContentsId: number): boolean {
|
||||
let budget = this.pageInitiatedTabBudgetByRootGuestId.get(rootGuestWebContentsId)
|
||||
if (!budget) {
|
||||
budget = createPageInitiatedTabBudget()
|
||||
this.pageInitiatedTabBudgetByRootGuestId.set(rootGuestWebContentsId, budget)
|
||||
}
|
||||
return budget.tryConsume(Date.now())
|
||||
}
|
||||
|
||||
private resolveRendererForBrowserTab(browserTabId: string): Electron.WebContents | null {
|
||||
const rendererWebContentsId = this.rendererWebContentsIdByTabId.get(browserTabId)
|
||||
if (!rendererWebContentsId) {
|
||||
@@ -758,8 +775,9 @@ export class BrowserManager {
|
||||
this.attachGuestPolicies(window.webContents, this.resolvePopupOwnerContext(guest.id))
|
||||
}
|
||||
guest.on('did-create-window', handleDidCreateWindow)
|
||||
guest.setWindowOpenHandler(({ url, frameName }) => {
|
||||
const browserTabId = this.resolveBrowserTabIdForGuestWebContentsId(guest.id)
|
||||
guest.setWindowOpenHandler(({ url, frameName, disposition, features }) => {
|
||||
const ownerContext = this.resolvePopupOwnerContext(guest.id)
|
||||
const browserTabId = ownerContext?.browserTabId ?? null
|
||||
const browserUrl = normalizeBrowserNavigationUrl(url)
|
||||
const externalUrl = normalizeExternalBrowserUrl(url)
|
||||
const expectedClickedLinkFrameName = this.clickedLinkFrameNameByGuestId.get(guest.id)
|
||||
@@ -784,6 +802,33 @@ export class BrowserManager {
|
||||
return { action: 'deny' }
|
||||
}
|
||||
|
||||
// Why: an unnamed, featureless window.open() is Chromium's own new-tab shape, so an Orca tab is
|
||||
// the honest presentation; a floating origin-bar window is not. Opener-dependent shapes are
|
||||
// excluded by isNewBrowserTabPopupIntent and still get a real child window below.
|
||||
if (
|
||||
ownerContext &&
|
||||
externalUrl &&
|
||||
isNewBrowserTabPopupIntent({ frameName, disposition, features })
|
||||
) {
|
||||
// Why: one activation lets a page loop window.open, and each routed tab persists into
|
||||
// workspace session state, so it survives the quit that used to clear popup windows.
|
||||
if (!this.tryConsumePageInitiatedTab(ownerContext.rootGuestWebContentsId)) {
|
||||
this.forwardOrQueuePopupEvent(guest.id, {
|
||||
origin: safeOrigin(externalUrl),
|
||||
action: 'blocked'
|
||||
})
|
||||
return { action: 'deny' }
|
||||
}
|
||||
if (this.openLinkInOrcaTab(ownerContext.browserTabId, externalUrl)) {
|
||||
this.forwardOrQueuePopupEvent(guest.id, {
|
||||
origin: safeOrigin(externalUrl),
|
||||
action: 'opened-in-orca'
|
||||
})
|
||||
}
|
||||
// Why: a recognized new-tab intent must never fall through to a native popup if its renderer vanished mid-open.
|
||||
return { action: 'deny' }
|
||||
}
|
||||
|
||||
// Why: file URLs are fine for in-pane previews, but must not spawn native child windows targeting local paths.
|
||||
const canOpenAsChild = Boolean(externalUrl || browserUrl === ORCA_BROWSER_BLANK_URL)
|
||||
if (browserTabId && canOpenAsChild) {
|
||||
@@ -1243,6 +1288,7 @@ export class BrowserManager {
|
||||
this.clickedLinkFrameNameByGuestId.delete(guestWebContentsId)
|
||||
this.offscreenGuestIds.delete(guestWebContentsId)
|
||||
this.popupOwnerContextByGuestId.delete(guestWebContentsId)
|
||||
this.pageInitiatedTabBudgetByRootGuestId.delete(guestWebContentsId)
|
||||
this.authUserAgentOverrideStateByGuestId.delete(guestWebContentsId)
|
||||
this.pendingNavigationByGuestId.delete(guestWebContentsId)
|
||||
// Why: a popup must stop inheriting authorization the moment its owner retires, before Chromium destroys the child.
|
||||
@@ -1444,6 +1490,7 @@ export class BrowserManager {
|
||||
this.clickedLinkFrameNameByGuestId.clear()
|
||||
this.tabIdByWebContentsId.clear()
|
||||
this.popupOwnerContextByGuestId.clear()
|
||||
this.pageInitiatedTabBudgetByRootGuestId.clear()
|
||||
this.worktreeIdByTabId.clear()
|
||||
this.sessionProfileIdByPageId.clear()
|
||||
this.userAgentModeByPageId.clear()
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
createPageInitiatedTabBudget,
|
||||
MAX_PAGE_INITIATED_TABS_PER_WINDOW,
|
||||
PAGE_INITIATED_TAB_WINDOW_MS
|
||||
} from './browser-page-initiated-tab-budget'
|
||||
|
||||
describe('page-initiated tab budget', () => {
|
||||
it('absorbs a window.open loop fired from a single activation', () => {
|
||||
const budget = createPageInitiatedTabBudget()
|
||||
const granted = Array.from({ length: 12 }, () => budget.tryConsume(1_000)).filter(Boolean)
|
||||
|
||||
expect(granted).toHaveLength(MAX_PAGE_INITIATED_TABS_PER_WINDOW)
|
||||
})
|
||||
|
||||
it('refills once the rolling window has passed, so real browsing is unaffected', () => {
|
||||
const budget = createPageInitiatedTabBudget()
|
||||
for (let i = 0; i < MAX_PAGE_INITIATED_TABS_PER_WINDOW; i++) {
|
||||
expect(budget.tryConsume(1_000)).toBe(true)
|
||||
}
|
||||
expect(budget.tryConsume(1_000 + PAGE_INITIATED_TAB_WINDOW_MS - 1)).toBe(false)
|
||||
expect(budget.tryConsume(1_000 + PAGE_INITIATED_TAB_WINDOW_MS)).toBe(true)
|
||||
})
|
||||
|
||||
it('slides rather than resetting, so a paced flood cannot outrun the cap', () => {
|
||||
const budget = createPageInitiatedTabBudget(2, 1_000)
|
||||
|
||||
expect(budget.tryConsume(0)).toBe(true)
|
||||
expect(budget.tryConsume(900)).toBe(true)
|
||||
// The first grant has aged out by 1_000 but the second has not.
|
||||
expect(budget.tryConsume(1_000)).toBe(true)
|
||||
expect(budget.tryConsume(1_100)).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,26 @@
|
||||
/** Bounded so one hostile click cannot plant an unbounded number of tabs. */
|
||||
export const MAX_PAGE_INITIATED_TABS_PER_WINDOW = 4
|
||||
export const PAGE_INITIATED_TAB_WINDOW_MS = 2_000
|
||||
|
||||
export type PageInitiatedTabBudget = {
|
||||
/** Records the grant when it returns true; call only when the tab is actually being opened. */
|
||||
tryConsume: (now: number) => boolean
|
||||
}
|
||||
|
||||
/** Rolling window, so it absorbs a same-tick `window.open` loop without capping real browsing. */
|
||||
export function createPageInitiatedTabBudget(
|
||||
maxPerWindow = MAX_PAGE_INITIATED_TABS_PER_WINDOW,
|
||||
windowMs = PAGE_INITIATED_TAB_WINDOW_MS
|
||||
): PageInitiatedTabBudget {
|
||||
let grants: number[] = []
|
||||
return {
|
||||
tryConsume: (now) => {
|
||||
grants = grants.filter((grantedAt) => now - grantedAt < windowMs)
|
||||
if (grants.length >= maxPerWindow) {
|
||||
return false
|
||||
}
|
||||
grants.push(now)
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* Whether a `window.open()` asks for a new tab rather than a popup window.
|
||||
*
|
||||
* Orca answers a tab by denying, which hands the page `null`, so named and featured opens — whose
|
||||
* flow may use that handle — stay popups.
|
||||
*/
|
||||
export function isNewBrowserTabPopupIntent(details: {
|
||||
frameName: string
|
||||
disposition: string
|
||||
features: string
|
||||
}): boolean {
|
||||
return (
|
||||
details.frameName === '' &&
|
||||
details.features.trim() === '' &&
|
||||
(details.disposition === 'foreground-tab' || details.disposition === 'background-tab')
|
||||
)
|
||||
}
|
||||
@@ -89,7 +89,20 @@ export function registerBrowserStateIpcBridge(
|
||||
if (!sourcePage || getRuntimeEnvironmentIdForWorktree(store, sourcePage.worktreeId)) {
|
||||
return
|
||||
}
|
||||
store.createBrowserTab(sourcePage.worktreeId, url, { title: url })
|
||||
// Why: the link inherits the opener's cookie jar. Falling back to the default profile would let
|
||||
// a page in an isolated session hand its links to the default one, silently crossing profiles.
|
||||
const sourceTab = (store.browserTabsByWorktree[sourcePage.worktreeId] ?? []).find(
|
||||
(tab) => tab.id === sourcePage.workspaceId
|
||||
)
|
||||
store.createBrowserTab(sourcePage.worktreeId, url, {
|
||||
title: url,
|
||||
...(sourceTab
|
||||
? {
|
||||
sessionProfileId: sourceTab.sessionProfileId,
|
||||
sessionPartition: sourceTab.sessionPartition
|
||||
}
|
||||
: {})
|
||||
})
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { createBrowserTabMock, storeState } = vi.hoisted(() => ({
|
||||
createBrowserTabMock: vi.fn(),
|
||||
storeState: {
|
||||
value: {} as Record<string, unknown>
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('../../store', () => ({
|
||||
useAppStore: { getState: () => storeState.value }
|
||||
}))
|
||||
vi.mock('@/lib/worktree-runtime-owner', () => ({
|
||||
getRuntimeEnvironmentIdForWorktree: () => null
|
||||
}))
|
||||
vi.mock('@/components/browser-pane/describe-page/live-browser-url-registry', () => ({
|
||||
rememberLiveBrowserUrl: vi.fn()
|
||||
}))
|
||||
vi.mock('./browser-automation-bootstrap-lease', () => ({
|
||||
acquireBrowserAutomationBootstrapLease: vi.fn()
|
||||
}))
|
||||
|
||||
import { registerBrowserStateIpcBridge } from './browser-state-ipc-bridge'
|
||||
|
||||
const noopUnsubscribe = (): void => {}
|
||||
|
||||
function captureOpenLinkHandler(): (event: { browserPageId: string; url: string }) => void {
|
||||
let handler: ((event: { browserPageId: string; url: string }) => void) | null = null
|
||||
const browserApi = new Proxy(
|
||||
{
|
||||
onOpenLinkInOrcaTab: (callback: (event: { browserPageId: string; url: string }) => void) => {
|
||||
handler = callback
|
||||
return noopUnsubscribe
|
||||
}
|
||||
} as Record<string, unknown>,
|
||||
{
|
||||
get: (target, property) =>
|
||||
property in target ? target[property as string] : () => noopUnsubscribe
|
||||
}
|
||||
)
|
||||
const api = new Proxy({ browser: browserApi } as Record<string, unknown>, {
|
||||
get: (target, property) =>
|
||||
property in target
|
||||
? target[property as string]
|
||||
: new Proxy({}, { get: () => () => noopUnsubscribe })
|
||||
})
|
||||
;(globalThis as { window?: unknown }).window = { api }
|
||||
|
||||
registerBrowserStateIpcBridge([], () => false)
|
||||
if (!handler) {
|
||||
throw new Error('Expected the bridge to subscribe to browser:open-link-in-orca-tab')
|
||||
}
|
||||
return handler
|
||||
}
|
||||
|
||||
describe('link-opened Orca tabs', () => {
|
||||
beforeEach(() => {
|
||||
createBrowserTabMock.mockReset()
|
||||
})
|
||||
|
||||
it('inherits the opener tab session so an isolated profile cannot leak into the default one', () => {
|
||||
storeState.value = {
|
||||
browserPagesByWorkspace: {
|
||||
'workspace-1': [{ id: 'page-1', workspaceId: 'workspace-1', worktreeId: 'worktree-1' }]
|
||||
},
|
||||
browserTabsByWorktree: {
|
||||
'worktree-1': [
|
||||
{
|
||||
id: 'workspace-1',
|
||||
sessionProfileId: 'profile-client-a',
|
||||
sessionPartition: 'persist:orca-browser-session-client-a'
|
||||
}
|
||||
]
|
||||
},
|
||||
createBrowserTab: createBrowserTabMock
|
||||
}
|
||||
|
||||
captureOpenLinkHandler()({ browserPageId: 'page-1', url: 'https://docs.example.com/guide' })
|
||||
|
||||
expect(createBrowserTabMock).toHaveBeenCalledWith(
|
||||
'worktree-1',
|
||||
'https://docs.example.com/guide',
|
||||
expect.objectContaining({
|
||||
sessionProfileId: 'profile-client-a',
|
||||
sessionPartition: 'persist:orca-browser-session-client-a'
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('leaves the profile unset when the opener tab is gone, so the user default still applies', () => {
|
||||
storeState.value = {
|
||||
browserPagesByWorkspace: {
|
||||
'workspace-1': [{ id: 'page-1', workspaceId: 'missing', worktreeId: 'worktree-1' }]
|
||||
},
|
||||
browserTabsByWorktree: {},
|
||||
createBrowserTab: createBrowserTabMock
|
||||
}
|
||||
|
||||
captureOpenLinkHandler()({ browserPageId: 'page-1', url: 'https://docs.example.com/guide' })
|
||||
|
||||
const options = createBrowserTabMock.mock.calls[0][2] as Record<string, unknown>
|
||||
expect('sessionProfileId' in options).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -680,7 +680,7 @@ test.describe('Browser Tab', () => {
|
||||
}
|
||||
})
|
||||
|
||||
test('plain links stay current while explicit new-tab gestures activate Orca tabs', async ({
|
||||
test('every new-tab link gesture activates an Orca tab and never a native window', async ({
|
||||
electronApp,
|
||||
orcaPage
|
||||
}) => {
|
||||
@@ -698,23 +698,20 @@ test.describe('Browser Tab', () => {
|
||||
const baseWindowCount = await electronApp.evaluate(
|
||||
({ BaseWindow }) => BaseWindow.getAllWindows().length
|
||||
)
|
||||
const baseTabCount = await orcaPage.locator('[data-tab-id]').count()
|
||||
await clickBrowserLink(orcaPage, sourceTab!.id, '#external-link')
|
||||
|
||||
// A plain target=_blank click is a new-tab request, in the main frame and in an iframe;
|
||||
// the source tab must stay put rather than navigate away under it.
|
||||
const sourceTabLocator = orcaPage.locator(`[data-tab-id="${sourceTab!.id}"]`)
|
||||
await expect(sourceTabLocator).toContainText('Linked destination', { timeout: 10_000 })
|
||||
await expect(orcaPage.locator('[data-tab-id]')).toHaveCount(baseTabCount)
|
||||
await clickBrowserLink(orcaPage, sourceTab!.id, '#external-link')
|
||||
await expectBrowserTabActive(orcaPage, 'Linked destination')
|
||||
await expect(sourceTabLocator).toContainText('Source page')
|
||||
await switchToBrowserTab(orcaPage, worktreeId, sourceTab!.id)
|
||||
|
||||
await clickBrowserLink(orcaPage, sourceTab!.id, '#return-link')
|
||||
await expect(sourceTabLocator).toContainText('Source page', { timeout: 10_000 })
|
||||
await clickBrowserLink(orcaPage, sourceTab!.id, '#frame-link', {
|
||||
frameSelector: '#link-frame'
|
||||
})
|
||||
await expect(sourceTabLocator).toContainText('Frame destination', { timeout: 10_000 })
|
||||
await expect(orcaPage.locator('[data-tab-id]')).toHaveCount(baseTabCount)
|
||||
|
||||
await clickBrowserLink(orcaPage, sourceTab!.id, '#return-link')
|
||||
await expect(sourceTabLocator).toContainText('Source page', { timeout: 10_000 })
|
||||
await expectBrowserTabActive(orcaPage, 'Frame destination')
|
||||
await expect(sourceTabLocator).toContainText('Source page')
|
||||
await switchToBrowserTab(orcaPage, worktreeId, sourceTab!.id)
|
||||
|
||||
await clickBrowserLink(orcaPage, sourceTab!.id, '#frame-modifier-link', {
|
||||
frameSelector: '#link-frame',
|
||||
|
||||
Reference in New Issue
Block a user