Merge pull request #3 from tamnd/feat/pack-cross-os

Make cross-platform packing robust and cover the webview build in CI
This commit is contained in:
Tam Nguyen Duc
2026-06-15 00:24:11 +07:00
committed by GitHub
6 changed files with 166 additions and 23 deletions
+19 -1
View File
@@ -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
+1 -1
View File
@@ -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
+48 -20
View File
@@ -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
+1 -1
View File
@@ -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
+57
View File
@@ -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
}
+40
View File
@@ -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)
}
}