/** * Archy Bridge — postMessage client for AIUI ↔ Archipelago communication. * * This is the ONLY way AIUI should communicate with the host Archy app. * Never make direct HTTP requests to the host machine. */ type AIContextCategory = 'apps' | 'system' | 'network' | 'wallet' | 'files' interface ContextResponse { data: unknown permitted: boolean } export interface ActionResponse { success: boolean error?: string } interface ThemeInfo { accent: string mode: 'dark' } type PermissionsCallback = (categories: AIContextCategory[]) => void type ThemeCallback = (theme: ThemeInfo) => void let requestId = 0 const pendingRequests = new Map void reject: (reason: unknown) => void }>() const permissionsCallbacks: PermissionsCallback[] = [] const themeCallbacks: ThemeCallback[] = [] let currentPermissions: AIContextCategory[] = [] let currentTheme: ThemeInfo | null = null let initialized = false function generateId(): string { return `aiui-${++requestId}-${Date.now()}` } function postToParent(msg: unknown) { if (window.parent === window) return // Not in iframe window.parent.postMessage(msg, '*') } function handleMessage(event: MessageEvent) { const msg = event.data if (!msg || typeof msg.type !== 'string') return switch (msg.type) { case 'context:response': { const pending = pendingRequests.get(msg.id) if (pending) { pendingRequests.delete(msg.id) pending.resolve({ data: msg.data, permitted: msg.permitted }) } break } case 'action:response': { const pending = pendingRequests.get(msg.id) if (pending) { pendingRequests.delete(msg.id) pending.resolve({ success: msg.success, error: msg.error }) } break } case 'permissions:update': { currentPermissions = msg.categories || [] for (const cb of permissionsCallbacks) cb(currentPermissions) break } case 'theme:response': { const theme: ThemeInfo | undefined = msg.theme if (theme) { currentTheme = theme for (const cb of themeCallbacks) cb(theme) } break } } } export const archyBridge = { /** * Initialize the bridge. Call once on app mount. * Sends 'ready' to Archy so it knows the iframe is loaded. */ init() { if (initialized) return initialized = true window.addEventListener('message', handleMessage) postToParent({ type: 'ready' }) }, /** Clean up listeners */ destroy() { window.removeEventListener('message', handleMessage) pendingRequests.clear() initialized = false }, /** Check if running inside Archy iframe */ isInArchy(): boolean { return window.parent !== window }, /** * Request context data from Archy. * Returns { data, permitted }. If permitted is false, the user hasn't enabled this category. */ requestContext(category: AIContextCategory, query?: string): Promise { const id = generateId() return new Promise((resolve, reject) => { pendingRequests.set(id, { resolve: resolve as (v: unknown) => void, reject }) postToParent({ type: 'context:request', id, category, query, }) // Timeout after 10s setTimeout(() => { if (pendingRequests.has(id)) { pendingRequests.delete(id) reject(new Error(`Context request timed out: ${category}`)) } }, 10000) }) }, /** * Request Archy to perform an action (install app, navigate, etc.) */ requestAction(action: string, params: Record = {}): Promise { const id = generateId() return new Promise((resolve, reject) => { pendingRequests.set(id, { resolve: resolve as (v: unknown) => void, reject }) postToParent({ type: 'action:request', id, action, params, }) setTimeout(() => { if (pendingRequests.has(id)) { pendingRequests.delete(id) reject(new Error(`Action request timed out: ${action}`)) } }, 30000) }) }, /** Request Archy's theme info */ requestTheme() { postToParent({ type: 'theme:request' }) }, /** Register callback for permission updates */ onPermissionsUpdate(callback: PermissionsCallback): () => void { permissionsCallbacks.push(callback) // If we already have permissions, fire immediately if (currentPermissions.length > 0) callback(currentPermissions) return () => { const idx = permissionsCallbacks.indexOf(callback) if (idx !== -1) permissionsCallbacks.splice(idx, 1) } }, /** Register callback for theme updates */ onThemeUpdate(callback: ThemeCallback): () => void { themeCallbacks.push(callback) if (currentTheme) callback(currentTheme) return () => { const idx = themeCallbacks.indexOf(callback) if (idx !== -1) themeCallbacks.splice(idx, 1) } }, /** Get current permitted categories */ getPermissions(): AIContextCategory[] { return [...currentPermissions] }, /** Get current theme */ getTheme(): ThemeInfo | null { return currentTheme }, }