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