Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.en.md
Original file line number Diff line number Diff line change
Expand Up @@ -300,6 +300,7 @@ pwsh -NoProfile -File build.ps1 -Out <dir> -Go <go.exe of go1.26.5>

- 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.

Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -302,6 +302,7 @@ pwsh -NoProfile -File build.ps1 -Out <目录> -Go <go1.26.5 的 go.exe>

- 用哪个 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 就不一样了。

Expand Down
7 changes: 5 additions & 2 deletions build.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -67,9 +67,12 @@ try {
# The package layout: <root>\tools\modavd.exe and <root>\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 {
Expand Down
7 changes: 4 additions & 3 deletions cmd/modavd-gui/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Binary file modified cmd/modavd-gui/rsrc_windows_amd64.syso
Binary file not shown.
190 changes: 172 additions & 18 deletions cmd/modavd-gui/winres/main.go
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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 = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<assembly xmlns="urn:schemas-microsoft-com:asm.v1" manifestVersion="1.0">
<assemblyIdentity type="win32" name="ModAVD.Manager" version="1.0.0.0" processorArchitecture="amd64"/>
<assemblyIdentity type="win32" name="{{NAME}}" version="1.0.0.0" processorArchitecture="amd64"/>
<description>ModAVD</description>
<dependency>
<dependentAssembly>
Expand Down Expand Up @@ -53,11 +62,29 @@ const manifest = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
</assembly>
`

// 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
Expand All @@ -77,30 +104,74 @@ 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) {
fmt.Fprintln(os.Stderr, "winres:", err)
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) {
Expand All @@ -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.
Expand Down
Loading
Loading