150 lines
4.8 KiB
TypeScript
150 lines
4.8 KiB
TypeScript
import { DEFAULT_HUMAN_SURFACE, SYSTEM_INTERNAL_SURFACE } from './languageOperatingModel'
|
|
|
|
export const HOLOLAKE_UI_PLUGIN_SCHEMA = 'hololake.ui-plugin/v1' as const
|
|
|
|
export type UiPluginCapability =
|
|
| 'goal.submit'
|
|
| 'confirmation.answer'
|
|
| 'evidence.expand'
|
|
|
|
export type UiPluginNodeKind =
|
|
| 'shell'
|
|
| 'stack'
|
|
| 'row'
|
|
| 'text'
|
|
| 'button'
|
|
| typeof DEFAULT_HUMAN_SURFACE[number]
|
|
| 'evidence_toggle'
|
|
|
|
export type UiPluginNode = {
|
|
kind: UiPluginNodeKind
|
|
id?: string
|
|
text?: string
|
|
approveText?: string
|
|
declineText?: string
|
|
action?: UiPluginCapability
|
|
children?: UiPluginNode[]
|
|
}
|
|
|
|
export type HoloLakeUiPlugin = {
|
|
manifest: {
|
|
schema: typeof HOLOLAKE_UI_PLUGIN_SCHEMA
|
|
id: string
|
|
version: string
|
|
target: 'language_shell'
|
|
requestedCapabilities: UiPluginCapability[]
|
|
}
|
|
tokens: Record<string, string>
|
|
layout: UiPluginNode
|
|
}
|
|
|
|
export type UiPluginValidation = { ok: boolean; errors: string[] }
|
|
|
|
const ALLOWED_ACTIONS = new Set<UiPluginCapability>([
|
|
'goal.submit',
|
|
'confirmation.answer',
|
|
'evidence.expand',
|
|
])
|
|
|
|
const SAFE_TOKEN_NAME = /^--hl-world-[a-z0-9-]+$/
|
|
const UNSAFE_TOKEN_VALUE = /(?:javascript:|expression\s*\(|@import|<\/?script)/i
|
|
|
|
function walk(node: UiPluginNode, visit: (node: UiPluginNode) => void): void {
|
|
visit(node)
|
|
node.children?.forEach((child) => walk(child, visit))
|
|
}
|
|
|
|
export function validateUiPlugin(plugin: HoloLakeUiPlugin): UiPluginValidation {
|
|
const errors: string[] = []
|
|
const seen = new Set<string>()
|
|
|
|
if (plugin.manifest.schema !== HOLOLAKE_UI_PLUGIN_SCHEMA) errors.push('SCHEMA_NOT_SUPPORTED')
|
|
if (!/^[a-z0-9][a-z0-9.-]+$/.test(plugin.manifest.id)) errors.push('PLUGIN_ID_INVALID')
|
|
if (!/^\d+\.\d+\.\d+$/.test(plugin.manifest.version)) errors.push('PLUGIN_VERSION_INVALID')
|
|
|
|
walk(plugin.layout, (node) => {
|
|
seen.add(node.kind)
|
|
if ((SYSTEM_INTERNAL_SURFACE as readonly string[]).includes(node.kind)) {
|
|
errors.push(`SYSTEM_INTERNAL_SURFACE_FORBIDDEN:${node.kind}`)
|
|
}
|
|
const actionAllowed = !node.action || ALLOWED_ACTIONS.has(node.action)
|
|
if (node.action && !actionAllowed) {
|
|
errors.push(`ACTION_NOT_ALLOWED:${node.action}`)
|
|
}
|
|
if (node.action && actionAllowed && !plugin.manifest.requestedCapabilities.includes(node.action)) {
|
|
errors.push(`ACTION_NOT_DECLARED:${node.action}`)
|
|
}
|
|
if (node.kind === 'language_input' && !node.text?.trim()) errors.push('COPY_REQUIRED:language_input')
|
|
if (node.kind === 'reality_boundary_confirmation'
|
|
&& (!node.approveText?.trim() || !node.declineText?.trim())) {
|
|
errors.push('COPY_REQUIRED:reality_boundary_confirmation')
|
|
}
|
|
if (node.kind === 'evidence_toggle' && !node.text?.trim()) errors.push('COPY_REQUIRED:evidence_toggle')
|
|
})
|
|
|
|
for (const surface of DEFAULT_HUMAN_SURFACE) {
|
|
if (!seen.has(surface)) errors.push(`REQUIRED_SURFACE_MISSING:${surface}`)
|
|
}
|
|
|
|
for (const capability of plugin.manifest.requestedCapabilities) {
|
|
if (!ALLOWED_ACTIONS.has(capability)) errors.push(`CAPABILITY_NOT_ALLOWED:${capability}`)
|
|
}
|
|
|
|
for (const [name, value] of Object.entries(plugin.tokens)) {
|
|
if (!SAFE_TOKEN_NAME.test(name)) errors.push(`TOKEN_NAME_NOT_ALLOWED:${name}`)
|
|
if (UNSAFE_TOKEN_VALUE.test(value)) errors.push(`TOKEN_VALUE_UNSAFE:${name}`)
|
|
}
|
|
|
|
return { ok: errors.length === 0, errors }
|
|
}
|
|
|
|
export type UiPluginRuntime = {
|
|
current(): HoloLakeUiPlugin
|
|
activate(candidate: HoloLakeUiPlugin): UiPluginValidation
|
|
rollback(): boolean
|
|
subscribe(listener: (plugin: HoloLakeUiPlugin) => void): () => void
|
|
}
|
|
|
|
export function createUiPluginRuntime(initial: HoloLakeUiPlugin): UiPluginRuntime {
|
|
const initialValidation = validateUiPlugin(initial)
|
|
if (!initialValidation.ok) throw new Error(initialValidation.errors.join(','))
|
|
|
|
let active = initial
|
|
let previous: HoloLakeUiPlugin | null = null
|
|
const listeners = new Set<(plugin: HoloLakeUiPlugin) => void>()
|
|
const publish = () => listeners.forEach((listener) => listener(active))
|
|
|
|
return {
|
|
current: () => active,
|
|
activate(candidate) {
|
|
const result = validateUiPlugin(candidate)
|
|
if (!result.ok) return result
|
|
previous = active
|
|
active = candidate
|
|
publish()
|
|
return result
|
|
},
|
|
rollback() {
|
|
if (!previous) return false
|
|
const replacement = previous
|
|
previous = active
|
|
active = replacement
|
|
publish()
|
|
return true
|
|
},
|
|
subscribe(listener) {
|
|
listeners.add(listener)
|
|
return () => listeners.delete(listener)
|
|
},
|
|
}
|
|
}
|
|
|
|
export function applyUiPluginTokens(
|
|
plugin: HoloLakeUiPlugin,
|
|
target: Pick<CSSStyleDeclaration, 'setProperty' | 'removeProperty'>,
|
|
previousTokenNames: readonly string[] = [],
|
|
): string[] {
|
|
previousTokenNames.forEach((name) => target.removeProperty(name))
|
|
Object.entries(plugin.tokens).forEach(([name, value]) => target.setProperty(name, value))
|
|
return Object.keys(plugin.tokens)
|
|
}
|