hermes-agent/apps/desktop/src/lib/media.ts
brooklyn! c6b0eb4de0
Some checks failed
Deploy Site / deploy-vercel (push) Waiting to run
Deploy Site / deploy-docs (push) Waiting to run
Docker Build and Publish / build-amd64 (push) Waiting to run
Docker Build and Publish / build-arm64 (push) Waiting to run
Docker Build and Publish / merge (push) Blocked by required conditions
Lint (ruff + ty) / ruff + ty diff (push) Waiting to run
Lint (ruff + ty) / ruff enforcement (blocking) (push) Waiting to run
Lint (ruff + ty) / Windows footguns (blocking) (push) Waiting to run
Tests / test (1) (push) Waiting to run
Tests / test (2) (push) Waiting to run
Tests / test (3) (push) Waiting to run
Tests / test (4) (push) Waiting to run
Tests / test (5) (push) Waiting to run
Tests / test (6) (push) Waiting to run
Tests / save-durations (push) Blocked by required conditions
Tests / e2e (push) Waiting to run
Typecheck / typecheck (apps/bootstrap-installer) (push) Waiting to run
Typecheck / typecheck (apps/desktop) (push) Waiting to run
Typecheck / typecheck (apps/shared) (push) Waiting to run
Typecheck / typecheck (ui-tui) (push) Waiting to run
Typecheck / typecheck (web) (push) Waiting to run
Typecheck / desktop-build (push) Waiting to run
Docker / shell lint / Lint Dockerfile (hadolint) (push) Has been cancelled
Docker / shell lint / Lint docker/ shell scripts (shellcheck) (push) Has been cancelled
OSV-Scanner / Scan lockfiles (push) Has been cancelled
uv.lock check / uv lock --check (push) Has been cancelled
fix(desktop): open remote-gateway artifacts via authenticated download (#46895)
On a remote gateway connection, agent-written files live on the gateway
host, not the desktop's disk, so the Artifacts view's file:// hrefs failed
("Invalid external URL") and image thumbnails broke.

Make mediaExternalUrl() remote-aware in one place: in remote mode it
rewrites gateway-local paths to GET /api/files/download (a new endpoint
that streams the file as a Content-Disposition: attachment). The artifacts
view now resolves through it, and so do the existing chat-media and
generated-image callers, for free.

The download endpoint stays auth-gated; auth_middleware additionally
accepts the session token as a ?token= query param for this one path so a
shell/browser-opened download (which can't set the session header) still
authenticates — the same query-token tradeoff as the /api/pty WebSocket.
It is NOT added to PUBLIC_API_PATHS.

Salvages #46663 (which carried ~19k lines of CRLF noise and made the
endpoint public). Reimplemented on a clean LF base with the security hole
closed and tests added.

Co-authored-by: qingshan89 <qs2816661685@gmail.com>
2026-06-15 23:50:19 -05:00

136 lines
4.3 KiB
TypeScript

import { $connection } from '@/store/session'
export type MediaKind = 'audio' | 'image' | 'video' | 'file'
interface MediaInfo {
kind: MediaKind
mime: string
}
const MEDIA_BY_EXT: Record<string, MediaInfo> = {
avi: { kind: 'video', mime: 'video/x-msvideo' },
bmp: { kind: 'image', mime: 'image/bmp' },
flac: { kind: 'audio', mime: 'audio/flac' },
gif: { kind: 'image', mime: 'image/gif' },
jpeg: { kind: 'image', mime: 'image/jpeg' },
jpg: { kind: 'image', mime: 'image/jpeg' },
m4a: { kind: 'audio', mime: 'audio/mp4' },
mkv: { kind: 'video', mime: 'video/x-matroska' },
mov: { kind: 'video', mime: 'video/quicktime' },
mp3: { kind: 'audio', mime: 'audio/mpeg' },
mp4: { kind: 'video', mime: 'video/mp4' },
ogg: { kind: 'audio', mime: 'audio/ogg' },
opus: { kind: 'audio', mime: 'audio/ogg; codecs=opus' },
png: { kind: 'image', mime: 'image/png' },
svg: { kind: 'image', mime: 'image/svg+xml' },
wav: { kind: 'audio', mime: 'audio/wav' },
webm: { kind: 'video', mime: 'video/webm' },
webp: { kind: 'image', mime: 'image/webp' }
}
function mediaInfo(path: string): MediaInfo | undefined {
const ext = path.split(/[?#]/, 1)[0]?.split('.').pop()?.toLowerCase()
return ext ? MEDIA_BY_EXT[ext] : undefined
}
export function mediaKind(path: string): MediaKind {
return mediaInfo(path)?.kind ?? 'file'
}
export function mediaMime(path: string): string {
return mediaInfo(path)?.mime ?? 'application/octet-stream'
}
export function mediaName(path: string): string {
try {
const url = new URL(path)
return url.pathname.split('/').filter(Boolean).pop() || path
} catch {
return path.split(/[\\/]/).filter(Boolean).pop() || path
}
}
export function mediaMarkdownHref(path: string): string {
return `#media:${encodeURIComponent(path)}`
}
// Resolve a media path to a URL the shell can open. Remote mode rewrites
// gateway-local paths to an authenticated /api/files/download URL (the file
// lives on the gateway, not this disk); local mode keeps the file:// form.
export function mediaExternalUrl(path: string): string {
if (/^https?:/i.test(path)) {
return path
}
if (isRemoteGateway()) {
const conn = $connection.get()
if (conn?.baseUrl && conn.token) {
const file = encodeURIComponent(filePathFromMediaPath(path))
return `${conn.baseUrl}/api/files/download?path=${file}&token=${encodeURIComponent(conn.token)}`
}
}
return /^file:/i.test(path) ? path : `file://${path}`
}
// Custom Electron scheme (registered in electron/main.cjs) that streams a local
// file with Range support. Used for audio/video so playback bypasses the data
// URL size cap and supports seeking. `path` may be a plain path or `file://…`.
export function mediaStreamUrl(path: string): string {
return `hermes-media://stream/${encodeURIComponent(filePathFromMediaPath(path))}`
}
export function mediaPathFromMarkdownHref(href?: string): string | null {
if (!href?.startsWith('#media:')) {
return null
}
try {
return decodeURIComponent(href.slice('#media:'.length))
} catch {
return null
}
}
export function filePathFromMediaPath(path: string): string {
if (!path.startsWith('file:')) {
return path
}
try {
return decodeURIComponent(new URL(path).pathname)
} catch {
return path.replace(/^file:\/\//, '')
}
}
// True when this desktop shell is wired to a remote gateway. Local media paths
// then live on the gateway machine, not this disk, so we fetch them over the API.
export function isRemoteGateway(): boolean {
return $connection.get()?.mode === 'remote'
}
// Fetch a gateway-local image as a data URL via the authenticated REST bridge.
// Used in remote mode where readFileDataUrl (which reads THIS machine's disk)
// can't see files the agent wrote on the gateway. Requires the gateway to
// expose GET /api/media (hermes_cli/web_server.py).
export async function gatewayMediaDataUrl(path: string): Promise<string> {
const file = filePathFromMediaPath(path)
const result = await window.hermesDesktop!.api<{ data_url: string }>({
path: `/api/media?path=${encodeURIComponent(file)}`
})
return result.data_url
}
export function mediaDisplayLabel(path: string): string {
const escaped = mediaName(path).replace(/[[\]\\]/g, '\\$&')
const kind = mediaKind(path)
return `${kind[0].toUpperCase()}${kind.slice(1)}: ${escaped}`
}