feat: PluginPage iframe bridge + CallPluginFunction binding
- PluginPage.svelte: bidirectional postMessage bridge with iframe - Handles: ready, get-events, create-event, update-event, delete-event - Queues messages until iframe is ready - Exports handleDrop() for drag-and-drop from parent - CallPluginFunction binding: calls arbitrary Lua functions on active plugins - Supports dotted paths: 'calendar.create_event' → _G.calendar.create_event - JSON params → Lua table conversion - LuaVM: added DoString(), LState(), VM() public methods - Plugin: added VM() getter for external access
This commit is contained in:
parent
308772dee8
commit
a1d7c7b88b
|
|
@ -1,10 +1,12 @@
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"verstak/internal/core/config"
|
"verstak/internal/core/config"
|
||||||
|
|
||||||
|
|
@ -202,16 +204,80 @@ func (a *App) ListSystemViewsWithPlugins() []SystemViewDTO {
|
||||||
return base
|
return base
|
||||||
}
|
}
|
||||||
|
|
||||||
// CallPluginAction invokes a named Lua hook on a specific plugin.
|
// CallPluginFunction calls a global Lua function on an active plugin.
|
||||||
func (a *App) CallPluginAction(pluginName, action string, paramsJSON string) (string, error) {
|
// The funcName can use dots: "calendar.create_event" → _G.calendar.create_event
|
||||||
|
// Returns JSON string or error.
|
||||||
|
func (a *App) CallPluginFunction(pluginName, funcName string, paramsJSON string) (string, error) {
|
||||||
if a.plugins == nil {
|
if a.plugins == nil {
|
||||||
return "", fmt.Errorf("plugin manager not ready")
|
return "", fmt.Errorf("plugin manager not ready")
|
||||||
}
|
}
|
||||||
result, err := a.plugins.CallPluginHook(pluginName, action, lua.LString(paramsJSON))
|
for _, p := range a.plugins.Plugins() {
|
||||||
if err != nil {
|
if p.Meta.Name != pluginName || !p.Active {
|
||||||
return "", fmt.Errorf("plugin call %s: %w", action, err)
|
continue
|
||||||
|
}
|
||||||
|
vm := p.VM()
|
||||||
|
if vm == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Resolve dotted path: "calendar.create_event" → _G.calendar.create_event
|
||||||
|
parts := strings.SplitN(funcName, ".", 2)
|
||||||
|
var luaFn string
|
||||||
|
if len(parts) == 2 {
|
||||||
|
luaFn = fmt.Sprintf("_G.%s['%s']", parts[0], parts[1])
|
||||||
|
} else {
|
||||||
|
luaFn = fmt.Sprintf("_G['%s']", funcName)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse params
|
||||||
|
var params interface{}
|
||||||
|
if paramsJSON != "" && paramsJSON != "{}" {
|
||||||
|
if err := json.Unmarshal([]byte(paramsJSON), ¶ms); err != nil {
|
||||||
|
params = paramsJSON
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert params to Lua value
|
||||||
|
var luaArg lua.LValue
|
||||||
|
switch v := params.(type) {
|
||||||
|
case nil:
|
||||||
|
luaArg = lua.LNil
|
||||||
|
case string:
|
||||||
|
luaArg = lua.LString(v)
|
||||||
|
case float64:
|
||||||
|
luaArg = lua.LNumber(v)
|
||||||
|
case bool:
|
||||||
|
luaArg = lua.LBool(v)
|
||||||
|
case map[string]interface{}:
|
||||||
|
tbl := vm.LState().NewTable()
|
||||||
|
for key, val := range v {
|
||||||
|
switch sv := val.(type) {
|
||||||
|
case string:
|
||||||
|
tbl.RawSetString(key, lua.LString(sv))
|
||||||
|
case float64:
|
||||||
|
tbl.RawSetString(key, lua.LNumber(sv))
|
||||||
|
case bool:
|
||||||
|
tbl.RawSetString(key, lua.LBool(sv))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
luaArg = tbl
|
||||||
|
default:
|
||||||
|
luaArg = lua.LString(paramsJSON)
|
||||||
|
}
|
||||||
|
|
||||||
|
var script string
|
||||||
|
if luaArg == lua.LNil {
|
||||||
|
script = fmt.Sprintf("return %s()", luaFn)
|
||||||
|
} else {
|
||||||
|
script = fmt.Sprintf("return %s(%s)", luaFn, luaArg.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := vm.DoString(script)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("call %s: %w", funcName, err)
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
}
|
}
|
||||||
return result.String(), nil
|
return "", fmt.Errorf("plugin %q not active or not found", pluginName)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ReloadPlugins re-scans the plugins directory and re-initializes runtimes.
|
// ReloadPlugins re-scans the plugins directory and re-initializes runtimes.
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
<script>
|
<script>
|
||||||
import { onMount } from 'svelte'
|
import { onMount, onDestroy } from 'svelte'
|
||||||
import { t } from './i18n'
|
|
||||||
|
|
||||||
export let sectionId = ''
|
export let sectionId = ''
|
||||||
|
|
||||||
|
|
@ -13,6 +12,9 @@
|
||||||
let htmlPanel = ''
|
let htmlPanel = ''
|
||||||
let loading = true
|
let loading = true
|
||||||
let error = ''
|
let error = ''
|
||||||
|
let iframeEl = null
|
||||||
|
let messageQueue = []
|
||||||
|
let iframeReady = false
|
||||||
|
|
||||||
function wailsCall(method, ...args) {
|
function wailsCall(method, ...args) {
|
||||||
try {
|
try {
|
||||||
|
|
@ -24,14 +26,173 @@
|
||||||
return Promise.reject(new Error('Wails not connected: ' + method))
|
return Promise.reject(new Error('Wails not connected: ' + method))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Post message to iframe (queue if not ready)
|
||||||
|
function postToIframe(msg) {
|
||||||
|
if (iframeEl && iframeEl.contentWindow && iframeReady) {
|
||||||
|
iframeEl.contentWindow.postMessage(msg, '*')
|
||||||
|
} else {
|
||||||
|
messageQueue.push(msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle messages from iframe
|
||||||
|
function handleIframeMessage(e) {
|
||||||
|
const msg = e.data
|
||||||
|
if (!msg || !msg.source || msg.source !== 'calendar-plugin') return
|
||||||
|
|
||||||
|
switch (msg.action) {
|
||||||
|
case 'ready':
|
||||||
|
iframeReady = true
|
||||||
|
// Flush queued messages
|
||||||
|
while (messageQueue.length > 0) {
|
||||||
|
iframeEl.contentWindow.postMessage(messageQueue.shift(), '*')
|
||||||
|
}
|
||||||
|
// Load initial data
|
||||||
|
loadCalendarData()
|
||||||
|
break
|
||||||
|
|
||||||
|
case 'get-events':
|
||||||
|
handleGetEvents(msg.data)
|
||||||
|
break
|
||||||
|
|
||||||
|
case 'create-event':
|
||||||
|
handleCreateEvent(msg.data)
|
||||||
|
break
|
||||||
|
|
||||||
|
case 'update-event':
|
||||||
|
handleUpdateEvent(msg.data)
|
||||||
|
break
|
||||||
|
|
||||||
|
case 'delete-event':
|
||||||
|
handleDeleteEvent(msg.data)
|
||||||
|
break
|
||||||
|
|
||||||
|
default:
|
||||||
|
console.log('[PluginPage] Unknown iframe action:', msg.action)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load events + categories from Lua backend and send to iframe
|
||||||
|
async function loadCalendarData() {
|
||||||
|
try {
|
||||||
|
// Get current month range
|
||||||
|
const now = new Date()
|
||||||
|
const year = now.getFullYear()
|
||||||
|
const month = now.getMonth()
|
||||||
|
const start = new Date(year, month, 1).toISOString().slice(0, 10) + 'T00:00:00'
|
||||||
|
const end = new Date(year, month + 1, 0).toISOString().slice(0, 10) + 'T23:59:59'
|
||||||
|
|
||||||
|
const [eventsRaw, categoriesRaw] = await Promise.all([
|
||||||
|
wailsCall('CallPluginAction', pluginName, 'get_events', JSON.stringify({ start, end })),
|
||||||
|
wailsCall('CallPluginAction', pluginName, 'get_categories', '{}'),
|
||||||
|
])
|
||||||
|
|
||||||
|
const events = eventsRaw ? JSON.parse(eventsRaw) : []
|
||||||
|
const categories = categoriesRaw ? JSON.parse(categoriesRaw) : []
|
||||||
|
|
||||||
|
postToIframe({
|
||||||
|
source: 'verstak',
|
||||||
|
type: 'calendar-data',
|
||||||
|
events,
|
||||||
|
categories,
|
||||||
|
})
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[PluginPage] loadCalendarData:', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleGetEvents(data) {
|
||||||
|
try {
|
||||||
|
const params = JSON.stringify({ start: data.start, end: data.end })
|
||||||
|
const raw = await wailsCall('CallPluginAction', pluginName, 'get_events', params)
|
||||||
|
const events = raw ? JSON.parse(raw) : []
|
||||||
|
postToIframe({
|
||||||
|
source: 'verstak',
|
||||||
|
type: 'calendar-data',
|
||||||
|
events,
|
||||||
|
})
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[PluginPage] get-events:', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleCreateEvent(data) {
|
||||||
|
try {
|
||||||
|
const params = JSON.stringify(data)
|
||||||
|
const raw = await wailsCall('CallPluginAction', pluginName, 'create_event', params)
|
||||||
|
const result = raw ? JSON.parse(raw) : {}
|
||||||
|
postToIframe({
|
||||||
|
source: 'verstak',
|
||||||
|
type: 'event-created',
|
||||||
|
event: result,
|
||||||
|
})
|
||||||
|
// Refresh data
|
||||||
|
loadCalendarData()
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[PluginPage] create-event:', e)
|
||||||
|
postToIframe({
|
||||||
|
source: 'verstak',
|
||||||
|
type: 'error',
|
||||||
|
message: String(e),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleUpdateEvent(data) {
|
||||||
|
try {
|
||||||
|
const params = JSON.stringify(data)
|
||||||
|
await wailsCall('CallPluginAction', pluginName, 'update_event', params)
|
||||||
|
postToIframe({
|
||||||
|
source: 'verstak',
|
||||||
|
type: 'event-updated',
|
||||||
|
event: data,
|
||||||
|
})
|
||||||
|
loadCalendarData()
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[PluginPage] update-event:', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDeleteEvent(data) {
|
||||||
|
try {
|
||||||
|
const params = JSON.stringify({ id: data.id })
|
||||||
|
await wailsCall('CallPluginAction', pluginName, 'delete_event', params)
|
||||||
|
postToIframe({
|
||||||
|
source: 'verstak',
|
||||||
|
type: 'event-deleted',
|
||||||
|
id: data.id,
|
||||||
|
})
|
||||||
|
loadCalendarData()
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[PluginPage] delete-event:', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
try {
|
try {
|
||||||
htmlPanel = await wailsCall('GetPluginPanelHTML', pluginName) || ''
|
htmlPanel = (await wailsCall('GetPluginPanelHTML', pluginName)) || ''
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error = String(e)
|
error = String(e)
|
||||||
}
|
}
|
||||||
loading = false
|
loading = false
|
||||||
|
|
||||||
|
// Listen for messages from iframe
|
||||||
|
window.addEventListener('message', handleIframeMessage)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
onDestroy(() => {
|
||||||
|
window.removeEventListener('message', handleIframeMessage)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Expose drop handler for parent Svelte components
|
||||||
|
export function handleDrop(data, date) {
|
||||||
|
postToIframe({
|
||||||
|
source: 'verstak',
|
||||||
|
type: 'drop',
|
||||||
|
date: date,
|
||||||
|
data: data,
|
||||||
|
})
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="plugin-page">
|
<div class="plugin-page">
|
||||||
|
|
@ -40,11 +201,12 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if loading}
|
{#if loading}
|
||||||
<p class="loading">{t('common.loading')}</p>
|
<p class="loading">Загрузка…</p>
|
||||||
{:else if error}
|
{:else if error}
|
||||||
<p class="error">{error}</p>
|
<p class="error">{error}</p>
|
||||||
{:else if htmlPanel}
|
{:else if htmlPanel}
|
||||||
<iframe
|
<iframe
|
||||||
|
bind:this={iframeEl}
|
||||||
class="plugin-frame"
|
class="plugin-frame"
|
||||||
srcdoc={htmlPanel}
|
srcdoc={htmlPanel}
|
||||||
sandbox="allow-scripts allow-same-origin"
|
sandbox="allow-scripts allow-same-origin"
|
||||||
|
|
@ -60,11 +222,11 @@
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.plugin-page { padding: 1.5rem; height: 100%; display: flex; flex-direction: column; }
|
.plugin-page { padding: 1.5rem; height: 100%; display: flex; flex-direction: column; }
|
||||||
.plugin-page-header { margin-bottom: 1rem; }
|
.plugin-page-header { margin-bottom: 1rem; flex-shrink: 0; }
|
||||||
.plugin-page-header h2 { margin: 0; font-size: 1.1rem; color: var(--text, #e0e0e0); }
|
.plugin-page-header h2 { margin: 0; font-size: 1.1rem; color: var(--text, #e0e0e0); }
|
||||||
.loading { color: var(--text-dim, #888); }
|
.loading { color: var(--text-dim, #888); }
|
||||||
.error { color: #f87171; }
|
.error { color: #f87171; }
|
||||||
.empty-state { text-align: center; padding: 3rem 1rem; color: var(--text-dim, #888); }
|
.empty-state { text-align: center; padding: 3rem 1rem; color: var(--text-dim, #888); }
|
||||||
.empty-state code { background: var(--surface-alt, #252538); padding: 0.15rem 0.4rem; border-radius: 3px; font-size: 0.85rem; }
|
.empty-state code { background: var(--surface-alt, #252538); padding: 0.15rem 0.4rem; border-radius: 3px; font-size: 0.85rem; }
|
||||||
.plugin-frame { flex: 1; border: 1px solid var(--border, #2a2a3e); border-radius: 8px; background: #fff; width: 100%; min-height: 400px; }
|
.plugin-frame { flex: 1; border: 1px solid var(--border, #2a2a3e); border-radius: 8px; background: #fff; width: 100%; min-height: 400px; display: block; }
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -168,6 +168,32 @@ func (vm *LuaVM) SetServices(svc *CoreServices) {
|
||||||
vm.Services = svc
|
vm.Services = svc
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DoString executes an arbitrary Lua script string and returns the first return value.
|
||||||
|
func (vm *LuaVM) DoString(src string) (string, error) {
|
||||||
|
vm.mu.Lock()
|
||||||
|
defer vm.mu.Unlock()
|
||||||
|
if vm.L == nil || vm.L.IsClosed() {
|
||||||
|
return "", fmt.Errorf("Lua VM is closed")
|
||||||
|
}
|
||||||
|
if err := vm.L.DoString(src); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
// Get return value from stack
|
||||||
|
ret := vm.L.Get(-1)
|
||||||
|
vm.L.Pop(1)
|
||||||
|
return ret.String(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// LState returns the underlying lua.LState (for table creation).
|
||||||
|
func (vm *LuaVM) LState() *lua.LState {
|
||||||
|
return vm.L
|
||||||
|
}
|
||||||
|
|
||||||
|
// VM returns the LuaVM for external use (bindings).
|
||||||
|
func (p *Plugin) VM() *LuaVM {
|
||||||
|
return p.vm
|
||||||
|
}
|
||||||
|
|
||||||
// callWithTimeout runs a PCall with a timeout and returns the first LValue.
|
// callWithTimeout runs a PCall with a timeout and returns the first LValue.
|
||||||
// nargs is the number of function arguments already on the stack.
|
// nargs is the number of function arguments already on the stack.
|
||||||
// Must be called with vm.mu held.
|
// Must be called with vm.mu held.
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue