Files
kage/cli/root.go
Duc-Tam Nguyen 5b7f7d9f31 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.
2026-06-14 21:07:53 +07:00

109 lines
3.5 KiB
Go

// Package cli wires kage's command surface: the cobra tree, the global flags,
// and the fang-rendered help and errors. The actual work lives in the clone,
// browser, sanitize, asset, and urlx packages; this layer only parses flags and
// prints progress.
package cli
import (
"context"
"fmt"
"io"
"net"
"net/http"
"os"
"github.com/charmbracelet/fang"
"github.com/spf13/cobra"
"github.com/tamnd/kage/pack"
"github.com/tamnd/kage/viewer"
"github.com/tamnd/kage/zim"
)
// Execute builds the root command and runs it through fang. main passes the
// signal-aware context so Ctrl-C cancels the in-flight clone and flushes resume
// state. It returns the process exit code.
func Execute(ctx context.Context) int {
// A kage binary with a ZIM appended runs as an offline viewer for that site,
// ignoring its arguments. A normal build has no trailer and falls through.
if ra, size, ok := pack.Embedded(); ok {
return runEmbeddedViewer(ctx, ra, size)
}
root := newRoot()
opts := []fang.Option{
fang.WithVersion(Version),
}
if err := fang.Execute(ctx, root, opts...); err != nil {
return 1
}
return 0
}
// newRoot assembles the command tree.
func newRoot() *cobra.Command {
root := &cobra.Command{
Use: "kage",
Short: "Clone any website for offline viewing, with the JavaScript stripped out",
Long: "kage (影, \"shadow\") renders each page in headless Chrome, snapshots the\n" +
"final DOM, removes every script and event handler, and localises the CSS,\n" +
"images, and fonts so the saved copy looks like the live site but runs no\n" +
"code. The result is a plain folder you can open straight from disk.",
Version: fmt.Sprintf("%s (commit %s, built %s)", Version, Commit, Date),
SilenceUsage: true,
SilenceErrors: true,
}
root.AddCommand(newCloneCmd())
root.AddCommand(newServeCmd())
root.AddCommand(newPackCmd())
root.AddCommand(newOpenCmd())
return root
}
// runEmbeddedViewer serves the ZIM appended to this executable on an ephemeral
// 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 {
fmt.Fprintln(os.Stderr, "kage: corrupt embedded archive:", err)
return 1
}
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
fmt.Fprintln(os.Stderr, "kage: cannot start viewer:", err)
return 1
}
url := "http://" + ln.Addr().String()
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)}
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 ""
}