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