273 lines
13 KiB
TypeScript
273 lines
13 KiB
TypeScript
/** Isolated Platform documents owned by the desktop account lifetime. */
|
|
import type { EventEmitter } from 'node:events'
|
|
import { createHash, randomUUID } from 'node:crypto'
|
|
import { WebContentsView, session, shell, type Session, type View, type WebFrameMain } from 'electron'
|
|
import { mergePlatformCookies, platformClientHeaders, type PlatformSession } from '@deepseek-ai/dsh-deepseek-account'
|
|
import { desktopClientMetadata } from './client-metadata.ts'
|
|
|
|
import { PLATFORM_IPC, type PlatformLocale } from './platform-ipc.ts'
|
|
|
|
export { PLATFORM_IPC } from './platform-ipc.ts'
|
|
|
|
/** Bounds in desktop content coordinates, supplied by the owned application renderer. */
|
|
export interface PlatformBounds { x: number; y: number; width: number; height: number }
|
|
|
|
/**
|
|
* Decode the renderer rectangle before allocating a native view.
|
|
* @param value - IPC payload.
|
|
* @returns finite, nonnegative integer coordinates.
|
|
*/
|
|
export function platformBounds(value: unknown): PlatformBounds {
|
|
if (typeof value !== 'object' || value === null) throw new Error('Invalid Platform bounds')
|
|
const row = value as Record<string, unknown>
|
|
const result: PlatformBounds = { x: 0, y: 0, width: 0, height: 0 }
|
|
for (const key of ['x', 'y', 'width', 'height'] as const) {
|
|
const n = row[key]
|
|
if (typeof n !== 'number' || !Number.isFinite(n) || n < 0 || n > 100_000) throw new Error('Invalid Platform bounds')
|
|
result[key] = Math.round(n)
|
|
}
|
|
return result
|
|
}
|
|
|
|
type PlatformOwner = Pick<EventEmitter, 'on' | 'removeListener'> & {
|
|
webContents: Pick<EventEmitter, 'on' | 'removeListener'>
|
|
contentView: Pick<View, 'addChildView' | 'removeChildView'>
|
|
isDestroyed(): boolean
|
|
}
|
|
|
|
type PlatformSender = { sender: object; senderFrame: Pick<WebFrameMain, 'url'> | null }
|
|
|
|
/** Native view and its credential snapshot are discarded together. */
|
|
export class DesktopPlatformView {
|
|
private account: PlatformSession | null = null
|
|
private view: WebContentsView | undefined
|
|
private owner: PlatformOwner | undefined
|
|
private releaseOwner: (() => void) | undefined
|
|
private generation = 0
|
|
private readonly storageCleanup = new Map<Session, Promise<{ error: unknown } | null>>()
|
|
private disposed = false
|
|
|
|
/**
|
|
* @param preload - bundled sandboxed Platform preload path.
|
|
* @param getLocale - current resolved Desktop language.
|
|
* @param platform - operating system this shell runs on, reported to Platform.
|
|
* @param blocked - Whether shell policy or an update prompt prevents opening a product view.
|
|
*/
|
|
constructor(private readonly preload: string, private readonly getLocale: () => PlatformLocale,
|
|
private readonly platform: 'darwin' | 'win32', private readonly blocked: (owner: PlatformOwner) => boolean) {}
|
|
|
|
/** @param next - private Host credentials; identity enrichment preserves an already open temporary document. */
|
|
setSession(next: PlatformSession | null): void {
|
|
if (next?.token === this.account?.token && next?.origin === this.account?.origin
|
|
&& next?.embeddedPageDist === this.account?.embeddedPageDist
|
|
&& JSON.stringify(next?.requestHeaders) === JSON.stringify(this.account?.requestHeaders)) {
|
|
if (next?.userId === this.account?.userId || this.account?.userId === null) {
|
|
this.account = next
|
|
return
|
|
}
|
|
}
|
|
this.close()
|
|
this.account = next
|
|
}
|
|
|
|
/**
|
|
* Open account-scoped persistent storage, or temporary storage when the account ID is unavailable.
|
|
* @param owner - application window containing the view.
|
|
* @param page - explicit supported Platform page.
|
|
* @param bounds - owned renderer rectangle.
|
|
* @returns when loading finishes, or without a document when superseded or the owner closes or navigates.
|
|
* @throws When a shell update prompt blocks admission, including after storage cleanup.
|
|
*/
|
|
async open(owner: PlatformOwner, page: 'usage' | 'top-up', bounds: PlatformBounds): Promise<void> {
|
|
if (this.disposed) throw new Error('Platform view disposed')
|
|
if (this.blocked(owner)) throw new Error('Platform view blocked by update prompt')
|
|
this.close()
|
|
const account = this.account
|
|
if (account === null) throw new Error('Platform account unavailable')
|
|
if (owner.isDestroyed()) return
|
|
const generation = this.generation
|
|
this.owner = owner
|
|
const closeOwnedView = () => { if (generation === this.generation) this.close() }
|
|
const navigateOwner = (_event: Electron.Event, _url: string, isInPlace: boolean, isMainFrame: boolean) => {
|
|
if (isMainFrame && !isInPlace) closeOwnedView()
|
|
}
|
|
owner.webContents.on('did-start-navigation', navigateOwner)
|
|
owner.webContents.on('render-process-gone', closeOwnedView)
|
|
owner.webContents.on('destroyed', closeOwnedView)
|
|
owner.on('closed', closeOwnedView)
|
|
this.releaseOwner = () => {
|
|
owner.webContents.removeListener('did-start-navigation', navigateOwner)
|
|
owner.webContents.removeListener('render-process-gone', closeOwnedView)
|
|
owner.webContents.removeListener('destroyed', closeOwnedView)
|
|
owner.removeListener('closed', closeOwnedView)
|
|
}
|
|
const partition = account.userId === null ? `dsh-platform-${randomUUID()}`
|
|
: `persist:dsh-platform-${createHash('sha256').update(JSON.stringify([account.origin, account.userId])).digest('hex')}`
|
|
const browserSession = session.fromPartition(partition)
|
|
// Sanitize persisted authentication even after an unclean previous process exit.
|
|
const failure = await this.cleanStorage(browserSession)
|
|
if (generation !== this.generation) return
|
|
if (this.blocked(owner)) { this.close(); throw new Error('Platform view blocked by update prompt') }
|
|
if (failure !== null) {
|
|
this.close()
|
|
throw failure.error
|
|
}
|
|
browserSession.setPermissionRequestHandler((_contents, _permission, callback) => { callback(false) })
|
|
browserSession.setPermissionCheckHandler(() => false)
|
|
const deploymentHeaders = account.requestHeaders ?? {}
|
|
const injectedNames = new Set([...Object.keys(deploymentHeaders),
|
|
...Object.keys(platformClientHeaders(this.platform, desktopClientMetadata(this.getLocale())))]
|
|
.map(name => name.toLowerCase()))
|
|
const injectedRequests = new Set<number>()
|
|
browserSession.webRequest.onCompleted((details) => { injectedRequests.delete(details.id) })
|
|
browserSession.webRequest.onErrorOccurred((details) => { injectedRequests.delete(details.id) })
|
|
browserSession.webRequest.onBeforeSendHeaders((details, callback) => {
|
|
let headers = Object.fromEntries(Object.entries(details.requestHeaders).map(([name, value]) => [name.toLowerCase(), value]))
|
|
if (new URL(details.url).origin === account.origin) {
|
|
injectedRequests.add(details.id)
|
|
// The document identifies the UI that is asking, so language and UTC offset are sampled now.
|
|
const injected = {
|
|
...deploymentHeaders,
|
|
...platformClientHeaders(this.platform, desktopClientMetadata(this.getLocale())),
|
|
}
|
|
const cookie = headers.cookie ?? ''
|
|
Object.assign(headers, injected)
|
|
if (injected.cookie !== undefined) headers.cookie = mergePlatformCookies(cookie, injected.cookie)
|
|
} else if (injectedRequests.has(details.id)) {
|
|
// Redirected subresources must not carry injected headers to another origin.
|
|
headers = Object.fromEntries(Object.entries(headers).filter(([name]) => !injectedNames.has(name.toLowerCase())))
|
|
}
|
|
callback({ requestHeaders: headers })
|
|
})
|
|
const view = new WebContentsView({ webPreferences: {
|
|
session: browserSession, preload: this.preload, sandbox: true, contextIsolation: true,
|
|
additionalArguments: [`--dsh-platform-origin=${account.origin}`],
|
|
nodeIntegration: false, webSecurity: true,
|
|
} })
|
|
this.view = view
|
|
// External payment and documentation pages open without the embedded session or token.
|
|
view.webContents.setWindowOpenHandler(({ url }) => {
|
|
const destination = new URL(url)
|
|
if (destination.protocol === 'https:' && !destination.username && !destination.password) {
|
|
void shell.openExternal(url).catch(() => {
|
|
// An OS browser-launch failure leaves the embedded page available for retry.
|
|
})
|
|
}
|
|
return { action: 'deny' }
|
|
})
|
|
const allowNavigation = (url: string): boolean => {
|
|
try {
|
|
const parsed = new URL(url)
|
|
return parsed.origin === account.origin && !parsed.username && !parsed.password
|
|
} catch { return false }
|
|
}
|
|
view.webContents.on('will-navigate', (event, url) => { if (!allowNavigation(url)) event.preventDefault() })
|
|
view.webContents.on('will-redirect', (event, url) => { if (!allowNavigation(url)) event.preventDefault() })
|
|
view.webContents.on('will-attach-webview', (event) => { event.preventDefault() })
|
|
view.webContents.on('preload-error', () => { if (this.view === view) this.close() })
|
|
view.webContents.on('render-process-gone', () => { if (this.view === view) this.close() })
|
|
view.setVisible(false)
|
|
owner.contentView.addChildView(view)
|
|
view.setBounds(bounds)
|
|
try {
|
|
const url = new URL(page === 'usage' ? '/usage' : '/top_up', account.origin)
|
|
if (account.embeddedPageDist) url.searchParams.set('dist', account.embeddedPageDist)
|
|
await view.webContents.loadURL(url.href)
|
|
} catch (error) {
|
|
// A Platform document may replace its own URL before the first load settles (for example to
|
|
// consume an embedded deployment parameter); that aborts loadURL with ERR_ABORTED instead of
|
|
// reporting a failed document, so the owned view stays open like the application window.
|
|
const aborted = error instanceof Error && 'code' in error && error.code === 'ERR_ABORTED'
|
|
if (!aborted) {
|
|
if (generation === this.generation) this.close()
|
|
throw error
|
|
}
|
|
}
|
|
if (generation === this.generation && this.view === view) view.setVisible(true)
|
|
}
|
|
|
|
/** @param bounds - current application viewport rectangle. */
|
|
setBounds(bounds: PlatformBounds): void { this.view?.setBounds(bounds) }
|
|
|
|
/**
|
|
* Return prepared credentials and resolved language only to the current Platform main frame.
|
|
* @param event - Electron-provided sender identity.
|
|
* @returns credentials and current language copied into the isolated preload.
|
|
*/
|
|
bootstrap(event: PlatformSender): Pick<PlatformSession, 'origin' | 'token'> & { locale: PlatformLocale } {
|
|
const view = this.view
|
|
const account = this.account
|
|
if (view === undefined || account === null || event.sender !== view.webContents
|
|
|| event.senderFrame !== view.webContents.mainFrame || new URL(event.senderFrame.url).origin !== account.origin) {
|
|
throw new Error('Rejected Platform bootstrap')
|
|
}
|
|
return { origin: account.origin, token: account.token, locale: this.getLocale() }
|
|
}
|
|
|
|
/** Notify the current document after the Desktop language changes. */
|
|
notifyLocaleChanged(): void {
|
|
const view = this.view
|
|
if (view !== undefined && !view.webContents.isDestroyed()) {
|
|
view.webContents.send(PLATFORM_IPC.localeChanged, this.getLocale())
|
|
}
|
|
}
|
|
|
|
/** Destroy the document and clear authentication; account-scoped page preferences survive reopening. */
|
|
close(): void {
|
|
this.generation++
|
|
const view = this.view
|
|
this.view = undefined
|
|
this.releaseOwner?.()
|
|
this.releaseOwner = undefined
|
|
const owner = this.owner
|
|
this.owner = undefined
|
|
if (view === undefined) return
|
|
if (owner !== undefined && !owner.isDestroyed()) owner.contentView.removeChildView(view)
|
|
const browserSession = view.webContents.session
|
|
const destroyed = new Promise<void>((resolve) => {
|
|
if (view.webContents.isDestroyed()) resolve()
|
|
else {
|
|
view.webContents.once('destroyed', resolve)
|
|
view.webContents.close({ waitForBeforeUnload: false })
|
|
}
|
|
})
|
|
browserSession.webRequest.onBeforeSendHeaders(null)
|
|
browserSession.webRequest.onCompleted(null)
|
|
browserSession.webRequest.onErrorOccurred(null)
|
|
browserSession.flushStorageData()
|
|
void this.cleanStorage(browserSession, destroyed)
|
|
}
|
|
|
|
/** Stop accepting documents and await all scheduled authentication cleanup. */
|
|
async dispose(): Promise<void> {
|
|
this.disposed = true
|
|
this.account = null
|
|
await this.closeAndWait()
|
|
}
|
|
|
|
/** Destroy the current document and await authentication cleanup before an installer takes over. */
|
|
async closeAndWait(): Promise<void> {
|
|
this.close()
|
|
const results = await Promise.all(this.storageCleanup.values())
|
|
const failures = results.filter(result => result !== null)
|
|
if (failures.length > 0) throw new AggregateError(failures.map(result => result.error), 'Platform storage cleanup failed')
|
|
}
|
|
|
|
private cleanStorage(browserSession: Session, destroyed: Promise<void> = Promise.resolve()): Promise<{ error: unknown } | null> {
|
|
const previous = this.storageCleanup.get(browserSession)
|
|
const cleanup = Promise.all([previous, destroyed]).then(async () => {
|
|
await browserSession.closeAllConnections()
|
|
const results = await Promise.allSettled([
|
|
browserSession.clearStorageData(browserSession.isPersistent()
|
|
? { storages: ['cookies', 'filesystem', 'indexdb', 'shadercache', 'serviceworkers', 'cachestorage'] } : undefined),
|
|
browserSession.clearCache(),
|
|
browserSession.clearAuthCache(),
|
|
])
|
|
const failures = results.filter(result => result.status === 'rejected')
|
|
if (failures.length > 0) throw new AggregateError(failures.map((result): unknown => result.reason), 'Platform storage cleanup failed')
|
|
}).then(() => null, (error: unknown) => ({ error }))
|
|
this.storageCleanup.set(browserSession, cleanup)
|
|
return cleanup
|
|
}
|
|
}
|