diff --git a/CHANGELOG.md b/CHANGELOG.md index 49bf475..6983c10 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,12 @@ All notable changes to kage are recorded here. The format follows `--no-compress`. - `kage open ` serves a packed ZIM over a local HTTP server and opens your browser, the read side of `kage pack --format zim`. +- An optional native-window viewer. Built with `-tags webview` (which needs + cgo), `kage open` and a packed binary present the offline site in a real + window backed by the operating system's WebView (WKWebView, WebView2, + WebKitGTK) instead of a browser tab, so a packed kage feels like a standalone + app. The default build stays pure Go (`CGO_ENABLED=0`) and falls back to the + system browser, so the release pipeline is unchanged. - A pure-Go `zim` package that writes and reads the ZIM format: a fixed header, MIME and pointer lists, zstd-compressed (or stored) clusters, redirects, and a trailing MD5. It reads xz clusters so archives from other tooling open, and diff --git a/Makefile b/Makefile index 099483f..529153f 100644 --- a/Makefile +++ b/Makefile @@ -10,11 +10,17 @@ LDFLAGS := -s -w \ export CGO_ENABLED := 0 -.PHONY: build install test test-short vet tidy clean run +.PHONY: build build-webview install test test-short vet tidy clean run build: go build -ldflags "$(LDFLAGS)" -o bin/$(BIN) $(PKG) +# A native-window viewer: opens packed sites in their own OS WebView window +# instead of the browser. Needs cgo, so it is built separately from the default +# pure-Go binary and the release pipeline. +build-webview: + CGO_ENABLED=1 go build -tags webview -ldflags "$(LDFLAGS)" -o bin/$(BIN) $(PKG) + install: go install -ldflags "$(LDFLAGS)" $(PKG) diff --git a/README.md b/README.md index bec4664..a5c4ba5 100644 --- a/README.md +++ b/README.md @@ -168,6 +168,15 @@ make test # full suite, including Chrome-driven end-to-end tests make test-short # skip the tests that launch a browser ``` +By default kage is pure Go (`CGO_ENABLED=0`) and a packed binary opens the system +browser. Build with the `webview` tag for a native-window viewer that shows a +packed site in its own window, backed by the OS WebView, instead of a browser +tab: + +```bash +CGO_ENABLED=1 go build -tags webview -o bin/kage ./cmd/kage +``` + ## License MIT. See [LICENSE](LICENSE). diff --git a/cli/open.go b/cli/open.go index 9c4889a..0b5af42 100644 --- a/cli/open.go +++ b/cli/open.go @@ -10,6 +10,7 @@ import ( "github.com/spf13/cobra" "github.com/tamnd/kage/pack" + "github.com/tamnd/kage/viewer" "github.com/tamnd/kage/zim" ) @@ -46,17 +47,19 @@ func runOpen(ctx context.Context, path, addr string, openBrowser bool) error { fmt.Fprintln(os.Stderr, styleTitle.Render("kage open")+" "+styleDim.Render(path)) fmt.Fprintln(os.Stderr, " open "+styleAccent.Render(url)) - fmt.Fprintln(os.Stderr, styleDim.Render(" press Ctrl-C to stop")) - if openBrowser { - _ = pack.OpenInBrowser(url) + if viewer.Native { + fmt.Fprintln(os.Stderr, styleDim.Render(" close the window to stop")) + } else { + fmt.Fprintln(os.Stderr, styleDim.Render(" press Ctrl-C to stop")) } srv := &http.Server{Handler: pack.Handler(r)} - go func() { - <-ctx.Done() - _ = srv.Close() - }() - if err := srv.Serve(ln); err != nil && err != http.ErrServerClosed { + srvErr := make(chan error, 1) + go func() { srvErr <- srv.Serve(ln) }() + + _ = viewer.Show(ctx, viewer.Options{Title: archiveTitle(r), URL: url, Browser: openBrowser}) + _ = srv.Close() + if err := <-srvErr; err != nil && err != http.ErrServerClosed { return err } return nil diff --git a/cli/root.go b/cli/root.go index c5a8039..62f2c3e 100644 --- a/cli/root.go +++ b/cli/root.go @@ -16,6 +16,7 @@ import ( "github.com/spf13/cobra" "github.com/tamnd/kage/pack" + "github.com/tamnd/kage/viewer" "github.com/tamnd/kage/zim" ) @@ -60,9 +61,10 @@ func newRoot() *cobra.Command { } // runEmbeddedViewer serves the ZIM appended to this executable on an ephemeral -// local port and opens the browser. It runs until the context is cancelled -// (Ctrl-C) and ignores all command-line arguments: a packed binary is the site, -// not the kage CLI. +// local port and shows it: a native window in the webview build, the system +// browser otherwise. It runs until the viewer closes or the context is +// cancelled (Ctrl-C) and ignores all command-line arguments, because a packed +// binary is the site, not the kage CLI. func runEmbeddedViewer(ctx context.Context, ra io.ReaderAt, size int64) int { r, err := zim.NewReader(ra, size) if err != nil { @@ -75,17 +77,32 @@ func runEmbeddedViewer(ctx context.Context, ra io.ReaderAt, size int64) int { return 1 } url := "http://" + ln.Addr().String() - fmt.Fprintln(os.Stderr, "serving offline site at "+url+" (Ctrl-C to stop)") - _ = pack.OpenInBrowser(url) + if viewer.Native { + fmt.Fprintln(os.Stderr, "opening offline site (close the window to stop)") + } else { + fmt.Fprintln(os.Stderr, "serving offline site at "+url+" (Ctrl-C to stop)") + } srv := &http.Server{Handler: pack.Handler(r)} - go func() { - <-ctx.Done() - _ = srv.Close() - }() - if err := srv.Serve(ln); err != nil && err != http.ErrServerClosed { + srvErr := make(chan error, 1) + go func() { srvErr <- srv.Serve(ln) }() + + // Show blocks until the window closes (native) or ctx is cancelled (browser); + // either way, tear the server down afterwards. + _ = viewer.Show(ctx, viewer.Options{Title: archiveTitle(r), URL: url, Browser: true}) + _ = srv.Close() + if err := <-srvErr; err != nil && err != http.ErrServerClosed { fmt.Fprintln(os.Stderr, "kage:", err) return 1 } return 0 } + +// archiveTitle returns the archive's M/Title metadata for use as a window +// title, falling back to the empty string (viewer defaults it to "kage"). +func archiveTitle(r *zim.Reader) string { + if b, err := r.Get(zim.NamespaceMetadata, "Title"); err == nil { + return string(b.Data) + } + return "" +} diff --git a/cmd/kage/main.go b/cmd/kage/main.go index 10a941e..31fdb25 100644 --- a/cmd/kage/main.go +++ b/cmd/kage/main.go @@ -9,9 +9,15 @@ import ( "os/signal" "github.com/tamnd/kage/cli" + "github.com/tamnd/kage/viewer" ) func main() { + // Pin the main goroutine to the process's initial OS thread before anything + // else. In the webview build the native window must be driven from that + // thread; in the default build this is a harmless no-op. + viewer.LockMainThread() + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt) defer stop() os.Exit(cli.Execute(ctx)) diff --git a/docs/content/guides/packing-a-mirror.md b/docs/content/guides/packing-a-mirror.md index 865fbbf..8847629 100644 --- a/docs/content/guides/packing-a-mirror.md +++ b/docs/content/guides/packing-a-mirror.md @@ -85,6 +85,25 @@ serving offline site at http://127.0.0.1:52431 (Ctrl-C to stop) The binary carries a full kage, so it is tens of megabytes regardless of site size; the trade is that the recipient needs nothing installed, not even kage. +### A native window instead of a browser + +By default the viewer opens the system browser, which means a tab with an address +bar and your other tabs alongside. Build kage with the `webview` tag and it opens +the site in its own native window instead, backed by the operating system's +WebView (WKWebView on macOS, WebView2 on Windows, WebKitGTK on Linux), so a +packed binary feels like a standalone app: + +```bash +CGO_ENABLED=1 go build -tags webview -o kage ./cmd/kage +kage pack kage-out/example.com --format binary --base kage +./example # opens a window, no browser +``` + +The window title comes from the archive's title. This build needs cgo and links +the platform WebView, so it is opt-in and kept out of the default +`CGO_ENABLED=0` release; the prebuilt binaries open the browser. `kage open` honours +the same tag: built with `-tags webview` it shows the ZIM in a native window too. + ### Build a viewer for another platform The appended archive is platform-independent; only the base executable carries diff --git a/docs/content/reference/cli.md b/docs/content/reference/cli.md index 0ab795a..cac9cb4 100644 --- a/docs/content/reference/cli.md +++ b/docs/content/reference/cli.md @@ -121,3 +121,7 @@ of `kage pack --format zim`. |------|---------|---------| | `-a, --addr` | `127.0.0.1:8800` | Address to listen on | | `--open` | `true` | Open the default browser (`--open=false` to skip) | + +Built with `-tags webview` (which needs cgo), `kage open` shows the archive in a +native window instead of the browser, and `--open` no longer applies. The default +`CGO_ENABLED=0` build uses the browser. diff --git a/go.mod b/go.mod index dcb2c99..e8bc238 100644 --- a/go.mod +++ b/go.mod @@ -9,6 +9,7 @@ require ( github.com/go-rod/stealth v0.4.9 github.com/klauspost/compress v1.18.6 github.com/spf13/cobra v1.10.2 + github.com/webview/webview_go v0.0.0-20240831120633-6173450d4dd6 golang.org/x/net v0.56.0 ) diff --git a/go.sum b/go.sum index addde55..f08a247 100644 --- a/go.sum +++ b/go.sum @@ -63,6 +63,8 @@ github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/webview/webview_go v0.0.0-20240831120633-6173450d4dd6 h1:VQpB2SpK88C6B5lPHTuSZKb2Qee1QWwiFlC5CKY4AW0= +github.com/webview/webview_go v0.0.0-20240831120633-6173450d4dd6/go.mod h1:yE65LFCeWf4kyWD5re+h4XNvOHJEXOCOuJZ4v8l5sgk= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= github.com/ysmood/fetchup v0.2.3 h1:ulX+SonA0Vma5zUFXtv52Kzip/xe7aj4vqT5AJwQ+ZQ= diff --git a/pack/serve.go b/pack/serve.go index 6b5bfae..ff3156c 100644 --- a/pack/serve.go +++ b/pack/serve.go @@ -3,8 +3,6 @@ package pack import ( "errors" "net/http" - "os/exec" - "runtime" "strings" "github.com/tamnd/kage/zim" @@ -45,18 +43,3 @@ func serveBlob(w http.ResponseWriter, b zim.Blob) { } _, _ = w.Write(b.Data) } - -// OpenInBrowser launches the platform default browser at url. It is best-effort: -// callers print the url regardless and never treat a launch failure as fatal. -func OpenInBrowser(url string) error { - var cmd *exec.Cmd - switch runtime.GOOS { - case "darwin": - cmd = exec.Command("open", url) - case "windows": - cmd = exec.Command("rundll32", "url.dll,FileProtocolHandler", url) - default: - cmd = exec.Command("xdg-open", url) - } - return cmd.Start() -} diff --git a/viewer/browser.go b/viewer/browser.go new file mode 100644 index 0000000..716deff --- /dev/null +++ b/viewer/browser.go @@ -0,0 +1,41 @@ +//go:build !webview + +package viewer + +import ( + "context" + "os/exec" + "runtime" +) + +// Native is false in the default pure-Go build: there is no native window, so +// the viewer hands the URL to the system browser. +const Native = false + +// LockMainThread is a no-op without a native UI to pin to the main thread. +func LockMainThread() {} + +// Show opens the system browser at o.URL when o.Browser is set, then blocks +// until the context is cancelled (Ctrl-C), leaving the caller's HTTP server up +// in the meantime. Launching the browser is best-effort; a failure is ignored +// because the URL has already been printed for the user to open by hand. +func Show(ctx context.Context, o Options) error { + if o.Browser { + _ = openInBrowser(o.URL) + } + <-ctx.Done() + return nil +} + +func openInBrowser(url string) error { + var cmd *exec.Cmd + switch runtime.GOOS { + case "darwin": + cmd = exec.Command("open", url) + case "windows": + cmd = exec.Command("rundll32", "url.dll,FileProtocolHandler", url) + default: + cmd = exec.Command("xdg-open", url) + } + return cmd.Start() +} diff --git a/viewer/browser_test.go b/viewer/browser_test.go new file mode 100644 index 0000000..8f9bdc7 --- /dev/null +++ b/viewer/browser_test.go @@ -0,0 +1,37 @@ +//go:build !webview + +package viewer + +import ( + "context" + "testing" + "time" +) + +func TestNativeIsFalseInDefaultBuild(t *testing.T) { + if Native { + t.Fatal("Native should be false without the webview build tag") + } +} + +func TestLockMainThreadIsNoop(t *testing.T) { + // Must not panic; there is no native UI to pin to. + LockMainThread() +} + +func TestShowReturnsWhenContextCancelled(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan error, 1) + // Browser:false so no system browser is launched during the test. + go func() { done <- Show(ctx, Options{URL: "http://127.0.0.1:0", Browser: false}) }() + + cancel() + select { + case err := <-done: + if err != nil { + t.Fatalf("Show returned error: %v", err) + } + case <-time.After(2 * time.Second): + t.Fatal("Show did not return after context cancellation") + } +} diff --git a/viewer/viewer.go b/viewer/viewer.go new file mode 100644 index 0000000..7d16fde --- /dev/null +++ b/viewer/viewer.go @@ -0,0 +1,24 @@ +// Package viewer presents a served site to the user. It has two +// implementations chosen at build time: by default (pure Go, CGO_ENABLED=0) it +// opens the system browser, and with the "webview" build tag (which needs cgo) +// it opens a native window backed by the operating system's WebView, so a +// packed kage binary feels like a standalone app rather than a browser tab. +// +// Both builds expose the same three symbols: Native, LockMainThread, and Show. +// The caller starts an HTTP server, then calls Show on the main goroutine; Show +// blocks until the window is closed (native) or the context is cancelled +// (browser), at which point the caller shuts the server down. +package viewer + +// Options configures a viewer window. +type Options struct { + Title string // window title; the archive's M/Title, falling back to "kage" + URL string // local URL the server is listening on + // Browser, in the default build, opens the system browser. The native build + // ignores it and always shows its own window. + Browser bool +} + +// Native reports whether this build opens a native window (webview tag) or +// falls back to the system browser. Show and LockMainThread are defined in the +// per-build files browser.go and webview.go. diff --git a/viewer/webview.go b/viewer/webview.go new file mode 100644 index 0000000..8d3c2f6 --- /dev/null +++ b/viewer/webview.go @@ -0,0 +1,53 @@ +//go:build webview + +package viewer + +import ( + "context" + "runtime" + + webview "github.com/webview/webview_go" +) + +// Native is true in the webview build: Show opens a real window backed by the +// operating system's WebView (WKWebView on macOS, WebView2 on Windows, +// WebKitGTK on Linux), so a packed kage feels like a standalone app. +// +// This build needs cgo and links the platform WebView, so it is opt-in +// (-tags webview) and kept out of the default CGO_ENABLED=0 release pipeline. +const Native = true + +// LockMainThread pins the calling goroutine to its OS thread. main calls it +// first thing, while the main goroutine is still on the process's initial +// thread, because the macOS WebView must be driven from that thread. +func LockMainThread() { runtime.LockOSThread() } + +// Show opens a native window pointed at o.URL and runs the UI event loop on the +// calling (main) goroutine, blocking until the window is closed. A cancelled +// context terminates the loop too, so Ctrl-C still shuts the viewer down. The +// o.Browser flag is ignored: the whole point of this build is the native window. +func Show(ctx context.Context, o Options) error { + w := webview.New(false) + defer w.Destroy() + + title := o.Title + if title == "" { + title = "kage" + } + w.SetTitle(title) + w.SetSize(1024, 768, webview.HintNone) + w.Navigate(o.URL) + + done := make(chan struct{}) + go func() { + select { + case <-ctx.Done(): + w.Dispatch(func() { w.Terminate() }) + case <-done: + } + }() + + w.Run() + close(done) + return nil +}