mirror of
https://github.com/stablyai/orca.git
synced 2026-11-13 17:37:40 +00:00
fix(browser): let This-device pages open popups and new tabs
The client-hosted webview had no allowpopups, so Electron returned null from window.open and dropped target=_blank before any handler ran. With popups allowed, the route guest's popup handler sent every popup to a child window, so tab-shaped opens now go through the manager's new-tab policy and land as This-device tabs. OAuth-style popups keep their opener in a child window.
This commit is contained in:
+51
-35
@@ -3,11 +3,15 @@ import {
|
||||
normalizeExternalBrowserUrl,
|
||||
redactKagiSessionToken
|
||||
} from '../../../../../shared/browser-url'
|
||||
import { trackBrowserGuestInputGesture } from '../../input/browser-guest-input-gesture'
|
||||
import {
|
||||
trackBrowserGuestInputGesture,
|
||||
type ObservedBrowserGuestGesture
|
||||
} from '../../input/browser-guest-input-gesture'
|
||||
import {
|
||||
isNewBrowserTabPopupIntent,
|
||||
isBrowserTabPopupRequest,
|
||||
normalizeBrowserPopupUrl
|
||||
normalizeBrowserPopupUrl,
|
||||
type NewBrowserTabPopupRequest
|
||||
} from '../../popups/browser-popup-new-tab-intent'
|
||||
import { SAFE_POPUP_WINDOW_OPTIONS } from '../../popups/presentation/browser-popup-window-policy'
|
||||
import { safeOrigin } from '../../browser-event-origin'
|
||||
@@ -37,45 +41,16 @@ export abstract class BrowserManagerGuestPopupPolicy extends BrowserManagerNavig
|
||||
guest.setWindowOpenHandler(({ url, frameName, disposition, postBody, features, referrer }) => {
|
||||
// Spend activation even on popups so it cannot fund a later tab.
|
||||
const observed = gesture.consume()
|
||||
if (this.openNewTabPopup(guest, { url, frameName, disposition, postBody }, observed)) {
|
||||
return { action: 'deny' }
|
||||
}
|
||||
const clicked = observed !== null
|
||||
// Only the desktop user's own click may bring a popup forward; agents and late opens wait.
|
||||
const showWhenAppActive = observed?.byAgent !== false
|
||||
const activateTab = disposition !== 'background-tab'
|
||||
const agentOpened = observed?.byAgent !== false && this.isAgentUsingGuest(guest.id)
|
||||
const ownerContext = this.resolvePopupOwnerContext(guest.id)
|
||||
const browserTabId = ownerContext?.browserTabId ?? null
|
||||
const browserTabId = this.resolvePopupOwnerContext(guest.id)?.browserTabId ?? null
|
||||
const externalUrl = normalizeExternalBrowserUrl(url)
|
||||
const newTabUrl = externalUrl ?? (isLocalFileLinkFromLocalPage(guest, url) ? url : null)
|
||||
// Opener-dependent requests retain a child window instead of becoming tabs.
|
||||
if (
|
||||
ownerContext &&
|
||||
newTabUrl &&
|
||||
isNewBrowserTabPopupIntent({ url, frameName, disposition, postBody })
|
||||
) {
|
||||
// Scripted opens persist as tabs, so opens without observed input consume a budget.
|
||||
if (!clicked && !this.tryConsumePageInitiatedTab(ownerContext.rootGuestWebContentsId)) {
|
||||
this.forwardOrQueuePopupEvent(guest.id, {
|
||||
origin: safeOrigin(newTabUrl),
|
||||
action: 'blocked'
|
||||
})
|
||||
return { action: 'deny' }
|
||||
}
|
||||
// A new tab's download would miss the agent's `download` claim, so save the link here.
|
||||
if (this.isAwaitingDownloadFrom(guest.id, ownerContext.rootGuestWebContentsId)) {
|
||||
guest.downloadURL(newTabUrl)
|
||||
return { action: 'deny' }
|
||||
}
|
||||
if (
|
||||
this.openLinkInOrcaTab(ownerContext.browserTabId, newTabUrl, activateTab, agentOpened)
|
||||
) {
|
||||
this.forwardOrQueuePopupEvent(guest.id, {
|
||||
origin: safeOrigin(newTabUrl),
|
||||
action: 'opened-in-orca'
|
||||
})
|
||||
}
|
||||
// A vanished renderer must not turn new-tab intent into a native popup.
|
||||
return { action: 'deny' }
|
||||
}
|
||||
|
||||
// Native popups must not target local files.
|
||||
const canOpenAsChild = normalizeBrowserPopupUrl(url) !== null
|
||||
@@ -148,6 +123,47 @@ export abstract class BrowserManagerGuestPopupPolicy extends BrowserManagerNavig
|
||||
}
|
||||
}
|
||||
|
||||
/** Answers a new-tab request as an Orca tab; false leaves opener-dependent requests to a child window. */
|
||||
openNewTabPopup(
|
||||
guest: Electron.WebContents,
|
||||
request: NewBrowserTabPopupRequest,
|
||||
observed: ObservedBrowserGuestGesture | null
|
||||
): boolean {
|
||||
const ownerContext = this.resolvePopupOwnerContext(guest.id)
|
||||
const newTabUrl =
|
||||
normalizeExternalBrowserUrl(request.url) ??
|
||||
(isLocalFileLinkFromLocalPage(guest, request.url) ? request.url : null)
|
||||
if (!ownerContext || !newTabUrl || !isNewBrowserTabPopupIntent(request)) {
|
||||
return false
|
||||
}
|
||||
// Scripted opens persist as tabs, so opens without observed input consume a budget.
|
||||
if (!observed && !this.tryConsumePageInitiatedTab(ownerContext.rootGuestWebContentsId)) {
|
||||
this.forwardOrQueuePopupEvent(guest.id, { origin: safeOrigin(newTabUrl), action: 'blocked' })
|
||||
return true
|
||||
}
|
||||
// A new tab's download would miss the agent's `download` claim, so save the link here.
|
||||
if (this.isAwaitingDownloadFrom(guest.id, ownerContext.rootGuestWebContentsId)) {
|
||||
guest.downloadURL(newTabUrl)
|
||||
return true
|
||||
}
|
||||
const agentOpened = observed?.byAgent !== false && this.isAgentUsingGuest(guest.id)
|
||||
if (
|
||||
this.openLinkInOrcaTab(
|
||||
ownerContext.browserTabId,
|
||||
newTabUrl,
|
||||
request.disposition !== 'background-tab',
|
||||
agentOpened
|
||||
)
|
||||
) {
|
||||
this.forwardOrQueuePopupEvent(guest.id, {
|
||||
origin: safeOrigin(newTabUrl),
|
||||
action: 'opened-in-orca'
|
||||
})
|
||||
}
|
||||
// A vanished renderer must not turn new-tab intent into a native popup.
|
||||
return true
|
||||
}
|
||||
|
||||
requestExternalAppLaunch(
|
||||
guest: Electron.WebContents,
|
||||
rawUrl: string | undefined,
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
// Keep named and OAuth popups attached to their opener; tab conversion returns null to the page.
|
||||
export function isNewBrowserTabPopupIntent(details: {
|
||||
export type NewBrowserTabPopupRequest = {
|
||||
url: string
|
||||
frameName: string
|
||||
disposition: string
|
||||
postBody?: Electron.PostBody | null
|
||||
}): boolean {
|
||||
}
|
||||
|
||||
// Keep named and OAuth popups attached to their opener; tab conversion returns null to the page.
|
||||
export function isNewBrowserTabPopupIntent(details: NewBrowserTabPopupRequest): boolean {
|
||||
return (
|
||||
isBrowserTabPopupRequest(details) &&
|
||||
!isBlankBrowserPopupUrl(details.url) &&
|
||||
|
||||
@@ -3,11 +3,15 @@ import '../../../../shared/constants'
|
||||
import { ORCA_BROWSER_BLANK_URL } from '../../../../shared/browser/browser-blank-page'
|
||||
import {
|
||||
isBlankBrowserPopupUrl,
|
||||
normalizeBrowserPopupUrl
|
||||
normalizeBrowserPopupUrl,
|
||||
type NewBrowserTabPopupRequest
|
||||
} from '../../guest-lifecycle/popups/browser-popup-new-tab-intent'
|
||||
import { enforceBrowserRouteWebRtcPolicy } from '../../network/egress/browser-route-webrtc-policy'
|
||||
import { trackBrowserGuestInputGesture } from '../../guest-lifecycle/input/browser-guest-input-gesture'
|
||||
import type { BrowserGuestInputGesture } from '../../guest-lifecycle/input/browser-guest-input-gesture'
|
||||
import type {
|
||||
BrowserGuestInputGesture,
|
||||
ObservedBrowserGuestGesture
|
||||
} from '../../guest-lifecycle/input/browser-guest-input-gesture'
|
||||
import {
|
||||
registerBrowserRouteGuestPopup,
|
||||
releaseBrowserRouteGuestPopup
|
||||
@@ -28,6 +32,12 @@ export type BrowserRouteGuestPopupDependencies = {
|
||||
getPartitionForSession: (session: Session) => string | null
|
||||
/** Route guests replace the manager's popup handler, so a denial is otherwise entirely silent. */
|
||||
reportBlockedPopup?: (input: { openerWebContentsId: number; url: string }) => void
|
||||
/** The manager's new-tab policy; true when it answered the request. */
|
||||
openNewTabPopup?: (
|
||||
source: WebContents,
|
||||
request: NewBrowserTabPopupRequest,
|
||||
observed: ObservedBrowserGuestGesture | null
|
||||
) => boolean
|
||||
openPopupWindow?: (
|
||||
options: PopupChildWindowOptions,
|
||||
targetUrl: string,
|
||||
@@ -172,13 +182,23 @@ export function createBrowserRouteGuestPopupController(input: {
|
||||
return (details) => {
|
||||
const observed = gesture.consume()
|
||||
const normalized = normalizeBrowserPopupUrl(details?.url)
|
||||
const permitted = !disposed && normalized !== null && navigationAllowed(normalized)
|
||||
if (
|
||||
disposed ||
|
||||
!normalized ||
|
||||
popups.size >= MAX_BROWSER_ROUTE_GUEST_POPUPS ||
|
||||
!navigationAllowed(normalized) ||
|
||||
!observed
|
||||
permitted &&
|
||||
input.dependencies.openNewTabPopup?.(
|
||||
source,
|
||||
{
|
||||
url: details?.url ?? normalized,
|
||||
frameName: details?.frameName ?? '',
|
||||
disposition: details?.disposition ?? '',
|
||||
postBody: details?.postBody
|
||||
},
|
||||
observed
|
||||
)
|
||||
) {
|
||||
return { action: 'deny' }
|
||||
}
|
||||
if (!permitted || popups.size >= MAX_BROWSER_ROUTE_GUEST_POPUPS || !observed) {
|
||||
if (!disposed) {
|
||||
reportBlockedPopup(details?.url)
|
||||
}
|
||||
|
||||
+2
@@ -4,6 +4,7 @@ import type {
|
||||
BrowserRoutePageOwnerIdentity
|
||||
} from './browser-route-page-authority'
|
||||
import type { BrowserRouteSessionRekey } from '../sessions/browser-route-session-state'
|
||||
import type { BrowserRouteGuestPopupDependencies } from './browser-route-guest-popups'
|
||||
|
||||
export type BrowserRouteWebContentsRegistryDependencies = {
|
||||
getPartitionForSession(session: Session): string | null
|
||||
@@ -16,5 +17,6 @@ export type BrowserRouteWebContentsRegistryDependencies = {
|
||||
retirePreparedPagesOwnedByRenderer(rendererWebContentsId: number): number
|
||||
/** Route-guest popup denials need an explicit notice on the owning page. */
|
||||
reportBlockedPopup?(input: { openerWebContentsId: number; url: string }): void
|
||||
openNewTabPopup?: BrowserRouteGuestPopupDependencies['openNewTabPopup']
|
||||
maxGuests?: number
|
||||
}
|
||||
|
||||
@@ -73,6 +73,7 @@ export class BrowserRouteWebContentsRegistry {
|
||||
getPartitionForSession: (routeSession) =>
|
||||
this.dependencies.getPartitionForSession(routeSession),
|
||||
reportBlockedPopup: (blocked) => this.dependencies.reportBlockedPopup?.(blocked),
|
||||
openNewTabPopup: this.dependencies.openNewTabPopup,
|
||||
isPopupNavigationAllowed: (url) => this.navigationAllowed(state, url, false)
|
||||
})
|
||||
} catch {
|
||||
|
||||
@@ -88,7 +88,9 @@ export const browserRouteWebContentsRegistry = new BrowserRouteWebContentsRegist
|
||||
retirePreparedPage: (page) => browserRouteSessionRegistry.retirePreparedPage(page),
|
||||
retirePreparedPagesOwnedByRenderer: (rendererWebContentsId) =>
|
||||
browserRouteSessionRegistry.retirePreparedPagesOwnedByRenderer(rendererWebContentsId),
|
||||
reportBlockedPopup: (blocked) => browserManager.reportRouteGuestPopupBlocked(blocked)
|
||||
reportBlockedPopup: (blocked) => browserManager.reportRouteGuestPopupBlocked(blocked),
|
||||
openNewTabPopup: (source, request, observed) =>
|
||||
browserManager.openNewTabPopup(source, request, observed)
|
||||
})
|
||||
routeWebContentsRegistryRef.current = browserRouteWebContentsRegistry
|
||||
|
||||
|
||||
@@ -66,6 +66,7 @@ export function createBrowserClientPageWebview(options: {
|
||||
const webview =
|
||||
options.createWebview?.() ?? (options.document.createElement('webview') as Electron.WebviewTag)
|
||||
webview.setAttribute('partition', options.identity.partition)
|
||||
webview.setAttribute('allowpopups', '')
|
||||
webview.setAttribute('webpreferences', ORCA_BROWSER_PAGE_GUEST_WEB_PREFERENCES_ATTRIBUTE)
|
||||
webview.setAttribute('src', 'about:blank')
|
||||
Object.assign(webview.style, { display: 'flex', width: '100%', height: '100%' })
|
||||
|
||||
@@ -1,157 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const {
|
||||
createBrowserTabMock,
|
||||
createWebRuntimeSessionBrowserTabMock,
|
||||
dispatchEventMock,
|
||||
runtimeOwner,
|
||||
storeState
|
||||
} = vi.hoisted(() => ({
|
||||
createBrowserTabMock: vi.fn(),
|
||||
createWebRuntimeSessionBrowserTabMock: vi.fn(),
|
||||
dispatchEventMock: vi.fn(),
|
||||
runtimeOwner: { value: null as string | null },
|
||||
storeState: { value: {} as Record<string, unknown> }
|
||||
}))
|
||||
|
||||
vi.mock('../../store', () => ({
|
||||
useAppStore: { getState: () => storeState.value }
|
||||
}))
|
||||
vi.mock('@/store/index', () => ({
|
||||
useAppStore: { getState: () => storeState.value }
|
||||
}))
|
||||
vi.mock('@/lib/workspace/ownership/worktree-runtime-owner', () => ({
|
||||
runtimeEnvironmentIdOrLocalUnlessTwin: () => runtimeOwner.value
|
||||
}))
|
||||
vi.mock('@/runtime/browser/creation/web-runtime-browser-creation', () => ({
|
||||
createWebRuntimeSessionBrowserTab: createWebRuntimeSessionBrowserTabMock
|
||||
}))
|
||||
vi.mock('@/lib/client-creation-action-error', () => ({
|
||||
showClientCreationActionError: vi.fn()
|
||||
}))
|
||||
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
|
||||
activate?: boolean
|
||||
}) => void {
|
||||
let handler:
|
||||
| ((event: { browserPageId: string; url: string; activate?: boolean }) => void)
|
||||
| null = null
|
||||
const browserApi = new Proxy(
|
||||
{
|
||||
onOpenLinkInOrcaTab: (
|
||||
callback: (event: { browserPageId: string; url: string; activate?: boolean }) => 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 })
|
||||
})
|
||||
vi.stubGlobal('window', { api, dispatchEvent: dispatchEventMock })
|
||||
registerBrowserStateIpcBridge([], () => false)
|
||||
if (!handler) {
|
||||
throw new Error('Expected the bridge to subscribe to browser:open-link-in-orca-tab')
|
||||
}
|
||||
return handler
|
||||
}
|
||||
|
||||
function clientHostedStore(handle: Record<string, unknown>): void {
|
||||
storeState.value = {
|
||||
browserPagesByWorkspace: {
|
||||
opener: [
|
||||
{
|
||||
id: 'page-1',
|
||||
workspaceId: 'opener',
|
||||
worktreeId: 'worktree-1'
|
||||
}
|
||||
]
|
||||
},
|
||||
browserTabsByWorktree: {
|
||||
'worktree-1': [{ id: 'opener', sessionProfileId: 'profile-a', sessionPartition: 'persist:a' }]
|
||||
},
|
||||
unifiedTabsByWorktree: {
|
||||
'worktree-1': [
|
||||
{ id: 'unified-browser', groupId: 'source', contentType: 'browser', entityId: 'opener' }
|
||||
]
|
||||
},
|
||||
groupsByWorktree: {
|
||||
'worktree-1': [{ id: 'source', tabOrder: ['unified-browser'] }]
|
||||
},
|
||||
remoteBrowserPageHandlesByPageId: { 'page-1': handle },
|
||||
createBrowserTab: createBrowserTabMock,
|
||||
focusGroup: vi.fn()
|
||||
}
|
||||
}
|
||||
|
||||
describe('client-hosted link opens on a paired server', () => {
|
||||
beforeEach(() => {
|
||||
createBrowserTabMock.mockReset()
|
||||
createWebRuntimeSessionBrowserTabMock.mockReset()
|
||||
createWebRuntimeSessionBrowserTabMock.mockResolvedValue(true)
|
||||
dispatchEventMock.mockReset()
|
||||
runtimeOwner.value = 'env-paired'
|
||||
})
|
||||
|
||||
it('opens a This device page link through the paired creator instead of dropping it', () => {
|
||||
clientHostedStore({
|
||||
environmentId: 'env-paired',
|
||||
remotePageId: 'remote-1',
|
||||
placement: { kind: 'client', browserHostClientId: 'this-desktop' }
|
||||
})
|
||||
captureOpenLinkHandler()({
|
||||
browserPageId: 'page-1',
|
||||
url: 'https://docs.example.com/next',
|
||||
activate: true
|
||||
})
|
||||
expect(createBrowserTabMock).not.toHaveBeenCalled()
|
||||
expect(createWebRuntimeSessionBrowserTabMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
worktreeId: 'worktree-1',
|
||||
environmentId: 'env-paired',
|
||||
url: 'https://docs.example.com/next',
|
||||
profileId: 'profile-a',
|
||||
clientAfterTabId: 'unified-browser',
|
||||
focusOnCreate: true,
|
||||
selectWorktree: false
|
||||
})
|
||||
)
|
||||
expect(createWebRuntimeSessionBrowserTabMock.mock.calls[0]?.[0]).not.toHaveProperty(
|
||||
'placementPreference'
|
||||
)
|
||||
})
|
||||
|
||||
it('still drops a streamed remote page instead of creating a local tab', () => {
|
||||
clientHostedStore({
|
||||
environmentId: 'env-paired',
|
||||
remotePageId: 'remote-1',
|
||||
placement: { kind: 'server' }
|
||||
})
|
||||
captureOpenLinkHandler()({
|
||||
browserPageId: 'page-1',
|
||||
url: 'https://docs.example.com/streamed'
|
||||
})
|
||||
expect(createBrowserTabMock).not.toHaveBeenCalled()
|
||||
expect(createWebRuntimeSessionBrowserTabMock).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -1,113 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { createBrowserTabMock, createWebRuntimeSessionBrowserTabMock, runtimeOwner, storeState } =
|
||||
vi.hoisted(() => ({
|
||||
createBrowserTabMock: vi.fn(),
|
||||
createWebRuntimeSessionBrowserTabMock: vi.fn(),
|
||||
runtimeOwner: { value: null as string | null },
|
||||
storeState: { value: {} as Record<string, unknown> }
|
||||
}))
|
||||
|
||||
vi.mock('@/store/index', () => ({
|
||||
useAppStore: { getState: () => storeState.value }
|
||||
}))
|
||||
vi.mock('@/lib/workspace/ownership/worktree-runtime-owner', () => ({
|
||||
runtimeEnvironmentIdOrLocalUnlessTwin: () => runtimeOwner.value
|
||||
}))
|
||||
vi.mock('@/runtime/browser/creation/web-runtime-browser-creation', () => ({
|
||||
createWebRuntimeSessionBrowserTab: createWebRuntimeSessionBrowserTabMock
|
||||
}))
|
||||
vi.mock('@/lib/client-creation-action-error', () => ({
|
||||
showClientCreationActionError: vi.fn()
|
||||
}))
|
||||
|
||||
import { openLinkInNewBrowserTab } from './open-client-hosted-link-beside-page'
|
||||
|
||||
describe('openLinkInNewBrowserTab', () => {
|
||||
beforeEach(() => {
|
||||
createBrowserTabMock.mockReset()
|
||||
createBrowserTabMock.mockReturnValue({ id: 'new-tab', activePageId: null })
|
||||
createWebRuntimeSessionBrowserTabMock.mockReset()
|
||||
createWebRuntimeSessionBrowserTabMock.mockResolvedValue(true)
|
||||
runtimeOwner.value = null
|
||||
})
|
||||
|
||||
it('opens a restored This device page in the background through the paired creator', () => {
|
||||
runtimeOwner.value = 'env-paired'
|
||||
storeState.value = {
|
||||
browserPagesByWorkspace: {
|
||||
opener: [{ id: 'page-1', workspaceId: 'opener', worktreeId: 'worktree-1' }]
|
||||
},
|
||||
browserTabsByWorktree: {
|
||||
'worktree-1': [{ id: 'opener', sessionProfileId: 'profile-a' }]
|
||||
},
|
||||
unifiedTabsByWorktree: {
|
||||
'worktree-1': [
|
||||
{ id: 'unified-browser', groupId: 'source', contentType: 'browser', entityId: 'opener' }
|
||||
]
|
||||
},
|
||||
groupsByWorktree: {
|
||||
'worktree-1': [{ id: 'source', tabOrder: ['unified-browser'] }]
|
||||
},
|
||||
remoteBrowserPageHandlesByPageId: {
|
||||
'page-1': {
|
||||
environmentId: 'env-paired',
|
||||
remotePageId: 'remote-1',
|
||||
restoredClientHosted: true
|
||||
}
|
||||
},
|
||||
createBrowserTab: createBrowserTabMock
|
||||
}
|
||||
openLinkInNewBrowserTab({
|
||||
sourcePageId: 'page-1',
|
||||
worktreeId: 'worktree-1',
|
||||
url: 'https://docs.example.com/menu',
|
||||
activate: false
|
||||
})
|
||||
expect(createBrowserTabMock).not.toHaveBeenCalled()
|
||||
expect(createWebRuntimeSessionBrowserTabMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
worktreeId: 'worktree-1',
|
||||
environmentId: 'env-paired',
|
||||
url: 'https://docs.example.com/menu',
|
||||
profileId: 'profile-a',
|
||||
clientAfterTabId: 'unified-browser',
|
||||
focusOnCreate: false,
|
||||
selectWorktree: false
|
||||
})
|
||||
)
|
||||
expect(createWebRuntimeSessionBrowserTabMock.mock.calls[0]?.[0]).not.toHaveProperty(
|
||||
'placementPreference'
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps a local page on createBrowserTab', () => {
|
||||
storeState.value = {
|
||||
browserPagesByWorkspace: {
|
||||
opener: [{ id: 'page-1', workspaceId: 'opener', worktreeId: 'worktree-1' }]
|
||||
},
|
||||
browserTabsByWorktree: {
|
||||
'worktree-1': [{ id: 'opener', sessionProfileId: 'profile-a' }]
|
||||
},
|
||||
unifiedTabsByWorktree: {},
|
||||
groupsByWorktree: {},
|
||||
remoteBrowserPageHandlesByPageId: {},
|
||||
createBrowserTab: createBrowserTabMock
|
||||
}
|
||||
openLinkInNewBrowserTab({
|
||||
sourcePageId: 'page-1',
|
||||
worktreeId: 'worktree-1',
|
||||
url: 'https://docs.example.com/local',
|
||||
activate: false
|
||||
})
|
||||
expect(createWebRuntimeSessionBrowserTabMock).not.toHaveBeenCalled()
|
||||
expect(createBrowserTabMock).toHaveBeenCalledWith(
|
||||
'worktree-1',
|
||||
'https://docs.example.com/local',
|
||||
expect.objectContaining({
|
||||
activate: false,
|
||||
sessionProfileId: 'profile-a'
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -1,4 +1,4 @@
|
||||
import { showClientCreationActionError } from '@/lib/client-creation-action-error'
|
||||
import { showClientCreationActionError } from '@/lib/workspace/creation/surface-policy/client-creation-action-error'
|
||||
import { runtimeEnvironmentIdOrLocalUnlessTwin } from '@/lib/workspace/ownership/worktree-runtime-owner'
|
||||
import { createWebRuntimeSessionBrowserTab } from '@/runtime/browser/creation/web-runtime-browser-creation'
|
||||
import { useAppStore } from '@/store/index'
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
import { createServer, type Server } from 'node:http'
|
||||
import type { AddressInfo } from 'node:net'
|
||||
import type { Page } from '@stablyai/playwright-test'
|
||||
import { expect, test } from './helpers/orca-app'
|
||||
import { launchHeadlessPairedRuntimeHost } from './helpers/headless-paired-runtime-host'
|
||||
import {
|
||||
launchPairedElectronClient,
|
||||
type PairedElectronClient
|
||||
} from './helpers/paired-electron-client'
|
||||
import { waitForPairedClientWorktree } from './helpers/paired-client-host-session'
|
||||
import { readHostBrowserPageUrls } from './helpers/host-session-tabs'
|
||||
|
||||
// A This-device page opens target=_blank links as This-device tabs and keeps OAuth popups attached to their opener.
|
||||
|
||||
const OPENER_HTML = `<!doctype html><title>Opener</title>
|
||||
<style>body{margin:0}a,button{display:block;width:300px;height:60px;margin:10px}</style>
|
||||
<a id="blank" href="/destination" target="_blank">blank link</a>
|
||||
<button id="oauth" onclick="startOauth()">sign in</button>
|
||||
<script>
|
||||
window.__oauthMessages = []
|
||||
addEventListener('message', (event) => window.__oauthMessages.push(String(event.data)))
|
||||
function startOauth() {
|
||||
const redirect = encodeURIComponent(location.origin + '/callback')
|
||||
window.__oauthPopup = window.open('/authorize?client_id=e2e&redirect_uri=' + redirect + '&response_type=code', 'oauth', 'popup,width=480,height=640')
|
||||
}
|
||||
</script>`
|
||||
|
||||
const CALLBACK_HTML = `<!doctype html><title>Callback</title><script>
|
||||
window.opener.postMessage('code=abc', '*')
|
||||
setTimeout(() => window.close(), 100)
|
||||
</script>`
|
||||
|
||||
async function startFixture(): Promise<{ server: Server; origin: string }> {
|
||||
const server = createServer((request, response) => {
|
||||
const path = new URL(request.url ?? '/', 'http://127.0.0.1').pathname
|
||||
response.writeHead(200, { 'content-type': 'text/html; charset=utf-8' })
|
||||
if (path === '/opener') {
|
||||
response.end(OPENER_HTML)
|
||||
} else if (path === '/authorize') {
|
||||
response.end(`<script>location.replace('/callback?code=abc')</script>`)
|
||||
} else if (path === '/callback') {
|
||||
response.end(CALLBACK_HTML)
|
||||
} else {
|
||||
response.end('<!doctype html><title>Destination</title>')
|
||||
}
|
||||
})
|
||||
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve))
|
||||
return { server, origin: `http://127.0.0.1:${(server.address() as AddressInfo).port}` }
|
||||
}
|
||||
|
||||
function readPagePlacements(page: Page, worktreeId: string): Promise<Record<string, string>> {
|
||||
return page.evaluate((worktreeId) => {
|
||||
const state = window.__store!.getState()
|
||||
const placements: Record<string, string> = {}
|
||||
for (const workspace of state.browserTabsByWorktree[worktreeId] ?? []) {
|
||||
for (const browserPage of state.browserPagesByWorkspace[workspace.id] ?? []) {
|
||||
const kind = state.remoteBrowserPageHandlesByPageId[browserPage.id]?.placement?.kind
|
||||
placements[new URL(browserPage.url).pathname] = kind ?? 'none'
|
||||
}
|
||||
}
|
||||
return placements
|
||||
}, worktreeId)
|
||||
}
|
||||
|
||||
async function showPage(page: Page, worktreeId: string, path: string): Promise<void> {
|
||||
await page.evaluate(
|
||||
({ worktreeId, path }) => {
|
||||
const state = window.__store!.getState()
|
||||
for (const workspace of state.browserTabsByWorktree[worktreeId] ?? []) {
|
||||
for (const browserPage of state.browserPagesByWorkspace[workspace.id] ?? []) {
|
||||
if (new URL(browserPage.url).pathname === path) {
|
||||
state.focusBrowserTabInWorktree(worktreeId, browserPage.id, { surfacePane: true })
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{ worktreeId, path }
|
||||
)
|
||||
}
|
||||
|
||||
function guestEval(client: PairedElectronClient, url: string, code: string): Promise<unknown> {
|
||||
return client.app.evaluate(
|
||||
({ webContents }, { url, code }) =>
|
||||
webContents
|
||||
.getAllWebContents()
|
||||
.find((contents) => contents.getType() === 'webview' && contents.getURL() === url)
|
||||
?.executeJavaScript(code) ?? null,
|
||||
{ url, code }
|
||||
)
|
||||
}
|
||||
|
||||
// A real mouse click, so main sees the user's gesture as it would on a desktop.
|
||||
async function clickInGuest(
|
||||
client: PairedElectronClient,
|
||||
url: string,
|
||||
selector: string
|
||||
): Promise<void> {
|
||||
await expect
|
||||
.poll(() => guestEval(client, url, 'innerWidth > 320 && innerHeight > 200'), {
|
||||
timeout: 30_000
|
||||
})
|
||||
.toBe(true)
|
||||
await client.app.evaluate(
|
||||
async ({ webContents }, { url, selector }) => {
|
||||
const guest = webContents
|
||||
.getAllWebContents()
|
||||
.find((contents) => contents.getType() === 'webview' && contents.getURL() === url)!
|
||||
const { x, y } = (await guest.executeJavaScript(
|
||||
`(() => { const r = document.querySelector(${JSON.stringify(selector)}).getBoundingClientRect(); return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) } })()`
|
||||
)) as { x: number; y: number }
|
||||
guest.sendInputEvent({ type: 'mouseDown', x, y, button: 'left', clickCount: 1 })
|
||||
guest.sendInputEvent({ type: 'mouseUp', x, y, button: 'left', clickCount: 1 })
|
||||
},
|
||||
{ url, selector }
|
||||
)
|
||||
}
|
||||
|
||||
test('a This-device page opens new tabs on this device and keeps OAuth popups working', async ({
|
||||
testRepoPath
|
||||
}, testInfo) => {
|
||||
test.setTimeout(300_000)
|
||||
const { server, origin } = await startFixture()
|
||||
const host = await launchHeadlessPairedRuntimeHost()
|
||||
let client: PairedElectronClient | null = null
|
||||
try {
|
||||
await host.client.call('repo.add', { path: testRepoPath, kind: 'git' })
|
||||
client = await launchPairedElectronClient(host.offer, testInfo, 'This-device popups')
|
||||
const page = client.page
|
||||
const worktreeId = await waitForPairedClientWorktree(page)
|
||||
await page.evaluate(
|
||||
({ environmentId, worktreeId }) =>
|
||||
window.__store!.getState().setActiveWorktree(worktreeId, `runtime:${environmentId}`),
|
||||
{ environmentId: client.environmentId, worktreeId }
|
||||
)
|
||||
const openerUrl = `${origin}/opener`
|
||||
await page.evaluate(async (url) => {
|
||||
const state = window.__store!.getState()
|
||||
state.setBrowserDefaultUrl(url)
|
||||
await state.openNewBrowserTabInActiveWorkspace(
|
||||
state.activeGroupIdByWorktree[state.activeWorktreeId!]!
|
||||
)
|
||||
}, openerUrl)
|
||||
await expect
|
||||
.poll(() => readPagePlacements(page, worktreeId), { timeout: 60_000 })
|
||||
.toEqual({ '/opener': 'client' })
|
||||
await expect
|
||||
.poll(() => guestEval(client!, openerUrl, 'document.readyState'), { timeout: 60_000 })
|
||||
.toBe('complete')
|
||||
|
||||
await showPage(page, worktreeId, '/opener')
|
||||
await clickInGuest(client, openerUrl, '#blank')
|
||||
await expect
|
||||
.poll(() => readPagePlacements(page, worktreeId), {
|
||||
timeout: 30_000,
|
||||
message: 'the target=_blank link did not open a This-device tab'
|
||||
})
|
||||
.toEqual({ '/opener': 'client', '/destination': 'client' })
|
||||
await expect
|
||||
.poll(() => readHostBrowserPageUrls(host.client, `path:${testRepoPath}`), { timeout: 30_000 })
|
||||
.toContain(`${origin}/destination`)
|
||||
|
||||
await showPage(page, worktreeId, '/opener')
|
||||
await clickInGuest(client, openerUrl, '#oauth')
|
||||
await expect
|
||||
.poll(() => guestEval(client!, openerUrl, 'window.__oauthMessages.join()'), {
|
||||
timeout: 30_000,
|
||||
message: 'the OAuth popup never reached its opener'
|
||||
})
|
||||
.toBe('code=abc')
|
||||
await expect
|
||||
.poll(() => guestEval(client!, openerUrl, 'window.__oauthPopup.closed'), { timeout: 10_000 })
|
||||
.toBe(true)
|
||||
} finally {
|
||||
await client?.dispose()
|
||||
await host.dispose()
|
||||
server.close()
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user