From c2926eb7d7c67f30c815244a4aa2df9db6668e3e Mon Sep 17 00:00:00 2001 From: QuickMythril Date: Wed, 29 Jul 2026 19:04:24 -0400 Subject: [PATCH 1/4] feat: adopt Home resource viewing bridge --- README.md | 10 ++++-- package-lock.json | 4 +-- package.json | 2 +- src/App.tsx | 67 ++++++++++++++++++++++++++++++------ src/contentViewer.test.ts | 31 +++++++++++++++++ src/contentViewer.tsx | 50 ++++++++++++++++++++++++--- src/dispatcher.test.ts | 27 +++++++++++++++ src/dispatcher.ts | 13 ++++++- src/qdnRequest.test.ts | 17 +++++++++- src/qdnRequest.ts | 21 +++++++++++- src/resourceBridge.test.ts | 69 ++++++++++++++++++++++++++++++++++++++ src/resourceBridge.ts | 56 +++++++++++++++++++++++++++++++ src/styles.css | 2 +- 13 files changed, 344 insertions(+), 25 deletions(-) create mode 100644 src/contentViewer.test.ts create mode 100644 src/resourceBridge.test.ts create mode 100644 src/resourceBridge.ts diff --git a/README.md b/README.md index 10eb82f..11f04c9 100644 --- a/README.md +++ b/README.md @@ -23,9 +23,13 @@ when the app is loaded through Qortium Home. - Browse QDN services, names, and resources through deep-linkable routes. - Search public QDN metadata with an optional service filter. - Inspect resource metadata, status, and properties. -- Open apps, websites, media, and documents through the appropriate Home - action, with safe internal viewers for text, Markdown, code, CSV, JSON, and - images. +- Feature-detect Home's generic QDN resource viewer and use it for public + non-browser resources, while keeping APP/WEBSITE/GAME on Home's navigation + path and retaining the older media/document actions as compatibility + fallbacks. +- Use Home-provided ranged URLs for lazy inline image, audio, and video + previews. On older Home versions, bounded image/text previews and the + existing Open actions continue to work. - Browse the individual files of a multi-file resource, with a deep-linkable route per file (`#/detail/{service}/{name}/{identifier}/file/{path}`). - View a published Git repository (bare or worktree layout) as a repository: diff --git a/package-lock.json b/package-lock.json index b953096..bde0a60 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "qortium-explore", - "version": "1.4.9", + "version": "1.4.10", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "qortium-explore", - "version": "1.4.9", + "version": "1.4.10", "hasInstallScript": true, "license": "0BSD", "dependencies": { diff --git a/package.json b/package.json index d73a665..2aaa967 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "qortium-explore", - "version": "1.4.9", + "version": "1.4.10", "private": true, "license": "0BSD", "description": "Browse, search, inspect, and open public Qortium QDN resources.", diff --git a/src/App.tsx b/src/App.tsx index 825785a..a083349 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -8,6 +8,12 @@ import { createTranslator } from './i18n'; import { detectGitRepositoryLayout } from './qdnGitRepository'; import { NameOwnerIdentity } from './NameOwnerIdentity'; import { hasHomeBridge, qdnRequest } from './qdnRequest'; +import { + canStreamResource, + resourceBridgeCapabilities, + resourceStreamRequest, + type ResourceBridgeCapabilities, +} from './resourceBridge'; import { loadResourceDetails } from './resourceDetails'; import { resourceFetchRequest, resourceFiles } from './resourceFiles'; import { isBrowserArchiveService, PUBLIC_QDN_SERVICES } from './services'; @@ -25,10 +31,31 @@ const date = (timestamp?: number) => timestamp ? new Date(timestamp).toLocaleStr function groupBy(resources: QdnResource[], key: (item: QdnResource) => T): Folder[] { const map = new Map(); for (const resource of resources) { const name = key(resource), old = map.get(name); map.set(name, { name, count: (old?.count ?? 0) + 1, updated: Math.max(old?.updated ?? 0, updatedOf(resource)) }); } return [...map.values()]; } function resourceQuery(route: ExploreRoute) { if (route.kind === 'services') return { action: 'LIST_QDN_RESOURCES', mode: 'ALL', limit: 0 }; if (route.kind === 'service') return { action: 'LIST_QDN_RESOURCES', mode: 'ALL', service: route.service, limit: 0 }; if (route.kind === 'name-services') return { action: 'LIST_QDN_RESOURCES', mode: 'ALL', name: route.name, exactMatchNames: true, limit: 0 }; if (route.kind === 'resources') return { action: 'LIST_QDN_RESOURCES', mode: 'ALL', service: route.service, name: route.name, exactMatchNames: true, includeStatus: true, includeMetadata: true, limit: 0 }; return null; } -function Thumbnail({ resource }: { resource: QdnResource }) { +function Thumbnail({ resource, streamUrlSupported }: { resource: QdnResource; streamUrlSupported: boolean | null }) { const [src, setSrc] = useState(); - useEffect(() => { if (!mayFetchThumbnail(resource)) return; let active = true; void qdnRequest(resourceFetchRequest(resource, { binary: true, maxBytes: THUMBNAIL_MAX_BYTES })).then(data => { if (active && typeof data === 'string') setSrc(`data:image/*;base64,${data}`); }).catch(() => undefined); return () => { active = false; }; }, [resource.identifier, resource.name, resource.path, resource.service, resource.size]); - return src ? : ; + useEffect(() => { + if (!mayFetchThumbnail(resource) || streamUrlSupported === null) return; + let active = true; + const load = async () => { + if (streamUrlSupported && canStreamResource(resource)) { + try { + const streamUrl = await qdnRequest(resourceStreamRequest(resource)); + if (typeof streamUrl === 'string' && streamUrl) { + if (active) setSrc(streamUrl); + return; + } + } catch { + // A newly advertised stream URL can still fail transiently. Retain + // Explore's bounded base64 thumbnail path as the compatibility fallback. + } + } + const data = await qdnRequest(resourceFetchRequest(resource, { binary: true, maxBytes: THUMBNAIL_MAX_BYTES })); + if (active && typeof data === 'string') setSrc(`data:image/*;base64,${data}`); + }; + void load().catch(() => undefined); + return () => { active = false; }; + }, [resource.identifier, resource.name, resource.path, resource.service, resource.size, streamUrlSupported]); + return src ? : ; } function SortButton({ active, children, onClick }: { active: boolean; children: ReactNode; onClick: () => void }) { return ; } @@ -41,6 +68,8 @@ export function App() { const [gitFallback, setGitFallback] = useState(''); const [previewing, setPreviewing] = useState(false); const [previewMessage, setPreviewMessage] = useState(''); const [previewFailure, setPreviewFailure] = useState(''); const [sourcePreviewSupported, setSourcePreviewSupported] = useState(false); + const [resourceBridge, setResourceBridge] = useState({ resourceViewer: false, streamUrl: false }); + const [resourceBridgeReady, setResourceBridgeReady] = useState(false); const t = useMemo(() => createTranslator(display.language), [display.language]); const navigate = (next: ExploreRoute) => { window.location.hash = hashForRoute(next); }; const replace = (next: ExploreRoute) => { @@ -49,7 +78,21 @@ export function App() { }; useEffect(() => { const onHash = () => setRoute(routeFromHash(window.location.hash)); window.addEventListener('hashchange', onHash); return () => window.removeEventListener('hashchange', onHash); }, []); useEffect(() => { applyDisplaySettings(display); const onMessage = (event: MessageEvent) => setDisplay(current => updateFromHostMessage(event.data, current) ?? current); window.addEventListener('message', onMessage); return () => window.removeEventListener('message', onMessage); }, [display]); - useEffect(() => { if (!hasHomeBridge()) { setSourcePreviewSupported(false); return; } let active = true; void qdnRequest({ action: 'SHOW_ACTIONS' }).then(actions => { if (active) setSourcePreviewSupported(supportsSourcePreview(actions)); }).catch(() => { if (active) setSourcePreviewSupported(false); }); return () => { active = false; }; }, []); + useEffect(() => { + let active = true; + void qdnRequest({ action: 'SHOW_ACTIONS' }).then(actions => { + if (!active) return; + setSourcePreviewSupported(hasHomeBridge() && supportsSourcePreview(actions)); + setResourceBridge(resourceBridgeCapabilities(actions)); + setResourceBridgeReady(true); + }).catch(() => { + if (!active) return; + setSourcePreviewSupported(false); + setResourceBridge({ resourceViewer: false, streamUrl: false }); + setResourceBridgeReady(true); + }); + return () => { active = false; }; + }, []); useEffect(() => { const request = resourceQuery(route); if (!request) return; let active = true; setLoading(true); setFailure(''); void qdnRequest(request).then(value => { if (!active) return; const nextResources = asResources(value); const detail = singleResourceDetailRoute(route, nextResources); if (detail) { replace(detail); return; } setResources(nextResources); }).catch(error => { if (active) setFailure(errorText(error)); }).finally(() => { if (active) setLoading(false); }); return () => { active = false; }; }, [route, refresh]); useEffect(() => { setGitFallback(''); if (route.kind !== 'detail') { setDetails(null); return; } let active = true; setDetails(null); setFailure(''); const resource = { service: route.service, name: route.name, identifier: route.identifier }; void loadResourceDetails(qdnRequest, resource).then(value => { if (active) setDetails(value); }).catch(error => { if (active) setFailure(errorText(error)); }); return () => { active = false; }; }, [route]); const folders = useMemo(() => route.kind === 'services' ? groupBy(resources, item => item.service) : route.kind === 'service' ? groupBy(resources, item => item.name) : route.kind === 'name-services' ? groupBy(resources, item => item.service) : [], [resources, route]); @@ -61,16 +104,20 @@ export function App() { const previewLocalFile = () => { setPreviewing(true); setPreviewMessage(''); setPreviewFailure(''); void previewQdnPublishSource(qdnRequest).then(result => setPreviewMessage(result.kind === 'canceled' ? t('preview.canceled') : t('preview.opened'))).catch(error => setPreviewFailure(errorText(error))).finally(() => setPreviewing(false)); }; const detailResource: QdnResource | null = route.kind === 'detail' ? { service: route.service, name: route.name, identifier: route.identifier } : null; if (route.kind === 'detail' && detailResource) { - const file = typeof details?.properties?.filename === 'string' ? details.properties.filename : undefined; - const mime = typeof details?.properties?.mimeType === 'string' ? details.properties.mimeType : undefined; - const open = dispatchOpen(detailResource, { filename: file, mimeType: mime }); - const opensInHomeViewer = open.action === 'OPEN_QDN_DOCUMENT_VIEWER' || open.action === 'OPEN_QDN_MEDIA_PLAYER'; const files = resourceFiles(details?.metadata); const selected = files.includes(route.path ?? '') ? route.path : undefined; const viewed = { ...detailResource, path: selected }; + const file = selected || (typeof details?.properties?.filename === 'string' ? details.properties.filename : undefined); + const mime = selected ? undefined : typeof details?.properties?.mimeType === 'string' ? details.properties.mimeType : undefined; + const open = dispatchOpen(viewed, { + filename: file, + mimeType: mime, + resourceViewer: resourceBridge.resourceViewer, + }); + const opensInHomeViewer = open.action === 'OPEN_QDN_DOCUMENT_VIEWER' || open.action === 'OPEN_QDN_MEDIA_PLAYER' || open.action === 'OPEN_QDN_RESOURCE_VIEWER'; const showFiles = files.length > 1; const showGit = !selected && !gitFallback && !!detectGitRepositoryLayout(files); - return

{t('app.title')}

{detailResource.service} / {detailResource.name} / {detailResource.identifier || 'default'}{selected ? ` / ${selected}` : ''}

{open.action === 'INTERNAL_VIEWER' ? null : }{isBrowserArchiveService(detailResource.service) ? : null}
{failure ?

{failure}

: null}

{t('label.details')}

{t('label.title')}
{String(details?.metadata?.title || '—')}
{t('label.description')}
{String(details?.metadata?.description || '—')}
{t('label.status')}
{String(details?.status?.status || '—')}
{t('column.size')}
{bytes(details?.status?.size)}
{t('column.updated')}
{date(details?.status?.updated)}
{showFiles ? <>

{t('label.files')} {files.length.toLocaleString()}

{files.map(path => )}
: null}

{t('label.properties')}

{JSON.stringify(details?.properties || {}, null, 2)}

{showGit ? t('git.title') : selected || (opensInHomeViewer ? t('viewer.preview') : t('viewer.source'))}

{selected ? : null}{gitFallback ?

{gitFallback}

: null}{showGit ? : showFiles && !selected ?

{t('viewer.selectFile')}

: }
; + return

{t('app.title')}

{detailResource.service} / {detailResource.name} / {detailResource.identifier || 'default'}{selected ? ` / ${selected}` : ''}

{open.action === 'INTERNAL_VIEWER' ? null : }{isBrowserArchiveService(detailResource.service) ? : null}
{failure ?

{failure}

: null}

{t('label.details')}

{t('label.title')}
{String(details?.metadata?.title || '—')}
{t('label.description')}
{String(details?.metadata?.description || '—')}
{t('label.status')}
{String(details?.status?.status || '—')}
{t('column.size')}
{bytes(details?.status?.size)}
{t('column.updated')}
{date(details?.status?.updated)}
{showFiles ? <>

{t('label.files')} {files.length.toLocaleString()}

{files.map(path => )}
: null}

{t('label.properties')}

{JSON.stringify(details?.properties || {}, null, 2)}

{showGit ? t('git.title') : selected || (opensInHomeViewer ? t('viewer.preview') : t('viewer.source'))}

{selected ? : null}{gitFallback ?

{gitFallback}

: null}{showGit ? : showFiles && !selected ?

{t('viewer.selectFile')}

: }
; } - return

{t('app.title')}

{t('app.subtitle')} {__APP_VERSION__}

{sourcePreviewSupported ? : null}
{previewMessage ?

{previewMessage}

: null}{previewFailure ?

{previewFailure}

: null}
setSearch(event.target.value)} onKeyDown={event => { if (event.key === 'Enter') doSearch(); }} />{searchResults ? : null}

{route.kind === 'services' ? 'QDN' : route.kind === 'service' ? route.service : route.kind === 'name-services' ? route.name : `${route.service} / ${route.name}`}

{failure ?
{t('error.coreOffline')}

{failure}

: null}{loading && !resources.length ?

{t('loading')}

: null}{!searchResults && folders.length > 0 ?
{t('label.name')} toggle('count')}>{t('column.count')} toggle('updated')}>{t('column.updated')}
{sortedFolders.map(row => )}
: null}{(searchResults || route.kind === 'resources') &&
{sortedResources.map(resource => )}
}{!loading && !failure && ((searchResults && !searchResults.length) || (!searchResults && !folders.length && route.kind !== 'resources') || (route.kind === 'resources' && !resources.length)) ?

{searchResults ? t('empty.search') : t('empty.resources')}

: null}
; + return

{t('app.title')}

{t('app.subtitle')} {__APP_VERSION__}

{sourcePreviewSupported ? : null}
{previewMessage ?

{previewMessage}

: null}{previewFailure ?

{previewFailure}

: null}
setSearch(event.target.value)} onKeyDown={event => { if (event.key === 'Enter') doSearch(); }} />{searchResults ? : null}

{route.kind === 'services' ? 'QDN' : route.kind === 'service' ? route.service : route.kind === 'name-services' ? route.name : `${route.service} / ${route.name}`}

{failure ?
{t('error.coreOffline')}

{failure}

: null}{loading && !resources.length ?

{t('loading')}

: null}{!searchResults && folders.length > 0 ?
{t('label.name')} toggle('count')}>{t('column.count')} toggle('updated')}>{t('column.updated')}
{sortedFolders.map(row => )}
: null}{(searchResults || route.kind === 'resources') &&
{sortedResources.map(resource => )}
}{!loading && !failure && ((searchResults && !searchResults.length) || (!searchResults && !folders.length && route.kind !== 'resources') || (route.kind === 'resources' && !resources.length)) ?

{searchResults ? t('empty.search') : t('empty.resources')}

: null}
; } diff --git a/src/contentViewer.test.ts b/src/contentViewer.test.ts new file mode 100644 index 0000000..8398010 --- /dev/null +++ b/src/contentViewer.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from 'vitest'; +import { classifyContent } from './contentViewer'; + +const resource = (service: string, path?: string) => ({ + service, + name: 'Alice', + identifier: 'one', + path, +}); + +describe('content viewer media classification', () => { + it.each([ + ['AUDIO', 'audio'], + ['VOICE', 'audio'], + ['PODCAST', 'audio'], + ['VIDEO', 'video'], + ['IMAGE', 'image'], + ] as const)('classifies %s by service as %s', (service, kind) => { + expect(classifyContent(resource(service))).toBe(kind); + }); + + it('classifies media inside generic file services by selected filename', () => { + expect(classifyContent(resource('FILES', 'media/song.opus'))).toBe('audio'); + expect(classifyContent(resource('ATTACHMENT', 'media/movie.webm'))).toBe('video'); + expect(classifyContent(resource('FILE'), { filename: 'cover.avif' })).toBe('image'); + }); + + it('does not apply container MIME hints to an individually selected file', () => { + expect(classifyContent(resource('FILES', 'README.md'), { mimeType: 'video/mp4' })).toBe('markdown'); + }); +}); diff --git a/src/contentViewer.tsx b/src/contentViewer.tsx index df23be2..58dec96 100644 --- a/src/contentViewer.tsx +++ b/src/contentViewer.tsx @@ -1,13 +1,14 @@ import { useEffect, useState } from 'react'; import { previewCache, previewCacheKey } from './previewCache'; import { qdnRequest } from './qdnRequest'; +import { canStreamResource, resourceStreamRequest } from './resourceBridge'; import { resourceFetchRequest } from './resourceFiles'; import type { QdnResource } from './types'; export const CONTENT_MAX_BYTES = 2 * 1024 * 1024; const IMAGE_MIME_TYPES: Record = { avif: 'image/avif', bmp: 'image/bmp', gif: 'image/gif', ico: 'image/x-icon', jpeg: 'image/jpeg', jpg: 'image/jpeg', png: 'image/png', svg: 'image/svg+xml', webp: 'image/webp' }; -export type ContentKind = 'binary' | 'csv' | 'image' | 'json' | 'markdown' | 'text'; +export type ContentKind = 'audio' | 'binary' | 'csv' | 'image' | 'json' | 'markdown' | 'text' | 'video'; function filename(resource: QdnResource, properties?: Record) { return resource.path || (typeof properties?.filename === 'string' ? properties.filename : '') || resource.identifier || 'resource'; } function extension(name: string) { return name.toLowerCase().split('/').pop()?.split('.').slice(1).pop() ?? ''; } @@ -23,6 +24,8 @@ export function classifyContent(resource: QdnResource, properties?: Record; binaryMessage?: string }) { - if (kind === 'binary') return

{binaryMessage || 'This resource cannot be rendered safely in Explore. Use Download to save its original bytes.'}

; + if (kind === 'binary' || kind === 'audio' || kind === 'video') return

{binaryMessage || 'This resource cannot be rendered safely in Explore. Use Download to save its original bytes.'}

; if (kind === 'image') return {filename(resource,; if (kind === 'json') { try { return
{JSON.stringify(JSON.parse(data), null, 2)}
; } catch { return
{data}
; } } if (kind === 'csv') { const rows = csvRows(data); return
{rows.map((row, i) => {row.map((cell, j) => i === 0 ? : )})}
{cell}{cell}
; } @@ -53,12 +56,46 @@ export function ContentPreview({ kind, data, resource, properties, binaryMessage return
{data}
; } -export function ContentViewer({ resource, properties, binaryMessage }: { resource: QdnResource; properties?: Record; binaryMessage?: string }) { +function StreamedContent({ kind, resource, properties }: { kind: 'audio' | 'image' | 'video'; resource: QdnResource; properties?: Record }) { + const [state, setState] = useState<{ error?: string; loading: boolean; url?: string }>({ loading: true }); + const knownFilename = filename(resource, properties); + const knownMimeType = resource.path ? undefined : String(properties?.mimeType || properties?.mimetype || '') || undefined; + + useEffect(() => { + let active = true; + setState({ loading: true }); + void qdnRequest(resourceStreamRequest(resource, { + filename: knownFilename, + mimeType: knownMimeType, + })).then((value) => { + if (!active) return; + if (typeof value !== 'string' || !value) throw new Error('Home did not return a media URL.'); + setState({ loading: false, url: value }); + }).catch((error) => { + if (active) setState({ + error: error instanceof Error ? error.message : 'Unable to open the media stream.', + loading: false, + }); + }); + return () => { active = false; }; + }, [knownFilename, knownMimeType, resource.identifier, resource.name, resource.path, resource.service]); + + if (state.loading) return

Loading preview…

; + if (state.error || !state.url) return

{state.error || 'Unable to open the media stream.'}

; + if (kind === 'image') return {knownFilename}; + if (kind === 'audio') return