mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-11-01 14:57:38 +00:00
feat(ota): kernelupgrade 抽成独立包 + 插件仓库迁移脚本
## 1. internal/kernelupgrade:抽包(避免两份实现漂移)
原来 641 行全在 cmd/homed-upgrade 的 main 包里。内核侧的 kernel_update
工具要复用同一套逻辑,而 main 包不可被 import —— 照抄一份的后果是
"CLI 能升级、agent 调用却把机器搞坏"。
导出:ReadBinVersion / CompareVersions / ParseSemver / CmpSemver /
ProbePluginCompat / ExtractPkg / FindFile / DoUpgrade / WaitHealthy 等。
CLI 变薄壳(只留 flag 解析与 fail 输出)。
★ 顺带修一个真实 bug(判据抓到):ParseSemver 原来只挡 len<2,
于是 "1.4" 漏过去、缺失的 patch 被零值填成 1.4.0 —— 会让
「1.4 → 1.4.0」被判成完全相同(进而说"无需升级"),而两者内容未必相同。
现在要求恰好三段。
## 2. scripts/migrate-plugins-to-org.sh:插件迁成独立仓库
为什么迁:插件散在 SDK 仓 example/ 下有三个问题 ——
① 插件版本与 SDK 版本绑死(一次 SDK 发版把所有插件都带上)
② 插件作者不能单独为自己的插件发版
③ plugin_update 需要"仓库地址 → 拉源码 → 编译",而 example/ 是
别人的仓库,不是插件自己的仓库
脚本纪律:
· 默认 --dry-run,不碰 GitHub
· 产物不入库(dist/build/rawfile/node_modules/*.hmap 全排除)——
实测源码 3MB、产物 1.9G,后者进了仓库就再也删不干净
· 幂等:仓库已存在(422)则跳过创建,重跑安全
· 自动给 plg.json 补 repo 字段,使 plugin_update 能定位仓库
· 用 tar 管道而非 rsync:后者在最小化系统里常常没有(本机实测就没有)
执行结果:26 个插件全部迁移(25 Go + 1 Lua),仓库名与插件名同名。
This commit is contained in:
543
internal/kernelupgrade/upgrade.go
Normal file
543
internal/kernelupgrade/upgrade.go
Normal file
@ -0,0 +1,543 @@
|
||||
// Package kernelupgrade 实现**内核**的升级闭环:探测 / 校验 / 备份 / 原子替换 / 重启 / 回滚。
|
||||
//
|
||||
// 抽成独立包而不是留在 cmd/homed-upgrade:内核侧的 kernel_update 工具
|
||||
// 与 CLI 必须走**同一套**逻辑。两份实现必然漂移,而这条路径上漂移的
|
||||
// 后果是"CLI 能升级、agent 调用却把机器搞坏"。
|
||||
//
|
||||
// ## 范围:只面向内核
|
||||
//
|
||||
// 只处理 homed / depgraph.bin / waiter。**不碰插件** —— 插件有独立通道
|
||||
// (plugin_update),且与内核的兼容性由"自动禁用不兼容插件"兜底。
|
||||
package kernelupgrade
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"compress/gzip"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// binVersion 是 homed --version 的输出。
|
||||
type BinVersion struct {
|
||||
Version string
|
||||
Commit string
|
||||
BuildTime string
|
||||
SDKVersion string
|
||||
}
|
||||
|
||||
// readBinVersion 跑 `<bin> --version` 并解析。
|
||||
//
|
||||
// ★ 为什么用"跑它自己"而不是读文件里的字符串:
|
||||
//
|
||||
// 版本是 ldflags 注入的,从 ELF 里 grep 只能撞运气;而 --version 是内核
|
||||
// 自己算出来的,还能一并拿到 commit("同版本号不同 commit"很难察觉)。
|
||||
func ReadBinVersion(bin string) (*BinVersion, error) {
|
||||
if _, err := os.Stat(bin); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out, err := exec.Command(bin, "--version").CombinedOutput()
|
||||
if err != nil {
|
||||
// ★ 回退:老版本内核没有 --version(它是 2026-10-25 才加的)。
|
||||
//
|
||||
// 这正是 OTA 的**第一次**升级必然撞到的情况:新版工具要读线上版本,
|
||||
// 而线上那个版本还不支持被读。此时不能失败退出——否则所有人第一次
|
||||
// 升级都要人工介入。改为从运行中进程的环境/文件里找线索,找不到就
|
||||
// 诚实地说"未知",让版本比较降级(其余预检照常)。
|
||||
if v := InferVersionFromRunning(); v != nil {
|
||||
return v, nil
|
||||
}
|
||||
return nil, fmt.Errorf("执行 --version: %v(老版本内核不支持 --version,且无法从运行进程推断)", err)
|
||||
}
|
||||
v := &BinVersion{}
|
||||
for _, line := range strings.Split(string(out), "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
k, val, ok := strings.Cut(line, "=")
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
switch k {
|
||||
case "version":
|
||||
v.Version = val
|
||||
case "commit":
|
||||
v.Commit = val
|
||||
case "build_time":
|
||||
v.BuildTime = val
|
||||
case "sdk":
|
||||
v.SDKVersion = val
|
||||
}
|
||||
}
|
||||
if v.Version == "" {
|
||||
return nil, fmt.Errorf("--version 输出里没有 version= 行: %q", strings.TrimSpace(string(out)))
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
|
||||
// compareVersions 给出可读结论。返回文案而不是 bool:
|
||||
// "能升但不能降"这类判断需要说明理由,用户才敢按 -apply。
|
||||
func CompareVersions(cur, nv *BinVersion) string {
|
||||
if cur == nil {
|
||||
return "无法读取当前版本,跳过比较(其余预检照常)"
|
||||
}
|
||||
c, n := ParseSemver(cur.Version), ParseSemver(nv.Version)
|
||||
switch {
|
||||
case n == nil || c == nil:
|
||||
// 版本号形态不标准(如 1.4.0dev):退化成字符串比较并说明
|
||||
if cur.Version == nv.Version && cur.Commit == nv.Commit {
|
||||
return "⚠️ 版本与 commit 都相同 —— 升级后内容不会变化,请确认包是否拿错"
|
||||
}
|
||||
return "版本号非标准形态,按字符串比较;实测内容有差异(commit 不同)"
|
||||
case CmpSemver(n, c) > 0:
|
||||
return "✅ 可升级:包内版本高于当前"
|
||||
case CmpSemver(n, c) == 0:
|
||||
if cur.Commit != nv.Commit {
|
||||
return fmt.Sprintf("⚠️ 同为 %s 但 commit 不同(%s → %s)—— 这是一次**同版本重发**,请确认是否有意为之",
|
||||
nv.Version, ShortCommit(cur.Commit), ShortCommit(nv.Commit))
|
||||
}
|
||||
return "⚠️ 与当前完全相同 —— 无需升级"
|
||||
default:
|
||||
return "❌ 这是**降级**(包内版本低于当前)。默认不建议;确认要回退请加 -allow-downgrade"
|
||||
}
|
||||
}
|
||||
|
||||
type Semver struct{ major, minor, patch int }
|
||||
|
||||
func ParseSemver(s string) *Semver {
|
||||
// 只认纯 X.Y.Z(1.4.0dev 这类走字符串回退,避免把 dev 当成正式版)
|
||||
if strings.ContainsAny(s, "dev-+") {
|
||||
return nil
|
||||
}
|
||||
// ★ 必须恰好三段。原来只挡 len<2,于是 "1.4" 会漏过去,
|
||||
// 缺失的 patch 被零值填成 1.4.0 —— 这会让「1.4 → 1.4.0」被判定为
|
||||
// 完全相同(进而说"无需升级"),而两者内容未必相同。
|
||||
parts := strings.Split(s, ".")
|
||||
if len(parts) != 3 {
|
||||
return nil
|
||||
}
|
||||
var nums [3]int
|
||||
for i, p := range parts {
|
||||
n, err := strconv.Atoi(p)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
nums[i] = n
|
||||
}
|
||||
return &Semver{nums[0], nums[1], nums[2]}
|
||||
}
|
||||
|
||||
func CmpSemver(a, b *Semver) int {
|
||||
switch {
|
||||
case a.major != b.major:
|
||||
return a.major - b.major
|
||||
case a.minor != b.minor:
|
||||
return a.minor - b.minor
|
||||
default:
|
||||
return a.patch - b.patch
|
||||
}
|
||||
}
|
||||
|
||||
// pluginCompat 是一个与新内核 SDK 主版本不匹配的插件。
|
||||
type PluginCompat struct {
|
||||
Name string
|
||||
SDKVersion string
|
||||
WantMajor string
|
||||
}
|
||||
|
||||
// probePluginCompat 扫插件目录,读每个插件编译时的 SDK 版本。
|
||||
//
|
||||
// 数据来源:plugin.json 里**没有** sdk_version 字段,所以读不到就诚实地说
|
||||
// "无法预判",而不是瞎猜。能读到的(有 meta 文件的)才比较。
|
||||
func ProbePluginCompat(dir, wantSDK string) []PluginCompat {
|
||||
if dir == "" || wantSDK == "" {
|
||||
return nil
|
||||
}
|
||||
wantMajor := MajorOf(wantSDK)
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
var out []PluginCompat
|
||||
for _, e := range entries {
|
||||
if !e.IsDir() {
|
||||
continue
|
||||
}
|
||||
pj := filepath.Join(dir, e.Name(), "plugin.json")
|
||||
b, err := os.ReadFile(pj)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
var m struct {
|
||||
SDKVersion string `json:"sdk_version"`
|
||||
}
|
||||
json.Unmarshal(b, &m)
|
||||
if m.SDKVersion == "" {
|
||||
continue // plugin.json 不含该字段:无法预判,不虚报
|
||||
}
|
||||
if MajorOf(m.SDKVersion) != wantMajor {
|
||||
out = append(out, PluginCompat{Name: e.Name(), SDKVersion: m.SDKVersion, WantMajor: wantMajor})
|
||||
}
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
|
||||
return out
|
||||
}
|
||||
|
||||
func MajorOf(v string) string {
|
||||
if i := strings.IndexByte(v, '.'); i > 0 {
|
||||
return v[:i]
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func ShortCommit(c string) string {
|
||||
if c == "" || c == "unknown" {
|
||||
return "unknown"
|
||||
}
|
||||
if len(c) > 8 {
|
||||
return c[:8]
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
// extractPkg 解包并返回包含 bin/ 的根目录。
|
||||
func ExtractPkg(pkg, dst string) (string, error) {
|
||||
f, err := os.Open(pkg)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer f.Close()
|
||||
gz, err := gzip.NewReader(f)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("非 gzip: %w", err)
|
||||
}
|
||||
defer gz.Close()
|
||||
tr := tar.NewReader(gz)
|
||||
for {
|
||||
h, err := tr.Next()
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
// 防路径穿越(包是被信任的,但这条成本几乎为零)
|
||||
clean := filepath.Clean(h.Name)
|
||||
if strings.HasPrefix(clean, "..") || filepath.IsAbs(clean) {
|
||||
return "", fmt.Errorf("包内有非法路径: %s", h.Name)
|
||||
}
|
||||
p := filepath.Join(dst, clean)
|
||||
switch h.Typeflag {
|
||||
case tar.TypeDir:
|
||||
os.MkdirAll(p, 0755)
|
||||
case tar.TypeReg:
|
||||
os.MkdirAll(filepath.Dir(p), 0755)
|
||||
out, err := os.OpenFile(p, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, os.FileMode(h.Mode))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if _, err := io.Copy(out, tr); err != nil {
|
||||
out.Close()
|
||||
return "", err
|
||||
}
|
||||
out.Close()
|
||||
}
|
||||
}
|
||||
// 找含 bin/homed 的那层
|
||||
if FindFile(dst, "homed") == "" {
|
||||
return "", fmt.Errorf("包内无 homed")
|
||||
}
|
||||
return dst, nil
|
||||
}
|
||||
|
||||
// findFile 在 root 下递归找文件名匹配的第一个。
|
||||
func FindFile(root, name string) string {
|
||||
var found string
|
||||
filepath.Walk(root, func(p string, info os.FileInfo, err error) error {
|
||||
if err != nil || info.IsDir() || found != "" {
|
||||
return nil
|
||||
}
|
||||
if info.Name() == name {
|
||||
found = p
|
||||
}
|
||||
return nil
|
||||
})
|
||||
return found
|
||||
}
|
||||
|
||||
func FileSize(p string) int64 {
|
||||
if st, err := os.Stat(p); err == nil {
|
||||
return st.Size()
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// doUpgrade 执行真实升级:备份 → 预检 → 原子替换 → 重启 → 后置验证 → 失败回滚。
|
||||
//
|
||||
// 每一步失败都往回退,且**回滚后必须能正常启动**——这是本函数的核心承诺:
|
||||
// 宁可升级失败,不可让机器起不来。
|
||||
func DoUpgrade(root, binPath, newDG, pluginsDir string) error {
|
||||
newBin := FindFile(root, "homed")
|
||||
if newBin == "" {
|
||||
return fmt.Errorf("包内无 homed")
|
||||
}
|
||||
|
||||
// ── 预检:新二进制必须能自报版本(说明它是可执行的、架构对的)──
|
||||
nv, err := ReadBinVersion(newBin)
|
||||
if err != nil {
|
||||
return fmt.Errorf("预检失败,新二进制不可执行: %w", err)
|
||||
}
|
||||
fmt.Printf("\n== 预检通过 ==\n新内核 %s 可执行\n", nv.Version)
|
||||
|
||||
// ── 备份:只备本次会动的文件(不整目录拷贝,那是几百 MB)──
|
||||
ts := NowStamp()
|
||||
backup := filepath.Join("/root", "homed-upgrade-backup-"+ts)
|
||||
if err := os.MkdirAll(backup, 0755); err != nil {
|
||||
return fmt.Errorf("建备份目录: %w", err)
|
||||
}
|
||||
targets := []string{binPath}
|
||||
if dg := filepath.Join(filepath.Dir(binPath), "depgraph.bin"); FileExists(dg) {
|
||||
targets = append(targets, dg)
|
||||
}
|
||||
for _, t := range targets {
|
||||
if err := CopyFile(t, filepath.Join(backup, filepath.Base(t))); err != nil {
|
||||
return fmt.Errorf("备份 %s: %w", t, err)
|
||||
}
|
||||
}
|
||||
fmt.Printf("已备份 %d 个文件 → %s\n", len(targets), backup)
|
||||
|
||||
rollback := func(cause error) error {
|
||||
fmt.Printf("\n❌ %v\n== 开始回滚 ==\n", cause)
|
||||
for _, t := range targets {
|
||||
src := filepath.Join(backup, filepath.Base(t))
|
||||
if err := CopyFile(src, t); err != nil {
|
||||
fmt.Printf(" ⚠️ 回滚 %s 失败: %v(需人工处理)\n", t, err)
|
||||
continue
|
||||
}
|
||||
fmt.Printf(" 已恢复 %s\n", t)
|
||||
}
|
||||
if err := restartService(); err != nil {
|
||||
fmt.Printf(" ⚠️ 重启失败: %v(需人工处理)\n", err)
|
||||
} else if !WaitHealthy(60) {
|
||||
fmt.Println(" ⚠️ 回滚后仍不健康,需人工介入")
|
||||
} else {
|
||||
fmt.Println(" ✅ 已回滚并恢复正常运行")
|
||||
}
|
||||
return fmt.Errorf("升级失败已回滚: %w", cause)
|
||||
}
|
||||
|
||||
// ── 替换(depgraph 必须与 homed 同批,顺序无关但都要成)──
|
||||
if newDG != "" {
|
||||
dst := filepath.Join(filepath.Dir(binPath), "depgraph.bin")
|
||||
if err := CopyFile(newDG, dst); err != nil {
|
||||
return rollback(fmt.Errorf("替换 depgraph.bin: %w", err))
|
||||
}
|
||||
fmt.Println("已替换 depgraph.bin")
|
||||
}
|
||||
if err := CopyFile(newBin, binPath); err != nil {
|
||||
return rollback(fmt.Errorf("替换 homed: %w", err))
|
||||
}
|
||||
fmt.Println("已替换 homed")
|
||||
|
||||
// ── 重启 + 后置验证 ──
|
||||
if err := restartService(); err != nil {
|
||||
return rollback(fmt.Errorf("重启: %w", err))
|
||||
}
|
||||
if !WaitHealthy(90) {
|
||||
return rollback(fmt.Errorf("新版本未在超时内达到健康状态"))
|
||||
}
|
||||
|
||||
// 版本复核:确认跑起来的**确实**是新版本(而不是重启失败后旧的还在)
|
||||
got, err := ReadBinVersion(binPath)
|
||||
if err != nil || got.Version != nv.Version || got.Commit != nv.Commit {
|
||||
return rollback(fmt.Errorf("版本复核不符:期望 %s/%s,实际 %v", nv.Version, ShortCommit(nv.Commit), got))
|
||||
}
|
||||
fmt.Printf("\n✅ 升级完成:%s (commit %s)\n", got.Version, ShortCommit(got.Commit))
|
||||
fmt.Printf(" 备份保留在 %s(确认稳定后可删)\n", backup)
|
||||
return nil
|
||||
}
|
||||
|
||||
// waitHealthy 轮询 systemd 的 active 状态。
|
||||
func WaitHealthy(seconds int) bool {
|
||||
for i := 0; i < seconds; i++ {
|
||||
out, err := exec.Command("systemctl", "is-active", "homeagent").Output()
|
||||
if err == nil && strings.TrimSpace(string(out)) == "active" {
|
||||
// active 还不够:给它几秒让插件真正加载完再判一次
|
||||
if i > 3 {
|
||||
return true
|
||||
}
|
||||
}
|
||||
sleepSecond()
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func restartService() error {
|
||||
out, err := exec.Command("systemctl", "restart", "homeagent").CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("%v (%s)", err, strings.TrimSpace(string(out)))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func CopyFile(src, dst string) error {
|
||||
in, err := os.Open(src)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer in.Close()
|
||||
st, err := in.Stat()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tmp := dst + ".tmp-upgrade"
|
||||
out, err := os.OpenFile(tmp, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, st.Mode())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := io.Copy(out, in); err != nil {
|
||||
out.Close()
|
||||
os.Remove(tmp)
|
||||
return err
|
||||
}
|
||||
if err := out.Close(); err != nil {
|
||||
os.Remove(tmp)
|
||||
return err
|
||||
}
|
||||
// 原子替换:先写临时文件再 rename,避免"写到一半进程在跑"
|
||||
return os.Rename(tmp, dst)
|
||||
}
|
||||
|
||||
func FileExists(p string) bool {
|
||||
_, err := os.Stat(p)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func NowStamp() string {
|
||||
return time.Now().Format("20060102-150405")
|
||||
}
|
||||
|
||||
// sleepSecond 隔一秒(抽出来便于将来换成可注入的等待,测试不必真睡)。
|
||||
func sleepSecond() { time.Sleep(time.Second) }
|
||||
|
||||
// inferVersionFromRunning 在老内核不支持 --version 时,尽力推断当前版本。
|
||||
//
|
||||
// 手段按可靠性排序,任一成功即返回:
|
||||
// 1. 读 /proc/<pid>/exe 指向的文件,用 strings 找 "version=" 形态(不保证有)
|
||||
// 2. 读 WebUI 的 /api/v1/agents(那里返回 meta.Version)—— 需服务在跑
|
||||
//
|
||||
// 都失败则返回 nil,调用方把比较降级为"未知",**不阻断**升级。
|
||||
func InferVersionFromRunning() *BinVersion {
|
||||
// 手段 1:从二进制里找注入的版本串。
|
||||
//
|
||||
// ldflags 注入的字符串在 rodata 里是裸的,形如 "1.4.0dev";
|
||||
// 配合 "commit=" 一起找才有意义,所以这里只认成对出现的情况。
|
||||
if v := scanBinaryForVersion("/usr/local/bin/homed"); v != nil {
|
||||
return v
|
||||
}
|
||||
// 手段 2:问 waiter。
|
||||
//
|
||||
// ★ 这条最可靠:waiter 与 homed **同批构建**(同一个版本、同一个 commit),
|
||||
// 而且它一直支持 --version(输出形如
|
||||
// `waiter 1.4.0 (commit 98850e1, built 2026-10-17T09:20:25Z)`)。
|
||||
// 老内核没有 --version,但同批的 waiter 有 —— 用它反推。
|
||||
if v := versionFromWaiter("/usr/local/bin/waiter"); v != nil {
|
||||
return v
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// versionFromWaiter 解析 waiter 的版本行反推内核版本。
|
||||
//
|
||||
// 只认 "waiter X.Y.Z (commit ABC, built ...)" 这一种形态;
|
||||
// 格式变了就返回 nil(宁可说"未知",不可瞎猜)。
|
||||
func versionFromWaiter(path string) *BinVersion {
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
return nil
|
||||
}
|
||||
out, err := exec.Command(path, "--version").CombinedOutput()
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
line := strings.TrimSpace(string(out))
|
||||
// 取第一个语义化版本号
|
||||
v := FirstSemverLike(line)
|
||||
if v == "" {
|
||||
return nil
|
||||
}
|
||||
commit := ""
|
||||
if i := strings.Index(line, "commit "); i >= 0 {
|
||||
rest := line[i+len("commit "):]
|
||||
if j := strings.IndexAny(rest, ",)"); j > 0 {
|
||||
commit = strings.TrimSpace(rest[:j])
|
||||
}
|
||||
}
|
||||
return &BinVersion{Version: v, Commit: commit}
|
||||
}
|
||||
|
||||
// scanBinaryForVersion 在二进制里搜版本串(老内核的兜底手段)。
|
||||
func scanBinaryForVersion(path string) *BinVersion {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
defer f.Close()
|
||||
// 只读前 200MB:版本串在 rodata,通常在文件前 1/3
|
||||
buf := make([]byte, 200<<20)
|
||||
n, _ := io.ReadFull(f, buf)
|
||||
buf = buf[:n]
|
||||
|
||||
// 找 SDKCompatibleVersion 附近的三元组:本仓 meta 变量相邻存放,
|
||||
// 命中 "1.3.0" 且附近有语义化版本形态时,取最近的一个当内核版本。
|
||||
// ★ 这是**尽力而为**:可能撞到别的版本号,所以只作为"未知"的替代,
|
||||
// 且返回的 commit 恒为 unknown(无法可靠恢复)。
|
||||
idx := strings.Index(string(buf), "SDKCompatibleVersion")
|
||||
if idx < 0 {
|
||||
return nil
|
||||
}
|
||||
// 在附近 4KB 里找 X.Y.Z 形态
|
||||
window := string(buf[maxInt(0, idx-4096):minInt(len(buf), idx+4096)])
|
||||
if v := FirstSemverLike(window); v != "" {
|
||||
return &BinVersion{Version: v, Commit: "unknown"}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func FirstSemverLike(s string) string {
|
||||
for i := 0; i+5 <= len(s); i++ {
|
||||
if s[i] < '0' || s[i] > '9' {
|
||||
continue
|
||||
}
|
||||
j := i
|
||||
dots := 0
|
||||
for j < len(s) && ((s[j] >= '0' && s[j] <= '9') || s[j] == '.') {
|
||||
if s[j] == '.' {
|
||||
dots++
|
||||
}
|
||||
j++
|
||||
}
|
||||
if dots == 2 && j-i >= 5 {
|
||||
return s[i:j]
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func maxInt(a, b int) int {
|
||||
if a > b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func minInt(a, b int) int {
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
Reference in New Issue
Block a user