From ac778425027df1de0b735f315de7ce23808b3b9b Mon Sep 17 00:00:00 2001 From: Angus Bezzina <37071175+angusbezzina@users.noreply.github.com> Date: Thu, 28 May 2026 14:01:57 -0500 Subject: [PATCH 1/7] Add planning docs for in-app HTML file viewing One-pager + complete plan for displaying/presenting AI-generated HTML files in attn via a sandboxed iframe over the existing attn:// protocol (zero new deps). Security model: iframe sandbox + IPC-bridge fencing (subframe guard + main-frame capability token) + a CSP that allows external fonts/CDN animation libs while blocking local-file exfiltration. Co-Authored-By: Claude Opus 4.8 (1M context) --- planning/complete-plan.md | 181 ++++++++++++++++++++++++++++++++++++++ planning/one-pager.md | 89 +++++++++++++++++++ 2 files changed, 270 insertions(+) create mode 100644 planning/complete-plan.md create mode 100644 planning/one-pager.md diff --git a/planning/complete-plan.md b/planning/complete-plan.md new file mode 100644 index 0000000..ad6a63e --- /dev/null +++ b/planning/complete-plan.md @@ -0,0 +1,181 @@ +# Complete plan: In-app HTML file viewing + +## 1. Background & current state +attn (Rust wry/tao + Svelte 5) previews markdown/image/video/audio. HTML is +currently `Unsupported` and hidden. Goal: **showcase AI-generated HTML in-app** — +polished, self-contained pages that typically use inline JavaScript, custom web +fonts, and CDN-hosted animation libraries (GSAP, anime.js, Lottie) — while keeping +the user safe. + +Existing pieces this builds on: +- `attn://` custom protocol serves any local file (`src/main.rs:526-596`) and + already returns `text/html` for `.html/.htm` (`mime_from_extension`, `src/main.rs:1608`). +- Non-markdown viewers render via `markdownSourceUrl(path)` → + `attn://localhost/` (`web/src/lib/markdown-layer.ts:99`). +- File-type routing lives in `App.svelte`'s `mainContent()` snippet (~`web/src/App.svelte:2408`). +- `FileType` is mirrored in Rust (`src/files.rs`) and TS (`web/src/lib/types.ts:23`). +- Binary-size gate: 32 MiB (CLAUDE.md). This feature adds **no dependencies**. + +## 2. Goals / non-goals +**Goals:** display `.html/.htm` in-app (navigable from sidebar/search/tabs); +**run the page's JavaScript** and let it load remote fonts/animation libraries for +aesthetics; do so safely (page cannot write the disk, drive attn, or read other +local files); live-reload on disk change. + +**Non-goals (v1):** a strict offline-only mode for untrusted files (future +toggle), editing HTML, comments/suggestions/live-collab on HTML. + +## 3. Architecture +Render HTML in a sandboxed ` + + diff --git a/web/src/lib/ipc.ts b/web/src/lib/ipc.ts index b5e4c14..6d27e35 100644 --- a/web/src/lib/ipc.ts +++ b/web/src/lib/ipc.ts @@ -17,9 +17,28 @@ declare global { } } +// Per-session capability token, injected only into the main app frame's init +// payload. The daemon requires it on privileged messages, so scripts inside a +// sandboxed HtmlViewer iframe — which never receives the token — cannot drive +// the app. @see src/ipc.rs handle_message. +// +// Captured at module load AND settable via `setIpcToken`: App.svelte deletes +// `window.__attn_init__` after reading it once, so we cannot rely on reading it +// lazily inside `send()`. The setter makes this robust regardless of whether +// this module evaluates before or after that delete. +let ipcToken: string | undefined = ( + window as unknown as { __attn_init__?: { ipcToken?: string } } +).__attn_init__?.ipcToken; + +/** Capture the capability token before the init payload is cleared. */ +export function setIpcToken(token: string | undefined): void { + if (token) ipcToken = token; +} + function send(message: IpcMessage): void { if (window.ipc) { - window.ipc.postMessage(JSON.stringify(message)); + const payload = ipcToken ? { ...message, token: ipcToken } : message; + window.ipc.postMessage(JSON.stringify(payload)); } } diff --git a/web/src/lib/markdown-layer.ts b/web/src/lib/markdown-layer.ts index be6018b..f7dbd7b 100644 --- a/web/src/lib/markdown-layer.ts +++ b/web/src/lib/markdown-layer.ts @@ -5,6 +5,7 @@ const EXTENSIONS_BY_TYPE: Record = { image: ['png', 'jpg', 'jpeg', 'gif', 'svg', 'webp', 'bmp', 'ico'], video: ['mp4', 'webm', 'mov', 'avi'], audio: ['mp3', 'wav', 'ogg', 'm4a', 'flac', 'aac'], + html: ['html', 'htm'], unsupported: [], directory: [], }; diff --git a/web/src/lib/types.ts b/web/src/lib/types.ts index bfb7503..8588d7a 100644 --- a/web/src/lib/types.ts +++ b/web/src/lib/types.ts @@ -20,7 +20,7 @@ export interface PlanStructure { file_refs: string[]; } -export type FileType = 'markdown' | 'image' | 'video' | 'audio' | 'directory' | 'unsupported'; +export type FileType = 'markdown' | 'image' | 'video' | 'audio' | 'html' | 'directory' | 'unsupported'; export interface TreeNode { name: string; @@ -304,6 +304,13 @@ export interface InitPayload { contentMtimeMs?: number; contentBytes?: number; reviewProfile?: ReviewProfileInit; + /** + * Per-session capability token. Injected only into the main app frame's + * payload (never into an embedded HtmlViewer iframe). `ipc.send()` attaches + * it to privileged messages; the daemon rejects privileged IPC without it, + * so scripts in a sandboxed HTML file cannot drive the app. @see src/ipc.rs + */ + ipcToken?: string; } /** From 07e801586c1d4596b88ee2696540813032f6c463 Mon Sep 17 00:00:00 2001 From: Angus Bezzina <37071175+angusbezzina@users.noreply.github.com> Date: Thu, 28 May 2026 16:18:37 -0500 Subject: [PATCH 4/7] HTML viewer: use shared breadcrumb header + open-in-browser button Drop HtmlViewer's bespoke header bar so HTML files share the exact same PathBreadcrumb chrome as every other file type. Add an "Open in browser" icon button (Lucide external-link) to the breadcrumb's top-right cluster, shown for HTML files (where the Share button sits for markdown). Verified: plan.html renders in the sandboxed iframe with the breadcrumb header, 1 open-in-browser button, 0 share buttons, and no leftover header bar. Co-Authored-By: Claude Opus 4.8 (1M context) --- web/src/App.svelte | 1 + web/src/lib/HtmlViewer.svelte | 66 +++++++++++-------------------- web/src/lib/PathBreadcrumb.svelte | 18 +++++++++ 3 files changed, 42 insertions(+), 43 deletions(-) diff --git a/web/src/App.svelte b/web/src/App.svelte index 4191be7..3b18138 100644 --- a/web/src/App.svelte +++ b/web/src/App.svelte @@ -2351,6 +2351,7 @@ onNavigate={(dir) => openPath(dir, inferFileTypeFromTree(dir))} onShare={showBreadcrumbShare ? openShareDialog : undefined} shareEnabled={showBreadcrumbShare} + onOpenInBrowser={activeFileType === 'html' ? () => openExternal(activePath) : undefined} /> {#if !hasSidebar} diff --git a/web/src/lib/HtmlViewer.svelte b/web/src/lib/HtmlViewer.svelte index 872c845..9de6ba3 100644 --- a/web/src/lib/HtmlViewer.svelte +++ b/web/src/lib/HtmlViewer.svelte @@ -1,7 +1,5 @@ -
-
- {fileName} - -
- -
- {#if loading} -
- Loading… -
- {/if} - - -
+ Loading… +
+ {/if} + + diff --git a/web/src/lib/PathBreadcrumb.svelte b/web/src/lib/PathBreadcrumb.svelte index 56c1c5e..7dffad3 100644 --- a/web/src/lib/PathBreadcrumb.svelte +++ b/web/src/lib/PathBreadcrumb.svelte @@ -9,6 +9,7 @@ } from '$lib/components/ui/breadcrumb'; import { dragWindow } from './ipc'; import Share2 from '@lucide/svelte/icons/share-2'; + import ExternalLink from '@lucide/svelte/icons/external-link'; interface Props { path: string; @@ -16,6 +17,9 @@ onNavigate?: (path: string) => void; onShare?: () => void; shareEnabled?: boolean; + /** When set, shows an "open in browser" icon button in the header cluster + * (used for HTML files, which can't be shared but can be opened externally). */ + onOpenInBrowser?: () => void; avoidWindowControls?: boolean; fixed?: boolean; topOffsetPx?: number; @@ -28,6 +32,7 @@ onNavigate, onShare, shareEnabled = false, + onOpenInBrowser, avoidWindowControls = false, fixed = false, topOffsetPx = 0, @@ -110,6 +115,19 @@ {:else} {/if} + {#if onOpenInBrowser} + + {/if} {#if onShare}