diff --git a/README.en.md b/README.en.md
index bf4ae17..4ffb23b 100644
--- a/README.en.md
+++ b/README.en.md
@@ -300,6 +300,7 @@ pwsh -NoProfile -File build.ps1 -Out
-Go
- Which go: `-Go`; if not given, the environment variable `MODAVD_GO`; otherwise the go on PATH. If the version differs from the `toolchain` in `go.mod`, it reports an error and never quietly builds with another version.
- It uses `-trimpath -buildvcs=false` and does not use `-X` to write the version number: the same source gives byte-identical results on any machine and in any directory.
+- It does not pass `-s -w`, so the exes keep their symbol table; both carry version information (product name, description, version) from the committed `rsrc_windows_amd64.syso` files (one in the root for `modavd.exe`, one in `cmd/modavd-gui/` for `ModAVD.exe`). Unsigned, stripped Go programs are what the machine-learning rules of antivirus programs flag most. After changing the version in `internal/buildinfo`, the icon or the manifest, run `go generate ./cmd/modavd-gui` and commit both .syso files; a unit test fails until then.
- It uses the standard library only, with no third-party dependencies, and builds offline too (`GOPROXY=off`).
- `.gitattributes` makes all text files in the repository LF (binary files such as icons excepted): `go:embed` embeds the bytes in the working tree, and an exe built from CRLF files would differ.
diff --git a/README.md b/README.md
index eac2226..ecf5311 100644
--- a/README.md
+++ b/README.md
@@ -302,6 +302,7 @@ pwsh -NoProfile -File build.ps1 -Out <目录> -Go
- 用哪个 go:`-Go`,没给就用环境变量 `MODAVD_GO`,再没有就用 PATH 上的 go。版本和 `go.mod` 的 `toolchain` 不一致就报错,不会悄悄换别的版本编。
- `-trimpath -buildvcs=false`,不用 `-X` 写版本号:同一份源码在哪台机器、哪个目录编,结果都逐字节相同。
+- 不加 `-s -w`,exe 留着符号表;两个 exe 都带版本信息(产品名、说明、版本号),来自提交在仓库里的 `rsrc_windows_amd64.syso`(根目录一份给 `modavd.exe`,`cmd/modavd-gui/` 一份给 `ModAVD.exe`)。没签名、去了符号的 Go 程序最容易被杀毒软件的机器学习规则误报。改了 `internal/buildinfo` 的版本号、图标或清单,要跑 `go generate ./cmd/modavd-gui` 重新生成两份 syso 一起提交,没重新生成单测会失败。
- 只用标准库,没有第三方依赖,离线(`GOPROXY=off`)也能编。
- `.gitattributes` 让仓库里的文本文件一律 LF(图标等二进制文件除外):`go:embed` 嵌的是工作区里的字节,换成 CRLF 编出来的 exe 就不一样了。
diff --git a/build.ps1 b/build.ps1
index acbda50..50fc273 100644
--- a/build.ps1
+++ b/build.ps1
@@ -67,9 +67,12 @@ try {
# The package layout: \tools\modavd.exe and \ModAVD.exe. Never side by side: Windows names are
# case-insensitive, so modavd.exe and ModAVD.exe in one directory are the same file.
New-Item -ItemType Directory -Force (Join-Path $Out 'tools') | Out-Null
- & $Go build -trimpath -buildvcs=false '-ldflags=-s -w' -o (Join-Path $Out 'tools\modavd.exe') .
+ # No -s -w: the exes keep their symbol table and DWARF. Stripped, unsigned Go binaries are what the machine-learning
+ # detections of antivirus programs flag most (0.38.2's tools\modavd.exe was quarantined as Bearfoos.A!ml); the
+ # version information in the .syso files (cmd/modavd-gui/winres) is there for the same reason.
+ & $Go build -trimpath -buildvcs=false -o (Join-Path $Out 'tools\modavd.exe') .
if ($LASTEXITCODE) { throw "go build modavd.exe failed ($LASTEXITCODE)" }
- & $Go build -trimpath -buildvcs=false '-ldflags=-s -w -H=windowsgui' -o (Join-Path $Out 'ModAVD.exe') ./cmd/modavd-gui
+ & $Go build -trimpath -buildvcs=false '-ldflags=-H=windowsgui' -o (Join-Path $Out 'ModAVD.exe') ./cmd/modavd-gui
if ($LASTEXITCODE) { throw "go build ModAVD.exe failed ($LASTEXITCODE)" }
} finally { Pop-Location }
} finally {
diff --git a/cmd/modavd-gui/main.go b/cmd/modavd-gui/main.go
index 4eaa9be..3565eb2 100644
--- a/cmd/modavd-gui/main.go
+++ b/cmd/modavd-gui/main.go
@@ -7,10 +7,11 @@
//
// Build (GUI subsystem: no console window):
//
-// go build -trimpath -buildvcs=false -ldflags "-H=windowsgui -s -w" -o ModAVD.exe ./cmd/modavd-gui
+// go build -trimpath -buildvcs=false -ldflags "-H=windowsgui" -o ModAVD.exe ./cmd/modavd-gui
//
-// rsrc_windows_amd64.syso next to this file carries the icon and the manifest; it is generated by ./winres (see
-// there). The icon (modavd.ico) is not covered by the code licence.
+// rsrc_windows_amd64.syso next to this file carries the icon, the manifest and the version information; it is
+// generated by ./winres (see there), which writes tools\modavd.exe's next to the root main.go too. The icon
+// (modavd.ico) is not covered by the code licence.
package main
//go:generate go run ./winres
diff --git a/cmd/modavd-gui/rsrc_windows_amd64.syso b/cmd/modavd-gui/rsrc_windows_amd64.syso
index 8dae776..ca588af 100644
Binary files a/cmd/modavd-gui/rsrc_windows_amd64.syso and b/cmd/modavd-gui/rsrc_windows_amd64.syso differ
diff --git a/cmd/modavd-gui/winres/main.go b/cmd/modavd-gui/winres/main.go
index c0c0318..16d6ed0 100644
--- a/cmd/modavd-gui/winres/main.go
+++ b/cmd/modavd-gui/winres/main.go
@@ -1,11 +1,14 @@
-// Command winres writes rsrc_windows_amd64.syso for ModAVD.exe: the icon (RT_ICON images + one RT_GROUP_ICON) and
-// the application manifest (RT_MANIFEST), as a COFF object with a single .rsrc section. The Go linker merges a
-// package's .syso files into the executable, so the icon shows in Explorer and the taskbar with no extra tool.
+// Command winres writes rsrc_windows_amd64.syso for both exes: cmd/modavd-gui (ModAVD.exe) and the module root
+// (tools\modavd.exe). Each holds the icon (RT_ICON images + one RT_GROUP_ICON), the application manifest
+// (RT_MANIFEST) and the version information (RT_VERSION: product, description, copyright and buildinfo.Version), as a
+// COFF object with a single .rsrc section. The Go linker merges a package's .syso files into the executable, so the
+// icon shows in Explorer and the taskbar and the file's Properties show the version, with no extra tool.
//
-// go run ./cmd/modavd-gui/winres
+// go generate ./cmd/modavd-gui (or: go run ./cmd/modavd-gui/winres, from anywhere in the module)
//
-// The output is deterministic (no timestamps), so the same icon gives the same bytes and release builds stay
-// reproducible. Run it again only when modavd.ico or the manifest changes, and commit the .syso.
+// The output is deterministic (no timestamps), so the same inputs give the same bytes and release builds stay
+// reproducible. Run it again when modavd.ico, the manifest or buildinfo.Version changes, and commit both .syso files;
+// TestSysoUpToDate fails until then.
//
// modavd.ico is not covered by the code licence.
package main
@@ -19,13 +22,19 @@ import (
"os"
"path/filepath"
"slices"
+ "strconv"
+ "strings"
+ "unicode/utf16"
+
+ "modavd/internal/buildinfo"
)
// The manifest: common controls v6 (MessageBoxW gets the current look), per-monitor DPI awareness (sharp text on
// scaled displays), asInvoker (no elevation prompt and no installer heuristics), Windows 10/11 as the supported OS.
+// {{NAME}} is the target's assembly name.
const manifest = `
-
+
ModAVD
@@ -53,11 +62,29 @@ const manifest = `
`
+// target is one exe: the package folder (relative to the module root) its .syso goes into, and what its manifest and
+// version information say.
+type target struct {
+ dir, exe, assembly, description string
+}
+
+var targets = []target{
+ {dir: "cmd/modavd-gui", exe: "ModAVD.exe", assembly: "ModAVD.Manager", description: "ModAVD manager"},
+ {dir: ".", exe: "modavd.exe", assembly: "ModAVD.CLI", description: "ModAVD command line and MCP server"},
+}
+
const (
+ sysoName = "rsrc_windows_amd64.syso"
+ icoPath = "cmd/modavd-gui/modavd.ico"
+ product = "ModAVD"
+ copyright = "Copyright 2026 qiyinxi"
+
rtIcon = 3
+ rtVersion = 16
rtGroupIcon = 14
rtManifest = 24
langEnUS = 0x0409
+ cpUnicode = 1200
machineAMD64 = 0x8664
fileLineNumsGone = 0x0004
@@ -77,23 +104,26 @@ type resource struct {
}
func main() {
- dir := flag.String("dir", ".", "folder with modavd.ico; the .syso is written there")
+ rootFlag := flag.String("root", "", "module root (default: the nearest folder with go.mod, from the current folder up)")
flag.Parse()
- ico, err := os.ReadFile(filepath.Join(*dir, "modavd.ico"))
- if err != nil {
- fail(err)
+ root := *rootFlag
+ if root == "" {
+ var err error
+ if root, err = moduleRoot(); err != nil {
+ fail(err)
+ }
}
- res, err := iconResources(ico)
+ files, err := build(root)
if err != nil {
fail(err)
}
- res = append(res, resource{typ: rtManifest, id: 1, data: []byte(manifest)})
- out := filepath.Join(*dir, "rsrc_windows_amd64.syso")
- obj := coff(res)
- if err := os.WriteFile(out, obj, 0o644); err != nil {
- fail(err)
+ for _, t := range targets {
+ out := filepath.Join(root, t.dir, sysoName)
+ if err := os.WriteFile(out, files[t.dir], 0o644); err != nil {
+ fail(err)
+ }
+ fmt.Printf("%s: %d bytes (%s, version %s)\n", out, len(files[t.dir]), t.exe, buildinfo.Version)
}
- fmt.Printf("%s: %d bytes, %d icon images, manifest %d bytes\n", out, len(obj), len(res)-2, len(manifest))
}
func fail(err error) {
@@ -101,6 +131,47 @@ func fail(err error) {
os.Exit(1)
}
+func moduleRoot() (string, error) {
+ dir, err := os.Getwd()
+ if err != nil {
+ return "", err
+ }
+ for {
+ if _, err := os.Stat(filepath.Join(dir, "go.mod")); err == nil {
+ return dir, nil
+ }
+ up := filepath.Dir(dir)
+ if up == dir {
+ return "", errors.New("no go.mod in the current folder or above; pass -root")
+ }
+ dir = up
+ }
+}
+
+// build returns each target's .syso, keyed by the target's dir.
+func build(root string) (map[string][]byte, error) {
+ ico, err := os.ReadFile(filepath.Join(root, filepath.FromSlash(icoPath)))
+ if err != nil {
+ return nil, err
+ }
+ icons, err := iconResources(ico)
+ if err != nil {
+ return nil, err
+ }
+ version, err := versionParts(buildinfo.Version)
+ if err != nil {
+ return nil, err
+ }
+ files := map[string][]byte{}
+ for _, t := range targets {
+ res := append(slices.Clone(icons),
+ resource{typ: rtManifest, id: 1, data: []byte(strings.Replace(manifest, "{{NAME}}", t.assembly, 1))},
+ resource{typ: rtVersion, id: 1, data: versionInfo(version, buildinfo.Version, t)})
+ files[t.dir] = coff(res)
+ }
+ return files, nil
+}
+
// iconResources splits an .ico into RT_ICON resources 1..n and the RT_GROUP_ICON 1 that lists them: each group
// entry is the file's directory entry with the 4-byte image offset replaced by the 2-byte resource id.
func iconResources(ico []byte) ([]resource, error) {
@@ -127,6 +198,89 @@ func iconResources(ico []byte) ([]resource, error) {
return append(res, resource{typ: rtGroupIcon, id: 1, data: group}), nil
}
+// versionParts reads buildinfo.Version as the four 16-bit numbers of a Windows file version (missing ones are 0).
+func versionParts(v string) ([4]uint16, error) {
+ var p [4]uint16
+ fields := strings.Split(v, ".")
+ if len(fields) > 4 {
+ return p, fmt.Errorf("buildinfo.Version %q is not up to four dot-separated numbers", v)
+ }
+ for k, f := range fields {
+ n, err := strconv.ParseUint(f, 10, 16)
+ if err != nil {
+ return p, fmt.Errorf("buildinfo.Version %q is not up to four dot-separated numbers", v)
+ }
+ p[k] = uint16(n)
+ }
+ return p, nil
+}
+
+// versionInfo is the VS_VERSIONINFO block: VS_FIXEDFILEINFO with the version numbers, a StringFileInfo with one
+// en-US / Unicode string table, and a VarFileInfo whose Translation names that table.
+func versionInfo(p [4]uint16, version string, t target) []byte {
+ le := binary.LittleEndian
+ ms, ls := uint32(p[0])<<16|uint32(p[1]), uint32(p[2])<<16|uint32(p[3])
+ var fixed []byte
+ for _, v := range []uint32{
+ 0xFEEF04BD, 0x00010000, // signature, structure version
+ ms, ls, ms, ls, // file version, product version
+ 0x3F, 0, // flags mask; flags: none (not debug, pre-release, patched or private)
+ 0x00040004, 1, 0, // VOS_NT_WINDOWS32, VFT_APP, no subtype
+ 0, 0, // file date: none, for reproducible builds
+ } {
+ fixed = le.AppendUint32(fixed, v)
+ }
+ var table [][]byte
+ for _, s := range [][2]string{
+ {"CompanyName", product},
+ {"FileDescription", t.description},
+ {"FileVersion", version},
+ {"InternalName", strings.TrimSuffix(t.exe, ".exe")},
+ {"LegalCopyright", copyright},
+ {"OriginalFilename", t.exe},
+ {"ProductName", product},
+ {"ProductVersion", version},
+ } {
+ v := utf16z(s[1])
+ table = append(table, verNode(s[0], 1, len(v)/2, v, nil))
+ }
+ strs := verNode("StringFileInfo", 1, 0, nil, [][]byte{verNode("040904b0", 1, 0, nil, table)})
+ trans := le.AppendUint16(le.AppendUint16(nil, langEnUS), cpUnicode)
+ vars := verNode("VarFileInfo", 1, 0, nil, [][]byte{verNode("Translation", 0, len(trans), trans, nil)})
+ return verNode("VS_VERSION_INFO", 0, len(fixed), fixed, [][]byte{strs, vars})
+}
+
+// verNode lays out one version-information structure: wLength, wValueLength (in words for text, type 1; in bytes
+// for binary, type 0), wType, the NUL-terminated UTF-16 key, padding to 4 bytes, the value, then each child, each
+// starting on a 4-byte boundary. wLength counts no padding after the last child.
+func verNode(key string, typ uint16, valueLen int, value []byte, children [][]byte) []byte {
+ b := append(make([]byte, 6), utf16z(key)...)
+ b = append(pad4(b), value...)
+ for _, c := range children {
+ b = append(pad4(b), c...)
+ }
+ le := binary.LittleEndian
+ le.PutUint16(b[0:], uint16(len(b)))
+ le.PutUint16(b[2:], uint16(valueLen))
+ le.PutUint16(b[4:], typ)
+ return b
+}
+
+func utf16z(s string) []byte {
+ var b []byte
+ for _, c := range utf16.Encode([]rune(s)) {
+ b = binary.LittleEndian.AppendUint16(b, c)
+ }
+ return append(b, 0, 0)
+}
+
+func pad4(b []byte) []byte {
+ for len(b)%4 != 0 {
+ b = append(b, 0)
+ }
+ return b
+}
+
// coff lays out the resource tree (type -> id -> language -> data entry), then the data, in one .rsrc section and
// wraps it in an AMD64 COFF object. Each data entry's OffsetToData holds the data's offset in the section plus an
// ADDR32NB relocation against the section symbol; the linker turns that into the RVA Windows expects.
diff --git a/cmd/modavd-gui/winres/main_test.go b/cmd/modavd-gui/winres/main_test.go
new file mode 100644
index 0000000..1d76873
--- /dev/null
+++ b/cmd/modavd-gui/winres/main_test.go
@@ -0,0 +1,156 @@
+package main
+
+import (
+ "bytes"
+ "encoding/binary"
+ "os"
+ "path/filepath"
+ "testing"
+ "unicode/utf16"
+
+ "modavd/internal/buildinfo"
+)
+
+// The committed .syso files are what winres writes now: a buildinfo.Version bump (or a new icon or manifest) without
+// running it again would ship exes whose Properties show the old version.
+func TestSysoUpToDate(t *testing.T) {
+ root := filepath.Join("..", "..", "..")
+ files, err := build(root)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(files) != 2 {
+ t.Fatalf("build wrote %d files, want 2", len(files))
+ }
+ for dir, want := range files {
+ got, err := os.ReadFile(filepath.Join(root, dir, sysoName))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !bytes.Equal(got, want) {
+ t.Errorf("%s/%s is out of date (buildinfo.Version %s): run go generate ./cmd/modavd-gui and commit it",
+ dir, sysoName, buildinfo.Version)
+ }
+ }
+}
+
+func TestVersionParts(t *testing.T) {
+ for v, want := range map[string][4]uint16{"0.6.0": {0, 6, 0, 0}, "1.2.3.4": {1, 2, 3, 4}, "7": {7}, "65535.0": {65535}} {
+ got, err := versionParts(v)
+ if err != nil || got != want {
+ t.Errorf("versionParts(%q) = %v, %v; want %v", v, got, err, want)
+ }
+ }
+ for _, v := range []string{"", "1.2.3.4.5", "0.6.0-rc1", "65536.0", "1..2", "v1.2", "-1.0"} {
+ if _, err := versionParts(v); err == nil {
+ t.Errorf("versionParts(%q): no error", v)
+ }
+ }
+}
+
+type verBlock struct {
+ key string
+ typ uint16
+ value []byte
+ children []verBlock
+}
+
+// parseVer reads one structure the way Windows' VerQueryValue walks it, checking every length on the way.
+func parseVer(t *testing.T, b []byte) (verBlock, int) {
+ t.Helper()
+ le := binary.LittleEndian
+ if len(b) < 6 {
+ t.Fatalf("block of %d bytes", len(b))
+ }
+ n, valueLen, typ := int(le.Uint16(b)), int(le.Uint16(b[2:])), le.Uint16(b[4:])
+ if n > len(b) || n < 6 {
+ t.Fatalf("wLength %d with %d bytes left", n, len(b))
+ }
+ b = b[:n]
+ var key []uint16
+ at := 6
+ for ; ; at += 2 {
+ if at+2 > n {
+ t.Fatal("key without NUL")
+ }
+ c := le.Uint16(b[at:])
+ if c == 0 {
+ at += 2
+ break
+ }
+ key = append(key, c)
+ }
+ at = (at + 3) &^ 3
+ size := valueLen
+ if typ == 1 {
+ size *= 2
+ }
+ if at+size > n {
+ t.Fatalf("%q: value of %d bytes past wLength %d", string(utf16.Decode(key)), size, n)
+ }
+ blk := verBlock{key: string(utf16.Decode(key)), typ: typ, value: b[at : at+size]}
+ at += size
+ for {
+ at = (at + 3) &^ 3
+ if at >= n {
+ break
+ }
+ c, used := parseVer(t, b[at:])
+ blk.children = append(blk.children, c)
+ at += used
+ }
+ return blk, n
+}
+
+func TestVersionInfo(t *testing.T) {
+ tg := targets[1]
+ b := versionInfo([4]uint16{1, 2, 3, 4}, "1.2.3.4", tg)
+ root, n := parseVer(t, b)
+ if n != len(b) || root.key != "VS_VERSION_INFO" || root.typ != 0 || len(root.value) != 52 {
+ t.Fatalf("root: %q type %d, %d of %d bytes, value %d bytes", root.key, root.typ, n, len(b), len(root.value))
+ }
+ le := binary.LittleEndian
+ fixed := root.value
+ if le.Uint32(fixed) != 0xFEEF04BD {
+ t.Fatalf("signature %#x", le.Uint32(fixed))
+ }
+ for k, want := range []uint32{0x00010002, 0x00030004, 0x00010002, 0x00030004} {
+ if got := le.Uint32(fixed[8+4*k:]); got != want {
+ t.Errorf("version word %d = %#x, want %#x", k, got, want)
+ }
+ }
+ if len(root.children) != 2 || root.children[0].key != "StringFileInfo" || root.children[1].key != "VarFileInfo" {
+ t.Fatalf("children %+v", root.children)
+ }
+ tables := root.children[0].children
+ if len(tables) != 1 || tables[0].key != "040904b0" {
+ t.Fatalf("string tables %+v", tables)
+ }
+ got := map[string]string{}
+ for _, s := range tables[0].children {
+ if s.typ != 1 || len(s.value) < 2 || le.Uint16(s.value[len(s.value)-2:]) != 0 {
+ t.Fatalf("string %q: type %d, value not NUL-terminated", s.key, s.typ)
+ }
+ u := make([]uint16, len(s.value)/2-1)
+ for k := range u {
+ u[k] = le.Uint16(s.value[2*k:])
+ }
+ got[s.key] = string(utf16.Decode(u))
+ }
+ want := map[string]string{
+ "CompanyName": "ModAVD", "FileDescription": tg.description, "FileVersion": "1.2.3.4", "InternalName": "modavd",
+ "LegalCopyright": copyright, "OriginalFilename": "modavd.exe", "ProductName": "ModAVD", "ProductVersion": "1.2.3.4",
+ }
+ if len(got) != len(want) {
+ t.Errorf("strings %v, want %v", got, want)
+ }
+ for k, v := range want {
+ if got[k] != v {
+ t.Errorf("%s = %q, want %q", k, got[k], v)
+ }
+ }
+ vars := root.children[1].children
+ if len(vars) != 1 || vars[0].key != "Translation" || !bytes.Equal(vars[0].value, []byte{0x09, 0x04, 0xb0, 0x04}) {
+ t.Fatalf("VarFileInfo %+v", vars)
+ }
+}
diff --git a/main.go b/main.go
index 6fcd489..b99360f 100644
--- a/main.go
+++ b/main.go
@@ -1,5 +1,8 @@
// modavd: command-line manager for ModAVD instances (create, delete, modify, list, start, stop) and an MCP server
// over stdio (`modavd mcp`). It works on a ModAVD root directory: \sdk, \avd, \mas-avd.json.
+//
+// rsrc_windows_amd64.syso next to this file carries the icon, the manifest and the version information; it is
+// generated by cmd/modavd-gui/winres (go generate ./cmd/modavd-gui) together with ModAVD.exe's.
package main
import (
diff --git a/rsrc_windows_amd64.syso b/rsrc_windows_amd64.syso
new file mode 100644
index 0000000..057ea4f
Binary files /dev/null and b/rsrc_windows_amd64.syso differ