249 lines
6.9 KiB
TypeScript
249 lines
6.9 KiB
TypeScript
|
|
import type { Ref } from 'vue'
|
||
|
|
import type {
|
||
|
|
AIUIRequest,
|
||
|
|
ArchyResponse,
|
||
|
|
AIContextCategory,
|
||
|
|
ArchyContextResponse,
|
||
|
|
ArchyActionResponse,
|
||
|
|
} from '@/types/aiui-protocol'
|
||
|
|
import { useAIPermissionsStore } from '@/stores/aiPermissions'
|
||
|
|
import { useAppStore } from '@/stores/app'
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Context Broker — mediates all communication between AIUI (iframe) and Archy.
|
||
|
|
*
|
||
|
|
* AIUI sends context/action requests via postMessage.
|
||
|
|
* The broker checks permissions, fetches data from Pinia stores,
|
||
|
|
* sanitizes it (strips sensitive fields), and responds.
|
||
|
|
*/
|
||
|
|
export class ContextBroker {
|
||
|
|
private iframe: Ref<HTMLIFrameElement | null>
|
||
|
|
private allowedOrigin: string
|
||
|
|
private listener: ((e: MessageEvent) => void) | null = null
|
||
|
|
|
||
|
|
constructor(iframe: Ref<HTMLIFrameElement | null>, aiuiUrl: string) {
|
||
|
|
this.iframe = iframe
|
||
|
|
// Extract origin from URL for security validation
|
||
|
|
try {
|
||
|
|
const url = new URL(aiuiUrl, window.location.origin)
|
||
|
|
this.allowedOrigin = url.origin
|
||
|
|
} catch {
|
||
|
|
this.allowedOrigin = window.location.origin
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/** Start listening for postMessage events from AIUI */
|
||
|
|
start() {
|
||
|
|
this.listener = (e: MessageEvent) => this.handleMessage(e)
|
||
|
|
window.addEventListener('message', this.listener)
|
||
|
|
}
|
||
|
|
|
||
|
|
/** Stop listening and clean up */
|
||
|
|
stop() {
|
||
|
|
if (this.listener) {
|
||
|
|
window.removeEventListener('message', this.listener)
|
||
|
|
this.listener = null
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/** Send permissions update to AIUI so it knows what it can ask for */
|
||
|
|
sendPermissionsUpdate() {
|
||
|
|
const perms = useAIPermissionsStore()
|
||
|
|
this.postToIframe({
|
||
|
|
type: 'permissions:update',
|
||
|
|
categories: perms.enabledCategories,
|
||
|
|
})
|
||
|
|
}
|
||
|
|
|
||
|
|
/** Send theme info to AIUI */
|
||
|
|
sendTheme() {
|
||
|
|
this.postToIframe({
|
||
|
|
type: 'theme:response',
|
||
|
|
theme: {
|
||
|
|
accent: '#fb923c',
|
||
|
|
mode: 'dark',
|
||
|
|
},
|
||
|
|
})
|
||
|
|
}
|
||
|
|
|
||
|
|
private handleMessage(event: MessageEvent) {
|
||
|
|
// Security: verify origin
|
||
|
|
if (event.origin !== this.allowedOrigin) return
|
||
|
|
|
||
|
|
const msg = event.data as AIUIRequest
|
||
|
|
if (!msg || typeof msg.type !== 'string') return
|
||
|
|
|
||
|
|
switch (msg.type) {
|
||
|
|
case 'ready':
|
||
|
|
this.sendPermissionsUpdate()
|
||
|
|
this.sendTheme()
|
||
|
|
break
|
||
|
|
|
||
|
|
case 'context:request':
|
||
|
|
this.handleContextRequest(msg.id, msg.category, msg.query)
|
||
|
|
break
|
||
|
|
|
||
|
|
case 'action:request':
|
||
|
|
this.handleActionRequest(msg.id, msg.action, msg.params)
|
||
|
|
break
|
||
|
|
|
||
|
|
case 'theme:request':
|
||
|
|
this.sendTheme()
|
||
|
|
break
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
private handleContextRequest(id: string, category: AIContextCategory, query?: string) {
|
||
|
|
const perms = useAIPermissionsStore()
|
||
|
|
|
||
|
|
if (!perms.isEnabled(category)) {
|
||
|
|
this.postToIframe({
|
||
|
|
type: 'context:response',
|
||
|
|
id,
|
||
|
|
data: null,
|
||
|
|
permitted: false,
|
||
|
|
} satisfies ArchyContextResponse)
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
const data = this.fetchAndSanitize(category, query)
|
||
|
|
this.postToIframe({
|
||
|
|
type: 'context:response',
|
||
|
|
id,
|
||
|
|
data,
|
||
|
|
permitted: true,
|
||
|
|
} satisfies ArchyContextResponse)
|
||
|
|
}
|
||
|
|
|
||
|
|
private handleActionRequest(id: string, action: string, params: Record<string, string>) {
|
||
|
|
const appStore = useAppStore()
|
||
|
|
let success = false
|
||
|
|
let error: string | undefined
|
||
|
|
|
||
|
|
try {
|
||
|
|
switch (action) {
|
||
|
|
case 'navigate':
|
||
|
|
if (params.path) {
|
||
|
|
window.dispatchEvent(new CustomEvent('aiui:navigate', { detail: params.path }))
|
||
|
|
success = true
|
||
|
|
} else {
|
||
|
|
error = 'Missing path parameter'
|
||
|
|
}
|
||
|
|
break
|
||
|
|
|
||
|
|
case 'open-app':
|
||
|
|
if (params.appId) {
|
||
|
|
window.dispatchEvent(new CustomEvent('aiui:open-app', { detail: params.appId }))
|
||
|
|
success = true
|
||
|
|
} else {
|
||
|
|
error = 'Missing appId parameter'
|
||
|
|
}
|
||
|
|
break
|
||
|
|
|
||
|
|
case 'install-app':
|
||
|
|
if (params.appId && params.marketplaceUrl && params.version) {
|
||
|
|
appStore.installPackage(params.appId, params.marketplaceUrl, params.version).then(() => {
|
||
|
|
this.postToIframe({
|
||
|
|
type: 'action:response',
|
||
|
|
id,
|
||
|
|
success: true,
|
||
|
|
} satisfies ArchyActionResponse)
|
||
|
|
}).catch((err: Error) => {
|
||
|
|
this.postToIframe({
|
||
|
|
type: 'action:response',
|
||
|
|
id,
|
||
|
|
success: false,
|
||
|
|
error: err.message,
|
||
|
|
} satisfies ArchyActionResponse)
|
||
|
|
})
|
||
|
|
return // async — response sent in promise callbacks
|
||
|
|
}
|
||
|
|
error = 'Missing appId parameter'
|
||
|
|
break
|
||
|
|
|
||
|
|
default:
|
||
|
|
error = `Unknown action: ${action}`
|
||
|
|
}
|
||
|
|
} catch (e) {
|
||
|
|
error = e instanceof Error ? e.message : 'Unknown error'
|
||
|
|
}
|
||
|
|
|
||
|
|
this.postToIframe({
|
||
|
|
type: 'action:response',
|
||
|
|
id,
|
||
|
|
success,
|
||
|
|
error,
|
||
|
|
} satisfies ArchyActionResponse)
|
||
|
|
}
|
||
|
|
|
||
|
|
/** Fetch data from stores and strip sensitive fields */
|
||
|
|
private fetchAndSanitize(category: AIContextCategory, _query?: string): unknown {
|
||
|
|
const appStore = useAppStore()
|
||
|
|
|
||
|
|
switch (category) {
|
||
|
|
case 'apps':
|
||
|
|
return this.sanitizeApps(appStore)
|
||
|
|
case 'system':
|
||
|
|
return this.sanitizeSystem(appStore)
|
||
|
|
case 'network':
|
||
|
|
return this.sanitizeNetwork(appStore)
|
||
|
|
case 'wallet':
|
||
|
|
return this.sanitizeWallet(appStore)
|
||
|
|
case 'files':
|
||
|
|
return this.sanitizeFiles(appStore)
|
||
|
|
default:
|
||
|
|
return null
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
private sanitizeApps(store: ReturnType<typeof useAppStore>): unknown {
|
||
|
|
const packages = store.packages || {}
|
||
|
|
return Object.entries(packages).map(([id, pkg]) => ({
|
||
|
|
id,
|
||
|
|
name: pkg.manifest?.title || id,
|
||
|
|
state: pkg.state || 'unknown',
|
||
|
|
status: pkg.installed?.status || 'unknown',
|
||
|
|
}))
|
||
|
|
}
|
||
|
|
|
||
|
|
private sanitizeSystem(store: ReturnType<typeof useAppStore>): unknown {
|
||
|
|
const info = store.serverInfo
|
||
|
|
if (!info) return { status: 'unavailable' }
|
||
|
|
return {
|
||
|
|
version: info.version,
|
||
|
|
name: info.name,
|
||
|
|
// Omit: hostname, IP, paths, kernel version, pubkey
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
private sanitizeNetwork(store: ReturnType<typeof useAppStore>): unknown {
|
||
|
|
return {
|
||
|
|
connected: store.isConnected,
|
||
|
|
// Omit: IP addresses, ports, peer details
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
private sanitizeWallet(_store: ReturnType<typeof useAppStore>): unknown {
|
||
|
|
// Wallet data requires careful handling — only expose aggregates
|
||
|
|
return {
|
||
|
|
available: false,
|
||
|
|
message: 'Wallet context not yet implemented',
|
||
|
|
// Will integrate with LND store when available
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
private sanitizeFiles(_store: ReturnType<typeof useAppStore>): unknown {
|
||
|
|
// File listing requires cloud store integration
|
||
|
|
return {
|
||
|
|
available: false,
|
||
|
|
message: 'File context not yet implemented',
|
||
|
|
// Will integrate with cloud store when available
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
private postToIframe(msg: ArchyResponse) {
|
||
|
|
if (!this.iframe.value?.contentWindow) return
|
||
|
|
this.iframe.value.contentWindow.postMessage(msg, this.allowedOrigin)
|
||
|
|
}
|
||
|
|
}
|