mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
Open links and window.open in Orca tabs the way Chrome does (#26976)
* fix(browser): route links and window.open to tabs by Chromium's own disposition window.open(url, '_blank', 'noopener,noreferrer') opened a floating popup window because the new-tab check required an empty features string. Chromium already reports Chrome's tab-vs-window decision as the disposition, so the one rule is now: a tab disposition opens an Orca tab unless the page keeps a named handle it may use (OAuth); size/position features and Shift-click stay popups. The injected click-routing script that relabeled link clicks is gone: native dispositions route target=_blank, Cmd/Ctrl-click, middle-click and iframe links identically. Real clicks still bypass the page-initiated tab budget, now via the browser-process input gesture tracker the route guests already use. Co-authored-by: Lasha Kvantaliani <lasha@sweenk.com> * refactor(browser): spend the click on every window open and drop the noopener parser Review follow-up: a click that opened a popup window stayed unspent and could fund a later scripted tab; every open now consumes it, as Chrome does. The noopener parser only changed named noopener opens, which stay working child windows, so the rule is just an unnamed open with a tab disposition. * test(browser): table-test the new-tab rule and correct stale comments --------- Co-authored-by: Lasha Kvantaliani <lasha@sweenk.com>
This commit is contained in:
co-authored by
Lasha Kvantaliani
parent
9a175d6800
commit
807e93baac
@@ -1,344 +0,0 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import {
|
||||
buildBrowserClickedLinkRoutingScript,
|
||||
buildBrowserIframeClickedLinkRoutingScript,
|
||||
installBrowserIframeClickedLinkRouting,
|
||||
installBrowserClickedLinkRouting
|
||||
} from './browser-clicked-link-routing'
|
||||
|
||||
const FOREGROUND_FRAME_NAME = '__orca_clicked_link_foreground_test'
|
||||
const BACKGROUND_FRAME_NAME = '__orca_clicked_link_background_test'
|
||||
|
||||
type RoutingGlobal = typeof globalThis & {
|
||||
__orcaBrowserClickedLinkRouting?: {
|
||||
listener: (event: MouseEvent) => void
|
||||
}
|
||||
}
|
||||
|
||||
let cleanupIframeRouting: (() => void) | null = null
|
||||
|
||||
function resetRouting(): void {
|
||||
const routingGlobal = globalThis as RoutingGlobal
|
||||
const state = routingGlobal.__orcaBrowserClickedLinkRouting
|
||||
if (state) {
|
||||
window.removeEventListener('click', state.listener)
|
||||
window.removeEventListener('auxclick', state.listener)
|
||||
}
|
||||
delete routingGlobal.__orcaBrowserClickedLinkRouting
|
||||
cleanupIframeRouting?.()
|
||||
cleanupIframeRouting = null
|
||||
}
|
||||
|
||||
function installRouting(isMac = true): void {
|
||||
installBrowserClickedLinkRouting(FOREGROUND_FRAME_NAME, BACKGROUND_FRAME_NAME, isMac, true)
|
||||
}
|
||||
|
||||
function clickLink(
|
||||
link: Element,
|
||||
init: MouseEventInit & { type?: 'click' | 'auxclick' } = {}
|
||||
): { event: MouseEvent; open: ReturnType<typeof vi.fn> } {
|
||||
const open = vi.fn()
|
||||
vi.spyOn(window, 'open').mockImplementation(open)
|
||||
const { type = 'click', ...eventInit } = init
|
||||
const event = new MouseEvent(type, {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
button: type === 'auxclick' ? 1 : 0,
|
||||
...eventInit
|
||||
})
|
||||
link.dispatchEvent(event)
|
||||
return { event, open }
|
||||
}
|
||||
|
||||
describe('browser clicked-link routing', () => {
|
||||
beforeEach(() => {
|
||||
resetRouting()
|
||||
document.head.innerHTML = ''
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
resetRouting()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
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'
|
||||
document.body.append(link)
|
||||
installRouting()
|
||||
|
||||
const { event, open } = clickLink(link)
|
||||
|
||||
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', () => {
|
||||
Object.defineProperty(navigator, 'userAgent', {
|
||||
configurable: true,
|
||||
value: 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X)'
|
||||
})
|
||||
const link = document.createElement('a')
|
||||
link.href = 'https://example.com/reference'
|
||||
document.body.append(link)
|
||||
installRouting(false)
|
||||
|
||||
expect(clickLink(link, { metaKey: true }).open).not.toHaveBeenCalled()
|
||||
expect(clickLink(link, { ctrlKey: true }).open).toHaveBeenCalledWith(
|
||||
'https://example.com/reference',
|
||||
BACKGROUND_FRAME_NAME
|
||||
)
|
||||
})
|
||||
|
||||
it('routes middle clicks and primary-modifier plus Shift clicks as new-tab intents', () => {
|
||||
const link = document.createElement('a')
|
||||
link.href = 'https://example.com/reference'
|
||||
document.body.append(link)
|
||||
installRouting()
|
||||
|
||||
expect(clickLink(link, { type: 'auxclick' }).open).toHaveBeenCalledWith(
|
||||
'https://example.com/reference',
|
||||
BACKGROUND_FRAME_NAME
|
||||
)
|
||||
expect(clickLink(link, { metaKey: true, shiftKey: true }).open).toHaveBeenCalledWith(
|
||||
'https://example.com/reference',
|
||||
FOREGROUND_FRAME_NAME
|
||||
)
|
||||
})
|
||||
|
||||
it.each([true, false])('routes Shift+middle-click to the foreground (isMac=%s)', (isMac) => {
|
||||
const link = document.createElement('a')
|
||||
link.href = 'https://example.com/reference'
|
||||
document.body.append(link)
|
||||
installRouting(isMac)
|
||||
|
||||
const { event, open } = clickLink(link, { type: 'auxclick', shiftKey: true })
|
||||
expect(event.defaultPrevented).toBe(true)
|
||||
expect(open).toHaveBeenCalledWith(link.href, FOREGROUND_FRAME_NAME)
|
||||
|
||||
resetRouting()
|
||||
cleanupIframeRouting = installBrowserIframeClickedLinkRouting(
|
||||
FOREGROUND_FRAME_NAME,
|
||||
BACKGROUND_FRAME_NAME,
|
||||
isMac,
|
||||
true
|
||||
)
|
||||
const iframeClick = clickLink(link, { type: 'auxclick', shiftKey: true })
|
||||
expect(iframeClick.event.defaultPrevented).toBe(true)
|
||||
expect(iframeClick.open).toHaveBeenCalledWith(link.href, FOREGROUND_FRAME_NAME)
|
||||
})
|
||||
|
||||
it('honors page cancellation and observes link rewrites before routing', () => {
|
||||
const cancelled = document.createElement('a')
|
||||
cancelled.href = 'https://example.com/original'
|
||||
cancelled.target = '_blank'
|
||||
cancelled.addEventListener('click', (event) => event.preventDefault())
|
||||
const rewritten = document.createElement('a')
|
||||
rewritten.href = 'https://example.com/original'
|
||||
rewritten.target = '_blank'
|
||||
rewritten.addEventListener('click', () => {
|
||||
rewritten.href = 'https://example.com/rewritten'
|
||||
})
|
||||
document.body.append(cancelled, rewritten)
|
||||
installRouting()
|
||||
|
||||
expect(clickLink(cancelled).open).not.toHaveBeenCalled()
|
||||
expect(cancelled.getAttribute('target')).toBe('_blank')
|
||||
// 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', () => {
|
||||
const svgLink = document.createElementNS('http://www.w3.org/2000/svg', 'a')
|
||||
svgLink.setAttribute('href', 'https://example.com/svg')
|
||||
svgLink.setAttribute('target', '_blank')
|
||||
const areaDownload = document.createElement('area')
|
||||
areaDownload.href = 'https://example.com/archive.zip'
|
||||
areaDownload.target = '_blank'
|
||||
areaDownload.download = 'archive.zip'
|
||||
const noHref = document.createElement('a')
|
||||
noHref.target = '_blank'
|
||||
document.body.append(svgLink, areaDownload, noHref)
|
||||
installRouting()
|
||||
|
||||
expect(clickLink(svgLink).open).toHaveBeenCalledWith(
|
||||
'https://example.com/svg',
|
||||
FOREGROUND_FRAME_NAME
|
||||
)
|
||||
expect(clickLink(areaDownload).open).not.toHaveBeenCalled()
|
||||
expect(clickLink(noHref).open).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('leaves ordinary same-tab, Shift-window, Alt, and unsafe links to Chromium', () => {
|
||||
const sameTab = document.createElement('a')
|
||||
sameTab.href = 'https://example.com/current'
|
||||
const shifted = document.createElement('a')
|
||||
shifted.href = 'https://example.com/window'
|
||||
shifted.target = '_blank'
|
||||
const altClicked = document.createElement('a')
|
||||
altClicked.href = 'https://example.com/download'
|
||||
altClicked.target = '_blank'
|
||||
const unsafe = document.createElement('a')
|
||||
unsafe.href = 'javascript:void(0)'
|
||||
unsafe.target = '_blank'
|
||||
document.body.append(sameTab, shifted, altClicked, unsafe)
|
||||
installRouting()
|
||||
|
||||
expect(clickLink(sameTab).open).not.toHaveBeenCalled()
|
||||
expect(clickLink(shifted, { shiftKey: true }).open).not.toHaveBeenCalled()
|
||||
expect(clickLink(altClicked, { altKey: true }).open).not.toHaveBeenCalled()
|
||||
expect(clickLink(unsafe).open).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('updates an existing isolated-world installation without adding another listener', () => {
|
||||
const addEventListener = vi.spyOn(window, 'addEventListener')
|
||||
const link = document.createElement('a')
|
||||
link.href = 'https://example.com/reference'
|
||||
document.body.append(link)
|
||||
installBrowserClickedLinkRouting(
|
||||
'__orca_clicked_link_old_fg',
|
||||
'__orca_clicked_link_old_bg',
|
||||
true,
|
||||
true
|
||||
)
|
||||
installBrowserClickedLinkRouting(
|
||||
'__orca_clicked_link_new_fg',
|
||||
'__orca_clicked_link_new_bg',
|
||||
false,
|
||||
true
|
||||
)
|
||||
|
||||
expect(addEventListener.mock.calls.filter(([event]) => event === 'click')).toHaveLength(1)
|
||||
expect(clickLink(link, { ctrlKey: true }).open).toHaveBeenCalledWith(
|
||||
'https://example.com/reference',
|
||||
'__orca_clicked_link_new_bg'
|
||||
)
|
||||
})
|
||||
|
||||
it('builds a self-contained isolated-world script', () => {
|
||||
const script = buildBrowserClickedLinkRoutingScript(
|
||||
FOREGROUND_FRAME_NAME,
|
||||
BACKGROUND_FRAME_NAME,
|
||||
false
|
||||
)
|
||||
|
||||
expect(script).toContain('installBrowserClickedLinkRouting')
|
||||
expect(script).toContain(`"${FOREGROUND_FRAME_NAME}","${BACKGROUND_FRAME_NAME}",false`)
|
||||
expect(script).not.toContain('BrowserClickedLinkRoutingState')
|
||||
})
|
||||
|
||||
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'
|
||||
document.body.append(link)
|
||||
cleanupIframeRouting = installBrowserIframeClickedLinkRouting(
|
||||
FOREGROUND_FRAME_NAME,
|
||||
BACKGROUND_FRAME_NAME,
|
||||
true,
|
||||
true
|
||||
)
|
||||
|
||||
const { event, open } = clickLink(link)
|
||||
|
||||
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', () => {
|
||||
const blank = document.createElement('a')
|
||||
blank.href = 'https://example.com/new-context'
|
||||
blank.target = '_blank'
|
||||
document.body.append(blank)
|
||||
cleanupIframeRouting = installBrowserIframeClickedLinkRouting(
|
||||
FOREGROUND_FRAME_NAME,
|
||||
BACKGROUND_FRAME_NAME,
|
||||
true,
|
||||
true
|
||||
)
|
||||
|
||||
expect(clickLink(blank, { metaKey: true }).open).toHaveBeenCalledWith(
|
||||
'https://example.com/new-context',
|
||||
BACKGROUND_FRAME_NAME
|
||||
)
|
||||
|
||||
cleanupIframeRouting = installBrowserIframeClickedLinkRouting(
|
||||
FOREGROUND_FRAME_NAME,
|
||||
BACKGROUND_FRAME_NAME,
|
||||
true,
|
||||
true
|
||||
)
|
||||
expect(clickLink(blank, { type: 'auxclick' }).open).toHaveBeenCalledWith(
|
||||
'https://example.com/new-context',
|
||||
BACKGROUND_FRAME_NAME
|
||||
)
|
||||
|
||||
cleanupIframeRouting = installBrowserIframeClickedLinkRouting(
|
||||
FOREGROUND_FRAME_NAME,
|
||||
BACKGROUND_FRAME_NAME,
|
||||
true,
|
||||
true
|
||||
)
|
||||
expect(clickLink(blank, { metaKey: true, shiftKey: true }).open).toHaveBeenCalledWith(
|
||||
'https://example.com/new-context',
|
||||
FOREGROUND_FRAME_NAME
|
||||
)
|
||||
})
|
||||
|
||||
it('leaves other-platform, Shift-window, and ordinary iframe clicks to Chromium', () => {
|
||||
const blank = document.createElement('a')
|
||||
blank.href = 'https://example.com/new-context'
|
||||
blank.target = '_blank'
|
||||
const ordinary = document.createElement('a')
|
||||
ordinary.href = 'https://example.com/current'
|
||||
document.body.append(blank, ordinary)
|
||||
cleanupIframeRouting = installBrowserIframeClickedLinkRouting(
|
||||
FOREGROUND_FRAME_NAME,
|
||||
BACKGROUND_FRAME_NAME,
|
||||
true,
|
||||
true
|
||||
)
|
||||
|
||||
expect(clickLink(blank, { ctrlKey: true }).open).not.toHaveBeenCalled()
|
||||
expect(clickLink(blank, { shiftKey: true }).open).not.toHaveBeenCalled()
|
||||
expect(clickLink(ordinary).open).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('ignores synthetic main-frame and iframe clicks in production mode', () => {
|
||||
const mainLink = document.createElement('a')
|
||||
mainLink.href = 'https://example.com/main-synthetic'
|
||||
const frameLink = document.createElement('a')
|
||||
frameLink.href = 'https://example.com/frame-synthetic'
|
||||
frameLink.target = '_blank'
|
||||
document.body.append(mainLink, frameLink)
|
||||
installBrowserClickedLinkRouting(FOREGROUND_FRAME_NAME, BACKGROUND_FRAME_NAME, true)
|
||||
cleanupIframeRouting = installBrowserIframeClickedLinkRouting(
|
||||
FOREGROUND_FRAME_NAME,
|
||||
BACKGROUND_FRAME_NAME,
|
||||
true
|
||||
)
|
||||
|
||||
expect(clickLink(mainLink, { metaKey: true }).open).not.toHaveBeenCalled()
|
||||
expect(clickLink(frameLink).open).not.toHaveBeenCalled()
|
||||
expect(frameLink.getAttribute('target')).toBe('_blank')
|
||||
})
|
||||
|
||||
it('builds a self-contained iframe script with per-frame routing tokens', () => {
|
||||
const script = buildBrowserIframeClickedLinkRoutingScript(
|
||||
FOREGROUND_FRAME_NAME,
|
||||
BACKGROUND_FRAME_NAME,
|
||||
false
|
||||
)
|
||||
|
||||
expect(script).toContain('installBrowserIframeClickedLinkRouting')
|
||||
expect(script).toContain(`"${FOREGROUND_FRAME_NAME}","${BACKGROUND_FRAME_NAME}",false`)
|
||||
})
|
||||
})
|
||||
@@ -1,223 +0,0 @@
|
||||
export const BROWSER_CLICKED_LINK_ROUTING_WORLD_ID = 1208
|
||||
|
||||
export type BrowserClickedLinkFrameNames = {
|
||||
foreground: string
|
||||
background: string
|
||||
}
|
||||
|
||||
type BrowserClickedLinkRoutingState = {
|
||||
foregroundFrameName: string
|
||||
backgroundFrameName: string
|
||||
isMac: boolean
|
||||
allowUntrustedEvents: boolean
|
||||
listener: (event: MouseEvent) => void
|
||||
}
|
||||
|
||||
type BrowserClickedLinkRoutingGlobal = typeof globalThis & {
|
||||
__orcaBrowserClickedLinkRouting?: BrowserClickedLinkRoutingState
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-expresses explicit new-tab link gestures with private frame names so main
|
||||
* can distinguish them from opener-dependent window.open calls.
|
||||
*/
|
||||
export function installBrowserClickedLinkRouting(
|
||||
foregroundFrameName: string,
|
||||
backgroundFrameName: string,
|
||||
isMac: boolean,
|
||||
allowUntrustedEvents = false
|
||||
): void {
|
||||
const routingGlobal = globalThis as BrowserClickedLinkRoutingGlobal
|
||||
const existing = routingGlobal.__orcaBrowserClickedLinkRouting
|
||||
if (existing) {
|
||||
existing.foregroundFrameName = foregroundFrameName
|
||||
existing.backgroundFrameName = backgroundFrameName
|
||||
existing.isMac = isMac
|
||||
existing.allowUntrustedEvents = allowUntrustedEvents
|
||||
return
|
||||
}
|
||||
|
||||
const state: BrowserClickedLinkRoutingState = {
|
||||
foregroundFrameName,
|
||||
backgroundFrameName,
|
||||
isMac,
|
||||
allowUntrustedEvents,
|
||||
listener: () => {}
|
||||
}
|
||||
state.listener = (event) => {
|
||||
const primaryClick = event.type === 'click' && event.button === 0
|
||||
const middleClick = event.type === 'auxclick' && event.button === 1
|
||||
if (
|
||||
!(event instanceof MouseEvent) ||
|
||||
(!event.isTrusted && !state.allowUntrustedEvents) ||
|
||||
(!primaryClick && !middleClick) ||
|
||||
event.defaultPrevented ||
|
||||
event.altKey
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
const link = event
|
||||
.composedPath()
|
||||
.find(
|
||||
(target): target is Element =>
|
||||
target instanceof Element &&
|
||||
((target.namespaceURI === 'http://www.w3.org/1999/xhtml' &&
|
||||
(target.localName === 'a' || target.localName === 'area')) ||
|
||||
(target.namespaceURI === 'http://www.w3.org/2000/svg' && target.localName === 'a'))
|
||||
)
|
||||
if (!link || link.hasAttribute('download')) {
|
||||
return
|
||||
}
|
||||
|
||||
const modifierClick = state.isMac ? event.metaKey : event.ctrlKey
|
||||
const otherPlatformModifier = state.isMac ? event.ctrlKey : event.metaKey
|
||||
if (otherPlatformModifier) {
|
||||
return
|
||||
}
|
||||
// Shift alone is browser-native new-window intent; keep OAuth and other
|
||||
// opener-dependent window flows in Orca's guarded popup window.
|
||||
if (event.shiftKey && !modifierClick && !middleClick) {
|
||||
return
|
||||
}
|
||||
|
||||
const baseTarget = document.querySelector('base[target]')?.getAttribute('target') ?? ''
|
||||
const ownTarget = link.getAttribute('target')
|
||||
const effectiveTarget = (ownTarget === null ? baseTarget : ownTarget).trim().toLowerCase()
|
||||
// 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
|
||||
}
|
||||
|
||||
const rawHref =
|
||||
link.getAttribute('href') ?? link.getAttributeNS('http://www.w3.org/1999/xlink', 'href')
|
||||
if (rawHref === null) {
|
||||
return
|
||||
}
|
||||
|
||||
let targetUrl: URL
|
||||
try {
|
||||
targetUrl = new URL(rawHref, document.baseURI)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
if (targetUrl.protocol !== 'http:' && targetUrl.protocol !== 'https:') {
|
||||
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.
|
||||
event.preventDefault()
|
||||
const openInBackground = (middleClick || modifierClick) && !event.shiftKey
|
||||
window.open(
|
||||
targetUrl.toString(),
|
||||
openInBackground ? state.backgroundFrameName : state.foregroundFrameName
|
||||
)
|
||||
}
|
||||
routingGlobal.__orcaBrowserClickedLinkRouting = state
|
||||
|
||||
// Why: page click handlers must get the first chance to cancel or rewrite a
|
||||
// link; capture-phase interception breaks SPA routing and analytics handlers.
|
||||
window.addEventListener('click', state.listener, false)
|
||||
window.addEventListener('auxclick', state.listener, false)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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(
|
||||
foregroundFrameName: string,
|
||||
backgroundFrameName: string,
|
||||
isMac: boolean,
|
||||
allowUntrustedEvents = false
|
||||
): () => void {
|
||||
const listener = (event: MouseEvent): void => {
|
||||
const primaryClick = event.type === 'click' && event.button === 0
|
||||
const middleClick = event.type === 'auxclick' && event.button === 1
|
||||
if (
|
||||
!(event instanceof MouseEvent) ||
|
||||
(!event.isTrusted && !allowUntrustedEvents) ||
|
||||
(!primaryClick && !middleClick) ||
|
||||
event.defaultPrevented ||
|
||||
event.altKey
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
const link = event
|
||||
.composedPath()
|
||||
.find(
|
||||
(target): target is Element =>
|
||||
target instanceof Element &&
|
||||
((target.namespaceURI === 'http://www.w3.org/1999/xhtml' &&
|
||||
(target.localName === 'a' || target.localName === 'area')) ||
|
||||
(target.namespaceURI === 'http://www.w3.org/2000/svg' && target.localName === 'a'))
|
||||
)
|
||||
if (!link || link.hasAttribute('download')) {
|
||||
return
|
||||
}
|
||||
|
||||
const modifierClick = isMac ? event.metaKey : event.ctrlKey
|
||||
const otherPlatformModifier = isMac ? event.ctrlKey : event.metaKey
|
||||
if (otherPlatformModifier || (event.shiftKey && !modifierClick && !middleClick)) {
|
||||
return
|
||||
}
|
||||
|
||||
const baseTarget = document.querySelector('base[target]')?.getAttribute('target') ?? ''
|
||||
const ownTarget = link.getAttribute('target')
|
||||
const effectiveTarget = (ownTarget === null ? baseTarget : ownTarget).trim().toLowerCase()
|
||||
if (!(middleClick || modifierClick || effectiveTarget === '_blank')) {
|
||||
return
|
||||
}
|
||||
|
||||
const rawHref =
|
||||
link.getAttribute('href') ?? link.getAttributeNS('http://www.w3.org/1999/xlink', 'href')
|
||||
if (rawHref === null) {
|
||||
return
|
||||
}
|
||||
|
||||
let targetUrl: URL
|
||||
try {
|
||||
targetUrl = new URL(rawHref, document.baseURI)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
if (targetUrl.protocol !== 'http:' && targetUrl.protocol !== 'https:') {
|
||||
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()
|
||||
cleanup()
|
||||
const openInBackground = (middleClick || modifierClick) && !event.shiftKey
|
||||
window.open(targetUrl.toString(), openInBackground ? backgroundFrameName : foregroundFrameName)
|
||||
}
|
||||
|
||||
const cleanup = (): void => {
|
||||
window.removeEventListener('click', listener, false)
|
||||
window.removeEventListener('auxclick', listener, false)
|
||||
}
|
||||
window.addEventListener('click', listener, false)
|
||||
window.addEventListener('auxclick', listener, false)
|
||||
return cleanup
|
||||
}
|
||||
|
||||
export function buildBrowserClickedLinkRoutingScript(
|
||||
foregroundFrameName: string,
|
||||
backgroundFrameName: string,
|
||||
isMac: boolean
|
||||
): string {
|
||||
return `(${installBrowserClickedLinkRouting.toString()})(${JSON.stringify(foregroundFrameName)},${JSON.stringify(backgroundFrameName)},${JSON.stringify(isMac)});`
|
||||
}
|
||||
|
||||
export function buildBrowserIframeClickedLinkRoutingScript(
|
||||
foregroundFrameName: string,
|
||||
backgroundFrameName: string,
|
||||
isMac: boolean
|
||||
): string {
|
||||
return `void (${installBrowserIframeClickedLinkRouting.toString()})(${JSON.stringify(foregroundFrameName)},${JSON.stringify(backgroundFrameName)},${JSON.stringify(isMac)});`
|
||||
}
|
||||
+9
-9
@@ -15,27 +15,27 @@ const ACTIVATING_INPUT_TYPES: ReadonlySet<string> = new Set([
|
||||
'pointerUp'
|
||||
])
|
||||
|
||||
export const BROWSER_ROUTE_GUEST_POPUP_GESTURE_WINDOW_MS = 1_000
|
||||
export const BROWSER_GUEST_INPUT_GESTURE_WINDOW_MS = 1_000
|
||||
|
||||
export type BrowserRouteGuestPopupGesture = {
|
||||
/** Single-use: one observed click cannot be replayed into a second popup. */
|
||||
export type BrowserGuestInputGesture = {
|
||||
/** Single-use: one observed click cannot be replayed into a second window or tab. */
|
||||
consume: () => boolean
|
||||
dispose: () => void
|
||||
}
|
||||
|
||||
export function trackBrowserRouteGuestPopupGesture(
|
||||
export function trackBrowserGuestInputGesture(
|
||||
guest: WebContents,
|
||||
now: () => number = () => Date.now()
|
||||
): BrowserRouteGuestPopupGesture {
|
||||
): BrowserGuestInputGesture {
|
||||
let lastGestureAt: number | null = null
|
||||
const onInputEvent = (_event: unknown, input: { type?: string }): void => {
|
||||
const onInputEvent = (_event: Electron.Event, input: Electron.InputEvent): void => {
|
||||
if (typeof input?.type === 'string' && ACTIVATING_INPUT_TYPES.has(input.type)) {
|
||||
lastGestureAt = now()
|
||||
}
|
||||
}
|
||||
let attached = false
|
||||
try {
|
||||
guest.on('input-event', onInputEvent as never)
|
||||
guest.on('input-event', onInputEvent)
|
||||
attached = true
|
||||
} catch {
|
||||
// Fail closed: an unobservable input stream never counts as a gesture.
|
||||
@@ -47,7 +47,7 @@ export function trackBrowserRouteGuestPopupGesture(
|
||||
return (
|
||||
attached &&
|
||||
observedAt !== null &&
|
||||
now() - observedAt <= BROWSER_ROUTE_GUEST_POPUP_GESTURE_WINDOW_MS
|
||||
now() - observedAt <= BROWSER_GUEST_INPUT_GESTURE_WINDOW_MS
|
||||
)
|
||||
},
|
||||
dispose: () => {
|
||||
@@ -57,7 +57,7 @@ export function trackBrowserRouteGuestPopupGesture(
|
||||
}
|
||||
attached = false
|
||||
try {
|
||||
guest.off('input-event', onInputEvent as never)
|
||||
guest.off('input-event', onInputEvent)
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
@@ -297,8 +297,8 @@ describe('browserManager', () => {
|
||||
expect(browserManager.getGuestWebContentsId('browser-1')).toBeNull()
|
||||
expect(browserManager.getGuestWebContentsId('browser-2')).toBeNull()
|
||||
expect(guestOffMock).toHaveBeenCalled()
|
||||
expect(unregisteredGuestOff).toHaveBeenCalledWith('dom-ready', expect.any(Function))
|
||||
expect(unregisteredGuestOff).toHaveBeenCalledWith('frame-created', expect.any(Function))
|
||||
expect(unregisteredGuestOff).toHaveBeenCalledWith('did-create-window', expect.any(Function))
|
||||
expect(unregisteredGuestOff).toHaveBeenCalledWith('input-event', expect.any(Function))
|
||||
})
|
||||
|
||||
it('rejects non-webview guest types to prevent privilege escalation', () => {
|
||||
|
||||
@@ -47,7 +47,6 @@ type GuestFake = {
|
||||
setBackgroundThrottling: (value: boolean) => void
|
||||
setWebRTCIPHandlingPolicy: (policy: string) => void
|
||||
setWindowOpenHandler: (handler: (details: { url: string; frameName: string }) => unknown) => void
|
||||
executeJavaScriptInIsolatedWorld: ReturnType<typeof vi.fn>
|
||||
debugger: {
|
||||
isAttached: () => boolean
|
||||
attach: ReturnType<typeof vi.fn>
|
||||
@@ -79,7 +78,6 @@ function createGuest(id: number, url: string): GuestFake {
|
||||
setWindowOpenHandler: (handler) => {
|
||||
guest.windowOpenHandler = handler
|
||||
},
|
||||
executeJavaScriptInIsolatedWorld: vi.fn(async () => undefined),
|
||||
debugger: {
|
||||
isAttached: () => true,
|
||||
attach: vi.fn(),
|
||||
@@ -147,13 +145,12 @@ describe('guest policy profiles', () => {
|
||||
// The presence half of every absence below: a browsing guest observably takes all of it through
|
||||
// the same method, so a profile that fenced nothing — or an attach path that stopped installing
|
||||
// anything at all — cannot pass these by being uniformly empty.
|
||||
it('gives a browsing guest link routing, popups and auth-identity detach tracking', () => {
|
||||
it('gives a browsing guest popups, link gestures and auth-identity detach tracking', () => {
|
||||
const guest = createGuest(300, 'https://example.com/')
|
||||
|
||||
browserManager.attachGuestPolicies(guest as never)
|
||||
|
||||
expect(listenerCount(guest, 'dom-ready')).toBe(1)
|
||||
expect(listenerCount(guest, 'frame-created')).toBe(1)
|
||||
expect(listenerCount(guest, 'input-event')).toBe(1)
|
||||
expect(listenerCount(guest, 'did-create-window')).toBe(1)
|
||||
expect(guest.debugger.on).toHaveBeenCalledWith('detach', expect.any(Function))
|
||||
// Why: a plain browsing tab must never attach a debugger; Cloudflare treats CDP as a bot signal.
|
||||
@@ -165,12 +162,10 @@ describe('guest policy profiles', () => {
|
||||
it('gives a workspace-document guest none of it', () => {
|
||||
const { guest } = attachPreviewGuest()
|
||||
|
||||
expect(listenerCount(guest, 'dom-ready')).toBe(0)
|
||||
expect(listenerCount(guest, 'frame-created')).toBe(0)
|
||||
expect(listenerCount(guest, 'input-event')).toBe(0)
|
||||
expect(listenerCount(guest, 'did-create-window')).toBe(0)
|
||||
expect(guest.debugger.on).not.toHaveBeenCalled()
|
||||
expect(guest.debugger.sendCommand).not.toHaveBeenCalled()
|
||||
expect(guest.executeJavaScriptInIsolatedWorld).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('holds a workspace-document guest inside the grant it is showing', () => {
|
||||
@@ -204,7 +199,7 @@ describe('guest policy profiles', () => {
|
||||
// The manager's own bookkeeping was reached too: a second attach is refused while the id is
|
||||
// still marked policy-attached, and accepted once its teardown has run.
|
||||
browserManager.attachGuestPolicies(guest as never)
|
||||
expect(listenerCount(guest, 'dom-ready')).toBe(1)
|
||||
expect(listenerCount(guest, 'did-create-window')).toBe(1)
|
||||
})
|
||||
|
||||
// The seam the whole split rests on: one public door answers for both halves, and each page id
|
||||
@@ -252,7 +247,7 @@ describe('guest policy profiles', () => {
|
||||
|
||||
browserManager.attachGuestPolicies(guest as never)
|
||||
|
||||
expect(listenerCount(guest, 'dom-ready')).toBe(0)
|
||||
expect(listenerCount(guest, 'did-create-window')).toBe(0)
|
||||
expect(listenerCount(guest, 'will-navigate')).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -29,7 +29,7 @@ export abstract class BrowserManagerGuestPolicy extends BrowserManagerGuestClean
|
||||
const disposeDetachTracking = this.trackDebuggerDetachForCdpOverrides(guest)
|
||||
// Why: disable throttling so background screenshots still get frames; else the compositor stalls and capture returns empty.
|
||||
guest.setBackgroundThrottling(false)
|
||||
const disposePopupPolicy = this.installGuestPopupPolicy(guest, !inheritedOwnerContext)
|
||||
const disposePopupPolicy = this.installGuestPopupPolicy(guest)
|
||||
const disposeNavigationPolicy = this.installGuestNavigationPolicy(guest)
|
||||
|
||||
// Why: store cleanup so unregisterGuest can drop these listeners on teardown and let the WebContents wrapper GC.
|
||||
|
||||
@@ -1,179 +1,38 @@
|
||||
import { shell } from 'electron'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { ORCA_BROWSER_BLANK_URL } from '../../shared/constants'
|
||||
import {
|
||||
normalizeBrowserNavigationUrl,
|
||||
normalizeExternalBrowserUrl,
|
||||
redactKagiSessionToken
|
||||
} from '../../shared/browser-url'
|
||||
import {
|
||||
BROWSER_CLICKED_LINK_ROUTING_WORLD_ID,
|
||||
buildBrowserClickedLinkRoutingScript,
|
||||
buildBrowserIframeClickedLinkRoutingScript,
|
||||
type BrowserClickedLinkFrameNames
|
||||
} from './browser-clicked-link-routing'
|
||||
import { trackBrowserGuestInputGesture } from './browser-guest-input-gesture'
|
||||
import { isNewBrowserTabPopupIntent } from './browser-popup-new-tab-intent'
|
||||
import { SAFE_POPUP_WINDOW_OPTIONS, safeOrigin } from './browser-manager-types'
|
||||
import type { PopupChildWindowOptions } from './popup-origin-bar-window'
|
||||
import { BrowserManagerNavigation } from './browser-manager-navigation'
|
||||
|
||||
export abstract class BrowserManagerGuestPopupPolicy extends BrowserManagerNavigation {
|
||||
protected installGuestPopupPolicy(
|
||||
guest: Electron.WebContents,
|
||||
routeClickedLinks: boolean
|
||||
): () => void {
|
||||
// OAuth child windows keep native link behavior.
|
||||
const clickedLinkFrameNames: BrowserClickedLinkFrameNames | null = routeClickedLinks
|
||||
? {
|
||||
foreground: `__orca_clicked_link_foreground_${randomUUID()}`,
|
||||
background: `__orca_clicked_link_background_${randomUUID()}`
|
||||
}
|
||||
: null
|
||||
let clickedLinkRoutingActive = routeClickedLinks
|
||||
const installClickedLinkRouting = (): void => {
|
||||
if (!clickedLinkRoutingActive || !clickedLinkFrameNames || guest.isDestroyed()) {
|
||||
return
|
||||
}
|
||||
// Why: an isolated-world listener labels real anchor clicks without exposing the frame name to page scripts.
|
||||
void guest
|
||||
.executeJavaScriptInIsolatedWorld(
|
||||
BROWSER_CLICKED_LINK_ROUTING_WORLD_ID,
|
||||
[
|
||||
{
|
||||
// Why: mobile emulation spoofs the UA as iOS, so use the real host platform from main for modifier routing.
|
||||
code: buildBrowserClickedLinkRoutingScript(
|
||||
clickedLinkFrameNames.foreground,
|
||||
clickedLinkFrameNames.background,
|
||||
process.platform === 'darwin'
|
||||
)
|
||||
}
|
||||
],
|
||||
false
|
||||
)
|
||||
.catch(() => {})
|
||||
}
|
||||
if (clickedLinkFrameNames) {
|
||||
guest.on('dom-ready', installClickedLinkRouting)
|
||||
}
|
||||
const pendingIframeRoutingInstalls = new Map<Electron.WebFrameMain, () => void>()
|
||||
const iframeFrameNamesByFrame = new Map<Electron.WebFrameMain, BrowserClickedLinkFrameNames>()
|
||||
const iframeRoutingByFrameName = new Map<
|
||||
string,
|
||||
{ frame: Electron.WebFrameMain; activate: boolean }
|
||||
>()
|
||||
const clearIframeFrameName = (frame: Electron.WebFrameMain): void => {
|
||||
const names = iframeFrameNamesByFrame.get(frame)
|
||||
if (!names) {
|
||||
return
|
||||
}
|
||||
iframeFrameNamesByFrame.delete(frame)
|
||||
for (const name of [names.foreground, names.background]) {
|
||||
iframeRoutingByFrameName.delete(name)
|
||||
}
|
||||
}
|
||||
const installIframeClickedLinkRouting = (frame: Electron.WebFrameMain): void => {
|
||||
clearIframeFrameName(frame)
|
||||
if (!clickedLinkRoutingActive || frame.isDestroyed()) {
|
||||
return
|
||||
}
|
||||
const foregroundName = `__orca_clicked_link_iframe_foreground_${randomUUID()}`
|
||||
const backgroundName = `__orca_clicked_link_iframe_background_${randomUUID()}`
|
||||
iframeFrameNamesByFrame.set(frame, {
|
||||
foreground: foregroundName,
|
||||
background: backgroundName
|
||||
})
|
||||
iframeRoutingByFrameName.set(foregroundName, { frame, activate: true })
|
||||
iframeRoutingByFrameName.set(backgroundName, { frame, activate: false })
|
||||
// Why: child-frame tokens live in the page world, so consume after one trusted click and replace before the next.
|
||||
void frame
|
||||
.executeJavaScript(
|
||||
buildBrowserIframeClickedLinkRoutingScript(
|
||||
foregroundName,
|
||||
backgroundName,
|
||||
process.platform === 'darwin'
|
||||
),
|
||||
false
|
||||
)
|
||||
.catch(() => {
|
||||
if (iframeFrameNamesByFrame.get(frame)?.foreground === foregroundName) {
|
||||
clearIframeFrameName(frame)
|
||||
}
|
||||
})
|
||||
}
|
||||
const handleFrameCreated = (
|
||||
_event: Electron.Event,
|
||||
{ frame }: Electron.FrameCreatedDetails
|
||||
): void => {
|
||||
if (!clickedLinkFrameNames || !frame || frame.parent === null) {
|
||||
return
|
||||
}
|
||||
for (const knownFrame of iframeFrameNamesByFrame.keys()) {
|
||||
if (knownFrame.isDestroyed()) {
|
||||
clearIframeFrameName(knownFrame)
|
||||
}
|
||||
}
|
||||
const installAfterDomReady = (): void => {
|
||||
pendingIframeRoutingInstalls.delete(frame)
|
||||
installIframeClickedLinkRouting(frame)
|
||||
}
|
||||
pendingIframeRoutingInstalls.set(frame, installAfterDomReady)
|
||||
frame.once('dom-ready', installAfterDomReady)
|
||||
}
|
||||
if (clickedLinkFrameNames) {
|
||||
guest.on('frame-created', handleFrameCreated)
|
||||
}
|
||||
protected installGuestPopupPolicy(guest: Electron.WebContents): () => void {
|
||||
// Why: a fresh input marks a click, which bypasses the page-initiated tab budget.
|
||||
const gesture = trackBrowserGuestInputGesture(guest)
|
||||
const handleDidCreateWindow = (window: Electron.BrowserWindow): void => {
|
||||
// Why: popup descendants inherit the opener's owner context but must not replace its primary registration.
|
||||
this.attachGuestPolicies(window.webContents, this.resolvePopupOwnerContext(guest.id))
|
||||
}
|
||||
guest.on('did-create-window', handleDidCreateWindow)
|
||||
guest.setWindowOpenHandler(({ url, frameName, disposition, features }) => {
|
||||
guest.setWindowOpenHandler(({ url, frameName, disposition }) => {
|
||||
// Why: as in Chrome, every open spends the click, so a popup's click cannot fund a later tab.
|
||||
const clicked = gesture.consume()
|
||||
const ownerContext = this.resolvePopupOwnerContext(guest.id)
|
||||
const browserTabId = ownerContext?.browserTabId ?? null
|
||||
const browserUrl = normalizeBrowserNavigationUrl(url)
|
||||
const externalUrl = normalizeExternalBrowserUrl(url)
|
||||
const expectedClickedLinkFrameNames = clickedLinkRoutingActive ? clickedLinkFrameNames : null
|
||||
const iframeRouting = frameName ? iframeRoutingByFrameName.get(frameName) : undefined
|
||||
let clickedLinkActivate: boolean | null = null
|
||||
if (expectedClickedLinkFrameNames && frameName === expectedClickedLinkFrameNames.foreground) {
|
||||
clickedLinkActivate = true
|
||||
} else if (
|
||||
expectedClickedLinkFrameNames &&
|
||||
frameName === expectedClickedLinkFrameNames.background
|
||||
) {
|
||||
clickedLinkActivate = false
|
||||
} else if (iframeRouting) {
|
||||
clickedLinkActivate = iframeRouting.activate
|
||||
clearIframeFrameName(iframeRouting.frame)
|
||||
queueMicrotask(() => installIframeClickedLinkRouting(iframeRouting.frame))
|
||||
}
|
||||
|
||||
if (clickedLinkActivate !== null) {
|
||||
if (
|
||||
browserTabId &&
|
||||
browserUrl &&
|
||||
this.openLinkInOrcaTab(browserTabId, browserUrl, clickedLinkActivate)
|
||||
) {
|
||||
this.forwardOrQueuePopupEvent(guest.id, {
|
||||
origin: safeOrigin(browserUrl),
|
||||
action: 'opened-in-orca'
|
||||
})
|
||||
}
|
||||
// Why: a recognized gesture must never fall through to a native popup if its renderer vanished mid-click.
|
||||
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 rule for every link and window.open; opener-dependent shapes are excluded by
|
||||
// isNewBrowserTabPopupIntent and still get a real child window below.
|
||||
if (ownerContext && externalUrl && isNewBrowserTabPopupIntent({ frameName, disposition })) {
|
||||
// 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)) {
|
||||
// workspace session state, so only opens beyond the observed clicks draw on the budget.
|
||||
if (!clicked && !this.tryConsumePageInitiatedTab(ownerContext.rootGuestWebContentsId)) {
|
||||
this.forwardOrQueuePopupEvent(guest.id, {
|
||||
origin: safeOrigin(externalUrl),
|
||||
action: 'blocked'
|
||||
@@ -225,21 +84,9 @@ export abstract class BrowserManagerGuestPopupPolicy extends BrowserManagerNavig
|
||||
})
|
||||
|
||||
return () => {
|
||||
clickedLinkRoutingActive = false
|
||||
gesture.dispose()
|
||||
try {
|
||||
guest.off('did-create-window', handleDidCreateWindow)
|
||||
if (clickedLinkFrameNames) {
|
||||
guest.off('dom-ready', installClickedLinkRouting)
|
||||
guest.off('frame-created', handleFrameCreated)
|
||||
for (const [frame, install] of pendingIframeRoutingInstalls) {
|
||||
if (!frame.isDestroyed()) {
|
||||
frame.off('dom-ready', install)
|
||||
}
|
||||
}
|
||||
pendingIframeRoutingInstalls.clear()
|
||||
iframeFrameNamesByFrame.clear()
|
||||
iframeRoutingByFrameName.clear()
|
||||
}
|
||||
} catch {
|
||||
// guest may already be destroyed
|
||||
}
|
||||
|
||||
@@ -40,7 +40,10 @@ 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 {
|
||||
MAX_PAGE_INITIATED_TABS_PER_WINDOW,
|
||||
PAGE_INITIATED_TAB_WINDOW_MS
|
||||
} from './browser-page-initiated-tab-budget'
|
||||
import {
|
||||
rendererWebContentsId,
|
||||
resetBrowserManagerMocks,
|
||||
@@ -171,9 +174,9 @@ describe('browserManager', () => {
|
||||
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, 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.
|
||||
// OAuth flows, whose returned handle must stay live. A named target that
|
||||
// keeps its opener and a blank URL each mark such a flow, so both keep a
|
||||
// real child window no matter which disposition Chromium reports.
|
||||
const guest = {
|
||||
id: 140,
|
||||
isDestroyed: vi.fn(() => false),
|
||||
@@ -201,7 +204,7 @@ describe('browserManager', () => {
|
||||
}) => { 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: 'https://sso.example.com/auth', frameName: 'ssoWindow', features: 'noopener=0' },
|
||||
{ url: 'about:blank', frameName: '', features: '' }
|
||||
]
|
||||
for (const disposition of ['foreground-tab', 'background-tab', 'new-window']) {
|
||||
@@ -209,6 +212,17 @@ describe('browserManager', () => {
|
||||
expect(handler({ ...open, disposition })).toMatchObject({ action: 'allow' })
|
||||
}
|
||||
}
|
||||
// Chromium reports size/position features as a popup, with or without an opener.
|
||||
for (const features of ['width=500,height=600', 'noopener,width=500,height=600']) {
|
||||
expect(
|
||||
handler({
|
||||
url: 'https://sso.example.com/auth',
|
||||
frameName: '',
|
||||
features,
|
||||
disposition: 'new-window'
|
||||
})
|
||||
).toMatchObject({ action: 'allow' })
|
||||
}
|
||||
expect(shellOpenExternalMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
@@ -288,6 +302,149 @@ describe('browserManager', () => {
|
||||
expect(shellOpenExternalMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('opens unnamed window.open with only opener features as a tab, as Chrome does', () => {
|
||||
const rendererSendMock = vi.fn()
|
||||
const guest = {
|
||||
id: 143,
|
||||
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 handler = guestSetWindowOpenHandlerMock.mock.calls[0][0] as (details: {
|
||||
url: string
|
||||
frameName: string
|
||||
features: string
|
||||
disposition: string
|
||||
}) => { action: 'allow' | 'deny' }
|
||||
// Chromium reports these as tabs: noopener and noreferrer are not window features.
|
||||
const openerlessOpens = [
|
||||
{ frameName: '', features: 'noopener,noreferrer' },
|
||||
{ frameName: '', features: 'noopener' },
|
||||
{ frameName: '', features: 'noopener=0' }
|
||||
]
|
||||
for (const open of openerlessOpens) {
|
||||
expect(
|
||||
handler({ url: 'https://docs.example.com/guide', disposition: 'foreground-tab', ...open })
|
||||
).toEqual({ action: 'deny' })
|
||||
}
|
||||
expect(
|
||||
rendererSendMock.mock.calls.filter(([channel]) => channel === 'browser:open-link-in-orca-tab')
|
||||
).toHaveLength(openerlessOpens.length)
|
||||
expect(rendererSendMock).toHaveBeenCalledWith('browser:open-link-in-orca-tab', {
|
||||
browserPageId: 'browser-1',
|
||||
url: 'https://docs.example.com/guide'
|
||||
})
|
||||
expect(openPopupWithOriginBarMock).not.toHaveBeenCalled()
|
||||
expect(shellOpenExternalMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('opens a tab for every clicked link but caps scripted opens', () => {
|
||||
vi.useFakeTimers()
|
||||
const rendererSendMock = vi.fn()
|
||||
const guest = {
|
||||
id: 144,
|
||||
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 handler = guestSetWindowOpenHandlerMock.mock.calls[0][0] as (details: {
|
||||
url: string
|
||||
frameName: string
|
||||
features: string
|
||||
disposition: string
|
||||
}) => { action: 'allow' | 'deny' }
|
||||
const inputEventHandler = guestOnMock.mock.calls.find(([event]) => event === 'input-event')?.[1]
|
||||
const open = (index: number): void => {
|
||||
handler({
|
||||
url: `https://docs.example.com/${index}`,
|
||||
frameName: '',
|
||||
features: '',
|
||||
disposition: 'background-tab'
|
||||
})
|
||||
}
|
||||
const routedTabCount = (): number =>
|
||||
rendererSendMock.mock.calls.filter(([channel]) => channel === 'browser:open-link-in-orca-tab')
|
||||
.length
|
||||
|
||||
const clicks = MAX_PAGE_INITIATED_TABS_PER_WINDOW * 2
|
||||
for (let index = 0; index < clicks; index++) {
|
||||
inputEventHandler({}, { type: 'mouseUp' })
|
||||
open(index)
|
||||
}
|
||||
expect(routedTabCount()).toBe(clicks)
|
||||
|
||||
// One click cannot be replayed: a loop after it draws on the budget.
|
||||
inputEventHandler({}, { type: 'mouseUp' })
|
||||
for (let index = 0; index < MAX_PAGE_INITIATED_TABS_PER_WINDOW * 2; index++) {
|
||||
open(clicks + index)
|
||||
}
|
||||
expect(routedTabCount()).toBe(clicks + 1 + MAX_PAGE_INITIATED_TABS_PER_WINDOW)
|
||||
|
||||
// A click spent on a popup window cannot fund a later scripted tab.
|
||||
vi.advanceTimersByTime(PAGE_INITIATED_TAB_WINDOW_MS)
|
||||
inputEventHandler({}, { type: 'mouseUp' })
|
||||
expect(
|
||||
handler({
|
||||
url: 'https://sso.example.com/auth',
|
||||
frameName: 'sso',
|
||||
features: 'width=500,height=600',
|
||||
disposition: 'new-window'
|
||||
})
|
||||
).toMatchObject({ action: 'allow' })
|
||||
const tabsBeforeScriptedLoop = routedTabCount()
|
||||
for (let index = 0; index < MAX_PAGE_INITIATED_TABS_PER_WINDOW * 2; index++) {
|
||||
open(clicks * 2 + index)
|
||||
}
|
||||
expect(routedTabCount()).toBe(tabsBeforeScriptedLoop + MAX_PAGE_INITIATED_TABS_PER_WINDOW)
|
||||
expect(rendererSendMock).toHaveBeenCalledWith('browser:popup', {
|
||||
browserPageId: 'browser-1',
|
||||
origin: 'https://docs.example.com',
|
||||
action: 'blocked'
|
||||
})
|
||||
})
|
||||
|
||||
it('shares the page-initiated tab budget across the whole opener popup tree', () => {
|
||||
const rendererSendMock = vi.fn()
|
||||
const guest = {
|
||||
@@ -384,182 +541,6 @@ describe('browserManager', () => {
|
||||
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)
|
||||
const guest = {
|
||||
id: 141,
|
||||
isDestroyed: vi.fn(() => false),
|
||||
getType: vi.fn(() => 'webview'),
|
||||
setBackgroundThrottling: guestSetBackgroundThrottlingMock,
|
||||
setWindowOpenHandler: guestSetWindowOpenHandlerMock,
|
||||
on: guestOnMock,
|
||||
off: guestOffMock,
|
||||
openDevTools: guestOpenDevToolsMock,
|
||||
executeJavaScriptInIsolatedWorld: executeJavaScriptInIsolatedWorldMock
|
||||
}
|
||||
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 domReadyHandler = guestOnMock.mock.calls.find(([event]) => event === 'dom-ready')?.[1] as
|
||||
| (() => void)
|
||||
| undefined
|
||||
domReadyHandler?.()
|
||||
await vi.waitFor(() => expect(executeJavaScriptInIsolatedWorldMock).toHaveBeenCalledTimes(1))
|
||||
|
||||
const script = executeJavaScriptInIsolatedWorldMock.mock.calls[0][1][0].code as string
|
||||
const clickedLinkFrameNames = {
|
||||
foreground: script.match(/__orca_clicked_link_foreground_[0-9a-f-]+/)?.[0],
|
||||
background: script.match(/__orca_clicked_link_background_[0-9a-f-]+/)?.[0]
|
||||
}
|
||||
if (!clickedLinkFrameNames.foreground || !clickedLinkFrameNames.background) {
|
||||
throw new Error('Expected private clicked-link frame names')
|
||||
}
|
||||
expect(clickedLinkFrameNames.foreground).toMatch(/^__orca_clicked_link_foreground_/)
|
||||
expect(executeJavaScriptInIsolatedWorldMock).toHaveBeenCalledWith(
|
||||
expect.any(Number),
|
||||
[
|
||||
expect.objectContaining({
|
||||
code: expect.stringContaining(
|
||||
`${JSON.stringify(clickedLinkFrameNames.foreground)},${JSON.stringify(clickedLinkFrameNames.background)},${process.platform === 'darwin'})`
|
||||
)
|
||||
})
|
||||
],
|
||||
false
|
||||
)
|
||||
|
||||
const handler = guestSetWindowOpenHandlerMock.mock.calls[0][0] as (details: {
|
||||
url: string
|
||||
frameName: string
|
||||
}) => { action: 'allow' | 'deny' }
|
||||
expect(
|
||||
handler({
|
||||
url: 'https://docs.example.com/guide',
|
||||
frameName: clickedLinkFrameNames.background
|
||||
})
|
||||
).toEqual({ action: 'deny' })
|
||||
|
||||
expect(rendererSendMock).toHaveBeenCalledWith('browser:open-link-in-orca-tab', {
|
||||
browserPageId: 'browser-1',
|
||||
url: 'https://docs.example.com/guide',
|
||||
activate: false
|
||||
})
|
||||
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()
|
||||
})
|
||||
|
||||
it('routes child-frame gestures with one-use tokens', async () => {
|
||||
const rendererSendMock = vi.fn()
|
||||
const executeJavaScriptMock = vi.fn().mockResolvedValue(undefined)
|
||||
const frameOnceMock = vi.fn()
|
||||
const frame = {
|
||||
parent: {},
|
||||
isDestroyed: vi.fn(() => false),
|
||||
executeJavaScript: executeJavaScriptMock,
|
||||
once: frameOnceMock,
|
||||
off: vi.fn()
|
||||
}
|
||||
const guest = {
|
||||
id: 142,
|
||||
isDestroyed: vi.fn(() => false),
|
||||
getType: vi.fn(() => 'webview'),
|
||||
setBackgroundThrottling: guestSetBackgroundThrottlingMock,
|
||||
setWindowOpenHandler: guestSetWindowOpenHandlerMock,
|
||||
on: guestOnMock,
|
||||
off: guestOffMock,
|
||||
openDevTools: guestOpenDevToolsMock,
|
||||
executeJavaScriptInIsolatedWorld: vi.fn().mockResolvedValue(undefined)
|
||||
}
|
||||
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-frame',
|
||||
webContentsId: guest.id,
|
||||
rendererWebContentsId
|
||||
})
|
||||
const frameCreatedHandler = guestOnMock.mock.calls.find(
|
||||
([event]) => event === 'frame-created'
|
||||
)?.[1] as ((event: Electron.Event, details: Electron.FrameCreatedDetails) => void) | undefined
|
||||
frameCreatedHandler?.({} as Electron.Event, { frame } as never)
|
||||
const frameDomReadyHandler = frameOnceMock.mock.calls.find(
|
||||
([event]) => event === 'dom-ready'
|
||||
)?.[1] as (() => void) | undefined
|
||||
frameDomReadyHandler?.()
|
||||
|
||||
await vi.waitFor(() => expect(executeJavaScriptMock).toHaveBeenCalledTimes(1))
|
||||
const firstScript = executeJavaScriptMock.mock.calls[0][0] as string
|
||||
const foregroundFrameName = firstScript.match(
|
||||
/__orca_clicked_link_iframe_foreground_[0-9a-f-]+/
|
||||
)?.[0]
|
||||
const backgroundFrameName = firstScript.match(
|
||||
/__orca_clicked_link_iframe_background_[0-9a-f-]+/
|
||||
)?.[0]
|
||||
if (!foregroundFrameName) {
|
||||
throw new Error('Expected a private child-frame routing token')
|
||||
}
|
||||
if (!backgroundFrameName) {
|
||||
throw new Error('Expected a private background child-frame routing token')
|
||||
}
|
||||
expect(firstScript).toContain('installBrowserIframeClickedLinkRouting')
|
||||
expect(executeJavaScriptMock).toHaveBeenCalledWith(firstScript, false)
|
||||
|
||||
const popupHandler = guestSetWindowOpenHandlerMock.mock.calls[0][0] as (details: {
|
||||
url: string
|
||||
frameName: string
|
||||
}) => { action: string }
|
||||
expect(
|
||||
popupHandler({
|
||||
url: 'https://docs.example.com/from-frame',
|
||||
frameName: backgroundFrameName
|
||||
})
|
||||
).toEqual({ action: 'deny' })
|
||||
expect(rendererSendMock).toHaveBeenCalledWith('browser:open-link-in-orca-tab', {
|
||||
browserPageId: 'browser-frame',
|
||||
url: 'https://docs.example.com/from-frame',
|
||||
activate: false
|
||||
})
|
||||
expect(
|
||||
popupHandler({
|
||||
url: 'https://docs.example.com/from-frame-sibling',
|
||||
frameName: foregroundFrameName
|
||||
})
|
||||
).toMatchObject({ action: 'allow' })
|
||||
expect(rendererSendMock).toHaveBeenCalledTimes(2)
|
||||
|
||||
await vi.waitFor(() => expect(executeJavaScriptMock).toHaveBeenCalledTimes(2))
|
||||
const secondScript = executeJavaScriptMock.mock.calls[1][0] as string
|
||||
expect(secondScript).not.toContain(foregroundFrameName)
|
||||
expect(secondScript).not.toContain(backgroundFrameName)
|
||||
})
|
||||
|
||||
it('hosts allowed popups in an origin-bar window with inherited guest policies', () => {
|
||||
const rendererSendMock = vi.fn()
|
||||
const guestOnceMock = vi.fn()
|
||||
|
||||
@@ -46,7 +46,7 @@ export type PopupOwnerContext = {
|
||||
}
|
||||
|
||||
/**
|
||||
* What a guest is allowed to be. A browsing guest is the web — popups, clicked-link routing and
|
||||
* What a guest is allowed to be. A browsing guest is the web — popups, link gestures and
|
||||
* auth-identity tracking all apply. A workspace-document guest renders one granted document and gets none
|
||||
* of that; `host` is the renderer that minted its grant, and the only sink for what it reports.
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { isNewBrowserTabPopupIntent } from './browser-popup-new-tab-intent'
|
||||
|
||||
describe('isNewBrowserTabPopupIntent', () => {
|
||||
it.each([
|
||||
// target=_blank, window.open(url), and noopener/noreferrer opens all arrive unnamed as a tab.
|
||||
{ frameName: '', disposition: 'foreground-tab', tab: true },
|
||||
// Cmd/Ctrl-click and middle-click.
|
||||
{ frameName: '', disposition: 'background-tab', tab: true },
|
||||
// Size/position features and Shift-click.
|
||||
{ frameName: '', disposition: 'new-window', tab: false },
|
||||
// A named open may use the returned handle, as OAuth does.
|
||||
{ frameName: 'oauth', disposition: 'foreground-tab', tab: false },
|
||||
{ frameName: 'oauth', disposition: 'new-window', tab: false },
|
||||
{ frameName: '', disposition: 'other', tab: false }
|
||||
])('$frameName/$disposition → tab: $tab', ({ frameName, disposition, tab }) => {
|
||||
expect(isNewBrowserTabPopupIntent({ frameName, disposition })).toBe(tab)
|
||||
})
|
||||
})
|
||||
@@ -1,17 +1,18 @@
|
||||
/**
|
||||
* Whether a `window.open()` asks for a new tab rather than a popup window.
|
||||
* Whether a new-window request from a page should open an Orca 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.
|
||||
* Chromium's disposition already carries Chrome's answer: new-tab links, Cmd/Ctrl/middle clicks, and
|
||||
* `window.open` without window features (`noopener` and `noreferrer` are not features) arrive as a
|
||||
* tab, while size/position features and Shift-click arrive as `new-window`. Orca answers a tab by
|
||||
* denying, which hands the page `null`, so a named open — whose flow may use that handle, as OAuth
|
||||
* does — stays a child window.
|
||||
*/
|
||||
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')
|
||||
)
|
||||
}
|
||||
|
||||
@@ -3,9 +3,9 @@ import { normalizeBrowserNavigationUrl } from '../../shared/browser-url'
|
||||
import { ORCA_BROWSER_BLANK_URL } from '../../shared/constants'
|
||||
import { enforceBrowserRouteWebRtcPolicy } from './browser-route-webrtc-policy'
|
||||
import {
|
||||
trackBrowserRouteGuestPopupGesture,
|
||||
type BrowserRouteGuestPopupGesture
|
||||
} from './browser-route-guest-popup-gesture'
|
||||
trackBrowserGuestInputGesture,
|
||||
type BrowserGuestInputGesture
|
||||
} from './browser-guest-input-gesture'
|
||||
import {
|
||||
registerBrowserRouteGuestPopup,
|
||||
releaseBrowserRouteGuestPopup
|
||||
@@ -46,9 +46,9 @@ export function createBrowserRouteGuestPopupController(input: {
|
||||
}): BrowserRouteGuestPopupController {
|
||||
const openPopupWindow = input.dependencies.openPopupWindow ?? openPopupWithOriginBar
|
||||
const popups = new Set<BrowserRouteGuestPopupWindow>()
|
||||
const gestures = new Set<BrowserRouteGuestPopupGesture>()
|
||||
const gestures = new Set<BrowserGuestInputGesture>()
|
||||
const popupWebContentsIds = new Set<number>()
|
||||
const openerGesture = trackBrowserRouteGuestPopupGesture(input.opener)
|
||||
const openerGesture = trackBrowserGuestInputGesture(input.opener)
|
||||
gestures.add(openerGesture)
|
||||
// Why only dispose latches: fencing must be reversible, because a reconnected page regains
|
||||
// navigation authority and its OAuth popups have to work again. `isNavigationAllowed` is what
|
||||
@@ -78,7 +78,7 @@ export function createBrowserRouteGuestPopupController(input: {
|
||||
if (!enforceBrowserRouteWebRtcPolicy(contents, () => {})) {
|
||||
return false
|
||||
}
|
||||
const gesture = trackBrowserRouteGuestPopupGesture(contents)
|
||||
const gesture = trackBrowserGuestInputGesture(contents)
|
||||
gestures.add(gesture)
|
||||
try {
|
||||
// Descendants inherit the same envelope; nothing about being a popup relaxes it.
|
||||
@@ -141,7 +141,7 @@ export function createBrowserRouteGuestPopupController(input: {
|
||||
}
|
||||
|
||||
function buildWindowOpenHandler(
|
||||
gesture: BrowserRouteGuestPopupGesture
|
||||
gesture: BrowserGuestInputGesture
|
||||
): (details?: { url?: string }) => Electron.WindowOpenHandlerResponse {
|
||||
return (details) => {
|
||||
const normalized = normalizeRoutePopupUrl(details?.url)
|
||||
|
||||
Reference in New Issue
Block a user