Restore the feature removed by #4886 on current master. Preserve the current settings entry point, moving-menu behavior, and desktop update UI.
171 lines
8.0 KiB
TypeScript
171 lines
8.0 KiB
TypeScript
/** Main-process ownership and fixed isolation policy for Sidebar webview guests. */
|
|
import { randomUUID } from 'node:crypto'
|
|
import { app, session, type BrowserWindow, type Session, type WebContents } from 'electron'
|
|
import type { DesktopBrowserLeaseId, DesktopBrowserOpenRequest, DesktopBrowserReservation } from '@deepseek-ai/dsh-client-ui-sidebar-browser/types'
|
|
import { DESKTOP_IPC } from './ipc.ts'
|
|
|
|
interface GuestLease {
|
|
readonly owner: WebContents
|
|
readonly partition: string
|
|
attached: boolean
|
|
guest?: WebContents
|
|
releaseInput?: () => void
|
|
}
|
|
|
|
/** Owns workspace storage partitions independently from individual tab guests. */
|
|
export class DesktopBrowserGuests {
|
|
private readonly partitions = new Map<string, string>()
|
|
private readonly leases = new Map<DesktopBrowserLeaseId, GuestLease>()
|
|
|
|
/** @param hostUrl - current authenticated DSH Host, which guests cannot request. */
|
|
constructor(private readonly hostUrl: () => string | undefined) {}
|
|
|
|
/**
|
|
* Reserve one guest in a workspace's process-lifetime partition.
|
|
* @param owner - authenticated primary application WebContents.
|
|
* @param workspace - workspace identity received over IPC.
|
|
* @returns opaque lease and the partition approved for it.
|
|
*/
|
|
acquire(owner: WebContents, workspace: unknown): DesktopBrowserReservation {
|
|
if (typeof workspace !== 'string' || workspace.length === 0 || workspace.length > 4096) {
|
|
throw new Error('desktop browser: a workspace storage identity is required')
|
|
}
|
|
let partition = this.partitions.get(workspace)
|
|
if (partition === undefined) {
|
|
partition = `dsh-sidebar-browser-${randomUUID()}`
|
|
this.configureSession(session.fromPartition(partition))
|
|
this.partitions.set(workspace, partition)
|
|
}
|
|
const lease = randomUUID() as DesktopBrowserLeaseId
|
|
this.leases.set(lease, { owner, partition, attached: false })
|
|
return { lease, partition }
|
|
}
|
|
|
|
/**
|
|
* Release only a lease issued to this application window; workspace storage survives.
|
|
* @param owner - authenticated IPC sender.
|
|
* @param id - lease received over IPC.
|
|
*/
|
|
async release(owner: WebContents, id: unknown): Promise<void> {
|
|
if (typeof id !== 'string') throw new Error('desktop browser: invalid guest lease')
|
|
const key = id as DesktopBrowserLeaseId
|
|
const lease = this.leases.get(key)
|
|
if (lease === undefined) return
|
|
if (lease.owner !== owner) throw new Error('desktop browser: guest belongs to another window')
|
|
lease.releaseInput?.()
|
|
this.leases.delete(key)
|
|
const guest = lease.guest
|
|
if (guest !== undefined && !guest.isDestroyed()) {
|
|
const destroyed = new Promise<void>((resolve) => { guest.once('destroyed', resolve) })
|
|
guest.close({ waitForBeforeUnload: false })
|
|
await destroyed
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Install attachment checks before the application document can create a webview.
|
|
* @param window - primary application window.
|
|
* @param attachInput - attaches native input after guest ownership is verified and returns its disposer.
|
|
*/
|
|
bind(window: BrowserWindow, attachInput: (guest: WebContents, name: DesktopBrowserLeaseId) => () => void): void {
|
|
const owner = window.webContents
|
|
owner.on('will-attach-webview', (event, preferences, params) => {
|
|
const id = typeof params.src === 'string' && params.src.startsWith('about:blank#')
|
|
? params.src.slice('about:blank#'.length) : ''
|
|
const lease = this.leases.get(id as DesktopBrowserLeaseId)
|
|
if (lease === undefined || lease.owner !== owner || lease.attached || params.partition !== lease.partition) {
|
|
event.preventDefault()
|
|
return
|
|
}
|
|
lease.attached = true
|
|
// Keep Electron's allowpopups dispatch flag; the guest handler still denies native windows.
|
|
for (const key of Object.keys(preferences)) {
|
|
if (key !== 'disablePopups') Reflect.deleteProperty(preferences, key)
|
|
}
|
|
Object.assign(preferences, {
|
|
partition: lease.partition,
|
|
nodeIntegration: false, nodeIntegrationInWorker: false, nodeIntegrationInSubFrames: false,
|
|
contextIsolation: true, sandbox: true, webSecurity: true, allowRunningInsecureContent: false,
|
|
webviewTag: false, plugins: false, navigateOnDragDrop: false, disableDialogs: true,
|
|
devTools: !app.isPackaged,
|
|
})
|
|
params.httpreferrer = ''
|
|
})
|
|
owner.on('did-attach-webview', (_event, guest) => {
|
|
let attachedLease: DesktopBrowserLeaseId | undefined
|
|
// The first document is an inert about:blank carrying the approved lease.
|
|
// Bind on the main-process event before the renderer can navigate the ready guest.
|
|
guest.once('dom-ready', () => {
|
|
const url = guest.getURL()
|
|
const id = (url.startsWith('about:blank#') ? url.slice('about:blank#'.length) : '') as DesktopBrowserLeaseId
|
|
const lease = this.leases.get(id)
|
|
if (lease === undefined || lease.owner !== owner || lease.guest !== undefined) {
|
|
guest.close({ waitForBeforeUnload: false })
|
|
return
|
|
}
|
|
lease.guest = guest
|
|
attachedLease = id
|
|
lease.releaseInput = attachInput(guest, id)
|
|
guest.once('destroyed', () => { lease.releaseInput?.(); this.leases.delete(id) })
|
|
})
|
|
guest.setWindowOpenHandler(({ url, postBody }) => {
|
|
const lease = attachedLease === undefined ? undefined : this.leases.get(attachedLease)
|
|
if (attachedLease !== undefined && lease?.guest === guest && lease.owner === owner && !owner.isDestroyed()
|
|
&& postBody === undefined && this.allowedNavigation(url)) {
|
|
const request: DesktopBrowserOpenRequest = { lease: attachedLease, url: new URL(url).href }
|
|
owner.send(DESKTOP_IPC.browserOpenRequested, request)
|
|
}
|
|
return { action: 'deny' }
|
|
})
|
|
guest.on('will-frame-navigate', (event) => {
|
|
if (event.isMainFrame && !this.allowedNavigation(event.url)) event.preventDefault()
|
|
})
|
|
guest.on('will-redirect', (event, url, _inPlace, mainFrame) => {
|
|
if (mainFrame && !this.allowedNavigation(url)) event.preventDefault()
|
|
})
|
|
guest.on('will-attach-webview', (event) => { event.preventDefault() })
|
|
guest.on('login', (event, _details, _authInfo, callback) => { event.preventDefault(); callback() })
|
|
})
|
|
const releaseAll = (): void => {
|
|
for (const [id, lease] of this.leases) {
|
|
if (lease.owner === owner) void this.release(owner, id).catch((error: unknown) => { console.error(error) })
|
|
}
|
|
}
|
|
owner.on('did-start-navigation', (_event, _url, inPlace, mainFrame) => {
|
|
if (mainFrame && !inPlace) releaseAll()
|
|
})
|
|
owner.on('render-process-gone', releaseAll)
|
|
owner.once('destroyed', releaseAll)
|
|
}
|
|
|
|
private configureSession(browserSession: Session): void {
|
|
browserSession.setPermissionRequestHandler((_contents, _permission, callback) => { callback(false) })
|
|
browserSession.setPermissionCheckHandler(() => false)
|
|
browserSession.setDevicePermissionHandler(() => false)
|
|
browserSession.setDisplayMediaRequestHandler((_request, callback) => { callback({}) })
|
|
browserSession.on('will-download', (event) => { event.preventDefault() })
|
|
browserSession.webRequest.onBeforeRequest((details, callback) => {
|
|
const url = new URL(details.url)
|
|
const network = ['http:', 'https:', 'ws:', 'wss:'].includes(url.protocol)
|
|
callback({ cancel: network
|
|
? url.username !== '' || url.password !== '' || this.isApplicationHost(url)
|
|
: !['about:', 'data:', 'blob:'].includes(url.protocol) })
|
|
})
|
|
}
|
|
|
|
private allowedNavigation(value: string): boolean {
|
|
if (!URL.canParse(value)) return false
|
|
const url = new URL(value)
|
|
return ['http:', 'https:'].includes(url.protocol) && url.username === '' && url.password === ''
|
|
&& !this.isApplicationHost(url)
|
|
}
|
|
|
|
private isApplicationHost(url: URL): boolean {
|
|
const value = this.hostUrl()
|
|
if (value === undefined) return false
|
|
const host = new URL(value)
|
|
return url.port === host.port
|
|
&& (url.hostname === host.hostname || ['localhost', '127.0.0.1', '[::1]'].includes(url.hostname))
|
|
}
|
|
}
|