diff --git a/src/main/browser/cdp-print-to-pdf.test.ts b/src/main/browser/cdp-print-to-pdf.test.ts
new file mode 100644
index 00000000000..e6f2163b5dd
--- /dev/null
+++ b/src/main/browser/cdp-print-to-pdf.test.ts
@@ -0,0 +1,172 @@
+import { describe, it, expect, vi, afterEach } from 'vitest'
+import { buildPrintToPdfOptions, CdpPdfStreamStore } from './cdp-print-to-pdf'
+
+const DEFAULT_MARGIN_INCHES = 1 / 2.54
+const TTL_MS = 5 * 60 * 1000
+
+describe('buildPrintToPdfOptions', () => {
+ it('returns empty options for empty params', () => {
+ expect(buildPrintToPdfOptions({})).toEqual({})
+ })
+
+ it('maps every well-formed field', () => {
+ expect(
+ buildPrintToPdfOptions({
+ landscape: true,
+ displayHeaderFooter: true,
+ printBackground: true,
+ preferCSSPageSize: true,
+ generateTaggedPDF: true,
+ generateDocumentOutline: false,
+ scale: 1.5,
+ paperWidth: 8.5,
+ paperHeight: 11,
+ marginTop: 0.25,
+ marginBottom: 0.5,
+ marginLeft: 0.75,
+ marginRight: 1,
+ pageRanges: '1-2',
+ headerTemplate: '',
+ footerTemplate: ''
+ })
+ ).toEqual({
+ landscape: true,
+ displayHeaderFooter: true,
+ printBackground: true,
+ preferCSSPageSize: true,
+ generateTaggedPDF: true,
+ generateDocumentOutline: false,
+ scale: 1.5,
+ pageSize: { width: 8.5, height: 11 },
+ margins: { marginType: 'custom', top: 0.25, bottom: 0.5, left: 0.75, right: 1 },
+ pageRanges: '1-2',
+ headerTemplate: '',
+ footerTemplate: ''
+ })
+ })
+
+ it('defaults omitted margin sides to 1cm when any side is set', () => {
+ expect(buildPrintToPdfOptions({ marginTop: 0.25 }).margins).toEqual({
+ marginType: 'custom',
+ top: 0.25,
+ bottom: DEFAULT_MARGIN_INCHES,
+ left: DEFAULT_MARGIN_INCHES,
+ right: DEFAULT_MARGIN_INCHES
+ })
+ })
+
+ it('drops non-finite and out-of-range numeric options', () => {
+ const options = buildPrintToPdfOptions({
+ scale: 0,
+ paperWidth: Number.POSITIVE_INFINITY,
+ paperHeight: 11,
+ marginTop: Number.NaN
+ })
+ expect(options.scale).toBeUndefined()
+ expect(options.pageSize).toBeUndefined()
+ expect(options.margins).toBeUndefined()
+ })
+
+ it('requires both paper dimensions for pageSize', () => {
+ expect(buildPrintToPdfOptions({ paperWidth: 8.5 }).pageSize).toBeUndefined()
+ })
+
+ it('ignores wrong-typed options', () => {
+ expect(buildPrintToPdfOptions({ landscape: 'yes', pageRanges: 7 })).toEqual({})
+ })
+})
+
+describe('CdpPdfStreamStore', () => {
+ afterEach(() => {
+ vi.useRealTimers()
+ })
+
+ it('claims only its own handles', () => {
+ const store = new CdpPdfStreamStore()
+ const handle = store.create(Buffer.from('pdf'))
+
+ expect(store.ownsHandle({ handle })).toBe(true)
+ expect(handle).toMatch(/^orca-pdf-[\da-f-]{36}-\d+$/)
+ expect(store.ownsHandle({ handle: 'trace-stream' })).toBe(false)
+ expect(store.ownsHandle({ handle: 'orca-pdf-forged-1' })).toBe(false)
+ expect(store.ownsHandle({})).toBe(false)
+ })
+
+ it('mints distinct handles per stream', () => {
+ const store = new CdpPdfStreamStore()
+ expect(store.create(Buffer.from('a'))).not.toBe(store.create(Buffer.from('b')))
+ })
+
+ it('reads sequential chunks and reports eof', () => {
+ const store = new CdpPdfStreamStore()
+ const handle = store.create(Buffer.from('abcdef'))
+
+ expect(store.read({ handle, size: 2 })).toEqual({
+ data: Buffer.from('ab').toString('base64'),
+ eof: false
+ })
+ expect(store.read({ handle })).toEqual({
+ data: Buffer.from('cdef').toString('base64'),
+ eof: true
+ })
+ })
+
+ it('honors an explicit read offset', () => {
+ const store = new CdpPdfStreamStore()
+ const handle = store.create(Buffer.from('abcdef'))
+
+ expect(store.read({ handle, offset: 4 })).toEqual({
+ data: Buffer.from('ef').toString('base64'),
+ eof: true
+ })
+ })
+
+ it('returns an empty eof chunk when reading past the end', () => {
+ const store = new CdpPdfStreamStore()
+ const handle = store.create(Buffer.from('abc'))
+
+ expect(store.read({ handle, offset: 99 })).toEqual({ data: '', eof: true })
+ })
+
+ it('returns null for unknown handles', () => {
+ const store = new CdpPdfStreamStore()
+ expect(store.read({ handle: 'nope' })).toBeNull()
+ expect(store.read({})).toBeNull()
+ })
+
+ it('drops a stream on close', () => {
+ const store = new CdpPdfStreamStore()
+ const handle = store.create(Buffer.from('abc'))
+
+ store.close({ handle })
+ expect(store.read({ handle })).toBeNull()
+ })
+
+ it('drops all streams on clear', () => {
+ const store = new CdpPdfStreamStore()
+ const handle = store.create(Buffer.from('abc'))
+
+ store.clear()
+ expect(store.read({ handle })).toBeNull()
+ })
+
+ it('evicts an abandoned stream after the TTL', () => {
+ vi.useFakeTimers()
+ const store = new CdpPdfStreamStore()
+ const handle = store.create(Buffer.from('abc'))
+
+ vi.advanceTimersByTime(TTL_MS + 1)
+ expect(store.read({ handle })).toBeNull()
+ })
+
+ it('refreshes the TTL on each read', () => {
+ vi.useFakeTimers()
+ const store = new CdpPdfStreamStore()
+ const handle = store.create(Buffer.from('abcdef'))
+
+ vi.advanceTimersByTime(TTL_MS - 1)
+ expect(store.read({ handle, size: 1 })).not.toBeNull()
+ vi.advanceTimersByTime(TTL_MS - 1)
+ expect(store.read({ handle, size: 1 })).not.toBeNull()
+ })
+})
diff --git a/src/main/browser/cdp-print-to-pdf.ts b/src/main/browser/cdp-print-to-pdf.ts
new file mode 100644
index 00000000000..643013377d8
--- /dev/null
+++ b/src/main/browser/cdp-print-to-pdf.ts
@@ -0,0 +1,177 @@
+import { randomUUID } from 'node:crypto'
+import type { PrintToPDFOptions } from 'electron'
+
+const PDF_DEFAULT_MARGIN_INCHES = 1 / 2.54
+const PDF_STREAM_CHUNK_BYTES = 1024 * 1024
+const PDF_STREAM_HANDLE_PREFIX = 'orca-pdf-'
+const PDF_STREAM_TTL_MS = 5 * 60 * 1000
+
+function finiteNumber(value: unknown): number | null {
+ return typeof value === 'number' && Number.isFinite(value) ? value : null
+}
+
+/**
+ * Translate CDP `Page.printToPDF` params into Electron `printToPDF` options.
+ * Only well-formed values are forwarded so a malformed param can never smuggle
+ * NaN/Infinity into Electron; omitted margin sides default to CDP's 1cm.
+ */
+export function buildPrintToPdfOptions(params: Record): PrintToPDFOptions {
+ const options: PrintToPDFOptions = {}
+
+ if (typeof params.landscape === 'boolean') {
+ options.landscape = params.landscape
+ }
+ if (typeof params.displayHeaderFooter === 'boolean') {
+ options.displayHeaderFooter = params.displayHeaderFooter
+ }
+ if (typeof params.printBackground === 'boolean') {
+ options.printBackground = params.printBackground
+ }
+ if (typeof params.preferCSSPageSize === 'boolean') {
+ options.preferCSSPageSize = params.preferCSSPageSize
+ }
+ if (typeof params.generateTaggedPDF === 'boolean') {
+ options.generateTaggedPDF = params.generateTaggedPDF
+ }
+ if (typeof params.generateDocumentOutline === 'boolean') {
+ options.generateDocumentOutline = params.generateDocumentOutline
+ }
+
+ const scale = finiteNumber(params.scale)
+ if (scale !== null && scale > 0) {
+ options.scale = scale
+ }
+
+ const paperWidth = finiteNumber(params.paperWidth)
+ const paperHeight = finiteNumber(params.paperHeight)
+ if (paperWidth !== null && paperHeight !== null && paperWidth > 0 && paperHeight > 0) {
+ options.pageSize = { width: paperWidth, height: paperHeight }
+ }
+
+ const marginTop = finiteNumber(params.marginTop)
+ const marginBottom = finiteNumber(params.marginBottom)
+ const marginLeft = finiteNumber(params.marginLeft)
+ const marginRight = finiteNumber(params.marginRight)
+ if ([marginTop, marginBottom, marginLeft, marginRight].some((margin) => margin !== null)) {
+ // CDP and Electron printToPDF both use inches; omitted CDP sides default to 1cm.
+ options.margins = {
+ marginType: 'custom',
+ top: marginTop ?? PDF_DEFAULT_MARGIN_INCHES,
+ bottom: marginBottom ?? PDF_DEFAULT_MARGIN_INCHES,
+ left: marginLeft ?? PDF_DEFAULT_MARGIN_INCHES,
+ right: marginRight ?? PDF_DEFAULT_MARGIN_INCHES
+ }
+ }
+
+ if (typeof params.pageRanges === 'string') {
+ options.pageRanges = params.pageRanges
+ }
+ if (typeof params.headerTemplate === 'string') {
+ options.headerTemplate = params.headerTemplate
+ }
+ if (typeof params.footerTemplate === 'string') {
+ options.footerTemplate = params.footerTemplate
+ }
+
+ return options
+}
+
+type PdfStream = {
+ data: Buffer
+ offset: number
+ cleanupTimer: ReturnType
+}
+
+export type PdfStreamChunk = {
+ data: string
+ eof: boolean
+}
+
+/**
+ * Holds the PDF buffers produced for CDP `transferMode: "ReturnAsStream"` and
+ * serves them back through `IO.read` / `IO.close`, the path Playwright's
+ * `page.pdf()` uses. Handles carry a per-instance random prefix so a CDP client
+ * cannot forge one or collide with a real Chromium IO stream, and each buffer is
+ * evicted after a TTL so an abandoned stream can never leak memory.
+ */
+export class CdpPdfStreamStore {
+ private readonly streams = new Map()
+ private readonly handlePrefix = `${PDF_STREAM_HANDLE_PREFIX}${randomUUID()}-`
+ private nextId = 0
+
+ /** True when `params.handle` names one of this store's streams. */
+ ownsHandle(params: Record): boolean {
+ return typeof params.handle === 'string' && params.handle.startsWith(this.handlePrefix)
+ }
+
+ create(data: Buffer): string {
+ const handle = `${this.handlePrefix}${++this.nextId}`
+ this.streams.set(handle, {
+ data,
+ offset: 0,
+ cleanupTimer: this.scheduleCleanup(handle)
+ })
+ return handle
+ }
+
+ /** Read the next chunk, or `null` if the handle is unknown/expired. */
+ read(params: Record): PdfStreamChunk | null {
+ const handle = typeof params.handle === 'string' ? params.handle : ''
+ const stream = this.streams.get(handle)
+ if (!stream) {
+ return null
+ }
+ this.refreshCleanup(handle, stream)
+
+ const offset = finiteNumber(params.offset)
+ if (offset !== null) {
+ stream.offset = Math.max(0, Math.floor(offset))
+ }
+ const requestedSize = finiteNumber(params.size)
+ const size =
+ requestedSize !== null && requestedSize > 0
+ ? Math.floor(requestedSize)
+ : PDF_STREAM_CHUNK_BYTES
+ const start = Math.min(stream.offset, stream.data.length)
+ const end = Math.min(start + size, stream.data.length)
+ const chunk = stream.data.subarray(start, end)
+ stream.offset = end
+
+ return { data: chunk.toString('base64'), eof: end >= stream.data.length }
+ }
+
+ close(params: Record): void {
+ const handle = typeof params.handle === 'string' ? params.handle : ''
+ this.delete(handle)
+ }
+
+ clear(): void {
+ for (const stream of this.streams.values()) {
+ clearTimeout(stream.cleanupTimer)
+ }
+ this.streams.clear()
+ }
+
+ private scheduleCleanup(handle: string): ReturnType {
+ const cleanupTimer = setTimeout(() => {
+ this.delete(handle)
+ }, PDF_STREAM_TTL_MS)
+ const maybeNodeTimer = cleanupTimer as { unref?: () => void }
+ maybeNodeTimer.unref?.()
+ return cleanupTimer
+ }
+
+ private refreshCleanup(handle: string, stream: PdfStream): void {
+ clearTimeout(stream.cleanupTimer)
+ stream.cleanupTimer = this.scheduleCleanup(handle)
+ }
+
+ private delete(handle: string): void {
+ const stream = this.streams.get(handle)
+ if (!stream) {
+ return
+ }
+ clearTimeout(stream.cleanupTimer)
+ this.streams.delete(handle)
+ }
+}
diff --git a/src/main/browser/cdp-ws-proxy.test.ts b/src/main/browser/cdp-ws-proxy.test.ts
index 11fbcd772cf..15976e9c729 100644
--- a/src/main/browser/cdp-ws-proxy.test.ts
+++ b/src/main/browser/cdp-ws-proxy.test.ts
@@ -43,6 +43,7 @@ function createMockWebContents() {
debugger: debuggerObj,
isDestroyed: () => destroyed,
focus: vi.fn(),
+ printToPDF: vi.fn(async () => Buffer.from('%PDF-test')),
reload: vi.fn(),
reloadIgnoringCache: vi.fn(),
getTitle: vi.fn(() => 'Example'),
@@ -109,6 +110,15 @@ describe('CdpWsProxy', () => {
return calls.map((call) => call[0])
}
+ function expectPdfStreamHandle(response: Record): string {
+ const result = response.result as Record
+ expect(result.data).toBe('')
+ expect(result.stream).toEqual(expect.stringMatching(/^orca-pdf-[\da-f-]{36}-\d+$/))
+ return result.stream as string
+ }
+
+ const defaultPdfMarginInches = 1 / 2.54
+
it('starts on a random port and returns ws:// URL', () => {
expect(endpoint).toMatch(/^ws:\/\/127\.0\.0\.1:\d+$/)
expect(proxy.getPort()).toBeGreaterThan(0)
@@ -493,6 +503,209 @@ describe('CdpWsProxy', () => {
client.close()
})
+ it('prints PDF data through native webContents printToPDF', async () => {
+ const client = await connect()
+
+ const response = await sendAndReceive(client, {
+ id: 19,
+ method: 'Page.printToPDF',
+ params: {
+ landscape: true,
+ printBackground: true,
+ paperWidth: 8.5,
+ paperHeight: 11,
+ marginTop: 0.25,
+ marginBottom: 0.5,
+ marginLeft: 0.75,
+ marginRight: 1,
+ pageRanges: '1-2',
+ preferCSSPageSize: true
+ }
+ })
+
+ expect(response).toEqual({
+ id: 19,
+ result: { data: Buffer.from('%PDF-test').toString('base64') }
+ })
+ expect(mock.webContents.printToPDF).toHaveBeenCalledWith({
+ landscape: true,
+ printBackground: true,
+ pageSize: { width: 8.5, height: 11 },
+ margins: {
+ marginType: 'custom',
+ top: 0.25,
+ bottom: 0.5,
+ left: 0.75,
+ right: 1
+ },
+ pageRanges: '1-2',
+ preferCSSPageSize: true
+ })
+ expect(getSendCommandMethods()).not.toContain('Page.printToPDF')
+ client.close()
+ })
+
+ it('keeps default PDF margins for omitted sides', async () => {
+ const client = await connect()
+
+ await sendAndReceive(client, {
+ id: 20,
+ method: 'Page.printToPDF',
+ params: {
+ marginTop: 0.25
+ }
+ })
+
+ expect(mock.webContents.printToPDF).toHaveBeenCalledWith({
+ margins: {
+ marginType: 'custom',
+ top: 0.25,
+ bottom: defaultPdfMarginInches,
+ left: defaultPdfMarginInches,
+ right: defaultPdfMarginInches
+ }
+ })
+ client.close()
+ })
+
+ it('supports streamed Page.printToPDF results for Playwright page.pdf', async () => {
+ mock.webContents.printToPDF.mockResolvedValueOnce(Buffer.from('abcdef'))
+ const client = await connect()
+
+ const printResponse = await sendAndReceive(client, {
+ id: 21,
+ method: 'Page.printToPDF',
+ params: { transferMode: 'ReturnAsStream' }
+ })
+ const handle = expectPdfStreamHandle(printResponse)
+
+ const firstRead = await sendAndReceive(client, {
+ id: 22,
+ method: 'IO.read',
+ params: { handle, size: 2 }
+ })
+ const secondRead = await sendAndReceive(client, {
+ id: 23,
+ method: 'IO.read',
+ params: { handle }
+ })
+ const closeResponse = await sendAndReceive(client, {
+ id: 24,
+ method: 'IO.close',
+ params: { handle }
+ })
+ const readAfterClose = await sendAndReceive(client, {
+ id: 25,
+ method: 'IO.read',
+ params: { handle }
+ })
+
+ expect(printResponse.id).toBe(21)
+ expect(firstRead).toEqual({
+ id: 22,
+ result: { base64Encoded: true, data: Buffer.from('ab').toString('base64'), eof: false }
+ })
+ expect(secondRead).toEqual({
+ id: 23,
+ result: { base64Encoded: true, data: Buffer.from('cdef').toString('base64'), eof: true }
+ })
+ expect(closeResponse).toEqual({ id: 24, result: {} })
+ expect(readAfterClose).toEqual({
+ id: 25,
+ error: { code: -32000, message: 'Invalid stream handle' }
+ })
+ client.close()
+ })
+
+ it('clears streamed PDF data when the client disconnects', async () => {
+ mock.webContents.printToPDF.mockResolvedValueOnce(Buffer.from('abcdef'))
+ const client = await connect()
+
+ const printResponse = await sendAndReceive(client, {
+ id: 26,
+ method: 'Page.printToPDF',
+ params: { transferMode: 'ReturnAsStream' }
+ })
+ const handle = expectPdfStreamHandle(printResponse)
+
+ expect(printResponse.id).toBe(26)
+ client.close()
+ await new Promise((resolve) => setTimeout(resolve, 10))
+
+ const nextClient = await connect()
+ const staleRead = await sendAndReceive(nextClient, {
+ id: 27,
+ method: 'IO.read',
+ params: { handle }
+ })
+
+ expect(staleRead).toEqual({
+ id: 27,
+ error: { code: -32000, message: 'Invalid stream handle' }
+ })
+ nextClient.close()
+ })
+
+ it('does not register a PDF stream when the client disconnects mid-print', async () => {
+ let resolvePrint: (buf: Buffer) => void = () => {}
+ mock.webContents.printToPDF.mockImplementationOnce(
+ () =>
+ new Promise((resolve) => {
+ resolvePrint = resolve
+ })
+ )
+ const store = (proxy as unknown as { pdfStreams: { create: (b: Buffer) => string } }).pdfStreams
+ const createSpy = vi.spyOn(store, 'create')
+
+ const client = await connect()
+ client.send(
+ JSON.stringify({
+ id: 30,
+ method: 'Page.printToPDF',
+ params: { transferMode: 'ReturnAsStream' }
+ })
+ )
+ await new Promise((resolve) => setTimeout(resolve, 10))
+ client.close()
+ await new Promise((resolve) => setTimeout(resolve, 10))
+
+ // Print resolves only after the client is gone: no stream must be created.
+ resolvePrint(Buffer.from('%PDF-late'))
+ await new Promise((resolve) => setTimeout(resolve, 10))
+
+ expect(createSpy).not.toHaveBeenCalled()
+ createSpy.mockRestore()
+ })
+
+ it('forwards non-PDF IO streams to the debugger', async () => {
+ mock.webContents.debugger.sendCommand
+ .mockResolvedValueOnce({ data: 'trace-data', eof: false })
+ .mockResolvedValueOnce({})
+ const client = await connect()
+
+ const readResponse = await sendAndReceive(client, {
+ id: 28,
+ method: 'IO.read',
+ params: { handle: 'trace-stream', size: 64 }
+ })
+ const closeResponse = await sendAndReceive(client, {
+ id: 29,
+ method: 'IO.close',
+ params: { handle: 'trace-stream' }
+ })
+
+ expect(readResponse).toEqual({ id: 28, result: { data: 'trace-data', eof: false } })
+ expect(closeResponse).toEqual({ id: 29, result: {} })
+ expect(mock.webContents.debugger.sendCommand).toHaveBeenCalledWith('IO.read', {
+ handle: 'trace-stream',
+ size: 64
+ })
+ expect(mock.webContents.debugger.sendCommand).toHaveBeenCalledWith('IO.close', {
+ handle: 'trace-stream'
+ })
+ client.close()
+ })
+
// ── Page.frameNavigated interception ──
// ── Cleanup ──
diff --git a/src/main/browser/cdp-ws-proxy.ts b/src/main/browser/cdp-ws-proxy.ts
index ec8de5be176..6c3a401d5b7 100644
--- a/src/main/browser/cdp-ws-proxy.ts
+++ b/src/main/browser/cdp-ws-proxy.ts
@@ -3,6 +3,7 @@ import { WebSocketServer, WebSocket } from 'ws'
import { createServer, type Server, type IncomingMessage, type ServerResponse } from 'node:http'
import type { WebContents } from 'electron'
import { captureScreenshot } from './cdp-screenshot'
+import { buildPrintToPdfOptions, CdpPdfStreamStore } from './cdp-print-to-pdf'
import { ANTI_DETECTION_SCRIPT } from './anti-detection'
import { acquireElectronDebugger, type ElectronDebuggerLease } from './electron-debugger-lease'
@@ -20,6 +21,7 @@ export class CdpWsProxy {
private attached = false
// Why: agent-browser filters events by sessionId from Target.attachToTarget.
private clientSessionId: string | undefined = undefined
+ private readonly pdfStreams = new CdpPdfStreamStore()
constructor(private readonly webContents: WebContents) {}
@@ -51,6 +53,7 @@ export class CdpWsProxy {
const onClose = (): void => {
detach()
if (this.client === ws) {
+ this.pdfStreams.clear()
this.client = null
}
}
@@ -101,6 +104,7 @@ export class CdpWsProxy {
this.detachClientListeners?.()
this.detachClientListeners = null
this.client = null
+ this.pdfStreams.clear()
client?.close()
}
@@ -290,6 +294,30 @@ export class CdpWsProxy {
this.handleScreenshot(client, clientId, msg.params)
return
}
+ // Why: CDP Page.printToPDF is not available for Electron webview guests.
+ // Electron's native printToPDF path is the reliable equivalent.
+ if (msg.method === 'Page.printToPDF') {
+ void this.handlePrintToPdf(client, clientId, msg.params ?? {})
+ return
+ }
+ if (msg.method === 'IO.read') {
+ const params = msg.params ?? {}
+ if (this.pdfStreams.ownsHandle(params)) {
+ this.handleStreamRead(client, clientId, params)
+ return
+ }
+ this.forwardCommand(client, clientId, msg.method, params, msg.sessionId)
+ return
+ }
+ if (msg.method === 'IO.close') {
+ const params = msg.params ?? {}
+ if (this.pdfStreams.ownsHandle(params)) {
+ this.handleStreamClose(client, clientId, params)
+ return
+ }
+ this.forwardCommand(client, clientId, msg.method, params, msg.sessionId)
+ return
+ }
// Why: Input.insertText can still require native focus in Electron webviews.
// Do not auto-focus generic Runtime.evaluate/callFunctionOn traffic: wait
// polling and read-only JS probes use those methods heavily, and focusing on
@@ -439,6 +467,57 @@ export class CdpWsProxy {
}
}
+ private async handlePrintToPdf(
+ client: WebSocket,
+ clientId: number,
+ params: Record
+ ): Promise {
+ if (this.webContents.isDestroyed()) {
+ this.sendError(clientId, 'Browser tab is no longer available', client)
+ return
+ }
+ try {
+ const pdf = await this.webContents.printToPDF(buildPrintToPdfOptions(params))
+ // Why: printToPDF can resolve after the client disconnected (or was
+ // replaced). Bail before registering a stream so its buffer isn't
+ // orphaned in pdfStreams past the disconnect's clear() until the TTL.
+ if (!this.isActiveClient(client)) {
+ return
+ }
+ const buffer = Buffer.isBuffer(pdf) ? pdf : Buffer.from(pdf)
+ if (params.transferMode === 'ReturnAsStream') {
+ const handle = this.pdfStreams.create(buffer)
+ this.sendResult(clientId, { data: '', stream: handle }, client)
+ return
+ }
+ this.sendResult(clientId, { data: buffer.toString('base64') }, client)
+ } catch (err) {
+ this.sendError(clientId, err instanceof Error ? err.message : String(err), client)
+ }
+ }
+
+ private handleStreamRead(
+ client: WebSocket,
+ clientId: number,
+ params: Record
+ ): void {
+ const chunk = this.pdfStreams.read(params)
+ if (!chunk) {
+ this.sendError(clientId, 'Invalid stream handle', client)
+ return
+ }
+ this.sendResult(clientId, { base64Encoded: true, data: chunk.data, eof: chunk.eof }, client)
+ }
+
+ private handleStreamClose(
+ client: WebSocket,
+ clientId: number,
+ params: Record
+ ): void {
+ this.pdfStreams.close(params)
+ this.sendResult(clientId, {}, client)
+ }
+
private handleScreenshot(
client: WebSocket,
clientId: number,
diff --git a/src/main/git/worktree.test.ts b/src/main/git/worktree.test.ts
index 44679c14ed3..69ce1ed46c5 100644
--- a/src/main/git/worktree.test.ts
+++ b/src/main/git/worktree.test.ts
@@ -237,6 +237,12 @@ bare
})
describe('listWorktreeGraph', () => {
+ beforeEach(() => {
+ gitExecFileAsyncMock.mockReset()
+ gitExecFileSyncMock.mockReset()
+ translateWslOutputPathsMock.mockClear()
+ })
+
it('returns the worktree graph without sparse-checkout annotation probes', async () => {
gitExecFileAsyncMock.mockResolvedValueOnce({
stdout: `worktree /repo
@@ -272,6 +278,98 @@ branch refs/heads/feature/test
})
})
+ it('falls back when older Git rejects worktree list -z with usage exit code', async () => {
+ gitExecFileAsyncMock
+ .mockRejectedValueOnce(
+ Object.assign(new Error('git usage error'), {
+ code: 129,
+ stderr: 'usage: git worktree list []\n'
+ })
+ )
+ .mockResolvedValueOnce({
+ stdout: `worktree /repo
+HEAD abc123
+branch refs/heads/main
+
+worktree /repo-main-2
+HEAD def456
+branch refs/heads/main-2
+`
+ })
+
+ await expect(listWorktreeGraph('/repo')).resolves.toEqual([
+ {
+ path: '/repo',
+ head: 'abc123',
+ branch: 'refs/heads/main',
+ isBare: false,
+ isMainWorktree: true
+ },
+ {
+ path: '/repo-main-2',
+ head: 'def456',
+ branch: 'refs/heads/main-2',
+ isBare: false,
+ isMainWorktree: false
+ }
+ ])
+
+ expect(gitExecFileAsyncMock.mock.calls).toEqual([
+ [['worktree', 'list', '--porcelain', '-z'], { cwd: '/repo' }],
+ [['worktree', 'list', '--porcelain'], { cwd: '/repo' }]
+ ])
+ })
+
+ it('falls back on a localized (non-English) older-Git usage error via exit code 129', async () => {
+ // Git under a non-English locale translates the usage text, so stderr
+ // matching alone misses it; the 129 exit code must still trigger fallback.
+ gitExecFileAsyncMock
+ .mockRejectedValueOnce(
+ Object.assign(new Error('git worktree list --porcelain -z'), {
+ code: 129,
+ stderr: 'Fehler: Unbekannter Schalter »z«\nAufruf: git worktree list []\n'
+ })
+ )
+ .mockResolvedValueOnce({
+ stdout: `worktree /repo
+HEAD abc123
+branch refs/heads/main
+`
+ })
+
+ await expect(listWorktreeGraph('/repo')).resolves.toEqual([
+ {
+ path: '/repo',
+ head: 'abc123',
+ branch: 'refs/heads/main',
+ isBare: false,
+ isMainWorktree: true
+ }
+ ])
+
+ expect(gitExecFileAsyncMock.mock.calls).toEqual([
+ [['worktree', 'list', '--porcelain', '-z'], { cwd: '/repo' }],
+ [['worktree', 'list', '--porcelain'], { cwd: '/repo' }]
+ ])
+ })
+
+ it('does not retry without -z for non-usage Git failures', async () => {
+ // A fatal error (exit 128) is not an unsupported-flag signal, so the -z
+ // command must not be silently re-run without it.
+ gitExecFileAsyncMock.mockRejectedValueOnce(
+ Object.assign(new Error('git worktree list --porcelain -z'), {
+ code: 128,
+ stderr: 'fatal: unable to read tree\n'
+ })
+ )
+
+ await expect(listWorktreeGraph('/repo')).resolves.toEqual([])
+
+ expect(gitExecFileAsyncMock.mock.calls).toEqual([
+ [['worktree', 'list', '--porcelain', '-z'], { cwd: '/repo' }]
+ ])
+ })
+
it('returns an empty graph for paths Git reports as non-repositories', async () => {
gitExecFileAsyncMock.mockRejectedValueOnce(new Error('fatal: not a git repository'))
diff --git a/src/main/git/worktree.ts b/src/main/git/worktree.ts
index ed830d06d70..1dce3fff2ae 100644
--- a/src/main/git/worktree.ts
+++ b/src/main/git/worktree.ts
@@ -104,9 +104,13 @@ function isNotGitRepositoryError(error: unknown): boolean {
}
function isUnsupportedWorktreeListZError(error: unknown): boolean {
- return /(?:unknown|invalid) (?:switch|option).*`?-z'?|(?:unknown|invalid) (?:switch|option).*`?z'?/i.test(
- getErrorText(error)
- )
+ // `-z` is this command's only flag older Git (<2.36) lacks, so its usage
+ // exit 129 signals the rejection in any locale; stderr match is a fallback.
+ if (getErrorCode(error) === '129') {
+ return true
+ }
+
+ return /(?:unknown|invalid|unrecognized) (?:switch|option).*`?-?z'?/i.test(getErrorText(error))
}
function isUnsupportedRevParsePathFormatError(error: unknown): boolean {
diff --git a/src/relay/git-handler-utils.test.ts b/src/relay/git-handler-utils.test.ts
index 9dca8777834..03d0fe14aed 100644
--- a/src/relay/git-handler-utils.test.ts
+++ b/src/relay/git-handler-utils.test.ts
@@ -1,5 +1,35 @@
import { describe, expect, it } from 'vitest'
import { parseStatusOutput } from './git-status-output-parser'
+import { isUnsupportedWorktreeListZError } from './git-handler-utils'
+
+describe('isUnsupportedWorktreeListZError', () => {
+ it('detects an unknown-switch usage error from stderr when the exit code is absent', () => {
+ // Isolates the regex fallback: no numeric code, so only the stderr text
+ // (a runner that dropped the exit code) can classify the rejection.
+ const error = Object.assign(new Error('worktree list -z'), {
+ stderr: "error: unknown switch `z'\nusage: git worktree list []\n"
+ })
+ expect(isUnsupportedWorktreeListZError(error)).toBe(true)
+ })
+
+ it('detects a localized (non-English) usage error via exit code 129', () => {
+ // The SSH remote may run under a non-English locale where the stderr text is
+ // translated; the numeric exit code must still classify the -z rejection.
+ const error = Object.assign(new Error('worktree list -z'), {
+ code: 129,
+ stderr: 'Fehler: Unbekannter Schalter »z«\nAufruf: git worktree list []\n'
+ })
+ expect(isUnsupportedWorktreeListZError(error)).toBe(true)
+ })
+
+ it('does not classify a fatal (exit 128) error as an unsupported -z rejection', () => {
+ const error = Object.assign(new Error('fatal'), {
+ code: 128,
+ stderr: 'fatal: unable to read tree\n'
+ })
+ expect(isUnsupportedWorktreeListZError(error)).toBe(false)
+ })
+})
describe('parseStatusOutput', () => {
it('parses upstream ahead/behind from porcelain v2 branch headers', () => {
diff --git a/src/relay/git-handler-utils.ts b/src/relay/git-handler-utils.ts
index 5ddd15fd896..f0a395da27b 100644
--- a/src/relay/git-handler-utils.ts
+++ b/src/relay/git-handler-utils.ts
@@ -147,10 +147,20 @@ function getErrorText(error: unknown): string {
return String(error)
}
+function getErrorCode(error: unknown): string | undefined {
+ return typeof error === 'object' && error !== null && 'code' in error
+ ? String((error as { code?: unknown }).code)
+ : undefined
+}
+
export function isUnsupportedWorktreeListZError(error: unknown): boolean {
- return /(?:unknown|invalid) (?:switch|option).*`?-z'?|(?:unknown|invalid) (?:switch|option).*`?z'?/i.test(
- getErrorText(error)
- )
+ // `-z` is this command's only flag older Git (<2.36) lacks, so its usage exit
+ // 129 signals the rejection in any locale; key for SSH remotes on old Git.
+ if (getErrorCode(error) === '129') {
+ return true
+ }
+
+ return /(?:unknown|invalid|unrecognized) (?:switch|option).*`?-?z'?/i.test(getErrorText(error))
}
export function parseWorktreeList(
diff --git a/src/renderer/src/components/Landing.tsx b/src/renderer/src/components/Landing.tsx
index 05d609f6d35..9e79671f25a 100644
--- a/src/renderer/src/components/Landing.tsx
+++ b/src/renderer/src/components/Landing.tsx
@@ -362,8 +362,8 @@ export default function Landing(): React.JSX.Element {
onClick={() => openModal('new-workspace-composer', { telemetrySource: 'unknown' })}
>
- {translate('auto.components.Landing.76a95f7f47', 'Create')}
- {createTargetLabel}
+ {translate('auto.components.Landing.76a95f7f47', 'Create')}{' '}
+ {createTargetLabel.toLowerCase()}
diff --git a/src/renderer/src/components/mobile/NetworkInterfacePicker.tsx b/src/renderer/src/components/mobile/NetworkInterfacePicker.tsx
index a668ccbc555..af45e066a3c 100644
--- a/src/renderer/src/components/mobile/NetworkInterfacePicker.tsx
+++ b/src/renderer/src/components/mobile/NetworkInterfacePicker.tsx
@@ -6,10 +6,11 @@ import type { MobileNetworkInterface } from '../settings/mobile-network-interfac
// Why: MobileHero (mobile pairing screen) and MobileNetworkInterfaceSection
// (Settings → Mobile) both need the same network selector. This wraps the
-// generic AddressPicker with the mobile grammar (IPv4 / Tailscale *.ts.net)
-// and copy. Discovered interfaces come from the OS; "Add custom address…"
-// opens a dialog for an address the OS didn't surface — the only way to pair
-// across networks.
+// generic AddressPicker with the mobile grammar (IPv4, any RFC 1123
+// hostname — including Tailscale *.ts.net and DDNS domains — optionally
+// with :port) and copy. Discovered interfaces come from the OS; "Add custom
+// address…" opens a dialog for an address the OS didn't surface — the only
+// way to pair across networks.
export type NetworkInterfacePickerProps = {
networkInterfaces: readonly MobileNetworkInterface[]
@@ -81,11 +82,11 @@ export function NetworkInterfacePicker({
inputLabel: translate('auto.components.mobile.CustomNetworkAddressDialog.label', 'Address'),
placeholder: translate(
'auto.components.mobile.CustomNetworkAddressDialog.placeholder',
- 'my-mac.ts.net or 192.168.1.50'
+ 'my-mac.ts.net, home.example.com, or 192.168.1.50'
),
hint: translate(
'auto.components.mobile.CustomNetworkAddressDialog.hint',
- 'Enter an IP address or a Tailscale hostname (ends in .ts.net).'
+ 'Enter an IP address or a hostname — a Tailscale MagicDNS name, a DDNS domain, or a LAN hostname — optionally with :port.'
),
cancel: translate('auto.components.mobile.CustomNetworkAddressDialog.cancel', 'Cancel'),
confirm: translate('auto.components.mobile.CustomNetworkAddressDialog.use', 'Use address')
diff --git a/src/renderer/src/components/sidebar/SidebarSettingsHelpMenu.test.tsx b/src/renderer/src/components/sidebar/SidebarSettingsHelpMenu.test.tsx
index 8fba33ed07d..6f6ea7809ef 100644
--- a/src/renderer/src/components/sidebar/SidebarSettingsHelpMenu.test.tsx
+++ b/src/renderer/src/components/sidebar/SidebarSettingsHelpMenu.test.tsx
@@ -1,6 +1,9 @@
+// @vitest-environment happy-dom
+
+import { act, type ReactNode } from 'react'
+import { createRoot, type Root } from 'react-dom/client'
import { renderToStaticMarkup } from 'react-dom/server'
-import type { ReactNode } from 'react'
-import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { SidebarSettingsHelpMenu } from './SidebarSettingsHelpMenu'
const mocks = vi.hoisted(() => ({
@@ -20,6 +23,7 @@ const mocks = vi.hoisted(() => ({
}))
let updateStatus = { state: 'idle' } as const
+const roots: Root[] = []
vi.mock('@/store', () => ({
useAppStore: (selector: (state: unknown) => unknown) =>
@@ -96,9 +100,48 @@ vi.mock('./SidebarFeedbackDialog', () => ({
SidebarFeedbackDialog: () =>
}))
+function installWindowApi(): void {
+ Object.assign(window, {
+ api: {
+ app: {
+ restart: mocks.appRestart
+ },
+ shell: {
+ openUrl: mocks.shellOpenUrl
+ },
+ updater: {
+ check: mocks.updaterCheck
+ }
+ }
+ })
+}
+
+async function renderMenu(): Promise {
+ const container = document.createElement('div')
+ document.body.appendChild(container)
+ const root = createRoot(container)
+ roots.push(root)
+
+ await act(async () => {
+ root.render()
+ })
+
+ return container
+}
+
+function findMenuItem(container: HTMLElement, label: string): HTMLButtonElement {
+ const button = Array.from(
+ container.querySelectorAll('[data-testid="menu-item"]')
+ ).find((element) => element.textContent?.includes(label))
+ expect(button).toBeDefined()
+ return button as HTMLButtonElement
+}
+
describe('SidebarSettingsHelpMenu', () => {
beforeEach(() => {
+ globalThis.IS_REACT_ACT_ENVIRONMENT = true
vi.clearAllMocks()
+ installWindowApi()
mocks.useShortcutKeyDetails.mockReturnValue({ keys: ['⌘', ','], doubleTap: false })
updateStatus = { state: 'idle' }
mocks.setupProgress = {
@@ -109,6 +152,13 @@ describe('SidebarSettingsHelpMenu', () => {
}
})
+ afterEach(() => {
+ roots.splice(0).forEach((root) => {
+ act(() => root.unmount())
+ })
+ document.body.replaceChildren()
+ })
+
it('renders the help button with correct aria-label', () => {
const html = renderToStaticMarkup()
expect(html).toContain('Help')
@@ -177,6 +227,19 @@ describe('SidebarSettingsHelpMenu', () => {
it('renders Discord link', () => {
const html = renderToStaticMarkup()
expect(html).toContain('Discord')
+ expect(html).toContain('viewBox="0 0 20 20"')
+ expect(html).toContain('M16.0742 4.45014C14.9244 3.92097 13.7106 3.54556 12.4638 3.3335')
+ })
+
+ it('opens Discord invite through the shell bridge', async () => {
+ const container = await renderMenu()
+ const discordButton = findMenuItem(container, 'Discord')
+
+ await act(async () => {
+ discordButton.click()
+ })
+
+ expect(mocks.shellOpenUrl).toHaveBeenCalledWith('https://discord.gg/fzjDKHxv8Q')
})
it('renders X link', () => {
diff --git a/src/renderer/src/components/sidebar/SidebarSettingsHelpMenu.tsx b/src/renderer/src/components/sidebar/SidebarSettingsHelpMenu.tsx
index f5bf3250c6f..d74fcfb11aa 100644
--- a/src/renderer/src/components/sidebar/SidebarSettingsHelpMenu.tsx
+++ b/src/renderer/src/components/sidebar/SidebarSettingsHelpMenu.tsx
@@ -46,8 +46,8 @@ function openExternalUrl(url: string): void {
function DiscordIcon(): React.JSX.Element {
return (
-