From d81b90b38c3d82a1bfef992342116272d4800723 Mon Sep 17 00:00:00 2001 From: Duc-Tam Nguyen Date: Mon, 15 Jun 2026 00:15:16 +0700 Subject: [PATCH 1/2] Detect the base binary's OS when packing a cross-platform viewer Packing with --base pointed at a kage built for another OS used to guess the target from the file name: a base ending in .exe meant Windows, anything else meant the host. That misfired in both directions. A Windows base without the .exe suffix produced a viewer with no extension that will not run on Windows, and an --out name that dropped .exe made the run hint print a macOS quarantine note for a Windows file. Sniff the base's executable header (ELF, PE, Mach-O) instead, so the target OS comes from the bytes rather than the name. A Windows viewer now always gets a .exe suffix, and the run hint names the real target and only mentions Gatekeeper for actual macOS viewers. --- cli/pack.go | 68 ++++++++++++++++++++++++++++++------------- pack/osdetect.go | 57 ++++++++++++++++++++++++++++++++++++ pack/osdetect_test.go | 40 +++++++++++++++++++++++++ 3 files changed, 145 insertions(+), 20 deletions(-) create mode 100644 pack/osdetect.go create mode 100644 pack/osdetect_test.go diff --git a/cli/pack.go b/cli/pack.go index 735352f..35e9f09 100644 --- a/cli/pack.go +++ b/cli/pack.go @@ -79,16 +79,22 @@ func runPack(mirrorArg string, f *packFlags) error { if err != nil { return err } + target := resolveTargetOS(f.base) out := f.out if out == "" { - out = defaultBinaryName(dir, f.base) + out = defaultBinaryName(dir) + } + // A Windows viewer must end in .exe to run, whether the name came from + // --out or the default, so make sure it does. + if target == "windows" && !strings.HasSuffix(strings.ToLower(out), ".exe") { + out += ".exe" } path, size, err := pack.BuildBinary(zbytes, pack.BinaryOptions{Out: out, Base: f.base}) if err != nil { return err } printPackResult(path, size) - printRunHint(path) + printRunHint(path, target) return nil default: @@ -110,27 +116,32 @@ func resolveMirror(arg string) string { return arg } -// defaultBinaryName derives a clean program name from the mirror's host: strip a -// trailing dot-suffix (paulgraham.com -> paulgraham), and append .exe when the -// target is Windows. The target is the running OS unless --base names a binary -// that looks like it was built for Windows. -func defaultBinaryName(dir, base string) string { +// defaultBinaryName derives a clean program name from the mirror's host by +// stripping a trailing dot-suffix (paulgraham.com -> paulgraham). The caller +// appends .exe for Windows targets. +func defaultBinaryName(dir string) string { host := filepath.Base(dir) - name := host if i := strings.IndexByte(host, '.'); i > 0 { - name = host[:i] + return host[:i] } - if windowsTarget(base) { - name += ".exe" - } - return name + return host } -func windowsTarget(base string) bool { +// resolveTargetOS reports which OS the packed viewer will run on. With no +// --base it is this kage's OS; with one, we sniff the base's executable header +// so detection does not hinge on the file being named ".exe". If the header is +// unrecognised we fall back to that name heuristic. +func resolveTargetOS(base string) string { if base == "" { - return runtime.GOOS == "windows" + return runtime.GOOS } - return strings.HasSuffix(strings.ToLower(base), ".exe") + if os := pack.SniffOS(base); os != "" { + return os + } + if strings.HasSuffix(strings.ToLower(base), ".exe") { + return "windows" + } + return "" } func printPackResult(path string, size int64) { @@ -138,21 +149,38 @@ func printPackResult(path string, size int64) { fmt.Fprintf(os.Stderr, " %s %s\n", styleAccent.Render("size"), humanBytes(size)) } -func printRunHint(path string) { +func printRunHint(path, target string) { rel := path if !strings.ContainsAny(path, "/\\") { rel = "./" + path } - if windowsTarget(path) && runtime.GOOS != "windows" { - fmt.Fprintf(os.Stderr, " this is a Windows viewer; run %s on Windows\n", styleAccent.Render(filepath.Base(path))) + // A viewer built for another OS cannot run here, so say where it goes + // instead of printing a run command that would not work. + if target != "" && target != runtime.GOOS { + fmt.Fprintf(os.Stderr, " this is a %s viewer; copy %s to that machine to run it\n", + osLabel(target), styleAccent.Render(filepath.Base(path))) return } fmt.Fprintf(os.Stderr, " run %s to view the site offline\n", styleAccent.Render(rel)) - if runtime.GOOS == "darwin" { + if target == "darwin" { fmt.Fprintln(os.Stderr, styleDim.Render(" (macOS may quarantine it: xattr -d com.apple.quarantine "+rel+")")) } } +// osLabel turns a GOOS value into a friendly name for the run hint. +func osLabel(goos string) string { + switch goos { + case "windows": + return "Windows" + case "darwin": + return "macOS" + case "linux": + return "Linux" + default: + return goos + } +} + // humanBytes renders a byte count in B, KiB, MiB, or GiB. func humanBytes(n int64) string { const unit = 1024 diff --git a/pack/osdetect.go b/pack/osdetect.go new file mode 100644 index 0000000..bc36e09 --- /dev/null +++ b/pack/osdetect.go @@ -0,0 +1,57 @@ +package pack + +import "os" + +// Executable-format magic numbers, enough to tell the three desktop targets +// apart by their first bytes. We only need the family (windows, darwin, linux), +// not the architecture, so the smallest distinguishing prefix is plenty. +var ( + magicELF = []byte{0x7f, 'E', 'L', 'F'} // Linux, FreeBSD, and other ELF systems + magicPE = []byte{'M', 'Z'} // Windows PE/COFF (DOS stub header) + machOLE64 = []byte{0xcf, 0xfa, 0xed, 0xfe} // Mach-O 64-bit, little-endian (amd64, arm64) + machOLE32 = []byte{0xce, 0xfa, 0xed, 0xfe} // Mach-O 32-bit, little-endian + machOBE64 = []byte{0xfe, 0xed, 0xfa, 0xcf} // Mach-O 64-bit, big-endian + machOBE32 = []byte{0xfe, 0xed, 0xfa, 0xce} // Mach-O 32-bit, big-endian + machOFat = []byte{0xca, 0xfe, 0xba, 0xbe} // Mach-O universal (fat) binary +) + +// SniffOS reads the first bytes of an executable and returns the GOOS family it +// was built for: "windows", "darwin", "linux", or "" when the bytes match none +// of them. It is how pack decides whether a cross-built viewer needs a .exe +// suffix and which run hint to print, without trusting the base's file name. +func SniffOS(path string) string { + f, err := os.Open(path) + if err != nil { + return "" + } + defer func() { _ = f.Close() }() + + head := make([]byte, 4) + if _, err := f.ReadAt(head, 0); err != nil { + return "" + } + switch { + case hasPrefix(head, magicPE): + return "windows" + case hasPrefix(head, magicELF): + return "linux" + case hasPrefix(head, machOLE64), hasPrefix(head, machOLE32), + hasPrefix(head, machOBE64), hasPrefix(head, machOBE32), + hasPrefix(head, machOFat): + return "darwin" + default: + return "" + } +} + +func hasPrefix(b, prefix []byte) bool { + if len(b) < len(prefix) { + return false + } + for i := range prefix { + if b[i] != prefix[i] { + return false + } + } + return true +} diff --git a/pack/osdetect_test.go b/pack/osdetect_test.go new file mode 100644 index 0000000..e89cdab --- /dev/null +++ b/pack/osdetect_test.go @@ -0,0 +1,40 @@ +package pack + +import ( + "os" + "path/filepath" + "testing" +) + +func TestSniffOS(t *testing.T) { + cases := []struct { + name string + head []byte + want string + }{ + {"elf", []byte{0x7f, 'E', 'L', 'F', 0x02, 0x01}, "linux"}, + {"pe", []byte{'M', 'Z', 0x90, 0x00}, "windows"}, + {"macho-le64", []byte{0xcf, 0xfa, 0xed, 0xfe}, "darwin"}, + {"macho-le32", []byte{0xce, 0xfa, 0xed, 0xfe}, "darwin"}, + {"macho-be64", []byte{0xfe, 0xed, 0xfa, 0xcf}, "darwin"}, + {"macho-fat", []byte{0xca, 0xfe, 0xba, 0xbe}, "darwin"}, + {"unknown", []byte{0x00, 0x01, 0x02, 0x03}, ""}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + p := filepath.Join(t.TempDir(), tc.name) + if err := os.WriteFile(p, tc.head, 0o644); err != nil { + t.Fatal(err) + } + if got := SniffOS(p); got != tc.want { + t.Errorf("SniffOS(%s) = %q, want %q", tc.name, got, tc.want) + } + }) + } +} + +func TestSniffOSMissingFile(t *testing.T) { + if got := SniffOS(filepath.Join(t.TempDir(), "nope")); got != "" { + t.Errorf("SniffOS(missing) = %q, want empty", got) + } +} From 5d8057473b405c6a20b6e1c50864f3b51c193345 Mon Sep 17 00:00:00 2001 From: Duc-Tam Nguyen Date: Mon, 15 Jun 2026 00:17:02 +0700 Subject: [PATCH 2/2] Cover all packages in gofmt and compile the webview build in CI The gofmt gate listed packages by hand and missed pack, zim, and viewer, so a formatting slip in the newer code would sail through. Check the whole module instead. Add a macOS job that compiles the -tags webview viewer, the cgo path the pure-Go CI never builds; the viewer code is identical across platforms, so one compile guards it. Also note the new base-OS detection in the docs. --- .github/workflows/ci.yml | 20 +++++++++++++++++++- README.md | 2 +- docs/content/guides/packing-a-mirror.md | 2 +- 3 files changed, 21 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e8fd589..28289b7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -38,7 +38,7 @@ jobs: id: chrome - name: gofmt run: | - unformatted=$(gofmt -s -l asset browser cli clone cmd robots sanitize urlx) + unformatted=$(gofmt -s -l .) if [ -n "$unformatted" ]; then echo "These files need gofmt -s -w:" echo "$unformatted" @@ -101,3 +101,21 @@ jobs: run: | go mod tidy git diff --exit-code -- go.mod go.sum + + # Compile the optional native-window viewer (-tags webview, cgo) so that path + # keeps building. The default CI build is pure Go and never touches it. The + # viewer code is the same Go on every OS, only the system WebView library + # differs, so a macOS compile (WebKit ships in the SDK) catches our + # regressions without the WebKitGTK version juggling Linux runners need. It is + # build-only: actually opening a window needs a display. + webview: + runs-on: macos-latest + steps: + - uses: actions/checkout@v5 + - uses: actions/setup-go@v6 + with: + go-version-file: go.mod + check-latest: true + cache: true + - name: build webview viewer + run: CGO_ENABLED=1 go build -tags webview ./cmd/kage diff --git a/README.md b/README.md index 6fddfec..ecbfe4a 100644 --- a/README.md +++ b/README.md @@ -149,7 +149,7 @@ kage pack paulgraham.com --format binary -o paulgraham ./paulgraham ``` -The appended archive is platform-independent; only the base executable carries the architecture. By default kage appends to itself, so you get a viewer for the machine you ran it on. Point `--base` at a kage built for another OS to produce a viewer for that platform from your own machine: +The appended archive is platform-independent; only the base executable carries the architecture. By default kage appends to itself, so you get a viewer for the machine you ran it on. Point `--base` at a kage built for another OS (grab one from a [release](https://github.com/tamnd/kage/releases); every platform ships one) to produce a viewer for that platform from your own machine. kage reads the base's executable header to figure out the target, so a Windows viewer automatically gets a `.exe` name: ```bash # Sitting on a Mac, build a Windows viewer diff --git a/docs/content/guides/packing-a-mirror.md b/docs/content/guides/packing-a-mirror.md index a3ca6f2..a62bf02 100644 --- a/docs/content/guides/packing-a-mirror.md +++ b/docs/content/guides/packing-a-mirror.md @@ -83,7 +83,7 @@ The window title comes from the archive's title. This build needs cgo and links ### Build a viewer for another platform -The appended archive is platform-independent; only the base executable carries the architecture. Point `--base` at a kage binary built for another OS (download one from a kage release) to produce a viewer for that platform from your own machine: +The appended archive is platform-independent; only the base executable carries the architecture. Point `--base` at a kage binary built for another OS (download one from a kage release; every platform ships one) to produce a viewer for that platform from your own machine. kage reads the base's executable header to detect the target OS, so a Windows viewer automatically gets a `.exe` name and the run hint names the right platform: ```bash # From macOS, build a Windows viewer