new plugin system
This commit is contained in:
@@ -46,6 +46,7 @@ Overlord-Server/plugins/*.zip
|
||||
Overlord-Server/plugins/.plugin-state.json
|
||||
plugin-sample-go/*.wasm
|
||||
plugin-sample-go/*.zip
|
||||
plugin-sample-go/*.h
|
||||
|
||||
# Runtime data
|
||||
Overlord-Server/data/
|
||||
|
||||
@@ -486,7 +486,7 @@ func HandleCommand(ctx context.Context, env *runtime.Env, envelope map[string]in
|
||||
return wire.WriteMsg(ctx, env.Conn, wire.CommandResult{Type: "command_result", CommandID: cmdID, OK: false, Message: "missing payload"})
|
||||
}
|
||||
manifestRaw, _ := payload["manifest"].(map[string]interface{})
|
||||
wasmBytes, _ := payload["wasm"].([]byte)
|
||||
binaryBytes, _ := payload["binary"].([]byte)
|
||||
if env.Plugins == nil {
|
||||
return wire.WriteMsg(ctx, env.Conn, wire.CommandResult{Type: "command_result", CommandID: cmdID, OK: false, Message: "plugin manager not ready"})
|
||||
}
|
||||
@@ -494,7 +494,7 @@ func HandleCommand(ctx context.Context, env *runtime.Env, envelope map[string]in
|
||||
if err != nil {
|
||||
return wire.WriteMsg(ctx, env.Conn, wire.CommandResult{Type: "command_result", CommandID: cmdID, OK: false, Message: err.Error()})
|
||||
}
|
||||
if err := env.Plugins.Load(ctx, manifest, wasmBytes); err != nil {
|
||||
if err := env.Plugins.Load(ctx, manifest, binaryBytes); err != nil {
|
||||
_ = wire.WriteMsg(ctx, env.Conn, wire.PluginEvent{Type: "plugin_event", PluginID: manifest.ID, Event: "error", Error: err.Error()})
|
||||
return wire.WriteMsg(ctx, env.Conn, wire.CommandResult{Type: "command_result", CommandID: cmdID, OK: false, Message: err.Error()})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
//go:build darwin && cgo
|
||||
|
||||
package plugins
|
||||
|
||||
/*
|
||||
#include <dlfcn.h>
|
||||
#include <stdint.h>
|
||||
#include <stdlib.h>
|
||||
|
||||
static void* so_dlopen(const char* path, char** errOut) {
|
||||
void* h = dlopen(path, RTLD_NOW | RTLD_LOCAL);
|
||||
if (!h && errOut) *errOut = (char*)dlerror();
|
||||
return h;
|
||||
}
|
||||
|
||||
static void* so_dlsym(void* h, const char* name, char** errOut) {
|
||||
void* s = dlsym(h, name);
|
||||
if (!s && errOut) *errOut = (char*)dlerror();
|
||||
return s;
|
||||
}
|
||||
|
||||
static int so_dlclose(void* h) {
|
||||
if (h) return dlclose(h);
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Go callback bridge — forward-declared here, implemented via //export below.
|
||||
extern void goPluginHostCallback(
|
||||
uintptr_t ctx,
|
||||
const char* event, int eventLen,
|
||||
const char* payload, int payloadLen);
|
||||
|
||||
// Call the plugin's PluginOnLoad, injecting our callback bridge + context.
|
||||
static int so_call_onload(void* fn, const char* info, int infoLen, uintptr_t ctx) {
|
||||
typedef int (*fn_t)(const char*, int, uintptr_t, uintptr_t);
|
||||
return ((fn_t)fn)(info, infoLen, (uintptr_t)goPluginHostCallback, ctx);
|
||||
}
|
||||
|
||||
static int so_call_onevent(void* fn, const char* ev, int evLen, const char* pl, int plLen) {
|
||||
typedef int (*fn_t)(const char*, int, const char*, int);
|
||||
return ((fn_t)fn)(ev, evLen, pl, plLen);
|
||||
}
|
||||
|
||||
static void so_call_onunload(void* fn) {
|
||||
typedef void (*fn_t)(void);
|
||||
((fn_t)fn)();
|
||||
}
|
||||
*/
|
||||
import "C"
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"runtime/cgo"
|
||||
"sync"
|
||||
"unsafe"
|
||||
)
|
||||
|
||||
//export goPluginHostCallback
|
||||
func goPluginHostCallback(ctx C.uintptr_t, event *C.char, eventLen C.int, payload *C.char, payloadLen C.int) {
|
||||
h := cgo.Handle(ctx)
|
||||
send := h.Value().(func(string, []byte))
|
||||
ev := C.GoStringN(event, eventLen)
|
||||
var pl []byte
|
||||
if payloadLen > 0 {
|
||||
pl = C.GoBytes(unsafe.Pointer(payload), payloadLen)
|
||||
}
|
||||
send(ev, pl)
|
||||
}
|
||||
|
||||
func loadNativePlugin(data []byte) (NativePlugin, error) {
|
||||
if len(data) == 0 {
|
||||
return nil, errors.New("empty plugin binary")
|
||||
}
|
||||
|
||||
f, err := os.CreateTemp("", "*.dylib")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create temp: %w", err)
|
||||
}
|
||||
tmpPath := f.Name()
|
||||
|
||||
if _, err := f.Write(data); err != nil {
|
||||
f.Close()
|
||||
os.Remove(tmpPath)
|
||||
return nil, fmt.Errorf("write temp: %w", err)
|
||||
}
|
||||
f.Close()
|
||||
|
||||
cPath := C.CString(tmpPath)
|
||||
defer C.free(unsafe.Pointer(cPath))
|
||||
var cErr *C.char
|
||||
handle := C.so_dlopen(cPath, &cErr)
|
||||
|
||||
os.Remove(tmpPath)
|
||||
|
||||
if handle == nil {
|
||||
return nil, fmt.Errorf("dlopen: %s", C.GoString(cErr))
|
||||
}
|
||||
|
||||
resolve := func(name string) (unsafe.Pointer, error) {
|
||||
cName := C.CString(name)
|
||||
defer C.free(unsafe.Pointer(cName))
|
||||
var cErr *C.char
|
||||
s := C.so_dlsym(handle, cName, &cErr)
|
||||
if s == nil {
|
||||
return nil, fmt.Errorf("dlsym(%s): %s", name, C.GoString(cErr))
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
onLoad, err := resolve("PluginOnLoad")
|
||||
if err != nil {
|
||||
C.so_dlclose(handle)
|
||||
return nil, err
|
||||
}
|
||||
onEvent, err := resolve("PluginOnEvent")
|
||||
if err != nil {
|
||||
C.so_dlclose(handle)
|
||||
return nil, err
|
||||
}
|
||||
onUnload, err := resolve("PluginOnUnload")
|
||||
if err != nil {
|
||||
C.so_dlclose(handle)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &soPlugin{
|
||||
handle: handle,
|
||||
onLoadFn: onLoad,
|
||||
onEventFn: onEvent,
|
||||
onUnloadFn: onUnload,
|
||||
}, nil
|
||||
}
|
||||
|
||||
type soPlugin struct {
|
||||
handle unsafe.Pointer // dlopen handle
|
||||
onLoadFn unsafe.Pointer
|
||||
onEventFn unsafe.Pointer
|
||||
onUnloadFn unsafe.Pointer
|
||||
cbHandle cgo.Handle
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
func (p *soPlugin) Load(send func(string, []byte), hostInfo []byte) error {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
|
||||
p.cbHandle = cgo.NewHandle(send)
|
||||
|
||||
var infoPtr *C.char
|
||||
if len(hostInfo) > 0 {
|
||||
infoPtr = (*C.char)(unsafe.Pointer(&hostInfo[0]))
|
||||
}
|
||||
|
||||
ret := C.so_call_onload(p.onLoadFn, infoPtr, C.int(len(hostInfo)), C.uintptr_t(p.cbHandle))
|
||||
if ret != 0 {
|
||||
p.cbHandle.Delete()
|
||||
p.cbHandle = 0
|
||||
return errors.New("PluginOnLoad returned error")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *soPlugin) Event(event string, payload []byte) error {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
|
||||
evBytes := []byte(event)
|
||||
var evPtr, plPtr *C.char
|
||||
if len(evBytes) > 0 {
|
||||
evPtr = (*C.char)(unsafe.Pointer(&evBytes[0]))
|
||||
}
|
||||
if len(payload) > 0 {
|
||||
plPtr = (*C.char)(unsafe.Pointer(&payload[0]))
|
||||
}
|
||||
|
||||
ret := C.so_call_onevent(p.onEventFn, evPtr, C.int(len(evBytes)), plPtr, C.int(len(payload)))
|
||||
if ret != 0 {
|
||||
return errors.New("PluginOnEvent returned error")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *soPlugin) Unload() {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
if p.onUnloadFn != nil {
|
||||
C.so_call_onunload(p.onUnloadFn)
|
||||
p.onUnloadFn = nil
|
||||
}
|
||||
}
|
||||
|
||||
func (p *soPlugin) Close() error {
|
||||
p.Unload()
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
if p.cbHandle != 0 {
|
||||
p.cbHandle.Delete()
|
||||
p.cbHandle = 0
|
||||
}
|
||||
if p.handle != nil {
|
||||
C.so_dlclose(p.handle)
|
||||
p.handle = nil
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
//go:build linux && cgo
|
||||
|
||||
package plugins
|
||||
|
||||
/*
|
||||
#cgo LDFLAGS: -ldl
|
||||
#include <dlfcn.h>
|
||||
#include <stdint.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <unistd.h>
|
||||
#include <sys/syscall.h>
|
||||
|
||||
#ifndef MFD_CLOEXEC
|
||||
#define MFD_CLOEXEC 0x0001U
|
||||
#endif
|
||||
|
||||
// memfd_create wrapper — creates an anonymous in-memory file descriptor.
|
||||
static int so_memfd_create(void) {
|
||||
return (int)syscall(SYS_memfd_create, "plugin", MFD_CLOEXEC);
|
||||
}
|
||||
|
||||
// Write entire buffer to fd. Returns 0 on success, -1 on error.
|
||||
static int so_write_all(int fd, const void* buf, size_t len) {
|
||||
const char* p = (const char*)buf;
|
||||
while (len > 0) {
|
||||
ssize_t n = write(fd, p, len);
|
||||
if (n <= 0) return -1;
|
||||
p += n;
|
||||
len -= (size_t)n;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Load a shared library directly from an in-memory file descriptor.
|
||||
static void* so_open_fd(int fd, char** errOut) {
|
||||
char path[64];
|
||||
snprintf(path, sizeof(path), "/proc/self/fd/%d", fd);
|
||||
void* h = dlopen(path, RTLD_NOW | RTLD_LOCAL);
|
||||
if (!h && errOut) *errOut = (char*)dlerror();
|
||||
return h;
|
||||
}
|
||||
|
||||
static void* so_dlsym(void* h, const char* name, char** errOut) {
|
||||
void* s = dlsym(h, name);
|
||||
if (!s && errOut) *errOut = (char*)dlerror();
|
||||
return s;
|
||||
}
|
||||
|
||||
static int so_dlclose(void* h) {
|
||||
if (h) return dlclose(h);
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Go callback bridge — forward-declared here, implemented via //export below.
|
||||
extern void goPluginHostCallback(
|
||||
uintptr_t ctx,
|
||||
const char* event, int eventLen,
|
||||
const char* payload, int payloadLen);
|
||||
|
||||
// Call the plugin's PluginOnLoad, injecting our callback bridge + context.
|
||||
static int so_call_onload(void* fn, const char* info, int infoLen, uintptr_t ctx) {
|
||||
typedef int (*fn_t)(const char*, int, uintptr_t, uintptr_t);
|
||||
return ((fn_t)fn)(info, infoLen, (uintptr_t)goPluginHostCallback, ctx);
|
||||
}
|
||||
|
||||
static int so_call_onevent(void* fn, const char* ev, int evLen, const char* pl, int plLen) {
|
||||
typedef int (*fn_t)(const char*, int, const char*, int);
|
||||
return ((fn_t)fn)(ev, evLen, pl, plLen);
|
||||
}
|
||||
|
||||
static void so_call_onunload(void* fn) {
|
||||
typedef void (*fn_t)(void);
|
||||
((fn_t)fn)();
|
||||
}
|
||||
*/
|
||||
import "C"
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"runtime/cgo"
|
||||
"sync"
|
||||
"unsafe"
|
||||
)
|
||||
|
||||
//export goPluginHostCallback
|
||||
func goPluginHostCallback(ctx C.uintptr_t, event *C.char, eventLen C.int, payload *C.char, payloadLen C.int) {
|
||||
h := cgo.Handle(ctx)
|
||||
send := h.Value().(func(string, []byte))
|
||||
ev := C.GoStringN(event, eventLen)
|
||||
var pl []byte
|
||||
if payloadLen > 0 {
|
||||
pl = C.GoBytes(unsafe.Pointer(payload), payloadLen)
|
||||
}
|
||||
send(ev, pl)
|
||||
}
|
||||
|
||||
func loadNativePlugin(data []byte) (NativePlugin, error) {
|
||||
if len(data) == 0 {
|
||||
return nil, errors.New("empty plugin binary")
|
||||
}
|
||||
|
||||
fd := C.so_memfd_create()
|
||||
if fd < 0 {
|
||||
return nil, errors.New("memfd_create failed")
|
||||
}
|
||||
|
||||
if C.so_write_all(fd, unsafe.Pointer(&data[0]), C.size_t(len(data))) != 0 {
|
||||
C.close(fd)
|
||||
return nil, errors.New("write to memfd failed")
|
||||
}
|
||||
|
||||
var cErr *C.char
|
||||
handle := C.so_open_fd(fd, &cErr)
|
||||
C.close(fd)
|
||||
if handle == nil {
|
||||
return nil, fmt.Errorf("dlopen: %s", C.GoString(cErr))
|
||||
}
|
||||
|
||||
resolve := func(name string) (unsafe.Pointer, error) {
|
||||
cName := C.CString(name)
|
||||
defer C.free(unsafe.Pointer(cName))
|
||||
var cErr *C.char
|
||||
s := C.so_dlsym(handle, cName, &cErr)
|
||||
if s == nil {
|
||||
return nil, fmt.Errorf("dlsym(%s): %s", name, C.GoString(cErr))
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
onLoad, err := resolve("PluginOnLoad")
|
||||
if err != nil {
|
||||
C.so_dlclose(handle)
|
||||
return nil, err
|
||||
}
|
||||
onEvent, err := resolve("PluginOnEvent")
|
||||
if err != nil {
|
||||
C.so_dlclose(handle)
|
||||
return nil, err
|
||||
}
|
||||
onUnload, err := resolve("PluginOnUnload")
|
||||
if err != nil {
|
||||
C.so_dlclose(handle)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &soPlugin{
|
||||
handle: handle,
|
||||
onLoadFn: onLoad,
|
||||
onEventFn: onEvent,
|
||||
onUnloadFn: onUnload,
|
||||
}, nil
|
||||
}
|
||||
|
||||
type soPlugin struct {
|
||||
handle unsafe.Pointer
|
||||
onLoadFn unsafe.Pointer
|
||||
onEventFn unsafe.Pointer
|
||||
onUnloadFn unsafe.Pointer
|
||||
cbHandle cgo.Handle
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
func (p *soPlugin) Load(send func(string, []byte), hostInfo []byte) error {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
|
||||
p.cbHandle = cgo.NewHandle(send)
|
||||
|
||||
var infoPtr *C.char
|
||||
if len(hostInfo) > 0 {
|
||||
infoPtr = (*C.char)(unsafe.Pointer(&hostInfo[0]))
|
||||
}
|
||||
|
||||
ret := C.so_call_onload(p.onLoadFn, infoPtr, C.int(len(hostInfo)), C.uintptr_t(p.cbHandle))
|
||||
if ret != 0 {
|
||||
p.cbHandle.Delete()
|
||||
p.cbHandle = 0
|
||||
return errors.New("PluginOnLoad returned error")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *soPlugin) Event(event string, payload []byte) error {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
|
||||
evBytes := []byte(event)
|
||||
var evPtr, plPtr *C.char
|
||||
if len(evBytes) > 0 {
|
||||
evPtr = (*C.char)(unsafe.Pointer(&evBytes[0]))
|
||||
}
|
||||
if len(payload) > 0 {
|
||||
plPtr = (*C.char)(unsafe.Pointer(&payload[0]))
|
||||
}
|
||||
|
||||
ret := C.so_call_onevent(p.onEventFn, evPtr, C.int(len(evBytes)), plPtr, C.int(len(payload)))
|
||||
if ret != 0 {
|
||||
return errors.New("PluginOnEvent returned error")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *soPlugin) Unload() {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
if p.onUnloadFn != nil {
|
||||
C.so_call_onunload(p.onUnloadFn)
|
||||
p.onUnloadFn = nil
|
||||
}
|
||||
}
|
||||
|
||||
func (p *soPlugin) Close() error {
|
||||
p.Unload()
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
if p.cbHandle != 0 {
|
||||
p.cbHandle.Delete()
|
||||
p.cbHandle = 0
|
||||
}
|
||||
if p.handle != nil {
|
||||
C.so_dlclose(p.handle)
|
||||
p.handle = nil
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
//go:build !(linux && cgo) && !(darwin && cgo) && !windows
|
||||
|
||||
package plugins
|
||||
|
||||
import "errors"
|
||||
|
||||
func loadNativePlugin(data []byte) (NativePlugin, error) {
|
||||
return nil, errors.New("native plugins not supported on this platform (requires cgo on linux/darwin, or windows)")
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
//go:build windows
|
||||
|
||||
package plugins
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"sync"
|
||||
"syscall"
|
||||
"unsafe"
|
||||
)
|
||||
|
||||
func loadNativePlugin(data []byte) (NativePlugin, error) {
|
||||
if len(data) == 0 {
|
||||
return nil, errors.New("empty plugin binary")
|
||||
}
|
||||
|
||||
mm, err := LoadMemoryModule(data)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("pe load: %w", err)
|
||||
}
|
||||
|
||||
if err := mm.CallEntryPoint(dllProcessAttach); err != nil {
|
||||
mm.Free()
|
||||
return nil, fmt.Errorf("DllMain init: %w", err)
|
||||
}
|
||||
|
||||
onLoad, err := mm.GetExport("PluginOnLoad")
|
||||
if err != nil {
|
||||
mm.Free()
|
||||
return nil, err
|
||||
}
|
||||
onEvent, err := mm.GetExport("PluginOnEvent")
|
||||
if err != nil {
|
||||
mm.Free()
|
||||
return nil, err
|
||||
}
|
||||
onUnload, err := mm.GetExport("PluginOnUnload")
|
||||
if err != nil {
|
||||
mm.Free()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
setCallback, _ := mm.GetExport("PluginSetCallback")
|
||||
|
||||
dp := &dllPlugin{
|
||||
mem: mm,
|
||||
onLoadAddr: onLoad,
|
||||
onEventAddr: onEvent,
|
||||
onUnloadAddr: onUnload,
|
||||
setCallbackAddr: setCallback,
|
||||
}
|
||||
return dp, nil
|
||||
}
|
||||
|
||||
type dllPlugin struct {
|
||||
mem *MemoryModule
|
||||
onLoadAddr uintptr
|
||||
onEventAddr uintptr
|
||||
onUnloadAddr uintptr
|
||||
setCallbackAddr uintptr
|
||||
callbackHandle uintptr // prevent GC of the callback closure
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
func (p *dllPlugin) Load(send func(string, []byte), hostInfo []byte) error {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
|
||||
// Create a stdcall callback the DLL can invoke to send events to the host.
|
||||
cb := syscall.NewCallback(func(eventPtr, eventLen, payloadPtr, payloadLen uintptr) uintptr {
|
||||
event := make([]byte, eventLen)
|
||||
if eventLen > 0 {
|
||||
copy(event, unsafe.Slice((*byte)(unsafe.Pointer(eventPtr)), eventLen))
|
||||
}
|
||||
payload := make([]byte, payloadLen)
|
||||
if payloadLen > 0 {
|
||||
copy(payload, unsafe.Slice((*byte)(unsafe.Pointer(payloadPtr)), payloadLen))
|
||||
}
|
||||
send(string(event), payload)
|
||||
return 0
|
||||
})
|
||||
p.callbackHandle = cb
|
||||
|
||||
if p.setCallbackAddr != 0 {
|
||||
syscall.SyscallN(p.setCallbackAddr, cb)
|
||||
}
|
||||
|
||||
var infoPtr uintptr
|
||||
infoLen := uintptr(len(hostInfo))
|
||||
if len(hostInfo) > 0 {
|
||||
infoPtr = uintptr(unsafe.Pointer(&hostInfo[0]))
|
||||
}
|
||||
ret, _, _ := syscall.SyscallN(p.onLoadAddr, infoPtr, infoLen, cb)
|
||||
if int32(ret) != 0 {
|
||||
return errors.New("PluginOnLoad returned non-zero")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *dllPlugin) Event(event string, payload []byte) error {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
|
||||
eventBytes := []byte(event)
|
||||
var eventPtr, payloadPtr uintptr
|
||||
eventLen := uintptr(len(eventBytes))
|
||||
payloadLen := uintptr(len(payload))
|
||||
if len(eventBytes) > 0 {
|
||||
eventPtr = uintptr(unsafe.Pointer(&eventBytes[0]))
|
||||
}
|
||||
if len(payload) > 0 {
|
||||
payloadPtr = uintptr(unsafe.Pointer(&payload[0]))
|
||||
}
|
||||
|
||||
ret, _, _ := syscall.SyscallN(p.onEventAddr, eventPtr, eventLen, payloadPtr, payloadLen)
|
||||
if int32(ret) != 0 {
|
||||
return errors.New("PluginOnEvent returned non-zero")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *dllPlugin) Unload() {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
if p.onUnloadAddr != 0 {
|
||||
syscall.SyscallN(p.onUnloadAddr)
|
||||
}
|
||||
}
|
||||
|
||||
func (p *dllPlugin) Close() error {
|
||||
p.Unload()
|
||||
if p.mem != nil {
|
||||
p.mem.Free()
|
||||
p.mem = nil
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -2,16 +2,11 @@ package plugins
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"log"
|
||||
"sync"
|
||||
|
||||
"github.com/tetratelabs/wazero"
|
||||
"github.com/tetratelabs/wazero/api"
|
||||
"github.com/tetratelabs/wazero/imports/wasi_snapshot_preview1"
|
||||
"github.com/vmihailenco/msgpack/v5"
|
||||
|
||||
"overlord-client/cmd/agent/wire"
|
||||
)
|
||||
|
||||
@@ -26,16 +21,7 @@ type Manager struct {
|
||||
type pluginInstance struct {
|
||||
id string
|
||||
manifest PluginManifest
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
runtime wazero.Runtime
|
||||
module api.Module
|
||||
stdin io.WriteCloser
|
||||
stdout io.ReadCloser
|
||||
stderr io.ReadCloser
|
||||
enc *msgpack.Encoder
|
||||
dec *msgpack.Decoder
|
||||
encMu sync.Mutex
|
||||
native NativePlugin
|
||||
}
|
||||
|
||||
type pendingBundle struct {
|
||||
@@ -135,9 +121,9 @@ func (m *Manager) FinalizeBundle(ctx context.Context, pluginId string) error {
|
||||
return m.Load(ctx, manifest, combined)
|
||||
}
|
||||
|
||||
func (m *Manager) Load(ctx context.Context, manifest PluginManifest, wasm []byte) error {
|
||||
if len(wasm) == 0 {
|
||||
return errors.New("empty wasm payload")
|
||||
func (m *Manager) Load(ctx context.Context, manifest PluginManifest, binary []byte) error {
|
||||
if len(binary) == 0 {
|
||||
return errors.New("empty plugin binary")
|
||||
}
|
||||
pluginID := manifest.ID
|
||||
if pluginID == "" {
|
||||
@@ -146,71 +132,51 @@ func (m *Manager) Load(ctx context.Context, manifest PluginManifest, wasm []byte
|
||||
|
||||
m.mu.Lock()
|
||||
if existing, ok := m.plugins[pluginID]; ok {
|
||||
_ = existing.Close()
|
||||
existing.native.Close()
|
||||
delete(m.plugins, pluginID)
|
||||
}
|
||||
m.mu.Unlock()
|
||||
|
||||
runtimeCtx, cancel := context.WithCancel(ctx)
|
||||
rt := wazero.NewRuntime(runtimeCtx)
|
||||
if _, err := wasi_snapshot_preview1.Instantiate(runtimeCtx, rt); err != nil {
|
||||
cancel()
|
||||
return err
|
||||
}
|
||||
|
||||
stdinR, stdinW := io.Pipe()
|
||||
stdoutR, stdoutW := io.Pipe()
|
||||
stderrR, stderrW := io.Pipe()
|
||||
|
||||
config := wazero.NewModuleConfig().
|
||||
WithStdin(stdinR).
|
||||
WithStdout(stdoutW).
|
||||
WithStderr(stderrW).
|
||||
WithStartFunctions()
|
||||
|
||||
compiled, err := rt.CompileModule(runtimeCtx, wasm)
|
||||
np, err := loadNativePlugin(binary)
|
||||
if err != nil {
|
||||
cancel()
|
||||
return err
|
||||
}
|
||||
|
||||
module, err := rt.InstantiateModule(runtimeCtx, compiled, config)
|
||||
if err != nil {
|
||||
cancel()
|
||||
return err
|
||||
}
|
||||
|
||||
pi := &pluginInstance{
|
||||
id: pluginID,
|
||||
manifest: manifest,
|
||||
ctx: runtimeCtx,
|
||||
cancel: cancel,
|
||||
runtime: rt,
|
||||
module: module,
|
||||
stdin: stdinW,
|
||||
stdout: stdoutR,
|
||||
stderr: stderrR,
|
||||
enc: msgpack.NewEncoder(stdinW),
|
||||
dec: msgpack.NewDecoder(stdoutR),
|
||||
native: np,
|
||||
}
|
||||
|
||||
goSafe("plugin readLoop", pi.cancel, func() {
|
||||
m.readLoop(pi)
|
||||
})
|
||||
goSafe("plugin readStderr", pi.cancel, func() {
|
||||
m.readStderr(pi)
|
||||
})
|
||||
|
||||
if start := module.ExportedFunction("_start"); start != nil {
|
||||
goSafe("plugin _start", pi.cancel, func() {
|
||||
if _, err := start.Call(runtimeCtx); err != nil {
|
||||
log.Printf("[plugin] %s _start error: %v", pluginID, err)
|
||||
send := func(event string, payload []byte) {
|
||||
var payloadVal interface{}
|
||||
if len(payload) > 0 {
|
||||
var parsed interface{}
|
||||
if json.Unmarshal(payload, &parsed) == nil {
|
||||
payloadVal = parsed
|
||||
} else {
|
||||
payloadVal = string(payload)
|
||||
}
|
||||
}
|
||||
err := wire.WriteMsg(context.Background(), m.writer, wire.PluginEvent{
|
||||
Type: "plugin_event",
|
||||
PluginID: pluginID,
|
||||
Event: event,
|
||||
Payload: payloadVal,
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("[plugin] %s send event error: %v", pluginID, err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := m.sendInit(pi); err != nil {
|
||||
_ = pi.Close()
|
||||
hostJSON, err := json.Marshal(m.host)
|
||||
if err != nil {
|
||||
np.Close()
|
||||
return err
|
||||
}
|
||||
|
||||
if err := np.Load(send, hostJSON); err != nil {
|
||||
np.Close()
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -218,7 +184,7 @@ func (m *Manager) Load(ctx context.Context, manifest PluginManifest, wasm []byte
|
||||
m.plugins[pluginID] = pi
|
||||
m.mu.Unlock()
|
||||
|
||||
log.Printf("[plugin] loaded %s", pluginID)
|
||||
log.Printf("[plugin] loaded %s (native)", pluginID)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -229,15 +195,19 @@ func (m *Manager) Dispatch(ctx context.Context, pluginId, event string, payload
|
||||
if pi == nil {
|
||||
return nil
|
||||
}
|
||||
msg := PluginMessage{Type: "event", Event: event, Payload: payload}
|
||||
return m.sendMessage(ctx, pi, msg)
|
||||
|
||||
data, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return pi.native.Event(event, data)
|
||||
}
|
||||
|
||||
func (m *Manager) Close() {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
for id, pi := range m.plugins {
|
||||
_ = pi.Close()
|
||||
pi.native.Close()
|
||||
delete(m.plugins, id)
|
||||
}
|
||||
}
|
||||
@@ -246,103 +216,7 @@ func (m *Manager) Unload(pluginId string) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if pi, ok := m.plugins[pluginId]; ok {
|
||||
_ = pi.Close()
|
||||
pi.native.Close()
|
||||
delete(m.plugins, pluginId)
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) sendInit(pi *pluginInstance) error {
|
||||
payload := map[string]interface{}{
|
||||
"manifest": pi.manifest,
|
||||
"host": m.host,
|
||||
}
|
||||
msg := PluginMessage{Type: "init", Payload: payload}
|
||||
return m.sendMessage(context.Background(), pi, msg)
|
||||
}
|
||||
|
||||
func (m *Manager) sendMessage(ctx context.Context, pi *pluginInstance, msg PluginMessage) error {
|
||||
pi.encMu.Lock()
|
||||
defer pi.encMu.Unlock()
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
default:
|
||||
return pi.enc.Encode(msg)
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) readLoop(pi *pluginInstance) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
log.Printf("[plugin] %s read loop panic: %v", pi.id, r)
|
||||
}
|
||||
}()
|
||||
for {
|
||||
var msg map[string]interface{}
|
||||
if err := pi.dec.Decode(&msg); err != nil {
|
||||
if errors.Is(err, io.EOF) || errors.Is(err, context.Canceled) {
|
||||
return
|
||||
}
|
||||
log.Printf("[plugin] %s read error: %v", pi.id, err)
|
||||
return
|
||||
}
|
||||
m.handlePluginMessage(pi.id, msg)
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) readStderr(pi *pluginInstance) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
log.Printf("[plugin] %s stderr loop panic: %v", pi.id, r)
|
||||
}
|
||||
}()
|
||||
buf := make([]byte, 4096)
|
||||
for {
|
||||
n, err := pi.stderr.Read(buf)
|
||||
if n > 0 {
|
||||
log.Printf("[plugin] %s stderr: %s", pi.id, string(buf[:n]))
|
||||
}
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) handlePluginMessage(pluginId string, msg map[string]interface{}) {
|
||||
msgType, _ := msg["type"].(string)
|
||||
if msgType == "log" {
|
||||
log.Printf("[plugin] %s log: %v", pluginId, msg["payload"])
|
||||
return
|
||||
}
|
||||
if msgType == "event" {
|
||||
event, _ := msg["event"].(string)
|
||||
payload := msg["payload"]
|
||||
if event == "" {
|
||||
return
|
||||
}
|
||||
err := wire.WriteMsg(context.Background(), m.writer, wire.PluginEvent{
|
||||
Type: "plugin_event",
|
||||
PluginID: pluginId,
|
||||
Event: event,
|
||||
Payload: payload,
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("[plugin] %s send event error: %v", pluginId, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func (pi *pluginInstance) Close() error {
|
||||
pi.cancel()
|
||||
_ = pi.stdin.Close()
|
||||
_ = pi.stdout.Close()
|
||||
_ = pi.stderr.Close()
|
||||
if pi.module != nil {
|
||||
_ = pi.module.Close(context.Background())
|
||||
}
|
||||
if pi.runtime != nil {
|
||||
_ = pi.runtime.Close(context.Background())
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,524 @@
|
||||
//go:build windows
|
||||
|
||||
package plugins
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"syscall"
|
||||
"unsafe"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
const (
|
||||
imageDOSSignature = 0x5A4D // "MZ"
|
||||
imageNTSignature = 0x00004550 // "PE\0\0"
|
||||
|
||||
imageOptionalHdrMagicPE32Plus = 0x20B
|
||||
|
||||
imageDirectoryEntryExport = 0
|
||||
imageDirectoryEntryImport = 1
|
||||
imageDirectoryEntryBaseReloc = 5
|
||||
imageDirectoryEntryTLS = 9
|
||||
|
||||
imageRelBasedDir64 = 10
|
||||
imageRelBasedHighLow = 3
|
||||
imageRelBasedAbsolute = 0
|
||||
|
||||
imageSCNMemExecute = 0x20000000
|
||||
imageSCNMemRead = 0x40000000
|
||||
imageSCNMemWrite = 0x80000000
|
||||
|
||||
dllProcessAttach = 1
|
||||
dllProcessDetach = 0
|
||||
)
|
||||
|
||||
type imageDOSHeader struct {
|
||||
Magic uint16
|
||||
_ [28]uint16
|
||||
LfaNew int32
|
||||
}
|
||||
|
||||
type imageFileHeader struct {
|
||||
Machine uint16
|
||||
NumberOfSections uint16
|
||||
TimeDateStamp uint32
|
||||
PointerToSymbolTable uint32
|
||||
NumberOfSymbols uint32
|
||||
SizeOfOptionalHeader uint16
|
||||
Characteristics uint16
|
||||
}
|
||||
|
||||
type imageDataDirectory struct {
|
||||
VirtualAddress uint32
|
||||
Size uint32
|
||||
}
|
||||
|
||||
type imageOptionalHeader64 struct {
|
||||
Magic uint16
|
||||
_ [14]byte
|
||||
AddressOfEntryPoint uint32
|
||||
_ [4]byte
|
||||
ImageBase uint64
|
||||
SectionAlignment uint32
|
||||
FileAlignment uint32
|
||||
_ [16]byte
|
||||
SizeOfImage uint32
|
||||
SizeOfHeaders uint32
|
||||
_ [4]byte
|
||||
_ [4]byte // Subsystem + DllCharacteristics
|
||||
_ [36]byte
|
||||
NumberOfRvaAndSizes uint32
|
||||
DataDirectory [16]imageDataDirectory
|
||||
}
|
||||
|
||||
type imageNTHeaders64 struct {
|
||||
Signature uint32
|
||||
FileHeader imageFileHeader
|
||||
OptionalHeader imageOptionalHeader64
|
||||
}
|
||||
|
||||
type imageSectionHeader struct {
|
||||
Name [8]byte
|
||||
VirtualSize uint32
|
||||
VirtualAddress uint32
|
||||
SizeOfRawData uint32
|
||||
PointerToRawData uint32
|
||||
_ [12]byte
|
||||
Characteristics uint32
|
||||
}
|
||||
|
||||
type imageImportDescriptor struct {
|
||||
OriginalFirstThunk uint32
|
||||
TimeDateStamp uint32
|
||||
ForwarderChain uint32
|
||||
Name uint32
|
||||
FirstThunk uint32
|
||||
}
|
||||
|
||||
type imageBaseRelocation struct {
|
||||
VirtualAddress uint32
|
||||
SizeOfBlock uint32
|
||||
}
|
||||
|
||||
type imageExportDirectory struct {
|
||||
_ [12]byte
|
||||
Name uint32
|
||||
Base uint32
|
||||
NumberOfFunctions uint32
|
||||
NumberOfNames uint32
|
||||
AddressOfFunctions uint32
|
||||
AddressOfNames uint32
|
||||
AddressOfNameOrdinals uint32
|
||||
}
|
||||
|
||||
type imageTLSDirectory64 struct {
|
||||
StartAddressOfRawData uint64
|
||||
EndAddressOfRawData uint64
|
||||
AddressOfIndex uint64
|
||||
AddressOfCallBacks uint64
|
||||
SizeOfZeroFill uint32
|
||||
Characteristics uint32
|
||||
}
|
||||
|
||||
type MemoryModule struct {
|
||||
base uintptr
|
||||
size uintptr
|
||||
entryPoint uintptr
|
||||
exports map[string]uintptr
|
||||
importDLLs []windows.Handle
|
||||
initialized bool
|
||||
tlsIndex uint32
|
||||
tlsData uintptr
|
||||
}
|
||||
|
||||
func LoadMemoryModule(data []byte) (*MemoryModule, error) {
|
||||
if len(data) < int(unsafe.Sizeof(imageDOSHeader{})) {
|
||||
return nil, errors.New("pe: data too small for DOS header")
|
||||
}
|
||||
|
||||
dosHdr := (*imageDOSHeader)(unsafe.Pointer(&data[0]))
|
||||
if dosHdr.Magic != imageDOSSignature {
|
||||
return nil, errors.New("pe: invalid DOS signature")
|
||||
}
|
||||
|
||||
ntOffset := int(dosHdr.LfaNew)
|
||||
if ntOffset < 0 || ntOffset+int(unsafe.Sizeof(imageNTHeaders64{})) > len(data) {
|
||||
return nil, errors.New("pe: NT headers out of bounds")
|
||||
}
|
||||
ntHdr := (*imageNTHeaders64)(unsafe.Pointer(&data[ntOffset]))
|
||||
if ntHdr.Signature != imageNTSignature {
|
||||
return nil, errors.New("pe: invalid NT signature")
|
||||
}
|
||||
if ntHdr.OptionalHeader.Magic != imageOptionalHdrMagicPE32Plus {
|
||||
return nil, errors.New("pe: only PE32+ (64-bit) is supported")
|
||||
}
|
||||
|
||||
imageSize := uintptr(ntHdr.OptionalHeader.SizeOfImage)
|
||||
preferredBase := uintptr(ntHdr.OptionalHeader.ImageBase)
|
||||
|
||||
base, err := windows.VirtualAlloc(preferredBase, imageSize, windows.MEM_RESERVE|windows.MEM_COMMIT, windows.PAGE_READWRITE)
|
||||
if err != nil || base == 0 {
|
||||
base, err = windows.VirtualAlloc(0, imageSize, windows.MEM_RESERVE|windows.MEM_COMMIT, windows.PAGE_READWRITE)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("pe: VirtualAlloc failed: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
mm := &MemoryModule{
|
||||
base: base,
|
||||
size: imageSize,
|
||||
exports: make(map[string]uintptr),
|
||||
tlsIndex: 0xFFFFFFFF,
|
||||
}
|
||||
|
||||
headerSize := uintptr(ntHdr.OptionalHeader.SizeOfHeaders)
|
||||
if headerSize > uintptr(len(data)) {
|
||||
headerSize = uintptr(len(data))
|
||||
}
|
||||
copyMem(base, &data[0], headerSize)
|
||||
|
||||
sectionStart := ntOffset + int(unsafe.Sizeof(ntHdr.Signature)) +
|
||||
int(unsafe.Sizeof(ntHdr.FileHeader)) +
|
||||
int(ntHdr.FileHeader.SizeOfOptionalHeader)
|
||||
|
||||
for i := 0; i < int(ntHdr.FileHeader.NumberOfSections); i++ {
|
||||
off := sectionStart + i*int(unsafe.Sizeof(imageSectionHeader{}))
|
||||
if off+int(unsafe.Sizeof(imageSectionHeader{})) > len(data) {
|
||||
mm.Free()
|
||||
return nil, errors.New("pe: section header out of bounds")
|
||||
}
|
||||
sec := (*imageSectionHeader)(unsafe.Pointer(&data[off]))
|
||||
if sec.SizeOfRawData > 0 {
|
||||
rawOff := int(sec.PointerToRawData)
|
||||
rawEnd := rawOff + int(sec.SizeOfRawData)
|
||||
if rawEnd > len(data) {
|
||||
mm.Free()
|
||||
return nil, errors.New("pe: section raw data out of bounds")
|
||||
}
|
||||
copySize := uintptr(sec.SizeOfRawData)
|
||||
if sec.VirtualSize > 0 && uintptr(sec.VirtualSize) < copySize {
|
||||
copySize = uintptr(sec.VirtualSize)
|
||||
}
|
||||
dest := uintptr(sec.VirtualAddress)
|
||||
if dest+copySize > imageSize {
|
||||
if dest >= imageSize {
|
||||
continue
|
||||
}
|
||||
copySize = imageSize - dest
|
||||
}
|
||||
copyMem(base+dest, &data[rawOff], copySize)
|
||||
}
|
||||
}
|
||||
|
||||
mappedNT := (*imageNTHeaders64)(unsafe.Pointer(base + uintptr(dosHdr.LfaNew)))
|
||||
delta := int64(base) - int64(mappedNT.OptionalHeader.ImageBase)
|
||||
|
||||
if delta != 0 {
|
||||
relocDir := mappedNT.OptionalHeader.DataDirectory[imageDirectoryEntryBaseReloc]
|
||||
if relocDir.VirtualAddress != 0 && relocDir.Size != 0 {
|
||||
if err := mm.processRelocations(relocDir, delta); err != nil {
|
||||
mm.Free()
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
importDir := mappedNT.OptionalHeader.DataDirectory[imageDirectoryEntryImport]
|
||||
if importDir.VirtualAddress != 0 && importDir.Size != 0 {
|
||||
if err := mm.resolveImports(importDir); err != nil {
|
||||
mm.Free()
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
for i := 0; i < int(ntHdr.FileHeader.NumberOfSections); i++ {
|
||||
off := sectionStart + i*int(unsafe.Sizeof(imageSectionHeader{}))
|
||||
sec := (*imageSectionHeader)(unsafe.Pointer(&data[off]))
|
||||
prot := sectionProtection(sec.Characteristics)
|
||||
size := uintptr(sec.VirtualSize)
|
||||
if size == 0 {
|
||||
size = uintptr(sec.SizeOfRawData)
|
||||
}
|
||||
if size == 0 {
|
||||
continue
|
||||
}
|
||||
var oldProt uint32
|
||||
_ = windows.VirtualProtect(base+uintptr(sec.VirtualAddress), size, prot, &oldProt)
|
||||
}
|
||||
|
||||
mm.setupTLS(dllProcessAttach)
|
||||
|
||||
if mappedNT.OptionalHeader.AddressOfEntryPoint != 0 {
|
||||
mm.entryPoint = base + uintptr(mappedNT.OptionalHeader.AddressOfEntryPoint)
|
||||
}
|
||||
|
||||
exportDir := mappedNT.OptionalHeader.DataDirectory[imageDirectoryEntryExport]
|
||||
if exportDir.VirtualAddress != 0 && exportDir.Size != 0 {
|
||||
mm.parseExports(exportDir)
|
||||
}
|
||||
|
||||
return mm, nil
|
||||
}
|
||||
|
||||
func (mm *MemoryModule) CallEntryPoint(reason uint32) error {
|
||||
if mm.entryPoint == 0 {
|
||||
return nil
|
||||
}
|
||||
ret, _, _ := syscall.SyscallN(mm.entryPoint, mm.base, uintptr(reason), 0)
|
||||
if ret == 0 && reason == dllProcessAttach {
|
||||
return errors.New("pe: DllMain returned FALSE")
|
||||
}
|
||||
mm.initialized = (reason == dllProcessAttach)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (mm *MemoryModule) GetExport(name string) (uintptr, error) {
|
||||
addr, ok := mm.exports[name]
|
||||
if !ok {
|
||||
return 0, fmt.Errorf("pe: export %q not found", name)
|
||||
}
|
||||
return addr, nil
|
||||
}
|
||||
|
||||
func (mm *MemoryModule) Free() {
|
||||
if mm.base == 0 {
|
||||
return
|
||||
}
|
||||
if mm.initialized && mm.entryPoint != 0 {
|
||||
syscall.SyscallN(mm.entryPoint, mm.base, dllProcessDetach, 0)
|
||||
mm.initialized = false
|
||||
}
|
||||
if mm.tlsIndex != 0xFFFFFFFF {
|
||||
if mm.tlsData != 0 {
|
||||
_ = windows.VirtualFree(mm.tlsData, 0, windows.MEM_RELEASE)
|
||||
}
|
||||
procTlsFree.Call(uintptr(mm.tlsIndex))
|
||||
}
|
||||
for _, h := range mm.importDLLs {
|
||||
_ = windows.FreeLibrary(h)
|
||||
}
|
||||
mm.importDLLs = nil
|
||||
_ = windows.VirtualFree(mm.base, 0, windows.MEM_RELEASE)
|
||||
mm.base = 0
|
||||
}
|
||||
|
||||
func (mm *MemoryModule) processRelocations(dir imageDataDirectory, delta int64) error {
|
||||
offset := uintptr(dir.VirtualAddress)
|
||||
end := offset + uintptr(dir.Size)
|
||||
|
||||
for offset < end {
|
||||
block := (*imageBaseRelocation)(unsafe.Pointer(mm.base + offset))
|
||||
if block.SizeOfBlock == 0 {
|
||||
break
|
||||
}
|
||||
count := (block.SizeOfBlock - 8) / 2
|
||||
entries := mm.base + offset + 8
|
||||
|
||||
for i := uint32(0); i < count; i++ {
|
||||
entry := *(*uint16)(unsafe.Pointer(entries + uintptr(i)*2))
|
||||
typ := entry >> 12
|
||||
off := uintptr(entry & 0xFFF)
|
||||
addr := mm.base + uintptr(block.VirtualAddress) + off
|
||||
|
||||
switch typ {
|
||||
case imageRelBasedAbsolute:
|
||||
case imageRelBasedHighLow:
|
||||
val := (*uint32)(unsafe.Pointer(addr))
|
||||
*val = uint32(int64(*val) + delta)
|
||||
case imageRelBasedDir64:
|
||||
val := (*uint64)(unsafe.Pointer(addr))
|
||||
*val = uint64(int64(*val) + delta)
|
||||
default:
|
||||
return fmt.Errorf("pe: unsupported relocation type %d", typ)
|
||||
}
|
||||
}
|
||||
offset += uintptr(block.SizeOfBlock)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (mm *MemoryModule) resolveImports(dir imageDataDirectory) error {
|
||||
descSize := unsafe.Sizeof(imageImportDescriptor{})
|
||||
offset := uintptr(dir.VirtualAddress)
|
||||
|
||||
for {
|
||||
desc := (*imageImportDescriptor)(unsafe.Pointer(mm.base + offset))
|
||||
if desc.Name == 0 {
|
||||
break
|
||||
}
|
||||
|
||||
dllName := peString(mm.base + uintptr(desc.Name))
|
||||
hDLL, err := windows.LoadLibrary(dllName)
|
||||
if err != nil {
|
||||
return fmt.Errorf("pe: LoadLibrary(%s): %w", dllName, err)
|
||||
}
|
||||
mm.importDLLs = append(mm.importDLLs, hDLL)
|
||||
|
||||
thunkRef := mm.base + uintptr(desc.OriginalFirstThunk)
|
||||
thunkAddr := mm.base + uintptr(desc.FirstThunk)
|
||||
if desc.OriginalFirstThunk == 0 {
|
||||
thunkRef = thunkAddr
|
||||
}
|
||||
|
||||
for {
|
||||
ref := *(*uint64)(unsafe.Pointer(thunkRef))
|
||||
if ref == 0 {
|
||||
break
|
||||
}
|
||||
|
||||
var procAddr uintptr
|
||||
if ref&(1<<63) != 0 {
|
||||
ordinal := uint16(ref & 0xFFFF)
|
||||
procAddr, err = getProcByOrdinal(hDLL, ordinal)
|
||||
} else {
|
||||
nameAddr := mm.base + uintptr(ref) + 2
|
||||
funcName := peString(nameAddr)
|
||||
procAddr, err = windows.GetProcAddress(hDLL, funcName)
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("pe: import resolve from %s: %w", dllName, err)
|
||||
}
|
||||
|
||||
*(*uintptr)(unsafe.Pointer(thunkAddr)) = procAddr
|
||||
thunkRef += 8
|
||||
thunkAddr += 8
|
||||
}
|
||||
offset += descSize
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (mm *MemoryModule) parseExports(dir imageDataDirectory) {
|
||||
if dir.Size == 0 {
|
||||
return
|
||||
}
|
||||
expDir := (*imageExportDirectory)(unsafe.Pointer(mm.base + uintptr(dir.VirtualAddress)))
|
||||
numNames := int(expDir.NumberOfNames)
|
||||
if numNames == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
namesRVA := mm.base + uintptr(expDir.AddressOfNames)
|
||||
ordinalsRVA := mm.base + uintptr(expDir.AddressOfNameOrdinals)
|
||||
funcsRVA := mm.base + uintptr(expDir.AddressOfFunctions)
|
||||
|
||||
exportStart := uintptr(dir.VirtualAddress)
|
||||
exportEnd := exportStart + uintptr(dir.Size)
|
||||
|
||||
for i := 0; i < numNames; i++ {
|
||||
nameRVA := *(*uint32)(unsafe.Pointer(namesRVA + uintptr(i)*4))
|
||||
ordinal := *(*uint16)(unsafe.Pointer(ordinalsRVA + uintptr(i)*2))
|
||||
funcRVA := *(*uint32)(unsafe.Pointer(funcsRVA + uintptr(ordinal)*4))
|
||||
|
||||
if uintptr(funcRVA) >= exportStart && uintptr(funcRVA) < exportEnd {
|
||||
continue
|
||||
}
|
||||
|
||||
name := peString(mm.base + uintptr(nameRVA))
|
||||
mm.exports[name] = mm.base + uintptr(funcRVA)
|
||||
}
|
||||
}
|
||||
|
||||
func (mm *MemoryModule) setupTLS(reason uint32) {
|
||||
dosHdr := (*imageDOSHeader)(unsafe.Pointer(mm.base))
|
||||
ntHdr := (*imageNTHeaders64)(unsafe.Pointer(mm.base + uintptr(dosHdr.LfaNew)))
|
||||
tlsDir := ntHdr.OptionalHeader.DataDirectory[imageDirectoryEntryTLS]
|
||||
if tlsDir.VirtualAddress == 0 || tlsDir.Size == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
tls := (*imageTLSDirectory64)(unsafe.Pointer(mm.base + uintptr(tlsDir.VirtualAddress)))
|
||||
|
||||
idx, _, _ := procTlsAlloc.Call()
|
||||
if idx == 0xFFFFFFFF {
|
||||
return
|
||||
}
|
||||
mm.tlsIndex = uint32(idx)
|
||||
|
||||
if tls.AddressOfIndex != 0 {
|
||||
*(*uint32)(unsafe.Pointer(uintptr(tls.AddressOfIndex))) = mm.tlsIndex
|
||||
}
|
||||
|
||||
dataSize := uintptr(tls.EndAddressOfRawData - tls.StartAddressOfRawData)
|
||||
totalSize := dataSize + uintptr(tls.SizeOfZeroFill)
|
||||
if totalSize > 0 {
|
||||
tlsData, err := windows.VirtualAlloc(0, totalSize,
|
||||
windows.MEM_RESERVE|windows.MEM_COMMIT, windows.PAGE_READWRITE)
|
||||
if err == nil && tlsData != 0 {
|
||||
if dataSize > 0 {
|
||||
copyMem(tlsData, (*byte)(unsafe.Pointer(uintptr(tls.StartAddressOfRawData))), dataSize)
|
||||
}
|
||||
procTlsSetValue.Call(uintptr(mm.tlsIndex), tlsData)
|
||||
mm.tlsData = tlsData
|
||||
}
|
||||
}
|
||||
|
||||
if tls.AddressOfCallBacks != 0 {
|
||||
cbAddr := uintptr(tls.AddressOfCallBacks)
|
||||
for {
|
||||
cb := *(*uintptr)(unsafe.Pointer(cbAddr))
|
||||
if cb == 0 {
|
||||
break
|
||||
}
|
||||
syscall.SyscallN(cb, mm.base, uintptr(reason), 0)
|
||||
cbAddr += 8
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func peString(addr uintptr) string {
|
||||
var buf []byte
|
||||
for i := 0; i < 4096; i++ { // safety limit
|
||||
b := *(*byte)(unsafe.Pointer(addr + uintptr(i)))
|
||||
if b == 0 {
|
||||
break
|
||||
}
|
||||
buf = append(buf, b)
|
||||
}
|
||||
return string(buf)
|
||||
}
|
||||
|
||||
var (
|
||||
modKernel32 = windows.NewLazySystemDLL("kernel32.dll")
|
||||
procGetProcAddr = modKernel32.NewProc("GetProcAddress")
|
||||
procTlsAlloc = modKernel32.NewProc("TlsAlloc")
|
||||
procTlsSetValue = modKernel32.NewProc("TlsSetValue")
|
||||
procTlsFree = modKernel32.NewProc("TlsFree")
|
||||
)
|
||||
|
||||
func getProcByOrdinal(module windows.Handle, ordinal uint16) (uintptr, error) {
|
||||
r, _, err := procGetProcAddr.Call(uintptr(module), uintptr(ordinal))
|
||||
if r == 0 {
|
||||
return 0, fmt.Errorf("GetProcAddress ordinal %d: %w", ordinal, err)
|
||||
}
|
||||
return r, nil
|
||||
}
|
||||
|
||||
func sectionProtection(chars uint32) uint32 {
|
||||
exec := chars&imageSCNMemExecute != 0
|
||||
read := chars&imageSCNMemRead != 0
|
||||
write := chars&imageSCNMemWrite != 0
|
||||
|
||||
switch {
|
||||
case exec && write:
|
||||
return windows.PAGE_EXECUTE_READWRITE
|
||||
case exec && read:
|
||||
return windows.PAGE_EXECUTE_READ
|
||||
case exec:
|
||||
return windows.PAGE_EXECUTE
|
||||
case write:
|
||||
return windows.PAGE_READWRITE
|
||||
case read:
|
||||
return windows.PAGE_READONLY
|
||||
default:
|
||||
return windows.PAGE_NOACCESS
|
||||
}
|
||||
}
|
||||
|
||||
func copyMem(dst uintptr, src *byte, size uintptr) {
|
||||
dstSlice := unsafe.Slice((*byte)(unsafe.Pointer(dst)), size)
|
||||
srcSlice := unsafe.Slice(src, size)
|
||||
copy(dstSlice, srcSlice)
|
||||
}
|
||||
@@ -9,13 +9,14 @@ type PluginAssets struct {
|
||||
}
|
||||
|
||||
type PluginManifest struct {
|
||||
ID string `msgpack:"id" json:"id"`
|
||||
Name string `msgpack:"name" json:"name"`
|
||||
Version string `msgpack:"version,omitempty" json:"version,omitempty"`
|
||||
Description string `msgpack:"description,omitempty" json:"description,omitempty"`
|
||||
Binary string `msgpack:"binary,omitempty" json:"binary,omitempty"`
|
||||
Entry string `msgpack:"entry,omitempty" json:"entry,omitempty"`
|
||||
Assets PluginAssets `msgpack:"assets,omitempty" json:"assets,omitempty"`
|
||||
ID string `msgpack:"id" json:"id"`
|
||||
Name string `msgpack:"name" json:"name"`
|
||||
Version string `msgpack:"version,omitempty" json:"version,omitempty"`
|
||||
Description string `msgpack:"description,omitempty" json:"description,omitempty"`
|
||||
Binary string `msgpack:"binary,omitempty" json:"binary,omitempty"`
|
||||
Binaries map[string]string `msgpack:"binaries,omitempty" json:"binaries,omitempty"`
|
||||
Entry string `msgpack:"entry,omitempty" json:"entry,omitempty"`
|
||||
Assets PluginAssets `msgpack:"assets,omitempty" json:"assets,omitempty"`
|
||||
}
|
||||
|
||||
type PluginMessage struct {
|
||||
@@ -26,10 +27,20 @@ type PluginMessage struct {
|
||||
}
|
||||
|
||||
type HostInfo struct {
|
||||
ClientID string `msgpack:"clientId"`
|
||||
OS string `msgpack:"os"`
|
||||
Arch string `msgpack:"arch"`
|
||||
Version string `msgpack:"version"`
|
||||
ClientID string `msgpack:"clientId" json:"clientId"`
|
||||
OS string `msgpack:"os" json:"os"`
|
||||
Arch string `msgpack:"arch" json:"arch"`
|
||||
Version string `msgpack:"version" json:"version"`
|
||||
}
|
||||
|
||||
type NativePlugin interface {
|
||||
Load(send func(event string, payload []byte), hostInfo []byte) error
|
||||
|
||||
Event(event string, payload []byte) error
|
||||
|
||||
Unload()
|
||||
|
||||
Close() error
|
||||
}
|
||||
|
||||
func ManifestFromMap(m map[string]interface{}) (PluginManifest, error) {
|
||||
@@ -41,6 +52,15 @@ func ManifestFromMap(m map[string]interface{}) (PluginManifest, error) {
|
||||
manifest.Binary = stringVal(m["binary"])
|
||||
manifest.Entry = stringVal(m["entry"])
|
||||
|
||||
if binariesRaw, ok := m["binaries"].(map[string]interface{}); ok {
|
||||
manifest.Binaries = make(map[string]string, len(binariesRaw))
|
||||
for k, v := range binariesRaw {
|
||||
if s, ok := v.(string); ok {
|
||||
manifest.Binaries[k] = s
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if assetsRaw, ok := m["assets"].(map[string]interface{}); ok {
|
||||
manifest.Assets = PluginAssets{
|
||||
HTML: stringVal(assetsRaw["html"]),
|
||||
|
||||
@@ -7,7 +7,6 @@ require (
|
||||
github.com/gen2brain/x264-go v0.3.1 // optional: used when cgo is enabled
|
||||
github.com/kbinani/screenshot v0.0.0-20250624051815-089614a94018
|
||||
github.com/pixiv/go-libjpeg v0.0.0-20190822045933-3da21a74767d // optional: turbojpeg build tag
|
||||
github.com/tetratelabs/wazero v1.7.0
|
||||
github.com/vmihailenco/msgpack/v5 v5.4.1
|
||||
nhooyr.io/websocket v1.8.10
|
||||
)
|
||||
|
||||
@@ -28,8 +28,6 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/stretchr/testify v1.6.1 h1:hDPOHmpOpP40lSULcqw7IrRb/u7w6RpDC9399XyoNd0=
|
||||
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/tetratelabs/wazero v1.7.0 h1:jg5qPydno59wqjpGrHph81lbtHzTrWzwwtD4cD88+hQ=
|
||||
github.com/tetratelabs/wazero v1.7.0/go.mod h1:ytl6Zuh20R/eROuyDaGPkp82O9C/DJfXAwJfQ3X6/7Y=
|
||||
github.com/vmihailenco/msgpack/v5 v5.4.1 h1:cQriyiUvjTwOHg8QZaPihLWeRAAVoCpE00IUPn0Bjt8=
|
||||
github.com/vmihailenco/msgpack/v5 v5.4.1/go.mod h1:GaZTsDaehaPpQVyxrf5mtQlH+pc21PIudVV/E3rRQok=
|
||||
github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g=
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: "Segoe UI", system-ui, sans-serif;
|
||||
background: #0f1115;
|
||||
color: #e8ecf1;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 820px;
|
||||
margin: 40px auto;
|
||||
padding: 0 20px;
|
||||
}
|
||||
|
||||
.header h1 {
|
||||
margin: 0 0 4px 0;
|
||||
font-size: 28px;
|
||||
}
|
||||
|
||||
.sub {
|
||||
margin: 0 0 24px 0;
|
||||
color: #9aa4b2;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: #161a22;
|
||||
border: 1px solid #222938;
|
||||
border-radius: 12px;
|
||||
padding: 18px;
|
||||
margin-bottom: 18px;
|
||||
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.row {
|
||||
display: grid;
|
||||
grid-template-columns: 120px 1fr;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.label {
|
||||
color: #9aa4b2;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.input {
|
||||
padding: 10px 12px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #2a3346;
|
||||
background: #0f131b;
|
||||
color: #e8ecf1;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.btn {
|
||||
background: #3b82f6;
|
||||
color: #fff;
|
||||
border: none;
|
||||
padding: 10px 14px;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
background: #2563eb;
|
||||
}
|
||||
|
||||
.log {
|
||||
min-height: 120px;
|
||||
background: #0f131b;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #2a3346;
|
||||
padding: 12px;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
@@ -10,7 +10,7 @@
|
||||
<main class="container">
|
||||
<header class="header">
|
||||
<h1>Sample Plugin</h1>
|
||||
<p class="sub">Go WASM + UI plugin</p>
|
||||
<p class="sub">Go Native + UI plugin</p>
|
||||
</header>
|
||||
|
||||
<section class="card">
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"id": "sample",
|
||||
"name": "sample",
|
||||
"version": "1.0.0",
|
||||
"binaries": {
|
||||
"windows-amd64": "sample-windows-amd64.dll"
|
||||
},
|
||||
"entry": "sample.html",
|
||||
"assets": {
|
||||
"html": "sample.html",
|
||||
"css": "sample.css",
|
||||
"js": "sample.js"
|
||||
}
|
||||
}
|
||||
@@ -177,8 +177,8 @@ const ensurePluginExtracted = (pluginId: string) =>
|
||||
ensurePluginExtractedFromRoot(PLUGIN_ROOT, pluginId, sanitizePluginId);
|
||||
const listPluginManifests = () =>
|
||||
listPluginManifestsFromRoot(PLUGIN_ROOT, pluginState, savePluginState, ensurePluginExtracted);
|
||||
const loadPluginBundle = (pluginId: string) =>
|
||||
loadPluginBundleFromRoot(PLUGIN_ROOT, pluginId, ensurePluginExtracted);
|
||||
const loadPluginBundle = (pluginId: string, clientOS?: string, clientArch?: string) =>
|
||||
loadPluginBundleFromRoot(PLUGIN_ROOT, pluginId, ensurePluginExtracted, clientOS, clientArch);
|
||||
const startBuildProcess = (buildId: string, buildConfig: any) =>
|
||||
runBuildProcess(buildId, buildConfig, {
|
||||
generateBuildMutex,
|
||||
|
||||
@@ -241,6 +241,7 @@ export type PluginManifest = {
|
||||
version?: string;
|
||||
description?: string;
|
||||
binary?: string;
|
||||
binaries?: Record<string, string>;
|
||||
entry?: string;
|
||||
assets?: {
|
||||
html?: string;
|
||||
|
||||
@@ -72,30 +72,43 @@ export async function ensurePluginExtracted(
|
||||
|
||||
const zip = new AdmZip(zipPath);
|
||||
const entries = zip.getEntries();
|
||||
let wasmEntry: Buffer | null = null;
|
||||
let htmlEntry: Buffer | null = null;
|
||||
let cssEntry: Buffer | null = null;
|
||||
let jsEntry: Buffer | null = null;
|
||||
const nativeBinaries: Map<string, Buffer> = new Map();
|
||||
|
||||
for (const entry of entries) {
|
||||
if (entry.isDirectory) continue;
|
||||
const base = path.basename(entry.entryName);
|
||||
if (base.toLowerCase().endsWith(".wasm")) {
|
||||
wasmEntry = entry.getData();
|
||||
} else if (base.toLowerCase().endsWith(".html")) {
|
||||
const lower = base.toLowerCase();
|
||||
if (lower.endsWith(".so") || lower.endsWith(".dll") || lower.endsWith(".dylib")) {
|
||||
nativeBinaries.set(base, entry.getData());
|
||||
} else if (lower.endsWith(".html")) {
|
||||
htmlEntry = entry.getData();
|
||||
} else if (base.toLowerCase().endsWith(".css")) {
|
||||
} else if (lower.endsWith(".css")) {
|
||||
cssEntry = entry.getData();
|
||||
} else if (base.toLowerCase().endsWith(".js")) {
|
||||
} else if (lower.endsWith(".js")) {
|
||||
jsEntry = entry.getData();
|
||||
}
|
||||
}
|
||||
|
||||
if (!wasmEntry || !htmlEntry || !cssEntry || !jsEntry) {
|
||||
throw new Error(`Invalid plugin bundle: ${safeId} (missing required files)`);
|
||||
const hasNative = nativeBinaries.size > 0;
|
||||
if (!hasNative) {
|
||||
throw new Error(`Invalid plugin bundle: ${safeId} (no native binaries found)`);
|
||||
}
|
||||
if (!htmlEntry || !cssEntry || !jsEntry) {
|
||||
throw new Error(`Invalid plugin bundle: ${safeId} (missing .html, .css, or .js)`);
|
||||
}
|
||||
|
||||
const binariesMap: Record<string, string> = {};
|
||||
for (const [filename, data] of nativeBinaries) {
|
||||
await fs.writeFile(path.join(pluginDir, filename), data);
|
||||
const platformKey = derivePlatformKey(filename);
|
||||
if (platformKey) {
|
||||
binariesMap[platformKey] = filename;
|
||||
}
|
||||
}
|
||||
|
||||
await fs.writeFile(path.join(pluginDir, `${safeId}.wasm`), wasmEntry);
|
||||
await fs.writeFile(path.join(assetsDir, `${safeId}.html`), htmlEntry);
|
||||
await fs.writeFile(path.join(assetsDir, `${safeId}.css`), cssEntry);
|
||||
await fs.writeFile(path.join(assetsDir, `${safeId}.js`), jsEntry);
|
||||
@@ -104,7 +117,7 @@ export async function ensurePluginExtracted(
|
||||
id: safeId,
|
||||
name: safeId,
|
||||
version: "1.0.0",
|
||||
binary: `${safeId}.wasm`,
|
||||
binaries: binariesMap,
|
||||
entry: `${safeId}.html`,
|
||||
assets: {
|
||||
html: `${safeId}.html`,
|
||||
@@ -168,7 +181,9 @@ export async function loadPluginBundle(
|
||||
pluginRoot: string,
|
||||
pluginId: string,
|
||||
ensureExtracted: (pluginId: string) => Promise<void>,
|
||||
): Promise<{ manifest: PluginManifest; wasm: Uint8Array }> {
|
||||
clientOS?: string,
|
||||
clientArch?: string,
|
||||
): Promise<{ manifest: PluginManifest; binary: Uint8Array }> {
|
||||
await ensureExtracted(pluginId);
|
||||
const dir = path.join(pluginRoot, pluginId);
|
||||
const manifestPath = path.join(dir, "manifest.json");
|
||||
@@ -177,30 +192,46 @@ export async function loadPluginBundle(
|
||||
manifest.id = manifest.id || pluginId;
|
||||
manifest.name = manifest.name || pluginId;
|
||||
|
||||
const binaryName = manifest.binary || manifest.entry || `${pluginId}.wasm`;
|
||||
let wasmPath = path.join(dir, binaryName);
|
||||
try {
|
||||
await fs.access(wasmPath);
|
||||
} catch {
|
||||
const files = await fs.readdir(dir);
|
||||
const firstWasm = files.find((f) => f.toLowerCase().endsWith(".wasm"));
|
||||
if (!firstWasm) throw new Error("No .wasm found for plugin " + pluginId);
|
||||
wasmPath = path.join(dir, firstWasm);
|
||||
let binaryPath: string | null = null;
|
||||
|
||||
if (manifest.binaries && clientOS && clientArch) {
|
||||
const key = `${clientOS}-${clientArch}`;
|
||||
const filename = manifest.binaries[key];
|
||||
if (filename) {
|
||||
const candidate = path.join(dir, filename);
|
||||
try {
|
||||
await fs.access(candidate);
|
||||
binaryPath = candidate;
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
const wasm = new Uint8Array(await fs.readFile(wasmPath));
|
||||
return { manifest, wasm };
|
||||
|
||||
if (!binaryPath) {
|
||||
const files = await fs.readdir(dir);
|
||||
const found = files.find(
|
||||
(f) =>
|
||||
f.toLowerCase().endsWith(".so") ||
|
||||
f.toLowerCase().endsWith(".dll") ||
|
||||
f.toLowerCase().endsWith(".dylib"),
|
||||
);
|
||||
if (!found) throw new Error(`No plugin binary found for ${pluginId} (${clientOS}-${clientArch})`);
|
||||
binaryPath = path.join(dir, found);
|
||||
}
|
||||
|
||||
const binary = new Uint8Array(await fs.readFile(binaryPath));
|
||||
return { manifest, binary };
|
||||
}
|
||||
|
||||
export function sendPluginBundle(
|
||||
target: ClientInfo,
|
||||
bundle: { manifest: PluginManifest; wasm: Uint8Array },
|
||||
bundle: { manifest: PluginManifest; binary: Uint8Array },
|
||||
): void {
|
||||
const chunkSize = 16 * 1024;
|
||||
const wasm = bundle.wasm;
|
||||
const totalChunks = Math.ceil(wasm.length / chunkSize);
|
||||
const data = bundle.binary;
|
||||
const totalChunks = Math.ceil(data.length / chunkSize);
|
||||
const initPayload = {
|
||||
manifest: bundle.manifest,
|
||||
size: wasm.length,
|
||||
size: data.length,
|
||||
chunks: totalChunks,
|
||||
};
|
||||
target.ws.send(
|
||||
@@ -214,8 +245,8 @@ export function sendPluginBundle(
|
||||
|
||||
for (let i = 0; i < totalChunks; i++) {
|
||||
const start = i * chunkSize;
|
||||
const end = Math.min(start + chunkSize, wasm.length);
|
||||
const chunk = wasm.slice(start, end);
|
||||
const end = Math.min(start + chunkSize, data.length);
|
||||
const chunk = data.slice(start, end);
|
||||
target.ws.send(
|
||||
encodeMessage({
|
||||
type: "command",
|
||||
@@ -235,3 +266,15 @@ export function sendPluginBundle(
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function derivePlatformKey(filename: string): string {
|
||||
const match = filename.match(/-(linux|darwin|windows|freebsd)-(amd64|arm64|arm|386)\.(so|dll|dylib)$/i);
|
||||
if (match) {
|
||||
return `${match[1].toLowerCase()}-${match[2].toLowerCase()}`;
|
||||
}
|
||||
const lower = filename.toLowerCase();
|
||||
if (lower.endsWith(".dll")) return "windows-amd64";
|
||||
if (lower.endsWith(".dylib")) return "darwin-amd64";
|
||||
if (lower.endsWith(".so")) return "linux-amd64";
|
||||
return "";
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ type PluginManifest = {
|
||||
|
||||
type PluginBundle = {
|
||||
manifest: PluginManifest;
|
||||
wasm: Uint8Array;
|
||||
binary: Uint8Array;
|
||||
};
|
||||
|
||||
type PluginState = {
|
||||
@@ -32,7 +32,7 @@ type PluginRouteDeps = {
|
||||
ensurePluginExtracted: (pluginId: string) => Promise<void>;
|
||||
savePluginState: () => Promise<void>;
|
||||
listPluginManifests: () => Promise<PluginManifest[]>;
|
||||
loadPluginBundle: (pluginId: string) => Promise<PluginBundle>;
|
||||
loadPluginBundle: (pluginId: string, clientOS?: string, clientArch?: string) => Promise<PluginBundle>;
|
||||
sendPluginBundle: (target: any, bundle: PluginBundle) => void;
|
||||
markPluginLoading: (clientId: string, pluginId: string) => void;
|
||||
isPluginLoaded: (clientId: string, pluginId: string) => boolean;
|
||||
@@ -231,7 +231,7 @@ export async function handlePluginRoutes(
|
||||
return Response.json({ ok: true, loading: true });
|
||||
}
|
||||
try {
|
||||
const bundle = await deps.loadPluginBundle(pluginId);
|
||||
const bundle = await deps.loadPluginBundle(pluginId, target.os, target.arch);
|
||||
deps.markPluginLoading(targetId, pluginId);
|
||||
deps.sendPluginBundle(target, bundle);
|
||||
metrics.recordCommand("plugin_load");
|
||||
@@ -276,7 +276,7 @@ export async function handlePluginRoutes(
|
||||
deps.enqueuePluginEvent(targetId, pluginId, event, payload);
|
||||
if (!deps.isPluginLoading(targetId, pluginId)) {
|
||||
try {
|
||||
const bundle = await deps.loadPluginBundle(pluginId);
|
||||
const bundle = await deps.loadPluginBundle(pluginId, target.os, target.arch);
|
||||
deps.markPluginLoading(targetId, pluginId);
|
||||
deps.sendPluginBundle(target, bundle);
|
||||
metrics.recordCommand("plugin_load");
|
||||
|
||||
+143
-168
@@ -1,8 +1,8 @@
|
||||
# Overlord Plugins
|
||||
|
||||
This document explains how to build Overlord plugins, how the runtime works, and what you can do from a plugin UI and a WASM module.
|
||||
This document explains how to build Overlord plugins using the **native plugin system**. Plugins run as native shared libraries (`.so` on Linux, `.dylib` on macOS, in-memory DLL on Windows), giving them full access to the Go runtime and system APIs.
|
||||
|
||||
> TL;DR: A plugin is a zip file with 4 files at the root: `<id>.wasm`, `<id>.html`, `<id>.css`, `<id>.js`. Upload it in the Plugins page or drop it in Overlord-Server/plugins.
|
||||
> TL;DR: A plugin is a zip with platform-specific binaries (`.so`/`.dll`/`.dylib`) plus `<id>.html`, `<id>.css`, `<id>.js`. Upload it in the Plugins page or drop it in Overlord-Server/plugins.
|
||||
|
||||
## 1) How plugins are structured
|
||||
|
||||
@@ -14,49 +14,54 @@ A plugin bundle is a zip file named after the plugin ID:
|
||||
<pluginId>.zip
|
||||
```
|
||||
|
||||
Inside the zip (root level), these four files are required:
|
||||
Inside the zip (root level), you need:
|
||||
|
||||
```
|
||||
<pluginId>.wasm
|
||||
<pluginId>.html
|
||||
<pluginId>.css
|
||||
<pluginId>.js
|
||||
```
|
||||
- **Platform-specific binaries** named `<pluginId>-<os>-<arch>.<ext>`
|
||||
- **Web assets**: `<pluginId>.html`, `<pluginId>.css`, `<pluginId>.js`
|
||||
|
||||
Example for plugin ID `sample`:
|
||||
|
||||
```
|
||||
sample.zip
|
||||
├─ sample.wasm
|
||||
├─ sample-linux-amd64.so
|
||||
├─ sample-linux-arm64.so
|
||||
├─ sample-darwin-arm64.dylib
|
||||
├─ sample-windows-amd64.dll
|
||||
├─ sample.html
|
||||
├─ sample.css
|
||||
└─ sample.js
|
||||
```
|
||||
|
||||
When the server sees `<pluginId>.zip`, it extracts to:
|
||||
When the server extracts the zip:
|
||||
|
||||
```
|
||||
Overlord-Server/plugins/<pluginId>/
|
||||
├─ <pluginId>.wasm
|
||||
├─ manifest.json
|
||||
Overlord-Server/plugins/sample/
|
||||
├─ sample-linux-amd64.so
|
||||
├─ sample-linux-arm64.so
|
||||
├─ sample-darwin-arm64.dylib
|
||||
├─ sample-windows-amd64.dll
|
||||
├─ manifest.json (auto-generated)
|
||||
└─ assets/
|
||||
├─ <pluginId>.html
|
||||
├─ <pluginId>.css
|
||||
└─ <pluginId>.js
|
||||
├─ sample.html
|
||||
├─ sample.css
|
||||
└─ sample.js
|
||||
```
|
||||
|
||||
The server **generates manifest.json** automatically. You do not need to include a manifest inside the zip.
|
||||
|
||||
### Manifest fields
|
||||
|
||||
The auto-generated manifest looks like:
|
||||
The auto-generated manifest:
|
||||
|
||||
```
|
||||
```json
|
||||
{
|
||||
"id": "sample",
|
||||
"name": "sample",
|
||||
"version": "1.0.0",
|
||||
"binary": "sample.wasm",
|
||||
"binaries": {
|
||||
"linux-amd64": "sample-linux-amd64.so",
|
||||
"linux-arm64": "sample-linux-arm64.so",
|
||||
"darwin-arm64": "sample-darwin-arm64.dylib",
|
||||
"windows-amd64": "sample-windows-amd64.dll"
|
||||
},
|
||||
"entry": "sample.html",
|
||||
"assets": {
|
||||
"html": "sample.html",
|
||||
@@ -66,47 +71,126 @@ The auto-generated manifest looks like:
|
||||
}
|
||||
```
|
||||
|
||||
## 2) Build a WASM plugin (Go WASI)
|
||||
The server picks the right binary for the target client's OS/arch when loading.
|
||||
|
||||
The plugin runtime expects a WASI-compatible WebAssembly binary. Go 1.21+ builds work well with the current wazero runtime.
|
||||
## 2) Build a native plugin
|
||||
|
||||
Build steps (from the plugin folder):
|
||||
### Plugin contract
|
||||
|
||||
```
|
||||
GOOS=wasip1 GOARCH=wasm go build -o <pluginId>.wasm ./wasm
|
||||
Plugins are Go packages that export specific functions. The core logic is shared across platforms, with thin platform-specific export wrappers.
|
||||
|
||||
All platforms use `-buildmode=c-shared` and export C-callable functions.
|
||||
|
||||
#### Linux / macOS (`.so` / `.dylib`)
|
||||
|
||||
```go
|
||||
//export PluginOnLoad
|
||||
func PluginOnLoad(hostInfo *C.char, hostInfoLen C.int, cb C.uintptr_t, ctx C.uintptr_t) C.int
|
||||
|
||||
//export PluginOnEvent
|
||||
func PluginOnEvent(event *C.char, eventLen C.int, payload *C.char, payloadLen C.int) C.int
|
||||
|
||||
//export PluginOnUnload
|
||||
func PluginOnUnload()
|
||||
```
|
||||
|
||||
There is a working example in:
|
||||
The host passes a callback function pointer and context during OnLoad. The plugin calls the callback to send events back.
|
||||
|
||||
- plugin-sample-go/wasm/main.go
|
||||
Build: `CGO_ENABLED=1 go build -buildmode=c-shared -o sample-linux-amd64.so ./native`
|
||||
|
||||
**On Linux, shared libraries are loaded entirely in memory via `memfd_create` — no files touch disk.**
|
||||
|
||||
#### Windows (`.dll`)
|
||||
|
||||
```go
|
||||
//export PluginOnLoad
|
||||
func PluginOnLoad(hostInfo *C.char, hostInfoLen C.int, callbackPtr C.ulonglong) C.int
|
||||
|
||||
//export PluginOnEvent
|
||||
func PluginOnEvent(event *C.char, eventLen C.int, payload *C.char, payloadLen C.int) C.int
|
||||
|
||||
//export PluginOnUnload
|
||||
func PluginOnUnload()
|
||||
|
||||
//export PluginSetCallback
|
||||
func PluginSetCallback(callbackPtr C.ulonglong)
|
||||
```
|
||||
|
||||
The callback is a stdcall function pointer: `func(eventPtr, eventLen, payloadPtr, payloadLen uintptr) uintptr`
|
||||
|
||||
Build: `CGO_ENABLED=1 GOOS=windows GOARCH=amd64 go build -buildmode=c-shared -o sample-windows-amd64.dll ./native`
|
||||
|
||||
**On Windows, DLLs are loaded entirely in memory — no files are written to disk.**
|
||||
|
||||
### HostInfo JSON
|
||||
|
||||
```json
|
||||
{
|
||||
"clientId": "abc123",
|
||||
"os": "windows",
|
||||
"arch": "amd64",
|
||||
"version": "1.0.0"
|
||||
}
|
||||
```
|
||||
|
||||
### Project structure
|
||||
|
||||
See `plugin-sample-go/native/` for a working example:
|
||||
|
||||
```
|
||||
plugin-sample-go/native/
|
||||
├─ main.go (shared core logic)
|
||||
├─ exports_unix.go (Go plugin exports for Linux/macOS)
|
||||
├─ exports_windows.go (C-shared DLL exports for Windows)
|
||||
└─ go.mod
|
||||
```
|
||||
|
||||
### Build scripts
|
||||
|
||||
Use the provided build scripts:
|
||||
|
||||
```bash
|
||||
# Linux/macOS — builds for current platform by default
|
||||
./build-plugin.sh
|
||||
|
||||
# Build for multiple targets
|
||||
BUILD_TARGETS="linux-amd64 linux-arm64 darwin-arm64" ./build-plugin.sh
|
||||
|
||||
# Windows — builds windows-amd64 by default
|
||||
build-plugin.bat
|
||||
|
||||
# Build for multiple targets
|
||||
set BUILD_TARGETS=windows-amd64 linux-amd64
|
||||
build-plugin.bat
|
||||
```
|
||||
|
||||
## 3) Install & open a plugin
|
||||
|
||||
### Install / upload
|
||||
|
||||
- Use the UI at /plugins to upload the zip
|
||||
- Or drop `<pluginId>.zip` into Overlord-Server/plugins and restart the server
|
||||
- Use the UI at `/plugins` to upload the zip
|
||||
- Or drop `<pluginId>.zip` into `Overlord-Server/plugins` and restart
|
||||
|
||||
### Open the UI
|
||||
|
||||
The plugin UI is served from:
|
||||
Plugin UI is served from:
|
||||
|
||||
```
|
||||
/plugins/<pluginId>?clientId=<CLIENT_ID>
|
||||
```
|
||||
|
||||
Your HTML should load its JS/CSS from `/plugins/<pluginId>/assets/`.
|
||||
Your HTML loads its JS/CSS from `/plugins/<pluginId>/assets/`.
|
||||
|
||||
## 4) Runtime: how events flow
|
||||
|
||||
Overlord plugins have **two parts**:
|
||||
|
||||
1. **UI (HTML/CSS/JS)** — Runs in the browser and can call server APIs or open WebSockets.
|
||||
2. **WASM module** — Runs in the agent (client) process via wazero.
|
||||
1. **UI (HTML/CSS/JS)** — Runs in the browser, calls server APIs.
|
||||
2. **Native module** — Runs in the agent (client) process as a loaded shared library.
|
||||
|
||||
### UI → agent (plugin event)
|
||||
|
||||
From your UI JS, you can send an event to a plugin on a client:
|
||||
From your UI JS:
|
||||
|
||||
```
|
||||
POST /api/clients/<clientId>/plugins/<pluginId>/event
|
||||
@@ -116,94 +200,53 @@ POST /api/clients/<clientId>/plugins/<pluginId>/event
|
||||
}
|
||||
```
|
||||
|
||||
If the plugin is not loaded yet, the server will:
|
||||
If the plugin is not loaded yet, the server will load it on the client, queue the event, and deliver it once ready.
|
||||
|
||||
- load it on the client
|
||||
- queue your event
|
||||
- deliver it when the plugin reports `loaded`
|
||||
### Agent → plugin (direct function call)
|
||||
|
||||
### Agent → plugin (WASM stdin)
|
||||
The agent calls your `OnEvent(event, payload)` function directly with JSON-encoded data. No stdin/stdout pipes, no msgpack — just a direct function call.
|
||||
|
||||
The agent forwards that event into the WASM plugin over msgpack via stdin.
|
||||
### Plugin → agent (callback)
|
||||
|
||||
The message envelope looks like:
|
||||
Your plugin sends events back to the host using the `send` callback received during `OnLoad`:
|
||||
|
||||
```
|
||||
{
|
||||
"type": "event",
|
||||
"event": "ui_message",
|
||||
"payload": { ... }
|
||||
}
|
||||
```go
|
||||
send("echo", []byte(`{"message":"hello back"}`))
|
||||
```
|
||||
|
||||
### Plugin → agent (WASM stdout)
|
||||
|
||||
Your WASM can reply by writing msgpack messages to stdout. Two supported types:
|
||||
|
||||
- `type: "event"` → forwarded back to the server as `plugin_event`
|
||||
- `type: "log"` → logged by the agent
|
||||
|
||||
Examples:
|
||||
|
||||
```
|
||||
{ "type": "event", "event": "ready", "payload": "plugin ready" }
|
||||
{ "type": "log", "payload": "starting capture" }
|
||||
```
|
||||
On Windows, the equivalent is calling the registered callback function pointer.
|
||||
|
||||
### Plugin lifecycle events
|
||||
|
||||
The agent sends lifecycle events to the server:
|
||||
The agent sends these events to the server:
|
||||
|
||||
- `loaded` on successful load
|
||||
- `unloaded` when unloaded
|
||||
- `error` if load or runtime fails
|
||||
|
||||
These update the server-side plugin status and error display.
|
||||
|
||||
> Note: plugin events are **not** pushed back to the plugin UI automatically. If you need UI feedback, call a server API that returns a response to the UI or implement your own polling/WS endpoint.
|
||||
|
||||
## 5) What can plugins do?
|
||||
|
||||
Plugins can do anything **the server already exposes** to authenticated users, such as:
|
||||
Since plugins run as native code, they can:
|
||||
|
||||
- start/stop remote desktop
|
||||
- open a console session
|
||||
- file browser actions (list, upload, download, edit)
|
||||
- process listing and kill
|
||||
- run scripts
|
||||
- send commands to clients
|
||||
- Call any system API (file I/O, network, processes, etc.)
|
||||
- Use any Go library
|
||||
- Spawn goroutines
|
||||
- Access hardware
|
||||
- Do anything a normal Go program can do
|
||||
|
||||
This is intentional: plugins **do not modify server code** and are sandboxed by the plugin CSP.
|
||||
Plugins have the same capabilities as the agent itself.
|
||||
|
||||
### Security constraints
|
||||
### UI Security constraints
|
||||
|
||||
Plugin pages are served with a tight CSP:
|
||||
Plugin UI pages are still served with a tight CSP:
|
||||
|
||||
- scripts must be same-origin
|
||||
- no third‑party JS/CDN
|
||||
- Scripts must be same-origin
|
||||
- No third-party JS/CDN
|
||||
- WebSocket and fetch are allowed to same origin
|
||||
|
||||
That means your plugin JS must be bundled into `<pluginId>.js` and loaded from `/plugins/<pluginId>/assets/`.
|
||||
Plugin UIs run in a **sandboxed iframe** with a fetch bridge.
|
||||
|
||||
### Sandboxed iframe isolation
|
||||
|
||||
Plugin UIs are rendered inside a **sandboxed iframe** to isolate them from the main dashboard. This provides:
|
||||
|
||||
- No access to main app DOM
|
||||
- No access to main app cookies/localStorage
|
||||
- No direct access to privileged APIs
|
||||
|
||||
Because of the sandbox, **direct network calls from the plugin are blocked** by CSP. The system injects a small bridge that replaces `window.fetch` and forwards requests to the parent page.
|
||||
|
||||
Practical implications:
|
||||
|
||||
- Use `fetch()` as usual in your plugin JS
|
||||
- Only the allowed plugin API routes are permitted
|
||||
- Direct WebSocket usage from the plugin frame is blocked
|
||||
|
||||
If your plugin needs WebSockets (e.g., remote desktop), you must build a parent‑page bridge that opens the WS in the parent and forwards frames into the plugin UI. See the remote desktop section below for the architecture.
|
||||
|
||||
## 6) API surface (what you can call)
|
||||
## 6) API surface
|
||||
|
||||
### Plugin management
|
||||
|
||||
@@ -220,78 +263,10 @@ If your plugin needs WebSockets (e.g., remote desktop), you must build a parent
|
||||
|
||||
### Useful built-in endpoints
|
||||
|
||||
- `POST /api/clients/<clientId>/command` (input, ping, scripts, etc.)
|
||||
- `POST /api/clients/<clientId>/command`
|
||||
- `WS /api/clients/<clientId>/rd/ws` (remote desktop)
|
||||
- `WS /api/clients/<clientId>/console/ws`
|
||||
- `WS /api/clients/<clientId>/files/ws`
|
||||
- `WS /api/clients/<clientId>/processes/ws`
|
||||
|
||||
## 7) Building a “pinnacle” plugin (remote desktop class)
|
||||
|
||||
A remote‑desktop‑class plugin typically has:
|
||||
|
||||
1. **A UI page** with a `<canvas>` and quality controls
|
||||
2. **A WebSocket** connection to `/api/clients/<id>/rd/ws`
|
||||
3. **Command messages** (`desktop_start`, `desktop_stop`, `desktop_select_display`, etc.)
|
||||
4. **Binary frame decoding** (JPEG/raw) and drawing into the canvas
|
||||
|
||||
### How it would work (high level)
|
||||
|
||||
1. UI page reads `clientId` from query string
|
||||
2. JS opens a WebSocket to `/api/clients/<clientId>/rd/ws`
|
||||
3. UI sends JSON messages:
|
||||
|
||||
```
|
||||
{ "type": "desktop_start" }
|
||||
{ "type": "desktop_select_display", "display": 0 }
|
||||
{ "type": "desktop_set_quality", "quality": 90, "codec": "jpeg" }
|
||||
```
|
||||
|
||||
4. Incoming WS messages contain encoded frames (see `public/assets/remotedesktop.js` for the parsing logic)
|
||||
5. Draw frames into the canvas and expose controls to the user
|
||||
|
||||
You do **not** need to re-implement the agent capture pipeline. The agent already supports it; your plugin just drives the existing WS endpoint.
|
||||
|
||||
### What you’d write (outline)
|
||||
|
||||
- **HTML**: Canvas + controls
|
||||
- **JS**:
|
||||
- parse `clientId`
|
||||
- open WS
|
||||
- send desktop commands
|
||||
- decode frames and draw
|
||||
- **WASM module** (optional):
|
||||
- background tasks or message validation
|
||||
- optional telemetry / state management
|
||||
|
||||
If you want a starting point, the existing remote desktop UI is in:
|
||||
|
||||
- Overlord-Server/public/remotedesktop.html
|
||||
- Overlord-Server/public/assets/remotedesktop.js
|
||||
|
||||
You can reuse that logic inside your plugin UI with minimal changes.
|
||||
|
||||
## 8) Example: minimal plugin code shape
|
||||
|
||||
### WASM side (Go, message loop)
|
||||
|
||||
- listen on stdin using msgpack
|
||||
- on `type=init`, send `event=ready`
|
||||
- on `type=event`, process payload and emit response events
|
||||
|
||||
See:
|
||||
|
||||
- plugin-sample-go/wasm/main.go
|
||||
|
||||
### UI side (JS)
|
||||
|
||||
- call `/api/clients/<id>/plugins/<pluginId>/event`
|
||||
- show responses via your own UI (or poll your own endpoints)
|
||||
|
||||
See:
|
||||
|
||||
- plugin-sample-go/sample.js
|
||||
|
||||
---
|
||||
|
||||
If you want a deeper walkthrough of a specific plugin type (file explorer, custom telemetry, or a remote-desktop clone), say which one and I’ll expand that section.
|
||||
|
||||
+60
-17
@@ -5,40 +5,83 @@ set "ROOT=%~dp0"
|
||||
set "PLUGIN_DIR=%ROOT%plugin-sample-go"
|
||||
if not "%~1"=="" set "PLUGIN_DIR=%~1"
|
||||
|
||||
set "WASM_DIR=%PLUGIN_DIR%\wasm"
|
||||
set "NATIVE_DIR=%PLUGIN_DIR%\native"
|
||||
set "PLUGIN_NAME=sample"
|
||||
set "WASM_OUT=%PLUGIN_DIR%\%PLUGIN_NAME%.wasm"
|
||||
set "ZIP_OUT=%PLUGIN_DIR%\%PLUGIN_NAME%.zip"
|
||||
|
||||
if not exist "%WASM_DIR%" (
|
||||
echo [error] wasm folder not found: %WASM_DIR%
|
||||
if not exist "%NATIVE_DIR%" (
|
||||
echo [error] native folder not found: %NATIVE_DIR%
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
pushd "%WASM_DIR%"
|
||||
pushd "%NATIVE_DIR%"
|
||||
|
||||
echo [build] go build -o "%WASM_OUT%" (GOOS=wasip1 GOARCH=wasm)
|
||||
set "GOOS=wasip1"
|
||||
set "GOARCH=wasm"
|
||||
go build -o "%WASM_OUT%" .
|
||||
set "GOOS="
|
||||
set "GOARCH="
|
||||
if errorlevel 1 (
|
||||
echo [warn] go build failed, trying tinygo
|
||||
echo [build] tinygo build -o "%WASM_OUT%" -target wasi -scheduler=none -gc=leaking .
|
||||
tinygo build -o "%WASM_OUT%" -target wasi -scheduler=none -gc=leaking .
|
||||
REM Build targets - default to windows-amd64 on Windows
|
||||
if not defined BUILD_TARGETS set "BUILD_TARGETS=windows-amd64"
|
||||
|
||||
set "BUILT_FILES="
|
||||
for %%T in (%BUILD_TARGETS%) do (
|
||||
for /f "tokens=1,2 delims=-" %%A in ("%%T") do (
|
||||
set "TARGET_OS=%%A"
|
||||
set "TARGET_ARCH=%%B"
|
||||
)
|
||||
|
||||
if "!TARGET_OS!"=="windows" (
|
||||
set "EXT=dll"
|
||||
) else if "!TARGET_OS!"=="darwin" (
|
||||
set "EXT=dylib"
|
||||
) else (
|
||||
set "EXT=so"
|
||||
)
|
||||
set "BUILDMODE=c-shared"
|
||||
|
||||
set "OUTFILE=%PLUGIN_DIR%\%PLUGIN_NAME%-!TARGET_OS!-!TARGET_ARCH!.!EXT!"
|
||||
echo [build] GOOS=!TARGET_OS! GOARCH=!TARGET_ARCH! CGO_ENABLED=1 go build -buildmode=!BUILDMODE! -o "!OUTFILE!"
|
||||
set "GOOS=!TARGET_OS!"
|
||||
set "GOARCH=!TARGET_ARCH!"
|
||||
set "CGO_ENABLED=1"
|
||||
go build -buildmode=!BUILDMODE! -o "!OUTFILE!" .
|
||||
if errorlevel 1 (
|
||||
echo [error] tinygo build failed
|
||||
echo [error] build failed for !TARGET_OS!-!TARGET_ARCH!
|
||||
popd
|
||||
exit /b 1
|
||||
)
|
||||
set "BUILT_FILES=!BUILT_FILES! '%PLUGIN_DIR%\%PLUGIN_NAME%-!TARGET_OS!-!TARGET_ARCH!.!EXT!'"
|
||||
)
|
||||
|
||||
set "GOOS="
|
||||
set "GOARCH="
|
||||
set "CGO_ENABLED="
|
||||
|
||||
popd
|
||||
|
||||
if exist "%ZIP_OUT%" del /f /q "%ZIP_OUT%"
|
||||
|
||||
powershell -NoProfile -Command "Compress-Archive -Path '%PLUGIN_DIR%\%PLUGIN_NAME%.wasm','%PLUGIN_DIR%\%PLUGIN_NAME%.html','%PLUGIN_DIR%\%PLUGIN_NAME%.css','%PLUGIN_DIR%\%PLUGIN_NAME%.js' -DestinationPath '%ZIP_OUT%'"
|
||||
REM Collect all build outputs and web assets
|
||||
set "ZIP_SOURCES="
|
||||
for %%T in (%BUILD_TARGETS%) do (
|
||||
for /f "tokens=1,2 delims=-" %%A in ("%%T") do (
|
||||
set "TARGET_OS=%%A"
|
||||
set "TARGET_ARCH=%%B"
|
||||
)
|
||||
if "!TARGET_OS!"=="windows" (
|
||||
set "ZIP_SOURCES=!ZIP_SOURCES!,'%PLUGIN_DIR%\%PLUGIN_NAME%-!TARGET_OS!-!TARGET_ARCH!.dll'"
|
||||
) else if "!TARGET_OS!"=="darwin" (
|
||||
set "ZIP_SOURCES=!ZIP_SOURCES!,'%PLUGIN_DIR%\%PLUGIN_NAME%-!TARGET_OS!-!TARGET_ARCH!.dylib'"
|
||||
) else (
|
||||
set "ZIP_SOURCES=!ZIP_SOURCES!,'%PLUGIN_DIR%\%PLUGIN_NAME%-!TARGET_OS!-!TARGET_ARCH!.so'"
|
||||
)
|
||||
)
|
||||
|
||||
REM Add web assets
|
||||
if exist "%PLUGIN_DIR%\%PLUGIN_NAME%.html" set "ZIP_SOURCES=!ZIP_SOURCES!,'%PLUGIN_DIR%\%PLUGIN_NAME%.html'"
|
||||
if exist "%PLUGIN_DIR%\%PLUGIN_NAME%.css" set "ZIP_SOURCES=!ZIP_SOURCES!,'%PLUGIN_DIR%\%PLUGIN_NAME%.css'"
|
||||
if exist "%PLUGIN_DIR%\%PLUGIN_NAME%.js" set "ZIP_SOURCES=!ZIP_SOURCES!,'%PLUGIN_DIR%\%PLUGIN_NAME%.js'"
|
||||
|
||||
REM Remove leading comma
|
||||
set "ZIP_SOURCES=!ZIP_SOURCES:~1!"
|
||||
|
||||
powershell -NoProfile -Command "Compress-Archive -Path !ZIP_SOURCES! -DestinationPath '%ZIP_OUT%'"
|
||||
if errorlevel 1 (
|
||||
echo [error] zip failed
|
||||
exit /b 1
|
||||
|
||||
+46
-12
@@ -3,31 +3,65 @@ set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PLUGIN_DIR="${1:-${ROOT_DIR}/plugin-sample-go}"
|
||||
WASM_DIR="${PLUGIN_DIR}/wasm"
|
||||
NATIVE_DIR="${PLUGIN_DIR}/native"
|
||||
PLUGIN_NAME="sample"
|
||||
WASM_OUT="${PLUGIN_DIR}/${PLUGIN_NAME}.wasm"
|
||||
ZIP_OUT="${PLUGIN_DIR}/${PLUGIN_NAME}.zip"
|
||||
|
||||
if [[ ! -d "${WASM_DIR}" ]]; then
|
||||
echo "[error] wasm folder not found: ${WASM_DIR}" >&2
|
||||
if [[ ! -d "${NATIVE_DIR}" ]]; then
|
||||
echo "[error] native folder not found: ${NATIVE_DIR}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
pushd "${WASM_DIR}" >/dev/null
|
||||
pushd "${NATIVE_DIR}" >/dev/null
|
||||
|
||||
echo "[build] go build -o ${WASM_OUT} (GOOS=wasip1 GOARCH=wasm)"
|
||||
if ! GOOS=wasip1 GOARCH=wasm go build -o "${WASM_OUT}" .; then
|
||||
echo "[warn] go build failed, trying tinygo"
|
||||
echo "[build] tinygo build -o ${WASM_OUT} -target wasi -scheduler=none -gc=leaking ."
|
||||
tinygo build -o "${WASM_OUT}" -target wasi -scheduler=none -gc=leaking .
|
||||
fi
|
||||
# Detect current OS/arch for default build
|
||||
HOST_OS="$(go env GOOS)"
|
||||
HOST_ARCH="$(go env GOARCH)"
|
||||
|
||||
# Build matrix - can be overridden with BUILD_TARGETS env var
|
||||
# Format: "os-arch os-arch ..." e.g. "linux-amd64 linux-arm64 darwin-arm64"
|
||||
DEFAULT_TARGETS="${HOST_OS}-${HOST_ARCH}"
|
||||
BUILD_TARGETS="${BUILD_TARGETS:-${DEFAULT_TARGETS}}"
|
||||
|
||||
BUILT_FILES=()
|
||||
|
||||
for target in ${BUILD_TARGETS}; do
|
||||
os="${target%%-*}"
|
||||
arch="${target#*-}"
|
||||
|
||||
if [[ "${os}" == "windows" ]]; then
|
||||
ext="dll"
|
||||
elif [[ "${os}" == "darwin" ]]; then
|
||||
ext="dylib"
|
||||
else
|
||||
ext="so"
|
||||
fi
|
||||
buildmode="c-shared"
|
||||
|
||||
outfile="${PLUGIN_DIR}/${PLUGIN_NAME}-${os}-${arch}.${ext}"
|
||||
echo "[build] GOOS=${os} GOARCH=${arch} CGO_ENABLED=1 go build -buildmode=${buildmode} -o ${outfile}"
|
||||
CGO_ENABLED=1 GOOS="${os}" GOARCH="${arch}" go build -buildmode="${buildmode}" -o "${outfile}" .
|
||||
BUILT_FILES+=("${PLUGIN_NAME}-${os}-${arch}.${ext}")
|
||||
done
|
||||
|
||||
popd >/dev/null
|
||||
|
||||
rm -f "${ZIP_OUT}"
|
||||
|
||||
# Collect files to zip
|
||||
ZIP_FILES=()
|
||||
for bf in "${BUILT_FILES[@]}"; do
|
||||
ZIP_FILES+=("${bf}")
|
||||
done
|
||||
# Add web assets
|
||||
for asset in "${PLUGIN_NAME}.html" "${PLUGIN_NAME}.css" "${PLUGIN_NAME}.js"; do
|
||||
if [[ -f "${PLUGIN_DIR}/${asset}" ]]; then
|
||||
ZIP_FILES+=("${asset}")
|
||||
fi
|
||||
done
|
||||
|
||||
if command -v zip >/dev/null 2>&1; then
|
||||
(cd "${PLUGIN_DIR}" && zip -q "${ZIP_OUT}" "${PLUGIN_NAME}.wasm" "${PLUGIN_NAME}.html" "${PLUGIN_NAME}.css" "${PLUGIN_NAME}.js")
|
||||
(cd "${PLUGIN_DIR}" && zip -q "${ZIP_OUT}" "${ZIP_FILES[@]}")
|
||||
else
|
||||
echo "[error] zip not found. Please install zip." >&2
|
||||
exit 1
|
||||
|
||||
@@ -2,5 +2,5 @@ go 1.24
|
||||
|
||||
use (
|
||||
./Overlord-Client
|
||||
./plugin-sample-go/wasm
|
||||
./plugin-sample-go/native
|
||||
)
|
||||
|
||||
+18
-18
@@ -1,49 +1,49 @@
|
||||
# Sample Go WASM Plugin (sample)
|
||||
# Sample Go Plugin (sample)
|
||||
|
||||
This sample lives at the repo root and builds a WASI-compatible Go plugin.
|
||||
This sample lives at the repo root and builds a native Go plugin.
|
||||
|
||||
## Files to include in the plugin zip
|
||||
|
||||
At the root of the zip:
|
||||
- sample.wasm
|
||||
- sample-linux-amd64.so / sample-darwin-arm64.dylib / sample-windows-amd64.dll
|
||||
- sample.html
|
||||
- sample.css
|
||||
- sample.js
|
||||
|
||||
## Build the WASM
|
||||
## Build
|
||||
|
||||
Recommended: Go WASI (most compatible with wazero)
|
||||
Use the provided build scripts:
|
||||
|
||||
1) Build the WASM with Go 1.21+:
|
||||
```bash
|
||||
# Linux/macOS
|
||||
./build-plugin.sh
|
||||
|
||||
GOOS=wasip1 GOARCH=wasm go build -o sample.wasm ./wasm
|
||||
# Multiple targets
|
||||
BUILD_TARGETS="linux-amd64 linux-arm64 darwin-arm64" ./build-plugin.sh
|
||||
|
||||
2) Optional TinyGo fallback:
|
||||
|
||||
tinygo build -o sample.wasm -target wasi -scheduler=none -gc=leaking ./wasm
|
||||
# Windows
|
||||
build-plugin.bat
|
||||
```
|
||||
|
||||
## Create the zip
|
||||
|
||||
Create a zip named sample.zip containing:
|
||||
- sample.wasm
|
||||
- sample.html
|
||||
- sample.css
|
||||
- sample.js
|
||||
The build script automatically creates `sample.zip` containing the built
|
||||
binaries and web assets.
|
||||
|
||||
Then place sample.zip in Overlord-Server/plugins.
|
||||
Then place `sample.zip` in `Overlord-Server/plugins`.
|
||||
|
||||
## Open the UI
|
||||
|
||||
Navigate to:
|
||||
- /plugins/sample?clientId=<CLIENT_ID>
|
||||
|
||||
Click "Send event" and the WASM plugin will echo back to the server logs.
|
||||
Click "Send event" and the plugin will echo back to the server logs.
|
||||
|
||||
## OS detection (Windows-only logic)
|
||||
|
||||
The plugin receives host metadata in the `init` payload. The sample plugin shows
|
||||
how to detect the host OS and branch logic (see
|
||||
[plugin-sample-go/wasm/main.go](plugin-sample-go/wasm/main.go)). For example:
|
||||
`plugin-sample-go/native/main.go`). For example:
|
||||
|
||||
- If `host.os == "windows"`, you can safely run Windows-only flows (e.g., WinAPI-backed behavior).
|
||||
- Otherwise, skip or provide a fallback path.
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
//go:build linux || darwin || freebsd
|
||||
|
||||
package main
|
||||
|
||||
/*
|
||||
#include <stdint.h>
|
||||
|
||||
typedef void (*host_callback_t)(uintptr_t ctx,
|
||||
const char* event, int eventLen,
|
||||
const char* payload, int payloadLen);
|
||||
|
||||
static void invoke_host_callback(uintptr_t fn, uintptr_t ctx,
|
||||
const char* ev, int evLen, const char* pl, int plLen) {
|
||||
((host_callback_t)fn)(ctx, ev, evLen, pl, plLen);
|
||||
}
|
||||
*/
|
||||
import "C"
|
||||
|
||||
import "unsafe"
|
||||
|
||||
var (
|
||||
hostCallbackFn C.uintptr_t
|
||||
hostCallbackCtx C.uintptr_t
|
||||
)
|
||||
|
||||
func hostSendViaCallback(event string, payload []byte) {
|
||||
fn := hostCallbackFn
|
||||
if fn == 0 {
|
||||
return
|
||||
}
|
||||
evBytes := []byte(event)
|
||||
var evPtr, plPtr unsafe.Pointer
|
||||
if len(evBytes) > 0 {
|
||||
evPtr = unsafe.Pointer(&evBytes[0])
|
||||
}
|
||||
if len(payload) > 0 {
|
||||
plPtr = unsafe.Pointer(&payload[0])
|
||||
}
|
||||
C.invoke_host_callback(fn, hostCallbackCtx,
|
||||
(*C.char)(evPtr), C.int(len(evBytes)),
|
||||
(*C.char)(plPtr), C.int(len(payload)))
|
||||
}
|
||||
|
||||
//export PluginOnLoad
|
||||
func PluginOnLoad(hostInfo *C.char, hostInfoLen C.int, cb C.uintptr_t, ctx C.uintptr_t) C.int {
|
||||
hostCallbackFn = cb
|
||||
hostCallbackCtx = ctx
|
||||
setSend(hostSendViaCallback)
|
||||
|
||||
data := C.GoBytes(unsafe.Pointer(hostInfo), hostInfoLen)
|
||||
if err := handleInit(data); err != nil {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
//export PluginOnEvent
|
||||
func PluginOnEvent(event *C.char, eventLen C.int, payload *C.char, payloadLen C.int) C.int {
|
||||
ev := C.GoStringN(event, eventLen)
|
||||
var pl []byte
|
||||
if payloadLen > 0 {
|
||||
pl = C.GoBytes(unsafe.Pointer(payload), payloadLen)
|
||||
}
|
||||
if err := handleEvent(ev, pl); err != nil {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
//export PluginOnUnload
|
||||
func PluginOnUnload() {
|
||||
handleUnload()
|
||||
}
|
||||
|
||||
func main() {}
|
||||
@@ -0,0 +1,67 @@
|
||||
//go:build windows
|
||||
|
||||
package main
|
||||
|
||||
import "C"
|
||||
import (
|
||||
"syscall"
|
||||
"unsafe"
|
||||
)
|
||||
|
||||
var callbackPtr uintptr
|
||||
|
||||
func hostSendViaCallback(event string, payload []byte) {
|
||||
cb := callbackPtr
|
||||
if cb == 0 {
|
||||
return
|
||||
}
|
||||
eventBytes := []byte(event)
|
||||
var evPtr, plPtr uintptr
|
||||
evLen := uintptr(len(eventBytes))
|
||||
plLen := uintptr(len(payload))
|
||||
if len(eventBytes) > 0 {
|
||||
evPtr = uintptr(unsafe.Pointer(&eventBytes[0]))
|
||||
}
|
||||
if len(payload) > 0 {
|
||||
plPtr = uintptr(unsafe.Pointer(&payload[0]))
|
||||
}
|
||||
syscall.SyscallN(cb, evPtr, evLen, plPtr, plLen)
|
||||
}
|
||||
|
||||
//export PluginSetCallback
|
||||
func PluginSetCallback(cb C.ulonglong) {
|
||||
callbackPtr = uintptr(cb)
|
||||
setSend(hostSendViaCallback)
|
||||
}
|
||||
|
||||
//export PluginOnLoad
|
||||
func PluginOnLoad(hostInfo *C.char, hostInfoLen C.int, cb C.ulonglong) C.int {
|
||||
callbackPtr = uintptr(cb)
|
||||
setSend(hostSendViaCallback)
|
||||
|
||||
data := C.GoBytes(unsafe.Pointer(hostInfo), hostInfoLen)
|
||||
if err := handleInit(data); err != nil {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
//export PluginOnEvent
|
||||
func PluginOnEvent(event *C.char, eventLen C.int, payload *C.char, payloadLen C.int) C.int {
|
||||
ev := C.GoStringN(event, eventLen)
|
||||
var pl []byte
|
||||
if payloadLen > 0 {
|
||||
pl = C.GoBytes(unsafe.Pointer(payload), payloadLen)
|
||||
}
|
||||
if err := handleEvent(ev, pl); err != nil {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
//export PluginOnUnload
|
||||
func PluginOnUnload() {
|
||||
handleUnload()
|
||||
}
|
||||
|
||||
func main() {}
|
||||
@@ -0,0 +1,3 @@
|
||||
module sample-plugin
|
||||
|
||||
go 1.24
|
||||
@@ -0,0 +1,73 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log"
|
||||
"sync"
|
||||
)
|
||||
|
||||
type HostInfo struct {
|
||||
ClientID string `json:"clientId"`
|
||||
OS string `json:"os"`
|
||||
Arch string `json:"arch"`
|
||||
Version string `json:"version"`
|
||||
}
|
||||
|
||||
var (
|
||||
hostInfo HostInfo
|
||||
sendFn func(event string, payload []byte)
|
||||
mu sync.Mutex
|
||||
)
|
||||
|
||||
func setSend(fn func(event string, payload []byte)) {
|
||||
mu.Lock()
|
||||
sendFn = fn
|
||||
mu.Unlock()
|
||||
}
|
||||
|
||||
func sendEvent(event string, payload interface{}) {
|
||||
mu.Lock()
|
||||
fn := sendFn
|
||||
mu.Unlock()
|
||||
if fn == nil {
|
||||
return
|
||||
}
|
||||
data, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
log.Printf("[sample] marshal error: %v", err)
|
||||
return
|
||||
}
|
||||
fn(event, data)
|
||||
}
|
||||
|
||||
func handleInit(hostJSON []byte) error {
|
||||
if err := json.Unmarshal(hostJSON, &hostInfo); err != nil {
|
||||
return err
|
||||
}
|
||||
log.Printf("[sample] init: clientId=%s os=%s arch=%s", hostInfo.ClientID, hostInfo.OS, hostInfo.Arch)
|
||||
sendEvent("ready", map[string]string{"message": "sample plugin ready"})
|
||||
return nil
|
||||
}
|
||||
|
||||
func handleEvent(event string, payload []byte) error {
|
||||
switch event {
|
||||
case "ui_message":
|
||||
var msg struct {
|
||||
Message string `json:"message"`
|
||||
}
|
||||
_ = json.Unmarshal(payload, &msg)
|
||||
log.Printf("[sample] got ui_message: %s", msg.Message)
|
||||
sendEvent("echo", map[string]string{"message": msg.Message})
|
||||
|
||||
case "ping":
|
||||
sendEvent("pong", nil)
|
||||
|
||||
default:
|
||||
log.Printf("[sample] unhandled event: %s", event)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func handleUnload() {
|
||||
log.Printf("[sample] unloading")
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
/* Code generated by cmd/cgo; DO NOT EDIT. */
|
||||
|
||||
/* package sample-plugin */
|
||||
|
||||
|
||||
#line 1 "cgo-builtin-export-prolog"
|
||||
|
||||
#include <stddef.h>
|
||||
|
||||
#ifndef GO_CGO_EXPORT_PROLOGUE_H
|
||||
#define GO_CGO_EXPORT_PROLOGUE_H
|
||||
|
||||
#ifndef GO_CGO_GOSTRING_TYPEDEF
|
||||
typedef struct { const char *p; ptrdiff_t n; } _GoString_;
|
||||
extern size_t _GoStringLen(_GoString_ s);
|
||||
extern const char *_GoStringPtr(_GoString_ s);
|
||||
#endif
|
||||
|
||||
#endif
|
||||
|
||||
/* Start of preamble from import "C" comments. */
|
||||
|
||||
|
||||
|
||||
|
||||
/* End of preamble from import "C" comments. */
|
||||
|
||||
|
||||
/* Start of boilerplate cgo prologue. */
|
||||
#line 1 "cgo-gcc-export-header-prolog"
|
||||
|
||||
#ifndef GO_CGO_PROLOGUE_H
|
||||
#define GO_CGO_PROLOGUE_H
|
||||
|
||||
typedef signed char GoInt8;
|
||||
typedef unsigned char GoUint8;
|
||||
typedef short GoInt16;
|
||||
typedef unsigned short GoUint16;
|
||||
typedef int GoInt32;
|
||||
typedef unsigned int GoUint32;
|
||||
typedef long long GoInt64;
|
||||
typedef unsigned long long GoUint64;
|
||||
typedef GoInt64 GoInt;
|
||||
typedef GoUint64 GoUint;
|
||||
typedef size_t GoUintptr;
|
||||
typedef float GoFloat32;
|
||||
typedef double GoFloat64;
|
||||
#ifdef _MSC_VER
|
||||
#if !defined(__cplusplus) || _MSVC_LANG <= 201402L
|
||||
#include <complex.h>
|
||||
typedef _Fcomplex GoComplex64;
|
||||
typedef _Dcomplex GoComplex128;
|
||||
#else
|
||||
#include <complex>
|
||||
typedef std::complex<float> GoComplex64;
|
||||
typedef std::complex<double> GoComplex128;
|
||||
#endif
|
||||
#else
|
||||
typedef float _Complex GoComplex64;
|
||||
typedef double _Complex GoComplex128;
|
||||
#endif
|
||||
|
||||
/*
|
||||
static assertion to make sure the file is being used on architecture
|
||||
at least with matching size of GoInt.
|
||||
*/
|
||||
typedef char _check_for_64_bit_pointer_matching_GoInt[sizeof(void*)==64/8 ? 1:-1];
|
||||
|
||||
#ifndef GO_CGO_GOSTRING_TYPEDEF
|
||||
typedef _GoString_ GoString;
|
||||
#endif
|
||||
typedef void *GoMap;
|
||||
typedef void *GoChan;
|
||||
typedef struct { void *t; void *v; } GoInterface;
|
||||
typedef struct { void *data; GoInt len; GoInt cap; } GoSlice;
|
||||
|
||||
#endif
|
||||
|
||||
/* End of boilerplate cgo prologue. */
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
extern __declspec(dllexport) void PluginSetCallback(long long unsigned int cb);
|
||||
extern __declspec(dllexport) int PluginOnLoad(char* hostInfo, int hostInfoLen, long long unsigned int cb);
|
||||
extern __declspec(dllexport) int PluginOnEvent(char* event, int eventLen, char* payload, int payloadLen);
|
||||
extern __declspec(dllexport) void PluginOnUnload(void);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
@@ -10,7 +10,7 @@
|
||||
<main class="container">
|
||||
<header class="header">
|
||||
<h1>Sample Plugin</h1>
|
||||
<p class="sub">Go WASM + UI plugin</p>
|
||||
<p class="sub">Go Native + UI plugin</p>
|
||||
</header>
|
||||
|
||||
<section class="card">
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
module plugin-sample-go/wasm
|
||||
|
||||
go 1.21
|
||||
|
||||
require github.com/vmihailenco/msgpack/v5 v5.4.1
|
||||
@@ -1,76 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"log"
|
||||
"os"
|
||||
|
||||
"github.com/vmihailenco/msgpack/v5"
|
||||
)
|
||||
|
||||
type Incoming struct {
|
||||
Type string `msgpack:"type"`
|
||||
Event string `msgpack:"event,omitempty"`
|
||||
Payload interface{} `msgpack:"payload,omitempty"`
|
||||
}
|
||||
|
||||
type Outgoing struct {
|
||||
Type string `msgpack:"type"`
|
||||
Event string `msgpack:"event,omitempty"`
|
||||
Payload interface{} `msgpack:"payload,omitempty"`
|
||||
}
|
||||
|
||||
type UiMessage struct {
|
||||
Message string `msgpack:"message"`
|
||||
}
|
||||
|
||||
type HostInfo struct {
|
||||
ClientID string `msgpack:"clientId"`
|
||||
OS string `msgpack:"os"`
|
||||
Arch string `msgpack:"arch"`
|
||||
Version string `msgpack:"version"`
|
||||
}
|
||||
|
||||
type InitPayload struct {
|
||||
Host HostInfo `msgpack:"host"`
|
||||
}
|
||||
|
||||
func main() {
|
||||
dec := msgpack.NewDecoder(os.Stdin)
|
||||
enc := msgpack.NewEncoder(os.Stdout)
|
||||
|
||||
for {
|
||||
var msg Incoming
|
||||
if err := dec.Decode(&msg); err != nil {
|
||||
log.Printf("decode error: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
switch msg.Type {
|
||||
case "init":
|
||||
var init InitPayload
|
||||
if payloadBytes, err := msgpack.Marshal(msg.Payload); err == nil {
|
||||
_ = msgpack.Unmarshal(payloadBytes, &init)
|
||||
}
|
||||
|
||||
osName := init.Host.OS
|
||||
if osName == "" {
|
||||
osName = "unknown"
|
||||
}
|
||||
|
||||
if osName == "windows" {
|
||||
log.Printf("[sample] windows detected - you can use WinAPI-specific flows")
|
||||
_ = enc.Encode(Outgoing{Type: "event", Event: "ready", Payload: "sample plugin ready (windows detected)"})
|
||||
} else {
|
||||
log.Printf("[sample] non-windows host detected (%s)", osName)
|
||||
_ = enc.Encode(Outgoing{Type: "event", Event: "ready", Payload: "sample plugin ready"})
|
||||
}
|
||||
case "event":
|
||||
if msg.Event == "ui_message" {
|
||||
payloadBytes, _ := msgpack.Marshal(msg.Payload)
|
||||
var ui UiMessage
|
||||
_ = msgpack.Unmarshal(payloadBytes, &ui)
|
||||
_ = enc.Encode(Outgoing{Type: "event", Event: "echo", Payload: ui.Message})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user