Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 2 additions & 8 deletions apps/desktop/src/app/chat/composer/status-stack/preview-row.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
import { Tip } from '@/components/ui/tooltip'
import { useI18n } from '@/i18n'
import { normalizeOrLocalPreviewTarget } from '@/lib/local-preview'
import { normalizeOrLocalPreviewTarget, openPreviewTargetInBrowser } from '@/lib/local-preview'
import { cn } from '@/lib/utils'
import { PREVIEW_PANE_ID } from '@/store/layout'
import { notifyError } from '@/store/notifications'
Expand Down Expand Up @@ -61,13 +61,7 @@ export const PreviewStatusRow = memo(function PreviewStatusRow({ item, onDismiss

const openInBrowser = async () => {
try {
const bridge = window.hermesDesktop?.openPreviewInBrowser

if (!bridge) {
throw new Error('Desktop preview browser bridge is unavailable')
}

await bridge((await resolveTarget()).url)
await openPreviewTargetInBrowser(await resolveTarget())
} catch (error) {
notifyError(error, t.preview.unavailable)
}
Expand Down
49 changes: 48 additions & 1 deletion apps/desktop/src/app/chat/right-rail/preview-pane.test.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { act, cleanup, render } from '@testing-library/react'
import { act, cleanup, fireEvent, render } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'

import { $connection } from '@/store/session'
Expand Down Expand Up @@ -86,4 +86,51 @@ describe('PreviewPane console state', () => {

expect(setTitlebarToolGroup).toHaveBeenCalledTimes(initialCalls)
})

it('renders authenticated remote HTML safely and honors source mode', async () => {
const dataUrl = `data:text/html;base64,${btoa('<h1>remote</h1>')}`
const setTitlebarToolGroup = vi.fn()

const target = {
dataUrl,
kind: 'file' as const,
label: 'report.html',
path: '/srv/report.html',
previewKind: 'html' as const,
source: '/srv/report.html',
url: 'file:///srv/report.html'
}

let rendered!: ReturnType<typeof render>
await act(async () => {
rendered = render(<PreviewPane setTitlebarToolGroup={setTitlebarToolGroup} target={target} />)
})

const iframe = rendered.container.querySelector('iframe')
const tools = setTitlebarToolGroup.mock.calls.at(-1)?.[1] ?? []

expect(rendered.container.querySelector('webview')).toBeNull()
expect(iframe?.getAttribute('sandbox')).toBe('')
expect(iframe?.getAttribute('referrerpolicy')).toBe('no-referrer')
expect(iframe?.getAttribute('srcdoc')).toContain(`default-src 'none'`)
expect(iframe?.getAttribute('srcdoc')).toContain('<h1>remote</h1>')
expect(tools).not.toEqual(expect.arrayContaining([expect.objectContaining({ id: 'preview-devtools' })]))
expect(rendered.container.textContent).not.toContain(dataUrl)

await act(async () => {
rendered.rerender(
<PreviewPane
setTitlebarToolGroup={setTitlebarToolGroup}
target={{ ...target, dataUrl: undefined, renderMode: 'source', transient: true }}
/>
)
})

expect(rendered.container.querySelector('iframe')).toBeNull()
const sourceLink = rendered.container.querySelector('a')

expect(sourceLink?.getAttribute('href')).toBeNull()
expect(sourceLink?.getAttribute('target')).toBeNull()
expect(fireEvent.click(sourceLink!)).toBe(false)
})
})
44 changes: 35 additions & 9 deletions apps/desktop/src/app/chat/right-rail/preview-pane.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
import { useStore } from '@nanostores/react'
import type { PointerEvent as ReactPointerEvent } from 'react'
import { useCallback, useEffect, useRef, useState } from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'

import type { SetTitlebarToolGroup, TitlebarTool } from '@/app/shell/titlebar-controls'
import { Tip } from '@/components/ui/tooltip'
import { type Translations, useI18n } from '@/i18n'
import { isDesktopFsRemoteMode } from '@/lib/desktop-fs'
import { Bug } from '@/lib/icons'
import { openPreviewTargetInBrowser, remoteHtmlPreviewDocument } from '@/lib/local-preview'
import { rafCoalesce } from '@/lib/raf-coalesce'
import { cn } from '@/lib/utils'
import { notify, notifyError } from '@/store/notifications'
Expand Down Expand Up @@ -154,6 +155,16 @@ export function PreviewPane({
target.kind !== 'artifact' &&
(target.kind === 'url' || (target.previewKind === 'html' && target.renderMode !== 'source'))

const isRemoteHtmlTarget =
target.kind === 'file' && target.previewKind === 'html' && Boolean(target.dataUrl || target.transient)

const isRemoteHtml = isRemoteHtmlTarget && target.renderMode !== 'source' && Boolean(target.dataUrl)

const remoteHtmlDocument = useMemo(
() => (isRemoteHtml ? remoteHtmlPreviewDocument(target.dataUrl!) : null),
[isRemoteHtml, target.dataUrl]
)

const currentLabel = compactUrl(currentUrl)

const previewLabel =
Expand Down Expand Up @@ -301,7 +312,7 @@ export function PreviewPane({
}

const tools: TitlebarTool[] = [
...(isWebPreview
...(isWebPreview && !isRemoteHtml
? [
{
active: consoleOpen,
Expand All @@ -324,7 +335,7 @@ export function PreviewPane({
setTitlebarToolGroup(TITLEBAR_GROUP_ID, tools)

return () => setTitlebarToolGroup(TITLEBAR_GROUP_ID, [])
}, [consoleOpen, consoleState, copy, devtoolsOpen, isWebPreview, setTitlebarToolGroup, toggleDevTools])
}, [consoleOpen, consoleState, copy, devtoolsOpen, isRemoteHtml, isWebPreview, setTitlebarToolGroup, toggleDevTools])

// eslint-disable-next-line no-restricted-syntax -- legitimate non-atom ref write (see eslint rule comment)
useEffect(() => {
Expand Down Expand Up @@ -524,7 +535,7 @@ export function PreviewPane({
consoleState.reset()
setLoading(true)

if (!isWebPreview) {
if (!isWebPreview || isRemoteHtml) {
setLoading(false)

return
Expand Down Expand Up @@ -617,7 +628,7 @@ export function PreviewPane({
webview.removeEventListener('did-stop-loading', onStop)
webview.remove()
}
}, [appendConsoleEntry, consoleState, copy, isWebPreview, target.url])
}, [appendConsoleEntry, consoleState, copy, isRemoteHtml, isWebPreview, target.url])

return (
<aside className="relative flex h-full w-full min-w-0 flex-col overflow-hidden bg-transparent text-muted-foreground">
Expand All @@ -628,9 +639,15 @@ export function PreviewPane({
<Tip label={copy.openTarget(currentUrl)}>
<a
className="pointer-events-auto inline max-w-full truncate text-left text-xs font-medium text-foreground underline-offset-4 decoration-current/20 transition-colors hover:text-primary hover:underline"
href={currentUrl}
href={isRemoteHtmlTarget ? undefined : currentUrl}
onClick={event => {
if (isRemoteHtmlTarget) {
event.preventDefault()
void openPreviewTargetInBrowser(target).catch(error => notifyError(error, t.preview.unavailable))
}
}}
rel="noreferrer"
target="_blank"
target={isRemoteHtmlTarget ? undefined : '_blank'}
>
{previewLabel || copy.fallbackTitle}
</a>
Expand All @@ -646,10 +663,19 @@ export function PreviewPane({
<div
className={cn(
'absolute inset-0 flex bg-transparent',
(!isWebPreview || loadError) && 'pointer-events-none opacity-0'
(isRemoteHtml || !isWebPreview || loadError) && 'pointer-events-none opacity-0'
)}
ref={hostRef}
/>
{isRemoteHtml && (
<iframe
className="absolute inset-0 size-full border-0 bg-white"
referrerPolicy="no-referrer"
sandbox=""
srcDoc={remoteHtmlDocument || ''}
title={target.label || copy.fallbackTitle}
/>
)}
{!isWebPreview &&
(target.kind === 'artifact' ? (
<ArtifactPreview target={target} />
Expand All @@ -666,7 +692,7 @@ export function PreviewPane({
/>
)}

{isWebPreview && consoleOpen && (
{isWebPreview && !isRemoteHtml && consoleOpen && (
<PreviewConsolePanel
consoleBodyRef={consoleBodyRef}
consoleShouldStickRef={consoleShouldStickRef}
Expand Down
170 changes: 170 additions & 0 deletions apps/desktop/src/lib/local-preview.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'

const { readDesktopFileDataUrl } = vi.hoisted(() => ({ readDesktopFileDataUrl: vi.fn() }))

vi.mock('@/lib/desktop-fs', () => ({
isDesktopFsRemoteMode: () => true,
readDesktopFileDataUrl,
readDesktopFileText: vi.fn()
}))

import {
localPreviewTarget,
normalizeOrLocalPreviewTarget,
openPreviewTargetInBrowser,
remoteHtmlPreviewDocument,
validatedRemoteHtmlDataUrl
} from './local-preview'

const remoteTarget = {
kind: 'file' as const,
label: 'report.html',
path: '/srv/report.html',
previewKind: 'html' as const,
source: '/srv/report.html',
url: 'file:///srv/report.html'
}

describe('remote HTML previews', () => {
beforeEach(() => {
vi.clearAllMocks()
window.hermesDesktop = {
normalizePreviewTarget: vi.fn(async () => remoteTarget)
} as never
})

it('loads bounded authenticated bytes while retaining the canonical file URL', async () => {
const dataUrl = `data:text/html;base64,${btoa('<h1>remote</h1>')}`
readDesktopFileDataUrl.mockResolvedValue(dataUrl)

await expect(normalizeOrLocalPreviewTarget('/srv/report.html')).resolves.toEqual({
...remoteTarget,
dataUrl
})
expect(readDesktopFileDataUrl).toHaveBeenCalledWith('/srv/report.html')
})

it('falls back to source mode when the transport is not canonical HTML', async () => {
readDesktopFileDataUrl.mockResolvedValue('data:text/plain;base64,SGVsbG8=')

await expect(normalizeOrLocalPreviewTarget('/srv/report.html')).resolves.toMatchObject({
renderMode: 'source',
transient: true,
url: 'file:///srv/report.html'
})
})

it('rejects malformed base64', () => {
expect(validatedRemoteHtmlDataUrl('data:text/html;base64,SGVsbG8=')).toBe('data:text/html;base64,SGVsbG8=')
expect(validatedRemoteHtmlDataUrl('data:text/html;base64,SGVsbG8')).toBeNull()
expect(validatedRemoteHtmlDataUrl('data:text/html;base64,SGVsbG8%3D')).toBeNull()
})

it('wraps remote HTML in a deny-by-default content policy', () => {
const html =
'<meta http-equiv="refresh" content="0;url=https://example.test"><a href="https://example.test">remote</a>'

const document = remoteHtmlPreviewDocument(`data:text/html;base64,${btoa(html)}`)

expect(document).toContain(`default-src 'none'`)
expect(document).toContain(`form-action 'none'`)
expect(document).toContain('<a>remote</a>')
expect(document).not.toContain('refresh')
expect(document).not.toContain('https://example.test')
})

it('strips scripts from remote HTML', () => {
const html =
'<p>safe</p><script>document.body.textContent = "SCRIPT-RAN"</script><template><script>TEMPLATE-SCRIPT</script></template>'

const document = remoteHtmlPreviewDocument(`data:text/html;base64,${btoa(html)}`)

expect(document).toContain('<p>safe</p>')
expect(document).not.toContain('<script')
expect(document).not.toContain('SCRIPT-RAN')
expect(document).not.toContain('TEMPLATE-SCRIPT')
})

it('does not create scripts when sanitized HTML is reparsed', () => {
const html = '<form><math><mtext></form><form><mglyph><style></math><script>MUTATION-SCRIPT</script>'

const sanitized = remoteHtmlPreviewDocument(`data:text/html;base64,${btoa(html)}`)

expect(sanitized).not.toContain('<script')
expect(new DOMParser().parseFromString(sanitized ?? '', 'text/html').querySelector('script')).toBeNull()
})

it('decodes remote HTML as UTF-8', () => {
const html = '<p>cafΓ© πŸ˜€</p>'
const payload = btoa(String.fromCharCode(...new TextEncoder().encode(html)))

expect(remoteHtmlPreviewDocument(`data:text/html;base64,${payload}`)).toContain(html)
})

it('stages remote HTML before opening it in the system browser', async () => {
const dataUrl = `data:text/html;base64,${btoa('<h1>remote</h1>')}`
const saveImageBuffer = vi.fn(async (_data: ArrayBuffer | Uint8Array, _ext: string) => '/tmp/report #1?.html')
const openPreviewInBrowser = vi.fn(async () => undefined)
window.hermesDesktop = { openPreviewInBrowser, saveImageBuffer } as never

await openPreviewTargetInBrowser({ ...remoteTarget, dataUrl })

expect(new TextDecoder().decode(saveImageBuffer.mock.calls[0]?.[0])).toBe('<h1>remote</h1>')
expect(saveImageBuffer).toHaveBeenCalledWith(expect.any(Uint8Array), '.html')
expect(openPreviewInBrowser).toHaveBeenCalledWith('file:///tmp/report%20%231%3F.html')
})

it('serializes UNC staging paths as file URLs', async () => {
const dataUrl = `data:text/html;base64,${btoa('<h1>remote</h1>')}`
const saveImageBuffer = vi.fn(async () => '\\\\server\\share\\report #1.html')
const openPreviewInBrowser = vi.fn(async () => undefined)
window.hermesDesktop = { openPreviewInBrowser, saveImageBuffer } as never

await openPreviewTargetInBrowser({ ...remoteTarget, dataUrl })

expect(openPreviewInBrowser).toHaveBeenCalledWith('file://server/share/report%20%231.html')
})

it('preserves backslashes in POSIX local file paths', () => {
expect(localPreviewTarget('/tmp/report\\draft.html')?.url).toBe('file:///tmp/report%5Cdraft.html')
})

it('preserves POSIX double-slash file paths', () => {
expect(localPreviewTarget('//srv/share/report #1?.html')?.url).toBe('file:////srv/share/report%20%231%3F.html')
})

it('opens ordinary targets without staging them', async () => {
const openPreviewInBrowser = vi.fn(async () => undefined)
const saveImageBuffer = vi.fn()
window.hermesDesktop = { openPreviewInBrowser, saveImageBuffer } as never

await openPreviewTargetInBrowser(remoteTarget)

expect(saveImageBuffer).not.toHaveBeenCalled()
expect(openPreviewInBrowser).toHaveBeenCalledWith(remoteTarget.url)
})

it('keeps local HTML source browser opens on their existing path', async () => {
const openPreviewInBrowser = vi.fn(async () => undefined)
window.hermesDesktop = { openPreviewInBrowser } as never

await openPreviewTargetInBrowser({
...remoteTarget,
renderMode: 'source',
source: '/tmp/local.html',
url: 'file:///tmp/local.html'
})

expect(openPreviewInBrowser).toHaveBeenCalledWith('file:///tmp/local.html')
})

it('does not send failed remote HTML paths to the local browser', async () => {
const openPreviewInBrowser = vi.fn(async () => undefined)
window.hermesDesktop = { openPreviewInBrowser } as never

await expect(
openPreviewTargetInBrowser({ ...remoteTarget, renderMode: 'source', transient: true })
).rejects.toThrow('Remote HTML preview could not be loaded')
expect(openPreviewInBrowser).not.toHaveBeenCalled()
})
})
Loading