diff --git a/.playwright/scripts/__tests__/browserBrowsingBoundaries.e2e.mjs b/.playwright/scripts/__tests__/browserBrowsingBoundaries.e2e.mjs new file mode 100644 index 00000000..372f5c37 --- /dev/null +++ b/.playwright/scripts/__tests__/browserBrowsingBoundaries.e2e.mjs @@ -0,0 +1,312 @@ +import { Buffer } from 'node:buffer' +import { mkdir, readFile, writeFile } from 'node:fs/promises' +import { createServer } from 'node:http' +import { join } from 'node:path' +import { expect, test } from '../fixtures/electron.mjs' + +const BODY = 'fixture,name\n1,safe-data\n' +async function fixtureSite() { + const server = createServer((request, response) => { + const path = new URL(request.url, 'http://localhost').pathname + if (['/download', '/delayed', '/stream', '/broken'].includes(path)) { + const send = () => { + if (response.destroyed) + return + response.setHeader('Content-Disposition', 'attachment; filename="report.csv"') + response.setHeader('Content-Type', 'application/octet-stream') + if (path === '/stream') { + response.setHeader('Content-Length', 20 * 1024 * 1024) + response.flushHeaders() + let sent = 0 + const timer = setInterval(() => { + if (sent >= 20 * 1024 * 1024) { + clearInterval(timer) + response.end() + } + else { + response.write(Buffer.alloc(64 * 1024, 65)) + sent += 64 * 1024 + } + }, 50) + response.on('close', () => clearInterval(timer)) + return + } + if (path === '/broken') { + response.setHeader('Content-Length', 100_000) + response.write('incomplete fixture') + const timer = setTimeout(() => response.destroy(), 200) + response.on('close', () => clearTimeout(timer)) + return + } + response.end(BODY) + } + if (path === '/delayed') { + const timer = setTimeout(send, 1_500) + response.on('close', () => clearTimeout(timer)) + } + else { + send() + } + return + } + response.setHeader('Content-Type', 'text/html; charset=utf-8') + response.end(`Browser boundary fixtureDownloadDelayed attachmentStreaming downloadBroken download`) + }) + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)) + return { + url: `http://127.0.0.1:${server.address().port}/`, + close: () => new Promise((resolve) => { + server.closeAllConnections() + server.close(resolve) + }), + } +} +async function launch(buddy, url, label) { + const instance = await buddy.createInstance(label) + const { app, page } = await instance.launch() + await expect(page.locator('.desktop-workbench-area__tasks .tiptap:visible').first()).toBeVisible() + await page.keyboard.press('Control+Shift+P') + await page.getByPlaceholder('输入命令名称').fill('浏览器') + await page.getByPlaceholder('输入命令名称').press('Enter') + await page.getByTestId('browser-address').fill(url) + await page.getByTestId('browser-address').press('Enter') + await expect(page.getByTestId('browser-pick-element')).toBeEnabled() + const sessionId = (await page.evaluate(() => window.lexoraDesktop.browser.listGuests()))[0].sessionId + return { app, page, sessionId } +} +async function guest(app, url, expression) { + return app.evaluate(({ webContents }, { url, expression }) => webContents.getAllWebContents().find(item => item.getType() === 'webview' && item.getURL() === url).executeJavaScript(expression), { url, expression }) +} +async function click(app, url, selector) { + await app.evaluate(async ({ webContents }, { url, selector }) => { + const page = webContents.getAllWebContents().find(item => item.getType() === 'webview' && item.getURL() === url) + const rect = await page.executeJavaScript(`(() => {const el=document.querySelector(${JSON.stringify(selector)});el.scrollIntoView({block:'center',behavior:'instant'});const r=el.getBoundingClientRect();return {x:r.x+r.width/2,y:r.y+r.height/2}})()`) + page.focus() + const point = { x: Math.round(rect.x * page.getZoomFactor()), y: Math.round(rect.y * page.getZoomFactor()) } + page.sendInputEvent({ type: 'mouseDown', button: 'left', clickCount: 1, ...point }) + page.sendInputEvent({ type: 'mouseUp', button: 'left', clickCount: 1, ...point }) + }, { url, selector }) +} +async function saveNext(app, url, path, cancel = false) { + await app.evaluate(({ webContents }, { url, path, cancel }) => { + const page = webContents.getAllWebContents().find(item => item.getType() === 'webview' && item.getURL() === url) + page.session.once('will-download', (_event, item) => { + globalThis.__fixtureDownload = item + globalThis.__fixtureDownloadResult = null + item.once('done', (_event, state) => { + globalThis.__fixtureDownloadResult = state + }) + if (cancel) + item.cancel() + else item.setSavePath(path) + }) + }, { url, path, cancel }) +} +async function files() { + const root = test.info().outputPath('fixture-files') + await mkdir(root, { recursive: true }) + return root +} + +test('download cancellation and simulated overwrite consent preserve the existing file until approved', async ({ buddy }) => { + const site = await fixtureSite() + try { + const { app, page } = await launch(buddy, site.url, 'browser-overwrite') + const path = join(await files(), 'report.csv') + await writeFile(path, 'existing fixture -- do not delete') + await saveNext(app, site.url, path, true) + await click(app, site.url, '#download') + await expect(page.getByTestId('browser-notice')).toContainText('已取消下载') + expect(await readFile(path, 'utf8')).toBe('existing fixture -- do not delete') + await saveNext(app, site.url, path) + await click(app, site.url, '#download') + await expect(page.getByTestId('browser-notice')).toContainText('已下载') + expect(await readFile(path, 'utf8')).toBe(BODY) + } + finally { await site.close() } +}) + +test('an immediate generated blob download is saved with matching bytes', async ({ buddy }) => { + const site = await fixtureSite() + try { + const { app, page } = await launch(buddy, site.url, 'browser-blob-download') + const path = join(await files(), 'generated.txt') + await saveNext(app, site.url, path) + await click(app, site.url, '#blob') + await expect(page.getByTestId('browser-notice')).toContainText('已下载 generated.txt') + expect(await readFile(path, 'utf8')).toBe('immediate fixture') + } + finally { await site.close() } +}) + +test('closing a streaming download requires confirmation; decline keeps it running and approval stops it', async ({ buddy }) => { + const site = await fixtureSite() + try { + const { app, page, sessionId } = await launch(buddy, site.url, 'browser-close-download') + await saveNext(app, site.url, join(await files(), 'stream.csv')) + await click(app, site.url, '#stream') + await expect(page.getByTestId('browser-notice')).toContainText('正在下载') + await app.evaluate(({ dialog }) => { + globalThis.__closeResponse = 0 + globalThis.__closeOptions = [] + dialog.showMessageBox = async (_window, options) => { + globalThis.__closeOptions.push(options) + return { response: globalThis.__closeResponse } + } + }) + const rejected = await page.evaluate(async (id) => { + try { + await window.lexoraDesktop.browser.close(id) + return false + } + catch { return true } + }, sessionId) + expect(rejected).toBe(true) + expect((await page.evaluate(() => window.lexoraDesktop.browser.listGuests())).map(item => item.sessionId)).toContain(sessionId) + expect(await app.evaluate(() => globalThis.__fixtureDownload.getState())).toBe('progressing') + await app.evaluate(() => { + globalThis.__closeResponse = 1 + }) + await page.evaluate(id => window.lexoraDesktop.browser.close(id), sessionId) + await expect.poll(() => app.evaluate(() => globalThis.__fixtureDownloadResult)).toBe('cancelled') + expect((await page.evaluate(() => window.lexoraDesktop.browser.listGuests())).map(item => item.sessionId)).not.toContain(sessionId) + expect(await app.evaluate(() => globalThis.__closeOptions)).toEqual([expect.objectContaining({ defaultId: 0, cancelId: 0 }), expect.objectContaining({ defaultId: 0, cancelId: 0 })]) + } + finally { await site.close() } +}) + +test('an interrupted transfer is reported as failed without a reveal-file action', async ({ buddy }) => { + const site = await fixtureSite() + try { + const { app, page } = await launch(buddy, site.url, 'browser-download-failure') + await saveNext(app, site.url, join(await files(), 'broken.csv')) + await click(app, site.url, '#broken') + await expect(page.getByTestId('browser-notice')).toContainText('下载失败', { timeout: 30_000 }) + await expect(page.getByTestId('browser-notice-reveal')).toHaveCount(0) + } + finally { await site.close() } +}) + +for (const selector of ['#generated', '#delayed']) { + test(`slow download ${selector} fails closed with explicit feedback`, async ({ buddy }) => { + const site = await fixtureSite() + try { + const { app, page } = await launch(buddy, site.url, 'browser-slow-download') + if (selector === '#generated') { + await guest(app, site.url, 'window.addEventListener("error",event=>document.body.dataset.fixtureError=event.message)') + await app.evaluate(({ webContents }, url) => { + globalThis.__generatedEvents = [] + const page = webContents.getAllWebContents().find(item => item.getType() === 'webview' && item.getURL() === url) + page.session.on('will-download', (_event, item, source) => globalThis.__generatedEvents.push({ id: source?.id, name: item.getFilename() })) + }, site.url) + } + await click(app, site.url, selector) + if (selector === '#generated') { + await expect.poll(() => guest(app, site.url, '({state:document.body.dataset.generation,error:document.body.dataset.fixtureError??""})')).toEqual({ state: 'completed', error: '' }) + await expect.poll(() => app.evaluate(() => globalThis.__generatedEvents.length)).toBe(1) + } + await expect(page.getByTestId('browser-notice')).toContainText('已拦截') + await expect(page.getByTestId('browser-notice-reveal')).toHaveCount(0) + } + finally { await site.close() } + }) +} + +test('single and multiple upload selections, cancel, and navigation during selection keep target ownership', async ({ buddy }) => { + const site = await fixtureSite() + try { + const { app, page, sessionId } = await launch(buddy, site.url, 'browser-upload-boundaries') + const root = await files() + const paths = [join(root, 'first.txt'), join(root, 'second.txt')] + await Promise.all(paths.map((path, index) => writeFile(path, `fixture ${index}`))) + await app.evaluate(({ dialog }, paths) => { + globalThis.__uploadMode = 'single' + globalThis.__uploadOptions = [] + dialog.showOpenDialog = async (_window, options) => { + globalThis.__uploadOptions.push(options) + if (globalThis.__uploadMode === 'pending') + return new Promise((resolve) => { globalThis.__finishUpload = resolve }) + return { canceled: globalThis.__uploadMode === 'cancel', filePaths: globalThis.__uploadMode === 'multiple' ? paths : [paths[0]] } + } + }, paths) + await click(app, site.url, '#single') + await expect.poll(() => guest(app, site.url, 'document.querySelector("#single").files.length')).toBe(1) + await app.evaluate(() => { + globalThis.__uploadMode = 'multiple' + }) + await click(app, site.url, '#file') + await expect.poll(() => guest(app, site.url, 'document.querySelector("#file").files.length')).toBe(2) + await app.evaluate(() => { + globalThis.__uploadMode = 'cancel' + }) + await click(app, site.url, '#file') + await expect.poll(() => app.evaluate(() => globalThis.__uploadOptions.length)).toBe(3) + expect(await guest(app, site.url, 'document.querySelector("#file").files.length')).toBe(2) + await app.evaluate(() => { + globalThis.__uploadMode = 'pending' + }) + await click(app, site.url, '#file') + await expect.poll(() => app.evaluate(() => typeof globalThis.__finishUpload)).toBe('function') + const replacement = `${site.url}replacement` + await page.evaluate(({ sessionId, replacement }) => window.lexoraDesktop.browser.navigate(sessionId, replacement), { sessionId, replacement }) + await app.evaluate((_electron, paths) => globalThis.__finishUpload({ canceled: false, filePaths: paths }), paths) + await expect(page.getByTestId('browser-notice')).toContainText('上传已取消') + expect(await guest(app, replacement, 'document.querySelector("#file").files.length')).toBe(0) + expect((await app.evaluate(() => globalThis.__uploadOptions))[0].properties).toEqual(['openFile']) + } + finally { await site.close() } +}) + +test('alert and confirm use trusted origins and preserve decisions; dialog storms are suppressed', async ({ buddy }) => { + const site = await fixtureSite() + try { + const url = `${site.url}?token=fixture-secret` + const { app, page } = await launch(buddy, url, 'browser-dialog-boundaries') + app.context().on('page', target => target.on('dialog', () => {})) + for (const target of app.context().pages()) + target.on('dialog', () => {}) + await app.evaluate(({ dialog }) => { + globalThis.__dialogOptions = [] + globalThis.__dialogResponses = [1, 0, 0] + dialog.showMessageBoxSync = (_window, options) => { + globalThis.__dialogOptions.push(options) + return globalThis.__dialogResponses.shift() ?? 0 + } + }) + await click(app, url, '#confirm') + await expect.poll(() => guest(app, url, 'document.body.dataset.answer')).toBe('true') + await click(app, url, '#confirm') + await expect.poll(() => guest(app, url, 'document.body.dataset.answer')).toBe('false') + await click(app, url, '#alert') + await expect.poll(() => guest(app, url, 'document.body.dataset.alerted')).toBe('yes') + await click(app, url, '#confirm') + await expect(page.getByTestId('browser-notice')).toContainText('网页对话框已抑制') + const options = await app.evaluate(() => globalThis.__dialogOptions) + expect(options).toHaveLength(3) + expect(options.every(option => option.message === new URL(url).origin && !option.message.includes('fixture-secret'))).toBe(true) + expect(await guest(app, url, '({desktop:typeof lexoraDesktop,node:typeof require,process:typeof process})')).toEqual({ desktop: 'undefined', node: 'undefined', process: 'undefined' }) + } + finally { await site.close() } +}) + +test('cache-only cleanup keeps fixture login and storage; clearing all removes browser data but not app data', async ({ buddy }) => { + const site = await fixtureSite() + try { + const { app, page } = await launch(buddy, site.url, 'browser-clear-data') + await guest(app, site.url, 'localStorage.setItem("fixture-login","signed-in")') + await app.evaluate(async ({ BrowserWindow, webContents }, url) => { + const guest = webContents.getAllWebContents().find(item => item.getType() === 'webview' && item.getURL() === url) + await guest.session.cookies.set({ url, name: 'fixture-login', value: 'signed-in' }) + await BrowserWindow.getAllWindows()[0].webContents.session.cookies.set({ url, name: 'app-sentinel', value: 'unchanged' }) + }, site.url) + expect(await page.evaluate(() => window.lexoraDesktop.browser.clearData({ cache: true, siteData: false }))).toEqual({ ok: true }) + expect(await guest(app, site.url, 'localStorage.getItem("fixture-login")')).toBe('signed-in') + const cookieCount = () => app.evaluate(async ({ webContents }, url) => webContents.getAllWebContents().find(item => item.getType() === 'webview' && item.getURL() === url).session.cookies.get({ name: 'fixture-login' }).then(items => items.length), site.url) + expect(await cookieCount()).toBe(1) + expect(await page.evaluate(() => window.lexoraDesktop.browser.clearData({ cache: true, siteData: true }))).toEqual({ ok: true }) + await expect.poll(cookieCount).toBe(0) + expect(await guest(app, site.url, 'localStorage.getItem("fixture-login")')).toBeNull() + expect(await app.evaluate(async ({ BrowserWindow }) => (await BrowserWindow.getAllWindows()[0].webContents.session.cookies.get({ name: 'app-sentinel' })).length)).toBe(1) + } + finally { await site.close() } +}) diff --git a/.playwright/scripts/__tests__/browserBrowsingCompletion.e2e.mjs b/.playwright/scripts/__tests__/browserBrowsingCompletion.e2e.mjs new file mode 100644 index 00000000..43163dac --- /dev/null +++ b/.playwright/scripts/__tests__/browserBrowsingCompletion.e2e.mjs @@ -0,0 +1,134 @@ +import { mkdtemp, readFile, writeFile } from 'node:fs/promises' +import { createServer } from 'node:http' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { expect, test } from '../fixtures/electron.mjs' + +async function site() { + const server = createServer((req, res) => { + if (req.url === '/download') { + res.setHeader('Content-Disposition', 'attachment; filename="report.csv"') + res.end('name,value\nfixture,42\n') + return + } + if (req.url === '/icon.svg') { + res.setHeader('Content-Type', 'image/svg+xml') + res.end('') + return + } + res.setHeader('Content-Type', 'text/html') + res.end(`Browser completion fixtureDownload reportPopup
State preservation
`) + }) + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)) + return { url: `http://127.0.0.1:${server.address().port}/`, close: () => new Promise(resolve => server.close(resolve)) } +} +async function open(page, url) { + await expect(page.locator('.desktop-workbench-area__tasks .tiptap:visible').first()).toBeVisible() + await page.keyboard.press('Control+Shift+P') + await page.getByPlaceholder('输入命令名称').fill('浏览器') + await page.getByPlaceholder('输入命令名称').press('Enter') + const address = page.getByTestId('browser-address') + await address.fill(url) + await address.press('Enter') + await expect(page.getByTestId('browser-pick-element')).toBeEnabled() +} +async function clickGuest(app, url, selector) { + await app.evaluate(async ({ webContents }, { url, selector }) => { + const guest = webContents.getAllWebContents().find(item => item.getType() === 'webview' && item.getURL() === url) + guest.focus() + const rect = await guest.executeJavaScript(`(() => {const r=document.querySelector(${JSON.stringify(selector)}).getBoundingClientRect();return {x:r.x+r.width/2,y:r.y+r.height/2}})()`) + const position = { x: Math.round(rect.x * guest.getZoomFactor()), y: Math.round(rect.y * guest.getZoomFactor()) } + guest.sendInputEvent({ type: 'mouseDown', button: 'left', clickCount: 1, ...position }) + guest.sendInputEvent({ type: 'mouseUp', button: 'left', clickCount: 1, ...position }) + }, { url, selector }) +} +async function guestValue(app, url, expression) { + return app.evaluate(async ({ webContents }, { url, expression }) => { + const guest = webContents.getAllWebContents().find(item => item.getType() === 'webview' && item.getURL() === url) + return guest.executeJavaScript(expression) + }, { url, expression }) +} + +test('human upload uses the intercepted DOM input and download bytes match the reported completion', async ({ buddy }) => { + const fixture = await site() + const directory = await mkdtemp(join(tmpdir(), 'lexora-browser-completion-')) + const upload = join(directory, 'upload.txt') + const saved = join(directory, 'report.csv') + await writeFile(upload, 'fixture upload') + try { + const instance = await buddy.createInstance('browser-upload-download') + const { app, page } = await instance.launch() + await app.evaluate(({ dialog }, upload) => { + globalThis.__chooserOptions = null + dialog.showOpenDialog = async (_window, options) => { + globalThis.__chooserOptions = options + return { canceled: false, filePaths: [upload] } + } + }, upload) + await open(page, fixture.url) + await clickGuest(app, fixture.url, '#file') + await expect.poll(() => guestValue(app, fixture.url, 'document.querySelector("#file").files[0]?.name')).toBe('upload.txt') + expect(await app.evaluate(() => globalThis.__chooserOptions)).toMatchObject({ properties: ['openFile', 'multiSelections'], filters: [{ name: '.txt', extensions: ['txt'] }, { name: '*', extensions: ['*'] }] }) + await app.evaluate(({ webContents }, { url, saved }) => { + const guest = webContents.getAllWebContents().find(item => item.getType() === 'webview' && item.getURL() === url) + guest.session.once('will-download', (_event, item) => item.setSavePath(saved)) + }, { url: fixture.url, saved }) + await clickGuest(app, fixture.url, '#download') + await expect(page.getByTestId('browser-notice')).toContainText('已下载 report.csv') + await expect(page.getByTestId('browser-notice-reveal')).toBeVisible() + expect(await readFile(saved, 'utf8')).toBe('name,value\nfixture,42\n') + await page.screenshot({ path: test.info().outputPath('upload-download-completed.png') }) + } + finally { await fixture.close() } +}) + +test('confirm cancellation preserves page semantics with a source-bound host dialog', async ({ buddy }) => { + const fixture = await site() + try { + const instance = await buddy.createInstance('browser-confirm') + const { app, page } = await instance.launch() + // The product owns these dialogs. Prevent Playwright's default auto-dismiss from racing it. + app.context().on('page', guest => guest.on('dialog', () => {})) + for (const target of app.context().pages()) + target.on('dialog', () => {}) + await app.evaluate(({ dialog }) => { + globalThis.__hostDialogOptions = [] + dialog.showMessageBoxSync = (_window, options) => { + globalThis.__hostDialogOptions.push(options) + return 0 + } + }) + await open(page, fixture.url) + await clickGuest(app, fixture.url, '#confirm') + await expect.poll(() => guestValue(app, fixture.url, 'document.body.dataset.confirmed')).toBe('false') + expect(await app.evaluate(() => globalThis.__hostDialogOptions)).toEqual([expect.objectContaining({ message: new URL(fixture.url).origin, detail: 'Continue?' })]) + } + finally { await fixture.close() } +}) + +test('responsive viewport and tab switching preserve the same guest and unsaved input', async ({ buddy }) => { + const fixture = await site() + try { + const instance = await buddy.createInstance('browser-responsive') + const { app, page } = await instance.launch() + await open(page, fixture.url) + await guestValue(app, fixture.url, 'document.querySelector("#draft").value="unsaved draft"') + const descriptors = await page.evaluate(() => window.lexoraDesktop.browser.listGuests()) + const sessionId = descriptors[0].sessionId + await page.evaluate(({ sessionId }) => window.lexoraDesktop.browser.setViewport(sessionId, { width: 390, height: 844, scale: 0.5 }), { sessionId }) + await expect.poll(() => guestValue(app, fixture.url, 'window.innerWidth')).toBe(390) + await expect.poll(() => guestValue(app, fixture.url, 'window.innerHeight')).toBe(844) + expect(await guestValue(app, fixture.url, 'document.querySelector("#draft").value')).toBe('unsaved draft') + await page.getByTestId('browser-more').click() + await page.getByText('退出响应式预览', { exact: true }).click() + await expect.poll(() => guestValue(app, fixture.url, 'window.innerWidth')).not.toBe(390) + await clickGuest(app, fixture.url, '#popup') + await expect(page.getByTestId('browser-address')).toHaveValue(`${fixture.url}destination`) + const tabs = page.getByRole('tab', { name: 'Browser completion fixture' }) + await tabs.first().click() + await expect(page.getByTestId('browser-address')).toHaveValue(fixture.url) + expect(await guestValue(app, fixture.url, 'document.querySelector("#draft").value')).toBe('unsaved draft') + expect((await page.evaluate(() => window.lexoraDesktop.browser.listGuests())).map(item => item.sessionId)).toContain(sessionId) + } + finally { await fixture.close() } +}) diff --git a/.playwright/scripts/__tests__/browserPopupNavigation.e2e.mjs b/.playwright/scripts/__tests__/browserPopupNavigation.e2e.mjs new file mode 100644 index 00000000..e05eb9fc --- /dev/null +++ b/.playwright/scripts/__tests__/browserPopupNavigation.e2e.mjs @@ -0,0 +1,120 @@ +import { createServer } from 'node:http' +import { expect, test } from '../fixtures/electron.mjs' + +const pages = { + '/': 'Video searchOpen video', + '/attachment': 'attachment body', + '/timer-download': 'Slow export
Exporting…
', + '/timer-popup': 'Popup storm
Popup storm
', + '/video': 'Video destination

Video destination

', +} + +/** Fixture site covering the managed-page flows Lexora has to route, block or explain. */ +async function startFixtureSite() { + const server = createServer((request, response) => { + const path = new URL(request.url, 'http://127.0.0.1').pathname + if (path === '/attachment') { + response.setHeader('Content-Disposition', 'attachment; filename="report.csv"') + response.setHeader('Content-Type', 'text/csv') + response.end(pages[path]) + return + } + response.setHeader('Content-Type', 'text/html; charset=utf-8') + response.end(pages[path] ?? pages['/']) + }) + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)) + return { + close: () => new Promise(resolve => server.close(resolve)), + url: `http://127.0.0.1:${server.address().port}`, + } +} + +/** Opens the browser panel through the command palette, the same path a user takes. */ +async function openBrowser(page) { + await expect(page.locator('.desktop-workbench-area__tasks .tiptap:visible').first()).toBeVisible() + await page.keyboard.press('Control+Shift+P') + await page.getByPlaceholder('输入命令名称').fill('浏览器') + await page.getByPlaceholder('输入命令名称').press('Enter') + const address = page.getByTestId('browser-address') + await expect(address).toBeVisible() + return address +} + +function listManagedPages(app, url) { + return app.evaluate(({ webContents }, prefix) => webContents + .getAllWebContents() + .filter(item => item.getType() === 'webview' && item.getURL().startsWith(prefix)) + .map(item => item.getURL()), url) +} + +test('manual target blank links open a managed browser tab and retain the source', async ({ buddy }) => { + const site = await startFixtureSite() + try { + const url = `${site.url}/` + const instance = await buddy.createInstance('browser-popup') + const { app, page } = await instance.launch() + const address = await openBrowser(page) + await address.fill(url) + await address.press('Enter') + await expect(page.getByTestId('browser-pick-element')).toBeEnabled() + await app.evaluate(async ({ webContents }, url) => { + const guest = webContents.getAllWebContents().find(item => item.getType() === 'webview' && item.getURL() === url) + guest.focus() + const point = await guest.executeJavaScript('(() => {const r=document.querySelector("#video").getBoundingClientRect();return {x:r.x+r.width/2,y:r.y+r.height/2}})()') + const position = { x: Math.round(point.x * guest.getZoomFactor()), y: Math.round(point.y * guest.getZoomFactor()) } + guest.sendInputEvent({ type: 'mouseDown', button: 'left', clickCount: 1, ...position }) + guest.sendInputEvent({ type: 'mouseUp', button: 'left', clickCount: 1, ...position }) + }, url) + await expect(address).toHaveValue(`${url}video`) + expect(await listManagedPages(app, site.url)).toEqual(expect.arrayContaining([url, `${url}video`])) + expect(await app.evaluate(({ BrowserWindow }) => BrowserWindow.getAllWindows().length)).toBe(1) + } + finally { + await site.close() + } +}) + +test('a scripted popup without a click is blocked and explained in the panel', async ({ buddy }) => { + const site = await startFixtureSite() + try { + const instance = await buddy.createInstance('browser-popup-blocked') + const { app, page } = await instance.launch() + const address = await openBrowser(page) + const url = `${site.url}/timer-popup` + await address.fill(url) + await address.press('Enter') + + const notice = page.getByTestId('browser-notice') + await expect(notice).toBeVisible() + await expect(notice).toContainText('已拦截') + expect(await listManagedPages(app, site.url)).toEqual([url]) + expect(await app.evaluate(({ BrowserWindow }) => BrowserWindow.getAllWindows().length)).toBe(1) + + await page.getByTestId('browser-notice-dismiss').click() + await expect(notice).toBeHidden() + } + finally { + await site.close() + } +}) + +test('a scripted download without a click is cancelled and explained in the panel', async ({ buddy }) => { + const site = await startFixtureSite() + try { + const instance = await buddy.createInstance('browser-download-blocked') + const { app, page } = await instance.launch() + const address = await openBrowser(page) + const url = `${site.url}/timer-download` + await address.fill(url) + await address.press('Enter') + + const notice = page.getByTestId('browser-notice') + await expect(notice).toBeVisible() + await expect(notice).toContainText('已拦截') + await expect(address).toHaveValue(url) + expect(await app.evaluate(({ BrowserWindow }) => BrowserWindow.getAllWindows().length)).toBe(1) + } + finally { + await site.close() + } +}) diff --git a/.playwright/scripts/__tests__/browserResponsivePreview.e2e.mjs b/.playwright/scripts/__tests__/browserResponsivePreview.e2e.mjs new file mode 100644 index 00000000..2fa4ac0d --- /dev/null +++ b/.playwright/scripts/__tests__/browserResponsivePreview.e2e.mjs @@ -0,0 +1,172 @@ +import { Buffer } from 'node:buffer' +import { execFile } from 'node:child_process' +import { writeFile } from 'node:fs/promises' +import { createServer } from 'node:http' +import { resolve } from 'node:path' +import process from 'node:process' +import { expect, test } from '../fixtures/electron.mjs' + +async function fixtureSite() { + const server = createServer((_req, res) => { + res.setHeader('Content-Type', 'text/html') + res.end(`Responsive preview fixture
Responsive layout
First section
Second section
`) + }) + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)) + return { url: `http://127.0.0.1:${server.address().port}/`, close: () => new Promise(resolve => server.close(resolve)) } +} +async function launch(buddy, url) { + const instance = await buddy.createInstance('browser-responsive-preview') + const { app, page } = await instance.launch() + await page.evaluate(() => { + window.lexoraDesktop.browser.onStateChanged((value) => { + window.__previewState = value + }) + }) + await page.keyboard.press('Control+Shift+P') + await page.getByPlaceholder('输入命令名称').fill('浏览器') + await page.getByPlaceholder('输入命令名称').press('Enter') + await page.getByTestId('browser-address').fill(url) + await page.getByTestId('browser-address').press('Enter') + await expect(page.getByTestId('browser-pick-element')).toBeEnabled() + return { app, page } +} +async function guest(app, url, expression) { + return app.evaluate(async ({ webContents }, { url, expression }) => { + const target = webContents.getAllWebContents().find(item => item.getType() === 'webview' && item.getURL() === url) + return target.executeJavaScript(expression) + }, { url, expression }) +} +async function toggleResponsive(page) { + const active = await page.getByTestId('browser-viewport-controls').isVisible() + await page.getByTestId('browser-more').click() + await page.getByText(active ? '退出响应式预览' : '响应式预览', { exact: true }).click() +} +async function state(page) { + return page.evaluate(() => window.__previewState) +} +async function nativeScreenshot(app, url, name) { + const image = await app.evaluate(async ({ webContents }, url) => { + const target = webContents.getAllWebContents().find(item => item.getType() === 'webview' && item.getURL() === url) + return (await target.capturePage()).toPNG().toString('base64') + }, url) + await writeFile(test.info().outputPath(name), Buffer.from(image, 'base64')) + if (process.platform === 'win32') { + const id = await app.evaluate(({ BrowserWindow }) => { + const window = BrowserWindow.getAllWindows()[0] + const handle = window.getNativeWindowHandle() + return (handle.length === 8 ? handle.readBigUInt64LE() : BigInt(handle.readUInt32LE())).toString() + }) + await new Promise((resolvePromise, reject) => { + execFile('powershell.exe', ['-NoProfile', '-File', resolve('.playwright/scripts/capture-responsive-window.ps1'), '-Handle', id, '-Path', test.info().outputPath(name.replace('guest', 'desktop'))], { windowsHide: true }, error => error ? reject(error) : resolvePromise()) + }) + } +} + +test('responsive preview is centered; zoom changes visual size but not CSS breakpoints or page state', async ({ buddy }) => { + const site = await fixtureSite() + try { + const { app, page } = await launch(buddy, site.url) + const before = await state(page) + await guest(app, site.url, 'document.querySelector("#draft").value="unsaved draft"') + await toggleResponsive(page) + await expect(page.getByTestId('browser-viewport-controls')).toBeVisible() + await expect(page.getByTestId('browser-viewport-device')).toHaveValue('iphone-14-pro') + await expect(page.getByTestId('browser-viewport-width')).toHaveValue('393') + await expect(page.getByTestId('browser-viewport-height')).toHaveValue('852') + await expect(page.getByTestId('browser-viewport-zoom')).toHaveValue('fit') + await expect.poll(() => guest(app, site.url, 'window.innerWidth')).toBe(393) + expect(await guest(app, site.url, 'getComputedStyle(document.querySelector("#layout"),"::before").content')).toContain('Compact layout') + const centered = await page.evaluate(() => { + const canvas = document.querySelector('[data-testid="browser-preview-canvas"]').getBoundingClientRect() + const frame = document.querySelector('[data-testid="browser-preview-frame"]').getBoundingClientRect() + return Math.abs(frame.x + frame.width / 2 - canvas.x - canvas.width / 2) + }) + expect(centered).toBeLessThan(2) + await guest(app, site.url, 'new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve)))') + await page.mouse.move(500, 100) + await nativeScreenshot(app, site.url, 'responsive-guest-native.png') + await page.screenshot({ path: test.info().outputPath('responsive-fit.png') }) + for (const zoom of ['50', '100', '200']) { + await page.getByTestId('browser-viewport-zoom').selectOption(zoom) + await expect.poll(async () => (await page.getByTestId('browser-preview-frame').boundingBox()).width).toBeCloseTo(393 * Number(zoom) / 100, 0) + await expect.poll(() => guest(app, site.url, 'window.innerWidth')).toBe(393) + expect(await guest(app, site.url, 'document.querySelector("#draft").value')).toBe('unsaved draft') + } + await page.getByTestId('browser-viewport-zoom').selectOption('50') + await expect.poll(async () => (await state(page)).viewport?.scale).toBe(0.5) + const position = await guest(app, site.url, '(()=>{const r=document.querySelector("#click").getBoundingClientRect();return {x:r.x+r.width/2,y:r.y+r.height/2}})()') + const bounds = await page.getByTestId('browser-guest-surface').boundingBox() + await page.mouse.click(bounds.x + position.x * 0.5, bounds.y + position.y * 0.5) + await expect.poll(() => guest(app, site.url, 'document.querySelector("#click").textContent')).toBe('Clicked correctly') + await guest(app, site.url, 'new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve)))') + await nativeScreenshot(app, site.url, 'responsive-guest-50-percent.png') + await page.screenshot({ path: test.info().outputPath('responsive-50-percent.png') }) + await toggleResponsive(page) + await expect(page.getByTestId('browser-viewport-controls')).toHaveCount(0) + await expect.poll(async () => (await state(page)).viewport).toBeNull() + expect((await state(page)).pageId).toBe(before.pageId) + expect(await guest(app, site.url, 'document.querySelector("#draft").value')).toBe('unsaved draft') + } + finally { await site.close() } +}) + +test('dimensions commit on blur or Enter; invalid drafts stay visible and resize handles support drag and keys', async ({ buddy }) => { + const site = await fixtureSite() + try { + const { app, page } = await launch(buddy, site.url) + await toggleResponsive(page) + const width = page.getByTestId('browser-viewport-width') + await width.fill('430') + await width.press('Enter') + await expect.poll(() => guest(app, site.url, 'window.innerWidth')).toBe(430) + const height = page.getByTestId('browser-viewport-height') + await height.fill('700') + await height.press('Tab') + await expect.poll(() => guest(app, site.url, 'window.innerHeight')).toBe(700) + await width.fill('100') + await width.press('Enter') + await expect(width).toHaveAttribute('aria-invalid', 'true') + await expect(width).toHaveValue('100') + expect(await guest(app, site.url, 'window.innerWidth')).toBe(430) + await width.press('Escape') + await expect(width).toHaveValue('430') + await page.getByTestId('browser-viewport-zoom').selectOption('50') + await expect.poll(async () => (await state(page)).viewport?.scale).toBe(0.5) + const handle = page.getByTestId('browser-viewport-resize-right') + await handle.focus() + await handle.press('Shift+ArrowRight') + await expect.poll(() => guest(app, site.url, 'window.innerWidth')).toBe(440) + const box = await handle.boundingBox() + await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2) + await page.mouse.down() + await page.mouse.move(box.x + box.width / 2 + 25, box.y + box.height / 2, { steps: 5 }) + await page.mouse.up() + await expect.poll(() => guest(app, site.url, 'window.innerWidth')).toBe(490) + await expect(page.getByTestId('browser-viewport-device')).toHaveValue('custom') + await page.getByTestId('browser-viewport-device').selectOption('iphone-se') + await expect.poll(() => guest(app, site.url, 'window.innerWidth')).toBe(375) + await expect.poll(() => guest(app, site.url, 'window.innerHeight')).toBe(667) + await expect(page.getByTestId('browser-viewport-zoom')).toHaveValue('fit') + await page.screenshot({ path: test.info().outputPath('responsive-resized.png') }) + } + finally { await site.close() } +}) + +test('Fit follows available panel height while preserving the logical size and the same guest', async ({ buddy }) => { + const site = await fixtureSite() + try { + const { app, page } = await launch(buddy, site.url) + const guestId = await app.evaluate(({ webContents }, url) => webContents.getAllWebContents().find(item => item.getType() === 'webview' && item.getURL() === url).id, site.url) + await toggleResponsive(page) + await expect.poll(async () => (await state(page)).viewport?.width).toBe(393) + const originalScale = (await state(page)).viewport.scale + await app.evaluate(({ BrowserWindow }) => BrowserWindow.getAllWindows()[0].setSize(1280, 1000)) + await expect.poll(async () => (await state(page)).viewport?.scale).toBeGreaterThan(originalScale) + expect(await guest(app, site.url, 'window.innerWidth')).toBe(393) + expect(await guest(app, site.url, 'window.innerHeight')).toBe(852) + await app.evaluate(({ BrowserWindow }) => BrowserWindow.getAllWindows()[0].setSize(1280, 820)) + await expect.poll(async () => (await state(page)).viewport?.scale).toBeCloseTo(originalScale, 2) + expect(await app.evaluate(({ webContents }, url) => webContents.getAllWebContents().find(item => item.getType() === 'webview' && item.getURL() === url).id, site.url)).toBe(guestId) + } + finally { await site.close() } +}) diff --git a/.playwright/scripts/capture-responsive-window.ps1 b/.playwright/scripts/capture-responsive-window.ps1 new file mode 100644 index 00000000..75fee920 --- /dev/null +++ b/.playwright/scripts/capture-responsive-window.ps1 @@ -0,0 +1,28 @@ +param([long]$Handle, [string]$Path) +$ErrorActionPreference = 'Stop' +Add-Type -AssemblyName System.Drawing +Add-Type @' +using System; +using System.Runtime.InteropServices; +public class ResponsivePreviewWindow { + [StructLayout(LayoutKind.Sequential)] public struct Rect { public int Left, Top, Right, Bottom; } + [DllImport("user32.dll")] public static extern bool PrintWindow(IntPtr handle, IntPtr dc, uint flags); + [DllImport("user32.dll")] public static extern bool GetWindowRect(IntPtr handle, out Rect rect); +} +'@ +$rect = [ResponsivePreviewWindow+Rect]::new() +if (-not [ResponsivePreviewWindow]::GetWindowRect([IntPtr]::new($Handle), [ref]$rect)) { throw 'Cannot read test window bounds.' } +$bitmap = [System.Drawing.Bitmap]::new($rect.Right - $rect.Left, $rect.Bottom - $rect.Top) +$graphics = [System.Drawing.Graphics]::FromImage($bitmap) +try { + $dc = $graphics.GetHdc() + try { + if (-not [ResponsivePreviewWindow]::PrintWindow([IntPtr]::new($Handle), $dc, 2)) { throw 'Cannot capture the test window.' } + } + finally { $graphics.ReleaseHdc($dc) } + $bitmap.Save($Path, [System.Drawing.Imaging.ImageFormat]::Png) +} +finally { + $graphics.Dispose() + $bitmap.Dispose() +} diff --git a/apps/buddy/electron.vite.config.ts b/apps/buddy/electron.vite.config.ts index cf0f0606..bb1c318c 100644 --- a/apps/buddy/electron.vite.config.ts +++ b/apps/buddy/electron.vite.config.ts @@ -61,6 +61,7 @@ export default defineConfig({ input: { index: fileURLToPath(new URL('./electron/preload/index.ts', import.meta.url)), extensionHost: fileURLToPath(new URL('./electron/preload/extensionHost.ts', import.meta.url)), + browserGuest: fileURLToPath(new URL('./electron/preload/browserGuest.ts', import.meta.url)), }, output: { format: 'cjs', diff --git a/apps/buddy/electron/main/app/DesktopApplication.ts b/apps/buddy/electron/main/app/DesktopApplication.ts index 481331f7..eb7ab990 100644 --- a/apps/buddy/electron/main/app/DesktopApplication.ts +++ b/apps/buddy/electron/main/app/DesktopApplication.ts @@ -44,6 +44,7 @@ class DesktopApplication { ] this.#windows = new DesktopWindowHost(environment) this.#browser = new BrowserIntegration({ + getLanguage: () => this.#runtime.language, report: event => environment.events.publish(event), isTaskLinked: () => this.#runtime.config?.desktop.contextPanelMode === 'task', onActivityError: () => environment.events.publish({ level: 'warn', event: 'browser.activity.failed', errorCode: 'BROWSER_ACTIVITY_FAILED' }), diff --git a/apps/buddy/electron/main/app/DesktopIntegrations.ts b/apps/buddy/electron/main/app/DesktopIntegrations.ts index f79c958a..7266793f 100644 --- a/apps/buddy/electron/main/app/DesktopIntegrations.ts +++ b/apps/buddy/electron/main/app/DesktopIntegrations.ts @@ -180,6 +180,7 @@ export class DesktopIntegrations { }) this.#subscriptions.push(() => configNotifications.dispose()) this.#subscriptions.push(registerBrowserDesktopIpc({ + getLanguage: () => runtime.language, data: this.#browser.data, screenshots: this.#browser.screenshots, getHost: () => this.#browser.host, diff --git a/apps/buddy/electron/main/browser/BrowserDebugger.ts b/apps/buddy/electron/main/browser/BrowserDebugger.ts index 77d0c66e..85b5be89 100644 --- a/apps/buddy/electron/main/browser/BrowserDebugger.ts +++ b/apps/buddy/electron/main/browser/BrowserDebugger.ts @@ -3,10 +3,19 @@ export interface BrowserDebuggerPort { detach: () => void isAttached: () => boolean sendCommand: (method: string, params?: Record) => Promise + on?: (event: 'message', listener: (event: unknown, method: string, params: unknown) => void) => unknown + off?: (event: 'message', listener: (event: unknown, method: string, params: unknown) => void) => unknown } export class BrowserDebugger { readonly #port: BrowserDebuggerPort + readonly #subscriptions = new Map void>>() + readonly #messageListener = (_event: unknown, method: string, params: unknown): void => { + for (const listener of [...this.#subscriptions.get(method) ?? []]) + listener(params) + } + + #bound = false #ownsAttachment = false constructor(port: BrowserDebuggerPort) { @@ -14,19 +23,48 @@ export class BrowserDebugger { } ensureAttached(): void { - if (this.#port.isAttached()) - return - this.#port.attach('1.3') - this.#ownsAttachment = true + if (!this.#port.isAttached()) { + this.#port.attach('1.3') + this.#ownsAttachment = true + } + this.#bindMessages() } sendCommand(...args: Parameters): Promise { return this.#port.sendCommand(...args) } + /** + * Subscribes to a protocol event. Requires an attached debugger, so callers must attach the + * connection first; listeners are dropped together with the connection. + */ + onEvent(method: string, listener: (params: unknown) => void): () => void { + let listeners = this.#subscriptions.get(method) + if (!listeners) { + listeners = new Set() + this.#subscriptions.set(method, listeners) + } + listeners.add(listener) + return () => { + listeners?.delete(listener) + } + } + dispose(): void { + if (this.#bound) { + this.#port.off?.('message', this.#messageListener) + this.#bound = false + } + this.#subscriptions.clear() if (this.#ownsAttachment && this.#port.isAttached()) this.#port.detach() this.#ownsAttachment = false } + + #bindMessages(): void { + if (this.#bound || !this.#port.on) + return + this.#port.on('message', this.#messageListener) + this.#bound = true + } } diff --git a/apps/buddy/electron/main/browser/BrowserHost.ts b/apps/buddy/electron/main/browser/BrowserHost.ts index 1b0ba463..97a4f4e0 100644 --- a/apps/buddy/electron/main/browser/BrowserHost.ts +++ b/apps/buddy/electron/main/browser/BrowserHost.ts @@ -48,6 +48,10 @@ const BROWSER_WAIT_STATE_INTERVAL_MS = 50 const BROWSER_WAIT_PROBE_INTERVAL_MS = 150 interface BrowserHostOptions { + guestPreloadPath?: string + showDialog?: (input: { type: 'alert' | 'confirm', message: string, origin: string }) => Promise + openExternal?: (url: string) => Promise + selectFiles?: (input: import('./browserHumanActions').BrowserFileSelectionOptions) => Promise getFreezeDelay?: (visible: boolean) => number | null onActivityError?: () => void getDefaultZoomFactor?: () => number @@ -87,6 +91,10 @@ export class BrowserHost { readonly #changes = new Emitter(() => console.error('BROWSER_OBSERVER_FAILED')) readonly onDidChange = this.#changes.event #revision = 0 + readonly #openExternal: ((url: string) => Promise) | undefined + readonly #guestPreloadPath: string | undefined + readonly #showDialog: BrowserHostOptions['showDialog'] + readonly #selectFiles: BrowserHostOptions['selectFiles'] readonly #getFreezeDelay: (visible: boolean) => number | null readonly #onActivityError: () => void readonly #getDefaultZoomFactor: () => number @@ -108,6 +116,10 @@ export class BrowserHost { #disposed = false #drained: Promise = Promise.resolve() constructor(options: BrowserHostOptions) { + this.#guestPreloadPath = options.guestPreloadPath + this.#showDialog = options.showDialog + this.#openExternal = options.openExternal + this.#selectFiles = options.selectFiles this.#getFreezeDelay = options.getFreezeDelay ?? (() => null) this.#onActivityError = options.onActivityError ?? (() => {}) this.#operations = options.operations ?? new BrowserOperationGuard() @@ -174,6 +186,7 @@ export class BrowserHost { conversationId: string | null, profileMode: DesktopBrowserProfileMode, tabId?: string, + popup?: { openedFrom: NonNullable, partition: string }, ): DesktopBrowserState { this.#operations.assertCanMutate() this.#assertActive() @@ -181,7 +194,7 @@ export class BrowserHost { try { const session = this.#sessions.ensure(conversationId, ({ sessionId }) => { wasCreated = true - const session = this.#createSession(conversationId, sessionId, profileMode) + const session = this.#createSession(conversationId, sessionId, profileMode, popup) return { session, teardown: reason => this.#teardownSession(session, reason), @@ -253,6 +266,24 @@ export class BrowserHost { return { ...this.#requireSession(sessionId).descriptor } } + handlePageDialog(webContentsId: number, request: { type: 'alert' | 'confirm', message: string }, show: (origin: string) => boolean): boolean { + const session = this.#sessions.values().find(session => session.page?.id === webContentsId) + return session?.handlePageDialog(request, show) ?? false + } + + hasActiveDownloads(sessionId: string): boolean { + return this.#requireSession(sessionId).hasActiveDownloads + } + + setViewport(sessionId: string, viewport: import('../../../shared/browser/browserDesktopApi').DesktopBrowserViewport | null): Promise { + return this.#requireSession(sessionId).setViewport(viewport) + } + + openDevTools(sessionId: string): boolean { + this.#assertActive() + return this.#requireSession(sessionId).openDevTools() + } + acquireControl(input: BrowserAcquireControlParams): BrowserControlLease { this.#operations.assertCanMutate() const session = this.#requireSession(input.sessionId) @@ -959,11 +990,12 @@ export class BrowserHost { conversationId: string | null, sessionId: string, profileMode: DesktopBrowserProfileMode, + popup?: { openedFrom: NonNullable, partition: string }, ): BrowserPageSession { const descriptor: DesktopBrowserGuestDescriptor = { - partition: profileMode === 'default' + partition: popup?.partition ?? (profileMode === 'default' ? BROWSER_DEFAULT_PARTITION - : `buddy-browser-incognito:${sessionId}`, + : `buddy-browser-incognito:${sessionId}`), sessionId, } const session: BrowserPageSession = new BrowserPageSession({ @@ -976,8 +1008,22 @@ export class BrowserHost { onStateChanged: state => this.#emit({ kind: 'state', state }), onGuestChanged: status => this.#emit({ kind: 'guest', status, sessionId, pageId: session.state.pageId }), onHumanInput: () => this.#acceptHumanPageInput(session), + onWindowOpen: (url, external) => { + void this.#openPopup(session, url, external).catch((error) => { + if (this.#sessions.get(sessionId) !== session) + return + session.state.error = { + code: error instanceof BrowserHostError && error.code === 'BROWSER_SESSION_LIMIT_REACHED' ? error.code : 'BROWSER_NAVIGATION_BLOCKED', + message: 'Unable to open this link', + } + this.#publish(session) + }) + }, + ...(this.#showDialog ? { showDialog: this.#showDialog } : {}), + ...(this.#selectFiles ? { selectFiles: this.#selectFiles } : {}), isCurrent: () => this.#sessions.get(sessionId) === session, state: { + ...(popup ? { openedFrom: popup.openedFrom } : {}), zoomFactor: this.#getDefaultZoomFactor(), canGoBack: false, canGoForward: false, @@ -997,6 +1043,25 @@ export class BrowserHost { return session } + async #openPopup(source: BrowserPageSession, url: string, external: boolean): Promise { + this.#operations.assertCanMutate() + if (this.#sessions.get(source.state.sessionId) !== source || source.state.controller !== 'human') + return + if (external) { + // Do not silently transfer private browsing into the system browser's normal profile. + if (source.state.profileMode === 'incognito' || !this.#openExternal) + throw new Error('External browser unavailable for this profile') + await this.#openExternal(url) + return + } + const tabId = this.#createId() + const state = this.#ensureSession(source.state.conversationId, source.state.profileMode, tabId, { + openedFrom: { sessionId: source.state.sessionId, tabId }, + partition: source.descriptor.partition, + }) + await this.navigate(state.sessionId, url) + } + #teardownSession( session: BrowserPageSession, reason: BrowserSessionTeardownReason, @@ -1138,13 +1203,17 @@ export class BrowserHost { return } - delete params.allowpopups + // The renderer sets allowpopups on the element; without that attribute Electron + // blocks new-window requests before they reach the deny-and-route handler below. + params.allowpopups = 'true' delete params.preload delete webPreferences.preload + if (this.#guestPreloadPath) + webPreferences.preload = this.#guestPreloadPath Object.assign(webPreferences, { allowRunningInsecureContent: false, contextIsolation: true, - devTools: false, + devTools: true, nodeIntegration: false, nodeIntegrationInSubFrames: false, nodeIntegrationInWorker: false, diff --git a/apps/buddy/electron/main/browser/BrowserIntegration.ts b/apps/buddy/electron/main/browser/BrowserIntegration.ts index 2868ded5..83cc00b8 100644 --- a/apps/buddy/electron/main/browser/BrowserIntegration.ts +++ b/apps/buddy/electron/main/browser/BrowserIntegration.ts @@ -2,6 +2,8 @@ import type { BrowserWindow } from 'electron' import type { LocalEndpoint } from '../../../platform/ipc/localTransport' import type { BrowserPreferences } from '../../../shared/browser/browserPreferences' import type { ApplicationDiagnosticReporter } from '../../../shared/diagnostics/applicationDiagnostic' +import { join } from 'node:path' +import { dialog, shell } from 'electron' import { DESKTOP_IPC_CHANNELS } from '../../shared/desktopApi' import { BrowserAdapterServer } from './BrowserAdapterServer' import { BrowserAdapterTestLeasePublisher } from './BrowserAdapterTestLeasePublisher' @@ -11,6 +13,7 @@ import { BrowserOperationGuard } from './BrowserOperationGuard' import { BrowserScreenshotService } from './BrowserScreenshotService' interface BrowserIntegrationOptions { + getLanguage?: () => string isTaskLinked?: () => boolean onActivityError?: () => void endpoint: LocalEndpoint @@ -45,6 +48,33 @@ export class BrowserIntegration { bindWindow(window: BrowserWindow): void { this.closeWindow() this.#host = new BrowserHost({ + guestPreloadPath: join(import.meta.dirname, '../preload/browserGuest.cjs'), + openExternal: url => shell.openExternal(url), + showDialog: async ({ type, message, origin }) => { + if (window.isDestroyed()) + return false + const chinese = this.#options.getLanguage?.() === 'zh-CN' + const result = await dialog.showMessageBox(window, { + type: type === 'confirm' ? 'question' : 'info', + title: chinese ? 'Lexora — 网页对话框' : 'Lexora — Web page', + message: origin, + detail: message, + buttons: type === 'confirm' ? (chinese ? ['取消', '确定'] : ['Cancel', 'OK']) : [chinese ? '确定' : 'OK'], + defaultId: 0, + cancelId: 0, + noLink: true, + }) + return type === 'alert' || result.response === 1 + }, + selectFiles: async ({ multiple, filters }) => { + if (window.isDestroyed()) + return null + const result = await dialog.showOpenDialog(window, { + properties: multiple ? ['openFile', 'multiSelections'] : ['openFile'], + ...(filters ? { filters } : {}), + }) + return result.canceled ? null : result.filePaths + }, getFreezeDelay: (visible) => { if (!this.#options.isTaskLinked?.()) return null diff --git a/apps/buddy/electron/main/browser/BrowserPageSession.ts b/apps/buddy/electron/main/browser/BrowserPageSession.ts index 54eacdd6..068c1737 100644 --- a/apps/buddy/electron/main/browser/BrowserPageSession.ts +++ b/apps/buddy/electron/main/browser/BrowserPageSession.ts @@ -1,12 +1,15 @@ import type { Input, MouseInputEvent, WebContents } from 'electron' import type { EventEmitter } from 'node:events' import type { BrowserErrorCode } from '../../../shared/browser' -import type { DesktopBrowserError, DesktopBrowserGuestDescriptor, DesktopBrowserProfileMode, DesktopBrowserState } from '../../../shared/browser/browserDesktopApi' +import type { DesktopBrowserBlockedAction, DesktopBrowserError, DesktopBrowserGuestDescriptor, DesktopBrowserProfileMode, DesktopBrowserState, DesktopBrowserViewport } from '../../../shared/browser/browserDesktopApi' +import type { BrowserFileSelectionOptions } from './browserHumanActions' import type { BrowserOperationGuard } from './BrowserOperationGuard' -import type { BrowserSecurityPage, BrowserSecuritySession } from './BrowserSecurityPolicy' +import type { BrowserDownloadItem, BrowserFileChooserRequest, BrowserSecurityPage, BrowserSecuritySession } from './BrowserSecurityPolicy' +import { browserViewportSchema } from '../../../shared/browser/browserDesktopSchemas' import { browserZoomFactorSchema, stepBrowserZoom } from '../../../shared/browser/browserPreferences' import { BrowserDebugger } from './BrowserDebugger' import { BrowserHostError } from './BrowserHostError' +import { browserNoticeOrigin, fileChooserFilters } from './browserHumanActions' import { BrowserPageActivity } from './BrowserPageActivity' import { BrowserSecurityPolicy, isLoopbackBrowserUrl } from './BrowserSecurityPolicy' import { SemanticBrowserDriver } from './SemanticBrowserDriver' @@ -45,6 +48,10 @@ export interface BrowserPage extends BrowserSecurityPage { } export interface BrowserSessionState { + favicon?: string | null + viewport?: DesktopBrowserViewport | null + openedFrom?: DesktopBrowserState['openedFrom'] + download?: DesktopBrowserState['download'] zoomFactor: number canGoBack: boolean canGoForward: boolean @@ -75,12 +82,25 @@ interface BrowserPageSessionOptions { getDefaultZoomFactor: () => number onGuestChanged: (status: 'attached' | 'detached' | 'crashed') => void onHumanInput: () => void + onWindowOpen: (url: string, external: boolean) => void onStateChanged: (state: DesktopBrowserState) => void isCurrent: () => boolean operations: BrowserOperationGuard + showDialog?: (input: { type: 'alert' | 'confirm', message: string, origin: string }) => Promise + selectFiles?: (input: BrowserFileSelectionOptions) => Promise state: BrowserSessionState } +interface BrowserHumanGesture { + epoch: number + expires: number + external: boolean + revision: number +} + +const BLOCKED_NOTICE_DEDUPE_MS = 3_000 +const HUMAN_ACTION_TTL_MS = 1_000 + export class BrowserPageSession { actionTail = Promise.resolve() activeNavigationSequence: number | null = null @@ -88,6 +108,15 @@ export class BrowserPageSession { readonly descriptor: DesktopBrowserGuestDescriptor #listeners: Array<() => void> = [] mainFrameCommitSequence: number | null = null + #activeDownloads = new Set() + #noticeSequence = 0 + #downloadSequence = 0 + #dialogPending = false + #dialogTimes: number[] = [] + #fileChooserPending = false + #humanGesture: BrowserHumanGesture | null = null + #navigationDownloadGesture: { gesture: BrowserHumanGesture, revision: number } | null = null + #lastBlockedNotice: { action: DesktopBrowserBlockedAction, at: number, message: string } | null = null navigationSequence = 0 page: BrowserPage | null = null #connection: BrowserDebugger | null = null @@ -106,6 +135,21 @@ export class BrowserPageSession { this.descriptor = options.descriptor } + try { + } + finally { + } + } + + return false + } + + } + + this.#humanGesture = null + this.#navigationDownloadGesture = null + } + async setZoomFactor(factor: number | null): Promise { this.#options.operations.assertCanMutate() const page = this.requirePage() @@ -121,6 +165,79 @@ export class BrowserPageSession { this.#options.onStateChanged(snapshot(this.state)) } + /** Opens detached developer tools for this page only, never for the desktop application. */ + openDevTools(): boolean { + if (this.state.controller !== 'human') { + throw new BrowserHostError( + 'BROWSER_CONTROL_REQUIRED', + 'Page developer tools require human control', + ) + } + const page = this.page + if (!page || page.isDestroyed()) + return false + page.openDevTools({ mode: 'detach' }) + return true + } + + handlePageDialog(request: { type: 'alert' | 'confirm', message: string }, show: (origin: string) => boolean): boolean { + if (!['alert', 'confirm'].includes(request.type) || typeof request.message !== 'string' || request.message.length > 2_048) + return false + const page = this.page + const now = Date.now() + this.#dialogTimes = this.#dialogTimes.filter(time => now - time < 10_000) + if (!page || !this.#selectionCurrent(page, this.documentVersion, this.state.controlEpoch) + || this.#dialogPending || this.#dialogTimes.length >= 3) { + this.#reportBlocked('permission', 'Page dialog suppressed: take control or wait before retrying', 'dialog-suppressed') + return false + } + this.#dialogPending = true + this.#dialogTimes.push(now) + const revision = this.documentVersion + const epoch = this.state.controlEpoch + try { + return show(browserNoticeOrigin(page.getURL()) ?? 'This page') && this.#selectionCurrent(page, revision, epoch) + } + catch { + this.#reportBlocked('permission', 'The page dialog could not be displayed', 'unavailable') + return false + } + finally { this.#dialogPending = false } + } + + get hasActiveDownloads(): boolean { + return this.#activeDownloads.size > 0 + } + + cancelDownloads(): void { + for (const item of this.#activeDownloads) { + try { + item.cancel() + } + catch {} + } + this.#activeDownloads.clear() + } + + async setViewport(viewport: DesktopBrowserViewport | null): Promise { + this.#options.operations.assertCanMutate() + if (this.state.controller !== 'human' || this.agentActionDepth > 0) + throw new BrowserHostError('BROWSER_CONTROL_REQUIRED', 'Take control before changing the viewport') + const value = viewport ? browserViewportSchema.parse(viewport) : null + const connection = this.#connection + if (!connection) + throw new BrowserHostError('BROWSER_PAGE_FAILED', 'Browser guest is unavailable') + await this.runWhileActive(async () => { + connection.ensureAttached() + await connection.sendCommand(value ? 'Emulation.setDeviceMetricsOverride' : 'Emulation.clearDeviceMetricsOverride', value ? { width: value.width, height: value.height, deviceScaleFactor: 0, mobile: false, dontSetVisibleSize: true } : undefined) + }) + this.state.viewport = value + this.invalidateElementReferences() + this.semanticDriver?.invalidateDocument() + this.publish() + return snapshot(this.state) + } + updateActivity(): Promise { return this.#activity?.update() ?? Promise.resolve() } @@ -164,7 +281,24 @@ export class BrowserPageSession { page, }) this.securityPolicy = new BrowserSecurityPolicy({ + allowDownload: () => this.#consumeHumanGesture(true) !== null, connection: this.#connection, + onDownloadBlocked: () => this.#reportBlocked('download', 'Download blocked: start it with a fresh click while you control the page'), + onDownloadStarted: item => this.#trackDownload(item), + onFileChooser: (request) => { + void this.#handleFileChooser(request).catch(() => {}) + }, + onWindowOpenBlocked: () => this.#reportBlocked('popup', 'Popup target is not a safe web link', 'invalid-target'), + onWindowOpen: (url, disposition) => { + const gesture = this.#consumeHumanGesture() + if (!this.#options.isCurrent() || this.page !== page) + return + if (!gesture) { + this.#reportBlocked('popup', 'Popup blocked: a fresh manual click is required') + return + } + this.#options.onWindowOpen(url, gesture.external || disposition === 'background-tab') + }, onCertificateError: ({ error, url }) => { this.state.error = { code: 'BROWSER_CERTIFICATE_ERROR', @@ -174,13 +308,7 @@ export class BrowserPageSession { this.state.url = normalizeBrowserUrl(url) ?? this.state.url this.publish() }, - onPermissionDenied: () => { - this.state.error = { - code: 'BROWSER_PERMISSION_DENIED', - message: 'Browser permission request was denied', - } - this.publish() - }, + onPermissionDenied: () => this.#reportBlocked('permission', 'Browser permission request was denied'), onRequestBlocked: (details) => { if (details.resourceType !== 'mainFrame') return @@ -195,6 +323,9 @@ export class BrowserPageSession { page, session: page.session, }) + this.#connection.onEvent('Page.javascriptDialogOpening', (params) => { + void this.#handleJavaScriptDialog(params, page).catch(() => {}) + }) this.#configureSession(page) this.pageReady.resolve(page) this.refreshPageState() @@ -202,6 +333,175 @@ export class BrowserPageSession { this.#options.onGuestChanged('attached') } + #rememberPopupGesture(external: boolean): void { + if (this.state.visible && this.state.controller === 'human' && this.agentActionDepth === 0) { + this.#humanGesture = { + epoch: this.state.controlEpoch, + expires: Date.now() + HUMAN_ACTION_TTL_MS, + external, + revision: this.documentVersion, + } + } + } + + /** + * Returns the pending human gesture once, and only while it still describes the page the user + * acted on. Freshness, document revision, control epoch, visibility and agent activity are all + * required, so delayed requests or synthetic input cannot reuse an earlier click. + */ + #consumeHumanGesture(download = false): BrowserHumanGesture | null { + const navigation = download ? this.#navigationDownloadGesture : null + const gesture = this.#humanGesture ?? navigation?.gesture + const revision = this.#humanGesture ? gesture?.revision : navigation?.revision + this.#humanGesture = null + this.#navigationDownloadGesture = null + if (!gesture || gesture.expires < Date.now() || revision !== this.documentVersion + || gesture.epoch !== this.state.controlEpoch || !this.state.visible + || this.state.controller !== 'human' || this.agentActionDepth > 0) { + return null + } + return gesture + } + + async #handleFileChooser(request: BrowserFileChooserRequest): Promise { + const page = this.page + const securityPolicy = this.securityPolicy + if (!page || !securityPolicy || !this.#options.isCurrent()) + return + const gesture = this.#consumeHumanGesture() + if (!gesture) { + this.#reportBlocked('file-chooser', 'File selection blocked: a fresh manual click is required', 'fresh-click') + return + } + const selectFiles = this.#options.selectFiles + if (!selectFiles) { + this.#reportBlocked('file-chooser', 'File selection is unavailable in this build', 'unavailable') + return + } + // One dialog at a time: a page that repeats the request must not stack native dialogs. + if (this.#fileChooserPending) + return + this.#fileChooserPending = true + const documentVersion = this.documentVersion + try { + const accept = await securityPolicy.fileChooserAccept(request) + if (!this.#selectionCurrent(page, documentVersion, gesture.epoch)) { + this.#reportBlocked('file-chooser', 'Upload cancelled: the page or control changed', 'target-changed') + return + } + const filters = fileChooserFilters(accept) + const files = await selectFiles({ multiple: request.mode === 'selectMultiple', ...(filters ? { filters } : {}) }) + // Cancelling a chooser is not a failure and must not touch the page. + if (!files?.length) + return + if (this.page !== page || !this.#options.isCurrent()) + return + if (!this.#selectionCurrent(page, documentVersion, gesture.epoch)) { + this.#reportBlocked('file-chooser', 'Upload cancelled: the page or control changed while choosing a file', 'target-changed') + return + } + await securityPolicy.deliverFileSelection(request, files) + } + catch { + this.#reportBlocked('file-chooser', 'The selected file could not be attached to the page', 'selection-failed') + } + finally { + this.#fileChooserPending = false + } + } + + #selectionCurrent(page: BrowserPage, revision: number, epoch: number): boolean { + return this.#options.isCurrent() && this.page === page && !page.isDestroyed() + && this.documentVersion === revision && this.state.controlEpoch === epoch + && this.state.controller === 'human' && this.state.visible && this.agentActionDepth === 0 + } + + async #handleJavaScriptDialog(params: unknown, page: BrowserPage): Promise { + if (!params || typeof params !== 'object') + return + const request = params as { type?: string, message?: string, url?: string, hasBrowserHandler?: boolean } + // Keep working Electron-native prompt/beforeunload and native alert/confirm paths; never stack a second dialog. + if (request.hasBrowserHandler || !['alert', 'confirm'].includes(request.type ?? '')) + return + const connection = this.#connection + if (!connection) + return + const revision = this.documentVersion + const epoch = this.state.controlEpoch + const now = Date.now() + this.#dialogTimes = this.#dialogTimes.filter(time => now - time < 10_000) + let accepted = false + if (this.#selectionCurrent(page, revision, epoch) && !this.#dialogPending && this.#dialogTimes.length < 3 && this.#options.showDialog) { + this.#dialogPending = true + this.#dialogTimes.push(now) + try { + accepted = await this.#options.showDialog({ type: request.type as 'alert' | 'confirm', message: String(request.message ?? '').slice(0, 2_048), origin: browserNoticeOrigin(request.url ?? '') ?? browserNoticeOrigin(this.state.url) ?? 'This page' }) + accepted = accepted && this.#selectionCurrent(page, revision, epoch) + } + catch { + this.#reportBlocked('permission', 'The page dialog could not be displayed', 'unavailable') + } + finally { + this.#dialogPending = false + } + } + else { + this.#reportBlocked('permission', 'Page dialog suppressed: take control or wait before retrying', 'dialog-suppressed') + } + if (this.page === page && this.#connection === connection && !page.isDestroyed()) + await connection.sendCommand('Page.handleJavaScriptDialog', { accept: accepted }) + } + + #trackDownload(item: BrowserDownloadItem): void { + this.#activeDownloads.add(item) + const id = `${this.state.sessionId}:download:${++this.#downloadSequence}` + this.state.download = { + id, + fileName: readDownloadFileName(item), + path: null, + state: 'started', + } + this.publish() + const updated = (_event: unknown, result: 'interrupted' | 'progressing'): void => { + if (!this.#options.isCurrent() || this.state.download?.id !== id) + return + const state = result === 'interrupted' ? 'failed' : 'started' + if (this.state.download.state === state) + return + this.state.download = { ...this.state.download, path: null, state } + this.publish() + } + item.on?.('updated', updated) + item.once('done', (_event, result) => { + item.off?.('updated', updated) + this.#activeDownloads.delete(item) + if (!this.#options.isCurrent() || this.state.download?.id !== id) + return + const path = result === 'completed' ? item.getSavePath() : '' + this.state.download = { + id, + fileName: readDownloadFileName(item), + path: path || null, + state: result === 'completed' ? 'completed' : result === 'cancelled' ? 'canceled' : 'failed', + } + this.publish() + }) + } + + /** Surfaces at most one notice per blocked action description inside the dedupe window. */ + #reportBlocked(action: DesktopBrowserBlockedAction, message: string, detail: DesktopBrowserError['detail'] = 'fresh-click'): void { + const now = Date.now() + if (this.#lastBlockedNotice + && this.#lastBlockedNotice.action === action + && this.#lastBlockedNotice.message === message + && now - this.#lastBlockedNotice.at < BLOCKED_NOTICE_DEDUPE_MS) { + return + } + this.#lastBlockedNotice = { action, at: now, message } + this.state.error = { action, code: 'BROWSER_PERMISSION_DENIED', message, detail, noticeId: `${this.state.sessionId}:notice:${++this.#noticeSequence}`, origin: browserNoticeOrigin(this.state.url) } + this.publish() + } + #onHumanInput(): void { this.markActive() this.#options.onHumanInput() @@ -230,8 +530,11 @@ export class BrowserPageSession { void this.setZoomFactor(factor).catch(() => {}) return } - if (input.type === 'keyDown' || input.type === 'rawKeyDown') + if (input.type === 'keyDown' || input.type === 'rawKeyDown') { this.#onHumanInput() + if (['Enter', ' '].includes(input.key) && !input.isAutoRepeat) + this.#rememberPopupGesture(Boolean(input.control || input.meta)) + } }, ) this.#listen( @@ -245,10 +548,19 @@ export class BrowserPageSession { && ['contextMenu', 'mouseDown', 'mouseWheel'].includes(input.type) ) { this.#onHumanInput() + if (input.type === 'mouseDown') + this.#rememberPopupGesture(input.modifiers?.includes('control') === true || input.modifiers?.includes('meta') === true || input.button === 'middle') } }, ) this.#listen(page, 'did-start-loading', () => { + // An attachment navigation starts loading before Electron emits will-download. Keep only + // this one transition's gesture for downloads; commit, further navigation or control changes invalidate it. + const gesture = this.#humanGesture + this.invalidateElementReferences() + if (gesture) + this.#navigationDownloadGesture = { gesture, revision: this.documentVersion } + this.state.favicon = null const isIndependentNavigation = this.activeNavigationSequence === null if (isIndependentNavigation) { this.navigationSequence += 1 @@ -278,6 +590,10 @@ export class BrowserPageSession { this.refreshPageState() this.publish() }) + this.#listen(page, 'page-favicon-updated', (_event, favicons: string[]) => { + this.state.favicon = favicons.map(safeBrowserFavicon).find(Boolean) ?? null + this.publish() + }) this.#listen(page, 'page-title-updated', (_event, title: string) => { this.state.title = title.slice(0, 512) this.publish() @@ -286,6 +602,7 @@ export class BrowserPageSession { const normalizedUrl = normalizeBrowserUrl(url) if (!normalizedUrl) return + this.#navigationDownloadGesture = null this.mainFrameCommitSequence = this.navigationSequence this.refreshPageState() this.state.url = normalizedUrl @@ -458,6 +775,10 @@ export class BrowserPageSession { } releasePage(): void { + this.cancelDownloads() + this.state.viewport = null + this.state.favicon = null + this.invalidateElementReferences() this.#activity?.dispose() this.#activity = null try { @@ -497,6 +818,14 @@ export class BrowserPageSession { } } +function safeBrowserFavicon(rawUrl: string): string | null { + try { + const url = new URL(rawUrl) + return ['http:', 'https:'].includes(url.protocol) && !url.username && !url.password && rawUrl.length <= 4_096 ? url.toString() : null + } + catch { return null } +} + function normalizeBrowserUrl(rawUrl: string): string | null { try { const url = new URL(rawUrl) @@ -517,6 +846,22 @@ export function snapshot(state: BrowserSessionState): DesktopBrowserState { } } +/** Download names come from the webpage, so they are only used as display text. */ +function readDownloadFileName(item: BrowserDownloadItem): string { + const raw = item.getFilename() + const name = typeof raw === 'string' ? sanitizeDownloadName(raw) : '' + return name || 'download' +} + +function sanitizeDownloadName(value: string): string { + let name = '' + for (const character of value.trim().slice(0, 200)) { + const code = character.codePointAt(0) ?? 0 + name += code < 32 || code === 127 || character === '/' || character === '\\' ? '_' : character + } + return name.trim() +} + function projectSecurityState( rawUrl: string, errorCode: BrowserErrorCode | undefined, diff --git a/apps/buddy/electron/main/browser/BrowserSecurityPolicy.ts b/apps/buddy/electron/main/browser/BrowserSecurityPolicy.ts index 8efbf6ba..4eb3e4c7 100644 --- a/apps/buddy/electron/main/browser/BrowserSecurityPolicy.ts +++ b/apps/buddy/electron/main/browser/BrowserSecurityPolicy.ts @@ -7,6 +7,9 @@ import { containsCanonicalPath } from '../../../platform/filesystem/filePaths' import { resolveFilePath } from '../../../platform/filesystem/resolveFilePath' import { BrowserDebugger } from './BrowserDebugger' +const CDP_FILE_CHOOSER_OPENED = 'Page.fileChooserOpened' +const MAX_FILE_CHOOSER_PATH_LENGTH = 4_096 + export interface BrowserSecurityPage { id: number debugger: { @@ -17,10 +20,13 @@ export interface BrowserSecurityPage { method: string, commandParams?: Record, ) => Promise + off?: (event: 'message', listener: (event: unknown, method: string, params: unknown) => void) => unknown + on?: (event: 'message', listener: (event: unknown, method: string, params: unknown) => void) => unknown } getURL: () => string loadURL: (url: string) => Promise - setWindowOpenHandler: (handler: () => { action: 'deny' }) => void + openDevTools: (options: { mode: 'detach' }) => void + setWindowOpenHandler: (handler: (details: { url: string, disposition: string, postBody?: unknown }) => { action: 'deny' }) => void on: (event: 'certificate-error', listener: CertificateErrorListener) => unknown off: (event: 'certificate-error', listener: CertificateErrorListener) => unknown } @@ -47,10 +53,16 @@ export interface BrowserSecuritySession { } interface BrowserSecurityPolicyOptions { + allowDownload?: () => boolean connection?: BrowserDebugger onCertificateError?: (details: BrowserCertificateErrorDetails) => void + onDownloadBlocked?: () => void + onDownloadStarted?: (item: BrowserDownloadItem) => void + onFileChooser?: (request: BrowserFileChooserRequest) => void onPermissionDenied?: () => void onRequestBlocked?: (details: BrowserRequestDetails) => void + onWindowOpenBlocked?: () => void + onWindowOpen?: (url: string, disposition: string) => void page: BrowserSecurityPage session: BrowserSecuritySession } @@ -66,13 +78,33 @@ interface BrowserCertificateErrorDetails { url: string } +/** Minimal structural view of an Electron download item, kept small for test doubles. */ +export interface BrowserDownloadItem { + on?: (event: 'updated', listener: (event: unknown, state: 'interrupted' | 'progressing') => void) => unknown + off?: (event: 'updated', listener: (event: unknown, state: 'interrupted' | 'progressing') => void) => unknown + cancel: () => void + getFilename: () => string + getSavePath: () => string + once: ( + event: 'done', + listener: (event: unknown, state: 'cancelled' | 'completed' | 'interrupted') => void, + ) => unknown +} + +/** A file chooser request intercepted from the managed page, before any native dialog. */ +export interface BrowserFileChooserRequest { + backendNodeId: number + frameId: string + mode: 'selectMultiple' | 'selectSingle' +} + interface DownloadEvent { preventDefault: () => void } type DownloadListener = ( event: DownloadEvent, - item: unknown, + item: BrowserDownloadItem, webContents: unknown, ) => void @@ -91,7 +123,10 @@ type BeforeRequestListener = ( ) => void interface BrowserSecurityRoute { + allowDownload: () => boolean isRequestAllowed: (details: BrowserRequestDetails) => Promise + onDownloadBlocked: () => void + onDownloadStarted: (item: BrowserDownloadItem) => void onPermissionDenied: () => void onRequestBlocked: (details: BrowserRequestDetails) => void } @@ -124,7 +159,16 @@ class BrowserSecuritySessionCoordinator { callback({ cancel: true }) }) } - this.#downloadListener = event => event.preventDefault() + this.#downloadListener = (event, item, webContents) => { + const route = this.#routes.get(readWebContentsId(webContents)) + if (!route || !route.allowDownload()) { + // Human downloads keep Electron's own save dialog; everything else stays blocked. + event.preventDefault() + route?.onDownloadBlocked() + return + } + route.onDownloadStarted(item) + } this.#session.setPermissionCheckHandler(() => false) this.#session.setPermissionRequestHandler((webContents, _permission, callback) => { callback(false) @@ -181,6 +225,28 @@ function readWebContentsId(value: unknown): number { return -1 } +function parseFileChooserRequest(params: unknown): BrowserFileChooserRequest | null { + if (typeof params !== 'object' || params === null) + return null + const { backendNodeId, frameId, mode } = params as Record + if (typeof backendNodeId !== 'number' || !Number.isSafeInteger(backendNodeId) || backendNodeId <= 0) + return null + if (typeof frameId !== 'string' || !frameId || frameId.length > 128) + return null + return { + backendNodeId, + frameId, + mode: mode === 'selectMultiple' ? 'selectMultiple' : 'selectSingle', + } +} + +export function isValidSelectedFilePath(value: unknown): value is string { + return typeof value === 'string' + && value.length > 0 + && value.length <= MAX_FILE_CHOOSER_PATH_LENGTH + && !value.includes('\u0000') +} + export class BrowserSecurityPolicyError extends Error { readonly code: DesktopBrowserErrorCode = 'BROWSER_NAVIGATION_BLOCKED' readonly reason: BrowserFailureReason @@ -193,21 +259,30 @@ export class BrowserSecurityPolicyError extends Error { } export class BrowserSecurityPolicy { + readonly #allowDownload: () => boolean readonly #certificateErrorListener: CertificateErrorListener readonly #onCertificateError: (details: BrowserCertificateErrorDetails) => void + readonly #onDownloadBlocked: () => void + readonly #onDownloadStarted: (item: BrowserDownloadItem) => void + readonly #onFileChooser: (request: BrowserFileChooserRequest) => void readonly #onPermissionDenied: () => void readonly #onRequestBlocked: (details: BrowserRequestDetails) => void readonly #page: BrowserSecurityPage readonly #releaseSessionPolicy: () => void readonly #session: BrowserSecuritySession #disposed = false + #fileChooserSubscription: (() => void) | null = null #fileChooserGuardPromise: Promise | null = null #localFileRoot: string | null = null readonly #connection: BrowserDebugger readonly #ownsConnection: boolean constructor(options: BrowserSecurityPolicyOptions) { + this.#allowDownload = options.allowDownload ?? (() => false) this.#onCertificateError = options.onCertificateError ?? (() => {}) + this.#onDownloadBlocked = options.onDownloadBlocked ?? (() => {}) + this.#onDownloadStarted = options.onDownloadStarted ?? (() => {}) + this.#onFileChooser = options.onFileChooser ?? (() => {}) this.#onPermissionDenied = options.onPermissionDenied ?? (() => {}) this.#onRequestBlocked = options.onRequestBlocked ?? (() => {}) this.#page = options.page @@ -218,7 +293,10 @@ export class BrowserSecurityPolicy { this.#session, this.#page.id, { + allowDownload: () => !this.#disposed && this.#allowDownload(), isRequestAllowed: details => this.#isRequestAllowed(details), + onDownloadBlocked: () => this.#onDownloadBlocked(), + onDownloadStarted: item => this.#onDownloadStarted(item), onPermissionDenied: () => this.#onPermissionDenied(), onRequestBlocked: details => this.#onRequestBlocked(details), }, @@ -229,7 +307,15 @@ export class BrowserSecurityPolicy { this.#onCertificateError({ error: error.slice(0, 1_024), url }) } this.#page.on('certificate-error', this.#certificateErrorListener) - this.#page.setWindowOpenHandler(() => ({ action: 'deny' })) + this.#page.setWindowOpenHandler((details) => { + const url = parseBrowserUrl(details.url) + if (!this.#disposed && url && !url.username && !url.password && details.url.length <= 4_096 && !details.postBody) + options.onWindowOpen?.(url.toString(), details.disposition) + else if (!this.#disposed) + (options.onWindowOpenBlocked ?? this.#onPermissionDenied)() + // Never let Electron create an unmanaged native window. + return { action: 'deny' } + }) } async authorizeNavigation(rawUrl: string): Promise { @@ -283,6 +369,8 @@ export class BrowserSecurityPolicy { if (this.#disposed) return this.#disposed = true + this.#fileChooserSubscription?.() + this.#fileChooserSubscription = null this.#releaseSessionPolicy() this.#page.off('certificate-error', this.#certificateErrorListener) if (this.#ownsConnection) @@ -318,10 +406,47 @@ export class BrowserSecurityPolicy { await this.#connection.sendCommand('Page.enable') await this.#connection.sendCommand( 'Page.setInterceptFileChooserDialog', - { cancel: true, enabled: true }, + { enabled: true }, + ) + this.#fileChooserSubscription ??= this.#connection.onEvent( + CDP_FILE_CHOOSER_OPENED, + (params) => { + if (this.#disposed) + return + const request = parseFileChooserRequest(params) + if (request) + this.#onFileChooser(request) + }, ) } + async fileChooserAccept(request: BrowserFileChooserRequest): Promise { + this.#assertActive() + const result = await this.#connection.sendCommand('DOM.describeNode', { backendNodeId: request.backendNodeId }) as { node?: { nodeName?: string, attributes?: string[] } } + if (result.node?.nodeName !== 'INPUT') + throw new Error('File chooser target is not an input') + const attributes = result.node.attributes ?? [] + for (let index = 0; index < attributes.length; index += 2) { + if (attributes[index] === 'accept') + return attributes[index + 1]?.slice(0, 2_048) ?? '' + } + return '' + } + + /** Delivers user-selected files to the intercepted chooser of the current page document. */ + async deliverFileSelection(request: BrowserFileChooserRequest, files: string[]): Promise { + this.#assertActive() + if (!files.length) + return + if (!files.every(isValidSelectedFilePath)) + throw new Error('Invalid selected file path') + this.#connection.ensureAttached() + await this.#connection.sendCommand('DOM.setFileInputFiles', { + backendNodeId: request.backendNodeId, + files, + }) + } + async #isRequestAllowed(details: BrowserRequestDetails): Promise { if (this.#disposed) return false diff --git a/apps/buddy/electron/main/browser/__tests__/BrowserHost.browsingCompletion.spec.ts b/apps/buddy/electron/main/browser/__tests__/BrowserHost.browsingCompletion.spec.ts new file mode 100644 index 00000000..c9ee73af --- /dev/null +++ b/apps/buddy/electron/main/browser/__tests__/BrowserHost.browsingCompletion.spec.ts @@ -0,0 +1,181 @@ +import { EventEmitter } from 'node:events' +import { describe, expect, it, vi } from 'vitest' +import { browserNoticeOrigin, fileChooserFilters } from '../browserHumanActions' +import { configureSemanticObservation, createFixture } from './browserHostFixture' + +async function setup(options: Parameters[0] = {}) { + const fixture = createFixture(options) + const state = fixture.host.ensureSession('conversation', 'manual') + fixture.host.setSurface({ sessionId: state.sessionId, visible: true }) + configureSemanticObservation(fixture.webContents) + await fixture.host.navigate(state.sessionId, 'https://example.com/upload?secret=hidden') + return { ...fixture, state } +} +function click(f: Awaited>) { + f.webContents.emit('before-mouse-event', {}, { type: 'mouseDown', button: 'left', clickCount: 1, x: 10, y: 10 }) +} +function chooser(f: Awaited>) { + f.webContents.debuggerEvents.emit('message', {}, 'Page.fileChooserOpened', { backendNodeId: 42, frameId: 'main-frame', mode: 'selectSingle' }) +} +function dialog(f: Awaited>, overrides = {}) { + f.webContents.debuggerEvents.emit('message', {}, 'Page.javascriptDialogOpening', { type: 'confirm', message: 'Proceed?', url: 'https://frame.example.com/dialog?token=hidden', hasBrowserHandler: false, ...overrides }) +} + +describe('browser browsing completion safety', () => { + it('requires a fresh human gesture for upload even while human-controlled', async () => { + const selectFiles = vi.fn(async () => ['C:\\file.txt']) + const f = await setup({ selectFiles }) + chooser(f) + expect(selectFiles).not.toHaveBeenCalled() + expect(f.host.getState(f.state.sessionId).error).toMatchObject({ action: 'file-chooser', detail: 'fresh-click', origin: 'https://example.com' }) + f.host.dispose() + }) + + it('does not deliver a file after control changed and was returned to the human', async () => { + let finish!: (files: string[] | null) => void + const selectFiles = vi.fn(() => new Promise((resolve) => { + finish = resolve + })) + const f = await setup({ selectFiles }) + click(f) + chooser(f) + await vi.waitFor(() => expect(selectFiles).toHaveBeenCalledOnce()) + const lease = f.host.acquireControl({ sessionId: f.state.sessionId, pageId: f.state.pageId }) + f.host.releaseControl(lease) + finish(['C:\\file.txt']) + await vi.waitFor(() => expect(f.host.getState(f.state.sessionId).error?.detail).toBe('target-changed')) + expect(f.webContents.debugger.sendCommand).not.toHaveBeenCalledWith('DOM.setFileInputFiles', expect.anything()) + f.host.dispose() + }) + + it('passes accept filters to the native chooser without widening file access', async () => { + const selectFiles = vi.fn(async () => null) + const f = await setup({ selectFiles }) + const send = f.webContents.debugger.sendCommand.getMockImplementation()! + f.webContents.debugger.sendCommand.mockImplementation((method, params) => method === 'DOM.describeNode' + ? Promise.resolve({ node: { nodeName: 'INPUT', attributes: ['type', 'file', 'accept', '.pdf,image/png'] } }) + : send(method, params)) + click(f) + chooser(f) + await vi.waitFor(() => expect(selectFiles).toHaveBeenCalledWith({ multiple: false, filters: [{ name: '.pdf,image/png', extensions: ['pdf', 'png'] }, { name: '*', extensions: ['*'] }] })) + f.host.dispose() + }) + + it('gives repeated interceptions new identities after the dedupe window', async () => { + const f = await setup() + chooser(f) + const first = f.host.getState(f.state.sessionId).error!.noticeId + const now = vi.spyOn(Date, 'now').mockReturnValue(Date.now() + 3_001) + chooser(f) + expect(f.host.getState(f.state.sessionId).error!.noticeId).not.toBe(first) + now.mockRestore() + f.host.dispose() + }) + + it('cancels all active downloads when the session closes without deleting files', async () => { + const f = await setup() + const events = new EventEmitter() + const item = { getFilename: () => 'file.txt', getSavePath: () => 'C:\\file.txt', once: events.once.bind(events), cancel: vi.fn(() => events.emit('done', {}, 'cancelled')) } + click(f) + f.webContents.session.emit('will-download', { preventDefault: vi.fn() }, item, { id: f.webContents.id }) + expect(f.host.hasActiveDownloads(f.state.sessionId)).toBe(true) + f.host.close(f.state.sessionId) + expect(item.cancel).toHaveBeenCalledOnce() + f.host.dispose() + }) + + it('reports interrupted download updates before done and removes the update listener on completion', async () => { + const f = await setup() + const events = new EventEmitter() + const item = { getFilename: () => 'file.txt', getSavePath: () => 'C:\\file.txt', on: events.on.bind(events), off: events.off.bind(events), once: events.once.bind(events), cancel: vi.fn() } + click(f) + f.webContents.session.emit('will-download', { preventDefault: vi.fn() }, item, { id: f.webContents.id }) + events.emit('updated', {}, 'interrupted') + expect(f.host.getState(f.state.sessionId).download?.state).toBe('failed') + expect(f.host.hasActiveDownloads(f.state.sessionId)).toBe(true) + events.emit('updated', {}, 'progressing') + expect(f.host.getState(f.state.sessionId).download?.state).toBe('started') + events.emit('done', {}, 'completed') + expect(f.host.getState(f.state.sessionId).download?.state).toBe('completed') + expect(f.host.hasActiveDownloads(f.state.sessionId)).toBe(false) + expect(events.listenerCount('updated')).toBe(0) + f.host.dispose() + }) + + it('sets and clears independent viewport metrics without navigating or changing page zoom', async () => { + const f = await setup() + const viewport = { width: 390, height: 844, scale: 0.5 } + expect((await f.host.setViewport(f.state.sessionId, viewport)).viewport).toEqual(viewport) + expect(f.webContents.debugger.sendCommand).toHaveBeenCalledWith('Emulation.setDeviceMetricsOverride', { width: viewport.width, height: viewport.height, mobile: false, deviceScaleFactor: 0, dontSetVisibleSize: true }) + expect(f.webContents.getZoomFactor()).toBe(1) + expect((await f.host.setViewport(f.state.sessionId, null)).viewport).toBeNull() + expect(f.webContents.debugger.sendCommand).toHaveBeenCalledWith('Emulation.clearDeviceMetricsOverride', undefined) + expect(f.webContents.loadURL).toHaveBeenCalledTimes(1) + f.host.dispose() + }) + + it('rejects viewport changes while the agent owns the page and invalid dimensions', async () => { + const f = await setup() + await expect(f.host.setViewport(f.state.sessionId, { width: 1, height: 844, scale: 1 })).rejects.toThrow() + f.host.acquireControl({ sessionId: f.state.sessionId, pageId: f.state.pageId }) + await expect(f.host.setViewport(f.state.sessionId, { width: 390, height: 844, scale: 1 })).rejects.toMatchObject({ code: 'BROWSER_CONTROL_REQUIRED' }) + f.host.dispose() + }) + + it('syncs safe favicon URLs and clears stale icons on navigation', async () => { + const f = await setup() + f.webContents.emit('page-favicon-updated', {}, ['javascript:alert(1)', 'https://example.com/icon.png']) + expect(f.host.getState(f.state.sessionId).favicon).toBe('https://example.com/icon.png') + f.webContents.emit('did-start-loading') + expect(f.host.getState(f.state.sessionId).favicon).toBeNull() + f.host.dispose() + }) + + it('returns user cancellation for host confirm dialogs and displays only the trusted origin', async () => { + const showDialog = vi.fn(async () => false) + const f = await setup({ showDialog }) + dialog(f) + await vi.waitFor(() => expect(f.webContents.debugger.sendCommand).toHaveBeenCalledWith('Page.handleJavaScriptDialog', { accept: false })) + expect(showDialog).toHaveBeenCalledWith({ type: 'confirm', message: 'Proceed?', origin: 'https://frame.example.com' }) + f.host.dispose() + }) + + it('never confirms an agent dialog through the human channel', async () => { + const showDialog = vi.fn(async () => true) + const f = await setup({ showDialog }) + f.host.acquireControl({ sessionId: f.state.sessionId, pageId: f.state.pageId }) + dialog(f) + await vi.waitFor(() => expect(f.webContents.debugger.sendCommand).toHaveBeenCalledWith('Page.handleJavaScriptDialog', { accept: false })) + expect(showDialog).not.toHaveBeenCalled() + f.host.dispose() + }) + + it('does not duplicate existing native dialogs and suppresses dialog storms', async () => { + const showDialog = vi.fn(async () => true) + const f = await setup({ showDialog }) + dialog(f, { hasBrowserHandler: true }) + expect(showDialog).not.toHaveBeenCalled() + for (let index = 0; index < 3; index++) { + dialog(f) + await vi.waitFor(() => expect(showDialog).toHaveBeenCalledTimes(index + 1)) + await Promise.resolve() + } + dialog(f) + expect(showDialog).toHaveBeenCalledTimes(3) + expect(f.host.getState(f.state.sessionId).error?.detail).toBe('dialog-suppressed') + f.host.dispose() + }) +}) + +describe('browser human action helpers', () => { + it('never displays tokens, credentials or local paths in source labels', () => { + expect(browserNoticeOrigin('https://user:password@example.com/file?token=secret')).toBe('https://example.com') + expect(browserNoticeOrigin('file:///C:/private/account.html')).toBe('file://') + expect(browserNoticeOrigin('javascript:secret')).toBeUndefined() + }) + it('handles wildcards and deduplicates extensions; unknown MIME types remain compatible', () => { + expect(fileChooserFilters('image/*,.png')?.[0]?.extensions.filter(value => value === 'png')).toHaveLength(1) + expect(fileChooserFilters('application/x-custom')).toBeUndefined() + expect(fileChooserFilters('.exe/../../secret')).toBeUndefined() + }) +}) diff --git a/apps/buddy/electron/main/browser/__tests__/BrowserHost.humanActions.spec.ts b/apps/buddy/electron/main/browser/__tests__/BrowserHost.humanActions.spec.ts new file mode 100644 index 00000000..d0435f99 --- /dev/null +++ b/apps/buddy/electron/main/browser/__tests__/BrowserHost.humanActions.spec.ts @@ -0,0 +1,346 @@ +import { EventEmitter } from 'node:events' +import { describe, expect, it, vi } from 'vitest' +import { configureSemanticObservation, createFixture } from './browserHostFixture' + +type Fixture = ReturnType + +function openHandler(fixture: Fixture) { + const handler = fixture.webContents.setWindowOpenHandler.mock.calls.at(-1)?.[0] + expect(handler).toBeTypeOf('function') + return handler as (details: { disposition: string, postBody?: unknown, url: string }) => { action: string } +} + +type FakePage = Fixture['webContents'] + +function handlerFor(page: FakePage) { + const handler = page.setWindowOpenHandler.mock.calls.at(-1)?.[0] + expect(handler).toBeTypeOf('function') + return handler as (details: { disposition: string, postBody?: unknown, url: string }) => { action: string } +} + +function clickHumanOn(page: FakePage, modifiers: string[] = []) { + page.emit('before-mouse-event', {}, { + button: 'left', + clickCount: 1, + modifiers, + type: 'mouseDown', + x: 12, + y: 12, + }) +} + +function clickHuman(fixture: Fixture, modifiers: string[] = []) { + clickHumanOn(fixture.webContents, modifiers) +} + +function createDownloadItem(name = 'report.csv') { + const events = new EventEmitter() + return { + events, + item: { + cancel: vi.fn(() => events.emit('done', {}, 'cancelled')), + getFilename: () => name, + getSavePath: () => `C:\\Users\\tester\\Downloads\\${name}`, + once: (event: string, listener: (...args: unknown[]) => void) => { + events.once(event, listener) + }, + }, + } +} + +/** A visible human-controlled session, which is the state every human action is judged against. */ +function createHumanSession(fixture: Fixture) { + const session = fixture.host.ensureSession(null, 'manual') + fixture.host.setSurface({ sessionId: session.sessionId, visible: true }) + fixture.onStateChanged.mockClear() + return session +} + +async function navigateHumanSession(fixture: Fixture, url = 'https://example.com/') { + const session = createHumanSession(fixture) + configureSemanticObservation(fixture.webContents) + await fixture.host.navigate(session.sessionId, url) + fixture.onStateChanged.mockClear() + return session +} + +function emitFileChooser(fixture: Fixture, params: Record = {}, humanClick = true) { + if (humanClick) + clickHuman(fixture) + fixture.webContents.debuggerEvents.emit('message', {}, 'Page.fileChooserOpened', { + backendNodeId: 42, + frameId: 'main-frame', + mode: 'selectSingle', + ...params, + }) +} + +describe('browser human action routing', () => { + it('routes a clicked target=_blank link into a managed tab that inherits the source profile', async () => { + const fixture = createFixture() + const source = await navigateHumanSession(fixture) + clickHuman(fixture) + + expect(openHandler(fixture)({ disposition: 'foreground-tab', url: 'https://example.com/video' })).toEqual({ action: 'deny' }) + await vi.waitFor(() => { + expect(fixture.host.listGuests()).toHaveLength(2) + }) + const popup = fixture.host.listGuests().at(-1)! + expect(popup.partition).toBe('persist:buddy-browser-default-v1') + await vi.waitFor(() => { + expect(fixture.host.getState(popup.sessionId)).toMatchObject({ + openedFrom: { sessionId: source.sessionId, tabId: expect.any(String) }, + url: 'https://example.com/video', + }) + }) + expect(fixture.createPage).toHaveBeenCalledTimes(2) + fixture.host.dispose() + }) + + it('blocks a popup that arrives without a fresh click and never opens a native window', () => { + const fixture = createFixture() + createHumanSession(fixture) + + expect(openHandler(fixture)({ disposition: 'foreground-tab', url: 'https://example.com/popup' })).toEqual({ action: 'deny' }) + expect(fixture.createPage).toHaveBeenCalledOnce() + expect(fixture.onStateChanged.mock.calls.at(-1)?.[0]).toMatchObject({ + error: { action: 'popup', code: 'BROWSER_PERMISSION_DENIED' }, + }) + + // A second identical interception inside the dedupe window must not republish the notice. + fixture.onStateChanged.mockClear() + openHandler(fixture)({ disposition: 'foreground-tab', url: 'https://example.com/popup' }) + expect(fixture.onStateChanged).not.toHaveBeenCalled() + fixture.host.dispose() + }) + + it('rejects popup targets that carry credentials, a body or a non-web scheme', () => { + const fixture = createFixture() + createHumanSession(fixture) + const handler = openHandler(fixture) + clickHuman(fixture) + + expect(handler({ disposition: 'foreground-tab', url: 'https://user:secret@example.com/private' })).toEqual({ action: 'deny' }) + expect(handler({ disposition: 'foreground-tab', postBody: {}, url: 'https://example.com/post' })).toEqual({ action: 'deny' }) + expect(handler({ disposition: 'foreground-tab', url: 'javascript:alert(1)' })).toEqual({ action: 'deny' }) + expect(fixture.createPage).toHaveBeenCalledOnce() + fixture.host.dispose() + }) + + it('hands modifier clicks to the system browser instead of opening an internal tab', async () => { + const openExternal = vi.fn(async () => {}) + const fixture = createFixture({ openExternal }) + createHumanSession(fixture) + clickHuman(fixture, ['control']) + + expect(openHandler(fixture)({ disposition: 'foreground-tab', url: 'https://example.com/external' })).toEqual({ action: 'deny' }) + await vi.waitFor(() => expect(openExternal).toHaveBeenCalledExactlyOnceWith('https://example.com/external')) + expect(fixture.createPage).toHaveBeenCalledOnce() + fixture.host.dispose() + }) + + it('never copies private browsing into the system browser', async () => { + const openExternal = vi.fn(async () => {}) + const fixture = createFixture({ openExternal }) + const source = await navigateHumanSession(fixture) + const incognito = await fixture.host.setProfileMode(source.sessionId, 'incognito') + fixture.host.setSurface({ sessionId: incognito.sessionId, visible: true }) + const incognitoPage = fixture.webContentsInstances.at(-1)! + clickHumanOn(incognitoPage) + + expect(handlerFor(incognitoPage)({ disposition: 'background-tab', url: 'https://example.com/external' })).toEqual({ action: 'deny' }) + await vi.waitFor(() => { + expect(fixture.host.getState(incognito.sessionId).error).toMatchObject({ code: 'BROWSER_NAVIGATION_BLOCKED' }) + }) + expect(openExternal).not.toHaveBeenCalled() + fixture.host.dispose() + }) + + it('blocks a modifier click when the system browser is unavailable', async () => { + const fixture = createFixture() + const session = createHumanSession(fixture) + clickHuman(fixture, ['control']) + + expect(openHandler(fixture)({ disposition: 'foreground-tab', url: 'https://example.com/external' })).toEqual({ action: 'deny' }) + await vi.waitFor(() => { + expect(fixture.host.getState(session.sessionId).error).toMatchObject({ code: 'BROWSER_NAVIGATION_BLOCKED' }) + }) + fixture.host.dispose() + }) + + it('delivers human file selections to the intercepted chooser', async () => { + const selectFiles = vi.fn(async () => ['C:\\Users\\tester\\Pictures\\avatar.png']) + const fixture = createFixture({ selectFiles }) + const session = await navigateHumanSession(fixture) + emitFileChooser(fixture, { mode: 'selectMultiple' }) + + await vi.waitFor(() => expect(selectFiles).toHaveBeenCalledExactlyOnceWith({ multiple: true })) + await vi.waitFor(() => expect(fixture.webContents.debugger.sendCommand).toHaveBeenCalledWith('DOM.setFileInputFiles', { + backendNodeId: 42, + files: ['C:\\Users\\tester\\Pictures\\avatar.png'], + })) + expect(fixture.host.getState(session.sessionId).error).toBeNull() + fixture.host.dispose() + }) + + it('treats a cancelled file selection as a no-op', async () => { + const selectFiles = vi.fn(async () => null) + const fixture = createFixture({ selectFiles }) + const session = await navigateHumanSession(fixture) + emitFileChooser(fixture) + + await vi.waitFor(() => expect(selectFiles).toHaveBeenCalledOnce()) + expect(fixture.webContents.debugger.sendCommand).not.toHaveBeenCalledWith('DOM.setFileInputFiles', expect.anything()) + expect(fixture.host.getState(session.sessionId).error).toBeNull() + fixture.host.dispose() + }) + + it('cancels a file selection when the page starts loading while the dialog is open', async () => { + let resolveSelection: (files: string[] | null) => void = () => {} + const selectFiles = vi.fn(() => new Promise((resolve) => { + resolveSelection = resolve + })) + const fixture = createFixture({ selectFiles }) + const session = await navigateHumanSession(fixture) + emitFileChooser(fixture) + await vi.waitFor(() => expect(selectFiles).toHaveBeenCalledOnce()) + + // The document the chooser belonged to is replaced before the user confirms. + fixture.webContents.emit('did-start-loading') + resolveSelection(['C:\\Users\\tester\\Pictures\\avatar.png']) + + await vi.waitFor(() => { + expect(fixture.host.getState(session.sessionId).error).toMatchObject({ action: 'file-chooser' }) + }) + expect(fixture.webContents.debugger.sendCommand).not.toHaveBeenCalledWith('DOM.setFileInputFiles', expect.anything()) + fixture.host.dispose() + }) + + it('blocks a file chooser requested while the agent controls the page', async () => { + const selectFiles = vi.fn(async () => ['C:\\Users\\tester\\secret.txt']) + const fixture = createFixture({ selectFiles }) + const session = fixture.host.ensureSession('conversation', 'manual') + fixture.host.setSurface({ sessionId: session.sessionId, visible: true }) + configureSemanticObservation(fixture.webContents) + await fixture.host.navigate(session.sessionId, 'https://example.com/') + const observation = await fixture.host.observe({ pageId: session.pageId, sessionId: session.sessionId }) + const lease = fixture.host.acquireControl({ pageId: session.pageId, sessionId: session.sessionId }) + const sendCommand = fixture.webContents.debugger.sendCommand.getMockImplementation()! + fixture.webContents.debugger.sendCommand.mockImplementation(async (method, params) => { + if (method === 'Input.dispatchMouseEvent') { + fixture.webContents.emit('before-mouse-event', {}, { + button: 'left', + clickCount: 1, + type: 'mouseDown', + x: 30, + y: 30, + }) + } + return sendCommand(method, params) + }) + await fixture.host.act({ + action: { kind: 'click', ref: 'e1' }, + controlEpoch: lease.controlEpoch, + documentRevision: observation.documentRevision, + frameId: 'main-frame', + observationId: observation.observationId, + pageId: session.pageId, + sessionId: session.sessionId, + }) + expect(fixture.host.getState(session.sessionId).controller).toBe('agent') + emitFileChooser(fixture, {}, false) + + await vi.waitFor(() => { + expect(fixture.host.getState(session.sessionId).error).toMatchObject({ action: 'file-chooser' }) + }) + expect(selectFiles).not.toHaveBeenCalled() + fixture.host.dispose() + }) + + it('opens developer tools for the current page only while a human controls it', async () => { + const fixture = createFixture() + const session = fixture.host.ensureSession('conversation', 'manual') + fixture.host.setSurface({ sessionId: session.sessionId, visible: true }) + configureSemanticObservation(fixture.webContents) + await fixture.host.navigate(session.sessionId, 'https://example.com/') + + expect(fixture.host.openDevTools(session.sessionId)).toBe(true) + expect(fixture.webContents.openDevTools).toHaveBeenCalledExactlyOnceWith({ mode: 'detach' }) + + const observation = await fixture.host.observe({ pageId: session.pageId, sessionId: session.sessionId }) + const lease = fixture.host.acquireControl({ pageId: session.pageId, sessionId: session.sessionId }) + const sendCommand = fixture.webContents.debugger.sendCommand.getMockImplementation()! + fixture.webContents.debugger.sendCommand.mockImplementation(async (method, params) => { + if (method === 'Input.dispatchMouseEvent') { + fixture.webContents.emit('before-mouse-event', {}, { + button: 'left', + clickCount: 1, + type: 'mouseDown', + x: 30, + y: 30, + }) + } + return sendCommand(method, params) + }) + await fixture.host.act({ + action: { kind: 'click', ref: 'e1' }, + controlEpoch: lease.controlEpoch, + documentRevision: observation.documentRevision, + frameId: 'main-frame', + observationId: observation.observationId, + pageId: session.pageId, + sessionId: session.sessionId, + }) + expect(() => fixture.host.openDevTools(session.sessionId)) + .toThrowError(expect.objectContaining({ code: 'BROWSER_CONTROL_REQUIRED' })) + expect(fixture.webContents.openDevTools).toHaveBeenCalledOnce() + fixture.host.dispose() + }) + + it('allows a human-started download and reports its lifecycle', () => { + const fixture = createFixture() + const session = createHumanSession(fixture) + clickHuman(fixture) + const event = { preventDefault: vi.fn() } + const { events, item } = createDownloadItem() + fixture.webContents.session.emit('will-download', event, item, { id: fixture.webContents.id }) + + expect(event.preventDefault).not.toHaveBeenCalled() + expect(fixture.host.getState(session.sessionId).download).toMatchObject({ fileName: 'report.csv', state: 'started' }) + events.emit('done', {}, 'completed') + expect(fixture.host.getState(session.sessionId).download).toMatchObject({ + fileName: 'report.csv', + path: 'C:\\Users\\tester\\Downloads\\report.csv', + state: 'completed', + }) + fixture.host.dispose() + }) + + it('cancels a download that has no fresh human click and explains why', () => { + const fixture = createFixture() + const session = createHumanSession(fixture) + const event = { preventDefault: vi.fn() } + const { item } = createDownloadItem() + fixture.webContents.session.emit('will-download', event, item, { id: fixture.webContents.id }) + + expect(event.preventDefault).toHaveBeenCalledOnce() + expect(fixture.host.getState(session.sessionId)).toMatchObject({ + error: { action: 'download', code: 'BROWSER_PERMISSION_DENIED' }, + }) + expect(fixture.host.getState(session.sessionId).download ?? null).toBeNull() + fixture.host.dispose() + }) + + it('ignores a download event for a page that this host does not own', () => { + const fixture = createFixture() + createHumanSession(fixture) + clickHuman(fixture) + const event = { preventDefault: vi.fn() } + const { item } = createDownloadItem() + // Another session in the same partition owns this request, so it must not be attributed here. + fixture.webContents.session.emit('will-download', event, item, { id: 999 }) + + expect(event.preventDefault).toHaveBeenCalledOnce() + fixture.host.dispose() + }) +}) diff --git a/apps/buddy/electron/main/browser/__tests__/BrowserHost.spec.ts b/apps/buddy/electron/main/browser/__tests__/BrowserHost.spec.ts index f4060861..3ac15ee2 100644 --- a/apps/buddy/electron/main/browser/__tests__/BrowserHost.spec.ts +++ b/apps/buddy/electron/main/browser/__tests__/BrowserHost.spec.ts @@ -88,7 +88,7 @@ describe('browserHost sessions and navigation', () => { expect(webPreferences).toMatchObject({ allowRunningInsecureContent: false, contextIsolation: true, - devTools: false, + devTools: true, nodeIntegration: false, sandbox: true, webSecurity: true, diff --git a/apps/buddy/electron/main/browser/__tests__/browserHostFixture.ts b/apps/buddy/electron/main/browser/__tests__/browserHostFixture.ts index 84c19204..f7f80207 100644 --- a/apps/buddy/electron/main/browser/__tests__/browserHostFixture.ts +++ b/apps/buddy/electron/main/browser/__tests__/browserHostFixture.ts @@ -7,7 +7,7 @@ import { createTemporaryDirectory } from '@buddy-tests/temporaryDirectories' import { vi } from 'vitest' import { BrowserHost } from '../BrowserHost' -export function createFixture(options: { operations?: BrowserOperationGuard, getDefaultZoomFactor?: () => number, getFreezeDelay?: (visible: boolean) => number | null } = {}) { +export function createFixture(options: { showDialog?: (input: { type: 'alert' | 'confirm', message: string, origin: string }) => Promise, operations?: BrowserOperationGuard, getDefaultZoomFactor?: () => number, getFreezeDelay?: (visible: boolean) => number | null, openExternal?: (url: string) => Promise, selectFiles?: (input: { multiple: boolean }) => Promise } = {}) { const ids = Array.from({ length: 16 }, (_, index) => ( `00000000-0000-4000-8000-${String(index + 1).padStart(12, '0')}` )) @@ -93,6 +93,7 @@ class FakeWebContents extends EventEmitter { toPNG: () => Uint8Array.from([137, 80, 78, 71, 13, 10, 26, 10]), })) + readonly debuggerEvents = new EventEmitter() readonly debugger = { attach: vi.fn(() => { this.debuggerAttached = true @@ -101,6 +102,12 @@ class FakeWebContents extends EventEmitter { this.debuggerAttached = false }), isAttached: vi.fn(() => this.debuggerAttached), + off: (event: string, listener: (...args: unknown[]) => void) => { + this.debuggerEvents.off(event, listener) + }, + on: (event: string, listener: (...args: unknown[]) => void) => { + this.debuggerEvents.on(event, listener) + }, sendCommand: vi.fn<( method: string, commandParams?: Record, @@ -112,6 +119,7 @@ class FakeWebContents extends EventEmitter { } readonly focus = vi.fn() + readonly openDevTools = vi.fn() readonly loadURL = vi.fn(async (url: string) => { this.currentUrl = url }) @@ -151,6 +159,8 @@ class FakeWebContents extends EventEmitter { export function configureSemanticObservation(webContents: FakeWebContents): void { webContents.debugger.sendCommand.mockImplementation(async (method) => { + if (method === 'DOM.describeNode') + return { node: { nodeName: 'INPUT', attributes: ['type', 'file'] } } if (method === 'Page.getFrameTree') { return { frameTree: { diff --git a/apps/buddy/electron/main/browser/__tests__/registerBrowserDesktopIpc.spec.ts b/apps/buddy/electron/main/browser/__tests__/registerBrowserDesktopIpc.spec.ts index d374f67e..4575e075 100644 --- a/apps/buddy/electron/main/browser/__tests__/registerBrowserDesktopIpc.spec.ts +++ b/apps/buddy/electron/main/browser/__tests__/registerBrowserDesktopIpc.spec.ts @@ -1,6 +1,7 @@ -import type { BrowserWindow, IpcMainInvokeEvent } from 'electron' +import type { BrowserWindow, IpcMainEvent, IpcMainInvokeEvent } from 'electron' import type { BrowserHost } from '../BrowserHost' import { beforeEach, describe, expect, it, vi } from 'vitest' +import { BROWSER_PAGE_DIALOG_CHANNEL } from '../../../../shared/browser/browserDialogs' import { DEFAULT_BROWSER_PREFERENCES } from '../../../../shared/browser/browserPreferences' import { DESKTOP_IPC_CHANNELS } from '../../../shared/desktopApi' import { BrowserScreenshotService } from '../BrowserScreenshotService' @@ -10,6 +11,8 @@ const electron = vi.hoisted(() => ({ fromId: vi.fn(), fromPartition: vi.fn(), handlers: new Map unknown>(), + listeners: new Map void>(), + showMessageBoxSync: vi.fn(), openExternal: vi.fn(), openPath: vi.fn(), removeHandler: vi.fn((channel: string) => electron.handlers.delete(channel)), @@ -25,10 +28,13 @@ vi.mock('electron', () => ({ app: { getVersion: () => '0.1.0' }, clipboard: { writeText: vi.fn() }, dialog: { + showMessageBoxSync: electron.showMessageBoxSync, showOpenDialog: electron.showOpenDialog, showSaveDialog: electron.showSaveDialog, }, ipcMain: { + on: vi.fn((channel, listener) => electron.listeners.set(channel, listener)), + removeListener: vi.fn(), handle: vi.fn((channel, handler) => electron.handlers.set(channel, handler)), removeHandler: electron.removeHandler, }, @@ -43,6 +49,8 @@ vi.mock('electron', () => ({ beforeEach(() => { electron.handlers.clear() + electron.listeners.clear() + electron.showMessageBoxSync.mockReset() electron.showOpenDialog.mockReset() electron.showSaveDialog.mockReset() electron.fromId.mockReset() @@ -54,6 +62,34 @@ beforeEach(() => { }) describe('registerBrowserDesktopIpc', () => { + it.each([1, 0])('replies only once, after the guest confirm choice %s', (response) => { + const mainFrame = {} + const desktop = {} + const window = { webContents: desktop } as BrowserWindow + const host = { handlePageDialog: vi.fn((_id, _request, show: (origin: string) => boolean) => show('https://example.com')) } as unknown as BrowserHost + registerBrowserDesktopIpc({ + data: { getSummary: async () => ({ cacheBytes: 0, cookieSiteCount: 0 }), clear: async () => ({ ok: true }) }, + screenshots: new BrowserScreenshotService(() => DEFAULT_BROWSER_PREFERENCES), + getHost: () => host, + getWindow: () => window, + resolveArtifactEntry: async () => { throw new Error('unused') }, + }) + const replies: unknown[] = [] + const event = { sender: { id: 17, mainFrame, hostWebContents: desktop, getType: () => 'webview', isDestroyed: () => false }, senderFrame: mainFrame } as unknown as IpcMainEvent + Object.defineProperty(event, 'returnValue', { get: () => replies.at(-1), set: value => replies.push(value) }) + electron.showMessageBoxSync.mockImplementation(() => { + expect(replies).toEqual([]) + return response + }) + const listener = electron.listeners.get(BROWSER_PAGE_DIALOG_CHANNEL)! + listener(event, { type: 'confirm', message: 'Fixture question' }) + expect(replies).toEqual([{ handled: true, value: response === 1 }]) + expect(electron.showMessageBoxSync).toHaveBeenCalledWith(window, expect.objectContaining({ message: 'https://example.com', cancelId: 0 })) + listener({ ...event, senderFrame: {} } as IpcMainEvent, { type: 'confirm', message: 'subframe' }) + listener(event, { type: 'confirm', message: 'spoof', origin: 'https://trusted.example' }) + expect(host.handlePageDialog).toHaveBeenCalledOnce() + }) + it('validates trusted Renderer requests before forwarding them to BrowserHost', async () => { const sessionId = 'd86be868-6a84-45da-90aa-ff61f3c88f85' const state = { @@ -75,6 +111,7 @@ describe('registerBrowserDesktopIpc', () => { } as const const getState = vi.fn().mockReturnValue(state) const host = { + hasActiveDownloads: vi.fn(() => false), close: vi.fn(), captureScreenshot: vi.fn().mockResolvedValue({ bytes: Uint8Array.from([137, 80, 78, 71]), @@ -89,6 +126,7 @@ describe('registerBrowserDesktopIpc', () => { goForward: vi.fn(), getState, navigate: vi.fn().mockResolvedValue({ ...state, url: 'https://example.com/' }), + openDevTools: vi.fn().mockReturnValue(true), reload: vi.fn(), setSurface: vi.fn(), stop: vi.fn(), @@ -327,6 +365,7 @@ describe('registerBrowserDesktopIpc', () => { title: state.title, }), getState, + openDevTools: vi.fn().mockReturnValue(true), } as unknown as BrowserHost const webContents = { mainFrame: {} } const window = { webContents } as unknown as BrowserWindow @@ -373,6 +412,11 @@ describe('registerBrowserDesktopIpc', () => { '/picked/space/site/index.html', ) + await expect(invoke(DESKTOP_IPC_CHANNELS.browserOpenDevTools, trustedEvent, { + sessionId, + })).resolves.toBe(true) + expect(host.openDevTools).toHaveBeenCalledExactlyOnceWith(sessionId) + getState.mockReturnValue({ ...state, security: { kind: 'secure', origin: 'https://example.com' }, @@ -386,6 +430,23 @@ describe('registerBrowserDesktopIpc', () => { sessionId, })).resolves.toBe(false) expect(electron.showItemInFolder).toHaveBeenCalledOnce() + + await expect(invoke(DESKTOP_IPC_CHANNELS.browserRevealDownload, trustedEvent, { + sessionId, + })).resolves.toBe(false) + + getState.mockReturnValue({ + ...state, + download: { + fileName: 'report.csv', + path: resolve('/downloads/report.csv'), + state: 'completed', + }, + }) + await expect(invoke(DESKTOP_IPC_CHANNELS.browserRevealDownload, trustedEvent, { + sessionId, + })).resolves.toBe(true) + expect(electron.showItemInFolder).toHaveBeenLastCalledWith(resolve('/downloads/report.csv')) }) it('resolves an HTML artifact with its local resource root', async () => { diff --git a/apps/buddy/electron/main/browser/browserHumanActions.ts b/apps/buddy/electron/main/browser/browserHumanActions.ts new file mode 100644 index 00000000..5e2d7318 --- /dev/null +++ b/apps/buddy/electron/main/browser/browserHumanActions.ts @@ -0,0 +1,47 @@ +export interface BrowserFileSelectionOptions { + multiple: boolean + filters?: Array<{ name: string, extensions: string[] }> +} + +const MIME_EXTENSIONS: Record = { + 'image/*': ['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp', 'svg', 'avif'], + 'audio/*': ['mp3', 'wav', 'ogg', 'm4a', 'flac', 'aac'], + 'video/*': ['mp4', 'webm', 'mov', 'mkv', 'avi'], + 'image/png': ['png'], + 'image/jpeg': ['jpg', 'jpeg'], + 'image/gif': ['gif'], + 'image/webp': ['webp'], + 'application/pdf': ['pdf'], + 'application/json': ['json'], + 'text/plain': ['txt'], + 'text/csv': ['csv'], +} + +/** Unknown MIME types remain selectable: accept is a chooser hint, not an upload validator. */ +export function fileChooserFilters(accept: string): BrowserFileSelectionOptions['filters'] { + const extensions = new Set() + for (const token of accept.toLowerCase().slice(0, 2_048).split(',')) { + const value = token.trim() + if (/^\.[a-z0-9]{1,16}$/.test(value)) { + extensions.add(value.slice(1)) + } + else { + for (const extension of MIME_EXTENSIONS[value] ?? []) + extensions.add(extension) + } + } + return extensions.size ? [{ name: accept.slice(0, 80), extensions: [...extensions] }, { name: '*', extensions: ['*'] }] : undefined +} + +/** Show only the origin; never disclose credentials, query tokens, or local paths. */ +export function browserNoticeOrigin(rawUrl: string): string | undefined { + try { + const url = new URL(rawUrl) + if (['http:', 'https:', 'blob:'].includes(url.protocol) && url.origin !== 'null') + return url.origin.slice(0, 512) + if (url.protocol === 'file:') + return 'file://' + } + catch {} + return undefined +} diff --git a/apps/buddy/electron/main/browser/registerBrowserDesktopIpc.ts b/apps/buddy/electron/main/browser/registerBrowserDesktopIpc.ts index 22f68295..cc2f9ed7 100644 --- a/apps/buddy/electron/main/browser/registerBrowserDesktopIpc.ts +++ b/apps/buddy/electron/main/browser/registerBrowserDesktopIpc.ts @@ -1,9 +1,9 @@ -import type { BrowserWindow, IpcMainInvokeEvent } from 'electron' +import type { BrowserWindow, IpcMainEvent, IpcMainInvokeEvent } from 'electron' import type { BrowserDataService } from './BrowserDataService' import type { BrowserHost } from './BrowserHost' import type { BrowserScreenshotService } from './BrowserScreenshotService' import { fileURLToPath } from 'node:url' -import { ipcMain, session, shell, webContents } from 'electron' +import { dialog, ipcMain, session, shell, webContents } from 'electron' import { browserClearDataInputSchema, browserClearDataResultSchema, browserDataSummarySchema } from '../../../shared/browser/browserData' import { browserAttachGuestInputSchema, @@ -13,14 +13,17 @@ import { browserSessionInputSchema, browserSetProfileModeInputSchema, browserSetSurfaceInputSchema, + browserSetViewportInputSchema, browserSetZoomFactorInputSchema, desktopBrowserGuestDescriptorsSchema, desktopBrowserStateSchema, } from '../../../shared/browser/browserDesktopSchemas' +import { BROWSER_PAGE_DIALOG_CHANNEL } from '../../../shared/browser/browserDialogs' import { DESKTOP_IPC_CHANNELS } from '../../shared/desktopApi' import { assertTrustedSender } from '../ipc' export interface RegisterBrowserDesktopIpcOptions { + getLanguage?: () => string data: Pick screenshots: Pick getHost: () => BrowserHost | null @@ -34,6 +37,48 @@ export interface RegisterBrowserDesktopIpcOptions { export function registerBrowserDesktopIpc( options: RegisterBrowserDesktopIpcOptions, ): () => void { + const handlePageDialog = (event: IpcMainEvent, input: unknown): boolean => { + // Guest-only entry: page payloads cannot choose a tab, origin, file or host command. + const window = options.getWindow() + const sender = event.sender + if (!window || sender.isDestroyed() || sender.getType() !== 'webview' + || sender.hostWebContents !== window.webContents || event.senderFrame !== sender.mainFrame) { + return false + } + if (!input || typeof input !== 'object' || Object.keys(input).length !== 2) + return false + const request = input as { type?: unknown, message?: unknown } + if ((request.type !== 'alert' && request.type !== 'confirm') || typeof request.message !== 'string' || request.message.length > 2_048) + return false + const host = options.getHost() + if (!host) + return false + return host.handlePageDialog(sender.id, { type: request.type, message: request.message }, (origin) => { + const chinese = options.getLanguage?.() === 'zh-CN' + const response = dialog.showMessageBoxSync(window, { + type: request.type === 'confirm' ? 'question' : 'info', + title: chinese ? 'Lexora — 网页对话框' : 'Lexora — Web page', + message: origin, + detail: request.message as string, + buttons: request.type === 'confirm' ? (chinese ? ['取消', '确定'] : ['Cancel', 'OK']) : [chinese ? '确定' : 'OK'], + defaultId: 0, + cancelId: 0, + noLink: true, + }) + return request.type === 'alert' || response === 1 + }) + } + const pageDialogListener = (event: IpcMainEvent, input: unknown): void => { + let value = false + try { + value = handlePageDialog(event, input) + } + catch {} + // Electron's setter sends the synchronous reply immediately. Reply exactly once, after + // the user choice, never with a provisional default before opening the dialog. + event.returnValue = { handled: true, value } + } + ipcMain.on(BROWSER_PAGE_DIALOG_CHANNEL, pageDialogListener) const registeredChannels: string[] = [] const handle = ( channel: string, @@ -73,6 +118,10 @@ export function registerBrowserDesktopIpc( handle(DESKTOP_IPC_CHANNELS.browserClearData, async (_host, input) => ( browserClearDataResultSchema.parse(await options.data.clear(browserClearDataInputSchema.parse(input))) )) + handle(DESKTOP_IPC_CHANNELS.browserSetViewport, async (host, input) => { + const { sessionId, viewport } = browserSetViewportInputSchema.parse(input) + return desktopBrowserStateSchema.parse(await host.setViewport(sessionId, viewport)) + }) handle(DESKTOP_IPC_CHANNELS.browserSetZoomFactor, async (host, input) => { const { sessionId, zoomFactor } = browserSetZoomFactorInputSchema.parse(input) takeHumanControl(host, sessionId) @@ -109,6 +158,10 @@ export function registerBrowserDesktopIpc( takeHumanControl(host, sessionId) return desktopBrowserStateSchema.parse(await host.openLocalFile(sessionId, entry)) }) + handle(DESKTOP_IPC_CHANNELS.browserOpenDevTools, (host, input) => { + const { sessionId } = browserSessionInputSchema.parse(input) + return host.openDevTools(sessionId) + }) handle(DESKTOP_IPC_CHANNELS.browserOpenExternal, async (host, input) => { const { sessionId } = browserSessionInputSchema.parse(input) const { url } = host.getState(sessionId) @@ -149,16 +202,42 @@ export function registerBrowserDesktopIpc( shell.showItemInFolder(fileURLToPath(url)) return true }) + handle(DESKTOP_IPC_CHANNELS.browserRevealDownload, (host, input) => { + const { sessionId } = browserSessionInputSchema.parse(input) + const path = host.getState(sessionId).download?.path + if (!path) + return false + shell.showItemInFolder(path) + return true + }) handle(DESKTOP_IPC_CHANNELS.browserTakeControl, (host, input) => { const { sessionId } = browserSessionInputSchema.parse(input) return desktopBrowserStateSchema.parse(host.takeControl(sessionId)) }) - handle(DESKTOP_IPC_CHANNELS.browserClose, (host, input) => { + handle(DESKTOP_IPC_CHANNELS.browserClose, async (host, input) => { const { sessionId } = browserSessionInputSchema.parse(input) + if (host.hasActiveDownloads(sessionId)) { + const window = options.getWindow() + if (!window) + throw new Error('Download cancellation requires confirmation') + const chinese = options.getLanguage?.() === 'zh-CN' + const result = await dialog.showMessageBox(window, { + type: 'warning', + message: chinese ? '取消下载并关闭标签页?' : 'Cancel downloads and close this tab?', + detail: chinese ? '此标签页仍有下载进行中。关闭会停止下载;Lexora 不会删除已有文件。' : 'Downloads from this tab are still in progress. Closing it will stop them. Existing files will not be deleted by Lexora.', + buttons: chinese ? ['保留标签页', '取消下载并关闭'] : ['Keep tab open', 'Cancel downloads and close'], + defaultId: 0, + cancelId: 0, + noLink: true, + }) + if (result.response !== 1) + throw new Error('Download cancellation declined') + } host.close(sessionId) }) return () => { + ipcMain.removeListener(BROWSER_PAGE_DIALOG_CHANNEL, pageDialogListener) for (const channel of registeredChannels) ipcMain.removeHandler(channel) } diff --git a/apps/buddy/electron/preload/browser.ts b/apps/buddy/electron/preload/browser.ts index 42dd0541..de719d5e 100644 --- a/apps/buddy/electron/preload/browser.ts +++ b/apps/buddy/electron/preload/browser.ts @@ -21,6 +21,10 @@ export function createBrowserApi(): Pick { { cache: input.cache, siteData: input.siteData }, ), getDataSummary: () => ipcRenderer.invoke(DESKTOP_IPC_CHANNELS.browserGetDataSummary), + setViewport: (sessionId: string, viewport: import('../../shared/browser/browserDesktopApi').DesktopBrowserViewport | null): Promise => ipcRenderer.invoke( + DESKTOP_IPC_CHANNELS.browserSetViewport, + { sessionId, viewport }, + ), setZoomFactor: (sessionId: string, zoomFactor: number | null) => ipcRenderer.invoke( DESKTOP_IPC_CHANNELS.browserSetZoomFactor, { sessionId, zoomFactor }, @@ -65,10 +69,18 @@ export function createBrowserApi(): Pick { DESKTOP_IPC_CHANNELS.browserOpenExternal, { sessionId }, ), + openDevTools: (sessionId: string): Promise => ipcRenderer.invoke( + DESKTOP_IPC_CHANNELS.browserOpenDevTools, + { sessionId }, + ), reload: (sessionId: string) => ipcRenderer.invoke( DESKTOP_IPC_CHANNELS.browserReload, { sessionId }, ), + revealDownload: (sessionId: string): Promise => ipcRenderer.invoke( + DESKTOP_IPC_CHANNELS.browserRevealDownload, + { sessionId }, + ), setProfileMode: ( sessionId: string, profileMode: DesktopBrowserProfileMode, diff --git a/apps/buddy/electron/preload/browserGuest.ts b/apps/buddy/electron/preload/browserGuest.ts new file mode 100644 index 00000000..e63c66b6 --- /dev/null +++ b/apps/buddy/electron/preload/browserGuest.ts @@ -0,0 +1,37 @@ +import type { BrowserPageDialogResponse } from '../../shared/browser/browserDialogs' +import { contextBridge, ipcRenderer } from 'electron' +import { BROWSER_PAGE_DIALOG_CHANNEL } from '../../shared/browser/browserDialogs' + +// This sandboxed Guest preload exposes only bounded alert/confirm requests. No filesystem, +// desktop API, generic IPC, Node.js, CDP or arbitrary evaluation is exposed to page content. +contextBridge.exposeInMainWorld('__lexoraPageDialog', Object.freeze({ + show(type: 'alert' | 'confirm', message: string): BrowserPageDialogResponse { + if (!['alert', 'confirm'].includes(type) || typeof message !== 'string') + return { handled: true, value: false } + try { + return ipcRenderer.sendSync(BROWSER_PAGE_DIALOG_CHANNEL, { type, message: message.slice(0, 2_048) }) + } + catch { return { handled: true, value: false } } + }, +})) +contextBridge.executeInMainWorld({ + func: () => { + const page = globalThis as unknown as { + alert: (message?: unknown) => void + confirm: (message?: string) => boolean + __lexoraPageDialog: { show: (type: 'alert' | 'confirm', message: string) => { handled: boolean, value: boolean } } + } + const bridge = page.__lexoraPageDialog + const alert = page.alert.bind(page) + const confirm = page.confirm.bind(page) + page.alert = (message?: unknown) => { + const result = bridge.show('alert', String(message ?? '')) + if (!result.handled) + alert(message) + } + page.confirm = (message?: string) => { + const result = bridge.show('confirm', String(message ?? '')) + return result.handled ? result.value : confirm(message) + } + }, +}) diff --git a/apps/buddy/electron/shared/desktopApi.ts b/apps/buddy/electron/shared/desktopApi.ts index f9e117f4..3884bea7 100644 --- a/apps/buddy/electron/shared/desktopApi.ts +++ b/apps/buddy/electron/shared/desktopApi.ts @@ -12,7 +12,7 @@ import type { DesktopUpdateApi, DesktopUpdateCheckResult } from './desktopUpdate import type { LocalChatApi } from './localChatApi' import { BUDDY_DEFAULT_PERMISSION_MODE } from '../../shared/permissions/permissionMode' -export { DESKTOP_BROWSER_ERROR_CODES, DESKTOP_BROWSER_PROFILE_MODES, DESKTOP_BROWSER_SECURITY_KINDS } from '../../shared/browser/browserDesktopApi' +export { DESKTOP_BROWSER_BLOCKED_ACTIONS, DESKTOP_BROWSER_ERROR_CODES, DESKTOP_BROWSER_PROFILE_MODES, DESKTOP_BROWSER_SECURITY_KINDS } from '../../shared/browser/browserDesktopApi' export const DESKTOP_IPC_CHANNELS = { workbenchRead: 'lexora:workbench:read', @@ -46,6 +46,7 @@ export const DESKTOP_IPC_CHANNELS = { browserCaptureScreenshot: 'lexora:browser:capture-screenshot', browserClearData: 'lexora:browser:clear-data', browserGetDataSummary: 'lexora:browser:get-data-summary', + browserSetViewport: 'lexora:browser:set-viewport', browserSetZoomFactor: 'lexora:browser:set-zoom-factor', browserClose: 'lexora:browser:close', browserEnsureSession: 'lexora:browser:ensure-session', @@ -56,7 +57,9 @@ export const DESKTOP_IPC_CHANNELS = { browserNavigate: 'lexora:browser:navigate', browserOpenArtifact: 'lexora:browser:open-artifact', browserOpenExternal: 'lexora:browser:open-external', + browserOpenDevTools: 'lexora:browser:open-dev-tools', browserReload: 'lexora:browser:reload', + browserRevealDownload: 'lexora:browser:reveal-download', browserSetProfileMode: 'lexora:browser:set-profile-mode', browserSetSurface: 'lexora:browser:set-surface', browserShowFileInFolder: 'lexora:browser:show-file-in-folder', @@ -249,6 +252,6 @@ export interface LexoraDesktopApi { localChat: LocalChatApi } -export type { DesktopBrowserApi, DesktopBrowserAttachGuestInput, DesktopBrowserEnsureSessionInput, DesktopBrowserError, DesktopBrowserErrorCode, DesktopBrowserGuestDescriptor, DesktopBrowserNavigateInput, DesktopBrowserOpenArtifactInput, DesktopBrowserProfileMode, DesktopBrowserSecurityKind, DesktopBrowserSecurityState, DesktopBrowserSessionInput, DesktopBrowserSetProfileModeInput, DesktopBrowserSetSurfaceInput, DesktopBrowserState, DesktopBrowserStatus } from '../../shared/browser/browserDesktopApi' +export type { DesktopBrowserApi, DesktopBrowserAttachGuestInput, DesktopBrowserBlockedAction, DesktopBrowserDownload, DesktopBrowserEnsureSessionInput, DesktopBrowserError, DesktopBrowserErrorCode, DesktopBrowserGuestDescriptor, DesktopBrowserNavigateInput, DesktopBrowserOpenArtifactInput, DesktopBrowserProfileMode, DesktopBrowserSecurityKind, DesktopBrowserSecurityState, DesktopBrowserSessionInput, DesktopBrowserSetProfileModeInput, DesktopBrowserSetSurfaceInput, DesktopBrowserState, DesktopBrowserStatus } from '../../shared/browser/browserDesktopApi' export type { DesktopCommandId, DesktopPlatform } from './desktopCommands' export type { DesktopUpdateCheckResult } from './desktopUpdates' diff --git a/apps/buddy/platform/windows/__tests__/fileConsumers.spec.ts b/apps/buddy/platform/windows/__tests__/fileConsumers.spec.ts index 3b4cfb2a..2d178c8f 100644 --- a/apps/buddy/platform/windows/__tests__/fileConsumers.spec.ts +++ b/apps/buddy/platform/windows/__tests__/fileConsumers.spec.ts @@ -154,6 +154,7 @@ function createPolicy() { debugger: { attach: () => {}, detach: () => {}, isAttached: () => true, sendCommand: async () => ({}) }, getURL: () => 'about:blank', loadURL: async () => {}, + openDevTools: () => {}, setWindowOpenHandler: () => {}, }, session, diff --git a/apps/buddy/shared/browser/browserDesktopApi.ts b/apps/buddy/shared/browser/browserDesktopApi.ts index 5662d62e..532c2383 100644 --- a/apps/buddy/shared/browser/browserDesktopApi.ts +++ b/apps/buddy/shared/browser/browserDesktopApi.ts @@ -25,17 +25,40 @@ export const DESKTOP_BROWSER_SECURITY_KINDS = [ export const DESKTOP_BROWSER_PROFILE_MODES = ['default', 'incognito'] as const +/** Blocked human-facing browser action, used to explain an interception in the interface. */ +export const DESKTOP_BROWSER_BLOCKED_ACTIONS = [ + 'download', + 'file-chooser', + 'permission', + 'popup', +] as const + +export type DesktopBrowserBlockedAction = typeof DESKTOP_BROWSER_BLOCKED_ACTIONS[number] + export type DesktopBrowserErrorCode = typeof DESKTOP_BROWSER_ERROR_CODES[number] export type DesktopBrowserProfileMode = typeof DESKTOP_BROWSER_PROFILE_MODES[number] export type DesktopBrowserSecurityKind = typeof DESKTOP_BROWSER_SECURITY_KINDS[number] export type DesktopBrowserStatus = 'error' | 'idle' | 'loading' | 'ready' export interface DesktopBrowserError { + noticeId?: string + origin?: string + detail?: 'fresh-click' | 'unavailable' | 'target-changed' | 'selection-failed' | 'invalid-target' | 'dialog-suppressed' + /** Set when a specific human action was intercepted, so the interface can explain it. */ + action?: DesktopBrowserBlockedAction code: DesktopBrowserErrorCode message: string reason?: BrowserFailureReason } +/** Latest download started from the managed page, reported for status display only. */ +export interface DesktopBrowserDownload { + id?: string + fileName: string + path: string | null + state: 'canceled' | 'completed' | 'failed' | 'started' +} + export type DesktopBrowserSecurityState = { kind: 'blank' origin: null @@ -44,7 +67,18 @@ export type DesktopBrowserSecurityState = { origin: string } +export interface DesktopBrowserViewport { + width: number + height: number + scale: number +} + export interface DesktopBrowserState { + favicon?: string | null + viewport?: DesktopBrowserViewport | null + /** Host-created popup identity; renderer uses the opener to retain the owning panel scope. */ + openedFrom?: { sessionId: string, tabId: string } + download?: DesktopBrowserDownload | null zoomFactor: number canGoBack: boolean canGoForward: boolean @@ -106,6 +140,7 @@ export interface DesktopBrowserApi { captureScreenshot: (sessionId: string) => Promise clearData: (input: BrowserClearDataInput) => Promise getDataSummary: () => Promise + setViewport: (sessionId: string, viewport: DesktopBrowserViewport | null) => Promise setZoomFactor: (sessionId: string, zoomFactor: number | null) => Promise close: (sessionId: string) => Promise ensureSession: (conversationId: string | null, tabId?: string) => Promise @@ -116,8 +151,10 @@ export interface DesktopBrowserApi { onGuestsChanged: (listener: () => void) => () => void onStateChanged: (listener: (state: DesktopBrowserState) => void) => () => void openArtifact: (sessionId: string, artifactId: string) => Promise + openDevTools: (sessionId: string) => Promise openExternal: (sessionId: string) => Promise reload: (sessionId: string) => Promise + revealDownload: (sessionId: string) => Promise setProfileMode: ( sessionId: string, profileMode: DesktopBrowserProfileMode, diff --git a/apps/buddy/shared/browser/browserDesktopSchemas.ts b/apps/buddy/shared/browser/browserDesktopSchemas.ts index 595b2aeb..7d07c496 100644 --- a/apps/buddy/shared/browser/browserDesktopSchemas.ts +++ b/apps/buddy/shared/browser/browserDesktopSchemas.ts @@ -1,6 +1,6 @@ import type { DesktopBrowserAttachGuestInput, DesktopBrowserEnsureSessionInput, DesktopBrowserGuestDescriptor, DesktopBrowserNavigateInput, DesktopBrowserOpenArtifactInput, DesktopBrowserSecurityState, DesktopBrowserSessionInput, DesktopBrowserSetProfileModeInput, DesktopBrowserSetSurfaceInput, DesktopBrowserState } from './browserDesktopApi' import { z } from 'zod' -import { DESKTOP_BROWSER_ERROR_CODES, DESKTOP_BROWSER_PROFILE_MODES, DESKTOP_BROWSER_SECURITY_KINDS } from './browserDesktopApi' +import { DESKTOP_BROWSER_BLOCKED_ACTIONS, DESKTOP_BROWSER_ERROR_CODES, DESKTOP_BROWSER_PROFILE_MODES, DESKTOP_BROWSER_SECURITY_KINDS } from './browserDesktopApi' import { browserZoomFactorSchema } from './browserPreferences' import { BROWSER_FAILURE_REASONS } from './primitives' @@ -76,6 +76,17 @@ export const browserSessionInputSchema: z.ZodType = sessionId: browserSessionIdSchema, }).strict() +export const browserViewportSchema = z.object({ + width: z.number().int().min(240).max(3_840), + height: z.number().int().min(240).max(2_160), + scale: z.number().min(0.1).max(2), +}).strict() + +export const browserSetViewportInputSchema = z.object({ + sessionId: browserSessionIdSchema, + viewport: browserViewportSchema.nullable(), +}).strict() + export const browserSetZoomFactorInputSchema = z.object({ sessionId: browserSessionIdSchema, zoomFactor: browserZoomFactorSchema.nullable(), @@ -100,6 +111,9 @@ const desktopBrowserSecurityStateSchema: z.ZodType ]) export const desktopBrowserStateSchema: z.ZodType = z.object({ + favicon: browserUrlSchema.nullable().optional(), + viewport: browserViewportSchema.nullable().optional(), + openedFrom: z.object({ sessionId: z.uuid(), tabId: z.uuid() }).strict().optional(), zoomFactor: browserZoomFactorSchema, canGoBack: z.boolean(), canGoForward: z.boolean(), @@ -107,10 +121,20 @@ export const desktopBrowserStateSchema: z.ZodType = z.objec controlEpoch: z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER), conversationId: browserConversationIdSchema.nullable(), error: z.object({ + noticeId: z.string().max(128).optional(), + origin: z.string().max(512).optional(), + detail: z.enum(['fresh-click', 'unavailable', 'target-changed', 'selection-failed', 'invalid-target', 'dialog-suppressed']).optional(), + action: z.enum(DESKTOP_BROWSER_BLOCKED_ACTIONS).optional(), code: z.enum(DESKTOP_BROWSER_ERROR_CODES), message: z.string().max(1_024), reason: z.enum(BROWSER_FAILURE_REASONS).optional(), }).strict().nullable(), + download: z.object({ + id: z.string().max(128).optional(), + fileName: z.string().min(1).max(512), + path: z.string().min(1).max(4_096).nullable(), + state: z.enum(['canceled', 'completed', 'failed', 'started']), + }).strict().nullable().optional(), pageId: z.uuid(), profileMode: z.enum(DESKTOP_BROWSER_PROFILE_MODES), security: desktopBrowserSecurityStateSchema, diff --git a/apps/buddy/shared/browser/browserDialogs.ts b/apps/buddy/shared/browser/browserDialogs.ts new file mode 100644 index 00000000..a1a3698f --- /dev/null +++ b/apps/buddy/shared/browser/browserDialogs.ts @@ -0,0 +1,9 @@ +export const BROWSER_PAGE_DIALOG_CHANNEL = 'lexora:browser:page-dialog' +export interface BrowserPageDialogRequest { + type: 'alert' | 'confirm' + message: string +} +export interface BrowserPageDialogResponse { + handled: boolean + value: boolean +} diff --git a/apps/buddy/src/i18n/locales/en-US/tasks.ts b/apps/buddy/src/i18n/locales/en-US/tasks.ts index 63367393..42a0b484 100644 --- a/apps/buddy/src/i18n/locales/en-US/tasks.ts +++ b/apps/buddy/src/i18n/locales/en-US/tasks.ts @@ -100,11 +100,47 @@ export default { 'desktop.context.browserSecurityLocalHttp': 'Local origin (HTTP)', 'desktop.context.browserSecuritySecure': 'Secure connection', 'desktop.context.browserShowFileInFolder': 'Open containing folder', + 'desktop.context.browserOpenDevTools': 'Open page developer tools', 'desktop.context.browserStop': 'Stop loading', 'desktop.context.browserTakeControl': 'Pause and take over', 'desktop.context.browserTakingControl': 'Taking control…', 'desktop.context.browserToolbar': 'Browser toolbar', 'desktop.context.browserViewport': 'Web page content', + 'desktop.context.browserResponsive': 'Responsive preview', + 'desktop.context.browserViewportWidth': 'Width (px)', + 'desktop.context.browserViewportHeight': 'Height (px)', + 'desktop.context.browserViewportDevice': 'Device size', + 'desktop.context.browserViewportDeviceHint': 'Preview viewport sizes only; this does not emulate the device OS or browser.', + 'desktop.context.browserViewportCustom': 'Custom', + 'desktop.context.browserViewportDesktop': 'Desktop', + 'desktop.context.browserViewportScale': 'Preview zoom', + 'desktop.context.browserViewportFit': 'Fit', + 'desktop.context.browserViewportExit': 'Exit responsive preview', + 'desktop.context.browserViewportRange': 'Enter an integer from 240 to {max}', + 'desktop.context.browserViewportResizeWidth': 'Drag or use arrow keys to resize width', + 'desktop.context.browserViewportResizeHeight': 'Drag or use arrow keys to resize height', + 'desktop.context.browserViewportResizeBoth': 'Drag or use arrow keys to resize width and height', + 'desktop.context.browserViewportFailed': 'Viewport update failed. Take control and try again.', + 'desktop.context.browserActionUnavailable': 'This action is unavailable. Please try again later.', + 'desktop.context.browserUploadTargetChanged': 'Upload canceled: the page or control changed. Pick the file again.', + 'desktop.context.browserUploadFailed': 'The file could not be attached. Please select it again.', + 'desktop.context.browserPopupInvalid': 'Blocked: the target is not a safe web link and cannot be replayed.', + 'desktop.context.browserDialogSuppressed': 'Page dialog suppressed. Take control or wait before retrying.', + 'desktop.context.browserNoticeDismiss': 'Dismiss notice', + 'desktop.context.browserNoticeRetry': 'Reload page', + 'desktop.context.browserNoticeReveal': 'Show in folder', + 'desktop.context.browserBlockedPopup': 'Blocked: the page tried to open a new window. Click the link again.', + 'desktop.context.browserBlockedUpload': 'Blocked: the page asked for a file. Take control of the page and pick the file yourself.', + 'desktop.context.browserBlockedDownload': 'Blocked: the page started a download. Click the download again in the page.', + 'desktop.context.browserBlockedPermission': 'Blocked: the page requested a system permission, which was not granted.', + 'desktop.context.browserPageFailed': 'The page failed to load. You can retry.', + 'desktop.context.browserPageCrashed': 'The page process exited. You can retry loading.', + 'desktop.context.browserPageUnresponsive': 'The page is not responding. You can retry.', + 'desktop.context.browserCertificateError': 'Certificate error. The page was not loaded.', + 'desktop.context.browserDownloadStarted': 'Downloading {name}', + 'desktop.context.browserDownloadCompleted': 'Downloaded {name}', + 'desktop.context.browserDownloadCanceled': 'Download canceled {name}', + 'desktop.context.browserDownloadFailed': 'Download failed {name}', 'desktop.context.files': 'Files', 'desktop.context.selectFileSpace': 'Select space', 'desktop.context.noFileSpaces': 'No spaces have a working directory', diff --git a/apps/buddy/src/i18n/locales/zh-CN/tasks.ts b/apps/buddy/src/i18n/locales/zh-CN/tasks.ts index 64762b64..37fc35f6 100644 --- a/apps/buddy/src/i18n/locales/zh-CN/tasks.ts +++ b/apps/buddy/src/i18n/locales/zh-CN/tasks.ts @@ -98,11 +98,47 @@ export default { 'desktop.context.browserSecurityLocalHttp': '本地来源(HTTP)', 'desktop.context.browserSecuritySecure': '安全连接', 'desktop.context.browserShowFileInFolder': '打开文件夹', + 'desktop.context.browserOpenDevTools': '打开网页开发者工具', 'desktop.context.browserStop': '停止加载', 'desktop.context.browserTakeControl': '暂停并接管', 'desktop.context.browserTakingControl': '正在接管…', 'desktop.context.browserToolbar': '浏览器工具栏', 'desktop.context.browserViewport': '网页内容', + 'desktop.context.browserResponsive': '响应式预览', + 'desktop.context.browserViewportWidth': '宽度(px)', + 'desktop.context.browserViewportHeight': '高度(px)', + 'desktop.context.browserViewportDevice': '设备尺寸', + 'desktop.context.browserViewportDeviceHint': '仅切换视口尺寸,不模拟手机系统或浏览器。', + 'desktop.context.browserViewportCustom': '自定义', + 'desktop.context.browserViewportDesktop': '桌面', + 'desktop.context.browserViewportScale': '预览缩放', + 'desktop.context.browserViewportFit': '自动适配', + 'desktop.context.browserViewportExit': '退出响应式预览', + 'desktop.context.browserViewportRange': '请输入 240–{max} 的整数', + 'desktop.context.browserViewportResizeWidth': '拖动或按方向键调整宽度', + 'desktop.context.browserViewportResizeHeight': '拖动或按方向键调整高度', + 'desktop.context.browserViewportResizeBoth': '拖动或按方向键调整宽高', + 'desktop.context.browserViewportFailed': '视口调整失败,请接管页面后重试。', + 'desktop.context.browserActionUnavailable': '此操作暂不可用,请稍后重试。', + 'desktop.context.browserUploadTargetChanged': '上传已取消:页面或控制权发生变化。请重新点击选择文件。', + 'desktop.context.browserUploadFailed': '文件未能附加到页面,请重新选择。', + 'desktop.context.browserPopupInvalid': '已拦截:目标不是安全的网页链接,无法重放。', + 'desktop.context.browserDialogSuppressed': '网页对话框已抑制:请先接管页面,或稍后重试。', + 'desktop.context.browserNoticeDismiss': '关闭提示', + 'desktop.context.browserNoticeRetry': '重试加载', + 'desktop.context.browserNoticeReveal': '在文件夹中显示', + 'desktop.context.browserBlockedPopup': '已拦截:网页想打开新窗口。请重新点击链接。', + 'desktop.context.browserBlockedUpload': '已拦截:网页请求选择文件。请先接管页面,再自己点击选择。', + 'desktop.context.browserBlockedDownload': '已拦截:网页发起了下载。请在页面中重新点击下载。', + 'desktop.context.browserBlockedPermission': '已拦截:网页请求系统权限,未授权。', + 'desktop.context.browserPageFailed': '页面加载失败,可重试。', + 'desktop.context.browserPageCrashed': '网页进程已退出,可重试加载。', + 'desktop.context.browserPageUnresponsive': '网页暂时无响应,可重试。', + 'desktop.context.browserCertificateError': '证书错误,未加载该页面。', + 'desktop.context.browserDownloadStarted': '正在下载 {name}', + 'desktop.context.browserDownloadCompleted': '已下载 {name}', + 'desktop.context.browserDownloadCanceled': '已取消下载 {name}', + 'desktop.context.browserDownloadFailed': '下载失败 {name}', 'desktop.context.files': '文件', 'desktop.context.selectFileSpace': '选择空间', 'desktop.context.noFileSpaces': '暂无绑定工作目录的空间', diff --git a/apps/buddy/src/modules/settings/widgets/browser/DesktopBrowserClearDataDialog.vue b/apps/buddy/src/modules/settings/widgets/browser/DesktopBrowserClearDataDialog.vue index f722d280..6d30a7b4 100644 --- a/apps/buddy/src/modules/settings/widgets/browser/DesktopBrowserClearDataDialog.vue +++ b/apps/buddy/src/modules/settings/widgets/browser/DesktopBrowserClearDataDialog.vue @@ -124,6 +124,9 @@ function close() { + + {{ t('desktop.browser.signOutNotice') }} +

{{ t('desktop.browser.summaryFailed') }}

diff --git a/apps/buddy/src/modules/tasks/model/context-panel/taskContextPanel.ts b/apps/buddy/src/modules/tasks/model/context-panel/taskContextPanel.ts index bfa09838..ac5939f7 100644 --- a/apps/buddy/src/modules/tasks/model/context-panel/taskContextPanel.ts +++ b/apps/buddy/src/modules/tasks/model/context-panel/taskContextPanel.ts @@ -51,6 +51,7 @@ export interface TaskViewContextTab extends ContextTabSource { } export interface ContextPanelTab { + favicon?: string | null id: string title: string icon: 'file' | 'folder' | 'changes' | 'browser' diff --git a/apps/buddy/src/modules/tasks/state/context-panel/useTaskResourcePanel.ts b/apps/buddy/src/modules/tasks/state/context-panel/useTaskResourcePanel.ts index 8c2cabca..926015d6 100644 --- a/apps/buddy/src/modules/tasks/state/context-panel/useTaskResourcePanel.ts +++ b/apps/buddy/src/modules/tasks/state/context-panel/useTaskResourcePanel.ts @@ -25,8 +25,22 @@ export function useTaskResourcePanel(options: TaskResourcePanelOptions) { } const stopBrowserState = options.browser.onStateChanged((state) => { const entry = Object.entries(browserStates.value).find(([, previous]) => previous.sessionId === state.sessionId) - if (entry) + if (entry) { browserStates.value = { ...browserStates.value, [entry[0]]: state } + return + } + if (!state.openedFrom) + return + const sourceEntry = Object.entries(browserStates.value).find(([, previous]) => previous.sessionId === state.openedFrom!.sessionId) + const source = taskContext.allTabs.value.find(tab => tab.id === sourceEntry?.[0]) + if (!source || source.kind !== 'browser' || source.conversationId !== state.conversationId) + return + const id = browserTabId(state.conversationId, state.openedFrom.tabId) + const foreground = taskContext.activeTab.value?.id === source.id + taskContext.restoreTab({ id, kind: 'browser', scope: source.scope, conversationId: state.conversationId, browserKey: state.openedFrom.tabId, source: source.source }) + browserStates.value = { ...browserStates.value, [id]: state } + if (foreground) + taskContext.selectTab(id) }) let operation = 0 let disposed = false diff --git a/apps/buddy/src/modules/tasks/widgets/context-panel/DesktopBrowserContextSurface.vue b/apps/buddy/src/modules/tasks/widgets/context-panel/DesktopBrowserContextSurface.vue index c983730e..464895b5 100644 --- a/apps/buddy/src/modules/tasks/widgets/context-panel/DesktopBrowserContextSurface.vue +++ b/apps/buddy/src/modules/tasks/widgets/context-panel/DesktopBrowserContextSurface.vue @@ -4,15 +4,19 @@ import type { TaskBrowserContextTab } from '../../model/context-panel/taskContex import type { BrowserToolbarBusyAction, BrowserToolbarMenuActionKey } from './browserToolbarMenu' import type { BuddyLocale } from '@/i18n/buddyI18n' import type { DesktopBrowserGuestSurfaceHost } from '@/platform/browser/browserGuestSurface' -import { Pause16Regular } from '@vicons/fluent' +import { Dismiss16Regular, Pause16Regular } from '@vicons/fluent' import { useMessage } from 'naive-ui' import { computed, shallowRef, toRef, useTemplateRef, watch } from 'vue' import { useBuddyI18n } from '@/i18n/buddyI18n' import DesktopIcon from '@/shared/ui/icon/DesktopIcon.vue' import WorkbenchPanelContent from '@/workbench/browser/WorkbenchPanelContent.vue' +import { browserErrorNotice, browserNoticeKey, downloadNotice } from './browserNotice' +import DesktopBrowserResponsiveViewport from './DesktopBrowserResponsiveViewport.vue' import DesktopBrowserToolbar from './DesktopBrowserToolbar.vue' +import DesktopBrowserViewportControls from './DesktopBrowserViewportControls.vue' import { useBrowserAddress } from './useBrowserAddress' import { useBrowserContextSurface } from './useBrowserContextSurface' +import { useBrowserResponsiveViewport } from './useBrowserResponsiveViewport' const props = defineProps<{ tab: TaskBrowserContextTab | null @@ -43,6 +47,44 @@ const browserView = useBrowserContextSurface({ const { address, openAddress, updateAddress } = useBrowserAddress(browserView.state, browserView.navigate) const browserState = browserView.state const browserBlank = computed(() => browserState.value?.url === 'about:blank' && browserState.value.status !== 'loading') +// Identity is host-issued: dismissing one event must not hide a later retry or another tab. +const dismissedNotices = shallowRef>(new Set()) +const notices = computed(() => { + const state = browserState.value + if (!state) + return [] + const result = [] + if (state.error) { + const key = browserNoticeKey(state.sessionId, state.error, null)! + if (!dismissedNotices.value.has(key)) + result.push({ key, retry: state.error.action === undefined, reveal: false, text: browserErrorNotice(state.error, t) }) + } + if (state.download) { + const key = browserNoticeKey(state.sessionId, null, state.download)! + if (!dismissedNotices.value.has(key)) + result.push({ key, retry: false, reveal: state.download.state === 'completed' && Boolean(state.download.path), text: downloadNotice(state.download, t) }) + } + return result +}) +const canvasSize = shallowRef({ width: 0, height: 0 }) +const responsive = useBrowserResponsiveViewport({ + api: props.api, + state: browserState, + visible: toRef(() => props.visible), + canvas: canvasSize, + updateState: state => props.updateState(state), + onError: () => message.error(t('desktop.context.browserViewportFailed')), +}) +watch(() => browserState.value?.sessionId, () => { + dismissedNotices.value = new Set() +}) +watch(responsive.viewport, () => props.guestHost.layout?.(), { flush: 'post' }) +function dismissNotice(key: string): void { + dismissedNotices.value = new Set([...dismissedNotices.value, key].slice(-64)) +} +function revealDownload(): void { + void browserView.revealDownload() +} const controlAnnouncement = shallowRef('') watch(() => browserState.value?.controller, (controller, previous) => { if (controller === 'agent') @@ -71,6 +113,10 @@ async function setZoom(factor: number | null) { function browserMenu(action: BrowserToolbarMenuActionKey) { if (action === 'capture-screenshot') void captureScreenshot() + else if (action === 'open-devtools') + void browserView.openDevTools() + else if (action === 'responsive-viewport') + responsive.toggle() else if (action === 'enter-incognito') void browserView.setProfileMode('incognito') else if (action === 'exit-incognito') @@ -85,12 +131,43 @@ function browserMenu(action: BrowserToolbarMenuActionKey) { diff --git a/apps/buddy/src/modules/tasks/widgets/context-panel/DesktopBrowserToolbar.vue b/apps/buddy/src/modules/tasks/widgets/context-panel/DesktopBrowserToolbar.vue index 93752fec..fbd607a9 100644 --- a/apps/buddy/src/modules/tasks/widgets/context-panel/DesktopBrowserToolbar.vue +++ b/apps/buddy/src/modules/tasks/widgets/context-panel/DesktopBrowserToolbar.vue @@ -15,11 +15,13 @@ import { ArrowLeft16Regular, ArrowRight16Regular, Camera20Regular, + Code20Regular, FolderOpen20Regular, Globe16Regular, LockClosed16Regular, MoreHorizontal20Regular, Open20Regular, + PhoneDesktop20Regular, Stop16Regular, TabInPrivate20Regular, Warning16Regular, @@ -32,6 +34,8 @@ import { getBrowserToolbarMenuActions } from './browserToolbarMenu' import DesktopBrowserZoomControls from './DesktopBrowserZoomControls.vue' const props = defineProps<{ + responsive?: boolean + responsiveBusy?: boolean busyAction: BrowserToolbarBusyAction | null language: BuddyLocale state: DesktopBrowserState | null @@ -78,6 +82,8 @@ const menuIconByAction: Record = { 'capture-screenshot': Camera20Regular, 'enter-incognito': TabInPrivate20Regular, 'exit-incognito': TabInPrivate20Regular, + 'responsive-viewport': PhoneDesktop20Regular, + 'open-devtools': Code20Regular, 'open-external': Open20Regular, 'show-file-in-folder': FolderOpen20Regular, } @@ -86,6 +92,8 @@ const menuOptions = computed(() => { busyAction: props.busyAction, controller: props.state?.controller ?? 'human', profileMode: props.state?.profileMode ?? 'default', + responsive: props.responsive, + responsiveBusy: props.responsiveBusy, url: props.state?.url ?? 'about:blank', }) return [{ @@ -115,6 +123,8 @@ function handleMenuAction(value: string | number): void { busyAction: props.busyAction, controller: props.state?.controller ?? 'human', profileMode: props.state?.profileMode ?? 'default', + responsive: props.responsive, + responsiveBusy: props.responsiveBusy, url: props.state?.url ?? 'about:blank', }).find(action => action.key === value) if (action && !action.disabled) diff --git a/apps/buddy/src/modules/tasks/widgets/context-panel/DesktopBrowserViewportControls.vue b/apps/buddy/src/modules/tasks/widgets/context-panel/DesktopBrowserViewportControls.vue new file mode 100644 index 00000000..ebb1cecb --- /dev/null +++ b/apps/buddy/src/modules/tasks/widgets/context-panel/DesktopBrowserViewportControls.vue @@ -0,0 +1,104 @@ + + + + + diff --git a/apps/buddy/src/modules/tasks/widgets/context-panel/DesktopTaskContextPanel.vue b/apps/buddy/src/modules/tasks/widgets/context-panel/DesktopTaskContextPanel.vue index d88cf0d6..58b43f1d 100644 --- a/apps/buddy/src/modules/tasks/widgets/context-panel/DesktopTaskContextPanel.vue +++ b/apps/buddy/src/modules/tasks/widgets/context-panel/DesktopTaskContextPanel.vue @@ -2,7 +2,7 @@ import type { ContextPanelTab } from '../../model/context-panel/taskContextPanel' import type { BuddyLocale } from '@/i18n/buddyI18n' import { Code16Regular, Folder20Regular, Globe16Regular } from '@vicons/fluent' -import { computed } from 'vue' +import { computed, shallowRef } from 'vue' import { useBuddyI18n } from '@/i18n/buddyI18n' import { FileIcon, FolderIcon } from '@/shared/ui/file-icon' import DesktopIcon from '@/shared/ui/icon/DesktopIcon.vue' @@ -22,6 +22,10 @@ const emit = defineEmits<{ }>() defineSlots<{ default?: () => unknown, toolbar?: () => unknown }>() const { t } = useBuddyI18n(() => props.language) +const failedFavicons = shallowRef>(new Set()) +function faviconFailed(url: string): void { + failedFavicons.value = new Set([...failedFavicons.value, url].slice(-64)) +} const actions = computed(() => [ ...(props.canAddChanges ? [{ id: 'changes' as const, label: t('desktop.context.changes'), icon: Code16Regular }] : []), ...(props.canAddFiles ? [{ id: 'files' as const, label: t('desktop.context.files'), icon: Folder20Regular }] : []), @@ -39,6 +43,7 @@ function add(id: string) { diff --git a/apps/buddy/src/modules/tasks/widgets/context-panel/DesktopTaskResourcePanel.vue b/apps/buddy/src/modules/tasks/widgets/context-panel/DesktopTaskResourcePanel.vue index b3516d15..b5146dff 100644 --- a/apps/buddy/src/modules/tasks/widgets/context-panel/DesktopTaskResourcePanel.vue +++ b/apps/buddy/src/modules/tasks/widgets/context-panel/DesktopTaskResourcePanel.vue @@ -36,7 +36,7 @@ const tabs = computed(() => props.panel.tabs.value.map((tab) const title = !state || state.url === 'about:blank' ? t('desktop.context.browserNewTab') : state.title.trim() || t('desktop.context.browser') - return { id: tab.id, title, icon: 'browser' } + return { id: tab.id, title, icon: 'browser', favicon: state?.favicon ?? null } })) function add(kind: 'changes' | 'files' | 'browser') { if (kind === 'files') { diff --git a/apps/buddy/src/modules/tasks/widgets/context-panel/__tests__/browserAddress.spec.ts b/apps/buddy/src/modules/tasks/widgets/context-panel/__tests__/browserAddress.spec.ts new file mode 100644 index 00000000..949ef972 --- /dev/null +++ b/apps/buddy/src/modules/tasks/widgets/context-panel/__tests__/browserAddress.spec.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from 'vitest' +import { normalizeBrowserAddress } from '../browserAddress' + +describe('browser address normalization', () => { + it.each([ + ['localhost:5173', 'http://localhost:5173/'], + ['127.0.0.1:3000/app', 'http://127.0.0.1:3000/app'], + ['[::1]:8080', 'http://[::1]:8080/'], + ['192.168.1.10', 'http://192.168.1.10/'], + ['10.0.0.4:8000', 'http://10.0.0.4:8000/'], + ['printer.local', 'http://printer.local/'], + ['example.com', 'https://example.com/'], + ['example.com/docs?q=1', 'https://example.com/docs?q=1'], + ['https://example.com/secure', 'https://example.com/secure'], + ])('resolves %s to %s', (input, expected) => { + expect(normalizeBrowserAddress(input)).toBe(expected) + }) + + it.each([ + [''], + [' '], + ['ftp://example.com/file'], + ['javascript:alert(1)'], + ['hello world'], + ['not a url at all / spaces'], + ])('rejects %j instead of guessing a search request', (input) => { + expect(normalizeBrowserAddress(input)).toBeNull() + }) +}) diff --git a/apps/buddy/src/modules/tasks/widgets/context-panel/__tests__/browserNotice.spec.ts b/apps/buddy/src/modules/tasks/widgets/context-panel/__tests__/browserNotice.spec.ts new file mode 100644 index 00000000..952f28bf --- /dev/null +++ b/apps/buddy/src/modules/tasks/widgets/context-panel/__tests__/browserNotice.spec.ts @@ -0,0 +1,41 @@ +import type { BuddyTranslate } from '@/i18n/buddyI18n' +import { describe, expect, it } from 'vitest' +import { blockedActionNotice, downloadNotice, pageErrorNotice } from '../browserNotice' + +const t = ((key: string, params?: Record) => ( + params ? `${key}:${JSON.stringify(params)}` : key +)) as unknown as BuddyTranslate + +describe('browser notices', () => { + it.each([ + ['popup', 'desktop.context.browserBlockedPopup'], + ['file-chooser', 'desktop.context.browserBlockedUpload'], + ['download', 'desktop.context.browserBlockedDownload'], + ['permission', 'desktop.context.browserBlockedPermission'], + ] as const)('explains a blocked %s without leaking diagnostics', (action, key) => { + expect(blockedActionNotice(action, t)).toBe(key) + }) + + it.each([ + ['BROWSER_CERTIFICATE_ERROR', 'desktop.context.browserCertificateError'], + ['BROWSER_PAGE_CRASHED', 'desktop.context.browserPageCrashed'], + ['BROWSER_PAGE_FAILED', 'desktop.context.browserPageFailed'], + ['BROWSER_PAGE_UNRESPONSIVE', 'desktop.context.browserPageUnresponsive'], + ] as const)('names the page failure %s', (code, key) => { + expect(pageErrorNotice(code, t)).toBe(key) + }) + + it('falls back to the generic load failure for unclassified codes', () => { + expect(pageErrorNotice('BROWSER_SESSION_NOT_FOUND', t)).toBe('desktop.context.browserPageFailed') + }) + + it.each([ + ['started', 'desktop.context.browserDownloadStarted'], + ['completed', 'desktop.context.browserDownloadCompleted'], + ['canceled', 'desktop.context.browserDownloadCanceled'], + ['failed', 'desktop.context.browserDownloadFailed'], + ] as const)('reports a %s download with its file name', (state, key) => { + expect(downloadNotice({ fileName: 'report.csv', path: null, state }, t)) + .toBe(`${key}:{"name":"report.csv"}`) + }) +}) diff --git a/apps/buddy/src/modules/tasks/widgets/context-panel/__tests__/browserToolbarMenu.spec.ts b/apps/buddy/src/modules/tasks/widgets/context-panel/__tests__/browserToolbarMenu.spec.ts new file mode 100644 index 00000000..a1ba0b7b --- /dev/null +++ b/apps/buddy/src/modules/tasks/widgets/context-panel/__tests__/browserToolbarMenu.spec.ts @@ -0,0 +1,56 @@ +import type { DesktopBrowserProfileMode } from '@buddy-electron/shared/desktopApi' +import type { BrowserToolbarBusyAction } from '../browserToolbarMenu' +import { describe, expect, it } from 'vitest' +import { getBrowserToolbarMenuActions } from '../browserToolbarMenu' + +interface MenuContext { + busyAction: BrowserToolbarBusyAction | null + controller: 'agent' | 'human' + profileMode: DesktopBrowserProfileMode + responsive?: boolean + responsiveBusy?: boolean + url: string +} + +const page: MenuContext = { + busyAction: null, + controller: 'human', + profileMode: 'default', + url: 'https://example.com/', +} + +function action(key: string, overrides: Partial = {}) { + return getBrowserToolbarMenuActions({ ...page, ...overrides }).find(candidate => candidate.key === key) +} + +describe('browser toolbar menu', () => { + it('offers page developer tools for a human-controlled page', () => { + expect(action('open-devtools')).toMatchObject({ + disabled: false, + labelKey: 'desktop.context.browserOpenDevTools', + }) + }) + + it.each<[Partial, string]>([ + [{ url: 'about:blank' }, 'an empty tab'], + [{ controller: 'agent' }, 'a page the agent controls'], + [{ busyAction: 'screenshot' }, 'a busy toolbar'], + ])('disables page developer tools for %s', (overrides) => { + expect(action('open-devtools', overrides)?.disabled).toBe(true) + }) + + it('offers responsive preview in More and labels the active mode as Exit', () => { + expect(action('responsive-viewport')).toMatchObject({ disabled: false, labelKey: 'desktop.context.browserResponsive' }) + expect(action('responsive-viewport', { responsive: true })).toMatchObject({ disabled: false, labelKey: 'desktop.context.browserViewportExit' }) + }) + + it('allows exiting an active preview on a blank page but preserves busy and agent guards', () => { + expect(action('responsive-viewport', { responsive: true, url: 'about:blank' })?.disabled).toBe(false) + expect(action('responsive-viewport', { responsive: true, responsiveBusy: true })?.disabled).toBe(true) + expect(action('responsive-viewport', { responsive: true, controller: 'agent' })?.disabled).toBe(true) + }) + + it('keeps developer tools available in private browsing', () => { + expect(action('open-devtools', { profileMode: 'incognito' })?.disabled).toBe(false) + }) +}) diff --git a/apps/buddy/src/modules/tasks/widgets/context-panel/__tests__/browserViewport.spec.ts b/apps/buddy/src/modules/tasks/widgets/context-panel/__tests__/browserViewport.spec.ts new file mode 100644 index 00000000..fc4e7f15 --- /dev/null +++ b/apps/buddy/src/modules/tasks/widgets/context-panel/__tests__/browserViewport.spec.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from 'vitest' +import { BROWSER_VIEWPORT_DEVICES, browserPreviewScale, browserViewportDeviceForSize, clampViewportSize, viewportDimensionValid } from '../browserViewport' + +const size = { width: 393, height: 852 } +describe('responsive browser preview', () => { + it('fits inside the padded canvas without changing the logical viewport', () => { + expect(browserPreviewScale(size, { width: 800, height: 458 }, 'fit')).toBe(0.5) + expect(size).toEqual({ width: 393, height: 852 }) + expect(browserPreviewScale(size, { width: 4000, height: 4000 }, 'fit')).toBe(1) + }) + it('handles hidden or tiny canvases without zero or invalid scale', () => { + expect(browserPreviewScale(size, { width: 0, height: 0 }, 'fit')).toBe(1) + expect(browserPreviewScale(size, { width: 33, height: 33 }, 'fit')).toBe(0.1) + }) + it.each([['25', 0.25], ['50', 0.5], ['100', 1], ['200', 2]] as const)('keeps fixed %s%% independent of panel size', (zoom, scale) => { + expect(browserPreviewScale(size, { width: 100, height: 100 }, zoom)).toBe(scale) + }) + it('rejects invalid dimensions instead of silently applying a clamped draft', () => { + expect(viewportDimensionValid('width', 239)).toBe(false) + expect(viewportDimensionValid('width', 3841)).toBe(false) + expect(viewportDimensionValid('height', 2161)).toBe(false) + expect(viewportDimensionValid('width', 393.5)).toBe(false) + expect(viewportDimensionValid('height', Number.NaN)).toBe(false) + expect(viewportDimensionValid('width', 3840)).toBe(true) + expect(viewportDimensionValid('height', 2160)).toBe(true) + }) + it('matches named device sizes and keeps arbitrary dimensions custom', () => { + for (const device of BROWSER_VIEWPORT_DEVICES) { + expect(viewportDimensionValid('width', device.width)).toBe(true) + expect(viewportDimensionValid('height', device.height)).toBe(true) + expect(browserViewportDeviceForSize(device)).toBe(device.id) + } + expect(browserViewportDeviceForSize({ width: 431, height: 701 })).toBe('custom') + }) + + it('bounds drag dimensions and rounds only the committed logical size', () => { + expect(clampViewportSize({ width: 50, height: 5000 })).toEqual({ width: 240, height: 2160 }) + expect(clampViewportSize({ width: 393.7, height: 851.2 })).toEqual({ width: 394, height: 851 }) + }) +}) diff --git a/apps/buddy/src/modules/tasks/widgets/context-panel/__tests__/useBrowserResponsiveViewport.spec.ts b/apps/buddy/src/modules/tasks/widgets/context-panel/__tests__/useBrowserResponsiveViewport.spec.ts new file mode 100644 index 00000000..46154fe0 --- /dev/null +++ b/apps/buddy/src/modules/tasks/widgets/context-panel/__tests__/useBrowserResponsiveViewport.spec.ts @@ -0,0 +1,78 @@ +// @vitest-environment jsdom +import type { DesktopBrowserState, DesktopBrowserViewport } from '@buddy-shared/browser/browserDesktopApi' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { createApp, nextTick, shallowRef } from 'vue' +import { useBrowserResponsiveViewport } from '../useBrowserResponsiveViewport' + +const cleanups: (() => void)[] = [] +afterEach(() => cleanups.splice(0).forEach(cleanup => cleanup())) +function fixture() { + const state = shallowRef({ sessionId: 'session-a', pageId: 'page-a', conversationId: null, viewport: null, controller: 'human', controlEpoch: 0, visible: true, canGoBack: false, canGoForward: false, error: null, profileMode: 'default', security: { kind: 'blank', origin: null }, status: 'ready', title: 'Fixture', url: 'about:blank', zoomFactor: 1 }) + const visible = shallowRef(true) + const canvas = shallowRef({ width: 800, height: 458 }) + const flights: { sessionId: string, viewport: DesktopBrowserViewport | null, resolve: (state: DesktopBrowserState) => void, reject: (error: Error) => void }[] = [] + const setViewport = vi.fn((sessionId: string, viewport: DesktopBrowserViewport | null) => new Promise((resolve, reject) => flights.push({ sessionId, viewport, resolve, reject }))) + const updateState = vi.fn((value: DesktopBrowserState) => state.value = value) + const onError = vi.fn() + let controls!: ReturnType + const app = createApp({ setup() { + controls = useBrowserResponsiveViewport({ api: { setViewport }, state, visible, canvas, updateState, onError }) + return () => null + } }) + const root = document.createElement('div') + app.mount(root) + cleanups.push(() => app.unmount()) + function complete(index = 0) { + flights[index].resolve({ ...state.value, sessionId: flights[index].sessionId, viewport: flights[index].viewport }) + } + return { controls, state, canvas, visible, flights, setViewport, updateState, onError, complete } +} + +describe('responsive viewport request ownership', () => { + it('enters with Fit and remembers dimensions when exiting', async () => { + const f = fixture() + f.controls.toggle() + expect(f.flights[0].viewport).toEqual({ width: 393, height: 852, scale: 0.5 }) + f.complete() + await vi.waitFor(() => expect(f.controls.busy.value).toBe(false)) + f.controls.toggle() + expect(f.flights[1].viewport).toBeNull() + f.complete(1) + await vi.waitFor(() => expect(f.controls.busy.value).toBe(false)) + f.controls.toggle() + expect(f.flights[2].viewport?.width).toBe(393) + }) + it('serializes and coalesces rapid drag changes; an old response cannot replace the newest size', async () => { + const f = fixture() + f.controls.resize({ width: 400, height: 600 }) + f.controls.resize({ width: 420, height: 600 }) + f.controls.resize({ width: 440, height: 600 }) + expect(f.setViewport).toHaveBeenCalledTimes(1) + f.complete() + await vi.waitFor(() => expect(f.setViewport).toHaveBeenCalledTimes(2)) + expect(f.updateState).not.toHaveBeenCalled() + expect(f.flights[1].viewport?.width).toBe(440) + f.complete(1) + await vi.waitFor(() => expect(f.controls.busy.value).toBe(false)) + expect(f.state.value.viewport?.width).toBe(440) + }) + it('discards a response after tab or control ownership changes', async () => { + const f = fixture() + f.controls.toggle() + f.state.value = { ...f.state.value, sessionId: 'session-b', controller: 'agent' } + await nextTick() + f.complete() + await vi.waitFor(() => expect(f.controls.busy.value).toBe(false)) + expect(f.updateState).not.toHaveBeenCalled() + f.controls.toggle() + expect(f.setViewport).toHaveBeenCalledTimes(1) + }) + it('rolls back a failed preview change and reports failure once', async () => { + const f = fixture() + f.controls.toggle() + f.flights[0].reject(new Error('Unavailable')) + await vi.waitFor(() => expect(f.controls.busy.value).toBe(false)) + expect(f.controls.active.value).toBe(false) + expect(f.onError).toHaveBeenCalledTimes(1) + }) +}) diff --git a/apps/buddy/src/modules/tasks/widgets/context-panel/browserNotice.ts b/apps/buddy/src/modules/tasks/widgets/context-panel/browserNotice.ts new file mode 100644 index 00000000..deec58ab --- /dev/null +++ b/apps/buddy/src/modules/tasks/widgets/context-panel/browserNotice.ts @@ -0,0 +1,63 @@ +import type { DesktopBrowserBlockedAction, DesktopBrowserDownload, DesktopBrowserError, DesktopBrowserErrorCode } from '@buddy-electron/shared/desktopApi' +import type { BuddyI18nKey, BuddyTranslate } from '@/i18n/buddyI18n' + +/** + * Copy for host-side browser interceptions and page failures. The host reports codes and the + * blocked action, so the interface owns the wording and never renders raw diagnostics. + */ +const BLOCKED_ACTION_KEYS = { + 'download': 'desktop.context.browserBlockedDownload', + 'file-chooser': 'desktop.context.browserBlockedUpload', + 'permission': 'desktop.context.browserBlockedPermission', + 'popup': 'desktop.context.browserBlockedPopup', +} as const satisfies Record + +const PAGE_ERROR_KEYS = { + BROWSER_CERTIFICATE_ERROR: 'desktop.context.browserCertificateError', + BROWSER_NAVIGATION_BLOCKED: 'desktop.context.browserPageFailed', + BROWSER_PAGE_CRASHED: 'desktop.context.browserPageCrashed', + BROWSER_PAGE_FAILED: 'desktop.context.browserPageFailed', + BROWSER_PAGE_UNRESPONSIVE: 'desktop.context.browserPageUnresponsive', +} as const satisfies Partial> + +const DOWNLOAD_STATE_KEYS = { + canceled: 'desktop.context.browserDownloadCanceled', + completed: 'desktop.context.browserDownloadCompleted', + failed: 'desktop.context.browserDownloadFailed', + started: 'desktop.context.browserDownloadStarted', +} as const satisfies Record + +export function blockedActionNotice(action: DesktopBrowserBlockedAction, t: BuddyTranslate): string { + return t(BLOCKED_ACTION_KEYS[action]) +} + +const DETAIL_KEYS = { + 'unavailable': 'desktop.context.browserActionUnavailable', + 'target-changed': 'desktop.context.browserUploadTargetChanged', + 'selection-failed': 'desktop.context.browserUploadFailed', + 'invalid-target': 'desktop.context.browserPopupInvalid', + 'dialog-suppressed': 'desktop.context.browserDialogSuppressed', +} as const satisfies Partial, BuddyI18nKey>> + +export function browserErrorNotice(error: DesktopBrowserError, t: BuddyTranslate): string { + const detail = error.detail && (DETAIL_KEYS as Partial, BuddyI18nKey>>)[error.detail] + const text = detail ? t(detail) : error.action ? blockedActionNotice(error.action, t) : pageErrorNotice(error.code, t) + return error.origin ? `${error.origin} — ${text}` : text +} + +export function browserNoticeKey(sessionId: string, error: DesktopBrowserError | null | undefined, download: DesktopBrowserDownload | null | undefined): string | null { + if (error) + return `${sessionId}:error:${error.noticeId ?? `${error.action ?? ''}:${error.code}:${error.message}`}` + if (download) + return `${sessionId}:download:${download.id ?? download.fileName}:${download.state}` + return null +} + +export function pageErrorNotice(code: DesktopBrowserErrorCode, t: BuddyTranslate): string { + const key = (PAGE_ERROR_KEYS as Partial>)[code] + return t(key ?? 'desktop.context.browserPageFailed') +} + +export function downloadNotice(download: DesktopBrowserDownload, t: BuddyTranslate): string { + return t(DOWNLOAD_STATE_KEYS[download.state], { name: download.fileName }) +} diff --git a/apps/buddy/src/modules/tasks/widgets/context-panel/browserToolbarMenu.ts b/apps/buddy/src/modules/tasks/widgets/context-panel/browserToolbarMenu.ts index eff3397c..93049d7b 100644 --- a/apps/buddy/src/modules/tasks/widgets/context-panel/browserToolbarMenu.ts +++ b/apps/buddy/src/modules/tasks/widgets/context-panel/browserToolbarMenu.ts @@ -7,6 +7,8 @@ export type BrowserToolbarMenuActionKey = | 'capture-screenshot' | 'enter-incognito' | 'exit-incognito' + | 'responsive-viewport' + | 'open-devtools' | 'open-external' | 'show-file-in-folder' @@ -20,6 +22,8 @@ interface BrowserToolbarMenuContext { busyAction: BrowserToolbarBusyAction | null controller: 'agent' | 'human' profileMode: DesktopBrowserProfileMode + responsive?: boolean + responsiveBusy?: boolean url: string } @@ -34,6 +38,14 @@ export function getBrowserToolbarMenuActions( labelKey: context.profileMode === 'incognito' ? 'desktop.context.browserExitIncognito' : 'desktop.context.browserEnterIncognito', + }, { + disabled: isBusy || !hasPage || context.controller === 'agent', + key: 'open-devtools', + labelKey: 'desktop.context.browserOpenDevTools', + }, { + disabled: isBusy || Boolean(context.responsiveBusy) || (!hasPage && !context.responsive) || context.controller === 'agent', + key: 'responsive-viewport', + labelKey: context.responsive ? 'desktop.context.browserViewportExit' : 'desktop.context.browserResponsive', }, { disabled: isBusy || !hasPage, key: 'open-external', diff --git a/apps/buddy/src/modules/tasks/widgets/context-panel/browserViewport.ts b/apps/buddy/src/modules/tasks/widgets/context-panel/browserViewport.ts new file mode 100644 index 00000000..84a67bb1 --- /dev/null +++ b/apps/buddy/src/modules/tasks/widgets/context-panel/browserViewport.ts @@ -0,0 +1,39 @@ +import type { DesktopBrowserViewport } from '@buddy-shared/browser/browserDesktopApi' + +export const DEFAULT_RESPONSIVE_VIEWPORT = { width: 393, height: 852, scale: 1 } as const +export const BROWSER_PREVIEW_ZOOMS = ['fit', '25', '50', '75', '100', '125', '150', '200'] as const +export type BrowserPreviewZoom = typeof BROWSER_PREVIEW_ZOOMS[number] +export type BrowserViewportSize = Pick + +// CSS viewport sizes, not UA, touch, DPR or operating-system emulation. +export const BROWSER_VIEWPORT_DEVICES = [ + { id: 'iphone-se', name: 'iPhone SE', width: 375, height: 667 }, + { id: 'iphone-13', name: 'iPhone 12 / 13 / 14', width: 390, height: 844 }, + { id: 'iphone-14-pro', name: 'iPhone 14 Pro', width: 393, height: 852 }, + { id: 'pixel-7', name: 'Pixel 7', width: 412, height: 915 }, + { id: 'ipad-air', name: 'iPad Air', width: 820, height: 1180 }, + { id: 'desktop', name: 'Desktop', width: 1280, height: 800 }, +] as const + +export function browserViewportDeviceForSize(size: BrowserViewportSize): string { + return BROWSER_VIEWPORT_DEVICES.find(device => device.width === size.width && device.height === size.height)?.id ?? 'custom' +} + +export function viewportDimensionValid(dimension: keyof BrowserViewportSize, value: number): boolean { + return Number.isInteger(value) && value >= 240 && value <= (dimension === 'width' ? 3_840 : 2_160) +} + +export function clampViewportSize(size: BrowserViewportSize): BrowserViewportSize { + return { + width: Math.round(Math.max(240, Math.min(3_840, size.width))), + height: Math.round(Math.max(240, Math.min(2_160, size.height))), + } +} + +export function browserPreviewScale(size: BrowserViewportSize, canvas: BrowserViewportSize, zoom: BrowserPreviewZoom): number { + if (zoom !== 'fit') + return Number(zoom) / 100 + if (canvas.width <= 32 || canvas.height <= 32) + return 1 + return Math.max(0.1, Math.min(1, (canvas.width - 32) / size.width, (canvas.height - 32) / size.height)) +} diff --git a/apps/buddy/src/modules/tasks/widgets/context-panel/useBrowserContextSurface.ts b/apps/buddy/src/modules/tasks/widgets/context-panel/useBrowserContextSurface.ts index f2720adc..ae640ddf 100644 --- a/apps/buddy/src/modules/tasks/widgets/context-panel/useBrowserContextSurface.ts +++ b/apps/buddy/src/modules/tasks/widgets/context-panel/useBrowserContextSurface.ts @@ -143,6 +143,18 @@ export function useBrowserContextSurface(options: UseBrowserContextSurfaceOption } } + async function openDevTools(): Promise { + const sessionId = state.value?.sessionId + if (!sessionId) + return false + try { + return await options.api.openDevTools(sessionId) + } + catch { + return false + } + } + async function openExternal(): Promise { const sessionId = state.value?.sessionId if (!sessionId || isOpeningExternal.value) @@ -204,6 +216,18 @@ export function useBrowserContextSurface(options: UseBrowserContextSurfaceOption } } + async function revealDownload(): Promise { + const sessionId = state.value?.sessionId + if (!sessionId || !state.value?.download?.path) + return false + try { + return await options.api.revealDownload(sessionId) + } + catch { + return false + } + } + function goBack(): Promise { return runSessionCommand(sessionId => options.api.goBack(sessionId)) } @@ -283,8 +307,10 @@ export function useBrowserContextSurface(options: UseBrowserContextSurfaceOption isSwitchingProfile: readonly(isSwitchingProfile), isTakingControl: readonly(isTakingControl), navigate, + openDevTools, openExternal, reload, + revealDownload, setProfileMode, setZoomFactor, showFileInFolder, diff --git a/apps/buddy/src/modules/tasks/widgets/context-panel/useBrowserResponsiveViewport.ts b/apps/buddy/src/modules/tasks/widgets/context-panel/useBrowserResponsiveViewport.ts new file mode 100644 index 00000000..8109f094 --- /dev/null +++ b/apps/buddy/src/modules/tasks/widgets/context-panel/useBrowserResponsiveViewport.ts @@ -0,0 +1,127 @@ +import type { DesktopBrowserApi, DesktopBrowserState, DesktopBrowserViewport } from '@buddy-shared/browser/browserDesktopApi' +import type { Ref } from 'vue' +import type { BrowserPreviewZoom, BrowserViewportSize } from './browserViewport' +import { computed, onBeforeUnmount, shallowRef, watch } from 'vue' +import { browserPreviewScale, DEFAULT_RESPONSIVE_VIEWPORT } from './browserViewport' + +export function useBrowserResponsiveViewport(options: { + api: Pick + state: Readonly> + visible: Readonly> + canvas: Readonly> + updateState: (state: DesktopBrowserState) => void + onError: () => void +}) { + const zoom = shallowRef('fit') + const busy = shallowRef(false) + const requested = shallowRef() + const viewport = computed(() => requested.value === undefined ? options.state.value?.viewport ?? null : requested.value) + const active = computed(() => Boolean(viewport.value)) + const disabled = computed(() => options.state.value?.controller !== 'human') + let remembered: DesktopBrowserViewport = { ...DEFAULT_RESPONSIVE_VIEWPORT } + let pending: { sessionId: string, version: number, viewport: DesktopBrowserViewport | null } | null = null + let version = 0 + let disposed = false + + watch(() => options.state.value?.sessionId, () => { + version += 1 + pending = null + requested.value = undefined + remembered = { ...options.state.value?.viewport ?? DEFAULT_RESPONSIVE_VIEWPORT } + zoom.value = 'fit' + }, { immediate: true }) + watch(disabled, (value) => { + if (value) { + version += 1 + pending = null + requested.value = undefined + } + }) + onBeforeUnmount(() => { + disposed = true + version += 1 + pending = null + }) + + function request(value: DesktopBrowserViewport | null): void { + const state = options.state.value + if (disposed || !state || disabled.value || !options.visible.value) + return + if (value) + remembered = { ...value } + if (JSON.stringify(viewport.value) === JSON.stringify(value)) + return + requested.value = value + pending = { sessionId: state.sessionId, viewport: value, version: ++version } + void drain() + } + + async function drain(): Promise { + if (busy.value) + return + busy.value = true + try { + while (pending) { + if (disposed) + break + const next = pending + pending = null + if (disabled.value || !options.visible.value || options.state.value?.sessionId !== next.sessionId) { + requested.value = undefined + continue + } + try { + const state = await options.api.setViewport(next.sessionId, next.viewport) + // A slow response must not overwrite a newer drag, tab, or control owner. + if (!disposed && next.version === version && options.state.value?.sessionId === next.sessionId) { + options.updateState(state) + requested.value = undefined + } + } + catch { + if (!disposed && next.version === version && options.state.value?.sessionId === next.sessionId) { + requested.value = undefined + options.onError() + } + } + } + } + finally { busy.value = false } + } + + function resize(size: BrowserViewportSize): void { + request({ ...size, scale: browserPreviewScale(size, options.canvas.value, zoom.value) }) + } + + function toggle(): void { + if (active.value) { + request(null) + } + else { + zoom.value = 'fit' + resize(remembered) + } + } + + function selectDevice(size: BrowserViewportSize): void { + zoom.value = 'fit' + resize(size) + } + + function setZoom(value: BrowserPreviewZoom): void { + zoom.value = value + if (viewport.value) + resize(viewport.value) + } + + watch(options.canvas, () => { + if (zoom.value === 'fit' && viewport.value && options.visible.value && !disabled.value) + resize(viewport.value) + }) + watch(options.visible, (visible) => { + if (visible && zoom.value === 'fit' && viewport.value && !disabled.value) + resize(viewport.value) + }) + + return { active, busy, disabled, viewport, zoom, resize, selectDevice, setZoom, toggle } +} diff --git a/apps/buddy/src/platform/browser/DesktopBrowserGuestHost.vue b/apps/buddy/src/platform/browser/DesktopBrowserGuestHost.vue index e36f88b7..1fdf3aef 100644 --- a/apps/buddy/src/platform/browser/DesktopBrowserGuestHost.vue +++ b/apps/buddy/src/platform/browser/DesktopBrowserGuestHost.vue @@ -22,6 +22,7 @@ let stopGuestsChanged: (() => void) | null = null interface BrowserGuestEntry { descriptor: DesktopBrowserGuestDescriptor element: WebviewTag + frame: HTMLElement layout: SurfaceLayoutLease onDestroyed: () => void onReady: () => void @@ -97,10 +98,15 @@ function createGuest(descriptor: DesktopBrowserGuestDescriptor): void { return const element = document.createElement('webview') as WebviewTag + const frame = document.createElement('div') + frame.className = 'desktop-browser-guest-host__frame' + Object.assign(frame.style, { overflow: 'hidden', pointerEvents: 'none' }) + Object.assign(element.style, { position: 'absolute', inset: '0', pointerEvents: 'auto', transformOrigin: 'top left' }) const entry: BrowserGuestEntry = { descriptor, element, - layout: props.layout.attach(element, { anchor: null, visible: false, interactive: true, layer: 'content' }), + frame, + layout: props.layout.attach(frame, { anchor: null, visible: false, interactive: true, childrenOnly: true, layer: 'content' }), onReady: () => { if (guests.get(descriptor.sessionId)?.element !== element) return @@ -127,24 +133,44 @@ function createGuest(descriptor: DesktopBrowserGuestDescriptor): void { element.className = 'desktop-browser-guest-host__guest' element.dataset.browserSessionId = descriptor.sessionId element.setAttribute('partition', descriptor.partition) + element.setAttribute('allowpopups', 'true') element.setAttribute('src', 'about:blank') element.addEventListener('destroyed', entry.onDestroyed) element.addEventListener('dom-ready', entry.onReady) guests.set(descriptor.sessionId, entry) - host.append(element) + frame.append(element) + host.append(frame) } function removeGuest(entry: BrowserGuestEntry): void { entry.layout.dispose() entry.element.removeEventListener('destroyed', entry.onDestroyed) entry.element.removeEventListener('dom-ready', entry.onReady) - entry.element.remove() + entry.frame.remove() } function scheduleLayout(): void { for (const [sessionId, entry] of guests) { const surface = surfaces.get(sessionId) - entry.layout.update({ anchor: surface?.element ?? null, visible: !!surface, interactive: true, layer: 'content' }) + entry.layout.update({ + anchor: surface?.element ?? null, + visible: !!surface, + interactive: true, + childrenOnly: true, + layer: 'content', + onLayout: (geometry) => { + const width = Number(surface?.element.dataset.browserViewportWidth) + const height = Number(surface?.element.dataset.browserViewportHeight) + const responsive = geometry.visible && width >= 240 && width <= 3_840 && height >= 240 && height <= 2_160 + // Keep the guest's logical bounds independent of the preview frame. Scaling only + // the composed surface preserves CSS breakpoints, input coordinates and screenshots. + Object.assign(entry.element.style, { + width: responsive ? `${width}px` : '100%', + height: responsive ? `${height}px` : '100%', + transform: responsive ? `scale(${geometry.width / width}, ${geometry.height / height})` : 'none', + }) + }, + }) } props.layout.invalidate() }