Add an optional native-window viewer behind the webview tag

A packed binary opened the system browser, so it felt like a tab, not
an app. Build with -tags webview (cgo) and the viewer instead opens the
site in its own window backed by the OS WebView: WKWebView on macOS,
WebView2 on Windows, WebKitGTK on Linux.

The viewer package picks an implementation at build time. The default
file opens the browser and keeps the build pure Go, so CGO_ENABLED=0 and
the release pipeline are untouched. The webview file links the platform
WebView and runs its event loop on the main goroutine, which main now
pins with LockOSThread before anything else, since macOS requires UI on
the initial thread. Both kage open and the embedded viewer serve over
HTTP in a goroutine and hand the URL to the viewer, then tear the server
down when the window closes or Ctrl-C cancels.

The window title comes from the archive's M/Title. OpenInBrowser moves
out of pack into the viewer package, its only caller.
This commit is contained in:
Duc-Tam Nguyen
2026-06-14 21:07:53 +07:00
parent 26dabd03bf
commit 5b7f7d9f31
15 changed files with 247 additions and 36 deletions
+6
View File
@@ -17,6 +17,12 @@ All notable changes to kage are recorded here. The format follows
`--no-compress`.
- `kage open <file.zim>` 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
+7 -1
View File
@@ -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)
+9
View File
@@ -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).
+11 -8
View File
@@ -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
+27 -10
View File
@@ -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 ""
}
+6
View File
@@ -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))
+19
View File
@@ -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
+4
View File
@@ -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.
+1
View File
@@ -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
)
+2
View File
@@ -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=
-17
View File
@@ -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()
}
+41
View File
@@ -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()
}
+37
View File
@@ -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")
}
}
+24
View File
@@ -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.
+53
View File
@@ -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
}