5b7f7d9f31
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.
109 lines
3.5 KiB
Go
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 ""
|
|
}
|