Files
homeagent-sdk/tools/hmapdev/cmd_build.go
JianFeeeee 4cb3a0bda4 feat(sdk): 通道方向契约落地 + 模板工程/示例插件显式登记 inputch + 生成器两处修正
## 背景:内核侧发现的真问题

在真实二进制压力测试里发现:插件只调 `RegisterOutputChannel("cli", ...)`,
却用同一个通道名 `InjectTextSync("cli", ...)` 注入输入 ⇒ 内核 inputch 登记表里
**没有**这个通道,"把 inputch 划给驻留子"直接失败(`划入 inputch cli: inputch 未注册`)。

根因是**契约没有落到插件与 SDK 面上**:inputch 是内核最基本的**输入路由单位**,
"谁会往这个通道注入输入"必须显式声明,而 SDK 文档没说清它与 RegisterOutputChannel
的分工,示例与模板工程也没有示范。

## SDK 面

- `RegisterInputChannel` / `RegisterOutputChannel` 的文档补齐**方向契约**:
  入站(谁会注入)与出站(output_send__<name> 的回复发给谁)是分开登记的两件事;
  凡是用 `InjectText*/InjectInput*/InjectInterrupt*(source, "<name>", ...)` 注入的
  通道名都要 RegisterInputChannel。README 同步补了一段契约说明。

## 示例插件(全部补齐,之前只有 qq/weather 是对的)

`a2a`、`acp`、`browser`、`memo`:注入用 `p.name` ⇒ 登记 `p.name`;
`calendar`、`rss`:注入用字面量通道名 ⇒ 登记同名通道。
(这些插件此前是"能注入、但通道不在登记表里",与 cli 同类问题。)

## 模板工程(生成器 templates.go)

- `tmplPluginGo`:示范入站+出站两个方向(含 ChannelDef/NoMemory 说明与 `inputch 未注册` 的成因)。
- `tmplMainLua`:同样两个方向(`register_input_channel` / `register_output_channel`)。
- `tmplReadme`:新增 "Channels" 一节(方向对照表 + 兜底告警说明)。
- 实测:`hmapdev init` 生成的 Go/Lua 工程都含通道代码,Go 工程可构建打包出 `.hmap`;
  `--lua` 工程同样生成通道代码。

## 生成器两处修正(都是实测踩出来的)

1. `sdk install --from <dir>`:install 原本只能从 Release 归档下载,而 SDK 开发期的新能力
   (如 proc 桥要透传的 `InjectOptions.Priority`)还没发版 ⇒ 生成的工程必然编译失败
   (`z_proc_gen.go: opts.Priority undefined`)。现在可用本地源码装一个版本并激活。
   实测:`hmapdev sdk install --from <local sdk>` → 装成 v1.3.0 并激活 → 工程构建通过。
2. 构建前置校验 `sdkHasInjectPriority`:proc 桥模板需要 `InjectOptions.Priority`,
   旧 SDK 没有时应给出**可执行**的报错(升级 SDK 或用 `--from`),
   而不是把两条 `opts.Priority undefined` 编译错误甩给用户(那些错误指向生成物,
   完全看不出是 SDK 版本问题)。实测:声明 sdk=1.2.0 的工程构建时正确命中该提示。

## 未决(发布期事项)

`InjectOptions.Priority` 属本特性线新增能力,**已发布的 SDK v1.2.0 不含它**;
发版时 SDK 版本需随之内含该能力(当前源码 meta 已是 1.3.0),否则外部开发者
按文档生成的工程会撞上上面那条守卫。
2026-09-13 11:37:36 +08:00

