mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-05-11 00:02:38 +02:00
feat: serve workspace files via app:// protocol and add image viewer
This commit is contained in:
parent
ede98f5378
commit
0d9cf71947
4 changed files with 185 additions and 14 deletions
93
apps/x/PLAN-MEDIA.md
Normal file
93
apps/x/PLAN-MEDIA.md
Normal file
|
|
@ -0,0 +1,93 @@
|
||||||
|
# Media File Rendering — Implementation Plan
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
Render PDFs, images, and videos in the knowledge view, alongside the existing HTML viewer.
|
||||||
|
|
||||||
|
## Foundation
|
||||||
|
All three file types are served through a single custom `app://` protocol registered in the Electron main process. This avoids `file://` (elevated privileges in Electron) and supports byte-range streaming (needed for video seeking).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 1 — Register `app://` custom protocol
|
||||||
|
|
||||||
|
### What
|
||||||
|
Main-process protocol handler that serves any workspace-relative file with path traversal guard.
|
||||||
|
|
||||||
|
### Work
|
||||||
|
1. In `apps/main/src/main.ts`, before `app.whenReady()`:
|
||||||
|
- `protocol.registerSchemesAsPrivileged` for `app` with `{ standard: true, secure: true, supportFetchAPI: true, stream: true }`
|
||||||
|
2. After `app.whenReady()`:
|
||||||
|
- `protocol.handle('app', ...)` — parses URL, decodes path, resolves against workspace root using the existing `resolveWorkspacePath`, returns `net.fetch(pathToFileURL(absPath))`
|
||||||
|
- On invalid/outside-workspace path: return 403
|
||||||
|
|
||||||
|
### Test ✅
|
||||||
|
- DevTools: `fetch('app://local/knowledge/<known-file>').then(r => r.status)` → 200
|
||||||
|
- DevTools: `fetch('app://local/../../etc/passwd')` → 403
|
||||||
|
- Verify response includes correct mime type for known extensions
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 2 — Image renderer
|
||||||
|
|
||||||
|
### What
|
||||||
|
`ImageFileViewer` component renders supported images via `<img src="app://local/<path>">`.
|
||||||
|
|
||||||
|
### Work
|
||||||
|
1. New file `apps/renderer/src/components/image-file-viewer.tsx`
|
||||||
|
2. Detects: `.png .jpg .jpeg .webp .gif .svg .avif .bmp .ico`
|
||||||
|
3. Centered, object-contain layout, dark/light bg
|
||||||
|
4. HEIC/HEIF (or any other unsupported decode failure) → fall back to "Open in system" via `shell:openPath`
|
||||||
|
5. Loading state and error state mirroring HtmlFileViewer
|
||||||
|
6. Wire into `App.tsx` render switch before the `<pre>` fallback
|
||||||
|
|
||||||
|
### Test ✅
|
||||||
|
- Open `.png`, `.jpg`, `.svg`, `.webp` files → renders correctly
|
||||||
|
- Open a `.heic` file (or rename a non-image to `.png` to force decode failure) → shows fallback
|
||||||
|
- Switch between image files rapidly → no stale image, no flicker
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 3 — Video renderer
|
||||||
|
|
||||||
|
### What
|
||||||
|
`VideoFileViewer` component using native `<video>` tag.
|
||||||
|
|
||||||
|
### Work
|
||||||
|
1. New file `apps/renderer/src/components/video-file-viewer.tsx`
|
||||||
|
2. Detects: `.mp4 .mov .webm .m4v`
|
||||||
|
3. `<video controls src="app://local/<path>" />` — full width, max height
|
||||||
|
4. Loading and error states
|
||||||
|
5. Wire into `App.tsx` render switch
|
||||||
|
|
||||||
|
### Test ✅
|
||||||
|
- Open a 50MB+ MP4 → starts playing, scrubber works, can seek to middle without re-downloading
|
||||||
|
- Verify byte-range requests in DevTools Network tab (Range header on requests)
|
||||||
|
- Switch away mid-playback → video stops cleanly, no leaked audio
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 4 — PDF renderer
|
||||||
|
|
||||||
|
### What
|
||||||
|
`PdfFileViewer` using Chromium's built-in PDFium via `<iframe src="app://local/<path>">`.
|
||||||
|
|
||||||
|
### Work
|
||||||
|
1. New file `apps/renderer/src/components/pdf-file-viewer.tsx`
|
||||||
|
2. Detects: `.pdf`
|
||||||
|
3. `<iframe src="app://local/<path>" className="w-full h-full" />` — Chromium auto-detects PDF mime type and uses PDFium plugin
|
||||||
|
4. Confirm `webPreferences.plugins: true` is set on the BrowserWindow (required for PDFium to activate)
|
||||||
|
5. Wire into `App.tsx` render switch
|
||||||
|
|
||||||
|
### Test ✅
|
||||||
|
- Open multi-page PDF → renders with native zoom/scroll/print toolbar
|
||||||
|
- Open password-protected PDF → Chromium prompts for password (built-in)
|
||||||
|
- Switch between PDFs → no stale content
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Out of scope for this branch
|
||||||
|
- DOCX rendering (requires `docx-preview` library — separate PR)
|
||||||
|
- Split-pane resizable layout
|
||||||
|
- Audio (the existing `AudioFileCard` is reused if needed)
|
||||||
|
- Annotation/highlighting on PDFs (would require `pdfjs-dist`)
|
||||||
|
- Iframe persistence cache for HTML viewer (separate optimization)
|
||||||
|
|
@ -31,6 +31,7 @@ import { shutdown as shutdownAnalytics } from "@x/core/dist/analytics/posthog.js
|
||||||
import { identifyIfSignedIn } from "@x/core/dist/analytics/identify.js";
|
import { identifyIfSignedIn } from "@x/core/dist/analytics/identify.js";
|
||||||
|
|
||||||
import { initConfigs } from "@x/core/dist/config/initConfigs.js";
|
import { initConfigs } from "@x/core/dist/config/initConfigs.js";
|
||||||
|
import { resolveWorkspacePath } from "@x/core/dist/workspace/workspace.js";
|
||||||
import started from "electron-squirrel-startup";
|
import started from "electron-squirrel-startup";
|
||||||
import { execSync, exec, execFileSync } from "node:child_process";
|
import { execSync, exec, execFileSync } from "node:child_process";
|
||||||
import { promisify } from "node:util";
|
import { promisify } from "node:util";
|
||||||
|
|
@ -133,16 +134,29 @@ const rendererPath = app.isPackaged
|
||||||
: path.join(__dirname, "../../../renderer/dist"); // Development
|
: path.join(__dirname, "../../../renderer/dist"); // Development
|
||||||
console.log("rendererPath", rendererPath);
|
console.log("rendererPath", rendererPath);
|
||||||
|
|
||||||
// Register custom protocol for serving built renderer files in production.
|
// Register custom protocol for serving built renderer files in production
|
||||||
// This keeps SPA routes working when users deep link into the packaged app.
|
// AND for serving local workspace files to the renderer (images, PDFs, video).
|
||||||
|
//
|
||||||
|
// app://workspace/<rel-path> → workspace file (path-traversal guarded)
|
||||||
|
// app://<anything-else>/... → renderer SPA (existing behavior)
|
||||||
function registerAppProtocol() {
|
function registerAppProtocol() {
|
||||||
protocol.handle("app", (request) => {
|
protocol.handle("app", (request) => {
|
||||||
const url = new URL(request.url);
|
const url = new URL(request.url);
|
||||||
|
|
||||||
// url.pathname starts with "/"
|
// Workspace files: app://workspace/<rel-path>
|
||||||
let urlPath = url.pathname;
|
if (url.host === "workspace") {
|
||||||
|
try {
|
||||||
|
const relPath = decodeURIComponent(url.pathname).replace(/^\/+/, "");
|
||||||
|
if (!relPath) return new Response("Not Found", { status: 404 });
|
||||||
|
const absPath = resolveWorkspacePath(relPath);
|
||||||
|
return net.fetch(pathToFileURL(absPath).toString());
|
||||||
|
} catch {
|
||||||
|
return new Response("Forbidden", { status: 403 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// If it's "/" or a SPA route (no extension), serve index.html
|
// Renderer SPA — existing logic
|
||||||
|
let urlPath = url.pathname;
|
||||||
if (urlPath === "/" || !path.extname(urlPath)) {
|
if (urlPath === "/" || !path.extname(urlPath)) {
|
||||||
urlPath = "/index.html";
|
urlPath = "/index.html";
|
||||||
}
|
}
|
||||||
|
|
@ -161,8 +175,8 @@ protocol.registerSchemesAsPrivileged([
|
||||||
supportFetchAPI: true,
|
supportFetchAPI: true,
|
||||||
corsEnabled: true,
|
corsEnabled: true,
|
||||||
allowServiceWorkers: true,
|
allowServiceWorkers: true,
|
||||||
// optional but often helpful:
|
// Required for byte-range requests so <video> seeking works.
|
||||||
// stream: true,
|
stream: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
@ -251,10 +265,10 @@ function createWindow() {
|
||||||
}
|
}
|
||||||
|
|
||||||
app.whenReady().then(async () => {
|
app.whenReady().then(async () => {
|
||||||
// Register custom protocol before creating window (for production builds)
|
// Register custom protocol before creating window.
|
||||||
if (app.isPackaged) {
|
// In production this serves the renderer SPA; in dev (and prod) it also
|
||||||
registerAppProtocol();
|
// serves workspace files via app://workspace/<rel-path> for media previews.
|
||||||
}
|
registerAppProtocol();
|
||||||
|
|
||||||
// Initialize auto-updater (only in production)
|
// Initialize auto-updater (only in production)
|
||||||
if (app.isPackaged) {
|
if (app.isPackaged) {
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ import { ChatMessageAttachments } from '@/components/chat-message-attachments'
|
||||||
import { GraphView, type GraphEdge, type GraphNode } from '@/components/graph-view';
|
import { GraphView, type GraphEdge, type GraphNode } from '@/components/graph-view';
|
||||||
import { BasesView, type BaseConfig, DEFAULT_BASE_CONFIG } from '@/components/bases-view';
|
import { BasesView, type BaseConfig, DEFAULT_BASE_CONFIG } from '@/components/bases-view';
|
||||||
import { HtmlFileViewer } from '@/components/html-file-viewer';
|
import { HtmlFileViewer } from '@/components/html-file-viewer';
|
||||||
|
import { ImageFileViewer } from '@/components/image-file-viewer';
|
||||||
import { useDebounce } from './hooks/use-debounce';
|
import { useDebounce } from './hooks/use-debounce';
|
||||||
import { SidebarContentPanel } from '@/components/sidebar-content';
|
import { SidebarContentPanel } from '@/components/sidebar-content';
|
||||||
import { SuggestedTopicsView } from '@/components/suggested-topics-view';
|
import { SuggestedTopicsView } from '@/components/suggested-topics-view';
|
||||||
|
|
@ -1425,9 +1426,10 @@ function App() {
|
||||||
}
|
}
|
||||||
const requestId = (fileLoadRequestIdRef.current += 1)
|
const requestId = (fileLoadRequestIdRef.current += 1)
|
||||||
const pathToLoad = selectedPath
|
const pathToLoad = selectedPath
|
||||||
// HtmlFileViewer self-loads (with size check, error states, etc.)
|
// Media viewers (HTML, image, video, PDF) self-load via app:// protocol.
|
||||||
// Skip the generic loader so we don't double-fetch large files.
|
// Skip the generic UTF-8 loader so we don't trash fileContent with binary
|
||||||
if (pathToLoad.toLowerCase().endsWith('.html') || pathToLoad.toLowerCase().endsWith('.htm')) {
|
// bytes or double-fetch large files.
|
||||||
|
if (/\.(html?|png|jpe?g|webp|gif|svg|avif|bmp|ico|mp4|mov|webm|m4v|pdf)$/i.test(pathToLoad)) {
|
||||||
setFileContent('')
|
setFileContent('')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -4830,6 +4832,10 @@ function App() {
|
||||||
<div className="flex-1 min-h-0 overflow-hidden">
|
<div className="flex-1 min-h-0 overflow-hidden">
|
||||||
<HtmlFileViewer path={selectedPath} />
|
<HtmlFileViewer path={selectedPath} />
|
||||||
</div>
|
</div>
|
||||||
|
) : selectedPath && /\.(png|jpe?g|webp|gif|svg|avif|bmp|ico)$/i.test(selectedPath) ? (
|
||||||
|
<div className="flex-1 min-h-0 overflow-hidden">
|
||||||
|
<ImageFileViewer path={selectedPath} />
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex-1 overflow-auto p-4">
|
<div className="flex-1 overflow-auto p-4">
|
||||||
<pre className="text-sm font-mono text-foreground whitespace-pre-wrap">
|
<pre className="text-sm font-mono text-foreground whitespace-pre-wrap">
|
||||||
|
|
|
||||||
58
apps/x/apps/renderer/src/components/image-file-viewer.tsx
Normal file
58
apps/x/apps/renderer/src/components/image-file-viewer.tsx
Normal file
|
|
@ -0,0 +1,58 @@
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { ExternalLinkIcon, FileImageIcon, Loader2Icon } from 'lucide-react'
|
||||||
|
|
||||||
|
interface ImageFileViewerProps {
|
||||||
|
path: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type State = 'loading' | 'loaded' | 'error'
|
||||||
|
|
||||||
|
export function ImageFileViewer({ path }: ImageFileViewerProps) {
|
||||||
|
const [state, setState] = useState<State>('loading')
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setState('loading')
|
||||||
|
}, [path])
|
||||||
|
|
||||||
|
const src = `app://workspace/${path.split('/').map(encodeURIComponent).join('/')}`
|
||||||
|
|
||||||
|
if (state === 'error') {
|
||||||
|
return (
|
||||||
|
<div className="flex h-full w-full flex-col items-center justify-center gap-3 px-6 text-center text-muted-foreground">
|
||||||
|
<FileImageIcon className="size-6" />
|
||||||
|
<p className="text-sm font-medium text-foreground">Cannot preview this image</p>
|
||||||
|
<p className="max-w-md text-xs">The format may be unsupported (e.g. HEIC on Windows).</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
void window.ipc.invoke('shell:openPath', { path })
|
||||||
|
}}
|
||||||
|
className="inline-flex items-center gap-1.5 rounded-md border border-border bg-background px-3 py-1.5 text-xs font-medium text-foreground hover:bg-accent"
|
||||||
|
>
|
||||||
|
<ExternalLinkIcon className="size-3.5" />
|
||||||
|
Open in system
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative flex h-full w-full items-center justify-center bg-muted/30">
|
||||||
|
<img
|
||||||
|
key={path}
|
||||||
|
src={src}
|
||||||
|
alt={path}
|
||||||
|
className="max-h-full max-w-full object-contain"
|
||||||
|
onLoad={() => setState('loaded')}
|
||||||
|
onError={() => setState('error')}
|
||||||
|
style={state === 'loading' ? { opacity: 0 } : undefined}
|
||||||
|
/>
|
||||||
|
{state === 'loading' && (
|
||||||
|
<div className="absolute inset-0 flex flex-col items-center justify-center gap-3 text-muted-foreground">
|
||||||
|
<Loader2Icon className="size-6 animate-spin" />
|
||||||
|
<p className="text-sm">Loading image…</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue