From d81b90b38c3d82a1bfef992342116272d4800723 Mon Sep 17 00:00:00 2001 From: Duc-Tam Nguyen Date: Mon, 15 Jun 2026 00:15:16 +0700 Subject: [PATCH] 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) + } +}