903 lines
27 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package main
import (
"archive/zip"
"encoding/json"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
)
type BuildConfig struct {
OutDir string
Targets []string
Bundle bool
SDKPath string
Replaces []string
}
// buildFailed 记录本次构建是否有平台失败。
//
// 为什么要它:这两个构建函数遇到错误只是 Printf 后 return而 cmdBuild 返回
// void于是**构建失败却以 0 退出**。调用方批量重编脚本、CI、发版脚本
// 只能靠翻日志发现失败——实测中一个示例的 windows 目标编译失败,脚本却报
// 「17/17 全绿」,并因此少产出 16 个 .hmap。
// 判成功要看退出码,不能靠人读日志。
var buildFailed bool
func cmdBuild(args []string) {
// Read all config from plg.json first
plg, err := readPlgJSON("plg.json")
if err != nil {
fmt.Printf("error: read plg.json: %v\n", err)
os.Exit(1)
}
// Base config from plg.json
outDir := plg.OutDirDefault()
targets := plg.TargetList()
bundle := plg.BundleDefault()
sdkPath := plg.SDKPath
var cliReplaces []string
// CLI flags override plg.json
for i := 0; i < len(args); i++ {
switch args[i] {
case "--outdir":
if i+1 < len(args) {
outDir = args[i+1]
i++
}
case "--target":
if i+1 < len(args) {
targets = append(targets, args[i+1])
i++
}
case "--bundle":
bundle = true
case "--no-bundle":
bundle = false
case "--sdk-path":
if i+1 < len(args) {
sdkPath = args[i+1]
i++
}
case "--replace", "-R":
if i+1 < len(args) {
cliReplaces = append(cliReplaces, args[i+1])
i++
}
}
}
if plg.IsLua() {
buildTarget(plg, "lua", outDir, "")
return
}
// 项目可在 plg.json 里声明 sdk中版本或完整版本如 "1.2" / "1.2.1"
// 显式 --sdk-path / plg.json 的 sdk_path 优先 —— 那是直指源码目录,
// 常用于本机改 SDK 的联调场景。
if sdkPath == "" && strings.TrimSpace(plg.SDK) != "" {
dir, ver, err := ResolveSDKForProject(plg.SDK)
if err != nil {
fmt.Printf("error: %v\n", err)
os.Exit(1)
}
sdkPath, plg.ResolvedSDK = dir, ver
fmt.Printf("[hmapdev] SDK %s项目声明 sdk=%s\n", ver, plg.SDK)
} else if sdkPath != "" && plg.ResolvedSDK == "" {
// 走的是显式路径:尽力记录它是哪版(读不到就不记,不因此失败)
plg.ResolvedSDK = normalizeSDKVersion(readMetaVersion(sdkPath))
}
// SDK 能力前置校验proc 桥的模板z_proc_gen.go会透传 InjectOptions.Priority
// 而旧版 SDK 没有这个字段。不校验的话,用户看到的是 z_proc_gen.go 里两条
// "opts.Priority undefined" 编译错误——错误信息指向生成物,完全看不出是 SDK 版本问题。
if sdkPath != "" && !sdkHasInjectPriority(sdkPath) {
fmt.Printf("error: 当前 SDK%s缺少 sdk.InjectOptions.Priority\n", plg.ResolvedSDK)
fmt.Printf(" 子进程模式proc 桥)的模板需要它来透传注入优先级 L1-L4。\n")
fmt.Printf(" 解决办法(二选一):\n")
fmt.Printf(" 1) 升级 SDKhmapdev sdk install <含该能力的版本> && hmapdev sdk use <版本>\n")
fmt.Printf(" 2) 用本地 SDK 源码hmapdev sdk install --from /path/to/homeagent-sdk\n")
os.Exit(1)
}
// Ensure go.mod exists with correct SDK path
sdkModule := ensureGoMod(plg, sdkPath)
// 保证 SDK 模块可解析,否则编译必死在 "missing go.sum entry"。
if sdkModule != "" {
ensureSDKResolvable(plg, sdkModule, sdkPath)
}
// Merge plg.json replaces + CLI overrides
replaceSlice := plg.ReplacesToSlice()
replaceSlice = append(replaceSlice, cliReplaces...)
// Apply go.mod replace directives for single-target go build
gmPatcher := NewGoModPatcher(".", replaceSlice)
gmRestore, err := gmPatcher.Apply()
if err != nil {
fmt.Printf("warn: apply go.mod replaces: %v\n", err)
}
defer gmRestore()
if bundle || len(targets) == 0 {
buildBundle(plg, outDir, sdkPath)
} else {
for _, t := range targets {
buildTarget(plg, t, outDir, sdkPath)
}
}
// 以非零码退出调用方批量重编、CI、发版脚本靠退出码判成败。
// 以前这里直接 return失败也退 0于是「构建失败」只能靠人翻日志发现——
// 实测中就因此把一次部分失败当成了全绿。
if buildFailed {
fmt.Println("error: 至少一个目标构建失败(详见上面日志)")
os.Exit(1)
}
}
// allBundleTargets 是 --bundle 模式构建的全部平台。
//
// 子进程模式下各平台产物同名plugin.bin——进程边界即 ABI 边界,
// 不存在平台特有扩展名,故 zip 内按平台加后缀区分;
// 内核安装时按当前平台挑对应条目重命名为 plugin.bin。
//
// **不含 windows**:插件只能运行在 homed 能跑的平台上,而 homed 已明确放弃
// Windows 原生支持(插件体系依赖 fd 继承 + 统一共享内存区的段内偏移,
// Windows 句柄模型无法表达。Windows 用户走 WSL2而 WSL2 就是 linux/amd64。
var allBundleTargets = []struct {
target string
entry string // 二进制在 zip 中的文件名
}{
{"linux/amd64", "plugin.bin.linux.amd64"},
{"darwin/amd64", "plugin.bin.darwin.amd64"},
}
// checkTargetSupported 在构建前拦下**已知不支持**的目标,给出可执行的报错。
//
// 为什么要有它:插件运行在 homed 的进程里,所以目标平台必须是 homed 能跑的。
// homed 已放弃 Windows 原生(原因:插件依赖 fd 继承与统一共享内存区段内偏移,
// Windows 句柄模型无法表达),却还去构建 windows 插件,结果是死在一句
// 「undefined: attachUnifiedShm」——看起来像代码 bug实际是平台策略。
// 这里换成明确的结论,并且**不静默跳过**:静默跳过会让人以为产出的包里包含 windows。
func checkTargetSupported(target string) error {
if strings.HasPrefix(target, "windows/") {
return fmt.Errorf("不支持 windows 插件目标:插件运行在 homed 内," +
"而 homed 已放弃 Windows 原生支持(插件体系依赖 fd 继承与统一共享内存区" +
"段内偏移解引用Windows 句柄模型无法表达。Windows 请用 WSL2——" +
"它就是 linux/amd64用 --target linux/amd64 即可")
}
return nil
}
func buildBundle(plg *PlgConfig, outDir string, sdkPath string) {
os.MkdirAll(outDir, 0755)
buildDir := "build"
os.MkdirAll(buildDir, 0755)
runtimeCleanup, err := generateProcRuntime()
if err != nil {
fmt.Printf(" error: %v\n", err)
buildFailed = true
return
}
defer runtimeCleanup()
thirdpartCleanup := linkThirdpart(plg, "linux/amd64")
defer thirdpartCleanup()
var binaries []binEntry
for _, bt := range allBundleTargets {
if err := checkTargetSupported(bt.target); err != nil {
fmt.Printf(" error: %v\n", err)
buildFailed = true
return
}
cfg, errMsg := resolveBuild(bt.target)
if cfg == nil {
fmt.Printf(" error: %s\n", errMsg)
buildFailed = true
return
}
// 每平台产物落到独立路径,避免相互覆盖
outName := fmt.Sprintf("%s_%s_%s", cfg.entryFile, cfg.goos, cfg.goarch)
outPath := filepath.Join(buildDir, outName)
// 零 cgo跨平台交叉编译不需目标平台 C 工具链
cmd := exec.Command("go", "build", "-trimpath", "-o", outPath)
cmd.Env = os.Environ()
cmd.Env = append(cmd.Env, "GOOS="+cfg.goos, "GOARCH="+cfg.goarch, "CGO_ENABLED=0")
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
fmt.Printf(" compiling %s/%s (子进程模式CGO_ENABLED=0)...\n", cfg.goos, cfg.goarch)
if err := cmd.Run(); err != nil {
// 单平台失败即整包失败bundle 少一个平台就是个坏包,
// 却仍会生成 .hmap 让人以为打包成功。
fmt.Printf(" error: build %s/%s: %v\n", cfg.goos, cfg.goarch, err)
buildFailed = true
return
}
binaries = append(binaries, binEntry{src: outPath, zip: bt.entry})
}
// Write plugin.json with all platforms declared
platforms := map[string]bool{}
for _, bt := range allBundleTargets {
parts := strings.SplitN(bt.target, "/", 2)
platforms[parts[0]] = true
}
plats := make([]string, 0, len(platforms))
for p := range platforms {
plats = append(plats, p)
}
writePluginJSON(plg, plats, procEntryFile)
// package single .hmap with correctly named entries
hmapPath := filepath.Join(outDir, fmt.Sprintf("%s_bundle.hmap", toSnake(plg.NameEn)))
createBundleHmap(hmapPath, "plugin.json", binaries)
fmt.Printf(" packaged %s\n", filepath.Base(hmapPath))
}
// IsLua 判断是否为 Lua 插件(走解释器,不经过 Go 编译)。
//
// 这是 entry 字段唯一仍在使用的用途Go 插件不再看 entry 值,一律产出 plugin.bin。
func (p *PlgConfig) IsLua() bool { return p.Entry == luaEntryFile }
func readPlgJSON(path string) (*PlgConfig, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, err
}
var plg PlgConfig
if err := json.Unmarshal(data, &plg); err != nil {
return nil, err
}
return &plg, nil
}
func parseTargets(raw string) []string {
raw = strings.TrimSpace(raw)
if raw == "" || raw == "native" {
return nil
}
var t []string
for _, s := range strings.Split(raw, ",") {
s = strings.TrimSpace(s)
if s != "" {
t = append(t, s)
}
}
return t
}
func writePluginJSON(plg *PlgConfig, platforms []string, entry string) {
m := map[string]interface{}{
"name": plg.Name,
"name_zh": plg.NameZh,
"name_en": plg.NameEn,
"version": plg.Version,
"description": plg.Description,
"author": plg.Author,
"entry": entry,
}
if len(platforms) > 0 {
m["platforms"] = platforms
}
if len(plg.Tags) > 0 {
m["tags"] = plg.Tags
}
// 记录「用哪版 SDK 编的」:插件产物与内核协议绑定,出问题时这是第一个要看的字段。
if plg.ResolvedSDK != "" {
m["sdk"] = plg.ResolvedSDK
}
data, _ := json.MarshalIndent(m, "", " ")
os.WriteFile("plugin.json", data, 0644)
}
type buildConfig struct {
goos string
goarch string
entryFile string // 一律为 plugin.bin进程边界即 ABI 边界,无平台特有扩展名)
}
// resolveBuild 解析目标平台。
//
// 全平台统一产出 plugin.bin子进程模式下不存在 .so/.dylib/.dll 的区分,
// 因为进程边界本身就是 ABI 边界——这正是三套独立 ABI 实现收敛为
// 单一 RPC 实现的直接后果§9.2Windows 不再是能力退化的第三套实现)。
func resolveBuild(target string) (*buildConfig, string) {
if target == "lua" || target == "" {
return nil, "lua"
}
goos, goarch, _ := strings.Cut(target, "/")
if goos == "" {
goos = runtime.GOOS
if goarch == "" {
goarch = runtime.GOARCH
}
}
switch goos {
case "linux", "darwin", "freebsd", "windows":
return &buildConfig{goos: goos, goarch: goarch, entryFile: procEntryFile}, ""
default:
return nil, fmt.Sprintf("unsupported OS %q", goos)
}
}
// ensureGoMod 确保插件项目的 go.mod 包含 SDK 的 replace 指令。
// 如果 go.mod 不存在或已有正确 replace则跳过。
func ensureGoMod(plg *PlgConfig, sdkPath string) string {
gomodPath := "go.mod"
data, err := os.ReadFile(gomodPath)
if err != nil {
return "" // no go.mod, skip
}
lines := strings.Split(string(data), "\n")
var sdkModule string
for _, line := range lines {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "//") {
continue
}
var mod string
if strings.HasPrefix(line, "require ") {
parts := strings.Fields(line)
if len(parts) >= 2 {
mod = parts[1]
}
} else if !strings.HasPrefix(line, "require") &&
!strings.HasPrefix(line, "module ") &&
!strings.HasPrefix(line, "go ") &&
!strings.HasPrefix(line, "replace ") {
// require 块内行(无前缀)或 import 行
parts := strings.Fields(line)
if len(parts) >= 1 {
mod = parts[0]
}
}
if mod != "" && strings.Contains(mod, "homeagent-sdk") {
sdkModule = mod
break
}
}
if sdkModule == "" {
return ""
}
if sdkPath == "" {
// 仅显式配置plg.json sdk_path 或 --sdk-path才写入 replace
// 避免 go.mod 中出现本地绝对路径。
return sdkModule
}
absSDK, _ := filepath.Abs(sdkPath)
absSDK = strings.ReplaceAll(absSDK, "\\", "/")
// Remove any existing replace line for this module (even if path differs)
var keep []string
replaceLine := fmt.Sprintf("replace %s => %s", sdkModule, absSDK)
alreadyExists := false
for _, line := range lines {
if strings.HasPrefix(strings.TrimSpace(line), "replace ") &&
strings.Contains(line, sdkModule) {
parts := strings.Fields(line)
if len(parts) >= 3 && strings.ReplaceAll(parts[2], "\\", "/") == absSDK {
alreadyExists = true
}
continue // strip any existing replace for this module
}
keep = append(keep, line)
}
// 同步 require 版本replace 指向 1.2.1 而 require 还写 1.2.0 是自相矛盾的
// —— 有人删掉 replace 就会静默退回旧版本去编(`go list -m` 报的也是假版本)。
// 以本次真正选中的版本为准改写 require 行。
requireChanged := false
if v := normalizeSDKVersion(plg.ResolvedSDK); v != "" {
want := "require " + sdkModule + " v" + v
for i, line := range keep {
t := strings.TrimSpace(line)
if !strings.HasPrefix(t, "require ") {
continue
}
parts := strings.Fields(t)
if len(parts) >= 3 && parts[1] == sdkModule {
indent := line[:len(line)-len(strings.TrimLeft(line, " \t"))]
if t != want {
keep[i] = indent + want
requireChanged = true
}
}
}
}
if alreadyExists && !requireChanged {
return sdkModule
}
keep = append(keep, replaceLine, "")
if err := os.WriteFile(gomodPath, []byte(strings.Join(keep, "\n")), 0644); err != nil {
fmt.Printf(" warn: update go.mod replace: %v\n", err)
}
return sdkModule
}
// ensureSDKResolvable 保证 SDK 模块在编译前可解析。
//
// 为何需要这个函数gitcode 的模块不在 proxy.golang.org 上。只要 go.mod
// 里的 SDK 靠 require 版本号解析,而本地又没 go.sum 条目go build 就报
// "missing go.sum entry";而原来那句 `go mod download <mod>` 会去公共 proxy
// 拉一个永远拉不到的条目,超时后只打一行 warn 就继继编译,紧接着死在
// 同一个错误上——新用户拿到的是两段无关的报错。
//
// 三级策略,按代价递增:
// 1. go.mod 已有指向本地目录的 replace —— 什么都不用做replace 到目录时
// go 不需要也不校验 go.sum
// 2. 能定位到本机 SDK 源码 —— 写入 replace。这是存量项目go.mod 旧、
// 无 replace的救场路径。
// 3. 都不行 —— 跑 `go mod tidy`(带 -mod=mod让它自己去试失败则给
// 可操作的提示而不是让用户去猜。
func ensureSDKResolvable(plg *PlgConfig, sdkModule, sdkPath string) {
data, err := os.ReadFile("go.mod")
if err != nil {
return
}
// 策略 1已有指向本地目录的 replace。
// replace 目标带 / 或 . 开头的才是路径;指向另一个模块的 replace 不算。
for _, line := range strings.Split(string(data), "\n") {
line = strings.TrimSpace(line)
if !strings.HasPrefix(line, "replace ") || !strings.Contains(line, sdkModule) {
continue
}
parts := strings.Fields(line)
if len(parts) < 4 {
continue
}
target := parts[3]
if strings.HasPrefix(target, ".") || strings.HasPrefix(target, "/") ||
strings.Contains(target, ":/") || strings.Contains(target, ":\\") {
return // 已指向本地目录,无需 go.sum
}
}
// 策略 2能定位到本机 SDK 就写 replace。
// resolveSDKPath 失败会 os.Exit所以只在能确定拿到路径时调用它背后的探测。
if root := findLocalSDK(sdkPath); root != "" {
if appendGoModReplace(sdkModule, root) {
fmt.Printf(" SDK 指向本机源码(已写入 go.mod replace%s\n", root)
return
}
}
// 策略 3交给 go mod tidy。
if _, err := os.Stat("go.sum"); err == nil {
return // 已有 go.sum不插手
}
fmt.Println(" 解析 SDK 依赖go mod tidy...")
tidy := exec.Command("go", "mod", "tidy")
tidy.Env = append(os.Environ(), "GOFLAGS=-mod=mod")
if out, err := tidy.CombinedOutput(); err != nil {
fmt.Printf(" warn: go mod tidy 失败:%v\n", err)
if len(out) > 0 {
fmt.Printf(" %s\n", strings.TrimSpace(string(out)))
}
fmt.Printf(" 提示:%s 不在公共 proxy 上。用以下任一方式指向本机 SDK\n", sdkModule)
fmt.Printf(" hmapdev sdk install latest # 装一份到 ~/.homeagent/hmapdev/sdk\n")
fmt.Printf(" hmapdev build --sdk-path <路径> # 或直接指定源码目录\n")
}
}
// findLocalSDK 探测本机 SDK 源码根目录,找不到返回空串。
//
// 与 resolveSDKPath 的区别:后者找不到就 os.Exit适合“必须有”的调用点
// 这里是“有则更好”的探测,不能把构建搞挂。
func findLocalSDK(sdkPath string) string {
candidates := []string{}
if sdkPath != "" {
if abs, err := filepath.Abs(sdkPath); err == nil {
candidates = append(candidates, abs)
}
}
// hmapdev 自身所在位置往上三级tools/hmapdev/hmapdev → SDK 根)
if self, err := os.Executable(); err == nil {
candidates = append(candidates, filepath.Dir(filepath.Dir(filepath.Dir(self))))
}
// hmapdev sdk use 选定的版本(复用 sdkStore(),含改名前的旧目录回退)
store := sdkStore()
if store != "" {
if d, err := os.ReadFile(filepath.Join(store, "current")); err == nil {
if ver := strings.TrimSpace(string(d)); ver != "" {
candidates = append(candidates, filepath.Join(store, ver))
}
}
}
for _, c := range candidates {
if c == "" {
continue
}
if _, err := os.Stat(filepath.Join(c, "sdk", "plugin.go")); err == nil {
return c
}
}
return ""
}
// appendGoModReplace 向 go.mod 追加一条 replace成功返回 true。
func appendGoModReplace(module, localPath string) bool {
data, err := os.ReadFile("go.mod")
if err != nil {
return false
}
abs, err := filepath.Abs(localPath)
if err != nil {
return false
}
abs = strings.ReplaceAll(abs, "\\", "/")
s := strings.TrimRight(string(data), "\r\n")
s += fmt.Sprintf("\n\nreplace %s => %s\n", module, abs)
return os.WriteFile("go.mod", []byte(s), 0644) == nil
}
func resolveSDKPath(sdkPath string) string {
if sdkPath != "" {
abs, _ := filepath.Abs(sdkPath)
if _, err := os.Stat(filepath.Join(abs, "sdk", "plugin.go")); err == nil {
return abs
}
fmt.Printf("error: --sdk-path %q not a valid SDK\n", sdkPath)
os.Exit(1)
}
// Detect from hmapdev's own location (internal dev)
self, err := os.Executable()
if err == nil {
cand := filepath.Dir(filepath.Dir(filepath.Dir(self)))
if _, err := os.Stat(filepath.Join(cand, "sdk", "plugin.go")); err == nil {
return cand
}
}
// Active SDK via hmapdev sdk use
store := sdkStore()
if store != "" {
if d, err := os.ReadFile(filepath.Join(store, "current")); err == nil {
ver := strings.TrimSpace(string(d))
if ver != "" {
root := filepath.Join(store, ver)
if _, err := os.Stat(filepath.Join(root, "sdk", "plugin.go")); err == nil {
return root
}
}
}
}
fmt.Printf("error: cannot locate SDK. Use --sdk-path or 'hmapdev sdk use'\n")
os.Exit(1)
return ""
}
func buildTarget(plg *PlgConfig, target, outDir, sdkPath string) {
os.MkdirAll(outDir, 0755)
// Lua: no compilation, package source directly
if target == "lua" {
writePluginJSON(plg, nil, "main.lua")
pkgFiles := []string{"plugin.json", "main.lua"}
for _, f := range []string{"README.md", "LICENSE"} {
if _, err := os.Stat(f); err == nil {
pkgFiles = append(pkgFiles, f)
}
}
// Include thirdpart Lua files
if entries, err := os.ReadDir("thirdpart"); err == nil {
for _, e := range entries {
if !e.IsDir() && strings.HasSuffix(e.Name(), ".lua") {
path := filepath.Join("thirdpart", e.Name())
if _, err := os.Stat(path); err == nil {
pkgFiles = append(pkgFiles, path)
}
}
}
}
hmapName := fmt.Sprintf("%s_lua.hmap", toSnake(plg.NameEn))
createHmap(filepath.Join(outDir, hmapName), pkgFiles)
fmt.Printf(" packaged %s\n", hmapName)
return
}
// Resolve build config全平台统一产出 plugin.bin
cfg, errMsg := resolveBuild(target)
if cfg == nil {
fmt.Printf(" error: %s\n", errMsg)
return
}
buildDir := "build"
os.MkdirAll(buildDir, 0755)
outPath := filepath.Join(buildDir, cfg.entryFile)
runtimeCleanup, err := generateProcRuntime()
if err != nil {
fmt.Printf(" error: %v\n", err)
return
}
defer runtimeCleanup()
// 已知未实现的目标在编译前拦下,给可执行的报错(见 checkTargetSupported
if err := checkTargetSupported(target); err != nil {
fmt.Printf(" error: %v\n", err)
buildFailed = true
return
}
// Auto-link thirdpart/ contents + source_dirs + replace targets
thirdpartCleanup := linkThirdpart(plg, target)
defer thirdpartCleanup()
// Write plugin.json with the correct entry for this target
writePluginJSON(plg, nil, cfg.entryFile)
// 普通 go build + 零 cgo交叉编译不再需要目标平台的 C 工具链
// (旧路径靠 detectWindowsCC 找 MinGW现在整个问题消失
cmd := exec.Command("go", "build", "-trimpath", "-o", outPath)
cmd.Env = os.Environ()
cmd.Env = append(cmd.Env, "GOOS="+cfg.goos, "GOARCH="+cfg.goarch, "CGO_ENABLED=0")
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
fmt.Printf(" compiling %s/%s (子进程模式CGO_ENABLED=0)...\n", cfg.goos, cfg.goarch)
if err := cmd.Run(); err != nil {
fmt.Printf(" error: build %s/%s: %v\n", cfg.goos, cfg.goarch, err)
buildFailed = true
return
}
// package
pkgFiles := []string{"plugin.json", outPath}
for _, f := range []string{"README.md", "LICENSE"} {
if _, err := os.Stat(f); err == nil {
pkgFiles = append(pkgFiles, f)
}
}
hmapName := fmt.Sprintf("%s_%s_%s.hmap", toSnake(plg.NameEn), cfg.goos, cfg.goarch)
createHmap(filepath.Join(outDir, hmapName), pkgFiles)
fmt.Printf(" packaged %s\n", hmapName)
}
type binEntry struct {
src string // 磁盘路径,如 build/plugin.bin
zip string // zip 中条目名,如 plugin.bin.linux.amd64
}
// createBundleHmap 创建包含多平台二进制的 bundle .hmap 文件。
// jsonName 是 plugin.json 在 zip 中的条目名;
// binaries 的 src 为磁盘路径zip 为 zip 中的条目名。
func createBundleHmap(hmapPath, jsonName string, binaries []binEntry) {
f, err := os.Create(hmapPath)
if err != nil {
fmt.Printf("error: create hmap %s: %v\n", hmapPath, err)
return
}
defer f.Close()
w := zip.NewWriter(f)
defer w.Close()
// Add plugin.json
writeZipEntry := func(zipName, diskPath string) {
info, err := os.Stat(diskPath)
if err != nil {
return
}
hdr, err := zip.FileInfoHeader(info)
if err != nil {
return
}
hdr.Method = zip.Deflate
hdr.Name = zipName
writer, err := w.CreateHeader(hdr)
if err != nil {
return
}
src, err := os.Open(diskPath)
if err != nil {
return
}
io.Copy(writer, src)
src.Close()
}
writeZipEntry(jsonName, jsonName)
// Add each platform binary with the correct zip entry name
for _, b := range binaries {
if _, err := os.Stat(b.src); err == nil {
writeZipEntry(b.zip, b.src)
}
}
// Add optional metadata files
for _, fname := range []string{"README.md", "LICENSE"} {
if _, err := os.Stat(fname); err == nil {
writeZipEntry(fname, fname)
}
}
}
func createHmap(hmapPath string, files []string) {
f, err := os.Create(hmapPath)
if err != nil {
fmt.Printf("error: create hmap %s: %v\n", hmapPath, err)
return
}
defer f.Close()
w := zip.NewWriter(f)
defer w.Close()
for _, path := range files {
if path == "" {
continue
}
info, err := os.Stat(path)
if err != nil {
continue
}
hdr, err := zip.FileInfoHeader(info)
if err != nil {
continue
}
hdr.Method = zip.Deflate
hdr.Name = filepath.Base(path)
writer, err := w.CreateHeader(hdr)
if err != nil {
continue
}
src, err := os.Open(path)
if err != nil {
continue
}
io.Copy(writer, src)
src.Close()
}
}
func toSnake(s string) string {
return strings.ToLower(strings.ReplaceAll(s, " ", "_"))
}
// stripIncludeGuard strips preprocessor guards and C++ comments from a C header,
// since these can confuse cgo's type resolution.
// linkThirdpart scans thirdpart/, source_dirs from plg.json, and replace target dirs
// for source files, generating auto-import stubs. Returns cleanup function.
func linkThirdpart(plg *PlgConfig, target string) func() {
const importFile = "z_thirdpart.go"
os.Remove(importFile)
if target == "lua" {
return func() {}
}
gomodPath := "go.mod"
data, err := os.ReadFile(gomodPath)
if err != nil {
return func() {}
}
modulePath := ""
for _, line := range strings.Split(string(data), "\n") {
if strings.HasPrefix(line, "module ") {
modulePath = strings.TrimSpace(line[7:])
break
}
}
if modulePath == "" {
return func() {}
}
// Collect directories to scan: thirdpart/ + source_dirs from plg.json + replace target dirs
var dirs []string
if info, err := os.Stat("thirdpart"); err == nil && info.IsDir() {
dirs = append(dirs, "thirdpart")
}
dirs = append(dirs, plg.SourceDirs...)
for _, r := range plg.ReplacesToSlice() {
_, to, found := strings.Cut(r, "=")
if !found {
continue
}
if abs, err := filepath.Abs(to); err == nil {
if info, err := os.Stat(abs); err == nil && info.IsDir() {
dirs = append(dirs, abs)
}
}
}
// Deduplicate
seen := map[string]bool{}
var unique []string
for _, d := range dirs {
abs, _ := filepath.Abs(d)
if abs != "" && !seen[abs] {
seen[abs] = true
unique = append(unique, d)
}
}
// Generate import stubs for each directory with .go files
var stubs []string
for _, d := range unique {
entries, err := os.ReadDir(d)
if err != nil {
continue
}
hasGo := false
for _, e := range entries {
if !e.IsDir() && strings.HasSuffix(e.Name(), ".go") {
hasGo = true
break
}
}
if !hasGo {
continue
}
if !filepath.IsAbs(d) {
importPath := modulePath + "/" + d
stubs = append(stubs, importPath)
} else {
// External directory: must be in replaces to get a valid import path
for _, r := range plg.ReplacesToSlice() {
from, to, found := strings.Cut(r, "=")
if !found {
continue
}
if absTo, _ := filepath.Abs(to); absTo == d {
stubs = append(stubs, strings.TrimSpace(from))
break
}
}
}
}
if len(stubs) > 0 {
var sb strings.Builder
sb.WriteString("package main\n")
for _, s := range stubs {
sb.WriteString("import _ \"" + s + "\"\n")
}
os.WriteFile(importFile, []byte(sb.String()), 0644)
}
return func() {
os.Remove(importFile)
}
}
// sdkHasInjectPriority 报告该 SDK 源码是否已具备 InjectOptions.Priority
// proc 桥透传注入优先级所必需的能力SDK 开发期与已发布版本可能不一致)。
func sdkHasInjectPriority(sdkPath string) bool {
data, err := os.ReadFile(filepath.Join(sdkPath, "sdk", "plugin.go"))
if err != nil {
return true // 读不到就不拦(不在校验范围内)
}
src := string(data)
i := strings.Index(src, "type InjectOptions struct")
if i < 0 {
return true
}
seg := src[i:]
if j := strings.Index(seg, "\n}"); j > 0 {
seg = seg[:j]
}
return strings.Contains(seg, "Priority")
}