diff --git a/NOSTR_WEBXDC.md b/NOSTR_WEBXDC.md deleted file mode 100644 index 9120bb9c..00000000 --- a/NOSTR_WEBXDC.md +++ /dev/null @@ -1,116 +0,0 @@ -NIP-DC -====== - -Nostr Webxdc ------------- - -`draft` `optional` - -This NIP defines how to share and run [webxdc](https://webxdc.org/) apps over Nostr. Webxdc apps are `.xdc` (ZIP) files containing sandboxed HTML5 applications. They are attached to regular Nostr events using `imeta` tags (NIP-92), and state is coordinated through a unique identifier. - -This spec covers public webxdc communication only. Private communication may be addressed in a future update. - -## Attachment - -A webxdc app is attached to any event by including the `.xdc` file URL in the content and an `imeta` tag with MIME type `application/x-webxdc`. - -The `imeta` tag SHOULD include a `webxdc` property with a randomly generated unique string. This serves as the coordination identifier for state updates and realtime channels. If omitted, the app can still run but state won't work. - -```json -{ - "kind": 1, - "content": "Let's play chess! https://blossom.example.com/abc123.xdc", - "tags": [ - ["imeta", - "url https://blossom.example.com/abc123.xdc", - "m application/x-webxdc", - "x a1b2c3d4e5f6...", - "webxdc 9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d" - ] - ] -} -``` - -A webxdc MAY also be published as a kind `1063` (NIP-94) file metadata event: - -```json -{ - "kind": 1063, - "content": "A collaborative chess game. Play with friends over Nostr!", - "tags": [ - ["url", "https://blossom.example.com/abc123.xdc"], - ["m", "application/x-webxdc"], - ["x", "a1b2c3d4e5f6..."], - ["alt", "Webxdc app: Chess"], - ["webxdc", "9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d"] - ] -} -``` - -## Kind `4932`: State Update - -A regular event carrying a state update, mapping to the webxdc [`sendUpdate()`](https://webxdc.org/docs/spec/sendUpdate.html) API. Updates are ordered by `created_at` and assigned serial numbers by the client. - -### Tags - -- `i`: The `webxdc` identifier from the originating event (required) -- `alt`: NIP-31 human-readable description (required) -- `info`: Short info message, max ~50 chars (optional) -- `document`: Document name being edited (optional) -- `summary`: Short summary text, e.g. "8 votes" (optional) - -The optional tags correspond to fields in the webxdc `sendUpdate()` API. - -### Content - -JSON-serialized payload from `sendUpdate()`. - -```json -{ - "kind": 4932, - "content": "{\"move\":\"e2e4\",\"player\":\"white\"}", - "tags": [ - ["i", "9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d"], - ["alt", "Webxdc update"], - ["info", "White played e2-e4"] - ] -} -``` - -## Kind `20932`: Realtime Data (Ephemeral) - -An ephemeral event carrying realtime data, mapping to the webxdc [`joinRealtimeChannel`](https://webxdc.org/docs/spec/joinRealtimeChannel.html) API. Relays forward these to active subscribers but do not store them. - -### Tags - -- `i`: The `webxdc` identifier from the originating event (required) - -### Content - -Base64-encoded `Uint8Array` payload (max 128,000 bytes raw). - -```json -{ - "kind": 20932, - "content": "SGVsbG8gZnJvbSBucHViMWFiYy4uLg==", - "tags": [ - ["i", "9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d"] - ] -} -``` - -## Flow - -1. A user uploads a `.xdc` file (e.g. to Blossom) and publishes an event with the URL in content and an `imeta` tag. The `imeta` SHOULD include a `webxdc` property. -2. A client detects the `imeta` tag, downloads the `.xdc`, extracts it, and runs `index.html` in a sandboxed iframe or webview. -3. `sendUpdate()` publishes a kind `4932` event with the `webxdc` identifier in an `i` tag. -4. The client subscribes to kind `4932` events with `#i` matching the identifier and delivers them via `setUpdateListener()`. -5. `joinRealtimeChannel()` subscribes to kind `20932` events with `#i` matching the identifier. `send()` publishes ephemeral kind `20932` events. `leave()` closes the subscription. -6. `selfAddr` and `selfName` MAY map to the user's npub and display name, or any other values. - -## Security Considerations - -- Webxdc apps MUST be sandboxed with no network access, per the [webxdc spec](https://webxdc.org/docs/spec/messenger.html). -- Clients SHOULD verify the `.xdc` file hash (`x` tag) before running it. -- All communication in this spec is public. Webxdc apps designed for private chats or small groups may not work as expected. -- Webxdc apps have no access to Nostr signatures or identity verification. Any participant can claim to be anyone within the app. Apps should not rely on `selfAddr` or `selfName` for trust decisions. diff --git a/package-lock.json b/package-lock.json index d0637e95..edfafe2a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -169,7 +169,6 @@ "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^6.0.1", "@webbtc/webln-types": "^3.0.0", - "@webxdc/types": "^2.1.2", "autoprefixer": "^10.4.20", "eslint": "^9.9.0", "eslint-plugin-react-hooks": "^5.1.0-rc.0", @@ -7418,13 +7417,6 @@ "url": "lightning:hello@getalby.com" } }, - "node_modules/@webxdc/types": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/@webxdc/types/-/types-2.1.2.tgz", - "integrity": "sha512-oklcyHvUXqCS5JwbPVaN8tt7nSB8ffRmyrUlVt0HeSn4kDyNE46oKSbw3KtrDzl530KHnm67LfcK/AjWbBoXUA==", - "dev": true, - "license": "unlicense" - }, "node_modules/@xmldom/xmldom": { "version": "0.8.12", "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.12.tgz", diff --git a/package.json b/package.json index dc4de403..2572fb4f 100644 --- a/package.json +++ b/package.json @@ -176,7 +176,6 @@ "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^6.0.1", "@webbtc/webln-types": "^3.0.0", - "@webxdc/types": "^2.1.2", "autoprefixer": "^10.4.20", "eslint": "^9.9.0", "eslint-plugin-react-hooks": "^5.1.0-rc.0", diff --git a/public/cartridge.png b/public/cartridge.png deleted file mode 100644 index 8b640428..00000000 Binary files a/public/cartridge.png and /dev/null differ diff --git a/src/App.tsx b/src/App.tsx index cb0ec2cb..6a512c4f 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -78,8 +78,6 @@ const hardcodedConfig: AppConfig = { feedIncludePeopleLists: true, showDecks: false, feedIncludeDecks: false, - showWebxdc: false, - feedIncludeWebxdc: false, showPhotos: true, feedIncludePhotos: true, showVideos: true, @@ -145,7 +143,6 @@ const hardcodedConfig: AppConfig = { autoplayVideos: false, imageQuality: 'compressed', curatorPubkey: '932614571afcbad4d17a191ee281e39eebbb41b93fac8fd87829622aeb112f4d', - sandboxDomain: 'iframe.diy', esploraApis: [ 'https://mempool.space/api', 'https://mempool.emzy.de/api', diff --git a/src/AppRouter.tsx b/src/AppRouter.tsx index 6bd699fb..1e26630e 100644 --- a/src/AppRouter.tsx +++ b/src/AppRouter.tsx @@ -1,4 +1,4 @@ -import { lazy, Suspense, useState } from "react"; +import { lazy, Suspense } from "react"; import { BrowserRouter, Link, Navigate, Outlet, Route, Routes } from "react-router-dom"; import { Toaster } from "./components/ui/toaster"; import { TopNav } from "./components/TopNav"; @@ -6,7 +6,6 @@ import { ScrollToTop } from "./components/ScrollToTop"; import { VersionCheck } from "./components/VersionCheck"; import { useCurrentUser } from "./hooks/useCurrentUser"; import { useProfileUrl } from "./hooks/useProfileUrl"; -import { CenterColumnContext } from "@/contexts/LayoutContext"; import { Skeleton } from "@/components/ui/skeleton"; import { cn } from "@/lib/utils"; @@ -93,23 +92,18 @@ function SiteFooter() { * pages that render their own internal layout. */ function FundraiserLayout({ narrow }: { narrow: boolean }) { - const [centerColumnEl, setCenterColumnEl] = useState(null); - return ( - -
- - }> -
- -
-
- -
-
+
+ + }> +
+ +
+
+ +
); } diff --git a/src/components/AppHandlerContent.tsx b/src/components/AppHandlerContent.tsx index 2bc153ee..9c9d7129 100644 --- a/src/components/AppHandlerContent.tsx +++ b/src/components/AppHandlerContent.tsx @@ -1,14 +1,13 @@ import type { NostrEvent, NostrMetadata } from '@nostrify/nostrify'; -import { ExternalLink, GitFork, Package, Play } from 'lucide-react'; -import { useMemo, useState } from 'react'; +import { ExternalLink, GitFork, Package } from 'lucide-react'; +import { useMemo } from 'react'; import { Link } from 'react-router-dom'; import { Button } from '@/components/ui/button'; import { ExternalFavicon } from '@/components/ExternalFavicon'; -import { NsitePreviewDialog } from '@/components/NsitePreviewDialog'; import { Skeleton } from '@/components/ui/skeleton'; -import { useAddrEvent } from '@/hooks/useEvent'; import { NostrURI } from '@/lib/NostrURI'; +import { hexToBase36 } from '@/lib/nsiteSubdomain'; import { sanitizeUrl } from '@/lib/sanitizeUrl'; import { cn } from '@/lib/utils'; @@ -93,6 +92,15 @@ function getNsiteRef(tags: string[][]): NsiteRef | undefined { return undefined; } +/** + * Build the canonical nsite URL for a kind 35128 reference. Mirrors the rule + * used by NsiteCard / nsiteSubdomain.ts: `.nsite.lol`. + */ +function nsiteUrl(ref: NsiteRef): string { + const pubkeyB36 = hexToBase36(ref.pubkey); + return `https://${pubkeyB36}${ref.identifier}.nsite.lol`; +} + interface AppHandlerContentProps { event: NostrEvent; /** If true, show compact preview (used in NoteCard feed). */ @@ -115,17 +123,13 @@ export function AppHandlerContent({ event, compact }: AppHandlerContentProps) { const hashtags = getAllTags(event.tags, 't'); const shakespeareUrl = useMemo(() => getShakespeareUrl(event.tags), [event.tags]); - const nsiteRef = useMemo(() => getNsiteRef(event.tags), [event.tags]); - const [previewOpen, setPreviewOpen] = useState(false); - - // Fetch the actual nsite event so we can serve files directly from Blossom. - const { data: nsiteEvent } = useAddrEvent( - nsiteRef ? { kind: 35128, pubkey: nsiteRef.pubkey, identifier: nsiteRef.identifier } : undefined, - ); + const nsiteOpenUrl = useMemo(() => { + const ref = getNsiteRef(event.tags); + return ref ? nsiteUrl(ref) : undefined; + }, [event.tags]); if (compact) { return ( - <>
{/* Banner hero */} @@ -195,19 +199,16 @@ export function AppHandlerContent({ event, compact }: AppHandlerContentProps) { {/* Actions */}
- {nsiteRef && ( - )} {websiteUrl && ( -
- - {nsiteRef && nsiteEvent && ( - - )} - ); } @@ -314,18 +304,16 @@ export function AppHandlerContent({ event, compact }: AppHandlerContentProps) { {/* Actions */}
- {nsiteRef && ( - )} {websiteUrl && ( -
- - {nsiteRef && nsiteEvent && ( - - )} ); } diff --git a/src/components/ComposeBox.tsx b/src/components/ComposeBox.tsx index 510eddd0..89211232 100644 --- a/src/components/ComposeBox.tsx +++ b/src/components/ComposeBox.tsx @@ -337,10 +337,6 @@ export function ComposeBox({ const [removedEmbeds, setRemovedEmbeds] = useState>(new Set()); /** Maps uploaded file URLs to their NIP-94 tags (grouped per upload). */ const [uploadedFileGroups, setUploadedFileGroups] = useState>(new Map()); - /** Maps .xdc URLs to their generated webxdc UUIDs. */ - const [webxdcUuids, setWebxdcUuids] = useState>(new Map()); - /** Maps .xdc URLs to extracted metadata (name + icon URL). */ - const [webxdcMetas, setWebxdcMetas] = useState>(new Map()); const textareaRef = useRef(null); const { insertAtCursor, insertEmoji: insertEmojiAtCursor } = useInsertText(textareaRef, content, setContent); const fileInputRef = useRef(null); @@ -363,8 +359,6 @@ export function ComposeBox({ setPollDuration(7); setRemovedEmbeds(new Set()); setUploadedFileGroups(new Map()); - setWebxdcUuids(new Map()); - setWebxdcMetas(new Map()); setDestination(defaultPostCountry); // Clear the auto-saved draft try { localStorage.removeItem(draftKey); } catch { /* ignore */ } @@ -577,13 +571,10 @@ export function ComposeBox({ return false; }, [content, customEmojis]); - // Detect webxdc attachments for preview mode - const hasWebxdc = useMemo(() => webxdcUuids.size > 0, [webxdcUuids]); - - // Check if content has any previewable content (link previews, images, videos, audio, webxdc, mentions, or custom emojis) + // Check if content has any previewable content (link previews, images, videos, audio, mentions, or custom emojis) const hasPreviewableContent = useMemo(() => { - return visibleEmbeds.length > 0 || hasPreviewImages || previewVideos.length > 0 || previewAudios.length > 0 || hasWebxdc || hasMentions || hasCustomEmojis; - }, [visibleEmbeds, hasPreviewImages, previewVideos, previewAudios, hasWebxdc, hasMentions, hasCustomEmojis]); + return visibleEmbeds.length > 0 || hasPreviewImages || previewVideos.length > 0 || previewAudios.length > 0 || hasMentions || hasCustomEmojis; + }, [visibleEmbeds, hasPreviewImages, previewVideos, previewAudios, hasMentions, hasCustomEmojis]); // Notify parent of previewable content changes useEffect(() => { @@ -641,33 +632,13 @@ export function ComposeBox({ if (processedUrls.has(url)) continue; processedUrls.add(url); const ext = m[1].toLowerCase(); - const isWebxdc = ext === 'xdc'; const fileTags = uploadedFileGroups.get(url); if (fileTags) { const imetaFields = fileTags.map(tag => `${tag[0]} ${tag[1]}`); - if (isWebxdc) { - const filtered = imetaFields.filter(f => !f.startsWith('m ')); - filtered.push('m application/x-webxdc'); - const uuid = webxdcUuids.get(url); - if (uuid) filtered.push(`webxdc ${uuid}`); - const meta = webxdcMetas.get(url); - if (meta?.name) filtered.push(`summary ${meta.name}`); - if (meta?.iconUrl) filtered.push(`image ${meta.iconUrl}`); - tags.push(['imeta', ...filtered]); - } else { - tags.push(['imeta', ...imetaFields]); - } + tags.push(['imeta', ...imetaFields]); } else { const mimeType = mimeFromExt(ext); - const imetaTag = ['imeta', `url ${url}`, `m ${mimeType}`]; - if (isWebxdc) { - const uuid = webxdcUuids.get(url); - if (uuid) imetaTag.push(`webxdc ${uuid}`); - const meta = webxdcMetas.get(url); - if (meta?.name) imetaTag.push(`summary ${meta.name}`); - if (meta?.iconUrl) imetaTag.push(`image ${meta.iconUrl}`); - } - tags.push(imetaTag); + tags.push(['imeta', `url ${url}`, `m ${mimeType}`]); } } @@ -680,7 +651,7 @@ export function ComposeBox({ tags, sig: '', }; - }, [user, content, customEmojis, uploadedFileGroups, webxdcUuids, webxdcMetas]); + }, [user, content, customEmojis, uploadedFileGroups]); const insertEmoji = useCallback((emoji: string) => { insertEmojiAtCursor(emoji); @@ -693,18 +664,12 @@ export function ComposeBox({ const handleFileUpload = useCallback(async (file: File) => { try { - // .xdc files are ZIP archives; browsers don't know their MIME type so file.type is ''. - // Blossom servers may reject uploads with an empty Content-Type, so we re-wrap the file - // with the correct MIME type before uploading. - const isXdc = file.name.endsWith('.xdc'); const isImage = file.type.startsWith('image/'); let uploadableFile: File; let resizedDim: string | undefined; - if (isXdc && !file.type) { - uploadableFile = new File([file], file.name, { type: 'application/x-webxdc' }); - } else if (isImage && imageQuality === 'compressed') { + if (isImage && imageQuality === 'compressed') { // Resize & optimize images before uploading for better performance. const resized = await resizeImage(file); uploadableFile = resized.file; @@ -714,19 +679,10 @@ export function ComposeBox({ } const tags = await uploadFile(uploadableFile); - let [[, url]] = tags; - - // Blossom returns hash-based URLs that may lack the original file extension. - // Append the extension so downstream media-URL detection and imeta generation work. - if (isXdc && !url.endsWith('.xdc')) { - url = url + '.xdc'; - // Update the url tag in the NIP-94 tags to match - const urlTag = tags.find(t => t[0] === 'url'); - if (urlTag) urlTag[1] = url; - } + const [[, url]] = tags; // Compute dim + blurhash and inject into NIP-94 tags - if (!isXdc && isImage) { + if (isImage) { // Use dimensions from resizeImage; compute blurhash from the resized file if (resizedDim) tags.push(['dim', resizedDim]); const { blurhash } = await getImageMeta(uploadableFile); @@ -737,34 +693,6 @@ export function ComposeBox({ setUploadedFileGroups((prev) => new Map(prev).set(url, tags)); setContent((prev) => (prev ? prev + '\n' + url : url)); - // For .xdc files, generate a UUID and extract manifest metadata - if (isXdc) { - const uuid = crypto.randomUUID(); - setWebxdcUuids((prev) => new Map(prev).set(url, uuid)); - - // Extract name and icon from the .xdc archive - try { - const { extractWebxdcMeta } = await import('@/lib/webxdcMeta'); - const meta = await extractWebxdcMeta(file); - const metaEntry: { name?: string; iconUrl?: string } = { name: meta.name }; - - // Upload the icon to Blossom if present - if (meta.iconFile) { - try { - const iconTags = await uploadFile(meta.iconFile); - const [[, iconUrl]] = iconTags; - metaEntry.iconUrl = iconUrl; - } catch { - // Icon upload failed — continue without it - } - } - - setWebxdcMetas((prev) => new Map(prev).set(url, metaEntry)); - } catch { - // Metadata extraction failed — continue without it - } - } - expand(); } catch { toast({ title: 'Upload failed', description: 'Could not upload file.', variant: 'destructive' }); @@ -1044,40 +972,17 @@ export function ComposeBox({ processedUrls.add(url); const ext = match[1].toLowerCase(); - const isWebxdc = ext === 'xdc'; // Build imeta from grouped upload tags if available, otherwise infer const fileTags = uploadedFileGroups.get(url); if (fileTags) { const imetaFields = fileTags.map(tag => `${tag[0]} ${tag[1]}`); - - if (isWebxdc) { - // Override MIME type for .xdc files and add webxdc UUID + metadata - const filtered = imetaFields.filter(f => !f.startsWith('m ')); - filtered.push('m application/x-webxdc'); - const uuid = webxdcUuids.get(url); - if (uuid) filtered.push(`webxdc ${uuid}`); - const meta = webxdcMetas.get(url); - if (meta?.name) filtered.push(`summary ${meta.name}`); - if (meta?.iconUrl) filtered.push(`image ${meta.iconUrl}`); - tags.push(['imeta', ...filtered]); - } else { - tags.push(['imeta', ...imetaFields]); - } + tags.push(['imeta', ...imetaFields]); } else { // Fallback: basic imeta tag with URL and inferred mime type const mimeType = mimeFromExt(ext); - - const imetaTag = ['imeta', `url ${url}`, `m ${mimeType}`]; - if (isWebxdc) { - const uuid = webxdcUuids.get(url); - if (uuid) imetaTag.push(`webxdc ${uuid}`); - const meta = webxdcMetas.get(url); - if (meta?.name) imetaTag.push(`summary ${meta.name}`); - if (meta?.iconUrl) imetaTag.push(`image ${meta.iconUrl}`); - } - tags.push(imetaTag); + tags.push(['imeta', `url ${url}`, `m ${mimeType}`]); } } @@ -1868,7 +1773,7 @@ export function ComposeBox({ { diff --git a/src/components/Feed.tsx b/src/components/Feed.tsx index 5832921c..3e7ee2fa 100644 --- a/src/components/Feed.tsx +++ b/src/components/Feed.tsx @@ -30,7 +30,7 @@ type FeedTab = CoreFeedTab | string; // string = saved feed id interface FeedProps { /** Override the kinds list instead of using feed settings. */ kinds?: number[]; - /** Additional tag filters to apply (e.g. `{ '#m': ['application/x-webxdc'] }`). */ + /** Additional tag filters to apply (e.g. `{ '#m': ['image/jpeg'] }`). */ tagFilters?: Record; /** Header element rendered above the tabs (e.g. back-arrow + title). */ header?: React.ReactNode; diff --git a/src/components/FileMetadataContent.tsx b/src/components/FileMetadataContent.tsx index 2d7362f6..a33cfee1 100644 --- a/src/components/FileMetadataContent.tsx +++ b/src/components/FileMetadataContent.tsx @@ -4,7 +4,6 @@ import type { NostrEvent } from '@nostrify/nostrify'; import { Button } from '@/components/ui/button'; import { ImageGallery } from '@/components/ImageGallery'; import { VideoPlayer } from '@/components/VideoPlayer'; -import { WebxdcEmbed } from '@/components/WebxdcEmbed'; import { AudioVisualizer } from '@/components/AudioVisualizer'; import { useAuthor } from '@/hooks/useAuthor'; import { getDisplayName } from '@/lib/getDisplayName'; @@ -86,11 +85,9 @@ export function FileMetadataContent({ event, compact }: FileMetadataContentProps const url = sanitizeUrl(getTag(event.tags, 'url')); const mime = getTag(event.tags, 'm') ?? ''; const alt = getTag(event.tags, 'alt'); - const webxdcId = getTag(event.tags, 'webxdc'); const dim = getTag(event.tags, 'dim'); const blurhash = getTag(event.tags, 'blurhash'); const thumb = getTag(event.tags, 'thumb') ?? getTag(event.tags, 'image'); - const summary = getTag(event.tags, 'summary'); const size = getTag(event.tags, 'size'); if (!url) return null; @@ -100,22 +97,6 @@ export function FileMetadataContent({ event, compact }: FileMetadataContentProps const fileName = url.split('/').pop() ?? 'file'; const sizeStr = size ? formatBytes(Number(size)) : undefined; - // ── Webxdc app ────────────────────────────────────────────────────── - if (mime === 'application/x-webxdc') { - const appName = altText?.replace(/^Webxdc app:\s*/i, '') ?? summary ?? fileName.replace('.xdc', ''); - return ( -
- - -
- ); - } - // ── Image ─────────────────────────────────────────────────────────── if (mime.startsWith('image/')) { const imetaMap = (dim || blurhash) diff --git a/src/components/GameControls.tsx b/src/components/GameControls.tsx deleted file mode 100644 index c0c46ad0..00000000 --- a/src/components/GameControls.tsx +++ /dev/null @@ -1,186 +0,0 @@ -import { useCallback, useRef } from 'react'; -import { cn } from '@/lib/utils'; -import { impactLight } from '@/lib/haptics'; -import type { WebxdcHandle } from '@/components/Webxdc'; - -// --------------------------------------------------------------------------- -// Types -// --------------------------------------------------------------------------- - -interface GameControlsProps { - webxdcHandle: WebxdcHandle | null; - className?: string; -} - -// Key mappings for each button. -const KEY_MAP = { - up: { key: 'ArrowUp', code: 'ArrowUp', keyCode: 38 }, - down: { key: 'ArrowDown', code: 'ArrowDown', keyCode: 40 }, - left: { key: 'ArrowLeft', code: 'ArrowLeft', keyCode: 37 }, - right: { key: 'ArrowRight', code: 'ArrowRight', keyCode: 39 }, - a: { key: 'x', code: 'KeyX', keyCode: 88 }, - b: { key: 'z', code: 'KeyZ', keyCode: 90 }, - start: { key: 'Enter', code: 'Enter', keyCode: 13 }, - select: { key: 'Shift', code: 'ShiftRight', keyCode: 16 }, -} as const; - -type GameButton = keyof typeof KEY_MAP; - -/** Buttons that trigger haptic feedback on press. */ -const HAPTIC_BUTTONS = new Set(['a', 'b']); - -/** Trigger a short vibration via the native haptic engine. */ -function haptic() { - impactLight(); -} - -// --------------------------------------------------------------------------- -// Component -// --------------------------------------------------------------------------- - -/** - * Virtual gamepad: d-pad + A/B + Start/Select. Buttons send synthetic key - * events into the webxdc iframe via `webxdc.keyboard` postMessage. - */ -export function GameControls({ webxdcHandle, className }: GameControlsProps) { - const activeKeys = useRef(new Set()); - - const sendKey = useCallback( - (type: 'keydown' | 'keyup', button: GameButton) => { - if (!webxdcHandle) return; - const { key, code, keyCode } = KEY_MAP[button]; - - if (type === 'keydown') { - if (activeKeys.current.has(code)) return; - activeKeys.current.add(code); - if (HAPTIC_BUTTONS.has(button)) haptic(); - } else { - activeKeys.current.delete(code); - } - - webxdcHandle.postMessage({ - jsonrpc: '2.0', - method: 'webxdc.keyboard', - params: { type, key, code, keyCode }, - }); - }, - [webxdcHandle], - ); - - const handlers = (button: GameButton) => ({ - onPointerDown: (e: React.PointerEvent) => { - e.preventDefault(); - (e.target as HTMLElement).setPointerCapture(e.pointerId); - sendKey('keydown', button); - }, - onPointerUp: (e: React.PointerEvent) => { - e.preventDefault(); - sendKey('keyup', button); - }, - onPointerCancel: (e: React.PointerEvent) => { - e.preventDefault(); - sendKey('keyup', button); - }, - onContextMenu: (e: React.SyntheticEvent) => e.preventDefault(), - }); - - return ( -
- {/* Main controls row: D-pad on left, A/B on right */} -
- {/* D-Pad */} -
- {/* Cross background */} -
-
-
-
- {/* Up */} - - {/* Down */} - - {/* Left */} - - {/* Right */} - -
- - {/* A / B buttons */} -
- - -
-
- - {/* Start / Select row */} -
- - -
-
- ); -} - -export default GameControls; diff --git a/src/components/NoteContent.tsx b/src/components/NoteContent.tsx index 3cc68aa8..23e5020f 100644 --- a/src/components/NoteContent.tsx +++ b/src/components/NoteContent.tsx @@ -12,7 +12,6 @@ import { EmbeddedNaddr } from '@/components/EmbeddedNaddr'; import { LightningInvoiceCard } from '@/components/LightningInvoiceCard'; import { VideoPlayer } from '@/components/VideoPlayer'; import { AudioVisualizer } from '@/components/AudioVisualizer'; -import { WebxdcEmbed } from '@/components/WebxdcEmbed'; import { Lightbox, ImageGallery } from '@/components/ImageGallery'; import { ProfileHoverCard } from '@/components/ProfileHoverCard'; import { EmojifiedText, CustomEmojiImg } from '@/components/CustomEmoji'; @@ -337,7 +336,7 @@ export function NoteContent({ continue; } - // Non-image media URLs (video, audio, webxdc) — render inline at their position. + // Non-image media URLs (video, audio) — render inline at their position. if (EMBED_MEDIA_URL_REGEX.test(url)) { if (result.length > 0) { const prev = result[result.length - 1]; @@ -462,7 +461,7 @@ export function NoteContent({ } // Append media-embed tokens for imeta-declared media URLs not found in the content. - // Some clients attach audio/video/webxdc via imeta tags without including the URL in + // Some clients attach audio/video via imeta tags without including the URL in // the content string. Without this, those attachments would be silently dropped. // Only scan for text note kinds — other kinds (DMs, calendar events, etc.) may use // imeta tags for different purposes. @@ -484,8 +483,7 @@ export function NoteContent({ } const url = sanitizeUrl(rawUrl); if (!url || contentMediaUrls.has(url)) continue; - const isEmbeddableMedia = mime?.startsWith('audio/') || mime?.startsWith('video/') - || mime === 'application/x-webxdc' || mime === 'application/vnd.webxdc+zip'; + const isEmbeddableMedia = mime?.startsWith('audio/') || mime?.startsWith('video/'); if (isEmbeddableMedia) { result.push({ type: 'media-embed', url }); } @@ -704,11 +702,7 @@ export function NoteContent({ } const imeta = imetaMap.get(token.url); const mime = imeta?.mime ?? ''; - const isWebxdc = mime === 'application/x-webxdc' || mime === 'application/vnd.webxdc+zip' || token.url.endsWith('.xdc'); const isAudio = mime.startsWith('audio/') || /\.(mp3|wav|ogg|flac|m4a|aac|opus)(\?[^\s]*)?$/i.test(token.url); - if (isWebxdc && imeta) { - return ; - } if (isAudio) { return ( n === "title")?.[1]; const description = event.tags.find(([n]) => n === "description")?.[1]; const dTag = event.tags.find(([n]) => n === "d")?.[1]; @@ -46,14 +38,6 @@ export function NsiteCard({ event, autoPlayKey }: NsiteCardProps) { const image = preview?.thumbnail_url; const previewTitle = preview?.title; - const { activeSubdomain, setActiveSubdomain } = useNsitePlayer(); - const [previewOpen, setPreviewOpen] = useState(!!autoPlayKey); - - // Ref tracks the latest activeSubdomain so the unmount cleanup can - // guard against clearing a *different* nsite's active state. - const activeRef = useRef(activeSubdomain); - activeRef.current = activeSubdomain; - const handleTogglePin = useCallback(() => { if (!sidebarUri) return; if (isPinned) { @@ -65,38 +49,11 @@ export function NsiteCard({ event, autoPlayKey }: NsiteCardProps) { } }, [sidebarUri, isPinned, addToSidebar, removeFromSidebar]); - // Sync open/close state with the global NsitePlayerContext. - const handlePreviewOpenChange = useCallback((open: boolean) => { - setPreviewOpen(open); - setActiveSubdomain(open ? nsiteSubdomain : null); - }, [nsiteSubdomain, setActiveSubdomain]); - - // Open the player when autoPlayKey changes (e.g. sidebar clicked again). - useEffect(() => { - if (autoPlayKey) { - handlePreviewOpenChange(true); - } - }, [autoPlayKey, handlePreviewOpenChange]); - - // Register on mount if auto-playing, and clean up on unmount. - useEffect(() => { - if (previewOpen) { - setActiveSubdomain(nsiteSubdomain); - } - return () => { - // Only clear if we are still the active subdomain. - if (activeRef.current === nsiteSubdomain) { - setActiveSubdomain(null); - } - }; - }, []); // eslint-disable-line react-hooks/exhaustive-deps - if (isLoading) { return ; } return ( - <>
- - - ); } diff --git a/src/components/NsitePermissionManager.tsx b/src/components/NsitePermissionManager.tsx deleted file mode 100644 index ee4b8b36..00000000 --- a/src/components/NsitePermissionManager.tsx +++ /dev/null @@ -1,164 +0,0 @@ -import { useCallback, useSyncExternalStore } from 'react'; -import { Shield, X } from 'lucide-react'; - -import { Button } from '@/components/ui/button'; -import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; -import { useCurrentUser } from '@/hooks/useCurrentUser'; -import { - clearNsitePermissions, - getNsiteAllowance, - getPermissionLabel, - removeNsitePermission, - type NsiteAllowance, - type NsitePermission, -} from '@/lib/nsitePermissions'; - -// --------------------------------------------------------------------------- -// Subscribe to localStorage changes so the component re-renders when -// permissions are modified (e.g. by the prompt granting a new permission). -// --------------------------------------------------------------------------- - -const STORAGE_KEY = 'nostr:nsite-permissions'; - -function subscribe(callback: () => void): () => void { - // Listen for changes from other tabs/windows. - const onStorage = (e: StorageEvent) => { - if (e.key === STORAGE_KEY) callback(); - }; - window.addEventListener('storage', onStorage); - - // For same-tab mutations, we override the localStorage setter to also - // dispatch a custom event. This is necessary because the `storage` event - // only fires across tabs, not within the same tab. - const onLocal = () => callback(); - window.addEventListener('nsite-permissions-changed', onLocal); - - return () => { - window.removeEventListener('storage', onStorage); - window.removeEventListener('nsite-permissions-changed', onLocal); - }; -} - -let _snapshotCache: string | null = null; - -function getSnapshot(): string | null { - const current = localStorage.getItem(STORAGE_KEY); - if (current !== _snapshotCache) { - _snapshotCache = current; - } - return _snapshotCache; -} - -// --------------------------------------------------------------------------- -// Component -// --------------------------------------------------------------------------- - -interface NsitePermissionManagerProps { - /** Canonical nsite subdomain identifier. */ - siteId: string; -} - -/** - * Popover triggered from the nsite preview nav bar that shows and manages - * stored permissions for the current site. - */ -export function NsitePermissionManager({ siteId }: NsitePermissionManagerProps) { - const { user } = useCurrentUser(); - - // Subscribe to permission changes so the list stays in sync. - useSyncExternalStore(subscribe, getSnapshot); - const allowance: NsiteAllowance | undefined = user - ? getNsiteAllowance(siteId, user.pubkey) - : undefined; - const permissions = allowance?.permissions ?? []; - - const handleRemove = useCallback( - (perm: NsitePermission) => { - if (!user) return; - removeNsitePermission(siteId, user.pubkey, perm.type, perm.kind); - }, - [siteId, user], - ); - - const handleClearAll = useCallback(() => { - if (!user) return; - clearNsitePermissions(siteId, user.pubkey); - }, [siteId, user]); - - // Don't render the manager if no user is logged in. - if (!user) return null; - - const hasPermissions = permissions.length > 0; - - return ( - - - - - - {/* Header */} -
-

Permissions

- {hasPermissions && ( - - )} -
- - {/* Permission list */} -
- {!hasPermissions ? ( -
- -

- No permissions granted -

-

- Permissions will appear here when the app requests them. -

-
- ) : ( -
- {permissions.map((perm) => ( -
- {/* Label */} - - {getPermissionLabel(perm.type, perm.kind)} - - - {/* Remove */} - -
- ))} -
- )} -
-
-
- ); -} - - diff --git a/src/components/NsitePermissionPrompt.tsx b/src/components/NsitePermissionPrompt.tsx deleted file mode 100644 index 6d26508f..00000000 --- a/src/components/NsitePermissionPrompt.tsx +++ /dev/null @@ -1,213 +0,0 @@ -import { useState } from 'react'; -import { Check, KeyRound, Lock, Pen, ShieldAlert, X } from 'lucide-react'; - -import { Button } from '@/components/ui/button'; -import { ExternalFavicon } from '@/components/ExternalFavicon'; -import { Checkbox } from '@/components/ui/checkbox'; -import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'; -import { Label } from '@/components/ui/label'; -import { getKindLabel } from '@/lib/nsitePermissions'; -import type { NsitePromptState, NsitePromptDecision } from '@/hooks/useNsiteSignerRpc'; - -// --------------------------------------------------------------------------- -// Props -// --------------------------------------------------------------------------- - -interface NsitePermissionPromptProps { - /** App icon URL, if available. */ - appPicture?: string; - /** Human-readable app name. */ - appName: string; - /** The nsite gateway URL, used to fetch the site favicon. */ - siteUrl?: string; - /** The pending prompt state from useNsiteSignerRpc. */ - prompt: NsitePromptState; - /** Callback to resolve the prompt. */ - onResolve: (decision: NsitePromptDecision) => void; -} - -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - -function getPromptIcon(type: NsitePromptState['type']) { - switch (type) { - case 'signEvent': - return ; - case 'nip04.encrypt': - case 'nip44.encrypt': - return ; - case 'nip04.decrypt': - case 'nip44.decrypt': - return ; - } -} - -function getPromptTitle(type: NsitePromptState['type'], kind: number | null): string { - switch (type) { - case 'signEvent': - return kind !== null - ? `Sign: ${getKindLabel(kind)}` - : 'Sign event'; - case 'nip04.encrypt': - return 'Encrypt message (NIP-04)'; - case 'nip04.decrypt': - return 'Decrypt message (NIP-04)'; - case 'nip44.encrypt': - return 'Encrypt message (NIP-44)'; - case 'nip44.decrypt': - return 'Decrypt message (NIP-44)'; - } -} - -function getPromptDescription(type: NsitePromptState['type']): string { - switch (type) { - case 'signEvent': - return 'This app wants to sign a Nostr event on your behalf.'; - case 'nip04.encrypt': - case 'nip44.encrypt': - return 'This app wants to encrypt a message using your keys.'; - case 'nip04.decrypt': - case 'nip44.decrypt': - return 'This app wants to decrypt a message using your keys.'; - } -} - -/** Truncate a string to a maximum character length. */ -function truncate(str: string, max: number): string { - if (str.length <= max) return str; - return str.slice(0, max) + '\u2026'; -} - -// --------------------------------------------------------------------------- -// Component -// --------------------------------------------------------------------------- - -/** - * Overlay prompt shown when an nsite requests a signer operation that requires - * user approval. Renders on top of the nsite iframe within the preview panel. - */ -export function NsitePermissionPrompt({ - appPicture, - appName, - siteUrl, - prompt, - onResolve, -}: NsitePermissionPromptProps) { - const [remember, setRemember] = useState(false); - const [showDetails, setShowDetails] = useState(false); - - const handleAllow = () => onResolve({ allowed: true, remember }); - const handleDeny = () => onResolve({ allowed: false, remember }); - - const icon = getPromptIcon(prompt.type); - const title = getPromptTitle(prompt.type, prompt.kind); - const description = getPromptDescription(prompt.type); - - // For signEvent, show a preview of the event content. - const eventContent = prompt.event?.content as string | undefined; - const eventJson = prompt.event ? JSON.stringify(prompt.event, null, 2) : null; - - return ( -
-
- {/* Header */} -
-
- } - /> -
-
-

