mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-10-04 00:03:59 +00:00
feat: multi-platform .hmap bundle + platform auto-selection
- PluginManifest: add platforms field declaring supported OS - validatePackage: accept bundle .hmap with platform-aware binary check - extractPackage: extract only current OS binary, skip others (macOS renames .dylib → .so) - installFromPath/installFromURL: path install keeps source, URL install auto-cleanup - cabi/loader.go + dynamic.go: add linux/darwin build constraints for Windows cross-compile - dynamic_loader_unix/windows: split SO loading with proper build tags - package/build.sh: enable windows/amd64 for homed, add CXX export for arm64 - tryLoadSO: fallback to plugin.dylib on macOS - docs: update PLUGIN_DEV.md for bundle format and install methods
This commit is contained in:
@ -112,26 +112,50 @@ Execution process:
|
|||||||
2. **Go plugin**: Runs `go build -buildmode=c-shared` (produces `.so` + C ABI header)
|
2. **Go plugin**: Runs `go build -buildmode=c-shared` (produces `.so` + C ABI header)
|
||||||
3. **Lua plugin**: Packages source code directly, no compilation needed
|
3. **Lua plugin**: Packages source code directly, no compilation needed
|
||||||
4. Generates `plugin.json` manifest file
|
4. Generates `plugin.json` manifest file
|
||||||
5. Packages as `.hmap` distribution (zip format, containing `plugin.json` + `plugin.so` + `plugin.dll` + `main.lua`)
|
5. Packages as `.hmap` distribution (zip format, containing `plugin.json` + platform binary)
|
||||||
|
|
||||||
|
The `platforms` field in `plugin.json` declares supported platforms; the build includes the corresponding binary:
|
||||||
|
|
||||||
|
| Platform | Binary name |
|
||||||
|
|----------|-------------|
|
||||||
|
| Linux | `plugin.so` |
|
||||||
|
| macOS | `plugin.dylib` |
|
||||||
|
| Windows | `plugin.dll` |
|
||||||
|
|
||||||
|
> Use `--bundle` to build a multi-platform bundle — the resulting `.hmap` contains binaries for all platforms.
|
||||||
|
> During installation, the kernel automatically selects the correct binary for the current OS, skipping others.
|
||||||
|
|
||||||
Output in `dist/` directory:
|
Output in `dist/` directory:
|
||||||
```
|
```
|
||||||
dist/
|
dist/
|
||||||
├── myplugin_linux_amd64.hmap # Go plugin Linux version
|
├── myplugin_linux_amd64.hmap # Go plugin Linux version
|
||||||
├── myplugin_windows_amd64.hmap # Go plugin Windows version
|
├── myplugin_windows_amd64.hmap # Go plugin Windows version
|
||||||
|
├── myplugin_darwin_amd64.hmap # Go plugin macOS version
|
||||||
└── myplugin_lua.hmap # Lua plugin
|
└── myplugin_lua.hmap # Lua plugin
|
||||||
```
|
```
|
||||||
|
|
||||||
### Deployment
|
### Deployment
|
||||||
|
|
||||||
Install via PluginMgr HTTP API:
|
Install via PluginMgr HTTP API (three methods):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Kernel PluginMgr listens on :9876
|
# 1. Install from URL (auto-cleanup)
|
||||||
curl -X POST http://127.0.0.1:9876/plugins \
|
curl -X POST http://127.0.0.1:9876/plugins \
|
||||||
-F "file=@dist/myplugin_linux_amd64.hmap"
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"url": "https://example.com/myplugin.hmap"}'
|
||||||
|
|
||||||
|
# 2. Install from local path (keeps source file)
|
||||||
|
curl -X POST http://127.0.0.1:9876/plugins \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"path": "/path/to/myplugin.hmap"}'
|
||||||
|
|
||||||
|
# 3. Upload binary directly
|
||||||
|
curl -X POST http://127.0.0.1:9876/plugins \
|
||||||
|
--data-binary @dist/myplugin.hmap
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Reload plugins via `/api/v1/plugins/reload` or restart the kernel to activate.
|
||||||
|
|
||||||
Or upload via WebUI plugin management page.
|
Or upload via WebUI plugin management page.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@ -113,27 +113,51 @@ plugindev build
|
|||||||
2. **Go 插件**:执行 `go build -buildmode=c-shared`(生成 `.so` + C ABI header)
|
2. **Go 插件**:执行 `go build -buildmode=c-shared`(生成 `.so` + C ABI header)
|
||||||
3. **Lua 插件**:直接打包源码,无需编译
|
3. **Lua 插件**:直接打包源码,无需编译
|
||||||
4. 生成 `plugin.json` 清单文件
|
4. 生成 `plugin.json` 清单文件
|
||||||
5. 打包为 `.hmap` 分发包(zip 格式,内含 `plugin.json` + `plugin.so` + `plugin.dll` + `main.lua`)
|
5. 打包为 `.hmap` 分发包(zip 格式,内含 `plugin.json` + 平台二进制)
|
||||||
|
|
||||||
|
`plugin.json` 的 `platforms` 字段声明支持的平台,打包时自动包含对应二进制:
|
||||||
|
|
||||||
|
| 平台 | 二进制文件名 |
|
||||||
|
|------|-------------|
|
||||||
|
| Linux | `plugin.so` |
|
||||||
|
| macOS | `plugin.dylib` |
|
||||||
|
| Windows | `plugin.dll` |
|
||||||
|
|
||||||
|
> 使用 `--bundle` 可一次打包多平台,生成的 `.hmap` 内含所有平台的二进制。
|
||||||
|
> 安装时核心自动选择当前平台的文件,跳过其他平台。
|
||||||
|
|
||||||
输出在 `dist/` 目录:
|
输出在 `dist/` 目录:
|
||||||
```
|
```
|
||||||
dist/
|
dist/
|
||||||
├── myplugin_linux_amd64.hmap # Go 插件 Linux 版
|
├── myplugin_linux_amd64.hmap # Go 插件 Linux 版
|
||||||
├── myplugin_windows_amd64.hmap # Go 插件 Windows 版
|
├── myplugin_windows_amd64.hmap # Go 插件 Windows 版
|
||||||
|
├── myplugin_darwin_amd64.hmap # Go 插件 macOS 版
|
||||||
└── myplugin_lua.hmap # Lua 插件
|
└── myplugin_lua.hmap # Lua 插件
|
||||||
```
|
```
|
||||||
|
|
||||||
### 安装部署
|
### 安装部署
|
||||||
|
|
||||||
通过 PluginMgr HTTP API 安装:
|
通过 PluginMgr HTTP API 安装,支持三种方式:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 内核 PluginMgr 监听 :9876
|
# 1. 从 URL 安装(自动清理安装包)
|
||||||
curl -X POST http://127.0.0.1:9876/plugins \
|
curl -X POST http://127.0.0.1:9876/plugins \
|
||||||
-F "file=@dist/myplugin_linux_amd64.hmap"
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"url": "https://example.com/myplugin.hmap"}'
|
||||||
|
|
||||||
|
# 2. 从本地路径安装(保留安装包)
|
||||||
|
curl -X POST http://127.0.0.1:9876/plugins \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"path": "/path/to/myplugin.hmap"}'
|
||||||
|
|
||||||
|
# 3. 直接上传二进制
|
||||||
|
curl -X POST http://127.0.0.1:9876/plugins \
|
||||||
|
--data-binary @dist/myplugin.hmap
|
||||||
```
|
```
|
||||||
|
|
||||||
或通过 WebUI 插件管理页面上传安装。
|
安装后需调用 `/api/v1/plugins/reload` 或重启内核生效。
|
||||||
|
|
||||||
|
也可通过 WebUI 插件管理页面上传安装。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@ -1,3 +1,5 @@
|
|||||||
|
//go:build linux || darwin
|
||||||
|
|
||||||
package cabi
|
package cabi
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|
|||||||
@ -1,26 +1,11 @@
|
|||||||
package plugin
|
package plugin
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"crypto/sha256"
|
|
||||||
"encoding/hex"
|
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"plugin"
|
|
||||||
"reflect"
|
|
||||||
|
|
||||||
sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk"
|
|
||||||
pubsdk "gitcode.com/JianFeeeee/homeagent-sdk/sdk"
|
|
||||||
|
|
||||||
"gitcode.com/JianFeeeee/HomeAgent/internal/plugin/cabi"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// .so 插件必须导出函数 NewPlugin,签名与 NativeFactory 一致:
|
|
||||||
//
|
|
||||||
// func NewPlugin(name string, config map[string]interface{}) (sdk.Plugin, error) {
|
|
||||||
// return &myPlugin{name: name}, nil
|
|
||||||
// }
|
|
||||||
const (
|
const (
|
||||||
soEntry = "plugin.so"
|
soEntry = "plugin.so"
|
||||||
dllEntry = "plugin.dll"
|
dllEntry = "plugin.dll"
|
||||||
@ -28,18 +13,6 @@ const (
|
|||||||
metaEntry = "plugin.json"
|
metaEntry = "plugin.json"
|
||||||
)
|
)
|
||||||
|
|
||||||
type dynamicPlugin struct {
|
|
||||||
name string
|
|
||||||
impl pubsdk.Plugin
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *dynamicPlugin) Name() string { return p.name }
|
|
||||||
func (p *dynamicPlugin) Start(s *sdk.PluginSDK) error {
|
|
||||||
return p.impl.Start(s.PluginSDK)
|
|
||||||
}
|
|
||||||
func (p *dynamicPlugin) Stop() error { return p.impl.Stop() }
|
|
||||||
|
|
||||||
// readManifest 读取插件目录下的 plugin.json。文件不存在时不报错。
|
|
||||||
func readManifest(dir string) *PluginManifest {
|
func readManifest(dir string) *PluginManifest {
|
||||||
data, err := os.ReadFile(filepath.Join(dir, metaEntry))
|
data, err := os.ReadFile(filepath.Join(dir, metaEntry))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -52,91 +25,4 @@ func readManifest(dir string) *PluginManifest {
|
|||||||
return &m
|
return &m
|
||||||
}
|
}
|
||||||
|
|
||||||
// cabiPlugin wraps a C ABI loaded plugin (.so via -buildmode=c-shared).
|
var _ = json.Marshal
|
||||||
type cabiPlugin struct {
|
|
||||||
name string
|
|
||||||
handle *cabi.Handle
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *cabiPlugin) Name() string { return p.name }
|
|
||||||
func (p *cabiPlugin) Start(s *sdk.PluginSDK) error {
|
|
||||||
corePtr := p.handle.CreateCoreAPI(s)
|
|
||||||
if corePtr == nil {
|
|
||||||
return fmt.Errorf("cabi: failed to create CoreAPI for %s", p.name)
|
|
||||||
}
|
|
||||||
if err := p.handle.Start(corePtr); err != nil {
|
|
||||||
return fmt.Errorf("cabi: start %s: %w", p.name, err)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *cabiPlugin) Stop() error {
|
|
||||||
_ = p.handle.Stop()
|
|
||||||
// Note: intentionally NOT calling p.handle.Close() (dlclose).
|
|
||||||
// The .so stays loaded because plugin goroutines (HTTP server, tickers)
|
|
||||||
// may still be running. dlclose would unmap their code and cause SIGSEGV.
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// tryLoadSO 尝试从插件目录加载 plugin.so。
|
|
||||||
// 优先尝试 C ABI 加载(-buildmode=c-shared),失败时回退到 Go plugin.Open。
|
|
||||||
func tryLoadSO(dir, name string, config map[string]interface{}) (sdk.Plugin, error) {
|
|
||||||
soPath := filepath.Join(dir, soEntry)
|
|
||||||
if _, err := os.Stat(soPath); os.IsNotExist(err) {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Try C ABI first
|
|
||||||
handle, err := cabi.Load(soPath, name, config)
|
|
||||||
if err == nil {
|
|
||||||
return &cabiPlugin{name: name, handle: handle}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fall back to Go plugin.Open
|
|
||||||
data, err := os.ReadFile(soPath)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("read %s: %w", soPath, err)
|
|
||||||
}
|
|
||||||
h := sha256.Sum256(data)
|
|
||||||
cacheKey := fmt.Sprintf("plugin_%s_%s.so", name, hex.EncodeToString(h[:8]))
|
|
||||||
cachePath := filepath.Join(os.TempDir(), cacheKey)
|
|
||||||
if _, err := os.Stat(cachePath); os.IsNotExist(err) {
|
|
||||||
if err := os.WriteFile(cachePath, data, 0644); err != nil {
|
|
||||||
return nil, fmt.Errorf("write cache %s: %w", cachePath, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
p, err := plugin.Open(cachePath)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("plugin.Open %s: %w", cachePath, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
sym, err := p.Lookup("NewPlugin")
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf(".so %s must export NewPlugin: %w", soPath, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
rv := reflect.ValueOf(sym)
|
|
||||||
if rv.Kind() != reflect.Func {
|
|
||||||
return nil, fmt.Errorf("NewPlugin in %s is not a function (type=%T)", soPath, sym)
|
|
||||||
}
|
|
||||||
if rv.Type().NumIn() != 2 || rv.Type().NumOut() != 2 {
|
|
||||||
return nil, fmt.Errorf("NewPlugin in %s has wrong arity", soPath)
|
|
||||||
}
|
|
||||||
outs := rv.Call([]reflect.Value{reflect.ValueOf(name), reflect.ValueOf(config)})
|
|
||||||
if len(outs) != 2 {
|
|
||||||
return nil, fmt.Errorf("NewPlugin in %s returned unexpected values", soPath)
|
|
||||||
}
|
|
||||||
if !outs[1].IsNil() {
|
|
||||||
if err, ok := outs[1].Interface().(error); ok {
|
|
||||||
return nil, fmt.Errorf("NewPlugin %s: %w", name, err)
|
|
||||||
}
|
|
||||||
return nil, fmt.Errorf("NewPlugin %s returned non-error second value", name)
|
|
||||||
}
|
|
||||||
plg, ok := outs[0].Interface().(pubsdk.Plugin)
|
|
||||||
if !ok {
|
|
||||||
return nil, fmt.Errorf("NewPlugin in %s does not implement pubsdk.Plugin", soPath)
|
|
||||||
}
|
|
||||||
|
|
||||||
return &dynamicPlugin{name: name, impl: plg}, nil
|
|
||||||
}
|
|
||||||
|
|||||||
119
internal/plugin/dynamic_loader_unix.go
Normal file
119
internal/plugin/dynamic_loader_unix.go
Normal file
@ -0,0 +1,119 @@
|
|||||||
|
//go:build linux || darwin
|
||||||
|
|
||||||
|
package plugin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"plugin"
|
||||||
|
"reflect"
|
||||||
|
|
||||||
|
pubsdk "gitcode.com/JianFeeeee/homeagent-sdk/sdk"
|
||||||
|
sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk"
|
||||||
|
|
||||||
|
"gitcode.com/JianFeeeee/HomeAgent/internal/plugin/cabi"
|
||||||
|
)
|
||||||
|
|
||||||
|
type dynamicPlugin struct {
|
||||||
|
name string
|
||||||
|
impl pubsdk.Plugin
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *dynamicPlugin) Name() string { return p.name }
|
||||||
|
func (p *dynamicPlugin) Start(s *sdk.PluginSDK) error {
|
||||||
|
return p.impl.Start(s.PluginSDK)
|
||||||
|
}
|
||||||
|
func (p *dynamicPlugin) Stop() error { return p.impl.Stop() }
|
||||||
|
|
||||||
|
type cabiPlugin struct {
|
||||||
|
name string
|
||||||
|
handle *cabi.Handle
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *cabiPlugin) Name() string { return p.name }
|
||||||
|
func (p *cabiPlugin) Start(s *sdk.PluginSDK) error {
|
||||||
|
corePtr := p.handle.CreateCoreAPI(s)
|
||||||
|
if corePtr == nil {
|
||||||
|
return fmt.Errorf("cabi: failed to create CoreAPI for %s", p.name)
|
||||||
|
}
|
||||||
|
if err := p.handle.Start(corePtr); err != nil {
|
||||||
|
return fmt.Errorf("cabi: start %s: %w", p.name, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *cabiPlugin) Stop() error {
|
||||||
|
_ = p.handle.Stop()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func tryLoadSO(dir, name string, config map[string]interface{}) (sdk.Plugin, error) {
|
||||||
|
soPath := filepath.Join(dir, soEntry)
|
||||||
|
if _, err := os.Stat(soPath); os.IsNotExist(err) {
|
||||||
|
// 回退尝试 plugin.dylib (macOS 原生扩展名)
|
||||||
|
dylibPath := filepath.Join(dir, "plugin.dylib")
|
||||||
|
if _, err2 := os.Stat(dylibPath); err2 == nil {
|
||||||
|
soPath = dylibPath
|
||||||
|
} else {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handle, err := cabi.Load(soPath, name, config)
|
||||||
|
if err == nil {
|
||||||
|
return &cabiPlugin{name: name, handle: handle}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := os.ReadFile(soPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("read %s: %w", soPath, err)
|
||||||
|
}
|
||||||
|
h := sha256.Sum256(data)
|
||||||
|
cacheKey := fmt.Sprintf("plugin_%s_%s.so", name, hex.EncodeToString(h[:8]))
|
||||||
|
cachePath := filepath.Join(os.TempDir(), cacheKey)
|
||||||
|
if _, err := os.Stat(cachePath); os.IsNotExist(err) {
|
||||||
|
if err := os.WriteFile(cachePath, data, 0644); err != nil {
|
||||||
|
return nil, fmt.Errorf("write cache %s: %w", cachePath, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
p, err := plugin.Open(cachePath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("plugin.Open %s: %w", cachePath, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
sym, err := p.Lookup("NewPlugin")
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf(".so %s must export NewPlugin: %w", soPath, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
rv := reflect.ValueOf(sym)
|
||||||
|
if rv.Kind() != reflect.Func {
|
||||||
|
return nil, fmt.Errorf("NewPlugin in %s is not a function (type=%T)", soPath, sym)
|
||||||
|
}
|
||||||
|
if rv.Type().NumIn() != 2 || rv.Type().NumOut() != 2 {
|
||||||
|
return nil, fmt.Errorf("NewPlugin in %s has wrong arity", soPath)
|
||||||
|
}
|
||||||
|
outs := rv.Call([]reflect.Value{reflect.ValueOf(name), reflect.ValueOf(config)})
|
||||||
|
if len(outs) != 2 {
|
||||||
|
return nil, fmt.Errorf("NewPlugin in %s returned unexpected values", soPath)
|
||||||
|
}
|
||||||
|
if !outs[1].IsNil() {
|
||||||
|
if err, ok := outs[1].Interface().(error); ok {
|
||||||
|
return nil, fmt.Errorf("NewPlugin %s: %w", name, err)
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("NewPlugin %s returned non-error second value", name)
|
||||||
|
}
|
||||||
|
plg, ok := outs[0].Interface().(pubsdk.Plugin)
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("NewPlugin in %s does not implement pubsdk.Plugin", soPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &dynamicPlugin{name: name, impl: plg}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ = json.Marshal
|
||||||
11
internal/plugin/dynamic_loader_windows.go
Normal file
11
internal/plugin/dynamic_loader_windows.go
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
//go:build windows
|
||||||
|
|
||||||
|
package plugin
|
||||||
|
|
||||||
|
import (
|
||||||
|
sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk"
|
||||||
|
)
|
||||||
|
|
||||||
|
func tryLoadSO(dir, name string, config map[string]interface{}) (sdk.Plugin, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
@ -19,7 +19,8 @@ type PluginManifest struct {
|
|||||||
License string `json:"license,omitempty"`
|
License string `json:"license,omitempty"`
|
||||||
Homepage string `json:"homepage,omitempty"`
|
Homepage string `json:"homepage,omitempty"`
|
||||||
Repository string `json:"repository,omitempty"`
|
Repository string `json:"repository,omitempty"`
|
||||||
Entry string `json:"entry"` // "plugin.so" | "main.lua" | "SKILL.md"
|
Entry string `json:"entry"` // "plugin.so" | "plugin.dll" | "main.lua" | "SKILL.md"
|
||||||
|
Platforms []string `json:"platforms,omitempty"` // 声明的支持平台: ["linux","darwin","windows"]
|
||||||
MinVersion string `json:"min_version,omitempty"`
|
MinVersion string `json:"min_version,omitempty"`
|
||||||
Tags []string `json:"tags,omitempty"`
|
Tags []string `json:"tags,omitempty"`
|
||||||
Deprecated bool `json:"deprecated,omitempty"`
|
Deprecated bool `json:"deprecated,omitempty"`
|
||||||
|
|||||||
@ -13,6 +13,7 @@ import (
|
|||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
@ -21,6 +22,30 @@ import (
|
|||||||
sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk"
|
sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// platformBinary 按当前 OS 选择正确的插件二进制文件名。
|
||||||
|
// 返回 (zip内文件名, 安装后重命名).
|
||||||
|
func platformBinary() (zipName, canonicalName string) {
|
||||||
|
switch runtime.GOOS {
|
||||||
|
case "linux":
|
||||||
|
return "plugin.so", "plugin.so"
|
||||||
|
case "darwin":
|
||||||
|
return "plugin.dylib", "plugin.so" // dlopen 兼容 .so 名称
|
||||||
|
case "windows":
|
||||||
|
return "plugin.dll", "plugin.dll"
|
||||||
|
default:
|
||||||
|
return "", ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// validBinaries 是 .hmap 中所有可识别的平台二进制文件名。
|
||||||
|
var validBinaries = map[string]bool{
|
||||||
|
"plugin.so": true,
|
||||||
|
"plugin.dylib": true,
|
||||||
|
"plugin.dll": true,
|
||||||
|
"main.lua": true,
|
||||||
|
"SKILL.md": true,
|
||||||
|
}
|
||||||
|
|
||||||
var downloadClient = &http.Client{
|
var downloadClient = &http.Client{
|
||||||
Timeout: 30 * time.Second,
|
Timeout: 30 * time.Second,
|
||||||
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
||||||
@ -210,22 +235,32 @@ func (p *Plugin) handlePlugins(w http.ResponseWriter, r *http.Request) {
|
|||||||
ct := r.Header.Get("Content-Type")
|
ct := r.Header.Get("Content-Type")
|
||||||
if strings.HasPrefix(ct, "application/json") {
|
if strings.HasPrefix(ct, "application/json") {
|
||||||
var body struct {
|
var body struct {
|
||||||
URL string `json:"url"`
|
URL string `json:"url"`
|
||||||
|
Path string `json:"path"`
|
||||||
}
|
}
|
||||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||||
http.Error(w, "invalid json", http.StatusBadRequest)
|
http.Error(w, "invalid json", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if body.URL == "" {
|
switch {
|
||||||
http.Error(w, "url is required", http.StatusBadRequest)
|
case body.URL != "":
|
||||||
|
result, err := p.installFromURL(body.URL)
|
||||||
|
if err != nil {
|
||||||
|
writeJSON(w, http.StatusInternalServerError, map[string]interface{}{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, result)
|
||||||
|
case body.Path != "":
|
||||||
|
result, err := p.installFromPath(body.Path)
|
||||||
|
if err != nil {
|
||||||
|
writeJSON(w, http.StatusInternalServerError, map[string]interface{}{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, result)
|
||||||
|
default:
|
||||||
|
http.Error(w, "url or path is required", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
result, err := p.installFromURL(body.URL)
|
|
||||||
if err != nil {
|
|
||||||
writeJSON(w, http.StatusInternalServerError, map[string]interface{}{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
writeJSON(w, http.StatusOK, result)
|
|
||||||
} else {
|
} else {
|
||||||
data, err := io.ReadAll(r.Body)
|
data, err := io.ReadAll(r.Body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -277,6 +312,14 @@ func (p *Plugin) handlePluginByID(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
// ======== Core Logic ========
|
// ======== Core Logic ========
|
||||||
|
|
||||||
|
func (p *Plugin) installFromPath(path string) (interface{}, error) {
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("read file: %w", err)
|
||||||
|
}
|
||||||
|
return p.installFromData(data)
|
||||||
|
}
|
||||||
|
|
||||||
func (p *Plugin) installFromURL(rawURL string) (interface{}, error) {
|
func (p *Plugin) installFromURL(rawURL string) (interface{}, error) {
|
||||||
log.Printf("[pluginmgr] downloading: %s", rawURL)
|
log.Printf("[pluginmgr] downloading: %s", rawURL)
|
||||||
|
|
||||||
@ -303,7 +346,16 @@ func (p *Plugin) installFromURL(rawURL string) (interface{}, error) {
|
|||||||
return nil, fmt.Errorf("read response: %w", err)
|
return nil, fmt.Errorf("read response: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return p.installFromData(data)
|
result, err := p.installFromData(data)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if m, ok := result.(map[string]interface{}); ok {
|
||||||
|
m["source"] = "url"
|
||||||
|
result = m
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *Plugin) installFromData(data []byte) (interface{}, error) {
|
func (p *Plugin) installFromData(data []byte) (interface{}, error) {
|
||||||
@ -438,9 +490,10 @@ func (p *Plugin) pluginInfo(name string) (interface{}, error) {
|
|||||||
// ======== Package Validation ========
|
// ======== Package Validation ========
|
||||||
|
|
||||||
type pluginPackage struct {
|
type pluginPackage struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Version string `json:"version"`
|
Version string `json:"version"`
|
||||||
Entry string `json:"entry"`
|
Entry string `json:"entry"`
|
||||||
|
Platforms []string `json:"platforms,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func validatePackage(data []byte) (*pluginPackage, error) {
|
func validatePackage(data []byte) (*pluginPackage, error) {
|
||||||
@ -495,43 +548,82 @@ func validatePackage(data []byte) (*pluginPackage, error) {
|
|||||||
return nil, fmt.Errorf("manifest: entry required")
|
return nil, fmt.Errorf("manifest: entry required")
|
||||||
}
|
}
|
||||||
|
|
||||||
hasEntry := false
|
// verify at least one valid binary exists for any platform
|
||||||
|
hasBinary := false
|
||||||
|
zipEntries := map[string]bool{}
|
||||||
for _, f := range reader.File {
|
for _, f := range reader.File {
|
||||||
if f.Name == pkg.Entry && !f.FileInfo().IsDir() {
|
if !f.FileInfo().IsDir() {
|
||||||
hasEntry = true
|
zipEntries[f.Name] = true
|
||||||
break
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if !hasEntry {
|
|
||||||
return nil, fmt.Errorf("entry %q not found in package", pkg.Entry)
|
if len(pkg.Platforms) > 0 {
|
||||||
|
// bundle mode: check each declared platform has a matching binary
|
||||||
|
for _, plat := range pkg.Platforms {
|
||||||
|
bin, ok := map[string]string{
|
||||||
|
"linux": "plugin.so",
|
||||||
|
"darwin": "plugin.dylib",
|
||||||
|
"windows": "plugin.dll",
|
||||||
|
}[plat]
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("unsupported platform: %q", plat)
|
||||||
|
}
|
||||||
|
if zipEntries[bin] {
|
||||||
|
hasBinary = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// legacy mode: check entry exists and is a known binary
|
||||||
|
if zipEntries[pkg.Entry] && validBinaries[pkg.Entry] {
|
||||||
|
hasBinary = true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
valid := map[string]bool{"plugin.so": true, "plugin.dll": true, "main.lua": true, "SKILL.md": true}
|
if !hasBinary {
|
||||||
if !valid[pkg.Entry] {
|
return nil, fmt.Errorf("no valid binary found in package (entry=%q, platforms=%v)", pkg.Entry, pkg.Platforms)
|
||||||
return nil, fmt.Errorf("unsupported entry: %q", pkg.Entry)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return &pkg, nil
|
return &pkg, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// copyZipEntry 解压 zip 中的单个文件到目标路径。
|
||||||
|
func copyZipEntry(f *zip.File, dest string) error {
|
||||||
|
os.MkdirAll(filepath.Dir(dest), 0755)
|
||||||
|
rc, err := f.Open()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("open %s: %w", f.Name, err)
|
||||||
|
}
|
||||||
|
defer rc.Close()
|
||||||
|
out, err := os.OpenFile(dest, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, f.Mode())
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("create %s: %w", dest, err)
|
||||||
|
}
|
||||||
|
defer out.Close()
|
||||||
|
_, err = io.Copy(out, rc)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
func extractPackage(data []byte, pluginDir string) error {
|
func extractPackage(data []byte, pluginDir string) error {
|
||||||
reader, err := zip.NewReader(bytes.NewReader(data), int64(len(data)))
|
reader, err := zip.NewReader(bytes.NewReader(data), int64(len(data)))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// 先读 manifest 确定插件名
|
// 先读 manifest 确定插件名和平台信息
|
||||||
var pkgName string
|
var pkgName string
|
||||||
|
var declaredPlatforms []string
|
||||||
for _, f := range reader.File {
|
for _, f := range reader.File {
|
||||||
if f.Name == "plugin.json" && !f.FileInfo().IsDir() {
|
if f.Name == "plugin.json" && !f.FileInfo().IsDir() {
|
||||||
rc, _ := f.Open()
|
rc, _ := f.Open()
|
||||||
mData, _ := io.ReadAll(rc)
|
mData, _ := io.ReadAll(rc)
|
||||||
rc.Close()
|
rc.Close()
|
||||||
var m struct {
|
var m struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
|
Platforms []string `json:"platforms"`
|
||||||
}
|
}
|
||||||
json.Unmarshal(mData, &m)
|
json.Unmarshal(mData, &m)
|
||||||
pkgName = m.Name
|
pkgName = m.Name
|
||||||
|
declaredPlatforms = m.Platforms
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -542,6 +634,9 @@ func extractPackage(data []byte, pluginDir string) error {
|
|||||||
target := filepath.Join(pluginDir, pkgName)
|
target := filepath.Join(pluginDir, pkgName)
|
||||||
os.MkdirAll(target, 0755)
|
os.MkdirAll(target, 0755)
|
||||||
|
|
||||||
|
zipBin, canonicalName := platformBinary()
|
||||||
|
isBundle := declaredPlatforms != nil
|
||||||
|
|
||||||
for _, f := range reader.File {
|
for _, f := range reader.File {
|
||||||
fpath := filepath.Join(target, f.Name)
|
fpath := filepath.Join(target, f.Name)
|
||||||
if !strings.HasPrefix(filepath.Clean(fpath), filepath.Clean(target)+string(os.PathSeparator)) {
|
if !strings.HasPrefix(filepath.Clean(fpath), filepath.Clean(target)+string(os.PathSeparator)) {
|
||||||
@ -551,19 +646,21 @@ func extractPackage(data []byte, pluginDir string) error {
|
|||||||
os.MkdirAll(fpath, 0755)
|
os.MkdirAll(fpath, 0755)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
os.MkdirAll(filepath.Dir(fpath), 0755)
|
|
||||||
rc, err := f.Open()
|
// bundle mode: skip other platforms' binaries, keep only current OS
|
||||||
if err != nil {
|
if isBundle && validBinaries[f.Name] && f.Name != zipBin {
|
||||||
return fmt.Errorf("open %s: %w", f.Name, err)
|
continue
|
||||||
}
|
}
|
||||||
out, err := os.OpenFile(fpath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, f.Mode())
|
|
||||||
if err != nil {
|
// rename platform binary to canonical name (e.g. plugin.dylib → plugin.so)
|
||||||
rc.Close()
|
dest := fpath
|
||||||
return fmt.Errorf("create %s: %w", f.Name, err)
|
if isBundle && f.Name == zipBin && canonicalName != zipBin {
|
||||||
|
dest = filepath.Join(target, canonicalName)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := copyZipEntry(f, dest); err != nil {
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
io.Copy(out, rc)
|
|
||||||
rc.Close()
|
|
||||||
out.Close()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@ -13,18 +13,18 @@ TARGET="${1:-native}"
|
|||||||
COMPONENT="${2:-all}"
|
COMPONENT="${2:-all}"
|
||||||
|
|
||||||
# ---- platform matrix ----
|
# ---- platform matrix ----
|
||||||
# homed: linux/amd64 + linux/arm64 (CGO), macOS native-only (no osxcross)
|
# homed: linux/amd64 + linux/arm64 (CGO), macOS native-only (no osxcross),
|
||||||
# windows: blocked — gojieba CXX flags + dlfcn.h not available in MinGW
|
# windows/amd64 (MinGW)
|
||||||
# waiter: all platforms (CGO-free, raw terminal mode is a no-op on non-Linux)
|
# waiter: all platforms (CGO-free, raw terminal mode is a no-op on non-Linux)
|
||||||
# gui: electron-builder handles cross-platform natively
|
# gui: electron-builder handles cross-platform natively
|
||||||
|
|
||||||
case "$TARGET" in
|
case "$TARGET" in
|
||||||
native) GOOS="" GOARCH="" ;;
|
native) GOOS="" GOARCH="" ;;
|
||||||
linux/amd64) GOOS=linux GOARCH=amd64 CC="${CC:-}" ;;
|
linux/amd64) GOOS=linux GOARCH=amd64 CC="${CC:-}" ;;
|
||||||
linux/arm64) GOOS=linux GOARCH=arm64 CC="${CC:-aarch64-linux-gnu-gcc}" ;;
|
linux/arm64) GOOS=linux GOARCH=arm64 CC="${CC:-aarch64-linux-gnu-gcc}" CXX="${CXX:-aarch64-linux-gnu-g++}" ;;
|
||||||
darwin/amd64) GOOS=darwin GOARCH=amd64 CC="${CC:-}" ;;
|
darwin/amd64) GOOS=darwin GOARCH=amd64 CC="${CC:-}" ;;
|
||||||
darwin/arm64) GOOS=darwin GOARCH=arm64 CC="${CC:-}" ;;
|
darwin/arm64) GOOS=darwin GOARCH=arm64 CC="${CC:-}" ;;
|
||||||
windows/amd64) GOOS=windows GOARCH=amd64 CC="${CC:-x86_64-w64-mingw32-gcc}" ;;
|
windows/amd64) GOOS=windows GOARCH=amd64 CC="${CC:-x86_64-w64-mingw32-gcc}" CXX="${CXX:-x86_64-w64-mingw32-g++}" ;;
|
||||||
all)
|
all)
|
||||||
"$0" linux/amd64 "$COMPONENT"
|
"$0" linux/amd64 "$COMPONENT"
|
||||||
"$0" linux/arm64 "$COMPONENT"
|
"$0" linux/arm64 "$COMPONENT"
|
||||||
@ -47,6 +47,9 @@ fi
|
|||||||
if [ -n "${CC:-}" ]; then
|
if [ -n "${CC:-}" ]; then
|
||||||
export CC
|
export CC
|
||||||
fi
|
fi
|
||||||
|
if [ -n "${CXX:-}" ]; then
|
||||||
|
export CXX
|
||||||
|
fi
|
||||||
export CGO_ENABLED="${CGO_ENABLED:-1}"
|
export CGO_ENABLED="${CGO_ENABLED:-1}"
|
||||||
|
|
||||||
mkdir -p "$BUILD_DIR"
|
mkdir -p "$BUILD_DIR"
|
||||||
@ -61,11 +64,8 @@ build_homed() {
|
|||||||
return
|
return
|
||||||
fi
|
fi
|
||||||
if [ "$GOOS" = "windows" ]; then
|
if [ "$GOOS" = "windows" ]; then
|
||||||
echo "[SKIP] homed ${plat} — gojieba CXX flags + dlfcn.h unavailable in MinGW cross-compiler"
|
out="${out}.exe"
|
||||||
return
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [ "$GOOS" = "windows" ]; then out="${out}.exe"; fi
|
|
||||||
echo "[BUILD] homed ${plat} → $out"
|
echo "[BUILD] homed ${plat} → $out"
|
||||||
CGO_ENABLED=1 "$GO" build -trimpath -installsuffix dynlink \
|
CGO_ENABLED=1 "$GO" build -trimpath -installsuffix dynlink \
|
||||||
-ldflags "$LDFLAGS" -o "$out" ./cmd/homed/
|
-ldflags "$LDFLAGS" -o "$out" ./cmd/homed/
|
||||||
|
|||||||
Reference in New Issue
Block a user