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:
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user