{appName}

-

Permission request

-
- {appPicture && ( - {appName} - )} -
- - {/* Body */} -
- {/* Operation */} -
-
{icon}
-
-

{title}

-

{description}

-
-
- - {/* Event content preview (signEvent only) */} - {prompt.type === 'signEvent' && eventContent && ( -
-

Content

-

- {truncate(eventContent, 280)} -

-
- )} - - {/* Raw event details (collapsible) */} - {eventJson && ( - - - - - -
-                  {eventJson}
-                
-
-
- )} - - {/* Remember checkbox */} -
- setRemember(checked === true)} - /> - -
-
- - {/* Actions */} -
- - -
-
-
- ); -} diff --git a/src/components/NsitePreviewDialog.tsx b/src/components/NsitePreviewDialog.tsx deleted file mode 100644 index 3e1fb970..00000000 --- a/src/components/NsitePreviewDialog.tsx +++ /dev/null @@ -1,312 +0,0 @@ -import type { NostrEvent } from '@nostrify/nostrify'; -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { createPortal } from 'react-dom'; -import { Package, X } from 'lucide-react'; - -import { Button } from '@/components/ui/button'; -import { ExternalFavicon } from '@/components/ExternalFavicon'; -import { NsitePermissionManager } from '@/components/NsitePermissionManager'; -import { NsitePermissionPrompt } from '@/components/NsitePermissionPrompt'; -import { SandboxFrame } from '@/components/SandboxFrame'; -import { useCenterColumn } from '@/contexts/LayoutContext'; -import { useAppContext } from '@/hooks/useAppContext'; -import { useCurrentUser } from '@/hooks/useCurrentUser'; -import { useNsiteSignerRpc } from '@/hooks/useNsiteSignerRpc'; -import { APP_BLOSSOM_SERVERS, getEffectiveBlossomServers } from '@/lib/appBlossom'; -import { deriveIframeSubdomain } from '@/lib/iframeSubdomain'; -import { getNsiteNostrProviderScript } from '@/lib/nsiteNostrProvider'; -import { getNsiteSubdomain } from '@/lib/nsiteSubdomain'; -import { getPreviewInjectedScript } from '@/lib/previewInjectedScript'; -import { getMimeType } from '@/lib/sandbox'; -import type { FileResponse, InjectedScript } from '@/lib/sandbox'; - -interface Rect { left: number; top: number; width: number; height: number } - -/** Track the viewport-relative bounding rect of an element, updating on resize. */ -function useElementRect(el: HTMLElement | null): Rect | null { - const [rect, setRect] = useState(null); - - useEffect(() => { - if (!el) { setRect(null); return; } - - const measure = () => { - const r = el.getBoundingClientRect(); - setRect({ left: r.left, top: r.top, width: r.width, height: r.height }); - }; - - measure(); - const ro = new ResizeObserver(measure); - ro.observe(el); - window.addEventListener('resize', measure); - return () => { ro.disconnect(); window.removeEventListener('resize', measure); }; - }, [el]); - - return rect; -} - -/** - * Build the path→sha256 manifest from a nsite event's `path` tags. - * Each path tag has the format: ["path", "/file/path", ""] - */ -function buildManifest(event: NostrEvent): Map { - const manifest = new Map(); - for (const tag of event.tags) { - if (tag[0] === 'path' && tag[1] && tag[2]) { - manifest.set(tag[1], tag[2]); - } - } - return manifest; -} - -/** - * Resolve the Blossom servers for a nsite event. - * Prefers the `server` tags on the event; falls back to the provided app servers. - */ -function resolveServers(event: NostrEvent, appServers: string[]): string[] { - const eventServers = event.tags - .filter(([name]) => name === 'server') - .map(([, url]) => url) - .filter((url) => { - try { new URL(url); return true; } catch { return false; } - }); - - return eventServers.length > 0 ? eventServers : appServers; -} - -/** - * Module-level preferred server. Once a Blossom server successfully serves - * a blob, it is promoted here so subsequent requests try it first — avoiding - * the round-trip penalty of 404s on servers that don't have the content. - */ -let preferredServer: string | null = null; - -/** - * Fetch a blob from the given sha256 by trying Blossom servers. - * - * If a server previously succeeded (the "preferred" server), it is tried - * first. On success the preferred server is reinforced; on failure we fall - * through to the remaining servers in order. Whichever server ultimately - * succeeds is promoted to preferred for the next call. - */ -async function fetchFromBlossom(sha256: string, servers: string[]): Promise { - let lastError: unknown; - - /** Try a single server. Returns the Response on success, or null. */ - async function tryServer(server: string): Promise { - const base = server.replace(/\/+$/, ''); - const url = `${base}/${sha256}`; - try { - const res = await fetch(url); - if (res.ok) { - preferredServer = server; - return res; - } - } catch (err) { - lastError = err; - } - return null; - } - - // Try the preferred server first if it's in the list. - if (preferredServer && servers.includes(preferredServer)) { - const res = await tryServer(preferredServer); - if (res) return res; - } - - // Fall through to the full list, skipping the preferred (already tried). - for (const server of servers) { - if (server === preferredServer) continue; - const res = await tryServer(server); - if (res) return res; - } - - throw lastError ?? new Error(`Failed to fetch blob ${sha256} from all servers`); -} - -interface NsitePreviewDialogProps { - /** The nsite event (kind 15128 or 35128) containing path and server tags. */ - event: NostrEvent; - /** Display name for the app. */ - appName: string; - /** Optional app icon URL. */ - appPicture?: string; - open: boolean; - onOpenChange: (open: boolean) => void; -} - -/** - * An in-app preview panel that covers the center column and loads an nsite in - * a sandboxed iframe. - * - * Files are served directly from Blossom servers using the manifest data - * embedded in the nsite event's `path` tags. Each path tag maps a file path - * to its sha256 hash, which is used to construct a Blossom content-addressed URL. - * - * The panel is portaled into the center column DOM element (via CenterColumnContext) - * and uses `position: fixed` to fill the viewport column area. - */ -export function NsitePreviewDialog({ event, appName, appPicture, open, onOpenChange }: NsitePreviewDialogProps) { - const centerColumn = useCenterColumn(); - const columnRect = useElementRect(open ? centerColumn : null); - const { config } = useAppContext(); - const { user } = useCurrentUser(); - - // Use the NIP-5A canonical subdomain as the stable identifier, then derive - // a private HMAC-SHA256 subdomain so the raw identifier is never exposed as - // a sandbox origin (preventing cross-app localStorage/IndexedDB collisions). - const nsiteSubdomain = getNsiteSubdomain(event); - const siteUrl = `https://${nsiteSubdomain}.nsite.lol`; - const previewSubdomain = useMemo(() => deriveIframeSubdomain(config.appId, 'nsite', nsiteSubdomain), [config.appId, nsiteSubdomain]); - - // NIP-07 signer proxy — only active when a user is logged in. - const signerRpc = useNsiteSignerRpc({ - siteId: nsiteSubdomain, - siteName: appName, - }); - - // Build the manifest and server list from the event (memoised per event identity) - const manifest = useRef>(new Map()); - const servers = useRef([]); - - useEffect(() => { - manifest.current = buildManifest(event); - const appServers = getEffectiveBlossomServers( - config.blossomServerMetadata, - config.useAppBlossomServers ?? true, - ); - servers.current = resolveServers(event, appServers.length > 0 ? appServers : APP_BLOSSOM_SERVERS.servers); - }, [event, config.blossomServerMetadata, config.useAppBlossomServers]); - - /** Injected scripts: SPA path normalisation + NIP-07 provider (when logged in). */ - const injectedScripts = useMemo(() => { - const scripts: InjectedScript[] = [{ - path: '__injected__/preview.js', - content: getPreviewInjectedScript(), - }]; - - // When a user is logged in, inject a NIP-07 provider so the nsite can - // use window.nostr to interact with the user's signer. - if (user) { - scripts.push({ - path: '__injected__/nostr-provider.js', - content: getNsiteNostrProviderScript(user.pubkey), - }); - } - - return scripts; - }, [user]); - - /** Resolve a pathname to file content from the Blossom manifest. */ - const resolveFile = useCallback(async (pathname: string): Promise => { - // Look up the sha256 for this path in the manifest. - // If not found, fall back to /index.html (SPA client-side routing). - let sha256 = manifest.current.get(pathname); - let servingPath = pathname; - - if (!sha256) { - sha256 = manifest.current.get('/index.html'); - servingPath = '/index.html'; - } - - if (!sha256) return null; - - // Fetch from Blossom. - const res = await fetchFromBlossom(sha256, servers.current); - const buffer = await res.arrayBuffer(); - const body = new Uint8Array(buffer); - - // Always determine content type from the file extension. - // Blossom servers commonly return incorrect types (e.g. text/plain for .js - // files), which causes browsers to reject module scripts. The file path from - // the manifest is authoritative for the correct MIME type. - const contentType = getMimeType(servingPath); - - return { status: 200, contentType, body }; - }, []); - - if (!open || !centerColumn || !columnRect) return null; - - // If the user has scrolled down, columnRect.top is negative (the column top - // is above the viewport). Clamp to 0 so the panel always starts at the - // viewport top edge and never grows taller than the viewport. - const panelTop = Math.max(0, columnRect.top); - const panelHeight = window.innerHeight - panelTop; - - return createPortal( -
- {/* Nav bar */} -
-
- {/* App icon + name */} -
- {appPicture ? ( - {appName} - ) : ( -
- } - /> -
- )} - {appName} -
- - {/* Permissions manager (only when logged in) */} - {user && ( - - )} - - {/* Close */} - -
-
- - {/* Sandboxed iframe */} -
- - - {/* Permission prompt overlay */} - {signerRpc.pendingPrompt && ( - - )} -
-
, - document.body, - ); -} diff --git a/src/components/SandboxFrame.tsx b/src/components/SandboxFrame.tsx deleted file mode 100644 index f139c40b..00000000 --- a/src/components/SandboxFrame.tsx +++ /dev/null @@ -1,405 +0,0 @@ -import { - useRef, - useEffect, - useCallback, - useMemo, - forwardRef, - useImperativeHandle, - type IframeHTMLAttributes, -} from 'react'; - -import { useAppContext } from '@/hooks/useAppContext'; -import { - bytesToBase64, - utf8ToBase64, - injectScriptTags, -} from '@/lib/sandbox'; -import type { - FileResponse, - InjectedScript, - JsonRpcResponse, - SerialisedRequest, -} from '@/lib/sandbox'; - -// --------------------------------------------------------------------------- -// Public types -// --------------------------------------------------------------------------- - -export interface SandboxFrameProps - extends Omit, 'src' | 'id' | 'sandbox'> { - /** HMAC-derived subdomain identifier. */ - id: string; - /** - * Resolve a pathname to file content. - * Return a `FileResponse` to serve the file, or `null` for a 404. - */ - resolveFile: (pathname: string) => Promise; - /** - * Handle non-fetch, non-lifecycle JSON-RPC methods (e.g. `webxdc.*`). - * Receives the method name, params, and a `post` function for sending - * arbitrary messages back into the sandbox (e.g. push notifications). - * Return the result value to send as the JSON-RPC response. - */ - onRpc?: ( - method: string, - params: unknown, - post: (msg: Record) => void, - ) => Promise; - /** - * Virtual scripts to inject into HTML responses. - * Each entry is served at its `path` and a `