// composables/extensionMessageHandler.ts import { invoke } from '@tauri-apps/api/core' import type { IHaexHubExtension } from '~/types/haexhub' import { EXTENSION_PROTOCOL_NAME, EXTENSION_PROTOCOL_PREFIX, } from '~/config/constants' import type { Platform } from '@tauri-apps/plugin-os' interface ExtensionRequest { id: string method: string params: Record timestamp: number } // Globaler Handler - nur einmal registriert let globalHandlerRegistered = false interface ExtensionInstance { extension: IHaexHubExtension windowId: string } const iframeRegistry = new Map() // Map event.source (WindowProxy) to extension instance for sandbox-compatible matching const sourceRegistry = new Map() // Reverse map: window ID to Window for broadcasting (supports multiple windows per extension) const windowIdToWindowMap = new Map() // Store context values that need to be accessed outside setup let contextGetters: { getTheme: () => string getLocale: () => string getPlatform: () => Platform | undefined } | null = null const registerGlobalMessageHandler = () => { if (globalHandlerRegistered) return window.addEventListener('message', async (event: MessageEvent) => { // Ignore console.forward messages - they're handled elsewhere if (event.data?.type === 'console.forward') { return } const request = event.data as ExtensionRequest // Find extension instance by decoding event.origin (works with sandboxed iframes) // Origin formats: // - Desktop: haex-extension:// // - Android: http://haex-extension.localhost (need to check request URL for base64) let instance: ExtensionInstance | undefined // Debug: Find which extension sent this message let sourceInfo = 'unknown source' for (const [iframe, inst] of iframeRegistry.entries()) { if (iframe.contentWindow === event.source) { sourceInfo = `${inst.extension.name} (${inst.windowId})` break } } console.log( '[ExtensionHandler] Received message from:', sourceInfo, 'Method:', request.method, ) // Try to decode extension info from origin if (event.origin) { let base64Host: string | null = null if (event.origin.startsWith(EXTENSION_PROTOCOL_PREFIX)) { // Desktop format: haex-extension:// base64Host = event.origin.replace(EXTENSION_PROTOCOL_PREFIX, '') console.log( '[ExtensionHandler] Extracted base64 (custom protocol):', base64Host, ) } else if ( event.origin === `http://${EXTENSION_PROTOCOL_NAME}.localhost` ) { // Android format: http://haex-extension.localhost/{base64} (origin doesn't contain extension info) // We need to identify extension by iframe source or fallback to single-extension mode console.log( `[ExtensionHandler] Android format detected (http://${EXTENSION_PROTOCOL_NAME}.localhost)`, ) // Fallback to single iframe mode if (iframeRegistry.size === 1) { const entry = Array.from(iframeRegistry.entries())[0] if (entry) { const [_, inst] = entry instance = inst sourceRegistry.set(event.source as Window, inst) windowIdToWindowMap.set(inst.windowId, event.source as Window) } } } if (base64Host && base64Host !== 'localhost') { try { const decodedInfo = JSON.parse(atob(base64Host)) as { name: string publicKey: string version: string } // Find matching extension in registry for (const [_, inst] of iframeRegistry.entries()) { if ( inst.extension.name === decodedInfo.name && inst.extension.publicKey === decodedInfo.publicKey && inst.extension.version === decodedInfo.version ) { instance = inst // Register for future lookups sourceRegistry.set(event.source as Window, inst) windowIdToWindowMap.set(inst.windowId, event.source as Window) break } } } catch (e) { console.error('[ExtensionHandler] Failed to decode origin:', e) } } } // Fallback: Try to find extension instance by event.source (for localhost origin or legacy) if (!instance) { instance = sourceRegistry.get(event.source as Window) // If not registered yet, find by matching iframe.contentWindow to event.source if (!instance) { for (const [iframe, inst] of iframeRegistry.entries()) { if (iframe.contentWindow === event.source) { instance = inst // Register for future lookups sourceRegistry.set(event.source as Window, inst) windowIdToWindowMap.set(inst.windowId, event.source as Window) console.log( '[ExtensionHandler] Registered instance via contentWindow match:', inst.windowId, ) break } } } else if (instance && !windowIdToWindowMap.has(instance.windowId)) { // Also register in reverse map for broadcasting windowIdToWindowMap.set(instance.windowId, event.source as Window) } } if (!instance) { console.warn( '[ExtensionHandler] Could not identify extension instance from event.source.', 'Registered iframes:', iframeRegistry.size, ) return // Message ist nicht von einem registrierten IFrame } if (!request.id || !request.method) { console.error('[ExtensionHandler] Invalid extension request:', request) return } try { let result: unknown if (request.method.startsWith('extension.')) { result = await handleExtensionMethodAsync(request, instance.extension) } else if (request.method.startsWith('db.')) { result = await handleDatabaseMethodAsync(request, instance.extension) } else if (request.method.startsWith('fs.')) { result = await handleFilesystemMethodAsync(request, instance.extension) } else if (request.method.startsWith('http.')) { result = await handleHttpMethodAsync(request, instance.extension) } else if (request.method.startsWith('permissions.')) { result = await handlePermissionsMethodAsync(request, instance.extension) } else if (request.method.startsWith('context.')) { result = await handleContextMethodAsync(request) } else if (request.method.startsWith('storage.')) { result = await handleStorageMethodAsync(request, instance) } else { throw new Error(`Unknown method: ${request.method}`) } // Use event.source instead of contentWindow to work with sandboxed iframes // For sandboxed iframes, event.origin is "null" (string), which is not valid for postMessage const targetOrigin = event.origin === 'null' ? '*' : event.origin || '*' ;(event.source as Window)?.postMessage( { id: request.id, result, }, targetOrigin, ) } catch (error) { console.error('[ExtensionHandler] Extension request error:', error) // Use event.source instead of contentWindow to work with sandboxed iframes // For sandboxed iframes, event.origin is "null" (string), which is not valid for postMessage const targetOrigin = event.origin === 'null' ? '*' : event.origin || '*' ;(event.source as Window)?.postMessage( { id: request.id, error: { code: 'INTERNAL_ERROR', message: error instanceof Error ? error.message : 'Unknown error', details: error, }, }, targetOrigin, ) } }) globalHandlerRegistered = true } export const useExtensionMessageHandler = ( iframeRef: Ref, extension: ComputedRef, windowId: Ref, ) => { // Initialize context getters (can use composables here because we're in setup) const { currentTheme } = storeToRefs(useUiStore()) const { locale } = useI18n() const { platform } = useDeviceStore() // Store getters for use outside setup context if (!contextGetters) { contextGetters = { getTheme: () => currentTheme.value?.value || 'system', getLocale: () => locale.value, getPlatform: () => platform, } } // Registriere globalen Handler beim ersten Aufruf registerGlobalMessageHandler() // Registriere dieses IFrame watchEffect(() => { if (iframeRef.value && extension.value) { iframeRegistry.set(iframeRef.value, { extension: extension.value, windowId: windowId.value, }) } }) // Cleanup beim Unmount onUnmounted(() => { if (iframeRef.value) { const instance = iframeRegistry.get(iframeRef.value) if (instance) { // Remove from all maps windowIdToWindowMap.delete(instance.windowId) for (const [source, inst] of sourceRegistry.entries()) { if (inst.windowId === instance.windowId) { sourceRegistry.delete(source) } } } iframeRegistry.delete(iframeRef.value) } }) } // Export Funktion für manuelle IFrame-Registrierung (kein Composable!) export const registerExtensionIFrame = ( iframe: HTMLIFrameElement, extension: IHaexHubExtension, windowId: string, ) => { // Stelle sicher, dass der globale Handler registriert ist registerGlobalMessageHandler() // Warnung wenn Context Getters nicht initialisiert wurden if (!contextGetters) { console.warn( 'Context getters not initialized. Make sure useExtensionMessageHandler was called in setup context first.', ) } iframeRegistry.set(iframe, { extension, windowId }) } export const unregisterExtensionIFrame = (iframe: HTMLIFrameElement) => { // Also remove from source registry and instance map const instance = iframeRegistry.get(iframe) if (instance) { // Find and remove all sources pointing to this instance for (const [source, inst] of sourceRegistry.entries()) { if (inst.windowId === instance.windowId) { sourceRegistry.delete(source) } } // Remove from instance-to-window map windowIdToWindowMap.delete(instance.windowId) } iframeRegistry.delete(iframe) } // Export function to get Window for a specific instance (for broadcasting) export const getInstanceWindow = (windowId: string): Window | undefined => { return windowIdToWindowMap.get(windowId) } // Get all windows for an extension (all instances) export const getAllInstanceWindows = (extensionId: string): Window[] => { const windows: Window[] = [] for (const [_, instance] of iframeRegistry.entries()) { if (instance.extension.id === extensionId) { const win = windowIdToWindowMap.get(instance.windowId) if (win) { windows.push(win) } } } return windows } // Deprecated - kept for backwards compatibility export const getExtensionWindow = (extensionId: string): Window | undefined => { // Return first window for this extension return getAllInstanceWindows(extensionId)[0] } // ========================================== // Extension Methods // ========================================== async function handleExtensionMethodAsync( request: ExtensionRequest, extension: IHaexHubExtension, // Direkter Typ, kein ComputedRef mehr ) { switch (request.method) { case 'extension.getInfo': { const info = (await invoke('get_extension_info', { publicKey: extension.publicKey, name: extension.name, })) as Record // Override allowedOrigin with the actual window origin // This fixes the dev-mode issue where Rust returns "tauri://localhost" // but the actual origin is "http://localhost:3003" return { ...info, allowedOrigin: window.location.origin, } } default: throw new Error(`Unknown extension method: ${request.method}`) } } // ========================================== // Database Methods // ========================================== async function handleDatabaseMethodAsync( request: ExtensionRequest, extension: IHaexHubExtension, // Direkter Typ ) { const params = request.params as { query?: string params?: unknown[] } switch (request.method) { case 'db.query': { const rows = await invoke('extension_sql_select', { sql: params.query || '', params: params.params || [], extensionId: extension.id, }) return { rows, rowsAffected: 0, lastInsertId: undefined, } } case 'db.execute': { await invoke('extension_sql_execute', { sql: params.query || '', params: params.params || [], extensionId: extension.id, }) return { rows: [], rowsAffected: 1, lastInsertId: undefined, } } case 'db.transaction': { const statements = (request.params as { statements?: string[] }).statements || [] for (const stmt of statements) { await invoke('extension_sql_execute', { sql: stmt, params: [], extensionId: extension.id, }) } return { success: true } } default: throw new Error(`Unknown database method: ${request.method}`) } } // ========================================== // Filesystem Methods (TODO) // ========================================== async function handleFilesystemMethodAsync( request: ExtensionRequest, extension: IHaexHubExtension, ) { if (!request || !extension) return // TODO: Implementiere Filesystem Commands im Backend throw new Error('Filesystem methods not yet implemented') } // ========================================== // HTTP Methods (TODO) // ========================================== async function handleHttpMethodAsync( request: ExtensionRequest, extension: IHaexHubExtension, ) { if (!extension || !request) { throw new Error('Extension not found') } // TODO: Implementiere HTTP Commands im Backend throw new Error('HTTP methods not yet implemented') } // ========================================== // Permission Methods (TODO) // ========================================== async function handlePermissionsMethodAsync( request: ExtensionRequest, extension: IHaexHubExtension, ) { if (!extension || !request) { throw new Error('Extension not found') } // TODO: Implementiere Permission Request UI throw new Error('Permission methods not yet implemented') } // ========================================== // Context Methods // ========================================== async function handleContextMethodAsync(request: ExtensionRequest) { switch (request.method) { case 'context.get': if (!contextGetters) { throw new Error( 'Context not initialized. Make sure useExtensionMessageHandler is called in a component.', ) } return { theme: contextGetters.getTheme(), locale: contextGetters.getLocale(), platform: contextGetters.getPlatform(), } default: throw new Error(`Unknown context method: ${request.method}`) } } // ========================================== // Storage Methods // ========================================== async function handleStorageMethodAsync( request: ExtensionRequest, instance: ExtensionInstance, ) { // Storage is now per-window, not per-extension const storageKey = `ext_${instance.extension.id}_${instance.windowId}_` console.log( `[HaexHub Storage] ${request.method} for window ${instance.windowId}`, ) switch (request.method) { case 'storage.getItem': { const key = request.params.key as string return localStorage.getItem(storageKey + key) } case 'storage.setItem': { const key = request.params.key as string const value = request.params.value as string localStorage.setItem(storageKey + key, value) return null } case 'storage.removeItem': { const key = request.params.key as string localStorage.removeItem(storageKey + key) return null } case 'storage.clear': { // Remove only instance-specific keys const keys = Object.keys(localStorage).filter((k) => k.startsWith(storageKey), ) keys.forEach((k) => localStorage.removeItem(k)) return null } case 'storage.keys': { // Return only instance-specific keys (without prefix) const keys = Object.keys(localStorage) .filter((k) => k.startsWith(storageKey)) .map((k) => k.substring(storageKey.length)) return keys } default: throw new Error(`Unknown storage method: ${request.method}`) } }