diff --git a/src/components/Webxdc.tsx b/src/components/Webxdc.tsx index 4217f093..218a1a12 100644 --- a/src/components/Webxdc.tsx +++ b/src/components/Webxdc.tsx @@ -6,7 +6,9 @@ import { forwardRef, type IframeHTMLAttributes, } from "react"; -import type { Webxdc as WebxdcAPI, ReceivedStatusUpdate } from "@webxdc/types"; +import { unzipSync } from "fflate"; + +import type { Webxdc as WebxdcAPI, ReceivedStatusUpdate } from "@webxdc/types/webxdc"; // --------------------------------------------------------------------------- // Types @@ -14,7 +16,7 @@ import type { Webxdc as WebxdcAPI, ReceivedStatusUpdate } from "@webxdc/types"; export interface WebxdcProps extends Omit, "src" | "id"> { - /** Unique session identifier — used as the subdomain: `.webxdc.app`. */ + /** Unique session identifier — used as the subdomain: `.iframe.diy`. */ id: string; /** The `.xdc` archive: raw bytes or a URL to fetch them from. */ xdc: Uint8Array | string; @@ -30,21 +32,231 @@ export interface WebxdcHandle { focus: () => void; } +// --------------------------------------------------------------------------- +// MIME type lookup (covers common web-relevant file types) +// --------------------------------------------------------------------------- + +const MIME_TYPES: Record = { + ".html": "text/html", ".htm": "text/html", + ".css": "text/css", + ".js": "application/javascript", ".mjs": "application/javascript", + ".json": "application/json", + ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", + ".gif": "image/gif", ".svg": "image/svg+xml", ".webp": "image/webp", + ".ico": "image/x-icon", ".bmp": "image/bmp", ".avif": "image/avif", + ".woff": "font/woff", ".woff2": "font/woff2", + ".ttf": "font/ttf", ".otf": "font/otf", + ".mp3": "audio/mpeg", ".ogg": "audio/ogg", ".wav": "audio/wav", + ".opus": "audio/opus", ".weba": "audio/webm", + ".mp4": "video/mp4", ".webm": "video/webm", + ".xml": "application/xml", ".txt": "text/plain", + ".wasm": "application/wasm", ".pdf": "application/pdf", + ".toml": "application/toml", +}; + +function getMimeType(path: string): string { + const dot = path.lastIndexOf("."); + if (dot === -1) return "application/octet-stream"; + const ext = path.slice(dot).toLowerCase(); + return MIME_TYPES[ext] ?? "application/octet-stream"; +} + // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- -/** Resolve `xdc` prop to an ArrayBuffer. */ -async function resolveXdc(xdc: Uint8Array | string): Promise { +/** Resolve `xdc` prop to a Uint8Array. */ +async function resolveXdc(xdc: Uint8Array | string): Promise { if (typeof xdc === "string") { const res = await fetch(xdc); if (!res.ok) throw new Error(`Failed to fetch xdc: ${res.status}`); - return res.arrayBuffer(); + return new Uint8Array(await res.arrayBuffer()); } - // Uint8Array → ArrayBuffer (copy so we can transfer) - const copy = new ArrayBuffer(xdc.byteLength); - new Uint8Array(copy).set(xdc); - return copy; + return xdc; +} + +/** Unzip a `.xdc` archive into a normalised file map. */ +function unzipXdc(bytes: Uint8Array): Map { + const unzipped = unzipSync(bytes); + const fileMap = new Map(); + for (const [path, content] of Object.entries(unzipped)) { + const normalised = path.replace(/^\/+/, "").replace(/\\/g, "/"); + if (normalised.endsWith("/")) continue; // skip directories + fileMap.set(normalised, content); + } + return fileMap; +} + +/** Encode a Uint8Array to base64. */ +function bytesToBase64(bytes: Uint8Array): string { + let binary = ""; + for (let i = 0; i < bytes.length; i++) { + binary += String.fromCharCode(bytes[i]); + } + return btoa(binary); +} + +/** Encode a UTF-8 string to base64. */ +function utf8ToBase64(str: string): string { + const bytes = new TextEncoder().encode(str); + return bytesToBase64(bytes); +} + +/** + * Generate the webxdc bridge script that will be injected into HTML responses. + * This script implements window.webxdc by sending JSON-RPC requests to the + * parent (Ditto) through iframe.diy's relay. + */ +function generateWebxdcBridge(api: WebxdcAPI): string { + return `(function(){ + var nextId = 1; + var pending = {}; + var updateListener = null; + var updateListenerReady = null; + var realtimeDataListener = null; + var realtimeChannelId = null; + + function send(msg) { + window.parent.postMessage(msg, "*"); + } + + function sendRequest(method, params) { + var id = nextId++; + return new Promise(function(resolve, reject) { + pending[id] = { resolve: resolve, reject: reject }; + send({ jsonrpc: "2.0", id: id, method: method, params: params }); + }); + } + + function sendNotification(method, params) { + send({ jsonrpc: "2.0", method: method, params: params }); + } + + window.addEventListener("message", function(event) { + var data = event.data; + if (!data || typeof data !== "object" || data.jsonrpc !== "2.0") return; + + // JSON-RPC response + if (data.id !== undefined && !data.method) { + var p = pending[data.id]; + if (p) { + delete pending[data.id]; + if (data.error) { + p.reject(new Error(data.error.message)); + } else { + p.resolve(data.result); + } + } + return; + } + + // Notifications from parent + if (data.method && data.id === undefined) { + switch (data.method) { + case "webxdc.update": + if (updateListener) updateListener(data.params.update); + break; + case "webxdc.realtimeChannel.data": + if (realtimeDataListener) realtimeDataListener(new Uint8Array(data.params.data)); + break; + case "webxdc.keyboard": + var p2 = data.params; + var evt = new KeyboardEvent(p2.type, { + key: p2.key, code: p2.code, keyCode: p2.keyCode, + bubbles: true, cancelable: true, composed: true + }); + window.dispatchEvent(evt); + document.dispatchEvent(new KeyboardEvent(p2.type, { + key: p2.key, code: p2.code, keyCode: p2.keyCode, + bubbles: true, cancelable: true + })); + break; + } + } + }); + + window.webxdc = { + selfAddr: ${JSON.stringify(api.selfAddr)}, + selfName: ${JSON.stringify(api.selfName)}, + sendUpdateInterval: ${api.sendUpdateInterval}, + sendUpdateMaxSize: ${api.sendUpdateMaxSize}, + + sendUpdate: function(update, descr) { + sendRequest("webxdc.sendUpdate", { update: update, descr: descr }); + }, + + setUpdateListener: function(cb, serial) { + updateListener = cb; + return new Promise(function(resolve) { + updateListenerReady = resolve; + sendRequest("webxdc.setUpdateListener", { serial: serial || 0 }).then(function() { + if (updateListenerReady) { updateListenerReady(); updateListenerReady = null; } + }); + }); + }, + + getAllUpdates: function() { + return sendRequest("webxdc.getAllUpdates"); + }, + + sendToChat: function(message) { + return sendRequest("webxdc.sendToChat", { message: message }); + }, + + importFiles: function(filter) { + return sendRequest("webxdc.importFiles", { filter: filter || {} }); + }, + + joinRealtimeChannel: function() { + if (realtimeChannelId) throw new Error("Already joined a realtime channel. Leave first."); + var channelIdPromise = sendRequest("webxdc.joinRealtimeChannel"); + var joined = true; + channelIdPromise.then(function(r) { realtimeChannelId = r.channelId; }); + return { + setListener: function(cb) { + if (!joined) throw new Error("Channel has been left."); + realtimeDataListener = cb; + }, + send: function(data) { + if (!joined) throw new Error("Channel has been left."); + channelIdPromise.then(function(r) { + sendRequest("webxdc.realtimeChannel.send", { channelId: r.channelId, data: Array.from(data) }); + }); + }, + leave: function() { + if (!joined) return; + joined = false; + realtimeDataListener = null; + channelIdPromise.then(function(r) { + sendRequest("webxdc.realtimeChannel.leave", { channelId: r.channelId }); + realtimeChannelId = null; + }); + } + }; + } + }; +})();`; +} + +/** Virtual path used to serve the webxdc bridge script. */ +const BRIDGE_SCRIPT_PATH = "webxdc.js"; + +/** + * Inject a `